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 00168a58c9f..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}, @@ -17,25 +18,24 @@ 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, ExecutionPayloadBid, SignedExecutionPayloadBid, Slot, + consts::gloas::PAYLOAD_BUILDER_VERSION, }; -/// Verify that an execution payload bid is consistent with the current chain state -/// and proposer preferences. -pub(crate) fn verify_bid_consistency( +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 { + Err(PayloadBidError::InvalidBidSlot { bid_slot }) + } +} + +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 { @@ -44,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; @@ -58,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, @@ -76,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(()) } @@ -159,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, @@ -186,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 @@ -198,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) @@ -211,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 @@ -269,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)? { @@ -302,35 +326,18 @@ 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, - )? + drop(fork_choice); + + // [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::InvalidGasLimit); + return Err(PayloadBidError::InvalidPrevRandao { slot: bid_slot }); } - drop(fork_choice); - - verify_bid_consistency( - &signed_bid.message, - current_slot, - &proposer_preferences, - head_state, - ctx.spec, - )?; + verify_builder(&signed_bid.message, head_state, ctx.spec)?; - // Verify signature execution_payload_bid_signature_set( head_state, |i| get_builder_pubkey_from_state(head_state, i), @@ -360,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, @@ -450,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 { .. }) @@ -510,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/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..289eb3622da 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, ExecutionPayloadHeader, ExecutionPayloadHeaderFulu, Hash256, + MinimalEthSpec, ProposerPreferences, SignedBeaconBlock, SignedExecutionPayloadBid, + SignedProposerPreferences, SignedRoot, Slot, consts::gloas::PAYLOAD_BUILDER_VERSION, }; use proto_array::{Block as ProtoBlock, ExecutionStatus}; @@ -26,6 +26,7 @@ use crate::{ beacon_snapshot::BeaconSnapshot, canonical_head::CanonicalHead, chain_config::FastConfirmationMode, + observed_execution_payloads::ObservedExecutionPayloads, payload_bid_verification::{ PayloadBidError, gossip_verified_bid::{GossipVerificationContext, GossipVerifiedPayloadBid}, @@ -50,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, @@ -78,9 +80,21 @@ 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"); + // 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, + 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 +118,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 @@ -148,6 +151,10 @@ 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"); + let (_, head_payload_status) = fork_choice .get_head(Slot::new(0), &spec) .expect("should run get_head"); @@ -162,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), @@ -170,6 +183,7 @@ impl TestContext { Self { canonical_head, + observed_execution_payloads, bid_cache: GossipVerifiedPayloadBidCache::default(), preferences_cache: GossipVerifiedProposerPreferenceCache::default(), slot_clock, @@ -201,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, @@ -219,6 +234,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, @@ -236,6 +260,7 @@ impl TestContext { gas_limit, value, parent_block_root, + parent_block_hash: self.execution_parent_hash(), prev_randao: self.expected_prev_randao(), ..ExecutionPayloadBid::default() }, @@ -249,8 +274,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), @@ -336,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); @@ -361,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(), }; @@ -371,7 +397,7 @@ fn builder_already_seen_for_slot() { assert!(matches!( result, Err(PayloadBidError::BuilderAlreadySeen { - builder_index: 42, + builder_index: 0, .. }) )); @@ -394,6 +420,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() }); @@ -497,16 +524,51 @@ fn gas_limit_mismatch() { let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + let bid = ctx.sign_bid(ExecutionPayloadBid { + slot, + builder_index: 0, + fee_recipient: Address::ZERO, + gas_limit: 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 result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!(result, Err(PayloadBidError::InvalidGasLimit))); + assert_eq!( + ctx.observed_execution_payloads + .get_gas_limit(ctx.execution_parent_hash()), + Some(30_000_000) + ); +} + +#[test] +fn unknown_parent_execution_payload_is_ignored_before_signature() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + 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); + + // 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, 0, Address::ZERO, - 50_000_000, - 100, + 30_000_000, + 0, ctx.genesis_block_root, ); - let result = GossipVerifiedPayloadBid::new(bid, &gossip); - assert!(matches!(result, Err(PayloadBidError::InvalidGasLimit))); + let result = GossipVerifiedPayloadBid::new(bid, &ctx.gossip_ctx()); + assert!(matches!( + result, + Err(PayloadBidError::ParentExecutionPayloadUnknown { .. }) + )); } #[test] @@ -525,6 +587,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() }, @@ -647,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); @@ -716,6 +780,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() @@ -748,12 +813,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) @@ -762,7 +824,7 @@ fn bad_signature() { } #[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; } @@ -778,6 +840,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() }); @@ -806,6 +869,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() }); @@ -824,6 +888,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() }); @@ -836,7 +901,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!( 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/beacon_node/network/src/network_beacon_processor/gossip_methods.rs b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs index d804ccefa9d..c1aa1aa8093 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -4242,6 +4242,7 @@ impl NetworkBeaconProcessor { | PayloadBidError::BuilderAlreadySeen { .. } | PayloadBidError::BidValueBelowCached { .. } | PayloadBidError::ParentBlockRootUnknown { .. } + | PayloadBidError::ParentExecutionPayloadUnknown { .. } | PayloadBidError::BidNotCompatibleWithHead { .. } | PayloadBidError::BuilderCantCoverBid { .. } | PayloadBidError::InvalidFeeRecipient 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) }