Skip to content

Commit 0ff3f4d

Browse files
ordiantdimitrov
andauthored
dispute-coordinator: disabling in participation (#2637)
Closes #2225. - [x] tests - [x] fix todos - [x] fix duplicates - [x] make the check part of `potential_spam` - [x] fix a bug with votes insertion - [x] guide changes - [x] docs --------- Co-authored-by: Tsvetomir Dimitrov <[email protected]>
1 parent a02b534 commit 0ff3f4d

File tree

12 files changed

+1135
-170
lines changed

12 files changed

+1135
-170
lines changed

polkadot/node/core/backing/src/lib.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1030,9 +1030,10 @@ async fn construct_per_relay_parent_state<Context>(
10301030
// Once runtime ver `DISABLED_VALIDATORS_RUNTIME_REQUIREMENT` is released remove this call to
10311031
// `get_disabled_validators_with_fallback`, add `request_disabled_validators` call to the
10321032
// `try_join!` above and use `try_runtime_api!` to get `disabled_validators`
1033-
let disabled_validators = get_disabled_validators_with_fallback(ctx.sender(), parent)
1034-
.await
1035-
.map_err(Error::UtilError)?;
1033+
let disabled_validators =
1034+
get_disabled_validators_with_fallback(ctx.sender(), parent).await.map_err(|e| {
1035+
Error::UtilError(TryFrom::try_from(e).expect("the conversion is infallible; qed"))
1036+
})?;
10361037

10371038
let signing_context = SigningContext { parent_hash: parent, session_index };
10381039
let validator = match Validator::construct(

polkadot/node/core/dispute-coordinator/src/import.rs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ pub struct CandidateEnvironment<'a> {
5252
executor_params: &'a ExecutorParams,
5353
/// Validator indices controlled by this node.
5454
controlled_indices: HashSet<ValidatorIndex>,
55+
/// Indices of disabled validators at the `relay_parent`.
56+
disabled_indices: HashSet<ValidatorIndex>,
5557
}
5658

5759
#[overseer::contextbounds(DisputeCoordinator, prefix = self::overseer)]
@@ -66,6 +68,16 @@ impl<'a> CandidateEnvironment<'a> {
6668
session_index: SessionIndex,
6769
relay_parent: Hash,
6870
) -> Option<CandidateEnvironment<'a>> {
71+
let disabled_indices = runtime_info
72+
.get_disabled_validators(ctx.sender(), relay_parent)
73+
.await
74+
.unwrap_or_else(|err| {
75+
gum::info!(target: LOG_TARGET, ?err, "Failed to get disabled validators");
76+
Vec::new()
77+
})
78+
.into_iter()
79+
.collect();
80+
6981
let (session, executor_params) = match runtime_info
7082
.get_session_info_by_index(ctx.sender(), relay_parent, session_index)
7183
.await
@@ -76,7 +88,7 @@ impl<'a> CandidateEnvironment<'a> {
7688
};
7789

7890
let controlled_indices = find_controlled_validator_indices(keystore, &session.validators);
79-
Some(Self { session_index, session, executor_params, controlled_indices })
91+
Some(Self { session_index, session, executor_params, controlled_indices, disabled_indices })
8092
}
8193

8294
/// Validators in the candidate's session.
@@ -103,6 +115,11 @@ impl<'a> CandidateEnvironment<'a> {
103115
pub fn controlled_indices(&'a self) -> &'a HashSet<ValidatorIndex> {
104116
&self.controlled_indices
105117
}
118+
119+
/// Indices of disabled validators at the `relay_parent`.
120+
pub fn disabled_indices(&'a self) -> &'a HashSet<ValidatorIndex> {
121+
&self.disabled_indices
122+
}
106123
}
107124

108125
/// Whether or not we already issued some statement about a candidate.
@@ -344,6 +361,14 @@ impl CandidateVoteState<CandidateVotes> {
344361
&self.votes.candidate_receipt
345362
}
346363

364+
/// Returns true if all the invalid votes are from disabled validators.
365+
pub fn invalid_votes_all_disabled(
366+
&self,
367+
mut is_disabled: impl FnMut(&ValidatorIndex) -> bool,
368+
) -> bool {
369+
self.votes.invalid.keys().all(|i| is_disabled(i))
370+
}
371+
347372
/// Extract `CandidateVotes` for handling import of new statements.
348373
fn into_old_state(self) -> (CandidateVotes, CandidateVoteState<()>) {
349374
let CandidateVoteState { votes, own_vote, dispute_status, byzantine_threshold_against } =

polkadot/node/core/dispute-coordinator/src/initialized.rs

Lines changed: 140 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
//! Dispute coordinator subsystem in initialized state (after first active leaf is received).
1818
1919
use std::{
20-
collections::{BTreeMap, VecDeque},
20+
collections::{BTreeMap, HashSet, VecDeque},
2121
sync::Arc,
2222
};
2323

@@ -47,6 +47,7 @@ use polkadot_primitives::{
4747
DisputeStatementSet, Hash, ScrapedOnChainVotes, SessionIndex, ValidDisputeStatementKind,
4848
ValidatorId, ValidatorIndex,
4949
};
50+
use schnellru::{LruMap, UnlimitedCompact};
5051

5152
use crate::{
5253
db,
@@ -92,6 +93,9 @@ pub struct InitialData {
9293
pub(crate) struct Initialized {
9394
keystore: Arc<LocalKeystore>,
9495
runtime_info: RuntimeInfo,
96+
/// We have the onchain state of disabled validators as well as the offchain
97+
/// state that is based on the lost disputes.
98+
offchain_disabled_validators: OffchainDisabledValidators,
9599
/// This is the highest `SessionIndex` seen via `ActiveLeavesUpdate`. It doesn't matter if it
96100
/// was cached successfully or not. It is used to detect ancient disputes.
97101
highest_session_seen: SessionIndex,
@@ -130,10 +134,12 @@ impl Initialized {
130134

131135
let (participation_sender, participation_receiver) = mpsc::channel(1);
132136
let participation = Participation::new(participation_sender, metrics.clone());
137+
let offchain_disabled_validators = OffchainDisabledValidators::default();
133138

134139
Self {
135140
keystore,
136141
runtime_info,
142+
offchain_disabled_validators,
137143
highest_session_seen,
138144
gaps_in_cache,
139145
spam_slots,
@@ -319,13 +325,16 @@ impl Initialized {
319325
self.runtime_info.pin_block(session_idx, new_leaf.unpin_handle);
320326
// Fetch the last `DISPUTE_WINDOW` number of sessions unless there are no gaps
321327
// in cache and we are not missing too many `SessionInfo`s
322-
let mut lower_bound = session_idx.saturating_sub(DISPUTE_WINDOW.get() - 1);
323-
if !self.gaps_in_cache && self.highest_session_seen > lower_bound {
324-
lower_bound = self.highest_session_seen + 1
325-
}
328+
let prune_up_to = session_idx.saturating_sub(DISPUTE_WINDOW.get() - 1);
329+
let fetch_lower_bound =
330+
if !self.gaps_in_cache && self.highest_session_seen > prune_up_to {
331+
self.highest_session_seen + 1
332+
} else {
333+
prune_up_to
334+
};
326335

327336
// There is a new session. Perform a dummy fetch to cache it.
328-
for idx in lower_bound..=session_idx {
337+
for idx in fetch_lower_bound..=session_idx {
329338
if let Err(err) = self
330339
.runtime_info
331340
.get_session_info_by_index(ctx.sender(), new_leaf.hash, idx)
@@ -344,11 +353,9 @@ impl Initialized {
344353

345354
self.highest_session_seen = session_idx;
346355

347-
db::v1::note_earliest_session(
348-
overlay_db,
349-
session_idx.saturating_sub(DISPUTE_WINDOW.get() - 1),
350-
)?;
351-
self.spam_slots.prune_old(session_idx.saturating_sub(DISPUTE_WINDOW.get() - 1));
356+
db::v1::note_earliest_session(overlay_db, prune_up_to)?;
357+
self.spam_slots.prune_old(prune_up_to);
358+
self.offchain_disabled_validators.prune_old(prune_up_to);
352359
},
353360
Ok(_) => { /* no new session => nothing to cache */ },
354361
Err(err) => {
@@ -978,11 +985,13 @@ impl Initialized {
978985
Some(env) => env,
979986
};
980987

988+
let n_validators = env.validators().len();
989+
981990
gum::trace!(
982991
target: LOG_TARGET,
983992
?candidate_hash,
984993
?session,
985-
num_validators = ?env.session_info().validators.len(),
994+
?n_validators,
986995
"Number of validators"
987996
);
988997

@@ -1084,18 +1093,42 @@ impl Initialized {
10841093
target: LOG_TARGET,
10851094
?candidate_hash,
10861095
?session,
1087-
num_validators = ?env.session_info().validators.len(),
1096+
?n_validators,
10881097
"Import result ready"
10891098
);
1099+
10901100
let new_state = import_result.new_state();
10911101

1102+
let byzantine_threshold = polkadot_primitives::byzantine_threshold(n_validators);
1103+
// combine on-chain with off-chain disabled validators
1104+
// process disabled validators in the following order:
1105+
// - on-chain disabled validators
1106+
// - prioritized order of off-chain disabled validators
1107+
// deduplicate the list and take at most `byzantine_threshold` validators
1108+
let disabled_validators = {
1109+
let mut d: HashSet<ValidatorIndex> = HashSet::new();
1110+
for v in env
1111+
.disabled_indices()
1112+
.iter()
1113+
.cloned()
1114+
.chain(self.offchain_disabled_validators.iter(session))
1115+
{
1116+
if d.len() == byzantine_threshold {
1117+
break
1118+
}
1119+
d.insert(v);
1120+
}
1121+
d
1122+
};
1123+
10921124
let is_included = self.scraper.is_candidate_included(&candidate_hash);
10931125
let is_backed = self.scraper.is_candidate_backed(&candidate_hash);
10941126
let own_vote_missing = new_state.own_vote_missing();
10951127
let is_disputed = new_state.is_disputed();
10961128
let is_confirmed = new_state.is_confirmed();
1097-
let potential_spam = is_potential_spam(&self.scraper, &new_state, &candidate_hash);
1098-
// We participate only in disputes which are not potential spam.
1129+
let potential_spam = is_potential_spam(&self.scraper, &new_state, &candidate_hash, |v| {
1130+
disabled_validators.contains(v)
1131+
});
10991132
let allow_participation = !potential_spam;
11001133

11011134
gum::trace!(
@@ -1106,6 +1139,7 @@ impl Initialized {
11061139
?candidate_hash,
11071140
confirmed = ?new_state.is_confirmed(),
11081141
has_invalid_voters = ?!import_result.new_invalid_voters().is_empty(),
1142+
n_disabled_validators = ?disabled_validators.len(),
11091143
"Is spam?"
11101144
);
11111145

@@ -1337,6 +1371,10 @@ impl Initialized {
13371371
);
13381372
}
13391373
}
1374+
for validator_index in new_state.votes().invalid.keys() {
1375+
self.offchain_disabled_validators
1376+
.insert_against_valid(session, *validator_index);
1377+
}
13401378
self.metrics.on_concluded_valid();
13411379
}
13421380
if import_result.is_freshly_concluded_against() {
@@ -1356,6 +1394,14 @@ impl Initialized {
13561394
);
13571395
}
13581396
}
1397+
for (validator_index, (kind, _sig)) in new_state.votes().valid.raw() {
1398+
let is_backer = kind.is_backing();
1399+
self.offchain_disabled_validators.insert_for_invalid(
1400+
session,
1401+
*validator_index,
1402+
is_backer,
1403+
);
1404+
}
13591405
self.metrics.on_concluded_invalid();
13601406
}
13611407

@@ -1591,3 +1637,82 @@ fn determine_undisputed_chain(
15911637

15921638
Ok(last)
15931639
}
1640+
1641+
#[derive(Default)]
1642+
struct OffchainDisabledValidators {
1643+
// Ideally, we want to use the top `byzantine_threshold` offenders here based on the amount of
1644+
// stake slashed. However, given that slashing might be applied with a delay, we want to have
1645+
// some list of offenders as soon as disputes conclude offchain. This list only approximates
1646+
// the top offenders and only accounts for lost disputes. But that should be good enough to
1647+
// prevent spam attacks.
1648+
per_session: BTreeMap<SessionIndex, LostSessionDisputes>,
1649+
}
1650+
1651+
struct LostSessionDisputes {
1652+
// We separate lost disputes to prioritize "for invalid" offenders. And among those, we
1653+
// prioritize backing votes the most. There's no need to limit the size of these sets, as they
1654+
// are already limited by the number of validators in the session. We use `LruMap` to ensure
1655+
// the iteration order prioritizes most recently disputes lost over older ones in case we reach
1656+
// the limit.
1657+
backers_for_invalid: LruMap<ValidatorIndex, (), UnlimitedCompact>,
1658+
for_invalid: LruMap<ValidatorIndex, (), UnlimitedCompact>,
1659+
against_valid: LruMap<ValidatorIndex, (), UnlimitedCompact>,
1660+
}
1661+
1662+
impl Default for LostSessionDisputes {
1663+
fn default() -> Self {
1664+
Self {
1665+
backers_for_invalid: LruMap::new(UnlimitedCompact),
1666+
for_invalid: LruMap::new(UnlimitedCompact),
1667+
against_valid: LruMap::new(UnlimitedCompact),
1668+
}
1669+
}
1670+
}
1671+
1672+
impl OffchainDisabledValidators {
1673+
fn prune_old(&mut self, up_to_excluding: SessionIndex) {
1674+
// split_off returns everything after the given key, including the key.
1675+
let mut relevant = self.per_session.split_off(&up_to_excluding);
1676+
std::mem::swap(&mut relevant, &mut self.per_session);
1677+
}
1678+
1679+
fn insert_for_invalid(
1680+
&mut self,
1681+
session_index: SessionIndex,
1682+
validator_index: ValidatorIndex,
1683+
is_backer: bool,
1684+
) {
1685+
let entry = self.per_session.entry(session_index).or_default();
1686+
if is_backer {
1687+
entry.backers_for_invalid.insert(validator_index, ());
1688+
} else {
1689+
entry.for_invalid.insert(validator_index, ());
1690+
}
1691+
}
1692+
1693+
fn insert_against_valid(
1694+
&mut self,
1695+
session_index: SessionIndex,
1696+
validator_index: ValidatorIndex,
1697+
) {
1698+
self.per_session
1699+
.entry(session_index)
1700+
.or_default()
1701+
.against_valid
1702+
.insert(validator_index, ());
1703+
}
1704+
1705+
/// Iterate over all validators that are offchain disabled.
1706+
/// The order of iteration prioritizes `for_invalid` offenders (and backers among those) over
1707+
/// `against_valid` offenders. And most recently lost disputes over older ones.
1708+
/// NOTE: the iterator might contain duplicates.
1709+
fn iter(&self, session_index: SessionIndex) -> impl Iterator<Item = ValidatorIndex> + '_ {
1710+
self.per_session.get(&session_index).into_iter().flat_map(|e| {
1711+
e.backers_for_invalid
1712+
.iter()
1713+
.chain(e.for_invalid.iter())
1714+
.chain(e.against_valid.iter())
1715+
.map(|(i, _)| *i)
1716+
})
1717+
}
1718+
}

polkadot/node/core/dispute-coordinator/src/lib.rs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -370,8 +370,10 @@ impl DisputeCoordinatorSubsystem {
370370
},
371371
};
372372
let vote_state = CandidateVoteState::new(votes, &env, now);
373-
374-
let potential_spam = is_potential_spam(&scraper, &vote_state, candidate_hash);
373+
let onchain_disabled = env.disabled_indices();
374+
let potential_spam = is_potential_spam(&scraper, &vote_state, candidate_hash, |v| {
375+
onchain_disabled.contains(v)
376+
});
375377
let is_included =
376378
scraper.is_candidate_included(&vote_state.votes().candidate_receipt.hash());
377379

@@ -462,17 +464,20 @@ async fn wait_for_first_leaf<Context>(ctx: &mut Context) -> Result<Option<Activa
462464
/// Check wheter a dispute for the given candidate could be spam.
463465
///
464466
/// That is the candidate could be made up.
465-
pub fn is_potential_spam<V>(
467+
pub fn is_potential_spam(
466468
scraper: &ChainScraper,
467-
vote_state: &CandidateVoteState<V>,
469+
vote_state: &CandidateVoteState<CandidateVotes>,
468470
candidate_hash: &CandidateHash,
471+
is_disabled: impl FnMut(&ValidatorIndex) -> bool,
469472
) -> bool {
470473
let is_disputed = vote_state.is_disputed();
471474
let is_included = scraper.is_candidate_included(candidate_hash);
472475
let is_backed = scraper.is_candidate_backed(candidate_hash);
473476
let is_confirmed = vote_state.is_confirmed();
477+
let all_invalid_votes_disabled = vote_state.invalid_votes_all_disabled(is_disabled);
478+
let ignore_disabled = !is_confirmed && all_invalid_votes_disabled;
474479

475-
is_disputed && !is_included && !is_backed && !is_confirmed
480+
(is_disputed && !is_included && !is_backed && !is_confirmed) || ignore_disabled
476481
}
477482

478483
/// Tell dispute-distribution to send all our votes.

polkadot/node/core/dispute-coordinator/src/participation/tests.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,6 @@ fn cannot_participate_if_cannot_recover_validation_code() {
372372
let mut participation = Participation::new(sender, Metrics::default());
373373
activate_leaf(&mut ctx, &mut participation, 10).await.unwrap();
374374
participate(&mut ctx, &mut participation).await.unwrap();
375-
376375
recover_available_data(&mut ctx_handle).await;
377376

378377
assert_matches!(

0 commit comments

Comments
 (0)