diff --git a/node/core/dispute-coordinator/src/import.rs b/node/core/dispute-coordinator/src/import.rs index 28eacffab861..84adae167c7a 100644 --- a/node/core/dispute-coordinator/src/import.rs +++ b/node/core/dispute-coordinator/src/import.rs @@ -28,7 +28,9 @@ use std::collections::{BTreeMap, HashMap, HashSet}; -use polkadot_node_primitives::{CandidateVotes, DisputeStatus, SignedDisputeStatement, Timestamp}; +use polkadot_node_primitives::{ + disputes::ValidCandidateVotes, CandidateVotes, DisputeStatus, SignedDisputeStatement, Timestamp, +}; use polkadot_node_subsystem_util::rolling_session_window::RollingSessionWindow; use polkadot_primitives::v2::{ CandidateReceipt, DisputeStatement, IndexedVec, SessionIndex, SessionInfo, @@ -101,7 +103,7 @@ impl OwnVoteState { let mut our_valid_votes = env .controlled_indices() .iter() - .filter_map(|i| votes.valid.get_key_value(i)) + .filter_map(|i| votes.valid.raw().get_key_value(i)) .peekable(); let mut our_invalid_votes = env.controlled_indices.iter().filter_map(|i| votes.invalid.get_key_value(i)); @@ -163,8 +165,11 @@ impl CandidateVoteState { /// /// in case there have not been any previous votes. pub fn new_from_receipt(candidate_receipt: CandidateReceipt) -> Self { - let votes = - CandidateVotes { candidate_receipt, valid: BTreeMap::new(), invalid: BTreeMap::new() }; + let votes = CandidateVotes { + candidate_receipt, + valid: ValidCandidateVotes::new(), + invalid: BTreeMap::new(), + }; Self { votes, own_vote: OwnVoteState::NoVote, dispute_status: None } } @@ -178,7 +183,7 @@ impl CandidateVoteState { polkadot_primitives::v2::supermajority_threshold(n_validators); // We have a dispute, if we have votes on both sides: - let is_disputed = !votes.invalid.is_empty() && !votes.valid.is_empty(); + let is_disputed = !votes.invalid.is_empty() && !votes.valid.raw().is_empty(); let dispute_status = if is_disputed { let mut status = DisputeStatus::active(); @@ -187,7 +192,7 @@ impl CandidateVoteState { if is_confirmed { status = status.confirm(); }; - let concluded_for = votes.valid.len() >= supermajority_threshold; + let concluded_for = votes.valid.raw().len() >= supermajority_threshold; if concluded_for { status = status.conclude_for(now); }; @@ -262,25 +267,20 @@ impl CandidateVoteState { match statement.statement() { DisputeStatement::Valid(valid_kind) => { - let fresh = insert_into_statements( - &mut votes.valid, - *valid_kind, + let fresh = votes.valid.insert_vote( val_index, + *valid_kind, statement.into_validator_signature(), ); - if fresh { imported_valid_votes += 1; } }, DisputeStatement::Invalid(invalid_kind) => { - let fresh = insert_into_statements( - &mut votes.invalid, - *invalid_kind, - val_index, - statement.into_validator_signature(), - ); - + let fresh = votes + .invalid + .insert(val_index, (*invalid_kind, statement.into_validator_signature())) + .is_none(); if fresh { new_invalid_voters.push(val_index); imported_invalid_votes += 1; @@ -481,12 +481,7 @@ impl ImportResult { }, "Signature check for imported approval votes failed! This is a serious bug. Session: {:?}, candidate hash: {:?}, validator index: {:?}", env.session_index(), votes.candidate_receipt.hash(), index ); - if insert_into_statements( - &mut votes.valid, - ValidDisputeStatementKind::ApprovalChecking, - index, - sig, - ) { + if votes.valid.insert_vote(index, ValidDisputeStatementKind::ApprovalChecking, sig) { imported_valid_votes += 1; imported_approval_votes += 1; } @@ -535,14 +530,3 @@ fn find_controlled_validator_indices( controlled } - -// Returns 'true' if no other vote by that validator was already -// present and 'false' otherwise. Same semantics as `HashSet`. -fn insert_into_statements( - m: &mut BTreeMap, - tag: T, - val_index: ValidatorIndex, - val_signature: ValidatorSignature, -) -> bool { - m.insert(val_index, (tag, val_signature)).is_none() -} diff --git a/node/core/dispute-coordinator/src/initialized.rs b/node/core/dispute-coordinator/src/initialized.rs index a9c174921749..1313eb600052 100644 --- a/node/core/dispute-coordinator/src/initialized.rs +++ b/node/core/dispute-coordinator/src/initialized.rs @@ -26,8 +26,8 @@ use futures::{ use sc_keystore::LocalKeystore; use polkadot_node_primitives::{ - CandidateVotes, DisputeMessage, DisputeMessageCheckError, DisputeStatus, - SignedDisputeStatement, Timestamp, + disputes::ValidCandidateVotes, CandidateVotes, DisputeMessage, DisputeMessageCheckError, + DisputeStatus, SignedDisputeStatement, Timestamp, }; use polkadot_node_subsystem::{ messages::{ @@ -1024,7 +1024,7 @@ impl Initialized { imported_approval_votes = ?import_result.imported_approval_votes(), imported_valid_votes = ?import_result.imported_valid_votes(), imported_invalid_votes = ?import_result.imported_invalid_votes(), - total_valid_votes = ?import_result.new_state().votes().valid.len(), + total_valid_votes = ?import_result.new_state().votes().valid.raw().len(), total_invalid_votes = ?import_result.new_state().votes().invalid.len(), confirmed = ?import_result.new_state().is_confirmed(), "Import summary" @@ -1099,7 +1099,7 @@ impl Initialized { .map(CandidateVotes::from) .unwrap_or_else(|| CandidateVotes { candidate_receipt: candidate_receipt.clone(), - valid: BTreeMap::new(), + valid: ValidCandidateVotes::new(), invalid: BTreeMap::new(), }); @@ -1272,8 +1272,12 @@ fn make_dispute_message( .map_err(|()| DisputeMessageCreationError::InvalidStoredStatement)?; (our_vote, our_index, other_vote, *validator_index) } else { - let (validator_index, (statement_kind, validator_signature)) = - votes.valid.iter().next().ok_or(DisputeMessageCreationError::NoOppositeVote)?; + let (validator_index, (statement_kind, validator_signature)) = votes + .valid + .raw() + .iter() + .next() + .ok_or(DisputeMessageCreationError::NoOppositeVote)?; let other_vote = SignedDisputeStatement::new_checked( DisputeStatement::Valid(*statement_kind), *our_vote.candidate_hash(), diff --git a/node/core/dispute-coordinator/src/scraping/mod.rs b/node/core/dispute-coordinator/src/scraping/mod.rs index 99a6e68cdfb5..7f4f819756e3 100644 --- a/node/core/dispute-coordinator/src/scraping/mod.rs +++ b/node/core/dispute-coordinator/src/scraping/mod.rs @@ -19,7 +19,7 @@ use std::num::NonZeroUsize; use futures::channel::oneshot; use lru::LruCache; -use polkadot_node_primitives::MAX_FINALITY_LAG; +use polkadot_node_primitives::{DISPUTE_CANDIDATE_LIFETIME_AFTER_FINALIZATION, MAX_FINALITY_LAG}; use polkadot_node_subsystem::{ messages::ChainApiMessage, overseer, ActivatedLeaf, ActiveLeavesUpdate, ChainApiError, SubsystemSender, @@ -65,7 +65,7 @@ const LRU_OBSERVED_BLOCKS_CAPACITY: NonZeroUsize = match NonZeroUsize::new(20) { /// With this information it provides a `CandidateComparator` and as a return value of /// `process_active_leaves_update` any scraped votes. /// -/// Scraped candidates are available `CANDIDATE_LIFETIME_AFTER_FINALIZATION` more blocks +/// Scraped candidates are available `DISPUTE_CANDIDATE_LIFETIME_AFTER_FINALIZATION` more blocks /// after finalization as a precaution not to prune them prematurely. pub struct ChainScraper { /// All candidates we have seen included, which not yet have been finalized. @@ -87,16 +87,6 @@ impl ChainScraper { /// As long as we have `MAX_FINALITY_LAG` this makes sense as a value. pub(crate) const ANCESTRY_SIZE_LIMIT: u32 = MAX_FINALITY_LAG; - /// How many blocks after finalization a backed/included candidate should be kept. - /// We don't want to remove scraped candidates on finalization because we want to - /// be sure that disputes will conclude on abandoned forks. - /// Removing the candidate on finalization creates a possibility for an attacker to - /// avoid slashing. If a bad fork is abandoned too quickly because in the same another - /// better one gets finalized the entries for the bad fork will be pruned and we - /// will never participate in a dispute for it. We want such disputes to conclude - /// in a timely manner so that the offenders are slashed. - pub(crate) const CANDIDATE_LIFETIME_AFTER_FINALIZATION: BlockNumber = 2; - /// Create a properly initialized `OrderingProvider`. /// /// Returns: `Self` and any scraped votes. @@ -175,12 +165,13 @@ impl ChainScraper { /// Prune finalized candidates. /// - /// We keep each candidate for `CANDIDATE_LIFETIME_AFTER_FINALIZATION` blocks after finalization. + /// We keep each candidate for `DISPUTE_CANDIDATE_LIFETIME_AFTER_FINALIZATION` blocks after finalization. /// After that we treat it as low priority. pub fn process_finalized_block(&mut self, finalized_block_number: &BlockNumber) { - // `CANDIDATE_LIFETIME_AFTER_FINALIZATION - 1` because `finalized_block_number`counts to the + // `DISPUTE_CANDIDATE_LIFETIME_AFTER_FINALIZATION - 1` because `finalized_block_number`counts to the // candidate lifetime. - match finalized_block_number.checked_sub(Self::CANDIDATE_LIFETIME_AFTER_FINALIZATION - 1) { + match finalized_block_number.checked_sub(DISPUTE_CANDIDATE_LIFETIME_AFTER_FINALIZATION - 1) + { Some(key_to_prune) => { self.backed_candidates.remove_up_to_height(&key_to_prune); self.included_candidates.remove_up_to_height(&key_to_prune); diff --git a/node/core/dispute-coordinator/src/scraping/tests.rs b/node/core/dispute-coordinator/src/scraping/tests.rs index 6251891af5a3..3a6befa2002d 100644 --- a/node/core/dispute-coordinator/src/scraping/tests.rs +++ b/node/core/dispute-coordinator/src/scraping/tests.rs @@ -23,6 +23,7 @@ use parity_scale_codec::Encode; use sp_core::testing::TaskExecutor; use ::test_helpers::{dummy_collator, dummy_collator_signature, dummy_hash}; +use polkadot_node_primitives::DISPUTE_CANDIDATE_LIFETIME_AFTER_FINALIZATION; use polkadot_node_subsystem::{ jaeger, messages::{ @@ -452,9 +453,9 @@ fn scraper_prunes_finalized_candidates() { let candidate = make_candidate_receipt(get_block_number_hash(TEST_TARGET_BLOCK_NUMBER)); - // After `CANDIDATE_LIFETIME_AFTER_FINALIZATION` blocks the candidate should be removed + // After `DISPUTE_CANDIDATE_LIFETIME_AFTER_FINALIZATION` blocks the candidate should be removed finalized_block_number = - TEST_TARGET_BLOCK_NUMBER + ChainScraper::CANDIDATE_LIFETIME_AFTER_FINALIZATION; + TEST_TARGET_BLOCK_NUMBER + DISPUTE_CANDIDATE_LIFETIME_AFTER_FINALIZATION; process_finalized_block(&mut scraper, &finalized_block_number); assert!(!scraper.is_candidate_backed(&candidate.hash())); @@ -512,10 +513,10 @@ fn scraper_handles_backed_but_not_included_candidate() { // The candidate should be removed. assert!( finalized_block_number < - TEST_TARGET_BLOCK_NUMBER + ChainScraper::CANDIDATE_LIFETIME_AFTER_FINALIZATION + TEST_TARGET_BLOCK_NUMBER + DISPUTE_CANDIDATE_LIFETIME_AFTER_FINALIZATION ); finalized_block_number += - TEST_TARGET_BLOCK_NUMBER + ChainScraper::CANDIDATE_LIFETIME_AFTER_FINALIZATION; + TEST_TARGET_BLOCK_NUMBER + DISPUTE_CANDIDATE_LIFETIME_AFTER_FINALIZATION; process_finalized_block(&mut scraper, &finalized_block_number); assert!(!scraper.is_candidate_included(&candidate.hash())); @@ -562,7 +563,7 @@ fn scraper_handles_the_same_candidate_incuded_in_two_different_block_heights() { // Finalize blocks to enforce pruning of scraped events. // The magic candidate was added twice, so it shouldn't be removed if we finalize two more blocks. finalized_block_number = test_targets.first().expect("there are two block nums") + - ChainScraper::CANDIDATE_LIFETIME_AFTER_FINALIZATION; + DISPUTE_CANDIDATE_LIFETIME_AFTER_FINALIZATION; process_finalized_block(&mut scraper, &finalized_block_number); let magic_candidate = make_candidate_receipt(get_magic_candidate_hash()); diff --git a/node/core/dispute-coordinator/src/tests.rs b/node/core/dispute-coordinator/src/tests.rs index 4d32620946e0..309bd91e44d5 100644 --- a/node/core/dispute-coordinator/src/tests.rs +++ b/node/core/dispute-coordinator/src/tests.rs @@ -706,7 +706,7 @@ fn too_many_unconfirmed_statements_are_considered_spam() { .await; let (_, _, votes) = rx.await.unwrap().get(0).unwrap().clone(); - assert_eq!(votes.valid.len(), 1); + assert_eq!(votes.valid.raw().len(), 1); assert_eq!(votes.invalid.len(), 1); } @@ -839,8 +839,11 @@ fn approval_vote_import_works() { .await; let (_, _, votes) = rx.await.unwrap().get(0).unwrap().clone(); - assert_eq!(votes.valid.len(), 2); - assert!(votes.valid.get(&ValidatorIndex(4)).is_some(), "Approval vote is missing!"); + assert_eq!(votes.valid.raw().len(), 2); + assert!( + votes.valid.raw().get(&ValidatorIndex(4)).is_some(), + "Approval vote is missing!" + ); assert_eq!(votes.invalid.len(), 1); } @@ -964,7 +967,7 @@ fn dispute_gets_confirmed_via_participation() { .await; let (_, _, votes) = rx.await.unwrap().get(0).unwrap().clone(); - assert_eq!(votes.valid.len(), 2); + assert_eq!(votes.valid.raw().len(), 2); assert_eq!(votes.invalid.len(), 1); } @@ -999,7 +1002,7 @@ fn dispute_gets_confirmed_via_participation() { .await; let (_, _, votes) = rx.await.unwrap().get(0).unwrap().clone(); - assert_eq!(votes.valid.len(), 1); + assert_eq!(votes.valid.raw().len(), 1); assert_eq!(votes.invalid.len(), 1); } @@ -1132,7 +1135,7 @@ fn dispute_gets_confirmed_at_byzantine_threshold() { .await; let (_, _, votes) = rx.await.unwrap().get(0).unwrap().clone(); - assert_eq!(votes.valid.len(), 2); + assert_eq!(votes.valid.raw().len(), 2); assert_eq!(votes.invalid.len(), 2); } @@ -1167,7 +1170,7 @@ fn dispute_gets_confirmed_at_byzantine_threshold() { .await; let (_, _, votes) = rx.await.unwrap().get(0).unwrap().clone(); - assert_eq!(votes.valid.len(), 1); + assert_eq!(votes.valid.raw().len(), 1); assert_eq!(votes.invalid.len(), 1); } @@ -1246,7 +1249,7 @@ fn backing_statements_import_works_and_no_spam() { .await; let (_, _, votes) = rx.await.unwrap().get(0).unwrap().clone(); - assert_eq!(votes.valid.len(), 2); + assert_eq!(votes.valid.raw().len(), 2); assert_eq!(votes.invalid.len(), 0); } @@ -1394,7 +1397,7 @@ fn conflicting_votes_lead_to_dispute_participation() { .await; let (_, _, votes) = rx.await.unwrap().get(0).unwrap().clone(); - assert_eq!(votes.valid.len(), 2); + assert_eq!(votes.valid.raw().len(), 2); assert_eq!(votes.invalid.len(), 1); } @@ -1421,7 +1424,7 @@ fn conflicting_votes_lead_to_dispute_participation() { .await; let (_, _, votes) = rx.await.unwrap().get(0).unwrap().clone(); - assert_eq!(votes.valid.len(), 2); + assert_eq!(votes.valid.raw().len(), 2); assert_eq!(votes.invalid.len(), 2); } @@ -1505,7 +1508,7 @@ fn positive_votes_dont_trigger_participation() { .await; let (_, _, votes) = rx.await.unwrap().get(0).unwrap().clone(); - assert_eq!(votes.valid.len(), 1); + assert_eq!(votes.valid.raw().len(), 1); assert!(votes.invalid.is_empty()); } @@ -1541,7 +1544,7 @@ fn positive_votes_dont_trigger_participation() { .await; let (_, _, votes) = rx.await.unwrap().get(0).unwrap().clone(); - assert_eq!(votes.valid.len(), 2); + assert_eq!(votes.valid.raw().len(), 2); assert!(votes.invalid.is_empty()); } @@ -3075,7 +3078,7 @@ fn refrain_from_participation() { .await; let (_, _, votes) = rx.await.unwrap().get(0).unwrap().clone(); - assert_eq!(votes.valid.len(), 1); + assert_eq!(votes.valid.raw().len(), 1); assert_eq!(votes.invalid.len(), 1); } @@ -3183,7 +3186,7 @@ fn participation_for_included_candidates() { .await; let (_, _, votes) = rx.await.unwrap().get(0).unwrap().clone(); - assert_eq!(votes.valid.len(), 2); // 2 => we have participated + assert_eq!(votes.valid.raw().len(), 2); // 2 => we have participated assert_eq!(votes.invalid.len(), 1); } diff --git a/node/core/provisioner/src/disputes/prioritized_selection/mod.rs b/node/core/provisioner/src/disputes/prioritized_selection/mod.rs index 4231dc840c2c..8ac582bb1cc4 100644 --- a/node/core/provisioner/src/disputes/prioritized_selection/mod.rs +++ b/node/core/provisioner/src/disputes/prioritized_selection/mod.rs @@ -216,7 +216,7 @@ where // Check if votes are within the limit for (session_index, candidate_hash, selected_votes) in votes { - let votes_len = selected_votes.valid.len() + selected_votes.invalid.len(); + let votes_len = selected_votes.valid.raw().len() + selected_votes.invalid.len(); if votes_len + total_votes_len > MAX_DISPUTE_VOTES_FORWARDED_TO_RUNTIME { // we are done - no more votes can be added return result diff --git a/node/core/provisioner/src/disputes/prioritized_selection/tests.rs b/node/core/provisioner/src/disputes/prioritized_selection/tests.rs index 1f8d4f180263..982d19356e6a 100644 --- a/node/core/provisioner/src/disputes/prioritized_selection/tests.rs +++ b/node/core/provisioner/src/disputes/prioritized_selection/tests.rs @@ -393,7 +393,9 @@ impl TestDisputes { ValidDisputeStatementKind::Explicit, 0, local_votes_count, - ), + ) + .into_iter() + .collect(), invalid: BTreeMap::new(), }, ); diff --git a/node/network/dispute-distribution/src/sender/mod.rs b/node/network/dispute-distribution/src/sender/mod.rs index 8cecc96c8dc7..c0cb7c1c0acc 100644 --- a/node/network/dispute-distribution/src/sender/mod.rs +++ b/node/network/dispute-distribution/src/sender/mod.rs @@ -279,7 +279,7 @@ impl DisputeSender { Some(votes) => votes, }; - let our_valid_vote = votes.valid.get(&our_index); + let our_valid_vote = votes.valid.raw().get(&our_index); let our_invalid_vote = votes.invalid.get(&our_index); @@ -291,7 +291,7 @@ impl DisputeSender { } else if let Some(our_invalid_vote) = our_invalid_vote { // Get some valid vote as well: let valid_vote = - votes.valid.iter().next().ok_or(JfyiError::MissingVotesFromCoordinator)?; + votes.valid.raw().iter().next().ok_or(JfyiError::MissingVotesFromCoordinator)?; (valid_vote, (&our_index, our_invalid_vote)) } else { // There is no vote from us yet - nothing to do. diff --git a/node/primitives/src/disputes/mod.rs b/node/primitives/src/disputes/mod.rs index ee047c7bcc22..bad2ddbffc46 100644 --- a/node/primitives/src/disputes/mod.rs +++ b/node/primitives/src/disputes/mod.rs @@ -14,7 +14,10 @@ // You should have received a copy of the GNU General Public License // along with Polkadot. If not, see . -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{ + btree_map::{Entry as Bentry, Keys as Bkeys}, + BTreeMap, BTreeSet, +}; use parity_scale_codec::{Decode, Encode}; @@ -49,7 +52,7 @@ pub struct CandidateVotes { /// The receipt of the candidate itself. pub candidate_receipt: CandidateReceipt, /// Votes of validity, sorted by validator index. - pub valid: BTreeMap, + pub valid: ValidCandidateVotes, /// Votes of invalidity, sorted by validator index. pub invalid: BTreeMap, } @@ -69,6 +72,99 @@ impl CandidateVotes { } } +#[derive(Debug, Clone)] +/// Valid candidate votes. +/// +/// Prefere backing votes over other votes. +pub struct ValidCandidateVotes { + votes: BTreeMap, +} + +impl ValidCandidateVotes { + /// Create new empty `ValidCandidateVotes` + pub fn new() -> Self { + Self { votes: BTreeMap::new() } + } + /// Insert a vote, replacing any already existing vote. + /// + /// Except, for backing votes: Backing votes are always kept, and will never get overridden. + /// Import of other king of `valid` votes, will be ignored if a backing vote is already + /// present. Any already existing `valid` vote, will be overridden by any given backing vote. + /// + /// Returns: true, if the insert had any effect. + pub fn insert_vote( + &mut self, + validator_index: ValidatorIndex, + kind: ValidDisputeStatementKind, + sig: ValidatorSignature, + ) -> bool { + match self.votes.entry(validator_index) { + Bentry::Vacant(vacant) => { + vacant.insert((kind, sig)); + true + }, + Bentry::Occupied(mut occupied) => match occupied.get().0 { + ValidDisputeStatementKind::BackingValid(_) | + ValidDisputeStatementKind::BackingSeconded(_) => false, + ValidDisputeStatementKind::Explicit | + ValidDisputeStatementKind::ApprovalChecking => { + occupied.insert((kind, sig)); + kind != occupied.get().0 + }, + }, + } + } + + /// Retain any votes that match the given criteria. + pub fn retain(&mut self, f: F) + where + F: FnMut(&ValidatorIndex, &mut (ValidDisputeStatementKind, ValidatorSignature)) -> bool, + { + self.votes.retain(f) + } + + /// Get all the validator indeces we have votes for. + pub fn keys( + &self, + ) -> Bkeys<'_, ValidatorIndex, (ValidDisputeStatementKind, ValidatorSignature)> { + self.votes.keys() + } + + /// Get read only direct access to underlying map. + pub fn raw( + &self, + ) -> &BTreeMap { + &self.votes + } +} + +impl FromIterator<(ValidatorIndex, (ValidDisputeStatementKind, ValidatorSignature))> + for ValidCandidateVotes +{ + fn from_iter(iter: T) -> Self + where + T: IntoIterator, + { + Self { votes: BTreeMap::from_iter(iter) } + } +} + +impl From + for BTreeMap +{ + fn from(wrapped: ValidCandidateVotes) -> Self { + wrapped.votes + } +} +impl IntoIterator for ValidCandidateVotes { + type Item = (ValidatorIndex, (ValidDisputeStatementKind, ValidatorSignature)); + type IntoIter = as IntoIterator>::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.votes.into_iter() + } +} + impl SignedDisputeStatement { /// Create a new `SignedDisputeStatement` from information /// that is available on-chain, and hence already can be trusted. diff --git a/node/primitives/src/lib.rs b/node/primitives/src/lib.rs index f9403ea6c186..da0a0eca80be 100644 --- a/node/primitives/src/lib.rs +++ b/node/primitives/src/lib.rs @@ -30,10 +30,10 @@ use parity_scale_codec::{Decode, Encode, Error as CodecError, Input}; use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; use polkadot_primitives::v2::{ - BlakeTwo256, CandidateCommitments, CandidateHash, CollatorPair, CommittedCandidateReceipt, - CompactStatement, EncodeAs, Hash, HashT, HeadData, Id as ParaId, OutboundHrmpMessage, - PersistedValidationData, SessionIndex, Signed, UncheckedSigned, UpwardMessage, ValidationCode, - ValidatorIndex, MAX_CODE_SIZE, MAX_POV_SIZE, + BlakeTwo256, BlockNumber, CandidateCommitments, CandidateHash, CollatorPair, + CommittedCandidateReceipt, CompactStatement, EncodeAs, Hash, HashT, HeadData, Id as ParaId, + OutboundHrmpMessage, PersistedValidationData, SessionIndex, Signed, UncheckedSigned, + UpwardMessage, ValidationCode, ValidatorIndex, MAX_CODE_SIZE, MAX_POV_SIZE, }; pub use sp_consensus_babe::{ AllowedSlots as BabeAllowedSlots, BabeEpochConfiguration, Epoch as BabeEpoch, @@ -73,8 +73,28 @@ pub const BACKING_EXECUTION_TIMEOUT: Duration = Duration::from_secs(2); /// ensure that in the absence of extremely large disparities between hardware, /// blocks that pass backing are considered executable by approval checkers or /// dispute participants. +/// +/// NOTE: If this value is increased significantly, also check the dispute coordinator to consider +/// candidates longer into finalization: `DISPUTE_CANDIDATE_LIFETIME_AFTER_FINALIZATION`. pub const APPROVAL_EXECUTION_TIMEOUT: Duration = Duration::from_secs(12); +/// How many blocks after finalization an information about backed/included candidate should be +/// kept. +/// +/// We don't want to remove scraped candidates on finalization because we want to +/// be sure that disputes will conclude on abandoned forks. +/// Removing the candidate on finalization creates a possibility for an attacker to +/// avoid slashing. If a bad fork is abandoned too quickly because another +/// better one gets finalized the entries for the bad fork will be pruned and we +/// might never participate in a dispute for it. +/// +/// This value should consider the timeout we allow for participation in approval-voting. In +/// particular, the following condition should hold: +/// +/// slot time * `DISPUTE_CANDIDATE_LIFETIME_AFTER_FINALIZATION` > `APPROVAL_EXECUTION_TIMEOUT` +/// + slot time +pub const DISPUTE_CANDIDATE_LIFETIME_AFTER_FINALIZATION: BlockNumber = 10; + /// Linked to `MAX_FINALITY_LAG` in relay chain selection, /// `MAX_HEADS_LOOK_BACK` in `approval-voting` and /// `MAX_BATCH_SCRAPE_ANCESTORS` in `dispute-coordinator` diff --git a/roadmap/implementers-guide/src/node/disputes/dispute-coordinator.md b/roadmap/implementers-guide/src/node/disputes/dispute-coordinator.md index 07fc647a711c..260f5843d0e2 100644 --- a/roadmap/implementers-guide/src/node/disputes/dispute-coordinator.md +++ b/roadmap/implementers-guide/src/node/disputes/dispute-coordinator.md @@ -9,12 +9,14 @@ In particular the dispute-coordinator is responsible for: - Ensuring that the node is able to raise a dispute in case an invalid candidate is found during approval checking. -- Ensuring lazy approval votes (votes given without running the parachain - validation function) will be recorded, so lazy nodes can get slashed properly. +- Ensuring approval votes will be recorded. - Coordinating actual participation in a dispute, ensuring that the node participates in any justified dispute in a way that ensures resolution of disputes on the network even in the case of many disputes raised (flood/DoS scenario). +- Ensuring disputes resolve, even for candidates on abandoned forks as much as + reasonably possible, to rule out "free tries" and thus guarantee our gambler's + ruin property. - Provide an API for chain selection, so we can prevent finalization of any chain which has included candidates for which a dispute is either ongoing or concluded invalid and avoid building on chains with an included invalid @@ -22,6 +24,8 @@ In particular the dispute-coordinator is responsible for: - Provide an API for retrieving (resolved) disputes, including all votes, both implicit (approval, backing) and explicit dispute votes. So validators can get rewarded/slashed accordingly. +- Ensure backing votes are recorded and will never get overridden by explicit + votes. ## Ensuring That Disputes Can Be Raised @@ -76,32 +80,38 @@ inefficient process. (Quadratic complexity adds up, with 35 votes in total per c Approval votes are very relevant nonetheless as we are going to see in the next section. -## Ensuring Lazy Approval Votes Will Be Recorded +## Ensuring approval votes will be recorded ### Ensuring Recording +Only votes recorded by the dispute coordinator will be considered for slashing. + While there is no need to record approval votes in the dispute coordinator -preemptively, we do need to make sure they are recorded when a dispute -actually happens. This is because only votes recorded by the dispute -coordinator will be considered for slashing. It is sufficient for our -threat model that malicious backers are slashed as opposed to both backers and -approval checkers. However, we still must import approval votes from the approvals -process into the disputes process to ensure that lazy approval checkers -actually run the parachain validation function. Slashing lazy approval checkers is necessary, else we risk a useless approvals process where every approval -checker blindly votes valid for every candidate. If we did not import approval -votes, lazy nodes would likely cast a properly checked explicit vote as part -of the dispute in addition to their blind approval vote and thus avoid a slash. -With the 2/3rd honest assumption it seems unrealistic that lazy approval voters -will keep sending unchecked approval votes once they became aware of a raised -dispute. Hence the most crucial approval votes to import are the early ones -(tranche 0), to take into account network latencies and such we still want to -import approval votes at a later point in time as well (in particular we need -to make sure the dispute can conclude, but more on that later). - -As mentioned already previously, importing votes is most efficient when batched. -At the same time approval voting and disputes are running concurrently so -approval votes are expected to trickle in still, when a dispute is already -ongoing. +preemptively, we make some effort to have any in approval-voting received +approval votes recorded when a dispute actually happens: + +This is not required for concluding the dispute, as nodes send their own vote +anyway (either explicit valid or their existing approval-vote). What nodes can +do though, is participating in approval-voting, casting a vote, but later when a +dispute is raised reconsider their vote and send an explicit invalid vote. If +they managed to only have that one recorded, then they could avoid a slash. + +This is not a problem for our basic security assumptions: The backers are the +ones to be supposed to have skin in the game, so we are not too woried about +colluding approval voters getting away slash free as the gambler's ruin property +is maintained anyway. There is however a separate problem, from colluding +approval-voters, that is "lazy" approval voters. If it were easy and reliable +for approval-voters to reconsider their vote, in case of an actual dispute, then +they don't have a direct incentive (apart from playing a part in securing the +network) to properly run the validation function at all - they could just always +vote "valid" totally risk free. (While they would alwasy risk a slash by voting +invalid.) + + +So we do want to fetch approval votes from approval-voting. Importing votes is +most efficient when batched. At the same time approval voting and disputes are +running concurrently so approval votes are expected to trickle in still, when a +dispute is already ongoing. Hence, we have the following requirements for importing approval votes: @@ -112,12 +122,12 @@ Hence, we have the following requirements for importing approval votes: already running. With a design where approval voting sends votes to the dispute-coordinator by -itself, we would need to make approval voting aware of ongoing disputes and -once it is aware it could start sending all already existing votes batched and +itself, we would need to make approval voting aware of ongoing disputes and once +it is aware it could start sending all already existing votes batched and trickling in votes as they come. The problem with this is, that it adds some unnecessary complexity to approval-voting and also we might still import most of -the votes unbatched, but one-by-one, depending on what point in time the dispute -was raised. +the votes unbatched one-by-one, depending on what point in time the dispute was +raised. Instead of the dispute coordinator informing approval-voting of an ongoing dispute for it to begin forwarding votes to the dispute coordinator, it makes @@ -126,14 +136,11 @@ candidates in dispute. This way, the dispute coordinator can also pick the best time for maximizing the number of votes in the batch. Now the question remains, when should the dispute coordinator ask -approval-voting for votes? As argued above already, querying approval votes at -the beginning of the dispute, will likely already take care of most malicious -votes. Still we would like to have a record of all, if possible. So what are -other points in time we might query approval votes? +approval-voting for votes? In fact for slashing it is only relevant to have them once the dispute concluded, so we can query approval voting the moment the dispute concludes! -There are two potential caveats with this though: +Two concerns that come to mind, are easily addressed: 1. Timing: We would like to rely as little as possible on implementation details of approval voting. In particular, if the dispute is ongoing for a long time, @@ -153,31 +160,12 @@ There are two potential caveats with this though: chain can no longer be finalized (some other/better fork already has been). This second problem can somehow be mitigated by also importing votes as soon as a dispute is detected, but not fully resolved. It is still inherently - racy. The problem can be solved in at least two ways: Either go back to full - eager import of approval votes into the dispute-coordinator in some more - efficient manner or by changing requirements on approval-voting, making it - hold on votes longer than necessary for approval-voting itself. Conceptually - both solutions are equivalent, as we make sure votes are available even - without an ongoing dispute. For now, in the interest of time we punt on this - issue: If nodes import votes as soon as a dispute is raised in addition to - when it concludes, we have a good chance of getting relevant votes and even - if not, the fundamental security properties will still hold: Backers are - getting slashed, therefore gambler's ruin is maintained. We would still like - to fix this at [some - point](https://github.com/paritytech/polkadot/issues/5864). -2. There could be a chicken and egg problem: If we wait for approval vote import - for the dispute to conclude, we would run into a problem if we needed those - approval votes to get enough votes to conclude the dispute. Luckily it turns - out that this is not quite true or at least can be made not true easily: As - already mentioned, approval voting and disputes are running concurrently, but - not only that, they race with each other! A node might simultaneously start - participating in a dispute via the dispute coordinator, due to learning about - a dispute via dispute-distribution, while also participating in approval - voting. By distributing our own approval vote we make sure the dispute can - conclude regardless how the race ended (we either participate explicitly - anyway or we sent our already present approval vote). By importing all - approval votes we make it possible to slash lazy approval voters, even - if they also cast an invalid explicit vote. + racy. The good thing is, this should be good enough: We are worried about + lazy approval checkers, the system does not need to be perfect. It should be + enough if there is some risk of getting caught. +2. We are not worried about the dispute not concluding, as nodes will always + send their own vote, regardless of it being an explict or an already existing + approval-vote. Conclusion: As long as we make sure, if our own approval vote gets imported (which would prevent dispute participation) to also distribute it via @@ -186,28 +174,27 @@ approval-voting deleting votes we will import approval votes twice during a dispute: Once when it is raised, to make as sure as possible to see approval votes also for abandoned forks and second when the dispute concludes, to maximize the amount of potentially malicious approval votes to be recorded. The -raciness obviously is not fully resolved by this, [a -ticket](https://github.com/paritytech/polkadot/issues/5864) exists. +raciness obviously is not fully resolved by this, but this is fine as argued +above. Ensuring vote import on chain is covered in the next section. -As already touched: Honest nodes -will likely validate twice, once in approval voting and once via -dispute-participation. Avoiding that does not really seem worthwhile though, as -disputes are for one exceptional, so a little wasted effort won't affect -everyday performance - second, even with eager importing of approval votes, -those doubled work is still present as disputes and approvals are racing. Every -time participation is faster than approval, a node would do double work. +What we don't care about is that honest approval-voters will likely validate +twice, once in approval voting and once via dispute-participation. Avoiding that +does not really seem worthwhile though, as disputes are for one exceptional, so +a little wasted effort won't affect everyday performance - second, even with +eager importing of approval votes, those doubled work is still present as +disputes and approvals are racing. Every time participation is faster than +approval, a node would do double work. ### Ensuring Chain Import While in the previous section we discussed means for nodes to ensure relevant votes are recorded so lazy approval checkers get slashed properly, it is crucial to also discuss the actual chain import. Only if we guarantee that recorded votes -will also get imported on chain (on all potential chains really) we will succeed +will get imported on chain (on all potential chains really) we will succeed in executing slashes. Particularly we need to make sure backing votes end up on -chain consistantly. In contrast recording and slashing lazy approval voters only -needs to be likely, not certain. +chain consistently. Dispute distribution will make sure all explicit dispute votes get distributed among nodes which includes current block producers (current authority set) which @@ -309,7 +296,7 @@ to send out a thousand tiny network messages by just sending out a single garbage message, is still a significant amplification and is nothing to ignore - this could absolutely be used to cause harm! -#### Participation +### Participation As explained, just blindly participating in any "dispute" that comes along is not a good idea. First we would like to make sure the dispute is actually @@ -336,7 +323,7 @@ participation at all on any _vote import_ if any of the following holds true: honest node that already voted, so the dispute must be genuine. Note: A node might be out of sync with the chain and we might only learn about a -block including a candidate, after we learned about the dispute. This means, we +block, including a candidate, after we learned about the dispute. This means, we have to re-evaluate participation decisions on block import! With this, nodes won't waste significant resources on completely made up @@ -348,7 +335,7 @@ obviously only need to worry about order if participations start queuing up. We treat participation for candidates that we have seen included with priority and put them on a priority queue which sorts participation based on the block -number of the relay parent of that candidate and for candidates with the same +number of the relay parent of the candidate and for candidates with the same relay parent height further by the `CandidateHash`. This ordering is globally unique and also prioritizes older candidates. @@ -359,38 +346,57 @@ times instead of just once to the oldest offender. This is obviously a good idea, in particular it makes it impossible for an attacker to prevent rolling back a very old candidate, by keeping raising disputes for newer candidates. -For candidates we have not seen included, but we know are backed (thanks to chain -scraping) or we have seen a dispute with 1/3+1 participation (confirmed dispute) -on them - we put participation on a best-effort queue. It has got the same -ordering as the priority one - by block heights of the relay parent, older blocks -are with priority. There is a possibility not to be able to obtain the block number -of the parent when we are inserting the dispute in the queue. The reason for this -is either the dispute is completely made up or we are out of sync with the other -nodes in terms of last finalized block. The former is very unlikely. If we are -adding a dispute in best-effort it should already be either confirmed or the -candidate is backed. In the latter case we will promote the dispute to the -priority queue once we learn about the new block. NOTE: this is still work in -progress and is tracked by [this issue] -(https://github.com/paritytech/polkadot/issues/5875). - -#### Import - -In the last section we looked at how to treat queuing participations to handle -heavy dispute load well. This already ensures, that honest nodes won't amplify -cheap DoS attacks. There is one minor issue remaining: Even if we delay +For candidates we have not seen included, but we know are backed (thanks to +chain scraping) or we have seen a dispute with 1/3+1 participation (confirmed +dispute) on them - we put participation on a best-effort queue. It has got the +same ordering as the priority one - by block heights of the relay parent, older +blocks are with priority. There is a possibility not to be able to obtain the +block number of the parent when we are inserting the dispute in the queue. To +account for races, we will promote any existing participation request to the +priority queue once we learn about an including block. NOTE: this is still work +in progress and is tracked by [this +issue](https://github.com/paritytech/polkadot/issues/5875). + +### Abandoned Forks + +Finalization: As mentioned we care about included and backed candidates on any +non-finalized chain, given that any disputed chain will not get finalized, we +don't need to care about finalized blocks, but what about forks that fall behind +the finalized chain in terms of block number? For those we would still like to +be able to participate in any raised disputes, otherwise attackers might be able +to avoid a slash if they manage to create a better fork after they learned about +the approval checkers. Therefore we do care about those forks even after they +have fallen behind the finalized chain. + +For simplicity we also care about the actual finalized chain (not just forks) up +to a certain depth. We do have to limit the depth, because otherwise we open a +DoS vector again. The depth (into the finalized chain) should be oriented on the +approval-voting execution timeout, in particular it should be significantly +larger. Otherwise by the time the execution is allowed to finish, we already +dropped information about those candidates and the dispute could not conclude. + +## Import + +### Spam Considerations + +In the last section we looked at how to treat queuing participations to +handle heavy dispute load well. This already ensures, that honest nodes won't +amplify cheap DoS attacks. There is one minor issue remaining: Even if we delay participation until we have some confirmation of the authenticity of the -dispute, we should also not blindly import all votes arriving into the -database as this might be used to just slowly fill up disk space, until the node -is no longer functional. This leads to our last protection mechanism at the -dispute coordinator level (dispute-distribution also has its own), which is spam -slots. For each import, where we don't know whether it might be spam or not we -increment a counter for each signing participant of explicit `invalid` votes. +dispute, we should also not blindly import all votes arriving into the database +as this might be used to just slowly fill up disk space, until the node is no +longer functional. This leads to our last protection mechanism at the dispute +coordinator level (dispute-distribution also has its own), which is spam slots. +For each import containing an invalid vote, where we don't know whether it might +be spam or not we increment a counter for each signing participant of explicit +`invalid` votes. What votes do we treat as a potential spam? A vote will increase a spam slot if -and only if all of the following condidions are satisfied: -* the candidate under dispute is not included on any chain +and only if all of the following conditions are satisfied: + +* the candidate under dispute was not seen included nor backed on any chain * the dispute is not confirmed -* we haven't casted a vote for the dispute +* we haven't cast a vote for the dispute The reason this works is because we only need to worry about actual dispute votes. Import of backing votes are already rate limited and concern only real @@ -401,6 +407,17 @@ an explicit `invalid` vote in the import. Only a third of the validators can be malicious, so spam disk usage is limited to `2*vote_size*n/3*NUM_SPAM_SLOTS`, with `n` being the number of validators. +### Backing Votes + +Backing votes are in some way special. For starters they are the only valid +votes that are guaranteed to exist for any valid dispute to be raised. Second +they are the only votes that commit to a shorter execution timeout +`BACKING_EXECUTION_TIMEOUT`, compared to a more lenient timeout used in approval +voting. To account properly for execution time variance across machines, +slashing might treat backing votes differently (more aggressively) than other +voting `valid` votes. Hence in import we shall never override a backing vote +with another valid vote. They can not be assumed to be interchangeable. + ## Attacks & Considerations The following attacks on the priority queue and best-effort queues are