Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
941fb48
only allow withdraw until the eras for which offences have been proce…
Ank4n Jul 2, 2025
857016b
cargo fmt
Ank4n Jul 2, 2025
34e5ca6
set active era instead of current
Ank4n Jul 3, 2025
737fe41
withdrawal blocking test
Ank4n Jul 3, 2025
5276b42
add test
Ank4n Jul 3, 2025
f69886c
minor fixes
Ank4n Jul 3, 2025
f9b3af9
make offence queue eras to be weak bounded vec as technically this ca…
Ank4n Jul 3, 2025
c65d61c
try state checks to ensure slash health
Ank4n Jul 3, 2025
ed04173
drop offences older than slashdeferduration - 1 old. 4 test failing
Ank4n Jul 3, 2025
5520308
test for discarding old offences
Ank4n Jul 3, 2025
899d8f9
fmt
Ank4n Jul 3, 2025
0bb1281
only ignore offence if slash deferral set
Ank4n Jul 3, 2025
1ff876a
almost there, need to block if unapplied slashes from last era.
Ank4n Jul 3, 2025
fe43897
finally everything works (fingers crossed); I am dead
Ank4n Jul 3, 2025
c0ae8aa
fmt
Ank4n Jul 3, 2025
fed49f3
Merge branch 'master' into ankn-prevent-withdraw-while-processing-off…
Ank4n Jul 3, 2025
822f6ab
fix ahm test
Ank4n Jul 4, 2025
6d174b6
fmt
Ank4n Jul 4, 2025
472433b
Merge branch 'master' into ankn-prevent-withdraw-while-processing-off…
Ank4n Jul 4, 2025
9208866
Update crate doc
Ank4n Jul 6, 2025
e824c10
update doc
Ank4n Jul 6, 2025
e2d3640
fmt
Ank4n Jul 6, 2025
12e0c43
minor edits
Ank4n Jul 6, 2025
60c247a
Merge branch 'master' into ankn-prevent-withdraw-while-processing-off…
Ank4n Jul 7, 2025
ed8bbd8
prdoc
Ank4n Jul 7, 2025
1a34989
fix pallet name in prdoc
Ank4n Jul 7, 2025
ca46e1a
remove rc client from prdoc
Ank4n Jul 7, 2025
c7c1346
Merge branch 'master' into ankn-prevent-withdraw-while-processing-off…
Ank4n Jul 10, 2025
2ed74ef
Merge branch 'master' into ankn-prevent-withdraw-while-processing-off…
Ank4n Jul 18, 2025
45f15d2
Merge branch 'master' into ankn-prevent-withdraw-while-processing-off…
Ank4n Jul 24, 2025
7361c29
fix kian feedback
Ank4n Jul 24, 2025
07a93cf
Merge branch 'master' into ankn-prevent-withdraw-while-processing-off…
Ank4n Jul 24, 2025
c25589d
Merge branch 'master' into ankn-prevent-withdraw-while-processing-off…
Ank4n Jul 28, 2025
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
30 changes: 30 additions & 0 deletions prdoc/pr_9079.prdoc
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion substrate/frame/staking-async/ahm-test/src/ah/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ pub(crate) fn roll_until_next_active(mut end_index: SessionIndex) -> Vec<Account
leftover: false,
// arbitrary, feel free to change if test setup updates
new_validator_set: vec![3, 5, 6, 8],
prune_up_to: None,
prune_up_to: active_era.checked_sub(BondingDuration::get()),
})
)
);
Expand Down
66 changes: 53 additions & 13 deletions substrate/frame/staking-async/ahm-test/src/ah/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -674,43 +674,83 @@ fn on_offence_previous_era() {
// flush the events.
let _ = staking_events_since_last_call();

// report an offence for the session belonging to the previous era
// GIVEN slash defer duration of 2 eras, active era = 3.
assert_eq!(SlashDeferredDuration::get(), 2);
assert_eq!(Rotator::<Runtime>::era_start_session_index(1), Some(5));
// 1 era is reserved for the application of slashes.
let oldest_reportable_era =
Rotator::<Runtime>::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::<Runtime>::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::<Runtime>::era_start_session_index(2), Some(10));
assert_ok!(rc_client::Pallet::<Runtime>::relay_new_offence(
RuntimeOrigin::root(),
// offence is in era 2
10,
vec![rc_client::Offence {
offender: 3,
reporters: vec![],
slash_fraction: Perbill::from_percent(50),
}]
));

// 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::<Runtime>::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
Expand Down
4 changes: 2 additions & 2 deletions substrate/frame/staking-async/src/benchmarking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ mod benchmarks {
let (_, controller) = create_stash_controller::<T>(0, 100, RewardDestination::Staked)?;
let amount = asset::existential_deposit::<T>() * 5u32.into(); // Half of total
Staking::<T>::unbond(RawOrigin::Signed(controller.clone()).into(), amount)?;
CurrentEra::<T>::put(EraIndex::max_value());
set_active_era::<T>(EraIndex::max_value());
let ledger = Ledger::<T>::get(&controller).ok_or("ledger not created before")?;
let original_total: BalanceOf<T> = ledger.total;
whitelist_account!(controller);
Expand Down Expand Up @@ -346,7 +346,7 @@ mod benchmarks {
let mut ledger = Ledger::<T>::get(&controller).unwrap();
ledger.active = ed - One::one();
Ledger::<T>::insert(&controller, ledger);
CurrentEra::<T>::put(EraIndex::max_value());
set_active_era::<T>(EraIndex::max_value());

whitelist_account!(controller);

Expand Down
131 changes: 129 additions & 2 deletions substrate/frame/staking-async/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,136 @@
//!
//! TODO
//!
//! ## Slashing of Validators and Exposures
//! ## Slashing Pipeline and Withdrawal Restrictions
Comment thread
Ank4n marked this conversation as resolved.
//!
//! 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<AccountId>, // 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"]
Expand Down
19 changes: 19 additions & 0 deletions substrate/frame/staking-async/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<T>::iter_prefix_values(era).count() as u32
}

pub(crate) fn era_unapplied_slash_count(era: EraIndex) -> u32 {
UnappliedSlashes::<T>::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::<T>::iter_prefix(era) {
assert_ok!(Staking::apply_slash(RuntimeOrigin::signed(1), era, key));
}
}
Loading
Loading