From 33bad730c97c583e7a948dd79dd7f3045fab15e7 Mon Sep 17 00:00:00 2001 From: shane-moore Date: Thu, 24 Sep 2026 18:43:44 -0700 Subject: [PATCH] feat(proposer): fold Gloas envelope signing Decide the block and payload root together and derive envelope signing from that decision. Enable stateless production, send paired shares and validate complete envelope bindings before local publication. Retire the separate dissemination flow with the replacement. Refs #1309. --- Cargo.lock | 15 +- Cargo.toml | 2 - anchor/client/Cargo.toml | 1 - anchor/client/src/lib.rs | 7 +- anchor/common/dissemination_store/Cargo.toml | 12 - anchor/common/dissemination_store/src/lib.rs | 253 --- anchor/common/qbft/src/tests.rs | 118 ++ anchor/common/ssv_types/src/consensus.rs | 306 ++-- anchor/common/ssv_types/src/dissemination.rs | 52 - anchor/common/ssv_types/src/lib.rs | 1 - anchor/common/ssv_types/src/message.rs | 25 +- anchor/common/ssv_types/src/msgid.rs | 64 +- anchor/common/ssv_types/src/partial_sig.rs | 9 +- .../testdata/devnet8_gloas_block_144352.ssz | Bin 0 -> 2142 bytes anchor/message_receiver/Cargo.toml | 1 - anchor/message_receiver/src/manager.rs | 19 +- .../src/proposer_view_tests.rs | 1 - .../src/consensus_message.rs | 132 +- anchor/message_validator/src/dissemination.rs | 720 -------- anchor/message_validator/src/duty_state.rs | 32 +- anchor/message_validator/src/lib.rs | 60 +- .../message_validator/src/message_counts.rs | 6 +- .../src/partial_signature.rs | 868 +++------ anchor/operator_doppelganger/src/service.rs | 11 - anchor/qbft_manager/src/lib.rs | 4 +- anchor/signature_collector/src/lib.rs | 59 - anchor/signature_collector/src/tests.rs | 81 +- anchor/validator_store/Cargo.toml | 2 +- anchor/validator_store/src/envelope.rs | 30 + anchor/validator_store/src/instrumentation.rs | 3 +- anchor/validator_store/src/lib.rs | 573 +++--- anchor/validator_store/src/metrics.rs | 8 +- anchor/validator_store/src/testing/common.rs | 80 +- .../src/testing/decided_block_root.rs | 33 + .../src/testing/decided_block_root_e2e.rs | 25 +- .../src/testing/envelope_signing.rs | 1600 +++++------------ 36 files changed, 1380 insertions(+), 3833 deletions(-) delete mode 100644 anchor/common/dissemination_store/Cargo.toml delete mode 100644 anchor/common/dissemination_store/src/lib.rs delete mode 100644 anchor/common/ssv_types/src/dissemination.rs create mode 100644 anchor/common/ssv_types/testdata/devnet8_gloas_block_144352.ssz delete mode 100644 anchor/message_validator/src/dissemination.rs create mode 100644 anchor/validator_store/src/envelope.rs 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 0000000000000000000000000000000000000000..ea4542b6f7783906040ebc30ca197a33764aa37a GIT binary patch literal 2142 zcma)73piA17(OmzM6=W67SW0diO6+JD-F4oD3{zDv?vLY>B6Kbnu>%pMx$Y6T-U8g zhOs1~P)Ij3q=ZCn*)Go6b7qXK_IbAbpZWjq{lCl1`~Tm2&iDZUa5JzeySLN|V_E5D zd)y}XXN$ZjKgFWV(X7>+9gODqI~p&Q8w{K_m?yHDe(f;BNMX5Sy2SK{9BRh#_Epvn zO0IdOhcf+aUu|aW%W--Tl!Olaa7?yQSw>+pK(=msP;#H1F#Ib3P;Dc`vnmV}rKGNc z5wZT`W{h<@YWV!klUmS)O z>pNr9?g)QNLeZvE~f6?1aN4F?N#< z=#fyh)K&Kg5UtOi@yGSvYF?KXj#VZ)QkJL}>%WZl;vEED#`MOMstelDtm&O2O3Ro@ z9^NE+?q5>+Bl0CvtrSgNOA7))_c#15T^}Q3LCx3p0(>Qc*&+e}$b{hW8Cf6@VnZN_ z2O$Wy?0E=5v-1e{%<(=85eUk+uUj8Vekm!Q8SU`yEjHY`=1jvee|L~wUsIi=p|rY) zCPy1|QI*&>u{Sfh`AqVnokKp|r+s{a*14)kojb;S;S>zjxNcCBZ*P=$pR?bPk71eP z)1wye$)!ZQGtyc!)AuaYsJC;rM~cSOIMcluWGEQ9GiVXK9QPdN<{M|Mu7m&q)} zdX+j?PjnfFoHwd^mftBmpfRML|4ZQ86{A;2FwDTR#&*+FpgnEf1dZ4V?Qf0BBx7gS zy)6-qFglzZ`{=2*^;GryAjc}Z)(f9SmCGa_xipSq>0<}YATuy#6J@J*=n)9Q@(3eW zO&W)rLHg>_ziJNL5Reyv0mS_#hu1}chYOAKAqg>GkZgV(vPH(5e^tXy5)$D;e1RbN ztHJ$87y>U(D6oG)F@nHWv2Gbw$#7G?a7anI^9K>ZIKw-9Vu#<6XSA1M%piga@(tO zpG>OO!x)|Wr;OSr0~A_h3aO!nvP5h5$Zdlhijij*hdTJKut~3v=y{hld6|q#ek9b= zZ5n8G&@Cr-*?LsHBqApQ`~)FG@RsE0IV|FP_-h|*;wor5K?!(_xXbLFLNB$p_D?wR zE)#9fS>uz=ATwK)d=^a^XQqX=Sq zQ?7-j$!;WGsS0eq6nO~SlWt8l(Ahr@cy~Bx3TIwb_b()9<;5G6Q-G;%sV7^)px31t zm6mqC1<4J{QQRA{!C!_u$(hFj!}>k4#z1|rrxCBBGvr&L#JyoL$INsumeSu9{>~1U zK>4&uAveK73%jLbO3u(z#G^bzt{|Veyre9gsudaJbIG}`N>MZZwGQiW0k{ueXee@a zqAdjFtXd}LWA*r_B1M+baKTmwhX2fnzgT|q6^GUfQqMUNAKl65{`@kD9#}>otT0%N z?6-)vkY@)P2gjjVo(?PF7@H?OE`qoqo2O^eIGc+k*8Zs9f;X9v5Sb!BT5IZU#cWH% zfr+)%z9_;#EnWhhz0&>ENv5ySTGvF!<3(E2SXyxEz3DYXtjb|r?B-oLuRGNJ$vFHV zJ6__Xbd!lwDv^;^ud$YyOs7i@{ZuwJSX@tKdJWbJI=w^y7OXfAZCTj!0skKq00jB} z`y@bK)smYROd?ZyhFEvUgl5K1k-j$Ru!R^c*MaZYSNrg5c?d}V2iG)(aO F`8VW0h1>uD literal 0 HcmV?d00001 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); + } }