From caaa9abc1aea8ec752fcc35ec7de62cd1e8944d5 Mon Sep 17 00:00:00 2001 From: Tsvetomir Dimitrov Date: Thu, 20 Jul 2023 15:15:44 +0300 Subject: [PATCH] Initial implementation of validator disabling strategy Based on https://github.com/paritytech/polkadot/issues/5948#issuecomment-1562682161 Related to https://github.com/paritytech/polkadot/issues/5948 --- bin/node/runtime/src/lib.rs | 1 + frame/session/src/lib.rs | 9 ++ frame/staking/Cargo.toml | 4 +- frame/staking/src/lib.rs | 9 ++ frame/staking/src/pallet/impls.rs | 7 +- frame/staking/src/pallet/mod.rs | 27 ++++- frame/staking/src/slashing.rs | 157 +++++++++++++++++++++++++++--- 7 files changed, 186 insertions(+), 28 deletions(-) diff --git a/bin/node/runtime/src/lib.rs b/bin/node/runtime/src/lib.rs index d5b3881eb4c71..b98f52a36ec16 100644 --- a/bin/node/runtime/src/lib.rs +++ b/bin/node/runtime/src/lib.rs @@ -601,6 +601,7 @@ impl pallet_staking::Config for Runtime { type EventListeners = NominationPools; type WeightInfo = pallet_staking::weights::SubstrateWeight; type BenchmarkingConfig = StakingBenchmarkingConfig; + type Randomness = pallet_babe::ParentBlockRandomness; } impl pallet_fast_unstake::Config for Runtime { diff --git a/frame/session/src/lib.rs b/frame/session/src/lib.rs index d2b1c2b744674..cf216690d6b45 100644 --- a/frame/session/src/lib.rs +++ b/frame/session/src/lib.rs @@ -731,6 +731,15 @@ impl Pallet { .unwrap_or(false) } + /// Clears all disabled validators and add a new set. Input vector should be sorted! + pub fn reset_disabled(new_disabled_indecies: Vec) { + // Should we trust the caller that the vector is sorted?! + // new_disabled_indecies.sort(); + >::set(new_disabled_indecies); + // TODO call `T::SessionHandler::on_disabled(i);` for the newly disabled validators + // TODO: should we do something with the reenabled ones? + } + /// Upgrade the key type from some old type to a new type. Supports adding /// and removing key types. /// diff --git a/frame/staking/Cargo.toml b/frame/staking/Cargo.toml index b4c2634dd8297..22b1f64bae6f7 100644 --- a/frame/staking/Cargo.toml +++ b/frame/staking/Cargo.toml @@ -31,10 +31,10 @@ pallet-authorship = { version = "4.0.0-dev", default-features = false, path = ". sp-application-crypto = { version = "23.0.0", default-features = false, path = "../../primitives/application-crypto", features = ["serde"] } frame-election-provider-support = { version = "4.0.0-dev", default-features = false, path = "../election-provider-support" } log = { version = "0.4.17", default-features = false } +rand_chacha = { version = "0.2", default-features = false } # Optional imports for benchmarking frame-benchmarking = { version = "4.0.0-dev", default-features = false, path = "../benchmarking", optional = true } -rand_chacha = { version = "0.2", default-features = false, optional = true } [dev-dependencies] sp-tracing = { version = "10.0.0", path = "../../primitives/tracing" } @@ -47,7 +47,6 @@ pallet-bags-list = { version = "4.0.0-dev", path = "../bags-list" } substrate-test-utils = { version = "4.0.0-dev", path = "../../test-utils" } frame-benchmarking = { version = "4.0.0-dev", path = "../benchmarking" } frame-election-provider-support = { version = "4.0.0-dev", path = "../election-provider-support" } -rand_chacha = { version = "0.2" } [features] default = ["std"] @@ -71,7 +70,6 @@ std = [ runtime-benchmarks = [ "frame-benchmarking/runtime-benchmarks", "frame-election-provider-support/runtime-benchmarks", - "rand_chacha", "sp-staking/runtime-benchmarks", ] try-runtime = ["frame-support/try-runtime", "frame-election-provider-support/try-runtime"] diff --git a/frame/staking/src/lib.rs b/frame/staking/src/lib.rs index 8f1513f5065aa..5af4781375505 100644 --- a/frame/staking/src/lib.rs +++ b/frame/staking/src/lib.rs @@ -756,6 +756,9 @@ pub trait SessionInterface { /// Disable the validator at the given index, returns `false` if the validator was already /// disabled or the index is out of bounds. fn disable_validator(validator_index: u32) -> bool; + /// Clear disabled validators and set new ones. If the input `Vec` is empty the disabled + /// validators list still will be cleared. Get the validators from session. + fn reset_disabled_validators(new_disabled_validator_indecies: Vec); /// Get the validators from session. fn validators() -> Vec; /// Prune historical session tries up to but not including the given index. @@ -787,6 +790,10 @@ where fn prune_historical_up_to(up_to: SessionIndex) { >::prune_up_to(up_to); } + + fn reset_disabled_validators(new_disabled_validator_indecies: Vec) { + >::reset_disabled(new_disabled_validator_indecies); + } } impl SessionInterface for () { @@ -799,6 +806,8 @@ impl SessionInterface for () { fn prune_historical_up_to(_: SessionIndex) { () } + + fn reset_disabled_validators(_: Vec) {} } /// Handler for determining how much of a balance should be paid out on the current era. diff --git a/frame/staking/src/pallet/impls.rs b/frame/staking/src/pallet/impls.rs index 052ba801618c2..39d8b8671f16d 100644 --- a/frame/staking/src/pallet/impls.rs +++ b/frame/staking/src/pallet/impls.rs @@ -377,10 +377,8 @@ impl Pallet { } // disable all offending validators that have been disabled for the whole era - for (index, disabled) in >::get() { - if disabled { - T::SessionInterface::disable_validator(index); - } + for index in >::get() { + T::SessionInterface::disable_validator(index); } } @@ -463,6 +461,7 @@ impl Pallet { // Clear offending validators. >::kill(); + >::kill(); } } diff --git a/frame/staking/src/pallet/mod.rs b/frame/staking/src/pallet/mod.rs index ec9805b333ecf..e4761f665aeda 100644 --- a/frame/staking/src/pallet/mod.rs +++ b/frame/staking/src/pallet/mod.rs @@ -25,8 +25,8 @@ use frame_support::{ pallet_prelude::*, traits::{ Currency, Defensive, DefensiveResult, DefensiveSaturating, EnsureOrigin, - EstimateNextNewSession, Get, LockIdentifier, LockableCurrency, OnUnbalanced, TryCollect, - UnixTime, + EstimateNextNewSession, Get, LockIdentifier, LockableCurrency, OnUnbalanced, Randomness, + TryCollect, UnixTime, }, weights::Weight, BoundedVec, @@ -272,6 +272,12 @@ pub mod pallet { /// Weight information for extrinsics in this pallet. type WeightInfo: WeightInfo; + + /// Source of randomness used for reshuffling in the validator disabling logic. + /// + /// `` because `ParentBlockRandomness` is intended to be used here. + /// `Randomness, T::BlockNumber>` is implemented for `ParentBlockRandomness` + type Randomness: Randomness, BlockNumberFor>; } /// The ideal number of active validators. @@ -560,8 +566,8 @@ pub mod pallet { #[pallet::getter(fn current_planned_session)] pub type CurrentPlannedSession = StorageValue<_, SessionIndex, ValueQuery>; - /// Indices of validators that have offended in the active era and whether they are currently - /// disabled. + /// Indices of validators that have offended in the active era and their corresponding offence + /// (slash percentage). /// /// This value should be a superset of disabled validators since not all offences lead to the /// validator being disabled (if there was no slash). This is needed to track the percentage of @@ -569,10 +575,20 @@ pub mod pallet { /// `OffendingValidatorsThreshold` is reached. The vec is always kept sorted so that we can find /// whether a given validator has previously offended using binary search. It gets cleared when /// the era ends. + /// + /// Values of the Vec: + /// * u32 - The stash account of the offender + /// * Perbill - slash proportion #[pallet::storage] #[pallet::unbounded] #[pallet::getter(fn offending_validators)] - pub type OffendingValidators = StorageValue<_, Vec<(u32, bool)>, ValueQuery>; + pub type OffendingValidators = StorageValue<_, Vec<(u32, Perbill)>, ValueQuery>; + + /// Keep track which validators are disabled because on new session start they should be + /// disabled again. The disabled list in `SessionInterface` is cleared on each new session. + #[pallet::storage] + #[pallet::unbounded] + pub type DisabledOffenders = StorageValue<_, Vec, ValueQuery>; /// The threshold for when users can start calling `chill_other` for other validators / /// nominators. The threshold is compared to the actual number of validators / nominators @@ -761,6 +777,7 @@ pub mod pallet { #[pallet::hooks] impl Hooks> for Pallet { fn on_initialize(_now: BlockNumberFor) -> Weight { + // TODO: new block is created - if offenders are above threshold -> reshuffle // just return the weight of the on_finalize. T::DbWeight::get().reads(1) } diff --git a/frame/staking/src/slashing.rs b/frame/staking/src/slashing.rs index bb02da73f6e5d..df18af099d19b 100644 --- a/frame/staking/src/slashing.rs +++ b/frame/staking/src/slashing.rs @@ -50,22 +50,23 @@ //! Based on research at use crate::{ - BalanceOf, Config, Error, Exposure, NegativeImbalanceOf, NominatorSlashInEra, - OffendingValidators, Pallet, Perbill, SessionInterface, SpanSlash, UnappliedSlash, - ValidatorSlashInEra, + BalanceOf, Config, DisabledOffenders, Error, Exposure, NegativeImbalanceOf, + NominatorSlashInEra, OffendingValidators, Pallet, Perbill, SessionInterface, SpanSlash, + UnappliedSlash, ValidatorSlashInEra, }; use codec::{Decode, Encode, MaxEncodedLen}; use frame_support::{ ensure, traits::{Currency, Defensive, Get, Imbalance, OnUnbalanced}, }; +use rand_chacha::rand_core::{RngCore, SeedableRng}; use scale_info::TypeInfo; use sp_runtime::{ traits::{Saturating, Zero}, DispatchResult, RuntimeDebug, }; use sp_staking::{offence::DisableStrategy, EraIndex}; -use sp_std::vec::Vec; +use sp_std::{collections::btree_set::BTreeSet, vec::Vec}; /// The proportion of the slashing reward to be paid out on the first slashing detection. /// This is f_1 in the paper. @@ -286,7 +287,7 @@ pub(crate) fn compute_slash( } let disable_when_slashed = params.disable_strategy != DisableStrategy::Never; - add_offending_validator::(params.stash, disable_when_slashed); + add_offending_validator::(params.stash, params.slash, disable_when_slashed); let mut nominators_slashed = Vec::new(); reward_payout += slash_nominators::(params.clone(), prior_slash_p, &mut nominators_slashed); @@ -320,13 +321,17 @@ fn kick_out_if_recent(params: SlashParams) { } let disable_without_slash = params.disable_strategy == DisableStrategy::Always; - add_offending_validator::(params.stash, disable_without_slash); + add_offending_validator::(params.stash, params.slash, disable_without_slash); } /// Add the given validator to the offenders list and optionally disable it. /// If after adding the validator `OffendingValidatorsThreshold` is reached /// a new era will be forced. -fn add_offending_validator(stash: &T::AccountId, disable: bool) { +fn add_offending_validator( + stash: &T::AccountId, + slash_proportion: Perbill, + disable: bool, +) { OffendingValidators::::mutate(|offending| { let validators = T::SessionInterface::validators(); let validator_index = match validators.iter().position(|i| i == stash) { @@ -338,33 +343,136 @@ fn add_offending_validator(stash: &T::AccountId, disable: bool) { match offending.binary_search_by_key(&validator_index_u32, |(index, _)| *index) { // this is a new offending validator - Err(index) => { - offending.insert(index, (validator_index_u32, disable)); + Err(pos) => { + offending.insert(pos, (validator_index_u32, slash_proportion)); let offending_threshold = T::OffendingValidatorsThreshold::get() * validators.len() as u32; if offending.len() >= offending_threshold as usize { - // force a new era, to select a new validator set + // force a new era, to select a new validator set. The era will be forced on the + // next session >::ensure_new_era() } if disable { - T::SessionInterface::disable_validator(validator_index_u32); + disable_new_offender::(offending, validator_index_u32); } }, - Ok(index) => { - if disable && !offending[index].1 { + Ok(pos) => { + // Keep the biggest offence + if slash_proportion > offending[pos].1 { + offending[pos].1 = slash_proportion; + } + if disable && !is_disabled::(validator_index_u32) { // the validator had previously offended without being disabled, - // let's make sure we disable it now - offending[index].1 = true; - T::SessionInterface::disable_validator(validator_index_u32); + // let's disable it now + disable_new_offender::(offending, validator_index_u32); } }, } }); } +// TODO: copy-pasted from polkadot: https://github.com/paritytech/polkadot/blob/0b56bcdb07752f3c2f369963d2c47eced549320d/runtime/parachains/src/paras_inherent/mod.rs#L875 +/// Derive entropy from babe provided per block randomness. +/// +/// In the odd case none is available, uses the `parent_hash` and +/// a const value, while emitting a warning. +fn compute_entropy(parent_hash: T::Hash) -> [u8; 32] { + use frame_support::traits::Randomness; + const CANDIDATE_SEED_SUBJECT: [u8; 32] = *b"candidate-seed-selection-subject"; + // NOTE: this is slightly gameable since this randomness was already public + // by the previous block, while for the block author this randomness was + // known 2 epochs ago. it is marginally better than using the parent block + // hash since it's harder to influence the VRF output than the block hash. + let vrf_random = T::Randomness::random(&CANDIDATE_SEED_SUBJECT[..]).0; + let mut entropy: [u8; 32] = CANDIDATE_SEED_SUBJECT; + if let Some(vrf_random) = vrf_random { + entropy.as_mut().copy_from_slice(vrf_random.as_ref()); + } else { + // in case there is no VRF randomness present, we utilize the relay parent + // as seed, it's better than a static value. + entropy.as_mut().copy_from_slice(parent_hash.as_ref()); + } + entropy +} + +// Decides if a validator should be disabled or not based on its offence. The bigger the offence - +// the higher chance to disable the validator in question. +fn disable_rand(offence: &Perbill) -> bool { + let entropy = compute_entropy::(>::parent_hash()); + let mut rng = rand_chacha::ChaChaRng::from_seed(entropy.into()); + let r = rng.next_u32() % 100; + + Perbill::from_percent(r) <= *offence +} + +// Gets a Vec and its desired length as an input. Randomly removes values from the Vec until its +// length matches the desired length. The validator length should be bigger than the desired one. +fn remove_random_validators(validators: Vec, desired_len: usize) -> Vec { + debug_assert!(validators.len() > desired_len); + + let entropy = compute_entropy::(>::parent_hash()); + let mut rng = rand_chacha::ChaChaRng::from_seed(entropy.into()); + + let mut indecies_to_remove = BTreeSet::new(); + for _ in 0..=desired_len.saturating_sub(validators.len()) { + indecies_to_remove.insert(rng.next_u32() as usize % validators.len()); + } + + validators + .into_iter() + .enumerate() + .filter(|(pos, _)| !indecies_to_remove.contains(pos)) + .map(|(_, val)| val) + .collect::>() +} + +/// Disable the validator with the id in `new_offender`. There are two possible cases: +/// 1. The total number of disabled validators is below the threshold. Then disable the new one and +/// finish. +/// 2. The total number of disabled validators is equal or above the threshold. In this case add the +/// disabled validators list is reshuffled. The reshuffling works by disabling the validator with +/// a probability equal to its offence. If the disabled validators list ends up bigger than the +/// threshold - validators are randomly removed from the list until the desired length is +/// achieved. +/// In both cases Session is notified for the change via the `SessionInterface. +/// +/// NOTE: in case 2 the new offender might not be disabled. +fn disable_new_offender(current_offenders: &Vec<(u32, Perbill)>, new_offender: u32) { + const LIMIT: usize = 42; // TODO: extract this as a parameter + + let currently_disabled = DisabledOffenders::::get(); + + if currently_disabled.len() < LIMIT { + // we are below the limit - just disable + T::SessionInterface::disable_validator(new_offender); + add_to_disabled_offenders::(new_offender); + + return + } + + // Now selectively disable based on the offence + let mut disabled = Vec::new(); + for (v, o) in current_offenders { + if disable_rand::(o) { + disabled.push(*v); + } + } + + let disabled = if disabled.len() > LIMIT { + // remove `disabled.len() - LIMIT` random elements from `disabled` + remove_random_validators::(disabled, LIMIT) + } else { + disabled + }; + + // update disabled list + DisabledOffenders::::set(disabled.clone()); + T::SessionInterface::reset_disabled_validators(disabled); +} + /// Slash nominators. Accepts general parameters and the prior slash percentage of the validator. /// /// Returns the amount of reward to pay out. @@ -689,6 +797,23 @@ fn pay_reporters( T::Slash::on_unbalanced(value_slashed); } +// Storage helper: adds a validator to `DisabledOffenders` by maintaining order. Does nothing if the +// offender is already added. +fn add_to_disabled_offenders(validator_id: u32) { + DisabledOffenders::::mutate(|offenders| { + if let Err(o) = offenders.binary_search_by_key(&validator_id, |k| *k) { + offenders.insert(o, validator_id); + } + }); +} + +// Storage helper: returns true if a validator is in `DisabledOffenders` +fn is_disabled(validator_id: u32) -> bool { + DisabledOffenders::::get() + .binary_search_by_key(&validator_id, |k| *k) + .is_ok() +} + #[cfg(test)] mod tests { use super::*;