From 42a70755dfc297faf5962a2213cd6955043564c6 Mon Sep 17 00:00:00 2001 From: Dino Pacandi Date: Thu, 5 Oct 2023 16:58:03 +0200 Subject: [PATCH] stake test utils & first test --- pallets/dapp-staking-v3/src/lib.rs | 21 +- .../dapp-staking-v3/src/test/testing_utils.rs | 194 ++++++++++++++++- pallets/dapp-staking-v3/src/test/tests.rs | 20 ++ pallets/dapp-staking-v3/src/types.rs | 200 +++++++++++------- 4 files changed, 353 insertions(+), 82 deletions(-) diff --git a/pallets/dapp-staking-v3/src/lib.rs b/pallets/dapp-staking-v3/src/lib.rs index 1ed4123327..f253fe6a0b 100644 --- a/pallets/dapp-staking-v3/src/lib.rs +++ b/pallets/dapp-staking-v3/src/lib.rs @@ -213,6 +213,8 @@ pub mod pallet { InternalStakeError, /// Total staked amount on contract is below the minimum required value. InsufficientStakeAmount, + /// Stake operation is rejected since period ends in the next era. + PeriodEndsInNextEra, } /// General information about dApp staking protocol state. @@ -614,12 +616,14 @@ pub mod pallet { ); let protocol_state = ActiveProtocolState::::get(); - let mut ledger = Ledger::::get(&account); - // Staker always stakes from the NEXT era let stake_era = protocol_state.era.saturating_add(1); + ensure!( + !protocol_state.period_info.is_ending(stake_era), + Error::::PeriodEndsInNextEra + ); - // TODO: add a check if Build&Earn period ends in the next era. If it does, staking should fail since it's pointless. + let mut ledger = Ledger::::get(&account); // 1. // Increase stake amount for the next era & current period in staker's ledger @@ -639,7 +643,7 @@ pub mod pallet { // // There are two distinct scenarios: // 1. Existing entry matches the current period number - just update it. - // 2. Entry doesn't exist or it's for an older era - create a new one. + // 2. Entry doesn't exist or it's for an older period - create a new one. // // This is ok since we only use this storage entry to keep track of how much each staker // has staked on each contract in the current period. We only ever need the latest information. @@ -676,7 +680,7 @@ pub mod pallet { ); // 4. - // Update total staked amount in this era. + // Update total staked amount for the next era. CurrentEraInfo::::mutate(|era_info| { era_info.add_stake_amount(amount, protocol_state.period_info.period_type); }); @@ -693,10 +697,9 @@ pub mod pallet { amount, }); - // TODO: maybe keep track of pending bonus rewards in the AccountLedger struct? - // That way it's easy to check if stake can even be called - bonus-rewards should be zero & last staked era should be None or current one. - - // TODO: has user claimed past rewards? Can we force them to do it before they start staking again? + // TODO: Handle both the bonus rewards, and the regular rewards via `AccountLedger`. + // Keep track of total stake both for both periods, and use it to calculate rewards. + // It literally seems like the simplest way to do it. Ok(()) } diff --git a/pallets/dapp-staking-v3/src/test/testing_utils.rs b/pallets/dapp-staking-v3/src/test/testing_utils.rs index e377a8a555..db9df7ed26 100644 --- a/pallets/dapp-staking-v3/src/test/testing_utils.rs +++ b/pallets/dapp-staking-v3/src/test/testing_utils.rs @@ -19,8 +19,8 @@ use crate::test::mock::*; use crate::types::*; use crate::{ - pallet as pallet_dapp_staking, ActiveProtocolState, BlockNumberFor, CurrentEraInfo, DAppId, - Event, IntegratedDApps, Ledger, NextDAppId, + pallet as pallet_dapp_staking, ActiveProtocolState, BlockNumberFor, ContractStake, + CurrentEraInfo, DAppId, Event, IntegratedDApps, Ledger, NextDAppId, StakerInfo, }; use frame_support::{assert_ok, traits::Get}; @@ -39,6 +39,15 @@ pub(crate) struct MemorySnapshot { DAppInfo<::AccountId>, >, ledger: HashMap<::AccountId, AccountLedgerFor>, + staker_info: HashMap< + ( + ::AccountId, + ::SmartContract, + ), + SingularStakingInfo, + >, + contract_stake: + HashMap<::SmartContract, ContractStakingInfoSeries>, } impl MemorySnapshot { @@ -50,6 +59,10 @@ impl MemorySnapshot { current_era_info: CurrentEraInfo::::get(), integrated_dapps: IntegratedDApps::::iter().collect(), ledger: Ledger::::iter().collect(), + staker_info: StakerInfo::::iter() + .map(|(k1, k2, v)| ((k1, k2), v)) + .collect(), + contract_stake: ContractStake::::iter().collect(), } } @@ -385,3 +398,180 @@ pub(crate) fn assert_relock_unlocking(account: AccountId) { pre_snapshot.current_era_info.total_locked + amount ); } + +/// Stake some funds on the specified smart contract. +pub(crate) fn assert_stake( + account: AccountId, + smart_contract: &MockSmartContract, + amount: Balance, +) { + // TODO: this is a huge function - I could break it down, but I'm not sure it will help with readability. + let pre_snapshot = MemorySnapshot::new(); + let pre_ledger = pre_snapshot.ledger.get(&account).unwrap(); + let pre_staker_info = pre_snapshot + .staker_info + .get(&(account, smart_contract.clone())); + let pre_contract_stake = pre_snapshot + .contract_stake + .get(&smart_contract) + .map_or(ContractStakingInfoSeries::default(), |series| { + series.clone() + }); + let pre_era_info = pre_snapshot.current_era_info; + + let stake_era = pre_snapshot.active_protocol_state.era + 1; + let stake_period = pre_snapshot.active_protocol_state.period_info.number; + let stake_period_type = pre_snapshot.active_protocol_state.period_info.period_type; + + // Stake on smart contract & verify event + assert_ok!(DappStaking::stake( + RuntimeOrigin::signed(account), + smart_contract.clone(), + amount + )); + System::assert_last_event(RuntimeEvent::DappStaking(Event::Stake { + account, + smart_contract: smart_contract.clone(), + amount, + })); + + // Verify post-state + let post_snapshot = MemorySnapshot::new(); + let post_ledger = post_snapshot.ledger.get(&account).unwrap(); + let post_staker_info = post_snapshot + .staker_info + .get(&(account, *smart_contract)) + .expect("Entry must exist since 'stake' operation was successfull."); + let post_contract_stake = post_snapshot + .contract_stake + .get(&smart_contract) + .expect("Entry must exist since 'stake' operation was successfull."); + let post_era_info = post_snapshot.current_era_info; + + // 1. verify ledger + // ===================== + // ===================== + assert_eq!(post_ledger.staked_period, Some(stake_period)); + assert_eq!( + post_ledger.staked_amount(stake_period), + pre_ledger.staked_amount(stake_period) + amount, + "Stake amount must increase by the 'amount'" + ); + assert_eq!( + post_ledger.stakeable_amount(stake_period), + pre_ledger.stakeable_amount(stake_period) - amount, + "Stakeable amount must decrease by the 'amount'" + ); + match pre_ledger.last_stake_era() { + Some(last_stake_era) if last_stake_era == stake_era => { + assert_eq!( + post_ledger.staked.0.len(), + pre_ledger.staked.0.len(), + "Existing entry must be modified." + ); + } + _ => { + assert_eq!( + post_ledger.staked.0.len(), + pre_ledger.staked.0.len() + 1, + "Additional entry must be added." + ); + } + } + + // 2. 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() + amount, + "Total staked amount must increase by the 'amount'" + ); + assert_eq!( + post_staker_info.staked_amount(stake_period_type), + pre_staker_info.staked_amount(stake_period_type) + amount, + "Staked amount must increase by the 'amount'" + ); + 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." + ); + } + // 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_period_type), + amount, + "Staked amount must be equal to exactly the 'amount'" + ); + assert_eq!(post_staker_info.period_number(), stake_period); + assert_eq!( + post_staker_info.is_loyal(), + stake_period_type == PeriodType::Voting + ); + } + } + + // 3. verify contract stake + // ========================= + // ========================= + // TODO: since default value is all zeros, maybe we can just skip the branching code and do it once? + match pre_contract_stake.last_stake_period() { + Some(last_stake_period) if last_stake_period == stake_period => { + assert_eq!(post_contract_stake.len(), pre_contract_stake.len()); + assert_eq!( + post_contract_stake.total_staked_amount(stake_period), + pre_contract_stake.total_staked_amount(stake_period) + amount, + "Staked amount must increase by the 'amount'" + ); + assert_eq!( + post_contract_stake.staked_amount(stake_period, stake_period_type), + pre_contract_stake.staked_amount(stake_period, stake_period_type) + amount, + "Staked amount must increase by the 'amount'" + ); + } + _ => { + assert_eq!(post_contract_stake.len(), 1); + assert_eq!( + post_contract_stake.total_staked_amount(stake_period), + amount, + "Total staked amount must be equal to exactly the 'amount'" + ); + assert_eq!( + post_contract_stake.staked_amount(stake_period, stake_period_type), + amount, + "Staked amount must be equal to exactly the 'amount'" + ); + } + } + assert_eq!(post_contract_stake.last_stake_period(), Some(stake_period)); + assert_eq!(post_contract_stake.last_stake_era(), Some(stake_era)); + + // 4. verify era info + // ========================= + // ========================= + assert_eq!( + post_era_info.total_staked_amount(), + pre_era_info.total_staked_amount(), + "Total staked amount for the current era must remain the same." + ); + assert_eq!( + post_era_info.total_staked_amount_next_era(), + pre_era_info.total_staked_amount_next_era() + amount + ); + assert_eq!( + post_era_info.staked_amount_next_era(stake_period_type), + pre_era_info.staked_amount_next_era(stake_period_type) + amount + ); +} diff --git a/pallets/dapp-staking-v3/src/test/tests.rs b/pallets/dapp-staking-v3/src/test/tests.rs index 508b830707..f185173c90 100644 --- a/pallets/dapp-staking-v3/src/test/tests.rs +++ b/pallets/dapp-staking-v3/src/test/tests.rs @@ -698,3 +698,23 @@ fn relock_unlocking_insufficient_lock_amount_fails() { ); }) } + +#[test] +fn stake_basic_example_is_ok() { + ExtBuilder::build().execute_with(|| { + // Register smart contract & lock some amount + let dev_account = 1; + let smart_contract = MockSmartContract::default(); + assert_register(dev_account, &smart_contract); + + let account = 2; + let lock_amount = 300; + assert_lock(account, lock_amount); + + // Stake some amount + let first_stake_amount = 100; + assert_stake(account, &smart_contract, first_stake_amount); + + // continue here + }) +} diff --git a/pallets/dapp-staking-v3/src/types.rs b/pallets/dapp-staking-v3/src/types.rs index 45add9d669..977cf961b3 100644 --- a/pallets/dapp-staking-v3/src/types.rs +++ b/pallets/dapp-staking-v3/src/types.rs @@ -246,6 +246,7 @@ pub struct PeriodInfo { } impl PeriodInfo { + /// Create new instance of `PeriodInfo` pub fn new(number: PeriodNumber, period_type: PeriodType, ending_era: EraNumber) -> Self { Self { number, @@ -253,6 +254,11 @@ impl PeriodInfo { ending_era, } } + + /// `true` if period ends in the provided `era` argument, `false` otherwise + pub fn is_ending(&self, era: EraNumber) -> bool { + self.ending_era == era + } } /// Force types to speed up the next era, and even period. @@ -485,76 +491,6 @@ where .saturating_add(self.unlocking_amount()) } - /// Active staked balance. - /// - /// In case latest stored information is from the past period, active stake is considered to be zero. - pub fn active_stake(&self, active_period: PeriodNumber) -> Balance { - match self.staked_period { - Some(last_staked_period) if last_staked_period == active_period => self - .staked - .0 - .last() - .map_or(Balance::zero(), |chunk| chunk.amount), - _ => Balance::zero(), - } - } - - /// Amount that is available for staking. - /// - /// This is equal to the total active locked amount, minus the staked amount already active. - pub fn stakeable_amount(&self, active_period: PeriodNumber) -> Balance { - self.active_locked_amount() - .saturating_sub(self.active_stake(active_period)) - } - - /// Amount that is staked, in respect to currently active period. - pub fn staked_amount(&self, active_period: PeriodNumber) -> Balance { - match self.staked_period { - Some(last_staked_period) if last_staked_period == active_period => self - .staked - .0 - .last() - // We should never fallback to the default value since that would mean ledger is in invalid state. - // TODO: perhaps this can be implemented in a better way to have some error handling? Returning 0 might not be the most secure way to handle it. - .map_or(Balance::zero(), |chunk| chunk.amount), - _ => Balance::zero(), - } - } - - /// Adds the specified amount to total staked amount, if possible. - /// - /// Staking is allowed only allowed if one of the two following conditions is met: - /// 1. Staker is staking again in the period in which they already staked. - /// 2. Staker is staking for the first time in this period, and there are no staking chunks from the previous eras. - /// - /// Additonally, the staked amount must not exceed what's available for staking. - pub fn add_stake_amount( - &mut self, - amount: Balance, - era: EraNumber, - current_period: PeriodNumber, - ) -> Result<(), AccountLedgerError> { - if amount.is_zero() { - return Ok(()); - } - - match self.staked_period { - Some(last_staked_period) if last_staked_period != current_period => { - return Err(AccountLedgerError::InvalidPeriod); - } - _ => (), - } - - if self.stakeable_amount(current_period) < amount { - return Err(AccountLedgerError::UnavailableStakeFunds); - } - - self.staked.add_amount(amount, era)?; - self.staked_period = Some(current_period); - - Ok(()) - } - /// Adds the specified amount to the total locked amount. pub fn add_lock_amount(&mut self, amount: Balance) { self.locked.saturating_accrue(amount); @@ -633,6 +569,86 @@ where amount } + + /// Active staked balance. + /// + /// In case latest stored information is from the past period, active stake is considered to be zero. + pub fn active_stake(&self, active_period: PeriodNumber) -> Balance { + match self.staked_period { + Some(last_staked_period) if last_staked_period == active_period => self + .staked + .0 + .last() + .map_or(Balance::zero(), |chunk| chunk.amount), + _ => Balance::zero(), + } + } + + /// Amount that is available for staking. + /// + /// This is equal to the total active locked amount, minus the staked amount already active. + pub fn stakeable_amount(&self, active_period: PeriodNumber) -> Balance { + self.active_locked_amount() + .saturating_sub(self.active_stake(active_period)) + } + + /// Amount that is staked, in respect to currently active period. + pub fn staked_amount(&self, active_period: PeriodNumber) -> Balance { + match self.staked_period { + Some(last_staked_period) if last_staked_period == active_period => self + .staked + .0 + .last() + // We should never fallback to the default value since that would mean ledger is in invalid state. + // TODO: perhaps this can be implemented in a better way to have some error handling? Returning 0 might not be the most secure way to handle it. + .map_or(Balance::zero(), |chunk| chunk.amount), + _ => Balance::zero(), + } + } + + /// Adds the specified amount to total staked amount, if possible. + /// + /// Staking is allowed only allowed if one of the two following conditions is met: + /// 1. Staker is staking again in the period in which they already staked. + /// 2. Staker is staking for the first time in this period, and there are no staking chunks from the previous eras. + /// + /// Additonally, the staked amount must not exceed what's available for staking. + pub fn add_stake_amount( + &mut self, + amount: Balance, + era: EraNumber, + current_period: PeriodNumber, + ) -> Result<(), AccountLedgerError> { + if amount.is_zero() { + return Ok(()); + } + + match self.staked_period { + Some(last_staked_period) if last_staked_period != current_period => { + return Err(AccountLedgerError::InvalidPeriod); + } + _ => (), + } + + if self.stakeable_amount(current_period) < amount { + return Err(AccountLedgerError::UnavailableStakeFunds); + } + + self.staked.add_amount(amount, era)?; + self.staked_period = Some(current_period); + + Ok(()) + } + + /// Last era for which a stake entry exists. + /// If no stake entries exist, returns `None`. + pub fn last_stake_era(&self) -> Option { + if let Some(chunk) = self.staked.0.last() { + Some(chunk.era) + } else { + None + } + } } /// Rewards pool for stakers & dApps @@ -938,12 +954,18 @@ pub struct ContractStakingInfoSeries( BoundedVec>, ); impl ContractStakingInfoSeries { - /// Helper + /// Helper TODO #[cfg(test)] pub fn new(inner: Vec) -> Self { Self(BoundedVec::try_from(inner).expect("Test should ensure this is always valid")) } + /// TODO + #[cfg(test)] + pub fn into_inner(self) -> Vec { + self.0.into_inner() + } + /// Length of the series. pub fn len(&self) -> usize { self.0.len() @@ -983,6 +1005,42 @@ impl ContractStakingInfoSeries { } } + /// Last era for which a stake entry exists, `None` if no entries exist. + pub fn last_stake_era(&self) -> Option { + if let Some(last_element) = self.0.last() { + Some(last_element.era()) + } else { + None + } + } + + /// Last period for which a stake entry exists, `None` if no entries exist. + pub fn last_stake_period(&self) -> Option { + if let Some(last_element) = self.0.last() { + Some(last_element.period()) + } else { + None + } + } + + pub fn total_staked_amount(&self, period: PeriodNumber) -> Balance { + match self.0.last() { + Some(last_element) if last_element.period() == period => { + last_element.total_staked_amount() + } + _ => Balance::zero(), + } + } + + pub fn staked_amount(&self, period: PeriodNumber, period_type: PeriodType) -> Balance { + match self.0.last() { + Some(last_element) if last_element.period() == period => { + last_element.staked_amount(period_type) + } + _ => Balance::zero(), + } + } + /// Stake the specified `amount` on the contract, for the specified `period_type` and `era`. pub fn stake( &mut self,