Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ impl pallet_staking_async::Config for Runtime {
type TargetList = UseValidatorsMap<Self>;
type MaxValidatorSet = MaxValidatorSet;
type NominationsQuota = pallet_staking_async::FixedNominationsQuota<{ MaxNominations::get() }>;
type NominationStalenessCurve = pallet_staking_async::NoNominationStaleness;
type MaxUnlockingChunks = frame_support::traits::ConstU32<32>;
type HistoryDepth = frame_support::traits::ConstU32<84>;
type MaxControllersInDeprecationBatch = MaxControllersInDeprecationBatch;
Expand Down
1 change: 1 addition & 0 deletions polkadot/runtime/test-runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ impl pallet_staking::Config for Runtime {
type VoterList = pallet_staking::UseNominatorsAndValidatorsMap<Runtime>;
type TargetList = pallet_staking::UseValidatorsMap<Runtime>;
type NominationsQuota = pallet_staking::FixedNominationsQuota<MAX_QUOTA_NOMINATIONS>;
type NominationStalenessCurve = pallet_staking::NoNominationStaleness;
type MaxUnlockingChunks = frame_support::traits::ConstU32<32>;
type MaxControllersInDeprecationBatch = ConstU32<5900>;
type HistoryDepth = frame_support::traits::ConstU32<84>;
Expand Down
1 change: 1 addition & 0 deletions polkadot/runtime/westend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,7 @@ impl pallet_staking::Config for Runtime {
type TargetList = UseValidatorsMap<Self>;
type MaxValidatorSet = MaxActiveValidators;
type NominationsQuota = pallet_staking::FixedNominationsQuota<{ MaxNominations::get() }>;
type NominationStalenessCurve = pallet_staking::NoNominationStaleness;
type MaxUnlockingChunks = frame_support::traits::ConstU32<32>;
type HistoryDepth = frame_support::traits::ConstU32<84>;
type MaxControllersInDeprecationBatch = MaxControllersInDeprecationBatch;
Expand Down
54 changes: 54 additions & 0 deletions prdoc/pr_11961.prdoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
title: 'Implement RFC #104: Stale Nomination Reward Curve'
doc:
- audience: Runtime Dev
description: |-
Implements [RFC #104](https://github.com/polkadot-fellows/RFCs/pull/104).

Adds a configurable decaying multiplier for stale nominations in NPoS staking.
A nominator's voter weight is scaled by a curve based on how long it has been
since they last called `nominate`. The multiplier is applied at the election
snapshot input only; reward distribution downstream is unchanged, so a stale
nominator's "lost" share of validator rewards naturally flows to that
validator's non-stale co-nominators through existing exposure-based payout
math.

The mechanism is applied to both `pallet-staking` and `pallet-staking-async`.
The shared trait and default impls live in `sp-staking`.

New API in `sp-staking`:
- `NominationStalenessCurve` trait.
- `NoNominationStaleness` no-op default.
- `LinearStalenessCurve<GracePeriod, DecayPeriod, Floor>` piecewise-linear default.

Each pallet adds:
- New `Config::NominationStalenessCurve` associated type. `TestDefaultConfig`
defaults to `NoNominationStaleness`, preserving pre-staleness behaviour.
- Multiplier applied at `get_npos_voters` using the existing `submitted_in`
field on `Nominations`.
- Migration helpers `migrations::nomination_staleness::reset_all_nomination_submitted_in`
and try-runtime helpers, ready to be wrapped in a versioned migration when
a runtime opts in. Storage version is intentionally not bumped.

Production runtimes (Westend, Asset Hub Westend, etc.) wire
`NoNominationStaleness` to preserve current behaviour. They can opt into
`LinearStalenessCurve<28, 140, 0>` (1 month grace, 5 month linear decay,
floor at zero, per the RFC's proposed defaults) once the RFC is approved.
crates:
- name: sp-staking
bump: minor
- name: pallet-staking
bump: major
- name: pallet-staking-async
bump: major
- name: kitchensink-runtime
bump: minor
- name: pallet-staking-async-rc-runtime
bump: minor
- name: polkadot-test-runtime
bump: minor
- name: westend-runtime
bump: minor
- name: pallet-staking-async-parachain-runtime
bump: minor
- name: asset-hub-westend-runtime
bump: minor
1 change: 1 addition & 0 deletions substrate/bin/node/runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,7 @@ impl pallet_staking::Config for Runtime {
type GenesisElectionProvider = onchain::OnChainExecution<OnChainSeqPhragmen>;
type VoterList = VoterList;
type NominationsQuota = pallet_staking::FixedNominationsQuota<MAX_QUOTA_NOMINATIONS>;
type NominationStalenessCurve = pallet_staking::NoNominationStaleness;
// This a placeholder, to be introduced in the next PR as an instance of bags-list
type TargetList = pallet_staking::UseValidatorsMap<Self>;
type MaxUnlockingChunks = ConstU32<32>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,7 @@ impl pallet_staking_async::Config for Runtime {
type MaxExposurePageSize = MaxExposurePageSize;
type MaxUnlockingChunks = ConstU32<16>;
type NominationsQuota = pallet_staking_async::FixedNominationsQuota<16>;
type NominationStalenessCurve = pallet_staking_async::NoNominationStaleness;

type VoterList = pallet_staking_async::UseNominatorsAndValidatorsMap<Self>;
type TargetList = pallet_staking_async::UseValidatorsMap<Self>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,7 @@ impl pallet_staking_async::Config for Runtime {
type TargetList = UseValidatorsMap<Self>;
type MaxValidatorSet = MaxValidatorSet;
type NominationsQuota = pallet_staking_async::FixedNominationsQuota<{ MaxNominations::get() }>;
type NominationStalenessCurve = pallet_staking_async::NoNominationStaleness;
type MaxUnlockingChunks = frame_support::traits::ConstU32<32>;
type HistoryDepth = ConstU32<1>;
type MaxControllersInDeprecationBatch = MaxControllersInDeprecationBatch;
Expand Down
1 change: 1 addition & 0 deletions substrate/frame/staking-async/runtimes/rc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -972,6 +972,7 @@ impl pallet_staking::Config for Runtime {
type TargetList = pallet_staking::UseValidatorsMap<Self>;
type MaxValidatorSet = MaxActiveValidators;
type NominationsQuota = pallet_staking::FixedNominationsQuota<{ MaxNominations::get() }>;
type NominationStalenessCurve = pallet_staking::NoNominationStaleness;
type MaxUnlockingChunks = frame_support::traits::ConstU32<32>;
type HistoryDepth = frame_support::traits::ConstU32<84>;
type MaxControllersInDeprecationBatch = MaxControllersInDeprecationBatch;
Expand Down
8 changes: 6 additions & 2 deletions substrate/frame/staking-async/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,12 @@ use sp_runtime::{
traits::{AtLeast32BitUnsigned, One, StaticLookup, UniqueSaturatedInto},
BoundedBTreeMap, Debug, Perbill, Saturating,
};
use sp_staking::{EraIndex, ExposurePage, PagedExposureMetadata, SessionIndex};
pub use sp_staking::{Exposure, IndividualExposure, StakerStatus};
use sp_staking::{
EraIndex, ExposurePage, NominationStalenessCurve, PagedExposureMetadata, SessionIndex,
};
pub use sp_staking::{
Exposure, IndividualExposure, LinearStalenessCurve, NoNominationStaleness, StakerStatus,
};
pub use weights::WeightInfo;

// public exports
Expand Down
81 changes: 80 additions & 1 deletion substrate/frame/staking-async/src/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@

//! Storage migrations for the staking-async pallet.

use crate::{log, reward::EraRewardManager, Config, DisableMintingGuard, RewardKind, RewardPot};
use crate::{
log, reward::EraRewardManager, Config, CurrentEra, DisableMintingGuard, Nominations,
Nominators, RewardKind, RewardPot,
};
use frame_support::{
pallet_prelude::*,
traits::{
Expand All @@ -30,6 +33,11 @@ use frame_support::{
use sp_runtime::{traits::AccountIdConversion, Saturating};
use sp_staking::EraIndex;

#[cfg(feature = "try-runtime")]
use alloc::vec::Vec;
#[cfg(feature = "try-runtime")]
use sp_runtime::TryRuntimeError;

/// One-shot migration relocating already-funded era pots after the seed-derivation
/// change (#11930) so existing rewards stay claimable. For runtimes that activated
/// DAP before the slot-based rotation of era pot accounts landed.
Expand Down Expand Up @@ -192,3 +200,74 @@ impl<T: Config, S: Get<PalletId>, K: Get<RewardKind>> MigrateEraPotsToPool<T, S,
}
}
}

/// Migration helpers for the nomination-staleness mechanism.
///
/// These are not yet wired into a versioned migration. When a runtime opts into a
/// non-trivial [`crate::Config::NominationStalenessCurve`], it should run
/// [`nomination_staleness::reset_all_nomination_submitted_in`] in a versioned
/// `OnRuntimeUpgrade` so that every existing nominator enters the new regime with a
/// full grace period.
///
/// Skipping this step would cause every existing nominator with an old `submitted_in`
/// value to be immediately exposed to the curve, which is almost certainly not the
/// intended behaviour.
pub mod nomination_staleness {
use super::*;

/// Reset every entry in `Nominators` to have `submitted_in` equal to the current
/// era. Returns the weight consumed.
///
/// Intended to be called from inside a versioned `on_runtime_upgrade` when the
/// nomination-staleness mechanism is first enabled on a runtime. See the module
/// docs for context.
pub fn reset_all_nomination_submitted_in<T: Config>() -> Weight {
let current_era = CurrentEra::<T>::get().unwrap_or(0);
let mut count: u64 = 0;

Nominators::<T>::translate::<Nominations<T>, _>(|_who, mut nomination| {
nomination.submitted_in = current_era;
count = count.saturating_add(1);
Some(nomination)
});

log!(
info,
"nomination-staleness init: reset submitted_in for {} nominators to era {}",
count,
current_era,
);

// One read for `CurrentEra`, plus one read+write per nominator.
T::DbWeight::get().reads_writes(count.saturating_add(1), count)
}

/// `try-runtime` helper for use in `pre_upgrade`. Encodes the current count of
/// nominators so that `post_upgrade` can verify nothing was added or dropped.
#[cfg(feature = "try-runtime")]
pub fn pre_upgrade_state<T: Config>() -> Vec<u8> {
(Nominators::<T>::iter().count() as u64).encode()
}

/// `try-runtime` helper for use in `post_upgrade`. Verifies that:
/// 1. The nominator count is unchanged across the migration.
/// 2. Every nominator's `submitted_in` was reset to the current era.
#[cfg(feature = "try-runtime")]
pub fn post_upgrade_check<T: Config>(pre_state: Vec<u8>) -> Result<(), TryRuntimeError> {
let pre_count = u64::decode(&mut pre_state.as_slice()).expect("encoded pre-upgrade count");
let post_count = Nominators::<T>::iter().count() as u64;
frame_support::ensure!(
pre_count == post_count,
"nomination-staleness init: nominator count changed across migration",
);

let current_era = CurrentEra::<T>::get().unwrap_or(0);
for (_, n) in Nominators::<T>::iter() {
frame_support::ensure!(
n.submitted_in == current_era,
"nomination-staleness init: submitted_in was not reset to the current era",
);
}
Ok(())
}
}
9 changes: 9 additions & 0 deletions substrate/frame/staking-async/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ parameter_types! {
pub static ElectionsBounds: ElectionBounds = ElectionBoundsBuilder::default().build();
pub static AbsoluteMaxNominations: u32 = 16;
pub static PlanningEraModeVal: PlanningEraMode = PlanningEraMode::Fixed(2);

// Staleness-curve parameters. Defaults are chosen so the curve is a no-op
// (`GracePeriod = u32::MAX`) and existing tests are unaffected. Tests that want
// to exercise the curve set these via `StalenessGracePeriod::set(...)` etc.
pub static StalenessGracePeriod: EraIndex = u32::MAX;
pub static StalenessDecayPeriod: EraIndex = 0;
pub static StalenessFloor: Perbill = Perbill::zero();
// Session configs
pub static SessionsPerEra: SessionIndex = 3;
pub static Period: BlockNumber = 5;
Expand Down Expand Up @@ -541,6 +548,8 @@ impl Config for Test {
type VoterList = VoterBagsList;
type TargetList = UseValidatorsMap<Self>;
type NominationsQuota = WeightedNominationsQuota<16>;
type NominationStalenessCurve =
crate::LinearStalenessCurve<StalenessGracePeriod, StalenessDecayPeriod, StalenessFloor>;
type MaxUnlockingChunks = MaxUnlockingChunks;
type HistoryDepth = HistoryDepth;
type BondingDuration = BondingDuration;
Expand Down
30 changes: 25 additions & 5 deletions substrate/frame/staking-async/src/pallet/impls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ use crate::{
session_rotation::{self, Eras, Rotator},
slashing::OffenceRecord,
weights::WeightInfo,
BalanceOf, Exposure, Forcing, LedgerIntegrityState, MaxNominationsOf, Nominations,
NominationsQuota, PositiveImbalanceOf, PotAccountProvider, RewardDestination, RewardKind,
RewardPot, SnapshotStatus, StakingLedger, ValidatorPrefs, STAKING_ID,
BalanceOf, Exposure, Forcing, LedgerIntegrityState, MaxNominationsOf, NominationStalenessCurve,
Nominations, NominationsQuota, PositiveImbalanceOf, PotAccountProvider, RewardDestination,
RewardKind, RewardPot, SnapshotStatus, StakingLedger, ValidatorPrefs, STAKING_ID,
};
use alloc::{boxed::Box, vec, vec::Vec};
use frame_election_provider_support::{
Expand Down Expand Up @@ -843,6 +843,7 @@ impl<T: Config> Pallet<T> {

// cache a few things.
let weight_of = Self::weight_of_fn();
let current_era = CurrentEra::<T>::get().unwrap_or(0);

let mut voters_seen = 0u32;
let mut validators_taken = 0u32;
Expand Down Expand Up @@ -870,19 +871,38 @@ impl<T: Config> Pallet<T> {
None => break,
};

let voter_weight = weight_of(&voter);
let mut voter_weight = weight_of(&voter);
// if voter weight is zero, do not consider this voter for the snapshot.
if voter_weight.is_zero() {
log!(debug, "voter's active balance is 0. skip this voter.");
continue;
}

if let Some(Nominations { targets, .. }) = <Nominators<T>>::get(&voter) {
if let Some(Nominations { targets, submitted_in, .. }) = <Nominators<T>>::get(&voter) {
if !targets.is_empty() {
// Note on lazy nomination quota: we do not check the nomination quota of the
// voter at this point and accept all the current nominations. The nomination
// quota is only enforced at `nominate` time.

// Apply the configured staleness curve to the voter's weight. A nominator
// who has not re-affirmed their nomination recently sees their effective
// stake reduced (or zeroed) for this election. With the default
// `NoNominationStaleness` curve, the multiplier is always `1` and this is
// a no-op (`Perbill::one() * x == x`).
let eras_since_last_nomination = current_era.saturating_sub(submitted_in);
let multiplier =
T::NominationStalenessCurve::multiplier(eras_since_last_nomination);
voter_weight = multiplier * voter_weight;
if voter_weight.is_zero() {
log!(
debug,
"voter {:?} weight is 0 after staleness multiplier ({} eras since last nomination). skip this voter.",
voter,
eras_since_last_nomination,
);
continue;
}

let voter = (voter, voter_weight, targets);
if voters_size_tracker.try_register_voter(&voter, &bounds).is_err() {
// no more space left for the election result, stop iterating.
Expand Down
16 changes: 13 additions & 3 deletions substrate/frame/staking-async/src/pallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@
use crate::{
asset, session_rotation::EraElectionPlanner, slashing, weights::WeightInfo, AccountIdLookupOf,
ActiveEraInfo, BalanceOf, EraPayout, EraRewardPoints, ExposurePage, Forcing,
LedgerIntegrityState, MaxNominationsOf, NegativeImbalanceOf, Nominations, NominationsQuota,
PositiveImbalanceOf, RewardDestination, StakingLedger, UnappliedSlash, UnlockChunk,
ValidatorPrefs,
LedgerIntegrityState, MaxNominationsOf, NegativeImbalanceOf, NominationStalenessCurve,
Nominations, NominationsQuota, PositiveImbalanceOf, RewardDestination, StakingLedger,
UnappliedSlash, UnlockChunk, ValidatorPrefs,
};
use alloc::{format, vec::Vec};
use codec::Codec;
Expand Down Expand Up @@ -178,6 +178,15 @@ pub mod pallet {
#[pallet::no_default_bounds]
type NominationsQuota: NominationsQuota<BalanceOf<Self>>;

/// Computes the staleness multiplier applied to a nominator's voter weight when the
/// election snapshot is built.
///
/// See [`NominationStalenessCurve`] for the semantics. Set to
/// [`crate::NoNominationStaleness`] to disable the staleness mechanism entirely
/// (this preserves the pre-staleness behaviour of the pallet).
#[pallet::no_default_bounds]
type NominationStalenessCurve: NominationStalenessCurve;

/// Number of eras to keep in history.
///
/// Following information is kept for eras in `[current_era -
Expand Down Expand Up @@ -457,6 +466,7 @@ pub mod pallet {
type CurrencyBalance = u128;
type CurrencyToVote = ();
type NominationsQuota = crate::FixedNominationsQuota<16>;
type NominationStalenessCurve = crate::NoNominationStaleness;
type HistoryDepth = ConstU32<84>;
type RewardRemainder = ();
type Slash = ();
Expand Down
1 change: 1 addition & 0 deletions substrate/frame/staking-async/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ mod era_rotation;
mod force_unstake_kill_stash;
mod ledger;
mod legacy_reward;
mod nomination_staleness;
mod nominators_no_slashing;
mod payout_stakers;
mod slashing;
Expand Down
Loading
Loading