diff --git a/Cargo.lock b/Cargo.lock
index 5ba5a7b2ac..71cf98c941 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 423cec30c5..e669f8fdd4 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 89ec801e53..8994f55015 100644
--- a/pallets/dapp-staking-v3/src/lib.rs
+++ b/pallets/dapp-staking-v3/src/lib.rs
@@ -64,8 +64,7 @@ mod test;
mod benchmarking;
mod types;
-use types::*;
-pub use types::{PriceProvider, TierThreshold};
+pub use types::*;
pub mod weights;
pub use weights::WeightInfo;
@@ -444,6 +443,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 {
@@ -521,6 +524,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.
@@ -580,7 +587,6 @@ pub mod pallet {
id: dapp_id,
state: DAppState::Registered,
reward_destination: None,
- tier_label: None,
},
);
@@ -1131,9 +1137,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_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.
@@ -1439,6 +1444,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.
///
@@ -1532,7 +1542,7 @@ pub mod pallet {
}
/// Calculates the `EraRewardSpan` index for the specified era.
- pub(crate) fn era_reward_span_index(era: EraNumber) -> EraNumber {
+ pub(crate) fn era_reward_index(era: EraNumber) -> EraNumber {
era.saturating_sub(era % T::EraRewardSpanLength::get())
}
@@ -1829,7 +1839,7 @@ pub mod pallet {
CurrentEraInfo::::put(era_info);
- let era_span_index = Self::era_reward_span_index(current_era);
+ 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.
@@ -1848,5 +1858,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/test/mock.rs b/pallets/dapp-staking-v3/src/test/mock.rs
index 050eb112f2..8be66cd86a 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},
*,
};
@@ -330,6 +330,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..1ed240409e 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;
@@ -1282,7 +1283,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 +1351,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 +1462,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 1a53113ad6..2e97511007 100644
--- a/pallets/dapp-staking-v3/src/test/tests.rs
+++ b/pallets/dapp-staking-v3/src/test/tests.rs
@@ -2368,6 +2368,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);
+ })
+}
+
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
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 3b18c7d7ac..569546ab03 100644
--- a/pallets/dapp-staking-v3/src/types.rs
+++ b/pallets/dapp-staking-v3/src/types.rs
@@ -271,8 +271,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 {
@@ -310,7 +308,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,
@@ -507,12 +558,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 {
@@ -520,7 +571,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 {
@@ -544,14 +596,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);
@@ -564,7 +616,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);
@@ -582,14 +634,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 {
@@ -1053,8 +1105,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 {
@@ -1686,10 +1736,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 f5e318d898..288c5cf6da 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},
@@ -523,6 +522,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.");
}
@@ -1739,6 +1739,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) -> (