From 164a19730ead83a0e8124ba4fc063f45e5785560 Mon Sep 17 00:00:00 2001 From: shannonwells Date: Wed, 11 Oct 2023 15:07:54 -0700 Subject: [PATCH] updates after rebase, get main version of design doc --- ...capacity_staking_rewards_implementation.md | 291 ++++++++++++------ 1 file changed, 193 insertions(+), 98 deletions(-) diff --git a/designdocs/capacity_staking_rewards_implementation.md b/designdocs/capacity_staking_rewards_implementation.md index 2cf1c38e9f..24f8b02af5 100644 --- a/designdocs/capacity_staking_rewards_implementation.md +++ b/designdocs/capacity_staking_rewards_implementation.md @@ -1,4 +1,26 @@ # 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. @@ -6,92 +28,120 @@ 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 uses Capacity to pay for certain transactions on chain. Accounts that wish to pay with Capacity must: +The Frequency Transaction Payment system allows certain transactions on chain to be paid for with Capacity. 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), without, at this time, regard to what the economic model actually is. +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. ## Glossary -1. **FRQCY**: the native token of the Frequency blockchain +1. **FRQCY**: the native token of Frequency, a Substrate parachain in the Polkdaot blockhain ecosystem. 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 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. **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. **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`. 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. +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. ```rust -pub struct StakingAccountDetails { +pub struct StakingAccountDetailsV2 { 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 - /// total staked amounts for each past era, up to StakingRewardsPastErasMax eras. - pub staking_history: BoundedVec, T::RewardEra>, T::StakingRewardsPastErasMax>, // 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. -pub struct StakingHistory { +```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 +} ``` **Unstaking thaw period** Changes the thaw period to begin at the first block of next RewardEra instead of immediately. ### Changes to extrinsics -```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 -} +#### 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 unstake( origin: OriginFor, target: MessageSourceId, requested_amount: BalanceOf, -) -> 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` -} +) -> DispatchResult {} + ``` ### NEW: StakingRewardsProvider - Economic Model 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. +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. ```rust use std::hash::Hash; @@ -115,6 +165,7 @@ 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. @@ -124,17 +175,6 @@ 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 { @@ -156,13 +196,16 @@ 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 -This is the necessary information about the reward pool for a given Reward Era and how it's stored. +### 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 ```rust pub struct RewardPoolInfo { /// the total staked for rewards in the associated RewardEra @@ -176,11 +219,12 @@ 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] @@ -206,60 +250,105 @@ 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 -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. +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 /// 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 one or both of the StakingRewardClaim eras are invalid - /// `proof` - the Merkle proof for the reward claim + /// - 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, + /// `proof` - the Merkle proof for the reward claim proof: Hash, + /// The staking reward claim payload for which the proof was generated payload: StakingRewardClaim ); - ``` - 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)** +``` +#### 3. 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 -/// 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. +/// 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. #[pallet:call_index(n+1)] // n = current call index in the pallet pub fn change_staking_target( origin: OriginFor, @@ -270,17 +359,23 @@ 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 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