Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions prdoc/pr_8701.prdoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
title: '[Staking] Cleanups and some improvements'
doc:
- audience: Runtime Dev
description: |-
## Changes
- Introduced a new `min_bond` value, which is the minimum of `MinValidatorBond` and `MinNominatorBond`, with a fallback to `ExistentialDeposit`. Since ED on AH is much lower than on RC, this ensures we enforce some min bonds for staking to cover storage costs for staking ledger and related data.
- Added an upper bound on era duration, protecting against anomalous conditions that could otherwise lead to excessive inflation.
- Some refactors to gracefully handle unexpected validator activation in RC.


## TODO
- [ ] Set `MaxEraDuration` in WAH.
- [ ] Port [full unbond](https://github.com/paritytech/polkadot-sdk/pull/3811) (will do in a separate PR)
crates:
- name: pallet-staking-async
bump: major
- name: pallet-staking-async-parachain-runtime
bump: major
6 changes: 3 additions & 3 deletions substrate/frame/staking-async/ahm-test/src/ah/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,10 @@ pub fn roll_until_matches(criteria: impl Fn() -> bool, with_rc: bool) {
/// Use the given `end_index` as the first session report, and increment as per needed.
pub(crate) fn roll_until_next_active(mut end_index: SessionIndex) -> Vec<AccountId> {
// receive enough session reports, such that we plan a new era
let planned_era = pallet_staking_async::session_rotation::Rotator::<Runtime>::planning_era();
let planned_era = pallet_staking_async::session_rotation::Rotator::<Runtime>::planned_era();
let active_era = pallet_staking_async::session_rotation::Rotator::<Runtime>::active_era();

while pallet_staking_async::session_rotation::Rotator::<Runtime>::planning_era() == planned_era
{
while pallet_staking_async::session_rotation::Rotator::<Runtime>::planned_era() == planned_era {
let report = SessionReport {
end_index,
activation_timestamp: None,
Expand Down Expand Up @@ -342,6 +341,7 @@ impl pallet_staking_async::Config for Runtime {
type RewardRemainder = ();
type Slash = ();
type SlashDeferDuration = SlashDeferredDuration;
type MaxEraDuration = ();

type HistoryDepth = ConstU32<7>;
type MaxControllersInDeprecationBatch = ();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,10 @@ parameter_types! {
// of nominators.
pub const MaxControllersInDeprecationBatch: u32 = 751;
pub const MaxNominations: u32 = <NposCompactSolution16 as frame_election_provider_support::NposSolution>::LIMIT as u32;
// Note: In WAH, this should be set closer to the ideal era duration to trigger capping more
// frequently. On Kusama and Polkadot, a higher value like 7 × ideal_era_duration is more
// appropriate.
pub const MaxEraDuration: u64 = RelaySessionDuration::get() as u64 * RELAY_CHAIN_SLOT_DURATION_MILLIS as u64 * SessionsPerEra::get() as u64;
}

impl pallet_staking_async::Config for Runtime {
Expand Down Expand Up @@ -273,6 +277,7 @@ impl pallet_staking_async::Config for Runtime {
type EventListeners = (NominationPools, DelegatedStaking);
type WeightInfo = weights::pallet_staking_async::WeightInfo<Runtime>;
type MaxInvulnerables = frame_support::traits::ConstU32<20>;
type MaxEraDuration = MaxEraDuration;
type MaxDisabledValidators = ConstU32<100>;
type PlanningEraOffset =
pallet_staking_async::PlanningEraOffsetOf<Self, RelaySessionDuration, ConstU32<10>>;
Expand Down
10 changes: 5 additions & 5 deletions substrate/frame/staking-async/src/benchmarking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1024,14 +1024,14 @@ mod benchmarks {
let _new_validators = Rotator::<T>::legacy_insta_plan_era();
// activate the previous one
Rotator::<T>::start_era(
crate::ActiveEraInfo { index: Rotator::<T>::planning_era() - 1, start: Some(1) },
crate::ActiveEraInfo { index: Rotator::<T>::planned_era() - 1, start: Some(1) },
42, // start session index doesn't really matter,
2, // timestamp doesn't really matter
);

// ensure our offender has at least a full exposure page
let offender_exposure =
Eras::<T>::get_full_exposure(Rotator::<T>::planning_era(), &offender);
Eras::<T>::get_full_exposure(Rotator::<T>::planned_era(), &offender);
ensure!(
offender_exposure.others.len() as u32 == 2 * T::MaxExposurePageSize::get(),
"exposure not created"
Expand Down Expand Up @@ -1073,7 +1073,7 @@ mod benchmarks {
fn rc_on_offence(
v: Linear<2, { T::MaxValidatorSet::get() / 2 }>,
) -> Result<(), BenchmarkError> {
let initial_era = Rotator::<T>::planning_era();
let initial_era = Rotator::<T>::planned_era();
let _ = crate::testing_utils::create_validators_with_nominators_for_era::<T>(
2 * v,
// number of nominators is irrelevant here, so we hardcode these
Expand All @@ -1085,7 +1085,7 @@ mod benchmarks {

// plan new era
let new_validators = Rotator::<T>::legacy_insta_plan_era();
ensure!(Rotator::<T>::planning_era() == initial_era + 1, "era should be incremented");
ensure!(Rotator::<T>::planned_era() == initial_era + 1, "era should be incremented");
// activate the previous one
Rotator::<T>::start_era(
crate::ActiveEraInfo { index: initial_era, start: Some(1) },
Expand Down Expand Up @@ -1135,7 +1135,7 @@ mod benchmarks {

#[benchmark]
fn rc_on_session_report() -> Result<(), BenchmarkError> {
let initial_planned_era = Rotator::<T>::planning_era();
let initial_planned_era = Rotator::<T>::planned_era();
let initial_active_era = Rotator::<T>::active_era();

// create a small, arbitrary number of stakers. This is just for sanity of the era planning,
Expand Down
21 changes: 18 additions & 3 deletions substrate/frame/staking-async/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -312,16 +312,29 @@ pub mod session_mock {
if QueuedBufferSessions::get() == 0 {
// buffer time has passed
Active::set(q);
Rotator::<Test>::end_session(ending, Some((Timestamp::get(), id)));
<Staking as rc_client::AHStakingInterface>::on_relay_session_report(
rc_client::SessionReport::new_terminal(
ending,
// TODO: currently we use `Eras::reward_active_era()` to set validator
// points in our tests. We should improve this and find a good way to
// set this value instead.
vec![],
Some((Timestamp::get(), id)),
),
);
Queued::reset();
QueuedId::reset();
} else {
QueuedBufferSessions::mutate(|s| *s -= 1);
Rotator::<Test>::end_session(ending, None);
<Staking as rc_client::AHStakingInterface>::on_relay_session_report(
rc_client::SessionReport::new_terminal(ending, vec![], None),
);
}
} else {
// just end the session.
Rotator::<Test>::end_session(ending, None);
<Staking as rc_client::AHStakingInterface>::on_relay_session_report(
rc_client::SessionReport::new_terminal(ending, vec![], None),
);
}
CurrentIndex::set(ending + 1);
}
Expand Down Expand Up @@ -385,6 +398,7 @@ ord_parameter_types! {

parameter_types! {
pub static RemainderRatio: Perbill = Perbill::from_percent(50);
pub static MaxEraDuration: u64 = time_per_era() * 7;
}
pub struct OneTokenPerMillisecond;
impl EraPayout<Balance> for OneTokenPerMillisecond {
Expand Down Expand Up @@ -423,6 +437,7 @@ impl crate::pallet::pallet::Config for Test {
type EventListeners = EventListenerMock;
type MaxInvulnerables = ConstU32<20>;
type MaxDisabledValidators = ConstU32<100>;
type MaxEraDuration = MaxEraDuration;
type PlanningEraOffset = PlanningEraOffset;
type Filter = MockedRestrictList;
type RcClientInterface = session_mock::Session;
Expand Down
44 changes: 28 additions & 16 deletions substrate/frame/staking-async/src/pallet/impls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,18 @@ use sp_runtime::TryRuntimeError;
const NPOS_MAX_ITERATIONS_COEFFICIENT: u32 = 2;

impl<T: Config> Pallet<T> {
/// Returns the minimum required bond for participation, considering validators, nominators,
/// and the chain’s existential deposit.
///
/// This function computes the smallest allowed bond among `MinValidatorBond` and
/// `MinNominatorBond`, but ensures it is not below the existential deposit required to keep an
/// account alive.
pub(crate) fn min_bond() -> BalanceOf<T> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pub(crate) fn min_bond() -> BalanceOf<T> {
pub(crate) fn min_chilled_bond() -> BalanceOf<T> {

and then two more for

fn min_validator_bond
fn min_nominator_bond

?

Copy link
Copy Markdown
Contributor Author

@Ank4n Ank4n Jun 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merged but still pls check!

MinValidatorBond::<T>::get()
.min(MinNominatorBond::<T>::get())
.max(asset::existential_deposit::<T>())
}

/// Fetches the ledger associated with a controller or stash account, if any.
pub fn ledger(account: StakingAccount<T::AccountId>) -> Result<StakingLedger<T>, Error<T>> {
StakingLedger::<T>::get(account)
Expand Down Expand Up @@ -169,8 +181,8 @@ impl<T: Config> Pallet<T> {

ledger.total = ledger.total.checked_add(&extra).ok_or(ArithmeticError::Overflow)?;
ledger.active = ledger.active.checked_add(&extra).ok_or(ArithmeticError::Overflow)?;
// last check: the new active amount of ledger must be more than ED.
ensure!(ledger.active >= asset::existential_deposit::<T>(), Error::<T>::InsufficientBond);
// last check: the new active amount of ledger must be more than min bond.
ensure!(ledger.active >= Self::min_bond(), Error::<T>::InsufficientBond);

// NOTE: ledger must be updated prior to calling `Self::weight_of`.
ledger.update()?;
Expand All @@ -193,22 +205,22 @@ impl<T: Config> Pallet<T> {
}
let new_total = ledger.total;

let ed = asset::existential_deposit::<T>();
let used_weight =
if ledger.unlocking.is_empty() && (ledger.active < ed || ledger.active.is_zero()) {
// This account must have called `unbond()` with some value that caused the active
// portion to fall below existential deposit + will have no more unlocking chunks
// left. We can now safely remove all staking-related information.
Self::kill_stash(&ledger.stash)?;
let used_weight = if ledger.unlocking.is_empty() &&
(ledger.active < Self::min_bond() || ledger.active.is_zero())
{
// This account must have called `unbond()` with some value that caused the active
// portion to fall below existential deposit + will have no more unlocking chunks
// left. We can now safely remove all staking-related information.
Self::kill_stash(&ledger.stash)?;

T::WeightInfo::withdraw_unbonded_kill()
} else {
// This was the consequence of a partial unbond. just update the ledger and move on.
ledger.update()?;
T::WeightInfo::withdraw_unbonded_kill()
} else {
// This was the consequence of a partial unbond. just update the ledger and move on.
ledger.update()?;

// This is only an update, so we use less overall weight.
T::WeightInfo::withdraw_unbonded_update()
};
// This is only an update, so we use less overall weight.
T::WeightInfo::withdraw_unbonded_update()
};

// `old_total` should never be less than the new total because
// `consolidate_unlocked` strictly subtracts balance.
Expand Down
50 changes: 38 additions & 12 deletions substrate/frame/staking-async/src/pallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,17 @@ pub mod pallet {
#[pallet::constant]
type MaxDisabledValidators: Get<u32>;

/// Maximum allowed era duration in milliseconds.
///
/// This provides a defensive upper bound to cap the effective era duration, preventing
/// excessively long eras from causing runaway inflation (e.g., due to bugs). If the actual
/// era duration exceeds this value, it will be clamped to this maximum.
///
/// Example: For an ideal era duration of 24 hours (86,400,000 ms),
/// this can be set to 604,800,000 ms (7 days).
#[pallet::constant]
type MaxEraDuration: Get<u64>;

/// Interface to talk to the RC-Client pallet, possibly sending election results to the
/// relay chain.
#[pallet::no_default]
Expand Down Expand Up @@ -373,6 +384,7 @@ pub mod pallet {
type MaxControllersInDeprecationBatch = ConstU32<100>;
type MaxInvulnerables = ConstU32<20>;
type MaxDisabledValidators = ConstU32<100>;
type MaxEraDuration = ();
type EventListeners = ();
type Filter = Nothing;
type WeightInfo = ();
Expand Down Expand Up @@ -1103,6 +1115,22 @@ pub mod pallet {
active_era: EraIndex,
planned_era: EraIndex,
},
/// Something occurred that should never happen under normal operation.
/// Logged as an event for fail-safe observability.
Unexpected(UnexpectedKind),
}

/// Represents unexpected or invariant-breaking conditions encountered during execution.
///
/// These variants are emitted as [`Event::Unexpected`] and indicate a defensive check has
/// failed. While these should never occur under normal operation, they are useful for
/// diagnosing issues in production or test environments.
#[derive(Clone, Encode, Decode, DecodeWithMemTracking, PartialEq, TypeInfo, RuntimeDebug)]
pub enum UnexpectedKind {
/// Emitted when calculated era duration exceeds the configured maximum.
EraDurationBoundExceeded,
/// Received a validator activation event that is not recognized.
UnknownValidatorActivation,
}

#[pallet::error]
Expand Down Expand Up @@ -1279,8 +1307,8 @@ pub mod pallet {
return Err(Error::<T>::AlreadyPaired.into());
}

// Reject a bond which is considered to be _dust_.
if value < asset::existential_deposit::<T>() {
// Reject a bond which is lower than the minimum bond.
if value < Self::min_bond() {
return Err(Error::<T>::InsufficientBond.into());
}

Expand Down Expand Up @@ -1383,6 +1411,7 @@ pub mod pallet {
} else if Validators::<T>::contains_key(&stash) {
MinValidatorBond::<T>::get()
} else {
// staker is chilled, no min bond.
Zero::zero()
};

Expand Down Expand Up @@ -1859,11 +1888,8 @@ pub mod pallet {

let initial_unlocking = ledger.unlocking.len() as u32;
let (ledger, rebonded_value) = ledger.rebond(value);
// Last check: the new active amount of ledger must be more than ED.
ensure!(
ledger.active >= asset::existential_deposit::<T>(),
Error::<T>::InsufficientBond
);
// Last check: the new active amount of ledger must be more than min bond.
ensure!(ledger.active >= Self::min_bond(), Error::<T>::InsufficientBond);

Self::deposit_event(Event::<T>::Bonded {
stash: ledger.stash.clone(),
Expand All @@ -1888,8 +1914,8 @@ pub mod pallet {
/// Remove all data structures concerning a staker/stash once it is at a state where it can
/// be considered `dust` in the staking system. The requirements are:
///
/// 1. the `total_balance` of the stash is below existential deposit.
/// 2. or, the `ledger.total` of the stash is below existential deposit.
/// 1. the `total_balance` of the stash is below minimum bond.
/// 2. or, the `ledger.total` of the stash is below minimum bond.
/// 3. or, existential deposit is zero and either `total_balance` or `ledger.total` is zero.
///
/// The former can happen in cases like a slash; the latter when a fully unbonded account
Expand All @@ -1916,13 +1942,13 @@ pub mod pallet {
// virtual stakers should not be allowed to be reaped.
ensure!(!Self::is_virtual_staker(&stash), Error::<T>::VirtualStakerNotAllowed);

let ed = asset::existential_deposit::<T>();
let min_bond = Self::min_bond();
let origin_balance = asset::total_balance::<T>(&stash);
let ledger_total =
Self::ledger(Stash(stash.clone())).map(|l| l.total).unwrap_or_default();
let reapable = origin_balance < ed ||
let reapable = origin_balance < min_bond ||
origin_balance.is_zero() ||
ledger_total < ed ||
ledger_total < min_bond ||
ledger_total.is_zero();
ensure!(reapable, Error::<T>::FundedTarget);

Expand Down
Loading