diff --git a/frame/dapp-staking-v3/src/lib.rs b/frame/dapp-staking-v3/src/lib.rs index d2057651..575bb608 100644 --- a/frame/dapp-staking-v3/src/lib.rs +++ b/frame/dapp-staking-v3/src/lib.rs @@ -99,6 +99,10 @@ pub mod pallet { /// Minimum amount an account has to lock in dApp staking in order to participate. #[pallet::constant] type MinimumLockedAmount: Get>; + + /// Amount of blocks that need to pass before unlocking chunks can be claimed by the owner. + #[pallet::constant] + type UnlockingPeriod: Get>; } #[pallet::event] @@ -130,6 +134,12 @@ pub mod pallet { account: T::AccountId, amount: BalanceOf, }, + // TODO: do we also add unlocking block info to the event? + /// Account has started the unlocking process for some amount. + Unlocking { + account: T::AccountId, + amount: BalanceOf, + }, } #[pallet::error] @@ -155,6 +165,10 @@ pub mod pallet { LockedAmountBelowThreshold, /// Cannot add additional locked balance chunks due to size limit. TooManyLockedBalanceChunks, + /// Cannot add additional unlocking chunks due to size limit + TooManyUnlockingChunks, + /// Remaining stake prevents entire balance of starting the unlocking process. + RemainingStakePreventsFullUnlock, } /// General information about dApp staking protocol state. @@ -388,7 +402,7 @@ pub mod pallet { // Calculate & check amount available for locking let available_balance = - T::Currency::free_balance(&account).saturating_sub(ledger.locked_amount()); + T::Currency::free_balance(&account).saturating_sub(ledger.active_locked_amount()); let amount_to_lock = available_balance.min(amount); ensure!(!amount_to_lock.is_zero(), Error::::ZeroAmount); @@ -398,13 +412,13 @@ pub mod pallet { .add_lock_amount(amount_to_lock, lock_era) .map_err(|_| Error::::TooManyLockedBalanceChunks)?; ensure!( - ledger.locked_amount() >= T::MinimumLockedAmount::get(), + ledger.active_locked_amount() >= T::MinimumLockedAmount::get(), Error::::LockedAmountBelowThreshold ); Self::update_ledger(&account, ledger); CurrentEraInfo::::mutate(|era_info| { - era_info.total_locked.saturating_accrue(amount_to_lock); + era_info.add_locked(amount_to_lock); }); Self::deposit_event(Event::::Locked { @@ -414,6 +428,68 @@ pub mod pallet { Ok(()) } + + /// Attempts to start the unlocking process for the specified amount. + /// + /// Only the amount that isn't actively used for staking can be unlocked. + /// If the amount is greater than the available amount for unlocking, everything is unlocked. + /// If the remaining locked amount would take the account below the minimum locked amount, everything is unlocked. + #[pallet::call_index(6)] + #[pallet::weight(Weight::zero())] + pub fn unlock( + origin: OriginFor, + #[pallet::compact] amount: BalanceOf, + ) -> DispatchResult { + Self::ensure_pallet_enabled()?; + let account = ensure_signed(origin)?; + + let state = ActiveProtocolState::::get(); + let mut ledger = Ledger::::get(&account); + + let available_for_unlocking = ledger.unlockable_amount(state.period); + let amount_to_unlock = available_for_unlocking.min(amount); + + // Ensure we unlock everything if remaining amount is below threshold. + let remaining_amount = ledger + .active_locked_amount() + .saturating_sub(amount_to_unlock); + let amount_to_unlock = if remaining_amount < T::MinimumLockedAmount::get() { + ensure!( + ledger.active_stake(state.period).is_zero(), + Error::::RemainingStakePreventsFullUnlock + ); + ledger.active_locked_amount() + } else { + amount_to_unlock + }; + + // Sanity check + ensure!(!amount_to_unlock.is_zero(), Error::::ZeroAmount); + + // Update ledger with new lock and unlocking amounts + ledger + .subtract_lock_amount(amount_to_unlock, state.era) + .map_err(|_| Error::::TooManyLockedBalanceChunks)?; + + let current_block = frame_system::Pallet::::block_number(); + let unlock_block = current_block.saturating_add(T::UnlockingPeriod::get()); + ledger + .add_unlocking_chunk(amount_to_unlock, unlock_block) + .map_err(|_| Error::::TooManyUnlockingChunks)?; + + // Update storage + Self::update_ledger(&account, ledger); + CurrentEraInfo::::mutate(|era_info| { + era_info.unlocking_started(amount_to_unlock); + }); + + Self::deposit_event(Event::::Unlocking { + account, + amount: amount_to_unlock, + }); + + Ok(()) + } } impl Pallet { @@ -450,7 +526,7 @@ pub mod pallet { T::Currency::set_lock( STAKING_ID, account, - ledger.locked_amount(), + ledger.active_locked_amount(), WithdrawReasons::all(), ); Ledger::::insert(account, ledger); diff --git a/frame/dapp-staking-v3/src/test/mock.rs b/frame/dapp-staking-v3/src/test/mock.rs index d4f11189..d3e331f1 100644 --- a/frame/dapp-staking-v3/src/test/mock.rs +++ b/frame/dapp-staking-v3/src/test/mock.rs @@ -20,7 +20,7 @@ use crate::{self as pallet_dapp_staking, *}; use frame_support::{ construct_runtime, parameter_types, - traits::{ConstU128, ConstU16, ConstU32}, + traits::{ConstU128, ConstU16, ConstU32, ConstU64}, weights::Weight, }; use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; @@ -109,6 +109,7 @@ impl pallet_dapp_staking::Config for Test { type MaxLockedChunks = ConstU32<5>; type MaxUnlockingChunks = ConstU32<5>; type MinimumLockedAmount = ConstU128; + type UnlockingPeriod = ConstU64<20>; } #[derive(PartialEq, Eq, Copy, Clone, Encode, Decode, Debug, TypeInfo, MaxEncodedLen, Hash)] @@ -144,6 +145,15 @@ impl ExtBuilder { ext.execute_with(|| { System::set_block_number(1); DappStaking::on_initialize(System::block_number()); + + // TODO: remove this after proper on_init handling is implemented + pallet_dapp_staking::ActiveProtocolState::::put(ProtocolState { + era: 1, + next_era_start: BlockNumber::from(101_u32), + period: 1, + period_type: PeriodType::Voting(16), + maintenance: false, + }); }); ext @@ -163,7 +173,7 @@ pub(crate) fn _run_to_block(n: u64) { /// Run for the specified number of blocks. /// Function assumes first block has been initialized. -pub(crate) fn _run_for_blocks(n: u64) { +pub(crate) fn run_for_blocks(n: u64) { _run_to_block(System::block_number() + n); } @@ -174,3 +184,11 @@ pub(crate) fn advance_to_era(era: EraNumber) { // TODO: Properly implement this later when additional logic has been implemented ActiveProtocolState::::mutate(|state| state.era = era); } + +/// Advance blocks until the specified period has been reached. +/// +/// Function has no effect if period is already passed. +pub(crate) fn advance_to_period(period: PeriodNumber) { + // TODO: Properly implement this later when additional logic has been implemented + ActiveProtocolState::::mutate(|state| state.period = period); +} diff --git a/frame/dapp-staking-v3/src/test/testing_utils.rs b/frame/dapp-staking-v3/src/test/testing_utils.rs index f383e9af..25d52490 100644 --- a/frame/dapp-staking-v3/src/test/testing_utils.rs +++ b/frame/dapp-staking-v3/src/test/testing_utils.rs @@ -17,9 +17,14 @@ // along with Astar. If not, see . use crate::test::mock::*; -use crate::*; +use crate::types::*; +use crate::{ + pallet as pallet_dapp_staking, ActiveProtocolState, BlockNumberFor, CurrentEraInfo, DAppId, + Event, IntegratedDApps, Ledger, NextDAppId, +}; -use frame_support::assert_ok; +use frame_support::{assert_ok, traits::Get}; +use sp_runtime::traits::Zero; use std::collections::HashMap; /// Helper struct used to store the entire pallet state snapshot. @@ -29,7 +34,7 @@ pub(crate) struct MemorySnapshot { next_dapp_id: DAppId, current_era_info: EraInfo>, integrated_dapps: HashMap< - ::SmartContract, + ::SmartContract, DAppInfo<::AccountId>, >, ledger: HashMap<::AccountId, AccountLedgerFor>, @@ -52,7 +57,7 @@ impl MemorySnapshot { pub fn locked_balance(&self, account: &AccountId) -> Balance { self.ledger .get(&account) - .map_or(Balance::zero(), |ledger| ledger.locked_amount()) + .map_or(Balance::zero(), |ledger| ledger.active_locked_amount()) } } @@ -196,7 +201,7 @@ pub(crate) fn assert_lock(account: AccountId, amount: Balance) { .ledger .get(&account) .expect("Ledger entry has to exist after succcessful lock call") - .era(), + .lock_era(), post_snapshot.active_protocol_state.era + 1 ); @@ -211,3 +216,90 @@ pub(crate) fn assert_lock(account: AccountId, amount: Balance) { "Active era locked amount should remain exactly the same." ); } + +/// Lock funds into dApp staking and assert success. +pub(crate) fn assert_unlock(account: AccountId, amount: Balance) { + let pre_snapshot = MemorySnapshot::new(); + + assert!( + pre_snapshot.ledger.contains_key(&account), + "Cannot unlock for non-existing ledger." + ); + + // Calculate expected unlock amount + let pre_ledger = &pre_snapshot.ledger[&account]; + let expected_unlock_amount = { + // Cannot unlock more than is available + let possible_unlock_amount = pre_ledger + .unlockable_amount(pre_snapshot.active_protocol_state.period) + .min(amount); + + // When unlocking would take accounn below the minimum lock threshold, unlock everything + let locked_amount = pre_ledger.active_locked_amount(); + let min_locked_amount = ::MinimumLockedAmount::get(); + if locked_amount.saturating_sub(possible_unlock_amount) < min_locked_amount { + locked_amount + } else { + possible_unlock_amount + } + }; + + // Unlock funds + assert_ok!(DappStaking::unlock(RuntimeOrigin::signed(account), amount,)); + System::assert_last_event(RuntimeEvent::DappStaking(Event::Unlocking { + account, + amount: expected_unlock_amount, + })); + + // Verify post-state + let post_snapshot = MemorySnapshot::new(); + + // Verify ledger is as expected + let period_number = pre_snapshot.active_protocol_state.period; + let post_ledger = &post_snapshot.ledger[&account]; + assert_eq!( + pre_ledger.active_locked_amount(), + post_ledger.active_locked_amount() + expected_unlock_amount, + "Active locked amount should be decreased by the amount unlocked." + ); + assert_eq!( + pre_ledger.unlocking_amount() + expected_unlock_amount, + post_ledger.unlocking_amount(), + "Total unlocking amount should be increased by the amount unlocked." + ); + assert_eq!( + pre_ledger.total_locked_amount(), + post_ledger.total_locked_amount(), + "Total locked amount should remain exactly the same since the unlocking chunks are still locked." + ); + assert_eq!( + pre_ledger.unlockable_amount(period_number), + post_ledger.unlockable_amount(period_number) + expected_unlock_amount, + "Unlockable amount should be decreased by the amount unlocked." + ); + + // In case ledger is empty, it should have been removed from the storage + if post_ledger.is_empty() { + assert!(!Ledger::::contains_key(&account)); + } + + // Verify era info post-state + let pre_era_info = &pre_snapshot.current_era_info; + let post_era_info = &post_snapshot.current_era_info; + assert_eq!( + pre_era_info.unlocking + expected_unlock_amount, + post_era_info.unlocking + ); + assert_eq!( + pre_era_info + .total_locked + .saturating_sub(expected_unlock_amount), + post_era_info.total_locked + ); + assert_eq!( + pre_era_info + .active_era_locked + .saturating_sub(expected_unlock_amount), + post_era_info.active_era_locked + ); +} diff --git a/frame/dapp-staking-v3/src/test/tests.rs b/frame/dapp-staking-v3/src/test/tests.rs index 381a3851..dd8c4f54 100644 --- a/frame/dapp-staking-v3/src/test/tests.rs +++ b/frame/dapp-staking-v3/src/test/tests.rs @@ -19,7 +19,8 @@ use crate::test::mock::*; use crate::test::testing_utils::*; use crate::{ - pallet as pallet_dapp_staking, ActiveProtocolState, DAppId, Error, IntegratedDApps, NextDAppId, + pallet as pallet_dapp_staking, ActiveProtocolState, DAppId, Error, IntegratedDApps, Ledger, + NextDAppId, StakeInfo, }; use frame_support::{assert_noop, assert_ok, error::BadOrigin, traits::Get}; @@ -78,6 +79,10 @@ fn maintenace_mode_call_filtering_works() { DappStaking::lock(RuntimeOrigin::signed(1), 100), Error::::Disabled ); + assert_noop!( + DappStaking::unlock(RuntimeOrigin::signed(1), 100), + Error::::Disabled + ); }) } @@ -362,3 +367,220 @@ fn lock_with_too_many_chunks_fails() { ); }) } + +#[test] +fn unlock_basic_example_is_ok() { + ExtBuilder::build().execute_with(|| { + // Lock some amount + let account = 2; + let lock_amount = 101; + assert_lock(account, lock_amount); + + // Unlock some amount in the same era that it was locked + let first_unlock_amount = 7; + assert_unlock(account, first_unlock_amount); + + // Advance era and unlock additional amount + advance_to_era(ActiveProtocolState::::get().era + 1); + assert_unlock(account, first_unlock_amount); + + // Lock a bit more, and unlock again + assert_lock(account, lock_amount); + assert_unlock(account, first_unlock_amount); + }) +} + +#[test] +fn unlock_with_remaining_amount_below_threshold_is_ok() { + ExtBuilder::build().execute_with(|| { + // Lock some amount in a few eras + let account = 2; + let lock_amount = 101; + assert_lock(account, lock_amount); + advance_to_era(ActiveProtocolState::::get().era + 1); + assert_lock(account, lock_amount); + advance_to_era(ActiveProtocolState::::get().era + 3); + + // Unlock such amount that remaining amount is below threshold, resulting in full unlock + let minimum_locked_amount: Balance = + ::MinimumLockedAmount::get(); + let ledger = Ledger::::get(&account); + assert_unlock( + account, + ledger.active_locked_amount() - minimum_locked_amount + 1, + ); + }) +} + +#[test] +fn unlock_with_amount_higher_than_avaiable_is_ok() { + ExtBuilder::build().execute_with(|| { + // Lock some amount in a few eras + let account = 2; + let lock_amount = 101; + assert_lock(account, lock_amount); + advance_to_era(ActiveProtocolState::::get().era + 1); + assert_lock(account, lock_amount); + + // Hacky, maybe improve later when staking is implemented? + let stake_amount = 91; + Ledger::::mutate(&account, |ledger| { + ledger.staked = StakeInfo { + amount: stake_amount, + period: ActiveProtocolState::::get().period, + } + }); + + // Try to unlock more than is available, due to active staked amount + assert_unlock(account, lock_amount - stake_amount + 1); + + // Ensure there is no effect of staked amount once we move to the following period + assert_lock(account, lock_amount - stake_amount); // restore previous state + advance_to_period(ActiveProtocolState::::get().period + 1); + assert_unlock(account, lock_amount - stake_amount + 1); + }) +} + +#[test] +fn unlock_advanced_examples_are_ok() { + ExtBuilder::build().execute_with(|| { + // Lock some amount + let account = 2; + let lock_amount = 101; + assert_lock(account, lock_amount); + + // Unlock some amount in the same era that it was locked + let unlock_amount = 7; + assert_unlock(account, unlock_amount); + + // Advance era and unlock additional amount + advance_to_era(ActiveProtocolState::::get().era + 1); + assert_unlock(account, unlock_amount * 2); + + // Advance few more eras, and unlock everything + advance_to_era(ActiveProtocolState::::get().era + 7); + assert_unlock(account, lock_amount); + assert!(Ledger::::get(&account) + .active_locked_amount() + .is_zero()); + + // Advance one more era and ensure we can still lock & unlock + advance_to_era(ActiveProtocolState::::get().era + 1); + assert_lock(account, lock_amount); + assert_unlock(account, unlock_amount); + }) +} + +#[test] +fn unlock_everything_with_active_stake_fails() { + ExtBuilder::build().execute_with(|| { + let account = 2; + let lock_amount = 101; + assert_lock(account, lock_amount); + advance_to_era(ActiveProtocolState::::get().era + 1); + + // We stake so the amount is just below the minimum locked amount, causing full unlock impossible. + let minimum_locked_amount: Balance = + ::MinimumLockedAmount::get(); + let stake_amount = minimum_locked_amount - 1; + // Hacky, maybe improve later when staking is implemented? + Ledger::::mutate(&account, |ledger| { + ledger.staked = StakeInfo { + amount: stake_amount, + period: ActiveProtocolState::::get().period, + } + }); + + // Try to unlock more than is available, due to active staked amount + assert_noop!( + DappStaking::unlock(RuntimeOrigin::signed(account), lock_amount), + Error::::RemainingStakePreventsFullUnlock, + ); + }) +} + +#[test] +fn unlock_with_zero_amount_fails() { + ExtBuilder::build().execute_with(|| { + let account = 2; + let lock_amount = 101; + assert_lock(account, lock_amount); + advance_to_era(ActiveProtocolState::::get().era + 1); + + // Unlock with zero fails + assert_noop!( + DappStaking::unlock(RuntimeOrigin::signed(account), 0), + Error::::ZeroAmount, + ); + + // Stake everything, so available unlock amount is always zero + // Hacky, maybe improve later when staking is implemented? + Ledger::::mutate(&account, |ledger| { + ledger.staked = StakeInfo { + amount: lock_amount, + period: ActiveProtocolState::::get().period, + } + }); + + // Try to unlock anything, expect zero amount error + assert_noop!( + DappStaking::unlock(RuntimeOrigin::signed(account), lock_amount), + Error::::ZeroAmount, + ); + }) +} + +#[test] +fn unlock_with_exceeding_locked_storage_limits_fails() { + ExtBuilder::build().execute_with(|| { + let account = 2; + let lock_amount = 103; + assert_lock(account, lock_amount); + + let unlock_amount = 3; + for _ in 0..::MaxLockedChunks::get() { + advance_to_era(ActiveProtocolState::::get().era + 1); + assert_unlock(account, unlock_amount); + } + + // We can still unlock in the current era, theoretically + for _ in 0..5 { + assert_unlock(account, unlock_amount); + } + + // Following unlock should fail due to exceeding storage limits + advance_to_era(ActiveProtocolState::::get().era + 1); + assert_noop!( + DappStaking::unlock(RuntimeOrigin::signed(account), unlock_amount), + Error::::TooManyLockedBalanceChunks, + ); + }) +} + +#[test] +fn unlock_with_exceeding_unlocking_chunks_storage_limits_fails() { + ExtBuilder::build().execute_with(|| { + // Lock some amount in a few eras + let account = 2; + let lock_amount = 103; + assert_lock(account, lock_amount); + + let unlock_amount = 3; + for _ in 0..::MaxUnlockingChunks::get() { + run_for_blocks(1); + assert_unlock(account, unlock_amount); + } + + // We can still unlock in the current erblocka, theoretically + for _ in 0..5 { + assert_unlock(account, unlock_amount); + } + + // Following unlock should fail due to exceeding storage limits + run_for_blocks(1); + assert_noop!( + DappStaking::unlock(RuntimeOrigin::signed(account), unlock_amount), + Error::::TooManyUnlockingChunks, + ); + }) +} diff --git a/frame/dapp-staking-v3/src/test/tests_types.rs b/frame/dapp-staking-v3/src/test/tests_types.rs index 1f2419d7..dfdda103 100644 --- a/frame/dapp-staking-v3/src/test/tests_types.rs +++ b/frame/dapp-staking-v3/src/test/tests_types.rs @@ -22,6 +22,7 @@ use crate::*; // Helper to generate custom `Get` types for testing the `AccountLedger` struct. macro_rules! get_u32_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() -> u32 { @@ -49,8 +50,8 @@ fn account_ledger_default() { let acc_ledger = AccountLedger::::default(); assert!(acc_ledger.is_empty()); - assert!(acc_ledger.locked_amount().is_zero()); - assert!(acc_ledger.era().is_zero()); + assert!(acc_ledger.active_locked_amount().is_zero()); + assert!(acc_ledger.lock_era().is_zero()); assert!(acc_ledger.latest_locked_chunk().is_none()); } @@ -63,16 +64,19 @@ fn account_ledger_add_lock_amount_works() { // First step, sanity checks let first_era = 1; - assert!(acc_ledger.locked_amount().is_zero()); + assert!(acc_ledger.active_locked_amount().is_zero()); + assert!(acc_ledger.total_locked_amount().is_zero()); assert!(acc_ledger.add_lock_amount(0, first_era).is_ok()); - assert!(acc_ledger.locked_amount().is_zero()); + assert!(acc_ledger.active_locked_amount().is_zero()); // Adding lock value works as expected let init_amount = 20; assert!(acc_ledger.add_lock_amount(init_amount, first_era).is_ok()); - assert_eq!(acc_ledger.locked_amount(), init_amount); - assert_eq!(acc_ledger.era(), first_era); + assert_eq!(acc_ledger.active_locked_amount(), init_amount); + assert_eq!(acc_ledger.total_locked_amount(), init_amount); + assert_eq!(acc_ledger.lock_era(), first_era); assert!(!acc_ledger.is_empty()); + assert_eq!(acc_ledger.locked.len(), 1); assert_eq!( acc_ledger.latest_locked_chunk(), Some(&LockedChunk:: { @@ -84,22 +88,536 @@ fn account_ledger_add_lock_amount_works() { // Add to the same era let addition = 7; assert!(acc_ledger.add_lock_amount(addition, first_era).is_ok()); - assert_eq!(acc_ledger.locked_amount(), init_amount + addition); - assert_eq!(acc_ledger.era(), first_era); + assert_eq!(acc_ledger.active_locked_amount(), init_amount + addition); + assert_eq!(acc_ledger.total_locked_amount(), init_amount + addition); + assert_eq!(acc_ledger.lock_era(), first_era); + assert_eq!(acc_ledger.locked.len(), 1); // Add up to storage limit for i in 2..=LockedDummy::get() { assert!(acc_ledger.add_lock_amount(addition, first_era + i).is_ok()); assert_eq!( - acc_ledger.locked_amount(), + acc_ledger.active_locked_amount(), init_amount + addition * i as u128 ); - assert_eq!(acc_ledger.era(), first_era + i); + assert_eq!(acc_ledger.lock_era(), first_era + i); + assert_eq!(acc_ledger.locked.len(), i as usize); } // Any further additions should fail due to exhausting bounded storage capacity + let acc_ledger_clone = acc_ledger.clone(); assert!(acc_ledger - .add_lock_amount(addition, acc_ledger.era() + 1) + .add_lock_amount(addition, acc_ledger.lock_era() + 1) .is_err()); - assert!(!acc_ledger.is_empty()); + assert_eq!(acc_ledger, acc_ledger_clone); +} + +#[test] +fn account_ledger_subtract_lock_amount_basic_usage_works() { + get_u32_type!(LockedDummy, 5); + get_u32_type!(UnlockingDummy, 5); + let mut acc_ledger = + AccountLedger::::default(); + + // Sanity check scenario + // Cannot reduce if there is nothing locked, should be a noop + assert!(acc_ledger.subtract_lock_amount(0, 1).is_ok()); + assert!(acc_ledger.subtract_lock_amount(10, 1).is_ok()); + assert!(acc_ledger.locked.len().is_zero()); + assert!(acc_ledger.is_empty()); + + // First basic scenario + // Add some lock amount, then reduce it for the same era + let first_era = 1; + let first_lock_amount = 19; + let unlock_amount = 7; + assert!(acc_ledger + .add_lock_amount(first_lock_amount, first_era) + .is_ok()); + assert!(acc_ledger + .subtract_lock_amount(unlock_amount, first_era) + .is_ok()); + assert_eq!(acc_ledger.locked.len(), 1); + assert_eq!( + acc_ledger.total_locked_amount(), + first_lock_amount - unlock_amount + ); + assert_eq!( + acc_ledger.active_locked_amount(), + first_lock_amount - unlock_amount + ); + assert_eq!(acc_ledger.unlocking_amount(), 0); + + // Second basic scenario + // Reduce the lock from the era which isn't latest in the vector + let first_lock_amount = first_lock_amount - unlock_amount; + let second_lock_amount = 31; + let second_era = 2; + assert!(acc_ledger + .add_lock_amount(second_lock_amount - first_lock_amount, second_era) + .is_ok()); + assert_eq!(acc_ledger.active_locked_amount(), second_lock_amount); + assert_eq!(acc_ledger.locked.len(), 2); + + // Subtract from the first era and verify state is as expected + assert!(acc_ledger + .subtract_lock_amount(unlock_amount, first_era) + .is_ok()); + assert_eq!(acc_ledger.locked.len(), 2); + assert_eq!( + acc_ledger.active_locked_amount(), + second_lock_amount - unlock_amount + ); + assert_eq!( + acc_ledger.locked[0].amount, + first_lock_amount - unlock_amount + ); + assert_eq!( + acc_ledger.locked[1].amount, + second_lock_amount - unlock_amount + ); + + // Third basic scenario + // Reduce the the latest era, don't expect the first one to change + assert!(acc_ledger + .subtract_lock_amount(unlock_amount, second_era) + .is_ok()); + assert_eq!(acc_ledger.locked.len(), 2); + assert_eq!( + acc_ledger.active_locked_amount(), + second_lock_amount - unlock_amount * 2 + ); + assert_eq!( + acc_ledger.locked[0].amount, + first_lock_amount - unlock_amount + ); + assert_eq!( + acc_ledger.locked[1].amount, + second_lock_amount - unlock_amount * 2 + ); +} + +#[test] +fn account_ledger_subtract_lock_amount_overflow_fails() { + get_u32_type!(LockedDummy, 5); + get_u32_type!(UnlockingDummy, 5); + let mut acc_ledger = + AccountLedger::::default(); + + let first_lock_amount = 17 * 19; + let era = 1; + let unlock_amount = 5; + assert!(acc_ledger.add_lock_amount(first_lock_amount, era).is_ok()); + for idx in 1..=LockedDummy::get() { + assert!(acc_ledger.subtract_lock_amount(unlock_amount, idx).is_ok()); + assert_eq!(acc_ledger.locked.len(), idx as usize); + assert_eq!( + acc_ledger.active_locked_amount(), + first_lock_amount - unlock_amount * idx as u128 + ); + } + + // Updating existing lock should still work + let locked_snapshot = acc_ledger.locked.clone(); + for i in 1..10 { + assert!(acc_ledger + .subtract_lock_amount(unlock_amount, LockedDummy::get()) + .is_ok()); + assert_eq!(acc_ledger.locked.len(), LockedDummy::get() as usize); + + let last_idx = LockedDummy::get() as usize - 1; + assert_eq!( + &acc_ledger.locked[0..last_idx], + &locked_snapshot[0..last_idx] + ); + assert_eq!( + acc_ledger.locked[last_idx].amount as u128 + unlock_amount * i, + locked_snapshot[last_idx].amount + ); + } + + // Attempt to add additional chunks should fail, and is a noop. + let acc_ledger_clone = acc_ledger.clone(); + assert!(acc_ledger + .subtract_lock_amount(unlock_amount, LockedDummy::get() + 1) + .is_err()); + assert_eq!(acc_ledger, acc_ledger_clone); +} + +#[test] +fn account_ledger_subtract_lock_amount_advanced_example_works() { + get_u32_type!(LockedDummy, 5); + get_u32_type!(UnlockingDummy, 5); + let mut acc_ledger = + AccountLedger::::default(); + + // Prepare an example where we have two non-consecutive entries, and we unlock in the era right before the second entry. + // This covers a scenario where user has called `lock` in the current era, + // creating an entry for the next era, and then decides to immediately unlock a portion of the locked amount. + let first_lock_amount = 17; + let second_lock_amount = 23; + let first_era = 1; + let second_era = 5; + let unlock_era = second_era - 1; + let unlock_amount = 5; + assert!(acc_ledger + .add_lock_amount(first_lock_amount, first_era) + .is_ok()); + assert!(acc_ledger + .add_lock_amount(second_lock_amount, second_era) + .is_ok()); + assert_eq!(acc_ledger.locked.len(), 2); + + assert!(acc_ledger + .subtract_lock_amount(unlock_amount, unlock_era) + .is_ok()); + assert_eq!( + acc_ledger.active_locked_amount(), + first_lock_amount + second_lock_amount - unlock_amount + ); + + // Check entries in more detail + assert_eq!(acc_ledger.locked.len(), 3); + assert_eq!(acc_ledger.locked[0].amount, first_lock_amount,); + assert_eq!( + acc_ledger.locked[2].amount, + first_lock_amount + second_lock_amount - unlock_amount + ); + // Verify the new entry is as expected + assert_eq!( + acc_ledger.locked[1].amount, + first_lock_amount - unlock_amount + ); + assert_eq!(acc_ledger.locked[1].era, unlock_era); +} + +#[test] +fn account_ledger_subtract_lock_amount_with_only_one_locked_chunk() { + get_u32_type!(LockedDummy, 5); + get_u32_type!(UnlockingDummy, 5); + let mut acc_ledger = + AccountLedger::::default(); + + // Scenario: user locks for era 2 while era 1 is active, immediately followed by unlock call. + // Locked amount should be updated for the next era, but active locked amount should be unchanged (zero). + let lock_amount = 17; + let unlock_amount = 5; + let lock_era = 2; + let unlock_era = 1; + assert!(acc_ledger.add_lock_amount(lock_amount, lock_era).is_ok()); + assert!(acc_ledger + .subtract_lock_amount(unlock_amount, unlock_era) + .is_ok()); + + assert_eq!(acc_ledger.locked.len(), 1); + assert_eq!( + acc_ledger.locked[0], + LockedChunk { + amount: lock_amount - unlock_amount, + era: lock_era, + } + ); +} + +#[test] +fn account_ledger_subtract_lock_amount_correct_zero_cleanup() { + get_u32_type!(LockedDummy, 5); + get_u32_type!(UnlockingDummy, 5); + let mut acc_ledger = + AccountLedger::::default(); + + // Ensure that zero entries are cleaned up correctly when required. + // There are a couple of distinct scenarios: + // 1. There is only one entry, and it's zero. The vector should be cleared & empty. + // 2. There are multiple entries, and the last one is zero. It's valid since it marks when someone fully unlocked. + // 3. Zero entry can exist in between two non-zero entries (not covered in this UT). + + // 1st scenario (A) - only one zero entry, unlock is in the same era + let lock_amount = 17; + let lock_era = 2; + assert!(acc_ledger.add_lock_amount(lock_amount, lock_era).is_ok()); + assert!(acc_ledger + .subtract_lock_amount(lock_amount, lock_era) + .is_ok()); + assert!(acc_ledger.locked.is_empty()); + + // 1st scenario (B) - only one zero entry, unlock is in the previous era + assert!(acc_ledger.add_lock_amount(lock_amount, lock_era).is_ok()); + assert!(acc_ledger + .subtract_lock_amount(lock_amount, lock_era - 1) + .is_ok()); + assert!(acc_ledger.locked.is_empty()); + + // 2nd scenario - last entry is zero + let first_lock_era = 3; + let second_lock_era = 11; + let unlock_era = second_lock_era + 2; + assert!(acc_ledger + .add_lock_amount(lock_amount, first_lock_era) + .is_ok()); + assert!(acc_ledger + .add_lock_amount(lock_amount, second_lock_era) + .is_ok()); + // Following should add new entry, to mark when the user fully unlocked + assert!(acc_ledger + .subtract_lock_amount(acc_ledger.active_locked_amount(), unlock_era) + .is_ok()); + assert_eq!(acc_ledger.locked.len(), 3); + assert!(acc_ledger.active_locked_amount().is_zero()); +} + +#[test] +fn account_ledger_subtract_lock_amount_zero_entry_between_two_non_zero() { + get_u32_type!(LockedDummy, 5); + get_u32_type!(UnlockingDummy, 5); + let mut acc_ledger = + AccountLedger::::default(); + + let (first_lock_amount, second_lock_amount, third_lock_amount) = (17, 23, 29); + let (first_lock_era, second_lock_era, third_lock_era) = (1, 3, 7); + + // Prepare scenario with 3 locked chunks + assert!(acc_ledger + .add_lock_amount(first_lock_amount, first_lock_era) + .is_ok()); + assert!(acc_ledger + .add_lock_amount(second_lock_amount, second_lock_era) + .is_ok()); + assert!(acc_ledger + .add_lock_amount(third_lock_amount, third_lock_era) + .is_ok()); + assert_eq!(acc_ledger.locked.len(), 3); + + // Unlock everything for the era right before the latest chunk era + // This should result in scenario like: + // [17, 17 + 23, 0, 29] + assert!(acc_ledger + .subtract_lock_amount(first_lock_amount + second_lock_amount, third_lock_era - 1) + .is_ok()); + assert_eq!(acc_ledger.locked.len(), 4); + assert_eq!(acc_ledger.active_locked_amount(), third_lock_amount); + assert_eq!( + acc_ledger.locked[0], + LockedChunk { + amount: first_lock_amount, + era: first_lock_era + } + ); + assert_eq!( + acc_ledger.locked[1], + LockedChunk { + amount: first_lock_amount + second_lock_amount, + era: second_lock_era + } + ); + assert_eq!( + acc_ledger.locked[2], + LockedChunk { + amount: 0, + era: third_lock_era - 1 + } + ); + assert_eq!( + acc_ledger.locked[3], + LockedChunk { + amount: third_lock_amount, + era: third_lock_era + } + ); +} + +#[test] +fn account_ledger_subtract_lock_amount_consecutive_zeroes_merged() { + get_u32_type!(LockedDummy, 5); + get_u32_type!(UnlockingDummy, 5); + let mut acc_ledger = + AccountLedger::::default(); + + // Prepare scenario with 3 locked chunks, where the middle one is zero + let lock_amount = 61; + let last_era = 11; + assert!(acc_ledger.add_lock_amount(lock_amount, 2).is_ok()); + assert!(acc_ledger.subtract_lock_amount(lock_amount, 5).is_ok()); + assert!(acc_ledger.add_lock_amount(lock_amount, last_era).is_ok()); + let second_chunk = acc_ledger.locked[1]; + + // Unlock everything in the era right before the latest chunk era, but that chunk should not persist + // [61, 0, 61] --> [61, 0, 0, 61] shouldn't happen since the 2nd zero is redundant. + assert!(acc_ledger + .subtract_lock_amount(lock_amount, last_era - 1) + .is_ok()); + assert_eq!(acc_ledger.locked.len(), 3); + assert_eq!(acc_ledger.locked[1], second_chunk); +} + +#[test] +fn account_ledger_add_unlocking_chunk_works() { + get_u32_type!(LockedDummy, 5); + get_u32_type!(UnlockingDummy, 5); + let mut acc_ledger = + AccountLedger::::default(); + + // Sanity check scenario + // Cannot reduce if there is nothing locked, should be a noop + assert!(acc_ledger.add_unlocking_chunk(0, 0).is_ok()); + assert!(acc_ledger.unlocking.len().is_zero()); + assert!(acc_ledger.is_empty()); + + // Basic scenario + let unlock_amount = 17; + let block_number = 29; + assert!(acc_ledger + .add_unlocking_chunk(unlock_amount, block_number) + .is_ok()); + assert_eq!( + acc_ledger.unlocking, + vec![UnlockingChunk { + amount: unlock_amount, + unlock_block: block_number + }] + ); + assert_eq!(acc_ledger.unlocking_amount(), unlock_amount); + + // Unlock additional amount in the same block + assert!(acc_ledger + .add_unlocking_chunk(unlock_amount, block_number) + .is_ok()); + assert_eq!( + acc_ledger.unlocking, + vec![UnlockingChunk { + amount: unlock_amount * 2, + unlock_block: block_number + }] + ); + assert_eq!(acc_ledger.unlocking_amount(), unlock_amount * 2); + + // Add unlocking chunks up to vector capacity + let mut total_unlocking = acc_ledger.unlocking_amount(); + for i in 2..=UnlockingDummy::get() { + let new_unlock_amount = unlock_amount + i as u128; + assert!(acc_ledger + .add_unlocking_chunk(new_unlock_amount, block_number + i as u64) + .is_ok()); + total_unlocking += new_unlock_amount; + assert_eq!(acc_ledger.unlocking_amount(), total_unlocking); + assert_eq!( + acc_ledger.unlocking[i as usize - 1].amount, + new_unlock_amount + ); + } + + // Any further addition should fail, resulting in a noop + let acc_ledger_snapshot = acc_ledger.clone(); + assert!(acc_ledger + .add_unlocking_chunk(1, block_number + UnlockingDummy::get() as u64 + 1) + .is_err()); + assert_eq!(acc_ledger, acc_ledger_snapshot); +} + +#[test] +fn active_stake_works() { + get_u32_type!(LockedDummy, 5); + get_u32_type!(UnlockingDummy, 5); + let mut acc_ledger = + AccountLedger::::default(); + + // Sanity check + assert!(acc_ledger.active_stake(0).is_zero()); + assert!(acc_ledger.active_stake(1).is_zero()); + + // Period matches + let amount = 29; + let period = 5; + acc_ledger.staked = StakeInfo { amount, period }; + assert_eq!(acc_ledger.active_stake(period), amount); + + // Period doesn't match + assert!(acc_ledger.active_stake(period - 1).is_zero()); + assert!(acc_ledger.active_stake(period + 1).is_zero()); +} + +#[test] +fn unlockable_amount_works() { + get_u32_type!(LockedDummy, 5); + get_u32_type!(UnlockingDummy, 5); + let mut acc_ledger = + AccountLedger::::default(); + + // Sanity check scenario + assert!(acc_ledger.unlockable_amount(0).is_zero()); + + // Nothing is staked + let lock_amount = 29; + let lock_era = 3; + assert!(acc_ledger.add_lock_amount(lock_amount, lock_era).is_ok()); + assert_eq!(acc_ledger.unlockable_amount(0), lock_amount); + + // Some amount is staked, period matches + let stake_period = 5; + let stake_amount = 17; + acc_ledger.staked = StakeInfo { + amount: stake_amount, + period: stake_period, + }; + assert_eq!( + acc_ledger.unlockable_amount(stake_period), + lock_amount - stake_amount + ); + + // Period doesn't match + assert_eq!(acc_ledger.unlockable_amount(stake_period - 1), lock_amount); + assert_eq!(acc_ledger.unlockable_amount(stake_period + 2), lock_amount); + + // Absurd example, for the sake of completeness - staked without any lock + acc_ledger.locked = Default::default(); + assert!(acc_ledger.unlockable_amount(stake_period).is_zero()); + assert!(acc_ledger.unlockable_amount(stake_period - 2).is_zero()); + assert!(acc_ledger.unlockable_amount(stake_period + 1).is_zero()); +} + +#[test] +fn era_info_manipulation_works() { + let mut era_info = EraInfo::::default(); + + // Sanity check + assert!(era_info.total_locked.is_zero()); + assert!(era_info.active_era_locked.is_zero()); + assert!(era_info.unlocking.is_zero()); + + // Basic add lock + let lock_amount = 7; + era_info.add_locked(lock_amount); + assert_eq!(era_info.total_locked, lock_amount); + era_info.add_locked(lock_amount); + assert_eq!(era_info.total_locked, lock_amount * 2); + + // Basic unlocking started + let unlock_amount = 2; + era_info.total_locked = 17; + era_info.active_era_locked = 13; + let era_info_snapshot = era_info; + + // First unlock & checks + era_info.unlocking_started(unlock_amount); + assert_eq!( + era_info.total_locked, + era_info_snapshot.total_locked - unlock_amount + ); + assert_eq!( + era_info.active_era_locked, + era_info_snapshot.active_era_locked - unlock_amount + ); + assert_eq!(era_info.unlocking, unlock_amount); + + // Second unlock and checks + era_info.unlocking_started(unlock_amount); + assert_eq!( + era_info.total_locked, + era_info_snapshot.total_locked - unlock_amount * 2 + ); + assert_eq!( + era_info.active_era_locked, + era_info_snapshot.active_era_locked - unlock_amount * 2 + ); + assert_eq!(era_info.unlocking, unlock_amount * 2); } diff --git a/frame/dapp-staking-v3/src/types.rs b/frame/dapp-staking-v3/src/types.rs index 958cd496..71874f2a 100644 --- a/frame/dapp-staking-v3/src/types.rs +++ b/frame/dapp-staking-v3/src/types.rs @@ -144,12 +144,11 @@ where } } -// TODO: would users get better UX if we kept using eras? Using blocks is more precise though. /// How much was unlocked in some block. #[derive(Encode, Decode, MaxEncodedLen, Clone, Copy, Debug, PartialEq, Eq, TypeInfo)] pub struct UnlockingChunk< Balance: AtLeast32BitUnsigned + MaxEncodedLen + Copy, - BlockNumber: AtLeast32BitUnsigned + MaxEncodedLen, + BlockNumber: AtLeast32BitUnsigned + MaxEncodedLen + Copy, > { #[codec(compact)] pub amount: Balance, @@ -160,7 +159,7 @@ pub struct UnlockingChunk< impl Default for UnlockingChunk where Balance: AtLeast32BitUnsigned + MaxEncodedLen + Copy, - BlockNumber: AtLeast32BitUnsigned + MaxEncodedLen, + BlockNumber: AtLeast32BitUnsigned + MaxEncodedLen + Copy, { fn default() -> Self { Self { @@ -170,12 +169,33 @@ where } } +/// Information about how much was staked in a specific period. +#[derive(Encode, Decode, MaxEncodedLen, Clone, Copy, Debug, PartialEq, Eq, TypeInfo)] +pub struct StakeInfo { + #[codec(compact)] + pub amount: Balance, + #[codec(compact)] + pub period: PeriodNumber, +} + +impl Default for StakeInfo +where + Balance: AtLeast32BitUnsigned + MaxEncodedLen + Copy, +{ + fn default() -> Self { + Self { + amount: Balance::zero(), + period: PeriodNumber::zero(), + } + } +} + /// General info about user's stakes #[derive(Encode, Decode, MaxEncodedLen, Clone, Debug, PartialEq, Eq, TypeInfo)] #[scale_info(skip_type_params(LockedLen, UnlockingLen))] pub struct AccountLedger< Balance: AtLeast32BitUnsigned + MaxEncodedLen + Copy, - BlockNumber: AtLeast32BitUnsigned + MaxEncodedLen, + BlockNumber: AtLeast32BitUnsigned + MaxEncodedLen + Copy, LockedLen: Get, UnlockingLen: Get, > { @@ -183,17 +203,15 @@ pub struct AccountLedger< pub locked: BoundedVec, LockedLen>, /// How much started unlocking on a certain block pub unlocking: BoundedVec, UnlockingLen>, - //TODO, make this a compact struct!!! /// How much user had staked in some period - // #[codec(compact)] - pub staked: (Balance, PeriodNumber), + pub staked: StakeInfo, } impl Default for AccountLedger where Balance: AtLeast32BitUnsigned + MaxEncodedLen + Copy, - BlockNumber: AtLeast32BitUnsigned + MaxEncodedLen, + BlockNumber: AtLeast32BitUnsigned + MaxEncodedLen + Copy, LockedLen: Get, UnlockingLen: Get, { @@ -201,7 +219,7 @@ where Self { locked: BoundedVec::, LockedLen>::default(), unlocking: BoundedVec::, UnlockingLen>::default(), - staked: (Balance::zero(), 0), + staked: StakeInfo::::default(), } } } @@ -210,13 +228,13 @@ impl AccountLedger where Balance: AtLeast32BitUnsigned + MaxEncodedLen + Copy, - BlockNumber: AtLeast32BitUnsigned + MaxEncodedLen, + BlockNumber: AtLeast32BitUnsigned + MaxEncodedLen + Copy, LockedLen: Get, UnlockingLen: Get, { /// Empty if no locked/unlocking/staked info exists. pub fn is_empty(&self) -> bool { - self.locked.is_empty() && self.unlocking.is_empty() && self.staked.0.is_zero() + self.locked.is_empty() && self.unlocking.is_empty() && self.staked.amount.is_zero() } /// Returns latest locked chunk if it exists, `None` otherwise @@ -224,20 +242,48 @@ where self.locked.last() } - /// Returns locked amount. - /// If `zero`, means that associated account hasn't locked any funds. - pub fn locked_amount(&self) -> Balance { + /// Returns active locked amount. + /// If `zero`, means that associated account hasn't got any active locked funds. + pub fn active_locked_amount(&self) -> Balance { self.latest_locked_chunk() .map_or(Balance::zero(), |locked| locked.amount) } + /// Returns unlocking amount. + /// If `zero`, means that associated account hasn't got any unlocking chunks. + pub fn unlocking_amount(&self) -> Balance { + self.unlocking.iter().fold(Balance::zero(), |sum, chunk| { + sum.saturating_add(chunk.amount) + }) + } + + /// Total locked amount by the user. + /// Includes both active locked amount & unlocking amount. + pub fn total_locked_amount(&self) -> Balance { + self.active_locked_amount() + self.unlocking_amount() + } + /// Returns latest era in which locked amount was updated or zero in case no lock amount exists - pub fn era(&self) -> EraNumber { + pub fn lock_era(&self) -> EraNumber { self.latest_locked_chunk() .map_or(EraNumber::zero(), |locked| locked.era) } + // TODO: can active_period be provided somehow different instead of using a parameter? + + /// 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 { + if active_period == self.staked.period { + self.staked.amount + } else { + Balance::zero() + } + } + /// Adds the specified amount to the total locked amount, if possible. + /// Caller must ensure that the era matches the next one, not the current one. /// /// If entry for the specified era already exists, it's updated. /// @@ -267,10 +313,118 @@ where Ok(()) } + + /// Subtracts the specified amount of the total locked amount, if possible. + /// + /// If entry for the specified era already exists, it's updated. + /// + /// If entry for the specified era doesn't exist, it's created and insertion is attempted. + /// In case vector has no more capacity, error is returned, and whole operation is a noop. + pub fn subtract_lock_amount(&mut self, amount: Balance, era: EraNumber) -> Result<(), ()> { + if amount.is_zero() || self.locked.is_empty() { + return Ok(()); + } + // TODO: this method can surely be optimized (avoid too many iters) but focus on that later, + // when it's all working fine, and we have good test coverage. + + // Find the most relevant locked chunk for the specified era + let index = if let Some(index) = self.locked.iter().rposition(|&chunk| chunk.era <= era) { + index + } else { + // Covers scenario when there's only 1 chunk for the next era, and remove it if it's zero. + self.locked + .iter_mut() + .for_each(|chunk| chunk.amount.saturating_reduce(amount)); + self.locked.retain(|chunk| !chunk.amount.is_zero()); + return Ok(()); + }; + + // Update existing or insert a new chunk + let mut inner = self.locked.clone().into_inner(); + let relevant_chunk_index = if inner[index].era == era { + inner[index].amount.saturating_reduce(amount); + index + } else { + let mut chunk = inner[index]; + chunk.amount.saturating_reduce(amount); + chunk.era = era; + + inner.insert(index + 1, chunk); + index + 1 + }; + + // Update all chunks after the relevant one, and remove eligible zero chunks + inner[relevant_chunk_index + 1..] + .iter_mut() + .for_each(|chunk| chunk.amount.saturating_reduce(amount)); + + // Merge all consecutive zero chunks + let mut i = relevant_chunk_index; + while i < inner.len() - 1 { + if inner[i].amount.is_zero() && inner[i + 1].amount.is_zero() { + inner.remove(i + 1); + } else { + i += 1; + } + } + + // Cleanup if only one zero chunk exists + if inner.len() == 1 && inner[0].amount.is_zero() { + inner.pop(); + } + + // Update `locked` to the new vector + self.locked = BoundedVec::try_from(inner).map_err(|_| ())?; + + Ok(()) + } + + /// Adds the specified amount to the unlocking chunks. + /// + /// If entry for the specified block already exists, it's updated. + /// + /// If entry for the specified block doesn't exist, it's created and insertion is attempted. + /// In case vector has no more capacity, error is returned, and whole operation is a noop. + pub fn add_unlocking_chunk( + &mut self, + amount: Balance, + unlock_block: BlockNumber, + ) -> Result<(), ()> { + if amount.is_zero() { + return Ok(()); + } + + let idx = self + .unlocking + .binary_search_by(|chunk| chunk.unlock_block.cmp(&unlock_block)); + + match idx { + Ok(idx) => { + self.unlocking[idx].amount.saturating_accrue(amount); + } + Err(idx) => { + let new_unlocking_chunk = UnlockingChunk { + amount, + unlock_block, + }; + self.unlocking + .try_insert(idx, new_unlocking_chunk) + .map_err(|_| ())?; + } + } + + Ok(()) + } + + /// Amount available for unlocking. + pub fn unlockable_amount(&self, current_period: PeriodNumber) -> Balance { + self.active_locked_amount() + .saturating_sub(self.active_stake(current_period)) + } } /// Rewards pool for lock participants & dApps -#[derive(Encode, Decode, MaxEncodedLen, Clone, Debug, PartialEq, Eq, TypeInfo, Default)] +#[derive(Encode, Decode, MaxEncodedLen, Copy, Clone, Debug, PartialEq, Eq, TypeInfo, Default)] pub struct RewardInfo { /// Rewards pool for accounts which have locked funds in dApp staking #[codec(compact)] @@ -281,7 +435,7 @@ pub struct RewardInfo { } /// Info about current era, including the rewards, how much is locked, unlocking, etc. -#[derive(Encode, Decode, MaxEncodedLen, Clone, Debug, PartialEq, Eq, TypeInfo, Default)] +#[derive(Encode, Decode, MaxEncodedLen, Copy, Clone, Debug, PartialEq, Eq, TypeInfo, Default)] pub struct EraInfo { /// Info about era rewards pub rewards: RewardInfo, @@ -297,3 +451,20 @@ pub struct EraInfo { #[codec(compact)] pub unlocking: Balance, } + +impl EraInfo +where + Balance: AtLeast32BitUnsigned + MaxEncodedLen + Copy, +{ + /// Update with the new amount that has just been locked. + pub fn add_locked(&mut self, amount: Balance) { + self.total_locked.saturating_accrue(amount); + } + + /// Update with the new amount that has just started undergoing the unlocking period. + pub fn unlocking_started(&mut self, amount: Balance) { + self.active_era_locked.saturating_reduce(amount); + self.total_locked.saturating_reduce(amount); + self.unlocking.saturating_accrue(amount); + } +}