diff --git a/prdoc/pr_9079.prdoc b/prdoc/pr_9079.prdoc new file mode 100644 index 0000000000000..c303ae09d54d2 --- /dev/null +++ b/prdoc/pr_9079.prdoc @@ -0,0 +1,30 @@ +title: "Prevent withdrawals while processing offences" + +doc: + - audience: Runtime Dev + description: | + Adds withdrawal restrictions to prevent users from withdrawing unbonded funds while + there are unprocessed offences that could result in slashing. This is a defensive + measure that ensures slashing guarantees are maintained even in extreme edge cases. + + Key changes: + - Withdrawals are blocked if there are unapplied slashes from the previous era + (returns `UnappliedSlashesInPreviousEra` error). This occurs when all unapplied + slashes for an era could not be applied within one era worth of blocks. While + one era is reserved for applying slashes page by page, if the era rolls over + before completion, these slashes can only be applied via the permissionless + `apply_slash` call. + - Withdrawals are restricted to the minimum of the active era and the last fully + processed offence era + - Unbonding chunks are now keyed by active era instead of current era + - Offences arriving after their intended application era are rejected and emit + `OffenceTooOld` event + + Both the `UnappliedSlashesInPreviousEra` error and withdrawal restrictions due to + delayed offence processing are extremely rare scenarios that should not occur under + normal operation. These are defensive measures to handle edge cases where slash + processing is delayed beyond expected timelines. + +crates: + - name: pallet-staking-async + bump: major \ No newline at end of file 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 aee4e304ef747..baeef5f1d4f72 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::era_start_session_index(1), Some(5)); + // 1 era is reserved for the application of slashes. + let oldest_reportable_era = + Rotator::::active_era() - (SlashDeferredDuration::get() - 1); + assert_eq!(oldest_reportable_era, 2); + // WHEN we report an offence older than Era 2 (oldest reportable era). assert_ok!(rc_client::Pallet::::relay_new_offence( RuntimeOrigin::root(), // offence is in era 1 5, + vec![rc_client::Offence { + offender: 3, + reporters: vec![], + slash_fraction: Perbill::from_percent(30), + }] + )); + + // THEN offence is ignored. + assert_eq!( + staking_events_since_last_call(), + vec![staking_async::Event::OffenceTooOld { + offence_era: 1, + validator: 3, + fraction: Perbill::from_percent(30) + }] + ); + + // WHEN: report an offence for the session belonging to the previous era + assert_eq!(Rotator::::era_start_session_index(2), Some(10)); + assert_ok!(rc_client::Pallet::::relay_new_offence( + RuntimeOrigin::root(), + // offence is in era 2 + 10, vec![rc_client::Offence { offender: 3, reporters: vec![], @@ -688,29 +717,40 @@ fn on_offence_previous_era() { }] )); - // reported + // THEN: offence is reported. assert_eq!( staking_events_since_last_call(), vec![staking_async::Event::OffenceReported { - offence_era: 1, + offence_era: 2, validator: 3, fraction: Perbill::from_percent(50) }] ); - // computed, and instantly applied, as we are already on era 3 (slash era = 1, defer = 2) + // computed in the next block (will be applied in era 4) roll_next(); assert_eq!( staking_events_since_last_call(), - vec![ - staking_async::Event::SlashComputed { - offence_era: 1, - slash_era: 3, - offender: 3, - page: 0 - }, - staking_async::Event::Slashed { staker: 3, amount: 50 } - ] + vec![staking_async::Event::SlashComputed { + offence_era: 2, + slash_era: 4, + offender: 3, + page: 0 + },] + ); + + // roll to the next era. + roll_until_next_active(15); + // ensure we are in era 4. + assert_eq!(Rotator::::active_era(), 4); + // clear staking events. + let _ = staking_events_since_last_call(); + + // the next block applies the slashes. + roll_next(); + assert_eq!( + staking_events_since_last_call(), + vec![staking_async::Event::Slashed { staker: 3, amount: 50 }] ); // nothing left diff --git a/substrate/frame/staking-async/src/benchmarking.rs b/substrate/frame/staking-async/src/benchmarking.rs index 2b741414fda5e..c8c024540d123 100644 --- a/substrate/frame/staking-async/src/benchmarking.rs +++ b/substrate/frame/staking-async/src/benchmarking.rs @@ -312,7 +312,7 @@ mod benchmarks { let (_, controller) = create_stash_controller::(0, 100, RewardDestination::Staked)?; let amount = asset::existential_deposit::() * 5u32.into(); // Half of total Staking::::unbond(RawOrigin::Signed(controller.clone()).into(), amount)?; - CurrentEra::::put(EraIndex::max_value()); + set_active_era::(EraIndex::max_value()); let ledger = Ledger::::get(&controller).ok_or("ledger not created before")?; let original_total: BalanceOf = ledger.total; whitelist_account!(controller); @@ -346,7 +346,7 @@ mod benchmarks { let mut ledger = Ledger::::get(&controller).unwrap(); ledger.active = ed - One::one(); Ledger::::insert(&controller, ledger); - CurrentEra::::put(EraIndex::max_value()); + set_active_era::(EraIndex::max_value()); whitelist_account!(controller); diff --git a/substrate/frame/staking-async/src/lib.rs b/substrate/frame/staking-async/src/lib.rs index 981acd6ba574d..571f6257c92ed 100644 --- a/substrate/frame/staking-async/src/lib.rs +++ b/substrate/frame/staking-async/src/lib.rs @@ -43,9 +43,136 @@ //! //! TODO //! -//! ## Slashing of Validators and Exposures +//! ## Slashing Pipeline and Withdrawal Restrictions //! -//! TODO +//! This pallet implements a robust slashing mechanism that ensures the integrity of the staking +//! system while preventing stakers from withdrawing funds that might still be subject to slashing. +//! +//! ### Overview of the Slashing Pipeline +//! +//! The slashing process consists of multiple phases: +//! +//! 1. **Offence Reporting**: Offences are reported from the relay chain through `on_new_offences` +//! 2. **Queuing**: Valid offences are added to the `OffenceQueue` for processing +//! 3. **Processing**: Offences are processed incrementally over multiple blocks +//! 4. **Application**: Slashes are either applied immediately or deferred based on configuration +//! +//! ### Phase 1: Offence Reporting +//! +//! Offences are reported from the relay chain (e.g., from BABE, GRANDPA, BEEFY, or parachain +//! modules) through the `on_new_offences` function: +//! +//! ```text +//! struct Offence { +//! offender: AccountId, // The validator being slashed +//! reporters: Vec, // Who reported the offence (may be empty) +//! slash_fraction: Perbill, // Percentage of stake to slash +//! } +//! ``` +//! +//! **Reporting Deadlines**: +//! - With deferred slashing: Offences must be reported within `SlashDeferDuration - 1` eras +//! - With immediate slashing: Offences can be reported up to `BondingDuration` eras old +//! +//! Example: If `SlashDeferDuration = 27` and current era is 100: +//! - Oldest reportable offence: Era 74 (100 - 26) +//! - Offences from era 73 or earlier are rejected +//! +//! ### Phase 2: Queuing +//! +//! When an offence passes validation, it's added to the queue: +//! +//! 1. **Storage**: Added to `OffenceQueue`: `(EraIndex, AccountId) -> OffenceRecord` +//! 2. **Era Tracking**: Era added to `OffenceQueueEras` (sorted vector of eras with offences) +//! 3. **Duplicate Handling**: If an offence already exists for the same validator in the same era, +//! only the higher slash fraction is kept +//! +//! ### Phase 3: Processing +//! +//! Offences are processed incrementally in `on_initialize` each block: +//! +//! ```text +//! 1. Load oldest offence from queue +//! 2. Move to `ProcessingOffence` storage +//! 3. For each exposure page (from last to first): +//! - Calculate slash for validator's own stake +//! - Calculate slash for each nominator (pro-rata based on exposure) +//! - Track total slash and reward amounts +//! 4. Once all pages processed, create `UnappliedSlash` +//! ``` +//! +//! **Key Features**: +//! - **Page-by-page processing**: Large validator sets don't overwhelm a single block +//! - **Pro-rata slashing**: Nominators slashed proportionally to their stake +//! - **Reward calculation**: A portion goes to reporters (if any) +//! +//! ### Phase 4: Application +//! +//! Based on `SlashDeferDuration`, slashes are either: +//! +//! **Immediate (SlashDeferDuration = 0)**: +//! - Applied right away in the same block +//! - Funds deducted from staking ledger immediately +//! +//! **Deferred (SlashDeferDuration > 0)**: +//! - Stored in `UnappliedSlashes` for future application +//! - Applied at era: `offence_era + SlashDeferDuration` +//! - Can be cancelled by governance before application +//! +//! ### Storage Items Involved +//! +//! - `OffenceQueue`: Pending offences to process +//! - `OffenceQueueEras`: Sorted list of eras with offences +//! - `ProcessingOffence`: Currently processing offence +//! - `ValidatorSlashInEra`: Tracks highest slash per validator per era +//! - `UnappliedSlashes`: Deferred slashes waiting for application +//! +//! ### Withdrawal Restrictions +//! +//! To maintain slashing guarantees, withdrawals are restricted: +//! +//! **Withdrawal Era Calculation**: +//! ```text +//! earliest_era_to_withdraw = min( +//! active_era, +//! last_fully_processed_offence_era + BondingDuration +//! ) +//! ``` +//! +//! **Example**: +//! - Active era: 100 +//! - Oldest unprocessed offence: Era 70 +//! - BondingDuration: 28 +//! - Withdrawal allowed only for chunks with era ≤ 97 (70 - 1 + 28) +//! +//! **Withdrawal Timeline Example with an Offence**: +//! ```text +//! Era: 90 91 92 93 94 95 96 97 98 99 100 ... 117 118 +//! | | | | | | | | | | | | | +//! Unbond: U +//! Offence: X +//! Reported: R +//! Processed: P (within next few blocks) +//! Slash Applied: S +//! Withdraw: ❌ ✓ +//! +//! With BondingDuration = 28 and SlashDeferDuration = 27: +//! - User unbonds in era 90 +//! - Offence occurs in era 90 +//! - Reported in era 92 (typically within 2 days, but reportable until Era 116) +//! - Processed in era 92 (within next few blocks after reporting) +//! - Slash deferred for 27 eras, applied at era 117 (90 + 27) +//! - Cannot withdraw unbonded chunks until era 118 (90 + 28) +//! +//! The 28-era bonding duration ensures that any offences committed before or during +//! unbonding have time to be reported, processed, and applied before funds can be +//! withdrawn. This provides a window for governance to cancel slashes that may have +//! resulted from software bugs. +//! ``` +//! +//! **Key Restrictions**: +//! 1. Cannot withdraw if previous era has unapplied slashes +//! 2. Cannot withdraw funds from eras with unprocessed offences #![cfg_attr(not(feature = "std"), no_std)] #![recursion_limit = "256"] diff --git a/substrate/frame/staking-async/src/mock.rs b/substrate/frame/staking-async/src/mock.rs index 279ca6eab8e16..875402d4dee09 100644 --- a/substrate/frame/staking-async/src/mock.rs +++ b/substrate/frame/staking-async/src/mock.rs @@ -1005,3 +1005,22 @@ pub(crate) fn restrict(who: &AccountId) { pub(crate) fn remove_from_restrict_list(who: &AccountId) { RestrictedAccounts::mutate(|l| l.retain(|x| x != who)); } + +pub(crate) fn era_unprocessed_offence_count(era: EraIndex) -> u32 { + OffenceQueue::::iter_prefix_values(era).count() as u32 +} + +pub(crate) fn era_unapplied_slash_count(era: EraIndex) -> u32 { + UnappliedSlashes::::iter_prefix_values(era).count() as u32 +} + +/// A pending slash from the previous era blocks withdrawal. Use this to apply them. +pub(crate) fn apply_pending_slashes_from_previous_era() { + apply_pending_slashes_from_era(active_era() - 1); +} + +pub(crate) fn apply_pending_slashes_from_era(era: EraIndex) { + for (key, _) in UnappliedSlashes::::iter_prefix(era) { + assert_ok!(Staking::apply_slash(RuntimeOrigin::signed(1), era, key)); + } +} diff --git a/substrate/frame/staking-async/src/pallet/impls.rs b/substrate/frame/staking-async/src/pallet/impls.rs index 54bdc8195f1a5..e9acf54bb2b59 100644 --- a/substrate/frame/staking-async/src/pallet/impls.rs +++ b/substrate/frame/staking-async/src/pallet/impls.rs @@ -42,6 +42,7 @@ use frame_support::{ OnUnbalanced, }, weights::Weight, + StorageDoubleMap, }; use frame_system::{pallet_prelude::BlockNumberFor, RawOrigin}; use pallet_staking_async_rc_client::{self as rc_client}; @@ -209,13 +210,60 @@ impl Pallet { Ok(()) } + /// 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 { + // get lowest era for which all offences are processed and withdrawals can be allowed. + let earliest_unlock_era_by_offence_queue = OffenceQueueEras::::get() + .as_ref() + .and_then(|eras| eras.first()) + .copied() + // if nothing in queue, use the active era. + .unwrap_or(active_era) + // 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 { let mut ledger = Self::ledger(Controller(controller.clone()))?; let (stash, old_total) = (ledger.stash.clone(), ledger.total); - if let Some(current_era) = CurrentEra::::get() { - ledger = ledger.consolidate_unlocked(current_era) + let active_era = Rotator::::active_era(); + + // Ensure last era slashes are applied. Else we block the withdrawals. + if active_era > 1 { + Self::ensure_era_slashes_applied(active_era.saturating_sub(1))?; } + + let earliest_era_to_withdraw = Self::calculate_earliest_withdrawal_era(active_era); + + log!( + debug, + "Withdrawing unbonded stake. Active_era is: {:?} | \ + Earliest era we can allow withdrawing: {:?}", + active_era, + earliest_era_to_withdraw + ); + + // withdraw unbonded balance from the ledger until earliest_era_to_withdraw. + ledger = ledger.consolidate_unlocked(earliest_era_to_withdraw); + let new_total = ledger.total; + debug_assert!( + new_total <= old_total, + "consolidate_unlocked should never increase the total balance of the ledger" + ); let used_weight = if ledger.unlocking.is_empty() && (ledger.active < Self::min_chilled_bond() || ledger.active.is_zero()) @@ -248,6 +296,14 @@ impl Pallet { Ok(used_weight) } + fn ensure_era_slashes_applied(era: EraIndex) -> Result<(), DispatchError> { + ensure!( + !UnappliedSlashes::::contains_prefix(era), + Error::::UnappliedSlashesInPreviousEra + ); + Ok(()) + } + pub(super) fn do_payout_stakers( validator_stash: T::AccountId, era: EraIndex, @@ -1089,6 +1145,16 @@ impl rc_client::AHStakingInterface for Pallet { Self::register_weight(consumed_weight); } + /// Accepts offences only if they are from era `active_era - (SlashDeferDuration - 1)` or newer. + /// + /// Slashes for offences are applied `SlashDeferDuration` eras after the offence occurred. + /// Accepting offences older than this range would not leave enough time for slashes to be + /// applied. + /// + /// Note: The validator set report that we send to the relay chain contains the pruning + /// information for a relay chain, but we conservatively keep some extra sessions, so it is + /// possible that an offence report is created for a session between SlashDeferDuration and + /// BondingDuration eras before the active era. But they will be dropped here. fn on_new_offences( slash_session: SessionIndex, offences: Vec>, @@ -1125,6 +1191,16 @@ 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()) + } else { + // slashes are deffered, so we only accept offences that are not older than the + // defferal duration. + active_era.index.saturating_sub(T::SlashDeferDuration::get().saturating_sub(1)) + }; + let invulnerables = Invulnerables::::get(); for o in offences { @@ -1136,6 +1212,17 @@ impl rc_client::AHStakingInterface for Pallet { continue } + // ignore offence if too old to report. + if offence_era < oldest_reportable_offence_era { + log!(warn, "🦹 on_new_offences: offence era {:?} too old; Can only accept offences from era {:?} or newer", offence_era, oldest_reportable_offence_era); + Self::deposit_event(Event::::OffenceTooOld { + validator: validator.clone(), + fraction: slash_fraction, + offence_era, + }); + // will emit an event for each validator in the report. + continue; + } let Some(exposure_overview) = >::get(&offence_era, &validator) else { // defensive: this implies offence is for a discarded era, and should already be @@ -1224,7 +1311,7 @@ impl rc_client::AHStakingInterface for Pallet { ) }); } else { - let mut eras = BoundedVec::default(); + let mut eras = WeakBoundedVec::default(); log!(debug, "🦹 inserting offence era {} into empty queue", offence_era); let _ = eras .try_push(offence_era) @@ -1682,6 +1769,7 @@ impl Pallet { Self::check_payees()?; Self::check_paged_exposures()?; Self::check_count()?; + Self::check_slash_health()?; Ok(()) } @@ -1915,6 +2003,65 @@ impl Pallet { .collect::>() } + /// Ensures offence pipeline and slashing is in a healthy state. + fn check_slash_health() -> Result<(), TryRuntimeError> { + // (1) Ensure offence queue is sorted + let offence_queue_eras = OffenceQueueEras::::get().unwrap_or_default().into_inner(); + let mut sorted_offence_queue_eras = offence_queue_eras.clone(); + sorted_offence_queue_eras.sort(); + ensure!( + sorted_offence_queue_eras == offence_queue_eras, + "Offence queue eras are not sorted" + ); + drop(sorted_offence_queue_eras); + + // (2) Ensure oldest offence queue era is old enough. + let active_era = Rotator::::active_era(); + let oldest_unprocessed_offence_era = + offence_queue_eras.first().cloned().unwrap_or(active_era); + + // how old is the oldest unprocessed offence era? + // given bonding duration = 28, the ideal value is between 0 and 2 eras. + // anything close to bonding duration is terrible. + let oldest_unprocessed_offence_age = + 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()) { + log!( + warn, + "Offence queue has unprocessed offences from older than 2 eras: oldest offence era in queue {:?} (active era: {:?})", + oldest_unprocessed_offence_era, + active_era + ); + } + + // error if the oldest unprocessed offence era closer to bonding duration. + ensure!( + oldest_unprocessed_offence_age < T::BondingDuration::get() - 1, + "offences from era less than 3 eras old from active era not processed yet" + ); + + // (3) Report count of offences in the queue. + for e in offence_queue_eras { + let count = OffenceQueue::::iter_prefix(e).count(); + ensure!(count > 0, "Offence queue is empty for era listed in offence queue eras"); + log!(info, "Offence queue for era {:?} has {:?} offences queued", e, count); + } + + // (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) { + // 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. + Self::ensure_era_slashes_applied(era)?; + } + + Ok(()) + } + fn ensure_ledger_role_and_min_bond(ctrl: &T::AccountId) -> Result<(), TryRuntimeError> { let ledger = Self::ledger(StakingAccount::Controller(ctrl.clone()))?; let stash = ledger.stash; diff --git a/substrate/frame/staking-async/src/pallet/mod.rs b/substrate/frame/staking-async/src/pallet/mod.rs index 8664f3faad774..aa59eff5ff548 100644 --- a/substrate/frame/staking-async/src/pallet/mod.rs +++ b/substrate/frame/staking-async/src/pallet/mod.rs @@ -733,7 +733,7 @@ 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<_, BoundedVec>; + pub type OffenceQueueEras = StorageValue<_, WeakBoundedVec>; /// Tracks the currently processed offence record from the `OffenceQueue`. /// @@ -1155,6 +1155,12 @@ pub mod pallet { /// Something occurred that should never happen under normal operation. /// Logged as an event for fail-safe observability. Unexpected(UnexpectedKind), + /// An offence was reported that was too old to be processed, and thus was dropped. + OffenceTooOld { + offence_era: EraIndex, + validator: T::AccountId, + fraction: Perbill, + }, } /// Represents unexpected or invariant-breaking conditions encountered during execution. @@ -1244,6 +1250,9 @@ pub mod pallet { /// Account is restricted from participation in staking. This may happen if the account is /// staking in another way already, such as via pool. Restricted, + /// Unapplied slashes in the recently concluded era is blocking this operation. + /// See `Call::apply_slash` to apply them. + UnappliedSlashesInPreviousEra, } impl Pallet { @@ -1414,8 +1423,8 @@ pub mod pallet { let unlocking = Self::ledger(Controller(controller.clone())).map(|l| l.unlocking.len())?; - // if there are no unlocking chunks available, try to withdraw chunks older than - // `BondingDuration` to proceed with the unbonding. + // if there are no unlocking chunks available, try to remove any chunks by withdrawing + // funds that have fully unbonded. let maybe_withdraw_weight = { if unlocking == T::MaxUnlockingChunks::get() as usize { Some(Self::do_withdraw_unbonded(&controller)?) @@ -1457,10 +1466,11 @@ pub mod pallet { // If a user runs into this error, they should chill first. ensure!(ledger.active >= min_active_bond, Error::::InsufficientBond); - // Note: in case there is no current era it is fine to bond one era more. - let era = CurrentEra::::get() - .unwrap_or(0) - .defensive_saturating_add(T::BondingDuration::get()); + // 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) { // 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 @@ -1492,10 +1502,14 @@ pub mod pallet { Ok(actual_weight.into()) } - /// Remove any unlocked chunks from the `unlocking` queue from our management. + /// Remove any stake that has been fully unbonded and is ready for withdrawal. /// - /// This essentially frees up that balance to be used by the stash account to do whatever - /// it wants. + /// 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. + /// + /// The unlocked stake will be returned as free balance in the stash account. /// /// The dispatch origin for this call must be _Signed_ by the controller. /// @@ -1505,8 +1519,8 @@ pub mod pallet { /// /// ## Parameters /// - /// - `num_slashing_spans`: **Deprecated**. This parameter is retained for backward - /// compatibility. It no longer has any effect. + /// - `num_slashing_spans`: **Deprecated**. Retained only for backward compatibility; this + /// parameter has no effect. #[pallet::call_index(3)] #[pallet::weight(T::WeightInfo::withdraw_unbonded_kill())] pub fn withdraw_unbonded( @@ -2440,11 +2454,21 @@ pub mod pallet { Ok(Pays::No.into()) } - /// Manually applies a deferred slash for a given era. + /// Manually and permissionlessly applies a deferred slash for a given era. /// /// Normally, slashes are automatically applied shortly after the start of the `slash_era`. - /// This function exists as a **fallback mechanism** in case slashes were not applied due to - /// unexpected reasons. It allows anyone to manually apply an unapplied slash. + /// The automatic application of slashes is handled by the pallet's internal logic, and it + /// tries to apply one slash page per block of the era. + /// If for some reason, one era is not enough for applying all slash pages, the remaining + /// slashes need to be manually (permissionlessly) applied. + /// + /// For a given era x, if at era x+1, slashes are still unapplied, all withdrawals get + /// blocked, and these need to be manually applied by calling this function. + /// This function exists as a **fallback mechanism** for this extreme situation, but we + /// never expect to encounter this in normal scenarios. + /// + /// The parameters for this call can be queried by looking at the `UnappliedSlashes` storage + /// for eras older than the active era. /// /// ## Parameters /// - `slash_era`: The staking era in which the slash was originally scheduled. @@ -2455,7 +2479,8 @@ pub mod pallet { /// /// ## Behavior /// - The function is **permissionless**—anyone can call it. - /// - The `slash_era` **must be the current era or a past era**. If it is in the future, the + /// - The `slash_era` **must be the current era or a past era**. + /// If it is in the future, the /// call fails with `EraNotStarted`. /// - The fee is waived if the slash is successfully applied. /// diff --git a/substrate/frame/staking-async/src/testing_utils.rs b/substrate/frame/staking-async/src/testing_utils.rs index a5ef580414aba..3028b3a4e55aa 100644 --- a/substrate/frame/staking-async/src/testing_utils.rs +++ b/substrate/frame/staking-async/src/testing_utils.rs @@ -259,3 +259,11 @@ pub fn migrate_to_old_currency(who: T::AccountId) { // replicate old behaviour of explicit increment of consumer. frame_system::Pallet::::inc_consumers(&who).expect("increment consumer failed"); } + +/// Set active era to the given era index. +pub fn set_active_era(era: EraIndex) { + // set the current era. + CurrentEra::::put(era); + // set the active era. + ActiveEra::::put(ActiveEraInfo { index: era, start: None }); +} diff --git a/substrate/frame/staking-async/src/tests/bonding.rs b/substrate/frame/staking-async/src/tests/bonding.rs index 28fc6c7229624..3f8b66ff3f774 100644 --- a/substrate/frame/staking-async/src/tests/bonding.rs +++ b/substrate/frame/staking-async/src/tests/bonding.rs @@ -15,6 +15,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Tests concerning bond, bond_extra, unbond, rebond, withdraw and chill for stakers. + use super::*; use frame_support::{hypothetically_ok, traits::Currency}; diff --git a/substrate/frame/staking-async/src/tests/slashing.rs b/substrate/frame/staking-async/src/tests/slashing.rs index c55004cc9c409..b90b74fb138a8 100644 --- a/substrate/frame/staking-async/src/tests/slashing.rs +++ b/substrate/frame/staking-async/src/tests/slashing.rs @@ -627,6 +627,92 @@ fn only_slash_validator_for_max_in_era() { }) } +#[test] +fn really_old_offences_are_ignored() { + ExtBuilder::default() + .slash_defer_duration(27) + .bonding_duration(28) + .build_and_execute(|| { + Session::roll_until_active_era(100); + + let expected_oldest_reportable_offence = active_era() - (SlashDeferDuration::get() - 1); + + assert_eq!(expected_oldest_reportable_offence, 74); + + // clear staking events until now + staking_events_since_last_call(); + + // WHEN: reporting offence for era 72 and 73, which are too old. + add_slash_in_era(11, 72, Perbill::from_percent(10)); + add_slash_in_era(21, 73, Perbill::from_percent(10)); + + // THEN: offence is ignored. + assert_eq!( + staking_events_since_last_call(), + vec![ + Event::OffenceTooOld { + offence_era: 72, + validator: 11, + fraction: Perbill::from_percent(10) + }, + Event::OffenceTooOld { + offence_era: 73, + validator: 21, + fraction: Perbill::from_percent(10) + }, + ] + ); + + // also check that the ignored offences are not stored anywhere + assert!(OffenceQueue::::iter_prefix(72).next().is_none()); + assert!(OffenceQueue::::iter_prefix(73).next().is_none()); + assert!(!OffenceQueueEras::::get().unwrap_or_default().contains(&72)); + assert!(!OffenceQueueEras::::get().unwrap_or_default().contains(&73)); + + // WHEN: reporting offence for era 74. + add_slash_in_era(11, 74, Perbill::from_percent(10)); + + // THEN: offence is reported. + assert_eq!( + staking_events_since_last_call(), + vec![Event::OffenceReported { + offence_era: 74, + validator: 11, + fraction: Perbill::from_percent(10) + }] + ); + + // AND: computed in the next block. + Session::roll_next(); + + assert_eq!( + staking_events_since_last_call(), + vec![Event::SlashComputed { + offence_era: 74, + slash_era: 101, + offender: 11, + page: 0 + },] + ); + + // Slash is applied at the start of the next era. + Session::roll_until_active_era(101); + // clear staking events until now + staking_events_since_last_call(); + + // this should apply the slash. + Session::roll_next(); + assert_eq!( + staking_events_since_last_call(), + vec![ + Event::Slashed { staker: 11, amount: 100 }, + // Nominator 101 is exposed to 11, so they are slashed too. + Event::Slashed { staker: 101, amount: 25 } + ] + ); + }); +} + #[test] fn nominator_is_slashed_by_max_for_validator_in_era() { ExtBuilder::default().build_and_execute(|| { @@ -1320,6 +1406,142 @@ fn proportional_ledger_slash_works() { }); } +#[test] +fn withdrawals_are_blocked_for_unprocessed_and_unapplied_slashes() { + ExtBuilder::default() + .slash_defer_duration(2) + .bonding_duration(3) + .add_staker(61, 1000, StakerStatus::Validator) + .add_staker(71, 1000, StakerStatus::Validator) + .add_staker(81, 1000, StakerStatus::Validator) + .add_staker(91, 1000, StakerStatus::Validator) + // we want to replicate a scenario where all offences could not be processed in 1 era, so we + // reduce the era length to 1 block. + .session_per_era(1) + .period(1) + .validator_count(6) + .build_and_execute(|| { + // NOTE for curious reader: Era change still takes 2 blocks... don't ask why ¯\_(ツ)_/¯ + let _expected_era_length = 2; + + // Set up nominator. + let validator = 11; + let nominator = 301; + bond_nominator(nominator, 500, vec![validator]); + + // create unbonding chunks for the next two eras. + Session::roll_until_active_era(2); + assert_ok!(Staking::unbond(RuntimeOrigin::signed(nominator), 100)); + Session::roll_until_active_era(3); + assert_ok!(Staking::unbond(RuntimeOrigin::signed(nominator), 150)); + + // Rationale: We want to simulate a backlog of offences from era 3 that remain + // unprocessed by the time unbonding becomes possible in era 6. + // + // Offences for era 3 must be reported no later than era 4, since slashing application + // starts in era 5. To achieve this, we flood era 3 with more than 4 offences, all + // reported just before the end of era 4. Given there are only 2 blocks per era + // (limiting processing throughput), this ensures not all offences will be processed by + // era 6 — blocking withdrawal as intended. + + // go to era 4. + Session::roll_until_active_era(4); + + // roll one block of 2 of era 4. + Session::roll_next(); + + // flood offence pipeline with offences for era 3. + // Note: our validator 11 is not slashed. + add_slash_in_era(21, 3, Perbill::from_percent(10)); + add_slash_in_era(61, 3, Perbill::from_percent(10)); + add_slash_in_era(71, 3, Perbill::from_percent(10)); + add_slash_in_era(81, 3, Perbill::from_percent(10)); + add_slash_in_era(91, 3, Perbill::from_percent(10)); + + // lets roll to era 6 where all unbonding chunks are available to withdraw. + Session::roll_until_active_era(6); + assert_eq!(active_era(), 6); + + // 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 }, + ]; + assert_eq!(Ledger::::get(nominator).unwrap().unlocking, expected_chunks); + + // and we created 5 offences, of which 3 would be processed in last block of era 4, and + // 2 blocks of era 5. + assert_eq!(era_unprocessed_offence_count(3), 5 - 3); + assert_eq!(OffenceQueueEras::::get().unwrap(), vec![3]); + + // all nominator balance other than ED is staked. + let nominator_balance_pre_withdraw = Balances::free_balance(&nominator); + assert_eq!(nominator_balance_pre_withdraw, 1); + + // Since the eras are too short, the offences that needed to be applied for last era 5 + // are still unapplied. This will block the withdrawal. + assert_eq!(era_unapplied_slash_count(5), 1); + + // WHEN: the nominator tries to withdraw unbonded funds while there are unapplied + // offence in the last era. + assert_noop!( + Staking::withdraw_unbonded(RuntimeOrigin::signed(nominator), 0), + Error::::UnappliedSlashesInPreviousEra + ); + + // let's clear the slashes by manually applying them. + apply_pending_slashes_from_previous_era(); + // ensure unapplied slashes are cleared. + assert_eq!(era_unapplied_slash_count(5), 0); + + // WHEN: the nominator tries to withdraw unbonded funds. + assert_ok!(Staking::withdraw_unbonded(RuntimeOrigin::signed(nominator), 0)); + + // THEN: only the first unbonding chunk is withdrawn, as the second one is blocked by + // unprocessed offences. + let nominator_balance_post_withdraw_1 = Balances::free_balance(&nominator); + // free balance increases by unlock chunk 1 value. + assert_eq!(nominator_balance_post_withdraw_1, nominator_balance_pre_withdraw + 100); + + // rolling a block creates another unapplied slash for era 3 as well as process a + // remaining offence. + Session::roll_next(); + assert_eq!(era_unapplied_slash_count(5), 1); + // clear the pending slashes. + apply_pending_slashes_from_previous_era(); + + // there is still one offence unprocessed for era 3. + assert_eq!(era_unprocessed_offence_count(3), 1); + + // withdrawals are still not possible for era (3 + 3 =) 6. + assert_ok!(Staking::withdraw_unbonded(RuntimeOrigin::signed(nominator), 0)); + assert_eq!(Balances::free_balance(&nominator), nominator_balance_post_withdraw_1); + + // WHEN: all offences are processed. + Session::roll_next(); + // Note that active_era has bumped to 7. + assert_eq!(active_era(), 7); + // The previous block created another unapplied slash for era 5, but we only block + // withdrawals upto 1 block (to give enough time for offchain actors to apply slashes + // manually). So, we dont need to apply pending slashes for era 5. + assert_eq!(era_unapplied_slash_count(5), 1); + // But era 6 (last era) has no unapplied slashes. + assert_eq!(era_unapplied_slash_count(6), 0); + // We also ensure all offences in the queue for era 3 are now processed. + assert_eq!(era_unprocessed_offence_count(3), 0); + assert_eq!(OffenceQueueEras::::get(), None); + + // Withdrawing for era 3 should be possible. + assert_ok!(Staking::withdraw_unbonded(RuntimeOrigin::signed(nominator), 0)); + assert_eq!(Balances::free_balance(&nominator), nominator_balance_post_withdraw_1 + 150); + + // Finally, we clear the unapplied slashes for era 5. Otherwise our try state checks + // will fail. (Try by commenting the next line :)) + apply_pending_slashes_from_era(5); + }); +} + mod paged_slashing { use super::*; use crate::slashing::OffenceRecord;