diff --git a/Cargo.lock b/Cargo.lock index 4c4d0344bf222..28abdd5099579 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11078,8 +11078,6 @@ dependencies = [ "parity-scale-codec", "polkadot-sdk-frame", "scale-info", - "sp-core 28.0.0", - "sp-session", "sp-staking", "sp-tracing 16.0.0", ] @@ -13204,6 +13202,7 @@ dependencies = [ "log", "pallet-bags-list", "pallet-balances", + "pallet-migrations", "pallet-staking-async-rc-client", "parity-scale-codec", "rand 0.8.5", diff --git a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/lib.rs b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/lib.rs index 46094e233b1c7..d9b9aed36e51b 100644 --- a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/lib.rs +++ b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/lib.rs @@ -1199,7 +1199,10 @@ parameter_types! { impl pallet_migrations::Config for Runtime { type RuntimeEvent = RuntimeEvent; #[cfg(not(feature = "runtime-benchmarks"))] - type Migrations = pallet_revive::migrations::v1::Migration; + type Migrations = ( + pallet_revive::migrations::v1::Migration, + pallet_staking_async::migrations::v18::LazyMigrationV17ToV18, + ); // Benchmarks need mocked migrations to guarantee that they succeed. #[cfg(feature = "runtime-benchmarks")] type Migrations = pallet_migrations::mock_helpers::MockedMigrations; diff --git a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/staking.rs b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/staking.rs index 55f614508f0ee..727cbf562f9f1 100644 --- a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/staking.rs +++ b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/staking.rs @@ -256,7 +256,7 @@ parameter_types! { /// Duration of a relay session in our blocks. Needs to be hardcoded per-runtime. pub const RelaySessionDuration: BlockNumber = 1 * HOURS; // 2 eras for unbonding (12 hours). - pub const BondingDuration: sp_staking::EraIndex = 2; + pub const MaxUnbondingDuration: sp_staking::EraIndex = 2; // 1 era in which slashes can be cancelled (6 hours). pub const SlashDeferDuration: sp_staking::EraIndex = 1; pub const MaxControllersInDeprecationBatch: u32 = 751; @@ -276,7 +276,7 @@ impl pallet_staking_async::Config for Runtime { type Slash = (); type Reward = (); type SessionsPerEra = SessionsPerEra; - type BondingDuration = BondingDuration; + type MaxUnbondingDuration = MaxUnbondingDuration; type SlashDeferDuration = SlashDeferDuration; type AdminOrigin = EitherOf, StakingAdmin>; type EraPayout = EraPayout; diff --git a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/weights/pallet_staking_async.rs b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/weights/pallet_staking_async.rs index e1ac4f8e096ef..64608dc438ce7 100644 --- a/cumulus/parachains/runtimes/assets/asset-hub-westend/src/weights/pallet_staking_async.rs +++ b/cumulus/parachains/runtimes/assets/asset-hub-westend/src/weights/pallet_staking_async.rs @@ -917,4 +917,17 @@ impl pallet_staking_async::WeightInfo for WeightInfo .saturating_add(T::DbWeight::get().writes((4_u64).saturating_mul(v.into()))) .saturating_add(Weight::from_parts(0, 2749).saturating_mul(v.into())) } + + /// Storage: `Staking::Ledger` (r:2 w:1) + /// Proof: `Staking::Ledger` (`max_values`: None, `max_size`: Some(1265), added: 3740, mode: `MaxEncodedLen`) + /// The range of component `c` is `[1, 32]`. + fn migration_from_v17_to_v18_migrate_staking_ledger_step(_c: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `698 + c * (3 ±0)` + // Estimated: `8470` + // Minimum execution time: 45_000_000 picoseconds. + Weight::from_parts(60_791_228, 8470) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } } diff --git a/prdoc/pr_8298.prdoc b/prdoc/pr_8298.prdoc new file mode 100644 index 0000000000000..438cf68fb5535 --- /dev/null +++ b/prdoc/pr_8298.prdoc @@ -0,0 +1,54 @@ +title: 'Implementation of RFC-0097: Unbonding Queue for pallet-staking-async' +doc: +- audience: [Runtime Dev, Runtime User] + description: | + # Description + This PR implements [RFC-0097](https://polkadot-fellows.github.io/RFCs/approved/0097-unbonding_queue.html) and its + [modification](https://hackmd.io/@vKfUEAWlRR2Ogaq8nYYknw/SyfioMGWgl), which introduces a variable unbonding time + mechanism to improve the security-liquidity trade-off in the staking system. + + The unbonding queue allows for faster unbonding of staked tokens while maintaining network security by keeping a + minimum slashable share of stake locked for the full unbonding period. + + ### Modified extrinsics: + - `unbond`: Enhanced to work with the unbonding queue mechanism, calculating variable unbonding times based on stake distribution. + - `withdraw_unbonded`: Modified to handle withdrawals from the unbonding queue with different unlock periods. + - `rebond`: Updated to work with the queue-based unbonding system. + + ### Storage items: + - `ElectableStashes`: Added the stake backing up a given electable stash account. + - `UnbondingQueueParams`: New storage item containing configuration parameters: + - `min_slashable_share`: The minimum share of stake of the lowest backed validators that must remain slashable at any point in time. + - `lowest_ratio`: The proportion of the lowest-backed validator set. + - `unbond_period_lower_bound`: Minimum unbonding time in eras. + - `ErasLowestRatioTotalStake`: Tracks the lowest stake proportion among validators in each era. + - `ErasTotalUnbond`: Tracks the stake that started unbonding in a given era. + - `Ledger`: Added `previous_unbonded_stake` to track the accumulated stake to be unbonded at the time a given unlock chunk was created. + + ### View functions: + - `unbonding_duration`: Used to obtain the list of funds that will be released at a given era. + + ## Key features: + 1. Variable unbonding time: Unbonding time varies based on the amount of stake in the system. + 2. Security maintenance: Ensures a sufficient percentage of stake remains slashable at all times. + 3. Unbonding queue management: Implements an efficient queue system for managing unbonding requests. + + ### Migration v18: + Includes multi-block migration `LazyMigrationV17ToV18` that: + - Updates `StakingLedger` structure to include the new `previous_unbonded_stake` field in `UnlockChunk`. + - Migrates `ElectableStashes` from set-based to map-based storage (AccountId -> Balance mapping). + - Performs era adjustment for existing unlock chunks by subtracting the bonding duration. + - Uses stepped migration pattern to handle large datasets efficiently across multiple blocks. + - Includes comprehensive pre/post-upgrade checks for data integrity. + +crates: +- name: pallet-staking-async-parachain-runtime + bump: minor +- name: pallet-staking-async-runtime-api + bump: minor +- name: pallet-staking-async + bump: major +- name: sp-staking + bump: patch +- name: asset-hub-westend-runtime + bump: major diff --git a/substrate/frame/staking-async/Cargo.toml b/substrate/frame/staking-async/Cargo.toml index 84dae83b0a412..5bd719df92056 100644 --- a/substrate/frame/staking-async/Cargo.toml +++ b/substrate/frame/staking-async/Cargo.toml @@ -20,6 +20,7 @@ frame-election-provider-support = { workspace = true } frame-support = { workspace = true } frame-system = { workspace = true } log = { workspace = true } +pallet-migrations = { workspace = true, default-features = false } pallet-staking-async-rc-client = { workspace = true } rand = { features = ["alloc"], workspace = true } rand_chacha = { workspace = true } @@ -59,6 +60,7 @@ std = [ "log/std", "pallet-bags-list/std", "pallet-balances/std", + "pallet-migrations/std", "pallet-staking-async-rc-client/std", "rand/std", "rand_chacha/std", @@ -80,6 +82,7 @@ runtime-benchmarks = [ "frame-system/runtime-benchmarks", "pallet-bags-list/runtime-benchmarks", "pallet-balances/runtime-benchmarks", + "pallet-migrations/runtime-benchmarks", "pallet-staking-async-rc-client/runtime-benchmarks", "sp-runtime/runtime-benchmarks", "sp-staking/runtime-benchmarks", @@ -90,6 +93,7 @@ try-runtime = [ "frame-system/try-runtime", "pallet-bags-list/try-runtime", "pallet-balances/try-runtime", + "pallet-migrations/try-runtime", "pallet-staking-async-rc-client/try-runtime", "sp-runtime/try-runtime", ] diff --git a/substrate/frame/staking-async/ahm-test/Cargo.toml b/substrate/frame/staking-async/ahm-test/Cargo.toml index 7c434eb42de9f..d65e1791fc0f6 100644 --- a/substrate/frame/staking-async/ahm-test/Cargo.toml +++ b/substrate/frame/staking-async/ahm-test/Cargo.toml @@ -21,8 +21,6 @@ frame = { workspace = true, default-features = true } frame-support = { workspace = true, default-features = true } log = { workspace = true } scale-info = { features = ["derive"], workspace = true, default-features = true } -sp-core = { workspace = true, default-features = true } -sp-session = { workspace = true, default-features = true } sp-staking = { workspace = true, default-features = true } sp-tracing = { workspace = true, default-features = true } diff --git a/substrate/frame/staking-async/ahm-test/src/ah/mock.rs b/substrate/frame/staking-async/ahm-test/src/ah/mock.rs index baeef5f1d4f72..722b1348fd17a 100644 --- a/substrate/frame/staking-async/ahm-test/src/ah/mock.rs +++ b/substrate/frame/staking-async/ahm-test/src/ah/mock.rs @@ -121,7 +121,7 @@ pub(crate) fn roll_until_next_active(mut end_index: SessionIndex) -> Vec; - type BondingDuration = BondingDuration; + type MaxUnbondingDuration = MaxUnbondingDuration; type SessionsPerEra = SessionsPerEra; type PlanningEraOffset = PlanningEraOffset; @@ -490,6 +490,11 @@ impl ExtBuilder { validator_count: 4, active_era: (0, 0, 0), force_era: if self.pre_migration { Forcing::ForceNone } else { Forcing::default() }, + unbonding_queue_config: Some(pallet_staking_async::UnbondingQueueConfig { + min_slashable_share: Perbill::from_percent(50), + lowest_ratio: Perbill::from_percent(34), + unbond_period_lower_bound: 2, + }), ..Default::default() } .assimilate_storage(&mut t) diff --git a/substrate/frame/staking-async/ahm-test/src/ah/test.rs b/substrate/frame/staking-async/ahm-test/src/ah/test.rs index 2894de24b5a2e..9a6fc089b6b6e 100644 --- a/substrate/frame/staking-async/ahm-test/src/ah/test.rs +++ b/substrate/frame/staking-async/ahm-test/src/ah/test.rs @@ -21,7 +21,7 @@ use frame_support::assert_ok; use pallet_election_provider_multi_block::{Event as ElectionEvent, Phase}; use pallet_staking_async::{ self as staking_async, session_rotation::Rotator, ActiveEra, ActiveEraInfo, CurrentEra, - Event as StakingEvent, + ErasLowestRatioTotalStake, Event as StakingEvent, }; use pallet_staking_async_rc_client::{self as rc_client, UnexpectedKind, ValidatorSetReport}; @@ -102,6 +102,9 @@ fn on_receive_session_report() { ); } + // The lowest stake proportion is zero before the election occurs. + assert_eq!(ErasLowestRatioTotalStake::::iter().collect::>(), vec![]); + // Next session we will begin election. assert_ok!(rc_client::Pallet::::relay_session_report( RuntimeOrigin::root(), @@ -196,6 +199,10 @@ fn on_receive_session_report() { }) )] ); + + // After the election, the stake backing the lowest third is composed of only one validator + // backed with 100. + assert_eq!(ErasLowestRatioTotalStake::::iter().collect::>(), vec![(1, 100)]); }) } diff --git a/substrate/frame/staking-async/runtime-api/src/lib.rs b/substrate/frame/staking-async/runtime-api/src/lib.rs index 7955f4184a434..1e2bd52d8eb49 100644 --- a/substrate/frame/staking-async/runtime-api/src/lib.rs +++ b/substrate/frame/staking-async/runtime-api/src/lib.rs @@ -19,6 +19,9 @@ #![cfg_attr(not(feature = "std"), no_std)] +extern crate alloc; + +use alloc::vec::Vec; use codec::Codec; sp_api::decl_runtime_apis! { @@ -35,5 +38,11 @@ sp_api::decl_runtime_apis! { /// Returns true if validator `account` has pages to be claimed for the given era. fn pending_rewards(era: sp_staking::EraIndex, account: AccountId) -> bool; + + /// Returns a list of (era, amount) that indices at which era unbonded funds will be unlocked. + fn unbonding_duration(account: AccountId) -> Vec<(sp_staking::EraIndex, Balance)>; + + /// Returns the estimated number of eras for unbonding a given amount. + fn estimate_unbonding_duration(amount: Balance) -> sp_staking::EraIndex; } } diff --git a/substrate/frame/staking-async/runtimes/parachain/src/genesis_config_presets.rs b/substrate/frame/staking-async/runtimes/parachain/src/genesis_config_presets.rs index a81bbfa424ba9..b5395f0145c35 100644 --- a/substrate/frame/staking-async/runtimes/parachain/src/genesis_config_presets.rs +++ b/substrate/frame/staking-async/runtimes/parachain/src/genesis_config_presets.rs @@ -90,6 +90,11 @@ fn staking_async_parachain_genesis(params: GenesisParams, preset: String) -> ser .into_iter() .map(|acc| (acc, endowment / 2, StakerStatus::Validator)) .collect(), + unbonding_queue_config: Some(pallet_staking_async::UnbondingQueueConfig { + min_slashable_share: Perbill::from_percent(50), + lowest_ratio: Perbill::from_percent(34), + unbond_period_lower_bound: 0, + }), ..Default::default() } }) diff --git a/substrate/frame/staking-async/runtimes/parachain/src/lib.rs b/substrate/frame/staking-async/runtimes/parachain/src/lib.rs index df0996a66f210..a04cf42384d99 100644 --- a/substrate/frame/staking-async/runtimes/parachain/src/lib.rs +++ b/substrate/frame/staking-async/runtimes/parachain/src/lib.rs @@ -1792,6 +1792,14 @@ impl_runtime_apis! { fn pending_rewards(era: sp_staking::EraIndex, account: AccountId) -> bool { Staking::api_pending_rewards(era, account) } + + fn unbonding_duration(account: AccountId) -> Vec<(sp_staking::EraIndex, Balance)> { + Staking::unbonding_duration(account) + } + + fn estimate_unbonding_duration(amount: Balance) -> sp_staking::EraIndex { + Staking::estimate_unbonding_duration(amount) + } } #[cfg(feature = "runtime-benchmarks")] diff --git a/substrate/frame/staking-async/runtimes/parachain/src/staking.rs b/substrate/frame/staking-async/runtimes/parachain/src/staking.rs index ab5c8f7fe4104..42930537b1d32 100644 --- a/substrate/frame/staking-async/runtimes/parachain/src/staking.rs +++ b/substrate/frame/staking-async/runtimes/parachain/src/staking.rs @@ -410,7 +410,7 @@ parameter_types! { /// Duration of a relay session in our blocks. Needs to be hardcoded per-runtime. pub const RelaySessionDuration: BlockNumber = 10; // 2 eras for unbonding (12 hours). - pub const BondingDuration: sp_staking::EraIndex = 2; + pub const MaxUnbondingDuration: sp_staking::EraIndex = 2; // 1 era in which slashes can be cancelled (6 hours). pub const SlashDeferDuration: sp_staking::EraIndex = 1; // Note: this is not really correct as Max Nominators is (MaxExposurePageSize * page_count) but @@ -435,7 +435,7 @@ impl pallet_staking_async::Config for Runtime { type Slash = (); type Reward = (); type SessionsPerEra = SessionsPerEra; - type BondingDuration = BondingDuration; + type MaxUnbondingDuration = MaxUnbondingDuration; type SlashDeferDuration = SlashDeferDuration; type AdminOrigin = EitherOf, StakingAdmin>; type EraPayout = EraPayout; diff --git a/substrate/frame/staking-async/runtimes/parachain/src/weights/pallet_staking_async.rs b/substrate/frame/staking-async/runtimes/parachain/src/weights/pallet_staking_async.rs index 47a2945daec9e..850809c6fe925 100644 --- a/substrate/frame/staking-async/runtimes/parachain/src/weights/pallet_staking_async.rs +++ b/substrate/frame/staking-async/runtimes/parachain/src/weights/pallet_staking_async.rs @@ -900,4 +900,16 @@ impl pallet_staking_async::WeightInfo for WeightInfo .saturating_add(T::DbWeight::get().writes((4_u64).saturating_mul(v.into()))) .saturating_add(Weight::from_parts(0, 3937).saturating_mul(v.into())) } + /// Storage: `Staking::Ledger` (r:2 w:1) + /// Proof: `Staking::Ledger` (`max_values`: None, `max_size`: Some(1265), added: 3740, mode: `MaxEncodedLen`) + /// The range of component `c` is `[1, 32]`. + fn migration_from_v17_to_v18_migrate_staking_ledger_step(_c: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `698 + c * (3 ±0)` + // Estimated: `8470` + // Minimum execution time: 45_000_000 picoseconds. + Weight::from_parts(60_791_228, 8470) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } } diff --git a/substrate/frame/staking-async/src/benchmarking.rs b/substrate/frame/staking-async/src/benchmarking.rs index a004824f7b7c5..d6cabafdebdeb 100644 --- a/substrate/frame/staking-async/src/benchmarking.rs +++ b/substrate/frame/staking-async/src/benchmarking.rs @@ -127,6 +127,25 @@ pub(crate) fn create_validator_with_nominators( Ok((v_stash, nominators, planned_era)) } +pub fn prepare_unbonding_scenario() { + Staking::::set_staking_configs( + RawOrigin::Root.into(), + ConfigOp::Noop, + ConfigOp::Noop, + ConfigOp::Noop, + ConfigOp::Noop, + ConfigOp::Noop, + ConfigOp::Noop, + ConfigOp::Noop, + ConfigOp::Set(UnbondingQueueConfig { + min_slashable_share: Perbill::from_percent(50), + lowest_ratio: Perbill::from_percent(34), + unbond_period_lower_bound: 2, + }), + ) + .expect("failed to set staking configs"); +} + struct ListScenario { /// Stash that is expected to be moved. origin_stash1: T::AccountId, @@ -219,7 +238,7 @@ impl ListScenario { const USER_SEED: u32 = 999666; -#[benchmarks] +#[benchmarks(where T: core::fmt::Debug)] mod benchmarks { use super::*; @@ -292,6 +311,8 @@ mod benchmarks { let ledger = Ledger::::get(&controller).ok_or("ledger not created before")?; let original_bonded: BalanceOf = ledger.active; + prepare_unbonding_scenario::(); + whitelist_account!(controller); #[extrinsic_call] @@ -770,7 +791,11 @@ mod benchmarks { // so the sum of unlocking chunks puts voter into the dest bag. assert!(value * l.into() + origin_weight > origin_weight); assert!(value * l.into() + origin_weight <= dest_weight); - let unlock_chunk = UnlockChunk::> { value, era: EraIndex::zero() }; + let unlock_chunk = UnlockChunk::> { + value, + era: Zero::zero(), + previous_unbonded_stake: Zero::zero(), + }; let controller = scenario.origin_controller1; let mut staking_ledger = Ledger::::get(controller.clone()).unwrap(); @@ -836,6 +861,11 @@ mod benchmarks { ConfigOp::Set(Percent::max_value()), ConfigOp::Set(Perbill::max_value()), ConfigOp::Set(Percent::max_value()), + ConfigOp::Set(UnbondingQueueConfig { + min_slashable_share: Perbill::from_percent(50), + lowest_ratio: Perbill::from_percent(34), + unbond_period_lower_bound: 2, + }), ); assert_eq!(MinNominatorBond::::get(), BalanceOf::::max_value()); @@ -859,6 +889,7 @@ mod benchmarks { ConfigOp::Remove, ConfigOp::Remove, ConfigOp::Remove, + ConfigOp::Remove, ); assert!(!MinNominatorBond::::exists()); @@ -868,6 +899,7 @@ mod benchmarks { assert!(!ChillThreshold::::exists()); assert!(!MinCommission::::exists()); assert!(!MaxStakedRewards::::exists()); + assert!(!UnbondingQueueParams::::exists()); } #[benchmark] @@ -892,6 +924,7 @@ mod benchmarks { ConfigOp::Set(Percent::from_percent(0)), ConfigOp::Set(Zero::zero()), ConfigOp::Noop, + ConfigOp::Noop, )?; let caller = whitelisted_caller(); @@ -1268,6 +1301,62 @@ mod benchmarks { Ok(()) } + #[benchmark] + fn migration_from_v17_to_v18_migrate_staking_ledger_step( + c: Linear<1, { T::MaxUnlockingChunks::get() }>, + ) -> Result<(), BenchmarkError> { + clear_validators_and_nominators::(); + let mut meter = frame_support::weights::WeightMeter::new(); + let stash = create_funded_user::("stash", USER_SEED, 100); + let max_bonding_duration = ::MaxUnbondingDuration::get(); + let mut unlocking = vec![]; + for era in 0..c { + unlocking.push(crate::migrations::v18::v17::UnlockChunk { + value: 100_u32.into(), + era: max_bonding_duration + era, + }); + } + crate::migrations::v18::v17::Ledger::::insert( + stash.clone(), + crate::migrations::v18::v17::StakingLedger { + stash: stash.clone(), + total: 600_u32.into(), + active: 500_u32.into(), + controller: None, + unlocking: unlocking.clone().try_into().unwrap(), + }, + ); + + #[block] + { + crate::migrations::v18::LazyMigrationV17ToV18::::do_migrate_staking_ledger( + &mut meter, &mut None, + ); + } + + assert_eq!( + Ledger::::get(&stash), + Some(StakingLedger { + stash, + total: 600_u32.into(), + active: 500_u32.into(), + controller: None, + unlocking: unlocking + .into_iter() + .map(|u| UnlockChunk { + value: u.value, + era: u.era.saturating_sub(T::MaxUnbondingDuration::get()), + previous_unbonded_stake: u32::MAX.into() + }) + .collect::>() + .try_into() + .unwrap(), + }) + ); + + Ok(()) + } + impl_benchmark_test_suite!( Staking, crate::mock::ExtBuilder::default().has_stakers(true), diff --git a/substrate/frame/staking-async/src/ledger.rs b/substrate/frame/staking-async/src/ledger.rs index 55a07ddec5362..1651ffdf6a2f3 100644 --- a/substrate/frame/staking-async/src/ledger.rs +++ b/substrate/frame/staking-async/src/ledger.rs @@ -32,8 +32,8 @@ //! state consistency. use crate::{ - asset, log, BalanceOf, Bonded, Config, DecodeWithMemTracking, Error, Ledger, Pallet, Payee, - RewardDestination, Vec, VirtualStakers, + asset, log, session_rotation::Eras, BalanceOf, Bonded, Config, DecodeWithMemTracking, Error, + Ledger, Pallet, Payee, RewardDestination, Vec, VirtualStakers, }; use alloc::{collections::BTreeMap, fmt::Debug}; use codec::{Decode, Encode, HasCompact, MaxEncodedLen}; @@ -54,9 +54,16 @@ pub struct UnlockChunk { /// Amount of funds to be unlocked. #[codec(compact)] pub value: Balance, - /// Era number at which point it'll be unlocked. + /// Era number when the chunk was created. + /// + /// Note: historically this field represented the *unlocking* era, but since storage version + /// v18 (which introduced dynamic unbonding duration), it now reflects the *creation* era of + /// the chunk. #[codec(compact)] pub era: EraIndex, + /// Total accumulated stake to be unbonded when this chunk was created. + #[codec(compact)] + pub previous_unbonded_stake: Balance, } /// The ledger of a (bonded) stash. @@ -344,30 +351,20 @@ impl StakingLedger { /// Remove entries from `unlocking` that are sufficiently old and reduce the /// total by the sum of their balances. - pub(crate) fn consolidate_unlocked(self, current_era: EraIndex) -> Self { - let mut total = self.total; - let unlocking: BoundedVec<_, _> = self - .unlocking - .into_iter() - .filter(|chunk| { - if chunk.era > current_era { - true - } else { - total = total.saturating_sub(chunk.value); - false - } - }) - .collect::>() - .try_into() - .expect( - "filtering items from a bounded vec always leaves length less than bounds. qed", - ); - + pub(crate) fn consolidate_unlocked(self, last_offence_era: EraIndex) -> Self { + let ((_, free), chunks) = + Pallet::::curate_unlocking_chunks(self.stash.clone(), last_offence_era); + let unlocking: Vec<_> = chunks.into_values().collect(); Self { stash: self.stash, - total, + total: self.total.defensive_saturating_sub(free), active: self.active, - unlocking, + unlocking: unlocking + .into_iter() + .flatten() + .collect::>() + .try_into() + .expect("unlocking chunk size cannot grow; qed"), controller: self.controller, } } @@ -380,6 +377,9 @@ impl StakingLedger { while let Some(last) = self.unlocking.last_mut() { if unlocking_balance.defensive_saturating_add(last.value) <= value { + let unbond = + Eras::::get_total_unbond_for_era(last.era).saturating_sub(last.value); + Eras::::set_total_unbond_for_era(last.era, unbond); unlocking_balance += last.value; self.active += last.value; self.unlocking.pop(); @@ -389,6 +389,8 @@ impl StakingLedger { unlocking_balance += diff; self.active += diff; last.value -= diff; + let unbond = Eras::::get_total_unbond_for_era(last.era).saturating_sub(diff); + Eras::::set_total_unbond_for_era(last.era, unbond); } if unlocking_balance >= value { @@ -438,7 +440,7 @@ impl StakingLedger { // for a `slash_era = x`, any chunk that is scheduled to be unlocked at era `x + 28` // (assuming 28 is the bonding duration) onwards should be slashed. - let slashable_chunks_start = slash_era.saturating_add(T::BondingDuration::get()); + let slashable_chunks_start = slash_era.saturating_add(T::MaxUnbondingDuration::get()); // `Some(ratio)` if this is proportional, with `ratio`, `None` otherwise. In both cases, we // slash first the active chunk, and then `slash_chunks_priority`. diff --git a/substrate/frame/staking-async/src/lib.rs b/substrate/frame/staking-async/src/lib.rs index 80c32b06ca251..cdf771c12de04 100644 --- a/substrate/frame/staking-async/src/lib.rs +++ b/substrate/frame/staking-async/src/lib.rs @@ -190,12 +190,15 @@ mod tests; pub mod asset; pub mod election_size_tracker; pub mod ledger; +pub mod migrations; mod pallet; pub mod session_rotation; pub mod slashing; pub mod weights; extern crate alloc; +extern crate core; + use alloc::{vec, vec::Vec}; use codec::{Decode, DecodeWithMemTracking, Encode, HasCompact, MaxEncodedLen}; use frame_election_provider_support::ElectionProvider; @@ -281,9 +284,36 @@ pub struct ActiveEraInfo { pub start: Option, } +/// Parameters of the unbonding queue mechanism. +#[derive( + PartialEq, + Eq, + Copy, + Clone, + Encode, + Decode, + DecodeWithMemTracking, + RuntimeDebug, + TypeInfo, + MaxEncodedLen, + Default, + serde::Serialize, + serde::Deserialize, +)] +pub struct UnbondingQueueConfig { + /// The share of stake backing the lowest portion of validators that is slashable at any point + /// in time. It should offer a trade-off between security and unbonding time. 50% is considered + /// a reasonable value. + pub min_slashable_share: Perbill, + /// Minimum truncate stake ratio. 1/3 is considered a reasonable value. + pub lowest_ratio: Perbill, + /// The minimum unbonding time, in eras, for an active stake. + pub unbond_period_lower_bound: EraIndex, +} + /// Reward points of an era. Used to split era total payout between validators. /// -/// This points will be used to reward validators and their respective nominators. +/// These points will be used to reward validators and their respective nominators. #[derive( PartialEqNoBound, Encode, Decode, DebugNoBound, TypeInfo, MaxEncodedLen, DefaultNoBound, )] diff --git a/substrate/frame/staking-async/src/migrations/mod.rs b/substrate/frame/staking-async/src/migrations/mod.rs new file mode 100644 index 0000000000000..5cee70d33faea --- /dev/null +++ b/substrate/frame/staking-async/src/migrations/mod.rs @@ -0,0 +1,20 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +pub mod v18; + +pub const PALLET_MIGRATIONS_ID: &[u8; 20] = b"pallet-staking-async"; diff --git a/substrate/frame/staking-async/src/migrations/v18.rs b/substrate/frame/staking-async/src/migrations/v18.rs new file mode 100644 index 0000000000000..be4a9fd917ee7 --- /dev/null +++ b/substrate/frame/staking-async/src/migrations/v18.rs @@ -0,0 +1,410 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Staking Async Pallet migration from v17 to v18. + +use crate::{ + migrations::PALLET_MIGRATIONS_ID, pallet::pallet::ElectableStashes, weights::WeightInfo, + BalanceOf, Config, Ledger, Pallet, StakingLedger, UnlockChunk, +}; +use alloc::{collections::BTreeMap, vec::Vec}; +use codec::{Decode, Encode}; +use core::fmt::Debug; +use frame_support::{ + migrations::{MigrationId, SteppedMigration, SteppedMigrationError}, + pallet_prelude::*, + weights::WeightMeter, +}; + +pub(crate) mod v17 { + use crate::{BalanceOf, Config, Pallet}; + use codec::{HasCompact, MaxEncodedLen}; + use frame_support::{pallet_prelude::*, storage_alias}; + use sp_staking::EraIndex; + + #[derive( + PartialEq, Eq, Clone, Encode, Decode, DecodeWithMemTracking, Debug, TypeInfo, MaxEncodedLen, + )] + pub struct UnlockChunk { + /// Amount of funds to be unlocked. + #[codec(compact)] + pub(crate) value: Balance, + /// Era number at which point it'll be unlocked. + #[codec(compact)] + pub(crate) era: EraIndex, + } + + #[derive( + PartialEqNoBound, + EqNoBound, + CloneNoBound, + Encode, + Decode, + DebugNoBound, + TypeInfo, + MaxEncodedLen, + )] + #[scale_info(skip_type_params(T))] + pub struct StakingLedger { + pub stash: T::AccountId, + #[codec(compact)] + pub total: BalanceOf, + #[codec(compact)] + pub active: BalanceOf, + pub unlocking: BoundedVec>, T::MaxUnlockingChunks>, + #[codec(skip)] + pub(crate) controller: Option, + } + + #[storage_alias] + pub type Ledger = StorageMap< + Pallet, + Blake2_128Concat, + ::AccountId, + StakingLedger, + >; + + #[storage_alias] + pub type ElectableStashes = StorageValue< + Pallet, + BoundedBTreeSet<::AccountId, ::MaxValidatorSet>, + ValueQuery, + >; +} + +/// Operations to be performed during this migration. +#[derive( + PartialEq, Eq, Clone, Encode, Decode, RuntimeDebug, scale_info::TypeInfo, MaxEncodedLen, +)] +pub enum MigrationSteps { + /// Migrate staking ledger storage. The cursor indicates the last processed account. + /// If None, start from the beginning. + MigrateStakingLedger { cursor: Option }, + /// Migrate electable stashes storage. + MigrateElectableStashes, + /// Changes the storage version to 18. + ChangeStorageVersion, + /// No more operations to be performed. + Noop, +} + +pub struct LazyMigrationV17ToV18(PhantomData); + +impl LazyMigrationV17ToV18 { + pub(crate) fn do_migrate_staking_ledger( + meter: &mut WeightMeter, + cursor: &mut Option, + ) { + let max_chunks = ::MaxUnlockingChunks::get(); + let required = + ::WeightInfo::migration_from_v17_to_v18_migrate_staking_ledger_step( + max_chunks, + ); + + let mut iter = if let Some(acc) = cursor.clone() { + v17::Ledger::::iter_from(v17::Ledger::::hashed_key_for(acc)) + } else { + v17::Ledger::::iter() + }; + + let max_bonding_duration = ::MaxUnbondingDuration::get(); + while meter.can_consume(required) { + if let Some((acc, old_ledger)) = iter.next() { + meter.consume(::WeightInfo::migration_from_v17_to_v18_migrate_staking_ledger_step(old_ledger.unlocking.len() as u32)); + let new_unlocking = old_ledger + .unlocking + .iter() + .map(|c| UnlockChunk { + value: c.value, + era: c.era.saturating_sub(max_bonding_duration), + previous_unbonded_stake: u32::MAX.into(), + }) + .collect::>(); + Ledger::::insert( + acc.clone(), + StakingLedger { + stash: old_ledger.stash, + total: old_ledger.total, + active: old_ledger.active, + unlocking: new_unlocking + .try_into() + .expect("Array lengths should be the same; qed"), + controller: None, + }, + ); + *cursor = Some(acc) + } else { + *cursor = None; + break; + } + } + } + + pub(crate) fn change_storage_version(meter: &mut WeightMeter) -> MigrationSteps { + let required = T::DbWeight::get().reads_writes(0, 1); + if meter.try_consume(required).is_ok() { + StorageVersion::new(Self::id().version_to as u16).put::>(); + MigrationSteps::Noop + } else { + MigrationSteps::ChangeStorageVersion + } + } + + pub(crate) fn migrate_electable_stashes(meter: &mut WeightMeter) -> MigrationSteps { + let required = T::DbWeight::get().reads_writes(1, 2); + if meter.try_consume(required).is_ok() { + let orig = v17::ElectableStashes::::take(); + let new_electable_stashes = orig + .iter() + .map(|acc| (acc.clone(), BalanceOf::::zero())) + .collect::::AccountId, BalanceOf>>(); + ElectableStashes::::set( + new_electable_stashes + .try_into() + .expect("The number of elements should be the same; qed"), + ); + MigrationSteps::ChangeStorageVersion + } else { + MigrationSteps::MigrateElectableStashes + } + } + + pub(crate) fn migrate_staking_ledger( + meter: &mut WeightMeter, + mut cursor: Option, + ) -> MigrationSteps { + Self::do_migrate_staking_ledger(meter, &mut cursor); + match cursor { + None => Self::migrate_electable_stashes(meter), + Some(checkpoint) => MigrationSteps::MigrateStakingLedger { cursor: Some(checkpoint) }, + } + } +} + +impl SteppedMigration for LazyMigrationV17ToV18 { + type Cursor = MigrationSteps; + type Identifier = MigrationId<20>; + + fn id() -> Self::Identifier { + MigrationId { pallet_id: *PALLET_MIGRATIONS_ID, version_from: 17, version_to: 18 } + } + + fn step( + maybe_cursor: Option, + meter: &mut WeightMeter, + ) -> Result, SteppedMigrationError> { + if Pallet::::on_chain_storage_version() != Self::id().version_from as u16 { + return Ok(None); + } + + let cursor = maybe_cursor.unwrap_or(MigrationSteps::MigrateStakingLedger { cursor: None }); + log::info!("Running migration at step: {:?}", cursor); + + let new_cursor = match cursor { + MigrationSteps::MigrateStakingLedger { cursor: checkpoint } => + Self::migrate_staking_ledger(meter, checkpoint), + MigrationSteps::MigrateElectableStashes => Self::migrate_electable_stashes(meter), + MigrationSteps::ChangeStorageVersion => Self::change_storage_version(meter), + MigrationSteps::Noop => MigrationSteps::Noop, + }; + + match new_cursor { + MigrationSteps::Noop => { + log::info!("Migration from v17 to v18 fully complete!"); + Ok(None) + }, + _ => { + log::info!("Migration from v17 to v18 not completed yet: {:?}", new_cursor); + Ok(Some(new_cursor)) + }, + } + } + + #[cfg(feature = "try-runtime")] + fn pre_upgrade() -> Result, sp_runtime::TryRuntimeError> { + let prev_electable_stashes = v17::ElectableStashes::::get().into_inner(); + let prev_ledgers = v17::Ledger::::iter().collect::>(); + Ok((prev_electable_stashes, prev_ledgers).encode()) + } + + #[cfg(feature = "try-runtime")] + fn post_upgrade(prev: Vec) -> Result<(), sp_runtime::TryRuntimeError> { + use codec::Decode; + + ensure!( + Pallet::::on_chain_storage_version() == + StorageVersion::new(Self::id().version_to as u16), + "Migration post-upgrade failed: the storage version is not the expected one" + ); + + let (prev_electable_stashes, prev_ledgers) = <( + alloc::collections::BTreeSet, + BTreeMap>, + )>::decode(&mut &prev[..]) + .expect("Failed to decode the previous storage state"); + + let new_electable_stashes = ElectableStashes::::get(); + ensure!( + new_electable_stashes.len() == prev_electable_stashes.len(), + "Migration failed: the number of electable stashes is not the same" + ); + for (acc, amount) in new_electable_stashes.into_inner() { + ensure!( + amount.is_zero(), + "Migration failed: the stake for the stash account is not zero after the migration" + ); + ensure!( + prev_electable_stashes.get(&acc).is_some(), + "Migration failed: the electable stash is missing in the previous storage state" + ); + } + + let new_ledgers = Ledger::::iter().collect::>(); + ensure!( + new_ledgers.len() == prev_ledgers.len(), + "Migration failed: the number of staking ledgers is not the same" + ); + for (acc, ledger) in new_ledgers.into_iter() { + if let Some(prev_ledger) = prev_ledgers.get(&acc) { + ensure!( + ledger.total == prev_ledger.total, + "Migration failed: the ledger's total stake is not the same" + ); + ensure!( + ledger.stash == prev_ledger.stash, + "Migration failed: the ledger's stash is not the same" + ); + ensure!( + ledger.controller == prev_ledger.controller, + "Migration failed: the ledger's controller is not the same" + ); + ensure!( + ledger.active == prev_ledger.active, + "Migration failed: the ledger's active stake is not the same" + ); + ensure!( + ledger.unlocking.len() == prev_ledger.unlocking.len(), + "Migration failed: different number of unlocking chunks" + ); + for (i, chunk) in ledger.unlocking.iter().enumerate() { + let old_chunk = &prev_ledger.unlocking[i]; + ensure!( + chunk.era == + old_chunk + .era + .saturating_sub(::MaxUnbondingDuration::get()), + "Migration failed: mismatch in chunk's era" + ); + ensure!( + chunk.value == old_chunk.value, + "Migration failed: mismatch in chunk's value" + ); + ensure!( + chunk.previous_unbonded_stake == u32::MAX.into(), + "Migration failed: previous unbonded stake in chunk is not zero" + ); + } + } else { + panic!("Ledger not found in the previous storage state: {:?}", acc); + } + } + + Ok(()) + } +} + +#[cfg(all(test, not(feature = "runtime-benchmarks")))] +mod tests { + use super::*; + use crate::mock::*; + use frame_support::{migrations::MultiStepMigrator, traits::OnRuntimeUpgrade}; + use std::collections::BTreeSet; + + #[test] + fn migration_of_many_elements_should_work() { + ExtBuilder::default().try_state(false).has_stakers(false).build_and_execute(|| { + let users = 1000; + assert_eq!(::MaxUnbondingDuration::get(), 3); + + StorageVersion::new(17).put::>(); + assert_eq!(Pallet::::on_chain_storage_version(), 17); + Session::roll_until_active_era(10); + let max_chunks = ::MaxUnlockingChunks::get(); + + for i in 1..=users { + let mut chunks = vec![]; + for _ in 0..max_chunks { + chunks.push(v17::UnlockChunk { value: 1000, era: 10 }); + } + v17::Ledger::::insert( + i, + v17::StakingLedger { + stash: i, + total: (max_chunks as u128) * 1000 + 300, + active: 300, + unlocking: chunks.try_into().unwrap(), + controller: None, + }, + ); + } + + let total_electable_stashes = ::MaxValidatorSet::get() as u64; + let mut electable_stashes = BTreeSet::new(); + (1..=total_electable_stashes).for_each(|i| { + electable_stashes.insert(i); + }); + v17::ElectableStashes::::set(electable_stashes.clone().try_into().unwrap()); + + // Perform the migration. + let initial_block = System::block_number(); + AllPalletsWithSystem::on_runtime_upgrade(); + while ::ongoing() { + let block = System::block_number(); + assert!( + block - initial_block <= 100, + "Migration should not take more than 100 blocks" + ); + Session::roll_next(); + } + assert!( + System::block_number() >= initial_block + 2, + "Migration did not last more than two blocks" + ); + + // Check the results after the migration. + assert_eq!(Pallet::::on_chain_storage_version(), StorageVersion::new(18)); + + let expected_stashes = electable_stashes + .into_iter() + .map(|acc| (acc, 0u128)) + .collect::>(); + assert_eq!(ElectableStashes::::get().into_inner(), expected_stashes); + + Ledger::::iter().for_each(|(acc, ledger)| { + assert_eq!(ledger.stash, acc); + assert_eq!(ledger.controller, None); + assert_eq!(ledger.total, (max_chunks * 1000 + 300).into()); + assert_eq!(ledger.active, 300); + for unlocking in ledger.unlocking.into_iter() { + assert_eq!(unlocking.value, 1000); + assert_eq!(unlocking.previous_unbonded_stake, u32::MAX.into()); + assert_eq!(unlocking.era, 10 - 3); + } + }) + }); + } +} diff --git a/substrate/frame/staking-async/src/mock.rs b/substrate/frame/staking-async/src/mock.rs index 875402d4dee09..4a06f428a4f16 100644 --- a/substrate/frame/staking-async/src/mock.rs +++ b/substrate/frame/staking-async/src/mock.rs @@ -32,12 +32,14 @@ use frame_support::{ traits::{EitherOfDiverse, Get, Imbalance, OnUnbalanced}, weights::constants::RocksDbWeight, }; -use frame_system::{pallet_prelude::BlockNumberFor, EnsureRoot, EnsureSignedBy}; +use frame_system::{ + limits::BlockWeights, pallet_prelude::BlockNumberFor, EnsureRoot, EnsureSignedBy, +}; use pallet_staking_async_rc_client as rc_client; use sp_core::{ConstBool, ConstU64}; use sp_io; use sp_npos_elections::BalancingConfig; -use sp_runtime::{traits::Zero, BuildStorage}; +use sp_runtime::{traits::Zero, BuildStorage, Weight}; use sp_staking::{ currency_to_vote::SaturatingCurrencyToVote, OnStakingUpdate, SessionIndex, StakingAccount, }; @@ -52,6 +54,7 @@ frame_support::construct_runtime!( Balances: pallet_balances, Staking: pallet_staking_async, VoterBagsList: pallet_bags_list::, + Migrator: pallet_migrations, } ); @@ -81,7 +84,7 @@ parameter_types! { pub static ExistentialDeposit: Balance = 1; pub static SlashDeferDuration: EraIndex = 0; pub static MaxControllersInDeprecationBatch: u32 = 5900; - pub static BondingDuration: EraIndex = 3; + pub static MaxUnbondingDuration: EraIndex = 3; pub static HistoryDepth: u32 = 80; pub static MaxExposurePageSize: u32 = 64; pub static MaxUnlockingChunks: u32 = 32; @@ -101,6 +104,7 @@ impl frame_system::Config for Test { type DbWeight = RocksDbWeight; type Block = frame_system::mocking::MockBlock; type AccountData = pallet_balances::AccountData; + type MultiBlockMigrator = Migrator; } #[derive_impl(pallet_balances::config_preludes::TestDefaultConfig)] impl pallet_balances::Config for Test { @@ -243,6 +247,7 @@ impl Contains for MockedRestrictList { /// A representation of the session pallet that lives on the relay chain. pub mod session_mock { use super::*; + use frame_support::migrations::MultiStepMigrator; use pallet_staking_async_rc_client::ValidatorSetReport; pub struct Session; @@ -269,7 +274,12 @@ pub mod session_mock { pub fn roll_next() { let now = System::block_number(); Timestamp::mutate(|ts| *ts += BLOCK_TIME); - System::run_to_block::(now + 1); + System::run_to_block_with::( + now + 1, + frame_system::RunToBlockHooks::default().after_initialize(|_| { + ::MultiBlockMigrator::step(); + }), + ); Self::maybe_rotate_session_now(); } @@ -434,7 +444,7 @@ impl crate::pallet::pallet::Config for Test { type NominationsQuota = WeightedNominationsQuota<16>; type MaxUnlockingChunks = MaxUnlockingChunks; type HistoryDepth = HistoryDepth; - type BondingDuration = BondingDuration; + type MaxUnbondingDuration = MaxUnbondingDuration; type MaxControllersInDeprecationBatch = MaxControllersInDeprecationBatch; type EventListeners = EventListenerMock; type MaxInvulnerables = ConstU32<20>; @@ -448,6 +458,20 @@ impl crate::pallet::pallet::Config for Test { type WeightInfo = (); } +parameter_types! { + // Set the block maximum capacity low enough so that many migration steps are required. + pub MaxServiceWeight: Weight = <::BlockWeights as Get>::get().max_block.div(10); +} + +#[derive_impl(pallet_migrations::config_preludes::TestDefaultConfig)] +impl pallet_migrations::Config for Test { + type MaxServiceWeight = MaxServiceWeight; + #[cfg(not(feature = "runtime-benchmarks"))] + type Migrations = (crate::migrations::v18::LazyMigrationV17ToV18,); + #[cfg(feature = "runtime-benchmarks")] + type Migrations = pallet_migrations::mock_helpers::MockedMigrations; +} + pub struct WeightedNominationsQuota; impl NominationsQuota for WeightedNominationsQuota where @@ -477,13 +501,14 @@ pub struct ExtBuilder { validator_count: u32, invulnerables: BoundedVec::MaxInvulnerables>, has_stakers: bool, - pub min_nominator_bond: Balance, + min_nominator_bond: Balance, min_validator_bond: Balance, balance_factor: Balance, status: BTreeMap>, stakes: BTreeMap, stakers: Vec<(AccountId, Balance, StakerStatus)>, flush_events: bool, + unbonding_queue_config: Option, } impl Default for ExtBuilder { @@ -500,6 +525,7 @@ impl Default for ExtBuilder { stakes: Default::default(), stakers: Default::default(), flush_events: true, + unbonding_queue_config: None, } } } @@ -515,7 +541,7 @@ impl ExtBuilder { self } pub(crate) fn bonding_duration(self, bonding_duration: EraIndex) -> Self { - BondingDuration::set(bonding_duration); + MaxUnbondingDuration::set(bonding_duration); self } pub(crate) fn planning_era_offset(self, offset: SessionIndex) -> Self { @@ -620,6 +646,18 @@ impl ExtBuilder { SkipTryStateCheck::set(!enable); self } + pub(crate) fn has_unbonding_queue_config(mut self, has: bool) -> Self { + self.unbonding_queue_config = if has { + Some(UnbondingQueueConfig { + min_slashable_share: Perbill::from_percent(50), + lowest_ratio: Perbill::from_percent(34), + unbond_period_lower_bound: 2, + }) + } else { + None + }; + self + } fn build(self) -> sp_io::TestExternalities { sp_tracing::try_init_simple(); let mut storage = frame_system::GenesisConfig::::default().build_storage().unwrap(); @@ -690,6 +728,7 @@ impl ExtBuilder { slash_reward_fraction: Perbill::from_percent(10), min_nominator_bond: self.min_nominator_bond, min_validator_bond: self.min_validator_bond, + unbonding_queue_config: self.unbonding_queue_config, ..Default::default() } .assimilate_storage(&mut storage); diff --git a/substrate/frame/staking-async/src/pallet/impls.rs b/substrate/frame/staking-async/src/pallet/impls.rs index 13bc247bcd6a0..38307b132e504 100644 --- a/substrate/frame/staking-async/src/pallet/impls.rs +++ b/substrate/frame/staking-async/src/pallet/impls.rs @@ -16,6 +16,14 @@ // limitations under the License. //! `pallet-staking-async`'s main `impl` blocks. +//! +//! This file contains the core implementation logic for the async staking pallet, including: +//! - Ledger management +//! - Bonding and unbonding operations +//! - Staking rewards distribution +//! - Nomination and validation operations +//! - Snapshot creation for NPoS elections +//! - Offence handling and slashing use crate::{ asset, @@ -26,9 +34,14 @@ use crate::{ weights::WeightInfo, BalanceOf, Exposure, Forcing, LedgerIntegrityState, MaxNominationsOf, Nominations, NominationsQuota, PositiveImbalanceOf, RewardDestination, SnapshotStatus, StakingLedger, - ValidatorPrefs, STAKING_ID, + UnlockChunk, ValidatorPrefs, STAKING_ID, +}; +use alloc::{ + boxed::Box, + collections::btree_map::BTreeMap, + vec, + vec::{IntoIter, Vec}, }; -use alloc::{boxed::Box, vec, vec::Vec}; use frame_election_provider_support::{ bounds::CountBound, data_provider, DataProviderBounds, ElectionDataProvider, ElectionProvider, PageIndex, ScoreProvider, SortedListProvider, VoteWeight, VoterOf, @@ -211,9 +224,9 @@ impl Pallet { /// Calculate the earliest era that withdrawals are allowed for, considering: /// - The current active era /// - Any unprocessed offences in the queue - fn calculate_earliest_withdrawal_era(active_era: EraIndex) -> EraIndex { + pub(crate) fn calculate_earliest_withdrawal_era(active_era: EraIndex) -> EraIndex { // get lowest era for which all offences are processed and withdrawals can be allowed. - let earliest_unlock_era_by_offence_queue = OffenceQueueEras::::get() + OffenceQueueEras::::get() .as_ref() .and_then(|eras| eras.first()) .copied() @@ -222,16 +235,6 @@ impl Pallet { // above returns earliest era for which offences are NOT processed yet, so we subtract // one from it which gives us the oldest era for which all offences are processed. .saturating_sub(1) - // Unlock chunks are keyed by the era they were initiated plus Bonding Duration. - // We do the same to processed offence era so they can be compared. - .saturating_add(T::BondingDuration::get()); - - // If there are unprocessed offences older than the active era, withdrawals are only - // allowed up to the last era for which offences have been processed. - // Note: This situation is extremely unlikely, since offences have `SlashDeferDuration` eras - // to be processed. If it ever occurs, it likely indicates offence spam and that we're - // struggling to keep up with processing. - active_era.min(earliest_unlock_era_by_offence_queue) } pub(super) fn do_withdraw_unbonded(controller: &T::AccountId) -> Result { @@ -908,11 +911,9 @@ impl Pallet { // dec provider let _ = frame_system::Pallet::::dec_providers(&stash)?; - return Ok(()) + Ok(()) } -} -impl Pallet { /// Returns the current nominations quota for nominators. /// /// Used by the runtime API. @@ -934,6 +935,123 @@ impl Pallet { pub fn api_pending_rewards(era: EraIndex, account: T::AccountId) -> bool { Eras::::pending_rewards(era, &account) } + + /// Calculates the total stake of the lowest portion validators and stores it for the planned + /// era. + pub(crate) fn calculate_lowest_total_stake(era: EraIndex) { + // Only calculate if unbonding queue params have been set. + if let Some(params) = UnbondingQueueParams::::get() { + // Determine the total stake from the lowest portion of validators and persist for the + // era. + let mut validator_total_stakes: Vec<_> = + ElectableStashes::::get().into_iter().map(|(_, b)| b).collect(); + let validators_to_check = + (params.lowest_ratio * validator_total_stakes.len() as u32).max(1) as usize; + + // Sort exposure total stake by the lowest first and truncate to the lowest portion. + validator_total_stakes.sort(); + validator_total_stakes.truncate(validators_to_check); + + // Calculate the total stake of the lowest portion validators. + let total_stake = validator_total_stakes.into_iter().sum(); + + // Store the total stake of the lowest portion validators for the planned era. + Eras::::set_lowest_stake(era, total_stake) + } + } + + /// Internal implementation of unlocking chunks curation that: + /// - Processes provided unlock chunks to determine which can be immediately withdrawn. + /// - Recalculates unlock eras based on current unbonding queue parameters. + /// - Groups remaining chunks by their final unlock era. + /// + /// Parameters: + /// - `current_era`: The current era index. + /// - `last_offence_era`: The era index of the last offence. + /// - `chunks`: Iterator over unlocking chunks to be curated. + /// + /// Returns a tuple containing: + /// - First element: Tuple of (current era, immediately withdrawable amount) + /// - Second element: Map of era -> unlocking chunks that will be available in that era + pub(crate) fn curate_unlocking_chunks_inner( + current_era: EraIndex, + last_offence_era: EraIndex, + chunks: IntoIter>>, + ) -> ((EraIndex, BalanceOf), BTreeMap>>>) { + let earliest_considered_era = + current_era.saturating_add(1).saturating_sub(T::MaxUnbondingDuration::get()); + let (min_unlock_era, min_slashable_share) = match UnbondingQueueParams::::get() { + None => (T::MaxUnbondingDuration::get(), Zero::zero()), + Some(params) => (params.unbond_period_lower_bound, params.min_slashable_share), + }; + let mut result: BTreeMap>>> = BTreeMap::new(); + let mut free = BalanceOf::::zero(); + for chunk in chunks { + let max_release_era = + chunk.era.defensive_saturating_add(T::MaxUnbondingDuration::get()); + let has_offence = last_offence_era < chunk.era; + if current_era >= max_release_era && !has_offence { + // We can immediately withdraw these funds. + free.saturating_accrue(chunk.value); + } else { + // Optimistically set the final releasing era to the minimum possible. + let mut final_era = chunk.era.defensive_saturating_add(min_unlock_era); + let mut total_unbond = BalanceOf::::zero(); + + for era in (earliest_considered_era..=chunk.era).rev() { + let era_total_amount = Eras::::get_total_unbond_for_era(era); + let unbond = if era == chunk.era { + era_total_amount.min( + chunk.previous_unbonded_stake.defensive_saturating_add(chunk.value), + ) + } else { + era_total_amount + }; + total_unbond.saturating_accrue(unbond); + + let lowest_stake = Eras::::get_lowest_stake(era); + let threshold = + (Perbill::from_percent(100) - min_slashable_share) * lowest_stake; + if total_unbond >= threshold { + final_era = final_era + .max(era.defensive_saturating_add(T::MaxUnbondingDuration::get())); + break; + } + } + if final_era <= current_era && !has_offence { + free.saturating_accrue(chunk.value); + } else { + if let Some(elem) = result.get_mut(&final_era) { + elem.push(chunk); + } else { + result.insert(final_era, vec![chunk]); + } + } + } + } + ((current_era, free), result) + } + + /// Curates the unlocking chunks for a stash account by: + /// - Calculating immediately withdrawable balance. + /// - Determining new unlock eras based on queue parameters. + /// - Organizing remaining chunks by era. + /// + /// Returns a tuple of: + /// - Amount that can be immediately withdrawn. + /// - Map of era to remaining unlock chunks expected to be released in that era. + pub(crate) fn curate_unlocking_chunks( + stash: T::AccountId, + last_offence_era: EraIndex, + ) -> ((EraIndex, BalanceOf), BTreeMap>>>) { + let current_era = Rotator::::planned_era(); + let ledger = match Self::ledger(Stash(stash)) { + Ok(l) => l, + Err(_) => return ((current_era, Zero::zero()), BTreeMap::new()), + }; + let chunks = ledger.unlocking.into_iter(); + Self::curate_unlocking_chunks_inner(current_era, last_offence_era, chunks) + } } impl ElectionDataProvider for Pallet { @@ -1195,7 +1313,7 @@ impl rc_client::AHStakingInterface for Pallet { let oldest_reportable_offence_era = if T::SlashDeferDuration::get() == 0 { // this implies that slashes are applied immediately, so we can accept any offence up to // bonding duration old. - active_era.index.saturating_sub(T::BondingDuration::get()) + active_era.index.saturating_sub(T::MaxUnbondingDuration::get()) } else { // slashes are deffered, so we only accept offences that are not older than the // defferal duration. @@ -1558,7 +1676,7 @@ impl StakingInterface for Pallet { } fn bonding_duration() -> EraIndex { - T::BondingDuration::get() + T::MaxUnbondingDuration::get() } fn current_era() -> EraIndex { @@ -2035,7 +2153,7 @@ impl Pallet { active_era.saturating_sub(oldest_unprocessed_offence_era); // warn if less than 26 eras old. - if oldest_unprocessed_offence_age > 2.min(T::BondingDuration::get()) { + if oldest_unprocessed_offence_age > 2.min(T::MaxUnbondingDuration::get()) { log!( warn, "Offence queue has unprocessed offences from older than 2 eras: oldest offence era in queue {:?} (active era: {:?})", @@ -2046,7 +2164,7 @@ impl Pallet { // error if the oldest unprocessed offence era closer to bonding duration. ensure!( - oldest_unprocessed_offence_age < T::BondingDuration::get() - 1, + oldest_unprocessed_offence_age < T::MaxUnbondingDuration::get() - 1, "offences from era less than 3 eras old from active era not processed yet" ); @@ -2060,7 +2178,7 @@ impl Pallet { // (4) Ensure all slashes older than (active era - 1) are applied. // We will look at all eras before the active era as it can take 1 era for slashes // to be applied. - for era in (active_era.saturating_sub(T::BondingDuration::get()))..(active_era) { + for era in (active_era.saturating_sub(T::MaxUnbondingDuration::get()))..(active_era) { // all unapplied slashes are expected to be applied until the active era. If this is not // the case, then we need to use a permissionless call to apply all of them. // See `Call::apply_slash` for more details. diff --git a/substrate/frame/staking-async/src/pallet/mod.rs b/substrate/frame/staking-async/src/pallet/mod.rs index 80efe449ede59..fbf63bb734b96 100644 --- a/substrate/frame/staking-async/src/pallet/mod.rs +++ b/substrate/frame/staking-async/src/pallet/mod.rs @@ -23,8 +23,9 @@ use crate::{ NegativeImbalanceOf, Nominations, NominationsQuota, PositiveImbalanceOf, RewardDestination, StakingLedger, UnappliedSlash, UnlockChunk, ValidatorPrefs, }; -use alloc::{format, vec::Vec}; +use alloc::{format, vec, vec::Vec}; use codec::Codec; +use core::iter::Sum; use frame_election_provider_support::{ElectionProvider, SortedListProvider, VoteWeight}; use frame_support::{ assert_ok, @@ -38,7 +39,7 @@ use frame_support::{ Nothing, OnUnbalanced, }, weights::Weight, - BoundedBTreeSet, BoundedVec, + BoundedVec, }; use frame_system::{ensure_root, ensure_signed, pallet_prelude::*}; pub use impls::*; @@ -62,16 +63,18 @@ mod impls; #[frame_support::pallet] pub mod pallet { - use core::ops::Deref; - use super::*; - use crate::{session_rotation, PagedExposureMetadata, SnapshotStatus}; + use crate::{ + session_rotation, session_rotation::Eras, PagedExposureMetadata, SnapshotStatus, + UnbondingQueueConfig, + }; use codec::HasCompact; + use core::ops::Deref; use frame_election_provider_support::{ElectionDataProvider, PageIndex}; use frame_support::DefaultNoBound; /// The in-code storage version. - const STORAGE_VERSION: StorageVersion = StorageVersion::new(17); + const STORAGE_VERSION: StorageVersion = StorageVersion::new(18); #[pallet::pallet] #[pallet::storage_version(STORAGE_VERSION)] @@ -123,6 +126,7 @@ pub mod pallet { + Default + From + TypeInfo + + Sum + Send + Sync + MaxEncodedLen; @@ -203,7 +207,7 @@ pub mod pallet { /// Number of eras that staked funds must remain bonded for. #[pallet::constant] - type BondingDuration: Get; + type MaxUnbondingDuration: Get; /// Number of eras that slashes are deferred by, after computation. /// @@ -356,7 +360,7 @@ pub mod pallet { parameter_types! { pub const SessionsPerEra: SessionIndex = 3; - pub const BondingDuration: EraIndex = 3; + pub const MaxBondingDuration: EraIndex = 3; } #[frame_support::register_default_impl(TestDefaultConfig)] @@ -371,7 +375,7 @@ pub mod pallet { type Slash = (); type Reward = (); type SessionsPerEra = SessionsPerEra; - type BondingDuration = BondingDuration; + type MaxUnbondingDuration = MaxBondingDuration; type PlanningEraOffset = ConstU32<1>; type SlashDeferDuration = (); type MaxExposurePageSize = ConstU32<64>; @@ -502,11 +506,11 @@ pub mod pallet { #[pallet::storage] pub type ActiveEra = StorageValue<_, ActiveEraInfo>; - /// Custom bound for [`BondedEras`] which is equal to [`Config::BondingDuration`] + 1. + /// Custom bound for [`BondedEras`] which is equal to [`Config::MaxUnbondingDuration`] + 1. pub struct BondedErasBound(core::marker::PhantomData); impl Get for BondedErasBound { fn get() -> u32 { - T::BondingDuration::get().saturating_add(1) + T::MaxUnbondingDuration::get().saturating_add(1) } } @@ -733,7 +737,8 @@ pub mod pallet { /// This eliminates the need for expensive iteration and sorting when fetching the next offence /// to process. #[pallet::storage] - pub type OffenceQueueEras = StorageValue<_, WeakBoundedVec>; + pub type OffenceQueueEras = + StorageValue<_, WeakBoundedVec>; /// Tracks the currently processed offence record from the `OffenceQueue`. /// @@ -815,8 +820,29 @@ pub mod pallet { /// A bounded list of the "electable" stashes that resulted from a successful election. #[pallet::storage] - pub type ElectableStashes = - StorageValue<_, BoundedBTreeSet, ValueQuery>; + pub type ElectableStashes = StorageValue< + _, + BoundedBTreeMap, T::MaxValidatorSet>, + ValueQuery, + >; + + /// The total amount of stake backed by [UnbondingQueueParams.lowest_ratio] of + /// validators for the last [Config::MaxUnbondingDuration] eras. + /// + /// This is used to determine the maximum amount of stake that can be unbonded for a period + /// potentially lower than [Config::MaxUnbondingDuration]. + #[pallet::storage] + pub type ErasLowestRatioTotalStake = + StorageMap<_, Twox64Concat, EraIndex, BalanceOf, OptionQuery>; + + /// The amount of stake that started unbonding in a given era. + #[pallet::storage] + pub type ErasTotalUnbond = + StorageMap<_, Twox64Concat, EraIndex, BalanceOf, ValueQuery>; + + /// Parameters for the unbonding queue mechanism. + #[pallet::storage] + pub type UnbondingQueueParams = StorageValue<_, UnbondingQueueConfig, OptionQuery>; #[pallet::genesis_config] #[derive(frame_support::DefaultNoBound, frame_support::DebugNoBound)] @@ -833,13 +859,15 @@ pub mod pallet { pub max_nominator_count: Option, /// Create the given number of validators and nominators. /// - /// These account need not be in the endowment list of balances, and are auto-topped up + /// These accounts need not be in the endowment list of balances, and are auto-topped up /// here. /// /// Useful for testing genesis config. pub dev_stakers: Option<(u32, u32)>, /// initial active era, corresponding session index and start timestamp. pub active_era: (u32, u32, u64), + /// initial unbonding queue configuration. + pub unbonding_queue_config: Option, } impl GenesisConfig { @@ -892,6 +920,7 @@ pub mod pallet { if let Some(x) = self.max_nominator_count { MaxNominatorsCount::::put(x); } + UnbondingQueueParams::::set(self.unbonding_queue_config); // First pass: set up all validators and idle stakers for &(ref stash, balance, ref status) in &self.stakers { @@ -1351,10 +1380,10 @@ pub mod pallet { assert!(!MaxNominationsOf::::get().is_zero()); assert!( - T::SlashDeferDuration::get() < T::BondingDuration::get() || T::BondingDuration::get() == 0, + T::SlashDeferDuration::get() < T::MaxUnbondingDuration::get() || T::MaxUnbondingDuration::get() == 0, "As per documentation, slash defer duration ({}) should be less than bonding duration ({}).", T::SlashDeferDuration::get(), - T::BondingDuration::get(), + T::MaxUnbondingDuration::get(), ); } @@ -1509,23 +1538,31 @@ pub mod pallet { // Make sure that the user maintains enough active bond for their role. // If a user runs into this error, they should chill first. ensure!(ledger.active >= min_active_bond, Error::::InsufficientBond); - // Note: we used current era before, but that is meant to be used for only election. // The right value to use here is the active era. - let era = session_rotation::Rotator::::active_era() - .saturating_add(T::BondingDuration::get()); - if let Some(chunk) = ledger.unlocking.last_mut().filter(|chunk| chunk.era == era) { + let current_era = session_rotation::Rotator::::active_era(); + let previous_unbonded_stake = + session_rotation::Eras::::get_total_unbond_for_era(current_era); + + if let Some(chunk) = + ledger.unlocking.last_mut().filter(|chunk| chunk.era == current_era) + { // To keep the chunk count down, we only keep one chunk per era. Since // `unlocking` is a FiFo queue, if a chunk exists for `era` we know that it will // be the last one. - chunk.value = chunk.value.defensive_saturating_add(value) + chunk.value = chunk.value.defensive_saturating_add(value); + chunk.previous_unbonded_stake = previous_unbonded_stake; } else { ledger .unlocking - .try_push(UnlockChunk { value, era }) + .try_push(UnlockChunk { value, era: current_era, previous_unbonded_stake }) .map_err(|_| Error::::NoMoreChunks)?; }; + let new_total_unbond_in_era = + previous_unbonded_stake.defensive_saturating_add(value); + Eras::::set_total_unbond_for_era(current_era, new_total_unbond_in_era); + // NOTE: ledger must be updated prior to calling `Self::weight_of`. ledger.update()?; @@ -1548,10 +1585,10 @@ pub mod pallet { /// Remove any stake that has been fully unbonded and is ready for withdrawal. /// - /// Stake is considered fully unbonded once [`Config::BondingDuration`] has elapsed since - /// the unbonding was initiated. In rare cases—such as when offences for the unbonded era - /// have been reported but not yet processed—withdrawal is restricted to eras for which - /// all offences have been processed. + /// Stake is considered fully unbonded once [`Config::MaxUnbondingDuration`] has elapsed + /// since the unbonding was initiated. In rare cases—such as when offences for the + /// unbonded era have been reported but not yet processed—withdrawal is restricted to eras + /// for which all offences have been processed. /// /// The unlocked stake will be returned as free balance in the stash account. /// @@ -2121,6 +2158,7 @@ pub mod pallet { /// should be filled in order for the `chill_other` transaction to work. /// * `min_commission`: The minimum amount of commission that each validators must maintain. /// This is checked only upon calling `validate`. Existing validators are not affected. + /// * `unbonding_queue_params`: The parameters for the unbonding queue. /// /// RuntimeOrigin must be Root to call this function. /// @@ -2142,6 +2180,7 @@ pub mod pallet { chill_threshold: ConfigOp, min_commission: ConfigOp, max_staked_rewards: ConfigOp, + unbonding_queue_params: ConfigOp, ) -> DispatchResult { ensure_root(origin)?; @@ -2162,6 +2201,14 @@ pub mod pallet { config_op_exp!(ChillThreshold, chill_threshold); config_op_exp!(MinCommission, min_commission); config_op_exp!(MaxStakedRewards, max_staked_rewards); + config_op_exp!(UnbondingQueueParams, unbonding_queue_params); + + if let ConfigOp::Set(params) = unbonding_queue_params { + ensure!( + params.unbond_period_lower_bound <= T::MaxUnbondingDuration::get(), + Error::::BoundNotMet + ); + } Ok(()) } /// Declare a `controller` to stop participating as either a validator or nominator. @@ -2561,4 +2608,66 @@ pub mod pallet { Ok(Pays::No.into()) } } + + #[pallet::view_functions] + impl Pallet { + /// Returns an array of `(era, amount)` that represents: + /// + /// - The era where funds can be withdrawn. If the era is the same as the active one, they + /// can be immediately retrieved. + /// - The amount of funds that can be withdrawn in a given era. + /// + /// The duration in eras may vary based on the amount being unbonded and current queue + /// parameters. For instance, larger amounts may need to wait longer. + pub fn unbonding_duration(stash: T::AccountId) -> Vec<(EraIndex, BalanceOf)> { + let active_era = ActiveEra::::get().map(|a| a.index).unwrap_or_default(); + let earliest_considered_era = Self::calculate_earliest_withdrawal_era(active_era); + let ((current_era, free), chunks) = + Self::curate_unlocking_chunks(stash, earliest_considered_era); + let mut result = Vec::new(); + if !free.is_zero() { + result.push((current_era, free)); + } + let rest = chunks.into_iter().map(|(era, v)| { + let sum_stake = v.into_iter().map(|UnlockChunk { value, .. }| value).sum(); + (era, sum_stake) + }); + result.extend(rest); + result + } + + /// Returns the estimated duration, in eras, that would be required to unbond the given + /// amount of funds. + /// + /// The duration in eras may vary based on the amount being unbonded and current queue + /// parameters. For instance, larger amounts may need to wait longer due to the unbonding + /// queue mechanism. + /// + /// ## Parameters + /// + /// - `value`: The amount of funds to be unbonded. + /// + /// Returns the estimated number of eras until the funds are fully unbonded and available + /// for withdrawal. + pub fn estimate_unbonding_duration(value: BalanceOf) -> EraIndex { + let active_era = ActiveEra::::get().map(|a| a.index).unwrap_or_default(); + let earliest_considered_era = Self::calculate_earliest_withdrawal_era(active_era); + let previous_unbonded_stake = Eras::::get_total_unbond_for_era(active_era); + let chunks = vec![UnlockChunk { value, era: active_era, previous_unbonded_stake }]; + let (_, curated_chunks) = Self::curate_unlocking_chunks_inner( + active_era, + earliest_considered_era, + chunks.into_iter(), + ); + if let Some((era, _)) = curated_chunks.into_iter().next() { + era.defensive_saturating_sub(active_era) + } else { + if let Some(config) = UnbondingQueueParams::::get() { + config.unbond_period_lower_bound + } else { + T::MaxUnbondingDuration::get() + } + } + } + } } diff --git a/substrate/frame/staking-async/src/session_rotation.rs b/substrate/frame/staking-async/src/session_rotation.rs index 2e1e000e6cb87..6323c0e46f230 100644 --- a/substrate/frame/staking-async/src/session_rotation.rs +++ b/substrate/frame/staking-async/src/session_rotation.rs @@ -83,7 +83,7 @@ use frame_support::{ pallet_prelude::*, traits::{Defensive, DefensiveMax, DefensiveSaturating, OnUnbalanced, TryCollect}, }; -use sp_runtime::{Perbill, Percent, Saturating}; +use sp_runtime::{Perbill, Percent, SaturatedConversion, Saturating}; use sp_staking::{ currency_to_vote::CurrencyToVote, Exposure, Page, PagedExposureMetadata, SessionIndex, }; @@ -373,6 +373,34 @@ impl Eras { pub(crate) fn get_reward_points(era: EraIndex) -> EraRewardPoints { ErasRewardPoints::::get(era) } + + pub(crate) fn set_lowest_stake(era: EraIndex, total_stake: BalanceOf) { + ErasLowestRatioTotalStake::::set(era, Some(total_stake)); + } + + pub(crate) fn get_lowest_stake(era: EraIndex) -> BalanceOf { + ErasLowestRatioTotalStake::::get(era).unwrap_or(Zero::zero()) + } + + pub(crate) fn clean_up_lowest_stake(era: EraIndex) { + ErasLowestRatioTotalStake::::remove(era); + } + + pub(crate) fn get_total_unbond_for_era(era: EraIndex) -> BalanceOf { + ErasTotalUnbond::::get(era) + } + + pub(crate) fn set_total_unbond_for_era(era: EraIndex, value: BalanceOf) { + if value.is_zero() { + Self::remove_total_unbond_for_era(era); + } else { + ErasTotalUnbond::::insert(era, value); + } + } + + pub(crate) fn remove_total_unbond_for_era(era: EraIndex) { + ErasTotalUnbond::::remove(era); + } } #[cfg(any(feature = "try-runtime", test, feature = "runtime-benchmarks"))] @@ -491,7 +519,7 @@ impl Rotator { EraElectionPlanner::::do_elect_paged(p); } - crate::ElectableStashes::::take().into_iter().collect() + crate::ElectableStashes::::take().into_iter().map(|(s, _)| s).collect() } #[cfg(any(feature = "try-runtime", test))] @@ -508,7 +536,8 @@ impl Rotator { let bonded = BondedEras::::get(); ensure!( bonded.into_iter().map(|(era, _sess)| era).collect::>() == - (active.saturating_sub(T::BondingDuration::get())..=active).collect::>(), + (active.saturating_sub(T::MaxUnbondingDuration::get())..=active) + .collect::>(), "BondedEras range incorrect" ); @@ -696,7 +725,7 @@ impl Rotator { } fn start_era_update_bonded_eras(starting_era: EraIndex, start_session: SessionIndex) { - let bonding_duration = T::BondingDuration::get(); + let bonding_duration = T::MaxUnbondingDuration::get(); BondedEras::::mutate(|bonded| { if bonded.is_full() { @@ -808,6 +837,11 @@ impl Rotator { fn cleanup_old_era(starting_era: EraIndex) { EraElectionPlanner::::cleanup(); + if let Some(target_era) = starting_era.checked_sub(T::MaxUnbondingDuration::get()) { + Eras::::remove_total_unbond_for_era(target_era); + Eras::::clean_up_lowest_stake(target_era); + } + // discard the ancient era info. if let Some(old_era) = starting_era.checked_sub(T::HistoryDepth::get() + 1) { log!(debug, "Removing era information for {:?}", old_era); @@ -891,8 +925,9 @@ impl EraElectionPlanner { use pallet_staking_async_rc_client::RcClientInterface; let id = CurrentEra::::get().defensive_unwrap_or(0); let prune_up_to = Self::get_prune_up_to(); - let rc_validators = ElectableStashes::::take().into_iter().collect::>(); - + Pallet::::calculate_lowest_total_stake(id); + let rc_validators: Vec<_> = + ElectableStashes::::take().into_iter().map(|(s, _)| s).collect(); crate::log!( info, "Sending new validator set of size {:?} to RC. ID: {:?}, prune_up_to: {:?}", @@ -965,7 +1000,7 @@ impl EraElectionPlanner { ) -> Result { let planning_era = Rotator::::planned_era(); - match Self::add_electables(supports.iter().map(|(s, _)| s.clone())) { + match Self::add_electables(&supports) { Ok(added) => { let exposures = Self::collect_exposures(supports); let _ = Self::store_stakers_info(exposures, planning_era); @@ -1092,13 +1127,20 @@ impl EraElectionPlanner { /// `Ok(newly_added)` if all stashes were added successfully. /// `Err(first_un_included)` if some stashes cannot be added due to bounds. pub(crate) fn add_electables( - new_stashes: impl Iterator, + supports: &BoundedSupportsOf, ) -> Result { ElectableStashes::::mutate(|electable| { let pre_size = electable.len(); - for (idx, stash) in new_stashes.enumerate() { - if electable.try_insert(stash).is_err() { + for (idx, (stash, support)) in supports.0.iter().enumerate() { + let current = *electable.get(stash).unwrap_or(&Zero::zero()); + if electable + .try_insert( + stash.clone(), + current.saturating_add(support.total.saturated_into()), + ) + .is_err() + { return Err(idx); } } diff --git a/substrate/frame/staking-async/src/tests/bonding.rs b/substrate/frame/staking-async/src/tests/bonding.rs index 5da3e7d45fead..ef2e30728bf68 100644 --- a/substrate/frame/staking-async/src/tests/bonding.rs +++ b/substrate/frame/staking-async/src/tests/bonding.rs @@ -317,7 +317,8 @@ fn cannot_bond_extra_to_lower_than_ed() { active: 0, unlocking: bounded_vec![UnlockChunk { value: 11 * 1000, - era: active_era() + 3 + era: active_era(), + previous_unbonded_stake: 0 }], } ); @@ -358,7 +359,11 @@ fn unbonding_works() { stash: 11, total: 1000, active: 500, - unlocking: bounded_vec![UnlockChunk { value: 500, era: active_era() + 3 }], + unlocking: bounded_vec![UnlockChunk { + value: 500, + era: active_era(), + previous_unbonded_stake: 0 + }], }, ); @@ -373,7 +378,11 @@ fn unbonding_works() { stash: 11, total: 1000, active: 500, - unlocking: bounded_vec![UnlockChunk { value: 500, era: active_era() + 3 }], + unlocking: bounded_vec![UnlockChunk { + value: 500, + era: active_era(), + previous_unbonded_stake: 0 + }], }, ); @@ -388,7 +397,11 @@ fn unbonding_works() { stash: 11, total: 1000, active: 500, - unlocking: bounded_vec![UnlockChunk { value: 500, era: 1 + 3 }], + unlocking: bounded_vec![UnlockChunk { + value: 500, + era: 1, + previous_unbonded_stake: 0 + }], }, ); @@ -405,7 +418,11 @@ fn unbonding_works() { stash: 11, total: 1000, active: 500, - unlocking: bounded_vec![UnlockChunk { value: 500, era: 1 + 3 }], + unlocking: bounded_vec![UnlockChunk { + value: 500, + era: 1, + previous_unbonded_stake: 0 + }], }, ); @@ -454,7 +471,11 @@ fn unbonding_multi_chunk() { stash: 11, total: 1000, active: 500, - unlocking: bounded_vec![UnlockChunk { value: 500, era: active_era() + 3 }], + unlocking: bounded_vec![UnlockChunk { + value: 500, + era: active_era(), + previous_unbonded_stake: 0 + }], }, ); @@ -475,11 +496,12 @@ fn unbonding_multi_chunk() { total: 1000, active: 250, unlocking: bounded_vec![ - UnlockChunk { value: 500, era: 1 + 3 }, - UnlockChunk { value: 250, era: 2 + 3 } + UnlockChunk { value: 500, era: 1, previous_unbonded_stake: 0 }, + UnlockChunk { value: 250, era: 2, previous_unbonded_stake: 0 } ], }, ); + assert_eq!(Staking::unbonding_duration(11), vec![(1 + 3, 500), (2 + 3, 250)]); // when Session::roll_until_active_era(4); @@ -497,9 +519,14 @@ fn unbonding_multi_chunk() { stash: 11, total: 500, active: 250, - unlocking: bounded_vec![UnlockChunk { value: 250, era: 2 + 3 }], + unlocking: bounded_vec![UnlockChunk { + value: 250, + era: 2, + previous_unbonded_stake: 0 + }], }, ); + assert_eq!(Staking::unbonding_duration(11), vec![(2 + 3, 250)]); // when Session::roll_until_active_era(5); @@ -515,6 +542,7 @@ fn unbonding_multi_chunk() { Staking::ledger(11.into()).unwrap(), StakingLedgerInspect { stash: 11, total: 250, active: 250, unlocking: bounded_vec![] }, ); + assert_eq!(Staking::unbonding_duration(11), vec![]); }); } @@ -579,9 +607,14 @@ fn unbonding_merges_if_era_exists() { stash: 11, total: 1000, active: 500, - unlocking: bounded_vec![UnlockChunk { value: 500, era: 1 + 3 }], + unlocking: bounded_vec![UnlockChunk { + value: 500, + era: active_era(), + previous_unbonded_stake: 0 + }], }, ); + assert_eq!(Staking::unbonding_duration(11), vec![(1 + 3, 500)]); // when Staking::unbond(RuntimeOrigin::signed(11), 250).unwrap(); @@ -593,9 +626,14 @@ fn unbonding_merges_if_era_exists() { stash: 11, total: 1000, active: 250, - unlocking: bounded_vec![UnlockChunk { value: 500 + 250, era: 1 + 3 }], + unlocking: bounded_vec![UnlockChunk { + value: 500 + 250, + era: 1, + previous_unbonded_stake: 500 + }], }, ); + assert_eq!(Staking::unbonding_duration(11), vec![(1 + 3, 500 + 250)]); }); } @@ -631,12 +669,16 @@ fn unbonding_rejects_if_max_chunks() { total: 1000, active: 250, unlocking: bounded_vec![ - UnlockChunk { value: 250, era: 1 + 7 }, - UnlockChunk { value: 250, era: 2 + 7 }, - UnlockChunk { value: 250, era: 3 + 7 }, + UnlockChunk { value: 250, era: 1, previous_unbonded_stake: 0 }, + UnlockChunk { value: 250, era: 2, previous_unbonded_stake: 0 }, + UnlockChunk { value: 250, era: 3, previous_unbonded_stake: 0 }, ], }, ); + assert_eq!( + Staking::unbonding_duration(11), + vec![(1 + 7, 250), (2 + 7, 250), (3 + 7, 250),] + ); // when Session::roll_until_active_era(4); @@ -673,12 +715,16 @@ fn unbonding_auto_withdraws_if_any() { total: 1000, active: 250, unlocking: bounded_vec![ - UnlockChunk { value: 250, era: 1 + 3 }, - UnlockChunk { value: 250, era: 2 + 3 }, - UnlockChunk { value: 250, era: 3 + 3 }, + UnlockChunk { value: 250, era: 1, previous_unbonded_stake: 0 }, + UnlockChunk { value: 250, era: 2, previous_unbonded_stake: 0 }, + UnlockChunk { value: 250, era: 3, previous_unbonded_stake: 0 }, ], }, ); + assert_eq!( + Staking::unbonding_duration(11), + vec![(1 + 3, 250), (2 + 3, 250), (3 + 3, 250),] + ); // when Session::roll_until_active_era(4); @@ -691,12 +737,16 @@ fn unbonding_auto_withdraws_if_any() { total: 750, active: 150, unlocking: bounded_vec![ - UnlockChunk { value: 250, era: 2 + 3 }, - UnlockChunk { value: 250, era: 3 + 3 }, - UnlockChunk { value: 100, era: 4 + 3 }, + UnlockChunk { value: 250, era: 2, previous_unbonded_stake: 0 }, + UnlockChunk { value: 250, era: 3, previous_unbonded_stake: 0 }, + UnlockChunk { value: 100, era: 4, previous_unbonded_stake: 0 }, ], }, ); + assert_eq!( + Staking::unbonding_duration(11), + vec![(2 + 3, 250), (3 + 3, 250), (4 + 3, 100),] + ); }); } @@ -727,7 +777,11 @@ fn unbonding_caps_to_ledger_active() { stash: 11, total: 1000, active: 0, - unlocking: bounded_vec![UnlockChunk { value: 1000, era: 1 + 3 }], + unlocking: bounded_vec![UnlockChunk { + value: 1000, + era: 1, + previous_unbonded_stake: 0 + }], } ); }); @@ -761,9 +815,14 @@ fn unbond_avoids_dust() { stash: 11, total: 1000, active: 0, - unlocking: bounded_vec![UnlockChunk { value: 1000, era: 1 + 3 }], + unlocking: bounded_vec![UnlockChunk { + value: 1000, + era: 1, + previous_unbonded_stake: 0 + }], } ); + assert_eq!(Staking::unbonding_duration(11), vec![(1 + 3, 1000)]); }); } @@ -809,15 +868,15 @@ fn reducing_max_unlocking_chunks_abrupt() { // when staker unbonds assert_ok!(Staking::unbond(RuntimeOrigin::signed(3), 20)); - // then an unlocking chunk is added at `current_era + bonding_duration` - // => 10 + 3 = 13 + // then an unlocking chunk is added let expected_unlocking: BoundedVec, MaxUnlockingChunks> = - bounded_vec![UnlockChunk { value: 20 as Balance, era: 13 as EraIndex }]; + bounded_vec![UnlockChunk { value: 20 as Balance, era: 10, previous_unbonded_stake: 0 }]; assert!(matches!(Staking::ledger(3.into()), Ok(StakingLedger { unlocking, .. }) if unlocking == expected_unlocking)); + assert_eq!(Staking::unbonding_duration(3), vec![(10 + 3, 20)]); // when staker unbonds at next era Session::roll_until_active_era(11); @@ -825,13 +884,16 @@ fn reducing_max_unlocking_chunks_abrupt() { assert_ok!(Staking::unbond(RuntimeOrigin::signed(3), 50)); // then another unlock chunk is added - let expected_unlocking: BoundedVec, MaxUnlockingChunks> = - bounded_vec![UnlockChunk { value: 20, era: 13 }, UnlockChunk { value: 50, era: 14 }]; + let expected_unlocking: BoundedVec, MaxUnlockingChunks> = bounded_vec![ + UnlockChunk { value: 20, era: 10, previous_unbonded_stake: 0 }, + UnlockChunk { value: 50, era: 11, previous_unbonded_stake: 0 } + ]; assert!(matches!(Staking::ledger(3.into()), Ok(StakingLedger { unlocking, .. }) if unlocking == expected_unlocking)); + assert_eq!(Staking::unbonding_duration(3), vec![(10 + 3, 20), (11 + 3, 50)]); // when staker unbonds further Session::roll_until_active_era(12); @@ -930,9 +992,14 @@ fn bond_with_no_staked_value() { stash: 1, active: 0, total: 5, - unlocking: bounded_vec![UnlockChunk { value: 5, era: 4 }], + unlocking: bounded_vec![UnlockChunk { + value: 5, + era: 1, + previous_unbonded_stake: 0 + }], } ); + assert_eq!(Staking::unbonding_duration(1), vec![(1 + 3, 5)]); Session::roll_until_active_era(2); Session::roll_until_active_era(3); @@ -948,6 +1015,7 @@ fn bond_with_no_staked_value() { assert_ok!(Staking::withdraw_unbonded(RuntimeOrigin::signed(1), 0)); assert!(Staking::ledger(1.into()).is_err()); assert_eq!(pallet_balances::Holds::::get(&1).len(), 0); + assert_eq!(Staking::unbonding_duration(1), vec![]); }); } @@ -1105,9 +1173,14 @@ mod rebond { stash: 11, total: 1000, active: 100, - unlocking: bounded_vec![UnlockChunk { value: 900, era: 1 + 3 }], + unlocking: bounded_vec![UnlockChunk { + value: 900, + era: 1, + previous_unbonded_stake: 0 + }], } ); + assert_eq!(Staking::unbonding_duration(11), vec![(1 + 3, 900)]); // then rebond all the funds unbonded. Staking::rebond(RuntimeOrigin::signed(11), 900).unwrap(); @@ -1120,6 +1193,7 @@ mod rebond { unlocking: Default::default(), } ); + assert_eq!(Staking::unbonding_duration(11), vec![]); // Unbond almost all of the funds in stash. Staking::unbond(RuntimeOrigin::signed(11), 900).unwrap(); @@ -1129,9 +1203,14 @@ mod rebond { stash: 11, total: 1000, active: 100, - unlocking: bounded_vec![UnlockChunk { value: 900, era: 1 + 3 }], + unlocking: bounded_vec![UnlockChunk { + value: 900, + era: 1, + previous_unbonded_stake: 0 + }], } ); + assert_eq!(Staking::unbonding_duration(11), vec![(1 + 3, 900)]); // Re-bond part of the funds unbonded. Staking::rebond(RuntimeOrigin::signed(11), 500).unwrap(); @@ -1141,7 +1220,11 @@ mod rebond { stash: 11, total: 1000, active: 600, - unlocking: bounded_vec![UnlockChunk { value: 400, era: 1 + 3 }], + unlocking: bounded_vec![UnlockChunk { + value: 400, + era: 1, + previous_unbonded_stake: 0 + }], } ); @@ -1156,6 +1239,7 @@ mod rebond { unlocking: Default::default(), } ); + assert_eq!(Staking::unbonding_duration(11), vec![]); // Unbond parts of the funds in stash. Staking::unbond(RuntimeOrigin::signed(11), 300).unwrap(); @@ -1167,9 +1251,14 @@ mod rebond { stash: 11, total: 1000, active: 100, - unlocking: bounded_vec![UnlockChunk { value: 900, era: 1 + 3 }], + unlocking: bounded_vec![UnlockChunk { + value: 900, + era: 1, + previous_unbonded_stake: 600, + }], } ); + assert_eq!(Staking::unbonding_duration(11), vec![(1 + 3, 900)]); // Re-bond part of the funds unbonded. Staking::rebond(RuntimeOrigin::signed(11), 500).unwrap(); @@ -1179,9 +1268,14 @@ mod rebond { stash: 11, total: 1000, active: 600, - unlocking: bounded_vec![UnlockChunk { value: 400, era: 1 + 3 }], + unlocking: bounded_vec![UnlockChunk { + value: 400, + era: 1, + previous_unbonded_stake: 600, + }], } ); + assert_eq!(Staking::unbonding_duration(11), vec![(1 + 3, 400)]); }) } @@ -1207,9 +1301,14 @@ mod rebond { stash: 11, total: 1000, active: 600, - unlocking: bounded_vec![UnlockChunk { value: 400, era: 1 + 3 }], + unlocking: bounded_vec![UnlockChunk { + value: 400, + era: active_era(), + previous_unbonded_stake: 0 + }], } ); + assert_eq!(Staking::unbonding_duration(11), vec![(1 + 3, 400)]); Session::roll_until_active_era(2); @@ -1222,11 +1321,12 @@ mod rebond { total: 1000, active: 300, unlocking: bounded_vec![ - UnlockChunk { value: 400, era: 1 + 3 }, - UnlockChunk { value: 300, era: 2 + 3 }, + UnlockChunk { value: 400, era: 1, previous_unbonded_stake: 0 }, + UnlockChunk { value: 300, era: 2, previous_unbonded_stake: 0 }, ], } ); + assert_eq!(Staking::unbonding_duration(11), vec![(1 + 3, 400), (2 + 3, 300)]); Session::roll_until_active_era(3); @@ -1239,12 +1339,16 @@ mod rebond { total: 1000, active: 100, unlocking: bounded_vec![ - UnlockChunk { value: 400, era: 1 + 3 }, - UnlockChunk { value: 300, era: 2 + 3 }, - UnlockChunk { value: 200, era: 3 + 3 }, + UnlockChunk { value: 400, era: 1, previous_unbonded_stake: 0 }, + UnlockChunk { value: 300, era: 2, previous_unbonded_stake: 0 }, + UnlockChunk { value: 200, era: 3, previous_unbonded_stake: 0 }, ], } ); + assert_eq!( + Staking::unbonding_duration(11), + vec![(1 + 3, 400), (2 + 3, 300), (3 + 3, 200)] + ); // Re-bond half of the unbonding funds. Staking::rebond(RuntimeOrigin::signed(11), 400).unwrap(); @@ -1255,11 +1359,12 @@ mod rebond { total: 1000, active: 500, unlocking: bounded_vec![ - UnlockChunk { value: 400, era: 1 + 3 }, - UnlockChunk { value: 100, era: 2 + 3 }, + UnlockChunk { value: 400, era: 1, previous_unbonded_stake: 0 }, + UnlockChunk { value: 100, era: 2, previous_unbonded_stake: 0 }, ], } ); + assert_eq!(Staking::unbonding_duration(11), vec![(1 + 3, 400), (2 + 3, 100)]); }) } @@ -1279,9 +1384,14 @@ mod rebond { stash: 11, total: 1000, active: 100, - unlocking: bounded_vec![UnlockChunk { value: 900, era: 1 + 3 }], + unlocking: bounded_vec![UnlockChunk { + value: 900, + era: 1, + previous_unbonded_stake: 0 + }], } ); + assert_eq!(Staking::unbonding_duration(11), vec![(1 + 3, 900)]); assert_eq!( staking_events_since_last_call(), vec![Event::Unbonded { stash: 11, amount: 900 }] @@ -1295,9 +1405,14 @@ mod rebond { stash: 11, total: 1000, active: 200, - unlocking: bounded_vec![UnlockChunk { value: 800, era: 1 + 3 }], + unlocking: bounded_vec![UnlockChunk { + value: 800, + era: 1, + previous_unbonded_stake: 0 + }], } ); + assert_eq!(Staking::unbonding_duration(11), vec![(1 + 3, 800)]); assert_eq!( staking_events_since_last_call(), vec![Event::Bonded { stash: 11, amount: 100 }] @@ -1314,6 +1429,7 @@ mod rebond { unlocking: Default::default(), } ); + assert_eq!(Staking::unbonding_duration(11), vec![]); assert_eq!( staking_events_since_last_call(), vec![Event::Bonded { stash: 11, amount: 800 }] @@ -1348,9 +1464,14 @@ mod rebond { stash: 21, total: 11 * 1000, active: 0, - unlocking: bounded_vec![UnlockChunk { value: 11 * 1000, era: 4 }], + unlocking: bounded_vec![UnlockChunk { + value: 11 * 1000, + era: 1, + previous_unbonded_stake: 0 + }], } ); + assert_eq!(Staking::unbonding_duration(21), vec![(1 + 3, 11 * 1000)]); // now bond a wee bit more assert_noop!( @@ -1655,6 +1776,7 @@ mod staking_bounds_chill_other { ConfigOp::Remove, ConfigOp::Remove, ConfigOp::Remove, + ConfigOp::Remove, )); // Can't chill these users @@ -1677,6 +1799,7 @@ mod staking_bounds_chill_other { ConfigOp::Noop, ConfigOp::Noop, ConfigOp::Noop, + ConfigOp::Noop, )); // Still can't chill these users @@ -1699,6 +1822,7 @@ mod staking_bounds_chill_other { ConfigOp::Noop, ConfigOp::Noop, ConfigOp::Noop, + ConfigOp::Noop, )); // Still can't chill these users @@ -1721,6 +1845,7 @@ mod staking_bounds_chill_other { ConfigOp::Set(Percent::from_percent(75)), ConfigOp::Noop, ConfigOp::Noop, + ConfigOp::Noop, )); // Still can't chill these users @@ -1743,6 +1868,7 @@ mod staking_bounds_chill_other { ConfigOp::Remove, ConfigOp::Remove, ConfigOp::Remove, + ConfigOp::Remove, )); // Still can't chill these users @@ -1765,6 +1891,7 @@ mod staking_bounds_chill_other { ConfigOp::Set(Percent::from_percent(75)), ConfigOp::Noop, ConfigOp::Noop, + ConfigOp::Noop, )); // Still can't chill these users @@ -1787,6 +1914,7 @@ mod staking_bounds_chill_other { ConfigOp::Set(Percent::from_percent(75)), ConfigOp::Noop, ConfigOp::Noop, + ConfigOp::Noop, )); // Still can't chill these users @@ -1809,6 +1937,7 @@ mod staking_bounds_chill_other { ConfigOp::Set(Percent::from_percent(75)), ConfigOp::Noop, ConfigOp::Noop, + ConfigOp::Noop, )); // 16 people total because tests start with 2 active one @@ -1857,6 +1986,7 @@ mod staking_bounds_chill_other { ConfigOp::Remove, ConfigOp::Remove, ConfigOp::Noop, + ConfigOp::Noop, )); // can create `max - validator_count` validators @@ -1928,6 +2058,7 @@ mod staking_bounds_chill_other { ConfigOp::Noop, ConfigOp::Noop, ConfigOp::Noop, + ConfigOp::Noop, )); assert_ok!(Staking::nominate(RuntimeOrigin::signed(last_nominator), vec![11])); assert_ok!(Staking::validate( diff --git a/substrate/frame/staking-async/src/tests/configs.rs b/substrate/frame/staking-async/src/tests/configs.rs index 99f68b8e211f4..eb5368918365d 100644 --- a/substrate/frame/staking-async/src/tests/configs.rs +++ b/substrate/frame/staking-async/src/tests/configs.rs @@ -29,7 +29,12 @@ fn set_staking_configs_works() { ConfigOp::Set(20), ConfigOp::Set(Percent::from_percent(75)), ConfigOp::Set(Zero::zero()), - ConfigOp::Set(Zero::zero()) + ConfigOp::Set(Zero::zero()), + ConfigOp::Set(UnbondingQueueConfig { + min_slashable_share: Perbill::from_percent(50), + lowest_ratio: Perbill::from_percent(34), + unbond_period_lower_bound: 2, + }) )); assert_eq!(MinNominatorBond::::get(), 1_500); assert_eq!(MinValidatorBond::::get(), 2_000); @@ -38,6 +43,14 @@ fn set_staking_configs_works() { assert_eq!(ChillThreshold::::get(), Some(Percent::from_percent(75))); assert_eq!(MinCommission::::get(), Perbill::from_percent(0)); assert_eq!(MaxStakedRewards::::get(), Some(Percent::from_percent(0))); + assert_eq!( + UnbondingQueueParams::::get(), + Some(UnbondingQueueConfig { + min_slashable_share: Perbill::from_percent(50), + lowest_ratio: Perbill::from_percent(34), + unbond_period_lower_bound: 2, + }) + ); // noop does nothing assert_storage_noop!(assert_ok!(Staking::set_staking_configs( @@ -48,7 +61,8 @@ fn set_staking_configs_works() { ConfigOp::Noop, ConfigOp::Noop, ConfigOp::Noop, - ConfigOp::Noop + ConfigOp::Noop, + ConfigOp::Noop, ))); // removing works @@ -60,7 +74,8 @@ fn set_staking_configs_works() { ConfigOp::Remove, ConfigOp::Remove, ConfigOp::Remove, - ConfigOp::Remove + ConfigOp::Remove, + ConfigOp::Remove, )); assert_eq!(MinNominatorBond::::get(), 0); assert_eq!(MinValidatorBond::::get(), 0); diff --git a/substrate/frame/staking-async/src/tests/election_provider.rs b/substrate/frame/staking-async/src/tests/election_provider.rs index 51dbc4c2702f4..b03f6ef6a7442 100644 --- a/substrate/frame/staking-async/src/tests/election_provider.rs +++ b/substrate/frame/staking-async/src/tests/election_provider.rs @@ -483,17 +483,25 @@ mod electable_stashes { assert!(ElectableStashes::::get().is_empty()); // adds stashes without duplicates, do not overflow bounds. - assert_ok!(EraElectionPlanner::::add_electables(vec![1u64, 2, 3].into_iter())); + assert_ok!(EraElectionPlanner::::add_electables(&to_bounded_supports(vec![ + (1, Support { total: 100, voters: vec![] }), + (2, Support { total: 100, voters: vec![] }), + (3, Support { total: 100, voters: vec![] }), + ]))); assert_eq!( ElectableStashes::::get().into_inner().into_iter().collect::>(), - vec![1, 2, 3] + vec![(1, 100), (2, 100), (3, 100)] ); // adds with duplicates which are deduplicated implicitly, no not overflow bounds. - assert_ok!(EraElectionPlanner::::add_electables(vec![1u64, 2, 4].into_iter())); + assert_ok!(EraElectionPlanner::::add_electables(&to_bounded_supports(vec![ + (1, Support { total: 100, voters: vec![] }), + (2, Support { total: 100, voters: vec![] }), + (4, Support { total: 100, voters: vec![] }), + ]))); assert_eq!( ElectableStashes::::get().into_inner().into_iter().collect::>(), - vec![1, 2, 3, 4] + vec![(1, 200), (2, 200), (3, 100), (4, 100)] ); }) } @@ -510,14 +518,21 @@ mod electable_stashes { // included. let expected_idx_not_included = 5; // stash 6. assert_eq!( - EraElectionPlanner::::add_electables( - vec![1u64, 2, 3, 4, 5, 6, 7, 8].into_iter() - ), + EraElectionPlanner::::add_electables(&to_bounded_supports(vec![ + (1, Support { total: 100, voters: vec![] }), + (2, Support { total: 100, voters: vec![] }), + (3, Support { total: 100, voters: vec![] }), + (4, Support { total: 100, voters: vec![] }), + (5, Support { total: 100, voters: vec![] }), + (6, Support { total: 100, voters: vec![] }), + (7, Support { total: 100, voters: vec![] }), + (8, Support { total: 100, voters: vec![] }), + ])), Err(expected_idx_not_included) ); // the included were added to the electable stashes, despite the error. assert_eq!( - ElectableStashes::::get().into_inner().into_iter().collect::>(), + ElectableStashes::::get().into_inner().into_keys().collect::>(), vec![1, 2, 3, 4, 5] ); }) @@ -546,7 +561,10 @@ mod electable_stashes { ); // electable stashes have been collected to the max bounds despite the error. - assert_eq!(ElectableStashes::::get().into_iter().collect::>(), vec![1, 2]); + assert_eq!( + ElectableStashes::::get().into_iter().collect::>(), + vec![(1, 100), (2, 200)] + ); let exposure_exists = |acc, era| Eras::::get_full_exposure(era, &acc).total != 0; @@ -715,8 +733,8 @@ mod paged_on_initialize_era_election_planner { assert_eq!(NextElectionPage::::get(), Some(1)); assert_eq!(VoterSnapshotStatus::::get(), SnapshotStatus::Ongoing(31)); assert_eq!( - ElectableStashes::::get().into_iter().collect::>(), - vec![11, 21, 31] + ElectableStashes::::get().into_iter().collect::>(), + vec![(11, 1000), (21, 1000), (31, 500)] ); assert_eq_uvec!( @@ -733,7 +751,7 @@ mod paged_on_initialize_era_election_planner { // the electable stashes remain the same. assert_eq_uvec!( ElectableStashes::::get().into_iter().collect::>(), - vec![11, 21, 31, 61, 71, 81] + vec![(11, 1000), (21, 1000), (31, 500), (61, 1000), (71, 1000), (81, 1000)] ); assert_eq!(NextElectionPage::::get(), Some(0)); assert_eq!(VoterSnapshotStatus::::get(), SnapshotStatus::Ongoing(81)); diff --git a/substrate/frame/staking-async/src/tests/era_rotation.rs b/substrate/frame/staking-async/src/tests/era_rotation.rs index 84823d5236c3f..583fff7dda63e 100644 --- a/substrate/frame/staking-async/src/tests/era_rotation.rs +++ b/substrate/frame/staking-async/src/tests/era_rotation.rs @@ -356,7 +356,7 @@ fn progress_many_eras_with_try_state() { // a bit slow, but worthwhile ExtBuilder::default().build_and_execute(|| { Session::roll_until_active_era_with( - HistoryDepth::get().max(BondingDuration::get()) + 2, + HistoryDepth::get().max(MaxUnbondingDuration::get()) + 2, || { Staking::do_try_state(System::block_number()).unwrap(); }, @@ -431,6 +431,7 @@ mod inflation { ConfigOp::Noop, ConfigOp::Noop, ConfigOp::Set(Percent::from_percent(10)), + ConfigOp::Noop, )); assert_eq!(>::get(), Some(Percent::from_percent(10))); diff --git a/substrate/frame/staking-async/src/tests/ledger.rs b/substrate/frame/staking-async/src/tests/ledger.rs index f0b62e5d1f71d..b7e04d62e24b3 100644 --- a/substrate/frame/staking-async/src/tests/ledger.rs +++ b/substrate/frame/staking-async/src/tests/ledger.rs @@ -144,7 +144,8 @@ fn bond_works() { assert!(ledger.clone().bond(reward_dest).is_err()); // once bonded, unbonding (or any other update) works as expected. - ledger.unlocking = bounded_vec![UnlockChunk { era: 42, value: 42 }]; + ledger.unlocking = + bounded_vec![UnlockChunk { era: 42, value: 42, previous_unbonded_stake: 0 }]; ledger.active -= 42; assert_ok!(ledger.update()); }) diff --git a/substrate/frame/staking-async/src/tests/mod.rs b/substrate/frame/staking-async/src/tests/mod.rs index beac476b97a49..90e85f2c5405b 100644 --- a/substrate/frame/staking-async/src/tests/mod.rs +++ b/substrate/frame/staking-async/src/tests/mod.rs @@ -45,6 +45,7 @@ mod force_unstake_kill_stash; mod ledger; mod payout_stakers; mod slashing; +mod unbonding_queue; #[test] fn basic_setup_session_queuing_should_work() { @@ -443,7 +444,11 @@ mod staking_interface { stash: 11, total: 1000, active: 0, - unlocking: bounded_vec![UnlockChunk { value: 1000, era: 4 }], + unlocking: bounded_vec![UnlockChunk { + value: 1000, + era: active_era(), + previous_unbonded_stake: 0 + }], }, ); @@ -522,7 +527,11 @@ mod staking_unchecked { stash: 10, total: 1100, active: 1100 - 200, - unlocking: bounded_vec![UnlockChunk { value: 200, era: 1 + 3 }], + unlocking: bounded_vec![UnlockChunk { + value: 200, + era: active_era(), + previous_unbonded_stake: 0 + }], } ); diff --git a/substrate/frame/staking-async/src/tests/payout_stakers.rs b/substrate/frame/staking-async/src/tests/payout_stakers.rs index a2bf777144b89..f7f77460f7ca9 100644 --- a/substrate/frame/staking-async/src/tests/payout_stakers.rs +++ b/substrate/frame/staking-async/src/tests/payout_stakers.rs @@ -593,6 +593,7 @@ fn min_commission_works() { ConfigOp::Remove, ConfigOp::Set(Perbill::from_percent(10)), ConfigOp::Noop, + ConfigOp::Noop, )); // can't make it less than 10 now diff --git a/substrate/frame/staking-async/src/tests/slashing.rs b/substrate/frame/staking-async/src/tests/slashing.rs index db965c5a9bd39..ac597dab51347 100644 --- a/substrate/frame/staking-async/src/tests/slashing.rs +++ b/substrate/frame/staking-async/src/tests/slashing.rs @@ -400,7 +400,7 @@ fn deferred_slashes_are_deferred() { #[test] fn retroactive_deferred_slashes_two_eras_before() { ExtBuilder::default().slash_defer_duration(2).build_and_execute(|| { - assert_eq!(BondingDuration::get(), 3); + assert_eq!(MaxUnbondingDuration::get(), 3); assert_eq!(Nominators::::get(101).unwrap().targets, vec![11, 21]); Session::roll_until_active_era(2); @@ -454,7 +454,7 @@ fn retroactive_deferred_slashes_one_before() { .slash_defer_duration(2) .nominate(false) .build_and_execute(|| { - assert_eq!(BondingDuration::get(), 3); + assert_eq!(MaxUnbondingDuration::get(), 3); // unbond at slash era. Session::roll_until_active_era(2); @@ -908,7 +908,7 @@ fn garbage_collection_on_window_pruning() { assert!(ValidatorSlashInEra::::get(&now, &11).is_some()); // + 1 because we have to exit the bonding window. - for era in (0..(BondingDuration::get() + 1)).map(|offset| offset + now + 1) { + for era in (0..(MaxUnbondingDuration::get() + 1)).map(|offset| offset + now + 1) { assert!(ValidatorSlashInEra::::get(&now, &11).is_some()); Session::roll_until_active_era(era); } @@ -954,7 +954,11 @@ fn staker_cannot_bail_deferred_slash() { active: 0, total: 500, stash: 101, - unlocking: bounded_vec![UnlockChunk { era: 4u32, value: 500 }], + unlocking: bounded_vec![UnlockChunk { + era: active_era(), + value: 500, + previous_unbonded_stake: 0 + }], } ); @@ -1300,7 +1304,7 @@ fn cancel_all_slashes_with_100_percent() { #[test] fn proportional_slash_stop_slashing_if_remaining_zero() { ExtBuilder::default().nominate(true).build_and_execute(|| { - let c = |era, value| UnlockChunk:: { era, value }; + let c = |era, value| UnlockChunk:: { era, value, previous_unbonded_stake: 0 }; // we have some chunks, but they are not affected. let unlocking = bounded_vec![c(1, 10), c(2, 10)]; @@ -1310,7 +1314,7 @@ fn proportional_slash_stop_slashing_if_remaining_zero() { ledger.total = 40; ledger.unlocking = unlocking; - assert_eq!(BondingDuration::get(), 3); + assert_eq!(MaxUnbondingDuration::get(), 3); // should not slash more than the amount requested, by accidentally slashing the first // chunk. @@ -1321,10 +1325,10 @@ fn proportional_slash_stop_slashing_if_remaining_zero() { #[test] fn proportional_ledger_slash_works() { ExtBuilder::default().nominate(true).build_and_execute(|| { - let c = |era, value| UnlockChunk:: { era, value }; + let c = |era, value| UnlockChunk:: { era, value, previous_unbonded_stake: 0 }; // Given let mut ledger = StakingLedger::::new(123, 10); - assert_eq!(BondingDuration::get(), 3); + assert_eq!(MaxUnbondingDuration::get(), 3); // When we slash a ledger with no unlocking chunks assert_eq!(ledger.slash(5, 1, 0), 5); @@ -1601,9 +1605,8 @@ fn withdrawals_are_blocked_for_unprocessed_and_unapplied_slashes() { // Ensure unbonding chunks can all be withdrawn by era 6. let expected_chunks: BoundedVec, MaxUnlockingChunks> = bounded_vec![ - // era is unbond_era + bonding_duration, starting from era 2 + 3. - UnlockChunk { era: 5, value: 100 }, - UnlockChunk { era: 6, value: 150 }, + UnlockChunk { era: 2, value: 100, previous_unbonded_stake: 0 }, + UnlockChunk { era: 3, value: 150, previous_unbonded_stake: 0 }, ]; assert_eq!(Ledger::::get(nominator).unwrap().unlocking, expected_chunks); diff --git a/substrate/frame/staking-async/src/tests/unbonding_queue.rs b/substrate/frame/staking-async/src/tests/unbonding_queue.rs new file mode 100644 index 0000000000000..5b95af585f2e0 --- /dev/null +++ b/substrate/frame/staking-async/src/tests/unbonding_queue.rs @@ -0,0 +1,553 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::*; +use crate::{session_rotation::Eras, tests::Test, UnbondingQueueConfig, UnbondingQueueParams}; +use frame_support::traits::fungible::Inspect; +use sp_runtime::Perbill; +use std::collections::BTreeMap; + +#[test] +fn setting_invalid_min_unbond_period_fails() { + ExtBuilder::default().build_and_execute(|| { + assert_eq!(::MaxUnbondingDuration::get(), 3); + assert_noop!( + Staking::set_staking_configs( + RuntimeOrigin::root(), + ConfigOp::Noop, + ConfigOp::Noop, + ConfigOp::Noop, + ConfigOp::Noop, + ConfigOp::Noop, + ConfigOp::Noop, + ConfigOp::Noop, + ConfigOp::Set(UnbondingQueueConfig { + min_slashable_share: Perbill::from_percent(50), + lowest_ratio: Perbill::from_percent(34), + unbond_period_lower_bound: 4, + }) + ), + Error::::BoundNotMet + ); + }); +} + +#[test] +fn get_min_lowest_stake_works() { + ExtBuilder::default() + .set_stake(11, 10_000) + .set_stake(21, 11_000) + .set_stake(31, 400) + .validator_count(3) + .nominate(false) + .build_and_execute(|| { + assert_ok!(Staking::set_staking_configs( + RuntimeOrigin::root(), + ConfigOp::Noop, + ConfigOp::Noop, + ConfigOp::Noop, + ConfigOp::Noop, + ConfigOp::Noop, + ConfigOp::Noop, + ConfigOp::Noop, + ConfigOp::Set(UnbondingQueueConfig { + min_slashable_share: Perbill::from_percent(50), + lowest_ratio: Perbill::from_percent(34), + unbond_period_lower_bound: 2, + }) + )); + + // Initial conditions. + assert_eq!(Staking::current_era(), 1); + assert_eq!(ErasLowestRatioTotalStake::::iter().collect::>(), vec![]); + + // Setup to nominate. + assert_ok!(Staking::bond(RuntimeOrigin::signed(999), 100, RewardDestination::Stash)); + assert_ok!(Staking::nominate(RuntimeOrigin::signed(999), vec![31])); + + // Era 1 -> 2 + Session::roll_until_active_era(2); + assert_eq!( + ErasLowestRatioTotalStake::::iter().collect::>(), + BTreeMap::from([(2, 500)]) + ); + + // Era 2 -> 3 + assert_ok!(Staking::bond_extra(RuntimeOrigin::signed(999), 100)); + Session::roll_until_active_era(3); + assert_eq!( + ErasLowestRatioTotalStake::::iter().collect::>(), + BTreeMap::from([(2, 500), (3, 600)]) + ); + + // Era 3 -> 4 + assert_ok!(Staking::bond_extra(RuntimeOrigin::signed(999), 100)); + Session::roll_until_active_era(4); + assert_eq!( + ErasLowestRatioTotalStake::::iter().collect::>(), + BTreeMap::from([(2, 500), (3, 600), (4, 700)]) + ); + + // Era 4 -> 5 + assert_ok!(Staking::bond_extra(RuntimeOrigin::signed(999), 100)); + Session::roll_until_active_era(5); + assert_eq!( + ErasLowestRatioTotalStake::::iter().collect::>(), + BTreeMap::from([(3, 600), (4, 700), (5, 800)]) + ); + + // Era 5 -> 6 + assert_ok!(Staking::bond_extra(RuntimeOrigin::signed(999), 100)); + Session::roll_until_active_era(6); + assert_eq!( + ErasLowestRatioTotalStake::::iter().collect::>(), + BTreeMap::from([(4, 700), (5, 800), (6, 900)]) + ); + }); +} + +#[test] +fn calculate_lowest_total_stake_works() { + ExtBuilder::default() + .has_stakers(false) + .validator_count(4) + .has_unbonding_queue_config(true) + .build_and_execute(|| { + Session::roll_until_active_era(4); + assert_eq!(current_era(), 4); + // There are no stakers + assert_eq!( + ErasLowestRatioTotalStake::::iter().collect::>(), + BTreeMap::from([(2, 0), (3, 0), (4, 0)]) + ); + let ed = Balances::minimum_balance(); + + // Validator 1 + assert_ok!(Balances::force_set_balance(RuntimeOrigin::root(), 1, 1000 + ed)); + assert_ok!(Staking::bond(RuntimeOrigin::signed(1), 1000, RewardDestination::Stash)); + assert_ok!(Staking::validate(RuntimeOrigin::signed(1), ValidatorPrefs::default())); + + // Validator 2 + assert_ok!(Balances::force_set_balance(RuntimeOrigin::root(), 2, 2000 + ed)); + assert_ok!(Staking::bond(RuntimeOrigin::signed(2), 2000, RewardDestination::Stash)); + assert_ok!(Staking::validate(RuntimeOrigin::signed(2), ValidatorPrefs::default())); + + // Validator 3 + assert_ok!(Balances::force_set_balance(RuntimeOrigin::root(), 3, 3000 + ed)); + assert_ok!(Staking::bond(RuntimeOrigin::signed(3), 3000, RewardDestination::Stash)); + assert_ok!(Staking::validate(RuntimeOrigin::signed(3), ValidatorPrefs::default())); + + // Validator 4 + assert_ok!(Balances::force_set_balance(RuntimeOrigin::root(), 4, 4000 + ed)); + assert_ok!(Staking::bond(RuntimeOrigin::signed(4), 4000, RewardDestination::Stash)); + assert_ok!(Staking::validate(RuntimeOrigin::signed(4), ValidatorPrefs::default())); + + // Trigger new era to calculate the lowest proportion. + Session::roll_until_active_era(5); + assert_eq!( + ErasLowestRatioTotalStake::::iter().collect::>(), + BTreeMap::from([(3, 0), (4, 0), (5, 1000)]) + ); + + Session::roll_until_active_era(6); + assert_eq!( + ErasLowestRatioTotalStake::::iter().collect::>(), + BTreeMap::from([(4, 0), (5, 1000), (6, 1000)]) + ); + + // Ensure old entry is pruned after bonding duration (3 eras). + Session::roll_until_active_era(7); + assert_eq!( + ErasLowestRatioTotalStake::::iter().collect::>(), + BTreeMap::from([(5, 1000), (6, 1000), (7, 1000)]) + ); + }); +} + +#[test] +fn correct_unbond_era_is_being_calculated_without_config_set() { + ExtBuilder::default().build_and_execute(|| { + // Start at era 1 with known minimum lowest stake + assert_eq!(UnbondingQueueParams::::get(), None); + let current_era = Staking::current_era(); + assert_eq!(current_era, 1); + + // The first attempt before unbonding should yield no unbonds. + assert_eq!( + StakingLedger::::get(StakingAccount::Stash(11)) + .unwrap() + .unlocking + .into_inner(), + vec![] + ); + assert_eq!(Staking::unbonding_duration(11), vec![]); + + // First unbond + assert_eq!(Staking::estimate_unbonding_duration(10), 3); + assert_ok!(Staking::unbond(RuntimeOrigin::signed(11), 10)); + assert_eq!( + StakingLedger::::get(StakingAccount::Stash(11)) + .unwrap() + .unlocking + .into_inner(), + vec![UnlockChunk { value: 10, era: 1, previous_unbonded_stake: 0 }] + ); + assert_eq!(Staking::unbonding_duration(11), vec![(1 + 3, 10)]); + assert_eq!(Eras::::get_total_unbond_for_era(1), 10); + + // Second unbond + assert_ok!(Staking::unbond(RuntimeOrigin::signed(11), 10)); + assert_eq!(Staking::estimate_unbonding_duration(10), 3); + assert_eq!( + StakingLedger::::get(StakingAccount::Stash(11)) + .unwrap() + .unlocking + .into_inner(), + vec![UnlockChunk { value: 20, era: 1, previous_unbonded_stake: 10 }] + ); + assert_eq!(Staking::unbonding_duration(11), vec![(1 + 3, 20)]); + assert_eq!(Eras::::get_total_unbond_for_era(1), 20); + }); +} + +#[test] +fn rebonding_after_one_era_and_unbonding_should_place_the_new_unbond_era_in_the_queue() { + ExtBuilder::default().has_unbonding_queue_config(true).build_and_execute(|| { + // Start at era 10 with known minimum lowest stake + Session::roll_until_active_era(10); + let current_era = Staking::current_era(); + assert_eq!(current_era, 10); + + // First unbond + assert_eq!( + StakingLedger::::get(StakingAccount::Stash(11)) + .unwrap() + .unlocking + .into_inner(), + vec![] + ); + assert_eq!(Staking::unbonding_duration(11), vec![]); + assert_eq!(Eras::::get_total_unbond_for_era(2), 0); + + assert_eq!(Staking::estimate_unbonding_duration(10), 2); + assert_ok!(Staking::unbond(RuntimeOrigin::signed(11), 10)); + assert_eq!( + StakingLedger::::get(StakingAccount::Stash(11)) + .unwrap() + .unlocking + .into_inner(), + vec![UnlockChunk { value: 10, era: 10, previous_unbonded_stake: 0 }] + ); + assert_eq!(Staking::unbonding_duration(11), vec![(10 + 2, 10)]); + assert_eq!(Eras::::get_total_unbond_for_era(10), 10); + + // Second unbond + Session::roll_until_active_era(11); + assert_eq!(Staking::estimate_unbonding_duration(500), 2); + assert_ok!(Staking::unbond(RuntimeOrigin::signed(11), 500)); + assert_eq!( + StakingLedger::::get(StakingAccount::Stash(11)) + .unwrap() + .unlocking + .into_inner(), + vec![ + UnlockChunk { value: 10, era: 10, previous_unbonded_stake: 0 }, + UnlockChunk { value: 500, era: 11, previous_unbonded_stake: 0 } + ] + ); + assert_eq!(Staking::unbonding_duration(11), vec![(10 + 2, 10), (11 + 2, 500)]); + assert_eq!(Eras::::get_total_unbond_for_era(10), 10); + assert_eq!(Eras::::get_total_unbond_for_era(11), 500); + + assert_ok!(Staking::rebond(RuntimeOrigin::signed(11), 490)); + assert_eq!( + StakingLedger::::get(StakingAccount::Stash(11)) + .unwrap() + .unlocking + .into_inner(), + vec![ + UnlockChunk { value: 10, era: 10, previous_unbonded_stake: 0 }, + UnlockChunk { value: 10, era: 11, previous_unbonded_stake: 0 } + ] + ); + assert_eq!(Staking::unbonding_duration(11), vec![(10 + 2, 10), (11 + 2, 10)]); + assert_eq!(Eras::::get_total_unbond_for_era(10), 10); + assert_eq!(Eras::::get_total_unbond_for_era(11), 10); + + // Rebond so that the last chunk gets removed and part of the previous one gets subtracted. + assert_ok!(Staking::rebond(RuntimeOrigin::signed(11), 15)); + assert_eq!( + StakingLedger::::get(StakingAccount::Stash(11)) + .unwrap() + .unlocking + .into_inner(), + vec![UnlockChunk { value: 5, era: 10, previous_unbonded_stake: 0 }] + ); + assert_eq!(Staking::unbonding_duration(11), vec![(10 + 2, 5)]); + assert_eq!(Eras::::get_total_unbond_for_era(10), 5); + assert_eq!(Eras::::get_total_unbond_for_era(11), 0); + }); +} + +#[test] +fn test_withdrawing_with_favorable_global_stake_threshold_should_work() { + ExtBuilder::default().has_unbonding_queue_config(true).build_and_execute(|| { + Session::roll_until_active_era(10); + assert_eq!(Staking::current_era(), 10); + + assert_eq!( + StakingLedger::::get(StakingAccount::Stash(11)) + .unwrap() + .unlocking + .into_inner(), + vec![] + ); + assert_eq!(Staking::estimate_unbonding_duration(10), 2); + assert_ok!(Staking::unbond(RuntimeOrigin::signed(11), 10)); + assert_eq!( + StakingLedger::::get(StakingAccount::Stash(11)) + .unwrap() + .unlocking + .into_inner(), + vec![UnlockChunk { value: 10, era: 10, previous_unbonded_stake: 0 }] + ); + + // Should be able to withdraw after one era. + assert_eq!(Staking::unbonding_duration(11), vec![(10 + 2, 10)]); + assert_eq!(Eras::::get_total_unbond_for_era(10), 10); + + // Should not have withdrawn any funds yet. + assert_ok!(Staking::withdraw_unbonded(RuntimeOrigin::signed(11), 10)); + assert_eq!( + StakingLedger::::get(StakingAccount::Stash(11)) + .unwrap() + .unlocking + .into_inner(), + vec![UnlockChunk { value: 10, era: 10, previous_unbonded_stake: 0 }] + ); + + // After one era the user still not be able to withdraw + Session::roll_until_active_era(11); + assert_ok!(Staking::withdraw_unbonded(RuntimeOrigin::signed(11), 10)); + assert_eq!( + StakingLedger::::get(StakingAccount::Stash(11)) + .unwrap() + .unlocking + .into_inner(), + vec![UnlockChunk { value: 10, era: 10, previous_unbonded_stake: 0 }] + ); + assert_eq!(Staking::unbonding_duration(11), vec![(10 + 2, 10)]); + assert_eq!(Eras::::get_total_unbond_for_era(10), 10); + + // After two eras the user can withdraw. + Session::roll_until_active_era(12); + assert_eq!(Staking::unbonding_duration(11), vec![(10 + 2, 10)]); + assert_ok!(Staking::withdraw_unbonded(RuntimeOrigin::signed(11), 10)); + assert_eq!( + StakingLedger::::get(StakingAccount::Stash(11)) + .unwrap() + .unlocking + .into_inner(), + vec![] + ); + assert_eq!(Staking::unbonding_duration(11), vec![]); + // This must not change. It includes the stake to be withdrawn and one already withdrawn. + assert_eq!(Eras::::get_total_unbond_for_era(10), 10); + }); +} + +#[test] +fn test_withdrawing_over_global_stake_threshold_should_not_work() { + ExtBuilder::default().has_unbonding_queue_config(true).build_and_execute(|| { + Session::roll_until_active_era(10); + assert_eq!(Staking::current_era(), 10); + // Assume there was no previous stake in any era. + let _ = ErasLowestRatioTotalStake::::clear(u32::MAX, None); + + assert_eq!( + StakingLedger::::get(StakingAccount::Stash(11)) + .unwrap() + .unlocking + .into_inner(), + vec![] + ); + assert_eq!(Staking::estimate_unbonding_duration(10), 3); + assert_ok!(Staking::unbond(RuntimeOrigin::signed(11), 10)); + assert_eq!( + StakingLedger::::get(StakingAccount::Stash(11)) + .unwrap() + .unlocking + .into_inner(), + vec![UnlockChunk { value: 10, era: 10, previous_unbonded_stake: 0 }] + ); + assert_eq!(Staking::unbonding_duration(11), vec![(10 + 3, 10)]); + assert_eq!(Eras::::get_total_unbond_for_era(10), 10); + + // Should not have withdrawn any funds yet. + Session::roll_until_active_era(11); + assert_ok!(Staking::withdraw_unbonded(RuntimeOrigin::signed(11), 10)); + assert_eq!( + StakingLedger::::get(StakingAccount::Stash(11)) + .unwrap() + .unlocking + .into_inner(), + vec![UnlockChunk { value: 10, era: 10, previous_unbonded_stake: 0 }] + ); + assert_eq!(Staking::unbonding_duration(11), vec![(10 + 3, 10)]); + assert_eq!(Eras::::get_total_unbond_for_era(10), 10); + + // With the lowest stake set the user should be able to withdraw. + Session::roll_until_active_era(12); + assert_eq!(Staking::unbonding_duration(11), vec![(10 + 3, 10)]); + assert_ok!(Staking::withdraw_unbonded(RuntimeOrigin::signed(11), 10)); + assert_eq!( + StakingLedger::::get(StakingAccount::Stash(11)) + .unwrap() + .unlocking + .into_inner(), + vec![UnlockChunk { value: 10, era: 10, previous_unbonded_stake: 0 }] + ); + assert_eq!(Staking::unbonding_duration(11), vec![(10 + 3, 10)]); + assert_eq!(Eras::::get_total_unbond_for_era(10), 10); + + // After three eras the user is finally able to withdraw. + Session::roll_until_active_era(13); + assert_ok!(Staking::withdraw_unbonded(RuntimeOrigin::signed(11), 10)); + assert_eq!( + StakingLedger::::get(StakingAccount::Stash(11)) + .unwrap() + .unlocking + .into_inner(), + vec![] + ); + assert_eq!(Staking::unbonding_duration(11), vec![]); + assert_eq!(Eras::::get_total_unbond_for_era(10), 0); + }); +} + +#[test] +fn old_unbonding_chunks_should_be_withdrawable_in_current_era() { + ExtBuilder::default().has_unbonding_queue_config(true).build_and_execute(|| { + Session::roll_until_active_era(10); + assert_eq!(Staking::current_era(), 10); + + assert_eq!( + StakingLedger::::get(StakingAccount::Stash(11)) + .unwrap() + .unlocking + .into_inner(), + vec![] + ); + assert_eq!(Staking::unbonding_duration(11), vec![]); + assert_eq!(Eras::::get_total_unbond_for_era(10), 0); + + assert_eq!(Staking::estimate_unbonding_duration(10), 2); + assert_ok!(Staking::unbond(RuntimeOrigin::signed(11), 10)); + assert_eq!( + StakingLedger::::get(StakingAccount::Stash(11)) + .unwrap() + .unlocking + .into_inner(), + vec![UnlockChunk { value: 10, era: 10, previous_unbonded_stake: 0 }] + ); + assert_eq!(Staking::unbonding_duration(11), vec![(10 + 2, 10)]); + assert_eq!(Eras::::get_total_unbond_for_era(10), 10); + + Session::roll_until_active_era(11); + assert_eq!(Staking::estimate_unbonding_duration(10), 2); + assert_ok!(Staking::unbond(RuntimeOrigin::signed(11), 10)); + assert_eq!( + StakingLedger::::get(StakingAccount::Stash(11)) + .unwrap() + .unlocking + .into_inner(), + vec![ + UnlockChunk { value: 10, era: 10, previous_unbonded_stake: 0 }, + UnlockChunk { value: 10, era: 11, previous_unbonded_stake: 0 } + ] + ); + assert_eq!(Staking::unbonding_duration(11), vec![(10 + 2, 10), (11 + 2, 10)]); + assert_eq!(Eras::::get_total_unbond_for_era(10), 10); + assert_eq!(Eras::::get_total_unbond_for_era(11), 10); + + Session::roll_until_active_era(12); + assert_eq!(Staking::unbonding_duration(11), vec![(10 + 2, 10), (11 + 2, 10)]); + assert_eq!(Eras::::get_total_unbond_for_era(10), 10); + assert_eq!(Eras::::get_total_unbond_for_era(11), 10); + + // Now both unbonds get collapsed in a single entry. + Session::roll_until_active_era(13); + assert_eq!(Staking::unbonding_duration(11), vec![(13, 20)]); + assert_eq!(Eras::::get_total_unbond_for_era(10), 0); + assert_eq!(Eras::::get_total_unbond_for_era(11), 10); + }); +} + +#[test] +fn increasing_unbond_amount_should_delay_expected_withdrawal() { + ExtBuilder::default() + .has_stakers(false) + .has_unbonding_queue_config(true) + .build_and_execute(|| { + let ed = Balances::minimum_balance(); + + assert_ok!(Balances::force_set_balance(RuntimeOrigin::root(), 11, 10_000 + ed)); + assert_ok!(Staking::bond(RuntimeOrigin::signed(11), 10_000, RewardDestination::Stash)); + assert_ok!(Staking::validate(RuntimeOrigin::signed(11), ValidatorPrefs::default())); + + Session::roll_until_active_era(10); + assert_eq!(Staking::current_era(), 10); + + assert_eq!( + StakingLedger::::get(StakingAccount::Stash(11)) + .unwrap() + .unlocking + .into_inner(), + vec![] + ); + assert_eq!(Staking::unbonding_duration(11), vec![]); + assert_eq!(Eras::::get_total_unbond_for_era(10), 0); + + assert_eq!(Staking::estimate_unbonding_duration(10), 2); + assert_ok!(Staking::unbond(RuntimeOrigin::signed(11), 10)); + assert_eq!( + StakingLedger::::get(StakingAccount::Stash(11)) + .unwrap() + .unlocking + .into_inner(), + vec![UnlockChunk { value: 10, era: 10, previous_unbonded_stake: 0 }] + ); + assert_eq!(Staking::unbonding_duration(11), vec![(10 + 2, 10)]); + assert_eq!(Eras::::get_total_unbond_for_era(10), 10); + + // Unbond a huge amount of stake. + assert_eq!(Staking::estimate_unbonding_duration(10), 2); + assert_ok!(Staking::unbond(RuntimeOrigin::signed(11), 8000 - 10)); + assert_eq!(Staking::estimate_unbonding_duration(10), 3); + assert_eq!( + StakingLedger::::get(StakingAccount::Stash(11)) + .unwrap() + .unlocking + .into_inner(), + vec![UnlockChunk { value: 8000, era: 10, previous_unbonded_stake: 10 }] + ); + + // The expected release has been increased. + assert_eq!(Staking::unbonding_duration(11), vec![(10 + 3, 8000)]); + assert_eq!(Eras::::get_total_unbond_for_era(10), 8000); + }); +} diff --git a/substrate/frame/staking-async/src/weights.rs b/substrate/frame/staking-async/src/weights.rs index 1dd5385923104..435c6d259e537 100644 --- a/substrate/frame/staking-async/src/weights.rs +++ b/substrate/frame/staking-async/src/weights.rs @@ -90,6 +90,7 @@ pub trait WeightInfo { fn rc_on_offence(v: u32, ) -> Weight; fn rc_on_session_report() -> Weight; fn prune_era(v: u32) -> Weight; + fn migration_from_v17_to_v18_migrate_staking_ledger_step(c: u32,) -> Weight; } /// Weights for `pallet_staking_async` using the Substrate node and recommended hardware. @@ -890,6 +891,19 @@ impl WeightInfo for SubstrateWeight { .saturating_add(T::DbWeight::get().writes((4_u64).saturating_mul(v.into()))) .saturating_add(Weight::from_parts(0, 3937).saturating_mul(v.into())) } + + /// Storage: `Staking::Ledger` (r:2 w:1) + /// Proof: `Staking::Ledger` (`max_values`: None, `max_size`: Some(1265), added: 3740, mode: `MaxEncodedLen`) + /// The range of component `c` is `[1, 32]`. + fn migration_from_v17_to_v18_migrate_staking_ledger_step(_c: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `698 + c * (3 ±0)` + // Estimated: `8470` + // Minimum execution time: 45_000_000 picoseconds. + Weight::from_parts(60_791_228, 8470) + .saturating_add(RocksDbWeight::get().reads(2_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } } // For backwards compatibility and tests. @@ -1689,4 +1703,17 @@ impl WeightInfo for () { .saturating_add(RocksDbWeight::get().writes((4_u64).saturating_mul(v.into()))) .saturating_add(Weight::from_parts(0, 3937).saturating_mul(v.into())) } + + /// Storage: `Staking::Ledger` (r:2 w:1) + /// Proof: `Staking::Ledger` (`max_values`: None, `max_size`: Some(1265), added: 3740, mode: `MaxEncodedLen`) + /// The range of component `c` is `[1, 32]`. + fn migration_from_v17_to_v18_migrate_staking_ledger_step(_c: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `698 + c * (3 ±0)` + // Estimated: `8470` + // Minimum execution time: 45_000_000 picoseconds. + Weight::from_parts(60_791_228, 8470) + .saturating_add(RocksDbWeight::get().reads(2_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } } diff --git a/substrate/primitives/staking/src/lib.rs b/substrate/primitives/staking/src/lib.rs index 863e6cbe2b20f..57debedf9ff5b 100644 --- a/substrate/primitives/staking/src/lib.rs +++ b/substrate/primitives/staking/src/lib.rs @@ -197,7 +197,7 @@ pub trait StakingInterface { /// possible. fn stash_by_ctrl(controller: &Self::AccountId) -> Result; - /// Number of eras that staked funds must remain bonded for. + /// Maximum duration, in eras, that staked funds must remain bonded for. fn bonding_duration() -> EraIndex; /// The current era index.