Skip to content

Commit

Permalink
Initial implementation of validator disabling strategy
Browse files Browse the repository at this point in the history
  • Loading branch information
tdimitrov committed Aug 28, 2023
1 parent 48415ce commit 6eef693
Show file tree
Hide file tree
Showing 7 changed files with 186 additions and 28 deletions.
1 change: 1 addition & 0 deletions substrate/bin/node/runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,7 @@ impl pallet_staking::Config for Runtime {
type EventListeners = NominationPools;
type WeightInfo = pallet_staking::weights::SubstrateWeight<Runtime>;
type BenchmarkingConfig = StakingBenchmarkingConfig;
type Randomness = pallet_babe::ParentBlockRandomness<Runtime>;
}

impl pallet_fast_unstake::Config for Runtime {
Expand Down
9 changes: 9 additions & 0 deletions substrate/frame/session/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,15 @@ impl<T: Config> Pallet<T> {
.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<u32>) {
// Should we trust the caller that the vector is sorted?!
// new_disabled_indecies.sort();
<DisabledValidators<T>>::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.
///
Expand Down
4 changes: 1 addition & 3 deletions substrate/frame/staking/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ pallet-authorship = { path = "../authorship", default-features = false}
sp-application-crypto = { path = "../../primitives/application-crypto", default-features = false, features = ["serde"] }
frame-election-provider-support = { path = "../election-provider-support", default-features = false}
log = { version = "0.4.17", default-features = false }
rand_chacha = { version = "0.2", default-features = false }

# Optional imports for benchmarking
frame-benchmarking = { path = "../benchmarking", default-features = false, optional = true }
rand_chacha = { version = "0.2", default-features = false, optional = true }

[dev-dependencies]
sp-tracing = { path = "../../primitives/tracing" }
Expand All @@ -47,7 +47,6 @@ pallet-bags-list = { path = "../bags-list" }
substrate-test-utils = { path = "../../test-utils" }
frame-benchmarking = { path = "../benchmarking" }
frame-election-provider-support = { path = "../election-provider-support" }
rand_chacha = { version = "0.2" }

[features]
default = [ "std" ]
Expand Down Expand Up @@ -82,7 +81,6 @@ runtime-benchmarks = [
"pallet-bags-list/runtime-benchmarks",
"pallet-balances/runtime-benchmarks",
"pallet-timestamp/runtime-benchmarks",
"rand_chacha",
"sp-runtime/runtime-benchmarks",
"sp-staking/runtime-benchmarks",
]
Expand Down
9 changes: 9 additions & 0 deletions substrate/frame/staking/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -791,6 +791,9 @@ pub trait SessionInterface<AccountId> {
/// 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<u32>);
/// Get the validators from session.
fn validators() -> Vec<AccountId>;
/// Prune historical session tries up to but not including the given index.
Expand Down Expand Up @@ -822,6 +825,10 @@ where
fn prune_historical_up_to(up_to: SessionIndex) {
<pallet_session::historical::Pallet<T>>::prune_up_to(up_to);
}

fn reset_disabled_validators(new_disabled_validator_indecies: Vec<u32>) {
<pallet_session::Pallet<T>>::reset_disabled(new_disabled_validator_indecies);
}
}

impl<AccountId> SessionInterface<AccountId> for () {
Expand All @@ -834,6 +841,8 @@ impl<AccountId> SessionInterface<AccountId> for () {
fn prune_historical_up_to(_: SessionIndex) {
()
}

fn reset_disabled_validators(_: Vec<u32>) {}
}

/// Handler for determining how much of a balance should be paid out on the current era.
Expand Down
7 changes: 3 additions & 4 deletions substrate/frame/staking/src/pallet/impls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -379,10 +379,8 @@ impl<T: Config> Pallet<T> {
}

// disable all offending validators that have been disabled for the whole era
for (index, disabled) in <OffendingValidators<T>>::get() {
if disabled {
T::SessionInterface::disable_validator(index);
}
for index in <DisabledOffenders<T>>::get() {
T::SessionInterface::disable_validator(index);
}
}

Expand Down Expand Up @@ -465,6 +463,7 @@ impl<T: Config> Pallet<T> {

// Clear offending validators.
<OffendingValidators<T>>::kill();
<DisabledOffenders<T>>::kill();
}
}

Expand Down
27 changes: 22 additions & 5 deletions substrate/frame/staking/src/pallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -271,6 +271,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.
///
/// `<Option<Self::Hash>` because `ParentBlockRandomness` is intended to be used here.
/// `Randomness<Option<T::Hash>, T::BlockNumber>` is implemented for `ParentBlockRandomness`
type Randomness: Randomness<Option<Self::Hash>, BlockNumberFor<Self>>;
}

/// The ideal number of active validators.
Expand Down Expand Up @@ -560,19 +566,29 @@ pub mod pallet {
#[pallet::getter(fn current_planned_session)]
pub type CurrentPlannedSession<T> = 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
/// validators that have offended in the current era, ensuring a new era is forced if
/// `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<T: Config> = StorageValue<_, Vec<(u32, bool)>, ValueQuery>;
pub type OffendingValidators<T: Config> = 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<T: Config> = StorageValue<_, Vec<u32>, 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
Expand Down Expand Up @@ -765,6 +781,7 @@ pub mod pallet {
#[pallet::hooks]
impl<T: Config> Hooks<BlockNumberFor<T>> for Pallet<T> {
fn on_initialize(_now: BlockNumberFor<T>) -> 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)
}
Expand Down
157 changes: 141 additions & 16 deletions substrate/frame/staking/src/slashing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,22 +50,23 @@
//! Based on research at <https://research.web3.foundation/en/latest/polkadot/slashing/npos.html>

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.
Expand Down Expand Up @@ -286,7 +287,7 @@ pub(crate) fn compute_slash<T: Config>(
}

let disable_when_slashed = params.disable_strategy != DisableStrategy::Never;
add_offending_validator::<T>(params.stash, disable_when_slashed);
add_offending_validator::<T>(params.stash, params.slash, disable_when_slashed);

let mut nominators_slashed = Vec::new();
reward_payout += slash_nominators::<T>(params.clone(), prior_slash_p, &mut nominators_slashed);
Expand Down Expand Up @@ -320,13 +321,17 @@ fn kick_out_if_recent<T: Config>(params: SlashParams<T>) {
}

let disable_without_slash = params.disable_strategy == DisableStrategy::Always;
add_offending_validator::<T>(params.stash, disable_without_slash);
add_offending_validator::<T>(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<T: Config>(stash: &T::AccountId, disable: bool) {
fn add_offending_validator<T: Config>(
stash: &T::AccountId,
slash_proportion: Perbill,
disable: bool,
) {
OffendingValidators::<T>::mutate(|offending| {
let validators = T::SessionInterface::validators();
let validator_index = match validators.iter().position(|i| i == stash) {
Expand All @@ -338,33 +343,136 @@ fn add_offending_validator<T: Config>(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
<Pallet<T>>::ensure_new_era()
}

if disable {
T::SessionInterface::disable_validator(validator_index_u32);
disable_new_offender::<T>(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::<T>(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::<T>(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<T: Config>(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<T: Config>(offence: &Perbill) -> bool {
let entropy = compute_entropy::<T>(<frame_system::Pallet<T>>::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<T: Config>(validators: Vec<u32>, desired_len: usize) -> Vec<u32> {
debug_assert!(validators.len() > desired_len);

let entropy = compute_entropy::<T>(<frame_system::Pallet<T>>::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::<Vec<_>>()
}

/// 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<T: Config>(current_offenders: &Vec<(u32, Perbill)>, new_offender: u32) {
const LIMIT: usize = 42; // TODO: extract this as a parameter

let currently_disabled = DisabledOffenders::<T>::get();

if currently_disabled.len() < LIMIT {
// we are below the limit - just disable
T::SessionInterface::disable_validator(new_offender);
add_to_disabled_offenders::<T>(new_offender);

return
}

// Now selectively disable based on the offence
let mut disabled = Vec::new();
for (v, o) in current_offenders {
if disable_rand::<T>(o) {
disabled.push(*v);
}
}

let disabled = if disabled.len() > LIMIT {
// remove `disabled.len() - LIMIT` random elements from `disabled`
remove_random_validators::<T>(disabled, LIMIT)
} else {
disabled
};

// update disabled list
DisabledOffenders::<T>::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.
Expand Down Expand Up @@ -689,6 +797,23 @@ fn pay_reporters<T: Config>(
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<T: Config>(validator_id: u32) {
DisabledOffenders::<T>::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<T: Config>(validator_id: u32) -> bool {
DisabledOffenders::<T>::get()
.binary_search_by_key(&validator_id, |k| *k)
.is_ok()
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down

0 comments on commit 6eef693

Please sign in to comment.