diff --git a/Cargo.lock b/Cargo.lock index fe18d417f9..f69aee8ac4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3066,7 +3066,7 @@ dependencies = [ [[package]] name = "fc-consensus" version = "2.0.0-dev" -source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#718c42a273280d73b71b83ff9ed1fe498dcee8f4" +source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#a5481542518ec420352d263adcb2f78835ac9bc2" dependencies = [ "async-trait", "fp-consensus", @@ -3082,7 +3082,7 @@ dependencies = [ [[package]] name = "fc-db" version = "2.0.0-dev" -source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#718c42a273280d73b71b83ff9ed1fe498dcee8f4" +source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#a5481542518ec420352d263adcb2f78835ac9bc2" dependencies = [ "async-trait", "fp-storage", @@ -3102,7 +3102,7 @@ dependencies = [ [[package]] name = "fc-mapping-sync" version = "2.0.0-dev" -source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#718c42a273280d73b71b83ff9ed1fe498dcee8f4" +source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#a5481542518ec420352d263adcb2f78835ac9bc2" dependencies = [ "fc-db", "fc-storage", @@ -3123,7 +3123,7 @@ dependencies = [ [[package]] name = "fc-rpc" version = "2.0.0-dev" -source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#718c42a273280d73b71b83ff9ed1fe498dcee8f4" +source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#a5481542518ec420352d263adcb2f78835ac9bc2" dependencies = [ "ethereum", "ethereum-types", @@ -3173,7 +3173,7 @@ dependencies = [ [[package]] name = "fc-rpc-core" version = "1.1.0-dev" -source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#718c42a273280d73b71b83ff9ed1fe498dcee8f4" +source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#a5481542518ec420352d263adcb2f78835ac9bc2" dependencies = [ "ethereum", "ethereum-types", @@ -3186,7 +3186,7 @@ dependencies = [ [[package]] name = "fc-storage" version = "1.0.0-dev" -source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#718c42a273280d73b71b83ff9ed1fe498dcee8f4" +source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#a5481542518ec420352d263adcb2f78835ac9bc2" dependencies = [ "ethereum", "ethereum-types", @@ -3338,7 +3338,7 @@ dependencies = [ [[package]] name = "fp-account" version = "1.0.0-dev" -source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#718c42a273280d73b71b83ff9ed1fe498dcee8f4" +source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#a5481542518ec420352d263adcb2f78835ac9bc2" dependencies = [ "hex", "impl-serde", @@ -3357,7 +3357,7 @@ dependencies = [ [[package]] name = "fp-consensus" version = "2.0.0-dev" -source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#718c42a273280d73b71b83ff9ed1fe498dcee8f4" +source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#a5481542518ec420352d263adcb2f78835ac9bc2" dependencies = [ "ethereum", "parity-scale-codec", @@ -3369,7 +3369,7 @@ dependencies = [ [[package]] name = "fp-ethereum" version = "1.0.0-dev" -source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#718c42a273280d73b71b83ff9ed1fe498dcee8f4" +source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#a5481542518ec420352d263adcb2f78835ac9bc2" dependencies = [ "ethereum", "ethereum-types", @@ -3383,7 +3383,7 @@ dependencies = [ [[package]] name = "fp-evm" version = "3.0.0-dev" -source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#718c42a273280d73b71b83ff9ed1fe498dcee8f4" +source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#a5481542518ec420352d263adcb2f78835ac9bc2" dependencies = [ "evm", "frame-support", @@ -3398,7 +3398,7 @@ dependencies = [ [[package]] name = "fp-rpc" version = "3.0.0-dev" -source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#718c42a273280d73b71b83ff9ed1fe498dcee8f4" +source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#a5481542518ec420352d263adcb2f78835ac9bc2" dependencies = [ "ethereum", "ethereum-types", @@ -3415,7 +3415,7 @@ dependencies = [ [[package]] name = "fp-self-contained" version = "1.0.0-dev" -source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#718c42a273280d73b71b83ff9ed1fe498dcee8f4" +source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#a5481542518ec420352d263adcb2f78835ac9bc2" dependencies = [ "frame-support", "parity-scale-codec", @@ -3427,7 +3427,7 @@ dependencies = [ [[package]] name = "fp-storage" version = "2.0.0" -source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#718c42a273280d73b71b83ff9ed1fe498dcee8f4" +source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#a5481542518ec420352d263adcb2f78835ac9bc2" dependencies = [ "parity-scale-codec", "serde", @@ -6794,7 +6794,7 @@ dependencies = [ [[package]] name = "pallet-base-fee" version = "1.0.0" -source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#718c42a273280d73b71b83ff9ed1fe498dcee8f4" +source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#a5481542518ec420352d263adcb2f78835ac9bc2" dependencies = [ "fp-evm", "frame-support", @@ -7112,10 +7112,12 @@ dependencies = [ name = "pallet-dapp-staking-v3" version = "0.0.1-alpha" dependencies = [ + "assert_matches", "astar-primitives", "frame-benchmarking", "frame-support", "frame-system", + "log", "num-traits", "pallet-balances", "parity-scale-codec", @@ -7226,7 +7228,7 @@ dependencies = [ [[package]] name = "pallet-ethereum" version = "4.0.0-dev" -source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#718c42a273280d73b71b83ff9ed1fe498dcee8f4" +source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#a5481542518ec420352d263adcb2f78835ac9bc2" dependencies = [ "ethereum", "ethereum-types", @@ -7273,7 +7275,7 @@ dependencies = [ [[package]] name = "pallet-evm" version = "6.0.0-dev" -source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#718c42a273280d73b71b83ff9ed1fe498dcee8f4" +source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#a5481542518ec420352d263adcb2f78835ac9bc2" dependencies = [ "environmental", "evm", @@ -7298,7 +7300,7 @@ dependencies = [ [[package]] name = "pallet-evm-chain-id" version = "1.0.0-dev" -source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#718c42a273280d73b71b83ff9ed1fe498dcee8f4" +source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#a5481542518ec420352d263adcb2f78835ac9bc2" dependencies = [ "frame-support", "frame-system", @@ -7363,7 +7365,7 @@ dependencies = [ [[package]] name = "pallet-evm-precompile-blake2" version = "2.0.0-dev" -source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#718c42a273280d73b71b83ff9ed1fe498dcee8f4" +source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#a5481542518ec420352d263adcb2f78835ac9bc2" dependencies = [ "fp-evm", ] @@ -7371,7 +7373,7 @@ dependencies = [ [[package]] name = "pallet-evm-precompile-bn128" version = "2.0.0-dev" -source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#718c42a273280d73b71b83ff9ed1fe498dcee8f4" +source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#a5481542518ec420352d263adcb2f78835ac9bc2" dependencies = [ "fp-evm", "sp-core", @@ -7406,7 +7408,7 @@ dependencies = [ [[package]] name = "pallet-evm-precompile-dispatch" version = "2.0.0-dev" -source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#718c42a273280d73b71b83ff9ed1fe498dcee8f4" +source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#a5481542518ec420352d263adcb2f78835ac9bc2" dependencies = [ "fp-evm", "frame-support", @@ -7416,7 +7418,7 @@ dependencies = [ [[package]] name = "pallet-evm-precompile-ed25519" version = "2.0.0-dev" -source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#718c42a273280d73b71b83ff9ed1fe498dcee8f4" +source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#a5481542518ec420352d263adcb2f78835ac9bc2" dependencies = [ "ed25519-dalek", "fp-evm", @@ -7425,7 +7427,7 @@ dependencies = [ [[package]] name = "pallet-evm-precompile-modexp" version = "2.0.0-dev" -source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#718c42a273280d73b71b83ff9ed1fe498dcee8f4" +source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#a5481542518ec420352d263adcb2f78835ac9bc2" dependencies = [ "fp-evm", "num", @@ -7434,7 +7436,7 @@ dependencies = [ [[package]] name = "pallet-evm-precompile-sha3fips" version = "2.0.0-dev" -source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#718c42a273280d73b71b83ff9ed1fe498dcee8f4" +source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#a5481542518ec420352d263adcb2f78835ac9bc2" dependencies = [ "fp-evm", "tiny-keccak", @@ -7443,7 +7445,7 @@ dependencies = [ [[package]] name = "pallet-evm-precompile-simple" version = "2.0.0-dev" -source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#718c42a273280d73b71b83ff9ed1fe498dcee8f4" +source = "git+https://github.com/AstarNetwork/frontier?branch=polkadot-v0.9.43#a5481542518ec420352d263adcb2f78835ac9bc2" dependencies = [ "fp-evm", "ripemd", diff --git a/pallets/dapp-staking-v3/Cargo.toml b/pallets/dapp-staking-v3/Cargo.toml index 0de398ce18..c79755828d 100644 --- a/pallets/dapp-staking-v3/Cargo.toml +++ b/pallets/dapp-staking-v3/Cargo.toml @@ -10,6 +10,7 @@ repository.workspace = true [dependencies] frame-support = { workspace = true } frame-system = { workspace = true } +log = { workspace = true } num-traits = { workspace = true } parity-scale-codec = { workspace = true } @@ -23,6 +24,7 @@ sp-std = { workspace = true } astar-primitives = { workspace = true } +assert_matches = { workspace = true, optional = true } frame-benchmarking = { workspace = true, optional = true } [dev-dependencies] @@ -32,6 +34,7 @@ pallet-balances = { workspace = true } default = ["std"] std = [ "serde", + "log/std", "parity-scale-codec/std", "scale-info/std", "num-traits/std", @@ -52,5 +55,6 @@ runtime-benchmarks = [ "frame-system/runtime-benchmarks", "sp-runtime/runtime-benchmarks", "astar-primitives/runtime-benchmarks", + "assert_matches", ] try-runtime = ["frame-support/try-runtime"] diff --git a/pallets/dapp-staking-v3/coverage.sh b/pallets/dapp-staking-v3/coverage.sh new file mode 100755 index 0000000000..46710a672c --- /dev/null +++ b/pallets/dapp-staking-v3/coverage.sh @@ -0,0 +1,18 @@ +#!/bin/sh + +targets=("protocol_state" "account_ledger" "dapp_info" "period_info" "era_info" \ + "stake_amount" "singular_staking_info" "contract_stake_amount" "era_reward_span" \ + "period_end_info" "era_stake_pair_iter" "tier_threshold" "tier_params" "tier_configuration" \ + "dapp_tier_rewards" ) + +for target in "${targets[@]}" +do + cargo tarpaulin -p pallet-dapp-staking-v3 -o=html --output-dir=./coverage/$target -- $target +done + +# Also need to check the coverage when only running extrinsic tests (disable type tests) + +# Also need similar approach to extrinsic testing, as above + + +# NOTE: this script will be deleted before the final release! \ No newline at end of file diff --git a/pallets/dapp-staking-v3/src/benchmarking.rs b/pallets/dapp-staking-v3/src/benchmarking.rs index e98fd1ce09..2b04874516 100644 --- a/pallets/dapp-staking-v3/src/benchmarking.rs +++ b/pallets/dapp-staking-v3/src/benchmarking.rs @@ -24,7 +24,11 @@ use frame_benchmarking::v2::*; use frame_support::assert_ok; use frame_system::{Pallet as System, RawOrigin}; -// TODO: copy/paste from mock, make it more generic later +use ::assert_matches::assert_matches; + +// TODO: make benchmark utils file and move all these helper methods there to keep this file clean(er) + +// TODO2: non-extrinsic calls still need to be benchmarked. /// Run to the specified block number. /// Function assumes first block has been initialized. @@ -58,28 +62,28 @@ pub(crate) fn advance_to_next_era() { advance_to_era::(ActiveProtocolState::::get().era + 1); } -// /// 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) { -// assert!(period >= ActiveProtocolState::::get().period_number()); -// while ActiveProtocolState::::get().period_number() < period { -// run_for_blocks::(One::one()); -// } -// } - -// /// Advance blocks until next period has been reached. -// pub(crate) fn advance_to_next_period() { -// advance_to_period::(ActiveProtocolState::::get().period_number() + 1); -// } - -// /// Advance blocks until next period type has been reached. -// pub(crate) fn advance_to_next_subperiod() { -// let subperiod = ActiveProtocolState::::get().subperiod(); -// while ActiveProtocolState::::get().subperiod() == subperiod { -// run_for_blocks::(One::one()); -// } -// } +/// 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) { + assert!(period >= ActiveProtocolState::::get().period_number()); + while ActiveProtocolState::::get().period_number() < period { + run_for_blocks::(One::one()); + } +} + +/// Advance blocks until next period has been reached. +pub(crate) fn advance_to_next_period() { + advance_to_period::(ActiveProtocolState::::get().period_number() + 1); +} + +/// Advance blocks until next period type has been reached. +pub(crate) fn advance_to_next_subperiod() { + let subperiod = ActiveProtocolState::::get().subperiod(); + while ActiveProtocolState::::get().subperiod() == subperiod { + run_for_blocks::(One::one()); + } +} // All our networks use 18 decimals for native currency so this should be fine. const UNIT: Balance = 1_000_000_000_000_000_000; @@ -87,11 +91,28 @@ const UNIT: Balance = 1_000_000_000_000_000_000; // Minimum amount that must be staked on a dApp to enter any tier const MIN_TIER_THRESHOLD: Balance = 10 * UNIT; -const NUMBER_OF_SLOTS: u16 = 100; +const NUMBER_OF_SLOTS: u32 = 100; +const SEED: u32 = 9000; + +/// Assert that the last event equals the provided one. +fn assert_last_event(generic_event: ::RuntimeEvent) { + frame_system::Pallet::::assert_last_event(generic_event.into()); +} + +// Return all dApp staking events from the event buffer. +fn dapp_staking_events() -> Vec> { + System::::events() + .into_iter() + .map(|r| r.event) + .filter_map(|e| ::RuntimeEvent::from(e).try_into().ok()) + .collect::>() +} + +// TODO: make it more generic per runtime? pub fn initial_config() { let era_length = T::StandardEraLength::get(); - let voting_period_length_in_eras = T::StandardErasPerVotingPeriod::get(); + let voting_period_length_in_eras = T::StandardErasPerVotingSubperiod::get(); // Init protocol state ActiveProtocolState::::put(ProtocolState { @@ -143,7 +164,7 @@ pub fn initial_config() { // Init tier config, based on the initial params let init_tier_config = TiersConfiguration:: { - number_of_slots: NUMBER_OF_SLOTS, + number_of_slots: NUMBER_OF_SLOTS.try_into().unwrap(), slots_per_tier: BoundedVec::try_from(vec![10, 20, 30, 40]).unwrap(), reward_portion: tier_params.reward_portion.clone(), tier_thresholds: tier_params.tier_thresholds.clone(), @@ -165,6 +186,762 @@ fn max_number_of_contracts() -> u32 { mod benchmarks { use super::*; + #[benchmark] + fn maintenance_mode() { + initial_config::(); + + #[extrinsic_call] + _(RawOrigin::Root, true); + + assert_last_event::(Event::::MaintenanceMode { enabled: true }.into()); + } + + #[benchmark] + fn register() { + initial_config::(); + + let account: T::AccountId = account("dapp_owner", 0, SEED); + let smart_contract = T::BenchmarkHelper::get_smart_contract(1); + + #[extrinsic_call] + _(RawOrigin::Root, account.clone(), smart_contract.clone()); + + assert_last_event::( + Event::::DAppRegistered { + owner: account, + smart_contract, + dapp_id: 0, + } + .into(), + ); + } + + #[benchmark] + fn set_dapp_reward_beneficiary() { + initial_config::(); + + let owner: T::AccountId = whitelisted_caller(); + let beneficiary: Option = Some(account("beneficiary", 0, SEED)); + let smart_contract = T::BenchmarkHelper::get_smart_contract(1); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + #[extrinsic_call] + _( + RawOrigin::Signed(owner), + smart_contract.clone(), + beneficiary.clone(), + ); + + assert_last_event::( + Event::::DAppRewardDestinationUpdated { + smart_contract, + beneficiary, + } + .into(), + ); + } + + #[benchmark] + fn set_dapp_owner() { + initial_config::(); + + let init_owner: T::AccountId = whitelisted_caller(); + let new_owner: T::AccountId = account("dapp_owner", 0, SEED); + let smart_contract = T::BenchmarkHelper::get_smart_contract(1); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + init_owner.clone().into(), + smart_contract.clone(), + )); + + #[extrinsic_call] + _( + RawOrigin::Signed(init_owner), + smart_contract.clone(), + new_owner.clone(), + ); + + assert_last_event::( + Event::::DAppOwnerChanged { + smart_contract, + new_owner, + } + .into(), + ); + } + + #[benchmark] + fn unregister() { + initial_config::(); + + let owner: T::AccountId = whitelisted_caller(); + let smart_contract = T::BenchmarkHelper::get_smart_contract(1); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + #[extrinsic_call] + _(RawOrigin::Root, smart_contract.clone()); + + assert_last_event::( + Event::::DAppUnregistered { + smart_contract, + era: ActiveProtocolState::::get().era, + } + .into(), + ); + } + + #[benchmark] + fn lock() { + initial_config::(); + + let staker: T::AccountId = whitelisted_caller(); + let owner: T::AccountId = account("dapp_owner", 0, SEED); + let smart_contract = T::BenchmarkHelper::get_smart_contract(1); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + let amount = T::MinimumLockedAmount::get(); + T::Currency::make_free_balance_be(&staker, amount); + + #[extrinsic_call] + _(RawOrigin::Signed(staker.clone()), amount); + + assert_last_event::( + Event::::Locked { + account: staker, + amount, + } + .into(), + ); + } + + #[benchmark] + fn unlock() { + initial_config::(); + + let staker: T::AccountId = whitelisted_caller(); + let owner: T::AccountId = account("dapp_owner", 0, SEED); + let smart_contract = T::BenchmarkHelper::get_smart_contract(1); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + let amount = T::MinimumLockedAmount::get() * 2; + T::Currency::make_free_balance_be(&staker, amount); + assert_ok!(DappStaking::::lock( + RawOrigin::Signed(staker.clone()).into(), + amount, + )); + + #[extrinsic_call] + _(RawOrigin::Signed(staker.clone()), 1); + + assert_last_event::( + Event::::Unlocking { + account: staker, + amount: 1, + } + .into(), + ); + } + + // TODO: maybe this is not needed. Compare it after running benchmarks to the 'not-full' unlock + #[benchmark] + fn full_unlock() { + initial_config::(); + + let staker: T::AccountId = whitelisted_caller(); + let owner: T::AccountId = account("dapp_owner", 0, SEED); + let smart_contract = T::BenchmarkHelper::get_smart_contract(1); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + let amount = T::MinimumLockedAmount::get() * 2; + T::Currency::make_free_balance_be(&staker, amount); + assert_ok!(DappStaking::::lock( + RawOrigin::Signed(staker.clone()).into(), + amount, + )); + + #[extrinsic_call] + unlock(RawOrigin::Signed(staker.clone()), amount); + + assert_last_event::( + Event::::Unlocking { + account: staker, + amount, + } + .into(), + ); + } + + #[benchmark] + fn claim_unlocked(x: Linear<0, { T::MaxNumberOfStakedContracts::get() }>) { + // Prepare staker account and lock some amount + let staker: T::AccountId = whitelisted_caller(); + let amount = (T::MinimumStakeAmount::get() + 1) + * Into::::into(max_number_of_contracts::()) + + Into::::into(T::MaxUnlockingChunks::get()); + T::Currency::make_free_balance_be(&staker, amount); + assert_ok!(DappStaking::::lock( + RawOrigin::Signed(staker.clone()).into(), + amount, + )); + + // Move over to the build&earn subperiod to ensure 'non-loyal' staking. + // This is needed so we can achieve staker entry cleanup after claiming unlocked tokens. + advance_to_next_subperiod::(); + assert_eq!( + ActiveProtocolState::::get().subperiod(), + Subperiod::BuildAndEarn, + "Sanity check - we need to stake during build&earn for entries to be cleaned up in the next era." + ); + + // Register required number of contracts and have staker stake on them. + // This is needed to achieve the cleanup functionality. + for idx in 0..x { + let smart_contract = T::BenchmarkHelper::get_smart_contract(idx as u32); + let owner: T::AccountId = account("dapp_owner", idx.into(), SEED); + + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + assert_ok!(DappStaking::::stake( + RawOrigin::Signed(staker.clone()).into(), + smart_contract, + T::MinimumStakeAmount::get() + 1, + )); + } + + // Unlock some amount - but we want to fill up the whole vector with chunks. + let unlock_amount = 1; + for _ in 0..T::MaxUnlockingChunks::get() { + assert_ok!(DappStaking::::unlock( + RawOrigin::Signed(staker.clone()).into(), + unlock_amount, + )); + run_for_blocks::(One::one()); + } + assert_eq!( + Ledger::::get(&staker).unlocking.len(), + T::MaxUnlockingChunks::get() as usize + ); + let unlock_amount = unlock_amount * Into::::into(T::MaxUnlockingChunks::get()); + + // Advance to next period to ensure the old stake entries are cleaned up. + advance_to_next_period::(); + + // Additionally, ensure enough blocks have passed so that the unlocking chunk can be claimed. + let unlock_block = Ledger::::get(&staker) + .unlocking + .last() + .expect("At least one entry must exist.") + .unlock_block; + run_to_block::(unlock_block); + + #[extrinsic_call] + _(RawOrigin::Signed(staker.clone())); + + assert_last_event::( + Event::::ClaimedUnlocked { + account: staker, + amount: unlock_amount, + } + .into(), + ); + } + + #[benchmark] + fn relock_unlocking() { + initial_config::(); + + let staker: T::AccountId = whitelisted_caller(); + let owner: T::AccountId = account("dapp_owner", 0, SEED); + let smart_contract = T::BenchmarkHelper::get_smart_contract(1); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + let amount = + T::MinimumLockedAmount::get() * 2 + Into::::into(T::MaxUnlockingChunks::get()); + T::Currency::make_free_balance_be(&staker, amount); + assert_ok!(DappStaking::::lock( + RawOrigin::Signed(staker.clone()).into(), + amount, + )); + + // Unlock some amount - but we want to fill up the whole vector with chunks. + let unlock_amount = 1; + for _ in 0..T::MaxUnlockingChunks::get() { + assert_ok!(DappStaking::::unlock( + RawOrigin::Signed(staker.clone()).into(), + unlock_amount, + )); + run_for_blocks::(One::one()); + } + let unlock_amount = unlock_amount * Into::::into(T::MaxUnlockingChunks::get()); + + #[extrinsic_call] + _(RawOrigin::Signed(staker.clone())); + + assert_last_event::( + Event::::Relock { + account: staker, + amount: unlock_amount, + } + .into(), + ); + } + + #[benchmark] + fn stake() { + initial_config::(); + + let staker: T::AccountId = whitelisted_caller(); + let owner: T::AccountId = account("dapp_owner", 0, SEED); + let smart_contract = T::BenchmarkHelper::get_smart_contract(1); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + let amount = T::MinimumLockedAmount::get(); + T::Currency::make_free_balance_be(&staker, amount); + assert_ok!(DappStaking::::lock( + RawOrigin::Signed(staker.clone()).into(), + amount, + )); + + #[extrinsic_call] + _( + RawOrigin::Signed(staker.clone()), + smart_contract.clone(), + amount, + ); + + assert_last_event::( + Event::::Stake { + account: staker, + smart_contract, + amount, + } + .into(), + ); + } + + #[benchmark] + fn unstake() { + initial_config::(); + + let staker: T::AccountId = whitelisted_caller(); + let owner: T::AccountId = account("dapp_owner", 0, SEED); + let smart_contract = T::BenchmarkHelper::get_smart_contract(1); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + let amount = T::MinimumLockedAmount::get() + 1; + T::Currency::make_free_balance_be(&staker, amount); + assert_ok!(DappStaking::::lock( + RawOrigin::Signed(staker.clone()).into(), + amount, + )); + + assert_ok!(DappStaking::::stake( + RawOrigin::Signed(staker.clone()).into(), + smart_contract.clone(), + amount + )); + + let unstake_amount = 1; + + #[extrinsic_call] + _( + RawOrigin::Signed(staker.clone()), + smart_contract.clone(), + unstake_amount, + ); + + assert_last_event::( + Event::::Unstake { + account: staker, + smart_contract, + amount: unstake_amount, + } + .into(), + ); + } + + #[benchmark] + fn claim_staker_rewards_past_period(x: Linear<1, { T::EraRewardSpanLength::get() }>) { + initial_config::(); + + // Prepare staker & register smart contract + let staker: T::AccountId = whitelisted_caller(); + let owner: T::AccountId = account("dapp_owner", 0, SEED); + let smart_contract = T::BenchmarkHelper::get_smart_contract(1); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + // Lock some amount by the staker + let amount = T::MinimumLockedAmount::get(); + T::Currency::make_free_balance_be(&staker, amount); + assert_ok!(DappStaking::::lock( + RawOrigin::Signed(staker.clone()).into(), + amount, + )); + + // Advance to the era just before a new span entry is created. + // This ensures that when rewards are claimed, we'll be claiming from the new span. + // + // This is convenient because it allows us to control how many rewards are claimed. + advance_to_era::(T::EraRewardSpanLength::get() - 1); + + // Now ensure the expected amount of rewards are claimable. + advance_to_era::( + ActiveProtocolState::::get().era + T::EraRewardSpanLength::get() - x, + ); + assert_ok!(DappStaking::::stake( + RawOrigin::Signed(staker.clone()).into(), + smart_contract.clone(), + amount + )); + + // This ensures we claim from the past period. + advance_to_next_period::(); + + // For testing purposes + System::::reset_events(); + + #[extrinsic_call] + claim_staker_rewards(RawOrigin::Signed(staker.clone())); + + // No need to do precise check of values, but predetermiend amount of 'Reward' events is expected. + let dapp_staking_events = dapp_staking_events::(); + assert_eq!(dapp_staking_events.len(), x as usize); + dapp_staking_events.iter().for_each(|e| { + assert_matches!(e, Event::Reward { .. }); + }); + } + + #[benchmark] + fn claim_staker_rewards_ongoing_period(x: Linear<1, { T::EraRewardSpanLength::get() }>) { + initial_config::(); + + // Prepare staker & register smart contract + let staker: T::AccountId = whitelisted_caller(); + let owner: T::AccountId = account("dapp_owner", 0, SEED); + let smart_contract = T::BenchmarkHelper::get_smart_contract(1); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + // Lock & stake some amount by the staker + let amount = T::MinimumLockedAmount::get(); + T::Currency::make_free_balance_be(&staker, amount); + assert_ok!(DappStaking::::lock( + RawOrigin::Signed(staker.clone()).into(), + amount, + )); + + // Advance to the era just before a new span entry is created. + // This ensures that when rewards are claimed, we'll be claiming from the new span. + // + // This is convenient because it allows us to control how many rewards are claimed. + advance_to_era::(T::EraRewardSpanLength::get() - 1); + + // Now ensure the expected amount of rewards are claimable. + advance_to_era::( + ActiveProtocolState::::get().era + T::EraRewardSpanLength::get() - x, + ); + assert_ok!(DappStaking::::stake( + RawOrigin::Signed(staker.clone()).into(), + smart_contract.clone(), + amount + )); + + // This ensures we move over the entire span. + advance_to_era::(T::EraRewardSpanLength::get() * 2); + + // For testing purposes + System::::reset_events(); + + #[extrinsic_call] + claim_staker_rewards(RawOrigin::Signed(staker.clone())); + + // No need to do precise check of values, but predetermiend amount of 'Reward' events is expected. + let dapp_staking_events = dapp_staking_events::(); + assert_eq!(dapp_staking_events.len(), x as usize); + dapp_staking_events.iter().for_each(|e| { + assert_matches!(e, Event::Reward { .. }); + }); + } + + #[benchmark] + fn claim_bonus_reward() { + initial_config::(); + + // Prepare staker & register smart contract + let staker: T::AccountId = whitelisted_caller(); + let owner: T::AccountId = account("dapp_owner", 0, SEED); + let smart_contract = T::BenchmarkHelper::get_smart_contract(1); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + // Lock & stake some amount by the staker + let amount = T::MinimumLockedAmount::get(); + T::Currency::make_free_balance_be(&staker, amount); + assert_ok!(DappStaking::::lock( + RawOrigin::Signed(staker.clone()).into(), + amount, + )); + assert_ok!(DappStaking::::stake( + RawOrigin::Signed(staker.clone()).into(), + smart_contract.clone(), + amount + )); + + // Advance to the next period so we can claim the bonus reward. + advance_to_next_period::(); + + #[extrinsic_call] + _(RawOrigin::Signed(staker.clone()), smart_contract.clone()); + + // No need to do precise check of values, but last event must be 'BonusReward'. + assert_matches!( + dapp_staking_events::().last(), + Some(Event::BonusReward { .. }) + ); + } + + #[benchmark] + fn claim_dapp_reward() { + initial_config::(); + + // Register a dApp & stake on it. + // This is the dApp for which we'll claim rewards for. + let owner: T::AccountId = whitelisted_caller(); + let smart_contract = T::BenchmarkHelper::get_smart_contract(0); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + let amount = T::MinimumLockedAmount::get() * 1000 * UNIT; + T::Currency::make_free_balance_be(&owner, amount); + assert_ok!(DappStaking::::lock( + RawOrigin::Signed(owner.clone()).into(), + amount, + )); + assert_ok!(DappStaking::::stake( + RawOrigin::Signed(owner.clone()).into(), + smart_contract.clone(), + amount + )); + + // Register & stake up to max number of contracts. + // The reason is we want to have reward vector filled up to the capacity. + for idx in 1..T::MaxNumberOfContracts::get() { + let owner: T::AccountId = account("dapp_owner", idx.into(), SEED); + let smart_contract = T::BenchmarkHelper::get_smart_contract(idx as u32); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + let staker: T::AccountId = account("staker", idx.into(), SEED); + T::Currency::make_free_balance_be(&staker, amount); + assert_ok!(DappStaking::::lock( + RawOrigin::Signed(staker.clone()).into(), + amount, + )); + assert_ok!(DappStaking::::stake( + RawOrigin::Signed(staker.clone()).into(), + smart_contract.clone(), + amount + )); + } + + // This is a hacky part to ensure we accomodate max number of contracts. + TierConfig::::mutate(|config| { + let max_number_of_contracts: u16 = T::MaxNumberOfContracts::get().try_into().unwrap(); + config.number_of_slots = max_number_of_contracts; + config.slots_per_tier[0] = max_number_of_contracts; + config.slots_per_tier[1..].iter_mut().for_each(|x| *x = 0); + }); + + // Advance enough eras so dApp reward can be claimed. + advance_to_next_subperiod::(); + advance_to_next_era::(); + let claim_era = ActiveProtocolState::::get().era - 1; + + assert_eq!( + DAppTiers::::get(claim_era) + .expect("Must exist since it's from past build&earn era.") + .dapps + .len(), + T::MaxNumberOfContracts::get() as usize, + "Sanity check to ensure we have filled up the vector completely." + ); + + #[extrinsic_call] + _( + RawOrigin::Signed(owner.clone()), + smart_contract.clone(), + claim_era, + ); + + // No need to do precise check of values, but last event must be 'DAppReward'. + assert_matches!( + dapp_staking_events::().last(), + Some(Event::DAppReward { .. }) + ); + } + + #[benchmark] + fn unstake_from_unregistered() { + initial_config::(); + + let staker: T::AccountId = whitelisted_caller(); + let owner: T::AccountId = account("dapp_owner", 0, SEED); + let smart_contract = T::BenchmarkHelper::get_smart_contract(1); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + let amount = T::MinimumLockedAmount::get(); + T::Currency::make_free_balance_be(&staker, amount); + assert_ok!(DappStaking::::lock( + RawOrigin::Signed(staker.clone()).into(), + amount, + )); + + assert_ok!(DappStaking::::stake( + RawOrigin::Signed(staker.clone()).into(), + smart_contract.clone(), + amount + )); + + assert_ok!(DappStaking::::unregister( + RawOrigin::Root.into(), + smart_contract.clone(), + )); + + #[extrinsic_call] + _(RawOrigin::Signed(staker.clone()), smart_contract.clone()); + + assert_last_event::( + Event::::UnstakeFromUnregistered { + account: staker, + smart_contract, + amount, + } + .into(), + ); + } + + #[benchmark] + fn cleanup_expired_entries(x: Linear<1, { T::MaxNumberOfStakedContracts::get() }>) { + initial_config::(); + + // Move over to the build&earn subperiod to ensure 'non-loyal' staking. + advance_to_next_subperiod::(); + + // Prepare staker & lock some amount + let staker: T::AccountId = whitelisted_caller(); + let amount = T::MinimumLockedAmount::get() + * Into::::into(T::MaxNumberOfStakedContracts::get()); + T::Currency::make_free_balance_be(&staker, amount); + assert_ok!(DappStaking::::lock( + RawOrigin::Signed(staker.clone()).into(), + amount, + )); + + // Register dApps up the the limit + for idx in 0..x { + let owner: T::AccountId = account("dapp_owner", idx.into(), SEED); + let smart_contract = T::BenchmarkHelper::get_smart_contract(idx as u32); + assert_ok!(DappStaking::::register( + RawOrigin::Root.into(), + owner.clone().into(), + smart_contract.clone(), + )); + + assert_ok!(DappStaking::::stake( + RawOrigin::Signed(staker.clone()).into(), + smart_contract.clone(), + T::MinimumStakeAmount::get(), + )); + } + + // Move over to the next period, marking the entries as expired since they don't have the loyalty flag. + advance_to_next_period::(); + + #[extrinsic_call] + _(RawOrigin::Signed(staker.clone())); + + assert_last_event::( + Event::::ExpiredEntriesRemoved { + account: staker, + count: x.try_into().unwrap(), + } + .into(), + ); + } + + #[benchmark] + fn force() { + initial_config::(); + + let forcing_type = ForcingType::Subperiod; + + #[extrinsic_call] + _(RawOrigin::Root, forcing_type); + + assert_last_event::(Event::::Force { forcing_type }.into()); + } + + // TODO: investigate why the PoV size is so large here, evne after removing read of `IntegratedDApps` storage. + // Relevant file: polkadot-sdk/substrate/utils/frame/benchmarking-cli/src/pallet/writer.rs + // UPDATE: after some investigation, it seems that PoV size benchmarks are very unprecise + // - the worst case measured is usually very far off the actual value that is consumed on chain. + // There's an ongoing item to improve it (mentioned on roundtable meeting). #[benchmark] fn dapp_tier_assignment(x: Linear<0, { max_number_of_contracts::() }>) { // Prepare init config (protocol state, tier params & config, etc.) @@ -217,17 +994,6 @@ mod benchmarks { } } - #[benchmark] - fn experimental_read() { - // Prepare init config (protocol state, tier params & config, etc.) - initial_config::(); - - #[block] - { - let _ = ExperimentalContractEntries::::get(10); - } - } - impl_benchmark_test_suite!( Pallet, crate::benchmarking::tests::new_test_ext(), diff --git a/pallets/dapp-staking-v3/src/dsv3_weight.rs b/pallets/dapp-staking-v3/src/dsv3_weight.rs index 1dca8a56ce..c1f88e9f1a 100644 --- a/pallets/dapp-staking-v3/src/dsv3_weight.rs +++ b/pallets/dapp-staking-v3/src/dsv3_weight.rs @@ -20,7 +20,7 @@ //! Autogenerated weights for pallet_dapp_staking_v3 //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 4.0.0-dev -//! DATE: 2023-11-02, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2023-11-14, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` //! HOSTNAME: `Dinos-MBP`, CPU: `` //! EXECUTION: Some(Wasm), WASM-EXECUTION: Compiled, CHAIN: Some("dev"), DB CACHE: 1024 @@ -32,12 +32,12 @@ // --chain=dev // --steps=50 // --repeat=20 -// --pallet=pallet_dapp_staking_v3 +// --pallet=pallet_dapp_staking-v3 // --extrinsic=* // --execution=wasm // --wasm-execution=compiled // --heap-pages=4096 -// --output=dsv3_weight.rs +// --output=dapp_staking_v3.rs // --template=./scripts/templates/weight-template.hbs #![cfg_attr(rustfmt, rustfmt_skip)] @@ -50,76 +50,51 @@ use core::marker::PhantomData; /// Weight functions needed for pallet_dapp_staking_v3. pub trait WeightInfo { fn dapp_tier_assignment(x: u32, ) -> Weight; - fn experimental_read() -> Weight; } /// Weights for pallet_dapp_staking_v3 using the Substrate node and recommended hardware. pub struct SubstrateWeight(PhantomData); impl WeightInfo for SubstrateWeight { - /// Storage: DappStaking TierConfig (r:1 w:0) - /// Proof: DappStaking TierConfig (max_values: Some(1), max_size: Some(161), added: 656, mode: MaxEncodedLen) /// Storage: DappStaking CounterForIntegratedDApps (r:1 w:0) /// Proof: DappStaking CounterForIntegratedDApps (max_values: Some(1), max_size: Some(4), added: 499, mode: MaxEncodedLen) - /// Storage: DappStaking IntegratedDApps (r:101 w:0) - /// Proof: DappStaking IntegratedDApps (max_values: None, max_size: Some(121), added: 2596, mode: MaxEncodedLen) - /// Storage: DappStaking ContractStake (r:100 w:0) - /// Proof: DappStaking ContractStake (max_values: None, max_size: Some(130), added: 2605, mode: MaxEncodedLen) + /// Storage: DappStaking ContractStake (r:101 w:0) + /// Proof: DappStaking ContractStake (max_values: Some(65535), max_size: Some(93), added: 2073, mode: MaxEncodedLen) + /// Storage: DappStaking TierConfig (r:1 w:0) + /// Proof: DappStaking TierConfig (max_values: Some(1), max_size: Some(161), added: 656, mode: MaxEncodedLen) /// The range of component `x` is `[0, 100]`. fn dapp_tier_assignment(x: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `836 + x * (169 ±0)` - // Estimated: `3586 + x * (2605 ±0)` + // Measured: `449 + x * (33 ±0)` + // Estimated: `3063 + x * (2073 ±0)` // Minimum execution time: 9_000_000 picoseconds. - Weight::from_parts(12_879_631, 3586) - // Standard Error: 18_480 - .saturating_add(Weight::from_parts(7_315_677, 0).saturating_mul(x.into())) + Weight::from_parts(16_776_512, 3063) + // Standard Error: 3_400 + .saturating_add(Weight::from_parts(2_636_298, 0).saturating_mul(x.into())) .saturating_add(T::DbWeight::get().reads(3_u64)) - .saturating_add(T::DbWeight::get().reads((2_u64).saturating_mul(x.into()))) - .saturating_add(Weight::from_parts(0, 2605).saturating_mul(x.into())) - } - /// Storage: DappStaking ExperimentalContractEntries (r:1 w:0) - /// Proof: DappStaking ExperimentalContractEntries (max_values: None, max_size: Some(3483), added: 5958, mode: MaxEncodedLen) - fn experimental_read() -> Weight { - // Proof Size summary in bytes: - // Measured: `224` - // Estimated: `6948` - // Minimum execution time: 5_000_000 picoseconds. - Weight::from_parts(5_000_000, 6948) - .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(x.into()))) + .saturating_add(Weight::from_parts(0, 2073).saturating_mul(x.into())) } } // For backwards compatibility and tests impl WeightInfo for () { - /// Storage: DappStaking TierConfig (r:1 w:0) - /// Proof: DappStaking TierConfig (max_values: Some(1), max_size: Some(161), added: 656, mode: MaxEncodedLen) /// Storage: DappStaking CounterForIntegratedDApps (r:1 w:0) /// Proof: DappStaking CounterForIntegratedDApps (max_values: Some(1), max_size: Some(4), added: 499, mode: MaxEncodedLen) - /// Storage: DappStaking IntegratedDApps (r:101 w:0) - /// Proof: DappStaking IntegratedDApps (max_values: None, max_size: Some(121), added: 2596, mode: MaxEncodedLen) - /// Storage: DappStaking ContractStake (r:100 w:0) - /// Proof: DappStaking ContractStake (max_values: None, max_size: Some(130), added: 2605, mode: MaxEncodedLen) + /// Storage: DappStaking ContractStake (r:101 w:0) + /// Proof: DappStaking ContractStake (max_values: Some(65535), max_size: Some(93), added: 2073, mode: MaxEncodedLen) + /// Storage: DappStaking TierConfig (r:1 w:0) + /// Proof: DappStaking TierConfig (max_values: Some(1), max_size: Some(161), added: 656, mode: MaxEncodedLen) /// The range of component `x` is `[0, 100]`. fn dapp_tier_assignment(x: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `836 + x * (169 ±0)` - // Estimated: `3586 + x * (2605 ±0)` + // Measured: `449 + x * (33 ±0)` + // Estimated: `3063 + x * (2073 ±0)` // Minimum execution time: 9_000_000 picoseconds. - Weight::from_parts(12_879_631, 3586) - // Standard Error: 18_480 - .saturating_add(Weight::from_parts(7_315_677, 0).saturating_mul(x.into())) + Weight::from_parts(16_776_512, 3063) + // Standard Error: 3_400 + .saturating_add(Weight::from_parts(2_636_298, 0).saturating_mul(x.into())) .saturating_add(RocksDbWeight::get().reads(3_u64)) - .saturating_add(RocksDbWeight::get().reads((2_u64).saturating_mul(x.into()))) - .saturating_add(Weight::from_parts(0, 2605).saturating_mul(x.into())) - } - /// Storage: DappStaking ExperimentalContractEntries (r:1 w:0) - /// Proof: DappStaking ExperimentalContractEntries (max_values: None, max_size: Some(3483), added: 5958, mode: MaxEncodedLen) - fn experimental_read() -> Weight { - // Proof Size summary in bytes: - // Measured: `224` - // Estimated: `6948` - // Minimum execution time: 5_000_000 picoseconds. - Weight::from_parts(5_000_000, 6948) - .saturating_add(RocksDbWeight::get().reads(1_u64)) + .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(x.into()))) + .saturating_add(Weight::from_parts(0, 2073).saturating_mul(x.into())) } } diff --git a/pallets/dapp-staking-v3/src/lib.rs b/pallets/dapp-staking-v3/src/lib.rs index 9f48f21b96..e282f0959f 100644 --- a/pallets/dapp-staking-v3/src/lib.rs +++ b/pallets/dapp-staking-v3/src/lib.rs @@ -47,7 +47,7 @@ use frame_support::{ }; use frame_system::pallet_prelude::*; use sp_runtime::{ - traits::{BadOrigin, One, Saturating, Zero}, + traits::{BadOrigin, One, Saturating, UniqueSaturatedInto, Zero}, Perbill, Permill, }; pub use sp_std::vec::Vec; @@ -68,9 +68,10 @@ pub use types::{PriceProvider, RewardPoolProvider, TierThreshold}; mod dsv3_weight; +// Lock identifier for the dApp staking pallet const STAKING_ID: LockIdentifier = *b"dapstake"; -// TODO: add tracing! +const LOG_TARGET: &str = "dapp-staking"; #[frame_support::pallet] pub mod pallet { @@ -91,10 +92,14 @@ pub mod pallet { #[pallet::config] pub trait Config: frame_system::Config { /// The overarching event type. - type RuntimeEvent: From> + IsType<::RuntimeEvent>; + type RuntimeEvent: From> + + IsType<::RuntimeEvent> + + TryInto>; /// Currency used for staking. /// TODO: remove usage of deprecated LockableCurrency trait and use the new freeze approach. Might require some renaming of Lock to Freeze :) + // https://github.com/paritytech/substrate/pull/12951/ + // Look at nomination pools implementation for reference! type Currency: LockableCurrency< Self::AccountId, Moment = Self::BlockNumber, @@ -117,16 +122,16 @@ pub mod pallet { #[pallet::constant] type StandardEraLength: Get; - /// Length of the `Voting` period in standard eras. - /// Although `Voting` period only consumes one 'era', we still measure its length in standard eras + /// Length of the `Voting` subperiod in standard eras. + /// Although `Voting` subperiod only consumes one 'era', we still measure its length in standard eras /// for the sake of simplicity & consistency. #[pallet::constant] - type StandardErasPerVotingPeriod: Get; + type StandardErasPerVotingSubperiod: Get; - /// Length of the `Build&Earn` period in standard eras. - /// Each `Build&Earn` period consists of one or more distinct standard eras. + /// Length of the `Build&Earn` subperiod in standard eras. + /// Each `Build&Earn` subperiod consists of one or more distinct standard eras. #[pallet::constant] - type StandardErasPerBuildAndEarnPeriod: Get; + type StandardErasPerBuildAndEarnSubperiod: Get; /// Maximum length of a single era reward span length entry. #[pallet::constant] @@ -139,7 +144,7 @@ pub mod pallet { /// Maximum number of contracts that can be integrated into dApp staking at once. #[pallet::constant] - type MaxNumberOfContracts: Get; + type MaxNumberOfContracts: Get; /// Maximum number of unlocking chunks that can exist per account at a time. #[pallet::constant] @@ -174,10 +179,12 @@ pub mod pallet { #[pallet::event] #[pallet::generate_deposit(pub(crate) fn deposit_event)] pub enum Event { + /// Maintenance mode has been either enabled or disabled. + MaintenanceMode { enabled: bool }, /// New era has started. NewEra { era: EraNumber }, - /// New period has started. - NewPeriod { + /// New subperiod has started. + NewSubperiod { subperiod: Subperiod, number: PeriodNumber, }, @@ -240,12 +247,14 @@ pub mod pallet { era: EraNumber, amount: Balance, }, + /// Bonus reward has been paid out to a loyal staker. BonusReward { account: T::AccountId, smart_contract: T::SmartContract, period: PeriodNumber, amount: Balance, }, + /// dApp reward has been paid out to a beneficiary. DAppReward { beneficiary: T::AccountId, smart_contract: T::SmartContract, @@ -253,11 +262,16 @@ pub mod pallet { era: EraNumber, amount: Balance, }, + /// Account has unstaked funds from an unregistered smart contract UnstakeFromUnregistered { account: T::AccountId, smart_contract: T::SmartContract, amount: Balance, }, + /// Some expired stake entries have been removed from storage. + ExpiredEntriesRemoved { account: T::AccountId, count: u16 }, + /// Privileged origin has forced a new era and possibly a subperiod to start from next block. + Force { forcing_type: ForcingType }, } #[pallet::error] @@ -291,8 +305,8 @@ pub mod pallet { NoUnlockingChunks, /// The amount being staked is too large compared to what's available for staking. UnavailableStakeFunds, - /// There are unclaimed rewards remaining from past periods. They should be claimed before staking again. - UnclaimedRewardsFromPastPeriods, + /// There are unclaimed rewards remaining from past eras or periods. They should be claimed before attempting any stake modification again. + UnclaimedRewards, /// An unexpected error occured while trying to stake. InternalStakeError, /// Total staked amount on contract is below the minimum required value. @@ -330,6 +344,8 @@ pub mod pallet { ContractStillActive, /// There are too many contract stake entries for the account. This can be cleaned up by either unstaking or cleaning expired entries. TooManyStakedContracts, + /// There are no expired entries to cleanup for the account. + NoExpiredEntries, } /// General information about dApp staking protocol state. @@ -341,15 +357,14 @@ pub mod pallet { #[pallet::storage] pub type NextDAppId = StorageValue<_, DAppId, ValueQuery>; - // TODO: where to track TierLabels? E.g. a label to bootstrap a dApp into a specific tier. /// Map of all dApps integrated into dApp staking protocol. #[pallet::storage] pub type IntegratedDApps = CountedStorageMap< - _, - Blake2_128Concat, - T::SmartContract, - DAppInfo, - OptionQuery, + Hasher = Blake2_128Concat, + Key = T::SmartContract, + Value = DAppInfo, + QueryKind = OptionQuery, + MaxValues = ConstU32<{ DAppId::MAX as u32 }>, >; /// General locked/staked information for each account. @@ -371,8 +386,13 @@ pub mod pallet { /// Information about how much has been staked on a smart contract in some era or period. #[pallet::storage] - pub type ContractStake = - StorageMap<_, Blake2_128Concat, T::SmartContract, ContractStakeAmount, ValueQuery>; + pub type ContractStake = StorageMap< + Hasher = Twox64Concat, + Key = DAppId, + Value = ContractStakeAmount, + QueryKind = ValueQuery, + MaxValues = ConstU32<{ DAppId::MAX as u32 }>, + >; /// General information about the current era. #[pallet::storage] @@ -416,11 +436,6 @@ pub mod pallet { pub type DAppTiers = StorageMap<_, Twox64Concat, EraNumber, DAppTierRewardsFor, OptionQuery>; - // TODO: this is experimental, please don't review - #[pallet::storage] - pub type ExperimentalContractEntries = - StorageMap<_, Twox64Concat, EraNumber, ContractEntriesFor, OptionQuery>; - #[pallet::genesis_config] #[derive(frame_support::DefaultNoBound)] pub struct GenesisConfig { @@ -496,8 +511,6 @@ pub mod pallet { fn on_initialize(now: BlockNumberFor) -> Weight { let mut protocol_state = ActiveProtocolState::::get(); - // TODO: maybe do lazy history cleanup in this function? - // We should not modify pallet storage while in maintenance mode. // This is a safety measure, since maintenance mode is expected to be // enabled in case some misbehavior or corrupted storage is detected. @@ -510,13 +523,15 @@ pub mod pallet { return T::DbWeight::get().reads(1); } + // At this point it's clear that an era change will happen let mut era_info = CurrentEraInfo::::get(); let current_era = protocol_state.era; let next_era = current_era.saturating_add(1); let (maybe_period_event, era_reward) = match protocol_state.subperiod() { + // Voting subperiod only lasts for one 'prolonged' era Subperiod::Voting => { - // For the sake of consistency, we put zero reward into storage + // For the sake of consistency, we put zero reward into storage. There are no rewards for the voting subperiod. let era_reward = EraReward { staker_reward_pool: Balance::zero(), staked: era_info.total_staked_amount(), @@ -524,7 +539,7 @@ pub mod pallet { }; let subperiod_end_era = - next_era.saturating_add(T::StandardErasPerBuildAndEarnPeriod::get()); + next_era.saturating_add(T::StandardErasPerBuildAndEarnSubperiod::get()); let build_and_earn_start_block = now.saturating_add(T::StandardEraLength::get()); protocol_state @@ -537,7 +552,7 @@ pub mod pallet { TierConfig::::put(next_tier_config); ( - Some(Event::::NewPeriod { + Some(Event::::NewSubperiod { subperiod: protocol_state.subperiod(), number: protocol_state.period_number(), }), @@ -593,7 +608,7 @@ pub mod pallet { NextTierConfig::::put(new_tier_config); ( - Some(Event::::NewPeriod { + Some(Event::::NewSubperiod { subperiod: protocol_state.subperiod(), number: protocol_state.period_number(), }), @@ -611,7 +626,6 @@ pub mod pallet { }; // Update storage items - protocol_state.era = next_era; ActiveProtocolState::::put(protocol_state); @@ -619,8 +633,14 @@ pub mod pallet { let era_span_index = Self::era_reward_span_index(current_era); let mut span = EraRewards::::get(&era_span_index).unwrap_or(EraRewardSpan::new()); - // TODO: Error "cannot" happen here. Log an error if it does though. - let _ = span.push(current_era, era_reward); + if let Err(_) = span.push(current_era, era_reward) { + // This must never happen but we log the error just in case. + log::error!( + target: LOG_TARGET, + "Failed to push era {} into the era reward span.", + current_era + ); + } EraRewards::::insert(&era_span_index, span); Self::deposit_event(Event::::NewEra { era: next_era }); @@ -642,12 +662,15 @@ pub mod pallet { pub fn maintenance_mode(origin: OriginFor, enabled: bool) -> DispatchResult { T::ManagerOrigin::ensure_origin(origin)?; ActiveProtocolState::::mutate(|state| state.maintenance = enabled); + + Self::deposit_event(Event::::MaintenanceMode { enabled }); Ok(()) } /// Used to register a new contract for dApp staking. /// /// If successful, smart contract will be assigned a simple, unique numerical identifier. + /// Owner is set to be initial beneficiary & manager of the dApp. #[pallet::call_index(1)] #[pallet::weight(Weight::zero())] pub fn register( @@ -679,6 +702,7 @@ pub mod pallet { id: dapp_id, state: DAppState::Registered, reward_destination: None, + tier_label: None, }, ); @@ -697,6 +721,7 @@ pub mod pallet { /// /// Caller has to be dApp owner. /// If set to `None`, rewards will be deposited to the dApp owner. + /// After this call, all existing & future rewards will be paid out to the beneficiary. #[pallet::call_index(2)] #[pallet::weight(Weight::zero())] pub fn set_dapp_reward_beneficiary( @@ -787,25 +812,18 @@ pub mod pallet { let current_era = ActiveProtocolState::::get().era; - IntegratedDApps::::try_mutate( - &smart_contract, - |maybe_dapp_info| -> DispatchResult { - let dapp_info = maybe_dapp_info - .as_mut() - .ok_or(Error::::ContractNotFound)?; - - ensure!( - dapp_info.state == DAppState::Registered, - Error::::NotOperatedDApp - ); + let mut dapp_info = + IntegratedDApps::::get(&smart_contract).ok_or(Error::::ContractNotFound)?; - dapp_info.state = DAppState::Unregistered(current_era); + ensure!( + dapp_info.state == DAppState::Registered, + Error::::NotOperatedDApp + ); - Ok(()) - }, - )?; + ContractStake::::remove(&dapp_info.id); - ContractStake::::remove(&smart_contract); + dapp_info.state = DAppState::Unregistered(current_era); + IntegratedDApps::::insert(&smart_contract, dapp_info); Self::deposit_event(Event::::DAppUnregistered { smart_contract, @@ -819,6 +837,8 @@ pub mod pallet { /// /// In case caller account doesn't have sufficient balance to cover the specified amount, everything is locked. /// After adjustment, lock amount must be greater than zero and in total must be equal or greater than the minimum locked amount. + /// + /// Locked amount can immediately be used for staking. #[pallet::call_index(5)] #[pallet::weight(Weight::zero())] pub fn lock(origin: OriginFor, #[pallet::compact] amount: Balance) -> DispatchResult { @@ -932,9 +952,6 @@ pub mod pallet { 0 }; - // TODO: discussion point - this will "kill" users ability to withdraw past rewards. - // This can be handled by the frontend though. - Self::update_ledger(&account, ledger); CurrentEraInfo::::mutate(|era_info| { era_info.unlocking_removed(amount); @@ -975,9 +992,13 @@ pub mod pallet { } /// Stake the specified amount on a smart contract. - /// The `amount` specified **must** be available for staking and meet the required minimum, otherwise the call will fail. + /// The precise `amount` specified **must** be available for staking. + /// The total amount staked on a dApp must be greater than the minimum required value. /// - /// Depending on the period type, appropriate stake amount will be updated. + /// Depending on the period type, appropriate stake amount will be updated. During `Voting` subperiod, `voting` stake amount is updated, + /// and same for `Build&Earn` subperiod. + /// + /// Staked amount is only eligible for rewards from the next era onwards. #[pallet::call_index(9)] #[pallet::weight(Weight::zero())] pub fn stake( @@ -990,38 +1011,38 @@ pub mod pallet { ensure!(amount > 0, Error::::ZeroAmount); - ensure!( - Self::is_active(&smart_contract), - Error::::NotOperatedDApp - ); + let dapp_info = + IntegratedDApps::::get(&smart_contract).ok_or(Error::::NotOperatedDApp)?; + ensure!(dapp_info.is_active(), Error::::NotOperatedDApp); let protocol_state = ActiveProtocolState::::get(); - let stake_era = protocol_state.era; + let current_era = protocol_state.era; ensure!( !protocol_state .period_info - .is_next_period(stake_era.saturating_add(1)), + .is_next_period(current_era.saturating_add(1)), Error::::PeriodEndsInNextEra ); let mut ledger = Ledger::::get(&account); - // TODO: suggestion is to change this a bit so we clean up ledger if rewards have expired + // In case old stake rewards are unclaimed & have expired, clean them up. + let threshold_period = Self::oldest_claimable_period(protocol_state.period_number()); + let _ignore = ledger.maybe_cleanup_expired(threshold_period); + // 1. // Increase stake amount for the next era & current period in staker's ledger ledger - .add_stake_amount(amount, stake_era, protocol_state.period_info) + .add_stake_amount(amount, current_era, protocol_state.period_info) .map_err(|err| match err { AccountLedgerError::InvalidPeriod | AccountLedgerError::InvalidEra => { - Error::::UnclaimedRewardsFromPastPeriods + Error::::UnclaimedRewards } AccountLedgerError::UnavailableStakeFunds => Error::::UnavailableStakeFunds, // Defensive check, should never happen _ => Error::::InternalStakeError, })?; - // TODO: also change this to check if rewards have expired - // 2. // Update `StakerInfo` storage with the new stake amount on the specified contract. // @@ -1040,12 +1061,15 @@ pub mod pallet { { (staking_info, false) } - // Entry exists but period doesn't match. Either reward should be claimed or cleaned up. - Some(_) => { - return Err(Error::::UnclaimedRewardsFromPastPeriods.into()); + // Entry exists but period doesn't match. Bonus reward might still be claimable. + Some(staking_info) + if staking_info.period_number() >= threshold_period + && staking_info.is_loyal() => + { + return Err(Error::::UnclaimedRewards.into()); } - // No entry exists - None => ( + // No valid entry exists + _ => ( SingularStakingInfo::new( protocol_state.period_number(), protocol_state.subperiod(), @@ -1053,7 +1077,7 @@ pub mod pallet { true, ), }; - new_staking_info.stake(amount, protocol_state.subperiod()); + new_staking_info.stake(amount, current_era, protocol_state.subperiod()); ensure!( new_staking_info.total_staked_amount() >= T::MinimumStakeAmount::get(), Error::::InsufficientStakeAmount @@ -1069,8 +1093,8 @@ pub mod pallet { // 3. // Update `ContractStake` storage with the new stake amount on the specified contract. - let mut contract_stake_info = ContractStake::::get(&smart_contract); - contract_stake_info.stake(amount, protocol_state.period_info, stake_era); + let mut contract_stake_info = ContractStake::::get(&dapp_info.id); + contract_stake_info.stake(amount, protocol_state.period_info, current_era); // 4. // Update total staked amount for the next era. @@ -1082,7 +1106,7 @@ pub mod pallet { // Update remaining storage entries Self::update_ledger(&account, ledger); StakerInfo::::insert(&account, &smart_contract, new_staking_info); - ContractStake::::insert(&smart_contract, contract_stake_info); + ContractStake::::insert(&dapp_info.id, contract_stake_info); Self::deposit_event(Event::::Stake { account, @@ -1096,7 +1120,12 @@ pub mod pallet { /// Unstake the specified amount from a smart contract. /// The `amount` specified **must** not exceed what's staked, otherwise the call will fail. /// + /// If unstaking the specified `amount` would take staker below the minimum stake threshold, everything is unstaked. + /// /// Depending on the period type, appropriate stake amount will be updated. + /// In case amount is unstaked during `Voting` subperiod, the `voting` amount is reduced. + /// In case amount is unstaked during `Build&Earn` subperiod, first the `build_and_earn` is reduced, + /// and any spillover is subtracted from the `voting` amount. #[pallet::call_index(10)] #[pallet::weight(Weight::zero())] pub fn unstake( @@ -1109,13 +1138,12 @@ pub mod pallet { ensure!(amount > 0, Error::::ZeroAmount); - ensure!( - Self::is_active(&smart_contract), - Error::::NotOperatedDApp - ); + let dapp_info = + IntegratedDApps::::get(&smart_contract).ok_or(Error::::NotOperatedDApp)?; + ensure!(dapp_info.is_active(), Error::::NotOperatedDApp); let protocol_state = ActiveProtocolState::::get(); - let unstake_era = protocol_state.era; + let current_era = protocol_state.era; let mut ledger = Ledger::::get(&account); @@ -1142,7 +1170,7 @@ pub mod pallet { amount }; - staking_info.unstake(amount, protocol_state.subperiod()); + staking_info.unstake(amount, current_era, protocol_state.subperiod()); (staking_info, amount) } None => { @@ -1153,11 +1181,11 @@ pub mod pallet { // 2. // Reduce stake amount ledger - .unstake_amount(amount, unstake_era, protocol_state.period_info) + .unstake_amount(amount, current_era, protocol_state.period_info) .map_err(|err| match err { // These are all defensive checks, which should never happen since we already checked them above. AccountLedgerError::InvalidPeriod | AccountLedgerError::InvalidEra => { - Error::::UnclaimedRewardsFromPastPeriods + Error::::UnclaimedRewards } AccountLedgerError::UnstakeAmountLargerThanStake => { Error::::UnstakeAmountTooLarge @@ -1167,8 +1195,8 @@ pub mod pallet { // 3. // Update `ContractStake` storage with the reduced stake amount on the specified contract. - let mut contract_stake_info = ContractStake::::get(&smart_contract); - contract_stake_info.unstake(amount, protocol_state.period_info, unstake_era); + let mut contract_stake_info = ContractStake::::get(&dapp_info.id); + contract_stake_info.unstake(amount, protocol_state.period_info, current_era); // 4. // Update total staked amount for the next era. @@ -1178,7 +1206,7 @@ pub mod pallet { // 5. // Update remaining storage entries - ContractStake::::insert(&smart_contract, contract_stake_info); + ContractStake::::insert(&dapp_info.id, contract_stake_info); if new_staking_info.is_empty() { ledger.contract_stake_count.saturating_dec(); @@ -1199,8 +1227,7 @@ pub mod pallet { } /// Claims some staker rewards, if user has any. - /// In the case of a successfull call, at least one era will be claimed, with the possibility of multiple claims happening - /// if appropriate entries exist in account's ledger. + /// In the case of a successfull call, at least one era will be claimed, with the possibility of multiple claims happening. #[pallet::call_index(11)] #[pallet::weight(Weight::zero())] pub fn claim_staker_rewards(origin: OriginFor) -> DispatchResult { @@ -1379,7 +1406,7 @@ pub mod pallet { let (amount, tier_id) = dapp_tiers - .try_consume(dapp_info.id) + .try_claim(dapp_info.id) .map_err(|error| match error { DAppTierError::NoDAppInTiers => Error::::NoClaimableRewards, DAppTierError::RewardAlreadyClaimed => Error::::DAppRewardAlreadyClaimed, @@ -1406,13 +1433,13 @@ pub mod pallet { } /// Used to unstake funds from a contract that was unregistered after an account staked on it. + /// This is required if staker wants to re-stake these funds on another active contract during the ongoing period. #[pallet::call_index(14)] #[pallet::weight(Weight::zero())] pub fn unstake_from_unregistered( origin: OriginFor, smart_contract: T::SmartContract, ) -> DispatchResult { - // TODO: tests are missing but will be added later. Self::ensure_pallet_enabled()?; let account = ensure_signed(origin)?; @@ -1422,7 +1449,7 @@ pub mod pallet { ); let protocol_state = ActiveProtocolState::::get(); - let unstake_era = protocol_state.era; + let current_era = protocol_state.era; // Extract total staked amount on the specified unregistered contract let amount = match StakerInfo::::get(&account, &smart_contract) { @@ -1442,21 +1469,25 @@ pub mod pallet { // Reduce stake amount in ledger let mut ledger = Ledger::::get(&account); ledger - .unstake_amount(amount, unstake_era, protocol_state.period_info) + .unstake_amount(amount, current_era, protocol_state.period_info) .map_err(|err| match err { - // These are all defensive checks, which should never happen since we already checked them above. + // These are all defensive checks, which should never fail since we already checked them above. AccountLedgerError::InvalidPeriod | AccountLedgerError::InvalidEra => { - Error::::UnclaimedRewardsFromPastPeriods + Error::::UnclaimedRewards } _ => Error::::InternalUnstakeError, })?; // Update total staked amount for the next era. // This means 'fake' stake total amount has been kept until now, even though contract was unregistered. + // Although strange, it's been requested to keep it like this from the team. CurrentEraInfo::::mutate(|era_info| { era_info.unstake_amount(amount, protocol_state.subperiod()); }); + // TODO: HOWEVER, we should not pay out bonus rewards for such contracts. + // Seems wrong because it serves as discentive for unstaking & moving over to a new contract. + // Update remaining storage entries Self::update_ledger(&account, ledger); StakerInfo::::remove(&account, &smart_contract); @@ -1470,22 +1501,28 @@ pub mod pallet { Ok(()) } - // TODO: an alternative to this could would be to allow `unstake` call to cleanup old entries, however that means more complexity in that call - /// Used to unstake funds from a contract that was unregistered after an account staked on it. + /// Cleanup expired stake entries for the contract. + /// + /// Entry is considered to be expired if: + /// 1. It's from a past period & the account wasn't a loyal staker, meaning there's no claimable bonus reward. + /// 2. It's from a period older than the oldest claimable period, regardless whether the account was loyal or not. #[pallet::call_index(15)] #[pallet::weight(Weight::zero())] pub fn cleanup_expired_entries(origin: OriginFor) -> DispatchResult { - // TODO: tests are missing but will be added later. Self::ensure_pallet_enabled()?; let account = ensure_signed(origin)?; let protocol_state = ActiveProtocolState::::get(); let current_period = protocol_state.period_number(); + let threshold_period = Self::oldest_claimable_period(current_period); - // Find all entries which have expired. This is bounded by max allowed number of entries. + // Find all entries which are from past periods & don't have claimable bonus rewards. + // This is bounded by max allowed number of stake entries per account. let to_be_deleted: Vec = StakerInfo::::iter_prefix(&account) .filter_map(|(smart_contract, stake_info)| { - if stake_info.period_number() < current_period { + if stake_info.period_number() < current_period && !stake_info.is_loyal() + || stake_info.period_number() < threshold_period + { Some(smart_contract) } else { None @@ -1494,18 +1531,25 @@ pub mod pallet { .collect(); let entries_to_delete = to_be_deleted.len(); + ensure!(!entries_to_delete.is_zero(), Error::::NoExpiredEntries); + // Remove all expired entries. for smart_contract in to_be_deleted { StakerInfo::::remove(&account, &smart_contract); } - // Remove expired ledger stake entries, if needed. - let threshold_period = Self::oldest_claimable_period(current_period); + // Remove expired stake entries from the ledger. let mut ledger = Ledger::::get(&account); - ledger.contract_stake_count.saturating_reduce(entries_to_delete as u32); - if ledger.maybe_cleanup_expired(threshold_period) { - Self::update_ledger(&account, ledger); - } + ledger + .contract_stake_count + .saturating_reduce(entries_to_delete.unique_saturated_into()); + ledger.maybe_cleanup_expired(threshold_period); // Not necessary but we do it for the sake of consistency + Self::update_ledger(&account, ledger); + + Self::deposit_event(Event::::ExpiredEntriesRemoved { + account, + count: entries_to_delete.unique_saturated_into(), + }); Ok(()) } @@ -1519,8 +1563,7 @@ pub mod pallet { /// Can only be called by manager origin. #[pallet::call_index(16)] #[pallet::weight(Weight::zero())] - pub fn force(origin: OriginFor, force_type: ForcingType) -> DispatchResult { - // TODO: tests are missing but will be added later. + pub fn force(origin: OriginFor, forcing_type: ForcingType) -> DispatchResult { Self::ensure_pallet_enabled()?; T::ManagerOrigin::ensure_origin(origin)?; @@ -1529,7 +1572,7 @@ pub mod pallet { let current_block = frame_system::Pallet::::block_number(); state.next_era_start = current_block.saturating_add(One::one()); - match force_type { + match forcing_type { ForcingType::Era => (), ForcingType::Subperiod => { state.period_info.subperiod_end_era = state.era.saturating_add(1); @@ -1537,6 +1580,8 @@ pub mod pallet { } }); + Self::deposit_event(Event::::Force { forcing_type }); + Ok(()) } } @@ -1584,13 +1629,14 @@ pub mod pallet { /// Returns the number of blocks per voting period. pub(crate) fn blocks_per_voting_period() -> BlockNumberFor { - T::StandardEraLength::get().saturating_mul(T::StandardErasPerVotingPeriod::get().into()) + T::StandardEraLength::get() + .saturating_mul(T::StandardErasPerVotingSubperiod::get().into()) } /// `true` if smart contract is active, `false` if it has been unregistered. pub(crate) fn is_active(smart_contract: &T::SmartContract) -> bool { IntegratedDApps::::get(smart_contract) - .map_or(false, |dapp_info| dapp_info.state == DAppState::Registered) + .map_or(false, |dapp_info| dapp_info.is_active()) } /// Calculates the `EraRewardSpan` index for the specified era. @@ -1611,48 +1657,51 @@ pub mod pallet { /// Assign eligible dApps into appropriate tiers, and calculate reward for each tier. /// + /// ### Algorithm + /// + /// 1. Read in over all contract stake entries. In case staked amount is zero for the current era, ignore it. + /// This information is used to calculate 'score' per dApp, which is used to determine the tier. + /// + /// 2. Sort the entries by the score, in descending order - the top score dApp comes first. + /// + /// 3. Read in tier configuration. This contains information about how many slots per tier there are, + /// as well as the threshold for each tier. Threshold is the minimum amount of stake required to be eligible for a tier. + /// Iterate over tier thresholds & capacities, starting from the top tier, and assign dApps to them. + /// + /// ```ignore + //// for each tier: + /// for each unassigned dApp: + /// if tier has capacity && dApp satisfies the tier threshold: + /// add dapp to the tier + /// else: + /// exit loop since no more dApps will satisfy the threshold since they are sorted by score + /// ``` + /// + /// 4. Sort the entries by dApp ID, in ascending order. This is so we can efficiently search for them using binary search. + /// + /// 5. Calculate rewards for each tier. + /// This is done by dividing the total reward pool into tier reward pools, + /// after which the tier reward pool is divided by the number of available slots in the tier. + /// /// The returned object contains information about each dApp that made it into a tier. pub(crate) fn get_dapp_tier_assignment( era: EraNumber, period: PeriodNumber, dapp_reward_pool: Balance, ) -> DAppTierRewardsFor { - // TODO - by breaking this into multiple steps, if they are too heavy for a single block, we can distribute them between multiple blocks. - // Benchmarks will show this, but I don't believe it will be needed, especially with increased block capacity we'll get with async backing. - // Even without async backing though, we should have enough capacity to handle this. - // UPDATE: might work with async backing, but right now we could handle up to 150 dApps before exceeding the PoV size. - - // UPDATE2: instead of taking the approach of reading an ever increasing amount of entries from storage, we can instead adopt an approach - // of eficiently storing composite information into `BTreeMap`. The approach is essentially the same as the one used below to store rewards. - // Each time `stake` or `unstake` are called, corresponding entries are updated. This way we can keep track of all the contract stake in a single DB entry. - // To make the solution more scalable, we could 'split' stake entries into spans, similar as rewards are handled now. - // - // Experiment with an 'experimental' entry shows PoV size of ~7kB induced for entry that can hold up to 100 entries. - - let mut dapp_stakes = Vec::with_capacity(IntegratedDApps::::count() as usize); + let mut dapp_stakes = Vec::with_capacity(T::MaxNumberOfContracts::get() as usize); // 1. - // Iterate over all registered dApps, and collect their stake amount. + // Iterate over all staked dApps. // This is bounded by max amount of dApps we allow to be registered. - for (smart_contract, dapp_info) in IntegratedDApps::::iter() { - // Skip unregistered dApps - if dapp_info.state != DAppState::Registered { - continue; - } - - // Skip dApps which don't have ANY amount staked (TODO: potential improvement is to prune all dApps below minimum threshold) - let stake_amount = match ContractStake::::get(&smart_contract).get(era, period) { + for (dapp_id, stake_amount) in ContractStake::::iter() { + // Skip dApps which don't have ANY amount staked + let stake_amount = match stake_amount.get(era, period) { Some(stake_amount) if !stake_amount.total().is_zero() => stake_amount, _ => continue, }; - // TODO: Need to handle labels! - // Proposition for label handling: - // Split them into 'musts' and 'good-to-have' - // In case of 'must', reduce appropriate tier size, and insert them at the end - // For good to have, we can insert them immediately, and then see if we need to adjust them later. - // Anyhow, labels bring complexity. For starters, we should only deliver the one for 'bootstraping' purposes. - dapp_stakes.push((dapp_info.id, stake_amount.total())); + dapp_stakes.push((dapp_id, stake_amount.total())); } // 2. @@ -1695,24 +1744,30 @@ pub mod pallet { tier_id.saturating_inc(); } + // TODO: what if multiple dApps satisfy the tier entry threshold but there's not enough slots to accomodate them all? + // 4. - // Sort by dApp ID, in ascending order (unstable sort should be faster, and stability is guaranteed due to lack of duplicated Ids). - // TODO & Idea: perhaps use BTreeMap instead? It will "sort" automatically based on dApp Id, and we can efficiently remove entries, - // reducing PoV size step by step. - // It's a trade-off between speed and PoV size. Although both are quite minor, so maybe it doesn't matter that much. + // Sort by dApp ID, in ascending order (unstable sort should be faster, and stability is "guaranteed" due to lack of duplicated Ids). dapp_tiers.sort_unstable_by(|first, second| first.dapp_id.cmp(&second.dapp_id)); // 5. Calculate rewards. let tier_rewards = tier_config .reward_portion .iter() - .map(|percent| *percent * dapp_reward_pool) + .zip(tier_config.slots_per_tier.iter()) + .map(|(percent, slots)| { + if slots.is_zero() { + Zero::zero() + } else { + *percent * dapp_reward_pool / >::into(*slots) + } + }) .collect::>(); // 6. // Prepare and return tier & rewards info. // In case rewards creation fails, we just write the default value. This should never happen though. - DAppTierRewards::, T::NumberOfTiers>::new( + DAppTierRewards::::new( dapp_tiers, tier_rewards, period, diff --git a/pallets/dapp-staking-v3/src/test/mock.rs b/pallets/dapp-staking-v3/src/test/mock.rs index 6b1a16287f..73535acd36 100644 --- a/pallets/dapp-staking-v3/src/test/mock.rs +++ b/pallets/dapp-staking-v3/src/test/mock.rs @@ -16,24 +16,27 @@ // You should have received a copy of the GNU General Public License // along with Astar. If not, see . -use crate::{self as pallet_dapp_staking, *}; +use crate::{ + self as pallet_dapp_staking, + test::testing_utils::{assert_block_bump, MemorySnapshot}, + *, +}; use frame_support::{ construct_runtime, parameter_types, - traits::{ConstU128, ConstU16, ConstU32, ConstU64}, + traits::{ConstU128, ConstU32, ConstU64}, weights::Weight, }; use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; use sp_arithmetic::fixed_point::FixedU64; use sp_core::H256; +use sp_io::TestExternalities; use sp_runtime::{ testing::Header, traits::{BlakeTwo256, IdentityLookup}, Permill, }; -use sp_io::TestExternalities; - pub(crate) type AccountId = u64; pub(crate) type BlockNumber = u64; pub(crate) type Balance = u128; @@ -155,15 +158,15 @@ impl pallet_dapp_staking::Config for Test { type NativePriceProvider = DummyPriceProvider; type RewardPoolProvider = DummyRewardPoolProvider; type StandardEraLength = ConstU64<10>; - type StandardErasPerVotingPeriod = ConstU32<8>; - type StandardErasPerBuildAndEarnPeriod = ConstU32<16>; + type StandardErasPerVotingSubperiod = ConstU32<8>; + type StandardErasPerBuildAndEarnSubperiod = ConstU32<16>; type EraRewardSpanLength = ConstU32<8>; type RewardRetentionInPeriods = ConstU32<2>; - type MaxNumberOfContracts = ConstU16<10>; + type MaxNumberOfContracts = ConstU32<10>; type MaxUnlockingChunks = ConstU32<5>; type MinimumLockedAmount = ConstU128; type UnlockingPeriod = ConstU32<2>; - type MaxNumberOfStakedContracts = ConstU32<3>; + type MaxNumberOfStakedContracts = ConstU32<5>; type MinimumStakeAmount = ConstU128<3>; type NumberOfTiers = ConstU32<4>; #[cfg(feature = "runtime-benchmarks")] @@ -195,7 +198,7 @@ impl ExtBuilder { let era_length: BlockNumber = <::StandardEraLength as sp_core::Get<_>>::get(); let voting_period_length_in_eras: EraNumber = - <::StandardErasPerVotingPeriod as sp_core::Get<_>>::get( + <::StandardErasPerVotingSubperiod as sp_core::Get<_>>::get( ); // Init protocol state @@ -209,6 +212,23 @@ impl ExtBuilder { }, maintenance: false, }); + pallet_dapp_staking::CurrentEraInfo::::put(EraInfo { + total_locked: 0, + unlocking: 0, + current_stake_amount: StakeAmount { + voting: 0, + build_and_earn: 0, + era: 1, + period: 1, + }, + next_stake_amount: StakeAmount { + voting: 0, + build_and_earn: 0, + era: 2, + period: 1, + }, + + }); // Init tier params let tier_params = TierParameters::<::NumberOfTiers> { @@ -230,15 +250,15 @@ impl ExtBuilder { TierThreshold::DynamicTvlAmount { amount: 100, minimum_amount: 80 }, TierThreshold::DynamicTvlAmount { amount: 50, minimum_amount: 40 }, TierThreshold::DynamicTvlAmount { amount: 20, minimum_amount: 20 }, - TierThreshold::FixedTvlAmount { amount: 10 }, + TierThreshold::FixedTvlAmount { amount: 15 }, ]) .unwrap(), }; // Init tier config, based on the initial params let init_tier_config = TiersConfiguration::<::NumberOfTiers> { - number_of_slots: 100, - slots_per_tier: BoundedVec::try_from(vec![10, 20, 30, 40]).unwrap(), + number_of_slots: 40, + slots_per_tier: BoundedVec::try_from(vec![2, 5, 13, 20]).unwrap(), reward_portion: tier_params.reward_portion.clone(), tier_thresholds: tier_params.tier_thresholds.clone(), }; @@ -263,7 +283,10 @@ pub(crate) fn run_to_block(n: u64) { DappStaking::on_finalize(System::block_number()); System::set_block_number(System::block_number() + 1); // This is performed outside of dapps staking but we expect it before on_initialize + + let pre_snapshot = MemorySnapshot::new(); DappStaking::on_initialize(System::block_number()); + assert_block_bump(&pre_snapshot); } } @@ -304,7 +327,7 @@ pub(crate) fn advance_to_next_period() { } /// Advance blocks until next period type has been reached. -pub(crate) fn advance_to_advance_to_next_subperiod() { +pub(crate) fn advance_to_next_subperiod() { let subperiod = ActiveProtocolState::::get().subperiod(); while ActiveProtocolState::::get().subperiod() == subperiod { run_for_blocks(1); @@ -316,12 +339,6 @@ pub fn dapp_staking_events() -> Vec> { System::events() .into_iter() .map(|r| r.event) - .filter_map(|e| { - if let RuntimeEvent::DappStaking(inner) = e { - Some(inner) - } else { - None - } - }) - .collect() + .filter_map(|e| ::RuntimeEvent::from(e).try_into().ok()) + .collect::>() } diff --git a/pallets/dapp-staking-v3/src/test/testing_utils.rs b/pallets/dapp-staking-v3/src/test/testing_utils.rs index 3ac63253ab..14b3fa0bad 100644 --- a/pallets/dapp-staking-v3/src/test/testing_utils.rs +++ b/pallets/dapp-staking-v3/src/test/testing_utils.rs @@ -20,8 +20,8 @@ use crate::test::mock::*; use crate::types::*; use crate::{ pallet::Config, ActiveProtocolState, BlockNumberFor, ContractStake, CurrentEraInfo, DAppId, - DAppTiers, EraRewards, Event, IntegratedDApps, Ledger, NextDAppId, PeriodEnd, PeriodEndInfo, - StakerInfo, + DAppTiers, EraRewards, Event, IntegratedDApps, Ledger, NextDAppId, NextTierConfig, PeriodEnd, + PeriodEndInfo, StakerInfo, TierConfig, }; use frame_support::{assert_ok, traits::Get}; @@ -47,10 +47,12 @@ pub(crate) struct MemorySnapshot { ), SingularStakingInfo, >, - contract_stake: HashMap<::SmartContract, ContractStakeAmount>, + contract_stake: HashMap, era_rewards: HashMap::EraRewardSpanLength>>, period_end: HashMap, dapp_tiers: HashMap>, + tier_config: TiersConfiguration<::NumberOfTiers>, + next_tier_config: TiersConfiguration<::NumberOfTiers>, } impl MemorySnapshot { @@ -69,6 +71,8 @@ impl MemorySnapshot { era_rewards: EraRewards::::iter().collect(), period_end: PeriodEnd::::iter().collect(), dapp_tiers: DAppTiers::::iter().collect(), + tier_config: TierConfig::::get(), + next_tier_config: NextTierConfig::::get(), } } @@ -187,7 +191,9 @@ pub(crate) fn assert_unregister(smart_contract: &MockSmartContract) { IntegratedDApps::::get(&smart_contract).unwrap().state, DAppState::Unregistered(pre_snapshot.active_protocol_state.era), ); - assert!(!ContractStake::::contains_key(&smart_contract)); + assert!(!ContractStake::::contains_key( + &IntegratedDApps::::get(&smart_contract).unwrap().id + )); } /// Lock funds into dApp staking and assert success. @@ -223,11 +229,6 @@ pub(crate) fn assert_lock(account: AccountId, amount: Balance) { pre_snapshot.current_era_info.total_locked + expected_lock_amount, "Total locked balance should be increased by the amount locked." ); - assert_eq!( - post_snapshot.current_era_info.active_era_locked, - pre_snapshot.current_era_info.active_era_locked, - "Active era locked amount should remain exactly the same." - ); } /// Start the unlocking process for locked funds and assert success. @@ -309,12 +310,6 @@ pub(crate) fn assert_unlock(account: AccountId, amount: Balance) { .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. @@ -423,7 +418,6 @@ pub(crate) fn assert_stake( smart_contract: &MockSmartContract, amount: Balance, ) { - // TODO: this is a huge function - I could break it down, but I'm not sure it will help with readability. let pre_snapshot = MemorySnapshot::new(); let pre_ledger = pre_snapshot.ledger.get(&account).unwrap(); let pre_staker_info = pre_snapshot @@ -431,7 +425,7 @@ pub(crate) fn assert_stake( .get(&(account, smart_contract.clone())); let pre_contract_stake = pre_snapshot .contract_stake - .get(&smart_contract) + .get(&pre_snapshot.integrated_dapps[&smart_contract].id) .map_or(ContractStakeAmount::default(), |series| series.clone()); let pre_era_info = pre_snapshot.current_era_info; @@ -460,7 +454,7 @@ pub(crate) fn assert_stake( .expect("Entry must exist since 'stake' operation was successfull."); let post_contract_stake = post_snapshot .contract_stake - .get(&smart_contract) + .get(&pre_snapshot.integrated_dapps[&smart_contract].id) .expect("Entry must exist since 'stake' operation was successfull."); let post_era_info = post_snapshot.current_era_info; @@ -482,7 +476,6 @@ pub(crate) fn assert_stake( pre_ledger.stakeable_amount(stake_period) - amount, "Stakeable amount must decrease by the 'amount'" ); - // TODO: expand with more detailed checks of staked and staked_future // 2. verify staker info // ===================== @@ -580,11 +573,10 @@ pub(crate) fn assert_unstake( .expect("Entry must exist since 'unstake' is being called."); let pre_contract_stake = pre_snapshot .contract_stake - .get(&smart_contract) + .get(&pre_snapshot.integrated_dapps[&smart_contract].id) .expect("Entry must exist since 'unstake' is being called."); let pre_era_info = pre_snapshot.current_era_info; - let _unstake_era = pre_snapshot.active_protocol_state.era; let unstake_period = pre_snapshot.active_protocol_state.period_number(); let unstake_subperiod = pre_snapshot.active_protocol_state.subperiod(); @@ -616,8 +608,8 @@ pub(crate) fn assert_unstake( let post_ledger = post_snapshot.ledger.get(&account).unwrap(); let post_contract_stake = post_snapshot .contract_stake - .get(&smart_contract) - .expect("Entry must exist since 'stake' operation was successfull."); + .get(&pre_snapshot.integrated_dapps[&smart_contract].id) + .expect("Entry must exist since 'unstake' operation was successfull."); let post_era_info = post_snapshot.current_era_info; // 1. verify ledger @@ -633,7 +625,6 @@ pub(crate) fn assert_unstake( pre_ledger.stakeable_amount(unstake_period) + amount, "Stakeable amount must increase by the 'amount'" ); - // TODO: expand with more detailed checks of staked and staked_future // 2. verify staker info // ===================== @@ -709,6 +700,7 @@ pub(crate) fn assert_unstake( "Total staked amount for the next era must decrease by 'amount'. No overflow is allowed." ); + // Check for unstake underflow. if unstake_subperiod == Subperiod::BuildAndEarn && pre_era_info.staked_amount_next_era(Subperiod::BuildAndEarn) < amount { @@ -929,7 +921,7 @@ pub(crate) fn assert_claim_dapp_reward( .expect("Entry must exist.") .clone(); - info.try_consume(dapp_info.id).unwrap() + info.try_claim(dapp_info.id).unwrap() }; // Claim dApp reward & verify event @@ -969,12 +961,377 @@ pub(crate) fn assert_claim_dapp_reward( .expect("Entry must exist.") .clone(); assert_eq!( - info.try_consume(dapp_info.id), + info.try_claim(dapp_info.id), Err(DAppTierError::RewardAlreadyClaimed), "It must not be possible to claim the same reward twice!.", ); } +/// Unstake some funds from the specified unregistered smart contract. +pub(crate) fn assert_unstake_from_unregistered( + account: AccountId, + smart_contract: &MockSmartContract, +) { + let pre_snapshot = MemorySnapshot::new(); + let pre_ledger = pre_snapshot.ledger.get(&account).unwrap(); + let pre_staker_info = pre_snapshot + .staker_info + .get(&(account, smart_contract.clone())) + .expect("Entry must exist since 'unstake_from_unregistered' is being called."); + let pre_era_info = pre_snapshot.current_era_info; + + let amount = pre_staker_info.total_staked_amount(); + + // Unstake from smart contract & verify event + assert_ok!(DappStaking::unstake_from_unregistered( + RuntimeOrigin::signed(account), + smart_contract.clone(), + )); + System::assert_last_event(RuntimeEvent::DappStaking(Event::UnstakeFromUnregistered { + account, + smart_contract: smart_contract.clone(), + amount, + })); + + // Verify post-state + let post_snapshot = MemorySnapshot::new(); + let post_ledger = post_snapshot.ledger.get(&account).unwrap(); + let post_era_info = post_snapshot.current_era_info; + let period = pre_snapshot.active_protocol_state.period_number(); + let unstake_subperiod = pre_snapshot.active_protocol_state.subperiod(); + + // 1. verify ledger + // ===================== + // ===================== + assert_eq!( + post_ledger.staked_amount(period), + pre_ledger.staked_amount(period) - amount, + "Stake amount must decrease by the 'amount'" + ); + assert_eq!( + post_ledger.stakeable_amount(period), + pre_ledger.stakeable_amount(period) + amount, + "Stakeable amount must increase by the 'amount'" + ); + + // 2. verify staker info + // ===================== + // ===================== + assert!( + !StakerInfo::::contains_key(&account, smart_contract), + "Entry must be deleted since contract is unregistered." + ); + + // 3. verify era info + // ========================= + // ========================= + // It's possible next era has staked more than the current era. This is because 'stake' will always stake for the NEXT era. + if pre_era_info.total_staked_amount() < amount { + assert!(post_era_info.total_staked_amount().is_zero()); + } else { + assert_eq!( + post_era_info.total_staked_amount(), + pre_era_info.total_staked_amount() - amount, + "Total staked amount for the current era must decrease by 'amount'." + ); + } + assert_eq!( + post_era_info.total_staked_amount_next_era(), + pre_era_info.total_staked_amount_next_era() - amount, + "Total staked amount for the next era must decrease by 'amount'. No overflow is allowed." + ); + + // Check for unstake underflow. + if unstake_subperiod == Subperiod::BuildAndEarn + && pre_era_info.staked_amount_next_era(Subperiod::BuildAndEarn) < amount + { + let overflow = amount - pre_era_info.staked_amount_next_era(Subperiod::BuildAndEarn); + + assert!(post_era_info + .staked_amount_next_era(Subperiod::BuildAndEarn) + .is_zero()); + assert_eq!( + post_era_info.staked_amount_next_era(Subperiod::Voting), + pre_era_info.staked_amount_next_era(Subperiod::Voting) - overflow + ); + } else { + assert_eq!( + post_era_info.staked_amount_next_era(unstake_subperiod), + pre_era_info.staked_amount_next_era(unstake_subperiod) - amount + ); + } +} + +/// Cleanup expired DB entries for the account and verify post state. +pub(crate) fn assert_cleanup_expired_entries(account: AccountId) { + let pre_snapshot = MemorySnapshot::new(); + + let current_period = pre_snapshot.active_protocol_state.period_number(); + let threshold_period = DappStaking::oldest_claimable_period(current_period); + + // Find entries which should be kept, and which should be deleted + let mut to_be_deleted = Vec::new(); + let mut to_be_kept = Vec::new(); + pre_snapshot + .staker_info + .iter() + .for_each(|((inner_account, contract), entry)| { + if *inner_account == account { + if entry.period_number() < current_period && !entry.is_loyal() + || entry.period_number() < threshold_period + { + to_be_deleted.push(contract); + } else { + to_be_kept.push(contract); + } + } + }); + + // Cleanup expired entries and verify event + assert_ok!(DappStaking::cleanup_expired_entries(RuntimeOrigin::signed( + account + ))); + System::assert_last_event(RuntimeEvent::DappStaking(Event::ExpiredEntriesRemoved { + account, + count: to_be_deleted.len().try_into().unwrap(), + })); + + // Verify post-state + let post_snapshot = MemorySnapshot::new(); + + // Ensure that correct entries have been kept + assert_eq!(post_snapshot.staker_info.len(), to_be_kept.len()); + to_be_kept.iter().for_each(|contract| { + assert!(post_snapshot + .staker_info + .contains_key(&(account, **contract))); + }); + + // Ensure that ledger has been correctly updated + let pre_ledger = pre_snapshot.ledger.get(&account).unwrap(); + let post_ledger = post_snapshot.ledger.get(&account).unwrap(); + + let num_of_deleted_entries: u32 = to_be_deleted.len().try_into().unwrap(); + assert_eq!( + pre_ledger.contract_stake_count - num_of_deleted_entries, + post_ledger.contract_stake_count + ); +} + +/// Asserts correct transitions of the protocol after a block has been produced. +pub(crate) fn assert_block_bump(pre_snapshot: &MemorySnapshot) { + let current_block_number = System::block_number(); + + // No checks if era didn't change. + if pre_snapshot.active_protocol_state.next_era_start > current_block_number { + return; + } + + // Verify post state + let post_snapshot = MemorySnapshot::new(); + + let is_new_subperiod = pre_snapshot + .active_protocol_state + .period_info + .subperiod_end_era + <= post_snapshot.active_protocol_state.era; + + // 1. Verify protocol state + let pre_protoc_state = pre_snapshot.active_protocol_state; + let post_protoc_state = post_snapshot.active_protocol_state; + assert_eq!(post_protoc_state.era, pre_protoc_state.era + 1); + + match pre_protoc_state.subperiod() { + Subperiod::Voting => { + assert_eq!( + post_protoc_state.subperiod(), + Subperiod::BuildAndEarn, + "Voting subperiod only lasts for a single era." + ); + + let eras_per_bep: EraNumber = + ::StandardErasPerBuildAndEarnSubperiod::get(); + assert_eq!( + post_protoc_state.period_info.subperiod_end_era, + post_protoc_state.era + eras_per_bep, + "Build&earn must last for the predefined amount of standard eras." + ); + + let standard_era_length: BlockNumber = ::StandardEraLength::get(); + assert_eq!( + post_protoc_state.next_era_start, + current_block_number + standard_era_length, + "Era in build&earn period must last for the predefined amount of blocks." + ); + } + Subperiod::BuildAndEarn => { + if is_new_subperiod { + assert_eq!( + post_protoc_state.subperiod(), + Subperiod::Voting, + "Since we expect a new subperiod, it must be 'Voting'." + ); + assert_eq!( + post_protoc_state.period_number(), + pre_protoc_state.period_number() + 1, + "Ending 'Build&Earn' triggers a new period." + ); + assert_eq!( + post_protoc_state.period_info.subperiod_end_era, + post_protoc_state.era + 1, + "Voting era must last for a single era." + ); + + let blocks_per_standard_era: BlockNumber = + ::StandardEraLength::get(); + let eras_per_voting_subperiod: EraNumber = + ::StandardErasPerVotingSubperiod::get(); + let eras_per_voting_subperiod: BlockNumber = eras_per_voting_subperiod.into(); + let era_length: BlockNumber = blocks_per_standard_era * eras_per_voting_subperiod; + assert_eq!( + post_protoc_state.next_era_start, + current_block_number + era_length, + "The upcoming 'Voting' subperiod must last for the 'standard eras per voting subperiod x standard era length' amount of blocks." + ); + } else { + assert_eq!( + post_protoc_state.period_info, pre_protoc_state.period_info, + "New subperiod hasn't started, hence it should remain 'Build&Earn'." + ); + } + } + } + + // 2. Verify current era info + let pre_era_info = pre_snapshot.current_era_info; + let post_era_info = post_snapshot.current_era_info; + + assert_eq!(post_era_info.total_locked, pre_era_info.total_locked); + assert_eq!(post_era_info.unlocking, pre_era_info.unlocking); + + // New period has started + if is_new_subperiod && pre_protoc_state.subperiod() == Subperiod::BuildAndEarn { + assert_eq!( + post_era_info.current_stake_amount, + StakeAmount { + voting: Zero::zero(), + build_and_earn: Zero::zero(), + era: pre_protoc_state.era + 1, + period: pre_protoc_state.period_number() + 1, + } + ); + assert_eq!( + post_era_info.next_stake_amount, + StakeAmount { + voting: Zero::zero(), + build_and_earn: Zero::zero(), + era: pre_protoc_state.era + 2, + period: pre_protoc_state.period_number() + 1, + } + ); + } else { + assert_eq!( + post_era_info.current_stake_amount, + pre_era_info.next_stake_amount + ); + assert_eq!( + post_era_info.next_stake_amount.total(), + post_era_info.current_stake_amount.total() + ); + assert_eq!( + post_era_info.next_stake_amount.era, + post_protoc_state.era + 1, + ); + assert_eq!( + post_era_info.next_stake_amount.period, + pre_protoc_state.period_number(), + ); + } + + // 3. Verify tier config + match pre_protoc_state.subperiod() { + Subperiod::Voting => { + assert!(!NextTierConfig::::exists()); + assert_eq!(post_snapshot.tier_config, pre_snapshot.next_tier_config); + } + Subperiod::BuildAndEarn if is_new_subperiod => { + assert!(NextTierConfig::::exists()); + assert_eq!(post_snapshot.tier_config, pre_snapshot.tier_config); + } + _ => { + assert_eq!(post_snapshot.tier_config, pre_snapshot.tier_config); + } + } + + // 4. Verify era reward + let era_span_index = DappStaking::era_reward_span_index(pre_protoc_state.era); + let maybe_pre_era_reward_span = pre_snapshot.era_rewards.get(&era_span_index); + let post_era_reward_span = post_snapshot + .era_rewards + .get(&era_span_index) + .expect("Era reward info must exist after era has finished."); + + // Sanity check + if let Some(pre_era_reward_span) = maybe_pre_era_reward_span { + assert_eq!( + pre_era_reward_span.last_era(), + pre_protoc_state.era - 1, + "If entry exists, it should cover eras up to the previous one, exactly." + ); + } + + assert_eq!( + post_era_reward_span.last_era(), + pre_protoc_state.era, + "Entry must cover the current era." + ); + assert_eq!( + post_era_reward_span + .get(pre_protoc_state.era) + .expect("Above check proved it must exist.") + .staked, + pre_snapshot.current_era_info.total_staked_amount(), + "Total staked amount must be equal to total amount staked at the end of the era." + ); + + // 5. Verify period end + if is_new_subperiod && pre_protoc_state.subperiod() == Subperiod::BuildAndEarn { + let period_end_info = post_snapshot.period_end[&pre_protoc_state.period_number()]; + assert_eq!( + period_end_info.total_vp_stake, + pre_snapshot + .current_era_info + .staked_amount(Subperiod::Voting), + ); + } + + // 6. Verify event(s) + if is_new_subperiod { + let events = dapp_staking_events(); + assert!( + events.len() >= 2, + "At least 2 events should exist from era & subperiod change." + ); + assert_eq!( + events[events.len() - 2], + Event::NewEra { + era: post_protoc_state.era, + } + ); + assert_eq!( + events[events.len() - 1], + Event::NewSubperiod { + subperiod: pre_protoc_state.subperiod().next(), + number: post_protoc_state.period_number(), + } + ) + } else { + System::assert_last_event(RuntimeEvent::DappStaking(Event::NewEra { + era: post_protoc_state.era, + })); + } +} + /// Returns from which starting era to which ending era can rewards be claimed for the specified account. /// /// If `None` is returned, there is nothing to claim. diff --git a/pallets/dapp-staking-v3/src/test/tests.rs b/pallets/dapp-staking-v3/src/test/tests.rs index 996a56f1e9..0795bd0653 100644 --- a/pallets/dapp-staking-v3/src/test/tests.rs +++ b/pallets/dapp-staking-v3/src/test/tests.rs @@ -16,26 +16,27 @@ // You should have received a copy of the GNU General Public License // along with Astar. If not, see . -use crate::test::mock::*; -use crate::test::testing_utils::*; +use crate::test::{mock::*, testing_utils::*}; use crate::{ - pallet::Config, ActiveProtocolState, DAppId, EraNumber, EraRewards, Error, ForcingType, - IntegratedDApps, Ledger, NextDAppId, PeriodNumber, Subperiod, + pallet::Config, ActiveProtocolState, DAppId, EraNumber, EraRewards, Error, Event, ForcingType, + IntegratedDApps, Ledger, NextDAppId, PeriodNumber, StakerInfo, Subperiod, TierConfig, }; -use frame_support::{assert_noop, assert_ok, error::BadOrigin, traits::Get}; +use frame_support::{ + assert_noop, assert_ok, assert_storage_noop, + error::BadOrigin, + traits::{Currency, Get, OnFinalize, OnInitialize}, +}; use sp_runtime::traits::Zero; -// TODO: test scenarios -// 1. user is staking, period passes, they can unlock their funds which were previously staked - +// TODO: remove this prior to the merge #[test] fn print_test() { ExtBuilder::build().execute_with(|| { use crate::dsv3_weight::WeightInfo; println!( ">>> dApp tier assignment reading & calculation {:?}", - crate::dsv3_weight::SubstrateWeight::::dapp_tier_assignment(200) + crate::dsv3_weight::SubstrateWeight::::dapp_tier_assignment(100) ); use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; @@ -61,8 +62,8 @@ fn print_test() { ); println!( - ">>> Experimental storage entry read {:?}", - crate::dsv3_weight::SubstrateWeight::::experimental_read() + ">>> Max encoded size of ContractStake: {:?}", + crate::ContractStakeAmount::max_encoded_len() ); }) } @@ -75,11 +76,17 @@ fn maintenace_mode_works() { // Enable maintenance mode & check post-state assert_ok!(DappStaking::maintenance_mode(RuntimeOrigin::root(), true)); + System::assert_last_event(RuntimeEvent::DappStaking(Event::MaintenanceMode { + enabled: true, + })); assert!(ActiveProtocolState::::get().maintenance); // Call still works, even in maintenance mode - assert_ok!(DappStaking::maintenance_mode(RuntimeOrigin::root(), true)); - assert!(ActiveProtocolState::::get().maintenance); + assert_ok!(DappStaking::maintenance_mode(RuntimeOrigin::root(), false)); + System::assert_last_event(RuntimeEvent::DappStaking(Event::MaintenanceMode { + enabled: false, + })); + assert!(!ActiveProtocolState::::get().maintenance); // Incorrect origin doesn't work assert_noop!( @@ -175,10 +182,27 @@ fn maintenace_mode_call_filtering_works() { } #[test] -fn on_initialize_state_change_works() { +fn on_initialize_is_noop_if_no_era_change() { ExtBuilder::build().execute_with(|| { - // TODO: test `EraInfo` change and verify events. This would be good to do each time we call the helper functions to go to next era or period. + let protocol_state = ActiveProtocolState::::get(); + let current_block_number = System::block_number(); + + assert!( + current_block_number < protocol_state.next_era_start, + "Sanity check, otherwise test doesn't make sense." + ); + // Sanity check + assert_storage_noop!(DappStaking::on_finalize(current_block_number)); + + // If no era change, on_initialize should be a noop + assert_storage_noop!(DappStaking::on_initialize(current_block_number + 1)); + }) +} + +#[test] +fn on_initialize_base_state_change_works() { + ExtBuilder::build().execute_with(|| { // Sanity check let protocol_state = ActiveProtocolState::::get(); assert_eq!(protocol_state.era, 1); @@ -211,7 +235,7 @@ fn on_initialize_state_change_works() { // Advance eras just until we reach the next voting period let eras_per_bep_period: EraNumber = - ::StandardErasPerBuildAndEarnPeriod::get(); + ::StandardErasPerBuildAndEarnSubperiod::get(); let blocks_per_era: BlockNumber = ::StandardEraLength::get(); for era in 2..(2 + eras_per_bep_period - 1) { let pre_block = System::block_number(); @@ -666,7 +690,7 @@ fn unlock_with_exceeding_unlocking_chunks_storage_limits_fails() { assert_unlock(account, unlock_amount); } - // We can still unlock in the current erblocka, theoretically + // We can still unlock in the current era, theoretically for _ in 0..5 { assert_unlock(account, unlock_amount); } @@ -834,10 +858,44 @@ fn stake_basic_example_is_ok() { let lock_amount = 300; assert_lock(account, lock_amount); - // Stake some amount, and then some more - let (stake_amount_1, stake_amount_2) = (31, 29); + // Stake some amount, and then some more in the same era. + let (stake_1, stake_2) = (31, 29); + assert_stake(account, &smart_contract, stake_1); + assert_stake(account, &smart_contract, stake_2); + }) +} + +#[test] +fn stake_after_expiry_is_ok() { + ExtBuilder::build().execute_with(|| { + // Register smart contract + let dev_account = 1; + let smart_contract = MockSmartContract::default(); + assert_register(dev_account, &smart_contract); + + // Lock & stake some amount + let account = 2; + let lock_amount = 300; + let (stake_amount_1, stake_amount_2) = (200, 100); + assert_lock(account, lock_amount); assert_stake(account, &smart_contract, stake_amount_1); + + // Advance so far that the stake rewards expire. + let reward_retention_in_periods: PeriodNumber = + ::RewardRetentionInPeriods::get(); + advance_to_period( + ActiveProtocolState::::get().period_number() + reward_retention_in_periods + 1, + ); + + // Sanity check that the rewards have expired + assert_noop!( + DappStaking::claim_staker_rewards(RuntimeOrigin::signed(account)), + Error::::RewardExpired, + ); + + // Calling stake again should work, expired stake entries should be cleaned up assert_stake(account, &smart_contract, stake_amount_2); + assert_stake(account, &smart_contract, stake_amount_1); }) } @@ -904,7 +962,7 @@ fn stake_in_final_era_fails() { } #[test] -fn stake_fails_if_unclaimed_rewards_from_past_period_remain() { +fn stake_fails_if_unclaimed_rewards_from_past_eras_remain() { ExtBuilder::build().execute_with(|| { // Register smart contract & lock some amount let smart_contract = MockSmartContract::default(); @@ -917,7 +975,7 @@ fn stake_fails_if_unclaimed_rewards_from_past_period_remain() { advance_to_next_period(); assert_noop!( DappStaking::stake(RuntimeOrigin::signed(account), smart_contract, 100), - Error::::UnclaimedRewardsFromPastPeriods + Error::::UnclaimedRewards ); }) } @@ -992,7 +1050,53 @@ fn stake_fails_due_to_too_small_staking_amount() { }) } -// TODO: add tests to cover staking & unstaking with unclaimed rewards! +#[test] +fn stake_fails_due_to_too_many_staked_contracts() { + ExtBuilder::build().execute_with(|| { + let max_number_of_contracts: u32 = ::MaxNumberOfStakedContracts::get(); + + // Lock amount by staker + let account = 1; + assert_lock(account, 100 as Balance * max_number_of_contracts as Balance); + + // Advance to build&earn subperiod so we ensure non-loyal staking + advance_to_next_subperiod(); + + // Register smart contracts up the the max allowed number + for id in 1..=max_number_of_contracts { + let smart_contract = MockSmartContract::Wasm(id.into()); + assert_register(2, &MockSmartContract::Wasm(id.into())); + assert_stake(account, &smart_contract, 10); + } + + let excess_smart_contract = MockSmartContract::Wasm((max_number_of_contracts + 1).into()); + assert_register(2, &excess_smart_contract); + + // Max number of staked contract entries has been exceeded. + assert_noop!( + DappStaking::stake( + RuntimeOrigin::signed(account), + excess_smart_contract.clone(), + 10 + ), + Error::::TooManyStakedContracts + ); + + // Advance into next period, error should still happen + advance_to_next_period(); + for _ in 0..required_number_of_reward_claims(account) { + assert_claim_staker_rewards(account); + } + assert_noop!( + DappStaking::stake( + RuntimeOrigin::signed(account), + excess_smart_contract.clone(), + 10 + ), + Error::::TooManyStakedContracts + ); + }) +} #[test] fn unstake_basic_example_is_ok() { @@ -1013,8 +1117,6 @@ fn unstake_basic_example_is_ok() { // Unstake some amount, in the current era. let unstake_amount_1 = 3; assert_unstake(account, &smart_contract, unstake_amount_1); - - // TODO: scenario where we unstake AFTER advancing an era and claiming rewards }) } @@ -1140,33 +1242,26 @@ fn unstake_from_non_staked_contract_fails() { } #[test] -fn unstake_from_a_contract_staked_in_past_period_fails() { +fn unstake_with_unclaimed_rewards_fails() { ExtBuilder::build().execute_with(|| { - // Register smart contract & lock some amount - let smart_contract_1 = MockSmartContract::Wasm(1); - let smart_contract_2 = MockSmartContract::Wasm(2); - assert_register(1, &smart_contract_1); - assert_register(1, &smart_contract_2); + // Register smart contract, lock&stake some amount + let smart_contract = MockSmartContract::Wasm(1); + assert_register(1, &smart_contract); let account = 2; assert_lock(account, 300); - - // Stake some amount on the 2nd contract. let stake_amount = 100; - assert_stake(account, &smart_contract_2, stake_amount); + assert_stake(account, &smart_contract, stake_amount); - // Advance to the next period, and stake on the 1st contract. - advance_to_next_period(); - // TODO: need to implement reward claiming for this check to work! - // assert_stake(account, &smart_contract_1, stake_amount); - // Try to unstake from the 2nd contract, which is no longer staked on due to period change. - // assert_noop!( - // DappStaking::unstake( - // RuntimeOrigin::signed(account), - // smart_contract_2, - // 1, - // ), - // Error::::UnstakeFromPastPeriod - // ); + // Advance 1 era, try to unstake and it should work since we're modifying the current era stake. + advance_to_next_era(); + assert_unstake(account, &smart_contract, 1); + + // Advance 1 more era, creating claimable rewards. Unstake should fail now. + advance_to_next_era(); + assert_noop!( + DappStaking::unstake(RuntimeOrigin::signed(account), smart_contract, 1), + Error::::UnclaimedRewards + ); }) } @@ -1314,7 +1409,7 @@ fn claim_staker_rewards_after_expiry_fails() { advance_to_period( ActiveProtocolState::::get().period_number() + reward_retention_in_periods, ); - advance_to_advance_to_next_subperiod(); + advance_to_next_subperiod(); advance_to_era( ActiveProtocolState::::get() .period_info @@ -1444,7 +1539,7 @@ fn claim_bonus_reward_with_only_build_and_earn_stake_fails() { assert_lock(account, lock_amount); // Stake in Build&Earn period type, advance to next era and try to claim bonus reward - advance_to_advance_to_next_subperiod(); + advance_to_next_subperiod(); assert_eq!( ActiveProtocolState::::get().subperiod(), Subperiod::BuildAndEarn, @@ -1637,3 +1732,614 @@ fn claim_dapp_reward_twice_for_same_era_fails() { assert_claim_dapp_reward(account, &smart_contract, claim_era_2); }) } + +#[test] +fn unstake_from_unregistered_is_ok() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let smart_contract = MockSmartContract::default(); + assert_register(1, &smart_contract); + + let account = 2; + let amount = 300; + assert_lock(account, amount); + assert_stake(account, &smart_contract, amount); + + // Unregister the smart contract, and unstake from it. + assert_unregister(&smart_contract); + assert_unstake_from_unregistered(account, &smart_contract); + }) +} + +#[test] +fn unstake_from_unregistered_fails_for_active_contract() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let smart_contract = MockSmartContract::default(); + assert_register(1, &smart_contract); + + let account = 2; + let amount = 300; + assert_lock(account, amount); + assert_stake(account, &smart_contract, amount); + + assert_noop!( + DappStaking::unstake_from_unregistered(RuntimeOrigin::signed(account), smart_contract), + Error::::ContractStillActive + ); + }) +} + +#[test] +fn unstake_from_unregistered_fails_for_not_staked_contract() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let smart_contract = MockSmartContract::default(); + assert_register(1, &smart_contract); + assert_unregister(&smart_contract); + + assert_noop!( + DappStaking::unstake_from_unregistered(RuntimeOrigin::signed(2), smart_contract), + Error::::NoStakingInfo + ); + }) +} + +#[test] +fn unstake_from_unregistered_fails_for_past_period() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let smart_contract = MockSmartContract::default(); + assert_register(1, &smart_contract); + + let account = 2; + let amount = 300; + assert_lock(account, amount); + assert_stake(account, &smart_contract, amount); + + // Unregister smart contract & advance to next period + assert_unregister(&smart_contract); + advance_to_next_period(); + + assert_noop!( + DappStaking::unstake_from_unregistered(RuntimeOrigin::signed(account), smart_contract), + Error::::UnstakeFromPastPeriod + ); + }) +} + +#[test] +fn cleanup_expired_entries_is_ok() { + ExtBuilder::build().execute_with(|| { + // Register smart contracts + let contracts: Vec<_> = (1..=5).map(|id| MockSmartContract::Wasm(id)).collect(); + contracts.iter().for_each(|smart_contract| { + assert_register(1, smart_contract); + }); + let account = 2; + assert_lock(account, 1000); + + // Scenario: + // - 1st contract will be staked in the period that expires due to exceeded reward retention + // - 2nd contract will be staked in the period on the edge of expiry, with loyalty flag + // - 3rd contract will be be staked in the period on the edge of expiry, without loyalty flag + // - 4th contract will be staked in the period right before the current one, with loyalty flag + // - 5th contract will be staked in the period right before the current one, without loyalty flag + // + // Expectation: 1, 3, 5 should be removed, 2 & 4 should remain + + // 1st + assert_stake(account, &contracts[0], 13); + + // 2nd & 3rd + advance_to_next_period(); + for _ in 0..required_number_of_reward_claims(account) { + assert_claim_staker_rewards(account); + } + assert_stake(account, &contracts[1], 17); + advance_to_next_subperiod(); + + assert_stake(account, &contracts[2], 19); + + // 4th & 5th + let reward_retention_in_periods: PeriodNumber = + ::RewardRetentionInPeriods::get(); + assert!( + reward_retention_in_periods >= 2, + "Sanity check, otherwise the test doesn't make sense." + ); + advance_to_period(reward_retention_in_periods + 1); + for _ in 0..required_number_of_reward_claims(account) { + assert_claim_staker_rewards(account); + } + assert_stake(account, &contracts[3], 23); + advance_to_next_subperiod(); + assert_stake(account, &contracts[4], 29); + + // Finally do the test + advance_to_next_period(); + assert_cleanup_expired_entries(account); + + // Additional sanity check according to the described scenario + assert!(!StakerInfo::::contains_key(account, &contracts[0])); + assert!(!StakerInfo::::contains_key(account, &contracts[2])); + assert!(!StakerInfo::::contains_key(account, &contracts[4])); + + assert!(StakerInfo::::contains_key(account, &contracts[1])); + assert!(StakerInfo::::contains_key(account, &contracts[3])); + }) +} + +#[test] +fn cleanup_expired_entries_fails_with_no_entries() { + ExtBuilder::build().execute_with(|| { + // Register smart contracts + let (contract_1, contract_2) = (MockSmartContract::Wasm(1), MockSmartContract::Wasm(2)); + assert_register(1, &contract_1); + assert_register(1, &contract_2); + + let account = 2; + assert_lock(account, 1000); + assert_stake(account, &contract_1, 13); + assert_stake(account, &contract_2, 17); + + // Advance only one period, rewards should still be valid. + let reward_retention_in_periods: PeriodNumber = + ::RewardRetentionInPeriods::get(); + assert!( + reward_retention_in_periods >= 1, + "Sanity check, otherwise the test doesn't make sense." + ); + advance_to_next_period(); + + assert_noop!( + DappStaking::cleanup_expired_entries(RuntimeOrigin::signed(account)), + Error::::NoExpiredEntries + ); + }) +} + +#[test] +fn force_era_works() { + ExtBuilder::build().execute_with(|| { + // 1. Force new era in the voting subperiod + let init_state = ActiveProtocolState::::get(); + assert!( + init_state.next_era_start > System::block_number() + 1, + "Sanity check, new era cannot start in next block, otherwise the test doesn't guarantee it tests what's expected." + ); + assert_eq!( + init_state.subperiod(), + Subperiod::Voting, + "Sanity check." + ); + assert_ok!(DappStaking::force(RuntimeOrigin::root(), ForcingType::Era)); + System::assert_last_event(RuntimeEvent::DappStaking(Event::Force { + forcing_type: ForcingType::Era, + })); + + // Verify state change + assert_eq!( + ActiveProtocolState::::get().next_era_start, + System::block_number() + 1, + ); + assert_eq!( + ActiveProtocolState::::get().period_end_era(), + init_state.period_end_era(), + ); + + // Go to the next block, and ensure new era is started + run_for_blocks(1); + assert_eq!( + ActiveProtocolState::::get().era, + init_state.era + 1, + "New era must be started." + ); + assert_eq!( + ActiveProtocolState::::get().subperiod(), + Subperiod::BuildAndEarn, + ); + + // 2. Force new era in the build&earn subperiod + let init_state = ActiveProtocolState::::get(); + assert!( + init_state.next_era_start > System::block_number() + 1, + "Sanity check, new era cannot start in next block, otherwise the test doesn't guarantee it tests what's expected." + ); + assert!(init_state.period_end_era() > init_state.era + 1, "Sanity check, otherwise the test doesn't guarantee it tests what's expected."); + assert_ok!(DappStaking::force(RuntimeOrigin::root(), ForcingType::Era)); + System::assert_last_event(RuntimeEvent::DappStaking(Event::Force { + forcing_type: ForcingType::Era, + })); + + // Verify state change + assert_eq!( + ActiveProtocolState::::get().next_era_start, + System::block_number() + 1, + ); + assert_eq!( + ActiveProtocolState::::get().period_end_era(), + init_state.period_end_era(), + "Only era is bumped, but we don't expect to switch over to the next subperiod." + ); + + run_for_blocks(1); + assert_eq!( + ActiveProtocolState::::get().era, + init_state.era + 1, + "New era must be started." + ); + assert_eq!( + ActiveProtocolState::::get().subperiod(), + Subperiod::BuildAndEarn, + "We're expected to remain in the same subperiod." + ); + }) +} + +#[test] +fn force_subperiod_works() { + ExtBuilder::build().execute_with(|| { + // 1. Force new subperiod in the voting subperiod + let init_state = ActiveProtocolState::::get(); + assert!( + init_state.next_era_start > System::block_number() + 1, + "Sanity check, new era cannot start in next block, otherwise the test doesn't guarantee it tests what's expected." + ); + assert_eq!( + init_state.subperiod(), + Subperiod::Voting, + "Sanity check." + ); + assert_ok!(DappStaking::force(RuntimeOrigin::root(), ForcingType::Subperiod)); + System::assert_last_event(RuntimeEvent::DappStaking(Event::Force { + forcing_type: ForcingType::Subperiod, + })); + + // Verify state change + assert_eq!( + ActiveProtocolState::::get().next_era_start, + System::block_number() + 1, + ); + assert_eq!( + ActiveProtocolState::::get().period_end_era(), + init_state.era + 1, + "The switch to the next subperiod must happen in the next era." + ); + + // Go to the next block, and ensure new era is started + run_for_blocks(1); + assert_eq!( + ActiveProtocolState::::get().era, + init_state.era + 1, + "New era must be started." + ); + assert_eq!( + ActiveProtocolState::::get().subperiod(), + Subperiod::BuildAndEarn, + "New subperiod must be started." + ); + assert_eq!(ActiveProtocolState::::get().period_number(), init_state.period_number(), "Period must remain the same."); + + // 2. Force new era in the build&earn subperiod + let init_state = ActiveProtocolState::::get(); + assert!( + init_state.next_era_start > System::block_number() + 1, + "Sanity check, new era cannot start in next block, otherwise the test doesn't guarantee it tests what's expected." + ); + assert!(init_state.period_end_era() > init_state.era + 1, "Sanity check, otherwise the test doesn't guarantee it tests what's expected."); + assert_ok!(DappStaking::force(RuntimeOrigin::root(), ForcingType::Subperiod)); + System::assert_last_event(RuntimeEvent::DappStaking(Event::Force { + forcing_type: ForcingType::Subperiod, + })); + + // Verify state change + assert_eq!( + ActiveProtocolState::::get().next_era_start, + System::block_number() + 1, + ); + assert_eq!( + ActiveProtocolState::::get().period_end_era(), + init_state.era + 1, + "The switch to the next subperiod must happen in the next era." + ); + + run_for_blocks(1); + assert_eq!( + ActiveProtocolState::::get().era, + init_state.era + 1, + "New era must be started." + ); + assert_eq!( + ActiveProtocolState::::get().subperiod(), + Subperiod::Voting, + "New subperiod must be started." + ); + assert_eq!(ActiveProtocolState::::get().period_number(), init_state.period_number() + 1, "New period must be started."); + }) +} + +#[test] +fn force_with_incorrect_origin_fails() { + ExtBuilder::build().execute_with(|| { + assert_noop!( + DappStaking::force(RuntimeOrigin::signed(1), ForcingType::Era), + BadOrigin + ); + }) +} + +#[test] +fn get_dapp_tier_assignment_basic_example_works() { + ExtBuilder::build().execute_with(|| { + // This test will rely on the configuration inside the mock file. + // If that changes, this test will have to be updated as well. + + // Scenario: + // - 1st tier is filled up, with one dApp satisfying the threshold but not making it due to lack of tier capacity + // - 2nd tier has 2 dApps - 1 that could make it into the 1st tier and one that's supposed to be in the 2nd tier + // - 3rd tier has no dApps + // - 4th tier has 2 dApps + // - 1 dApp doesn't make it into any tier + + // Register smart contracts + let tier_config = TierConfig::::get(); + let number_of_smart_contracts = tier_config.slots_per_tier[0] + 1 + 1 + 0 + 2 + 1; + let smart_contracts: Vec<_> = (1..=number_of_smart_contracts) + .map(|x| { + let smart_contract = MockSmartContract::Wasm(x.into()); + assert_register(x.into(), &smart_contract); + smart_contract + }) + .collect(); + let mut dapp_index: usize = 0; + + fn lock_and_stake(account: usize, smart_contract: &MockSmartContract, amount: Balance) { + let account = account.try_into().unwrap(); + Balances::make_free_balance_be(&account, amount); + assert_lock(account, amount); + assert_stake(account, smart_contract, amount); + } + + // 1st tier is completely filled up, with 1 more dApp not making it inside + for x in 0..tier_config.slots_per_tier[0] as Balance { + lock_and_stake( + dapp_index, + &smart_contracts[dapp_index], + tier_config.tier_thresholds[0].threshold() + x + 1, + ); + dapp_index += 1; + } + // One that won't make it into the 1st tier. + lock_and_stake( + dapp_index, + &smart_contracts[dapp_index], + tier_config.tier_thresholds[0].threshold(), + ); + dapp_index += 1; + + // 2nd tier - 1 dedicated dApp + lock_and_stake( + dapp_index, + &smart_contracts[dapp_index], + tier_config.tier_thresholds[0].threshold() - 1, + ); + dapp_index += 1; + + // 3rd tier is empty + // 4th tier has 2 dApps + for x in 0..2 { + lock_and_stake( + dapp_index, + &smart_contracts[dapp_index], + tier_config.tier_thresholds[3].threshold() + x, + ); + dapp_index += 1; + } + + // One dApp doesn't make it into any tier + lock_and_stake( + dapp_index, + &smart_contracts[dapp_index], + tier_config.tier_thresholds[3].threshold() - 1, + ); + + // Finally, the actual test + let protocol_state = ActiveProtocolState::::get(); + let dapp_reward_pool = 1000000; + let tier_assignment = DappStaking::get_dapp_tier_assignment( + protocol_state.era + 1, + protocol_state.period_number(), + dapp_reward_pool, + ); + + // Basic checks + let number_of_tiers: u32 = ::NumberOfTiers::get(); + assert_eq!(tier_assignment.period, protocol_state.period_number()); + assert_eq!(tier_assignment.rewards.len(), number_of_tiers as usize); + assert_eq!( + tier_assignment.dapps.len(), + number_of_smart_contracts as usize - 1, + "One contract doesn't make it into any tier." + ); + + // 1st tier checks + let (entry_1, entry_2) = (tier_assignment.dapps[0], tier_assignment.dapps[1]); + assert_eq!(entry_1.dapp_id, 0); + assert_eq!(entry_1.tier_id, Some(0)); + + assert_eq!(entry_2.dapp_id, 1); + assert_eq!(entry_2.tier_id, Some(0)); + + // 2nd tier checks + let (entry_3, entry_4) = (tier_assignment.dapps[2], tier_assignment.dapps[3]); + assert_eq!(entry_3.dapp_id, 2); + assert_eq!(entry_3.tier_id, Some(1)); + + assert_eq!(entry_4.dapp_id, 3); + assert_eq!(entry_4.tier_id, Some(1)); + + // 4th tier checks + let (entry_5, entry_6) = (tier_assignment.dapps[4], tier_assignment.dapps[5]); + assert_eq!(entry_5.dapp_id, 4); + assert_eq!(entry_5.tier_id, Some(3)); + + assert_eq!(entry_6.dapp_id, 5); + assert_eq!(entry_6.tier_id, Some(3)); + + // Sanity check - last dapp should not exists in the tier assignment + assert!(tier_assignment + .dapps + .binary_search_by(|x| x.dapp_id.cmp(&(dapp_index.try_into().unwrap()))) + .is_err()); + + // Check that rewards are calculated correctly + tier_config + .reward_portion + .iter() + .zip(tier_config.slots_per_tier.iter()) + .enumerate() + .for_each(|(idx, (reward_portion, slots))| { + let total_tier_allocation = *reward_portion * dapp_reward_pool; + let tier_reward: Balance = total_tier_allocation / (*slots as Balance); + + assert_eq!(tier_assignment.rewards[idx], tier_reward,); + }); + }) +} + +#[test] +fn get_dapp_tier_assignment_zero_slots_per_tier_works() { + ExtBuilder::build().execute_with(|| { + // This test will rely on the configuration inside the mock file. + // If that changes, this test might have to be updated as well. + + // Ensure that first tier has 0 slots. + TierConfig::::mutate(|config| { + let slots_in_first_tier = config.slots_per_tier[0]; + config.number_of_slots = config.number_of_slots - slots_in_first_tier; + config.slots_per_tier[0] = 0; + }); + + // Calculate tier assignment (we don't need dApps for this test) + let protocol_state = ActiveProtocolState::::get(); + let dapp_reward_pool = 1000000; + let tier_assignment = DappStaking::get_dapp_tier_assignment( + protocol_state.era, + protocol_state.period_number(), + dapp_reward_pool, + ); + + // Basic checks + let number_of_tiers: u32 = ::NumberOfTiers::get(); + assert_eq!(tier_assignment.period, protocol_state.period_number()); + assert_eq!(tier_assignment.rewards.len(), number_of_tiers as usize); + assert!(tier_assignment.dapps.is_empty()); + + assert!( + tier_assignment.rewards[0].is_zero(), + "1st tier has no slots so no rewards should be assigned to it." + ); + + // Regardless of that, other tiers shouldn't benefit from this + assert!(tier_assignment.rewards.iter().sum::() < dapp_reward_pool); + }) +} + +//////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////// +/////// More complex & composite scenarios, maybe move them into a separate file + +#[test] +fn unlock_after_staked_period_ends_is_ok() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let smart_contract = MockSmartContract::default(); + assert_register(1, &smart_contract); + + let account = 2; + let amount = 101; + assert_lock(account, amount); + assert_stake(account, &smart_contract, amount); + + // Advance to the next period, and ensure stake is reset and can be fully unlocked + advance_to_next_period(); + assert!(Ledger::::get(&account) + .staked_amount(ActiveProtocolState::::get().period_number()) + .is_zero()); + assert_unlock(account, amount); + assert_eq!(Ledger::::get(&account).unlocking_amount(), amount); + }) +} + +#[test] +fn unstake_from_a_contract_staked_in_past_period_fails() { + ExtBuilder::build().execute_with(|| { + // Register smart contract & lock some amount + let smart_contract_1 = MockSmartContract::Wasm(1); + let smart_contract_2 = MockSmartContract::Wasm(2); + assert_register(1, &smart_contract_1); + assert_register(1, &smart_contract_2); + let account = 2; + assert_lock(account, 300); + + // Stake some amount on the 2nd contract. + let stake_amount = 100; + assert_stake(account, &smart_contract_2, stake_amount); + + // Advance to the next period, and stake on the 1st contract. + advance_to_next_period(); + for _ in 0..required_number_of_reward_claims(account) { + assert_claim_staker_rewards(account); + } + + // Try to unstake from the 2nd contract, which is no longer staked on due to period change. + assert_noop!( + DappStaking::unstake(RuntimeOrigin::signed(account), smart_contract_2, 1,), + Error::::UnstakeFromPastPeriod + ); + + // Staking on the 1st contract should succeed since we haven't staked on it before so there are no bonus rewards to claim + assert_stake(account, &smart_contract_1, stake_amount); + + // Even with active stake on the 1st contract, unstake from 2nd should still fail since period change reset its stake. + assert_noop!( + DappStaking::unstake(RuntimeOrigin::signed(account), smart_contract_2, 1,), + Error::::UnstakeFromPastPeriod + ); + }) +} + +#[test] +fn stake_and_unstake_after_reward_claim_is_ok() { + ExtBuilder::build().execute_with(|| { + // Register smart contract, lock&stake some amount + let dev_account = 1; + let smart_contract = MockSmartContract::default(); + assert_register(dev_account, &smart_contract); + + let account = 2; + let amount = 400; + assert_lock(account, amount); + assert_stake(account, &smart_contract, amount - 100); + + // Advance 2 eras so we have claimable rewards. Both stake & unstake should fail. + advance_to_era(ActiveProtocolState::::get().era + 2); + assert_noop!( + DappStaking::stake(RuntimeOrigin::signed(account), smart_contract, 1), + Error::::UnclaimedRewards + ); + assert_noop!( + DappStaking::unstake(RuntimeOrigin::signed(account), smart_contract, 1), + Error::::UnclaimedRewards + ); + + // Claim rewards, unstake should work now. + for _ in 0..required_number_of_reward_claims(account) { + assert_claim_staker_rewards(account); + } + assert_stake(account, &smart_contract, 1); + assert_unstake(account, &smart_contract, 1); + }) +} diff --git a/pallets/dapp-staking-v3/src/test/tests_types.rs b/pallets/dapp-staking-v3/src/test/tests_types.rs index 6554faed11..50074ec7a6 100644 --- a/pallets/dapp-staking-v3/src/test/tests_types.rs +++ b/pallets/dapp-staking-v3/src/test/tests_types.rs @@ -153,6 +153,7 @@ fn dapp_info_basic_checks() { id: 7, state: DAppState::Registered, reward_destination: None, + tier_label: None, }; // Owner receives reward in case no beneficiary is set @@ -161,6 +162,20 @@ fn dapp_info_basic_checks() { // Beneficiary receives rewards in case it is set dapp_info.reward_destination = Some(beneficiary); assert_eq!(*dapp_info.reward_beneficiary(), beneficiary); + + // Check if dApp is active + assert!(dapp_info.is_active()); + + dapp_info.state = DAppState::Unregistered(10); + assert!(!dapp_info.is_active()); +} + +#[test] +fn unlocking_chunk_basic_check() { + // Sanity check + let unlocking_chunk = UnlockingChunk::::default(); + assert!(unlocking_chunk.amount.is_zero()); + assert!(unlocking_chunk.unlock_block.is_zero()); } #[test] @@ -204,31 +219,31 @@ fn account_ledger_subtract_lock_amount_basic_usage_works() { // First basic scenario // Add some lock amount, then reduce it - let first_lock_amount = 19; + let lock_amount_1 = 19; let unlock_amount = 7; - acc_ledger.add_lock_amount(first_lock_amount); + acc_ledger.add_lock_amount(lock_amount_1); acc_ledger.subtract_lock_amount(unlock_amount); assert_eq!( acc_ledger.total_locked_amount(), - first_lock_amount - unlock_amount + lock_amount_1 - unlock_amount ); assert_eq!( acc_ledger.active_locked_amount(), - first_lock_amount - unlock_amount + lock_amount_1 - unlock_amount ); assert_eq!(acc_ledger.unlocking_amount(), 0); // Second basic scenario - let first_lock_amount = first_lock_amount - unlock_amount; - let second_lock_amount = 31; - acc_ledger.add_lock_amount(second_lock_amount - first_lock_amount); - assert_eq!(acc_ledger.active_locked_amount(), second_lock_amount); + let lock_amount_1 = lock_amount_1 - unlock_amount; + let lock_amount_2 = 31; + acc_ledger.add_lock_amount(lock_amount_2 - lock_amount_1); + assert_eq!(acc_ledger.active_locked_amount(), lock_amount_2); // Subtract from the first era and verify state is as expected acc_ledger.subtract_lock_amount(unlock_amount); assert_eq!( acc_ledger.active_locked_amount(), - second_lock_amount - unlock_amount + lock_amount_2 - unlock_amount ); } @@ -312,7 +327,12 @@ fn account_ledger_staked_amount_works() { // Period matches let amount_1 = 29; let period = 5; - acc_ledger.staked = StakeAmount::new(amount_1, 0, 1, period); + acc_ledger.staked = StakeAmount { + voting: amount_1, + build_and_earn: 0, + era: 1, + period, + }; assert_eq!(acc_ledger.staked_amount(period), amount_1); // Period doesn't match @@ -321,7 +341,12 @@ fn account_ledger_staked_amount_works() { // Add future entry let amount_2 = 17; - acc_ledger.staked_future = Some(StakeAmount::new(0, amount_2, 2, period)); + acc_ledger.staked_future = Some(StakeAmount { + voting: 0, + build_and_earn: amount_2, + era: 2, + period, + }); assert_eq!(acc_ledger.staked_amount(period), amount_2); assert!(acc_ledger.staked_amount(period - 1).is_zero()); assert!(acc_ledger.staked_amount(period + 1).is_zero()); @@ -407,9 +432,14 @@ fn account_ledger_stakeable_amount_works() { ); // Second scenario - some staked amount is introduced, period is still valid - let first_era = 1; + let era_1 = 1; let staked_amount = 7; - acc_ledger.staked = StakeAmount::new(0, staked_amount, first_era, period_1); + acc_ledger.staked = StakeAmount { + voting: 0, + build_and_earn: staked_amount, + era: era_1, + period: period_1, + }; assert_eq!( acc_ledger.stakeable_amount(period_1), @@ -493,7 +523,7 @@ fn account_ledger_add_stake_amount_basic_example_works() { assert!(acc_ledger.staked_future.is_none()); // 1st scenario - stake some amount in Voting period, and ensure values are as expected. - let first_era = 1; + let era_1 = 1; let period_1 = 1; let period_info_1 = PeriodInfo { number: period_1, @@ -505,7 +535,7 @@ fn account_ledger_add_stake_amount_basic_example_works() { acc_ledger.add_lock_amount(lock_amount); assert!(acc_ledger - .add_stake_amount(stake_amount, first_era, period_info_1) + .add_stake_amount(stake_amount, era_1, period_info_1) .is_ok()); assert!( @@ -537,9 +567,8 @@ fn account_ledger_add_stake_amount_basic_example_works() { subperiod: Subperiod::BuildAndEarn, subperiod_end_era: 100, }; - assert!(acc_ledger - .add_stake_amount(1, first_era, period_info_2) - .is_ok()); + let era_2 = era_1 + 1; + assert!(acc_ledger.add_stake_amount(1, era_2, period_info_2).is_ok()); assert_eq!(acc_ledger.staked_amount(period_1), stake_amount + 1); assert_eq!( acc_ledger.staked_amount_for_type(Subperiod::Voting, period_1), @@ -558,7 +587,7 @@ fn account_ledger_add_stake_amount_advanced_example_works() { let mut acc_ledger = AccountLedger::::default(); // 1st scenario - stake some amount, and ensure values are as expected. - let first_era = 1; + let era_1 = 1; let period_1 = 1; let period_info_1 = PeriodInfo { number: period_1, @@ -570,12 +599,17 @@ fn account_ledger_add_stake_amount_advanced_example_works() { acc_ledger.add_lock_amount(lock_amount); // We only have entry for the current era - acc_ledger.staked = StakeAmount::new(stake_amount_1, 0, first_era, period_1); + acc_ledger.staked = StakeAmount { + voting: stake_amount_1, + build_and_earn: 0, + era: era_1, + period: period_1, + }; let stake_amount_2 = 2; let acc_ledger_snapshot = acc_ledger.clone(); assert!(acc_ledger - .add_stake_amount(stake_amount_2, first_era, period_info_1) + .add_stake_amount(stake_amount_2, era_1, period_info_1) .is_ok()); assert_eq!( acc_ledger.staked_amount(period_1), @@ -596,7 +630,7 @@ fn account_ledger_add_stake_amount_advanced_example_works() { .for_type(Subperiod::Voting), stake_amount_1 + stake_amount_2 ); - assert_eq!(acc_ledger.staked_future.unwrap().era, first_era + 1); + assert_eq!(acc_ledger.staked_future.unwrap().era, era_1 + 1); } #[test] @@ -605,7 +639,7 @@ fn account_ledger_add_stake_amount_invalid_era_or_period_fails() { let mut acc_ledger = AccountLedger::::default(); // Prep actions - let first_era = 5; + let era_1 = 5; let period_1 = 2; let period_info_1 = PeriodInfo { number: period_1, @@ -616,12 +650,12 @@ fn account_ledger_add_stake_amount_invalid_era_or_period_fails() { let stake_amount = 7; acc_ledger.add_lock_amount(lock_amount); assert!(acc_ledger - .add_stake_amount(stake_amount, first_era, period_info_1) + .add_stake_amount(stake_amount, era_1, period_info_1) .is_ok()); - // Try to add to the next era, it should fail. + // Try to add to era after next, it should fail. assert_eq!( - acc_ledger.add_stake_amount(1, first_era + 1, period_info_1), + acc_ledger.add_stake_amount(1, era_1 + 2, period_info_1), Err(AccountLedgerError::InvalidEra) ); @@ -629,7 +663,7 @@ fn account_ledger_add_stake_amount_invalid_era_or_period_fails() { assert_eq!( acc_ledger.add_stake_amount( 1, - first_era, + era_1, PeriodInfo { number: period_1 + 1, subperiod: Subperiod::Voting, @@ -640,17 +674,22 @@ fn account_ledger_add_stake_amount_invalid_era_or_period_fails() { ); // Alternative situation - no future entry, only current era - acc_ledger.staked = StakeAmount::new(0, stake_amount, first_era, period_1); + acc_ledger.staked = StakeAmount { + voting: 0, + build_and_earn: stake_amount, + era: era_1, + period: period_1, + }; acc_ledger.staked_future = None; assert_eq!( - acc_ledger.add_stake_amount(1, first_era + 1, period_info_1), + acc_ledger.add_stake_amount(1, era_1 + 1, period_info_1), Err(AccountLedgerError::InvalidEra) ); assert_eq!( acc_ledger.add_stake_amount( 1, - first_era, + era_1, PeriodInfo { number: period_1 + 1, subperiod: Subperiod::Voting, @@ -681,7 +720,7 @@ fn account_ledger_add_stake_amount_too_large_amount_fails() { ); // Lock some amount, and try to stake more than that - let first_era = 5; + let era_1 = 5; let period_1 = 2; let period_info_1 = PeriodInfo { number: period_1, @@ -691,16 +730,16 @@ fn account_ledger_add_stake_amount_too_large_amount_fails() { let lock_amount = 13; acc_ledger.add_lock_amount(lock_amount); assert_eq!( - acc_ledger.add_stake_amount(lock_amount + 1, first_era, period_info_1), + acc_ledger.add_stake_amount(lock_amount + 1, era_1, period_info_1), Err(AccountLedgerError::UnavailableStakeFunds) ); // Additional check - have some active stake, and then try to overstake assert!(acc_ledger - .add_stake_amount(lock_amount - 2, first_era, period_info_1) + .add_stake_amount(lock_amount - 2, era_1, period_info_1) .is_ok()); assert_eq!( - acc_ledger.add_stake_amount(3, first_era, period_info_1), + acc_ledger.add_stake_amount(3, era_1, period_info_1), Err(AccountLedgerError::UnavailableStakeFunds) ); } @@ -729,7 +768,12 @@ fn account_ledger_unstake_amount_basic_scenario_works() { .is_ok()); // Only 'current' entry has some values, future is set to None. - acc_ledger_2.staked = StakeAmount::new(0, amount_1, era_1, period_1); + acc_ledger_2.staked = StakeAmount { + voting: 0, + build_and_earn: amount_1, + era: era_1, + period: period_1, + }; acc_ledger_2.staked_future = None; for mut acc_ledger in vec![acc_ledger, acc_ledger_2] { @@ -773,8 +817,18 @@ fn account_ledger_unstake_amount_advanced_scenario_works() { acc_ledger.add_lock_amount(amount_1); // We have two entries at once - acc_ledger.staked = StakeAmount::new(amount_1 - 1, 0, era_1, period_1); - acc_ledger.staked_future = Some(StakeAmount::new(amount_1 - 1, 1, era_1 + 1, period_1)); + acc_ledger.staked = StakeAmount { + voting: amount_1 - 1, + build_and_earn: 0, + era: era_1, + period: period_1, + }; + acc_ledger.staked_future = Some(StakeAmount { + voting: amount_1 - 1, + build_and_earn: 1, + era: era_1 + 1, + period: period_1, + }); // 1st scenario - unstake some amount from the current era, both entries should be affected. let unstake_amount_1 = 3; @@ -847,9 +901,15 @@ fn account_ledger_unstake_from_invalid_era_fails() { .add_stake_amount(amount_1, era_1, period_info_1) .is_ok()); - // Try to unstake from the next era, it should fail. + // Try to unstake from the current & next era, it should work. + assert!(acc_ledger.unstake_amount(1, era_1, period_info_1).is_ok()); + assert!(acc_ledger + .unstake_amount(1, era_1 + 1, period_info_1) + .is_ok()); + + // Try to unstake from the stake era + 2, it should fail since it would mean we have unclaimed rewards. assert_eq!( - acc_ledger.unstake_amount(1, era_1 + 1, period_info_1), + acc_ledger.unstake_amount(1, era_1 + 2, period_info_1), Err(AccountLedgerError::InvalidEra) ); @@ -868,7 +928,12 @@ fn account_ledger_unstake_from_invalid_era_fails() { ); // Alternative situation - no future entry, only current era - acc_ledger.staked = StakeAmount::new(0, 1, era_1, period_1); + acc_ledger.staked = StakeAmount { + voting: 0, + build_and_earn: 1, + era: era_1, + period: period_1, + }; acc_ledger.staked_future = None; assert_eq!( @@ -1007,8 +1072,582 @@ fn account_ledger_consume_unlocking_chunks_works() { } #[test] -fn account_ledger_claim_up_to_era_works() { - // TODO!!! +fn account_ledger_expired_cleanup_works() { + get_u32_type!(UnlockingDummy, 5); + + // 1st scenario - nothing is expired + let mut acc_ledger = AccountLedger::::default(); + acc_ledger.staked = StakeAmount { + voting: 3, + build_and_earn: 7, + era: 100, + period: 5, + }; + acc_ledger.staked_future = Some(StakeAmount { + voting: 3, + build_and_earn: 13, + era: 101, + period: 5, + }); + + let acc_ledger_snapshot = acc_ledger.clone(); + + assert!(!acc_ledger.maybe_cleanup_expired(acc_ledger.staked.period - 1)); + assert_eq!( + acc_ledger, acc_ledger_snapshot, + "No change must happen since period hasn't expired." + ); + + assert!(!acc_ledger.maybe_cleanup_expired(acc_ledger.staked.period)); + assert_eq!( + acc_ledger, acc_ledger_snapshot, + "No change must happen since period hasn't expired." + ); + + // 2nd scenario - stake has expired + assert!(acc_ledger.maybe_cleanup_expired(acc_ledger.staked.period + 1)); + assert!(acc_ledger.staked.is_empty()); + assert!(acc_ledger.staked_future.is_none()); +} + +#[test] +fn account_ledger_claim_up_to_era_only_staked_without_cleanup_works() { + get_u32_type!(UnlockingDummy, 5); + let stake_era = 100; + + let acc_ledger_snapshot = { + let mut acc_ledger = AccountLedger::::default(); + acc_ledger.staked = StakeAmount { + voting: 3, + build_and_earn: 7, + era: stake_era, + period: 5, + }; + acc_ledger + }; + + // 1st scenario - claim one era, period hasn't ended yet + { + let mut acc_ledger = acc_ledger_snapshot.clone(); + let mut result_iter = acc_ledger + .claim_up_to_era(stake_era, None) + .expect("Must provide iter with exactly one era."); + + // Iter values are correct + assert_eq!( + result_iter.next(), + Some((stake_era, acc_ledger_snapshot.staked.total())) + ); + assert!(result_iter.next().is_none()); + + // Ledger values are correct + let mut expected_stake_amount = acc_ledger_snapshot.staked; + expected_stake_amount.era += 1; + assert_eq!( + acc_ledger.staked, expected_stake_amount, + "Only era should be bumped by 1." + ); + assert!( + acc_ledger.staked_future.is_none(), + "Was and should remain None." + ); + } + + // 2nd scenario - claim multiple eras (5), period hasn't ended yet + { + let mut acc_ledger = acc_ledger_snapshot.clone(); + let mut result_iter = acc_ledger + .claim_up_to_era(stake_era + 4, None) // staked era + 4 additional eras + .expect("Must provide iter with 5 values."); + + // Iter values are correct + for inc in 0..5 { + assert_eq!( + result_iter.next(), + Some((stake_era + inc, acc_ledger_snapshot.staked.total())) + ); + } + assert!(result_iter.next().is_none()); + + // Ledger values are correct + let mut expected_stake_amount = acc_ledger_snapshot.staked; + expected_stake_amount.era += 5; + assert_eq!( + acc_ledger.staked, expected_stake_amount, + "Only era should be bumped by 5." + ); + assert!( + acc_ledger.staked_future.is_none(), + "Was and should remain None." + ); + } +} + +#[test] +fn account_ledger_claim_up_to_era_only_staked_with_cleanup_works() { + get_u32_type!(UnlockingDummy, 5); + let stake_era = 100; + + let acc_ledger_snapshot = { + let mut acc_ledger = AccountLedger::::default(); + acc_ledger.staked = StakeAmount { + voting: 3, + build_and_earn: 7, + era: stake_era, + period: 5, + }; + acc_ledger + }; + + // 1st scenario - claim one era, period has ended + { + let mut acc_ledger = acc_ledger_snapshot.clone(); + let mut result_iter = acc_ledger + .claim_up_to_era(stake_era, Some(stake_era)) + .expect("Must provide iter with exactly one era."); + + // Iter values are correct + assert_eq!( + result_iter.next(), + Some((stake_era, acc_ledger_snapshot.staked.total())) + ); + assert!(result_iter.next().is_none()); + + // Ledger values are cleaned up + assert!( + acc_ledger.staked.is_empty(), + "Period has ended so stake entry should be cleaned up." + ); + assert!( + acc_ledger.staked_future.is_none(), + "Was and should remain None." + ); + } + + // 2nd scenario - claim multiple eras (5), period has ended + { + let mut acc_ledger = acc_ledger_snapshot.clone(); + let mut result_iter = acc_ledger + .claim_up_to_era(stake_era + 4, Some(stake_era)) // staked era + 4 additional eras + .expect("Must provide iter with 5 values."); + + for inc in 0..5 { + assert_eq!( + result_iter.next(), + Some((stake_era + inc, acc_ledger_snapshot.staked.total())) + ); + } + assert!(result_iter.next().is_none()); + + // Ledger values are cleaned up + assert!( + acc_ledger.staked.is_empty(), + "Period has ended so stake entry should be cleaned up." + ); + assert!( + acc_ledger.staked_future.is_none(), + "Was and should remain None." + ); + } + + // 3rd scenario - claim one era, period has ended in some future era + { + let mut acc_ledger = acc_ledger_snapshot.clone(); + let mut result_iter = acc_ledger + .claim_up_to_era(stake_era, Some(stake_era + 1)) + .expect("Must provide iter with exactly one era."); + + // Iter values are correct + assert_eq!( + result_iter.next(), + Some((stake_era, acc_ledger_snapshot.staked.total())) + ); + assert!(result_iter.next().is_none()); + + // Ledger values are correctly updated + let mut expected_stake_amount = acc_ledger_snapshot.staked; + expected_stake_amount.era += 1; + assert_eq!( + acc_ledger.staked, expected_stake_amount, + "Entry must exist since we still haven't reached the period end era." + ); + assert!( + acc_ledger.staked_future.is_none(), + "Was and should remain None." + ); + } +} + +#[test] +fn account_ledger_claim_up_to_era_only_staked_future_without_cleanup_works() { + get_u32_type!(UnlockingDummy, 5); + let stake_era = 50; + + let acc_ledger_snapshot = { + let mut acc_ledger = AccountLedger::::default(); + acc_ledger.staked_future = Some(StakeAmount { + voting: 5, + build_and_earn: 11, + era: stake_era, + period: 4, + }); + acc_ledger + }; + + // 1st scenario - claim one era, period hasn't ended yet + { + let mut acc_ledger = acc_ledger_snapshot.clone(); + let mut result_iter = acc_ledger + .claim_up_to_era(stake_era, None) + .expect("Must provide iter with exactly one era."); + + // Iter values are correct + assert_eq!( + result_iter.next(), + Some(( + stake_era, + acc_ledger_snapshot.staked_future.unwrap().total() + )) + ); + assert!(result_iter.next().is_none()); + + // Ledger values are correct + let mut expected_stake_amount = acc_ledger_snapshot.staked_future.unwrap(); + expected_stake_amount.era += 1; + assert_eq!( + acc_ledger.staked, expected_stake_amount, + "Era must be bumped by 1, and entry must switch from staked_future over to staked." + ); + assert!( + acc_ledger.staked_future.is_none(), + "staked_future must be cleaned up after the claim." + ); + } + + // 2nd scenario - claim multiple eras (5), period hasn't ended yet + { + let mut acc_ledger = acc_ledger_snapshot.clone(); + let mut result_iter = acc_ledger + .claim_up_to_era(stake_era + 4, None) // staked era + 4 additional eras + .expect("Must provide iter with 5 entries."); + + // Iter values are correct + for inc in 0..5 { + assert_eq!( + result_iter.next(), + Some(( + stake_era + inc, + acc_ledger_snapshot.staked_future.unwrap().total() + )) + ); + } + assert!(result_iter.next().is_none()); + + // Ledger values are correct + let mut expected_stake_amount = acc_ledger_snapshot.staked_future.unwrap(); + expected_stake_amount.era += 5; + assert_eq!( + acc_ledger.staked, expected_stake_amount, + "Era must be bumped by 5, and entry must switch from staked_future over to staked." + ); + assert!( + acc_ledger.staked_future.is_none(), + "staked_future must be cleaned up after the claim." + ); + } +} + +#[test] +fn account_ledger_claim_up_to_era_only_staked_future_with_cleanup_works() { + get_u32_type!(UnlockingDummy, 5); + let stake_era = 50; + + let acc_ledger_snapshot = { + let mut acc_ledger = AccountLedger::::default(); + acc_ledger.staked_future = Some(StakeAmount { + voting: 2, + build_and_earn: 17, + era: stake_era, + period: 3, + }); + acc_ledger + }; + + // 1st scenario - claim one era, period has ended + { + let mut acc_ledger = acc_ledger_snapshot.clone(); + let mut result_iter = acc_ledger + .claim_up_to_era(stake_era, Some(stake_era)) + .expect("Must provide iter with exactly one era."); + + // Iter values are correct + assert_eq!( + result_iter.next(), + Some(( + stake_era, + acc_ledger_snapshot.staked_future.unwrap().total() + )) + ); + assert!(result_iter.next().is_none()); + + // Ledger values are cleaned up + assert!( + acc_ledger.staked.is_empty(), + "Period has ended so stake entry should be cleaned up." + ); + assert!( + acc_ledger.staked_future.is_none(), + "Was and should remain None." + ); + } + + // 2nd scenario - claim multiple eras (5), period has ended + { + let mut acc_ledger = acc_ledger_snapshot.clone(); + let mut result_iter = acc_ledger + .claim_up_to_era(stake_era + 4, Some(stake_era)) // staked era + 4 additional eras + .expect("Must provide iter with 5 entries."); + + for inc in 0..5 { + assert_eq!( + result_iter.next(), + Some(( + stake_era + inc, + acc_ledger_snapshot.staked_future.unwrap().total() + )) + ); + } + assert!(result_iter.next().is_none()); + + // Ledger values are cleaned up + assert!( + acc_ledger.staked.is_empty(), + "Period has ended so stake entry should be cleaned up." + ); + assert!( + acc_ledger.staked_future.is_none(), + "Was and should remain None." + ); + } + + // 3rd scenario - claim one era, period has ended in some future era + { + let mut acc_ledger = acc_ledger_snapshot.clone(); + let mut result_iter = acc_ledger + .claim_up_to_era(stake_era, Some(stake_era + 1)) + .expect("Must provide iter with exactly one era."); + + // Iter values are correct + assert_eq!( + result_iter.next(), + Some(( + stake_era, + acc_ledger_snapshot.staked_future.unwrap().total() + )) + ); + assert!(result_iter.next().is_none()); + + // Ledger values are correctly updated + let mut expected_stake_amount = acc_ledger_snapshot.staked_future.unwrap(); + expected_stake_amount.era += 1; + assert_eq!( + acc_ledger.staked, expected_stake_amount, + "Entry must exist since we still haven't reached the period end era." + ); + assert!( + acc_ledger.staked_future.is_none(), + "Was and should remain None." + ); + } +} + +#[test] +fn account_ledger_claim_up_to_era_staked_and_staked_future_works() { + get_u32_type!(UnlockingDummy, 5); + let stake_era_1 = 100; + let stake_era_2 = stake_era_1 + 1; + + let acc_ledger_snapshot = { + let mut acc_ledger = AccountLedger::::default(); + acc_ledger.staked = StakeAmount { + voting: 3, + build_and_earn: 7, + era: stake_era_1, + period: 5, + }; + acc_ledger.staked_future = Some(StakeAmount { + voting: 3, + build_and_earn: 11, + era: stake_era_2, + period: 5, + }); + acc_ledger + }; + + // 1st scenario - claim only one era + { + let mut acc_ledger = acc_ledger_snapshot.clone(); + let mut result_iter = acc_ledger + .claim_up_to_era(stake_era_1, None) + .expect("Must provide iter with exactly one era."); + + // Iter values are correct + assert_eq!( + result_iter.next(), + Some((stake_era_1, acc_ledger_snapshot.staked.total())) + ); + assert!(result_iter.next().is_none()); + + // Ledger values are correct + let mut expected_stake_amount = acc_ledger_snapshot.staked; + expected_stake_amount.era += 1; + assert_eq!( + acc_ledger.staked, + acc_ledger_snapshot.staked_future.unwrap(), + "staked_future entry must be moved over to staked." + ); + assert!( + acc_ledger.staked_future.is_none(), + "staked_future is cleaned up since it's been moved over to staked entry." + ); + } + + // 2nd scenario - claim multiple eras (3), period hasn't ended yet, do the cleanup + { + let mut acc_ledger = acc_ledger_snapshot.clone(); + let mut result_iter = acc_ledger + .claim_up_to_era(stake_era_2 + 1, None) // staked era + 2 additional eras + .expect("Must provide iter with exactly two entries."); + + // Iter values are correct + assert_eq!( + result_iter.next(), + Some((stake_era_1, acc_ledger_snapshot.staked.total())) + ); + assert_eq!( + result_iter.next(), + Some(( + stake_era_2, + acc_ledger_snapshot.staked_future.unwrap().total() + )) + ); + assert_eq!( + result_iter.next(), + Some(( + stake_era_2 + 1, + acc_ledger_snapshot.staked_future.unwrap().total() + )) + ); + assert!(result_iter.next().is_none()); + + // Ledger values are correct + let mut expected_stake_amount = acc_ledger_snapshot.staked_future.unwrap(); + expected_stake_amount.era += 2; + assert_eq!( + acc_ledger.staked, expected_stake_amount, + "staked_future must move over to staked, and era must be incremented by 2." + ); + assert!( + acc_ledger.staked_future.is_none(), + "staked_future is cleaned up since it's been moved over to staked entry." + ); + } +} + +#[test] +fn account_ledger_claim_up_to_era_fails_for_historic_eras() { + get_u32_type!(UnlockingDummy, 5); + let stake_era = 50; + + // Only staked entry + { + let mut acc_ledger = AccountLedger::::default(); + acc_ledger.staked = StakeAmount { + voting: 2, + build_and_earn: 17, + era: stake_era, + period: 3, + }; + assert_eq!( + acc_ledger.claim_up_to_era(stake_era - 1, None), + Err(AccountLedgerError::NothingToClaim) + ); + } + + // Only staked-future entry + { + let mut acc_ledger = AccountLedger::::default(); + acc_ledger.staked_future = Some(StakeAmount { + voting: 2, + build_and_earn: 17, + era: stake_era, + period: 3, + }); + assert_eq!( + acc_ledger.claim_up_to_era(stake_era - 1, None), + Err(AccountLedgerError::NothingToClaim) + ); + } + + // Both staked and staked-future entries + { + let mut acc_ledger = AccountLedger::::default(); + acc_ledger.staked = StakeAmount { + voting: 2, + build_and_earn: 17, + era: stake_era, + period: 3, + }; + acc_ledger.staked_future = Some(StakeAmount { + voting: 2, + build_and_earn: 19, + era: stake_era + 1, + period: 3, + }); + assert_eq!( + acc_ledger.claim_up_to_era(stake_era - 1, None), + Err(AccountLedgerError::NothingToClaim) + ); + } +} + +#[test] +fn era_stake_pair_iter_works() { + // 1st scenario - only span is given + let (era_1, last_era, amount) = (2, 5, 11); + let mut iter_1 = EraStakePairIter::new((era_1, last_era, amount), None).unwrap(); + for era in era_1..=last_era { + assert_eq!(iter_1.next(), Some((era, amount))); + } + assert!(iter_1.next().is_none()); + + // 2nd scenario - first value & span are given + let (maybe_era_1, maybe_first_amount) = (1, 7); + let maybe_first = Some((maybe_era_1, maybe_first_amount)); + let mut iter_2 = EraStakePairIter::new((era_1, last_era, amount), maybe_first).unwrap(); + + assert_eq!(iter_2.next(), Some((maybe_era_1, maybe_first_amount))); + for era in era_1..=last_era { + assert_eq!(iter_2.next(), Some((era, amount))); + } +} + +#[test] +fn era_stake_pair_iter_returns_error_for_illegal_data() { + // 1st scenario - spans are reversed; first era comes AFTER the last era + let (era_1, last_era, amount) = (2, 5, 11); + assert!(EraStakePairIter::new((last_era, era_1, amount), None).is_err()); + + // 2nd scenario - maybe_first covers the same era as the span + assert!(EraStakePairIter::new((era_1, last_era, amount), Some((era_1, 10))).is_err()); + + // 3rd scenario - maybe_first is before the span, but not exactly 1 era before the first era in the span + assert!(EraStakePairIter::new((era_1, last_era, amount), Some((era_1 - 2, 10))).is_err()); + + assert!( + EraStakePairIter::new((era_1, last_era, amount), Some((era_1 - 1, 10))).is_ok(), + "Sanity check." + ); } #[test] @@ -1017,7 +1656,6 @@ fn era_info_lock_unlock_works() { // 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 @@ -1030,7 +1668,6 @@ fn era_info_lock_unlock_works() { // 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 @@ -1039,10 +1676,6 @@ fn era_info_lock_unlock_works() { 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 @@ -1051,17 +1684,13 @@ fn era_info_lock_unlock_works() { 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); + assert_eq!(era_info.total_locked, old_era_info.total_locked); } #[test] @@ -1111,10 +1740,18 @@ fn era_info_unstake_works() { let bep_stake_amount_2 = bep_stake_amount_1 + 6; let period_number = 1; let era = 2; - era_info.current_stake_amount = - StakeAmount::new(vp_stake_amount, bep_stake_amount_1, era, period_number); - era_info.next_stake_amount = - StakeAmount::new(vp_stake_amount, bep_stake_amount_2, era + 1, period_number); + era_info.current_stake_amount = StakeAmount { + voting: vp_stake_amount, + build_and_earn: bep_stake_amount_1, + era, + period: period_number, + }; + era_info.next_stake_amount = StakeAmount { + voting: vp_stake_amount, + build_and_earn: bep_stake_amount_2, + era: era + 1, + period: period_number, + }; let total_staked = era_info.total_staked_amount(); let total_staked_next_era = era_info.total_staked_amount_next_era(); @@ -1169,6 +1806,88 @@ fn era_info_unstake_works() { .is_zero()); } +#[test] +fn era_info_migrate_to_next_era_works() { + // Make dummy era info with stake amounts + let era_info_snapshot = EraInfo { + total_locked: 456, + unlocking: 13, + current_stake_amount: StakeAmount { + voting: 13, + build_and_earn: 29, + era: 2, + period: 1, + }, + next_stake_amount: StakeAmount { + voting: 13, + build_and_earn: 41, + era: 3, + period: 1, + }, + }; + + // 1st scenario - rollover to next era, no subperiod change + { + let mut era_info = era_info_snapshot; + era_info.migrate_to_next_era(None); + + assert_eq!(era_info.total_locked, era_info_snapshot.total_locked); + assert_eq!(era_info.unlocking, era_info_snapshot.unlocking); + assert_eq!( + era_info.current_stake_amount, + era_info_snapshot.next_stake_amount + ); + + let mut new_next_stake_amount = era_info_snapshot.next_stake_amount; + new_next_stake_amount.era += 1; + assert_eq!(era_info.next_stake_amount, new_next_stake_amount); + } + + // 2nd scenario - rollover to next era, change from Voting into Build&Earn subperiod + { + let mut era_info = era_info_snapshot; + era_info.migrate_to_next_era(Some(Subperiod::BuildAndEarn)); + + assert_eq!(era_info.total_locked, era_info_snapshot.total_locked); + assert_eq!(era_info.unlocking, era_info_snapshot.unlocking); + assert_eq!( + era_info.current_stake_amount, + era_info_snapshot.next_stake_amount + ); + + let mut new_next_stake_amount = era_info_snapshot.next_stake_amount; + new_next_stake_amount.era += 1; + assert_eq!(era_info.next_stake_amount, new_next_stake_amount); + } + + // 3rd scenario - rollover to next era, change from Build&Earn to Voting subperiod + { + let mut era_info = era_info_snapshot; + era_info.migrate_to_next_era(Some(Subperiod::Voting)); + + assert_eq!(era_info.total_locked, era_info_snapshot.total_locked); + assert_eq!(era_info.unlocking, era_info_snapshot.unlocking); + assert_eq!( + era_info.current_stake_amount, + StakeAmount { + voting: Zero::zero(), + build_and_earn: Zero::zero(), + era: era_info_snapshot.current_stake_amount.era + 1, + period: era_info_snapshot.current_stake_amount.period + 1, + } + ); + assert_eq!( + era_info.next_stake_amount, + StakeAmount { + voting: Zero::zero(), + build_and_earn: Zero::zero(), + era: era_info_snapshot.current_stake_amount.era + 2, + period: era_info_snapshot.current_stake_amount.period + 1, + } + ); + } +} + #[test] fn stake_amount_works() { let mut stake_amount = StakeAmount::default(); @@ -1243,11 +1962,14 @@ fn singular_staking_info_basics_are_ok() { assert_eq!(staking_info.period_number(), period_number); assert!(staking_info.is_loyal()); assert!(staking_info.total_staked_amount().is_zero()); + assert!(staking_info.is_empty()); + assert!(staking_info.era().is_zero()); assert!(!SingularStakingInfo::new(period_number, Subperiod::BuildAndEarn).is_loyal()); // Add some staked amount during `Voting` period + let era_1 = 7; let vote_stake_amount_1 = 11; - staking_info.stake(vote_stake_amount_1, Subperiod::Voting); + staking_info.stake(vote_stake_amount_1, era_1, Subperiod::Voting); assert_eq!(staking_info.total_staked_amount(), vote_stake_amount_1); assert_eq!( staking_info.staked_amount(Subperiod::Voting), @@ -1256,10 +1978,16 @@ fn singular_staking_info_basics_are_ok() { assert!(staking_info .staked_amount(Subperiod::BuildAndEarn) .is_zero()); + assert_eq!( + staking_info.era(), + era_1 + 1, + "Stake era should remain valid." + ); // Add some staked amount during `BuildAndEarn` period + let era_2 = 9; let bep_stake_amount_1 = 23; - staking_info.stake(bep_stake_amount_1, Subperiod::BuildAndEarn); + staking_info.stake(bep_stake_amount_1, era_2, Subperiod::BuildAndEarn); assert_eq!( staking_info.total_staked_amount(), vote_stake_amount_1 + bep_stake_amount_1 @@ -1272,6 +2000,7 @@ fn singular_staking_info_basics_are_ok() { staking_info.staked_amount(Subperiod::BuildAndEarn), bep_stake_amount_1 ); + assert_eq!(staking_info.era(), era_2 + 1); } #[test] @@ -1281,13 +2010,14 @@ fn singular_staking_info_unstake_during_voting_is_ok() { let mut staking_info = SingularStakingInfo::new(period_number, subperiod); // Prep actions + let era_1 = 2; let vote_stake_amount_1 = 11; - staking_info.stake(vote_stake_amount_1, Subperiod::Voting); + staking_info.stake(vote_stake_amount_1, era_1, Subperiod::Voting); // Unstake some amount during `Voting` period, loyalty should remain as expected. let unstake_amount_1 = 5; assert_eq!( - staking_info.unstake(unstake_amount_1, Subperiod::Voting), + staking_info.unstake(unstake_amount_1, era_1, Subperiod::Voting), (unstake_amount_1, Balance::zero()) ); assert_eq!( @@ -1295,15 +2025,22 @@ fn singular_staking_info_unstake_during_voting_is_ok() { vote_stake_amount_1 - unstake_amount_1 ); assert!(staking_info.is_loyal()); + assert_eq!( + staking_info.era(), + era_1 + 1, + "Stake era should remain valid." + ); // Fully unstake, attempting to undersaturate, and ensure loyalty flag is still true. + let era_2 = era_1 + 2; let remaining_stake = staking_info.total_staked_amount(); assert_eq!( - staking_info.unstake(remaining_stake + 1, Subperiod::Voting), + staking_info.unstake(remaining_stake + 1, era_2, Subperiod::Voting), (remaining_stake, Balance::zero()) ); assert!(staking_info.total_staked_amount().is_zero()); assert!(staking_info.is_loyal()); + assert_eq!(staking_info.era(), era_2); } #[test] @@ -1313,15 +2050,16 @@ fn singular_staking_info_unstake_during_bep_is_ok() { let mut staking_info = SingularStakingInfo::new(period_number, subperiod); // Prep actions + let era_1 = 3; let vote_stake_amount_1 = 11; - staking_info.stake(vote_stake_amount_1, Subperiod::Voting); + staking_info.stake(vote_stake_amount_1, era_1 - 1, Subperiod::Voting); let bep_stake_amount_1 = 23; - staking_info.stake(bep_stake_amount_1, Subperiod::BuildAndEarn); + staking_info.stake(bep_stake_amount_1, era_1, Subperiod::BuildAndEarn); // 1st scenario - Unstake some of the amount staked during B&E period let unstake_1 = 5; assert_eq!( - staking_info.unstake(5, Subperiod::BuildAndEarn), + staking_info.unstake(5, era_1, Subperiod::BuildAndEarn), (Balance::zero(), unstake_1) ); assert_eq!( @@ -1337,6 +2075,11 @@ fn singular_staking_info_unstake_during_bep_is_ok() { bep_stake_amount_1 - unstake_1 ); assert!(staking_info.is_loyal()); + assert_eq!( + staking_info.era(), + era_1 + 1, + "Stake era should remain valid." + ); // 2nd scenario - unstake all of the amount staked during B&E period, and then some more. // The point is to take a chunk from the voting period stake too. @@ -1344,9 +2087,10 @@ fn singular_staking_info_unstake_during_bep_is_ok() { let current_bep_stake = staking_info.staked_amount(Subperiod::BuildAndEarn); let voting_stake_overflow = 2; let unstake_2 = current_bep_stake + voting_stake_overflow; + let era_2 = era_1 + 3; assert_eq!( - staking_info.unstake(unstake_2, Subperiod::BuildAndEarn), + staking_info.unstake(unstake_2, era_2, Subperiod::BuildAndEarn), (voting_stake_overflow, current_bep_stake) ); assert_eq!( @@ -1364,49 +2108,129 @@ fn singular_staking_info_unstake_during_bep_is_ok() { !staking_info.is_loyal(), "Loyalty flag should have been removed due to non-zero voting period unstake" ); + assert_eq!(staking_info.era(), era_2); } #[test] -fn contract_stake_info_get_works() { - let info_1 = StakeAmount::new(0, 0, 4, 2); - let info_2 = StakeAmount::new(11, 0, 7, 3); +fn contract_stake_amount_basic_get_checks_work() { + // Sanity checks for empty struct + let contract_stake = ContractStakeAmount { + staked: Default::default(), + staked_future: None, + tier_label: None, + }; + assert!(contract_stake.is_empty()); + assert!(contract_stake.latest_stake_period().is_none()); + assert!(contract_stake.latest_stake_era().is_none()); + assert!(contract_stake.total_staked_amount(0).is_zero()); + assert!(contract_stake.staked_amount(0, Subperiod::Voting).is_zero()); + assert!(contract_stake + .staked_amount(0, Subperiod::BuildAndEarn) + .is_zero()); + let era = 3; + let period = 2; + let amount = StakeAmount { + voting: 11, + build_and_earn: 17, + era, + period, + }; let contract_stake = ContractStakeAmount { - staked: info_1, - staked_future: Some(info_2), + staked: amount, + staked_future: None, + tier_label: None, }; + assert!(!contract_stake.is_empty()); - // Sanity check + // Checks for illegal periods + for illegal_period in [period - 1, period + 1] { + assert!(contract_stake.total_staked_amount(illegal_period).is_zero()); + assert!(contract_stake + .staked_amount(illegal_period, Subperiod::Voting) + .is_zero()); + assert!(contract_stake + .staked_amount(illegal_period, Subperiod::BuildAndEarn) + .is_zero()); + } + + // Check for the valid period + assert_eq!(contract_stake.latest_stake_period(), Some(period)); + assert_eq!(contract_stake.latest_stake_era(), Some(era)); + assert_eq!(contract_stake.total_staked_amount(period), amount.total()); + assert_eq!( + contract_stake.staked_amount(period, Subperiod::Voting), + amount.voting + ); + assert_eq!( + contract_stake.staked_amount(period, Subperiod::BuildAndEarn), + amount.build_and_earn + ); +} + +#[test] +fn contract_stake_amount_advanced_get_checks_work() { + let (era_1, era_2) = (4, 7); + let period = 2; + let amount_1 = StakeAmount { + voting: 11, + build_and_earn: 0, + era: era_1, + period, + }; + let amount_2 = StakeAmount { + voting: 11, + build_and_earn: 13, + era: era_2, + period, + }; + + let contract_stake = ContractStakeAmount { + staked: amount_1, + staked_future: Some(amount_2), + tier_label: None, + }; + + // Sanity checks - all values from the 'future' entry should be relevant assert!(!contract_stake.is_empty()); + assert_eq!(contract_stake.latest_stake_period(), Some(period)); + assert_eq!(contract_stake.latest_stake_era(), Some(era_2)); + assert_eq!(contract_stake.total_staked_amount(period), amount_2.total()); + assert_eq!( + contract_stake.staked_amount(period, Subperiod::Voting), + amount_2.voting + ); + assert_eq!( + contract_stake.staked_amount(period, Subperiod::BuildAndEarn), + amount_2.build_and_earn + ); // 1st scenario - get existing entries - assert_eq!(contract_stake.get(4, 2), Some(info_1)); - assert_eq!(contract_stake.get(7, 3), Some(info_2)); + assert_eq!(contract_stake.get(era_1, period), Some(amount_1)); + assert_eq!(contract_stake.get(era_2, period), Some(amount_2)); // 2nd scenario - get non-existing entries for covered eras - { - let era_1 = 6; - let entry_1 = contract_stake.get(era_1, 2).expect("Has to be Some"); - assert!(entry_1.total().is_zero()); - assert_eq!(entry_1.era, era_1); - assert_eq!(entry_1.period, 2); - - let era_2 = 8; - let entry_1 = contract_stake.get(era_2, 3).expect("Has to be Some"); - assert_eq!(entry_1.total(), 11); - assert_eq!(entry_1.era, era_2); - assert_eq!(entry_1.period, 3); - } + let era_3 = era_2 - 1; + let entry_1 = contract_stake.get(era_3, 2).expect("Has to be Some"); + assert_eq!(entry_1.total(), amount_1.total()); + assert_eq!(entry_1.era, era_3); + assert_eq!(entry_1.period, period); + + let era_4 = era_2 + 1; + let entry_1 = contract_stake.get(era_4, period).expect("Has to be Some"); + assert_eq!(entry_1.total(), amount_2.total()); + assert_eq!(entry_1.era, era_4); + assert_eq!(entry_1.period, period); // 3rd scenario - get non-existing entries for covered eras but mismatching period - assert!(contract_stake.get(8, 2).is_none()); + assert!(contract_stake.get(8, period + 1).is_none()); // 4th scenario - get non-existing entries for non-covered eras - assert!(contract_stake.get(3, 2).is_none()); + assert!(contract_stake.get(3, period).is_none()); } #[test] -fn contract_stake_info_stake_is_ok() { +fn contract_stake_amount_stake_is_ok() { let mut contract_stake = ContractStakeAmount::default(); // 1st scenario - stake some amount and verify state change @@ -1421,6 +2245,11 @@ fn contract_stake_info_stake_is_ok() { let amount_1 = 31; contract_stake.stake(amount_1, period_info_1, era_1); assert!(!contract_stake.is_empty()); + assert!( + contract_stake.staked.is_empty(), + "Only future entry should be modified." + ); + assert!(contract_stake.staked_future.is_some()); assert!( contract_stake.get(era_1, period_1).is_none(), @@ -1432,6 +2261,8 @@ fn contract_stake_info_stake_is_ok() { "Stake is only valid from next era." ); assert_eq!(entry_1_1.total(), amount_1); + assert_eq!(entry_1_1.for_type(Subperiod::Voting), amount_1); + assert!(entry_1_1.for_type(Subperiod::BuildAndEarn).is_zero()); // 2nd scenario - stake some more to the same era but different period type, and verify state change. let period_info_1 = PeriodInfo { @@ -1443,6 +2274,13 @@ fn contract_stake_info_stake_is_ok() { let entry_1_2 = contract_stake.get(stake_era_1, period_1).unwrap(); assert_eq!(entry_1_2.era, stake_era_1); assert_eq!(entry_1_2.total(), amount_1 * 2); + assert_eq!(entry_1_2.for_type(Subperiod::Voting), amount_1); + assert_eq!(entry_1_2.for_type(Subperiod::BuildAndEarn), amount_1); + assert!( + contract_stake.staked.is_empty(), + "Only future entry should be modified." + ); + assert!(contract_stake.staked_future.is_some()); // 3rd scenario - stake more to the next era, while still in the same period. let era_2 = era_1 + 2; @@ -1459,6 +2297,11 @@ fn contract_stake_info_stake_is_ok() { entry_2_1.total() + amount_2, "Since it's the same period, stake amount must carry over from the previous entry." ); + assert!( + !contract_stake.staked.is_empty(), + "staked should keep the old future entry" + ); + assert!(contract_stake.staked_future.is_some()); // 4th scenario - stake some more to the next era, but this time also bump the period. let era_3 = era_2 + 3; @@ -1488,6 +2331,11 @@ fn contract_stake_info_stake_is_ok() { amount_3, "No carry over from previous entry since period has changed." ); + assert!( + contract_stake.staked.is_empty(), + "New period, all stakes should be reset so 'staked' should be empty." + ); + assert!(contract_stake.staked_future.is_some()); // 5th scenario - stake to the next era let era_4 = era_3 + 1; @@ -1500,10 +2348,15 @@ fn contract_stake_info_stake_is_ok() { assert_eq!(entry_4_2.era, stake_era_4); assert_eq!(entry_4_2.period, period_2); assert_eq!(entry_4_2.total(), amount_3 + amount_4); + assert!( + !contract_stake.staked.is_empty(), + "staked should keep the old future entry" + ); + assert!(contract_stake.staked_future.is_some()); } #[test] -fn contract_stake_info_unstake_is_ok() { +fn contract_stake_amount_unstake_is_ok() { let mut contract_stake = ContractStakeAmount::default(); // Prep action - create a stake entry @@ -1528,24 +2381,65 @@ fn contract_stake_info_unstake_is_ok() { contract_stake.staked_amount(period, Subperiod::Voting), stake_amount - amount_1 ); + assert!(contract_stake.staked.is_empty()); + assert!(contract_stake.staked_future.is_some()); - // 2nd scenario - unstake in the future era, entries should be aligned to the current era + // 2nd scenario - unstake in the next era let period_info = PeriodInfo { number: period, subperiod: Subperiod::BuildAndEarn, subperiod_end_era: 40, }; - let era_2 = era_1 + 3; + let era_2 = era_1 + 1; + + contract_stake.unstake(amount_1, period_info, era_2); + assert_eq!( + contract_stake.total_staked_amount(period), + stake_amount - amount_1 * 2 + ); + assert_eq!( + contract_stake.staked_amount(period, Subperiod::Voting), + stake_amount - amount_1 * 2 + ); + assert!( + !contract_stake.staked.is_empty(), + "future entry should be moved over to the current entry" + ); + assert!( + contract_stake.staked_future.is_none(), + "future entry should be cleaned up since it refers to the current era" + ); + + // 3rd scenario - bump up unstake eras by more than 1, entries should be aligned to the current era + let era_3 = era_2 + 3; let amount_2 = 7; - contract_stake.unstake(amount_2, period_info, era_2); + contract_stake.unstake(amount_2, period_info, era_3); assert_eq!( contract_stake.total_staked_amount(period), - stake_amount - amount_1 - amount_2 + stake_amount - amount_1 * 2 - amount_2 ); assert_eq!( contract_stake.staked_amount(period, Subperiod::Voting), - stake_amount - amount_1 - amount_2 + stake_amount - amount_1 * 2 - amount_2 + ); + assert_eq!( + contract_stake.staked.era, era_3, + "Should be aligned to the current era." + ); + assert!( + contract_stake.staked_future.is_none(), + "future enry should remain 'None'" + ); + + // 4th scenario - do a full unstake with existing future entry, expect a cleanup + contract_stake.stake(stake_amount, period_info, era_3); + contract_stake.unstake( + contract_stake.total_staked_amount(period), + period_info, + era_3, ); + assert!(contract_stake.staked.is_empty()); + assert!(contract_stake.staked_future.is_none()); } #[test] @@ -1586,6 +2480,14 @@ fn era_reward_span_push_and_get_works() { // Get the values and verify they are as expected assert_eq!(era_reward_span.get(era_1), Some(&era_reward_1)); assert_eq!(era_reward_span.get(era_2), Some(&era_reward_2)); + + // Try and get the values outside of the span + assert!(era_reward_span + .get(era_reward_span.first_era() - 1) + .is_none()); + assert!(era_reward_span + .get(era_reward_span.last_era() + 1) + .is_none()); } #[test] @@ -1622,7 +2524,102 @@ fn era_reward_span_fails_when_expected() { } #[test] -fn tier_slot_configuration_basic_tests() { +fn tier_threshold_is_ok() { + let amount = 100; + + // Fixed TVL + let fixed_threshold = TierThreshold::FixedTvlAmount { amount }; + assert!(fixed_threshold.is_satisfied(amount)); + assert!(fixed_threshold.is_satisfied(amount + 1)); + assert!(!fixed_threshold.is_satisfied(amount - 1)); + + // Dynamic TVL + let dynamic_threshold = TierThreshold::DynamicTvlAmount { + amount, + minimum_amount: amount / 2, // not important + }; + assert!(dynamic_threshold.is_satisfied(amount)); + assert!(dynamic_threshold.is_satisfied(amount + 1)); + assert!(!dynamic_threshold.is_satisfied(amount - 1)); +} + +#[test] +fn tier_params_check_is_ok() { + // Prepare valid params + get_u32_type!(TiersNum, 3); + let params = TierParameters:: { + reward_portion: BoundedVec::try_from(vec![ + Permill::from_percent(60), + Permill::from_percent(30), + Permill::from_percent(10), + ]) + .unwrap(), + slot_distribution: BoundedVec::try_from(vec![ + Permill::from_percent(10), + Permill::from_percent(20), + Permill::from_percent(70), + ]) + .unwrap(), + tier_thresholds: BoundedVec::try_from(vec![ + TierThreshold::DynamicTvlAmount { + amount: 1000, + minimum_amount: 100, + }, + TierThreshold::DynamicTvlAmount { + amount: 100, + minimum_amount: 10, + }, + TierThreshold::FixedTvlAmount { amount: 10 }, + ]) + .unwrap(), + }; + assert!(params.is_valid()); + + // 1st scenario - sums are below 100%, and that is ok + let mut new_params = params.clone(); + new_params.reward_portion = BoundedVec::try_from(vec![ + Permill::from_percent(59), + Permill::from_percent(30), + Permill::from_percent(10), + ]) + .unwrap(); + new_params.slot_distribution = BoundedVec::try_from(vec![ + Permill::from_percent(10), + Permill::from_percent(19), + Permill::from_percent(70), + ]) + .unwrap(); + assert!(params.is_valid()); + + // 2nd scenario - reward portion is too much + let mut new_params = params.clone(); + new_params.reward_portion = BoundedVec::try_from(vec![ + Permill::from_percent(61), + Permill::from_percent(30), + Permill::from_percent(10), + ]) + .unwrap(); + assert!(!new_params.is_valid()); + + // 3rd scenario - tier distribution is too much + let mut new_params = params.clone(); + new_params.slot_distribution = BoundedVec::try_from(vec![ + Permill::from_percent(10), + Permill::from_percent(20), + Permill::from_percent(71), + ]) + .unwrap(); + assert!(!new_params.is_valid()); + + // 4th scenario - incorrect vector length + let mut new_params = params.clone(); + new_params.tier_thresholds = + BoundedVec::try_from(vec![TierThreshold::FixedTvlAmount { amount: 10 }]).unwrap(); + assert!(!new_params.is_valid()); +} + +#[test] +fn tier_configuration_basic_tests() { // TODO: this should be expanded & improved later get_u32_type!(TiersNum, 4); let params = TierParameters:: { @@ -1669,9 +2666,79 @@ fn tier_slot_configuration_basic_tests() { assert!(init_config.is_valid(), "Init config must be valid!"); // Create a new config, based on a new price - let new_price = FixedU64::from_rational(20, 100); // in production will be expressed in USD - let new_config = init_config.calculate_new(new_price, ¶ms); + let high_price = FixedU64::from_rational(20, 100); // in production will be expressed in USD + let new_config = init_config.calculate_new(high_price, ¶ms); + assert!(new_config.is_valid()); + + let low_price = FixedU64::from_rational(1, 100); // in production will be expressed in USD + let new_config = init_config.calculate_new(low_price, ¶ms); assert!(new_config.is_valid()); // TODO: expand tests, add more sanity checks (e.g. tier 3 requirement should never be lower than tier 4, etc.) } + +#[test] +fn dapp_tier_rewards_basic_tests() { + get_u32_type!(NumberOfDApps, 8); + get_u32_type!(NumberOfTiers, 3); + + // Example dApps & rewards + let dapps = vec![ + DAppTier { + dapp_id: 1, + tier_id: Some(0), + }, + DAppTier { + dapp_id: 2, + tier_id: Some(0), + }, + DAppTier { + dapp_id: 3, + tier_id: Some(1), + }, + DAppTier { + dapp_id: 5, + tier_id: Some(1), + }, + DAppTier { + dapp_id: 6, + tier_id: Some(2), + }, + ]; + let tier_rewards = vec![300, 20, 1]; + let period = 2; + + let mut dapp_tier_rewards = DAppTierRewards::::new( + dapps.clone(), + tier_rewards.clone(), + period, + ) + .expect("Bounds are respected."); + + // 1st scenario - claim reward for a dApps + let tier_id = dapps[0].tier_id.unwrap(); + assert_eq!( + dapp_tier_rewards.try_claim(dapps[0].dapp_id), + Ok((tier_rewards[tier_id as usize], tier_id)) + ); + + let tier_id = dapps[3].tier_id.unwrap(); + assert_eq!( + dapp_tier_rewards.try_claim(dapps[3].dapp_id), + Ok((tier_rewards[tier_id as usize], tier_id)) + ); + + // 2nd scenario - try to claim already claimed reward + assert_eq!( + dapp_tier_rewards.try_claim(dapps[0].dapp_id), + Err(DAppTierError::RewardAlreadyClaimed), + "Cannot claim the same reward twice." + ); + + // 3rd scenario - claim for a dApp that is not in the list + assert_eq!( + dapp_tier_rewards.try_claim(4), + Err(DAppTierError::NoDAppInTiers), + "dApp doesn't exist in the list so no rewards can be claimed." + ); +} diff --git a/pallets/dapp-staking-v3/src/types.rs b/pallets/dapp-staking-v3/src/types.rs index 8949519e5a..b629a82774 100644 --- a/pallets/dapp-staking-v3/src/types.rs +++ b/pallets/dapp-staking-v3/src/types.rs @@ -63,14 +63,13 @@ //! * `DAppTier` - a compact struct describing a dApp's tier. //! * `DAppTierRewards` - composite of `DAppTier` objects, describing the entire reward distribution for a particular era. //! -//! TODO: some types are missing so double check before final merge that everything is covered and explained correctly use frame_support::{pallet_prelude::*, BoundedVec}; use frame_system::pallet_prelude::*; use parity_scale_codec::{Decode, Encode}; use sp_arithmetic::fixed_point::FixedU64; use sp_runtime::{ - traits::{AtLeast32BitUnsigned, UniqueSaturatedInto, Zero}, + traits::{AtLeast32BitUnsigned, CheckedAdd, UniqueSaturatedInto, Zero}, FixedPointNumber, Permill, Saturating, }; pub use sp_std::{fmt::Debug, vec::Vec}; @@ -84,16 +83,7 @@ pub type AccountLedgerFor = AccountLedger, ::M // Convenience type for `DAppTierRewards` usage. pub type DAppTierRewardsFor = - DAppTierRewards, ::NumberOfTiers>; - -// Helper struct for converting `u16` getter into `u32` -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub struct MaxNumberOfContractsU32(PhantomData); -impl Get for MaxNumberOfContractsU32 { - fn get() -> u32 { - T::MaxNumberOfContracts::get() as u32 - } -} + DAppTierRewards<::MaxNumberOfContracts, ::NumberOfTiers>; /// Era number type pub type EraNumber = u32; @@ -121,6 +111,8 @@ pub enum AccountLedgerError { NothingToClaim, /// Rewards have already been claimed AlreadyClaimed, + /// Attempt to crate the iterator failed due to incorrect data. + InvalidIterator, } /// Distinct subperiods in dApp staking protocol. @@ -148,7 +140,7 @@ pub struct PeriodInfo { /// Period number. #[codec(compact)] pub number: PeriodNumber, - /// subperiod. + /// Subperiod type. pub subperiod: Subperiod, /// Last era of the subperiod, after this a new subperiod should start. #[codec(compact)] @@ -157,7 +149,7 @@ pub struct PeriodInfo { impl PeriodInfo { /// `true` if the provided era belongs to the next period, `false` otherwise. - /// It's only possible to provide this information for the `BuildAndEarn` subperiod. + /// It's only possible to provide this information correctly for the ongoing `BuildAndEarn` subperiod. pub fn is_next_period(&self, era: EraNumber) -> bool { self.subperiod == Subperiod::BuildAndEarn && self.subperiod_end_era <= era } @@ -238,7 +230,7 @@ where self.period_info.subperiod_end_era } - /// Checks whether a new era should be triggered, based on the provided `BlockNumber` argument + /// Checks whether a new era should be triggered, based on the provided _current_ block number argument /// or possibly other protocol state parameters. pub fn is_new_era(&self, now: BlockNumber) -> bool { self.next_era_start <= now @@ -286,6 +278,8 @@ pub struct DAppInfo { pub state: DAppState, // If `None`, rewards goes to the developer account, otherwise to the account Id in `Some`. pub reward_destination: Option, + /// If `Some(_)` dApp has a tier label which can influence the tier assignment. + pub tier_label: Option, } impl DAppInfo { @@ -296,6 +290,11 @@ impl DAppInfo { None => &self.owner, } } + + /// `true` if dApp is still active (registered), `false` otherwise. + pub fn is_active(&self) -> bool { + self.state == DAppState::Registered + } } /// How much was unlocked in some block. @@ -337,10 +336,10 @@ pub struct AccountLedger< BlockNumber: AtLeast32BitUnsigned + MaxEncodedLen + Copy + Debug, UnlockingLen: Get, > { - /// How much active locked amount an account has. + /// How much active locked amount an account has. This can be used for staking. #[codec(compact)] pub locked: Balance, - /// Vector of all the unlocking chunks. + /// Vector of all the unlocking chunks. This is also considered _locked_ but cannot be used for staking. pub unlocking: BoundedVec, UnlockingLen>, /// Primary field used to store how much was staked in a particular era. pub staked: StakeAmount, @@ -518,8 +517,10 @@ where } } - /// Verify that current era and period info arguments are valid for `stake` and `unstake` operations. - fn verify_stake_unstake_args( + /// Check for stake/unstake operation era & period arguments. + /// + /// Ensures that the provided era & period are valid according to the current ledger state. + fn stake_unstake_argument_check( &self, era: EraNumber, current_period_info: &PeriodInfo, @@ -532,17 +533,15 @@ where if self.staked.period != current_period_info.number { return Err(AccountLedgerError::InvalidPeriod); } - // In case it doesn't (i.e. first time staking), then the future era must match exactly - // one era after the one provided via argument. + // In case it doesn't (i.e. first time staking), then the future era must either be the current or the next era. } else if let Some(stake_amount) = self.staked_future { - if stake_amount.era != era.saturating_add(1) { + if stake_amount.era != era.saturating_add(1) && stake_amount.era != era { return Err(AccountLedgerError::InvalidEra); } if stake_amount.period != current_period_info.number { return Err(AccountLedgerError::InvalidPeriod); } } - Ok(()) } @@ -567,7 +566,7 @@ where return Ok(()); } - self.verify_stake_unstake_args(era, ¤t_period_info)?; + self.stake_unstake_argument_check(era, ¤t_period_info)?; if self.stakeable_amount(current_period_info.number) < amount { return Err(AccountLedgerError::UnavailableStakeFunds); @@ -605,7 +604,7 @@ where return Ok(()); } - self.verify_stake_unstake_args(era, ¤t_period_info)?; + self.stake_unstake_argument_check(era, ¤t_period_info)?; // User must be precise with their unstake amount. if self.staked_amount(current_period_info.number) < amount { @@ -653,12 +652,12 @@ where /// Cleanup staking information if it has expired. /// /// # Args - /// `threshold_period` - last period for which entries can still be considered valid. + /// `valid_threshold_period` - last period for which entries can still be considered valid. /// /// `true` if any change was made, `false` otherwise. - pub fn maybe_cleanup_expired(&mut self, threshold_period: PeriodNumber) -> bool { + pub fn maybe_cleanup_expired(&mut self, valid_threshold_period: PeriodNumber) -> bool { match self.staked_period() { - Some(staked_period) if staked_period < threshold_period => { + Some(staked_period) if staked_period < valid_threshold_period => { self.staked = Default::default(); self.staked_future = None; true @@ -678,22 +677,23 @@ where period_end: Option, ) -> Result { // Main entry exists, but era isn't 'in history' - if !self.staked.is_empty() && era <= self.staked.era { - return Err(AccountLedgerError::NothingToClaim); + if !self.staked.is_empty() { + ensure!(era >= self.staked.era, AccountLedgerError::NothingToClaim); } else if let Some(stake_amount) = self.staked_future { // Future entry exists, but era isn't 'in history' - if era < stake_amount.era { - return Err(AccountLedgerError::NothingToClaim); - } + ensure!(era >= stake_amount.era, AccountLedgerError::NothingToClaim); } // There are multiple options: // 1. We only have future entry, no current entry - // 2. We have both current and future entry - // 3. We only have current entry, no future entry + // 2. We have both current and future entry, but are only claiming 1 era + // 3. We have both current and future entry, and are claiming multiple eras + // 4. We only have current entry, no future entry let (span, maybe_first) = if let Some(stake_amount) = self.staked_future { if self.staked.is_empty() { ((stake_amount.era, era, stake_amount.total()), None) + } else if self.staked.era == era { + ((era, era, self.staked.total()), None) } else { ( (stake_amount.era, era, stake_amount.total()), @@ -704,7 +704,8 @@ where ((self.staked.era, era, self.staked.total()), None) }; - let result = EraStakePairIter::new(span, maybe_first); + let result = EraStakePairIter::new(span, maybe_first) + .map_err(|_| AccountLedgerError::InvalidIterator)?; // Rollover future to 'current' stake amount if let Some(stake_amount) = self.staked_future.take() { @@ -714,7 +715,7 @@ where // Make sure to clean up the entries if all rewards for the period have been claimed. match period_end { - Some(subperiod_end_era) if era >= subperiod_end_era => { + Some(period_end_era) if era >= period_end_era => { self.staked = Default::default(); self.staked_future = None; } @@ -752,20 +753,25 @@ impl EraStakePairIter { pub fn new( span: (EraNumber, EraNumber, Balance), maybe_first: Option<(EraNumber, Balance)>, - ) -> Self { - if let Some((era, _amount)) = maybe_first { - debug_assert!( - span.0 == era + 1, - "The 'other', if it exists, must cover era preceding the span." - ); + ) -> Result { + // First era must be smaller or equal to the last era. + if span.0 > span.1 { + return Err(()); + } + // If 'maybe_first' is defined, it must exactly match the `span.0 - 1` era value. + match maybe_first { + Some((era, _)) if span.0.saturating_sub(era) != 1 => { + return Err(()); + } + _ => (), } - Self { + Ok(Self { maybe_first, start_era: span.0, end_era: span.1, amount: span.2, - } + }) } } @@ -807,21 +813,6 @@ pub struct StakeAmount { } impl StakeAmount { - /// Create new instance of `StakeAmount` with specified `voting` and `build_and_earn` amounts. - pub fn new( - voting: Balance, - build_and_earn: Balance, - era: EraNumber, - period: PeriodNumber, - ) -> Self { - Self { - voting, - build_and_earn, - era, - period, - } - } - /// `true` if nothing is staked, `false` otherwise pub fn is_empty(&self) -> bool { self.voting.is_zero() && self.build_and_earn.is_zero() @@ -862,6 +853,10 @@ impl StakeAmount { self.build_and_earn.saturating_reduce(amount); } else { // Rollover from build&earn to voting, is guaranteed to be larger than zero due to previous check + // E.g. voting = 10, build&earn = 5, amount = 7 + // underflow = build&earn - amount = 5 - 7 = -2 + // voting = 10 - 2 = 8 + // build&earn = 0 let remainder = amount.saturating_sub(self.build_and_earn); self.build_and_earn = Balance::zero(); self.voting.saturating_reduce(remainder); @@ -874,12 +869,8 @@ impl StakeAmount { /// Info about current era, including the rewards, how much is locked, unlocking, etc. #[derive(Encode, Decode, MaxEncodedLen, Copy, Clone, Debug, PartialEq, Eq, TypeInfo, Default)] pub struct EraInfo { - /// How much balance is considered to be locked in the current era. - /// This value influences the reward distribution. - #[codec(compact)] - pub active_era_locked: Balance, - /// How much balance is locked in dApp staking, in total. - /// For rewards, this amount isn't relevant for the current era, but only from the next one. + /// How much balance is locked in dApp staking. + /// Does not include the amount that is undergoing the unlocking period. #[codec(compact)] pub total_locked: Balance, /// How much balance is undergoing unlocking process. @@ -900,7 +891,6 @@ impl EraInfo { /// 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); } @@ -946,15 +936,19 @@ impl EraInfo { /// ## Args /// `next_subperiod` - `None` if no subperiod change, `Some(type)` if `type` is starting from the next era. pub fn migrate_to_next_era(&mut self, next_subperiod: Option) { - self.active_era_locked = self.total_locked; match next_subperiod { // If next era marks start of new voting period period, it means we're entering a new period Some(Subperiod::Voting) => { - self.current_stake_amount = Default::default(); - self.next_stake_amount = Default::default(); + for stake_amount in [&mut self.current_stake_amount, &mut self.next_stake_amount] { + stake_amount.voting = Zero::zero(); + stake_amount.build_and_earn = Zero::zero(); + stake_amount.era.saturating_inc(); + stake_amount.period.saturating_inc(); + } } Some(Subperiod::BuildAndEarn) | None => { self.current_stake_amount = self.next_stake_amount; + self.next_stake_amount.era.saturating_inc(); } }; } @@ -980,16 +974,22 @@ impl SingularStakingInfo { /// `subperiod` - subperiod during which this entry is created. pub fn new(period: PeriodNumber, subperiod: Subperiod) -> Self { Self { - staked: StakeAmount::new(Balance::zero(), Balance::zero(), 0, period), + staked: StakeAmount { + voting: Balance::zero(), + build_and_earn: Balance::zero(), + era: 0, + period, + }, // Loyalty staking is only possible if stake is first made during the voting period. loyal_staker: subperiod == Subperiod::Voting, } } /// Stake the specified amount on the contract, for the specified subperiod. - pub fn stake(&mut self, amount: Balance, subperiod: Subperiod) { - // TODO: if we keep `StakeAmount` type here, consider including the era as well for consistency + pub fn stake(&mut self, amount: Balance, current_era: EraNumber, subperiod: Subperiod) { self.staked.add(amount, subperiod); + // Stake is only valid from the next era so we keep it consistent here + self.staked.era = current_era.saturating_add(1); } /// Unstakes some of the specified amount from the contract. @@ -998,10 +998,17 @@ impl SingularStakingInfo { /// and `voting period` has passed, this will remove the _loyalty_ flag from the staker. /// /// Returns the amount that was unstaked from the `voting period` stake, and from the `build&earn period` stake. - pub fn unstake(&mut self, amount: Balance, subperiod: Subperiod) -> (Balance, Balance) { + pub fn unstake( + &mut self, + amount: Balance, + current_era: EraNumber, + subperiod: Subperiod, + ) -> (Balance, Balance) { let snapshot = self.staked; self.staked.subtract(amount, subperiod); + // Keep the latest era for which the entry is valid + self.staked.era = self.staked.era.max(current_era); self.loyal_staker = self.loyal_staker && (subperiod == Subperiod::Voting @@ -1036,6 +1043,11 @@ impl SingularStakingInfo { self.staked.period } + /// Era in which the entry was last time updated + pub fn era(&self) -> EraNumber { + self.staked.era + } + /// `true` if no stake exists, `false` otherwise. pub fn is_empty(&self) -> bool { self.staked.is_empty() @@ -1056,7 +1068,10 @@ pub struct ContractStakeAmount { pub staked: StakeAmount, /// Staked amount in the next or 'future' era. pub staked_future: Option, + /// Tier label for the contract, if any. + pub tier_label: Option, } + impl ContractStakeAmount { /// `true` if series is empty, `false` otherwise. pub fn is_empty(&self) -> bool { @@ -1130,7 +1145,6 @@ impl ContractStakeAmount { /// Stake the specified `amount` on the contract, for the specified `subperiod` and `era`. pub fn stake(&mut self, amount: Balance, period_info: PeriodInfo, current_era: EraNumber) { - // TODO: tests need to be re-writen for this after the refactoring let stake_era = current_era.saturating_add(1); match self.staked_future.as_mut() { @@ -1168,8 +1182,6 @@ impl ContractStakeAmount { /// Unstake the specified `amount` from the contract, for the specified `subperiod` and `era`. pub fn unstake(&mut self, amount: Balance, period_info: PeriodInfo, current_era: EraNumber) { - // TODO: tests need to be re-writen for this after the refactoring - // First align entries - we only need to keep track of the current era, and the next one match self.staked_future { // Future entry exists, but it covers current or older era. @@ -1347,6 +1359,17 @@ impl TierThreshold { Self::DynamicTvlAmount { amount, .. } => stake >= *amount, } } + + /// Return threshold for the tier. + pub fn threshold(&self) -> Balance { + match self { + Self::FixedTvlAmount { amount } => *amount, + Self::DynamicTvlAmount { amount, .. } => *amount, + } + } + + // TODO: maybe add a check that compares `Self` to another threshold and ensures it has lower requirements? + // Could be useful to have this check as a sanity check when params are configured. } /// Top level description of tier slot parameters used to calculate tier configuration. @@ -1364,11 +1387,13 @@ impl TierThreshold { pub struct TierParameters> { /// Reward distribution per tier, in percentage. /// First entry refers to the first tier, and so on. - /// The sum of all values must be exactly equal to 1. + /// The sum of all values must not exceed 100%. + /// In case it is less, portion of rewards will never be distributed. pub reward_portion: BoundedVec, /// Distribution of number of slots per tier, in percentage. /// First entry refers to the first tier, and so on. - /// The sum of all values must be exactly equal to 1. + /// The sum of all values must not exceed 100%. + /// In case it is less, slot capacity will never be fully filled. pub slot_distribution: BoundedVec, /// Requirements for entry into each tier. /// First entry refers to the first tier, and so on. @@ -1379,11 +1404,36 @@ impl> TierParameters { /// Check if configuration is valid. /// All vectors are expected to have exactly the amount of entries as `number_of_tiers`. pub fn is_valid(&self) -> bool { + // Reward portions sum should not exceed 100%. + if self + .reward_portion + .iter() + .fold(Some(Permill::zero()), |acc, permill| match acc { + Some(acc) => acc.checked_add(permill), + None => None, + }) + .is_none() + { + return false; + } + + // Slot distribution sum should not exceed 100%. + if self + .slot_distribution + .iter() + .fold(Some(Permill::zero()), |acc, permill| match acc { + Some(acc) => acc.checked_add(permill), + None => None, + }) + .is_none() + { + return false; + } + let number_of_tiers: usize = NT::get() as usize; number_of_tiers == self.reward_portion.len() && number_of_tiers == self.slot_distribution.len() && number_of_tiers == self.tier_thresholds.len() - // TODO: Make check more detailed, verify that entries sum up to 1 or 100% } } @@ -1602,6 +1652,8 @@ impl, NT: Get> DAppTierRewards { rewards: Vec, period: PeriodNumber, ) -> Result { + // TODO: should this part of the code ensure that dapps are sorted by Id? + let dapps = BoundedVec::try_from(dapps).map_err(|_| ())?; let rewards = BoundedVec::try_from(rewards).map_err(|_| ())?; Ok(Self { @@ -1613,7 +1665,7 @@ impl, NT: Get> DAppTierRewards { /// Consume reward for the specified dapp id, returning its amount and tier Id. /// In case dapp isn't applicable for rewards, or they have already been consumed, returns `None`. - pub fn try_consume(&mut self, dapp_id: DAppId) -> Result<(Balance, TierId), DAppTierError> { + pub fn try_claim(&mut self, dapp_id: DAppId) -> Result<(Balance, TierId), DAppTierError> { // Check if dApp Id exists. let dapp_idx = self .dapps @@ -1649,6 +1701,12 @@ pub enum DAppTierError { InternalError, } +/// Tier labels can be assigned to dApps in order to provide them benefits (or drawbacks) when being assigned into a tier. +#[derive(Encode, Decode, MaxEncodedLen, Copy, Clone, Debug, PartialEq, Eq, TypeInfo)] +pub enum TierLabel { + // Empty for now, on purpose. +} + /////////////////////////////////////////////////////////////////////// //////////// MOVE THIS TO SOME PRIMITIVES CRATE LATER //////////// /////////////////////////////////////////////////////////////////////// @@ -1682,39 +1740,3 @@ pub trait RewardPoolProvider { /// Get the bonus pool for stakers. fn bonus_reward_pool() -> Balance; } - -// TODO: these are experimental, don't review -#[derive(Encode, Decode, MaxEncodedLen, Copy, Clone, Debug, PartialEq, Eq, TypeInfo)] -pub struct ExperimentalContractStakeEntry { - #[codec(compact)] - pub dapp_id: DAppId, - #[codec(compact)] - pub voting: Balance, - #[codec(compact)] - pub build_and_earn: Balance, -} - -#[derive( - Encode, - Decode, - MaxEncodedLen, - RuntimeDebugNoBound, - PartialEqNoBound, - EqNoBound, - CloneNoBound, - TypeInfo, -)] -#[scale_info(skip_type_params(MD, NT))] -pub struct ExperimentalContractStakeEntries, NT: Get> { - /// DApps and their corresponding tiers (or `None` if they have been claimed in the meantime) - pub dapps: BoundedVec, - /// Rewards for each tier. First entry refers to the first tier, and so on. - pub rewards: BoundedVec, - /// Period during which this struct was created. - #[codec(compact)] - pub period: PeriodNumber, -} - -// TODO: temp experimental type, don't review -pub type ContractEntriesFor = - ExperimentalContractStakeEntries, ::NumberOfTiers>; diff --git a/runtime/local/src/lib.rs b/runtime/local/src/lib.rs index 12ec1e4062..ab8217a9ff 100644 --- a/runtime/local/src/lib.rs +++ b/runtime/local/src/lib.rs @@ -27,7 +27,7 @@ include!(concat!(env!("OUT_DIR"), "/wasm_binary.rs")); use frame_support::{ construct_runtime, parameter_types, traits::{ - AsEnsureOriginWithArg, ConstU128, ConstU16, ConstU32, ConstU64, Currency, EitherOfDiverse, + AsEnsureOriginWithArg, ConstU128, ConstU32, ConstU64, Currency, EitherOfDiverse, EqualPrivilegeOnly, FindAuthor, Get, InstanceFilter, Nothing, OnFinalize, WithdrawReasons, }, weights::{ @@ -479,11 +479,11 @@ impl pallet_dapp_staking_v3::Config for Runtime { type NativePriceProvider = DummyPriceProvider; type RewardPoolProvider = DummyRewardPoolProvider; type StandardEraLength = ConstU32<30>; // should be 1 minute per standard era - type StandardErasPerVotingPeriod = ConstU32<2>; - type StandardErasPerBuildAndEarnPeriod = ConstU32<10>; + type StandardErasPerVotingSubperiod = ConstU32<2>; + type StandardErasPerBuildAndEarnSubperiod = ConstU32<10>; type EraRewardSpanLength = ConstU32<8>; type RewardRetentionInPeriods = ConstU32<2>; - type MaxNumberOfContracts = ConstU16<100>; + type MaxNumberOfContracts = ConstU32<100>; type MaxUnlockingChunks = ConstU32<5>; type MinimumLockedAmount = ConstU128; type UnlockingPeriod = ConstU32<2>;