diff --git a/designdocs/capacity_staking_rewards_implementation.md b/designdocs/capacity_staking_rewards_implementation.md index a8b3221051..ac8f762c16 100644 --- a/designdocs/capacity_staking_rewards_implementation.md +++ b/designdocs/capacity_staking_rewards_implementation.md @@ -231,28 +231,27 @@ pub enum Error { to_era: Option ); ``` + Both emit events `StakingRewardClaimed` with the parameters of the extrinsic. 2. **change_staking_target(origin, from, to, amount)** +Changes a staking account detail's target MSA Id to a new one by `amount` +Rules for this are similar to unstaking; if `amount` would leave less than the minimum staking amount for the `from` target, the entire amount is retargeted. +No more than T::MaxUnlockingChunks staking amounts may be retargeted within this Thawing Period. +Each call creates one chunk. Emits a `StakingTargetChanged` event with the parameters of the extrinsic. + ```rust -/// Change a staking account detail's target MSA Id to a new one. -/// If Some(amount) is specified, that amount up to the total staking amount is retargeted. -/// 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. -/// If amount is None, ALL of the total staking amount for 'from' is changed to the new target MSA Id. -/// No more than T::MaxUnlockingChunks staking amounts may be retargeted within this Thawing Period. -/// Each call creates one chunk. /// Errors: -/// - NotAStakingAccount if origin has no StakingAccount associated with the target -/// - pallet_msa::Error::ProviderNotRegistered if 'to' MSA Id does not exist or is not a Provider /// - MaxUnlockingChunksExceeded if 'from' target staking amount is still thawing in the staking unlock chunks (either type) -/// - ZeroAmountNotAllowed if `amount` is zero -/// - InsufficientStakingAmount if `amount` to transfer to the new target is below the minimum staking amount. +/// - StakerTargetRelationshipNotFound` if `from` is not a staking target for Origin. This also covers when account's MSA is not staking anything at all or account has no MSA +/// - StakingAmountBelowMinimum if amount to retarget is below the minimum staking amount. +/// - InsufficientStakingBalance if amount to retarget exceeds what the staker has targeted to the `from` MSA Id. +/// - InvalidTarget if `to` is not a Registered Provider. #[pallet:call_index(n+1)] // n = current call index in the pallet pub fn change_staking_target( origin: OriginFor, from: MessageSourceId, to: MessageSourceId, - amount: Option> + amount: BalanceOf ); ``` diff --git a/integration-tests/capacity/change_staking_target.test.ts b/integration-tests/capacity/change_staking_target.test.ts new file mode 100644 index 0000000000..fcaeeccedd --- /dev/null +++ b/integration-tests/capacity/change_staking_target.test.ts @@ -0,0 +1,54 @@ +import "@frequency-chain/api-augment"; +import { KeyringPair } from "@polkadot/keyring/types"; +import { u64, } from "@polkadot/types"; +import assert from "assert"; +import { ExtrinsicHelper, } from "../scaffolding/extrinsicHelpers"; +import { + devAccounts, createKeys, createMsaAndProvider, + stakeToProvider, CHAIN_ENVIRONMENT, + TEST_EPOCH_LENGTH, setEpochLength, + CENTS, DOLLARS, createAndFundKeypair, createProviderKeysAndId +} + from "../scaffolding/helpers"; +import { firstValueFrom } from "rxjs"; +import { MessageSourceId} from "@frequency-chain/api-augment/interfaces"; + +describe.only("change_staking_target tests", () => { + const tokenMinStake: bigint = 1n * CENTS; + const capacityMin: bigint = tokenMinStake / 50n; + + const unusedMsaId = async () => { + const maxMsaId = (await ExtrinsicHelper.getCurrentMsaIdentifierMaximum()).toNumber(); + return maxMsaId + 99; + } + + before(async () => { + if (process.env.CHAIN_ENVIRONMENT === CHAIN_ENVIRONMENT.DEVELOPMENT) { + await setEpochLength(devAccounts[0].keys, TEST_EPOCH_LENGTH); + } + }); + + it("happy path succeeds", async () => { + const providerBalance = 2n * DOLLARS; + const stakeKeys = createKeys("staker"); + const oldProvider = await createMsaAndProvider(stakeKeys, "Provider1", providerBalance); + const [_unused, newProvider] = await createProviderKeysAndId(); + + await assert.doesNotReject(stakeToProvider(stakeKeys, oldProvider, tokenMinStake*3n)); + + const call = ExtrinsicHelper.changeStakingTarget(stakeKeys, oldProvider, newProvider, tokenMinStake); + const [events] = await call.signAndSend(); + assert.notEqual(events, undefined); + }); + + // not intended to be exhaustive, just check one error case + it("fails if 'to' is not a Provider", async () => { + const providerBalance = 2n * DOLLARS; + const stakeKeys = createKeys("staker"); + const notAProvider = await unusedMsaId(); + const oldProvider = await createMsaAndProvider(stakeKeys, "Provider1", providerBalance); + await assert.doesNotReject(stakeToProvider(stakeKeys, oldProvider, tokenMinStake*3n)); + const call = ExtrinsicHelper.changeStakingTarget(stakeKeys, oldProvider, notAProvider, tokenMinStake); + await assert.rejects(call.signAndSend(), {name: "InvalidTarget"}) + }); +}); diff --git a/integration-tests/capacity/replenishment.test.ts b/integration-tests/capacity/replenishment.test.ts index f2787affb1..affd8d27f1 100644 --- a/integration-tests/capacity/replenishment.test.ts +++ b/integration-tests/capacity/replenishment.test.ts @@ -101,7 +101,7 @@ describe("Capacity Replenishment Testing: ", function () { // new user/msa stakes to provider const userKeys = createKeys("userKeys"); await fundKeypair(devAccounts[0].keys, userKeys, 5n * DOLLARS); - let [_, events] = await ExtrinsicHelper.stake(userKeys, stakeProviderId, userStakeAmt).fundAndSend(); + let [_, events] = await ExtrinsicHelper.stake(userKeys, stakeProviderId, userStakeAmt, 'MaximumCapacity').fundAndSend(); assertEvent(events, 'system.ExtrinsicSuccess'); const payload = JSON.stringify({ changeType: 1, fromId: 1, objectId: 2 }) @@ -125,7 +125,7 @@ describe("Capacity Replenishment Testing: ", function () { assert(remainingCapacity < callCapacityCost); // user stakes tiny additional amount - [_, events] = await ExtrinsicHelper.stake(userKeys, stakeProviderId, userIncrementAmt).fundAndSend(); + [_, events] = await ExtrinsicHelper.stake(userKeys, stakeProviderId, userIncrementAmt, 'MaximumCapacity').fundAndSend(); assertEvent(events, 'capacity.Staked'); // provider can now send a message diff --git a/integration-tests/capacity/staking.test.ts b/integration-tests/capacity/staking.test.ts index 6789b55ed4..5308ebddf9 100644 --- a/integration-tests/capacity/staking.test.ts +++ b/integration-tests/capacity/staking.test.ts @@ -208,7 +208,7 @@ describe("Capacity Staking Tests", function () { const stakeAmount = 10n * CENTS; const stakeKeys = await createAndFundKeypair(stakeAmount, "StakeKeys"); - const failStakeObj = ExtrinsicHelper.stake(stakeKeys, maxMsaId + 1, stakeAmount); + const failStakeObj = ExtrinsicHelper.stake(stakeKeys, maxMsaId + 1, stakeAmount, 'MaximumCapacity'); await assert.rejects(failStakeObj.fundAndSend(), { name: "InvalidTarget" }); }); }); @@ -219,7 +219,7 @@ describe("Capacity Staking Tests", function () { let providerId = await createMsaAndProvider(stakingKeys, "stakingKeys", 150n * CENTS); let stakeAmount = 1500n; - const failStakeObj = ExtrinsicHelper.stake(stakingKeys, providerId, stakeAmount); + const failStakeObj = ExtrinsicHelper.stake(stakingKeys, providerId, stakeAmount, 'MaximumCapacity'); await assert.rejects(failStakeObj.fundAndSend(), { name: "InsufficientStakingAmount" }); }); }); @@ -229,7 +229,7 @@ describe("Capacity Staking Tests", function () { let stakingKeys = createKeys("stakingKeys"); let providerId = await createMsaAndProvider(stakingKeys, "stakingKeys", ); - const failStakeObj = ExtrinsicHelper.stake(stakingKeys, providerId, 0); + const failStakeObj = ExtrinsicHelper.stake(stakingKeys, providerId, 0, 'MaximumCapacity'); await assert.rejects(failStakeObj.fundAndSend(), { name: "ZeroAmountNotAllowed" }); }); }); @@ -240,7 +240,7 @@ describe("Capacity Staking Tests", function () { let providerId = await createMsaAndProvider(stakingKeys, "stakingKeys"); let stakingAmount = 1n * DOLLARS; - const failStakeObj = ExtrinsicHelper.stake(stakingKeys, providerId, stakingAmount); + const failStakeObj = ExtrinsicHelper.stake(stakingKeys, providerId, stakingAmount, 'MaximumCapacity'); await assert.rejects(failStakeObj.fundAndSend(), { name: "BalanceTooLowtoStake" }); }); }); @@ -282,7 +282,7 @@ describe("Capacity Staking Tests", function () { let stakingKeys: KeyringPair = createKeys("stakingKeys"); let providerId: u64 = await createMsaAndProvider(stakingKeys, "stakingKeys", accountBalance); - const stakeObj = ExtrinsicHelper.stake(stakingKeys, providerId, tokenMinStake); + const stakeObj = ExtrinsicHelper.stake(stakingKeys, providerId, tokenMinStake, 'MaximumCapacity'); const [stakeEvent] = await stakeObj.fundAndSend(); assert.notEqual(stakeEvent, undefined, "should return a Stake event"); diff --git a/integration-tests/capacity/transactions.test.ts b/integration-tests/capacity/transactions.test.ts index 36e716bdb3..4f0f709775 100644 --- a/integration-tests/capacity/transactions.test.ts +++ b/integration-tests/capacity/transactions.test.ts @@ -513,7 +513,7 @@ describe("Capacity Transactions", function () { it("fails to pay with Capacity for a non-capacity transaction", async function () { const capacityKeys = createKeys("CapacityKeys"); const capacityProvider = await createMsaAndProvider(capacityKeys, "CapacityProvider", FUNDS_AMOUNT); - const nonCapacityTxn = ExtrinsicHelper.stake(capacityKeys, capacityProvider, 1n * CENTS); + const nonCapacityTxn = ExtrinsicHelper.stake(capacityKeys, capacityProvider, 1n * CENTS, 'MaximumCapacity'); await assert.rejects(nonCapacityTxn.payWithCapacity(), { name: "RpcError", message: "1010: Invalid Transaction: Custom error: 0" diff --git a/integration-tests/scaffolding/extrinsicHelpers.ts b/integration-tests/scaffolding/extrinsicHelpers.ts index 6d30e41f2b..655ce187d6 100644 --- a/integration-tests/scaffolding/extrinsicHelpers.ts +++ b/integration-tests/scaffolding/extrinsicHelpers.ts @@ -384,8 +384,8 @@ export class ExtrinsicHelper { public static setEpochLength(keys: KeyringPair, epoch_length: any): Extrinsic { return new Extrinsic(() => ExtrinsicHelper.api.tx.capacity.setEpochLength(epoch_length), keys, ExtrinsicHelper.api.events.capacity.EpochLengthUpdated); } - public static stake(keys: KeyringPair, target: any, amount: any): Extrinsic { - return new Extrinsic(() => ExtrinsicHelper.api.tx.capacity.stake(target, amount), keys, ExtrinsicHelper.api.events.capacity.Staked); + public static stake(keys: KeyringPair, target: any, amount: any, stakingType: 'MaximumCapacity' | 'ProviderBoost'): Extrinsic { + return new Extrinsic(() => ExtrinsicHelper.api.tx.capacity.stake(target, amount, stakingType), keys, ExtrinsicHelper.api.events.capacity.Staked); } public static unstake(keys: KeyringPair, target: any, amount: any): Extrinsic { @@ -396,6 +396,10 @@ export class ExtrinsicHelper { return new Extrinsic(() => ExtrinsicHelper.api.tx.capacity.withdrawUnstaked(), keys, ExtrinsicHelper.api.events.capacity.StakeWithdrawn); } + public static changeStakingTarget(keys: KeyringPair, fromMsa: any, toMsa: any, amount: any): Extrinsic { + return new Extrinsic(() => ExtrinsicHelper.api.tx.capacity.changeStakingTarget(fromMsa, toMsa, amount), keys, ExtrinsicHelper.api.events.capacity.StakingTargetChanged); + } + public static payWithCapacityBatchAll(keys: KeyringPair, calls: any): Extrinsic { return new Extrinsic(() => ExtrinsicHelper.api.tx.frequencyTxPayment.payWithCapacityBatchAll(calls), keys, ExtrinsicHelper.api.events.utility.BatchCompleted); } diff --git a/integration-tests/scaffolding/helpers.ts b/integration-tests/scaffolding/helpers.ts index f5c21ef83d..55307d2fa5 100644 --- a/integration-tests/scaffolding/helpers.ts +++ b/integration-tests/scaffolding/helpers.ts @@ -271,7 +271,7 @@ export async function createMsaAndProvider(keys: KeyringPair, providerName: stri // Stakes the given amount of tokens from the given keys to the given provider export async function stakeToProvider(keys: KeyringPair, providerId: u64, tokensToStake: bigint): Promise { - const stakeOp = ExtrinsicHelper.stake(keys, providerId, tokensToStake); + const stakeOp = ExtrinsicHelper.stake(keys, providerId, tokensToStake, 'MaximumCapacity'); const [stakeEvent] = await stakeOp.fundAndSend(); assert.notEqual(stakeEvent, undefined, 'stakeToProvider: should have returned Stake event'); diff --git a/pallets/capacity/src/benchmarking.rs b/pallets/capacity/src/benchmarking.rs index f8f5ce0720..014894537d 100644 --- a/pallets/capacity/src/benchmarking.rs +++ b/pallets/capacity/src/benchmarking.rs @@ -16,6 +16,33 @@ pub fn register_provider(target_id: MessageSourceId, name: &'static s let name = Vec::from(name).try_into().expect("error"); assert_ok!(T::BenchmarkHelper::create(target_id, name)); } + +#[allow(clippy::expect_used)] +pub fn setup_provider_stake( + staker: &T::AccountId, + target_id: &MessageSourceId, + amount: u32, +) { + let staking_amount: BalanceOf = T::MinimumStakingAmount::get().saturating_add(amount.into()); + let capacity_amount: BalanceOf = Capacity::::capacity_generated(staking_amount); + + // will add the amount to the account if it exists. + let mut staking_account: StakingAccountDetails = + Capacity::get_staking_account_for(staker).unwrap_or_default(); + + let mut target_details = StakingTargetDetails::::default(); + let mut capacity_details = + CapacityDetails::, ::EpochNumber>::default(); + + staking_account.deposit(staking_amount); + target_details.deposit(staking_amount, capacity_amount); + capacity_details.deposit(&staking_amount, &capacity_amount); + + Capacity::::set_staking_account(staker, &staking_account); + Capacity::::set_target_details_for(staker, *target_id, target_details); + Capacity::::set_capacity_for(*target_id, capacity_details); +} + pub fn create_funded_account( string: &'static str, n: u32, @@ -92,7 +119,7 @@ benchmarks! { let block_number = 4u32; let mut staking_account = StakingAccountDetails::::default(); - let mut target_details = StakingTargetDetails::>::default(); + let mut target_details = StakingTargetDetails::::default(); let mut capacity_details = CapacityDetails::, ::EpochNumber>::default(); staking_account.deposit(staking_amount); @@ -116,6 +143,30 @@ benchmarks! { assert_last_event::(Event::::EpochLengthUpdated {blocks: epoch_length}.into()); } + change_staking_target { + let caller: T::AccountId = create_funded_account::("account", SEED, 5u32); + let from_msa = 33; + let to_msa = 34; + // amount in addition to minimum + let from_msa_amount = 32u32; + let to_msa_amount = 1u32; + + register_provider::(from_msa, "frommsa"); + register_provider::(to_msa, "tomsa"); + setup_provider_stake::(&caller, &from_msa, from_msa_amount); + setup_provider_stake::(&caller, &to_msa, to_msa_amount); + let restake_amount = 11u32; + + }: _ (RawOrigin::Signed(caller.clone(), ), from_msa, to_msa, restake_amount.into()) + verify { + assert_last_event::(Event::::StakingTargetChanged { + account: caller, + from_msa, + to_msa, + amount: restake_amount.into() + }.into()); + } + impl_benchmark_test_suite!(Capacity, crate::tests::mock::new_test_ext(), crate::tests::mock::Test); diff --git a/pallets/capacity/src/lib.rs b/pallets/capacity/src/lib.rs index a9931fa236..9a6b572156 100644 --- a/pallets/capacity/src/lib.rs +++ b/pallets/capacity/src/lib.rs @@ -179,6 +179,12 @@ pub mod pallet { /// The StakingRewardsProvider used by this pallet in a given runtime type RewardsProvider: StakingRewardsProvider; + + /// After calling change_staking_target, the thaw period for the created + /// thaw chunk to expire. If the staker has called change_staking_target MaxUnlockingChunks + /// times, then at least one of the chunks must have expired before the next call + /// will succeed. + type ChangeStakingTargetThawEras: Get; } /// Storage for keeping a ledger of staked token amounts for accounts. @@ -200,7 +206,7 @@ pub mod pallet { T::AccountId, Twox64Concat, MessageSourceId, - StakingTargetDetails>, + StakingTargetDetails, >; /// Storage for target Capacity usage. @@ -295,6 +301,17 @@ pub mod pallet { /// The amount of Capacity withdrawn from MSA. amount: BalanceOf, }, + /// The target of a staked amount was changed to a new MessageSourceId + StakingTargetChanged { + /// The account that retargeted the staking amount + account: T::AccountId, + /// The Provider MSA that the staking amount is taken from + from_msa: MessageSourceId, + /// The Provider MSA that the staking amount is retargeted to + to_msa: MessageSourceId, + /// The amount in token that was retargeted + amount: BalanceOf, + }, } #[pallet::error] @@ -302,29 +319,30 @@ pub mod pallet { /// Staker attempted to stake to an invalid staking target. InvalidTarget, /// Capacity is not available for the given MSA. - InsufficientBalance, + InsufficientCapacityBalance, /// Staker is attempting to stake an amount below the minimum amount. - InsufficientStakingAmount, - /// Staker is attempting to stake a zero amount. + StakingAmountBelowMinimum, + /// Staker is attempting to stake a zero amount. DEPRECATED ZeroAmountNotAllowed, /// This AccountId does not have a staking account. NotAStakingAccount, /// No staked value is available for withdrawal; either nothing is being unstaked, /// or nothing has passed the thaw period. NoUnstakedTokensAvailable, - /// Unstaking amount should be greater than zero. + /// Unstaking amount must be greater than zero. UnstakedAmountIsZero, - /// Amount to unstake is greater than the amount staked. - AmountToUnstakeExceedsAmountStaked, - /// Attempting to get a staker / target relationship that does not exist. + /// Amount to unstake or change targets is greater than the amount staked. + InsufficientStakingBalance, + /// Attempted to get a staker / target relationship that does not exist. StakerTargetRelationshipNotFound, - /// Attempting to get the target's capacity that does not exist. + /// Attempted to get the target's capacity that does not exist. TargetCapacityNotFound, - /// Staker reached the limit number for the allowed amount of unlocking chunks. + /// Staker has reached the limit of unlocking chunks and must wait for at least one thaw period + /// to complete. MaxUnlockingChunksExceeded, - /// Increase Capacity increase exceeds the total available Capacity for target. + /// Capacity increase exceeds the total available Capacity for target. IncreaseExceedsAvailable, - /// Attempting to set the epoch length to a value greater than the max epoch length. + /// Attempted to set the Epoch length to a value greater than the max Epoch length. MaxEpochLengthExceeded, /// Staker is attempting to stake an amount that leaves a token balance below the minimum amount. BalanceTooLowtoStake, @@ -350,7 +368,6 @@ pub mod pallet { /// /// ### Errors /// - /// - Returns Error::ZeroAmountNotAllowed if the staker is attempting to stake a zero amount. /// - Returns Error::InvalidTarget if attempting to stake to an invalid target. /// - Returns Error::InsufficientStakingAmount if attempting to stake an amount below the minimum amount. /// - Returns Error::CannotChangeStakingType if the staking account exists and staking_type is different @@ -427,6 +444,7 @@ pub mod pallet { requested_amount: BalanceOf, ) -> DispatchResult { let unstaker = ensure_signed(origin)?; + Self::ensure_can_unstake(&unstaker)?; ensure!(requested_amount > Zero::zero(), Error::::UnstakedAmountIsZero); @@ -460,6 +478,51 @@ pub mod pallet { Self::deposit_event(Event::EpochLengthUpdated { blocks: length }); Ok(()) } + + /// Sets the target of the staking capacity to a new target. + /// This adds a chunk to `StakingAccountDetails.stake_change_unlocking chunks`, up to `T::MaxUnlockingChunks`. + /// The staked amount and Capacity generated by `amount` originally targeted to the `from` MSA Id is reassigned to the `to` MSA Id. + /// Does not affect unstaking process or additional stake amounts. + /// ### Errors + /// - [`Error::StakingAccountNotFound`] if origin does not have a staking account + /// - [`Error::MaxUnlockingChunksExceeded`] if `stake_change_unlocking_chunks` == `T::MaxUnlockingChunks` + /// - [`Error::StakerTargetRelationshipNotFound`] if `from` is not a target for Origin's staking account. + /// - [`Error::StakingAmountBelowMinimum`] if `amount` to retarget is below the minimum staking amount. + /// - [`Error::InsufficientStakingBalance`] if `amount` to retarget exceeds what the staker has targeted to `from` MSA Id. + /// - [`Error::InvalidTarget`] if `to` does not belong to a registered Provider. + #[pallet::call_index(4)] + #[pallet::weight(T::WeightInfo::unstake())] + pub fn change_staking_target( + origin: OriginFor, + from: MessageSourceId, + to: MessageSourceId, + amount: BalanceOf, + ) -> DispatchResult { + let staker = ensure_signed(origin)?; + ensure!( + StakingAccountLedger::::contains_key(&staker), + Error::::StakingAccountNotFound + ); + ensure!( + StakingTargetLedger::::contains_key(&staker, &from), + Error::::StakerTargetRelationshipNotFound + ); + ensure!( + amount >= T::MinimumStakingAmount::get(), + Error::::StakingAmountBelowMinimum + ); + ensure!(T::TargetValidator::validate(to), Error::::InvalidTarget); + + Self::do_retarget(&staker, &from, &to, &amount)?; + + Self::deposit_event(Event::StakingTargetChanged { + account: staker, + from_msa: from, + to_msa: to, + amount, + }); + Ok(()) + } } } @@ -468,7 +531,7 @@ impl Pallet { /// and leave the minimum required free balance after staking. /// /// # Errors - /// * [`Error::ZeroAmountNotAllowed`] + /// * [`Error::StakingAmountBelowMinimum`] /// * [`Error::InvalidTarget`] /// * [`Error::BalanceTooLowtoStake`] /// @@ -478,7 +541,7 @@ impl Pallet { amount: BalanceOf, staking_type: &StakingType, ) -> Result<(StakingAccountDetails, BalanceOf), DispatchError> { - ensure!(amount > Zero::zero(), Error::::ZeroAmountNotAllowed); + ensure!(amount > Zero::zero(), Error::::StakingAmountBelowMinimum); ensure!(T::TargetValidator::validate(target), Error::::InvalidTarget); let staking_account: StakingAccountDetails = @@ -502,7 +565,7 @@ impl Pallet { ensure!( new_active_staking_amount >= T::MinimumStakingAmount::get(), - Error::::InsufficientStakingAmount + Error::::StakingAmountBelowMinimum ); Ok((staking_account, stakable_amount)) @@ -560,9 +623,13 @@ impl Pallet { fn set_target_details_for( staker: &T::AccountId, target: MessageSourceId, - target_details: StakingTargetDetails>, + target_details: StakingTargetDetails, ) { - StakingTargetLedger::::insert(staker, target, target_details); + if target_details.amount.is_zero() { + StakingTargetLedger::::remove(staker, target); + } else { + StakingTargetLedger::::insert(staker, target, target_details); + } } /// Sets targets Capacity. @@ -582,18 +649,33 @@ impl Pallet { Self::get_staking_account_for(unstaker).ok_or(Error::::NotAStakingAccount)?; ensure!(amount <= staking_account.active, Error::::AmountToUnstakeExceedsAmountStaked); - let current_epoch: T::EpochNumber = Self::get_current_epoch(); - let thaw_period = T::UnstakingThawPeriod::get(); - let thaw_at = current_epoch.saturating_add(thaw_period.into()); - - let unstake_result = staking_account.withdraw(amount, thaw_at)?; + let unstake_result = staking_account.withdraw(amount, Self::get_thaw_at_epoch())?; Self::set_staking_account(&unstaker, &staking_account); Ok(unstake_result) } + fn get_thaw_at_epoch() -> ::EpochNumber { + let current_epoch: T::EpochNumber = Self::get_current_epoch(); + let thaw_period = T::UnstakingThawPeriod::get(); + current_epoch.saturating_add(thaw_period.into()) + } + + fn ensure_can_unstake(unstaker: &T::AccountId) -> Result<(), DispatchError> { + let staking_account: StakingAccountDetails = + Self::get_staking_account_for(unstaker).ok_or(Error::::StakingAccountNotFound)?; + ensure!( + staking_account.unlocking.len().lt(&(T::MaxUnlockingChunks::get() as usize)), + Error::::MaxUnlockingChunksExceeded + ); + Ok(()) + } + /// Reduce available capacity of target and return the amount of capacity reduction. + /// If withdrawing `amount` would take the StakingTargetDetails below the minimum staking amount, + /// the entire amount is transferred and the record will be deleted. CapacityDetails will not + /// be checked. fn reduce_capacity( unstaker: &T::AccountId, target: MessageSourceId, @@ -601,6 +683,9 @@ impl Pallet { ) -> Result, DispatchError> { let mut staking_target_details = Self::get_target_for(&unstaker, &target) .ok_or(Error::::StakerTargetRelationshipNotFound)?; + + ensure!(amount.le(&staking_target_details.amount), Error::::InsufficientStakingBalance); + let mut capacity_details = Self::get_capacity_for(target).ok_or(Error::::TargetCapacityNotFound)?; @@ -609,8 +694,11 @@ impl Pallet { capacity_details.total_tokens_staked, capacity_details.total_capacity_issued, ); - staking_target_details.withdraw(amount, capacity_to_withdraw); - capacity_details.withdraw(capacity_to_withdraw, amount); + // this call will return an amount > than requested if the resulting StakingTargetDetails balance + // is below the minimum. This ensures we withdraw the same amounts as for staking_target_details. + let (actual_amount, actual_capacity) = + staking_target_details.withdraw(amount, capacity_to_withdraw); + capacity_details.withdraw(actual_capacity, actual_amount); Self::set_capacity_for(target, capacity_details); Self::set_target_details_for(unstaker, target, staking_target_details); @@ -672,6 +760,47 @@ impl Pallet { Self::get_staking_account_for(account_id).ok_or(Error::::StakingAccountNotFound); false } + + /// adds a new chunk to StakingAccountDetails.stake_change_unlocking. The + /// call `update_stake_change_unlocking` garbage-collects thawed chunks before adding the new one. + fn add_change_staking_target_unlock_chunk( + staker: &T::AccountId, + amount: &BalanceOf, + ) -> Result<(), DispatchError> { + let mut staking_account_details: StakingAccountDetails = + Self::get_staking_account_for(staker).ok_or(Error::::StakingAccountNotFound)?; + + let current_era: T::RewardEra = Self::get_current_era().era_index; + let thaw_at = current_era.saturating_add(T::ChangeStakingTargetThawEras::get().into()); + staking_account_details.update_stake_change_unlocking(amount, &thaw_at, ¤t_era)?; + Self::set_staking_account(staker, &staking_account_details); + Ok(()) + } + + /// Performs the work of withdrawing the requested amount from the old staker-provider target details, and + /// from the Provider's capacity details, and depositing it into the new staker-provider target details. + pub fn do_retarget( + staker: &T::AccountId, + from_msa: &MessageSourceId, + to_msa: &MessageSourceId, + amount: &BalanceOf, + ) -> Result<(), DispatchError> { + let capacity_withdrawn = Self::reduce_capacity(staker, *from_msa, *amount)?; + + let mut to_msa_target = Self::get_target_for(staker, to_msa).unwrap_or_default(); + to_msa_target + .deposit(*amount, capacity_withdrawn) + .ok_or(ArithmeticError::Overflow)?; + + let mut capacity_details = Self::get_capacity_for(to_msa).unwrap_or_default(); + capacity_details + .deposit(amount, &capacity_withdrawn) + .ok_or(ArithmeticError::Overflow)?; + + Self::set_target_details_for(staker, *to_msa, to_msa_target); + Self::set_capacity_for(*to_msa, capacity_details); + Self::add_change_staking_target_unlock_chunk(staker, amount) + } } impl Nontransferable for Pallet { @@ -692,7 +821,7 @@ impl Nontransferable for Pallet { capacity_details .deduct_capacity_by_amount(amount) - .map_err(|_| Error::::InsufficientBalance)?; + .map_err(|_| Error::::InsufficientCapacityBalance)?; Self::set_capacity_for(msa_id, capacity_details); diff --git a/pallets/capacity/src/tests/change_staking_target_tests.rs b/pallets/capacity/src/tests/change_staking_target_tests.rs new file mode 100644 index 0000000000..bf1ee4e5b4 --- /dev/null +++ b/pallets/capacity/src/tests/change_staking_target_tests.rs @@ -0,0 +1,317 @@ +use super::{mock::*, testing_utils::*}; +use crate::{ + BalanceOf, CapacityDetails, Config, CurrentEraInfo, Error, Event, RewardEraInfo, + StakingAccountDetails, StakingAccountLedger, StakingTargetDetails, +}; +use common_primitives::{ + capacity::StakingType::{MaximumCapacity, ProviderBoost}, + msa::MessageSourceId, +}; +use frame_support::{assert_noop, assert_ok, traits::Get}; + +// staker is unused unless amount > 0 +fn setup_provider(staker: u64, target: MessageSourceId, amount: u64) { + let provider_name = String::from("Cst-") + target.to_string().as_str(); + register_provider(target, provider_name); + if amount > 0 { + assert_ok!(Capacity::stake(RuntimeOrigin::signed(staker), target, amount, ProviderBoost)); + } +} + +type TestCapacityDetails = CapacityDetails, u32>; +type TestTargetDetails = StakingTargetDetails; + +#[test] +fn do_retarget_happy_path() { + new_test_ext().execute_with(|| { + let staker = 200u64; + let from_msa: MessageSourceId = 1; + let from_amount = 20u64; + let to_amount = from_amount / 2; + let to_msa: MessageSourceId = 2; + setup_provider(staker, from_msa, from_amount); + setup_provider(staker, to_msa, to_amount); + + // retarget half the stake to to_msa + assert_ok!(Capacity::do_retarget(&staker, &from_msa, &to_msa, &to_amount)); + + // expect from stake amounts to be halved + let expected_from_details: TestCapacityDetails = CapacityDetails { + remaining_capacity: 1, + total_tokens_staked: 10, + total_capacity_issued: 1, + last_replenished_epoch: 0, + }; + let from_capacity_details: TestCapacityDetails = + Capacity::get_capacity_for(from_msa).unwrap(); + assert_eq!(from_capacity_details, expected_from_details); + + // expect to stake amounts to be increased by the retarget amount + let expected_to_details: TestCapacityDetails = CapacityDetails { + remaining_capacity: 2, + total_tokens_staked: 20, + total_capacity_issued: 2, + last_replenished_epoch: 0, + }; + let to_capacity_details = Capacity::get_capacity_for(to_msa).unwrap(); + assert_eq!(to_capacity_details, expected_to_details); + + let expected_from_target_details: TestTargetDetails = + StakingTargetDetails { amount: 10, capacity: 1 }; + let from_target_details = Capacity::get_target_for(staker, from_msa).unwrap(); + assert_eq!(from_target_details, expected_from_target_details); + + let expected_to_target_details: TestTargetDetails = + StakingTargetDetails { amount: 20, capacity: 2 }; + let to_target_details = Capacity::get_target_for(staker, to_msa).unwrap(); + assert_eq!(to_target_details, expected_to_target_details); + }) +} + +#[test] +fn do_retarget_deletes_staking_target_details_if_zero_balance() { + new_test_ext().execute_with(|| { + let staker = 200u64; + let from_msa: MessageSourceId = 1; + let to_msa: MessageSourceId = 2; + let amount = 10u64; + setup_provider(staker, from_msa, amount); + setup_provider(staker, to_msa, amount); + + // stake additional to provider from another Msa, doesn't matter which type. + // total staked to from_msa is now 22u64. + assert_ok!(Capacity::stake( + RuntimeOrigin::signed(300u64), + from_msa, + 12u64, + MaximumCapacity + )); + + assert_ok!(Capacity::do_retarget(&staker, &from_msa, &to_msa, &amount)); + + let expected_from_details: TestCapacityDetails = CapacityDetails { + remaining_capacity: 1, + total_tokens_staked: 12, + total_capacity_issued: 1, + last_replenished_epoch: 0, + }; + let from_capacity_details: TestCapacityDetails = + Capacity::get_capacity_for(from_msa).unwrap(); + assert_eq!(from_capacity_details, expected_from_details); + + let expected_to_details: TestCapacityDetails = CapacityDetails { + remaining_capacity: 2, + total_tokens_staked: 2 * amount, + total_capacity_issued: 2, + last_replenished_epoch: 0, + }; + + let to_capacity_details = Capacity::get_capacity_for(to_msa).unwrap(); + assert_eq!(to_capacity_details, expected_to_details); + + assert!(Capacity::get_target_for(staker, from_msa).is_none()); + + let expected_to_target_details: TestTargetDetails = + StakingTargetDetails { amount: 2 * amount, capacity: 2 }; + let to_target_details = Capacity::get_target_for(staker, to_msa).unwrap(); + assert_eq!(to_target_details, expected_to_target_details); + + assert!(Capacity::get_target_for(staker, from_msa).is_none()); + }) +} + +#[test] +fn change_staking_starget_emits_event_on_success() { + new_test_ext().execute_with(|| { + let staker = 200u64; + let from_msa: MessageSourceId = 1; + let from_amount = 20u64; + let to_amount = from_amount / 2; + let to_msa: MessageSourceId = 2; + setup_provider(staker, from_msa, from_amount); + setup_provider(staker, to_msa, to_amount); + + assert_ok!(Capacity::change_staking_target( + RuntimeOrigin::signed(staker), + from_msa, + to_msa, + to_amount + )); + let events = staking_events(); + + assert_eq!( + events.last().unwrap(), + &Event::StakingTargetChanged { account: staker, from_msa, to_msa, amount: to_amount } + ); + }) +} + +#[test] +fn change_staking_target_errors_if_too_many_changes_before_thaw() { + new_test_ext().execute_with(|| { + let staker = 200u64; + let from_msa: MessageSourceId = 1; + let to_msa: MessageSourceId = 2; + + let max_chunks: u32 = ::MaxUnlockingChunks::get(); + let staking_amount = ((max_chunks + 2u32) * 10u32) as u64; + setup_provider(staker, from_msa, staking_amount); + setup_provider(staker, to_msa, 10); + + let retarget_amount = 10u64; + for _i in 0..(max_chunks) { + assert_ok!(Capacity::change_staking_target( + RuntimeOrigin::signed(staker), + from_msa, + to_msa, + retarget_amount + )); + } + + assert_noop!( + Capacity::change_staking_target( + RuntimeOrigin::signed(staker), + from_msa, + to_msa, + retarget_amount + ), + Error::::MaxUnlockingChunksExceeded + ); + }); +} + +#[test] +fn change_staking_target_garbage_collects_thawed_chunks() { + new_test_ext().execute_with(|| { + let staked_amount = 50u64; + let staking_account = 200u64; + let from_target: MessageSourceId = 3; + let to_target: MessageSourceId = 4; + setup_provider(staking_account, from_target, staked_amount); + setup_provider(staking_account, to_target, staked_amount); + + CurrentEraInfo::::set(RewardEraInfo { era_index: 20, started_at: 100 }); + let max_chunks = ::MaxUnlockingChunks::get(); + for i in 0..max_chunks { + println!("{:?}", i); + assert_ok!(Capacity::change_staking_target( + RuntimeOrigin::signed(staking_account), + from_target, + to_target, + 10u64, + )); + } + CurrentEraInfo::::set(RewardEraInfo { era_index: 25, started_at: 100 }); + assert_ok!(Capacity::change_staking_target( + RuntimeOrigin::signed(staking_account), + from_target, + to_target, + 10u64, + )); + }) +} + +#[test] +fn change_staking_target_test_parametric_validity() { + new_test_ext().execute_with(|| { + let staked_amount = 10u64; + let from_account = 200u64; + + StakingAccountLedger::::insert( + from_account, + StakingAccountDetails { + active: 20, + total: 20, + unlocking: Default::default(), + staking_type: ProviderBoost, + last_rewards_claimed_at: None, + stake_change_unlocking: Default::default(), + }, + ); + let from_account_not_staking = 100u64; + let from_target_not_staked: MessageSourceId = 1; + let to_target_not_provider: MessageSourceId = 2; + let from_target: MessageSourceId = 3; + let to_target: MessageSourceId = 4; + setup_provider(from_account, from_target_not_staked, 0); + setup_provider(from_account, from_target, staked_amount); + setup_provider(from_account, to_target, staked_amount); + + assert_ok!(Capacity::stake( + RuntimeOrigin::signed(from_account), + from_target, + staked_amount, + ProviderBoost + )); + + struct TestCase { + from_account: u64, + from_target: MessageSourceId, + to_target: MessageSourceId, + retarget_amount: u64, + expected_err: Error, + } + let test_cases: Vec = vec![ + // from is a provider but account is not staking to it + TestCase { + from_account, + from_target: from_target_not_staked, + to_target, + retarget_amount: staked_amount, + expected_err: Error::::StakerTargetRelationshipNotFound, + }, + // from_account is not staking at all. + TestCase { + from_account: from_account_not_staking, + from_target, + to_target, + retarget_amount: staked_amount, + expected_err: Error::::StakingAccountNotFound, + }, + // // from and to providers are valid, but zero amount too small + TestCase { + from_account, + from_target, + to_target, + retarget_amount: 0, + expected_err: Error::::StakingAmountBelowMinimum, + }, + // // nonzero amount below minimum is still too small + TestCase { + from_account, + from_target, + to_target, + retarget_amount: 9, + expected_err: Error::::StakingAmountBelowMinimum, + }, + // // account is staked with from-target, but to-target is not a provider + TestCase { + from_account, + from_target, + to_target: to_target_not_provider, + retarget_amount: staked_amount, + expected_err: Error::::InvalidTarget, + }, + // account doesn't have enough staked to make the transfer + TestCase { + from_account, + from_target, + to_target, + retarget_amount: 999, + expected_err: Error::::InsufficientStakingBalance, + }, + ]; + + for tc in test_cases { + assert_noop!( + Capacity::change_staking_target( + RuntimeOrigin::signed(tc.from_account), + tc.from_target, + tc.to_target, + tc.retarget_amount, + ), + tc.expected_err + ); + } + }); +} diff --git a/pallets/capacity/src/tests/mock.rs b/pallets/capacity/src/tests/mock.rs index 3b0bbec8a1..51e52ec282 100644 --- a/pallets/capacity/src/tests/mock.rs +++ b/pallets/capacity/src/tests/mock.rs @@ -1,8 +1,8 @@ use crate as pallet_capacity; -use crate::{BalanceOf, Config, StakingRewardClaim, StakingRewardsProvider}; +use crate::{BalanceOf, StakingRewardClaim, StakingRewardsProvider}; use common_primitives::{ - node::{AccountId, Balance, Hash, Header, ProposalProvider}, + node::{AccountId, Hash, Header, ProposalProvider}, schema::{SchemaId, SchemaValidator}, }; use frame_support::{ @@ -194,6 +194,7 @@ impl pallet_capacity::Config for Test { type EraLength = ConstU32<10>; type StakingRewardsPastErasMax = ConstU32<5>; type RewardsProvider = TestStakingRewardsProvider; + type ChangeStakingTargetThawEras = ConstU32<5>; } pub fn new_test_ext() -> sp_io::TestExternalities { diff --git a/pallets/capacity/src/tests/mod.rs b/pallets/capacity/src/tests/mod.rs index df9704d540..7dd04f1bde 100644 --- a/pallets/capacity/src/tests/mod.rs +++ b/pallets/capacity/src/tests/mod.rs @@ -1,4 +1,5 @@ mod capacity_details_tests; +mod change_staking_target_tests; mod epochs_tests; mod eras_tests; pub mod mock; diff --git a/pallets/capacity/src/tests/other_tests.rs b/pallets/capacity/src/tests/other_tests.rs index 503bdbf87b..f896339d47 100644 --- a/pallets/capacity/src/tests/other_tests.rs +++ b/pallets/capacity/src/tests/other_tests.rs @@ -83,7 +83,7 @@ fn set_target_details_is_successful() { assert_eq!(Capacity::get_target_for(&staker, target), None); - let mut target_details = StakingTargetDetails::>::default(); + let mut target_details = StakingTargetDetails::::default(); target_details.amount = 10; target_details.capacity = 10; diff --git a/pallets/capacity/src/tests/rewards_provider_tests.rs b/pallets/capacity/src/tests/rewards_provider_tests.rs index 17d7fd86db..536b6e4dff 100644 --- a/pallets/capacity/src/tests/rewards_provider_tests.rs +++ b/pallets/capacity/src/tests/rewards_provider_tests.rs @@ -1,7 +1,6 @@ use super::mock::*; use crate::{ - tests::testing_utils::{run_to_block, system_run_to_block}, - Config, CurrentEraInfo, Error, Event, RewardEraInfo, RewardPoolInfo, StakingAccountDetails, + CurrentEraInfo, Error, RewardEraInfo, RewardPoolInfo, StakingAccountDetails, StakingRewardClaim, StakingRewardPool, StakingRewardsProvider, }; use frame_support::assert_err; diff --git a/pallets/capacity/src/tests/stake_and_deposit_tests.rs b/pallets/capacity/src/tests/stake_and_deposit_tests.rs index 39663efb9e..396f896a47 100644 --- a/pallets/capacity/src/tests/stake_and_deposit_tests.rs +++ b/pallets/capacity/src/tests/stake_and_deposit_tests.rs @@ -106,7 +106,7 @@ fn stake_errors_insufficient_staking_amount_when_staking_below_minimum_staking_a register_provider(target, String::from("Foo")); assert_noop!( Capacity::stake(RuntimeOrigin::signed(account), target, amount, MaximumCapacity), - Error::::InsufficientStakingAmount + Error::::StakingAmountBelowMinimum ); }); } @@ -119,7 +119,7 @@ fn stake_errors_zero_amount_not_allowed() { let amount = 0; assert_noop!( Capacity::stake(RuntimeOrigin::signed(account), target, amount, MaximumCapacity), - Error::::ZeroAmountNotAllowed + Error::::StakingAmountBelowMinimum ); }); } @@ -363,7 +363,7 @@ fn ensure_can_stake_errors_with_zero_amount_not_allowed() { let amount = 0; assert_noop!( Capacity::ensure_can_stake(&account, target, amount, &MaximumCapacity), - Error::::ZeroAmountNotAllowed + Error::::StakingAmountBelowMinimum ); }); } @@ -416,7 +416,7 @@ fn ensure_can_stake_errors_insufficient_staking_amount() { assert_noop!( Capacity::ensure_can_stake(&account, target, amount, &MaximumCapacity), - Error::::InsufficientStakingAmount + Error::::StakingAmountBelowMinimum ); }); } diff --git a/pallets/capacity/src/tests/staking_account_details_tests.rs b/pallets/capacity/src/tests/staking_account_details_tests.rs index e95addb869..6bd935e8a4 100644 --- a/pallets/capacity/src/tests/staking_account_details_tests.rs +++ b/pallets/capacity/src/tests/staking_account_details_tests.rs @@ -1,6 +1,7 @@ use super::mock::*; use crate::*; -use frame_support::assert_err; +use common_primitives::node::{BlockNumber, RewardEra}; +use frame_support::{assert_err, assert_ok, traits::Get}; use sp_core::bounded::BoundedVec; type UnlockBVec = BoundedVec< @@ -147,3 +148,69 @@ fn impl_staking_account_details_get_stakable_amount_for() { assert_eq!(staking_account.get_stakable_amount_for(&account, 200), 190); }); } + +#[test] +fn impl_update_stake_change_unlocking_bound() { + new_test_ext().execute_with(|| { + let mut staking_account: StakingAccountDetails = StakingAccountDetails { + active: 150, + total: 150, + unlocking: Default::default(), + staking_type: StakingType::MaximumCapacity, + last_rewards_claimed_at: None, + stake_change_unlocking: Default::default(), + }; + let current_era_info: RewardEraInfo = + RewardEraInfo { era_index: 20, started_at: 100 }; + let new_chunk_amount: u64 = 10; + let thaw_at: u32 = 25; + CurrentEraInfo::::set(current_era_info.clone()); + let max_chunks = ::MaxUnlockingChunks::get(); + for i in 0..max_chunks { + assert_ok!(staking_account.update_stake_change_unlocking( + &(new_chunk_amount + (i as u64)), + &thaw_at, + ¤t_era_info.era_index + )); + } + assert_err!( + staking_account.update_stake_change_unlocking( + &new_chunk_amount, + &thaw_at, + ¤t_era_info.era_index + ), + Error::::MaxUnlockingChunksExceeded + ); + }) +} + +#[test] +fn impl_update_stake_change_unlocking_cleanup() { + new_test_ext().execute_with(|| { + let mut staking_account: StakingAccountDetails = StakingAccountDetails { + active: 150, + total: 150, + unlocking: Default::default(), + staking_type: StakingType::MaximumCapacity, + last_rewards_claimed_at: None, + stake_change_unlocking: Default::default(), + }; + let new_chunk_amount = 10u64; + let thaw_at = 25u32; + let era_index = 20u32; + CurrentEraInfo::::set(RewardEraInfo { era_index, started_at: 100 }); + assert_ok!(staking_account.update_stake_change_unlocking( + &new_chunk_amount, + &thaw_at, + &era_index + )); + + CurrentEraInfo::::set(RewardEraInfo { era_index: thaw_at, started_at: 100 }); + assert_ok!(staking_account.update_stake_change_unlocking( + &new_chunk_amount, + &thaw_at, + &thaw_at + )); + assert_eq!(1, staking_account.stake_change_unlocking.len()); + }); +} diff --git a/pallets/capacity/src/tests/staking_target_details_tests.rs b/pallets/capacity/src/tests/staking_target_details_tests.rs index dbe9ea0d4a..da6f3360d1 100644 --- a/pallets/capacity/src/tests/staking_target_details_tests.rs +++ b/pallets/capacity/src/tests/staking_target_details_tests.rs @@ -4,35 +4,42 @@ use frame_support::{assert_err, assert_ok}; #[test] fn impl_staking_target_details_increase_by() { - let mut staking_target = StakingTargetDetails::>::default(); + let mut staking_target = StakingTargetDetails::::default(); assert_eq!(staking_target.deposit(10, 10), Some(())); assert_eq!( staking_target, - StakingTargetDetails::> { - amount: BalanceOf::::from(10u64), - capacity: 10 - } + StakingTargetDetails:: { amount: BalanceOf::::from(10u64), capacity: 10 } ) } #[test] fn staking_target_details_withdraw_reduces_staking_and_capacity_amounts() { - let mut staking_target_details = StakingTargetDetails::> { - amount: BalanceOf::::from(15u64), - capacity: BalanceOf::::from(20u64), + let mut staking_target_details = StakingTargetDetails:: { + amount: BalanceOf::::from(25u64), + capacity: BalanceOf::::from(30u64), }; staking_target_details.withdraw(10, 10); assert_eq!( staking_target_details, - StakingTargetDetails::> { - amount: BalanceOf::::from(5u64), - capacity: BalanceOf::::from(10u64), + StakingTargetDetails:: { + amount: BalanceOf::::from(15u64), + capacity: BalanceOf::::from(20u64), } ) } +#[test] +fn staking_target_details_withdraw_reduces_to_zero_if_balance_is_below_minimum() { + let mut staking_target_details = StakingTargetDetails:: { + amount: BalanceOf::::from(10u64), + capacity: BalanceOf::::from(20u64), + }; + staking_target_details.withdraw(8, 16); + assert_eq!(staking_target_details, StakingTargetDetails::::default()); +} + #[test] fn staking_target_details_withdraw_reduces_total_tokens_staked_and_total_tokens_available() { let mut capacity_details = CapacityDetails::, ::EpochNumber> { diff --git a/pallets/capacity/src/tests/unstaking_tests.rs b/pallets/capacity/src/tests/unstaking_tests.rs index f8c7c8abbb..aa39df0528 100644 --- a/pallets/capacity/src/tests/unstaking_tests.rs +++ b/pallets/capacity/src/tests/unstaking_tests.rs @@ -55,7 +55,7 @@ fn unstake_happy_path() { assert_eq!( staking_target_details, - StakingTargetDetails::> { + StakingTargetDetails:: { amount: BalanceOf::::from(60u64), capacity: BalanceOf::::from(6u64), } @@ -160,7 +160,7 @@ fn unstake_errors_amount_to_unstake_exceeds_amount_staked() { )); assert_noop!( Capacity::unstake(RuntimeOrigin::signed(token_account), target, unstaking_amount), - Error::::AmountToUnstakeExceedsAmountStaked + Error::::InsufficientStakingBalance ); }); } diff --git a/pallets/capacity/src/tests/withdrawal_tests.rs b/pallets/capacity/src/tests/withdrawal_tests.rs index f8b0038b08..46f6fe443a 100644 --- a/pallets/capacity/src/tests/withdrawal_tests.rs +++ b/pallets/capacity/src/tests/withdrawal_tests.rs @@ -63,7 +63,7 @@ fn impl_withdraw_errors_insufficient_balance() { assert_noop!( Capacity::deduct(target_msa_id, 20u32.into()), - Error::::InsufficientBalance + Error::::InsufficientCapacityBalance ); let mut capacity_details = diff --git a/pallets/capacity/src/types.rs b/pallets/capacity/src/types.rs index 042eaf8425..99dde11d63 100644 --- a/pallets/capacity/src/types.rs +++ b/pallets/capacity/src/types.rs @@ -65,7 +65,7 @@ impl StakingAccountDetails { #[cfg(any(feature = "runtime-benchmarks", test))] #[allow(clippy::unwrap_used)] - /// tmp fn for testing only + /// For testing and benchmarks only! /// set unlock chunks with (balance, thaw_at). does not check that the unlock chunks /// don't exceed total. /// returns true on success, false on failure (?) @@ -101,6 +101,37 @@ impl StakingAccountDetails { total_reaped } + #[cfg(any(feature = "runtime-benchmarks", test))] + #[allow(clippy::unwrap_used)] + /// for testing and benchmarks only! + /// set stake_change_unlocking chunks with (balance, thaw_at). does not check that the unlock chunks + /// don't exceed total. + /// returns true on success, false on failure (?) + pub fn set_stake_change_unlock_chunks(&mut self, chunks: &Vec<(u32, u32)>) -> bool { + let result: Vec, ::EpochNumber>> = chunks + .into_iter() + .map(|chunk| UnlockChunk { value: chunk.0.into(), thaw_at: chunk.1.into() }) + .collect(); + self.unlocking = BoundedVec::try_from(result).unwrap(); + self.unlocking.len() == chunks.len() + } + + /// update unlock chunks; remove those that have expired and add the new one + // this doesn't affect staking amount, just controls how often a staker may retarget + pub fn update_stake_change_unlocking( + &mut self, + new_chunk_amount: &BalanceOf, + thaw_at: &T::RewardEra, + current_era: &T::RewardEra, + ) -> Result<(), DispatchError> { + self.stake_change_unlocking.retain(|chunk| current_era.lt(&chunk.thaw_at)); + let unlock_chunk = UnlockChunk { value: *new_chunk_amount, thaw_at: *thaw_at }; + self.stake_change_unlocking + .try_push(unlock_chunk) + .map_err(|_| Error::::MaxUnlockingChunksExceeded)?; + Ok(()) + } + /// Decrease the amount of active stake by an amount and create an UnlockChunk. pub fn withdraw( &mut self, @@ -146,17 +177,24 @@ impl Default for StakingAccountDetails { /// Details about the total token amount targeted to an MSA. /// The Capacity that the target will receive. -#[derive(PartialEq, Eq, Default, Clone, Encode, Decode, RuntimeDebug, TypeInfo, MaxEncodedLen)] -pub struct StakingTargetDetails { +#[derive(PartialEq, Eq, Clone, Encode, Decode, RuntimeDebug, TypeInfo, MaxEncodedLen)] +#[scale_info(skip_type_params(T))] +pub struct StakingTargetDetails { /// The total amount of tokens that have been targeted to the MSA. - pub amount: Balance, + pub amount: BalanceOf, /// The total Capacity that an MSA received. - pub capacity: Balance, + pub capacity: BalanceOf, +} + +impl Default for StakingTargetDetails { + fn default() -> Self { + Self { amount: Zero::zero(), capacity: Zero::zero() } + } } -impl StakingTargetDetails { +impl StakingTargetDetails { /// Increase an MSA target Staking total and Capacity amount. - pub fn deposit(&mut self, amount: Balance, capacity: Balance) -> Option<()> { + pub fn deposit(&mut self, amount: BalanceOf, capacity: BalanceOf) -> Option<()> { self.amount = amount.checked_add(&self.amount)?; self.capacity = capacity.checked_add(&self.capacity)?; @@ -164,9 +202,23 @@ impl StakingTargetDetails { } /// Decrease an MSA target Staking total and Capacity amount. - pub fn withdraw(&mut self, amount: Balance, capacity: Balance) { + /// If the amount would put you below the minimum, zero out the amount. + /// Return the actual amounts withdrawn. + pub fn withdraw( + &mut self, + amount: BalanceOf, + capacity: BalanceOf, + ) -> (BalanceOf, BalanceOf) { + let entire_amount = self.amount; + let entire_capacity = self.capacity; self.amount = self.amount.saturating_sub(amount); - self.capacity = self.capacity.saturating_sub(capacity); + if self.amount.lt(&T::MinimumStakingAmount::get()) { + *self = Self::default(); + return (entire_amount, entire_capacity) + } else { + self.capacity = self.capacity.saturating_sub(capacity); + } + (amount, capacity) } } diff --git a/pallets/capacity/src/weights.rs b/pallets/capacity/src/weights.rs index b22becedf7..a01992b275 100644 --- a/pallets/capacity/src/weights.rs +++ b/pallets/capacity/src/weights.rs @@ -55,6 +55,7 @@ pub trait WeightInfo { fn on_initialize() -> Weight; fn unstake() -> Weight; fn set_epoch_length() -> Weight; + fn change_staking_target() -> Weight; } /// Weights for pallet_capacity using the Substrate node and recommended hardware. @@ -138,6 +139,13 @@ impl WeightInfo for SubstrateWeight { Weight::from_parts(6_891_000, 0) .saturating_add(T::DbWeight::get().writes(1_u64)) } + + /// Storage: + /// Proof: + fn change_staking_target() -> Weight { + Weight::from_parts(1_000_000, 0) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } } // For backwards compatibility and tests @@ -220,4 +228,11 @@ impl WeightInfo for () { Weight::from_parts(6_891_000, 0) .saturating_add(RocksDbWeight::get().writes(1_u64)) } + + /// Storage: + /// Proof: + fn change_staking_target() -> Weight { + Weight::from_parts(1_000_000, 0) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } } diff --git a/pallets/frequency-tx-payment/src/tests/mock.rs b/pallets/frequency-tx-payment/src/tests/mock.rs index 322f38fb84..81823e29e2 100644 --- a/pallets/frequency-tx-payment/src/tests/mock.rs +++ b/pallets/frequency-tx-payment/src/tests/mock.rs @@ -225,6 +225,7 @@ impl pallet_capacity::Config for Test { type EraLength = ConstU32<5>; type StakingRewardsPastErasMax = ConstU32<2>; type RewardsProvider = Capacity; + type ChangeStakingTargetThawEras = ConstU32<1>; } use pallet_balances::Call as BalancesCall; diff --git a/runtime/common/src/constants.rs b/runtime/common/src/constants.rs index fe67e56322..d703fb10db 100644 --- a/runtime/common/src/constants.rs +++ b/runtime/common/src/constants.rs @@ -352,4 +352,6 @@ parameter_types! { pub const CapacityPerToken: Perbill = Perbill::from_percent(2); } +pub type CapacityChangeStakingTargetThawEras = ConstU32<5>; + // -end- Capacity Pallet --- diff --git a/runtime/frequency/src/lib.rs b/runtime/frequency/src/lib.rs index 89f7ab4461..baee758495 100644 --- a/runtime/frequency/src/lib.rs +++ b/runtime/frequency/src/lib.rs @@ -411,6 +411,7 @@ impl pallet_capacity::Config for Runtime { type EraLength = ConstU32<{ 14 * DAYS }>; type StakingRewardsPastErasMax = ConstU32<26u32>; // 1 year type RewardsProvider = Capacity; + type ChangeStakingTargetThawEras = CapacityChangeStakingTargetThawEras; } impl pallet_schemas::Config for Runtime {