diff --git a/Cargo.lock b/Cargo.lock
index 3f13fd2bea..b364e0dca4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2434,6 +2434,15 @@ dependencies = [
"syn 2.0.32",
]
+[[package]]
+name = "dapp-staking-v3-runtime-api"
+version = "0.0.1-alpha"
+dependencies = [
+ "astar-primitives",
+ "pallet-dapp-staking-v3",
+ "sp-api",
+]
+
[[package]]
name = "dapps-staking-chain-extension-types"
version = "1.1.0"
@@ -6010,6 +6019,7 @@ version = "5.25.0"
dependencies = [
"array-bytes 6.1.0",
"astar-primitives",
+ "dapp-staking-v3-runtime-api",
"fp-rpc",
"fp-self-contained",
"frame-benchmarking",
diff --git a/Cargo.toml b/Cargo.toml
index 5d2e846b37..47b112e254 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -284,6 +284,8 @@ pallet-inflation = { path = "./pallets/inflation", default-features = false }
pallet-dynamic-evm-base-fee = { path = "./pallets/dynamic-evm-base-fee", default-features = false }
pallet-unified-accounts = { path = "./pallets/unified-accounts", default-features = false }
+dapp-staking-v3-runtime-api = { path = "./pallets/dapp-staking-v3/rpc/runtime-api", default-features = false }
+
astar-primitives = { path = "./primitives", default-features = false }
astar-test-utils = { path = "./tests/utils", default-features = false }
diff --git a/pallets/dapp-staking-v3/README.md b/pallets/dapp-staking-v3/README.md
index 72accbfc36..c8de9b0036 100644
--- a/pallets/dapp-staking-v3/README.md
+++ b/pallets/dapp-staking-v3/README.md
@@ -27,7 +27,7 @@ Each period consists of two subperiods:
Each period is denoted by a number, which increments each time a new period begins.
Period beginning is marked by the `voting` subperiod, after which follows the `build&earn` period.
-Stakes are **only** valid throughout a period. When new period starts, all stakes are reset to **zero**. This helps prevent projects remaining staked due to intertia of stakers, and makes for a more dynamic staking system.
+Stakes are **only** valid throughout a period. When new period starts, all stakes are reset to **zero**. This helps prevent projects remaining staked due to intertia of stakers, and makes for a more dynamic staking system. Staker doesn't need to do anything for this to happen, it is automatic.
Even though stakes are reset, locks (or freezes) of tokens remain.
@@ -39,7 +39,7 @@ Projects participating in dApp staking are expected to market themselves to (re)
Stakers must assess whether the project they want to stake on brings value to the ecosystem, and then `vote` for it.
Casting a vote, or staking, during the `Voting` subperiod makes the staker eligible for bonus rewards. so they are encouraged to participate.
-`Voting` subperiod length is expressed in _standard_ era lengths, even though the entire voting period is treated as a single _voting era_.
+`Voting` subperiod length is expressed in _standard_ era lengths, even though the entire voting subperiod is treated as a single _voting era_.
E.g. if `voting` subperiod lasts for **5 eras**, and each era lasts for **100** blocks, total length of the `voting` subperiod will be **500** blocks.
* Block 1, Era 1 starts, Period 1 starts, `Voting` subperiod starts
* Block 501, Era 2 starts, Period 1 continues, `Build&Earn` subperiod starts
@@ -173,11 +173,70 @@ Rewards don't remain available forever, and if not claimed within some time peri
Rewards are calculated using a simple formula: `staker_reward_pool * staker_staked_amount / total_staked_amount`.
-#### Claim Bonus Reward
+#### Claiming Bonus Reward
-If staker staked on a dApp during the voting period, and didn't reduce their staked amount below what was staked at the end of the voting period, this makes them eligible for the bonus reward.
+If staker staked on a dApp during the voting subperiod, and didn't reduce their staked amount below what was staked at the end of the voting subperiod, this makes them eligible for the bonus reward.
Bonus rewards need to be claimed per contract, unlike staker rewards.
-Bonus reward is calculated using a simple formula: `bonus_reward_pool * staker_voting_period_stake / total_voting_period_stake`.
+Bonus reward is calculated using a simple formula: `bonus_reward_pool * staker_voting_subperiod_stake / total_voting_subperiod_stake`.
+#### Handling Expired Entries
+
+There is a limit to how much contracts can a staker stake on at once.
+Or to be more precise, for how many contract a database entry can exist at once.
+
+It's possible that stakers get themselves into a situation where some number of expired database entries associated to
+their account has accumulated. In that case, it's required to call a special extrinsic to cleanup these expired entries.
+
+### Developers
+
+Main thing for developers to do is develop a good product & attract stakers to stake on them.
+
+#### Claiming dApp Reward
+
+If at the end of an build&earn subperiod era dApp has high enough score to enter a tier, it gets rewards assigned to it.
+Rewards aren't paid out automatically but must be claimed instead, similar to staker & bonus rewards.
+
+When dApp reward is being claimed, both smart contract & claim era must be specified.
+
+dApp reward is calculated based on the tier in which ended. All dApps that end up in one tier will get the exact same reward.
+
+### Tier System
+
+At the end of each build&earn subperiod era, dApps are evaluated using a simple metric - total value staked on them.
+Based on this metric, they are sorted, and assigned to tiers.
+
+There is a limited number of tiers, and each tier has a limited capacity of slots.
+Each tier also has a _threshold_ which a dApp must satisfy in order to enter it.
+
+Better tiers bring bigger rewards, so dApps are encouraged to compete for higher tiers and attract staker's support.
+For each tier, the reward pool and capacity are fixed. Each dApp within a tier always gets the same amount of reward.
+Even if tier capacity hasn't been fully taken, rewards are paid out as if they were.
+
+For example, if tier 1 has capacity for 10 dApps, and reward pool is **500 ASTR**, it means that each dApp that ends up
+in this tier will earn **50 ASTR**. Even if only 3 dApps manage to enter this tier, they will still earn each **50 ASTR**.
+The rest, **350 ASTR** in this case, won't be minted (or will be _burned_ if the reader prefers such explanation).
+
+If there are more dApps eligible for a tier than there is capacity, the dApps with the higher score get the advantage.
+dApps which missed out get priority for entry into the next lower tier (if there still is any).
+
+In the case a dApp doesn't satisfy the entry threshold for any tier, even though there is still capacity, the dApp will simply
+be left out of tiers and won't earn **any** reward.
+
+In a special and unlikely case that two or more dApps have the exact same score and satisfy tier entry threshold, but there isn't enough
+leftover tier capacity to accomodate them all, this is considered _undefined_ behavior. Some of the dApps will manage to enter the tier, while
+others will be left out. There is no strict rule which defines this behavior - instead dApps are encouraged to ensure their tier entry by
+having a larger stake than the other dApp(s).
+
+### Reward Expiry
+
+Unclaimed rewards aren't kept indefinitely in storage. Eventually, they expire.
+Stakers & developers should make sure they claim those rewards before this happens.
+
+In case they don't, they will simply miss on the earnings.
+
+However, this should not be a problem given how the system is designed.
+There is no longer _stake&forger_ - users are expected to revisit dApp staking at least at the
+beginning of each new period to pick out old or new dApps on which to stake on.
+If they don't do that, they miss out on the bonus reward & won't earn staker rewards.
\ No newline at end of file
diff --git a/pallets/dapp-staking-v3/rpc/runtime-api/Cargo.toml b/pallets/dapp-staking-v3/rpc/runtime-api/Cargo.toml
new file mode 100644
index 0000000000..559d5f299b
--- /dev/null
+++ b/pallets/dapp-staking-v3/rpc/runtime-api/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "dapp-staking-v3-runtime-api"
+version = "0.0.1-alpha"
+description = "dApp Staking v3 runtime API"
+authors.workspace = true
+edition.workspace = true
+homepage.workspace = true
+repository.workspace = true
+
+[package.metadata.docs.rs]
+targets = ["x86_64-unknown-linux-gnu"]
+
+[dependencies]
+sp-api = { workspace = true }
+
+astar-primitives = { workspace = true }
+pallet-dapp-staking-v3 = { workspace = true }
+
+[features]
+default = ["std"]
+std = [
+ "sp-api/std",
+ "pallet-dapp-staking-v3/std",
+ "astar-primitives/std",
+]
diff --git a/pallets/dapp-staking-v3/rpc/runtime-api/src/lib.rs b/pallets/dapp-staking-v3/rpc/runtime-api/src/lib.rs
new file mode 100644
index 0000000000..dde32bf695
--- /dev/null
+++ b/pallets/dapp-staking-v3/rpc/runtime-api/src/lib.rs
@@ -0,0 +1,40 @@
+// This file is part of Astar.
+
+// Copyright (C) 2019-2023 Stake Technologies Pte.Ltd.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// Astar is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+// Astar is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+// You should have received a copy of the GNU General Public License
+// along with Astar. If not, see .
+
+#![cfg_attr(not(feature = "std"), no_std)]
+
+use astar_primitives::BlockNumber;
+use pallet_dapp_staking_v3::EraNumber;
+
+sp_api::decl_runtime_apis! {
+
+ /// dApp Staking Api.
+ ///
+ /// Used to provide information otherwise not available via RPC.
+ pub trait DappStakingApi {
+
+ /// For how many standard era lengths does the voting subperiod last.
+ fn eras_per_voting_subperiod() -> EraNumber;
+
+ /// How many standard eras are there in the build&earn subperiod.
+ fn eras_per_build_and_earn_subperiod() -> EraNumber;
+
+ /// How many blocks are there per standard era.
+ fn blocks_per_era() -> BlockNumber;
+ }
+}
diff --git a/pallets/dapp-staking-v3/src/benchmarking/mod.rs b/pallets/dapp-staking-v3/src/benchmarking/mod.rs
index 4f9cbafdb9..3eab3c6f85 100644
--- a/pallets/dapp-staking-v3/src/benchmarking/mod.rs
+++ b/pallets/dapp-staking-v3/src/benchmarking/mod.rs
@@ -902,6 +902,44 @@ mod benchmarks {
}
}
+ #[benchmark]
+ fn on_idle_cleanup() {
+ // Prepare init config (protocol state, tier params & config, etc.)
+ initial_config::();
+
+ // Advance enough periods to trigger the cleanup
+ let retention_period = T::RewardRetentionInPeriods::get();
+ advance_to_period::(
+ ActiveProtocolState::::get().period_number() + retention_period + 2,
+ );
+
+ let first_era_span_index = 0;
+ assert!(
+ EraRewards::::contains_key(first_era_span_index),
+ "Sanity check - era reward span entry must exist."
+ );
+ let first_period = 1;
+ assert!(
+ PeriodEnd::::contains_key(first_period),
+ "Sanity check - period end info must exist."
+ );
+ let block_number = System::::block_number();
+
+ #[block]
+ {
+ DappStaking::::on_idle(block_number, Weight::MAX);
+ }
+
+ assert!(
+ !EraRewards::::contains_key(first_era_span_index),
+ "Entry should have been cleaned up."
+ );
+ assert!(
+ !PeriodEnd::::contains_key(first_period),
+ "Period end info should have been cleaned up."
+ );
+ }
+
impl_benchmark_test_suite!(
Pallet,
crate::benchmarking::tests::new_test_ext(),
diff --git a/pallets/dapp-staking-v3/src/lib.rs b/pallets/dapp-staking-v3/src/lib.rs
index 775b2b37b7..7709515fad 100644
--- a/pallets/dapp-staking-v3/src/lib.rs
+++ b/pallets/dapp-staking-v3/src/lib.rs
@@ -446,6 +446,10 @@ pub mod pallet {
pub type DAppTiers =
StorageMap<_, Twox64Concat, EraNumber, DAppTierRewardsFor, OptionQuery>;
+ /// History cleanup marker - holds information about which DB entries should be cleaned up next, when applicable.
+ #[pallet::storage]
+ pub type HistoryCleanupMarker = StorageValue<_, CleanupMarker, ValueQuery>;
+
#[pallet::genesis_config]
#[derive(frame_support::DefaultNoBound)]
pub struct GenesisConfig {
@@ -523,6 +527,10 @@ pub mod pallet {
fn on_initialize(now: BlockNumber) -> Weight {
Self::era_and_period_handler(now, TierAssignment::Real)
}
+
+ fn on_idle(_block: BlockNumberFor, remaining_weight: Weight) -> Weight {
+ Self::expired_entry_cleanup(&remaining_weight)
+ }
}
/// A reason for freezing funds.
@@ -582,7 +590,6 @@ pub mod pallet {
id: dapp_id,
state: DAppState::Registered,
reward_destination: None,
- tier_label: None,
},
);
@@ -1133,9 +1140,8 @@ pub mod pallet {
let earliest_staked_era = ledger
.earliest_staked_era()
.ok_or(Error::::InternalClaimStakerError)?;
- let era_rewards =
- EraRewards::::get(Self::era_reward_span_index(earliest_staked_era))
- .ok_or(Error::::NoClaimableRewards)?;
+ let era_rewards = EraRewards::::get(Self::era_reward_span_index(earliest_staked_era))
+ .ok_or(Error::::NoClaimableRewards)?;
// The last era for which we can theoretically claim rewards.
// And indicator if we know the period's ending era.
@@ -1251,6 +1257,9 @@ pub mod pallet {
// Cleanup entry since the reward has been claimed
StakerInfo::::remove(&account, &smart_contract);
+ Ledger::::mutate(&account, |ledger| {
+ ledger.contract_stake_count.saturating_dec();
+ });
Self::deposit_event(Event::::BonusReward {
account: account.clone(),
@@ -1441,6 +1450,11 @@ pub mod pallet {
.into())
}
+ // TODO: this call should be removed prior to mainnet launch.
+ // It's super useful for testing purposes, but even though force is used in this pallet & works well,
+ // it won't apply to the inflation recalculation logic - which is wrong.
+ // Probably for this call to make sense, an outside logic should handle both inflation & dApp staking state changes.
+
/// Used to force a change of era or subperiod.
/// The effect isn't immediate but will happen on the next block.
///
@@ -1850,5 +1864,79 @@ pub mod pallet {
consumed_weight
}
+
+ /// Attempt to cleanup some expired entries, if enough remaining weight & applicable entries exist.
+ ///
+ /// Returns consumed weight.
+ fn expired_entry_cleanup(remaining_weight: &Weight) -> Weight {
+ // Need to be able to process full pass
+ if remaining_weight.any_lt(T::WeightInfo::on_idle_cleanup()) {
+ return Weight::zero();
+ }
+
+ // Get the cleanup marker
+ let mut cleanup_marker = HistoryCleanupMarker::::get();
+
+ // Whitelisted storage, no need to account for the read.
+ let protocol_state = ActiveProtocolState::::get();
+ let latest_expired_period = match protocol_state
+ .period_number()
+ .checked_sub(T::RewardRetentionInPeriods::get().saturating_add(1))
+ {
+ Some(latest_expired_period) => latest_expired_period,
+ None => {
+ // Protocol hasn't advanced enough to have any expired entries.
+ return T::WeightInfo::on_idle_cleanup();
+ }
+ };
+
+ // Get the oldest valid era - any era before it is safe to be cleaned up.
+ let oldest_valid_era = match PeriodEnd::::get(latest_expired_period) {
+ Some(period_end_info) => period_end_info.final_era.saturating_add(1),
+ None => {
+ // Can happen if it's period 0 or if the entry has already been cleaned up.
+ return T::WeightInfo::on_idle_cleanup();
+ }
+ };
+
+ // Attempt to cleanup one expired `EraRewards` entry.
+ if let Some(era_reward) = EraRewards::::get(cleanup_marker.era_reward_index) {
+ // If oldest valid era comes AFTER this span, it's safe to delete it.
+ if era_reward.last_era() < oldest_valid_era {
+ EraRewards::::remove(cleanup_marker.era_reward_index);
+ cleanup_marker
+ .era_reward_index
+ .saturating_accrue(T::EraRewardSpanLength::get());
+ }
+ } else {
+ // Should never happen, but if it does, log an error and move on.
+ log::error!(
+ target: LOG_TARGET,
+ "Era rewards span for era {} is missing, but cleanup marker is set.",
+ cleanup_marker.era_reward_index
+ );
+ }
+
+ // Attempt to cleanup one expired `DAppTiers` entry.
+ if cleanup_marker.dapp_tiers_index < oldest_valid_era {
+ DAppTiers::::remove(cleanup_marker.dapp_tiers_index);
+ cleanup_marker.dapp_tiers_index.saturating_inc();
+ }
+
+ // One extra grace period before we cleanup period end info.
+ // This so we can always read the `final_era` of that period.
+ if let Some(period_end_cleanup) = latest_expired_period.checked_sub(1) {
+ PeriodEnd::::remove(period_end_cleanup);
+ }
+
+ // Store the updated cleanup marker
+ HistoryCleanupMarker::::put(cleanup_marker);
+
+ // We could try & cleanup more entries, but since it's not a critical operation and can happen whenever,
+ // we opt for the simpler solution where only 1 entry per block is cleaned up.
+ // It can be changed though.
+
+ T::WeightInfo::on_idle_cleanup()
+ }
}
}
diff --git a/pallets/dapp-staking-v3/src/lib.rs.orig b/pallets/dapp-staking-v3/src/lib.rs.orig
new file mode 100644
index 0000000000..c2d4787162
--- /dev/null
+++ b/pallets/dapp-staking-v3/src/lib.rs.orig
@@ -0,0 +1,1946 @@
+// This file is part of Astar.
+
+// Copyright (C) 2019-2023 Stake Technologies Pte.Ltd.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// Astar is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+// Astar is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+// You should have received a copy of the GNU General Public License
+// along with Astar. If not, see .
+
+//! # dApp Staking v3 Pallet
+//!
+//! For detailed high level documentation, please refer to the attached README.md file.
+//! The crate level docs will cover overal pallet structure & implementation details.
+//!
+//! ## Overview
+//!
+//! Pallet that implements the dApp staking v3 protocol.
+//! It covers everything from locking, staking, tier configuration & assignment, reward calculation & payout.
+//!
+//! The `types` module contains all of the types used to implement the pallet.
+//! All of these _types_ are exentisvely tested in their dedicated `test_types` module.
+//!
+//! Rest of the pallet logic is concenrated in the lib.rs file.
+//! This logic is tested in the `tests` module, with the help of extensive `testing_utils`.
+//!
+
+#![cfg_attr(not(feature = "std"), no_std)]
+
+use frame_support::{
+ pallet_prelude::*,
+ traits::{
+ fungible::{Inspect as FunInspect, MutateFreeze as FunMutateFreeze},
+ StorageVersion,
+ },
+ weights::Weight,
+};
+use frame_system::pallet_prelude::*;
+use sp_runtime::{
+ traits::{BadOrigin, One, Saturating, UniqueSaturatedInto, Zero},
+ Perbill, Permill,
+};
+pub use sp_std::vec::Vec;
+
+use astar_primitives::{
+ dapp_staking::{CycleConfiguration, SmartContractHandle, StakingRewardHandler},
+ Balance, BlockNumber,
+};
+
+pub use pallet::*;
+
+#[cfg(test)]
+mod test;
+
+#[cfg(feature = "runtime-benchmarks")]
+mod benchmarking;
+
+mod types;
+pub use types::*;
+
+pub mod weights;
+pub use weights::WeightInfo;
+
+const LOG_TARGET: &str = "dapp-staking";
+
+/// Helper enum for benchmarking.
+pub(crate) enum TierAssignment {
+ /// Real tier assignment calculation should be done.
+ Real,
+ /// Dummy tier assignment calculation should be done, e.g. default value should be returned.
+ #[cfg(feature = "runtime-benchmarks")]
+ Dummy,
+}
+
+#[doc = include_str!("../README.md")]
+#[frame_support::pallet]
+pub mod pallet {
+ use super::*;
+
+ /// The current storage version.
+ const STORAGE_VERSION: StorageVersion = StorageVersion::new(5);
+
+ #[pallet::pallet]
+ #[pallet::storage_version(STORAGE_VERSION)]
+ pub struct Pallet(_);
+
+ #[cfg(feature = "runtime-benchmarks")]
+ pub trait BenchmarkHelper {
+ fn get_smart_contract(id: u32) -> SmartContract;
+
+ fn set_balance(account: &AccountId, balance: Balance);
+ }
+
+ #[pallet::config]
+ pub trait Config: frame_system::Config {
+ /// The overarching event type.
+ type RuntimeEvent: From>
+ + IsType<::RuntimeEvent>
+ + TryInto>;
+
+ /// The overarching freeze reason.
+ type RuntimeFreezeReason: From;
+
+ /// Currency used for staking.
+ /// Reference:
+ type Currency: FunMutateFreeze<
+ Self::AccountId,
+ Id = Self::RuntimeFreezeReason,
+ Balance = Balance,
+ >;
+
+ /// Describes smart contract in the context required by dApp staking.
+ type SmartContract: Parameter
+ + Member
+ + MaxEncodedLen
+ + SmartContractHandle;
+
+ /// Privileged origin for managing dApp staking pallet.
+ type ManagerOrigin: EnsureOrigin<::RuntimeOrigin>;
+
+ /// Used to provide price information about the native token.
+ type NativePriceProvider: PriceProvider;
+
+ /// Used to handle reward payouts & reward pool amount fetching.
+ type StakingRewardHandler: StakingRewardHandler;
+
+ /// Describes era length, subperiods & period length, as well as cycle length.
+ type CycleConfiguration: CycleConfiguration;
+
+ /// Maximum length of a single era reward span length entry.
+ #[pallet::constant]
+ type EraRewardSpanLength: Get;
+
+ /// Number of periods for which we keep rewards available for claiming.
+ /// After that period, they are no longer claimable.
+ #[pallet::constant]
+ type RewardRetentionInPeriods: Get;
+
+ /// Maximum number of contracts that can be integrated into dApp staking at once.
+ #[pallet::constant]
+ type MaxNumberOfContracts: Get;
+
+ /// Maximum number of unlocking chunks that can exist per account at a time.
+ #[pallet::constant]
+ type MaxUnlockingChunks: Get;
+
+ /// Minimum amount an account has to lock in dApp staking in order to participate.
+ #[pallet::constant]
+ type MinimumLockedAmount: Get;
+
+ /// Number of standard eras that need to pass before unlocking chunk can be claimed.
+ /// Even though it's expressed in 'eras', it's actually measured in number of blocks.
+ #[pallet::constant]
+ type UnlockingPeriod: Get;
+
+ /// Maximum amount of stake contract entries an account is allowed to have at once.
+ #[pallet::constant]
+ type MaxNumberOfStakedContracts: Get;
+
+ /// Minimum amount staker can stake on a contract.
+ #[pallet::constant]
+ type MinimumStakeAmount: Get;
+
+ /// Number of different tiers.
+ #[pallet::constant]
+ type NumberOfTiers: Get;
+
+ /// Weight info for various calls & operations in the pallet.
+ type WeightInfo: WeightInfo;
+
+ /// Helper trait for benchmarks.
+ #[cfg(feature = "runtime-benchmarks")]
+ type BenchmarkHelper: BenchmarkHelper;
+ }
+
+ #[pallet::event]
+ #[pallet::generate_deposit(pub(crate) fn deposit_event)]
+ pub enum Event {
+ /// Maintenance mode has been either enabled or disabled.
+ MaintenanceMode { enabled: bool },
+ /// New era has started.
+ NewEra { era: EraNumber },
+ /// New subperiod has started.
+ NewSubperiod {
+ subperiod: Subperiod,
+ number: PeriodNumber,
+ },
+ /// A smart contract has been registered for dApp staking
+ DAppRegistered {
+ owner: T::AccountId,
+ smart_contract: T::SmartContract,
+ dapp_id: DAppId,
+ },
+ /// dApp reward destination has been updated.
+ DAppRewardDestinationUpdated {
+ smart_contract: T::SmartContract,
+ beneficiary: Option,
+ },
+ /// dApp owner has been changed.
+ DAppOwnerChanged {
+ smart_contract: T::SmartContract,
+ new_owner: T::AccountId,
+ },
+ /// dApp has been unregistered
+ DAppUnregistered {
+ smart_contract: T::SmartContract,
+ era: EraNumber,
+ },
+ /// Account has locked some amount into dApp staking.
+ Locked {
+ account: T::AccountId,
+ amount: Balance,
+ },
+ /// Account has started the unlocking process for some amount.
+ Unlocking {
+ account: T::AccountId,
+ amount: Balance,
+ },
+ /// Account has claimed unlocked amount, removing the lock from it.
+ ClaimedUnlocked {
+ account: T::AccountId,
+ amount: Balance,
+ },
+ /// Account has relocked all of the unlocking chunks.
+ Relock {
+ account: T::AccountId,
+ amount: Balance,
+ },
+ /// Account has staked some amount on a smart contract.
+ Stake {
+ account: T::AccountId,
+ smart_contract: T::SmartContract,
+ amount: Balance,
+ },
+ /// Account has unstaked some amount from a smart contract.
+ Unstake {
+ account: T::AccountId,
+ smart_contract: T::SmartContract,
+ amount: Balance,
+ },
+ /// Account has claimed some stake rewards.
+ Reward {
+ account: T::AccountId,
+ era: EraNumber,
+ amount: Balance,
+ },
+ /// Bonus reward has been paid out to a loyal staker.
+ BonusReward {
+ account: T::AccountId,
+ smart_contract: T::SmartContract,
+ period: PeriodNumber,
+ amount: Balance,
+ },
+ /// dApp reward has been paid out to a beneficiary.
+ DAppReward {
+ beneficiary: T::AccountId,
+ smart_contract: T::SmartContract,
+ tier_id: TierId,
+ era: EraNumber,
+ amount: Balance,
+ },
+ /// Account has unstaked funds from an unregistered smart contract
+ UnstakeFromUnregistered {
+ account: T::AccountId,
+ smart_contract: T::SmartContract,
+ amount: Balance,
+ },
+ /// Some expired stake entries have been removed from storage.
+ ExpiredEntriesRemoved { account: T::AccountId, count: u16 },
+ /// Privileged origin has forced a new era and possibly a subperiod to start from next block.
+ Force { forcing_type: ForcingType },
+ }
+
+ #[pallet::error]
+ pub enum Error {
+ /// Pallet is disabled/in maintenance mode.
+ Disabled,
+ /// Smart contract already exists within dApp staking protocol.
+ ContractAlreadyExists,
+ /// Maximum number of smart contracts has been reached.
+ ExceededMaxNumberOfContracts,
+ /// Not possible to assign a new dApp Id.
+ /// This should never happen since current type can support up to 65536 - 1 unique dApps.
+ NewDAppIdUnavailable,
+ /// Specified smart contract does not exist in dApp staking.
+ ContractNotFound,
+ /// Call origin is not dApp owner.
+ OriginNotOwner,
+ /// dApp is part of dApp staking but isn't active anymore.
+ NotOperatedDApp,
+ /// Performing locking or staking with 0 amount.
+ ZeroAmount,
+ /// Total locked amount for staker is below minimum threshold.
+ LockedAmountBelowThreshold,
+ /// Cannot add additional unlocking chunks due to capacity limit.
+ TooManyUnlockingChunks,
+ /// Remaining stake prevents entire balance of starting the unlocking process.
+ RemainingStakePreventsFullUnlock,
+ /// There are no eligible unlocked chunks to claim. This can happen either if no eligible chunks exist, or if user has no chunks at all.
+ NoUnlockedChunksToClaim,
+ /// There are no unlocking chunks available to relock.
+ NoUnlockingChunks,
+ /// The amount being staked is too large compared to what's available for staking.
+ UnavailableStakeFunds,
+ /// There are unclaimed rewards remaining from past eras or periods. They should be claimed before attempting any stake modification again.
+ UnclaimedRewards,
+ /// An unexpected error occured while trying to stake.
+ InternalStakeError,
+ /// Total staked amount on contract is below the minimum required value.
+ InsufficientStakeAmount,
+ /// Stake operation is rejected since period ends in the next era.
+ PeriodEndsInNextEra,
+ /// Unstaking is rejected since the period in which past stake was active has passed.
+ UnstakeFromPastPeriod,
+ /// Unstake amount is greater than the staked amount.
+ UnstakeAmountTooLarge,
+ /// Account has no staking information for the contract.
+ NoStakingInfo,
+ /// An unexpected error occured while trying to unstake.
+ InternalUnstakeError,
+ /// Rewards are no longer claimable since they are too old.
+ RewardExpired,
+ /// Reward payout has failed due to an unexpected reason.
+ RewardPayoutFailed,
+ /// There are no claimable rewards.
+ NoClaimableRewards,
+ /// An unexpected error occured while trying to claim staker rewards.
+ InternalClaimStakerError,
+ /// Account is has no eligible stake amount for bonus reward.
+ NotEligibleForBonusReward,
+ /// An unexpected error occured while trying to claim bonus reward.
+ InternalClaimBonusError,
+ /// Claim era is invalid - it must be in history, and rewards must exist for it.
+ InvalidClaimEra,
+ /// No dApp tier info exists for the specified era. This can be because era has expired
+ /// or because during the specified era there were no eligible rewards or protocol wasn't active.
+ NoDAppTierInfo,
+ /// dApp reward has already been claimed for this era.
+ DAppRewardAlreadyClaimed,
+ /// An unexpected error occured while trying to claim dApp reward.
+ InternalClaimDAppError,
+ /// Contract is still active, not unregistered.
+ ContractStillActive,
+ /// There are too many contract stake entries for the account. This can be cleaned up by either unstaking or cleaning expired entries.
+ TooManyStakedContracts,
+ /// There are no expired entries to cleanup for the account.
+ NoExpiredEntries,
+ }
+
+ /// General information about dApp staking protocol state.
+ #[pallet::storage]
+ #[pallet::whitelist_storage]
+ pub type ActiveProtocolState = StorageValue<_, ProtocolState, ValueQuery>;
+
+ /// Counter for unique dApp identifiers.
+ #[pallet::storage]
+ pub type NextDAppId = StorageValue<_, DAppId, ValueQuery>;
+
+ /// Map of all dApps integrated into dApp staking protocol.
+ ///
+ /// Even though dApp is integrated, it does not mean it's still actively participating in dApp staking.
+ /// It might have been unregistered at some point in history.
+ #[pallet::storage]
+ pub type IntegratedDApps = CountedStorageMap<
+ Hasher = Blake2_128Concat,
+ Key = T::SmartContract,
+ Value = DAppInfo,
+ QueryKind = OptionQuery,
+ MaxValues = ConstU32<{ DAppId::MAX as u32 }>,
+ >;
+
+ /// General locked/staked information for each account.
+ #[pallet::storage]
+ pub type Ledger =
+ StorageMap<_, Blake2_128Concat, T::AccountId, AccountLedgerFor, ValueQuery>;
+
+ /// Information about how much each staker has staked for each smart contract in some period.
+ #[pallet::storage]
+ pub type StakerInfo = StorageDoubleMap<
+ _,
+ Blake2_128Concat,
+ T::AccountId,
+ Blake2_128Concat,
+ T::SmartContract,
+ SingularStakingInfo,
+ OptionQuery,
+ >;
+
+ /// Information about how much has been staked on a smart contract in some era or period.
+ #[pallet::storage]
+ pub type ContractStake = StorageMap<
+ Hasher = Twox64Concat,
+ Key = DAppId,
+ Value = ContractStakeAmount,
+ QueryKind = ValueQuery,
+ MaxValues = ConstU32<{ DAppId::MAX as u32 }>,
+ >;
+
+ /// General information about the current era.
+ #[pallet::storage]
+ pub type CurrentEraInfo = StorageValue<_, EraInfo, ValueQuery>;
+
+ /// Information about rewards for each era.
+ ///
+ /// Since each entry is a 'span', covering up to `T::EraRewardSpanLength` entries, only certain era value keys can exist in storage.
+ /// For the sake of simplicity, valid `era` keys are calculated as:
+ ///
+ /// era_key = era - (era % T::EraRewardSpanLength)
+ ///
+ /// This means that e.g. in case `EraRewardSpanLength = 8`, only era values 0, 8, 16, 24, etc. can exist in storage.
+ /// Eras 1-7 will be stored in the same entry as era 0, eras 9-15 will be stored in the same entry as era 8, etc.
+ #[pallet::storage]
+ pub type EraRewards =
+ StorageMap<_, Twox64Concat, EraNumber, EraRewardSpan, OptionQuery>;
+
+ /// Information about period's end.
+ #[pallet::storage]
+ pub type PeriodEnd =
+ StorageMap<_, Twox64Concat, PeriodNumber, PeriodEndInfo, OptionQuery>;
+
+ /// Static tier parameters used to calculate tier configuration.
+ #[pallet::storage]
+ pub type StaticTierParams =
+ StorageValue<_, TierParameters, ValueQuery>;
+
+ /// Tier configuration to be used during the newly started period
+ #[pallet::storage]
+ pub type NextTierConfig =
+ StorageValue<_, TiersConfiguration, ValueQuery>;
+
+ /// Tier configuration user for current & preceding eras.
+ #[pallet::storage]
+ pub type TierConfig =
+ StorageValue<_, TiersConfiguration, ValueQuery>;
+
+ /// Information about which tier a dApp belonged to in a specific era.
+ #[pallet::storage]
+ pub type DAppTiers =
+ StorageMap<_, Twox64Concat, EraNumber, DAppTierRewardsFor, OptionQuery>;
+
+ /// History cleanup marker - holds information about which DB entries should be cleaned up next, when applicable.
+ #[pallet::storage]
+ pub type HistoryCleanupMarker = StorageValue<_, CleanupMarker, ValueQuery>;
+
+ #[pallet::genesis_config]
+ #[derive(frame_support::DefaultNoBound)]
+ pub struct GenesisConfig {
+ pub reward_portion: Vec,
+ pub slot_distribution: Vec,
+ pub tier_thresholds: Vec,
+ pub slots_per_tier: Vec,
+ }
+
+ #[pallet::genesis_build]
+ impl GenesisBuild for GenesisConfig {
+ fn build(&self) {
+ // Prepare tier parameters & verify their correctness
+ let tier_params = TierParameters:: {
+ reward_portion: BoundedVec::::try_from(
+ self.reward_portion.clone(),
+ )
+ .expect("Invalid number of reward portions provided."),
+ slot_distribution: BoundedVec::::try_from(
+ self.slot_distribution.clone(),
+ )
+ .expect("Invalid number of slot distributions provided."),
+ tier_thresholds: BoundedVec::::try_from(
+ self.tier_thresholds.clone(),
+ )
+ .expect("Invalid number of tier thresholds provided."),
+ };
+ assert!(
+ tier_params.is_valid(),
+ "Invalid tier parameters values provided."
+ );
+
+ // Prepare tier configuration and verify its correctness
+ let number_of_slots = self.slots_per_tier.iter().fold(0_u16, |acc, &slots| {
+ acc.checked_add(slots).expect("Overflow")
+ });
+ let tier_config = TiersConfiguration:: {
+ number_of_slots,
+ slots_per_tier: BoundedVec::::try_from(
+ self.slots_per_tier.clone(),
+ )
+ .expect("Invalid number of slots per tier entries provided."),
+ reward_portion: tier_params.reward_portion.clone(),
+ tier_thresholds: tier_params.tier_thresholds.clone(),
+ };
+ assert!(
+ tier_params.is_valid(),
+ "Invalid tier config values provided."
+ );
+
+ // Prepare initial protocol state
+ let protocol_state = ProtocolState {
+ era: 1,
+ next_era_start: Pallet::::blocks_per_voting_period()
+ .checked_add(1)
+ .expect("Must not overflow - especially not at genesis."),
+ period_info: PeriodInfo {
+ number: 1,
+ subperiod: Subperiod::Voting,
+ next_subperiod_start_era: 2,
+ },
+ maintenance: false,
+ };
+
+ // Initialize necessary storage items
+ ActiveProtocolState::::put(protocol_state);
+ StaticTierParams::::put(tier_params);
+ TierConfig::::put(tier_config.clone());
+ NextTierConfig::::put(tier_config);
+ }
+ }
+
+ #[pallet::hooks]
+ impl Hooks for Pallet {
+ fn on_initialize(now: BlockNumber) -> Weight {
+ Self::era_and_period_handler(now, TierAssignment::Real)
+ }
+
+ fn on_idle(_block: BlockNumberFor, remaining_weight: Weight) -> Weight {
+ Self::expired_entry_cleanup(&remaining_weight)
+ }
+ }
+
+ /// A reason for freezing funds.
+ #[pallet::composite_enum]
+ pub enum FreezeReason {
+ /// Account is participating in dApp staking.
+ #[codec(index = 0)]
+ DAppStaking,
+ }
+
+ #[pallet::call]
+ impl Pallet {
+ /// Used to enable or disable maintenance mode.
+ /// Can only be called by manager origin.
+ #[pallet::call_index(0)]
+ #[pallet::weight(T::WeightInfo::maintenance_mode())]
+ pub fn maintenance_mode(origin: OriginFor, enabled: bool) -> DispatchResult {
+ T::ManagerOrigin::ensure_origin(origin)?;
+ ActiveProtocolState::::mutate(|state| state.maintenance = enabled);
+
+ Self::deposit_event(Event::::MaintenanceMode { enabled });
+ Ok(())
+ }
+
+ /// Used to register a new contract for dApp staking.
+ ///
+ /// If successful, smart contract will be assigned a simple, unique numerical identifier.
+ /// Owner is set to be initial beneficiary & manager of the dApp.
+ #[pallet::call_index(1)]
+ #[pallet::weight(T::WeightInfo::register())]
+ pub fn register(
+ origin: OriginFor,
+ owner: T::AccountId,
+ smart_contract: T::SmartContract,
+ ) -> DispatchResult {
+ Self::ensure_pallet_enabled()?;
+ T::ManagerOrigin::ensure_origin(origin)?;
+
+ ensure!(
+ !IntegratedDApps::::contains_key(&smart_contract),
+ Error::::ContractAlreadyExists,
+ );
+
+ ensure!(
+ IntegratedDApps::::count() < T::MaxNumberOfContracts::get().into(),
+ Error::::ExceededMaxNumberOfContracts
+ );
+
+ let dapp_id = NextDAppId::::get();
+ // MAX value must never be assigned as a dApp Id since it serves as a sentinel value.
+ ensure!(dapp_id < DAppId::MAX, Error::::NewDAppIdUnavailable);
+
+ IntegratedDApps::::insert(
+ &smart_contract,
+ DAppInfo {
+ owner: owner.clone(),
+ id: dapp_id,
+ state: DAppState::Registered,
+ reward_destination: None,
+ },
+ );
+
+ NextDAppId::::put(dapp_id.saturating_add(1));
+
+ Self::deposit_event(Event::::DAppRegistered {
+ owner,
+ smart_contract,
+ dapp_id,
+ });
+
+ Ok(())
+ }
+
+ /// Used to modify the reward beneficiary account for a dApp.
+ ///
+ /// Caller has to be dApp owner.
+ /// If set to `None`, rewards will be deposited to the dApp owner.
+ /// After this call, all existing & future rewards will be paid out to the beneficiary.
+ #[pallet::call_index(2)]
+ #[pallet::weight(T::WeightInfo::set_dapp_reward_beneficiary())]
+ pub fn set_dapp_reward_beneficiary(
+ origin: OriginFor,
+ smart_contract: T::SmartContract,
+ beneficiary: Option,
+ ) -> DispatchResult {
+ Self::ensure_pallet_enabled()?;
+ let dev_account = ensure_signed(origin)?;
+
+ IntegratedDApps::::try_mutate(
+ &smart_contract,
+ |maybe_dapp_info| -> DispatchResult {
+ let dapp_info = maybe_dapp_info
+ .as_mut()
+ .ok_or(Error::::ContractNotFound)?;
+
+ ensure!(dapp_info.owner == dev_account, Error::::OriginNotOwner);
+
+ dapp_info.reward_destination = beneficiary.clone();
+
+ Ok(())
+ },
+ )?;
+
+ Self::deposit_event(Event::::DAppRewardDestinationUpdated {
+ smart_contract,
+ beneficiary,
+ });
+
+ Ok(())
+ }
+
+ /// Used to change dApp owner.
+ ///
+ /// Can be called by dApp owner or dApp staking manager origin.
+ /// This is useful in two cases:
+ /// 1. when the dApp owner account is compromised, manager can change the owner to a new account
+ /// 2. if project wants to transfer ownership to a new account (DAO, multisig, etc.).
+ #[pallet::call_index(3)]
+ #[pallet::weight(T::WeightInfo::set_dapp_owner())]
+ pub fn set_dapp_owner(
+ origin: OriginFor,
+ smart_contract: T::SmartContract,
+ new_owner: T::AccountId,
+ ) -> DispatchResult {
+ Self::ensure_pallet_enabled()?;
+ let origin = Self::ensure_signed_or_manager(origin)?;
+
+ IntegratedDApps::::try_mutate(
+ &smart_contract,
+ |maybe_dapp_info| -> DispatchResult {
+ let dapp_info = maybe_dapp_info
+ .as_mut()
+ .ok_or(Error::::ContractNotFound)?;
+
+ // If manager origin, `None`, no need to check if caller is the owner.
+ if let Some(caller) = origin {
+ ensure!(dapp_info.owner == caller, Error::::OriginNotOwner);
+ }
+
+ dapp_info.owner = new_owner.clone();
+
+ Ok(())
+ },
+ )?;
+
+ Self::deposit_event(Event::::DAppOwnerChanged {
+ smart_contract,
+ new_owner,
+ });
+
+ Ok(())
+ }
+
+ /// Unregister dApp from dApp staking protocol, making it ineligible for future rewards.
+ /// This doesn't remove the dApp completely from the system just yet, but it can no longer be used for staking.
+ ///
+ /// Can be called by dApp staking manager origin.
+ #[pallet::call_index(4)]
+ #[pallet::weight(T::WeightInfo::unregister())]
+ pub fn unregister(
+ origin: OriginFor,
+ smart_contract: T::SmartContract,
+ ) -> DispatchResult {
+ Self::ensure_pallet_enabled()?;
+ T::ManagerOrigin::ensure_origin(origin)?;
+
+ let current_era = ActiveProtocolState::::get().era;
+
+ let mut dapp_info =
+ IntegratedDApps::::get(&smart_contract).ok_or(Error::::ContractNotFound)?;
+
+ ensure!(
+ dapp_info.state == DAppState::Registered,
+ Error::::NotOperatedDApp
+ );
+
+ ContractStake::::remove(&dapp_info.id);
+
+ dapp_info.state = DAppState::Unregistered(current_era);
+ IntegratedDApps::::insert(&smart_contract, dapp_info);
+
+ Self::deposit_event(Event::::DAppUnregistered {
+ smart_contract,
+ era: current_era,
+ });
+
+ Ok(())
+ }
+
+ /// Locks additional funds into dApp staking.
+ ///
+ /// In case caller account doesn't have sufficient balance to cover the specified amount, everything is locked.
+ /// After adjustment, lock amount must be greater than zero and in total must be equal or greater than the minimum locked amount.
+ ///
+ /// Locked amount can immediately be used for staking.
+ #[pallet::call_index(5)]
+ #[pallet::weight(T::WeightInfo::lock())]
+ pub fn lock(origin: OriginFor, #[pallet::compact] amount: Balance) -> DispatchResult {
+ Self::ensure_pallet_enabled()?;
+ let account = ensure_signed(origin)?;
+
+ let mut ledger = Ledger::::get(&account);
+
+ // Calculate & check amount available for locking
+ let available_balance =
+ T::Currency::balance(&account).saturating_sub(ledger.active_locked_amount());
+ let amount_to_lock = available_balance.min(amount);
+ ensure!(!amount_to_lock.is_zero(), Error::::ZeroAmount);
+
+ ledger.add_lock_amount(amount_to_lock);
+
+ ensure!(
+ ledger.active_locked_amount() >= T::MinimumLockedAmount::get(),
+ Error::::LockedAmountBelowThreshold
+ );
+
+ Self::update_ledger(&account, ledger)?;
+ CurrentEraInfo::::mutate(|era_info| {
+ era_info.add_locked(amount_to_lock);
+ });
+
+ Self::deposit_event(Event::::Locked {
+ account,
+ amount: amount_to_lock,
+ });
+
+ Ok(())
+ }
+
+ /// Attempts to start the unlocking process for the specified amount.
+ ///
+ /// Only the amount that isn't actively used for staking can be unlocked.
+ /// If the amount is greater than the available amount for unlocking, everything is unlocked.
+ /// If the remaining locked amount would take the account below the minimum locked amount, everything is unlocked.
+ #[pallet::call_index(6)]
+ #[pallet::weight(T::WeightInfo::unlock())]
+ pub fn unlock(origin: OriginFor, #[pallet::compact] amount: Balance) -> DispatchResult {
+ Self::ensure_pallet_enabled()?;
+ let account = ensure_signed(origin)?;
+
+ let state = ActiveProtocolState::::get();
+ let mut ledger = Ledger::::get(&account);
+
+ let available_for_unlocking = ledger.unlockable_amount(state.period_info.number);
+ let amount_to_unlock = available_for_unlocking.min(amount);
+
+ // Ensure we unlock everything if remaining amount is below threshold.
+ let remaining_amount = ledger
+ .active_locked_amount()
+ .saturating_sub(amount_to_unlock);
+ let amount_to_unlock = if remaining_amount < T::MinimumLockedAmount::get() {
+ ensure!(
+ ledger.staked_amount(state.period_info.number).is_zero(),
+ Error::::RemainingStakePreventsFullUnlock
+ );
+ ledger.active_locked_amount()
+ } else {
+ amount_to_unlock
+ };
+
+ // Sanity check
+ ensure!(!amount_to_unlock.is_zero(), Error::::ZeroAmount);
+
+ // Update ledger with new lock and unlocking amounts
+ ledger.subtract_lock_amount(amount_to_unlock);
+
+ let current_block = frame_system::Pallet::::block_number();
+ let unlock_block = current_block.saturating_add(Self::unlocking_period());
+ ledger
+ .add_unlocking_chunk(amount_to_unlock, unlock_block)
+ .map_err(|_| Error::::TooManyUnlockingChunks)?;
+
+ // Update storage
+ Self::update_ledger(&account, ledger)?;
+ CurrentEraInfo::::mutate(|era_info| {
+ era_info.unlocking_started(amount_to_unlock);
+ });
+
+ Self::deposit_event(Event::::Unlocking {
+ account,
+ amount: amount_to_unlock,
+ });
+
+ Ok(())
+ }
+
+ /// Claims all of fully unlocked chunks, removing the lock from them.
+ #[pallet::call_index(7)]
+ #[pallet::weight(T::WeightInfo::claim_unlocked(T::MaxNumberOfStakedContracts::get()))]
+ pub fn claim_unlocked(origin: OriginFor) -> DispatchResultWithPostInfo {
+ Self::ensure_pallet_enabled()?;
+ let account = ensure_signed(origin)?;
+
+ let mut ledger = Ledger::::get(&account);
+
+ let current_block = frame_system::Pallet::::block_number();
+ let amount = ledger.claim_unlocked(current_block);
+ ensure!(amount > Zero::zero(), Error::::NoUnlockedChunksToClaim);
+
+ // In case it's full unlock, account is exiting dApp staking, ensure all storage is cleaned up.
+ let removed_entries = if ledger.is_empty() {
+ let _ = StakerInfo::::clear_prefix(&account, ledger.contract_stake_count, None);
+ ledger.contract_stake_count
+ } else {
+ 0
+ };
+
+ Self::update_ledger(&account, ledger)?;
+ CurrentEraInfo::::mutate(|era_info| {
+ era_info.unlocking_removed(amount);
+ });
+
+ Self::deposit_event(Event::::ClaimedUnlocked { account, amount });
+
+ Ok(Some(T::WeightInfo::claim_unlocked(removed_entries)).into())
+ }
+
+ #[pallet::call_index(8)]
+ #[pallet::weight(T::WeightInfo::relock_unlocking())]
+ pub fn relock_unlocking(origin: OriginFor) -> DispatchResult {
+ Self::ensure_pallet_enabled()?;
+ let account = ensure_signed(origin)?;
+
+ let mut ledger = Ledger::::get(&account);
+
+ ensure!(!ledger.unlocking.is_empty(), Error::::NoUnlockingChunks);
+
+ let amount = ledger.consume_unlocking_chunks();
+
+ ledger.add_lock_amount(amount);
+ ensure!(
+ ledger.active_locked_amount() >= T::MinimumLockedAmount::get(),
+ Error::::LockedAmountBelowThreshold
+ );
+
+ Self::update_ledger(&account, ledger)?;
+ CurrentEraInfo::::mutate(|era_info| {
+ era_info.add_locked(amount);
+ era_info.unlocking_removed(amount);
+ });
+
+ Self::deposit_event(Event::::Relock { account, amount });
+
+ Ok(())
+ }
+
+ /// Stake the specified amount on a smart contract.
+ /// The precise `amount` specified **must** be available for staking.
+ /// The total amount staked on a dApp must be greater than the minimum required value.
+ ///
+ /// Depending on the period type, appropriate stake amount will be updated. During `Voting` subperiod, `voting` stake amount is updated,
+ /// and same for `Build&Earn` subperiod.
+ ///
+ /// Staked amount is only eligible for rewards from the next era onwards.
+ #[pallet::call_index(9)]
+ #[pallet::weight(T::WeightInfo::stake())]
+ pub fn stake(
+ origin: OriginFor,
+ smart_contract: T::SmartContract,
+ #[pallet::compact] amount: Balance,
+ ) -> DispatchResult {
+ Self::ensure_pallet_enabled()?;
+ let account = ensure_signed(origin)?;
+
+ ensure!(amount > 0, Error::::ZeroAmount);
+
+ let dapp_info =
+ IntegratedDApps::::get(&smart_contract).ok_or(Error::::NotOperatedDApp)?;
+ ensure!(dapp_info.is_registered(), Error::::NotOperatedDApp);
+
+ let protocol_state = ActiveProtocolState::::get();
+ let current_era = protocol_state.era;
+ ensure!(
+ !protocol_state
+ .period_info
+ .is_next_period(current_era.saturating_add(1)),
+ Error::::PeriodEndsInNextEra
+ );
+
+ let mut ledger = Ledger::::get(&account);
+
+ // In case old stake rewards are unclaimed & have expired, clean them up.
+ let threshold_period = Self::oldest_claimable_period(protocol_state.period_number());
+ let _ignore = ledger.maybe_cleanup_expired(threshold_period);
+
+ // 1.
+ // Increase stake amount for the next era & current period in staker's ledger
+ ledger
+ .add_stake_amount(amount, current_era, protocol_state.period_info)
+ .map_err(|err| match err {
+ AccountLedgerError::InvalidPeriod | AccountLedgerError::InvalidEra => {
+ Error::::UnclaimedRewards
+ }
+ AccountLedgerError::UnavailableStakeFunds => Error::::UnavailableStakeFunds,
+ // Defensive check, should never happen
+ _ => Error::::InternalStakeError,
+ })?;
+
+ // 2.
+ // Update `StakerInfo` storage with the new stake amount on the specified contract.
+ //
+ // There are two distinct scenarios:
+ // 1. Existing entry matches the current period number - just update it.
+ // 2. Entry doesn't exist or it's for an older period - create a new one.
+ //
+ // This is ok since we only use this storage entry to keep track of how much each staker
+ // has staked on each contract in the current period. We only ever need the latest information.
+ // This is because `AccountLedger` is the one keeping information about how much was staked when.
+ let (mut new_staking_info, is_new_entry) =
+ match StakerInfo::::get(&account, &smart_contract) {
+ // Entry with matching period exists
+ Some(staking_info)
+ if staking_info.period_number() == protocol_state.period_number() =>
+ {
+ (staking_info, false)
+ }
+ // Entry exists but period doesn't match. Bonus reward might still be claimable.
+ Some(staking_info)
+ if staking_info.period_number() >= threshold_period
+ && staking_info.is_loyal() =>
+ {
+ return Err(Error::::UnclaimedRewards.into());
+ }
+ // No valid entry exists
+ _ => (
+ SingularStakingInfo::new(
+ protocol_state.period_number(),
+ protocol_state.subperiod(),
+ ),
+ true,
+ ),
+ };
+ new_staking_info.stake(amount, current_era, protocol_state.subperiod());
+ ensure!(
+ new_staking_info.total_staked_amount() >= T::MinimumStakeAmount::get(),
+ Error::::InsufficientStakeAmount
+ );
+
+ if is_new_entry {
+ ledger.contract_stake_count.saturating_inc();
+ ensure!(
+ ledger.contract_stake_count <= T::MaxNumberOfStakedContracts::get(),
+ Error::::TooManyStakedContracts
+ );
+ }
+
+ // 3.
+ // Update `ContractStake` storage with the new stake amount on the specified contract.
+ let mut contract_stake_info = ContractStake::::get(&dapp_info.id);
+ contract_stake_info.stake(amount, protocol_state.period_info, current_era);
+
+ // 4.
+ // Update total staked amount for the next era.
+ CurrentEraInfo::::mutate(|era_info| {
+ era_info.add_stake_amount(amount, protocol_state.subperiod());
+ });
+
+ // 5.
+ // Update remaining storage entries
+ Self::update_ledger(&account, ledger)?;
+ StakerInfo::::insert(&account, &smart_contract, new_staking_info);
+ ContractStake::::insert(&dapp_info.id, contract_stake_info);
+
+ Self::deposit_event(Event::::Stake {
+ account,
+ smart_contract,
+ amount,
+ });
+
+ Ok(())
+ }
+
+ /// Unstake the specified amount from a smart contract.
+ /// The `amount` specified **must** not exceed what's staked, otherwise the call will fail.
+ ///
+ /// If unstaking the specified `amount` would take staker below the minimum stake threshold, everything is unstaked.
+ ///
+ /// Depending on the period type, appropriate stake amount will be updated.
+ /// In case amount is unstaked during `Voting` subperiod, the `voting` amount is reduced.
+ /// In case amount is unstaked during `Build&Earn` subperiod, first the `build_and_earn` is reduced,
+ /// and any spillover is subtracted from the `voting` amount.
+ #[pallet::call_index(10)]
+ #[pallet::weight(T::WeightInfo::unstake())]
+ pub fn unstake(
+ origin: OriginFor,
+ smart_contract: T::SmartContract,
+ #[pallet::compact] amount: Balance,
+ ) -> DispatchResult {
+ Self::ensure_pallet_enabled()?;
+ let account = ensure_signed(origin)?;
+
+ ensure!(amount > 0, Error::::ZeroAmount);
+
+ let dapp_info =
+ IntegratedDApps::::get(&smart_contract).ok_or(Error::::NotOperatedDApp)?;
+ ensure!(dapp_info.is_registered(), Error::::NotOperatedDApp);
+
+ let protocol_state = ActiveProtocolState::::get();
+ let current_era = protocol_state.era;
+
+ let mut ledger = Ledger::::get(&account);
+
+ // 1.
+ // Update `StakerInfo` storage with the reduced stake amount on the specified contract.
+ let (new_staking_info, amount) = match StakerInfo::::get(&account, &smart_contract) {
+ Some(mut staking_info) => {
+ ensure!(
+ staking_info.period_number() == protocol_state.period_number(),
+ Error::::UnstakeFromPastPeriod
+ );
+ ensure!(
+ staking_info.total_staked_amount() >= amount,
+ Error::::UnstakeAmountTooLarge
+ );
+
+ // If unstaking would take the total staked amount below the minimum required value,
+ // unstake everything.
+ let amount = if staking_info.total_staked_amount().saturating_sub(amount)
+ < T::MinimumStakeAmount::get()
+ {
+ staking_info.total_staked_amount()
+ } else {
+ amount
+ };
+
+ staking_info.unstake(amount, current_era, protocol_state.subperiod());
+ (staking_info, amount)
+ }
+ None => {
+ return Err(Error::::NoStakingInfo.into());
+ }
+ };
+
+ // 2.
+ // Reduce stake amount
+ ledger
+ .unstake_amount(amount, current_era, protocol_state.period_info)
+ .map_err(|err| match err {
+ AccountLedgerError::InvalidPeriod | AccountLedgerError::InvalidEra => {
+ Error::::UnclaimedRewards
+ }
+ // This is a defensive check, which should never happen since we calculate the correct value above.
+ AccountLedgerError::UnstakeAmountLargerThanStake => {
+ Error::::UnstakeAmountTooLarge
+ }
+ _ => Error::::InternalUnstakeError,
+ })?;
+
+ // 3.
+ // Update `ContractStake` storage with the reduced stake amount on the specified contract.
+ let mut contract_stake_info = ContractStake::::get(&dapp_info.id);
+ contract_stake_info.unstake(amount, protocol_state.period_info, current_era);
+
+ // 4.
+ // Update total staked amount for the next era.
+ CurrentEraInfo::::mutate(|era_info| {
+ era_info.unstake_amount(amount, protocol_state.subperiod());
+ });
+
+ // 5.
+ // Update remaining storage entries
+ ContractStake::::insert(&dapp_info.id, contract_stake_info);
+
+ if new_staking_info.is_empty() {
+ ledger.contract_stake_count.saturating_dec();
+ StakerInfo::::remove(&account, &smart_contract);
+ } else {
+ StakerInfo::::insert(&account, &smart_contract, new_staking_info);
+ }
+
+ Self::update_ledger(&account, ledger)?;
+
+ Self::deposit_event(Event::::Unstake {
+ account,
+ smart_contract,
+ amount,
+ });
+
+ Ok(())
+ }
+
+ /// Claims some staker rewards, if user has any.
+ /// In the case of a successfull call, at least one era will be claimed, with the possibility of multiple claims happening.
+ #[pallet::call_index(11)]
+ #[pallet::weight({
+ let max_span_length = T::EraRewardSpanLength::get();
+ T::WeightInfo::claim_staker_rewards_ongoing_period(max_span_length)
+ .max(T::WeightInfo::claim_staker_rewards_past_period(max_span_length))
+ })]
+ pub fn claim_staker_rewards(origin: OriginFor) -> DispatchResultWithPostInfo {
+ Self::ensure_pallet_enabled()?;
+ let account = ensure_signed(origin)?;
+
+ let mut ledger = Ledger::::get(&account);
+ let staked_period = ledger
+ .staked_period()
+ .ok_or(Error::::NoClaimableRewards)?;
+
+ // Check if the rewards have expired
+ let protocol_state = ActiveProtocolState::::get();
+ ensure!(
+ staked_period >= Self::oldest_claimable_period(protocol_state.period_number()),
+ Error::::RewardExpired
+ );
+
+ // Calculate the reward claim span
+ let earliest_staked_era = ledger
+ .earliest_staked_era()
+ .ok_or(Error::::InternalClaimStakerError)?;
+ let era_rewards = EraRewards::::get(Self::era_reward_index(earliest_staked_era))
+ .ok_or(Error::::NoClaimableRewards)?;
+
+ // The last era for which we can theoretically claim rewards.
+ // And indicator if we know the period's ending era.
+ let (last_period_era, period_end) = if staked_period == protocol_state.period_number() {
+ (protocol_state.era.saturating_sub(1), None)
+ } else {
+ PeriodEnd::::get(&staked_period)
+ .map(|info| (info.final_era, Some(info.final_era)))
+ .ok_or(Error::::InternalClaimStakerError)?
+ };
+
+ // The last era for which we can claim rewards for this account.
+ let last_claim_era = era_rewards.last_era().min(last_period_era);
+
+ // Get chunks for reward claiming
+ let rewards_iter =
+ ledger
+ .claim_up_to_era(last_claim_era, period_end)
+ .map_err(|err| match err {
+ AccountLedgerError::NothingToClaim => Error::::NoClaimableRewards,
+ _ => Error::::InternalClaimStakerError,
+ })?;
+
+ // Calculate rewards
+ let mut rewards: Vec<_> = Vec::new();
+ let mut reward_sum = Balance::zero();
+ for (era, amount) in rewards_iter {
+ let era_reward = era_rewards
+ .get(era)
+ .ok_or(Error::::InternalClaimStakerError)?;
+
+ // Optimization, and zero-division protection
+ if amount.is_zero() || era_reward.staked.is_zero() {
+ continue;
+ }
+ let staker_reward = Perbill::from_rational(amount, era_reward.staked)
+ * era_reward.staker_reward_pool;
+
+ rewards.push((era, staker_reward));
+ reward_sum.saturating_accrue(staker_reward);
+ }
+ let rewards_len: u32 = rewards.len().unique_saturated_into();
+
+ T::StakingRewardHandler::payout_reward(&account, reward_sum)
+ .map_err(|_| Error::::RewardPayoutFailed)?;
+
+ Self::update_ledger(&account, ledger)?;
+
+ rewards.into_iter().for_each(|(era, reward)| {
+ Self::deposit_event(Event::::Reward {
+ account: account.clone(),
+ era,
+ amount: reward,
+ });
+ });
+
+ Ok(Some(if period_end.is_some() {
+ T::WeightInfo::claim_staker_rewards_past_period(rewards_len)
+ } else {
+ T::WeightInfo::claim_staker_rewards_ongoing_period(rewards_len)
+ })
+ .into())
+ }
+
+ /// Used to claim bonus reward for a smart contract, if eligible.
+ #[pallet::call_index(12)]
+ #[pallet::weight(T::WeightInfo::claim_bonus_reward())]
+ pub fn claim_bonus_reward(
+ origin: OriginFor,
+ smart_contract: T::SmartContract,
+ ) -> DispatchResult {
+ Self::ensure_pallet_enabled()?;
+ let account = ensure_signed(origin)?;
+
+ let staker_info = StakerInfo::::get(&account, &smart_contract)
+ .ok_or(Error::::NoClaimableRewards)?;
+ let protocol_state = ActiveProtocolState::::get();
+
+ // Ensure:
+ // 1. Period for which rewards are being claimed has ended.
+ // 2. Account has been a loyal staker.
+ // 3. Rewards haven't expired.
+ let staked_period = staker_info.period_number();
+ ensure!(
+ staked_period < protocol_state.period_number(),
+ Error::::NoClaimableRewards
+ );
+ ensure!(
+ staker_info.is_loyal(),
+ Error::::NotEligibleForBonusReward
+ );
+ ensure!(
+ staker_info.period_number()
+ >= Self::oldest_claimable_period(protocol_state.period_number()),
+ Error::::RewardExpired
+ );
+
+ let period_end_info =
+ PeriodEnd::::get(&staked_period).ok_or(Error::::InternalClaimBonusError)?;
+ // Defensive check - we should never get this far in function if no voting period stake exists.
+ ensure!(
+ !period_end_info.total_vp_stake.is_zero(),
+ Error::::InternalClaimBonusError
+ );
+
+ let eligible_amount = staker_info.staked_amount(Subperiod::Voting);
+ let bonus_reward =
+ Perbill::from_rational(eligible_amount, period_end_info.total_vp_stake)
+ * period_end_info.bonus_reward_pool;
+
+ T::StakingRewardHandler::payout_reward(&account, bonus_reward)
+ .map_err(|_| Error::::RewardPayoutFailed)?;
+
+ // Cleanup entry since the reward has been claimed
+ StakerInfo::::remove(&account, &smart_contract);
+ Ledger::::mutate(&account, |ledger| {
+ ledger.contract_stake_count.saturating_dec();
+ });
+
+ Self::deposit_event(Event::::BonusReward {
+ account: account.clone(),
+ smart_contract,
+ period: staked_period,
+ amount: bonus_reward,
+ });
+
+ Ok(())
+ }
+
+ /// Used to claim dApp reward for the specified era.
+ #[pallet::call_index(13)]
+ #[pallet::weight(T::WeightInfo::claim_dapp_reward())]
+ pub fn claim_dapp_reward(
+ origin: OriginFor,
+ smart_contract: T::SmartContract,
+ #[pallet::compact] era: EraNumber,
+ ) -> DispatchResult {
+ Self::ensure_pallet_enabled()?;
+
+ // To keep in line with legacy behavior, dApp rewards can be claimed by anyone.
+ let _ = ensure_signed(origin)?;
+
+ let dapp_info =
+ IntegratedDApps::::get(&smart_contract).ok_or(Error::::ContractNotFound)?;
+
+ // Make sure provided era has ended
+ let protocol_state = ActiveProtocolState::::get();
+ ensure!(era < protocol_state.era, Error::::InvalidClaimEra);
+
+ // 'Consume' dApp reward for the specified era, if possible.
+ let mut dapp_tiers = DAppTiers::::get(&era).ok_or(Error::::NoDAppTierInfo)?;
+ ensure!(
+ dapp_tiers.period >= Self::oldest_claimable_period(protocol_state.period_number()),
+ Error::::RewardExpired
+ );
+
+ let (amount, tier_id) =
+ dapp_tiers
+ .try_claim(dapp_info.id)
+ .map_err(|error| match error {
+ DAppTierError::NoDAppInTiers => Error::::NoClaimableRewards,
+ DAppTierError::RewardAlreadyClaimed => Error::::DAppRewardAlreadyClaimed,
+ _ => Error::::InternalClaimDAppError,
+ })?;
+
+ // Get reward destination, and deposit the reward.
+ let beneficiary = dapp_info.reward_beneficiary();
+ T::StakingRewardHandler::payout_reward(&beneficiary, amount)
+ .map_err(|_| Error::::RewardPayoutFailed)?;
+
+ // Write back updated struct to prevent double reward claims
+ DAppTiers::::insert(&era, dapp_tiers);
+
+ Self::deposit_event(Event::::DAppReward {
+ beneficiary: beneficiary.clone(),
+ smart_contract,
+ tier_id,
+ era,
+ amount,
+ });
+
+ Ok(())
+ }
+
+ /// Used to unstake funds from a contract that was unregistered after an account staked on it.
+ /// This is required if staker wants to re-stake these funds on another active contract during the ongoing period.
+ #[pallet::call_index(14)]
+ #[pallet::weight(T::WeightInfo::unstake_from_unregistered())]
+ pub fn unstake_from_unregistered(
+ origin: OriginFor,
+ smart_contract: T::SmartContract,
+ ) -> DispatchResult {
+ Self::ensure_pallet_enabled()?;
+ let account = ensure_signed(origin)?;
+
+ ensure!(
+ !Self::is_registered(&smart_contract),
+ Error::::ContractStillActive
+ );
+
+ let protocol_state = ActiveProtocolState::::get();
+ let current_era = protocol_state.era;
+
+ // Extract total staked amount on the specified unregistered contract
+ let amount = match StakerInfo::::get(&account, &smart_contract) {
+ Some(staking_info) => {
+ ensure!(
+ staking_info.period_number() == protocol_state.period_number(),
+ Error::::UnstakeFromPastPeriod
+ );
+
+ staking_info.total_staked_amount()
+ }
+ None => {
+ return Err(Error::::NoStakingInfo.into());
+ }
+ };
+
+ // Reduce stake amount in ledger
+ let mut ledger = Ledger::::get(&account);
+ ledger
+ .unstake_amount(amount, current_era, protocol_state.period_info)
+ .map_err(|err| match err {
+ // These are all defensive checks, which should never fail since we already checked them above.
+ AccountLedgerError::InvalidPeriod | AccountLedgerError::InvalidEra => {
+ Error::::UnclaimedRewards
+ }
+ _ => Error::::InternalUnstakeError,
+ })?;
+
+ // Update total staked amount for the next era.
+ // This means 'fake' stake total amount has been kept until now, even though contract was unregistered.
+ // Although strange, it's been requested to keep it like this from the team.
+ CurrentEraInfo::::mutate(|era_info| {
+ era_info.unstake_amount(amount, protocol_state.subperiod());
+ });
+
+ // Update remaining storage entries
+ Self::update_ledger(&account, ledger)?;
+ StakerInfo::::remove(&account, &smart_contract);
+
+ Self::deposit_event(Event::::UnstakeFromUnregistered {
+ account,
+ smart_contract,
+ amount,
+ });
+
+ Ok(())
+ }
+
+ /// Cleanup expired stake entries for the contract.
+ ///
+ /// Entry is considered to be expired if:
+ /// 1. It's from a past period & the account wasn't a loyal staker, meaning there's no claimable bonus reward.
+ /// 2. It's from a period older than the oldest claimable period, regardless whether the account was loyal or not.
+ #[pallet::call_index(15)]
+ #[pallet::weight(T::WeightInfo::cleanup_expired_entries(
+ T::MaxNumberOfStakedContracts::get()
+ ))]
+ pub fn cleanup_expired_entries(origin: OriginFor) -> DispatchResultWithPostInfo {
+ Self::ensure_pallet_enabled()?;
+ let account = ensure_signed(origin)?;
+
+ let protocol_state = ActiveProtocolState::::get();
+ let current_period = protocol_state.period_number();
+ let threshold_period = Self::oldest_claimable_period(current_period);
+
+ // Find all entries which are from past periods & don't have claimable bonus rewards.
+ // This is bounded by max allowed number of stake entries per account.
+ let to_be_deleted: Vec = StakerInfo::