From 6a36db164906c72931958909fa894d2f15a47a5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dino=20Pa=C4=8Dandi?= <3002868+Dinonard@users.noreply.github.com> Date: Thu, 21 Sep 2023 09:55:45 +0100 Subject: [PATCH] Feat/dapp staking v3 phase2 (#991) * Phase2 progress * Adapt for 0.9.43 * Abstract sparse vector type * Further modifications * claim unlocked functionality WIP * claim unlocked tested * Relock unlocking * Check era when adding chunk * Custom error for some types * Additional type tests - WIP * More type tests * Review comments * Additional changes --- Cargo.lock | 19 + pallets/dapp-staking-v3/Cargo.toml | 3 + pallets/dapp-staking-v3/src/lib.rs | 179 +++- pallets/dapp-staking-v3/src/test/mock.rs | 29 +- .../dapp-staking-v3/src/test/testing_utils.rs | 198 +++- pallets/dapp-staking-v3/src/test/tests.rs | 400 +++++++- .../dapp-staking-v3/src/test/tests_types.rs | 915 +++++++++++++++++- pallets/dapp-staking-v3/src/types.rs | 476 +++++++-- 8 files changed, 2111 insertions(+), 108 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 28f586a9b3..536ec19859 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7105,6 +7105,25 @@ dependencies = [ "sp-std", ] +[[package]] +name = "pallet-dapp-staking-v3" +version = "0.0.1-alpha" +dependencies = [ + "astar-primitives", + "frame-support", + "frame-system", + "num-traits", + "pallet-balances", + "parity-scale-codec", + "scale-info", + "serde", + "sp-arithmetic", + "sp-core", + "sp-io", + "sp-runtime", + "sp-std", +] + [[package]] name = "pallet-dapps-staking" version = "3.10.0" diff --git a/pallets/dapp-staking-v3/Cargo.toml b/pallets/dapp-staking-v3/Cargo.toml index 27f927fe6e..acc457ac8f 100644 --- a/pallets/dapp-staking-v3/Cargo.toml +++ b/pallets/dapp-staking-v3/Cargo.toml @@ -21,6 +21,8 @@ sp-io = { workspace = true } sp-runtime = { workspace = true } sp-std = { workspace = true } +astar-primitives = { workspace = true } + [dev-dependencies] pallet-balances = { workspace = true } @@ -39,4 +41,5 @@ std = [ "frame-support/std", "frame-system/std", "pallet-balances/std", + "astar-primitives/std", ] diff --git a/pallets/dapp-staking-v3/src/lib.rs b/pallets/dapp-staking-v3/src/lib.rs index d2057651ed..fc5a24d8a3 100644 --- a/pallets/dapp-staking-v3/src/lib.rs +++ b/pallets/dapp-staking-v3/src/lib.rs @@ -47,6 +47,8 @@ use frame_support::{ use frame_system::pallet_prelude::*; use sp_runtime::traits::{BadOrigin, Saturating, Zero}; +use astar_primitives::Balance; + use crate::types::*; pub use pallet::*; @@ -65,7 +67,6 @@ pub mod pallet { const STORAGE_VERSION: StorageVersion = StorageVersion::new(5); #[pallet::pallet] - #[pallet::generate_store(pub(crate) trait Store)] #[pallet::storage_version(STORAGE_VERSION)] pub struct Pallet(_); @@ -75,7 +76,12 @@ pub mod pallet { type RuntimeEvent: From> + IsType<::RuntimeEvent>; /// Currency used for staking. - type Currency: LockableCurrency; + /// TODO: remove usage of deprecated LockableCurrency trait and use the new freeze approach. Might require some renaming of Lock to Freeze :) + type Currency: LockableCurrency< + Self::AccountId, + Moment = Self::BlockNumber, + Balance = Balance, + >; /// Describes smart contract in the context required by dApp staking. type SmartContract: Parameter + Member + MaxEncodedLen; @@ -89,6 +95,7 @@ pub mod pallet { type MaxNumberOfContracts: Get; /// Maximum number of locked chunks that can exist per account at a time. + // TODO: should this just be hardcoded to 2? Nothing else makes sense really - current era and next era are required. #[pallet::constant] type MaxLockedChunks: Get; @@ -98,7 +105,15 @@ pub mod pallet { /// Minimum amount an account has to lock in dApp staking in order to participate. #[pallet::constant] - type MinimumLockedAmount: Get>; + type MinimumLockedAmount: Get; + + /// Amount of blocks that need to pass before unlocking chunks can be claimed by the owner. + #[pallet::constant] + type UnlockingPeriod: Get>; + + /// Maximum number of staking chunks that can exist per account at a time. + #[pallet::constant] + type MaxStakingChunks: Get; } #[pallet::event] @@ -128,7 +143,22 @@ pub mod pallet { /// Account has locked some amount into dApp staking. Locked { account: T::AccountId, - amount: BalanceOf, + amount: Balance, + }, + /// Account has started the unlocking process for some amount. + Unlocking { + account: T::AccountId, + amount: Balance, + }, + /// Account has claimed unlocked amount, removing the lock from it. + ClaimedUnlocked { + account: T::AccountId, + amount: Balance, + }, + /// Account has relocked all of the unlocking chunks. + Relock { + account: T::AccountId, + amount: Balance, }, } @@ -155,6 +185,14 @@ 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, + /// There are no eligible unlocked chunks to claim. This can happen either if no eligible chunks exist, or if user has no chunks at all. + NoUnlockedChunksToClaim, + /// There are no unlocking chunks available to relock. + NoUnlockingChunks, } /// General information about dApp staking protocol state. @@ -183,7 +221,7 @@ pub mod pallet { /// General information about the current era. #[pallet::storage] - pub type CurrentEraInfo = StorageValue<_, EraInfo>, ValueQuery>; + pub type CurrentEraInfo = StorageValue<_, EraInfo, ValueQuery>; #[pallet::call] impl Pallet { @@ -376,10 +414,7 @@ pub mod pallet { /// caller should claim pending rewards, before retrying to lock additional funds. #[pallet::call_index(5)] #[pallet::weight(Weight::zero())] - pub fn lock( - origin: OriginFor, - #[pallet::compact] amount: BalanceOf, - ) -> DispatchResult { + pub fn lock(origin: OriginFor, #[pallet::compact] amount: Balance) -> DispatchResult { Self::ensure_pallet_enabled()?; let account = ensure_signed(origin)?; @@ -388,7 +423,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 +433,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 +449,124 @@ 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: Balance) -> DispatchResult { + Self::ensure_pallet_enabled()?; + let account = ensure_signed(origin)?; + + let state = ActiveProtocolState::::get(); + let mut ledger = Ledger::::get(&account); + + let available_for_unlocking = ledger.unlockable_amount(state.period); + 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(()) + } + + /// Claims all of fully unlocked chunks, removing the lock from them. + #[pallet::call_index(7)] + #[pallet::weight(Weight::zero())] + pub fn claim_unlocked(origin: OriginFor) -> DispatchResult { + Self::ensure_pallet_enabled()?; + let account = ensure_signed(origin)?; + + let mut ledger = Ledger::::get(&account); + + let current_block = frame_system::Pallet::::block_number(); + let amount = ledger.claim_unlocked(current_block); + ensure!(amount > Zero::zero(), Error::::NoUnlockedChunksToClaim); + + Self::update_ledger(&account, ledger); + CurrentEraInfo::::mutate(|era_info| { + era_info.unlocking_removed(amount); + }); + + // TODO: We should ensure user doesn't unlock everything if they still have storage leftovers (e.g. unclaimed rewards?) + + Self::deposit_event(Event::::ClaimedUnlocked { account, amount }); + + Ok(()) + } + + #[pallet::call_index(8)] + #[pallet::weight(Weight::zero())] + pub fn relock_unlocking(origin: OriginFor) -> DispatchResult { + Self::ensure_pallet_enabled()?; + let account = ensure_signed(origin)?; + + let state = ActiveProtocolState::::get(); + let mut ledger = Ledger::::get(&account); + + ensure!(!ledger.unlocking.is_empty(), Error::::NoUnlockingChunks); + + // Only lock for the next era onwards. + let lock_era = state.era.saturating_add(1); + let amount = ledger.consume_unlocking_chunks(); + + ledger + .add_lock_amount(amount, lock_era) + .map_err(|_| Error::::TooManyLockedBalanceChunks)?; + ensure!( + ledger.active_locked_amount() >= T::MinimumLockedAmount::get(), + Error::::LockedAmountBelowThreshold + ); + + Self::update_ledger(&account, ledger); + CurrentEraInfo::::mutate(|era_info| { + era_info.add_locked(amount); + era_info.unlocking_removed(amount); + }); + + Self::deposit_event(Event::::Relock { account, amount }); + + Ok(()) + } } impl Pallet { @@ -450,7 +603,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/pallets/dapp-staking-v3/src/test/mock.rs b/pallets/dapp-staking-v3/src/test/mock.rs index d4f11189f9..b8ef08163a 100644 --- a/pallets/dapp-staking-v3/src/test/mock.rs +++ b/pallets/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}; @@ -58,7 +58,7 @@ construct_runtime!( parameter_types! { pub const BlockHashCount: u64 = 250; pub BlockWeights: frame_system::limits::BlockWeights = - frame_system::limits::BlockWeights::simple_max(Weight::from_ref_time(1024)); + frame_system::limits::BlockWeights::simple_max(Weight::from_parts(1024, 0)); } impl frame_system::Config for Test { @@ -97,6 +97,10 @@ impl pallet_balances::Config for Test { type DustRemoval = (); type ExistentialDeposit = ConstU128; type AccountStore = System; + type HoldIdentifier = (); + type FreezeIdentifier = (); + type MaxHolds = ConstU32<0>; + type MaxFreezes = ConstU32<0>; type WeightInfo = (); } @@ -108,7 +112,9 @@ impl pallet_dapp_staking::Config for Test { type MaxNumberOfContracts = ConstU16<10>; type MaxLockedChunks = ConstU32<5>; type MaxUnlockingChunks = ConstU32<5>; + type MaxStakingChunks = ConstU32<8>; type MinimumLockedAmount = ConstU128; + type UnlockingPeriod = ConstU64<20>; } #[derive(PartialEq, Eq, Copy, Clone, Encode, Decode, Debug, TypeInfo, MaxEncodedLen, Hash)] @@ -144,6 +150,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 +178,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 +189,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/pallets/dapp-staking-v3/src/test/testing_utils.rs b/pallets/dapp-staking-v3/src/test/testing_utils.rs index f383e9afee..ef15c21f0f 100644 --- a/pallets/dapp-staking-v3/src/test/testing_utils.rs +++ b/pallets/dapp-staking-v3/src/test/testing_utils.rs @@ -17,19 +17,25 @@ // 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. /// Used when comparison of before/after states is required. +#[derive(Debug)] pub(crate) struct MemorySnapshot { active_protocol_state: ProtocolState>, next_dapp_id: DAppId, - current_era_info: EraInfo>, + current_era_info: EraInfo, integrated_dapps: HashMap< - ::SmartContract, + ::SmartContract, DAppInfo<::AccountId>, >, ledger: HashMap<::AccountId, AccountLedgerFor>, @@ -52,7 +58,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 +202,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 +217,183 @@ pub(crate) fn assert_lock(account: AccountId, amount: Balance) { "Active era locked amount should remain exactly the same." ); } + +/// Start the unlocking process for locked funds 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 account 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 + ); +} + +/// Claims the unlocked funds back into free balance of the user and assert success. +pub(crate) fn assert_claim_unlocked(account: AccountId) { + let pre_snapshot = MemorySnapshot::new(); + + assert!( + pre_snapshot.ledger.contains_key(&account), + "Cannot claim unlocked for non-existing ledger." + ); + + let current_block = System::block_number(); + let mut consumed_chunks = 0; + let mut amount = 0; + for unlock_chunk in pre_snapshot.ledger[&account].clone().unlocking.into_inner() { + if unlock_chunk.unlock_block <= current_block { + amount += unlock_chunk.amount; + consumed_chunks += 1; + } + } + + // Claim unlocked chunks + assert_ok!(DappStaking::claim_unlocked(RuntimeOrigin::signed(account))); + System::assert_last_event(RuntimeEvent::DappStaking(Event::ClaimedUnlocked { + account, + amount, + })); + + // Verify post-state + let post_snapshot = MemorySnapshot::new(); + + let post_ledger = if let Some(ledger) = post_snapshot.ledger.get(&account) { + ledger.clone() + } else { + Default::default() + }; + + assert_eq!( + post_ledger.unlocking.len(), + pre_snapshot.ledger[&account].unlocking.len() - consumed_chunks + ); + assert_eq!( + post_ledger.unlocking_amount(), + pre_snapshot.ledger[&account].unlocking_amount() - amount + ); + assert_eq!( + post_snapshot.current_era_info.unlocking, + pre_snapshot.current_era_info.unlocking - amount + ); +} + +/// Claims the unlocked funds back into free balance of the user and assert success. +pub(crate) fn assert_relock_unlocking(account: AccountId) { + let pre_snapshot = MemorySnapshot::new(); + + assert!( + pre_snapshot.ledger.contains_key(&account), + "Cannot relock unlocking non-existing ledger." + ); + + let amount = pre_snapshot.ledger[&account].unlocking_amount(); + + // Relock unlocking chunks + assert_ok!(DappStaking::relock_unlocking(RuntimeOrigin::signed( + account + ))); + System::assert_last_event(RuntimeEvent::DappStaking(Event::Relock { account, amount })); + + // Verify post-state + let post_snapshot = MemorySnapshot::new(); + + // Account ledger + let post_ledger = &post_snapshot.ledger[&account]; + assert!(post_ledger.unlocking.is_empty()); + assert!(post_ledger.unlocking_amount().is_zero()); + assert_eq!( + post_ledger.active_locked_amount(), + pre_snapshot.ledger[&account].active_locked_amount() + amount + ); + assert_eq!( + post_ledger.lock_era(), + post_snapshot.active_protocol_state.era + 1 + ); + + // Current era info + assert_eq!( + post_snapshot.current_era_info.unlocking, + pre_snapshot.current_era_info.unlocking - amount + ); + assert_eq!( + post_snapshot.current_era_info.total_locked, + pre_snapshot.current_era_info.total_locked + amount + ); +} diff --git a/pallets/dapp-staking-v3/src/test/tests.rs b/pallets/dapp-staking-v3/src/test/tests.rs index 381a385139..4a6dcdc901 100644 --- a/pallets/dapp-staking-v3/src/test/tests.rs +++ b/pallets/dapp-staking-v3/src/test/tests.rs @@ -19,10 +19,11 @@ 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, SparseBoundedAmountEraVec, StakeChunk, }; -use frame_support::{assert_noop, assert_ok, error::BadOrigin, traits::Get}; +use frame_support::{assert_noop, assert_ok, error::BadOrigin, traits::Get, BoundedVec}; use sp_runtime::traits::Zero; #[test] @@ -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,394 @@ 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); + + // TODO: Hacky, maybe improve later when staking is implemented? + let stake_amount = 91; + Ledger::::mutate(&account, |ledger| { + ledger.staked = SparseBoundedAmountEraVec( + BoundedVec::try_from(vec![StakeChunk { + amount: stake_amount, + era: ActiveProtocolState::::get().era, + }]) + .expect("Only one chunk so creation should succeed."), + ); + ledger.staked_period = Some(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; + // TODO: Hacky, maybe improve later when staking is implemented? + Ledger::::mutate(&account, |ledger| { + ledger.staked = SparseBoundedAmountEraVec( + BoundedVec::try_from(vec![StakeChunk { + amount: stake_amount, + era: ActiveProtocolState::::get().era, + }]) + .expect("Only one chunk so creation should succeed."), + ); + ledger.staked_period = Some(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 + // TODO: Hacky, maybe improve later when staking is implemented? + Ledger::::mutate(&account, |ledger| { + ledger.staked = SparseBoundedAmountEraVec( + BoundedVec::try_from(vec![StakeChunk { + amount: lock_amount, + era: ActiveProtocolState::::get().era, + }]) + .expect("Only one chunk so creation should succeed."), + ); + ledger.staked_period = Some(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, + ); + }) +} + +#[test] +fn claim_unlocked_is_ok() { + ExtBuilder::build().execute_with(|| { + let unlocking_blocks: BlockNumber = + ::UnlockingPeriod::get(); + + // Lock some amount in a few eras + let account = 2; + let lock_amount = 103; + assert_lock(account, lock_amount); + + // Basic example + let unlock_amount = 3; + assert_unlock(account, unlock_amount); + run_for_blocks(unlocking_blocks); + assert_claim_unlocked(account); + + // Advanced example + let max_unlocking_chunks: u32 = + ::MaxUnlockingChunks::get(); + for _ in 0..max_unlocking_chunks { + run_for_blocks(1); + assert_unlock(account, unlock_amount); + } + + // Leave two blocks remaining after the claim + run_for_blocks(unlocking_blocks - 2); + assert_claim_unlocked(account); + + // Claim last two blocks together + run_for_blocks(2); + assert_claim_unlocked(account); + assert!(Ledger::::get(&account).unlocking.is_empty()); + }) +} + +#[test] +fn claim_unlocked_no_eligible_chunks_fails() { + ExtBuilder::build().execute_with(|| { + // Sanity check + let account = 2; + assert_noop!( + DappStaking::claim_unlocked(RuntimeOrigin::signed(account)), + Error::::NoUnlockedChunksToClaim, + ); + + // Cannot claim if unlock period hasn't passed yet + let lock_amount = 103; + assert_lock(account, lock_amount); + let unlocking_blocks: BlockNumber = + ::UnlockingPeriod::get(); + run_for_blocks(unlocking_blocks - 1); + assert_noop!( + DappStaking::claim_unlocked(RuntimeOrigin::signed(account)), + Error::::NoUnlockedChunksToClaim, + ); + }) +} + +#[test] +fn relock_unlocking_is_ok() { + ExtBuilder::build().execute_with(|| { + // Lock some amount + let account = 2; + let lock_amount = 91; + assert_lock(account, lock_amount); + + // Prepare some unlock chunks + let unlock_amount = 5; + assert_unlock(account, unlock_amount); + run_for_blocks(2); + assert_unlock(account, unlock_amount); + + assert_relock_unlocking(account); + + let max_unlocking_chunks: u32 = + ::MaxUnlockingChunks::get(); + for _ in 0..max_unlocking_chunks { + run_for_blocks(1); + assert_unlock(account, unlock_amount); + } + + assert_relock_unlocking(account); + }) +} + +#[test] +fn relock_unlocking_no_chunks_fails() { + ExtBuilder::build().execute_with(|| { + assert_noop!( + DappStaking::relock_unlocking(RuntimeOrigin::signed(1)), + Error::::NoUnlockingChunks, + ); + }) +} + +#[test] +fn relock_unlocking_too_many_chunks_fails() { + ExtBuilder::build().execute_with(|| { + let max_locked_chunks = ::MaxLockedChunks::get(); + + // Fill up the locked chunks to the limit + let account = 3; + for current_era in 1..=max_locked_chunks { + assert_lock(account, 11); + advance_to_era(current_era + 1); + } + assert_unlock(account, 7); + + assert_noop!( + DappStaking::relock_unlocking(RuntimeOrigin::signed(account)), + Error::::TooManyLockedBalanceChunks, + ); + }) +} + +#[test] +fn relock_unlocking_insufficient_lock_amount_fails() { + ExtBuilder::build().execute_with(|| { + let minimum_locked_amount: Balance = + ::MinimumLockedAmount::get(); + + // lock amount should be above the threshold + let account = 2; + assert_lock(account, minimum_locked_amount + 1); + + // Create two unlocking chunks + assert_unlock(account, 1); + run_for_blocks(1); + assert_unlock(account, minimum_locked_amount); + + // This scenario can only be achieved if minimum staking amount increases on live network. + // Otherwise we always have a guarantee that the latest unlocking chunk at least covers the + // minimum staking amount. + // To test this, we will do a "dirty trick", and swap the two unlocking chunks that were just created. + // This shoudl ensure that the latest unlocking chunk is below the minimum staking amount. + Ledger::::mutate(&account, |ledger| { + ledger.unlocking = ledger + .unlocking + .clone() + .try_mutate(|inner| { + let temp_block = inner[0].unlock_block; + inner[0].unlock_block = inner[1].unlock_block; + inner[1].unlock_block = temp_block; + inner.swap(0, 1); + }) + .expect("No size manipulation, only element swap."); + }); + + // Make sure only one chunk is left + let unlocking_blocks: BlockNumber = + ::UnlockingPeriod::get(); + run_for_blocks(unlocking_blocks - 1); + assert_claim_unlocked(account); + + assert_noop!( + DappStaking::relock_unlocking(RuntimeOrigin::signed(account)), + Error::::LockedAmountBelowThreshold, + ); + }) +} diff --git a/pallets/dapp-staking-v3/src/test/tests_types.rs b/pallets/dapp-staking-v3/src/test/tests_types.rs index 1f2419d714..87623d8e82 100644 --- a/pallets/dapp-staking-v3/src/test/tests_types.rs +++ b/pallets/dapp-staking-v3/src/test/tests_types.rs @@ -16,12 +16,15 @@ // You should have received a copy of the GNU General Public License // along with Astar. If not, see . -use crate::test::mock::*; +use frame_support::assert_ok; + +use crate::test::mock::{Balance, *}; 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 { @@ -31,6 +34,274 @@ macro_rules! get_u32_type { }; } +#[derive(Encode, Decode, MaxEncodedLen, Clone, Copy, Debug, PartialEq, Eq, Default)] +struct DummyEraAmount { + amount: Balance, + era: u32, +} +impl AmountEraPair for DummyEraAmount { + fn get_amount(&self) -> Balance { + self.amount + } + fn get_era(&self) -> u32 { + self.era + } + fn set_era(&mut self, era: u32) { + self.era = era; + } + fn saturating_accrue(&mut self, increase: Balance) { + self.amount.saturating_accrue(increase); + } + fn saturating_reduce(&mut self, reduction: Balance) { + self.amount.saturating_reduce(reduction); + } +} +impl DummyEraAmount { + pub fn new(amount: Balance, era: u32) -> Self { + Self { amount, era } + } +} + +#[test] +fn sparse_bounded_amount_era_vec_add_amount_works() { + get_u32_type!(MaxLen, 5); + + // Sanity check + let mut vec = SparseBoundedAmountEraVec::::new(); + assert!(vec.0.is_empty()); + assert_ok!(vec.add_amount(0, 0)); + assert!(vec.0.is_empty()); + + // 1st scenario - add to empty vector, should create one entry + let init_amount = 19; + let first_era = 3; + assert_ok!(vec.add_amount(init_amount, first_era)); + assert_eq!(vec.0.len(), 1); + assert_eq!(vec.0[0], DummyEraAmount::new(init_amount, first_era)); + + // 2nd scenario - add to the same era, should update the entry + assert_ok!(vec.add_amount(init_amount, first_era)); + assert_eq!(vec.0.len(), 1); + assert_eq!(vec.0[0], DummyEraAmount::new(init_amount * 2, first_era)); + + // 3rd scenario - add to the next era, should create a new entry + let second_era = first_era + 1; + assert_ok!(vec.add_amount(init_amount, second_era)); + assert_eq!(vec.0.len(), 2); + assert_eq!(vec.0[0], DummyEraAmount::new(init_amount * 2, first_era)); + assert_eq!(vec.0[1], DummyEraAmount::new(init_amount * 3, second_era)); + + // 4th scenario - add to the previous era, should fail and be a noop + assert_eq!( + vec.add_amount(init_amount, first_era), + Err(SparseBoundedError::OldEra) + ); + assert_eq!(vec.0.len(), 2); + assert_eq!(vec.0[0], DummyEraAmount::new(init_amount * 2, first_era)); + assert_eq!(vec.0[1], DummyEraAmount::new(init_amount * 3, second_era)); + + // 5th scenario - exceed capacity, should fail + for i in vec.0.len()..MaxLen::get() as usize { + assert_ok!(vec.add_amount(init_amount, second_era + i as u32)); + } + assert_eq!( + vec.add_amount(init_amount, 100), + Err(SparseBoundedError::NoCapacity) + ); +} + +// Test two scenarios: +// +// 1. [amount, era] -> subtract(x, era) -> [amount - x, era] +// 2. [amount, era] -> subtract (amount * 2, era) -> [] +#[test] +fn sparse_bounded_amount_era_vec_subtract_amount_basic_scenario_works() { + get_u32_type!(MaxLen, 5); + + // Sanity check + let mut vec = SparseBoundedAmountEraVec::::new(); + assert_ok!(vec.subtract_amount(0, 0)); + assert!(vec.0.is_empty()); + + // 1st scenario - only one entry exists, and it's the same era as the unlock + let init_amount = 19; + let first_era = 1; + let sub_amount = 3; + assert_ok!(vec.add_amount(init_amount, first_era)); + assert_ok!(vec.subtract_amount(sub_amount, first_era)); + assert_eq!(vec.0.len(), 1); + assert_eq!( + vec.0[0], + DummyEraAmount::new(init_amount - sub_amount, first_era), + "Only single entry and it should be updated." + ); + + // 2nd scenario - subtract everything (and more - underflow!) from the current era, causing full removal. Should cleanup the vector. + assert_ok!(vec.subtract_amount(init_amount * 2, first_era)); + assert!(vec.0.is_empty(), "Full removal should cleanup the vector."); +} + +#[test] +fn sparse_bounded_amount_era_vec_subtract_amount_advanced_consecutive_works() { + get_u32_type!(MaxLen, 5); + let mut vec = SparseBoundedAmountEraVec::::new(); + + // 1st scenario - two entries, consecutive eras, subtract from the second era. + // Only the second entry should be updated. + let (first_era, second_era) = (1, 2); + let (first_amount, second_amount) = (19, 23); + assert_ok!(vec.add_amount(first_amount, first_era)); + assert_ok!(vec.add_amount(second_amount, second_era)); + + let sub_amount = 3; + assert_ok!(vec.subtract_amount(sub_amount, second_era)); + assert_eq!(vec.0.len(), 2); + assert_eq!( + vec.0[0], + DummyEraAmount::new(first_amount, first_era), + "First entry should remain unchanged." + ); + assert_eq!( + vec.0[1], + DummyEraAmount::new(first_amount + second_amount - sub_amount, second_era), + "Second entry should have it's amount reduced by the subtracted amount." + ); + + // 2nd scenario - two entries, consecutive eras, subtract from the first era. + // Both the first and second entry should be updated. + assert_ok!(vec.subtract_amount(sub_amount, first_era)); + assert_eq!(vec.0.len(), 2); + assert_eq!( + vec.0[0], + DummyEraAmount::new(first_amount - sub_amount, first_era), + "First entry is updated since it was specified." + ); + assert_eq!( + vec.0[1], + DummyEraAmount::new(first_amount + second_amount - sub_amount * 2, second_era), + "Second entry is updated because it comes AFTER the first one - same applies to all future entries." + ); + + // 3rd scenario - three entries, consecutive eras, subtract from the second era. + // Only second and third entry should be updated. First one should remain unchanged. + let third_era = 3; + let third_amount = 29; + assert_ok!(vec.add_amount(third_amount, third_era)); + assert_ok!(vec.subtract_amount(sub_amount, second_era)); + assert_eq!(vec.0.len(), 3); + assert_eq!( + vec.0[0], + DummyEraAmount::new(first_amount - sub_amount, first_era), + "First entry should remain unchanged, compared to previous scenario." + ); + assert_eq!( + vec.0[1], + DummyEraAmount::new(first_amount + second_amount - sub_amount * 3, second_era), + "Second entry should be reduced by the subtracted amount, compared to previous scenario." + ); + assert_eq!( + vec.0[2], + DummyEraAmount::new( + first_amount + second_amount + third_amount - sub_amount * 3, + third_era + ), + "Same as for the second entry." + ); +} + +#[test] +fn sparse_bounded_amount_era_vec_subtract_amount_advanced_non_consecutive_works() { + get_u32_type!(MaxLen, 5); + let mut vec = SparseBoundedAmountEraVec::::new(); + + // 1st scenario - two entries, non-consecutive eras, subtract from the mid era. + // Only the second entry should be updated but a new entry should be created. + let (first_era, second_era) = (1, 5); + let (first_amount, second_amount) = (19, 23); + assert_ok!(vec.add_amount(first_amount, first_era)); + assert_ok!(vec.add_amount(second_amount, second_era)); + + let sub_amount = 3; + let mid_era = second_era - 1; + assert_ok!(vec.subtract_amount(sub_amount, mid_era)); + assert_eq!(vec.0.len(), 3); + assert_eq!( + vec.0[0], + DummyEraAmount::new(first_amount, first_era), + "No impact on the first entry expected." + ); + assert_eq!( + vec.0[1], + DummyEraAmount::new(first_amount - sub_amount, mid_era), + "Newly created entry should be equal to the first amount, minus what was subtracted." + ); + assert_eq!( + vec.0[2], + DummyEraAmount::new(vec.0[1].amount + second_amount, second_era), + "Previous 'second' entry should be total added minus the subtracted amount." + ); + + // 2nd scenario - fully unlock the mid-entry to create a zero entry. + assert_ok!(vec.subtract_amount(vec.0[1].amount, mid_era)); + assert_eq!(vec.0.len(), 3); + assert_eq!( + vec.0[0], + DummyEraAmount::new(first_amount, first_era), + "No impact on the first entry expected." + ); + assert_eq!( + vec.0[1], + DummyEraAmount::new(0, mid_era), + "Zero entry should be kept since it's in between two non-zero entries." + ); + assert_eq!( + vec.0[2], + DummyEraAmount::new(second_amount, second_era), + "Only the second staked amount should remain since everything else was unstaked." + ); + + // 3rd scenario - create an additional non-zero chunk as prep for the next scenario. + let pre_mid_era = mid_era - 1; + assert!(pre_mid_era > first_era, "Sanity check."); + assert_ok!(vec.subtract_amount(sub_amount, pre_mid_era)); + assert_eq!(vec.0.len(), 4); + assert_eq!( + vec.0[1], + DummyEraAmount::new(first_amount - sub_amount, pre_mid_era), + "Newly created entry, derives it's initial value from the first entry." + ); + assert_eq!( + vec.0[2], + DummyEraAmount::new(0, mid_era), + "Zero entry should be kept at this point since it's still between two non-zero entries." + ); + assert_eq!( + vec.0[3], + DummyEraAmount::new(second_amount - sub_amount, second_era), + "Last entry should be further reduced by the newly subtracted amount." + ); + + // 4th scenario - create an additional zero entry, but ensure it's cleaned up correctly. + let final_sub_amount = vec.0[1].amount; + assert_ok!(vec.subtract_amount(final_sub_amount, pre_mid_era)); + assert_eq!(vec.0.len(), 3); + assert_eq!( + vec.0[0], + DummyEraAmount::new(first_amount, first_era), + "First entry should still remain unchanged." + ); + assert_eq!( + vec.0[1], + DummyEraAmount::new(0, pre_mid_era), + "The older zero entry should consume the newer ones, hence the pre_mid_era usage" + ); + assert_eq!( + vec.0[2], + DummyEraAmount::new(second_amount - sub_amount - final_sub_amount, second_era), + "Last entry should be further reduced by the newly subtracted amount." + ); +} + #[test] fn protocol_state_default() { let protoc_state = ProtocolState::::default(); @@ -46,11 +317,13 @@ fn protocol_state_default() { fn account_ledger_default() { get_u32_type!(LockedDummy, 5); get_u32_type!(UnlockingDummy, 5); - let acc_ledger = AccountLedger::::default(); + get_u32_type!(StakingDummy, 8); + 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()); } @@ -58,24 +331,28 @@ fn account_ledger_default() { fn account_ledger_add_lock_amount_works() { get_u32_type!(LockedDummy, 5); get_u32_type!(UnlockingDummy, 5); + get_u32_type!(StakingDummy, 8); let mut acc_ledger = - AccountLedger::::default(); + AccountLedger::::default(); // 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.0.len(), 1); assert_eq!( acc_ledger.latest_locked_chunk(), - Some(&LockedChunk:: { + Some(&LockedChunk { amount: init_amount, era: first_era, }) @@ -84,22 +361,626 @@ 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.0.len(), 1); + + // Adding to previous era should fail + assert_eq!( + acc_ledger.add_lock_amount(addition, first_era - 1), + Err(SparseBoundedError::OldEra) + ); // 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.0.len(), i as usize); } // Any further additions should fail due to exhausting bounded storage capacity + let acc_ledger_clone = acc_ledger.clone(); + assert_eq!( + acc_ledger.add_lock_amount(addition, acc_ledger.lock_era() + 1), + Err(SparseBoundedError::NoCapacity) + ); + 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); + get_u32_type!(StakingDummy, 8); + 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.0.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(addition, acc_ledger.era() + 1) - .is_err()); - assert!(!acc_ledger.is_empty()); + .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.0.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.0.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.0.len(), 2); + assert_eq!( + acc_ledger.active_locked_amount(), + second_lock_amount - unlock_amount + ); + assert_eq!( + acc_ledger.locked.0[0].amount, + first_lock_amount - unlock_amount + ); + assert_eq!( + acc_ledger.locked.0[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.0.len(), 2); + assert_eq!( + acc_ledger.active_locked_amount(), + second_lock_amount - unlock_amount * 2 + ); + assert_eq!( + acc_ledger.locked.0[0].amount, + first_lock_amount - unlock_amount + ); + assert_eq!( + acc_ledger.locked.0[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); + get_u32_type!(StakingDummy, 8); + 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.0.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.0.clone(); + for i in 1..10 { + assert!(acc_ledger + .subtract_lock_amount(unlock_amount, LockedDummy::get()) + .is_ok()); + assert_eq!(acc_ledger.locked.0.len(), LockedDummy::get() as usize); + + let last_idx = LockedDummy::get() as usize - 1; + assert_eq!( + &acc_ledger.locked.0[0..last_idx], + &locked_snapshot[0..last_idx] + ); + assert_eq!( + acc_ledger.locked.0[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_eq!( + acc_ledger.subtract_lock_amount(unlock_amount, LockedDummy::get() + 1), + Err(SparseBoundedError::NoCapacity) + ); + 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); + get_u32_type!(StakingDummy, 8); + 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.0.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.0.len(), 3); + assert_eq!(acc_ledger.locked.0[0].amount, first_lock_amount,); + assert_eq!( + acc_ledger.locked.0[2].amount, + first_lock_amount + second_lock_amount - unlock_amount + ); + // Verify the new entry is as expected + assert_eq!( + acc_ledger.locked.0[1].amount, + first_lock_amount - unlock_amount + ); + assert_eq!(acc_ledger.locked.0[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); + get_u32_type!(StakingDummy, 8); + 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.0.len(), 1); + assert_eq!( + acc_ledger.locked.0[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); + get_u32_type!(StakingDummy, 8); + 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.0.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.0.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.0.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); + get_u32_type!(StakingDummy, 8); + 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.0.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.0.len(), 4); + assert_eq!(acc_ledger.active_locked_amount(), third_lock_amount); + assert_eq!( + acc_ledger.locked.0[0], + LockedChunk { + amount: first_lock_amount, + era: first_lock_era + } + ); + assert_eq!( + acc_ledger.locked.0[1], + LockedChunk { + amount: first_lock_amount + second_lock_amount, + era: second_lock_era + } + ); + assert_eq!( + acc_ledger.locked.0[2], + LockedChunk { + amount: 0, + era: third_lock_era - 1 + } + ); + assert_eq!( + acc_ledger.locked.0[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); + get_u32_type!(StakingDummy, 8); + 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.0[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.0.len(), 2); + assert_eq!(acc_ledger.locked.0[1], second_chunk); +} + +#[test] +fn account_ledger_add_unlocking_chunk_works() { + get_u32_type!(LockedDummy, 5); + get_u32_type!(UnlockingDummy, 5); + get_u32_type!(StakingDummy, 8); + 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_eq!( + acc_ledger.add_unlocking_chunk(1, block_number + UnlockingDummy::get() as u64 + 1), + Err(SparseBoundedError::NoCapacity) + ); + assert_eq!(acc_ledger, acc_ledger_snapshot); +} + +#[test] +fn active_stake_works() { + get_u32_type!(LockedDummy, 5); + get_u32_type!(UnlockingDummy, 5); + get_u32_type!(StakingDummy, 8); + 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 = SparseBoundedAmountEraVec( + BoundedVec::try_from(vec![StakeChunk { amount, era: 1 }]) + .expect("Only one chunk so creation should succeed."), + ); + acc_ledger.staked_period = Some(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); + get_u32_type!(StakingDummy, 8); + 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 = SparseBoundedAmountEraVec( + BoundedVec::try_from(vec![StakeChunk { + amount: stake_amount, + era: lock_era, + }]) + .expect("Only one chunk so creation should succeed."), + ); + acc_ledger.staked_period = Some(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.0 = 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 claim_unlocked_works() { + get_u32_type!(LockedDummy, 5); + get_u32_type!(UnlockingDummy, 5); + get_u32_type!(StakingDummy, 8); + let mut acc_ledger = + AccountLedger::::default(); + + // Sanity check scenario + assert!(acc_ledger.claim_unlocked(0).is_zero()); + + // Add a chunk, assert it can be claimed correctly + let amount = 19; + let block_number = 1; + assert_ok!(acc_ledger.add_unlocking_chunk(amount, block_number)); + assert!(acc_ledger.claim_unlocked(0).is_zero()); + assert_eq!(acc_ledger.claim_unlocked(block_number), amount); + assert!(acc_ledger.unlocking.is_empty()); + + // Add multiple chunks, assert claim works correctly + let (amount1, amount2, amount3) = (7, 13, 19); + let (block1, block2, block3) = (1, 3, 5); + + // Prepare unlocking chunks + assert_ok!(acc_ledger.add_unlocking_chunk(amount1, block1)); + assert_ok!(acc_ledger.add_unlocking_chunk(amount2, block2)); + assert_ok!(acc_ledger.add_unlocking_chunk(amount3, block3)); + + // Only claim 1 chunk + assert_eq!(acc_ledger.claim_unlocked(block1 + 1), amount1); + assert_eq!(acc_ledger.unlocking.len(), 2); + + // Claim remaining two chunks + assert_eq!(acc_ledger.claim_unlocked(block3 + 1), amount2 + amount3); + assert!(acc_ledger.unlocking.is_empty()); +} + +#[test] +fn consume_unlocking_chunks_works() { + get_u32_type!(LockedDummy, 5); + get_u32_type!(UnlockingDummy, 5); + get_u32_type!(StakingDummy, 8); + let mut acc_ledger = + AccountLedger::::default(); + + // Sanity check scenario + assert!(acc_ledger.consume_unlocking_chunks().is_zero()); + + // Add multiple chunks, cal should return correct amount + let (amount1, amount2) = (7, 13); + assert_ok!(acc_ledger.add_unlocking_chunk(amount1, 1)); + assert_ok!(acc_ledger.add_unlocking_chunk(amount2, 2)); + + assert_eq!(acc_ledger.consume_unlocking_chunks(), amount1 + amount2); + assert!(acc_ledger.unlocking.is_empty()); +} + +#[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); + + // Claim unlocked chunks + let old_era_info = era_info.clone(); + era_info.unlocking_removed(1); + assert_eq!(era_info.unlocking, old_era_info.unlocking - 1); + assert_eq!(era_info.active_era_locked, old_era_info.active_era_locked); } diff --git a/pallets/dapp-staking-v3/src/types.rs b/pallets/dapp-staking-v3/src/types.rs index 958cd496d5..59321fe311 100644 --- a/pallets/dapp-staking-v3/src/types.rs +++ b/pallets/dapp-staking-v3/src/types.rs @@ -16,25 +16,26 @@ // You should have received a copy of the GNU General Public License // along with Astar. If not, see . -use frame_support::{pallet_prelude::*, traits::Currency, BoundedVec}; +use frame_support::{pallet_prelude::*, BoundedVec}; use frame_system::pallet_prelude::*; use parity_scale_codec::{Decode, Encode}; -use sp_runtime::traits::{AtLeast32BitUnsigned, Zero}; +use sp_runtime::{ + traits::{AtLeast32BitUnsigned, Zero}, + Saturating, +}; + +use astar_primitives::Balance; use crate::pallet::Config; // TODO: instead of using `pub` visiblity for fields, either use `pub(crate)` or add dedicated methods for accessing them. -/// The balance type used by the currency system. -pub type BalanceOf = - <::Currency as Currency<::AccountId>>::Balance; - /// Convenience type for `AccountLedger` usage. pub type AccountLedgerFor = AccountLedger< - BalanceOf, BlockNumberFor, ::MaxLockedChunks, ::MaxUnlockingChunks, + ::MaxStakingChunks, >; /// Era number type @@ -44,6 +45,184 @@ pub type PeriodNumber = u32; /// Dapp Id type pub type DAppId = u16; +// TODO: perhaps this trait is not needed and instead of having 2 separate '___Chunk' types, we can have just one? +/// Trait for types that can be used as a pair of amount & era. +pub trait AmountEraPair: MaxEncodedLen + Default + Copy { + /// Balance amount used somehow during the accompanied era. + fn get_amount(&self) -> Balance; + /// Era acting as timestamp for the accompanied amount. + fn get_era(&self) -> EraNumber; + // Sets the era to the specified value. + fn set_era(&mut self, era: EraNumber); + /// Increase the total amount by the specified increase, saturating at the maximum value. + fn saturating_accrue(&mut self, increase: Balance); + /// Reduce the total amount by the specified reduction, saturating at the minumum value. + fn saturating_reduce(&mut self, reduction: Balance); +} + +/// Simple enum representing errors possible when using sparse bounded vector. +#[derive(Debug, PartialEq, Eq)] +pub enum SparseBoundedError { + /// Old era values cannot be added. + OldEra, + /// Bounded storage capacity exceeded. + NoCapacity, +} + +/// Helper struct for easier manipulation of sparse pairs. +/// +/// The struct guarantees the following: +/// ----------------------------------- +/// 1. The vector is always sorted by era, in ascending order. +/// 2. There are no two consecutive zero chunks. +/// 3. There are no two chunks with the same era. +/// 4. The vector is always bounded by the specified maximum length. +/// +#[derive(Encode, Decode, MaxEncodedLen, Clone, Debug, PartialEq, Eq, TypeInfo)] +#[scale_info(skip_type_params(ML))] +pub struct SparseBoundedAmountEraVec>(pub BoundedVec); + +impl SparseBoundedAmountEraVec +where + P: AmountEraPair, + ML: Get, +{ + /// Create new instance + pub fn new() -> Self { + Self(BoundedVec::::default()) + } + + /// Places the specified pair into the vector, in an appropriate place. + /// + /// There are two possible successful scenarios: + /// 1. If entry for the specified era already exists, it's updated. + /// [(100, 1)] -- add_amount(50, 1) --> [(150, 1)] + /// + /// 2. If entry for the specified era doesn't exist, it's created and insertion is attempted. + /// [(100, 1)] -- add_amount(50, 2) --> [(100, 1), (150, 2)] + /// + /// In case vector has no more capacity, error is returned, and whole operation is a noop. + pub fn add_amount( + &mut self, + amount: Balance, + era: EraNumber, + ) -> Result<(), SparseBoundedError> { + if amount.is_zero() { + return Ok(()); + } + + let mut chunk = if let Some(&chunk) = self.0.last() { + ensure!(chunk.get_era() <= era, SparseBoundedError::OldEra); + chunk + } else { + P::default() + }; + + chunk.saturating_accrue(amount); + + if chunk.get_era() == era && !self.0.is_empty() { + if let Some(last) = self.0.last_mut() { + *last = chunk; + } + } else { + chunk.set_era(era); + self.0 + .try_push(chunk) + .map_err(|_| SparseBoundedError::NoCapacity)?; + } + + Ok(()) + } + + /// Subtracts the specified amount of the total locked amount, if possible. + /// + /// There are multiple success scenarios/rules: + /// 1. If entry for the specified era already exists, it's updated. + /// a. [(100, 1)] -- subtract_amount(50, 1) --> [(50, 1)] + /// b. [(100, 1)] -- subtract_amount(100, 1) --> [] + /// + /// 2. All entries following the specified era will have their amount reduced as well. + /// [(100, 1), (150, 2)] -- subtract_amount(50, 1) --> [(50, 1), (100, 2)] + /// + /// 3. If entry for the specified era doesn't exist, it's created and insertion is attempted. + /// [(100, 1), (200, 3)] -- subtract_amount(100, 2) --> [(100, 1), (0, 2), (100, 3)] + /// + /// 4. No two consecutive zero chunks are allowed. + /// [(100, 1), (0, 2), (100, 3), (200, 4)] -- subtract_amount(100, 3) --> [(100, 1), (0, 2), (100, 4)] + /// + /// In case vector has no more capacity, error is returned, and whole operation is a noop. + pub fn subtract_amount( + &mut self, + amount: Balance, + era: EraNumber, + ) -> Result<(), SparseBoundedError> { + if amount.is_zero() || self.0.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. + // TODO2: realistically, the only eligible eras are the last two ones (current & previous). Code could be optimized for that. + + // Find the most relevant locked chunk for the specified era + let index = if let Some(index) = self.0.iter().rposition(|&chunk| chunk.get_era() <= era) { + index + } else { + // Covers scenario when there's only 1 chunk for the next era, and remove it if it's zero. + self.0 + .iter_mut() + .for_each(|chunk| chunk.saturating_reduce(amount)); + self.0.retain(|chunk| !chunk.get_amount().is_zero()); + return Ok(()); + }; + + // Update existing or insert a new chunk + let mut inner = self.0.clone().into_inner(); + let relevant_chunk_index = if inner[index].get_era() == era { + inner[index].saturating_reduce(amount); + index + } else { + // Take the most relevant chunk for the desired era, + // and use it as 'base' for the new chunk. + let mut chunk = inner[index]; + chunk.saturating_reduce(amount); + chunk.set_era(era); + + // Insert the new chunk AFTER the previous 'most relevant chunk'. + // The chunk we find is always either for the requested era, or some era before it. + 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.saturating_reduce(amount)); + + // Prune all consecutive zero chunks + let mut new_inner = Vec::

::new(); + new_inner.push(inner[0]); + for i in 1..inner.len() { + if inner[i].get_amount().is_zero() && inner[i - 1].get_amount().is_zero() { + continue; + } else { + new_inner.push(inner[i]); + } + } + + inner = new_inner; + + // Cleanup if only one zero chunk exists + if inner.len() == 1 && inner[0].get_amount().is_zero() { + inner.pop(); + } + + // Update `locked` to the new vector + self.0 = BoundedVec::try_from(inner).map_err(|_| SparseBoundedError::NoCapacity)?; + + Ok(()) + } +} + /// Distinct period types in dApp staking protocol. #[derive(Encode, Decode, MaxEncodedLen, Clone, Copy, Debug, PartialEq, Eq, TypeInfo)] pub enum PeriodType { @@ -125,17 +304,14 @@ pub struct DAppInfo { /// How much was locked in a specific era #[derive(Encode, Decode, MaxEncodedLen, Clone, Copy, Debug, PartialEq, Eq, TypeInfo)] -pub struct LockedChunk { +pub struct LockedChunk { #[codec(compact)] pub amount: Balance, #[codec(compact)] pub era: EraNumber, } -impl Default for LockedChunk -where - Balance: AtLeast32BitUnsigned + MaxEncodedLen + Copy, -{ +impl Default for LockedChunk { fn default() -> Self { Self { amount: Balance::zero(), @@ -144,23 +320,36 @@ where } } -// TODO: would users get better UX if we kept using eras? Using blocks is more precise though. +impl AmountEraPair for LockedChunk { + fn get_amount(&self) -> Balance { + self.amount + } + fn get_era(&self) -> EraNumber { + self.era + } + fn set_era(&mut self, era: EraNumber) { + self.era = era; + } + fn saturating_accrue(&mut self, increase: Balance) { + self.amount.saturating_accrue(increase); + } + fn saturating_reduce(&mut self, reduction: Balance) { + self.amount.saturating_reduce(reduction); + } +} + /// 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, -> { +pub struct UnlockingChunk { #[codec(compact)] pub amount: Balance, #[codec(compact)] pub unlock_block: BlockNumber, } -impl Default for UnlockingChunk +impl Default for UnlockingChunk where - Balance: AtLeast32BitUnsigned + MaxEncodedLen + Copy, - BlockNumber: AtLeast32BitUnsigned + MaxEncodedLen, + BlockNumber: AtLeast32BitUnsigned + MaxEncodedLen + Copy, { fn default() -> Self { Self { @@ -170,108 +359,241 @@ where } } +/// Information about how much was staked in a specific era. +#[derive(Encode, Decode, MaxEncodedLen, Clone, Copy, Debug, PartialEq, Eq, TypeInfo)] +pub struct StakeChunk { + #[codec(compact)] + pub amount: Balance, + #[codec(compact)] + pub era: EraNumber, +} + +impl Default for StakeChunk { + fn default() -> Self { + Self { + amount: Balance::zero(), + era: EraNumber::zero(), + } + } +} + +impl AmountEraPair for StakeChunk { + fn get_amount(&self) -> Balance { + self.amount + } + fn get_era(&self) -> EraNumber { + self.era + } + fn set_era(&mut self, era: EraNumber) { + self.era = era; + } + fn saturating_accrue(&mut self, increase: Balance) { + self.amount.saturating_accrue(increase); + } + fn saturating_reduce(&mut self, reduction: Balance) { + self.amount.saturating_reduce(reduction); + } +} + /// General info about user's stakes #[derive(Encode, Decode, MaxEncodedLen, Clone, Debug, PartialEq, Eq, TypeInfo)] -#[scale_info(skip_type_params(LockedLen, UnlockingLen))] +#[scale_info(skip_type_params(LockedLen, UnlockingLen, StakedLen))] pub struct AccountLedger< - Balance: AtLeast32BitUnsigned + MaxEncodedLen + Copy, - BlockNumber: AtLeast32BitUnsigned + MaxEncodedLen, + BlockNumber: AtLeast32BitUnsigned + MaxEncodedLen + Copy, LockedLen: Get, UnlockingLen: Get, + StakedLen: Get, > { /// How much was staked in each era - pub locked: BoundedVec, LockedLen>, + pub locked: SparseBoundedAmountEraVec, /// How much started unlocking on a certain block - pub unlocking: BoundedVec, UnlockingLen>, - //TODO, make this a compact struct!!! + pub unlocking: BoundedVec, UnlockingLen>, /// How much user had staked in some period - // #[codec(compact)] - pub staked: (Balance, PeriodNumber), + pub staked: SparseBoundedAmountEraVec, + /// Last period in which account had staked. + pub staked_period: Option, } -impl Default - for AccountLedger +impl Default + for AccountLedger where - Balance: AtLeast32BitUnsigned + MaxEncodedLen + Copy, - BlockNumber: AtLeast32BitUnsigned + MaxEncodedLen, + BlockNumber: AtLeast32BitUnsigned + MaxEncodedLen + Copy, LockedLen: Get, UnlockingLen: Get, + StakedLen: Get, { fn default() -> Self { Self { - locked: BoundedVec::, LockedLen>::default(), - unlocking: BoundedVec::, UnlockingLen>::default(), - staked: (Balance::zero(), 0), + locked: SparseBoundedAmountEraVec(BoundedVec::::default()), + unlocking: BoundedVec::, UnlockingLen>::default(), + staked: SparseBoundedAmountEraVec(BoundedVec::::default()), + staked_period: None, } } } -impl - AccountLedger +impl + AccountLedger where - Balance: AtLeast32BitUnsigned + MaxEncodedLen + Copy, - BlockNumber: AtLeast32BitUnsigned + MaxEncodedLen, + BlockNumber: AtLeast32BitUnsigned + MaxEncodedLen + Copy, LockedLen: Get, UnlockingLen: Get, + StakedLen: 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.0.is_empty() && self.unlocking.is_empty() && self.staked.0.is_empty() } /// Returns latest locked chunk if it exists, `None` otherwise - pub fn latest_locked_chunk(&self) -> Option<&LockedChunk> { - self.locked.last() + pub fn latest_locked_chunk(&self) -> Option<&LockedChunk> { + self.locked.0.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() + .saturating_add(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) } + /// 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(), + } + } + /// 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. /// /// 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 add_lock_amount(&mut self, amount: Balance, era: EraNumber) -> Result<(), ()> { + pub fn add_lock_amount( + &mut self, + amount: Balance, + era: EraNumber, + ) -> Result<(), SparseBoundedError> { + self.locked.add_amount(amount, era) + } + + /// 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<(), SparseBoundedError> { + self.locked.subtract_amount(amount, era) + } + + /// 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<(), SparseBoundedError> { if amount.is_zero() { return Ok(()); } - let mut locked_chunk = if let Some(&locked_chunk) = self.locked.last() { - locked_chunk - } else { - LockedChunk::default() - }; - - locked_chunk.amount.saturating_accrue(amount); + let idx = self + .unlocking + .binary_search_by(|chunk| chunk.unlock_block.cmp(&unlock_block)); - if locked_chunk.era == era && !self.locked.is_empty() { - if let Some(last) = self.locked.last_mut() { - *last = locked_chunk; + 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(|_| SparseBoundedError::NoCapacity)?; } - } else { - locked_chunk.era = era; - self.locked.try_push(locked_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)) + } + + /// Claims all of the fully unlocked chunks, and returns the total claimable amount. + pub fn claim_unlocked(&mut self, current_block_number: BlockNumber) -> Balance { + let mut total = Balance::zero(); + + self.unlocking.retain(|chunk| { + if chunk.unlock_block <= current_block_number { + total.saturating_accrue(chunk.amount); + false + } else { + true + } + }); + + total + } + + /// Consumes all of the unlocking chunks, and returns the total amount being unlocked. + pub fn consume_unlocking_chunks(&mut self) -> Balance { + let amount = self.unlocking.iter().fold(Balance::zero(), |sum, chunk| { + sum.saturating_add(chunk.amount) + }); + self.unlocking = Default::default(); + + amount + } } -/// Rewards pool for lock participants & dApps -#[derive(Encode, Decode, MaxEncodedLen, Clone, Debug, PartialEq, Eq, TypeInfo, Default)] -pub struct RewardInfo { +/// Rewards pool for stakers & dApps +#[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)] pub participants: Balance, @@ -281,10 +603,10 @@ 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)] -pub struct EraInfo { +#[derive(Encode, Decode, MaxEncodedLen, Copy, Clone, Debug, PartialEq, Eq, TypeInfo, Default)] +pub struct EraInfo { /// Info about era rewards - pub rewards: RewardInfo, + pub rewards: RewardInfo, /// How much balance is considered to be locked in the current era. /// This value influences the reward distribution. #[codec(compact)] @@ -293,7 +615,27 @@ pub struct EraInfo { /// For rewards, this amount isn't relevant for the current era, but only from the next one. #[codec(compact)] pub total_locked: Balance, - /// How much balance is undergoing unlocking process (still counts into locked amount) + /// How much balance is undergoing unlocking process. + /// This amount still counts into locked amount. #[codec(compact)] pub unlocking: Balance, } + +impl EraInfo { + /// 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); + } + + /// Update with the new amount that has been removed from unlocking. + pub fn unlocking_removed(&mut self, amount: Balance) { + self.unlocking.saturating_reduce(amount); + } +}