From 9260b1cd466a41ffe397c16ed74d9a64dc8085ea Mon Sep 17 00:00:00 2001 From: Shannon Wells Date: Fri, 7 Jul 2023 15:30:03 -0700 Subject: [PATCH] change staking target extrinsic, closes #1570 (#1623) The goal of this PR is to implement the `change_staking_target` extrinsic as specified in the Staking Rewards design document. The design document is updated as part of this PR to account for needed changes discovered during this implementation phase. Closes #1570 --- ...capacity_staking_rewards_implementation.md | 321 ++++++------------ .../capacity/change_staking_target.test.ts | 54 +++ .../capacity/replenishment.test.ts | 4 +- integration-tests/capacity/staking.test.ts | 10 +- .../capacity/transactions.test.ts | 2 +- .../scaffolding/extrinsicHelpers.ts | 8 +- integration-tests/scaffolding/helpers.ts | 2 +- pallets/capacity/src/benchmarking.rs | 53 ++- pallets/capacity/src/lib.rs | 181 ++++++++-- .../src/tests/change_staking_target_tests.rs | 317 +++++++++++++++++ pallets/capacity/src/tests/mock.rs | 5 +- pallets/capacity/src/tests/mod.rs | 1 + pallets/capacity/src/tests/other_tests.rs | 2 +- .../src/tests/rewards_provider_tests.rs | 3 +- .../src/tests/stake_and_deposit_tests.rs | 8 +- .../tests/staking_account_details_tests.rs | 69 +++- .../src/tests/staking_target_details_tests.rs | 29 +- pallets/capacity/src/tests/unstaking_tests.rs | 4 +- .../capacity/src/tests/withdrawal_tests.rs | 2 +- pallets/capacity/src/types.rs | 70 +++- pallets/capacity/src/weights.rs | 15 + .../frequency-tx-payment/src/tests/mock.rs | 1 + runtime/common/src/constants.rs | 2 + runtime/frequency/src/lib.rs | 1 + 24 files changed, 872 insertions(+), 292 deletions(-) create mode 100644 integration-tests/capacity/change_staking_target.test.ts create mode 100644 pallets/capacity/src/tests/change_staking_target_tests.rs diff --git a/designdocs/capacity_staking_rewards_implementation.md b/designdocs/capacity_staking_rewards_implementation.md index 24f8b02af5..ac8f762c16 100644 --- a/designdocs/capacity_staking_rewards_implementation.md +++ b/designdocs/capacity_staking_rewards_implementation.md @@ -1,26 +1,4 @@ # Capacity Staking Rewards Implementation - -## Overview -Staking Capacity for rewards is a new feature which allows token holders to stake FRQCY and split the staking -rewards with a Provider they choose. The Provider receives a small reward in Capacity -(which is periodically replenished), and the staker receives a periodic return in FRQCY token. -The amount of Capacity that the Provider would receive in such case is a fraction of what they would get from a -`MaximumCapacity` stake. - -The period of Capacity replenishment - the `Epoch` - and the period of token reward - the `RewardEra`- are different. -Epochs much necessarily be much shorter than rewards because Capacity replenishment needs to be multiple times a day to meet the needs of a high traffic network, and to allow Providers the ability to delay transactions to a time of day with lower network activity if necessary. -Reward eras need to be on a much longer scale, such as every two weeks, because there are potentially orders of magnitude more stakers, and calculating rewards is computationally more intensive than updating Capacity balances for the comparatively few Providers. -In addition, this lets the chain to store Reward history for much longer rather than forcing people to have to take steps to claim rewards. - -### Diagram -This illustrates roughly (and not to scale) how Provider Boost staking works. Just like the current staking behavior, now called Maximized staking, The Capacity generated by staking is added to the Provider's Capacity ledger immediately so it can be used right away. The amount staked is locked in Alice's account, preventing transfer. - -Provider Boost token rewards are earned only for token staked for a complete Reward Era. So Alice does not begin earning rewards until Reward Era 5 in the diagram, and this means Alice must wait until Reward Era 6 to claim rewards for Reward Era 5. Unclaimed reward amounts are actually not minted or transferred until they are claimed, and may also not be calculated until then, depending on the economic model. - -This process will be described in more detail in the Economic Model Design Document. - -![Provider boosted staking](https://github.com/LibertyDSNP/frequency/assets/502640/ffb632f2-79c2-4a09-a906-e4de02e4f348) - The proposed feature is a design for staking FRQCY token in exchange for Capacity and/or FRQCY. It is specific to the Frequency Substrate parachain. It consists of enhancements to the capacity pallet, needed traits and their implementations, and needed runtime configuration. @@ -28,70 +6,44 @@ It consists of enhancements to the capacity pallet, needed traits and their impl This does _not_ outline the economic model for Staking Rewards (also known as "Provider Boosting"); it describes the economic model as a black box, i.e. an interface. ## Context and Scope: -The Frequency Transaction Payment system allows certain transactions on chain to be paid for with Capacity. Accounts that wish to pay with Capacity must: +The Frequency Transaction Payment system uses Capacity to pay for certain transactions on chain. Accounts that wish to pay with Capacity must: 1. Have an [MSA](https://github.com/LibertyDSNP/frequency/blob/main/designdocs/accounts.md) 2. Be a [Provider](https://github.com/LibertyDSNP/frequency/blob/main/designdocs/provider_registration.md) (see also [Provider Permissions and Grants](https://github.com/LibertyDSNP/frequency/blob/main/designdocs/provider_permissions.md)) 3. Stake a minimum amount of FRQCY (on mainnet, UNIT on Rococo testnet) token to receive [Capacity](https://github.com/LibertyDSNP/frequency/blob/main/designdocs/capacity.md). # Problem Statement -This document outlines how to implement the Staking for Rewards feature described in [Capacity Staking Rewards Economic Model (TBD)](TBD). -It does not give regard to what the economic model actually is, since that is yet to be determined. +This document outlines how to implement the Staking for Rewards feature described in [Capacity Staking Rewards Economic Model (TBD)](TBD), without, at this time, regard to what the economic model actually is. ## Glossary -1. **FRQCY**: the native token of Frequency, a Substrate parachain in the Polkdaot blockhain ecosystem. +1. **FRQCY**: the native token of the Frequency blockchain 1. **Capacity**: the non-transferrable utility token which can be used only to pay for certain Frequency transactions. 1. **Account**: a Frequency System Account controlled by a private key and addressed by a public key, having at least a minimum balance (currently 0.01 FRQCY). 1. **Stake** (verb): to lock some amount of a token against transfer for a period of time in exchange for some reward. -1. **RewardEra**: the time period (TBD in blocks) that Staking Rewards are based upon. `RewardEra` is to distinguish it easily from Substrate's staking pallet Era, or the index of said time period. +1. **RewardEra**: the time period (TBD in blocks or Capacity Epochs) that Staking Rewards are based upon. RewardEra is to distinguish it easily from Substrate's staking pallet Era, or the index of said time period. 1. **Staking Reward**: a per-RewardEra share of a staking reward pool of FRQCY tokens for a given staking account. -1. **Reward Pool**: a fixed amount of FRQCY that can be minted for rewards each RewardEra and distributed to stakers. +1. **Reward Pool**: a fixed amount of FRQCY that can be minted for rewards each RewardEra and distributed to stakers. 1. **StakingRewardsProvider**: a trait that encapsulates the economic model for staking rewards, providing functionality for calculating the reward pool and staking rewards. ## Staking Token Rewards ### StakingAccountDetails updates -New fields are added. The field **`last_rewarded_at`** is to keep track of the last time rewards were claimed for this Staking Account. -MaximumCapacity staking accounts MUST always have the value `None` for `last_rewarded_at`. -Finally, `stake_change_unlocking`, is added, which stores an `UnlockChunk` when a staking account has changed. -targets for some amount of funds. This is to prevent retarget spamming. - -This will be a V2 of this storage and original StakingAccountDetails will need to be migrated. +New fields are added. The field `last_rewarded_at` is to keep track of the last time rewards were claimed for this Staking Account. +MaximumCapacity staking accounts MUST always have the value `None` for `last_rewarded_at`. This should be the default value also. +`MaximumCapacity` is also the default value for `staking_type` and should map to 0. +Finally, `stake_change_unlocking`, a BoundedVec is added which tracks the chunks of when a staking account has changed targets for some amount of funds. ```rust -pub struct StakingAccountDetailsV2 { +pub struct StakingAccountDetails { pub active: BalanceOf, pub total: BalanceOf, pub unlocking: BoundedVec, T::EpochNumber>, T::MaxUnlockingChunks>, /// The number of the last StakingEra that this account's rewards were claimed. pub last_rewards_claimed_at: Option, // NEW None means never rewarded, Some(RewardEra) means last rewarded RewardEra. + /// What type of staking this account is doing + pub staking_type: StakingType, // NEW /// staking amounts that have been retargeted are prevented from being retargeted again for the /// configured Thawing Period number of blocks. - pub stake_change_unlocking: BoundedVec, T::RewardEra>, T::MaxUnlockingChunks>, // NEW -} -``` - -### StakingTargetDetails updates, StakingHistory -A new field, `staking_type` is added to indicate the type of staking the Account holder is doing in relation to this target. -Staking type may be `MaximumCapacity` or `ProviderBoost`. `MaximumCapacity` is the default value for `staking_type` and maps to 0. - -```rust -/// A per-reward-era record for StakingAccount total_staked amount. -pub struct StakingHistory { // NEW - total_staked: Balance, - reward_era: RewardEra, -} - -#[derive(PartialEq, Eq, Clone, Encode, Decode, RuntimeDebug, TypeInfo, MaxEncodedLen)] -#[scale_info(skip_type_params(T))] -pub struct StakingTargetDetails { - /// The total amount of tokens that have been targeted to the MSA. - pub amount: BalanceOf, - /// The total Capacity that an MSA received. - pub capacity: BalanceOf, - /// The type of staking, which determines ultimate capacity per staked token. - pub staking_type: StakingType, // NEW - /// total staked amounts for each past era, up to StakingRewardsPastErasMax eras. - pub staking_history: BoundedVec, T::RewardEra>, T::StakingRewardsPastErasMax>, // NEW + pub stake_change_unlocking: BoundedVec, EraOf>, T::MaxUnlockingChunks> // NEW } ``` @@ -99,49 +51,40 @@ pub struct StakingTargetDetails { Changes the thaw period to begin at the first block of next RewardEra instead of immediately. ### Changes to extrinsics -#### stake -The parameters for the `stake` extrinsic remain the same and the behavior is the same, in that this creates or adds -more token to a staker-target relationship with type `MaximiumCapacity`. -However, if one calls `stake` with a `target` that `origin` already has a staker-target relationsip with, -it is _not_ a `MaximumCapacity` staking type, it will error with `Error::CannotChangeStakingType`. - -#### unstake -The unstake parameters are the same, and unstake behavior is the same for `MaximumCapacity` as before, however -for a `ProviderBoost` staker-target relationship, the behavior must be different. While it's not feasible to -store either `reward_pool` history or individual staking reward history indefinitely, it still may be lengthy -enough that having to calculate _all_ unclaimed rewards for what could be numerous accounts in one block -could make a block heavier than desired. Therefore there must be a limit limit on how many eras -one can claim rewards for. This value will likely be a pallet constant. The logic would be: - - * If a ProviderBoost stake is `payout_eligible`, - * check whether their last payout era is recent enough to pay out all rewards at once. - * if so, first pay out all rewards and then continue with rest of unstaking code as is - * if not, emit error `MustFirstClaimRewards`, `UnclaimedRewardsOverTooManyEras` or something like that. - Don't use `EraOutOfRange` because it will overload the meaning of that error; needs to be something more specific. - * If not payout eligible, - * check whether the last payout era is the current one. - * if so, all rewards have been claimed, so continue with rest of unstaking code as is, - * if not, it means they have too many unlocking chunks so they'll have to wait. - the unstaking code - will catch this anyway and emit `MaxUnlockingChunksExceeded` - ```rust +pub fn stake( + origin: OriginFor, + target: MessageSourceId, + amount: BalanceOf, + staking_type: StakingType // NEW +) -> DispatchResult { + /// NEW BEHAVIOR: + // if the account is new, save the new staking type + // if not new and staking type is different, Error::CannotChangeStakingType +} + pub fn unstake( origin: OriginFor, target: MessageSourceId, requested_amount: BalanceOf, -) -> DispatchResult {} - +) -> DispatchResult { + // NEW BEHAVIOR: + // If StakingType is RewardsType + // If payout_eligible, + // check whether their last payout era is recent enough to pay out all rewards at once. + // if so, first pay out all rewards and then continue with rest of unstaking code as is + // if not, emit error "MustFirstClaimUnclaimedRewards", "UnclaimedRewardsOverTooManyEras" or something like that + // If not payout eligible, + // check whether the last payout era is the current one. + // if so, all rewards have been claimed, so continue with rest of unstaking code as is, + // + // otherwise, they have too many unlocking chunks so they'll have to wait. - the unstaking code + // will catch this anyway and emit `MaxUnlockingChunksExceeded` +} ``` ### NEW: StakingRewardsProvider - Economic Model trait -This one is not yet determined, however there are certain functions that will definitely be needed. -The rewards system will still need to know the `reward_pool_size`. - -The struct and method for claiming rewards is probably going to change. -The `staking_reward_total` for a given staker may not be calculable by the node, depending on the complexity of the -economic rewards model. -It's possible that it would be calculated via some app with access to the staker's wallet, and submitted as a proof -with a payload. -In that case the `validate_staking_reward_claim` is more likely to be part of the trait. +This one is most likely to change, however there are certain functions that will definitely be needed. +The struct and method for claiming rewards is probably going to change, but the rewards system will still need to know the `reward_pool_size` and the `staking_reward_total` for a given staker. ```rust use std::hash::Hash; @@ -165,7 +108,6 @@ pub trait StakingRewardsProvider { /// Return the total unclaimed reward in token for `account_id` for `fromEra` --> `toEra`, inclusive /// Errors: /// - EraOutOfRange when fromEra or toEra are prior to the history retention limit, or greater than the current RewardEra. - /// May not be possible depending on economic model complexity. fn staking_reward_total(account_id: T::AccountId, fromEra: T::RewardEra, toEra: T::RewardEra); /// Validate a payout claim for `account_id`, using `proof` and the provided `payload` StakingRewardClaim. @@ -175,6 +117,17 @@ pub trait StakingRewardsProvider { } ``` +### NEW: StakingType enum +```rust +pub enum StakingType { + /// Staking account targets Providers for capacity only, no token reward + MaximizedCapacity, + /// Staking account targets Providers and splits reward between capacity to the Provider + /// and token for the account holder + Rewards, +} +``` + ### NEW: Config items ```rust pub trait Config: frame_system::Config { @@ -196,35 +149,24 @@ pub trait Config: frame_system::Config { type EraLength: Get; /// The maximum number of eras over which one can claim rewards type StakingRewardsPastErasMax: Get; - /// The trait providing the ProviderBoost economic model calculations and values + type RewardsProvider: StakingRewardsProvider; }; ``` -### NEW: RewardPoolInfo, RewardPoolHistory -Information about the reward pool for a given Reward Era and how it's stored. The size of this pool is limited to -`StakingRewardsPastErasMax` but is stored as a CountedStorageMap instead of a BoundedVec for performance reasons: -* claiming rewards for the entire history will be unlikely to be allowed. Iterating over a much smaller range is more performant -* Fetching/writing the entire history every block could affect block times. Instead, once per block, retrieve the latest record, delete the earliest record and insert a new one +### NEW: RewardPoolInfo +This is the necessary information about the reward pool for a given Reward Era and how it's stored. ```rust -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, +pub struct RewardPoolInfo { } - /// Reward Pool history #[pallet::storage] #[pallet::getter(fn get_reward_pool_for_era)] -pub type StakingRewardPool = ; +pub type StakingRewardPool = ; ``` ### NEW: CurrentEra, RewardEraInfo Incremented, like CurrentEpoch, tracks the current RewardEra number and the block when it started. -Storage is whitelisted because it's accessed every block and would improperly adversely impact all benchmarks. ```rust #[pallet::storage] #[pallet::whitelist_storage] @@ -250,105 +192,60 @@ pub enum Error { 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, } ``` ### NEW Extrinsics -This is the most undecided portion of this design and depends strongly on the chosen economic model for Provider Boosting. -There are generally two forms that claiming a staking reward could take, and this depends on whether it's possible to -calculate rewards on chain at all. - -Regardless, on success, the claimed rewards are minted and transferred as locked token to the origin, with the existing -unstaking thaw period for withdrawal (which simply unlocks thawed token amounts as before). -There is no chunk added; instead the existing unstaking thaw period is applied to last_rewards_claimed_at in StakingAccountDetails. - -Forcing stakers to wait a thaw period for every claim is an incentive to claim rewards sooner than later, leveling out -possible inflationary effects and helping prevent unclaimed rewards from expiring. -The thaw period must be short enough for all rewards to be claimed before rewards history would end. -Therefore, it's possible that a complete separate reward claim thaw period would need to be used. - -For all forms of claim_staking_reward, the event `StakingRewardClaimed` is emitted with the parameters of the extrinsic. - -#### provider_boost(origin, target, amount) -Like `stake`, except this extrinsic creates or adds staked token to a `ProviderBoost` type staker-target relationship. -In the case of an increase in stake, `staking_type` MUST be a `ProviderBoost` type, or else it will error with `Error::CannotChangeStakingType`. -```rust -pub fn provider_boost( - origin: OriginFor, - target: MessageSourceId, - amount: BalanceOf, -) -> DispatchResult {} -``` - -#### 1. claim_staking_reward(origin, from_era, to_era), simple economic model -In the case of a simple economic model such as a fixed rate return, reward calculations may be done on chain - -within discussed limits. -```rust -/// Claim staking rewards from `from_era` to `to_era`, inclusive. -/// from_era: if None, since last_reward_claimed_at -/// to_era: if None, to CurrentEra - 1 -/// Errors: -/// - NotAStakingAccount: if Origin does not own the StakingRewardDetails in the claim. -/// - IneligibleForPayoutInEraRange: if rewards were already paid out in the provided RewardEra range -/// - EraOutOfRange: -/// - if `from_era` is earlier than history storage -/// - if `to_era` is >= current era -/// - if `to_era` - `from_era` > StakingRewardsPastErasMax -#[pallet::call_index(n)] -pub fn claim_staking_reward( - origin: OriginFor, - from_era: Option, - to_era: Option -); -``` - -#### 2. claim_staking_reward(origin,proof,payload) -TBD whether this is the form for claiming rewards. -This could be the form if calculations are done off chain and submitted for validation. - -```rust +1. *claim_staking_reward*, first version + a. `claim_staking_reward(origin,proof,payload)` + ```rust + /// TBD whether this is the form for claiming rewards. This could be the form if calculations are + /// done off chain and submitted for validation. /// Validates the reward claim. If validated, mints token and transfers to Origin. /// Errors: /// - NotAStakingAccount: if Origin does not own the StakingRewardDetails in the claim. /// - StakingRewardClaimInvalid: if validation of calculation fails /// - IneligibleForPayoutInEraRange: if rewards were already paid out in the provided RewardEra range - /// - EraOutOfRange: - /// - if `from_era` is earlier than history storage - /// - if `to_era` is >= current era - /// - if `to_era` - `from_era` > StakingRewardsPastErasMax + /// - EraOutOfRange: if one or both of the StakingRewardClaim eras are invalid + /// `proof` - the Merkle proof for the reward claim #[pallet::call_index(n)] pub fn claim_staking_reward( origin: OriginFor, - /// `proof` - the Merkle proof for the reward claim proof: Hash, - /// The staking reward claim payload for which the proof was generated payload: StakingRewardClaim ); -``` -#### 3. change_staking_target(origin, from, to, amount) + ``` + b. *claim_staking_reward*, alternate version + ```rust + /// An alternative, depending on staking reward economic model. This could be the form if calculations are done on chain. + /// from_era: if None, since last_reward_claimed_at + /// to_era: if None, to CurrentEra - 1 + /// Errors: + /// - NotAStakingAccount: if Origin does not own the StakingRewardDetails in the claim. + /// - IneligibleForPayoutInEraRange: if rewards were already paid out in the provided RewardEra range + /// - EraOutOfRange: if one or both of the eras specified are invalid + #[pallet::call_index(n)] + pub fn claim_staking_reward( + origin: OriginFor, + from_era: Option, + to_era: Option + ); + ``` + Both emit events `StakingRewardClaimed` with the parameters of the extrinsic. + +2. **change_staking_target(origin, from, to, amount)** Changes a staking account detail's target MSA Id to a new one by `amount` Rules for this are similar to unstaking; if `amount` would leave less than the minimum staking amount for the `from` target, the entire amount is retargeted. -No more than `T::MaxUnlockingChunks` staking amounts may be retargeted within this Thawing Period. +No more than T::MaxUnlockingChunks staking amounts may be retargeted within this Thawing Period. Each call creates one chunk. Emits a `StakingTargetChanged` event with the parameters of the extrinsic. + ```rust -/// 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::NotAStakingAccount`] if origin does not have a staking account -/// - [`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::CannotChangeStakingType`] if origin already has funds staked for `to` and the staking type for `from` is different. +/// Errors: +/// - MaxUnlockingChunksExceeded if 'from' target staking amount is still thawing in the staking unlock chunks (either type) +/// - StakerTargetRelationshipNotFound` if `from` is not a staking target for Origin. This also covers when account's MSA is not staking anything at all or account has no MSA +/// - StakingAmountBelowMinimum if amount to retarget is below the minimum staking amount. +/// - InsufficientStakingBalance if amount to retarget exceeds what the staker has targeted to the `from` MSA Id. +/// - InvalidTarget if `to` is not a Registered Provider. #[pallet:call_index(n+1)] // n = current call index in the pallet pub fn change_staking_target( origin: OriginFor, @@ -359,37 +256,19 @@ pub fn change_staking_target( ``` ### NEW: Capacity pallet helper function -#### payout_eligible -Returns whether `account_id` can claim a reward at all. -This function will return false if there is no staker-target relationship. -Staking accounts may claim rewards: -* ONCE per RewardEra, -* Only for funds staked for a complete RewardEra, i.e. the balance at the end of the Era, -* Must wait for the thaw period to claim rewards again (see `last_rewards_claimed_at`) ```rust +/// Return whether `account_id` can claim a reward. Staking accounts may not claim a reward more than once +/// per RewardEra, may not claim rewards before a complete RewardEra has been staked, and may not claim more rewards past +/// the number of `MaxUnlockingChunks`. +/// Errors: +/// NotAStakingAccount if account_id has no StakingAccountDetails in storage. fn payout_eligible(account_id: AccountIdOf) -> bool; ``` -### NEW RPCS +### NEW RPC There are no custom RPCs for the Capacity pallet, so that work will need to be done first. - -The form of this will depend on whether the rewards calculation for an individual account is done by the node or externally -with a submitted proof. If externally, then unclaimed rewards would not include an earned amount. - ```rust -pub struct UnclaimedRewardInfo { - /// The Reward Era for which this reward was earned - reward_era: RewardEra, - /// An ISO8701 string, UTC, estimated using current block time, and the number of blocks between - /// the current block and the block when this era's RewardPoolInfo would be removed from StakingRewardPool history - expires_at: string, - /// The amount staked in this era - staked_amount: BalanceOf, - /// The amount in token of the reward (only if it can be calculated using only on chain data) - earned_amount: BalanceOf -} - -/// Check what unclaimed rewards origin has and how long they have left to claim them -/// If no unclaimed rewards, returns empty list. -fn check_for_unclaimed_rewards(origin: OriginFor) -> Vec; +/// RPC access to the pallet function by the same name +pub fn payout_eligible(account_id: AccountId) -> bool; ``` + diff --git a/integration-tests/capacity/change_staking_target.test.ts b/integration-tests/capacity/change_staking_target.test.ts new file mode 100644 index 0000000000..fcaeeccedd --- /dev/null +++ b/integration-tests/capacity/change_staking_target.test.ts @@ -0,0 +1,54 @@ +import "@frequency-chain/api-augment"; +import { KeyringPair } from "@polkadot/keyring/types"; +import { u64, } from "@polkadot/types"; +import assert from "assert"; +import { ExtrinsicHelper, } from "../scaffolding/extrinsicHelpers"; +import { + devAccounts, createKeys, createMsaAndProvider, + stakeToProvider, CHAIN_ENVIRONMENT, + TEST_EPOCH_LENGTH, setEpochLength, + CENTS, DOLLARS, createAndFundKeypair, createProviderKeysAndId +} + from "../scaffolding/helpers"; +import { firstValueFrom } from "rxjs"; +import { MessageSourceId} from "@frequency-chain/api-augment/interfaces"; + +describe.only("change_staking_target tests", () => { + const tokenMinStake: bigint = 1n * CENTS; + const capacityMin: bigint = tokenMinStake / 50n; + + const unusedMsaId = async () => { + const maxMsaId = (await ExtrinsicHelper.getCurrentMsaIdentifierMaximum()).toNumber(); + return maxMsaId + 99; + } + + before(async () => { + if (process.env.CHAIN_ENVIRONMENT === CHAIN_ENVIRONMENT.DEVELOPMENT) { + await setEpochLength(devAccounts[0].keys, TEST_EPOCH_LENGTH); + } + }); + + it("happy path succeeds", async () => { + const providerBalance = 2n * DOLLARS; + const stakeKeys = createKeys("staker"); + const oldProvider = await createMsaAndProvider(stakeKeys, "Provider1", providerBalance); + const [_unused, newProvider] = await createProviderKeysAndId(); + + await assert.doesNotReject(stakeToProvider(stakeKeys, oldProvider, tokenMinStake*3n)); + + const call = ExtrinsicHelper.changeStakingTarget(stakeKeys, oldProvider, newProvider, tokenMinStake); + const [events] = await call.signAndSend(); + assert.notEqual(events, undefined); + }); + + // not intended to be exhaustive, just check one error case + it("fails if 'to' is not a Provider", async () => { + const providerBalance = 2n * DOLLARS; + const stakeKeys = createKeys("staker"); + const notAProvider = await unusedMsaId(); + const oldProvider = await createMsaAndProvider(stakeKeys, "Provider1", providerBalance); + await assert.doesNotReject(stakeToProvider(stakeKeys, oldProvider, tokenMinStake*3n)); + const call = ExtrinsicHelper.changeStakingTarget(stakeKeys, oldProvider, notAProvider, tokenMinStake); + await assert.rejects(call.signAndSend(), {name: "InvalidTarget"}) + }); +}); diff --git a/integration-tests/capacity/replenishment.test.ts b/integration-tests/capacity/replenishment.test.ts index f2787affb1..affd8d27f1 100644 --- a/integration-tests/capacity/replenishment.test.ts +++ b/integration-tests/capacity/replenishment.test.ts @@ -101,7 +101,7 @@ describe("Capacity Replenishment Testing: ", function () { // new user/msa stakes to provider const userKeys = createKeys("userKeys"); await fundKeypair(devAccounts[0].keys, userKeys, 5n * DOLLARS); - let [_, events] = await ExtrinsicHelper.stake(userKeys, stakeProviderId, userStakeAmt).fundAndSend(); + let [_, events] = await ExtrinsicHelper.stake(userKeys, stakeProviderId, userStakeAmt, 'MaximumCapacity').fundAndSend(); assertEvent(events, 'system.ExtrinsicSuccess'); const payload = JSON.stringify({ changeType: 1, fromId: 1, objectId: 2 }) @@ -125,7 +125,7 @@ describe("Capacity Replenishment Testing: ", function () { assert(remainingCapacity < callCapacityCost); // user stakes tiny additional amount - [_, events] = await ExtrinsicHelper.stake(userKeys, stakeProviderId, userIncrementAmt).fundAndSend(); + [_, events] = await ExtrinsicHelper.stake(userKeys, stakeProviderId, userIncrementAmt, 'MaximumCapacity').fundAndSend(); assertEvent(events, 'capacity.Staked'); // provider can now send a message diff --git a/integration-tests/capacity/staking.test.ts b/integration-tests/capacity/staking.test.ts index 6789b55ed4..5308ebddf9 100644 --- a/integration-tests/capacity/staking.test.ts +++ b/integration-tests/capacity/staking.test.ts @@ -208,7 +208,7 @@ describe("Capacity Staking Tests", function () { const stakeAmount = 10n * CENTS; const stakeKeys = await createAndFundKeypair(stakeAmount, "StakeKeys"); - const failStakeObj = ExtrinsicHelper.stake(stakeKeys, maxMsaId + 1, stakeAmount); + const failStakeObj = ExtrinsicHelper.stake(stakeKeys, maxMsaId + 1, stakeAmount, 'MaximumCapacity'); await assert.rejects(failStakeObj.fundAndSend(), { name: "InvalidTarget" }); }); }); @@ -219,7 +219,7 @@ describe("Capacity Staking Tests", function () { let providerId = await createMsaAndProvider(stakingKeys, "stakingKeys", 150n * CENTS); let stakeAmount = 1500n; - const failStakeObj = ExtrinsicHelper.stake(stakingKeys, providerId, stakeAmount); + const failStakeObj = ExtrinsicHelper.stake(stakingKeys, providerId, stakeAmount, 'MaximumCapacity'); await assert.rejects(failStakeObj.fundAndSend(), { name: "InsufficientStakingAmount" }); }); }); @@ -229,7 +229,7 @@ describe("Capacity Staking Tests", function () { let stakingKeys = createKeys("stakingKeys"); let providerId = await createMsaAndProvider(stakingKeys, "stakingKeys", ); - const failStakeObj = ExtrinsicHelper.stake(stakingKeys, providerId, 0); + const failStakeObj = ExtrinsicHelper.stake(stakingKeys, providerId, 0, 'MaximumCapacity'); await assert.rejects(failStakeObj.fundAndSend(), { name: "ZeroAmountNotAllowed" }); }); }); @@ -240,7 +240,7 @@ describe("Capacity Staking Tests", function () { let providerId = await createMsaAndProvider(stakingKeys, "stakingKeys"); let stakingAmount = 1n * DOLLARS; - const failStakeObj = ExtrinsicHelper.stake(stakingKeys, providerId, stakingAmount); + const failStakeObj = ExtrinsicHelper.stake(stakingKeys, providerId, stakingAmount, 'MaximumCapacity'); await assert.rejects(failStakeObj.fundAndSend(), { name: "BalanceTooLowtoStake" }); }); }); @@ -282,7 +282,7 @@ describe("Capacity Staking Tests", function () { let stakingKeys: KeyringPair = createKeys("stakingKeys"); let providerId: u64 = await createMsaAndProvider(stakingKeys, "stakingKeys", accountBalance); - const stakeObj = ExtrinsicHelper.stake(stakingKeys, providerId, tokenMinStake); + const stakeObj = ExtrinsicHelper.stake(stakingKeys, providerId, tokenMinStake, 'MaximumCapacity'); const [stakeEvent] = await stakeObj.fundAndSend(); assert.notEqual(stakeEvent, undefined, "should return a Stake event"); diff --git a/integration-tests/capacity/transactions.test.ts b/integration-tests/capacity/transactions.test.ts index 36e716bdb3..4f0f709775 100644 --- a/integration-tests/capacity/transactions.test.ts +++ b/integration-tests/capacity/transactions.test.ts @@ -513,7 +513,7 @@ describe("Capacity Transactions", function () { it("fails to pay with Capacity for a non-capacity transaction", async function () { const capacityKeys = createKeys("CapacityKeys"); const capacityProvider = await createMsaAndProvider(capacityKeys, "CapacityProvider", FUNDS_AMOUNT); - const nonCapacityTxn = ExtrinsicHelper.stake(capacityKeys, capacityProvider, 1n * CENTS); + const nonCapacityTxn = ExtrinsicHelper.stake(capacityKeys, capacityProvider, 1n * CENTS, 'MaximumCapacity'); await assert.rejects(nonCapacityTxn.payWithCapacity(), { name: "RpcError", message: "1010: Invalid Transaction: Custom error: 0" diff --git a/integration-tests/scaffolding/extrinsicHelpers.ts b/integration-tests/scaffolding/extrinsicHelpers.ts index 6d30e41f2b..655ce187d6 100644 --- a/integration-tests/scaffolding/extrinsicHelpers.ts +++ b/integration-tests/scaffolding/extrinsicHelpers.ts @@ -384,8 +384,8 @@ export class ExtrinsicHelper { public static setEpochLength(keys: KeyringPair, epoch_length: any): Extrinsic { return new Extrinsic(() => ExtrinsicHelper.api.tx.capacity.setEpochLength(epoch_length), keys, ExtrinsicHelper.api.events.capacity.EpochLengthUpdated); } - public static stake(keys: KeyringPair, target: any, amount: any): Extrinsic { - return new Extrinsic(() => ExtrinsicHelper.api.tx.capacity.stake(target, amount), keys, ExtrinsicHelper.api.events.capacity.Staked); + public static stake(keys: KeyringPair, target: any, amount: any, stakingType: 'MaximumCapacity' | 'ProviderBoost'): Extrinsic { + return new Extrinsic(() => ExtrinsicHelper.api.tx.capacity.stake(target, amount, stakingType), keys, ExtrinsicHelper.api.events.capacity.Staked); } public static unstake(keys: KeyringPair, target: any, amount: any): Extrinsic { @@ -396,6 +396,10 @@ export class ExtrinsicHelper { return new Extrinsic(() => ExtrinsicHelper.api.tx.capacity.withdrawUnstaked(), keys, ExtrinsicHelper.api.events.capacity.StakeWithdrawn); } + public static changeStakingTarget(keys: KeyringPair, fromMsa: any, toMsa: any, amount: any): Extrinsic { + return new Extrinsic(() => ExtrinsicHelper.api.tx.capacity.changeStakingTarget(fromMsa, toMsa, amount), keys, ExtrinsicHelper.api.events.capacity.StakingTargetChanged); + } + public static payWithCapacityBatchAll(keys: KeyringPair, calls: any): Extrinsic { return new Extrinsic(() => ExtrinsicHelper.api.tx.frequencyTxPayment.payWithCapacityBatchAll(calls), keys, ExtrinsicHelper.api.events.utility.BatchCompleted); } diff --git a/integration-tests/scaffolding/helpers.ts b/integration-tests/scaffolding/helpers.ts index f5c21ef83d..55307d2fa5 100644 --- a/integration-tests/scaffolding/helpers.ts +++ b/integration-tests/scaffolding/helpers.ts @@ -271,7 +271,7 @@ export async function createMsaAndProvider(keys: KeyringPair, providerName: stri // Stakes the given amount of tokens from the given keys to the given provider export async function stakeToProvider(keys: KeyringPair, providerId: u64, tokensToStake: bigint): Promise { - const stakeOp = ExtrinsicHelper.stake(keys, providerId, tokensToStake); + const stakeOp = ExtrinsicHelper.stake(keys, providerId, tokensToStake, 'MaximumCapacity'); const [stakeEvent] = await stakeOp.fundAndSend(); assert.notEqual(stakeEvent, undefined, 'stakeToProvider: should have returned Stake event'); diff --git a/pallets/capacity/src/benchmarking.rs b/pallets/capacity/src/benchmarking.rs index f8f5ce0720..014894537d 100644 --- a/pallets/capacity/src/benchmarking.rs +++ b/pallets/capacity/src/benchmarking.rs @@ -16,6 +16,33 @@ pub fn register_provider(target_id: MessageSourceId, name: &'static s let name = Vec::from(name).try_into().expect("error"); assert_ok!(T::BenchmarkHelper::create(target_id, name)); } + +#[allow(clippy::expect_used)] +pub fn setup_provider_stake( + staker: &T::AccountId, + target_id: &MessageSourceId, + amount: u32, +) { + let staking_amount: BalanceOf = T::MinimumStakingAmount::get().saturating_add(amount.into()); + let capacity_amount: BalanceOf = Capacity::::capacity_generated(staking_amount); + + // will add the amount to the account if it exists. + let mut staking_account: StakingAccountDetails = + Capacity::get_staking_account_for(staker).unwrap_or_default(); + + let mut target_details = StakingTargetDetails::::default(); + let mut capacity_details = + CapacityDetails::, ::EpochNumber>::default(); + + staking_account.deposit(staking_amount); + target_details.deposit(staking_amount, capacity_amount); + capacity_details.deposit(&staking_amount, &capacity_amount); + + Capacity::::set_staking_account(staker, &staking_account); + Capacity::::set_target_details_for(staker, *target_id, target_details); + Capacity::::set_capacity_for(*target_id, capacity_details); +} + pub fn create_funded_account( string: &'static str, n: u32, @@ -92,7 +119,7 @@ benchmarks! { let block_number = 4u32; let mut staking_account = StakingAccountDetails::::default(); - let mut target_details = StakingTargetDetails::>::default(); + let mut target_details = StakingTargetDetails::::default(); let mut capacity_details = CapacityDetails::, ::EpochNumber>::default(); staking_account.deposit(staking_amount); @@ -116,6 +143,30 @@ benchmarks! { assert_last_event::(Event::::EpochLengthUpdated {blocks: epoch_length}.into()); } + change_staking_target { + let caller: T::AccountId = create_funded_account::("account", SEED, 5u32); + let from_msa = 33; + let to_msa = 34; + // amount in addition to minimum + let from_msa_amount = 32u32; + let to_msa_amount = 1u32; + + register_provider::(from_msa, "frommsa"); + register_provider::(to_msa, "tomsa"); + setup_provider_stake::(&caller, &from_msa, from_msa_amount); + setup_provider_stake::(&caller, &to_msa, to_msa_amount); + let restake_amount = 11u32; + + }: _ (RawOrigin::Signed(caller.clone(), ), from_msa, to_msa, restake_amount.into()) + verify { + assert_last_event::(Event::::StakingTargetChanged { + account: caller, + from_msa, + to_msa, + amount: restake_amount.into() + }.into()); + } + impl_benchmark_test_suite!(Capacity, crate::tests::mock::new_test_ext(), crate::tests::mock::Test); diff --git a/pallets/capacity/src/lib.rs b/pallets/capacity/src/lib.rs index a9931fa236..9a6b572156 100644 --- a/pallets/capacity/src/lib.rs +++ b/pallets/capacity/src/lib.rs @@ -179,6 +179,12 @@ pub mod pallet { /// The StakingRewardsProvider used by this pallet in a given runtime type RewardsProvider: StakingRewardsProvider; + + /// After calling change_staking_target, the thaw period for the created + /// thaw chunk to expire. If the staker has called change_staking_target MaxUnlockingChunks + /// times, then at least one of the chunks must have expired before the next call + /// will succeed. + type ChangeStakingTargetThawEras: Get; } /// Storage for keeping a ledger of staked token amounts for accounts. @@ -200,7 +206,7 @@ pub mod pallet { T::AccountId, Twox64Concat, MessageSourceId, - StakingTargetDetails>, + StakingTargetDetails, >; /// Storage for target Capacity usage. @@ -295,6 +301,17 @@ 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, + }, } #[pallet::error] @@ -302,29 +319,30 @@ 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. NoUnstakedTokensAvailable, - /// Unstaking amount should be greater than zero. + /// Unstaking amount must 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. 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, @@ -350,7 +368,6 @@ 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::CannotChangeStakingType if the staking account exists and staking_type is different @@ -427,6 +444,7 @@ pub mod pallet { requested_amount: BalanceOf, ) -> DispatchResult { let unstaker = ensure_signed(origin)?; + Self::ensure_can_unstake(&unstaker)?; ensure!(requested_amount > Zero::zero(), Error::::UnstakedAmountIsZero); @@ -460,6 +478,51 @@ 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. + /// ### Errors + /// - [`Error::StakingAccountNotFound`] if origin does not have a staking account + /// - [`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. + #[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)?; + ensure!( + StakingAccountLedger::::contains_key(&staker), + Error::::StakingAccountNotFound + ); + ensure!( + StakingTargetLedger::::contains_key(&staker, &from), + Error::::StakerTargetRelationshipNotFound + ); + 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(()) + } } } @@ -468,7 +531,7 @@ impl Pallet { /// and leave the minimum required free balance after staking. /// /// # Errors - /// * [`Error::ZeroAmountNotAllowed`] + /// * [`Error::StakingAmountBelowMinimum`] /// * [`Error::InvalidTarget`] /// * [`Error::BalanceTooLowtoStake`] /// @@ -478,7 +541,7 @@ impl Pallet { amount: BalanceOf, staking_type: &StakingType, ) -> Result<(StakingAccountDetails, BalanceOf), DispatchError> { - ensure!(amount > Zero::zero(), Error::::ZeroAmountNotAllowed); + ensure!(amount > Zero::zero(), Error::::StakingAmountBelowMinimum); ensure!(T::TargetValidator::validate(target), Error::::InvalidTarget); let staking_account: StakingAccountDetails = @@ -502,7 +565,7 @@ impl Pallet { ensure!( new_active_staking_amount >= T::MinimumStakingAmount::get(), - Error::::InsufficientStakingAmount + Error::::StakingAmountBelowMinimum ); Ok((staking_account, stakable_amount)) @@ -560,9 +623,13 @@ impl Pallet { fn set_target_details_for( staker: &T::AccountId, target: MessageSourceId, - target_details: StakingTargetDetails>, + 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. @@ -582,18 +649,33 @@ impl Pallet { Self::get_staking_account_for(unstaker).ok_or(Error::::NotAStakingAccount)?; ensure!(amount <= staking_account.active, Error::::AmountToUnstakeExceedsAmountStaked); - let current_epoch: T::EpochNumber = Self::get_current_epoch(); - let thaw_period = T::UnstakingThawPeriod::get(); - let thaw_at = current_epoch.saturating_add(thaw_period.into()); - - let unstake_result = staking_account.withdraw(amount, thaw_at)?; + let unstake_result = staking_account.withdraw(amount, Self::get_thaw_at_epoch())?; Self::set_staking_account(&unstaker, &staking_account); Ok(unstake_result) } + 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()) + } + + fn ensure_can_unstake(unstaker: &T::AccountId) -> Result<(), DispatchError> { + let staking_account: StakingAccountDetails = + Self::get_staking_account_for(unstaker).ok_or(Error::::StakingAccountNotFound)?; + ensure!( + staking_account.unlocking.len().lt(&(T::MaxUnlockingChunks::get() as usize)), + Error::::MaxUnlockingChunksExceeded + ); + Ok(()) + } + /// Reduce available capacity of target and return the amount of capacity reduction. + /// If withdrawing `amount` would take the StakingTargetDetails below the minimum staking amount, + /// the entire amount is transferred and the record will be deleted. CapacityDetails will not + /// be checked. fn reduce_capacity( unstaker: &T::AccountId, target: MessageSourceId, @@ -601,6 +683,9 @@ impl Pallet { ) -> 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)?; @@ -609,8 +694,11 @@ impl Pallet { capacity_details.total_tokens_staked, capacity_details.total_capacity_issued, ); - staking_target_details.withdraw(amount, capacity_to_withdraw); - capacity_details.withdraw(capacity_to_withdraw, amount); + // 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. + let (actual_amount, actual_capacity) = + staking_target_details.withdraw(amount, capacity_to_withdraw); + capacity_details.withdraw(actual_capacity, actual_amount); Self::set_capacity_for(target, capacity_details); Self::set_target_details_for(unstaker, target, staking_target_details); @@ -672,6 +760,47 @@ impl Pallet { Self::get_staking_account_for(account_id).ok_or(Error::::StakingAccountNotFound); false } + + /// adds a new chunk to StakingAccountDetails.stake_change_unlocking. The + /// call `update_stake_change_unlocking` garbage-collects thawed chunks before adding the new one. + fn add_change_staking_target_unlock_chunk( + staker: &T::AccountId, + amount: &BalanceOf, + ) -> Result<(), DispatchError> { + let mut staking_account_details: StakingAccountDetails = + Self::get_staking_account_for(staker).ok_or(Error::::StakingAccountNotFound)?; + + let current_era: T::RewardEra = Self::get_current_era().era_index; + let thaw_at = current_era.saturating_add(T::ChangeStakingTargetThawEras::get().into()); + staking_account_details.update_stake_change_unlocking(amount, &thaw_at, ¤t_era)?; + Self::set_staking_account(staker, &staking_account_details); + 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 capacity_withdrawn = Self::reduce_capacity(staker, *from_msa, *amount)?; + + 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); + Self::add_change_staking_target_unlock_chunk(staker, amount) + } } impl Nontransferable for Pallet { @@ -692,7 +821,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); diff --git a/pallets/capacity/src/tests/change_staking_target_tests.rs b/pallets/capacity/src/tests/change_staking_target_tests.rs new file mode 100644 index 0000000000..bf1ee4e5b4 --- /dev/null +++ b/pallets/capacity/src/tests/change_staking_target_tests.rs @@ -0,0 +1,317 @@ +use super::{mock::*, testing_utils::*}; +use crate::{ + BalanceOf, CapacityDetails, Config, CurrentEraInfo, Error, Event, RewardEraInfo, + StakingAccountDetails, StakingAccountLedger, StakingTargetDetails, +}; +use common_primitives::{ + capacity::StakingType::{MaximumCapacity, ProviderBoost}, + msa::MessageSourceId, +}; +use frame_support::{assert_noop, assert_ok, traits::Get}; + +// staker is unused unless amount > 0 +fn setup_provider(staker: u64, target: MessageSourceId, amount: u64) { + let provider_name = String::from("Cst-") + target.to_string().as_str(); + register_provider(target, provider_name); + if amount > 0 { + assert_ok!(Capacity::stake(RuntimeOrigin::signed(staker), target, amount, ProviderBoost)); + } +} + +type TestCapacityDetails = CapacityDetails, u32>; +type TestTargetDetails = StakingTargetDetails; + +#[test] +fn do_retarget_happy_path() { + new_test_ext().execute_with(|| { + let staker = 200u64; + let from_msa: MessageSourceId = 1; + let from_amount = 20u64; + let to_amount = from_amount / 2; + let to_msa: MessageSourceId = 2; + setup_provider(staker, from_msa, from_amount); + setup_provider(staker, to_msa, to_amount); + + // retarget half the stake to to_msa + assert_ok!(Capacity::do_retarget(&staker, &from_msa, &to_msa, &to_amount)); + + // expect from stake amounts to be halved + let expected_from_details: TestCapacityDetails = CapacityDetails { + remaining_capacity: 1, + total_tokens_staked: 10, + total_capacity_issued: 1, + last_replenished_epoch: 0, + }; + let from_capacity_details: TestCapacityDetails = + Capacity::get_capacity_for(from_msa).unwrap(); + assert_eq!(from_capacity_details, expected_from_details); + + // expect to stake amounts to be increased by the retarget amount + let expected_to_details: TestCapacityDetails = CapacityDetails { + remaining_capacity: 2, + total_tokens_staked: 20, + total_capacity_issued: 2, + last_replenished_epoch: 0, + }; + let to_capacity_details = Capacity::get_capacity_for(to_msa).unwrap(); + assert_eq!(to_capacity_details, expected_to_details); + + let expected_from_target_details: TestTargetDetails = + StakingTargetDetails { amount: 10, capacity: 1 }; + let from_target_details = Capacity::get_target_for(staker, from_msa).unwrap(); + assert_eq!(from_target_details, expected_from_target_details); + + let expected_to_target_details: TestTargetDetails = + StakingTargetDetails { amount: 20, capacity: 2 }; + let to_target_details = Capacity::get_target_for(staker, to_msa).unwrap(); + assert_eq!(to_target_details, expected_to_target_details); + }) +} + +#[test] +fn do_retarget_deletes_staking_target_details_if_zero_balance() { + new_test_ext().execute_with(|| { + let staker = 200u64; + let from_msa: MessageSourceId = 1; + let to_msa: MessageSourceId = 2; + let amount = 10u64; + setup_provider(staker, from_msa, amount); + setup_provider(staker, to_msa, amount); + + // stake additional to provider from another Msa, doesn't matter which type. + // total staked to from_msa is now 22u64. + assert_ok!(Capacity::stake( + RuntimeOrigin::signed(300u64), + from_msa, + 12u64, + MaximumCapacity + )); + + assert_ok!(Capacity::do_retarget(&staker, &from_msa, &to_msa, &amount)); + + let expected_from_details: TestCapacityDetails = CapacityDetails { + remaining_capacity: 1, + total_tokens_staked: 12, + total_capacity_issued: 1, + last_replenished_epoch: 0, + }; + let from_capacity_details: TestCapacityDetails = + Capacity::get_capacity_for(from_msa).unwrap(); + assert_eq!(from_capacity_details, expected_from_details); + + let expected_to_details: TestCapacityDetails = CapacityDetails { + remaining_capacity: 2, + total_tokens_staked: 2 * amount, + total_capacity_issued: 2, + last_replenished_epoch: 0, + }; + + let to_capacity_details = Capacity::get_capacity_for(to_msa).unwrap(); + assert_eq!(to_capacity_details, expected_to_details); + + assert!(Capacity::get_target_for(staker, from_msa).is_none()); + + let expected_to_target_details: TestTargetDetails = + StakingTargetDetails { amount: 2 * amount, capacity: 2 }; + let to_target_details = Capacity::get_target_for(staker, to_msa).unwrap(); + assert_eq!(to_target_details, expected_to_target_details); + + assert!(Capacity::get_target_for(staker, from_msa).is_none()); + }) +} + +#[test] +fn change_staking_starget_emits_event_on_success() { + new_test_ext().execute_with(|| { + let staker = 200u64; + let from_msa: MessageSourceId = 1; + let from_amount = 20u64; + let to_amount = from_amount / 2; + let to_msa: MessageSourceId = 2; + setup_provider(staker, from_msa, from_amount); + setup_provider(staker, to_msa, to_amount); + + assert_ok!(Capacity::change_staking_target( + RuntimeOrigin::signed(staker), + from_msa, + to_msa, + to_amount + )); + let events = staking_events(); + + assert_eq!( + events.last().unwrap(), + &Event::StakingTargetChanged { account: staker, from_msa, to_msa, amount: to_amount } + ); + }) +} + +#[test] +fn change_staking_target_errors_if_too_many_changes_before_thaw() { + new_test_ext().execute_with(|| { + let staker = 200u64; + let from_msa: MessageSourceId = 1; + let to_msa: MessageSourceId = 2; + + let max_chunks: u32 = ::MaxUnlockingChunks::get(); + let staking_amount = ((max_chunks + 2u32) * 10u32) as u64; + setup_provider(staker, from_msa, staking_amount); + setup_provider(staker, to_msa, 10); + + let retarget_amount = 10u64; + for _i in 0..(max_chunks) { + assert_ok!(Capacity::change_staking_target( + RuntimeOrigin::signed(staker), + from_msa, + to_msa, + retarget_amount + )); + } + + assert_noop!( + Capacity::change_staking_target( + RuntimeOrigin::signed(staker), + from_msa, + to_msa, + retarget_amount + ), + Error::::MaxUnlockingChunksExceeded + ); + }); +} + +#[test] +fn change_staking_target_garbage_collects_thawed_chunks() { + new_test_ext().execute_with(|| { + let staked_amount = 50u64; + let staking_account = 200u64; + let from_target: MessageSourceId = 3; + let to_target: MessageSourceId = 4; + setup_provider(staking_account, from_target, staked_amount); + setup_provider(staking_account, to_target, staked_amount); + + CurrentEraInfo::::set(RewardEraInfo { era_index: 20, started_at: 100 }); + let max_chunks = ::MaxUnlockingChunks::get(); + for i in 0..max_chunks { + println!("{:?}", i); + assert_ok!(Capacity::change_staking_target( + RuntimeOrigin::signed(staking_account), + from_target, + to_target, + 10u64, + )); + } + CurrentEraInfo::::set(RewardEraInfo { era_index: 25, started_at: 100 }); + assert_ok!(Capacity::change_staking_target( + RuntimeOrigin::signed(staking_account), + from_target, + to_target, + 10u64, + )); + }) +} + +#[test] +fn change_staking_target_test_parametric_validity() { + new_test_ext().execute_with(|| { + let staked_amount = 10u64; + let from_account = 200u64; + + StakingAccountLedger::::insert( + from_account, + StakingAccountDetails { + active: 20, + total: 20, + unlocking: Default::default(), + staking_type: ProviderBoost, + last_rewards_claimed_at: None, + stake_change_unlocking: Default::default(), + }, + ); + 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, 0); + setup_provider(from_account, from_target, staked_amount); + setup_provider(from_account, to_target, staked_amount); + + assert_ok!(Capacity::stake( + RuntimeOrigin::signed(from_account), + from_target, + staked_amount, + ProviderBoost + )); + + struct TestCase { + from_account: u64, + from_target: MessageSourceId, + to_target: MessageSourceId, + retarget_amount: u64, + expected_err: Error, + } + let test_cases: Vec = vec![ + // from is a provider but account is not staking to it + TestCase { + from_account, + from_target: from_target_not_staked, + to_target, + retarget_amount: staked_amount, + expected_err: Error::::StakerTargetRelationshipNotFound, + }, + // from_account is not staking at all. + TestCase { + from_account: from_account_not_staking, + from_target, + to_target, + retarget_amount: staked_amount, + expected_err: Error::::StakingAccountNotFound, + }, + // // from and to providers are valid, but zero amount too small + TestCase { + from_account, + from_target, + to_target, + retarget_amount: 0, + expected_err: Error::::StakingAmountBelowMinimum, + }, + // // nonzero amount below minimum is still too small + TestCase { + from_account, + from_target, + to_target, + retarget_amount: 9, + expected_err: Error::::StakingAmountBelowMinimum, + }, + // // account is staked with from-target, but to-target is not a provider + TestCase { + from_account, + from_target, + to_target: to_target_not_provider, + retarget_amount: staked_amount, + expected_err: Error::::InvalidTarget, + }, + // account doesn't have enough staked to make the transfer + TestCase { + from_account, + from_target, + to_target, + retarget_amount: 999, + expected_err: Error::::InsufficientStakingBalance, + }, + ]; + + for tc in test_cases { + assert_noop!( + Capacity::change_staking_target( + RuntimeOrigin::signed(tc.from_account), + tc.from_target, + tc.to_target, + tc.retarget_amount, + ), + tc.expected_err + ); + } + }); +} diff --git a/pallets/capacity/src/tests/mock.rs b/pallets/capacity/src/tests/mock.rs index 3b0bbec8a1..51e52ec282 100644 --- a/pallets/capacity/src/tests/mock.rs +++ b/pallets/capacity/src/tests/mock.rs @@ -1,8 +1,8 @@ use crate as pallet_capacity; -use crate::{BalanceOf, Config, StakingRewardClaim, StakingRewardsProvider}; +use crate::{BalanceOf, StakingRewardClaim, StakingRewardsProvider}; use common_primitives::{ - node::{AccountId, Balance, Hash, Header, ProposalProvider}, + node::{AccountId, Hash, Header, ProposalProvider}, schema::{SchemaId, SchemaValidator}, }; use frame_support::{ @@ -194,6 +194,7 @@ impl pallet_capacity::Config for Test { type EraLength = ConstU32<10>; type StakingRewardsPastErasMax = ConstU32<5>; type RewardsProvider = TestStakingRewardsProvider; + type ChangeStakingTargetThawEras = ConstU32<5>; } pub fn new_test_ext() -> sp_io::TestExternalities { diff --git a/pallets/capacity/src/tests/mod.rs b/pallets/capacity/src/tests/mod.rs index df9704d540..7dd04f1bde 100644 --- a/pallets/capacity/src/tests/mod.rs +++ b/pallets/capacity/src/tests/mod.rs @@ -1,4 +1,5 @@ mod capacity_details_tests; +mod change_staking_target_tests; mod epochs_tests; mod eras_tests; pub mod mock; diff --git a/pallets/capacity/src/tests/other_tests.rs b/pallets/capacity/src/tests/other_tests.rs index 503bdbf87b..f896339d47 100644 --- a/pallets/capacity/src/tests/other_tests.rs +++ b/pallets/capacity/src/tests/other_tests.rs @@ -83,7 +83,7 @@ fn set_target_details_is_successful() { assert_eq!(Capacity::get_target_for(&staker, target), None); - let mut target_details = StakingTargetDetails::>::default(); + let mut target_details = StakingTargetDetails::::default(); target_details.amount = 10; target_details.capacity = 10; diff --git a/pallets/capacity/src/tests/rewards_provider_tests.rs b/pallets/capacity/src/tests/rewards_provider_tests.rs index 17d7fd86db..536b6e4dff 100644 --- a/pallets/capacity/src/tests/rewards_provider_tests.rs +++ b/pallets/capacity/src/tests/rewards_provider_tests.rs @@ -1,7 +1,6 @@ use super::mock::*; use crate::{ - tests::testing_utils::{run_to_block, system_run_to_block}, - Config, CurrentEraInfo, Error, Event, RewardEraInfo, RewardPoolInfo, StakingAccountDetails, + CurrentEraInfo, Error, RewardEraInfo, RewardPoolInfo, StakingAccountDetails, StakingRewardClaim, StakingRewardPool, StakingRewardsProvider, }; use frame_support::assert_err; diff --git a/pallets/capacity/src/tests/stake_and_deposit_tests.rs b/pallets/capacity/src/tests/stake_and_deposit_tests.rs index 39663efb9e..396f896a47 100644 --- a/pallets/capacity/src/tests/stake_and_deposit_tests.rs +++ b/pallets/capacity/src/tests/stake_and_deposit_tests.rs @@ -106,7 +106,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, MaximumCapacity), - Error::::InsufficientStakingAmount + Error::::StakingAmountBelowMinimum ); }); } @@ -119,7 +119,7 @@ fn stake_errors_zero_amount_not_allowed() { let amount = 0; assert_noop!( Capacity::stake(RuntimeOrigin::signed(account), target, amount, MaximumCapacity), - Error::::ZeroAmountNotAllowed + Error::::StakingAmountBelowMinimum ); }); } @@ -363,7 +363,7 @@ fn ensure_can_stake_errors_with_zero_amount_not_allowed() { let amount = 0; assert_noop!( Capacity::ensure_can_stake(&account, target, amount, &MaximumCapacity), - Error::::ZeroAmountNotAllowed + Error::::StakingAmountBelowMinimum ); }); } @@ -416,7 +416,7 @@ fn ensure_can_stake_errors_insufficient_staking_amount() { assert_noop!( Capacity::ensure_can_stake(&account, target, amount, &MaximumCapacity), - Error::::InsufficientStakingAmount + Error::::StakingAmountBelowMinimum ); }); } diff --git a/pallets/capacity/src/tests/staking_account_details_tests.rs b/pallets/capacity/src/tests/staking_account_details_tests.rs index e95addb869..6bd935e8a4 100644 --- a/pallets/capacity/src/tests/staking_account_details_tests.rs +++ b/pallets/capacity/src/tests/staking_account_details_tests.rs @@ -1,6 +1,7 @@ use super::mock::*; use crate::*; -use frame_support::assert_err; +use common_primitives::node::{BlockNumber, RewardEra}; +use frame_support::{assert_err, assert_ok, traits::Get}; use sp_core::bounded::BoundedVec; type UnlockBVec = BoundedVec< @@ -147,3 +148,69 @@ fn impl_staking_account_details_get_stakable_amount_for() { assert_eq!(staking_account.get_stakable_amount_for(&account, 200), 190); }); } + +#[test] +fn impl_update_stake_change_unlocking_bound() { + new_test_ext().execute_with(|| { + let mut staking_account: StakingAccountDetails = StakingAccountDetails { + active: 150, + total: 150, + unlocking: Default::default(), + staking_type: StakingType::MaximumCapacity, + last_rewards_claimed_at: None, + stake_change_unlocking: Default::default(), + }; + let current_era_info: RewardEraInfo = + RewardEraInfo { era_index: 20, started_at: 100 }; + let new_chunk_amount: u64 = 10; + let thaw_at: u32 = 25; + CurrentEraInfo::::set(current_era_info.clone()); + let max_chunks = ::MaxUnlockingChunks::get(); + for i in 0..max_chunks { + assert_ok!(staking_account.update_stake_change_unlocking( + &(new_chunk_amount + (i as u64)), + &thaw_at, + ¤t_era_info.era_index + )); + } + assert_err!( + staking_account.update_stake_change_unlocking( + &new_chunk_amount, + &thaw_at, + ¤t_era_info.era_index + ), + Error::::MaxUnlockingChunksExceeded + ); + }) +} + +#[test] +fn impl_update_stake_change_unlocking_cleanup() { + new_test_ext().execute_with(|| { + let mut staking_account: StakingAccountDetails = StakingAccountDetails { + active: 150, + total: 150, + unlocking: Default::default(), + staking_type: StakingType::MaximumCapacity, + last_rewards_claimed_at: None, + stake_change_unlocking: Default::default(), + }; + let new_chunk_amount = 10u64; + let thaw_at = 25u32; + let era_index = 20u32; + CurrentEraInfo::::set(RewardEraInfo { era_index, started_at: 100 }); + assert_ok!(staking_account.update_stake_change_unlocking( + &new_chunk_amount, + &thaw_at, + &era_index + )); + + CurrentEraInfo::::set(RewardEraInfo { era_index: thaw_at, started_at: 100 }); + assert_ok!(staking_account.update_stake_change_unlocking( + &new_chunk_amount, + &thaw_at, + &thaw_at + )); + assert_eq!(1, staking_account.stake_change_unlocking.len()); + }); +} diff --git a/pallets/capacity/src/tests/staking_target_details_tests.rs b/pallets/capacity/src/tests/staking_target_details_tests.rs index dbe9ea0d4a..da6f3360d1 100644 --- a/pallets/capacity/src/tests/staking_target_details_tests.rs +++ b/pallets/capacity/src/tests/staking_target_details_tests.rs @@ -4,35 +4,42 @@ use frame_support::{assert_err, assert_ok}; #[test] fn impl_staking_target_details_increase_by() { - let mut staking_target = StakingTargetDetails::>::default(); + let mut staking_target = StakingTargetDetails::::default(); assert_eq!(staking_target.deposit(10, 10), Some(())); assert_eq!( staking_target, - StakingTargetDetails::> { - amount: BalanceOf::::from(10u64), - capacity: 10 - } + StakingTargetDetails:: { amount: BalanceOf::::from(10u64), capacity: 10 } ) } #[test] fn staking_target_details_withdraw_reduces_staking_and_capacity_amounts() { - let mut staking_target_details = StakingTargetDetails::> { - amount: BalanceOf::::from(15u64), - capacity: BalanceOf::::from(20u64), + let mut staking_target_details = StakingTargetDetails:: { + amount: BalanceOf::::from(25u64), + capacity: BalanceOf::::from(30u64), }; staking_target_details.withdraw(10, 10); assert_eq!( staking_target_details, - StakingTargetDetails::> { - amount: BalanceOf::::from(5u64), - capacity: BalanceOf::::from(10u64), + StakingTargetDetails:: { + amount: BalanceOf::::from(15u64), + capacity: BalanceOf::::from(20u64), } ) } +#[test] +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_target_details.withdraw(8, 16); + assert_eq!(staking_target_details, StakingTargetDetails::::default()); +} + #[test] fn staking_target_details_withdraw_reduces_total_tokens_staked_and_total_tokens_available() { let mut capacity_details = CapacityDetails::, ::EpochNumber> { diff --git a/pallets/capacity/src/tests/unstaking_tests.rs b/pallets/capacity/src/tests/unstaking_tests.rs index f8c7c8abbb..aa39df0528 100644 --- a/pallets/capacity/src/tests/unstaking_tests.rs +++ b/pallets/capacity/src/tests/unstaking_tests.rs @@ -55,7 +55,7 @@ fn unstake_happy_path() { assert_eq!( staking_target_details, - StakingTargetDetails::> { + StakingTargetDetails:: { amount: BalanceOf::::from(60u64), capacity: BalanceOf::::from(6u64), } @@ -160,7 +160,7 @@ fn unstake_errors_amount_to_unstake_exceeds_amount_staked() { )); assert_noop!( Capacity::unstake(RuntimeOrigin::signed(token_account), target, unstaking_amount), - Error::::AmountToUnstakeExceedsAmountStaked + Error::::InsufficientStakingBalance ); }); } diff --git a/pallets/capacity/src/tests/withdrawal_tests.rs b/pallets/capacity/src/tests/withdrawal_tests.rs index f8b0038b08..46f6fe443a 100644 --- a/pallets/capacity/src/tests/withdrawal_tests.rs +++ b/pallets/capacity/src/tests/withdrawal_tests.rs @@ -63,7 +63,7 @@ fn impl_withdraw_errors_insufficient_balance() { assert_noop!( Capacity::deduct(target_msa_id, 20u32.into()), - Error::::InsufficientBalance + Error::::InsufficientCapacityBalance ); let mut capacity_details = diff --git a/pallets/capacity/src/types.rs b/pallets/capacity/src/types.rs index 042eaf8425..99dde11d63 100644 --- a/pallets/capacity/src/types.rs +++ b/pallets/capacity/src/types.rs @@ -65,7 +65,7 @@ impl StakingAccountDetails { #[cfg(any(feature = "runtime-benchmarks", test))] #[allow(clippy::unwrap_used)] - /// tmp fn for testing only + /// For testing and benchmarks only! /// set unlock chunks with (balance, thaw_at). does not check that the unlock chunks /// don't exceed total. /// returns true on success, false on failure (?) @@ -101,6 +101,37 @@ impl StakingAccountDetails { total_reaped } + #[cfg(any(feature = "runtime-benchmarks", test))] + #[allow(clippy::unwrap_used)] + /// for testing and benchmarks only! + /// set stake_change_unlocking chunks with (balance, thaw_at). does not check that the unlock chunks + /// don't exceed total. + /// returns true on success, false on failure (?) + pub fn set_stake_change_unlock_chunks(&mut self, chunks: &Vec<(u32, u32)>) -> bool { + let result: Vec, ::EpochNumber>> = chunks + .into_iter() + .map(|chunk| UnlockChunk { value: chunk.0.into(), thaw_at: chunk.1.into() }) + .collect(); + self.unlocking = BoundedVec::try_from(result).unwrap(); + self.unlocking.len() == chunks.len() + } + + /// update unlock chunks; remove those that have expired and add the new one + // this doesn't affect staking amount, just controls how often a staker may retarget + pub fn update_stake_change_unlocking( + &mut self, + new_chunk_amount: &BalanceOf, + thaw_at: &T::RewardEra, + current_era: &T::RewardEra, + ) -> Result<(), DispatchError> { + self.stake_change_unlocking.retain(|chunk| current_era.lt(&chunk.thaw_at)); + let unlock_chunk = UnlockChunk { value: *new_chunk_amount, thaw_at: *thaw_at }; + self.stake_change_unlocking + .try_push(unlock_chunk) + .map_err(|_| Error::::MaxUnlockingChunksExceeded)?; + Ok(()) + } + /// Decrease the amount of active stake by an amount and create an UnlockChunk. pub fn withdraw( &mut self, @@ -146,17 +177,24 @@ impl Default for StakingAccountDetails { /// 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(PartialEq, Eq, Clone, Encode, Decode, RuntimeDebug, TypeInfo, MaxEncodedLen)] +#[scale_info(skip_type_params(T))] +pub struct StakingTargetDetails { /// The total amount of tokens that have been targeted to the MSA. - pub amount: Balance, + pub amount: BalanceOf, /// The total Capacity that an MSA received. - pub capacity: Balance, + pub capacity: BalanceOf, +} + +impl Default for StakingTargetDetails { + fn default() -> Self { + Self { amount: Zero::zero(), capacity: Zero::zero() } + } } -impl StakingTargetDetails { +impl StakingTargetDetails { /// Increase an MSA target Staking total and Capacity amount. - pub fn deposit(&mut self, amount: Balance, capacity: Balance) -> Option<()> { + pub fn deposit(&mut self, amount: BalanceOf, capacity: BalanceOf) -> Option<()> { self.amount = amount.checked_add(&self.amount)?; self.capacity = capacity.checked_add(&self.capacity)?; @@ -164,9 +202,23 @@ impl StakingTargetDetails { } /// 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: BalanceOf, + capacity: BalanceOf, + ) -> (BalanceOf, BalanceOf) { + 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(&T::MinimumStakingAmount::get()) { + *self = Self::default(); + return (entire_amount, entire_capacity) + } else { + self.capacity = self.capacity.saturating_sub(capacity); + } + (amount, capacity) } } diff --git a/pallets/capacity/src/weights.rs b/pallets/capacity/src/weights.rs index b22becedf7..a01992b275 100644 --- a/pallets/capacity/src/weights.rs +++ b/pallets/capacity/src/weights.rs @@ -55,6 +55,7 @@ pub trait WeightInfo { fn on_initialize() -> Weight; fn unstake() -> Weight; fn set_epoch_length() -> Weight; + fn change_staking_target() -> Weight; } /// Weights for pallet_capacity using the Substrate node and recommended hardware. @@ -138,6 +139,13 @@ impl WeightInfo for SubstrateWeight { Weight::from_parts(6_891_000, 0) .saturating_add(T::DbWeight::get().writes(1_u64)) } + + /// Storage: + /// Proof: + fn change_staking_target() -> Weight { + Weight::from_parts(1_000_000, 0) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } } // For backwards compatibility and tests @@ -220,4 +228,11 @@ impl WeightInfo for () { Weight::from_parts(6_891_000, 0) .saturating_add(RocksDbWeight::get().writes(1_u64)) } + + /// Storage: + /// Proof: + fn change_staking_target() -> Weight { + Weight::from_parts(1_000_000, 0) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } } diff --git a/pallets/frequency-tx-payment/src/tests/mock.rs b/pallets/frequency-tx-payment/src/tests/mock.rs index 322f38fb84..81823e29e2 100644 --- a/pallets/frequency-tx-payment/src/tests/mock.rs +++ b/pallets/frequency-tx-payment/src/tests/mock.rs @@ -225,6 +225,7 @@ impl pallet_capacity::Config for Test { type EraLength = ConstU32<5>; type StakingRewardsPastErasMax = ConstU32<2>; type RewardsProvider = Capacity; + type ChangeStakingTargetThawEras = ConstU32<1>; } use pallet_balances::Call as BalancesCall; diff --git a/runtime/common/src/constants.rs b/runtime/common/src/constants.rs index fe67e56322..d703fb10db 100644 --- a/runtime/common/src/constants.rs +++ b/runtime/common/src/constants.rs @@ -352,4 +352,6 @@ parameter_types! { pub const CapacityPerToken: Perbill = Perbill::from_percent(2); } +pub type CapacityChangeStakingTargetThawEras = ConstU32<5>; + // -end- Capacity Pallet --- diff --git a/runtime/frequency/src/lib.rs b/runtime/frequency/src/lib.rs index 89f7ab4461..baee758495 100644 --- a/runtime/frequency/src/lib.rs +++ b/runtime/frequency/src/lib.rs @@ -411,6 +411,7 @@ impl pallet_capacity::Config for Runtime { type EraLength = ConstU32<{ 14 * DAYS }>; type StakingRewardsPastErasMax = ConstU32<26u32>; // 1 year type RewardsProvider = Capacity; + type ChangeStakingTargetThawEras = CapacityChangeStakingTargetThawEras; } impl pallet_schemas::Config for Runtime {