diff --git a/Cargo.lock b/Cargo.lock index 39d857449..0d3844991 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -806,7 +806,6 @@ dependencies = [ "bls", "builder_types", "database", - "dissemination_store", "eth2", "ethereum_ssz", "fork", @@ -830,6 +829,7 @@ dependencies = [ "tokio", "tracing", "tree_hash", + "tree_hash_derive", "types", "validator_metrics", "validator_services", @@ -1945,7 +1945,6 @@ dependencies = [ "clap", "cli", "database", - "dissemination_store", "duties_tracker", "eth", "eth2", @@ -2643,17 +2642,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "dissemination_store" -version = "0.1.0" -dependencies = [ - "bls", - "parking_lot", - "ssv_types", - "tokio", - "types", -] - [[package]] name = "docgen" version = "0.1.0" @@ -5268,7 +5256,6 @@ dependencies = [ "bls", "bls_lagrange", "database", - "dissemination_store", "duties_tracker", "eth2", "ethereum_ssz", diff --git a/Cargo.toml b/Cargo.toml index 1f5da2d55..18d5a0ced 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,6 @@ members = [ "anchor/client", "anchor/common/api_types", "anchor/common/bls_lagrange", - "anchor/common/dissemination_store", "anchor/common/fork", "anchor/common/global_config", "anchor/common/operator_key", @@ -49,7 +48,6 @@ bls_lagrange = { path = "anchor/common/bls_lagrange" } cli = { path = "anchor/cli" } client = { path = "anchor/client" } database = { path = "anchor/database" } -dissemination_store = { path = "anchor/common/dissemination_store" } docgen = { path = "anchor/docgen" } duties_tracker = { path = "anchor/duties_tracker" } eth = { path = "anchor/eth" } diff --git a/anchor/client/Cargo.toml b/anchor/client/Cargo.toml index d95d6e6a8..ca6433985 100644 --- a/anchor/client/Cargo.toml +++ b/anchor/client/Cargo.toml @@ -17,7 +17,6 @@ builder_types = { workspace = true } clap = { workspace = true } cli = { workspace = true } database = { workspace = true } -dissemination_store = { workspace = true } duties_tracker = { workspace = true } eth = { workspace = true } eth2 = { workspace = true } diff --git a/anchor/client/src/lib.rs b/anchor/client/src/lib.rs index 70c20c740..5fc2e3177 100644 --- a/anchor/client/src/lib.rs +++ b/anchor/client/src/lib.rs @@ -630,15 +630,10 @@ impl Client { let (outcome_tx, outcome_rx) = mpsc::channel::(9000); - // Shared between the message receiver (writer) and the validator store's envelope duty - // runner (reader); SIP-94 §6. - let dissemination_store = Arc::new(dissemination_store::DisseminationStore::new()); - let message_receiver = NetworkMessageReceiver::::new( processor_senders.clone(), qbft_manager.clone(), signature_collector.clone(), - dissemination_store.clone(), database.watch(), is_synced.clone(), outcome_tx, @@ -672,7 +667,6 @@ impl Client { let validator_store = AnchorValidatorStore::<_, E, _>::new( database.clone(), Box::new(signature_collector), - dissemination_store, qbft_manager, slashing_protection, config.disable_slashing_protection, @@ -779,6 +773,7 @@ impl Client { // the cache's only `prune()` caller. let request_auth_cache = RequestAuthCache::default(); let mut block_service_builder = BlockServiceBuilder::new() + .stateless_block_production(true) .slot_clock(slot_clock.clone()) .validator_store(validator_store.clone()) .beacon_nodes(beacon_nodes.clone()) diff --git a/anchor/common/dissemination_store/Cargo.toml b/anchor/common/dissemination_store/Cargo.toml deleted file mode 100644 index 26d75863e..000000000 --- a/anchor/common/dissemination_store/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "dissemination_store" -version = "0.1.0" -edition = { workspace = true } -authors = ["Sigma Prime "] - -[dependencies] -bls = { workspace = true } -parking_lot = { workspace = true } -ssv_types = { workspace = true } -tokio = { workspace = true } -types = { workspace = true } diff --git a/anchor/common/dissemination_store/src/lib.rs b/anchor/common/dissemination_store/src/lib.rs deleted file mode 100644 index 3b328f615..000000000 --- a/anchor/common/dissemination_store/src/lib.rs +++ /dev/null @@ -1,253 +0,0 @@ -//! Handoff store for SIP-94 §6 envelope disseminations. -//! -//! The message receiver writes the first validator-accepted `EnvelopeDissemination` per -//! `(validator, slot)`; the envelope duty runner awaits it. Message validation's first-valid -//! rule delivers at most one dissemination per key for the process lifetime, so the store is -//! first-write-wins and never replaces an entry. - -use std::collections::HashMap; - -use bls::PublicKeyBytes; -use parking_lot::Mutex; -use ssv_types::dissemination::EnvelopeDissemination; -use tokio::{sync::oneshot, time::Instant}; -use types::Slot; - -/// Number of slots an entry stays readable, mirroring the decided-block context retention. -const MAX_DISSEMINATION_AGE_SLOTS: u64 = 4; - -#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] -struct Key { - validator: PublicKeyBytes, - slot: Slot, -} - -enum Entry { - /// The accepted dissemination for the key. - Ready(EnvelopeDissemination), - /// Runners awaiting the dissemination. Senders are pruned when closed, so timed-out or - /// cancelled waiters do not accumulate. - Waiting(Vec>), -} - -/// Shared store connecting the message receiver (writer) to the envelope duty runner (reader). -#[derive(Default)] -pub struct DisseminationStore { - inner: Mutex>, -} - -impl DisseminationStore { - pub fn new() -> Self { - Self::default() - } - - /// Records the accepted dissemination for `(validator, slot)` and wakes every waiter. - /// - /// First-write-wins: a `Ready` entry is never replaced. Entries older than - /// `MAX_DISSEMINATION_AGE_SLOTS` relative to the inserted slot are dropped on insert - /// (addition on the stored side, so an early slot cannot underflow). - pub fn insert(&self, validator: PublicKeyBytes, dissemination: EnvelopeDissemination) { - let slot = dissemination.slot; - let key = Key { validator, slot }; - let mut inner = self.inner.lock(); - Self::sweep(&mut inner, slot); - - match inner.entry(key) { - std::collections::hash_map::Entry::Vacant(entry) => { - entry.insert(Entry::Ready(dissemination)); - } - std::collections::hash_map::Entry::Occupied(mut entry) => match entry.get_mut() { - Entry::Ready(_) => {} - Entry::Waiting(waiters) => { - for waiter in waiters.drain(..) { - // A dropped receiver (timed-out waiter) is fine; the value stays Ready. - let _ = waiter.send(dissemination.clone()); - } - entry.insert(Entry::Ready(dissemination)); - } - }, - } - } - - /// Awaits the dissemination for `(validator, slot)` until `deadline`. - /// - /// Returns immediately when the entry is already `Ready`. A timed-out or cancelled wait - /// leaves a `Ready` value available for a later call. Waiter registrations sweep stale - /// keys and prune closed senders, so unanswered waits stay bounded even when no - /// dissemination ever arrives. - pub async fn wait( - &self, - validator: PublicKeyBytes, - slot: Slot, - deadline: Instant, - ) -> Option { - let receiver = { - let key = Key { validator, slot }; - let mut inner = self.inner.lock(); - Self::sweep(&mut inner, slot); - for entry in inner.values_mut() { - if let Entry::Waiting(waiters) = entry { - waiters.retain(|waiter| !waiter.is_closed()); - } - } - - match inner - .entry(key) - .or_insert_with(|| Entry::Waiting(Vec::new())) - { - Entry::Ready(dissemination) => return Some(dissemination.clone()), - Entry::Waiting(waiters) => { - let (sender, receiver) = oneshot::channel(); - waiters.push(sender); - receiver - } - } - }; - - tokio::time::timeout_at(deadline, receiver).await.ok()?.ok() - } - - /// Drops entries older than `MAX_DISSEMINATION_AGE_SLOTS` relative to `slot`. A call for - /// an old slot cannot evict a newer entry. - fn sweep(inner: &mut HashMap, slot: Slot) { - inner.retain(|stored_key, _| stored_key.slot + MAX_DISSEMINATION_AGE_SLOTS >= slot); - } -} - -#[cfg(test)] -mod tests { - use std::{sync::Arc, time::Duration}; - - use ssv_types::VariableList; - - use super::*; - - fn dissemination(slot: u64) -> EnvelopeDissemination { - EnvelopeDissemination { - slot: Slot::new(slot), - envelope: VariableList::new(vec![0xAA; 8]).unwrap(), - } - } - - fn pubkey(byte: u8) -> PublicKeyBytes { - PublicKeyBytes::deserialize(&[byte; 48]).expect("48 bytes is a valid pubkey length") - } - - fn soon() -> Instant { - Instant::now() + Duration::from_millis(200) - } - - #[tokio::test] - async fn ready_before_wait_returns_immediately() { - let store = DisseminationStore::new(); - store.insert(pubkey(1), dissemination(5)); - - let got = store.wait(pubkey(1), Slot::new(5), soon()).await; - assert_eq!(got, Some(dissemination(5))); - } - - #[tokio::test] - async fn wait_before_ready_wakes_all_same_key_waiters() { - let store = Arc::new(DisseminationStore::new()); - let deadline = Instant::now() + Duration::from_secs(5); - let w1 = tokio::spawn({ - let store = store.clone(); - async move { store.wait(pubkey(1), Slot::new(5), deadline).await } - }); - let w2 = tokio::spawn({ - let store = store.clone(); - async move { store.wait(pubkey(1), Slot::new(5), deadline).await } - }); - // Let both waiters register before the insert. - tokio::time::sleep(Duration::from_millis(50)).await; - - store.insert(pubkey(1), dissemination(5)); - - assert_eq!(w1.await.unwrap(), Some(dissemination(5))); - assert_eq!(w2.await.unwrap(), Some(dissemination(5))); - } - - #[tokio::test] - async fn timed_out_wait_can_retry_against_ready() { - let store = DisseminationStore::new(); - - let got = store.wait(pubkey(1), Slot::new(5), soon()).await; - assert_eq!(got, None, "no insert: the wait must time out"); - - store.insert(pubkey(1), dissemination(5)); - let got = store.wait(pubkey(1), Slot::new(5), soon()).await; - assert_eq!( - got, - Some(dissemination(5)), - "the value must stay available after an earlier timed-out wait" - ); - } - - #[tokio::test] - async fn first_write_wins() { - let store = DisseminationStore::new(); - let first = dissemination(5); - let mut second = dissemination(5); - second.envelope = VariableList::new(vec![0xBB; 8]).unwrap(); - - store.insert(pubkey(1), first.clone()); - store.insert(pubkey(1), second); - - let got = store.wait(pubkey(1), Slot::new(5), soon()).await; - assert_eq!(got, Some(first), "a Ready entry must never be replaced"); - } - - #[tokio::test] - async fn keys_are_isolated() { - let store = DisseminationStore::new(); - store.insert(pubkey(1), dissemination(5)); - - assert_eq!(store.wait(pubkey(2), Slot::new(5), soon()).await, None); - assert_eq!(store.wait(pubkey(1), Slot::new(6), soon()).await, None); - } - - #[tokio::test] - async fn insert_evicts_entries_past_the_age_window() { - let store = DisseminationStore::new(); - store.insert(pubkey(1), dissemination(5)); - store.insert( - pubkey(1), - dissemination(5 + MAX_DISSEMINATION_AGE_SLOTS + 1), - ); - - assert_eq!( - store.wait(pubkey(1), Slot::new(5), soon()).await, - None, - "an insert past the age window must evict the older entry" - ); - } - - #[tokio::test] - async fn unanswered_waits_stay_bounded() { - let store = DisseminationStore::new(); - // Many timed-out waits across distinct slots, no inserts at all. - for slot in 0..100u64 { - let _ = store.wait(pubkey(1), Slot::new(slot), Instant::now()).await; - } - - let inner = store.inner.lock(); - assert!( - inner.len() as u64 <= MAX_DISSEMINATION_AGE_SLOTS + 1, - "wait-created entries must be swept; got {} keys", - inner.len() - ); - let waiters: usize = inner - .values() - .map(|entry| match entry { - Entry::Ready(_) => 0, - Entry::Waiting(waiters) => waiters.len(), - }) - .sum(); - // The final wait's own sender has no later registration to prune it; everything - // older must be gone. - assert!( - waiters <= 1, - "closed senders must be pruned on later registrations; got {waiters}" - ); - } -} diff --git a/anchor/common/qbft/src/tests.rs b/anchor/common/qbft/src/tests.rs index f35fd546c..1a01ac31b 100644 --- a/anchor/common/qbft/src/tests.rs +++ b/anchor/common/qbft/src/tests.rs @@ -626,3 +626,121 @@ fn test_leader_waits_when_highest_prepared_data_missing() { // Test passes if we reach this point without panicking } + +/// A prepared Gloas value must retain its payload root when the next leader has another +/// local candidate. Exercise the real prepare, timeout, round-change and proposal paths. +#[test] +fn gloas_prepared_reproposal_preserves_the_entire_wrapper() { + use ssv_types::{ + ValidatorIndex, + consensus::{ + BEACON_ROLE_PROPOSER, DataVersion, GloasProposalData, ProposerConsensusData, + ValidatorDuty, + }, + }; + use types::{ + BeaconBlockGloas, ChainSpec, EmptyBlock, ForkName, MainnetEthSpec, Slot, + consts::gloas::BUILDER_INDEX_SELF_BUILD, + }; + + // Arrange: operator 1 proposes one payload root; the next leader has a different root. + let mut block = BeaconBlockGloas::::empty(&ChainSpec::mainnet()); + block + .body + .signed_execution_payload_bid + .message + .builder_index = BUILDER_INDEX_SELF_BUILD; + let value = |payload_root| ProposerConsensusData { + duty: ValidatorDuty { + r#type: BEACON_ROLE_PROPOSER, + pub_key: ssz::Decode::from_ssz_bytes(&[0u8; 48]).unwrap(), + slot: Slot::new(0), + validator_index: ValidatorIndex(0), + committee_index: 0, + committee_length: 0, + committees_at_slot: 0, + validator_committee_index: 0, + validator_sync_committee_indices: Default::default(), + }, + version: DataVersion::from(ForkName::Gloas), + data_ssz: VariableList::new( + GloasProposalData { + block: block.clone(), + payload_root, + } + .as_ssz_bytes(), + ) + .unwrap(), + }; + let prepared = value(Hash256::repeat_byte(1)); + let other = value(Hash256::repeat_byte(2)); + let expected_bytes = prepared.as_ssz_bytes(); + assert_ne!(expected_bytes, other.as_ssz_bytes()); + let queue = Rc::new(RefCell::new(VecDeque::new())); + let mut instances = Vec::new(); + for operator in 1..=4u64 { + let sender = Rc::clone(&queue); + let config = ConfigBuilder::::new( + operator.into(), + InstanceHeight::default(), + (1..=4).map(OperatorId::from).collect(), + ) + .build() + .unwrap(); + instances.push(Qbft::new( + config, + if operator == 2 { + other.clone() + } else { + prepared.clone() + }, + Box::new(NoDataValidation), + MessageId::from([0; 56]), + move |message| { + sender + .borrow_mut() + .push_back((OperatorId(operator), message)) + }, + )); + } + + // Act: deliver proposal and prepare messages, withholding commits until a round timeout. + loop { + let Some((sender, message)) = queue.borrow_mut().pop_front() else { + break; + }; + if message.qbft_message.qbft_message_type == QbftMessageType::Commit { + continue; + } + for instance in &mut instances { + instance + .receive(convert_unsigned_to_signed(message.clone(), sender)) + .unwrap(); + } + } + for instance in &mut instances { + assert_eq!(instance.last_prepared_value, Some(prepared.hash())); + assert!(instance.completed.is_none()); + instance.end_round(); + } + let mut reproposal_seen = false; + loop { + let Some((sender, message)) = queue.borrow_mut().pop_front() else { + break; + }; + if message.qbft_message.qbft_message_type == QbftMessageType::Proposal { + // Assert: the new leader copies every decided byte, including PayloadRoot. + assert_eq!(sender, OperatorId(2)); + assert_eq!(message.unsigned_message.full_data, expected_bytes); + assert_eq!(message.qbft_message.root, prepared.hash()); + reproposal_seen = true; + } + for instance in &mut instances { + instance + .receive(convert_unsigned_to_signed(message.clone(), sender)) + .unwrap(); + } + } + assert!(reproposal_seen, "round two must emit the prepared proposal"); + assert!(instances.iter().all(|instance| matches!(instance.completed, Some(Completed::Success(root)) if root == prepared.hash()))); +} diff --git a/anchor/common/ssv_types/src/consensus.rs b/anchor/common/ssv_types/src/consensus.rs index 78777d58c..7458145d5 100644 --- a/anchor/common/ssv_types/src/consensus.rs +++ b/anchor/common/ssv_types/src/consensus.rs @@ -25,9 +25,8 @@ use typenum::{ use types::{ AggregateAndProof, AggregateAndProofBase, AggregateAndProofElectra, AggregateAndProofGloas, Attestation, AttestationBase, AttestationData, AttestationElectra, AttestationGloas, - BeaconBlock, BlindedBeaconBlock, ChainSpec, Checkpoint, CommitteeIndex, Domain, EthSpec, - ExecutionPayloadEnvelope, ExecutionRequestsGloas, ForkName, Hash256, SignedRoot, Slot, - SyncCommitteeContribution, + BeaconBlock, BeaconBlockGloas, BlindedBeaconBlock, ChainSpec, Checkpoint, CommitteeIndex, + Domain, EthSpec, ForkName, Hash256, Slot, SyncCommitteeContribution, }; use crate::{CommitteeId, ValidatorIndex, message::*, partial_sig::PartialSignatureKind}; @@ -265,10 +264,14 @@ impl ProposerConsensusData { FullBlockContents::from_ssz_bytes_for_fork(&self.data_ssz, fork) } - /// Decode as a full beacon block shape. - pub fn decode_block(&self) -> Result, DecodeError> { - let fork = ForkName::from(self.version); - BeaconBlock::from_ssz_bytes_for_fork(&self.data_ssz, fork) + /// Decode the Gloas value, including the payload commitment agreed by QBFT. + pub fn decode_gloas_proposal(&self) -> Result, DecodeError> { + if ForkName::from(self.version) != ForkName::Gloas { + return Err(DecodeError::NoMatchingVariant); + } + let proposal = GloasProposalData::::from_ssz_bytes(&self.data_ssz)?; + proposal.validate_payload_root()?; + Ok(proposal) } } @@ -285,50 +288,28 @@ impl QbftData for ProposerConsensusData { } } -/// Blinded envelope disseminated and threshold-signed for the envelope duty (SIP-94 §6). -/// -/// Under ePBS (EIP-7732), the cluster signs this blinded form rather than the full -/// multi-MB `ExecutionPayloadEnvelope`. The builder operator substitutes the full `payload` field -/// with its tree-hash root, preserving SSZ merkleization parity: the blinded envelope's root -/// equals the full envelope's root, so a signature over the blinded signing root is valid for -/// the full envelope. -/// -/// EIP-7688 makes the full `ExecutionPayloadEnvelope` a progressive container, so this mirror -/// must merkleize progressively too or the parity above breaks (SIP-94 §6). -#[derive(Clone, Debug, PartialEq, Encode, Decode, TreeHash)] -#[tree_hash( - struct_behaviour = "progressive_container", - active_fields(1, 1, 1, 1, 1) -)] -pub struct BlindedExecutionPayloadEnvelope { - /// Tree-hash root of the full `payload` (`ExecutionPayloadGloas`). +/// Gloas proposer value. The payload itself never crosses the SSV network. +#[derive(Clone, Debug, PartialEq, Encode, Decode)] +pub struct GloasProposalData { + pub block: BeaconBlockGloas, pub payload_root: Hash256, - /// Execution requests (deposits, withdrawals, consolidations, plus Gloas-added - /// `builder_deposits` and `builder_exits`), copied verbatim from the full envelope. - pub execution_requests: ExecutionRequestsGloas, - /// Builder index: `u64::MAX` (self-build) or the builder's registry index for external - /// builds. Used to attribute the payload source. - pub builder_index: u64, - /// Root of the beacon block that this envelope is proposed within. Used to bind the - /// envelope to the proposing beacon block. - pub beacon_block_root: Hash256, - /// Root of the parent beacon block, used for fork-choice context. - pub parent_beacon_block_root: Hash256, } -impl SignedRoot for BlindedExecutionPayloadEnvelope {} - -impl BlindedExecutionPayloadEnvelope { - /// Build the blinded envelope from the full `ExecutionPayloadEnvelope`, substituting the - /// `payload` field with its tree-hash root. - pub fn from_full(full: &ExecutionPayloadEnvelope) -> Self { - Self { - payload_root: full.payload.tree_hash_root(), - execution_requests: full.execution_requests.clone(), - builder_index: full.builder_index, - beacon_block_root: full.beacon_block_root, - parent_beacon_block_root: full.parent_beacon_block_root, +impl GloasProposalData { + pub fn validate_payload_root(&self) -> Result<(), DecodeError> { + let self_build = self + .block + .body + .signed_execution_payload_bid + .message + .builder_index + == types::consts::gloas::BUILDER_INDEX_SELF_BUILD; + if self_build == self.payload_root.is_zero() { + return Err(DecodeError::BytesInvalid( + "payload root must be nonzero exactly for a self-build bid".into(), + )); } + Ok(()) } } @@ -458,16 +439,12 @@ impl ProposerConsensusDataValidator { }); } - // Decode the block header to ensure the value is decodable (even when slashing - // protection is disabled). Under Gloas (EIP-7732), DataSSZ is decoded directly as a plain - // BeaconBlock. This behaviour is not behaviourally load-bearing as the execution - // payload is decoupled from the block body. The outcome of `decode_blinded_block` - // and `decode_block` are identical for this variant. The Pre-Gloas branch - // preserves the existing try-blinded-then-full fallback. + // Gloas commits to both the block and payload root. Earlier forks retain their + // blinded-first decoding, including the full-contents fallback. let header = if fork >= ForkName::Gloas { value - .decode_block::() - .map(|block| block.block_header()) + .decode_gloas_proposal::() + .map(|proposal| BeaconBlock::Gloas(proposal.block).block_header()) .map_err(DataValidationError::DecodeError)? } else { value @@ -1618,8 +1595,8 @@ mod tests { use ssz::ProgressiveBitList; use ssz_types::{BitList, BitVector}; use types::{ - BeaconBlockDeneb, BeaconBlockGloas, Checkpoint, EmptyBlock, Epoch, ExecutionPayloadGloas, - MainnetEthSpec, SyncCommitteeContribution, test_utils::generate_deterministic_keypair, + BeaconBlockDeneb, BeaconBlockGloas, Checkpoint, EmptyBlock, Epoch, MainnetEthSpec, + SignedRoot, SyncCommitteeContribution, test_utils::generate_deterministic_keypair, }; use super::*; @@ -3343,17 +3320,24 @@ mod tests { /// SSZ bytes for a Gloas block variant. fn decode_block_round_trip() { let spec = ChainSpec::mainnet(); - let block = BeaconBlock::Gloas(BeaconBlockGloas::::empty(&spec)); + let block = BeaconBlockGloas::::empty(&spec); - let consensus_data = - proposer_consensus_data(Slot::new(0), ForkName::Gloas, block.as_ssz_bytes()); + let consensus_data = proposer_consensus_data( + Slot::new(0), + ForkName::Gloas, + GloasProposalData { + block: block.clone(), + payload_root: Hash256::ZERO, + } + .as_ssz_bytes(), + ); let decoded = consensus_data - .decode_block::() + .decode_gloas_proposal::() .expect("Gloas block should decode from DataSSZ"); assert_eq!( - decoded, block, + decoded.block, block, "decoded Gloas block should equal the original block" ); } @@ -3467,7 +3451,126 @@ mod tests { fn gloas_block_bytes(spec: &ChainSpec, slot: Slot) -> Vec { let mut block = BeaconBlockGloas::::empty(spec); block.slot = slot; - BeaconBlock::Gloas(block).as_ssz_bytes() + GloasProposalData { + block, + payload_root: Hash256::ZERO, + } + .as_ssz_bytes() + } + + /// Fixture and expected chain root copied from go-ssv at + /// 56916c7982d905cd74c04d973ca9b10e3b257eb0: + /// protocol/v2/types/gloas/testdata/devnet8_gloas_block_144352.ssz and + /// protocol/v2/types/gloas/beacon_block_golden_test.go. + #[test] + fn gloas_proposal_matches_go_devnet8_fixture_bytes_and_block_root() { + // Arrange: the fixture is a SignedBeaconBlock, whose message starts at offset 100. + let signed_bytes = include_bytes!("../testdata/devnet8_gloas_block_144352.ssz"); + assert_eq!(&signed_bytes[..4], &100u32.to_le_bytes()); + let block_bytes = &signed_bytes[100..]; + let block = BeaconBlockGloas::::from_ssz_bytes(block_bytes).unwrap(); + let mut root_bytes = [0u8; 32]; + root_bytes[0] = 1; + let payload_root = Hash256::from(root_bytes); + let expected_root = Hash256::from_slice( + &hex::decode("1f6c7bd0c7a6dc446057bacc566df52117dbfb3ee586148948271d6821cf22f1") + .unwrap(), + ); + + // Act: encode Anchor's actual wrapper with Go's fixed PayloadRoot fixture. + let bytes = GloasProposalData { + block: block.clone(), + payload_root, + } + .as_ssz_bytes(); + let mut expected_bytes = 36u32.to_le_bytes().to_vec(); + expected_bytes.extend_from_slice(payload_root.as_slice()); + expected_bytes.extend_from_slice(block_bytes); + + // Assert: SSZ bytes and the progressive block root agree with the other client. + assert_eq!(block.slot, Slot::new(144352)); + assert_eq!(BeaconBlock::Gloas(block).canonical_root(), expected_root); + assert_eq!(bytes, expected_bytes); + let decoded = GloasProposalData::::from_ssz_bytes(&bytes).unwrap(); + assert_eq!(decoded.payload_root, payload_root); + assert_eq!(decoded.block.as_ssz_bytes(), block_bytes); + } + + #[test] + fn gloas_proposal_ssz_layout_preserves_block_and_payload_root() { + // Arrange: the Go container has a variable block followed by a fixed 32-byte root. + let spec = gloas_scheduled_spec(); + let mut block = BeaconBlockGloas::::empty(&spec); + block.slot = gloas_era_slot(); + block + .body + .signed_execution_payload_bid + .message + .builder_index = types::consts::gloas::BUILDER_INDEX_SELF_BUILD; + let payload_root = Hash256::repeat_byte(0x42); + let block_bytes = block.as_ssz_bytes(); + let proposal = GloasProposalData { + block, + payload_root, + }; + + // Act: encode the real proposal type and decode it through the consensus helper. + let bytes = proposal.as_ssz_bytes(); + let consensus = proposer_consensus_data(gloas_era_slot(), ForkName::Gloas, bytes.clone()); + let decoded = consensus.decode_gloas_proposal::().unwrap(); + + // Assert: the independently assembled container matches the cross-client SSZ layout. + let mut expected = 36u32.to_le_bytes().to_vec(); + expected.extend_from_slice(payload_root.as_slice()); + expected.extend_from_slice(&block_bytes); + assert_eq!(bytes, expected); + assert_eq!(decoded.payload_root, payload_root); + assert_eq!(decoded.block.as_ssz_bytes(), block_bytes); + let reproposal = ProposerConsensusData::from_ssz_bytes(&consensus.as_ssz_bytes()).unwrap(); + assert_eq!(reproposal.data_ssz, consensus.data_ssz); + } + + #[test] + fn gloas_proposal_payload_root_presence_matches_builder_kind() { + for self_build in [false, true] { + for root_present in [false, true] { + // Arrange: exercise all four builder/root combinations. + let spec = gloas_scheduled_spec(); + let mut block = BeaconBlockGloas::::empty(&spec); + block.slot = gloas_era_slot(); + block + .body + .signed_execution_payload_bid + .message + .builder_index = if self_build { + types::consts::gloas::BUILDER_INDEX_SELF_BUILD + } else { + 42 + }; + let proposal = GloasProposalData { + block, + payload_root: if root_present { + Hash256::repeat_byte(1) + } else { + Hash256::ZERO + }, + }; + let consensus = proposer_consensus_data( + gloas_era_slot(), + ForkName::Gloas, + proposal.as_ssz_bytes(), + ); + let (_dir, validator) = test_block_proposal_validator(Arc::new(spec), true); + + // Act: both the decode path and QBFT proposal validator must enforce the rule. + let decoded = consensus.decode_gloas_proposal::(); + let validated = validator.do_validation(&consensus, &consensus); + + // Assert: self-build has a nonzero root; external builders have the zero root. + assert_eq!(decoded.is_ok(), self_build == root_present); + assert_eq!(validated.is_ok(), self_build == root_present); + } + } } /// SSZ bytes of an empty Deneb `FullBlockContents` whose block's internal slot equals `slot`. @@ -3679,83 +3782,6 @@ mod tests { assert_block_slot_mismatch(result, slot, slot + 1); } - // ═══════════════════════════════════════════════════════════════════════════════ - // BlindedExecutionPayloadEnvelope tests (ePBS envelope root parity) - // ═══════════════════════════════════════════════════════════════════════════════ - - /// Builds a small full `ExecutionPayloadEnvelope` with the given `builder_index` and - /// `beacon_block_root`. Payload and requests stay at their (small) defaults so the blinded - /// form is cheap to encode. - fn envelope_test_full_envelope( - builder_index: u64, - beacon_block_root: Hash256, - ) -> ExecutionPayloadEnvelope { - ExecutionPayloadEnvelope { - payload: ExecutionPayloadGloas::::default(), - execution_requests: ExecutionRequestsGloas::::default(), - builder_index, - beacon_block_root, - parent_beacon_block_root: Hash256::from_low_u64_be(0x2222), - } - } - - /// Build a full envelope, blind it, and assert: - /// 1. `blinded.tree_hash_root() == full.tree_hash_root()`. - /// 2. `payload_root` really is the payload's hash. - /// 3. The blinded form survives SSZ encode/decode. - #[test] - fn blinded_execution_payload_envelope_root_parity() { - // Construct a full ExecutionPayloadEnvelope with test data. - let full_envelope = envelope_test_full_envelope(42, Hash256::from_low_u64_be(0x1111)); - - // Create blinded envelope from full. - let blinded = BlindedExecutionPayloadEnvelope::from_full(&full_envelope); - - // This equality is load-bearing for the envelope signing duty. - assert_eq!( - blinded.tree_hash_root(), - full_envelope.tree_hash_root(), - "BlindedExecutionPayloadEnvelope root must equal full ExecutionPayloadEnvelope root" - ); - - assert_eq!( - blinded.payload_root, - full_envelope.payload.tree_hash_root(), - "payload_root must equal the full payload's tree-hash root" - ); - - // Fixed expected root (SIP-94 §6): equivalence must be pinned against a constant, - // not only same-implementation parity, so a silent merkleization change (e.g. a - // tree_hash dependency bump altering progressive-container hashing) fails loudly. - // Cross-checked against the consensus-specs pyspec (remerkleable) at pin a5a1bc630, - // which merkleizes the same fixture (ExecutionPayloadEnvelope as a - // ProgressiveContainer with active_fields [1, 1, 1, 1, 1]) to this exact root, so - // the vector is a second-implementation check, not same-implementation parity. - // go-ssv parity remains to be added when its implementation exists. - let fixed_expected_root = Hash256::from_slice( - &hex::decode("9af9a50572381e869605147c3d6220c969d6f12087d94393ec0440660f752c5e") - .expect("fixture root hex must decode"), - ); - assert_eq!( - blinded.tree_hash_root(), - fixed_expected_root, - "the blinded envelope fixture root must match the pinned vector" - ); - - let encoded = blinded.as_ssz_bytes(); - let decoded = BlindedExecutionPayloadEnvelope::::from_ssz_bytes(&encoded) - .expect("SSZ decode should succeed"); - assert_eq!( - blinded, decoded, - "SSZ round-trip must preserve BlindedExecutionPayloadEnvelope" - ); - assert_eq!( - blinded.tree_hash_root(), - decoded.tree_hash_root(), - "SSZ round-trip must preserve tree-hash root" - ); - } - // ═══════════════════════════════════════════════════════════════════════════════ // DataVersion Fork-Aware Decode Helper Tests (EIP-7688 / SIP-94 §2) // ═══════════════════════════════════════════════════════════════════════════════ diff --git a/anchor/common/ssv_types/src/dissemination.rs b/anchor/common/ssv_types/src/dissemination.rs deleted file mode 100644 index 89df105e5..000000000 --- a/anchor/common/ssv_types/src/dissemination.rs +++ /dev/null @@ -1,52 +0,0 @@ -use ssz::{Decode, DecodeError}; -use ssz_derive::{Decode, Encode}; -use ssz_types::VariableList; -use tree_hash_derive::TreeHash; -use types::{EthSpec, Slot}; - -use crate::{consensus::BlindedExecutionPayloadEnvelope, message::SSVMessageDataLen}; - -/// Wire payload of `MsgType::SSVEnvelopeDisseminationMsgType` (SIP-94 §6). -/// -/// The builder operator broadcasts the blinded form of its full envelope after the block -/// QBFT decides a self-build block; every operator validates it against its own block -/// decision and threshold-signs its root. `slot` stamps the duty slot for message -/// validation, mirroring `PartialSignatureMessages.slot`. The envelope rides as opaque -/// SSZ bytes so the wire container stays non-generic; the runner decodes it with its -/// `EthSpec` via [`Self::blinded_envelope`]. -/// -/// The inner list reuses the outer `SSVMessage.Data` bound: the blinded envelope is a few -/// hundred bytes in practice, and its Gloas progressive request lists carry no type-level -/// maximum to derive a tighter cap from. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, TreeHash)] -pub struct EnvelopeDissemination { - pub slot: Slot, - pub envelope: VariableList, -} - -impl EnvelopeDissemination { - /// Decode the disseminated `BlindedExecutionPayloadEnvelope`. - pub fn blinded_envelope( - &self, - ) -> Result, DecodeError> { - BlindedExecutionPayloadEnvelope::from_ssz_bytes(&self.envelope) - } -} - -#[cfg(test)] -mod tests { - use ssz::Encode; - - use super::*; - - #[test] - fn envelope_dissemination_ssz_round_trip() { - let original = EnvelopeDissemination { - slot: Slot::new(42), - envelope: VariableList::new(vec![1, 2, 3, 4]).unwrap(), - }; - let decoded = EnvelopeDissemination::from_ssz_bytes(&original.as_ssz_bytes()) - .expect("round trip should decode"); - assert_eq!(decoded, original); - } -} diff --git a/anchor/common/ssv_types/src/lib.rs b/anchor/common/ssv_types/src/lib.rs index 3bd1e5e39..93795d950 100644 --- a/anchor/common/ssv_types/src/lib.rs +++ b/anchor/common/ssv_types/src/lib.rs @@ -5,7 +5,6 @@ pub use share::Share; mod cluster; mod committee; pub mod consensus; -pub mod dissemination; pub mod domain_type; pub mod message; pub mod msgid; diff --git a/anchor/common/ssv_types/src/message.rs b/anchor/common/ssv_types/src/message.rs index 32767fa5e..0ed321b0f 100644 --- a/anchor/common/ssv_types/src/message.rs +++ b/anchor/common/ssv_types/src/message.rs @@ -15,7 +15,6 @@ use types::{Hash256, Slot}; use crate::{ MAX_SIGNATURES, OperatorId, RSA_SIGNATURE_SIZE, consensus::{PrepareJustificationLength, QbftMessage, RoundChangeJustificationLength}, - dissemination::EnvelopeDissemination, msgid::MessageId, partial_sig::PartialSignatureMessages, try_to_variable_list, @@ -82,9 +81,6 @@ pub type SSVMessageDataLen = Sum, U932>; pub enum MsgType { SSVConsensusMsgType = 0, SSVPartialSignatureMsgType = 1, - /// Envelope dissemination for the SIP-94 §6 self-build duty: the builder operator - /// broadcasts a `BlindedExecutionPayloadEnvelope` for the committee to threshold-sign. - SSVEnvelopeDisseminationMsgType = 3, } impl TreeHash for MsgType { @@ -116,7 +112,7 @@ impl TryFrom for MsgType { 1 => Ok(MsgType::SSVPartialSignatureMsgType), // 2 is ssv-spec's DKG message type, which Anchor does not implement; the // discriminant stays reserved so the numbering matches the shared spec. - 3 => Ok(MsgType::SSVEnvelopeDisseminationMsgType), + // 3 was retired with the separate envelope dissemination flow. _ => Err(DecodeError::NoMatchingVariant), } } @@ -250,11 +246,6 @@ impl SSVMessage { }); } } - // No per-type cap tighter than the `SSVMessageDataLen` bound already enforced by - // the `data` list type: the blinded envelope is a few hundred bytes in practice, - // and its Gloas progressive request lists carry no type-level maximum to derive a - // tighter cap from. - MsgType::SSVEnvelopeDisseminationMsgType => {} } Ok(()) } @@ -304,11 +295,6 @@ impl SSVMessage { .ok() .map(|msg| msg.slot) } - MsgType::SSVEnvelopeDisseminationMsgType => { - EnvelopeDissemination::from_ssz_bytes(&self.data) - .ok() - .map(|msg| msg.slot) - } } } } @@ -743,17 +729,12 @@ mod tests { let encoded = msg_type.as_ssz_bytes(); let decoded = MsgType::from_ssz_bytes(&encoded).unwrap(); assert_eq!(decoded, msg_type); - - let msg_type = MsgType::SSVEnvelopeDisseminationMsgType; - let encoded = msg_type.as_ssz_bytes(); - let decoded = MsgType::from_ssz_bytes(&encoded).unwrap(); - assert_eq!(decoded, msg_type); } #[test] fn test_msgtype_decode_invalid_variant() { - // 2 is ssv-spec's DKG type, unimplemented in Anchor; 4 is past the last variant. - for invalid in [2u64, 4u64] { + // 2 is unimplemented DKG, 3 is retired dissemination, and 4 is unassigned. + for invalid in [2u64, 3u64, 4u64] { let result = MsgType::from_ssz_bytes(&invalid.to_le_bytes()); assert!( matches!(result, Err(DecodeError::NoMatchingVariant)), diff --git a/anchor/common/ssv_types/src/msgid.rs b/anchor/common/ssv_types/src/msgid.rs index 4adb4f274..c873404c4 100644 --- a/anchor/common/ssv_types/src/msgid.rs +++ b/anchor/common/ssv_types/src/msgid.rs @@ -23,7 +23,6 @@ pub enum Role { AggregatorCommittee, PTCAttester, ProposerPreferences, - EnvelopeProposer, } impl From for [u8; 4] { @@ -38,7 +37,6 @@ impl From for [u8; 4] { Role::AggregatorCommittee => [6, 0, 0, 0], Role::PTCAttester => [7, 0, 0, 0], Role::ProposerPreferences => [8, 0, 0, 0], - Role::EnvelopeProposer => [9, 0, 0, 0], } } } @@ -57,7 +55,7 @@ impl TryFrom<&[u8]> for Role { [6, 0, 0, 0] => Ok(Role::AggregatorCommittee), [7, 0, 0, 0] => Ok(Role::PTCAttester), [8, 0, 0, 0] => Ok(Role::ProposerPreferences), - [9, 0, 0, 0] => Ok(Role::EnvelopeProposer), + // Role 9 was retired when envelope signing joined the proposer duty. _ => Err(DecodeError::NoMatchingVariant), } } @@ -83,19 +81,17 @@ impl Role { Role::Committee | Role::Aggregator | Role::AggregatorCommittee => Some(12), Role::Proposer => Some(2), Role::SyncCommittee => Some(6), - // These roles don't use QBFT consensus. EnvelopeProposer disseminates and - // threshold-signs the decided envelope without a consensus round (SIP-94 §6). + // These roles do not use QBFT consensus. Role::ValidatorRegistration | Role::VoluntaryExit | Role::PTCAttester - | Role::ProposerPreferences - | Role::EnvelopeProposer => None, + | Role::ProposerPreferences => None, } } /// Returns true if this role runs a QBFT consensus round, i.e. it has a /// max QBFT round. The roles that do not (ValidatorRegistration, VoluntaryExit, - /// PTCAttester, ProposerPreferences, EnvelopeProposer) return false. + /// PTCAttester, ProposerPreferences) return false. pub fn is_qbft_role(self) -> bool { self.max_round().is_some() } @@ -186,8 +182,7 @@ impl MessageId { | Role::ValidatorRegistration | Role::VoluntaryExit | Role::PTCAttester - | Role::ProposerPreferences - | Role::EnvelopeProposer => PublicKeyBytes::deserialize(&self.0[8..]) + | Role::ProposerPreferences => PublicKeyBytes::deserialize(&self.0[8..]) .ok() .map(DutyExecutor::Validator), } @@ -274,6 +269,7 @@ mod tests { #[test] fn role_decoding_invalid_variant() { + assert!(Role::try_from([9, 0, 0, 0].as_slice()).is_err()); assert!(Role::try_from([255, 0, 0, 0].as_slice()).is_err()); assert!(Role::try_from([0, 1, 0, 0].as_slice()).is_err()); } @@ -434,7 +430,6 @@ mod tests { Role::VoluntaryExit, Role::PTCAttester, Role::ProposerPreferences, - Role::EnvelopeProposer, ] { assert!(!role.is_qbft_role(), "{role:?} must not run QBFT"); assert!( @@ -469,7 +464,6 @@ mod tests { Role::ValidatorRegistration, Role::VoluntaryExit, Role::PTCAttester, - Role::EnvelopeProposer, ] { assert!( role.monotonic_slot_role(), @@ -478,52 +472,6 @@ mod tests { } } - /// Tests that EnvelopeProposer is a validator-scoped non-QBFT role: the envelope - /// duty disseminates and threshold-signs without a consensus round (SIP-94 §6). - #[test] - fn envelope_proposer_is_validator_scoped_non_qbft_role() { - assert!( - !Role::EnvelopeProposer.is_committee_role(), - "EnvelopeProposer is per-validator, not a committee role" - ); - assert_eq!( - Role::EnvelopeProposer.max_round(), - None, - "EnvelopeProposer has no consensus round" - ); - assert!( - !Role::EnvelopeProposer.is_qbft_role(), - "EnvelopeProposer does not run QBFT (max_round is None)" - ); - assert!( - Role::EnvelopeProposer.monotonic_slot_role(), - "EnvelopeProposer signers advance slot-by-slot; lower slots are stale" - ); - - // Wire byte 9 for EnvelopeProposer check. - let bytes: [u8; 4] = Role::EnvelopeProposer.into(); - assert_eq!(bytes, [9, 0, 0, 0], "EnvelopeProposer wire byte is 9"); - assert_eq!( - Role::try_from(bytes.as_slice()).unwrap(), - Role::EnvelopeProposer, - "wire byte 9 decodes back to EnvelopeProposer" - ); - - // duty_executor resolves to Validator (per-proposer, pubkey-scoped). - let domain = DomainType([0, 0, 0, 1]); - let pk = PublicKeyBytes::empty(); - let msg_id = MessageId::new( - &domain, - Role::EnvelopeProposer, - &DutyExecutor::Validator(pk), - ); - assert_eq!( - msg_id.duty_executor(), - Some(DutyExecutor::Validator(pk)), - "EnvelopeProposer resolves to a validator-scoped duty executor" - ); - } - /// Pins the proposer QBFT and sync-committee round caps. #[test] fn proposer_and_sync_committee_max_rounds_are_pinned() { diff --git a/anchor/common/ssv_types/src/partial_sig.rs b/anchor/common/ssv_types/src/partial_sig.rs index 695eea4c3..b9b2c2c76 100644 --- a/anchor/common/ssv_types/src/partial_sig.rs +++ b/anchor/common/ssv_types/src/partial_sig.rs @@ -48,10 +48,6 @@ pub enum PartialSignatureKind { // BuilderRequestAuth object (validator-scoped, non-QBFT; rides Role::ProposerPreferences // as the role's second kind per SIP-94 §5's builder request auth extension) RequestAuth = 9, - // EnvelopePartialSig is a standalone single-validator partial signature over the - // disseminated BlindedExecutionPayloadEnvelope root (validator-scoped, non-QBFT; - // SIP-94 §6's self-build envelope signing round) - Envelope = 10, } impl TryFrom for PartialSignatureKind { @@ -69,7 +65,7 @@ impl TryFrom for PartialSignatureKind { 7 => Ok(PartialSignatureKind::PTCAttester), 8 => Ok(PartialSignatureKind::ProposerPreferences), 9 => Ok(PartialSignatureKind::RequestAuth), - 10 => Ok(PartialSignatureKind::Envelope), + // Kind 10 was retired when envelope shares joined PostConsensus packets. _ => Err(()), } } @@ -209,7 +205,6 @@ mod tests { PartialSignatureKind::PTCAttester, PartialSignatureKind::ProposerPreferences, PartialSignatureKind::RequestAuth, - PartialSignatureKind::Envelope, ]; for variant in variants { @@ -245,7 +240,6 @@ mod tests { (PartialSignatureKind::PTCAttester, 7u64), (PartialSignatureKind::ProposerPreferences, 8u64), (PartialSignatureKind::RequestAuth, 9u64), - (PartialSignatureKind::Envelope, 10u64), ]; for (variant, expected_value) in test_cases { @@ -263,6 +257,7 @@ mod tests { #[test] fn partial_signature_kind_ssz_decode_invalid_variant() { + assert!(PartialSignatureKind::from_ssz_bytes(&10u64.to_le_bytes()).is_err()); let invalid_value = 11u64.to_le_bytes(); let result = PartialSignatureKind::from_ssz_bytes(&invalid_value); assert!(matches!(result, Err(DecodeError::NoMatchingVariant))); diff --git a/anchor/common/ssv_types/testdata/devnet8_gloas_block_144352.ssz b/anchor/common/ssv_types/testdata/devnet8_gloas_block_144352.ssz new file mode 100644 index 000000000..ea4542b6f Binary files /dev/null and b/anchor/common/ssv_types/testdata/devnet8_gloas_block_144352.ssz differ diff --git a/anchor/message_receiver/Cargo.toml b/anchor/message_receiver/Cargo.toml index a62936f1b..a07797d42 100644 --- a/anchor/message_receiver/Cargo.toml +++ b/anchor/message_receiver/Cargo.toml @@ -6,7 +6,6 @@ authors = ["Sigma Prime "] [dependencies] database = { workspace = true } -dissemination_store = { workspace = true } hex = { workspace = true } libp2p = { workspace = true } message_validator = { workspace = true } diff --git a/anchor/message_receiver/src/manager.rs b/anchor/message_receiver/src/manager.rs index 850f80174..ba5b07f0b 100644 --- a/anchor/message_receiver/src/manager.rs +++ b/anchor/message_receiver/src/manager.rs @@ -1,7 +1,6 @@ use std::sync::Arc; use database::{NetworkState, NonUniqueIndex, UniqueIndex}; -use dissemination_store::DisseminationStore; use libp2p::{ PeerId, gossipsub::{Message, MessageAcceptance, MessageId}, @@ -33,7 +32,6 @@ pub struct NetworkMessageReceiver>, signature_collector: Arc>, - dissemination_store: Arc, network_state_rx: watch::Receiver, is_synced: watch::Receiver, outcome_tx: mpsc::Sender, @@ -47,7 +45,6 @@ impl NetworkMessag processor: processor::Senders, qbft_manager: Arc>, signature_collector: Arc>, - dissemination_store: Arc, network_state_rx: watch::Receiver, is_synced: watch::Receiver, outcome_tx: mpsc::Sender, @@ -58,7 +55,6 @@ impl NetworkMessag processor, qbft_manager, signature_collector, - dissemination_store, network_state_rx, is_synced, outcome_tx, @@ -201,20 +197,7 @@ impl MessageReceiv error!(gossipsub_message_id = ?message_id, ssv_msg_id = ?msg_id, ?err, "Unable to receive partial signature message"); } } - ValidatedSSVMessage::EnvelopeDissemination(dissemination) => { - // Validation admits the class only for validator-scoped role-9 message - // IDs, so the duty executor is always a validator public key. - match msg_id.duty_executor() { - Some(DutyExecutor::Validator(validator_pubkey)) => { - receiver - .dissemination_store - .insert(validator_pubkey, dissemination); - } - _ => { - error!(gossipsub_message_id = ?message_id, ssv_msg_id = ?msg_id, "Envelope dissemination without a validator duty executor"); - } - } - } + } }, RECEIVER_NAME, diff --git a/anchor/message_receiver/src/proposer_view_tests.rs b/anchor/message_receiver/src/proposer_view_tests.rs index 281da6c25..6735c6e74 100644 --- a/anchor/message_receiver/src/proposer_view_tests.rs +++ b/anchor/message_receiver/src/proposer_view_tests.rs @@ -367,7 +367,6 @@ async fn test_proposer_preferences_local_positive_reaches_collector_despite_http processor.clone(), qbft, collector.clone(), - Arc::new(dissemination_store::DisseminationStore::new()), database.watch(), synced_rx, outcome_tx, diff --git a/anchor/message_validator/src/consensus_message.rs b/anchor/message_validator/src/consensus_message.rs index 42c7fe1f6..e7e833173 100644 --- a/anchor/message_validator/src/consensus_message.rs +++ b/anchor/message_validator/src/consensus_message.rs @@ -494,8 +494,7 @@ mod tests { use super::*; use crate::{ - LATE_MESSAGE_MARGIN, LATE_SLOT_ALLOWANCE, MessageAcceptance, ValidatedSSVMessage, - duty_limit, + LATE_MESSAGE_MARGIN, LATE_SLOT_ALLOWANCE, ValidatedSSVMessage, duty_limit, tests::{ FOUR_NODE_COMMITTEE, SINGLE_NODE_COMMITTEE, create_committee_info, create_operator_pub_keys, generate_random_rsa_public_keys, generate_test_key_pair, @@ -1026,14 +1025,13 @@ mod tests { let committee_info = create_committee_info(SINGLE_NODE_COMMITTEE); // Every non-QBFT role must reject consensus messages, including - // PTCAttester, ProposerPreferences, and EnvelopeProposer (all leaderless, + // PTCAttester and ProposerPreferences (both leaderless, // no QBFT round). for role in [ Role::ValidatorRegistration, Role::VoluntaryExit, Role::PTCAttester, Role::ProposerPreferences, - Role::EnvelopeProposer, ] { let msg_id = create_message_id_for_test(role); let qbft_message = QbftMessageBuilder::new(role, QbftMessageType::Proposal) @@ -1922,94 +1920,6 @@ mod tests { assert_eq!(result, Ok(Some(SLOTS_PER_EPOCH))); } - #[test] - fn test_duty_limit_envelope_proposer() { - // EnvelopeProposer is validator-scoped and per-slot. Its duty limit is - // `slots_per_epoch` (one envelope per slot across the lookahead window), - // independent of the validator-index slice length. Mirror - // test_duty_limit_proposer_preferences. - const SLOTS_PER_EPOCH: u64 = 32; - - let now = SystemTime::now(); - let slot_clock = ManualSlotClock::new( - Slot::new(100), - now.duration_since(UNIX_EPOCH).unwrap(), - Duration::from_secs(1), - ); - - let msg_id = MessageId::new( - &DomainType([0, 0, 0, 1]), - Role::EnvelopeProposer, - &DutyExecutor::Validator(PublicKeyBytes::empty()), - ); - let ssv_msg = SSVMessage::new( - MsgType::SSVEnvelopeDisseminationMsgType, - msg_id, - vec![1, 2, 3], - ) - .expect("SSVMessage should be created"); - let signed_msg = SignedSSVMessage::new( - vec![[0xAA; RSA_SIGNATURE_SIZE]], - vec![OperatorId(1)], - ssv_msg, - vec![], - ) - .expect("SignedSSVMessage should be created"); - - let committee_info = create_committee_info(FOUR_NODE_COMMITTEE); - let mock_duties_provider = Arc::new(MockDutiesProvider::default()); - let map = HashMap::new(); - - // Create fork schedule with Boole at epoch 0 (active from start). - let mut fork_epochs = BTreeMap::new(); - fork_epochs.insert(Fork::Alan, (Epoch::new(0), DomainType([0, 0, 0, 42]))); - fork_epochs.insert(Fork::Boole, (Epoch::new(0), DomainType([0, 0, 0, 43]))); - let fork_schedule = Arc::new( - fork::ForkSchedule::from_fork_configs(fork_epochs, "testing") - .expect("test fork schedule creation should succeed"), - ); - - let validation_context = ValidationContext { - signed_ssv_message: &signed_msg, - committee_info: &committee_info, - role: Role::EnvelopeProposer, - received_at: now, - slots_per_epoch: SLOTS_PER_EPOCH, - epochs_per_sync_committee_period: 256, - sync_committee_size: 512, - slot_clock: slot_clock.clone(), - operator_pub_keys: &map, - fork_schedule, - spec: Arc::new(types::ChainSpec::mainnet()), - }; - - let slot = slot_clock.now().unwrap(); - - // Act: Call duty_limit directly (private items visible to child-module tests). - let result = duty_limit( - &validation_context, - slot, - &[ValidatorIndex(0)], // Single validator; irrelevant for EnvelopeProposer - mock_duties_provider.clone(), - ); - - // Assert: Duty cap must equal slots_per_epoch and be independent of slice length. - assert_eq!( - result, - Ok(Some(SLOTS_PER_EPOCH)), - "EnvelopeProposer duty cap must be slots_per_epoch" - ); - - // Verify independence from slice length. - let many = vec![ValidatorIndex(0); 100]; - let result = duty_limit(&validation_context, slot, &many, mock_duties_provider); - assert_eq!( - result, - Ok(Some(SLOTS_PER_EPOCH)), - "duty cap must be independent of validator-index slice length" - ); - } - /// Builds a signed consensus message for `role` and runs it through the full /// `validate_ssv_message` path (including `validate_role_for_fork`) with the /// given fork schedule and chain spec, returning the result for the caller @@ -2158,42 +2068,4 @@ mod tests { "RoleNotActiveAfterEthFork (ValidatorRegistration consensus message post-Gloas)", ); } - - #[test] - fn test_envelope_proposer_consensus_message_rejected_before_gloas() { - // `EnvelopeProposer` is a post-Gloas role. With Gloas never activating - // (`spec_with_gloas(None)`), the shared `validate_role_for_fork` gate must reject - // its consensus message at every slot. The gate is role-agnostic across entry - // points, so exercising it once via the consensus path covers the envelope role. - let result = run_role_fork_validation( - Role::EnvelopeProposer, - generate_fork_schedule(), - spec_with_gloas(None), - ); - assert_validation_error( - result, - |failure| { - matches!( - failure, - ValidationFailure::RoleNotActiveBeforeEthFork { - minimum_fork: types::ForkName::Gloas, - .. - } - ) - }, - "RoleNotActiveBeforeEthFork (EnvelopeProposer consensus message pre-Gloas)", - ); - - // Ensures that pre-Gloas EnvelopeProposer messages caught at fork-gate are rejected and not - // ignored. - assert_eq!( - MessageAcceptance::from(&ValidationFailure::RoleNotActiveBeforeEthFork { - role: Role::EnvelopeProposer, - current_fork: types::ForkName::Base, - minimum_fork: types::ForkName::Gloas, - }), - MessageAcceptance::Reject, - "fork-gate failure must be Reject", - ); - } } diff --git a/anchor/message_validator/src/dissemination.rs b/anchor/message_validator/src/dissemination.rs deleted file mode 100644 index 3ee6dcf91..000000000 --- a/anchor/message_validator/src/dissemination.rs +++ /dev/null @@ -1,720 +0,0 @@ -use std::sync::Arc; - -use duties_tracker::DutiesProvider; -use slot_clock::SlotClock; -use ssv_types::{dissemination::EnvelopeDissemination, msgid::Role}; -use ssz::Decode; - -use crate::{ - ValidatedSSVMessage, ValidationContext, ValidationFailure, duty_state::DutyState, - validate_beacon_duty, validate_duty_count, validate_role_for_fork, validate_slot_time, - verify_single_signer, -}; - -/// Validates an envelope dissemination message (SIP-94 §6/§7). -/// -/// The class is structural-only at this layer: the inner envelope must SSZ-decode as a -/// blinded execution payload envelope (shape, Reject-class), but the checks binding it to -/// the block-QBFT decision are runner concerns, and validation never judges the envelope's -/// content against the decision. Dedup is first-valid per (`MessageId`, slot): the first -/// message passing all other rules is recorded, and further dissemination messages for the -/// tuple are Ignore regardless of content or peer (an honest origin retry can repeat one -/// after the recipient's gossip duplicate cache expires, so repetition does not prove peer -/// fault). -/// -/// First-valid means first STRUCTURALLY valid, by design: SIP-94 accepts that a Byzantine -/// committee member can consume a slot's dissemination budget with a decision-unbound or -/// payload-unbound (but well-formed) carrier, costing at most one missed self-build reveal -/// (never a wrong payload on chain). Do not move semantic rejection into a -/// replacement-capable store; the named hardening for that trade is sign-all, a protocol -/// change. -pub(crate) fn validate_envelope_dissemination( - validation_context: ValidationContext, - duty_state: &mut DutyState, - duty_provider: Arc, -) -> Result { - // Rule: dissemination messages are admitted only for `Role::EnvelopeProposer`. - if validation_context.role != Role::EnvelopeProposer { - return Err(ValidationFailure::UnexpectedDisseminationMessage { - role: validation_context.role, - }); - } - - let dissemination = EnvelopeDissemination::from_ssz_bytes( - validation_context.signed_ssv_message.ssv_message().data(), - ) - .map_err(ValidationFailure::UndecodableMessageData)?; - let slot = dissemination.slot; - - // Rule: the inner envelope bytes must decode as a blinded execution payload envelope - // (SIP-94 §7, Reject-class). Shape only; the decoded value is not compared to anything. - // Undecodable bytes must not reach the first-valid record below, where they would consume - // the slot's single dissemination budget and starve the honest dissemination. The Gloas - // envelope's SSZ shape is `EthSpec`-independent (its request lists are progressive, with - // no preset-derived bounds), so decoding under `MainnetEthSpec` accepts and rejects - // exactly the same byte strings for every preset. - dissemination - .blinded_envelope::() - .map_err(ValidationFailure::UndecodableDisseminationEnvelope)?; - - validate_role_for_fork(slot, &validation_context)?; - - // Rule: exactly one signer. - let signers = validation_context.signed_ssv_message.operator_ids(); - if signers.len() != 1 { - return Err(ValidationFailure::DisseminationOneSigner); - } - let signer = signers[0]; - - // Rule: full data rides only consensus proposals. - if !validation_context.signed_ssv_message.full_data().is_empty() { - return Err(ValidationFailure::FullDataNotInConsensusMessage); - } - - // Rule: `EnvelopeProposer` is a monotonic-slot role, so a signer that already advanced to - // a later slot must not disseminate for an earlier one; the mirror of the partial-signature - // path's guard, since both message classes advance the same shared `max_slot`. - let max_slot = duty_state.get_or_create_operator(&signer).max_slot(); - if max_slot.as_u64() != 0 && max_slot > slot { - return Err(ValidationFailure::SlotAlreadyAdvanced { - got: slot.as_u64(), - want: max_slot.as_u64(), - }); - } - - // Rule: the validator must be the assigned proposer at the slot (Ignore when the epoch's - // duties are not yet known locally). - let is_randao_msg = false; // a dissemination carries no RANDAO signature - validate_beacon_duty( - &validation_context, - slot, - is_randao_msg, - duty_provider.clone(), - )?; - - // Rule: first-valid dedup per (`MessageId`, slot), signer-independent. Ignore-class. - if duty_state.is_dissemination_recorded(slot) { - return Err(ValidationFailure::RelayedDuplicateMessage { - got: format!("envelope dissemination for slot {slot}"), - }); - } - - // Rule: no earliness allowance, 3-slot lateness TTL (role-keyed). - validate_slot_time(slot, &validation_context)?; - - // Rule: per-epoch duty limit (role-keyed, `SLOTS_PER_EPOCH`). Ignore-class. - let operator_state = duty_state.get_or_create_operator(&signer); - validate_duty_count(&validation_context, slot, operator_state, duty_provider)?; - - verify_single_signer(&validation_context, signer)?; - - // Record only after every other rule passed, so a rejected message cannot consume the - // slot's single dissemination budget. - duty_state.record_dissemination(slot, &signer); - - Ok(ValidatedSSVMessage::EnvelopeDissemination(dissemination)) -} - -#[cfg(test)] -mod tests { - use std::{ - collections::HashMap, - time::{Duration, SystemTime, UNIX_EPOCH}, - }; - - use bls::Signature; - use duties_tracker::DutyAssignment; - use fork::Fork; - use openssl::{ - hash::MessageDigest, - pkey::{PKey, Private, Public}, - rsa::Rsa, - sign::Signer, - }; - use slot_clock::ManualSlotClock; - use ssv_types::{ - OperatorId, ValidatorIndex, VariableList, - message::{MsgType, SSVMessage, SignedSSVMessage}, - partial_sig::{PartialSignatureKind, PartialSignatureMessage, PartialSignatureMessages}, - }; - use ssz::Encode; - use types::{Hash256, Slot}; - - use super::*; - use crate::{ - MessageAcceptance, ValidationContext, - tests::{ - MockDutiesProvider, assert_validation_error, create_message_id_for_test, - four_node_committee_and_keypair, generate_fork_schedule, generate_test_key_pair, - spec_with_gloas, - }, - }; - - const SLOTS_PER_EPOCH_TEST: u64 = 32; - /// Message slot used by most tests; the clock genesis sits one slot before it. - const TEST_SLOT: u64 = 1; - - /// SSZ bytes of a minimal but well-formed blinded envelope: the inner-decode rule - /// admits shape, not content, so defaults suffice. - fn test_blinded_envelope_bytes() -> Vec { - use ssv_types::consensus::BlindedExecutionPayloadEnvelope; - use types::{ExecutionRequestsGloas, Hash256, MainnetEthSpec}; - BlindedExecutionPayloadEnvelope:: { - payload_root: Hash256::ZERO, - execution_requests: ExecutionRequestsGloas::default(), - builder_index: 0, - beacon_block_root: Hash256::ZERO, - parent_beacon_block_root: Hash256::ZERO, - } - .as_ssz_bytes() - } - - /// Builds a signed dissemination message for `role`'s message ID at `slot`, signed by - /// each of `signers` with `private_key`, carrying `full_data`. - fn create_signed_dissemination_with( - role: Role, - signers: Vec, - private_key: &Rsa, - slot: Slot, - full_data: Vec, - ) -> SignedSSVMessage { - create_signed_dissemination_with_envelope( - role, - signers, - private_key, - slot, - full_data, - test_blinded_envelope_bytes(), - ) - } - - /// As `create_signed_dissemination_with`, but carrying `envelope_bytes` verbatim as the - /// inner envelope field. - fn create_signed_dissemination_with_envelope( - role: Role, - signers: Vec, - private_key: &Rsa, - slot: Slot, - full_data: Vec, - envelope_bytes: Vec, - ) -> SignedSSVMessage { - let dissemination = EnvelopeDissemination { - slot, - envelope: VariableList::new(envelope_bytes).unwrap(), - }; - let ssv_msg = SSVMessage::new( - MsgType::SSVEnvelopeDisseminationMsgType, - create_message_id_for_test(role), - dissemination.as_ssz_bytes(), - ) - .unwrap(); - - let p_key = PKey::from_rsa(private_key.clone()).unwrap(); - let mut signer = Signer::new(MessageDigest::sha256(), &p_key).unwrap(); - signer.update(&ssv_msg.as_ssz_bytes()).unwrap(); - let signature: [u8; 256] = signer.sign_to_vec().unwrap().try_into().unwrap(); - - let signatures = vec![signature; signers.len()]; - SignedSSVMessage::new(signatures, signers, ssv_msg, full_data).unwrap() - } - - /// Single-signer dissemination at `TEST_SLOT` with no full data. - fn create_signed_dissemination( - role: Role, - signer_id: OperatorId, - private_key: &Rsa, - ) -> SignedSSVMessage { - create_signed_dissemination_with( - role, - vec![signer_id], - private_key, - Slot::new(TEST_SLOT), - vec![], - ) - } - - /// Context whose clock genesis sits one slot before `TEST_SLOT`: `slots_since_genesis: 1` - /// receives the message exactly at its slot start (on time), larger values push it late. - fn create_dissemination_context<'a>( - signed_msg: &'a SignedSSVMessage, - committee_info: &'a crate::CommitteeInfo, - role: Role, - operator_pub_keys: &'a HashMap>, - slots_since_genesis: u64, - gloas_epoch: Option, - ) -> ValidationContext<'a, ManualSlotClock> { - let now = SystemTime::now(); - let slot_clock = ManualSlotClock::new( - Slot::new(0), - now.duration_since(UNIX_EPOCH).unwrap(), - Duration::from_secs(12), - ); - - ValidationContext { - signed_ssv_message: signed_msg, - committee_info, - role, - received_at: now + Duration::from_secs(12 * slots_since_genesis), - slots_per_epoch: SLOTS_PER_EPOCH_TEST, - epochs_per_sync_committee_period: 256, - sync_committee_size: 512, - slot_clock, - operator_pub_keys, - fork_schedule: generate_fork_schedule(Fork::Boole), - spec: spec_with_gloas(gloas_epoch), - } - } - - #[test] - fn valid_dissemination_accepted() { - let (committee_info, private_key, map) = four_node_committee_and_keypair(); - let signed_msg = - create_signed_dissemination(Role::EnvelopeProposer, OperatorId(1), &private_key); - let ctx = create_dissemination_context( - &signed_msg, - &committee_info, - Role::EnvelopeProposer, - &map, - 1, - Some(0), - ); - - let result = validate_envelope_dissemination( - ctx, - &mut DutyState::new(64), - Arc::new(MockDutiesProvider::default()), - ); - - match result { - Ok(ValidatedSSVMessage::EnvelopeDissemination(d)) => { - assert_eq!(d.slot, Slot::new(TEST_SLOT)); - } - other => panic!("expected accepted dissemination, got {other:?}"), - } - } - - #[test] - fn non_envelope_role_rejected() { - let (committee_info, private_key, map) = four_node_committee_and_keypair(); - let signed_msg = create_signed_dissemination(Role::Proposer, OperatorId(1), &private_key); - let ctx = create_dissemination_context( - &signed_msg, - &committee_info, - Role::Proposer, - &map, - 1, - Some(0), - ); - - let result = validate_envelope_dissemination( - ctx, - &mut DutyState::new(64), - Arc::new(MockDutiesProvider::default()), - ); - - match &result { - Err(failure @ ValidationFailure::UnexpectedDisseminationMessage { .. }) => { - assert_eq!( - MessageAcceptance::from(failure), - MessageAcceptance::Reject, - "dissemination on a foreign role must be Reject" - ); - } - other => panic!("expected UnexpectedDisseminationMessage, got {other:?}"), - } - } - - #[test] - fn second_dissemination_for_slot_ignored_regardless_of_signer() { - let (committee_info, private_key, mut map) = four_node_committee_and_keypair(); - // A second operator with its own key, so the dedup is proven signer-independent. - let (private_key_2, public_key_2) = generate_test_key_pair(); - map.insert(OperatorId(2), public_key_2); - let mut duty_state = DutyState::new(64); - - let first = - create_signed_dissemination(Role::EnvelopeProposer, OperatorId(1), &private_key); - let ctx = create_dissemination_context( - &first, - &committee_info, - Role::EnvelopeProposer, - &map, - 1, - Some(0), - ); - validate_envelope_dissemination( - ctx, - &mut duty_state, - Arc::new(MockDutiesProvider::default()), - ) - .expect("first dissemination must be accepted"); - - let second = - create_signed_dissemination(Role::EnvelopeProposer, OperatorId(2), &private_key_2); - let ctx = create_dissemination_context( - &second, - &committee_info, - Role::EnvelopeProposer, - &map, - 1, - Some(0), - ); - let result = validate_envelope_dissemination( - ctx, - &mut duty_state, - Arc::new(MockDutiesProvider::default()), - ); - - match &result { - Err(failure @ ValidationFailure::RelayedDuplicateMessage { .. }) => { - assert_eq!( - MessageAcceptance::from(failure), - MessageAcceptance::Ignore, - "a further dissemination for a recorded slot must be Ignore, not Reject" - ); - } - other => panic!("expected RelayedDuplicateMessage, got {other:?}"), - } - } - - #[test] - fn two_signers_rejected() { - let (committee_info, private_key, map) = four_node_committee_and_keypair(); - // The one-signer rule fires before RSA verify, so the second signature's validity - // is irrelevant. - let signed_msg = create_signed_dissemination_with( - Role::EnvelopeProposer, - vec![OperatorId(1), OperatorId(2)], - &private_key, - Slot::new(TEST_SLOT), - vec![], - ); - - let ctx = create_dissemination_context( - &signed_msg, - &committee_info, - Role::EnvelopeProposer, - &map, - 1, - Some(0), - ); - let result = validate_envelope_dissemination( - ctx, - &mut DutyState::new(64), - Arc::new(MockDutiesProvider::default()), - ); - - assert_validation_error( - result, - |failure| matches!(failure, ValidationFailure::DisseminationOneSigner), - "DisseminationOneSigner", - ); - } - - #[test] - fn dissemination_below_advanced_slot_ignored() { - let (committee_info, private_key, map) = four_node_committee_and_keypair(); - let mut duty_state = DutyState::new(64); - - // A dissemination at slot 2 advances the signer's max_slot. - let later = create_signed_dissemination_with( - Role::EnvelopeProposer, - vec![OperatorId(1)], - &private_key, - Slot::new(TEST_SLOT + 1), - vec![], - ); - let ctx = create_dissemination_context( - &later, - &committee_info, - Role::EnvelopeProposer, - &map, - 2, - Some(0), - ); - validate_envelope_dissemination( - ctx, - &mut duty_state, - Arc::new(MockDutiesProvider::default()), - ) - .expect("dissemination at the later slot must be accepted"); - - // The same signer's dissemination for the earlier slot is now stale. - let earlier = - create_signed_dissemination(Role::EnvelopeProposer, OperatorId(1), &private_key); - let ctx = create_dissemination_context( - &earlier, - &committee_info, - Role::EnvelopeProposer, - &map, - 2, - Some(0), - ); - let result = validate_envelope_dissemination( - ctx, - &mut duty_state, - Arc::new(MockDutiesProvider::default()), - ); - - assert_validation_error( - result, - |failure| matches!(failure, ValidationFailure::SlotAlreadyAdvanced { .. }), - "SlotAlreadyAdvanced (monotonic-slot role)", - ); - } - - #[test] - fn dissemination_below_advanced_partial_sig_slot_ignored() { - let (committee_info, private_key, map) = four_node_committee_and_keypair(); - let mut duty_state = DutyState::new(64); - - // An Envelope partial sig at slot 2 advances the signer's max_slot (both message - // classes share it): seed through the state update the partial-signature validator - // applies. - let partial_sig_messages = PartialSignatureMessages { - kind: PartialSignatureKind::Envelope, - slot: Slot::new(TEST_SLOT + 1), - messages: VariableList::new(vec![PartialSignatureMessage { - partial_signature: Signature::empty(), - signing_root: Hash256::from([0x99; 32]), - signer: OperatorId(1), - validator_index: ValidatorIndex(0), - }]) - .unwrap(), - }; - duty_state - .update_for_partial_signature(&partial_sig_messages, &OperatorId(1)) - .expect("seeding partial-signature state must succeed"); - - // The same signer's dissemination for the earlier slot is now stale. - let earlier = - create_signed_dissemination(Role::EnvelopeProposer, OperatorId(1), &private_key); - let ctx = create_dissemination_context( - &earlier, - &committee_info, - Role::EnvelopeProposer, - &map, - 2, - Some(0), - ); - let result = validate_envelope_dissemination( - ctx, - &mut duty_state, - Arc::new(MockDutiesProvider::default()), - ); - - assert_validation_error( - result, - |failure| matches!(failure, ValidationFailure::SlotAlreadyAdvanced { .. }), - "SlotAlreadyAdvanced (§7 monotonic-slot rule, partial-sig-advances direction: a dissemination below the signer's Envelope-partial slot is stale)", - ); - } - - #[test] - fn beyond_short_ttl_rejected() { - let (committee_info, private_key, map) = four_node_committee_and_keypair(); - let signed_msg = - create_signed_dissemination(Role::EnvelopeProposer, OperatorId(1), &private_key); - // Received 4 slots after the message slot's start, past the role's short TTL - // (1 + LATE_SLOT_ALLOWANCE = 3 slots). - let ctx = create_dissemination_context( - &signed_msg, - &committee_info, - Role::EnvelopeProposer, - &map, - 5, - Some(0), - ); - - let result = validate_envelope_dissemination( - ctx, - &mut DutyState::new(64), - Arc::new(MockDutiesProvider::default()), - ); - - assert_validation_error( - result, - |failure| matches!(failure, ValidationFailure::LateSlotMessage { .. }), - "LateSlotMessage (dissemination past short TTL)", - ); - } - - #[test] - fn rejected_before_gloas() { - let (committee_info, private_key, map) = four_node_committee_and_keypair(); - let signed_msg = - create_signed_dissemination(Role::EnvelopeProposer, OperatorId(1), &private_key); - let ctx = create_dissemination_context( - &signed_msg, - &committee_info, - Role::EnvelopeProposer, - &map, - 1, - None, - ); - - let result = validate_envelope_dissemination( - ctx, - &mut DutyState::new(64), - Arc::new(MockDutiesProvider::default()), - ); - - assert_validation_error( - result, - |failure| { - matches!( - failure, - ValidationFailure::RoleNotActiveBeforeEthFork { .. } - ) - }, - "RoleNotActiveBeforeEthFork (dissemination before Gloas)", - ); - } - - #[test] - fn failing_message_does_not_consume_the_slot_budget() { - let (committee_info, private_key, map) = four_node_committee_and_keypair(); - let mut duty_state = DutyState::new(64); - - // First message fails the proposer-assignment check (Ignore), so it must not record. - let first = - create_signed_dissemination(Role::EnvelopeProposer, OperatorId(1), &private_key); - let ctx = create_dissemination_context( - &first, - &committee_info, - Role::EnvelopeProposer, - &map, - 1, - Some(0), - ); - let result = validate_envelope_dissemination( - ctx, - &mut duty_state, - Arc::new(MockDutiesProvider { - proposer_assignment: DutyAssignment::NotAssigned, - ..Default::default() - }), - ); - assert_validation_error( - result, - |failure| matches!(failure, ValidationFailure::NoDuty), - "NoDuty (unassigned proposer)", - ); - - // A valid dissemination for the same slot must still be accepted afterwards. - let second = - create_signed_dissemination(Role::EnvelopeProposer, OperatorId(1), &private_key); - let ctx = create_dissemination_context( - &second, - &committee_info, - Role::EnvelopeProposer, - &map, - 1, - Some(0), - ); - validate_envelope_dissemination( - ctx, - &mut duty_state, - Arc::new(MockDutiesProvider::default()), - ) - .expect("a rejected message must not consume the slot's dissemination budget"); - } - - #[test] - fn full_data_rejected() { - let (committee_info, private_key, map) = four_node_committee_and_keypair(); - let signed_msg = create_signed_dissemination_with( - Role::EnvelopeProposer, - vec![OperatorId(1)], - &private_key, - Slot::new(TEST_SLOT), - vec![0xBB; 8], - ); - - let ctx = create_dissemination_context( - &signed_msg, - &committee_info, - Role::EnvelopeProposer, - &map, - 1, - Some(0), - ); - let result = validate_envelope_dissemination( - ctx, - &mut DutyState::new(64), - Arc::new(MockDutiesProvider::default()), - ); - - assert_validation_error( - result, - |failure| matches!(failure, ValidationFailure::FullDataNotInConsensusMessage), - "FullDataNotInConsensusMessage", - ); - } - - /// An inner envelope that does not SSZ-decode as a blinded envelope is Reject-class and - /// must not consume the slot's first-valid budget (SIP-94 §7 decode rule): the honest - /// dissemination arriving later is still accepted. - #[test] - fn undecodable_inner_envelope_rejected_without_consuming_budget() { - let (committee_info, private_key, map) = four_node_committee_and_keypair(); - let mut duty_state = DutyState::new(64); - - let garbage = create_signed_dissemination_with_envelope( - Role::EnvelopeProposer, - vec![OperatorId(1)], - &private_key, - Slot::new(TEST_SLOT), - vec![], - vec![0xAA; 64], - ); - let ctx = create_dissemination_context( - &garbage, - &committee_info, - Role::EnvelopeProposer, - &map, - 1, - Some(0), - ); - let result = validate_envelope_dissemination( - ctx, - &mut duty_state, - Arc::new(MockDutiesProvider::default()), - ); - assert_validation_error( - result, - |failure| { - matches!( - failure, - ValidationFailure::UndecodableDisseminationEnvelope(_) - ) - }, - "UndecodableDisseminationEnvelope (garbage inner bytes)", - ); - assert_eq!( - MessageAcceptance::from(&ValidationFailure::UndecodableDisseminationEnvelope( - ssz::DecodeError::BytesInvalid("test".into()), - )), - MessageAcceptance::Reject, - "an undecodable inner envelope must be Reject, not Ignore", - ); - - // The budget was not consumed: a well-formed dissemination for the same slot passes. - let honest = - create_signed_dissemination(Role::EnvelopeProposer, OperatorId(1), &private_key); - let ctx = create_dissemination_context( - &honest, - &committee_info, - Role::EnvelopeProposer, - &map, - 1, - Some(0), - ); - validate_envelope_dissemination( - ctx, - &mut duty_state, - Arc::new(MockDutiesProvider::default()), - ) - .expect("a well-formed dissemination after a rejected garbage one must be accepted"); - } -} diff --git a/anchor/message_validator/src/duty_state.rs b/anchor/message_validator/src/duty_state.rs index 060fec216..5eb163303 100644 --- a/anchor/message_validator/src/duty_state.rs +++ b/anchor/message_validator/src/duty_state.rs @@ -53,11 +53,6 @@ pub(crate) struct DutyState { operators: HashMap, /// The number of slots for which state is stored (defines the size of the circular buffer) stored_slot_count: usize, - /// Slot-indexed ring recording whether an envelope dissemination was already accepted for a - /// slot of this `MessageId` (SIP-94 §7 first-valid dedup: one dissemination per - /// (`MessageId`, slot), signer-independent). Allocated on first record: only - /// `Role::EnvelopeProposer` message IDs ever populate it. - disseminated_slots: Vec>, } impl DutyState { @@ -66,30 +61,6 @@ impl DutyState { Self { operators: HashMap::new(), stored_slot_count, - disseminated_slots: Vec::new(), - } - } - - /// True if a dissemination was already recorded for `slot` (SIP-94 §7: further - /// dissemination messages for the tuple are Ignore, regardless of content or peer). - pub(crate) fn is_dissemination_recorded(&self, slot: Slot) -> bool { - !self.disseminated_slots.is_empty() - && self.disseminated_slots[slot.as_usize() % self.disseminated_slots.len()] - == Some(slot) - } - - /// Records the accepted dissemination for `slot` and creates the sender's signer state so - /// the duty counts toward `signer`'s per-epoch ring occupancy. - pub(crate) fn record_dissemination(&mut self, slot: Slot, signer: &OperatorId) { - if self.disseminated_slots.is_empty() { - self.disseminated_slots = vec![None; self.stored_slot_count]; - } - let index = slot.as_usize() % self.disseminated_slots.len(); - self.disseminated_slots[index] = Some(slot); - - let operator_state = self.get_or_create_operator(signer); - if operator_state.is_first_message_for_duty(slot) { - operator_state.set_signer_state(&slot, SignerState::new(slot, FIRST_ROUND)); } } @@ -384,8 +355,7 @@ impl SignerState { | PartialSignatureKind::ValidatorRegistration | PartialSignatureKind::VoluntaryExit | PartialSignatureKind::AggregatorCommitteePartialSig - | PartialSignatureKind::PTCAttester - | PartialSignatureKind::Envelope => None, + | PartialSignatureKind::PTCAttester => None, } } diff --git a/anchor/message_validator/src/lib.rs b/anchor/message_validator/src/lib.rs index 31012d856..89e34d4ec 100644 --- a/anchor/message_validator/src/lib.rs +++ b/anchor/message_validator/src/lib.rs @@ -1,5 +1,4 @@ mod consensus_message; -mod dissemination; mod duty_state; mod message_counts; mod partial_signature; @@ -28,7 +27,6 @@ use slot_clock::SlotClock; use ssv_types::{ CommitteeInfo, IndexSet, OperatorId, ValidatorIndex, consensus::QbftMessage, - dissemination::EnvelopeDissemination, message::{MsgType, SSVMessageError, SignedSSVMessage, SignedSSVMessageError}, msgid::{DutyExecutor, MessageId, Role}, partial_sig::PartialSignatureMessages, @@ -42,7 +40,6 @@ use types::{ChainSpec, Epoch, ForkName, Slot}; use crate::{ consensus_message::validate_consensus_message, - dissemination::validate_envelope_dissemination, duty_state::{DutyState, OperatorState}, partial_signature::validate_partial_signature_message, }; @@ -253,16 +250,6 @@ pub enum ValidationFailure { current_fork: ForkName, deprecated_since_fork: ForkName, }, - /// An envelope dissemination message for a role other than `EnvelopeProposer`, the only - /// role that admits the class (SIP-94 §7). Reject-class. - UnexpectedDisseminationMessage { - role: Role, - }, - /// A dissemination message with a signer count other than exactly one. Reject-class. - DisseminationOneSigner, - /// A dissemination message whose inner envelope bytes do not SSZ-decode as a blinded - /// execution payload envelope (SIP-94 §7). Reject-class. - UndecodableDisseminationEnvelope(DecodeError), } impl From<&ValidationFailure> for MessageAcceptance { @@ -337,7 +324,6 @@ impl From for ValidationFailure { pub enum ValidatedSSVMessage { QbftMessage(QbftMessage), PartialSignatureMessages(PartialSignatureMessages), - EnvelopeDissemination(EnvelopeDissemination), } #[derive(Debug)] @@ -535,8 +521,7 @@ impl Validator { | Role::ValidatorRegistration | Role::VoluntaryExit | Role::PTCAttester - | Role::ProposerPreferences - | Role::EnvelopeProposer => { + | Role::ProposerPreferences => { let validator_pk = match ssv_message.msg_id().duty_executor() { Some(DutyExecutor::Validator(pk)) => pk, _ => return Err(ValidationFailure::UnknownValidator), @@ -787,7 +772,6 @@ pub(crate) fn stored_slot_count(role: Role, slots_per_epoch: u64, spec: &ChainSp | Role::ValidatorRegistration | Role::VoluntaryExit | Role::PTCAttester - | Role::EnvelopeProposer | Role::AggregatorCommittee => 2 * slots_per_epoch + LATE_SLOT_ALLOWANCE + 1, }; count as usize @@ -807,9 +791,6 @@ fn validate_ssv_message( MsgType::SSVPartialSignatureMsgType => { validate_partial_signature_message(validation_context, duty_state, duty_provider) } - MsgType::SSVEnvelopeDisseminationMsgType => { - validate_envelope_dissemination(validation_context, duty_state, duty_provider) - } } } @@ -847,9 +828,7 @@ fn verify_message_signature( } } -/// Looks up `signer`'s RSA key and verifies the message's first signature against it, the -/// shared tail of the single-signer validation paths (partial signatures and envelope -/// disseminations). +/// Looks up `signer`'s RSA key and verifies the partial signature packet's RSA signature. pub(crate) fn verify_single_signer( validation_context: &ValidationContext, signer: OperatorId, @@ -959,8 +938,8 @@ pub(crate) fn validate_beacon_duty( // Unknown proposer schedules are tolerated. Preferences also tolerate a conflicting negative // when the local duty producer currently assigns this validator and slot, so reception can - // support its signing work. Envelopes retain the complete tracker's assignment policy. - if matches!(role, Role::ProposerPreferences | Role::EnvelopeProposer) { + // support its signing work. + if role == Role::ProposerPreferences { let validator_pubkey = match validation_context .signed_ssv_message .ssv_message() @@ -973,8 +952,7 @@ pub(crate) fn validate_beacon_duty( if duty_provider.proposer_assignment_at_slot(slot, &validator_pubkey) == DutyAssignment::NotAssigned - && !(role == Role::ProposerPreferences - && duty_provider.local_proposer_assignment_at_slot(slot, &validator_pubkey)) + && !duty_provider.local_proposer_assignment_at_slot(slot, &validator_pubkey) { return Err(ValidationFailure::NoDuty); } @@ -1012,7 +990,6 @@ pub(crate) fn validate_beacon_duty( /// - PTCAttester before the Ethereum Gloas (ePBS) fork (not yet active) /// - ValidatorRegistration at/after the Ethereum Gloas (ePBS) fork (deprecated by SIP-94) /// - ProposerPreferences before the Ethereum Gloas (ePBS) fork (not yet active) -/// - EnvelopeProposer before the Ethereum Gloas (ePBS) fork (not yet active) pub(crate) fn validate_role_for_fork( slot: Slot, validation_context: &ValidationContext, @@ -1055,12 +1032,9 @@ pub(crate) fn validate_role_for_fork( } } - // Reject post-Gloas roles (PTCAttester, ProposerPreferences, EnvelopeProposer) before the + // Reject post-Gloas roles (PTCAttester, ProposerPreferences) before the // Ethereum Gloas (ePBS) fork, read from the consensus spec. - if matches!( - role, - Role::PTCAttester | Role::ProposerPreferences | Role::EnvelopeProposer - ) { + if matches!(role, Role::PTCAttester | Role::ProposerPreferences) { let current_fork = validation_context.spec.fork_name_at_epoch(epoch); if !current_fork.gloas_enabled() { return Err(ValidationFailure::RoleNotActiveBeforeEthFork { @@ -1149,9 +1123,7 @@ fn message_lateness( validation_context: &ValidationContext, ) -> Result { let ttl = match validation_context.role { - Role::Proposer | Role::SyncCommittee | Role::PTCAttester | Role::EnvelopeProposer => { - 1 + LATE_SLOT_ALLOWANCE - } + Role::Proposer | Role::SyncCommittee | Role::PTCAttester => 1 + LATE_SLOT_ALLOWANCE, Role::Committee | Role::Aggregator | Role::ValidatorRegistration @@ -1272,14 +1244,11 @@ fn duty_limit( } // Proposer and SyncCommittee have no duty limit Role::Proposer | Role::SyncCommittee => Ok(None), - // Per-proposal-slot roles: max duties capped at SLOTS_PER_EPOCH (one preferences packet / - // one self-build envelope per proposal slot). Overflow is IGNORE-classified. Both + // Preferences duties are capped at SLOTS_PER_EPOCH. Overflow is IGNORE-classified. Both // ProposerPreferences kinds (preferences and request-auth) share each proposal slot's // single ring entry, so kind-9 packets add no distinct slots beyond kind-8's; this is // the stricter reading of SIP-94 §7's "type-9 messages ride existing duty slots". - Role::ProposerPreferences | Role::EnvelopeProposer => { - Ok(Some(validation_context.slots_per_epoch)) - } + Role::ProposerPreferences => Ok(Some(validation_context.slots_per_epoch)), } } @@ -1423,10 +1392,6 @@ mod tests { for (msg_type, role) in [ (MsgType::SSVConsensusMsgType, Role::Committee), (MsgType::SSVPartialSignatureMsgType, Role::Proposer), - ( - MsgType::SSVEnvelopeDisseminationMsgType, - Role::EnvelopeProposer, - ), ] { let signed_message = signed_test_message(msg_type, create_message_id_for_test(role), vec![0x01]); @@ -1710,8 +1675,7 @@ mod tests { | Role::ValidatorRegistration | Role::VoluntaryExit | Role::PTCAttester - | Role::ProposerPreferences - | Role::EnvelopeProposer => DutyExecutor::Validator(PublicKeyBytes::empty()), + | Role::ProposerPreferences => DutyExecutor::Validator(PublicKeyBytes::empty()), }; MessageId::new(&domain, role, &duty_executor) } @@ -1763,7 +1727,7 @@ mod tests { /// behavior. pub(crate) validator_is_proposer: bool, /// Value returned by `proposer_assignment_at_slot`, the pubkey-keyed - /// lookup used by the `ProposerPreferences` / `EnvelopeProposer` arm. + /// lookup used by the `ProposerPreferences` arm. /// `DutyAssignment::Assigned` = assigned proposer at the slot, /// `DutyAssignment::NotAssigned` = a fetched epoch proves the pubkey is /// not the proposer at the slot, `DutyAssignment::Unknown` = the slot's diff --git a/anchor/message_validator/src/message_counts.rs b/anchor/message_validator/src/message_counts.rs index ac5752d1f..d559ccca7 100644 --- a/anchor/message_validator/src/message_counts.rs +++ b/anchor/message_validator/src/message_counts.rs @@ -67,8 +67,7 @@ impl MessageCounts { | PartialSignatureKind::ValidatorRegistration | PartialSignatureKind::VoluntaryExit | PartialSignatureKind::AggregatorCommitteePartialSig - | PartialSignatureKind::PTCAttester - | PartialSignatureKind::Envelope => { + | PartialSignatureKind::PTCAttester => { if self.pre_consensus >= MAX_MESSAGES_PER_ROUND { return Err(ValidationFailure::InvalidPartialSignatureTypeCount { got: format!("pre-consensus, having {self:?}"), @@ -115,8 +114,7 @@ impl MessageCounts { | PartialSignatureKind::ValidatorRegistration | PartialSignatureKind::VoluntaryExit | PartialSignatureKind::AggregatorCommitteePartialSig - | PartialSignatureKind::PTCAttester - | PartialSignatureKind::Envelope => self.pre_consensus += 1, + | PartialSignatureKind::PTCAttester => self.pre_consensus += 1, PartialSignatureKind::PostConsensus => self.post_consensus += 1, // ProposerPreferences and RequestAuth are tracked per signing-root on // `SignerState`, not via a shared counter. diff --git a/anchor/message_validator/src/partial_signature.rs b/anchor/message_validator/src/partial_signature.rs index 6be93ae2e..fa09442cd 100644 --- a/anchor/message_validator/src/partial_signature.rs +++ b/anchor/message_validator/src/partial_signature.rs @@ -111,12 +111,12 @@ fn validate_partial_signature_message_semantics( return Err(ValidationFailure::InconsistentSigners); } - // Rule: a multi-entry RequestAuth packet names one validator (SIP-94 §5: every auth root of a - // packet belongs to the same duty and validator). This is a packet-internal structural check, - // so it runs before the membership loop below: that loop depends on the local validator view - // and maps to the Ignore-class `ValidatorIndexMismatch`, which must not mask a malformed - // packet as a local-metadata gap. - if is_request_auth_batch_role_kind(validation_context.role, partial_signature_messages.kind) + // Multi-entry validator packets must name one validator, independently of the local + // membership view. Check this before metadata-dependent index validation. + let same_validator = + is_request_auth_batch_role_kind(validation_context.role, partial_signature_messages.kind) + || is_gloas_proposer_post(validation_context, partial_signature_messages); + if same_validator && partial_signature_messages.messages.iter().any(|message| { message.validator_index != partial_signature_messages.messages[0].validator_index }) @@ -155,7 +155,6 @@ fn partial_signature_type_matches_role(kind: PartialSignatureKind, role: Role) - kind == PartialSignatureKind::ProposerPreferences || kind == PartialSignatureKind::RequestAuth } - Role::EnvelopeProposer => kind == PartialSignatureKind::Envelope, Role::Aggregator => { kind == PartialSignatureKind::PostConsensus || kind == PartialSignatureKind::SelectionProofPartialSig @@ -177,14 +176,23 @@ fn partial_signature_type_matches_role(kind: PartialSignatureKind, role: Role) - } } -/// The one validator-scoped (role, kind) pair whose packets may carry several entries: -/// `RequestAuth` on `Role::ProposerPreferences`, one entry per configured builder entry (the -/// proposed SIP-94 §5/§7 amendment). Every other validator-scoped packet, including -/// `ProposerPreferences` on the same role, keeps the one-entry rule. +/// Builder authorization packets contain one entry per configured builder. fn is_request_auth_batch_role_kind(role: Role, kind: PartialSignatureKind) -> bool { role == Role::ProposerPreferences && kind == PartialSignatureKind::RequestAuth } +fn is_gloas_proposer_post( + context: &ValidationContext, + messages: &PartialSignatureMessages, +) -> bool { + context.role == Role::Proposer + && messages.kind == PartialSignatureKind::PostConsensus + && context + .spec + .fork_name_at_epoch(messages.slot.epoch(context.slots_per_epoch)) + .gloas_enabled() +} + /// Validates partial signature messages based on duty logic. fn validate_partial_sig_messages_by_duty_logic( validation_context: &ValidationContext, @@ -353,13 +361,24 @@ fn validate_partial_sig_messages_by_duty_logic( }); } } - // Per-validator roles only allow one signature + Role::Proposer => { + let limit = if is_gloas_proposer_post(validation_context, partial_signature_messages) { + 2 + } else { + 1 + }; + if message_count > limit { + return Err(ValidationFailure::TooManyPartialSignatureMessages { + got: message_count, + limit, + }); + } + } + // Other per-validator duties keep singleton packets. Role::Aggregator - | Role::Proposer | Role::ValidatorRegistration | Role::VoluntaryExit - | Role::PTCAttester - | Role::EnvelopeProposer => { + | Role::PTCAttester => { if message_count > 1 { return Err(ValidationFailure::TooManyPartialSignatureMessages { got: message_count, @@ -520,6 +539,230 @@ mod tests { } } + fn signed_proposer_test_packet( + role: Role, + kind: PartialSignatureKind, + private_key: &Rsa, + entries: &[(u8, OperatorId, ValidatorIndex)], + ) -> SignedSSVMessage { + let messages = PartialSignatureMessages { + kind, + slot: Slot::new(0), + messages: VariableList::new( + entries + .iter() + .map(|(root, signer, index)| PartialSignatureMessage { + partial_signature: Signature::empty(), + signing_root: Hash256::repeat_byte(*root), + signer: *signer, + validator_index: *index, + }) + .collect(), + ) + .unwrap(), + }; + let message = SSVMessage::new( + MsgType::SSVPartialSignatureMsgType, + create_message_id_for_test(role), + messages.as_ssz_bytes(), + ) + .unwrap(); + let key = PKey::from_rsa(private_key.clone()).unwrap(); + let mut signer = Signer::new(MessageDigest::sha256(), &key).unwrap(); + signer.update(&message.as_ssz_bytes()).unwrap(); + SignedSSVMessage::new( + vec![signer.sign_to_vec().unwrap().try_into().unwrap()], + vec![OperatorId(1)], + message, + vec![], + ) + .unwrap() + } + + #[test] + fn gloas_proposer_pair_is_order_independent_and_uses_one_packet_budget() { + for roots in [[1, 2], [2, 1]] { + // Arrange: one authenticated proposer packet with a block and envelope entry. + let (committee, private_key, keys) = four_node_committee_and_keypair(); + let entries = roots.map(|root| (root, OperatorId(1), ValidatorIndex(0))); + let message = signed_proposer_test_packet( + Role::Proposer, + PartialSignatureKind::PostConsensus, + &private_key, + &entries, + ); + let mut state = DutyState::new(2 * SLOTS_PER_EPOCH_TEST as usize); + let validate = |state: &mut DutyState| { + let mut context = create_test_validation_context_with_fork( + &message, + &committee, + Role::Proposer, + &keys, + None, + ); + context.spec = spec_with_gloas(Some(0)); + validate_partial_signature_message( + context, + state, + Arc::new(MockDutiesProvider::default()), + ) + }; + + // Act: admit the pair, then try to send another packet in the same round. + let first = validate(&mut state); + let repeated = validate(&mut state); + + // Assert: entry order is irrelevant, and two entries spend only one packet allowance. + assert!(first.is_ok(), "pair order {roots:?}: {first:?}"); + assert!(matches!( + repeated, + Err(ValidationFailure::InvalidPartialSignatureTypeCount { .. }) + )); + } + } + + #[test] + fn proposer_two_entry_allowance_is_gloas_post_consensus_only() { + for (role, kind, gloas, count, accepted) in [ + ( + Role::Proposer, + PartialSignatureKind::PostConsensus, + true, + 1, + true, + ), + ( + Role::Proposer, + PartialSignatureKind::PostConsensus, + true, + 2, + true, + ), + ( + Role::Proposer, + PartialSignatureKind::PostConsensus, + true, + 3, + false, + ), + ( + Role::Proposer, + PartialSignatureKind::PostConsensus, + false, + 1, + true, + ), + ( + Role::Proposer, + PartialSignatureKind::PostConsensus, + false, + 2, + false, + ), + ( + Role::Proposer, + PartialSignatureKind::RandaoPartialSig, + true, + 1, + true, + ), + ( + Role::Proposer, + PartialSignatureKind::RandaoPartialSig, + true, + 2, + false, + ), + ( + Role::Aggregator, + PartialSignatureKind::PostConsensus, + true, + 2, + false, + ), + ] { + // Arrange: hold membership and signing fixed while varying the fork/kind/count. + let (committee, private_key, keys) = four_node_committee_and_keypair(); + let entries = (1..=count) + .map(|root| (root, OperatorId(1), ValidatorIndex(0))) + .collect::>(); + let message = signed_proposer_test_packet(role, kind, &private_key, &entries); + let mut context = + create_test_validation_context_with_fork(&message, &committee, role, &keys, None); + context.spec = spec_with_gloas(gloas.then_some(0)); + + // Act: use the complete admission path, including RSA verification and duty checks. + let result = validate_partial_signature_message( + context, + &mut DutyState::new(2 * SLOTS_PER_EPOCH_TEST as usize), + Arc::new(MockDutiesProvider::default()), + ); + + // Assert: the allowance cannot leak into RANDAO, other duties or pre-Gloas slots. + if accepted { + assert!( + result.is_ok(), + "{role:?}/{kind:?}/{gloas}/{count}: {result:?}" + ); + } else { + assert!( + matches!( + result, + Err(ValidationFailure::TooManyPartialSignatureMessages { .. }) + ), + "{result:?}" + ); + } + } + } + + #[test] + fn gloas_proposer_pair_rejects_inconsistent_signer_or_validator() { + for different_signer in [false, true] { + // Arrange: both entries belong to a real committee, but disagree internally. + let (committee, private_key, keys) = four_node_committee_and_keypair(); + let second = if different_signer { + (2, OperatorId(2), ValidatorIndex(0)) + } else { + (2, OperatorId(1), ValidatorIndex(1)) + }; + let message = signed_proposer_test_packet( + Role::Proposer, + PartialSignatureKind::PostConsensus, + &private_key, + &[(1, OperatorId(1), ValidatorIndex(0)), second], + ); + let mut context = create_test_validation_context_with_fork( + &message, + &committee, + Role::Proposer, + &keys, + None, + ); + context.spec = spec_with_gloas(Some(0)); + + // Act: structural validation must precede locally known index membership. + let result = validate_partial_signature_message( + context, + &mut DutyState::new(2 * SLOTS_PER_EPOCH_TEST as usize), + Arc::new(MockDutiesProvider::default()), + ); + + // Assert: neither inconsistency can be admitted or hidden as a missing local duty. + if different_signer { + assert!(matches!( + result, + Err(ValidationFailure::InconsistentSigners) + )); + } else { + assert!( + matches!(result, Err(ValidationFailure::InconsistentValidatorIndices)), + "{result:?}" + ); + } + } + } + #[test] fn test_aggregator_committee_message_count_small_committee() { // Small committee (V ≤ 512): min(5*V, V + 4*512) = 5*V @@ -2306,254 +2549,6 @@ mod tests { )); } - // ==================== EnvelopeProposer partial-signature tests ==================== - // - // `EnvelopeProposer` (wire byte [9,0,0,0]) is the validator-scoped self-build role: - // binds the `Envelope` kind, gated to the Ethereum Gloas (ePBS) fork, caps its packet at - // one message, and uses the SHORT `1 + LATE_SLOT_ALLOWANCE` (3-slot) lateness bucket. - - /// Helper to create a SignedSSVMessage for EnvelopeProposer testing. - fn create_signed_envelope_proposer_message( - signer_id: OperatorId, - private_key: &Rsa, - slot: Slot, - signing_root: Hash256, - ) -> SignedSSVMessage { - let partial_sig_messages = PartialSignatureMessages { - kind: PartialSignatureKind::Envelope, - slot, - messages: VariableList::new(vec![PartialSignatureMessage { - partial_signature: Signature::empty(), - signing_root, - signer: signer_id, - // ValidatorIndex(0) is in the test committee's validator_indices. - validator_index: ValidatorIndex(0), - }]) - .unwrap(), - }; - - let msg_id = create_message_id_for_test(Role::EnvelopeProposer); - let ssv_msg = SSVMessage::new( - MsgType::SSVPartialSignatureMsgType, - msg_id, - partial_sig_messages.as_ssz_bytes(), - ) - .unwrap(); - - let p_key = PKey::from_rsa(private_key.clone()).unwrap(); - let mut signer = Signer::new(MessageDigest::sha256(), &p_key).unwrap(); - signer.update(&ssv_msg.as_ssz_bytes()).unwrap(); - let signature = signer.sign_to_vec().unwrap().try_into().unwrap(); - - SignedSSVMessage::new(vec![signature], vec![signer_id], ssv_msg, vec![]).unwrap() - } - - /// Helper to create a ValidationContext for EnvelopeProposer testing. - fn create_envelope_proposer_context<'a>( - signed_msg: &'a SignedSSVMessage, - committee_info: &'a crate::CommitteeInfo, - operator_pub_keys: &'a HashMap>, - current_slot: Slot, - ) -> ValidationContext<'a, ManualSlotClock> { - let now = SystemTime::now(); - let slot_clock = ManualSlotClock::new( - current_slot, - now.duration_since(UNIX_EPOCH).unwrap(), - Duration::from_secs(12), - ); - - ValidationContext { - signed_ssv_message: signed_msg, - committee_info, - role: Role::EnvelopeProposer, - received_at: now, - slots_per_epoch: SLOTS_PER_EPOCH_TEST, - epochs_per_sync_committee_period: 256, - sync_committee_size: 512, - slot_clock, - operator_pub_keys, - fork_schedule: generate_fork_schedule(Fork::Boole), - // EnvelopeProposer only exists post-Gloas; activate from epoch 0. - spec: spec_with_gloas(Some(0)), - } - } - - /// `EnvelopeProposer` only accepts the `Envelope` kind (the Gloas fork gate is - /// covered once in `consensus_message.rs` via the shared `validate_role_for_fork`). - /// Asserts that this kind mismatch maps to - /// `ValidationFailure::PartialSignatureTypeRoleMismatch` and is rejected (not ignored). - #[test] - fn envelope_proposer_binds_envelope_kind() { - assert!( - partial_signature_type_matches_role( - PartialSignatureKind::Envelope, - Role::EnvelopeProposer, - ), - "EnvelopeProposer must accept the Envelope kind" - ); - assert_eq!( - MessageAcceptance::from(&ValidationFailure::PartialSignatureTypeRoleMismatch), - MessageAcceptance::Reject, - "kind mismatch must be Reject", - ); - assert!( - !partial_signature_type_matches_role( - PartialSignatureKind::PostConsensus, - Role::EnvelopeProposer, - ), - "EnvelopeProposer must reject non-Envelope kinds, including PostConsensus" - ); - } - - #[test] - fn envelope_proposer_rejects_multiple_messages_per_packet() { - // EnvelopeProposer is validator-scoped: exactly one Envelope message per packet. - // The per-validator `> 1` bound rejects a second message, which also subsumes the - // validator-index occurrence cap (two messages for the same index would already trip - // `> 1`). - let (committee_info, private_key, map) = four_node_committee_and_keypair(); - - // ValidatorIndex(123) is in the test committee's validator_indices, so each message - // passes the per-validator index check; the second message then trips the `> 1` bound. - let messages: Vec<_> = (0..2) - .map(|_| PartialSignatureMessage { - partial_signature: Signature::empty(), - signing_root: Hash256::from([0u8; 32]), - signer: OperatorId(1), - validator_index: ValidatorIndex(123), - }) - .collect(); - - let partial_sig_messages = PartialSignatureMessages { - kind: PartialSignatureKind::Envelope, - slot: Slot::new(1), - messages: VariableList::new(messages).unwrap(), - }; - - let msg_id = create_message_id_for_test(Role::EnvelopeProposer); - let ssv_msg = SSVMessage::new( - MsgType::SSVPartialSignatureMsgType, - msg_id, - partial_sig_messages.as_ssz_bytes(), - ) - .unwrap(); - - let p_key = PKey::from_rsa(private_key).unwrap(); - let mut signer = Signer::new(MessageDigest::sha256(), &p_key).unwrap(); - signer.update(&ssv_msg.as_ssz_bytes()).unwrap(); - let signature = signer.sign_to_vec().unwrap().try_into().unwrap(); - - let signed_msg = - SignedSSVMessage::new(vec![signature], vec![OperatorId(1)], ssv_msg, vec![]).unwrap(); - - let validation_context = - create_envelope_proposer_context(&signed_msg, &committee_info, &map, Slot::new(1)); - - let result = validate_partial_signature_message( - validation_context, - &mut DutyState::new(64), - Arc::new(MockDutiesProvider { - voluntary_exit_duty_count: 0, - ..Default::default() - }), - ); - - assert_validation_error( - result, - |failure| { - matches!( - failure, - ValidationFailure::TooManyPartialSignatureMessages { limit: 1, .. } - ) - }, - "TooManyPartialSignatureMessages (EnvelopeProposer cap 1 per packet)", - ); - - // Check that more than 1 partial signature message in a packet is rejected and not ignored. - assert_eq!( - MessageAcceptance::from(&ValidationFailure::TooManyPartialSignatureMessages { - got: 2, - limit: 1 - }), - MessageAcceptance::Reject, - "packet-count overflow must be Reject", - ); - } - - #[test] - fn envelope_proposer_within_ttl_accepted() { - let (committee_info, private_key, map) = four_node_committee_and_keypair(); - let signed_msg = create_signed_partial_sig_message( - Role::EnvelopeProposer, - PartialSignatureKind::Envelope, - OperatorId(1), - &private_key, - ); - - // `EnvelopeProposer` uses the SHORT slot-bound TTL (`1 + LATE_SLOT_ALLOWANCE` = 3 slots); - // two slots late is inside it. `create_ttl_validation_context` sets a pre-Gloas spec, so - // override it (the role only exists post-Gloas). - let mut validation_context = create_ttl_validation_context( - &signed_msg, - &committee_info, - Role::EnvelopeProposer, - &map, - LATE_SLOT_ALLOWANCE_TEST, - generate_fork_schedule(Fork::Boole), - ); - validation_context.spec = spec_with_gloas(Some(0)); - - let result = validate_partial_signature_message( - validation_context, - &mut DutyState::new(64), - Arc::new(MockDutiesProvider { - voluntary_exit_duty_count: 0, - ..Default::default() - }), - ); - - assert!(result.is_ok(), "Expected ok but got: {result:?}"); - } - - #[test] - fn envelope_proposer_beyond_short_ttl_rejected() { - let (committee_info, private_key, map) = four_node_committee_and_keypair(); - let signed_msg = create_signed_partial_sig_message( - Role::EnvelopeProposer, - PartialSignatureKind::Envelope, - OperatorId(1), - &private_key, - ); - - // 20 slots late is still inside the long (committee) TTL of 34 slots; rejecting it pins - // `EnvelopeProposer` to the short slot-bound bucket (`1 + LATE_SLOT_ALLOWANCE` = 3 slots). - // Override the helper's pre-Gloas spec (the role only exists post-Gloas). - let mut validation_context = create_ttl_validation_context( - &signed_msg, - &committee_info, - Role::EnvelopeProposer, - &map, - COMMITTEE_TTL_BUCKET_SLOTS, - generate_fork_schedule(Fork::Boole), - ); - validation_context.spec = spec_with_gloas(Some(0)); - - let result = validate_partial_signature_message( - validation_context, - &mut DutyState::new(64), - Arc::new(MockDutiesProvider { - voluntary_exit_duty_count: 0, - ..Default::default() - }), - ); - - assert_validation_error( - result, - |failure| matches!(failure, ValidationFailure::LateSlotMessage { .. }), - "LateSlotMessage (EnvelopeProposer past short TTL)", - ); - } - // ==================== ProposerPreferences tests ==================== // // ProposerPreferences (wire byte [8,0,0,0]) is validator-scoped and non-QBFT. @@ -3235,7 +3230,6 @@ mod tests { Role::VoluntaryExit, Role::AggregatorCommittee, Role::PTCAttester, - Role::EnvelopeProposer, ] { for (earliness, accepted) in [ (Duration::from_millis(50), true), @@ -3382,37 +3376,6 @@ mod tests { ); } - #[test] - fn envelope_proposer_future_slot_still_early() { - // `EnvelopeProposer` message slot is its PRESENT emission slot (no future-slot allowance). - // This case is rejected. - - // Creates `EnvelopeProposer` packet with an envelope slot of 1. - let (committee_info, private_key, map) = four_node_committee_and_keypair(); - let signed_msg = create_signed_envelope_proposer_message( - OperatorId(1), - &private_key, - Slot::new(1), - Hash256::from([0x44; 32]), - ); - // Current slot set to 0. Envelope slot 1 = one slot (12s) in the future. - let ctx = - create_envelope_proposer_context(&signed_msg, &committee_info, &map, Slot::new(0)); - - // Validate the message and raise the error. - let result = validate_partial_signature_message( - ctx, - &mut DutyState::new(64), - Arc::new(MockDutiesProvider::default()), - ); - - assert_validation_error( - result, - |failure| matches!(failure, ValidationFailure::EarlySlotMessage { .. }), - "EarlySlotMessage (EnvelopeProposer has no future-slot allowance. One slot ahead is early)", - ); - } - // ============ ProposerPreferences dedup criteria (#1131, #1254) ============ // // Each test below pins one branch of the classification in @@ -3836,7 +3799,6 @@ mod tests { Role::AggregatorCommittee, Role::PTCAttester, Role::ProposerPreferences, - Role::EnvelopeProposer, ]; // Compile-time guard tied to `all_roles` above: adding a `Role` variant breaks this // match, forcing the array (and thus the sweep) to be extended rather than silently @@ -3851,8 +3813,7 @@ mod tests { | Role::VoluntaryExit | Role::AggregatorCommittee | Role::PTCAttester - | Role::ProposerPreferences - | Role::EnvelopeProposer => {} + | Role::ProposerPreferences => {} } } for role in all_roles { @@ -5106,64 +5067,6 @@ mod tests { ); } - /// Runs the full `EnvelopeProposer` (role 9) partial-signature pipeline, driving - /// `proposer_assignment_at_slot` DIRECTLY with `proposer_assignment` and allowing the caller - /// to supply the `committee_info`. Mirrors `run_proposer_preferences_with_assignment` for the - /// shared `Role::ProposerPreferences | Role::EnvelopeProposer` arm, which is keyed on the - /// message-id validator PUBKEY and does not consult `committee_info.validator_indices`. - fn run_envelope_proposer_with_assignment( - committee_info: crate::CommitteeInfo, - proposer_assignment: DutyAssignment, - ) -> Result { - let (_, private_key, map) = four_node_committee_and_keypair(); - let signed_msg = create_signed_envelope_proposer_message( - OperatorId(1), - &private_key, - Slot::new(0), - Hash256::from([0x33; 32]), - ); - let validation_context = - create_envelope_proposer_context(&signed_msg, &committee_info, &map, Slot::new(0)); - - validate_partial_signature_message( - validation_context, - &mut DutyState::new(64), - Arc::new(MockDutiesProvider { - proposer_assignment, - ..Default::default() - }), - ) - } - - #[test] - fn test_envelope_proposer_assigned_pubkey_accepted_with_unresolved_local_index() { - // #1147: the shared `Role::ProposerPreferences | Role::EnvelopeProposer` arm is keyed on - // the message-id validator PUBKEY via `proposer_assignment_at_slot`, and no longer reads - // `committee_info.validator_indices`. A locally-unresolved validator index (empty - // `validator_indices`) must therefore NOT block an otherwise-assigned EnvelopeProposer - // (role 9) through the FULL partial-signature pipeline. The old index-based path would - // have failed to find a validator index on empty indices and rejected (UnexpectedFailure). - - // Arrange: reuse the consistent role-9 committee + signing key, but override to NO resolved - // local validator indices, and a mock reporting the pubkey IS the assigned proposer. - let (committee_info, _, _) = four_node_committee_and_keypair(); - let committee_info = crate::CommitteeInfo { - committee_members: committee_info.committee_members, - validator_indices: vec![], - }; - - // Act - let result = - run_envelope_proposer_with_assignment(committee_info, DutyAssignment::Assigned); - - // Assert: accepted purely on the pubkey-keyed assignment, index resolution irrelevant. - assert!( - result.is_ok(), - "Expected assigned pubkey to be accepted despite an unresolved local validator \ - index, got: {result:?}" - ); - } - #[test] fn test_proposer_preferences_assignment_some_true_accepted() { // #1142: `proposer_assignment_at_slot` == `Assigned` (assigned proposer) -> accepted. @@ -5270,40 +5173,6 @@ mod tests { } } - #[test] - fn test_local_proposer_positive_does_not_override_envelope_assignment() { - // Arrange: an otherwise-valid envelope has positive producer evidence but a negative - // tracker. - let (committee, private_key, keys) = four_node_committee_and_keypair(); - let slot = Slot::new(0); - let message = create_signed_envelope_proposer_message( - OperatorId(1), - &private_key, - slot, - Hash256::repeat_byte(0x33), - ); - let context = create_envelope_proposer_context(&message, &committee, &keys, slot); - - // Act: use the full envelope partial-signature pipeline with the new evidence active. - let result = validate_partial_signature_message( - context, - &mut DutyState::new(2 * SLOTS_PER_EPOCH_TEST as usize), - Arc::new(MockDutiesProvider { - proposer_assignment: DutyAssignment::NotAssigned, - local_proposer_assignment: true, - ..Default::default() - }), - ); - - // Assert: the preference exception must not admit envelopes or change peer scoring. - let failure = result.unwrap_err(); - assert!(matches!(failure, ValidationFailure::NoDuty)); - assert!(matches!( - MessageAcceptance::from(&failure), - MessageAcceptance::Ignore - )); - } - #[test] fn test_proposer_role_still_uses_index_path_not_pubkey_assignment() { // Regression pin for #1142: the INDEX-based `Role::Proposer` arm of `validate_beacon_duty` @@ -5615,225 +5484,6 @@ mod tests { ); } - // ==================== EnvelopeProposer proposer-assignment arm ==================== - // - // `validate_beacon_duty`'s `Role::ProposerPreferences | Role::EnvelopeProposer` arm (keyed on - // the message's `slot`) rejects with `NoDuty` only when the slot's epoch is known AND the - // validator is NOT the assigned proposer; an unknown epoch is tolerated and no RANDAO - // tolerance applies. - - /// Runs the `EnvelopeProposer` proposer-assignment arm of `validate_beacon_duty` with the - /// mock's two knobs, returning the result for the caller to assert on. `randao_msg` is always - /// false for `EnvelopeProposer` (no RANDAO tolerance applies). - fn run_envelope_beacon_duty( - epoch_known: bool, - is_proposer: bool, - ) -> Result<(), ValidationFailure> { - let (committee_info, private_key, map) = four_node_committee_and_keypair(); - let signed_msg = create_signed_envelope_proposer_message( - OperatorId(1), - &private_key, - Slot::new(0), - Hash256::from([0x33; 32]), - ); - let validation_context = - create_envelope_proposer_context(&signed_msg, &committee_info, &map, Slot::new(0)); - - validate_beacon_duty( - &validation_context, - Slot::new(0), - false, - Arc::new(MockDutiesProvider { - proposer_assignment: proposer_assignment_from_knobs(epoch_known, is_proposer), - ..Default::default() - }), - ) - } - - #[test] - fn envelope_proposer_tolerates_unknown_proposer_epoch() { - // Before the epoch's proposer duties are fetched, tolerate (Ok), not drop as NoDuty. - let result = run_envelope_beacon_duty(false, false); - assert!( - result.is_ok(), - "unknown proposer epoch must be tolerated for EnvelopeProposer, got: {result:?}" - ); - } - - #[test] - fn envelope_proposer_known_epoch_non_proposer_is_no_duty() { - // Known epoch, not the assigned proposer -> reject with NoDuty. - let result = run_envelope_beacon_duty(true, false); - assert_validation_error( - result, - |failure| matches!(failure, ValidationFailure::NoDuty), - "NoDuty (known epoch, validator not the assigned proposer)", - ); - } - - #[test] - fn envelope_proposer_known_epoch_proposer_ok() { - // Known epoch and the assigned proposer -> accept. - let result = run_envelope_beacon_duty(true, true); - assert!( - result.is_ok(), - "Expected assigned proposer to be accepted for EnvelopeProposer, got: {result:?}" - ); - } - - #[test] - fn envelope_proposer_partial_sig_accepted_at_disseminated_slot() { - // Envelope disseminations and Envelope partial sigs share one max_slot per signer (both - // create the signer state for a new slot). Record a dissemination at slot 1, then - // validate a same-slot Envelope partial. The guard is strict (`max_slot > message_slot`), - // so the `max_slot == message_slot` equality boundary must be tolerated (Ok): that is the - // builder's normal same-slot dissemination-then-share sequence. - let (committee_info, private_key, map) = four_node_committee_and_keypair(); - - // Arrange: Seed DutyState at slot 1 with the state effect the dissemination validator - // applies once all its rules pass (those rules are covered in `dissemination.rs`; what - // this test pins is the shared max_slot). - let mut duty_state = crate::duty_state::DutyState::new(64); - duty_state.record_dissemination(Slot::new(1), &OperatorId(1)); - - // Arrange: Envelope partial sig at slot 1 (equal to the disseminated slot). - let partial_sig_signed_msg = create_signed_envelope_proposer_message( - OperatorId(1), - &private_key, - Slot::new(1), - Hash256::from([0x33; 32]), - ); - let validation_context = create_envelope_proposer_context( - &partial_sig_signed_msg, - &committee_info, - &map, - Slot::new(1), - ); - - // Act: Validate the same-slot Envelope partial sig against the seeded DutyState. - let result = validate_partial_signature_message( - validation_context, - &mut duty_state, - Arc::new(MockDutiesProvider::default()), - ); - - // Assert: Must be accepted at the equality boundary. - assert!( - result.is_ok(), - "EnvelopeProposer Envelope partial at slot 1 must be accepted when the signer's disseminated max_slot already equals 1: the strict monotonic guard (max_slot > message_slot) must tolerate the max_slot == message_slot equality boundary, the builder's normal same-slot dissemination-then-share sequence, got: {result:?}" - ); - } - - #[test] - fn envelope_proposer_dissemination_advances_blocks_lower_partial_sig() { - // §7 monotonic-slot rule, dissemination-advances direction: a dissemination at slot 10 - // must block a later Envelope partial sig at the lower slot 5. - let (committee_info, private_key, map) = four_node_committee_and_keypair(); - - // Arrange: Seed DutyState at slot 10 via the dissemination validator's state effect. - let mut duty_state = crate::duty_state::DutyState::new(64); - duty_state.record_dissemination(Slot::new(10), &OperatorId(1)); - - // Arrange: Envelope partial sig at slot 5 (lower than 10). - let partial_sig_signed_msg = create_signed_envelope_proposer_message( - OperatorId(1), - &private_key, - Slot::new(5), - Hash256::from([0x33; 32]), - ); - let validation_context = create_envelope_proposer_context( - &partial_sig_signed_msg, - &committee_info, - &map, - Slot::new(10), - ); - - // Act: Validate Envelope partial sig at slot 5 against advanced DutyState. - let result = validate_partial_signature_message( - validation_context, - &mut duty_state, - Arc::new(MockDutiesProvider::default()), - ); - - // Assert: Must be rejected as SlotAlreadyAdvanced. - assert_validation_error( - result, - |failure| { - matches!( - failure, - crate::ValidationFailure::SlotAlreadyAdvanced { .. } - ) - }, - "EnvelopeProposer Envelope partial below the signer's disseminated slot must be SlotAlreadyAdvanced", - ); - } - - #[test] - fn envelope_proposer_repeated_envelope_partial_rejected() { - // Validate an EnvelopeProposer Envelope partial at slot 5 (accepted; records the - // pre-consensus count), then validate a second Envelope partial for the same - // signer/slot against the same DutyState. The shared pre-consensus seen-message - // guard must reject the second as InvalidPartialSignatureTypeCount. - - let (committee_info, private_key, map) = four_node_committee_and_keypair(); - - // Arrange: First Envelope partial at slot 5. - let first_signed_msg = create_signed_envelope_proposer_message( - OperatorId(1), - &private_key, - Slot::new(5), - Hash256::from([0x33; 32]), - ); - let first_context = create_envelope_proposer_context( - &first_signed_msg, - &committee_info, - &map, - Slot::new(5), - ); - - let mut duty_state = crate::duty_state::DutyState::new(64); - - let first_result = validate_partial_signature_message( - first_context, - &mut duty_state, - Arc::new(MockDutiesProvider::default()), - ); - assert!( - first_result.is_ok(), - "First EnvelopeProposer Envelope partial must be accepted" - ); - - // A second Envelope partial for the same signer/slot (only the root differs). - let second_signed_msg = create_signed_envelope_proposer_message( - OperatorId(1), - &private_key, - Slot::new(5), - Hash256::from([0x44; 32]), - ); - let second_context = create_envelope_proposer_context( - &second_signed_msg, - &committee_info, - &map, - Slot::new(5), - ); - let second_result = validate_partial_signature_message( - second_context, - &mut duty_state, - Arc::new(MockDutiesProvider::default()), - ); - - assert_validation_error( - second_result, - |failure| { - matches!( - failure, - crate::ValidationFailure::InvalidPartialSignatureTypeCount { .. } - ) - }, - "Repeated EnvelopeProposer Envelope partial for the same signer/slot must be rejected", - ); - } - // ==================== Aggregator duty-limit helpers ==================== /// Slot layout for the Aggregator duty-limit test: three distinct duty slots inside diff --git a/anchor/operator_doppelganger/src/service.rs b/anchor/operator_doppelganger/src/service.rs index dd6d58397..8f2446afc 100644 --- a/anchor/operator_doppelganger/src/service.rs +++ b/anchor/operator_doppelganger/src/service.rs @@ -18,7 +18,6 @@ fn extract_message_slot(validated_message: &ValidatedSSVMessage) -> Slot { match validated_message { ValidatedSSVMessage::QbftMessage(msg) => Slot::new(msg.height), ValidatedSSVMessage::PartialSignatureMessages(msg) => msg.slot, - ValidatedSSVMessage::EnvelopeDissemination(msg) => msg.slot, } } @@ -213,16 +212,6 @@ impl OperatorDoppelgangerService { Another instance of this operator is running. Shutting down to prevent equivocation." ); } - ValidatedSSVMessage::EnvelopeDissemination(_) => { - error!( - operator_id = *own_operator_id, - duty_executor = ?msg_id.duty_executor(), - msg_slot = msg_slot.as_u64(), - startup_slot = self.startup_slot.as_u64(), - "OPERATOR DOPPELGÄNGER DETECTED: Received envelope dissemination signed with our operator ID for slot after startup. \ - Another instance of this operator is running. Shutting down to prevent equivocation." - ); - } } true diff --git a/anchor/qbft_manager/src/lib.rs b/anchor/qbft_manager/src/lib.rs index e559ed967..bcbf7eaab 100644 --- a/anchor/qbft_manager/src/lib.rs +++ b/anchor/qbft_manager/src/lib.rs @@ -292,7 +292,7 @@ impl QbftManager { Some(Role::Committee | Role::AggregatorCommittee) // These roles don't use QBFT consensus | Some( - Role::ValidatorRegistration | Role::VoluntaryExit | Role::PTCAttester | Role::ProposerPreferences | Role::EnvelopeProposer, + Role::ValidatorRegistration | Role::VoluntaryExit | Role::PTCAttester | Role::ProposerPreferences, ) | None => { error!(?msg_id, "Unexpected role/executor combination in msg id"); @@ -360,7 +360,7 @@ impl QbftManager { Some(Role::Aggregator | Role::Proposer | Role::SyncCommittee) // These roles don't use QBFT consensus | Some( - Role::ValidatorRegistration | Role::VoluntaryExit | Role::PTCAttester | Role::ProposerPreferences | Role::EnvelopeProposer, + Role::ValidatorRegistration | Role::VoluntaryExit | Role::PTCAttester | Role::ProposerPreferences, ) | None => Err(QbftError::InconsistentMessageId), } diff --git a/anchor/signature_collector/src/lib.rs b/anchor/signature_collector/src/lib.rs index f262ff1d3..3ae300259 100644 --- a/anchor/signature_collector/src/lib.rs +++ b/anchor/signature_collector/src/lib.rs @@ -22,7 +22,6 @@ use ssv_types::typenum::Unsigned; pub use ssv_types::{ CommitteeId, OperatorId, ValidatorIndex, consensus::UnsignedSSVMessage, - dissemination::EnvelopeDissemination, domain_type::DomainType, message::{MsgType, SSVMessage, SSVMessageError}, msgid::{DutyExecutor, MessageId, Role}, @@ -769,39 +768,6 @@ impl SignatureCollectorManager { injection_messages } - /// Broadcasts an envelope dissemination for the builder operator (SIP-94 §6); see the - /// `SignatureCollecting` method of the same name. - fn broadcast_dissemination( - &self, - validator_pubkey: PublicKeyBytes, - committee_id: CommitteeId, - dissemination: EnvelopeDissemination, - ) -> Result<(), CollectionError> { - let domain = self.domain_type_for_slot(dissemination.slot); - let message_id = MessageId::new( - &domain, - Role::EnvelopeProposer, - &DutyExecutor::Validator(validator_pubkey), - ); - let ssv_message = SSVMessage::new( - MsgType::SSVEnvelopeDisseminationMsgType, - message_id, - dissemination.as_ssz_bytes(), - ) - .map_err(|err| CollectionError::DisseminationSendFailed(err.to_string()))?; - - self.message_sender - .sign_and_send( - UnsignedSSVMessage { - ssv_message, - full_data: vec![], - }, - committee_id, - None, - ) - .map_err(|err| CollectionError::DisseminationSendFailed(format!("{err:?}"))) - } - fn create_message( &self, metadata: &SignatureMetadata, @@ -1120,8 +1086,6 @@ pub enum CollectionError { InvalidProposerPacket, OwnOperatorIdUnknown, RecoverError(bls_lagrange::Error), - /// Building or sending an envelope dissemination failed (SIP-94 §6). - DisseminationSendFailed(String), } impl From for CollectionError { @@ -1173,15 +1137,6 @@ pub trait SignatureCollecting: Send + Sync { root: Hash256, required_companion: Option, ) -> Pin, CollectionError>> + Send + '_>>; - - /// Broadcasts an envelope dissemination for the builder operator (SIP-94 §6): the - /// operator-signed carrier every committee member validates and threshold-signs over. - fn broadcast_dissemination( - &self, - validator_pubkey: PublicKeyBytes, - committee_id: CommitteeId, - dissemination: EnvelopeDissemination, - ) -> Result<(), CollectionError>; } impl SignatureCollecting for Arc> { @@ -1228,20 +1183,6 @@ impl SignatureCollecting for Arc Result<(), CollectionError> { - SignatureCollectorManager::broadcast_dissemination( - self, - validator_pubkey, - committee_id, - dissemination, - ) - } } /// The actual signature collector task, waiting for messages. diff --git a/anchor/signature_collector/src/tests.rs b/anchor/signature_collector/src/tests.rs index bd6b886e0..1d7392ae8 100644 --- a/anchor/signature_collector/src/tests.rs +++ b/anchor/signature_collector/src/tests.rs @@ -1,5 +1,5 @@ use std::{ - collections::{BTreeMap, HashMap}, + collections::HashMap, sync::{ Arc, Condvar, LazyLock, Mutex as StdMutex, atomic::{AtomicUsize, Ordering}, @@ -26,7 +26,7 @@ use ssv_types::{ use ssz::Decode; use task_executor::test_utils::TestRuntime; use tokio::sync::{Mutex, oneshot}; -use types::{Epoch, Graffiti, SyncSubnetId}; +use types::{Graffiti, SyncSubnetId}; use super::*; @@ -334,13 +334,6 @@ impl BatchScenario { Self::new_with_max_workers(message_sender, 4) } - fn new_with_fork_schedule( - message_sender: Arc, - fork_schedule: Arc, - ) -> Self { - Self::new_with_max_workers_and_fork_schedule(message_sender, 4, fork_schedule) - } - fn new_with_max_workers(message_sender: Arc, max_workers: usize) -> Self { let fork_schedule = Arc::new(ForkSchedule::new(Fork::Alan, DomainType::default(), "test")); Self::new_with_max_workers_and_fork_schedule(message_sender, max_workers, fork_schedule) @@ -616,76 +609,6 @@ async fn register_manager_notifier( .expect("collector should remain active") } -#[test] -fn broadcast_dissemination_builds_fork_aware_wire_message() { - let alan_domain = DomainType([0, 0, 0, 1]); - let boole_domain = DomainType([0, 0, 0, 2]); - let mut fork_configs = BTreeMap::new(); - fork_configs.insert(Fork::Alan, (Epoch::new(0), alan_domain)); - fork_configs.insert(Fork::Boole, (Epoch::new(2), boole_domain)); - let fork_schedule = Arc::new( - ForkSchedule::from_fork_configs(fork_configs, "test") - .expect("two-fork schedule should be valid"), - ); - let sender = Arc::new(RecordingMessageSender::new(0)); - let scenario = BatchScenario::new_with_fork_schedule( - Arc::clone(&sender) as Arc, - fork_schedule, - ); - let cases = [ - ( - EnvelopeDissemination { - slot: Slot::new(63), - envelope: VariableList::new(vec![0xAA]) - .expect("Alan envelope should fit the wire payload"), - }, - alan_domain, - ), - ( - EnvelopeDissemination { - slot: Slot::new(64), - envelope: VariableList::new(vec![0xBB, 0xCC]) - .expect("Boole envelope should fit the wire payload"), - }, - boole_domain, - ), - ]; - - for (dissemination, _) in &cases { - scenario - .manager - .broadcast_dissemination( - scenario.validator_pubkey, - scenario.metadata.committee_id, - dissemination.clone(), - ) - .expect("dissemination should be broadcast"); - } - - assert_eq!(sender.attempts(), cases.len()); - let messages = sender.messages(); - assert_eq!(messages.len(), cases.len()); - for (message, (expected_dissemination, expected_domain)) in messages.iter().zip(&cases) { - assert_eq!( - message.ssv_message.msg_type(), - &MsgType::SSVEnvelopeDisseminationMsgType - ); - assert!(message.full_data.is_empty()); - - let message_id = message.ssv_message.msg_id(); - assert_eq!(message_id.domain(), *expected_domain); - assert_eq!(message_id.role(), Some(Role::EnvelopeProposer)); - assert_eq!( - message_id.duty_executor(), - Some(DutyExecutor::Validator(scenario.validator_pubkey)) - ); - - let decoded = EnvelopeDissemination::from_ssz_bytes(message.ssv_message.data()) - .expect("wire payload should decode as an envelope dissemination"); - assert_eq!(&decoded, expected_dissemination); - } -} - #[test] fn single_validator_batch_envelope_cases() { let sender = Arc::new(RecordingMessageSender::new(0)); diff --git a/anchor/validator_store/Cargo.toml b/anchor/validator_store/Cargo.toml index b48a99d4f..5e9979c81 100644 --- a/anchor/validator_store/Cargo.toml +++ b/anchor/validator_store/Cargo.toml @@ -9,7 +9,6 @@ beacon_node_fallback = { workspace = true } bls = { workspace = true } builder_types = { workspace = true } database = { workspace = true } -dissemination_store = { workspace = true } eth2 = { workspace = true } ethereum_ssz = { workspace = true } fork = { workspace = true } @@ -30,6 +29,7 @@ task_executor = { workspace = true } tokio = { workspace = true, features = ["sync", "time"] } tracing = { workspace = true } tree_hash = { workspace = true } +tree_hash_derive = { workspace = true } types = { workspace = true } validator_metrics = { workspace = true } validator_services = { workspace = true } diff --git a/anchor/validator_store/src/envelope.rs b/anchor/validator_store/src/envelope.rs new file mode 100644 index 000000000..edb992374 --- /dev/null +++ b/anchor/validator_store/src/envelope.rs @@ -0,0 +1,30 @@ +use tree_hash::TreeHash; +use types::{EthSpec, ExecutionPayloadEnvelope, Hash256, SignedRoot}; + +/// Progressive root view of an envelope. Only its signing root is sent to peers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, tree_hash_derive::TreeHash)] +#[tree_hash( + struct_behaviour = "progressive_container", + active_fields(1, 1, 1, 1, 1) +)] +pub(super) struct BlindedExecutionPayloadEnvelope { + pub payload_root: Hash256, + pub execution_requests_root: Hash256, + pub builder_index: u64, + pub beacon_block_root: Hash256, + pub parent_beacon_block_root: Hash256, +} + +impl SignedRoot for BlindedExecutionPayloadEnvelope {} + +impl BlindedExecutionPayloadEnvelope { + pub fn from_full(full: &ExecutionPayloadEnvelope) -> Self { + Self { + payload_root: full.payload.tree_hash_root(), + execution_requests_root: full.execution_requests.tree_hash_root(), + builder_index: full.builder_index, + beacon_block_root: full.beacon_block_root, + parent_beacon_block_root: full.parent_beacon_block_root, + } + } +} diff --git a/anchor/validator_store/src/instrumentation.rs b/anchor/validator_store/src/instrumentation.rs index 0ba7841c6..d27e3476e 100644 --- a/anchor/validator_store/src/instrumentation.rs +++ b/anchor/validator_store/src/instrumentation.rs @@ -63,8 +63,7 @@ pub fn classify_collection_failure(error: &Error) -> CollectionFailureClass { | CollectionError::OwnOperatorIdUnknown | CollectionError::InvalidProposerPacket | CollectionError::EmptySignature - | CollectionError::RecoverError(_) - | CollectionError::DisseminationSendFailed(_) => CollectionFailureClass::Infra, + | CollectionError::RecoverError(_) => CollectionFailureClass::Infra, } } _ => CollectionFailureClass::NonCollection, diff --git a/anchor/validator_store/src/lib.rs b/anchor/validator_store/src/lib.rs index 10c258f78..65e582c52 100644 --- a/anchor/validator_store/src/lib.rs +++ b/anchor/validator_store/src/lib.rs @@ -1,4 +1,5 @@ mod aggregator_post_consensus; +mod envelope; mod instrumentation; pub mod metadata_service; mod metrics; @@ -9,14 +10,13 @@ use std::{ future::Future, num::NonZeroUsize, str::from_utf8, - sync::{Arc, LazyLock, Weak}, + sync::{Arc, LazyLock}, time::Duration, }; use bls::{AggregateSignature, PublicKeyBytes, SecretKey, Signature}; use builder_types::{RequestAuth, SignedRequestAuth}; use database::{NetworkDatabase, NonUniqueIndex, UniqueIndex}; -use dissemination_store::DisseminationStore; use eth2::types::{BlockContents, BlockContentsTuple, FullBlockContents, PublishBlockRequest}; use fork::{Fork, ForkSchedule}; use futures::{ @@ -49,12 +49,11 @@ use ssv_types::{ consensus::{ AggregatorCommitteeConsensusData, AggregatorCommitteeDataValidator, BEACON_ROLE_AGGREGATOR, BEACON_ROLE_PROPOSER, BEACON_ROLE_SYNC_COMMITTEE_CONTRIBUTION, BeaconVote, - BeaconVoteValidator, BlindedExecutionPayloadEnvelope, Contribution, ContributionWrapper, - Contributions, DataVersion, ForkDecodeError, GloasBeaconVote, GloasBeaconVoteValidator, + BeaconVoteValidator, Contribution, ContributionWrapper, Contributions, DataVersion, + ForkDecodeError, GloasBeaconVote, GloasBeaconVoteValidator, GloasProposalData, ProposerConsensusData, ProposerConsensusDataValidator, QbftData, SelectionProofBatchId, ValidatorDuty, }, - dissemination::EnvelopeDissemination, msgid::Role, partial_sig::PartialSignatureKind, try_to_variable_list, @@ -76,8 +75,8 @@ use types::{ PayloadAttestationMessage, ProposerPreferences, SelectionProof, SignedAggregateAndProof, SignedBeaconBlock, SignedBlindedBeaconBlock, SignedContributionAndProof, SignedExecutionPayloadEnvelope, SignedProposerPreferences, SignedRoot, - SignedValidatorRegistrationData, SignedVoluntaryExit, SingleAttestation, Slot, SlotData, - SyncAggregatorSelectionData, SyncCommitteeContribution, SyncCommitteeMessage, + SignedValidatorRegistrationData, SignedVoluntaryExit, SigningData, SingleAttestation, Slot, + SlotData, SyncAggregatorSelectionData, SyncCommitteeContribution, SyncCommitteeMessage, SyncSelectionProof, SyncSubnetId, ValidatorRegistrationData, VoluntaryExit, consts::gloas::BUILDER_INDEX_SELF_BUILD, }; @@ -90,7 +89,7 @@ use validator_store::{ use crate::{ aggregator_post_consensus::AggregatorPostConsensusShared, - instrumentation::CollectionFailureClass, + envelope::BlindedExecutionPayloadEnvelope, instrumentation::CollectionFailureClass, }; /// Number of epochs of slashing protection history to keep. @@ -110,12 +109,10 @@ struct DecidedBlockKey { slot: Slot, } -/// The block-QBFT decision fields the envelope duty validates a dissemination against -/// (SIP-94 §6): the decided block root plus the bid commitments recoverable only from -/// the decided block itself, and whether this operator's own proposal was the decided -/// block (builder provenance for the envelope duty). +/// Immutable proposer decision commitments and local ownership for envelope publication. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct DecidedBlockContext { + pub payload_root: Hash256, pub beacon_block_root: Hash256, pub parent_block_root: Hash256, pub execution_requests_root: Hash256, @@ -133,12 +130,14 @@ pub struct DecidedBlockContext { impl DecidedBlockContext { /// Validates a blinded envelope's decision bindings against this context (SIP-94 §6). - /// `payload_root` has no binding here by design: it is trusted from the builder operator. - fn validate_blinded( + fn validate_blinded( &self, - blinded: &BlindedExecutionPayloadEnvelope, + blinded: &BlindedExecutionPayloadEnvelope, ) -> Result<(), SpecificError> { let mismatch = |field| SpecificError::EnvelopeBindingMismatch { field }; + if blinded.payload_root != self.payload_root { + return Err(mismatch("payload_root")); + } if blinded.beacon_block_root != self.beacon_block_root { return Err(mismatch("beacon_block_root")); } @@ -153,16 +152,27 @@ impl DecidedBlockContext { if blinded.builder_index != self.builder_index { return Err(mismatch("builder_index")); } - if blinded.execution_requests.tree_hash_root() != self.execution_requests_root { + if blinded.execution_requests_root != self.execution_requests_root { return Err(mismatch("execution_requests_root")); } Ok(()) } + fn blinded_envelope(&self) -> BlindedExecutionPayloadEnvelope { + BlindedExecutionPayloadEnvelope { + payload_root: self.payload_root, + execution_requests_root: self.execution_requests_root, + builder_index: self.builder_index, + beacon_block_root: self.beacon_block_root, + parent_beacon_block_root: self.parent_block_root, + } + } + /// True if `other` carries the same decision bindings, ignoring the operator-local /// `built_locally` bit. fn same_decision(&self, other: &DecidedBlockContext) -> bool { - self.beacon_block_root == other.beacon_block_root + self.payload_root == other.payload_root + && self.beacon_block_root == other.beacon_block_root && self.parent_block_root == other.parent_block_root && self.execution_requests_root == other.execution_requests_root && self.builder_index == other.builder_index @@ -344,8 +354,6 @@ pub struct AnchorValidatorStore< decrypted_keys: Mutex>, /// Block-QBFT decision contexts, keyed `(validator, slot)`. decided_block_contexts: Mutex>, - /// Handoff store for SIP-94 §6 envelope disseminations (written by the message receiver). - dissemination_store: Arc, signature_collector: Box, consensus: Arc, slashing_protection: Arc, @@ -371,9 +379,6 @@ pub struct AnchorValidatorStore< strict_mfp: bool, is_synced: watch::Receiver, task_executor: TaskExecutor, - /// Self-reference for work that outlives a `&self` trait call: the detached non-builder - /// envelope signing task spawned from [`Self::sign_block`]. - weak_self: Weak, /// `(committee, slot)` keys whose Boole+ `AggregatorCommittee` post-consensus execution has /// already been started. /// @@ -541,7 +546,6 @@ impl + 'static> AnchorValidator pub fn new( database: Arc, signature_collector: Box, - dissemination_store: Arc, consensus: Arc, slashing_protection: Arc, disable_slashing_protection: bool, @@ -558,11 +562,10 @@ impl + 'static> AnchorValidator is_synced: watch::Receiver, task_executor: TaskExecutor, ) -> Arc> { - Arc::new_cyclic(|weak_self| Self { + Arc::new(Self { database, decrypted_keys: Mutex::new(LruCache::new(MAX_VALIDATORS_PER_OPERATOR)), decided_block_contexts: Mutex::new(HashMap::new()), - dissemination_store, signature_collector, consensus, slashing_protection, @@ -583,7 +586,6 @@ impl + 'static> AnchorValidator strict_mfp, is_synced, task_executor, - weak_self: weak_self.clone(), aggregator_post_consensus: Mutex::new(HashSet::new()), }) } @@ -848,9 +850,47 @@ impl + 'static> AnchorValidator signing_root: Hash256, slot: Slot, ) -> Result { - let committee_id = cluster.committee_id(); - let metadata = SignatureMetadata { - kind: signature_kind, + let metadata = self.signature_metadata(signature_kind, role, cluster, slot)?; + let requester = match collection_mode { + CollectionMode::SingleValidator => SignatureRequester::SingleValidator { + pubkey: validator.public_key, + }, + CollectionMode::SingleValidatorBatch { + subnet_id, + descriptor, + } => SignatureRequester::SingleValidatorBatch { + pubkey: validator.public_key, + subnet_id, + descriptor, + }, + CollectionMode::Committee { + validator_partial_signature_batch_size, + base_hash, + } => SignatureRequester::Committee { + validator_partial_signature_batch_size, + base_hash, + }, + }; + let signing_data = self.validator_signing_data(validator, signing_root)?; + + let _timer = + validator_metrics::start_timer_vec(&validator_metrics::SIGNING_TIMES, &["ssv"]); + + let collector = + self.signature_collector + .sign_and_collect(metadata, requester, signing_data); + Ok((*collector.await.map_err(SpecificError::from)?).clone()) + } + + fn signature_metadata( + &self, + kind: PartialSignatureKind, + role: Role, + cluster: &Cluster, + slot: Slot, + ) -> Result { + Ok(SignatureMetadata { + kind, role, threshold: cluster .get_f() @@ -858,68 +898,41 @@ impl + 'static> AnchorValidator .and_then(|x| x.safe_add(1)) .map_err(SpecificError::from)?, slot, - committee_id, - }; - - let (requester, encrypted_private_key) = { - let state = self.database.state(); - let requester = match collection_mode { - CollectionMode::SingleValidator => SignatureRequester::SingleValidator { - pubkey: validator.public_key, - }, - CollectionMode::SingleValidatorBatch { - subnet_id, - descriptor, - } => SignatureRequester::SingleValidatorBatch { - pubkey: validator.public_key, - subnet_id, - descriptor, - }, - CollectionMode::Committee { - validator_partial_signature_batch_size, - base_hash, - } => SignatureRequester::Committee { - validator_partial_signature_batch_size, - base_hash, - }, - }; - let encrypted_private_key = state - .shares() - .get_by(&validator.public_key) - .ok_or(Error::UnknownPubkey(validator.public_key))? - .encrypted_private_key; - (requester, encrypted_private_key) - }; + committee_id: cluster.committee_id(), + }) + } - let decrypted_key_share = if let Some(operator_key) = &self.private_key { - let key = self - .decrypted_keys - .lock() - .try_get_or_insert(encrypted_private_key, || { - decrypt_key_share(operator_key, encrypted_private_key, validator.public_key) - .map_err(|_| SpecificError::KeyShareDecryptionFailed) - }) - .cloned()?; - Some(key) + fn validator_signing_data( + &self, + validator: &ValidatorMetadata, + root: Hash256, + ) -> Result { + let encrypted_private_key = self + .database + .state() + .shares() + .get_by(&validator.public_key) + .ok_or(Error::UnknownPubkey(validator.public_key))? + .encrypted_private_key; + let share = if let Some(operator_key) = &self.private_key { + Some( + self.decrypted_keys + .lock() + .try_get_or_insert(encrypted_private_key, || { + decrypt_key_share(operator_key, encrypted_private_key, validator.public_key) + .map_err(|_| SpecificError::KeyShareDecryptionFailed) + }) + .cloned()?, + ) } else { - // We are in imposter mode and cannot decrypt the share. None }; - - let signing_data = ValidatorSigningData { - root: signing_root, + Ok(ValidatorSigningData { + root, index: validator.index.ok_or(SpecificError::MissingIndex)?, validator_pubkey: validator.public_key, - share: decrypted_key_share, - }; - - let _timer = - validator_metrics::start_timer_vec(&validator_metrics::SIGNING_TIMES, &["ssv"]); - - let collector = - self.signature_collector - .sign_and_collect(metadata, requester, signing_data); - Ok((*collector.await.map_err(SpecificError::from)?).clone()) + share, + }) } /// Bound a [`Self::collect_signature`] future, mapping elapse to `CollectionTimeout` (the @@ -947,7 +960,8 @@ impl + 'static> AnchorValidator &self, validator: &ValidatorMetadata, cluster: &Cluster, - signable_block: &impl SignableBlock, + signable_block: &BeaconBlock>, + local_payload_root: Option, ) -> Result, Error> { let block = signable_block.as_block(); let slot = block.slot(); @@ -984,11 +998,36 @@ impl + 'static> AnchorValidator validator_sync_committee_indices: Default::default(), }; - // Package the consensus data + let proposal_bytes = if let BeaconBlockRef::Gloas(gloas) = block { + let payload_root = match local_payload_root { + Some(root) => root, + None if gloas + .body + .signed_execution_payload_bid + .message + .builder_index + == BUILDER_INDEX_SELF_BUILD => + { + return Err(Error::SpecificError(SpecificError::MissingLocalPayloadRoot)); + } + None => Hash256::ZERO, + }; + let proposal = GloasProposalData { + block: gloas.clone().into(), + payload_root, + }; + proposal + .validate_payload_root() + .map_err(SpecificError::InvalidQbftData)?; + proposal.as_ssz_bytes() + } else { + signable_block.as_ssz_bytes() + }; + let consensus_data = ProposerConsensusData { duty: validator_duty, version: block_version, - data_ssz: try_to_variable_list(signable_block.as_ssz_bytes(), |provided, max| { + data_ssz: try_to_variable_list(proposal_bytes, |provided, max| { Error::SpecificError(SpecificError::DataTooLarge(format!( "Block data too large for consensus: {} > {}", provided, max @@ -1018,18 +1057,19 @@ impl + 'static> AnchorValidator }; // Decode the decided data into a block we can sign - let unsigned_block = decode_decided_block(&completed_data) + let (unsigned_block, payload_root) = decode_decided_block(&completed_data) .map_err(|err| Error::SpecificError(SpecificError::InvalidQbftData(err)))?; // Record the decided context for later reads. This point is reached holding the consensus // decided value and each operator's own local proposal. Every participating // operator records the same context. Post-Gloas only. - if ForkName::from(completed_data.version) >= ForkName::Gloas + if let Some(payload_root) = payload_root && let UnsignedBlock::Full(FullBlockContents::Block(decided_block)) = &unsigned_block && let Ok(bid) = decided_block.body().signed_execution_payload_bid() { let decided_root = decided_block.canonical_root(); let context = DecidedBlockContext { + payload_root, beacon_block_root: decided_root, // The block's own parent_root, per SIP-94 §6 (the bid carries an equal copy, // enforced by process_execution_payload_bid, but the block is the source). @@ -1137,7 +1177,7 @@ impl + 'static> AnchorValidator } /// The configured payload-due deadline for `slot` (SIP-94 §6). At or after it the envelope - /// cannot satisfy the slot, so neither dissemination nor collection may start or continue. + /// cannot satisfy the slot, so signature collection may not start or continue. fn envelope_deadline(&self, slot: Slot) -> Result { let deadline = self.get_instant_in_slot(slot, self.spec.get_payload_due())?; if Instant::now() >= deadline { @@ -1148,114 +1188,6 @@ impl + 'static> AnchorValidator Ok(deadline) } - /// Sign another operator's envelope for `(validator, slot)`: the SIP-94 §6 non-builder path. - /// - /// Spawned by [`Self::sign_block`] once the decided block is threshold-signed, when the - /// decided bid is self-build and the decided block is not this operator's own proposal. It - /// runs detached from Lighthouse's envelope callback on purpose: that callback first fetches - /// this operator's own envelope from its beacon node and never reaches the store when the - /// node holds none (its local bid was external), so a non-builder share must not depend on - /// it. Awaits the builder operator's dissemination until the payload-due deadline, validates - /// it against the decided context, and contributes this operator's partial signature. - /// Publishes nothing: only the builder operator holds the payload bytes. - pub(crate) async fn sign_disseminated_envelope( - self: Arc, - validator: ValidatorMetadata, - cluster: Cluster, - context: DecidedBlockContext, - slot: Slot, - ) -> Result<(), Error> { - let record_outcome = |outcome: &str| { - metrics::inc_counter_vec(&metrics::ENVELOPE_SIGNING_OUTCOMES, &[outcome]); - }; - let deadline = self - .envelope_deadline(slot) - .inspect_err(|_| record_outcome(metrics::ENVELOPE_OUTCOME_FAILED))?; - - let Some(dissemination) = self - .dissemination_store - .wait(validator.public_key, slot, deadline) - .await - else { - record_outcome(metrics::ENVELOPE_OUTCOME_FAILED); - return Err(Error::SpecificError(SpecificError::DisseminationTimeout { - slot, - })); - }; - let disseminated = dissemination.blinded_envelope::().map_err(|err| { - record_outcome(metrics::ENVELOPE_OUTCOME_FAILED); - Error::SpecificError(SpecificError::DisseminationUndecodable(err)) - })?; - context.validate_blinded(&disseminated).map_err(|err| { - record_outcome(metrics::ENVELOPE_OUTCOME_FAILED); - Error::SpecificError(err) - })?; - - // `payload_root` is trusted from the builder operator by design (SIP-94 §6). - let domain_hash = self.get_domain(slot.epoch(E::slots_per_epoch()), Domain::BeaconBuilder); - let signing_root = disseminated.signing_root(domain_hash); - let remaining = deadline.saturating_duration_since(Instant::now()); - Self::collect_within( - remaining, - self.collect_signature( - PartialSignatureKind::Envelope, - Role::EnvelopeProposer, - CollectionMode::SingleValidator, - &validator, - &cluster, - signing_root, - slot, - ), - ) - .await - .inspect_err(|_| record_outcome(metrics::ENVELOPE_OUTCOME_FAILED))?; - - info!( - %slot, - validator_pubkey = %validator.public_key, - disseminated_root = ?disseminated.tree_hash_root(), - "Signed another operator's envelope" - ); - record_outcome(metrics::ENVELOPE_OUTCOME_NOT_BUILT_LOCALLY); - Ok(()) - } - - /// Start [`Self::sign_disseminated_envelope`] for `(validator, slot)` when the recorded - /// decision calls for it: a self-build bid on a block another operator built. No-op - /// otherwise (no Gloas context recorded, external build, or this operator is the builder and - /// signs from Lighthouse's envelope callback). Detached; a terminal failure is logged here - /// because nothing awaits the task. - fn spawn_non_builder_envelope_signing( - &self, - validator: &ValidatorMetadata, - cluster: &Cluster, - slot: Slot, - ) { - let Ok(context) = self.get_decided_block_context(validator.public_key, slot) else { - return; - }; - if context.builder_index != BUILDER_INDEX_SELF_BUILD || context.built_locally { - return; - } - let Some(store) = self.weak_self.upgrade() else { - return; - }; - let validator = validator.clone(); - let cluster = cluster.clone(); - let validator_pubkey = validator.public_key; - self.task_executor.spawn( - async move { - if let Err(error) = store - .sign_disseminated_envelope(validator, cluster, context, slot) - .await - { - warn!(?error, %slot, %validator_pubkey, "Non-builder envelope signing failed"); - } - }, - "envelope_non_builder_signing", - ); - } - async fn sign_abstract_block( &self, validator: &ValidatorMetadata, @@ -1293,8 +1225,32 @@ impl + 'static> AnchorValidator } let signing_root = block.signing_root(domain_hash); - let signature = self - .collect_signature( + let signature = if block.fork_name_unchecked() >= ForkName::Gloas { + let context = self.get_decided_block_context(validator.public_key, header.slot)?; + let metadata = self.signature_metadata( + PartialSignatureKind::PostConsensus, + Role::Proposer, + cluster, + header.slot, + )?; + let block_data = self.validator_signing_data(validator, signing_root)?; + let envelope_data = if context.builder_index == BUILDER_INDEX_SELF_BUILD { + let domain = self.get_domain(block.epoch(), Domain::BeaconBuilder); + Some(self.validator_signing_data( + validator, + context.blinded_envelope().signing_root(domain), + )?) + } else { + None + }; + (*self + .signature_collector + .sign_proposer_packet(metadata, validator.public_key, block_data, envelope_data) + .await + .map_err(SpecificError::from)?) + .clone() + } else { + self.collect_signature( PartialSignatureKind::PostConsensus, Role::Proposer, CollectionMode::SingleValidator, @@ -1303,7 +1259,8 @@ impl + 'static> AnchorValidator signing_root, header.slot, ) - .await?; + .await? + }; Ok(signable_block.to_signed_block(signature)) } @@ -2478,13 +2435,16 @@ impl + 'static> AnchorValidator /// decoding logic. fn decode_decided_block( completed_data: &ProposerConsensusData, -) -> Result, DecodeError> { - // Under the Gloas fork (EIP-7732), DataSSZ carries a plain BeaconBlock — decode directly. +) -> Result<(UnsignedBlock, Option), DecodeError> { + // Gloas carries the block and payload commitment in one decided wrapper. // Pre-Gloas: try blinded first, fall back to full block contents. if ForkName::from(completed_data.version) >= ForkName::Gloas { - completed_data - .decode_block() - .map(|block| UnsignedBlock::Full(FullBlockContents::Block(block))) + completed_data.decode_gloas_proposal().map(|proposal| { + ( + UnsignedBlock::Full(FullBlockContents::Block(BeaconBlock::Gloas(proposal.block))), + Some(proposal.payload_root), + ) + }) } else { completed_data .decode_blinded_block() @@ -2494,6 +2454,7 @@ fn decode_decided_block( .decode_block_contents() .map(UnsignedBlock::Full) }) + .map(|block| (block, None)) } } @@ -2501,7 +2462,10 @@ fn decode_decided_block( mod decode_decided_block_tests { use ssv_types::{ ValidatorIndex, - consensus::{BEACON_ROLE_PROPOSER, DataVersion, ProposerConsensusData, ValidatorDuty}, + consensus::{ + BEACON_ROLE_PROPOSER, DataVersion, GloasProposalData, ProposerConsensusData, + ValidatorDuty, + }, }; use ssz_types::VariableList; use types::{ @@ -2525,54 +2489,54 @@ mod decode_decided_block_tests { } #[test] - fn gloas_full_block_decodes_via_full_path() { - let spec = ChainSpec::mainnet(); - let block = BeaconBlock::Gloas(BeaconBlockGloas::::empty(&spec)); - let data = ProposerConsensusData { - duty: test_duty(), - version: DataVersion::from(ForkName::Gloas), - data_ssz: VariableList::new(block.as_ssz_bytes()).unwrap(), - }; - - match decode_decided_block::(&data) { - Ok(UnsignedBlock::Full(FullBlockContents::Block(decoded_block))) => { - assert_eq!(decoded_block, block); - } - other => panic!( - "Expected Ok(UnsignedBlock::Full(FullBlockContents::Block(_))), got {:?}", - other - ), - } - } - - #[test] - fn gloas_blinded_projection_decodes_via_full_path() { - let spec = ChainSpec::mainnet(); - let block = BeaconBlock::Gloas(BeaconBlockGloas::::empty(&spec)); - let blinded: BeaconBlock> = - block.to_ref().into(); - - // Pin the byte-identity invariant where full and blinded projections are identical. - assert_eq!( - block.as_ssz_bytes(), - blinded.as_ssz_bytes(), - "Gloas full and blinded projections must be byte-identical" - ); - - let data = ProposerConsensusData { - duty: test_duty(), - version: DataVersion::from(ForkName::Gloas), - data_ssz: VariableList::new(blinded.as_ssz_bytes()).unwrap(), - }; + fn gloas_full_block_and_payload_root_decode_together() { + for (builder_index, payload_root) in [ + (BUILDER_INDEX_SELF_BUILD, Hash256::repeat_byte(0x42)), + (7, Hash256::ZERO), + ] { + // Arrange: self-build and external wrappers both retain their payload field. + let spec = ChainSpec::mainnet(); + let mut gloas_block = BeaconBlockGloas::::empty(&spec); + gloas_block + .body + .signed_execution_payload_bid + .message + .builder_index = builder_index; + let block = BeaconBlock::Gloas(gloas_block.clone()); + let blinded: BeaconBlock> = + block.to_ref().into(); + assert_eq!( + block.as_ssz_bytes(), + blinded.as_ssz_bytes(), + "Gloas full and blinded projections must be byte-identical" + ); + let data = ProposerConsensusData { + duty: test_duty(), + version: DataVersion::from(ForkName::Gloas), + data_ssz: VariableList::new( + GloasProposalData { + block: gloas_block, + payload_root, + } + .as_ssz_bytes(), + ) + .unwrap(), + }; - match decode_decided_block::(&data) { - Ok(UnsignedBlock::Full(FullBlockContents::Block(decoded_block))) => { - assert_eq!(decoded_block, block); + // Act: decode the decided wrapper once, retaining both components. + let decoded = decode_decided_block::(&data); + + // Assert: the full block and its actual payload root are returned together. + match decoded { + Ok(( + UnsignedBlock::Full(FullBlockContents::Block(decoded_block)), + decoded_root, + )) => { + assert_eq!(decoded_block, block); + assert_eq!(decoded_root, Some(payload_root)); + } + other => panic!("Expected a full Gloas block and payload root, got {other:?}"), } - other => panic!( - "Expected Ok(UnsignedBlock::Full(FullBlockContents::Block(_))), got {:?}", - other - ), } } @@ -2600,7 +2564,7 @@ mod decode_decided_block_tests { }; match decode_decided_block::(&data) { - Ok(UnsignedBlock::Blinded(decoded_blinded)) => { + Ok((UnsignedBlock::Blinded(decoded_blinded), None)) => { assert_eq!(decoded_blinded, blinded); } other => panic!("Expected Ok(UnsignedBlock::Blinded(_)), got {:?}", other), @@ -2622,7 +2586,7 @@ mod decode_decided_block_tests { }; match decode_decided_block::(&data) { - Ok(UnsignedBlock::Full(_)) => {} + Ok((UnsignedBlock::Full(_), None)) => {} other => panic!("Expected Ok(UnsignedBlock::Full(_)), got {:?}", other), } } @@ -3220,38 +3184,29 @@ pub enum SpecificError { builder_index: u64, }, /// The decided block committed to an external builder's bid, so no self-build envelope - /// duty exists for the slot: nothing will be disseminated, and the reveal belongs to the + /// duty exists for the slot, and the reveal belongs to the /// external builder. This is an intentional no-op, not a failure. EnvelopeExternalBuild { builder_index: u64, }, - /// Another operator built the decided block. This operator's envelope share is signed by - /// the detached non-builder task started from `sign_block`, not from Lighthouse's envelope - /// callback, which has nothing to publish here. This is an intentional non-publish, not a - /// failure. - EnvelopeNonBuilderDelegated { + /// This operator does not hold the decided block's locally produced contents. + EnvelopeNotLocal { slot: Slot, }, + /// Stateless self-build production did not supply the payload root required by QBFT. + MissingLocalPayloadRoot, /// The envelope duty started at or after the payload-due deadline (50% of the slot), past /// which the envelope cannot satisfy this slot. EnvelopeDeadlinePassed { slot: Slot, }, - /// No dissemination arrived before the payload-due deadline. - DisseminationTimeout { - slot: Slot, - }, - /// The disseminated envelope bytes did not decode as a blinded envelope. - DisseminationUndecodable(ssz::DecodeError), - /// Building or sending the builder's dissemination broadcast failed. - DisseminationBroadcastFailed(CollectionError), /// A blinded envelope failed a decision binding against the decided block context /// (SIP-94 §6). Carries the first mismatching field. EnvelopeBindingMismatch { field: &'static str, }, /// The builder's local BN returned an envelope whose payload block hash differs from the - /// decided bid's; disseminating it would spread a payload the decision does not commit to. + /// decided bid's. EnvelopeBuilderInconsistent { local: ExecutionBlockHash, decided: ExecutionBlockHash, @@ -3532,7 +3487,7 @@ impl + 'static> ValidatorStore validator_pubkey: PublicKeyBytes, block: UnsignedBlock, current_slot: Slot, - _local_payload_root: Option, + local_payload_root: Option, ) -> Result, Error> { let (block_type, block_slot) = match block { UnsignedBlock::Full(FullBlockContents::BlockContents(ref contents)) => { @@ -3594,7 +3549,7 @@ impl + 'static> ValidatorStore ); let decided_block = self - .decide_abstract_block(&validator, &cluster, &blinded_block) + .decide_abstract_block(&validator, &cluster, &blinded_block, local_payload_root) .await?; trace!( @@ -3624,8 +3579,6 @@ impl + 'static> ValidatorStore "Block threshold signature completed" ); - self.spawn_non_builder_envelope_signing(&validator, &cluster, blinded_block.slot()); - let publish_decision = select_publish_block(signed_block, &blinded_block, local_full_block); Span::current().record("proposal_matched", publish_decision.proposal_matched); @@ -4265,9 +4218,7 @@ impl + 'static> ValidatorStore Span::current().record("outcome", outcome); }; - // The decided bid names an external builder: no self-build envelope duty exists - // for the slot. Nothing will be disseminated (the reveal belongs to the external - // builder), so return before the non-builder path can wait for it (SIP-94 §6). + // External builders publish their own envelopes; this callback only serves self-builds. if context.builder_index != BUILDER_INDEX_SELF_BUILD { info!( builder_index = context.builder_index, @@ -4279,29 +4230,15 @@ impl + 'static> ValidatorStore })); } - // Another operator built the decided block. Its envelope reaches this operator by - // dissemination and is signed by the task `sign_block` spawned once the block was - // threshold-signed (`sign_disseminated_envelope`); that task needs nothing from this - // callback, which Lighthouse only reaches after fetching this operator's own envelope - // from its beacon node. The caller publishes every `Ok`, and there is nothing to - // publish, so return the sentinel (SIP-94 §6). + // A valid signature alone is not publishable without the decided payload bytes. if !context.built_locally { - info!( - "Decided block was built by another operator, its envelope is signed by the non-builder task (expected)" - ); - return Err(Error::SpecificError( - SpecificError::EnvelopeNonBuilderDelegated { slot }, - )); + return Err(Error::SpecificError(SpecificError::EnvelopeNotLocal { slot })); } let deadline = self .envelope_deadline(slot) .inspect_err(|_| record_outcome(metrics::ENVELOPE_OUTCOME_FAILED))?; - // The builder operator disseminates its own blinded envelope and signs it; the - // other operators sign the disseminated copy from their non-builder task (SIP-94 §6). - // Bind the local BN's envelope to the decided bid before disseminating: a - // stale or inconsistent BN response must not go out under our signature. if envelope.payload.block_hash != context.block_hash { warn!( local = ?envelope.payload.block_hash, @@ -4325,45 +4262,27 @@ impl + 'static> ValidatorStore Error::SpecificError(err) })?; - let dissemination = EnvelopeDissemination { - slot, - envelope: try_to_variable_list( - local_blinded.as_ssz_bytes(), - |provided, max| { - record_outcome(metrics::ENVELOPE_OUTCOME_FAILED); - Error::SpecificError(SpecificError::DataTooLarge(format!( - "Envelope too large for dissemination: {provided} > {max}" - ))) - }, - )?, - }; - self.signature_collector - .broadcast_dissemination( - validator_pubkey, - cluster.committee_id(), - dissemination, - ) - .map_err(|err| { - warn!(?err, "Envelope dissemination broadcast failed"); - record_outcome(metrics::ENVELOPE_OUTCOME_FAILED); - Error::SpecificError(SpecificError::DisseminationBroadcastFailed(err)) - })?; - let epoch = slot.epoch(E::slots_per_epoch()); let domain_hash = self.get_domain(epoch, Domain::BeaconBuilder); let signing_root = local_blinded.signing_root(domain_hash); let remaining = deadline.saturating_duration_since(Instant::now()); let signature = Self::collect_within( remaining, - self.collect_signature( - PartialSignatureKind::Envelope, - Role::EnvelopeProposer, - CollectionMode::SingleValidator, - &validator, - &cluster, - signing_root, - slot, - ), + async { + let metadata = self.signature_metadata( + PartialSignatureKind::PostConsensus, Role::Proposer, &cluster, slot, + )?; + let block_signing_root = SigningData { + object_root: context.beacon_block_root, + domain: self.get_domain(epoch, Domain::BeaconProposer), + }.tree_hash_root(); + self.signature_collector.wait_for_registered_signature( + metadata, validator_index, validator_pubkey, signing_root, + Some(block_signing_root), + ).await.map(|signature| (*signature).clone()).map_err(|err| { + Error::SpecificError(SpecificError::from(err)) + }) + }, ) .await .inspect_err(|err| { diff --git a/anchor/validator_store/src/metrics.rs b/anchor/validator_store/src/metrics.rs index e89749748..5f0fc6470 100644 --- a/anchor/validator_store/src/metrics.rs +++ b/anchor/validator_store/src/metrics.rs @@ -281,16 +281,12 @@ pub static REQUEST_AUTH_RECONSTRUCTION_FAILURES: LazyLock> ) }); -/// The builder path disseminated, signed, and returned the envelope for publication. +/// The local builder returned the reconstructed envelope signature for publication. pub const ENVELOPE_OUTCOME_PUBLISHED: &str = "published"; -/// A non-builder signed the disseminated envelope and intentionally skipped publication; -/// never a failure. -pub const ENVELOPE_OUTCOME_NOT_BUILT_LOCALLY: &str = "not_built_locally"; /// The decided block committed to an external builder's bid: no self-build envelope duty /// exists for the slot, so the operator neither waits nor signs; never a failure. pub const ENVELOPE_OUTCOME_EXTERNAL_BUILD: &str = "external_build"; -/// The duty failed: deadline passed, dissemination missing/undecodable/mismatched, the -/// builder's envelope was inconsistent, broadcast failed, or signature collection failed. +/// The envelope was inconsistent, its deadline passed, or signature collection failed. pub const ENVELOPE_OUTCOME_FAILED: &str = "failed"; pub static ENVELOPE_SIGNING_OUTCOMES: LazyLock> = LazyLock::new(|| { diff --git a/anchor/validator_store/src/testing/common.rs b/anchor/validator_store/src/testing/common.rs index 0c9058399..cc30d188e 100644 --- a/anchor/validator_store/src/testing/common.rs +++ b/anchor/validator_store/src/testing/common.rs @@ -17,7 +17,6 @@ use std::{ use bls::{AggregateSignature, FixedBytesExtended, PublicKeyBytes, Signature}; use database::{NetworkDatabase, PendingStateUpdates}; -use dissemination_store::DisseminationStore; use fork::{Fork, ForkSchedule}; use futures::StreamExt; use parking_lot::Mutex; @@ -33,7 +32,6 @@ use ssv_types::{ Cluster, ClusterId, CommitteeId, ENCRYPTED_KEY_LENGTH, IndexSet, OperatorId, Share, ValidatorIndex, ValidatorMetadata, consensus::{AggregatorCommitteeConsensusData, BeaconVote, GloasBeaconVote, QbftDataValidator}, - dissemination::EnvelopeDissemination, }; use ssz::Encode; use task_executor::TaskExecutor; @@ -187,17 +185,6 @@ impl ConsensusDecider for MockConsensusDecider { // ==================== Mock signature collector ==================== -/// One captured `broadcast_dissemination` call. -#[derive(Debug, Clone)] -pub(super) struct CapturedDissemination { - pub(super) validator_pubkey: PublicKeyBytes, - pub(super) committee_id: CommitteeId, - pub(super) dissemination: EnvelopeDissemination, -} - -/// Shared storage for captured `broadcast_dissemination` calls. -pub(super) type CapturedDisseminations = Arc>>; - /// Shared storage for captured `sign_and_collect` calls. pub(super) type CapturedCalls = Arc>>; @@ -207,6 +194,8 @@ pub(super) struct CapturedSignatureCall { /// Only the root is captured, not the full `ValidatorSigningData`, so the capture never /// holds key share material. pub(super) signing_root: Hash256, + pub(super) envelope_signing_root: Option, + pub(super) wait_only: bool, pub(super) validator_pubkey: PublicKeyBytes, /// When the call was made. pub(super) captured_at: Instant, @@ -228,9 +217,6 @@ type FailingPubkeys = Arc>>; /// over both failure modes. struct MockSignatureCollector { captured: CapturedCalls, - captured_disseminations: CapturedDisseminations, - /// Set from `HarnessOptions::dissemination_failure`; every broadcast fails with this error. - dissemination_failure: Option, /// Set from `HarnessOptions::collector_failure`; every call fails with this error. failure: Option, fails: Arc, @@ -280,6 +266,8 @@ impl SignatureCollecting for MockSignatureCollector { requester, metadata, signing_root: signing_data.root, + envelope_signing_root: None, + wait_only: false, validator_pubkey: signing_data.validator_pubkey, captured_at: Instant::now(), }); @@ -291,12 +279,14 @@ impl SignatureCollecting for MockSignatureCollector { metadata: SignatureMetadata, pubkey: PublicKeyBytes, block: ValidatorSigningData, - _envelope: Option, + envelope: Option, ) -> Pin, CollectionError>> + Send + '_>> { self.captured.lock().push(CapturedSignatureCall { requester: SignatureRequester::SingleValidator { pubkey }, metadata, signing_root: block.root, + envelope_signing_root: envelope.map(|data| data.root), + wait_only: false, validator_pubkey: block.validator_pubkey, captured_at: Instant::now(), }); @@ -309,7 +299,7 @@ impl SignatureCollecting for MockSignatureCollector { _index: ValidatorIndex, validator_pubkey: PublicKeyBytes, root: Hash256, - _required_companion: Option, + required_companion: Option, ) -> Pin, CollectionError>> + Send + '_>> { self.captured.lock().push(CapturedSignatureCall { requester: SignatureRequester::SingleValidator { @@ -317,68 +307,39 @@ impl SignatureCollecting for MockSignatureCollector { }, metadata, signing_root: root, + envelope_signing_root: required_companion, + wait_only: true, validator_pubkey, captured_at: Instant::now(), }); self.collection_result(validator_pubkey) } - - fn broadcast_dissemination( - &self, - validator_pubkey: PublicKeyBytes, - committee_id: CommitteeId, - dissemination: EnvelopeDissemination, - ) -> Result<(), CollectionError> { - if let Some(failure) = self.dissemination_failure.clone() { - return Err(failure); - } - self.captured_disseminations - .lock() - .push(CapturedDissemination { - validator_pubkey, - committee_id, - dissemination, - }); - Ok(()) - } } /// Creates a mock signature collector, returning the shared captured calls and failure-mode /// handles. fn create_mock_collector( failure: Option, - dissemination_failure: Option, hang: bool, ) -> ( Box, CapturedCalls, - CapturedDisseminations, Arc, Arc, FailingPubkeys, ) { let captured: CapturedCalls = Arc::new(Mutex::new(Vec::new())); - let captured_disseminations: CapturedDisseminations = Arc::new(Mutex::new(Vec::new())); let fails = Arc::new(AtomicBool::new(false)); let hangs = Arc::new(AtomicBool::new(hang)); let failing_pubkeys: FailingPubkeys = Arc::new(Mutex::new(HashSet::new())); let mock = MockSignatureCollector { captured: Arc::clone(&captured), - captured_disseminations: Arc::clone(&captured_disseminations), - dissemination_failure, failure, fails: Arc::clone(&fails), hangs: Arc::clone(&hangs), failing_pubkeys: Arc::clone(&failing_pubkeys), }; - ( - Box::new(mock), - captured, - captured_disseminations, - fails, - hangs, - failing_pubkeys, - ) + (Box::new(mock), captured, fails, hangs, failing_pubkeys) } // ==================== Committee setup ==================== @@ -502,8 +463,6 @@ pub(super) fn create_committee_setup( pub(super) struct HarnessOptions { /// When set, every `sign_and_collect` call fails with this error after being captured. pub(super) collector_failure: Option, - /// Every `broadcast_dissemination` call fails with this error. - pub(super) dissemination_failure: Option, /// When `true`, every `sign_and_collect` call captures the call and then returns a future that /// never resolves, modeling a quorum that never forms. Used to drive the production /// collection-timeout path. Takes precedence over `collector_failure`. @@ -529,7 +488,6 @@ impl Default for HarnessOptions { fn default() -> Self { Self { collector_failure: None, - dissemination_failure: None, collector_hangs: false, // Slashing protection is disabled by default; most tests do not exercise it. When a // test enables it, the harness registers every configured validator in the slashing @@ -578,12 +536,8 @@ pub(super) struct ValidatorStoreTestHarness { Arc>, committee_setups: Vec, pub(super) captured_calls: CapturedCalls, - pub(super) captured_disseminations: CapturedDisseminations, /// Timeout origins supplied by the real signing and committee consensus callers. pub(super) captured_consensus_timeouts: Arc>>, - /// The dissemination handoff store the store awaits on; tests insert into it to stand in - /// for the message receiver. - pub(super) dissemination_store: Arc, /// Set by [`Self::fail_signature_collection`]; read by the mock collector on every call. signature_collection_fails: Arc, /// Filled by [`Self::hang_signature_collection`]; read by the mock collector on every call. @@ -675,17 +629,10 @@ impl ValidatorStoreTestHarness { let ( mock_collector, captured_calls, - captured_disseminations, signature_collection_fails, signature_collection_hangs, failing_pubkeys, - ) = create_mock_collector( - options.collector_failure, - options.dissemination_failure, - options.collector_hangs, - ); - - let dissemination_store = Arc::new(DisseminationStore::new()); + ) = create_mock_collector(options.collector_failure, options.collector_hangs); // Database let database = Arc::new( @@ -771,7 +718,6 @@ impl ValidatorStoreTestHarness { let validator_store = AnchorValidatorStore::new( database, mock_collector, - Arc::clone(&dissemination_store), Arc::new(decider), Arc::clone(&slashing_protection), options.disable_slashing_protection, @@ -793,9 +739,7 @@ impl ValidatorStoreTestHarness { validator_store, committee_setups, captured_calls, - captured_disseminations, captured_consensus_timeouts, - dissemination_store, signature_collection_fails, signature_collection_hangs, failing_pubkeys, diff --git a/anchor/validator_store/src/testing/decided_block_root.rs b/anchor/validator_store/src/testing/decided_block_root.rs index 63686f568..adda2ea02 100644 --- a/anchor/validator_store/src/testing/decided_block_root.rs +++ b/anchor/validator_store/src/testing/decided_block_root.rs @@ -19,6 +19,7 @@ fn test_context(seed: u8) -> DecidedBlockContext { beacon_block_root: Hash256::from([seed; 32]), parent_block_root: Hash256::from([seed.wrapping_add(1); 32]), execution_requests_root: Hash256::from([seed.wrapping_add(2); 32]), + payload_root: Hash256::from([seed.wrapping_add(4); 32]), builder_index: seed as u64, block_hash: ExecutionBlockHash::from_root(Hash256::from([seed.wrapping_add(3); 32])), built_locally: false, @@ -525,3 +526,35 @@ async fn out_of_order_old_insert_does_not_disturb_a_newer_root() { "an out-of-order older insert must not evict or alter a newer live root" ); } + +#[tokio::test] +async fn same_block_with_conflicting_payload_root_preserves_first_decision() { + // Arrange: both contexts identify the same block but disagree on the decided payload. + let state = ValidatorStoreTestState::new(1); + set_clock_to_slot(&state.harness, RECORD_SLOT); + let mut conflicting = state.context; + conflicting.payload_root = Hash256::repeat_byte(0xFE); + state + .harness + .validator_store + .record_decided_block_context(state.pubkey, Slot::new(RECORD_SLOT), state.context) + .unwrap(); + + // Act: try to rewrite only the payload binding. + let result = state.harness.validator_store.record_decided_block_context( + state.pubkey, + Slot::new(RECORD_SLOT), + conflicting, + ); + + // Assert: a payload conflict is a consensus conflict even when the block roots match. + assert!(matches!(result, Err(SpecificError::DecidedRootConflict(_)))); + assert_eq!( + state + .harness + .validator_store + .get_decided_block_context(state.pubkey, Slot::new(RECORD_SLOT)) + .unwrap(), + state.context + ); +} diff --git a/anchor/validator_store/src/testing/decided_block_root_e2e.rs b/anchor/validator_store/src/testing/decided_block_root_e2e.rs index 8038588fa..c1af4eab5 100644 --- a/anchor/validator_store/src/testing/decided_block_root_e2e.rs +++ b/anchor/validator_store/src/testing/decided_block_root_e2e.rs @@ -11,7 +11,8 @@ use eth2::types::FullBlockContents; use ssv_types::{ OperatorId, ValidatorIndex, consensus::{ - BEACON_ROLE_PROPOSER, DataVersion, ProposerConsensusData, QbftData, ValidatorDuty, + BEACON_ROLE_PROPOSER, DataVersion, GloasProposalData, ProposerConsensusData, QbftData, + ValidatorDuty, }, }; use ssz::Encode; @@ -71,6 +72,12 @@ impl ValidatorStoreTestState { fn gloas_block(&self) -> BeaconBlock { let mut block = BeaconBlockGloas::::empty(&self.harness.spec); block.slot = Slot::new(DUTY_SLOT); + block.parent_root = Hash256::repeat_byte(0x41); + block + .body + .signed_execution_payload_bid + .message + .parent_block_root = Hash256::repeat_byte(0x42); BeaconBlock::Gloas(block) } } @@ -93,8 +100,9 @@ async fn stored_root_is_the_decoded_block_root_not_the_qbft_wrapper_hash() { .message; DecidedBlockContext { beacon_block_root: expected_root, - parent_block_root: bid.parent_block_root, + parent_block_root: block.parent_root(), execution_requests_root: bid.execution_requests_root, + payload_root: Hash256::ZERO, builder_index: bid.builder_index, block_hash: bid.block_hash, // The echoing mock decides exactly the proposed block, so the write site must @@ -118,7 +126,17 @@ async fn stored_root_is_the_decoded_block_root_not_the_qbft_wrapper_hash() { validator_sync_committee_indices: Default::default(), }, version: DataVersion::from(ForkName::Gloas), - data_ssz: VariableList::new(block.as_ssz_bytes()).expect("block bytes should fit"), + data_ssz: VariableList::new( + GloasProposalData { + block: match block.clone() { + BeaconBlock::Gloas(block) => block, + _ => unreachable!(), + }, + payload_root: Hash256::ZERO, + } + .as_ssz_bytes(), + ) + .expect("proposal bytes should fit"), } .hash(); @@ -177,6 +195,7 @@ async fn conflicting_root_aborts_before_threshold_signing() { beacon_block_root: Hash256::from([0xEE; 32]), parent_block_root: Hash256::ZERO, execution_requests_root: Hash256::ZERO, + payload_root: Hash256::ZERO, builder_index: 0, block_hash: ExecutionBlockHash::zero(), built_locally: false, diff --git a/anchor/validator_store/src/testing/envelope_signing.rs b/anchor/validator_store/src/testing/envelope_signing.rs index 488dec5b8..2df8d1576 100644 --- a/anchor/validator_store/src/testing/envelope_signing.rs +++ b/anchor/validator_store/src/testing/envelope_signing.rs @@ -1,22 +1,15 @@ -//! Envelope-signing duty tests (SIP-94 §6, disseminate-and-sign). +//! SIP-94 proposer folding and local envelope publication regressions. -use std::{ - sync::{Arc, LazyLock}, - time::Duration, -}; +use std::time::Duration; use bls::{FixedBytesExtended, PublicKeyBytes}; use eth2::types::FullBlockContents; -use futures::FutureExt; -use signature_collector::CollectionError; -use slashing_protection::Safe; +use slot_clock::SlotClock; use ssv_types::{ OperatorId, consensus::{ - BEACON_ROLE_PROPOSER, BlindedExecutionPayloadEnvelope, DataVersion, ProposerConsensusData, - ValidatorDuty, + BEACON_ROLE_PROPOSER, DataVersion, GloasProposalData, ProposerConsensusData, ValidatorDuty, }, - dissemination::EnvelopeDissemination, msgid::Role, partial_sig::PartialSignatureKind, }; @@ -26,54 +19,67 @@ use tree_hash::TreeHash; use types::{ BeaconBlock, BeaconBlockGloas, ChainSpec, Domain, EmptyBlock, EthSpec, ExecutionPayloadEnvelope, ExecutionPayloadGloas, ExecutionRequestsGloas, ForkName, Hash256, - MainnetEthSpec, SignedExecutionPayloadEnvelope, SignedRoot, Slot, - consts::gloas::BUILDER_INDEX_SELF_BUILD, + MainnetEthSpec, SignedRoot, SigningData, Slot, consts::gloas::BUILDER_INDEX_SELF_BUILD, }; use validator_store::{UnsignedBlock, ValidatorStore}; use super::common::*; -use crate::{DecidedBlockContext, Error, SpecificError}; +use crate::{BlindedExecutionPayloadEnvelope, DecidedBlockContext, Error, SpecificError}; -/// Serializes every test that records an envelope outcome: the labels live in the global -/// prometheus registry, so concurrent recordings would race the delta assertions. Tokio mutex -/// because the guard is held across awaits. -static METRIC_TEST_LOCK: LazyLock> = - LazyLock::new(|| tokio::sync::Mutex::new(())); +const EXTERNAL_BUILDER: u64 = 7; -const BEFORE_PAYLOAD_DEADLINE: Duration = Duration::from_millis(1); -const COLLECTION_START_OFFSET: Duration = Duration::from_secs(1); -const PAYLOAD_DUE_CASES: [(u64, Duration); 2] = [ - (2500, Duration::from_secs(3)), - (7500, Duration::from_secs(9)), -]; +fn committee() -> (CommitteeSetup, PublicKeyBytes) { + let setup = create_primary_committee_setup(1); + let pubkey = setup.validators[0].public_key; + (setup, pubkey) +} -// ==================== Fixtures and helpers ==================== +fn options() -> HarnessOptions { + HarnessOptions { + spec: gloas_at_genesis_spec(), + ..Default::default() + } +} -fn test_operator_ids() -> [OperatorId; 4] { - [OperatorId(1), OperatorId(2), OperatorId(3), OperatorId(4)] +fn harness() -> (ValidatorStoreTestHarness, PublicKeyBytes) { + let (setup, pubkey) = committee(); + ( + ValidatorStoreTestHarness::new_with_options(vec![setup], OperatorId(1), options()), + pubkey, + ) } -/// The block root envelope tests bind envelopes to. -fn test_decided_root() -> Hash256 { - Hash256::from_low_u64_be(0xdec1) +fn block(spec: &ChainSpec, builder_index: u64) -> BeaconBlock { + let mut block = BeaconBlockGloas::::empty(spec); + block.slot = Slot::new(TEST_SLOT); + block + .body + .signed_execution_payload_bid + .message + .builder_index = builder_index; + block + .body + .signed_execution_payload_bid + .message + .execution_requests_root = + ExecutionRequestsGloas::::default().tree_hash_root(); + BeaconBlock::Gloas(block) } -/// Builds a Gloas self-build envelope at `TEST_SLOT` bound to `beacon_block_root`. -fn self_build_envelope(beacon_block_root: Hash256) -> ExecutionPayloadEnvelope { +fn envelope(block: &BeaconBlock) -> ExecutionPayloadEnvelope { ExecutionPayloadEnvelope { - payload: ExecutionPayloadGloas:: { + payload: ExecutionPayloadGloas { slot_number: Slot::new(TEST_SLOT), ..Default::default() }, - execution_requests: ExecutionRequestsGloas::::default(), + execution_requests: ExecutionRequestsGloas::default(), builder_index: BUILDER_INDEX_SELF_BUILD, - beacon_block_root, - parent_beacon_block_root: Hash256::from_low_u64_be(0x1111), + beacon_block_root: block.canonical_root(), + parent_beacon_block_root: block.parent_root(), } } -/// Derives the decided-block context whose bindings all match `envelope`. -fn context_for( +fn context( envelope: &ExecutionPayloadEnvelope, built_locally: bool, ) -> DecidedBlockContext { @@ -81,200 +87,45 @@ fn context_for( beacon_block_root: envelope.beacon_block_root, parent_block_root: envelope.parent_beacon_block_root, execution_requests_root: envelope.execution_requests.tree_hash_root(), + payload_root: envelope.payload.tree_hash_root(), builder_index: envelope.builder_index, block_hash: envelope.payload.block_hash, built_locally, } } -/// Records `context` for the validator at `TEST_SLOT`. -fn seed_context( - harness: &ValidatorStoreTestHarness, - pubkey: PublicKeyBytes, - context: DecidedBlockContext, -) { - harness - .validator_store - .record_decided_block_context(pubkey, Slot::new(TEST_SLOT), context) - .expect("seeding the decided context must succeed"); -} - -/// Inserts the blinded form of `envelope` into the harness dissemination store, standing in -/// for the message receiver, and returns the inserted blinded envelope. -fn insert_dissemination( - harness: &ValidatorStoreTestHarness, - pubkey: PublicKeyBytes, - envelope: &ExecutionPayloadEnvelope, -) -> BlindedExecutionPayloadEnvelope { - let blinded = BlindedExecutionPayloadEnvelope::from_full(envelope); - harness.dissemination_store.insert( - pubkey, - EnvelopeDissemination { - slot: Slot::new(TEST_SLOT), - envelope: VariableList::new(blinded.as_ssz_bytes()) - .expect("blinded envelope bytes should fit"), - }, - ); - blinded -} - -/// A single-validator committee over `test_operator_ids()` and its validator's public key. -fn single_validator_committee() -> (CommitteeSetup, PublicKeyBytes) { - let committee = create_committee_setup(&test_operator_ids(), 1, 5); - let pubkey = committee.validators[0].public_key; - (committee, pubkey) -} - -/// Default envelope-test options: Gloas at genesis, slashing protection disabled. -fn gloas_options() -> HarnessOptions { - HarnessOptions { - spec: gloas_at_genesis_spec(), - disable_slashing_protection: true, - ..Default::default() - } -} - -fn gloas_options_with_payload_due(payload_due_bps: u64) -> HarnessOptions { - let mut spec = (*gloas_at_genesis_spec()).clone(); - spec.payload_due_bps = payload_due_bps; - HarnessOptions { - spec: Arc::new(spec.compute_derived_values::()), - ..gloas_options() - } -} - -/// Builds a single-validator harness owned by `OperatorId(1)` with the given options. -fn harness_with_options(options: HarnessOptions) -> (ValidatorStoreTestHarness, PublicKeyBytes) { - let (committee, pubkey) = single_validator_committee(); - let harness = - ValidatorStoreTestHarness::new_with_options(vec![committee], OperatorId(1), options); - (harness, pubkey) -} - -/// Builds the default Gloas harness most envelope tests use. -fn gloas_harness() -> (ValidatorStoreTestHarness, PublicKeyBytes) { - harness_with_options(gloas_options()) -} - -/// The `Domain::BeaconBuilder` domain hash at `TEST_SLOT`'s epoch, recomputed from the spec -/// so tests do not depend on the store's own fork-selection logic. -fn envelope_domain_hash(harness: &ValidatorStoreTestHarness) -> Hash256 { - let spec = &harness.spec; +fn domain(harness: &ValidatorStoreTestHarness, domain: Domain) -> Hash256 { let epoch = Slot::new(TEST_SLOT).epoch(MainnetEthSpec::slots_per_epoch()); - spec.get_domain( + harness.spec.get_domain( epoch, - Domain::BeaconBuilder, - &spec.fork_at_epoch(epoch), + domain, + &harness.spec.fork_at_epoch(epoch), harness.genesis_validators_root, ) } -/// Drives the envelope duty under test against the store. -async fn sign_envelope( - harness: &ValidatorStoreTestHarness, - pubkey: PublicKeyBytes, - envelope: ExecutionPayloadEnvelope, -) -> Result, Error> { - harness - .validator_store - .sign_execution_payload_envelope(pubkey, envelope) - .await -} - -/// Runs the body of the detached non-builder task for the harness validator at `TEST_SLOT`, -/// awaiting it directly: the same code `sign_block` spawns, minus the executor. -async fn run_non_builder_task( - harness: &ValidatorStoreTestHarness, - pubkey: PublicKeyBytes, - context: DecidedBlockContext, -) -> Result<(), Error> { - let (validator, cluster) = harness.validator_store.get_validator_and_cluster(pubkey)?; +fn seed(harness: &ValidatorStoreTestHarness, pubkey: PublicKeyBytes, context: DecidedBlockContext) { harness .validator_store - .clone() - .sign_disseminated_envelope(validator, cluster, context, Slot::new(TEST_SLOT)) - .await -} - -/// Number of captured signature collections of the envelope kind. -fn envelope_collection_count(harness: &ValidatorStoreTestHarness) -> usize { - harness - .captured_calls - .lock() - .iter() - .filter(|call| call.metadata.kind == PartialSignatureKind::Envelope) - .count() -} - -/// Waits for the detached non-builder task to reach signature collection and returns the root -/// it signed. Bounded so a task that never signs fails the test instead of hanging it. -async fn wait_for_envelope_signing_root(harness: &ValidatorStoreTestHarness) -> Hash256 { - tokio::time::timeout(Duration::from_secs(5), async { - loop { - let signed_root = harness - .captured_calls - .lock() - .iter() - .find(|call| call.metadata.kind == PartialSignatureKind::Envelope) - .map(|call| call.signing_root); - if let Some(root) = signed_root { - return root; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("the non-builder task must reach signature collection") -} - -/// Gives spawned tasks a chance to run before a negative assertion: a wrongly spawned -/// non-builder task with a valid dissemination already stored would sign immediately. -async fn let_spawned_tasks_run() { - for _ in 0..8 { - tokio::task::yield_now().await; - } - tokio::time::sleep(Duration::from_millis(50)).await; -} - -/// A Gloas block at `TEST_SLOT` whose bid names `builder_index` and commits to the default -/// execution requests, so `self_build_envelope(block.canonical_root())` binds to it. -fn gloas_block_with_bid(spec: &ChainSpec, builder_index: u64) -> BeaconBlock { - // `EmptyBlock::empty` fixes the slot to `spec.genesis_slot`, so the slot is set on the - // inner struct before wrapping. - let mut gloas_block = BeaconBlockGloas::::empty(spec); - gloas_block.slot = Slot::new(TEST_SLOT); - let bid = &mut gloas_block.body.signed_execution_payload_bid.message; - bid.builder_index = builder_index; - bid.execution_requests_root = - ExecutionRequestsGloas::::default().tree_hash_root(); - BeaconBlock::Gloas(gloas_block) -} - -/// The envelope for `block` that every decision binding accepts. -fn envelope_for_block( - block: &BeaconBlock, -) -> ExecutionPayloadEnvelope { - let mut envelope = self_build_envelope(block.canonical_root()); - envelope.parent_beacon_block_root = block.parent_root(); - envelope + .record_decided_block_context(pubkey, Slot::new(TEST_SLOT), context) + .unwrap(); } -/// The consensus value a fixed-decision mock returns so `sign_block` decides `block` for the -/// harness validator regardless of the local proposal. -fn decided_consensus_data( - committee: &CommitteeSetup, +fn decision( + setup: &CommitteeSetup, pubkey: PublicKeyBytes, block: &BeaconBlock, + payload_root: Hash256, ) -> ProposerConsensusData { - let validator_index = committee.validators[0] - .index - .expect("the harness validator has a beacon index"); + let BeaconBlock::Gloas(block) = block.clone() else { + panic!("Gloas fixture") + }; ProposerConsensusData { duty: ValidatorDuty { r#type: BEACON_ROLE_PROPOSER, pub_key: pubkey, slot: Slot::new(TEST_SLOT), - validator_index, + validator_index: setup.validators[0].index.unwrap(), committee_index: 0, committee_length: 0, committees_at_slot: 0, @@ -282,907 +133,201 @@ fn decided_consensus_data( validator_sync_committee_indices: Default::default(), }, version: DataVersion::from(ForkName::Gloas), - data_ssz: VariableList::new(block.as_ssz_bytes()).expect("block bytes should fit"), - } -} - -/// Before-values of the three envelope outcome labels, taken under `METRIC_TEST_LOCK`. -struct OutcomeCounters { - published: crate::metrics::IntCounter, - not_built_locally: crate::metrics::IntCounter, - failed: crate::metrics::IntCounter, - external_build: crate::metrics::IntCounter, - published_before: u64, - not_built_locally_before: u64, - failed_before: u64, - external_build_before: u64, -} - -impl OutcomeCounters { - fn snapshot() -> Self { - let metric = crate::metrics::ENVELOPE_SIGNING_OUTCOMES - .as_ref() - .expect("metric should be created"); - let published = metric.with_label_values(&[crate::metrics::ENVELOPE_OUTCOME_PUBLISHED]); - let not_built_locally = - metric.with_label_values(&[crate::metrics::ENVELOPE_OUTCOME_NOT_BUILT_LOCALLY]); - let failed = metric.with_label_values(&[crate::metrics::ENVELOPE_OUTCOME_FAILED]); - let external_build = - metric.with_label_values(&[crate::metrics::ENVELOPE_OUTCOME_EXTERNAL_BUILD]); - Self { - published_before: published.get(), - not_built_locally_before: not_built_locally.get(), - failed_before: failed.get(), - external_build_before: external_build.get(), - published, - not_built_locally, - failed, - external_build, - } - } - - /// Asserts the delta of every outcome label since the snapshot. `assert_deltas` pins - /// the external_build label to zero; external-build tests use `assert_external_build`. - fn assert_external_build(&self, external_build: u64) { - assert_eq!( - self.external_build.get() - self.external_build_before, - external_build, - "unexpected delta on the external_build outcome label" - ); - } - - fn assert_deltas(&self, published: u64, not_built_locally: u64, failed: u64) { - self.assert_external_build(0); - assert_eq!( - self.published.get() - self.published_before, - published, - "unexpected delta on the published outcome label" - ); - assert_eq!( - self.not_built_locally.get() - self.not_built_locally_before, - not_built_locally, - "unexpected delta on the not_built_locally outcome label" - ); - assert_eq!( - self.failed.get() - self.failed_before, - failed, - "unexpected delta on the failed outcome label" - ); + data_ssz: VariableList::new( + GloasProposalData { + block, + payload_root, + } + .as_ssz_bytes(), + ) + .unwrap(), } } -/// Asserts that the duty stopped before any outward action: no dissemination broadcast and no -/// signature collection. -fn assert_no_outward_action(harness: &ValidatorStoreTestHarness, context: &str) { - assert!( - harness.captured_disseminations.lock().is_empty(), - "no dissemination may be broadcast {context}" - ); - assert!( - harness.captured_calls.lock().is_empty(), - "no signature collection may happen {context}" - ); -} - -// ==================== Builder path ==================== - -/// The builder operator disseminates its blinded envelope, signs, and returns the envelope. -#[tokio::test(flavor = "multi_thread")] -async fn builder_disseminates_signs_and_publishes() { - let _guard = METRIC_TEST_LOCK.lock().await; - let (harness, pubkey) = gloas_harness(); - let envelope = self_build_envelope(test_decided_root()); - seed_context(&harness, pubkey, context_for(&envelope, true)); - - let signed = sign_envelope(&harness, pubkey, envelope.clone()) - .await - .expect("the builder path must sign successfully"); - - assert_eq!( - signed.message, envelope, - "the returned message must be the input envelope unchanged" - ); - let disseminations = harness.captured_disseminations.lock(); - assert_eq!( - disseminations.len(), - 1, - "the builder must broadcast exactly one dissemination" - ); - assert_eq!( - disseminations[0].validator_pubkey, pubkey, - "the dissemination must carry the duty validator" - ); - assert_eq!( - disseminations[0].committee_id, - ssv_types::CommitteeId::from(test_operator_ids().to_vec()), - "the dissemination must route to the cluster's committee" - ); - let sent = &disseminations[0].dissemination; - assert_eq!(sent.slot, Slot::new(TEST_SLOT)); - assert_eq!( - sent.blinded_envelope::() - .expect("the broadcast bytes must decode"), - BlindedExecutionPayloadEnvelope::from_full(&envelope), - "the broadcast must carry the blinded form of the local envelope" - ); -} - -/// Exactly one collection happens, over the blinded envelope root under -/// `Domain::BeaconBuilder`, with the `Envelope` partial-signature kind. -#[tokio::test(flavor = "multi_thread")] -async fn signing_root_is_the_blinded_envelope_root_under_beacon_builder_domain() { - let _guard = METRIC_TEST_LOCK.lock().await; - let (harness, pubkey) = gloas_harness(); - let envelope = self_build_envelope(test_decided_root()); - seed_context(&harness, pubkey, context_for(&envelope, true)); - - let domain_hash = envelope_domain_hash(&harness); - let expected_root = - BlindedExecutionPayloadEnvelope::from_full(&envelope).signing_root(domain_hash); +#[test] +fn doubly_blinded_envelope_matches_independent_progressive_root_vector() { + // Arrange: retain the fixture checked against consensus-specs pyspec at a5a1bc630. + let full = ExecutionPayloadEnvelope:: { + payload: ExecutionPayloadGloas::default(), + execution_requests: ExecutionRequestsGloas::default(), + builder_index: 42, + beacon_block_root: Hash256::from_low_u64_be(0x1111), + parent_beacon_block_root: Hash256::from_low_u64_be(0x2222), + }; - sign_envelope(&harness, pubkey, envelope) - .await - .expect("the envelope duty must sign successfully"); + // Act: replace both variable fields with their roots using the production view. + let blinded = BlindedExecutionPayloadEnvelope::from_full(&full); - let captured = harness.captured_calls.lock(); - assert_eq!( - captured.len(), - 1, - "exactly one signature collection must happen" - ); - assert_eq!( - captured[0].signing_root, expected_root, - "the signing root must be the blinded envelope root under Domain::BeaconBuilder" + // Assert: both full parity and an independent fixed root survive the second blinding. + let expected = Hash256::from_slice( + &hex::decode("9af9a50572381e869605147c3d6220c969d6f12087d94393ec0440660f752c5e").unwrap(), ); + assert_eq!(blinded.tree_hash_root(), full.tree_hash_root()); + assert_eq!(blinded.tree_hash_root(), expected); + assert_eq!(blinded.payload_root, full.payload.tree_hash_root()); assert_eq!( - captured[0].metadata.kind, - PartialSignatureKind::Envelope, - "the partial signature kind must be Envelope" - ); - assert_eq!( - captured[0].metadata.role, - Role::EnvelopeProposer, - "the collection role must be EnvelopeProposer" + blinded.execution_requests_root, + full.execution_requests.tree_hash_root() ); } -/// The envelope path succeeds with slashing protection enabled and writes no block-proposal -/// record at the duty slot: a first-time probe insertion afterwards is still accepted as safe. -#[tokio::test(flavor = "multi_thread")] -async fn envelope_signing_does_not_touch_the_slashing_db() { - let _guard = METRIC_TEST_LOCK.lock().await; - let (harness, pubkey) = harness_with_options(HarnessOptions { - disable_slashing_protection: false, - ..gloas_options() - }); - let envelope = self_build_envelope(test_decided_root()); - seed_context(&harness, pubkey, context_for(&envelope, true)); - - let signed = sign_envelope(&harness, pubkey, envelope.clone()) - .await - .expect("the envelope duty must succeed despite slashing protection being enabled"); +#[tokio::test] +async fn self_build_signs_one_pair_then_callback_only_waits() { + // Arrange: local contents match the consensus value. + let (harness, pubkey) = harness(); + let block = block(&harness.spec, BUILDER_INDEX_SELF_BUILD); + let envelope = envelope(&block); + let expected_block = SigningData { + object_root: block.canonical_root(), + domain: domain(&harness, Domain::BeaconProposer), + } + .tree_hash_root(); + let expected_envelope = BlindedExecutionPayloadEnvelope::from_full(&envelope) + .signing_root(domain(&harness, Domain::BeaconBuilder)); - assert_eq!( - signed.message, envelope, - "the returned message must be the input envelope unchanged" - ); - // Any record left by the envelope path would surface here as `SameData` or a - // double-proposal error instead of `Valid`. - let first_time_probe = harness - .slashing_protection - .check_and_insert_block_signing_root( - &pubkey, + // Act: the block callback completes before Lighthouse asks for the envelope. + harness + .validator_store + .sign_block( + pubkey, + UnsignedBlock::Full(FullBlockContents::Block(block)), Slot::new(TEST_SLOT), - Hash256::repeat_byte(0xEE).into(), - ); - assert!( - matches!(first_time_probe, Ok(Safe::Valid)), - "no block-proposal record may exist at the duty slot after envelope signing, got {first_time_probe:?}" - ); -} - -/// A builder whose local BN envelope carries a different execution block hash than the decided -/// bid must not disseminate or sign it. -#[tokio::test(flavor = "multi_thread")] -async fn builder_with_inconsistent_block_hash_broadcasts_nothing() { - let _guard = METRIC_TEST_LOCK.lock().await; - let (harness, pubkey) = gloas_harness(); - let envelope = self_build_envelope(test_decided_root()); - let mut context = context_for(&envelope, true); - context.block_hash = types::ExecutionBlockHash::from_root(Hash256::repeat_byte(0xBB)); - seed_context(&harness, pubkey, context); - let counters = OutcomeCounters::snapshot(); - - let result = sign_envelope(&harness, pubkey, envelope).await; - - assert!( - matches!( - result, - Err(Error::SpecificError( - SpecificError::EnvelopeBuilderInconsistent { .. } - )) - ), - "an inconsistent local envelope must be rejected with the dedicated error, got {result:?}" - ); - counters.assert_deltas(0, 0, 1); - assert_no_outward_action(&harness, "for an inconsistent local envelope"); -} - -/// A builder whose local envelope fails a decision binding must not disseminate or sign it. -#[tokio::test(flavor = "multi_thread")] -async fn builder_with_binding_mismatch_broadcasts_nothing() { - let _guard = METRIC_TEST_LOCK.lock().await; - let (harness, pubkey) = gloas_harness(); - let envelope = self_build_envelope(test_decided_root()); - let mut context = context_for(&envelope, true); - context.parent_block_root = Hash256::repeat_byte(0xCC); - seed_context(&harness, pubkey, context); - let counters = OutcomeCounters::snapshot(); - - let result = sign_envelope(&harness, pubkey, envelope).await; - - assert!( - matches!( - result, - Err(Error::SpecificError( - SpecificError::EnvelopeBindingMismatch { - field: "parent_beacon_block_root" - } - )) - ), - "a binding mismatch must be rejected with the mismatching field, got {result:?}" - ); - counters.assert_deltas(0, 0, 1); - assert_no_outward_action(&harness, "for a binding-mismatched local envelope"); -} - -// ==================== Non-builder path ==================== - -/// Lighthouse's envelope callback on a non-builder returns the delegated sentinel at once: the -/// share is signed by the task `sign_block` spawned, and the callback must neither wait for the -/// dissemination nor collect a second signature. -#[tokio::test(start_paused = true)] -async fn non_builder_callback_returns_delegated_sentinel_without_signing() { - let _guard = METRIC_TEST_LOCK.lock().await; - let (harness, pubkey) = gloas_harness(); - let local_envelope = self_build_envelope(test_decided_root()); - let mut builder_envelope = local_envelope.clone(); - builder_envelope.payload.block_number = 42; - seed_context(&harness, pubkey, context_for(&builder_envelope, false)); - insert_dissemination(&harness, pubkey, &builder_envelope); - let counters = OutcomeCounters::snapshot(); - - let result = sign_envelope(&harness, pubkey, local_envelope).await; - - assert!( - matches!( - result, - Err(Error::SpecificError( - SpecificError::EnvelopeNonBuilderDelegated { .. } - )) - ), - "a non-builder callback must return the delegated sentinel, got {result:?}" - ); - counters.assert_deltas(0, 0, 0); - assert_no_outward_action(&harness, "from a non-builder callback"); -} - -/// The non-builder task signs the disseminated envelope's root, never its own, and broadcasts -/// no dissemination. -#[tokio::test(flavor = "multi_thread")] -async fn non_builder_task_signs_disseminated_root() { - let _guard = METRIC_TEST_LOCK.lock().await; - let (harness, pubkey) = gloas_harness(); - // The builder's envelope differs from what this operator's own node built. - let mut builder_envelope = self_build_envelope(test_decided_root()); - builder_envelope.payload.block_number = 42; - let context = context_for(&builder_envelope, false); - seed_context(&harness, pubkey, context); - let disseminated = insert_dissemination(&harness, pubkey, &builder_envelope); - let counters = OutcomeCounters::snapshot(); - - let domain_hash = envelope_domain_hash(&harness); - - run_non_builder_task(&harness, pubkey, context) + Some(envelope.payload.tree_hash_root()), + ) .await - .expect("the non-builder task must contribute its share"); - - counters.assert_deltas(0, 1, 0); - let captured = harness.captured_calls.lock(); - assert_eq!( - captured.len(), - 1, - "exactly one signature share is contributed" - ); - assert_eq!( - captured[0].signing_root, - disseminated.signing_root(domain_hash), - "the non-builder must sign the disseminated envelope's root" - ); - assert!( - harness.captured_disseminations.lock().is_empty(), - "a non-builder must never broadcast a dissemination" - ); -} - -/// Without a dissemination, the non-builder task times out at the deadline having signed -/// nothing. -#[tokio::test(start_paused = true)] -async fn non_builder_without_dissemination_times_out() { - let _guard = METRIC_TEST_LOCK.lock().await; - let (harness, pubkey) = gloas_harness(); - let envelope = self_build_envelope(test_decided_root()); - let context = context_for(&envelope, false); - seed_context(&harness, pubkey, context); - let counters = OutcomeCounters::snapshot(); - - let result = run_non_builder_task(&harness, pubkey, context).await; - - assert!( - matches!( - result, - Err(Error::SpecificError( - SpecificError::DisseminationTimeout { .. } - )) - ), - "a missing dissemination must time out with the dedicated error, got {result:?}" - ); - counters.assert_deltas(0, 0, 1); - assert_no_outward_action(&harness, "when no dissemination arrives"); -} - -/// Missing dissemination follows the configured due point, including one later than half-slot. -#[tokio::test(start_paused = true)] -async fn dissemination_wait_uses_configured_payload_due() { - let _guard = METRIC_TEST_LOCK.lock().await; - for (bps, expected_offset) in PAYLOAD_DUE_CASES { - // Arrange: no peer supplies the envelope before the configured deadline. - let (harness, pubkey) = harness_with_options(gloas_options_with_payload_due(bps)); - harness.slot_clock.set_slot(TEST_SLOT); - let envelope = self_build_envelope(test_decided_root()); - let context = context_for(&envelope, false); - let signing = run_non_builder_task(&harness, pubkey, context); - tokio::pin!(signing); - assert!(signing.as_mut().now_or_never().is_none()); - - // Act: drive both clocks to just before the deadline, then across it. - let before_deadline = expected_offset - BEFORE_PAYLOAD_DEADLINE; - harness.slot_clock.advance_time(before_deadline); - tokio::time::advance(before_deadline).await; - assert!( - signing.as_mut().now_or_never().is_none(), - "dissemination wait ended early" - ); - harness.slot_clock.advance_time(BEFORE_PAYLOAD_DEADLINE); - tokio::time::advance(BEFORE_PAYLOAD_DEADLINE).await; - - // Assert: the wait ends at payload due without signing or publication. - let result = signing - .now_or_never() - .expect("dissemination wait should end at payload due"); - assert!(matches!( - result, - Err(Error::SpecificError( - SpecificError::DisseminationTimeout { .. } - )) - )); - assert_no_outward_action(&harness, "when no dissemination arrives"); - } -} - -/// Builder and non-builder collection share an absolute deadline, even after a late start. -#[tokio::test(start_paused = true)] -async fn envelope_collection_uses_configured_payload_due() { - let _guard = METRIC_TEST_LOCK.lock().await; - for (bps, expected_offset) in PAYLOAD_DUE_CASES { - for built_locally in [true, false] { - // Arrange: start collection late and withhold quorum on either signing path. - let mut options = gloas_options_with_payload_due(bps); - options.collector_hangs = true; - let (harness, pubkey) = harness_with_options(options); - harness.slot_clock.set_slot(TEST_SLOT); - let envelope = self_build_envelope(test_decided_root()); - let context = context_for(&envelope, built_locally); - seed_context(&harness, pubkey, context); - if !built_locally { - insert_dissemination(&harness, pubkey, &envelope); - } - let call_offset = COLLECTION_START_OFFSET; - harness.slot_clock.advance_time(call_offset); - tokio::time::advance(call_offset).await; - let signing = async { - if built_locally { - sign_envelope(&harness, pubkey, envelope).await.map(|_| ()) - } else { - run_non_builder_task(&harness, pubkey, context).await - } - }; - tokio::pin!(signing); - assert!(signing.as_mut().now_or_never().is_none()); - assert_eq!(envelope_collection_count(&harness), 1); - - // Act: the late start must not move the absolute slot deadline. - let before_deadline = expected_offset - call_offset - BEFORE_PAYLOAD_DEADLINE; - harness.slot_clock.advance_time(before_deadline); - tokio::time::advance(before_deadline).await; - assert!( - signing.as_mut().now_or_never().is_none(), - "collection ended early" - ); - harness.slot_clock.advance_time(BEFORE_PAYLOAD_DEADLINE); - tokio::time::advance(BEFORE_PAYLOAD_DEADLINE).await; - - // Assert: the collector receives the configured payload deadline. - let result = signing - .now_or_never() - .expect("collection should expire at payload due"); - assert!(matches!( - result, - Err(Error::SpecificError( - SpecificError::SignatureCollectionFailed(CollectionError::CollectionTimeout) - )) - )); - } - } -} - -/// Starting at payload due is rejected before either envelope path can sign or disseminate. -#[tokio::test(start_paused = true)] -async fn envelope_paths_reject_at_configured_payload_due() { - let _guard = METRIC_TEST_LOCK.lock().await; - for (bps, expected_offset) in PAYLOAD_DUE_CASES { - for built_locally in [true, false] { - // Arrange: start either signing path exactly at the configured deadline. - let (harness, pubkey) = harness_with_options(gloas_options_with_payload_due(bps)); - harness.slot_clock.set_slot(TEST_SLOT); - let envelope = self_build_envelope(test_decided_root()); - let context = context_for(&envelope, built_locally); - seed_context(&harness, pubkey, context); - harness.slot_clock.advance_time(expected_offset); - tokio::time::advance(expected_offset).await; - - // Act: try to sign an envelope bound to the accepted block context. - let result = if built_locally { - sign_envelope(&harness, pubkey, envelope).await.map(|_| ()) - } else { - run_non_builder_task(&harness, pubkey, context).await - }; - - // Assert: reject before collecting signatures, disseminating, or publishing. - assert!(matches!( - result, - Err(Error::SpecificError( - SpecificError::EnvelopeDeadlinePassed { .. } - )) - )); - assert_no_outward_action(&harness, "at the configured payload-due deadline"); - } + .unwrap(); + { + let calls = harness.captured_calls.lock(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].signing_root, expected_block); + assert_eq!(calls[0].envelope_signing_root, Some(expected_envelope)); + assert_eq!(calls[0].metadata.role, Role::Proposer); + assert_eq!(calls[0].metadata.kind, PartialSignatureKind::PostConsensus); + assert!(!calls[0].wait_only); } -} - -/// A disseminated envelope that fails a decision binding is never signed. -#[tokio::test(flavor = "multi_thread")] -async fn non_builder_rejects_binding_mismatched_dissemination() { - let _guard = METRIC_TEST_LOCK.lock().await; - let (harness, pubkey) = gloas_harness(); - let local_envelope = self_build_envelope(test_decided_root()); - let context = context_for(&local_envelope, false); - seed_context(&harness, pubkey, context); - // Disseminated envelope binds to a different beacon block root. - let mut forged = local_envelope.clone(); - forged.beacon_block_root = Hash256::repeat_byte(0xDD); - insert_dissemination(&harness, pubkey, &forged); - let counters = OutcomeCounters::snapshot(); - - let result = run_non_builder_task(&harness, pubkey, context).await; - - assert!( - matches!( - result, - Err(Error::SpecificError( - SpecificError::EnvelopeBindingMismatch { - field: "beacon_block_root" - } - )) - ), - "a binding-mismatched dissemination must be rejected, got {result:?}" - ); - counters.assert_deltas(0, 0, 1); - assert_no_outward_action(&harness, "for a binding-mismatched dissemination"); -} - -/// Disseminated bytes that do not decode as a blinded envelope are never signed. -#[tokio::test(flavor = "multi_thread")] -async fn non_builder_rejects_undecodable_dissemination() { - let _guard = METRIC_TEST_LOCK.lock().await; - let (harness, pubkey) = gloas_harness(); - let envelope = self_build_envelope(test_decided_root()); - let context = context_for(&envelope, false); - seed_context(&harness, pubkey, context); - harness.dissemination_store.insert( - pubkey, - EnvelopeDissemination { - slot: Slot::new(TEST_SLOT), - envelope: VariableList::new(vec![0xFF; 3]).expect("garbage bytes should fit"), - }, - ); - let counters = OutcomeCounters::snapshot(); - - let result = run_non_builder_task(&harness, pubkey, context).await; - - assert!( - matches!( - result, - Err(Error::SpecificError( - SpecificError::DisseminationUndecodable { .. } - )) - ), - "undecodable disseminated bytes must be rejected, got {result:?}" - ); - counters.assert_deltas(0, 0, 1); - assert_no_outward_action(&harness, "for an undecodable dissemination"); -} - -// ==================== Gate tests ==================== - -/// A pre-Gloas slot is rejected before any outward action. -#[tokio::test(flavor = "multi_thread")] -async fn pre_gloas_slot_is_rejected_before_signing() { - let (harness, pubkey) = harness_with_options(HarnessOptions { - spec: electra_at_genesis_spec(), - ..Default::default() - }); - let envelope = self_build_envelope(test_decided_root()); - - let result = sign_envelope(&harness, pubkey, envelope).await; - - assert!( - matches!( - result, - Err(Error::SpecificError( - SpecificError::EnvelopeBeforeGloas { .. } - )) - ), - "a pre-Gloas envelope must be rejected with the dedicated error, got {result:?}" - ); - assert_no_outward_action(&harness, "for a pre-Gloas envelope"); -} - -/// A non-self-build envelope is rejected before any outward action. -#[tokio::test(flavor = "multi_thread")] -async fn non_self_build_envelope_is_rejected_before_signing() { - let (harness, pubkey) = gloas_harness(); - let mut envelope = self_build_envelope(test_decided_root()); - envelope.builder_index = 7; - - let result = sign_envelope(&harness, pubkey, envelope).await; - - assert!( - matches!( - result, - Err(Error::SpecificError(SpecificError::EnvelopeNotSelfBuild { - builder_index: 7 - })) - ), - "a non-self-build envelope must be rejected with the dedicated error, got {result:?}" - ); - assert_no_outward_action(&harness, "for a non-self-build envelope"); -} - -/// A decided context that committed to an external builder's bid short-circuits the duty -/// before the non-builder path can wait for a dissemination that will never arrive: the -/// runner returns the dedicated no-op sentinel immediately, with no outward action. The -/// local BN's envelope is self-build here (the mixed case: this operator produced a -/// self-build candidate, but consensus decided another operator's external-bid block). -#[tokio::test(start_paused = true)] -async fn external_build_decision_short_circuits_before_waiting() { - let _guard = METRIC_TEST_LOCK.lock().await; - let (harness, pubkey) = gloas_harness(); - let counters = OutcomeCounters::snapshot(); - let envelope = self_build_envelope(test_decided_root()); - let mut context = context_for(&envelope, false); - context.builder_index = 7; - seed_context(&harness, pubkey, context); - - // With a paused clock, a regression back into the dissemination wait would hang the - // test rather than pass it: nothing advances time and no dissemination is inserted. - let result = sign_envelope(&harness, pubkey, envelope).await; - - assert!( - matches!( - result, - Err(Error::SpecificError(SpecificError::EnvelopeExternalBuild { - builder_index: 7 - })) - ), - "an external-build decision must return the no-op sentinel, got {result:?}" - ); - assert_no_outward_action(&harness, "for an external-build decision"); - counters.assert_external_build(1); -} - -/// A future-slot envelope is rejected before any outward action. -#[tokio::test(flavor = "multi_thread")] -async fn future_slot_envelope_is_rejected_before_signing() { - let (harness, pubkey) = gloas_harness(); - let mut envelope = self_build_envelope(test_decided_root()); - envelope.payload.slot_number = Slot::new(TEST_SLOT + 1); - - let result = sign_envelope(&harness, pubkey, envelope).await; - - assert!( - matches!(result, Err(Error::GreaterThanCurrentSlot { .. })), - "a future-slot envelope must be rejected with the dedicated error, got {result:?}" - ); - assert_no_outward_action(&harness, "for a future-slot envelope"); -} - -/// An envelope without a recorded decided context is rejected before any outward action. -#[tokio::test(flavor = "multi_thread")] -async fn missing_decided_context_is_rejected_before_signing() { - let (harness, pubkey) = gloas_harness(); - let envelope = self_build_envelope(test_decided_root()); - - let result = sign_envelope(&harness, pubkey, envelope).await; - - assert!( - matches!( - result, - Err(Error::SpecificError( - SpecificError::DecidedRootUnavailable { .. } - )) - ), - "an envelope without a decided context must be rejected with the dedicated error, got {result:?}" - ); - assert_no_outward_action(&harness, "without a decided context"); -} - -/// A duty starting past the payload-due mark (50% of the slot) is rejected before any outward -/// action, builder or not. -#[tokio::test(flavor = "multi_thread")] -async fn duty_past_the_payload_due_deadline_is_rejected() { - let _guard = METRIC_TEST_LOCK.lock().await; - let (harness, pubkey) = gloas_harness(); - let envelope = self_build_envelope(test_decided_root()); - seed_context(&harness, pubkey, context_for(&envelope, true)); - // Position the clock 7s into the 12s slot, past the 6s payload-due mark. `start_of` is a - // `SlotClock` trait method, so bring the trait into scope locally. - use slot_clock::SlotClock as _; - let slot_start = harness - .slot_clock - .start_of(Slot::new(TEST_SLOT)) - .expect("slot start must exist"); - harness - .slot_clock - .set_current_time(slot_start + std::time::Duration::from_secs(7)); - let counters = OutcomeCounters::snapshot(); - - let result = sign_envelope(&harness, pubkey, envelope).await; - - assert!( - matches!( - result, - Err(Error::SpecificError( - SpecificError::EnvelopeDeadlinePassed { .. } - )) - ), - "a duty past the payload-due mark must be rejected, got {result:?}" - ); - counters.assert_deltas(0, 0, 1); - assert_no_outward_action(&harness, "past the payload-due deadline"); -} - -// ==================== Metrics and failure tests ==================== - -/// The happy path increments only the `published` label. -#[tokio::test(flavor = "multi_thread")] -async fn published_outcome_increments_only_the_published_label() { - let _guard = METRIC_TEST_LOCK.lock().await; - let (harness, pubkey) = gloas_harness(); - let envelope = self_build_envelope(test_decided_root()); - seed_context(&harness, pubkey, context_for(&envelope, true)); - let counters = OutcomeCounters::snapshot(); - - sign_envelope(&harness, pubkey, envelope) + let signed = harness + .validator_store + .sign_execution_payload_envelope(pubkey, envelope.clone()) .await - .expect("the happy-path envelope duty must sign successfully"); + .unwrap(); - counters.assert_deltas(1, 0, 0); + // Assert: publication uses the original contents and does not sign or send a second packet. + assert_eq!(signed.message, envelope); + let calls = harness.captured_calls.lock(); + assert_eq!(calls.len(), 2); + assert!(calls[1].wait_only); + assert_eq!(calls[1].signing_root, expected_envelope); + assert_eq!(calls[1].envelope_signing_root, Some(expected_block)); } -/// A signature-collection failure increments the `failed` label. -#[tokio::test(flavor = "multi_thread")] -async fn collection_failure_increments_the_failed_label() { - let _guard = METRIC_TEST_LOCK.lock().await; - let (harness, pubkey) = harness_with_options(HarnessOptions { - collector_failure: Some(CollectionError::EmptySignature), - ..gloas_options() - }); - let envelope = self_build_envelope(test_decided_root()); - seed_context(&harness, pubkey, context_for(&envelope, true)); - let counters = OutcomeCounters::snapshot(); - - let result = sign_envelope(&harness, pubkey, envelope).await; - - assert!( - matches!( - result, - Err(Error::SpecificError( - SpecificError::SignatureCollectionFailed(CollectionError::EmptySignature) - )) - ), - "a collection failure must surface as SignatureCollectionFailed, got {result:?}" - ); - counters.assert_deltas(0, 0, 1); -} - -/// A failed dissemination broadcast surfaces the dedicated error, increments `failed`, and -/// never starts signature collection. -#[tokio::test(flavor = "multi_thread")] -async fn broadcast_failure_increments_failed_and_signs_nothing() { - let _guard = METRIC_TEST_LOCK.lock().await; - let (harness, pubkey) = harness_with_options(HarnessOptions { - dissemination_failure: Some(CollectionError::DisseminationSendFailed( - "sender closed".to_string(), - )), - ..gloas_options() - }); - let envelope = self_build_envelope(test_decided_root()); - seed_context(&harness, pubkey, context_for(&envelope, true)); - let counters = OutcomeCounters::snapshot(); - - let result = sign_envelope(&harness, pubkey, envelope).await; - - assert!( - matches!( - result, - Err(Error::SpecificError( - SpecificError::DisseminationBroadcastFailed(_) - )) - ), - "a broadcast failure must surface as DisseminationBroadcastFailed, got {result:?}" +#[tokio::test] +async fn external_local_candidate_signs_decided_self_build_without_local_payload() { + // Arrange: the local builder is external, but QBFT decides another operator's self-build. + let (setup, pubkey) = committee(); + let spec = gloas_at_genesis_spec(); + let decided_block = block(&spec, BUILDER_INDEX_SELF_BUILD); + let decided_envelope = envelope(&decided_block); + let decided = decision( + &setup, + pubkey, + &decided_block, + decided_envelope.payload.tree_hash_root(), ); - counters.assert_deltas(0, 0, 1); - assert!( - harness.captured_calls.lock().is_empty(), - "no signature collection may happen after a failed broadcast" + let harness = ValidatorStoreTestHarness::new_with_options( + vec![setup], + OperatorId(1), + HarnessOptions { + decider: MockConsensusDecider::fixed_after_barrier(&decided, 1), + ..options() + }, ); -} - -// ==================== End-to-end tests ==================== - -/// End to end: `sign_block` records the decided context (with builder provenance), then a -/// matching envelope disseminates and signs through the builder path. -#[tokio::test(flavor = "multi_thread")] -async fn sign_block_then_matching_envelope_succeeds() { - let _guard = METRIC_TEST_LOCK.lock().await; - let (harness, pubkey) = gloas_harness(); - // `EmptyBlock::empty` fixes the slot to `spec.genesis_slot`, so the slot is set on the - // inner struct before wrapping. The bid commitments are aligned with the envelope the - // test presents afterwards, since the write site now records them for validation. - let mut gloas_block = BeaconBlockGloas::::empty(&harness.spec); - gloas_block.slot = Slot::new(TEST_SLOT); - { - let bid = &mut gloas_block.body.signed_execution_payload_bid.message; - // A self-build block: the empty fixture's zeroed bid must carry the sentinel and the - // commitments the presented envelope will bind to. - bid.builder_index = BUILDER_INDEX_SELF_BUILD; - bid.execution_requests_root = - ExecutionRequestsGloas::::default().tree_hash_root(); - } - let bid = &gloas_block.body.signed_execution_payload_bid.message; - let parent_block_root = bid.parent_block_root; - let block = BeaconBlock::Gloas(gloas_block); - let mut envelope = self_build_envelope(block.canonical_root()); - envelope.parent_beacon_block_root = parent_block_root; + let expected = BlindedExecutionPayloadEnvelope::from_full(&decided_envelope) + .signing_root(domain(&harness, Domain::BeaconBuilder)); + // Act: no local payload or later dissemination is supplied. harness .validator_store .sign_block( pubkey, - UnsignedBlock::Full(FullBlockContents::Block(block)), + UnsignedBlock::Full(FullBlockContents::Block(block(&spec, EXTERNAL_BUILDER))), Slot::new(TEST_SLOT), None, ) .await - .expect("the Gloas block duty must sign successfully"); - let signed = sign_envelope(&harness, pubkey, envelope.clone()) - .await - .expect("an envelope matching the recorded context must sign through the builder path"); + .unwrap(); + let result = harness + .validator_store + .sign_execution_payload_envelope(pubkey, decided_envelope) + .await; - assert_eq!( - signed.message, envelope, - "the returned message must be the input envelope unchanged" - ); - assert_eq!( - harness.captured_disseminations.lock().len(), - 1, - "the builder provenance recorded by sign_block must drive a dissemination" - ); + // Assert: both shares were signed from the decision, while local publication is suppressed. + assert!(matches!( + result, + Err(Error::SpecificError(SpecificError::EnvelopeNotLocal { .. })) + )); + let calls = harness.captured_calls.lock(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].envelope_signing_root, Some(expected)); } -/// Mixed bid, the case Lighthouse's callback cannot serve: this operator's own node took an -/// external builder's bid, the cluster decided another operator's self-build block. `sign_block` -/// must spawn the non-builder task, which signs the disseminated envelope's root once it arrives. -/// A later callback for the slot returns the delegated sentinel and adds no second share. -#[tokio::test(flavor = "multi_thread")] -async fn sign_block_with_another_operators_self_build_block_signs_its_envelope() { - let _guard = METRIC_TEST_LOCK.lock().await; - let (committee, pubkey) = single_validator_committee(); +#[tokio::test] +async fn decided_external_builder_sends_only_block_share() { + // Arrange: the local self-build loses to an external builder decision. + let (setup, pubkey) = committee(); let spec = gloas_at_genesis_spec(); - let decided_block = gloas_block_with_bid(&spec, BUILDER_INDEX_SELF_BUILD); - let decided = decided_consensus_data(&committee, pubkey, &decided_block); + let decided = decision( + &setup, + pubkey, + &block(&spec, EXTERNAL_BUILDER), + Hash256::ZERO, + ); let harness = ValidatorStoreTestHarness::new_with_options( - vec![committee], + vec![setup], OperatorId(1), HarnessOptions { decider: MockConsensusDecider::fixed_after_barrier(&decided, 1), - ..gloas_options() + ..options() }, ); - let local_block = gloas_block_with_bid(&spec, 7); - assert_ne!( - local_block.canonical_root(), - decided_block.canonical_root(), - "the fixture must model a decided block this operator did not build" - ); - let builder_envelope = envelope_for_block(&decided_block); - let domain_hash = envelope_domain_hash(&harness); + let local = block(&spec, BUILDER_INDEX_SELF_BUILD); + let local_envelope = envelope(&local); + // Act: sign the committee decision, then exercise Lighthouse's local callback. harness .validator_store .sign_block( pubkey, - UnsignedBlock::Full(FullBlockContents::Block(local_block)), + UnsignedBlock::Full(FullBlockContents::Block(local)), Slot::new(TEST_SLOT), - None, + Some(local_envelope.payload.tree_hash_root()), ) .await - .expect("the Gloas block duty must sign successfully"); - // The builder operator's dissemination arrives after the block decided. - let disseminated = insert_dissemination(&harness, pubkey, &builder_envelope); - - let signed_root = wait_for_envelope_signing_root(&harness).await; - - assert_eq!( - signed_root, - disseminated.signing_root(domain_hash), - "the spawned task must sign the disseminated envelope's root" - ); - assert!( - harness.captured_disseminations.lock().is_empty(), - "a non-builder must never broadcast a dissemination" - ); - - // Lighthouse's callback for the same slot: nothing to publish, no second share. - let result = sign_envelope(&harness, pubkey, envelope_for_block(&decided_block)).await; - assert!( - matches!( - result, - Err(Error::SpecificError( - SpecificError::EnvelopeNonBuilderDelegated { .. } - )) - ), - "the callback on a non-builder must return the delegated sentinel, got {result:?}" - ); - assert_eq!( - envelope_collection_count(&harness), - 1, - "the callback must not collect a second envelope share" - ); -} - -/// The builder operator signs from Lighthouse's callback only: `sign_block` on a block this -/// operator built spawns no non-builder task. A valid dissemination is stored up front so a -/// wrongly spawned task would sign immediately and be caught. -#[tokio::test(flavor = "multi_thread")] -async fn sign_block_as_builder_spawns_no_non_builder_task() { - let _guard = METRIC_TEST_LOCK.lock().await; - let (harness, pubkey) = gloas_harness(); - let block = gloas_block_with_bid(&harness.spec, BUILDER_INDEX_SELF_BUILD); - insert_dissemination(&harness, pubkey, &envelope_for_block(&block)); + .unwrap(); + let result = harness + .validator_store + .sign_execution_payload_envelope(pubkey, local_envelope) + .await; - harness + // Assert: the external decision requires no envelope share or wait. + assert!(matches!( + result, + Err(Error::SpecificError( + SpecificError::EnvelopeExternalBuild { .. } + )) + )); + let calls = harness.captured_calls.lock(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].envelope_signing_root, None); +} + +#[tokio::test] +async fn missing_local_self_build_payload_root_aborts_before_signing() { + // Arrange: a malformed stateless production response omitted the self-build payload root. + let (harness, pubkey) = harness(); + let block = block(&harness.spec, BUILDER_INDEX_SELF_BUILD); + + // Act: enter the actual block callback without a root. + let result = harness .validator_store .sign_block( pubkey, @@ -1190,122 +335,287 @@ async fn sign_block_as_builder_spawns_no_non_builder_task() { Slot::new(TEST_SLOT), None, ) - .await - .expect("the Gloas block duty must sign successfully"); - let_spawned_tasks_run().await; + .await; - assert_eq!( - envelope_collection_count(&harness), - 0, - "the builder must not sign its envelope before Lighthouse's callback" - ); + // Assert: no invented root or outward signature can follow the malformed response. + assert!(matches!( + result, + Err(Error::SpecificError(SpecificError::MissingLocalPayloadRoot)) + )); + assert!(harness.captured_calls.lock().is_empty()); +} + +#[tokio::test] +async fn each_decided_envelope_field_is_required_before_publication_wait() { + for field in [ + "payload_root", + "execution_requests_root", + "builder_index", + "beacon_block_root", + "parent_beacon_block_root", + "block_hash", + ] { + // Arrange: start with matching contents and change exactly one decided field. + let (harness, pubkey) = harness(); + let envelope = envelope(&block(&harness.spec, BUILDER_INDEX_SELF_BUILD)); + let mut context = context(&envelope, true); + match field { + "payload_root" => context.payload_root = Hash256::repeat_byte(1), + "execution_requests_root" => context.execution_requests_root = Hash256::repeat_byte(2), + "builder_index" => context.builder_index = EXTERNAL_BUILDER, + "block_hash" => { + context.block_hash = types::ExecutionBlockHash::from_root(Hash256::repeat_byte(5)) + } + "beacon_block_root" => context.beacon_block_root = Hash256::repeat_byte(3), + "parent_beacon_block_root" => context.parent_block_root = Hash256::repeat_byte(4), + _ => unreachable!(), + } + seed(&harness, pubkey, context); + + // Act: try to publish local contents against the mismatching decision. + let result = harness + .validator_store + .sign_execution_payload_envelope(pubkey, envelope) + .await; + + // Assert: rejection happens before even waiting on a collector. + assert!(result.is_err(), "must bind {field}"); + assert!( + harness.captured_calls.lock().is_empty(), + "must reject {field} before collection" + ); + } } -/// A decided block that committed to an external builder's bid has no self-build envelope duty, -/// so `sign_block` spawns nothing even though this operator did not build the block. -#[tokio::test(flavor = "multi_thread")] -async fn sign_block_with_external_build_decision_spawns_no_non_builder_task() { - let _guard = METRIC_TEST_LOCK.lock().await; - let (committee, pubkey) = single_validator_committee(); - let spec = gloas_at_genesis_spec(); - let decided_block = gloas_block_with_bid(&spec, 7); - let decided = decided_consensus_data(&committee, pubkey, &decided_block); +#[tokio::test] +async fn slashable_block_signs_neither_block_nor_envelope_share() { + // Arrange: protection already records a different block at this slot. + let (setup, pubkey) = committee(); let harness = ValidatorStoreTestHarness::new_with_options( - vec![committee], + vec![setup], OperatorId(1), HarnessOptions { - decider: MockConsensusDecider::fixed_after_barrier(&decided, 1), - ..gloas_options() + disable_slashing_protection: false, + ..options() }, ); - let local_block = gloas_block_with_bid(&spec, BUILDER_INDEX_SELF_BUILD); - insert_dissemination(&harness, pubkey, &envelope_for_block(&decided_block)); - harness + .slashing_protection + .check_and_insert_block_signing_root( + &pubkey, + Slot::new(TEST_SLOT), + Hash256::repeat_byte(9).into(), + ) + .unwrap(); + let block = block(&harness.spec, BUILDER_INDEX_SELF_BUILD); + let payload_root = envelope(&block).payload.tree_hash_root(); + + // Act: the paired signing path must pass the existing block slashing gate. + let result = harness .validator_store .sign_block( pubkey, - UnsignedBlock::Full(FullBlockContents::Block(local_block)), + UnsignedBlock::Full(FullBlockContents::Block(block)), Slot::new(TEST_SLOT), - None, + Some(payload_root), ) - .await - .expect("the Gloas block duty must sign successfully"); - let_spawned_tasks_run().await; + .await; - assert_eq!( - envelope_collection_count(&harness), - 0, - "an external-build decision carries no self-build envelope duty" - ); + // Assert: both shares remain unsent, not merely the conflicting block share. + assert!(matches!(result, Err(Error::Slashable(_)))); + assert!(harness.captured_calls.lock().is_empty()); } -/// Lighthouse can invoke `sign_block` twice for one slot: a second block-service notification -/// for the same slot was observed on a devnet. The repeat must fail slashing protection inside -/// `sign_abstract_block` as `SameData` before `sign_block` reaches the non-builder spawn, so only -/// the first call's task signs. This pins the spawn's placement after `sign_abstract_block`: the -/// other spawn-path tests disable slashing protection and call `sign_block` once, so a spawn -/// moved above the slashing check would pass them and double-sign on the devnet. -#[tokio::test(flavor = "multi_thread")] -async fn repeated_sign_block_for_the_same_slot_spawns_one_non_builder_task() { - let _guard = METRIC_TEST_LOCK.lock().await; - let (committee, pubkey) = single_validator_committee(); +#[tokio::test(start_paused = true)] +async fn envelope_wait_times_out_without_affecting_completed_block() { + // Arrange: the block quorum succeeds, then the envelope quorum remains unavailable. + let (harness, pubkey) = harness(); + let block = block(&harness.spec, BUILDER_INDEX_SELF_BUILD); + let envelope = envelope(&block); + let signed_block = harness + .validator_store + .sign_block( + pubkey, + UnsignedBlock::Full(FullBlockContents::Block(block)), + Slot::new(TEST_SLOT), + Some(envelope.payload.tree_hash_root()), + ) + .await; + assert!(signed_block.is_ok()); + harness.hang_signature_collection(); + + // Act: only the envelope callback waits for its independent threshold. + let result = harness + .validator_store + .sign_execution_payload_envelope(pubkey, envelope) + .await; + + // Assert: the deadline ends that wait, with one paired send and one wait-only call. + assert!(result.is_err()); + let calls = harness.captured_calls.lock(); + assert_eq!(calls.len(), 2); + assert!(!calls[0].wait_only); + assert!(calls[1].wait_only); +} + +#[tokio::test] +async fn envelope_past_payload_deadline_never_waits() { + // Arrange: valid local contents, but the configured payload deadline has elapsed. + let (harness, pubkey) = harness(); + let envelope = envelope(&block(&harness.spec, BUILDER_INDEX_SELF_BUILD)); + seed(&harness, pubkey, context(&envelope, true)); + let slot_start = harness.slot_clock.start_of(Slot::new(TEST_SLOT)).unwrap(); + harness + .slot_clock + .set_current_time(slot_start + Duration::from_secs(7)); + + // Act: exercise the real deadline guard. + let result = harness + .validator_store + .sign_execution_payload_envelope(pubkey, envelope) + .await; + + // Assert: expiry is checked before registering any collection wait. + assert!(matches!( + result, + Err(Error::SpecificError( + SpecificError::EnvelopeDeadlinePassed { .. } + )) + )); + assert!(harness.captured_calls.lock().is_empty()); +} + +#[tokio::test] +async fn same_block_with_different_decided_payload_signs_decision_but_cannot_publish_local_contents() + { + // Arrange: block bytes match locally, but the decided payload root belongs to different + // contents. + let (setup, pubkey) = committee(); let spec = gloas_at_genesis_spec(); - let decided_block = gloas_block_with_bid(&spec, BUILDER_INDEX_SELF_BUILD); - let decided = decided_consensus_data(&committee, pubkey, &decided_block); + let block = block(&spec, BUILDER_INDEX_SELF_BUILD); + let local_envelope = envelope(&block); + let mut decided_envelope = local_envelope.clone(); + decided_envelope.payload.block_number = 42; + let decided = decision( + &setup, + pubkey, + &block, + decided_envelope.payload.tree_hash_root(), + ); let harness = ValidatorStoreTestHarness::new_with_options( - vec![committee], + vec![setup], OperatorId(1), HarnessOptions { decider: MockConsensusDecider::fixed_after_barrier(&decided, 1), - disable_slashing_protection: false, - ..gloas_options() + ..options() }, ); - let local_block = gloas_block_with_bid(&spec, 7); - assert_ne!( - local_block.canonical_root(), - decided_block.canonical_root(), - "the fixture must model a decided block this operator did not build" - ); - // Stored up front so the legitimately spawned task signs immediately, and a wrongly spawned - // second task would too. - insert_dissemination(&harness, pubkey, &envelope_for_block(&decided_block)); + let expected = BlindedExecutionPayloadEnvelope::from_full(&decided_envelope) + .signing_root(domain(&harness, Domain::BeaconBuilder)); + // Act: sign the decided roots, then offer the local payload to the publication callback. harness .validator_store .sign_block( pubkey, - UnsignedBlock::Full(FullBlockContents::Block(local_block.clone())), + UnsignedBlock::Full(FullBlockContents::Block(block)), Slot::new(TEST_SLOT), - None, + Some(local_envelope.payload.tree_hash_root()), ) .await - .expect("the first Gloas block duty must sign successfully"); - wait_for_envelope_signing_root(&harness).await; - - let repeat = harness + .unwrap(); + let result = harness .validator_store - .sign_block( - pubkey, - UnsignedBlock::Full(FullBlockContents::Block(local_block)), - Slot::new(TEST_SLOT), - None, - ) + .sign_execution_payload_envelope(pubkey, local_envelope) .await; - assert!( - matches!(repeat, Err(Error::SameData)), - "a repeated sign_block for the same slot must be rejected by slashing protection as SameData, got {repeat:?}" - ); - let_spawned_tasks_run().await; - assert_eq!( - envelope_collection_count(&harness), - 1, - "a repeated sign_block must not spawn a second non-builder task" - ); - assert!( - harness.captured_disseminations.lock().is_empty(), - "a non-builder must never broadcast a dissemination" - ); + // Assert: same block ownership is insufficient when the payload itself differs. + assert!(matches!( + result, + Err(Error::SpecificError( + SpecificError::EnvelopeBindingMismatch { + field: "payload_root" + } + )) + )); + let calls = harness.captured_calls.lock(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].envelope_signing_root, Some(expected)); +} + +#[tokio::test] +async fn invalid_envelope_callback_inputs_never_wait() { + for case in ["future_slot", "external_envelope", "missing_decision"] { + // Arrange: no decided context, with one callback precondition violated per case. + let (harness, pubkey) = harness(); + let mut envelope = envelope(&block(&harness.spec, BUILDER_INDEX_SELF_BUILD)); + match case { + "future_slot" => envelope.payload.slot_number = Slot::new(TEST_SLOT + 1), + "external_envelope" => envelope.builder_index = EXTERNAL_BUILDER, + _ => {} + } + + // Act: the callback validates inputs before interacting with collectors. + let result = harness + .validator_store + .sign_execution_payload_envelope(pubkey, envelope) + .await; + + // Assert: retain the precise error ordering and no outward action. + match case { + "future_slot" => assert!(matches!(result, Err(Error::GreaterThanCurrentSlot { .. }))), + "external_envelope" => assert!(matches!( + result, + Err(Error::SpecificError( + SpecificError::EnvelopeNotSelfBuild { .. } + )) + )), + _ => assert!(matches!( + result, + Err(Error::SpecificError( + SpecificError::DecidedRootUnavailable { .. } + )) + )), + } + assert!(harness.captured_calls.lock().is_empty()); + } +} + +#[tokio::test(start_paused = true)] +async fn envelope_wait_uses_configured_payload_deadline() { + for (payload_due_bps, due_seconds) in [(2500, 3), (7500, 9)] { + // Arrange: keep collection pending from one second into the slot. + let (setup, pubkey) = committee(); + let mut spec = (*gloas_at_genesis_spec()).clone(); + spec.payload_due_bps = payload_due_bps; + let harness = ValidatorStoreTestHarness::new_with_options( + vec![setup], + OperatorId(1), + HarnessOptions { + spec: std::sync::Arc::new(spec.compute_derived_values::()), + collector_hangs: true, + ..options() + }, + ); + let envelope = envelope(&block(&harness.spec, BUILDER_INDEX_SELF_BUILD)); + seed(&harness, pubkey, context(&envelope, true)); + let slot_start = harness.slot_clock.start_of(Slot::new(TEST_SLOT)).unwrap(); + harness + .slot_clock + .set_current_time(slot_start + Duration::from_secs(1)); + let start = tokio::time::Instant::now(); + + // Act: Tokio advances only to the production callback's actual timeout. + let result = harness + .validator_store + .sign_execution_payload_envelope(pubkey, envelope) + .await; + + // Assert: #1310's configured deadline survives the fold, including non-default values. + assert!(result.is_err()); + assert_eq!(start.elapsed(), Duration::from_secs(due_seconds - 1)); + let calls = harness.captured_calls.lock(); + assert_eq!(calls.len(), 1); + assert!(calls[0].wait_only); + } }