diff --git a/Cargo.lock b/Cargo.lock index 261b803..2a28c0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2375,6 +2375,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "dyn-eq" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d035d21af5cde1a6f5c7b444a5bf963520a9f142e5d06931178433d7d5388" + [[package]] name = "ecdsa" version = "0.16.9" @@ -5519,12 +5525,15 @@ dependencies = [ "cancellation", "chrono", "crossbeam", + "dyn-clone", + "dyn-eq", "futures", "hex", "libp2p", "pluto-build-proto", "pluto-eth2api", "pluto-eth2util", + "pluto-testutil", "prost 0.14.3", "prost-types 0.14.3", "rand 0.8.5", @@ -5538,6 +5547,7 @@ dependencies = [ "tokio-util", "tracing", "tree_hash", + "vise", ] [[package]] @@ -5745,6 +5755,7 @@ dependencies = [ "hex", "k256", "pluto-crypto", + "pluto-eth2api", "rand 0.8.5", "rand_core 0.6.4", "thiserror 2.0.18", diff --git a/Cargo.toml b/Cargo.toml index 6c45349..3a7a2e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,8 @@ cancellation = "0.1.0" chrono = { version = "0.4", features = ["serde"] } clap = { version = "4.5.53", features = ["derive", "env", "cargo"] } crossbeam = "0.8.4" +dyn-clone = "1.0" +dyn-eq = "0.1.3" either = "1.13" futures = "0.3" futures-timer = "3.0" diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index cdb9c9a..ccbf223 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -12,8 +12,11 @@ cancellation.workspace = true chrono.workspace = true crossbeam.workspace = true futures.workspace = true +dyn-clone.workspace = true +dyn-eq.workspace = true hex.workspace = true libp2p.workspace = true +vise.workspace = true pluto-eth2api.workspace = true prost.workspace = true prost-types.workspace = true @@ -38,6 +41,8 @@ prost-types.workspace = true hex.workspace = true chrono.workspace = true test-case.workspace = true +pluto-eth2util.workspace = true +pluto-testutil.workspace = true [build-dependencies] pluto-build-proto.workspace = true diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index d38cbdc..ac70996 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -22,3 +22,10 @@ pub mod version; /// Duty deadline tracking and notification. pub mod deadline; + +/// parsigdb +pub mod parsigdb; + +/// Test utilities. +#[cfg(test)] +pub mod testutils; diff --git a/crates/core/src/parasigdb/memory.rs b/crates/core/src/parsigdb/memory.rs similarity index 87% rename from crates/core/src/parasigdb/memory.rs rename to crates/core/src/parsigdb/memory.rs index 1a20e14..e4d025e 100644 --- a/crates/core/src/parasigdb/memory.rs +++ b/crates/core/src/parsigdb/memory.rs @@ -5,7 +5,8 @@ use tracing::{debug, warn}; use crate::{ deadline::Deadliner, - parasigdb::metrics::PARASIG_DB_METRICS, + parsigdb::metrics::PARSIG_DB_METRICS, + signeddata::SignedDataError, types::{Duty, DutyType, ParSignedData, ParSignedDataSet, PubKey}, }; use chrono::{DateTime, Utc}; @@ -55,9 +56,9 @@ pub type ThreshSub = Arc< /// Helper to create an internal subscriber from a closure. /// -/// The closure receives owned copies of the duty and data set. Since the closure -/// is `Fn` (can be called multiple times), you need to clone any captured Arc values -/// before the `async move` block. +/// The closure receives owned copies of the duty and data set. Since the +/// closure is `Fn` (can be called multiple times), you need to clone any +/// captured Arc values before the `async move` block. /// /// # Example /// ```ignore @@ -88,8 +89,8 @@ where /// Helper to create a threshold subscriber from a closure. /// /// The closure receives owned copies of the duty and data. Since the closure -/// is `Fn` (can be called multiple times), you need to clone any captured Arc values -/// before the `async move` block. +/// is `Fn` (can be called multiple times), you need to clone any captured Arc +/// values before the `async move` block. /// /// # Example /// ```ignore @@ -128,6 +129,10 @@ pub enum MemDBError { /// Share index of the mismatched signature share_idx: u64, }, + + /// Signed data error. + #[error("signed data error: {0}")] + SignedDataError(#[from] SignedDataError), } type Result = std::result::Result; @@ -186,8 +191,8 @@ impl MemDB { impl MemDB { /// Registers a subscriber for internally generated partial signed data. /// - /// The subscriber will be called when the node generates partial signed data - /// that needs to be exchanged with peers. + /// The subscriber will be called when the node generates partial signed + /// data that needs to be exchanged with peers. pub async fn subscribe_internal(&self, sub: InternalSub) -> Result<()> { let mut inner = self.inner.lock().await; inner.internal_subs.push(sub); @@ -204,11 +209,13 @@ impl MemDB { Ok(()) } - /// Stores internally generated partial signed data and notifies subscribers. + /// Stores internally generated partial signed data and notifies + /// subscribers. /// - /// This is called when the node generates partial signed data that needs to be - /// stored and exchanged with peers. It first stores the data (via `store_external`), - /// then calls all internal subscribers to trigger peer exchange. + /// This is called when the node generates partial signed data that needs to + /// be stored and exchanged with peers. It first stores the data (via + /// `store_external`), then calls all internal subscribers to trigger + /// peer exchange. pub async fn store_internal(&self, duty: &Duty, signed_set: &ParSignedDataSet) -> Result<()> { self.store_external(duty, signed_set).await?; @@ -226,9 +233,10 @@ impl MemDB { /// Stores externally received partial signed data and checks for threshold. /// - /// This is called when the node receives partial signed data from peers. It stores - /// the data, checks if enough matching signatures have been collected to meet the - /// threshold, and calls threshold subscribers when the threshold is reached. + /// This is called when the node receives partial signed data from peers. It + /// stores the data, checks if enough matching signatures have been + /// collected to meet the threshold, and calls threshold subscribers + /// when the threshold is reached. pub async fn store_external(&self, duty: &Duty, signed_data: &ParSignedDataSet) -> Result<()> { let _ = self.deadliner.add(duty.clone()).await; @@ -239,7 +247,7 @@ impl MemDB { .store( Key { duty: duty.clone(), - pub_key: pub_key.clone(), + pub_key: *pub_key, }, par_signed.clone(), ) @@ -257,7 +265,7 @@ impl MemDB { continue; }; - output.insert(pub_key.clone(), psigs); + output.insert(*pub_key, psigs); } if output.is_empty() { @@ -278,17 +286,15 @@ impl MemDB { /// Trims expired duties from the database. /// - /// This method runs in a loop, listening for expired duties from the deadliner - /// and removing their associated data from the database. It should be spawned - /// as a background task and will run until the cancellation token is triggered. + /// This method runs in a loop, listening for expired duties from the + /// deadliner and removing their associated data from the database. It + /// should be spawned as a background task and will run until the + /// cancellation token is triggered. pub async fn trim(&self) { - let deadliner_rx = self.deadliner.c(); - if deadliner_rx.is_none() { + let Some(mut deadliner_rx) = self.deadliner.c() else { warn!("Deadliner channel is not available"); return; - } - - let mut deadliner_rx = deadliner_rx.unwrap(); + }; loop { tokio::select! { @@ -345,14 +351,10 @@ impl MemDB { .push(k.clone()); if k.duty.duty_type == DutyType::Exit { - PARASIG_DB_METRICS.exit_total[&k.pub_key.to_string()].inc(); + PARSIG_DB_METRICS.exit_total[&k.pub_key.to_string()].inc(); } - let result = inner - .entries - .get(&k) - .map(|entries| entries.clone()) - .unwrap_or_default(); + let result = inner.entries.get(&k).cloned().unwrap_or_default(); Ok(Some(result)) } @@ -381,11 +383,11 @@ async fn get_threshold_matching( let mut sigs_by_msg_root: HashMap<[u8; 32], Vec> = HashMap::new(); for sig in sigs { - let root = sig.signed_data.message_root(); - sigs_by_msg_root - .entry(root) - .or_insert_with(Vec::new) - .push(sig.clone()); + let root = sig + .signed_data + .message_root() + .map_err(MemDBError::SignedDataError)?; + sigs_by_msg_root.entry(root).or_default().push(sig.clone()); } // Return the first set that has exactly threshold number of signatures diff --git a/crates/core/src/parsigdb/memory_internal_test.rs b/crates/core/src/parsigdb/memory_internal_test.rs new file mode 100644 index 0000000..58f4c47 --- /dev/null +++ b/crates/core/src/parsigdb/memory_internal_test.rs @@ -0,0 +1,215 @@ +use std::{ + sync::{Arc, Mutex as StdMutex}, + time::Duration, +}; + +use futures::future::{BoxFuture, FutureExt}; +use pluto_eth2api::{spec::altair, v1}; +use pluto_testutil as testutil; +use test_case::test_case; +use tokio::sync::{Mutex, mpsc}; +use tokio_util::sync::CancellationToken; + +use super::{MemDB, get_threshold_matching, threshold_subscriber}; +use crate::{ + deadline::Deadliner, + signeddata::{BeaconCommitteeSelection, SignedSyncMessage, VersionedAttestation}, + testutils::random_core_pub_key, + types::{Duty, DutyType, ParSignedData, ParSignedDataSet, SlotNumber}, +}; + +fn threshold(nodes: usize) -> u64 { + (2_u64 + .checked_mul(u64::try_from(nodes).expect("nodes overflow")) + .expect("nodes overflow")) + .div_ceil(3) +} + +#[test_case(Vec::new(), Vec::new() ; "empty")] +#[test_case(vec![0, 0, 0], vec![0, 1, 2] ; "all identical exact threshold")] +#[test_case(vec![0, 0, 0, 0], Vec::new() ; "all identical above threshold")] +#[test_case(vec![0, 0, 1, 0], vec![0, 1, 3] ; "one odd")] +#[test_case(vec![0, 0, 1, 1], Vec::new() ; "two odd")] +#[tokio::test] +async fn test_get_threshold_matching(input: Vec, output: Vec) { + const N: usize = 4; + + let slot = testutil::random_slot(); + let validator_index = testutil::random_v_idx(); + let roots = [testutil::random_root_bytes(), testutil::random_root_bytes()]; + let threshold = threshold(N); + + type Providers<'a> = [(&'a str, Box ParSignedData + 'a>); 2]; + + let providers: Providers<'_> = [ + ( + "sync_committee_message", + Box::new(|i| { + let message = altair::SyncCommitteeMessage { + slot, + beacon_block_root: roots[input[i]], + validator_index, + signature: testutil::random_eth2_signature_bytes(), + }; + + SignedSyncMessage::new_partial(message, u64::try_from(i.wrapping_add(1)).unwrap()) + }), + ), + ( + "selection", + Box::new(|i| { + let selection = v1::BeaconCommitteeSelection { + validator_index, + slot: u64::try_from(input[i]).unwrap(), + selection_proof: testutil::random_eth2_signature_bytes(), + }; + + BeaconCommitteeSelection::new_partial( + selection, + u64::try_from(i.wrapping_add(1)).unwrap(), + ) + }), + ), + ]; + + for (name, provider) in providers { + let mut data = Vec::new(); + for i in 0..input.len() { + data.push(provider(i)); + } + + let out = get_threshold_matching(&DutyType::SyncMessage, &data, threshold) + .await + .expect("threshold matching should succeed"); + let expect: Vec<_> = output.iter().map(|idx| data[*idx].clone()).collect(); + let expected_out = if expect.is_empty() { + None + } else { + Some(expect.clone()) + }; + + assert_eq!(expected_out, out, "{name}/output mismatch"); + assert_eq!( + out.as_ref() + .map(|matches| u64::try_from(matches.len()).unwrap() == threshold) + .unwrap_or(false), + expect.len() as u64 == threshold, + "{name}/ok mismatch" + ); + } +} + +#[tokio::test] +async fn test_memdb_threshold() { + const THRESHOLD: u64 = 7; + const N: usize = 10; + + let deadliner = Arc::new(TestDeadliner::new()); + let cancel = CancellationToken::new(); + let db = Arc::new(MemDB::new(cancel.clone(), THRESHOLD, deadliner.clone())); + + let trim_handle = tokio::spawn({ + let db = db.clone(); + async move { + db.trim().await; + } + }); + + let times_called = Arc::new(Mutex::new(0usize)); + db.subscribe_threshold(threshold_subscriber({ + let times_called = times_called.clone(); + move |_duty, _data| { + let times_called = times_called.clone(); + async move { + *times_called.lock().await += 1; + Ok(()) + } + } + })) + .await + .expect("subscription should succeed"); + + let pubkey = random_core_pub_key(); + let attestation = testutil::random_deneb_versioned_attestation(); + let duty = Duty::new_attester_duty(SlotNumber::new(123)); + + let enqueue_n = || async { + for i in 0..N { + let partial = VersionedAttestation::new_partial( + attestation.clone(), + u64::try_from(i + 1).unwrap(), + ) + .expect("versioned attestation should be valid"); + + let mut set = ParSignedDataSet::new(); + set.insert(pubkey, partial); + + db.store_external(&duty, &set) + .await + .expect("store_external should succeed"); + } + }; + + enqueue_n().await; + assert_eq!(1, *times_called.lock().await); + + deadliner.expire().await; + tokio::time::sleep(Duration::from_millis(20)).await; + + enqueue_n().await; + assert_eq!(2, *times_called.lock().await); + + cancel.cancel(); + trim_handle + .await + .expect("trim task should shut down cleanly"); +} + +struct TestDeadliner { + added: StdMutex>, + tx: mpsc::Sender, + rx: StdMutex>>, +} + +impl TestDeadliner { + fn new() -> Self { + let (tx, rx) = mpsc::channel(32); + Self { + added: StdMutex::new(Vec::new()), + tx, + rx: StdMutex::new(Some(rx)), + } + } + + async fn expire(&self) -> bool { + let duties = { + let mut added = self.added.lock().expect("test deadliner lock poisoned"); + std::mem::take(&mut *added) + }; + + for duty in duties { + if self.tx.send(duty).await.is_err() { + return false; + } + } + + true + } +} + +impl Deadliner for TestDeadliner { + fn add(&self, duty: Duty) -> BoxFuture<'_, bool> { + async move { + self.added + .lock() + .expect("test deadliner lock poisoned") + .push(duty); + true + } + .boxed() + } + + fn c(&self) -> Option> { + self.rx.lock().expect("test deadliner lock poisoned").take() + } +} diff --git a/crates/core/src/parsigdb/metrics.rs b/crates/core/src/parsigdb/metrics.rs new file mode 100644 index 0000000..24a05fd --- /dev/null +++ b/crates/core/src/parsigdb/metrics.rs @@ -0,0 +1,12 @@ +use vise::*; + +/// Metrics for the ParSigDB. +#[derive(Debug, Clone, Metrics)] +pub struct ParsigDBMetrics { + /// Total number of partially signed voluntary exits per public key + #[metrics(labels = ["pubkey"])] + pub exit_total: LabeledFamily, +} + +/// Global metrics for the ParSigDB. +pub static PARSIG_DB_METRICS: Global = Global::new(); diff --git a/crates/core/src/parsigdb/mod.rs b/crates/core/src/parsigdb/mod.rs new file mode 100644 index 0000000..fd01b27 --- /dev/null +++ b/crates/core/src/parsigdb/mod.rs @@ -0,0 +1,5 @@ +/// Memory implementation of the ParSigDB. +pub mod memory; + +/// Metrics for the ParSigDB. +pub mod metrics; diff --git a/crates/core/src/signeddata.rs b/crates/core/src/signeddata.rs index 09865da..4244e8a 100644 --- a/crates/core/src/signeddata.rs +++ b/crates/core/src/signeddata.rs @@ -48,6 +48,9 @@ pub enum SignedDataError { /// Invalid attestation wrapper JSON. #[error("unmarshal attestation")] AttestationJson, + /// Custom error. + #[error("{0}")] + Custom(Box), } fn hash_root(value: &T) -> [u8; 32] { @@ -127,23 +130,21 @@ impl Signature { } /// Creates a partially signed signature wrapper. - pub fn new_partial(sig: Self, share_idx: u64) -> ParSignedData { + pub fn new_partial(sig: Self, share_idx: u64) -> ParSignedData { ParSignedData::new(sig, share_idx) } } impl SignedData for Signature { - type Error = SignedDataError; - - fn signature(&self) -> Result { + fn signature(&self) -> Result { Ok(self.clone()) } - fn set_signature(&self, signature: Signature) -> Result { + fn set_signature(&self, signature: Signature) -> Result { Ok(signature) } - fn message_root(&self) -> Result<[u8; 32], Self::Error> { + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { Err(SignedDataError::UnsupportedSignatureMessageRoot) } } @@ -179,7 +180,7 @@ impl VersionedSignedProposal { pub fn new_partial( proposal: versioned::VersionedSignedProposal, share_idx: u64, - ) -> Result, SignedDataError> { + ) -> Result { Ok(ParSignedData::new(Self::new(proposal)?, share_idx)) } @@ -222,7 +223,7 @@ impl VersionedSignedProposal { pub fn new_partial_from_blinded_proposal( proposal: versioned::VersionedSignedBlindedProposal, share_idx: u64, - ) -> Result, SignedDataError> { + ) -> Result { Ok(ParSignedData::new( Self::from_blinded_proposal(proposal)?, share_idx, @@ -231,9 +232,7 @@ impl VersionedSignedProposal { } impl SignedData for VersionedSignedProposal { - type Error = SignedDataError; - - fn signature(&self) -> Result { + fn signature(&self) -> Result { let proposal = &self.0; if proposal.version == versioned::DataVersion::Unknown { return Err(SignedDataError::UnknownVersion); @@ -241,7 +240,7 @@ impl SignedData for VersionedSignedProposal { Ok(sig_from_eth2(proposal.block.signature())) } - fn set_signature(&self, signature: Signature) -> Result { + fn set_signature(&self, signature: Signature) -> Result { let mut out = self.clone(); let proposal = &mut out.0; if proposal.version == versioned::DataVersion::Unknown { @@ -253,7 +252,7 @@ impl SignedData for VersionedSignedProposal { Ok(out) } - fn message_root(&self) -> Result<[u8; 32], Self::Error> { + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { let proposal = &self.0; if proposal.version == versioned::DataVersion::Unknown { return Err(SignedDataError::UnknownVersion); @@ -378,25 +377,23 @@ impl Attestation { } /// Creates a partial signed attestation wrapper. - pub fn new_partial(attestation: phase0::Attestation, share_idx: u64) -> ParSignedData { + pub fn new_partial(attestation: phase0::Attestation, share_idx: u64) -> ParSignedData { ParSignedData::new(Self::new(attestation), share_idx) } } impl SignedData for Attestation { - type Error = SignedDataError; - - fn signature(&self) -> Result { + fn signature(&self) -> Result { Ok(sig_from_eth2(self.0.signature)) } - fn set_signature(&self, signature: Signature) -> Result { + fn set_signature(&self, signature: Signature) -> Result { let mut out = self.clone(); out.0.signature = sig_to_eth2(&signature); Ok(out) } - fn message_root(&self) -> Result<[u8; 32], Self::Error> { + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { Ok(hash_root(&self.0.data)) } } @@ -427,7 +424,7 @@ impl VersionedAttestation { pub fn new_partial( attestation: versioned::VersionedAttestation, share_idx: u64, - ) -> Result, SignedDataError> { + ) -> Result { Ok(ParSignedData::new(Self::new(attestation)?, share_idx)) } @@ -447,9 +444,7 @@ impl VersionedAttestation { } impl SignedData for VersionedAttestation { - type Error = SignedDataError; - - fn signature(&self) -> Result { + fn signature(&self) -> Result { let version = self.0.version; if version == versioned::DataVersion::Unknown { return Err(SignedDataError::UnknownVersion); @@ -461,7 +456,7 @@ impl SignedData for VersionedAttestation { .ok_or(SignedDataError::MissingAttestation(version)) } - fn set_signature(&self, signature: Signature) -> Result { + fn set_signature(&self, signature: Signature) -> Result { let mut out = self.clone(); let version = out.0.version; if version == versioned::DataVersion::Unknown { @@ -476,7 +471,7 @@ impl SignedData for VersionedAttestation { Ok(out) } - fn message_root(&self) -> Result<[u8; 32], Self::Error> { + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { let version = self.0.version; if version == versioned::DataVersion::Unknown { return Err(SignedDataError::UnknownVersion); @@ -584,19 +579,17 @@ pub struct SignedVoluntaryExit( ); impl SignedData for SignedVoluntaryExit { - type Error = SignedDataError; - - fn signature(&self) -> Result { + fn signature(&self) -> Result { Ok(sig_from_eth2(self.0.signature)) } - fn set_signature(&self, signature: Signature) -> Result { + fn set_signature(&self, signature: Signature) -> Result { let mut out = self.clone(); out.0.signature = sig_to_eth2(&signature); Ok(out) } - fn message_root(&self) -> Result<[u8; 32], Self::Error> { + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { Ok(hash_root(&self.0.message)) } } @@ -608,7 +601,7 @@ impl SignedVoluntaryExit { } /// Creates a partially signed voluntary exit wrapper. - pub fn new_partial(exit: phase0::SignedVoluntaryExit, share_idx: u64) -> ParSignedData { + pub fn new_partial(exit: phase0::SignedVoluntaryExit, share_idx: u64) -> ParSignedData { ParSignedData::new(Self::new(exit), share_idx) } } @@ -643,15 +636,13 @@ impl VersionedSignedValidatorRegistration { pub fn new_partial( registration: versioned::VersionedSignedValidatorRegistration, share_idx: u64, - ) -> Result, SignedDataError> { + ) -> Result { Ok(ParSignedData::new(Self::new(registration)?, share_idx)) } } impl SignedData for VersionedSignedValidatorRegistration { - type Error = SignedDataError; - - fn signature(&self) -> Result { + fn signature(&self) -> Result { match self.0.version { versioned::BuilderVersion::V1 => self .0 @@ -663,7 +654,7 @@ impl SignedData for VersionedSignedValidatorRegistration { } } - fn set_signature(&self, signature: Signature) -> Result { + fn set_signature(&self, signature: Signature) -> Result { let mut out = self.clone(); match out.0.version { versioned::BuilderVersion::V1 => { @@ -680,7 +671,7 @@ impl SignedData for VersionedSignedValidatorRegistration { Ok(out) } - fn message_root(&self) -> Result<[u8; 32], Self::Error> { + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { match self.0.version { versioned::BuilderVersion::V1 => { let Some(v1) = self.0.v1.as_ref() else { @@ -748,19 +739,17 @@ pub struct SignedRandao( ); impl SignedData for SignedRandao { - type Error = SignedDataError; - - fn signature(&self) -> Result { + fn signature(&self) -> Result { Ok(sig_from_eth2(self.0.signature)) } - fn set_signature(&self, signature: Signature) -> Result { + fn set_signature(&self, signature: Signature) -> Result { let mut out = self.clone(); out.0.signature = sig_to_eth2(&signature); Ok(out) } - fn message_root(&self) -> Result<[u8; 32], Self::Error> { + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { Ok(hash_root(&self.0)) } } @@ -779,7 +768,7 @@ impl SignedRandao { epoch: phase0::Epoch, randao: phase0::BLSSignature, share_idx: u64, - ) -> ParSignedData { + ) -> ParSignedData { ParSignedData::new(Self::new(epoch, randao), share_idx) } } @@ -793,19 +782,17 @@ pub struct BeaconCommitteeSelection( ); impl SignedData for BeaconCommitteeSelection { - type Error = SignedDataError; - - fn signature(&self) -> Result { + fn signature(&self) -> Result { Ok(sig_from_eth2(self.0.selection_proof)) } - fn set_signature(&self, signature: Signature) -> Result { + fn set_signature(&self, signature: Signature) -> Result { let mut out = self.clone(); out.0.selection_proof = sig_to_eth2(&signature); Ok(out) } - fn message_root(&self) -> Result<[u8; 32], Self::Error> { + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { Ok(hash_root(&self.0.slot)) } } @@ -817,10 +804,7 @@ impl BeaconCommitteeSelection { } /// Creates a partial beacon committee selection wrapper. - pub fn new_partial( - selection: v1::BeaconCommitteeSelection, - share_idx: u64, - ) -> ParSignedData { + pub fn new_partial(selection: v1::BeaconCommitteeSelection, share_idx: u64) -> ParSignedData { ParSignedData::new(Self::new(selection), share_idx) } } @@ -834,19 +818,17 @@ pub struct SyncCommitteeSelection( ); impl SignedData for SyncCommitteeSelection { - type Error = SignedDataError; - - fn signature(&self) -> Result { + fn signature(&self) -> Result { Ok(sig_from_eth2(self.0.selection_proof)) } - fn set_signature(&self, signature: Signature) -> Result { + fn set_signature(&self, signature: Signature) -> Result { let mut out = self.clone(); out.0.selection_proof = sig_to_eth2(&signature); Ok(out) } - fn message_root(&self) -> Result<[u8; 32], Self::Error> { + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { let data = altair::SyncAggregatorSelectionData { slot: self.0.slot, subcommittee_index: self.0.subcommittee_index, @@ -863,10 +845,7 @@ impl SyncCommitteeSelection { } /// Creates a partial sync committee selection wrapper. - pub fn new_partial( - selection: v1::SyncCommitteeSelection, - share_idx: u64, - ) -> ParSignedData { + pub fn new_partial(selection: v1::SyncCommitteeSelection, share_idx: u64) -> ParSignedData { ParSignedData::new(Self::new(selection), share_idx) } } @@ -880,19 +859,17 @@ pub struct SignedAggregateAndProof( ); impl SignedData for SignedAggregateAndProof { - type Error = SignedDataError; - - fn signature(&self) -> Result { + fn signature(&self) -> Result { Ok(sig_from_eth2(self.0.signature)) } - fn set_signature(&self, signature: Signature) -> Result { + fn set_signature(&self, signature: Signature) -> Result { let mut out = self.clone(); out.0.signature = sig_to_eth2(&signature); Ok(out) } - fn message_root(&self) -> Result<[u8; 32], Self::Error> { + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { Ok(hash_root(&self.0.message)) } } @@ -904,10 +881,7 @@ impl SignedAggregateAndProof { } /// Creates a partial signed aggregate-and-proof wrapper. - pub fn new_partial( - data: phase0::SignedAggregateAndProof, - share_idx: u64, - ) -> ParSignedData { + pub fn new_partial(data: phase0::SignedAggregateAndProof, share_idx: u64) -> ParSignedData { ParSignedData::new(Self::new(data), share_idx) } } @@ -947,15 +921,13 @@ impl VersionedSignedAggregateAndProof { pub fn new_partial( data: versioned::VersionedSignedAggregateAndProof, share_idx: u64, - ) -> ParSignedData { + ) -> ParSignedData { ParSignedData::new(Self::new(data), share_idx) } } impl SignedData for VersionedSignedAggregateAndProof { - type Error = SignedDataError; - - fn signature(&self) -> Result { + fn signature(&self) -> Result { let version = self.0.version; if version == versioned::DataVersion::Unknown { return Err(SignedDataError::UnknownVersion); @@ -964,7 +936,7 @@ impl SignedData for VersionedSignedAggregateAndProof { Ok(sig_from_eth2(self.0.aggregate_and_proof.signature())) } - fn set_signature(&self, signature: Signature) -> Result { + fn set_signature(&self, signature: Signature) -> Result { let mut out = self.clone(); let version = out.0.version; if version == versioned::DataVersion::Unknown { @@ -977,7 +949,7 @@ impl SignedData for VersionedSignedAggregateAndProof { Ok(out) } - fn message_root(&self) -> Result<[u8; 32], Self::Error> { + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { let version = self.0.version; if version == versioned::DataVersion::Unknown { return Err(SignedDataError::UnknownVersion); @@ -1068,19 +1040,17 @@ pub struct SignedSyncMessage( ); impl SignedData for SignedSyncMessage { - type Error = SignedDataError; - - fn signature(&self) -> Result { + fn signature(&self) -> Result { Ok(sig_from_eth2(self.0.signature)) } - fn set_signature(&self, signature: Signature) -> Result { + fn set_signature(&self, signature: Signature) -> Result { let mut out = self.clone(); out.0.signature = sig_to_eth2(&signature); Ok(out) } - fn message_root(&self) -> Result<[u8; 32], Self::Error> { + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { Ok(self.0.beacon_block_root) } } @@ -1092,7 +1062,7 @@ impl SignedSyncMessage { } /// Creates a partial signed sync committee message wrapper. - pub fn new_partial(data: altair::SyncCommitteeMessage, share_idx: u64) -> ParSignedData { + pub fn new_partial(data: altair::SyncCommitteeMessage, share_idx: u64) -> ParSignedData { ParSignedData::new(Self::new(data), share_idx) } } @@ -1106,19 +1076,17 @@ pub struct SyncContributionAndProof( ); impl SignedData for SyncContributionAndProof { - type Error = SignedDataError; - - fn signature(&self) -> Result { + fn signature(&self) -> Result { Ok(sig_from_eth2(self.0.selection_proof)) } - fn set_signature(&self, signature: Signature) -> Result { + fn set_signature(&self, signature: Signature) -> Result { let mut out = self.clone(); out.0.selection_proof = sig_to_eth2(&signature); Ok(out) } - fn message_root(&self) -> Result<[u8; 32], Self::Error> { + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { let data = altair::SyncAggregatorSelectionData { slot: self.0.contribution.slot, subcommittee_index: self.0.contribution.subcommittee_index, @@ -1135,7 +1103,7 @@ impl SyncContributionAndProof { } /// Creates a partial sync contribution-and-proof wrapper. - pub fn new_partial(proof: altair::ContributionAndProof, share_idx: u64) -> ParSignedData { + pub fn new_partial(proof: altair::ContributionAndProof, share_idx: u64) -> ParSignedData { ParSignedData::new(Self::new(proof), share_idx) } } @@ -1149,19 +1117,17 @@ pub struct SignedSyncContributionAndProof( ); impl SignedData for SignedSyncContributionAndProof { - type Error = SignedDataError; - - fn signature(&self) -> Result { + fn signature(&self) -> Result { Ok(sig_from_eth2(self.0.signature)) } - fn set_signature(&self, signature: Signature) -> Result { + fn set_signature(&self, signature: Signature) -> Result { let mut out = self.clone(); out.0.signature = sig_to_eth2(&signature); Ok(out) } - fn message_root(&self) -> Result<[u8; 32], Self::Error> { + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { Ok(hash_root(&self.0.message)) } } @@ -1173,10 +1139,7 @@ impl SignedSyncContributionAndProof { } /// Creates a partial signed sync contribution-and-proof wrapper. - pub fn new_partial( - proof: altair::SignedContributionAndProof, - share_idx: u64, - ) -> ParSignedData { + pub fn new_partial(proof: altair::SignedContributionAndProof, share_idx: u64) -> ParSignedData { ParSignedData::new(Self::new(proof), share_idx) } } @@ -2052,7 +2015,7 @@ mod tests { fn assert_set_signature(data: T) where - T: SignedData + std::fmt::Debug + PartialEq, + T: SignedData + std::fmt::Debug + PartialEq, { let clone = data.set_signature(sample_signature(0xAB)).unwrap(); let clone_sig = clone.signature().unwrap(); diff --git a/crates/core/src/testutils.rs b/crates/core/src/testutils.rs new file mode 100644 index 0000000..ae6b4f4 --- /dev/null +++ b/crates/core/src/testutils.rs @@ -0,0 +1,143 @@ +//! Test utilities for the Charon core. + +use rand::{Rng, SeedableRng}; + +use crate::types::PubKey; + +/// The size of a BLS public key in bytes. +const PK_LEN: usize = 48; + +/// Creates a new seeded random number generator. +/// +/// Returns a new random number generator seeded with a random value. +/// This matches the Go implementation: +/// `rand.New(rand.NewSource(rand.Int63()))`. +pub fn new_seed_rand() -> impl Rng { + let seed = rand::random::(); + rand::rngs::StdRng::seed_from_u64(seed) +} + +/// Returns a random core workflow pubkey. +/// +/// This is a convenience wrapper around `random_core_pub_key_seed` that creates +/// a new random seed for each call. +pub fn random_core_pub_key() -> PubKey { + random_core_pub_key_seed(new_seed_rand()) +} + +/// Returns a random core workflow pubkey using a provided random source. +/// +/// # Arguments +/// +/// * `rng` - A random number generator to use for generating the pubkey. +/// +/// # Panics +/// +/// Panics if the generated bytes cannot be converted to a valid PubKey. +/// This should never happen in practice as we generate exactly 48 bytes. +pub fn random_core_pub_key_seed(mut rng: R) -> PubKey { + let pubkey = deterministic_pub_key_seed(&mut rng); + PubKey::try_from(&pubkey[..]).expect("valid pubkey length") +} + +/// Generates a deterministic pubkey from a seeded RNG. +/// +/// This function creates a new RNG seeded from the input RNG, then fills +/// a 48-byte array with random data. This matches the Go implementation: +/// +/// ```go +/// random := rand.New(rand.NewSource(r.Int63())) +/// var key tbls.PublicKey +/// _, err := random.Read(key[:]) +/// ``` +/// +/// # Arguments +/// +/// * `rng` - A mutable reference to a random number generator. +/// +/// # Returns +/// +/// A 48-byte array containing random data suitable for use as a public key. +fn deterministic_pub_key_seed(rng: &mut R) -> [u8; PK_LEN] { + // Create a new RNG seeded from the input RNG (matching Go's + // rand.New(rand.NewSource(r.Int63()))) + let seed: u64 = rng.r#gen(); + let mut seeded_rng = rand::rngs::StdRng::seed_from_u64(seed); + + let mut key = [0u8; PK_LEN]; + // Fill the key with random bytes + for byte in &mut key { + *byte = seeded_rng.r#gen(); + } + + key +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_seed_rand_produces_different_values() { + let mut rng1 = new_seed_rand(); + let mut rng2 = new_seed_rand(); + + let val1: u64 = rng1.r#gen(); + let val2: u64 = rng2.r#gen(); + + // These should be different with very high probability + assert_ne!(val1, val2); + } + + #[test] + fn test_random_core_pub_key_generates_valid_keys() { + let pk1 = random_core_pub_key(); + let pk2 = random_core_pub_key(); + + // Keys should be different + assert_ne!(pk1, pk2); + + // Keys should have the correct length when serialized + assert_eq!(pk1.to_string().len(), 98); // 0x + 96 hex chars + assert_eq!(pk2.to_string().len(), 98); + } + + #[test] + fn test_random_core_pub_key_seed_is_deterministic() { + let seed = 12345u64; + let mut rng1 = rand::rngs::StdRng::seed_from_u64(seed); + let mut rng2 = rand::rngs::StdRng::seed_from_u64(seed); + + let pk1 = random_core_pub_key_seed(&mut rng1); + let pk2 = random_core_pub_key_seed(&mut rng2); + + // Same seed should produce same key + assert_eq!(pk1, pk2); + } + + #[test] + fn test_deterministic_pub_key_seed() { + let seed = 42u64; + let mut rng = rand::rngs::StdRng::seed_from_u64(seed); + + let key = deterministic_pub_key_seed(&mut rng); + + // Check that we got 48 bytes + assert_eq!(key.len(), PK_LEN); + + // Check that the key is not all zeros (very unlikely with a proper RNG) + assert!(key.iter().any(|&b| b != 0)); + } + + #[test] + fn test_random_core_pub_key_seed_different_rngs() { + let mut rng1 = rand::rngs::StdRng::seed_from_u64(1); + let mut rng2 = rand::rngs::StdRng::seed_from_u64(2); + + let pk1 = random_core_pub_key_seed(&mut rng1); + let pk2 = random_core_pub_key_seed(&mut rng2); + + // Different seeds should produce different keys + assert_ne!(pk1, pk2); + } +} diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 2d0f3b3..78e2bc6 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -3,9 +3,13 @@ use std::{collections::HashMap, fmt::Display, iter}; use chrono::{DateTime, Duration, Utc}; +use dyn_clone::DynClone; +use dyn_eq::DynEq; use serde::{Deserialize, Serialize}; use std::fmt::Debug as StdDebug; +use crate::signeddata::SignedDataError; + /// The type of duty. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -448,42 +452,64 @@ impl AsRef<[u8; SIG_LEN]> for Signature { } /// Signed data type -pub trait SignedData: Clone + Serialize + StdDebug { - /// The error type - type Error: std::error::Error; - +pub trait SignedData: DynClone + DynEq + StdDebug + Send + Sync { /// signature returns the signed duty data's signature. - fn signature(&self) -> Result; + fn signature(&self) -> Result; /// Returns a copy of signed duty data with the signature replaced. - fn set_signature(&self, signature: Signature) -> Result + fn set_signature(&self, signature: Signature) -> Result where Self: Sized; /// message_root returns the message root for the unsigned data. - fn message_root(&self) -> Result<[u8; 32], Self::Error>; + fn message_root(&self) -> Result<[u8; 32], SignedDataError>; } +dyn_eq::eq_trait_object!(SignedData); +dyn_clone::clone_trait_object!(SignedData); + // todo: add Eth2SignedData type // https://github.com/ObolNetwork/charon/blob/b3008103c5429b031b63518195f4c49db4e9a68d/core/types.go#L396 /// ParSignedData is a partially signed duty data only signed by a single /// threshold BLS share. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ParSignedData { +#[derive(Debug)] +pub struct ParSignedData { /// Partially signed duty data. - pub signed_data: T, + pub signed_data: Box, /// Threshold BLS share index. pub share_idx: u64, } -impl ParSignedData -where - T: SignedData, -{ +impl Clone for ParSignedData { + fn clone(&self) -> Self { + Self { + signed_data: self.signed_data.clone(), + share_idx: self.share_idx, + } + } +} + +impl PartialEq for ParSignedData { + fn eq(&self, other: &Self) -> bool { + self.share_idx == other.share_idx && self.signed_data == other.signed_data + } +} + +impl Eq for ParSignedData {} + +impl ParSignedData { /// Create a new partially signed data. - pub fn new(partially_signed_data: T, share_idx: u64) -> Self { + pub fn new(partially_signed_data: T, share_idx: u64) -> Self { + Self { + signed_data: Box::new(partially_signed_data), + share_idx, + } + } + + /// Create a new partially signed data from a boxed signed data. + pub fn new_boxed(partially_signed_data: Box, share_idx: u64) -> Self { Self { signed_data: partially_signed_data, share_idx, @@ -493,49 +519,37 @@ where /// ParSignedDataSet is a set of partially signed duty data only signed by a /// single threshold BLS share. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ParSignedDataSet(HashMap>); +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ParSignedDataSet(HashMap); -impl Default for ParSignedDataSet -where - T: SignedData, -{ - fn default() -> Self { - Self(HashMap::default()) - } -} - -impl ParSignedDataSet -where - T: SignedData, -{ +impl ParSignedDataSet { /// Create a new partially signed data set. pub fn new() -> Self { Self::default() } /// Get a partially signed data by public key. - pub fn get(&self, pub_key: &PubKey) -> Option<&ParSignedData> { + pub fn get(&self, pub_key: &PubKey) -> Option<&ParSignedData> { self.inner().get(pub_key) } /// Insert a partially signed data. - pub fn insert(&mut self, pub_key: PubKey, partially_signed_data: ParSignedData) { + pub fn insert(&mut self, pub_key: PubKey, partially_signed_data: ParSignedData) { self.inner_mut().insert(pub_key, partially_signed_data); } /// Remove a partially signed data by public key. - pub fn remove(&mut self, pub_key: &PubKey) -> Option> { + pub fn remove(&mut self, pub_key: &PubKey) -> Option { self.inner_mut().remove(pub_key) } /// Inner partially signed data set. - pub fn inner(&self) -> &HashMap> { + pub fn inner(&self) -> &HashMap { &self.0 } /// Inner partially signed data set. - pub fn inner_mut(&mut self) -> &mut HashMap> { + pub fn inner_mut(&mut self) -> &mut HashMap { &mut self.0 } } @@ -856,17 +870,15 @@ mod tests { struct MockSignedData; impl SignedData for MockSignedData { - type Error = std::io::Error; - - fn signature(&self) -> Result { + fn signature(&self) -> Result { Ok(Signature::new([42u8; SIG_LEN])) } - fn set_signature(&self, _signature: Signature) -> Result { + fn set_signature(&self, _signature: Signature) -> Result { Ok(self.clone()) } - fn message_root(&self) -> Result<[u8; 32], std::io::Error> { + fn message_root(&self) -> Result<[u8; 32], SignedDataError> { Ok([42u8; 32]) } } @@ -874,13 +886,15 @@ mod tests { #[test] fn test_partially_signed_data_set() { let mut partially_signed_data_set = ParSignedDataSet::new(); - partially_signed_data_set.insert( - PubKey::new([42u8; PK_LEN]), - ParSignedData::new(MockSignedData, 0), - ); + let par_signed = ParSignedData::new(MockSignedData, 0); + partially_signed_data_set.insert(PubKey::new([42u8; PK_LEN]), par_signed.clone()); + let retrieved = partially_signed_data_set.get(&PubKey::new([42u8; PK_LEN])); + assert!(retrieved.is_some()); + let retrieved = retrieved.unwrap(); + assert_eq!(retrieved.share_idx, 0); assert_eq!( - partially_signed_data_set.get(&PubKey::new([42u8; PK_LEN])), - Some(&ParSignedData::new(MockSignedData, 0)) + retrieved.signed_data.signature().unwrap(), + Signature::new([42u8; SIG_LEN]) ); } diff --git a/crates/testutil/Cargo.toml b/crates/testutil/Cargo.toml index 0a973d1..720bcc8 100644 --- a/crates/testutil/Cargo.toml +++ b/crates/testutil/Cargo.toml @@ -9,6 +9,7 @@ publish.workspace = true [dependencies] k256.workspace = true pluto-crypto.workspace = true +pluto-eth2api.workspace = true rand.workspace = true rand_core.workspace = true thiserror.workspace = true diff --git a/crates/testutil/src/lib.rs b/crates/testutil/src/lib.rs index abc00e7..686c8c7 100644 --- a/crates/testutil/src/lib.rs +++ b/crates/testutil/src/lib.rs @@ -6,3 +6,8 @@ /// Random utilities. pub mod random; + +pub use random::{ + random_deneb_versioned_attestation, random_eth2_signature, random_eth2_signature_bytes, + random_root, random_root_bytes, random_slot, random_v_idx, +}; diff --git a/crates/testutil/src/random.rs b/crates/testutil/src/random.rs index 65be74c..8e4a8ee 100644 --- a/crates/testutil/src/random.rs +++ b/crates/testutil/src/random.rs @@ -7,6 +7,14 @@ use k256::{ elliptic_curve::rand_core::{CryptoRng, Error, RngCore}, }; use pluto_crypto::{blst_impl::BlstImpl, tbls::Tbls, types::PrivateKey}; +use pluto_eth2api::{ + spec::phase0, + types::{ + AltairBeaconStateCurrentJustifiedCheckpoint, Data, + GetBlockAttestationsV2ResponseResponseDataArray2, + }, + versioned::{self, AttestationPayload}, +}; use rand::{Rng, SeedableRng, rngs::StdRng}; /// A deterministic RNG that always returns the same byte value. @@ -67,6 +75,26 @@ pub fn generate_test_bls_key(seed: u64) -> PrivateKey { .expect("deterministic key generation should not fail") } +/// Generates a random BLS signature as a hex string for testing. +/// +/// Returns a 96-byte (192 hex characters) BLS signature encoded as a hex string +/// with "0x" prefix. +pub fn random_eth2_signature() -> String { + let mut bytes = [0u8; 96]; + let mut rng = rand::thread_rng(); + for byte in &mut bytes { + *byte = rng.r#gen(); + } + format!("0x{}", hex::encode(bytes)) +} + +/// Generates a random Ethereum consensus signature for testing. +pub fn random_eth2_signature_bytes() -> phase0::BLSSignature { + let mut signature = [0u8; 96]; + rand::thread_rng().fill(&mut signature[..]); + signature +} + /// Generate random Ethereum address for testing. pub fn random_eth_address(rand: &mut impl Rng) -> [u8; 20] { let mut bytes = [0u8; 20]; @@ -74,6 +102,134 @@ pub fn random_eth_address(rand: &mut impl Rng) -> [u8; 20] { bytes } +/// Generates a random 32-byte root as a hex string for testing. +/// +/// Returns a 32-byte (64 hex characters) root encoded as a hex string with "0x" +/// prefix. +pub fn random_root() -> String { + let mut bytes = [0u8; 32]; + let mut rng = rand::thread_rng(); + for byte in &mut bytes { + *byte = rng.r#gen(); + } + format!("0x{}", hex::encode(bytes)) +} + +/// Generates a random Ethereum consensus root for testing. +pub fn random_root_bytes() -> phase0::Root { + let mut root = [0u8; 32]; + rand::thread_rng().fill(&mut root); + root +} + +/// Generates a random slot for testing. +pub fn random_slot() -> phase0::Slot { + rand::thread_rng().r#gen() +} + +/// Generates a random validator index for testing. +pub fn random_v_idx() -> phase0::ValidatorIndex { + rand::thread_rng().r#gen() +} + +/// Generates a random bitlist as a hex string for testing. +/// +/// # Arguments +/// +/// * `length` - The number of bits to set in the bitlist +/// +/// Returns a hex-encoded bitlist string with "0x" prefix. +pub fn random_bit_list(length: usize) -> String { + // Create a byte array large enough to hold the bits + // For simplicity, use 32 bytes (256 bits) + let mut bytes = [0u8; 32]; + let mut rng = rand::thread_rng(); + + // Set 'length' random bits + for _ in 0..length { + let bit_idx = rng.r#gen::() % 256; + let byte_idx = bit_idx / 8; + let bit_offset = bit_idx % 8; + bytes[byte_idx] |= 1 << bit_offset; + } + + format!("0x{}", hex::encode(bytes)) +} + +/// Generates a random checkpoint for testing. +fn random_checkpoint() -> AltairBeaconStateCurrentJustifiedCheckpoint { + let mut rng = rand::thread_rng(); + AltairBeaconStateCurrentJustifiedCheckpoint { + epoch: rng.r#gen::().to_string(), + root: random_root(), + } +} + +/// Generates random attestation data for Phase 0. +fn random_attestation_data_phase0() -> Data { + let mut rng = rand::thread_rng(); + Data { + slot: rng.r#gen::().to_string(), + index: rng.r#gen::().to_string(), + beacon_block_root: random_root(), + source: random_checkpoint(), + target: random_checkpoint(), + } +} + +/// Generates a random Phase 0 attestation. +/// +/// Returns an attestation with random aggregation bits, attestation data, and +/// signature. +pub fn random_phase0_attestation() -> GetBlockAttestationsV2ResponseResponseDataArray2 { + GetBlockAttestationsV2ResponseResponseDataArray2 { + aggregation_bits: random_bit_list(1), + data: random_attestation_data_phase0(), + signature: random_eth2_signature(), + } +} + +/// Generates a random Deneb versioned attestation. +/// +/// Returns a versioned attestation containing a Phase 0 attestation with the +/// Deneb version tag. This matches the Go implementation: +/// +/// ```go +/// func RandomDenebVersionedAttestation() *eth2spec.VersionedAttestation { +/// return ð2spec.VersionedAttestation{ +/// Version: eth2spec.DataVersionDeneb, +/// Deneb: RandomPhase0Attestation(), +/// } +/// } +/// ``` +pub fn random_deneb_versioned_attestation() -> versioned::VersionedAttestation { + let mut rng = rand::thread_rng(); + + let attestation = phase0::Attestation { + aggregation_bits: phase0::BitList::default(), + data: phase0::AttestationData { + slot: rng.r#gen(), + index: rng.r#gen(), + beacon_block_root: random_root_bytes(), + source: phase0::Checkpoint { + epoch: rng.r#gen(), + root: random_root_bytes(), + }, + target: phase0::Checkpoint { + epoch: rng.r#gen(), + root: random_root_bytes(), + }, + }, + signature: random_eth2_signature_bytes(), + }; + + versioned::VersionedAttestation { + version: versioned::DataVersion::Deneb, + validator_index: Some(rng.r#gen()), + attestation: Some(AttestationPayload::Deneb(attestation)), + } +} + #[cfg(test)] mod tests { use super::*; @@ -150,4 +306,92 @@ mod tests { "Different seeds should produce different BLS keys" ); } + + #[test] + fn test_random_eth2_signature() { + let sig1 = random_eth2_signature(); + let sig2 = random_eth2_signature(); + + // Check format + assert!(sig1.starts_with("0x")); + // 96 bytes = 192 hex chars + "0x" prefix = 194 total + assert_eq!(sig1.len(), 194); + + // Different calls should produce different signatures + assert_ne!(sig1, sig2); + } + + #[test] + fn test_random_root() { + let root1 = random_root(); + let root2 = random_root(); + + // Check format + assert!(root1.starts_with("0x")); + // 32 bytes = 64 hex chars + "0x" prefix = 66 total + assert_eq!(root1.len(), 66); + + // Different calls should produce different roots + assert_ne!(root1, root2); + } + + #[test] + fn test_random_bit_list() { + let bitlist = random_bit_list(5); + + // Check format + assert!(bitlist.starts_with("0x")); + // 32 bytes = 64 hex chars + "0x" prefix = 66 total + assert_eq!(bitlist.len(), 66); + } + + #[test] + fn test_random_phase0_attestation() { + let att = random_phase0_attestation(); + + // Check that all fields are populated + assert!(att.aggregation_bits.starts_with("0x")); + assert!(att.signature.starts_with("0x")); + assert!(att.data.beacon_block_root.starts_with("0x")); + assert!(!att.data.slot.is_empty()); + assert!(!att.data.index.is_empty()); + } + + #[test] + fn test_random_deneb_versioned_attestation() { + let versioned_att = random_deneb_versioned_attestation(); + + // Check version is Deneb + assert!(matches!( + versioned_att.version, + versioned::DataVersion::Deneb + )); + + // Check that data is populated + match versioned_att.attestation { + Some(AttestationPayload::Deneb(att)) => { + assert_eq!(att.signature.len(), 96); + } + _ => panic!("Expected Deneb attestation"), + } + } + + #[test] + fn test_random_deneb_versioned_attestation_different() { + let att1 = random_deneb_versioned_attestation(); + let att2 = random_deneb_versioned_attestation(); + + // Different calls should produce different attestations + // Check signatures are different + let sig1 = match &att1.attestation { + Some(AttestationPayload::Deneb(a)) => &a.signature, + _ => panic!("Expected Deneb attestation"), + }; + let sig2 = match &att2.attestation { + Some(AttestationPayload::Deneb(a)) => &a.signature, + _ => panic!("Expected Deneb attestation"), + }; + + assert_ne!(sig1, sig2); + } }