Skip to content
This repository has been archived by the owner on Nov 15, 2023. It is now read-only.

Initial implementation of validator disabling strategy #14697

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions bin/node/runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,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 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 frame/staking/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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"]
Expand All @@ -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"]
9 changes: 9 additions & 0 deletions frame/staking/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -756,6 +756,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 @@ -787,6 +790,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 @@ -799,6 +806,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 frame/staking/src/pallet/impls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -377,10 +377,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 @@ -463,6 +461,7 @@ impl<T: Config> Pallet<T> {

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

Expand Down
27 changes: 22 additions & 5 deletions 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 @@ -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.
///
/// `<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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validator set might change on a new session. Is this taken into account?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good question. It's not taken into account in the old implementation (here) and we might disable a validator which is not an authority (unless this is checked in session pallet).

I'll check this further and fix it if necessary.

#[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 @@ -761,6 +777,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 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The very next session will already have a new validator set? When is the corresponding election happening?

<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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe proper fields would be better?

}
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()) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sub seems backwards.

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 {
Copy link
Member

@eskimor eskimor Aug 3, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could end up with disabled validators being way beyond LIMIT, which is not desired. (Might be a flaw in the "spec" already).

On a high level what we want is: Limit to LIMIT (which should be a percentage eventually), by preferring higher slashes to stay disabled. There was some concern about enabling 100% slashed validators, I am absolutely fine with only ever re-enabling 100% validators if there is no other option of adhering to the limit.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, disabled initially might have more validators than LIMIT but this is handled by remove_random_validators (two lines below). It ensures we don't disable too much validators.

by preferring higher slashes to stay disabled

I think this contradicts with the initial spec. We discussed it and decided that we can ignore slash amount for simplicity. Or I am missing something?

// remove `disabled.len() - LIMIT` random elements from `disabled`
remove_random_validators::<T>(disabled, LIMIT)
} else {
disabled
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about the new offender?

Copy link
Contributor Author

@tdimitrov tdimitrov Aug 9, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is one of the shortcuts I've made and not sure if it is sound or not.

The new offender might not get disabled thanks to the reshuffling. On second thought - this is not at all a good idea.


// 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
Loading