From f86282177a93b30b72b8bd48d3c215065cd68b3c Mon Sep 17 00:00:00 2001 From: Shannon Wells Date: Thu, 31 Oct 2024 14:42:37 -0700 Subject: [PATCH] Provider Boosting implementation (#1694) # Goal Implement a Provider Boost feature, whereby token holders may support the network and a specific Provider by means of a custom staking model. Token holders lock up a certain amount of token, and receive a return in Frequency token for this support. The token holder chooses a Provider to receive some Capacity, which the Provider may use to pay for chain transactions. Token holders may still stake for `MaximizedCapacity` and receive no token return. As before, the entire benefit for staking would go to the targeted Provider for this type. #### For more details, please see the [Capacity Staking Rewards Implementation](designdocs/capacity_staking_rewards_implementation.md) design doc, which links to the economic model for this feature. --------- Co-authored-by: Wil Wade Co-authored-by: Puneet Saraswat <61435908+saraswatpuneet@users.noreply.github.com> Co-authored-by: Aramik Co-authored-by: Matthew Orris <1466844+mattheworris@users.noreply.github.com> Co-authored-by: Wil Wade --- Cargo.lock | 13 + Cargo.toml | 1 + Makefile | 3 - common/primitives/src/capacity.rs | 41 +- designdocs/capacity.md | 444 ++++++----- .../provider_boosting_economic_model.md | 8 +- .../provider_boosting_implementation.md | 384 +++++----- e2e/capacity/change_staking_target.test.ts | 44 ++ e2e/capacity/list_unclaimed_rewards.test.ts | 71 ++ e2e/capacity/provider_boost.test.ts | 51 ++ e2e/capacity/replenishment.test.ts | 7 +- e2e/capacity/staking.test.ts | 4 +- e2e/scaffolding/extrinsicHelpers.ts | 16 + e2e/scaffolding/funding.ts | 3 + e2e/scaffolding/helpers.ts | 38 +- js/api-augment/definitions/capacity.ts | 15 + js/api-augment/definitions/index.ts | 1 + pallets/capacity/README.md | 47 +- pallets/capacity/src/benchmarking.rs | 212 +++++- pallets/capacity/src/lib.rs | 718 ++++++++++++++++-- pallets/capacity/src/migration/mod.rs | 2 + .../src/migration/provider_boost_init.rs | 47 ++ pallets/capacity/src/runtime-api/Cargo.toml | 27 + pallets/capacity/src/runtime-api/src/lib.rs | 43 ++ .../src/tests/change_staking_target_tests.rs | 467 ++++++++++++ .../src/tests/claim_staking_rewards_tests.rs | 158 ++++ pallets/capacity/src/tests/eras_tests.rs | 218 ++++++ pallets/capacity/src/tests/mock.rs | 69 +- pallets/capacity/src/tests/mod.rs | 7 + pallets/capacity/src/tests/other_tests.rs | 8 +- .../src/tests/provider_boost_history_tests.rs | 133 ++++ .../src/tests/provider_boost_tests.rs | 108 +++ .../capacity/src/tests/replenishment_tests.rs | 4 +- .../capacity/src/tests/reward_pool_tests.rs | 116 +++ .../src/tests/rewards_provider_tests.rs | 322 ++++++++ .../src/tests/stake_and_deposit_tests.rs | 35 +- .../src/tests/staking_target_details_tests.rs | 22 +- pallets/capacity/src/tests/testing_utils.rs | 52 +- pallets/capacity/src/tests/unstaking_tests.rs | 271 ++++++- .../src/tests/withdraw_unstaked_tests.rs | 12 +- .../capacity/src/tests/withdrawal_tests.rs | 4 +- pallets/capacity/src/types.rs | 339 ++++++++- pallets/capacity/src/weights.rs | 207 +++++ .../frequency-tx-payment/src/tests/mock.rs | 10 +- runtime/common/src/constants.rs | 3 + runtime/frequency/Cargo.toml | 3 + runtime/frequency/src/lib.rs | 49 +- 47 files changed, 4267 insertions(+), 590 deletions(-) create mode 100644 e2e/capacity/change_staking_target.test.ts create mode 100644 e2e/capacity/list_unclaimed_rewards.test.ts create mode 100644 e2e/capacity/provider_boost.test.ts create mode 100644 js/api-augment/definitions/capacity.ts create mode 100644 pallets/capacity/src/migration/provider_boost_init.rs create mode 100644 pallets/capacity/src/runtime-api/Cargo.toml create mode 100644 pallets/capacity/src/runtime-api/src/lib.rs create mode 100644 pallets/capacity/src/tests/change_staking_target_tests.rs create mode 100644 pallets/capacity/src/tests/claim_staking_rewards_tests.rs create mode 100644 pallets/capacity/src/tests/eras_tests.rs create mode 100644 pallets/capacity/src/tests/provider_boost_history_tests.rs create mode 100644 pallets/capacity/src/tests/provider_boost_tests.rs create mode 100644 pallets/capacity/src/tests/reward_pool_tests.rs create mode 100644 pallets/capacity/src/tests/rewards_provider_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 4113e25cda..0833edfc39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3704,6 +3704,7 @@ dependencies = [ "pallet-authorship", "pallet-balances", "pallet-capacity", + "pallet-capacity-runtime-api", "pallet-collator-selection", "pallet-collective", "pallet-democracy", @@ -3753,6 +3754,7 @@ dependencies = [ "sp-transaction-pool", "sp-version", "staging-parachain-info", + "static_assertions", "substrate-wasm-builder", "system-runtime-api", ] @@ -6807,6 +6809,17 @@ dependencies = [ "sp-std 14.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=release-polkadot-v1.13.0)", ] +[[package]] +name = "pallet-capacity-runtime-api" +version = "0.0.0" +dependencies = [ + "common-primitives", + "parity-scale-codec", + "sp-api", + "sp-runtime", + "sp-std 14.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=release-polkadot-v1.13.0)", +] + [[package]] name = "pallet-child-bounties" version = "27.0.0" diff --git a/Cargo.toml b/Cargo.toml index b30bc1cd0d..e9c4ab451a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -118,6 +118,7 @@ serde_json = { version = "1.0.86", default-features = false } tokio = { version = "1.25.0", default-features = false } unicode-normalization = { version = "0.1.22", default-features = false } clap = { version = "4.2.5", features = ["derive"] } +static_assertions = { version = "1.1.0", default-features = false } sp-externalities = { git = "https://github.com/paritytech/polkadot-sdk", branch = "release-polkadot-v1.13.0", default-features = false} sp-runtime-interface = { git = "https://github.com/paritytech/polkadot-sdk", branch = "release-polkadot-v1.13.0", default-features = false} diff --git a/Makefile b/Makefile index 2457d06275..5ef6736a9e 100644 --- a/Makefile +++ b/Makefile @@ -198,9 +198,6 @@ benchmarks-multi: benchmarks-multi-local: ./scripts/run_benchmarks.sh -t bench-dev $(PALLETS) -benchmarks-capacity: - ./scripts/run_benchmark.sh -p capacity - .PHONY: docs docs: RUSTC_BOOTSTRAP=1 RUSTDOCFLAGS="--enable-index-page -Zunstable-options" cargo doc --no-deps --workspace --features frequency diff --git a/common/primitives/src/capacity.rs b/common/primitives/src/capacity.rs index 9b470f64ea..87ecc3aa3d 100644 --- a/common/primitives/src/capacity.rs +++ b/common/primitives/src/capacity.rs @@ -1,7 +1,12 @@ use crate::msa::MessageSourceId; use frame_support::traits::tokens::Balance; +use scale_info::TypeInfo; +use sp_core::{Decode, Encode, MaxEncodedLen, RuntimeDebug}; use sp_runtime::DispatchError; +/// The type of a Reward Era +pub type RewardEra = u32; + /// A trait for checking that a target MSA can be staked to. pub trait TargetValidator { /// Checks if an MSA is a valid target. @@ -15,19 +20,24 @@ impl TargetValidator for () { } } -/// A trait for Non-transferable asset. +/// A trait for Non-transferable asset pub trait Nontransferable { /// Scalar type for representing balance of an account. type Balance: Balance; - /// The balance Capacity for an MSA account. + /// The available Capacity for an MSA. fn balance(msa_id: MessageSourceId) -> Self::Balance; - /// Reduce Capacity of an MSA account by amount. - fn deduct(msa_id: MessageSourceId, amount: Self::Balance) -> Result<(), DispatchError>; + /// Reduce Capacity of an MSA by amount. + fn deduct(msa_id: MessageSourceId, capacity_amount: Self::Balance) + -> Result<(), DispatchError>; - /// Increase Capacity of an MSA account by an amount. - fn deposit(msa_id: MessageSourceId, amount: Self::Balance) -> Result<(), DispatchError>; + /// Increase Staked Token + Capacity amounts of an MSA. (unused) + fn deposit( + msa_id: MessageSourceId, + token_amount: Self::Balance, + capacity_amount: Self::Balance, + ) -> Result<(), DispatchError>; } /// A trait for replenishing Capacity. @@ -47,3 +57,22 @@ pub trait Replenishable { /// Checks if an account can be replenished. fn can_replenish(msa_id: MessageSourceId) -> bool; } + +/// Result of checking a Boost History item to see if it's eligible for a reward. +#[derive( + Copy, Clone, Default, Encode, Eq, Decode, RuntimeDebug, MaxEncodedLen, PartialEq, TypeInfo, +)] + +pub struct UnclaimedRewardInfo { + /// The Reward Era for which this reward was earned + pub reward_era: RewardEra, + /// When this reward expires, i.e. can no longer be claimed + pub expires_at_block: BlockNumber, + /// The total staked in this era as of the current block + pub staked_amount: Balance, + /// The amount staked in this era that is eligible for rewards. Does not count additional amounts + /// staked in this era. + pub eligible_amount: Balance, + /// The amount in token of the reward (only if it can be calculated using only on chain data) + pub earned_amount: Balance, +} diff --git a/designdocs/capacity.md b/designdocs/capacity.md index dfdf639559..cc37d117f5 100644 --- a/designdocs/capacity.md +++ b/designdocs/capacity.md @@ -4,7 +4,7 @@ Feeless transactions are essential in reaching mass adoption as it removes the overhead costs of transactions for app developers to acquire a far-reaching user base. -In this document, I will introduce the concept of [Capacity](https://forums.projectliberty.io/t/05-what-is-capacity-frequency-economics-part-1/248), a non-transferable resource that is associated with an MSA account of a [Registered Provider](https://github.com/frequency-chain/frequency/blob/main/designdocs/provider_registration.md), and how Capacity can be acquired through staking, refills, and used to perform transactions such as: +In this document, I will introduce the concept of [Capacity](https://forums.projectliberty.io/t/05-what-is-capacity-frequency-economics-part-1/248), a non-transferable resource that is associated with a [Message Source Account (MSA)](./README.md#basic-data-model)) of a [Registered Provider](https://github.com/frequency-chain/frequency/blob/main/designdocs/provider_registration.md), and how Capacity can be acquired through staking, refills, and used to perform transactions such as: - Create an MSA. - Add a key to an MSA. @@ -23,89 +23,105 @@ Frequency explains how Capacity can be acquired through staking, refills, and us - [Block space allocation for Capacity transactions](#block-space) - [Implementation of spending Capacity to perform transactions](#capacity-transactions) - **Implementation of how to acquire through staking:** +### **Implementation of how to acquire Capacity through staking:** This section is limited to the interfaces for staking and un-staking tokens. As a Registered Provider, you can receive Capacity by staking your tokens to the network or when others stake their tokens to the network. -When staking tokens to the network, the network generates Capacity based on a Capacity-generating function that considers usage and other criteria. When you stake tokens, you will also provide a target Registered Provider to receive the Capacity generated. In exchange for staking Token to the network, you receive rewards. Rewards are deferred to a supplemental [staking design doc](https://github.com/frequency-chain/frequency/issues/40). You may increase your stake to network many times and target different Service Providers each time you stake. Note every time you stake to network your tokens are locked until you decide to unstake. +When staking tokens to the network, the network generates Capacity based on a Capacity-generating function that considers usage and other criteria. When you stake tokens, you will also provide a target Registered Provider to receive the Capacity generated. In exchange for staking Token to the network, you receive rewards. For more information on rewards, please see the [Tokenomics docs](https://docs.frequency.xyz/Tokenomics/index.html). You may increase your stake to network many times and target different Service Providers each time you stake. Note every time you stake to network your tokens are frozen until you decide to unstake. -Unstaking tokens allow you to schedule a number of tokens to be unlocked from your balance. There is no limit on the amount that you can schedule to be unlocked (up to the amount staked), but there is a limit on how many scheduled requests you can make. After scheduling tokens to be unlocked using **`unstake`**, you can withdraw those tokens after a thaw period has elapsed by using the **`withdraw_unstaked`** extrinsic. If the call is successful, all thawed tokens become unlocked and increase the ability to make more scheduled requests. +Unstaking tokens allow you to schedule a number of tokens to be unfrozen from your balance. There is no limit on the amount that you can schedule to be unfrozen (up to the amount staked), but there is a limit on how many scheduled requests you can make. After scheduling tokens to be unfrozen using **`unstake`**, you can withdraw those tokens after a thaw period has elapsed by using the **`withdraw_unstaked`** extrinsic. If the call is successful, all thawed tokens become unfrozen and increase the ability to make more scheduled requests. Note that the thaw period is measured in Epoch Periods. An Epoch Period is composed of a set number of blocks. The number of blocks for an Epoch will be approximately 100 blocks and can be adjusted through governance. -### **Interfaces for Staking-Pallet** +#### **Interfaces for Staking-Pallet** -### **Config** +#### **Config** ```rust + #[pallet::config] + pub trait Config: frame_system::Config { + /// The overarching event type. + type RuntimeEvent: From> + IsType<::RuntimeEvent>; -#[pallet::config] -pub trait Config: frame_system::Config { - /// The overarching event type. - type RuntimeEvent: From> + IsType<::RuntimeEvent>; + /// The overarching freeze reason. + type RuntimeFreezeReason: From; - /// Weight information for extrinsics in this pallet. - type WeightInfo: WeightInfo; + /// Weight information for extrinsics in this pallet. + type WeightInfo: WeightInfo; - /// Function that allows a balance to be locked. - type Currency: LockableCurrency; + /// Functions that allow a fungible balance to be changed or frozen. + type Currency: MutateFreeze + + Mutate + + InspectFreeze + + InspectFungible; - /// Function that checks if an MSA is a valid target. - type TargetValidator: TargetValidator; + /// Function that checks if an MSA is a valid target. + type TargetValidator: TargetValidator; - /// The minimum required token amount to stake. It facilitates cleaning dust when unstaking. - #[pallet::constant] - type MinimumStakingAmount: Get>; + /// The minimum required token amount to stake. It facilitates cleaning dust when unstaking. + #[pallet::constant] + type MinimumStakingAmount: Get>; - /// The maximum number of unlocking chunks a StakingAccountLedger can have. - /// It determines how many concurrent unstaked chunks may exist. - #[pallet::constant] - type MaxUnlockingChunks: Get; + /// The minimum required token amount to remain in the account after staking. + #[pallet::constant] + type MinimumTokenBalance: Get>; - #[cfg(feature = "runtime-benchmarks")] - /// A set of helper functions for benchmarking. - type BenchmarkHelper: RegisterProviderBenchmarkHelper; + /// The maximum number of unlocking chunks a StakingAccountLedger can have. + /// It determines how many concurrent unstaked chunks may exist. + #[pallet::constant] + type MaxUnlockingChunks: Get; - /// The number of Epochs before you can unlock tokens after unstaking. - #[pallet::constant] - type UnstakingThawPeriod: Get; + #[cfg(feature = "runtime-benchmarks")] + /// A set of helper functions for benchmarking. + type BenchmarkHelper: RegisterProviderBenchmarkHelper; - /// Maximum number of blocks an epoch can be - /// currently used as the actual value of epoch length. - #[pallet::constant] - type MaxEpochLength: Get>; + /// The number of Epochs before you can unlock tokens after unstaking. + #[pallet::constant] + type UnstakingThawPeriod: Get; - /// A type that provides an Epoch number - /// traits pulled from frame_system::Config::BlockNumber - type EpochNumber: Parameter - + Member - + MaybeSerializeDeserialize - + MaybeDisplay - + AtLeast32BitUnsigned - + Default - + Copy - + sp_std::hash::Hash - + MaxEncodedLen - + TypeInfo; -}trait Config + /// Maximum number of blocks an epoch can be + #[pallet::constant] + type MaxEpochLength: Get>; + + /// A type that provides an Epoch number + /// traits pulled from frame_system::Config::BlockNumber + type EpochNumber: Parameter + + Member + + MaybeSerializeDeserialize + + MaybeDisplay + + AtLeast32BitUnsigned + + Default + + Copy + + sp_std::hash::Hash + + MaxEncodedLen + + TypeInfo; + + /// How much FRQCY one unit of Capacity costs + #[pallet::constant] + type CapacityPerToken: Get; + + // ... + } ``` -### **Constants** +#### **Constants** -LockIdentifier is an eight-character long identifier used to distinguish between different locks. +FreezeReason is an enum that defines the reason for freezing funds in an account. ```rust -/// LockIdentifier is an eight-character long identifier used to distinguish between different locks. -const STAKING_ID: LockIdentifier = *b"ntwstkg"; + pub enum FreezeReason { + /// The account has staked tokens to the Frequency network. + CapacityStaking, + } ``` -### **Calls** +#### **Calls** -**Stake** +##### **Stake** Stakes some amount of tokens to the network and generates Capacity. @@ -115,23 +131,21 @@ Stakes some amount of tokens to the network and generates Capacity. /// /// ### Errors /// -/// - Returns Error::InsufficientBalance if the sender does not have free balance amount needed to stake. /// - Returns Error::InvalidTarget if attempting to stake to an invalid target. -/// - Returns Error::InsufficientStakingAmount if attempting to stake an amount below the minimum amount. -/// - Returns Error::BalanceTooLowtoStake if the sender does not have -/// free balance amount > MinimumTokenBalance after staking. -pub fn stake(origin: OriginFor, target: MessageSourceId, amount: BalanceOf) -> DispatchResult {} +/// - Returns Error::StakingAmountBelowMinimum if attempting to stake an amount below the minimum amount. +/// - Returns Error::CannotChangeStakingType if the staking account is a ProviderBoost account +pub fn stake( origin: OriginFor, target: MessageSourceId, amount: BalanceOf,) -> DispatchResult {} ``` Acceptance Criteria are listed below but can evolve: 1. Dispatched origin is Signed by Staker. -2. A Target MSA account must be a Registered Provider. +2. A Target MSA must be a Registered Provider. 3. When stake amount is greater than the available free-balance, it stakes all available free-balance. 4. A Staker can stake multiple times and target different providers. -5. Additional staking increases total locked amount. -6. The token amount staked is to remain [locked](https://paritytech.github.io/substrate/master/frame_support/traits/trait.LockableCurrency.html) with reason [WithdrawReasons::all()](https://paritytech.github.io/substrate/master/frame_support/traits/tokens/struct.WithdrawReasons.html#method.all). +5. Additional staking increases total frozen amount. +6. The token amount staked is to remain [frozen](https://paritytech.github.io/polkadot-sdk/master/frame_support/traits/tokens/fungible/index.html). 7. Capacity generated by staking to a target is calculated by a configurable capacity-generation function. 8. Target Registered Provider is issued generated Capacity. 9. Target Registered Provider issued Capacity becomes available immediately. @@ -140,9 +154,9 @@ Acceptance Criteria are listed below but can evolve: 12. Note: MinimumStakingAmount should be greater or equal to the existential deposit. 13. Note: MinimumTokenBalance should be greater or equal to the existential deposit. -Note that we are considering allowing locked tokens to be used to pay transaction fees. +Note that we are considering allowing frozen tokens to be used to pay transaction fees. -**Unstake** +##### **Unstake** Schedules an amount of the stake to be unlocked. @@ -154,16 +168,17 @@ Schedules an amount of the stake to be unlocked. /// - Returns `Error::UnstakedAmountIsZero` if `amount` is not greater than zero. /// - Returns `Error::MaxUnlockingChunksExceeded` if attempting to unlock more times than config::MaxUnlockingChunks. /// - Returns `Error::AmountToUnstakeExceedsAmountStaked` if `amount` exceeds the amount currently staked. -/// - Returns `Error::InvalidTarget` if `target` is not a valid staking target -/// - Returns `Error:: NotAStakingAccount` if `origin` has nothing staked -pub fn unstake(origin: OriginFor, target: MessageSourceId, amount: BalanceOf) -> DispatchResult {} +/// - Returns `Error::InvalidTarget` if `target` is not a valid staking target (not a Provider) +/// - Returns `Error::NotAStakingAccount` if `origin` has nothing staked at all +/// - Returns `Error::StakerTargetRelationshipNotFound` if `origin` has nothing staked to `target` +pub fn unstake(origin: OriginFor, target: MessageSourceId, requested_amount: BalanceOf) -> DispatchResult {} ``` Acceptance Criteria are listed below but can evolve: 1. Dispatched origin is Signed by Staker. -2. Schedules a portion of the stake to be unlocked and ready for transfer after the `confg::UnstakingThawPeriod` ends. +2. Schedules a portion of the stake to be unfrozen and ready for transfer after the `confg::UnstakingThawPeriod` ends. 3. The amount unstaked must be greater than 0. 4. Issued Capacity to the target is reduced by using a weighted average: @@ -174,21 +189,21 @@ Acceptance Criteria are listed below but can evolve: 6. The amount unstaked cannot exceed the amount staked. 7. If the result of the unstaking would be to leave a balance below `config::MinimumStakingAmount`, the entire amount will be unstaked to avoid leaving dust. 8. when an account has never been a staking account and an attempt to call unstake an error message of NotAStakingAccount should be returned. -9. If you have a staking account and your active balance is zero, then an error message of AmountToUnstakeExceedsAmountStaked should be returned (the test should include unlocking). +9. If you have a staking account and your active balance is zero, then an error message of AmountToUnstakeExceedsAmountStaked should be returned (the test should include unfreezing). 10. Emits Unstake event. -**withdraw_unstaked** +##### **withdraw_unstaked** -Remove locks from unstaked chunks which have completed the UnstakingThawPeriod. +Unfreeze unstaked chunks which have completed the UnstakingThawPeriod. ```rust -/// Remove locks from unstaked chunks which have completed UnstakingThawPeriod. -/// Schedules an amount of the stake to be unlocked. -/// ### Errors +/// Removes all thawed UnlockChunks from caller's UnstakeUnlocks and thaws(unfreezes) the sum of the thawed values +/// in the caller's token account. /// -/// - Returns `Error::NoUnstakedTokensAvailable` if there are no unstaking tokens available to withdraw. -/// - Returns `Error:: NotAStakingAccount` if `origin` has nothing staked +/// ### Errors +/// - Returns `Error::NoUnstakedTokensAvailable` if the account has no unstaking chunks. +/// - Returns `Error::NoThawedTokenAvailable` if there are unstaking chunks, but none are thawed. pub fn withdraw_unstaked(origin: OriginFor) -> DispatchResultWithPostInfo {} ``` @@ -196,104 +211,108 @@ pub fn withdraw_unstaked(origin: OriginFor) -> DispatchResultWithPostInfo {} Acceptance Criteria are listed below but can evolve. 1. Dispatched origin is Signed by Staker. -2. Sums all chunks that are less than or equal to the current Epoch and removes the lock by amount from the account balance. -3. Updates `StakingAccountLedger` total with new locking amount. +2. Sums all chunks that are less than or equal to the current Epoch and unfreezes by amount from the account balance. +3. Updates `StakingAccountLedger` total with new frozen amount. 4. If an account has nothing at stake, clean up storage by removing StakingLedger and TargetLedger entries. 5. Emits event Withdrawn to notify that a withdrawal was made. -### **Errors** +#### **Errors** ```rust -pub enum Error { + pub enum Error { /// 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 + /// #[deprecated(since = "1.13.0", note = "Use StakingAmountBelowMinimum instead")] ZeroAmountNotAllowed, - /// Origin has no Staking Account + /// This AccountId does not have a staking account. NotAStakingAccount, /// No staked value is available for withdrawal; either nothing is being unstaked, - /// or nothing has passed the thaw period. + /// or nothing has passed the thaw period. (5) NoUnstakedTokensAvailable, /// Unstaking amount should be greater than zero. UnstakedAmountIsZero, - /// Amount to unstake is greater than the amount staked. - AmountToUnstakeExceedsAmountStaked, - /// Attempting to unstake from a target that has not been staked to. - StakingAccountNotFound, - /// Attempting to get a staker / target relationship that does not exist. + /// Amount to unstake or change targets is greater than the amount staked. + InsufficientStakingBalance, + /// Attempted to get a staker / target relationship that does not exist. StakerTargetRelationshipNotFound, - /// Attempting to get the target's capacity that does not exist. + /// Attempted to get the target's capacity that does not exist. TargetCapacityNotFound, - /// Staker reached the limit number for the allowed amount of unlocking chunks. + /// Staker has reached the limit of unlocking chunks and must wait for at least one thaw period + /// to complete. (10) MaxUnlockingChunksExceeded, - /// Increase Capacity increase exceeds the total available Capacity for target. + /// Capacity increase exceeds the total available Capacity for target. IncreaseExceedsAvailable, - /// Attempting to set the epoch length to a value greater than the max epoch length. + /// Attempted to set the Epoch length to a value greater than the max Epoch length. MaxEpochLengthExceeded, /// Staker is attempting to stake an amount that leaves a token balance below the minimum amount. BalanceTooLowtoStake, -} + /// There are no unstaked token amounts that have passed their thaw period. + NoThawedTokenAvailable, + /// ... + } ``` -### **Events** +#### **Events** ```rust -pub enum Event { + pub enum Event { /// Tokens have been staked to the Frequency network. Staked { - /// The token account that staked tokens to the network. - account: T::AccountId, - /// The MSA that a token account targeted to receive Capacity based on this staking amount. - target: MessageSourceId, - /// An amount that was staked. - amount: BalanceOf, - /// The Capacity amount issued to the target as a result of the stake. - capacity: BalanceOf, + /// The token account that staked tokens to the network. + account: T::AccountId, + /// The MSA that a token account targeted to receive Capacity based on this staking amount. + target: MessageSourceId, + /// An amount that was staked. + amount: BalanceOf, + /// The Capacity amount issued to the target as a result of the stake. + capacity: BalanceOf, }, - /// Unsstaked token that has thawed was unlocked for the given account + /// Unstaked token that has thawed was unlocked for the given account StakeWithdrawn { - /// the account that withdrew its stake - account: T::AccountId, - /// the total amount withdrawn, i.e. put back into free balance. - amount: BalanceOf, + /// the account that withdrew its stake + account: T::AccountId, + /// the total amount withdrawn, i.e. put back into free balance. + amount: BalanceOf, }, /// A token account has unstaked the Frequency network. UnStaked { - /// The token account that staked tokens to the network. - account: T::AccountId, - /// The MSA that a token account targeted to receive Capacity to unstake from. - target: MessageSourceId, - /// An amount that was unstaked. - amount: BalanceOf, - /// The Capacity amount that was reduced from a target. - capacity: BalanceOf, + /// The token account that unstaked tokens from the network. + account: T::AccountId, + /// The MSA target that will have reduced Capacity as a result of unstaking. + target: MessageSourceId, + /// The amount that was unstaked. + amount: BalanceOf, + /// The Capacity amount that was reduced from a target. + capacity: BalanceOf, }, /// The Capacity epoch length was changed. EpochLengthUpdated { - /// The new length of an epoch in blocks. - blocks: BlockNumberFor, + /// The new length of an epoch in blocks. + blocks: BlockNumberFor, }, /// Capacity has been withdrawn from a MessageSourceId. CapacityWithdrawn { - /// The MSA from which Capacity has been withdrawn. - msa_id: MessageSourceId, - /// The amount of Capacity withdrawn from MSA. - amount: BalanceOf, + /// The MSA from which Capacity has been withdrawn. + msa_id: MessageSourceId, + /// The amount of Capacity withdrawn from MSA. + amount: BalanceOf, }, -} + /// ... + } ``` -### **Storage** +#### **Storage** -**Staking Storage** +##### **Staking Storage** Storage for keeping records of staking accounting. @@ -312,8 +331,14 @@ Storage to record how many tokens were targeted to an MSA. /// Storage to record how many tokens were targeted to an MSA. #[pallet::storage] -pub type StakingTargetLedger = - StorageDoubleMap<_, Twox64Concat, T::AccountId, Twox64Concat, MessageSourceId, StakingTargetDetails; + pub type StakingTargetLedger = StorageDoubleMap< + _, + Twox64Concat, + T::AccountId, + Twox64Concat, + MessageSourceId, + StakingTargetDetails>, + >; ``` Storage for target Capacity usage. @@ -340,11 +365,15 @@ The type used for storing information about the targeted MSA that received Capac ```rust /// Details about the total token amount targeted to an MSA. /// The Capacity that the target will receive. -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, +#[derive(Default, PartialEq, Eq, Clone, Encode, Decode, RuntimeDebug, TypeInfo, MaxEncodedLen)] +pub struct StakingTargetDetails +where + Balance: Default + Saturating + Copy + CheckedAdd + CheckedSub, +{ + /// The total amount of tokens that have been targeted to the MSA. + pub amount: Balance, + /// The total Capacity that an MSA received. + pub capacity: Balance, } ``` @@ -353,48 +382,51 @@ The type used for storing information about staking details. ```rust -pub struct StakingDetails { - /// The amount a Staker has staked, minus the sum of all tokens in `unlocking`. - pub active: Balance, - /// The total amount of tokens in `active` and `unlocking` - pub total: Balance, - /// Unstaked balances that are thawing or awaiting withdrawal. - pub unlocking: BoundedVec>, T::MaxUnlockingChunks>, +#[derive( + TypeInfo, RuntimeDebugNoBound, PartialEqNoBound, EqNoBound, Clone, Decode, Encode, MaxEncodedLen, +)] +#[scale_info(skip_type_params(T))] +pub struct StakingDetails { + /// The amount a Staker has staked, minus the sum of all tokens in `unlocking`. + pub active: BalanceOf, + /// The type of staking for this staking account + pub staking_type: StakingType, } ``` -The type that is used to record a single request for a number of tokens to be unlocked. +The type that is used to record a single request for a number of tokens to be unfrozen. ```rust +#[derive(PartialEq, Eq, Clone, Encode, Decode, RuntimeDebug, TypeInfo, MaxEncodedLen)] pub struct UnlockChunk { - /// Amount to be unlocked. - #[codec(compact)] - value: Balance, - /// Epoch at which point funds are unlocked. - #[codec(compact)] - thaw_at: EpochNumber, + /// Amount to be unfrozen. + pub value: Balance, + /// Block number at which point funds are unfrozen. + pub thaw_at: EpochNumber, } ``` -### **Interfaces for Capacity-Pallet** +#### **Interfaces for Capacity-Pallet** -### **Calls** +#### **Calls** -**Set_epoch_length** +##### **Set_epoch_length** The extrinsic that sets the length of Epoch in number of blocks through governance. ```rust -/// Sets the Epoch Period through governance. +/// Sets the epoch period length (in blocks). /// -/// ### Errors +/// # Requires +/// * Root Origin /// -/// - Returns Error::BadOrigin if sender is not root. -pub fn set_epoch_period(origin: OriginFor, blocks: MessageSourceId) {} +/// ### Errors +/// - Returns `Error::MaxEpochLengthExceeded` if `length` is greater than T::MaxEpochLength. +pub fn set_epoch_length(origin: OriginFor, length: BlockNumberFor) -> DispatchResult {} ``` @@ -404,16 +436,18 @@ Acceptance Criteria are listed below but can evolve: 2. Sets the new Epoch-Period. 3. New Epoch-Period begins at the Next Epoch's start. -**Capacity Storage** +##### **Capacity Storage** Storage for the issuance of Capacity to Registered Providers: ```rust -/// Storage for an MSA's Capacity balance details. +/// Storage for target Capacity usage. +/// - Keys: MSA Id +/// - Value: [`CapacityDetails`](types::CapacityDetails) #[pallet::storage] -pub type CapacityOf = - StorageMap<_, Blake2_128Concat, MessageSourceId, CapacityDetails>; +pub type CapacityLedger = + StorageMap<_, Twox64Concat, MessageSourceId, CapacityDetails, T::EpochNumber>>; ``` @@ -434,31 +468,38 @@ pub struct CapacityDetails { ``` -### **Traits** +#### **Traits** As mentioned above, Capacity is non-transferable and implements the following interface to reduce and increase capacity on an MSA. ```rust -traits Nontransferable { - type Balance; +pub trait Nontransferable { + /// Scalar type for representing balance of an account. + type Balance: Balance; - /// The available Capacity for an MSA account. - fn balance(msa_id: MessageSourceId) -> Result; + /// The available Capacity for an MSA. + fn balance(msa_id: MessageSourceId) -> Self::Balance; - /// Reduce the available Capacity of an MSA account. - fn deduct(msa_id: MessageSourceId, amount: Balance) -> Result; + /// Reduce Capacity of an MSA by amount. + fn deduct(msa_id: MessageSourceId, capacity_amount: Self::Balance) + -> Result<(), DispatchError>; - /// Increase the available Capacity for an MSA account. - fn deposit(msa_id: MessageSourceId, amount: Balance) -> Result; + /// Increase Staked Token + Capacity amounts of an MSA. (unused) + fn deposit( + msa_id: MessageSourceId, + token_amount: Self::Balance, + capacity_amount: Self::Balance, + ) -> Result<(), DispatchError>; } ``` -**Implementation of how to Replenish** +### **Implementation of how to Replenish** + Replenishable means that all Capacity is replenished after a fixed period called an Epoch Period. An Epoch Period is composed of a set number of blocks. In the example below, the Epoch Period is three blocks. The initial Epoch Period will be around 100 blocks. This Epoch Period may be modified through governance. -To support scaling, Capacity is replenished lazily for each Capacity Target. When the Target attempts to post a message, their remaining capacity and last*replenished_epoch is checked. If they are out of capacity \_and* their last_replenished_epoch is less than the current epoch, then the Target's capacity is automatically replenished to their total allowed, minus the amount needed for the current transaction. The last_replenished_epoch is then set to the current epoch. +To support scaling, Capacity is replenished lazily for each Capacity Target. When the Target attempts to post a message, their remaining capacity and last `replenished_epoch` is checked. If they are out of capacity **and** their `last_replenished_epoch` is less than the current epoch, then the Target's capacity is automatically replenished to their total allowed, minus the amount needed for the current transaction. The `last_replenished_epoch` is then set to the current epoch. Consumers of Capacity may choose a strategy for posting transactions: @@ -473,7 +514,7 @@ Capacity can be replenished by making your first Capacity transaction during a n The following interface is implemented on Capacity-Pallet to facilitate replenishment: -### **Hooks** +#### **Hooks** ```rust @@ -490,7 +531,7 @@ Acceptance Criteria are listed below but can evolve: 2. At the start of a new Epoch Period, `EpochNumber` storage is increased by 1. 3. At the start of a new block, `CurrentBlockUsedCapacity` storage is reset. -### Traits +#### Traits Replenishable trait implemented on Capacity-Pallet. This trait is used to replenish the Capacity of a Registered Provider. @@ -510,7 +551,7 @@ trait Replenishable { } ``` -**Storage** +#### **Storage** `CurrentEpoch` help keep count of the number of Epoch-Periods: @@ -522,7 +563,7 @@ pub type CurrentEpoch = StorageValue<_, T::EpochNumber, ValueQuery>; ``` To facilitate keeping track of the Capacity consumed in a block. -_(Not yet implemented)_ +*(Not yet implemented)* ```rust @@ -532,7 +573,7 @@ pub type CurrentBlockUsedCapacity = StorageValue<_, BalanceOf, Val ``` -**Prioritization of Capacity transactions** +### **Prioritization of Capacity transactions** Substrate default prioritization is composed of the transaction length, weight, and tip. Adding a tip allows you to increase your priority and thus increases the chance that your transaction is added to the next block. @@ -540,7 +581,7 @@ Capacity transactions do not have the ability to tip, unlike token transactions. To prevent token transactions from dominating block space, we prioritize Capacity transactions over token transactions. Additionally, we put a limit on the amount of block space Capacity transactions can consume. This new priority allows Capacity transactions to fill up their allocated space first and once the limit has been reached allow for token transactions to fill up the remaining block. We flip the prioritization in this manner because we expect more Capacity transactions than non-capacity transactions. The following section will describe how the block space is filled. -**Block space allocation for Capacity transactions** +### **Block space allocation for Capacity transactions** (*not implemented yet*) We expect more Capacity transactions versus non-capacity transactions. To prevent Capacity transactions from dominating block space, we extend what Substrate does to distribute block space among Mandatory, Operational, and Normal transactions. @@ -635,7 +676,7 @@ Acceptance Criteria are listed below but can evolve: 1. Only run validation, pre-dispatch, and post-dispatch on calls that match Capacity Transactions. 2. Adding the Capacity transaction weight to the block-weight total should not cause an overflow. 3. Given Call is a Capacity transaction, it checks that the extrinsic does not exceed the size of the `max_total` allocated weight. -4. Given Call is a Capacity Transaction, it checks that adding the transaction _length_ will not exceed the [max length](https://paritytech.github.io/substrate/master/frame_system/limits/struct.BlockLength.html) for the Normal dispatch class. +4. Given Call is a Capacity Transaction, it checks that adding the transaction *length* will not exceed the [max length](https://paritytech.github.io/substrate/master/frame_system/limits/struct.BlockLength.html) for the Normal dispatch class. 5. Given the call is a Capacity transaction, checks that adding the weight of the transaction will not exceed the `max_total` weight of Normal transactions 1. base_weight + transaction weight + total weight < current total weight of normal transactions. 6. Given Call is a Capacity transaction, check that adding the transaction weight will not exceed the `max_total` weight of Capacity Transactions. @@ -662,7 +703,7 @@ Acceptance Criteria are listed below but can evolve: **Implementation of using Capacity** -**Transaction payment** +### **Transaction payment** When submitting a transaction, it is validated at the transaction pool before including it in a block. The validation is implemented with a [SignedExtension](https://docs.rs/sp-runtime/latest/sp_runtime/traits/trait.SignedExtension.html) that validates that the signer has enough token or Capacity to submit the transaction. @@ -670,9 +711,9 @@ When submitting a transaction, it is validated at the transaction pool before in Capacity introduces an additional form of payment for transacting. As a result, FRAME's Transaction-Payment-Pallet can be modified or wrapped to toggle between token and Capacity transactions. The following implementation introduces the Dual-Payment-Pallet, a wrapper for the Transaction-Payment-Pallet, and augments it with additional functionality. In addition, it implements the `pay_with_capacity` extrinsic used to distinguish between Capacity transactions and Token transactions. -**Calls** +### **Calls** -`ChargeTransactionPayment` struct type is used to implement a SignedExtension which validates that the signer has enough Capacity or Token to transact. The struct is a named tuple that holds a tip amount. Note that tipping is only limited to Token transactions. Capacity transactions cannot tip. Any tip that is added to Capacity transactions is ignored. +`ChargeTransactionPayment` struct type is used to implement a SignedExtension which validates that the signer has enough Capacity or Token to transact. The struct is a named tuple that holds a tip amount. Note that tipping is limited to only Token transactions. Capacity transactions cannot tip. Any tip that is added to Capacity transactions is ignored. ```rust @@ -688,10 +729,11 @@ pub struct ChargeTransactionPayment(#[codec(compact)] BalanceOf); ```rust -impl ChargeTransactionPayment +impl ChargeFrqTransactionPayment where - CallOf: Dispatchable + IsSubType> + From>, - BalanceOf: Send + Sync + FixedPointOperand + IsType>, + BalanceOf: Send + Sync + FixedPointOperand + IsType>, + ::RuntimeCall: + Dispatchable + IsSubType>, { /// Withdraws fees from either Token or Capacity transactions. /// @@ -726,11 +768,11 @@ An enum is used for describing whether the payment was made with Capacity, Token #[derive(Encode, Decode, DefaultNoBound, TypeInfo)] pub enum InitialPayment { /// Pay no fee. - Nothing, + Free, /// Pay fee with Token. Token(LiquidityInfoOf), /// Pay fee with Capacity. - Capacity(ChargeCapacityBalanceOf), + Capacity, } ``` @@ -740,19 +782,31 @@ Below are the interfaces of the SignedExtension that ChargeTransactionPayment im ```rust /// Implement signed extension SignedExtension to validate that a transaction payment can be withdrawn for a Capacity or Token account. This allows transactions to be dropped from the transaction pool if the signer does not have enough to pay the fee. Pre-dispatch withdraws the actual payment from the account, and Post-dispatch refunds over charges made at pre-dispatch. -impl SignedExtension for ChargeTransactionPayment +impl SignedExtension for ChargeFrqTransactionPayment where - BalanceOf: Send + Sync + FixedPointOperand + From + IsType>, - CallOf: Dispatchable + IsSubType>, + ::RuntimeCall: + IsSubType> + Dispatchable, + + BalanceOf: Send + + Sync + + FixedPointOperand + + From + + IsType> + + IsType>, { - const IDENTIFIER: &'static str = "ChargePayment"; - type AccountId = T::AccountId; - type Call = CallOf; - type AdditionalSigned = (); + const IDENTIFIER: &'static str = "ChargeTransactionPayment"; + type AccountId = T::AccountId; + type Call = ::RuntimeCall; + type AdditionalSigned = (); /// The type that gets past to post-dispatch. /// The InitialPayment allows post-dispatch to know to what account /// a refund should be applied. - type Pre = (BalanceOf, Self::AccountId, InitialPayment); + type Pre = ( + // tip + BalanceOf, + Self::AccountId, + InitialPayment, + ); /// Below, you can find validate, pre-dispatch, and post-dispatch interfaces. ... @@ -825,7 +879,7 @@ Note that Capacity transactions do not get refunded for overcharges. ## Non-goals -Staking rewards and re-staking are left for another design document. +Rewards and re-staking are left for another design document. ## Benefits and Risk @@ -838,7 +892,7 @@ Here I will discuss two alternative options for managing congestion with differe 1. Create a new Epoch Period based on total Capacity usage. 2. Create a new Epoch Period based on the moving average of used Capacity. -**Create a new Epoch Period based on total Capacity usage** +### **Create a new Epoch Period based on total Capacity usage** Epochs Periods are used to manage congestion on the network. Instead of having a contiguous fixed Epoch Period at the end of the current Epoch Period, we can change the length of the next Epoch based on network demand. We can calculate demand for Capacity based on the current Epoch “fullness.” The Epoch “fullness” is a target used to increase or decrease the next Epoch Period to keep the total Capacity used in an Epoch as close as possible to the target. @@ -881,9 +935,9 @@ Acceptance Criteria are listed below but can evolve: 2. At the start of an Epoch Period, `CurrentEpoch` storage is increased by 1. 3. At the start of an Epoch Period, calculate the next epoch length. 4. At the start of a new block, `CurrentBlockUsedCapacity` storage is reset. -5. At the start of a new block, `CurrentEpochUsedCapacity` storage is incremented with the total Capacity used in the previous block. _(Not yet implemented)_ +5. At the start of a new block, `CurrentEpochUsedCapacity` storage is incremented with the total Capacity used in the previous block. *(Not yet implemented)* -**Create a new Epoch based on the moving average of used Capacity** +### **Create a new Epoch based on the moving average of used Capacity** To manage congestion, the following solution uses the moving average of Capacity used after each block to calculate the next Epoch Period. Unlike the previous implementation, a new Epoch is created after the moving average of used Capacity goes below a configurable threshold called `config::MovingAverageBound`. An essential difference from the other solutions is that it becomes less predictable to know when a new Epoch Period starts. diff --git a/designdocs/provider_boosting_economic_model.md b/designdocs/provider_boosting_economic_model.md index 85c4b626dc..ac81cb3d1c 100644 --- a/designdocs/provider_boosting_economic_model.md +++ b/designdocs/provider_boosting_economic_model.md @@ -66,11 +66,13 @@ This document does not: ### Formula -The Provider Boost reward in FRQCY tokens for a given Era e is +The Provider Boost reward in FRQCY tokens for a given Era e is a simple interest model, with the following formula: R = min(Rera*Lu/LT, Lu*Pmax) -Put into words, if the pool of Rewards per Era is Rera FRQCY, then the Reward amount in FRQCY earned by a given Provider Booster will be proportional to how much they've locked for Provider Boosting out of the total, OR Pmax times the amount locked, whichever is less. +Put into words, if the pool of Rewards per Era is + +Rera FRQCY, then the Reward amount in FRQCY earned by a given Provider Booster will be proportional to how much they've locked for Provider Boosting out of the total OR Pmax times the amount locked, whichever is less. Put another way, there is a fixed number of tokens to be rewarded each Era (Rera), split up according to each Provider Boost account holder's percentage of the locked total. However, the reward return each Era for every individual account (Pmax) is capped at some rate, for example, 10%. @@ -93,4 +95,4 @@ Rewards are not prorated; they are calculated only for balances held for an enti - Provider Boost Rewards are not minted until they are explicitly claimed by the Provider Boost account holder, by calling a non-free extrinsic. - Rewards must be claimed within a certain number of Provider Boost Eras. - When claimed, all available, unexpired Rewards for each previous Era are minted and transferred to the same account that locked them. -- **Is there a cap on how much can be claimed at once?** +- Currently there is no cap on how much can be claimed at once. diff --git a/designdocs/provider_boosting_implementation.md b/designdocs/provider_boosting_implementation.md index c078c59f83..4f86ab9d36 100644 --- a/designdocs/provider_boosting_implementation.md +++ b/designdocs/provider_boosting_implementation.md @@ -1,8 +1,8 @@ -# Capacity Staking Rewards Implementation +# Capacity Provider Boost Implementation ## Overview -This document describes a new type of staking which allows token holders to stake FRQCY and split staking rewards with a Provider the staker chooses. +This document describes a new type of staking which allows token holders to stake FRQCY and split rewards with a Provider the staker chooses. Currently, when staking token for Capacity, the only choice is to assign all the generated Capacity to the designated target. The target, who must be a Provider, may then spend this Capacity to pay for specific transactions. This is called **Maximized Capacity** staking. @@ -23,7 +23,7 @@ Provider Boost token rewards are earned only for token staked for a complete Rew This process will be described in more detail in the Economic Model Design Document. -### NOTE: Actual reward amounts are TBD; amounts are for illustration purposes only +### NOTE: Actual reward amounts may differ; amounts are for illustration purposes only ![Provider boosted staking](https://github.com/frequency-chain/frequency/assets/502640/ffb632f2-79c2-4a09-a906-e4de02e4f348) @@ -31,7 +31,7 @@ The proposed feature is a design for staking FRQCY token in exchange for Capacit 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. -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. +This does _not_ outline the economic model for Provider Boosting; it describes the economic model as a black box, i.e. an interface. ## Context and Scope: @@ -43,8 +43,7 @@ The Frequency Transaction Payment system allows certain transactions on chain to # 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](https://github.com/frequency-chain/frequency/blob/main/designdocs/provider_boosting_economic_model.md). ## Glossary @@ -52,51 +51,36 @@ It does not give regard to what the economic model actually is, since that is ye 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) that Provider Boost reweards 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. **StakingRewardsProvider**: a trait that encapsulates the economic model for staking rewards, providing functionality for calculating the reward pool and staking rewards. +1. **ProviderBoostRewardsProvider**: a trait that encapsulates the economic model for Provider Boosting, providing functionality for calculating the reward pool and Provider Boosting. ## Staking Token Rewards ### StakingAccountDetails --> StakingDetails -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`. +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. This is a second version of this storage, to replace StakingAccountDetails, and StakingAccountDetails data will need to be migrated. ```rust pub struct StakingDetails { - pub active: BalanceOf, - pub last_rewards_claimed_at: Option, // NEW None means never rewarded, Some(RewardEra) means last rewarded RewardEra. + /// The amount a Staker has staked, minus the sum of all tokens in `unlocking`. + pub active: BalanceOf, + /// The type of staking for this staking account + pub staking_type: StakingType, } ``` -### 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. +### ProviderBoostHistories ```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 -} + /// Individual history for each account that has Provider-Boosted. + #[pallet::storage] + pub type ProviderBoostHistories = + StorageMap<_, Twox64Concat, T::AccountId, ProviderBoostHistory>; ``` **Unstaking thaw period** @@ -111,25 +95,14 @@ 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`. +This means that a single Account Id must choose between staking for Maximum Capacity or staking for Provider Boost + Rewards. +Those who wish to do both types of staking must use two different accounts, one for each purpose. + #### 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` +for a `ProviderBoost` staker-target relationship, the behavior must be different. The Provider-Boost account must +first claim all unpaid rewards before an unstake can succeed. ```rust pub fn unstake( @@ -140,101 +113,103 @@ pub fn unstake( ``` -### NEW: StakingRewardsProvider - Economic Model trait +### NEW: ProviderBoostRewardsProvider - Economic Model trait +The ProviderBoostRewardsProvider trait implementation depends on the chosen economic model and must +implement the following: -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`. +```rust +/// A trait that provides the Economic Model for Provider Boosting. +pub trait ProviderBoostRewardsProvider { + /// the AccountId this provider is using + type AccountId; -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. + /// the range of blocks over which a Reward Pool is determined and rewards are paid out + type RewardEra; -```rust -use std::hash::Hash; - -pub struct StakingRewardClaim { - /// How much is claimed, in token - pub claimed_reward: Balance, - /// The end state of the staking account if the operations are valid - pub staking_account_end_state: StakingDetails, - /// The starting era for the claimed reward period, inclusive - pub from_era: T::RewardEra, - /// The ending era for the claimed reward period, inclusive - pub to_era: T::RewardEra, -} + /// The hasher to use for proofs + type Hash; -pub trait StakingRewardsProvider { + /// The type for currency + type Balance; - /// Calculate the size of the reward pool for the given era, in token - fn reward_pool_size() -> BalanceOf; + /// Return the size of the reward pool using the current economic model + fn reward_pool_size(total_staked: BalanceOf) -> BalanceOf; - /// Return the total unclaimed reward in token for `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); + /// Return the list of unclaimed rewards for `accountId`, using the current economic model + fn staking_reward_totals( + account_id: Self::AccountId, + ) -> Result, DispatchError>; - /// Validate a payout claim for `account_id`, using `proof` and the provided `payload` StakingRewardClaim. - /// Returns whether the claim passes validation. Accounts must first pass `payoutEligible` test. - /// Errors: None - fn validate_staking_reward_claim(account_id: T::AccountID, proof: Hash, payload: StakingRewardClaim) -> bool; + /// Calculate the reward for a single era. We don't care about the era number, + /// just the values. + fn era_staking_reward( + era_amount_staked: BalanceOf, // how much individual staked for a specific era + era_total_staked: BalanceOf, // how much everyone staked for the era + era_reward_pool_size: BalanceOf, // how much token in the reward pool that era + ) -> BalanceOf; + + + /// Return the effective amount when staked for a Provider Boost + /// The amount is multiplied by a factor > 0 and < 1. + fn capacity_boost(amount: BalanceOf) -> BalanceOf; } ``` ### NEW: Config items +The list below is not inclusive. Other structures and storage may be needed to support required +functionality. ```rust +use common_primitives::capacity::RewardEra // a u32 + pub trait Config: frame_system::Config { // ... - /// A period of `EraLength` blocks in which a Staking Pool applies and - /// when Staking Rewards may be earned. - type RewardEra: Parameter - + Member - + MaybeSerializeDeserialize - + MaybeDisplay - + AtLeast32BitUnsigned - + Default - + Copy - + sp_std::hash::Hash - + MaxEncodedLen - + TypeInfo; /// The number of blocks in a Staking RewardEra type EraLength: Get; + /// The maximum number of eras over which one can claim rewards - type StakingRewardsPastErasMax: Get; + type ProviderBoostHistoryLimit: Get; + /// The trait providing the ProviderBoost economic model calculations and values - type RewardsProvider: StakingRewardsProvider; -}; -``` + type RewardsProvider: ProviderBoostRewardsProvider; -### NEW: RewardPoolInfo, RewardPoolHistory + /// A staker may not retarget more than MaxRetargetsPerRewardEra + type MaxRetargetsPerRewardEra: Get; -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: + /// The fixed size of the reward pool in each Reward Era. + type RewardPoolPerEra: Get>; -- 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 + /// the percentage cap per era of an individual Provider Boost reward + type RewardPercentCap: Get; -```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, -} + /// The number of chunks of Reward Pool history we expect to store + /// MUST be a divisor of [`Self::ProviderBoostHistoryLimit`] + type RewardPoolChunkLength: Get; +}; +``` +### NEW: Retargets -/// Reward Pool history -#[pallet::storage] -pub type StakingRewardPool = ; +### NEW: ProviderBoostRewardPools, CurrentEraProviderBoostTotal +The storage of the total amount staked for the ProviderBoostHistoryLimit number of eras is divided into chunks of +BoundedBTreeMaps, which store Key = RewardEra, Value = Total stake for that era. The chunks are updated in rotating fashion +in order to minimize reads and writes for listing individual rewards, claiming individual rewards, and changing to a +new Reward Era, when necessary, during `on_initialize`. + +```rust + /// Reward Pool history is divided into chunks of size RewardPoolChunkLength. + /// ProviderBoostHistoryLimit is the total number of items, the key is the + /// chunk number. + #[pallet::storage] + pub type ProviderBoostRewardPools = + StorageMap<_, Twox64Concat, u32, RewardPoolHistoryChunk>; + + /// How much is staked this era + #[pallet::storage] + pub type CurrentEraProviderBoostTotal = StorageValue<_, BalanceOf, ValueQuery>; ``` -### NEW: CurrentEra, RewardEraInfo +### NEW: CurrentEraInfo, 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. @@ -262,37 +237,68 @@ pub enum Error { CannotChangeStakingType, /// The Era specified is too far in the past or is in the future 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, + /// There are no rewards eligible to claim. Rewards have expired, have already been + /// claimed, or the first ProviderBoost occurred in the current era. + NoRewardsEligibleToClaim, + /// Caller must claim rewards before unstaking. + MustFirstClaimRewards, + /// Too many change_staking_target calls made in this RewardEra. + MaxRetargetsExceeded, + /// Account either has no staking account at all or it is not a ProviderBoost type + NotAProviderBoostAccount, } ``` -### 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 StakingDetails. +### NEW: Events -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. +```rust + /// ... + /// The target of a staked amount was changed to a new MessageSourceId + StakingTargetChanged { + /// The account that retargeted the staking amount + account: T::AccountId, + /// The Provider MSA that the staking amount is taken from + from_msa: MessageSourceId, + /// The Provider MSA that the staking amount is retargeted to + to_msa: MessageSourceId, + /// The amount in token that was retargeted + amount: BalanceOf, + }, + /// Tokens have been staked on the network for Provider Boosting + ProviderBoosted { + /// The token account that staked tokens to the network. + account: T::AccountId, + /// The MSA that a token account targeted to receive Capacity based on this staking amount. + target: MessageSourceId, + /// An amount that was staked. + amount: BalanceOf, + /// The Capacity amount issued to the target as a result of the stake. + capacity: BalanceOf, + }, + /// Provider Boost Token Rewards have been minted and transferred to the staking account. + ProviderBoostRewardClaimed { + /// The token account claiming and receiving the reward from ProviderBoost staking + account: T::AccountId, + /// The reward amount + reward_amount: BalanceOf, + }, +``` -For all forms of claim_staking_reward, the event `StakingRewardClaimed` is emitted with the parameters of the extrinsic. +### NEW Extrinsics #### 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`. +This means that a single Account Id (origin) must choose between staking for Maximum Capacity or staking for Provider Boost + Rewards. +Those who wish to do both types of staking must use two different accounts, one for each purpose. + +The event `ProviderBoosted` is emitted with the parameters of the extrinsic on success. + + ```rust pub fn provider_boost( origin: OriginFor, @@ -301,61 +307,30 @@ pub fn provider_boost( ) -> DispatchResult {} ``` -#### 1. claim_staking_reward(origin, from_era, to_era), simple economic model +#### 1. claim_staking_rewards(origin) +Mints and transfers all unclaimed rewards to origin. +The event `ProviderBoostRewardClaimed` is emitted with the parameters of the extrinsic on success. -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 +/// Claim all outstanding Provider Boost rewards, up to ProviderBoostHistoryLimit Reward Eras +/// in the past. Accounts should check for unclaimed rewards before calling this extrinsic +/// to avoid needless transaction fees. /// 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 +/// - NotAProviderBoostAccount: if Origin has nothing staked for ProviderBoost +/// - NothingToClaim: if Origin has no unclaimed rewards to pay out. #[pallet::call_index(n)] -pub fn claim_staking_reward( +pub fn claim_staking_rewards( 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 `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 - ); -``` - #### 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. -Each call creates one chunk. Emits a `StakingTargetChanged` event with the parameters of the extrinsic. +Each call creates one chunk. Emits a `StakingTargetChanged` event with the parameters of the extrinsic on success. ```rust /// Sets the target of the staking capacity to a new target. @@ -383,41 +358,52 @@ pub fn change_staking_target( ### NEW: Capacity pallet helper function -#### payout_eligible +#### has_unclaimed_rewards -Returns whether `account_id` can claim a reward at all. +A shortcut for 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, +- Once per RewardEra - in which all outstanding rewards are paid out. - 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 -fn payout_eligible(account_id: AccountIdOf) -> bool; +fn has_unclaimed_rewards(account_id: AccountIdOf) -> bool; ``` -### NEW RPCS +#### list_unclaimed_rewards +This is used by the `CapacityRuntimeAPI::list_unclaimed_rewards` function and the `claim_rewards` extrinsic. +```rust +/// Get all unclaimed rewards information for each eligible Reward Era. +/// If no unclaimed rewards, returns empty list. +pub fn list_unclaimed_rewards( + account: &T::AccountId, +) -> Result< + BoundedVec< + UnclaimedRewardInfo, BlockNumberFor>, + T::ProviderBoostHistoryLimit, + >, + DispatchError> +``` -There are no custom RPCs for the Capacity pallet, so that work will need to be done first. +### NEW RPCS -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. +Only a CapacityRuntimeAPI will be built; custom RPCs are deprecated for Polkadot parachains. Use a state call to access this function. ```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 +pub struct UnclaimedRewardInfo { + /// The Reward Era for which this reward was earned + pub reward_era: T::RewardEra, + /// When this reward expires, i.e. can no longer be claimed + pub expires_at_block: BlockNumberFor, + /// The amount staked in this era that is eligible for rewards. Does not count additional amounts + /// staked in this era. + pub eligible_amount: BalanceOf, + /// The amount in token of the reward (only if it can be calculated using only on chain data) + pub earned_amount: BalanceOf, } -/// 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; + // state_call method: CapacityRuntimeApi_list_unclaimed_rewards + /// Get the list of unclaimed rewards information for each eligible Reward Era. + fn list_unclaimed_rewards(who: AccountId) -> Vec>; ``` diff --git a/e2e/capacity/change_staking_target.test.ts b/e2e/capacity/change_staking_target.test.ts new file mode 100644 index 0000000000..d2f76a1dcc --- /dev/null +++ b/e2e/capacity/change_staking_target.test.ts @@ -0,0 +1,44 @@ +import '@frequency-chain/api-augment'; +import assert from 'assert'; +import { ExtrinsicHelper } from '../scaffolding/extrinsicHelpers'; +import { getFundingSource } from '../scaffolding/funding'; +import { + createKeys, + createMsaAndProvider, + stakeToProvider, + CENTS, + DOLLARS, + createProviderKeysAndId, +} from '../scaffolding/helpers'; + +const fundingSource = getFundingSource('capacity-change-staking-target'); + +describe('Capacity: change_staking_target', function () { + const tokenMinStake: bigint = 1n * CENTS; + const capacityMin: bigint = tokenMinStake / 50n; + + it('successfully stake tokens to a provider', async function () { + const providerBalance = 2n * DOLLARS; + const stakeKeys = createKeys('staker'); + const oldProvider = await createMsaAndProvider(fundingSource, stakeKeys, 'Provider1', providerBalance); + const [_bar, newProvider] = await createProviderKeysAndId(fundingSource, providerBalance); + + await assert.doesNotReject(stakeToProvider(fundingSource, 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 function () { + const providerBalance = 2n * DOLLARS; + const stakeKeys = createKeys('staker'); + const oldProvider = await createMsaAndProvider(fundingSource, stakeKeys, 'Provider2', providerBalance); + + await assert.doesNotReject(stakeToProvider(fundingSource, stakeKeys, oldProvider, tokenMinStake * 6n)); + const notAProvider = 9999; + const call = ExtrinsicHelper.changeStakingTarget(stakeKeys, oldProvider, notAProvider, tokenMinStake * 2n); + await assert.rejects(call.signAndSend(), { name: 'InvalidTarget' }); + }); +}); diff --git a/e2e/capacity/list_unclaimed_rewards.test.ts b/e2e/capacity/list_unclaimed_rewards.test.ts new file mode 100644 index 0000000000..d77d657616 --- /dev/null +++ b/e2e/capacity/list_unclaimed_rewards.test.ts @@ -0,0 +1,71 @@ +import '@frequency-chain/api-augment'; +import assert from 'assert'; +import { ExtrinsicHelper } from '../scaffolding/extrinsicHelpers'; +import { getFundingSource } from '../scaffolding/funding'; +import { + createKeys, + createMsaAndProvider, + DOLLARS, + createAndFundKeypair, + boostProvider, + getNextRewardEraBlock, +} from '../scaffolding/helpers'; +import { isTestnet } from '../scaffolding/env'; +import { KeyringPair } from '@polkadot/keyring/types'; + +const fundingSource = getFundingSource('capacity-list-unclaimed-rewards'); + +describe('Capacity: list_unclaimed_rewards', function () { + const providerBalance = 2n * DOLLARS; + + const setUpForBoosting = async (boosterName: string, providerName: string): Promise<[number, KeyringPair]> => { + const booster = await createAndFundKeypair(fundingSource, 5n * DOLLARS, boosterName); + const providerKeys = createKeys(providerName); + const provider = await createMsaAndProvider(fundingSource, providerKeys, providerName, providerBalance); + await assert.doesNotReject(boostProvider(fundingSource, booster, provider, 1n * DOLLARS)); + + return [provider.toNumber(), booster]; + }; + + it('can be called', async function () { + const [_provider, booster] = await setUpForBoosting('booster1', 'provider1'); + const result = await ExtrinsicHelper.apiPromise.call.capacityRuntimeApi.listUnclaimedRewards(booster.address); + assert.equal(result.length, 0, `result should have been empty but had ${result.length} items`); + }); + + it('returns correct rewards after enough eras have passed', async function () { + // this will be too long if run against testnet + if (isTestnet()) this.skip(); + + const [_provider, booster] = await setUpForBoosting('booster2', 'provider2'); + + // Move out of the era we boosted inside of + await ExtrinsicHelper.runToBlock(await getNextRewardEraBlock()); + // Have three eras where we get rewards + await ExtrinsicHelper.runToBlock(await getNextRewardEraBlock()); + await ExtrinsicHelper.runToBlock(await getNextRewardEraBlock()); + await ExtrinsicHelper.runToBlock(await getNextRewardEraBlock()); + + const result = await ExtrinsicHelper.apiPromise.call.capacityRuntimeApi.listUnclaimedRewards(booster.address); + + assert(result.length >= 4, `Length should be >= 4 but is ${result.length}`); + + // This is the era we first boosted in, shouldn't have any rewards + assert.equal(result[0].stakedAmount.toHuman(), '100,000,000'); + assert.equal(result[0].eligibleAmount.toHuman(), '0'); + assert.equal(result[0].earnedAmount.toHuman(), '0'); + + // Boosted entire eras, should have rewards + assert.equal(result[1].stakedAmount.toHuman(), '100,000,000'); + assert.equal(result[1].eligibleAmount.toHuman(), '100,000,000'); + assert.equal(result[1].earnedAmount.toHuman(), '380,000'); + + assert.equal(result[2].stakedAmount.toHuman(), '100,000,000'); + assert.equal(result[2].eligibleAmount.toHuman(), '100,000,000'); + assert.equal(result[2].earnedAmount.toHuman(), '380,000'); + + assert.equal(result[3].stakedAmount.toHuman(), '100,000,000'); + assert.equal(result[3].eligibleAmount.toHuman(), '100,000,000'); + assert.equal(result[3].earnedAmount.toHuman(), '380,000'); + }); +}); diff --git a/e2e/capacity/provider_boost.test.ts b/e2e/capacity/provider_boost.test.ts new file mode 100644 index 0000000000..f64c93e034 --- /dev/null +++ b/e2e/capacity/provider_boost.test.ts @@ -0,0 +1,51 @@ +import '@frequency-chain/api-augment'; +import assert from 'assert'; +import { getFundingSource } from '../scaffolding/funding'; +import { + createKeys, + createMsaAndProvider, + CENTS, + DOLLARS, + createAndFundKeypair, + boostProvider, + stakeToProvider, +} from '../scaffolding/helpers'; + +const fundingSource = getFundingSource('capacity-provider-boost'); +const tokenMinStake: bigint = 1n * CENTS; + +describe('Capacity: provider_boost extrinsic', function () { + const providerBalance = 2n * DOLLARS; + + it('An account can do a simple provider boost call', async function () { + const stakeKeys = createKeys('booster'); + const provider = await createMsaAndProvider(fundingSource, stakeKeys, 'Provider1', providerBalance); + const booster = await createAndFundKeypair(fundingSource, 5n * DOLLARS, 'booster'); + await assert.doesNotReject(boostProvider(fundingSource, booster, provider, 1n * DOLLARS)); + }); + + it('fails when staker is a Maximized Capacity staker', async function () { + const stakeKeys = createKeys('booster'); + const provider = await createMsaAndProvider(fundingSource, stakeKeys, 'Provider1', providerBalance); + await assert.doesNotReject(stakeToProvider(fundingSource, stakeKeys, provider, tokenMinStake)); + await assert.rejects(boostProvider(fundingSource, stakeKeys, provider, tokenMinStake), { + name: 'CannotChangeStakingType', + }); + }); + + it("fails when staker doesn't have enough token", async function () { + const stakeKeys = createKeys('booster'); + const provider = await createMsaAndProvider(fundingSource, stakeKeys, 'Provider1', providerBalance); + const booster = await createAndFundKeypair(fundingSource, 1n * DOLLARS, 'booster'); + await assert.rejects(boostProvider(booster, booster, provider, 1n * DOLLARS), { name: 'BalanceTooLowtoStake' }); + }); + + it('staker can boost multiple times', async function () { + const stakeKeys = createKeys('booster'); + const provider = await createMsaAndProvider(fundingSource, stakeKeys, 'Provider1', providerBalance); + const booster = await createAndFundKeypair(fundingSource, 10n * DOLLARS, 'booster'); + await assert.doesNotReject(boostProvider(fundingSource, booster, provider, 1n * DOLLARS)); + await assert.doesNotReject(boostProvider(fundingSource, booster, provider, 1n * DOLLARS)); + await assert.doesNotReject(boostProvider(fundingSource, booster, provider, 1n * DOLLARS)); + }); +}); diff --git a/e2e/capacity/replenishment.test.ts b/e2e/capacity/replenishment.test.ts index 18704a5313..7deb99cab6 100644 --- a/e2e/capacity/replenishment.test.ts +++ b/e2e/capacity/replenishment.test.ts @@ -23,8 +23,6 @@ import { isTestnet } from '../scaffolding/env'; const fundingSource = getFundingSource('capacity-replenishment'); describe('Capacity Replenishment Testing: ', function () { - let schemaId: u16; - async function createAndStakeProvider(name: string, stakingAmount: bigint): Promise<[KeyringPair, u64]> { const stakeKeys = createKeys(name); const stakeProviderId = await createMsaAndProvider(fundingSource, stakeKeys, 'ReplProv', 50n * DOLLARS); @@ -36,12 +34,11 @@ describe('Capacity Replenishment Testing: ', function () { before(async function () { // Replenishment requires the epoch length to be shorter than testnet (set in globalHooks) if (isTestnet()) this.skip(); - - schemaId = await getOrCreateGraphChangeSchema(fundingSource); }); describe('Capacity is replenished', function () { it('after new epoch', async function () { + const schemaId = await getOrCreateGraphChangeSchema(fundingSource); const totalStaked = 3n * DOLLARS; const expectedCapacity = totalStaked / getTokenPerCapacity(); const [stakeKeys, stakeProviderId] = await createAndStakeProvider('ReplFirst', totalStaked); @@ -84,6 +81,7 @@ describe('Capacity Replenishment Testing: ', function () { describe('Capacity is not replenished', function () { it('if out of capacity and last_replenished_at is <= current epoch', async function () { + const schemaId = await getOrCreateGraphChangeSchema(fundingSource); const [stakeKeys, stakeProviderId] = await createAndStakeProvider('NoSend', 150n * CENTS); const payload = JSON.stringify({ changeType: 1, fromId: 1, objectId: 2 }); const call = ExtrinsicHelper.addOnChainMessage(stakeKeys, schemaId, payload); @@ -108,6 +106,7 @@ describe('Capacity Replenishment Testing: ', function () { const { eventMap } = await ExtrinsicHelper.stake(userKeys, stakeProviderId, userStakeAmt).signAndSend(); assertEvent(eventMap, 'system.ExtrinsicSuccess'); + const schemaId = await getOrCreateGraphChangeSchema(fundingSource); const payload = JSON.stringify({ changeType: 1, fromId: 1, objectId: 2 }); const call = ExtrinsicHelper.addOnChainMessage(stakeKeys, schemaId, payload); diff --git a/e2e/capacity/staking.test.ts b/e2e/capacity/staking.test.ts index b3f84a3d36..0c93431af0 100644 --- a/e2e/capacity/staking.test.ts +++ b/e2e/capacity/staking.test.ts @@ -256,13 +256,13 @@ describe('Capacity Staking Tests', function () { }); describe('when attempting to stake below the minimum staking requirements', function () { - it('should fail to stake for InsufficientStakingAmount', async function () { + it('should fail to stake for StakingAmountBelowMinimum', async function () { const stakingKeys = createKeys('stakingKeys'); const providerId = await createMsaAndProvider(fundingSource, stakingKeys, 'stakingKeys', 150n * CENTS); const stakeAmount = 1500n; const failStakeObj = ExtrinsicHelper.stake(stakingKeys, providerId, stakeAmount); - await assert.rejects(failStakeObj.signAndSend(), { name: 'InsufficientStakingAmount' }); + await assert.rejects(failStakeObj.signAndSend(), { name: 'StakingAmountBelowMinimum' }); }); }); diff --git a/e2e/scaffolding/extrinsicHelpers.ts b/e2e/scaffolding/extrinsicHelpers.ts index 0d467d7780..ad003c670c 100644 --- a/e2e/scaffolding/extrinsicHelpers.ts +++ b/e2e/scaffolding/extrinsicHelpers.ts @@ -824,6 +824,22 @@ export class ExtrinsicHelper { ); } + public static providerBoost(keys: KeyringPair, target: any, amount: any) { + return new Extrinsic( + () => ExtrinsicHelper.api.tx.capacity.providerBoost(target, amount), + keys, + ExtrinsicHelper.api.events.capacity.ProviderBoosted + ); + } + + public static changeStakingTarget(keys: KeyringPair, from: any, to: any, amount: any) { + return new Extrinsic( + () => ExtrinsicHelper.api.tx.capacity.changeStakingTarget(from, to, amount), + keys, + ExtrinsicHelper.api.events.capacity.StakingTargetChanged + ); + } + public static payWithCapacityBatchAll(keys: KeyringPair, calls: any) { return new Extrinsic( () => ExtrinsicHelper.api.tx.frequencyTxPayment.payWithCapacityBatchAll(calls), diff --git a/e2e/scaffolding/funding.ts b/e2e/scaffolding/funding.ts index 3667155007..3dcfb0dc32 100644 --- a/e2e/scaffolding/funding.ts +++ b/e2e/scaffolding/funding.ts @@ -10,6 +10,9 @@ const keyring = new Keyring({ type: 'sr25519' }); // tldr: Each test file should have a separate funding source listed below export const fundingSources = [ 'capacity-replenishment', + 'capacity-provider-boost', + 'capacity-list-unclaimed-rewards', + 'capacity-change-staking-target', 'capacity-rpcs', 'capacity-staking', 'capacity-transactions', diff --git a/e2e/scaffolding/helpers.ts b/e2e/scaffolding/helpers.ts index fa5c3156db..a87a3e6be0 100644 --- a/e2e/scaffolding/helpers.ts +++ b/e2e/scaffolding/helpers.ts @@ -50,7 +50,7 @@ export interface Sr25519Signature { export const TEST_EPOCH_LENGTH = 50; export const CENTS = 1000000n; export const DOLLARS = 100n * CENTS; -export const STARTING_BALANCE = 6n * CENTS + DOLLARS; +export const BOOST_ADJUSTMENT = 2n; // divide by 2 or 50% of Maximum Capacity export function getTokenPerCapacity(): bigint { // Perbil @@ -87,6 +87,7 @@ export async function getBlockNumber(): Promise { } let cacheED: null | bigint = null; + export async function getExistentialDeposit(): Promise { if (cacheED !== null) return cacheED; return (cacheED = ExtrinsicHelper.api.consts.balances.existentialDeposit.toBigInt()); @@ -446,6 +447,30 @@ export async function stakeToProvider( } } +export async function boostProvider( + source: KeyringPair, + keys: KeyringPair, + providerId: u64, + tokensToStake: bigint +): Promise { + const stakeOp = ExtrinsicHelper.providerBoost(keys, providerId, tokensToStake); + const { target: stakeEvent } = await stakeOp.fundAndSend(source); + assert.notEqual(stakeEvent, undefined, 'stakeToProvider: should have returned Stake event'); + if (stakeEvent) { + const stakedCapacity = stakeEvent.data.capacity; + + const expectedCapacity = tokensToStake / getTokenPerCapacity() / BOOST_ADJUSTMENT; + + assert.equal( + stakedCapacity, + expectedCapacity, + `stakeToProvider: expected ${expectedCapacity}, got ${stakedCapacity}` + ); + } else { + return Promise.reject('stakeToProvider: stakeEvent should be capacity.Staked event'); + } +} + export async function getNextEpochBlock() { const epochInfo = await ExtrinsicHelper.apiPromise.query.capacity.currentEpochInfo(); const actualEpochLength = await ExtrinsicHelper.apiPromise.query.capacity.epochLength(); @@ -469,19 +494,26 @@ export async function setEpochLength(keys: KeyringPair, epochLength: number): Pr } } +export async function getNextRewardEraBlock(): Promise { + const eraInfo = await ExtrinsicHelper.apiPromise.query.capacity.currentEraInfo(); + const actualEraLength: number = ExtrinsicHelper.api.consts.capacity.eraLength.toNumber(); + return actualEraLength + eraInfo.startedAt.toNumber() + 1; +} + export async function getOrCreateGraphChangeSchema(source: KeyringPair): Promise { const existingSchemaId = getGraphChangeSchema(); if (existingSchemaId) { return new u16(ExtrinsicHelper.api.registry, existingSchemaId); } else { - const { target: createSchemaEvent, eventMap } = await ExtrinsicHelper.createSchemaV3( + const op = await ExtrinsicHelper.createSchemaV3( source, AVRO_GRAPH_CHANGE, 'AvroBinary', 'OnChain', [], 'test.graphChangeSchema' - ).fundAndSend(source); + ); + const { target: createSchemaEvent, eventMap } = await op.fundAndSend(source); assertExtrinsicSuccess(eventMap); if (createSchemaEvent) { return createSchemaEvent.data.schemaId; diff --git a/js/api-augment/definitions/capacity.ts b/js/api-augment/definitions/capacity.ts new file mode 100644 index 0000000000..7c7c4d8cc3 --- /dev/null +++ b/js/api-augment/definitions/capacity.ts @@ -0,0 +1,15 @@ +export default { + rpc: { + dummy: { description: 'This API has no custom RPCs', params: [], type: 'undefined' }, + }, + types: { + RewardEra: 'u32', + UnclaimedRewardInfo: { + reward_era: 'RewardEra', + expires_at_block: 'BlockNumber', + staked_amount: 'Balance', + eligible_amount: 'Balance', + earned_amount: 'Balance', + }, + }, +}; diff --git a/js/api-augment/definitions/index.ts b/js/api-augment/definitions/index.ts index 53969a32d5..7644dab34f 100644 --- a/js/api-augment/definitions/index.ts +++ b/js/api-augment/definitions/index.ts @@ -8,3 +8,4 @@ export { default as statefulStorage } from './statefulStorage.js'; export { default as handles } from './handles.js'; export { default as frequency } from './frequency.js'; export { default as frequencyTxPayment } from './frequencyTxPayment.js'; +export { default as capacity } from './capacity.js'; diff --git a/pallets/capacity/README.md b/pallets/capacity/README.md index 83fb97678b..3e7fc3a27f 100644 --- a/pallets/capacity/README.md +++ b/pallets/capacity/README.md @@ -40,24 +40,45 @@ The Capacity Pallet provides for: ### Extrinsics -| Name/Description | Caller | Payment | Key Events | Runtime Added | -| -------------------------------- | ------------- | ------- | ------------------------------------------------------------------------------------------------------------- | ------------- | -| `stake`
Lock tokens to grant Capacity to a Provider | Token Account | Tokens | [`Staked`](https://frequency-chain.github.io/frequency/pallet_capacity/pallet/enum.Event.html#variant.Staked) | 1 | -| `unstake`
Begin the process of unlocking tokens by unstaking currently staked tokens | Token Account | Tokens | [`UnStaked`](https://frequency-chain.github.io/frequency/pallet_capacity/pallet/enum.Event.html#variant.UnStaked) | 1 | -| `withdraw_unstaked`
Complete the process of unlocking tokens staked by releasing locks on expired unlock chunks | Token Account | Tokens | [`StakeWithdrawn`](https://frequency-chain.github.io/frequency/pallet_capacity/pallet/enum.Event.html#variant.StakeWithdrawn) | 1 | +| Name/Description | Caller | Payment | Key Events | Runtime Added | +|----------------------------------------------------------------------------------------------------------------------| ------------- | ------- |-------------------------------------------------------------------------------------------------------------------------------------------| ------------- | +| `change_staking_target`
Change the target of a stake from one Provider to the other. | Token Account | Tokens | [`StakingTargetChanged`](https://frequency-chain.github.io/frequency/pallet_capacity/pallet/enum.Event.html#variant.StakingTargetChanged) | 1 | +| `claim_staking_rewards`
Mint and pay out eligible staking rewards from Provider Boosting. | Token Account | Tokens | [`ProviderBoostRewardClaimed`](https://frequency-chain.github.io/frequency/pallet_capacity/pallet/enum.Event.html#variant.ProviderBoostRewardClaimed) | 1 | +| `provider_boost`
Lock tokens to grant Capacity to a Provider and earn token Rewards | Token Account | Tokens | [`ProviderBoosted`](https://frequency-chain.github.io/frequency/pallet_capacity/pallet/enum.Event.html#variant.Staked) | 1 | +| `stake`
Lock tokens to grant Capacity to a Provider | Token Account | Tokens | [`Staked`](https://frequency-chain.github.io/frequency/pallet_capacity/pallet/enum.Event.html#variant.Staked) | 1 | +| `unstake`
Begin the process of unlocking tokens by unstaking currently staked tokens | Token Account | Tokens | [`UnStaked`](https://frequency-chain.github.io/frequency/pallet_capacity/pallet/enum.Event.html#variant.UnStaked) | 1 | +| `withdraw_unstaked`
Complete the process of unlocking tokens staked by releasing locks on expired unlock chunks | Token Account | Tokens | [`StakeWithdrawn`](https://frequency-chain.github.io/frequency/pallet_capacity/pallet/enum.Event.html#variant.StakeWithdrawn) | 1 | See [Rust Docs](https://frequency-chain.github.io/frequency/pallet_capacity/pallet/struct.Pallet.html) for more details. ### State Queries -| Name | Description | Query | Runtime Added | -| --------- | ------------------- | ------------------------ | ------------- | -| Get Capacity Ledger | Returns the Capacity balance details for a Provider's MSA Id | `capacityLedger` | 1 | -| Get Current Epoch | Returns the current Capacity Epoch number | `currentEpoch` | 1 | -| Get Current Epoch Info | Returns information about the current Capacity Epoch such as the starting block number | `currentEpochInfo` | 1 | -| Get Staking Account Ledger | Returns information about an account's current staking details | `stakingAccountLedger` | 1 | -| Staking Target Ledger | Returns information about an account's current staking details for a specific target Provider MSA Id | `stakingTargetLedger` | 1 | -| Get Unstake Information | Returns the information about an account's current unstaking details and the unlocking chunks | `unstakeUnlocks` | 1 | +| Name | Description | Query | Runtime Added | +|----------------------------------|---------------------------------------------------------------------------------------------------|-----------------------------|---------------| +| Get Capacity Ledger | Returns the Capacity balance details for a Provider's MSA Id | `capacityLedger` | 1 | +| Get Current Epoch | Returns the current Capacity Epoch number | `currentEpoch` | 1 | +| Get Current Epoch Info | Returns information about the current Capacity Epoch such as the starting block number | `currentEpochInfo` | 1 | +| Current Era Info | Returns the index of the current era and the block when it started | `currentEraInfo` | 1 | +| Current Era Provider Boost Total | Returns the total amount of token staked this Reward Era, as of the current block | `currentProviderBoostTotal` | 1 | +| Provider Boost Histories | Returns the ProviderBoostHistory stored for the provided AccountId | `providerBoostHistories` | 1 | +| Provider Boost Reward Pool | Returns the Provider Boost Reward Pool Chunk at the given index | `providerBoostRewardBools` | 1 | +| Retargets | Returns the count of retargets and what era was the last retarget, for the provided AccountId. | `retargets` | 1 | +| Get Staking Account Ledger | Returns information about an account's current staking details | `stakingAccountLedger` | 1 | +| Staking Target Ledger | Returns information about an account's current staking details for a specific target Provider MSA Id | `stakingTargetLedger` | 1 | +| Get Unstake Information | Returns the information about an account's current unstaking details and the unlocking chunks | `unstakeUnlocks` | 1 | + +### RPCs +Custom RPCs are not enabled for this pallet. The following RuntimeAPI functions may be accessed by making a state call, for example: +```javascript + const encodedAddr = api.registry.createType('AccountId32', booster.address); // where booster is a polkadot/keyring Keypair type + let result = await api.rcp.state.call('CapacityRuntimeApi_list_unclaimed_rewards', encodedAddr); + const decodedResult: Vec = api.registry.createType('Vec', result); +``` + +| Name | Description | Query | Runtime Added | +|------------------------|---------------------------------------------------------------------|---------------------------------------------|---------------| +| List unclaimed rewards | Returns a list of `UnclaimedRewardInfo` for the provided `AccountId`. | `CapacityRuntimeApi_list_unclaimed_rewards` | 1 | + See the [Rust Docs](https://frequency-chain.github.io/frequency/pallet_capacity/pallet/storage_types/index.html) for additional state queries and details. diff --git a/pallets/capacity/src/benchmarking.rs b/pallets/capacity/src/benchmarking.rs index f12979f35e..0e40c3eb2d 100644 --- a/pallets/capacity/src/benchmarking.rs +++ b/pallets/capacity/src/benchmarking.rs @@ -1,12 +1,14 @@ use super::*; use crate::Pallet as Capacity; +use crate::StakingType::*; use frame_benchmarking::{account, benchmarks, whitelist_account}; use frame_support::{assert_ok, BoundedVec}; -use frame_system::RawOrigin; +use frame_system::{pallet_prelude::BlockNumberFor, RawOrigin}; use parity_scale_codec::alloc::vec::Vec; const SEED: u32 = 0; +const REWARD_POOL_TOTAL: u32 = 2_000_000; fn assert_last_event(generic_event: ::RuntimeEvent) { frame_system::Pallet::::assert_last_event(generic_event.into()); @@ -37,13 +39,94 @@ pub fn set_up_epoch(current_block: BlockNumberFor, current_epoch: CurrentEpochInfo::::set(EpochInfo { epoch_start }); } +pub fn set_era_and_reward_pool_at_block( + era_index: RewardEra, + started_at: BlockNumberFor, + total_staked_token: BalanceOf, +) { + let era_info: RewardEraInfo> = + RewardEraInfo { era_index, started_at }; + CurrentEraInfo::::set(era_info); + CurrentEraProviderBoostTotal::::set(total_staked_token) +} + +// caller stakes the given amount to the given target +pub fn setup_provider_stake( + caller: &T::AccountId, + target: &MessageSourceId, + staking_amount: BalanceOf, + is_provider_boost: bool, +) { + let capacity_amount: BalanceOf = Capacity::::capacity_generated(staking_amount); + + let mut staking_account = StakingDetails::::default(); + if is_provider_boost { + staking_account.staking_type = ProviderBoost; + } + 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_and_lock(caller, &staking_account) + .expect("Failed to set staking account"); + Capacity::::set_target_details_for(caller, *target, target_details); + Capacity::::set_capacity_for(*target, capacity_details); +} + +// fill up unlock chunks to max bound - 1 +fn fill_unlock_chunks(caller: &T::AccountId, count: u32) { + let mut unlocking: UnlockChunkList = BoundedVec::default(); + for _i in 0..count { + let unlock_chunk: UnlockChunk, T::EpochNumber> = + UnlockChunk { value: 1u32.into(), thaw_at: 3u32.into() }; + assert_ok!(unlocking.try_push(unlock_chunk)); + } + UnstakeUnlocks::::set(caller, Some(unlocking)); +} + +fn fill_reward_pool_chunks(current_era: RewardEra) { + let history_limit: RewardEra = ::ProviderBoostHistoryLimit::get(); + let starting_era: RewardEra = current_era - history_limit - 1u32; + for era in starting_era..current_era { + Capacity::::update_provider_boost_reward_pool(era, REWARD_POOL_TOTAL.into()); + } +} + +fn fill_boost_history( + caller: &T::AccountId, + amount: BalanceOf, + current_era: RewardEra, +) { + let max_history: RewardEra = ::ProviderBoostHistoryLimit::get().into(); + let starting_era = current_era - max_history - 1u32; + for i in starting_era..current_era { + assert_ok!(Capacity::::upsert_boost_history(caller.into(), i, amount, true)); + } +} + +fn unclaimed_rewards_total(caller: &T::AccountId) -> BalanceOf { + let zero_balance: BalanceOf = 0u32.into(); + let rewards: Vec, BlockNumberFor>> = + Capacity::::list_unclaimed_rewards(caller).unwrap_or_default().to_vec(); + rewards + .iter() + .fold(zero_balance, |acc, reward_info| acc.saturating_add(reward_info.earned_amount)) + .into() +} + benchmarks! { stake { let caller: T::AccountId = create_funded_account::("account", SEED, 105u32); let amount: BalanceOf = T::MinimumStakingAmount::get(); let capacity: BalanceOf = Capacity::::capacity_generated(amount); let target = 1; + let staking_type = MaximumCapacity; + set_era_and_reward_pool_at_block::(1u32.into(), 1u32.into(), 1_000u32.into()); register_provider::(target, "Foo"); }: _ (RawOrigin::Signed(caller.clone()), target, amount) @@ -56,12 +139,7 @@ benchmarks! { withdraw_unstaked { let caller: T::AccountId = create_funded_account::("account", SEED, 5u32); - let mut unlocking: UnlockChunkList = BoundedVec::default(); - for _i in 0..T::MaxUnlockingChunks::get() { - let unlock_chunk: UnlockChunk, T::EpochNumber> = UnlockChunk { value: 1u32.into(), thaw_at: 3u32.into() }; - assert_ok!(unlocking.try_push(unlock_chunk)); - } - UnstakeUnlocks::::set(&caller, Some(unlocking)); + fill_unlock_chunks::(&caller, T::MaxUnlockingChunks::get()); CurrentEpoch::::set(T::EpochNumber::from(5u32)); @@ -71,16 +149,35 @@ benchmarks! { assert_last_event::(Event::::StakeWithdrawn {account: caller, amount: total.into() }.into()); } - on_initialize { + start_new_epoch_if_needed { let current_block: BlockNumberFor = 100_000u32.into(); let current_epoch: T::EpochNumber = 10_000u32.into(); set_up_epoch::(current_block, current_epoch); - }: { - Capacity::::on_initialize(current_block); + }: { + Capacity::::start_new_epoch_if_needed(current_block) } verify { assert_eq!(current_epoch.saturating_add(1u32.into()), CurrentEpoch::::get()); assert_eq!(current_block, CurrentEpochInfo::::get().epoch_start); } + + start_new_reward_era_if_needed { + let current_block: BlockNumberFor = 1_209_600u32.into(); + let history_limit: u32 = ::ProviderBoostHistoryLimit::get(); + let total_reward_pool: BalanceOf = ::RewardPoolPerEra::get(); + let unclaimed_balance: BalanceOf = 5_000u32.into(); + let total_staked_token: BalanceOf = 5_000u32.into(); + let started_at: BlockNumberFor = current_block.saturating_sub(::EraLength::get().into()); + + let current_era: RewardEra = (history_limit + 1u32).into(); + CurrentEraInfo::::set(RewardEraInfo{ era_index: current_era, started_at }); + fill_reward_pool_chunks::(current_era); + }: { + Capacity::::start_new_reward_era_if_needed(current_block); + } verify { + let new_era_info = CurrentEraInfo::::get(); + assert_eq!(current_era.saturating_add(1u32.into()), new_era_info.era_index); + assert_eq!(current_block, new_era_info.started_at); + } unstake { let caller: T::AccountId = create_funded_account::("account", SEED, 5u32); let staking_amount: BalanceOf = T::MinimumStakingAmount::get().saturating_add(20u32.into()); @@ -89,31 +186,24 @@ benchmarks! { let target = 1; let block_number = 4u32; - let mut staking_account = StakingDetails::::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_and_lock(&caller.clone(), &staking_account).expect("Failed to set staking account"); - Capacity::::set_target_details_for(&caller.clone(), target, target_details); - Capacity::::set_capacity_for(target, capacity_details); - - // fill up unlock chunks to max bound - 1 - let count = T::MaxUnlockingChunks::get()-1; - let mut unlocking: UnlockChunkList = BoundedVec::default(); - for _i in 0..count { - let unlock_chunk: UnlockChunk, T::EpochNumber> = UnlockChunk { value: 1u32.into(), thaw_at: 3u32.into() }; - assert_ok!(unlocking.try_push(unlock_chunk)); - } - UnstakeUnlocks::::set(&caller, Some(unlocking)); + // Adds a boost history entry for this era only so unstake succeeds and there is an update + // to provider boost history. + let mut pbh: ProviderBoostHistory = ProviderBoostHistory::new(); + pbh.add_era_balance(&1u32.into(), &staking_amount); + ProviderBoostHistories::::set(caller.clone(), Some(pbh)); + set_era_and_reward_pool_at_block::(1u32.into(), 1u32.into(), 1_000u32.into()); + setup_provider_stake::(&caller, &target, staking_amount, true); + fill_unlock_chunks::(&caller, T::MaxUnlockingChunks::get() - 1); }: _ (RawOrigin::Signed(caller.clone()), target, unstaking_amount.into()) verify { - assert_last_event::(Event::::UnStaked {account: caller, target: target, amount: unstaking_amount.into(), capacity: Capacity::::calculate_capacity_reduction(unstaking_amount.into(), staking_amount, capacity_amount) }.into()); + assert_last_event::(Event::::UnStaked { + account: caller.clone(), + target, + amount: unstaking_amount.into(), + capacity: Capacity::::calculate_capacity_reduction(unstaking_amount.into(), staking_amount,capacity_amount) + }.into()); } set_epoch_length { @@ -124,7 +214,63 @@ 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: BalanceOf = T::MinimumStakingAmount::get().saturating_add(31u32.into()); + let to_msa_amount: BalanceOf = T::MinimumStakingAmount::get().saturating_add(1u32.into()); + + set_era_and_reward_pool_at_block::(1u32.into(), 1u32.into(), 1_000u32.into()); + register_provider::(from_msa, "frommsa"); + register_provider::(to_msa, "tomsa"); + setup_provider_stake::(&caller, &from_msa, from_msa_amount, false); + setup_provider_stake::(&caller, &to_msa, to_msa_amount, false); + let restake_amount: BalanceOf = from_msa_amount.saturating_sub(10u32.into()); + + }: _ (RawOrigin::Signed(caller.clone(), ), from_msa, to_msa, restake_amount) + verify { + assert_last_event::(Event::::StakingTargetChanged { + account: caller, + from_msa, + to_msa, + amount: restake_amount.into() + }.into()); + } + + provider_boost { + let caller: T::AccountId = create_funded_account::("boostaccount", SEED, 260u32); + let boost_amount: BalanceOf = T::MinimumStakingAmount::get().saturating_add(1u32.into()); + let capacity: BalanceOf = Capacity::::capacity_generated(::RewardsProvider::capacity_boost(boost_amount)); + let target = 1; + + set_era_and_reward_pool_at_block::(1u32.into(), 1u32.into(), 1_000u32.into()); + register_provider::(target, "Foo"); + + }: _ (RawOrigin::Signed(caller.clone()), target, boost_amount) + verify { + assert_last_event::(Event::::ProviderBoosted {account: caller, amount: boost_amount, target, capacity}.into()); + } + + // TODO: vary the boost_history to get better weight estimates. + claim_staking_rewards { + let caller: T::AccountId = create_funded_account::("account", SEED, 5u32); + let from_msa = 33; + let boost_amount: BalanceOf = T::MinimumStakingAmount::get(); + setup_provider_stake::(&caller, &from_msa, boost_amount, false); + frame_system::Pallet::::set_block_number(1002u32.into()); + let current_era: RewardEra = 100; + set_era_and_reward_pool_at_block::(current_era, 1001u32.into(), REWARD_POOL_TOTAL.into()); + fill_reward_pool_chunks::(current_era); + fill_boost_history::(&caller, 100u32.into(), current_era); + let unclaimed_rewards = unclaimed_rewards_total::(&caller); + }: _ (RawOrigin::Signed(caller.clone())) + verify { + assert_last_event::(Event::::ProviderBoostRewardClaimed {account: caller.clone(), reward_amount: unclaimed_rewards}.into()); + } + impl_benchmark_test_suite!(Capacity, - crate::tests::mock::new_test_ext(), - crate::tests::mock::Test); + tests::mock::new_test_ext(), + tests::mock::Test); } diff --git a/pallets/capacity/src/lib.rs b/pallets/capacity/src/lib.rs index 4e814810cd..3ef979999e 100644 --- a/pallets/capacity/src/lib.rs +++ b/pallets/capacity/src/lib.rs @@ -3,6 +3,7 @@ //! ## Quick Links //! - [Configuration: `Config`](Config) //! - [Extrinsics: `Call`](Call) +//! - [Runtime API: `CapacityRuntimeApi`](../pallet_capacity_runtime_api/trait.CapacityRuntimeApi.html) //! - [Event Enum: `Event`](Event) //! - [Error Enum: `Error`](Error) #![doc = include_str!("../README.md")] @@ -26,33 +27,42 @@ missing_docs )] +use sp_std::ops::Mul; + use frame_support::{ ensure, traits::{ - tokens::fungible::{Inspect as InspectFungible, InspectFreeze, Mutate, MutateFreeze}, + fungible::Inspect, + tokens::{ + fungible::{Inspect as InspectFungible, InspectFreeze, Mutate, MutateFreeze}, + Fortitude, Preservation, + }, Get, Hooks, }, weights::Weight, }; use sp_runtime::{ - traits::{CheckedAdd, Saturating, Zero}, - ArithmeticError, DispatchError, Perbill, + traits::{CheckedAdd, CheckedDiv, One, Saturating, Zero}, + ArithmeticError, BoundedVec, DispatchError, Perbill, Permill, }; -use sp_std::ops::Mul; pub use common_primitives::{ - capacity::{Nontransferable, Replenishable, TargetValidator}, + capacity::*, msa::MessageSourceId, + node::{AccountId, Balance, BlockNumber}, utils::wrap_binary_data, }; +use frame_system::pallet_prelude::*; + #[cfg(feature = "runtime-benchmarks")] use common_primitives::benchmarks::RegisterProviderBenchmarkHelper; pub use pallet::*; pub use types::*; pub use weights::*; + pub mod types; #[cfg(feature = "runtime-benchmarks")] @@ -66,13 +76,14 @@ pub mod migration; pub mod weights; type BalanceOf = <::Currency as InspectFungible<::AccountId>>::Balance; - -use frame_system::pallet_prelude::*; +type ChunkIndex = u32; #[frame_support::pallet] pub mod pallet { use super::*; + use crate::StakingType::*; + use common_primitives::capacity::RewardEra; use frame_support::{ pallet_prelude::{StorageVersion, *}, Twox64Concat, @@ -151,6 +162,37 @@ pub mod pallet { /// How much FRQCY one unit of Capacity costs #[pallet::constant] type CapacityPerToken: Get; + + /// The number of blocks in a RewardEra + #[pallet::constant] + type EraLength: Get; + + /// The maximum number of eras over which one can claim rewards + /// Note that you can claim rewards even if you no longer are boosting, because you + /// may claim rewards for past eras up to the history limit. + /// MUST be a multiple of [`Self::RewardPoolChunkLength`] + #[pallet::constant] + type ProviderBoostHistoryLimit: Get; + + /// The ProviderBoostRewardsProvider used by this pallet in a given runtime + type RewardsProvider: ProviderBoostRewardsProvider; + + /// A staker may not retarget more than MaxRetargetsPerRewardEra + #[pallet::constant] + type MaxRetargetsPerRewardEra: Get; + + /// The fixed size of the reward pool in each Reward Era. + #[pallet::constant] + type RewardPoolPerEra: Get>; + + /// the percentage cap per era of an individual Provider Boost reward + #[pallet::constant] + type RewardPercentCap: Get; + + /// The number of chunks of Reward Pool history we expect to store + /// Is a divisor of [`Self::ProviderBoostHistoryLimit`] + #[pallet::constant] + type RewardPoolChunkLength: Get; } /// Storage for keeping a ledger of staked token amounts for accounts. @@ -205,6 +247,32 @@ pub mod pallet { pub type UnstakeUnlocks = StorageMap<_, Twox64Concat, T::AccountId, UnlockChunkList>; + /// stores how many times an account has retargeted, and when it last retargeted. + #[pallet::storage] + pub type Retargets = StorageMap<_, Twox64Concat, T::AccountId, RetargetInfo>; + + /// Information about the current reward era. Checked every block. + #[pallet::storage] + #[pallet::whitelist_storage] + pub type CurrentEraInfo = + StorageValue<_, RewardEraInfo>, ValueQuery>; + + /// Reward Pool history is divided into chunks of size RewardPoolChunkLength. + /// ProviderBoostHistoryLimit is the total number of items, the key is the + /// chunk number. + #[pallet::storage] + pub type ProviderBoostRewardPools = + StorageMap<_, Twox64Concat, ChunkIndex, RewardPoolHistoryChunk>; + + /// How much is staked this era + #[pallet::storage] + pub type CurrentEraProviderBoostTotal = StorageValue<_, BalanceOf, ValueQuery>; + + /// Individual history for each account that has Provider-Boosted. + #[pallet::storage] + pub type ProviderBoostHistories = + StorageMap<_, Twox64Concat, T::AccountId, ProviderBoostHistory>; + // Simple declaration of the `Pallet` type. It is placeholder we use to implement traits and // method. #[pallet::pallet] @@ -255,6 +323,35 @@ pub mod pallet { /// The amount of Capacity withdrawn from MSA. amount: BalanceOf, }, + /// The target of a staked amount was changed to a new MessageSourceId + StakingTargetChanged { + /// The account that retargeted the staking amount + account: T::AccountId, + /// The Provider MSA that the staking amount is taken from + from_msa: MessageSourceId, + /// The Provider MSA that the staking amount is retargeted to + to_msa: MessageSourceId, + /// The amount in token that was retargeted + amount: BalanceOf, + }, + /// Tokens have been staked on the network for Provider Boosting + ProviderBoosted { + /// The token account that staked tokens to the network. + account: T::AccountId, + /// The MSA that a token account targeted to receive Capacity based on this staking amount. + target: MessageSourceId, + /// An amount that was staked. + amount: BalanceOf, + /// The Capacity amount issued to the target as a result of the stake. + capacity: BalanceOf, + }, + /// Provider Boost Token Rewards have been minted and transferred to the staking account. + ProviderBoostRewardClaimed { + /// The token account claiming and receiving the reward from ProviderBoost staking + account: T::AccountId, + /// The reward amount + reward_amount: BalanceOf, + }, } #[pallet::error] @@ -262,40 +359,60 @@ 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 + /// #[deprecated(since = "1.13.0", note = "Use StakingAmountBelowMinimum instead")] ZeroAmountNotAllowed, /// This AccountId does not have a staking account. NotAStakingAccount, /// No staked value is available for withdrawal; either nothing is being unstaked, - /// or nothing has passed the thaw period. + /// or nothing has passed the thaw period. (5) NoUnstakedTokensAvailable, /// Unstaking amount should be greater than zero. UnstakedAmountIsZero, - /// Amount to unstake is greater than the amount staked. - AmountToUnstakeExceedsAmountStaked, - /// Attempting to get a staker / target relationship that does not exist. + /// Amount to unstake or change targets is greater than the amount staked. + InsufficientStakingBalance, + /// Attempted to get a staker / target relationship that does not exist. StakerTargetRelationshipNotFound, - /// Attempting to get the target's capacity that does not exist. + /// Attempted to get the target's capacity that does not exist. TargetCapacityNotFound, - /// Staker reached the limit number for the allowed amount of unlocking chunks. + /// Staker has reached the limit of unlocking chunks and must wait for at least one thaw period + /// to complete. (10) MaxUnlockingChunksExceeded, - /// Increase Capacity increase exceeds the total available Capacity for target. + /// Capacity increase exceeds the total available Capacity for target. IncreaseExceedsAvailable, - /// Attempting to set the epoch length to a value greater than the max epoch length. + /// Attempted to set the Epoch length to a value greater than the max Epoch length. MaxEpochLengthExceeded, /// Staker is attempting to stake an amount that leaves a token balance below the minimum amount. BalanceTooLowtoStake, - /// None of the token amounts in UnlockChunks has thawed yet. + /// There are no unstaked token amounts that have passed their thaw period. NoThawedTokenAvailable, + /// Staker tried to change StakingType on an existing account + CannotChangeStakingType, + /// The Era specified is too far in the past or is in the future (15) + EraOutOfRange, + /// Attempted to retarget but from and to Provider MSA Ids were the same + CannotRetargetToSameProvider, + /// There are no rewards eligible to claim. Rewards have expired, have already been + /// claimed, or boosting has never been done before the current era. + NoRewardsEligibleToClaim, + /// Caller must claim rewards before unstaking. + MustFirstClaimRewards, + /// Too many change_staking_target calls made in this RewardEra. (20) + MaxRetargetsExceeded, + /// Tried to exceed bounds of a some Bounded collection + CollectionBoundExceeded, + /// This origin has nothing staked for ProviderBoost. + NotAProviderBoostAccount, } #[pallet::hooks] impl Hooks> for Pallet { fn on_initialize(current: BlockNumberFor) -> Weight { Self::start_new_epoch_if_needed(current) + .saturating_add(Self::start_new_reward_era_if_needed(current)) } } @@ -305,9 +422,9 @@ pub mod pallet { /// /// ### Errors /// - /// - Returns Error::ZeroAmountNotAllowed if the staker is attempting to stake a zero amount. /// - Returns Error::InvalidTarget if attempting to stake to an invalid target. - /// - Returns Error::InsufficientStakingAmount if attempting to stake an amount below the minimum amount. + /// - Returns Error::StakingAmountBelowMinimum if attempting to stake an amount below the minimum amount. + /// - Returns Error::CannotChangeStakingType if the staking account is a ProviderBoost account #[pallet::call_index(0)] #[pallet::weight(T::WeightInfo::stake())] pub fn stake( @@ -318,7 +435,7 @@ pub mod pallet { let staker = ensure_signed(origin)?; let (mut staking_account, actual_amount) = - Self::ensure_can_stake(&staker, target, amount)?; + Self::ensure_can_stake(&staker, target, amount, MaximumCapacity)?; let capacity = Self::increase_stake_and_issue_capacity( &staker, @@ -337,7 +454,7 @@ pub mod pallet { Ok(()) } - /// Removes all thawed UnlockChunks from caller's UnstakeUnlocks and unlocks the sum of the thawed values + /// Removes all thawed UnlockChunks from caller's UnstakeUnlocks and thaws(unfreezes) the sum of the thawed values /// in the caller's token account. /// /// ### Errors @@ -362,7 +479,7 @@ pub mod pallet { /// - Returns `Error::MaxUnlockingChunksExceeded` if attempting to unlock more times than config::MaxUnlockingChunks. /// - Returns `Error::AmountToUnstakeExceedsAmountStaked` if `amount` exceeds the amount currently staked. /// - Returns `Error::InvalidTarget` if `target` is not a valid staking target (not a Provider) - /// - Returns `Error:: NotAStakingAccount` if `origin` has nothing staked at all + /// - Returns `Error::NotAStakingAccount` if `origin` has nothing staked at all /// - Returns `Error::StakerTargetRelationshipNotFound` if `origin` has nothing staked to `target` #[pallet::call_index(2)] #[pallet::weight(T::WeightInfo::unstake())] @@ -375,10 +492,14 @@ pub mod pallet { ensure!(requested_amount > Zero::zero(), Error::::UnstakedAmountIsZero); - let actual_amount = Self::decrease_active_staking_balance(&unstaker, requested_amount)?; + ensure!(!Self::has_unclaimed_rewards(&unstaker), Error::::MustFirstClaimRewards); + + let (actual_amount, staking_type) = + Self::decrease_active_staking_balance(&unstaker, requested_amount)?; Self::add_unlock_chunk(&unstaker, actual_amount)?; - let capacity_reduction = Self::reduce_capacity(&unstaker, target, actual_amount)?; + let capacity_reduction = + Self::reduce_capacity(&unstaker, target, actual_amount, staking_type)?; Self::deposit_event(Event::UnStaked { account: unstaker, @@ -407,6 +528,105 @@ 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 `StakingDetails.stake_change_unlocking chunks`, up to `T::MaxUnlockingChunks`. + /// The staked amount and Capacity generated by `amount` originally targeted to the `from` MSA Id is reassigned to the `to` MSA Id. + /// Does not affect unstaking process or additional stake amounts. + /// Changing a staking target to a Provider when Origin has nothing staked them will retain the staking type. + /// Changing a staking target to a Provider when Origin has any amount staked to them will error if the staking types are not the same. + /// ### Errors + /// - [`Error::MaxUnlockingChunksExceeded`] if `stake_change_unlocking_chunks` == `T::MaxUnlockingChunks` + /// - [`Error::StakerTargetRelationshipNotFound`] if `from` is not a target for Origin's staking account. + /// - [`Error::StakingAmountBelowMinimum`] if `amount` to retarget is below the minimum staking amount. + /// - [`Error::InsufficientStakingBalance`] if `amount` to retarget exceeds what the staker has targeted to `from` MSA Id. + /// - [`Error::InvalidTarget`] if `to` does not belong to a registered Provider. + /// - [`Error::MaxRetargetsExceeded`] if origin has reached the maximimum number of retargets for the current RewardEra. + #[pallet::call_index(4)] + #[pallet::weight(T::WeightInfo::change_staking_target())] + pub fn change_staking_target( + origin: OriginFor, + from: MessageSourceId, + to: MessageSourceId, + amount: BalanceOf, + ) -> DispatchResult { + let staker = ensure_signed(origin)?; + // This will bounce immediately if they've tried to do this too many times. + Self::update_retarget_record(&staker)?; + ensure!(from.ne(&to), Error::::CannotRetargetToSameProvider); + ensure!( + amount >= T::MinimumStakingAmount::get(), + Error::::StakingAmountBelowMinimum + ); + + ensure!(T::TargetValidator::validate(to), Error::::InvalidTarget); + + Self::do_retarget(&staker, &from, &to, &amount)?; + + Self::deposit_event(Event::StakingTargetChanged { + account: staker, + from_msa: from, + to_msa: to, + amount, + }); + Ok(()) + } + /// Stakes some amount of tokens to the network and generates a comparatively small amount of Capacity + /// for the target, and gives periodic rewards to origin. + /// ### Errors + /// + /// - Error::InvalidTarget if attempting to stake to an invalid target. + /// - Error::StakingAmountBelowMinimum if attempting to stake an amount below the minimum amount. + /// - Error::CannotChangeStakingType if the staking account exists and staking_type is MaximumCapacity + #[pallet::call_index(5)] + #[pallet::weight(T::WeightInfo::provider_boost())] + pub fn provider_boost( + origin: OriginFor, + target: MessageSourceId, + amount: BalanceOf, + ) -> DispatchResult { + let staker = ensure_signed(origin)?; + let (mut boosting_details, actual_amount) = + Self::ensure_can_boost(&staker, &target, &amount)?; + + let capacity = Self::increase_stake_and_issue_boost_capacity( + &staker, + &mut boosting_details, + &target, + &actual_amount, + )?; + + Self::deposit_event(Event::ProviderBoosted { + account: staker, + amount: actual_amount, + target, + capacity, + }); + + Ok(()) + } + + /// Claim all outstanding Provider Boost rewards, up to ProviderBoostHistoryLimit Reward Eras + /// in the past. Accounts should check for unclaimed rewards before calling this extrinsic + /// to avoid needless transaction fees. + /// Errors: + /// - NotAProviderBoostAccount: if Origin has nothing staked for ProviderBoost + /// - NoRewardsEligibleToClaim: if Origin has no unclaimed rewards to pay out. + #[pallet::call_index(6)] + #[pallet::weight(T::WeightInfo::claim_staking_rewards())] + pub fn claim_staking_rewards(origin: OriginFor) -> DispatchResult { + let staker = ensure_signed(origin)?; + ensure!( + ProviderBoostHistories::::contains_key(staker.clone()), + Error::::NotAProviderBoostAccount + ); + let total_to_mint = Self::do_claim_rewards(&staker)?; + Self::deposit_event(Event::ProviderBoostRewardClaimed { + account: staker.clone(), + reward_amount: total_to_mint, + }); + Ok(()) + } } } @@ -417,32 +637,47 @@ impl Pallet { /// # Errors /// * [`Error::ZeroAmountNotAllowed`] /// * [`Error::InvalidTarget`] + /// * [`Error::CannotChangeStakingType`] /// * [`Error::BalanceTooLowtoStake`] + /// * [`Error::StakingAmountBelowMinimum`] /// fn ensure_can_stake( staker: &T::AccountId, target: MessageSourceId, amount: BalanceOf, + staking_type: StakingType, ) -> Result<(StakingDetails, BalanceOf), DispatchError> { ensure!(amount > Zero::zero(), Error::::ZeroAmountNotAllowed); ensure!(T::TargetValidator::validate(target), Error::::InvalidTarget); - let staking_account = StakingAccountLedger::::get(&staker).unwrap_or_default(); + let staking_details = StakingAccountLedger::::get(&staker).unwrap_or_default(); + if !staking_details.active.is_zero() { + ensure!( + staking_details.staking_type.eq(&staking_type), + Error::::CannotChangeStakingType + ); + } + let stakable_amount = Self::get_stakable_amount_for(&staker, amount); ensure!(stakable_amount > Zero::zero(), Error::::BalanceTooLowtoStake); - - let new_active_staking_amount = staking_account - .active - .checked_add(&stakable_amount) - .ok_or(ArithmeticError::Overflow)?; - ensure!( - new_active_staking_amount >= T::MinimumStakingAmount::get(), - Error::::InsufficientStakingAmount + stakable_amount >= T::MinimumStakingAmount::get(), + Error::::StakingAmountBelowMinimum ); - Ok((staking_account, stakable_amount)) + Ok((staking_details, stakable_amount)) + } + + fn ensure_can_boost( + staker: &T::AccountId, + target: &MessageSourceId, + amount: &BalanceOf, + ) -> Result<(StakingDetails, BalanceOf), DispatchError> { + let (mut staking_details, stakable_amount) = + Self::ensure_can_stake(staker, *target, *amount, StakingType::ProviderBoost)?; + staking_details.staking_type = StakingType::ProviderBoost; + Ok((staking_details, stakable_amount)) } /// Increase a staking account and target account balances by amount. @@ -471,6 +706,37 @@ impl Pallet { Ok(capacity) } + fn increase_stake_and_issue_boost_capacity( + staker: &T::AccountId, + staking_details: &mut StakingDetails, + target: &MessageSourceId, + amount: &BalanceOf, + ) -> Result, DispatchError> { + staking_details.deposit(*amount).ok_or(ArithmeticError::Overflow)?; + Self::set_staking_account_and_lock(staker, staking_details)?; + + // get the capacity generated by a Provider Boost + let capacity = Self::capacity_generated(T::RewardsProvider::capacity_boost(*amount)); + + let mut target_details = + StakingTargetLedger::::get(&staker, &target).unwrap_or_default(); + + target_details.deposit(*amount, capacity).ok_or(ArithmeticError::Overflow)?; + Self::set_target_details_for(staker, *target, target_details); + + let mut capacity_details = CapacityLedger::::get(target).unwrap_or_default(); + capacity_details.deposit(amount, &capacity).ok_or(ArithmeticError::Overflow)?; + Self::set_capacity_for(*target, capacity_details); + + let era = CurrentEraInfo::::get().era_index; + Self::upsert_boost_history(staker, era, *amount, true)?; + + let reward_pool_total = CurrentEraProviderBoostTotal::::get(); + CurrentEraProviderBoostTotal::::set(reward_pool_total.saturating_add(*amount)); + + Ok(capacity) + } + /// Sets staking account details after a deposit fn set_staking_account_and_lock( staker: &T::AccountId, @@ -515,18 +781,31 @@ impl Pallet { CapacityLedger::::insert(target, capacity_details); } - /// Decrease a staking account's active token. + /// Decrease a staking account's active token and reap if it goes below the minimum. + /// Returns: actual amount unstaked, plus the staking type + StakingDetails, + /// since StakingDetails may be reaped and staking type must be used to calculate the + /// capacity reduction later. fn decrease_active_staking_balance( unstaker: &T::AccountId, amount: BalanceOf, - ) -> Result, DispatchError> { + ) -> Result<(BalanceOf, StakingType), DispatchError> { let mut staking_account = StakingAccountLedger::::get(unstaker).ok_or(Error::::NotAStakingAccount)?; - ensure!(amount <= staking_account.active, Error::::AmountToUnstakeExceedsAmountStaked); + ensure!(amount <= staking_account.active, Error::::InsufficientStakingBalance); let actual_unstaked_amount = staking_account.withdraw(amount)?; Self::set_staking_account(unstaker, &staking_account); - Ok(actual_unstaked_amount) + + let staking_type = staking_account.staking_type; + if staking_type == StakingType::ProviderBoost { + let era = CurrentEraInfo::::get().era_index; + Self::upsert_boost_history(&unstaker, era, actual_unstaked_amount, false)?; + let reward_pool_total = CurrentEraProviderBoostTotal::::get(); + CurrentEraProviderBoostTotal::::set( + reward_pool_total.saturating_sub(actual_unstaked_amount), + ); + } + Ok((actual_unstaked_amount, staking_type)) } fn add_unlock_chunk( @@ -553,7 +832,8 @@ impl Pallet { staker: &T::AccountId, proposed_amount: BalanceOf, ) -> BalanceOf { - let account_balance = T::Currency::balance(&staker); + let account_balance = + T::Currency::reducible_balance(&staker, Preservation::Preserve, Fortitude::Polite); account_balance .saturating_sub(T::MinimumTokenBalance::get()) .min(proposed_amount) @@ -587,29 +867,52 @@ impl Pallet { Ok(amount_withdrawn) } + #[allow(unused)] + fn get_thaw_at_epoch() -> ::EpochNumber { + let current_epoch: T::EpochNumber = CurrentEpoch::::get(); + let thaw_period = T::UnstakingThawPeriod::get(); + current_epoch.saturating_add(thaw_period.into()) + } + /// Reduce available capacity of target and return the amount of capacity reduction. fn reduce_capacity( unstaker: &T::AccountId, target: MessageSourceId, amount: BalanceOf, + staking_type: StakingType, ) -> Result, DispatchError> { let mut staking_target_details = StakingTargetLedger::::get(&unstaker, &target) .ok_or(Error::::StakerTargetRelationshipNotFound)?; + + ensure!(amount.le(&staking_target_details.amount), Error::::InsufficientStakingBalance); + let mut capacity_details = CapacityLedger::::get(target).ok_or(Error::::TargetCapacityNotFound)?; let capacity_to_withdraw = if staking_target_details.amount.eq(&amount) { staking_target_details.capacity } else { - Self::calculate_capacity_reduction( - amount, - capacity_details.total_tokens_staked, - capacity_details.total_capacity_issued, - ) + if staking_type.eq(&StakingType::ProviderBoost) { + Perbill::from_rational(amount, staking_target_details.amount) + .mul_ceil(staking_target_details.capacity) + } else { + Self::calculate_capacity_reduction( + amount, + capacity_details.total_tokens_staked, + capacity_details.total_capacity_issued, + ) + } + // this call will return an amount > than requested if the resulting StakingTargetDetails balance + // is below the minimum. This ensures we withdraw the same amounts as for staking_target_details. }; - staking_target_details.withdraw(amount, capacity_to_withdraw); - capacity_details.withdraw(capacity_to_withdraw, amount); + let (actual_amount, actual_capacity) = staking_target_details.withdraw( + amount, + capacity_to_withdraw, + T::MinimumStakingAmount::get(), + ); + + capacity_details.withdraw(actual_capacity, actual_amount); Self::set_capacity_for(target, capacity_details); Self::set_target_details_for(unstaker, target, staking_target_details); @@ -623,7 +926,8 @@ impl Pallet { cpt.mul(amount).into() } - /// Determine the capacity reduction when given total_capacity, unstaking_amount, and total_amount_staked. + /// Determine the capacity reduction when given total_capacity, unstaking_amount, and total_amount_staked, + /// based on ratios fn calculate_capacity_reduction( unstaking_amount: BalanceOf, total_amount_staked: BalanceOf, @@ -640,16 +944,287 @@ impl Pallet { let current_epoch = CurrentEpoch::::get(); CurrentEpoch::::set(current_epoch.saturating_add(1u32.into())); CurrentEpochInfo::::set(EpochInfo { epoch_start: current_block }); - T::WeightInfo::on_initialize() - .saturating_add(T::DbWeight::get().reads(1)) - .saturating_add(T::DbWeight::get().writes(2)) + T::WeightInfo::start_new_epoch_if_needed() } else { // 1 for get_current_epoch_info, 1 for get_epoch_length T::DbWeight::get().reads(2u64).saturating_add(T::DbWeight::get().writes(1)) } } + + fn start_new_reward_era_if_needed(current_block: BlockNumberFor) -> Weight { + let current_era_info: RewardEraInfo> = + CurrentEraInfo::::get(); // 1r + + if current_block.saturating_sub(current_era_info.started_at) >= T::EraLength::get().into() { + // 1r + let new_era_info = RewardEraInfo { + era_index: current_era_info.era_index.saturating_add(One::one()), + started_at: current_block, + }; + CurrentEraInfo::::set(new_era_info); // 1w + + // carry over the current reward pool total + let current_reward_pool_total: BalanceOf = CurrentEraProviderBoostTotal::::get(); // 1 + Self::update_provider_boost_reward_pool( + current_era_info.era_index, + current_reward_pool_total, + ); + T::WeightInfo::start_new_reward_era_if_needed() + } else { + T::DbWeight::get().reads(1) + } + } + + /// attempts to increment number of retargets this RewardEra + /// Returns: + /// Error::MaxRetargetsExceeded if they try to retarget too many times in one era. + fn update_retarget_record(staker: &T::AccountId) -> Result<(), DispatchError> { + let current_era: RewardEra = CurrentEraInfo::::get().era_index; + let mut retargets = Retargets::::get(staker).unwrap_or_default(); + ensure!(retargets.update(current_era).is_some(), Error::::MaxRetargetsExceeded); + Retargets::::set(staker, Some(retargets)); + Ok(()) + } + + /// Performs the work of withdrawing the requested amount from the old staker-provider target details, and + /// from the Provider's capacity details, and depositing it into the new staker-provider target details. + pub(crate) fn do_retarget( + staker: &T::AccountId, + from_msa: &MessageSourceId, + to_msa: &MessageSourceId, + amount: &BalanceOf, + ) -> Result<(), DispatchError> { + let staking_type = StakingAccountLedger::::get(staker).unwrap_or_default().staking_type; + let capacity_withdrawn = Self::reduce_capacity(staker, *from_msa, *amount, staking_type)?; + + let mut to_msa_target = StakingTargetLedger::::get(staker, to_msa).unwrap_or_default(); + + to_msa_target + .deposit(*amount, capacity_withdrawn) + .ok_or(ArithmeticError::Overflow)?; + + let mut capacity_details = CapacityLedger::::get(to_msa).unwrap_or_default(); + capacity_details + .deposit(amount, &capacity_withdrawn) + .ok_or(ArithmeticError::Overflow)?; + + Self::set_target_details_for(staker, *to_msa, to_msa_target); + Self::set_capacity_for(*to_msa, capacity_details); + Ok(()) + } + + /// updates or inserts a new boost history record for current_era. Pass 'add' = true for an increase (provider_boost), + /// pass 'false' for a decrease (unstake) + pub(crate) fn upsert_boost_history( + account: &T::AccountId, + current_era: RewardEra, + boost_amount: BalanceOf, + add: bool, + ) -> Result<(), DispatchError> { + let mut boost_history = ProviderBoostHistories::::get(account).unwrap_or_default(); + + let upsert_result = if add { + boost_history.add_era_balance(¤t_era, &boost_amount) + } else { + boost_history.subtract_era_balance(¤t_era, &boost_amount) + }; + match upsert_result { + Some(0usize) => ProviderBoostHistories::::remove(account), + None => return Err(DispatchError::from(Error::::EraOutOfRange)), + _ => ProviderBoostHistories::::set(account, Some(boost_history)), + } + Ok(()) + } + + pub(crate) fn has_unclaimed_rewards(account: &T::AccountId) -> bool { + let current_era = CurrentEraInfo::::get().era_index; + match ProviderBoostHistories::::get(account) { + Some(provider_boost_history) => { + match provider_boost_history.count() { + 0usize => false, + 1usize => { + // if there is just one era entry and: + // it's for the previous era, it means we've already paid out rewards for that era, or they just staked in the last era. + // or if it's for the current era, they only just started staking. + provider_boost_history + .get_entry_for_era(¤t_era.saturating_sub(1u32.into())) + .is_none() && provider_boost_history + .get_entry_for_era(¤t_era) + .is_none() + }, + _ => true, + } + }, + None => false, + } // 1r + } + + /// Get all unclaimed rewards information for each eligible Reward Era. + /// If no unclaimed rewards, returns empty list. + pub fn list_unclaimed_rewards( + account: &T::AccountId, + ) -> Result< + BoundedVec< + UnclaimedRewardInfo, BlockNumberFor>, + T::ProviderBoostHistoryLimit, + >, + DispatchError, + > { + if !Self::has_unclaimed_rewards(account) { + return Ok(BoundedVec::new()); + } + + let staking_history = ProviderBoostHistories::::get(account) + .ok_or(Error::::NotAProviderBoostAccount)?; // cached read + + let current_era_info = CurrentEraInfo::::get(); // cached read, ditto + let max_history: u32 = T::ProviderBoostHistoryLimit::get(); + + let start_era = current_era_info.era_index.saturating_sub((max_history).into()); + let end_era = current_era_info.era_index.saturating_sub(One::one()); // stop at previous era + + // start with how much was staked in the era before the earliest for which there are eligible rewards. + let mut previous_amount: BalanceOf = match start_era { + 0 => 0u32.into(), + _ => + staking_history.get_amount_staked_for_era(&(start_era.saturating_sub(1u32.into()))), + }; + let mut unclaimed_rewards: BoundedVec< + UnclaimedRewardInfo, BlockNumberFor>, + T::ProviderBoostHistoryLimit, + > = BoundedVec::new(); + for reward_era in start_era..=end_era { + let staked_amount = staking_history.get_amount_staked_for_era(&reward_era); + if !staked_amount.is_zero() { + let expires_at_era = reward_era.saturating_add(max_history.into()); + let expires_at_block = Self::block_at_end_of_era(expires_at_era); + let eligible_amount = staked_amount.min(previous_amount); + let total_for_era = + Self::get_total_stake_for_past_era(reward_era, current_era_info.era_index)?; + let earned_amount = ::RewardsProvider::era_staking_reward( + eligible_amount, + total_for_era, + T::RewardPoolPerEra::get(), + ); + unclaimed_rewards + .try_push(UnclaimedRewardInfo { + reward_era, + expires_at_block, + staked_amount, + eligible_amount, + earned_amount, + }) + .map_err(|_e| Error::::CollectionBoundExceeded)?; + // ^^ there's no good reason for this ever to fail in production but it must be handled. + previous_amount = staked_amount; + } + } // 1r * up to ProviderBoostHistoryLimit-1, if they staked every RewardEra. + Ok(unclaimed_rewards) + } + + // Returns the block number for the end of the provided era. Assumes `era` is at least this + // era or in the future + pub(crate) fn block_at_end_of_era(era: RewardEra) -> BlockNumberFor { + let current_era_info = CurrentEraInfo::::get(); + let era_length: BlockNumberFor = T::EraLength::get().into(); + + let era_diff = if current_era_info.era_index.eq(&era) { + 1u32.into() + } else { + era.saturating_sub(current_era_info.era_index).saturating_add(1u32.into()) + }; + current_era_info.started_at + era_length.mul(era_diff.into()) - 1u32.into() + } + + // Figure out the history chunk that a given era is in and pull out the total stake for that era. + pub(crate) fn get_total_stake_for_past_era( + reward_era: RewardEra, + current_era: RewardEra, + ) -> Result, DispatchError> { + // Make sure that the past era is not too old + let era_range = current_era.saturating_sub(reward_era); + ensure!( + current_era.gt(&reward_era) && + era_range.le(&T::ProviderBoostHistoryLimit::get().into()), + Error::::EraOutOfRange + ); + + let chunk_idx: ChunkIndex = Self::get_chunk_index_for_era(reward_era); + let reward_pool_chunk = ProviderBoostRewardPools::::get(chunk_idx).unwrap_or_default(); // 1r + let total_for_era = + reward_pool_chunk.total_for_era(&reward_era).ok_or(Error::::EraOutOfRange)?; + Ok(*total_for_era) + } + + /// Get the index of the chunk for a given era, history limit, and chunk length + /// Example with history limit of 6 and chunk length 3: + /// - Arrange the chunks such that we overwrite a complete chunk only when it is not needed + /// - The cycle is thus era modulo (history limit + chunk length) + /// - `[0,1,2],[3,4,5],[6,7,8],[]` + /// Note Chunks stored = (History Length / Chunk size) + 1 + /// - The second step is which chunk to add to: + /// - Divide the cycle by the chunk length and take the floor + /// - Floor(5 / 3) = 1 + /// Chunk Index = Floor((era % (History Length + chunk size)) / chunk size) + pub(crate) fn get_chunk_index_for_era(era: RewardEra) -> u32 { + let history_limit: u32 = T::ProviderBoostHistoryLimit::get(); + let chunk_len = T::RewardPoolChunkLength::get(); + let era_u32: u32 = era; + + // Add one chunk so that we always have the full history limit in our chunks + let cycle: u32 = era_u32 % history_limit.saturating_add(chunk_len); + cycle.saturating_div(chunk_len) + } + + // This is where the reward pool gets updated. + pub(crate) fn update_provider_boost_reward_pool(era: RewardEra, boost_total: BalanceOf) { + // Current era is this era + let chunk_idx: ChunkIndex = Self::get_chunk_index_for_era(era); + let mut new_chunk = ProviderBoostRewardPools::::get(chunk_idx).unwrap_or_default(); // 1r + + // If it is full we are resetting. + // This assumes that the chunk length is a divisor of the history limit + if new_chunk.is_full() { + new_chunk = RewardPoolHistoryChunk::new(); + }; + + if new_chunk.try_insert(era, boost_total).is_err() { + // Handle the error case that should never happen + log::warn!("could not insert a new chunk into provider boost reward pool") + } + ProviderBoostRewardPools::::set(chunk_idx, Some(new_chunk)); // 1w + } + fn do_claim_rewards(staker: &T::AccountId) -> Result, DispatchError> { + let rewards = Self::list_unclaimed_rewards(&staker)?; + ensure!(!rewards.len().is_zero(), Error::::NoRewardsEligibleToClaim); + let zero_balance: BalanceOf = 0u32.into(); + let total_to_mint: BalanceOf = rewards + .iter() + .fold(zero_balance, |acc, reward_info| acc.saturating_add(reward_info.earned_amount)) + .into(); + ensure!(total_to_mint.gt(&Zero::zero()), Error::::NoRewardsEligibleToClaim); + let _minted_unused = T::Currency::mint_into(&staker, total_to_mint)?; + + let mut new_history: ProviderBoostHistory = ProviderBoostHistory::new(); + let last_staked_amount = + rewards.last().unwrap_or(&UnclaimedRewardInfo::default()).staked_amount; + let current_era = CurrentEraInfo::::get().era_index; + // We have already paid out for the previous era. Put one entry for the previous era as if that is when they staked, + // so they will be credited for current_era. + ensure!( + new_history + .add_era_balance(¤t_era.saturating_sub(1u32.into()), &last_staked_amount) + .is_some(), + Error::::CollectionBoundExceeded + ); + ProviderBoostHistories::::set(staker, Some(new_history)); + + Ok(total_to_mint) + } } +/// Nontransferable functions are intended for capacity spend and recharge. +/// Implementations of Nontransferable MUST NOT be concerned with StakingType. impl Nontransferable for Pallet { type Balance = BalanceOf; @@ -668,7 +1243,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); @@ -677,10 +1252,14 @@ impl Nontransferable for Pallet { } /// Increase all totals for the MSA's CapacityDetails. - fn deposit(msa_id: MessageSourceId, amount: Self::Balance) -> Result<(), DispatchError> { + fn deposit( + msa_id: MessageSourceId, + token_amount: Self::Balance, + capacity_amount: Self::Balance, + ) -> Result<(), DispatchError> { let mut capacity_details = CapacityLedger::::get(msa_id).ok_or(Error::::TargetCapacityNotFound)?; - capacity_details.deposit(&amount, &Self::capacity_generated(amount)); + capacity_details.deposit(&token_amount, &capacity_amount); Self::set_capacity_for(msa_id, capacity_details); Ok(()) } @@ -702,6 +1281,7 @@ impl Replenishable for Pallet { /// Change: now calls new fn replenish_by_amount on the capacity_details, /// which does what this (actually Self::deposit) used to do + /// Currently unused. fn replenish_by_amount( msa_id: MessageSourceId, amount: Self::Balance, @@ -719,3 +1299,31 @@ impl Replenishable for Pallet { false } } + +impl ProviderBoostRewardsProvider for Pallet { + type Balance = BalanceOf; + + fn reward_pool_size(_total_staked: Self::Balance) -> Self::Balance { + T::RewardPoolPerEra::get() + } + + /// Calculate the reward for a single era. We don't care about the era number, + /// just the values. + fn era_staking_reward( + era_amount_staked: Self::Balance, + era_total_staked: Self::Balance, + era_reward_pool_size: Self::Balance, + ) -> Self::Balance { + let capped_reward = T::RewardPercentCap::get().mul(era_amount_staked); + let proportional_reward = era_reward_pool_size + .saturating_mul(era_amount_staked) + .checked_div(&era_total_staked) + .unwrap_or_else(|| Zero::zero()); + proportional_reward.min(capped_reward) + } + + /// How much, as a percentage of staked token, to boost a targeted Provider when staking. + fn capacity_boost(amount: Self::Balance) -> Self::Balance { + Perbill::from_percent(STAKED_PERCENTAGE_TO_BOOST).mul(amount) + } +} diff --git a/pallets/capacity/src/migration/mod.rs b/pallets/capacity/src/migration/mod.rs index 96aff987a4..5d9877e178 100644 --- a/pallets/capacity/src/migration/mod.rs +++ b/pallets/capacity/src/migration/mod.rs @@ -1,2 +1,4 @@ +/// initial values for ProviderBoost storage +pub mod provider_boost_init; /// Migration logic for 6 second block updates sideffects to capacity pallet pub mod v4; diff --git a/pallets/capacity/src/migration/provider_boost_init.rs b/pallets/capacity/src/migration/provider_boost_init.rs new file mode 100644 index 0000000000..3a4747beda --- /dev/null +++ b/pallets/capacity/src/migration/provider_boost_init.rs @@ -0,0 +1,47 @@ +use crate::{Config, CurrentEraInfo, CurrentEraProviderBoostTotal, RewardEraInfo}; +use frame_support::{ + pallet_prelude::Weight, + traits::{Get, OnRuntimeUpgrade}, +}; + +#[cfg(feature = "try-runtime")] +use sp_std::vec::Vec; + +/// Initialization during runtime upgrade for Provider Boost storage +pub struct ProviderBoostInit(sp_std::marker::PhantomData); + +impl OnRuntimeUpgrade for ProviderBoostInit { + fn on_runtime_upgrade() -> Weight { + let current_era_info = CurrentEraInfo::::get(); // 1r + if current_era_info.eq(&RewardEraInfo::default()) { + CurrentEraInfo::::set(RewardEraInfo { + era_index: 0u32.into(), + started_at: frame_system::Pallet::::block_number(), + }); // 1w + CurrentEraProviderBoostTotal::::set(0u32.into()); // 1w + T::DbWeight::get().reads_writes(2, 1) + } else { + T::DbWeight::get().reads(1) + } + } + + #[cfg(feature = "try-runtime")] + fn pre_upgrade() -> Result, TryRuntimeError> { + if CurrentEraInfo::::exists() { + log::info!("CurrentEraInfo exists; initialization should be skipped."); + } else { + log::info!("CurrentEraInfo not found. Initialization should proceed."); + } + Ok(Vec::default()) + } + + #[cfg(feature = "try-runtime")] + fn post_upgrade(_state: Vec) -> Result<(), TryRuntimeError> { + assert!(CurrentEraInfo::::exists()); + let current_block = frame_system::Pallet::::block_number(); + let info = CurrentEraInfo::::get(); + assert_eq!(info.started_at, current_block); + log::info!("CurrentEraInfo.started_at is set to {:?}.", info.started_at); + Ok(()) + } +} diff --git a/pallets/capacity/src/runtime-api/Cargo.toml b/pallets/capacity/src/runtime-api/Cargo.toml new file mode 100644 index 0000000000..4fbda3003a --- /dev/null +++ b/pallets/capacity/src/runtime-api/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "pallet-capacity-runtime-api" +version = "0.0.0" +description = "A package that adds Runtime Api for the Capacity pallet" +authors = ["Frequency"] +license = "Apache-2.0" +publish = false +homepage = "https://frequency.xyz" +repository = "https://github.com/frequency-chain/frequency/" +edition = "2021" + + +[dependencies] +parity-scale-codec = { workspace = true, features = ["derive"] } +sp-api = { workspace = true, default-features = false } +sp-std = { workspace = true, default-features = false } +sp-runtime = { workspace = true, default-features = false } +common-primitives = { path="../../../../common/primitives", default-features = false} + +[features] +default = ["std"] +std = [ + "parity-scale-codec/std", + "sp-api/std", + "sp-std/std", + "sp-runtime/std" +] \ No newline at end of file diff --git a/pallets/capacity/src/runtime-api/src/lib.rs b/pallets/capacity/src/runtime-api/src/lib.rs new file mode 100644 index 0000000000..05897c1309 --- /dev/null +++ b/pallets/capacity/src/runtime-api/src/lib.rs @@ -0,0 +1,43 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![allow(clippy::too_many_arguments)] +#![allow(clippy::unnecessary_mut_passed)] +#![allow(rustdoc::bare_urls)] +// Strong Documentation Lints +#![deny( + rustdoc::broken_intra_doc_links, + rustdoc::missing_crate_level_docs, + rustdoc::invalid_codeblock_attributes, + missing_docs +)] + +//! Runtime API definition for [Capacity](../pallet_capacity/index.html) +//! +//! This api must be implemented by the node runtime. +//! Runtime APIs Provide: +//! - An interface between the runtime and Custom RPCs. +//! - Runtime interfaces for end users beyond just State Queries + +use common_primitives::capacity::UnclaimedRewardInfo; +use parity_scale_codec::Codec; +use sp_runtime::traits::MaybeDisplay; +use sp_std::vec::Vec; + +// Here we declare the runtime API. It is implemented it the `impl` block in +// runtime files (the `runtime` folder) +sp_api::decl_runtime_apis! { + /// Runtime Version for Capacity + /// - MUST be incremented if anything changes + /// - Also update in js/api-augment + /// - See: https://paritytech.github.io/polkadot/doc/polkadot_primitives/runtime_api/index.html + #[api_version(1)] + /// Runtime APIs for [Capacity](../pallet_capacity/index.html) + pub trait CapacityRuntimeApi where + AccountId: Codec + MaybeDisplay, + Balance: Codec + MaybeDisplay, + BlockNumber: Codec + MaybeDisplay, + { + // state_call method: CapacityRuntimeApi_list_unclaimed_rewards + /// Get the list of unclaimed rewards information for each eligible Reward Era. + fn list_unclaimed_rewards(who: AccountId) -> Vec>; + } +} 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..6cffe7f62e --- /dev/null +++ b/pallets/capacity/src/tests/change_staking_target_tests.rs @@ -0,0 +1,467 @@ +use super::{ + mock::*, + testing_utils::{capacity_events, setup_provider}, +}; +use crate::*; +use common_primitives::msa::MessageSourceId; +use frame_support::{assert_noop, assert_ok, traits::Get}; + +// staker is unused unless amount > 0 +type TestCapacityDetails = CapacityDetails, u32>; +type TestTargetDetails = StakingTargetDetails>; + +fn assert_capacity_details( + msa_id: MessageSourceId, + remaining_capacity: u64, + total_tokens_staked: u64, + total_capacity_issued: u64, +) { + let expected_from_details: TestCapacityDetails = CapacityDetails { + remaining_capacity, + total_tokens_staked, + total_capacity_issued, + last_replenished_epoch: 0, + }; + let from_capacity_details: TestCapacityDetails = CapacityLedger::::get(msa_id).unwrap(); + assert_eq!(from_capacity_details, expected_from_details); +} + +fn assert_target_details(staker: u64, msa_id: MessageSourceId, amount: u64, capacity: u64) { + let expected_from_target_details: TestTargetDetails = StakingTargetDetails { amount, capacity }; + let from_target_details = StakingTargetLedger::::get(staker, msa_id).unwrap(); + assert_eq!(from_target_details, expected_from_target_details); +} + +#[test] +fn do_retarget_happy_path() { + new_test_ext().execute_with(|| { + let staker = 10_000; + let from_msa: MessageSourceId = 1; + let from_amount = 600u64; + let to_amount = 300u64; + let to_msa: MessageSourceId = 2; + let staking_type = StakingType::ProviderBoost; + setup_provider(&staker, &from_msa, &from_amount, staking_type.clone()); + setup_provider(&staker, &to_msa, &to_amount, staking_type.clone()); + + // 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 + assert_capacity_details(from_msa, 15, 300, 15); + + // expect to stake amounts to be increased by the retarget amount + assert_capacity_details(to_msa, 30, 600, 30); + + assert_target_details(staker, from_msa, 300, 15); + + assert_target_details(staker, to_msa, 600, 30); + }) +} + +#[test] +fn do_retarget_flip_flop() { + new_test_ext().execute_with(|| { + let staker = 10_000; + let from_msa: MessageSourceId = 1; + let from_amount = 600u64; + let to_amount = 300u64; + let to_msa: MessageSourceId = 2; + setup_provider(&staker, &from_msa, &from_amount, StakingType::ProviderBoost); + setup_provider(&staker, &to_msa, &to_amount, StakingType::ProviderBoost); + + for i in 0..4 { + if i % 2 == 0 { + assert_ok!(Capacity::do_retarget(&staker, &from_msa, &to_msa, &to_amount,)); + } else { + assert_ok!(Capacity::do_retarget(&staker, &to_msa, &from_msa, &to_amount,)); + } + } + assert_capacity_details(from_msa, 30, 600, 30); + assert_capacity_details(to_msa, 15, 300, 15); + }) +} + +#[test] +// check that no capacity is minted or burned just by retargeting. +fn check_retarget_rounding_errors() { + new_test_ext().execute_with(|| { + let staker = 10_000; + let from_msa: MessageSourceId = 1; + let from_amount = 666u64; + let to_amount = 301u64; + let to_msa: MessageSourceId = 2; + + setup_provider(&staker, &from_msa, &from_amount, StakingType::ProviderBoost); + setup_provider(&staker, &to_msa, &to_amount, StakingType::ProviderBoost); + assert_capacity_details(from_msa, 33, 666, 33); + assert_capacity_details(to_msa, 15, 301, 15); + // 666+301= 967, 3+1=4 + + assert_ok!(Capacity::do_retarget(&staker, &from_msa, &to_msa, &301u64)); + assert_capacity_details(from_msa, 18, 365, 18); + assert_capacity_details(to_msa, 30, 602, 30); + // 602+365 = 967, 3+1 = 4 + + assert_ok!(Capacity::do_retarget(&staker, &to_msa, &from_msa, &151u64)); + assert_capacity_details(from_msa, 26, 516, 26); + assert_capacity_details(to_msa, 22, 451, 22); + // 451+516 = 967, 2+2 = 4 + }) +} + +fn assert_total_capacity(msas: Vec, total: u64) { + let sum = msas + .into_iter() + .map(|a| { + let capacity_details: TestCapacityDetails = CapacityLedger::::get(a).unwrap(); + capacity_details.total_capacity_issued + }) + .fold(0, |a, b| a + b); + assert_eq!(total, sum); +} + +// Tests that the total stake remains the same after retargets +#[test] +fn check_retarget_multiple_stakers() { + new_test_ext().execute_with(|| { + let staker_10k = 10_000; + let staker_600 = 600u64; + let staker_500 = 500u64; + let staker_400 = 400u64; + + let from_msa: MessageSourceId = 1; + let to_msa: MessageSourceId = 2; + let amt1 = 192u64; + let amt2 = 313u64; + + setup_provider(&staker_10k, &from_msa, &647u64, StakingType::ProviderBoost); + setup_provider(&staker_500, &to_msa, &293u64, StakingType::ProviderBoost); + assert_ok!(Capacity::provider_boost( + RuntimeOrigin::signed(staker_600.clone()), + from_msa, + 479u64, + )); + assert_ok!(Capacity::provider_boost( + RuntimeOrigin::signed(staker_400.clone()), + to_msa, + 211u64, + )); + + // 647 * .10 * .5 = 32 (rounded) + // 293 * .10 * .5 = 15 (rounded) + // 479 * .10 * .5 = 24 (round) + // 211 * .10 * .5 = 10 (rounded down) + // total capacity should be sum of above + let expected_total = 81u64; + + assert_total_capacity(vec![from_msa, to_msa], expected_total); + + assert_ok!(Capacity::do_retarget(&staker_10k, &from_msa, &to_msa, &amt2)); + assert_ok!(Capacity::do_retarget(&staker_600, &from_msa, &to_msa, &amt1)); + assert_ok!(Capacity::do_retarget(&staker_500, &to_msa, &from_msa, &amt1)); + assert_ok!(Capacity::do_retarget(&staker_400, &to_msa, &from_msa, &amt1)); + assert_total_capacity(vec![from_msa, to_msa], expected_total); + }) +} + +#[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, StakingType::MaximumCapacity); + setup_provider(&staker, &to_msa, &amount, StakingType::MaximumCapacity); + + // stake additional to provider from another Msa, doesn't matter which type. + // total staked to from_msa is now 22u64. + assert_ok!(Capacity::stake(RuntimeOrigin::signed(300u64), from_msa, 12u64,)); + + 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 = + CapacityLedger::::get(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 = CapacityLedger::::get(to_msa).unwrap(); + assert_eq!(to_capacity_details, expected_to_details); + + assert!(StakingTargetLedger::::get(staker, from_msa).is_none()); + + let expected_to_target_details: TestTargetDetails = + StakingTargetDetails { amount: 2 * amount, capacity: 2 }; + let to_target_details = StakingTargetLedger::::get(staker, to_msa).unwrap(); + assert_eq!(to_target_details, expected_to_target_details); + + assert!(StakingTargetLedger::::get(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, StakingType::ProviderBoost); + setup_provider(&staker, &to_msa, &to_amount, StakingType::ProviderBoost); + + assert_ok!(Capacity::change_staking_target( + RuntimeOrigin::signed(staker), + from_msa, + to_msa, + to_amount + )); + let events = capacity_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 = ::MaxRetargetsPerRewardEra::get(); + let staking_amount = ((max_chunks + 2u32) * 10u32) as u64; + setup_provider(&staker, &from_msa, &staking_amount, StakingType::ProviderBoost); + setup_provider(&staker, &to_msa, &10u64, StakingType::ProviderBoost); + + let retarget_amount = 10u64; + for _i in 0..max_chunks { + 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::::MaxRetargetsExceeded + ); + }); +} + +#[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, StakingType::ProviderBoost); + setup_provider(&staking_account, &to_target, &staked_amount, StakingType::ProviderBoost); + + CurrentEraInfo::::set(RewardEraInfo { era_index: 20, started_at: 100 }); + let max_chunks = ::MaxUnlockingChunks::get(); + for _i in 0..max_chunks { + 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, + StakingDetails { active: 20, staking_type: StakingType::ProviderBoost }, + ); + let from_account_not_staking = 100u64; + let from_target_not_staked: MessageSourceId = 1; + let to_target_not_provider: MessageSourceId = 2; + let from_target: MessageSourceId = 3; + let to_target: MessageSourceId = 4; + setup_provider(&from_account, &from_target_not_staked, &0u64, StakingType::ProviderBoost); + setup_provider(&from_account, &from_target, &staked_amount, StakingType::ProviderBoost); + setup_provider(&from_account, &to_target, &staked_amount, StakingType::ProviderBoost); + + assert_ok!(Capacity::provider_boost( + RuntimeOrigin::signed(from_account), + from_target, + staked_amount, + )); + + 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::::StakerTargetRelationshipNotFound, + }, + // 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, + }, + TestCase { + from_account, + from_target, + to_target: from_target, + retarget_amount: 999, + expected_err: Error::::CannotRetargetToSameProvider, + }, + ]; + + 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 + ); + } + }); +} + +#[test] +fn impl_retarget_info_errors_when_attempt_to_update_past_bounded_max() { + new_test_ext().execute_with(|| { + struct TestCase { + era: u32, + retargets: u32, + last_retarget: u32, + expected: Option<()>, + } + for tc in [ + TestCase { era: 1u32, retargets: 0u32, last_retarget: 1, expected: Some(()) }, + TestCase { era: 1u32, retargets: 1u32, last_retarget: 1, expected: Some(()) }, + TestCase { era: 1u32, retargets: 1u32, last_retarget: 3, expected: Some(()) }, + TestCase { era: 1u32, retargets: 1u32, last_retarget: 4, expected: Some(()) }, + TestCase { era: 2u32, retargets: 5u32, last_retarget: 1, expected: Some(()) }, + TestCase { era: 1u32, retargets: 5u32, last_retarget: 1, expected: None }, + ] { + let mut retarget_info: RetargetInfo = + RetargetInfo::new(tc.retargets, tc.last_retarget); + assert_eq!(retarget_info.update(tc.era), tc.expected); + } + }) +} + +#[test] +fn impl_retarget_info_updates_values_correctly() { + new_test_ext().execute_with(|| { + struct TestCase { + era: u32, + retargets: u32, + last_retarget: u32, + expected_retargets: u32, + } + for tc in [ + TestCase { era: 5, retargets: 0, last_retarget: 5, expected_retargets: 1 }, + TestCase { era: 1, retargets: 1, last_retarget: 1, expected_retargets: 2 }, + TestCase { era: 3, retargets: 1, last_retarget: 1, expected_retargets: 1 }, + TestCase { era: 2, retargets: 5, last_retarget: 1, expected_retargets: 1 }, + TestCase { era: 1, retargets: 5, last_retarget: 1, expected_retargets: 5 }, + ] { + let mut retarget_info: RetargetInfo = + RetargetInfo::new(tc.retargets, tc.last_retarget); + retarget_info.update(tc.era); + assert_eq!(retarget_info.retarget_count, tc.expected_retargets); + } + }) +} + +#[test] +fn impl_retarget_chunks_cleanup_when_new_reward_era() { + new_test_ext().execute_with(|| { + let current_era = 2u32; + let mut retarget_info: RetargetInfo = RetargetInfo::new(5, 1); + assert!(retarget_info.update(current_era).is_some()); + let expected: RetargetInfo = RetargetInfo::new(1, 2); + assert_eq!(retarget_info, expected); + }); +} diff --git a/pallets/capacity/src/tests/claim_staking_rewards_tests.rs b/pallets/capacity/src/tests/claim_staking_rewards_tests.rs new file mode 100644 index 0000000000..d66d8dbf29 --- /dev/null +++ b/pallets/capacity/src/tests/claim_staking_rewards_tests.rs @@ -0,0 +1,158 @@ +use crate::{ + tests::{ + mock::{ + assert_transferable, get_balance, new_test_ext, Capacity, RuntimeOrigin, System, Test, + }, + testing_utils::{run_to_block, setup_provider}, + }, + CurrentEraInfo, Error, Event, + Event::ProviderBoostRewardClaimed, + MessageSourceId, ProviderBoostHistories, + StakingType::*, +}; +use frame_support::{assert_noop, assert_ok}; + +#[test] +fn claim_staking_rewards_leaves_one_history_item_for_current_era() { + new_test_ext().execute_with(|| { + let account = 10_000u64; + let target: MessageSourceId = 10; + let amount = 1_000u64; + + setup_provider(&account, &target, &amount, ProviderBoost); + run_to_block(31); + assert_eq!(CurrentEraInfo::::get().era_index, 3u32); + + let current_history = ProviderBoostHistories::::get(account).unwrap(); + assert_eq!(current_history.count(), 1usize); + let history_item = current_history.get_entry_for_era(&0u32).unwrap(); + assert_eq!(*history_item, amount); + }) +} + +#[test] +fn claim_staking_rewards_allows_unstake() { + new_test_ext().execute_with(|| { + let account = 10_000u64; + let target: MessageSourceId = 10; + let amount = 1_000u64; + + setup_provider(&account, &target, &amount, ProviderBoost); + run_to_block(31); + assert_noop!( + Capacity::unstake(RuntimeOrigin::signed(account), target, 100u64), + Error::::MustFirstClaimRewards + ); + assert_ok!(Capacity::claim_staking_rewards(RuntimeOrigin::signed(account))); + assert_ok!(Capacity::unstake(RuntimeOrigin::signed(account), target, 400u64)); + }) +} + +#[test] +fn claim_staking_rewards_mints_and_transfers_expected_total() { + new_test_ext().execute_with(|| { + let account = 10_000u64; + let target: MessageSourceId = 10; + let amount = 1_000u64; + + setup_provider(&account, &target, &amount, ProviderBoost); + run_to_block(31); + assert_eq!(CurrentEraInfo::::get().era_index, 3u32); + assert_ok!(Capacity::claim_staking_rewards(RuntimeOrigin::signed(account))); + System::assert_last_event( + Event::::ProviderBoostRewardClaimed { account, reward_amount: 8u64 }.into(), + ); + + // should have 2 era's worth of payouts: 4 each for eras 1, 2 + assert_eq!(get_balance::(&account), 10_008u64); + + // the reward value is unlocked + assert_transferable::(&account, 8u64); + + run_to_block(41); + assert_eq!(CurrentEraInfo::::get().era_index, 4u32); + assert_ok!(Capacity::claim_staking_rewards(RuntimeOrigin::signed(account))); + // rewards available for one more era + System::assert_last_event( + ProviderBoostRewardClaimed { account, reward_amount: 4u64 }.into(), + ); + // should have 4 for eras 1-3 + assert_eq!(get_balance::(&account), 10_012u64); + assert_transferable::(&account, 12u64); + }) +} + +#[test] +fn claim_staking_rewards_has_expected_total_when_other_stakers() { + new_test_ext().execute_with(|| { + let account = 10_000u64; + let target: MessageSourceId = 10; + let amount = 1_000u64; + + let other_staker = 600_u64; + let other_amount = 300_u64; + setup_provider(&other_staker, &target, &other_amount, ProviderBoost); + assert_ok!(Capacity::provider_boost(RuntimeOrigin::signed(account), target, amount)); + run_to_block(31); // 4 + assert_ok!(Capacity::claim_staking_rewards(RuntimeOrigin::signed(account))); + assert_eq!(get_balance::(&account), 10_008u64); + }) +} + +#[test] +fn claim_staking_rewards_has_expected_total_if_amount_should_be_capped() { + new_test_ext().execute_with(|| { + let account = 10_000u64; + let target: MessageSourceId = 10; + let amount = 9_900u64; + setup_provider(&account, &target, &amount, ProviderBoost); + run_to_block(31); + assert_ok!(Capacity::claim_staking_rewards(RuntimeOrigin::signed(account))); + assert_eq!(get_balance::(&account), 10_076u64); // cap is 0.38%,*2 = 76, rounded + assert_transferable::(&account, 76u64); + }) +} + +#[test] +fn claim_staking_rewards_fails_if_no_available_rewards() { + new_test_ext().execute_with(|| { + let account = 10_000u64; + let target: MessageSourceId = 10; + let amount = 1_000u64; + + setup_provider(&account, &target, &amount, ProviderBoost); + // Unclaimed rewards should have zero length b/c it's still Era 1. + // Nothing will be in history + assert_noop!( + Capacity::claim_staking_rewards(RuntimeOrigin::signed(account)), + Error::::NoRewardsEligibleToClaim + ); + + run_to_block(15); // Era is 2, but still not available for staking rewards until era 3. + assert_noop!( + Capacity::claim_staking_rewards(RuntimeOrigin::signed(account)), + Error::::NoRewardsEligibleToClaim + ); + }) +} + +#[test] +fn claim_staking_rewards_fails_if_stake_maximized() { + new_test_ext().execute_with(|| { + let account = 10_000u64; + let target: MessageSourceId = 10; + let amount = 1_000u64; + + assert_noop!( + Capacity::claim_staking_rewards(RuntimeOrigin::signed(account)), + Error::::NotAProviderBoostAccount + ); + + setup_provider(&account, &target, &amount, MaximumCapacity); + + assert_noop!( + Capacity::claim_staking_rewards(RuntimeOrigin::signed(account)), + Error::::NotAProviderBoostAccount + ); + }) +} diff --git a/pallets/capacity/src/tests/eras_tests.rs b/pallets/capacity/src/tests/eras_tests.rs new file mode 100644 index 0000000000..b1d2d1de7d --- /dev/null +++ b/pallets/capacity/src/tests/eras_tests.rs @@ -0,0 +1,218 @@ +use super::mock::*; +use crate::{ + tests::testing_utils::*, BalanceOf, Config, CurrentEraInfo, CurrentEraProviderBoostTotal, + ProviderBoostRewardPools, RewardEraInfo, +}; +use common_primitives::{capacity::RewardEra, msa::MessageSourceId}; +use frame_support::{assert_ok, traits::Get}; + +pub fn boost_provider_and_run_to_end_of_era( + staker: u64, + provider_msa: MessageSourceId, + stake_amount: u64, + era: u32, +) { + assert_ok!(Capacity::provider_boost(RuntimeOrigin::signed(staker), provider_msa, stake_amount)); + // want to run to the first block of era so we get the on_initialize updates + let first_block_of_era: u32 = ((era + 1u32) * 10) + 1; + run_to_block(first_block_of_era); + let expected_stake = stake_amount * ((era + 1u32) as u64); + assert_eq!(CurrentEraProviderBoostTotal::::get(), expected_stake); + // system_run to end of era. we don't need the on_initialize updates + let era_length: RewardEra = ::EraLength::get(); + system_run_to_block(first_block_of_era + era_length - 1); +} + +#[test] +fn start_new_era_if_needed_updates_era_info() { + new_test_ext().execute_with(|| { + system_run_to_block(9); + for i in 1..=4 { + let block_decade = (i * 10) + 1; + run_to_block(block_decade); + + let current_era_info = CurrentEraInfo::::get(); + + let expected_era = i; + assert_eq!( + current_era_info, + RewardEraInfo { era_index: expected_era, started_at: block_decade } + ); + system_run_to_block(block_decade + 9); + } + }) +} + +// checks that the chunk indicated has an earliest era matching `era`, +// asserts whether it is full (or not) and asserts that its total stored matches `total` +fn assert_chunk_is_full_and_has_earliest_era_total( + chunk_index: u32, + is_full: bool, + era: RewardEra, + total: BalanceOf, +) { + let chunk = ProviderBoostRewardPools::::get(chunk_index).unwrap(); + assert_eq!(chunk.is_full(), is_full, "Chunk {:?} should be full: {:?}", chunk_index, chunk); + assert_eq!(chunk.earliest_era(), Some(&era), "Earliest era should be {:?}, {:?}", era, chunk); + assert_eq!( + chunk.total_for_era(&era), + Some(&total), + "Chunk total should be {:?}, {:?}", + total, + chunk + ); +} + +// gets the last (i.e. latest non-current) stored reward pool era, which is in chunk 0. +// asserts that it is the same as `era`, and that it has amount `total` +fn assert_last_era_total(era: RewardEra, total: BalanceOf) { + let chunk_idx = Capacity::get_chunk_index_for_era(era); + let chunk_opt = ProviderBoostRewardPools::::get(chunk_idx); + assert!(chunk_opt.is_some(), "No pool for Era: {:?} with chunk index: {:?}", era, chunk_idx); + let chunk = chunk_opt.unwrap(); + let (_earliest, latest) = chunk.era_range(); + assert_eq!(latest, era); + assert_eq!( + chunk.total_for_era(&era), + Some(&total), + "Chunk total should be {:?}, {:?}", + total, + chunk + ); +} + +fn assert_chunk_is_empty(chunk_index: u32) { + let chunk_opt = ProviderBoostRewardPools::::get(chunk_index); + if chunk_opt.is_some() { + let chunk = chunk_opt.unwrap(); + assert!( + chunk.earliest_era().is_none(), + "Earliest era for chunk {:?} should be None but it is {:?}", + chunk_index, + chunk + ) + } else { + assert!(chunk_opt.is_none(), "Expected chunk {:?} to be empty, but it's not", chunk_index); + } +} + +// Test that additional stake is carried over to the next era's RewardPoolInfo. +#[test] +fn start_new_era_if_needed_updates_reward_pool() { + new_test_ext().execute_with(|| { + system_run_to_block(8); + let staker = 10_000; + let provider_msa: MessageSourceId = 1; + let stake_amount = 100u64; + + register_provider(provider_msa, "Binky".to_string()); + + for i in 0u32..=2u32 { + boost_provider_and_run_to_end_of_era(staker, provider_msa, stake_amount, i); + } + // check that first chunk is filled correctly. + assert_chunk_is_full_and_has_earliest_era_total(0, true, 0, 100); + for i in 1u32..=4u32 { + assert_chunk_is_empty(i); + } + + for i in [3u32, 4u32, 5u32] { + boost_provider_and_run_to_end_of_era(staker, provider_msa, stake_amount, i); + } + // No change + assert_chunk_is_full_and_has_earliest_era_total(0, true, 0, 100); + // New Chunk + assert_chunk_is_full_and_has_earliest_era_total(1, true, 3, 400); + assert_chunk_is_empty(2); + assert_last_era_total(5, 600); + + for i in [6u32, 7u32, 8u32, 9u32, 10u32, 11u32, 12u32, 13u32, 14u32] { + boost_provider_and_run_to_end_of_era(staker, provider_msa, stake_amount, i); + } + // No changes + assert_chunk_is_full_and_has_earliest_era_total(0, true, 0, 100); + assert_chunk_is_full_and_has_earliest_era_total(1, true, 3, 400); + // New + assert_chunk_is_full_and_has_earliest_era_total(2, true, 6, 700); + assert_chunk_is_full_and_has_earliest_era_total(3, true, 9, 1000); + assert_chunk_is_full_and_has_earliest_era_total(4, true, 12, 1300); + assert_last_era_total(14, 1500); + + // check that it all rolls over properly. + for i in [15u32, 16u32] { + boost_provider_and_run_to_end_of_era(staker, provider_msa, stake_amount, i); + } + // No Changes in chunk content for these eras + assert_chunk_is_full_and_has_earliest_era_total(1, true, 3, 400); + assert_chunk_is_full_and_has_earliest_era_total(2, true, 6, 700); + assert_chunk_is_full_and_has_earliest_era_total(3, true, 9, 1000); + assert_chunk_is_full_and_has_earliest_era_total(4, true, 12, 1300); + // New + assert_chunk_is_full_and_has_earliest_era_total(0, false, 15, 1600); + assert_last_era_total(16, 1700); + // There shouldn't be a chunk 5 ever with this config + assert_chunk_is_empty(5); + }); +} + +#[test] +fn get_expiration_block_for_era_works() { + new_test_ext().execute_with(|| { + assert_eq!(CurrentEraInfo::::get().era_index, 0u32); + assert_eq!(Capacity::block_at_end_of_era(9u32), 100); + + set_era_and_reward_pool(7, 63, 0); + assert_eq!(Capacity::block_at_end_of_era(7u32), 72); + assert_eq!(Capacity::block_at_end_of_era(10u32), 102); + + set_era_and_reward_pool(10, 91, 0); + assert_eq!(Capacity::block_at_end_of_era(10u32), 100); + + set_era_and_reward_pool(99, 2342, 0); + assert_eq!(Capacity::block_at_end_of_era(99), 2351); + + assert_eq!(Capacity::block_at_end_of_era(108), 2441); + }) +} + +#[test] +fn get_chunk_index_for_era_works() { + new_test_ext().execute_with(|| { + #[derive(Debug)] + struct TestCase { + era: u32, + expected: u32, + } + // assuming history limit is 12, chunk length is 3 + // [1,2,3],[4,5,6],[7,8,9],[10,11,12],[13,14,15] + // [16],[4,5,6],[7,8,9],[10,11,12],[13,14,15] + for test in { + vec![ + TestCase { era: 0, expected: 0 }, + TestCase { era: 1, expected: 0 }, + TestCase { era: 2, expected: 0 }, + TestCase { era: 3, expected: 1 }, + TestCase { era: 4, expected: 1 }, + TestCase { era: 5, expected: 1 }, + TestCase { era: 6, expected: 2 }, + TestCase { era: 7, expected: 2 }, + TestCase { era: 8, expected: 2 }, + TestCase { era: 9, expected: 3 }, + TestCase { era: 10, expected: 3 }, + TestCase { era: 11, expected: 3 }, + TestCase { era: 12, expected: 4 }, // This is not wrong; there is an extra chunk to leave space for cycling + TestCase { era: 13, expected: 4 }, + TestCase { era: 14, expected: 4 }, + TestCase { era: 15, expected: 0 }, // So cycle restarts here, not at 12. + TestCase { era: 16, expected: 0 }, + TestCase { era: 17, expected: 0 }, + TestCase { era: 18, expected: 1 }, + TestCase { era: 22, expected: 2 }, + TestCase { era: 55, expected: 3 }, + TestCase { era: 998, expected: 2 }, + ] + } { + assert_eq!(Capacity::get_chunk_index_for_era(test.era), test.expected, "{:?}", test); + } + }) +} diff --git a/pallets/capacity/src/tests/mock.rs b/pallets/capacity/src/tests/mock.rs index 70687a9c1f..00c793445e 100644 --- a/pallets/capacity/src/tests/mock.rs +++ b/pallets/capacity/src/tests/mock.rs @@ -1,19 +1,27 @@ use crate as pallet_capacity; +use crate::{ + tests::testing_utils::set_era_and_reward_pool, BalanceOf, Config, ProviderBoostRewardPools, + ProviderBoostRewardsProvider, RewardPoolHistoryChunk, STAKED_PERCENTAGE_TO_BOOST, +}; use common_primitives::{ node::{AccountId, ProposalProvider}, schema::{SchemaId, SchemaValidator}, }; use frame_support::{ construct_runtime, parameter_types, - traits::{ConstU16, ConstU32, ConstU64}, + traits::{ + tokens::{fungible::Inspect, WithdrawConsequence}, + ConstU16, ConstU32, ConstU64, + }, }; use frame_system::EnsureSigned; use sp_core::{ConstU8, H256}; use sp_runtime::{ - traits::{BlakeTwo256, Convert, IdentityLookup}, - AccountId32, BuildStorage, DispatchError, Perbill, + traits::{BlakeTwo256, Convert, Get, IdentityLookup}, + AccountId32, BuildStorage, DispatchError, Perbill, Permill, }; +use sp_std::ops::Mul; type Block = frame_system::mocking::MockBlockU32; @@ -134,9 +142,35 @@ impl pallet_msa::Config for Test { type MaxSignaturesStored = ConstU32<8000>; } +// not used yet +pub struct TestRewardsProvider {} + +impl ProviderBoostRewardsProvider for TestRewardsProvider { + type Balance = BalanceOf; + + // To reflect new economic model behavior of having a constant RewardPool amount. + fn reward_pool_size(_total_staked: Self::Balance) -> Self::Balance { + 10_000u64.into() + } + + // use the pallet version of the era calculation. + fn era_staking_reward( + amount_staked: Self::Balance, + total_staked: Self::Balance, + reward_pool_size: Self::Balance, + ) -> Self::Balance { + Capacity::era_staking_reward(amount_staked, total_staked, reward_pool_size) + } + + fn capacity_boost(amount: Self::Balance) -> Self::Balance { + Perbill::from_percent(STAKED_PERCENTAGE_TO_BOOST).mul(amount) + } +} + // Needs parameter_types! for the Perbill parameter_types! { pub const TestCapacityPerToken: Perbill = Perbill::from_percent(10); + pub const TestRewardCap: Permill = Permill::from_parts(3_800); // 0.38% or 0.0038 per RewardEra } impl pallet_capacity::Config for Test { type RuntimeEvent = RuntimeEvent; @@ -156,6 +190,29 @@ impl pallet_capacity::Config for Test { type MaxEpochLength = ConstU32<100>; type EpochNumber = u32; type CapacityPerToken = TestCapacityPerToken; + type EraLength = ConstU32<10>; + type ProviderBoostHistoryLimit = ConstU32<12>; + type RewardsProvider = Capacity; + type MaxRetargetsPerRewardEra = ConstU32<5>; + type RewardPoolPerEra = ConstU64<10_000>; + type RewardPercentCap = TestRewardCap; + type RewardPoolChunkLength = ConstU32<3>; +} + +fn initialize_reward_pool() { + let history_limit: u32 = ::ProviderBoostHistoryLimit::get(); + let chunks = history_limit.saturating_div(::RewardPoolChunkLength::get()); + for i in 0u32..chunks { + ProviderBoostRewardPools::::insert(i, RewardPoolHistoryChunk::::new()) + } +} + +pub fn get_balance(who: &T::AccountId) -> BalanceOf { + T::Currency::balance(who) +} + +pub fn assert_transferable(who: &T::AccountId, amount: BalanceOf) { + assert_eq!(T::Currency::can_withdraw(who, amount), WithdrawConsequence::Success); } pub fn new_test_ext() -> sp_io::TestExternalities { @@ -176,6 +233,10 @@ pub fn new_test_ext() -> sp_io::TestExternalities { .unwrap(); let mut ext = sp_io::TestExternalities::new(t); - ext.execute_with(|| System::set_block_number(1)); + ext.execute_with(|| { + System::set_block_number(1); + initialize_reward_pool(); + set_era_and_reward_pool(0, 1, 0); + }); ext } diff --git a/pallets/capacity/src/tests/mod.rs b/pallets/capacity/src/tests/mod.rs index 78d2469203..a8382fa6a1 100644 --- a/pallets/capacity/src/tests/mod.rs +++ b/pallets/capacity/src/tests/mod.rs @@ -1,8 +1,15 @@ pub mod capacity_details_tests; +mod change_staking_target_tests; +mod claim_staking_rewards_tests; pub mod epochs_tests; +mod eras_tests; pub mod mock; pub mod other_tests; +mod provider_boost_history_tests; +mod provider_boost_tests; pub mod replenishment_tests; +mod reward_pool_tests; +mod rewards_provider_tests; pub mod stake_and_deposit_tests; pub mod staking_account_details_tests; pub mod staking_target_details_tests; diff --git a/pallets/capacity/src/tests/other_tests.rs b/pallets/capacity/src/tests/other_tests.rs index f702e3e9da..d2ff8480f7 100644 --- a/pallets/capacity/src/tests/other_tests.rs +++ b/pallets/capacity/src/tests/other_tests.rs @@ -193,13 +193,13 @@ fn calculate_capacity_reduction_determines_the_correct_capacity_reduction_amount fn impl_balance_is_successful() { new_test_ext().execute_with(|| { let target_msa_id = 1; - let remaining_amount = 10u32; - let total_available_amount = 10u32; + let remaining_amount = 10u64; + let total_available_amount = 10u64; let _ = create_capacity_account_and_fund( target_msa_id, - remaining_amount.into(), - total_available_amount.into(), + remaining_amount, + total_available_amount, 1u32, ); diff --git a/pallets/capacity/src/tests/provider_boost_history_tests.rs b/pallets/capacity/src/tests/provider_boost_history_tests.rs new file mode 100644 index 0000000000..ebd12ff05a --- /dev/null +++ b/pallets/capacity/src/tests/provider_boost_history_tests.rs @@ -0,0 +1,133 @@ +use crate::{ + tests::{ + mock::{new_test_ext, Capacity, RuntimeOrigin, Test}, + testing_utils::{run_to_block, setup_provider, system_run_to_block}, + }, + Config, ProviderBoostHistories, ProviderBoostHistory, + StakingType::{MaximumCapacity, ProviderBoost}, +}; +use common_primitives::capacity::RewardEra; +use frame_support::assert_ok; +use sp_runtime::traits::{Get, Zero}; + +#[test] +fn provider_boost_adds_to_staking_history() { + new_test_ext().execute_with(|| { + let staker = 10_000u64; + let target = 1u64; + + setup_provider(&staker, &target, &1_000u64, ProviderBoost); + let history = ProviderBoostHistories::::get(staker); + assert!(history.is_some()); + }) +} + +#[test] +fn multiple_provider_boosts_updates_history_correctly() { + new_test_ext().execute_with(|| { + let staker = 10_000u64; + let target = 1u64; + setup_provider(&staker, &target, &500u64, ProviderBoost); + + assert_ok!(Capacity::provider_boost(RuntimeOrigin::signed(staker), target, 200)); + + // should update era 1 history + let mut history = ProviderBoostHistories::::get(staker).unwrap(); + assert_eq!(history.count(), 1); + assert_eq!(history.get_entry_for_era(&0u32).unwrap(), &700u64); + + system_run_to_block(10); + run_to_block(11); + + assert_ok!(Capacity::provider_boost(RuntimeOrigin::signed(staker), target, 200)); + + // should add an era 2 history + history = ProviderBoostHistories::::get(staker).unwrap(); + assert_eq!(history.count(), 2); + assert_eq!(history.get_entry_for_era(&1u32).unwrap(), &900u64); + }) +} + +#[test] +fn stake_does_not_add_to_staking_history() { + new_test_ext().execute_with(|| { + let staker = 10_000u64; + let target = 1u64; + + setup_provider(&staker, &target, &1_000u64, MaximumCapacity); + let history = ProviderBoostHistories::::get(staker); + assert!(history.is_none()); + }) +} + +#[test] +fn provider_boost_history_add_era_balance_adds_entries_and_deletes_old_if_full() { + let mut pbh = ProviderBoostHistory::::new(); + let bound: u32 = ::ProviderBoostHistoryLimit::get(); + + for era in 1..bound + 1u32 { + let add_amount: u64 = 1_000 * era as u64; + pbh.add_era_balance(&era, &add_amount); + assert_eq!(pbh.count(), era as usize); + } + assert_eq!(pbh.count(), bound as usize); + + let new_era: RewardEra = 99; + pbh.add_era_balance(&new_era, &1000u64); + assert_eq!(pbh.count(), bound as usize); + assert!(pbh.get_entry_for_era(&new_era).is_some()); + + let first_era: RewardEra = 1; + assert!(pbh.get_entry_for_era(&first_era).is_none()); +} + +#[test] +fn provider_boost_history_add_era_balance_in_same_era_updates_entry() { + let mut pbh = ProviderBoostHistory::::new(); + let add_amount: u64 = 1_000u64; + let era = 2u32; + pbh.add_era_balance(&era, &add_amount); + pbh.add_era_balance(&era, &add_amount); + assert_eq!(pbh.get_entry_for_era(&era).unwrap(), &2_000u64) +} + +#[test] +fn provider_boost_history_subtract_era_balance_in_same_era_updates_entry() { + let mut pbh = ProviderBoostHistory::::new(); + let era = 2u32; + pbh.add_era_balance(&era, &1000u64); + pbh.subtract_era_balance(&era, &300u64); + assert_eq!(pbh.get_entry_for_era(&(era)).unwrap(), &700u64); +} + +#[test] +fn provider_boost_history_add_era_balance_in_new_era_correctly_adds_value() { + let mut pbh = ProviderBoostHistory::::new(); + let add_amount: u64 = 1_000u64; + let era = 2u32; + pbh.add_era_balance(&era, &add_amount); + pbh.add_era_balance(&(era + 1), &add_amount); + assert_eq!(pbh.get_entry_for_era(&(era + 1)).unwrap(), &2_000u64) +} + +#[test] +fn provider_boost_history_subtract_era_balance_in_new_era_correctly_subtracts_value() { + let mut pbh = ProviderBoostHistory::::new(); + let era = 2u32; + pbh.add_era_balance(&era, &1000u64); + + pbh.subtract_era_balance(&(era + 1), &400u64); + assert_eq!(pbh.get_entry_for_era(&(era + 1)).unwrap(), &600u64); + + pbh.subtract_era_balance(&(era + 2), &600u64); + assert!(pbh.get_entry_for_era(&(era + 2)).unwrap().is_zero()); +} + +#[test] +fn provider_boost_history_subtract_all_balance_on_only_entry_returns_some_0() { + let mut pbh = ProviderBoostHistory::::new(); + let era = 22u32; + let amount = 1000u64; + pbh.add_era_balance(&era, &amount); + assert_eq!(pbh.subtract_era_balance(&(era), &amount), Some(0usize)); +} diff --git a/pallets/capacity/src/tests/provider_boost_tests.rs b/pallets/capacity/src/tests/provider_boost_tests.rs new file mode 100644 index 0000000000..e30871d533 --- /dev/null +++ b/pallets/capacity/src/tests/provider_boost_tests.rs @@ -0,0 +1,108 @@ +use super::{mock::*, testing_utils::*}; +use crate::{ + CapacityLedger, Config, CurrentEraProviderBoostTotal, Error, Event, FreezeReason, + StakingAccountLedger, StakingDetails, StakingTargetLedger, StakingType, +}; +use common_primitives::msa::MessageSourceId; +use frame_support::{assert_noop, assert_ok, traits::fungible::InspectFreeze}; + +#[test] +fn provider_boost_works() { + new_test_ext().execute_with(|| { + let account = 600; + let target: MessageSourceId = 1; + let amount = 200; + let capacity = 10; // Maximized stake (10% of staked amount) * 50% (in trait impl) + register_provider(target, String::from("Foo")); + assert_ok!(Capacity::provider_boost(RuntimeOrigin::signed(account), target, amount)); + + // Check that StakingAccountLedger is updated. + let boost_account: StakingDetails = + StakingAccountLedger::::get(account).unwrap(); + + // Check that the staking account has the correct staking type. + assert_eq!(boost_account.active, 200); + assert_eq!(boost_account.staking_type, StakingType::ProviderBoost); + + // Check that the capacity generated is correct. (5% of amount staked, since 10% is what's in the mock) + let capacity_details = CapacityLedger::::get(target).unwrap(); + assert_eq!(capacity_details.total_capacity_issued, capacity); + + let events = capacity_events(); + assert_eq!( + events.first().unwrap(), + &Event::ProviderBoosted { account, target, amount, capacity } + ); + + assert_eq!( + ::Currency::balance_frozen( + &FreezeReason::CapacityStaking.into(), + &account + ), + 200u64 + ); + + let target_details = StakingTargetLedger::::get(account, target).unwrap(); + assert_eq!(target_details.amount, amount); + }); +} + +#[test] +fn provider_boost_updates_reward_pool_history() { + // two accounts staking to the same target + new_test_ext().execute_with(|| { + let account1 = 600; + let account2 = 500; + let target: MessageSourceId = 1; + let amount1 = 500; + let amount2 = 200; + register_provider(target, String::from("Foo")); + + assert_ok!(Capacity::provider_boost(RuntimeOrigin::signed(account1), target, amount1)); + assert_ok!(Capacity::provider_boost(RuntimeOrigin::signed(account2), target, amount2)); + assert_eq!(CurrentEraProviderBoostTotal::::get(), 700u64); + }); +} + +#[test] +fn provider_boost_updates_staking_details() { + new_test_ext().execute_with(|| { + let account = 600; + let target: MessageSourceId = 1; + let amount = 500; + register_provider(target, String::from("Foo")); + assert_ok!(Capacity::provider_boost(RuntimeOrigin::signed(account), target, amount)); + let boost_details: StakingDetails = + StakingAccountLedger::::get(account).unwrap(); + assert_eq!(boost_details.active, 500); + }) +} + +#[test] +fn calling_stake_on_provider_boost_target_errors() { + new_test_ext().execute_with(|| { + let account = 600; + let target: MessageSourceId = 1; + let amount = 200; + register_provider(target, String::from("Bear")); + assert_ok!(Capacity::provider_boost(RuntimeOrigin::signed(account), target, amount)); + assert_noop!( + Capacity::stake(RuntimeOrigin::signed(account), target, 50), + Error::::CannotChangeStakingType + ); + }) +} +#[test] +fn calling_provider_boost_on_staked_target_errors() { + new_test_ext().execute_with(|| { + let account = 600; + let target: MessageSourceId = 1; + let amount = 200; + register_provider(target, String::from("Foobear")); + assert_ok!(Capacity::stake(RuntimeOrigin::signed(account), target, amount)); + assert_noop!( + Capacity::provider_boost(RuntimeOrigin::signed(account), target, 50), + Error::::CannotChangeStakingType + ); + }) +} diff --git a/pallets/capacity/src/tests/replenishment_tests.rs b/pallets/capacity/src/tests/replenishment_tests.rs index 24b6cb60dc..3f3dfafe49 100644 --- a/pallets/capacity/src/tests/replenishment_tests.rs +++ b/pallets/capacity/src/tests/replenishment_tests.rs @@ -63,7 +63,7 @@ fn impl_can_replenish_is_false_when_last_replenished_at_is_greater_or_equal_curr assert_eq!(Capacity::can_replenish(target_msa_id), false); - CurrentEpoch::::set(1u32.into()); + CurrentEpoch::::set(1); assert_eq!(Capacity::can_replenish(target_msa_id), false); }); @@ -84,7 +84,7 @@ fn impl_can_replenish_is_true_when_last_replenished_at_is_less_than_current_epoc last_replenished_at, ); - CurrentEpoch::::set(3u32.into()); + CurrentEpoch::::set(3); assert_eq!(Capacity::can_replenish(target_msa_id), true); }); diff --git a/pallets/capacity/src/tests/reward_pool_tests.rs b/pallets/capacity/src/tests/reward_pool_tests.rs new file mode 100644 index 0000000000..849c6475ea --- /dev/null +++ b/pallets/capacity/src/tests/reward_pool_tests.rs @@ -0,0 +1,116 @@ +use crate::{ + tests::{mock::*, testing_utils::set_era_and_reward_pool}, + BalanceOf, Config, ProviderBoostRewardPools, RewardPoolHistoryChunk, +}; +use common_primitives::capacity::RewardEra; +use frame_support::{assert_ok, traits::Get}; +use std::ops::Add; + +// Check eras_tests for how reward pool chunks are expected to be filled during runtime. + +// Fill up a reward pool history chunk by adding 100 in each new era from the starting value. +fn fill_reward_pool_history_chunk( + chunk_index: u32, + starting_era: RewardEra, + number_of_items: u32, + starting_total_stake: BalanceOf, +) { + let mut chunk: RewardPoolHistoryChunk = RewardPoolHistoryChunk::new(); + for i in 0u32..number_of_items { + let additional_stake: BalanceOf = (i * 100u32).into(); + let total_stake: BalanceOf = starting_total_stake.add(additional_stake); + let era = starting_era + i; + assert_ok!(chunk.try_insert(era, total_stake)); + } + ProviderBoostRewardPools::set(chunk_index, Some(chunk)); +} + +#[test] +fn reward_pool_history_chunk_default_tests() { + let chunk: RewardPoolHistoryChunk = RewardPoolHistoryChunk::new(); + assert!(!chunk.is_full()); + assert!(chunk.total_for_era(&0u32).is_none()); + let default: RewardPoolHistoryChunk = RewardPoolHistoryChunk::default(); + assert!(default.total_for_era(&032).is_none()); + assert_eq!(default.era_range(), (0u32, 0u32)); +} + +#[test] +fn reward_pool_history_chunk_insert_range_remove() { + let mut chunk: RewardPoolHistoryChunk = RewardPoolHistoryChunk::new(); + assert_eq!(chunk.try_insert(22u32, 100u64), Ok(None)); + assert_eq!(chunk.try_insert(22u32, 110u64), Ok(Some(100u64))); + assert!(chunk.try_insert(23u32, 123u64).is_ok()); + assert!(chunk.try_insert(24u32, 99u64).is_ok()); + // For the limit is 3 + assert_eq!(chunk.try_insert(25u32, 99u64), Err((25u32, 99u64))); + assert_eq!(chunk.total_for_era(&23u32), Some(&123u64)); + assert_eq!(chunk.era_range(), (22u32, 24u32)); +} + +// Check boundary behavior for the first reward eras before hitting maximum history, +// then check behavior once the reward pool chunks are always full. +#[test] +fn get_total_stake_for_past_era_works_with_partly_filled_single_chunk() { + new_test_ext().execute_with(|| { + set_era_and_reward_pool(3, 21, 1000); + System::set_block_number(22); + fill_reward_pool_history_chunk(0, 1, 2, 100); + assert_eq!(Capacity::get_total_stake_for_past_era(1, 3), Ok(100)); + assert_eq!(Capacity::get_total_stake_for_past_era(2, 3), Ok(200)); + assert!(Capacity::get_total_stake_for_past_era(3, 3).is_err()); + assert!(Capacity::get_total_stake_for_past_era(99, 3).is_err()); + }) +} + +#[test] +fn get_total_stake_for_past_era_works_with_1_full_chunk() { + new_test_ext().execute_with(|| { + System::set_block_number(52); + set_era_and_reward_pool(6, 51, 1000); + fill_reward_pool_history_chunk(0, 0, 3, 0); // eras 1-3 + fill_reward_pool_history_chunk(1, 3, 2, 300); // eras 4,5 + for i in 2u32..=4u32 { + let expected_total: BalanceOf = (i * 100u32).into(); + let actual = Capacity::get_total_stake_for_past_era(i, 5); + assert_eq!(actual, Ok(expected_total)); + } + assert!(Capacity::get_total_stake_for_past_era(6, 6).is_err()); + }) +} + +#[test] +fn get_total_stake_for_past_era_works_with_2_full_chunks() { + new_test_ext().execute_with(|| { + System::set_block_number(72); + set_era_and_reward_pool(7, 71, 1000); + fill_reward_pool_history_chunk(0, 0, 3, 0); + fill_reward_pool_history_chunk(1, 3, 3, 300); + fill_reward_pool_history_chunk(2, 6, 1, 600); + for i in 0u32..=6u32 { + let expected_total: BalanceOf = (i * 100u32).into(); + assert_eq!(Capacity::get_total_stake_for_past_era(i, 7), Ok(expected_total)); + } + assert!(Capacity::get_total_stake_for_past_era(7, 7).is_err()); + }) +} + +#[test] +fn get_total_stake_for_past_era_works_with_full_reward_pool() { + new_test_ext().execute_with(|| { + System::set_block_number(121); + let history_limit: u32 = ::ProviderBoostHistoryLimit::get(); + set_era_and_reward_pool(12, 121, (2000u32).into()); + + fill_reward_pool_history_chunk(0, 0, 3, 1); + fill_reward_pool_history_chunk(1, 3, 3, 301); + fill_reward_pool_history_chunk(2, 6, 3, 601); + fill_reward_pool_history_chunk(3, 9, 3, 901); + + (0u32..history_limit).for_each(|era| { + let expected_total: BalanceOf = ((era * 100u32) + 1u32).into(); + assert_eq!(Capacity::get_total_stake_for_past_era(era, 12), Ok(expected_total)); + }); + assert!(Capacity::get_total_stake_for_past_era(12, 12).is_err()); + }) +} diff --git a/pallets/capacity/src/tests/rewards_provider_tests.rs b/pallets/capacity/src/tests/rewards_provider_tests.rs new file mode 100644 index 0000000000..674d2b0674 --- /dev/null +++ b/pallets/capacity/src/tests/rewards_provider_tests.rs @@ -0,0 +1,322 @@ +use super::mock::*; +use crate::{ + tests::testing_utils::{ + run_to_block, set_era_and_reward_pool, setup_provider, system_run_to_block, + }, + BalanceOf, Config, CurrentEraInfo, ProviderBoostHistories, ProviderBoostHistory, + ProviderBoostRewardsProvider, + StakingType::*, + UnclaimedRewardInfo, +}; +use common_primitives::msa::MessageSourceId; +use frame_support::{assert_ok, traits::Len}; +use frame_system::pallet_prelude::BlockNumberFor; +use sp_core::Get; + +// This tests Capacity implementation of the trait, but uses the mock's constants, +// to ensure that it's correctly specified in the pallet. +#[test] +fn era_staking_reward_implementation() { + struct TestCase { + total_staked: u64, // by all boosters + amount_staked: u64, // by a single booster + expected_reward: u64, // for a single era + reward_pool: u64, // reward pool size + } + let test_cases: Vec = vec![ + TestCase { + total_staked: 1_000_000, + amount_staked: 0, + expected_reward: 0, + reward_pool: 10_000, + }, // shouldn't happen, but JIC + TestCase { + total_staked: 1_000_000, + amount_staked: 30, + expected_reward: 0, + reward_pool: 10_000, + }, // truncated result + TestCase { + total_staked: 1_000_000, + amount_staked: 150, + expected_reward: 1, + reward_pool: 10_000, + }, // truncated result + TestCase { + total_staked: 1_000_000, + amount_staked: 1000, + expected_reward: 4, + reward_pool: 10_000, + }, // hits the cap starting with this example + TestCase { + total_staked: 1_000_000, + amount_staked: 11000, + expected_reward: 42, + reward_pool: 10_000, + }, // > 0.0038% of total, reward = 11000*.0038 + TestCase { + total_staked: 20_000_000, + amount_staked: 888_889, + expected_reward: 3378, + reward_pool: 11_000_000, + }, // testnet/mainnet values + ]; + for tc in test_cases { + assert_eq!( + Capacity::era_staking_reward(tc.amount_staked, tc.total_staked, tc.reward_pool), + tc.expected_reward + ); + } +} + +#[test] +fn list_unclaimed_rewards_returns_empty_set_when_no_staking() { + new_test_ext().execute_with(|| { + let account = 500u64; + let history: ProviderBoostHistory = ProviderBoostHistory::new(); + ProviderBoostHistories::::set(account, Some(history)); + let rewards = Capacity::list_unclaimed_rewards(&account).unwrap(); + assert!(rewards.is_empty()) + }) +} +#[test] +fn list_unclaimed_rewards_returns_empty_set_when_only_staked_this_era() { + new_test_ext().execute_with(|| { + system_run_to_block(5); + set_era_and_reward_pool(5u32, 1u32, 1000u64); + let account = 500u64; + let mut history: ProviderBoostHistory = ProviderBoostHistory::new(); + history.add_era_balance(&5u32, &100u64); + ProviderBoostHistories::::set(account, Some(history)); + let rewards = Capacity::list_unclaimed_rewards(&account).unwrap(); + assert!(rewards.is_empty()) + }) +} + +// Check that eligible amounts are only for what's staked an entire era. +#[test] +fn list_unclaimed_rewards_has_eligible_rewards() { + new_test_ext().execute_with(|| { + let account = 10_000u64; + let target: MessageSourceId = 10; + let amount = 1_000u64; + + // staking 1k as of block 1, era 1 (1-10) + setup_provider(&account, &target, &amount, ProviderBoost); + + // staking 2k as of block 11, era 2 (11-20) + run_to_block(11); + assert_ok!(Capacity::provider_boost(RuntimeOrigin::signed(account), target, amount)); + + // staking 3k as of era 4, block 31, first block of era (31-40) + run_to_block(31); + assert_ok!(Capacity::provider_boost(RuntimeOrigin::signed(account), target, amount)); + + run_to_block(51); // era 5 + assert_eq!(CurrentEraInfo::::get().era_index, 5u32); + assert_eq!(CurrentEraInfo::::get().started_at, 51u32); + + // rewards for era 6 should not be returned; era 6 is current era and therefore ineligible. + // eligible amounts for rewards for eras should be: 1=0, 2=1k, 3=2k, 4=2k, 5=3k + let rewards = Capacity::list_unclaimed_rewards(&account).unwrap(); + assert_eq!(rewards.len(), 5usize); + let expected_info: Vec, BlockNumberFor>> = [ + UnclaimedRewardInfo { + reward_era: 0u32, + expires_at_block: 130, + staked_amount: 1000, + eligible_amount: 0, + earned_amount: 0, + }, + UnclaimedRewardInfo { + reward_era: 1u32, + expires_at_block: 140, + staked_amount: 2000, + eligible_amount: 1000, + earned_amount: 4, + }, + UnclaimedRewardInfo { + reward_era: 2u32, + expires_at_block: 150, + staked_amount: 2000, + eligible_amount: 2_000, + earned_amount: 8, + }, + UnclaimedRewardInfo { + reward_era: 3u32, + expires_at_block: 160, + staked_amount: 3000, + eligible_amount: 2000, + earned_amount: 8, + }, + UnclaimedRewardInfo { + reward_era: 4u32, + expires_at_block: 170, + staked_amount: 3000, + eligible_amount: 3_000, + earned_amount: 11, + }, + ] + .to_vec(); + for i in 0..expected_info.len() { + assert_eq!(rewards.get(i).unwrap(), &expected_info[i]); + } + + run_to_block(141); // current era = 14 + let rewards = Capacity::list_unclaimed_rewards(&account).unwrap(); + let max_history: u32 = ::ProviderBoostHistoryLimit::get(); + // the earliest eras, 0 and 1, should no longer be stored. + assert_eq!(rewards.len(), max_history as usize); + assert_eq!(rewards.get(0).unwrap().reward_era, 2u32); + + // there was no change in stake, so the eligible and earned amounts + // for era 13 should be the same as in reward era 5. + assert_eq!( + rewards.get((max_history - 1) as usize).unwrap(), + &UnclaimedRewardInfo { + reward_era: 13u32, + expires_at_block: 260, + staked_amount: 3_000, + eligible_amount: 3_000, + earned_amount: 11, + } + ) + }) +} + +// "Set and forget" test. +// Check that if an account boosted and then let it run for more than the number +// of history retention eras, eligible rewards are correct. +#[test] +fn list_unclaimed_rewards_returns_correctly_for_old_single_boost() { + new_test_ext().execute_with(|| { + let account = 10_000u64; + let target: MessageSourceId = 10; + let amount = 1_000u64; + + assert!(!Capacity::has_unclaimed_rewards(&account)); + + // boost 1k as of block 1, era 1 + setup_provider(&account, &target, &amount, ProviderBoost); + assert!(!Capacity::has_unclaimed_rewards(&account)); + + run_to_block(131); // era 13 + assert_eq!(CurrentEraInfo::::get().era_index, 13u32); + assert_eq!(CurrentEraInfo::::get().started_at, 131u32); + + let boost_history = ProviderBoostHistories::::get(account).unwrap(); + assert!(boost_history.get_entry_for_era(&0u32).is_some()); + + let rewards = Capacity::list_unclaimed_rewards(&account).unwrap(); + + let max_history: u32 = ::ProviderBoostHistoryLimit::get(); + assert_eq!(rewards.len(), max_history as usize); + + // the earliest era should not be returned. + assert_eq!(rewards.get(0).unwrap().reward_era, 1u32); + + for era in 1u32..=max_history { + let expires_at_era = era.saturating_add(max_history.into()); + let expires_at_block = Capacity::block_at_end_of_era(expires_at_era); + let expected_info: UnclaimedRewardInfo, BlockNumberFor> = + UnclaimedRewardInfo { + reward_era: era, + expires_at_block, + staked_amount: 1000, + eligible_amount: 1000, + earned_amount: 4, + }; + let era_index: usize = (era - 1u32) as usize; + assert_eq!(rewards.get(era_index).unwrap(), &expected_info); + } + }) +} + +// this is to check that our 0-indexed era + when a Current Era starts at something besides one, +// that the calculations are still correct +#[test] +fn list_unclaimed_rewards_current_era_starts_at_later_block_works() { + new_test_ext().execute_with(|| { + let account = 10_000u64; + let target: MessageSourceId = 10; + let amount = 1000u64; + + System::set_block_number(9900); + set_era_and_reward_pool(0, 9898, 10_000); + setup_provider(&account, &target, &amount, ProviderBoost); + + run_to_block(9910); // middle of era 1 + assert_eq!(CurrentEraInfo::::get().era_index, 1u32); + assert_eq!(CurrentEraInfo::::get().started_at, 9908u32); + + run_to_block(9920); // middle of era 2, now some rewards can be claimed + assert_eq!(CurrentEraInfo::::get().era_index, 2u32); + assert_eq!(CurrentEraInfo::::get().started_at, 9918u32); + + let expected_info_era_0: UnclaimedRewardInfo, BlockNumberFor> = + UnclaimedRewardInfo { + reward_era: 0, + expires_at_block: 9898u32 + 129u32, + staked_amount: 1000, + eligible_amount: 0, + earned_amount: 0, + }; + let expected_info_era_1: UnclaimedRewardInfo, BlockNumberFor> = + UnclaimedRewardInfo { + reward_era: 1, + expires_at_block: 9908u32 + 129u32, + staked_amount: 1000, + eligible_amount: 1000, + earned_amount: 4, + }; + + let rewards = Capacity::list_unclaimed_rewards(&account).unwrap(); + assert_eq!(rewards.get(0).unwrap(), &expected_info_era_0); + assert_eq!(rewards.get(1).unwrap(), &expected_info_era_1); + }) +} + +#[test] +fn has_unclaimed_rewards_works() { + new_test_ext().execute_with(|| { + let account = 10_000u64; + let target: MessageSourceId = 10; + let amount = 1_000u64; + + assert!(!Capacity::has_unclaimed_rewards(&account)); + + // staking 1k as of block 1, era 1 + setup_provider(&account, &target, &amount, ProviderBoost); + assert!(!Capacity::has_unclaimed_rewards(&account)); + + // staking 2k as of block 11, era 2 + run_to_block(11); + assert_ok!(Capacity::provider_boost(RuntimeOrigin::signed(account), target, amount)); + assert!(Capacity::has_unclaimed_rewards(&account)); + + // staking 3k as of era 4, block 31 + run_to_block(31); + assert!(Capacity::has_unclaimed_rewards(&account)); + + run_to_block(61); + assert!(Capacity::has_unclaimed_rewards(&account)); + }) +} + +#[test] +fn has_unclaimed_rewards_returns_true_with_old_single_boost() { + new_test_ext().execute_with(|| { + let account = 10_000u64; + let target: MessageSourceId = 10; + let amount = 1_000u64; + + assert!(!Capacity::has_unclaimed_rewards(&account)); + + // boost 1k as of block 1, era 1 + setup_provider(&account, &target, &amount, ProviderBoost); + assert!(!Capacity::has_unclaimed_rewards(&account)); + + run_to_block(71); + assert!(Capacity::has_unclaimed_rewards(&account)); + }); +} diff --git a/pallets/capacity/src/tests/stake_and_deposit_tests.rs b/pallets/capacity/src/tests/stake_and_deposit_tests.rs index ecdf1193b2..9ae495a08f 100644 --- a/pallets/capacity/src/tests/stake_and_deposit_tests.rs +++ b/pallets/capacity/src/tests/stake_and_deposit_tests.rs @@ -1,7 +1,7 @@ use super::{mock::*, testing_utils::*}; use crate::{ BalanceOf, CapacityDetails, CapacityLedger, Config, Error, Event, FreezeReason, - StakingAccountLedger, StakingDetails, StakingTargetLedger, + StakingAccountLedger, StakingDetails, StakingTargetLedger, StakingType::MaximumCapacity, }; use common_primitives::{capacity::Nontransferable, msa::MessageSourceId}; use frame_support::{assert_noop, assert_ok, traits::fungible::InspectFreeze}; @@ -37,7 +37,7 @@ fn stake_works() { capacity_details ); - let events = staking_events(); + let events = capacity_events(); assert_eq!(events.first().unwrap(), &Event::Staked { account, target, amount, capacity }); assert_eq!( @@ -72,7 +72,7 @@ fn stake_errors_insufficient_staking_amount_when_staking_below_minimum_staking_a register_provider(target, String::from("Foo")); assert_noop!( Capacity::stake(RuntimeOrigin::signed(account), target, amount), - Error::::InsufficientStakingAmount + Error::::StakingAmountBelowMinimum ); }); } @@ -101,7 +101,7 @@ fn stake_increase_stake_amount_works() { // First Stake assert_ok!(Capacity::stake(RuntimeOrigin::signed(account), target, initial_amount)); - let events = staking_events(); + let events = capacity_events(); assert_eq!( events.first().unwrap(), &Event::Staked { account, target, amount: initial_amount, capacity } @@ -136,7 +136,7 @@ fn stake_increase_stake_amount_works() { assert_eq!(CapacityLedger::::get(target).unwrap().total_capacity_issued, 15); assert_eq!(CapacityLedger::::get(target).unwrap().last_replenished_epoch, 0); - let events = staking_events(); + let events = capacity_events(); assert_eq!( events.last().unwrap(), &Event::Staked { account, target, amount: additional_amount, capacity } @@ -255,10 +255,10 @@ fn stake_when_staking_amount_is_greater_than_free_balance_it_stakes_maximum() { assert_ok!(Capacity::stake(RuntimeOrigin::signed(account), target, amount)); // Check that StakingAccountLedger is updated. - assert_eq!(StakingAccountLedger::::get(account).unwrap().active, 190); + assert_eq!(StakingAccountLedger::::get(account).unwrap().active, 189); // Check that StakingTargetLedger is updated. - assert_eq!(StakingTargetLedger::::get(account, target).unwrap().amount, 190); + assert_eq!(StakingTargetLedger::::get(account, target).unwrap().amount, 189); assert_eq!(StakingTargetLedger::::get(account, target).unwrap().capacity, 19); // Check that CapacityLedger is updated. @@ -275,7 +275,7 @@ fn get_stakable_amount_for_works() { // An amount greater than the free balance let amount = 230; let res: u64 = Capacity::get_stakable_amount_for(&account, amount); - assert_eq!(res, 190); + assert_eq!(res, 189); }) } @@ -302,7 +302,7 @@ fn ensure_can_stake_errors_with_zero_amount_not_allowed() { let target: MessageSourceId = 1; let amount = 0; assert_noop!( - Capacity::ensure_can_stake(&account, target, amount), + Capacity::ensure_can_stake(&account, target, amount, MaximumCapacity), Error::::ZeroAmountNotAllowed ); }); @@ -340,7 +340,7 @@ fn ensure_can_stake_errors_invalid_target() { let amount = 1; assert_noop!( - Capacity::ensure_can_stake(&account, target, amount), + Capacity::ensure_can_stake(&account, target, amount, MaximumCapacity), Error::::InvalidTarget ); }); @@ -355,8 +355,8 @@ fn ensure_can_stake_errors_insufficient_staking_amount() { register_provider(target, String::from("Foo")); assert_noop!( - Capacity::ensure_can_stake(&account, target, amount), - Error::::InsufficientStakingAmount + Capacity::ensure_can_stake(&account, target, amount, MaximumCapacity), + Error::::StakingAmountBelowMinimum ); }); } @@ -371,7 +371,7 @@ fn ensure_can_stake_is_successful() { let staking_details = StakingDetails::::default(); assert_ok!( - Capacity::ensure_can_stake(&account, target, amount), + Capacity::ensure_can_stake(&account, target, amount, MaximumCapacity), (staking_details, BalanceOf::::from(10u64)) ); }); @@ -438,8 +438,9 @@ fn impl_deposit_is_successful() { total_available_amount, 1u32, ); - - assert_ok!(Capacity::deposit(target_msa_id, 5u32.into()),); + let amount = BalanceOf::::from(5u32); + let capacity = BalanceOf::::from(1u32); + assert_ok!(Capacity::deposit(target_msa_id, amount, capacity)); }); } @@ -448,8 +449,10 @@ fn impl_deposit_errors_target_capacity_not_found() { new_test_ext().execute_with(|| { let target_msa_id = 1; let amount = BalanceOf::::from(10u32); + let capacity = BalanceOf::::from(5u32); + assert_noop!( - Capacity::deposit(target_msa_id, amount), + Capacity::deposit(target_msa_id, amount, capacity), Error::::TargetCapacityNotFound ); }); diff --git a/pallets/capacity/src/tests/staking_target_details_tests.rs b/pallets/capacity/src/tests/staking_target_details_tests.rs index dbe9ea0d4a..69bbb33834 100644 --- a/pallets/capacity/src/tests/staking_target_details_tests.rs +++ b/pallets/capacity/src/tests/staking_target_details_tests.rs @@ -11,7 +11,7 @@ fn impl_staking_target_details_increase_by() { staking_target, StakingTargetDetails::> { amount: BalanceOf::::from(10u64), - capacity: 10 + capacity: 10, } ) } @@ -19,20 +19,30 @@ fn impl_staking_target_details_increase_by() { #[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), + amount: BalanceOf::::from(25u64), + capacity: BalanceOf::::from(30u64), }; - staking_target_details.withdraw(10, 10); + staking_target_details.withdraw(10, 10, ::MinimumStakingAmount::get()); assert_eq!( staking_target_details, StakingTargetDetails::> { - amount: BalanceOf::::from(5u64), - capacity: BalanceOf::::from(10u64), + 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, ::MinimumStakingAmount::get()); + 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/testing_utils.rs b/pallets/capacity/src/tests/testing_utils.rs index 0624901f39..f751006032 100644 --- a/pallets/capacity/src/tests/testing_utils.rs +++ b/pallets/capacity/src/tests/testing_utils.rs @@ -4,14 +4,25 @@ use frame_support::{assert_ok, traits::Hooks}; #[allow(unused)] use sp_runtime::traits::SignedExtension; -use crate::{BalanceOf, CapacityDetails, Config, Event}; +use crate::{ + BalanceOf, CapacityDetails, Config, CurrentEraInfo, CurrentEraProviderBoostTotal, Event, + RewardEraInfo, StakingAccountLedger, StakingTargetLedger, StakingType, +}; use common_primitives::msa::MessageSourceId; -pub fn staking_events() -> Vec> { +pub fn capacity_events() -> Vec> { let result = System::events() .into_iter() .map(|r| r.event) - .filter_map(|e| if let RuntimeEvent::Capacity(inner) = e { Some(inner) } else { None }) + .filter_map(|e| { + if let RuntimeEvent::Capacity(inner) = e { + log::warn!("inner: {:?}", inner); + Some(inner) + } else { + log::warn!("nothing"); + None + } + }) .collect::>(); System::reset_events(); @@ -28,6 +39,7 @@ pub fn run_to_block(n: u32) { Capacity::on_initialize(System::block_number()); } } + // Remove capacity on_initialize, needed to emulate pre-existing block height pub fn system_run_to_block(n: u32) { while System::block_number() < n { @@ -61,5 +73,37 @@ pub fn create_capacity_account_and_fund( Capacity::set_capacity_for(target_msa_id, capacity_details.clone()); - capacity_details + capacity_details.clone() +} + +pub fn setup_provider( + staker: &u64, + target: &MessageSourceId, + amount: &u64, + staking_type: StakingType, +) { + let provider_name = String::from("Cst-") + target.to_string().as_str(); + register_provider(*target, provider_name); + if amount.gt(&0u64) { + if staking_type == StakingType::MaximumCapacity { + assert_ok!(Capacity::stake(RuntimeOrigin::signed(staker.clone()), *target, *amount,)); + } else { + assert_ok!(Capacity::provider_boost( + RuntimeOrigin::signed(staker.clone()), + *target, + *amount + )); + } + let target = StakingTargetLedger::::get(staker, target).unwrap(); + assert_eq!(target.amount, *amount); + let account_staking_type = StakingAccountLedger::::get(staker).unwrap().staking_type; + assert_eq!(account_staking_type, staking_type); + } +} + +// Currently the reward pool is a constant, however it could change in the future. +pub fn set_era_and_reward_pool(era_index: u32, started_at: u32, total_staked_token: u64) { + let era_info = RewardEraInfo { era_index, started_at }; + CurrentEraInfo::::set(era_info); + CurrentEraProviderBoostTotal::::set(total_staked_token); } diff --git a/pallets/capacity/src/tests/unstaking_tests.rs b/pallets/capacity/src/tests/unstaking_tests.rs index 9403c6ae1d..1f1852cd32 100644 --- a/pallets/capacity/src/tests/unstaking_tests.rs +++ b/pallets/capacity/src/tests/unstaking_tests.rs @@ -1,8 +1,10 @@ use super::{mock::*, testing_utils::*}; use crate as pallet_capacity; use crate::{ - CapacityDetails, CapacityLedger, FreezeReason, StakingAccountLedger, StakingDetails, - StakingTargetDetails, StakingTargetLedger, StakingType, UnlockChunk, UnstakeUnlocks, + CapacityDetails, CapacityLedger, CurrentEraProviderBoostTotal, FreezeReason, + ProviderBoostHistories, ProviderBoostHistory, StakingAccountLedger, StakingDetails, + StakingTargetDetails, StakingTargetLedger, StakingType, StakingType::ProviderBoost, + UnlockChunk, UnstakeUnlocks, }; use common_primitives::msa::MessageSourceId; use frame_support::{ @@ -56,8 +58,8 @@ fn unstake_happy_path() { assert_eq!( staking_target_details, StakingTargetDetails::> { - amount: BalanceOf::::from(60u64), - capacity: BalanceOf::::from(6u64), + amount: BalanceOf::::from(60u32), + capacity: BalanceOf::::from(6u32), } ); @@ -74,7 +76,7 @@ fn unstake_happy_path() { } ); - let events = staking_events(); + let events = capacity_events(); assert_eq!( events.last().unwrap(), &Event::UnStaked { @@ -159,8 +161,17 @@ fn unstake_errors_unstaking_amount_is_zero() { }); } +fn fill_unstake_unlock_chunks(token_account: u64, target: MessageSourceId, unstaking_amount: u64) { + for _n in 0..::MaxUnlockingChunks::get() { + assert_ok!(Capacity::unstake( + RuntimeOrigin::signed(token_account), + target, + unstaking_amount + )); + } +} #[test] -fn unstake_errors_max_unlocking_chunks_exceeded() { +fn unstake_errors_max_unlocking_chunks_exceeded_stake() { new_test_ext().execute_with(|| { let token_account = 200; let target: MessageSourceId = 1; @@ -171,13 +182,31 @@ fn unstake_errors_max_unlocking_chunks_exceeded() { assert_ok!(Capacity::stake(RuntimeOrigin::signed(token_account), target, staking_amount)); - for _n in 0..::MaxUnlockingChunks::get() { - assert_ok!(Capacity::unstake( - RuntimeOrigin::signed(token_account), - target, - unstaking_amount - )); - } + fill_unstake_unlock_chunks(token_account, target, unstaking_amount); + + assert_noop!( + Capacity::unstake(RuntimeOrigin::signed(token_account), target, unstaking_amount), + Error::::MaxUnlockingChunksExceeded + ); + }); +} +#[test] +fn unstake_errors_max_unlocking_chunks_exceeded_provider_boost() { + new_test_ext().execute_with(|| { + let token_account = 200; + let target: MessageSourceId = 1; + let staking_amount = 60; + let unstaking_amount = 10; + + register_provider(target, String::from("Test Target")); + + assert_ok!(Capacity::provider_boost( + RuntimeOrigin::signed(token_account), + target, + staking_amount + )); + + fill_unstake_unlock_chunks(token_account, target, unstaking_amount); assert_noop!( Capacity::unstake(RuntimeOrigin::signed(token_account), target, unstaking_amount), @@ -199,7 +228,7 @@ fn unstake_errors_amount_to_unstake_exceeds_amount_staked() { assert_ok!(Capacity::stake(RuntimeOrigin::signed(token_account), target, staking_amount)); assert_noop!( Capacity::unstake(RuntimeOrigin::signed(token_account), target, unstaking_amount), - Error::::AmountToUnstakeExceedsAmountStaked + Error::::InsufficientStakingBalance ); }); } @@ -225,20 +254,25 @@ fn unstake_errors_not_a_staking_account() { fn unstaking_everything_reaps_staking_account() { new_test_ext().execute_with(|| { let staker = 500; + let booster = 600; let target = 1; let amount = 20; assert_ok!(Capacity::set_epoch_length(RuntimeOrigin::root(), 10)); register_provider(target, String::from("WithdrawUnst")); assert_ok!(Capacity::stake(RuntimeOrigin::signed(staker), target, amount)); + assert_ok!(Capacity::provider_boost(RuntimeOrigin::signed(booster), target, amount)); run_to_block(1); // unstake everything assert_ok!(Capacity::unstake(RuntimeOrigin::signed(staker), target, 20)); assert_eq!(20u64, Balances::balance_frozen(&FreezeReason::CapacityStaking.into(), &staker)); + assert_ok!(Capacity::unstake(RuntimeOrigin::signed(booster), target, 20)); + assert_eq!(20u64, Balances::balance_frozen(&FreezeReason::CapacityStaking.into(), &staker)); // it should reap the staking account right away assert!(StakingAccountLedger::::get(&staker).is_none()); + assert!(StakingAccountLedger::::get(&booster).is_none()); }) } @@ -257,3 +291,212 @@ fn unstake_when_not_staking_to_target_errors() { ); }) } + +#[test] +fn unstake_provider_boosted_target_in_same_era_adjusts_reward_pool_total() { + new_test_ext().execute_with(|| { + // two accounts staking to the same target + let account1 = 600; + let target: MessageSourceId = 1; + let amount1 = 500; + let unstake_amount = 200; + register_provider(target, String::from("Foo")); + run_to_block(5); // ensures Capacity::on_initialize is run + + assert_ok!(Capacity::provider_boost(RuntimeOrigin::signed(account1), target, amount1)); + assert_ok!(Capacity::unstake(RuntimeOrigin::signed(account1), target, unstake_amount)); + + assert_eq!(CurrentEraProviderBoostTotal::::get(), 300u64); + }); +} + +#[test] +fn unstake_maximum_does_not_change_reward_pool() { + new_test_ext().execute_with(|| { + // two accounts staking to the same target + let account1 = 600; + let a_booster = 10_000; + let target: MessageSourceId = 1; + let amount1 = 500; + let unstake_amount = 200; + + register_provider(target, String::from("Foo")); + run_to_block(5); // ensures Capacity::on_initialize is run + + assert_ok!(Capacity::stake(RuntimeOrigin::signed(account1), target, amount1)); + assert_ok!(Capacity::provider_boost(RuntimeOrigin::signed(a_booster), target, amount1)); + + assert_eq!(CurrentEraProviderBoostTotal::::get(), amount1); + + assert_ok!(Capacity::unstake(RuntimeOrigin::signed(account1), target, unstake_amount)); + assert_eq!(CurrentEraProviderBoostTotal::::get(), amount1); + }); +} + +#[test] +fn unstake_fills_up_common_unlock_for_any_target() { + new_test_ext().execute_with(|| { + let staker = 10_000; + + let target1 = 1; + let target2 = 2; + register_provider(target1, String::from("Test Target")); + register_provider(target2, String::from("Test Target")); + + assert_ok!(Capacity::provider_boost(RuntimeOrigin::signed(staker), target1, 1_000)); + assert_ok!(Capacity::provider_boost(RuntimeOrigin::signed(staker), target2, 2_000)); + + // max unlock chunks in mock is 4 + for _i in 0..2 { + assert_ok!(Capacity::unstake(RuntimeOrigin::signed(staker), target1, 50)); + assert_ok!(Capacity::unstake(RuntimeOrigin::signed(staker), target2, 50)); + } + assert_noop!( + Capacity::unstake(RuntimeOrigin::signed(staker), target1, 50), + Error::::MaxUnlockingChunksExceeded + ); + }) +} + +#[test] +fn unstake_by_a_booster_updates_provider_boost_history_with_correct_amount() { + new_test_ext().execute_with(|| { + let staker = 10_000; + let target1 = 1; + register_provider(target1, String::from("Test Target")); + + assert_ok!(Capacity::provider_boost(RuntimeOrigin::signed(staker), target1, 1_000)); + let mut pbh = ProviderBoostHistories::::get(staker).unwrap(); + assert_eq!(pbh.count(), 1); + + // If unstaking in the next era, this should add a new staking history entry. + system_run_to_block(10); // last block of era 0 + run_to_block(41); // beginning of era 4 + assert_ok!(Capacity::claim_staking_rewards(RuntimeOrigin::signed(staker))); + pbh = ProviderBoostHistories::::get(staker).unwrap(); + assert_eq!(pbh.count(), 1); + + // This adds a new history item for the unstake, in current era, 4 + assert_ok!(Capacity::unstake(RuntimeOrigin::signed(staker), target1, 400u64)); + + // earned 4 in rewards for eras 3,2,1 + assert_eq!(get_balance::(&staker), 10_012u64); + assert_transferable::(&staker, 12u64); + + pbh = ProviderBoostHistories::::get(staker).unwrap(); + assert_eq!(pbh.count(), 2); + let entry = pbh.get_entry_for_era(&4u32.into()).unwrap(); + assert_eq!(entry, &600u64); + }) +} + +#[test] +fn unstake_all_by_booster_reaps_boost_history() { + new_test_ext().execute_with(|| { + let staker = 10_000; + let target1 = 1; + register_provider(target1, String::from("Test Target")); + + // Era 0, block 1. + assert_ok!(Capacity::provider_boost(RuntimeOrigin::signed(staker), target1, 1_000)); + let pbh = ProviderBoostHistories::::get(staker).unwrap(); + assert_eq!(pbh.count(), 1); + + // If unstaking in the next era, this should add a new staking history entry. + system_run_to_block(10); // last block of era 0 + run_to_block(41); // First block of Era 4. + assert_ok!(Capacity::claim_staking_rewards(RuntimeOrigin::signed(staker))); + assert_ok!(Capacity::unstake(RuntimeOrigin::signed(staker), target1, 1_000)); + assert!(ProviderBoostHistories::::get(staker).is_none()); + // earn 4 each for 3 past eras, 3,2,1 + assert_eq!(get_balance::(&staker), 10_012u64); + assert_transferable::(&staker, 12u64); + }) +} + +#[test] +fn unstake_maximum_immediately_after_staking_does_not_create_provider_boost_history() { + new_test_ext().execute_with(|| { + let staker = 10_000; + let target1 = 1; + register_provider(target1, String::from("Test Target")); + + assert_ok!(Capacity::stake(RuntimeOrigin::signed(staker), target1, 1_000)); + assert_ok!(Capacity::unstake(RuntimeOrigin::signed(staker), target1, 500)); + assert!(ProviderBoostHistories::::get(staker).is_none()); + }) +} + +// Simulate a series of stake/unstake events over 10 eras then check for +// correct staking values, including for eras that do not have an explicit entry. +#[test] +fn get_amount_staked_for_era_works() { + let mut staking_history: ProviderBoostHistory = ProviderBoostHistory::new(); + + for i in 0u32..5u32 { + staking_history.add_era_balance(&i.into(), &10u64); + } + assert_eq!(staking_history.get_amount_staked_for_era(&0u32), 10u64); + assert_eq!(staking_history.get_amount_staked_for_era(&4u32), 50u64); + + staking_history.subtract_era_balance(&4u32.into(), &50u64); + assert_eq!(staking_history.get_amount_staked_for_era(&5u32), 0u64); + + for i in 10u32..=13u32 { + staking_history.add_era_balance(&i.into(), &5u64); + } + assert_eq!(staking_history.get_amount_staked_for_era(&10u32), 5u64); + assert_eq!(staking_history.get_amount_staked_for_era(&13u32), 20u64); + + staking_history.subtract_era_balance(&14u32, &7u64); + assert_eq!(staking_history.get_amount_staked_for_era(&14u32), 13u64); + assert_eq!(staking_history.get_amount_staked_for_era(&15u32), 13u64); + + staking_history.add_era_balance(&15u32, &10u64); + + let expected_balance = 23u64; + assert_eq!(staking_history.get_amount_staked_for_era(&15u32), expected_balance); + + // unstake everything + staking_history.subtract_era_balance(&20u32, &expected_balance); + + assert_eq!(staking_history.get_amount_staked_for_era(&16u32), expected_balance); + assert_eq!(staking_history.get_amount_staked_for_era(&17u32), expected_balance); + assert_eq!(staking_history.get_amount_staked_for_era(&18u32), expected_balance); + assert_eq!(staking_history.get_amount_staked_for_era(&19u32), expected_balance); + + // from 20 onward, should return 0. + assert_eq!(staking_history.get_amount_staked_for_era(&20u32), 0u64); + assert_eq!(staking_history.get_amount_staked_for_era(&31u32), 0u64); + + // ensure reporting from earlier is still correct. + assert_eq!(staking_history.get_amount_staked_for_era(&14u32), 13u64); + + // querying for an era that has been cleared due to the hitting the bound + // (ProviderBoostHistoryLimit = 5 in mock) returns zero. + assert_eq!(staking_history.get_amount_staked_for_era(&9u32), 0u64); +} + +#[test] +fn unstake_fails_if_provider_boosted_and_have_unclaimed_rewards() { + new_test_ext().execute_with(|| { + let account = 10_000u64; + let target: MessageSourceId = 10; + let amount = 1_000u64; + + // staking 1k as of block 1, era 1 + setup_provider(&account, &target, &amount, ProviderBoost); + + // staking 2k as of block 11, era 2 + run_to_block(11); + assert_ok!(Capacity::provider_boost(RuntimeOrigin::signed(account), target, amount)); + + // staking 3k as of era 4, block 31 + run_to_block(31); + + assert_noop!( + Capacity::unstake(RuntimeOrigin::signed(account), target, amount), + Error::::MustFirstClaimRewards + ); + }) +} diff --git a/pallets/capacity/src/tests/withdraw_unstaked_tests.rs b/pallets/capacity/src/tests/withdraw_unstaked_tests.rs index 05f5b3e26b..bb5e9bc7ca 100644 --- a/pallets/capacity/src/tests/withdraw_unstaked_tests.rs +++ b/pallets/capacity/src/tests/withdraw_unstaked_tests.rs @@ -89,9 +89,17 @@ fn withdraw_unstaked_cleans_up_storage_and_removes_all_locks_if_no_stake_left() // wait for thaw run_to_block(21); assert_ok!(Capacity::withdraw_unstaked(RuntimeOrigin::signed(staker))); - assert_eq!(0, Balances::locks(&staker).len()); + + assert_eq!( + ::Currency::balance_frozen( + &FreezeReason::CapacityStaking.into(), + &staker + ), + 0u64 + ); + assert!(UnstakeUnlocks::::get(&staker).is_none()); - }) + }); } #[test] diff --git a/pallets/capacity/src/tests/withdrawal_tests.rs b/pallets/capacity/src/tests/withdrawal_tests.rs index e3560df791..8a097d983e 100644 --- a/pallets/capacity/src/tests/withdrawal_tests.rs +++ b/pallets/capacity/src/tests/withdrawal_tests.rs @@ -17,7 +17,7 @@ fn impl_withdraw_is_successful() { ); assert_ok!(Capacity::deduct(target_msa_id, 5u32.into())); - let events = staking_events(); + let events = capacity_events(); assert_eq!( events.last().unwrap(), @@ -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 80dc35c396..3d6bad88e7 100644 --- a/pallets/capacity/src/types.rs +++ b/pallets/capacity/src/types.rs @@ -1,15 +1,23 @@ //! Types for the Capacity Pallet use super::*; -use frame_support::{BoundedVec, EqNoBound, PartialEqNoBound, RuntimeDebugNoBound}; -use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; +use common_primitives::capacity::RewardEra; +use frame_support::{ + pallet_prelude::PhantomData, BoundedVec, EqNoBound, PartialEqNoBound, RuntimeDebugNoBound, +}; +use parity_scale_codec::{Decode, Encode, EncodeLike, MaxEncodedLen}; use scale_info::TypeInfo; use sp_runtime::{ - traits::{CheckedAdd, CheckedSub, Saturating, Zero}, - RuntimeDebug, + traits::{AtLeast32BitUnsigned, CheckedAdd, CheckedSub, Get, Saturating, Zero}, + BoundedBTreeMap, RuntimeDebug, }; + #[cfg(any(feature = "runtime-benchmarks", test))] use sp_std::vec::Vec; +/// How much, as a percentage of staked token, to boost a targeted Provider when staking. +/// this value should be between 0 and 100 +pub const STAKED_PERCENTAGE_TO_BOOST: u32 = 50; + #[derive( Clone, Copy, Debug, Decode, Encode, TypeInfo, Eq, MaxEncodedLen, PartialEq, PartialOrd, )] @@ -37,9 +45,9 @@ pub struct StakingDetails { /// The type that is used to record a single request for a number of tokens to be unlocked. #[derive(PartialEq, Eq, Clone, Encode, Decode, RuntimeDebug, TypeInfo, MaxEncodedLen)] pub struct UnlockChunk { - /// Amount to be unlocked. + /// Amount to be unfrozen. pub value: Balance, - /// Block number at which point funds are unlocked. + /// Block number at which point funds are unfrozen. pub thaw_at: EpochNumber, } @@ -75,27 +83,46 @@ impl Default for StakingDetails { /// Details about the total token amount targeted to an MSA. /// The Capacity that the target will receive. -#[derive(PartialEq, Eq, Default, Clone, Encode, Decode, RuntimeDebug, TypeInfo, MaxEncodedLen)] -pub struct StakingTargetDetails { +#[derive(Default, PartialEq, Eq, Clone, Encode, Decode, RuntimeDebug, TypeInfo, MaxEncodedLen)] +pub struct StakingTargetDetails +where + Balance: Default + Saturating + Copy + CheckedAdd + CheckedSub, +{ /// The total amount of tokens that have been targeted to the MSA. pub amount: Balance, /// The total Capacity that an MSA received. pub capacity: Balance, } -impl StakingTargetDetails { +impl + StakingTargetDetails +{ /// Increase an MSA target Staking total and Capacity amount. pub fn deposit(&mut self, amount: Balance, capacity: Balance) -> Option<()> { self.amount = amount.checked_add(&self.amount)?; self.capacity = capacity.checked_add(&self.capacity)?; - Some(()) } /// Decrease an MSA target Staking total and Capacity amount. - pub fn withdraw(&mut self, amount: Balance, capacity: Balance) { + /// If the amount would put you below the minimum, zero out the amount. + /// Return the actual amounts withdrawn. + pub fn withdraw( + &mut self, + amount: Balance, + capacity: Balance, + minimum: Balance, + ) -> (Balance, Balance) { + let entire_amount = self.amount; + let entire_capacity = self.capacity; self.amount = self.amount.saturating_sub(amount); - self.capacity = self.capacity.saturating_sub(capacity); + if self.amount.lt(&minimum) { + *self = Self::default(); + return (entire_amount, entire_capacity); + } else { + self.capacity = self.capacity.saturating_sub(capacity); + } + (amount, capacity) } } @@ -223,3 +250,291 @@ pub fn unlock_chunks_from_vec(chunks: &Vec<(u32, u32)>) -> UnlockChun // CAUTION BoundedVec::try_from(result).unwrap() } + +/// The information needed to track a Reward Era +#[derive( + PartialEq, + Eq, + Clone, + Copy, + Default, + PartialOrd, + Encode, + Decode, + RuntimeDebug, + TypeInfo, + MaxEncodedLen, +)] +pub struct RewardEraInfo +where + RewardEra: AtLeast32BitUnsigned + EncodeLike, + BlockNumber: AtLeast32BitUnsigned + EncodeLike, +{ + /// the index of this era + pub era_index: RewardEra, + /// the starting block of this era + pub started_at: BlockNumber, +} + +/// A chunk of Reward Pool history items consists of a BoundedBTreeMap, +/// RewardEra is the key and the total stake for the RewardPool is the value. +/// the map has up to T::RewardPoolChunkLength items, however, the chunk storing the current era +/// has only that one. +#[derive(PartialEq, Eq, Encode, Decode, RuntimeDebug, TypeInfo, MaxEncodedLen)] +#[scale_info(skip_type_params(T))] +pub struct RewardPoolHistoryChunk( + BoundedBTreeMap, T::RewardPoolChunkLength>, +); +impl Default for RewardPoolHistoryChunk { + fn default() -> Self { + Self::new() + } +} + +impl Clone for RewardPoolHistoryChunk { + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} + +impl RewardPoolHistoryChunk { + /// Constructs a new empty RewardPoolHistoryChunk + pub fn new() -> Self { + RewardPoolHistoryChunk(BoundedBTreeMap::new()) + } + + /// A wrapper for retrieving how much was provider_boosted in the given era + /// from the BoundedBTreeMap + pub fn total_for_era(&self, reward_era: &RewardEra) -> Option<&BalanceOf> { + self.0.get(reward_era) + } + + /// returns the range of eras in this chunk + #[cfg(test)] + pub fn era_range(&self) -> (RewardEra, RewardEra) { + let zero_reward_era: RewardEra = Zero::zero(); + let zero_balance: BalanceOf = Zero::zero(); + let (first, _vf) = self.0.first_key_value().unwrap_or((&zero_reward_era, &zero_balance)); + let (last, _vl) = self.0.last_key_value().unwrap_or((&zero_reward_era, &zero_balance)); + (*first, *last) + } + + /// A wrapper for adding a new reward_era_entry to the BoundedBTreeMap + pub fn try_insert( + &mut self, + reward_era: RewardEra, + total: BalanceOf, + ) -> Result>, (RewardEra, BalanceOf)> { + self.0.try_insert(reward_era, total) + } + + /// Get the earliest reward era stored in this BoundedBTreeMap + #[cfg(test)] + pub fn earliest_era(&self) -> Option<&RewardEra> { + if let Some((first_era, _first_total)) = self.0.first_key_value() { + return Some(first_era); + } + None + } + + /// Is this chunk full? It should always be yes once there is enough RewardPool history. + pub fn is_full(&self) -> bool { + self.0.len().eq(&(T::RewardPoolChunkLength::get() as usize)) + } +} + +/// A record of staked amounts for a complete RewardEra +#[derive(PartialEq, Eq, Clone, Encode, Decode, RuntimeDebug, TypeInfo, MaxEncodedLen)] +#[scale_info(skip_type_params(T))] +pub struct ProviderBoostHistory( + BoundedBTreeMap, T::ProviderBoostHistoryLimit>, +); + +impl Default for ProviderBoostHistory { + fn default() -> Self { + Self::new() + } +} + +impl ProviderBoostHistory { + /// Constructs a new empty ProviderBoostHistory + pub fn new() -> Self { + ProviderBoostHistory(BoundedBTreeMap::new()) + } + + /// Adds `add_amount` to the entry for `reward_era`. + /// Updates entry, or creates a new entry if it does not exist + /// returns the total number of history items + pub fn add_era_balance( + &mut self, + reward_era: &RewardEra, + add_amount: &BalanceOf, + ) -> Option { + if let Some(entry) = self.0.get_mut(&reward_era) { + // update + *entry = entry.saturating_add(*add_amount); + } else { + // insert + self.remove_oldest_entry_if_full(); // this guarantees a try_insert never fails + let current_staking_amount = self.get_last_staking_amount(); + if self + .0 + .try_insert(*reward_era, current_staking_amount.saturating_add(*add_amount)) + .is_err() + { + return None; + }; + } + + Some(self.count()) + } + + /// Subtracts `subtract_amount` from the entry for `reward_era`. Zero values are still retained. + /// Returns None if there is no entry for the reward era. + /// Returns Some(0) if they unstaked everything and this is the only entry + /// Otherwise returns Some(history_count) + pub fn subtract_era_balance( + &mut self, + reward_era: &RewardEra, + subtract_amount: &BalanceOf, + ) -> Option { + if self.count().is_zero() { + return None; + }; + + let current_staking_amount = self.get_last_staking_amount(); + if current_staking_amount.eq(subtract_amount) && self.count().eq(&1usize) { + // Should not get here unless rewards have all been claimed, and provider boost history was + // correctly updated. This && condition is to protect stakers against loss of rewards in the + // case of some bug with payouts and boost history. + return Some(0usize); + } + + if let Some(entry) = self.0.get_mut(reward_era) { + *entry = entry.saturating_sub(*subtract_amount); + } else { + self.remove_oldest_entry_if_full(); + if self + .0 + .try_insert(*reward_era, current_staking_amount.saturating_sub(*subtract_amount)) + .is_err() + { + return None; + } + } + Some(self.count()) + } + + /// A wrapper for the key/value retrieval of the BoundedBTreeMap. + pub(crate) fn get_entry_for_era(&self, reward_era: &RewardEra) -> Option<&BalanceOf> { + self.0.get(reward_era) + } + + /// Returns how much was staked during the given era, even if there is no explicit entry for that era. + /// If there is no history entry for `reward_era`, returns the next earliest entry's staking balance. + /// + /// Note there is no sense of what the current era is; subsequent calls could return a different result + /// if 'reward_era' is the current era and there has been a boost or unstake. + pub(crate) fn get_amount_staked_for_era(&self, reward_era: &RewardEra) -> BalanceOf { + // this gives an ordered-by-key Iterator + let mut bmap_iter = self.0.iter(); + let mut eligible_amount: BalanceOf = Zero::zero(); + while let Some((era, balance)) = bmap_iter.next() { + if era.eq(reward_era) { + return *balance; + } + // there was a boost or unstake in this era. + else if era.gt(reward_era) { + return eligible_amount; + } // eligible_amount has been staked through reward_era + eligible_amount = *balance; + } + eligible_amount + } + + /// Returns the number of history items + pub fn count(&self) -> usize { + self.0.len() + } + + fn remove_oldest_entry_if_full(&mut self) { + if self.is_full() { + // compiler errors with unwrap + if let Some((earliest_key, _earliest_val)) = self.0.first_key_value() { + self.0.remove(&earliest_key.clone()); + } + } + } + + fn get_last_staking_amount(&self) -> BalanceOf { + // compiler errors with unwrap + if let Some((_last_key, last_value)) = self.0.last_key_value() { + return *last_value; + }; + Zero::zero() + } + + fn is_full(&self) -> bool { + self.count().eq(&(T::ProviderBoostHistoryLimit::get() as usize)) + } +} + +/// Struct with utilities for storing and updating unlock chunks +#[derive(Debug, TypeInfo, PartialEqNoBound, EqNoBound, Clone, Decode, Encode, MaxEncodedLen)] +#[scale_info(skip_type_params(T))] +pub struct RetargetInfo { + /// How many times the account has retargeted this RewardEra + pub retarget_count: u32, + /// The last RewardEra they retargeted + pub last_retarget_at: RewardEra, + _marker: PhantomData, +} + +impl Default for RetargetInfo { + fn default() -> Self { + Self { retarget_count: 0u32, last_retarget_at: Zero::zero(), _marker: Default::default() } + } +} + +impl RetargetInfo { + /// Constructor + pub fn new(retarget_count: u32, last_retarget_at: RewardEra) -> Self { + Self { retarget_count, last_retarget_at, _marker: Default::default() } + } + /// Increment retarget count and return Some() or + /// If there are too many, return None + pub fn update(&mut self, current_era: RewardEra) -> Option<()> { + let max_retargets = T::MaxRetargetsPerRewardEra::get(); + if self.retarget_count.ge(&max_retargets) && self.last_retarget_at.eq(¤t_era) { + return None; + } + if self.last_retarget_at.lt(¤t_era) { + self.last_retarget_at = current_era; + self.retarget_count = 1; + } else { + self.retarget_count = self.retarget_count.saturating_add(1u32); + } + Some(()) + } +} + +/// A trait that provides the Economic Model for Provider Boosting. +pub trait ProviderBoostRewardsProvider { + /// The type for currency + type Balance; + + /// Return the size of the reward pool using the current economic model + fn reward_pool_size(total_staked: BalanceOf) -> BalanceOf; + + /// Calculate the reward for a single era. We don't care about the era number, + /// just the values. + fn era_staking_reward( + era_amount_staked: BalanceOf, // how much individual staked for a specific era + era_total_staked: BalanceOf, // how much everyone staked for the era + era_reward_pool_size: BalanceOf, // how much token in the reward pool that era + ) -> BalanceOf; + + /// Return the effective amount when staked for a Provider Boost + /// The amount is multiplied by a factor > 0 and < 1. + fn capacity_boost(amount: BalanceOf) -> BalanceOf; +} diff --git a/pallets/capacity/src/weights.rs b/pallets/capacity/src/weights.rs index 17045c43b7..cc1c8ee2c7 100644 --- a/pallets/capacity/src/weights.rs +++ b/pallets/capacity/src/weights.rs @@ -35,9 +35,14 @@ use core::marker::PhantomData; pub trait WeightInfo { fn stake() -> Weight; fn withdraw_unstaked() -> Weight; + fn start_new_epoch_if_needed() -> Weight; + fn start_new_reward_era_if_needed() -> Weight; fn on_initialize() -> Weight; fn unstake() -> Weight; fn set_epoch_length() -> Weight; + fn change_staking_target() -> Weight; + fn provider_boost() -> Weight; + fn claim_staking_rewards() -> Weight; } /// Weights for `pallet_capacity` using the Substrate node and recommended hardware. @@ -96,8 +101,38 @@ impl WeightInfo for SubstrateWeight { .saturating_add(T::DbWeight::get().reads(2_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } + /// Storage: `Capacity::CurrentEpochInfo` (r:1 w:1) + /// Proof: `Capacity::CurrentEpochInfo` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Capacity::EpochLength` (r:1 w:0) + /// Proof: `Capacity::EpochLength` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + fn start_new_epoch_if_needed() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `2974` + // Minimum execution time: 2_231_000 picoseconds. + Weight::from_parts(2_419_000, 2974) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: `Capacity::CurrentEraProviderBoostTotal` (r:1 w:0) + /// Proof: `Capacity::CurrentEraProviderBoostTotal` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Capacity::ProviderBoostRewardPools` (r:1 w:1) + /// Proof: `Capacity::ProviderBoostRewardPools` (`max_values`: None, `max_size`: Some(113), added: 2588, mode: `MaxEncodedLen`) + fn start_new_reward_era_if_needed() -> Weight { + // Proof Size summary in bytes: + // Measured: `193` + // Estimated: `5063` + // Minimum execution time: 5_000_000 picoseconds. + Weight::from_parts(5_000_000, 5063) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: `Capacity::ProviderBoostHistories` (r:1 w:1) + /// Proof: `Capacity::ProviderBoostHistories` (`max_values`: None, `max_size`: Some(641), added: 3116, mode: `MaxEncodedLen`) /// Storage: `Capacity::StakingAccountLedger` (r:1 w:1) /// Proof: `Capacity::StakingAccountLedger` (`max_values`: None, `max_size`: Some(57), added: 2532, mode: `MaxEncodedLen`) + /// Storage: `Capacity::CurrentEraProviderBoostTotal` (r:1 w:1) + /// Proof: `Capacity::CurrentEraProviderBoostTotal` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) /// Storage: `Capacity::UnstakeUnlocks` (r:1 w:1) /// Proof: `Capacity::UnstakeUnlocks` (`max_values`: None, `max_size`: Some(121), added: 2596, mode: `MaxEncodedLen`) /// Storage: `Capacity::StakingTargetLedger` (r:1 w:1) @@ -123,6 +158,65 @@ impl WeightInfo for SubstrateWeight { Weight::from_parts(4_164_000, 0) .saturating_add(T::DbWeight::get().writes(1_u64)) } + /// Storage: `Capacity::Retargets` (r:1 w:1) + /// Proof: `Capacity::Retargets` (`max_values`: None, `max_size`: Some(48), added: 2523, mode: `MaxEncodedLen`) + /// Storage: `Msa::ProviderToRegistryEntry` (r:1 w:0) + /// Proof: `Msa::ProviderToRegistryEntry` (`max_values`: None, `max_size`: Some(33), added: 2508, mode: `MaxEncodedLen`) + /// Storage: `Capacity::StakingAccountLedger` (r:1 w:0) + /// Proof: `Capacity::StakingAccountLedger` (`max_values`: None, `max_size`: Some(57), added: 2532, mode: `MaxEncodedLen`) + /// Storage: `Capacity::StakingTargetLedger` (r:2 w:2) + /// Proof: `Capacity::StakingTargetLedger` (`max_values`: None, `max_size`: Some(88), added: 2563, mode: `MaxEncodedLen`) + /// Storage: `Capacity::CapacityLedger` (r:2 w:2) + /// Proof: `Capacity::CapacityLedger` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `MaxEncodedLen`) + fn change_staking_target() -> Weight { + // Proof Size summary in bytes: + // Measured: `315` + // Estimated: `7601` + // Minimum execution time: 29_000_000 picoseconds. + Weight::from_parts(31_000_000, 7601) + .saturating_add(T::DbWeight::get().reads(7_u64)) + .saturating_add(T::DbWeight::get().writes(5_u64)) + } + /// Storage: `Msa::ProviderToRegistryEntry` (r:1 w:0) + /// Proof: `Msa::ProviderToRegistryEntry` (`max_values`: None, `max_size`: Some(33), added: 2508, mode: `MaxEncodedLen`) + /// Storage: `Capacity::StakingAccountLedger` (r:1 w:1) + /// Proof: `Capacity::StakingAccountLedger` (`max_values`: None, `max_size`: Some(57), added: 2532, mode: `MaxEncodedLen`) + /// Storage: `Capacity::UnstakeUnlocks` (r:1 w:0) + /// Proof: `Capacity::UnstakeUnlocks` (`max_values`: None, `max_size`: Some(121), added: 2596, mode: `MaxEncodedLen`) + /// Storage: `Balances::Freezes` (r:1 w:1) + /// Proof: `Balances::Freezes` (`max_values`: None, `max_size`: Some(85), added: 2560, mode: `MaxEncodedLen`) + /// Storage: `Balances::Locks` (r:1 w:0) + /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(1299), added: 3774, mode: `MaxEncodedLen`) + /// Storage: `Capacity::StakingTargetLedger` (r:1 w:1) + /// Proof: `Capacity::StakingTargetLedger` (`max_values`: None, `max_size`: Some(88), added: 2563, mode: `MaxEncodedLen`) + /// Storage: `Capacity::CapacityLedger` (r:1 w:1) + /// Proof: `Capacity::CapacityLedger` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `MaxEncodedLen`) + /// Storage: `Capacity::ProviderBoostHistories` (r:1 w:1) + /// Proof: `Capacity::ProviderBoostHistories` (`max_values`: None, `max_size`: Some(641), added: 3116, mode: `MaxEncodedLen`) + /// Storage: `Capacity::CurrentEraProviderBoostTotal` (r:1 w:1) + /// Proof: `Capacity::CurrentEraProviderBoostTotal` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + fn provider_boost() -> Weight { + // Proof Size summary in bytes: + // Measured: `247` + // Estimated: `6249` + // Minimum execution time: 45_000_000 picoseconds. + Weight::from_parts(48_000_000, 6249) + .saturating_add(T::DbWeight::get().reads(9_u64)) + .saturating_add(T::DbWeight::get().writes(6_u64)) + } + /// Storage: `Capacity::ProviderBoostHistories` (r:1 w:1) + /// Proof: `Capacity::ProviderBoostHistories` (`max_values`: None, `max_size`: Some(641), added: 3116, mode: `MaxEncodedLen`) + /// Storage: `Capacity::ProviderBoostRewardPools` (r:7 w:0) + /// Proof: `Capacity::ProviderBoostRewardPools` (`max_values`: None, `max_size`: Some(113), added: 2588, mode: `MaxEncodedLen`) + fn claim_staking_rewards() -> Weight { + // Proof Size summary in bytes: + // Measured: `1592` + // Estimated: `20591` + // Minimum execution time: 104_000_000 picoseconds. + Weight::from_parts(105_000_000, 20591) + .saturating_add(T::DbWeight::get().reads(8_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } } // For backwards compatibility and tests. @@ -180,8 +274,38 @@ impl WeightInfo for () { .saturating_add(RocksDbWeight::get().reads(2_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } + /// Storage: `Capacity::CurrentEraProviderBoostTotal` (r:1 w:0) + /// Proof: `Capacity::CurrentEraProviderBoostTotal` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + /// Storage: `Capacity::ProviderBoostRewardPools` (r:1 w:1) + /// Proof: `Capacity::ProviderBoostRewardPools` (`max_values`: None, `max_size`: Some(113), added: 2588, mode: `MaxEncodedLen`) + fn start_new_reward_era_if_needed() -> Weight { + // Proof Size summary in bytes: + // Measured: `193` + // Estimated: `5063` + // Minimum execution time: 5_000_000 picoseconds. + Weight::from_parts(5_000_000, 5063) + .saturating_add(RocksDbWeight::get().reads(2_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } + /// Storage: `Capacity::CurrentEpochInfo` (r:1 w:1) + /// Proof: `Capacity::CurrentEpochInfo` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `Capacity::EpochLength` (r:1 w:0) + /// Proof: `Capacity::EpochLength` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + fn start_new_epoch_if_needed() -> Weight { + // Proof Size summary in bytes: + // Measured: `0` + // Estimated: `2974` + // Minimum execution time: 2_231_000 picoseconds. + Weight::from_parts(2_419_000, 2974) + .saturating_add(RocksDbWeight::get().reads(2_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } + /// Storage: `Capacity::ProviderBoostHistories` (r:1 w:1) + /// Proof: `Capacity::ProviderBoostHistories` (`max_values`: None, `max_size`: Some(641), added: 3116, mode: `MaxEncodedLen`) /// Storage: `Capacity::StakingAccountLedger` (r:1 w:1) /// Proof: `Capacity::StakingAccountLedger` (`max_values`: None, `max_size`: Some(57), added: 2532, mode: `MaxEncodedLen`) + /// Storage: `Capacity::StakingRewardPool` (r:1 w:1) + /// Proof: `Capacity::StakingRewardPool` (`max_values`: None, `max_size`: Some(60), added: 2535, mode: `MaxEncodedLen`) /// Storage: `Capacity::UnstakeUnlocks` (r:1 w:1) /// Proof: `Capacity::UnstakeUnlocks` (`max_values`: None, `max_size`: Some(121), added: 2596, mode: `MaxEncodedLen`) /// Storage: `Capacity::StakingTargetLedger` (r:1 w:1) @@ -207,6 +331,65 @@ impl WeightInfo for () { Weight::from_parts(4_164_000, 0) .saturating_add(RocksDbWeight::get().writes(1_u64)) } + /// Storage: `Capacity::Retargets` (r:1 w:1) + /// Proof: `Capacity::Retargets` (`max_values`: None, `max_size`: Some(48), added: 2523, mode: `MaxEncodedLen`) + /// Storage: `Msa::ProviderToRegistryEntry` (r:1 w:0) + /// Proof: `Msa::ProviderToRegistryEntry` (`max_values`: None, `max_size`: Some(33), added: 2508, mode: `MaxEncodedLen`) + /// Storage: `Capacity::StakingAccountLedger` (r:1 w:0) + /// Proof: `Capacity::StakingAccountLedger` (`max_values`: None, `max_size`: Some(57), added: 2532, mode: `MaxEncodedLen`) + /// Storage: `Capacity::StakingTargetLedger` (r:2 w:2) + /// Proof: `Capacity::StakingTargetLedger` (`max_values`: None, `max_size`: Some(88), added: 2563, mode: `MaxEncodedLen`) + /// Storage: `Capacity::CapacityLedger` (r:2 w:2) + /// Proof: `Capacity::CapacityLedger` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `MaxEncodedLen`) + fn change_staking_target() -> Weight { + // Proof Size summary in bytes: + // Measured: `315` + // Estimated: `7601` + // Minimum execution time: 29_000_000 picoseconds. + Weight::from_parts(31_000_000, 7601) + .saturating_add(RocksDbWeight::get().reads(7_u64)) + .saturating_add(RocksDbWeight::get().writes(5_u64)) + } + /// Storage: `Msa::ProviderToRegistryEntry` (r:1 w:0) + /// Proof: `Msa::ProviderToRegistryEntry` (`max_values`: None, `max_size`: Some(33), added: 2508, mode: `MaxEncodedLen`) + /// Storage: `Capacity::StakingAccountLedger` (r:1 w:1) + /// Proof: `Capacity::StakingAccountLedger` (`max_values`: None, `max_size`: Some(57), added: 2532, mode: `MaxEncodedLen`) + /// Storage: `Capacity::UnstakeUnlocks` (r:1 w:0) + /// Proof: `Capacity::UnstakeUnlocks` (`max_values`: None, `max_size`: Some(121), added: 2596, mode: `MaxEncodedLen`) + /// Storage: `Balances::Freezes` (r:1 w:1) + /// Proof: `Balances::Freezes` (`max_values`: None, `max_size`: Some(85), added: 2560, mode: `MaxEncodedLen`) + /// Storage: `Balances::Locks` (r:1 w:0) + /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(1299), added: 3774, mode: `MaxEncodedLen`) + /// Storage: `Capacity::StakingTargetLedger` (r:1 w:1) + /// Proof: `Capacity::StakingTargetLedger` (`max_values`: None, `max_size`: Some(88), added: 2563, mode: `MaxEncodedLen`) + /// Storage: `Capacity::CapacityLedger` (r:1 w:1) + /// Proof: `Capacity::CapacityLedger` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `MaxEncodedLen`) + /// Storage: `Capacity::ProviderBoostHistories` (r:1 w:1) + /// Proof: `Capacity::ProviderBoostHistories` (`max_values`: None, `max_size`: Some(641), added: 3116, mode: `MaxEncodedLen`) + /// Storage: `Capacity::CurrentEraProviderBoostTotal` (r:1 w:1) + /// Proof: `Capacity::CurrentEraProviderBoostTotal` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) + fn provider_boost() -> Weight { + // Proof Size summary in bytes: + // Measured: `247` + // Estimated: `6249` + // Minimum execution time: 45_000_000 picoseconds. + Weight::from_parts(48_000_000, 6249) + .saturating_add(RocksDbWeight::get().reads(9_u64)) + .saturating_add(RocksDbWeight::get().writes(6_u64)) + } + /// Storage: `Capacity::ProviderBoostHistories` (r:1 w:1) + /// Proof: `Capacity::ProviderBoostHistories` (`max_values`: None, `max_size`: Some(641), added: 3116, mode: `MaxEncodedLen`) + /// Storage: `Capacity::ProviderBoostRewardPools` (r:7 w:0) + /// Proof: `Capacity::ProviderBoostRewardPools` (`max_values`: None, `max_size`: Some(113), added: 2588, mode: `MaxEncodedLen`) + fn claim_staking_rewards() -> Weight { + // Proof Size summary in bytes: + // Measured: `1592` + // Estimated: `20591` + // Minimum execution time: 104_000_000 picoseconds. + Weight::from_parts(105_000_000, 20591) + .saturating_add(RocksDbWeight::get().reads(8_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } } @@ -269,6 +452,30 @@ mod tests { ); } #[test] + fn test_start_new_epoch_if_needed() { + assert!( + BlockWeights::get() + .per_class + .get(frame_support::dispatch::DispatchClass::Normal) + .max_extrinsic + .unwrap_or_else(::max_value) + .proof_size() + > 2974 + ); + } + #[test] + fn test_start_new_reward_era_if_needed() { + assert!( + BlockWeights::get() + .per_class + .get(frame_support::dispatch::DispatchClass::Normal) + .max_extrinsic + .unwrap_or_else(::max_value) + .proof_size() + > 5063 + ); + } + #[test] fn test_unstake() { assert!( BlockWeights::get() diff --git a/pallets/frequency-tx-payment/src/tests/mock.rs b/pallets/frequency-tx-payment/src/tests/mock.rs index 3e2bcab165..864c34922e 100644 --- a/pallets/frequency-tx-payment/src/tests/mock.rs +++ b/pallets/frequency-tx-payment/src/tests/mock.rs @@ -11,7 +11,7 @@ use pallet_transaction_payment::FungibleAdapter; use sp_core::{ConstU8, H256}; use sp_runtime::{ traits::{BlakeTwo256, Convert, IdentityLookup, SaturatedConversion}, - AccountId32, BuildStorage, Perbill, + AccountId32, BuildStorage, Perbill, Permill, }; use frame_support::{ @@ -208,6 +208,7 @@ pub const TEST_TOKEN_PER_CAPACITY: u32 = 10; // Needs parameter_types! for the Perbill parameter_types! { pub const TestCapacityPerToken: Perbill = Perbill::from_percent(TEST_TOKEN_PER_CAPACITY); + pub const TestRewardCap: Permill = Permill::from_parts(3_800); // 0.38% or 0.0038 per RewardEra } impl pallet_capacity::Config for Test { @@ -228,6 +229,13 @@ impl pallet_capacity::Config for Test { type EpochNumber = u32; type CapacityPerToken = TestCapacityPerToken; type RuntimeFreezeReason = RuntimeFreezeReason; + type EraLength = ConstU32<5>; + type ProviderBoostHistoryLimit = ConstU32<6>; + type RewardsProvider = Capacity; + type MaxRetargetsPerRewardEra = ConstU32<5>; + type RewardPoolPerEra = ConstU64<10_000>; + type RewardPercentCap = TestRewardCap; + type RewardPoolChunkLength = ConstU32<2>; } use pallet_balances::Call as BalancesCall; diff --git a/runtime/common/src/constants.rs b/runtime/common/src/constants.rs index 24bcde806f..9613263ec8 100644 --- a/runtime/common/src/constants.rs +++ b/runtime/common/src/constants.rs @@ -398,6 +398,9 @@ parameter_types! { // 1:50 Capacity:Token, must be declared this way instead of using `from_rational` because of // ```error[E0015]: cannot call non-const fn `Perbill::from_rational::` in constant functions``` pub const CapacityPerToken: Perbill = Perbill::from_percent(2); + pub const CapacityRewardCap: Permill = Permill::from_parts(3_800); // 0.38% or 0.0038 per RewardEra } +pub type CapacityRewardEraLength = + ConstU32<{ prod_or_testnet_or_local!(14 * DAYS, 1 * HOURS, 50) }>; // -end- Capacity Pallet --- diff --git a/runtime/frequency/Cargo.toml b/runtime/frequency/Cargo.toml index 6f1149e4c9..b904dae80a 100644 --- a/runtime/frequency/Cargo.toml +++ b/runtime/frequency/Cargo.toml @@ -59,6 +59,7 @@ sp-core = { workspace = true } sp-inherents = { workspace = true } sp-io = { workspace = true } sp-genesis-builder = { workspace = true } +static_assertions = { workspace = true } sp-offchain = { workspace = true } sp-runtime = { workspace = true } @@ -72,6 +73,7 @@ pallet-time-release = { path = "../../pallets/time-release", default-features = common-primitives = { default-features = false, path = "../../common/primitives" } common-runtime = { path = "../common", default-features = false } pallet-capacity = { path = "../../pallets/capacity", default-features = false } +pallet-capacity-runtime-api = { path="../../pallets/capacity/src/runtime-api", default-features = false } pallet-frequency-tx-payment = { path = "../../pallets/frequency-tx-payment", default-features = false } pallet-frequency-tx-payment-runtime-api = { path = "../../pallets/frequency-tx-payment/src/runtime-api", default-features = false } pallet-messages = { path = "../../pallets/messages", default-features = false } @@ -119,6 +121,7 @@ std = [ "pallet-authorship/std", "pallet-balances/std", "pallet-capacity/std", + "pallet-capacity-runtime-api/std", "pallet-collator-selection/std", "pallet-collective/std", "pallet-democracy/std", diff --git a/runtime/frequency/src/lib.rs b/runtime/frequency/src/lib.rs index 521ae49d8f..8f8d9f0e2f 100644 --- a/runtime/frequency/src/lib.rs +++ b/runtime/frequency/src/lib.rs @@ -42,6 +42,7 @@ use sp_std::prelude::*; #[cfg(feature = "std")] use sp_version::NativeVersion; use sp_version::RuntimeVersion; +use static_assertions::const_assert; use common_primitives::{ handles::{BaseHandle, DisplayHandle, HandleResponse, PresumptiveSuffixesResponse}, @@ -60,7 +61,10 @@ use common_primitives::{ }; pub use common_runtime::{ - constants::{currency::EXISTENTIAL_DEPOSIT, *}, + constants::{ + currency::{CENTS, EXISTENTIAL_DEPOSIT}, + *, + }, fee::WeightToFee, prod_or_testnet_or_local, proxy::ProxyType, @@ -105,6 +109,8 @@ pub use pallet_time_release; // Polkadot Imports use polkadot_runtime_common::{BlockHashCount, SlowAdjustingFeeUpdate}; +use common_primitives::capacity::UnclaimedRewardInfo; +use common_runtime::weights::rocksdb_weights::constants::RocksDbWeight; pub use common_runtime::{ constants::MaxSchemaGrants, weights, @@ -112,8 +118,6 @@ pub use common_runtime::{ }; use frame_support::traits::Contains; -use common_runtime::weights::rocksdb_weights::constants::RocksDbWeight; - mod genesis; /// Interface to collective pallet to propose a proposal. @@ -339,7 +343,11 @@ pub type Executive = frame_executive::Executive< frame_system::ChainContext, Runtime, AllPalletsWithSystem, - (MigratePalletsCurrentStorage, pallet_capacity::migration::v4::MigrationToV4), + ( + MigratePalletsCurrentStorage, + pallet_capacity::migration::v4::MigrationToV4, + pallet_capacity::migration::provider_boost_init::ProviderBoostInit, + ), >; pub struct MigratePalletsCurrentStorage(sp_std::marker::PhantomData); @@ -396,7 +404,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { spec_name: create_runtime_str!("frequency"), impl_name: create_runtime_str!("frequency"), authoring_version: 1, - spec_version: 125, + spec_version: 130, impl_version: 0, apis: RUNTIME_API_VERSIONS, transaction_version: 1, @@ -410,7 +418,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { spec_name: create_runtime_str!("frequency-testnet"), impl_name: create_runtime_str!("frequency"), authoring_version: 1, - spec_version: 125, + spec_version: 130, impl_version: 0, apis: RUNTIME_API_VERSIONS, transaction_version: 1, @@ -549,6 +557,15 @@ impl pallet_msa::Config for Runtime { >; } +parameter_types! { + /// The maximum number of eras over which one can claim rewards + pub const ProviderBoostHistoryLimit : u32 = 30; + /// The number of chunks of Reward Pool history we expect to store + pub const RewardPoolChunkLength: u32 = 5; +} +// RewardPoolChunkLength MUST be a divisor of ProviderBoostHistoryLimit +const_assert!(ProviderBoostHistoryLimit::get() % RewardPoolChunkLength::get() == 0); + impl pallet_capacity::Config for Runtime { type RuntimeEvent = RuntimeEvent; type WeightInfo = pallet_capacity::weights::SubstrateWeight; @@ -564,6 +581,15 @@ impl pallet_capacity::Config for Runtime { type EpochNumber = u32; type CapacityPerToken = CapacityPerToken; type RuntimeFreezeReason = RuntimeFreezeReason; + type EraLength = CapacityRewardEraLength; + type ProviderBoostHistoryLimit = ProviderBoostHistoryLimit; + type RewardsProvider = Capacity; + type MaxRetargetsPerRewardEra = ConstU32<2>; + // Value determined by desired inflation rate limits for chosen economic model + type RewardPoolPerEra = ConstU128<{ currency::CENTS.saturating_mul(172_602_740u128) }>; + type RewardPercentCap = CapacityRewardCap; + // Must evenly divide ProviderBoostHistoryLimit + type RewardPoolChunkLength = RewardPoolChunkLength; } impl pallet_schemas::Config for Runtime { @@ -748,7 +774,7 @@ impl pallet_democracy::Config for Runtime { type EnactmentPeriod = EnactmentPeriod; type RuntimeEvent = RuntimeEvent; type FastTrackVotingPeriod = FastTrackVotingPeriod; - type InstantAllowed = frame_support::traits::ConstBool; + type InstantAllowed = ConstBool; type LaunchPeriod = LaunchPeriod; type MaxProposals = DemocracyMaxProposals; type MaxVotes = DemocracyMaxVotes; @@ -1581,6 +1607,15 @@ sp_api::impl_runtime_apis! { Handles::validate_handle(base_handle.to_vec()) } } + impl pallet_capacity_runtime_api::CapacityRuntimeApi for Runtime { + fn list_unclaimed_rewards(who: AccountId) -> Vec> { + match Capacity::list_unclaimed_rewards(&who) { + Ok(rewards) => return rewards.into_inner(), + Err(_) => return Vec::new(), + } + + } + } #[cfg(feature = "try-runtime")] impl frame_try_runtime::TryRuntime for Runtime {