diff --git a/Cargo.lock b/Cargo.lock index 117661afe..7d8b6c247 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7748,7 +7748,6 @@ name = "serai-coordinator" version = "0.1.0" dependencies = [ "bitvec", - "blake2 0.11.0-rc.6", "borsh", "ciphersuite 0.4.2", "dalek-ff-group", @@ -7816,6 +7815,7 @@ dependencies = [ name = "serai-coordinator-substrate" version = "0.1.0" dependencies = [ + "blake2 0.11.0-rc.6", "borsh", "dkg", "futures", @@ -7835,15 +7835,20 @@ dependencies = [ "ciphersuite 0.4.2", "dalek-ff-group", "dkg", - "log", + "rand 0.8.6", + "rand_chacha 0.3.1", "rand_core 0.6.4", "schnorr-signatures", "serai-coordinator-substrate", "serai-cosign-types", "serai-db", + "serai-env", "serai-primitives", "serai-processor-messages", + "serai-substrate-tests", "serai-task", + "tendermint-machine", + "tokio", "tributary-sdk", "zeroize", ] diff --git a/clippy.toml b/clippy.toml index 7071c5b52..355c92bde 100644 --- a/clippy.toml +++ b/clippy.toml @@ -5,6 +5,10 @@ path = "Option::map_or" [[disallowed-methods]] path = "Option::map_or_else" +[[disallowed-methods]] +path = "borsh::from_reader" +reason = "`borsh::from_reader` errors if there's any bytes following the read item, which isn't documented behavior" + # TODO: https://github.com/rust-lang/rust-clippy/pull/12194 [[disallowed-methods]] path = "::add" diff --git a/coordinator/Cargo.toml b/coordinator/Cargo.toml index a495df1fe..edc269735 100644 --- a/coordinator/Cargo.toml +++ b/coordinator/Cargo.toml @@ -21,7 +21,6 @@ zeroize = { version = "^1.5", default-features = false, features = ["std"] } bitvec = { version = "1", default-features = false, features = ["std"] } rand_core = { version = "0.6", default-features = false, features = ["std"] } -blake2 = { version = "0.11.0-rc.5", default-features = false, features = ["alloc"] } schnorrkel = { version = "0.11", default-features = false, features = ["std"] } dalek-ff-group = { path = "../crypto/dalek-ff-group", default-features = false, features = ["std"] } diff --git a/coordinator/cosign/src/tests/evaluator.rs b/coordinator/cosign/src/tests/evaluator.rs index 7563cd98f..ce272a3b4 100644 --- a/coordinator/cosign/src/tests/evaluator.rs +++ b/coordinator/cosign/src/tests/evaluator.rs @@ -131,7 +131,7 @@ fn signed_cosign( block_hash: random_block_hash(&mut OsRng), cosigner, }, - signature: random_bytes_64(&mut OsRng), + signature: random_bytes(&mut OsRng), } } diff --git a/coordinator/cosign/types/src/tests/mod.rs b/coordinator/cosign/types/src/tests/mod.rs index 6246de3e0..8b748984c 100644 --- a/coordinator/cosign/types/src/tests/mod.rs +++ b/coordinator/cosign/types/src/tests/mod.rs @@ -21,7 +21,7 @@ pub fn random_external_network_id(rng: &mut (impl RngCore + CryptoRng)) -> Exter /// Generate a random global session ID (`[u8; 32]`). pub fn random_global_session(rng: &mut R) -> [u8; 32] { - serai_primitives::test_helpers::random_bytes_32(rng) + serai_primitives::test_helpers::random_bytes(rng) } /// Generate a random [`Cosign`] for testing. diff --git a/coordinator/src/dkg_confirmation.rs b/coordinator/src/dkg_confirmation.rs index 2564f17f0..9bc45fe08 100644 --- a/coordinator/src/dkg_confirmation.rs +++ b/coordinator/src/dkg_confirmation.rs @@ -108,9 +108,9 @@ fn handle_frost_error(result: Result) -> Result, share: [u8; 32], machine: Box>, @@ -151,7 +151,7 @@ impl ConfirmDkgTask { fn preprocess( db: &mut CD, set: ExternalValidatorSet, - attempt: u32, + attempt: u64, key: Zeroizing<::F>, signer: &mut Option, ) { diff --git a/coordinator/src/tributary.rs b/coordinator/src/tributary.rs index f23ee95b8..60495113b 100644 --- a/coordinator/src/tributary.rs +++ b/coordinator/src/tributary.rs @@ -3,7 +3,6 @@ use std::sync::Arc; use zeroize::Zeroizing; use rand_core::OsRng; -use blake2::{digest::typenum::U32, Digest as _, Blake2s}; use ciphersuite::*; use dalek_ff_group::Ristretto; @@ -482,8 +481,7 @@ pub(crate) async fn spawn_tributary( return; } - let genesis = - <[u8; 32]>::from(Blake2s::::digest(borsh::to_vec(&(set.serai_block, set.set)).unwrap())); + let genesis = set.tributary_genesis(); // Since the Serai block will be finalized, then cosigned, before we handle this, this time will // be a couple of minutes stale. While the Tributary will still function with a start time in the diff --git a/coordinator/substrate/Cargo.toml b/coordinator/substrate/Cargo.toml index cfe31b423..a4c0c81bd 100644 --- a/coordinator/substrate/Cargo.toml +++ b/coordinator/substrate/Cargo.toml @@ -19,6 +19,7 @@ workspace = true [dependencies] borsh = { version = "1", default-features = false, features = ["std", "derive", "de_strict_order"] } +blake2 = { version = "0.11.0-rc.5", default-features = false, features = ["alloc"] } dkg = { path = "../../crypto/dkg", default-features = false, features = ["std"] } serai-client-serai = { path = "../../substrate/client/serai", default-features = false } diff --git a/coordinator/substrate/src/lib.rs b/coordinator/substrate/src/lib.rs index e6aa70465..e0f5414e7 100644 --- a/coordinator/substrate/src/lib.rs +++ b/coordinator/substrate/src/lib.rs @@ -7,6 +7,10 @@ use std::collections::HashMap; use borsh::{BorshSerialize, BorshDeserialize}; +use blake2::{ + digest::{typenum::U32, Digest as _}, + Blake2b, +}; use dkg::Participant; use serai_client_serai::abi::{ @@ -80,6 +84,37 @@ impl NewSetInformation { self.participant_indexes.insert(*validator, these_is); } } + + /// Create a new [`NewSetInformation`]. + pub fn new( + set: ExternalValidatorSet, + serai_block: [u8; 32], + declaration_time: u64, + threshold: u16, + validators: Vec<(SeraiAddress, u16)>, + evrf_public_keys: Vec<([u8; 32], Vec)>, + ) -> Self { + let mut result = Self { + set, + serai_block, + declaration_time, + threshold, + validators, + evrf_public_keys, + participant_indexes: Default::default(), + participant_indexes_reverse_lookup: Default::default(), + }; + result.init_participant_indexes(); + result + } +} + +impl NewSetInformation { + /// The hash to use for the genesis of the corresponding Tributary. + pub fn tributary_genesis(&self) -> [u8; 32] { + // This MUST only hash data completely deterministic to the Substrate blockchain. + Blake2b::::digest(borsh::to_vec(self).unwrap()).into() + } } mod _public_db { diff --git a/coordinator/tributary/Cargo.toml b/coordinator/tributary/Cargo.toml index e1f84d776..ad97a3a09 100644 --- a/coordinator/tributary/Cargo.toml +++ b/coordinator/tributary/Cargo.toml @@ -28,7 +28,7 @@ dalek-ff-group = { path = "../../crypto/dalek-ff-group", default-features = fals dkg = { path = "../../crypto/dkg", default-features = false, features = ["std"] } schnorr = { package = "schnorr-signatures", path = "../../crypto/schnorr", default-features = false, features = ["std"] } -serai-primitives = { path = "../../substrate/primitives", default-features = false, features = ["std"] } +serai-primitives = { path = "../../substrate/primitives", default-features = false, features = ["std", "test-helpers"] } serai-db = { path = "../../common/db" } serai-task = { path = "../../common/task", version = "0.1" } @@ -40,7 +40,19 @@ serai-coordinator-substrate = { path = "../substrate" } messages = { package = "serai-processor-messages", path = "../../processor/messages" } -log = { version = "0.4", default-features = false, features = ["std"] } +serai-env = { path = "../../common/env", version = "0.1.0" } + +[dev-dependencies] +rand = { version = "0.8", default-features = false, features = ["std"] } +rand_chacha = { version = "0.3", default-features = false, features = ["std"] } + +tendermint = { package = "tendermint-machine", path = "../tributary-sdk/tendermint" } +tributary-sdk = { path = "../tributary-sdk", features = ["tests"] } + +tokio = { version = "1", default-features = false, features = ["rt", "time", "macros", "rt-multi-thread"] } + +serai-task = { path = "../../common/task", features = ["test-helpers"] } +serai-substrate-tests = { path = "../../tests/substrate" } [features] longer-reattempts = [] diff --git a/coordinator/tributary/src/db.rs b/coordinator/tributary/src/db.rs index ed4a27db4..fa9a203d3 100644 --- a/coordinator/tributary/src/db.rs +++ b/coordinator/tributary/src/db.rs @@ -2,13 +2,15 @@ use std::collections::HashMap; use borsh::{BorshSerialize, BorshDeserialize}; -use serai_primitives::{BlockHash, validator_sets::ExternalValidatorSet, address::SeraiAddress}; - -use messages::sign::{VariantSignId, SignId}; +use serai_primitives::{ + BlockHash, + validator_sets::{KeyShares, ExternalValidatorSet}, + address::SeraiAddress, +}; use serai_db::*; - use serai_cosign_types::CosignIntent; +use messages::sign::{VariantSignId, SignId}; use crate::transaction::SigningProtocolRound; @@ -22,11 +24,11 @@ pub enum Topic { }, // DkgParticipation isn't represented here as participations are immediately sent to the - // processor, not accumulated within this databse + // processor, not accumulated within this database /// Participation in the signing protocol to confirm the DKG results on Substrate DkgConfirmation { /// The attempt number this is for - attempt: u32, + attempt: u64, /// The round of the signing protocol round: SigningProtocolRound, }, @@ -39,20 +41,21 @@ pub enum Topic { /// The ID of the signing protocol id: VariantSignId, /// The attempt number this is for - attempt: u32, + attempt: u64, /// The round of the signing protocol round: SigningProtocolRound, }, } -enum Participating { +#[derive(PartialEq, Eq, Debug)] +pub(crate) enum Participating { Participated, Everyone, } impl Topic { // The topic used by the next attempt of this protocol - fn next_attempt_topic(self) -> Option { + pub(crate) fn next_attempt_topic(self) -> Option { #[expect(clippy::match_same_arms)] match self { Topic::RemoveParticipant { .. } => None, @@ -68,16 +71,19 @@ impl Topic { } // The topic for the re-attempt to schedule - fn reattempt_topic(self) -> Option<(u32, Topic)> { + pub(crate) fn reattempt_topic(self) -> Option<(u64, Topic)> { #[expect(clippy::match_same_arms)] match self { Topic::RemoveParticipant { .. } => None, Topic::DkgConfirmation { attempt, round } => match round { SigningProtocolRound::Preprocess => { - let attempt = attempt + 1; + let next_attempt = attempt + 1; Some(( - attempt, - Topic::DkgConfirmation { attempt, round: SigningProtocolRound::Preprocess }, + next_attempt, + Topic::DkgConfirmation { + attempt: next_attempt, + round: SigningProtocolRound::Preprocess, + }, )) } SigningProtocolRound::Share => None, @@ -85,8 +91,11 @@ impl Topic { Topic::SlashReport => None, Topic::Sign { id, attempt, round } => match round { SigningProtocolRound::Preprocess => { - let attempt = attempt + 1; - Some((attempt, Topic::Sign { id, attempt, round: SigningProtocolRound::Preprocess })) + let next_attempt = attempt + 1; + Some(( + next_attempt, + Topic::Sign { id, attempt: next_attempt, round: SigningProtocolRound::Preprocess }, + )) } SigningProtocolRound::Share => None, }, @@ -136,7 +145,7 @@ impl Topic { /// The topic which precedes this topic as a prerequisite /// /// The preceding topic must define this topic as succeeding - fn preceding_topic(self) -> Option { + pub(crate) fn preceding_topic(self) -> Option { #[expect(clippy::match_same_arms)] match self { Topic::RemoveParticipant { .. } => None, @@ -159,7 +168,7 @@ impl Topic { /// The topic which succeeds this topic, with this topic as a prerequisite /// /// The succeeding topic must define this topic as preceding - fn succeeding_topic(self) -> Option { + pub(crate) fn succeeding_topic(self) -> Option { #[expect(clippy::match_same_arms)] match self { Topic::RemoveParticipant { .. } => None, @@ -194,13 +203,19 @@ impl Topic { } } - fn required_participation(&self, n: u16) -> u16 { + pub(crate) fn required_participation(&self, n: u16) -> u16 { + // All of our current topics require 2/3rds participation let _ = self; - // All of our topics require 2/3rds participation - ((2 * n) / 3) + 1 + + let wide = u32::from(n); + let fraction_lt_input = + wide.checked_mul(2).expect("widened integer overflowed when multiplied by `2`") / 3; + let result_lte_input = fraction_lt_input + 1; + u16::try_from(result_lte_input) + .expect("value less than or equal to `u16` input wasn't itself valid as a `u16`") } - fn participating(&self) -> Participating { + pub(crate) fn participating(&self) -> Participating { #[expect(clippy::match_same_arms)] match self { Topic::RemoveParticipant { .. } => Participating::Everyone, @@ -271,6 +286,16 @@ db_channel!( } ); +// 5 minutes +#[cfg(not(feature = "longer-reattempts"))] +pub(crate) const BASE_REATTEMPT_DELAY: u32 = + (5u32 * 60 * 1000).div_ceil(tributary_sdk::tendermint::TARGET_BLOCK_TIME); + +// 10 minutes, intended for latent environments like the GitHub CI +#[cfg(feature = "longer-reattempts")] +pub(crate) const BASE_REATTEMPT_DELAY: u32 = + (10u32 * 60 * 1000).div_ceil(tributary_sdk::tendermint::TARGET_BLOCK_TIME); + pub(crate) struct TributaryDb; impl TributaryDb { pub(crate) fn last_handled_tributary_block( @@ -330,7 +355,10 @@ impl TributaryDb { ); } pub(crate) fn finish_cosigning(txn: &mut impl DbTxn, set: ExternalValidatorSet) { - assert!(ActivelyCosigning::take(txn, set).is_some(), "finished cosigning but not cosigning"); + assert!( + ActivelyCosigning::take(txn, set).is_some(), + "tried to finish cosigning but wasn't actively cosigning" + ); } pub(crate) fn mark_cosigned( txn: &mut impl DbTxn, @@ -354,6 +382,13 @@ impl TributaryDb { pub(crate) fn recognized(getter: &impl Get, set: ExternalValidatorSet, topic: Topic) -> bool { AccumulatedWeight::get(getter, set, topic).is_some() } + /// The next topic which required recognition which has now been recognized by this Tributary. + pub(crate) fn try_recv_topic_requiring_recognition( + txn: &mut impl DbTxn, + set: ExternalValidatorSet, + ) -> Option { + RecognizedTopics::try_recv(txn, set) + } pub(crate) fn start_of_block(txn: &mut impl DbTxn, set: ExternalValidatorSet, block_number: u64) { for topic in Reattempt::take(txn, set, block_number).unwrap_or(vec![]) { @@ -387,9 +422,9 @@ impl TributaryDb { txn: &mut impl DbTxn, set: ExternalValidatorSet, validator: SeraiAddress, - reason: &str, + #[cfg_attr(coverage, allow(unused_variables))] reason: &str, ) { - log::warn!("{validator} fatally slashed: {reason}"); + serai_env::warn!("{validator} fatally slashed: {reason}"); SlashPoints::set(txn, set, validator, &u32::MAX); } @@ -415,6 +450,10 @@ impl TributaryDb { ) -> DataSet { // This function will only be called once for a (validator, topic) tuple due to how we handle // nonces on transactions (deterministically to the topic) + assert!( + txn.get(Accumulated::::key(set, topic, validator)).is_none(), + "accumulate called twice for the same (validator, topic) tuple", + ); let accumulated_weight = AccumulatedWeight::get(txn, set, topic); if topic.requires_recognition() && accumulated_weight.is_none() { @@ -431,7 +470,9 @@ impl TributaryDb { // Check if there's a preceding topic, this validator participated let preceding_topic = topic.preceding_topic(); if let Some(preceding_topic) = preceding_topic { - if Accumulated::::get(txn, set, preceding_topic, validator).is_none() { + // Use a raw key-existence check instead of `Accumulated::::get` because the preceding + // topic may have stored a different type (e.g. preprocess is [u8; 64], share is [u8; 32]) + if txn.get(Accumulated::::key(set, preceding_topic, validator)).is_none() { Self::fatal_slash( txn, set, @@ -442,10 +483,13 @@ impl TributaryDb { } } + let required_participation = topic.required_participation(total_weight); + + // TODO: // The complete lack of validation on the data by these NOPs opens the potential for spam here // If we've already accumulated past the threshold, NOP - if accumulated_weight >= topic.required_participation(total_weight) { + if accumulated_weight >= required_participation { return DataSet::None; } // If this is for an old attempt, NOP @@ -456,27 +500,21 @@ impl TributaryDb { } // Accumulate the data + const { + // If this is true, the following addition won't trip unless we're accumulating past the max + assert!(KeyShares::MAX_PER_SET < u16::MAX); + } accumulated_weight += validator_weight; AccumulatedWeight::set(txn, set, topic, &accumulated_weight); Accumulated::set(txn, set, topic, validator, data); // Check if we now cross the weight threshold - if accumulated_weight >= topic.required_participation(total_weight) { + if accumulated_weight >= required_participation { // Queue this for re-attempt after enough time passes let reattempt_topic = topic.reattempt_topic(); if let Some((attempt, reattempt_topic)) = reattempt_topic { - // 5 minutes - #[cfg(not(feature = "longer-reattempts"))] - const BASE_REATTEMPT_DELAY: u32 = - (5u32 * 60 * 1000).div_ceil(tributary_sdk::tendermint::TARGET_BLOCK_TIME); - - // 10 minutes, intended for latent environments like the GitHub CI - #[cfg(feature = "longer-reattempts")] - const BASE_REATTEMPT_DELAY: u32 = - (10u32 * 60 * 1000).div_ceil(tributary_sdk::tendermint::TARGET_BLOCK_TIME); - - // Linearly scale the time for the protocol with the attempt number - let blocks_till_reattempt = u64::from(attempt * BASE_REATTEMPT_DELAY); + // Linearly scale the time for the protocol with the attempt number, up to 10x + let blocks_till_reattempt = attempt.min(10).saturating_mul(u64::from(BASE_REATTEMPT_DELAY)); let recognize_at = block_number + blocks_till_reattempt; let mut queued = Reattempt::get(txn, set, recognize_at).unwrap_or(Vec::with_capacity(1)); diff --git a/coordinator/tributary/src/lib.rs b/coordinator/tributary/src/lib.rs index b1ab27e41..436d893a2 100644 --- a/coordinator/tributary/src/lib.rs +++ b/coordinator/tributary/src/lib.rs @@ -39,6 +39,9 @@ mod db; use db::*; pub use db::Topic; +#[cfg(test)] +mod tests; + /// Messages to send to the Processors. pub struct ProcessorMessages; impl ProcessorMessages { @@ -101,7 +104,7 @@ impl RecognizedTopics { txn: &mut impl DbTxn, set: ExternalValidatorSet, ) -> Option { - db::RecognizedTopics::try_recv(txn, set) + TributaryDb::try_recv_topic_requiring_recognition(txn, set) } } @@ -190,6 +193,10 @@ impl ScanBlock<'_, TD, TDT, P> { data: &D, signer: SeraiAddress, ) -> Option<(SignId, HashMap>)> { + assert!( + matches!(topic, Topic::DkgConfirmation { .. }), + "`accumulate_dkg_confirmation` called with non-`DkgConfirmation` topic: {topic:?}" + ); match TributaryDb::accumulate::( self.tributary_txn, self.set.set, @@ -479,7 +486,9 @@ impl ScanBlock<'_, TD, TDT, P> { slash_report.push(Slash::Points(points)); } } - assert!(slash_report.len() <= f); + assert!( + slash_report.iter().filter(|points| !matches!(points, Slash::Points(0))).count() <= f + ); // Recognize the topic for signing the slash report TributaryDb::recognize_topic( @@ -611,11 +620,19 @@ pub struct ScanTributaryTask { impl ScanTributaryTask { /// Create a new instance of this task. + /// + /// This will panic if the Tributary read does not correspond to the set. pub fn new( tributary_db: TD, set: NewSetInformation, tributary: TributaryReader, ) -> Self { + assert_eq!( + set.tributary_genesis(), + tributary.genesis(), + "set information is inconsistent with the tributary" + ); + let mut validators = Vec::with_capacity(set.validators.len()); let mut total_weight = 0; let mut validator_weights = HashMap::with_capacity(set.validators.len()); @@ -647,10 +664,9 @@ impl ContinuallyRan for ScanTributaryTask { .unwrap_or((0, self.tributary.genesis())); let mut made_progress = false; - while let Some(next) = self.tributary.block_after(&last_block_hash) { - let block = self.tributary.block(&next).unwrap(); + while let Some(block_hash) = self.tributary.block_after(&last_block_hash) { + let block = self.tributary.block(&block_hash).unwrap(); let block_number = last_block_number + 1; - let block_hash = block.hash(); // Make sure we have all of the provided transactions for this block for tx in &block.transactions { @@ -696,7 +712,7 @@ impl ContinuallyRan for ScanTributaryTask { } } -/// Create the Transaction::SlashReport to publish per the local view. +/// Create the `Transaction::SlashReport` to publish per the local view. pub fn slash_report_transaction(getter: &impl Get, set: &NewSetInformation) -> Transaction { let mut slash_points = Vec::with_capacity(set.validators.len()); for (validator, _weight) in set.validators.iter().copied() { diff --git a/coordinator/tributary/src/tests/db.rs b/coordinator/tributary/src/tests/db.rs new file mode 100644 index 000000000..98a28e629 --- /dev/null +++ b/coordinator/tributary/src/tests/db.rs @@ -0,0 +1,1320 @@ +use rand::{Rng as _, RngCore as _, rngs::OsRng}; + +use serai_primitives::{ + address::SeraiAddress, + validator_sets::{ExternalValidatorSet, KeyShares}, + test_helpers::{ + random_bytes, random_block_hash, random_serai_address, random_validator_set, random_vec_u8, + }, +}; + +use messages::sign::{SignId, VariantSignId}; +use serai_db::{Db as _, DbTxn, MemDb}; +use crate::{ + transaction::SigningProtocolRound, + db::{*, ProcessorMessages, DkgConfirmationMessages}, + tests::*, +}; + +/// One of each topic kind, and attempts: at 0 and a random attempt. +fn all_topics_and_attempts() -> Vec { + let random_attempt = OsRng.gen_range(1u64 .. u64::MAX); + vec![ + // RemoveParticipant + Topic::RemoveParticipant { participant: random_serai_address(&mut OsRng) }, + // DkgConfirmation Preprocess + Topic::DkgConfirmation { attempt: 0, round: SigningProtocolRound::Preprocess }, + Topic::DkgConfirmation { attempt: random_attempt, round: SigningProtocolRound::Preprocess }, + // DkgConfirmation Share + Topic::DkgConfirmation { attempt: 0, round: SigningProtocolRound::Share }, + Topic::DkgConfirmation { attempt: random_attempt, round: SigningProtocolRound::Share }, + // SlashReport + Topic::SlashReport, + // Sign Preprocess + Topic::Sign { + id: random_variant_sign_id(), + attempt: 0, + round: SigningProtocolRound::Preprocess, + }, + Topic::Sign { + id: random_variant_sign_id(), + attempt: random_attempt, + round: SigningProtocolRound::Preprocess, + }, + // Sign Share + Topic::Sign { id: random_variant_sign_id(), attempt: 0, round: SigningProtocolRound::Share }, + Topic::Sign { + id: random_variant_sign_id(), + attempt: random_attempt, + round: SigningProtocolRound::Share, + }, + ] +} + +/// Share-round topics only, with attempts: at 0 and random. +fn all_share_topics_and_attempts() -> Vec { + all_topics_and_attempts() + .into_iter() + .filter(|t| { + matches!( + t, + Topic::DkgConfirmation { round: SigningProtocolRound::Share, .. } | + Topic::Sign { round: SigningProtocolRound::Share, .. } + ) + }) + .collect() +} + +/// Preprocess-round topics only, with attempts: at 0 and random. +fn all_preprocess_topics_and_attempts() -> Vec { + all_topics_and_attempts() + .into_iter() + .filter(|t| { + matches!( + t, + Topic::DkgConfirmation { round: SigningProtocolRound::Preprocess, .. } | + Topic::Sign { round: SigningProtocolRound::Preprocess, .. } + ) + }) + .collect() +} + +type NoEachFn = fn(usize, &DataSet<[u8; 32]>); + +/// Cross threshold by accumulating from all validators, returning the final result. +#[expect(clippy::too_many_arguments)] +fn accumulate_to_threshold( + txn: &mut impl DbTxn, + set: ExternalValidatorSet, + validators: &[SeraiAddress], + total_weight: u16, + block_number: u64, + topic: Topic, + make_data: F2, + mut on_each: Option, +) -> DataSet +where + F1: FnMut(usize, &DataSet), + F2: Fn(usize) -> D, +{ + let mut result = DataSet::None; + for (i, v) in validators.iter().enumerate() { + let data = make_data(i); + result = TributaryDb::accumulate::( + txn, + set, + validators, + total_weight, + block_number, + topic, + *v, + 1, + &data, + ); + if let Some(f) = &mut on_each { + f(i, &result); + } + } + + result +} + +#[test] +fn required_participation() { + assert_eq!(Topic::SlashReport.required_participation(0), 1); + assert_eq!(Topic::SlashReport.required_participation(u16::MAX), 43691); + for _ in 0 .. 128 { + #[expect(clippy::as_conversions, clippy::cast_possible_truncation)] + let validators = OsRng.next_u64() as u16; + let required = Topic::SlashReport.required_participation(validators); + assert!(((2 * (validators - required)) + 1) <= required); + assert!((required - ((2 * (validators - required)) + 1)) <= 2); + } +} + +mod topic { + use super::*; + + #[test] + fn next_attempt_topic() { + for topic in all_topics_and_attempts() { + match topic { + Topic::DkgConfirmation { attempt, .. } => assert_eq!( + topic.next_attempt_topic(), + attempt.checked_add(1).map(|next| Topic::DkgConfirmation { + attempt: next, + round: SigningProtocolRound::Preprocess, + }) + ), + Topic::Sign { id, attempt, .. } => assert_eq!( + topic.next_attempt_topic(), + attempt.checked_add(1).map(|next| Topic::Sign { + id, + attempt: next, + round: SigningProtocolRound::Preprocess + }) + ), + Topic::RemoveParticipant { .. } | Topic::SlashReport => { + assert_eq!(topic.next_attempt_topic(), None); + } + } + } + } + + #[test] + fn reattempt_topic() { + for topic in all_topics_and_attempts() { + match topic { + Topic::DkgConfirmation { attempt, round } => match round { + SigningProtocolRound::Preprocess => assert_eq!( + topic.reattempt_topic(), + Some(( + attempt + 1, + Topic::DkgConfirmation { + attempt: attempt + 1, + round: SigningProtocolRound::Preprocess + }, + )) + ), + SigningProtocolRound::Share => assert_eq!(topic.reattempt_topic(), None), + }, + Topic::Sign { id, attempt, round } => match round { + SigningProtocolRound::Preprocess => assert_eq!( + topic.reattempt_topic(), + Some(( + attempt + 1, + Topic::Sign { id, attempt: attempt + 1, round: SigningProtocolRound::Preprocess } + )) + ), + SigningProtocolRound::Share => assert_eq!(topic.reattempt_topic(), None), + }, + Topic::RemoveParticipant { .. } | Topic::SlashReport => { + assert_eq!(topic.reattempt_topic(), None); + } + } + } + } + + #[test] + fn sign_id() { + let set = random_validator_set(&mut OsRng); + for topic in all_topics_and_attempts() { + match topic { + Topic::Sign { id, attempt, round: _ } => { + assert_eq!(topic.sign_id(set), Some(SignId { session: set.session, id, attempt })); + } + Topic::RemoveParticipant { .. } | Topic::DkgConfirmation { .. } | Topic::SlashReport => { + assert_eq!(topic.sign_id(set), None); + } + } + } + } + + #[test] + fn dkg_confirmation_sign_id() { + let set = random_validator_set(&mut OsRng); + for topic in all_topics_and_attempts() { + match topic { + Topic::DkgConfirmation { attempt, round: _ } => { + assert_eq!( + topic.dkg_confirmation_sign_id(set), + Some({ + let id = { + let mut id = [0; 32]; + let encoded_set = borsh::to_vec(&set).unwrap(); + id[.. encoded_set.len()].copy_from_slice(&encoded_set); + VariantSignId::Batch(id) + }; + SignId { session: set.session, id, attempt } + }) + ); + } + Topic::RemoveParticipant { .. } | Topic::SlashReport | Topic::Sign { .. } => { + assert_eq!(topic.dkg_confirmation_sign_id(set), None); + } + } + } + } + + #[test] + fn preceding_topic() { + for topic in all_topics_and_attempts() { + match topic { + Topic::DkgConfirmation { attempt, round: SigningProtocolRound::Share } => assert_eq!( + topic.preceding_topic(), + Some(Topic::DkgConfirmation { attempt, round: SigningProtocolRound::Preprocess }) + ), + Topic::Sign { id, attempt, round: SigningProtocolRound::Share } => assert_eq!( + topic.preceding_topic(), + Some(Topic::Sign { id, attempt, round: SigningProtocolRound::Preprocess }) + ), + Topic::RemoveParticipant { .. } | + Topic::DkgConfirmation { round: SigningProtocolRound::Preprocess, .. } | + Topic::SlashReport | + Topic::Sign { round: SigningProtocolRound::Preprocess, .. } => { + assert_eq!(topic.preceding_topic(), None); + } + } + + // preceding and succeeding should be inverses + if let Some(preceding) = topic.preceding_topic() { + assert_eq!(preceding.succeeding_topic(), Some(topic)); + } + if let Some(succeeding) = topic.succeeding_topic() { + assert_eq!(succeeding.preceding_topic(), Some(topic)); + } + } + } + + #[test] + fn succeeding_topic() { + for topic in all_topics_and_attempts() { + match topic { + Topic::DkgConfirmation { attempt, round: SigningProtocolRound::Preprocess } => assert_eq!( + topic.succeeding_topic(), + Some(Topic::DkgConfirmation { attempt, round: SigningProtocolRound::Share }) + ), + Topic::Sign { id, attempt, round: SigningProtocolRound::Preprocess } => assert_eq!( + topic.succeeding_topic(), + Some(Topic::Sign { id, attempt, round: SigningProtocolRound::Share }) + ), + Topic::RemoveParticipant { .. } | + Topic::DkgConfirmation { round: SigningProtocolRound::Share, .. } | + Topic::SlashReport | + Topic::Sign { round: SigningProtocolRound::Share, .. } => { + assert_eq!(topic.succeeding_topic(), None); + } + } + } + } + + #[test] + fn requires_recognition() { + for topic in all_topics_and_attempts() { + match topic { + Topic::DkgConfirmation { attempt, .. } => { + assert_eq!(topic.requires_recognition(), attempt != 0); + } + Topic::Sign { .. } => assert!(topic.requires_recognition()), + Topic::RemoveParticipant { .. } | Topic::SlashReport => { + assert!(!topic.requires_recognition()); + } + } + } + } + + #[test] + fn participating() { + for topic in all_topics_and_attempts() { + match topic { + Topic::RemoveParticipant { .. } | Topic::SlashReport => { + assert_eq!(topic.participating(), Participating::Everyone); + } + Topic::DkgConfirmation { .. } | Topic::Sign { .. } => { + assert_eq!(topic.participating(), Participating::Participated); + } + } + } + } +} + +mod tributary_db { + use super::*; + + #[test] + fn start_and_finish_cosigning() { + let mut db = MemDb::new(); + let set = random_validator_set(&mut OsRng); + let block_hash1 = random_block_hash(&mut OsRng); + let block_number1 = OsRng.next_u64(); + + let expected_topic = initial_sign_topic(VariantSignId::Cosign(block_number1)); + + // Recognizes topic + { + let mut txn = db.txn(); + TributaryDb::start_cosigning(&mut txn, set, block_hash1, block_number1); + assert_start_cosigning_invariants(&mut txn, set, block_hash1, block_number1); + txn.commit(); + } + + // Same set cannot recognize again until finished + { + let mut txn = db.txn(); + assert_eq!(ActivelyCosigning::get(&txn, set), Some(block_hash1)); + + let retry = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let block_hash2 = random_block_hash(&mut OsRng); + let block_number2 = OsRng.next_u64(); + TributaryDb::start_cosigning(&mut txn, set, block_hash2, block_number2); + })); + + assert!(retry.is_err()); + + // Previous topic still recognized + assert!(TributaryDb::recognized(&txn, set, expected_topic)); + } + + // Finish cosigning + { + let mut txn = db.txn(); + TributaryDb::finish_cosigning(&mut txn, set); + assert_eq!(ActivelyCosigning::get(&txn, set), None); + + // Previous topic remains recognized + assert!(TributaryDb::recognized(&txn, set, expected_topic)); + + txn.commit(); + } + + // Start cosigning new block + { + let mut txn = db.txn(); + let block_hash2 = random_block_hash(&mut OsRng); + let block_number2 = OsRng.next_u64(); + + TributaryDb::start_cosigning(&mut txn, set, block_hash2, block_number2); + assert_eq!(ActivelyCosigning::get(&txn, set), Some(block_hash2)); + + TributaryDb::finish_cosigning(&mut txn, set); + assert_eq!(ActivelyCosigning::get(&txn, set), None); + + // The new topic is now recognized + assert!(TributaryDb::recognized( + &txn, + set, + initial_sign_topic(VariantSignId::Cosign(block_number2)) + )); + // Previous topic also remains recognized + assert!(TributaryDb::recognized(&txn, set, expected_topic)); + + txn.commit(); + } + } + + #[test] + fn start_of_block() { + serai_env::init_logger(); + let set = random_validator_set(&mut OsRng); + + let reattemptable_topics: Vec = all_topics_and_attempts() + .into_iter() + .filter_map(|t| t.reattempt_topic().map(|(_, reattempt_topic)| reattempt_topic)) + .collect(); + + serai_env::info!( + "start_of_block fuzz: reattemptable_topics={reattemptable_topics:?}, \ + all_topics_and_attempts count={}", + all_topics_and_attempts().len() + ); + + for iteration in 0 .. 100 { + for topic in all_topics_and_attempts() { + // Fresh DB per topic so recognized state doesn't leak between iterations + let mut db = MemDb::new(); + let mut txn = db.txn(); + let block_number = OsRng.next_u64(); + + // Randomly select which reattempt topics are queued for this block + let reattempts: Vec = + reattemptable_topics.iter().copied().filter(|_| OsRng.next_u64() % 2 == 0).collect(); + + serai_env::trace!( + "iteration={iteration}, topic={topic:?}, block_number={block_number}, \ + reattempts={reattempts:?}" + ); + + if !reattempts.is_empty() { + Reattempt::set(&mut txn, set, block_number, &reattempts); + serai_env::trace!("set {} reattempt(s) for block {block_number}", reattempts.len()); + } + + TributaryDb::start_of_block(&mut txn, set, block_number); + + // Verify each queued reattempt topic was recognized and its message sent + for reattempt in &reattempts { + assert!(TributaryDb::recognized(&txn, set, *reattempt)); + if reattempt.sign_id(set).is_some() { + assert!(ProcessorMessages::try_recv(&mut txn, set).is_some()); + serai_env::trace!("verified ProcessorMessage for {reattempt:?}"); + } else if reattempt.dkg_confirmation_sign_id(set).is_some() { + assert!(DkgConfirmationMessages::try_recv(&mut txn, set).is_some()); + serai_env::trace!("verified DkgConfirmationMessage for {reattempt:?}"); + } + } + + // When no reattempts were set, verify the current topic's reattempt was not recognized + if reattempts.is_empty() { + if let Some((_, reattempt_topic)) = topic.reattempt_topic() { + assert!(!TributaryDb::recognized(&txn, set, reattempt_topic)); + serai_env::trace!("verified {reattempt_topic:?} not recognized (no reattempts)"); + } + } + + // No extra messages should remain in either queue + assert_no_pending_messages(&mut txn, set); + + txn.commit(); + } + } + + serai_env::log::info!("start_of_block fuzz: completed 100 iterations"); + } + + #[test] + fn fatal_slash() { + let mut db = MemDb::new(); + let set = random_validator_set(&mut OsRng); + let validator = random_serai_address(&mut OsRng); + + { + let mut txn = db.txn(); + TributaryDb::fatal_slash(&mut txn, set, validator, "test reason"); + txn.commit(); + } + + assert!(TributaryDb::is_fatally_slashed(&db, set, validator)); + assert_eq!(SlashPoints::get(&db, set, validator), Some(u32::MAX)); + } + + mod accumulate { + use super::*; + + /// Common test setup: random validator set, 3 validators of weight 1, total_weight = 3. + fn default_accumulate_setup( + ) -> (ExternalValidatorSet, SeraiAddress, Vec, u16, u16) { + let set = random_validator_set(&mut OsRng); + let (_, _, validators, _, total_weight) = setup_test_validators_and_weights_with_keys(); + let validator = validators[0]; + let validator_weight = 1; + (set, validator, validators, total_weight, validator_weight) + } + + mod accumulate_preceding_topic { + use super::*; + + #[test] + fn no_preceding_data_slashes_validator() { + for share_topic in all_share_topics_and_attempts() { + let (set, validator, validators, total_weight, validator_weight) = + default_accumulate_setup(); + let mut db = MemDb::new(); + let mut txn = db.txn(); + + // Recognize the share topic so we reach the preceding-topic check + if share_topic.requires_recognition() { + TributaryDb::recognize_topic(&mut txn, set, share_topic); + } + + // Do not store any preceding Preprocess data + // Validator should be slashed with reason: + // "participated in topic without participating in prior" + let result = TributaryDb::accumulate::<[u8; 32]>( + &mut txn, + set, + &validators, + total_weight, + OsRng.next_u64(), + share_topic, + validator, + validator_weight, + &random_bytes(&mut OsRng), + ); + txn.commit(); + + assert!(matches!(result, DataSet::None)); + assert!( + TributaryDb::is_fatally_slashed(&db, set, validator), + "validator should be slashed for not participating in prior: {share_topic:?}" + ); + } + } + + #[test] + fn preceding_topic_passes_existence_check() { + // Different types: stores Preprocess, accumulates Share + for share_topic in all_share_topics_and_attempts() { + let (set, validator, validators, total_weight, validator_weight) = + default_accumulate_setup(); + let mut db = MemDb::new(); + let mut txn = db.txn(); + + // Recognize the share topic so we reach the preceding-topic check + if share_topic.requires_recognition() { + TributaryDb::recognize_topic(&mut txn, set, share_topic); + } + + // Store preceding preprocess data + Accumulated::<[u8; 64]>::set( + &mut txn, + set, + share_topic.preceding_topic().unwrap(), + validator, + &random_bytes(&mut OsRng), + ); + + // Accumulate a share + // The preceding check should find the key despite the type mismatch and NOT slash. + let result = TributaryDb::accumulate::<[u8; 32]>( + &mut txn, + set, + &validators, + total_weight, + OsRng.next_u64(), + share_topic, + validator, + validator_weight, + &random_bytes(&mut OsRng), + ); + txn.commit(); + + assert!(!TributaryDb::is_fatally_slashed(&db, set, validator)); + + // Below threshold (1 of 3) so result is None but data is stored + assert!(matches!(result, DataSet::None)); + // Confirm data is stored + assert!(Accumulated::<[u8; 32]>::get(&db, set, share_topic, validator).is_some()); + } + + // Same types: stores type of Vec> for both Preprocess and Share. + // Only topics where the preprocess data survives after threshold + // (reattempt exists). + for share_topic in all_share_topics_and_attempts() + .into_iter() + .filter(|t| t.preceding_topic().unwrap().reattempt_topic().is_some()) + { + let (set, validator, validators, total_weight, validator_weight) = + default_accumulate_setup(); + + let mut db = MemDb::new(); + let mut txn = db.txn(); + + let preprocess_topic = share_topic.preceding_topic().unwrap(); + + if preprocess_topic.requires_recognition() { + TributaryDb::recognize_topic(&mut txn, set, preprocess_topic); + } + + // Accumulate the preprocess to threshold + accumulate_to_threshold( + &mut txn, + set, + &validators, + total_weight, + OsRng.next_u64(), + preprocess_topic, + |_| vec![random_vec_u8(&mut OsRng, 0 ..= 128)], + None::>>)>, + ); + + // Accumulate a share with the same Vec> type + let share_data: Vec> = vec![random_vec_u8(&mut OsRng, 0 ..= 128)]; + let result = TributaryDb::accumulate::>>( + &mut txn, + set, + &validators, + total_weight, + OsRng.next_u64(), + share_topic, + validator, + validator_weight, + &share_data, + ); + txn.commit(); + + assert!( + !TributaryDb::is_fatally_slashed(&db, set, validator), + "preceding key exists (same type) so validator should not be slashed" + ); + assert!(matches!(result, DataSet::None), "below threshold (1 of 3)"); + assert_eq!( + Accumulated::>>::get(&db, set, share_topic, validator), + Some(share_data) + ); + } + } + } + + mod accumulate_next_attempt_topic { + use super::*; + + #[test] + fn accumulates_to_threshold() { + for topic in all_preprocess_topics_and_attempts() { + let (set, _validator, validators, total_weight, _validator_weight) = + default_accumulate_setup(); + let mut db = MemDb::new(); + let block_number = OsRng.next_u64(); + + { + let mut txn = db.txn(); + if topic.requires_recognition() { + TributaryDb::recognize_topic(&mut txn, set, topic); + } + + let result = accumulate_to_threshold( + &mut txn, + set, + &validators, + total_weight, + block_number, + topic, + |i| [u8::try_from(i).unwrap(); 32], + Some(|i: usize, result: &DataSet<[u8; 32]>| { + if i < 2 { + assert!(matches!(result, DataSet::None)); + } else { + match result { + DataSet::Participating(data_set) => assert_eq!(data_set.len(), 3), + DataSet::None => panic!("expected Participating after crossing threshold"), + } + } + }), + ); + assert!(matches!(result, DataSet::Participating(_))); + + txn.commit(); + } + + let has_reattempt = topic.reattempt_topic().is_some(); + + for v in &validators { + assert!(!TributaryDb::is_fatally_slashed(&db, set, *v)); + if has_reattempt { + assert!( + Accumulated::<[u8; 32]>::get(&db, set, topic, *v).is_some(), + "data should be preserved when reattempt exists: {topic:?}" + ); + } else { + assert!( + Accumulated::<[u8; 32]>::get(&db, set, topic, *v).is_none(), + "data should be cleaned up when no reattempt: {topic:?}" + ); + } + } + + assert_eq!(AccumulatedWeight::get(&db, set, topic), Some(3)); + } + } + + /// Accumulating for a topic proceeds when the next attempt's topic has no + /// weight, regardless of whether an unrelated topic already has weight. + #[test] + fn not_nopd_without_next_attempt_weight() { + for topic in all_preprocess_topics_and_attempts() { + let (set, _validator, validators, total_weight, validator_weight) = + default_accumulate_setup(); + + let mut db = MemDb::new(); + + // Accumulate for an unrelated topic so some weight exists in the DB + let unrelated = Topic::SlashReport; + { + let mut txn = db.txn(); + let result = TributaryDb::accumulate::<[u8; 32]>( + &mut txn, + set, + &validators, + total_weight, + OsRng.next_u64(), + unrelated, + validators[0], + validator_weight, + &random_bytes(&mut OsRng), + ); + assert!(matches!(result, DataSet::None)); + txn.commit(); + } + + assert_eq!(AccumulatedWeight::get(&db, set, unrelated), Some(validator_weight)); + + // Accumulating for our topic proceeds (not NOP'd by unrelated weight) + let data = random_bytes(&mut OsRng); + { + let mut txn = db.txn(); + if topic.requires_recognition() { + TributaryDb::recognize_topic(&mut txn, set, topic); + } + let result = TributaryDb::accumulate::<[u8; 32]>( + &mut txn, + set, + &validators, + total_weight, + OsRng.next_u64(), + topic, + validators[1], + validator_weight, + &data, + ); + assert!(matches!(result, DataSet::None), "below threshold (1 of 3)"); + txn.commit(); + } + + // Data was stored (not NOP'd) + assert_eq!(Accumulated::<[u8; 32]>::get(&db, set, topic, validators[1]), Some(data)); + assert_eq!(AccumulatedWeight::get(&db, set, topic), Some(validator_weight)); + } + } + } + + mod accumulate_reattempt_topic { + use super::*; + + #[test] + fn data_preserved_or_cleaned_up_based_on_reattempt() { + for topic in all_preprocess_topics_and_attempts() { + let (set, _validator, validators, total_weight, _validator_weight) = + default_accumulate_setup(); + let mut db = MemDb::new(); + let block_number = 1_000_000u64; + + { + let mut txn = db.txn(); + if topic.requires_recognition() { + TributaryDb::recognize_topic(&mut txn, set, topic); + } + + let result = accumulate_to_threshold( + &mut txn, + set, + &validators, + total_weight, + block_number, + topic, + |i| [u8::try_from(i).unwrap(); 32], + None::, + ); + assert!(matches!(result, DataSet::Participating(_))); + txn.commit(); + } + + if topic.reattempt_topic().is_some() { + for (i, v) in validators.iter().enumerate() { + assert_eq!( + Accumulated::<[u8; 32]>::get(&db, set, topic, *v), + Some([u8::try_from(i).unwrap(); 32]), + "data should be preserved when reattempt exists: {topic:?}" + ); + } + } else { + assert!( + Reattempt::get(&db, set, block_number).is_none(), + "no reattempt should be queued: {topic:?}" + ); + for v in &validators { + assert!( + Accumulated::<[u8; 32]>::get(&db, set, topic, *v).is_none(), + "data should be cleaned up when no reattempt: {topic:?}" + ); + } + } + } + } + + /// Reattempt scheduling panics on overflow instead of silently scheduling + /// at an unreachable block. + #[test] + fn reattempt_schedule_panics_on_overflow() { + let (set, _validator, validators, total_weight, _validator_weight) = + default_accumulate_setup(); + + // attempt just below u64::MAX so reattempt_topic() returns Some(u64::MAX) + let topic = + Topic::DkgConfirmation { attempt: u64::MAX - 1, round: SigningProtocolRound::Preprocess }; + assert_eq!(topic.reattempt_topic().unwrap().0, u64::MAX); + + // block_number near u64::MAX forces checked_add to overflow + let block_number = u64::MAX - 1; + + let mut db = MemDb::new(); + let mut txn = db.txn(); + TributaryDb::recognize_topic(&mut txn, set, topic); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + accumulate_to_threshold( + &mut txn, + set, + &validators, + total_weight, + block_number, + topic, + |i| [u8::try_from(i).unwrap(); 32], + None::, + ); + })); + + assert!(result.is_err(), "should panic on reattempt block number overflow"); + } + + #[test] + fn succeeding_topic_recognized_after_threshold() { + for topic in all_preprocess_topics_and_attempts() { + let (set, _validator, validators, total_weight, _validator_weight) = + default_accumulate_setup(); + let mut db = MemDb::new(); + + let succeeding = topic.succeeding_topic().unwrap(); + + { + let mut txn = db.txn(); + if topic.requires_recognition() { + TributaryDb::recognize_topic(&mut txn, set, topic); + } + accumulate_to_threshold( + &mut txn, + set, + &validators, + total_weight, + OsRng.next_u64(), + topic, + |i| [u8::try_from(i).unwrap(); 32], + None::, + ); + txn.commit(); + } + + assert!(TributaryDb::recognized(&db, set, succeeding)); + assert_eq!( + AccumulatedWeight::get(&db, set, succeeding), + Some(0), + "succeeding topic should be recognized after threshold: {topic:?}" + ); + } + } + } + + /// Tests the invariant documented at fn accumulate: + /// "This function will only be called once for a (validator, topic) tuple" + mod duplicate_accumulate { + use super::*; + + /// Calling accumulate twice for the same (validator, topic) panics, + /// enforcing the invariant that the nonce system prevents duplicate calls. + #[test] + #[should_panic = "accumulate called twice for the same (validator, topic) tuple"] + fn double_call_before_threshold_panics() { + let topic = Topic::RemoveParticipant { participant: random_serai_address(&mut OsRng) }; + let (set, validator, validators, total_weight, validator_weight) = + default_accumulate_setup(); + let mut db = MemDb::new(); + let mut txn = db.txn(); + + // First call succeeds + TributaryDb::accumulate::>( + &mut txn, + set, + &validators, + total_weight, + OsRng.next_u64(), + topic, + validator, + validator_weight, + &random_vec_u8(&mut OsRng, 0 ..= 128), + ); + + // Second call with same (validator, topic) should panic + TributaryDb::accumulate::>( + &mut txn, + set, + &validators, + total_weight, + OsRng.next_u64(), + topic, + validator, + validator_weight, + &random_vec_u8(&mut OsRng, 0 ..= 128), + ); + } + + /// After threshold with a reattempt topic, Accumulated entries are + /// preserved (for the reattempt protocol), so the duplicate assert fires. + #[test] + #[should_panic = "accumulate called twice for the same (validator, topic) tuple"] + fn double_call_after_threshold_with_reattempt_panics() { + // DkgConfirmation Preprocess has a reattempt topic, so entries survive post-threshold + let topic = Topic::DkgConfirmation { attempt: 0, round: SigningProtocolRound::Preprocess }; + assert!(topic.reattempt_topic().is_some()); + let (set, validator, validators, total_weight, validator_weight) = + default_accumulate_setup(); + let mut db = MemDb::new(); + let mut txn = db.txn(); + let block_number = OsRng.next_u64(); + + TributaryDb::recognize_topic(&mut txn, set, topic); + + accumulate_to_threshold::, _, _>( + &mut txn, + set, + &validators, + total_weight, + block_number, + topic, + |i| vec![u8::try_from(i).unwrap()], + None::>)>, + ); + + // Entries preserved for reattempt, duplicate panics + TributaryDb::accumulate::>( + &mut txn, + set, + &validators, + total_weight, + block_number, + topic, + validator, + validator_weight, + &random_vec_u8(&mut OsRng, 0 ..= 128), + ); + } + + /// After threshold without a reattempt topic, Accumulated entries are + /// cleaned up. The duplicate call does not hit the assertion (key is gone) + /// and instead falls through to the weight >= threshold NOP. + /* + TODO: This test is unclear. + + It should test an unreachable case (double accumulate), which is why that is allowed to + generally panic. This test shows the literal behavior where if the topic's data is pruned, + then those asserts for an unreachable case disappear, which is fine. Why are we testing + this behavior though? It should be unreachable and unobservable. This is more akin to a bug + report that sanity checks disappear than functionality we want to assert the behavior of. + */ + #[test] + fn double_call_after_threshold_without_reattempt_is_nop() { + // RemoveParticipant has no reattempt, so entries are cleaned up post-threshold + let topic = Topic::RemoveParticipant { participant: random_serai_address(&mut OsRng) }; + let (set, validator, validators, total_weight, validator_weight) = + default_accumulate_setup(); + let mut db = MemDb::new(); + let mut txn = db.txn(); + let block_number = OsRng.next_u64(); + + accumulate_to_threshold::, _, _>( + &mut txn, + set, + &validators, + total_weight, + block_number, + topic, + |i| vec![u8::try_from(i).unwrap()], + None::>)>, + ); + + let weight_after_threshold = AccumulatedWeight::get(&txn, set, topic).unwrap(); + + // Entry was cleaned up, so assertion doesn't fire. + // Falls through to the `accumulated_weight >= required_participation` NOP. + let result = TributaryDb::accumulate::>( + &mut txn, + set, + &validators, + total_weight, + block_number, + topic, + validator, + validator_weight, + &random_vec_u8(&mut OsRng, 0 ..= 128), + ); + + assert!(matches!(result, DataSet::None), "should be NOP after threshold"); + assert_eq!( + AccumulatedWeight::get(&txn, set, topic).unwrap(), + weight_after_threshold, + "weight should not change" + ); + } + } + + mod fuzz { + use super::*; + + /// Verify all DB invariants after a single `TributaryDb::accumulate` call. + /// + /// Independently computes the expected DB state by tracing the code paths in `accumulate` + /// based on the inputs and pre-state, then asserts the actual DB matches. + #[expect(clippy::too_many_arguments, clippy::fn_params_excessive_bools)] + fn verify_accumulate_invariants( + db: &MemDb, + set: ExternalValidatorSet, + total_weight: u16, + block_number: u64, + topic: Topic, + validator: SeraiAddress, + validator_weight: u16, + data: &Vec, + pre_weight: Option, + pre_slashed: bool, + has_preceding_accumulated: bool, + has_next_topic_weight: bool, + validator_in_list: bool, + result: &DataSet>, + ) { + let required = topic.required_participation(total_weight); + let post_slashed = TributaryDb::is_fatally_slashed(db, set, validator); + let post_weight = AccumulatedWeight::get(db, set, topic); + + // Slash for participating in unrecognized topic requiring recognition. + if topic.requires_recognition() && pre_weight.is_none() { + assert!(post_slashed, "should be fatally slashed for unrecognized topic"); + assert!(matches!(result, DataSet::None)); + assert_eq!(post_weight, None, "weight should remain None after recognition slash"); + assert!( + Accumulated::>::get(db, set, topic, validator).is_none(), + "no data should be stored after recognition slash" + ); + return; + } + + let weight_before = pre_weight.unwrap_or(0); + + // Slash for participating without completing the preceding topic. + if topic.preceding_topic().is_some() && (!has_preceding_accumulated) { + assert!(post_slashed, "should be fatally slashed for missing preceding participation"); + assert!(matches!(result, DataSet::None)); + assert_eq!(post_weight, pre_weight, "weight unchanged after preceding slash"); + return; + } + + // Already accumulated past the threshold - NOP. + if weight_before >= required { + assert!(matches!(result, DataSet::None)); + assert_eq!(post_weight, pre_weight, "weight unchanged when past threshold"); + if !pre_slashed { + assert!(!post_slashed, "should not be slashed on threshold NOP"); + } + return; + } + + // Old attempt, the next attempt's topic already has weight. + // Note: pre_weight may be None (topic not yet recognized) which is preserved. + let next_attempt_superseded = has_next_topic_weight && topic.next_attempt_topic().is_some(); + if next_attempt_superseded { + assert!(matches!(result, DataSet::None)); + assert_eq!(post_weight, pre_weight, "weight unchanged for superseded attempt"); + if !pre_slashed { + assert!(!post_slashed, "should not be slashed on superseded NOP"); + } + return; + } + + // Accumulation happened + let new_weight = weight_before + validator_weight; + assert_eq!(post_weight, Some(new_weight), "weight should reflect accumulation"); + + if !pre_slashed { + assert!(!post_slashed, "should not be slashed after valid accumulation"); + } + + if new_weight >= required { + // Threshold crossed. + + // Reattempt should be queued if topic is reattemptable. + if let Some((reattempt_attempt, reattempt_topic)) = topic.reattempt_topic() { + let blocks_till = reattempt_attempt + .min(10) + .checked_mul(u64::from(BASE_REATTEMPT_DELAY)) + .expect("reattempt delay overflowed u64"); + let recognize_at = + block_number.checked_add(blocks_till).expect("reattempt block number overflowed u64"); + + let queued = Reattempt::get(db, set, recognize_at); + assert!(queued.is_some(), "reattempt should be queued at block {recognize_at}"); + assert!( + queued.unwrap().contains(&reattempt_topic), + "reattempt queue should contain {reattempt_topic:?}" + ); + } + + // Succeeding topic should be recognized (weight set to 0). + if let Some(succeeding) = topic.succeeding_topic() { + assert_eq!( + AccumulatedWeight::get(db, set, succeeding), + Some(0), + "succeeding topic should be recognized with weight=0" + ); + } + + // Accumulated data cleanup depends on whether a reattempt exists. + // The cleanup loop only iterates the `validators` slice, so data for a validator + // not in the list is never deleted regardless of reattempt status. + let has_reattempt = topic.reattempt_topic().is_some(); + if has_reattempt || !validator_in_list { + assert_eq!( + Accumulated::>::get(db, set, topic, validator), + Some(data.clone()), + "data should be preserved (reattempt={has_reattempt}, in_list={validator_in_list})" + ); + } else { + assert!( + Accumulated::>::get(db, set, topic, validator).is_none(), + "data should be cleaned up when no reattempt and validator in list" + ); + } + + // Result depends on whether the validator was in the collection list. + if validator_in_list { + match result { + DataSet::Participating(data_set) => { + assert!( + data_set.contains_key(&validator), + "validator should be in result data set" + ); + assert_eq!( + data_set.get(&validator).unwrap(), + data, + "result data should match input" + ); + } + DataSet::None => { + panic!("result should be Participating when threshold crossed by listed validator"); + } + } + } else { + match topic.participating() { + Participating::Participated => { + // Validator accumulated but isn't in the list, so participated=false + assert!(matches!(result, DataSet::None), "Participated + not in list => None"); + } + Participating::Everyone => { + // Everyone always returns Participating, but the validator's data won't + // be in the set (it was only collected from the validators slice) + match result { + DataSet::Participating(data_set) => { + assert!( + !data_set.contains_key(&validator), + "validator not in list should not appear in data set" + ); + } + DataSet::None => { + panic!("Everyone topics always return Participating"); + } + } + } + } + } + } else { + // Below threshold + // data stored, result is None. + assert!(matches!(result, DataSet::None), "result should be None when below threshold"); + assert_eq!( + Accumulated::>::get(db, set, topic, validator), + Some(data.clone()), + "accumulated data should be stored" + ); + } + } + + #[test] + fn fuzz_accumulate() { + for _ in 0 .. 1000 { + let has_initial_weight = OsRng.gen::(); + let initial_weight = OsRng.gen_range(0u16 .. KeyShares::MAX_PER_SET); + let total_weight = OsRng.gen_range(1u16 .. KeyShares::MAX_PER_SET); + + let has_next_topic_weight = OsRng.gen::(); + let next_topic_initial_weight = OsRng.gen_range(0u16 .. KeyShares::MAX_PER_SET); + + let has_preceding_topic_accumulated = OsRng.gen::(); + + let topic_variant = OsRng.gen_range(0u8 .. 5); + let attempt = OsRng.gen_range(0u64 .. 100); + let round = if OsRng.gen::() { + SigningProtocolRound::Preprocess + } else { + SigningProtocolRound::Share + }; + let cosign_block = OsRng.next_u64(); + let batch_id: [u8; 32] = OsRng.gen(); + let validator_weight = OsRng.gen_range(1u16 .. KeyShares::MAX_PER_SET); + let block_number = OsRng.gen_range(1u64 .. u64::MAX); + let data: Vec = (0 .. OsRng.gen_range(0usize .. 64)).map(|_| OsRng.gen()).collect(); + + let num_validators = OsRng.gen_range(1u16 .. u16::from(u8::MAX)); + let cur_validator = OsRng.gen_range(0u16 .. u16::from(u8::MAX)); + let validator_in_list = OsRng.gen::(); + + let topic = match topic_variant % 5 { + 0 => Topic::RemoveParticipant { participant: random_serai_address(&mut OsRng) }, + 1 => Topic::DkgConfirmation { attempt: attempt % 100, round }, + 2 => Topic::SlashReport, + 3 => { + Topic::Sign { id: VariantSignId::Cosign(cosign_block), attempt: attempt % 100, round } + } + _ => Topic::Sign { id: VariantSignId::Batch(batch_id), attempt: attempt % 100, round }, + }; + + let mut db = MemDb::new(); + let set = random_validator_set(&mut OsRng); + + let validators: Vec = + (0 .. num_validators).map(|_i| random_serai_address(&mut OsRng)).collect(); + + let validator_weight = validator_weight.min(total_weight).max(1); + + let db_clone = db.clone(); + let mut txn = db.txn(); + + if has_initial_weight { + AccumulatedWeight::set(&mut txn, set, topic, &initial_weight); + } + + if has_next_topic_weight { + if let Some(next_attempt_topic) = topic.next_attempt_topic() { + AccumulatedWeight::set(&mut txn, set, next_attempt_topic, &next_topic_initial_weight); + } + } + + // When validator_in_list is false, the accumulating validator is an outsider + // not present in the validators slice. This exercises the `participated = false` + // branch when the threshold is crossed. + let cur_validator = usize::from(cur_validator) % validators.len(); + let validator = if validator_in_list { + validators[cur_validator] + } else { + random_serai_address(&mut OsRng) + }; + + if has_preceding_topic_accumulated { + if let Some(preceding_topic) = topic.preceding_topic() { + Accumulated::set(&mut txn, set, preceding_topic, validator, &data); + } + } + + let pre_weight = AccumulatedWeight::get(&txn, set, topic); + let pre_slashed = TributaryDb::is_fatally_slashed(&txn, set, validator); + + let result = TributaryDb::accumulate::>( + &mut txn, + set, + &validators, + total_weight, + block_number, + topic, + validator, + validator_weight, + &data, + ); + + txn.commit(); + + verify_accumulate_invariants( + &db_clone, + set, + total_weight, + block_number, + topic, + validator, + validator_weight, + &data, + pre_weight, + pre_slashed, + has_preceding_topic_accumulated, + has_next_topic_weight, + validator_in_list, + &result, + ); + } + } + } + } +} diff --git a/coordinator/tributary/src/tests/mod.rs b/coordinator/tributary/src/tests/mod.rs new file mode 100644 index 000000000..7e742eb68 --- /dev/null +++ b/coordinator/tributary/src/tests/mod.rs @@ -0,0 +1,290 @@ +use core::future::Future; +use std::collections::HashMap; + +use zeroize::Zeroizing; +use rand::{RngCore, CryptoRng, Rng, rngs::OsRng}; + +use ciphersuite::{group::GroupEncoding as _, WrappedGroup}; +use dalek_ff_group::Ristretto; + +use serai_primitives::{ + address::SeraiAddress, + validator_sets::ExternalValidatorSet, + test_helpers::{ + random_bytes, random_block_hash, random_serai_address, random_vec_u8, random_validator_set, + }, +}; + +use tendermint::{ + SignedMessage, Message, Data, + ext::{BlockNumber, RoundNumber}, +}; +use tributary_sdk::{P2p, tendermint::TendermintBlock}; + +use messages::sign::VariantSignId; +use serai_coordinator_substrate::NewSetInformation; + +use crate::*; + +mod transaction; +mod db; +mod scan_block; +mod scan_tributary; +mod tributary; + +/// A P2P implementation which is a NOP and does nothing. +#[derive(Clone)] +struct NopP2p; +impl P2p for NopP2p { + fn broadcast(&self, _: [u8; 32], _: Vec) -> impl Send + Future { + async {} + } +} + +fn random_key(rng: &mut R) -> Zeroizing<::F> { + Zeroizing::new(::F::random(&mut *rng)) +} + +fn random_serai_address_and_key( + rng: &mut R, +) -> (::G, SeraiAddress) { + let key = Ristretto::generator() * *random_key(rng); + (key, SeraiAddress(key.to_bytes())) +} + +fn random_signed(rng: &mut R) -> Signed { + let signed = tributary_sdk::tests::random_signed(&mut *rng); + Signed { signer: signed.signer, signature: signed.signature } +} + +/// One of each signed transaction kind, and attempts: at 0 and a random attempt. +#[expect(clippy::large_types_passed_by_value)] +fn all_signed_transactions_and_attempts(signed: Signed) -> Vec { + let random_attempt = OsRng.next_u64().saturating_add(1); + vec![ + // RemoveParticipant + Transaction::RemoveParticipant { participant: random_serai_address(&mut OsRng), signed }, + // DkgParticipation + Transaction::DkgParticipation { participation: random_vec_u8(&mut OsRng, 0 ..= 128), signed }, + // DkgConfirmationPreprocess + Transaction::DkgConfirmationPreprocess { + attempt: 0, + preprocess: random_bytes(&mut OsRng), + signed, + }, + Transaction::DkgConfirmationPreprocess { + attempt: random_attempt, + preprocess: random_bytes(&mut OsRng), + signed, + }, + // DkgConfirmationShare + Transaction::DkgConfirmationShare { attempt: 0, share: random_bytes(&mut OsRng), signed }, + Transaction::DkgConfirmationShare { + attempt: random_attempt, + share: random_bytes(&mut OsRng), + signed, + }, + // Sign Preprocess + Transaction::Sign { + id: VariantSignId::Transaction(random_bytes(&mut OsRng)), + attempt: 0, + round: SigningProtocolRound::Preprocess, + data: vec![random_vec_u8(&mut OsRng, 0 ..= 128)], + signed, + }, + Transaction::Sign { + id: VariantSignId::Transaction(random_bytes(&mut OsRng)), + attempt: random_attempt, + round: SigningProtocolRound::Preprocess, + data: vec![random_vec_u8(&mut OsRng, 0 ..= 128)], + signed, + }, + // Sign Share + Transaction::Sign { + id: VariantSignId::Batch(random_bytes(&mut OsRng)), + attempt: 0, + round: SigningProtocolRound::Share, + data: vec![random_vec_u8(&mut OsRng, 0 ..= 128), random_vec_u8(&mut OsRng, 0 ..= 128)], + signed, + }, + Transaction::Sign { + id: VariantSignId::Batch(random_bytes(&mut OsRng)), + attempt: random_attempt, + round: SigningProtocolRound::Share, + data: vec![random_vec_u8(&mut OsRng, 0 ..= 128), random_vec_u8(&mut OsRng, 0 ..= 128)], + signed, + }, + // SlashReport + Transaction::SlashReport { slash_points: (0 .. 3).map(|_| OsRng.next_u32()).collect(), signed }, + ] +} + +/// One of each provided transaction kind. +fn all_provided_transactions() -> Vec { + vec![ + Transaction::Cosign { substrate_block_hash: random_block_hash(&mut OsRng) }, + Transaction::Cosigned { substrate_block_hash: random_block_hash(&mut OsRng) }, + Transaction::SubstrateBlock { hash: random_block_hash(&mut OsRng) }, + Transaction::Batch { hash: random_bytes(&mut OsRng) }, + ] +} + +/// One of each of all transaction kinds. +fn all_transactions() -> Vec { + let mut txs = all_signed_transactions_and_attempts(random_signed(&mut OsRng)); + txs.extend(all_provided_transactions()); + txs +} + +/// Assert that no messages remain in either the processor or DKG confirmation queues. +fn assert_no_pending_messages(txn: &mut impl serai_db::DbTxn, set: ExternalValidatorSet) { + assert!( + crate::ProcessorMessages::try_recv(txn, set).is_none(), + "unexpected remaining `ProcessorMessages`", + ); + assert!( + crate::DkgConfirmationMessages::try_recv(txn, set).is_none(), + "unexpected remaining `DkgConfirmationMessages`", + ); +} + +fn random_variant_sign_id() -> VariantSignId { + // TODO: Randomly select a variant + VariantSignId::Transaction(random_bytes(&mut OsRng)) +} + +/// The topic for a sign protocol for a just-recognized ID. +fn initial_sign_topic(id: VariantSignId) -> Topic { + Topic::Sign { id, attempt: 0, round: SigningProtocolRound::Preprocess } +} + +/// Assert the DB invariants established by `TributaryDb::start_cosigning`: +/// - `ActivelyCosigning` is set to the given block hash. +/// - The cosign topic is recognized (`AccumulatedWeight` initialized). +/// - The cosign topic was queued for recognition (`RecognizedTopics`). +fn assert_start_cosigning_invariants( + txn: &mut impl serai_db::DbTxn, + set: ExternalValidatorSet, + block_hash: serai_primitives::BlockHash, + block_number: u64, +) { + let expected_topic = initial_sign_topic(VariantSignId::Cosign(block_number)); + + assert_eq!( + ActivelyCosigning::get(txn, set), + Some(block_hash), + "ActivelyCosigning should be set to the block hash after start_cosigning" + ); + assert!( + RecognizedTopics::recognized(txn, set, expected_topic), + "cosign topic should be recognized after start_cosigning" + ); + assert_eq!( + RecognizedTopics::try_recv_topic_requiring_recognition(txn, set), + Some(expected_topic), + "cosign topic should be queued for recognition after start_cosigning" + ); +} + +/// Construct a `borsh`-encoded `SignedMessage` for our `TendermintNetwork`. +fn make_signed_message_bytes(sender: [u8; 32]) -> Vec { + let msg = Message::<[u8; 32], TendermintBlock, [u8; 64]> { + sender, + block: BlockNumber(0), + round: RoundNumber(0), + data: Data::Prevote(None), + }; + borsh::to_vec(&SignedMessage { msg, sig: [0u8; 64] }).unwrap() +} + +/// Drain expected messages produced by the given transactions, then assert both queues are empty. +/// +/// Some transactions produce messages on first submission (DkgParticipation, Cosign, SlashReport). +/// This function drains those expected messages before calling `assert_no_pending_messages`. +fn assert_block_side_effects( + txn: &mut impl serai_db::DbTxn, + set: ExternalValidatorSet, + transactions: &[tributary_sdk::Transaction], +) { + for tx in transactions { + // TODO: Expand from checking the message is `Some(_)` to the exact expected message + match tx { + tributary_sdk::Transaction::Application(app_tx) => match app_tx { + Transaction::DkgParticipation { .. } => { + assert!( + crate::ProcessorMessages::try_recv(txn, set).is_some(), + "DkgParticipation should produce a processor message", + ); + } + Transaction::Cosign { .. } => { + assert!( + crate::ProcessorMessages::try_recv(txn, set).is_some(), + "Cosign should produce a processor message", + ); + } + Transaction::SlashReport { .. } => { + assert!( + RecognizedTopics::recognized(txn, set, Topic::SlashReport), + "SlashReport topic should be recognized", + ); + } + // TODO: Some of these will cause effects, but only conditionally + Transaction::RemoveParticipant { .. } | + Transaction::DkgConfirmationPreprocess { .. } | + Transaction::DkgConfirmationShare { .. } | + Transaction::Cosigned { .. } | + Transaction::SubstrateBlock { .. } | + Transaction::Batch { .. } | + Transaction::Sign { .. } => {} + }, + tributary_sdk::Transaction::Tendermint(_) => {} + } + } + assert_no_pending_messages(txn, set); +} + +fn new_test_set_info(validators: &[(SeraiAddress, u16)]) -> NewSetInformation { + let set = random_validator_set(&mut OsRng); + let serai_block_hash = random_bytes(&mut OsRng); + let serai_block_time = OsRng.next_u64(); + let threshold = u16::try_from( + ((usize::from(validators.iter().map(|(_validator, weight)| *weight).sum::()) * 2) / 3) + 1, + ) + .unwrap(); + let validators = validators.to_vec(); + let evrf_public_keys = vec![]; + NewSetInformation::new( + set, + serai_block_hash, + serai_block_time, + threshold, + validators, + evrf_public_keys, + ) +} + +type Setup = ( + Vec<(::G, SeraiAddress)>, + Vec<(SeraiAddress, u16)>, + Vec, + HashMap, + u16, +); + +/// Generate `n` random validators (weight 1 each) with keys, returning all derived collections. +fn setup_n_validators_with_keys(n: u16) -> Setup { + let keys_addrs: Vec<(::G, SeraiAddress)> = + (0 .. n).map(|_| random_serai_address_and_key(&mut OsRng)).collect(); + let validator_data: Vec<(SeraiAddress, u16)> = + keys_addrs.iter().map(|(_, addr)| (*addr, 1u16)).collect(); + let validators: Vec = validator_data.iter().map(|(a, _)| *a).collect(); + let weights: HashMap = validator_data.iter().copied().collect(); + let total_weight = n; + + (keys_addrs, validator_data, validators, weights, total_weight) +} + +/// Common test setup with 3 random validators each with weight 1, total_weight = 3. +fn setup_test_validators_and_weights_with_keys() -> Setup { + setup_n_validators_with_keys(3) +} diff --git a/coordinator/tributary/src/tests/scan_block.rs b/coordinator/tributary/src/tests/scan_block.rs new file mode 100644 index 000000000..dc6575745 --- /dev/null +++ b/coordinator/tributary/src/tests/scan_block.rs @@ -0,0 +1,1299 @@ +use core::marker::PhantomData; + +use schnorr::SchnorrSignature; + +use serai_primitives::test_helpers::{random_block_hash, random_vec_u8}; + +use serai_db::{Db as _, DbTxn, MemDb}; +use tributary_sdk::{ + tendermint::tx::TendermintTx, Evidence, Transaction as TributaryTransaction, BlockHeader, Block, +}; + +use serai_cosign_types::CosignIntent; +use crate::{db::CosignIntents as DbCosignIntents, *}; +use super::*; + +fn new_scan_block<'a, TDT: DbTxn>( + txn: &'a mut TDT, + set_info: &'a NewSetInformation, + validators: &'a [SeraiAddress], + total_weight: u16, + validator_weights: &'a HashMap, +) -> ScanBlock<'a, MemDb, TDT, NopP2p> { + ScanBlock { + _td: PhantomData, + _p2p: PhantomData, + tributary_txn: txn, + set: set_info, + validators, + total_weight, + validator_weights, + } +} + +/// Create a Signed with the given signer key and a random signature. +fn random_signed_for_key(signer: ::G) -> Signed { + Signed { + signer, + signature: SchnorrSignature { + R: Ristretto::generator() * ::F::random(&mut OsRng), + s: ::F::random(&mut OsRng), + }, + } +} + +#[test] +fn potentially_start_cosign() { + let (_, validator_data, validators, weights, total_weight) = + setup_test_validators_and_weights_with_keys(); + let set_info = new_test_set_info(&validator_data); + let set = set_info.set; + + // Already actively cosigning: should not replace the actively cosigning block + { + let mut db = MemDb::new(); + let initial_block_hash = random_block_hash(&mut OsRng); + + { + let mut txn = db.txn(); + TributaryDb::start_cosigning(&mut txn, set, initial_block_hash, OsRng.next_u64()); + let new_block_hash = random_block_hash(&mut OsRng); + TributaryDb::set_latest_substrate_block_to_cosign(&mut txn, set, new_block_hash); + txn.commit(); + } + + let mut txn = db.txn(); + { + let mut scan_block = new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + scan_block.potentially_start_cosign(); + } + + // Did not replace initial_block_hash for new_block_hash + assert_eq!(TributaryDb::actively_cosigning(&mut txn, set), Some(initial_block_hash)); + } + + // No TributaryDb::latest_substrate_block_to_cosign block: nop + { + let mut db = MemDb::new(); + let mut txn = db.txn(); + { + let mut scan_block = new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + scan_block.potentially_start_cosign(); + } + assert!(TributaryDb::actively_cosigning(&mut txn, set).is_none()); + } + + // Already cosigned: nop + { + let mut db = MemDb::new(); + let initial_block_hash = random_block_hash(&mut OsRng); + + { + let mut txn = db.txn(); + TributaryDb::set_latest_substrate_block_to_cosign(&mut txn, set, initial_block_hash); + TributaryDb::mark_cosigned(&mut txn, set, initial_block_hash); + txn.commit(); + } + + let mut txn = db.txn(); + { + let mut scan_block = new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + scan_block.potentially_start_cosign(); + } + + assert!(TributaryDb::actively_cosigning(&mut txn, set).is_none()); + } + + // Ready to cosign: starts cosigning and sends processor message + { + let mut db = MemDb::new(); + let block_hash = random_block_hash(&mut OsRng); + let global_session = random_bytes(&mut OsRng); + + let intent = + CosignIntent { global_session, block_number: OsRng.next_u64(), block_hash, notable: false }; + + { + let mut txn = db.txn(); + TributaryDb::set_latest_substrate_block_to_cosign(&mut txn, set, block_hash); + CosignIntents::provide(&mut txn, set, &intent); + txn.commit(); + } + + let mut txn = db.txn(); + { + let mut scan_block = new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + scan_block.potentially_start_cosign(); + } + + assert_start_cosigning_invariants(&mut txn, set, block_hash, intent.block_number); + assert!(ProcessorMessages::try_recv(&mut txn, set).is_some()); + } + + // Panics when stored intent's block_hash differs from latest_substrate_block_to_cosign + { + let mut db = MemDb::new(); + let block_hash = random_block_hash(&mut OsRng); + let global_session = random_bytes(&mut OsRng); + + { + let mut txn = db.txn(); + TributaryDb::set_latest_substrate_block_to_cosign(&mut txn, set, block_hash); + + let new_block_hash = random_block_hash(&mut OsRng); + DbCosignIntents::set( + &mut txn, + set, + // Store the intent under block_hash (the key `CosignIntents::take` will look up) + block_hash, + &CosignIntent { + global_session, + block_number: OsRng.next_u64(), + // but the intent's block_hash field is a new_block_hash + block_hash: new_block_hash, + notable: false, + }, + ); + txn.commit(); + } + + let result = std::panic::catch_unwind(move || { + let mut txn = db.txn(); + let mut scan_block = new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + scan_block.potentially_start_cosign(); + }); + let err = result.expect_err("should panic on differing intent block hash"); + let msg = err.downcast_ref::().expect("panic payload should be a String"); + assert!( + msg.contains("provided CosignIntent wasn't saved by its block hash"), + "unexpected panic message: {msg}" + ); + } +} + +#[test] +fn accumulate_dkg_confirmation() { + let (_, validator_data, validators, weights, total_weight) = + setup_test_validators_and_weights_with_keys(); + let (v1, v2, v3) = (validators[0], validators[1], validators[2]); + let set_info = new_test_set_info(&validator_data); + let set = set_info.set; + let topic = Topic::DkgConfirmation { attempt: 0, round: SigningProtocolRound::Preprocess }; + + // Panics if the topic isn't DkgConfirmation + { + let mut db = MemDb::new(); + let mut txn = db.txn(); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut scan_block = new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + scan_block.accumulate_dkg_confirmation( + OsRng.next_u64(), + Topic::RemoveParticipant { participant: random_serai_address(&mut OsRng) }, + &random_vec_u8(&mut OsRng, 4 ..= 4), + validators[0], + ); + })); + + assert!(result.is_err(), "should panic when called with a non-DkgConfirmation topic"); + } + + // Threshold crossed: third accumulation returns SignId + correctly mapped data + { + let mut db = MemDb::new(); + let mut txn = db.txn(); + let block_number = OsRng.next_u64(); + + { + let data1 = random_vec_u8(&mut OsRng, 4 ..= 4); + let data2 = random_vec_u8(&mut OsRng, 4 ..= 4); + let data3 = random_vec_u8(&mut OsRng, 4 ..= 4); + + let mut scan_block = new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + + assert!(scan_block.accumulate_dkg_confirmation(block_number, topic, &data1, v1).is_none()); + assert!(scan_block.accumulate_dkg_confirmation(block_number, topic, &data2, v2).is_none()); + let result = scan_block.accumulate_dkg_confirmation(block_number, topic, &data3, v3); + + let (sign_id, data_set) = result.expect("third accumulation should cross threshold"); + + assert_eq!( + sign_id, + topic.dkg_confirmation_sign_id(set).unwrap(), + "SignId must match what dkg_confirmation_sign_id produces" + ); + + // Participants are 1-indexed by list position, not by weight-based indices + assert_eq!(data_set.len(), 3); + assert_eq!(data_set[&Participant::new(1).unwrap()], data1); + assert_eq!(data_set[&Participant::new(2).unwrap()], data2); + assert_eq!(data_set[&Participant::new(3).unwrap()], data3); + } + + // Past threshold: further accumulations from a new validator are nops + { + // Add a 4th validator so we have a fresh signer after threshold is crossed. + // TODO: The set should have 4 validators from the start do we don't have a conflict here + let v4 = random_serai_address(&mut OsRng); + let mut validator_data_4 = validator_data.clone(); + validator_data_4.push((v4, 1)); + let validators_4: Vec = validator_data_4.iter().map(|(a, _)| *a).collect(); + let mut weights_4 = weights.clone(); + weights_4.insert(v4, 1); + let set_info_4 = new_test_set_info(&validator_data_4); + + let data4 = random_vec_u8(&mut OsRng, 4 ..= 4); + + { + let mut scan_block = new_scan_block(&mut txn, &set_info_4, &validators_4, 4, &weights_4); + assert!( + scan_block.accumulate_dkg_confirmation(block_number, topic, &data4, v4).is_none(), + "accumulation after threshold should be a NOP" + ); + } + } + } +} + +mod handle_application_tx { + use super::*; + + #[test] + fn dont_handle_signed_kind_from_fatally_slashed() { + let (_, validator_data, validators, weights, total_weight) = + setup_test_validators_and_weights_with_keys(); + let set_info = new_test_set_info(&validator_data); + let set = set_info.set; + let default_signer = SeraiAddress(Signed::default().signer().to_bytes()); + + let mut db = MemDb::new(); + + { + let mut txn = db.txn(); + TributaryDb::fatal_slash(&mut txn, set, default_signer, "test reason"); + assert!(TributaryDb::is_fatally_slashed(&txn, set, default_signer)); + txn.commit(); + } + + for tx in all_signed_transactions_and_attempts(Signed::default()) { + let mut txn = db.txn(); + + { + let mut scan_block = + new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + scan_block.handle_application_tx(OsRng.next_u64(), tx.clone()); + } + + assert!( + ProcessorMessages::try_recv(&mut txn, set).is_none(), + "fatally slashed signer should be ignored for {tx:?}" + ); + } + } + + #[test] + fn remove_participant() { + let (_, validator_data, validators, weights, total_weight) = + setup_test_validators_and_weights_with_keys(); + let set_info = new_test_set_info(&validator_data); + let set = set_info.set; + let default_signer = SeraiAddress(Signed::default().signer().to_bytes()); + + // The signer is fatally slashed if the participant voted to be removed is nonexistent + { + let mut db = MemDb::new(); + let mut txn = db.txn(); + let mut scan_block = new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + + let nonexistent = random_serai_address(&mut OsRng); + + scan_block.handle_application_tx( + OsRng.next_u64(), + Transaction::RemoveParticipant { participant: nonexistent, signed: Signed::default() }, + ); + + assert!(TributaryDb::is_fatally_slashed(&txn, set, default_signer)); + } + + // Valid RemoveParticipant accumulates weight and eventually crosses threshold + { + let (keys_addrs, validator_data, validators, weights, _) = setup_n_validators_with_keys(3); + let set_info = new_test_set_info(&validator_data); + let set = set_info.set; + let (key0, addr0) = keys_addrs[0]; + let (key1, _) = keys_addrs[1]; + let (key2, _) = keys_addrs[2]; + + let target = addr0; + let block_number = OsRng.next_u64(); + + let mut db = MemDb::new(); + let mut txn = db.txn(); + + // First vote: topic is recognized, target not yet slashed + { + let mut scan_block = new_scan_block(&mut txn, &set_info, &validators, 3, &weights); + scan_block.handle_application_tx( + block_number, + Transaction::RemoveParticipant { + participant: target, + signed: random_signed_for_key(key0), + }, + ); + } + assert!( + RecognizedTopics::recognized(&txn, set, Topic::RemoveParticipant { participant: target }), + "RemoveParticipant topic should be recognized after handling the tx" + ); + assert!( + !TributaryDb::is_fatally_slashed(&txn, set, target), + "target should not be fatally slashed after one vote" + ); + + // Threshold crossed, target gets fatally slashed + { + let mut scan_block = new_scan_block(&mut txn, &set_info, &validators, 3, &weights); + scan_block.handle_application_tx( + block_number, + Transaction::RemoveParticipant { + participant: target, + signed: random_signed_for_key(key1), + }, + ); + assert!( + !TributaryDb::is_fatally_slashed(scan_block.tributary_txn, set, target), + "target should not be fatally slashed after two votes" + ); + scan_block.handle_application_tx( + block_number, + Transaction::RemoveParticipant { + participant: target, + signed: random_signed_for_key(key2), + }, + ); + } + assert!( + TributaryDb::is_fatally_slashed(&txn, set, target), + "target should be fatally slashed after threshold is crossed" + ); + } + } + + #[test] + fn dkg_participation() { + let mut db = MemDb::new(); + + let (keys_addrs, validator_data, validators, weights, total_weight) = + setup_test_validators_and_weights_with_keys(); + let set_info = new_test_set_info(&validator_data); + let set = set_info.set; + let (signer_key, _) = keys_addrs[0]; + + let mut txn = db.txn(); + + { + let mut scan_block = new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + scan_block.handle_application_tx( + OsRng.next_u64(), + Transaction::DkgParticipation { + participation: vec![1, 2, 3], + signed: random_signed_for_key(signer_key), + }, + ); + } + + // TODO: Check the received message is the expected one + assert!(ProcessorMessages::try_recv(&mut txn, set).is_some()); + } + + #[test] + fn dkg_confirmation_preprocess() { + let (keys_addrs, validator_data, validators, weights, total_weight) = + setup_test_validators_and_weights_with_keys(); + let set_info = new_test_set_info(&validator_data); + let set = set_info.set; + let (key0, key1, key2) = (keys_addrs[0].0, keys_addrs[1].0, keys_addrs[2].0); + + let mut db = MemDb::new(); + let mut txn = db.txn(); + let block_number = OsRng.next_u64(); + + { + let mut scan_block = new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + for (i, key) in [key0, key1, key2].into_iter().enumerate() { + scan_block.handle_application_tx( + block_number, + Transaction::DkgConfirmationPreprocess { + attempt: 0, + preprocess: random_bytes(&mut OsRng), + signed: random_signed_for_key(key), + }, + ); + if i != 2 { + // Below threshold: no DkgConfirmationMessages sent + assert!(DkgConfirmationMessages::try_recv(scan_block.tributary_txn, set).is_none()); + } + } + } + // Threshold crossed: sends DkgConfirmationMessages (Preprocesses) + // TODO: Check the received message is the expected one + assert!(DkgConfirmationMessages::try_recv(&mut txn, set).is_some()); + } + + #[test] + fn dkg_confirmation_share() { + let (keys_addrs, validator_data, validators, weights, total_weight) = + setup_test_validators_and_weights_with_keys(); + let set_info = new_test_set_info(&validator_data); + let set = set_info.set; + let (key0, addr0) = keys_addrs[0]; + let (key1, key2) = (keys_addrs[1].0, keys_addrs[2].0); + + // Share without preceding preprocess participation -> fatal slash + { + let mut db = MemDb::new(); + let mut txn = db.txn(); + let mut scan_block = new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + + scan_block.handle_application_tx( + OsRng.next_u64(), + Transaction::DkgConfirmationShare { + attempt: 0, + share: random_bytes(&mut OsRng), + signed: random_signed_for_key(key0), + }, + ); + + assert!( + TributaryDb::is_fatally_slashed(&txn, set, addr0), + "share without preceding preprocess should fatally slash" + ); + } + + // Full preprocess->share flow + let mut db = MemDb::new(); + let mut txn = db.txn(); + let block_number = OsRng.next_u64(); + + // All 3 validators submit preprocesses (threshold crossed -> DkgConfirmationMessages sent) + { + let mut scan_block = new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + for (i, key) in [key0, key1, key2].into_iter().enumerate() { + scan_block.handle_application_tx( + block_number, + Transaction::DkgConfirmationPreprocess { + attempt: 0, + preprocess: random_bytes(&mut OsRng), + signed: random_signed_for_key(key), + }, + ); + if i != 2 { + assert!(DkgConfirmationMessages::try_recv(scan_block.tributary_txn, set).is_none()); + } + } + } + // TODO: Check the exact message received + assert!( + DkgConfirmationMessages::try_recv(&mut txn, set).is_some(), + "preprocesses crossing threshold should produce DkgConfirmationMessages" + ); + + // Threshold crossed: sends DkgConfirmationMessages (Shares) + { + let mut scan_block = new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + for (i, key) in [key0, key1, key2].into_iter().enumerate() { + scan_block.handle_application_tx( + block_number, + Transaction::DkgConfirmationShare { + attempt: 0, + share: random_bytes(&mut OsRng), + signed: random_signed_for_key(key), + }, + ); + if i != 2 { + assert!( + DkgConfirmationMessages::try_recv(scan_block.tributary_txn, set).is_none(), + "less than threshold should not produce DkgConfirmationMessages" + ); + } + } + } + // TODO: Check the exact message received + assert!( + DkgConfirmationMessages::try_recv(&mut txn, set).is_some(), + "shares crossing threshold should produce DkgConfirmationMessages" + ); + } + + #[test] + fn cosign() { + let (_, validator_data, validators, weights, total_weight) = + setup_test_validators_and_weights_with_keys(); + let set_info = new_test_set_info(&validator_data); + let set = set_info.set; + + let block_hash = random_block_hash(&mut OsRng); + let global_session = random_bytes(&mut OsRng); + + let intent = + CosignIntent { global_session, block_number: OsRng.next_u64(), block_hash, notable: false }; + + // Sets LatestSubstrateBlockToCosign and starts cosigning + { + let mut db = MemDb::new(); + { + let mut txn = db.txn(); + CosignIntents::provide(&mut txn, set, &intent); + txn.commit(); + } + + let mut txn = db.txn(); + let mut scan_block = new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + + scan_block.handle_application_tx( + OsRng.next_u64(), + Transaction::Cosign { substrate_block_hash: block_hash }, + ); + + assert_eq!(TributaryDb::latest_substrate_block_to_cosign(&txn, set), Some(block_hash)); + assert_eq!(TributaryDb::actively_cosigning(&mut txn, set), Some(block_hash)); + assert!(ProcessorMessages::try_recv(&mut txn, set).is_some()); + } + + // When already cosigning, updates LatestSubstrateBlockToCosign but doesn't replace active + { + let mut db = MemDb::new(); + let first_hash = random_block_hash(&mut OsRng); + let second_hash = random_block_hash(&mut OsRng); + + { + let mut txn = db.txn(); + TributaryDb::start_cosigning(&mut txn, set, first_hash, OsRng.next_u64()); + txn.commit(); + } + + let mut txn = db.txn(); + let mut scan_block = new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + + scan_block.handle_application_tx( + OsRng.next_u64(), + Transaction::Cosign { substrate_block_hash: second_hash }, + ); + + assert_eq!(TributaryDb::latest_substrate_block_to_cosign(&txn, set), Some(second_hash)); + assert_eq!(TributaryDb::actively_cosigning(&mut txn, set), Some(first_hash)); + } + } + + #[test] + fn cosigned() { + let (_, validator_data, validators, weights, total_weight) = + setup_test_validators_and_weights_with_keys(); + let set_info = new_test_set_info(&validator_data); + let set = set_info.set; + + // Marks block as cosigned + { + let mut db = MemDb::new(); + let mut txn = db.txn(); + let block_hash = random_block_hash(&mut OsRng); + + { + let mut scan_block = + new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + scan_block.handle_application_tx( + OsRng.next_u64(), + Transaction::Cosigned { substrate_block_hash: block_hash }, + ); + } + + assert!(TributaryDb::cosigned(&mut txn, set, block_hash)); + } + + // Finishes active cosign when matching block + { + let mut db = MemDb::new(); + let block_hash = random_block_hash(&mut OsRng); + + { + let mut txn = db.txn(); + TributaryDb::start_cosigning(&mut txn, set, block_hash, OsRng.next_u64()); + txn.commit(); + } + + let mut txn = db.txn(); + assert_eq!(TributaryDb::actively_cosigning(&mut txn, set), Some(block_hash)); + + { + let mut scan_block = + new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + scan_block.handle_application_tx( + OsRng.next_u64(), + Transaction::Cosigned { substrate_block_hash: block_hash }, + ); + } + assert!(TributaryDb::actively_cosigning(&mut txn, set).is_none()); + } + + // Does not finish active cosign when block doesn't match + /* + TODO: The story for this test is unclear. + + The intent is that if we are to cosign block #500, then block #501, we don't interrupt + cosigning block #500 to begin on block #501. Instead, we finish #500, by which point we may + be asked to cosign block #501, or maybe even #502. The intent is by finishing #500, we + inherently begin the latest block to cosign. + + This test asserts that if we're cosigning X, but then finish Y (which should be an + unreachable invariant, as we shouldn't start cosinging while already cosigning), that we + continue on X. Presumably, this is a byproduct of how if we finish #500 but have #501 + pending, we're intended to immediately rollover to #501, presented here as explicit + functionality to test for. This has to be straightened out. + */ + { + let mut db = MemDb::new(); + let active_hash = random_block_hash(&mut OsRng); + let other_hash = random_block_hash(&mut OsRng); + + { + let mut txn = db.txn(); + TributaryDb::start_cosigning(&mut txn, set, active_hash, OsRng.next_u64()); + txn.commit(); + } + + let mut txn = db.txn(); + { + let mut scan_block = + new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + scan_block.handle_application_tx( + OsRng.next_u64(), + Transaction::Cosigned { substrate_block_hash: other_hash }, + ); + } + assert_eq!(TributaryDb::actively_cosigning(&mut txn, set), Some(active_hash)); + assert!(TributaryDb::cosigned(&mut txn, set, other_hash)); + } + } + + #[test] + fn substrate_block() { + let (_, validator_data, validators, weights, total_weight) = + setup_test_validators_and_weights_with_keys(); + let set_info = new_test_set_info(&validator_data); + let set = set_info.set; + + let mut db = MemDb::new(); + let block_hash = random_block_hash(&mut OsRng); + let plans = vec![random_bytes(&mut OsRng), random_bytes(&mut OsRng)]; + + { + let mut txn = db.txn(); + SubstrateBlockPlans::set(&mut txn, set, block_hash, &plans); + txn.commit(); + } + + let mut txn = db.txn(); + { + let mut scan_block = new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + scan_block + .handle_application_tx(OsRng.next_u64(), Transaction::SubstrateBlock { hash: block_hash }); + } + + for plan in &plans { + let topic = initial_sign_topic(VariantSignId::Transaction(*plan)); + assert!(RecognizedTopics::recognized(&txn, set, topic)); + } + } + + #[test] + fn batch() { + let (_, validator_data, validators, weights, total_weight) = + setup_test_validators_and_weights_with_keys(); + let set_info = new_test_set_info(&validator_data); + let set = set_info.set; + + let mut db = MemDb::new(); + let batch_hash = random_bytes(&mut OsRng); + + let mut txn = db.txn(); + { + let mut scan_block = new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + scan_block.handle_application_tx(OsRng.next_u64(), Transaction::Batch { hash: batch_hash }); + } + + let topic = initial_sign_topic(VariantSignId::Batch(batch_hash)); + assert!(RecognizedTopics::recognized(&txn, set, topic)); + } + + mod slash_report { + use super::*; + + #[test] + fn wrong_length() { + let num_validators = OsRng.gen_range(4u16 .. 10); + let mut wrong_len = OsRng.gen_range(1u16 .. 20); + if wrong_len == num_validators { + wrong_len = if wrong_len == 1 { 2 } else { wrong_len - 1 }; + } + + let (keys_addrs, validator_data, validators, weights, total_weight) = + setup_n_validators_with_keys(num_validators); + let set_info = new_test_set_info(&validator_data); + let set = set_info.set; + + let mut db = MemDb::new(); + let mut txn = db.txn(); + + let (signer_key, signer_addr) = keys_addrs[0]; + + { + let mut scan_block = + new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + scan_block.handle_application_tx( + OsRng.next_u64(), + Transaction::SlashReport { + slash_points: vec![0; usize::from(wrong_len)], + signed: random_signed_for_key(signer_key), + }, + ); + } + + assert!( + TributaryDb::is_fatally_slashed(&txn, set, signer_addr), + "signer should be fatally slashed for wrong-length slash report", + ); + assert!( + ProcessorMessages::try_recv(&mut txn, set).is_none(), + "no message should be sent for wrong-length slash report", + ); + } + + #[test] + fn fatal_slash_as_reported_median() { + let num_validators = OsRng.gen_range(4u16 .. 10); + let num_reports = usize::from(Topic::SlashReport.required_participation(num_validators)); + + let (keys_addrs, validator_data, validators, weights, total_weight) = + setup_n_validators_with_keys(num_validators); + let set_info = new_test_set_info(&validator_data); + let set = set_info.set; + + let mut report = vec![0u32; usize::from(num_validators)]; + report[0] = u32::MAX; + let reports: Vec> = vec![report; num_reports]; + + let mut db = MemDb::new(); + let mut txn = db.txn(); + + { + let mut scan_block = + new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + for (i, report) in reports.iter().enumerate() { + let (key, _) = keys_addrs[i]; + scan_block.handle_application_tx( + OsRng.next_u64(), + Transaction::SlashReport { + slash_points: report.clone(), + signed: random_signed_for_key(key), + }, + ); + } + } + + // A ProcessorMessage should be produced containing a Fatal slash + // TODO: Check the exact message received + let msg = ProcessorMessages::try_recv(&mut txn, set); + assert!(msg.is_some(), "expected ProcessorMessage for fatal slash report"); + } + + mod fuzz_slash_report { + use super::*; + + /// Independently compute the expected slash report that `handle_application_tx` should + /// produce when `DataSet::Participating` is reached, mirroring the production logic. + /// + /// Returns `None` if `f == 0` (the slash report would be empty and nothing is sent). + fn expected_slash_report(num_validators: u16, reports: &[Vec]) -> Vec { + let f = (num_validators - 1) / 3; + + // Compute the median for each validator position across all reporters + let mut medians = Vec::with_capacity(usize::from(num_validators)); + for i in 0 .. usize::from(num_validators) { + let mut values: Vec = reports.iter().map(|r| r[i]).collect(); + values.sort_unstable(); + let median_index = + if (values.len() % 2) == 1 { values.len() / 2 } else { (values.len() / 2) - 1 }; + medians.push(values[median_index]); + } + + // Find worst validator in the supermajority and amortize + let mut sorted = medians.clone(); + sorted.sort_unstable(); + let amortization = sorted[usize::from(num_validators - f - 1)]; + + medians.iter().map(|p| p.saturating_sub(amortization)).collect::>() + } + + /// Generate `count` slash report vectors, each of length `num_validators`. + fn random_slash_reports( + rng: &mut impl Rng, + num_validators: u16, + count: u16, + ) -> Vec> { + (0 .. count).map(|_| (0 .. num_validators).map(|_| rng.next_u32()).collect()).collect() + } + + #[test] + fn fuzz_slash_report_even_validators() { + for _ in 0 .. 200 { + // random even: 4, 6, 8, or 10 + let n = OsRng.gen_range(2u16 ..= 5) * 2; + let num_reports = Topic::SlashReport.required_participation(n); + + let (keys_addrs, validator_data, validators, weights, total_weight) = + setup_n_validators_with_keys(n); + let set_info = new_test_set_info(&validator_data); + let set = set_info.set; + + let reports = random_slash_reports(&mut OsRng, n, num_reports); + let expected = expected_slash_report(n, &reports); + + let mut db = MemDb::new(); + let mut txn = db.txn(); + + { + let mut scan_block = + new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + for (i, report) in reports.iter().enumerate() { + let (key, _) = keys_addrs[i]; + scan_block.handle_application_tx( + OsRng.next_u64(), + Transaction::SlashReport { + slash_points: report.clone(), + signed: random_signed_for_key(key), + }, + ); + } + } + + assert_eq!( + ProcessorMessages::try_recv(&mut txn, set), + Some(messages::CoordinatorMessage::from( + messages::coordinator::CoordinatorMessage::SignSlashReport { + session: set.session, + slash_report: expected + .into_iter() + .map(|points| if points == u32::MAX { + Slash::Fatal + } else { + Slash::Points(points) + }) + .collect::>() + .try_into() + .unwrap(), + } + )) + ); + + let sign_topic = initial_sign_topic(VariantSignId::SlashReport); + assert!( + RecognizedTopics::recognized(&txn, set, sign_topic), + "SlashReport sign topic should be recognized", + ); + } + } + + #[test] + fn fuzz_slash_report_odd_validators() { + for _ in 0 .. 200 { + // random odd: 5, 7, 9, or 11 + let n = OsRng.gen_range(2u16 ..= 5) * 2 + 1; + let num_reports = Topic::SlashReport.required_participation(n); + + let (keys_addrs, validator_data, validators, weights, total_weight) = + setup_n_validators_with_keys(n); + let set_info = new_test_set_info(&validator_data); + let set = set_info.set; + + let reports = random_slash_reports(&mut OsRng, n, num_reports); + let expected = expected_slash_report(n, &reports); + + let mut db = MemDb::new(); + let mut txn = db.txn(); + + { + let mut scan_block = + new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + for (i, report) in reports.iter().enumerate() { + let (key, _) = keys_addrs[i]; + scan_block.handle_application_tx( + OsRng.next_u64(), + Transaction::SlashReport { + slash_points: report.clone(), + signed: random_signed_for_key(key), + }, + ); + } + } + + assert_eq!( + ProcessorMessages::try_recv(&mut txn, set), + Some(messages::CoordinatorMessage::from( + messages::coordinator::CoordinatorMessage::SignSlashReport { + session: set.session, + slash_report: expected + .into_iter() + .map(|points| if points == u32::MAX { + Slash::Fatal + } else { + Slash::Points(points) + }) + .collect::>() + .try_into() + .unwrap(), + } + )) + ); + let sign_topic = initial_sign_topic(VariantSignId::SlashReport); + assert!(RecognizedTopics::recognized(&txn, set, sign_topic)); + } + } + } + } + + #[test] + fn sign() { + let (keys_addrs, validator_data, validators, weights, total_weight) = + setup_test_validators_and_weights_with_keys(); + let set_info = new_test_set_info(&validator_data); + let set = set_info.set; + let (key0, addr0) = keys_addrs[0]; + let (key1, key2) = (keys_addrs[1].0, keys_addrs[2].0); + + let sign_id = VariantSignId::Transaction(random_bytes(&mut OsRng)); + let topic = initial_sign_topic(sign_id); + + // Wrong data length: signer has weight 1 but submits 2 entries -> fatal slash + { + let mut db = MemDb::new(); + let mut txn = db.txn(); + TributaryDb::recognize_topic(&mut txn, set, topic); + + let mut scan_block = new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + scan_block.handle_application_tx( + OsRng.next_u64(), + Transaction::Sign { + id: sign_id, + attempt: 0, + round: SigningProtocolRound::Preprocess, + data: vec![vec![1], vec![2]], + signed: random_signed_for_key(key0), + }, + ); + + assert!(TributaryDb::is_fatally_slashed(&txn, set, addr0)); + } + + // Valid data: threshold crossing sends ProcessorMessage + { + let mut db = MemDb::new(); + let mut txn = db.txn(); + TributaryDb::recognize_topic(&mut txn, set, topic); + + { + let mut scan_block = + new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + for key in [key0, key1, key2] { + scan_block.handle_application_tx( + OsRng.next_u64(), + Transaction::Sign { + id: sign_id, + attempt: 0, + round: SigningProtocolRound::Preprocess, + data: vec![vec![1, 2, 3]], + signed: random_signed_for_key(key), + }, + ); + } + } + + // TODO: Check the exact message received + assert!(ProcessorMessages::try_recv(&mut txn, set).is_some()); + } + } + + /// Exercises the Sign Share -> Participating path. + /// Requires first accumulating preprocesses to threshold (which recognizes the Share topic + /// and stores preceding data), then accumulating shares to threshold. + #[test] + fn sign_share_sends_shares_message() { + let (keys_addrs, validator_data, validators, weights, total_weight) = + setup_test_validators_and_weights_with_keys(); + let set_info = new_test_set_info(&validator_data); + let set = set_info.set; + let (key0, key1, key2) = (keys_addrs[0].0, keys_addrs[1].0, keys_addrs[2].0); + + let sign_id = VariantSignId::Transaction(random_bytes(&mut OsRng)); + let preprocess_topic = initial_sign_topic(sign_id); + let share_topic = Topic::Sign { id: sign_id, attempt: 0, round: SigningProtocolRound::Share }; + + let mut db = MemDb::new(); + let mut txn = db.txn(); + + // Recognize the Preprocess topic + TributaryDb::recognize_topic(&mut txn, set, preprocess_topic); + + // Step 1: All validators submit preprocesses, crossing threshold. + // This auto-recognizes the Share topic (succeeding_topic) and stores preprocess data. + { + let block_number = OsRng.next_u64(); + let mut scan_block = new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + for key in [key0, key1, key2] { + scan_block.handle_application_tx( + block_number, + Transaction::Sign { + id: sign_id, + attempt: 0, + round: SigningProtocolRound::Preprocess, + data: vec![vec![1, 2, 3]], + signed: random_signed_for_key(key), + }, + ); + } + } + + // Drain the Preprocesses message from step 1 + // TODO: Check the exact message received + assert!(ProcessorMessages::try_recv(&mut txn, set).is_some()); + + // Share topic should now be recognized + assert!(RecognizedTopics::recognized(&txn, set, share_topic)); + + // Step 2: All validators submit shares, crossing threshold -> sends Shares message. + { + let block_number = OsRng.next_u64(); + let mut scan_block = new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + for key in [key0, key1, key2] { + scan_block.handle_application_tx( + block_number, + Transaction::Sign { + id: sign_id, + attempt: 0, + round: SigningProtocolRound::Share, + data: vec![vec![4, 5, 6]], + signed: random_signed_for_key(key), + }, + ); + } + } + + // The Shares message should have been sent + let msg = ProcessorMessages::try_recv(&mut txn, set); + // TODO: Check the exact message received + assert!(msg.is_some(), "expected Shares processor message"); + + // No validators should be slashed + for v in &validators { + assert!(!TributaryDb::is_fatally_slashed(&txn, set, *v)); + } + } +} + +#[test] +fn handle_block() { + let (keys_addrs, validator_data, validators, weights, total_weight) = + setup_n_validators_with_keys(3); + let set_info = new_test_set_info(&validator_data); + let set = set_info.set; + let addr0 = validator_data[0].0; + let signed = random_signed_for_key(keys_addrs[0].0); + + // Empty block only calls start of block + { + let mut db = MemDb::new(); + let mut txn = db.txn(); + let block = Block { + header: BlockHeader { + parent: random_bytes(&mut OsRng), + transactions: random_bytes(&mut OsRng), + }, + transactions: vec![], + }; + + { + let scan_block = new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + scan_block.handle_block(OsRng.next_u64(), block); + } + assert_no_pending_messages(&mut txn, set); + } + + // Each application transaction type passes through handle_block. + // Signed transactions use a real validator key so participant_indexes lookups succeed. + // Cosign and SubstrateBlock need external state populated before they can run. + for tx in all_signed_transactions_and_attempts(signed) { + let mut db = MemDb::new(); + let mut txn = db.txn(); + + let block_txs = vec![TributaryTransaction::Application(tx)]; + let block = Block { + header: BlockHeader { + parent: random_bytes(&mut OsRng), + transactions: random_bytes(&mut OsRng), + }, + transactions: block_txs.clone(), + }; + + { + let scan_block = new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + scan_block.handle_block(OsRng.next_u64(), block); + } + assert_block_side_effects(&mut txn, set, &block_txs); + } + + // Provided transactions that need preconditions + for tx in all_provided_transactions() { + let mut db = MemDb::new(); + let mut txn = db.txn(); + + // Set up required external state + match &tx { + Transaction::Cosign { substrate_block_hash } => { + CosignIntents::provide( + &mut txn, + set, + &CosignIntent { + global_session: random_bytes(&mut OsRng), + block_number: OsRng.next_u64(), + block_hash: *substrate_block_hash, + notable: false, + }, + ); + } + Transaction::SubstrateBlock { hash } => { + let plans = vec![random_bytes(&mut OsRng)]; + SubstrateBlockPlans::set(&mut txn, set, *hash, &plans); + } + // `Cosigned`, `Batch` are provided but do not require pre-existing state + Transaction::Cosigned { .. } | Transaction::Batch { .. } => {} + // These aren't provided transactions + Transaction::RemoveParticipant { .. } | + Transaction::DkgParticipation { .. } | + Transaction::DkgConfirmationPreprocess { .. } | + Transaction::DkgConfirmationShare { .. } | + Transaction::Sign { .. } | + Transaction::SlashReport { .. } => unreachable!(), + } + + let block_txs = vec![TributaryTransaction::Application(tx)]; + let block = Block { + header: BlockHeader { + parent: random_bytes(&mut OsRng), + transactions: random_bytes(&mut OsRng), + }, + transactions: block_txs.clone(), + }; + + { + let scan_block = new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + scan_block.handle_block(OsRng.next_u64(), block); + } + assert_block_side_effects(&mut txn, set, &block_txs); + } + + // Each Tendermint SlashEvidence type fatally slashes the sender + { + let all_evidence = [ + Evidence::InvalidPrecommit(make_signed_message_bytes(addr0.0)), + Evidence::InvalidValidRound(make_signed_message_bytes(addr0.0)), + Evidence::ConflictingMessages( + make_signed_message_bytes(addr0.0), + make_signed_message_bytes(addr0.0), + ), + ]; + + for evidence in all_evidence { + let mut db = MemDb::new(); + let mut txn = db.txn(); + + let block = Block { + header: BlockHeader { + parent: random_bytes(&mut OsRng), + transactions: random_bytes(&mut OsRng), + }, + transactions: vec![TributaryTransaction::Tendermint(TendermintTx::SlashEvidence(evidence))], + }; + + { + let scan_block = new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + scan_block.handle_block(1, block); + } + assert!( + TributaryDb::is_fatally_slashed(&txn, set, addr0), + "SlashEvidence should fatally slash the sender", + ); + + assert_no_pending_messages(&mut txn, set); + txn.commit(); + } + } + + // Fuzz mixed blocks with random quantities, types, and ordering + for _ in 0 .. 100 { + let mut db = MemDb::new(); + let mut txn = db.txn(); + + let num_txs = OsRng.gen_range(1usize ..= 8); + let mut transactions = Vec::with_capacity(num_txs); + let mut has_evidence = false; + let mut batch_hashes = vec![]; + + for _ in 0 .. num_txs { + if OsRng.gen_bool(0.5) { + // Random Tendermint evidence type + let evidence = match OsRng.gen_range(0u8 .. 3) { + 0 => Evidence::InvalidPrecommit(make_signed_message_bytes(addr0.0)), + 1 => Evidence::InvalidValidRound(make_signed_message_bytes(addr0.0)), + _ => Evidence::ConflictingMessages( + make_signed_message_bytes(addr0.0), + make_signed_message_bytes(addr0.0), + ), + }; + transactions.push(TributaryTransaction::Tendermint(TendermintTx::SlashEvidence(evidence))); + has_evidence = true; + } else { + // Random application transaction, use Batch so we can assert recognition + let hash = random_bytes(&mut OsRng); + batch_hashes.push(hash); + transactions.push(TributaryTransaction::Application(Transaction::Batch { hash })); + } + } + + let block = Block { + header: BlockHeader { + parent: random_bytes(&mut OsRng), + transactions: random_bytes(&mut OsRng), + }, + transactions: transactions.clone(), + }; + + { + let scan_block = new_scan_block(&mut txn, &set_info, &validators, total_weight, &weights); + scan_block.handle_block(OsRng.next_u64(), block); + } + + if has_evidence { + assert!( + TributaryDb::is_fatally_slashed(&txn, set, addr0), + "SlashEvidence should fatally slash the sender in mixed blocks", + ); + } + for hash in &batch_hashes { + let topic = initial_sign_topic(VariantSignId::Batch(*hash)); + assert!( + RecognizedTopics::recognized(&txn, set, topic), + "Batch should be recognized regardless of other txs in the block", + ); + } + assert_block_side_effects(&mut txn, set, &transactions); + } +} diff --git a/coordinator/tributary/src/tests/scan_tributary.rs b/coordinator/tributary/src/tests/scan_tributary.rs new file mode 100644 index 000000000..57eed11d1 --- /dev/null +++ b/coordinator/tributary/src/tests/scan_tributary.rs @@ -0,0 +1,237 @@ +use core::time::Duration; +use std::time::Instant; + +use blake2::{Digest as _, Blake2s256}; + +use serai_primitives::test_helpers::random_bytes; +use serai_task::test_helpers::TaskTest; +use tributary_sdk::{ + ReadWrite as _, Evidence, tendermint::tx::TendermintTx, Transaction as TributaryTransaction, + BlockHeader, Block, Tributary, +}; +use super::*; + +/// Create a Tributary with a single validator. +/// +/// This returns the Tributary (kept alive so the Tendermint machine keeps running) and the +/// validator's signing key. +async fn make_tributary( + db: MemDb, + weights: &[u16], +) -> (NewSetInformation, Tributary) { + let mut key = None; + let mut validator_keys = vec![]; + let mut validators = vec![]; + for weight in weights.iter().copied() { + let this_key = random_key(&mut OsRng); + let pub_key = ::generator() * *this_key; + key = Some(this_key); + validator_keys.push((pub_key, u64::from(weight))); + let addr = SeraiAddress(pub_key.to_bytes()); + validators.push((addr, weight)); + } + let set_info = new_test_set_info(&validators); + let tributary = Tributary::::new( + db, + set_info.tributary_genesis(), + // Use a past start_time so TendermintMachine::new doesn't sleep waiting for block end time + 1, + key.unwrap(), + validator_keys, + NopP2p, + ) + .await + .expect("Tributary::new returned `None`?"); + (set_info, tributary) +} + +#[tokio::test] +async fn new_scan_tributary_task() { + // Single validator with weight > 1 + { + let db = MemDb::new(); + let (set_info, tributary) = make_tributary(db.clone(), &[3]).await; + + let task = + ScanTributaryTask::::new(db.clone(), set_info.clone(), tributary.reader()); + + assert_eq!(task.set.set, set_info.set); + assert_eq!(task.validators.len(), 1); + assert_eq!(task.validators[0], set_info.validators[0].0); + assert_eq!(task.total_weight, 3); + assert_eq!(task.validator_weights.len(), 1); + assert_eq!(task.validator_weights[&set_info.validators[0].0], 3); + } + + // Multiple validators with different weights + { + let db = MemDb::new(); + let (set_info, tributary) = make_tributary(db.clone(), &[1, 2, 4]).await; + let task = + ScanTributaryTask::::new(db.clone(), set_info.clone(), tributary.reader()); + + assert_eq!(task.set.set, set_info.set); + assert_eq!(task.validators.len(), 3); + assert_eq!(task.total_weight, 7); + assert_eq!(task.validator_weights.len(), 3); + assert_eq!(task.validator_weights[&set_info.validators[0].0], 1); + assert_eq!(task.validator_weights[&set_info.validators[1].0], 2); + assert_eq!(task.validator_weights[&set_info.validators[2].0], 4); + } +} + +/// Wait until `block_after(parent)` returns `Some`, with a 30s timeout. +async fn wait_for_block_after( + tributary: &Tributary, + parent: &[u8; 32], +) -> [u8; 32] { + let reader = tributary.reader(); + let start = Instant::now(); + loop { + if let Some(hash) = reader.block_after(parent) { + return hash; + } + assert!( + start.elapsed() <= Duration::from_secs(30), + "timed out waiting for a block after {parent:?}" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } +} + +/// Write a fake block into the DB so the TributaryReader can find it. +/// +/// Returns the block's hash. +fn inject_block( + mut txn: impl DbTxn, + genesis: [u8; 32], + parent: [u8; 32], + transactions: Vec>, +) -> [u8; 32] { + let tx_hashes: Vec<[u8; 32]> = + transactions.iter().map(tributary_sdk::Transaction::hash).collect(); + let txs_hash = + Blake2s256::digest(tx_hashes.iter().flat_map(|h| h.iter().copied()).collect::>()).into(); + let block = Block { header: BlockHeader { parent, transactions: txs_hash }, transactions }; + let block_hash = block.hash(); + let serialized = block.serialize(); + + let block_after_key = MemDb::key( + b"tributary_blockchain", + b"block_after", + [genesis.as_ref(), parent.as_ref()].concat(), + ); + let block_key = + MemDb::key(b"tributary_blockchain", b"block", [genesis.as_ref(), block_hash.as_ref()].concat()); + + txn.put(block_after_key, block_hash); + txn.put(block_key, serialized); + txn.commit(); + + block_hash +} + +#[tokio::test(flavor = "multi_thread")] +async fn scan_tributary_task_run_iteration() { + // No blocks committed yet: returns false + { + let db = MemDb::new(); + let (set_info, tributary) = make_tributary(db.clone(), &[1]).await; + + let mut task = ScanTributaryTask::::new(db, set_info, tributary.reader()); + TaskTest::task_runs_once_and_matches_progress(&mut task, false).await; + } + + { + let mut db = MemDb::new(); + let (set_info, tributary) = make_tributary(db.clone(), &[1]).await; + let genesis = set_info.tributary_genesis(); + + // Wait for at least one real committed block + wait_for_block_after(&tributary, &genesis).await; + + // Create one task that persists across the remaining steps so each run_iteration + // continues from where the previous one left off. + let mut task = + ScanTributaryTask::::new(db.clone(), set_info.clone(), tributary.reader()); + + // Processes committed block(s) and records progress + TaskTest::task_runs_once_and_matches_progress(&mut task, true).await; + + let (last_handled_block_number, last_handled_block_hash) = + TributaryDb::last_handled_tributary_block(&db, set_info.set).unwrap(); + assert!(last_handled_block_number >= 1, "expected at least block 1 to be handled"); + + // Processes block with provided and signed txs - inject after the actual last handled block + let batch_tx = + TributaryTransaction::Application(Transaction::Batch { hash: random_bytes(&mut OsRng) }); + let fake_evidence = TributaryTransaction::Tendermint(TendermintTx::SlashEvidence( + Evidence::InvalidPrecommit(make_signed_message_bytes(set_info.validators[0].0 .0)), + )); + let block_txs = vec![fake_evidence, batch_tx]; + + let local_qty_key = + MemDb::key(b"tributary_provided", b"local_quantity", [genesis.as_ref(), b"Batch"].concat()); + let block_hash = inject_block(db.txn(), genesis, last_handled_block_hash, block_txs.clone()); + let block_qty_key = MemDb::key( + b"tributary_provided", + b"block_quantity", + [genesis.as_ref(), block_hash.as_ref(), b"Batch"].concat(), + ); + { + let mut txn = db.txn(); + txn.put(&local_qty_key, 1u32.to_le_bytes()); + txn.put(block_qty_key, 1u32.to_le_bytes()); + txn.commit(); + } + + TaskTest::task_runs_once_and_matches_progress(&mut task, true).await; + + let mut txn = db.txn(); + assert_block_side_effects(&mut txn, set_info.set, &block_txs); + } + + // Errors when locally provided txs are missing + { + let mut db = MemDb::new(); + let (set_info, tributary) = make_tributary(db.clone(), &[1]).await; + let genesis = set_info.tributary_genesis(); + + let cosign_tx = Transaction::Cosign { substrate_block_hash: random_block_hash(&mut OsRng) }; + tributary.provide_transaction(cosign_tx).await.unwrap(); + + // Wait for a block that includes the provided transaction + let reader = tributary.reader(); + let mut parent = genesis; + let start = Instant::now(); + loop { + assert!( + start.elapsed() <= Duration::from_secs(30), + "timed out waiting for a block with the provided tx" + ); + if let Some(hash) = reader.block_after(&parent) { + let block = reader.block(&hash).unwrap(); + if block + .transactions + .iter() + .any(|tx| matches!(tx.kind(), tributary_sdk::transaction::TransactionKind::Provided(_))) + { + break; + } + parent = hash; + } else { + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + + // Delete the locally_provided_quantity to trigger the error + let local_qty_key = + MemDb::key(b"tributary_provided", b"local_quantity", [genesis.as_ref(), b"Cosign"].concat()); + let mut txn = db.txn(); + txn.del(local_qty_key); + txn.commit(); + + let mut task = ScanTributaryTask::::new(db, set_info, reader); + TaskTest::task_runs_and_fails_with(&mut task, "didn't have the provided Transactions").await; + } +} diff --git a/coordinator/tributary/src/tests/transaction.rs b/coordinator/tributary/src/tests/transaction.rs new file mode 100644 index 000000000..b87378e1d --- /dev/null +++ b/coordinator/tributary/src/tests/transaction.rs @@ -0,0 +1,684 @@ +use core::ops::Deref as _; +use std::{ + io::{self, Cursor, Read, Write}, + collections::HashSet, +}; + +use rand_core::{RngCore as _, OsRng}; +use blake2::{digest::typenum::U32, Digest as _, Blake2b}; +use ciphersuite::{ + group::{ff::PrimeField as _, Group as _, GroupEncoding as _}, + *, +}; +use dalek_ff_group::Ristretto; + +use borsh::{BorshDeserialize as _, BorshSerialize as _}; + +use messages::sign::VariantSignId; +use serai_primitives::{test_helpers::*, validator_sets::KeyShares}; +use tributary_sdk::{ + ReadWrite, + transaction::{TransactionKind, Transaction as _, TransactionError}, +}; + +use super::*; + +fn all_signing_protocol_rounds() -> Vec { + vec![SigningProtocolRound::Preprocess, SigningProtocolRound::Share] +} + +#[test] +fn signing_protocol_round_nonce() { + for (i, round) in all_signing_protocol_rounds().into_iter().enumerate() { + assert_eq!(round.nonce(), u32::try_from(i).unwrap(), "Wrong nonce for {round:?}"); + } +} + +mod signed { + use super::*; + + #[test] + fn borsh_serialize_and_deserialize() { + // Check the format of `Signed` + { + let signed = random_signed(&mut OsRng); + let serialized = borsh::to_vec(&signed).unwrap(); + + // `signer || R || s` + let mut expected: Vec = Vec::new(); + expected.extend(signed.signer.to_bytes().as_ref()); + expected.extend(signed.signature.R.to_bytes().as_ref()); + expected.extend(signed.signature.s.to_repr().as_ref()); + assert_eq!(serialized, expected, "serialized format should be `signer || R || s`"); + + let deserialized: Signed = borsh::from_slice(&serialized).unwrap(); + assert_eq!(signed, deserialized, "round-trip should preserve the original Signed"); + } + + // Should serialize + { + let signed = random_signed(&mut OsRng); + + let serialized = borsh::to_vec(&signed).unwrap(); + let mut manual_buf = Vec::new(); + signed.serialize(&mut manual_buf).unwrap(); + assert_eq!( + serialized, manual_buf, + "borsh::to_vec and manual serialize should produce identical bytes" + ); + + let deserialized: Signed = borsh::from_slice(&serialized).unwrap(); + assert_eq!( + deserialized, + Signed::deserialize_reader(&mut serialized.as_slice()).unwrap(), + "borsh::from_slice and Signed::deserialize_reader should produce identical results" + ); + + assert_eq!(signed, deserialized, "round-trip should preserve the original Signed"); + } + + // Check writer failure propagation + { + struct FailingWriter; + impl Write for FailingWriter { + fn write(&mut self, _buf: &[u8]) -> io::Result { + Err(io::Error::other("simulated write failure")) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + let result = random_signed(&mut OsRng).serialize(&mut FailingWriter); + assert!(result.is_err(), "serialize into a failing writer should error"); + assert_eq!( + result.unwrap_err().kind(), + io::ErrorKind::Other, + "write error kind should be Other" + ); + } + + // Check reader failure propagation + { + struct FailingReader; + impl Read for FailingReader { + fn read(&mut self, _buf: &mut [u8]) -> io::Result { + Err(io::Error::new(io::ErrorKind::UnexpectedEof, "simulated read failure")) + } + } + + let result = Signed::deserialize_reader(&mut FailingReader); + assert!(result.is_err(), "deserialize from a failing reader should error"); + assert_eq!( + result.unwrap_err().kind(), + io::ErrorKind::UnexpectedEof, + "read error kind should be UnexpectedEof" + ); + } + + // Check incomplete data is rejected (signer read_G fails) + { + let serialized = borsh::to_vec(&random_signed(&mut OsRng)).unwrap(); + let truncated = &serialized[.. 5]; + let result = Signed::deserialize_reader(&mut &*truncated); + assert!(result.is_err(), "truncated data should fail to deserialize"); + } + + // Check missing signature data is rejected (SchnorrSignature::read fails) + { + let serialized = borsh::to_vec(&random_signed(&mut OsRng)).unwrap(); + let signer_only = &serialized[.. 32]; + let result = Signed::deserialize_reader(&mut &*signer_only); + assert!(result.is_err(), "signer-only data without signature should fail to deserialize"); + } + } + + #[test] + fn to_tributary_signed_matches_signed() { + let signed = random_signed(&mut OsRng); + for round in all_signing_protocol_rounds() { + let tributary_signed = signed.to_tributary_signed(round.nonce()); + assert_eq!(signed.signer(), tributary_signed.signer); + assert_eq!(signed.signature, tributary_signed.signature); + assert_eq!(tributary_signed.nonce, round.nonce()); + } + } + + #[test] + fn default_signer_is_identity() { + let default_signed = Signed::default(); + let identity = ::G::identity(); + assert_eq!(default_signed.signer(), identity); + assert_eq!(default_signed.signature.R, identity); + assert_eq!(default_signed.signature.s, ::F::ZERO); + } +} + +#[allow(clippy::module_inception)] +mod transaction { + use super::*; + + mod readwrite { + use super::*; + + #[test] + fn serialize_and_deserialize() { + for mut tx in all_transactions() { + let serialized = ReadWrite::serialize(&tx); + + let expected = match &tx { + Transaction::RemoveParticipant { participant, signed } => { + let mut expected = vec![0u8]; + expected.extend(&participant.0); + expected.extend(borsh::to_vec(signed).unwrap()); + expected + } + Transaction::DkgParticipation { participation, signed } => { + let mut expected = vec![1u8]; + expected.extend(&u32::try_from(participation.len()).unwrap().to_le_bytes()); + expected.extend(participation); + expected.extend(borsh::to_vec(signed).unwrap()); + expected + } + Transaction::DkgConfirmationPreprocess { attempt, preprocess, signed } => { + let mut expected = vec![2u8]; + expected.extend(&attempt.to_le_bytes()); + expected.extend(preprocess); + expected.extend(borsh::to_vec(signed).unwrap()); + expected + } + Transaction::DkgConfirmationShare { attempt, share, signed } => { + let mut expected = vec![3u8]; + expected.extend(&attempt.to_le_bytes()); + expected.extend(share); + expected.extend(borsh::to_vec(signed).unwrap()); + expected + } + Transaction::Cosign { substrate_block_hash } => { + let mut expected = vec![4u8]; + expected.extend(&substrate_block_hash.0); + expected + } + Transaction::Cosigned { substrate_block_hash } => { + let mut expected = vec![5u8]; + expected.extend(&substrate_block_hash.0); + expected + } + Transaction::SubstrateBlock { hash } => { + let mut expected = vec![6u8]; + expected.extend(&hash.0); + expected + } + Transaction::Batch { hash } => { + let mut expected = vec![7u8]; + expected.extend(hash); + expected + } + Transaction::Sign { id, attempt, round, data, signed } => { + let mut expected = vec![8u8]; + // Independently encode VariantSignId + match id { + VariantSignId::Cosign(v) => { + expected.push(0u8); + expected.extend(&v.to_le_bytes()); + } + VariantSignId::Batch(h) => { + expected.push(1u8); + expected.extend(h); + } + VariantSignId::SlashReport => { + expected.push(2u8); + } + VariantSignId::Transaction(h) => { + expected.push(3u8); + expected.extend(h); + } + } + expected.extend(&attempt.to_le_bytes()); + match round { + SigningProtocolRound::Preprocess => expected.push(0u8), + SigningProtocolRound::Share => expected.push(1u8), + } + expected.extend(&u32::try_from(data.len()).unwrap().to_le_bytes()); + for d in data { + expected.extend(&u32::try_from(d.len()).unwrap().to_le_bytes()); + expected.extend(d); + } + expected.extend(borsh::to_vec(signed).unwrap()); + expected + } + Transaction::SlashReport { slash_points, signed } => { + let mut expected = vec![9u8]; + expected.extend(&u32::try_from(slash_points.len()).unwrap().to_le_bytes()); + for &p in slash_points { + expected.extend(&p.to_le_bytes()); + } + expected.extend(borsh::to_vec(signed).unwrap()); + expected + } + }; + + assert_eq!(serialized, expected, "format mismatch for {tx:?}"); + + let deserialized = Transaction::read(&mut serialized.as_slice()).unwrap(); + assert_eq!(tx, deserialized); + + if let TransactionKind::Signed(_, _) = tx.kind() { + tx.sign(&mut OsRng, random_bytes(&mut OsRng), &random_key(&mut OsRng)); + let serialized = ReadWrite::serialize(&tx); + let deserialized = Transaction::read(&mut serialized.as_slice()).unwrap(); + assert_eq!(tx, deserialized, "ReadWrite failed after signing for {tx:?}"); + } + } + } + + /// Regression test: `Transaction::read` must use `deserialize_reader`, not `borsh::from_reader` + /// + /// `borsh::from_reader` asserts the reader is exhausted after deserialization. When multiple + /// transactions are serialized into a single stream (as happens in `Block::read`), the first + /// `from_reader` call would fail because subsequent transactions' bytes remain in the reader. + #[test] + fn sequential_reads_from_shared_reader() { + let txs = all_transactions(); + + let mut buf = Vec::new(); + buf.extend(&u32::try_from(txs.len()).unwrap().to_le_bytes()); + for tx in &txs { + tx.write(&mut buf).unwrap(); + } + + let mut cursor = Cursor::new(&buf); + let mut count = [0u8; 4]; + cursor.read_exact(&mut count).unwrap(); + + for (i, expected) in txs.iter().enumerate() { + let actual = Transaction::read(&mut cursor) + .unwrap_or_else(|e| panic!("failed to read transaction {i} from shared reader: {e}")); + assert_eq!(&actual, expected, "transaction {i} mismatch after sequential read"); + } + + let mut leftover = [0u8; 1]; + assert_eq!( + cursor.read(&mut leftover).unwrap(), + 0, + "reader should be exhausted after reading all transactions" + ); + } + } + + mod kind { + use super::*; + + #[test] + fn signed_transactions_match_kind_and_nonce_and_sig() { + let key = random_key(&mut OsRng); + let genesis = random_bytes(&mut OsRng); + + /// Borsh-encodes a byte-string label: `len(4 LE) || label` + fn borsh_label(label: &[u8]) -> Vec { + let mut out = Vec::new(); + out.extend(&u32::try_from(label.len()).unwrap().to_le_bytes()); + out.extend(label); + out + } + + let mut orders = HashSet::new(); + for mut tx in all_signed_transactions_and_attempts(random_signed(&mut OsRng)) { + tx.sign(&mut OsRng, genesis, &key); + + let (expected_order, expected_nonce) = match &tx { + Transaction::RemoveParticipant { participant, .. } => { + let mut order = borsh_label(b"RemoveParticipant"); + order.extend(&participant.0); + (order, 0) + } + Transaction::DkgParticipation { .. } => (borsh_label(b"DkgParticipation"), 0), + Transaction::DkgConfirmationPreprocess { attempt, .. } => { + let mut order = borsh_label(b"DkgConfirmation"); + order.extend(&attempt.to_le_bytes()); + (order, 0) + } + Transaction::DkgConfirmationShare { attempt, .. } => { + let mut order = borsh_label(b"DkgConfirmation"); + order.extend(&attempt.to_le_bytes()); + (order, 1) + } + Transaction::Sign { id, attempt, round, .. } => { + let mut order = borsh_label(b"Sign"); + // Independently encode VariantSignId + match id { + VariantSignId::Cosign(v) => { + order.push(0u8); + order.extend(&v.to_le_bytes()); + } + VariantSignId::Batch(h) => { + order.push(1u8); + order.extend(h); + } + VariantSignId::SlashReport => { + order.push(2u8); + } + VariantSignId::Transaction(h) => { + order.push(3u8); + order.extend(h); + } + } + order.extend(&attempt.to_le_bytes()); + let nonce = match round { + SigningProtocolRound::Preprocess => 0, + SigningProtocolRound::Share => 1, + }; + (order, nonce) + } + Transaction::SlashReport { .. } => (borsh_label(b"SlashReport"), 0), + other @ (Transaction::Cosign { .. } | + Transaction::Cosigned { .. } | + Transaction::SubstrateBlock { .. } | + Transaction::Batch { .. }) => { + unreachable!("all_signed_transactions_and_attempts returned non-signed tx: {other:?}") + } + }; + orders.insert((expected_order.clone(), expected_nonce)); + + match tx.kind() { + TransactionKind::Signed(order, signed) => { + assert_eq!(order, expected_order, "Wrong order bytes for {tx:?}"); + assert_eq!(signed.nonce, expected_nonce, "Wrong nonce for {tx:?}"); + assert!( + signed.signature.verify(signed.signer, tx.sig_hash(genesis)), + "Signature verification failed for {tx:?}" + ); + } + other @ (TransactionKind::Provided(_) | TransactionKind::Unsigned) => { + panic!("Expected Signed kind, got {other:?} for {tx:?}") + } + } + } + assert_eq!(orders.len(), 11); + } + + #[test] + fn provided_transactions_kind() { + let mut orders = HashSet::new(); + for tx in all_provided_transactions() { + let expected_order = match &tx { + Transaction::Cosign { .. } => "Cosign", + Transaction::Cosigned { .. } => "Cosigned", + Transaction::SubstrateBlock { .. } => "SubstrateBlock", + Transaction::Batch { .. } => "Batch", + other @ (Transaction::RemoveParticipant { .. } | + Transaction::DkgParticipation { .. } | + Transaction::DkgConfirmationPreprocess { .. } | + Transaction::DkgConfirmationShare { .. } | + Transaction::Sign { .. } | + Transaction::SlashReport { .. }) => { + panic!("all_provided_transactions returned non-provided tx: {other:?}") + } + }; + orders.insert(expected_order); + + match tx.kind() { + TransactionKind::Provided(actual_order) => { + assert_eq!(actual_order, expected_order, "Wrong order for {tx:?}"); + } + other @ (TransactionKind::Unsigned | TransactionKind::Signed(..)) => { + panic!("Expected Provided kind, got {other:?} for {tx:?}") + } + } + } + assert_eq!(orders.len(), 4); + } + } + + mod hash { + use super::*; + + #[test] + fn hash_format_and_determinism() { + let key = random_key(&mut OsRng); + let genesis = random_bytes(&mut OsRng); + + for tx in all_transactions() { + assert_eq!(tx.hash(), tx.hash(), "Hash not deterministic for {tx:?}"); + + let serialized = ReadWrite::serialize(&tx); + + let (hash_input, is_signed) = match &tx { + // Signed txs: strip the last 64 bytes (signature R || s) + Transaction::RemoveParticipant { signed, .. } | + Transaction::DkgParticipation { signed, .. } | + Transaction::DkgConfirmationPreprocess { signed, .. } | + Transaction::DkgConfirmationShare { signed, .. } | + Transaction::Sign { signed, .. } | + Transaction::SlashReport { signed, .. } => { + // Verify the stripped bytes are exactly the signature + let sig_bytes = signed.signature.serialize(); + assert_eq!( + &serialized[serialized.len() - 64 ..], + sig_bytes.as_slice(), + "last 64 bytes should be signature R || s for {tx:?}" + ); + (&serialized[.. serialized.len() - 64], true) + } + // Provided txs: hash the full serialization + Transaction::Cosign { .. } | + Transaction::Cosigned { .. } | + Transaction::SubstrateBlock { .. } | + Transaction::Batch { .. } => (&serialized[..], false), + }; + + let expected_hash: [u8; 32] = Blake2b::::digest(hash_input).into(); + assert_eq!(tx.hash(), expected_hash, "Hash format mismatch for {tx:?}"); + + // For signed txs: different signatures should produce the same hash + if is_signed { + let mut tx1 = tx.clone(); + let mut tx2 = tx.clone(); + tx1.sign(&mut OsRng, genesis, &key); + tx2.sign(&mut OsRng, genesis, &key); + assert_eq!( + tx1.hash(), + tx2.hash(), + "Hashes should be equal despite different nonces and signatures" + ); + assert_ne!(ReadWrite::serialize(&tx1), ReadWrite::serialize(&tx2)); + } + } + } + + #[test] + fn hash_differs_for_distinct_transactions() { + let txs = all_transactions(); + for i in 0 .. txs.len() { + for j in (i + 1) .. txs.len() { + assert_ne!( + txs[i].hash(), + txs[j].hash(), + "Distinct TXs should have different hashes: {:?} vs {:?}", + txs[i], + txs[j] + ); + } + } + } + } + + #[test] + fn verify() { + let max = usize::from(KeyShares::MAX_PER_SET); + + for tx in all_transactions() { + // All default transactions should be valid + assert_eq!(tx.verify(), Ok(()), "verify() rejected valid tx: {tx:?}"); + + // Test boundary conditions per variant + match &tx { + // No additional validation beyond structure + Transaction::RemoveParticipant { .. } | + Transaction::DkgParticipation { .. } | + Transaction::DkgConfirmationPreprocess { .. } | + Transaction::DkgConfirmationShare { .. } | + Transaction::Cosign { .. } | + Transaction::Cosigned { .. } | + Transaction::SubstrateBlock { .. } | + Transaction::Batch { .. } => {} + + // Sign: data.len() must be <= KeyShares::MAX_PER_SET + Transaction::Sign { id, attempt, round, signed, .. } => { + let with_data = |data| Transaction::Sign { + id: *id, + attempt: *attempt, + round: *round, + data, + signed: *signed, + }; + assert_eq!(with_data(Vec::new()).verify(), Ok(())); + let random_len = usize::try_from(OsRng.next_u32()).unwrap() % max; + assert_eq!(with_data(vec![vec![]; random_len]).verify(), Ok(())); + assert_eq!(with_data(vec![vec![]; max]).verify(), Ok(())); + assert_eq!( + with_data(vec![vec![]; max + 1]).verify(), + Err(TransactionError::InvalidContent) + ); + } + + // SlashReport: slash_points.len() must be <= KeyShares::MAX_PER_SET + Transaction::SlashReport { signed, .. } => { + let with_points = + |points| Transaction::SlashReport { slash_points: points, signed: *signed }; + assert_eq!(with_points(vec![]).verify(), Ok(())); + let random_len = usize::try_from(OsRng.next_u32()).unwrap() % max; + assert_eq!(with_points(vec![0; random_len]).verify(), Ok(())); + assert_eq!(with_points(vec![0; max]).verify(), Ok(())); + assert_eq!(with_points(vec![0; max + 1]).verify(), Err(TransactionError::InvalidContent)); + } + } + } + } + + #[test] + fn topic() { + for tx in all_transactions() { + let expected = match &tx { + Transaction::RemoveParticipant { participant, .. } => { + Some(Topic::RemoveParticipant { participant: *participant }) + } + Transaction::DkgParticipation { .. } | + Transaction::Cosign { .. } | + Transaction::Cosigned { .. } | + Transaction::SubstrateBlock { .. } | + Transaction::Batch { .. } => None, + Transaction::DkgConfirmationPreprocess { attempt, .. } => Some(Topic::DkgConfirmation { + attempt: *attempt, + round: SigningProtocolRound::Preprocess, + }), + Transaction::DkgConfirmationShare { attempt, .. } => { + Some(Topic::DkgConfirmation { attempt: *attempt, round: SigningProtocolRound::Share }) + } + Transaction::Sign { id, attempt, round, .. } => { + Some(Topic::Sign { id: *id, attempt: *attempt, round: *round }) + } + Transaction::SlashReport { .. } => Some(Topic::SlashReport), + }; + assert_eq!(tx.topic(), expected, "Wrong topic for {tx:?}"); + } + } + + mod sign { + use super::*; + + #[test] + fn tx_sign() { + let key = random_key(&mut OsRng); + let expected_signer = Ristretto::generator() * key.deref(); + let genesis = random_bytes(&mut OsRng); + + // Sets correct signer and produces verifiable signature + for mut tx in all_signed_transactions_and_attempts(random_signed(&mut OsRng)) { + tx.sign(&mut OsRng, genesis, &key); + let TransactionKind::Signed(order, tributary_signed) = tx.kind() else { + panic!("non-signed TX from `all_signed_transactions_and_attempts`") + }; + let sig_hash = tx.sig_hash(genesis); + assert_eq!( + sig_hash, + ::F::from_bytes_mod_order_wide( + &blake2::Blake2b512::digest( + [ + b"Tributary Signed Transaction", + genesis.as_slice(), + &tx.hash(), + order.as_slice(), + tributary_signed.signature.R.to_bytes().as_slice(), + ] + .concat(), + ) + .into(), + ) + ); + + assert_eq!(tributary_signed.signer, expected_signer, "Wrong signer for {tx:?}"); + assert_ne!(tributary_signed.signature.R, ::G::identity()); + assert!( + tributary_signed.signature.verify(tributary_signed.signer, sig_hash), + "Signature verification failed for {tx:?}" + ); + } + + // Wrong genesis fails verification + { + let mut tx = Transaction::RemoveParticipant { + participant: random_serai_address(&mut OsRng), + signed: random_signed(&mut OsRng), + }; + let genesis = random_bytes(&mut OsRng); + tx.sign(&mut OsRng, genesis, &key); + + let mut wrong_genesis = random_bytes(&mut OsRng); + // guaranteed to be the wrong genesis + if wrong_genesis == genesis { + wrong_genesis[0] ^= 1; + } + let wrong_challenge = tx.sig_hash(wrong_genesis); + if let TransactionKind::Signed(_, tributary_signed) = tx.kind() { + assert!( + !tributary_signed.signature.verify(tributary_signed.signer, wrong_challenge), + "Signature should not verify with wrong genesis" + ); + } + } + } + + #[test] + #[should_panic(expected = "signing Cosign transaction (provided)")] + fn panics_on_cosign() { + let key = random_key(&mut OsRng); + let mut tx = Transaction::Cosign { substrate_block_hash: random_block_hash(&mut OsRng) }; + tx.sign(&mut OsRng, random_bytes(&mut OsRng), &key); + } + + #[test] + #[should_panic(expected = "signing Cosigned transaction (provided)")] + fn panics_on_cosigned() { + let key = random_key(&mut OsRng); + let mut tx = Transaction::Cosigned { substrate_block_hash: random_block_hash(&mut OsRng) }; + tx.sign(&mut OsRng, random_bytes(&mut OsRng), &key); + } + + #[test] + #[should_panic(expected = "signing SubstrateBlock transaction (provided)")] + fn panics_on_substrate_block() { + let key = random_key(&mut OsRng); + let mut tx = Transaction::SubstrateBlock { hash: random_block_hash(&mut OsRng) }; + tx.sign(&mut OsRng, random_bytes(&mut OsRng), &key); + } + + #[test] + #[should_panic(expected = "signing Batch transaction (provided)")] + fn panics_on_batch() { + let key = random_key(&mut OsRng); + let mut tx = Transaction::Batch { hash: random_block_hash(&mut OsRng).0 }; + tx.sign(&mut OsRng, random_bytes(&mut OsRng), &key); + } + } +} diff --git a/coordinator/tributary/src/tests/tributary.rs b/coordinator/tributary/src/tests/tributary.rs new file mode 100644 index 000000000..9349b6ff2 --- /dev/null +++ b/coordinator/tributary/src/tests/tributary.rs @@ -0,0 +1,77 @@ +use serai_db::{Db as _, DbTxn as _, MemDb}; +use crate::{Transaction, SlashPoints, TributaryDb, slash_report_transaction}; +use super::*; + +// TODO: Test the resulting slash report the Tributary would yield in response to consensus on this +#[test] +fn slash_report() { + // No slash points set: all zeros + { + let db = MemDb::new(); + let validators = vec![ + (random_serai_address(&mut OsRng), 1), + (random_serai_address(&mut OsRng), 1), + (random_serai_address(&mut OsRng), 1), + ]; + let set_info = new_test_set_info(&validators); + + assert_eq!( + slash_report_transaction(&db, &set_info), + Transaction::SlashReport { slash_points: vec![0, 0, 0], signed: Signed::default() } + ); + } + + // Respects validator order + { + let mut db = MemDb::new(); + let (v1, v2, v3, v4) = ( + random_serai_address(&mut OsRng), + random_serai_address(&mut OsRng), + random_serai_address(&mut OsRng), + random_serai_address(&mut OsRng), + ); + let set_info = new_test_set_info(&[(v1, 1), (v2, 1), (v3, 1), (v4, 1)]); + let set = set_info.set; + + let (slash1, slash2, slash3, slash4) = + (OsRng.next_u32(), OsRng.next_u32(), OsRng.next_u32(), OsRng.next_u32()); + + { + let mut txn = db.txn(); + SlashPoints::set(&mut txn, set, v1, &slash1); + // SlashPoints sets validator 3 before 2 here, + // but this order doesn't affect the validators order of set_info + SlashPoints::set(&mut txn, set, v3, &slash3); + SlashPoints::set(&mut txn, set, v2, &slash2); + SlashPoints::set(&mut txn, set, v4, &slash4); + txn.commit(); + } + + assert_eq!( + slash_report_transaction(&db, &set_info), + Transaction::SlashReport { + slash_points: vec![slash1, slash2, slash3, slash4], + signed: Signed::default() + } + ); + } + + // Fatal slash yields u32::MAX + { + let mut db = MemDb::new(); + let (v1, v2) = (random_serai_address(&mut OsRng), random_serai_address(&mut OsRng)); + let set_info = new_test_set_info(&[(v1, 1), (v2, 1)]); + let set = set_info.set; + + { + let mut txn = db.txn(); + TributaryDb::fatal_slash(&mut txn, set, v1, "test reason"); + txn.commit(); + } + + assert_eq!( + slash_report_transaction(&db, &set_info), + Transaction::SlashReport { slash_points: vec![u32::MAX, 0], signed: Signed::default() } + ); + } +} diff --git a/coordinator/tributary/src/transaction.rs b/coordinator/tributary/src/transaction.rs index 950d352ac..d7c68c8fc 100644 --- a/coordinator/tributary/src/transaction.rs +++ b/coordinator/tributary/src/transaction.rs @@ -37,7 +37,7 @@ pub enum SigningProtocolRound { } impl SigningProtocolRound { - fn nonce(self) -> u32 { + pub(crate) fn nonce(self) -> u32 { match self { SigningProtocolRound::Preprocess => 0, SigningProtocolRound::Share => 1, @@ -51,9 +51,9 @@ impl SigningProtocolRound { #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub struct Signed { /// The signer. - signer: ::G, + pub(crate) signer: ::G, /// The signature. - signature: SchnorrSignature, + pub(crate) signature: SchnorrSignature, } impl BorshSerialize for Signed { @@ -77,7 +77,7 @@ impl Signed { } /// Provide a nonce to convert a `Signed` into a `tributary::Signed`. - fn to_tributary_signed(self, nonce: u32) -> TributarySigned { + pub(crate) fn to_tributary_signed(self, nonce: u32) -> TributarySigned { TributarySigned { signer: self.signer, nonce, signature: self.signature } } } @@ -94,7 +94,11 @@ impl Default for Signed { } } -/// The Tributary transaction definition used by Serai +/// The Tributary transaction definition used by Serai. +/// +/// Two transactions will be considered equal if equal on every level. This means transactions +/// which aren't equal may share a hash, due to the hash not binding to the signature, yet the +/// equality binding to the signature. #[derive(Clone, PartialEq, Eq, Debug, BorshSerialize, BorshDeserialize)] pub enum Transaction { /// A vote to remove a participant for invalid behavior @@ -115,7 +119,7 @@ pub enum Transaction { /// The preprocess to confirm the DKG results on-chain DkgConfirmationPreprocess { /// The attempt number of this signing protocol - attempt: u32, + attempt: u64, /// The preprocess preprocess: [u8; 64], /// The transaction's signer and signature @@ -124,7 +128,7 @@ pub enum Transaction { /// The signature share to confirm the DKG results on-chain DkgConfirmationShare { /// The attempt number of this signing protocol - attempt: u32, + attempt: u64, /// The signature share share: [u8; 32], /// The transaction's signer and signature @@ -206,7 +210,7 @@ pub enum Transaction { /// The ID of the object being signed id: VariantSignId, /// The attempt number of this signing protocol - attempt: u32, + attempt: u64, /// The round this data is for, within the signing protocol round: SigningProtocolRound, /// The data itself @@ -229,7 +233,7 @@ pub enum Transaction { impl ReadWrite for Transaction { fn read(reader: &mut R) -> io::Result { - borsh::from_reader(reader) + Self::deserialize_reader(reader) } fn write(&self, writer: &mut W) -> io::Result<()> { @@ -251,11 +255,11 @@ impl TransactionTrait for Transaction { ), Transaction::DkgConfirmationPreprocess { attempt, signed, .. } => TransactionKind::Signed( borsh::to_vec(&(b"DkgConfirmation".as_slice(), attempt)).unwrap(), - signed.to_tributary_signed(0), + signed.to_tributary_signed(SigningProtocolRound::Preprocess.nonce()), ), Transaction::DkgConfirmationShare { attempt, signed, .. } => TransactionKind::Signed( borsh::to_vec(&(b"DkgConfirmation".as_slice(), attempt)).unwrap(), - signed.to_tributary_signed(1), + signed.to_tributary_signed(SigningProtocolRound::Share.nonce()), ), Transaction::Cosign { .. } => TransactionKind::Provided("Cosign"), diff --git a/processor/ethereum/src/primitives/transaction.rs b/processor/ethereum/src/primitives/transaction.rs index d91455576..0e9922569 100644 --- a/processor/ethereum/src/primitives/transaction.rs +++ b/processor/ethereum/src/primitives/transaction.rs @@ -1,5 +1,7 @@ use std::io; +use borsh::BorshDeserialize as _; + use ciphersuite_kp256::Secp256k1; use frost::dkg::ThresholdKeys; @@ -122,7 +124,7 @@ impl SignableTransaction for Action { Action::SetKey { chain_id, router_address, nonce, key } } 1 => { - let coin = borsh::from_reader(reader)?; + let coin = <_>::deserialize_reader(reader)?; let mut fee = [0; 32]; reader.read_exact(&mut fee)?; @@ -134,7 +136,7 @@ impl SignableTransaction for Action { let mut outs = vec![]; for _ in 0 .. outs_len { - let address = borsh::from_reader(reader)?; + let address = <_>::deserialize_reader(reader)?; let mut amount = [0; 32]; reader.read_exact(&mut amount)?; @@ -202,7 +204,7 @@ impl primitives::Eventuality for Eventuality { } fn read(reader: &mut impl io::Read) -> io::Result { - Ok(Self(borsh::from_reader(reader)?)) + Ok(Self(<_>::deserialize_reader(reader)?)) } fn write(&self, writer: &mut impl io::Write) -> io::Result<()> { borsh::BorshSerialize::serialize(&self.0, writer) diff --git a/processor/frost-attempt-manager/src/individual.rs b/processor/frost-attempt-manager/src/individual.rs index e77538689..59bfb4185 100644 --- a/processor/frost-attempt-manager/src/individual.rs +++ b/processor/frost-attempt-manager/src/individual.rs @@ -14,7 +14,7 @@ use messages::sign::{VariantSignId, SignId, ProcessorMessage}; create_db!( FrostAttemptManager { - Attempted: (session: Session, id: VariantSignId) -> u32, + Attempted: (session: Session, id: VariantSignId) -> u64, } ); @@ -31,10 +31,10 @@ pub(crate) struct SigningProtocol { id: VariantSignId, // This accepts a vector of `root` machines in order to support signing with multiple key shares. root: Vec, - preprocessed: HashMap, HashMap>)>, + preprocessed: HashMap, HashMap>)>, // Here, we drop to a single machine as we only need one to complete the signature. shared: HashMap< - u32, + u64, ( >::SignatureMachine, HashMap>, @@ -67,7 +67,7 @@ impl SigningProtocol { /// Start a new attempt of the signing protocol. /// /// Returns the (serialized) preprocesses for the attempt. - pub(crate) fn attempt(&mut self, attempt: u32) -> Vec { + pub(crate) fn attempt(&mut self, attempt: u64) -> Vec { /* We'd get slashed as malicious if we: 1) Preprocessed @@ -134,7 +134,7 @@ impl SigningProtocol { /// Returns the (serialized) shares for the attempt. pub(crate) fn preprocesses( &mut self, - attempt: u32, + attempt: u64, serialized_preprocesses: HashMap>, ) -> Vec { log::debug!("handling preprocesses for signing protocol {:?}", self.id); @@ -226,7 +226,7 @@ impl SigningProtocol { /// Returns the signature produced by the protocol. pub(crate) fn shares( &mut self, - attempt: u32, + attempt: u64, serialized_shares: HashMap>, ) -> Result> { log::debug!("handling shares for signing protocol {:?}", self.id); diff --git a/processor/messages/src/lib.rs b/processor/messages/src/lib.rs index 3194a0bfc..496753270 100644 --- a/processor/messages/src/lib.rs +++ b/processor/messages/src/lib.rs @@ -176,7 +176,7 @@ pub mod sign { pub struct SignId { pub session: Session, pub id: VariantSignId, - pub attempt: u32, + pub attempt: u64, } #[derive(Clone, PartialEq, Eq, Debug, BorshSerialize, BorshDeserialize)] diff --git a/substrate/primitives/src/test_helpers.rs b/substrate/primitives/src/test_helpers.rs index 9113bad50..0026b459e 100644 --- a/substrate/primitives/src/test_helpers.rs +++ b/substrate/primitives/src/test_helpers.rs @@ -1,6 +1,7 @@ //! Test helpers for generating random instances of primitive types. -use alloc::vec; +use core::ops::{Bound, RangeBounds}; +use alloc::{vec, vec::Vec}; use rand_core::{RngCore, CryptoRng}; @@ -8,22 +9,54 @@ use crate::{ BlockHash, address::{SeraiAddress, ExternalAddress}, crypto::{Public, ExternalKey}, + network_id::ExternalNetworkId, + validator_sets::{Session, ExternalValidatorSet}, }; -/// Generate a random 32-byte array. -pub fn random_bytes_32(rng: &mut R) -> [u8; 32] { - let mut bytes = [0u8; 32]; +/// Generate a random byte array. +pub fn random_bytes(rng: &mut R) -> [u8; N] { + let mut bytes = [0u8; N]; rng.fill_bytes(&mut bytes); bytes } -/// Generate a random 64-byte array. -pub fn random_bytes_64(rng: &mut R) -> [u8; 64] { - let mut bytes = [0u8; 64]; +/// Generate a random byte vector of a length within a range. +pub fn random_vec_u8(rng: &mut R, len: impl RangeBounds) -> Vec { + let len = { + let inclusive_start = match len.start_bound() { + Bound::Included(start) => *start, + Bound::Excluded(start) => start + 1, + Bound::Unbounded => 0, + }; + let inclusive_end = match len.end_bound() { + Bound::Included(end) => *end, + Bound::Excluded(end) => end - 1, + Bound::Unbounded => panic!("do not request a random vector of unbounded length"), + }; + let range_len = inclusive_end + .checked_sub(inclusive_start) + .expect("requested a random vector for a length within a range with no elements") + + 1; + let i = usize::try_from(rng.next_u64() % u64::try_from(range_len).unwrap()).unwrap(); + inclusive_start + i + }; + + let mut bytes = vec![0u8; len]; rng.fill_bytes(&mut bytes); bytes } +#[test] +fn random_vec_u8_handles_ranges_correctly() { + use rand_core::OsRng; + for _ in 0 .. 128 { + assert_eq!(random_vec_u8(&mut OsRng, 0 ..= 0).len(), 0); + assert_eq!(random_vec_u8(&mut OsRng, 0 .. 1).len(), 0); + assert_eq!(random_vec_u8(&mut OsRng, ..= 0).len(), 0); + assert_eq!(random_vec_u8(&mut OsRng, .. 1).len(), 0); + } +} + /// Generate a random [`ExternalAddress`]. pub fn random_external_address(rng: &mut R) -> ExternalAddress { let len = usize::try_from(rng.next_u32() % ExternalAddress::MAX_SIZE).unwrap(); @@ -41,12 +74,12 @@ fn random_external_address_is_in_range() { /// Generate a random [`SeraiAddress`]. pub fn random_serai_address(rng: &mut R) -> SeraiAddress { - SeraiAddress(random_bytes_32(rng)) + SeraiAddress(random_bytes(rng)) } /// Generate a random [`Public`]. pub fn random_public(rng: &mut R) -> Public { - Public(random_bytes_32(rng)) + Public(random_bytes(rng)) } /// Generate a random schnorrkel keypair and its [`Public`] wrapper. @@ -73,5 +106,19 @@ fn random_external_key_is_in_range() { /// Generate a random [`BlockHash`]. pub fn random_block_hash(rng: &mut R) -> BlockHash { - BlockHash(random_bytes_32(rng)) + BlockHash(random_bytes(rng)) +} + +/// Generate a random [`ExternalNetworkId`]. +pub fn random_external_network_id(rng: &mut R) -> ExternalNetworkId { + let all: Vec<_> = ExternalNetworkId::all().collect(); + all[usize::try_from(rng.next_u64() % u64::try_from(all.len()).unwrap()).unwrap()] +} + +/// Generate a random [`ExternalValidatorSet`]. +pub fn random_validator_set(rng: &mut R) -> ExternalValidatorSet { + ExternalValidatorSet { + network: random_external_network_id(rng), + session: Session(rng.next_u32()), + } }