From 242e463f286b2e426397c55945454a16200a18d0 Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Fri, 21 Aug 2026 14:17:58 +0000 Subject: [PATCH 1/6] Fix Gloas bid parent gas limit validation --- .../gossip_verified_bid.rs | 108 +++++++++++++++--- .../src/payload_bid_verification/mod.rs | 6 +- .../src/payload_bid_verification/tests.rs | 64 ++++++++++- .../gossip_methods.rs | 1 + 4 files changed, 158 insertions(+), 21 deletions(-) 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 index 00168a58c9f..9e27e4e5e13 100644 --- 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 @@ -17,10 +17,80 @@ use state_processing::signature_sets::{ }; use tracing::debug; use types::{ - BeaconState, ChainSpec, EthSpec, ExecutionPayloadBid, SignedExecutionPayloadBid, - SignedProposerPreferences, Slot, consts::gloas::PAYLOAD_BUILDER_VERSION, + BeaconState, ChainSpec, EthSpec, ExecPayload, ExecutionBlockHash, ExecutionPayloadBid, Hash256, + SignedExecutionPayloadBid, SignedProposerPreferences, Slot, + consts::gloas::PAYLOAD_BUILDER_VERSION, }; +/// Find the beacon block carrying the known execution payload referenced by a bid. +pub(crate) fn find_execution_payload_block_root( + fork_choice_read: &ForkChoiceReadGuard<'_, T>, + parent_block_root: Hash256, + parent_block_hash: ExecutionBlockHash, +) -> Result { + let mut block_root = Some(parent_block_root); + while let Some(root) = block_root { + let Some(block) = fork_choice_read.get_block(&root) else { + break; + }; + + if let Some(block_hash) = block.execution_payload_block_hash { + if fork_choice_read.is_payload_received(&root) && block_hash == parent_block_hash { + return Ok(root); + } + } else if !block.execution_status.is_invalid() + && block.execution_status.block_hash() == Some(parent_block_hash) + { + return Ok(root); + } + + block_root = block.parent_root; + } + Err(PayloadBidError::ParentExecutionPayloadUnknown { parent_block_hash }) +} + +/// Return the gas limit committed by the block carrying an execution payload. +pub(crate) fn get_parent_gas_limit( + store: &BeaconStore, + execution_payload_block_root: Hash256, + parent_block_hash: ExecutionBlockHash, +) -> Result { + let block = store + .get_blinded_block(&execution_payload_block_root) + .map_err(|e| { + PayloadBidError::InternalError(format!( + "failed to load execution payload block {execution_payload_block_root:?}: {e:?}" + )) + })? + .ok_or_else(|| { + PayloadBidError::InternalError(format!( + "execution payload block {execution_payload_block_root:?} unavailable" + )) + })?; + + if block.fork_name_unchecked().gloas_enabled() { + let bid = &block + .message() + .body() + .signed_execution_payload_bid()? + .message; + if bid.block_hash != parent_block_hash { + return Err(PayloadBidError::InternalError(format!( + "execution payload hash mismatch for block {execution_payload_block_root:?}" + ))); + } + Ok(bid.gas_limit) + } else { + let payload = block.message().execution_payload()?; + if payload.block_hash() != parent_block_hash { + return Err(PayloadBidError::InternalError(format!( + "execution payload hash mismatch for block {execution_payload_block_root:?}" + ))); + } + Ok(payload.gas_limit()) + } +} + /// Verify that an execution payload bid is consistent with the current chain state /// and proposer preferences. pub(crate) fn verify_bid_consistency( @@ -302,26 +372,26 @@ impl GossipVerifiedPayloadBid { }); } - // TODO(gloas): [IGNORE] bid.parent_block_hash is the block hash of a known execution - // payload in fork choice. - - // TODO(gloas): This uses head state's bid gas_limit as parent_gas_limit, which is only - // correct when the bid's parent is the head. If the parent is an ancestor further back - // this check may be inaccurate. Fixing this requires storing - // gas_limit in fork choice or looking it up from the store by parent_block_hash. Taking the above - // TODO into consideration maybe should persist parent block hash and gas limit in fork choice? - if let Ok(parent_bid) = head_state.latest_execution_payload_bid() - && !is_gas_limit_target_compatible( - parent_bid.gas_limit, - signed_bid.message.gas_limit, - proposer_preferences.message.target_gas_limit, - )? - { + let execution_payload_block_root = find_execution_payload_block_root( + &fork_choice, + signed_bid.message.parent_block_root, + signed_bid.message.parent_block_hash, + )?; + drop(fork_choice); + + let parent_gas_limit = get_parent_gas_limit::( + ctx.store, + execution_payload_block_root, + signed_bid.message.parent_block_hash, + )?; + if !is_gas_limit_target_compatible( + parent_gas_limit, + signed_bid.message.gas_limit, + proposer_preferences.message.target_gas_limit, + )? { return Err(PayloadBidError::InvalidGasLimit); } - drop(fork_choice); - verify_bid_consistency( &signed_bid.message, current_slot, diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs b/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs index afa9805fad1..120998db883 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs @@ -10,7 +10,7 @@ //! GossipVerifiedPayloadBid -------> Insert into GossipVerifiedPayloadBidCache //! ``` -use types::{BeaconStateError, Hash256, Slot}; +use types::{BeaconStateError, ExecutionBlockHash, Hash256, Slot}; pub mod gossip_verified_bid; pub mod payload_bid_cache; @@ -22,6 +22,10 @@ mod tests; pub enum PayloadBidError { /// The bid's parent block root is unknown. ParentBlockRootUnknown { parent_block_root: Hash256 }, + /// The bid's parent execution payload is unknown. + ParentExecutionPayloadUnknown { + parent_block_hash: ExecutionBlockHash, + }, /// The bid does not build on the head block or on the head block's parent. BidNotCompatibleWithHead { parent_block_root: Hash256 }, /// The signature is invalid. diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs index d76138bf76c..fe83c0e7b7c 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs @@ -28,7 +28,10 @@ use crate::{ chain_config::FastConfirmationMode, payload_bid_verification::{ PayloadBidError, - gossip_verified_bid::{GossipVerificationContext, GossipVerifiedPayloadBid}, + gossip_verified_bid::{ + GossipVerificationContext, GossipVerifiedPayloadBid, find_execution_payload_block_root, + get_parent_gas_limit, is_gas_limit_target_compatible, + }, payload_bid_cache::{BidParent, GossipVerifiedPayloadBidCache}, }, proposer_preferences_verification::{ @@ -148,6 +151,13 @@ impl TestContext { ForkChoice::from_anchor(fc_store, block_root, &signed_block, &state, None, &spec) .expect("should create fork choice"); + store + .put_block(&block_root, signed_block.clone()) + .expect("should store genesis block"); + fork_choice + .on_valid_payload_envelope_received(block_root) + .expect("should mark genesis payload as received"); + let (_, head_payload_status) = fork_choice .get_head(Slot::new(0), &spec) .expect("should run get_head"); @@ -509,6 +519,58 @@ fn gas_limit_mismatch() { assert!(matches!(result, Err(PayloadBidError::InvalidGasLimit))); } +#[test] +fn gas_limit_uses_known_execution_parent_after_unreceived_payload() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let rejected_payload_root = ctx.insert_non_canonical_block(); + let fork_choice = ctx.canonical_head.fork_choice_read_lock(); + + let actual_parent_block_root = find_execution_payload_block_root( + &fork_choice, + rejected_payload_root, + ExecutionBlockHash::zero(), + ) + .expect("should resolve the last received execution payload"); + drop(fork_choice); + let actual_parent_gas_limit = get_parent_gas_limit::( + &ctx.store, + actual_parent_block_root, + ExecutionBlockHash::zero(), + ) + .expect("should load the last received execution payload gas limit"); + + let rejected_payload_gas_limit = 30_029_295; + let next_gas_limit = 30_029_295; + let target_gas_limit = 60_000_000; + assert_eq!(actual_parent_gas_limit, 30_000_000); + assert!( + is_gas_limit_target_compatible(actual_parent_gas_limit, next_gas_limit, target_gas_limit,) + .expect("gas limit calculation should succeed") + ); + assert!( + !is_gas_limit_target_compatible( + rejected_payload_gas_limit, + next_gas_limit, + target_gas_limit, + ) + .expect("gas limit calculation should succeed") + ); + + let fork_choice = ctx.canonical_head.fork_choice_read_lock(); + let result = find_execution_payload_block_root( + &fork_choice, + rejected_payload_root, + ExecutionBlockHash::repeat_byte(0xab), + ); + assert!(matches!( + result, + Err(PayloadBidError::ParentExecutionPayloadUnknown { .. }) + )); +} + #[test] fn execution_payment_nonzero() { if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { 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 928e9caeff2..ebf76b7ccef 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -4238,6 +4238,7 @@ impl NetworkBeaconProcessor { | PayloadBidError::BuilderAlreadySeen { .. } | PayloadBidError::BidValueBelowCached { .. } | PayloadBidError::ParentBlockRootUnknown { .. } + | PayloadBidError::ParentExecutionPayloadUnknown { .. } | PayloadBidError::BidNotCompatibleWithHead { .. } | PayloadBidError::BuilderCantCoverBid { .. } | PayloadBidError::InvalidFeeRecipient From 32ac23802ae7e604ae65a066cf9c905ec6edc94f Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Mon, 24 Aug 2026 04:49:58 +0000 Subject: [PATCH 2/6] Accept Gloas bids whose execution parent predates finalization --- .../gossip_verified_bid.rs | 125 ++++++++++--- .../src/payload_bid_verification/tests.rs | 167 ++++++++++++------ 2 files changed, 208 insertions(+), 84 deletions(-) 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 index 9e27e4e5e13..adcf12466f0 100644 --- 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 @@ -22,35 +22,96 @@ use types::{ consts::gloas::PAYLOAD_BUILDER_VERSION, }; -/// Find the beacon block carrying the known execution payload referenced by a bid. -pub(crate) fn find_execution_payload_block_root( +enum ExecutionPayloadBlockRootLookup { + Found(Hash256), + ContinueInStore(Hash256), +} + +/// Find the beacon block carrying the known execution payload within fork choice. +fn find_execution_payload_block_root_in_fork_choice( fork_choice_read: &ForkChoiceReadGuard<'_, T>, parent_block_root: Hash256, parent_block_hash: ExecutionBlockHash, -) -> Result { - let mut block_root = Some(parent_block_root); - while let Some(root) = block_root { - let Some(block) = fork_choice_read.get_block(&root) else { +) -> ExecutionPayloadBlockRootLookup { + let mut block_root = parent_block_root; + loop { + let Some(block) = fork_choice_read.get_block(&block_root) else { break; }; if let Some(block_hash) = block.execution_payload_block_hash { - if fork_choice_read.is_payload_received(&root) && block_hash == parent_block_hash { - return Ok(root); + if fork_choice_read.is_payload_received(&block_root) && block_hash == parent_block_hash + { + return ExecutionPayloadBlockRootLookup::Found(block_root); } } else if !block.execution_status.is_invalid() && block.execution_status.block_hash() == Some(parent_block_hash) { - return Ok(root); + return ExecutionPayloadBlockRootLookup::Found(block_root); } - block_root = block.parent_root; + let Some(parent_root) = block.parent_root else { + break; + }; + block_root = parent_root; } + + ExecutionPayloadBlockRootLookup::ContinueInStore(block_root) +} + +/// Continue the parent gas limit lookup through canonical blocks hidden behind finalization. +fn find_parent_gas_limit_in_store( + store: &BeaconStore, + mut block_root: Hash256, + parent_block_hash: ExecutionBlockHash, +) -> Result { + loop { + let block = store + .get_blinded_block(&block_root) + .map_err(|e| { + PayloadBidError::InternalError(format!( + "failed to load beacon block {block_root:?} while finding execution payload: {e:?}" + )) + })? + .ok_or_else(|| { + PayloadBidError::InternalError(format!( + "beacon block {block_root:?} unavailable while finding execution payload" + )) + })?; + + if block.fork_name_unchecked().gloas_enabled() { + let bid = &block + .message() + .body() + .signed_execution_payload_bid()? + .message; + let payload_received = store.payload_envelope_exists(&block_root).map_err(|e| { + PayloadBidError::InternalError(format!( + "failed to check payload envelope for block {block_root:?}: {e:?}" + )) + })?; + if payload_received && bid.block_hash == parent_block_hash { + return Ok(bid.gas_limit); + } + } else { + let payload = block.message().execution_payload()?; + if payload.block_hash() == parent_block_hash { + return Ok(payload.gas_limit()); + } + } + + let parent_root = block.message().parent_root(); + if parent_root.is_zero() { + break; + } + block_root = parent_root; + } + Err(PayloadBidError::ParentExecutionPayloadUnknown { parent_block_hash }) } /// Return the gas limit committed by the block carrying an execution payload. -pub(crate) fn get_parent_gas_limit( +fn get_parent_gas_limit( store: &BeaconStore, execution_payload_block_root: Hash256, parent_block_hash: ExecutionBlockHash, @@ -372,26 +433,13 @@ impl GossipVerifiedPayloadBid { }); } - let execution_payload_block_root = find_execution_payload_block_root( + let execution_payload_block_root_lookup = find_execution_payload_block_root_in_fork_choice( &fork_choice, signed_bid.message.parent_block_root, signed_bid.message.parent_block_hash, - )?; + ); drop(fork_choice); - let parent_gas_limit = get_parent_gas_limit::( - ctx.store, - execution_payload_block_root, - signed_bid.message.parent_block_hash, - )?; - if !is_gas_limit_target_compatible( - parent_gas_limit, - signed_bid.message.gas_limit, - proposer_preferences.message.target_gas_limit, - )? { - return Err(PayloadBidError::InvalidGasLimit); - } - verify_bid_consistency( &signed_bid.message, current_slot, @@ -400,7 +448,7 @@ impl GossipVerifiedPayloadBid { ctx.spec, )?; - // Verify signature + // Verify the signature before falling back to a historical database scan. execution_payload_bid_signature_set( head_state, |i| get_builder_pubkey_from_state(head_state, i), @@ -413,6 +461,29 @@ impl GossipVerifiedPayloadBid { .then_some(()) .ok_or(PayloadBidError::BadSignature)?; + let parent_gas_limit = match execution_payload_block_root_lookup { + ExecutionPayloadBlockRootLookup::Found(block_root) => get_parent_gas_limit::( + ctx.store, + block_root, + signed_bid.message.parent_block_hash, + )?, + ExecutionPayloadBlockRootLookup::ContinueInStore(block_root) => { + find_parent_gas_limit_in_store::( + ctx.store, + block_root, + signed_bid.message.parent_block_hash, + )? + } + }; + + if !is_gas_limit_target_compatible( + parent_gas_limit, + signed_bid.message.gas_limit, + proposer_preferences.message.target_gas_limit, + )? { + return Err(PayloadBidError::InvalidGasLimit); + } + let gossip_verified_bid = GossipVerifiedPayloadBid { signed_bid }; ctx.gossip_verified_payload_bid_cache diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs index fe83c0e7b7c..101e4b23468 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs @@ -13,9 +13,9 @@ use state_processing::genesis::genesis_block; use store::{HotColdDB, StoreConfig}; use types::{ Address, ChainSpec, Checkpoint, Domain, Epoch, EthSpec, ExecutionBlockHash, - ExecutionPayloadBid, Hash256, MinimalEthSpec, ProposerPreferences, SignedBeaconBlock, - SignedExecutionPayloadBid, SignedProposerPreferences, SignedRoot, Slot, - consts::gloas::PAYLOAD_BUILDER_VERSION, + ExecutionPayloadBid, ExecutionPayloadEnvelope, Hash256, MinimalEthSpec, ProposerPreferences, + SignedBeaconBlock, SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope, + SignedProposerPreferences, SignedRoot, Slot, consts::gloas::PAYLOAD_BUILDER_VERSION, }; use proto_array::{Block as ProtoBlock, ExecutionStatus}; @@ -28,10 +28,7 @@ use crate::{ chain_config::FastConfirmationMode, payload_bid_verification::{ PayloadBidError, - gossip_verified_bid::{ - GossipVerificationContext, GossipVerifiedPayloadBid, find_execution_payload_block_root, - get_parent_gas_limit, is_gas_limit_target_compatible, - }, + gossip_verified_bid::{GossipVerificationContext, GossipVerifiedPayloadBid}, payload_bid_cache::{BidParent, GossipVerifiedPayloadBidCache}, }, proposer_preferences_verification::{ @@ -154,6 +151,15 @@ impl TestContext { store .put_block(&block_root, signed_block.clone()) .expect("should store genesis block"); + store + .put_payload_envelope( + &block_root, + &SignedExecutionPayloadEnvelope { + message: ExecutionPayloadEnvelope::empty(), + signature: Signature::empty(), + }, + ) + .expect("should store genesis payload envelope"); fork_choice .on_valid_payload_envelope_received(block_root) .expect("should mark genesis payload as received"); @@ -191,6 +197,74 @@ impl TestContext { } } + fn with_unreceived_finalized_head(mut self, gas_limit: u64) -> (Self, Hash256) { + let cached_head = self.canonical_head.cached_head(); + let mut state = cached_head.snapshot.beacon_state.clone(); + let head_slot = Epoch::new(1).start_slot(E::slots_per_epoch()); + let current_slot = head_slot + 1; + *state.slot_mut() = head_slot; + + let head_bid = state + .latest_execution_payload_bid_mut() + .expect("should have a Gloas payload bid"); + head_bid.slot = head_slot; + head_bid.parent_block_root = self.genesis_block_root; + head_bid.parent_block_hash = ExecutionBlockHash::zero(); + head_bid.block_hash = ExecutionBlockHash::repeat_byte(0xab); + head_bid.gas_limit = gas_limit; + + let mut head_block = genesis_block(&state, &self.spec).expect("should build head block"); + *head_block.slot_mut() = head_slot; + *head_block.parent_root_mut() = self.genesis_block_root; + state.latest_block_header_mut().slot = head_slot; + state.latest_block_header_mut().parent_root = self.genesis_block_root; + state.latest_block_header_mut().body_root = head_block.body_root(); + *head_block.state_root_mut() = state + .update_tree_hash_cache() + .expect("should hash head state"); + let signed_head_block = SignedBeaconBlock::from_block(head_block, Signature::empty()); + let head_block_root = signed_head_block.canonical_root(); + + self.store + .put_block(&head_block_root, signed_head_block.clone()) + .expect("should store head block"); + + let snapshot = BeaconSnapshot::new( + Arc::new(signed_head_block.clone()), + None, + head_block_root, + state.clone(), + ); + let fc_store = + BeaconForkChoiceStore::get_forkchoice_store(self.store.clone(), snapshot.clone()) + .expect("should create fork choice store"); + let mut fork_choice = ForkChoice::from_anchor( + fc_store, + head_block_root, + &signed_head_block, + &state, + None, + &self.spec, + ) + .expect("should create fork choice at the finalized head"); + let (_, head_payload_status) = fork_choice + .get_head(current_slot, &self.spec) + .expect("should run get_head"); + + self.canonical_head = CanonicalHead::new( + fork_choice, + Arc::new(snapshot), + head_payload_status, + FastConfirmationMode::Disabled, + &self.store, + &self.spec, + ) + .expect("should create canonical head"); + self.slot_clock.set_slot(current_slot.as_u64()); + + (self, head_block_root) + } + fn sign_bid(&self, bid: ExecutionPayloadBid) -> Arc> { let head = self.canonical_head.cached_head(); let state = &head.snapshot.beacon_state; @@ -259,8 +333,9 @@ impl TestContext { 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() + let mut fork_choice = self.canonical_head.fork_choice_write_lock(); + fork_choice + .proto_array_mut() .process_block::( ProtoBlock { slot: Slot::new(1), @@ -507,14 +582,16 @@ fn gas_limit_mismatch() { let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); - let bid = ctx.make_signed_bid( + let bid = ctx.sign_bid(ExecutionPayloadBid { slot, - 0, - Address::ZERO, - 50_000_000, - 100, - ctx.genesis_block_root, - ); + builder_index: 0, + fee_recipient: Address::ZERO, + gas_limit: 50_000_000, + value: 100, + parent_block_root: ctx.genesis_block_root, + prev_randao: ctx.expected_prev_randao(), + ..ExecutionPayloadBid::default() + }); let result = GossipVerifiedPayloadBid::new(bid, &gossip); assert!(matches!(result, Err(PayloadBidError::InvalidGasLimit))); } @@ -524,51 +601,27 @@ fn gas_limit_uses_known_execution_parent_after_unreceived_payload() { if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { return; } - let ctx = TestContext::new(); - let rejected_payload_root = ctx.insert_non_canonical_block(); - let fork_choice = ctx.canonical_head.fork_choice_read_lock(); - - let actual_parent_block_root = find_execution_payload_block_root( - &fork_choice, - rejected_payload_root, - ExecutionBlockHash::zero(), - ) - .expect("should resolve the last received execution payload"); - drop(fork_choice); - let actual_parent_gas_limit = get_parent_gas_limit::( - &ctx.store, - actual_parent_block_root, - ExecutionBlockHash::zero(), - ) - .expect("should load the last received execution payload gas limit"); - let rejected_payload_gas_limit = 30_029_295; let next_gas_limit = 30_029_295; let target_gas_limit = 60_000_000; - assert_eq!(actual_parent_gas_limit, 30_000_000); - assert!( - is_gas_limit_target_compatible(actual_parent_gas_limit, next_gas_limit, target_gas_limit,) - .expect("gas limit calculation should succeed") - ); - assert!( - !is_gas_limit_target_compatible( - rejected_payload_gas_limit, - next_gas_limit, - target_gas_limit, - ) - .expect("gas limit calculation should succeed") - ); + let (ctx, head_block_root) = + TestContext::new().with_unreceived_finalized_head(rejected_payload_gas_limit); + let slot = ctx.slot_clock.now().expect("should read slot clock"); + seed_preferences(&ctx, slot, Address::ZERO, target_gas_limit); - let fork_choice = ctx.canonical_head.fork_choice_read_lock(); - let result = find_execution_payload_block_root( - &fork_choice, - rejected_payload_root, - ExecutionBlockHash::repeat_byte(0xab), - ); - assert!(matches!( - result, - Err(PayloadBidError::ParentExecutionPayloadUnknown { .. }) - )); + let bid = ctx.sign_bid(ExecutionPayloadBid { + slot, + builder_index: 0, + fee_recipient: Address::ZERO, + gas_limit: next_gas_limit, + parent_block_root: head_block_root, + parent_block_hash: ExecutionBlockHash::zero(), + prev_randao: ctx.expected_prev_randao(), + ..ExecutionPayloadBid::default() + }); + + GossipVerifiedPayloadBid::new(bid, &ctx.gossip_ctx()) + .expect("bid should use the received execution payload before finalization"); } #[test] From f795fdbb1342dedf7b243eddb8f4c0fa1f710f6d Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Mon, 24 Aug 2026 05:48:33 +0000 Subject: [PATCH 3/6] Fix Gloas genesis parent gas limit lookup Use the EL genesis hash carried by the genesis bid when no payload envelope exists. Cache resolved parent gas limits per slot so invalid follow-up bids do not repeat historical ancestry scans. --- .../gossip_verified_bid.rs | 141 +++++++++++------- .../payload_bid_cache.rs | 49 ++++++ .../src/payload_bid_verification/tests.rs | 135 ++++++++++++----- 3 files changed, 232 insertions(+), 93 deletions(-) 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 index adcf12466f0..e0c15c5860d 100644 --- 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 @@ -18,7 +18,7 @@ use state_processing::signature_sets::{ use tracing::debug; use types::{ BeaconState, ChainSpec, EthSpec, ExecPayload, ExecutionBlockHash, ExecutionPayloadBid, Hash256, - SignedExecutionPayloadBid, SignedProposerPreferences, Slot, + SignedBlindedBeaconBlock, SignedExecutionPayloadBid, SignedProposerPreferences, Slot, consts::gloas::PAYLOAD_BUILDER_VERSION, }; @@ -34,10 +34,14 @@ fn find_execution_payload_block_root_in_fork_choice( parent_block_hash: ExecutionBlockHash, ) -> ExecutionPayloadBlockRootLookup { let mut block_root = parent_block_root; - loop { - let Some(block) = fork_choice_read.get_block(&block_root) else { - break; - }; + while let Some(block) = fork_choice_read.get_block(&block_root) { + // Gloas genesis has no payload envelope. Its bid retains the EL genesis hash in + // `parent_block_hash`, while `block_hash` is zero to represent an empty payload. + if block.slot == Slot::new(0) + && block.execution_payload_parent_hash == Some(parent_block_hash) + { + return ExecutionPayloadBlockRootLookup::Found(block_root); + } if let Some(block_hash) = block.execution_payload_block_hash { if fork_choice_read.is_payload_received(&block_root) && block_hash == parent_block_hash @@ -59,6 +63,28 @@ fn find_execution_payload_block_root_in_fork_choice( ExecutionPayloadBlockRootLookup::ContinueInStore(block_root) } +/// Return the latest available execution payload hash and gas limit represented by a beacon block. +fn execution_payload_hash_and_gas_limit( + block: &SignedBlindedBeaconBlock, +) -> Result<(ExecutionBlockHash, u64), PayloadBidError> { + if block.fork_name_unchecked().gloas_enabled() { + let bid = &block + .message() + .body() + .signed_execution_payload_bid()? + .message; + let block_hash = if block.slot() == Slot::new(0) { + bid.parent_block_hash + } else { + bid.block_hash + }; + Ok((block_hash, bid.gas_limit)) + } else { + let payload = block.message().execution_payload()?; + Ok((payload.block_hash(), payload.gas_limit())) + } +} + /// Continue the parent gas limit lookup through canonical blocks hidden behind finalization. fn find_parent_gas_limit_in_store( store: &BeaconStore, @@ -79,24 +105,21 @@ fn find_parent_gas_limit_in_store( )) })?; - if block.fork_name_unchecked().gloas_enabled() { - let bid = &block - .message() - .body() - .signed_execution_payload_bid()? - .message; - let payload_received = store.payload_envelope_exists(&block_root).map_err(|e| { - PayloadBidError::InternalError(format!( - "failed to check payload envelope for block {block_root:?}: {e:?}" - )) - })?; - if payload_received && bid.block_hash == parent_block_hash { - return Ok(bid.gas_limit); - } - } else { - let payload = block.message().execution_payload()?; - if payload.block_hash() == parent_block_hash { - return Ok(payload.gas_limit()); + let (block_hash, gas_limit) = execution_payload_hash_and_gas_limit(&block)?; + if block_hash == parent_block_hash { + // Non-genesis Gloas blocks only carry an execution payload after its envelope arrives. + // Compare the hash first so unrelated ancestors do not require another database read. + if block.fork_name_unchecked().gloas_enabled() && block.slot() != Slot::new(0) { + let payload_received = store.payload_envelope_exists(&block_root).map_err(|e| { + PayloadBidError::InternalError(format!( + "failed to check payload envelope for block {block_root:?}: {e:?}" + )) + })?; + if payload_received { + return Ok(gas_limit); + } + } else { + return Ok(gas_limit); } } @@ -129,27 +152,13 @@ fn get_parent_gas_limit( )) })?; - if block.fork_name_unchecked().gloas_enabled() { - let bid = &block - .message() - .body() - .signed_execution_payload_bid()? - .message; - if bid.block_hash != parent_block_hash { - return Err(PayloadBidError::InternalError(format!( - "execution payload hash mismatch for block {execution_payload_block_root:?}" - ))); - } - Ok(bid.gas_limit) - } else { - let payload = block.message().execution_payload()?; - if payload.block_hash() != parent_block_hash { - return Err(PayloadBidError::InternalError(format!( - "execution payload hash mismatch for block {execution_payload_block_root:?}" - ))); - } - Ok(payload.gas_limit()) + let (block_hash, gas_limit) = execution_payload_hash_and_gas_limit(&block)?; + if block_hash != parent_block_hash { + return Err(PayloadBidError::InternalError(format!( + "execution payload hash mismatch for block {execution_payload_block_root:?}" + ))); } + Ok(gas_limit) } /// Verify that an execution payload bid is consistent with the current chain state @@ -433,11 +442,6 @@ impl GossipVerifiedPayloadBid { }); } - let execution_payload_block_root_lookup = find_execution_payload_block_root_in_fork_choice( - &fork_choice, - signed_bid.message.parent_block_root, - signed_bid.message.parent_block_hash, - ); drop(fork_choice); verify_bid_consistency( @@ -461,19 +465,40 @@ impl GossipVerifiedPayloadBid { .then_some(()) .ok_or(PayloadBidError::BadSignature)?; - let parent_gas_limit = match execution_payload_block_root_lookup { - ExecutionPayloadBlockRootLookup::Found(block_root) => get_parent_gas_limit::( - ctx.store, - block_root, - signed_bid.message.parent_block_hash, - )?, - ExecutionPayloadBlockRootLookup::ContinueInStore(block_root) => { - find_parent_gas_limit_in_store::( + let parent_gas_limit = if let Some(gas_limit) = ctx + .gossip_verified_payload_bid_cache + .get_parent_gas_limit(bid_slot, bid_parent) + { + gas_limit + } else { + // Ancestor lookup can walk fork choice and the database, so only perform it for a bid + // whose builder and signature have already been verified. + let fork_choice = ctx.canonical_head.fork_choice_read_lock(); + let execution_payload_block_root_lookup = + find_execution_payload_block_root_in_fork_choice( + &fork_choice, + signed_bid.message.parent_block_root, + signed_bid.message.parent_block_hash, + ); + drop(fork_choice); + + let gas_limit = match execution_payload_block_root_lookup { + ExecutionPayloadBlockRootLookup::Found(block_root) => get_parent_gas_limit::( ctx.store, block_root, signed_bid.message.parent_block_hash, - )? - } + )?, + ExecutionPayloadBlockRootLookup::ContinueInStore(block_root) => { + find_parent_gas_limit_in_store::( + ctx.store, + block_root, + signed_bid.message.parent_block_hash, + )? + } + }; + ctx.gossip_verified_payload_bid_cache + .insert_parent_gas_limit(bid_slot, bid_parent, gas_limit); + gas_limit }; if !is_gas_limit_target_compatible( 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 index 060a5a965ee..c706dad0506 100644 --- 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 @@ -26,10 +26,12 @@ impl BidParent { } type HighestBidMap = BTreeMap>>; +type ParentGasLimitMap = BTreeMap>; pub struct GossipVerifiedPayloadBidCache { highest_bid: RwLock>, seen_builder_bids: RwLock>>, + parent_gas_limits: RwLock, } impl Default for GossipVerifiedPayloadBidCache { @@ -37,6 +39,7 @@ impl Default for GossipVerifiedPayloadBidCache { Self { highest_bid: RwLock::new(BTreeMap::new()), seen_builder_bids: RwLock::new(BTreeMap::new()), + parent_gas_limits: RwLock::new(BTreeMap::new()), } } } @@ -94,6 +97,28 @@ impl GossipVerifiedPayloadBidCache { )); } + /// Get the gas limit of the execution payload identified by `bid_parent`. + pub(crate) fn get_parent_gas_limit(&self, slot: Slot, bid_parent: BidParent) -> Option { + self.parent_gas_limits + .read() + .get(&slot) + .and_then(|gas_limits| gas_limits.get(&bid_parent).copied()) + } + + /// Cache the gas limit of the execution payload identified by `bid_parent`. + pub(crate) fn insert_parent_gas_limit( + &self, + slot: Slot, + bid_parent: BidParent, + gas_limit: u64, + ) { + self.parent_gas_limits + .write() + .entry(slot) + .or_default() + .insert(bid_parent, gas_limit); + } + /// Prune anything before `current_slot` pub fn prune(&self, current_slot: Slot) { self.highest_bid @@ -103,6 +128,10 @@ impl GossipVerifiedPayloadBidCache { self.seen_builder_bids .write() .retain(|&slot, _| slot >= current_slot); + + self.parent_gas_limits + .write() + .retain(|&slot, _| slot >= current_slot); } } @@ -221,6 +250,26 @@ mod tests { assert_eq!(highest_b.message.builder_index, 3); } + #[test] + fn parent_gas_limit_is_cached_and_pruned() { + let cache = GossipVerifiedPayloadBidCache::::default(); + let slot = Slot::new(1); + let bid_parent = BidParent { + parent_block_hash: ExecutionBlockHash::repeat_byte(0x01), + parent_block_root: Hash256::repeat_byte(0x02), + }; + + assert_eq!(cache.get_parent_gas_limit(slot, bid_parent), None); + cache.insert_parent_gas_limit(slot, bid_parent, 30_000_000); + assert_eq!( + cache.get_parent_gas_limit(slot, bid_parent), + Some(30_000_000) + ); + + cache.prune(Slot::new(2)); + assert_eq!(cache.get_parent_gas_limit(slot, bid_parent), None); + } + #[test] fn prune_removes_old_retains_current() { let cache = GossipVerifiedPayloadBidCache::::default(); diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs index 101e4b23468..5d1776017f6 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs @@ -13,9 +13,10 @@ use state_processing::genesis::genesis_block; use store::{HotColdDB, StoreConfig}; use types::{ Address, ChainSpec, Checkpoint, Domain, Epoch, EthSpec, ExecutionBlockHash, - ExecutionPayloadBid, ExecutionPayloadEnvelope, Hash256, MinimalEthSpec, ProposerPreferences, - SignedBeaconBlock, SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope, - SignedProposerPreferences, SignedRoot, Slot, consts::gloas::PAYLOAD_BUILDER_VERSION, + ExecutionPayloadBid, ExecutionPayloadEnvelope, ExecutionPayloadHeader, Hash256, MinimalEthSpec, + ProposerPreferences, SignedBeaconBlock, SignedExecutionPayloadBid, + SignedExecutionPayloadEnvelope, SignedProposerPreferences, SignedRoot, Slot, + consts::gloas::PAYLOAD_BUILDER_VERSION, }; use proto_array::{Block as ProtoBlock, ExecutionStatus}; @@ -78,9 +79,20 @@ impl TestContext { 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"); + let mut execution_payload_header = ExecutionPayloadHeader::Fulu(Default::default()); + let ExecutionPayloadHeader::Fulu(header) = &mut execution_payload_header else { + unreachable!("constructed a Fulu execution payload header") + }; + header.block_hash = ExecutionBlockHash::repeat_byte(0x42); + header.gas_limit = 30_000_000; + let mut state = interop_genesis_state::( + &keypairs, + 0, + Hash256::repeat_byte(0x42), + Some(execution_payload_header), + &spec, + ) + .expect("should build genesis state"); // Register builders in the builder registry. for keypair in keypairs.iter().take(NUM_BUILDERS) { @@ -104,17 +116,6 @@ impl TestContext { root: Hash256::ZERO, }; - // Set a non-zero gas_limit on latest_execution_payload_bid so the gas limit - // compatibility check doesn't reject all bids at genesis. - if let Ok(bid) = state.latest_execution_payload_bid_mut() { - bid.gas_limit = 30_000_000; - } - // Update body_root to reflect the modified bid (genesis block embeds it). - let genesis_body_root = genesis_block(&state, &spec) - .expect("should build genesis block") - .body_root(); - state.latest_block_header_mut().body_root = genesis_body_root; - let inactive_keypair = &keypairs[NUM_BUILDERS]; let inactive_creds = builder_withdrawal_credentials(&inactive_keypair.pk, &spec); let inactive_builder_index = state @@ -151,18 +152,6 @@ impl TestContext { store .put_block(&block_root, signed_block.clone()) .expect("should store genesis block"); - store - .put_payload_envelope( - &block_root, - &SignedExecutionPayloadEnvelope { - message: ExecutionPayloadEnvelope::empty(), - signature: Signature::empty(), - }, - ) - .expect("should store genesis payload envelope"); - fork_choice - .on_valid_payload_envelope_received(block_root) - .expect("should mark genesis payload as received"); let (_, head_payload_status) = fork_choice .get_head(Slot::new(0), &spec) @@ -200,24 +189,78 @@ impl TestContext { fn with_unreceived_finalized_head(mut self, gas_limit: u64) -> (Self, Hash256) { let cached_head = self.canonical_head.cached_head(); let mut state = cached_head.snapshot.beacon_state.clone(); + let execution_genesis_hash = *state + .latest_block_hash() + .expect("should have a Gloas execution block hash"); + let carrier_block_hash = ExecutionBlockHash::repeat_byte(0xaa); + let carrier_slot = Slot::new(1); + + *state.slot_mut() = carrier_slot; + let carrier_bid = state + .latest_execution_payload_bid_mut() + .expect("should have a Gloas payload bid"); + carrier_bid.slot = carrier_slot; + carrier_bid.parent_block_root = self.genesis_block_root; + carrier_bid.parent_block_hash = execution_genesis_hash; + carrier_bid.block_hash = carrier_block_hash; + carrier_bid.gas_limit = 30_000_000; + let carrier_builder_index = carrier_bid.builder_index; + + let mut carrier_block = + genesis_block(&state, &self.spec).expect("should build payload carrier block"); + *carrier_block.slot_mut() = carrier_slot; + *carrier_block.parent_root_mut() = self.genesis_block_root; + state.latest_block_header_mut().slot = carrier_slot; + state.latest_block_header_mut().parent_root = self.genesis_block_root; + state.latest_block_header_mut().body_root = carrier_block.body_root(); + *carrier_block.state_root_mut() = state + .update_tree_hash_cache() + .expect("should hash payload carrier state"); + let signed_carrier_block = SignedBeaconBlock::from_block(carrier_block, Signature::empty()); + let carrier_block_root = signed_carrier_block.canonical_root(); + + self.store + .put_block(&carrier_block_root, signed_carrier_block) + .expect("should store payload carrier block"); + let mut carrier_envelope = ExecutionPayloadEnvelope::empty(); + carrier_envelope.payload.parent_hash = execution_genesis_hash; + carrier_envelope.payload.block_hash = carrier_block_hash; + carrier_envelope.payload.gas_limit = 30_000_000; + carrier_envelope.payload.slot_number = carrier_slot; + carrier_envelope.builder_index = carrier_builder_index; + carrier_envelope.beacon_block_root = carrier_block_root; + carrier_envelope.parent_beacon_block_root = self.genesis_block_root; + self.store + .put_payload_envelope( + &carrier_block_root, + &SignedExecutionPayloadEnvelope { + message: carrier_envelope, + signature: Signature::empty(), + }, + ) + .expect("should store payload carrier envelope"); + let head_slot = Epoch::new(1).start_slot(E::slots_per_epoch()); let current_slot = head_slot + 1; *state.slot_mut() = head_slot; + *state + .latest_block_hash_mut() + .expect("should have a Gloas execution block hash") = carrier_block_hash; let head_bid = state .latest_execution_payload_bid_mut() .expect("should have a Gloas payload bid"); head_bid.slot = head_slot; - head_bid.parent_block_root = self.genesis_block_root; - head_bid.parent_block_hash = ExecutionBlockHash::zero(); + head_bid.parent_block_root = carrier_block_root; + head_bid.parent_block_hash = carrier_block_hash; head_bid.block_hash = ExecutionBlockHash::repeat_byte(0xab); head_bid.gas_limit = gas_limit; let mut head_block = genesis_block(&state, &self.spec).expect("should build head block"); *head_block.slot_mut() = head_slot; - *head_block.parent_root_mut() = self.genesis_block_root; + *head_block.parent_root_mut() = carrier_block_root; state.latest_block_header_mut().slot = head_slot; - state.latest_block_header_mut().parent_root = self.genesis_block_root; + state.latest_block_header_mut().parent_root = carrier_block_root; state.latest_block_header_mut().body_root = head_block.body_root(); *head_block.state_root_mut() = state .update_tree_hash_cache() @@ -303,6 +346,15 @@ impl TestContext { .expect("should read current epoch randao mix") } + fn execution_parent_hash(&self) -> ExecutionBlockHash { + let head = self.canonical_head.cached_head(); + *head + .snapshot + .beacon_state + .latest_block_hash() + .expect("should have a Gloas execution block hash") + } + fn make_signed_bid( &self, slot: Slot, @@ -320,6 +372,7 @@ impl TestContext { gas_limit, value, parent_block_root, + parent_block_hash: self.execution_parent_hash(), prev_randao: self.expected_prev_randao(), ..ExecutionPayloadBid::default() }, @@ -479,6 +532,7 @@ fn same_builder_new_parent_tuple_not_blocked() { gas_limit: 30_000_000, value: 0, parent_block_root: ctx.genesis_block_root, + parent_block_hash: ctx.execution_parent_hash(), prev_randao: ctx.expected_prev_randao(), ..ExecutionPayloadBid::default() }); @@ -589,11 +643,17 @@ fn gas_limit_mismatch() { gas_limit: 50_000_000, value: 100, parent_block_root: ctx.genesis_block_root, + parent_block_hash: ctx.execution_parent_hash(), prev_randao: ctx.expected_prev_randao(), ..ExecutionPayloadBid::default() }); + let bid_parent = BidParent::from_bid(&bid.message); let result = GossipVerifiedPayloadBid::new(bid, &gossip); assert!(matches!(result, Err(PayloadBidError::InvalidGasLimit))); + assert_eq!( + ctx.bid_cache.get_parent_gas_limit(slot, bid_parent), + Some(30_000_000) + ); } #[test] @@ -615,7 +675,7 @@ fn gas_limit_uses_known_execution_parent_after_unreceived_payload() { fee_recipient: Address::ZERO, gas_limit: next_gas_limit, parent_block_root: head_block_root, - parent_block_hash: ExecutionBlockHash::zero(), + parent_block_hash: ctx.execution_parent_hash(), prev_randao: ctx.expected_prev_randao(), ..ExecutionPayloadBid::default() }); @@ -640,6 +700,7 @@ fn execution_payment_nonzero() { gas_limit: 30_000_000, execution_payment: 42, parent_block_root: ctx.genesis_block_root, + parent_block_hash: ctx.execution_parent_hash(), prev_randao: ctx.expected_prev_randao(), ..ExecutionPayloadBid::default() }, @@ -831,6 +892,7 @@ fn invalid_blob_kzg_commitments() { gas_limit: 30_000_000, value: 0, parent_block_root: ctx.genesis_block_root, + parent_block_hash: ctx.execution_parent_hash(), prev_randao: ctx.expected_prev_randao(), blob_kzg_commitments: ProgressiveVariableList::new(commitments), ..ExecutionPayloadBid::default() @@ -893,6 +955,7 @@ fn valid_bid() { gas_limit: 30_000_000, value: 0, parent_block_root: ctx.genesis_block_root, + parent_block_hash: ctx.execution_parent_hash(), prev_randao: ctx.expected_prev_randao(), ..ExecutionPayloadBid::default() }); @@ -921,6 +984,7 @@ fn two_builders_coexist_in_cache() { gas_limit: 30_000_000, value: 0, parent_block_root: ctx.genesis_block_root, + parent_block_hash: ctx.execution_parent_hash(), prev_randao: ctx.expected_prev_randao(), ..ExecutionPayloadBid::default() }); @@ -939,6 +1003,7 @@ fn two_builders_coexist_in_cache() { gas_limit: 30_000_000, value: 1, parent_block_root: ctx.genesis_block_root, + parent_block_hash: ctx.execution_parent_hash(), prev_randao: ctx.expected_prev_randao(), ..ExecutionPayloadBid::default() }); @@ -951,7 +1016,7 @@ fn two_builders_coexist_in_cache() { // Both builders should be seen. let bid_parent = BidParent { - parent_block_hash: ExecutionBlockHash::zero(), + parent_block_hash: ctx.execution_parent_hash(), parent_block_root: ctx.genesis_block_root, }; assert!( From 66f5f78341698d7d31b642bf5f4967a4f16e6ab5 Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Mon, 24 Aug 2026 06:53:57 +0000 Subject: [PATCH 4/6] Fix Gloas parent gas limit caching --- .../gossip_verified_bid.rs | 71 ++++++------ .../payload_bid_cache.rs | 74 ++++++++----- .../src/payload_bid_verification/tests.rs | 102 +++++++++++++++--- 3 files changed, 170 insertions(+), 77 deletions(-) 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 index e0c15c5860d..dbb93f10b37 100644 --- 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 @@ -15,6 +15,7 @@ use slot_clock::SlotClock; use state_processing::signature_sets::{ execution_payload_bid_signature_set, get_builder_pubkey_from_state, }; +use store::iter::ParentRootBlockIterator; use tracing::debug; use types::{ BeaconState, ChainSpec, EthSpec, ExecPayload, ExecutionBlockHash, ExecutionPayloadBid, Hash256, @@ -22,13 +23,13 @@ use types::{ consts::gloas::PAYLOAD_BUILDER_VERSION, }; -enum ExecutionPayloadBlockRootLookup { +pub(super) enum ExecutionPayloadBlockRootLookup { Found(Hash256), ContinueInStore(Hash256), } /// Find the beacon block carrying the known execution payload within fork choice. -fn find_execution_payload_block_root_in_fork_choice( +pub(super) fn find_execution_payload_block_root_in_fork_choice( fork_choice_read: &ForkChoiceReadGuard<'_, T>, parent_block_root: Hash256, parent_block_hash: ExecutionBlockHash, @@ -44,6 +45,7 @@ fn find_execution_payload_block_root_in_fork_choice( } if let Some(block_hash) = block.execution_payload_block_hash { + // Payload receipt is only recorded after the Gloas envelope has been validated. if fork_choice_read.is_payload_received(&block_root) && block_hash == parent_block_hash { return ExecutionPayloadBlockRootLookup::Found(block_root); @@ -88,22 +90,15 @@ fn execution_payload_hash_and_gas_limit( /// Continue the parent gas limit lookup through canonical blocks hidden behind finalization. fn find_parent_gas_limit_in_store( store: &BeaconStore, - mut block_root: Hash256, + block_root: Hash256, parent_block_hash: ExecutionBlockHash, ) -> Result { - loop { - let block = store - .get_blinded_block(&block_root) - .map_err(|e| { - PayloadBidError::InternalError(format!( - "failed to load beacon block {block_root:?} while finding execution payload: {e:?}" - )) - })? - .ok_or_else(|| { - PayloadBidError::InternalError(format!( - "beacon block {block_root:?} unavailable while finding execution payload" - )) - })?; + for block_result in ParentRootBlockIterator::new(store.as_ref(), block_root) { + let (block_root, block) = block_result.map_err(|e| { + PayloadBidError::InternalError(format!( + "failed to load beacon block while finding execution payload: {e:?}" + )) + })?; let (block_hash, gas_limit) = execution_payload_hash_and_gas_limit(&block)?; if block_hash == parent_block_hash { @@ -122,12 +117,6 @@ fn find_parent_gas_limit_in_store( return Ok(gas_limit); } } - - let parent_root = block.message().parent_root(); - if parent_root.is_zero() { - break; - } - block_root = parent_root; } Err(PayloadBidError::ParentExecutionPayloadUnknown { parent_block_hash }) @@ -452,6 +441,25 @@ impl GossipVerifiedPayloadBid { ctx.spec, )?; + let check_parent_gas_limit = |parent_gas_limit| { + if is_gas_limit_target_compatible( + parent_gas_limit, + signed_bid.message.gas_limit, + proposer_preferences.message.target_gas_limit, + )? { + Ok(()) + } else { + Err(PayloadBidError::InvalidGasLimit) + } + }; + + let cached_parent_gas_limit = ctx + .gossip_verified_payload_bid_cache + .get_parent_gas_limit(bid_slot, signed_bid.message.parent_block_hash); + if let Some(parent_gas_limit) = cached_parent_gas_limit { + check_parent_gas_limit(parent_gas_limit)?; + } + // Verify the signature before falling back to a historical database scan. execution_payload_bid_signature_set( head_state, @@ -465,12 +473,7 @@ impl GossipVerifiedPayloadBid { .then_some(()) .ok_or(PayloadBidError::BadSignature)?; - let parent_gas_limit = if let Some(gas_limit) = ctx - .gossip_verified_payload_bid_cache - .get_parent_gas_limit(bid_slot, bid_parent) - { - gas_limit - } else { + if cached_parent_gas_limit.is_none() { // Ancestor lookup can walk fork choice and the database, so only perform it for a bid // whose builder and signature have already been verified. let fork_choice = ctx.canonical_head.fork_choice_read_lock(); @@ -497,16 +500,8 @@ impl GossipVerifiedPayloadBid { } }; ctx.gossip_verified_payload_bid_cache - .insert_parent_gas_limit(bid_slot, bid_parent, gas_limit); - gas_limit - }; - - if !is_gas_limit_target_compatible( - parent_gas_limit, - signed_bid.message.gas_limit, - proposer_preferences.message.target_gas_limit, - )? { - return Err(PayloadBidError::InvalidGasLimit); + .insert_parent_gas_limit(bid_slot, signed_bid.message.parent_block_hash, gas_limit); + check_parent_gas_limit(gas_limit)?; } let gossip_verified_bid = GossipVerifiedPayloadBid { signed_bid }; 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 index c706dad0506..0b069d6c6e5 100644 --- 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 @@ -26,7 +26,14 @@ impl BidParent { } type HighestBidMap = BTreeMap>>; -type ParentGasLimitMap = BTreeMap>; + +#[derive(Clone, Copy)] +struct CachedParentGasLimit { + gas_limit: u64, + last_used_slot: Slot, +} + +type ParentGasLimitMap = HashMap; pub struct GossipVerifiedPayloadBidCache { highest_bid: RwLock>, @@ -39,7 +46,7 @@ impl Default for GossipVerifiedPayloadBidCache { Self { highest_bid: RwLock::new(BTreeMap::new()), seen_builder_bids: RwLock::new(BTreeMap::new()), - parent_gas_limits: RwLock::new(BTreeMap::new()), + parent_gas_limits: RwLock::new(HashMap::new()), } } } @@ -97,26 +104,39 @@ impl GossipVerifiedPayloadBidCache { )); } - /// Get the gas limit of the execution payload identified by `bid_parent`. - pub(crate) fn get_parent_gas_limit(&self, slot: Slot, bid_parent: BidParent) -> Option { + /// Get the gas limit of an execution payload and record its latest use. + pub(crate) fn get_parent_gas_limit( + &self, + slot: Slot, + parent_block_hash: ExecutionBlockHash, + ) -> Option { self.parent_gas_limits - .read() - .get(&slot) - .and_then(|gas_limits| gas_limits.get(&bid_parent).copied()) + .write() + .get_mut(&parent_block_hash) + .map(|cached| { + cached.last_used_slot = cached.last_used_slot.max(slot); + cached.gas_limit + }) } - /// Cache the gas limit of the execution payload identified by `bid_parent`. + /// Cache the gas limit of the execution payload identified by `parent_block_hash`. pub(crate) fn insert_parent_gas_limit( &self, slot: Slot, - bid_parent: BidParent, + parent_block_hash: ExecutionBlockHash, gas_limit: u64, ) { self.parent_gas_limits .write() - .entry(slot) - .or_default() - .insert(bid_parent, gas_limit); + .entry(parent_block_hash) + .and_modify(|cached| { + cached.gas_limit = gas_limit; + cached.last_used_slot = cached.last_used_slot.max(slot); + }) + .or_insert(CachedParentGasLimit { + gas_limit, + last_used_slot: slot, + }); } /// Prune anything before `current_slot` @@ -131,7 +151,7 @@ impl GossipVerifiedPayloadBidCache { self.parent_gas_limits .write() - .retain(|&slot, _| slot >= current_slot); + .retain(|_, cached| cached.last_used_slot.saturating_add(1u64) >= current_slot); } } @@ -251,23 +271,29 @@ mod tests { } #[test] - fn parent_gas_limit_is_cached_and_pruned() { + fn parent_gas_limit_is_reused_across_slots_and_pruned_by_last_use() { let cache = GossipVerifiedPayloadBidCache::::default(); - let slot = Slot::new(1); - let bid_parent = BidParent { - parent_block_hash: ExecutionBlockHash::repeat_byte(0x01), - parent_block_root: Hash256::repeat_byte(0x02), - }; + let parent_block_hash = ExecutionBlockHash::repeat_byte(0x01); - assert_eq!(cache.get_parent_gas_limit(slot, bid_parent), None); - cache.insert_parent_gas_limit(slot, bid_parent, 30_000_000); assert_eq!( - cache.get_parent_gas_limit(slot, bid_parent), - Some(30_000_000) + cache.get_parent_gas_limit(Slot::new(1), parent_block_hash), + None ); + cache.insert_parent_gas_limit(Slot::new(1), parent_block_hash, 30_000_000); + // Slot pruning runs before bids arrive for the new slot, so an entry used in the + // preceding slot must survive long enough to be reused and refreshed. cache.prune(Slot::new(2)); - assert_eq!(cache.get_parent_gas_limit(slot, bid_parent), None); + assert_eq!( + cache.get_parent_gas_limit(Slot::new(2), parent_block_hash), + Some(30_000_000) + ); + + cache.prune(Slot::new(4)); + assert_eq!( + cache.get_parent_gas_limit(Slot::new(4), parent_block_hash), + None + ); } #[test] diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs index 5d1776017f6..5e5f0f1aa77 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs @@ -29,7 +29,10 @@ use crate::{ chain_config::FastConfirmationMode, payload_bid_verification::{ PayloadBidError, - gossip_verified_bid::{GossipVerificationContext, GossipVerifiedPayloadBid}, + gossip_verified_bid::{ + ExecutionPayloadBlockRootLookup, GossipVerificationContext, GossipVerifiedPayloadBid, + find_execution_payload_block_root_in_fork_choice, + }, payload_bid_cache::{BidParent, GossipVerifiedPayloadBidCache}, }, proposer_preferences_verification::{ @@ -381,20 +384,40 @@ impl TestContext { } fn insert_non_canonical_block(&self) -> Hash256 { + let fork_block_root = Hash256::repeat_byte(0xab); + self.insert_execution_block_in_fork_choice( + fork_block_root, + ExecutionStatus::irrelevant(), + Some(ExecutionBlockHash::zero()), + Some(ExecutionBlockHash::repeat_byte(0xab)), + false, + ); + fork_block_root + } + + fn insert_execution_block_in_fork_choice( + &self, + block_root: Hash256, + execution_status: ExecutionStatus, + execution_payload_parent_hash: Option, + execution_payload_block_hash: Option, + payload_received: bool, + ) { 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 spec = self.spec.clone(); + spec.gloas_fork_epoch = execution_payload_block_hash.map(|_| Epoch::new(0)); let mut fork_choice = self.canonical_head.fork_choice_write_lock(); fork_choice .proto_array_mut() .process_block::( ProtoBlock { slot: Slot::new(1), - root: fork_block_root, + root: block_root, parent_root: Some(self.genesis_block_root), - target_root: fork_block_root, + target_root: block_root, current_epoch_shuffling_id: shuffling_id.clone(), next_epoch_shuffling_id: shuffling_id, state_root: Hash256::ZERO, @@ -406,20 +429,25 @@ impl TestContext { epoch: Epoch::new(0), root: self.genesis_block_root, }, - execution_status: ExecutionStatus::irrelevant(), + execution_status, unrealized_justified_checkpoint: None, unrealized_finalized_checkpoint: None, - execution_payload_parent_hash: Some(ExecutionBlockHash::zero()), - execution_payload_block_hash: Some(ExecutionBlockHash::repeat_byte(0xab)), + execution_payload_parent_hash, + execution_payload_block_hash, proposer_index: Some(0), payload_received: false, }, Slot::new(1), - &self.spec, + &spec, Duration::from_secs(0), ) - .expect("should insert fork block"); - fork_block_root + .expect("should insert execution block"); + + if payload_received { + fork_choice + .on_valid_payload_envelope_received(block_root) + .expect("should mark payload received"); + } } } @@ -651,7 +679,8 @@ fn gas_limit_mismatch() { let result = GossipVerifiedPayloadBid::new(bid, &gossip); assert!(matches!(result, Err(PayloadBidError::InvalidGasLimit))); assert_eq!( - ctx.bid_cache.get_parent_gas_limit(slot, bid_parent), + ctx.bid_cache + .get_parent_gas_limit(slot, bid_parent.parent_block_hash), Some(30_000_000) ); } @@ -925,12 +954,9 @@ fn bad_signature() { 0, ctx.genesis_block_root, ); + let bid_parent = BidParent::from_bid(&bid.message); let result = GossipVerifiedPayloadBid::new(bid, &gossip); assert!(matches!(result, Err(PayloadBidError::BadSignature))); - let bid_parent = BidParent { - parent_block_hash: ExecutionBlockHash::zero(), - parent_block_root: ctx.genesis_block_root, - }; assert!( !ctx.bid_cache .seen_builder_bid_for_parent(&slot, bid_parent, 0) @@ -938,6 +964,52 @@ fn bad_signature() { assert!(ctx.bid_cache.get_highest_bid(slot, bid_parent).is_none()); } +#[test] +fn parent_execution_payload_found_in_received_gloas_block() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let block_root = Hash256::repeat_byte(0x81); + let block_hash = ExecutionBlockHash::repeat_byte(0x82); + ctx.insert_execution_block_in_fork_choice( + block_root, + ExecutionStatus::irrelevant(), + Some(ctx.execution_parent_hash()), + Some(block_hash), + true, + ); + + let fork_choice = ctx.canonical_head.fork_choice_read_lock(); + assert!(matches!( + find_execution_payload_block_root_in_fork_choice(&fork_choice, block_root, block_hash), + ExecutionPayloadBlockRootLookup::Found(root) if root == block_root + )); +} + +#[test] +fn parent_execution_payload_found_in_pre_gloas_block() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let block_root = Hash256::repeat_byte(0x91); + let block_hash = ExecutionBlockHash::repeat_byte(0x92); + ctx.insert_execution_block_in_fork_choice( + block_root, + ExecutionStatus::Valid(block_hash), + None, + None, + false, + ); + + let fork_choice = ctx.canonical_head.fork_choice_read_lock(); + assert!(matches!( + find_execution_payload_block_root_in_fork_choice(&fork_choice, block_root, block_hash), + ExecutionPayloadBlockRootLookup::Found(root) if root == block_root + )); +} + #[test] fn valid_bid() { if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { From 455c07ac850ef85adfe1ed771693dde2081a51be Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Tue, 25 Aug 2026 02:00:42 +0000 Subject: [PATCH 5/6] Clarify Gloas parent gas limit lookup --- .../gossip_verified_bid.rs | 108 ++++---- .../payload_bid_cache.rs | 37 +-- .../src/payload_bid_verification/tests.rs | 239 ++++++++++-------- 3 files changed, 215 insertions(+), 169 deletions(-) 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 index dbb93f10b37..c92f783ced6 100644 --- 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 @@ -23,37 +23,40 @@ use types::{ consts::gloas::PAYLOAD_BUILDER_VERSION, }; -pub(super) enum ExecutionPayloadBlockRootLookup { - Found(Hash256), - ContinueInStore(Hash256), +pub(super) enum ParentExecutionPayloadLocation { + /// The beacon block identifying the parent execution payload is available in fork choice. + BeaconBlock(Hash256), + /// Continue the canonical ancestry search in the database from this beacon block root. + SearchStoreFrom(Hash256), } -/// Find the beacon block carrying the known execution payload within fork choice. -pub(super) fn find_execution_payload_block_root_in_fork_choice( +/// Locate the beacon block that identifies the bid's parent execution payload in fork choice. +pub(super) fn locate_parent_execution_payload_in_fork_choice( fork_choice_read: &ForkChoiceReadGuard<'_, T>, - parent_block_root: Hash256, - parent_block_hash: ExecutionBlockHash, -) -> ExecutionPayloadBlockRootLookup { - let mut block_root = parent_block_root; + bid_parent_beacon_block_root: Hash256, + parent_execution_block_hash: ExecutionBlockHash, +) -> ParentExecutionPayloadLocation { + let mut block_root = bid_parent_beacon_block_root; while let Some(block) = fork_choice_read.get_block(&block_root) { // Gloas genesis has no payload envelope. Its bid retains the EL genesis hash in // `parent_block_hash`, while `block_hash` is zero to represent an empty payload. if block.slot == Slot::new(0) - && block.execution_payload_parent_hash == Some(parent_block_hash) + && block.execution_payload_parent_hash == Some(parent_execution_block_hash) { - return ExecutionPayloadBlockRootLookup::Found(block_root); + return ParentExecutionPayloadLocation::BeaconBlock(block_root); } if let Some(block_hash) = block.execution_payload_block_hash { // Payload receipt is only recorded after the Gloas envelope has been validated. - if fork_choice_read.is_payload_received(&block_root) && block_hash == parent_block_hash + if fork_choice_read.is_payload_received(&block_root) + && block_hash == parent_execution_block_hash { - return ExecutionPayloadBlockRootLookup::Found(block_root); + return ParentExecutionPayloadLocation::BeaconBlock(block_root); } } else if !block.execution_status.is_invalid() - && block.execution_status.block_hash() == Some(parent_block_hash) + && block.execution_status.block_hash() == Some(parent_execution_block_hash) { - return ExecutionPayloadBlockRootLookup::Found(block_root); + return ParentExecutionPayloadLocation::BeaconBlock(block_root); } let Some(parent_root) = block.parent_root else { @@ -62,10 +65,13 @@ pub(super) fn find_execution_payload_block_root_in_fork_choice( block: &SignedBlindedBeaconBlock, ) -> Result<(ExecutionBlockHash, u64), PayloadBidError> { @@ -87,13 +93,15 @@ fn execution_payload_hash_and_gas_limit( } } -/// Continue the parent gas limit lookup through canonical blocks hidden behind finalization. -fn find_parent_gas_limit_in_store( +/// Continue searching canonical ancestry in the database after fork choice reaches its finalized +/// boundary. +fn find_parent_execution_payload_gas_limit_in_store( store: &BeaconStore, - block_root: Hash256, - parent_block_hash: ExecutionBlockHash, + search_start_beacon_block_root: Hash256, + parent_execution_block_hash: ExecutionBlockHash, ) -> Result { - for block_result in ParentRootBlockIterator::new(store.as_ref(), block_root) { + for block_result in ParentRootBlockIterator::new(store.as_ref(), search_start_beacon_block_root) + { let (block_root, block) = block_result.map_err(|e| { PayloadBidError::InternalError(format!( "failed to load beacon block while finding execution payload: {e:?}" @@ -101,7 +109,7 @@ fn find_parent_gas_limit_in_store( })?; let (block_hash, gas_limit) = execution_payload_hash_and_gas_limit(&block)?; - if block_hash == parent_block_hash { + if block_hash == parent_execution_block_hash { // Non-genesis Gloas blocks only carry an execution payload after its envelope arrives. // Compare the hash first so unrelated ancestors do not require another database read. if block.fork_name_unchecked().gloas_enabled() && block.slot() != Slot::new(0) { @@ -119,32 +127,35 @@ fn find_parent_gas_limit_in_store( } } - Err(PayloadBidError::ParentExecutionPayloadUnknown { parent_block_hash }) + Err(PayloadBidError::ParentExecutionPayloadUnknown { + parent_block_hash: parent_execution_block_hash, + }) } -/// Return the gas limit committed by the block carrying an execution payload. -fn get_parent_gas_limit( +/// Load the gas limit for `parent_execution_block_hash` from the beacon block previously identified +/// as representing that execution payload. +fn get_parent_execution_payload_gas_limit_from_beacon_block( store: &BeaconStore, - execution_payload_block_root: Hash256, - parent_block_hash: ExecutionBlockHash, + parent_execution_payload_beacon_block_root: Hash256, + parent_execution_block_hash: ExecutionBlockHash, ) -> Result { let block = store - .get_blinded_block(&execution_payload_block_root) + .get_blinded_block(&parent_execution_payload_beacon_block_root) .map_err(|e| { PayloadBidError::InternalError(format!( - "failed to load execution payload block {execution_payload_block_root:?}: {e:?}" + "failed to load execution payload block {parent_execution_payload_beacon_block_root:?}: {e:?}" )) })? .ok_or_else(|| { PayloadBidError::InternalError(format!( - "execution payload block {execution_payload_block_root:?} unavailable" + "execution payload block {parent_execution_payload_beacon_block_root:?} unavailable" )) })?; let (block_hash, gas_limit) = execution_payload_hash_and_gas_limit(&block)?; - if block_hash != parent_block_hash { + if block_hash != parent_execution_block_hash { return Err(PayloadBidError::InternalError(format!( - "execution payload hash mismatch for block {execution_payload_block_root:?}" + "execution payload hash mismatch for block {parent_execution_payload_beacon_block_root:?}" ))); } Ok(gas_limit) @@ -477,24 +488,25 @@ impl GossipVerifiedPayloadBid { // Ancestor lookup can walk fork choice and the database, so only perform it for a bid // whose builder and signature have already been verified. let fork_choice = ctx.canonical_head.fork_choice_read_lock(); - let execution_payload_block_root_lookup = - find_execution_payload_block_root_in_fork_choice( - &fork_choice, - signed_bid.message.parent_block_root, - signed_bid.message.parent_block_hash, - ); + let location = locate_parent_execution_payload_in_fork_choice( + &fork_choice, + signed_bid.message.parent_block_root, + signed_bid.message.parent_block_hash, + ); drop(fork_choice); - let gas_limit = match execution_payload_block_root_lookup { - ExecutionPayloadBlockRootLookup::Found(block_root) => get_parent_gas_limit::( - ctx.store, - block_root, - signed_bid.message.parent_block_hash, - )?, - ExecutionPayloadBlockRootLookup::ContinueInStore(block_root) => { - find_parent_gas_limit_in_store::( + let gas_limit = match location { + ParentExecutionPayloadLocation::BeaconBlock(beacon_block_root) => { + get_parent_execution_payload_gas_limit_from_beacon_block::( + ctx.store, + beacon_block_root, + signed_bid.message.parent_block_hash, + )? + } + ParentExecutionPayloadLocation::SearchStoreFrom(beacon_block_root) => { + find_parent_execution_payload_gas_limit_in_store::( ctx.store, - block_root, + beacon_block_root, signed_bid.message.parent_block_hash, )? } 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 index 0b069d6c6e5..24b097ea2b1 100644 --- 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 @@ -30,15 +30,13 @@ type HighestBidMap = BTreeMap; - pub struct GossipVerifiedPayloadBidCache { highest_bid: RwLock>, seen_builder_bids: RwLock>>, - parent_gas_limits: RwLock, + parent_gas_limits: RwLock>, } impl Default for GossipVerifiedPayloadBidCache { @@ -104,38 +102,39 @@ impl GossipVerifiedPayloadBidCache { )); } - /// Get the gas limit of an execution payload and record its latest use. + /// Get the gas limit of a parent execution payload and record the latest bid slot that + /// referenced it. pub(crate) fn get_parent_gas_limit( &self, - slot: Slot, - parent_block_hash: ExecutionBlockHash, + bid_slot: Slot, + parent_execution_block_hash: ExecutionBlockHash, ) -> Option { self.parent_gas_limits .write() - .get_mut(&parent_block_hash) + .get_mut(&parent_execution_block_hash) .map(|cached| { - cached.last_used_slot = cached.last_used_slot.max(slot); + cached.last_referenced_bid_slot = cached.last_referenced_bid_slot.max(bid_slot); cached.gas_limit }) } - /// Cache the gas limit of the execution payload identified by `parent_block_hash`. + /// Cache the gas limit of the parent execution payload identified by its execution block hash. pub(crate) fn insert_parent_gas_limit( &self, - slot: Slot, - parent_block_hash: ExecutionBlockHash, + bid_slot: Slot, + parent_execution_block_hash: ExecutionBlockHash, gas_limit: u64, ) { self.parent_gas_limits .write() - .entry(parent_block_hash) + .entry(parent_execution_block_hash) .and_modify(|cached| { cached.gas_limit = gas_limit; - cached.last_used_slot = cached.last_used_slot.max(slot); + cached.last_referenced_bid_slot = cached.last_referenced_bid_slot.max(bid_slot); }) .or_insert(CachedParentGasLimit { gas_limit, - last_used_slot: slot, + last_referenced_bid_slot: bid_slot, }); } @@ -151,7 +150,11 @@ impl GossipVerifiedPayloadBidCache { self.parent_gas_limits .write() - .retain(|_, cached| cached.last_used_slot.saturating_add(1u64) >= current_slot); + // Pruning runs before bids arrive for `current_slot`. Keep parents referenced in the + // preceding slot so a continuing empty-payload chain can refresh the entry. + .retain(|_, cached| { + cached.last_referenced_bid_slot.saturating_add(1u64) >= current_slot + }); } } @@ -271,7 +274,7 @@ mod tests { } #[test] - fn parent_gas_limit_is_reused_across_slots_and_pruned_by_last_use() { + fn parent_gas_limit_is_reused_across_slots_and_pruned_by_latest_reference() { let cache = GossipVerifiedPayloadBidCache::::default(); let parent_block_hash = ExecutionBlockHash::repeat_byte(0x01); diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs index 5e5f0f1aa77..38f447573d7 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs @@ -12,11 +12,11 @@ use ssz_types::ProgressiveVariableList; use state_processing::genesis::genesis_block; use store::{HotColdDB, StoreConfig}; use types::{ - Address, ChainSpec, Checkpoint, Domain, Epoch, EthSpec, ExecutionBlockHash, - ExecutionPayloadBid, ExecutionPayloadEnvelope, ExecutionPayloadHeader, Hash256, MinimalEthSpec, - ProposerPreferences, SignedBeaconBlock, SignedExecutionPayloadBid, - SignedExecutionPayloadEnvelope, SignedProposerPreferences, SignedRoot, Slot, - consts::gloas::PAYLOAD_BUILDER_VERSION, + Address, BeaconState, ChainSpec, Checkpoint, Domain, Epoch, EthSpec, ExecutionBlockHash, + ExecutionPayloadBid, ExecutionPayloadEnvelope, ExecutionPayloadHeader, + ExecutionPayloadHeaderFulu, ForkName, Hash256, MinimalEthSpec, ProposerPreferences, + SignedBeaconBlock, SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope, + SignedProposerPreferences, SignedRoot, Slot, consts::gloas::PAYLOAD_BUILDER_VERSION, }; use proto_array::{Block as ProtoBlock, ExecutionStatus}; @@ -30,8 +30,8 @@ use crate::{ payload_bid_verification::{ PayloadBidError, gossip_verified_bid::{ - ExecutionPayloadBlockRootLookup, GossipVerificationContext, GossipVerifiedPayloadBid, - find_execution_payload_block_root_in_fork_choice, + GossipVerificationContext, GossipVerifiedPayloadBid, ParentExecutionPayloadLocation, + locate_parent_execution_payload_in_fork_choice, }, payload_bid_cache::{BidParent, GossipVerifiedPayloadBidCache}, }, @@ -82,12 +82,13 @@ impl TestContext { let keypairs = generate_deterministic_keypairs(NUM_VALIDATORS); - let mut execution_payload_header = ExecutionPayloadHeader::Fulu(Default::default()); - let ExecutionPayloadHeader::Fulu(header) = &mut execution_payload_header else { - unreachable!("constructed a Fulu execution payload header") - }; - header.block_hash = ExecutionBlockHash::repeat_byte(0x42); - header.gas_limit = 30_000_000; + // Gloas replaces the execution payload header with a bid, so seed genesis with the last + // pre-Gloas header and let the genesis upgrades convert it. + let execution_payload_header = ExecutionPayloadHeader::Fulu(ExecutionPayloadHeaderFulu { + block_hash: ExecutionBlockHash::repeat_byte(0x42), + gas_limit: 30_000_000, + ..Default::default() + }); let mut state = interop_genesis_state::( &keypairs, 0, @@ -189,96 +190,121 @@ impl TestContext { } } - fn with_unreceived_finalized_head(mut self, gas_limit: u64) -> (Self, Hash256) { + /// Create a finalized empty head whose latest received execution payload is only in the store. + /// + /// The head commits to a payload that was not received. Its parent execution payload was + /// received before the fork-choice anchor, forcing bid verification to continue the lookup in + /// the database. + fn with_finalized_empty_head_requiring_store_lookup( + mut self, + unreceived_head_bid_gas_limit: u64, + ) -> (Self, Hash256) { let cached_head = self.canonical_head.cached_head(); let mut state = cached_head.snapshot.beacon_state.clone(); - let execution_genesis_hash = *state - .latest_block_hash() - .expect("should have a Gloas execution block hash"); - let carrier_block_hash = ExecutionBlockHash::repeat_byte(0xaa); - let carrier_slot = Slot::new(1); - - *state.slot_mut() = carrier_slot; - let carrier_bid = state - .latest_execution_payload_bid_mut() - .expect("should have a Gloas payload bid"); - carrier_bid.slot = carrier_slot; - carrier_bid.parent_block_root = self.genesis_block_root; - carrier_bid.parent_block_hash = execution_genesis_hash; - carrier_bid.block_hash = carrier_block_hash; - carrier_bid.gas_limit = 30_000_000; - let carrier_builder_index = carrier_bid.builder_index; - - let mut carrier_block = - genesis_block(&state, &self.spec).expect("should build payload carrier block"); - *carrier_block.slot_mut() = carrier_slot; - *carrier_block.parent_root_mut() = self.genesis_block_root; - state.latest_block_header_mut().slot = carrier_slot; - state.latest_block_header_mut().parent_root = self.genesis_block_root; - state.latest_block_header_mut().body_root = carrier_block.body_root(); - *carrier_block.state_root_mut() = state - .update_tree_hash_cache() - .expect("should hash payload carrier state"); - let signed_carrier_block = SignedBeaconBlock::from_block(carrier_block, Signature::empty()); - let carrier_block_root = signed_carrier_block.canonical_root(); - - self.store - .put_block(&carrier_block_root, signed_carrier_block) - .expect("should store payload carrier block"); - let mut carrier_envelope = ExecutionPayloadEnvelope::empty(); - carrier_envelope.payload.parent_hash = execution_genesis_hash; - carrier_envelope.payload.block_hash = carrier_block_hash; - carrier_envelope.payload.gas_limit = 30_000_000; - carrier_envelope.payload.slot_number = carrier_slot; - carrier_envelope.builder_index = carrier_builder_index; - carrier_envelope.beacon_block_root = carrier_block_root; - carrier_envelope.parent_beacon_block_root = self.genesis_block_root; - self.store - .put_payload_envelope( - &carrier_block_root, - &SignedExecutionPayloadEnvelope { - message: carrier_envelope, - signature: Signature::empty(), - }, - ) - .expect("should store payload carrier envelope"); + let execution_genesis_hash = self.execution_parent_hash(); + let received_payload_block_hash = ExecutionBlockHash::repeat_byte(0xaa); + let received_payload_slot = Slot::new(1); + + let received_payload_bid = { + let bid = state + .latest_execution_payload_bid_mut() + .expect("should have a Gloas payload bid"); + bid.slot = received_payload_slot; + bid.parent_block_root = self.genesis_block_root; + bid.parent_block_hash = execution_genesis_hash; + bid.block_hash = received_payload_block_hash; + bid.gas_limit = 30_000_000; + bid.clone() + }; + let (_, received_payload_beacon_block_root) = + self.store_block_from_state(&mut state, received_payload_slot, self.genesis_block_root); + self.store_payload_envelope_for_bid( + received_payload_beacon_block_root, + &received_payload_bid, + ); let head_slot = Epoch::new(1).start_slot(E::slots_per_epoch()); let current_slot = head_slot + 1; - *state.slot_mut() = head_slot; *state .latest_block_hash_mut() - .expect("should have a Gloas execution block hash") = carrier_block_hash; + .expect("should have a Gloas execution block hash") = received_payload_block_hash; let head_bid = state .latest_execution_payload_bid_mut() .expect("should have a Gloas payload bid"); head_bid.slot = head_slot; - head_bid.parent_block_root = carrier_block_root; - head_bid.parent_block_hash = carrier_block_hash; + head_bid.parent_block_root = received_payload_beacon_block_root; + head_bid.parent_block_hash = received_payload_block_hash; head_bid.block_hash = ExecutionBlockHash::repeat_byte(0xab); - head_bid.gas_limit = gas_limit; - - let mut head_block = genesis_block(&state, &self.spec).expect("should build head block"); - *head_block.slot_mut() = head_slot; - *head_block.parent_root_mut() = carrier_block_root; - state.latest_block_header_mut().slot = head_slot; - state.latest_block_header_mut().parent_root = carrier_block_root; - state.latest_block_header_mut().body_root = head_block.body_root(); - *head_block.state_root_mut() = state + head_bid.gas_limit = unreceived_head_bid_gas_limit; + + let (signed_head_block, head_block_root) = + self.store_block_from_state(&mut state, head_slot, received_payload_beacon_block_root); + self.set_finalized_head(state, signed_head_block, head_block_root, current_slot); + + (self, head_block_root) + } + + fn store_block_from_state( + &self, + state: &mut BeaconState, + slot: Slot, + parent_root: Hash256, + ) -> (SignedBeaconBlock, Hash256) { + *state.slot_mut() = slot; + let mut block = genesis_block(state, &self.spec).expect("should build block"); + *block.slot_mut() = slot; + *block.parent_root_mut() = parent_root; + state.latest_block_header_mut().slot = slot; + state.latest_block_header_mut().parent_root = parent_root; + state.latest_block_header_mut().body_root = block.body_root(); + *block.state_root_mut() = state .update_tree_hash_cache() - .expect("should hash head state"); - let signed_head_block = SignedBeaconBlock::from_block(head_block, Signature::empty()); - let head_block_root = signed_head_block.canonical_root(); + .expect("should hash block state"); + let signed_block = SignedBeaconBlock::from_block(block, Signature::empty()); + let block_root = signed_block.canonical_root(); self.store - .put_block(&head_block_root, signed_head_block.clone()) - .expect("should store head block"); + .put_block(&block_root, signed_block.clone()) + .expect("should store block"); + (signed_block, block_root) + } + fn store_payload_envelope_for_bid( + &self, + beacon_block_root: Hash256, + bid: &ExecutionPayloadBid, + ) { + let mut envelope = ExecutionPayloadEnvelope::empty(); + envelope.payload.parent_hash = bid.parent_block_hash; + envelope.payload.block_hash = bid.block_hash; + envelope.payload.gas_limit = bid.gas_limit; + envelope.payload.slot_number = bid.slot; + envelope.builder_index = bid.builder_index; + envelope.beacon_block_root = beacon_block_root; + envelope.parent_beacon_block_root = bid.parent_block_root; + self.store + .put_payload_envelope( + &beacon_block_root, + &SignedExecutionPayloadEnvelope { + message: envelope, + signature: Signature::empty(), + }, + ) + .expect("should store payload envelope"); + } + + fn set_finalized_head( + &mut self, + state: BeaconState, + signed_block: SignedBeaconBlock, + block_root: Hash256, + current_slot: Slot, + ) { let snapshot = BeaconSnapshot::new( - Arc::new(signed_head_block.clone()), + Arc::new(signed_block.clone()), None, - head_block_root, + block_root, state.clone(), ); let fc_store = @@ -286,13 +312,13 @@ impl TestContext { .expect("should create fork choice store"); let mut fork_choice = ForkChoice::from_anchor( fc_store, - head_block_root, - &signed_head_block, + block_root, + &signed_block, &state, None, &self.spec, ) - .expect("should create fork choice at the finalized head"); + .expect("should create fork choice at finalized head"); let (_, head_payload_status) = fork_choice .get_head(current_slot, &self.spec) .expect("should run get_head"); @@ -307,8 +333,6 @@ impl TestContext { ) .expect("should create canonical head"); self.slot_clock.set_slot(current_slot.as_u64()); - - (self, head_block_root) } fn sign_bid(&self, bid: ExecutionPayloadBid) -> Arc> { @@ -385,7 +409,9 @@ impl TestContext { fn insert_non_canonical_block(&self) -> Hash256 { let fork_block_root = Hash256::repeat_byte(0xab); - self.insert_execution_block_in_fork_choice( + let current_fork = self.spec.fork_name_at_slot::(Slot::new(1)); + self.insert_fork_choice_block( + current_fork, fork_block_root, ExecutionStatus::irrelevant(), Some(ExecutionBlockHash::zero()), @@ -395,8 +421,11 @@ impl TestContext { fork_block_root } - fn insert_execution_block_in_fork_choice( + /// Insert a synthetic block under explicit fork rules, then apply payload receipt through the + /// same fork-choice transition used in production. + fn insert_fork_choice_block( &self, + fork_name: ForkName, block_root: Hash256, execution_status: ExecutionStatus, execution_payload_parent_hash: Option, @@ -407,8 +436,7 @@ impl TestContext { shuffling_epoch: Epoch::new(0), shuffling_decision_block: self.genesis_block_root, }; - let mut spec = self.spec.clone(); - spec.gloas_fork_epoch = execution_payload_block_hash.map(|_| Epoch::new(0)); + let spec = fork_name.make_genesis_spec(self.spec.clone()); let mut fork_choice = self.canonical_head.fork_choice_write_lock(); fork_choice .proto_array_mut() @@ -686,15 +714,15 @@ fn gas_limit_mismatch() { } #[test] -fn gas_limit_uses_known_execution_parent_after_unreceived_payload() { +fn gas_limit_uses_stored_parent_after_finalized_empty_head() { if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { return; } let rejected_payload_gas_limit = 30_029_295; let next_gas_limit = 30_029_295; let target_gas_limit = 60_000_000; - let (ctx, head_block_root) = - TestContext::new().with_unreceived_finalized_head(rejected_payload_gas_limit); + let (ctx, head_block_root) = TestContext::new() + .with_finalized_empty_head_requiring_store_lookup(rejected_payload_gas_limit); let slot = ctx.slot_clock.now().expect("should read slot clock"); seed_preferences(&ctx, slot, Address::ZERO, target_gas_limit); @@ -965,14 +993,16 @@ fn bad_signature() { } #[test] -fn parent_execution_payload_found_in_received_gloas_block() { +fn fork_choice_locator_recognizes_received_gloas_block() { if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { return; } let ctx = TestContext::new(); let block_root = Hash256::repeat_byte(0x81); let block_hash = ExecutionBlockHash::repeat_byte(0x82); - ctx.insert_execution_block_in_fork_choice( + let current_fork = ctx.spec.fork_name_at_slot::(Slot::new(1)); + ctx.insert_fork_choice_block( + current_fork, block_root, ExecutionStatus::irrelevant(), Some(ctx.execution_parent_hash()), @@ -982,20 +1012,21 @@ fn parent_execution_payload_found_in_received_gloas_block() { let fork_choice = ctx.canonical_head.fork_choice_read_lock(); assert!(matches!( - find_execution_payload_block_root_in_fork_choice(&fork_choice, block_root, block_hash), - ExecutionPayloadBlockRootLookup::Found(root) if root == block_root + locate_parent_execution_payload_in_fork_choice(&fork_choice, block_root, block_hash), + ParentExecutionPayloadLocation::BeaconBlock(root) if root == block_root )); } #[test] -fn parent_execution_payload_found_in_pre_gloas_block() { +fn fork_choice_locator_recognizes_pre_gloas_execution_status() { if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { return; } let ctx = TestContext::new(); let block_root = Hash256::repeat_byte(0x91); let block_hash = ExecutionBlockHash::repeat_byte(0x92); - ctx.insert_execution_block_in_fork_choice( + ctx.insert_fork_choice_block( + ForkName::Fulu, block_root, ExecutionStatus::Valid(block_hash), None, @@ -1005,8 +1036,8 @@ fn parent_execution_payload_found_in_pre_gloas_block() { let fork_choice = ctx.canonical_head.fork_choice_read_lock(); assert!(matches!( - find_execution_payload_block_root_in_fork_choice(&fork_choice, block_root, block_hash), - ExecutionPayloadBlockRootLookup::Found(root) if root == block_root + locate_parent_execution_payload_in_fork_choice(&fork_choice, block_root, block_hash), + ParentExecutionPayloadLocation::BeaconBlock(root) if root == block_root )); } From d0d560ea0feb73a03348047bd42d21c67c505e86 Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Wed, 26 Aug 2026 16:15:32 +0000 Subject: [PATCH 6/6] Use observed payload gas limits for Gloas bids --- beacon_node/beacon_chain/src/beacon_chain.rs | 12 + beacon_node/beacon_chain/src/builder.rs | 5 + .../beacon_chain/src/canonical_head.rs | 14 +- beacon_node/beacon_chain/src/lib.rs | 1 + .../src/observed_execution_payloads.rs | 246 ++++++++++ .../gossip_verified_bid.rs | 424 ++++-------------- .../payload_bid_cache.rs | 78 ---- .../src/payload_bid_verification/tests.rs | 318 +++---------- .../gossip_verified_envelope.rs | 38 +- .../payload_envelope_verification/import.rs | 5 + .../tests/envelope_verification.rs | 120 ++++- .../src/proto_array_fork_choice.rs | 8 + 12 files changed, 562 insertions(+), 707 deletions(-) create mode 100644 beacon_node/beacon_chain/src/observed_execution_payloads.rs diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index cca74d918c4..4f7b93fafa8 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -58,6 +58,7 @@ use crate::observed_attesters::{ }; use crate::observed_block_producers::ObservedBlockProducers; use crate::observed_data_sidecars::ObservedDataSidecars; +use crate::observed_execution_payloads::ObservedExecutionPayloads; use crate::observed_operations::{ObservationOutcome, ObservedOperations}; use crate::observed_slashable::ObservedSlashable; use crate::partial_data_column_assembler::PartialMergeResult; @@ -440,6 +441,8 @@ pub struct BeaconChain { pub observed_slashable: RwLock>, /// Maintains a record of execution proofs seen over the gossip network. pub observed_execution_proofs: RwLock, + /// Maintains the gas limit of execution payloads seen through gossip or trusted imports. + pub observed_execution_payloads: ObservedExecutionPayloads, /// Cache of pending execution payload envelopes for local block building. /// Envelopes are stored here during block production and eventually published. pub pending_payload_envelopes: RwLock>, @@ -4638,6 +4641,15 @@ impl BeaconChain { // This prevents inconsistency between the two at the expense of concurrency. drop(fork_choice); + // Keep pre-Gloas payloads available across a live transition to Gloas. + if !block.fork_name_unchecked().gloas_enabled() + && let Ok(payload) = block.body().execution_payload() + && payload.block_hash() != ExecutionBlockHash::zero() + { + self.observed_execution_payloads + .insert(payload.block_hash(), payload.gas_limit()); + } + // We're declaring the block "imported" at this point, since fork choice and the DB know // about it. let block_time_imported = self.slot_clock.now_duration().unwrap_or(Duration::MAX); diff --git a/beacon_node/beacon_chain/src/builder.rs b/beacon_node/beacon_chain/src/builder.rs index 6d3dd7b9447..403330991b2 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -1019,6 +1019,7 @@ where observed_column_sidecars: RwLock::new(ObservedDataSidecars::new(self.spec.clone())), observed_slashable: <_>::default(), observed_execution_proofs: <_>::default(), + observed_execution_payloads: <_>::default(), pending_payload_envelopes: <_>::default(), observed_voluntary_exits: <_>::default(), observed_proposer_slashings: <_>::default(), @@ -1084,6 +1085,10 @@ where observed_payload_envelopes: <_>::default(), }; + beacon_chain + .initialize_observed_execution_payloads() + .map_err(|e| format!("Unable to restore execution payload gas limits: {e:?}"))?; + let head = beacon_chain.head_snapshot(); // Only perform the check if it was configured. diff --git a/beacon_node/beacon_chain/src/canonical_head.rs b/beacon_node/beacon_chain/src/canonical_head.rs index 4b3ca1f33f5..c889d12c62d 100644 --- a/beacon_node/beacon_chain/src/canonical_head.rs +++ b/beacon_node/beacon_chain/src/canonical_head.rs @@ -35,6 +35,7 @@ //! stack. use crate::chain_config::FastConfirmationMode; +use crate::observed_execution_payloads::referenced_execution_payload_hashes; use crate::persisted_fork_choice::PersistedForkChoice; use crate::shuffling_cache::BlockShufflingIds; use crate::state_advance_timer::MAX_ADVANCE_DISTANCE; @@ -1680,8 +1681,17 @@ impl BeaconChain { .process_prune_blobs(data_availability_boundary); } - // Take a write-lock on the canonical head and signal for it to prune. - self.canonical_head.fork_choice_write_lock().prune()?; + let mut fork_choice = self.canonical_head.fork_choice_write_lock(); + let node_count_before_pruning = fork_choice.proto_array().len(); + fork_choice.prune()?; + if fork_choice.proto_array().len() < node_count_before_pruning { + // Keep fork choice locked through cache pruning so an import cannot insert a payload + // that is absent from the retained-hash snapshot. Other paths release fork choice + // before taking the payload-cache lock, so there is no reverse lock order. + let retained_payload_hashes = referenced_execution_payload_hashes::(&fork_choice); + self.observed_execution_payloads + .retain(&retained_payload_hashes); + } Ok(()) } diff --git a/beacon_node/beacon_chain/src/lib.rs b/beacon_node/beacon_chain/src/lib.rs index b4e71f07573..dadf9eb46bc 100644 --- a/beacon_node/beacon_chain/src/lib.rs +++ b/beacon_node/beacon_chain/src/lib.rs @@ -42,6 +42,7 @@ pub mod observed_aggregates; mod observed_attesters; pub mod observed_block_producers; pub mod observed_data_sidecars; +pub mod observed_execution_payloads; pub mod observed_operations; mod observed_slashable; pub mod partial_data_column_assembler; diff --git a/beacon_node/beacon_chain/src/observed_execution_payloads.rs b/beacon_node/beacon_chain/src/observed_execution_payloads.rs new file mode 100644 index 00000000000..5f81a1bc5cf --- /dev/null +++ b/beacon_node/beacon_chain/src/observed_execution_payloads.rs @@ -0,0 +1,246 @@ +use parking_lot::RwLock; +use proto_array::Block as ProtoBlock; +use std::collections::{HashMap, HashSet}; +use tracing::warn; +use types::{ExecPayload, ExecutionBlockHash, Hash256, Slot}; + +use crate::{BeaconChain, BeaconChainError, BeaconChainTypes, beacon_chain::BeaconForkChoice}; + +/// Gas limits from execution payloads observed through gossip or another trusted source. +#[derive(Default)] +pub struct ObservedExecutionPayloads { + gas_limits: RwLock>, +} + +impl ObservedExecutionPayloads { + pub fn get_gas_limit(&self, block_hash: ExecutionBlockHash) -> Option { + self.gas_limits.read().get(&block_hash).copied() + } + + pub(crate) fn insert(&self, block_hash: ExecutionBlockHash, gas_limit: u64) { + self.gas_limits + .write() + .entry(block_hash) + .or_insert(gas_limit); + } + + pub(crate) fn retain(&self, block_hashes: &HashSet) { + self.gas_limits + .write() + .retain(|block_hash, _| block_hashes.contains(block_hash)); + } +} + +enum StoredPayloadSource { + GloasGenesis { + block_root: Hash256, + expected_block_hash: ExecutionBlockHash, + }, + GloasEnvelope { + block_root: Hash256, + expected_block_hash: ExecutionBlockHash, + }, + PreGloasBlock { + block_root: Hash256, + expected_block_hash: ExecutionBlockHash, + }, +} + +fn stored_payload_source(block: &ProtoBlock) -> Option { + if let (Some(parent_block_hash), Some(block_hash)) = ( + block.execution_payload_parent_hash, + block.execution_payload_block_hash, + ) { + if block.slot == Slot::new(0) { + return Some(StoredPayloadSource::GloasGenesis { + block_root: block.root, + expected_block_hash: parent_block_hash, + }); + } + + if block.payload_received { + return Some(StoredPayloadSource::GloasEnvelope { + block_root: block.root, + expected_block_hash: block_hash, + }); + } + + return None; + } + + if block.execution_status.is_invalid() { + return None; + } + block + .execution_status + .block_hash() + .map(|expected_block_hash| StoredPayloadSource::PreGloasBlock { + block_root: block.root, + expected_block_hash, + }) +} + +pub(crate) fn referenced_execution_payload_hashes( + fork_choice: &BeaconForkChoice, +) -> HashSet { + fork_choice + .proto_array() + .blocks() + .flat_map(|block| { + [ + block.execution_payload_parent_hash, + block.execution_payload_block_hash, + block.execution_status.block_hash(), + ] + .into_iter() + .flatten() + }) + .collect() +} + +impl BeaconChain { + /// Restore gas limits available directly from payloads retained with fork choice. + pub(crate) fn initialize_observed_execution_payloads(&self) -> Result<(), BeaconChainError> { + let sources = { + let fork_choice = self.canonical_head.fork_choice_read_lock(); + fork_choice + .proto_array() + .blocks() + .filter_map(|block| stored_payload_source(&block)) + .collect::>() + }; + + for source in sources { + match source { + StoredPayloadSource::GloasGenesis { + block_root, + expected_block_hash, + } => { + let Some(block) = self + .store + .get_blinded_block(&block_root) + .map_err(BeaconChainError::DBError)? + else { + warn!( + ?block_root, + "Unable to restore execution payload gas limit: block missing" + ); + continue; + }; + let bid = &block + .message() + .body() + .signed_execution_payload_bid() + .map_err(BeaconChainError::BeaconStateError)? + .message; + if bid.parent_block_hash == expected_block_hash { + self.observed_execution_payloads + .insert(bid.parent_block_hash, bid.gas_limit); + } else { + warn!( + ?block_root, + %expected_block_hash, + actual_block_hash = %bid.parent_block_hash, + "Unable to restore execution payload gas limit: block hash mismatch" + ); + } + } + StoredPayloadSource::GloasEnvelope { + block_root, + expected_block_hash, + } => { + let Some(envelope) = self + .store + .get_payload_envelope(&block_root) + .map_err(BeaconChainError::DBError)? + else { + warn!( + ?block_root, + "Unable to restore execution payload gas limit: envelope missing" + ); + continue; + }; + let payload = &envelope.message.payload; + if payload.block_hash == expected_block_hash { + self.observed_execution_payloads + .insert(payload.block_hash, payload.gas_limit); + } else { + warn!( + ?block_root, + %expected_block_hash, + actual_block_hash = %payload.block_hash, + "Unable to restore execution payload gas limit: envelope hash mismatch" + ); + } + } + StoredPayloadSource::PreGloasBlock { + block_root, + expected_block_hash, + } => { + let Some(block) = self + .store + .get_blinded_block(&block_root) + .map_err(BeaconChainError::DBError)? + else { + warn!( + ?block_root, + "Unable to restore execution payload gas limit: block missing" + ); + continue; + }; + let payload = block + .message() + .execution_payload() + .map_err(BeaconChainError::BeaconStateError)?; + if payload.block_hash() == expected_block_hash { + self.observed_execution_payloads + .insert(payload.block_hash(), payload.gas_limit()); + } else { + warn!( + ?block_root, + %expected_block_hash, + actual_block_hash = %payload.block_hash(), + "Unable to restore execution payload gas limit: block hash mismatch" + ); + } + } + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use types::ExecutionBlockHash; + + use super::ObservedExecutionPayloads; + + #[test] + fn retains_only_referenced_payloads() { + let payloads = ObservedExecutionPayloads::default(); + let retained = ExecutionBlockHash::repeat_byte(0x01); + let pruned = ExecutionBlockHash::repeat_byte(0x02); + + payloads.insert(retained, 30_000_000); + payloads.insert(pruned, 36_000_000); + payloads.retain(&HashSet::from([retained])); + + assert_eq!(payloads.get_gas_limit(retained), Some(30_000_000)); + assert_eq!(payloads.get_gas_limit(pruned), None); + } + + #[test] + fn keeps_first_gas_limit_for_execution_block_hash() { + let payloads = ObservedExecutionPayloads::default(); + let block_hash = ExecutionBlockHash::repeat_byte(0x01); + + payloads.insert(block_hash, 30_000_000); + payloads.insert(block_hash, 36_000_000); + + assert_eq!(payloads.get_gas_limit(block_hash), Some(30_000_000)); + } +} 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 index c92f783ced6..d5478d83704 100644 --- 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 @@ -3,6 +3,7 @@ use std::sync::Arc; use crate::{ BeaconChain, BeaconChainTypes, BeaconStore, CachedHead, CanonicalHead, canonical_head::ForkChoiceReadGuard, + observed_execution_payloads::ObservedExecutionPayloads, payload_bid_verification::{ PayloadBidError, payload_bid_cache::{BidParent, GossipVerifiedPayloadBidCache}, @@ -15,167 +16,26 @@ use slot_clock::SlotClock; use state_processing::signature_sets::{ execution_payload_bid_signature_set, get_builder_pubkey_from_state, }; -use store::iter::ParentRootBlockIterator; use tracing::debug; use types::{ - BeaconState, ChainSpec, EthSpec, ExecPayload, ExecutionBlockHash, ExecutionPayloadBid, Hash256, - SignedBlindedBeaconBlock, SignedExecutionPayloadBid, SignedProposerPreferences, Slot, + BeaconState, ChainSpec, EthSpec, ExecutionPayloadBid, SignedExecutionPayloadBid, Slot, consts::gloas::PAYLOAD_BUILDER_VERSION, }; -pub(super) enum ParentExecutionPayloadLocation { - /// The beacon block identifying the parent execution payload is available in fork choice. - BeaconBlock(Hash256), - /// Continue the canonical ancestry search in the database from this beacon block root. - SearchStoreFrom(Hash256), -} - -/// Locate the beacon block that identifies the bid's parent execution payload in fork choice. -pub(super) fn locate_parent_execution_payload_in_fork_choice( - fork_choice_read: &ForkChoiceReadGuard<'_, T>, - bid_parent_beacon_block_root: Hash256, - parent_execution_block_hash: ExecutionBlockHash, -) -> ParentExecutionPayloadLocation { - let mut block_root = bid_parent_beacon_block_root; - while let Some(block) = fork_choice_read.get_block(&block_root) { - // Gloas genesis has no payload envelope. Its bid retains the EL genesis hash in - // `parent_block_hash`, while `block_hash` is zero to represent an empty payload. - if block.slot == Slot::new(0) - && block.execution_payload_parent_hash == Some(parent_execution_block_hash) - { - return ParentExecutionPayloadLocation::BeaconBlock(block_root); - } - - if let Some(block_hash) = block.execution_payload_block_hash { - // Payload receipt is only recorded after the Gloas envelope has been validated. - if fork_choice_read.is_payload_received(&block_root) - && block_hash == parent_execution_block_hash - { - return ParentExecutionPayloadLocation::BeaconBlock(block_root); - } - } else if !block.execution_status.is_invalid() - && block.execution_status.block_hash() == Some(parent_execution_block_hash) - { - return ParentExecutionPayloadLocation::BeaconBlock(block_root); - } - - let Some(parent_root) = block.parent_root else { - break; - }; - block_root = parent_root; - } - - ParentExecutionPayloadLocation::SearchStoreFrom(block_root) -} - -/// Return the execution block hash and gas limit represented by a beacon block. -/// -/// Gloas genesis represents the EL genesis block with `parent_block_hash` because it has no payload -/// envelope of its own. -fn execution_payload_hash_and_gas_limit( - block: &SignedBlindedBeaconBlock, -) -> Result<(ExecutionBlockHash, u64), PayloadBidError> { - if block.fork_name_unchecked().gloas_enabled() { - let bid = &block - .message() - .body() - .signed_execution_payload_bid()? - .message; - let block_hash = if block.slot() == Slot::new(0) { - bid.parent_block_hash - } else { - bid.block_hash - }; - Ok((block_hash, bid.gas_limit)) +pub(crate) fn verify_bid_slot(bid_slot: Slot, current_slot: Slot) -> Result<(), PayloadBidError> { + if bid_slot == current_slot || bid_slot == current_slot.saturating_add(1u64) { + Ok(()) } else { - let payload = block.message().execution_payload()?; - Ok((payload.block_hash(), payload.gas_limit())) - } -} - -/// Continue searching canonical ancestry in the database after fork choice reaches its finalized -/// boundary. -fn find_parent_execution_payload_gas_limit_in_store( - store: &BeaconStore, - search_start_beacon_block_root: Hash256, - parent_execution_block_hash: ExecutionBlockHash, -) -> Result { - for block_result in ParentRootBlockIterator::new(store.as_ref(), search_start_beacon_block_root) - { - let (block_root, block) = block_result.map_err(|e| { - PayloadBidError::InternalError(format!( - "failed to load beacon block while finding execution payload: {e:?}" - )) - })?; - - let (block_hash, gas_limit) = execution_payload_hash_and_gas_limit(&block)?; - if block_hash == parent_execution_block_hash { - // Non-genesis Gloas blocks only carry an execution payload after its envelope arrives. - // Compare the hash first so unrelated ancestors do not require another database read. - if block.fork_name_unchecked().gloas_enabled() && block.slot() != Slot::new(0) { - let payload_received = store.payload_envelope_exists(&block_root).map_err(|e| { - PayloadBidError::InternalError(format!( - "failed to check payload envelope for block {block_root:?}: {e:?}" - )) - })?; - if payload_received { - return Ok(gas_limit); - } - } else { - return Ok(gas_limit); - } - } - } - - Err(PayloadBidError::ParentExecutionPayloadUnknown { - parent_block_hash: parent_execution_block_hash, - }) -} - -/// Load the gas limit for `parent_execution_block_hash` from the beacon block previously identified -/// as representing that execution payload. -fn get_parent_execution_payload_gas_limit_from_beacon_block( - store: &BeaconStore, - parent_execution_payload_beacon_block_root: Hash256, - parent_execution_block_hash: ExecutionBlockHash, -) -> Result { - let block = store - .get_blinded_block(&parent_execution_payload_beacon_block_root) - .map_err(|e| { - PayloadBidError::InternalError(format!( - "failed to load execution payload block {parent_execution_payload_beacon_block_root:?}: {e:?}" - )) - })? - .ok_or_else(|| { - PayloadBidError::InternalError(format!( - "execution payload block {parent_execution_payload_beacon_block_root:?} unavailable" - )) - })?; - - let (block_hash, gas_limit) = execution_payload_hash_and_gas_limit(&block)?; - if block_hash != parent_execution_block_hash { - return Err(PayloadBidError::InternalError(format!( - "execution payload hash mismatch for block {parent_execution_payload_beacon_block_root:?}" - ))); + Err(PayloadBidError::InvalidBidSlot { bid_slot }) } - Ok(gas_limit) } -/// Verify that an execution payload bid is consistent with the current chain state -/// and proposer preferences. -pub(crate) fn verify_bid_consistency( +fn verify_bid_payment_and_blobs( 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 { @@ -184,10 +44,6 @@ pub(crate) fn verify_bid_consistency( }); } - if bid.fee_recipient != proposer_preferences.message.fee_recipient { - return Err(PayloadBidError::InvalidFeeRecipient); - } - let max_blobs_per_block = spec.max_blobs_per_block(bid_slot.epoch(E::slots_per_epoch())) as usize; @@ -198,17 +54,34 @@ pub(crate) fn verify_bid_consistency( }); } + Ok(()) +} + +fn verify_builder( + bid: &ExecutionPayloadBid, + head_state: &BeaconState, + spec: &ChainSpec, +) -> Result<(), PayloadBidError> { let builder_index = bid.builder_index; + let builder_version = head_state + .get_builder(builder_index) + .map_err(|_| PayloadBidError::InvalidBuilder { builder_index })? + .version; + + if !head_state.can_builder_cover_bid(builder_index, bid.value, spec)? { + return Err(PayloadBidError::BuilderCantCoverBid { + builder_index, + builder_bid: bid.value, + }); + } 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 }); } - let builder_version = head_state.get_builder(builder_index)?.version; if builder_version != PAYLOAD_BUILDER_VERSION { return Err(PayloadBidError::InvalidBuilderVersion { builder_index, @@ -216,13 +89,6 @@ pub(crate) fn verify_bid_consistency( }); } - if !head_state.can_builder_cover_bid(builder_index, bid.value, spec)? { - return Err(PayloadBidError::BuilderCantCoverBid { - builder_index, - builder_bid: bid.value, - }); - } - Ok(()) } @@ -299,6 +165,7 @@ pub(crate) fn is_bid_compatible_with_head( pub struct GossipVerificationContext<'a, T: BeaconChainTypes> { pub canonical_head: &'a CanonicalHead, + pub observed_execution_payloads: &'a ObservedExecutionPayloads, pub gossip_verified_payload_bid_cache: &'a GossipVerifiedPayloadBidCache, pub gossip_verified_proposer_preferences_cache: &'a GossipVerifiedProposerPreferenceCache, pub slot_clock: &'a T::SlotClock, @@ -326,6 +193,11 @@ impl GossipVerifiedPayloadBid { let bid_parent = BidParent::from_bid(&signed_bid.message); let bid_parent_block_root = signed_bid.message.parent_block_root; let bid_value = signed_bid.message.value; + let current_slot = ctx + .slot_clock + .now() + .ok_or(PayloadBidError::UnableToReadSlot)?; + verify_bid_slot(bid_slot, current_slot)?; if ctx .gossip_verified_payload_bid_cache @@ -338,7 +210,7 @@ impl GossipVerifiedPayloadBid { } // TODO(gloas): Extract into `bid_value_over_threshold` on the bid cache and potentially - // make this more sophisticate than just a <= check. + // make this more sophisticated than just a <= check. if let Some(cached_bid) = ctx .gossip_verified_payload_bid_cache .get_highest_bid(bid_slot, bid_parent) @@ -351,10 +223,27 @@ impl GossipVerifiedPayloadBid { } let cached_head = ctx.canonical_head.cached_head(); - let current_slot = ctx - .slot_clock - .now() - .ok_or(PayloadBidError::UnableToReadSlot)?; + + // Check the descendant rule before the bid fields that follow it in the gossip spec. Delay + // reporting an unknown parent until after those fields have been checked. + let fork_choice = ctx.canonical_head.fork_choice_read_lock(); + let parent_block = fork_choice.get_block(&bid_parent_block_root); + if let Some(parent_block) = parent_block.as_ref() + && bid_slot <= parent_block.slot + { + return Err(PayloadBidError::BidNotDescendantOfParent { + bid_slot, + parent_slot: parent_block.slot, + }); + } + + verify_bid_payment_and_blobs(&signed_bid.message, ctx.spec)?; + + parent_block.ok_or(PayloadBidError::ParentBlockRootUnknown { + parent_block_root: bid_parent_block_root, + })?; + drop(fork_choice); + let snapshot_state = &cached_head.snapshot.beacon_state; // At the Gloas fork boundary the head snapshot is still a pre-Gloas state, so we must @@ -409,31 +298,26 @@ impl GossipVerifiedPayloadBid { 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? - let parent_block = fork_choice.get_block(&bid_parent_block_root).ok_or( - PayloadBidError::ParentBlockRootUnknown { - parent_block_root: bid_parent_block_root, - }, - )?; - - // [REJECT] The bid is for a higher slot than its parent block. - if bid_slot <= parent_block.slot { - return Err(PayloadBidError::BidNotDescendantOfParent { - bid_slot, - parent_slot: parent_block.slot, - }); + if signed_bid.message.fee_recipient != proposer_preferences.message.fee_recipient { + return Err(PayloadBidError::InvalidFeeRecipient); } - // [REJECT] `bid.prev_randao` is the correct RANDAO mix -- i.e. validate that - // `bid.prev_randao == get_randao_mix(parent_state, get_current_epoch(parent_state))` - if signed_bid.message.prev_randao - != *head_state.get_randao_mix(current_slot.epoch(E::slots_per_epoch()))? - { - return Err(PayloadBidError::InvalidPrevRandao { slot: bid_slot }); + let parent_gas_limit = ctx + .observed_execution_payloads + .get_gas_limit(signed_bid.message.parent_block_hash) + .ok_or(PayloadBidError::ParentExecutionPayloadUnknown { + parent_block_hash: signed_bid.message.parent_block_hash, + })?; + if !is_gas_limit_target_compatible( + parent_gas_limit, + signed_bid.message.gas_limit, + proposer_preferences.message.target_gas_limit, + )? { + return Err(PayloadBidError::InvalidGasLimit); } + let fork_choice = ctx.canonical_head.fork_choice_read_lock(); + // TODO(gloas) should we reprocess a dropped bid when the head changes to its parent? if !is_bid_compatible_with_head(&cached_head, &fork_choice, &signed_bid.message, ctx.spec)? { @@ -444,34 +328,16 @@ impl GossipVerifiedPayloadBid { drop(fork_choice); - verify_bid_consistency( - &signed_bid.message, - current_slot, - &proposer_preferences, - head_state, - ctx.spec, - )?; - - let check_parent_gas_limit = |parent_gas_limit| { - if is_gas_limit_target_compatible( - parent_gas_limit, - signed_bid.message.gas_limit, - proposer_preferences.message.target_gas_limit, - )? { - Ok(()) - } else { - Err(PayloadBidError::InvalidGasLimit) - } - }; - - let cached_parent_gas_limit = ctx - .gossip_verified_payload_bid_cache - .get_parent_gas_limit(bid_slot, signed_bid.message.parent_block_hash); - if let Some(parent_gas_limit) = cached_parent_gas_limit { - check_parent_gas_limit(parent_gas_limit)?; + // [REJECT] `bid.prev_randao` is the correct RANDAO mix -- i.e. validate that + // `bid.prev_randao == get_randao_mix(parent_state, get_current_epoch(parent_state))` + if signed_bid.message.prev_randao + != *head_state.get_randao_mix(current_slot.epoch(E::slots_per_epoch()))? + { + return Err(PayloadBidError::InvalidPrevRandao { slot: bid_slot }); } - // Verify the signature before falling back to a historical database scan. + verify_builder(&signed_bid.message, head_state, ctx.spec)?; + execution_payload_bid_signature_set( head_state, |i| get_builder_pubkey_from_state(head_state, i), @@ -484,38 +350,6 @@ impl GossipVerifiedPayloadBid { .then_some(()) .ok_or(PayloadBidError::BadSignature)?; - if cached_parent_gas_limit.is_none() { - // Ancestor lookup can walk fork choice and the database, so only perform it for a bid - // whose builder and signature have already been verified. - let fork_choice = ctx.canonical_head.fork_choice_read_lock(); - let location = locate_parent_execution_payload_in_fork_choice( - &fork_choice, - signed_bid.message.parent_block_root, - signed_bid.message.parent_block_hash, - ); - drop(fork_choice); - - let gas_limit = match location { - ParentExecutionPayloadLocation::BeaconBlock(beacon_block_root) => { - get_parent_execution_payload_gas_limit_from_beacon_block::( - ctx.store, - beacon_block_root, - signed_bid.message.parent_block_hash, - )? - } - ParentExecutionPayloadLocation::SearchStoreFrom(beacon_block_root) => { - find_parent_execution_payload_gas_limit_in_store::( - ctx.store, - beacon_block_root, - signed_bid.message.parent_block_hash, - )? - } - }; - ctx.gossip_verified_payload_bid_cache - .insert_parent_gas_limit(bid_slot, signed_bid.message.parent_block_hash, gas_limit); - check_parent_gas_limit(gas_limit)?; - } - let gossip_verified_bid = GossipVerifiedPayloadBid { signed_bid }; ctx.gossip_verified_payload_bid_cache @@ -533,6 +367,7 @@ impl BeaconChain { pub fn payload_bid_gossip_verification_context(&self) -> GossipVerificationContext<'_, T> { GossipVerificationContext { canonical_head: &self.canonical_head, + observed_execution_payloads: &self.observed_execution_payloads, gossip_verified_payload_bid_cache: &self.gossip_verified_payload_bid_cache, gossip_verified_proposer_preferences_cache: &self .gossip_verified_proposer_preferences_cache, @@ -623,58 +458,16 @@ pub fn is_gas_limit_target_compatible( #[cfg(test)] mod tests { - use super::is_gas_limit_target_compatible; - use bls::Signature; - use kzg::KzgCommitment; - use ssz_types::ProgressiveVariableList; - use types::{ - Address, BeaconState, ChainSpec, EthSpec, ExecutionPayloadBid, MinimalEthSpec, - ProposerPreferences, SignedProposerPreferences, Slot, - }; + use super::{is_gas_limit_target_compatible, verify_bid_slot}; + use types::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, - target_gas_limit: u64, - ) -> SignedProposerPreferences { - SignedProposerPreferences { - message: ProposerPreferences { - fee_recipient, - target_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); + let result = verify_bid_slot(Slot::new(5), current_slot); assert!(matches!( result, Err(PayloadBidError::InvalidBidSlot { .. }) @@ -683,66 +476,15 @@ mod tests { #[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); + let result = verify_bid_slot(Slot::new(12), current_slot); 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 = ProgressiveVariableList::new(commitments); - - let result = verify_bid_consistency::(&bid, current_slot, &prefs, &state, &spec); - assert!(matches!( - result, - Err(PayloadBidError::InvalidBlobKzgCommitments { .. }) - )); - } - #[test] fn test_is_gas_limit_target_compatible_increase_within_limit() { assert!(is_gas_limit_target_compatible(60_000_000, 60_000_100, 60_000_100).unwrap()); 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 index 24b097ea2b1..060a5a965ee 100644 --- 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 @@ -27,16 +27,9 @@ impl BidParent { type HighestBidMap = BTreeMap>>; -#[derive(Clone, Copy)] -struct CachedParentGasLimit { - gas_limit: u64, - last_referenced_bid_slot: Slot, -} - pub struct GossipVerifiedPayloadBidCache { highest_bid: RwLock>, seen_builder_bids: RwLock>>, - parent_gas_limits: RwLock>, } impl Default for GossipVerifiedPayloadBidCache { @@ -44,7 +37,6 @@ impl Default for GossipVerifiedPayloadBidCache { Self { highest_bid: RwLock::new(BTreeMap::new()), seen_builder_bids: RwLock::new(BTreeMap::new()), - parent_gas_limits: RwLock::new(HashMap::new()), } } } @@ -102,42 +94,6 @@ impl GossipVerifiedPayloadBidCache { )); } - /// Get the gas limit of a parent execution payload and record the latest bid slot that - /// referenced it. - pub(crate) fn get_parent_gas_limit( - &self, - bid_slot: Slot, - parent_execution_block_hash: ExecutionBlockHash, - ) -> Option { - self.parent_gas_limits - .write() - .get_mut(&parent_execution_block_hash) - .map(|cached| { - cached.last_referenced_bid_slot = cached.last_referenced_bid_slot.max(bid_slot); - cached.gas_limit - }) - } - - /// Cache the gas limit of the parent execution payload identified by its execution block hash. - pub(crate) fn insert_parent_gas_limit( - &self, - bid_slot: Slot, - parent_execution_block_hash: ExecutionBlockHash, - gas_limit: u64, - ) { - self.parent_gas_limits - .write() - .entry(parent_execution_block_hash) - .and_modify(|cached| { - cached.gas_limit = gas_limit; - cached.last_referenced_bid_slot = cached.last_referenced_bid_slot.max(bid_slot); - }) - .or_insert(CachedParentGasLimit { - gas_limit, - last_referenced_bid_slot: bid_slot, - }); - } - /// Prune anything before `current_slot` pub fn prune(&self, current_slot: Slot) { self.highest_bid @@ -147,14 +103,6 @@ impl GossipVerifiedPayloadBidCache { self.seen_builder_bids .write() .retain(|&slot, _| slot >= current_slot); - - self.parent_gas_limits - .write() - // Pruning runs before bids arrive for `current_slot`. Keep parents referenced in the - // preceding slot so a continuing empty-payload chain can refresh the entry. - .retain(|_, cached| { - cached.last_referenced_bid_slot.saturating_add(1u64) >= current_slot - }); } } @@ -273,32 +221,6 @@ mod tests { assert_eq!(highest_b.message.builder_index, 3); } - #[test] - fn parent_gas_limit_is_reused_across_slots_and_pruned_by_latest_reference() { - let cache = GossipVerifiedPayloadBidCache::::default(); - let parent_block_hash = ExecutionBlockHash::repeat_byte(0x01); - - assert_eq!( - cache.get_parent_gas_limit(Slot::new(1), parent_block_hash), - None - ); - cache.insert_parent_gas_limit(Slot::new(1), parent_block_hash, 30_000_000); - - // Slot pruning runs before bids arrive for the new slot, so an entry used in the - // preceding slot must survive long enough to be reused and refreshed. - cache.prune(Slot::new(2)); - assert_eq!( - cache.get_parent_gas_limit(Slot::new(2), parent_block_hash), - Some(30_000_000) - ); - - cache.prune(Slot::new(4)); - assert_eq!( - cache.get_parent_gas_limit(Slot::new(4), parent_block_hash), - None - ); - } - #[test] fn prune_removes_old_retains_current() { let cache = GossipVerifiedPayloadBidCache::::default(); diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs index 38f447573d7..289eb3622da 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs @@ -12,10 +12,9 @@ use ssz_types::ProgressiveVariableList; use state_processing::genesis::genesis_block; use store::{HotColdDB, StoreConfig}; use types::{ - Address, BeaconState, ChainSpec, Checkpoint, Domain, Epoch, EthSpec, ExecutionBlockHash, - ExecutionPayloadBid, ExecutionPayloadEnvelope, ExecutionPayloadHeader, - ExecutionPayloadHeaderFulu, ForkName, Hash256, MinimalEthSpec, ProposerPreferences, - SignedBeaconBlock, SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope, + Address, ChainSpec, Checkpoint, Domain, Epoch, EthSpec, ExecutionBlockHash, + ExecutionPayloadBid, ExecutionPayloadHeader, ExecutionPayloadHeaderFulu, Hash256, + MinimalEthSpec, ProposerPreferences, SignedBeaconBlock, SignedExecutionPayloadBid, SignedProposerPreferences, SignedRoot, Slot, consts::gloas::PAYLOAD_BUILDER_VERSION, }; @@ -27,12 +26,10 @@ use crate::{ beacon_snapshot::BeaconSnapshot, canonical_head::CanonicalHead, chain_config::FastConfirmationMode, + observed_execution_payloads::ObservedExecutionPayloads, payload_bid_verification::{ PayloadBidError, - gossip_verified_bid::{ - GossipVerificationContext, GossipVerifiedPayloadBid, ParentExecutionPayloadLocation, - locate_parent_execution_payload_in_fork_choice, - }, + gossip_verified_bid::{GossipVerificationContext, GossipVerifiedPayloadBid}, payload_bid_cache::{BidParent, GossipVerifiedPayloadBidCache}, }, proposer_preferences_verification::{ @@ -54,6 +51,7 @@ const BUILDER_BALANCE: u64 = 2_000_000_000; struct TestContext { canonical_head: CanonicalHead, + observed_execution_payloads: ObservedExecutionPayloads, bid_cache: GossipVerifiedPayloadBidCache, preferences_cache: GossipVerifiedProposerPreferenceCache, slot_clock: TestingSlotClock, @@ -171,6 +169,12 @@ impl TestContext { ) .unwrap(); + let observed_execution_payloads = ObservedExecutionPayloads::default(); + let genesis_bid = state + .latest_execution_payload_bid() + .expect("should have a Gloas payload bid"); + observed_execution_payloads.insert(genesis_bid.parent_block_hash, genesis_bid.gas_limit); + let slot_clock = TestingSlotClock::new( Slot::new(0), Duration::from_secs(0), @@ -179,6 +183,7 @@ impl TestContext { Self { canonical_head, + observed_execution_payloads, bid_cache: GossipVerifiedPayloadBidCache::default(), preferences_cache: GossipVerifiedProposerPreferenceCache::default(), slot_clock, @@ -190,151 +195,6 @@ impl TestContext { } } - /// Create a finalized empty head whose latest received execution payload is only in the store. - /// - /// The head commits to a payload that was not received. Its parent execution payload was - /// received before the fork-choice anchor, forcing bid verification to continue the lookup in - /// the database. - fn with_finalized_empty_head_requiring_store_lookup( - mut self, - unreceived_head_bid_gas_limit: u64, - ) -> (Self, Hash256) { - let cached_head = self.canonical_head.cached_head(); - let mut state = cached_head.snapshot.beacon_state.clone(); - let execution_genesis_hash = self.execution_parent_hash(); - let received_payload_block_hash = ExecutionBlockHash::repeat_byte(0xaa); - let received_payload_slot = Slot::new(1); - - let received_payload_bid = { - let bid = state - .latest_execution_payload_bid_mut() - .expect("should have a Gloas payload bid"); - bid.slot = received_payload_slot; - bid.parent_block_root = self.genesis_block_root; - bid.parent_block_hash = execution_genesis_hash; - bid.block_hash = received_payload_block_hash; - bid.gas_limit = 30_000_000; - bid.clone() - }; - let (_, received_payload_beacon_block_root) = - self.store_block_from_state(&mut state, received_payload_slot, self.genesis_block_root); - self.store_payload_envelope_for_bid( - received_payload_beacon_block_root, - &received_payload_bid, - ); - - let head_slot = Epoch::new(1).start_slot(E::slots_per_epoch()); - let current_slot = head_slot + 1; - *state - .latest_block_hash_mut() - .expect("should have a Gloas execution block hash") = received_payload_block_hash; - - let head_bid = state - .latest_execution_payload_bid_mut() - .expect("should have a Gloas payload bid"); - head_bid.slot = head_slot; - head_bid.parent_block_root = received_payload_beacon_block_root; - head_bid.parent_block_hash = received_payload_block_hash; - head_bid.block_hash = ExecutionBlockHash::repeat_byte(0xab); - head_bid.gas_limit = unreceived_head_bid_gas_limit; - - let (signed_head_block, head_block_root) = - self.store_block_from_state(&mut state, head_slot, received_payload_beacon_block_root); - self.set_finalized_head(state, signed_head_block, head_block_root, current_slot); - - (self, head_block_root) - } - - fn store_block_from_state( - &self, - state: &mut BeaconState, - slot: Slot, - parent_root: Hash256, - ) -> (SignedBeaconBlock, Hash256) { - *state.slot_mut() = slot; - let mut block = genesis_block(state, &self.spec).expect("should build block"); - *block.slot_mut() = slot; - *block.parent_root_mut() = parent_root; - state.latest_block_header_mut().slot = slot; - state.latest_block_header_mut().parent_root = parent_root; - state.latest_block_header_mut().body_root = block.body_root(); - *block.state_root_mut() = state - .update_tree_hash_cache() - .expect("should hash block state"); - - let signed_block = SignedBeaconBlock::from_block(block, Signature::empty()); - let block_root = signed_block.canonical_root(); - self.store - .put_block(&block_root, signed_block.clone()) - .expect("should store block"); - (signed_block, block_root) - } - - fn store_payload_envelope_for_bid( - &self, - beacon_block_root: Hash256, - bid: &ExecutionPayloadBid, - ) { - let mut envelope = ExecutionPayloadEnvelope::empty(); - envelope.payload.parent_hash = bid.parent_block_hash; - envelope.payload.block_hash = bid.block_hash; - envelope.payload.gas_limit = bid.gas_limit; - envelope.payload.slot_number = bid.slot; - envelope.builder_index = bid.builder_index; - envelope.beacon_block_root = beacon_block_root; - envelope.parent_beacon_block_root = bid.parent_block_root; - self.store - .put_payload_envelope( - &beacon_block_root, - &SignedExecutionPayloadEnvelope { - message: envelope, - signature: Signature::empty(), - }, - ) - .expect("should store payload envelope"); - } - - fn set_finalized_head( - &mut self, - state: BeaconState, - signed_block: SignedBeaconBlock, - block_root: Hash256, - current_slot: Slot, - ) { - let snapshot = BeaconSnapshot::new( - Arc::new(signed_block.clone()), - None, - block_root, - state.clone(), - ); - let fc_store = - BeaconForkChoiceStore::get_forkchoice_store(self.store.clone(), snapshot.clone()) - .expect("should create fork choice store"); - let mut fork_choice = ForkChoice::from_anchor( - fc_store, - block_root, - &signed_block, - &state, - None, - &self.spec, - ) - .expect("should create fork choice at finalized head"); - let (_, head_payload_status) = fork_choice - .get_head(current_slot, &self.spec) - .expect("should run get_head"); - - self.canonical_head = CanonicalHead::new( - fork_choice, - Arc::new(snapshot), - head_payload_status, - FastConfirmationMode::Disabled, - &self.store, - &self.spec, - ) - .expect("should create canonical head"); - self.slot_clock.set_slot(current_slot.as_u64()); - } - fn sign_bid(&self, bid: ExecutionPayloadBid) -> Arc> { let head = self.canonical_head.cached_head(); let state = &head.snapshot.beacon_state; @@ -355,6 +215,7 @@ impl TestContext { fn gossip_ctx(&self) -> GossipVerificationContext<'_, T> { GossipVerificationContext { canonical_head: &self.canonical_head, + observed_execution_payloads: &self.observed_execution_payloads, gossip_verified_payload_bid_cache: &self.bid_cache, gossip_verified_proposer_preferences_cache: &self.preferences_cache, slot_clock: &self.slot_clock, @@ -408,44 +269,20 @@ impl TestContext { } fn insert_non_canonical_block(&self) -> Hash256 { - let fork_block_root = Hash256::repeat_byte(0xab); - let current_fork = self.spec.fork_name_at_slot::(Slot::new(1)); - self.insert_fork_choice_block( - current_fork, - fork_block_root, - ExecutionStatus::irrelevant(), - Some(ExecutionBlockHash::zero()), - Some(ExecutionBlockHash::repeat_byte(0xab)), - false, - ); - fork_block_root - } - - /// Insert a synthetic block under explicit fork rules, then apply payload receipt through the - /// same fork-choice transition used in production. - fn insert_fork_choice_block( - &self, - fork_name: ForkName, - block_root: Hash256, - execution_status: ExecutionStatus, - execution_payload_parent_hash: Option, - execution_payload_block_hash: Option, - payload_received: bool, - ) { let shuffling_id = AttestationShufflingId { shuffling_epoch: Epoch::new(0), shuffling_decision_block: self.genesis_block_root, }; - let spec = fork_name.make_genesis_spec(self.spec.clone()); + let fork_block_root = Hash256::repeat_byte(0xab); let mut fork_choice = self.canonical_head.fork_choice_write_lock(); fork_choice .proto_array_mut() .process_block::( ProtoBlock { slot: Slot::new(1), - root: block_root, + root: fork_block_root, parent_root: Some(self.genesis_block_root), - target_root: block_root, + target_root: fork_block_root, current_epoch_shuffling_id: shuffling_id.clone(), next_epoch_shuffling_id: shuffling_id, state_root: Hash256::ZERO, @@ -457,25 +294,20 @@ impl TestContext { epoch: Epoch::new(0), root: self.genesis_block_root, }, - execution_status, + execution_status: ExecutionStatus::irrelevant(), unrealized_justified_checkpoint: None, unrealized_finalized_checkpoint: None, - execution_payload_parent_hash, - execution_payload_block_hash, + execution_payload_parent_hash: Some(ExecutionBlockHash::zero()), + execution_payload_block_hash: Some(ExecutionBlockHash::repeat_byte(0xab)), proposer_index: Some(0), payload_received: false, }, Slot::new(1), - &spec, + &self.spec, Duration::from_secs(0), ) - .expect("should insert execution block"); - - if payload_received { - fork_choice - .on_valid_payload_envelope_received(block_root) - .expect("should mark payload received"); - } + .expect("should insert fork block"); + fork_block_root } } @@ -530,12 +362,12 @@ fn no_proposer_preferences_for_slot() { let ctx = TestContext::new(); let gossip = ctx.gossip_ctx(); let bid = ctx.make_signed_bid( - Slot::new(0), + Slot::new(1), 0, Address::ZERO, 30_000_000, 100, - Hash256::ZERO, + ctx.genesis_block_root, ); let result = GossipVerifiedPayloadBid::new(bid, &gossip); @@ -555,7 +387,7 @@ fn builder_already_seen_for_slot() { let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); - let bid = ctx.make_signed_bid(slot, 42, Address::ZERO, 30_000_000, 100, Hash256::ZERO); + let bid = ctx.make_signed_bid(slot, 0, Address::ZERO, 30_000_000, 100, Hash256::ZERO); let verified = GossipVerifiedPayloadBid { signed_bid: bid.clone(), }; @@ -565,7 +397,7 @@ fn builder_already_seen_for_slot() { assert!(matches!( result, Err(PayloadBidError::BuilderAlreadySeen { - builder_index: 42, + builder_index: 0, .. }) )); @@ -703,42 +535,40 @@ fn gas_limit_mismatch() { prev_randao: ctx.expected_prev_randao(), ..ExecutionPayloadBid::default() }); - let bid_parent = BidParent::from_bid(&bid.message); let result = GossipVerifiedPayloadBid::new(bid, &gossip); assert!(matches!(result, Err(PayloadBidError::InvalidGasLimit))); assert_eq!( - ctx.bid_cache - .get_parent_gas_limit(slot, bid_parent.parent_block_hash), + ctx.observed_execution_payloads + .get_gas_limit(ctx.execution_parent_hash()), Some(30_000_000) ); } #[test] -fn gas_limit_uses_stored_parent_after_finalized_empty_head() { +fn unknown_parent_execution_payload_is_ignored_before_signature() { if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { return; } - let rejected_payload_gas_limit = 30_029_295; - let next_gas_limit = 30_029_295; - let target_gas_limit = 60_000_000; - let (ctx, head_block_root) = TestContext::new() - .with_finalized_empty_head_requiring_store_lookup(rejected_payload_gas_limit); - let slot = ctx.slot_clock.now().expect("should read slot clock"); - seed_preferences(&ctx, slot, Address::ZERO, target_gas_limit); + let mut ctx = TestContext::new(); + ctx.observed_execution_payloads = ObservedExecutionPayloads::default(); + let slot = Slot::new(1); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); - let bid = ctx.sign_bid(ExecutionPayloadBid { + // The signature is also invalid, but an unknown parent payload is an IGNORE condition and + // must be returned before signature verification. + let bid = ctx.make_signed_bid( slot, - builder_index: 0, - fee_recipient: Address::ZERO, - gas_limit: next_gas_limit, - parent_block_root: head_block_root, - parent_block_hash: ctx.execution_parent_hash(), - prev_randao: ctx.expected_prev_randao(), - ..ExecutionPayloadBid::default() - }); - - GossipVerifiedPayloadBid::new(bid, &ctx.gossip_ctx()) - .expect("bid should use the received execution payload before finalization"); + 0, + Address::ZERO, + 30_000_000, + 0, + ctx.genesis_block_root, + ); + let result = GossipVerifiedPayloadBid::new(bid, &ctx.gossip_ctx()); + assert!(matches!( + result, + Err(PayloadBidError::ParentExecutionPayloadUnknown { .. }) + )); } #[test] @@ -880,6 +710,7 @@ fn parent_block_root_not_canonical() { let gossip = ctx.gossip_ctx(); // The non-canonical fork block is at slot 1, so use slot 2 to satisfy the `bid.slot > parent // block slot` rule and exercise the bid descendant from parent check specifically. + ctx.slot_clock.set_slot(1); let slot = Slot::new(2); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); @@ -993,56 +824,7 @@ fn bad_signature() { } #[test] -fn fork_choice_locator_recognizes_received_gloas_block() { - if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { - return; - } - let ctx = TestContext::new(); - let block_root = Hash256::repeat_byte(0x81); - let block_hash = ExecutionBlockHash::repeat_byte(0x82); - let current_fork = ctx.spec.fork_name_at_slot::(Slot::new(1)); - ctx.insert_fork_choice_block( - current_fork, - block_root, - ExecutionStatus::irrelevant(), - Some(ctx.execution_parent_hash()), - Some(block_hash), - true, - ); - - let fork_choice = ctx.canonical_head.fork_choice_read_lock(); - assert!(matches!( - locate_parent_execution_payload_in_fork_choice(&fork_choice, block_root, block_hash), - ParentExecutionPayloadLocation::BeaconBlock(root) if root == block_root - )); -} - -#[test] -fn fork_choice_locator_recognizes_pre_gloas_execution_status() { - if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { - return; - } - let ctx = TestContext::new(); - let block_root = Hash256::repeat_byte(0x91); - let block_hash = ExecutionBlockHash::repeat_byte(0x92); - ctx.insert_fork_choice_block( - ForkName::Fulu, - block_root, - ExecutionStatus::Valid(block_hash), - None, - None, - false, - ); - - let fork_choice = ctx.canonical_head.fork_choice_read_lock(); - assert!(matches!( - locate_parent_execution_payload_in_fork_choice(&fork_choice, block_root, block_hash), - ParentExecutionPayloadLocation::BeaconBlock(root) if root == block_root - )); -} - -#[test] -fn valid_bid() { +fn valid_bid_after_empty_genesis_uses_parent_payload_gas_limit() { if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { return; } 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 2cbc724fd91..dd5ee87fbb7 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 @@ -16,6 +16,7 @@ use crate::{ BeaconChain, BeaconChainError, BeaconChainTypes, BeaconStore, ServerSentEventHandler, beacon_proposer_cache::{self, BeaconProposerCache}, canonical_head::CanonicalHead, + observed_execution_payloads::ObservedExecutionPayloads, payload_envelope_verification::{ EnvelopeError, EnvelopeProcessingSnapshot, EnvelopeSource, load_snapshot_from_state_root, }, @@ -34,6 +35,7 @@ pub struct GossipVerificationContext<'a, T: BeaconChainTypes> { pub observed_payload_envelopes: &'a ObservedPayloadEnvelopes, pub genesis_validators_root: Hash256, pub event_handler: &'a Option>, + pub observed_execution_payloads: &'a ObservedExecutionPayloads, } /// Verify that an execution payload envelope is consistent with its beacon block @@ -300,23 +302,24 @@ impl GossipVerifiedEnvelope { }); } - // Emit SSE event once per envelope, on first observation from any source - if !envelope_already_seen - && let Some(event_handler) = ctx.event_handler.as_ref() - && event_handler.has_execution_payload_gossip_subscribers() - { - event_handler.register(EventKind::ExecutionPayloadGossip( - SseExecutionPayloadGossip { - slot: block_slot, - builder_index, - block_hash: gossip_verified_envelope - .signed_envelope - .message - .payload - .block_hash, - block_root: beacon_block_root, - }, - )); + if !envelope_already_seen { + let payload = &gossip_verified_envelope.signed_envelope.message.payload; + ctx.observed_execution_payloads + .insert(payload.block_hash, payload.gas_limit); + + // Emit the SSE event once for the first observation from any source. + if let Some(event_handler) = ctx.event_handler.as_ref() + && event_handler.has_execution_payload_gossip_subscribers() + { + event_handler.register(EventKind::ExecutionPayloadGossip( + SseExecutionPayloadGossip { + slot: block_slot, + builder_index, + block_hash: payload.block_hash, + block_root: beacon_block_root, + }, + )); + } } Ok(gossip_verified_envelope) @@ -343,6 +346,7 @@ impl BeaconChain { observed_payload_envelopes: &self.observed_payload_envelopes, genesis_validators_root: self.genesis_validators_root, event_handler: &self.event_handler, + observed_execution_payloads: &self.observed_execution_payloads, } } diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs index 02c8521fbda..5a7b56730a2 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs @@ -300,6 +300,11 @@ impl BeaconChain { // This prevents inconsistency between the two at the expense of concurrency. drop(fork_choice); + self.observed_execution_payloads.insert( + signed_envelope.message.payload.block_hash, + signed_envelope.message.payload.gas_limit, + ); + // We're declaring the envelope "imported" at this point, since fork choice and the DB know // about it. let envelope_time_imported = self.slot_clock.now_duration().unwrap_or(Duration::MAX); diff --git a/beacon_node/beacon_chain/tests/envelope_verification.rs b/beacon_node/beacon_chain/tests/envelope_verification.rs index e1ce1b755d1..7df86fd0bf4 100644 --- a/beacon_node/beacon_chain/tests/envelope_verification.rs +++ b/beacon_node/beacon_chain/tests/envelope_verification.rs @@ -4,10 +4,67 @@ use beacon_chain::test_utils::{BeaconChainHarness, fork_name_from_env}; use bls::PublicKeyBytes; use eth2::types::EventKind; use std::sync::Arc; -use types::{Address, MinimalEthSpec, Slot, WithdrawalRequest}; +use types::{Address, ExecPayload, ForkName, MinimalEthSpec, Slot, WithdrawalRequest}; type E = MinimalEthSpec; +#[tokio::test] +async fn pre_gloas_block_import_records_payload_gas_limit() { + if fork_name_from_env() != Some(ForkName::Fulu) { + 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 head = harness.chain.head_beacon_block(); + let payload = head + .message() + .execution_payload() + .expect("Fulu block should contain an execution payload"); + assert_eq!( + harness + .chain + .observed_execution_payloads + .get_gas_limit(payload.block_hash()), + Some(payload.gas_limit()) + ); +} + +#[tokio::test] +async fn startup_seeds_gloas_genesis_parent_payload() { + 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(); + + let head = harness.chain.canonical_head.cached_head(); + let genesis_bid = head + .snapshot + .beacon_state + .latest_execution_payload_bid() + .expect("Gloas genesis should contain an execution payload bid"); + assert_eq!( + harness + .chain + .observed_execution_payloads + .get_gas_limit(genesis_bid.parent_block_hash), + Some(genesis_bid.gas_limit) + ); +} + /// An envelope whose `execution_requests` don't hash to the bid's committed /// `execution_requests_root` must be rejected by the full gossip verification path. #[tokio::test] @@ -38,6 +95,7 @@ async fn gossip_rejects_execution_requests_root_mismatch() { .expect("block should be processed"); let mut signed_envelope = opt_envelope.expect("Gloas block should produce an envelope"); + let block_hash = signed_envelope.message.payload.block_hash; signed_envelope .message .execution_requests @@ -56,6 +114,66 @@ async fn gossip_rejects_execution_requests_root_mismatch() { result, Err(EnvelopeError::ExecutionRequestsRootMismatch { .. }) )); + assert_eq!( + harness + .chain + .observed_execution_payloads + .get_gas_limit(block_hash), + None + ); +} + +#[tokio::test] +async fn gossip_verified_envelope_records_payload_gas_limit() { + 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 block_hash = signed_envelope.message.payload.block_hash; + let gas_limit = signed_envelope.message.payload.gas_limit; + assert_eq!( + harness + .chain + .observed_execution_payloads + .get_gas_limit(block_hash), + None + ); + + harness + .chain + .verify_envelope_for_gossip(Arc::new(signed_envelope), EnvelopeSource::Gossip) + .await + .expect("envelope should pass gossip verification"); + + assert_eq!( + harness + .chain + .observed_execution_payloads + .get_gas_limit(block_hash), + Some(gas_limit) + ); } #[tokio::test] diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index 8c4f07c5aa2..df58c691a7f 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -940,6 +940,14 @@ impl ProtoArrayForkChoice { self.proto_array.nodes.is_empty() } + /// Iterate over all blocks currently retained in fork choice. + pub fn blocks(&self) -> impl Iterator + '_ { + self.proto_array + .nodes + .iter() + .map(|node| self.proto_node_to_block(node)) + } + pub fn contains_block(&self, block_root: &Hash256) -> bool { self.proto_array.indices.contains_key(block_root) }