diff --git a/common/primitives/src/capacity.rs b/common/primitives/src/capacity.rs index 6200a1b9d6..dffe65e124 100644 --- a/common/primitives/src/capacity.rs +++ b/common/primitives/src/capacity.rs @@ -1,5 +1,7 @@ use crate::msa::MessageSourceId; use frame_support::traits::tokens::Balance; +use scale_info::TypeInfo; +use sp_core::{Decode, Encode, MaxEncodedLen, RuntimeDebug}; use sp_runtime::DispatchError; /// The type of a Reward Era @@ -55,3 +57,22 @@ pub trait Replenishable { /// Checks if an account can be replenished. fn can_replenish(msa_id: MessageSourceId) -> bool; } + +/// Result of checking a Boost History item to see if it's eligible for a reward. +#[derive( + Copy, Clone, Default, Encode, Eq, Decode, RuntimeDebug, MaxEncodedLen, PartialEq, TypeInfo, +)] + +pub struct UnclaimedRewardInfo { + /// The Reward Era for which this reward was earned + pub reward_era: RewardEra, + /// When this reward expires, i.e. can no longer be claimed + pub expires_at_block: BlockNumber, + /// The total staked in this era as of the current block + pub staked_amount: Balance, + /// The amount staked in this era that is eligible for rewards. Does not count additional amounts + /// staked in this era. + pub eligible_amount: Balance, + /// The amount in token of the reward (only if it can be calculated using only on chain data) + pub earned_amount: Balance, +} diff --git a/e2e/capacity/change_staking_target.test.ts b/e2e/capacity/change_staking_target.test.ts index acbfa737ab..448ef16397 100644 --- a/e2e/capacity/change_staking_target.test.ts +++ b/e2e/capacity/change_staking_target.test.ts @@ -5,8 +5,9 @@ import { getFundingSource } from '../scaffolding/funding'; import { createKeys, createMsaAndProvider, stakeToProvider, - CENTS, DOLLARS, createAndFundKeypair, createProviderKeysAndId -} from "../scaffolding/helpers"; + CENTS, DOLLARS, createAndFundKeypair, createProviderKeysAndId, getNonce, +} from '../scaffolding/helpers'; +import { KeyringPair } from '@polkadot/keyring/types'; const fundingSource = getFundingSource('capacity-replenishment'); @@ -39,8 +40,6 @@ describe("Capacity: change_staking_target", function() { await assert.rejects(call.signAndSend(), (err) => { assert. strictEqual(err?.name, 'InvalidTarget', `expected InvalidTarget, got ${err?.name}`); - // // {name: "InvalidTarget"} - // assert. strictEqual(err?.message, `Wrong value: expected`); return true; }); }); diff --git a/pallets/capacity/src/benchmarking.rs b/pallets/capacity/src/benchmarking.rs index deebfa02b2..4fc8b0c7e4 100644 --- a/pallets/capacity/src/benchmarking.rs +++ b/pallets/capacity/src/benchmarking.rs @@ -8,6 +8,7 @@ use frame_system::{pallet_prelude::BlockNumberFor, RawOrigin}; use parity_scale_codec::alloc::vec::Vec; const SEED: u32 = 0; +const REWARD_POOL_TOTAL: u32 = 2_000_000; fn assert_last_event(generic_event: ::RuntimeEvent) { frame_system::Pallet::::assert_last_event(generic_event.into()); @@ -87,19 +88,36 @@ fn fill_unlock_chunks(caller: &T::AccountId, count: u32) { UnstakeUnlocks::::set(caller, Some(unlocking)); } -fn fill_reward_pool_chunks() { - let chunk_len = T::RewardPoolChunkLength::get(); - let chunks = T::ProviderBoostHistoryLimit::get() / (chunk_len); - for i in 0..chunks { - let mut new_chunk = RewardPoolHistoryChunk::::new(); - for j in 0..chunk_len { - let era = (i + 1) * (j + 1); - assert_ok!(new_chunk.try_insert(era.into(), (1000u32 * era).into())); - } - ProviderBoostRewardPools::::set(i, Some(new_chunk)); +fn fill_reward_pool_chunks(current_era: RewardEra) { + let history_limit: RewardEra = ::ProviderBoostHistoryLimit::get(); + let starting_era: RewardEra = current_era - history_limit - 1u32; + for era in starting_era..current_era { + Capacity::::update_provider_boost_reward_pool(era, REWARD_POOL_TOTAL.into()); } } +fn fill_boost_history( + caller: &T::AccountId, + amount: BalanceOf, + current_era: RewardEra, +) { + let max_history: RewardEra = ::ProviderBoostHistoryLimit::get().into(); + let starting_era = current_era - max_history - 1u32; + for i in starting_era..current_era { + assert_ok!(Capacity::::upsert_boost_history(caller.into(), i, amount, true)); + } +} + +fn unclaimed_rewards_total(caller: &T::AccountId) -> BalanceOf { + let zero_balance: BalanceOf = 0u32.into(); + let rewards: Vec, BlockNumberFor>> = + Capacity::::list_unclaimed_rewards(caller).unwrap_or_default().to_vec(); + rewards + .iter() + .fold(zero_balance, |acc, reward_info| acc.saturating_add(reward_info.earned_amount)) + .into() +} + benchmarks! { stake { let caller: T::AccountId = create_funded_account::("account", SEED, 105u32); @@ -152,7 +170,7 @@ benchmarks! { let current_era: RewardEra = (history_limit + 1u32).into(); CurrentEraInfo::::set(RewardEraInfo{ era_index: current_era, started_at }); - fill_reward_pool_chunks::(); + fill_reward_pool_chunks::(current_era); }: { Capacity::::start_new_reward_era_if_needed(current_block); } verify { @@ -232,12 +250,26 @@ benchmarks! { }: _ (RawOrigin::Signed(caller.clone()), target, boost_amount) verify { - assert!(StakingAccountLedger::::contains_key(&caller)); - assert!(StakingTargetLedger::::contains_key(&caller, target)); - assert!(CapacityLedger::::contains_key(target)); assert_last_event::(Event::::ProviderBoosted {account: caller, amount: boost_amount, target, capacity}.into()); } + // TODO: vary the boost_history to get better weight estimates. + claim_staking_rewards { + let caller: T::AccountId = create_funded_account::("account", SEED, 5u32); + let from_msa = 33; + let boost_amount: BalanceOf = T::MinimumStakingAmount::get(); + setup_provider_stake::(&caller, &from_msa, boost_amount, false); + frame_system::Pallet::::set_block_number(1002u32.into()); + let current_era: RewardEra = 100; + set_era_and_reward_pool_at_block::(current_era, 1001u32.into(), REWARD_POOL_TOTAL.into()); + fill_reward_pool_chunks::(current_era); + fill_boost_history::(&caller, 100u32.into(), current_era); + let unclaimed_rewards = unclaimed_rewards_total::(&caller); + }: _ (RawOrigin::Signed(caller.clone())) + verify { + assert_last_event::(Event::::ProviderBoostRewardClaimed {account: caller.clone(), reward_amount: unclaimed_rewards}.into()); + } + impl_benchmark_test_suite!(Capacity, tests::mock::new_test_ext(), tests::mock::Test); diff --git a/pallets/capacity/src/lib.rs b/pallets/capacity/src/lib.rs index f1b2f6867f..5fe153eb70 100644 --- a/pallets/capacity/src/lib.rs +++ b/pallets/capacity/src/lib.rs @@ -31,25 +31,30 @@ use sp_std::ops::Mul; use frame_support::{ ensure, traits::{ + fungible::Inspect, tokens::fungible::{Inspect as InspectFungible, InspectFreeze, Mutate, MutateFreeze}, Get, Hooks, }, weights::Weight, }; -use frame_system::pallet_prelude::BlockNumberFor; + use sp_runtime::{ traits::{CheckedAdd, CheckedDiv, One, Saturating, Zero}, ArithmeticError, BoundedVec, DispatchError, Perbill, Permill, }; pub use common_primitives::{ - capacity::{Nontransferable, Replenishable, TargetValidator}, + capacity::*, msa::MessageSourceId, + node::{AccountId, Balance, BlockNumber}, utils::wrap_binary_data, }; +use frame_system::pallet_prelude::*; + #[cfg(feature = "runtime-benchmarks")] use common_primitives::benchmarks::RegisterProviderBenchmarkHelper; + pub use pallet::*; pub use types::*; pub use weights::*; @@ -68,15 +73,11 @@ pub mod weights; type BalanceOf = <::Currency as InspectFungible<::AccountId>>::Balance; -use crate::StakingType::ProviderBoost; -use common_primitives::capacity::RewardEra; -use frame_system::pallet_prelude::*; - #[frame_support::pallet] pub mod pallet { use super::*; - use crate::StakingType::MaximumCapacity; + use crate::StakingType::*; use common_primitives::capacity::RewardEra; use frame_support::{ pallet_prelude::{StorageVersion, *}, @@ -343,6 +344,13 @@ pub mod pallet { /// The Capacity amount issued to the target as a result of the stake. capacity: BalanceOf, }, + /// Provider Boost Token Rewards have been minted and transferred to the staking account. + ProviderBoostRewardClaimed { + /// The token account claiming and receiving the reward from ProviderBoost staking + account: T::AccountId, + /// The reward amount + reward_amount: BalanceOf, + }, } #[pallet::error] @@ -395,6 +403,10 @@ pub mod pallet { MaxRetargetsExceeded, /// Tried to exceed bounds of a some Bounded collection CollectionBoundExceeded, + /// This origin has nothing staked for ProviderBoost. + NotAProviderBoostAccount, + /// There are no unpaid rewards to claim from ProviderBoost staking. + NothingToClaim, } #[pallet::hooks] @@ -594,6 +606,23 @@ pub mod pallet { Ok(()) } + + /// Claim all outstanding rewards earned from ProviderBoosting. + #[pallet::call_index(6)] + #[pallet::weight(T::WeightInfo::claim_staking_rewards())] + pub fn claim_staking_rewards(origin: OriginFor) -> DispatchResult { + let staker = ensure_signed(origin)?; + ensure!( + ProviderBoostHistories::::contains_key(staker.clone()), + Error::::NotAProviderBoostAccount + ); + let total_to_mint = Self::do_claim_rewards(&staker)?; + Self::deposit_event(Event::ProviderBoostRewardClaimed { + account: staker.clone(), + reward_amount: total_to_mint, + }); + Ok(()) + } } } @@ -643,8 +672,8 @@ impl Pallet { amount: &BalanceOf, ) -> Result<(StakingDetails, BalanceOf), DispatchError> { let (mut staking_details, stakable_amount) = - Self::ensure_can_stake(staker, *target, *amount, ProviderBoost)?; - staking_details.staking_type = ProviderBoost; + Self::ensure_can_stake(staker, *target, *amount, StakingType::ProviderBoost)?; + staking_details.staking_type = StakingType::ProviderBoost; Ok((staking_details, stakable_amount)) } @@ -772,7 +801,7 @@ impl Pallet { Self::set_staking_account(unstaker, &staking_account); let staking_type = staking_account.staking_type; - if staking_type == ProviderBoost { + if staking_type == StakingType::ProviderBoost { let era = Self::get_current_era().era_index; Self::upsert_boost_history(&unstaker, era, actual_unstaked_amount, false)?; let reward_pool_total = CurrentEraProviderBoostTotal::::get(); @@ -1015,8 +1044,16 @@ impl Pallet { Some(provider_boost_history) => { match provider_boost_history.count() { 0usize => false, - // they staked before the current era, so they have unclaimed rewards. - 1usize => provider_boost_history.get_entry_for_era(¤t_era).is_none(), + 1usize => { + // if there is just one era entry and: + // it's for the previous era, it means we've already paid out rewards for that era, or they just staked in the last era. + // or if it's for the current era, they only just started staking. + provider_boost_history + .get_entry_for_era(¤t_era.saturating_sub(1u32.into())) + .is_none() && provider_boost_history + .get_entry_for_era(¤t_era) + .is_none() + }, _ => true, } }, @@ -1024,28 +1061,25 @@ impl Pallet { } // 1r } - // this could be up to 35 reads. - #[allow(unused)] - pub(crate) fn list_unclaimed_rewards( + /// Get all unclaimed rewards information for each eligible Reward Era + pub fn list_unclaimed_rewards( account: &T::AccountId, - ) -> Result, T::ProviderBoostHistoryLimit>, DispatchError> { - let mut unclaimed_rewards: BoundedVec< - UnclaimedRewardInfo, + ) -> Result< + BoundedVec< + UnclaimedRewardInfo, BlockNumberFor>, T::ProviderBoostHistoryLimit, - > = BoundedVec::new(); - + >, + DispatchError, + > { if !Self::has_unclaimed_rewards(account) { - // 2r - return Ok(unclaimed_rewards); + return Ok(BoundedVec::new()); } let staking_history = - Self::get_staking_history_for(account).ok_or(Error::::NotAStakingAccount)?; // cached read from has_unclaimed_rewards + Self::get_staking_history_for(account).ok_or(Error::::NotAProviderBoostAccount)?; // cached read let current_era_info = Self::get_current_era(); // cached read, ditto - let max_history: u32 = T::ProviderBoostHistoryLimit::get(); // 1r - let era_length: u32 = T::EraLength::get(); // 1r length in blocks - let chunk_length: u32 = T::RewardPoolChunkLength::get(); + let max_history: u32 = T::ProviderBoostHistoryLimit::get(); let mut reward_era = current_era_info.era_index.saturating_sub((max_history).into()); let end_era = current_era_info.era_index.saturating_sub(One::one()); @@ -1054,6 +1088,10 @@ impl Pallet { let mut previous_amount: BalanceOf = staking_history.get_amount_staked_for_era(&(reward_era.saturating_sub(1u32.into()))); + let mut unclaimed_rewards: BoundedVec< + UnclaimedRewardInfo, BlockNumberFor>, + T::ProviderBoostHistoryLimit, + > = BoundedVec::new(); while reward_era.le(&end_era) { let staked_amount = staking_history.get_amount_staked_for_era(&reward_era); if !staked_amount.is_zero() { @@ -1075,6 +1113,7 @@ impl Pallet { .try_push(UnclaimedRewardInfo { reward_era, expires_at_block, + staked_amount, eligible_amount, earned_amount, }) @@ -1125,10 +1164,12 @@ impl Pallet { /// Example with history limit of 6 and chunk length 3: /// - Arrange the chunks such that we overwrite a complete chunk only when it is not needed /// - The cycle is thus era modulo (history limit + chunk length) - /// - `[0,1,2],[3,4,5],[6,7,8]` + /// - `[0,1,2],[3,4,5],[6,7,8],[]` + /// Note Chunks stored = (History Length / Chunk size) + 1 /// - The second step is which chunk to add to: /// - Divide the cycle by the chunk length and take the floor /// - Floor(5 / 3) = 1 + /// Chunk Index = Floor((era % (History Length + chunk size)) / chunk size) pub(crate) fn get_chunk_index_for_era(era: RewardEra) -> u32 { let history_limit: u32 = T::ProviderBoostHistoryLimit::get(); let chunk_len = T::RewardPoolChunkLength::get(); @@ -1141,11 +1182,6 @@ impl Pallet { } // This is where the reward pool gets updated. - // Example with Limit 6, Chunk 2: - // - [0,1], [2,3], [4,5] - // - [6], [2,3], [4,5] - // - [6,7], [2,3], [4,5] - // - [6,7], [8], [4,5] pub(crate) fn update_provider_boost_reward_pool(era: RewardEra, boost_total: BalanceOf) { // Current era is this era let chunk_idx: u32 = Self::get_chunk_index_for_era(era); @@ -1163,6 +1199,33 @@ impl Pallet { } ProviderBoostRewardPools::::set(chunk_idx, Some(new_chunk)); // 1w } + fn do_claim_rewards(staker: &T::AccountId) -> Result, DispatchError> { + let rewards = Self::list_unclaimed_rewards(&staker)?; + ensure!(!rewards.len().is_zero(), Error::::NothingToClaim); + let zero_balance: BalanceOf = 0u32.into(); + let total_to_mint: BalanceOf = rewards + .iter() + .fold(zero_balance, |acc, reward_info| acc.saturating_add(reward_info.earned_amount)) + .into(); + ensure!(total_to_mint.gt(&Zero::zero()), Error::::NothingToClaim); + let _minted_unused = T::Currency::mint_into(&staker, total_to_mint)?; + + let mut new_history: ProviderBoostHistory = ProviderBoostHistory::new(); + let last_staked_amount = + rewards.last().unwrap_or(&UnclaimedRewardInfo::default()).staked_amount; + let current_era = Self::get_current_era().era_index; + // We have already paid out for the previous era. Put one entry for the previous era as if that is when they staked, + // so they will be credited for current_era. + ensure!( + new_history + .add_era_balance(¤t_era.saturating_sub(1u32.into()), &last_staked_amount) + .is_some(), + Error::::CollectionBoundExceeded + ); + ProviderBoostHistories::::set(staker, Some(new_history)); + + Ok(total_to_mint) + } } /// Nontransferable functions are intended for capacity spend and recharge. @@ -1253,13 +1316,6 @@ impl ProviderBoostRewardsProvider for Pallet { T::RewardPoolEachEra::get() } - // TODO: implement or pull in list_unclaimed_rewards fn - fn staking_reward_totals( - _account_id: Self::AccountId, - ) -> Result, T::ProviderBoostHistoryLimit>, DispatchError> { - Ok(BoundedVec::new()) - } - /// Calculate the reward for a single era. We don't care about the era number, /// just the values. fn era_staking_reward( diff --git a/pallets/capacity/src/tests/change_staking_target_tests.rs b/pallets/capacity/src/tests/change_staking_target_tests.rs index bc84219cbf..8088903844 100644 --- a/pallets/capacity/src/tests/change_staking_target_tests.rs +++ b/pallets/capacity/src/tests/change_staking_target_tests.rs @@ -2,7 +2,7 @@ use super::{ mock::*, testing_utils::{capacity_events, setup_provider}, }; -use crate::{StakingType::*, *}; +use crate::*; use common_primitives::msa::MessageSourceId; use frame_support::{assert_noop, assert_ok, traits::Get}; @@ -40,7 +40,7 @@ fn do_retarget_happy_path() { let from_amount = 600u64; let to_amount = 300u64; let to_msa: MessageSourceId = 2; - let staking_type = ProviderBoost; + let staking_type = StakingType::ProviderBoost; setup_provider(&staker, &from_msa, &from_amount, staking_type.clone()); setup_provider(&staker, &to_msa, &to_amount, staking_type.clone()); @@ -67,8 +67,8 @@ fn do_retarget_flip_flop() { let from_amount = 600u64; let to_amount = 300u64; let to_msa: MessageSourceId = 2; - setup_provider(&staker, &from_msa, &from_amount, ProviderBoost); - setup_provider(&staker, &to_msa, &to_amount, ProviderBoost); + setup_provider(&staker, &from_msa, &from_amount, StakingType::ProviderBoost); + setup_provider(&staker, &to_msa, &to_amount, StakingType::ProviderBoost); for i in 0..4 { if i % 2 == 0 { @@ -92,8 +92,8 @@ fn check_retarget_rounding_errors() { let to_amount = 301u64; let to_msa: MessageSourceId = 2; - setup_provider(&staker, &from_msa, &from_amount, ProviderBoost); - setup_provider(&staker, &to_msa, &to_amount, ProviderBoost); + setup_provider(&staker, &from_msa, &from_amount, StakingType::ProviderBoost); + setup_provider(&staker, &to_msa, &to_amount, StakingType::ProviderBoost); assert_capacity_details(from_msa, 33, 666, 33); assert_capacity_details(to_msa, 15, 301, 15); // 666+301= 967, 3+1=4 @@ -135,8 +135,8 @@ fn check_retarget_multiple_stakers() { let amt1 = 192u64; let amt2 = 313u64; - setup_provider(&staker_10k, &from_msa, &647u64, ProviderBoost); - setup_provider(&staker_500, &to_msa, &293u64, ProviderBoost); + setup_provider(&staker_10k, &from_msa, &647u64, StakingType::ProviderBoost); + setup_provider(&staker_500, &to_msa, &293u64, StakingType::ProviderBoost); assert_ok!(Capacity::provider_boost( RuntimeOrigin::signed(staker_600.clone()), from_msa, @@ -172,8 +172,8 @@ fn do_retarget_deletes_staking_target_details_if_zero_balance() { let from_msa: MessageSourceId = 1; let to_msa: MessageSourceId = 2; let amount = 10u64; - setup_provider(&staker, &from_msa, &amount, MaximumCapacity); - setup_provider(&staker, &to_msa, &amount, MaximumCapacity); + setup_provider(&staker, &from_msa, &amount, StakingType::MaximumCapacity); + setup_provider(&staker, &to_msa, &amount, StakingType::MaximumCapacity); // stake additional to provider from another Msa, doesn't matter which type. // total staked to from_msa is now 22u64. @@ -220,8 +220,8 @@ fn change_staking_starget_emits_event_on_success() { let from_amount = 20u64; let to_amount = from_amount / 2; let to_msa: MessageSourceId = 2; - setup_provider(&staker, &from_msa, &from_amount, ProviderBoost); - setup_provider(&staker, &to_msa, &to_amount, ProviderBoost); + setup_provider(&staker, &from_msa, &from_amount, StakingType::ProviderBoost); + setup_provider(&staker, &to_msa, &to_amount, StakingType::ProviderBoost); assert_ok!(Capacity::change_staking_target( RuntimeOrigin::signed(staker), @@ -247,8 +247,8 @@ fn change_staking_target_errors_if_too_many_changes_before_thaw() { let max_chunks: u32 = ::MaxRetargetsPerRewardEra::get(); let staking_amount = ((max_chunks + 2u32) * 10u32) as u64; - setup_provider(&staker, &from_msa, &staking_amount, ProviderBoost); - setup_provider(&staker, &to_msa, &10u64, ProviderBoost); + setup_provider(&staker, &from_msa, &staking_amount, StakingType::ProviderBoost); + setup_provider(&staker, &to_msa, &10u64, StakingType::ProviderBoost); let retarget_amount = 10u64; for _i in 0..max_chunks { @@ -280,8 +280,8 @@ fn change_staking_target_garbage_collects_thawed_chunks() { let from_target: MessageSourceId = 3; let to_target: MessageSourceId = 4; - setup_provider(&staking_account, &from_target, &staked_amount, ProviderBoost); - setup_provider(&staking_account, &to_target, &staked_amount, ProviderBoost); + setup_provider(&staking_account, &from_target, &staked_amount, StakingType::ProviderBoost); + setup_provider(&staking_account, &to_target, &staked_amount, StakingType::ProviderBoost); CurrentEraInfo::::set(RewardEraInfo { era_index: 20, started_at: 100 }); let max_chunks = ::MaxUnlockingChunks::get(); @@ -311,16 +311,16 @@ fn change_staking_target_test_parametric_validity() { StakingAccountLedger::::insert( from_account, - StakingDetails { active: 20, staking_type: ProviderBoost }, + StakingDetails { active: 20, staking_type: StakingType::ProviderBoost }, ); let from_account_not_staking = 100u64; let from_target_not_staked: MessageSourceId = 1; let to_target_not_provider: MessageSourceId = 2; let from_target: MessageSourceId = 3; let to_target: MessageSourceId = 4; - setup_provider(&from_account, &from_target_not_staked, &0u64, ProviderBoost); - setup_provider(&from_account, &from_target, &staked_amount, ProviderBoost); - setup_provider(&from_account, &to_target, &staked_amount, ProviderBoost); + setup_provider(&from_account, &from_target_not_staked, &0u64, StakingType::ProviderBoost); + setup_provider(&from_account, &from_target, &staked_amount, StakingType::ProviderBoost); + setup_provider(&from_account, &to_target, &staked_amount, StakingType::ProviderBoost); assert_ok!(Capacity::provider_boost( RuntimeOrigin::signed(from_account), diff --git a/pallets/capacity/src/tests/claim_staking_rewards_tests.rs b/pallets/capacity/src/tests/claim_staking_rewards_tests.rs new file mode 100644 index 0000000000..70a7302c4e --- /dev/null +++ b/pallets/capacity/src/tests/claim_staking_rewards_tests.rs @@ -0,0 +1,157 @@ +use crate::{ + tests::{ + mock::{ + assert_transferable, get_balance, new_test_ext, Capacity, RuntimeOrigin, System, Test, + }, + testing_utils::{run_to_block, setup_provider}, + }, + Error, + Event::ProviderBoostRewardClaimed, + MessageSourceId, + StakingType::*, +}; +use frame_support::{assert_noop, assert_ok}; + +#[test] +fn claim_staking_rewards_leaves_one_history_item_for_current_era() { + new_test_ext().execute_with(|| { + let account = 10_000u64; + let target: MessageSourceId = 10; + let amount = 1_000u64; + + setup_provider(&account, &target, &amount, ProviderBoost); + run_to_block(31); + assert_eq!(Capacity::get_current_era().era_index, 4u32); + + let current_history = Capacity::get_staking_history_for(account).unwrap(); + assert_eq!(current_history.count(), 1usize); + let history_item = current_history.get_entry_for_era(&1u32).unwrap(); + assert_eq!(*history_item, amount); + }) +} + +#[test] +fn claim_staking_rewards_allows_unstake() { + new_test_ext().execute_with(|| { + let account = 10_000u64; + let target: MessageSourceId = 10; + let amount = 1_000u64; + + setup_provider(&account, &target, &amount, ProviderBoost); + run_to_block(31); + assert_noop!( + Capacity::unstake(RuntimeOrigin::signed(account), target, 100u64), + Error::::MustFirstClaimRewards + ); + assert_ok!(Capacity::claim_staking_rewards(RuntimeOrigin::signed(account))); + assert_ok!(Capacity::unstake(RuntimeOrigin::signed(account), target, 400u64)); + }) +} + +#[test] +fn claim_staking_rewards_mints_and_transfers_expected_total() { + new_test_ext().execute_with(|| { + let account = 10_000u64; + let target: MessageSourceId = 10; + let amount = 1_000u64; + + setup_provider(&account, &target, &amount, ProviderBoost); + run_to_block(31); + assert_eq!(Capacity::get_current_era().era_index, 4u32); + assert_ok!(Capacity::claim_staking_rewards(RuntimeOrigin::signed(account))); + System::assert_last_event( + Event::::ProviderBoostRewardClaimed { account, reward_amount: 8u64 }.into(), + ); + + // should have 2 era's worth of payouts: 4 each for eras 2, 3 + assert_eq!(get_balance::(&account), 10_008u64); + + // the reward value is unlocked + assert_transferable::(&account, 8u64); + + run_to_block(51); + assert_eq!(Capacity::get_current_era().era_index, 6u32); + assert_ok!(Capacity::claim_staking_rewards(RuntimeOrigin::signed(account))); + System::assert_last_event( + ProviderBoostRewardClaimed { account, reward_amount: 8u64 }.into(), + ); + // should have 4 for eras 2-5 + assert_eq!(get_balance::(&account), 10_016u64); + assert_transferable::(&account, 16u64); + }) +} + +#[test] +fn claim_staking_rewards_has_expected_total_when_other_stakers() { + new_test_ext().execute_with(|| { + let account = 10_000u64; + let target: MessageSourceId = 10; + let amount = 1_000u64; + + let other_staker = 600_u64; + let other_amount = 300_u64; + setup_provider(&other_staker, &target, &other_amount, ProviderBoost); + assert_ok!(Capacity::provider_boost(RuntimeOrigin::signed(account), target, amount)); + run_to_block(31); // 4 + assert_ok!(Capacity::claim_staking_rewards(RuntimeOrigin::signed(account))); + assert_eq!(get_balance::(&account), 10_008u64); + }) +} + +#[test] +fn claim_staking_rewards_has_expected_total_if_amount_should_be_capped() { + new_test_ext().execute_with(|| { + let account = 10_000u64; + let target: MessageSourceId = 10; + let amount = 9_900u64; + setup_provider(&account, &target, &amount, ProviderBoost); + run_to_block(31); + assert_ok!(Capacity::claim_staking_rewards(RuntimeOrigin::signed(account))); + assert_eq!(get_balance::(&account), 10_076u64); // cap is 0.38%,*2 = 76, rounded + assert_transferable::(&account, 76u64); + }) +} + +#[test] +fn claim_staking_rewards_fails_if_no_available_rewards() { + new_test_ext().execute_with(|| { + let account = 10_000u64; + let target: MessageSourceId = 10; + let amount = 1_000u64; + + setup_provider(&account, &target, &amount, ProviderBoost); + // Unclaimed rewards should have zero length b/c it's still Era 1. + // Nothing will be in history + assert_noop!( + Capacity::claim_staking_rewards(RuntimeOrigin::signed(account)), + Error::::NothingToClaim + ); + + run_to_block(15); // Era is 2, but still not available for staking rewards until era 3. + assert_noop!( + Capacity::claim_staking_rewards(RuntimeOrigin::signed(account)), + Error::::NothingToClaim + ); + }) +} + +#[test] +fn claim_staking_rewards_fails_if_stake_maximized() { + new_test_ext().execute_with(|| { + let account = 10_000u64; + let target: MessageSourceId = 10; + let amount = 1_000u64; + + assert_noop!( + Capacity::claim_staking_rewards(RuntimeOrigin::signed(account)), + Error::::NotAProviderBoostAccount + ); + + setup_provider(&account, &target, &amount, MaximumCapacity); + + assert_noop!( + Capacity::claim_staking_rewards(RuntimeOrigin::signed(account)), + Error::::NotAProviderBoostAccount + ); + }) +} diff --git a/pallets/capacity/src/tests/eras_tests.rs b/pallets/capacity/src/tests/eras_tests.rs index f30b17c464..9dd21411d1 100644 --- a/pallets/capacity/src/tests/eras_tests.rs +++ b/pallets/capacity/src/tests/eras_tests.rs @@ -172,11 +172,20 @@ fn get_chunk_index_for_era_works() { TestCase { era: 5, expected: 1 }, TestCase { era: 6, expected: 1 }, TestCase { era: 7, expected: 2 }, + TestCase { era: 8, expected: 2 }, + TestCase { era: 9, expected: 2 }, + TestCase { era: 10, expected: 3 }, TestCase { era: 11, expected: 3 }, + TestCase { era: 12, expected: 3 }, + TestCase { era: 13, expected: 4 }, // This is not wrong; there is an extra chunk to leave space for cycling + TestCase { era: 14, expected: 4 }, TestCase { era: 15, expected: 4 }, - TestCase { era: 16, expected: 0 }, + TestCase { era: 16, expected: 0 }, // So cycle restarts here, not at 13. + TestCase { era: 17, expected: 0 }, + TestCase { era: 18, expected: 0 }, TestCase { era: 22, expected: 2 }, TestCase { era: 55, expected: 3 }, + TestCase { era: 999, expected: 2 }, ] } { assert_eq!(Capacity::get_chunk_index_for_era(test.era), test.expected, "{:?}", test); diff --git a/pallets/capacity/src/tests/mock.rs b/pallets/capacity/src/tests/mock.rs index 7e2ff441d1..c0d0626577 100644 --- a/pallets/capacity/src/tests/mock.rs +++ b/pallets/capacity/src/tests/mock.rs @@ -2,7 +2,7 @@ use crate as pallet_capacity; use crate::{ tests::testing_utils::set_era_and_reward_pool, BalanceOf, Config, ProviderBoostRewardPools, - ProviderBoostRewardsProvider, RewardPoolHistoryChunk, UnclaimedRewardInfo, + ProviderBoostRewardsProvider, RewardPoolHistoryChunk, }; use common_primitives::{ node::{AccountId, Hash, ProposalProvider}, @@ -10,8 +10,10 @@ use common_primitives::{ }; use frame_support::{ construct_runtime, parameter_types, - traits::{ConstU16, ConstU32, ConstU64}, - BoundedVec, + traits::{ + tokens::{fungible::Inspect, WithdrawConsequence}, + ConstU16, ConstU32, ConstU64, + }, }; use frame_system::EnsureSigned; use sp_core::{ConstU8, H256}; @@ -156,15 +158,6 @@ impl ProviderBoostRewardsProvider for TestRewardsProvider { 10_000u64.into() } - fn staking_reward_totals( - _account_id: Self::AccountId, - ) -> Result< - BoundedVec, ::ProviderBoostHistoryLimit>, - DispatchError, - > { - Ok(BoundedVec::new()) - } - // use the pallet version of the era calculation. fn era_staking_reward( amount_staked: Self::Balance, @@ -219,6 +212,14 @@ fn initialize_reward_pool() { } } +pub fn get_balance(who: &T::AccountId) -> BalanceOf { + T::Currency::balance(who) +} + +pub fn assert_transferable(who: &T::AccountId, amount: BalanceOf) { + assert_eq!(T::Currency::can_withdraw(who, amount), WithdrawConsequence::Success); +} + pub fn new_test_ext() -> sp_io::TestExternalities { let mut t = frame_system::GenesisConfig::::default().build_storage().unwrap(); pallet_balances::GenesisConfig:: { diff --git a/pallets/capacity/src/tests/mod.rs b/pallets/capacity/src/tests/mod.rs index 805483d98d..a8382fa6a1 100644 --- a/pallets/capacity/src/tests/mod.rs +++ b/pallets/capacity/src/tests/mod.rs @@ -1,5 +1,6 @@ pub mod capacity_details_tests; mod change_staking_target_tests; +mod claim_staking_rewards_tests; pub mod epochs_tests; mod eras_tests; pub mod mock; diff --git a/pallets/capacity/src/tests/rewards_provider_tests.rs b/pallets/capacity/src/tests/rewards_provider_tests.rs index 947cd46b60..868a6c02a7 100644 --- a/pallets/capacity/src/tests/rewards_provider_tests.rs +++ b/pallets/capacity/src/tests/rewards_provider_tests.rs @@ -1,14 +1,15 @@ use super::mock::*; use crate::{ - Config, ProviderBoostHistories, ProviderBoostHistory, ProviderBoostRewardsProvider, - StakingType::*, UnclaimedRewardInfo, -}; -use frame_support::{assert_ok, traits::Len}; - -use crate::tests::testing_utils::{ - run_to_block, set_era_and_reward_pool, setup_provider, system_run_to_block, + tests::testing_utils::{ + run_to_block, set_era_and_reward_pool, setup_provider, system_run_to_block, + }, + BalanceOf, Config, ProviderBoostHistories, ProviderBoostHistory, ProviderBoostRewardsProvider, + StakingType::*, + UnclaimedRewardInfo, }; use common_primitives::msa::MessageSourceId; +use frame_support::{assert_ok, traits::Len}; +use frame_system::pallet_prelude::BlockNumberFor; use sp_core::Get; // This tests Capacity implementation of the trait, but uses the mock's constants, @@ -68,7 +69,7 @@ fn era_staking_reward_implementation() { } #[test] -fn check_for_unclaimed_rewards_returns_empty_set_when_no_staking() { +fn list_unclaimed_rewards_returns_empty_set_when_no_staking() { new_test_ext().execute_with(|| { let account = 500u64; let history: ProviderBoostHistory = ProviderBoostHistory::new(); @@ -78,7 +79,7 @@ fn check_for_unclaimed_rewards_returns_empty_set_when_no_staking() { }) } #[test] -fn check_for_unclaimed_rewards_returns_empty_set_when_only_staked_this_era() { +fn list_unclaimed_rewards_returns_empty_set_when_only_staked_this_era() { new_test_ext().execute_with(|| { system_run_to_block(5); set_era_and_reward_pool(5u32, 1u32, 1000u64); @@ -93,7 +94,7 @@ fn check_for_unclaimed_rewards_returns_empty_set_when_only_staked_this_era() { // Check that eligible amounts are only for what's staked an entire era. #[test] -fn check_for_unclaimed_rewards_has_eligible_rewards() { +fn list_unclaimed_rewards_has_eligible_rewards() { new_test_ext().execute_with(|| { let account = 10_000u64; let target: MessageSourceId = 10; @@ -118,34 +119,39 @@ fn check_for_unclaimed_rewards_has_eligible_rewards() { // eligible amounts for rewards for eras should be: 1=0, 2=1k, 3=2k, 4=2k, 5=3k let rewards = Capacity::list_unclaimed_rewards(&account).unwrap(); assert_eq!(rewards.len(), 5usize); - let expected_info: [UnclaimedRewardInfo; 5] = [ + let expected_info: [UnclaimedRewardInfo, BlockNumberFor>; 5] = [ UnclaimedRewardInfo { reward_era: 1u32, expires_at_block: 130, + staked_amount: 1000, eligible_amount: 0, earned_amount: 0, }, UnclaimedRewardInfo { reward_era: 2u32, expires_at_block: 140, + staked_amount: 2000, eligible_amount: 1000, earned_amount: 4, }, UnclaimedRewardInfo { reward_era: 3u32, expires_at_block: 150, + staked_amount: 2000, eligible_amount: 2_000, earned_amount: 8, }, UnclaimedRewardInfo { reward_era: 4u32, expires_at_block: 160, + staked_amount: 3000, eligible_amount: 2000, earned_amount: 8, }, UnclaimedRewardInfo { reward_era: 5u32, expires_at_block: 170, + staked_amount: 3000, eligible_amount: 3_000, earned_amount: 11, }, @@ -168,6 +174,7 @@ fn check_for_unclaimed_rewards_has_eligible_rewards() { &UnclaimedRewardInfo { reward_era: 13u32, expires_at_block: 250, + staked_amount: 3_000, eligible_amount: 3_000, earned_amount: 11, } @@ -178,7 +185,7 @@ fn check_for_unclaimed_rewards_has_eligible_rewards() { // check that if an account boosted and then let it run for more than the number // of history retention eras, eligible rewards are correct. #[test] -fn check_for_unclaimed_rewards_returns_correctly_for_old_single_boost() { +fn list_unclaimed_rewards_returns_correctly_for_old_single_boost() { new_test_ext().execute_with(|| { let account = 10_000u64; let target: MessageSourceId = 10; @@ -202,12 +209,14 @@ fn check_for_unclaimed_rewards_returns_correctly_for_old_single_boost() { // the earliest era should no longer be stored. for i in 0u32..max_history { let era = i + 2u32; - let expected_info: UnclaimedRewardInfo = UnclaimedRewardInfo { - reward_era: era.into(), - expires_at_block: (era * 10u32 + 120u32).into(), - eligible_amount: 1000, - earned_amount: 4, - }; + let expected_info: UnclaimedRewardInfo, BlockNumberFor> = + UnclaimedRewardInfo { + reward_era: era.into(), + expires_at_block: (era * 10u32 + 120u32).into(), + staked_amount: 1000, + eligible_amount: 1000, + earned_amount: 4, + }; assert_eq!(rewards.get(i as usize).unwrap(), &expected_info); } }) diff --git a/pallets/capacity/src/tests/unstaking_tests.rs b/pallets/capacity/src/tests/unstaking_tests.rs index 6e00aee089..88d6a8b43b 100644 --- a/pallets/capacity/src/tests/unstaking_tests.rs +++ b/pallets/capacity/src/tests/unstaking_tests.rs @@ -351,10 +351,7 @@ fn unstake_fills_up_common_unlock_for_any_target() { }) } -// This fails now because unstaking is disallowed before claiming unclaimed rewards. -// TODO: add claim_rewards call after it's implemented and un-ignore. #[test] -#[ignore] fn unstake_by_a_booster_updates_provider_boost_history_with_correct_amount() { new_test_ext().execute_with(|| { let staker = 10_000; @@ -362,16 +359,39 @@ fn unstake_by_a_booster_updates_provider_boost_history_with_correct_amount() { register_provider(target1, String::from("Test Target")); assert_ok!(Capacity::provider_boost(RuntimeOrigin::signed(staker), target1, 1_000)); - let mut pbh = Capacity::get_staking_history_for(staker).unwrap(); + let pbh = Capacity::get_staking_history_for(staker).unwrap(); assert_eq!(pbh.count(), 1); // If unstaking in the next era, this should add a new staking history entry. system_run_to_block(9); - run_to_block(11); - assert_ok!(Capacity::unstake(RuntimeOrigin::signed(staker), target1, 50)); - pbh = Capacity::get_staking_history_for(staker).unwrap(); - assert_eq!(pbh.count(), 2); - assert_eq!(pbh.get_entry_for_era(&2u32).unwrap(), &950u64); + run_to_block(51); + assert_ok!(Capacity::claim_staking_rewards(RuntimeOrigin::signed(staker))); + assert_ok!(Capacity::unstake(RuntimeOrigin::signed(staker), target1, 400u64)); + + assert_eq!(get_balance::(&staker), 10_016u64); + assert_transferable::(&staker, 16u64); + }) +} + +#[test] +fn unstake_all_by_booster_reaps_boost_history() { + new_test_ext().execute_with(|| { + let staker = 10_000; + let target1 = 1; + register_provider(target1, String::from("Test Target")); + + assert_ok!(Capacity::provider_boost(RuntimeOrigin::signed(staker), target1, 1_000)); + let pbh = Capacity::get_staking_history_for(staker).unwrap(); + assert_eq!(pbh.count(), 1); + + // If unstaking in the next era, this should add a new staking history entry. + system_run_to_block(9); + run_to_block(51); + assert_ok!(Capacity::claim_staking_rewards(RuntimeOrigin::signed(staker))); + assert_ok!(Capacity::unstake(RuntimeOrigin::signed(staker), target1, 1_000)); + assert!(Capacity::get_staking_history_for(staker).is_none()); + assert_eq!(get_balance::(&staker), 10_016u64); + assert_transferable::(&staker, 16u64); }) } diff --git a/pallets/capacity/src/types.rs b/pallets/capacity/src/types.rs index 08aaf7b594..65fb4b8eb2 100644 --- a/pallets/capacity/src/types.rs +++ b/pallets/capacity/src/types.rs @@ -4,7 +4,6 @@ use common_primitives::capacity::RewardEra; use frame_support::{ pallet_prelude::PhantomData, BoundedVec, EqNoBound, PartialEqNoBound, RuntimeDebugNoBound, }; -use frame_system::pallet_prelude::BlockNumberFor; use parity_scale_codec::{Decode, Encode, EncodeLike, MaxEncodedLen}; use scale_info::TypeInfo; use sp_runtime::{ @@ -398,19 +397,19 @@ impl ProviderBoostHistory { if self.count().is_zero() { return None; }; - // have to save this because calling self.count() after self.0.get_mut produces compiler error - let current_count = self.count(); + + let current_staking_amount = self.get_last_staking_amount(); + if current_staking_amount.eq(subtract_amount) && self.count().eq(&1usize) { + // Should not get here unless rewards have all been claimed, and provider boost history was + // correctly updated. This && condition is to protect stakers against loss of rewards in the + // case of some bug with payouts and boost history. + return Some(0usize); + } if let Some(entry) = self.0.get_mut(reward_era) { *entry = entry.saturating_sub(*subtract_amount); - // if they unstaked everything and there are no other entries, return 0 count (a lie) - // so that the storage can be reaped. - if current_count.eq(&1usize) && entry.is_zero() { - return Some(0usize); - } } else { self.remove_oldest_entry_if_full(); - let current_staking_amount = self.get_last_staking_amount(); if self .0 .try_insert(*reward_era, current_staking_amount.saturating_sub(*subtract_amount)) @@ -532,11 +531,6 @@ pub trait ProviderBoostRewardsProvider { /// Return the size of the reward pool using the current economic model fn reward_pool_size(total_staked: BalanceOf) -> BalanceOf; - /// Return the list of unclaimed rewards for `accountId`, using the current economic model - fn staking_reward_totals( - account_id: Self::AccountId, - ) -> Result, T::ProviderBoostHistoryLimit>, DispatchError>; - /// Calculate the reward for a single era. We don't care about the era number, /// just the values. fn era_staking_reward( @@ -549,20 +543,3 @@ pub trait ProviderBoostRewardsProvider { /// The amount is multiplied by a factor > 0 and < 1. fn capacity_boost(amount: BalanceOf) -> BalanceOf; } - -/// Result of checking a Boost History item to see if it's eligible for a reward. -#[derive( - Copy, Clone, Encode, Eq, Decode, Default, RuntimeDebug, MaxEncodedLen, PartialEq, TypeInfo, -)] -#[scale_info(skip_type_params(T))] -pub struct UnclaimedRewardInfo { - /// The Reward Era for which this reward was earned - pub reward_era: RewardEra, - /// When this reward expires, i.e. can no longer be claimed - pub expires_at_block: BlockNumberFor, - /// The amount staked in this era that is eligible for rewards. Does not count additional amounts - /// staked in this era. - pub eligible_amount: BalanceOf, - /// The amount in token of the reward (only if it can be calculated using only on chain data) - pub earned_amount: BalanceOf, -}