diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 7eff6fb99a3..12f8154e081 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -65,6 +65,7 @@ use crate::payload_attestation_verification::VerifiedPayloadAttestationMessage; use crate::payload_bid_verification::payload_bid_cache::GossipVerifiedPayloadBidCache; #[cfg(not(test))] use crate::payload_envelope_streamer::{EnvelopeRequestSource, launch_payload_envelope_stream}; +use crate::payload_envelope_verification::gossip_verified_envelope_cache::GossipVerifiedEnvelopeCache; use crate::pending_payload_cache::PendingPayloadCache; use crate::pending_payload_cache::{ Availability as PayloadAvailability, @@ -485,6 +486,8 @@ pub struct BeaconChain { pub pre_finalization_block_cache: PreFinalizationBlockCache, /// A cache used to store gossip verified payload bids. pub gossip_verified_payload_bid_cache: GossipVerifiedPayloadBidCache, + /// A cache used to deduplicate gossip verified execution payload envelopes. + pub gossip_verified_envelope_cache: GossipVerifiedEnvelopeCache, /// A cache used to store gossip verified proposer preferences. pub gossip_verified_proposer_preferences_cache: GossipVerifiedProposerPreferenceCache, /// A cache used to produce light_client server messages @@ -6952,6 +6955,7 @@ impl BeaconChain { self.block_times_cache.write().prune(slot); self.envelope_times_cache.write().prune(slot); self.gossip_verified_payload_bid_cache.prune(slot); + self.gossip_verified_envelope_cache.prune(slot); self.gossip_verified_proposer_preferences_cache.prune(slot); // Don't run heavy-weight tasks during sync. diff --git a/beacon_node/beacon_chain/src/builder.rs b/beacon_node/beacon_chain/src/builder.rs index 3b137f6faa3..ec35667c818 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -1058,6 +1058,7 @@ where kzg: self.kzg.clone(), rng: Arc::new(Mutex::new(rng)), gossip_verified_payload_bid_cache: <_>::default(), + gossip_verified_envelope_cache: <_>::default(), gossip_verified_proposer_preferences_cache: <_>::default(), }; diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs index 341c7c00f02..945196d0148 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs @@ -15,7 +15,8 @@ use crate::{ beacon_proposer_cache::{self, BeaconProposerCache}, canonical_head::CanonicalHead, payload_envelope_verification::{ - EnvelopeError, EnvelopeProcessingSnapshot, load_snapshot_from_state_root, + EnvelopeError, EnvelopeProcessingSnapshot, + gossip_verified_envelope_cache::GossipVerifiedEnvelopeCache, load_snapshot_from_state_root, }, validator_pubkey_cache::ValidatorPubkeyCache, }; @@ -30,6 +31,7 @@ pub struct GossipVerificationContext<'a, T: BeaconChainTypes> { pub validator_pubkey_cache: &'a RwLock>, pub genesis_validators_root: Hash256, pub event_handler: &'a Option>, + pub gossip_verified_envelope_cache: &'a GossipVerifiedEnvelopeCache, } /// Verify that an execution payload envelope is consistent with its beacon block @@ -120,8 +122,22 @@ impl GossipVerifiedEnvelope { .epoch .start_slot(T::EthSpec::slots_per_epoch()); - // TODO(EIP-7732): check that we haven't seen another valid `SignedExecutionPayloadEnvelope` - // for this block root from this builder - envelope status table check + // Ignore a second valid `SignedExecutionPayloadEnvelope` for this block root from this + // builder, per the Gloas `execution_payload` gossip rules. Only envelopes that pass the + // full verification below are recorded (see the insert before returning `Ok`), so an + // earlier invalid envelope from this builder never suppresses a later valid one. Checking + // here, before the state load and signature verification, keeps duplicates cheap. + if ctx.gossip_verified_envelope_cache.seen_envelope( + &envelope.slot(), + beacon_block_root, + envelope.builder_index, + ) { + return Err(EnvelopeError::EnvelopeAlreadySeen { + block_root: beacon_block_root, + builder_index: envelope.builder_index, + }); + } + let block = match ctx.store.try_get_full_block(&beacon_block_root)? { Some(DatabaseBlock::Full(block)) => Arc::new(block), Some(DatabaseBlock::Blinded(_)) | None => { @@ -215,6 +231,14 @@ impl GossipVerifiedEnvelope { return Err(EnvelopeError::BadSignature); } + // Record this now fully-verified envelope so any subsequent duplicate for the same block + // root from the same builder is ignored by the `seen_envelope` check above. + ctx.gossip_verified_envelope_cache.insert_seen_envelope( + block_slot, + beacon_block_root, + builder_index, + ); + if let Some(event_handler) = ctx.event_handler.as_ref() && event_handler.has_execution_payload_gossip_subscribers() { @@ -251,6 +275,7 @@ impl BeaconChain { validator_pubkey_cache: &self.validator_pubkey_cache, genesis_validators_root: self.genesis_validators_root, event_handler: &self.event_handler, + gossip_verified_envelope_cache: &self.gossip_verified_envelope_cache, } } diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope_cache.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope_cache.rs new file mode 100644 index 00000000000..c1bfdff4894 --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope_cache.rs @@ -0,0 +1,105 @@ +use std::collections::{BTreeMap, HashSet}; + +use parking_lot::RwLock; +use types::{BuilderIndex, Hash256, Slot}; + +/// Tracks which execution payload envelopes have already been seen over gossip, keyed by +/// `(block_root, builder_index)`, so that a second valid `SignedExecutionPayloadEnvelope` for the +/// same block root from the same builder can be ignored per the Gloas `execution_payload` gossip +/// rules. +/// +/// This is the envelope counterpart of the seen-builder half of +/// [`GossipVerifiedPayloadBidCache`](crate::payload_bid_verification::payload_bid_cache::GossipVerifiedPayloadBidCache). +/// Unlike the bid cache it does not need to be generic over `BeaconChainTypes`, because it only +/// stores the `(block_root, builder_index)` identity rather than a verified message. The `Slot` is +/// retained purely so entries can be pruned once they fall below the current slot; the dedup +/// identity is `(block_root, builder_index)`. +#[derive(Default)] +pub struct GossipVerifiedEnvelopeCache { + seen_envelope: RwLock>>, +} + +impl GossipVerifiedEnvelopeCache { + /// A gossip verified envelope for `(block_root, builder_index)` already exists at `slot`. + pub fn seen_envelope( + &self, + slot: &Slot, + block_root: Hash256, + builder_index: BuilderIndex, + ) -> bool { + self.seen_envelope + .read() + .get(slot) + .is_some_and(|seen_envelopes| seen_envelopes.contains(&(block_root, builder_index))) + } + + /// Record that a valid envelope for `(block_root, builder_index)` at `slot` has been seen. + pub fn insert_seen_envelope( + &self, + slot: Slot, + block_root: Hash256, + builder_index: BuilderIndex, + ) { + self.seen_envelope + .write() + .entry(slot) + .or_default() + .insert((block_root, builder_index)); + } + + /// Prune anything before `current_slot`. + pub fn prune(&self, current_slot: Slot) { + self.seen_envelope + .write() + .retain(|&slot, _| slot >= current_slot); + } +} + +#[cfg(test)] +mod tests { + use super::GossipVerifiedEnvelopeCache; + use types::{Hash256, Slot}; + + fn root(byte: u8) -> Hash256 { + Hash256::repeat_byte(byte) + } + + #[test] + fn seen_envelope_discriminates_by_block_root_and_builder() { + let cache = GossipVerifiedEnvelopeCache::default(); + let slot = Slot::new(3); + + assert!(!cache.seen_envelope(&slot, root(1), 7)); + + cache.insert_seen_envelope(slot, root(1), 7); + + // Same (block_root, builder_index) at the same slot is now seen. + assert!(cache.seen_envelope(&slot, root(1), 7)); + // A different builder for the same block root is not seen. + assert!(!cache.seen_envelope(&slot, root(1), 8)); + // A different block root for the same builder is not seen. + assert!(!cache.seen_envelope(&slot, root(2), 7)); + // The same identity at a different slot is tracked independently. + assert!(!cache.seen_envelope(&Slot::new(4), root(1), 7)); + } + + #[test] + fn prune_removes_old_retains_current() { + let cache = GossipVerifiedEnvelopeCache::default(); + + for slot in [1, 2, 3, 7, 8, 9, 10] { + cache.insert_seen_envelope(Slot::new(slot), root(slot as u8), slot); + } + + cache.prune(Slot::new(8)); + + // Slots 1-7 pruned. + for slot in [1, 2, 3, 7] { + assert!(!cache.seen_envelope(&Slot::new(slot), root(slot as u8), slot)); + } + // Slots 8-10 retained. + for slot in [8, 9, 10] { + assert!(cache.seen_envelope(&Slot::new(slot), root(slot as u8), slot)); + } + } +} diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs index 1dc84184203..631edd2b1dc 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs @@ -36,6 +36,7 @@ use types::{ pub mod execution_pending_envelope; pub mod gossip_verified_envelope; +pub mod gossip_verified_envelope_cache; pub mod import; mod payload_notifier; @@ -200,6 +201,15 @@ impl AvailableExecutedEnvelope { pub enum EnvelopeError { /// The envelope's block root is unknown. BlockRootUnknown { block_root: Hash256 }, + /// A valid envelope for this block root from this builder has already been seen over gossip. + /// + /// Per the Gloas `execution_payload` gossip rules, a node forwards at most one valid + /// `SignedExecutionPayloadEnvelope` for a given `(block_root, builder_index)`, so duplicates + /// are ignored (not penalized). + EnvelopeAlreadySeen { + block_root: Hash256, + builder_index: u64, + }, /// The signature is invalid. BadSignature, /// The builder index doesn't match the committed bid @@ -262,6 +272,7 @@ impl EnvelopeError { | EnvelopeError::EnvelopeProcessingError(_) => true, EnvelopeError::ExecutionPayloadError(e) => e.penalize_peer(), EnvelopeError::BlockRootUnknown { .. } + | EnvelopeError::EnvelopeAlreadySeen { .. } | EnvelopeError::PriorToFinalization { .. } | EnvelopeError::BeaconChainError(_) | EnvelopeError::BeaconStateError(_) diff --git a/beacon_node/beacon_chain/tests/events.rs b/beacon_node/beacon_chain/tests/events.rs index 537e8f40ee8..f5ebc604045 100644 --- a/beacon_node/beacon_chain/tests/events.rs +++ b/beacon_node/beacon_chain/tests/events.rs @@ -1,5 +1,6 @@ use arbitrary::Arbitrary; use beacon_chain::data_column_verification::GossipVerifiedDataColumn; +use beacon_chain::payload_envelope_verification::EnvelopeError; use beacon_chain::test_utils::{ BeaconChainHarness, fork_name_from_env, generate_data_column_sidecars_from_block, test_spec, }; @@ -336,6 +337,69 @@ async fn execution_payload_envelope_events() { ); } +/// Verifies that a second valid `SignedExecutionPayloadEnvelope` for the same block root from the +/// same builder is ignored (`EnvelopeAlreadySeen`) during gossip verification, per the Gloas +/// `execution_payload` gossip deduplication rule. +#[tokio::test] +async fn duplicate_execution_payload_envelope_is_ignored() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + + let harness = BeaconChainHarness::builder(E::default()) + .default_spec() + .deterministic_keypairs(64) + .fresh_ephemeral_store() + .mock_execution_layer() + .build(); + + harness.extend_to_slot(Slot::new(1)).await; + + let state = harness.get_current_state(); + let target_slot = Slot::new(2); + harness.advance_slot(); + let (block_contents, opt_envelope, _new_state) = + harness.make_block_with_envelope(state, target_slot).await; + + let block_root = block_contents.0.canonical_root(); + + harness + .process_block(target_slot, block_root, block_contents) + .await + .expect("block should be processed"); + + let signed_envelope = opt_envelope.expect("Gloas block should produce an envelope"); + let envelope = Arc::new(signed_envelope); + + let event_handler = harness.chain.event_handler.as_ref().unwrap(); + let mut gossip_receiver = event_handler.subscribe_execution_payload_gossip(); + + // The first envelope passes gossip verification and fires the gossip event. + harness + .chain + .verify_envelope_for_gossip(envelope.clone()) + .await + .expect("first envelope gossip verification should succeed"); + assert!( + gossip_receiver.try_recv().is_ok(), + "the first envelope should fire an execution_payload_gossip event" + ); + + // A second, identical envelope is ignored as a duplicate and does not re-propagate. + let duplicate_result = harness.chain.verify_envelope_for_gossip(envelope).await; + assert!( + matches!( + duplicate_result, + Err(EnvelopeError::EnvelopeAlreadySeen { .. }) + ), + "the duplicate envelope should be ignored, got {duplicate_result:?}" + ); + assert!( + gossip_receiver.try_recv().is_err(), + "the duplicate envelope should not fire another gossip event" + ); +} + /// Verifies that a `payload_attestation_message` event is emitted when a payload attestation /// message passes gossip verification. #[tokio::test] diff --git a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs index c20673b79b1..f785b10f873 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -3833,6 +3833,23 @@ impl NetworkBeaconProcessor { } } + EnvelopeError::EnvelopeAlreadySeen { + block_root, + builder_index, + } => { + debug!( + ?block_root, + %builder_index, + "Ignoring duplicate execution payload envelope" + ); + + self.propagate_validation_result( + message_id, + peer_id, + MessageAcceptance::Ignore, + ); + } + EnvelopeError::PriorToFinalization { .. } | EnvelopeError::BeaconChainError(_) | EnvelopeError::BeaconStateError(_)