From b40a17811176543306fc90565327dff06e13bace Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Wed, 15 Apr 2026 01:39:59 +0900 Subject: [PATCH] Gloas bid and preference verification (#9036) Gossip verify and cache bids and proposer preferences. This PR also ensures we subscribe to new fork topics one epoch early instead of two slots early. This is required for proposer preferences. Co-Authored-By: Eitan Seri- Levi --- beacon_node/beacon_chain/src/beacon_chain.rs | 8 + beacon_node/beacon_chain/src/builder.rs | 2 + beacon_node/beacon_chain/src/lib.rs | 2 + .../gossip_verified_bid.rs | 380 +++++++++ .../src/payload_bid_verification/mod.rs | 76 ++ .../payload_bid_cache.rs | 156 ++++ .../src/payload_bid_verification/tests.rs | 748 ++++++++++++++++++ .../gossip_verified_envelope.rs | 6 +- .../gossip_verified_proposer_preferences.rs | 223 ++++++ .../proposer_preferences_verification/mod.rs | 70 ++ .../proposer_preference_cache.rs | 107 +++ .../tests.rs | 279 +++++++ .../gossip_methods.rs | 127 ++- .../src/network_beacon_processor/mod.rs | 6 +- .../src/per_block_processing.rs | 24 +- .../process_operations.rs | 3 +- .../per_block_processing/signature_sets.rs | 47 +- consensus/types/src/builder/builder.rs | 11 +- consensus/types/src/state/beacon_state.rs | 71 +- 19 files changed, 2267 insertions(+), 79 deletions(-) create mode 100644 beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs create mode 100644 beacon_node/beacon_chain/src/payload_bid_verification/mod.rs create mode 100644 beacon_node/beacon_chain/src/payload_bid_verification/payload_bid_cache.rs create mode 100644 beacon_node/beacon_chain/src/payload_bid_verification/tests.rs create mode 100644 beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs create mode 100644 beacon_node/beacon_chain/src/proposer_preferences_verification/mod.rs create mode 100644 beacon_node/beacon_chain/src/proposer_preferences_verification/proposer_preference_cache.rs create mode 100644 beacon_node/beacon_chain/src/proposer_preferences_verification/tests.rs diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index e226c707a4..acf7ad9c4c 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -54,6 +54,7 @@ use crate::observed_block_producers::ObservedBlockProducers; use crate::observed_data_sidecars::ObservedDataSidecars; use crate::observed_operations::{ObservationOutcome, ObservedOperations}; use crate::observed_slashable::ObservedSlashable; +use crate::payload_bid_verification::payload_bid_cache::GossipVerifiedPayloadBidCache; #[cfg(not(test))] use crate::payload_envelope_streamer::{EnvelopeRequestSource, launch_payload_envelope_stream}; use crate::pending_payload_envelopes::PendingPayloadEnvelopes; @@ -61,6 +62,7 @@ use crate::persisted_beacon_chain::PersistedBeaconChain; use crate::persisted_custody::persist_custody_context; use crate::persisted_fork_choice::PersistedForkChoice; use crate::pre_finalization_cache::PreFinalizationBlockCache; +use crate::proposer_preferences_verification::proposer_preference_cache::GossipVerifiedProposerPreferenceCache; use crate::shuffling_cache::{BlockShufflingIds, ShufflingCache}; use crate::sync_committee_verification::{ Error as SyncCommitteeError, VerifiedSyncCommitteeMessage, VerifiedSyncContribution, @@ -466,6 +468,10 @@ pub struct BeaconChain { pub envelope_times_cache: Arc>, /// A cache used to track pre-finalization block roots for quick rejection. 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 store gossip verified proposer preferences. + pub gossip_verified_proposer_preferences_cache: GossipVerifiedProposerPreferenceCache, /// A cache used to produce light_client server messages pub light_client_server_cache: LightClientServerCache, /// Sender to signal the light_client server to produce new updates @@ -6403,6 +6409,8 @@ impl BeaconChain { self.naive_aggregation_pool.write().prune(slot); 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_proposer_preferences_cache.prune(slot); // Don't run heavy-weight tasks during sync. if self.best_slot() + MAX_PER_SLOT_FORK_CHOICE_DISTANCE < slot { diff --git a/beacon_node/beacon_chain/src/builder.rs b/beacon_node/beacon_chain/src/builder.rs index 11b87351b1..b963f7c342 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -1064,6 +1064,8 @@ where ), kzg: self.kzg.clone(), rng: Arc::new(Mutex::new(rng)), + gossip_verified_payload_bid_cache: <_>::default(), + gossip_verified_proposer_preferences_cache: <_>::default(), }; let head = beacon_chain.head_snapshot(); diff --git a/beacon_node/beacon_chain/src/lib.rs b/beacon_node/beacon_chain/src/lib.rs index d71aec6987..a8a706d8bc 100644 --- a/beacon_node/beacon_chain/src/lib.rs +++ b/beacon_node/beacon_chain/src/lib.rs @@ -43,6 +43,7 @@ pub mod observed_block_producers; pub mod observed_data_sidecars; pub mod observed_operations; mod observed_slashable; +pub mod payload_bid_verification; pub mod payload_envelope_streamer; pub mod payload_envelope_verification; pub mod pending_payload_envelopes; @@ -50,6 +51,7 @@ pub mod persisted_beacon_chain; pub mod persisted_custody; mod persisted_fork_choice; mod pre_finalization_cache; +pub mod proposer_preferences_verification; pub mod proposer_prep_service; pub mod schema_change; pub mod shuffling_cache; diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs b/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs new file mode 100644 index 0000000000..91945896df --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs @@ -0,0 +1,380 @@ +use std::sync::Arc; + +use crate::{ + BeaconChain, BeaconChainTypes, CanonicalHead, + payload_bid_verification::{PayloadBidError, payload_bid_cache::GossipVerifiedPayloadBidCache}, + proposer_preferences_verification::proposer_preference_cache::GossipVerifiedProposerPreferenceCache, +}; +use educe::Educe; +use slot_clock::SlotClock; +use state_processing::signature_sets::{ + execution_payload_bid_signature_set, get_builder_pubkey_from_state, +}; +use tracing::debug; +use types::{ + BeaconState, ChainSpec, EthSpec, ExecutionPayloadBid, SignedExecutionPayloadBid, + SignedProposerPreferences, Slot, +}; + +/// Verify that an execution payload bid is consistent with the current chain state +/// and proposer preferences. +pub(crate) fn verify_bid_consistency( + bid: &ExecutionPayloadBid, + current_slot: Slot, + proposer_preferences: &SignedProposerPreferences, + head_state: &BeaconState, + spec: &ChainSpec, +) -> Result<(), PayloadBidError> { + let bid_slot = bid.slot; + + if bid_slot != current_slot && bid_slot != current_slot.saturating_add(1u64) { + return Err(PayloadBidError::InvalidBidSlot { bid_slot }); + } + + // Execution payments are used by off protocol builders. In protocol bids + // should always have this value set to zero. + if bid.execution_payment != 0 { + return Err(PayloadBidError::ExecutionPaymentNonZero { + execution_payment: bid.execution_payment, + }); + } + + if bid.fee_recipient != proposer_preferences.message.fee_recipient { + return Err(PayloadBidError::InvalidFeeRecipient); + } + if bid.gas_limit != proposer_preferences.message.gas_limit { + return Err(PayloadBidError::InvalidGasLimit); + } + + let max_blobs_per_block = + spec.max_blobs_per_block(bid_slot.epoch(E::slots_per_epoch())) as usize; + + if bid.blob_kzg_commitments.len() > max_blobs_per_block { + return Err(PayloadBidError::InvalidBlobKzgCommitments { + max_blobs_per_block, + blob_kzg_commitments_len: bid.blob_kzg_commitments.len(), + }); + } + + let builder_index = bid.builder_index; + + let is_active_builder = head_state + .is_active_builder(builder_index, spec) + .map_err(|_| PayloadBidError::InvalidBuilder { builder_index })?; + + if !is_active_builder { + return Err(PayloadBidError::InvalidBuilder { builder_index }); + } + + if !head_state.can_builder_cover_bid(builder_index, bid.value, spec)? { + return Err(PayloadBidError::BuilderCantCoverBid { + builder_index, + builder_bid: bid.value, + }); + } + + Ok(()) +} + +pub struct GossipVerificationContext<'a, T: BeaconChainTypes> { + pub canonical_head: &'a CanonicalHead, + pub gossip_verified_payload_bid_cache: &'a GossipVerifiedPayloadBidCache, + pub gossip_verified_proposer_preferences_cache: &'a GossipVerifiedProposerPreferenceCache, + pub slot_clock: &'a T::SlotClock, + pub spec: &'a ChainSpec, +} + +/// A wrapper around a `SignedExecutionPayloadBid` that indicates it has been approved for re-gossiping on +/// the p2p network. +#[derive(Educe)] +#[educe( + Debug(bound = "T: BeaconChainTypes"), + Clone(bound = "T: BeaconChainTypes") +)] +pub struct GossipVerifiedPayloadBid { + pub signed_bid: Arc>, +} + +impl GossipVerifiedPayloadBid { + pub fn new( + signed_bid: Arc>, + ctx: &GossipVerificationContext<'_, T>, + ) -> Result { + let bid_slot = signed_bid.message.slot; + let bid_parent_block_hash = signed_bid.message.parent_block_hash; + let bid_parent_block_root = signed_bid.message.parent_block_root; + let bid_value = signed_bid.message.value; + + if ctx + .gossip_verified_payload_bid_cache + .seen_builder_index(&bid_slot, signed_bid.message.builder_index) + { + return Err(PayloadBidError::BuilderAlreadySeen { + builder_index: signed_bid.message.builder_index, + slot: bid_slot, + }); + } + + // TODO(gloas): Extract into `bid_value_over_threshold` on the bid cache and potentially + // make this more sophisticate than just a <= check. + if let Some(cached_bid) = ctx.gossip_verified_payload_bid_cache.get_highest_bid( + bid_slot, + bid_parent_block_hash, + bid_parent_block_root, + ) && bid_value <= cached_bid.message.value + { + return Err(PayloadBidError::BidValueBelowCached { + cached_value: cached_bid.message.value, + incoming_value: bid_value, + }); + } + + let cached_head = ctx.canonical_head.cached_head(); + let current_slot = ctx + .slot_clock + .now() + .ok_or(PayloadBidError::UnableToReadSlot)?; + let head_state = &cached_head.snapshot.beacon_state; + + let Some(proposer_preferences) = ctx + .gossip_verified_proposer_preferences_cache + .get_preferences(&bid_slot) + else { + return Err(PayloadBidError::NoProposerPreferences { slot: bid_slot }); + }; + + let fork_choice = ctx.canonical_head.fork_choice_read_lock(); + + // TODO(gloas) reprocess bids whose parent_block_root becomes known & canonical after a reorg? + if !fork_choice.contains_block(&bid_parent_block_root) { + return Err(PayloadBidError::ParentBlockRootUnknown { + parent_block_root: bid_parent_block_root, + }); + } + + // TODO(gloas) reprocess bids whose parent_block_root becomes canonical after a reorg. + let head_root = cached_head.head_block_root(); + if !fork_choice.is_descendant(bid_parent_block_root, head_root) { + return Err(PayloadBidError::ParentBlockRootNotCanonical { + parent_block_root: bid_parent_block_root, + }); + } + + // TODO(gloas) [IGNORE] bid.parent_block_hash is the block hash of a known execution payload in fork choice. + + drop(fork_choice); + + verify_bid_consistency( + &signed_bid.message, + current_slot, + &proposer_preferences, + head_state, + ctx.spec, + )?; + + // Verify signature + execution_payload_bid_signature_set( + head_state, + |i| get_builder_pubkey_from_state(head_state, i), + &signed_bid, + ctx.spec, + ) + .map_err(|_| PayloadBidError::BadSignature)? + .ok_or(PayloadBidError::BadSignature)? + .verify() + .then_some(()) + .ok_or(PayloadBidError::BadSignature)?; + + let gossip_verified_bid = GossipVerifiedPayloadBid { signed_bid }; + + ctx.gossip_verified_payload_bid_cache + .insert_seen_builder(&gossip_verified_bid); + + ctx.gossip_verified_payload_bid_cache + .insert_highest_bid(gossip_verified_bid.clone()); + + Ok(gossip_verified_bid) + } +} + +impl BeaconChain { + /// Build a `GossipVerificationContext` from this `BeaconChain` for `GossipVerifiedPayloadBid`. + pub fn payload_bid_gossip_verification_context(&self) -> GossipVerificationContext<'_, T> { + GossipVerificationContext { + canonical_head: &self.canonical_head, + gossip_verified_payload_bid_cache: &self.gossip_verified_payload_bid_cache, + gossip_verified_proposer_preferences_cache: &self + .gossip_verified_proposer_preferences_cache, + slot_clock: &self.slot_clock, + spec: &self.spec, + } + } + + /// Returns `Ok(GossipVerifiedPayloadBid)` if the supplied `bid` should be forwarded onto the + /// gossip network and cached. + /// + /// ## Errors + /// + /// Returns an `Err` if the given bid was invalid, or an error was encountered during verification. + pub fn verify_payload_bid_for_gossip( + &self, + bid: Arc>, + ) -> Result, PayloadBidError> { + let slot = bid.message.slot; + let parent_block_root = bid.message.parent_block_root; + let parent_block_hash = bid.message.parent_block_hash; + + let ctx = self.payload_bid_gossip_verification_context(); + match GossipVerifiedPayloadBid::new(bid, &ctx) { + Ok(verified) => { + debug!( + %slot, + %parent_block_hash, + %parent_block_root, + "Successfully verified gossip payload bid" + ); + Ok(verified) + } + Err(e) => { + debug!( + error = e.to_string(), + %slot, + %parent_block_hash, + %parent_block_root, + "Rejected gossip payload bid" + ); + Err(e) + } + } + } +} + +#[cfg(test)] +mod tests { + use bls::Signature; + use kzg::KzgCommitment; + use ssz_types::VariableList; + use types::{ + Address, BeaconState, ChainSpec, EthSpec, ExecutionPayloadBid, MinimalEthSpec, + ProposerPreferences, SignedProposerPreferences, Slot, + }; + + use super::verify_bid_consistency; + use crate::payload_bid_verification::PayloadBidError; + + type E = MinimalEthSpec; + + fn make_bid(slot: Slot, fee_recipient: Address, gas_limit: u64) -> ExecutionPayloadBid { + ExecutionPayloadBid { + slot, + fee_recipient, + gas_limit, + value: 100, + ..ExecutionPayloadBid::default() + } + } + + fn make_preferences(fee_recipient: Address, gas_limit: u64) -> SignedProposerPreferences { + SignedProposerPreferences { + message: ProposerPreferences { + fee_recipient, + gas_limit, + ..ProposerPreferences::default() + }, + signature: Signature::empty(), + } + } + + fn state_and_spec() -> (BeaconState, ChainSpec) { + let spec = E::default_spec(); + let state = BeaconState::new(0, <_>::default(), &spec); + (state, spec) + } + + #[test] + fn test_invalid_bid_slot_too_old() { + let (state, spec) = state_and_spec(); + let current_slot = Slot::new(10); + let bid = make_bid(Slot::new(5), Address::ZERO, 30_000_000); + let prefs = make_preferences(Address::ZERO, 30_000_000); + + let result = verify_bid_consistency::(&bid, current_slot, &prefs, &state, &spec); + assert!(matches!( + result, + Err(PayloadBidError::InvalidBidSlot { .. }) + )); + } + + #[test] + fn test_invalid_bid_slot_too_far_ahead() { + let (state, spec) = state_and_spec(); + let current_slot = Slot::new(10); + let bid = make_bid(Slot::new(12), Address::ZERO, 30_000_000); + let prefs = make_preferences(Address::ZERO, 30_000_000); + + let result = verify_bid_consistency::(&bid, current_slot, &prefs, &state, &spec); + assert!(matches!( + result, + Err(PayloadBidError::InvalidBidSlot { .. }) + )); + } + + #[test] + fn test_execution_payment_nonzero() { + let (state, spec) = state_and_spec(); + let current_slot = Slot::new(10); + let mut bid = make_bid(current_slot, Address::ZERO, 30_000_000); + bid.execution_payment = 42; + let prefs = make_preferences(Address::ZERO, 30_000_000); + + let result = verify_bid_consistency::(&bid, current_slot, &prefs, &state, &spec); + assert!(matches!( + result, + Err(PayloadBidError::ExecutionPaymentNonZero { + execution_payment: 42 + }) + )); + } + + #[test] + fn test_fee_recipient_mismatch() { + let (state, spec) = state_and_spec(); + let current_slot = Slot::new(10); + let bid = make_bid(current_slot, Address::ZERO, 30_000_000); + let prefs = make_preferences(Address::repeat_byte(0xaa), 30_000_000); + + let result = verify_bid_consistency::(&bid, current_slot, &prefs, &state, &spec); + assert!(matches!(result, Err(PayloadBidError::InvalidFeeRecipient))); + } + + #[test] + fn test_invalid_blob_kzg_commitments() { + let (state, spec) = state_and_spec(); + let current_slot = Slot::new(10); + let mut bid = make_bid(current_slot, Address::ZERO, 30_000_000); + let prefs = make_preferences(Address::ZERO, 30_000_000); + + let max_blobs = spec.max_blobs_per_block(current_slot.epoch(E::slots_per_epoch())) as usize; + let commitments: Vec = (0..=max_blobs) + .map(|_| KzgCommitment::empty_for_testing()) + .collect(); + bid.blob_kzg_commitments = VariableList::new(commitments).unwrap(); + + let result = verify_bid_consistency::(&bid, current_slot, &prefs, &state, &spec); + assert!(matches!( + result, + Err(PayloadBidError::InvalidBlobKzgCommitments { .. }) + )); + } + + #[test] + fn test_gas_limit_mismatch() { + let (state, spec) = state_and_spec(); + let current_slot = Slot::new(10); + let bid = make_bid(current_slot, Address::ZERO, 30_000_000); + let prefs = make_preferences(Address::ZERO, 50_000_000); + + let result = verify_bid_consistency::(&bid, current_slot, &prefs, &state, &spec); + assert!(matches!(result, Err(PayloadBidError::InvalidGasLimit))); + } +} diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs b/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs new file mode 100644 index 0000000000..514695f5c0 --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs @@ -0,0 +1,76 @@ +//! Gossip verification for execution payload bids. +//! +//! A `SignedExecutionPayloadBid` is verified and wrapped as a `GossipVerifiedPayloadBid`, +//! which is then inserted into the `GossipVerifiedPayloadBidCache`. +//! +//! ```ignore +//! SignedExecutionPayloadBid +//! | +//! ▼ +//! GossipVerifiedPayloadBid -------> Insert into GossipVerifiedPayloadBidCache +//! ``` + +use types::{BeaconStateError, Hash256, Slot}; + +pub mod gossip_verified_bid; +pub mod payload_bid_cache; + +#[cfg(test)] +mod tests; + +#[derive(Debug)] +pub enum PayloadBidError { + /// The bid's parent block root is unknown. + ParentBlockRootUnknown { parent_block_root: Hash256 }, + /// The bid's parent block root is known but not on the canonical chain. + ParentBlockRootNotCanonical { parent_block_root: Hash256 }, + /// The signature is invalid. + BadSignature, + /// A bid for this builder at this slot has already been seen. + BuilderAlreadySeen { builder_index: u64, slot: Slot }, + /// Builder is not valid/active for the given epoch + InvalidBuilder { builder_index: u64 }, + /// The bid value is lower than the currently cached bid. + BidValueBelowCached { + cached_value: u64, + incoming_value: u64, + }, + /// The bids slot is not the current slot or the next slot. + InvalidBidSlot { bid_slot: Slot }, + /// The slot clock cannot be read. + UnableToReadSlot, + /// No proposer preferences for the current slot. + NoProposerPreferences { slot: Slot }, + /// The builder doesn't have enough deposited funds to cover the bid. + BuilderCantCoverBid { + builder_index: u64, + builder_bid: u64, + }, + /// The bids fee recipient doesn't match the proposer preferences fee recipient. + InvalidFeeRecipient, + /// The bids gas limit doesn't match the proposer preferences gas limit. + InvalidGasLimit, + /// The bids execution payment is non-zero + ExecutionPaymentNonZero { execution_payment: u64 }, + /// The number of blob KZG commitments exceeds the maximum allowed. + InvalidBlobKzgCommitments { + max_blobs_per_block: usize, + blob_kzg_commitments_len: usize, + }, + /// Some Beacon State error + BeaconStateError(BeaconStateError), + /// Internal error + InternalError(String), +} + +impl std::fmt::Display for PayloadBidError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + +impl From for PayloadBidError { + fn from(e: BeaconStateError) -> Self { + PayloadBidError::BeaconStateError(e) + } +} diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/payload_bid_cache.rs b/beacon_node/beacon_chain/src/payload_bid_verification/payload_bid_cache.rs new file mode 100644 index 0000000000..1c98569bc5 --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_bid_verification/payload_bid_cache.rs @@ -0,0 +1,156 @@ +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + sync::Arc, +}; + +use crate::{ + BeaconChainTypes, payload_bid_verification::gossip_verified_bid::GossipVerifiedPayloadBid, +}; +use parking_lot::RwLock; +use types::{BuilderIndex, ExecutionBlockHash, Hash256, SignedExecutionPayloadBid, Slot}; + +type HighestBidMap = + BTreeMap>>; + +pub struct GossipVerifiedPayloadBidCache { + highest_bid: RwLock>, + seen_builder: RwLock>>, +} + +impl Default for GossipVerifiedPayloadBidCache { + fn default() -> Self { + Self { + highest_bid: RwLock::new(BTreeMap::new()), + seen_builder: RwLock::new(BTreeMap::new()), + } + } +} + +impl GossipVerifiedPayloadBidCache { + /// Get the cached bid for the tuple `(slot, parent_block_hash, parent_block_root)`. + pub fn get_highest_bid( + &self, + slot: Slot, + parent_block_hash: ExecutionBlockHash, + parent_block_root: Hash256, + ) -> Option>> { + self.highest_bid.read().get(&slot).and_then(|map| { + map.get(&(parent_block_hash, parent_block_root)) + .map(|b| b.signed_bid.clone()) + }) + } + + /// Insert a bid for the tuple `(slot, parent_block_hash, parent_block_root)` only if + /// its value is higher than the currently cached bid for that tuple. + pub fn insert_highest_bid(&self, bid: GossipVerifiedPayloadBid) { + let key = ( + bid.signed_bid.message.parent_block_hash, + bid.signed_bid.message.parent_block_root, + ); + let mut highest_bid = self.highest_bid.write(); + let slot_map = highest_bid.entry(bid.signed_bid.message.slot).or_default(); + + if let Some(existing) = slot_map.get(&key) + && existing.signed_bid.message.value >= bid.signed_bid.message.value + { + return; + } + slot_map.insert(key, bid); + } + + /// A gossip verified bid for `BuilderIndex` already exists at `slot` + pub fn seen_builder_index(&self, slot: &Slot, builder_index: BuilderIndex) -> bool { + self.seen_builder + .read() + .get(slot) + .is_some_and(|seen_builders| seen_builders.contains(&builder_index)) + } + + /// Insert a builder into the seen cache. + pub fn insert_seen_builder(&self, bid: &GossipVerifiedPayloadBid) { + let mut seen_builder = self.seen_builder.write(); + seen_builder + .entry(bid.signed_bid.message.slot) + .or_default() + .insert(bid.signed_bid.message.builder_index); + } + + /// Prune anything before `current_slot` + pub fn prune(&self, current_slot: Slot) { + self.highest_bid + .write() + .retain(|&slot, _| slot >= current_slot); + + self.seen_builder + .write() + .retain(|&slot, _| slot >= current_slot); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use bls::Signature; + use types::{ + ExecutionBlockHash, ExecutionPayloadBid, Hash256, MinimalEthSpec, + SignedExecutionPayloadBid, Slot, + }; + + use super::GossipVerifiedPayloadBidCache; + use crate::{ + payload_bid_verification::gossip_verified_bid::GossipVerifiedPayloadBid, + test_utils::EphemeralHarnessType, + }; + + type E = MinimalEthSpec; + type T = EphemeralHarnessType; + + fn make_gossip_verified( + slot: Slot, + builder_index: u64, + parent_block_hash: ExecutionBlockHash, + parent_block_root: Hash256, + value: u64, + ) -> GossipVerifiedPayloadBid { + GossipVerifiedPayloadBid { + signed_bid: Arc::new(SignedExecutionPayloadBid { + message: ExecutionPayloadBid { + slot, + builder_index, + parent_block_hash, + parent_block_root, + value, + ..ExecutionPayloadBid::default() + }, + signature: Signature::empty(), + }), + } + } + + #[test] + fn prune_removes_old_retains_current() { + let cache = GossipVerifiedPayloadBidCache::::default(); + let hash = ExecutionBlockHash::zero(); + let root = Hash256::ZERO; + + for slot in [1, 2, 3, 7, 8, 9, 10] { + let verified = make_gossip_verified(Slot::new(slot), slot, hash, root, slot * 100); + cache.insert_seen_builder(&verified); + cache.insert_highest_bid(verified); + } + + cache.prune(Slot::new(8)); + + // Slots 1-7 pruned from both maps. + for slot in [1, 2, 3, 7] { + assert!(cache.get_highest_bid(Slot::new(slot), hash, root).is_none()); + assert!(!cache.seen_builder_index(&Slot::new(slot), slot)); + } + // Slots 8-10 retained in both maps. + for slot in [8, 9, 10] { + assert!(cache.get_highest_bid(Slot::new(slot), hash, root).is_some()); + assert!(cache.seen_builder_index(&Slot::new(slot), slot)); + } + } +} diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs new file mode 100644 index 0000000000..bb59b16ffb --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs @@ -0,0 +1,748 @@ +use std::sync::Arc; + +use std::time::Duration; + +use bls::{Keypair, PublicKeyBytes, Signature}; +use ethereum_hashing::hash; +use fork_choice::ForkChoice; +use genesis::{generate_deterministic_keypairs, interop_genesis_state}; +use kzg::KzgCommitment; +use slot_clock::{SlotClock, TestingSlotClock}; +use ssz::Encode; +use ssz_types::VariableList; +use store::{HotColdDB, StoreConfig}; +use types::{ + Address, BeaconBlock, ChainSpec, Checkpoint, Domain, Epoch, EthSpec, ExecutionBlockHash, + ExecutionPayloadBid, Hash256, MinimalEthSpec, ProposerPreferences, SignedBeaconBlock, + SignedExecutionPayloadBid, SignedProposerPreferences, SignedRoot, Slot, +}; + +use proto_array::{Block as ProtoBlock, ExecutionStatus, PayloadStatus}; +use types::AttestationShufflingId; + +use crate::{ + beacon_fork_choice_store::BeaconForkChoiceStore, + beacon_snapshot::BeaconSnapshot, + canonical_head::CanonicalHead, + payload_bid_verification::{ + PayloadBidError, + gossip_verified_bid::{GossipVerificationContext, GossipVerifiedPayloadBid}, + payload_bid_cache::GossipVerifiedPayloadBidCache, + }, + proposer_preferences_verification::{ + gossip_verified_proposer_preferences::GossipVerifiedProposerPreferences, + proposer_preference_cache::GossipVerifiedProposerPreferenceCache, + }, + test_utils::{EphemeralHarnessType, fork_name_from_env, test_spec}, +}; + +type E = MinimalEthSpec; +type T = EphemeralHarnessType; + +/// Number of regular validators (must be >= min_genesis_active_validator_count for MinimalEthSpec). +const NUM_VALIDATORS: usize = 64; +/// Number of builders to register. +const NUM_BUILDERS: usize = 4; +/// Balance given to each builder (min_deposit_amount + extra to cover bids in tests). +const BUILDER_BALANCE: u64 = 2_000_000_000; + +struct TestContext { + canonical_head: CanonicalHead, + bid_cache: GossipVerifiedPayloadBidCache, + preferences_cache: GossipVerifiedProposerPreferenceCache, + slot_clock: TestingSlotClock, + keypairs: Vec, + spec: ChainSpec, + genesis_block_root: Hash256, + inactive_builder_index: u64, +} + +fn builder_withdrawal_credentials(pubkey: &bls::PublicKey, spec: &ChainSpec) -> Hash256 { + let fake_execution_address = &hash(&pubkey.as_ssz_bytes())[0..20]; + let mut credentials = [0u8; 32]; + credentials[0] = spec.builder_withdrawal_prefix_byte; + credentials[12..].copy_from_slice(fake_execution_address); + Hash256::from_slice(&credentials) +} + +impl TestContext { + fn new() -> Self { + let spec = test_spec::(); + let store = Arc::new( + HotColdDB::open_ephemeral(StoreConfig::default(), Arc::new(spec.clone())) + .expect("should open ephemeral store"), + ); + + let keypairs = generate_deterministic_keypairs(NUM_VALIDATORS); + + let mut state = + interop_genesis_state::(&keypairs, 0, Hash256::repeat_byte(0x42), None, &spec) + .expect("should build genesis state"); + + // Register builders in the builder registry. + for keypair in keypairs.iter().take(NUM_BUILDERS) { + let creds = builder_withdrawal_credentials(&keypair.pk, &spec); + state + .add_builder_to_registry( + PublicKeyBytes::from(keypair.pk.clone()), + creds, + BUILDER_BALANCE, + Slot::new(0), + &spec, + ) + .expect("should register builder"); + } + + // Bump finalized checkpoint epoch so builders are considered active + // (is_active_builder requires deposit_epoch < finalized_checkpoint.epoch). + *state.finalized_checkpoint_mut() = Checkpoint { + epoch: Epoch::new(1), + root: Hash256::ZERO, + }; + + let inactive_keypair = &keypairs[NUM_BUILDERS]; + let inactive_creds = builder_withdrawal_credentials(&inactive_keypair.pk, &spec); + let inactive_builder_index = state + .add_builder_to_registry( + PublicKeyBytes::from(inactive_keypair.pk.clone()), + inactive_creds, + BUILDER_BALANCE, + Slot::new(E::slots_per_epoch()), + &spec, + ) + .expect("should register inactive builder"); + + let mut genesis_block = BeaconBlock::empty(&spec); + *genesis_block.state_root_mut() = state + .update_tree_hash_cache() + .expect("should hash genesis state"); + let signed_block = SignedBeaconBlock::from_block(genesis_block, Signature::empty()); + let block_root = signed_block.canonical_root(); + + let snapshot = BeaconSnapshot::new( + Arc::new(signed_block.clone()), + None, + block_root, + state.clone(), + ); + + let fc_store = BeaconForkChoiceStore::get_forkchoice_store(store.clone(), snapshot.clone()) + .expect("should create fork choice store"); + let fork_choice = + ForkChoice::from_anchor(fc_store, block_root, &signed_block, &state, None, &spec) + .expect("should create fork choice"); + + let canonical_head = + CanonicalHead::new(fork_choice, Arc::new(snapshot), PayloadStatus::Pending); + + let slot_clock = TestingSlotClock::new( + Slot::new(0), + Duration::from_secs(0), + spec.get_slot_duration(), + ); + + Self { + canonical_head, + bid_cache: GossipVerifiedPayloadBidCache::default(), + preferences_cache: GossipVerifiedProposerPreferenceCache::default(), + slot_clock, + keypairs, + spec, + genesis_block_root: block_root, + inactive_builder_index, + } + } + + fn sign_bid(&self, bid: ExecutionPayloadBid) -> Arc> { + let head = self.canonical_head.cached_head(); + let state = &head.snapshot.beacon_state; + let domain = self.spec.get_domain( + bid.slot.epoch(E::slots_per_epoch()), + Domain::BeaconBuilder, + &state.fork(), + state.genesis_validators_root(), + ); + let message = bid.signing_root(domain); + let signature = self.keypairs[bid.builder_index as usize].sk.sign(message); + Arc::new(SignedExecutionPayloadBid { + message: bid, + signature, + }) + } + + fn gossip_ctx(&self) -> GossipVerificationContext<'_, T> { + GossipVerificationContext { + canonical_head: &self.canonical_head, + gossip_verified_payload_bid_cache: &self.bid_cache, + gossip_verified_proposer_preferences_cache: &self.preferences_cache, + slot_clock: &self.slot_clock, + spec: &self.spec, + } + } + + fn insert_non_canonical_block(&self) -> Hash256 { + let shuffling_id = AttestationShufflingId { + shuffling_epoch: Epoch::new(0), + shuffling_decision_block: self.genesis_block_root, + }; + let fork_block_root = Hash256::repeat_byte(0xab); + let mut fc = self.canonical_head.fork_choice_write_lock(); + fc.proto_array_mut() + .process_block::( + ProtoBlock { + slot: Slot::new(1), + root: fork_block_root, + parent_root: Some(self.genesis_block_root), + target_root: fork_block_root, + current_epoch_shuffling_id: shuffling_id.clone(), + next_epoch_shuffling_id: shuffling_id, + state_root: Hash256::ZERO, + justified_checkpoint: Checkpoint { + epoch: Epoch::new(0), + root: self.genesis_block_root, + }, + finalized_checkpoint: Checkpoint { + epoch: Epoch::new(0), + root: self.genesis_block_root, + }, + execution_status: ExecutionStatus::irrelevant(), + unrealized_justified_checkpoint: None, + unrealized_finalized_checkpoint: None, + execution_payload_parent_hash: Some(ExecutionBlockHash::zero()), + execution_payload_block_hash: Some(ExecutionBlockHash::repeat_byte(0xab)), + proposer_index: Some(0), + }, + Slot::new(1), + &self.spec, + Duration::from_secs(0), + ) + .expect("should insert fork block"); + fork_block_root + } +} + +fn make_signed_bid( + slot: Slot, + builder_index: u64, + fee_recipient: Address, + gas_limit: u64, + value: u64, + parent_block_root: Hash256, +) -> Arc> { + Arc::new(SignedExecutionPayloadBid { + message: ExecutionPayloadBid { + slot, + builder_index, + fee_recipient, + gas_limit, + value, + parent_block_root, + ..ExecutionPayloadBid::default() + }, + signature: Signature::empty(), + }) +} + +fn make_signed_preferences( + proposal_slot: Slot, + validator_index: u64, + fee_recipient: Address, + gas_limit: u64, +) -> Arc { + Arc::new(SignedProposerPreferences { + message: ProposerPreferences { + proposal_slot, + validator_index, + fee_recipient, + gas_limit, + }, + signature: Signature::empty(), + }) +} + +fn seed_preferences(ctx: &TestContext, slot: Slot, fee_recipient: Address, gas_limit: u64) { + let prefs = GossipVerifiedProposerPreferences { + signed_preferences: make_signed_preferences(slot, 0, fee_recipient, gas_limit), + }; + ctx.preferences_cache.insert_preferences(prefs); +} + +#[test] +fn no_proposer_preferences_for_slot() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let bid = make_signed_bid( + Slot::new(0), + 0, + Address::ZERO, + 30_000_000, + 100, + Hash256::ZERO, + ); + + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::NoProposerPreferences { .. }) + )); +} + +#[test] +fn builder_already_seen_for_slot() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let bid = make_signed_bid(slot, 42, Address::ZERO, 30_000_000, 100, Hash256::ZERO); + let verified = GossipVerifiedPayloadBid { + signed_bid: bid.clone(), + }; + ctx.bid_cache.insert_seen_builder(&verified); + + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::BuilderAlreadySeen { + builder_index: 42, + .. + }) + )); +} + +#[test] +fn bid_value_below_cached() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let high_bid = GossipVerifiedPayloadBid { + signed_bid: make_signed_bid(slot, 99, Address::ZERO, 30_000_000, 500, Hash256::ZERO), + }; + ctx.bid_cache.insert_highest_bid(high_bid); + + let low_bid = make_signed_bid(slot, 1, Address::ZERO, 30_000_000, 100, Hash256::ZERO); + let result = GossipVerifiedPayloadBid::new(low_bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::BidValueBelowCached { .. }) + )); +} + +#[test] +fn invalid_bid_slot() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(5); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let bid = make_signed_bid( + slot, + 0, + Address::ZERO, + 30_000_000, + 100, + ctx.genesis_block_root, + ); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::InvalidBidSlot { .. }) + )); +} + +#[test] +fn fee_recipient_mismatch() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::repeat_byte(0xaa), 30_000_000); + + let bid = make_signed_bid( + slot, + 0, + Address::ZERO, + 30_000_000, + 100, + ctx.genesis_block_root, + ); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!(result, Err(PayloadBidError::InvalidFeeRecipient))); +} + +#[test] +fn gas_limit_mismatch() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let bid = make_signed_bid( + slot, + 0, + Address::ZERO, + 50_000_000, + 100, + ctx.genesis_block_root, + ); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!(result, Err(PayloadBidError::InvalidGasLimit))); +} + +#[test] +fn execution_payment_nonzero() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let bid = Arc::new(SignedExecutionPayloadBid { + message: ExecutionPayloadBid { + slot, + gas_limit: 30_000_000, + execution_payment: 42, + parent_block_root: ctx.genesis_block_root, + ..ExecutionPayloadBid::default() + }, + signature: Signature::empty(), + }); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::ExecutionPaymentNonZero { .. }) + )); +} + +#[test] +fn unknown_builder_index() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + // Use a builder_index that doesn't exist in the registry. + let bid = make_signed_bid( + slot, + 9999, + Address::ZERO, + 30_000_000, + 100, + ctx.genesis_block_root, + ); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::InvalidBuilder { + builder_index: 9999 + }) + )); +} + +#[test] +fn inactive_builder() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let bid = make_signed_bid( + slot, + ctx.inactive_builder_index, + Address::ZERO, + 30_000_000, + 100, + ctx.genesis_block_root, + ); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::InvalidBuilder { .. }) + )); +} + +#[test] +fn builder_cant_cover_bid() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + // Builder index 0 exists but bid value far exceeds their balance. + let bid = make_signed_bid( + slot, + 0, + Address::ZERO, + 30_000_000, + u64::MAX, + ctx.genesis_block_root, + ); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::BuilderCantCoverBid { .. }) + )); +} + +#[test] +fn parent_block_root_unknown() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + // Parent block root not in fork choice. + let unknown_root = Hash256::repeat_byte(0xff); + let bid = make_signed_bid(slot, 0, Address::ZERO, 30_000_000, 0, unknown_root); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(result.is_err(), "expected error, got Ok"); + let err = result.unwrap_err(); + assert!( + matches!(err, PayloadBidError::ParentBlockRootUnknown { .. }), + "expected ParentBlockRootUnknown, got: {err:?}" + ); +} + +#[test] +fn parent_block_root_not_canonical() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let fork_root = ctx.insert_non_canonical_block(); + let bid = make_signed_bid(slot, 0, Address::ZERO, 30_000_000, 0, fork_root); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(result.is_err(), "expected error, got Ok"); + let err = result.unwrap_err(); + assert!( + matches!(err, PayloadBidError::ParentBlockRootNotCanonical { .. }), + "expected ParentBlockRootNotCanonical, got: {err:?}" + ); +} + +#[test] +fn invalid_blob_kzg_commitments() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let max_blobs = ctx + .spec + .max_blobs_per_block(slot.epoch(E::slots_per_epoch())) as usize; + let commitments: Vec = (0..=max_blobs) + .map(|_| KzgCommitment::empty_for_testing()) + .collect(); + + let bid = Arc::new(SignedExecutionPayloadBid { + message: ExecutionPayloadBid { + slot, + builder_index: 0, + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + value: 0, + parent_block_root: ctx.genesis_block_root, + blob_kzg_commitments: VariableList::new(commitments).unwrap(), + ..ExecutionPayloadBid::default() + }, + signature: Signature::empty(), + }); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::InvalidBlobKzgCommitments { .. }) + )); +} + +#[test] +fn bad_signature() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + // All checks pass but signature is empty/invalid. + let bid = make_signed_bid( + slot, + 0, + Address::ZERO, + 30_000_000, + 0, + ctx.genesis_block_root, + ); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!(result, Err(PayloadBidError::BadSignature))); + assert!(!ctx.bid_cache.seen_builder_index(&slot, 0)); + assert!( + ctx.bid_cache + .get_highest_bid(slot, ExecutionBlockHash::zero(), ctx.genesis_block_root) + .is_none() + ); +} + +#[test] +fn valid_bid() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let bid = ctx.sign_bid(ExecutionPayloadBid { + slot, + builder_index: 0, + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + value: 0, + parent_block_root: ctx.genesis_block_root, + ..ExecutionPayloadBid::default() + }); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!( + result.is_ok(), + "expected Ok, got: {:?}", + result.unwrap_err() + ); +} + +#[test] +fn two_builders_coexist_in_cache() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let bid_0 = ctx.sign_bid(ExecutionPayloadBid { + slot, + builder_index: 0, + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + value: 0, + parent_block_root: ctx.genesis_block_root, + ..ExecutionPayloadBid::default() + }); + let result_0 = GossipVerifiedPayloadBid::new(bid_0, &gossip); + assert!( + result_0.is_ok(), + "builder 0 should pass: {:?}", + result_0.unwrap_err() + ); + + // Builder 1 must bid strictly higher than builder 0's cached value. + let bid_1 = ctx.sign_bid(ExecutionPayloadBid { + slot, + builder_index: 1, + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + value: 1, + parent_block_root: ctx.genesis_block_root, + ..ExecutionPayloadBid::default() + }); + let result_1 = GossipVerifiedPayloadBid::new(bid_1, &gossip); + assert!( + result_1.is_ok(), + "builder 1 should pass: {:?}", + result_1.unwrap_err() + ); + + // Both builders should be seen. + assert!(ctx.bid_cache.seen_builder_index(&slot, 0)); + assert!(ctx.bid_cache.seen_builder_index(&slot, 1)); + + let highest = ctx + .bid_cache + .get_highest_bid(slot, ExecutionBlockHash::zero(), ctx.genesis_block_root) + .expect("should have highest bid"); + assert_eq!(highest.message.value, 1); + assert_eq!(highest.message.builder_index, 1); +} + +#[test] +fn bid_equal_to_cached_value_rejected() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + // Seed a cached bid with value 100. + let high_bid = GossipVerifiedPayloadBid { + signed_bid: make_signed_bid( + slot, + 99, + Address::ZERO, + 30_000_000, + 100, + ctx.genesis_block_root, + ), + }; + ctx.bid_cache.insert_highest_bid(high_bid); + + // Submit a bid with exactly the same value — should be rejected. + let equal_bid = make_signed_bid( + slot, + 1, + Address::ZERO, + 30_000_000, + 100, + ctx.genesis_block_root, + ); + let result = GossipVerifiedPayloadBid::new(equal_bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::BidValueBelowCached { + cached_value: 100, + incoming_value: 100, + }) + )); +} 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 4d40a29332..77b44a2af0 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 @@ -242,8 +242,8 @@ impl GossipVerifiedEnvelope { } impl BeaconChain { - /// Build a `GossipVerificationContext` from this `BeaconChain`. - pub fn gossip_verification_context(&self) -> GossipVerificationContext<'_, T> { + /// Build a `GossipVerificationContext` from this `BeaconChain` for `GossipVerifiedEnvelope`. + pub fn payload_envelope_gossip_verification_context(&self) -> GossipVerificationContext<'_, T> { GossipVerificationContext { canonical_head: &self.canonical_head, store: &self.store, @@ -277,7 +277,7 @@ impl BeaconChain { let slot = envelope.slot(); let beacon_block_root = envelope.message.beacon_block_root; - let ctx = chain.gossip_verification_context(); + let ctx = chain.payload_envelope_gossip_verification_context(); match GossipVerifiedEnvelope::new(envelope, &ctx) { Ok(verified) => { debug!( diff --git a/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs b/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs new file mode 100644 index 0000000000..8ea095743f --- /dev/null +++ b/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs @@ -0,0 +1,223 @@ +use std::sync::Arc; + +use crate::{ + BeaconChain, BeaconChainTypes, CanonicalHead, + proposer_preferences_verification::{ + ProposerPreferencesError, proposer_preference_cache::GossipVerifiedProposerPreferenceCache, + }, +}; +use slot_clock::SlotClock; +use state_processing::signature_sets::{get_pubkey_from_state, proposer_preferences_signature_set}; +use tracing::debug; +use types::{ + BeaconState, ChainSpec, EthSpec, ProposerPreferences, SignedProposerPreferences, Slot, +}; + +/// Verify that proposer preferences are consistent with the current chain state +pub(crate) fn verify_preferences_consistency( + preferences: &ProposerPreferences, + current_slot: Slot, + head_state: &BeaconState, +) -> Result<(), ProposerPreferencesError> { + let proposal_slot = preferences.proposal_slot; + let validator_index = preferences.validator_index; + let current_epoch = current_slot.epoch(E::slots_per_epoch()); + let proposal_epoch = proposal_slot.epoch(E::slots_per_epoch()); + + if proposal_epoch < current_epoch || proposal_epoch > current_epoch.saturating_add(1u64) { + return Err(ProposerPreferencesError::InvalidProposalEpoch { proposal_epoch }); + } + + if proposal_slot <= current_slot { + return Err(ProposerPreferencesError::ProposalSlotAlreadyPassed { + proposal_slot, + current_slot, + }); + } + + if !head_state.is_valid_proposal_slot(preferences)? { + return Err(ProposerPreferencesError::InvalidProposalSlot { + validator_index, + proposal_slot, + }); + } + + Ok(()) +} + +pub struct GossipVerificationContext<'a, T: BeaconChainTypes> { + pub canonical_head: &'a CanonicalHead, + pub gossip_verified_proposer_preferences_cache: &'a GossipVerifiedProposerPreferenceCache, + pub slot_clock: &'a T::SlotClock, + pub spec: &'a ChainSpec, +} + +/// A wrapper around `SignedProposerPreferences` that has been verified for gossip propagation. +#[derive(Debug, Clone)] +pub struct GossipVerifiedProposerPreferences { + pub signed_preferences: Arc, +} + +impl GossipVerifiedProposerPreferences { + pub fn new( + signed_preferences: Arc, + ctx: &GossipVerificationContext<'_, T>, + ) -> Result { + let proposal_slot = signed_preferences.message.proposal_slot; + let validator_index = signed_preferences.message.validator_index; + let cached_head = ctx.canonical_head.cached_head(); + let current_slot = ctx + .slot_clock + .now() + .ok_or(ProposerPreferencesError::UnableToReadSlot)?; + let head_state = &cached_head.snapshot.beacon_state; + + if ctx + .gossip_verified_proposer_preferences_cache + .get_seen_validator(&proposal_slot, validator_index) + { + return Err(ProposerPreferencesError::AlreadySeen { + validator_index, + proposal_slot, + }); + } + + verify_preferences_consistency(&signed_preferences.message, current_slot, head_state)?; + + // Verify signature + proposer_preferences_signature_set( + head_state, + |i| get_pubkey_from_state(head_state, i), + &signed_preferences, + ctx.spec, + ) + .map_err(|_| ProposerPreferencesError::BadSignature)? + .verify() + .then_some(()) + .ok_or(ProposerPreferencesError::BadSignature)?; + + let gossip_verified = GossipVerifiedProposerPreferences { signed_preferences }; + + ctx.gossip_verified_proposer_preferences_cache + .insert_seen_validator(&gossip_verified); + + ctx.gossip_verified_proposer_preferences_cache + .insert_preferences(gossip_verified.clone()); + + Ok(gossip_verified) + } +} + +impl BeaconChain { + pub fn proposer_preferences_gossip_verification_context( + &self, + ) -> GossipVerificationContext<'_, T> { + GossipVerificationContext { + canonical_head: &self.canonical_head, + gossip_verified_proposer_preferences_cache: &self + .gossip_verified_proposer_preferences_cache, + slot_clock: &self.slot_clock, + spec: &self.spec, + } + } + + pub fn verify_proposer_preferences_for_gossip( + &self, + signed_preferences: Arc, + ) -> Result { + let proposal_slot = signed_preferences.message.proposal_slot; + let validator_index = signed_preferences.message.validator_index; + + let ctx = self.proposer_preferences_gossip_verification_context(); + match GossipVerifiedProposerPreferences::new(signed_preferences, &ctx) { + Ok(verified) => { + debug!( + %proposal_slot, + %validator_index, + "Successfully verified gossip proposer preferences" + ); + Ok(verified) + } + Err(e) => { + debug!( + error = e.to_string(), + %proposal_slot, + %validator_index, + "Rejected gossip proposer preferences" + ); + Err(e) + } + } + } +} + +#[cfg(test)] +mod tests { + use types::{Address, BeaconState, EthSpec, MinimalEthSpec, ProposerPreferences, Slot}; + + use super::verify_preferences_consistency; + use crate::proposer_preferences_verification::ProposerPreferencesError; + + type E = MinimalEthSpec; + + fn make_preferences(proposal_slot: Slot, validator_index: u64) -> ProposerPreferences { + ProposerPreferences { + proposal_slot, + validator_index, + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + } + } + + fn state() -> BeaconState { + BeaconState::new(0, <_>::default(), &E::default_spec()) + } + + #[test] + fn test_invalid_epoch_too_old() { + let current_slot = Slot::new(2 * E::slots_per_epoch()); + let prefs = make_preferences(Slot::new(3), 0); + + let result = verify_preferences_consistency::(&prefs, current_slot, &state()); + assert!(matches!( + result, + Err(ProposerPreferencesError::InvalidProposalEpoch { .. }) + )); + } + + #[test] + fn test_invalid_epoch_too_far_ahead() { + let current_slot = Slot::new(E::slots_per_epoch()); + let prefs = make_preferences(Slot::new(3 * E::slots_per_epoch() + 1), 0); + + let result = verify_preferences_consistency::(&prefs, current_slot, &state()); + assert!(matches!( + result, + Err(ProposerPreferencesError::InvalidProposalEpoch { .. }) + )); + } + + #[test] + fn test_proposal_slot_already_passed() { + let current_slot = Slot::new(10); + let prefs = make_preferences(Slot::new(9), 0); + + let result = verify_preferences_consistency::(&prefs, current_slot, &state()); + assert!(matches!( + result, + Err(ProposerPreferencesError::ProposalSlotAlreadyPassed { .. }) + )); + } + + #[test] + fn test_proposal_slot_equal_to_current() { + let current_slot = Slot::new(10); + let prefs = make_preferences(Slot::new(10), 0); + + let result = verify_preferences_consistency::(&prefs, current_slot, &state()); + assert!(matches!( + result, + Err(ProposerPreferencesError::ProposalSlotAlreadyPassed { .. }) + )); + } +} diff --git a/beacon_node/beacon_chain/src/proposer_preferences_verification/mod.rs b/beacon_node/beacon_chain/src/proposer_preferences_verification/mod.rs new file mode 100644 index 0000000000..a2e96dfce1 --- /dev/null +++ b/beacon_node/beacon_chain/src/proposer_preferences_verification/mod.rs @@ -0,0 +1,70 @@ +//! Gossip verification for proposer preferences. +//! +//! A `SignedProposerPreferences` is verified and wrapped as a `GossipVerifiedProposerPreferences`, +//! which is then inserted into the `GossipVerifiedProposerPreferenceCache`. +//! +//! ```ignore +//! SignedProposerPreferences +//! | +//! ▼ +//! GossipVerifiedProposerPreferences -------> Insert into GossipVerifiedProposerPreferenceCache +//! ``` + +use std::sync::Arc; + +use types::{BeaconStateError, Epoch, Slot}; + +use crate::BeaconChainError; + +pub mod gossip_verified_proposer_preferences; +pub mod proposer_preference_cache; + +#[cfg(test)] +mod tests; + +#[derive(Debug)] +pub enum ProposerPreferencesError { + /// The proposal slot is not in the current or next epoch. + InvalidProposalEpoch { proposal_epoch: Epoch }, + /// The proposal slot has already passed. + ProposalSlotAlreadyPassed { + proposal_slot: Slot, + current_slot: Slot, + }, + /// The validator index does not match the proposer at the given slot. + InvalidProposalSlot { + validator_index: u64, + proposal_slot: Slot, + }, + /// The slot clock cannot be read. + UnableToReadSlot, + /// A valid message from this validator for this slot has already been seen. + AlreadySeen { + validator_index: u64, + proposal_slot: Slot, + }, + /// The signature is invalid. + BadSignature, + /// Some Beacon Chain Error + BeaconChainError(Arc), + /// Some Beacon State error + BeaconStateError(BeaconStateError), +} + +impl std::fmt::Display for ProposerPreferencesError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + +impl From for ProposerPreferencesError { + fn from(e: BeaconStateError) -> Self { + ProposerPreferencesError::BeaconStateError(e) + } +} + +impl From for ProposerPreferencesError { + fn from(e: BeaconChainError) -> Self { + ProposerPreferencesError::BeaconChainError(Arc::new(e)) + } +} diff --git a/beacon_node/beacon_chain/src/proposer_preferences_verification/proposer_preference_cache.rs b/beacon_node/beacon_chain/src/proposer_preferences_verification/proposer_preference_cache.rs new file mode 100644 index 0000000000..69337f2a83 --- /dev/null +++ b/beacon_node/beacon_chain/src/proposer_preferences_verification/proposer_preference_cache.rs @@ -0,0 +1,107 @@ +use std::{ + collections::{BTreeMap, HashSet}, + sync::Arc, +}; + +use crate::proposer_preferences_verification::gossip_verified_proposer_preferences::GossipVerifiedProposerPreferences; +use parking_lot::RwLock; +use types::{SignedProposerPreferences, Slot}; + +pub struct GossipVerifiedProposerPreferenceCache { + preferences: RwLock>, + seen: RwLock>>, +} + +impl Default for GossipVerifiedProposerPreferenceCache { + fn default() -> Self { + Self { + preferences: RwLock::new(BTreeMap::new()), + seen: RwLock::new(BTreeMap::new()), + } + } +} + +impl GossipVerifiedProposerPreferenceCache { + pub fn get_preferences(&self, slot: &Slot) -> Option> { + self.preferences + .read() + .get(slot) + .map(|p| p.signed_preferences.clone()) + } + + pub fn insert_preferences(&self, preferences: GossipVerifiedProposerPreferences) { + let slot = preferences.signed_preferences.message.proposal_slot; + self.preferences.write().insert(slot, preferences); + } + + pub fn get_seen_validator(&self, slot: &Slot, validator_index: u64) -> bool { + self.seen + .read() + .get(slot) + .is_some_and(|seen| seen.contains(&validator_index)) + } + + pub fn insert_seen_validator(&self, preferences: &GossipVerifiedProposerPreferences) { + let slot = preferences.signed_preferences.message.proposal_slot; + let validator_index = preferences.signed_preferences.message.validator_index; + self.seen + .write() + .entry(slot) + .or_default() + .insert(validator_index); + } + + pub fn prune(&self, current_slot: Slot) { + self.preferences + .write() + .retain(|&slot, _| slot >= current_slot); + self.seen.write().retain(|&slot, _| slot >= current_slot); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use bls::Signature; + use types::{Address, ProposerPreferences, SignedProposerPreferences, Slot}; + + use super::GossipVerifiedProposerPreferenceCache; + use crate::proposer_preferences_verification::gossip_verified_proposer_preferences::GossipVerifiedProposerPreferences; + + fn make_gossip_verified(slot: Slot, validator_index: u64) -> GossipVerifiedProposerPreferences { + GossipVerifiedProposerPreferences { + signed_preferences: Arc::new(SignedProposerPreferences { + message: ProposerPreferences { + proposal_slot: slot, + validator_index, + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + }, + signature: Signature::empty(), + }), + } + } + + #[test] + fn prune_removes_old_retains_current() { + let cache = GossipVerifiedProposerPreferenceCache::default(); + + for slot in [1, 2, 3, 7, 8, 9, 10] { + let verified = make_gossip_verified(Slot::new(slot), slot); + cache.insert_seen_validator(&verified); + cache.insert_preferences(verified); + } + + cache.prune(Slot::new(8)); + + for slot in [1, 2, 3, 7] { + assert!(cache.get_preferences(&Slot::new(slot)).is_none()); + assert!(!cache.get_seen_validator(&Slot::new(slot), slot)); + } + for slot in [8, 9, 10] { + assert!(cache.get_preferences(&Slot::new(slot)).is_some()); + assert!(cache.get_seen_validator(&Slot::new(slot), slot)); + } + } +} diff --git a/beacon_node/beacon_chain/src/proposer_preferences_verification/tests.rs b/beacon_node/beacon_chain/src/proposer_preferences_verification/tests.rs new file mode 100644 index 0000000000..2f1b24fcbb --- /dev/null +++ b/beacon_node/beacon_chain/src/proposer_preferences_verification/tests.rs @@ -0,0 +1,279 @@ +use std::sync::Arc; +use std::time::Duration; + +use bls::Signature; +use fork_choice::ForkChoice; +use genesis::{generate_deterministic_keypairs, interop_genesis_state}; +use proto_array::PayloadStatus; +use slot_clock::{SlotClock, TestingSlotClock}; +use store::{HotColdDB, StoreConfig}; +use types::{ + Address, BeaconBlock, ChainSpec, Checkpoint, Epoch, EthSpec, Hash256, MinimalEthSpec, + ProposerPreferences, SignedBeaconBlock, SignedProposerPreferences, Slot, +}; + +use crate::{ + beacon_fork_choice_store::BeaconForkChoiceStore, + beacon_snapshot::BeaconSnapshot, + canonical_head::CanonicalHead, + proposer_preferences_verification::{ + ProposerPreferencesError, + gossip_verified_proposer_preferences::{ + GossipVerificationContext, GossipVerifiedProposerPreferences, + }, + proposer_preference_cache::GossipVerifiedProposerPreferenceCache, + }, + test_utils::{EphemeralHarnessType, fork_name_from_env, test_spec}, +}; + +type E = MinimalEthSpec; +type T = EphemeralHarnessType; + +const NUM_VALIDATORS: usize = 64; + +struct TestContext { + canonical_head: CanonicalHead, + preferences_cache: GossipVerifiedProposerPreferenceCache, + slot_clock: TestingSlotClock, + spec: ChainSpec, +} + +impl TestContext { + fn new() -> Self { + let spec = test_spec::(); + let store = Arc::new( + HotColdDB::open_ephemeral(StoreConfig::default(), Arc::new(spec.clone())) + .expect("should open ephemeral store"), + ); + + let keypairs = generate_deterministic_keypairs(NUM_VALIDATORS); + + let mut state = + interop_genesis_state::(&keypairs, 0, Hash256::repeat_byte(0x42), None, &spec) + .expect("should build genesis state"); + + *state.finalized_checkpoint_mut() = Checkpoint { + epoch: Epoch::new(1), + root: Hash256::ZERO, + }; + + let mut genesis_block = BeaconBlock::empty(&spec); + *genesis_block.state_root_mut() = state + .update_tree_hash_cache() + .expect("should hash genesis state"); + let signed_block = SignedBeaconBlock::from_block(genesis_block, Signature::empty()); + let block_root = signed_block.canonical_root(); + + let snapshot = BeaconSnapshot::new( + Arc::new(signed_block.clone()), + None, + block_root, + state.clone(), + ); + + let fc_store = BeaconForkChoiceStore::get_forkchoice_store(store.clone(), snapshot.clone()) + .expect("should create fork choice store"); + let fork_choice = + ForkChoice::from_anchor(fc_store, block_root, &signed_block, &state, None, &spec) + .expect("should create fork choice"); + + let canonical_head = + CanonicalHead::new(fork_choice, Arc::new(snapshot), PayloadStatus::Pending); + + let slot_clock = TestingSlotClock::new( + Slot::new(0), + Duration::from_secs(0), + spec.get_slot_duration(), + ); + + Self { + canonical_head, + preferences_cache: GossipVerifiedProposerPreferenceCache::default(), + slot_clock, + spec, + } + } + + fn gossip_ctx(&self) -> GossipVerificationContext<'_, T> { + GossipVerificationContext { + canonical_head: &self.canonical_head, + gossip_verified_proposer_preferences_cache: &self.preferences_cache, + slot_clock: &self.slot_clock, + spec: &self.spec, + } + } + + fn proposer_at_slot(&self, slot: Slot) -> u64 { + let head = self.canonical_head.cached_head(); + let state = &head.snapshot.beacon_state; + let lookahead = state + .proposer_lookahead() + .expect("Gloas state has lookahead"); + let slot_in_epoch = slot.as_usize() % E::slots_per_epoch() as usize; + let epoch = slot.epoch(E::slots_per_epoch()); + let current_epoch = state.slot().epoch(E::slots_per_epoch()); + let index = if epoch == current_epoch.saturating_add(1u64) { + E::slots_per_epoch() as usize + slot_in_epoch + } else { + slot_in_epoch + }; + *lookahead.get(index).expect("index in range") + } +} + +fn make_signed_preferences( + proposal_slot: Slot, + validator_index: u64, +) -> Arc { + Arc::new(SignedProposerPreferences { + message: ProposerPreferences { + proposal_slot, + validator_index, + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + }, + signature: Signature::empty(), + }) +} + +#[test] +fn already_seen_validator() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(1); + + let verified = GossipVerifiedProposerPreferences { + signed_preferences: make_signed_preferences(slot, 42), + }; + ctx.preferences_cache.insert_seen_validator(&verified); + + let prefs = make_signed_preferences(slot, 42); + let result = GossipVerifiedProposerPreferences::new(prefs, &gossip); + assert!(matches!( + result, + Err(ProposerPreferencesError::AlreadySeen { + validator_index: 42, + .. + }) + )); +} + +#[test] +fn invalid_epoch_too_far_ahead() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + + let far_slot = Slot::new(3 * E::slots_per_epoch()); + let prefs = make_signed_preferences(far_slot, 0); + let result = GossipVerifiedProposerPreferences::new(prefs, &gossip); + assert!(matches!( + result, + Err(ProposerPreferencesError::InvalidProposalEpoch { .. }) + )); +} + +#[test] +fn proposal_slot_already_passed() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + + let prefs = make_signed_preferences(Slot::new(0), 0); + let result = GossipVerifiedProposerPreferences::new(prefs, &gossip); + assert!(matches!( + result, + Err(ProposerPreferencesError::ProposalSlotAlreadyPassed { .. }) + )); +} + +#[test] +fn wrong_proposer_for_slot() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(1); + + let actual_proposer = ctx.proposer_at_slot(slot); + let wrong_validator = if actual_proposer == 0 { 1 } else { 0 }; + + let prefs = make_signed_preferences(slot, wrong_validator); + let result = GossipVerifiedProposerPreferences::new(prefs, &gossip); + assert!(matches!( + result, + Err(ProposerPreferencesError::InvalidProposalSlot { .. }) + )); +} + +#[test] +fn correct_proposer_bad_signature() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(1); + + let actual_proposer = ctx.proposer_at_slot(slot); + let prefs = make_signed_preferences(slot, actual_proposer); + let result = GossipVerifiedProposerPreferences::new(prefs, &gossip); + assert!(matches!( + result, + Err(ProposerPreferencesError::BadSignature) + )); + assert!( + !ctx.preferences_cache + .get_seen_validator(&slot, actual_proposer) + ); + assert!(ctx.preferences_cache.get_preferences(&slot).is_none()); +} + +#[test] +fn validator_index_out_of_bounds() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(1); + + let prefs = make_signed_preferences(slot, u64::MAX); + let result = GossipVerifiedProposerPreferences::new(prefs, &gossip); + assert!(matches!( + result, + Err(ProposerPreferencesError::InvalidProposalSlot { .. }) + )); +} + +// TODO(gloas) add successful proposer preferences check once we have proposer preferences signing logic + +#[test] +fn preferences_for_next_epoch_slot() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + + // Head is at slot 0 (epoch 0). Pick a slot in epoch 1. + let next_epoch_slot = Slot::new(E::slots_per_epoch() + 1); + let actual_proposer = ctx.proposer_at_slot(next_epoch_slot); + + let prefs = make_signed_preferences(next_epoch_slot, actual_proposer); + let result = GossipVerifiedProposerPreferences::new(prefs, &gossip); + // Should pass consistency checks but fail on signature (empty sig). + assert!( + matches!(result, Err(ProposerPreferencesError::BadSignature)), + "expected BadSignature for next-epoch slot, got: {:?}", + result + ); +} 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 1f55d9a878..c0aa30ffcc 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -4,8 +4,6 @@ use crate::{ service::NetworkMessage, sync::SyncMessage, }; -use beacon_chain::block_verification_types::AsBlock; -use beacon_chain::data_column_verification::{GossipDataColumnError, GossipVerifiedDataColumn}; use beacon_chain::store::Error; use beacon_chain::{ AvailabilityProcessingStatus, BeaconChainError, BeaconChainTypes, BlockError, ForkChoiceError, @@ -24,6 +22,11 @@ use beacon_chain::{ EnvelopeError, gossip_verified_envelope::GossipVerifiedEnvelope, }, }; +use beacon_chain::{block_verification_types::AsBlock, payload_bid_verification::PayloadBidError}; +use beacon_chain::{ + data_column_verification::{GossipDataColumnError, GossipVerifiedDataColumn}, + proposer_preferences_verification::ProposerPreferencesError, +}; use beacon_processor::{Work, WorkEvent}; use lighthouse_network::{Client, MessageAcceptance, MessageId, PeerAction, PeerId, ReportSource}; use logging::crit; @@ -3470,26 +3473,103 @@ impl NetworkBeaconProcessor { } } + #[instrument( + name = "lh_process_execution_payload_bid", + parent = None, + level = "debug", + skip_all, + fields(parent_block_hash = ?bid.message.parent_block_hash, parent_block_root = ?bid.message.parent_block_root), + )] pub fn process_gossip_execution_payload_bid( self: &Arc, message_id: MessageId, peer_id: PeerId, - payload_bid: SignedExecutionPayloadBid, + bid: Arc>, ) { - // TODO(EIP-7732): Implement proper payload bid gossip processing. - // This should integrate with a payload execution bid verification module once it's implemented. + let verification_result = self.chain.verify_payload_bid_for_gossip(bid.clone()); - trace!( - %peer_id, - slot = %payload_bid.message.slot, - value = %payload_bid.message.value, - "Processing execution payload bid" - ); - - // For now, ignore all payload bids since verification is not implemented - self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); + match verification_result { + Ok(_) => { + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Accept); + } + Err( + PayloadBidError::BadSignature + | PayloadBidError::InvalidBuilder { .. } + | PayloadBidError::InvalidFeeRecipient + | PayloadBidError::InvalidGasLimit + | PayloadBidError::ExecutionPaymentNonZero { .. } + | PayloadBidError::InvalidBlobKzgCommitments { .. }, + ) => { + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Reject); + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "invalid_gossip_payload_bid", + ); + } + Err( + PayloadBidError::NoProposerPreferences { .. } + | PayloadBidError::BuilderAlreadySeen { .. } + | PayloadBidError::BidValueBelowCached { .. } + | PayloadBidError::ParentBlockRootUnknown { .. } + | PayloadBidError::ParentBlockRootNotCanonical { .. } + | PayloadBidError::BuilderCantCoverBid { .. } + | PayloadBidError::BeaconStateError(_) + | PayloadBidError::InternalError(_) + | PayloadBidError::InvalidBidSlot { .. } + | PayloadBidError::UnableToReadSlot, + ) => { + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); + } + } } + #[instrument( + name = "lh_process_proposer_preferences", + parent = None, + level = "debug", + skip_all, + fields(validator_index = ?proposer_preferences.message.validator_index, proposal_slot = ?proposer_preferences.message.proposal_slot), + )] + pub fn process_gossip_proposer_preferences( + self: &Arc, + message_id: MessageId, + peer_id: PeerId, + proposer_preferences: Arc, + ) { + let verification_result = self + .chain + .verify_proposer_preferences_for_gossip(proposer_preferences); + + match verification_result { + Ok(_) => { + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Accept); + } + Err( + ProposerPreferencesError::AlreadySeen { .. } + | ProposerPreferencesError::InvalidProposalEpoch { .. } + | ProposerPreferencesError::ProposalSlotAlreadyPassed { .. } + | ProposerPreferencesError::BeaconChainError(_) + | ProposerPreferencesError::BeaconStateError(_) + | ProposerPreferencesError::UnableToReadSlot, + ) => { + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); + } + Err( + ProposerPreferencesError::InvalidProposalSlot { .. } + | ProposerPreferencesError::BadSignature, + ) => { + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Reject); + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "invalid_gossip_proposer_preferences", + ); + } + } + } + + // TODO(gloas) dont forget to add tracing instrumentation pub fn process_gossip_payload_attestation( self: &Arc, message_id: MessageId, @@ -3510,23 +3590,4 @@ impl NetworkBeaconProcessor { // For now, ignore all payload attestation messages since verification is not implemented self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); } - - pub fn process_gossip_proposer_preferences( - self: &Arc, - message_id: MessageId, - peer_id: PeerId, - proposer_preferences: SignedProposerPreferences, - ) { - // TODO(EIP-7732): Implement proper proposer preferences gossip processing. - - trace!( - %peer_id, - validator_index = proposer_preferences.message.validator_index, - slot = %proposer_preferences.message.proposal_slot, - "Processing proposer preferences" - ); - - // For now, ignore all proposer preferences since verification is not implemented - self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); - } } diff --git a/beacon_node/network/src/network_beacon_processor/mod.rs b/beacon_node/network/src/network_beacon_processor/mod.rs index b3d6874b8a..2b354aaa20 100644 --- a/beacon_node/network/src/network_beacon_processor/mod.rs +++ b/beacon_node/network/src/network_beacon_processor/mod.rs @@ -463,7 +463,7 @@ impl NetworkBeaconProcessor { processor.process_gossip_execution_payload_bid( message_id, peer_id, - *execution_payload_bid, + Arc::new(*execution_payload_bid), ) }; @@ -507,12 +507,12 @@ impl NetworkBeaconProcessor { processor.process_gossip_proposer_preferences( message_id, peer_id, - *proposer_preferences, + Arc::new(*proposer_preferences), ) }; self.try_send(BeaconWorkEvent { - drop_during_sync: false, + drop_during_sync: true, work: Work::GossipProposerPreferences(Box::new(process_fn)), }) } diff --git a/consensus/state_processing/src/per_block_processing.rs b/consensus/state_processing/src/per_block_processing.rs index 5aa610e98e..210e0437be 100644 --- a/consensus/state_processing/src/per_block_processing.rs +++ b/consensus/state_processing/src/per_block_processing.rs @@ -531,26 +531,6 @@ pub fn compute_timestamp_at_slot( .and_then(|since_genesis| state.genesis_time().safe_add(since_genesis)) } -pub fn can_builder_cover_bid( - state: &BeaconState, - builder_index: BuilderIndex, - builder: &Builder, - bid_amount: u64, - spec: &ChainSpec, -) -> Result { - let builder_balance = builder.balance; - let pending_withdrawals_amount = - state.get_pending_balance_to_withdraw_for_builder(builder_index)?; - let min_balance = spec - .min_deposit_amount - .safe_add(pending_withdrawals_amount)?; - if builder_balance < min_balance { - Ok(false) - } else { - Ok(builder_balance.safe_sub(min_balance)? >= bid_amount) - } -} - pub fn process_execution_payload_bid>( state: &mut BeaconState, block: BeaconBlockRef<'_, E, Payload>, @@ -579,13 +559,13 @@ pub fn process_execution_payload_bid // Verify that the builder is active block_verify!( - builder.is_active_at_finalized_epoch(state.finalized_checkpoint().epoch, spec), + state.is_active_builder(builder_index, spec)?, ExecutionPayloadBidInvalid::BuilderNotActive(builder_index).into() ); // Verify that the builder has funds to cover the bid block_verify!( - can_builder_cover_bid(state, builder_index, builder, amount, spec)?, + state.can_builder_cover_bid(builder_index, amount, spec)?, ExecutionPayloadBidInvalid::InsufficientBalance { builder_index, builder_balance: builder.balance, diff --git a/consensus/state_processing/src/per_block_processing/process_operations.rs b/consensus/state_processing/src/per_block_processing/process_operations.rs index ac64398655..f1de284fc8 100644 --- a/consensus/state_processing/src/per_block_processing/process_operations.rs +++ b/consensus/state_processing/src/per_block_processing/process_operations.rs @@ -556,8 +556,7 @@ fn process_builder_voluntary_exit( )))?; // Verify the builder is active - let finalized_epoch = state.finalized_checkpoint().epoch; - if !builder.is_active_at_finalized_epoch(finalized_epoch, spec) { + if !state.is_active_builder(builder_index, spec)? { return Err(BlockOperationError::invalid(ExitInvalid::NotActive( signed_exit.message.validator_index, ))); diff --git a/consensus/state_processing/src/per_block_processing/signature_sets.rs b/consensus/state_processing/src/per_block_processing/signature_sets.rs index 71ee1f8993..5c1767f227 100644 --- a/consensus/state_processing/src/per_block_processing/signature_sets.rs +++ b/consensus/state_processing/src/per_block_processing/signature_sets.rs @@ -12,9 +12,9 @@ use types::{ BuilderIndex, ChainSpec, DepositData, Domain, Epoch, EthSpec, Fork, Hash256, InconsistentFork, IndexedAttestation, IndexedAttestationRef, IndexedPayloadAttestation, ProposerSlashing, SignedAggregateAndProof, SignedBeaconBlock, SignedBeaconBlockHeader, - SignedBlsToExecutionChange, SignedContributionAndProof, SignedExecutionPayloadBid, SignedRoot, - SignedVoluntaryExit, SigningData, Slot, SyncAggregate, SyncAggregatorSelectionData, - consts::gloas::BUILDER_INDEX_SELF_BUILD, + SignedBlsToExecutionChange, SignedContributionAndProof, SignedExecutionPayloadBid, + SignedProposerPreferences, SignedRoot, SignedVoluntaryExit, SigningData, Slot, SyncAggregate, + SyncAggregatorSelectionData, consts::gloas::BUILDER_INDEX_SELF_BUILD, }; pub type Result = std::result::Result; @@ -389,6 +389,37 @@ where Ok(SignatureSet::multiple_pubkeys(signature, pubkeys, message)) } +pub fn proposer_preferences_signature_set<'a, E, F>( + state: &'a BeaconState, + get_pubkey: F, + signed_proposer_preferences: &'a SignedProposerPreferences, + spec: &'a ChainSpec, +) -> Result> +where + E: EthSpec, + F: Fn(usize) -> Option>, +{ + let preferences = &signed_proposer_preferences.message; + let validator_index = preferences.validator_index as usize; + + let proposal_epoch = preferences.proposal_slot.epoch(E::slots_per_epoch()); + let proposal_fork = spec.fork_at_epoch(proposal_epoch); + let domain = spec.get_domain( + proposal_epoch, + Domain::ProposerPreferences, + &proposal_fork, + state.genesis_validators_root(), + ); + + let message = preferences.signing_root(domain); + + Ok(SignatureSet::single_pubkey( + &signed_proposer_preferences.signature, + get_pubkey(validator_index).ok_or(Error::ValidatorUnknown(validator_index as u64))?, + message, + )) +} + pub fn execution_payload_bid_signature_set<'a, E, F>( state: &'a BeaconState, get_builder_pubkey: F, @@ -407,10 +438,16 @@ where // See `process_execution_payload_bid`. return Ok(None); } + + let bid_epoch = signed_execution_payload_bid + .message + .slot + .epoch(E::slots_per_epoch()); + let bid_fork = spec.fork_at_epoch(bid_epoch); let domain = spec.get_domain( - state.current_epoch(), + bid_epoch, Domain::BeaconBuilder, - &state.fork(), + &bid_fork, state.genesis_validators_root(), ); diff --git a/consensus/types/src/builder/builder.rs b/consensus/types/src/builder/builder.rs index 7d494da3ee..2bd50f42cc 100644 --- a/consensus/types/src/builder/builder.rs +++ b/consensus/types/src/builder/builder.rs @@ -1,5 +1,5 @@ use crate::test_utils::TestRandom; -use crate::{Address, ChainSpec, Epoch, ForkName}; +use crate::{Address, Epoch, ForkName}; use bls::PublicKeyBytes; use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; @@ -24,12 +24,3 @@ pub struct Builder { pub deposit_epoch: Epoch, pub withdrawable_epoch: Epoch, } - -impl Builder { - /// Check if a builder is active in a state with `finalized_epoch`. - /// - /// This implements `is_active_builder` from the spec. - pub fn is_active_at_finalized_epoch(&self, finalized_epoch: Epoch, spec: &ChainSpec) -> bool { - self.deposit_epoch < finalized_epoch && self.withdrawable_epoch == spec.far_future_epoch - } -} diff --git a/consensus/types/src/state/beacon_state.rs b/consensus/types/src/state/beacon_state.rs index a033272b9d..8bef8816e5 100644 --- a/consensus/types/src/state/beacon_state.rs +++ b/consensus/types/src/state/beacon_state.rs @@ -24,7 +24,7 @@ use tree_hash_derive::TreeHash; use typenum::Unsigned; use crate::{ - Address, ExecutionBlockHash, ExecutionPayloadBid, Withdrawal, + Address, ExecutionBlockHash, ExecutionPayloadBid, ProposerPreferences, Withdrawal, attestation::{ AttestationData, AttestationDuty, BeaconCommittee, Checkpoint, CommitteeIndex, PTC, ParticipationFlags, PendingAttestation, @@ -1349,6 +1349,43 @@ impl BeaconState { } } + /// Check if the validator is the proposer for the given slot in the current or next epoch. + pub fn is_valid_proposal_slot( + &self, + preferences: &ProposerPreferences, + ) -> Result { + let current_epoch = self.current_epoch(); + let proposal_epoch = preferences.proposal_slot.epoch(E::slots_per_epoch()); + + if proposal_epoch < current_epoch { + return Ok(false); + } + + let next_epoch = current_epoch.saturating_add(1u64); + if proposal_epoch > next_epoch { + return Ok(false); + } + + let epoch_offset = proposal_epoch.as_u64().safe_sub(current_epoch.as_u64())?; + + let slot_in_epoch = preferences + .proposal_slot + .as_u64() + .safe_rem(E::slots_per_epoch())?; + + let index = epoch_offset + .safe_mul(E::slots_per_epoch()) + .and_then(|v| v.safe_add(slot_in_epoch))?; + + let proposer_lookahead = self.proposer_lookahead()?; + + let proposer = proposer_lookahead + .get(index as usize) + .ok_or(BeaconStateError::ProposerLookaheadOutOfBounds { i: index as usize })?; + + Ok(*proposer == preferences.validator_index) + } + /// Returns the beacon proposer index for each `slot` in `epoch`. /// /// The returned `Vec` contains one proposer index for each slot in the epoch. @@ -3259,6 +3296,38 @@ impl BeaconState { Ok(effective_balance.safe_mul(MAX_RANDOM_VALUE)? >= max_effective_balance.safe_mul(random_value)?) } + + pub fn can_builder_cover_bid( + &self, + builder_index: BuilderIndex, + bid_amount: u64, + spec: &ChainSpec, + ) -> Result { + let builder = self.get_builder(builder_index)?; + + let builder_balance = builder.balance; + let pending_withdrawals_amount = + self.get_pending_balance_to_withdraw_for_builder(builder_index)?; + + let min_balance = spec + .min_deposit_amount + .safe_add(pending_withdrawals_amount)?; + if builder_balance < min_balance { + return Ok(false); + } + Ok(builder_balance.safe_sub(min_balance)? >= bid_amount) + } + + pub fn is_active_builder( + &self, + builder_index: BuilderIndex, + spec: &ChainSpec, + ) -> Result { + let builder = self.get_builder(builder_index)?; + + Ok(builder.deposit_epoch < self.finalized_checkpoint().epoch + && builder.withdrawable_epoch == spec.far_future_epoch) + } } impl ForkVersionDecode for BeaconState {