Skip to content

Commit

Permalink
[Staking] Bounded Slashing: Paginated Offence Processing & Slash Appl…
Browse files Browse the repository at this point in the history
…ication (paritytech#7424)

closes paritytech#3610.

helps paritytech#6344, but need
to migrate storage `Offences::Reports` before we can remove exposure
dependency in RC pallets.

replaces paritytech#6788.

## Context  
Slashing in staking is unbounded currently, which is a major blocker
until staking can move to a parachain (AH).

### Current Slashing Process (Unbounded)  

1. **Offence Reported**  
- Offences include multiple validators, each with potentially large
exposure pages.
- Slashes are **computed immediately** and scheduled for application
after **28 eras**.

2. **Slash Applied**  
- All unapplied slashes are executed in **one block** at the start of
the **28th era**. This is an **unbounded operation**.


### Proposed Slashing Process (Bounded)  

1. **Offence Queueing**  
   - Offences are **queued** after basic sanity checks.  

2. **Paged Offence Processing (Computing Slash)**  
   - Slashes are **computed one validator exposure page at a time**.  
   - **Unapplied slashes** are stored in a **double map**:  
     - **Key 1 (k1):** `EraIndex`  
- **Key 2 (k2):** `(Validator, SlashFraction, PageIndex)` — a unique
identifier for each slash page

3. **Paged Slash Application**  
- Slashes are **applied one page at a time** across multiple blocks.
- Slash application starts at the **27th era** (one era earlier than
before) to ensure all slashes are applied **before stakers can unbond**
(which starts from era 28 onwards).

---

## Worst-Case Block Calculation for Slash Application  

### Polkadot:  
- **1 era = 24 hours**, **1 block = 6s** → **14,400 blocks/era**  
- On parachains (**12s blocks**) → **7,200 blocks/era**  

### Kusama:  
- **1 era = 6 hours**, **1 block = 6s** → **3,600 blocks/era**  
- On parachains (**12s blocks**) → **1,800 blocks/era**  

### Worst-Case Assumptions:  
- **Total stakers:** 40,000 nominators, 1000 validators. (Polkadot
currently has ~23k nominators and 500 validators)
- **Max slashed:** 50% so 20k nominators, 250 validators.  
- **Page size:** Validators with multiple page: (512 + 1)/2 = 256 ,
Validators with single page: 1

### Calculation:  
There might be a more accurate way to calculate this worst-case number,
and this estimate could be significantly higher than necessary, but it
shouldn’t exceed this value.

Blocks needed: 250 + 20k/256 = ~330 blocks.

##  *Potential Improvement:*  
- Consider adding an **Offchain Worker (OCW)** task to further optimize
slash application in future updates.
- Dynamically batch unapplied slashes based on number of nominators in
the page, or process until reserved weight limit is exhausted.

----
## Summary of Changes  

### Storage  
- **New:**  
  - `OffenceQueue` *(StorageDoubleMap)*  
    - **K1:** Era  
    - **K2:** Offending validator account  
    - **V:** `OffenceRecord`  
  - `OffenceQueueEras` *(StorageValue)*  
    - **V:** `BoundedVec<EraIndex, BoundingDuration>`  
  - `ProcessingOffence` *(StorageValue)*  
    - **V:** `(Era, offending validator account, OffenceRecord)`  

- **Changed:**  
  - `UnappliedSlashes`:  
    - **Old:** `StorageMap<K -> Era, V -> Vec<UnappliedSlash>>`  
- **New:** `StorageDoubleMap<K1 -> Era, K2 -> (validator_acc, perbill,
page_index), V -> UnappliedSlash>`

### Events  
- **New:**  
  - `SlashComputed { offence_era, slash_era, offender, page }`  
  - `SlashCancelled { slash_era, slash_key, payout }`  

### Error  
- **Changed:**  
  - `InvalidSlashIndex` → Renamed to `InvalidSlashRecord`  
- **Removed:**  
  - `NotSortedAndUnique`  
- **Added:**  
  - `EraNotStarted`  

### Call  
- **Changed:**  
  - `cancel_deferred_slash(era, slash_indices: Vec<u32>)`  
    → Now takes `Vec<(validator_acc, slash_fraction, page_index)>`  
- **New:**  
- `apply_slash(slash_era, slash_key: (validator_acc, slash_fraction,
page_index))`

### Runtime Config  
- `FullIdentification` is now set to a unit type (`()`) / null identity,
replacing the previous exposure type for all runtimes using
`pallet_session::historical`.

## TODO
- [x] Fixed broken `CancelDeferredSlashes`.
- [x] Ensure on_offence called only with validator account for
identification everywhere.
- [ ] Ensure we never need to read full exposure.
- [x] Tests for multi block processing and application of slash.
- [x] Migrate UnappliedSlashes 
- [x] Bench (crude, needs proper bench as followup)
  - [x] on_offence()
  - [x] process_offence()
  - [x] apply_slash()
 
 
## Followups (tracker
[link](paritytech#7596))
- [ ] OCW task to process offence + apply slashes.
- [ ] Minimum time for governance to cancel deferred slash.
- [ ] Allow root or staking admin to add a custom slash.
- [ ] Test HistoricalSession proof works fine with eras before removing
exposure as full identity.
- [ ] Properly bench offence processing and slashing.
- [ ] Handle Offences::Reports migration when removing validator
exposure as identity.

---------

Co-authored-by: Gonçalo Pestana <[email protected]>
Co-authored-by: command-bot <>
Co-authored-by: Kian Paimani <[email protected]>
Co-authored-by: Guillaume Thiolliere <[email protected]>
Co-authored-by: kianenigma <[email protected]>
Co-authored-by: Giuseppe Re <[email protected]>
Co-authored-by: cmd[bot] <41898282+github-actions[bot]@users.noreply.github.com>
  • Loading branch information
7 people authored and clangenb committed Feb 19, 2025
1 parent 7b3e792 commit b503825
Show file tree
Hide file tree
Showing 24 changed files with 1,605 additions and 1,061 deletions.
4 changes: 2 additions & 2 deletions polkadot/runtime/test-runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -322,8 +322,8 @@ impl pallet_session::Config for Runtime {
}

impl pallet_session::historical::Config for Runtime {
type FullIdentification = pallet_staking::Exposure<AccountId, Balance>;
type FullIdentificationOf = pallet_staking::ExposureOf<Runtime>;
type FullIdentification = ();
type FullIdentificationOf = pallet_staking::NullIdentity;
}

pallet_staking_reward_curve::build! {
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 @@ -1871,6 +1871,7 @@ pub mod migrations {
parachains_shared::migration::MigrateToV1<Runtime>,
parachains_scheduler::migration::MigrateV2ToV3<Runtime>,
pallet_staking::migrations::v16::MigrateV15ToV16<Runtime>,
pallet_staking::migrations::v17::MigrateV16ToV17<Runtime>,
// permanent
pallet_xcm::migration::MigrateToLatestXcmVersion<Runtime>,
);
Expand Down
4 changes: 4 additions & 0 deletions polkadot/runtime/westend/src/weights/pallet_staking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -805,4 +805,8 @@ impl<T: frame_system::Config> pallet_staking::WeightInfo for WeightInfo<T> {
.saturating_add(T::DbWeight::get().reads(6))
.saturating_add(T::DbWeight::get().writes(2))
}
fn apply_slash() -> Weight {
// TODO CI-FAIL: run CI bench bot
Weight::zero()
}
}
37 changes: 37 additions & 0 deletions prdoc/pr_7424.prdoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Schema: Polkadot SDK PRDoc Schema (prdoc) v1.0.0
# See doc at https://raw.githubusercontent.com/paritytech/polkadot-sdk/master/prdoc/schema_user.json

title: 'Bounded Slashing: Paginated Offence Processing & Slash Application'

doc:
- audience: Runtime Dev
description: |
This PR refactors the slashing mechanism in `pallet-staking` to be bounded by introducing paged offence processing and paged slash application.

### Key Changes
- Offences are queued instead of being processed immediately.
- Slashes are computed in pages, stored as a `StorageDoubleMap` with `(Validator, SlashFraction, PageIndex)` to uniquely identify them.
- Slashes are applied incrementally across multiple blocks instead of a single unbounded operation.
- New storage items: `OffenceQueue`, `ProcessingOffence`, `OffenceQueueEras`.
- Updated API for cancelling and applying slashes.
- Preliminary benchmarks added; further optimizations planned.

This enables staking slashing to scale efficiently and removes a major blocker for staking migration to a parachain (AH).

crates:
- name: pallet-babe
bump: patch
- name: pallet-staking
bump: major
- name: pallet-grandpa
bump: patch
- name: westend-runtime
bump: minor
- name: pallet-beefy
bump: patch
- name: pallet-offences-benchmarking
bump: patch
- name: pallet-session-benchmarking
bump: patch
- name: pallet-root-offences
bump: patch
54 changes: 17 additions & 37 deletions substrate/bin/node/runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -676,8 +676,6 @@ impl_opaque_keys! {

#[cfg(feature = "staking-playground")]
pub mod staking_playground {
use pallet_staking::Exposure;

use super::*;

/// An adapter to make the chain work with --dev only, even though it is running a large staking
Expand Down Expand Up @@ -712,61 +710,43 @@ pub mod staking_playground {
}
}

impl pallet_session::historical::SessionManager<AccountId, Exposure<AccountId, Balance>>
for AliceAsOnlyValidator
{
impl pallet_session::historical::SessionManager<AccountId, ()> for AliceAsOnlyValidator {
fn end_session(end_index: sp_staking::SessionIndex) {
<Staking as pallet_session::historical::SessionManager<
AccountId,
Exposure<AccountId, Balance>,
>>::end_session(end_index)
<Staking as pallet_session::historical::SessionManager<AccountId, ()>>::end_session(
end_index,
)
}

fn new_session(
new_index: sp_staking::SessionIndex,
) -> Option<Vec<(AccountId, Exposure<AccountId, Balance>)>> {
<Staking as pallet_session::historical::SessionManager<
AccountId,
Exposure<AccountId, Balance>,
>>::new_session(new_index)
fn new_session(new_index: sp_staking::SessionIndex) -> Option<Vec<(AccountId, ())>> {
<Staking as pallet_session::historical::SessionManager<AccountId, ()>>::new_session(
new_index,
)
.map(|_ignored| {
// construct a fake exposure for alice.
vec![(
sp_keyring::Sr25519Keyring::AliceStash.to_account_id().into(),
pallet_staking::Exposure {
total: 1_000_000_000,
own: 1_000_000_000,
others: vec![],
},
)]
vec![(sp_keyring::Sr25519Keyring::AliceStash.to_account_id().into(), ())]
})
}

fn new_session_genesis(
new_index: sp_staking::SessionIndex,
) -> Option<Vec<(AccountId, Exposure<AccountId, Balance>)>> {
) -> Option<Vec<(AccountId, ())>> {
<Staking as pallet_session::historical::SessionManager<
AccountId,
Exposure<AccountId, Balance>,
(),
>>::new_session_genesis(new_index)
.map(|_ignored| {
// construct a fake exposure for alice.
vec![(
sp_keyring::Sr25519Keyring::AliceStash.to_account_id().into(),
pallet_staking::Exposure {
total: 1_000_000_000,
own: 1_000_000_000,
others: vec![],
},
(),
)]
})
}

fn start_session(start_index: sp_staking::SessionIndex) {
<Staking as pallet_session::historical::SessionManager<
AccountId,
Exposure<AccountId, Balance>,
>>::start_session(start_index)
<Staking as pallet_session::historical::SessionManager<AccountId, ()>>::start_session(
start_index,
)
}
}
}
Expand All @@ -790,8 +770,8 @@ impl pallet_session::Config for Runtime {
}

impl pallet_session::historical::Config for Runtime {
type FullIdentification = pallet_staking::Exposure<AccountId, Balance>;
type FullIdentificationOf = pallet_staking::ExposureOf<Runtime>;
type FullIdentification = ();
type FullIdentificationOf = pallet_staking::NullIdentity;
}

pallet_staking_reward_curve::build! {
Expand Down
4 changes: 2 additions & 2 deletions substrate/frame/babe/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,8 @@ impl pallet_session::Config for Test {
}

impl pallet_session::historical::Config for Test {
type FullIdentification = pallet_staking::Exposure<u64, u128>;
type FullIdentificationOf = pallet_staking::ExposureOf<Self>;
type FullIdentification = ();
type FullIdentificationOf = pallet_staking::NullIdentity;
}

impl pallet_authorship::Config for Test {
Expand Down
4 changes: 2 additions & 2 deletions substrate/frame/beefy/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,8 @@ impl pallet_session::Config for Test {
}

impl pallet_session::historical::Config for Test {
type FullIdentification = pallet_staking::Exposure<u64, u128>;
type FullIdentificationOf = pallet_staking::ExposureOf<Self>;
type FullIdentification = ();
type FullIdentificationOf = pallet_staking::NullIdentity;
}

impl pallet_authorship::Config for Test {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,8 @@ impl pallet_session::Config for Runtime {
type WeightInfo = ();
}
impl pallet_session::historical::Config for Runtime {
type FullIdentification = pallet_staking::Exposure<AccountId, Balance>;
type FullIdentificationOf = pallet_staking::ExposureOf<Runtime>;
type FullIdentification = ();
type FullIdentificationOf = pallet_staking::NullIdentity;
}

frame_election_provider_support::generate_solution_type!(
Expand Down Expand Up @@ -908,10 +908,7 @@ pub(crate) fn on_offence_now(
// Add offence to validator, slash it.
pub(crate) fn add_slash(who: &AccountId) {
on_offence_now(
&[OffenceDetails {
offender: (*who, Staking::eras_stakers(active_era(), who)),
reporters: vec![],
}],
&[OffenceDetails { offender: (*who, ()), reporters: vec![] }],
&[Perbill::from_percent(10)],
);
}
Expand Down
4 changes: 2 additions & 2 deletions substrate/frame/grandpa/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ impl pallet_session::Config for Test {
}

impl pallet_session::historical::Config for Test {
type FullIdentification = pallet_staking::Exposure<u64, u128>;
type FullIdentificationOf = pallet_staking::ExposureOf<Self>;
type FullIdentification = ();
type FullIdentificationOf = pallet_staking::NullIdentity;
}

impl pallet_authorship::Config for Test {
Expand Down
15 changes: 13 additions & 2 deletions substrate/frame/offences/benchmarking/src/inner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,13 @@ fn make_offenders<T: Config>(
Ok(id_tuples)
}

#[cfg(test)]
fn run_staking_next_block<T: Config>() {
use frame_support::traits::Hooks;
System::<T>::set_block_number(System::<T>::block_number().saturating_add(1u32.into()));
Staking::<T>::on_initialize(System::<T>::block_number());
}

#[cfg(test)]
fn assert_all_slashes_applied<T>(offender_count: usize)
where
Expand All @@ -182,10 +189,10 @@ where
// make sure that all slashes have been applied
// deposit to reporter + reporter account endowed.
assert_eq!(System::<T>::read_events_for_pallet::<pallet_balances::Event<T>>().len(), 2);
// (n nominators + one validator) * slashed + Slash Reported
// (n nominators + one validator) * slashed + Slash Reported + Slash Computed
assert_eq!(
System::<T>::read_events_for_pallet::<pallet_staking::Event<T>>().len(),
1 * (offender_count + 1) as usize + 1
1 * (offender_count + 1) as usize + 2
);
// offence
assert_eq!(System::<T>::read_events_for_pallet::<pallet_offences::Event>().len(), 1);
Expand Down Expand Up @@ -232,6 +239,8 @@ mod benchmarks {

#[cfg(test)]
{
// slashes applied at the next block.
run_staking_next_block::<T>();
assert_all_slashes_applied::<T>(n as usize);
}

Expand Down Expand Up @@ -266,6 +275,8 @@ mod benchmarks {
}
#[cfg(test)]
{
// slashes applied at the next block.
run_staking_next_block::<T>();
assert_all_slashes_applied::<T>(n as usize);
}

Expand Down
5 changes: 2 additions & 3 deletions substrate/frame/offences/benchmarking/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ use sp_runtime::{
};

type AccountId = u64;
type Balance = u64;

#[derive_impl(frame_system::config_preludes::TestDefaultConfig)]
impl frame_system::Config for Test {
Expand All @@ -54,8 +53,8 @@ impl pallet_timestamp::Config for Test {
type WeightInfo = ();
}
impl pallet_session::historical::Config for Test {
type FullIdentification = pallet_staking::Exposure<AccountId, Balance>;
type FullIdentificationOf = pallet_staking::ExposureOf<Test>;
type FullIdentification = ();
type FullIdentificationOf = pallet_staking::NullIdentity;
}

sp_runtime::impl_opaque_keys! {
Expand Down
17 changes: 5 additions & 12 deletions substrate/frame/root-offences/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ extern crate alloc;

use alloc::vec::Vec;
use pallet_session::historical::IdentificationTuple;
use pallet_staking::{BalanceOf, Exposure, ExposureOf, Pallet as Staking};
use pallet_staking::Pallet as Staking;
use sp_runtime::Perbill;
use sp_staking::offence::OnOffenceHandler;

Expand All @@ -49,11 +49,8 @@ pub mod pallet {
+ pallet_staking::Config
+ pallet_session::Config<ValidatorId = <Self as frame_system::Config>::AccountId>
+ pallet_session::historical::Config<
FullIdentification = Exposure<
<Self as frame_system::Config>::AccountId,
BalanceOf<Self>,
>,
FullIdentificationOf = ExposureOf<Self>,
FullIdentification = (),
FullIdentificationOf = pallet_staking::NullIdentity,
>
{
type RuntimeEvent: From<Event<Self>> + IsType<<Self as frame_system::Config>::RuntimeEvent>;
Expand Down Expand Up @@ -106,15 +103,11 @@ pub mod pallet {
fn get_offence_details(
offenders: Vec<(T::AccountId, Perbill)>,
) -> Result<Vec<OffenceDetails<T>>, DispatchError> {
let now = pallet_staking::ActiveEra::<T>::get()
.map(|e| e.index)
.ok_or(Error::<T>::FailedToGetActiveEra)?;

Ok(offenders
.clone()
.into_iter()
.map(|(o, _)| OffenceDetails::<T> {
offender: (o.clone(), Staking::<T>::eras_stakers(now, &o)),
offender: (o.clone(), ()),
reporters: Default::default(),
})
.collect())
Expand All @@ -124,7 +117,7 @@ pub mod pallet {
fn submit_offence(offenders: &[OffenceDetails<T>], slash_fraction: &[Perbill]) {
let session_index = <pallet_session::Pallet<T> as frame_support::traits::ValidatorSet<T::AccountId>>::session_index();

<pallet_staking::Pallet<T> as OnOffenceHandler<
<Staking<T> as OnOffenceHandler<
T::AccountId,
IdentificationTuple<T>,
Weight,
Expand Down
11 changes: 8 additions & 3 deletions substrate/frame/root-offences/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ use frame_support::{
traits::{ConstU32, ConstU64, OneSessionHandler},
BoundedVec,
};
use pallet_staking::StakerStatus;
use pallet_staking::{BalanceOf, StakerStatus};
use sp_core::ConstBool;
use sp_runtime::{curve::PiecewiseLinear, testing::UintAuthorityId, traits::Zero, BuildStorage};
use sp_staking::{EraIndex, SessionIndex};
Expand Down Expand Up @@ -148,8 +148,8 @@ impl pallet_staking::Config for Test {
}

impl pallet_session::historical::Config for Test {
type FullIdentification = pallet_staking::Exposure<AccountId, Balance>;
type FullIdentificationOf = pallet_staking::ExposureOf<Test>;
type FullIdentification = ();
type FullIdentificationOf = pallet_staking::NullIdentity;
}

sp_runtime::impl_opaque_keys! {
Expand Down Expand Up @@ -297,6 +297,11 @@ pub(crate) fn run_to_block(n: BlockNumber) {
);
}

/// Progress by n block.
pub(crate) fn advance_blocks(n: u64) {
run_to_block(System::block_number() + n);
}

pub(crate) fn active_era() -> EraIndex {
pallet_staking::ActiveEra::<Test>::get().unwrap().index
}
12 changes: 11 additions & 1 deletion substrate/frame/root-offences/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@

use super::*;
use frame_support::{assert_err, assert_ok};
use mock::{active_era, start_session, ExtBuilder, RootOffences, RuntimeOrigin, System, Test as T};
use mock::{
active_era, advance_blocks, start_session, ExtBuilder, RootOffences, RuntimeOrigin, System,
Test as T,
};
use pallet_staking::asset;

#[test]
Expand All @@ -42,6 +45,10 @@ fn create_offence_works_given_root_origin() {
assert_ok!(RootOffences::create_offence(RuntimeOrigin::root(), offenders.clone()));

System::assert_last_event(Event::OffenceCreated { offenders }.into());

// offence is processed in the following block.
advance_blocks(1);

// the slash should be applied right away.
assert_eq!(asset::staked::<T>(&11), 500);

Expand All @@ -66,6 +73,9 @@ fn create_offence_wont_slash_non_active_validators() {

System::assert_last_event(Event::OffenceCreated { offenders }.into());

// advance to the next block so offence gets processed.
advance_blocks(1);

// so 31 didn't get slashed.
assert_eq!(asset::staked::<T>(&31), 500);

Expand Down
Loading

0 comments on commit b503825

Please sign in to comment.