From 6b312335a846c32326508f1d2f2419aa51ca26ec Mon Sep 17 00:00:00 2001 From: Igor Papandinas <26460174+ipapandinas@users.noreply.github.com> Date: Fri, 3 Jan 2025 19:37:13 +0100 Subject: [PATCH 1/8] feat: BonusStatus implementation --- pallets/dapp-staking/README.md | 36 +- pallets/dapp-staking/src/lib.rs | 22 +- pallets/dapp-staking/src/test/mock.rs | 5 +- .../dapp-staking/src/test/testing_utils.rs | 81 +++-- pallets/dapp-staking/src/test/tests.rs | 63 +++- pallets/dapp-staking/src/test/tests_types.rs | 308 ++++++++++++++++-- pallets/dapp-staking/src/types.rs | 160 ++++++++- pallets/inflation/src/lib.rs | 2 +- precompiles/dapp-staking/src/lib.rs | 8 +- precompiles/dapp-staking/src/test/mock.rs | 3 +- runtime/astar/src/lib.rs | 3 +- runtime/local/src/lib.rs | 7 +- runtime/shibuya/src/lib.rs | 3 +- runtime/shiden/src/lib.rs | 5 +- tests/xcm-simulator/src/mocks/parachain.rs | 5 +- 15 files changed, 604 insertions(+), 107 deletions(-) diff --git a/pallets/dapp-staking/README.md b/pallets/dapp-staking/README.md index b78827acde..2857ff22e8 100644 --- a/pallets/dapp-staking/README.md +++ b/pallets/dapp-staking/README.md @@ -21,8 +21,9 @@ After an era ends, it's usually possible to claim rewards for it, if user or dAp Periods are another _time unit_ in dApp staking. They are expected to be more lengthy than eras. Each period consists of two subperiods: -* `Voting` -* `Build&Earn` + +- `Voting` +- `Build&Earn` 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. @@ -41,8 +42,9 @@ Casting a vote, or staking, during the `Voting` subperiod makes the staker eligi `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 + +- Block 1, Era 1 starts, Period 1 starts, `Voting` subperiod starts +- Block 501, Era 2 starts, Period 1 continues, `Build&Earn` subperiod starts Neither stakers nor dApps earn rewards during this subperiod - no new rewards are generated after `voting` subperiod ends. @@ -56,14 +58,15 @@ It is still possible to _stake_ during this period, and stakers are encouraged t The only exemption is the **final era** of the `build&earn` subperiod - it's not possible to _stake_ then since the stake would be invalid anyhow (stake is only valid from the next era which would be in the next period). To continue the previous example where era length is **100** blocks, let's assume that `Build&Earn` subperiod lasts for 10 eras: -* Block 1, Era 1 starts, Period 1 starts, `Voting` subperiod starts -* Block 501, Era 2 starts, Period 1 continues, `Build&Earn` subperiod starts -* Block 601, Era 3 starts, Period 1 continues, `Build&Earn` subperiod continues -* Block 701, Era 4 starts, Period 1 continues, `Build&Earn` subperiod continues -* ... -* Block 1401, Era 11 starts, Period 1 continues, `Build&Earn` subperiod enters the final era -* Block 1501, Era 12 starts, Period 2 starts, `Voting` subperiod starts -* Block 2001, Era 13 starts, Period 2 continues, `Build&Earn` subperiod starts + +- Block 1, Era 1 starts, Period 1 starts, `Voting` subperiod starts +- Block 501, Era 2 starts, Period 1 continues, `Build&Earn` subperiod starts +- Block 601, Era 3 starts, Period 1 continues, `Build&Earn` subperiod continues +- Block 701, Era 4 starts, Period 1 continues, `Build&Earn` subperiod continues +- ... +- Block 1401, Era 11 starts, Period 1 continues, `Build&Earn` subperiod enters the final era +- Block 1501, Era 12 starts, Period 2 starts, `Voting` subperiod starts +- Block 2001, Era 13 starts, Period 2 continues, `Build&Earn` subperiod starts ### dApps & Smart Contracts @@ -137,11 +140,14 @@ User's stake on a contract must be equal or greater than the `MinimumStakeAmount Although user can stake on multiple smart contracts, the amount is limited. To be more precise, amount of database entries that can exist per user is limited. -The protocol keeps track of how much was staked by the user in `voting` and `build&earn` subperiod. This is important for the bonus reward calculation. +The protocol keeps track of how much was staked by the user in `voting` and `build&earn` subperiod. This is important for the bonus reward calculation. Only a limited number of _move actions_ are allowed during the `build&earn` subperiod to preserve bonus reward elegibility. _Move actions_ refer either to: + +- a 'partial unstake with voting stake decrease', +- a 'stake transfer between two contracts'. It is not possible to stake on a dApp that has been unregistered. However, if dApp is unregistered after user has staked on it, user will keep earning -rewards for the staked amount. +rewards for the staked amount, or can 'move' his stake without impacting his number of allowed 'move actions' for the ongoing period. #### Unstaking Tokens @@ -240,4 +246,4 @@ 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 +If they don't do that, they miss out on the bonus reward & won't earn staker rewards. diff --git a/pallets/dapp-staking/src/lib.rs b/pallets/dapp-staking/src/lib.rs index d96ff175bc..932f79291c 100644 --- a/pallets/dapp-staking/src/lib.rs +++ b/pallets/dapp-staking/src/lib.rs @@ -211,6 +211,12 @@ pub mod pallet { #[pallet::constant] type RankingEnabled: Get; + /// The maximum number of 'move actions' allowed within a single period while + /// retaining eligibility for bonus rewards. Exceeding this limit will result in the + /// forfeiture of the bonus rewards for the affected stake. + #[pallet::constant] + type MaxBonusMovesPerPeriod: Get + Default + Debug; + /// Weight info for various calls & operations in the pallet. type WeightInfo: WeightInfo; @@ -290,7 +296,7 @@ pub mod pallet { era: EraNumber, amount: Balance, }, - /// Bonus reward has been paid out to a loyal staker. + /// Bonus reward has been paid out to a 'loyal' staker. BonusReward { account: T::AccountId, smart_contract: T::SmartContract, @@ -429,7 +435,7 @@ pub mod pallet { T::AccountId, Blake2_128Concat, T::SmartContract, - SingularStakingInfo, + SingularStakingInfoFor, OptionQuery, >; @@ -1069,7 +1075,7 @@ pub mod pallet { // 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() => + && staking_info.has_bonus() => { return Err(Error::::UnclaimedRewards.into()); } @@ -1391,8 +1397,8 @@ pub mod pallet { /// 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. + /// 1. It's from a past period & the account has NO BONUS reward. + /// 2. It's from a period older than the oldest claimable period, regardless whether the account was has bonus reward or not. #[pallet::call_index(17)] #[pallet::weight(T::WeightInfo::cleanup_expired_entries( T::MaxNumberOfStakedContracts::get() @@ -1409,7 +1415,7 @@ pub mod pallet { // 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() + if stake_info.period_number() < current_period && !stake_info.has_bonus() || stake_info.period_number() < threshold_period { Some(smart_contract) @@ -2159,7 +2165,7 @@ pub mod pallet { // Ensure: // 1. Period for which rewards are being claimed has ended. - // 2. Account has been a loyal staker. + // 2. Account is eligible to bonus rewards. // 3. Rewards haven't expired. let staked_period = staker_info.period_number(); ensure!( @@ -2167,7 +2173,7 @@ pub mod pallet { Error::::NoClaimableRewards ); ensure!( - staker_info.is_loyal(), + staker_info.has_bonus(), Error::::NotEligibleForBonusReward ); ensure!( diff --git a/pallets/dapp-staking/src/test/mock.rs b/pallets/dapp-staking/src/test/mock.rs index 1a1b1532cd..a84f09e156 100644 --- a/pallets/dapp-staking/src/test/mock.rs +++ b/pallets/dapp-staking/src/test/mock.rs @@ -26,7 +26,9 @@ use frame_support::{ construct_runtime, derive_impl, migrations::MultiStepMigrator, ord_parameter_types, parameter_types, - traits::{fungible::Mutate as FunMutate, ConstBool, ConstU128, ConstU32, EitherOfDiverse}, + traits::{ + fungible::Mutate as FunMutate, ConstBool, ConstU128, ConstU32, ConstU8, EitherOfDiverse, + }, weights::Weight, }; use sp_arithmetic::fixed_point::FixedU128; @@ -258,6 +260,7 @@ impl pallet_dapp_staking::Config for Test { type MinimumStakeAmount = ConstU128<3>; type NumberOfTiers = ConstU32<4>; type RankingEnabled = ConstBool; + type MaxBonusMovesPerPeriod = ConstU8<2>; type WeightInfo = weights::SubstrateWeight; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = BenchmarkHelper; diff --git a/pallets/dapp-staking/src/test/testing_utils.rs b/pallets/dapp-staking/src/test/testing_utils.rs index 018593e6b3..c4c7168f97 100644 --- a/pallets/dapp-staking/src/test/testing_utils.rs +++ b/pallets/dapp-staking/src/test/testing_utils.rs @@ -19,9 +19,9 @@ use crate::test::mock::*; use crate::types::*; use crate::{ - pallet::Config, ActiveProtocolState, ContractStake, CurrentEraInfo, DAppId, DAppTiers, - EraRewards, Event, FreezeReason, HistoryCleanupMarker, IntegratedDApps, Ledger, NextDAppId, - PeriodEnd, PeriodEndInfo, StakerInfo, + pallet::Config, ActiveProtocolState, BonusStatusFor, ContractStake, CurrentEraInfo, DAppId, + DAppTiers, EraRewards, Event, FreezeReason, HistoryCleanupMarker, IntegratedDApps, Ledger, + NextDAppId, PeriodEnd, PeriodEndInfo, StakerInfo, }; use frame_support::{ @@ -54,7 +54,7 @@ pub(crate) struct MemorySnapshot { ::AccountId, ::SmartContract, ), - SingularStakingInfo, + SingularStakingInfo<::MaxBonusMovesPerPeriod>, >, contract_stake: HashMap, era_rewards: HashMap::EraRewardSpanLength>>, @@ -550,9 +550,10 @@ pub(crate) fn assert_stake( ); assert_eq!(post_staker_info.period_number(), stake_period); assert_eq!( - post_staker_info.is_loyal(), - pre_staker_info.is_loyal(), - "Staking operation mustn't change loyalty flag." + post_staker_info.has_bonus(), + pre_staker_info.has_bonus(), + "Staking operation mustn't change bonus reward + eligibility." ); } // A new entry is created. @@ -570,7 +571,7 @@ pub(crate) fn assert_stake( ); assert_eq!(post_staker_info.period_number(), stake_period); assert_eq!( - post_staker_info.is_loyal(), + post_staker_info.has_bonus(), stake_subperiod == Subperiod::Voting ); } @@ -713,20 +714,42 @@ pub(crate) fn assert_unstake( "Staked amount must decrease by the 'amount'" ); - let is_loyal = pre_staker_info.is_loyal() - && match unstake_subperiod { - Subperiod::Voting => !post_staker_info.staked_amount(Subperiod::Voting).is_zero(), - Subperiod::BuildAndEarn => { - post_staker_info.staked_amount(Subperiod::Voting) - == pre_staker_info.staked_amount(Subperiod::Voting) - } - }; + let should_keep_bonus = if pre_staker_info.has_bonus() { + match pre_staker_info.bonus_status { + BonusStatus::SafeMovesRemaining(remaining_moves) if remaining_moves > 0 => true, + _ => match unstake_subperiod { + Subperiod::Voting => { + !post_staker_info.staked_amount(Subperiod::Voting).is_zero() + } + Subperiod::BuildAndEarn => { + post_staker_info.staked_amount(Subperiod::Voting) + == pre_staker_info.staked_amount(Subperiod::Voting) + } + }, + } + } else { + false + }; assert_eq!( - post_staker_info.is_loyal(), - is_loyal, - "If 'Voting' stake amount is reduced in B&E period, loyalty flag must be set to false." + post_staker_info.has_bonus(), + should_keep_bonus, + "If 'voting stake' amount is fully unstaked in Voting subperiod or reduced in B&E subperiod, 'BonusStatus' must reflect this." ); + + if unstake_subperiod == Subperiod::BuildAndEarn + && pre_staker_info.has_bonus() + && post_staker_info.staked_amount(Subperiod::Voting) + < pre_staker_info.staked_amount(Subperiod::Voting) + { + let mut bonus_status_clone = pre_staker_info.bonus_status.clone(); + bonus_status_clone.decrease_moves(); + + assert_eq!( + post_staker_info.bonus_status, bonus_status_clone, + "'BonusStatus' must correctly decrease moves when 'voting stake' is reduced in B&E subperiod." + ); + } } let unstaked_amount_era_pairs = @@ -828,6 +851,24 @@ pub(crate) fn assert_unstake( } } +/// Assert the bonus status of a staker for a specific smart contract. +pub(crate) fn assert_bonus_status( + account: AccountId, + smart_contract: &MockSmartContract, + expected_bonus_status: BonusStatusFor, +) { + let snapshot = MemorySnapshot::new(); + let staker_info = snapshot + .staker_info + .get(&(account, *smart_contract)) + .expect("Staker info entry must exist to verify bonus status."); + + assert_eq!( + staker_info.bonus_status, expected_bonus_status, + "The staker's bonus status does not match the expected value." + ); +} + /// Claim staker rewards. pub(crate) fn assert_claim_staker_rewards(account: AccountId) { let pre_snapshot = MemorySnapshot::new(); @@ -1195,7 +1236,7 @@ pub(crate) fn assert_cleanup_expired_entries(account: AccountId) { .iter() .for_each(|((inner_account, contract), entry)| { if *inner_account == account { - if entry.period_number() < current_period && !entry.is_loyal() + if entry.period_number() < current_period && !entry.has_bonus() || entry.period_number() < threshold_period { to_be_deleted.push(contract); diff --git a/pallets/dapp-staking/src/test/tests.rs b/pallets/dapp-staking/src/test/tests.rs index f3014e326f..3f01012d6d 100644 --- a/pallets/dapp-staking/src/test/tests.rs +++ b/pallets/dapp-staking/src/test/tests.rs @@ -18,10 +18,10 @@ use crate::test::{mock::*, testing_utils::*}; use crate::{ - pallet::Config, ActiveProtocolState, ContractStake, DAppId, DAppTierRewardsFor, DAppTiers, - EraRewards, Error, Event, ForcingType, GenesisConfig, IntegratedDApps, Ledger, NextDAppId, - Perbill, PeriodNumber, Permill, Safeguard, StakerInfo, StaticTierParams, Subperiod, TierConfig, - TierThreshold, + pallet::Config, ActiveProtocolState, BonusStatus, ContractStake, DAppId, DAppTierRewardsFor, + DAppTiers, EraRewards, Error, Event, ForcingType, GenesisConfig, IntegratedDApps, Ledger, + NextDAppId, Perbill, PeriodNumber, Permill, Safeguard, StakerInfo, StaticTierParams, Subperiod, + TierConfig, TierThreshold, }; use frame_support::{ @@ -1246,7 +1246,7 @@ fn stake_fails_due_to_too_many_staked_contracts() { let account = 1; assert_lock(account, 100 as Balance * max_number_of_contracts as Balance); - // Advance to build&earn subperiod so we ensure non-loyal staking + // Advance to build&earn subperiod so we ensure 'non-loyal' staking advance_to_next_subperiod(); // Register smart contracts up to the max allowed number @@ -2147,10 +2147,10 @@ fn cleanup_expired_entries_is_ok() { // Scenario: // - 1st contract will be staked in the period that expires due to exceeded reward retention - // - 2nd contract will be staked in the period on the edge of expiry, with loyalty flag - // - 3rd contract will be be staked in the period on the edge of expiry, without loyalty flag - // - 4th contract will be staked in the period right before the current one, with loyalty flag - // - 5th contract will be staked in the period right before the current one, without loyalty flag + // - 2nd contract will be staked in the period on the edge of expiry, with bonus elegibility + // - 3rd contract will be be staked in the period on the edge of expiry, without bonus elegibility + // - 4th contract will be staked in the period right before the current one, with bonus elegibility + // - 5th contract will be staked in the period right before the current one, without bonus elegibility // // Expectation: 1, 3, 5 should be removed, 2 & 4 should remain @@ -2926,6 +2926,51 @@ fn stake_after_period_ends_with_max_staked_contracts() { }) } +#[test] +fn stake_after_period_ends_reset_bonus_status_is_ok() { + ExtBuilder::default().build_and_execute(|| { + let max_bonus_moves: u8 = ::MaxBonusMovesPerPeriod::get(); + + // Phase 1: Register smart contract, lock&stake some amount + let dev_account = 1; + let smart_contract = MockSmartContract::wasm(1 as AccountId); + assert_register(dev_account, &smart_contract); + + let account = 2; + let amount = 400; + let partial_unstake_amount = 100; + assert_lock(account, amount); + assert_stake(account, &smart_contract, amount - partial_unstake_amount); + + // Phase 2: Advance to B&E subperiod, we ensure 'bonus safe moves' remaining is decreased with a partial unstake (overflowing 'voting' stake) + advance_to_next_subperiod(); + assert_unstake(account, &smart_contract, partial_unstake_amount); + + if max_bonus_moves == 0 { + let expected_bonus_status = BonusStatus::BonusForfeited; + assert_bonus_status(account, &smart_contract, expected_bonus_status); + } else { + let expected_bonus_status = BonusStatus::SafeMovesRemaining(max_bonus_moves - 1); + assert_bonus_status(account, &smart_contract, expected_bonus_status); + } + + // Phase 3: Advance to the next period, claim rewards + advance_to_next_period(); + for _ in 0..required_number_of_reward_claims(account) { + assert_claim_staker_rewards(account); + } + + if max_bonus_moves > 0 { + assert_claim_bonus_reward(account, &smart_contract); + } + + // Phase 4: Restake and verify BonusStatus reset + assert_stake(account, &smart_contract, partial_unstake_amount); + let default_bonus_status = BonusStatus::default(); + assert_bonus_status(account, &smart_contract, default_bonus_status); + }) +} + #[test] fn post_unlock_balance_cannot_be_transferred() { ExtBuilder::default().build_and_execute(|| { diff --git a/pallets/dapp-staking/src/test/tests_types.rs b/pallets/dapp-staking/src/test/tests_types.rs index 94ca8f0c09..eff1bb4a3c 100644 --- a/pallets/dapp-staking/src/test/tests_types.rs +++ b/pallets/dapp-staking/src/test/tests_types.rs @@ -39,6 +39,19 @@ macro_rules! get_u32_type { }; } +// Helper to generate custom `Get` types for testing the `BonusStatus` enum. +macro_rules! get_u8_type { + ($struct_name:ident, $value:expr) => { + #[derive(Encode, Decode, MaxEncodedLen, Clone, Copy, Debug, PartialEq, Eq, TypeInfo)] + struct $struct_name; + impl Get for $struct_name { + fn get() -> u8 { + $value + } + } + }; +} + #[test] fn subperiod_sanity_check() { assert_eq!(Subperiod::Voting.next(), Subperiod::BuildAndEarn); @@ -2010,21 +2023,175 @@ fn stake_amount_works() { assert!(stake_amount.for_type(Subperiod::BuildAndEarn).is_zero()); } +#[test] +fn default_bonus_status_works() { + get_u8_type!(MaxMoves, 2); + type TestBonusStatus = BonusStatus; + let default_value = TestBonusStatus::default(); + + assert_eq!( + default_value, + BonusStatus::SafeMovesRemaining(MaxMoves::get()), + "Default should use SafeMovesRemaining(MaxMoves) value" + ) +} + +#[test] +fn bonus_status_decrease_works() { + get_u8_type!(MaxMoves, 1); + type TestBonusStatus = BonusStatus; + let mut bonus_status = TestBonusStatus::default(); + + bonus_status.decrease_moves(); + assert_eq!( + bonus_status, + BonusStatus::SafeMovesRemaining(0), + "Safe moves must be decreased by one" + ); + + bonus_status.decrease_moves(); + assert_eq!( + bonus_status, + BonusStatus::BonusForfeited, + "Bonus must be forfeited" + ); + + // Decreasing one more time has no effet, bonus is already forfeited + bonus_status.decrease_moves(); + assert_eq!( + bonus_status, + BonusStatus::BonusForfeited, + "Bonus must be forfeited" + ); +} + +#[test] +fn singular_staking_encoding_decoding_works() { + get_u8_type!(MaxMoves, 2); + type TestSingularStakingInfo = SingularStakingInfo; + + let staking_info = TestSingularStakingInfo::new(0, Subperiod::Voting); + let encoded = staking_info.encode(); + let expected_encoded = vec![ + 0x00, 0x00, 0x00, 0x00, // previous_staked (StakeAmount default) + 0x00, 0x00, 0x00, 0x00, // staked (StakeAmount default) + 0x01, 0x02, // SafeMovesRemaining(2) + ]; + + assert_eq!(encoded, expected_encoded); + + let decoded: TestSingularStakingInfo = + Decode::decode(&mut &encoded[..]).expect("Decoding should succeed"); + + assert_eq!(decoded.bonus_status, BonusStatus::SafeMovesRemaining(2)); + + let staking_info_no_bonus = TestSingularStakingInfo::new(0, Subperiod::BuildAndEarn); + let encoded_no_bonus = staking_info_no_bonus.encode(); + let expected_encoded_no_bonus = vec![ + 0x00, 0x00, 0x00, 0x00, // previous_staked (StakeAmount default) + 0x00, 0x00, 0x00, 0x00, // staked (StakeAmount default) + 0x00, // BonusForfeited + ]; + + assert_eq!(encoded_no_bonus, expected_encoded_no_bonus); + + let decoded_no_bonus: TestSingularStakingInfo = + Decode::decode(&mut &encoded_no_bonus[..]).expect("Decoding should succeed"); + + assert_eq!(decoded_no_bonus.bonus_status, BonusStatus::BonusForfeited); +} + +#[test] +fn singular_staking_decoding_incorrect_moves_fails() { + get_u8_type!(MaxMoves, 5); + type TestSingularStakingInfo = SingularStakingInfo; + + let invalid_encoded_moves = vec![ + 0x00, 0x00, 0x00, 0x00, // previous_staked (StakeAmount default) + 0x00, 0x00, 0x00, 0x00, // staked (StakeAmount default) + 0x01, 0x06, // 6 moves (out of range for MaxMoves = 2) + ]; + + let decoded = TestSingularStakingInfo::decode(&mut &invalid_encoded_moves[..]); + assert!(decoded.is_err()); +} + +#[test] +fn legacy_singular_staking_format_decoding_works() { + get_u8_type!(MaxMoves, 2); + type TestSingularStakingInfo = SingularStakingInfo; + + let legacy_encoded_false: Vec = vec![ + 0x00, 0x00, 0x00, 0x00, // previous_staked (StakeAmount default) + 0x00, 0x00, 0x00, 0x00, // staked (StakeAmount default) + 0x00, // loyal_staker = false + ]; + + let decoded: TestSingularStakingInfo = + Decode::decode(&mut &legacy_encoded_false[..]).expect("Decoding should succeed"); + + assert_eq!(decoded.bonus_status, BonusStatus::BonusForfeited); + + let legacy_encoded_true: Vec = vec![ + 0x00, 0x00, 0x00, 0x00, // previous_staked (StakeAmount default) + 0x00, 0x00, 0x00, 0x00, // staked (StakeAmount default) + 0x01, // loyal_staker = true + ]; + + let decoded: TestSingularStakingInfo = + Decode::decode(&mut &legacy_encoded_true[..]).expect("Decoding should succeed"); + + assert_eq!(decoded.bonus_status, BonusStatus::SafeMovesRemaining(0)); + + let legacy_encoded_false_with_extra_byte: Vec = vec![ + 0x00, 0x00, 0x00, 0x00, // previous_staked (StakeAmount default) + 0x00, 0x00, 0x00, 0x00, // staked (StakeAmount default) + 0x00, // loyal_staker = false + 0x01, // useless extra byte + ]; + + let decoded: TestSingularStakingInfo = + Decode::decode(&mut &legacy_encoded_false_with_extra_byte[..]) + .expect("Decoding should succeed"); + + assert_eq!(decoded.bonus_status, BonusStatus::BonusForfeited); +} + +#[test] +fn legacy_singular_staking_incorrect_format_decoding_fails() { + get_u8_type!(MaxMoves, 2); + type TestSingularStakingInfo = SingularStakingInfo; + + let encoded_empty: Vec = vec![]; + let decoded = TestSingularStakingInfo::decode(&mut &encoded_empty[..]); + assert!(decoded.is_err()); + + let legacy_encoded_incomplete: Vec = vec![ + 0x00, 0x00, 0x00, 0x00, // previous_staked (StakeAmount default) + 0x00, 0x00, 0x00, 0x00, // staked (StakeAmount default) + // missing byte + ]; + let decoded = TestSingularStakingInfo::decode(&mut &legacy_encoded_incomplete[..]); + assert!(decoded.is_err()); +} + #[test] fn singular_staking_info_basics_are_ok() { + get_u8_type!(MaxMoves, 0); + type TestSingularStakingInfo = SingularStakingInfo; let period_number = 3; let subperiod = Subperiod::Voting; - let mut staking_info = SingularStakingInfo::new(period_number, subperiod); + let mut staking_info = TestSingularStakingInfo::new(period_number, subperiod); // Sanity checks assert_eq!(staking_info.period_number(), period_number); - assert!(staking_info.is_loyal()); + assert!(staking_info.has_bonus()); assert!(staking_info.total_staked_amount().is_zero()); assert!(staking_info.is_empty()); assert!(staking_info.era().is_zero()); - assert!(!SingularStakingInfo::new(period_number, Subperiod::BuildAndEarn).is_loyal()); + assert!(!TestSingularStakingInfo::new(period_number, Subperiod::BuildAndEarn).has_bonus()); - // Add some staked amount during `Voting` period + // Add some staked amount during `Voting` subperiod let era_1 = 7; let vote_stake_amount_1 = 11; staking_info.stake(vote_stake_amount_1, era_1, Subperiod::Voting); @@ -2070,16 +2237,19 @@ fn singular_staking_info_basics_are_ok() { #[test] fn singular_staking_info_unstake_during_voting_is_ok() { + get_u8_type!(MaxMoves, 1); + type TestSingularStakingInfo = SingularStakingInfo; let period_number = 3; let subperiod = Subperiod::Voting; - let mut staking_info = SingularStakingInfo::new(period_number, subperiod); + let mut staking_info = TestSingularStakingInfo::new(period_number, subperiod); // Prep actions let era_1 = 2; let vote_stake_amount_1 = 11; staking_info.stake(vote_stake_amount_1, era_1, Subperiod::Voting); + let bonus_status_snapshot = staking_info.bonus_status; - // 1. Unstake some amount during `Voting` period, loyalty should remain as expected. + // 1. Unstake some amount during `Voting` period, bonus status should remain as expected. let unstake_amount_1 = 5; assert_eq!( staking_info.unstake(unstake_amount_1, era_1, Subperiod::Voting), @@ -2089,7 +2259,11 @@ fn singular_staking_info_unstake_during_voting_is_ok() { staking_info.total_staked_amount(), vote_stake_amount_1 - unstake_amount_1 ); - assert!(staking_info.is_loyal()); + assert_eq!( + staking_info.bonus_status, bonus_status_snapshot, + "Bonus should remain unchanged with max safe moves preserved." + ); + assert!(staking_info.bonus_status.has_bonus()); assert_eq!( staking_info.era(), era_1 + 1, @@ -2099,7 +2273,7 @@ fn singular_staking_info_unstake_during_voting_is_ok() { assert!(staking_info.previous_staked.is_empty()); assert!(staking_info.previous_staked.era.is_zero()); - // 2. Fully unstake, attempting to underflow, and ensure loyalty flag has been removed. + // 2. Fully unstake, attempting to underflow, and ensure bonus is forfeited. let era_2 = era_1 + 2; let remaining_stake = staking_info.total_staked_amount(); assert_eq!( @@ -2109,8 +2283,8 @@ fn singular_staking_info_unstake_during_voting_is_ok() { ); assert!(staking_info.total_staked_amount().is_zero()); assert!( - !staking_info.is_loyal(), - "Loyalty flag should have been removed since it was full unstake." + !staking_info.has_bonus(), + "Bonus should have been forfeited since it was full unstake." ); assert!(staking_info.era().is_zero()); @@ -2120,9 +2294,18 @@ fn singular_staking_info_unstake_during_voting_is_ok() { #[test] fn singular_staking_info_unstake_during_bep_is_ok() { + get_u8_type!(MaxMoves, 1); + type TestSingularStakingInfo = SingularStakingInfo; let period_number = 3; let subperiod = Subperiod::Voting; - let mut staking_info = SingularStakingInfo::new(period_number, subperiod); + let mut staking_info = TestSingularStakingInfo::new(period_number, subperiod); + + // Sanity check + assert_eq!( + staking_info.bonus_status, + BonusStatus::SafeMovesRemaining(1), + "Sanity check to cover all scenarios.", + ); // Prep actions let era_1 = 3; @@ -2153,7 +2336,12 @@ fn singular_staking_info_unstake_during_bep_is_ok() { staking_info.staked_amount(Subperiod::BuildAndEarn), bep_stake_amount_1 - unstake_1 ); - assert!(staking_info.is_loyal()); + assert_eq!( + staking_info.bonus_status, + BonusStatus::SafeMovesRemaining(1), + "Bonus should remain unchanged with max safe moves preserved." + ); + assert!(staking_info.has_bonus()); assert_eq!( staking_info.era(), era_1 + 1, @@ -2190,6 +2378,11 @@ fn singular_staking_info_unstake_during_bep_is_ok() { staking_info.staked_amount(Subperiod::BuildAndEarn), bep_stake_amount_1 + bep_stake_amount_2 - unstake_1 - unstake_2 ); + assert_eq!( + staking_info.bonus_status, + BonusStatus::SafeMovesRemaining(1), + "Bonus should remain unchanged with max safe moves preserved." + ); assert_eq!( staking_info.previous_staked.total(), @@ -2197,7 +2390,7 @@ fn singular_staking_info_unstake_during_bep_is_ok() { ); assert_eq!(staking_info.previous_staked.era, era_1); - // 3rd scenario - unstake all of the amount staked during B&E period, and then some more. + // 3rd scenario - unstake all of the amount staked during B&E subperiod, and then some more. // The point is to take a chunk from the voting subperiod stake too. let current_total_stake = staking_info.total_staked_amount(); let current_bep_stake = staking_info.staked_amount(Subperiod::BuildAndEarn); @@ -2210,6 +2403,13 @@ fn singular_staking_info_unstake_during_bep_is_ok() { vec![(era_2, unstake_2), (era_2 + 1, unstake_2)], "Also chipping away from the next era since the unstake is relevant to the ongoing era." ); + + assert_eq!( + staking_info.bonus_status, + BonusStatus::SafeMovesRemaining(0), + "Bonus status moves counter should have been decreased to 0." + ); + assert!(staking_info.has_bonus(), "Bonus should have been preserved since it is the first partial unstake from the 'voting subperiod' stake"); assert_eq!( staking_info.total_staked_amount(), current_total_stake - unstake_2 @@ -2221,27 +2421,45 @@ fn singular_staking_info_unstake_during_bep_is_ok() { assert!(staking_info .staked_amount(Subperiod::BuildAndEarn) .is_zero()); - assert!( - !staking_info.is_loyal(), - "Loyalty flag should have been removed due to non-zero voting subperiod unstake" - ); assert_eq!(staking_info.era(), era_2); assert_eq!(staking_info.previous_staked.total(), current_total_stake); assert_eq!(staking_info.previous_staked.era, era_2 - 1); + + // 4th scenario - Bonus forfeited + // Fully exhaust the bonus by performing another unstake during the B&E subperiod + let era_3 = era_2 + 2; + let unstake_3 = 5; + + assert_eq!( + staking_info.unstake(unstake_3, era_3, Subperiod::BuildAndEarn), + vec![(era_3, unstake_3), (era_3 + 1, unstake_3)] + ); + assert_eq!( + staking_info.bonus_status, + BonusStatus::BonusForfeited, + "Bonus should be forfeited after exhausting all safe moves." + ); + assert!( + !staking_info.has_bonus(), + "Bonus should no longer be active." + ); + assert_eq!(staking_info.era(), era_3); } #[test] fn singular_staking_info_unstake_era_amount_pairs_are_ok() { + get_u8_type!(MaxMoves, 1); + type TestSingularStakingInfo = SingularStakingInfo; let period_number = 1; let subperiod = Subperiod::BuildAndEarn; // 1. Unstake only reduces the amount from a the future era { - let era = 3; + let era: u32 = 3; let stake_amount = 13; let unstake_amount = 3; - let mut staking_info = SingularStakingInfo::new(period_number, subperiod); + let mut staking_info = TestSingularStakingInfo::new(period_number, subperiod); staking_info.stake(stake_amount, era, Subperiod::BuildAndEarn); assert_eq!( @@ -2255,7 +2473,7 @@ fn singular_staking_info_unstake_era_amount_pairs_are_ok() { let era = 3; let stake_amount = 17; let unstake_amount = 5; - let mut staking_info = SingularStakingInfo::new(period_number, subperiod); + let mut staking_info = TestSingularStakingInfo::new(period_number, subperiod); staking_info.stake(stake_amount, era, Subperiod::BuildAndEarn); assert_eq!( @@ -2272,7 +2490,7 @@ fn singular_staking_info_unstake_era_amount_pairs_are_ok() { let era = 3; let stake_amount = 17; let unstake_amount = 5; - let mut staking_info = SingularStakingInfo::new(period_number, subperiod); + let mut staking_info = TestSingularStakingInfo::new(period_number, subperiod); staking_info.stake(stake_amount, era, Subperiod::BuildAndEarn); assert_eq!( @@ -2284,6 +2502,54 @@ fn singular_staking_info_unstake_era_amount_pairs_are_ok() { } } +#[test] +fn bonus_status_transition_between_subperiods_is_ok() { + get_u8_type!(MaxMoves, 1); + type TestSingularStakingInfo = SingularStakingInfo; + let mut staking_info = TestSingularStakingInfo::new(1, Subperiod::Voting); + staking_info.staked.voting = 15; + + // Sanity check + assert_eq!( + staking_info.bonus_status, + BonusStatus::SafeMovesRemaining(1), + "Sanity check to cover all scenarios.", + ); + + // First unstake in Voting subperiod + staking_info.update_bonus_status(Subperiod::Voting, 10); + assert_eq!( + staking_info.bonus_status, + BonusStatus::SafeMovesRemaining(1), + "Unstaking during Voting subperiod should not decrement safe moves if voting stake remains." + ); + + // Then unstake in B&E subperiod + staking_info.update_bonus_status(Subperiod::BuildAndEarn, 20); + assert_eq!( + staking_info.bonus_status, + BonusStatus::SafeMovesRemaining(0), + "Safe moves should decrement when unstaking in B&E subperiod." + ); +} + +#[test] +fn bonus_status_no_change_on_exact_match() { + get_u8_type!(MaxMoves, 2); + type TestSingularStakingInfo = SingularStakingInfo; + + let mut staking_info = TestSingularStakingInfo::new(1, Subperiod::Voting); + staking_info.bonus_status = BonusStatus::SafeMovesRemaining(2); + staking_info.staked.voting = 15; + + staking_info.update_bonus_status(Subperiod::BuildAndEarn, 15); + assert_eq!( + staking_info.bonus_status, + BonusStatus::SafeMovesRemaining(2), + "Safe moves should not decrement when voting stake matches the reference amount." + ); +} + #[test] fn contract_stake_amount_basic_get_checks_work() { // Sanity checks for empty struct diff --git a/pallets/dapp-staking/src/types.rs b/pallets/dapp-staking/src/types.rs index 8cad803274..dcaecaba4b 100644 --- a/pallets/dapp-staking/src/types.rs +++ b/pallets/dapp-staking/src/types.rs @@ -47,7 +47,7 @@ //! * `UnlockingChunk` - describes some amount undergoing the unlocking process. //! * `StakeAmount` - contains information about the staked amount in a particular era, and period. //! * `AccountLedger` - keeps track of total locked & staked balance, unlocking chunks and number of stake entries. -//! * `SingularStakingInfo` - contains information about a particular staker's stake on a specific smart contract. Used to track loyalty. +//! * `SingularStakingInfo` - contains information about a particular staker's stake on a specific smart contract. Used to track bonus reward elegibility. //! //! ## Era Information //! @@ -84,6 +84,12 @@ use crate::pallet::Config; // Convenience type for `AccountLedger` usage. pub type AccountLedgerFor = AccountLedger<::MaxUnlockingChunks>; +// Convenience type for `SingularStakingInfo` usage. +pub type SingularStakingInfoFor = SingularStakingInfo<::MaxBonusMovesPerPeriod>; + +// Convenience type for `BonusStatus` usage. +pub type BonusStatusFor = BonusStatus<::MaxBonusMovesPerPeriod>; + // Convenience type for `DAppTierRewards` usage. pub type DAppTierRewardsFor = DAppTierRewards<::MaxNumberOfContracts, ::NumberOfTiers>; @@ -993,20 +999,77 @@ impl EraInfo { } } +/// Bonus status to track remaining 'voting stake' safe move actions in the ongoing B&E subperiod. +/// Move actions during B&E refer either to: +/// - a 'partial unstake with voting stake decrease', +/// - a 'stake transfer between two contracts'. +#[derive(Encode, Decode, MaxEncodedLen, Copy, Clone, Debug, TypeInfo)] +#[scale_info(skip_type_params(MaxBonusMoves))] +pub enum BonusStatus> { + /// Bonus rewards are forfeited. + BonusForfeited, + /// Bonus rewards are preserved with a value which is the number of remaining 'safe moves' allowed in the ongoing B&E subperiod. + SafeMovesRemaining(u8), + #[codec(skip)] + _Phantom(PhantomData), +} + +impl> Default for BonusStatus { + fn default() -> Self { + let max = MaxBonusMoves::get(); + BonusStatus::SafeMovesRemaining(max) + } +} + +impl> BonusStatus { + /// Decrease the number of remaining safe moves by 1. + /// If the counter reaches 0, the bonus is forfeited. + pub fn decrease_moves(&mut self) { + *self = match self { + BonusStatus::SafeMovesRemaining(counter) => { + if *counter == 0 { + BonusStatus::BonusForfeited + } else { + BonusStatus::SafeMovesRemaining(counter.saturating_sub(1)) + } + } + _ => BonusStatus::BonusForfeited, + } + } + + /// Check if bonus rewards are preserved. + pub fn has_bonus(&self) -> bool { + matches!(self, BonusStatus::SafeMovesRemaining(_)) + } +} + +impl> PartialEq for BonusStatus { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (BonusStatus::BonusForfeited, BonusStatus::BonusForfeited) => true, + (BonusStatus::SafeMovesRemaining(a), BonusStatus::SafeMovesRemaining(b)) => a == b, + _ => false, + } + } +} + +impl> Eq for BonusStatus {} + /// Information about how much a particular staker staked on a particular smart contract. /// /// Keeps track of amount staked in the 'voting subperiod', as well as 'build&earn subperiod'. -#[derive(Encode, Decode, MaxEncodedLen, Copy, Clone, Debug, PartialEq, Eq, TypeInfo, Default)] -pub struct SingularStakingInfo { +#[derive(Encode, MaxEncodedLen, Copy, Clone, Debug, PartialEq, Eq, TypeInfo, Default)] +#[scale_info(skip_type_params(MaxBonusMoves))] +pub struct SingularStakingInfo> { /// Amount staked before, if anything. pub(crate) previous_staked: StakeAmount, /// Staked amount pub(crate) staked: StakeAmount, - /// Indicates whether a staker is a loyal staker or not. - pub(crate) loyal_staker: bool, + /// Tracks the remaining allowable actions to preserve a staking bonus, based on the staker's behavior during specific subperiods. + pub(crate) bonus_status: BonusStatus, } -impl SingularStakingInfo { +impl> SingularStakingInfo { /// Creates new instance of the struct. /// /// ## Args @@ -1014,14 +1077,20 @@ impl SingularStakingInfo { /// `period` - period number for which this entry is relevant. /// `subperiod` - subperiod during which this entry is created. pub(crate) fn new(period: PeriodNumber, subperiod: Subperiod) -> Self { + // Bonus staking is only possible if stake is first made during the voting subperiod. + let bonus_status = if subperiod == Subperiod::Voting { + BonusStatus::::default() + } else { + BonusStatus::::BonusForfeited + }; + Self { previous_staked: Default::default(), staked: StakeAmount { period, ..Default::default() }, - // Loyalty staking is only possible if stake is first made during the voting subperiod. - loyal_staker: subperiod == Subperiod::Voting, + bonus_status, } } @@ -1042,7 +1111,8 @@ impl SingularStakingInfo { /// Unstakes some of the specified amount from the contract. /// /// In case the `amount` being unstaked is larger than the amount staked in the `Voting` subperiod, - /// and `Voting` subperiod has passed, this will remove the _loyalty_ flag from the staker. + /// and `Voting` subperiod has passed, this will reduce the staker's remaining safe moves in the bonus status. + /// Once all safe moves are exhausted, the bonus will be forfeited. /// /// Returns a vector of `(era, amount)` pairs, where `era` is the era in which the unstake happened, /// and the amount is the corresponding amount. @@ -1067,12 +1137,8 @@ impl SingularStakingInfo { self.staked.era = self.staked.era.max(current_era); result.push((self.staked.era, unstaked_amount)); - // 2. Update loyal staker flag accordingly. - self.loyal_staker = self.loyal_staker - && match subperiod { - Subperiod::Voting => !self.staked.voting.is_zero(), - Subperiod::BuildAndEarn => self.staked.voting == staked_snapshot.voting, - }; + // 2. Update bonus status accordingly. + self.update_bonus_status(subperiod, staked_snapshot.voting); // 3. Determine what was the previous staked amount. // This is done by simply comparing where does the _previous era_ fit in the current context. @@ -1137,6 +1203,24 @@ impl SingularStakingInfo { result } + /// Updates the bonus_status based on the current subperiod + /// For Voting subperiod: bonus_status is forfeited for full unstake + /// For B&E: the number of 'bonus safe moves' remaining is reduced for full unstake or for partial unstake if it exceeds the previous ‘voting’ stake used as a reference (the bonus status changes to 'forfeited' if there are no safe moves remaining) + pub fn update_bonus_status(&mut self, subperiod: Subperiod, previous_voting_stake: Balance) { + match subperiod { + Subperiod::Voting => { + if self.staked.voting.is_zero() { + self.bonus_status = BonusStatus::BonusForfeited; + } + } + Subperiod::BuildAndEarn => { + if self.staked.voting < previous_voting_stake { + self.bonus_status.decrease_moves(); + } + } + } + } + /// Total staked on the contract by the user. Both subperiod stakes are included. pub fn total_staked_amount(&self) -> Balance { self.staked.total() @@ -1147,9 +1231,9 @@ impl SingularStakingInfo { self.staked.for_type(subperiod) } - /// If `true` staker has staked during voting subperiod and has never reduced their sta - pub fn is_loyal(&self) -> bool { - self.loyal_staker + /// If `true` staker has bonus rewards + pub fn has_bonus(&self) -> bool { + self.bonus_status.has_bonus() } /// Period for which this entry is relevant. @@ -1168,6 +1252,46 @@ impl SingularStakingInfo { } } +impl> Decode for SingularStakingInfo { + /// Decodes SingularStakingInfo from input, supporting both current and legacy format with 'loyal_staker' flag. + fn decode( + input: &mut I, + ) -> Result { + let previous_staked = StakeAmount::decode(input)?; + let staked = StakeAmount::decode(input)?; + + let bonus_status = match input.read_byte() { + Ok(0x00) => BonusStatus::BonusForfeited, // Legacy format: loyal_staker = false + Ok(0x01) => { + if input.remaining_len()?.unwrap_or(0) > 0 { + let remaining_moves = u8::decode(input)?; + if remaining_moves > MaxBonusMoves::get() { + return Err(parity_scale_codec::Error::from( + "Remaining bonus safe moves cannot exceed 'MaxBonusMoves' value.", + )); + } + + BonusStatus::SafeMovesRemaining(remaining_moves) + } else { + // Legacy format `loyal_staker = true` without count. + BonusStatus::SafeMovesRemaining(0) + } + } + _ => { + return Err(parity_scale_codec::Error::from( + "Invalid byte for BonusStatus", + )) + } + }; + + Ok(Self { + previous_staked, + staked, + bonus_status, + }) + } +} + /// Composite type that holds information about how much was staked on a contract in up to two distinct eras. /// /// This is needed since 'stake' operation only makes the staked amount valid from the next era. diff --git a/pallets/inflation/src/lib.rs b/pallets/inflation/src/lib.rs index c9baf754a0..29188fcd8a 100644 --- a/pallets/inflation/src/lib.rs +++ b/pallets/inflation/src/lib.rs @@ -494,7 +494,7 @@ pub struct InflationConfiguration { /// This is provided to the stakers according to formula: 'pool * min(1, total_staked / ideal_staked)'. #[codec(compact)] pub adjustable_staker_reward_pool_per_era: Balance, - /// Bonus reward pool per period, for loyal stakers. + /// Bonus reward pool per period, for eligible stakers. #[codec(compact)] pub bonus_reward_pool_per_period: Balance, /// The ideal staking rate, in respect to total issuance. diff --git a/precompiles/dapp-staking/src/lib.rs b/precompiles/dapp-staking/src/lib.rs index b60a8270ee..b83c2ec90a 100644 --- a/precompiles/dapp-staking/src/lib.rs +++ b/precompiles/dapp-staking/src/lib.rs @@ -266,7 +266,7 @@ where handle.record_db_read::( 24 + ProtocolState::max_encoded_len() + ::SmartContract::max_encoded_len() - + SingularStakingInfo::max_encoded_len(), + + SingularStakingInfo::<::MaxBonusMovesPerPeriod>::max_encoded_len(), )?; let smart_contract = @@ -402,7 +402,7 @@ where handle.record_db_read::( 24 + ProtocolState::max_encoded_len() + ::SmartContract::max_encoded_len() - + SingularStakingInfo::max_encoded_len(), + + SingularStakingInfo::<::MaxBonusMovesPerPeriod>::max_encoded_len(), )?; let smart_contract = @@ -527,7 +527,7 @@ where // Blake2_128Concat(16 + SmartContract::max_encoded_len) + SingularStakingInfo::max_encoded_len handle.record_db_read::( 16 + ::SmartContract::max_encoded_len() - + SingularStakingInfo::max_encoded_len(), + + SingularStakingInfo::<::MaxBonusMovesPerPeriod>::max_encoded_len(), )?; let origin_smart_contract = @@ -688,7 +688,7 @@ where Ok(true) } - /// Attempts to claim bonus reward for being a loyal staker of the given dApp. + /// Attempts to claim bonus reward for an elegible staker. #[precompile::public("claim_bonus_reward((uint8,bytes))")] fn claim_bonus_reward( handle: &mut impl PrecompileHandle, diff --git a/precompiles/dapp-staking/src/test/mock.rs b/precompiles/dapp-staking/src/test/mock.rs index 04db72aa52..5b26246b4a 100644 --- a/precompiles/dapp-staking/src/test/mock.rs +++ b/precompiles/dapp-staking/src/test/mock.rs @@ -35,7 +35,7 @@ use sp_arithmetic::{fixed_point::FixedU128, Permill}; use sp_core::{H160, H256}; use sp_io::TestExternalities; use sp_runtime::{ - traits::{BlakeTwo256, ConstU32, IdentityLookup}, + traits::{BlakeTwo256, ConstU32, ConstU8, IdentityLookup}, BuildStorage, Perbill, }; extern crate alloc; @@ -279,6 +279,7 @@ impl pallet_dapp_staking::Config for Test { type MinimumStakeAmount = ConstU128<3>; type NumberOfTiers = ConstU32<4>; type RankingEnabled = ConstBool; + type MaxBonusMovesPerPeriod = ConstU8<2>; type WeightInfo = pallet_dapp_staking::weights::SubstrateWeight; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = BenchmarkHelper; diff --git a/runtime/astar/src/lib.rs b/runtime/astar/src/lib.rs index 61b6f7f780..5f76c74921 100644 --- a/runtime/astar/src/lib.rs +++ b/runtime/astar/src/lib.rs @@ -30,7 +30,7 @@ use frame_support::{ traits::{ fungible::{Balanced, Credit, HoldConsideration}, tokens::{PayFromAccount, UnityAssetBalanceConversion}, - AsEnsureOriginWithArg, ConstBool, ConstU128, ConstU32, ConstU64, Contains, + AsEnsureOriginWithArg, ConstBool, ConstU128, ConstU32, ConstU64, ConstU8, Contains, EqualPrivilegeOnly, FindAuthor, Get, Imbalance, InstanceFilter, LinearStoragePrice, Nothing, OnFinalize, OnUnbalanced, Randomness, WithdrawReasons, }, @@ -462,6 +462,7 @@ impl pallet_dapp_staking::Config for Runtime { type MinimumStakeAmount = MinimumStakingAmount; type NumberOfTiers = ConstU32<4>; type RankingEnabled = ConstBool; + type MaxBonusMovesPerPeriod = ConstU8<2>; type WeightInfo = weights::pallet_dapp_staking::SubstrateWeight; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = DAppStakingBenchmarkHelper, AccountId>; diff --git a/runtime/local/src/lib.rs b/runtime/local/src/lib.rs index 7ce660b321..1a8330efa0 100644 --- a/runtime/local/src/lib.rs +++ b/runtime/local/src/lib.rs @@ -29,9 +29,9 @@ use frame_support::{ traits::{ fungible::{Balanced, Credit, HoldConsideration}, tokens::{PayFromAccount, UnityAssetBalanceConversion}, - AsEnsureOriginWithArg, ConstU128, ConstU32, ConstU64, Contains, EqualPrivilegeOnly, - FindAuthor, Get, InsideBoth, InstanceFilter, LinearStoragePrice, Nothing, OnFinalize, - WithdrawReasons, + AsEnsureOriginWithArg, ConstU128, ConstU32, ConstU64, ConstU8, Contains, + EqualPrivilegeOnly, FindAuthor, Get, InsideBoth, InstanceFilter, LinearStoragePrice, + Nothing, OnFinalize, WithdrawReasons, }, weights::{ constants::{ExtrinsicBaseWeight, RocksDbWeight, WEIGHT_REF_TIME_PER_SECOND}, @@ -487,6 +487,7 @@ impl pallet_dapp_staking::Config for Runtime { type MinimumStakeAmount = ConstU128; type NumberOfTiers = ConstU32<4>; type RankingEnabled = ConstBool; + type MaxBonusMovesPerPeriod = ConstU8<2>; type WeightInfo = pallet_dapp_staking::weights::SubstrateWeight; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = BenchmarkHelper, AccountId>; diff --git a/runtime/shibuya/src/lib.rs b/runtime/shibuya/src/lib.rs index bf839ea052..9b6bb314e8 100644 --- a/runtime/shibuya/src/lib.rs +++ b/runtime/shibuya/src/lib.rs @@ -30,7 +30,7 @@ use frame_support::{ traits::{ fungible::{Balanced, Credit, HoldConsideration}, tokens::{PayFromAccount, UnityAssetBalanceConversion}, - AsEnsureOriginWithArg, ConstBool, ConstU128, ConstU32, ConstU64, Contains, + AsEnsureOriginWithArg, ConstBool, ConstU128, ConstU32, ConstU64, ConstU8, Contains, EqualPrivilegeOnly, FindAuthor, Get, Imbalance, InsideBoth, InstanceFilter, LinearStoragePrice, Nothing, OnFinalize, OnUnbalanced, WithdrawReasons, }, @@ -494,6 +494,7 @@ impl pallet_dapp_staking::Config for Runtime { type MinimumStakeAmount = MinimumStakingAmount; type NumberOfTiers = ConstU32<4>; type RankingEnabled = ConstBool; + type MaxBonusMovesPerPeriod = ConstU8<2>; type WeightInfo = weights::pallet_dapp_staking::SubstrateWeight; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = DAppStakingBenchmarkHelper, AccountId>; diff --git a/runtime/shiden/src/lib.rs b/runtime/shiden/src/lib.rs index 80a6cf3ffe..8a96487aa6 100644 --- a/runtime/shiden/src/lib.rs +++ b/runtime/shiden/src/lib.rs @@ -29,8 +29,8 @@ use frame_support::{ genesis_builder_helper, parameter_types, traits::{ fungible::{Balanced, Credit}, - AsEnsureOriginWithArg, ConstBool, ConstU32, ConstU64, Contains, FindAuthor, Get, Imbalance, - InstanceFilter, Nothing, OnFinalize, OnUnbalanced, WithdrawReasons, + AsEnsureOriginWithArg, ConstBool, ConstU32, ConstU64, ConstU8, Contains, FindAuthor, Get, + Imbalance, InstanceFilter, Nothing, OnFinalize, OnUnbalanced, WithdrawReasons, }, weights::{ constants::{ @@ -450,6 +450,7 @@ impl pallet_dapp_staking::Config for Runtime { type MinimumStakeAmount = MinimumStakingAmount; type NumberOfTiers = ConstU32<4>; type RankingEnabled = ConstBool; + type MaxBonusMovesPerPeriod = ConstU8<2>; type WeightInfo = weights::pallet_dapp_staking::SubstrateWeight; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = DAppStakingBenchmarkHelper, AccountId>; diff --git a/tests/xcm-simulator/src/mocks/parachain.rs b/tests/xcm-simulator/src/mocks/parachain.rs index 26d2118936..ddb89405fb 100644 --- a/tests/xcm-simulator/src/mocks/parachain.rs +++ b/tests/xcm-simulator/src/mocks/parachain.rs @@ -23,8 +23,8 @@ use frame_support::{ dispatch::DispatchClass, parameter_types, traits::{ - AsEnsureOriginWithArg, ConstBool, ConstU128, ConstU32, ConstU64, Contains, Everything, - InstanceFilter, Nothing, + AsEnsureOriginWithArg, ConstBool, ConstU128, ConstU32, ConstU64, ConstU8, Contains, + Everything, InstanceFilter, Nothing, }, weights::{ constants::{BlockExecutionWeight, ExtrinsicBaseWeight, WEIGHT_REF_TIME_PER_SECOND}, @@ -729,6 +729,7 @@ impl pallet_dapp_staking::Config for Runtime { type MinimumStakeAmount = ConstU128<3>; type NumberOfTiers = ConstU32<4>; type RankingEnabled = ConstBool; + type MaxBonusMovesPerPeriod = ConstU8<2>; type WeightInfo = (); #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = BenchmarkHelper; From 6e6c56b3229736d3ca7597ff99ef0a4b3d1f8840 Mon Sep 17 00:00:00 2001 From: Igor Papandinas <26460174+ipapandinas@users.noreply.github.com> Date: Fri, 10 Jan 2025 17:28:06 +0100 Subject: [PATCH 2/8] feat: move_stake extrinsic implementation --- pallets/dapp-staking/src/benchmarking/mod.rs | 54 ++ pallets/dapp-staking/src/lib.rs | 194 +++++- .../dapp-staking/src/test/testing_utils.rs | 509 ++++++++++++++- pallets/dapp-staking/src/test/tests.rs | 590 +++++++++++++++++- pallets/dapp-staking/src/test/tests_types.rs | 82 +++ pallets/dapp-staking/src/types.rs | 66 +- pallets/dapp-staking/src/weights.rs | 43 ++ primitives/src/dapp_staking.rs | 11 + .../astar/src/weights/pallet_dapp_staking.rs | 17 + .../src/weights/pallet_dapp_staking.rs | 17 + .../shiden/src/weights/pallet_dapp_staking.rs | 17 + 11 files changed, 1565 insertions(+), 35 deletions(-) diff --git a/pallets/dapp-staking/src/benchmarking/mod.rs b/pallets/dapp-staking/src/benchmarking/mod.rs index c8ec3ddef9..be904e1a6a 100644 --- a/pallets/dapp-staking/src/benchmarking/mod.rs +++ b/pallets/dapp-staking/src/benchmarking/mod.rs @@ -839,6 +839,60 @@ mod benchmarks { assert_last_event::(Event::::Force { forcing_type }.into()); } + #[benchmark] + fn move_stake() { + initial_config::(); + + let staker: T::AccountId = whitelisted_caller(); + let owner: T::AccountId = account("dapp_owner", 0, SEED); + let source_contract = T::BenchmarkHelper::get_smart_contract(1); + let destination_contract = T::BenchmarkHelper::get_smart_contract(2); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + source_contract.clone(), + )); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + destination_contract.clone(), + )); + + // To preserve source staking and create destination staking + let amount = T::MinimumLockedAmount::get() + T::MinimumLockedAmount::get(); + T::BenchmarkHelper::set_balance(&staker, amount); + assert_ok!(DappStaking::::lock( + RawOrigin::Signed(staker.clone()).into(), + amount, + )); + + assert_ok!(DappStaking::::stake( + RawOrigin::Signed(staker.clone()).into(), + source_contract.clone(), + amount + )); + + let amount_to_move = T::MinimumLockedAmount::get(); + + #[extrinsic_call] + _( + RawOrigin::Signed(staker.clone()), + source_contract.clone(), + destination_contract.clone(), + Some(amount_to_move.clone()), + ); + + assert_last_event::( + Event::::StakeMoved { + account: staker, + source_contract, + destination_contract, + amount: amount_to_move, + } + .into(), + ); + } + #[benchmark] fn on_initialize_voting_to_build_and_earn() { initial_config::(); diff --git a/pallets/dapp-staking/src/lib.rs b/pallets/dapp-staking/src/lib.rs index 932f79291c..7e2c6d9328 100644 --- a/pallets/dapp-staking/src/lib.rs +++ b/pallets/dapp-staking/src/lib.rs @@ -215,7 +215,7 @@ pub mod pallet { /// retaining eligibility for bonus rewards. Exceeding this limit will result in the /// forfeiture of the bonus rewards for the affected stake. #[pallet::constant] - type MaxBonusMovesPerPeriod: Get + Default + Debug; + type MaxBonusMovesPerPeriod: Get + Default + Debug + Clone; /// Weight info for various calls & operations in the pallet. type WeightInfo: WeightInfo; @@ -322,6 +322,13 @@ pub mod pallet { 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 }, + /// Account has moved some stake from a source smart contract to a destination smart contract. + StakeMoved { + account: T::AccountId, + source_contract: T::SmartContract, + destination_contract: T::SmartContract, + amount: Balance, + }, } #[pallet::error] @@ -398,6 +405,10 @@ pub mod pallet { NoExpiredEntries, /// Force call is not allowed in production. ForceNotAllowed, + /// Same contract specified as source and destination. + SameContracts, + /// Performing stake move from a registered contract without specifying amount. + InvalidAmount, } /// General information about dApp staking protocol state. @@ -1522,6 +1533,157 @@ pub mod pallet { Self::internal_claim_bonus_reward_for(account, smart_contract) } + + /// Transfers stake between two smart contracts, ensuring period alignment, bonus status preservation if elegible, + /// and adherence to staking limits. Updates all relevant storage and emits a `StakeMoved` event. + #[pallet::call_index(21)] + #[pallet::weight(T::WeightInfo::move_stake())] + pub fn move_stake( + origin: OriginFor, + source_contract: T::SmartContract, + destination_contract: T::SmartContract, + maybe_amount: Option, + ) -> DispatchResult { + Self::ensure_pallet_enabled()?; + let account = ensure_signed(origin)?; + + ensure!( + !source_contract.eq(&destination_contract), + Error::::SameContracts + ); + + let dest_dapp_info = IntegratedDApps::::get(&destination_contract) + .ok_or(Error::::ContractNotFound)?; + + let protocol_state = ActiveProtocolState::::get(); + let current_era = protocol_state.era; + + 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); + + let mut source_staking_info = StakerInfo::::get(&account, &source_contract) + .ok_or(Error::::NoStakingInfo)?; + + ensure!( + source_staking_info.period_number() == protocol_state.period_number(), + Error::::UnstakeFromPastPeriod + ); + + let maybe_source_dapp_info = IntegratedDApps::::get(&source_contract); + let is_source_unregistered = maybe_source_dapp_info.is_none(); + let bonus_status_snapshot = source_staking_info.bonus_status.clone(); + + let amount_to_move = Self::get_amount_to_move( + &source_staking_info, + maybe_amount, + is_source_unregistered, + )?; + + // 1. + // Prepare Destination Contract Staking Info + let (mut dest_staking_info, is_new_entry) = + match StakerInfo::::get(&account, &destination_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.has_bonus() => + { + return Err(Error::::UnclaimedRewards.into()); + } + // No valid entry exists + _ => ( + SingularStakingInfo::new( + protocol_state.period_number(), + protocol_state.subperiod(), + ), + true, + ), + }; + + // 2. + // Perform 'Move' + let (era_and_amount_pairs, _) = source_staking_info.move_stake( + &mut dest_staking_info, + amount_to_move, + current_era, + protocol_state.subperiod(), + ); + + ensure!( + dest_staking_info.total_staked_amount() >= T::MinimumStakeAmount::get(), + Error::::InsufficientStakeAmount + ); + + if is_new_entry && !is_source_unregistered { + ledger.contract_stake_count.saturating_inc(); + ensure!( + ledger.contract_stake_count <= T::MaxNumberOfStakedContracts::get(), + Error::::TooManyStakedContracts + ); + } + + // 3. + // Handle Bonus Status + // For an unregistered contract the bonus status is preserved. + // For a registered contract, the source unstake has already handled the bonus status logic. + dest_staking_info.bonus_status = if is_source_unregistered { + bonus_status_snapshot + } else { + source_staking_info.bonus_status.clone() + }; + + // 4. + // Update Afected Contract Stakes + if let Some(source_dapp_info) = maybe_source_dapp_info { + // Registered source: perform unstake operations. + let mut source_contract_stake_info = ContractStake::::get(source_dapp_info.id); + source_contract_stake_info.unstake( + era_and_amount_pairs, + protocol_state.period_info, + current_era, + ); + + ContractStake::::insert(&source_dapp_info.id, source_contract_stake_info); + } + + let mut dest_contract_stake_info = ContractStake::::get(&dest_dapp_info.id); + dest_contract_stake_info.stake(amount_to_move, protocol_state.period_info, current_era); + + // 5. + // Update remaining storage entries + if !is_source_unregistered && source_staking_info.is_empty() { + ledger.contract_stake_count.saturating_dec(); + StakerInfo::::remove(&account, &source_contract); + } else if !is_source_unregistered { + StakerInfo::::insert(&account, &source_contract, source_staking_info); + } else { + // Unregistered source: remove staker info directly + StakerInfo::::remove(&account, &source_contract); + } + + StakerInfo::::insert(&account, &destination_contract, dest_staking_info); + + Self::update_ledger(&account, ledger)?; + ContractStake::::insert(&dest_dapp_info.id, dest_contract_stake_info); + + Self::deposit_event(Event::::StakeMoved { + account, + source_contract, + destination_contract, + amount: amount_to_move, + }); + + Ok(()) + } } impl Pallet { @@ -2222,6 +2384,36 @@ pub mod pallet { Self::deposit_event(Event::::MaintenanceMode { enabled }); } + // Helper to get the correct amount to move based on source contract status + pub(crate) fn get_amount_to_move( + source_staking_info: &SingularStakingInfoFor, + maybe_amount: Option, + is_source_unregistered: bool, + ) -> Result { + if is_source_unregistered { + // Unregistered contracts: Move all funds. + Ok(source_staking_info.total_staked_amount()) + } else { + let amount = maybe_amount.ok_or(Error::::InvalidAmount)?; + ensure!(amount > 0, Error::::ZeroAmount); + ensure!( + source_staking_info.total_staked_amount() >= amount, + Error::::UnstakeAmountTooLarge + ); + + // If the remaining stake falls below the minimum, unstake everything. + if source_staking_info + .total_staked_amount() + .saturating_sub(amount) + < T::MinimumStakeAmount::get() + { + Ok(source_staking_info.total_staked_amount()) + } else { + Ok(amount) + } + } + } + /// Ensure the correctness of the state of this pallet. #[cfg(any(feature = "try-runtime", test))] pub fn do_try_state() -> Result<(), sp_runtime::TryRuntimeError> { diff --git a/pallets/dapp-staking/src/test/testing_utils.rs b/pallets/dapp-staking/src/test/testing_utils.rs index c4c7168f97..effa8e3970 100644 --- a/pallets/dapp-staking/src/test/testing_utils.rs +++ b/pallets/dapp-staking/src/test/testing_utils.rs @@ -21,7 +21,7 @@ use crate::types::*; use crate::{ pallet::Config, ActiveProtocolState, BonusStatusFor, ContractStake, CurrentEraInfo, DAppId, DAppTiers, EraRewards, Event, FreezeReason, HistoryCleanupMarker, IntegratedDApps, Ledger, - NextDAppId, PeriodEnd, PeriodEndInfo, StakerInfo, + NextDAppId, PeriodEnd, PeriodEndInfo, StakerInfo, Subperiod, }; use frame_support::{ @@ -33,13 +33,13 @@ use sp_runtime::{traits::Zero, Perbill}; use std::collections::HashMap; use astar_primitives::{ - dapp_staking::{CycleConfiguration, EraNumber, PeriodNumber}, + dapp_staking::{CycleConfiguration, EraNumber, PeriodNumber, StakeAmountMoved}, Balance, BlockNumber, }; /// Helper struct used to store the entire pallet state snapshot. /// Used when comparison of before/after states is required. -#[derive(Debug)] +#[derive(Debug, Clone)] pub(crate) struct MemorySnapshot { active_protocol_state: ProtocolState, next_dapp_id: DAppId, @@ -535,41 +535,38 @@ pub(crate) fn assert_stake( // 2. verify staker info // ===================== // ===================== + + let stake_amount = match stake_subperiod { + Subperiod::Voting => StakeAmountMoved { + voting: amount, + build_and_earn: 0, + }, + Subperiod::BuildAndEarn => StakeAmountMoved { + voting: 0, + build_and_earn: amount, + }, + }; + + verify_staker_info_stake( + pre_snapshot.clone(), + post_snapshot.clone(), + account, + smart_contract, + stake_amount, + ); + match pre_staker_info { // We're just updating an existing entry Some(pre_staker_info) if pre_staker_info.period_number() == stake_period => { - assert_eq!( - post_staker_info.total_staked_amount(), - pre_staker_info.total_staked_amount() + amount, - "Total staked amount must increase by the 'amount'" - ); - assert_eq!( - post_staker_info.staked_amount(stake_subperiod), - pre_staker_info.staked_amount(stake_subperiod) + amount, - "Staked amount must increase by the 'amount'" - ); - assert_eq!(post_staker_info.period_number(), stake_period); assert_eq!( post_staker_info.has_bonus(), pre_staker_info.has_bonus(), - "Staking operation mustn't change bonus reward + "Staking operation mustn't change bonus reward eligibility." ); } // A new entry is created. _ => { - assert_eq!( - post_staker_info.total_staked_amount(), - amount, - "Total staked amount must be equal to exactly the 'amount'" - ); - assert!(amount >= ::MinimumStakeAmount::get()); - assert_eq!( - post_staker_info.staked_amount(stake_subperiod), - amount, - "Staked amount must be equal to exactly the 'amount'" - ); - assert_eq!(post_staker_info.period_number(), stake_period); assert_eq!( post_staker_info.has_bonus(), stake_subperiod == Subperiod::Voting @@ -869,6 +866,24 @@ pub(crate) fn assert_bonus_status( ); } +/// Assert the singular staking info of a staker for a specific smart contract. +pub(crate) fn assert_staker_info( + account: AccountId, + smart_contract: &MockSmartContract, + expected_staker_info: SingularStakingInfoFor, +) { + let snapshot = MemorySnapshot::new(); + let staker_info = snapshot + .staker_info + .get(&(account, *smart_contract)) + .expect("Staker info entry must exist to verify bonus status."); + + assert!( + staker_info.equals(&expected_staker_info), + "Staker infos do not match." + ); +} + /// Claim staker rewards. pub(crate) fn assert_claim_staker_rewards(account: AccountId) { let pre_snapshot = MemorySnapshot::new(); @@ -1625,3 +1640,443 @@ pub(crate) fn is_account_ledger_expired( _ => false, } } + +/// Move stake funds from source contract to destination contract. +pub(crate) fn assert_move_stake( + account: AccountId, + source_contract: &MockSmartContract, + destination_contract: &MockSmartContract, + maybe_amount: Option, +) { + let pre_snapshot = MemorySnapshot::new(); + let is_source_unregistered = IntegratedDApps::::get(&source_contract).is_none(); + + let pre_era_info = pre_snapshot.current_era_info; + let pre_ledger = pre_snapshot.ledger.get(&account).unwrap(); + let pre_staker_info = pre_snapshot + .staker_info + .get(&(account, source_contract.clone())) + .expect("Entry must exist since 'move' is being called on a registered contract."); + let maybe_pre_source_contract_stake = if is_source_unregistered { + None + } else { + Some( + pre_snapshot + .contract_stake + .get(&pre_snapshot.integrated_dapps[&source_contract].id) + .expect("Entry must exist since 'move' is being called."), + ) + }; + let maybe_pre_destination_contract_stake = pre_snapshot + .contract_stake + .get(&pre_snapshot.integrated_dapps[&destination_contract].id); + let maybe_pre_destination_staker_info = pre_snapshot + .staker_info + .get(&(account, *destination_contract)); + + let move_era = pre_snapshot.active_protocol_state.era; + let move_period = pre_snapshot.active_protocol_state.period_number(); + let move_subperiod = pre_snapshot.active_protocol_state.subperiod(); + + let amount_to_move: Balance = + DappStaking::get_amount_to_move(pre_staker_info, maybe_amount, is_source_unregistered) + .expect("Invalid amount to move"); + let is_full_move_from_source = pre_staker_info.total_staked_amount() == amount_to_move; + + // Move from source contract to destination contract & verify event + assert_ok!(DappStaking::move_stake( + RuntimeOrigin::signed(account), + source_contract.clone(), + destination_contract.clone(), + maybe_amount + )); + System::assert_last_event(RuntimeEvent::DappStaking(Event::StakeMoved { + account, + source_contract: source_contract.clone(), + destination_contract: destination_contract.clone(), + amount: amount_to_move, + })); + + // Verify post-state + let post_snapshot = MemorySnapshot::new(); + let post_era_info = post_snapshot.current_era_info; + let post_ledger = post_snapshot.ledger.get(&account).unwrap(); + let maybe_post_source_contract_stake = if is_source_unregistered { + None + } else { + Some( + post_snapshot + .contract_stake + .get(&post_snapshot.integrated_dapps[&source_contract].id) + .expect("Entry must exist since 'move' is being called on a registered contract."), + ) + }; + let post_destination_contract_stake = post_snapshot + .contract_stake + .get(&post_snapshot.integrated_dapps[&destination_contract].id) + .expect("Entry must exist since 'move' is being called."); + + // 1. verify staker info + // ===================== + // ===================== + + let mut source_staker_info = pre_staker_info.clone(); + let new_staker_info_entry = SingularStakingInfoFor::::new(move_period, move_subperiod); + let dest_staker_info = match maybe_pre_destination_staker_info { + Some(staking_info) => staking_info, + _ => &new_staker_info_entry, + }; + + let (_, stake_amount) = source_staker_info.move_stake( + &mut dest_staker_info.clone(), + amount_to_move, + move_era, + move_subperiod, + ); + + verify_staker_info_unstake( + pre_snapshot.clone(), + post_snapshot.clone(), + account, + source_contract, + amount_to_move, + ); + verify_staker_info_stake( + pre_snapshot.clone(), + post_snapshot.clone(), + account, + destination_contract, + stake_amount, + ); + + // 2. verify contract stake (for registered source contract) + // ========================= + // ========================= + + if let (Some(pre_source_contract_stake), Some(post_source_contract_stake)) = ( + maybe_pre_source_contract_stake, + maybe_post_source_contract_stake, + ) { + assert_eq!( + post_source_contract_stake.total_staked_amount(move_period), + pre_source_contract_stake.total_staked_amount(move_period) - amount_to_move, + "Staked amount must decreased by the 'amount_to_move'" + ); + assert_eq!( + post_source_contract_stake.staked_amount(move_period, move_subperiod), + pre_source_contract_stake + .staked_amount(move_period, move_subperiod) + .saturating_sub(amount_to_move), + "Staked amount must decreased by the 'amount_to_move'" + ); + + // A generic check, comparing what was received in the (era, amount) pairs and the impact it had on the contract stake. + let unstaked_amount_era_pairs = + pre_staker_info + .clone() + .unstake(amount_to_move, move_era, move_subperiod); + for (move_era_iter, move_amount_iter) in unstaked_amount_era_pairs { + assert_eq!( + post_source_contract_stake + .get(move_era_iter, move_period) + .unwrap_or_default() // it's possible that full move cleared the entry + .total(), + pre_source_contract_stake + .get(move_era_iter, move_period) + .expect("Must exist") + .total() + - move_amount_iter + ); + } + + // More precise check, independent of the generic check above. + // If next era entry exists, it must be reduced by the unstake amount, nothing less. + if let Some(entry) = pre_source_contract_stake.get(move_era + 1, move_period) { + assert_eq!( + post_source_contract_stake + .get(move_era + 1, move_period) + .unwrap_or_default() + .total(), + entry.total() - amount_to_move + ); + } + } + + if let Some(pre_destination_contract_stake) = maybe_pre_destination_contract_stake { + assert_eq!( + post_destination_contract_stake.total_staked_amount(move_period), + pre_destination_contract_stake.total_staked_amount(move_period) + amount_to_move, + "Staked amount must increase by the 'amount_to_move'" + ); + assert_eq!( + post_destination_contract_stake.staked_amount(move_period, move_subperiod), + pre_destination_contract_stake.staked_amount(move_period, move_subperiod) + + amount_to_move, + "Staked amount must increase by the 'amount_to_move'" + ); + } else { + assert_eq!( + post_destination_contract_stake.total_staked_amount(move_period), + amount_to_move, + "Staked amount must increase by the 'amount_to_move'" + ); + assert_eq!( + post_destination_contract_stake.staked_amount(move_period, move_subperiod), + amount_to_move, + "Staked amount must increase by the 'amount_to_move'" + ); + } + + assert_eq!( + post_destination_contract_stake.latest_stake_period(), + Some(move_period) + ); + assert_eq!( + post_destination_contract_stake.latest_stake_era(), + Some(move_era + 1) + ); + + // 3. verify ledger (unchanged) + // ===================== + // ===================== + assert_eq!( + post_ledger.staked_amount(move_period), + pre_ledger.staked_amount(move_period), + "Stake amount must remain unchanged for a 'move'" + ); + assert_eq!( + post_ledger.stakeable_amount(move_period), + pre_ledger.stakeable_amount(move_period), + "Stakeable amount must remain unchanged for a 'move'" + ); + + let is_new_dest = maybe_pre_destination_staker_info.is_none(); + if is_new_dest { + if is_source_unregistered || is_full_move_from_source { + assert_eq!( + pre_ledger.contract_stake_count, + post_ledger.contract_stake_count, + "Number of contract stakes must remain the same for a 'move' with an unregistered source." + ); + } else { + assert_eq!( + pre_ledger.contract_stake_count.saturating_add(1), + post_ledger.contract_stake_count, + "Number of contract stakes must be incremented for a new destination after 'move'." + ); + } + } else { + if is_full_move_from_source { + if is_source_unregistered { + assert_eq!( + pre_ledger.contract_stake_count, + post_ledger.contract_stake_count, + "Number of contract stakes must remain the same for a full 'move' from an unregistered source." + ); + } else { + assert_eq!( + pre_ledger.contract_stake_count.saturating_sub(1), + post_ledger.contract_stake_count, + "Number of contract stakes must be decreased for a full 'move' from a registered source." + ); + } + } else { + assert_eq!( + pre_ledger.contract_stake_count, post_ledger.contract_stake_count, + "Number of contract stakes must remain the same for a partial 'move'." + ); + } + } + + // 4. verify era info (unchanged) + // ========================= + // ========================= + + assert_eq!( + post_era_info.total_staked_amount(), + pre_era_info.total_staked_amount(), + "Total staked amount for the current era must remain unchanged for a 'move'." + ); + assert_eq!( + post_era_info.total_staked_amount_next_era(), + pre_era_info.total_staked_amount_next_era(), + "Total staked amount for the next era must remain unchanged for a 'move'." + ); +} + +pub(crate) fn verify_staker_info_unstake( + pre_snapshot: MemorySnapshot, + post_snapshot: MemorySnapshot, + account: AccountId, + smart_contract: &MockSmartContract, + amount: Balance, +) { + let pre_staker_info = pre_snapshot + .staker_info + .get(&(account, smart_contract.clone())) + .expect("Entry must exist since 'unstake' is being called."); + + let unstake_era = pre_snapshot.active_protocol_state.era; + let unstake_period = pre_snapshot.active_protocol_state.period_number(); + let unstake_subperiod = pre_snapshot.active_protocol_state.subperiod(); + + let minimum_stake_amount: Balance = ::MinimumStakeAmount::get(); + let is_full_unstake = + pre_staker_info.total_staked_amount().saturating_sub(amount) < minimum_stake_amount; + + // Unstake all if we expect to go below the minimum stake amount + let expected_amount = if is_full_unstake { + pre_staker_info.total_staked_amount() + } else { + amount + }; + + // verify staker info + // ===================== + // ===================== + + // Verify that expected unstake amounts are applied. + if is_full_unstake { + assert!( + !StakerInfo::::contains_key(&account, smart_contract), + "Entry must be deleted since it was a full unstake." + ); + } else { + let post_staker_info = post_snapshot + .staker_info + .get(&(account, *smart_contract)) + .expect( + "Entry must exist since 'stake' operation was successful and it wasn't a full unstake.", + ); + assert_eq!(post_staker_info.period_number(), unstake_period); + assert_eq!( + post_staker_info.total_staked_amount(), + pre_staker_info.total_staked_amount() - expected_amount, + "Total staked amount must decrease by the 'amount'" + ); + assert_eq!( + post_staker_info.staked_amount(unstake_subperiod), + pre_staker_info + .staked_amount(unstake_subperiod) + .saturating_sub(expected_amount), + "Staked amount must decrease by the 'amount'" + ); + + let should_keep_bonus = if pre_staker_info.has_bonus() { + match pre_staker_info.bonus_status { + BonusStatus::SafeMovesRemaining(remaining_moves) if remaining_moves > 0 => true, + _ => match unstake_subperiod { + Subperiod::Voting => { + !post_staker_info.staked_amount(Subperiod::Voting).is_zero() + } + Subperiod::BuildAndEarn => { + post_staker_info.staked_amount(Subperiod::Voting) + == pre_staker_info.staked_amount(Subperiod::Voting) + } + }, + } + } else { + false + }; + + assert_eq!( + post_staker_info.has_bonus(), + should_keep_bonus, + "If 'voting stake' amount is fully unstaked in Voting subperiod or reduced in B&E subperiod, 'BonusStatus' must reflect this." + ); + + if unstake_subperiod == Subperiod::BuildAndEarn + && pre_staker_info.has_bonus() + && post_staker_info.staked_amount(Subperiod::Voting) + < pre_staker_info.staked_amount(Subperiod::Voting) + { + let mut bonus_status_clone = pre_staker_info.bonus_status.clone(); + bonus_status_clone.decrease_moves(); + + assert_eq!( + post_staker_info.bonus_status, bonus_status_clone, + "'BonusStatus' must correctly decrease moves when 'voting stake' is reduced in B&E subperiod." + ); + } + } + + let unstaked_amount_era_pairs = + pre_staker_info + .clone() + .unstake(expected_amount, unstake_era, unstake_subperiod); + assert!(unstaked_amount_era_pairs.len() <= 2 && unstaked_amount_era_pairs.len() > 0); + + // If unstake from next era exists, it must exactly match the expected unstake amount. + unstaked_amount_era_pairs + .iter() + .filter(|(era, _)| *era > unstake_era) + .for_each(|(_, amount)| { + assert_eq!(*amount, expected_amount); + }); +} + +pub(crate) fn verify_staker_info_stake( + pre_snapshot: MemorySnapshot, + post_snapshot: MemorySnapshot, + account: AccountId, + smart_contract: &MockSmartContract, + stake_amount: StakeAmountMoved, +) { + let pre_staker_info = pre_snapshot + .staker_info + .get(&(account, smart_contract.clone())); + + let stake_period = pre_snapshot.active_protocol_state.period_number(); + + // Verify post-state + let post_staker_info = post_snapshot + .staker_info + .get(&(account, *smart_contract)) + .expect("Entry must exist since 'stake' operation was successful."); + + // Verify staker info + // ===================== + // ===================== + match pre_staker_info { + // We're just updating an existing entry + Some(pre_staker_info) if pre_staker_info.period_number() == stake_period => { + assert_eq!( + post_staker_info.total_staked_amount(), + pre_staker_info.total_staked_amount() + stake_amount.total(), + "Total staked amount must increase by the total 'stake_amount'" + ); + assert_eq!( + post_staker_info.staked_amount(Subperiod::Voting), + pre_staker_info.staked_amount(Subperiod::Voting) + stake_amount.voting, + "Voting staked amount must increase by the voting 'stake_amount'" + ); + assert_eq!( + post_staker_info.staked_amount(Subperiod::BuildAndEarn), + pre_staker_info.staked_amount(Subperiod::BuildAndEarn) + + stake_amount.build_and_earn, + "B&E staked amount must increase by the B&E 'stake_amount'" + ); + assert_eq!(post_staker_info.period_number(), stake_period); + } + // A new entry is created. + _ => { + assert_eq!( + post_staker_info.total_staked_amount(), + stake_amount.total(), + "Total staked amount must be equal to exactly the 'amount'" + ); + assert!(stake_amount.total() >= ::MinimumStakeAmount::get()); + assert_eq!( + post_staker_info.staked_amount(Subperiod::Voting), + stake_amount.voting, + "Voting staked amount must be equal to exactly the voting 'stake_amount'" + ); + assert_eq!( + post_staker_info.staked_amount(Subperiod::BuildAndEarn), + stake_amount.build_and_earn, + "B&E staked amount must be equal to exactly the B&E 'stake_amount'" + ); + assert_eq!(post_staker_info.period_number(), stake_period); + } + } +} diff --git a/pallets/dapp-staking/src/test/tests.rs b/pallets/dapp-staking/src/test/tests.rs index 3f01012d6d..660d04199f 100644 --- a/pallets/dapp-staking/src/test/tests.rs +++ b/pallets/dapp-staking/src/test/tests.rs @@ -18,10 +18,11 @@ use crate::test::{mock::*, testing_utils::*}; use crate::{ - pallet::Config, ActiveProtocolState, BonusStatus, ContractStake, DAppId, DAppTierRewardsFor, - DAppTiers, EraRewards, Error, Event, ForcingType, GenesisConfig, IntegratedDApps, Ledger, - NextDAppId, Perbill, PeriodNumber, Permill, Safeguard, StakerInfo, StaticTierParams, Subperiod, - TierConfig, TierThreshold, + pallet::Config, ActiveProtocolState, BonusStatus, BonusStatusFor, ContractStake, DAppId, + DAppTierRewardsFor, DAppTiers, EraRewards, Error, Event, ForcingType, GenesisConfig, + IntegratedDApps, Ledger, NextDAppId, Perbill, PeriodNumber, Permill, Safeguard, + SingularStakingInfoFor, StakeAmount, StakerInfo, StaticTierParams, Subperiod, TierConfig, + TierThreshold, }; use frame_support::{ @@ -3532,3 +3533,584 @@ fn claim_bonus_reward_for_works() { ); }) } + +#[test] +// Tests moving stakes from unregistered contracts to another contract while verifying that: +// - All staked funds are moved from the unregistered contracts (for Some and None amounts). +// - The bonus_status is preserved during the move from an unregistered contract. +// - Subsequent moves preserve the adjusted bonus_status (check with an unstake in B&E before last move). +// - Destination stake compounds successfully if entry already exists. +fn move_stake_from_unregistered_contract_is_ok() { + ExtBuilder::default().build_and_execute(|| { + // 0.1. Setup 1 + // Register smart contracts 1 & 2 & 3, lock&stake some amount on 1, unregister the smart contract 1 + let source_contract = MockSmartContract::wasm(1 as AccountId); + let stopover_contract = MockSmartContract::wasm(2 as AccountId); + let final_contract = MockSmartContract::wasm(3 as AccountId); + assert_register(1, &source_contract); + assert_register(1, &stopover_contract); + assert_register(1, &final_contract); + + let account = 2; + let amount = 300; + let partial_move_amount = 200; + assert_lock(account, amount); + assert_stake(account, &source_contract, amount); + assert_unregister(&source_contract); + + // Advance to B&E subperiod to check that bonus is preserved during a move from an unregistered contract + advance_to_next_subperiod(); + + // 1. First Move + assert_move_stake( + account, + &source_contract, + &stopover_contract, + Some(partial_move_amount), + ); + + // 2. Verify newly created destination staking info + // - Total stake must be moved from unregistered contracts. + // - The new STOPOVER staker info bonus must be preserved with a default bonus status value. + let move_era = ActiveProtocolState::::get().era; + let move_period = ActiveProtocolState::::get().period_number(); + let expected_stopover_staker_info = SingularStakingInfoFor:: { + staked: StakeAmount { + voting: amount, + build_and_earn: 0, + era: move_era + 1, + period: move_period, + }, + ..Default::default() + }; + assert_staker_info( + account, + &stopover_contract, + expected_stopover_staker_info.clone(), + ); + + // 0.2. Setup 2 + // Move again from an unregistered contract in the next era to ensure previous 'voting' & 'b&e' stakes where properly moved. + advance_to_next_era(); + assert_claim_staker_rewards(account); // Require before unstake + + let unstake_amount = 3; + assert_unstake(account, &stopover_contract, unstake_amount); // To decrese BonusStatus by 1 + assert_unregister(&stopover_contract); + + // 2. Second Move + assert_move_stake(account, &stopover_contract, &final_contract, None); + + let move_2_era = ActiveProtocolState::::get().era; + let move_2_period = ActiveProtocolState::::get().period_number(); + let mut expected_bonus_status = BonusStatusFor::::default(); + expected_bonus_status.decrease_moves(); + let expected_final_staker_info = SingularStakingInfoFor:: { + staked: StakeAmount { + voting: amount - unstake_amount, + build_and_earn: 0, + era: move_2_era + 1, + period: move_2_period, + }, + bonus_status: expected_bonus_status, + ..Default::default() + }; + + // The new final decreased staker's bonus must still be preserved. + assert_staker_info(account, &final_contract, expected_final_staker_info); + }) +} + +// Tests moving a stake from a registered contract to another contract under various scenarios: +// - Partial stake move (to a new destination). +// - Full stake move when the remaining stake is below the minimum required. +// - Verifies proper cleanup of the source contract's staking info when fully moved. +// - Tests the impact on the bonus_status when moves occur during B&E subperiod (until bonus is forfeited). +#[test] +fn move_stake_from_registered_contract_is_ok() { + ExtBuilder::default().build_and_execute(|| { + // 0.1. Setup 1 + // Register smart contracts 1 & 2 & 3, lock&stake some amount on 1 + let source_contract = MockSmartContract::wasm(1 as AccountId); + let stopover_contract = MockSmartContract::wasm(2 as AccountId); + let final_contract = MockSmartContract::wasm(3 as AccountId); + assert_register(1, &source_contract); + assert_register(1, &stopover_contract); + assert_register(1, &final_contract); + + let account = 2; + let source_stake_amount = 300; + let final_stake_amount = 100; + assert_lock(account, source_stake_amount + final_stake_amount); + + let stake_era = ActiveProtocolState::::get().era; + let stake_period = ActiveProtocolState::::get().period_number(); + assert_stake(account, &source_contract, source_stake_amount); + assert_stake(account, &final_contract, final_stake_amount); + + // 1. First Partial Move + let partial_move_amount = 200; + assert_move_stake( + account, + &source_contract, + &stopover_contract, + Some(partial_move_amount), + ); + + // 2. Verify newly created destination staking info + // - Partial stake must be moved from SOURCE contract to STOPOVER contract. + // - The new STOPOVER staker info bonus must be preserved with a default bonus status value (still 'Voting' subperiod). + let expected_source_staker_info = SingularStakingInfoFor:: { + staked: StakeAmount { + voting: source_stake_amount - partial_move_amount, + build_and_earn: 0, + era: stake_era + 1, + period: stake_period, + }, + ..Default::default() + }; + assert_staker_info(account, &source_contract, expected_source_staker_info); + + let expected_stopover_staker_info = SingularStakingInfoFor:: { + staked: StakeAmount { + voting: partial_move_amount, + build_and_earn: 0, + era: stake_era + 1, + period: stake_period, + }, + ..Default::default() + }; + assert_staker_info(account, &stopover_contract, expected_stopover_staker_info); + + // 0.2. Setup 2 + // Move again from STOPOVER contract in the next subperiod to an already staked FINAL contract + advance_to_next_subperiod(); + + let min_stake_amount: Balance = ::MinimumStakeAmount::get(); + // Ensure partial stake move but below the minimum allowed remaining stake for this to be treated as a full move + let partial_move_amount_2 = partial_move_amount.saturating_sub(min_stake_amount) + 1; + + // 2. Second Move + assert_move_stake( + account, + &stopover_contract, + &final_contract, + Some(partial_move_amount_2), + ); + + let move_2_era = ActiveProtocolState::::get().era; + let move_2_period = ActiveProtocolState::::get().period_number(); + let mut expected_bonus_status = BonusStatusFor::::default(); + expected_bonus_status.decrease_moves(); + let expected_final_staker_info = SingularStakingInfoFor:: { + previous_staked: StakeAmount { + voting: final_stake_amount, + build_and_earn: 0, + era: stake_era + 1, + period: stake_period, + }, + staked: StakeAmount { + voting: final_stake_amount + partial_move_amount, + build_and_earn: 0, + era: move_2_era + 1, + period: move_2_period, + }, + bonus_status: expected_bonus_status, + }; + + assert_staker_info(account, &final_contract, expected_final_staker_info); + + let max_bonus_moves: u8 = ::MaxBonusMovesPerPeriod::get(); + + // Sanity check + if max_bonus_moves > 0 { + let remaining_moves = max_bonus_moves.saturating_sub(1); // To account for previous unstake + // Move stake until bonus is forfeited + for _ in 0..=remaining_moves { + assert_move_stake(account, &final_contract, &source_contract, Some(1)); + } + + let expected_source_staker_info = SingularStakingInfoFor:: { + previous_staked: StakeAmount { + voting: source_stake_amount - partial_move_amount + + ((max_bonus_moves as u128) - 1), + build_and_earn: 0, + era: stake_era + 1, + period: stake_period, + }, + staked: StakeAmount { + voting: source_stake_amount - partial_move_amount + (max_bonus_moves as u128), + build_and_earn: 0, + era: move_2_era + 1, + period: move_2_period, + }, + bonus_status: BonusStatus::BonusForfeited, + }; + assert_staker_info(account, &source_contract, expected_source_staker_info); + + let expected_final_staker_info = SingularStakingInfoFor:: { + previous_staked: StakeAmount { + voting: final_stake_amount, + build_and_earn: 0, + era: stake_era + 1, + period: stake_period, + }, + staked: StakeAmount { + voting: final_stake_amount + partial_move_amount - (max_bonus_moves as u128), + build_and_earn: 0, + era: move_2_era + 1, + period: move_2_period, + }, + bonus_status: BonusStatus::BonusForfeited, // Maybe to rework: the bonus of an already staked contract is also forfeited after exessive moves + }; + assert_staker_info(account, &final_contract, expected_final_staker_info); + } + }) +} + +#[test] +fn move_stake_for_different_subperiod_stakes_is_ok() { + ExtBuilder::default().build_and_execute(|| { + // Register smart contracts 1 & 2, lock&stake some amount on 1 + let source_contract = MockSmartContract::wasm(1 as AccountId); + let destination_contract = MockSmartContract::wasm(2 as AccountId); + assert_register(1, &source_contract); + assert_register(1, &destination_contract); + + let account = 2; + let total_locked_amount = 400; + let source_initial_stake_amount = 300; + assert_lock(account, total_locked_amount); + assert_stake(account, &source_contract, source_initial_stake_amount); + + // 1. First Partial Move (Voting subperiod) + let partial_move_amount = 200; + assert_move_stake( + account, + &source_contract, + &destination_contract, + Some(partial_move_amount), + ); + + advance_to_next_subperiod(); + + // Second stake during B&E + let stake_2_era = ActiveProtocolState::::get().era; + let stake_2_period = ActiveProtocolState::::get().period_number(); + let source_second_stake_amount = 100; + assert_stake(account, &source_contract, source_second_stake_amount); + + advance_to_next_era(); + let move_2_era = ActiveProtocolState::::get().era; + let move_2_period = ActiveProtocolState::::get().period_number(); + + // 2. Second Partial Move (B&E subperiod) + let partial_move_2_amount = 50; + assert_move_stake( + account, + &source_contract, + &destination_contract, + Some(partial_move_2_amount), + ); + + let expected_source_staker_info = SingularStakingInfoFor:: { + previous_staked: StakeAmount { + voting: source_initial_stake_amount - partial_move_amount, + build_and_earn: 0, + era: stake_2_era, + period: stake_2_period, + }, + staked: StakeAmount { + voting: source_initial_stake_amount - partial_move_amount, + build_and_earn: source_second_stake_amount - partial_move_2_amount, + era: move_2_era, + period: move_2_period, + }, + bonus_status: BonusStatus::default(), + }; + assert_staker_info(account, &source_contract, expected_source_staker_info); + + let expected_destination_staker_info = SingularStakingInfoFor:: { + previous_staked: StakeAmount { + voting: partial_move_amount, + build_and_earn: 0, + era: move_2_era, + period: move_2_period, + }, + staked: StakeAmount { + voting: partial_move_amount, + build_and_earn: partial_move_2_amount, + era: move_2_era + 1, + period: move_2_period, + }, + bonus_status: BonusStatus::default(), + }; + assert_staker_info( + account, + &destination_contract, + expected_destination_staker_info, + ); + }) +} + +#[test] +fn move_for_same_contract_fails() { + ExtBuilder::default().build_and_execute(|| { + let account = 2; + let contract = MockSmartContract::wasm(1 as AccountId); + assert_register(1, &contract); + + assert_noop!( + DappStaking::move_stake(RuntimeOrigin::signed(account), contract, contract, None), + Error::::SameContracts + ); + }) +} + +#[test] +fn move_from_past_period_fails() { + ExtBuilder::default().build_and_execute(|| { + let source_contract = MockSmartContract::wasm(1 as AccountId); + let destination_contract = MockSmartContract::wasm(2 as AccountId); + assert_register(1, &source_contract); + assert_register(1, &destination_contract); + + let account = 2; + let source_stake_amount = 300; + let partial_move_amount = 200; + assert_lock(account, source_stake_amount); + assert_stake(account, &source_contract, source_stake_amount); + + advance_to_next_period(); + // for _ in 0..required_number_of_reward_claims(account) { + // assert_claim_staker_rewards(account); + // } + + // Try to move from the source contract, which is no longer staked on due to period change. + assert_noop!( + DappStaking::move_stake( + RuntimeOrigin::signed(account), + source_contract, + destination_contract, + Some(partial_move_amount) + ), + Error::::UnstakeFromPastPeriod + ); + }) +} + +#[test] +fn move_too_small_amount_fails() { + ExtBuilder::default().build_and_execute(|| { + let source_contract = MockSmartContract::wasm(1 as AccountId); + let destination_contract = MockSmartContract::wasm(2 as AccountId); + assert_register(1, &source_contract); + assert_register(1, &destination_contract); + + let account = 2; + let source_stake_amount = 300; + assert_lock(account, source_stake_amount); + assert_stake(account, &source_contract, source_stake_amount); + + let min_stake_amount: Balance = ::MinimumStakeAmount::get(); + let partial_move_amount = min_stake_amount - 1; + // Move to a new contract with too small amount, expect a failure + assert_noop!( + DappStaking::move_stake( + RuntimeOrigin::signed(account), + source_contract, + destination_contract, + Some(partial_move_amount) + ), + Error::::InsufficientStakeAmount + ); + }) +} + +// Destination contract is not found in IntegratedDApps. +#[test] +fn move_to_invalid_dapp_fails() { + ExtBuilder::default().build_and_execute(|| { + let source_contract = MockSmartContract::wasm(1 as AccountId); + let destination_contract = MockSmartContract::wasm(2 as AccountId); + assert_register(1, &source_contract); + + let account = 2; + assert_lock(account, 300); + + // Try to move to non-existing destination contract + assert_noop!( + DappStaking::move_stake( + RuntimeOrigin::signed(account), + source_contract, + destination_contract, + None + ), + Error::::ContractNotFound + ); + }) +} + +// No staking info exists for the account and the source contract. +#[test] +fn move_from_non_staked_contract_fails() { + ExtBuilder::default().build_and_execute(|| { + let source_contract = MockSmartContract::Wasm(1); + let destination_contract = MockSmartContract::Wasm(2); + assert_register(1, &source_contract); + assert_register(1, &destination_contract); + let account = 2; + assert_lock(account, 300); + + // Try to move from the source contract, which isn't staked on. + assert_noop!( + DappStaking::move_stake( + RuntimeOrigin::signed(account), + source_contract, + destination_contract, + None + ), + Error::::NoStakingInfo + ); + }) +} + +// Registered contract, but the maybe_amount is None or 0. +#[test] +fn move_with_invalid_amount_fails() { + ExtBuilder::default().build_and_execute(|| { + let source_contract = MockSmartContract::Wasm(1); + let destination_contract = MockSmartContract::Wasm(2); + assert_register(1, &source_contract); + assert_register(1, &destination_contract); + + let account = 2; + let source_stake_amount = 300; + assert_lock(account, source_stake_amount); + assert_stake(account, &source_contract, source_stake_amount); + + assert_noop!( + DappStaking::move_stake( + RuntimeOrigin::signed(account), + source_contract, + destination_contract, + None + ), + Error::::InvalidAmount + ); + + assert_noop!( + DappStaking::move_stake( + RuntimeOrigin::signed(account), + source_contract, + destination_contract, + Some(0) + ), + Error::::ZeroAmount + ); + }) +} + +// Move ammount exceeds the staked amount. +#[test] +fn move_with_exceeding_amount_fails() { + ExtBuilder::default().build_and_execute(|| { + let source_contract = MockSmartContract::Wasm(1); + let destination_contract = MockSmartContract::Wasm(2); + assert_register(1, &source_contract); + assert_register(1, &destination_contract); + + let account = 2; + let source_stake_amount = 300; + assert_lock(account, source_stake_amount); + assert_stake(account, &source_contract, source_stake_amount); + + assert_noop!( + DappStaking::move_stake( + RuntimeOrigin::signed(account), + source_contract, + destination_contract, + Some(source_stake_amount + 1) + ), + Error::::UnstakeAmountTooLarge + ); + }) +} + +#[test] +fn move_fails_due_to_too_many_staked_contracts() { + ExtBuilder::default().build_and_execute(|| { + 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); + + // Advance to build&earn subperiod so we ensure 'non-loyal' staking + advance_to_next_subperiod(); + + let source_contract = MockSmartContract::Wasm(1); + assert_register(1, &source_contract); + assert_stake(account, &source_contract, 10); + + // Register smart contracts up to the max allowed number + for id in 2..=max_number_of_contracts { + let smart_contract = MockSmartContract::Wasm(id.into()); + assert_register(2, &MockSmartContract::Wasm(id.into())); + assert_stake(account, &smart_contract, 10); + } + + let excess_destination_smart_contract = + MockSmartContract::Wasm((max_number_of_contracts + 1).into()); + assert_register(2, &excess_destination_smart_contract); + + // Max number of staked contract entries has been exceeded. + assert_noop!( + DappStaking::move_stake( + RuntimeOrigin::signed(account), + source_contract, + excess_destination_smart_contract.clone(), + Some(10) + ), + Error::::TooManyStakedContracts + ); + }) +} + +#[test] +fn move_fails_if_unclaimed_destination_staker_rewards_from_past_remain() { + ExtBuilder::default().build_and_execute(|| { + let source_contract = MockSmartContract::Wasm(1); + let source_2_contract = MockSmartContract::Wasm(2); + let destination_contract = MockSmartContract::Wasm(3); + assert_register(1, &source_contract); + assert_register(1, &source_2_contract); + assert_register(1, &destination_contract); + + let account = 2; + assert_lock(account, 300); + assert_stake(account, &source_contract, 100); + + // To transfer bonus reward elegibility to destination_contract + assert_move_stake(account, &source_contract, &destination_contract, Some(10)); + + // Advance to next period, claim all staker rewards + advance_to_next_period(); + for _ in 0..required_number_of_reward_claims(account) { + assert_claim_staker_rewards(account); + } + + // Try to move again on the same destination contract, expect an error due to unclaimed bonus rewards + advance_to_era(ActiveProtocolState::::get().era + 2); + assert_stake(account, &source_2_contract, 100); + assert_noop!( + DappStaking::move_stake( + RuntimeOrigin::signed(account), + source_2_contract, + destination_contract, + Some(10) + ), + Error::::UnclaimedRewards + ); + }) +} diff --git a/pallets/dapp-staking/src/test/tests_types.rs b/pallets/dapp-staking/src/test/tests_types.rs index eff1bb4a3c..a937d239c0 100644 --- a/pallets/dapp-staking/src/test/tests_types.rs +++ b/pallets/dapp-staking/src/test/tests_types.rs @@ -2502,6 +2502,88 @@ fn singular_staking_info_unstake_era_amount_pairs_are_ok() { } } +#[test] +fn move_stake_basic() { + get_u8_type!(MaxMoves, 1); + type TestSingularStakingInfo = SingularStakingInfo; + + // Setup initial staking info for source and destination + let current_era = 10; + let subperiod = Subperiod::Voting; + let mut source_staking_info = TestSingularStakingInfo::new(1, subperiod); + source_staking_info.staked.add(100, Subperiod::Voting); + source_staking_info.staked.add(50, Subperiod::BuildAndEarn); + source_staking_info.staked.era = current_era; + + let mut destination_staking_info = TestSingularStakingInfo::new(1, subperiod); + + // Move 80 tokens from source to destination + let move_amount = 80; + let (era_and_amount_pairs, stake_moved) = source_staking_info.move_stake( + &mut destination_staking_info, + move_amount, + current_era, + subperiod, + ); + + // Verify source staking info + assert_eq!(source_staking_info.staked.voting, 20); // 100 - 80 + assert_eq!(source_staking_info.staked.build_and_earn, 50); // Unchanged + assert_eq!(source_staking_info.staked.era, current_era); + + // Verify destination staking info + assert_eq!(destination_staking_info.staked.voting, 80); + assert_eq!(destination_staking_info.staked.build_and_earn, 0); + assert_eq!(destination_staking_info.staked.era, current_era + 1); // Stake valid from next era + + // Verify return values + assert_eq!(era_and_amount_pairs.len(), 1); + assert_eq!(era_and_amount_pairs[0], (current_era, move_amount)); + assert_eq!(stake_moved.voting, 80); + assert_eq!(stake_moved.build_and_earn, 0); +} + +#[test] +fn move_stake_full_transfer() { + get_u8_type!(MaxMoves, 1); + type TestSingularStakingInfo = SingularStakingInfo; + + // Setup initial staking info for source and destination + let current_era = 10; + let subperiod = Subperiod::BuildAndEarn; + let mut source_staking_info = TestSingularStakingInfo::new(1, subperiod); + source_staking_info.staked.add(50, Subperiod::Voting); + source_staking_info.staked.add(50, Subperiod::BuildAndEarn); + source_staking_info.staked.era = current_era; + + let mut destination_staking_info = TestSingularStakingInfo::new(1, subperiod); + + // Move all 100 tokens from source to destination + let move_amount = 100; + let (era_and_amount_pairs, stake_moved) = source_staking_info.move_stake( + &mut destination_staking_info, + move_amount, + current_era, + subperiod, + ); + + // Verify source staking info is emptied + assert_eq!(source_staking_info.staked.voting, 0); + assert_eq!(source_staking_info.staked.build_and_earn, 0); + assert_eq!(source_staking_info.staked.era, current_era); + + // Verify destination staking info - full transfer + assert_eq!(destination_staking_info.staked.voting, 50); + assert_eq!(destination_staking_info.staked.build_and_earn, 50); + assert_eq!(destination_staking_info.staked.era, current_era + 1); + + // Verify return values + assert_eq!(era_and_amount_pairs.len(), 1); + assert_eq!(era_and_amount_pairs[0], (current_era, move_amount)); + assert_eq!(stake_moved.voting, 50); + assert_eq!(stake_moved.build_and_earn, 50); +} + #[test] fn bonus_status_transition_between_subperiods_is_ok() { get_u8_type!(MaxMoves, 1); diff --git a/pallets/dapp-staking/src/types.rs b/pallets/dapp-staking/src/types.rs index dcaecaba4b..f1e2f40a9a 100644 --- a/pallets/dapp-staking/src/types.rs +++ b/pallets/dapp-staking/src/types.rs @@ -75,7 +75,9 @@ use sp_runtime::{ pub use sp_std::{collections::btree_map::BTreeMap, fmt::Debug, vec::Vec}; use astar_primitives::{ - dapp_staking::{DAppId, EraNumber, PeriodNumber, RankedTier, TierSlots as TierSlotsFunc}, + dapp_staking::{ + DAppId, EraNumber, PeriodNumber, RankedTier, StakeAmountMoved, TierSlots as TierSlotsFunc, + }, Balance, BlockNumber, }; @@ -1041,6 +1043,16 @@ impl> BonusStatus { pub fn has_bonus(&self) -> bool { matches!(self, BonusStatus::SafeMovesRemaining(_)) } + + /// Custom equality function to ignore the lack of PartialEq and Eq implementation for ConstU8 in MaxBonusMoves. + pub fn equals(&self, other: &Self) -> bool { + match (self, other) { + (BonusStatus::BonusForfeited, BonusStatus::BonusForfeited) => true, + (BonusStatus::SafeMovesRemaining(a), BonusStatus::SafeMovesRemaining(b)) => a == b, + (BonusStatus::_Phantom(_), BonusStatus::_Phantom(_)) => true, + _ => false, + } + } } impl> PartialEq for BonusStatus { @@ -1203,10 +1215,51 @@ impl> SingularStakingInfo { result } + /// Transfers stake between contracts while maintaining subperiod-specific allocations, era consistency, and bonus-safe conditions. + pub fn move_stake( + &mut self, + destination: &mut SingularStakingInfo, + amount: Balance, + current_era: EraNumber, + subperiod: Subperiod, + ) -> (Vec<(EraNumber, Balance)>, StakeAmountMoved) { + let staked_snapshot = self.staked; + let era_and_amount_pairs = self.unstake(amount, current_era, subperiod); + + let voting_stake_moved = staked_snapshot.voting.saturating_sub(self.staked.voting); + let build_earn_stake_moved = staked_snapshot + .build_and_earn + .saturating_sub(self.staked.build_and_earn); + + // Similar to a stake on destination but for the 2 subperiods + destination.previous_staked = destination.staked; + destination.previous_staked.era = current_era; + if destination.previous_staked.total().is_zero() { + destination.previous_staked = Default::default(); + } + destination + .staked + .add(voting_stake_moved, Subperiod::Voting); + destination + .staked + .add(build_earn_stake_moved, Subperiod::BuildAndEarn); + + // Moved stake is only valid from the next era so we keep it consistent here + destination.staked.era = current_era.saturating_add(1); + + ( + era_and_amount_pairs, + StakeAmountMoved { + voting: voting_stake_moved, + build_and_earn: build_earn_stake_moved, + }, + ) + } + /// Updates the bonus_status based on the current subperiod /// For Voting subperiod: bonus_status is forfeited for full unstake /// For B&E: the number of 'bonus safe moves' remaining is reduced for full unstake or for partial unstake if it exceeds the previous ‘voting’ stake used as a reference (the bonus status changes to 'forfeited' if there are no safe moves remaining) - pub fn update_bonus_status(&mut self, subperiod: Subperiod, previous_voting_stake: Balance) { + pub fn update_bonus_status(&mut self, subperiod: Subperiod, voting_stake_snapshot: Balance) { match subperiod { Subperiod::Voting => { if self.staked.voting.is_zero() { @@ -1214,7 +1267,7 @@ impl> SingularStakingInfo { } } Subperiod::BuildAndEarn => { - if self.staked.voting < previous_voting_stake { + if self.staked.voting < voting_stake_snapshot { self.bonus_status.decrease_moves(); } } @@ -1250,6 +1303,13 @@ impl> SingularStakingInfo { pub fn is_empty(&self) -> bool { self.staked.is_empty() } + + /// Custom equality function to ignore the lack of PartialEq and Eq implementation for ConstU8 in MaxBonusMoves. + pub fn equals(&self, other: &Self) -> bool { + self.previous_staked == other.previous_staked + && self.staked == other.staked + && self.bonus_status.equals(&other.bonus_status) + } } impl> Decode for SingularStakingInfo { diff --git a/pallets/dapp-staking/src/weights.rs b/pallets/dapp-staking/src/weights.rs index 31859fc357..fdc55d0796 100644 --- a/pallets/dapp-staking/src/weights.rs +++ b/pallets/dapp-staking/src/weights.rs @@ -68,6 +68,7 @@ pub trait WeightInfo { fn unstake_from_unregistered() -> Weight; fn cleanup_expired_entries(x: u32, ) -> Weight; fn force() -> Weight; + fn move_stake() -> Weight; fn on_initialize_voting_to_build_and_earn() -> Weight; fn on_initialize_build_and_earn_to_voting() -> Weight; fn on_initialize_build_and_earn_to_build_and_earn() -> Weight; @@ -395,6 +396,27 @@ impl WeightInfo for SubstrateWeight { // Minimum execution time: 11_543_000 picoseconds. Weight::from_parts(11_735_000, 0) } + /// Storage: `DappStaking::IntegratedDApps` (r:2 w:0) + /// Proof: `DappStaking::IntegratedDApps` (`max_values`: Some(65535), `max_size`: Some(116), added: 2096, mode: `MaxEncodedLen`) + /// Storage: `DappStaking::Ledger` (r:1 w:1) + /// Proof: `DappStaking::Ledger` (`max_values`: None, `max_size`: Some(310), added: 2785, mode: `MaxEncodedLen`) + /// Storage: `DappStaking::StakerInfo` (r:2 w:2) + /// Proof: `DappStaking::StakerInfo` (`max_values`: None, `max_size`: Some(179), added: 2654, mode: `MaxEncodedLen`) + /// Storage: `DappStaking::ContractStake` (r:2 w:2) + /// Proof: `DappStaking::ContractStake` (`max_values`: Some(65535), `max_size`: Some(91), added: 2071, mode: `MaxEncodedLen`) + /// Storage: `Balances::Freezes` (r:1 w:1) + /// Proof: `Balances::Freezes` (`max_values`: None, `max_size`: Some(67), added: 2542, mode: `MaxEncodedLen`) + /// Storage: `Balances::Locks` (r:1 w:0) + /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(1299), added: 3774, mode: `MaxEncodedLen`) + fn move_stake() -> Weight { + // Proof Size summary in bytes: + // Measured: `536` + // Estimated: `6298` + // Minimum execution time: 59_000_000 picoseconds. + Weight::from_parts(60_000_000, 6298) + .saturating_add(T::DbWeight::get().reads(9_u64)) + .saturating_add(T::DbWeight::get().writes(6_u64)) + } /// Storage: DappStaking CurrentEraInfo (r:1 w:1) /// Proof: DappStaking CurrentEraInfo (max_values: Some(1), max_size: Some(112), added: 607, mode: MaxEncodedLen) /// Storage: DappStaking EraRewards (r:1 w:1) @@ -809,6 +831,27 @@ impl WeightInfo for () { // Minimum execution time: 11_543_000 picoseconds. Weight::from_parts(11_735_000, 0) } + /// Storage: `DappStaking::IntegratedDApps` (r:2 w:0) + /// Proof: `DappStaking::IntegratedDApps` (`max_values`: Some(65535), `max_size`: Some(116), added: 2096, mode: `MaxEncodedLen`) + /// Storage: `DappStaking::Ledger` (r:1 w:1) + /// Proof: `DappStaking::Ledger` (`max_values`: None, `max_size`: Some(310), added: 2785, mode: `MaxEncodedLen`) + /// Storage: `DappStaking::StakerInfo` (r:2 w:2) + /// Proof: `DappStaking::StakerInfo` (`max_values`: None, `max_size`: Some(179), added: 2654, mode: `MaxEncodedLen`) + /// Storage: `DappStaking::ContractStake` (r:2 w:2) + /// Proof: `DappStaking::ContractStake` (`max_values`: Some(65535), `max_size`: Some(91), added: 2071, mode: `MaxEncodedLen`) + /// Storage: `Balances::Freezes` (r:1 w:1) + /// Proof: `Balances::Freezes` (`max_values`: None, `max_size`: Some(67), added: 2542, mode: `MaxEncodedLen`) + /// Storage: `Balances::Locks` (r:1 w:0) + /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(1299), added: 3774, mode: `MaxEncodedLen`) + fn move_stake() -> Weight { + // Proof Size summary in bytes: + // Measured: `536` + // Estimated: `6298` + // Minimum execution time: 59_000_000 picoseconds. + Weight::from_parts(60_000_000, 6298) + .saturating_add(RocksDbWeight::get().reads(9_u64)) + .saturating_add(RocksDbWeight::get().writes(6_u64)) + } /// Storage: DappStaking CurrentEraInfo (r:1 w:1) /// Proof: DappStaking CurrentEraInfo (max_values: Some(1), max_size: Some(112), added: 607, mode: MaxEncodedLen) /// Storage: DappStaking EraRewards (r:1 w:1) diff --git a/primitives/src/dapp_staking.rs b/primitives/src/dapp_staking.rs index e44c61caf3..3e7542ffc1 100644 --- a/primitives/src/dapp_staking.rs +++ b/primitives/src/dapp_staking.rs @@ -274,6 +274,17 @@ impl RankedTier { } } +pub struct StakeAmountMoved { + pub voting: Balance, + pub build_and_earn: Balance, +} + +impl StakeAmountMoved { + pub fn total(&self) -> Balance { + self.voting.saturating_add(self.build_and_earn) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/runtime/astar/src/weights/pallet_dapp_staking.rs b/runtime/astar/src/weights/pallet_dapp_staking.rs index 29b26bbffc..7723927d74 100644 --- a/runtime/astar/src/weights/pallet_dapp_staking.rs +++ b/runtime/astar/src/weights/pallet_dapp_staking.rs @@ -40,6 +40,8 @@ // --output=./benchmark-results/astar-dev/dapp_staking_weights.rs // --template=./scripts/templates/weight-template.hbs +// TODO: Dummy values for move_stake, do proper benchmark using gha + #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] #![allow(unused_imports)] @@ -370,6 +372,21 @@ impl WeightInfo for SubstrateWeight { Weight::from_parts(8_948_000, 1486) .saturating_add(T::DbWeight::get().reads(1_u64)) } + /// Storage: `DappStaking::IntegratedDApps` (r:2 w:0) + /// Proof: `DappStaking::IntegratedDApps` (`max_values`: Some(65535), `max_size`: Some(116), added: 2096, mode: `MaxEncodedLen`) + /// Storage: `DappStaking::StakerInfo` (r:2 w:2) + /// Proof: `DappStaking::StakerInfo` (`max_values`: None, `max_size`: Some(179), added: 2654, mode: `MaxEncodedLen`) + /// Storage: `DappStaking::ContractStake` (r:2 w:2) + /// Proof: `DappStaking::ContractStake` (`max_values`: Some(65535), `max_size`: Some(91), added: 2071, mode: `MaxEncodedLen`) + fn move_stake() -> Weight { + // Proof Size summary in bytes: + // Measured: `373` + // Estimated: `6298` + // Minimum execution time: 38_000_000 picoseconds. + Weight::from_parts(38_000_000, 6298) + .saturating_add(T::DbWeight::get().reads(6_u64)) + .saturating_add(T::DbWeight::get().writes(4_u64)) + } /// Storage: `DappStaking::CurrentEraInfo` (r:1 w:1) /// Proof: `DappStaking::CurrentEraInfo` (`max_values`: Some(1), `max_size`: Some(112), added: 607, mode: `MaxEncodedLen`) /// Storage: `DappStaking::EraRewards` (r:1 w:1) diff --git a/runtime/shibuya/src/weights/pallet_dapp_staking.rs b/runtime/shibuya/src/weights/pallet_dapp_staking.rs index e10cd8d18c..06c2441c13 100644 --- a/runtime/shibuya/src/weights/pallet_dapp_staking.rs +++ b/runtime/shibuya/src/weights/pallet_dapp_staking.rs @@ -40,6 +40,8 @@ // --output=./benchmark-results/shibuya-dev/dapp_staking_weights.rs // --template=./scripts/templates/weight-template.hbs +// TODO: Dummy values for move_stake, do proper benchmark using gha + #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] #![allow(unused_imports)] @@ -370,6 +372,21 @@ impl WeightInfo for SubstrateWeight { Weight::from_parts(8_696_000, 1486) .saturating_add(T::DbWeight::get().reads(1_u64)) } + /// Storage: `DappStaking::IntegratedDApps` (r:2 w:0) + /// Proof: `DappStaking::IntegratedDApps` (`max_values`: Some(65535), `max_size`: Some(116), added: 2096, mode: `MaxEncodedLen`) + /// Storage: `DappStaking::StakerInfo` (r:2 w:2) + /// Proof: `DappStaking::StakerInfo` (`max_values`: None, `max_size`: Some(179), added: 2654, mode: `MaxEncodedLen`) + /// Storage: `DappStaking::ContractStake` (r:2 w:2) + /// Proof: `DappStaking::ContractStake` (`max_values`: Some(65535), `max_size`: Some(91), added: 2071, mode: `MaxEncodedLen`) + fn move_stake() -> Weight { + // Proof Size summary in bytes: + // Measured: `373` + // Estimated: `6298` + // Minimum execution time: 38_000_000 picoseconds. + Weight::from_parts(38_000_000, 6298) + .saturating_add(T::DbWeight::get().reads(6_u64)) + .saturating_add(T::DbWeight::get().writes(4_u64)) + } /// Storage: `DappStaking::CurrentEraInfo` (r:1 w:1) /// Proof: `DappStaking::CurrentEraInfo` (`max_values`: Some(1), `max_size`: Some(112), added: 607, mode: `MaxEncodedLen`) /// Storage: `DappStaking::EraRewards` (r:1 w:1) diff --git a/runtime/shiden/src/weights/pallet_dapp_staking.rs b/runtime/shiden/src/weights/pallet_dapp_staking.rs index 169284360b..77a9d4a3e7 100644 --- a/runtime/shiden/src/weights/pallet_dapp_staking.rs +++ b/runtime/shiden/src/weights/pallet_dapp_staking.rs @@ -40,6 +40,8 @@ // --output=./benchmark-results/shiden-dev/dapp_staking_weights.rs // --template=./scripts/templates/weight-template.hbs +// TODO: Dummy values for move_stake, do proper benchmark using gha + #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] #![allow(unused_imports)] @@ -370,6 +372,21 @@ impl WeightInfo for SubstrateWeight { Weight::from_parts(8_785_000, 1486) .saturating_add(T::DbWeight::get().reads(1_u64)) } + /// Storage: `DappStaking::IntegratedDApps` (r:2 w:0) + /// Proof: `DappStaking::IntegratedDApps` (`max_values`: Some(65535), `max_size`: Some(116), added: 2096, mode: `MaxEncodedLen`) + /// Storage: `DappStaking::StakerInfo` (r:2 w:2) + /// Proof: `DappStaking::StakerInfo` (`max_values`: None, `max_size`: Some(179), added: 2654, mode: `MaxEncodedLen`) + /// Storage: `DappStaking::ContractStake` (r:2 w:2) + /// Proof: `DappStaking::ContractStake` (`max_values`: Some(65535), `max_size`: Some(91), added: 2071, mode: `MaxEncodedLen`) + fn move_stake() -> Weight { + // Proof Size summary in bytes: + // Measured: `373` + // Estimated: `6298` + // Minimum execution time: 38_000_000 picoseconds. + Weight::from_parts(38_000_000, 6298) + .saturating_add(T::DbWeight::get().reads(6_u64)) + .saturating_add(T::DbWeight::get().writes(4_u64)) + } /// Storage: `DappStaking::CurrentEraInfo` (r:1 w:1) /// Proof: `DappStaking::CurrentEraInfo` (`max_values`: Some(1), `max_size`: Some(112), added: 607, mode: `MaxEncodedLen`) /// Storage: `DappStaking::EraRewards` (r:1 w:1) From df12ac864f59c692090b867a54117dcfc78bfdef Mon Sep 17 00:00:00 2001 From: Igor Papandinas <26460174+ipapandinas@users.noreply.github.com> Date: Mon, 13 Jan 2025 17:53:32 +0100 Subject: [PATCH 3/8] task: v9 mb migration for bonus-status --- pallets/dapp-staking/src/benchmarking/mod.rs | 38 ++++++ pallets/dapp-staking/src/lib.rs | 4 +- pallets/dapp-staking/src/migration.rs | 123 +++++++++++++++++- pallets/dapp-staking/src/test/migrations.rs | 90 ++++++++++++- pallets/dapp-staking/src/test/mock.rs | 6 +- pallets/dapp-staking/src/weights.rs | 23 ++++ .../astar/src/weights/pallet_dapp_staking.rs | 13 +- .../src/weights/pallet_dapp_staking.rs | 13 +- .../shiden/src/weights/pallet_dapp_staking.rs | 13 +- 9 files changed, 314 insertions(+), 9 deletions(-) diff --git a/pallets/dapp-staking/src/benchmarking/mod.rs b/pallets/dapp-staking/src/benchmarking/mod.rs index be904e1a6a..538af1fd9a 100644 --- a/pallets/dapp-staking/src/benchmarking/mod.rs +++ b/pallets/dapp-staking/src/benchmarking/mod.rs @@ -1191,6 +1191,44 @@ mod benchmarks { ); } + /// Benchmark a single step of v9 mbm migration (for bonus_status). + #[benchmark] + fn mbm_step_v9_bonus_status() { + let alice: T::AccountId = account("alice", 0, 1); + let smart_contract = T::BenchmarkHelper::get_smart_contract(1); + + crate::migration::v8::StakerInfo::::set( + &alice, + &smart_contract, + Some(crate::migration::v8::SingularStakingInfo { + previous_staked: Default::default(), + staked: Default::default(), + loyal_staker: true, + }), + ); + + let mut meter = WeightMeter::new(); + + #[block] + { + crate::migration::v9::LazyMigrationBonusStatus::>::step( + None, &mut meter, + ) + .unwrap(); + } + + let expected_staker_info = SingularStakingInfoFor:: { + previous_staked: Default::default(), + staked: Default::default(), + bonus_status: BonusStatus::SafeMovesRemaining(0), + }; + + assert!(match StakerInfo::::get(&alice, &smart_contract) { + Some(staker_info) => staker_info.equals(&expected_staker_info), + _ => false, + }); + } + impl_benchmark_test_suite!( Pallet, crate::benchmarking::tests::new_test_ext(), diff --git a/pallets/dapp-staking/src/lib.rs b/pallets/dapp-staking/src/lib.rs index 7e2c6d9328..dff3a778b9 100644 --- a/pallets/dapp-staking/src/lib.rs +++ b/pallets/dapp-staking/src/lib.rs @@ -94,7 +94,7 @@ pub mod pallet { use super::*; /// The current storage version. - pub const STORAGE_VERSION: StorageVersion = StorageVersion::new(8); + pub const STORAGE_VERSION: StorageVersion = StorageVersion::new(9); #[pallet::pallet] #[pallet::storage_version(STORAGE_VERSION)] @@ -1534,7 +1534,7 @@ pub mod pallet { Self::internal_claim_bonus_reward_for(account, smart_contract) } - /// Transfers stake between two smart contracts, ensuring period alignment, bonus status preservation if elegible, + /// Transfers stake between two smart contracts, ensuring period alignment, bonus status preservation if elegible, /// and adherence to staking limits. Updates all relevant storage and emits a `StakeMoved` event. #[pallet::call_index(21)] #[pallet::weight(T::WeightInfo::move_stake())] diff --git a/pallets/dapp-staking/src/migration.rs b/pallets/dapp-staking/src/migration.rs index 79fa7e858c..044d8f2196 100644 --- a/pallets/dapp-staking/src/migration.rs +++ b/pallets/dapp-staking/src/migration.rs @@ -56,12 +56,133 @@ pub mod versioned_migrations { >; } +pub mod v9 { + use super::*; + + // The loyal staker flag is replaced by 'BonusStatus' + pub struct LazyMigrationBonusStatus(PhantomData<(T, W)>); + + impl SteppedMigration for LazyMigrationBonusStatus { + type Cursor = (::AccountId, T::SmartContract); + // Without the explicit length here the construction of the ID would not be infallible. + type Identifier = MigrationId<16>; + + /// The identifier of this migration. Which should be globally unique. + fn id() -> Self::Identifier { + MigrationId { + pallet_id: *PALLET_MIGRATIONS_ID, + version_from: 8, + version_to: 9, + } + } + + fn step( + mut cursor: Option, + meter: &mut WeightMeter, + ) -> Result, SteppedMigrationError> { + let on_chain_version = Pallet::::on_chain_storage_version(); + if on_chain_version != 9 { + return Ok(None); + } + + let required = W::mbm_step_v9_bonus_status(); + + // If there is not enough weight for a single step, return an error. This case can be + // problematic if it is the first migration that ran in this block. But there is nothing + // that we can do about it here. + if meter.remaining().any_lt(required) { + return Err(SteppedMigrationError::InsufficientWeight { required }); + } + + let mut count = 0u32; + let mut migrated = 0u32; + + loop { + if meter.try_consume(required).is_err() { + break; + } + + let mut iter = if let Some(last_key_pair) = cursor { + // If a cursor is provided, start iterating from the stored value + // corresponding to the last key pair processed in the previous step. + // Note that this only works if the old and the new map use the same way to hash + // storage keys. + v8::StakerInfo::::iter_from(v8::StakerInfo::::hashed_key_for( + last_key_pair.0, + last_key_pair.1, + )) + } else { + // If no cursor is provided, start iterating from the beginning. + v8::StakerInfo::::iter() + }; + + if let Some((account, smart_contract, old_staking_info)) = iter.next() { + // inc count + count.saturating_inc(); + + let bonus_status = if old_staking_info.loyal_staker { + BonusStatusFor::::SafeMovesRemaining(0) + } else { + BonusStatusFor::::BonusForfeited + }; + + let new_staking_info = SingularStakingInfoFor:: { + previous_staked: old_staking_info.previous_staked, + staked: old_staking_info.staked, + bonus_status, + }; + + // Override StakerInfo + StakerInfo::::insert(&account, &smart_contract, new_staking_info); + + // inc migrated + migrated.saturating_inc(); + + // Return the processed key pair as the new cursor. + cursor = Some((account, smart_contract)) + } else { + // Signal that the migration is complete (no more items to process). + cursor = None; + break; + } + } + log::info!(target: LOG_TARGET, "🚚 iterated {count} entries, migrated {migrated}"); + Ok(cursor) + } + } +} + // TierThreshold as percentage of the total issuance -mod v8 { +pub mod v8 { use super::*; use crate::migration::v7::TierParameters as TierParametersV7; use crate::migration::v7::TiersConfiguration as TiersConfigurationV7; + /// Information about how much a particular staker staked on a particular smart contract. + #[derive( + Encode, Decode, MaxEncodedLen, Copy, Clone, Debug, PartialEq, Eq, TypeInfo, Default, + )] + pub struct SingularStakingInfo { + /// Amount staked before, if anything. + pub(crate) previous_staked: StakeAmount, + /// Staked amount + pub(crate) staked: StakeAmount, + /// Indicates whether a staker is a loyal staker or not. + pub(crate) loyal_staker: bool, + } + + /// v8 type for [`crate::StakerInfo`] + #[storage_alias] + pub type StakerInfo = StorageDoubleMap< + Pallet, + Blake2_128Concat, + ::AccountId, + Blake2_128Concat, + ::SmartContract, + SingularStakingInfo, + OptionQuery, + >; + pub struct VersionMigrateV7ToV8( PhantomData<(T, TierThresholds, ThresholdVariationPercentage)>, ); diff --git a/pallets/dapp-staking/src/test/migrations.rs b/pallets/dapp-staking/src/test/migrations.rs index d1e1109acc..a074c71cda 100644 --- a/pallets/dapp-staking/src/test/migrations.rs +++ b/pallets/dapp-staking/src/test/migrations.rs @@ -19,9 +19,14 @@ #![cfg(all(test, not(feature = "runtime-benchmarks")))] use crate::test::mock::*; -use crate::{AccountLedger, CurrentEraInfo, EraInfo, Ledger, UnlockingChunk}; +use crate::{ + AccountLedger, BonusStatus, CurrentEraInfo, EraInfo, Ledger, SingularStakingInfoFor, + StakerInfo, UnlockingChunk, +}; use frame_support::traits::OnRuntimeUpgrade; +use astar_primitives::dapp_staking::SmartContractHandle; + #[test] fn lazy_migrations() { ExtBuilder::default().build_and_execute(|| { @@ -83,3 +88,86 @@ fn lazy_migrations() { ); }) } + +#[test] +fn lazy_migrations_bonus_status() { + ExtBuilder::default().build_and_execute(|| { + let account_1 = 1; + let account_2 = 2; + let contract_1 = MockSmartContract::wasm(1 as AccountId); + let contract_2 = MockSmartContract::wasm(2 as AccountId); + let contract_3 = MockSmartContract::wasm(3 as AccountId); + + crate::migration::v8::StakerInfo::::set( + &account_1, + &contract_1, + Some(crate::migration::v8::SingularStakingInfo { + previous_staked: Default::default(), + staked: Default::default(), + loyal_staker: true, + }), + ); + crate::migration::v8::StakerInfo::::set( + &account_1, + &contract_2, + Some(crate::migration::v8::SingularStakingInfo { + previous_staked: Default::default(), + staked: Default::default(), + loyal_staker: false, + }), + ); + crate::migration::v8::StakerInfo::::set( + &account_2, + &contract_1, + Some(crate::migration::v8::SingularStakingInfo { + previous_staked: Default::default(), + staked: Default::default(), + loyal_staker: false, + }), + ); + crate::migration::v8::StakerInfo::::set( + &account_2, + &contract_3, + Some(crate::migration::v8::SingularStakingInfo { + previous_staked: Default::default(), + staked: Default::default(), + loyal_staker: true, + }), + ); + + // go to block before migration + run_to_block(9); + + // onboard MBMs + AllPalletsWithSystem::on_runtime_upgrade(); + run_to_block(10); + + let expected_staker_info_with_bonus = SingularStakingInfoFor:: { + previous_staked: Default::default(), + staked: Default::default(), + bonus_status: BonusStatus::SafeMovesRemaining(0), + }; + let expected_staker_info_without_bonus = SingularStakingInfoFor:: { + previous_staked: Default::default(), + staked: Default::default(), + bonus_status: BonusStatus::BonusForfeited, + }; + + assert!(match StakerInfo::::get(&account_1, &contract_1) { + Some(staker_info) => staker_info.equals(&expected_staker_info_with_bonus), + _ => false, + }); + assert!(match StakerInfo::::get(&account_1, &contract_2) { + Some(staker_info) => staker_info.equals(&expected_staker_info_without_bonus), + _ => false, + }); + assert!(match StakerInfo::::get(&account_2, &contract_1) { + Some(staker_info) => staker_info.equals(&expected_staker_info_without_bonus), + _ => false, + }); + assert!(match StakerInfo::::get(&account_2, &contract_3) { + Some(staker_info) => staker_info.equals(&expected_staker_info_with_bonus), + _ => false, + }); + }) +} diff --git a/pallets/dapp-staking/src/test/mock.rs b/pallets/dapp-staking/src/test/mock.rs index a84f09e156..56ef8bf275 100644 --- a/pallets/dapp-staking/src/test/mock.rs +++ b/pallets/dapp-staking/src/test/mock.rs @@ -123,8 +123,10 @@ parameter_types! { #[derive_impl(pallet_migrations::config_preludes::TestDefaultConfig)] impl pallet_migrations::Config for Test { #[cfg(not(feature = "runtime-benchmarks"))] - type Migrations = - (crate::migration::LazyMigration>,); + type Migrations = ( + crate::migration::LazyMigration>, + crate::migration::v9::LazyMigrationBonusStatus>, + ); #[cfg(feature = "runtime-benchmarks")] type Migrations = pallet_migrations::mock_helpers::MockedMigrations; type MigrationStatusHandler = (); diff --git a/pallets/dapp-staking/src/weights.rs b/pallets/dapp-staking/src/weights.rs index fdc55d0796..18c3568d10 100644 --- a/pallets/dapp-staking/src/weights.rs +++ b/pallets/dapp-staking/src/weights.rs @@ -75,6 +75,7 @@ pub trait WeightInfo { fn dapp_tier_assignment(x: u32, ) -> Weight; fn on_idle_cleanup() -> Weight; fn step() -> Weight; + fn mbm_step_v9_bonus_status() -> Weight; } /// Weights for pallet_dapp_staking using the Substrate node and recommended hardware. @@ -511,6 +512,17 @@ impl WeightInfo for SubstrateWeight { .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } + /// Storage: `DappStaking::StakerInfo` (r:2 w:1) + /// Proof: `DappStaking::StakerInfo` (`max_values`: None, `max_size`: Some(179), added: 2654, mode: `MaxEncodedLen`) + fn mbm_step_v9_bonus_status() -> Weight { + // Proof Size summary in bytes: + // Measured: `150` + // Estimated: `6298` + // Minimum execution time: 15_000_000 picoseconds. + Weight::from_parts(15_000_000, 6298) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } } // For backwards compatibility and tests @@ -946,4 +958,15 @@ impl WeightInfo for () { .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } + /// Storage: `DappStaking::StakerInfo` (r:2 w:1) + /// Proof: `DappStaking::StakerInfo` (`max_values`: None, `max_size`: Some(179), added: 2654, mode: `MaxEncodedLen`) + fn mbm_step_v9_bonus_status() -> Weight { + // Proof Size summary in bytes: + // Measured: `150` + // Estimated: `6298` + // Minimum execution time: 15_000_000 picoseconds. + Weight::from_parts(15_000_000, 6298) + .saturating_add(RocksDbWeight::get().reads(2_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } } diff --git a/runtime/astar/src/weights/pallet_dapp_staking.rs b/runtime/astar/src/weights/pallet_dapp_staking.rs index 7723927d74..aaaea3ab70 100644 --- a/runtime/astar/src/weights/pallet_dapp_staking.rs +++ b/runtime/astar/src/weights/pallet_dapp_staking.rs @@ -40,7 +40,7 @@ // --output=./benchmark-results/astar-dev/dapp_staking_weights.rs // --template=./scripts/templates/weight-template.hbs -// TODO: Dummy values for move_stake, do proper benchmark using gha +// TODO: Dummy values for move_stake & mbm_step_v9_bonus_status: do proper benchmark using gha #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] @@ -495,4 +495,15 @@ impl WeightInfo for SubstrateWeight { .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } + /// Storage: `DappStaking::StakerInfo` (r:2 w:1) + /// Proof: `DappStaking::StakerInfo` (`max_values`: None, `max_size`: Some(179), added: 2654, mode: `MaxEncodedLen`) + fn mbm_step_v9_bonus_status() -> Weight { + // Proof Size summary in bytes: + // Measured: `150` + // Estimated: `6298` + // Minimum execution time: 15_000_000 picoseconds. + Weight::from_parts(15_000_000, 6298) + .saturating_add(RocksDbWeight::get().reads(2_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } } diff --git a/runtime/shibuya/src/weights/pallet_dapp_staking.rs b/runtime/shibuya/src/weights/pallet_dapp_staking.rs index 06c2441c13..b86703ed8e 100644 --- a/runtime/shibuya/src/weights/pallet_dapp_staking.rs +++ b/runtime/shibuya/src/weights/pallet_dapp_staking.rs @@ -40,7 +40,7 @@ // --output=./benchmark-results/shibuya-dev/dapp_staking_weights.rs // --template=./scripts/templates/weight-template.hbs -// TODO: Dummy values for move_stake, do proper benchmark using gha +// TODO: Dummy values for move_stake & mbm_step_v9_bonus_status: do proper benchmark using gha #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] @@ -495,4 +495,15 @@ impl WeightInfo for SubstrateWeight { .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } + /// Storage: `DappStaking::StakerInfo` (r:2 w:1) + /// Proof: `DappStaking::StakerInfo` (`max_values`: None, `max_size`: Some(179), added: 2654, mode: `MaxEncodedLen`) + fn mbm_step_v9_bonus_status() -> Weight { + // Proof Size summary in bytes: + // Measured: `150` + // Estimated: `6298` + // Minimum execution time: 15_000_000 picoseconds. + Weight::from_parts(15_000_000, 6298) + .saturating_add(RocksDbWeight::get().reads(2_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } } diff --git a/runtime/shiden/src/weights/pallet_dapp_staking.rs b/runtime/shiden/src/weights/pallet_dapp_staking.rs index 77a9d4a3e7..85fea2e464 100644 --- a/runtime/shiden/src/weights/pallet_dapp_staking.rs +++ b/runtime/shiden/src/weights/pallet_dapp_staking.rs @@ -40,7 +40,7 @@ // --output=./benchmark-results/shiden-dev/dapp_staking_weights.rs // --template=./scripts/templates/weight-template.hbs -// TODO: Dummy values for move_stake, do proper benchmark using gha +// TODO: Dummy values for move_stake & mbm_step_v9_bonus_status: do proper benchmark using gha #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] @@ -495,4 +495,15 @@ impl WeightInfo for SubstrateWeight { .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } + /// Storage: `DappStaking::StakerInfo` (r:2 w:1) + /// Proof: `DappStaking::StakerInfo` (`max_values`: None, `max_size`: Some(179), added: 2654, mode: `MaxEncodedLen`) + fn mbm_step_v9_bonus_status() -> Weight { + // Proof Size summary in bytes: + // Measured: `150` + // Estimated: `6298` + // Minimum execution time: 15_000_000 picoseconds. + Weight::from_parts(15_000_000, 6298) + .saturating_add(RocksDbWeight::get().reads(2_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } } From d94b330e822ce765e2df4b11387d00b9dd5b54a7 Mon Sep 17 00:00:00 2001 From: Igor Papandinas <26460174+ipapandinas@users.noreply.github.com> Date: Mon, 13 Jan 2025 18:43:46 +0100 Subject: [PATCH 4/8] task: README updated --- pallets/dapp-staking/README.md | 38 ++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/pallets/dapp-staking/README.md b/pallets/dapp-staking/README.md index 2857ff22e8..63da7d1a88 100644 --- a/pallets/dapp-staking/README.md +++ b/pallets/dapp-staking/README.md @@ -140,10 +140,7 @@ User's stake on a contract must be equal or greater than the `MinimumStakeAmount Although user can stake on multiple smart contracts, the amount is limited. To be more precise, amount of database entries that can exist per user is limited. -The protocol keeps track of how much was staked by the user in `voting` and `build&earn` subperiod. This is important for the bonus reward calculation. Only a limited number of _move actions_ are allowed during the `build&earn` subperiod to preserve bonus reward elegibility. _Move actions_ refer either to: - -- a 'partial unstake with voting stake decrease', -- a 'stake transfer between two contracts'. +The protocol keeps track of how much was staked by the user in `voting` and `build&earn` subperiod. This is important for the bonus reward calculation. It is not possible to stake on a dApp that has been unregistered. However, if dApp is unregistered after user has staked on it, user will keep earning @@ -163,7 +160,23 @@ If unstake would reduce the staked amount below `MinimumStakeAmount`, everything Once period finishes, all stakes are reset back to zero. This means that no unstake operation is needed after period ends to _unstake_ funds - it's done automatically. -If dApp has been unregistered, a special operation to unstake from unregistered contract must be used. +During the `build&earn` subperiod, if unstaking reduces the voting stake, the bonus status will be updated, and the number of allowed _move actions_ for the ongoing period will be reduced. + +If dApp has been unregistered, a special operation to unstake from unregistered contract must be used that preserves bonus elegibility. + +#### Moving Stake Between Contracts + +The moving stake feature allows users to transfer their staked amount between two smart contracts without undergoing the unstake and stake process separately. This feature ensures that the transferred stake remains aligned with the current staking period (effective in the next era), and any bonus eligibility is preserved as long as the conditions for the bonus reward are not violated (move actions are limited by `MaxBonusMovesPerPeriod`). + +Key details about moving stake: + +- The destination contract must be different from the source contract. +- The user must ensure that unclaimed rewards are claimed before initiating a stake move. +- Only a limited number of move actions (defined by `MaxBonusMovesPerPeriod`) are allowed during the `build&earn` subperiod to preserve bonus reward eligibility (check "Claiming Bonus Reward" section below). +- If the destination contract is newly staked, the user's total staked contracts must not exceed the maximum allowed number of staked contracts. +- The destination contract must not be unregistered, but moving stake away from an unregistered contract is allowed without affecting bonus eligibility. + +This feature is particularly useful for stakers who wish to rebalance their stake across multiple contracts (including new registrations) or move their stake to better-performing dApps while retaining the potential for rewards and maintaining bonus eligibility. #### Claiming Staker Rewards @@ -181,7 +194,20 @@ Rewards are calculated using a simple formula: `staker_reward_pool * staker_stak #### Claiming 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. +If a staker has staked on a dApp during the voting subperiod, and the bonus status for the associated staked amount has not been forfeited due to excessive move actions, they remain eligible for the bonus reward. + +Only a limited number of _move actions_ are allowed during the `build&earn` subperiod to preserve bonus reward eligibility. Move actions refer to either: + +- A 'partial unstake that decreases the voting stake', +- A 'stake transfer between two contracts'. (check previous "Moving Stake Between Contracts" section) + +The number of authorized safe move actions is defined by `MaxBonusMovesPerPeriod`. For example: +If 2 safe bonus move actions are allowed for one period, and a user has staked **100** on contract A during the `voting` subperiod and **50** during the `build&earn` subperiod, they can safely: + +1. Unstake **70**, reducing the `voting` stake to **80**. +2. Transfer **50** to contract B. + +After these actions, the user will still be eligible for bonus rewards (**20** on contract A and **50** on contract B). However, if an additional move action is performed on contract A, the bonus eligibility will be forfeited. Bonus rewards need to be claimed per contract, unlike staker rewards. From 68edd56451e86ac109dee8fd0af2d52b7ce96bb8 Mon Sep 17 00:00:00 2001 From: Igor Papandinas <26460174+ipapandinas@users.noreply.github.com> Date: Mon, 13 Jan 2025 21:32:16 +0100 Subject: [PATCH 5/8] fix: tests fixed and weights updated --- pallets/dapp-staking/src/test/tests_types.rs | 18 +- .../src/weights/pallet_dapp_staking.rs | 194 +++++++++--------- 2 files changed, 109 insertions(+), 103 deletions(-) diff --git a/pallets/dapp-staking/src/test/tests_types.rs b/pallets/dapp-staking/src/test/tests_types.rs index a937d239c0..152f784986 100644 --- a/pallets/dapp-staking/src/test/tests_types.rs +++ b/pallets/dapp-staking/src/test/tests_types.rs @@ -2527,20 +2527,20 @@ fn move_stake_basic() { ); // Verify source staking info - assert_eq!(source_staking_info.staked.voting, 20); // 100 - 80 - assert_eq!(source_staking_info.staked.build_and_earn, 50); // Unchanged + assert_eq!(source_staking_info.staked.voting, 70); // 100 - 30 + assert_eq!(source_staking_info.staked.build_and_earn, 0); assert_eq!(source_staking_info.staked.era, current_era); // Verify destination staking info - assert_eq!(destination_staking_info.staked.voting, 80); - assert_eq!(destination_staking_info.staked.build_and_earn, 0); + assert_eq!(destination_staking_info.staked.voting, 30); + assert_eq!(destination_staking_info.staked.build_and_earn, 50); assert_eq!(destination_staking_info.staked.era, current_era + 1); // Stake valid from next era // Verify return values - assert_eq!(era_and_amount_pairs.len(), 1); + assert_eq!(era_and_amount_pairs.len(), 2); assert_eq!(era_and_amount_pairs[0], (current_era, move_amount)); - assert_eq!(stake_moved.voting, 80); - assert_eq!(stake_moved.build_and_earn, 0); + assert_eq!(stake_moved.voting, 30); + assert_eq!(stake_moved.build_and_earn, 50); } #[test] @@ -2570,7 +2570,7 @@ fn move_stake_full_transfer() { // Verify source staking info is emptied assert_eq!(source_staking_info.staked.voting, 0); assert_eq!(source_staking_info.staked.build_and_earn, 0); - assert_eq!(source_staking_info.staked.era, current_era); + assert_eq!(source_staking_info.staked.era, 0); // Staked is empty - Default value expected // Verify destination staking info - full transfer assert_eq!(destination_staking_info.staked.voting, 50); @@ -2578,7 +2578,7 @@ fn move_stake_full_transfer() { assert_eq!(destination_staking_info.staked.era, current_era + 1); // Verify return values - assert_eq!(era_and_amount_pairs.len(), 1); + assert_eq!(era_and_amount_pairs.len(), 2); assert_eq!(era_and_amount_pairs[0], (current_era, move_amount)); assert_eq!(stake_moved.voting, 50); assert_eq!(stake_moved.build_and_earn, 50); diff --git a/runtime/shibuya/src/weights/pallet_dapp_staking.rs b/runtime/shibuya/src/weights/pallet_dapp_staking.rs index b86703ed8e..92dfc70a2b 100644 --- a/runtime/shibuya/src/weights/pallet_dapp_staking.rs +++ b/runtime/shibuya/src/weights/pallet_dapp_staking.rs @@ -40,8 +40,6 @@ // --output=./benchmark-results/shibuya-dev/dapp_staking_weights.rs // --template=./scripts/templates/weight-template.hbs -// TODO: Dummy values for move_stake & mbm_step_v9_bonus_status: do proper benchmark using gha - #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] #![allow(unused_imports)] @@ -57,8 +55,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `0` // Estimated: `0` - // Minimum execution time: 5_644_000 picoseconds. - Weight::from_parts(5_873_000, 0) + // Minimum execution time: 6_098_000 picoseconds. + Weight::from_parts(6_327_000, 0) } /// Storage: `DappStaking::IntegratedDApps` (r:1 w:1) /// Proof: `DappStaking::IntegratedDApps` (`max_values`: Some(65535), `max_size`: Some(116), added: 2096, mode: `MaxEncodedLen`) @@ -70,8 +68,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `0` // Estimated: `3086` - // Minimum execution time: 11_812_000 picoseconds. - Weight::from_parts(12_247_000, 3086) + // Minimum execution time: 12_199_000 picoseconds. + Weight::from_parts(12_522_000, 3086) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().writes(3_u64)) } @@ -81,8 +79,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `97` // Estimated: `3086` - // Minimum execution time: 10_416_000 picoseconds. - Weight::from_parts(10_663_000, 3086) + // Minimum execution time: 10_566_000 picoseconds. + Weight::from_parts(10_804_000, 3086) .saturating_add(T::DbWeight::get().reads(1_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -92,8 +90,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `97` // Estimated: `3086` - // Minimum execution time: 10_216_000 picoseconds. - Weight::from_parts(10_571_000, 3086) + // Minimum execution time: 10_621_000 picoseconds. + Weight::from_parts(10_852_000, 3086) .saturating_add(T::DbWeight::get().reads(1_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } @@ -107,8 +105,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `97` // Estimated: `3086` - // Minimum execution time: 14_241_000 picoseconds. - Weight::from_parts(14_711_000, 3086) + // Minimum execution time: 14_854_000 picoseconds. + Weight::from_parts(15_124_000, 3086) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(3_u64)) } @@ -126,8 +124,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `138` // Estimated: `4764` - // Minimum execution time: 30_193_000 picoseconds. - Weight::from_parts(30_498_000, 4764) + // Minimum execution time: 32_344_000 picoseconds. + Weight::from_parts(32_943_000, 4764) .saturating_add(T::DbWeight::get().reads(5_u64)) .saturating_add(T::DbWeight::get().writes(3_u64)) } @@ -143,8 +141,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `156` // Estimated: `4764` - // Minimum execution time: 30_765_000 picoseconds. - Weight::from_parts(31_167_000, 4764) + // Minimum execution time: 31_864_000 picoseconds. + Weight::from_parts(32_267_000, 4764) .saturating_add(T::DbWeight::get().reads(4_u64)) .saturating_add(T::DbWeight::get().writes(3_u64)) } @@ -160,8 +158,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `156` // Estimated: `4764` - // Minimum execution time: 28_141_000 picoseconds. - Weight::from_parts(28_502_000, 4764) + // Minimum execution time: 29_025_000 picoseconds. + Weight::from_parts(29_438_000, 4764) .saturating_add(T::DbWeight::get().reads(4_u64)) .saturating_add(T::DbWeight::get().writes(3_u64)) } @@ -178,10 +176,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `187` // Estimated: `4764` - // Minimum execution time: 27_809_000 picoseconds. - Weight::from_parts(28_780_892, 4764) - // Standard Error: 4_544 - .saturating_add(Weight::from_parts(190_464, 0).saturating_mul(x.into())) + // Minimum execution time: 29_474_000 picoseconds. + Weight::from_parts(30_710_702, 4764) + // Standard Error: 6_409 + .saturating_add(Weight::from_parts(194_066, 0).saturating_mul(x.into())) .saturating_add(T::DbWeight::get().reads(4_u64)) .saturating_add(T::DbWeight::get().writes(3_u64)) } @@ -197,8 +195,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `182` // Estimated: `4764` - // Minimum execution time: 25_097_000 picoseconds. - Weight::from_parts(25_376_000, 4764) + // Minimum execution time: 26_463_000 picoseconds. + Weight::from_parts(27_068_000, 4764) .saturating_add(T::DbWeight::get().reads(4_u64)) .saturating_add(T::DbWeight::get().writes(3_u64)) } @@ -207,7 +205,7 @@ impl WeightInfo for SubstrateWeight { /// Storage: `DappStaking::Ledger` (r:1 w:1) /// Proof: `DappStaking::Ledger` (`max_values`: None, `max_size`: Some(310), added: 2785, mode: `MaxEncodedLen`) /// Storage: `DappStaking::StakerInfo` (r:1 w:1) - /// Proof: `DappStaking::StakerInfo` (`max_values`: None, `max_size`: Some(178), added: 2653, mode: `MaxEncodedLen`) + /// Proof: `DappStaking::StakerInfo` (`max_values`: None, `max_size`: Some(179), added: 2654, mode: `MaxEncodedLen`) /// Storage: `DappStaking::ContractStake` (r:1 w:1) /// Proof: `DappStaking::ContractStake` (`max_values`: Some(65535), `max_size`: Some(91), added: 2071, mode: `MaxEncodedLen`) /// Storage: `DappStaking::CurrentEraInfo` (r:1 w:1) @@ -220,8 +218,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `272` // Estimated: `4764` - // Minimum execution time: 38_233_000 picoseconds. - Weight::from_parts(38_804_000, 4764) + // Minimum execution time: 39_595_000 picoseconds. + Weight::from_parts(40_088_000, 4764) .saturating_add(T::DbWeight::get().reads(7_u64)) .saturating_add(T::DbWeight::get().writes(5_u64)) } @@ -230,7 +228,7 @@ impl WeightInfo for SubstrateWeight { /// Storage: `DappStaking::Ledger` (r:1 w:1) /// Proof: `DappStaking::Ledger` (`max_values`: None, `max_size`: Some(310), added: 2785, mode: `MaxEncodedLen`) /// Storage: `DappStaking::StakerInfo` (r:1 w:1) - /// Proof: `DappStaking::StakerInfo` (`max_values`: None, `max_size`: Some(178), added: 2653, mode: `MaxEncodedLen`) + /// Proof: `DappStaking::StakerInfo` (`max_values`: None, `max_size`: Some(179), added: 2654, mode: `MaxEncodedLen`) /// Storage: `DappStaking::ContractStake` (r:1 w:1) /// Proof: `DappStaking::ContractStake` (`max_values`: Some(65535), `max_size`: Some(91), added: 2071, mode: `MaxEncodedLen`) /// Storage: `DappStaking::CurrentEraInfo` (r:1 w:1) @@ -241,10 +239,10 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(1299), added: 3774, mode: `MaxEncodedLen`) fn unstake() -> Weight { // Proof Size summary in bytes: - // Measured: `453` + // Measured: `454` // Estimated: `4764` - // Minimum execution time: 42_466_000 picoseconds. - Weight::from_parts(42_850_000, 4764) + // Minimum execution time: 43_110_000 picoseconds. + Weight::from_parts(43_910_000, 4764) .saturating_add(T::DbWeight::get().reads(7_u64)) .saturating_add(T::DbWeight::get().writes(5_u64)) } @@ -263,10 +261,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `522` // Estimated: `4764` - // Minimum execution time: 46_446_000 picoseconds. - Weight::from_parts(45_930_258, 4764) - // Standard Error: 4_071 - .saturating_add(Weight::from_parts(1_720_079, 0).saturating_mul(x.into())) + // Minimum execution time: 49_946_000 picoseconds. + Weight::from_parts(49_147_898, 4764) + // Standard Error: 4_064 + .saturating_add(Weight::from_parts(1_943_911, 0).saturating_mul(x.into())) .saturating_add(T::DbWeight::get().reads(5_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -283,25 +281,25 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `501` // Estimated: `4764` - // Minimum execution time: 44_171_000 picoseconds. - Weight::from_parts(43_679_252, 4764) - // Standard Error: 4_295 - .saturating_add(Weight::from_parts(1_728_663, 0).saturating_mul(x.into())) + // Minimum execution time: 47_733_000 picoseconds. + Weight::from_parts(47_109_880, 4764) + // Standard Error: 4_956 + .saturating_add(Weight::from_parts(1_916_359, 0).saturating_mul(x.into())) .saturating_add(T::DbWeight::get().reads(4_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } /// Storage: `DappStaking::StakerInfo` (r:1 w:1) - /// Proof: `DappStaking::StakerInfo` (`max_values`: None, `max_size`: Some(178), added: 2653, mode: `MaxEncodedLen`) + /// Proof: `DappStaking::StakerInfo` (`max_values`: None, `max_size`: Some(179), added: 2654, mode: `MaxEncodedLen`) /// Storage: `DappStaking::PeriodEnd` (r:1 w:0) /// Proof: `DappStaking::PeriodEnd` (`max_values`: None, `max_size`: Some(48), added: 2523, mode: `MaxEncodedLen`) /// Storage: `DappStaking::Ledger` (r:1 w:1) /// Proof: `DappStaking::Ledger` (`max_values`: None, `max_size`: Some(310), added: 2785, mode: `MaxEncodedLen`) fn claim_bonus_reward() -> Weight { // Proof Size summary in bytes: - // Measured: `271` + // Measured: `272` // Estimated: `3775` - // Minimum execution time: 34_646_000 picoseconds. - Weight::from_parts(34_959_000, 3775) + // Minimum execution time: 37_267_000 picoseconds. + Weight::from_parts(37_943_000, 3775) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -313,15 +311,15 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `2672` // Estimated: `5113` - // Minimum execution time: 50_005_000 picoseconds. - Weight::from_parts(50_884_000, 5113) + // Minimum execution time: 53_090_000 picoseconds. + Weight::from_parts(54_752_000, 5113) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } /// Storage: `DappStaking::IntegratedDApps` (r:1 w:0) /// Proof: `DappStaking::IntegratedDApps` (`max_values`: Some(65535), `max_size`: Some(116), added: 2096, mode: `MaxEncodedLen`) /// Storage: `DappStaking::StakerInfo` (r:1 w:1) - /// Proof: `DappStaking::StakerInfo` (`max_values`: None, `max_size`: Some(178), added: 2653, mode: `MaxEncodedLen`) + /// Proof: `DappStaking::StakerInfo` (`max_values`: None, `max_size`: Some(179), added: 2654, mode: `MaxEncodedLen`) /// Storage: `DappStaking::Ledger` (r:1 w:1) /// Proof: `DappStaking::Ledger` (`max_values`: None, `max_size`: Some(310), added: 2785, mode: `MaxEncodedLen`) /// Storage: `DappStaking::CurrentEraInfo` (r:1 w:1) @@ -332,15 +330,15 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(1299), added: 3774, mode: `MaxEncodedLen`) fn unstake_from_unregistered() -> Weight { // Proof Size summary in bytes: - // Measured: `317` + // Measured: `318` // Estimated: `4764` - // Minimum execution time: 36_058_000 picoseconds. - Weight::from_parts(36_468_000, 4764) + // Minimum execution time: 35_980_000 picoseconds. + Weight::from_parts(36_676_000, 4764) .saturating_add(T::DbWeight::get().reads(6_u64)) .saturating_add(T::DbWeight::get().writes(4_u64)) } /// Storage: `DappStaking::StakerInfo` (r:9 w:8) - /// Proof: `DappStaking::StakerInfo` (`max_values`: None, `max_size`: Some(178), added: 2653, mode: `MaxEncodedLen`) + /// Proof: `DappStaking::StakerInfo` (`max_values`: None, `max_size`: Some(179), added: 2654, mode: `MaxEncodedLen`) /// Storage: `DappStaking::Ledger` (r:1 w:1) /// Proof: `DappStaking::Ledger` (`max_values`: None, `max_size`: Some(310), added: 2785, mode: `MaxEncodedLen`) /// Storage: `Balances::Freezes` (r:1 w:1) @@ -351,16 +349,16 @@ impl WeightInfo for SubstrateWeight { fn cleanup_expired_entries(x: u32, ) -> Weight { // Proof Size summary in bytes: // Measured: `255 + x * (73 ±0)` - // Estimated: `4764 + x * (2653 ±0)` - // Minimum execution time: 35_401_000 picoseconds. - Weight::from_parts(31_550_798, 4764) - // Standard Error: 9_420 - .saturating_add(Weight::from_parts(4_950_275, 0).saturating_mul(x.into())) + // Estimated: `4764 + x * (2654 ±0)` + // Minimum execution time: 36_644_000 picoseconds. + Weight::from_parts(33_541_433, 4764) + // Standard Error: 13_277 + .saturating_add(Weight::from_parts(4_844_988, 0).saturating_mul(x.into())) .saturating_add(T::DbWeight::get().reads(4_u64)) .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(x.into()))) .saturating_add(T::DbWeight::get().writes(2_u64)) .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(x.into()))) - .saturating_add(Weight::from_parts(0, 2653).saturating_mul(x.into())) + .saturating_add(Weight::from_parts(0, 2654).saturating_mul(x.into())) } /// Storage: `DappStaking::Safeguard` (r:1 w:0) /// Proof: `DappStaking::Safeguard` (`max_values`: Some(1), `max_size`: Some(1), added: 496, mode: `MaxEncodedLen`) @@ -368,41 +366,47 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `0` // Estimated: `1486` - // Minimum execution time: 8_432_000 picoseconds. - Weight::from_parts(8_696_000, 1486) + // Minimum execution time: 8_797_000 picoseconds. + Weight::from_parts(9_044_000, 1486) .saturating_add(T::DbWeight::get().reads(1_u64)) } /// Storage: `DappStaking::IntegratedDApps` (r:2 w:0) /// Proof: `DappStaking::IntegratedDApps` (`max_values`: Some(65535), `max_size`: Some(116), added: 2096, mode: `MaxEncodedLen`) + /// Storage: `DappStaking::Ledger` (r:1 w:1) + /// Proof: `DappStaking::Ledger` (`max_values`: None, `max_size`: Some(310), added: 2785, mode: `MaxEncodedLen`) /// Storage: `DappStaking::StakerInfo` (r:2 w:2) /// Proof: `DappStaking::StakerInfo` (`max_values`: None, `max_size`: Some(179), added: 2654, mode: `MaxEncodedLen`) /// Storage: `DappStaking::ContractStake` (r:2 w:2) /// Proof: `DappStaking::ContractStake` (`max_values`: Some(65535), `max_size`: Some(91), added: 2071, mode: `MaxEncodedLen`) + /// Storage: `Balances::Freezes` (r:1 w:1) + /// Proof: `Balances::Freezes` (`max_values`: None, `max_size`: Some(67), added: 2542, mode: `MaxEncodedLen`) + /// Storage: `Balances::Locks` (r:1 w:0) + /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(1299), added: 3774, mode: `MaxEncodedLen`) fn move_stake() -> Weight { // Proof Size summary in bytes: - // Measured: `373` + // Measured: `536` // Estimated: `6298` - // Minimum execution time: 38_000_000 picoseconds. - Weight::from_parts(38_000_000, 6298) - .saturating_add(T::DbWeight::get().reads(6_u64)) - .saturating_add(T::DbWeight::get().writes(4_u64)) + // Minimum execution time: 50_599_000 picoseconds. + Weight::from_parts(51_494_000, 6298) + .saturating_add(T::DbWeight::get().reads(9_u64)) + .saturating_add(T::DbWeight::get().writes(6_u64)) } /// Storage: `DappStaking::CurrentEraInfo` (r:1 w:1) /// Proof: `DappStaking::CurrentEraInfo` (`max_values`: Some(1), `max_size`: Some(112), added: 607, mode: `MaxEncodedLen`) /// Storage: `DappStaking::EraRewards` (r:1 w:1) /// Proof: `DappStaking::EraRewards` (`max_values`: None, `max_size`: Some(789), added: 3264, mode: `MaxEncodedLen`) /// Storage: `DappStaking::StaticTierParams` (r:1 w:0) - /// Proof: `DappStaking::StaticTierParams` (`max_values`: Some(1), `max_size`: Some(167), added: 662, mode: `MaxEncodedLen`) + /// Proof: `DappStaking::StaticTierParams` (`max_values`: Some(1), `max_size`: Some(71), added: 566, mode: `MaxEncodedLen`) /// Storage: `PriceAggregator::ValuesCircularBuffer` (r:1 w:0) /// Proof: `PriceAggregator::ValuesCircularBuffer` (`max_values`: Some(1), `max_size`: Some(117), added: 612, mode: `MaxEncodedLen`) /// Storage: `DappStaking::TierConfig` (r:1 w:1) - /// Proof: `DappStaking::TierConfig` (`max_values`: Some(1), `max_size`: Some(161), added: 656, mode: `MaxEncodedLen`) + /// Proof: `DappStaking::TierConfig` (`max_values`: Some(1), `max_size`: Some(91), added: 586, mode: `MaxEncodedLen`) fn on_initialize_voting_to_build_and_earn() -> Weight { // Proof Size summary in bytes: - // Measured: `334` + // Measured: `196` // Estimated: `4254` - // Minimum execution time: 24_158_000 picoseconds. - Weight::from_parts(24_700_000, 4254) + // Minimum execution time: 25_909_000 picoseconds. + Weight::from_parts(26_770_000, 4254) .saturating_add(T::DbWeight::get().reads(5_u64)) .saturating_add(T::DbWeight::get().writes(3_u64)) } @@ -415,19 +419,19 @@ impl WeightInfo for SubstrateWeight { /// Storage: `DappStaking::EraRewards` (r:1 w:1) /// Proof: `DappStaking::EraRewards` (`max_values`: None, `max_size`: Some(789), added: 3264, mode: `MaxEncodedLen`) /// Storage: `DappStaking::StaticTierParams` (r:1 w:0) - /// Proof: `DappStaking::StaticTierParams` (`max_values`: Some(1), `max_size`: Some(167), added: 662, mode: `MaxEncodedLen`) + /// Proof: `DappStaking::StaticTierParams` (`max_values`: Some(1), `max_size`: Some(71), added: 566, mode: `MaxEncodedLen`) /// Storage: `PriceAggregator::ValuesCircularBuffer` (r:1 w:0) /// Proof: `PriceAggregator::ValuesCircularBuffer` (`max_values`: Some(1), `max_size`: Some(117), added: 612, mode: `MaxEncodedLen`) /// Storage: `DappStaking::TierConfig` (r:1 w:1) - /// Proof: `DappStaking::TierConfig` (`max_values`: Some(1), `max_size`: Some(161), added: 656, mode: `MaxEncodedLen`) + /// Proof: `DappStaking::TierConfig` (`max_values`: Some(1), `max_size`: Some(91), added: 586, mode: `MaxEncodedLen`) /// Storage: `DappStaking::DAppTiers` (r:0 w:1) /// Proof: `DappStaking::DAppTiers` (`max_values`: None, `max_size`: Some(1648), added: 4123, mode: `MaxEncodedLen`) fn on_initialize_build_and_earn_to_voting() -> Weight { // Proof Size summary in bytes: - // Measured: `631` + // Measured: `511` // Estimated: `4254` - // Minimum execution time: 37_971_000 picoseconds. - Weight::from_parts(38_780_000, 4254) + // Minimum execution time: 38_082_000 picoseconds. + Weight::from_parts(38_491_000, 4254) .saturating_add(T::DbWeight::get().reads(7_u64)) .saturating_add(T::DbWeight::get().writes(7_u64)) } @@ -436,35 +440,35 @@ impl WeightInfo for SubstrateWeight { /// Storage: `DappStaking::EraRewards` (r:1 w:1) /// Proof: `DappStaking::EraRewards` (`max_values`: None, `max_size`: Some(789), added: 3264, mode: `MaxEncodedLen`) /// Storage: `DappStaking::StaticTierParams` (r:1 w:0) - /// Proof: `DappStaking::StaticTierParams` (`max_values`: Some(1), `max_size`: Some(167), added: 662, mode: `MaxEncodedLen`) + /// Proof: `DappStaking::StaticTierParams` (`max_values`: Some(1), `max_size`: Some(71), added: 566, mode: `MaxEncodedLen`) /// Storage: `PriceAggregator::ValuesCircularBuffer` (r:1 w:0) /// Proof: `PriceAggregator::ValuesCircularBuffer` (`max_values`: Some(1), `max_size`: Some(117), added: 612, mode: `MaxEncodedLen`) /// Storage: `DappStaking::TierConfig` (r:1 w:1) - /// Proof: `DappStaking::TierConfig` (`max_values`: Some(1), `max_size`: Some(161), added: 656, mode: `MaxEncodedLen`) + /// Proof: `DappStaking::TierConfig` (`max_values`: Some(1), `max_size`: Some(91), added: 586, mode: `MaxEncodedLen`) /// Storage: `DappStaking::DAppTiers` (r:0 w:1) /// Proof: `DappStaking::DAppTiers` (`max_values`: None, `max_size`: Some(1648), added: 4123, mode: `MaxEncodedLen`) fn on_initialize_build_and_earn_to_build_and_earn() -> Weight { // Proof Size summary in bytes: - // Measured: `386` + // Measured: `250` // Estimated: `4254` - // Minimum execution time: 27_435_000 picoseconds. - Weight::from_parts(28_483_000, 4254) + // Minimum execution time: 29_588_000 picoseconds. + Weight::from_parts(29_848_000, 4254) .saturating_add(T::DbWeight::get().reads(5_u64)) .saturating_add(T::DbWeight::get().writes(4_u64)) } /// Storage: `DappStaking::ContractStake` (r:101 w:0) /// Proof: `DappStaking::ContractStake` (`max_values`: Some(65535), `max_size`: Some(91), added: 2071, mode: `MaxEncodedLen`) /// Storage: `DappStaking::TierConfig` (r:1 w:0) - /// Proof: `DappStaking::TierConfig` (`max_values`: Some(1), `max_size`: Some(161), added: 656, mode: `MaxEncodedLen`) + /// Proof: `DappStaking::TierConfig` (`max_values`: Some(1), `max_size`: Some(91), added: 586, mode: `MaxEncodedLen`) /// The range of component `x` is `[0, 100]`. fn dapp_tier_assignment(x: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `152 + x * (32 ±0)` + // Measured: `97 + x * (32 ±0)` // Estimated: `3061 + x * (2071 ±0)` - // Minimum execution time: 8_569_000 picoseconds. - Weight::from_parts(11_220_207, 3061) - // Standard Error: 2_396 - .saturating_add(Weight::from_parts(2_393_849, 0).saturating_mul(x.into())) + // Minimum execution time: 8_573_000 picoseconds. + Weight::from_parts(10_616_356, 3061) + // Standard Error: 2_680 + .saturating_add(Weight::from_parts(2_382_574, 0).saturating_mul(x.into())) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(x.into()))) .saturating_add(Weight::from_parts(0, 2071).saturating_mul(x.into())) @@ -479,8 +483,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `293` // Estimated: `4254` - // Minimum execution time: 8_023_000 picoseconds. - Weight::from_parts(8_354_000, 4254) + // Minimum execution time: 8_248_000 picoseconds. + Weight::from_parts(8_394_000, 4254) .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(3_u64)) } @@ -490,20 +494,22 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `76` // Estimated: `6560` - // Minimum execution time: 10_060_000 picoseconds. - Weight::from_parts(10_314_000, 6560) - .saturating_add(RocksDbWeight::get().reads(2_u64)) - .saturating_add(RocksDbWeight::get().writes(1_u64)) + // Minimum execution time: 12_939_000 picoseconds. + Weight::from_parts(13_209_000, 6560) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) } + /// Storage: UNKNOWN KEY `0x802ef6eba87d4e0518600ef1e62725104e7b9012096b41c4eb3aaf947f6ea429` (r:1 w:0) + /// Proof: UNKNOWN KEY `0x802ef6eba87d4e0518600ef1e62725104e7b9012096b41c4eb3aaf947f6ea429` (r:1 w:0) /// Storage: `DappStaking::StakerInfo` (r:2 w:1) /// Proof: `DappStaking::StakerInfo` (`max_values`: None, `max_size`: Some(179), added: 2654, mode: `MaxEncodedLen`) fn mbm_step_v9_bonus_status() -> Weight { // Proof Size summary in bytes: // Measured: `150` // Estimated: `6298` - // Minimum execution time: 15_000_000 picoseconds. - Weight::from_parts(15_000_000, 6298) - .saturating_add(RocksDbWeight::get().reads(2_u64)) - .saturating_add(RocksDbWeight::get().writes(1_u64)) + // Minimum execution time: 14_773_000 picoseconds. + Weight::from_parts(15_247_000, 6298) + .saturating_add(T::DbWeight::get().reads(3_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) } } From d8045f586408760ea135da3a34644165c20157a6 Mon Sep 17 00:00:00 2001 From: Igor Papandinas <26460174+ipapandinas@users.noreply.github.com> Date: Mon, 13 Jan 2025 23:58:07 +0100 Subject: [PATCH 6/8] task: remove custom 'equals' method --- pallets/dapp-staking/src/benchmarking/mod.rs | 2 +- pallets/dapp-staking/src/test/migrations.rs | 8 +++---- .../dapp-staking/src/test/testing_utils.rs | 2 +- pallets/dapp-staking/src/types.rs | 21 +++++++------------ 4 files changed, 13 insertions(+), 20 deletions(-) diff --git a/pallets/dapp-staking/src/benchmarking/mod.rs b/pallets/dapp-staking/src/benchmarking/mod.rs index 538af1fd9a..71b820f46c 100644 --- a/pallets/dapp-staking/src/benchmarking/mod.rs +++ b/pallets/dapp-staking/src/benchmarking/mod.rs @@ -1224,7 +1224,7 @@ mod benchmarks { }; assert!(match StakerInfo::::get(&alice, &smart_contract) { - Some(staker_info) => staker_info.equals(&expected_staker_info), + Some(staker_info) => staker_info.eq(&expected_staker_info), _ => false, }); } diff --git a/pallets/dapp-staking/src/test/migrations.rs b/pallets/dapp-staking/src/test/migrations.rs index a074c71cda..18ebcd0e5e 100644 --- a/pallets/dapp-staking/src/test/migrations.rs +++ b/pallets/dapp-staking/src/test/migrations.rs @@ -154,19 +154,19 @@ fn lazy_migrations_bonus_status() { }; assert!(match StakerInfo::::get(&account_1, &contract_1) { - Some(staker_info) => staker_info.equals(&expected_staker_info_with_bonus), + Some(staker_info) => staker_info.eq(&expected_staker_info_with_bonus), _ => false, }); assert!(match StakerInfo::::get(&account_1, &contract_2) { - Some(staker_info) => staker_info.equals(&expected_staker_info_without_bonus), + Some(staker_info) => staker_info.eq(&expected_staker_info_without_bonus), _ => false, }); assert!(match StakerInfo::::get(&account_2, &contract_1) { - Some(staker_info) => staker_info.equals(&expected_staker_info_without_bonus), + Some(staker_info) => staker_info.eq(&expected_staker_info_without_bonus), _ => false, }); assert!(match StakerInfo::::get(&account_2, &contract_3) { - Some(staker_info) => staker_info.equals(&expected_staker_info_with_bonus), + Some(staker_info) => staker_info.eq(&expected_staker_info_with_bonus), _ => false, }); }) diff --git a/pallets/dapp-staking/src/test/testing_utils.rs b/pallets/dapp-staking/src/test/testing_utils.rs index effa8e3970..2a53fcddb8 100644 --- a/pallets/dapp-staking/src/test/testing_utils.rs +++ b/pallets/dapp-staking/src/test/testing_utils.rs @@ -879,7 +879,7 @@ pub(crate) fn assert_staker_info( .expect("Staker info entry must exist to verify bonus status."); assert!( - staker_info.equals(&expected_staker_info), + staker_info.eq(&expected_staker_info), "Staker infos do not match." ); } diff --git a/pallets/dapp-staking/src/types.rs b/pallets/dapp-staking/src/types.rs index f1e2f40a9a..eb74a465aa 100644 --- a/pallets/dapp-staking/src/types.rs +++ b/pallets/dapp-staking/src/types.rs @@ -1043,16 +1043,6 @@ impl> BonusStatus { pub fn has_bonus(&self) -> bool { matches!(self, BonusStatus::SafeMovesRemaining(_)) } - - /// Custom equality function to ignore the lack of PartialEq and Eq implementation for ConstU8 in MaxBonusMoves. - pub fn equals(&self, other: &Self) -> bool { - match (self, other) { - (BonusStatus::BonusForfeited, BonusStatus::BonusForfeited) => true, - (BonusStatus::SafeMovesRemaining(a), BonusStatus::SafeMovesRemaining(b)) => a == b, - (BonusStatus::_Phantom(_), BonusStatus::_Phantom(_)) => true, - _ => false, - } - } } impl> PartialEq for BonusStatus { @@ -1070,7 +1060,7 @@ impl> Eq for BonusStatus {} /// Information about how much a particular staker staked on a particular smart contract. /// /// Keeps track of amount staked in the 'voting subperiod', as well as 'build&earn subperiod'. -#[derive(Encode, MaxEncodedLen, Copy, Clone, Debug, PartialEq, Eq, TypeInfo, Default)] +#[derive(Encode, MaxEncodedLen, Copy, Clone, Debug, TypeInfo, Default)] #[scale_info(skip_type_params(MaxBonusMoves))] pub struct SingularStakingInfo> { /// Amount staked before, if anything. @@ -1303,15 +1293,18 @@ impl> SingularStakingInfo { pub fn is_empty(&self) -> bool { self.staked.is_empty() } +} - /// Custom equality function to ignore the lack of PartialEq and Eq implementation for ConstU8 in MaxBonusMoves. - pub fn equals(&self, other: &Self) -> bool { +impl> PartialEq for SingularStakingInfo { + fn eq(&self, other: &Self) -> bool { self.previous_staked == other.previous_staked && self.staked == other.staked - && self.bonus_status.equals(&other.bonus_status) + && self.bonus_status.eq(&other.bonus_status) } } +impl> Eq for SingularStakingInfo {} + impl> Decode for SingularStakingInfo { /// Decodes SingularStakingInfo from input, supporting both current and legacy format with 'loyal_staker' flag. fn decode( From 691b60d2cc0e6e426cd9e98cbae1b5e1db2ed44e Mon Sep 17 00:00:00 2001 From: Igor Papandinas <26460174+ipapandinas@users.noreply.github.com> Date: Tue, 14 Jan 2025 01:19:02 +0100 Subject: [PATCH 7/8] task: maintenance mode enable during migration --- pallets/dapp-staking/src/migration.rs | 20 ++++++++++++++++++-- pallets/dapp-staking/src/weights.rs | 16 ++++++++++------ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/pallets/dapp-staking/src/migration.rs b/pallets/dapp-staking/src/migration.rs index 044d8f2196..dde1c113c8 100644 --- a/pallets/dapp-staking/src/migration.rs +++ b/pallets/dapp-staking/src/migration.rs @@ -112,7 +112,15 @@ pub mod v9 { last_key_pair.1, )) } else { - // If no cursor is provided, start iterating from the beginning. + // If no cursor is provided, migration is starting + + // Enable maintenance mode. + ActiveProtocolState::::mutate(|state| { + state.maintenance = true; + }); + log::warn!("Maintenance mode enabled."); + + // Start iterating from the beginning. v8::StakerInfo::::iter() }; @@ -141,7 +149,15 @@ pub mod v9 { // Return the processed key pair as the new cursor. cursor = Some((account, smart_contract)) } else { - // Signal that the migration is complete (no more items to process). + // Migration is complete + + // Disable maintenance mode. + ActiveProtocolState::::mutate(|state| { + state.maintenance = false; + }); + log::warn!("Maintenance mode disabled."); + + // No more items to process cursor = None; break; } diff --git a/pallets/dapp-staking/src/weights.rs b/pallets/dapp-staking/src/weights.rs index 18c3568d10..874393eff0 100644 --- a/pallets/dapp-staking/src/weights.rs +++ b/pallets/dapp-staking/src/weights.rs @@ -512,15 +512,17 @@ impl WeightInfo for SubstrateWeight { .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } + /// Storage: UNKNOWN KEY `0x802ef6eba87d4e0518600ef1e62725104e7b9012096b41c4eb3aaf947f6ea429` (r:1 w:0) + /// Proof: UNKNOWN KEY `0x802ef6eba87d4e0518600ef1e62725104e7b9012096b41c4eb3aaf947f6ea429` (r:1 w:0) /// Storage: `DappStaking::StakerInfo` (r:2 w:1) /// Proof: `DappStaking::StakerInfo` (`max_values`: None, `max_size`: Some(179), added: 2654, mode: `MaxEncodedLen`) fn mbm_step_v9_bonus_status() -> Weight { // Proof Size summary in bytes: // Measured: `150` // Estimated: `6298` - // Minimum execution time: 15_000_000 picoseconds. - Weight::from_parts(15_000_000, 6298) - .saturating_add(T::DbWeight::get().reads(2_u64)) + // Minimum execution time: 21_000_000 picoseconds. + Weight::from_parts(22_000_000, 6298) + .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } } @@ -958,15 +960,17 @@ impl WeightInfo for () { .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } + /// Storage: UNKNOWN KEY `0x802ef6eba87d4e0518600ef1e62725104e7b9012096b41c4eb3aaf947f6ea429` (r:1 w:0) + /// Proof: UNKNOWN KEY `0x802ef6eba87d4e0518600ef1e62725104e7b9012096b41c4eb3aaf947f6ea429` (r:1 w:0) /// Storage: `DappStaking::StakerInfo` (r:2 w:1) /// Proof: `DappStaking::StakerInfo` (`max_values`: None, `max_size`: Some(179), added: 2654, mode: `MaxEncodedLen`) fn mbm_step_v9_bonus_status() -> Weight { // Proof Size summary in bytes: // Measured: `150` // Estimated: `6298` - // Minimum execution time: 15_000_000 picoseconds. - Weight::from_parts(15_000_000, 6298) - .saturating_add(RocksDbWeight::get().reads(2_u64)) + // Minimum execution time: 21_000_000 picoseconds. + Weight::from_parts(22_000_000, 6298) + .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } } From ce5d89a75b552ab6ef2326b85504c6cdccaabaec Mon Sep 17 00:00:00 2001 From: Igor Papandinas <26460174+ipapandinas@users.noreply.github.com> Date: Tue, 14 Jan 2025 15:00:18 +0100 Subject: [PATCH 8/8] task: update rust-toolchain --- rust-toolchain.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust-toolchain.toml b/rust-toolchain.toml index a03f6dbfcb..78fa5b92e8 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,5 +1,5 @@ [toolchain] -channel = "1.77.0" +channel = "1.78.0" components = ["rust-src", "rustfmt", "clippy"] targets = ["wasm32-unknown-unknown"] profile = "minimal"