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::::iter_prefix(&account) + .filter_map(|(smart_contract, stake_info)| { + if stake_info.period_number() < current_period && !stake_info.is_loyal() + || stake_info.period_number() < threshold_period + { + Some(smart_contract) + } else { + None + } + }) + .collect(); + let entries_to_delete = to_be_deleted.len(); + + ensure!(!entries_to_delete.is_zero(), Error::::NoExpiredEntries); + + // Remove all expired entries. + for smart_contract in to_be_deleted { + StakerInfo::::remove(&account, &smart_contract); + } + + // Remove expired stake entries from the ledger. + let mut ledger = Ledger::::get(&account); + ledger + .contract_stake_count + .saturating_reduce(entries_to_delete.unique_saturated_into()); + ledger.maybe_cleanup_expired(threshold_period); // Not necessary but we do it for the sake of consistency + Self::update_ledger(&account, ledger)?; + + Self::deposit_event(Event::::ExpiredEntriesRemoved { + account, + count: entries_to_delete.unique_saturated_into(), + }); + + Ok(Some(T::WeightInfo::cleanup_expired_entries( + entries_to_delete.unique_saturated_into(), + )) + .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. + /// + /// Used for testing purposes, when we want to force an era change, or a subperiod change. + /// Not intended to be used in production, except in case of unforseen circumstances. + /// + /// Can only be called by manager origin. + #[pallet::call_index(16)] + #[pallet::weight(T::WeightInfo::force())] + pub fn force(origin: OriginFor, forcing_type: ForcingType) -> DispatchResult { + Self::ensure_pallet_enabled()?; + T::ManagerOrigin::ensure_origin(origin)?; + + // Ensure a 'change' happens on the next block + ActiveProtocolState::::mutate(|state| { + let current_block = frame_system::Pallet::::block_number(); + state.next_era_start = current_block.saturating_add(One::one()); + + match forcing_type { + ForcingType::Era => (), + ForcingType::Subperiod => { + state.period_info.next_subperiod_start_era = state.era.saturating_add(1); + } + } + }); + + Self::deposit_event(Event::::Force { forcing_type }); + + Ok(()) + } + } + + impl Pallet { + /// `Err` if pallet disabled for maintenance, `Ok` otherwise. + pub(crate) fn ensure_pallet_enabled() -> Result<(), Error> { + if ActiveProtocolState::::get().maintenance { + Err(Error::::Disabled) + } else { + Ok(()) + } + } + + /// Ensure that the origin is either the `ManagerOrigin` or a signed origin. + /// + /// In case of manager, `Ok(None)` is returned, and if signed origin `Ok(Some(AccountId))` is returned. + pub(crate) fn ensure_signed_or_manager( + origin: T::RuntimeOrigin, + ) -> Result, BadOrigin> { + if T::ManagerOrigin::ensure_origin(origin.clone()).is_ok() { + return Ok(None); + } + let who = ensure_signed(origin)?; + Ok(Some(who)) + } + + /// Update the account ledger, and dApp staking balance freeze. + /// + /// In case account ledger is empty, entries from the DB are removed and freeze is thawed. + /// + /// This call can fail if the `freeze` or `thaw` operations fail. This should never happen since + /// runtime definition must ensure it supports necessary freezes. + pub(crate) fn update_ledger( + account: &T::AccountId, + ledger: AccountLedgerFor, + ) -> Result<(), DispatchError> { + if ledger.is_empty() { + Ledger::::remove(&account); + T::Currency::thaw(&FreezeReason::DAppStaking.into(), account)?; + } else { + T::Currency::set_freeze( + &FreezeReason::DAppStaking.into(), + account, + ledger.active_locked_amount(), + )?; + Ledger::::insert(account, ledger); + } + + Ok(()) + } + + /// Returns the number of blocks per voting period. + pub(crate) fn blocks_per_voting_period() -> BlockNumber { + T::CycleConfiguration::blocks_per_era() + .saturating_mul(T::CycleConfiguration::eras_per_voting_subperiod().into()) + } + + /// `true` if smart contract is registered, `false` otherwise. + pub(crate) fn is_registered(smart_contract: &T::SmartContract) -> bool { + IntegratedDApps::::get(smart_contract) + .map_or(false, |dapp_info| dapp_info.is_registered()) + } + + /// Calculates the `EraRewardSpan` index for the specified era. +<<<<<<< HEAD + pub fn era_reward_span_index(era: EraNumber) -> EraNumber { +======= + pub(crate) fn era_reward_index(era: EraNumber) -> EraNumber { +>>>>>>> origin/feat/dapp-staking-v3 + era.saturating_sub(era % T::EraRewardSpanLength::get()) + } + + /// Return the oldest period for which rewards can be claimed. + /// All rewards before that period are considered to be expired. + pub(crate) fn oldest_claimable_period(current_period: PeriodNumber) -> PeriodNumber { + current_period.saturating_sub(T::RewardRetentionInPeriods::get()) + } + + /// Unlocking period expressed in the number of blocks. + pub fn unlocking_period() -> BlockNumber { + T::CycleConfiguration::blocks_per_era().saturating_mul(T::UnlockingPeriod::get().into()) + } + + /// Assign eligible dApps into appropriate tiers, and calculate reward for each tier. + /// + /// ### Algorithm + /// + /// 1. Read in over all contract stake entries. In case staked amount is zero for the current era, ignore it. + /// This information is used to calculate 'score' per dApp, which is used to determine the tier. + /// + /// 2. Sort the entries by the score, in descending order - the top score dApp comes first. + /// + /// 3. Read in tier configuration. This contains information about how many slots per tier there are, + /// as well as the threshold for each tier. Threshold is the minimum amount of stake required to be eligible for a tier. + /// Iterate over tier thresholds & capacities, starting from the top tier, and assign dApps to them. + /// + /// ```text + //// for each tier: + /// for each unassigned dApp: + /// if tier has capacity && dApp satisfies the tier threshold: + /// add dapp to the tier + /// else: + /// exit loop since no more dApps will satisfy the threshold since they are sorted by score + /// ``` + /// (Sort the entries by dApp ID, in ascending order. This is so we can efficiently search for them using binary search.) + /// + /// 4. Calculate rewards for each tier. + /// This is done by dividing the total reward pool into tier reward pools, + /// after which the tier reward pool is divided by the number of available slots in the tier. + /// + /// The returned object contains information about each dApp that made it into a tier. + /// Alongside tier assignment info, number of read DB contract stake entries is returned. + pub(crate) fn get_dapp_tier_assignment( + era: EraNumber, + period: PeriodNumber, + dapp_reward_pool: Balance, + ) -> (DAppTierRewardsFor, DAppId) { + let mut dapp_stakes = Vec::with_capacity(T::MaxNumberOfContracts::get() as usize); + + // 1. + // Iterate over all staked dApps. + // This is bounded by max amount of dApps we allow to be registered. + let mut counter = 0; + for (dapp_id, stake_amount) in ContractStake::::iter() { + counter.saturating_inc(); + + // Skip dApps which don't have ANY amount staked + let stake_amount = match stake_amount.get(era, period) { + Some(stake_amount) if !stake_amount.total().is_zero() => stake_amount, + _ => continue, + }; + + dapp_stakes.push((dapp_id, stake_amount.total())); + } + + // 2. + // Sort by amount staked, in reverse - top dApp will end in the first place, 0th index. + dapp_stakes.sort_unstable_by(|(_, amount_1), (_, amount_2)| amount_2.cmp(amount_1)); + + // 3. + // Iterate over configured tier and potential dApps. + // Each dApp will be assigned to the best possible tier if it satisfies the required condition, + // and tier capacity hasn't been filled yet. + let mut dapp_tiers = Vec::with_capacity(dapp_stakes.len()); + let tier_config = TierConfig::::get(); + + let mut global_idx = 0; + let mut tier_id = 0; + for (tier_capacity, tier_threshold) in tier_config + .slots_per_tier + .iter() + .zip(tier_config.tier_thresholds.iter()) + { + let max_idx = global_idx + .saturating_add(*tier_capacity as usize) + .min(dapp_stakes.len()); + + // Iterate over dApps until one of two conditions has been met: + // 1. Tier has no more capacity + // 2. dApp doesn't satisfy the tier threshold (since they're sorted, none of the following dApps will satisfy the condition either) + for (dapp_id, stake_amount) in dapp_stakes[global_idx..max_idx].iter() { + if tier_threshold.is_satisfied(*stake_amount) { + global_idx.saturating_inc(); + dapp_tiers.push(DAppTier { + dapp_id: *dapp_id, + tier_id: Some(tier_id), + }); + } else { + break; + } + } + + tier_id.saturating_inc(); + } + + // In case when tier has 1 more free slot, but two dApps with exactly same score satisfy the threshold, + // one of them will be assigned to the tier, and the other one will be assigned to the lower tier, if it exists. + // + // There is no explicit definition of which dApp gets the advantage - it's decided by dApp IDs hash & the unstable sort algorithm. + + // 4. Calculate rewards. + let tier_rewards = tier_config + .reward_portion + .iter() + .zip(tier_config.slots_per_tier.iter()) + .map(|(percent, slots)| { + if slots.is_zero() { + Zero::zero() + } else { + *percent * dapp_reward_pool / >::into(*slots) + } + }) + .collect::>(); + + // 5. + // Prepare and return tier & rewards info. + // In case rewards creation fails, we just write the default value. This should never happen though. + ( + DAppTierRewards::::new( + dapp_tiers, + tier_rewards, + period, + ) + .unwrap_or_default(), + counter, + ) + } + + /// Used to handle era & period transitions. + pub(crate) fn era_and_period_handler( + now: BlockNumber, + tier_assignment: TierAssignment, + ) -> Weight { + let mut protocol_state = ActiveProtocolState::::get(); + + // `ActiveProtocolState` is whitelisted, so we need to account for its read. + let mut consumed_weight = T::DbWeight::get().reads(1); + + // We should not modify pallet storage while in maintenance mode. + // This is a safety measure, since maintenance mode is expected to be + // enabled in case some misbehavior or corrupted storage is detected. + if protocol_state.maintenance { + return consumed_weight; + } + + // Nothing to do if it's not new era + if !protocol_state.is_new_era(now) { + return consumed_weight; + } + + // At this point it's clear that an era change will happen + let mut era_info = CurrentEraInfo::::get(); + + let current_era = protocol_state.era; + let next_era = current_era.saturating_add(1); + let (maybe_period_event, era_reward) = match protocol_state.subperiod() { + // Voting subperiod only lasts for one 'prolonged' era + Subperiod::Voting => { + // For the sake of consistency, we put zero reward into storage. There are no rewards for the voting subperiod. + let era_reward = EraReward { + staker_reward_pool: Balance::zero(), + staked: era_info.total_staked_amount(), + dapp_reward_pool: Balance::zero(), + }; + + let next_subperiod_start_era = next_era + .saturating_add(T::CycleConfiguration::eras_per_build_and_earn_subperiod()); + let build_and_earn_start_block = + now.saturating_add(T::CycleConfiguration::blocks_per_era()); + protocol_state.advance_to_next_subperiod( + next_subperiod_start_era, + build_and_earn_start_block, + ); + + era_info.migrate_to_next_era(Some(protocol_state.subperiod())); + + // Update tier configuration to be used when calculating rewards for the upcoming eras + let next_tier_config = NextTierConfig::::take(); + TierConfig::::put(next_tier_config); + + consumed_weight + .saturating_accrue(T::WeightInfo::on_initialize_voting_to_build_and_earn()); + + ( + Some(Event::::NewSubperiod { + subperiod: protocol_state.subperiod(), + number: protocol_state.period_number(), + }), + era_reward, + ) + } + Subperiod::BuildAndEarn => { + let staked = era_info.total_staked_amount(); + let (staker_reward_pool, dapp_reward_pool) = + T::StakingRewardHandler::staker_and_dapp_reward_pools(staked); + let era_reward = EraReward { + staker_reward_pool, + staked, + dapp_reward_pool, + }; + + // Distribute dapps into tiers, write it into storage + // + // To help with benchmarking, it's possible to omit real tier calculation using the `Dummy` approach. + // This must never be used in production code, obviously. + let (dapp_tier_rewards, counter) = match tier_assignment { + TierAssignment::Real => Self::get_dapp_tier_assignment( + current_era, + protocol_state.period_number(), + dapp_reward_pool, + ), + #[cfg(feature = "runtime-benchmarks")] + TierAssignment::Dummy => (DAppTierRewardsFor::::default(), 0), + }; + DAppTiers::::insert(¤t_era, dapp_tier_rewards); + + consumed_weight + .saturating_accrue(T::WeightInfo::dapp_tier_assignment(counter.into())); + + // Switch to `Voting` period if conditions are met. + if protocol_state.period_info.is_next_period(next_era) { + // Store info about period end + let bonus_reward_pool = T::StakingRewardHandler::bonus_reward_pool(); + PeriodEnd::::insert( + &protocol_state.period_number(), + PeriodEndInfo { + bonus_reward_pool, + total_vp_stake: era_info.staked_amount(Subperiod::Voting), + final_era: current_era, + }, + ); + + // For the sake of consistency we treat the whole `Voting` period as a single era. + // This means no special handling is required for this period, it only lasts potentially longer than a single standard era. + let next_subperiod_start_era = next_era.saturating_add(1); + let voting_period_length = Self::blocks_per_voting_period(); + let next_era_start_block = now.saturating_add(voting_period_length); + + protocol_state.advance_to_next_subperiod( + next_subperiod_start_era, + next_era_start_block, + ); + + era_info.migrate_to_next_era(Some(protocol_state.subperiod())); + + // Re-calculate tier configuration for the upcoming new period + let tier_params = StaticTierParams::::get(); + let average_price = T::NativePriceProvider::average_price(); + let new_tier_config = + TierConfig::::get().calculate_new(average_price, &tier_params); + NextTierConfig::::put(new_tier_config); + + consumed_weight.saturating_accrue( + T::WeightInfo::on_initialize_build_and_earn_to_voting(), + ); + + ( + Some(Event::::NewSubperiod { + subperiod: protocol_state.subperiod(), + number: protocol_state.period_number(), + }), + era_reward, + ) + } else { + let next_era_start_block = + now.saturating_add(T::CycleConfiguration::blocks_per_era()); + protocol_state.next_era_start = next_era_start_block; + + era_info.migrate_to_next_era(None); + + consumed_weight.saturating_accrue( + T::WeightInfo::on_initialize_build_and_earn_to_build_and_earn(), + ); + + (None, era_reward) + } + } + }; + + // Update storage items + protocol_state.era = next_era; + ActiveProtocolState::::put(protocol_state); + + CurrentEraInfo::::put(era_info); + + let era_span_index = Self::era_reward_index(current_era); + let mut span = EraRewards::::get(&era_span_index).unwrap_or(EraRewardSpan::new()); + if let Err(_) = span.push(current_era, era_reward) { + // This must never happen but we log the error just in case. + log::error!( + target: LOG_TARGET, + "Failed to push era {} into the era reward span.", + current_era + ); + } + EraRewards::::insert(&era_span_index, span); + + Self::deposit_event(Event::::NewEra { era: next_era }); + if let Some(period_event) = maybe_period_event { + Self::deposit_event(period_event); + } + + 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/test/mock.rs b/pallets/dapp-staking-v3/src/test/mock.rs index 37db264053..3f116c6bbc 100644 --- a/pallets/dapp-staking-v3/src/test/mock.rs +++ b/pallets/dapp-staking-v3/src/test/mock.rs @@ -18,7 +18,7 @@ use crate::{ self as pallet_dapp_staking, - test::testing_utils::{assert_block_bump, MemorySnapshot}, + test::testing_utils::{assert_block_bump, assert_on_idle_cleanup, MemorySnapshot}, *, }; @@ -319,6 +319,7 @@ impl ExtBuilder { pub(crate) fn run_to_block(n: BlockNumber) { while System::block_number() < n { DappStaking::on_finalize(System::block_number()); + assert_on_idle_cleanup(); System::set_block_number(System::block_number() + 1); // This is performed outside of dapps staking but we expect it before on_initialize diff --git a/pallets/dapp-staking-v3/src/test/testing_utils.rs b/pallets/dapp-staking-v3/src/test/testing_utils.rs index f20de522f1..29cfd9e40d 100644 --- a/pallets/dapp-staking-v3/src/test/testing_utils.rs +++ b/pallets/dapp-staking-v3/src/test/testing_utils.rs @@ -20,13 +20,14 @@ use crate::test::mock::*; use crate::types::*; use crate::{ pallet::Config, ActiveProtocolState, ContractStake, CurrentEraInfo, DAppId, DAppTiers, - EraRewards, Event, FreezeReason, IntegratedDApps, Ledger, NextDAppId, NextTierConfig, - PeriodEnd, PeriodEndInfo, StakerInfo, TierConfig, + EraRewards, Event, FreezeReason, HistoryCleanupMarker, IntegratedDApps, Ledger, NextDAppId, + NextTierConfig, PeriodEnd, PeriodEndInfo, StakerInfo, TierConfig, }; use frame_support::{ - assert_ok, - traits::{fungible::InspectFreeze, Get}, + assert_ok, assert_storage_noop, + traits::{fungible::InspectFreeze, Get, OnIdle}, + weights::Weight, }; use sp_runtime::{traits::Zero, Perbill}; use std::collections::HashMap; @@ -918,6 +919,11 @@ pub(crate) fn assert_claim_bonus_reward(account: AccountId, smart_contract: &Moc !StakerInfo::::contains_key(&account, smart_contract), "Entry must be removed after successful reward claim." ); + assert_eq!( + pre_snapshot.ledger[&account].contract_stake_count, + Ledger::::get(&account).contract_stake_count + 1, + "Count must be reduced since the staker info entry was removed." + ); } /// Claim dapp reward for a particular era. @@ -1282,7 +1288,7 @@ pub(crate) fn assert_block_bump(pre_snapshot: &MemorySnapshot) { } // 4. Verify era reward - let era_span_index = DappStaking::era_reward_span_index(pre_protoc_state.era); + let era_span_index = DappStaking::era_reward_index(pre_protoc_state.era); let maybe_pre_era_reward_span = pre_snapshot.era_rewards.get(&era_span_index); let post_era_reward_span = post_snapshot .era_rewards @@ -1350,6 +1356,80 @@ pub(crate) fn assert_block_bump(pre_snapshot: &MemorySnapshot) { } } +/// Verify `on_idle` cleanup. +pub(crate) fn assert_on_idle_cleanup() { + // Pre-data snapshot (limited to speed up testing) + let pre_cleanup_marker = HistoryCleanupMarker::::get(); + let pre_era_rewards: HashMap::EraRewardSpanLength>> = + EraRewards::::iter().collect(); + let pre_period_ends: HashMap = PeriodEnd::::iter().collect(); + + // Calculated the oldest era which is valid (not expired) + let protocol_state = ActiveProtocolState::::get(); + let retention_period: PeriodNumber = ::RewardRetentionInPeriods::get(); + + let oldest_valid_era = match protocol_state + .period_number() + .checked_sub(retention_period + 1) + { + Some(expired_period) if expired_period > 0 => { + pre_period_ends[&expired_period].final_era + 1 + } + _ => { + // No cleanup so no storage changes are expected + assert_storage_noop!(DappStaking::on_idle(System::block_number(), Weight::MAX)); + return; + } + }; + + // Check if any span or tiers cleanup is needed. + let is_era_span_cleanup_expected = + pre_era_rewards[&pre_cleanup_marker.era_reward_index].last_era() < oldest_valid_era; + let is_dapp_tiers_cleanup_expected = pre_cleanup_marker.dapp_tiers_index > 0 + && pre_cleanup_marker.dapp_tiers_index < oldest_valid_era; + + // Check if period end info should be cleaned up + let maybe_period_end_cleanup = match protocol_state + .period_number() + .checked_sub(retention_period + 2) + { + Some(period) if period > 0 => Some(period), + _ => None, + }; + + // Cleanup and verify post state. + + DappStaking::on_idle(System::block_number(), Weight::MAX); + + // Post checks + let post_cleanup_marker = HistoryCleanupMarker::::get(); + + if is_era_span_cleanup_expected { + assert!(!EraRewards::::contains_key( + pre_cleanup_marker.era_reward_index + )); + let span_length: EraNumber = ::EraRewardSpanLength::get(); + assert_eq!( + post_cleanup_marker.era_reward_index, + pre_cleanup_marker.era_reward_index + span_length + ); + } + if is_dapp_tiers_cleanup_expected { + assert!( + !DAppTiers::::contains_key(pre_cleanup_marker.dapp_tiers_index), + "Sanity check." + ); + assert_eq!( + post_cleanup_marker.dapp_tiers_index, + pre_cleanup_marker.dapp_tiers_index + 1 + ) + } + + if let Some(period) = maybe_period_end_cleanup { + assert!(!PeriodEnd::::contains_key(period)); + } +} + /// Returns from which starting era to which ending era can rewards be claimed for the specified account. /// /// If `None` is returned, there is nothing to claim. @@ -1387,10 +1467,10 @@ pub(crate) fn required_number_of_reward_claims(account: AccountId) -> u32 { }; let era_span_length: EraNumber = ::EraRewardSpanLength::get(); - let first = DappStaking::era_reward_span_index(range.0) + let first = DappStaking::era_reward_index(range.0) .checked_div(era_span_length) .unwrap(); - let second = DappStaking::era_reward_span_index(range.1) + let second = DappStaking::era_reward_index(range.1) .checked_div(era_span_length) .unwrap(); diff --git a/pallets/dapp-staking-v3/src/test/tests.rs b/pallets/dapp-staking-v3/src/test/tests.rs index 0efe2ebba7..d6154a319a 100644 --- a/pallets/dapp-staking-v3/src/test/tests.rs +++ b/pallets/dapp-staking-v3/src/test/tests.rs @@ -1074,7 +1074,7 @@ fn stake_fails_due_to_too_many_staked_contracts() { // Advance to build&earn subperiod so we ensure non-loyal staking advance_to_next_subperiod(); - // Register smart contracts up the the max allowed number + // Register smart contracts up to the max allowed number for id in 1..=max_number_of_contracts { let smart_contract = MockSmartContract::Wasm(id.into()); assert_register(2, &MockSmartContract::Wasm(id.into())); @@ -2382,6 +2382,13 @@ fn get_dapp_tier_assignment_zero_slots_per_tier_works() { }) } +#[test] +fn advance_for_some_periods_works() { + ExtBuilder::build().execute_with(|| { + advance_to_period(10); + }) +} + //////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////// @@ -2479,3 +2486,37 @@ fn stake_and_unstake_after_reward_claim_is_ok() { assert_unstake(account, &smart_contract, 1); }) } + +#[test] +fn stake_after_period_ends_with_max_staked_contracts() { + ExtBuilder::build().execute_with(|| { + let max_number_of_contracts: u32 = ::MaxNumberOfStakedContracts::get(); + + // Lock amount by staker + let account = 1; + assert_lock(account, 100 as Balance * max_number_of_contracts as Balance); + + // Register smart contracts up to the max allowed number + for id in 1..=max_number_of_contracts { + let smart_contract = MockSmartContract::Wasm(id.into()); + assert_register(2, &smart_contract); + assert_stake(account, &smart_contract, 10); + } + + // Advance to the next period, and claim ALL rewards + advance_to_next_period(); + for _ in 0..required_number_of_reward_claims(account) { + assert_claim_staker_rewards(account); + } + for id in 1..=max_number_of_contracts { + let smart_contract = MockSmartContract::Wasm(id.into()); + assert_claim_bonus_reward(account, &smart_contract); + } + + // Make sure it's possible to stake again + for id in 1..=max_number_of_contracts { + let smart_contract = MockSmartContract::Wasm(id.into()); + assert_stake(account, &smart_contract, 10); + } + }) +} diff --git a/pallets/dapp-staking-v3/src/test/tests_types.rs b/pallets/dapp-staking-v3/src/test/tests_types.rs index aadfbda6d1..8bf9183b69 100644 --- a/pallets/dapp-staking-v3/src/test/tests_types.rs +++ b/pallets/dapp-staking-v3/src/test/tests_types.rs @@ -159,7 +159,6 @@ fn dapp_info_basic_checks() { id: 7, state: DAppState::Registered, reward_destination: None, - tier_label: None, }; // Owner receives reward in case no beneficiary is set @@ -2123,7 +2122,6 @@ fn contract_stake_amount_basic_get_checks_work() { let contract_stake = ContractStakeAmount { staked: Default::default(), staked_future: None, - tier_label: None, }; assert!(contract_stake.is_empty()); assert!(contract_stake.latest_stake_period().is_none()); @@ -2145,7 +2143,6 @@ fn contract_stake_amount_basic_get_checks_work() { let contract_stake = ContractStakeAmount { staked: amount, staked_future: None, - tier_label: None, }; assert!(!contract_stake.is_empty()); @@ -2194,7 +2191,6 @@ fn contract_stake_amount_advanced_get_checks_work() { let contract_stake = ContractStakeAmount { staked: amount_1, staked_future: Some(amount_2), - tier_label: None, }; // Sanity checks - all values from the 'future' entry should be relevant diff --git a/pallets/dapp-staking-v3/src/types.rs b/pallets/dapp-staking-v3/src/types.rs index d3ae193ce5..73c26974ee 100644 --- a/pallets/dapp-staking-v3/src/types.rs +++ b/pallets/dapp-staking-v3/src/types.rs @@ -277,8 +277,6 @@ pub struct DAppInfo { pub state: DAppState, // If `None`, rewards goes to the developer account, otherwise to the account Id in `Some`. pub reward_destination: Option, - /// If `Some(_)` dApp has a tier label which can influence the tier assignment. - pub tier_label: Option, } impl DAppInfo { @@ -316,7 +314,60 @@ impl Default for UnlockingChunk { } } -/// General info about user's stakes +/// General info about an account's lock & stakes. +/// +/// ## Overview +/// +/// The most complex part about this type are the `staked` and `staked_future` fields. +/// To understand why the two fields exist and how they are used, it's important to consider some facts: +/// * when an account _stakes_, the staked amount is only eligible for rewards from the next era +/// * all stakes are reset when a period ends - but this is done in a lazy fashion, account ledgers aren't directly updated +/// * `stake` and `unstake` operations are allowed only if the account has claimed all pending rewards +/// +/// In order to keep track of current era stake, and _next era_ stake, two fields are needed. +/// Since it's not allowed to stake/unstake if there are pending rewards, it's guaranteed that the `staked` and `staked_future` eras are **always consecutive**. +/// In order to understand if _stake_ is still valid, it's enough to check the `period` field of either `staked` or `staked_future`. +/// +/// ## Example +/// +/// ### Scenario 1 +/// +/// * current era is **20**, and current period is **1** +/// * `staked` is equal to: `{ voting: 100, build_and_earn: 50, era: 5, period: 1 }` +/// * `staked_future` is equal to: `{ voting: 100, build_and_earn: 100, era: 6, period: 1 }` +/// +/// The correct way to interpret this is: +/// * account had staked **150** in total in era 5 +/// * account had increased their stake to **200** in total in era 6 +/// * since then, era 6, account hadn't staked or unstaked anything or hasn't claimed any rewards +/// * since we're in era **20** and period is still **1**, the account's stake for eras **7** to **20** is still **200** +/// +/// ### Scenario 2 +/// +/// * current era is **20**, and current period is **1** +/// * `staked` is equal to: `{ voting: 0, build_and_earn: 0, era: 0, period: 0 }` +/// * `staked_future` is equal to: `{ voting: 0, build_and_earn: 350, era: 13, period: 1 }` +/// +/// The correct way to interpret this is: +/// * `staked` entry is _empty_ +/// * account had called `stake` during era 12, and staked **350** for the next era +/// * account hadn't staked, unstaked or claimed rewards since then +/// * since we're in era **20** and period is still **1**, the account's stake for eras **13** to **20** is still **350** +/// +/// ### Scenario 3 +/// +/// * current era is **30**, and current period is **2** +/// * period **1** ended after era **24**, and period **2** started in era **25** +/// * `staked` is equal to: `{ voting: 100, build_and_earn: 300, era: 20, period: 1 }` +/// * `staked_future` is equal to `None` +/// +/// The correct way to interpret this is: +/// * in era **20**, account had claimed rewards for the past eras, so only the `staked` entry remained +/// * since then, account hadn't staked, unstaked or claimed rewards +/// * period 1 ended in era **24**, which means that after that era, the `staked` entry is no longer valid +/// * account had staked **400** in total from era **20** up to era **24** (inclusive) +/// * account's stake in era **25** is **zero** +/// #[derive( Encode, Decode, @@ -345,6 +396,7 @@ pub struct AccountLedger> { /// Number of contract stake entries in storage. #[codec(compact)] pub contract_stake_count: u32, + // TODO: rename to staker_info_count? } impl Default for AccountLedger @@ -513,12 +565,12 @@ where /// Ensures that the provided era & period are valid according to the current ledger state. fn stake_unstake_argument_check( &self, - era: EraNumber, + current_era: EraNumber, current_period_info: &PeriodInfo, ) -> Result<(), AccountLedgerError> { if !self.staked.is_empty() { // In case entry for the current era exists, it must match the era exactly. - if self.staked.era != era { + if self.staked.era != current_era { return Err(AccountLedgerError::InvalidEra); } if self.staked.period != current_period_info.number { @@ -526,7 +578,8 @@ where } // In case it doesn't (i.e. first time staking), then the future era must either be the current or the next era. } else if let Some(stake_amount) = self.staked_future { - if stake_amount.era != era.saturating_add(1) && stake_amount.era != era { + if stake_amount.era != current_era.saturating_add(1) && stake_amount.era != current_era + { return Err(AccountLedgerError::InvalidEra); } if stake_amount.period != current_period_info.number { @@ -550,14 +603,14 @@ where pub fn add_stake_amount( &mut self, amount: Balance, - era: EraNumber, + current_era: EraNumber, current_period_info: PeriodInfo, ) -> Result<(), AccountLedgerError> { if amount.is_zero() { return Ok(()); } - self.stake_unstake_argument_check(era, ¤t_period_info)?; + self.stake_unstake_argument_check(current_era, ¤t_period_info)?; if self.stakeable_amount(current_period_info.number) < amount { return Err(AccountLedgerError::UnavailableStakeFunds); @@ -570,7 +623,7 @@ where } None => { let mut stake_amount = self.staked; - stake_amount.era = era.saturating_add(1); + stake_amount.era = current_era.saturating_add(1); stake_amount.period = current_period_info.number; stake_amount.add(amount, current_period_info.subperiod); self.staked_future = Some(stake_amount); @@ -588,14 +641,14 @@ where pub fn unstake_amount( &mut self, amount: Balance, - era: EraNumber, + current_era: EraNumber, current_period_info: PeriodInfo, ) -> Result<(), AccountLedgerError> { if amount.is_zero() { return Ok(()); } - self.stake_unstake_argument_check(era, ¤t_period_info)?; + self.stake_unstake_argument_check(current_era, ¤t_period_info)?; // User must be precise with their unstake amount. if self.staked_amount(current_period_info.number) < amount { @@ -1059,8 +1112,6 @@ pub struct ContractStakeAmount { pub staked: StakeAmount, /// Staked amount in the next or 'future' era. pub staked_future: Option, - /// Tier label for the contract, if any. - pub tier_label: Option, } impl ContractStakeAmount { @@ -1692,10 +1743,15 @@ pub enum DAppTierError { InternalError, } -/// Tier labels can be assigned to dApps in order to provide them benefits (or drawbacks) when being assigned into a tier. -#[derive(Encode, Decode, MaxEncodedLen, Copy, Clone, Debug, PartialEq, Eq, TypeInfo)] -pub enum TierLabel { - // Empty for now, on purpose. +/// Describes which entries are next in line for cleanup. +#[derive(Encode, Decode, MaxEncodedLen, Copy, Clone, Debug, PartialEq, Eq, TypeInfo, Default)] +pub struct CleanupMarker { + /// Era reward span index that should be checked & cleaned up next. + #[codec(compact)] + pub era_reward_index: EraNumber, + /// dApp tier rewards index that should be checked & cleaned up next. + #[codec(compact)] + pub dapp_tiers_index: EraNumber, } /////////////////////////////////////////////////////////////////////// diff --git a/pallets/dapp-staking-v3/src/weights.rs b/pallets/dapp-staking-v3/src/weights.rs index d8891c7115..3b13aa4622 100644 --- a/pallets/dapp-staking-v3/src/weights.rs +++ b/pallets/dapp-staking-v3/src/weights.rs @@ -71,6 +71,7 @@ pub trait WeightInfo { fn on_initialize_build_and_earn_to_voting() -> Weight; fn on_initialize_build_and_earn_to_build_and_earn() -> Weight; fn dapp_tier_assignment(x: u32, ) -> Weight; + fn on_idle_cleanup() -> Weight; } /// Weights for pallet_dapp_staking_v3 using the Substrate node and recommended hardware. @@ -439,6 +440,9 @@ impl WeightInfo for SubstrateWeight { .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(x.into()))) .saturating_add(Weight::from_parts(0, 2073).saturating_mul(x.into())) } + fn on_idle_cleanup() -> Weight { + T::DbWeight::get().reads_writes(3, 2) + } } // For backwards compatibility and tests @@ -806,4 +810,7 @@ impl WeightInfo for () { .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(x.into()))) .saturating_add(Weight::from_parts(0, 2073).saturating_mul(x.into())) } + fn on_idle_cleanup() -> Weight { + RocksDbWeight::get().reads_writes(3, 2) + } } diff --git a/runtime/local/Cargo.toml b/runtime/local/Cargo.toml index 9212158faa..3a258c29c8 100644 --- a/runtime/local/Cargo.toml +++ b/runtime/local/Cargo.toml @@ -82,6 +82,8 @@ pallet-inflation = { workspace = true } pallet-unified-accounts = { workspace = true } pallet-xvm = { workspace = true } +dapp-staking-v3-runtime-api = { workspace = true } + # Moonbeam tracing moonbeam-evm-tracer = { workspace = true, optional = true } moonbeam-rpc-primitives-debug = { workspace = true, optional = true } @@ -122,6 +124,7 @@ std = [ "pallet-chain-extension-unified-accounts/std", "pallet-dapps-staking/std", "pallet-dapp-staking-v3/std", + "dapp-staking-v3-runtime-api/std", "pallet-inflation/std", "pallet-dynamic-evm-base-fee/std", "pallet-ethereum/std", diff --git a/runtime/local/src/lib.rs b/runtime/local/src/lib.rs index dfddfead1d..32c0e47cd7 100644 --- a/runtime/local/src/lib.rs +++ b/runtime/local/src/lib.rs @@ -27,9 +27,8 @@ include!(concat!(env!("OUT_DIR"), "/wasm_binary.rs")); use frame_support::{ construct_runtime, parameter_types, traits::{ - fungible::Unbalanced as FunUnbalanced, AsEnsureOriginWithArg, ConstU128, ConstU32, - ConstU64, Currency, EitherOfDiverse, EqualPrivilegeOnly, FindAuthor, Get, InstanceFilter, - Nothing, OnFinalize, WithdrawReasons, + AsEnsureOriginWithArg, ConstU128, ConstU32, ConstU64, Currency, EitherOfDiverse, + EqualPrivilegeOnly, FindAuthor, Get, InstanceFilter, Nothing, OnFinalize, WithdrawReasons, }, weights::{ constants::{ExtrinsicBaseWeight, RocksDbWeight, WEIGHT_REF_TIME_PER_SECOND}, @@ -500,6 +499,7 @@ impl pallet_dapp_staking_v3::BenchmarkHelper, AccountId } fn set_balance(account: &AccountId, amount: Balance) { + use frame_support::traits::fungible::Unbalanced as FunUnbalanced; Balances::write_balance(account, amount) .expect("Must succeed in test/benchmark environment."); } @@ -1716,6 +1716,20 @@ impl_runtime_apis! { } } + impl dapp_staking_v3_runtime_api::DappStakingApi for Runtime { + fn eras_per_voting_subperiod() -> pallet_dapp_staking_v3::EraNumber { + InflationCycleConfig::eras_per_voting_subperiod() + } + + fn eras_per_build_and_earn_subperiod() -> pallet_dapp_staking_v3::EraNumber { + InflationCycleConfig::eras_per_build_and_earn_subperiod() + } + + fn blocks_per_era() -> BlockNumber { + InflationCycleConfig::blocks_per_era() + } + } + #[cfg(feature = "runtime-benchmarks")] impl frame_benchmarking::Benchmark for Runtime { fn benchmark_metadata(extra: bool) -> (