--- a/Cargo.lock +++ b/Cargo.lock @@ -5782,7 +5782,7 @@ [[package]] name = "pallet-app-promotion" -version = "0.1.4" +version = "0.1.5" dependencies = [ "frame-benchmarking", "frame-support", --- a/pallets/app-promotion/CHANGELOG.md +++ b/pallets/app-promotion/CHANGELOG.md @@ -4,6 +4,12 @@ +## [0.1.5] - 2023-02-14 + +### Added + +- `unstake_partial` extrinsic. + ## [0.1.4] - 2023-01-31 ### Changed --- a/pallets/app-promotion/Cargo.toml +++ b/pallets/app-promotion/Cargo.toml @@ -9,7 +9,7 @@ license = 'GPLv3' name = 'pallet-app-promotion' repository = 'https://github.com/UniqueNetwork/unique-chain' -version = '0.1.4' +version = '0.1.5' [package.metadata.docs.rs] targets = ['x86_64-unknown-linux-gnu'] --- a/pallets/app-promotion/src/benchmarking.rs +++ b/pallets/app-promotion/src/benchmarking.rs @@ -65,7 +65,7 @@ let staker = account::("staker", index, SEED); ::Currency::make_free_balance_be(&staker, Perbill::from_rational(1u32, 2) * BalanceOf::::max_value()); PromototionPallet::::stake(RawOrigin::Signed(staker.clone()).into(), Into::>::into(100u128) * T::Nominal::get())?; - PromototionPallet::::unstake(RawOrigin::Signed(staker.clone()).into()).map_err(|e| e.error)?; + PromototionPallet::::unstake_all(RawOrigin::Signed(staker.clone()).into())?; Result::<(), sp_runtime::DispatchError>::Ok(()) })?; let block_number = >::current_block_number() + T::PendingInterval::get(); @@ -115,7 +115,7 @@ let _ = ::Currency::make_free_balance_be(&caller, Perbill::from_rational(1u32, 2) * BalanceOf::::max_value()); } : _(RawOrigin::Signed(caller.clone()), share * ::Currency::total_balance(&caller)) - unstake { + unstake_all { let caller = account::("caller", 0, SEED); let share = Perbill::from_rational(1u32, 20); let _ = ::Currency::make_free_balance_be(&caller, Perbill::from_rational(1u32, 2) * BalanceOf::::max_value()); @@ -130,6 +130,21 @@ } : _(RawOrigin::Signed(caller.clone())) + unstake_partial { + let caller = account::("caller", 0, SEED); + let share = Perbill::from_rational(1u32, 20); + let _ = ::Currency::make_free_balance_be(&caller, Perbill::from_rational(1u32, 2) * BalanceOf::::max_value()); + (1..11).map(|i| { + // used to change block number + >::set_block_number(i.into()); + T::RelayBlockNumberProvider::set_block_number((2*i).into()); + assert_eq!(>::block_number(), i.into()); + assert_eq!(T::RelayBlockNumberProvider::current_block_number(), (2*i).into()); + PromototionPallet::::stake(RawOrigin::Signed(caller.clone()).into(), Into::>::into(100u128) * T::Nominal::get()) + }).collect::, _>>()?; + + } : _(RawOrigin::Signed(caller.clone()), Into::>::into(1000u128) * T::Nominal::get()) + sponsor_collection { let pallet_admin = account::("admin", 0, SEED); PromototionPallet::::set_admin_address(RawOrigin::Root.into(), T::CrossAccountId::from_sub(pallet_admin.clone()))?; --- a/pallets/app-promotion/src/lib.rs +++ b/pallets/app-promotion/src/lib.rs @@ -72,7 +72,7 @@ traits::{ Currency, Get, LockableCurrency, WithdrawReasons, tokens::Balance, ExistenceRequirement, }, - ensure, + ensure, BoundedVec, }; use weights::WeightInfo; @@ -156,7 +156,7 @@ pub struct Pallet(_); #[pallet::event] - #[pallet::generate_deposit(fn deposit_event)] + #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum Event { /// Staking recalculation was performed /// @@ -208,6 +208,8 @@ SponsorNotSet, /// Errors caused by incorrect actions with a locked balance. IncorrectLockedBalanceOperation, + /// Errors caused by insufficient staked balance. + InsufficientStakedBalance, } /// Stores the total staked amount. @@ -489,53 +491,30 @@ } /// Unstakes all stakes. - /// Moves the sum of all stakes to the `reserved` state. /// After the end of `PendingInterval` this sum becomes completely /// free for further use. #[pallet::call_index(2)] - #[pallet::weight(::WeightInfo::unstake())] - pub fn unstake(staker: OriginFor) -> DispatchResultWithPostInfo { + #[pallet::weight(::WeightInfo::unstake_all())] + pub fn unstake_all(staker: OriginFor) -> DispatchResult { let staker_id = ensure_signed(staker)?; - let config = >::get(); - // calculate block number where the sum would be free - let block = >::block_number() + config.pending_interval; + Self::unstake_all_internal(staker_id) + } - let mut pendings = >::get(block); - - // checks that we can do unreserve stakes in the block - ensure!(!pendings.is_full(), Error::::PendingForBlockOverflow); - - let mut total_stakes = 0u64; - - let total_staked: BalanceOf = Staked::::drain_prefix((&staker_id,)) - .map(|(_, (amount, _))| { - total_stakes += 1; - amount - }) - .sum(); + /// Unstakes the amount of balance for the staker. + /// After the end of `PendingInterval` this sum becomes completely + /// free for further use. + /// + /// # Arguments + /// + /// * `staker`: staker account. + /// * `amount`: amount of unstaked funds. + #[pallet::call_index(8)] + #[pallet::weight(::WeightInfo::unstake_partial())] + pub fn unstake_partial(staker: OriginFor, amount: BalanceOf) -> DispatchResult { + let staker_id = ensure_signed(staker)?; - if total_staked.is_zero() { - return Ok(None::.into()); // TO-DO - } - - pendings - .try_push((staker_id.clone(), total_staked)) - .map_err(|_| Error::::PendingForBlockOverflow)?; - - >::insert(block, pendings); - - TotalStaked::::set( - TotalStaked::::get() - .checked_sub(&total_staked) - .ok_or(ArithmeticError::Underflow)?, - ); - - StakesPerAccount::::remove(&staker_id); - - Self::deposit_event(Event::Unstake(staker_id, total_staked)); - - Ok(None::.into()) + Self::unstake_partial_internal(staker_id, amount) } /// Sets the pallet to be the sponsor for the collection. @@ -809,6 +788,100 @@ T::PalletId::get().into_account_truncating() } + /// Unstakes the balance for the staker. + /// + /// - `staker`: staker account. + /// - `amount`: amount of unstaked funds. + fn unstake_partial_internal( + staker_id: T::AccountId, + unstaked_balance: BalanceOf, + ) -> DispatchResult { + if unstaked_balance == Default::default() { + return Ok(()); + } + + let config = >::get(); + + // calculate block number where the sum would be free + let unpending_block = >::block_number() + config.pending_interval; + + let mut pendings = >::get(unpending_block); + + // checks that we can do unstake in the block + ensure!(!pendings.is_full(), Error::::PendingForBlockOverflow); + + let mut stakes = Staked::::iter_prefix((&staker_id,)).collect::>(); + + let total_staked = stakes + .iter() + .fold(>::default(), |acc, (_, (balance, _))| { + acc + *balance + }); + + ensure!( + unstaked_balance <= total_staked, + >::InsufficientStakedBalance + ); + + >::set( + >::get() + .checked_sub(&unstaked_balance) + .ok_or(ArithmeticError::Underflow)?, + ); + + stakes.sort_by_key(|(block, _)| *block); + + let mut acc_amount = unstaked_balance; + let mut will_deleted_stakes_count = 0u8; + + let changed_stakes = stakes + .into_iter() + .map_while(|(block, (balance_per_block, _))| { + if acc_amount == >::default() { + return None; + } + if acc_amount < balance_per_block { + let res = (block, balance_per_block - acc_amount); + acc_amount = >::default(); + return Some(res); + } else { + acc_amount -= balance_per_block; + will_deleted_stakes_count += 1; + return Some((block, >::default())); + } + }) + .collect::>(); + + pendings + .try_push((staker_id.clone(), unstaked_balance)) + .map_err(|_| Error::::PendingForBlockOverflow)?; + + StakesPerAccount::::try_mutate(&staker_id, |stakes| -> DispatchResult { + *stakes = stakes + .checked_sub(will_deleted_stakes_count) + .ok_or(ArithmeticError::Underflow)?; + Ok(()) + })?; + + changed_stakes + .into_iter() + .for_each(|(staked_block, current_stake_state)| { + if current_stake_state == Default::default() { + >::remove((&staker_id, staked_block)); + } else { + >::mutate((&staker_id, staked_block), |(old_stake_state, _)| { + *old_stake_state = current_stake_state + }); + } + }); + + >::insert(unpending_block, pendings); + + Self::deposit_event(Event::Unstake(staker_id, total_staked)); + + Ok(()) + } + /// Adds the balance to locked by the pallet. /// /// - `staker`: staker account. @@ -1005,4 +1078,47 @@ unsorted_res.sort_by_key(|(block, _)| *block); unsorted_res } + + fn unstake_all_internal(staker_id: T::AccountId) -> DispatchResult { + let config = >::get(); + + // calculate block number where the sum would be free + let block = >::block_number() + config.pending_interval; + + let mut pendings = >::get(block); + + // checks that we can do unstake in the block + ensure!(!pendings.is_full(), Error::::PendingForBlockOverflow); + + let mut total_stakes = 0u64; + + let total_staked: BalanceOf = Staked::::drain_prefix((&staker_id,)) + .map(|(_, (amount, _))| { + total_stakes += 1; + amount + }) + .sum(); + + if total_staked.is_zero() { + return Ok(()); + } + + pendings + .try_push((staker_id.clone(), total_staked)) + .map_err(|_| Error::::PendingForBlockOverflow)?; + + >::insert(block, pendings); + + TotalStaked::::set( + TotalStaked::::get() + .checked_sub(&total_staked) + .ok_or(ArithmeticError::Underflow)?, + ); + + StakesPerAccount::::remove(&staker_id); + + Self::deposit_event(Event::Unstake(staker_id, total_staked)); + + Ok(()) + } } --- a/pallets/app-promotion/src/weights.rs +++ b/pallets/app-promotion/src/weights.rs @@ -3,7 +3,7 @@ //! Autogenerated weights for pallet_app_promotion //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 4.0.0-dev -//! DATE: 2022-12-25, STEPS: `50`, REPEAT: 80, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2023-02-15, STEPS: `50`, REPEAT: 80, LOW RANGE: `[]`, HIGH RANGE: `[]` //! EXECUTION: None, WASM-EXECUTION: Compiled, CHAIN: None, DB CACHE: 1024 // Executed Command: @@ -38,7 +38,8 @@ fn set_admin_address() -> Weight; fn payout_stakers(b: u32, ) -> Weight; fn stake() -> Weight; - fn unstake() -> Weight; + fn unstake_all() -> Weight; + fn unstake_partial() -> Weight; fn sponsor_collection() -> Weight; fn stop_sponsoring_collection() -> Weight; fn sponsor_contract() -> Weight; @@ -49,18 +50,19 @@ pub struct SubstrateWeight(PhantomData); impl WeightInfo for SubstrateWeight { // Storage: AppPromotion PendingUnstake (r:1 w:0) + // Storage: Balances Locks (r:1 w:1) // Storage: System Account (r:1 w:1) fn on_initialize(b: u32, ) -> Weight { - Weight::from_ref_time(3_079_948 as u64) - // Standard Error: 30_376 - .saturating_add(Weight::from_ref_time(6_343_630 as u64).saturating_mul(b as u64)) + Weight::from_ref_time(2_592_346 as u64) + // Standard Error: 23_629 + .saturating_add(Weight::from_ref_time(7_523_802 as u64).saturating_mul(b as u64)) .saturating_add(T::DbWeight::get().reads(1 as u64)) - .saturating_add(T::DbWeight::get().reads((1 as u64).saturating_mul(b as u64))) - .saturating_add(T::DbWeight::get().writes((1 as u64).saturating_mul(b as u64))) + .saturating_add(T::DbWeight::get().reads((2 as u64).saturating_mul(b as u64))) + .saturating_add(T::DbWeight::get().writes((2 as u64).saturating_mul(b as u64))) } // Storage: AppPromotion Admin (r:0 w:1) fn set_admin_address() -> Weight { - Weight::from_ref_time(6_653_000 as u64) + Weight::from_ref_time(6_209_000 as u64) .saturating_add(T::DbWeight::get().writes(1 as u64)) } // Storage: AppPromotion Admin (r:1 w:0) @@ -72,9 +74,9 @@ // Storage: Balances Locks (r:1 w:1) // Storage: AppPromotion TotalStaked (r:1 w:1) fn payout_stakers(b: u32, ) -> Weight { - Weight::from_ref_time(74_048_000 as u64) - // Standard Error: 33_223 - .saturating_add(Weight::from_ref_time(57_702_092 as u64).saturating_mul(b as u64)) + Weight::from_ref_time(64_917_000 as u64) + // Standard Error: 34_206 + .saturating_add(Weight::from_ref_time(51_518_500 as u64).saturating_mul(b as u64)) .saturating_add(T::DbWeight::get().reads(7 as u64)) .saturating_add(T::DbWeight::get().reads((12 as u64).saturating_mul(b as u64))) .saturating_add(T::DbWeight::get().writes(3 as u64)) @@ -88,47 +90,55 @@ // Storage: AppPromotion Staked (r:1 w:1) // Storage: AppPromotion TotalStaked (r:1 w:1) fn stake() -> Weight { - Weight::from_ref_time(20_314_000 as u64) + Weight::from_ref_time(18_208_000 as u64) .saturating_add(T::DbWeight::get().reads(7 as u64)) .saturating_add(T::DbWeight::get().writes(5 as u64)) } // Storage: Configuration AppPromomotionConfigurationOverride (r:1 w:0) // Storage: AppPromotion PendingUnstake (r:1 w:1) // Storage: AppPromotion Staked (r:11 w:10) - // Storage: Balances Locks (r:1 w:1) - // Storage: System Account (r:1 w:1) // Storage: AppPromotion TotalStaked (r:1 w:1) // Storage: AppPromotion StakesPerAccount (r:0 w:1) - fn unstake() -> Weight { - Weight::from_ref_time(64_582_000 as u64) - .saturating_add(T::DbWeight::get().reads(16 as u64)) - .saturating_add(T::DbWeight::get().writes(15 as u64)) + fn unstake_all() -> Weight { + Weight::from_ref_time(45_018_000 as u64) + .saturating_add(T::DbWeight::get().reads(14 as u64)) + .saturating_add(T::DbWeight::get().writes(13 as u64)) + } + // Storage: Configuration AppPromomotionConfigurationOverride (r:1 w:0) + // Storage: AppPromotion PendingUnstake (r:1 w:1) + // Storage: AppPromotion Staked (r:11 w:10) + // Storage: AppPromotion TotalStaked (r:1 w:1) + // Storage: AppPromotion StakesPerAccount (r:1 w:1) + fn unstake_partial() -> Weight { + Weight::from_ref_time(49_066_000 as u64) + .saturating_add(T::DbWeight::get().reads(15 as u64)) + .saturating_add(T::DbWeight::get().writes(13 as u64)) } // Storage: AppPromotion Admin (r:1 w:0) // Storage: Common CollectionById (r:1 w:1) fn sponsor_collection() -> Weight { - Weight::from_ref_time(16_364_000 as u64) + Weight::from_ref_time(15_039_000 as u64) .saturating_add(T::DbWeight::get().reads(2 as u64)) .saturating_add(T::DbWeight::get().writes(1 as u64)) } // Storage: AppPromotion Admin (r:1 w:0) // Storage: Common CollectionById (r:1 w:1) fn stop_sponsoring_collection() -> Weight { - Weight::from_ref_time(15_710_000 as u64) + Weight::from_ref_time(14_692_000 as u64) .saturating_add(T::DbWeight::get().reads(2 as u64)) .saturating_add(T::DbWeight::get().writes(1 as u64)) } // Storage: AppPromotion Admin (r:1 w:0) // Storage: EvmContractHelpers Sponsoring (r:0 w:1) fn sponsor_contract() -> Weight { - Weight::from_ref_time(12_669_000 as u64) + Weight::from_ref_time(11_810_000 as u64) .saturating_add(T::DbWeight::get().reads(1 as u64)) .saturating_add(T::DbWeight::get().writes(1 as u64)) } // Storage: AppPromotion Admin (r:1 w:0) // Storage: EvmContractHelpers Sponsoring (r:1 w:1) fn stop_sponsoring_contract() -> Weight { - Weight::from_ref_time(14_406_000 as u64) + Weight::from_ref_time(13_570_000 as u64) .saturating_add(T::DbWeight::get().reads(2 as u64)) .saturating_add(T::DbWeight::get().writes(1 as u64)) } @@ -137,18 +147,19 @@ // For backwards compatibility and tests impl WeightInfo for () { // Storage: AppPromotion PendingUnstake (r:1 w:0) + // Storage: Balances Locks (r:1 w:1) // Storage: System Account (r:1 w:1) fn on_initialize(b: u32, ) -> Weight { - Weight::from_ref_time(3_079_948 as u64) - // Standard Error: 30_376 - .saturating_add(Weight::from_ref_time(6_343_630 as u64).saturating_mul(b as u64)) + Weight::from_ref_time(2_592_346 as u64) + // Standard Error: 23_629 + .saturating_add(Weight::from_ref_time(7_523_802 as u64).saturating_mul(b as u64)) .saturating_add(RocksDbWeight::get().reads(1 as u64)) - .saturating_add(RocksDbWeight::get().reads((1 as u64).saturating_mul(b as u64))) - .saturating_add(RocksDbWeight::get().writes((1 as u64).saturating_mul(b as u64))) + .saturating_add(RocksDbWeight::get().reads((2 as u64).saturating_mul(b as u64))) + .saturating_add(RocksDbWeight::get().writes((2 as u64).saturating_mul(b as u64))) } // Storage: AppPromotion Admin (r:0 w:1) fn set_admin_address() -> Weight { - Weight::from_ref_time(6_653_000 as u64) + Weight::from_ref_time(6_209_000 as u64) .saturating_add(RocksDbWeight::get().writes(1 as u64)) } // Storage: AppPromotion Admin (r:1 w:0) @@ -160,9 +171,9 @@ // Storage: Balances Locks (r:1 w:1) // Storage: AppPromotion TotalStaked (r:1 w:1) fn payout_stakers(b: u32, ) -> Weight { - Weight::from_ref_time(74_048_000 as u64) - // Standard Error: 33_223 - .saturating_add(Weight::from_ref_time(57_702_092 as u64).saturating_mul(b as u64)) + Weight::from_ref_time(64_917_000 as u64) + // Standard Error: 34_206 + .saturating_add(Weight::from_ref_time(51_518_500 as u64).saturating_mul(b as u64)) .saturating_add(RocksDbWeight::get().reads(7 as u64)) .saturating_add(RocksDbWeight::get().reads((12 as u64).saturating_mul(b as u64))) .saturating_add(RocksDbWeight::get().writes(3 as u64)) @@ -176,47 +187,55 @@ // Storage: AppPromotion Staked (r:1 w:1) // Storage: AppPromotion TotalStaked (r:1 w:1) fn stake() -> Weight { - Weight::from_ref_time(20_314_000 as u64) + Weight::from_ref_time(18_208_000 as u64) .saturating_add(RocksDbWeight::get().reads(7 as u64)) .saturating_add(RocksDbWeight::get().writes(5 as u64)) } // Storage: Configuration AppPromomotionConfigurationOverride (r:1 w:0) // Storage: AppPromotion PendingUnstake (r:1 w:1) // Storage: AppPromotion Staked (r:11 w:10) - // Storage: Balances Locks (r:1 w:1) - // Storage: System Account (r:1 w:1) // Storage: AppPromotion TotalStaked (r:1 w:1) // Storage: AppPromotion StakesPerAccount (r:0 w:1) - fn unstake() -> Weight { - Weight::from_ref_time(64_582_000 as u64) - .saturating_add(RocksDbWeight::get().reads(16 as u64)) - .saturating_add(RocksDbWeight::get().writes(15 as u64)) + fn unstake_all() -> Weight { + Weight::from_ref_time(45_018_000 as u64) + .saturating_add(RocksDbWeight::get().reads(14 as u64)) + .saturating_add(RocksDbWeight::get().writes(13 as u64)) } + // Storage: Configuration AppPromomotionConfigurationOverride (r:1 w:0) + // Storage: AppPromotion PendingUnstake (r:1 w:1) + // Storage: AppPromotion Staked (r:11 w:10) + // Storage: AppPromotion TotalStaked (r:1 w:1) + // Storage: AppPromotion StakesPerAccount (r:1 w:1) + fn unstake_partial() -> Weight { + Weight::from_ref_time(49_066_000 as u64) + .saturating_add(RocksDbWeight::get().reads(15 as u64)) + .saturating_add(RocksDbWeight::get().writes(13 as u64)) + } // Storage: AppPromotion Admin (r:1 w:0) // Storage: Common CollectionById (r:1 w:1) fn sponsor_collection() -> Weight { - Weight::from_ref_time(16_364_000 as u64) + Weight::from_ref_time(15_039_000 as u64) .saturating_add(RocksDbWeight::get().reads(2 as u64)) .saturating_add(RocksDbWeight::get().writes(1 as u64)) } // Storage: AppPromotion Admin (r:1 w:0) // Storage: Common CollectionById (r:1 w:1) fn stop_sponsoring_collection() -> Weight { - Weight::from_ref_time(15_710_000 as u64) + Weight::from_ref_time(14_692_000 as u64) .saturating_add(RocksDbWeight::get().reads(2 as u64)) .saturating_add(RocksDbWeight::get().writes(1 as u64)) } // Storage: AppPromotion Admin (r:1 w:0) // Storage: EvmContractHelpers Sponsoring (r:0 w:1) fn sponsor_contract() -> Weight { - Weight::from_ref_time(12_669_000 as u64) + Weight::from_ref_time(11_810_000 as u64) .saturating_add(RocksDbWeight::get().reads(1 as u64)) .saturating_add(RocksDbWeight::get().writes(1 as u64)) } // Storage: AppPromotion Admin (r:1 w:0) // Storage: EvmContractHelpers Sponsoring (r:1 w:1) fn stop_sponsoring_contract() -> Weight { - Weight::from_ref_time(14_406_000 as u64) + Weight::from_ref_time(13_570_000 as u64) .saturating_add(RocksDbWeight::get().reads(2 as u64)) .saturating_add(RocksDbWeight::get().writes(1 as u64)) } --- a/pallets/nonfungible/src/benchmarking.rs +++ b/pallets/nonfungible/src/benchmarking.rs @@ -24,7 +24,10 @@ CommonCollectionOperations, }; use sp_std::prelude::*; -use up_data_structs::{CollectionMode, MAX_ITEMS_PER_BATCH, MAX_PROPERTIES_PER_ITEM, budget::Unlimited}; +use up_data_structs::{ + CollectionMode, MAX_ITEMS_PER_BATCH, MAX_PROPERTIES_PER_ITEM, budget::Unlimited, + PropertyPermission, +}; const SEED: u32 = 1; --- a/pallets/refungible/src/benchmarking.rs +++ b/pallets/refungible/src/benchmarking.rs @@ -25,7 +25,10 @@ benchmarking::{create_collection_raw, property_key, property_value}, }; use sp_std::prelude::*; -use up_data_structs::{CollectionMode, MAX_ITEMS_PER_BATCH, MAX_PROPERTIES_PER_ITEM, budget::Unlimited}; +use up_data_structs::{ + CollectionMode, MAX_ITEMS_PER_BATCH, MAX_PROPERTIES_PER_ITEM, budget::Unlimited, + PropertyPermission, +}; const SEED: u32 = 1; --- a/tests/src/sub/appPromotion/appPromotion.test.ts +++ b/tests/src/sub/appPromotion/appPromotion.test.ts @@ -26,6 +26,13 @@ let nominal: bigint; let palletAddress: string; let accounts: IKeyringPair[]; +let usedAccounts: IKeyringPair[] = []; + +function getAccount(accountsNumber: number) { + const accs = accounts.splice(0, accountsNumber); + usedAccounts.push(...accs); + return accs; +} // App promotion periods: // LOCKING_PERIOD = 12 blocks of relay // UNLOCKING_PERIOD = 6 blocks of parachain @@ -39,15 +46,29 @@ palletAdmin = await privateKey('//PromotionAdmin'); nominal = helper.balance.getOneTokenNominal(); - const accountBalances = new Array(100); - accountBalances.fill(1000n); + const accountBalances = new Array(200).fill(1000n); accounts = await helper.arrange.createAccounts(accountBalances, donor); // create accounts-pool to speed up tests }); }); + afterEach(async () => { + await usingPlaygrounds(async (helper) => { + let unstakeTxs = []; + for (const account of usedAccounts) { + if (unstakeTxs.length === 3) { + await Promise.all(unstakeTxs); + unstakeTxs = []; + } + unstakeTxs.push(helper.staking.unstakeAll(account)); + } + await Promise.all(unstakeTxs); + usedAccounts = []; + }); + }); + describe('stake extrinsic', () => { itSub('should "lock" staking balance, add it to "staked" map, and increase "totalStaked" amount', async ({helper}) => { - const [staker, recepient] = [accounts.pop()!, accounts.pop()!]; + const [staker, recepient] = getAccount(2); const totalStakedBefore = await helper.staking.getTotalStaked(); // Minimum stake amount is 100: @@ -73,26 +94,48 @@ expect(totalStakedPerBlock[1].amount).to.equal(200n * nominal); }); - itSub('should allow to create maximum 10 stakes for account', async ({helper}) => { - const [staker] = await helper.arrange.createAccounts([2000n], donor); - for (let i = 0; i < 10; i++) { - await helper.staking.stake(staker, 100n * nominal); - } + [ + {unstake: 'unstakeAll' as const}, + {unstake: 'unstakePartial' as const}, + ].map(testCase => { + itSub('should allow to create maximum 10 stakes for account', async ({helper}) => { + const [staker] = await helper.arrange.createAccounts([2000n], donor); + const ONE_STAKE = 100n * nominal; + for (let i = 0; i < 10; i++) { + await helper.staking.stake(staker, ONE_STAKE); + } + + // can have 10 stakes + expect(await helper.staking.getTotalStaked({Substrate: staker.address})).to.be.equal(1000n * nominal); + expect(await helper.staking.getTotalStakedPerBlock({Substrate: staker.address})).to.have.length(10); - // can have 10 stakes - expect(await helper.staking.getTotalStaked({Substrate: staker.address})).to.be.equal(1000n * nominal); - expect(await helper.staking.getTotalStakedPerBlock({Substrate: staker.address})).to.have.length(10); + await expect(helper.staking.stake(staker, ONE_STAKE)).to.be.rejectedWith('appPromotion.NoPermission'); - await expect(helper.staking.stake(staker, 100n * nominal)).to.be.rejectedWith('appPromotion.NoPermission'); + // After unstake can stake again - // After unstake can stake again - await helper.staking.unstake(staker); - await helper.staking.stake(staker, 100n * nominal); - expect(await helper.staking.getTotalStaked({Substrate: staker.address})).to.equal(100n * nominal); + // CASE 1: unstakeAll + if (testCase.unstake === 'unstakeAll') { + await helper.staking.unstakeAll(staker); + expect(await helper.staking.getStakesNumber({Substrate: staker.address})).to.eq(0); + await helper.staking.stake(staker, 100n * nominal); + expect(await helper.staking.getTotalStaked({Substrate: staker.address})).to.equal(100n * nominal); + } + // CASE 2: unstakePartial + else { + await helper.staking.unstakePartial(staker, ONE_STAKE); + expect(await helper.staking.getStakesNumber({Substrate: staker.address})).to.eq(9); + await helper.staking.stake(staker, 100n * nominal); + expect(await helper.staking.getStakesNumber({Substrate: staker.address})).to.eq(10); + await expect(helper.staking.stake(staker, 100n * nominal)).to.be.rejectedWith('appPromotion.NoPermission'); + await helper.staking.unstakePartial(staker, 150n * nominal); + expect(await helper.staking.getStakesNumber({Substrate: staker.address})).to.eq(9); + expect(await helper.staking.getTotalStaked({Substrate: staker.address})).to.equal(850n * nominal); + } + }); }); itSub('should allow to stake() if balance is locked with different id', async ({helper}) => { - const staker = accounts.pop()!; + const [staker] = getAccount(1); // staker has tokens locked with vesting id: await helper.balance.vestedTransfer(donor, staker.address, {start: 0n, period: 1n, periodCount: 1n, perPeriod: 200n * nominal}); @@ -109,7 +152,7 @@ expect(await helper.staking.getTotalStaked({Substrate: staker.address})).to.eq(1199n * nominal); // staker can unstake - await helper.staking.unstake(staker); + await helper.staking.unstakeAll(staker); expect(await helper.staking.getPendingUnstake({Substrate: staker.address})).to.eq(1199n * nominal); const [pendingUnstake] = await helper.staking.getPendingUnstakePerBlock({Substrate: staker.address}); await helper.wait.forParachainBlockNumber(pendingUnstake.block); @@ -125,7 +168,7 @@ }); itSub('should not allow to stake(), if stake amount is more than total free balance minus locked by staking', async ({helper}) => { - const staker = accounts.pop()!; + const [staker] = getAccount(1); // Can't stake full balance because Alice needs to pay some fee await expect(helper.staking.stake(staker, 1000n * nominal)).to.be.rejected; // With('Arithmetic') @@ -137,7 +180,7 @@ }); itSub('for different accounts in one block is possible', async ({helper}) => { - const crowd = [accounts.pop()!, accounts.pop()!, accounts.pop()!, accounts.pop()!]; + const crowd = getAccount(4); const crowdStartsToStake = crowd.map(user => helper.staking.stake(user, 100n * nominal)); await expect(Promise.all(crowdStartsToStake)).to.be.fulfilled; @@ -147,132 +190,271 @@ }); }); - describe('unstake extrinsic', () => { - itSub('should move tokens to "pendingUnstake" map and subtract it from totalStaked', async ({helper}) => { - const [staker, recepient] = [accounts.pop()!, accounts.pop()!]; - const totalStakedBefore = await helper.staking.getTotalStaked(); - await helper.staking.stake(staker, 900n * nominal); - await helper.staking.unstake(staker); + describe('Unstaking', () => { + [ + {method: 'unstakeAll' as const}, + {method: 'unstakePartial' as const}, + ].map(testCase => { + itSub(`[${testCase.method}] should move tokens to "pendingUnstake" and subtract it from totalStaked`, async ({helper}) => { + const [staker, recepient] = getAccount(2); + const totalStakedBefore = await helper.staking.getTotalStaked(); + const STAKE_AMOUNT = 900n * nominal; - // Right after unstake tokens are still locked - expect(await helper.balance.getLocked(staker.address)).to.deep.eq([{id: 'appstake', amount: 900n * nominal, reasons: 'All'}]); - expect(await helper.balance.getSubstrateFull(staker.address)).to.deep.contain({reserved: 0n, miscFrozen: 900n * nominal, feeFrozen: 900n * nominal}); - // Staker can not transfer - await expect(helper.balance.transferToSubstrate(staker, recepient.address, 100n * nominal)).to.be.rejectedWith('balances.LiquidityRestrictions'); - expect(await helper.staking.getPendingUnstake({Substrate: staker.address})).to.be.equal(900n * nominal); - expect(await helper.staking.getTotalStaked({Substrate: staker.address})).to.be.equal(0n); - expect(await helper.staking.getTotalStaked()).to.be.equal(totalStakedBefore); + await helper.staking.stake(staker, STAKE_AMOUNT); + testCase.method === 'unstakeAll' + ? await helper.staking.unstakeAll(staker) + : await helper.staking.unstakePartial(staker, STAKE_AMOUNT); + + // Right after unstake tokens are still locked + expect(await helper.staking.getStakesNumber({Substrate: staker.address})).to.eq(0); + expect(await helper.balance.getLocked(staker.address)).to.deep.eq([{id: 'appstake', amount: STAKE_AMOUNT, reasons: 'All'}]); + expect(await helper.balance.getSubstrateFull(staker.address)).to.deep.contain({reserved: 0n, miscFrozen: STAKE_AMOUNT, feeFrozen: STAKE_AMOUNT}); + // Staker can not transfer + await expect(helper.balance.transferToSubstrate(staker, recepient.address, 100n * nominal)).to.be.rejectedWith('balances.LiquidityRestrictions'); + expect(await helper.staking.getPendingUnstake({Substrate: staker.address})).to.be.equal(STAKE_AMOUNT); + expect(await helper.staking.getTotalStaked({Substrate: staker.address})).to.be.equal(0n); + expect(await helper.staking.getTotalStaked()).to.be.equal(totalStakedBefore); + }); }); - itSub('should unlock balance after unlocking period ends and remove it from "pendingUnstake"', async ({helper}) => { - const staker = accounts.pop()!; - await helper.staking.stake(staker, 100n * nominal); - await helper.staking.unstake(staker); - const [pendingUnstake] = await helper.staking.getPendingUnstakePerBlock({Substrate: staker.address}); + [ + {method: 'unstakeAll' as const}, + {method: 'unstakePartial' as const}, + ].map(testCase => { + itSub(`[${testCase.method}] should unlock balance after unlocking period ends and remove it from "pendingUnstake"`, async ({helper}) => { + const [staker] = getAccount(1); + await helper.staking.stake(staker, 100n * nominal); + testCase.method === 'unstakeAll' + ? await helper.staking.unstakeAll(staker) + : await helper.staking.unstakePartial(staker, 100n * nominal); + const [pendingUnstake] = await helper.staking.getPendingUnstakePerBlock({Substrate: staker.address}); - // Wait for unstaking period. Balance now free ~1000; reserved, frozen, miscFrozeb: 0n - await helper.wait.forParachainBlockNumber(pendingUnstake.block); - expect(await helper.balance.getSubstrateFull(staker.address)).to.deep.contain({reserved: 0n, miscFrozen: 0n, feeFrozen: 0n}); - expect(await helper.balance.getSubstrate(staker.address) / nominal).to.be.equal(999n); + // Wait for unstaking period. Balance now free ~1000; reserved, frozen, miscFrozeb: 0n + await helper.wait.forParachainBlockNumber(pendingUnstake.block); + expect(await helper.balance.getSubstrateFull(staker.address)).to.deep.contain({reserved: 0n, miscFrozen: 0n, feeFrozen: 0n}); + expect(await helper.balance.getSubstrate(staker.address) / nominal).to.be.equal(999n); - // staker can transfer: - await helper.balance.transferToSubstrate(staker, donor.address, 998n * nominal); - expect(await helper.balance.getSubstrate(staker.address) / nominal).to.be.equal(1n); + // staker can transfer: + await helper.balance.transferToSubstrate(staker, donor.address, 998n * nominal); + expect(await helper.balance.getSubstrate(staker.address) / nominal).to.be.equal(1n); + }); }); - itSub('should successfully unstake multiple stakes', async ({helper}) => { - const staker = accounts.pop()!; - await helper.staking.stake(staker, 100n * nominal); - await helper.staking.stake(staker, 200n * nominal); - await helper.staking.stake(staker, 300n * nominal); + [ + {method: 'unstakeAll' as const}, + {method: 'unstakePartial' as const}, + ].map(testCase => { + itSub(`[${testCase.method}] should successfully unstake multiple stakes`, async ({helper}) => { + const [staker] = getAccount(1); + await helper.staking.stake(staker, 100n * nominal); + await helper.staking.stake(staker, 200n * nominal); + await helper.staking.stake(staker, 300n * nominal); + + // staked: [100, 200, 300]; unstaked: 0 + let totalPendingUnstake = await helper.staking.getPendingUnstake({Substrate: staker.address}); + let pendingUnstake = await helper.staking.getPendingUnstakePerBlock({Substrate: staker.address}); + let stakes = await helper.staking.getTotalStakedPerBlock({Substrate: staker.address}); + expect(totalPendingUnstake).to.be.deep.equal(0n); + expect(pendingUnstake).to.be.deep.equal([]); + expect(stakes[0].amount).to.equal(100n * nominal); + expect(stakes[1].amount).to.equal(200n * nominal); + expect(stakes[2].amount).to.equal(300n * nominal); - // staked: [100, 200, 300]; unstaked: 0 - let totalPendingUnstake = await helper.staking.getPendingUnstake({Substrate: staker.address}); - let pendingUnstake = await helper.staking.getPendingUnstakePerBlock({Substrate: staker.address}); - let stakes = await helper.staking.getTotalStakedPerBlock({Substrate: staker.address}); - expect(totalPendingUnstake).to.be.deep.equal(0n); - expect(pendingUnstake).to.be.deep.equal([]); - expect(stakes[0].amount).to.equal(100n * nominal); - expect(stakes[1].amount).to.equal(200n * nominal); - expect(stakes[2].amount).to.equal(300n * nominal); + // Can unstake multiple stakes + testCase.method === 'unstakeAll' + ? await helper.staking.unstakeAll(staker) + : await helper.staking.unstakePartial(staker, 600n * nominal); - // Can unstake multiple stakes - await helper.staking.unstake(staker); - pendingUnstake = await helper.staking.getPendingUnstakePerBlock({Substrate: staker.address}); - totalPendingUnstake = await helper.staking.getPendingUnstake({Substrate: staker.address}); - stakes = await helper.staking.getTotalStakedPerBlock({Substrate: staker.address}); - expect(totalPendingUnstake).to.be.equal(600n * nominal); - expect(stakes).to.be.deep.equal([]); - expect(pendingUnstake[0].amount).to.equal(600n * nominal); + pendingUnstake = await helper.staking.getPendingUnstakePerBlock({Substrate: staker.address}); + totalPendingUnstake = await helper.staking.getPendingUnstake({Substrate: staker.address}); + stakes = await helper.staking.getTotalStakedPerBlock({Substrate: staker.address}); + expect(totalPendingUnstake).to.be.equal(600n * nominal); + expect(stakes).to.be.deep.equal([]); + expect(pendingUnstake[0].amount).to.equal(600n * nominal); - expect (await helper.balance.getSubstrateFull(staker.address)).to.deep.contain({reserved: 0n, feeFrozen: 600n * nominal, miscFrozen: 600n * nominal}); - expect (await helper.balance.getSubstrate(staker.address) / nominal).to.be.equal(999n); - await helper.wait.forParachainBlockNumber(pendingUnstake[0].block); - expect (await helper.balance.getSubstrateFull(staker.address)).to.deep.contain({reserved: 0n, feeFrozen: 0n, miscFrozen: 0n}); - expect (await helper.balance.getSubstrate(staker.address) / nominal).to.be.equal(999n); + expect (await helper.balance.getSubstrateFull(staker.address)).to.deep.contain({reserved: 0n, feeFrozen: 600n * nominal, miscFrozen: 600n * nominal}); + expect (await helper.balance.getSubstrate(staker.address) / nominal).to.be.equal(999n); + await helper.wait.forParachainBlockNumber(pendingUnstake[0].block); + expect (await helper.balance.getSubstrateFull(staker.address)).to.deep.contain({reserved: 0n, feeFrozen: 0n, miscFrozen: 0n}); + expect (await helper.balance.getSubstrate(staker.address) / nominal).to.be.equal(999n); + }); }); - itSub('should not have any effects if no active stakes', async ({helper}) => { - const staker = accounts.pop()!; + [ + {method: 'unstakeAll' as const}, + {method: 'unstakePartial' as const}, + ].map(testCase => { + itSub(`[${testCase.method}] should not have any effects if no active stakes`, async ({helper}) => { + const [staker] = getAccount(1); + + // unstake has no effect if no stakes at all + testCase.method === 'unstakeAll' + ? await helper.staking.unstakeAll(staker) + : await expect(helper.staking.unstakePartial(staker, 100n * nominal)).to.be.rejectedWith('appPromotion.InsufficientStakedBalance'); + + expect(await helper.staking.getPendingUnstake({Substrate: staker.address})).to.be.equal(0n); + expect(await helper.balance.getSubstrate(staker.address) / nominal).to.be.equal(999n); // TODO bigint closeTo helper - // unstake has no effect if no stakes at all - await helper.staking.unstake(staker); - expect(await helper.staking.getPendingUnstake({Substrate: staker.address})).to.be.equal(0n); - expect(await helper.balance.getSubstrate(staker.address) / nominal).to.be.equal(999n); // TODO bigint closeTo helper + // TODO stake() unstake() waitUnstaked() unstake(); - // TODO stake() unstake() waitUnstaked() unstake(); + // can't unstake if there are only pendingUnstakes + await helper.staking.stake(staker, 100n * nominal); - // can't unstake if there are only pendingUnstakes - await helper.staking.stake(staker, 100n * nominal); - await helper.staking.unstake(staker); - await helper.staking.unstake(staker); + if (testCase.method === 'unstakeAll') { + await helper.staking.unstakeAll(staker); + await helper.staking.unstakeAll(staker); + } else { + await helper.staking.unstakePartial(staker, 100n * nominal); + await expect(helper.staking.unstakePartial(staker, 100n * nominal)).to.be.rejectedWith('appPromotion.InsufficientStakedBalance'); + } - expect(await helper.staking.getPendingUnstake({Substrate: staker.address})).to.be.equal(100n * nominal); - expect(await helper.staking.getTotalStaked({Substrate: staker.address})).to.be.equal(0n); + expect(await helper.staking.getStakesNumber({Substrate: staker.address})).to.eq(0); + expect(await helper.staking.getPendingUnstake({Substrate: staker.address})).to.be.equal(100n * nominal); + expect(await helper.staking.getTotalStaked({Substrate: staker.address})).to.be.equal(0n); + }); }); - itSub('should keep different unlocking block for each unlocking stake', async ({helper}) => { - const staker = accounts.pop()!; - await helper.staking.stake(staker, 100n * nominal); - await helper.staking.unstake(staker); - await helper.staking.stake(staker, 120n * nominal); - await helper.staking.unstake(staker); + [ + {method: 'unstakeAll' as const}, + {method: 'unstakePartial' as const}, + ].map(testCase => { + itSub(`[${testCase.method}] should create different pending-unlock for each unlocking stake`, async ({helper}) => { + const [staker] = getAccount(1); + await helper.staking.stake(staker, 100n * nominal); + testCase.method === 'unstakeAll' + ? await helper.staking.unstakeAll(staker) + : await helper.staking.unstakePartial(staker, 100n * nominal); + await helper.staking.stake(staker, 120n * nominal); + testCase.method === 'unstakeAll' + ? await helper.staking.unstakeAll(staker) + : await helper.staking.unstakePartial(staker, 120n * nominal); - const unstakingPerBlock = await helper.staking.getPendingUnstakePerBlock({Substrate: staker.address}); - expect(unstakingPerBlock).has.length(2); - expect(unstakingPerBlock[0].amount).to.equal(100n * nominal); - expect(unstakingPerBlock[1].amount).to.equal(120n * nominal); + const unstakingPerBlock = await helper.staking.getPendingUnstakePerBlock({Substrate: staker.address}); + expect(unstakingPerBlock).has.length(2); + expect(unstakingPerBlock[0].amount).to.equal(100n * nominal); + expect(unstakingPerBlock[1].amount).to.equal(120n * nominal); + expect(await helper.staking.getStakesNumber({Substrate: staker.address})).to.equal(0); + }); }); - itSub('should be possible for 3 accounts in one block', async ({helper}) => { - const stakers = [accounts.pop()!, accounts.pop()!, accounts.pop()!]; + [ + {method: 'unstakeAll' as const}, + {method: 'unstakePartial' as const}, + ].map(testCase => { + itSub(`[${testCase.method}] should be possible for 3 accounts in one block`, async ({helper}) => { + const stakers = getAccount(3); - await Promise.all(stakers.map(staker => helper.staking.stake(staker, 100n * nominal))); - await Promise.all(stakers.map(staker => helper.staking.unstake(staker))); + await Promise.all(stakers.map(staker => helper.staking.stake(staker, 100n * nominal))); + await Promise.all(stakers.map(staker => { + return testCase.method === 'unstakeAll' + ? helper.staking.unstakeAll(staker) + : helper.staking.unstakePartial(staker, 100n * nominal); + })); - await Promise.all(stakers.map(async (staker) => { - expect(await helper.staking.getPendingUnstake({Substrate: staker.address})).to.be.equal(100n * nominal); - expect(await helper.staking.getTotalStaked({Substrate: staker.address})).to.be.equal(0n); - })); + await Promise.all(stakers.map(async (staker) => { + expect(await helper.staking.getPendingUnstake({Substrate: staker.address})).to.be.equal(100n * nominal); + expect(await helper.staking.getTotalStaked({Substrate: staker.address})).to.be.equal(0n); + })); + }); }); itSub('should not be possible for more than 3 accounts in one block', async ({helper}) => { if (!await helper.arrange.isDevNode()) { - const stakers = await helper.arrange.createAccounts([200n,200n,200n,200n,200n,200n,200n,200n,200n,200n], donor); + const stakers = getAccount(10); await Promise.all(stakers.map(staker => helper.staking.stake(staker, 100n * nominal))); - const unstakingResults = await Promise.allSettled(stakers.map(staker => helper.staking.unstake(staker))); + const unstakingResults = await Promise.allSettled(stakers.map((staker, i) => { + return i % 2 === 0 + ? helper.staking.unstakeAll(staker) + : helper.staking.unstakePartial(staker, 100n * nominal); + })); const successfulUnstakes = unstakingResults.filter(result => result.status === 'fulfilled'); expect(successfulUnstakes).to.have.length(3); } }); + + itSub('Cannot partially unstake more than staked', async ({helper}) => { + const [staker] = getAccount(1); + // Staker stakes 300: + await helper.staking.stake(staker, 100n * nominal); + await helper.staking.stake(staker, 200n * nominal); + + // cannot usntake 300.00000...1 + await expect(helper.staking.unstakePartial(staker, 300n * nominal + 1n)).to.be.rejectedWith('appPromotion.InsufficientStakedBalance'); + expect(await helper.staking.getStakesNumber({Substrate: staker.address})).eq(2); + + await helper.staking.unstakePartial(staker, 150n * nominal); + expect(await helper.staking.getStakesNumber({Substrate: staker.address})).eq(1); + await expect(helper.staking.unstakePartial(staker, 150n * nominal + 1n)).to.be.rejectedWith('appPromotion.InsufficientStakedBalance'); + expect(await helper.staking.getStakesNumber({Substrate: staker.address})).eq(1); + + // nothing broken, can unstake full amount: + await helper.staking.unstakePartial(staker, 150n * nominal); + expect(await helper.staking.getStakesNumber({Substrate: staker.address})).eq(0); + }); + + itSub('Can partially unstake arbitrary amount', async ({helper}) => { + const [staker] = getAccount(1); + await helper.staking.stake(staker, 100n * nominal); + await helper.staking.stake(staker, 200n * nominal); + + // 0. Staker cannot unstake negative amount + await expect(helper.staking.unstakePartial(staker, -1n)).to.be.rejected; + + // 1. Staker can unstake 0 wei + await helper.staking.unstakePartial(staker, 0n); + expect(await helper.staking.getStakesNumber({Substrate: staker.address})).to.eq(2); + expect(await helper.staking.getTotalStaked({Substrate: staker.address})).to.eq(300n * nominal); + expect(await helper.staking.getPendingUnstake({Substrate: staker.address})).to.eq(0n); + + // 2. Staker can unstake 1 wei + await helper.staking.unstakePartial(staker, 1n); + expect(await helper.staking.getStakesNumber({Substrate: staker.address})).to.eq(2); + expect(await helper.staking.getTotalStaked({Substrate: staker.address})).to.eq(300n * nominal - 1n); + expect(await helper.staking.getPendingUnstake({Substrate: staker.address})).to.eq(1n); + // 2.1 The oldest stake decreased: + let [stake1, stake2] = await helper.staking.getTotalStakedPerBlock({Substrate: staker.address}); + expect(stake1.amount).to.eq(100n * nominal - 1n); + expect(stake2.amount).to.eq(200n * nominal); + + // 3. Staker can unstake all but 1 wei + await helper.staking.unstakePartial(staker, 100n * nominal - 2n); + expect(await helper.staking.getStakesNumber({Substrate: staker.address})).to.eq(2); + expect(await helper.staking.getTotalStaked({Substrate: staker.address})).to.eq(200n * nominal + 1n); + expect(await helper.staking.getPendingUnstake({Substrate: staker.address})).to.eq(100n * nominal - 1n); + [stake1, stake2] = await helper.staking.getTotalStakedPerBlock({Substrate: staker.address}); + expect(stake1.amount).to.eq(1n); + expect(stake2.amount).to.eq(200n * nominal); + }); + + itSub('can mix different type of unstakes', async ({helper}) => { + const [staker] = getAccount(1); + await helper.staking.stake(staker, 100n * nominal); + await helper.staking.stake(staker, 200n * nominal); + + await helper.staking.unstakePartial(staker, 50n * nominal); + await helper.staking.unstakeAll(staker); + expect(await helper.staking.getStakesNumber({Substrate: staker.address})).to.eq(0); + expect(await helper.staking.getTotalStaked({Substrate: staker.address})).to.eq(0n); + expect(await helper.staking.getPendingUnstake({Substrate: staker.address})).to.eq(300n * nominal); + + const [_unstake1, unstake2] = await helper.staking.getPendingUnstakePerBlock({Substrate: staker.address}); + await helper.wait.forParachainBlockNumber(unstake2.block); + + expect(await helper.balance.getLocked(staker.address)).to.deep.eq([]); + expect(await helper.balance.getSubstrateFull(staker.address)).to.deep.contain({reserved: 0n, miscFrozen: 0n, feeFrozen: 0n}); + expect(await helper.balance.getSubstrate(staker.address) / nominal).to.eq(999n); + expect(await helper.staking.getTotalStaked({Substrate: staker.address})).to.eq(0n); + expect(await helper.staking.getPendingUnstake({Substrate: staker.address})).to.eq(0n); + expect(await helper.staking.getPendingUnstakePerBlock({Substrate: staker.address})).to.deep.eq([]); + }); }); describe('collection sponsoring', () => { itSub('should actually sponsor transactions', async ({helper}) => { const api = helper.getApi(); - const [collectionOwner, tokenSender, receiver] = [accounts.pop()!, accounts.pop()!, accounts.pop()!]; + const [collectionOwner, tokenSender, receiver] = getAccount(3); const collection = await helper.nft.mintCollection(collectionOwner, {name: 'Name', description: 'Description', tokenPrefix: 'Prefix', limits: {sponsorTransferTimeout: 0}}); const token = await collection.mintToken(collectionOwner, {Substrate: tokenSender.address}); await helper.signTransaction(palletAdmin, api.tx.appPromotion.sponsorCollection(collection.collectionId)); @@ -289,7 +471,7 @@ itSub('can not be set by non admin', async ({helper}) => { const api = helper.getApi(); - const [collectionOwner, nonAdmin] = [accounts.pop()!, accounts.pop()!]; + const [collectionOwner, nonAdmin] = getAccount(2); const collection = await helper.nft.mintCollection(collectionOwner, {name: 'New', description: 'New Collection', tokenPrefix: 'Promotion'}); @@ -299,7 +481,7 @@ itSub('should set pallet address as confirmed admin', async ({helper}) => { const api = helper.getApi(); - const [collectionOwner, oldSponsor] = [accounts.pop()!, accounts.pop()!]; + const [collectionOwner, oldSponsor] = getAccount(2); // Can set sponsoring for collection without sponsor const collectionWithoutSponsor = await helper.nft.mintCollection(collectionOwner, {name: 'No-sponsor', description: 'New Collection', tokenPrefix: 'Promotion'}); @@ -321,7 +503,7 @@ itSub('can be overwritten by collection owner', async ({helper}) => { const api = helper.getApi(); - const [collectionOwner, newSponsor] = [accounts.pop()!, accounts.pop()!]; + const [collectionOwner, newSponsor] = getAccount(2); const collection = await helper.nft.mintCollection(collectionOwner, {name: 'New', description: 'New Collection', tokenPrefix: 'Promotion'}); const collectionId = collection.collectionId; @@ -340,7 +522,7 @@ itSub('should not overwrite collection limits set by the owner earlier', async ({helper}) => { const api = helper.getApi(); const limits = {ownerCanDestroy: true, ownerCanTransfer: true, sponsorTransferTimeout: 0}; - const collectionWithLimits = await helper.nft.mintCollection(accounts.pop()!, {name: 'New', description: 'New Collection', tokenPrefix: 'Promotion', limits}); + const collectionWithLimits = await helper.nft.mintCollection(getAccount(1)[0], {name: 'New', description: 'New Collection', tokenPrefix: 'Promotion', limits}); await expect(helper.signTransaction(palletAdmin, api.tx.appPromotion.sponsorCollection(collectionWithLimits.collectionId))).to.be.fulfilled; expect((await collectionWithLimits.getData())?.raw.limits).to.be.deep.contain(limits); @@ -348,7 +530,7 @@ itSub('should reject transaction if collection doesn\'t exist', async ({helper}) => { const api = helper.getApi(); - const collectionOwner = accounts.pop()!; + const [collectionOwner] = getAccount(1); // collection has never existed await expect(helper.signTransaction(palletAdmin, api.tx.appPromotion.sponsorCollection(999999999))).to.be.rejected; @@ -363,7 +545,7 @@ describe('stopSponsoringCollection', () => { itSub('can not be called by non-admin', async ({helper}) => { const api = helper.getApi(); - const [collectionOwner, nonAdmin] = [accounts.pop()!, accounts.pop()!]; + const [collectionOwner, nonAdmin] = getAccount(2); const collection = await helper.nft.mintCollection(collectionOwner, {name: 'New', description: 'New Collection', tokenPrefix: 'Promotion'}); await expect(helper.signTransaction(palletAdmin, api.tx.appPromotion.sponsorCollection(collection.collectionId))).to.be.fulfilled; @@ -374,7 +556,7 @@ itSub('should set sponsoring as disabled', async ({helper}) => { const api = helper.getApi(); - const [collectionOwner, recepient] = [accounts.pop()!, accounts.pop()!]; + const [collectionOwner, recepient] = getAccount(2); const collection = await helper.nft.mintCollection(collectionOwner, {name: 'New', description: 'New Collection', tokenPrefix: 'Promotion', limits: {sponsorTransferTimeout: 0}}); const token = await collection.mintToken(collectionOwner, {Substrate: collectionOwner.address}); @@ -392,7 +574,7 @@ itSub('should not affect collection which is not sponsored by pallete', async ({helper}) => { const api = helper.getApi(); - const collectionOwner = accounts.pop()!; + const [collectionOwner] = getAccount(1); const collection = await helper.nft.mintCollection(collectionOwner, {name: 'New', description: 'New Collection', tokenPrefix: 'Promotion', pendingSponsor: collectionOwner.address}); await collection.confirmSponsorship(collectionOwner); @@ -402,7 +584,7 @@ }); itSub('should reject transaction if collection does not exist', async ({helper}) => { - const collectionOwner = accounts.pop()!; + const [collectionOwner] = getAccount(1); const collection = await helper.nft.mintCollection(collectionOwner, {name: 'New', description: 'New Collection', tokenPrefix: 'Promotion'}); await collection.burn(collectionOwner); @@ -476,7 +658,7 @@ }); itEth('can not be set by non admin', async ({helper}) => { - const nonAdmin = accounts.pop()!; + const [nonAdmin] = getAccount(1); const contractOwner = (await helper.eth.createAccountWithBalance(donor, 1000n)).toLowerCase(); const flipper = await helper.eth.deployFlipper(contractOwner); // await deployFlipper(web3, contractOwner); const contractHelper = await helper.ethNativeContract.contractHelpers(contractOwner); @@ -558,7 +740,7 @@ }); itEth('can not be called by non-admin', async ({helper}) => { - const nonAdmin = accounts.pop()!; + const [nonAdmin] = getAccount(1); const contractOwner = (await helper.eth.createAccountWithBalance(donor, 1000n)).toLowerCase(); const flipper = await helper.eth.deployFlipper(contractOwner); @@ -568,7 +750,7 @@ }); itEth('should not affect a contract which is not sponsored by pallete', async ({helper}) => { - const nonAdmin = accounts.pop()!; + const [nonAdmin] = getAccount(1); const contractOwner = (await helper.eth.createAccountWithBalance(donor, 1000n)).toLowerCase(); const flipper = await helper.eth.deployFlipper(contractOwner); const contractHelper = await helper.ethNativeContract.contractHelpers(contractOwner); @@ -580,12 +762,12 @@ describe('payoutStakers', () => { itSub('can not be called by non admin', async ({helper}) => { - const nonAdmin = accounts.pop()!; + const [nonAdmin] = getAccount(1); await expect(helper.admin.payoutStakers(nonAdmin, 100)).to.be.rejectedWith('appPromotion.NoPermission'); }); itSub('should increase total staked', async ({helper}) => { - const staker = accounts.pop()!; + const [staker] = getAccount(1); const totalStakedBefore = await helper.staking.getTotalStaked(); await helper.staking.stake(staker, 100n * nominal); @@ -597,12 +779,12 @@ const totalStakedAfter = await helper.staking.getTotalStaked(); expect(totalStakedAfter).to.equal(totalStakedBefore + (100n * nominal) + totalPayout); // staker can unstake - await helper.staking.unstake(staker); + await helper.staking.unstakeAll(staker); expect(await helper.staking.getTotalStaked()).to.be.equal(totalStakedAfter - calculateIncome(100n * nominal)); }); itSub('should credit 0.05% for staking period', async ({helper}) => { - const staker = accounts.pop()!; + const [staker] = getAccount(1); await waitPromotionPeriodDoesntEnd(helper); @@ -628,7 +810,7 @@ }); itSub('shoud be paid for more than one period if payments was missed', async ({helper}) => { - const staker = accounts.pop()!; + const [staker] = getAccount(1); await helper.staking.stake(staker, 100n * nominal); // wait for two rewards are available: @@ -647,11 +829,11 @@ itSub('should not be credited for pending-unstaked tokens', async ({helper}) => { // staker unstakes before rewards been payed - const staker = accounts.pop()!; + const [staker] = getAccount(1); await helper.staking.stake(staker, 100n * nominal); const [stake] = await helper.staking.getTotalStakedPerBlock({Substrate: staker.address}); await helper.wait.forRelayBlockNumber(rewardAvailableInBlock(stake.block) + LOCKING_PERIOD); - await helper.staking.unstake(staker); + await helper.staking.unstakeAll(staker); // so he did not receive any rewards const totalBalanceBefore = await helper.balance.getSubstrate(staker.address); @@ -662,7 +844,7 @@ }); itSub('should bring compound interest', async ({helper}) => { - const staker = accounts.pop()!; + const [staker] = getAccount(1); await helper.staking.stake(staker, 100n * nominal); @@ -679,36 +861,48 @@ expect(stake.amount).to.equal(calculateIncome(100n * nominal, 2)); }); - itSub.skip('can be paid 1000 rewards in a time', async ({helper}) => { - // all other stakes should be unstaked - const oneHundredStakers = await helper.arrange.createCrowd(100, 1050n, donor); + itSub('can calculate reward for tiny stake', async ({helper}) => { + const [staker] = getAccount(1); + await helper.staking.stake(staker, 100n * nominal); + await helper.staking.stake(staker, 100n * nominal); + await helper.staking.unstakePartial(staker, 100n * nominal - 1n); - // stakers stakes 10 times each - for (let i = 0; i < 10; i++) { - await Promise.all(oneHundredStakers.map(staker => helper.staking.stake(staker, 100n * nominal))); - } - await helper.wait.newBlocks(40); - await helper.admin.payoutStakers(palletAdmin, 100); + const [stake] = await helper.staking.getTotalStakedPerBlock({Substrate: staker.address}); + await helper.wait.forRelayBlockNumber(rewardAvailableInBlock(stake.block)); + + const payouts = await helper.admin.payoutStakers(palletAdmin, 100); + const stakerPayout = payouts.find(p => p.staker === staker.address); + expect(stakerPayout!.stake).to.eq(100n * nominal + 1n); }); - itSub.skip('can handle 40.000 rewards', async ({helper}) => { - const crowdStakes = async () => { - // each account in the crowd stakes 2 times - const crowd = await helper.arrange.createCrowd(500, 300n, donor); - await Promise.all(crowd.map(account => helper.staking.stake(account, 100n * nominal))); - await Promise.all(crowd.map(account => helper.staking.stake(account, 100n * nominal))); - // - }; + itSub('can eventually pay all rewards', async ({helper}) => { + const stakers = getAccount(30); + // Create 30 stakes: + await Promise.all(stakers.map(staker => helper.staking.stake(staker, 100n * nominal))); - for (let i = 0; i < 40; i++) { - await crowdStakes(); + let unstakingTxs = []; + for (const staker of stakers) { + if (unstakingTxs.length == 3) { + await Promise.all(unstakingTxs); + unstakingTxs = []; + } + unstakingTxs.push(helper.staking.unstakePartial(staker, 100n * nominal - 1n)); } - // TODO pay rewards for some period + const [staker] = getAccount(1); + await helper.staking.stake(staker, 100n * nominal); + const [stake] = await helper.staking.getTotalStakedPerBlock({Substrate: staker.address}); + await helper.wait.forRelayBlockNumber(rewardAvailableInBlock(stake.block)); + + let payouts; + do { + payouts = await helper.admin.payoutStakers(palletAdmin, 20); + } while (payouts.length !== 0); }); }); }); + function calculateIncome(base: bigint, iter = 0, calcPeriod: bigint = UNLOCKING_PERIOD): bigint { const DAY = 7200n; const ACCURACY = 1_000_000_000n; --- a/tests/src/util/globalSetup.ts +++ b/tests/src/util/globalSetup.ts @@ -29,8 +29,8 @@ const api = helper.getApi(); await helper.signTransaction(superuser, api.tx.sudo.sudo(api.tx.appPromotion.setAdminAddress({Substrate: palletAdmin.address}))); const nominal = helper.balance.getOneTokenNominal(); - await helper.balance.transferToSubstrate(superuser, palletAdmin.address, 1000n * nominal); - await helper.balance.transferToSubstrate(superuser, palletAddress, 1000n * nominal); + await helper.balance.transferToSubstrate(superuser, palletAdmin.address, 10000n * nominal); + await helper.balance.transferToSubstrate(superuser, palletAddress, 10000n * nominal); await helper.executeExtrinsic(superuser, 'api.tx.sudo.sudo', [api.tx.configuration .setAppPromotionConfigurationOverride({ recalculationInterval: LOCKING_PERIOD, --- a/tests/src/util/index.ts +++ b/tests/src/util/index.ts @@ -94,7 +94,7 @@ }; export const MINIMUM_DONOR_FUND = 100_000n; -export const DONOR_FUNDING = 1_000_000n; +export const DONOR_FUNDING = 2_000_000n; // App-promotion periods: export const LOCKING_PERIOD = 12n; // 12 blocks of relay --- a/tests/src/util/playgrounds/types.ts +++ b/tests/src/util/playgrounds/types.ts @@ -20,7 +20,8 @@ event: IEvent; }[]; }, - moduleError?: string; + blockHash: string, + moduleError?: string | object; } export interface ISubscribeBlockEventsData { --- a/tests/src/util/playgrounds/unique.dev.ts +++ b/tests/src/util/playgrounds/unique.dev.ts @@ -214,7 +214,7 @@ accounts.push(recipient); if (balance !== 0n) { const tx = this.helper.constructApiCall('api.tx.balances.transfer', [{Id: recipient.address}, balance * tokenNominal]); - transactions.push(this.helper.signTransaction(donor, tx, {nonce}, 'account generation')); + transactions.push(this.helper.signTransaction(donor, tx, {nonce, era: 0}, 'account generation')); nonce++; } } --- a/tests/src/util/playgrounds/unique.ts +++ b/tests/src/util/playgrounds/unique.ts @@ -6,7 +6,8 @@ /* eslint-disable no-prototype-builtins */ import {ApiPromise, WsProvider, Keyring} from '@polkadot/api'; -import {ApiInterfaceEvents, SignerOptions} from '@polkadot/api/types'; +import {SignerOptions} from '@polkadot/api/types/submittable'; +import {ApiInterfaceEvents} from '@polkadot/api/types'; import {encodeAddress, decodeAddress, keccakAsHex, evmToAddress, addressToEvm, base58Encode, blake2AsU8a} from '@polkadot/util-crypto'; import {IKeyringPair} from '@polkadot/types/types'; import {hexToU8a} from '@polkadot/util/hex'; @@ -561,7 +562,7 @@ if (status === this.transactionStatus.SUCCESS) { this.logger.log(`${label} successful`); unsub(); - resolve({result, status}); + resolve({result, status, blockHash: result.status.asInBlock.toHuman()}); } else if (status === this.transactionStatus.FAIL) { let moduleError = null; @@ -672,8 +673,15 @@ params, } as IUniqueHelperLog; + let errorMessage = ''; + if(result.status !== this.transactionStatus.SUCCESS) { - if (result.moduleError) log.moduleError = result.moduleError; + if (result.moduleError) { + errorMessage = typeof result.moduleError === 'string' + ? result.moduleError + : `${Object.keys(result.moduleError)[0]}: ${Object.values(result.moduleError)[0]}`; + log.moduleError = errorMessage; + } else if (result.result.dispatchError) log.dispatchError = result.result.dispatchError; } if(events.length > 0) log.events = events; @@ -681,7 +689,7 @@ this.chainLog.push(log); if(expectSuccess && result.status !== this.transactionStatus.SUCCESS) { - if (result.moduleError) throw Error(`${result.moduleError}`); + if (result.moduleError) throw Error(`${errorMessage}`); else if (result.result.dispatchError) throw Error(JSON.stringify(result.result.dispatchError)); } return result; @@ -2657,20 +2665,45 @@ } /** - * Unstake tokens for App Promotion + * Unstake all staked tokens * @param signer keyring of signer * @param amountToUnstake amount of tokens to unstake * @param label extra label for log - * @returns block number where balances will be unlocked + * @returns block hash where unstake happened */ - async unstake(signer: TSigner, label?: string): Promise { + async unstakeAll(signer: TSigner, label?: string): Promise { if(typeof label === 'undefined') label = `${signer.address}`; - const _unstakeResult = await this.helper.executeExtrinsic( - signer, 'api.tx.appPromotion.unstake', + const unstakeResult = await this.helper.executeExtrinsic( + signer, 'api.tx.appPromotion.unstakeAll', [], true, ); - // TODO extract block number fron events - return 1; + return unstakeResult.blockHash; + } + + /** + * Unstake the part of a staked tokens + * @param signer keyring of signer + * @param amount amount of tokens to unstake + * @param label extra label for log + * @returns block hash where unstake happened + */ + async unstakePartial(signer: TSigner, amount: bigint, label?: string): Promise { + if(typeof label === 'undefined') label = `${signer.address}`; + const unstakeResult = await this.helper.executeExtrinsic( + signer, 'api.tx.appPromotion.unstakePartial', + [amount], true, + ); + return unstakeResult.blockHash; + } + + /** + * Get total number of active stakes + * @param address substrate address + * @returns {number} + */ + async getStakesNumber(address: ICrossAccountId): Promise { + if (address.Ethereum) throw Error('only substrate address'); + return (await this.helper.callRpc('api.query.appPromotion.stakesPerAccount', [address.Substrate])).toNumber(); } /**