From 63f66519a29032c0dcb29ebf94e3adb4b1bebfcd Mon Sep 17 00:00:00 2001 From: shannonwells Date: Tue, 12 Dec 2023 14:42:42 -0800 Subject: [PATCH] updates after rebase --- common/primitives/src/capacity.rs | 21 - designdocs/capacity.md | 4 +- e2e/capacity/staking.test.ts | 4 +- pallets/capacity/src/benchmarking.rs | 2 +- pallets/capacity/src/lib.rs | 472 ++++++++++++++++-- pallets/capacity/src/tests/other_tests.rs | 4 +- .../src/tests/stake_and_deposit_tests.rs | 13 +- .../src/tests/staking_target_details_tests.rs | 4 - pallets/capacity/src/tests/testing_utils.rs | 12 +- pallets/capacity/src/tests/unstaking_tests.rs | 7 +- pallets/capacity/src/types.rs | 163 +++++- .../frequency-tx-payment/src/tests/mock.rs | 2 +- .../src/tests/pallet_tests.rs | 4 +- 13 files changed, 614 insertions(+), 98 deletions(-) diff --git a/common/primitives/src/capacity.rs b/common/primitives/src/capacity.rs index f209b8eab3..03c1fa735f 100644 --- a/common/primitives/src/capacity.rs +++ b/common/primitives/src/capacity.rs @@ -1,8 +1,5 @@ use crate::msa::MessageSourceId; -use codec::{Encode, MaxEncodedLen}; use frame_support::traits::tokens::Balance; -use scale_info::TypeInfo; -use sp_api::Decode; use sp_runtime::DispatchError; /// A trait for checking that a target MSA can be staked to. @@ -55,21 +52,3 @@ pub trait Replenishable { /// Checks if an account can be replenished. fn can_replenish(msa_id: MessageSourceId) -> bool; } - -#[derive( - Clone, Copy, Debug, Decode, Encode, TypeInfo, Eq, MaxEncodedLen, PartialEq, PartialOrd, -)] -/// The type of staking a given Staking Account is doing. -pub enum StakingType { - /// Staking account targets Providers for capacity only, no token reward - MaximumCapacity, - /// Staking account targets Providers and splits reward between capacity to the Provider - /// and token for the account holder - ProviderBoost, -} - -impl Default for StakingType { - fn default() -> Self { - StakingType::MaximumCapacity - } -} diff --git a/designdocs/capacity.md b/designdocs/capacity.md index 8f0a911ce8..7eaa2a816a 100644 --- a/designdocs/capacity.md +++ b/designdocs/capacity.md @@ -117,7 +117,7 @@ Stakes some amount of tokens to the network and generates Capacity. /// /// - Returns Error::InsufficientBalance if the sender does not have free balance amount needed to stake. /// - Returns Error::InvalidTarget if attempting to stake to an invalid target. -/// - Returns Error::InsufficientStakingAmount if attempting to stake an amount below the minimum amount. +/// - Returns Error::StakingAmountBelowMinimum if attempting to stake an amount below the minimum amount. /// - Returns Error::BalanceTooLowtoStake if the sender does not have /// free balance amount > MinimumTokenBalance after staking. pub fn stake(origin: OriginFor, target: MessageSourceId, amount: BalanceOf) -> DispatchResult {} @@ -211,7 +211,7 @@ pub enum Error { /// Capacity is not available for the given MSA. InsufficientBalance, /// Staker is attempting to stake an amount below the minimum amount. - InsufficientStakingAmount, + StakingAmountBelowMinimum, /// Staker is attempting to stake a zero amount. ZeroAmountNotAllowed, /// Origin has no Staking Account diff --git a/e2e/capacity/staking.test.ts b/e2e/capacity/staking.test.ts index 9766ae576c..677fbed6bc 100644 --- a/e2e/capacity/staking.test.ts +++ b/e2e/capacity/staking.test.ts @@ -254,13 +254,13 @@ describe('Capacity Staking Tests', function () { }); describe('when attempting to stake below the minimum staking requirements', function () { - it('should fail to stake for InsufficientStakingAmount', async function () { + it('should fail to stake for StakingAmountBelowMinimum', async function () { const stakingKeys = createKeys('stakingKeys'); const providerId = await createMsaAndProvider(fundingSource, stakingKeys, 'stakingKeys', 150n * CENTS); const stakeAmount = 1500n; const failStakeObj = ExtrinsicHelper.stake(stakingKeys, providerId, stakeAmount); - await assert.rejects(failStakeObj.signAndSend(), { name: 'InsufficientStakingAmount' }); + await assert.rejects(failStakeObj.signAndSend(), { name: 'StakingAmountBelowMinimum' }); }); }); diff --git a/pallets/capacity/src/benchmarking.rs b/pallets/capacity/src/benchmarking.rs index ec68b0ab53..34dcf175cb 100644 --- a/pallets/capacity/src/benchmarking.rs +++ b/pallets/capacity/src/benchmarking.rs @@ -47,7 +47,7 @@ benchmarks! { register_provider::(target, "Foo"); - }: _ (RawOrigin::Signed(caller.clone()), target, amount, staking_type) + }: _ (RawOrigin::Signed(caller.clone()), target, amount) verify { assert!(StakingAccountLedger::::contains_key(&caller)); assert!(StakingTargetLedger::::contains_key(&caller, target)); diff --git a/pallets/capacity/src/lib.rs b/pallets/capacity/src/lib.rs index 8444e6da46..523910e72c 100644 --- a/pallets/capacity/src/lib.rs +++ b/pallets/capacity/src/lib.rs @@ -47,6 +47,7 @@ rustdoc::invalid_codeblock_attributes, missing_docs )] +use sp_std::ops::{Add, Mul}; use frame_support::{ ensure, @@ -55,10 +56,9 @@ use frame_support::{ }; use sp_runtime::{ - traits::{CheckedAdd, Saturating, Zero}, + traits::{CheckedAdd, CheckedDiv, One, Saturating, Zero}, ArithmeticError, DispatchError, Perbill, }; -use sp_std::ops::Mul; pub use common_primitives::{ capacity::{Nontransferable, Replenishable, TargetValidator}, @@ -72,6 +72,7 @@ use common_primitives::benchmarks::RegisterProviderBenchmarkHelper; pub use pallet::*; pub use types::*; pub use weights::*; + pub mod types; #[cfg(feature = "runtime-benchmarks")] @@ -94,10 +95,8 @@ use frame_system::pallet_prelude::*; pub mod pallet { use super::*; - use frame_support::{ - pallet_prelude::{StorageVersion, *}, - Twox64Concat, - }; + use frame_support::{pallet_prelude::*, Twox64Concat}; + use parity_scale_codec::EncodeLike; use sp_runtime::traits::{AtLeast32BitUnsigned, MaybeDisplay}; /// the storage version for this pallet @@ -128,7 +127,7 @@ pub mod pallet { /// The maximum number of unlocking chunks a StakingAccountLedger can have. /// It determines how many concurrent unstaked chunks may exist. #[pallet::constant] - type MaxUnlockingChunks: Get; + type MaxUnlockingChunks: Get + Clone; #[cfg(feature = "runtime-benchmarks")] /// A set of helper functions for benchmarking. @@ -158,6 +157,36 @@ pub mod pallet { /// How much FRQCY one unit of Capacity costs #[pallet::constant] type CapacityPerToken: Get; + + /// A period of `EraLength` blocks in which a Staking Pool applies and + /// when Staking Rewards may be earned. + type RewardEra: Parameter + + Member + + MaybeSerializeDeserialize + + MaybeDisplay + + AtLeast32BitUnsigned + + Default + + Copy + + sp_std::hash::Hash + + MaxEncodedLen + + EncodeLike + + Into> + + TypeInfo; + + /// The number of blocks in a RewardEra + #[pallet::constant] + type EraLength: Get; + + /// The maximum number of eras over which one can claim rewards + #[pallet::constant] + type StakingRewardsPastErasMax: Get; + + /// The StakingRewardsProvider used by this pallet in a given runtime + type RewardsProvider: StakingRewardsProvider; + + /// A staker may not retarget more than MaxRetargetsPerRewardEra + #[pallet::constant] + type MaxRetargetsPerRewardEra: Get; } /// Storage for keeping a ledger of staked token amounts for accounts. @@ -219,6 +248,26 @@ pub mod pallet { pub type UnstakeUnlocks = StorageMap<_, Twox64Concat, T::AccountId, UnlockChunkList>; + /// Information about the current staking reward era. Checked every block. + #[pallet::storage] + #[pallet::whitelist_storage] + #[pallet::getter(fn get_current_era)] + pub type CurrentEraInfo = + StorageValue<_, RewardEraInfo>, ValueQuery>; + + /// Reward Pool history + #[pallet::storage] + #[pallet::getter(fn get_reward_pool_for_era)] + pub type StakingRewardPool = + CountedStorageMap<_, Twox64Concat, T::RewardEra, RewardPoolInfo>>; + + // TODO: storage for staking history + + /// stores how many times an account has retargeted, and when it last retargeted. + #[pallet::storage] + #[pallet::getter(fn get_retargets_for)] + pub type Retargets = StorageMap<_, Twox64Concat, T::AccountId, RetargetInfo>; + // Simple declaration of the `Pallet` type. It is placeholder we use to implement traits and // method. #[pallet::pallet] @@ -269,6 +318,28 @@ pub mod pallet { /// The amount of Capacity withdrawn from MSA. amount: BalanceOf, }, + /// The target of a staked amount was changed to a new MessageSourceId + StakingTargetChanged { + /// The account that retargeted the staking amount + account: T::AccountId, + /// The Provider MSA that the staking amount is taken from + from_msa: MessageSourceId, + /// The Provider MSA that the staking amount is retargeted to + to_msa: MessageSourceId, + /// The amount in token that was retargeted + amount: BalanceOf, + }, + /// Tokens have been staked on the network for Provider Boosting + ProviderBoosted { + /// The token account that staked tokens to the network. + account: T::AccountId, + /// The MSA that a token account targeted to receive Capacity based on this staking amount. + target: MessageSourceId, + /// An amount that was staked. + amount: BalanceOf, + /// The Capacity amount issued to the target as a result of the stake. + capacity: BalanceOf, + }, } #[pallet::error] @@ -276,34 +347,49 @@ pub mod pallet { /// Staker attempted to stake to an invalid staking target. InvalidTarget, /// Capacity is not available for the given MSA. - InsufficientBalance, + InsufficientCapacityBalance, /// Staker is attempting to stake an amount below the minimum amount. - InsufficientStakingAmount, - /// Staker is attempting to stake a zero amount. + StakingAmountBelowMinimum, + /// Staker is attempting to stake a zero amount. DEPRECATED ZeroAmountNotAllowed, /// This AccountId does not have a staking account. NotAStakingAccount, /// No staked value is available for withdrawal; either nothing is being unstaked, - /// or nothing has passed the thaw period. + /// or nothing has passed the thaw period. (5) NoUnstakedTokensAvailable, /// Unstaking amount should be greater than zero. UnstakedAmountIsZero, - /// Amount to unstake is greater than the amount staked. - AmountToUnstakeExceedsAmountStaked, - /// Attempting to get a staker / target relationship that does not exist. + /// Amount to unstake or change targets is greater than the amount staked. + InsufficientStakingBalance, + /// Attempted to get a staker / target relationship that does not exist. StakerTargetRelationshipNotFound, - /// Attempting to get the target's capacity that does not exist. + /// Attempted to get the target's capacity that does not exist. TargetCapacityNotFound, - /// Staker reached the limit number for the allowed amount of unlocking chunks. + /// Staker has reached the limit of unlocking chunks and must wait for at least one thaw period + /// to complete. (10) MaxUnlockingChunksExceeded, - /// Increase Capacity increase exceeds the total available Capacity for target. + /// Capacity increase exceeds the total available Capacity for target. IncreaseExceedsAvailable, - /// Attempting to set the epoch length to a value greater than the max epoch length. + /// Attempted to set the Epoch length to a value greater than the max Epoch length. MaxEpochLengthExceeded, /// Staker is attempting to stake an amount that leaves a token balance below the minimum amount. BalanceTooLowtoStake, - /// None of the token amounts in UnlockChunks has thawed yet. - NoThawedTokenAvailable, + /// Staker tried to change StakingType on an existing account + CannotChangeStakingType, + /// The Era specified is too far in the past or is in the future (15) + EraOutOfRange, + /// Rewards were already paid out for the specified Era range + IneligibleForPayoutInEraRange, + /// Attempted to retarget but from and to Provider MSA Ids were the same + CannotRetargetToSameProvider, + /// Rewards were already paid out this era + AlreadyClaimedRewardsThisEra, + /// Caller must wait until the next RewardEra, claim rewards and then can boost again. + MustFirstClaimRewards, + /// Too many change_staking_target calls made in this RewardEra. (20) + MaxRetargetsExceeded, + /// There are no unstaked token amounts that have passed their thaw period. + NoThawedTokenAvailable } #[pallet::hooks] @@ -319,9 +405,9 @@ pub mod pallet { /// /// ### Errors /// - /// - Returns Error::ZeroAmountNotAllowed if the staker is attempting to stake a zero amount. /// - Returns Error::InvalidTarget if attempting to stake to an invalid target. - /// - Returns Error::InsufficientStakingAmount if attempting to stake an amount below the minimum amount. + /// - Returns Error::StakingAmountBelowMinimum if attempting to stake an amount below the minimum amount. + /// - Returns Error::CannotChangeStakingType if the staking account is a ProviderBoost account #[pallet::call_index(0)] #[pallet::weight(T::WeightInfo::stake())] pub fn stake( @@ -389,10 +475,10 @@ pub mod pallet { ensure!(requested_amount > Zero::zero(), Error::::UnstakedAmountIsZero); - let actual_amount = Self::decrease_active_staking_balance(&unstaker, requested_amount)?; + let (actual_amount, staking_type) = Self::decrease_active_staking_balance(&unstaker, requested_amount)?; Self::add_unlock_chunk(&unstaker, actual_amount)?; - let capacity_reduction = Self::reduce_capacity(&unstaker, target, actual_amount)?; + let capacity_reduction = Self::reduce_capacity(&unstaker, target, actual_amount, staking_type)?; Self::deposit_event(Event::UnStaked { account: unstaker, @@ -421,6 +507,84 @@ pub mod pallet { Self::deposit_event(Event::EpochLengthUpdated { blocks: length }); Ok(()) } + + /// Sets the target of the staking capacity to a new target. + /// This adds a chunk to `StakingAccountDetails.stake_change_unlocking chunks`, up to `T::MaxUnlockingChunks`. + /// The staked amount and Capacity generated by `amount` originally targeted to the `from` MSA Id is reassigned to the `to` MSA Id. + /// Does not affect unstaking process or additional stake amounts. + /// Changing a staking target to a Provider when Origin has nothing staked them will retain the staking type. + /// Changing a staking target to a Provider when Origin has any amount staked to them will error if the staking types are not the same. + /// ### Errors + /// - [`Error::MaxUnlockingChunksExceeded`] if `stake_change_unlocking_chunks` == `T::MaxUnlockingChunks` + /// - [`Error::StakerTargetRelationshipNotFound`] if `from` is not a target for Origin's staking account. + /// - [`Error::StakingAmountBelowMinimum`] if `amount` to retarget is below the minimum staking amount. + /// - [`Error::InsufficientStakingBalance`] if `amount` to retarget exceeds what the staker has targeted to `from` MSA Id. + /// - [`Error::InvalidTarget`] if `to` does not belong to a registered Provider. + /// - [`Error::MaxRetargetsExceeded`] if origin has reached the maximimum number of retargets for the current RewardEra. + #[pallet::call_index(4)] + #[pallet::weight(T::WeightInfo::unstake())] + pub fn change_staking_target( + origin: OriginFor, + from: MessageSourceId, + to: MessageSourceId, + amount: BalanceOf, + ) -> DispatchResult { + let staker = ensure_signed(origin)?; + // This will bounce immediately if they've tried to do this too many times. + Self::update_retarget_record(&staker)?; + ensure!(from.ne(&to), Error::::CannotRetargetToSameProvider); + ensure!( + amount >= T::MinimumStakingAmount::get(), + Error::::StakingAmountBelowMinimum + ); + + ensure!(T::TargetValidator::validate(to), Error::::InvalidTarget); + + Self::do_retarget(&staker, &from, &to, &amount)?; + + Self::deposit_event(Event::StakingTargetChanged { + account: staker, + from_msa: from, + to_msa: to, + amount, + }); + Ok(()) + } + /// Stakes some amount of tokens to the network and generates a comparatively small amount of Capacity + /// for the target, and gives periodic rewards to origin. + /// ### Errors + /// + /// - Returns Error::InvalidTarget if attempting to stake to an invalid target. + /// - Returns Error::StakingAmountBelowMinimum if attempting to stake an amount below the minimum amount. + /// - Returns Error::CannotChangeStakingType if the staking account exists and staking_type is MaximumCapacity + #[pallet::call_index(5)] + #[pallet::weight(T::WeightInfo::provider_boost())] + pub fn provider_boost( + origin: OriginFor, + target: MessageSourceId, + amount: BalanceOf, + ) -> DispatchResult { + let staker = ensure_signed(origin)?; + let (mut boosting_details, actual_amount) = + Self::ensure_can_boost(&staker, &target, &amount)?; + + let capacity = Self::increase_stake_and_issue_boost( + &staker, + &mut boosting_details, + &target, + &actual_amount, + )?; + + Self::deposit_event(Event::ProviderBoosted { + account: staker, + amount: actual_amount, + target, + capacity, + }); + + Ok(()) + } + } } @@ -442,6 +606,8 @@ impl Pallet { ensure!(T::TargetValidator::validate(target), Error::::InvalidTarget); let staking_account = Self::get_staking_account_for(&staker).unwrap_or_default(); + ensure!(staking_account.staking_type.ne(&StakingType::ProviderBoost), Error::::CannotChangeStakingType); + let stakable_amount = Self::get_stakable_amount_for(&staker, amount); ensure!(stakable_amount > Zero::zero(), Error::::BalanceTooLowtoStake); @@ -453,12 +619,24 @@ impl Pallet { ensure!( new_active_staking_amount >= T::MinimumStakingAmount::get(), - Error::::InsufficientStakingAmount + Error::::StakingAmountBelowMinimum ); Ok((staking_account, stakable_amount)) } + // TODO: this should return StakingAccount, BoostHistory and Balance + fn ensure_can_boost( + staker: &T::AccountId, + target: &MessageSourceId, + amount: &BalanceOf, + ) -> Result<(StakingDetails, BalanceOf), DispatchError> { + let (staking_details, stakable_amount) = Self::ensure_can_stake(staker, *target, *amount)?; + // TODO: boost history + // let boost_history = Self::get_boost_history_for(staker).unwrap_or_defaul(); + Ok((staking_details, stakable_amount)) + } + /// Increase a staking account and target account balances by amount. /// Additionally, it issues Capacity to the MSA target. fn increase_stake_and_issue_capacity( @@ -484,6 +662,36 @@ impl Pallet { Ok(capacity) } + // TODO: + fn increase_stake_and_issue_boost( + staker: &T::AccountId, + staking_details: &mut StakingDetails, + target: &MessageSourceId, + amount: &BalanceOf, + ) -> Result, DispatchError> { + staking_details + .deposit(*amount) + .ok_or(ArithmeticError::Overflow)?; + + let capacity = Self::capacity_generated(T::RewardsProvider::capacity_boost(*amount)); + + // TODO: target details added fields + let mut target_details = Self::get_target_for(staker, target).unwrap_or_default(); + + target_details.deposit(*amount, capacity).ok_or(ArithmeticError::Overflow)?; + + let mut capacity_details = Self::get_capacity_for(target).unwrap_or_default(); + capacity_details.deposit(amount, &capacity).ok_or(ArithmeticError::Overflow)?; + + // TODO: add boost history record for era + // let era = Self::get_current_era().era_index; + Self::set_staking_account_and_lock(staker, staking_details)?; + Self::set_target_details_for(staker, *target, target_details); + Self::set_capacity_for(*target, capacity_details); + + Ok(capacity) + } + /// Sets staking account details after a deposit fn set_staking_account_and_lock( staker: &T::AccountId, @@ -507,13 +715,25 @@ impl Pallet { } } + /// If the staking account total is zero we reap storage, otherwise set the account to the new details. + // TODO: do not remove this lock unless both types of staking account details are cleared. + // fn delete_boosting_account(staker: &T::AccountId) { + // // otherwise call set_lock for the new value containing only the other type of staking account. + // T::Currency::remove_lock(STAKING_ID, &staker); + // BoostingAccountLedger::::remove(&staker); + // } + /// Sets target account details. fn set_target_details_for( staker: &T::AccountId, target: MessageSourceId, target_details: StakingTargetDetails>, ) { - StakingTargetLedger::::insert(staker, target, target_details); + if target_details.amount.is_zero() { + StakingTargetLedger::::remove(staker, target); + } else { + StakingTargetLedger::::insert(staker, target, target_details); + } } /// Sets targets Capacity. @@ -524,18 +744,22 @@ impl Pallet { CapacityLedger::::insert(target, capacity_details); } - /// Decrease a staking account's active token. + /// Decrease a staking account's active token and reap if it goes below the minimum. + /// Returns: actual amount unstaked, plus the staking type + StakingDetails, + /// since StakingDetails may be reaped and staking type must be used to calculate the + /// capacity reduction later. fn decrease_active_staking_balance( unstaker: &T::AccountId, amount: BalanceOf, - ) -> Result, DispatchError> { + ) -> Result<(BalanceOf, StakingType), DispatchError> { let mut staking_account = Self::get_staking_account_for(unstaker).ok_or(Error::::NotAStakingAccount)?; - ensure!(amount <= staking_account.active, Error::::AmountToUnstakeExceedsAmountStaked); + ensure!(amount <= staking_account.active, Error::::InsufficientStakingBalance); let actual_unstaked_amount = staking_account.withdraw(amount)?; + let staking_type = staking_account.staking_type; Self::set_staking_account(unstaker, &staking_account); - Ok(actual_unstaked_amount) + Ok((actual_unstaked_amount, staking_type)) } fn add_unlock_chunk( @@ -596,24 +820,48 @@ impl Pallet { Ok(amount_withdrawn) } + fn get_thaw_at_epoch() -> ::EpochNumber { + let current_epoch: T::EpochNumber = Self::get_current_epoch(); + let thaw_period = T::UnstakingThawPeriod::get(); + current_epoch.saturating_add(thaw_period.into()) + } + /// Reduce available capacity of target and return the amount of capacity reduction. fn reduce_capacity( unstaker: &T::AccountId, target: MessageSourceId, amount: BalanceOf, + staking_type: StakingType ) -> Result, DispatchError> { let mut staking_target_details = Self::get_target_for(&unstaker, &target) .ok_or(Error::::StakerTargetRelationshipNotFound)?; + + ensure!(amount.le(&staking_target_details.amount), Error::::InsufficientStakingBalance); + let mut capacity_details = Self::get_capacity_for(target).ok_or(Error::::TargetCapacityNotFound)?; - let capacity_to_withdraw = Self::calculate_capacity_reduction( + let capacity_to_withdraw = + if staking_type.eq(&StakingType::ProviderBoost) { + Perbill::from_rational(amount, staking_target_details.amount) + .mul_ceil(staking_target_details.capacity) + } else { + Self::calculate_capacity_reduction( + amount, + capacity_details.total_tokens_staked, + capacity_details.total_capacity_issued, + ) + }; + // this call will return an amount > than requested if the resulting StakingTargetDetails balance + // is below the minimum. This ensures we withdraw the same amounts as for staking_target_details. + // TODO: update this function + let (actual_amount, actual_capacity) = staking_target_details.withdraw( amount, - capacity_details.total_tokens_staked, - capacity_details.total_capacity_issued, + capacity_to_withdraw, + T::MinimumStakingAmount::get(), ); - staking_target_details.withdraw(amount, capacity_to_withdraw); - capacity_details.withdraw(capacity_to_withdraw, amount); + + capacity_details.withdraw(actual_capacity, actual_amount); Self::set_capacity_for(target, capacity_details); Self::set_target_details_for(unstaker, target, staking_target_details); @@ -627,7 +875,8 @@ impl Pallet { cpt.mul(amount).into() } - /// Determine the capacity reduction when given total_capacity, unstaking_amount, and total_amount_staked. + /// Determine the capacity reduction when given total_capacity, unstaking_amount, and total_amount_staked, + /// based on ratios fn calculate_capacity_reduction( unstaking_amount: BalanceOf, total_amount_staked: BalanceOf, @@ -642,18 +891,105 @@ impl Pallet { Self::get_epoch_length() { let current_epoch = Self::get_current_epoch(); - CurrentEpoch::::set(current_epoch.saturating_add(1u32.into())); + CurrentEpoch::::set(current_epoch.saturating_add(One::one())); CurrentEpochInfo::::set(EpochInfo { epoch_start: current_block }); T::WeightInfo::on_initialize() .saturating_add(T::DbWeight::get().reads(1)) .saturating_add(T::DbWeight::get().writes(2)) } else { // 1 for get_current_epoch_info, 1 for get_epoch_length - T::DbWeight::get().reads(2u64).saturating_add(RocksDbWeight::get().writes(1)) + T::DbWeight::get().reads(2).saturating_add(RocksDbWeight::get().writes(1)) + } + } + + fn start_new_reward_era_if_needed(current_block: BlockNumberFor) -> Weight { + let current_era_info: RewardEraInfo> = Self::get_current_era(); // 1r + + if current_block.saturating_sub(current_era_info.started_at) >= T::EraLength::get().into() { + let new_era_info = RewardEraInfo { + era_index: current_era_info.era_index.saturating_add(One::one()), + started_at: current_block, + }; + + let current_reward_pool_info = + Self::get_reward_pool_for_era(current_era_info.era_index).unwrap_or_default(); // 1r + + let past_eras_max = T::StakingRewardsPastErasMax::get(); + let entries: u32 = StakingRewardPool::::count(); // 1r + + if past_eras_max.eq(&entries) { + let earliest_era = + current_era_info.era_index.saturating_sub(past_eras_max.into()).add(One::one()); + StakingRewardPool::::remove(earliest_era); // 1w + } + CurrentEraInfo::::set(new_era_info); // 1w + + let total_reward_pool = + T::RewardsProvider::reward_pool_size(current_reward_pool_info.total_staked_token); + let new_reward_pool = RewardPoolInfo { + total_staked_token: current_reward_pool_info.total_staked_token, + total_reward_pool, + unclaimed_balance: total_reward_pool, + }; + StakingRewardPool::::insert(new_era_info.era_index, new_reward_pool); // 1w + + T::WeightInfo::on_initialize() + .saturating_add(T::DbWeight::get().reads(3)) + .saturating_add(T::DbWeight::get().writes(3)) + } else { + T::DbWeight::get().reads(1) } } + + /// Returns whether `account_id` may claim and and be paid token rewards. + pub fn payout_eligible(account_id: T::AccountId) -> bool { + // TODO: stake vs boost + let _staking_account = + Self::get_staking_account_for(account_id).ok_or(Error::::NotAStakingAccount); + false + } + + /// attempts to increment number of retargets this RewardEra + /// Returns: + /// Error::MaxRetargetsExceeded if they try to retarget too many times in one era. + fn update_retarget_record(staker: &T::AccountId) -> Result<(), DispatchError> { + let current_era: T::RewardEra = Self::get_current_era().era_index; + let mut retargets = Self::get_retargets_for(staker).unwrap_or_default(); + ensure!(retargets.update(current_era).is_some(), Error::::MaxRetargetsExceeded); + Retargets::::set(staker, Some(retargets)); + Ok(()) + } + + /// Performs the work of withdrawing the requested amount from the old staker-provider target details, and + /// from the Provider's capacity details, and depositing it into the new staker-provider target details. + pub fn do_retarget( + staker: &T::AccountId, + from_msa: &MessageSourceId, + to_msa: &MessageSourceId, + amount: &BalanceOf, + ) -> Result<(), DispatchError> { + let staking_type = Self::get_staking_account_for(staker).unwrap_or_default().staking_type; + let capacity_withdrawn = Self::reduce_capacity(staker, *from_msa, *amount, staking_type)?; + + let mut to_msa_target = Self::get_target_for(staker, to_msa).unwrap_or_default(); + + to_msa_target + .deposit(*amount, capacity_withdrawn) + .ok_or(ArithmeticError::Overflow)?; + + let mut capacity_details = Self::get_capacity_for(to_msa).unwrap_or_default(); + capacity_details + .deposit(amount, &capacity_withdrawn) + .ok_or(ArithmeticError::Overflow)?; + + Self::set_target_details_for(staker, *to_msa, to_msa_target); + Self::set_capacity_for(*to_msa, capacity_details); + Ok(()) + } } +/// Nontransferable functions are intended for capacity spend and recharge. +/// Implementations of Nontransferable MUST NOT be concerned with StakingType. impl Nontransferable for Pallet { type Balance = BalanceOf; @@ -672,7 +1008,7 @@ impl Nontransferable for Pallet { capacity_details .deduct_capacity_by_amount(amount) - .map_err(|_| Error::::InsufficientBalance)?; + .map_err(|_| Error::::InsufficientCapacityBalance)?; Self::set_capacity_for(msa_id, capacity_details); @@ -681,10 +1017,10 @@ impl Nontransferable for Pallet { } /// Increase all totals for the MSA's CapacityDetails. - fn deposit(msa_id: MessageSourceId, amount: Self::Balance) -> Result<(), DispatchError> { + fn deposit(msa_id: MessageSourceId, token_amount: Self::Balance, capacity_amount: Self::Balance) -> Result<(), DispatchError> { let mut capacity_details = Self::get_capacity_for(msa_id).ok_or(Error::::TargetCapacityNotFound)?; - capacity_details.deposit(&amount, &Self::capacity_generated(amount)); + capacity_details.deposit(&token_amount, &capacity_amount); Self::set_capacity_for(msa_id, capacity_details); Ok(()) } @@ -706,6 +1042,7 @@ impl Replenishable for Pallet { /// Change: now calls new fn replenish_by_amount on the capacity_details, /// which does what this (actually Self::deposit) used to do + /// Currently unused. fn replenish_by_amount( msa_id: MessageSourceId, amount: Self::Balance, @@ -723,3 +1060,56 @@ impl Replenishable for Pallet { false } } + +impl StakingRewardsProvider for Pallet { + type AccountId = T::AccountId; + type RewardEra = T::RewardEra; + type Hash = T::Hash; + + // Calculate the size of the reward pool for the current era, based on current staked token + // and the other determined factors of the current economic model + fn reward_pool_size(total_staked: BalanceOf) -> BalanceOf { + if total_staked.is_zero() { + return BalanceOf::::zero() + } + + // For now reward pool size is set to 10% of total staked token + total_staked.checked_div(&BalanceOf::::from(10u8)).unwrap_or_default() + } + + // Performs range checks plus a reward calculation based on economic model for the era range + fn staking_reward_total( + _account_id: T::AccountId, + from_era: T::RewardEra, + to_era: T::RewardEra, + ) -> Result, DispatchError> { + let era_range = from_era.saturating_sub(to_era); + ensure!( + era_range.le(&T::StakingRewardsPastErasMax::get().into()), + Error::::EraOutOfRange + ); + ensure!(from_era.le(&to_era), Error::::EraOutOfRange); + let current_era_info = Self::get_current_era(); + ensure!(to_era.lt(¤t_era_info.era_index), Error::::EraOutOfRange); + + // TODO: update when staking history is implemented + // For now rewards 1 unit per era for a valid range since there is no history storage + let per_era = BalanceOf::::one(); + + let num_eras = to_era.saturating_sub(from_era); + Ok(per_era.saturating_mul(num_eras.into())) + } + + fn validate_staking_reward_claim( + _account_id: T::AccountId, + _proof: T::Hash, + _payload: StakingRewardClaim, + ) -> bool { + true + } + + /// How much, as a percentage of staked token, to boost a targeted Provider when staking. + fn capacity_boost(amount: BalanceOf) -> BalanceOf { + Perbill::from_percent(5u32).mul(amount) + } +} diff --git a/pallets/capacity/src/tests/other_tests.rs b/pallets/capacity/src/tests/other_tests.rs index 17e31ddc0e..723a79bb1e 100644 --- a/pallets/capacity/src/tests/other_tests.rs +++ b/pallets/capacity/src/tests/other_tests.rs @@ -91,7 +91,7 @@ fn set_target_details_is_successful() { target_details.amount = 10; target_details.capacity = 10; - Capacity::set_target_details_for(&staker, &target, &target_details); + Capacity::set_target_details_for(&staker, target, target_details); let stored_target_details = Capacity::get_target_for(&staker, target).unwrap(); @@ -115,7 +115,7 @@ fn set_capacity_details_is_successful() { last_replenished_epoch: 1u32, }; - Capacity::set_capacity_for(&target, &capacity_details); + Capacity::set_capacity_for(target, capacity_details); let stored_capacity_details = Capacity::get_capacity_for(target).unwrap(); diff --git a/pallets/capacity/src/tests/stake_and_deposit_tests.rs b/pallets/capacity/src/tests/stake_and_deposit_tests.rs index b91039730e..df24f980de 100644 --- a/pallets/capacity/src/tests/stake_and_deposit_tests.rs +++ b/pallets/capacity/src/tests/stake_and_deposit_tests.rs @@ -64,7 +64,7 @@ fn stake_errors_insufficient_staking_amount_when_staking_below_minimum_staking_a register_provider(target, String::from("Foo")); assert_noop!( Capacity::stake(RuntimeOrigin::signed(account), target, amount), - Error::::InsufficientStakingAmount + Error::::StakingAmountBelowMinimum ); }); } @@ -339,7 +339,7 @@ fn ensure_can_stake_errors_insufficient_staking_amount() { assert_noop!( Capacity::ensure_can_stake(&account, target, amount), - Error::::InsufficientStakingAmount + Error::::StakingAmountBelowMinimum ); }); } @@ -422,8 +422,9 @@ fn impl_deposit_is_successful() { total_available_amount, 1u32, ); - - assert_ok!(Capacity::deposit(target_msa_id, 5u32.into()),); + let amount = BalanceOf::::from(5u32); + let capacity = BalanceOf::::from(1u32); + assert_ok!(Capacity::deposit(target_msa_id, amount, capacity)); }); } @@ -432,8 +433,10 @@ fn impl_deposit_errors_target_capacity_not_found() { new_test_ext().execute_with(|| { let target_msa_id = 1; let amount = BalanceOf::::from(10u32); + let capacity = BalanceOf::::from(5u32); + assert_noop!( - Capacity::deposit(target_msa_id, amount), + Capacity::deposit(target_msa_id, amount, capacity), Error::::TargetCapacityNotFound ); }); diff --git a/pallets/capacity/src/tests/staking_target_details_tests.rs b/pallets/capacity/src/tests/staking_target_details_tests.rs index 82de07d8a7..69bbb33834 100644 --- a/pallets/capacity/src/tests/staking_target_details_tests.rs +++ b/pallets/capacity/src/tests/staking_target_details_tests.rs @@ -12,7 +12,6 @@ fn impl_staking_target_details_increase_by() { StakingTargetDetails::> { amount: BalanceOf::::from(10u64), capacity: 10, - staking_type: StakingType::MaximumCapacity, } ) } @@ -22,7 +21,6 @@ fn staking_target_details_withdraw_reduces_staking_and_capacity_amounts() { let mut staking_target_details = StakingTargetDetails::> { amount: BalanceOf::::from(25u64), capacity: BalanceOf::::from(30u64), - staking_type: StakingType::MaximumCapacity, }; staking_target_details.withdraw(10, 10, ::MinimumStakingAmount::get()); @@ -31,7 +29,6 @@ fn staking_target_details_withdraw_reduces_staking_and_capacity_amounts() { StakingTargetDetails::> { amount: BalanceOf::::from(15u64), capacity: BalanceOf::::from(20u64), - staking_type: StakingType::MaximumCapacity, } ) } @@ -41,7 +38,6 @@ fn staking_target_details_withdraw_reduces_to_zero_if_balance_is_below_minimum() let mut staking_target_details = StakingTargetDetails::> { amount: BalanceOf::::from(10u64), capacity: BalanceOf::::from(20u64), - staking_type: StakingType::MaximumCapacity, }; staking_target_details.withdraw(8, 16, ::MinimumStakingAmount::get()); assert_eq!(staking_target_details, StakingTargetDetails::>::default()); diff --git a/pallets/capacity/src/tests/testing_utils.rs b/pallets/capacity/src/tests/testing_utils.rs index 62d22bae4b..2ecdd901c7 100644 --- a/pallets/capacity/src/tests/testing_utils.rs +++ b/pallets/capacity/src/tests/testing_utils.rs @@ -1,11 +1,10 @@ use super::mock::*; use frame_support::{assert_ok, traits::Hooks}; -use common_primitives::capacity::{StakingType, StakingType::MaximumCapacity}; #[allow(unused)] use sp_runtime::traits::SignedExtension; -use crate::{BalanceOf, CapacityDetails, Config, Event}; +use crate::{BalanceOf, CapacityDetails, Config, Event, StakingType}; use common_primitives::msa::MessageSourceId; pub fn staking_events() -> Vec> { @@ -61,9 +60,9 @@ pub fn create_capacity_account_and_fund( capacity_details.total_capacity_issued = available; capacity_details.last_replenished_epoch = last_replenished; - Capacity::set_capacity_for(&target_msa_id, &capacity_details); + Capacity::set_capacity_for(target_msa_id, capacity_details.clone()); - capacity_details + capacity_details.clone() } pub fn setup_provider( @@ -75,7 +74,7 @@ pub fn setup_provider( let provider_name = String::from("Cst-") + target.to_string().as_str(); register_provider(*target, provider_name); if amount.gt(&0u64) { - if staking_type == MaximumCapacity { + if staking_type == StakingType::MaximumCapacity { assert_ok!(Capacity::stake(RuntimeOrigin::signed(staker.clone()), *target, *amount,)); } else { assert_ok!(Capacity::provider_boost( @@ -86,6 +85,7 @@ pub fn setup_provider( } let target = Capacity::get_target_for(staker, target).unwrap(); assert_eq!(target.amount, *amount); - assert_eq!(target.staking_type, staking_type); + let account_staking_type = Capacity::get_staking_account_for(staker).unwrap().staking_type; + assert_eq!(account_staking_type, staking_type); } } diff --git a/pallets/capacity/src/tests/unstaking_tests.rs b/pallets/capacity/src/tests/unstaking_tests.rs index 52c86856da..069be7999f 100644 --- a/pallets/capacity/src/tests/unstaking_tests.rs +++ b/pallets/capacity/src/tests/unstaking_tests.rs @@ -49,9 +49,9 @@ fn unstake_happy_path() { assert_eq!( staking_target_details, - StakingTargetDetails:: { - amount: BalanceOf::::from(60u64), - capacity: BalanceOf::::from(6u64), + StakingTargetDetails::> { + amount: BalanceOf::::from(60u32), + capacity: BalanceOf::::from(6u32), } ); @@ -172,6 +172,7 @@ fn unstaking_everything_reaps_staking_account() { register_provider(target, String::from("WithdrawUnst")); assert_ok!(Capacity::stake(RuntimeOrigin::signed(staker), target, amount)); + run_to_block(1); // unstake everything assert_ok!(Capacity::unstake(RuntimeOrigin::signed(staker), target, 20)); diff --git a/pallets/capacity/src/types.rs b/pallets/capacity/src/types.rs index 80dc35c396..7219a13394 100644 --- a/pallets/capacity/src/types.rs +++ b/pallets/capacity/src/types.rs @@ -1,12 +1,13 @@ //! Types for the Capacity Pallet use super::*; use frame_support::{BoundedVec, EqNoBound, PartialEqNoBound, RuntimeDebugNoBound}; -use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; +use parity_scale_codec::{Decode, Encode, EncodeLike, MaxEncodedLen}; use scale_info::TypeInfo; use sp_runtime::{ - traits::{CheckedAdd, CheckedSub, Saturating, Zero}, + traits::{CheckedAdd, CheckedSub, Saturating}, RuntimeDebug, }; +use sp_runtime::traits::AtLeast32BitUnsigned; #[cfg(any(feature = "runtime-benchmarks", test))] use sp_std::vec::Vec; @@ -75,27 +76,46 @@ impl Default for StakingDetails { /// Details about the total token amount targeted to an MSA. /// The Capacity that the target will receive. -#[derive(PartialEq, Eq, Default, Clone, Encode, Decode, RuntimeDebug, TypeInfo, MaxEncodedLen)] -pub struct StakingTargetDetails { +#[derive(Default, PartialEq, Eq, Clone, Encode, Decode, RuntimeDebug, TypeInfo, MaxEncodedLen)] +pub struct StakingTargetDetails +where + Balance: Default + Saturating + Copy + CheckedAdd + CheckedSub, +{ /// The total amount of tokens that have been targeted to the MSA. pub amount: Balance, /// The total Capacity that an MSA received. pub capacity: Balance, } -impl StakingTargetDetails { +impl + StakingTargetDetails +{ /// Increase an MSA target Staking total and Capacity amount. pub fn deposit(&mut self, amount: Balance, capacity: Balance) -> Option<()> { self.amount = amount.checked_add(&self.amount)?; self.capacity = capacity.checked_add(&self.capacity)?; - Some(()) } /// Decrease an MSA target Staking total and Capacity amount. - pub fn withdraw(&mut self, amount: Balance, capacity: Balance) { + /// If the amount would put you below the minimum, zero out the amount. + /// Return the actual amounts withdrawn. + pub fn withdraw( + &mut self, + amount: Balance, + capacity: Balance, + minimum: Balance, + ) -> (Balance, Balance) { + let entire_amount = self.amount; + let entire_capacity = self.capacity; self.amount = self.amount.saturating_sub(amount); - self.capacity = self.capacity.saturating_sub(capacity); + if self.amount.lt(&minimum) { + *self = Self::default(); + return (entire_amount, entire_capacity) + } else { + self.capacity = self.capacity.saturating_sub(capacity); + } + (amount, capacity) } } @@ -223,3 +243,130 @@ pub fn unlock_chunks_from_vec(chunks: &Vec<(u32, u32)>) -> UnlockChun // CAUTION BoundedVec::try_from(result).unwrap() } + +/// The information needed to track a Reward Era +#[derive( PartialEq, Eq, Clone, Copy, Default, PartialOrd, Encode, Decode, RuntimeDebug, TypeInfo, MaxEncodedLen)] +pub struct RewardEraInfo + where + RewardEra: AtLeast32BitUnsigned + EncodeLike, +{ + /// the index of this era + pub era_index: RewardEra, + /// the starting block of this era + pub started_at: BlockNumber, +} + +/// Needed data about a RewardPool for a given RewardEra. +/// The total_reward_pool balance for the previous era is set when a new era starts, +/// based on total staked token at the end of the previous era, and remains unchanged. +/// The unclaimed_balance is initialized to total_reward_pool and deducted whenever a +/// valid claim occurs. +#[derive( +PartialEq, Eq, Clone, Default, PartialOrd, Encode, Decode, RuntimeDebug, TypeInfo, MaxEncodedLen, +)] +pub struct RewardPoolInfo { + /// the total staked for rewards in the associated RewardEra + pub total_staked_token: Balance, + /// the reward pool for this era + pub total_reward_pool: Balance, + /// the remaining rewards balance to be claimed + pub unclaimed_balance: Balance, +} + +// TODO: Store retarget unlock chunks in their own storage but can be for any stake type +// TODO: Store unstake unlock chunks in their own storage but can be for any stake type (at first do only boosted unstake) + +/// A record of a staked amount for a complete RewardEra +#[derive(PartialEq, Eq, Clone, Encode, Decode, RuntimeDebug, TypeInfo, MaxEncodedLen)] +pub struct StakingHistory { + /// The era this amount was staked + pub reward_era: RewardEra, + /// The total amount staked for the era + pub total_staked: Balance, +} + +/// Struct with utilities for storing and updating unlock chunks +#[derive(Debug, TypeInfo, PartialEqNoBound, EqNoBound, Clone, Decode, Encode, MaxEncodedLen)] +#[scale_info(skip_type_params(T))] +pub struct RetargetInfo { + /// How many times the account has retargeted this RewardEra + pub retarget_count: u32, + /// The last RewardEra they retargeted + pub last_retarget_at: T::RewardEra, +} + +impl Default for RetargetInfo { + fn default() -> Self { + Self { retarget_count: 0u32, last_retarget_at: Zero::zero() } + } +} + +impl RetargetInfo { + /// Increment retarget count and return Some() or + /// If there are too many, return None + pub fn update(&mut self, current_era: T::RewardEra) -> Option<()> { + let max_retargets = T::MaxRetargetsPerRewardEra::get(); + if self.retarget_count.ge(&max_retargets) && self.last_retarget_at.eq(¤t_era) { + return None + } + if self.last_retarget_at.lt(¤t_era) { + self.last_retarget_at = current_era; + self.retarget_count = 1; + } else { + self.retarget_count = self.retarget_count.saturating_add(1u32); + } + Some(()) + } +} + +/// A claim to be rewarded `claimed_reward` in token value +pub struct StakingRewardClaim { + /// How much is claimed, in token + pub claimed_reward: BalanceOf, + /// The end state of the staking account if the operations are valid + pub staking_account_end_state: StakingDetails, + /// The starting era for the claimed reward period, inclusive + pub from_era: T::RewardEra, + /// The ending era for the claimed reward period, inclusive + pub to_era: T::RewardEra, +} + +/// A trait that provides the Economic Model for Provider Boosting. +pub trait StakingRewardsProvider { + /// the AccountId this provider is using + type AccountId; + + /// the range of blocks over which a Reward Pool is determined and rewards are paid out + type RewardEra; + + /// The hasher to use for proofs + type Hash; + + /// Calculate the size of the reward pool using the current economic model + fn reward_pool_size(total_staked: BalanceOf) -> BalanceOf; + + /// Return the total unclaimed reward in token for `accountId` for `from_era` --> `to_era`, inclusive + /// Errors: + /// - EraOutOfRange when from_era or to_era are prior to the history retention limit, or greater than the current Era. + fn staking_reward_total( + account_id: Self::AccountId, + from_era: Self::RewardEra, + to_era: Self::RewardEra, + ) -> Result, DispatchError>; + + /// Validate a payout claim for `accountId`, using `proof` and the provided `payload` StakingRewardClaim. + /// Returns whether the claim passes validation. Accounts must first pass `payoutEligible` test. + /// Errors: + /// - NotAStakingAccount + /// - MaxUnlockingChunksExceeded + /// - All other conditions that would prevent a reward from being claimed return 'false' + fn validate_staking_reward_claim( + account_id: Self::AccountId, + proof: Self::Hash, + payload: StakingRewardClaim, + ) -> bool; + + /// Return the boost factor in capacity per token staked. + /// This factor is > 0 and < 1 token + fn capacity_boost(amount: BalanceOf) -> BalanceOf; +} diff --git a/pallets/frequency-tx-payment/src/tests/mock.rs b/pallets/frequency-tx-payment/src/tests/mock.rs index 4cb03ea98e..f885e69852 100644 --- a/pallets/frequency-tx-payment/src/tests/mock.rs +++ b/pallets/frequency-tx-payment/src/tests/mock.rs @@ -361,5 +361,5 @@ fn create_capacity_for(target: MessageSourceId, amount: u64) { let mut capacity_details = Capacity::get_capacity_for(target).unwrap_or_default(); let capacity: u64 = amount / (TEST_TOKEN_PER_CAPACITY as u64); capacity_details.deposit(&amount, &capacity).unwrap(); - Capacity::set_capacity_for(&target, &capacity_details); + Capacity::set_capacity_for(target, capacity_details); } diff --git a/pallets/frequency-tx-payment/src/tests/pallet_tests.rs b/pallets/frequency-tx-payment/src/tests/pallet_tests.rs index 910b9ba367..aa2754c9de 100644 --- a/pallets/frequency-tx-payment/src/tests/pallet_tests.rs +++ b/pallets/frequency-tx-payment/src/tests/pallet_tests.rs @@ -614,7 +614,7 @@ fn withdraw_fee_replenishes_capacity_account_on_new_epoch_before_deducting_fee() total_capacity_issued, last_replenished_epoch: 10, }; - Capacity::set_capacity_for(&provider_msa_id, &capacity_details); + Capacity::set_capacity_for(provider_msa_id, capacity_details); let call: &::RuntimeCall = &RuntimeCall::Balances(BalancesCall::transfer { dest: 2, value: 100 }); @@ -660,7 +660,7 @@ fn withdraw_fee_does_not_replenish_if_not_new_epoch() { total_capacity_issued, last_replenished_epoch, }; - Capacity::set_capacity_for(&provider_msa_id, &capacity_details); + Capacity::set_capacity_for(provider_msa_id, capacity_details); let call: &::RuntimeCall = &RuntimeCall::Balances(BalancesCall::transfer { dest: 2, value: 100 });