1717//! Dispute coordinator subsystem in initialized state (after first active leaf is received).
1818
1919use 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
5152use crate :: {
5253 db,
@@ -92,6 +93,9 @@ pub struct InitialData {
9293pub ( 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+ }
0 commit comments