diff --git a/substrate/frame/session/src/disabling.rs b/substrate/frame/session/src/disabling.rs new file mode 100644 index 0000000000000..3fd7dbb2385c4 --- /dev/null +++ b/substrate/frame/session/src/disabling.rs @@ -0,0 +1,190 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::*; +use frame_support::defensive; +/// Controls validator disabling +pub trait DisablingStrategy { + /// Make a disabling decision. Returning a [`DisablingDecision`] + fn decision( + offender_stash: &T::ValidatorId, + offender_slash_severity: OffenceSeverity, + currently_disabled: &Vec<(u32, OffenceSeverity)>, + ) -> DisablingDecision; +} + +/// Helper struct representing a decision coming from a given [`DisablingStrategy`] implementing +/// `decision` +/// +/// `disable` is the index of the validator to disable, +/// `reenable` is the index of the validator to re-enable. +#[derive(Debug)] +pub struct DisablingDecision { + pub disable: Option, + pub reenable: Option, +} + +/// Calculate the disabling limit based on the number of validators and the disabling limit factor. +/// +/// This is a sensible default implementation for the disabling limit factor for most disabling +/// strategies. +/// +/// Disabling limit factor n=2 -> 1/n = 1/2 = 50% of validators can be disabled +fn factor_based_disable_limit(validators_len: usize, disabling_limit_factor: usize) -> usize { + validators_len + .saturating_sub(1) + .checked_div(disabling_limit_factor) + .unwrap_or_else(|| { + defensive!("DISABLING_LIMIT_FACTOR should not be 0"); + 0 + }) +} + +/// Implementation of [`DisablingStrategy`] using factor_based_disable_limit which disables +/// validators from the active set up to a threshold. `DISABLING_LIMIT_FACTOR` is the factor of the +/// maximum disabled validators in the active set. E.g. setting this value to `3` means no more than +/// 1/3 of the validators in the active set can be disabled in an era. +/// +/// By default a factor of 3 is used which is the byzantine threshold. +pub struct UpToLimitDisablingStrategy; + +impl UpToLimitDisablingStrategy { + /// Disabling limit calculated from the total number of validators in the active set. When + /// reached no more validators will be disabled. + pub fn disable_limit(validators_len: usize) -> usize { + factor_based_disable_limit(validators_len, DISABLING_LIMIT_FACTOR) + } +} + +impl DisablingStrategy + for UpToLimitDisablingStrategy +{ + fn decision( + offender_stash: &T::ValidatorId, + _offender_slash_severity: OffenceSeverity, + currently_disabled: &Vec<(u32, OffenceSeverity)>, + ) -> DisablingDecision { + let active_set = Validators::::get(); + + // We don't disable more than the limit + if currently_disabled.len() >= Self::disable_limit(active_set.len()) { + log!( + debug, + "Won't disable: reached disabling limit {:?}", + Self::disable_limit(active_set.len()) + ); + return DisablingDecision { disable: None, reenable: None } + } + + let offender_idx = if let Some(idx) = active_set.iter().position(|i| i == offender_stash) { + idx as u32 + } else { + log!(debug, "Won't disable: offender not in active set",); + return DisablingDecision { disable: None, reenable: None } + }; + + log!(debug, "Will disable {:?}", offender_idx); + + DisablingDecision { disable: Some(offender_idx), reenable: None } + } +} + +/// Implementation of [`DisablingStrategy`] which disables validators from the active set up to a +/// limit (factor_based_disable_limit) and if the limit is reached and the new offender is higher +/// (bigger punishment/severity) then it re-enables the lowest offender to free up space for the new +/// offender. +/// +/// This strategy is not based on cumulative severity of offences but only on the severity of the +/// highest offence. Offender first committing a 25% offence and then a 50% offence will be treated +/// the same as an offender committing 50% offence. +/// +/// An extension of [`UpToLimitDisablingStrategy`]. +pub struct UpToLimitWithReEnablingDisablingStrategy; + +impl + UpToLimitWithReEnablingDisablingStrategy +{ + /// Disabling limit calculated from the total number of validators in the active set. When + /// reached re-enabling logic might kick in. + pub fn disable_limit(validators_len: usize) -> usize { + factor_based_disable_limit(validators_len, DISABLING_LIMIT_FACTOR) + } +} + +impl DisablingStrategy + for UpToLimitWithReEnablingDisablingStrategy +{ + fn decision( + offender_stash: &T::ValidatorId, + offender_slash_severity: OffenceSeverity, + currently_disabled: &Vec<(u32, OffenceSeverity)>, + ) -> DisablingDecision { + let active_set = Validators::::get(); + + // We don't disable validators that are not in the active set + let offender_idx = if let Some(idx) = active_set.iter().position(|i| i == offender_stash) { + idx as u32 + } else { + log!(debug, "Won't disable: offender not in active set",); + return DisablingDecision { disable: None, reenable: None } + }; + + // Check if offender is already disabled + if let Some((_, old_severity)) = + currently_disabled.iter().find(|(idx, _)| *idx == offender_idx) + { + if offender_slash_severity > *old_severity { + log!(debug, "Offender already disabled but with lower severity, will disable again to refresh severity of {:?}", offender_idx); + return DisablingDecision { disable: Some(offender_idx), reenable: None }; + } else { + log!(debug, "Offender already disabled with higher or equal severity"); + return DisablingDecision { disable: None, reenable: None }; + } + } + + // We don't disable more than the limit (but we can re-enable a smaller offender to make + // space) + if currently_disabled.len() >= Self::disable_limit(active_set.len()) { + log!( + debug, + "Reached disabling limit {:?}, checking for re-enabling", + Self::disable_limit(active_set.len()) + ); + + // Find the smallest offender to re-enable that is not higher than + // offender_slash_severity + if let Some((smallest_idx, _)) = currently_disabled + .iter() + .filter(|(_, severity)| *severity <= offender_slash_severity) + .min_by_key(|(_, severity)| *severity) + { + log!(debug, "Will disable {:?} and re-enable {:?}", offender_idx, smallest_idx); + return DisablingDecision { + disable: Some(offender_idx), + reenable: Some(*smallest_idx), + } + } else { + log!(debug, "No smaller offender found to re-enable"); + return DisablingDecision { disable: None, reenable: None } + } + } else { + // If we are not at the limit, just disable the new offender and dont re-enable anyone + log!(debug, "Will disable {:?}", offender_idx); + return DisablingDecision { disable: Some(offender_idx), reenable: None } + } + } +} diff --git a/substrate/frame/session/src/lib.rs b/substrate/frame/session/src/lib.rs index e8b4a355f49a4..425d4c2a933b2 100644 --- a/substrate/frame/session/src/lib.rs +++ b/substrate/frame/session/src/lib.rs @@ -106,6 +106,7 @@ #![cfg_attr(not(feature = "std"), no_std)] +mod disabling; #[cfg(feature = "historical")] pub mod historical; pub mod migrations; @@ -138,11 +139,24 @@ use sp_runtime::{ traits::{AtLeast32BitUnsigned, Convert, Member, One, OpaqueKeys, Zero}, ConsensusEngineId, DispatchError, KeyTypeId, Permill, RuntimeAppPublic, }; -use sp_staking::SessionIndex; +use sp_staking::{offence::OffenceSeverity, SessionIndex}; pub use pallet::*; pub use weights::WeightInfo; +pub(crate) const LOG_TARGET: &str = "runtime::session"; + +// syntactic sugar for logging. +#[macro_export] +macro_rules! log { + ($level:tt, $patter:expr $(, $values:expr)* $(,)?) => { + log::$level!( + target: crate::LOG_TARGET, + concat!("[{:?}] 💸 ", $patter), >::block_number() $(, $values)* + ) + }; +} + /// Decides whether the session should be ended. pub trait ShouldEndSession { /// Return `true` if the session should be ended. @@ -639,7 +653,7 @@ impl Pallet { /// punishment after a fork. pub fn rotate_session() { let session_index = CurrentIndex::::get(); - log::trace!(target: "runtime::session", "rotating session {:?}", session_index); + log!(trace, "rotating session {:?}", session_index); let changed = QueuedChanged::::get(); @@ -718,6 +732,10 @@ impl Pallet { T::SessionHandler::on_new_session::(changed, &session_keys, &queued_amalgamated); } + pub fn offending_validator(validator: T::ValidatorId, severity: OffenceSeverity) { + // todo(ank4n) + } + /// Disable the validator of index `i`, returns `false` if the validator was already disabled. pub fn disable_index(i: u32) -> bool { if i >= Validators::::decode_len().unwrap_or(0) as u32 { diff --git a/substrate/frame/staking/src/lib.rs b/substrate/frame/staking/src/lib.rs index 42230cb27b756..6cf30737d43cc 100644 --- a/substrate/frame/staking/src/lib.rs +++ b/substrate/frame/staking/src/lib.rs @@ -810,6 +810,29 @@ impl UnappliedSlash { } } +/// Represents a deferred slash to be applied in a future era. +/// +/// This struct holds minimal metadata required for recording and processing a slash. Slash amounts +/// for nominators and the validator are computed dynamically when the slash is applied, reducing +/// upfront computation and storage overhead. +#[derive(Encode, Decode, RuntimeDebug, TypeInfo, MaxEncodedLen)] +pub struct DeferredSlash { + /// The stash account ID of the offending validator. + validator: AccountId, + /// The fraction of the validator's total exposure to be slashed. + slash_fraction: Perbill, + /// Reporter of the offence; bounty payout recipient. + reporter: Option, + /// The era in which the offence occurred. + offence_era: EraIndex, + /// The current page being processed in the slash application. Each page represents a portion + /// of nominators being slashed. + current_page: u32, + /// The total number of pages to process for this slash. This is derived based on the number of + /// nominators. + total_pages: u32, +} + /// Something that defines the maximum number of nominations per nominator based on a curve. /// /// The method `curve` implements the nomination quota curve and should not be used directly. @@ -844,14 +867,21 @@ impl NominationsQuota for FixedNominationsQuot /// /// This is needed because `Staking` sets the `ValidatorIdOf` of the `pallet_session::Config` pub trait SessionInterface { + /// Report an offending validator. + fn offending_validator(validator: AccountId, severity: OffenceSeverity); + /// Disable the validator at the given index, returns `false` if the validator was already /// disabled or the index is out of bounds. + // todo(ank4n): remove the next two methods. fn disable_validator(validator_index: u32) -> bool; + /// Re-enable a validator that was previously disabled. Returns `false` if the validator was /// already enabled or the index is out of bounds. fn enable_validator(validator_index: u32) -> bool; + /// Get the validators from session. fn validators() -> Vec; + /// Prune historical session tries up to but not including the given index. fn prune_historical_up_to(up_to: SessionIndex); } @@ -870,6 +900,13 @@ where Option<::AccountId>, >, { + fn offending_validator( + validator: ::AccountId, + severity: OffenceSeverity, + ) { + >::offending_validator(validator, severity) + } + fn disable_validator(validator_index: u32) -> bool { >::disable_index(validator_index) } @@ -888,6 +925,10 @@ where } impl SessionInterface for () { + fn offending_validator(validator: AccountId, severity: OffenceSeverity) { + () + } + fn disable_validator(_: u32) -> bool { true } @@ -1237,10 +1278,10 @@ impl EraInfo { let page_size = T::MaxExposurePageSize::get().defensive_max(1); let nominator_count = exposure.others.len(); - // expected page count is the number of nominators divided by the page size, rounded up. - let expected_page_count = nominator_count - .defensive_saturating_add((page_size as usize).defensive_saturating_sub(1)) - .saturating_div(page_size as usize); + // expected page count is the number of nominators divided by the page size, rounded up. + let expected_page_count = nominator_count + .defensive_saturating_add((page_size as usize).defensive_saturating_sub(1)) + .saturating_div(page_size as usize); let (exposure_metadata, exposure_pages) = exposure.into_pages(page_size); defensive_assert!(exposure_pages.len() == expected_page_count, "unexpected page count"); diff --git a/substrate/frame/staking/src/mock.rs b/substrate/frame/staking/src/mock.rs index 6346949576fa7..6113dff86a6a9 100644 --- a/substrate/frame/staking/src/mock.rs +++ b/substrate/frame/staking/src/mock.rs @@ -112,6 +112,7 @@ parameter_types! { pub static SessionsPerEra: SessionIndex = 3; pub static ExistentialDeposit: Balance = 1; pub static SlashDeferDuration: EraIndex = 0; + pub static SlashCancellationDuration: EraIndex = 0; pub static Period: BlockNumber = 5; pub static Offset: BlockNumber = 0; pub static MaxControllersInDeprecationBatch: u32 = 5900; @@ -270,6 +271,7 @@ impl crate::pallet::pallet::Config for Test { type Reward = MockReward; type SessionsPerEra = SessionsPerEra; type SlashDeferDuration = SlashDeferDuration; + type SlashCancellationDuration = SlashCancellationDuration; type AdminOrigin = EnsureOneOrRoot; type SessionInterface = Self; type EraPayout = ConvertCurve; @@ -371,6 +373,11 @@ impl ExtBuilder { SLASH_DEFER_DURATION.with(|v| *v.borrow_mut() = eras); self } + + pub fn slash_cancellation_duration(self, eras: EraIndex) -> Self { + SLASH_CANCELLATION_DURATION.with(|v| *v.borrow_mut() = eras); + self + } pub fn invulnerables(mut self, invulnerables: Vec) -> Self { self.invulnerables = invulnerables; self diff --git a/substrate/frame/staking/src/pallet/impls.rs b/substrate/frame/staking/src/pallet/impls.rs index 8c3ff23315a42..1465730754b2e 100644 --- a/substrate/frame/staking/src/pallet/impls.rs +++ b/substrate/frame/staking/src/pallet/impls.rs @@ -50,10 +50,10 @@ use sp_staking::{ use crate::{ asset, election_size_tracker::StaticTracker, log, slashing, weights::WeightInfo, ActiveEraInfo, - BalanceOf, EraInfo, EraPayout, Exposure, ExposureOf, Forcing, IndividualExposure, - LedgerIntegrityState, MaxNominationsOf, MaxWinnersOf, Nominations, NominationsQuota, - PositiveImbalanceOf, RewardDestination, SessionInterface, StakingLedger, ValidatorPrefs, - STAKING_ID, + BalanceOf, DeferredSlash, EraInfo, EraPayout, Exposure, ExposureOf, Forcing, + IndividualExposure, LedgerIntegrityState, MaxNominationsOf, MaxWinnersOf, Nominations, + NominationsQuota, PositiveImbalanceOf, RewardDestination, SessionInterface, StakingLedger, + ValidatorPrefs, STAKING_ID, }; use alloc::{boxed::Box, vec, vec::Vec}; @@ -558,7 +558,12 @@ impl Pallet { slashing::clear_era_metadata::(pruned_era); } - if let Some(&(_, first_session)) = bonded.first() { + // Session prunes upto `active_era - bonding_duration + SlashCancellationDuration`. + // This ensures sessions that are old and have less than `SlashCancellationDuration` + // left to be reported are pruned, and hence cannot be reported. + if let Some(&(_, first_session)) = + bonded.get(T::SlashCancellationDuration::get() as usize) + { T::SessionInterface::prune_historical_up_to(first_session); } } @@ -1505,7 +1510,7 @@ where Option<::AccountId>, >, { - fn on_offence( + /*fn on_offence( offenders: &[OffenceDetails< T::AccountId, pallet_session::historical::IdentificationTuple, @@ -1556,6 +1561,15 @@ where add_db_reads_writes(1, 1); let slash_defer_duration = T::SlashDeferDuration::get(); + // era at the start of which slash will be applied. + let slash_apply_era = slash_era.saturating_add(slash_defer_duration).saturating_add(One::one()); + + if slash_apply_era < active_era.saturating_add(T::SlashCancellationDuration::get()) { + // this era cannot be too recent to allow a minimum time for slash to be reverted. + // just ignore the report. + // Note: the caller would ideally filter this already. + return consumed_weight + } let invulnerables = Invulnerables::::get(); add_db_reads_writes(1, 0); @@ -1616,7 +1630,7 @@ where slash_era + slash_defer_duration + 1, ); UnappliedSlashes::::mutate( - slash_era.saturating_add(slash_defer_duration).saturating_add(One::one()), + slash_apply_era, move |for_later| for_later.push(unapplied), ); add_db_reads_writes(1, 1); @@ -1626,6 +1640,134 @@ where } } + consumed_weight + }*/ + + fn on_offence( + offenders: &[OffenceDetails< + T::AccountId, + pallet_session::historical::IdentificationTuple, + >], + slash_fraction: &[Perbill], + slash_session: SessionIndex, + ) -> Weight { + let mut consumed_weight = Weight::zero(); + // todo(ank4n): This should be benchmarked for proper storage proof weight. + let mut add_db_reads_writes = |reads, writes| { + consumed_weight += T::DbWeight::get().reads_writes(reads, writes); + }; + + // get the active era + let active_era = { + let active_era = ActiveEra::::get(); + add_db_reads_writes(1, 0); + if active_era.is_none() { + // This offence need not be re-submitted. + return consumed_weight + } + active_era.expect("value checked not to be `None`; qed").index + }; + + // get the start session index of the active era + let active_era_start_session_index = ErasStartSessionIndex::::get(active_era) + .unwrap_or_else(|| { + frame_support::print("Error: start_session_index must be set for current_era"); + 0 + }); + add_db_reads_writes(1, 0); + + // get the era in which offence occurred + let slash_era = if slash_session >= active_era_start_session_index { + active_era + } else { + let eras = BondedEras::::get(); + add_db_reads_writes(1, 0); + + // Reverse because it's more likely to find reports from recent eras. + match eras.iter().rev().find(|&(_, sesh)| sesh <= &slash_session) { + Some((slash_era, _)) => *slash_era, + // Before bonding period. defensive - should be filtered out. + None => return consumed_weight, + } + }; + add_db_reads_writes(1, 1); + + // compute the era at which slash will be applied + let slash_defer_duration = T::SlashDeferDuration::get(); + // Note: If slash_defer_duration is zero, then the slash will be applied at the start of the + // next era. + let slash_apply_era = slash_era.saturating_add(slash_defer_duration); + + // check if the era is too recent to allow a minimum time for slash to be reverted + if slash_apply_era < active_era.saturating_add(T::SlashCancellationDuration::get()) { + return consumed_weight + } + + // Get the invulnerable validators. They are never slashed. + let invulnerables = Invulnerables::::get(); + add_db_reads_writes(1, 0); + + // generate slash report. + for (details, slash_fraction) in offenders.iter().zip(slash_fraction) { + let (stash, exposure) = &details.offender; + + // Skip if the validator is invulnerable. + if invulnerables.contains(stash) { + continue + } + + // get the exposure metadata for the validator + let slash_page_count = EraInfo::::get_page_count(slash_era, stash); + add_db_reads_writes(1, 0); + + // Get reporter. If there are multiple reporters, we only consider the first one. + let unapplied = DeferredSlash { + validator: stash.clone(), + slash_fraction: *slash_fraction, + reporter: details.reporters.first().cloned(), + offence_era: slash_era, + current_page: 0, + total_pages: slash_page_count, + }; + + log!( + debug, + "deferring slash of {:?}% happened in {:?} (reported in {:?}) to {:?}", + slash_fraction, + slash_era, + active_era, + slash_apply_era, + ); + + // push to the deferred slashes list for the slashing era. + add_db_reads_writes(1, 1); + let defer_result = + DeferredSlashes::::try_mutate(slash_apply_era, |for_later| -> Result<(), ()> { + if for_later.try_push(unapplied).is_err() { + log::warn!( + "Failed to defer slash for validator {:?} to era {:?}. Max capacity reached.", + stash.clone(), + slash_apply_era + ); + return Err(()); + } + + Ok(()) + }); + + if defer_result.is_err() { + // Break out early since we have reached max capacity for slash_apply_era and + // further processing is futile. + break + } + + Self::deposit_event(Event::::SlashReported { + validator: stash.clone(), + fraction: *slash_fraction, + slash_era, + }); + } + consumed_weight } } diff --git a/substrate/frame/staking/src/pallet/mod.rs b/substrate/frame/staking/src/pallet/mod.rs index 7d5da9ea0c497..b7f0fa91d76ff 100644 --- a/substrate/frame/staking/src/pallet/mod.rs +++ b/substrate/frame/staking/src/pallet/mod.rs @@ -54,7 +54,7 @@ pub use impls::*; use crate::{ asset, slashing, weights::WeightInfo, AccountIdLookupOf, ActiveEraInfo, BalanceOf, - DisablingStrategy, EraPayout, EraRewardPoints, Exposure, ExposurePage, Forcing, + DeferredSlash, DisablingStrategy, EraPayout, EraRewardPoints, Exposure, ExposurePage, Forcing, LedgerIntegrityState, MaxNominationsOf, NegativeImbalanceOf, Nominations, NominationsQuota, PositiveImbalanceOf, RewardDestination, SessionInterface, StakingLedger, UnappliedSlash, UnlockChunk, ValidatorPrefs, @@ -215,11 +215,32 @@ pub mod pallet { /// Number of eras that slashes are deferred by, after computation. /// - /// This should be less than the bonding duration. Set to 0 if slashes + /// This should be strictly less than the [`Config::BondingDuration`]. Set to 0 if slashes /// should be applied immediately, without opportunity for intervention. #[pallet::constant] type SlashDeferDuration: Get; + /// Number of eras preceding an unapplied slash where governance could cancel the slash. + /// + /// Any offence reported for an era such that they have less than + /// `SlashCancellationDuration` eras until application of slash are rejected. This ensures + /// governance always have `SlashCancellationDuration` eras to cancel a slash. + /// + /// This should strictly be less than [`Config::SlashDeferDuration`]. If set to 0, then + /// governance has no opportunity to cancel the slash. If set to `SlashDeferDuration`, then + /// an offence can never be reported. + #[pallet::constant] + type SlashCancellationDuration: Get; + + // todo(ank4n): add doc + #[pallet::constant] + type ActiveValidatorCount: Get; + + // todo(ank4n): add doc + /// A good number would be 1/3rd of the active validator count. + #[pallet::constant] + type MaxDeferredSlashQueue: Get; + /// The origin which can manage less critical staking parameters that does not require root. /// /// Supported actions: (1) cancel deferred slash, (2) set minimum commission. @@ -367,13 +388,16 @@ pub mod pallet { type SessionsPerEra = SessionsPerEra; type BondingDuration = BondingDuration; type SlashDeferDuration = (); + type SlashCancellationDuration = (); type SessionInterface = (); type NextNewSession = (); type MaxExposurePageSize = ConstU32<64>; type MaxUnlockingChunks = ConstU32<32>; + type ActiveValidatorCount = ConstU32<1000>; + type MaxDeferredSlashQueue = ConstU32<333>; type MaxControllersInDeprecationBatch = ConstU32<100>; type EventListeners = (); - type DisablingStrategy = crate::UpToLimitDisablingStrategy; + type DisablingStrategy = crate::UpToLimitWithReEnablingDisablingStrategy; #[cfg(feature = "std")] type BenchmarkingConfig = crate::TestBenchmarkingConfig; type WeightInfo = (); @@ -381,6 +405,7 @@ pub mod pallet { } /// The ideal number of active validators. + // todo(ank4n): Make this a configuration. #[pallet::storage] pub type ValidatorCount = StorageValue<_, u32, ValueQuery>; @@ -681,6 +706,16 @@ pub mod pallet { ValueQuery, >; + /// All unapplied slashes that are queued for later. + #[pallet::storage] + pub type DeferredSlashes = StorageMap< + _, + Twox64Concat, + EraIndex, + BoundedVec, T::MaxDeferredSlashQueue>, + ValueQuery, + >; + /// A mapping from still-bonded eras to the first session index of that era. /// /// Must contains information for eras for the range: