diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 8c0363608a..d278c91bb6 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -27,7 +27,7 @@ use crate::data_availability_checker::DataAvailabilityChecker; use crate::data_column_verification::{ GossipDataColumnError, GossipPartialDataColumnError, GossipVerifiedDataColumn, GossipVerifiedPartialDataColumnHeader, KzgVerifiedCustodyPartialDataColumn, - KzgVerifiedPartialDataColumn, PartialColumnVerificationResult, + KzgVerifiedPartialDataColumn, PartialColumnVerificationResult, load_gloas_payload_bid, validate_partial_data_column_sidecar_for_gossip, }; use crate::early_attester_cache::EarlyAttesterCache; @@ -36,6 +36,7 @@ use crate::errors::{BeaconChainError as Error, BlockProductionError}; use crate::events::ServerSentEventHandler; use crate::execution_payload::{NotifyExecutionLayer, PreparePayloadHandle, get_execution_payload}; use crate::fetch_blobs::EngineGetBlobsOutput; +use crate::kzg_utils::reconstruct_blobs; use crate::fork_choice_signal::{ForkChoiceSignalRx, ForkChoiceSignalTx}; use crate::graffiti_calculator::{GraffitiCalculator, GraffitiSettings}; use crate::light_client_finality_update_verification::{ @@ -1312,6 +1313,54 @@ impl BeaconChain { .map_err(Error::from) } + /// Returns the blobs at the given root, if any. + /// + /// Uses the `block.epoch()` to determine whether to retrieve blobs or columns from the store. + /// + /// If at least 50% of columns are retrieved, blobs will be reconstructed and returned, + /// otherwise an error `InsufficientColumnsToReconstructBlobs` is returned. + /// + /// ## Errors + /// May return a database error. + pub fn get_or_reconstruct_blobs( + &self, + block_root: &Hash256, + ) -> Result>, Error> { + let Some(block) = self.store.get_blinded_block(block_root)? else { + return Ok(None); + }; + + // Gloas removes the standalone `BlobSidecar` shape — KZG commitments live in the bid and + // there's no signed-block-header / inclusion-proof to populate a `BlobSidecar` from. The + // canonical data is the column sidecar set on disk; callers needing data for a Gloas + // block should consume columns directly via `get_data_columns`. + if block.fork_name_unchecked().gloas_enabled() { + return Ok(None); + } + + if self.spec.is_peer_das_enabled_for_epoch(block.epoch()) { + let fork_name = self.spec.fork_name_at_epoch(block.epoch()); + if let Some(columns) = self.store.get_data_columns(block_root, fork_name)? { + let num_required_columns = T::EthSpec::number_of_columns() / 2; + let reconstruction_possible = columns.len() >= num_required_columns; + if reconstruction_possible { + reconstruct_blobs(&self.kzg, columns, None, &block, &self.spec) + .map(Some) + .map_err(Error::FailedToReconstructBlobs) + } else { + Err(Error::InsufficientColumnsToReconstructBlobs { + columns_found: columns.len(), + }) + } + } else { + Ok(None) + } + } else { + Ok(self.get_blobs(block_root)?.blobs()) + } + } + + /// Returns the data columns at the given root, if any. /// /// ## Errors @@ -3020,7 +3069,34 @@ impl BeaconChain { // // Note that `check_block_relevancy` is incapable of returning // `DuplicateImportStatusUnknown` so we don't need to handle that case here. - Err(BlockError::DuplicateFullyImported(_)) => continue, + Err(BlockError::DuplicateFullyImported(_)) => { + debug!( + block_root = %block_root, + slot = %block.slot(), + "Skipping DuplicateFullyImported block in chain segment", + ); + // For Gloas blocks, persist the envelope even though we're + // skipping the block. After checkpoint sync, blocks between + // the finalized checkpoint and the head are already in fork + // choice but their envelopes aren't in the store. + if let RangeSyncBlock::Gloas { + envelope: Some(ref available_envelope), + .. + } = block + { + let (signed_envelope, _columns) = available_envelope.clone().deconstruct(); + if let Err(e) = self + .store + .put_payload_envelope(&block_root, &signed_envelope) + { + return Err(Box::new(ChainSegmentResult::Failed { + imported_blocks, + error: BlockError::BeaconChainError(Box::new(e.into())), + })); + } + } + continue; + } // If the block is the genesis block, simply ignore this block. Err(BlockError::GenesisBlock) => continue, // If the block is is for a finalized slot, simply ignore this block. @@ -3036,7 +3112,34 @@ impl BeaconChain { // In the case of (2), skipping the block is valid since we should never import it. // However, we will potentially get a `ParentUnknown` on a later block. The sync // protocol will need to ensure this is handled gracefully. - Err(BlockError::WouldRevertFinalizedSlot { .. }) => continue, + Err(BlockError::WouldRevertFinalizedSlot { .. }) => { + debug!( + block_root = %block_root, + slot = %block.slot(), + "Skipping WouldRevertFinalizedSlot block in chain segment", + ); + // For Gloas blocks, persist the envelope even though we're skipping + // the block. This is needed after checkpoint sync: the checkpoint + // block's envelope must be in the store so that `load_parent` can + // verify it when importing the first post-checkpoint block. + if let RangeSyncBlock::Gloas { + envelope: Some(ref available_envelope), + .. + } = block + { + let (signed_envelope, _columns) = available_envelope.clone().deconstruct(); + if let Err(e) = self + .store + .put_payload_envelope(&block_root, &signed_envelope) + { + return Err(Box::new(ChainSegmentResult::Failed { + imported_blocks, + error: BlockError::BeaconChainError(Box::new(e.into())), + })); + } + } + continue; + } // The block has a known parent that does not descend from the finalized block. // There is no need to process this block or any children. Err(BlockError::NotFinalizedDescendant { block_parent_root }) => { @@ -3145,11 +3248,33 @@ impl BeaconChain { }; // Import the blocks into the chain. - for signature_verified_block in signature_verified_blocks { + for (signature_verified_block, envelope) in signature_verified_blocks { + let block_root = signature_verified_block.block_root(); let block_slot = signature_verified_block.slot(); + + // For Gloas blocks, persist the envelope and notify fork choice + // before importing the block. The next block's `load_parent` will + // check for this envelope in the store. + if let Some(available_envelope) = envelope.clone() { + let (signed_envelope, _columns) = available_envelope.deconstruct(); + if let Err(e) = self + .store + .put_payload_envelope(&block_root, &signed_envelope) + { + return ChainSegmentResult::Failed { + imported_blocks, + error: BlockError::BeaconChainError(Box::new(e.into())), + }; + } + // Note: we do NOT call on_valid_payload_envelope_received here + // because the block hasn't been added to fork choice yet (that + // happens in process_block below). The fork choice update is + // handled by import_envelope_from_range_sync after process_block. + } + match self .process_block( - signature_verified_block.block_root(), + block_root, signature_verified_block, notify_execution_layer, BlockImportSource::RangeSync, @@ -3160,6 +3285,16 @@ impl BeaconChain { Ok(status) => { match status { AvailabilityProcessingStatus::Imported(block_root) => { + // Import the envelope if one was provided (Gloas+). + if let Some(envelope) = envelope + && let Err(e) = + self.import_envelope_from_range_sync(*envelope, block_root) + { + return ChainSegmentResult::Failed { + imported_blocks, + error: e, + }; + } // The block was imported successfully. imported_blocks.push((block_root, block_slot)); } @@ -3301,7 +3436,20 @@ impl BeaconChain { )); }; - if self.is_block_data_imported(block_root, slot) { + let is_gloas = self + .spec + .fork_name_at_slot::(slot) + .gloas_enabled(); + + // Before Gloas, if this block has already been imported to fork choice it must have been + // available, so we don't need to process its samples again. In Gloas the beacon block is + // imported before the payload envelope and data columns, so this check does not apply. + if !is_gloas + && self + .canonical_head + .fork_choice_read_lock() + .contains_block(&block_root) + { return Err(BlockError::DuplicateFullyImported(block_root)); } @@ -3390,9 +3538,15 @@ impl BeaconChain { .fork_name_at_slot::(slot) .gloas_enabled() { + let bid = load_gloas_payload_bid(block_root, self)? + .ok_or(BlockError::EnvelopeBlockRootUnknown(block_root))?; let availability = self .pending_payload_cache - .put_kzg_verified_custody_data_columns(block_root, &merge_result.full_columns) + .put_kzg_verified_custody_data_columns( + block_root, + bid, + &merge_result.full_columns, + ) .map_err(BlockError::from)?; self.process_payload_envelope_availability(slot, availability, || Ok(())) .await? @@ -3603,11 +3757,13 @@ impl BeaconChain { .gloas_enabled(); if is_gloas { + let bid = load_gloas_payload_bid(block_root, self)? + .ok_or(BlockError::EnvelopeBlockRootUnknown(block_root))?; let pending_payload_cache = self.pending_payload_cache.clone(); let result = self .task_executor .spawn_blocking_with_rayon_async(RayonPoolType::HighPriority, move || { - pending_payload_cache.reconstruct_data_columns(&block_root) + pending_payload_cache.reconstruct_data_columns(&block_root, bid) }) .await .map_err(|_| BlockError::from(BeaconChainError::RuntimeShutdown))? @@ -3958,9 +4114,11 @@ impl BeaconChain { .fork_name_at_slot::(slot) .gloas_enabled() { + let bid = load_gloas_payload_bid(block_root, self)? + .ok_or(BlockError::EnvelopeBlockRootUnknown(block_root))?; let availability = self .pending_payload_cache - .put_gossip_verified_data_columns(block_root, data_columns)?; + .put_gossip_verified_data_columns(block_root, bid, data_columns)?; Ok(self .process_payload_envelope_availability(slot, availability, publish_fn) .await?) @@ -4062,9 +4220,11 @@ impl BeaconChain { .fork_name_at_slot::(slot) .gloas_enabled() { + let bid = load_gloas_payload_bid(block_root, self)? + .ok_or(BlockError::EnvelopeBlockRootUnknown(block_root))?; let availability = self .pending_payload_cache - .put_kzg_verified_custody_data_columns(block_root, &data_columns) + .put_kzg_verified_custody_data_columns(block_root, bid, &data_columns) .map_err(BlockError::from)?; Ok(self .process_payload_envelope_availability(slot, availability, || Ok(())) @@ -4104,9 +4264,11 @@ impl BeaconChain { .fork_name_at_slot::(slot) .gloas_enabled() { + let bid = load_gloas_payload_bid(block_root, self)? + .ok_or(BlockError::EnvelopeBlockRootUnknown(block_root))?; let availability = self .pending_payload_cache - .put_rpc_custody_columns(block_root, custody_columns) + .put_rpc_custody_columns(block_root, bid, custody_columns) .map_err(BlockError::from)?; Ok(self .process_payload_envelope_availability(slot, availability, || Ok(())) diff --git a/beacon_node/beacon_chain/src/block_verification.rs b/beacon_node/beacon_chain/src/block_verification.rs index 24f971f736..da9e7de855 100644 --- a/beacon_node/beacon_chain/src/block_verification.rs +++ b/beacon_node/beacon_chain/src/block_verification.rs @@ -60,6 +60,7 @@ use crate::execution_payload::{ }; use crate::kzg_utils::blobs_to_data_column_sidecars; use crate::observed_block_producers::SeenBlock; +use crate::payload_envelope_verification::{AvailableEnvelope, EnvelopeError}; use crate::validator_monitor::HISTORIC_EPOCHS as VALIDATOR_MONITOR_HISTORIC_EPOCHS; use crate::validator_pubkey_cache::ValidatorPubkeyCache; use crate::{ @@ -324,6 +325,20 @@ pub enum BlockError { bid_parent_root: Hash256, block_parent_root: Hash256, }, + /// The block is known but its parent execution payload envelope has not been received yet. + /// + /// ## Peer scoring + /// + /// It's unclear if this block is valid, but it cannot be fully verified without the parent's + /// execution payload envelope. + ParentEnvelopeUnknown { parent_root: Hash256 }, + /// An error occurred while processing the execution payload envelope during range sync. + EnvelopeError(Box), + + PayloadEnvelopeError { + e: Box, + penalize_peer: bool, + }, } /// Which specific signature(s) are invalid in a SignedBeaconBlock @@ -490,6 +505,37 @@ impl From for BlockError { } } +impl From for BlockError { + fn from(e: EnvelopeError) -> Self { + let penalize_peer = match &e { + // REJECT per spec: peer sent invalid envelope data + EnvelopeError::BadSignature + | EnvelopeError::BuilderIndexMismatch { .. } + | EnvelopeError::BlockHashMismatch { .. } + | EnvelopeError::SlotMismatch { .. } + | EnvelopeError::IncorrectBlockProposer { .. } => true, + // IGNORE per spec: not the peer's fault + EnvelopeError::BlockRootUnknown { .. } + | EnvelopeError::PriorToFinalization { .. } + | EnvelopeError::UnknownValidator { .. } => false, + // Internal errors: not the peer's fault + EnvelopeError::BeaconChainError(_) + | EnvelopeError::BeaconStateError(_) + | EnvelopeError::BlockProcessingError(_) + | EnvelopeError::EnvelopeProcessingError(_) + | EnvelopeError::ExecutionPayloadError(_) + | EnvelopeError::ImportError(_) + | EnvelopeError::BlockError(_) + | EnvelopeError::InternalError(_) + | EnvelopeError::OptimisticSyncNotSupported { .. } => false, + }; + BlockError::PayloadEnvelopeError { + e: Box::new(e), + penalize_peer, + } + } +} + /// Stores information about verifying a payload against an execution engine. #[derive(Debug, PartialEq, Clone, Encode, Decode)] pub struct PayloadVerificationOutcome { @@ -587,10 +633,17 @@ pub(crate) fn process_block_slash_info( mut chain_segment: Vec<(Hash256, RangeSyncBlock)>, chain: &BeaconChain, -) -> Result>, BlockError> { +) -> Result< + Vec<( + SignatureVerifiedBlock, + Option>>, + )>, + BlockError, +> { if chain_segment.is_empty() { return Ok(vec![]); } @@ -619,14 +672,29 @@ pub fn signature_verify_chain_segment( let consensus_context = ConsensusContext::new(block.slot()).set_current_block_root(block_root); - let available_block = block.into_available_block(); + let (available_block, envelope) = match block { + RangeSyncBlock::Base(ab) => (ab, None), + RangeSyncBlock::Gloas { block, envelope } => { + let ab = AvailableBlock::new( + block, + AvailableBlockData::NoData, + &chain.data_availability_checker, + chain.spec.clone(), + ) + .map_err(BlockError::AvailabilityCheck)?; + (ab, envelope) + } + }; available_blocks.push(available_block.clone()); - signature_verified_blocks.push(SignatureVerifiedBlock { - block: MaybeAvailableBlock::Available(available_block), - block_root, - parent: None, - consensus_context, - }); + signature_verified_blocks.push(( + SignatureVerifiedBlock { + block: MaybeAvailableBlock::Available(available_block), + block_root, + parent: None, + consensus_context, + }, + envelope, + )); } // TODO(gloas) When implementing range and backfill sync for gloas // we need a batch verify kzg function in the new da checker as well. @@ -637,7 +705,7 @@ pub fn signature_verify_chain_segment( // verify signatures let pubkey_cache = get_validator_pubkey_cache(chain)?; let mut signature_verifier = get_signature_verifier(&state, &pubkey_cache, &chain.spec); - for svb in &mut signature_verified_blocks { + for (svb, _) in &mut signature_verified_blocks { signature_verifier .include_all_signatures(svb.block.as_block(), &mut svb.consensus_context)?; } @@ -648,7 +716,7 @@ pub fn signature_verify_chain_segment( drop(pubkey_cache); - if let Some(signature_verified_block) = signature_verified_blocks.first_mut() { + if let Some((signature_verified_block, _)) = signature_verified_blocks.first_mut() { signature_verified_block.parent = Some(parent); } @@ -892,22 +960,43 @@ impl GossipVerifiedBlock { let (parent_block, block) = verify_parent_block_is_known::(&fork_choice_read_lock, block)?; + let Ok(bid) = block.message().body().signed_execution_payload_bid() else { + return Err(BlockError::InternalError("Invalid variant".to_string())); + }; + // [New in Gloas]: Verify bid.parent_block_root matches block.parent_root. - if let Ok(bid) = block.message().body().signed_execution_payload_bid() - && bid.message.parent_block_root != block.message().parent_root() - { + if bid.message.parent_block_root != block.message().parent_root() { return Err(BlockError::BidParentRootMismatch { bid_parent_root: bid.message.parent_block_root, block_parent_root: block.message().parent_root(), }); } + // Check that we've received the parent envelope. If not, issue a single envelope + // lookup for the parent and queue this block in the reprocess queue. + // + // The anchor block (proto-array root) is implicitly considered to have its payload + // received: there is no envelope to fetch for the anchor (per spec, the anchor is + // never added to `store.payloads`), and the anchor is trusted by definition. + let parent_is_gloas = chain + .spec + .fork_name_at_slot::(parent_block.slot) + .gloas_enabled(); + let parent_is_anchor = parent_block.parent_root.is_none(); - // TODO(gloas) The following validation can only be completed once fork choice has been implemented: - // The block's parent execution payload (defined by bid.parent_block_hash) has been seen - // (via gossip or non-gossip sources) (a client MAY queue blocks for processing - // once the parent payload is retrieved). If execution_payload verification of block's execution - // payload parent by an execution node is complete, verify the block's execution payload - // parent (defined by bid.parent_block_hash) passes all validation. + // Check if this block's bid references a payload envelope we haven't received. + // Only trigger a lookup if the bid's parent_block_hash matches the parent block's + // committed execution_payload_block_hash (meaning this block builds directly on + // the parent's payload). If they don't match, the block is building on an older + // execution state (e.g. grandparent's) and doesn't need the parent's envelope. + if parent_is_gloas + && !parent_is_anchor + && Some(bid.message.parent_block_hash) == parent_block.execution_payload_block_hash + && !fork_choice_read_lock.is_payload_received(&block.message().parent_root()) + { + return Err(BlockError::ParentEnvelopeUnknown { + parent_root: block.message().parent_root(), + }); + } drop(fork_choice_read_lock); @@ -1196,7 +1285,7 @@ impl SignatureVerifiedBlock { let result = info_span!("signature_verify").in_scope(|| signature_verifier.verify()); match result { Ok(_) => { - // gloas blocks are always available. + // Gloas blocks are always available — data arrives via the envelope. let maybe_available = if chain .spec .fork_name_at_slot::(block.slot()) @@ -1951,12 +2040,51 @@ fn load_parent>( BlockError::from(BeaconChainError::MissingBeaconBlock(block.parent_root())) })?; + // For post-Gloas parent blocks, the execution payload arrives via the envelope. + // If the parent's execution payload envelope hasn't arrived yet, + // return an unknown parent error so the block gets sent to the + // reprocess queue. + // + // Skip this check if the parent is at or before the finalized slot (e.g. after + // checkpoint sync the finalized block won't have a stored envelope). + let finalized_slot = chain + .canonical_head + .cached_head() + .finalized_checkpoint() + .epoch + .start_slot(T::EthSpec::slots_per_epoch()); + if chain + .spec + .fork_name_at_slot::(parent_block.slot()) + .gloas_enabled() + && parent_block.slot() > finalized_slot + { + let in_store = chain.store.get_payload_envelope(&root)?.is_some(); + if !in_store { + // If the parent is already in fork choice it was previously imported. + // Its envelope may not be in the store if PayloadEnvelopesByRange + // didn't return it, but the block itself is valid and trusted. + let in_fork_choice = chain + .canonical_head + .fork_choice_read_lock() + .contains_block(&root); + if !in_fork_choice { + debug!( + parent_root = %root, + parent_slot = %parent_block.slot(), + %finalized_slot, + "load_parent: parent envelope not in store and not in fork choice", + ); + return Err(BlockError::ParentEnvelopeUnknown { parent_root: root }); + } + } + } + // Load the parent block's state from the database, returning an error if it is not found. // It is an error because if we know the parent block we should also know the parent state. // Retrieve any state that is advanced through to at most `block.slot()`: this is // particularly important if `block` descends from the finalized/split block, but at a slot // prior to the finalized slot (which is invalid and inaccessible in our DB schema). - // let (parent_state_root, state) = chain .store .get_advanced_hot_state(root, block.slot(), parent_block.state_root())? diff --git a/beacon_node/beacon_chain/src/block_verification_types.rs b/beacon_node/beacon_chain/src/block_verification_types.rs index be73ef15d7..f97e695b29 100644 --- a/beacon_node/beacon_chain/src/block_verification_types.rs +++ b/beacon_node/beacon_chain/src/block_verification_types.rs @@ -2,10 +2,11 @@ use crate::data_availability_checker::{AvailabilityCheckError, DataAvailabilityC pub use crate::data_availability_checker::{ AvailableBlock, AvailableBlockData, MaybeAvailableBlock, }; +use crate::payload_envelope_verification::AvailableEnvelope; use crate::{BeaconChainTypes, PayloadVerificationOutcome}; -use educe::Educe; use state_processing::ConsensusContext; use std::fmt::{Debug, Formatter}; +use std::hash::{Hash, Hasher}; use std::sync::Arc; use types::data::BlobIdentifier; use types::{ @@ -45,38 +46,61 @@ impl LookupBlock { /// This includes any and all blobs/columns required, including zero if /// none are required. This can happen if the block is pre-deneb or if /// it's simply past the DA boundary. -#[derive(Clone, Educe)] -#[educe(Hash(bound(E: EthSpec)))] -pub struct RangeSyncBlock { - block: AvailableBlock, +#[derive(Clone)] +pub enum RangeSyncBlock { + Base(AvailableBlock), + Gloas { + block: Arc>, + envelope: Option>>, + }, +} + +impl Hash for RangeSyncBlock { + fn hash(&self, state: &mut H) { + self.block_root().hash(state); + } } impl Debug for RangeSyncBlock { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "RpcBlock({:?})", self.block_root()) + write!(f, "RangeSyncBlock({:?})", self.block_root()) } } impl RangeSyncBlock { pub fn block_root(&self) -> Hash256 { - self.block.block_root() + match self { + RangeSyncBlock::Base(block) => block.block_root(), + RangeSyncBlock::Gloas { block, .. } => block.canonical_root(), + } } pub fn as_block(&self) -> &SignedBeaconBlock { - self.block.block() + match self { + RangeSyncBlock::Base(block) => block.block(), + RangeSyncBlock::Gloas { block, .. } => block, + } } pub fn block_cloned(&self) -> Arc> { - self.block.block_cloned() + match self { + RangeSyncBlock::Base(block) => block.block_cloned(), + RangeSyncBlock::Gloas { block, .. } => block.clone(), + } } pub fn block_data(&self) -> &AvailableBlockData { - self.block.data() + match self { + RangeSyncBlock::Base(block) => block.data(), + RangeSyncBlock::Gloas { .. } => { + unreachable!("block_data called on Gloas variant — use envelope data instead") + } + } } } impl RangeSyncBlock { - /// Constructs an `RangeSyncBlock` from a block and availability data. + /// Constructs a `RangeSyncBlock` from a block and availability data. /// /// # Errors /// @@ -94,32 +118,53 @@ impl RangeSyncBlock { T: BeaconChainTypes, { let available_block = AvailableBlock::new(block, block_data, da_checker, spec)?; - Ok(Self { - block: available_block, - }) + Ok(Self::Base(available_block)) + } + + pub fn new_gloas( + block: Arc>, + envelope: Option>>, + ) -> Self { + Self::Gloas { block, envelope } } #[allow(clippy::type_complexity)] pub fn deconstruct(self) -> (Hash256, Arc>, AvailableBlockData) { - self.block.deconstruct() + match self { + RangeSyncBlock::Base(block) => block.deconstruct(), + RangeSyncBlock::Gloas { .. } => { + unreachable!("deconstruct called on Gloas variant") + } + } } pub fn n_blobs(&self) -> usize { - match self.block_data() { - AvailableBlockData::NoData | AvailableBlockData::DataColumns(_) => 0, - AvailableBlockData::Blobs(blobs) => blobs.len(), + match self { + RangeSyncBlock::Base(block) => match block.data() { + AvailableBlockData::NoData | AvailableBlockData::DataColumns(_) => 0, + AvailableBlockData::Blobs(blobs) => blobs.len(), + }, + RangeSyncBlock::Gloas { .. } => 0, } } pub fn n_data_columns(&self) -> usize { - match self.block_data() { - AvailableBlockData::NoData | AvailableBlockData::Blobs(_) => 0, - AvailableBlockData::DataColumns(columns) => columns.len(), + match self { + RangeSyncBlock::Base(block) => match block.data() { + AvailableBlockData::NoData | AvailableBlockData::Blobs(_) => 0, + AvailableBlockData::DataColumns(columns) => columns.len(), + }, + RangeSyncBlock::Gloas { .. } => 0, } } pub fn into_available_block(self) -> AvailableBlock { - self.block + match self { + RangeSyncBlock::Base(block) => block, + RangeSyncBlock::Gloas { .. } => { + unreachable!("into_available_block called on Gloas variant") + } + } } } @@ -387,31 +432,31 @@ impl AsBlock for AvailableBlock { impl AsBlock for RangeSyncBlock { fn slot(&self) -> Slot { - self.as_block().slot() + RangeSyncBlock::as_block(self).slot() } fn epoch(&self) -> Epoch { - self.as_block().epoch() + RangeSyncBlock::as_block(self).epoch() } fn parent_root(&self) -> Hash256 { - self.as_block().parent_root() + RangeSyncBlock::as_block(self).parent_root() } fn state_root(&self) -> Hash256 { - self.as_block().state_root() + RangeSyncBlock::as_block(self).state_root() } fn signed_block_header(&self) -> SignedBeaconBlockHeader { - self.as_block().signed_block_header() + RangeSyncBlock::as_block(self).signed_block_header() } fn message(&self) -> BeaconBlockRef<'_, E> { - self.as_block().message() + RangeSyncBlock::as_block(self).message() } fn as_block(&self) -> &SignedBeaconBlock { - self.block.as_block() + RangeSyncBlock::as_block(self) } fn block_cloned(&self) -> Arc> { - self.block.block_cloned() + RangeSyncBlock::block_cloned(self) } fn canonical_root(&self) -> Hash256 { - self.block.block_root() + self.block_root() } } diff --git a/beacon_node/beacon_chain/src/builder.rs b/beacon_node/beacon_chain/src/builder.rs index 61c026e0a9..775f1bba95 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -47,8 +47,8 @@ use tracing::{debug, error, info, warn}; use tree_hash::TreeHash; use types::data::CustodyIndex; use types::{ - BeaconState, BlobSidecarList, ChainSpec, ColumnIndex, DataColumnSidecarList, Epoch, EthSpec, - Hash256, SignedBeaconBlock, Slot, + BeaconBlock, BeaconState, BlobSidecarList, ChainSpec, ColumnIndex, DataColumnSidecarList, + Epoch, EthSpec, Hash256, SignedBeaconBlock, Slot, }; /// An empty struct used to "witness" all the `BeaconChainTypes` traits. It has no user-facing @@ -1188,9 +1188,21 @@ fn make_genesis_block( genesis_state: &mut BeaconState, spec: &ChainSpec, ) -> Result, String> { + // For Gloas, genesis_block() populates the bid in the block body. However, if + // the genesis state was produced by an external tool (e.g. ethereum-genesis-generator), + // its latest_block_header.body_root may correspond to an empty block. In that case, + // use an empty block so the stored block root matches what fork choice derives from + // the state's latest_block_header. let mut block = genesis_block(genesis_state, spec) .map_err(|e| format!("Error building genesis block: {:?}", e))?; + let state_body_root = genesis_state.latest_block_header().body_root; + if state_body_root != block.body_root() + && state_body_root == BeaconBlock::::empty(spec).body_root() + { + block = BeaconBlock::empty(spec); + } + *block.state_root_mut() = genesis_state .update_tree_hash_cache() .map_err(|e| format!("Error hashing genesis state: {:?}", e))?; diff --git a/beacon_node/beacon_chain/src/data_availability_checker.rs b/beacon_node/beacon_chain/src/data_availability_checker.rs index cfd8ee7d34..f41f745482 100644 --- a/beacon_node/beacon_chain/src/data_availability_checker.rs +++ b/beacon_node/beacon_chain/src/data_availability_checker.rs @@ -21,7 +21,7 @@ use tracing::{debug, error, instrument}; use types::data::{BlobIdentifier, FixedBlobSidecarList, PartialDataColumn}; use types::{ BlobSidecar, BlobSidecarList, BlockImportSource, ChainSpec, DataColumnSidecar, - DataColumnSidecarList, Epoch, EthSpec, Hash256, PartialDataColumnSidecarError, + DataColumnSidecarList, Epoch, EthSpec, ForkName, Hash256, PartialDataColumnSidecarError, PartialDataColumnSidecarRef, SignedBeaconBlock, Slot, new_non_zero_usize, }; @@ -539,6 +539,11 @@ impl DataAvailabilityChecker { self.da_check_required_for_epoch(epoch) && self.spec.is_peer_das_enabled_for_epoch(epoch) } + /// Determines if execution payload envelopes are required for an epoch (Gloas and later). + pub fn envelopes_required_for_epoch(&self, epoch: Epoch) -> bool { + self.spec.fork_name_at_epoch(epoch) >= ForkName::Gloas + } + /// See `Self::blobs_required_for_epoch` fn blobs_required_for_block(&self, block: &SignedBeaconBlock) -> bool { block.num_expected_blobs() > 0 && self.blobs_required_for_epoch(block.epoch()) diff --git a/beacon_node/beacon_chain/src/data_column_verification.rs b/beacon_node/beacon_chain/src/data_column_verification.rs index 45cd687b36..f05e004ec5 100644 --- a/beacon_node/beacon_chain/src/data_column_verification.rs +++ b/beacon_node/beacon_chain/src/data_column_verification.rs @@ -29,9 +29,9 @@ use types::data::{ PartialDataColumnSidecarError, }; use types::{ - BeaconStateError, ChainSpec, DataColumnSidecar, DataColumnSubnetId, EthSpec, Hash256, - KzgCommitment, PartialDataColumnSidecarRef, SignedBeaconBlockHeader, SignedExecutionPayloadBid, - Slot, + BeaconStateError, ChainSpec, DataColumnSidecar, DataColumnSidecarFulu, DataColumnSidecarGloas, + DataColumnSubnetId, EthSpec, Hash256, KzgCommitment, PartialDataColumnSidecarRef, + SignedBeaconBlockHeader, SignedExecutionPayloadBid, Slot, }; /// An error occurred while validating a gossip data column. @@ -1078,6 +1078,16 @@ pub fn validate_data_column_sidecar_for_gossip_gloas< *data_column.index(), )); } + + if !chain + .spec + .fork_name_at_slot::(column_slot) + .gloas_enabled() + { + return Err(GossipDataColumnError::InvalidVariant); + } + + verify_index_matches_subnet(&data_column, subnet, &chain.spec)?; verify_sidecar_not_from_future_slot(chain, column_slot)?; verify_slot_greater_than_latest_finalized_slot(chain, column_slot)?; diff --git a/beacon_node/beacon_chain/src/fetch_blobs/fetch_blobs_beacon_adapter.rs b/beacon_node/beacon_chain/src/fetch_blobs/fetch_blobs_beacon_adapter.rs index f5ba647fce..31852a4a61 100644 --- a/beacon_node/beacon_chain/src/fetch_blobs/fetch_blobs_beacon_adapter.rs +++ b/beacon_node/beacon_chain/src/fetch_blobs/fetch_blobs_beacon_adapter.rs @@ -136,7 +136,7 @@ impl FetchBlobsBeaconAdapter { self.chain .process_engine_blobs(slot, block_root, blobs) .await - .map_err(FetchEngineBlobError::BlobProcessingError) + .map_err(|e| FetchEngineBlobError::BlobProcessingError(Box::new(e))) } pub(crate) fn fork_choice_contains_block(&self, block_root: &Hash256) -> bool { diff --git a/beacon_node/beacon_chain/src/fetch_blobs/mod.rs b/beacon_node/beacon_chain/src/fetch_blobs/mod.rs index 351e35666a..f6e0b9345e 100644 --- a/beacon_node/beacon_chain/src/fetch_blobs/mod.rs +++ b/beacon_node/beacon_chain/src/fetch_blobs/mod.rs @@ -50,7 +50,7 @@ pub enum EngineGetBlobsOutput { pub enum FetchEngineBlobError { BeaconStateError(BeaconStateError), BeaconChainError(Box), - BlobProcessingError(BlockError), + BlobProcessingError(Box), BlobSidecarError(BlobSidecarError), DataColumnSidecarError(DataColumnSidecarError), ExecutionLayerMissing, diff --git a/beacon_node/beacon_chain/src/historical_blocks.rs b/beacon_node/beacon_chain/src/historical_blocks.rs index bfda52558e..938fbc4104 100644 --- a/beacon_node/beacon_chain/src/historical_blocks.rs +++ b/beacon_node/beacon_chain/src/historical_blocks.rs @@ -1,3 +1,4 @@ +use crate::block_verification_types::{AsBlock, RangeSyncBlock}; use crate::data_availability_checker::{AvailableBlock, AvailableBlockData}; use crate::{BeaconChain, BeaconChainTypes, WhenSlotSkipped, metrics}; use fixed_bytes::FixedBytesExtended; @@ -8,12 +9,13 @@ use state_processing::{ }; use std::borrow::Cow; use std::iter; +use std::sync::Arc; use std::time::Duration; use store::metadata::DataColumnInfo; use store::{AnchorInfo, BlobInfo, DBColumn, Error as StoreError, KeyValueStore, KeyValueStoreOp}; use strum::IntoStaticStr; use tracing::{debug, debug_span, instrument}; -use types::{Hash256, Slot}; +use types::{Hash256, SignedExecutionPayloadEnvelope, Slot}; /// Use a longer timeout on the pubkey cache. /// @@ -315,4 +317,209 @@ impl BeaconChain { Ok(num_relevant) } + + /// Store a batch of historical GLOaS blocks in the database. + /// + /// Similar to `import_historical_block_batch` but handles `RangeSyncBlock::Gloas` variants, + /// storing both the beacon block and the execution payload envelope. + /// + /// The `blocks` should be given in slot-ascending order. Block root verification, + /// signature verification, and anchor updates follow the same logic as the pre-GLOaS path. + #[instrument(skip_all)] + pub fn import_historical_gloas_block_batch( + &self, + mut blocks: Vec>, + ) -> Result { + let anchor_info = self.store.get_anchor_info(); + + // Take all blocks with slots less than or equal to the oldest block slot. + let num_relevant = blocks.partition_point(|block| { + block.slot() <= anchor_info.oldest_block_slot + }); + + let total_blocks = blocks.len(); + blocks.truncate(num_relevant); + let blocks_to_import = blocks; + + if blocks_to_import.len() != total_blocks { + debug!( + oldest_block_slot = %anchor_info.oldest_block_slot, + total_blocks, + ignored = total_blocks.saturating_sub(blocks_to_import.len()), + "Ignoring some historic GLOaS blocks" + ); + } + + if blocks_to_import.is_empty() { + return Ok(0); + } + + let mut expected_block_root = anchor_info.oldest_block_parent; + let mut last_block_root = expected_block_root; + let mut prev_block_slot = anchor_info.oldest_block_slot; + + let mut cold_batch = Vec::with_capacity(blocks_to_import.len()); + let mut hot_batch = Vec::with_capacity(blocks_to_import.len()); + let mut signed_blocks = Vec::with_capacity(blocks_to_import.len()); + let mut envelopes_to_store: Vec<(Hash256, Arc>)> = + Vec::new(); + + for range_block in blocks_to_import.into_iter().rev() { + let block_root = range_block.block_root(); + let block = range_block.block_cloned(); + + // Extract envelope if this is a GLOaS block with one. + if let RangeSyncBlock::Gloas { + envelope: Some(available_envelope), + .. + } = range_block + { + let (signed_envelope, _columns) = available_envelope.deconstruct(); + envelopes_to_store.push((block_root, signed_envelope)); + } + + if block.slot() == anchor_info.oldest_block_slot { + // When reimporting, verify that this is actually the same block (same block root). + let oldest_block_root = self + .block_root_at_slot(block.slot(), WhenSlotSkipped::None) + .ok() + .flatten() + .ok_or(HistoricalBlockError::MissingOldestBlockRoot { slot: block.slot() })?; + if block_root != oldest_block_root { + return Err(HistoricalBlockError::MismatchedBlockRoot { + block_root, + expected_block_root: oldest_block_root, + }); + } + + debug!( + ?block_root, + slot = %block.slot(), + "Re-importing historic GLOaS block" + ); + last_block_root = block_root; + } else if block_root != expected_block_root { + return Err(HistoricalBlockError::MismatchedBlockRoot { + block_root, + expected_block_root, + }); + } + + // Store block in the hot database. + // GLOaS blocks always have their payload in the envelope, so we store blinded. + let blinded_block = block.clone_as_blinded(); + self.store.blinded_block_as_kv_store_ops( + &block_root, + &blinded_block, + &mut hot_batch, + ); + + // Store block roots, including at all skip slots in the freezer DB. + for slot in (block.slot().as_u64()..prev_block_slot.as_u64()).rev() { + cold_batch.push(KeyValueStoreOp::PutKeyValue( + DBColumn::BeaconBlockRoots, + slot.to_be_bytes().to_vec(), + block_root.as_slice().to_vec(), + )); + } + + prev_block_slot = block.slot(); + expected_block_root = block.message().parent_root(); + signed_blocks.push(block); + + // If we've reached genesis, add the genesis block root to the batch. + if expected_block_root == self.genesis_block_root { + let genesis_slot = self.spec.genesis_slot; + for slot in genesis_slot.as_u64()..prev_block_slot.as_u64() { + cold_batch.push(KeyValueStoreOp::PutKeyValue( + DBColumn::BeaconBlockRoots, + slot.to_be_bytes().to_vec(), + self.genesis_block_root.as_slice().to_vec(), + )); + } + prev_block_slot = genesis_slot; + expected_block_root = Hash256::zero(); + break; + } + } + // Blocks were pushed in reverse order so reverse again. + signed_blocks.reverse(); + + // Verify signatures in one batch. + let sig_timer = metrics::start_timer(&metrics::BACKFILL_SIGNATURE_TOTAL_TIMES); + let setup_timer = metrics::start_timer(&metrics::BACKFILL_SIGNATURE_SETUP_TIMES); + let pubkey_cache = self + .validator_pubkey_cache + .try_read_for(PUBKEY_CACHE_LOCK_TIMEOUT) + .ok_or(HistoricalBlockError::ValidatorPubkeyCacheTimeout)?; + let block_roots = signed_blocks + .get(1..) + .ok_or(HistoricalBlockError::IndexOutOfBounds)? + .iter() + .map(|block| block.parent_root()) + .chain(iter::once(last_block_root)); + let signature_set = signed_blocks + .iter() + .zip_eq(block_roots) + .filter(|&(_block, block_root)| block_root != self.genesis_block_root) + .map(|(block, block_root)| { + block_proposal_signature_set_from_parts( + block, + Some(block_root), + block.message().proposer_index(), + &self.spec.fork_at_epoch(block.message().epoch()), + self.genesis_validators_root, + |validator_index| pubkey_cache.get(validator_index).cloned().map(Cow::Owned), + &self.spec, + ) + }) + .collect::, _>>() + .map_err(HistoricalBlockError::SignatureSet) + .map(ParallelSignatureSets::from)?; + drop(pubkey_cache); + drop(setup_timer); + + let verify_timer = metrics::start_timer(&metrics::BACKFILL_SIGNATURE_VERIFY_TIMES); + if !signature_set.verify() { + return Err(HistoricalBlockError::InvalidSignature); + } + drop(verify_timer); + drop(sig_timer); + + // Write envelopes to the hot DB. + for (block_root, signed_envelope) in &envelopes_to_store { + self.store + .put_payload_envelope(block_root, signed_envelope)?; + } + + // Write the block batches to disk. + { + let _span = debug_span!("backfill_write_hot_db").entered(); + self.store.hot_db.do_atomically(hot_batch)?; + } + { + let _span = debug_span!("backfill_write_cold_db").entered(); + self.store.cold_db.do_atomically(cold_batch)?; + } + + // Update the anchor. + let new_anchor = AnchorInfo { + oldest_block_slot: prev_block_slot, + oldest_block_parent: expected_block_root, + ..anchor_info + }; + let backfill_complete = new_anchor.block_backfill_complete(self.genesis_backfill_slot); + let anchor_batch = vec![ + self.store + .compare_and_set_anchor_info(anchor_info, new_anchor)?, + ]; + self.store.hot_db.do_atomically(anchor_batch)?; + + // If backfill has completed, trigger reconstruction. + if backfill_complete && self.genesis_backfill_slot == Slot::new(0) && self.config.archive { + self.store_migrator.process_reconstruction(); + } + + Ok(num_relevant) + } } diff --git a/beacon_node/beacon_chain/src/kzg_utils.rs b/beacon_node/beacon_chain/src/kzg_utils.rs index bc803efe93..fe0ffbe034 100644 --- a/beacon_node/beacon_chain/src/kzg_utils.rs +++ b/beacon_node/beacon_chain/src/kzg_utils.rs @@ -673,13 +673,24 @@ pub fn reconstruct_blobs( return Err("data_columns should have at least one element".to_string()); } + let first_data_column = &data_columns[0]; + let blob_indices: Vec = match blob_indices_opt { Some(indices) => indices.into_iter().map(|i| i as usize).collect(), None => { - let num_of_blobs = signed_block - .message() - .blob_kzg_commitments_len() - .ok_or_else(|| "Block does not have blob KZG commitments".to_string())?; + // Fulu columns carry commitments inline; Gloas columns don't, so fall back to + // the block's payload bid commitments. + let num_of_blobs = match first_data_column.kzg_commitments() { + Ok(commitments) => commitments.len(), + Err(_) => signed_block + .message() + .body() + .signed_execution_payload_bid() + .map(|bid| bid.message.blob_kzg_commitments.len()) + .map_err(|_| { + "Gloas blob reconstruction: block missing payload bid".to_string() + })?, + }; (0..num_of_blobs).collect() } }; diff --git a/beacon_node/beacon_chain/src/metrics.rs b/beacon_node/beacon_chain/src/metrics.rs index df1b005820..c34b9ddb83 100644 --- a/beacon_node/beacon_chain/src/metrics.rs +++ b/beacon_node/beacon_chain/src/metrics.rs @@ -2158,7 +2158,7 @@ pub fn scrape_for_metrics(beacon_chain: &BeaconChain) { ); set_gauge_by_usize( &PENDING_PAYLOAD_CACHE_SIZE, - beacon_chain.pending_payload_cache.cache_size(), + beacon_chain.pending_payload_cache.block_cache_size(), ); if let Some((size, num_lookups)) = beacon_chain.pre_finalization_block_cache.metrics() { 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 73ddb43273..88b021e8a6 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs @@ -9,12 +9,14 @@ use tracing::{debug, error, info, info_span, instrument, warn}; use types::{BlockImportSource, Hash256, SignedExecutionPayloadEnvelope}; use super::{ - AvailableEnvelope, AvailableExecutedEnvelope, EnvelopeError, + AvailableEnvelope, AvailableExecutedEnvelope, EnvelopeError, EnvelopeImportData, gossip_verified_envelope::GossipVerifiedEnvelope, }; +use crate::data_column_verification::load_gloas_payload_bid; use crate::{ AvailabilityProcessingStatus, BeaconChain, BeaconChainError, BeaconChainTypes, BlockError, NotifyExecutionLayer, + block_verification::PayloadVerificationOutcome, block_verification_types::AvailableBlockData, metrics, payload_envelope_verification::{ @@ -160,9 +162,12 @@ impl BeaconChain { envelope: AvailabilityPendingExecutedEnvelope, ) -> Result { let slot = envelope.envelope.slot(); + let block_root = envelope.block_root; + let bid = load_gloas_payload_bid(block_root, self)? + .ok_or(BlockError::EnvelopeBlockRootUnknown(block_root))?; let availability = self .pending_payload_cache - .put_executed_payload_envelope(envelope)?; + .put_executed_payload_envelope(bid, envelope)?; self.process_payload_envelope_availability(slot, availability, || Ok(())) .await } @@ -187,13 +192,11 @@ impl BeaconChain { .map_err(BeaconChainError::TokioJoin)? .ok_or(BeaconChainError::RuntimeShutdown)??; - // TODO(gloas): optimistic sync is not supported for Gloas, maybe we could re-add it - if payload_verification_outcome - .payload_verification_status - .is_optimistic() - { - return Err(BlockError::OptimisticSyncNotSupported { block_root }); - } + // NOTE: We allow optimistic (SYNCING) payload verification status here. + // This can happen when the EL is still catching up (e.g., after range sync imports + // blocks that the EL hasn't validated yet). The envelope import will proceed and + // fork choice will mark the payload as received. If the payload is later found to + // be invalid, the normal invalidation mechanism will handle it. Ok(AvailabilityPendingExecutedEnvelope::new( signed_envelope, @@ -202,6 +205,39 @@ impl BeaconChain { )) } + /// Import an envelope whose data column availability has not yet been satisfied. + /// + /// Marks the block's payload as received in fork choice and persists the envelope to the + /// store, but does not write data column ops. Columns are expected to arrive separately + /// (gossip, engineGetBlobs, or reconstruction). + #[instrument(skip_all)] + pub async fn import_pending_execution_payload_envelope( + self: &Arc, + signed_envelope: Arc>, + import_data: EnvelopeImportData, + payload_verification_outcome: PayloadVerificationOutcome, + ) -> Result { + let EnvelopeImportData { + block_root, + _phantom, + } = import_data; + let block_root = { + let chain = self.clone(); + self.spawn_blocking_handle( + move || { + chain.import_execution_payload_envelope_pending_columns( + signed_envelope, + block_root, + payload_verification_outcome.payload_verification_status, + ) + }, + "payload_verification_handle", + ) + .await?? + }; + Ok(AvailabilityProcessingStatus::Imported(block_root)) + } + #[instrument(skip_all)] pub async fn import_available_execution_payload_envelope( self: &Arc, @@ -231,6 +267,50 @@ impl BeaconChain { Ok(AvailabilityProcessingStatus::Imported(block_root)) } + /// Same as `import_execution_payload_envelope` but for envelopes whose data columns + /// have not yet been received. Marks the payload as received in fork choice and + /// persists the envelope; columns are persisted separately as they arrive. + #[instrument(skip_all)] + fn import_execution_payload_envelope_pending_columns( + &self, + signed_envelope: Arc>, + block_root: Hash256, + payload_verification_status: PayloadVerificationStatus, + ) -> Result { + let fork_choice_reader = self.canonical_head.fork_choice_upgradable_read_lock(); + if !fork_choice_reader.contains_block(&block_root) { + return Err(EnvelopeError::BlockRootUnknown { block_root }); + } + + let mut fork_choice = parking_lot::RwLockUpgradableReadGuard::upgrade(fork_choice_reader); + fork_choice + .on_valid_payload_envelope_received(block_root) + .map_err(|e| EnvelopeError::InternalError(format!("{e:?}")))?; + + let db_write_timer = metrics::start_timer(&metrics::ENVELOPE_PROCESSING_DB_WRITE); + let ops = vec![StoreOp::PutPayloadEnvelope( + block_root, + signed_envelope.clone(), + )]; + let db_span = info_span!("persist_envelope_pending_columns").entered(); + if let Err(e) = self.store.do_atomically_with_block_and_blobs_cache(ops) { + error!(error = ?e, "Database write failed for pending-columns envelope"); + return Err(e.into()); + } + drop(db_span); + drop(fork_choice); + + let envelope_time_imported = self.slot_clock.now_duration().unwrap_or(Duration::MAX); + metrics::stop_timer(db_write_timer); + self.import_envelope_update_metrics_and_events( + signed_envelope, + block_root, + payload_verification_status, + envelope_time_imported, + ); + Ok(block_root) + } + /// Accepts a fully-verified and available envelope and imports it into the chain without performing any /// additional verification. /// @@ -388,4 +468,53 @@ impl BeaconChain { )); } } + + /// Import an execution payload envelope received via range sync. + /// + /// This is a simplified import path that trusts the envelope since it was fetched alongside + /// a valid block during range sync. It stores the envelope to the database and marks it as + /// received in fork choice. + pub fn import_envelope_from_range_sync( + &self, + envelope: AvailableEnvelope, + block_root: Hash256, + ) -> Result<(), BlockError> { + let fork_choice_reader = self.canonical_head.fork_choice_upgradable_read_lock(); + if !fork_choice_reader.contains_block(&block_root) { + return Err(BlockError::EnvelopeBlockRootUnknown(block_root)); + } + + let mut fork_choice = parking_lot::RwLockUpgradableReadGuard::upgrade(fork_choice_reader); + + fork_choice + .on_valid_payload_envelope_received(block_root) + .map_err(|e| BlockError::InternalError(format!("{e:?}")))?; + + let (signed_envelope, columns) = envelope.deconstruct(); + + let mut ops = vec![]; + + if let Some(blobs_or_columns_store_op) = self.get_blobs_or_columns_store_op( + block_root, + signed_envelope.slot(), + AvailableBlockData::DataColumns(columns), + ) { + ops.push(blobs_or_columns_store_op); + } + + ops.push(StoreOp::PutPayloadEnvelope(block_root, signed_envelope)); + + drop(fork_choice); + + if let Err(e) = self.store.do_atomically_with_block_and_blobs_cache(ops) { + error!( + msg = "Failed to store range sync envelope", + error = ?e, + "Database write failed!" + ); + return Err(e.into()); + } + + Ok(()) + } } diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs index a1e4e34eb6..80016b674b 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs @@ -18,7 +18,9 @@ //! //! ``` +use state_processing::BlockProcessingError; use state_processing::envelope_processing::EnvelopeProcessingError; +use std::marker::PhantomData; use std::sync::Arc; use store::Error as DBError; use strum::AsRefStr; @@ -40,18 +42,35 @@ mod payload_notifier; pub use execution_pending_envelope::ExecutionPendingEnvelope; -#[derive(Debug)] +// TODO(gloas): could remove this type completely, or remove the generic +#[derive(Debug, PartialEq)] +pub struct EnvelopeImportData { + pub block_root: Hash256, + _phantom: PhantomData, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] pub struct AvailableEnvelope { envelope: Arc>, pub columns: DataColumnSidecarList, + // TODO(gloas) this field is unread, do we need it? + #[expect(dead_code)] + /// Timestamp at which this envelope first became available (UNIX timestamp, time since 1970). + columns_available_timestamp: Option, } impl AvailableEnvelope { pub fn new( envelope: Arc>, columns: DataColumnSidecarList, + columns_available_timestamp: Option, ) -> Self { - Self { envelope, columns } + Self { + envelope, + columns, + columns_available_timestamp, + } } pub fn message(&self) -> &ExecutionPayloadEnvelope { @@ -90,6 +109,26 @@ pub struct AvailabilityPendingExecutedEnvelope { pub payload_verification_outcome: PayloadVerificationOutcome, } +/// A payload envelope that has gone through processing checks and execution by an EL client. +/// This envelope hasn't necessarily completed data availability checks. +/// +/// +/// It contains 2 variants: +/// 1. `Available`: This envelope has been executed and also contains all data to consider it +/// fully available. +/// 2. `AvailabilityPending`: This envelope hasn't received all required blobs to consider it +/// fully available. The envelope is still imported (fork-choice marks the block's payload +/// as received and the envelope is persisted); column persistence is handled separately +/// via gossip / engineGetBlobs as columns arrive. +pub enum ExecutedEnvelope { + Available(AvailableExecutedEnvelope), + AvailabilityPending { + signed_envelope: Arc>, + import_data: EnvelopeImportData, + payload_verification_outcome: PayloadVerificationOutcome, + }, +} + impl AvailabilityPendingExecutedEnvelope { pub fn new( envelope: Arc>, @@ -164,6 +203,14 @@ pub enum EnvelopeError { ExecutionPayloadError(ExecutionPayloadError), /// An error from importing the envelope. ImportError(BlockError), + /// A block processing error. + BlockProcessingError(BlockProcessingError), + /// A block error. + BlockError(BlockError), + /// An internal error. + InternalError(String), + /// Optimistic sync is not supported. + OptimisticSyncNotSupported { block_root: Hash256 }, } impl std::fmt::Display for EnvelopeError { diff --git a/beacon_node/beacon_chain/src/pending_payload_cache/mod.rs b/beacon_node/beacon_chain/src/pending_payload_cache/mod.rs index 2100a5fe9f..881da9fd34 100644 --- a/beacon_node/beacon_chain/src/pending_payload_cache/mod.rs +++ b/beacon_node/beacon_chain/src/pending_payload_cache/mod.rs @@ -2,28 +2,30 @@ //! over gossip/p2p we insert its bid into this cache, keyed by block root. As soon as the bid //! is received we can begin using it to verify data columns. //! -//! When a payload envelope is received and executed against the EL, it is inserted into this cache. -//! Once all required custody columns have been kzg verified and the envelope has been executed we can -//! import the envelope into fork choice and store it to disk. +//! When a payload envelope is received over gossip/p2p we first insert it as a pre-executed envelope. A separate +//! thread eventually executes the payload envelope against the EL. Assuming the payload is executed successfully +//! the envelope is updated in the cache from `PreExecuted` -> `Executed`. Once all required custody columns +//! have been kzg verified and the envelope has been executed we can import the envelope into fork choice and store it to disk. //! -//! Note that the block must have arrived before the envelope or data columns can reach this cache. -//! Data columns require the bid (from the block) for verification. Columns that arrive before -//! the block are rejected with `BlockRootUnknown`. +//! Note that the block must have arrived before the envelope for the envelope to pass upstream verification checks and reach this cache. +//! However data columns can potentially arrive before the block. use crate::data_availability_checker::{AvailabilityCheckError, MissingCellsError}; use crate::payload_envelope_verification::{ AvailabilityPendingExecutedEnvelope, AvailableExecutedEnvelope, }; -use crate::{BeaconChainTypes, CustodyContext, metrics}; +use crate::{BeaconChain, BeaconChainTypes, CustodyContext, metrics}; use kzg::Kzg; use lru::LruCache; use parking_lot::{MappedRwLockReadGuard, RwLock, RwLockReadGuard, RwLockWriteGuard}; +use slot_clock::SlotClock; use std::collections::HashMap; use std::fmt; use std::fmt::Debug; use std::num::NonZeroUsize; use std::sync::Arc; -use tracing::{Span, debug, error, instrument}; +use task_executor::TaskExecutor; +use tracing::{Span, debug, error, instrument, trace}; use types::{ ChainSpec, ColumnIndex, DataColumnSidecar, DataColumnSidecarList, Epoch, EthSpec, Hash256, PartialDataColumnSidecarRef, @@ -174,14 +176,11 @@ impl PendingPayloadCache { /// Insert an executed payload envelope into the cache and performs an availability check pub fn put_executed_payload_envelope( &self, + bid: Arc>, executed_envelope: AvailabilityPendingExecutedEnvelope, ) -> Result, AvailabilityCheckError> { let epoch = executed_envelope.envelope.epoch(); let beacon_block_root = executed_envelope.envelope.beacon_block_root(); - let bid = self - .get_bid(&beacon_block_root) - .ok_or(AvailabilityCheckError::MissingBid(beacon_block_root))?; - let pending_components = self.update_pending_components(beacon_block_root, bid, |pending_components| { pending_components.insert_executed_payload_envelope(executed_envelope); @@ -203,7 +202,6 @@ impl PendingPayloadCache { } /// Inserts a bid into the pending payload cache. - /// This will silently drop the bid if a bid for this block root already exists in the cache. pub fn insert_bid(&self, block_root: Hash256, bid: Arc>) { let mut write_lock = self.availability_cache.write(); write_lock.get_or_insert_mut(block_root, || PendingComponents::new(block_root, bid)); @@ -215,11 +213,9 @@ impl PendingPayloadCache { pub fn put_rpc_custody_columns( &self, block_root: Hash256, + bid: Arc>, custody_columns: DataColumnSidecarList, ) -> Result, AvailabilityCheckError> { - let bid = self - .get_bid(&block_root) - .ok_or(AvailabilityCheckError::MissingBid(block_root))?; let kzg_verified_columns = KzgVerifiedDataColumn::from_batch_with_scoring_and_commitments( custody_columns, bid.message.blob_kzg_commitments.as_ref(), @@ -237,7 +233,7 @@ impl PendingPayloadCache { .map(KzgVerifiedCustodyDataColumn::from_asserted_custody) .collect::>(); - self.put_kzg_verified_custody_data_columns(block_root, &verified_custody_columns) + self.put_kzg_verified_custody_data_columns(block_root, bid, &verified_custody_columns) } /// Perform KZG verification on gossip verified custody columns and insert them into the cache. @@ -246,11 +242,9 @@ impl PendingPayloadCache { pub fn put_gossip_verified_data_columns( &self, block_root: Hash256, + bid: Arc>, data_columns: Vec>, ) -> Result, AvailabilityCheckError> { - let bid = self - .get_bid(&block_root) - .ok_or(AvailabilityCheckError::MissingBid(block_root))?; let epoch = bid.message.slot.epoch(T::EthSpec::slots_per_epoch()); let sampling_columns = self .custody_context @@ -261,7 +255,7 @@ impl PendingPayloadCache { .map(|c| KzgVerifiedCustodyDataColumn::from_asserted_custody(c.into_inner())) .collect::>(); - self.put_kzg_verified_custody_data_columns(block_root, &custody_columns) + self.put_kzg_verified_custody_data_columns(block_root, bid, &custody_columns) } /// Insert KZG verified columns into the cache. @@ -269,12 +263,9 @@ impl PendingPayloadCache { pub fn put_kzg_verified_custody_data_columns( &self, block_root: Hash256, + bid: Arc>, kzg_verified_data_columns: &[KzgVerifiedCustodyDataColumn], ) -> Result, AvailabilityCheckError> { - let bid = self - .get_bid(&block_root) - .ok_or(AvailabilityCheckError::MissingBid(block_root))?; - let pending_components = self.update_pending_components(block_root, bid.clone(), |pending_components| { pending_components.merge_data_columns(kzg_verified_data_columns) @@ -301,11 +292,8 @@ impl PendingPayloadCache { pub fn reconstruct_data_columns( &self, block_root: &Hash256, + bid: Arc>, ) -> Result, AvailabilityCheckError> { - let bid = self - .get_bid(block_root) - .ok_or(AvailabilityCheckError::MissingBid(*block_root))?; - let verified_data_columns = match self.check_and_set_reconstruction_started(block_root) { ReconstructColumnsDecision::Yes(verified_data_columns) => verified_data_columns, ReconstructColumnsDecision::No(reason) => { @@ -337,7 +325,12 @@ impl PendingPayloadCache { AvailabilityCheckError::ReconstructColumnsError(e) })?; - let slot = bid.message.slot; + let Some(slot) = all_data_columns.first().map(|d| d.as_data_column().slot()) else { + return Ok(DataColumnReconstructionResult::RecoveredColumnsNotImported( + "No new columns to import and publish", + )); + }; + let columns_to_sample = self .custody_context() .sampling_columns_for_epoch(slot.epoch(T::EthSpec::slots_per_epoch()), &self.spec); @@ -363,22 +356,33 @@ impl PendingPayloadCache { "Reconstructed columns" ); - self.put_kzg_verified_custody_data_columns(*block_root, &data_columns_to_import_and_publish) - .map(|availability| { - DataColumnReconstructionResult::Success(( - availability, - data_columns_to_import_and_publish - .into_iter() - .map(|d| d.clone_arc()) - .collect::>(), - )) - }) + self.put_kzg_verified_custody_data_columns( + *block_root, + bid, + &data_columns_to_import_and_publish, + ) + .map(|availability| { + DataColumnReconstructionResult::Success(( + availability, + data_columns_to_import_and_publish + .into_iter() + .map(|d| d.clone_arc()) + .collect::>(), + )) + }) } // ── Metrics ── + /// Collects metrics from the data availability checker. + pub fn metrics(&self) -> DataAvailabilityCheckerMetrics { + DataAvailabilityCheckerMetrics { + block_cache_size: self.block_cache_size(), + } + } + /// Number of pending component entries in memory in the cache. - pub fn cache_size(&self) -> usize { + pub fn block_cache_size(&self) -> usize { self.availability_cache.read().len() } @@ -503,19 +507,108 @@ impl PendingPayloadCache { } } +/// Helper struct to group data availability checker metrics. +pub struct DataAvailabilityCheckerMetrics { + pub block_cache_size: usize, +} + +pub fn start_availability_cache_maintenance_service( + executor: TaskExecutor, + chain: Arc>, +) { + if chain.spec.gloas_fork_epoch.is_some() { + let da_checker = chain.pending_payload_cache.clone(); + executor.spawn( + async move { availability_cache_maintenance_service(chain, da_checker).await }, + "availability_cache_service", + ); + } else { + trace!("Gloas fork not configured, not starting availability cache maintenance service"); + } +} + +async fn availability_cache_maintenance_service( + chain: Arc>, + da_checker: Arc>, +) { + let epoch_duration = chain.slot_clock.slot_duration() * T::EthSpec::slots_per_epoch() as u32; + loop { + match chain + .slot_clock + .duration_to_next_epoch(T::EthSpec::slots_per_epoch()) + { + Some(duration) => { + // this service should run 3/4 of the way through the epoch + let additional_delay = (epoch_duration * 3) / 4; + tokio::time::sleep(duration + additional_delay).await; + + let Some(gloas_fork_epoch) = chain.spec.gloas_fork_epoch else { + // shutdown service if gloas fork epoch not set + break; + }; + + debug!("Availability cache maintenance service firing"); + let Some(current_epoch) = chain + .slot_clock + .now() + .map(|slot| slot.epoch(T::EthSpec::slots_per_epoch())) + else { + continue; + }; + + if current_epoch < gloas_fork_epoch { + // we are not in gloas yet + continue; + } + + let finalized_epoch = chain + .canonical_head + .fork_choice_read_lock() + .finalized_checkpoint() + .epoch; + + let Some(min_epochs_for_blobs) = chain + .spec + .min_epoch_data_availability_boundary(current_epoch) + else { + // Shutdown service if deneb fork epoch not set. + break; + }; + + // any data belonging to an epoch before this should be pruned + let cutoff_epoch = std::cmp::max(finalized_epoch + 1, min_epochs_for_blobs); + + if let Err(e) = da_checker.do_maintenance(cutoff_epoch) { + error!(error = ?e,"Failed to maintain availability cache"); + } + } + None => { + error!("Failed to read slot clock"); + // If we can't read the slot clock, just wait another slot. + tokio::time::sleep(chain.slot_clock.slot_duration()).await; + } + }; + } +} + #[cfg(test)] mod data_availability_checker_tests { use super::*; use crate::block_verification::PayloadVerificationOutcome; - use crate::custody_context::NodeCustodyType; use crate::test_utils::{ - DiskHarnessType, NumBlobs, generate_data_column_indices_rand_order, - generate_rand_block_and_data_columns, get_kzg, + NumBlobs, generate_data_column_indices_rand_order, generate_rand_block_and_data_columns, + }; + use crate::{ + custody_context::NodeCustodyType, + test_utils::{BeaconChainHarness, DiskHarnessType}, }; use fork_choice::PayloadVerificationStatus; use logging::create_test_tracing_subscriber; - use types::test_utils::test_unstructured; + use rand::SeedableRng; + use rand::rngs::StdRng; + use store::{HotColdDB, StoreConfig, database::interface::BeaconNodeBackend}; + use tempfile::{TempDir, tempdir}; use types::{ ExecutionPayloadEnvelope, ExecutionPayloadGloas, ExecutionRequests, ForkName, MinimalEthSpec, SignedExecutionPayloadEnvelope, @@ -524,107 +617,83 @@ mod data_availability_checker_tests { type E = MinimalEthSpec; type T = DiskHarnessType; - const NUM_BLOBS: usize = 1; + const LOW_VALIDATOR_COUNT: usize = 32; + const RNG_SEED: u64 = 0xDEADBEEF; - /// Stand up a cache + a 1-blob Gloas block for the given custody type. The bid is registered - /// in the cache; `custody` is pre-filtered to the sampling subset. - fn setup(node_custody: NodeCustodyType) -> Setup { - setup_with(node_custody, NumBlobs::Number(NUM_BLOBS)) + fn gloas_spec() -> Arc { + Arc::new(ForkName::Gloas.make_genesis_spec(E::default_spec())) } - fn setup_zero_blob(node_custody: NodeCustodyType) -> Setup { - setup_with(node_custody, NumBlobs::Number(0)) + fn get_store_with_spec( + db_path: &TempDir, + spec: Arc, + ) -> Arc, BeaconNodeBackend>> { + let hot_path = db_path.path().join("hot_db"); + let cold_path = db_path.path().join("cold_db"); + let blobs_path = db_path.path().join("blobs_db"); + let config = StoreConfig::default(); + + HotColdDB::open( + &hot_path, + &cold_path, + &blobs_path, + |_, _, _| Ok(()), + config, + spec, + ) + .expect("disk store should initialize") } - fn setup_with(node_custody: NodeCustodyType, num_blobs: NumBlobs) -> Setup { + async fn get_gloas_chain( + db_path: &TempDir, + ) -> BeaconChainHarness> { + let spec = gloas_spec::(); + + let chain_store = get_store_with_spec::(db_path, spec.clone()); + let validators_keypairs = + types::test_utils::generate_deterministic_keypairs(LOW_VALIDATOR_COUNT); + BeaconChainHarness::builder(E::default()) + .spec(spec.clone()) + .keypairs(validators_keypairs) + .fresh_disk_store(chain_store) + .mock_execution_layer() + .build() + } + + async fn setup() -> (BeaconChainHarness, Arc>, TempDir) { create_test_tracing_subscriber(); - let spec = Arc::new(ForkName::Gloas.make_genesis_spec(E::default_spec())); - let kzg = get_kzg(&spec); + let chain_db_path = tempdir().expect("should get temp dir"); + let harness = get_gloas_chain::(&chain_db_path).await; + let spec = harness.spec.clone(); let custody_context = Arc::new(CustodyContext::::new( - node_custody, + NodeCustodyType::Fullnode, generate_data_column_indices_rand_order::(), &spec, )); + let cache = Arc::new( - PendingPayloadCache::::new(kzg, custody_context, spec.clone()) - .expect("create cache"), + PendingPayloadCache::::new(harness.chain.kzg.clone(), custody_context, spec.clone()) + .expect("should create cache"), ); - - let mut u = test_unstructured(); - let (block, columns) = - generate_rand_block_and_data_columns::(ForkName::Gloas, num_blobs, &mut u, &spec) - .expect("generate test block"); - let block_root = block.canonical_root(); - let bid = Arc::new( - block - .message() - .body() - .signed_execution_payload_bid() - .expect("Gloas block has bid") - .clone(), - ); - cache.insert_bid(block_root, bid.clone()); - - let epoch = bid.message.slot.epoch(E::slots_per_epoch()); - let sampling = cache - .custody_context() - .sampling_columns_for_epoch(epoch, &cache.spec); - let custody = columns - .into_iter() - .filter(|c| sampling.contains(c.index())) - .collect(); - - Setup { - cache, - block_root, - custody, - } + (harness, cache, chain_db_path) } - struct Setup { - cache: Arc>, - block_root: Hash256, - custody: DataColumnSidecarList, + fn make_test_signed_envelope(block_root: Hash256) -> Arc> { + Arc::new(SignedExecutionPayloadEnvelope { + message: ExecutionPayloadEnvelope { + payload: ExecutionPayloadGloas::default(), + execution_requests: ExecutionRequests::default(), + builder_index: 0, + beacon_block_root: block_root, + parent_beacon_block_root: Hash256::random(), + }, + signature: bls::Signature::infinity().expect("should create infinity sig"), + }) } - impl Setup { - fn put_envelope(&self) -> Availability { - self.cache - .put_executed_payload_envelope(executed_envelope(self.block_root)) - .expect("put envelope") - } - - fn put_columns(&self, columns: DataColumnSidecarList) -> Availability { - self.cache - .put_rpc_custody_columns(self.block_root, columns) - .expect("put columns") - } - - fn reconstruct(&self) -> Result, AvailabilityCheckError> { - self.cache.reconstruct_data_columns(&self.block_root) - } - - fn cached_indexes(&self) -> Vec { - self.cache - .cached_data_column_indexes(&self.block_root) - .expect("entry") - } - } - - /// Hand-rolled executed envelope with bypassed verification; the cache only inspects - /// `beacon_block_root` and the verification outcome, never the signature or payload. - fn executed_envelope(block_root: Hash256) -> AvailabilityPendingExecutedEnvelope { + fn make_test_executed_envelope(block_root: Hash256) -> AvailabilityPendingExecutedEnvelope { AvailabilityPendingExecutedEnvelope { - envelope: Arc::new(SignedExecutionPayloadEnvelope { - message: ExecutionPayloadEnvelope { - payload: ExecutionPayloadGloas::default(), - execution_requests: ExecutionRequests::default(), - builder_index: 0, - beacon_block_root: block_root, - parent_beacon_block_root: Hash256::random(), - }, - signature: bls::Signature::infinity().expect("infinity sig"), - }), + envelope: make_test_signed_envelope(block_root), block_root, payload_verification_outcome: PayloadVerificationOutcome { payload_verification_status: PayloadVerificationStatus::Verified, @@ -632,150 +701,191 @@ mod data_availability_checker_tests { } } - #[track_caller] - fn assert_missing(availability: Availability) { - assert!( - matches!(availability, Availability::MissingComponents(_)), - "expected MissingComponents, got {availability:?}", + fn init_block( + cache: &PendingPayloadCache, + spec: &ChainSpec, + num_blobs: NumBlobs, + seed: u64, + ) -> ( + Arc>, + Hash256, + DataColumnSidecarList, + ) { + let mut rng = StdRng::seed_from_u64(seed); + let (block, data_columns) = + generate_rand_block_and_data_columns::(ForkName::Gloas, num_blobs, &mut rng, spec); + let block_root = block.canonical_root(); + let bid = Arc::new( + block + .message() + .body() + .signed_execution_payload_bid() + .expect("should get payload bid") + .clone(), ); + cache.insert_bid(block_root, bid.clone()); + (bid, block_root, data_columns) } - #[track_caller] - fn assert_available(availability: Availability) -> Box> { - match availability { - Availability::Available(env) => env, - other => panic!("expected Available, got {other:?}"), + #[tokio::test] + async fn caches_and_deduplicates_columns() { + let (harness, cache, _path) = setup().await; + let (bid, block_root, data_columns) = + init_block(&cache, &harness.spec, NumBlobs::Number(1), RNG_SEED); + let epoch = bid.message.slot.epoch(E::slots_per_epoch()); + let sampling_cols = cache + .custody_context() + .sampling_columns_for_epoch(epoch, &harness.spec); + let column = data_columns + .iter() + .find(|c| sampling_cols.contains(c.index())) + .cloned() + .expect("should have a sampling column"); + let column_index = *column.index(); + + for _ in 0..2 { + cache + .put_rpc_custody_columns(block_root, bid.clone(), vec![column.clone()]) + .expect("should put column"); } + + assert_eq!( + cache.cached_data_column_indexes(&block_root), + Some(vec![column_index]) + ); + assert_eq!( + cache.get_data_columns(block_root).map(|cols| cols.len()), + Some(1) + ); + assert_eq!(cache.block_cache_size(), 1); } - // ─── Tier 1: real-path availability flows ─────────────────────────────── - - /// Envelope first → MissingComponents. Then all sampling columns → Available. #[tokio::test] - async fn availability_arrives_envelope_first() { - let s = setup(NodeCustodyType::Fullnode); - assert_missing(s.put_envelope()); - let envelope = assert_available(s.put_columns(s.custody.clone())); - assert_eq!(envelope.block_root, s.block_root); - assert_eq!(envelope.envelope.columns.len(), s.custody.len()); + async fn requires_columns_and_executed_envelope() { + let (harness, cache, _path) = setup().await; + let (bid, block_root, data_columns) = + init_block(&cache, &harness.spec, NumBlobs::Number(1), RNG_SEED); + + let epoch = bid.message.slot.epoch(E::slots_per_epoch()); + let num_sampling_columns = cache + .custody_context() + .sampling_columns_for_epoch(epoch, &harness.spec) + .len(); + + let result = cache + .put_rpc_custody_columns(block_root, bid.clone(), data_columns) + .expect("should put columns"); + assert!(matches!(result, Availability::MissingComponents(_))); + + let result = cache + .put_executed_payload_envelope(bid, make_test_executed_envelope(block_root)) + .expect("should put executed envelope"); + let Availability::Available(envelope) = result else { + panic!("expected available envelope"); + }; + assert_eq!(envelope.block_root, block_root); + assert_eq!(envelope.envelope.columns.len(), num_sampling_columns); } - /// Columns first → MissingComponents. Then envelope → Available. #[tokio::test] - async fn availability_arrives_columns_first() { - let s = setup(NodeCustodyType::Fullnode); - assert_missing(s.put_columns(s.custody.clone())); - let envelope = assert_available(s.put_envelope()); - assert_eq!(envelope.block_root, s.block_root); - assert_eq!(envelope.envelope.columns.len(), s.custody.len()); - } + async fn zero_blob_envelope_is_available_without_columns() { + let (harness, cache, _path) = setup().await; + let (bid, block_root, _columns) = + init_block(&cache, &harness.spec, NumBlobs::Number(0), RNG_SEED); - /// N-1 columns + envelope is still MissingComponents; the Nth column flips to Available. - /// Guards the strict count comparison in `make_available`. - #[tokio::test] - async fn partial_columns_then_complete() { - let mut s = setup(NodeCustodyType::Fullnode); - assert!(s.custody.len() >= 2, "needs at least 2 sampling columns"); - let last = s.custody.pop().expect("non-empty custody"); - - s.put_envelope(); - assert_missing(s.put_columns(s.custody.clone())); - assert_available(s.put_columns(vec![last])); - } - - /// Zero-blob block + envelope → Available. Guards the `num_blobs_expected == 0` early-return - /// in `make_available`. - #[tokio::test] - async fn zero_blob_envelope_immediately_available() { - let s = setup_zero_blob(NodeCustodyType::Fullnode); - let envelope = assert_available(s.put_envelope()); + let result = cache + .put_executed_payload_envelope(bid, make_test_executed_envelope(block_root)) + .expect("should put executed envelope"); + let Availability::Available(envelope) = result else { + panic!("zero-blob block should be available"); + }; assert!(envelope.envelope.columns.is_empty()); } - /// Receiving the same column twice keeps a single cache entry. Guards `PendingColumn::insert` - /// staying only-if-empty under repeated arrivals. #[tokio::test] - async fn dedups_repeated_column_inserts() { - let s = setup(NodeCustodyType::Fullnode); - let column = s.custody.first().cloned().expect("sampling column"); - let column_index = *column.index(); - s.put_columns(vec![column.clone()]); - s.put_columns(vec![column]); + async fn partial_columns_wait_for_missing_columns() { + let (harness, cache, _path) = setup().await; + let (bid, block_root, data_columns) = + init_block(&cache, &harness.spec, NumBlobs::Number(1), RNG_SEED); - assert_eq!(s.cached_indexes(), vec![column_index]); + cache + .put_executed_payload_envelope(bid.clone(), make_test_executed_envelope(block_root)) + .expect("should put executed envelope"); + + let columns = data_columns.into_iter().take(1).collect(); + let result = cache + .put_rpc_custody_columns(block_root, bid, columns) + .expect("should put columns"); + assert!(matches!(result, Availability::MissingComponents(_))); + } + + #[tokio::test] + async fn reconstruction_failure_clears_columns() { + let (harness, cache, _path) = setup().await; + let (bid, block_root, data_columns) = + init_block(&cache, &harness.spec, NumBlobs::Number(1), RNG_SEED); + + let epoch = bid.message.slot.epoch(E::slots_per_epoch()); + let sampling_cols = cache + .custody_context() + .sampling_columns_for_epoch(epoch, &harness.spec); + let columns: Vec<_> = data_columns + .into_iter() + .filter(|c| sampling_cols.contains(c.index())) + .take(5) + .collect(); + let num_columns = columns.len(); + + cache + .put_rpc_custody_columns(block_root, bid, columns) + .expect("should put columns"); assert_eq!( - s.cache.get_data_columns(s.block_root).map(|c| c.len()), - Some(1), + cache + .cached_data_column_indexes(&block_root) + .map(|indices| indices.len()), + Some(num_columns) ); + + cache.handle_reconstruction_failure(&block_root); + assert_eq!(cache.cached_data_column_indexes(&block_root), Some(vec![])); } - // ─── Tier 2: reconstruction state machine ─────────────────────────────── - // - // Reconstruction only triggers when `total/2 ≤ received < sampling_count`. Fullnode's small - // sampling count never satisfies this, so these tests use `Supernode`. - - /// Fewer than `number_of_columns / 2` columns received → reconstruction is `NotStarted`. #[tokio::test] - async fn reconstruction_below_threshold_is_not_started() { - let s = setup(NodeCustodyType::Supernode); - let half = E::number_of_columns() / 2; - s.put_columns(s.custody.iter().take(half - 1).cloned().collect()); - assert!(matches!( - s.reconstruct().expect("reconstruct call"), - DataColumnReconstructionResult::NotStarted("not enough columns") - )); + async fn lru_eviction_keeps_cache_bounded() { + let (harness, cache, _path) = setup().await; + let mut roots = Vec::new(); + + for i in 0..33 { + let (bid, block_root, data_columns) = + init_block(&cache, &harness.spec, NumBlobs::Number(1), RNG_SEED + i); + let column = data_columns.first().cloned().expect("should have column"); + roots.push(block_root); + cache + .put_rpc_custody_columns(block_root, bid, vec![column]) + .expect("should put columns"); + } + + assert_eq!(cache.block_cache_size(), 32); + assert!(cache.get_data_columns(roots[0]).is_none()); + assert!(cache.get_data_columns(*roots.last().unwrap()).is_some()); } - /// All sampling columns received → reconstruction unnecessary, returns `NotStarted`. #[tokio::test] - async fn reconstruction_already_complete_is_not_started() { - let s = setup(NodeCustodyType::Supernode); - s.put_columns(s.custody.clone()); - assert!(matches!( - s.reconstruct().expect("reconstruct call"), - DataColumnReconstructionResult::NotStarted("all sampling columns received") - )); - } + async fn maintenance_prunes_old_entries() { + let (harness, cache, _path) = setup().await; + let (bid, block_root, data_columns) = + init_block(&cache, &harness.spec, NumBlobs::Number(1), RNG_SEED); + let block_epoch = bid.message.slot.epoch(E::slots_per_epoch()); + let column = data_columns.first().cloned().expect("should have column"); - /// Envelope + 50% of sampling columns → reconstruction recovers the rest, the entry flips - /// to `Available`, and the cache holds every sampling column. - #[tokio::test] - async fn reconstruction_success_fills_missing_columns() { - let s = setup(NodeCustodyType::Supernode); - s.put_envelope(); - let sampling_count = s.custody.len(); - let half = sampling_count / 2; - s.put_columns(s.custody.iter().take(half).cloned().collect()); - assert_eq!(s.cached_indexes().len(), half); + cache + .put_rpc_custody_columns(block_root, bid, vec![column]) + .expect("should put columns"); - let result = s.reconstruct().expect("reconstruction must succeed"); - let (availability, _recovered) = match result { - DataColumnReconstructionResult::Success(inner) => inner, - other => panic!("expected Success, got {other:?}"), - }; - assert_available(availability); - assert_eq!(s.cached_indexes().len(), sampling_count); - } - - // ─── Tier 3: invariants ───────────────────────────────────────────────── - - /// `get_data_columns` and `cached_data_column_indexes` must agree on which columns are - /// complete. Drift between these two would corrupt the DB on import. - #[tokio::test] - async fn cached_columns_match_completed_indexes() { - let mut s = setup(NodeCustodyType::Fullnode); - let last = s.custody.pop().expect("non-empty custody"); - - let assert_lengths_match = |s: &Setup| { - let indexes_len = s.cached_indexes().len(); - let sidecars_len = s.cache.get_data_columns(s.block_root).expect("entry").len(); - assert_eq!(indexes_len, sidecars_len); - }; - - s.put_columns(s.custody.clone()); - assert_lengths_match(&s); - - s.put_columns(vec![last]); - assert_lengths_match(&s); + assert_eq!(cache.block_cache_size(), 1); + cache + .do_maintenance(block_epoch + 1) + .expect("maintenance should succeed"); + assert_eq!(cache.block_cache_size(), 0); } } diff --git a/beacon_node/beacon_chain/src/pending_payload_cache/pending_components.rs b/beacon_node/beacon_chain/src/pending_payload_cache/pending_components.rs index e7b9009577..091c007a80 100644 --- a/beacon_node/beacon_chain/src/pending_payload_cache/pending_components.rs +++ b/beacon_node/beacon_chain/src/pending_payload_cache/pending_components.rs @@ -137,7 +137,7 @@ impl PendingComponents { } }; - let available_envelope = AvailableEnvelope::new(envelope.clone(), columns); + let available_envelope = AvailableEnvelope::new(envelope.clone(), columns, None); Ok(Some(AvailableExecutedEnvelope { envelope: available_envelope, diff --git a/beacon_node/beacon_processor/src/lib.rs b/beacon_node/beacon_processor/src/lib.rs index d8f63ec561..aada17fa5e 100644 --- a/beacon_node/beacon_processor/src/lib.rs +++ b/beacon_node/beacon_processor/src/lib.rs @@ -424,6 +424,9 @@ pub enum Work { RpcBlobs { process_fn: AsyncFn, }, + RpcPayloadEnvelope { + process_fn: AsyncFn, + }, RpcCustodyColumn(AsyncFn), RpcEnvelope(AsyncFn), ColumnReconstruction(AsyncFn), @@ -493,6 +496,7 @@ pub enum WorkType { GossipLightClientOptimisticUpdate, RpcBlock, RpcBlobs, + RpcPayloadEnvelope, RpcCustodyColumn, RpcEnvelope, ColumnReconstruction, @@ -557,6 +561,7 @@ impl Work { Work::GossipProposerPreferences(_) => WorkType::GossipProposerPreferences, Work::RpcBlock { .. } => WorkType::RpcBlock, Work::RpcBlobs { .. } => WorkType::RpcBlobs, + Work::RpcPayloadEnvelope { .. } => WorkType::RpcPayloadEnvelope, Work::RpcCustodyColumn { .. } => WorkType::RpcCustodyColumn, Work::RpcEnvelope(_) => WorkType::RpcEnvelope, Work::ColumnReconstruction(_) => WorkType::ColumnReconstruction, @@ -1205,7 +1210,9 @@ impl BeaconProcessor { Work::GossipLightClientOptimisticUpdate { .. } => work_queues .lc_gossip_optimistic_update_queue .push(work, work_id), - Work::RpcBlock { .. } | Work::IgnoredRpcBlock { .. } => { + Work::RpcBlock { .. } + | Work::IgnoredRpcBlock { .. } + | Work::RpcPayloadEnvelope { .. } => { work_queues.rpc_block_queue.push(work, work_id) } Work::RpcBlobs { .. } => work_queues.rpc_blob_queue.push(work, work_id), @@ -1352,7 +1359,9 @@ impl BeaconProcessor { WorkType::GossipLightClientOptimisticUpdate => { work_queues.lc_gossip_optimistic_update_queue.len() } - WorkType::RpcBlock => work_queues.rpc_block_queue.len(), + WorkType::RpcBlock | WorkType::RpcPayloadEnvelope => { + work_queues.rpc_block_queue.len() + } WorkType::RpcBlobs | WorkType::IgnoredRpcBlock => { work_queues.rpc_blob_queue.len() } @@ -1550,6 +1559,7 @@ impl BeaconProcessor { beacon_block_root: _, } | Work::RpcBlobs { process_fn } + | Work::RpcPayloadEnvelope { process_fn } | Work::RpcCustodyColumn(process_fn) | Work::RpcEnvelope(process_fn) | Work::ColumnReconstruction(process_fn) => task_spawner.spawn_async(process_fn), diff --git a/beacon_node/client/src/builder.rs b/beacon_node/client/src/builder.rs index 0a3c414632..7a7d574085 100644 --- a/beacon_node/client/src/builder.rs +++ b/beacon_node/client/src/builder.rs @@ -5,8 +5,9 @@ use crate::compute_light_client_updates::{ use crate::config::{ClientGenesis, Config as ClientConfig}; use crate::notifier::spawn_notifier; use beacon_chain::attestation_simulator::start_attestation_simulator_service; -use beacon_chain::data_availability_checker::start_availability_cache_maintenance_service; +use beacon_chain::data_availability_checker::start_availability_cache_maintenance_service as start_block_cache_maintenance_service; use beacon_chain::graffiti_calculator::start_engine_version_cache_refresh_service; +use beacon_chain::pending_payload_cache::start_availability_cache_maintenance_service as start_payload_cache_maintenance_service; use beacon_chain::proposer_prep_service::start_proposer_prep_service; use beacon_chain::schema_change::migrate_schema; use beacon_chain::{ @@ -787,7 +788,11 @@ where } start_proposer_prep_service(runtime_context.executor.clone(), beacon_chain.clone()); - start_availability_cache_maintenance_service( + start_block_cache_maintenance_service( + runtime_context.executor.clone(), + beacon_chain.clone(), + ); + start_payload_cache_maintenance_service( runtime_context.executor.clone(), beacon_chain.clone(), ); diff --git a/beacon_node/http_api/src/beacon/execution_payload_envelope.rs b/beacon_node/http_api/src/beacon/execution_payload_envelope.rs index d8813b0db5..39063edf25 100644 --- a/beacon_node/http_api/src/beacon/execution_payload_envelope.rs +++ b/beacon_node/http_api/src/beacon/execution_payload_envelope.rs @@ -268,7 +268,8 @@ fn build_gloas_data_columns( let index = *col.index(); match GossipVerifiedDataColumn::new_for_block_publishing(col, chain) { Ok(verified) => Some(verified), - Err(GossipDataColumnError::PriorKnown { .. }) => None, + Err(GossipDataColumnError::PriorKnownUnpublished) + | Err(GossipDataColumnError::PriorKnown { .. }) => None, Err(e) => { warn!( %slot, diff --git a/beacon_node/lighthouse_network/src/service/api_types.rs b/beacon_node/lighthouse_network/src/service/api_types.rs index 2429b813e9..a32f0bcd40 100644 --- a/beacon_node/lighthouse_network/src/service/api_types.rs +++ b/beacon_node/lighthouse_network/src/service/api_types.rs @@ -23,8 +23,6 @@ pub enum SyncRequestId { SingleBlock { id: SingleLookupReqId }, /// Request searching for a set of blobs given a hash. SingleBlob { id: SingleLookupReqId }, - /// Request searching for a payload envelope given a hash. - SinglePayloadEnvelope { id: SingleLookupReqId }, /// Request searching for a set of data columns given a hash and list of column indices. DataColumnsByRoot(DataColumnsByRootRequestId), /// Blocks by range request @@ -33,6 +31,10 @@ pub enum SyncRequestId { BlobsByRange(BlobsByRangeRequestId), /// Data columns by range request DataColumnsByRange(DataColumnsByRangeRequestId), + /// Request searching for an execution payload envelope given a block root. + SinglePayloadEnvelope { id: SingleLookupReqId }, + /// Payload envelopes by range request + PayloadEnvelopesByRange(PayloadEnvelopesByRangeRequestId), } /// Request ID for data_columns_by_root requests. Block lookups do not issue this request directly. @@ -78,6 +80,14 @@ pub enum DataColumnsByRangeRequester { CustodyBackfillSync(CustodyBackFillBatchRequestId), } +#[derive(Debug, Hash, PartialEq, Eq, Clone, Copy)] +pub struct PayloadEnvelopesByRangeRequestId { + /// Id to identify this attempt at a payload_envelopes_by_range request for `parent_request_id` + pub id: Id, + /// The Id of the overall By Range request for block components. + pub parent_request_id: ComponentsByRangeRequestId, +} + /// Block components by range request for range sync. Includes an ID for downstream consumers to /// handle retries and tie all their sub requests together. #[derive(Debug, Hash, PartialEq, Eq, Clone, Copy)] @@ -261,6 +271,12 @@ macro_rules! impl_display { impl_display!(BlocksByRangeRequestId, "{}/{}", id, parent_request_id); impl_display!(BlobsByRangeRequestId, "{}/{}", id, parent_request_id); impl_display!(DataColumnsByRangeRequestId, "{}/{}", id, parent_request_id); +impl_display!( + PayloadEnvelopesByRangeRequestId, + "{}/{}", + id, + parent_request_id +); impl_display!(ComponentsByRangeRequestId, "{}/{}", id, requester); impl_display!(DataColumnsByRootRequestId, "{}/{}", id, requester); impl_display!(SingleLookupReqId, "{}/Lookup/{}", req_id, lookup_id); 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 fb8623adac..7aab66f81a 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -1816,6 +1816,17 @@ impl NetworkBeaconProcessor { self.send_sync_message(SyncMessage::UnknownParentBlock(peer_id, block, block_root)); return None; } + Err(BlockError::ParentEnvelopeUnknown { parent_root }) => { + debug!( + ?block_root, + ?parent_root, + "Parent envelope not yet available for gossip block" + ); + self.send_sync_message(SyncMessage::UnknownParentEnvelope( + peer_id, block, block_root, + )); + return None; + } Err(e @ BlockError::BeaconChainError(_)) => { debug!( error = ?e, @@ -1908,10 +1919,26 @@ impl NetworkBeaconProcessor { Err(e @ BlockError::InternalError(_)) | Err(e @ BlockError::BlobNotRequired(_)) | Err(e @ BlockError::EnvelopeBlockRootUnknown(_)) + | Err(e @ BlockError::PayloadEnvelopeError { .. }) | Err(e @ BlockError::OptimisticSyncNotSupported { .. }) => { error!(error = %e, "Internal block gossip validation error"); return None; } + Err(BlockError::ParentEnvelopeUnknown { .. }) => { + // Gossip validation does not check envelope availability; this should not occur. + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); + return None; + } + Err(e @ BlockError::EnvelopeError(_)) => { + debug!(error = %e, "Gossip block envelope error"); + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); + return None; + } + Err(e @ BlockError::PayloadEnvelopeError { .. }) => { + debug!(error = %e, "Gossip block payload envelope error"); + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); + return None; + } }; metrics::inc_counter(&metrics::BEACON_PROCESSOR_GOSSIP_BLOCK_VERIFIED_TOTAL); @@ -2105,6 +2132,16 @@ impl NetworkBeaconProcessor { "Block with unknown parent attempted to be processed" ); } + Err(BlockError::ParentEnvelopeUnknown { parent_root }) => { + debug!( + %block_root, + ?parent_root, + "Parent envelope not yet available, need envelope lookup" + ); + // Unlike ParentUnknown, this can legitimately happen during processing + // because the parent envelope may not have arrived yet. The lookup + // system will handle retrying via Action::ParentEnvelopeUnknown. + } Err(e @ BlockError::ExecutionPayloadError(epe)) if !epe.penalize_peer() => { debug!( error = %e, @@ -3977,7 +4014,11 @@ impl NetworkBeaconProcessor { EnvelopeError::PriorToFinalization { .. } | EnvelopeError::BeaconChainError(_) | EnvelopeError::BeaconStateError(_) - | EnvelopeError::ImportError(_) => { + | EnvelopeError::ImportError(_) + | EnvelopeError::BlockProcessingError(_) + | EnvelopeError::BlockError(_) + | EnvelopeError::InternalError(_) + | EnvelopeError::OptimisticSyncNotSupported { .. } => { self.propagate_validation_result( message_id, peer_id, @@ -4055,13 +4096,46 @@ impl NetworkBeaconProcessor { // TODO(gloas) metrics // register_process_result_metrics(&result, metrics::BlockSource::Gossip, "envelope"); - if let Err(e) = &result { - debug!( - ?beacon_block_root, - %peer_id, - error = ?e, - "Execution payload envelope processing failed" - ); + match &result { + Ok(AvailabilityProcessingStatus::Imported(block_root)) => { + // Notify sync so any pending child lookup awaiting this parent envelope unblocks. + self.send_sync_message(SyncMessage::GossipEnvelopeImported { + block_root: *block_root, + }); + } + Ok(AvailabilityProcessingStatus::MissingComponents(_slot, _block_root)) => { + // TODO(gloas): wire this into the envelope DA checker once it exists, analogous to + // how `process_availability` drives block import once blobs/columns arrive. Until + // then gossip envelopes with missing columns will be stuck until columns arrive via + // gossip or engineGetBlobs. + } + Err(e) => match e { + EnvelopeError::ExecutionPayloadError(epe) if !epe.penalize_peer() => {} + EnvelopeError::BadSignature + | EnvelopeError::BuilderIndexMismatch { .. } + | EnvelopeError::SlotMismatch { .. } + | EnvelopeError::BlockHashMismatch { .. } + | EnvelopeError::UnknownValidator { .. } + | EnvelopeError::IncorrectBlockProposer { .. } + | EnvelopeError::ExecutionPayloadError(_) + | EnvelopeError::EnvelopeProcessingError(_) => { + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "gossip_envelope_processing_low", + ); + } + + EnvelopeError::BlockRootUnknown { .. } + | EnvelopeError::PriorToFinalization { .. } + | EnvelopeError::BeaconChainError(_) + | EnvelopeError::BeaconStateError(_) + | EnvelopeError::ImportError(_) + | EnvelopeError::BlockProcessingError(_) + | EnvelopeError::BlockError(_) + | EnvelopeError::InternalError(_) + | EnvelopeError::OptimisticSyncNotSupported { .. } => {} + }, } } diff --git a/beacon_node/network/src/network_beacon_processor/mod.rs b/beacon_node/network/src/network_beacon_processor/mod.rs index b3c7d1d134..70de6ba559 100644 --- a/beacon_node/network/src/network_beacon_processor/mod.rs +++ b/beacon_node/network/src/network_beacon_processor/mod.rs @@ -565,6 +565,22 @@ impl NetworkBeaconProcessor { }) } + /// Create a new `Work` event for an RPC payload envelope. + pub fn send_rpc_payload_envelope( + self: &Arc, + envelope: Arc>, + seen_timestamp: Duration, + process_type: BlockProcessType, + ) -> Result<(), Error> { + let process_fn = + self.clone() + .generate_rpc_envelope_process_fn(envelope, seen_timestamp, process_type); + self.try_send(BeaconWorkEvent { + drop_during_sync: false, + work: Work::RpcPayloadEnvelope { process_fn }, + }) + } + /// Create a new `Work` event for some blobs, where the result from computation (if any) is /// sent to the other side of `result_tx`. pub fn send_rpc_blobs( @@ -1017,9 +1033,9 @@ impl NetworkBeaconProcessor { "Fetch blobs completed without import" ); } - Err(FetchEngineBlobError::BlobProcessingError(BlockError::DuplicateFullyImported( - .., - ))) => { + Err(FetchEngineBlobError::BlobProcessingError(e)) + if matches!(*e, BlockError::DuplicateFullyImported(..)) => + { debug!( %block_root, "Fetch blobs duplicate import" diff --git a/beacon_node/network/src/network_beacon_processor/sync_methods.rs b/beacon_node/network/src/network_beacon_processor/sync_methods.rs index e3ba6fb3c4..9f6f7e2ea4 100644 --- a/beacon_node/network/src/network_beacon_processor/sync_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/sync_methods.rs @@ -4,7 +4,7 @@ use crate::sync::BatchProcessResult; use crate::sync::manager::CustodyBatchProcessResult; use crate::sync::{ ChainId, - manager::{BlockProcessType, SyncMessage}, + manager::{BlockProcessType, BlockProcessingResult, SyncMessage}, }; use beacon_chain::block_verification_types::LookupBlock; use beacon_chain::block_verification_types::{AsBlock, RangeSyncBlock}; @@ -28,7 +28,9 @@ use store::KzgCommitment; use tracing::{debug, debug_span, error, info, instrument, warn}; use types::data::FixedBlobSidecarList; use types::kzg_ext::format_kzg_commitments; -use types::{BlockImportSource, DataColumnSidecarList, Epoch, Hash256}; +use types::{ + BlockImportSource, DataColumnSidecarList, Epoch, Hash256, SignedExecutionPayloadEnvelope, +}; /// Id associated to a batch processing request, either a sync batch or a parent lookup. #[derive(Clone, Debug, PartialEq)] @@ -73,6 +75,77 @@ impl NetworkBeaconProcessor { Box::pin(process_fn) } + /// Returns an async closure which processes a payload envelope received via RPC. + pub fn generate_rpc_envelope_process_fn( + self: Arc, + envelope: Arc>, + seen_timestamp: Duration, + process_type: BlockProcessType, + ) -> AsyncFn { + let process_fn = async move { + self.process_rpc_envelope(envelope, seen_timestamp, process_type) + .await; + }; + Box::pin(process_fn) + } + + /// Process an execution payload envelope received via RPC. + async fn process_rpc_envelope( + self: Arc, + envelope: Arc>, + _seen_timestamp: Duration, + process_type: BlockProcessType, + ) { + let beacon_block_root = envelope.beacon_block_root(); + + // Verify the envelope using the gossip verification path (same checks apply to RPC) + let verified_envelope = match self.chain.verify_envelope_for_gossip(envelope).await { + Ok(verified) => verified, + Err(e) => { + debug!( + error = ?e, + ?beacon_block_root, + "RPC payload envelope failed verification" + ); + self.send_sync_message(SyncMessage::BlockComponentProcessed { + process_type, + result: BlockProcessingResult::Err(e.into()), + }); + return; + } + }; + + // Process the verified envelope + let result = self + .chain + .process_execution_payload_envelope( + beacon_block_root, + verified_envelope, + NotifyExecutionLayer::Yes, + BlockImportSource::Lookup, + #[allow(clippy::result_large_err)] + || Ok(()), + ) + .await; + + let processing_result = match result { + Ok(status) => BlockProcessingResult::Ok(status), + Err(e) => { + debug!( + error = ?e, + ?beacon_block_root, + "RPC payload envelope processing failed" + ); + BlockProcessingResult::Err(e.into()) + } + }; + + self.send_sync_message(SyncMessage::BlockComponentProcessed { + process_type, + result: processing_result, + }); + } + /// Returns the `process_fn` and `ignore_fn` required when requeuing an RPC block. pub fn generate_lookup_beacon_block_fns( self: Arc, @@ -783,6 +856,33 @@ impl NetworkBeaconProcessor { downloaded_blocks: Vec>, ) -> (usize, Result<(), ChainSegmentFailed>) { let total_blocks = downloaded_blocks.len(); + + // Check if this batch contains GLOaS blocks. + let is_gloas_batch = downloaded_blocks + .first() + .map(|b| matches!(b, RangeSyncBlock::Gloas { .. })) + .unwrap_or(false); + + if is_gloas_batch { + // GLOaS blocks: store blocks and envelopes directly. + // KZG verification for columns was already done during coupling. + match self.chain.import_historical_gloas_block_batch(downloaded_blocks) { + Ok(imported_blocks) => { + metrics::inc_counter( + &metrics::BEACON_PROCESSOR_BACKFILL_CHAIN_SEGMENT_SUCCESS_TOTAL, + ); + return (imported_blocks, Ok(())); + } + Err(e) => { + metrics::inc_counter( + &metrics::BEACON_PROCESSOR_BACKFILL_CHAIN_SEGMENT_FAILED_TOTAL, + ); + return self.handle_historical_block_error(e); + } + } + } + + // Pre-GLOaS path: convert to AvailableBlocks and verify KZG. let available_blocks = downloaded_blocks .into_iter() .map(|block| block.into_available_block()) @@ -843,75 +943,83 @@ impl NetworkBeaconProcessor { metrics::inc_counter( &metrics::BEACON_PROCESSOR_BACKFILL_CHAIN_SEGMENT_FAILED_TOTAL, ); - let peer_action = match &e { - HistoricalBlockError::MismatchedBlockRoot { - block_root, - expected_block_root, - } => { - debug!( - error = "mismatched_block_root", - ?block_root, - expected_root = ?expected_block_root, - "Backfill batch processing error" - ); - // The peer is faulty if they send blocks with bad roots. - Some(PeerAction::LowToleranceError) - } - HistoricalBlockError::InvalidSignature - | HistoricalBlockError::SignatureSet(_) => { - warn!( - error = ?e, - "Backfill batch processing error" - ); - // The peer is faulty if they bad signatures. - Some(PeerAction::LowToleranceError) - } - HistoricalBlockError::MissingOldestBlockRoot { slot } => { - warn!( - %slot, - error = "missing_oldest_block_root", - "Backfill batch processing error" - ); - // This is an internal error, do not penalize the peer. - None - } - - HistoricalBlockError::ValidatorPubkeyCacheTimeout => { - warn!( - error = "pubkey_cache_timeout", - "Backfill batch processing error" - ); - // This is an internal error, do not penalize the peer. - None - } - HistoricalBlockError::IndexOutOfBounds => { - error!( - error = ?e, - "Backfill batch OOB error" - ); - // This should never occur, don't penalize the peer. - None - } - HistoricalBlockError::StoreError(e) => { - warn!(error = ?e, "Backfill batch processing error"); - // This is an internal error, don't penalize the peer. - None - } // - // Do not use a fallback match, handle all errors explicitly - }; - let err_str: &'static str = e.into(); - ( - 0, - Err(ChainSegmentFailed { - message: format!("{:?}", err_str), - // This is an internal error, don't penalize the peer. - peer_action, - }), - ) + self.handle_historical_block_error(e) } } } + /// Maps a `HistoricalBlockError` to the appropriate peer action and error tuple. + fn handle_historical_block_error( + &self, + e: HistoricalBlockError, + ) -> (usize, Result<(), ChainSegmentFailed>) { + let peer_action = match &e { + HistoricalBlockError::MismatchedBlockRoot { + block_root, + expected_block_root, + } => { + debug!( + error = "mismatched_block_root", + ?block_root, + expected_root = ?expected_block_root, + "Backfill batch processing error" + ); + // The peer is faulty if they send blocks with bad roots. + Some(PeerAction::LowToleranceError) + } + HistoricalBlockError::InvalidSignature + | HistoricalBlockError::SignatureSet(_) => { + warn!( + error = ?e, + "Backfill batch processing error" + ); + // The peer is faulty if they bad signatures. + Some(PeerAction::LowToleranceError) + } + HistoricalBlockError::MissingOldestBlockRoot { slot } => { + warn!( + %slot, + error = "missing_oldest_block_root", + "Backfill batch processing error" + ); + // This is an internal error, do not penalize the peer. + None + } + + HistoricalBlockError::ValidatorPubkeyCacheTimeout => { + warn!( + error = "pubkey_cache_timeout", + "Backfill batch processing error" + ); + // This is an internal error, do not penalize the peer. + None + } + HistoricalBlockError::IndexOutOfBounds => { + error!( + error = ?e, + "Backfill batch OOB error" + ); + // This should never occur, don't penalize the peer. + None + } + HistoricalBlockError::StoreError(e) => { + warn!(error = ?e, "Backfill batch processing error"); + // This is an internal error, don't penalize the peer. + None + } // + // Do not use a fallback match, handle all errors explicitly + }; + let err_str: &'static str = e.into(); + ( + 0, + Err(ChainSegmentFailed { + message: format!("{:?}", err_str), + // This is an internal error, don't penalize the peer. + peer_action, + }), + ) + } + /// Helper function to handle a `BlockError` from `process_chain_segment` fn handle_failed_chain_segment(&self, error: BlockError) -> Result<(), ChainSegmentFailed> { match error { @@ -923,6 +1031,16 @@ impl NetworkBeaconProcessor { peer_action: Some(PeerAction::LowToleranceError), }) } + BlockError::ParentEnvelopeUnknown { parent_root } => { + Err(ChainSegmentFailed { + message: format!( + "Block's parent envelope has not been received: {}", + parent_root + ), + // Don't penalize the peer, the envelope may arrive later. + peer_action: None, + }) + } BlockError::DuplicateFullyImported(_) | BlockError::DuplicateImportStatusUnknown(..) => { // This can happen for many reasons. Head sync's can download multiples and parent diff --git a/beacon_node/network/src/network_beacon_processor/tests.rs b/beacon_node/network/src/network_beacon_processor/tests.rs index 7e9380e433..0d8a1bea93 100644 --- a/beacon_node/network/src/network_beacon_processor/tests.rs +++ b/beacon_node/network/src/network_beacon_processor/tests.rs @@ -9,6 +9,7 @@ use crate::{ sync::{SyncMessage, manager::BlockProcessType}, }; use beacon_chain::block_verification_types::LookupBlock; +use beacon_chain::chain_config::ChainConfig; use beacon_chain::custody_context::NodeCustodyType; use beacon_chain::data_column_verification::GossipVerifiedDataColumn; use beacon_chain::kzg_utils::blobs_to_data_column_sidecars; @@ -134,7 +135,10 @@ impl TestRig { .fresh_ephemeral_store() .mock_execution_layer() .node_custody_type(NodeCustodyType::Fullnode) - .chain_config(<_>::default()) + .chain_config(ChainConfig { + disable_get_blobs: true, + ..ChainConfig::default() + }) .build(); harness.advance_slot(); @@ -169,7 +173,10 @@ impl TestRig { .fresh_ephemeral_store() .mock_execution_layer() .node_custody_type(node_custody_type) - .chain_config(<_>::default()) + .chain_config(ChainConfig { + disable_get_blobs: true, + ..ChainConfig::default() + }) .build(); harness.advance_slot(); @@ -1012,14 +1019,30 @@ async fn data_column_reconstruction_at_deadline() { rig.enqueue_gossip_data_columns(i); } - // Expect all gossip events + reconstruction - let mut expected_events: Vec = (0..min_columns_for_reconstruction) - .map(|_| WorkType::GossipDataColumnSidecar) - .collect(); - expected_events.push(WorkType::ColumnReconstruction); - - rig.assert_event_journal_contains_ordered(&expected_events) - .await; + // Drain the journal until we've seen all gossip events plus at least one + // reconstruction. Under real crypto the reprocess queue can dispatch the + // reconstruction work item more than once (the second is a no-op via + // `reconstruction_started`), so we don't pin the count — we just require >= 1. + let gsc: &str = WorkType::GossipDataColumnSidecar.into(); + let cr: &str = WorkType::ColumnReconstruction.into(); + let (mut gossip_seen, mut recon_seen) = (0usize, 0usize); + let drain = async { + while let Some(event) = rig.work_journal_rx.recv().await { + if event == gsc { + gossip_seen += 1; + } else if event == cr { + recon_seen += 1; + } + if gossip_seen == min_columns_for_reconstruction && recon_seen >= 1 { + break; + } + } + }; + if tokio::time::timeout(STANDARD_TIMEOUT, drain).await.is_err() { + panic!("timeout: gossip_seen={gossip_seen}, recon_seen={recon_seen}"); + } + assert_eq!(gossip_seen, min_columns_for_reconstruction); + assert!(recon_seen >= 1); } // Test the column reconstruction is delayed for columns that arrive for a previous slot. diff --git a/beacon_node/network/src/router.rs b/beacon_node/network/src/router.rs index ed719cc1f7..261dd76d5c 100644 --- a/beacon_node/network/src/router.rs +++ b/beacon_node/network/src/router.rs @@ -19,7 +19,7 @@ use lighthouse_network::{ }; use logging::TimeLatch; use logging::crit; -use slot_clock::SlotClock; +use slot_clock::{SlotClock, timestamp_now}; use std::sync::Arc; use tokio::sync::mpsc; use tokio_stream::wrappers::UnboundedReceiverStream; @@ -349,13 +349,19 @@ impl Router { Response::DataColumnsByRange(data_column) => { self.on_data_columns_by_range_response(peer_id, app_request_id, data_column); } - Response::PayloadEnvelopesByRoot(envelope) => { - self.on_payload_envelopes_by_root_response(peer_id, app_request_id, envelope); + Response::PayloadEnvelopesByRoot(payload_envelope) => { + self.on_payload_envelopes_by_root_response( + peer_id, + app_request_id, + payload_envelope, + ); } - // TODO(EIP-7732): implement outgoing payload envelopes by range responses - // once sync manager requests them. - Response::PayloadEnvelopesByRange(_) => { - debug!("Requesting envelopes by range not supported yet"); + Response::PayloadEnvelopesByRange(payload_envelope) => { + self.on_payload_envelopes_by_range_response( + peer_id, + app_request_id, + payload_envelope, + ); } // Lighthouse currently only serves BlocksByHead and does not issue it as a client, // so receiving a response is unexpected. Drop it without crashing. @@ -831,7 +837,7 @@ impl Router { &mut self, peer_id: PeerId, app_request_id: AppRequestId, - envelope: Option>>, + payload_envelope: Option>>, ) { let sync_request_id = match app_request_id { AppRequestId::Sync(id @ SyncRequestId::SinglePayloadEnvelope { .. }) => id, @@ -844,11 +850,34 @@ impl Router { self.send_to_sync(SyncMessage::RpcPayloadEnvelope { sync_request_id, peer_id, - envelope, + payload_envelope, seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), }); } + pub fn on_payload_envelopes_by_range_response( + &mut self, + peer_id: PeerId, + app_request_id: AppRequestId, + payload_envelope: Option>>, + ) { + trace!( + %peer_id, + "Received PayloadEnvelopesByRange Response" + ); + + if let AppRequestId::Sync(sync_request_id) = app_request_id { + self.send_to_sync(SyncMessage::RpcPayloadEnvelope { + peer_id, + sync_request_id, + payload_envelope, + seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), + }); + } else { + crit!("All payload envelopes by range responses should belong to sync"); + } + } + fn handle_beacon_processor_send_result( &mut self, result: Result<(), crate::network_beacon_processor::Error>, diff --git a/beacon_node/network/src/sync/batch.rs b/beacon_node/network/src/sync/batch.rs index 10af1bf503..e0704e2569 100644 --- a/beacon_node/network/src/sync/batch.rs +++ b/beacon_node/network/src/sync/batch.rs @@ -33,6 +33,7 @@ pub type BatchId = Epoch; #[strum(serialize_all = "snake_case")] pub enum ByRangeRequestType { BlocksAndColumns, + BlocksAndEnvelopesAndColumns, BlocksAndBlobs, Blocks, Columns(HashSet), diff --git a/beacon_node/network/src/sync/block_lookups/common.rs b/beacon_node/network/src/sync/block_lookups/common.rs index edd99345b4..bb8d81cc6e 100644 --- a/beacon_node/network/src/sync/block_lookups/common.rs +++ b/beacon_node/network/src/sync/block_lookups/common.rs @@ -2,7 +2,7 @@ use crate::sync::block_lookups::single_block_lookup::{ LookupRequestError, SingleBlockLookup, SingleLookupRequestState, }; use crate::sync::block_lookups::{ - BlobRequestState, BlockRequestState, CustodyRequestState, PeerId, + BlobRequestState, BlockRequestState, CustodyRequestState, EnvelopeRequestState, PeerId, }; use crate::sync::manager::BlockProcessType; use crate::sync::network_context::{LookupRequestResult, SyncNetworkContext}; @@ -12,16 +12,17 @@ use parking_lot::RwLock; use std::collections::HashSet; use std::sync::Arc; use types::data::FixedBlobSidecarList; -use types::{DataColumnSidecarList, SignedBeaconBlock}; +use types::{DataColumnSidecarList, SignedBeaconBlock, SignedExecutionPayloadEnvelope}; use super::SingleLookupId; use super::single_block_lookup::{ComponentRequests, DownloadResult}; -#[derive(Debug, Copy, Clone)] +#[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum ResponseType { Block, Blob, CustodyColumn, + Envelope, } /// This trait unifies common single block lookup functionality across blocks and blobs. This @@ -151,6 +152,7 @@ impl RequestState for BlobRequestState { ComponentRequests::WaitingForBlock => Err("waiting for block"), ComponentRequests::ActiveBlobRequest(request, _) => Ok(request), ComponentRequests::ActiveCustodyRequest { .. } => Err("expecting custody request"), + ComponentRequests::ActiveEnvelopeRequest { .. } => Err("expecting envelope request"), ComponentRequests::NotNeeded { .. } => Err("not needed"), } } @@ -205,6 +207,7 @@ impl RequestState for CustodyRequestState { ComponentRequests::WaitingForBlock => Err("waiting for block"), ComponentRequests::ActiveBlobRequest { .. } => Err("expecting blob request"), ComponentRequests::ActiveCustodyRequest(request) => Ok(request), + ComponentRequests::ActiveEnvelopeRequest { .. } => Err("expecting envelope request"), ComponentRequests::NotNeeded { .. } => Err("not needed"), } } @@ -215,3 +218,52 @@ impl RequestState for CustodyRequestState { &mut self.state } } + +impl RequestState for EnvelopeRequestState { + type VerifiedResponseType = Arc>; + + fn make_request( + &self, + id: Id, + lookup_peers: Arc>>, + _: usize, + cx: &mut SyncNetworkContext, + ) -> Result { + cx.envelope_lookup_request(id, lookup_peers, self.block_root) + .map_err(LookupRequestError::SendFailedNetwork) + } + + fn send_for_processing( + id: Id, + download_result: DownloadResult, + cx: &SyncNetworkContext, + ) -> Result<(), LookupRequestError> { + let DownloadResult { + value, + block_root, + seen_timestamp, + .. + } = download_result; + cx.send_envelope_for_processing(id, value, seen_timestamp, block_root) + .map_err(LookupRequestError::SendFailedProcessor) + } + + fn response_type() -> ResponseType { + ResponseType::Envelope + } + + fn request_state_mut(request: &mut SingleBlockLookup) -> Result<&mut Self, &'static str> { + match &mut request.component_requests { + ComponentRequests::ActiveEnvelopeRequest(request) => Ok(request), + _ => Err("expecting envelope request"), + } + } + + fn get_state(&self) -> &SingleLookupRequestState { + &self.state + } + + fn get_state_mut(&mut self) -> &mut SingleLookupRequestState { + &mut self.state + } +} diff --git a/beacon_node/network/src/sync/block_lookups/mod.rs b/beacon_node/network/src/sync/block_lookups/mod.rs index f10610c751..a9d08c30a4 100644 --- a/beacon_node/network/src/sync/block_lookups/mod.rs +++ b/beacon_node/network/src/sync/block_lookups/mod.rs @@ -22,7 +22,9 @@ use self::parent_chain::{NodeChain, compute_parent_chains}; pub use self::single_block_lookup::DownloadResult; -use self::single_block_lookup::{LookupRequestError, LookupResult, SingleBlockLookup}; +use self::single_block_lookup::{ + AwaitingParent, LookupRequestError, LookupResult, SingleBlockLookup, +}; use super::manager::{BlockProcessType, BlockProcessingResult, SLOT_IMPORT_TOLERANCE}; use super::network_context::{PeerGroup, RpcResponseError, SyncNetworkContext}; use crate::metrics; @@ -39,7 +41,9 @@ use fnv::FnvHashMap; use lighthouse_network::service::api_types::SingleLookupReqId; use lighthouse_network::{PeerAction, PeerId}; use lru_cache::LRUTimeCache; -pub use single_block_lookup::{BlobRequestState, BlockRequestState, CustodyRequestState}; +pub use single_block_lookup::{ + BlobRequestState, BlockRequestState, CustodyRequestState, EnvelopeRequestState, +}; use std::collections::hash_map::Entry; use std::sync::Arc; use std::time::Duration; @@ -50,6 +54,7 @@ use types::{EthSpec, SignedBeaconBlock}; pub mod common; pub mod parent_chain; mod single_block_lookup; +mod single_envelope_lookup; /// The maximum depth we will search for a parent block. In principle we should have sync'd any /// canonical chain to its head once the peer connects. A chain should not appear where it's depth @@ -109,6 +114,7 @@ pub type SingleLookupId = u32; enum Action { Retry, ParentUnknown { parent_root: Hash256 }, + ParentEnvelopeUnknown { parent_root: Hash256 }, Drop(/* reason: */ String), Continue, } @@ -213,7 +219,7 @@ impl BlockLookups { self.new_current_lookup( block_root, Some(block_component), - Some(parent_root), + Some(AwaitingParent::Block(parent_root)), // On a `UnknownParentBlock` or `UnknownParentBlob` event the peer is not required // to have the rest of the block components (refer to decoupled blob gossip). Create // the lookup with zero peers to house the block components. @@ -225,7 +231,37 @@ impl BlockLookups { } } - /// Seach a block whose parent root is unknown. + /// A child block's parent envelope is missing. Create a child lookup (with the block component) + /// that waits for the parent envelope, and an envelope-only lookup for the parent. + /// + /// Returns true if both lookups are created or already exist. + #[must_use = "only reference the new lookup if returns true"] + pub fn search_child_and_parent_envelope( + &mut self, + block_root: Hash256, + block_component: BlockComponent, + parent_root: Hash256, + peer_id: PeerId, + cx: &mut SyncNetworkContext, + ) -> bool { + let envelope_lookup_exists = + self.search_parent_envelope_of_child(parent_root, &[peer_id], cx); + if envelope_lookup_exists { + // Create child lookup that waits for the parent envelope. + // The child block itself has already been seen, so we pass it as a component. + self.new_current_lookup( + block_root, + Some(block_component), + Some(AwaitingParent::Envelope(parent_root)), + &[], + cx, + ) + } else { + false + } + } + + /// Search a block whose parent root is unknown. /// /// Returns true if the lookup is created or already exists #[must_use = "only reference the new lookup if returns true"] @@ -343,6 +379,57 @@ impl BlockLookups { self.new_current_lookup(block_root_to_search, None, None, peers, cx) } + /// A block triggers the search of a parent envelope. + #[must_use = "only reference the new lookup if returns true"] + pub fn search_parent_envelope_of_child( + &mut self, + parent_root: Hash256, + peers: &[PeerId], + cx: &mut SyncNetworkContext, + ) -> bool { + // Check if there's already a lookup for this root (could be a block lookup or envelope + // lookup). If so, add peers and let it handle the envelope. + if let Some((&lookup_id, _lookup)) = self + .single_block_lookups + .iter_mut() + .find(|(_, lookup)| lookup.is_for_block(parent_root)) + { + if let Err(e) = self.add_peers_to_lookup_and_ancestors(lookup_id, peers, cx) { + warn!(error = ?e, "Error adding peers to envelope lookup"); + } + return true; + } + + if self.single_block_lookups.len() >= MAX_LOOKUPS { + warn!(?parent_root, "Dropping envelope lookup reached max"); + return false; + } + + let lookup = SingleBlockLookup::new_envelope_only(parent_root, peers, cx.next_id()); + let _guard = lookup.span.clone().entered(); + + let id = lookup.id; + let lookup = match self.single_block_lookups.entry(id) { + Entry::Vacant(entry) => entry.insert(lookup), + Entry::Occupied(_) => { + warn!(id, "Lookup exists with same id"); + return false; + } + }; + + debug!( + ?peers, + ?parent_root, + id = lookup.id, + "Created envelope-only lookup" + ); + metrics::inc_counter(&metrics::SYNC_LOOKUP_CREATED); + self.metrics.created_lookups += 1; + + let result = lookup.continue_requests(cx); + self.on_lookup_result(id, result, "new_envelope_lookup", cx) + } + /// Searches for a single block hash. If the blocks parent is unknown, a chain of blocks is /// constructed. /// Returns true if the lookup is created or already exists @@ -351,7 +438,7 @@ impl BlockLookups { &mut self, block_root: Hash256, block_component: Option>, - awaiting_parent: Option, + awaiting_parent: Option, peers: &[PeerId], cx: &mut SyncNetworkContext, ) -> bool { @@ -386,13 +473,14 @@ impl BlockLookups { } // Ensure that awaiting parent exists, otherwise this lookup won't be able to make progress - if let Some(awaiting_parent) = awaiting_parent + if let Some(AwaitingParent::Block(parent_root) | AwaitingParent::Envelope(parent_root)) = + awaiting_parent && !self .single_block_lookups .iter() - .any(|(_, lookup)| lookup.is_for_block(awaiting_parent)) + .any(|(_, lookup)| lookup.is_for_block(parent_root)) { - warn!(block_root = ?awaiting_parent, "Ignoring child lookup parent lookup not found"); + warn!(block_root = ?parent_root, "Ignoring child lookup parent lookup not found"); return false; } @@ -426,9 +514,7 @@ impl BlockLookups { debug!( ?peers, ?block_root, - awaiting_parent = awaiting_parent - .map(|root| root.to_string()) - .unwrap_or("none".to_owned()), + ?awaiting_parent, id = lookup.id, "Created block lookup" ); @@ -559,8 +645,35 @@ impl BlockLookups { BlockProcessType::SingleCustodyColumn(id) => { self.on_processing_result_inner::>(id, result, cx) } - // TODO(gloas): route into the payload envelope lookup state machine. - BlockProcessType::SinglePayloadEnvelope(_) => Ok(LookupResult::Pending), + BlockProcessType::SinglePayloadEnvelope { id, block_root } => { + // When envelope processing returns `MissingComponents`, the envelope has been + // executed but data columns are not yet available. Transition the lookup to fetch + // custody columns instead of retrying the envelope or erroring. + if matches!( + &result, + BlockProcessingResult::Ok( + AvailabilityProcessingStatus::MissingComponents { .. } + ) + ) && let Some(lookup) = self.single_block_lookups.get_mut(&id) + && lookup.transition_envelope_to_custody() + { + debug!( + ?block_root, + "Envelope processed, transitioning to custody column lookup" + ); + let lookup_result = lookup.continue_requests(cx); + self.on_lookup_result(id, lookup_result, "envelope_to_custody_transition", cx); + return; + } + + let result = self + .on_processing_result_inner::>(id, result, cx); + // On successful envelope import, unblock child lookups waiting for this envelope + if matches!(&result, Ok(LookupResult::Completed)) { + self.continue_envelope_child_lookups(block_root, cx); + } + result + } }; self.on_lookup_result(process_type.id(), lookup_result, "processing_result", cx); } @@ -647,6 +760,12 @@ impl BlockLookups { request_state.revert_to_awaiting_processing()?; Action::ParentUnknown { parent_root } } + BlockError::ParentEnvelopeUnknown { parent_root } => { + // The parent block is known but its execution payload envelope is missing. + // Revert to awaiting processing and fetch the envelope via RPC. + request_state.revert_to_awaiting_processing()?; + Action::ParentEnvelopeUnknown { parent_root } + } ref e @ BlockError::ExecutionPayloadError(ref epe) if !epe.penalize_peer() => { // These errors indicate that the execution layer is offline // and failed to validate the execution payload. Do not downscore peer. @@ -669,6 +788,26 @@ impl BlockLookups { // We opt to drop the lookup instead. Action::Drop(format!("{e:?}")) } + BlockError::PayloadEnvelopeError { e, penalize_peer } => { + debug!( + ?block_root, + error = ?e, + "Payload envelope processing error" + ); + if penalize_peer { + let peer_group = request_state.on_processing_failure()?; + for peer in peer_group.all() { + cx.report_peer( + *peer, + PeerAction::MidToleranceError, + "lookup_envelope_processing_failure", + ); + } + Action::Retry + } else { + Action::Drop(format!("{e:?}")) + } + } other => { debug!( ?block_root, @@ -703,6 +842,7 @@ impl BlockLookups { ResponseType::CustodyColumn => { "lookup_custody_column_processing_failure" } + ResponseType::Envelope => "lookup_envelope_processing_failure", }, ); } @@ -744,6 +884,25 @@ impl BlockLookups { ))) } } + Action::ParentEnvelopeUnknown { parent_root } => { + let peers = lookup.all_peers(); + lookup.set_awaiting_parent_envelope(parent_root); + let envelope_lookup_exists = + self.search_parent_envelope_of_child(parent_root, &peers, cx); + if envelope_lookup_exists { + debug!( + id = lookup_id, + ?block_root, + ?parent_root, + "Marking lookup as awaiting parent envelope" + ); + Ok(LookupResult::Pending) + } else { + Err(LookupRequestError::Failed(format!( + "Envelope lookup could not be created for {parent_root:?}" + ))) + } + } Action::Drop(reason) => { // Drop with noop Err(LookupRequestError::Failed(reason)) @@ -793,7 +952,7 @@ impl BlockLookups { let mut lookup_results = vec![]; // < need to buffer lookup results to not re-borrow &mut self for (id, lookup) in self.single_block_lookups.iter_mut() { - if lookup.awaiting_parent() == Some(block_root) { + if lookup.awaiting_parent_block() == Some(block_root) { lookup.resolve_awaiting_parent(); debug!( parent_root = ?block_root, @@ -811,6 +970,33 @@ impl BlockLookups { } } + /// Makes progress on lookups that were waiting for a parent envelope to be imported. + pub fn continue_envelope_child_lookups( + &mut self, + block_root: Hash256, + cx: &mut SyncNetworkContext, + ) { + let mut lookup_results = vec![]; + + for (id, lookup) in self.single_block_lookups.iter_mut() { + if lookup.awaiting_parent_envelope() == Some(block_root) { + lookup.resolve_awaiting_parent(); + debug!( + envelope_root = ?block_root, + id, + block_root = ?lookup.block_root(), + "Continuing lookup after envelope imported" + ); + let result = lookup.continue_requests(cx); + lookup_results.push((*id, result)); + } + } + + for (id, result) in lookup_results { + self.on_lookup_result(id, result, "continue_envelope_child_lookups", cx); + } + } + /// Drops `dropped_id` lookup and all its children recursively. Lookups awaiting a parent need /// the parent to make progress to resolve, therefore we must drop them if the parent is /// dropped. @@ -826,10 +1012,14 @@ impl BlockLookups { metrics::inc_counter_vec(&metrics::SYNC_LOOKUP_DROPPED, &[reason]); self.metrics.dropped_lookups += 1; + let dropped_root = dropped_lookup.block_root(); let child_lookups = self .single_block_lookups .iter() - .filter(|(_, lookup)| lookup.awaiting_parent() == Some(dropped_lookup.block_root())) + .filter(|(_, lookup)| { + lookup.awaiting_parent_block() == Some(dropped_root) + || lookup.awaiting_parent_envelope() == Some(dropped_root) + }) .map(|(id, _)| *id) .collect::>(); @@ -997,17 +1187,15 @@ impl BlockLookups { &'a self, lookup: &'a SingleBlockLookup, ) -> Result<&'a SingleBlockLookup, String> { - if let Some(awaiting_parent) = lookup.awaiting_parent() { + if let Some(parent_root) = lookup.awaiting_parent_block() { if let Some(lookup) = self .single_block_lookups .values() - .find(|l| l.block_root() == awaiting_parent) + .find(|l| l.block_root() == parent_root) { self.find_oldest_ancestor_lookup(lookup) } else { - Err(format!( - "Lookup references unknown parent {awaiting_parent:?}" - )) + Err(format!("Lookup references unknown parent {parent_root:?}")) } } else { Ok(lookup) @@ -1040,7 +1228,7 @@ impl BlockLookups { } } - if let Some(parent_root) = lookup.awaiting_parent() { + if let Some(parent_root) = lookup.awaiting_parent_block() { if let Some((&child_id, _)) = self .single_block_lookups .iter() diff --git a/beacon_node/network/src/sync/block_lookups/parent_chain.rs b/beacon_node/network/src/sync/block_lookups/parent_chain.rs index 5deea1dd94..18363e9b8d 100644 --- a/beacon_node/network/src/sync/block_lookups/parent_chain.rs +++ b/beacon_node/network/src/sync/block_lookups/parent_chain.rs @@ -13,7 +13,7 @@ impl From<&SingleBlockLookup> for Node { fn from(value: &SingleBlockLookup) -> Self { Self { block_root: value.block_root(), - parent_root: value.awaiting_parent(), + parent_root: value.awaiting_parent_block(), } } } diff --git a/beacon_node/network/src/sync/block_lookups/single_block_lookup.rs b/beacon_node/network/src/sync/block_lookups/single_block_lookup.rs index 23bfd531f0..cdcb574219 100644 --- a/beacon_node/network/src/sync/block_lookups/single_block_lookup.rs +++ b/beacon_node/network/src/sync/block_lookups/single_block_lookup.rs @@ -16,7 +16,9 @@ use store::Hash256; use strum::IntoStaticStr; use tracing::{Span, debug_span}; use types::data::FixedBlobSidecarList; -use types::{DataColumnSidecarList, EthSpec, SignedBeaconBlock, Slot}; +use types::{ + DataColumnSidecarList, EthSpec, SignedBeaconBlock, SignedExecutionPayloadEnvelope, Slot, +}; // Dedicated enum for LookupResult to force its usage #[must_use = "LookupResult must be handled with on_lookup_result"] @@ -56,6 +58,14 @@ pub enum LookupRequestError { }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AwaitingParent { + /// Waiting for the parent block to be imported. + Block(Hash256), + /// The parent block is imported but its execution payload envelope is missing. + Envelope(Hash256), +} + #[derive(Educe)] #[educe(Debug(bound(T: BeaconChainTypes)))] pub struct SingleBlockLookup { @@ -68,8 +78,8 @@ pub struct SingleBlockLookup { /// than the lifetime of a custody request. #[educe(Debug(method(fmt_peer_set_as_len)))] peers: Arc>>, - block_root: Hash256, - awaiting_parent: Option, + pub(super) block_root: Hash256, + pub(super) awaiting_parent: Option, created: Instant, pub(crate) span: Span, } @@ -79,6 +89,7 @@ pub(crate) enum ComponentRequests { WaitingForBlock, ActiveBlobRequest(BlobRequestState, usize), ActiveCustodyRequest(CustodyRequestState), + ActiveEnvelopeRequest(EnvelopeRequestState), // When printing in debug this state display the reason why it's not needed #[allow(dead_code)] NotNeeded(&'static str), @@ -89,7 +100,7 @@ impl SingleBlockLookup { requested_block_root: Hash256, peers: &[PeerId], id: Id, - awaiting_parent: Option, + awaiting_parent: Option, ) -> Self { let lookup_span = debug_span!( "lh_single_block_lookup", @@ -109,10 +120,18 @@ impl SingleBlockLookup { } } - /// Reset the status of all internal requests pub fn reset_requests(&mut self) { self.block_request_state = BlockRequestState::new(self.block_root); - self.component_requests = ComponentRequests::WaitingForBlock; + match &self.component_requests { + ComponentRequests::ActiveEnvelopeRequest(_) => { + self.component_requests = ComponentRequests::ActiveEnvelopeRequest( + EnvelopeRequestState::new(self.block_root), + ); + } + _ => { + self.component_requests = ComponentRequests::WaitingForBlock; + } + } } /// Return the slot of this lookup's block if it's currently cached as `AwaitingProcessing` @@ -128,18 +147,24 @@ impl SingleBlockLookup { self.block_root } - pub fn awaiting_parent(&self) -> Option { + pub fn awaiting_parent(&self) -> Option { self.awaiting_parent } - /// Mark this lookup as awaiting a parent lookup from being processed. Meanwhile don't send - /// components for processing. - pub fn set_awaiting_parent(&mut self, parent_root: Hash256) { - self.awaiting_parent = Some(parent_root) + /// Returns the parent root if awaiting a parent block. + pub fn awaiting_parent_block(&self) -> Option { + match self.awaiting_parent { + Some(AwaitingParent::Block(root)) => Some(root), + _ => None, + } } - /// Mark this lookup as no longer awaiting a parent lookup. Components can be sent for - /// processing. + /// Mark this lookup as awaiting a parent block to be imported before processing. + pub fn set_awaiting_parent(&mut self, parent_root: Hash256) { + self.awaiting_parent = Some(AwaitingParent::Block(parent_root)); + } + + /// Mark this lookup as no longer awaiting any parent. pub fn resolve_awaiting_parent(&mut self) { self.awaiting_parent = None; } @@ -180,6 +205,7 @@ impl SingleBlockLookup { ComponentRequests::WaitingForBlock => false, ComponentRequests::ActiveBlobRequest(request, _) => request.state.is_processed(), ComponentRequests::ActiveCustodyRequest(request) => request.state.is_processed(), + ComponentRequests::ActiveEnvelopeRequest(request) => request.state.is_processed(), ComponentRequests::NotNeeded { .. } => true, } } @@ -199,6 +225,9 @@ impl SingleBlockLookup { ComponentRequests::ActiveCustodyRequest(request) => { request.state.is_awaiting_event() } + ComponentRequests::ActiveEnvelopeRequest(request) => { + request.state.is_awaiting_event() + } ComponentRequests::NotNeeded { .. } => false, } } @@ -268,6 +297,9 @@ impl SingleBlockLookup { ComponentRequests::ActiveCustodyRequest(_) => { self.continue_request::>(cx, 0)? } + ComponentRequests::ActiveEnvelopeRequest(_) => { + self.continue_request::>(cx, 0)? + } ComponentRequests::NotNeeded { .. } => {} // do nothing } @@ -289,7 +321,7 @@ impl SingleBlockLookup { expected_blobs: usize, ) -> Result<(), LookupRequestError> { let id = self.id; - let awaiting_parent = self.awaiting_parent.is_some(); + let awaiting_event = self.awaiting_parent.is_some(); let request = R::request_state_mut(self).map_err(|e| LookupRequestError::BadState(e.to_owned()))?; @@ -333,7 +365,7 @@ impl SingleBlockLookup { // Otherwise, attempt to progress awaiting processing // If this request is awaiting a parent lookup to be processed, do not send for processing. // The request will be rejected with unknown parent error. - } else if !awaiting_parent { + } else if !awaiting_event { // maybe_start_processing returns Some if state == AwaitingProcess. This pattern is // useful to conditionally access the result data. if let Some(result) = request.get_state_mut().maybe_start_processing() { @@ -429,6 +461,26 @@ impl BlockRequestState { } } +/// The state of the envelope request component of a `SingleBlockLookup`. +/// Used for envelope-only lookups where the parent block is already imported +/// but its execution payload envelope is missing. +#[derive(Educe)] +#[educe(Debug)] +pub struct EnvelopeRequestState { + #[educe(Debug(ignore))] + pub block_root: Hash256, + pub state: SingleLookupRequestState>>, +} + +impl EnvelopeRequestState { + pub fn new(block_root: Hash256) -> Self { + Self { + block_root, + state: SingleLookupRequestState::new(), + } + } +} + #[derive(Debug, Clone)] pub struct DownloadResult { pub value: T, diff --git a/beacon_node/network/src/sync/block_lookups/single_envelope_lookup.rs b/beacon_node/network/src/sync/block_lookups/single_envelope_lookup.rs new file mode 100644 index 0000000000..88fa042439 --- /dev/null +++ b/beacon_node/network/src/sync/block_lookups/single_envelope_lookup.rs @@ -0,0 +1,62 @@ +//! Envelope-specific extensions to `SingleBlockLookup`. +//! +//! Envelope-only lookups are created when a block's parent is known and imported but its +//! execution payload envelope has not yet been received. The block download step is skipped +//! (marked complete immediately), and only the envelope — and possibly subsequent custody +//! columns — are fetched. + +use super::single_block_lookup::{ + AwaitingParent, ComponentRequests, CustodyRequestState, EnvelopeRequestState, SingleBlockLookup, +}; +use beacon_chain::BeaconChainTypes; +use lighthouse_network::PeerId; +use lighthouse_network::service::api_types::Id; +use store::Hash256; + +impl SingleBlockLookup { + /// Create an envelope-only lookup. The block is already imported; only the envelope (and + /// potentially custody columns) need to be fetched. + pub fn new_envelope_only(block_root: Hash256, peers: &[PeerId], id: Id) -> Self { + let mut lookup = Self::new(block_root, peers, id, None); + // Block is already imported — advance past the download step immediately. + lookup + .block_request_state + .state + .on_completed_request("block already imported") + .expect("block state starts as AwaitingDownload"); + lookup.component_requests = + ComponentRequests::ActiveEnvelopeRequest(EnvelopeRequestState::new(block_root)); + lookup + } + + /// Transition from `ActiveEnvelopeRequest` to `ActiveCustodyRequest`. + /// + /// Called when envelope processing returns `MissingComponents`: the envelope has been executed + /// but data columns have not yet arrived and must be fetched separately. + /// Returns `true` if the transition was made, `false` if state was not an envelope request. + pub fn transition_envelope_to_custody(&mut self) -> bool { + if matches!( + self.component_requests, + ComponentRequests::ActiveEnvelopeRequest(_) + ) { + self.component_requests = + ComponentRequests::ActiveCustodyRequest(CustodyRequestState::new(self.block_root)); + true + } else { + false + } + } + + /// Returns the parent root if this lookup is awaiting a parent envelope. + pub fn awaiting_parent_envelope(&self) -> Option { + match self.awaiting_parent { + Some(AwaitingParent::Envelope(root)) => Some(root), + _ => None, + } + } + + /// Mark this lookup as awaiting a parent envelope before processing can resume. + pub fn set_awaiting_parent_envelope(&mut self, parent_root: Hash256) { + self.awaiting_parent = Some(AwaitingParent::Envelope(parent_root)); + } +} diff --git a/beacon_node/network/src/sync/block_sidecar_coupling.rs b/beacon_node/network/src/sync/block_sidecar_coupling.rs index bb43396473..d9aa67f778 100644 --- a/beacon_node/network/src/sync/block_sidecar_coupling.rs +++ b/beacon_node/network/src/sync/block_sidecar_coupling.rs @@ -4,11 +4,13 @@ use beacon_chain::{ data_availability_checker::DataAvailabilityChecker, data_column_verification::CustodyDataColumn, get_block_root, + payload_envelope_verification::AvailableEnvelope, }; use lighthouse_network::{ PeerId, service::api_types::{ BlobsByRangeRequestId, BlocksByRangeRequestId, DataColumnsByRangeRequestId, + PayloadEnvelopesByRangeRequestId, }, }; use ssz_types::RuntimeVariableList; @@ -16,7 +18,7 @@ use std::{collections::HashMap, sync::Arc}; use tracing::{Span, debug}; use types::{ BlobSidecar, ChainSpec, ColumnIndex, DataColumnSidecar, DataColumnSidecarList, EthSpec, - Hash256, SignedBeaconBlock, + Hash256, SignedBeaconBlock, SignedExecutionPayloadEnvelope, }; use crate::sync::network_context::MAX_COLUMN_RETRIES; @@ -35,6 +37,13 @@ use crate::sync::network_context::MAX_COLUMN_RETRIES; pub struct RangeBlockComponentsRequest { /// Blocks we have received awaiting for their corresponding sidecar. blocks_request: ByRangeRequest>>>, + /// Payload envelopes (Gloas+). None for pre-Gloas forks. + payloads_request: Option< + ByRangeRequest< + PayloadEnvelopesByRangeRequestId, + Vec>>, + >, + >, /// Sidecars we have received awaiting for their corresponding block. block_data_request: RangeBlockDataRequest, /// Span to track the range request and all children range requests. @@ -88,6 +97,7 @@ impl RangeBlockComponentsRequest { Vec<(DataColumnsByRangeRequestId, Vec)>, Vec, )>, + payloads_req_id: Option, request_span: Span, ) -> Self { let block_data_request = if let Some(blobs_req_id) = blobs_req_id { @@ -109,6 +119,7 @@ impl RangeBlockComponentsRequest { Self { blocks_request: ByRangeRequest::Active(blocks_req_id), + payloads_request: payloads_req_id.map(ByRangeRequest::Active), block_data_request, request_span, } @@ -191,6 +202,18 @@ impl RangeBlockComponentsRequest { } } + /// Adds received payload envelopes to the request. + pub fn add_payload_envelopes( + &mut self, + req_id: PayloadEnvelopesByRangeRequestId, + envelopes: Vec>>, + ) -> Result<(), String> { + match &mut self.payloads_request { + Some(req) => req.finish(req_id, envelopes), + None => Err("received payload envelopes but none expected".to_owned()), + } + } + /// Attempts to construct RPC blocks from all received components. /// /// Returns `None` if not all expected requests have completed. @@ -208,6 +231,13 @@ impl RangeBlockComponentsRequest { return None; }; + // If payloads are expected, they must also be complete before we can produce responses. + if let Some(payloads_req) = &self.payloads_request + && payloads_req.to_finished().is_none() + { + return None; + } + // Increment the attempt once this function returns the response or errors match &mut self.block_data_request { RangeBlockDataRequest::NoData => Some(Self::responses_with_blobs( @@ -254,15 +284,29 @@ impl RangeBlockComponentsRequest { } } - let resp = Self::responses_with_custody_columns( - blocks.to_vec(), - data_columns, - column_to_peer_id, - expected_custody_columns, - *attempt, - da_checker, - spec, - ); + // Gloas path: if payloads are present, produce Gloas blocks + let resp = if let Some(payloads_req) = &self.payloads_request { + let payloads = payloads_req.to_finished().expect("checked above").to_vec(); + Self::responses_with_envelopes_and_columns( + blocks.to_vec(), + payloads, + data_columns, + column_to_peer_id, + expected_custody_columns, + *attempt, + spec, + ) + } else { + Self::responses_with_custody_columns( + blocks.to_vec(), + data_columns, + column_to_peer_id, + expected_custody_columns, + *attempt, + da_checker, + spec, + ) + }; if let Err(CouplingError::DataColumnPeerFailure { error: _, @@ -364,102 +408,199 @@ impl RangeBlockComponentsRequest { where T: BeaconChainTypes, { - // Group data columns by block_root and index - let mut data_columns_by_block = - HashMap::>>>::new(); - - for column in data_columns { - let block_root = column.block_root(); - let index = *column.index(); - if data_columns_by_block - .entry(block_root) - .or_default() - .insert(index, column) - .is_some() - { - // `DataColumnsByRangeRequestItems` ensures that we do not request any duplicated indices across all peers - // we request the data from. - // If there are duplicated indices, its likely a peer sending us the same index multiple times. - // However we can still proceed even if there are extra columns, just log an error. - debug!(?block_root, ?index, "Repeated column for block_root"); - continue; - } - } - - // Now iterate all blocks ensuring that the block roots of each block and data column match, - // plus we have columns for our custody requirements + let mut columns_by_root = Self::group_columns_by_root(data_columns); let mut range_sync_blocks = Vec::with_capacity(blocks.len()); - let exceeded_retries = attempt >= MAX_COLUMN_RETRIES; + for block in blocks { let block_root = get_block_root(&block); range_sync_blocks.push(if block.num_expected_blobs() > 0 { - let Some(mut data_columns_by_index) = data_columns_by_block.remove(&block_root) - else { - let responsible_peers = column_to_peer.iter().map(|c| (*c.0, *c.1)).collect(); - return Err(CouplingError::DataColumnPeerFailure { - error: format!("No columns for block {block_root:?} with data"), - faulty_peers: responsible_peers, - exceeded_retries, - - }); - }; - - let mut custody_columns = vec![]; - let mut naughty_peers = vec![]; - for index in expects_custody_columns { - // Safe to convert to `CustodyDataColumn`: we have asserted that the index of - // this column is in the set of `expects_custody_columns` and with the expected - // block root, so for the expected epoch of this batch. - if let Some(data_column) = data_columns_by_index.remove(index) { - custody_columns.push(CustodyDataColumn::from_asserted_custody(data_column)); - } else { - let Some(responsible_peer) = column_to_peer.get(index) else { - return Err(CouplingError::InternalError(format!("Internal error, no request made for column {}", index))); - }; - naughty_peers.push((*index, *responsible_peer)); - } - } - if !naughty_peers.is_empty() { - return Err(CouplingError::DataColumnPeerFailure { - error: format!("Peers did not return column for block_root {block_root:?} {naughty_peers:?}"), - faulty_peers: naughty_peers, - exceeded_retries - }); - } - - // Assert that there are no columns left - if !data_columns_by_index.is_empty() { - let remaining_indices = data_columns_by_index.keys().collect::>(); - // log the error but don't return an error, we can still progress with extra columns. - debug!( - ?block_root, - ?remaining_indices, - "Not all columns consumed for block" - ); - } - - let block_data = AvailableBlockData::new_with_data_columns(custody_columns.iter().map(|c| c.as_data_column().clone()).collect::>()); - + // Safe to convert to `CustodyDataColumn`: we have asserted that the index of + // this column is in the set of `expects_custody_columns` and with the expected + // block root, so for the expected epoch of this batch. + let columns = Self::extract_custody_columns_for_root( + block_root, + &mut columns_by_root, + expects_custody_columns, + &column_to_peer, + exceeded_retries, + )?; + let custody_columns = columns + .into_iter() + .map(CustodyDataColumn::from_asserted_custody) + .collect::>(); + let block_data = AvailableBlockData::new_with_data_columns( + custody_columns + .iter() + .map(|c| c.as_data_column().clone()) + .collect::>(), + ); RangeSyncBlock::new(block, block_data, &da_checker, spec.clone()) .map_err(|e| CouplingError::InternalError(format!("{:?}", e)))? } else { - // Block has no data, expects zero columns RangeSyncBlock::new(block, AvailableBlockData::NoData, &da_checker, spec.clone()) .map_err(|e| CouplingError::InternalError(format!("{:?}", e)))? }); } - // Assert that there are no columns left for other blocks - if !data_columns_by_block.is_empty() { - let remaining_roots = data_columns_by_block.keys().collect::>(); - // log the error but don't return an error, we can still progress with responses. - // this is most likely an internal error with overrequesting or a client bug. + if !columns_by_root.is_empty() { + let remaining_roots = columns_by_root.keys().collect::>(); debug!(?remaining_roots, "Not all columns consumed for block"); } Ok(range_sync_blocks) } + + /// Couples blocks with payload envelopes and custody columns for Gloas range sync. + fn responses_with_envelopes_and_columns( + blocks: Vec>>, + payloads: Vec>>, + data_columns: DataColumnSidecarList, + column_to_peer: HashMap, + expects_custody_columns: &[ColumnIndex], + attempt: usize, + _spec: Arc, + ) -> Result>, CouplingError> { + let mut columns_by_root = Self::group_columns_by_root(data_columns); + let mut range_sync_blocks = Vec::with_capacity(blocks.len()); + let mut payload_iter = payloads.into_iter().peekable(); + let exceeded_retries = attempt >= MAX_COLUMN_RETRIES; + + for block in blocks { + let mut envelope_for_block = None; + if payload_iter + .peek() + .map(|e| e.message.slot() == block.slot()) + .unwrap_or(false) + { + envelope_for_block = payload_iter.next(); + } + + let block_root = get_block_root(&block); + + let available_envelope = if block.num_expected_blobs() > 0 { + let envelope = envelope_for_block.ok_or_else(|| { + CouplingError::InternalError(format!( + "Missing payload envelope for block {block_root:?} with blobs" + )) + })?; + let custody_columns = Self::extract_custody_columns_for_root( + block_root, + &mut columns_by_root, + expects_custody_columns, + &column_to_peer, + exceeded_retries, + )?; + Some(Box::new(AvailableEnvelope::new( + envelope, + custody_columns, + None, + ))) + } else { + envelope_for_block + .map(|envelope| Box::new(AvailableEnvelope::new(envelope, vec![], None))) + }; + + range_sync_blocks.push(RangeSyncBlock::new_gloas(block, available_envelope)); + } + + if payload_iter.next().is_some() { + let remaining = payload_iter.count() + 1; + debug!( + remaining, + "Received payload envelopes that don't pair with blocks" + ); + } + + if !columns_by_root.is_empty() { + let remaining_roots = columns_by_root.keys().collect::>(); + debug!( + ?remaining_roots, + "Not all columns consumed for Gloas blocks" + ); + } + + Ok(range_sync_blocks) + } + + /// Groups data columns by their block root, logging and skipping duplicates. + fn group_columns_by_root( + data_columns: DataColumnSidecarList, + ) -> HashMap>>> { + let mut by_root = + HashMap::>>>::new(); + for column in data_columns { + let block_root = column.block_root(); + let index = *column.index(); + if by_root + .entry(block_root) + .or_default() + .insert(index, column) + .is_some() + { + // `DataColumnsByRangeRequestItems` ensures no duplicated indices across peers. + // Duplicates are likely a peer sending the same index multiple times; log and skip. + debug!(?block_root, ?index, "Repeated column for block_root"); + } + } + by_root + } + + /// Extracts and validates custody columns for a single block root. + /// + /// Removes the matching entry from `columns_by_root`, checks all expected indices are + /// present, and logs any extras. Returns the raw columns; callers wrap them as needed. + fn extract_custody_columns_for_root( + block_root: Hash256, + columns_by_root: &mut HashMap>>>, + expects_custody_columns: &[ColumnIndex], + column_to_peer: &HashMap, + exceeded_retries: bool, + ) -> Result>>, CouplingError> { + let Some(mut by_index) = columns_by_root.remove(&block_root) else { + let responsible_peers = column_to_peer.iter().map(|c| (*c.0, *c.1)).collect(); + return Err(CouplingError::DataColumnPeerFailure { + error: format!("No columns for block {block_root:?} with data"), + faulty_peers: responsible_peers, + exceeded_retries, + }); + }; + + let mut columns = vec![]; + let mut naughty_peers = vec![]; + for index in expects_custody_columns { + if let Some(col) = by_index.remove(index) { + columns.push(col); + } else { + let Some(responsible_peer) = column_to_peer.get(index) else { + return Err(CouplingError::InternalError(format!( + "Internal error, no request made for column {index}" + ))); + }; + naughty_peers.push((*index, *responsible_peer)); + } + } + if !naughty_peers.is_empty() { + return Err(CouplingError::DataColumnPeerFailure { + error: format!( + "Peers did not return column for block_root {block_root:?} {naughty_peers:?}" + ), + faulty_peers: naughty_peers, + exceeded_retries, + }); + } + + if !by_index.is_empty() { + let remaining_indices = by_index.keys().collect::>(); + debug!( + ?block_root, + ?remaining_indices, + "Not all columns consumed for block" + ); + } + + Ok(columns) + } } impl ByRangeRequest { @@ -494,6 +635,8 @@ mod tests { NumBlobs, generate_rand_block_and_blobs, generate_rand_block_and_data_columns, test_da_checker, test_spec, }; + use bls::Signature; + use lighthouse_network::service::api_types::PayloadEnvelopesByRangeRequestId; use lighthouse_network::{ PeerId, service::api_types::{ @@ -503,7 +646,11 @@ mod tests { }; use std::{collections::HashMap, sync::Arc}; use tracing::Span; - use types::{Epoch, ForkName, MinimalEthSpec as E, SignedBeaconBlock}; + use types::{ + Epoch, EthSpec, ExecutionBlockHash, ExecutionPayloadEnvelope, ExecutionPayloadGloas, + ExecutionRequests, ForkName, Hash256, MinimalEthSpec as E, SignedBeaconBlock, + SignedExecutionPayloadEnvelope, Slot, test_utils::XorShiftRng, + }; fn components_id() -> ComponentsByRangeRequestId { ComponentsByRangeRequestId { @@ -550,15 +697,14 @@ mod tests { fn no_blobs_into_responses() { let spec = Arc::new(test_spec::()); - let mut u = types::test_utils::test_unstructured(); + let mut rng = XorShiftRng::from_seed([42; 16]); let blocks = (0..4) .map(|_| { generate_rand_block_and_blobs::( spec.fork_name_at_epoch(Epoch::new(0)), NumBlobs::None, - &mut u, + &mut rng, ) - .unwrap() .0 .into() }) @@ -566,7 +712,7 @@ mod tests { let blocks_req_id = blocks_id(components_id()); let mut info = - RangeBlockComponentsRequest::::new(blocks_req_id, None, None, Span::none()); + RangeBlockComponentsRequest::::new(blocks_req_id, None, None, None, Span::none()); // Send blocks and complete terminate response info.add_blocks(blocks_req_id, blocks).unwrap(); @@ -597,6 +743,7 @@ mod tests { blocks_req_id, Some(blobs_req_id), None, + None, Span::none(), ); @@ -657,6 +804,7 @@ mod tests { blocks_req_id, None, Some((columns_req_id.clone(), expects_custody_columns.clone())), + None, Span::none(), ); // Send blocks and complete terminate response @@ -733,6 +881,7 @@ mod tests { blocks_req_id, None, Some((columns_req_id.clone(), expected_sampling_columns.clone())), + None, Span::none(), ); @@ -827,6 +976,7 @@ mod tests { blocks_req_id, None, Some((columns_req_id.clone(), expected_sampling_columns.clone())), + None, Span::none(), ); @@ -925,6 +1075,7 @@ mod tests { blocks_req_id, None, Some((columns_req_id.clone(), expected_sampling_columns.clone())), + None, Span::none(), ); @@ -1041,6 +1192,7 @@ mod tests { blocks_req_id, None, Some((columns_req_id.clone(), expected_sampling_columns.clone())), + None, Span::none(), ); @@ -1112,4 +1264,171 @@ mod tests { panic!("Expected PeerFailure error with exceeded_retries=true"); } } + + // --- Gloas tests --- + + fn make_gloas_envelope( + slot: Slot, + rng: &mut impl rand::Rng, + ) -> Arc> { + let envelope = ExecutionPayloadEnvelope { + payload: ExecutionPayloadGloas { + slot_number: slot, + block_hash: ExecutionBlockHash::from_root(Hash256::from(rng.random::<[u8; 32]>())), + ..ExecutionPayloadGloas::default() + }, + execution_requests: ExecutionRequests::default(), + builder_index: 0, + beacon_block_root: Hash256::from(rng.random::<[u8; 32]>()), + parent_beacon_block_root: Hash256::repeat_byte(0), + }; + Arc::new(SignedExecutionPayloadEnvelope { + message: envelope, + signature: Signature::empty(), + }) + } + + fn envelope_id( + parent_request_id: ComponentsByRangeRequestId, + ) -> PayloadEnvelopesByRangeRequestId { + use lighthouse_network::service::api_types::PayloadEnvelopesByRangeRequestId; + PayloadEnvelopesByRangeRequestId { + id: 99, + parent_request_id, + } + } + + #[test] + fn gloas_blocks_couple_with_envelopes() { + let mut spec = test_spec::(); + spec.deneb_fork_epoch = Some(Epoch::new(0)); + spec.fulu_fork_epoch = Some(Epoch::new(0)); + spec.gloas_fork_epoch = Some(Epoch::new(0)); + let spec = Arc::new(spec); + let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); + let mut rng = XorShiftRng::from_seed([42; 16]); + + let blocks = (0..4) + .map(|_| { + let (raw_block, _) = generate_rand_block_and_data_columns::( + ForkName::Gloas, + NumBlobs::None, + &mut rng, + &spec, + ); + Arc::new(raw_block) as Arc> + }) + .collect::>(); + + // Build envelopes with slots matching each block + let envelopes: Vec>> = blocks + .iter() + .map(|b| make_gloas_envelope::(b.slot(), &mut rng)) + .collect(); + + let components_id = components_id(); + let blocks_req_id = blocks_id(components_id); + let env_req_id = envelope_id(components_id); + + let mut info = RangeBlockComponentsRequest::::new( + blocks_req_id, + None, + None, + Some(env_req_id), + Span::none(), + ); + + info.add_blocks(blocks_req_id, blocks).unwrap(); + // Not finished — envelopes still pending + assert!(!is_finished(&mut info)); + + info.add_payload_envelopes(env_req_id, envelopes).unwrap(); + + let result = info.responses(da_checker, spec).unwrap(); + assert!(result.is_ok()); + assert_eq!(result.unwrap().len(), 4); + } + + #[test] + fn gloas_blocks_without_envelopes_succeed() { + // Blocks with no blobs don't require envelopes — they should couple fine with an empty envelope response. + let mut spec = test_spec::(); + spec.deneb_fork_epoch = Some(Epoch::new(0)); + spec.fulu_fork_epoch = Some(Epoch::new(0)); + spec.gloas_fork_epoch = Some(Epoch::new(0)); + let spec = Arc::new(spec); + let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); + let mut rng = XorShiftRng::from_seed([42; 16]); + + let (raw_block, _) = generate_rand_block_and_data_columns::( + ForkName::Gloas, + NumBlobs::None, + &mut rng, + &spec, + ); + let block: Arc> = Arc::new(raw_block); + + let components_id = components_id(); + let blocks_req_id = blocks_id(components_id); + let env_req_id = envelope_id(components_id); + + let mut info = RangeBlockComponentsRequest::::new( + blocks_req_id, + None, + None, + Some(env_req_id), + Span::none(), + ); + + info.add_blocks(blocks_req_id, vec![block]).unwrap(); + // No envelope for this block (peer didn't send one) + info.add_payload_envelopes(env_req_id, vec![]).unwrap(); + + let result = info.responses(da_checker, spec).unwrap(); + assert!(result.is_ok(), "expected Ok, got: {:?}", result); + assert_eq!(result.unwrap().len(), 1); + } + + #[test] + fn gloas_extra_envelopes_are_ignored() { + let mut spec = test_spec::(); + spec.deneb_fork_epoch = Some(Epoch::new(0)); + spec.fulu_fork_epoch = Some(Epoch::new(0)); + spec.gloas_fork_epoch = Some(Epoch::new(0)); + let spec = Arc::new(spec); + let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); + let mut rng = XorShiftRng::from_seed([99; 16]); + + let (raw_block, _) = generate_rand_block_and_data_columns::( + ForkName::Gloas, + NumBlobs::None, + &mut rng, + &spec, + ); + let block: Arc> = Arc::new(raw_block); + let slot = block.slot(); + + let components_id = components_id(); + let blocks_req_id = blocks_id(components_id); + let env_req_id = envelope_id(components_id); + + let mut info = RangeBlockComponentsRequest::::new( + blocks_req_id, + None, + None, + Some(env_req_id), + Span::none(), + ); + + info.add_blocks(blocks_req_id, vec![block]).unwrap(); + // Two envelopes: one matching, one extra at a different slot + let env1 = make_gloas_envelope::(slot, &mut rng); + let env2 = make_gloas_envelope::(Slot::new(slot.as_u64() + 10), &mut rng); + info.add_payload_envelopes(env_req_id, vec![env1, env2]) + .unwrap(); + + let result = info.responses(da_checker, spec).unwrap(); + assert!(result.is_ok()); + assert_eq!(result.unwrap().len(), 1); + } } diff --git a/beacon_node/network/src/sync/manager.rs b/beacon_node/network/src/sync/manager.rs index 14a38f0e72..6c0914c843 100644 --- a/beacon_node/network/src/sync/manager.rs +++ b/beacon_node/network/src/sync/manager.rs @@ -45,6 +45,7 @@ use crate::service::NetworkMessage; use crate::status::ToStatusMessage; use crate::sync::block_lookups::{ BlobRequestState, BlockComponent, BlockRequestState, CustodyRequestState, DownloadResult, + EnvelopeRequestState, }; use crate::sync::custody_backfill_sync::CustodyBackFillSync; use crate::sync::network_context::{PeerGroup, RpcResponseResult}; @@ -59,13 +60,14 @@ use lighthouse_network::service::api_types::{ BlobsByRangeRequestId, BlocksByRangeRequestId, ComponentsByRangeRequestId, CustodyBackFillBatchRequestId, CustodyBackfillBatchId, CustodyRequester, DataColumnsByRangeRequestId, DataColumnsByRangeRequester, DataColumnsByRootRequestId, - DataColumnsByRootRequester, Id, SingleLookupReqId, SyncRequestId, + DataColumnsByRootRequester, Id, PayloadEnvelopesByRangeRequestId, SingleLookupReqId, + SyncRequestId, }; use lighthouse_network::types::{NetworkGlobals, SyncState}; use lighthouse_network::{PeerAction, PeerId}; use logging::crit; use lru_cache::LRUTimeCache; -use slot_clock::SlotClock; +use slot_clock::{SlotClock, timestamp_now}; use std::ops::Sub; use std::sync::Arc; use std::time::Duration; @@ -137,7 +139,7 @@ pub enum SyncMessage { RpcPayloadEnvelope { sync_request_id: SyncRequestId, peer_id: PeerId, - envelope: Option>>, + payload_envelope: Option>>, seen_timestamp: Duration, }, @@ -150,6 +152,13 @@ pub enum SyncMessage { /// A data column with an unknown parent has been received. UnknownParentDataColumn(PeerId, Arc>), + /// A block's parent is known but its execution payload envelope has not been received yet. + UnknownParentEnvelope(PeerId, Arc>, Hash256), + + /// An execution payload envelope has been imported via the local gossip path. + /// Sync uses this to unblock any child lookups that were awaiting this parent envelope. + GossipEnvelopeImported { block_root: Hash256 }, + /// A partial data column with an unknown parent has been received. UnknownParentPartialDataColumn { peer_id: PeerId, @@ -202,7 +211,7 @@ pub enum BlockProcessType { SingleBlock { id: Id }, SingleBlob { id: Id }, SingleCustodyColumn(Id), - SinglePayloadEnvelope(Id), + SinglePayloadEnvelope { id: Id, block_root: Hash256 }, } impl BlockProcessType { @@ -211,7 +220,7 @@ impl BlockProcessType { BlockProcessType::SingleBlock { id } | BlockProcessType::SingleBlob { id } | BlockProcessType::SingleCustodyColumn(id) - | BlockProcessType::SinglePayloadEnvelope(id) => *id, + | BlockProcessType::SinglePayloadEnvelope { id, .. } => *id, } } } @@ -514,7 +523,7 @@ impl SyncManager { self.on_single_blob_response(id, peer_id, RpcEvent::RPCError(error)) } SyncRequestId::SinglePayloadEnvelope { id } => { - self.on_single_payload_envelope_response(id, peer_id, RpcEvent::RPCError(error)) + self.on_single_envelope_response(id, peer_id, RpcEvent::RPCError(error)) } SyncRequestId::DataColumnsByRoot(req_id) => { self.on_data_columns_by_root_response(req_id, peer_id, RpcEvent::RPCError(error)) @@ -528,6 +537,11 @@ impl SyncManager { SyncRequestId::DataColumnsByRange(req_id) => { self.on_data_columns_by_range_response(req_id, peer_id, RpcEvent::RPCError(error)) } + SyncRequestId::SinglePayloadEnvelope { id } => { + self.on_single_envelope_response(id, peer_id, RpcEvent::RPCError(error)) + } + SyncRequestId::PayloadEnvelopesByRange(req_id) => self + .on_payload_envelopes_by_range_response(req_id, peer_id, RpcEvent::RPCError(error)), } } @@ -865,12 +879,12 @@ impl SyncManager { SyncMessage::RpcPayloadEnvelope { sync_request_id, peer_id, - envelope, + payload_envelope, seen_timestamp, } => self.rpc_payload_envelope_received( sync_request_id, peer_id, - envelope, + payload_envelope, seen_timestamp, ), SyncMessage::UnknownParentBlock(peer_id, block, block_root) => { @@ -942,6 +956,35 @@ impl SyncManager { } } } + SyncMessage::UnknownParentEnvelope(peer_id, block, block_root) => { + let block_slot = block.slot(); + let parent_root = block.parent_root(); + debug!( + %block_root, + %parent_root, + "Parent envelope not yet available, creating envelope lookup" + ); + self.handle_unknown_parent_envelope( + peer_id, + block_root, + parent_root, + block_slot, + BlockComponent::Block(DownloadResult { + value: block.block_cloned(), + block_root, + seen_timestamp: timestamp_now(), + peer_group: PeerGroup::from_single(peer_id), + }), + ); + } + SyncMessage::GossipEnvelopeImported { block_root } => { + debug!( + %block_root, + "Gossip-imported envelope; unblocking awaiting child lookups" + ); + self.block_lookups + .continue_envelope_child_lookups(block_root, &mut self.network); + } SyncMessage::UnknownParentPartialDataColumn { peer_id, block_root, @@ -1067,6 +1110,55 @@ impl SyncManager { } } + /// Handle a block whose parent block is known but parent envelope is missing. + /// Creates an envelope-only lookup for the parent and a child lookup that waits for it. + fn handle_unknown_parent_envelope( + &mut self, + peer_id: PeerId, + block_root: Hash256, + parent_root: Hash256, + slot: Slot, + block_component: BlockComponent, + ) { + // Defensive: if the parent's payload envelope was already received between when + // gossip-verification raised `ParentEnvelopeUnknown` and now, no lookup is needed. + if self + .chain + .canonical_head + .fork_choice_read_lock() + .is_payload_received(&parent_root) + { + debug!( + %block_root, + %parent_root, + "Parent envelope already received, skipping envelope lookup" + ); + return; + } + match self.should_search_for_block(Some(slot), &peer_id) { + Ok(_) => { + if self.block_lookups.search_child_and_parent_envelope( + block_root, + block_component, + parent_root, + peer_id, + &mut self.network, + ) { + // Lookups created + } else { + debug!( + ?block_root, + ?parent_root, + "No lookup created for child and parent envelope" + ); + } + } + Err(reason) => { + debug!(%block_root, %parent_root, reason, "Ignoring unknown parent envelope request"); + } + } + } + fn handle_unknown_block_root(&mut self, peer_id: PeerId, block_root: Hash256) { match self.should_search_for_block(None, &peer_id) { Ok(_) => { @@ -1234,27 +1326,6 @@ impl SyncManager { } } - // TODO(gloas): dispatch into block_lookups once the envelope lookup state machine lands. - fn rpc_payload_envelope_received( - &mut self, - sync_request_id: SyncRequestId, - peer_id: PeerId, - envelope: Option>>, - seen_timestamp: Duration, - ) { - match sync_request_id { - SyncRequestId::SinglePayloadEnvelope { id } => self - .on_single_payload_envelope_response( - id, - peer_id, - RpcEvent::from_chunk(envelope, seen_timestamp), - ), - _ => { - crit!(%peer_id, "bad request id for payload envelope"); - } - } - } - fn rpc_data_column_received( &mut self, sync_request_id: SyncRequestId, @@ -1283,19 +1354,52 @@ impl SyncManager { } } - fn on_single_payload_envelope_response( + fn rpc_payload_envelope_received( + &mut self, + sync_request_id: SyncRequestId, + peer_id: PeerId, + payload_envelope: Option>>, + seen_timestamp: Duration, + ) { + match sync_request_id { + SyncRequestId::SinglePayloadEnvelope { id } => { + self.on_single_envelope_response( + id, + peer_id, + RpcEvent::from_chunk(payload_envelope, seen_timestamp), + ); + } + SyncRequestId::PayloadEnvelopesByRange(req_id) => { + self.on_payload_envelopes_by_range_response( + req_id, + peer_id, + RpcEvent::from_chunk(payload_envelope, seen_timestamp), + ); + } + _ => { + crit!(%peer_id, "bad request id for payload_envelope"); + } + } + } + + fn on_single_envelope_response( &mut self, id: SingleLookupReqId, peer_id: PeerId, - envelope: RpcEvent>>, + rpc_event: RpcEvent>>, ) { - if let Some(_resp) = self + if let Some(resp) = self .network - .on_single_payload_envelope_response(id, peer_id, envelope) + .on_single_envelope_response(id, peer_id, rpc_event) { - // TODO(gloas): dispatch into - // `block_lookups.on_download_response::>(...)` once - // the envelope lookup state machine lands. + self.block_lookups + .on_download_response::>( + id, + resp.map(|(value, seen_timestamp)| { + (value, PeerGroup::from_single(peer_id), seen_timestamp) + }), + &mut self.network, + ) } } @@ -1399,6 +1503,24 @@ impl SyncManager { } } + fn on_payload_envelopes_by_range_response( + &mut self, + id: PayloadEnvelopesByRangeRequestId, + peer_id: PeerId, + envelope: RpcEvent>>, + ) { + if let Some(resp) = self + .network + .on_payload_envelopes_by_range_response(id, peer_id, envelope) + { + self.on_range_components_response( + id.parent_request_id, + peer_id, + RangeBlockComponent::PayloadEnvelope(id, resp), + ); + } + } + fn on_custody_by_root_result( &mut self, requester: CustodyRequester, diff --git a/beacon_node/network/src/sync/network_context.rs b/beacon_node/network/src/sync/network_context.rs index 9d5ac40c0a..1881ad7e31 100644 --- a/beacon_node/network/src/sync/network_context.rs +++ b/beacon_node/network/src/sync/network_context.rs @@ -25,14 +25,17 @@ use beacon_chain::block_verification_types::{AsBlock, RangeSyncBlock}; use beacon_chain::{BeaconChain, BeaconChainTypes, BlockProcessStatus, EngineState}; use custody::CustodyRequestResult; use fnv::FnvHashMap; -use lighthouse_network::rpc::methods::{BlobsByRangeRequest, DataColumnsByRangeRequest}; +use lighthouse_network::rpc::methods::{ + BlobsByRangeRequest, DataColumnsByRangeRequest, PayloadEnvelopesByRangeRequest, +}; use lighthouse_network::rpc::{BlocksByRangeRequest, GoodbyeReason, RPCError, RequestType}; pub use lighthouse_network::service::api_types::RangeRequestId; use lighthouse_network::service::api_types::{ AppRequestId, BlobsByRangeRequestId, BlocksByRangeRequestId, ComponentsByRangeRequestId, CustodyBackFillBatchRequestId, CustodyBackfillBatchId, CustodyId, CustodyRequester, DataColumnsByRangeRequestId, DataColumnsByRangeRequester, DataColumnsByRootRequestId, - DataColumnsByRootRequester, Id, SingleLookupReqId, SyncRequestId, + DataColumnsByRootRequester, Id, PayloadEnvelopesByRangeRequestId, SingleLookupReqId, + SyncRequestId, }; use lighthouse_network::{Client, NetworkGlobals, PeerAction, PeerId, ReportSource}; use parking_lot::RwLock; @@ -40,7 +43,7 @@ pub use requests::LookupVerifyError; use requests::{ ActiveRequests, BlobsByRangeRequestItems, BlobsByRootRequestItems, BlocksByRangeRequestItems, BlocksByRootRequestItems, DataColumnsByRangeRequestItems, DataColumnsByRootRequestItems, - PayloadEnvelopesByRootRequestItems, + PayloadEnvelopesByRangeRequestItems, PayloadEnvelopesByRootRequestItems, }; #[cfg(test)] use slot_clock::SlotClock; @@ -220,6 +223,11 @@ pub struct SyncNetworkContext { /// A mapping of active DataColumnsByRange requests data_columns_by_range_requests: ActiveRequests>, + /// A mapping of active PayloadEnvelopesByRange requests + payload_envelopes_by_range_requests: ActiveRequests< + PayloadEnvelopesByRangeRequestId, + PayloadEnvelopesByRangeRequestItems, + >, /// Mapping of active custody column requests for a block root custody_by_root_requests: FnvHashMap>, @@ -257,6 +265,10 @@ pub enum RangeBlockComponent { DataColumnsByRangeRequestId, RpcResponseResult>>>, ), + PayloadEnvelope( + PayloadEnvelopesByRangeRequestId, + RpcResponseResult>>>, + ), } #[cfg(test)] @@ -306,6 +318,7 @@ impl SyncNetworkContext { blocks_by_range_requests: ActiveRequests::new("blocks_by_range"), blobs_by_range_requests: ActiveRequests::new("blobs_by_range"), data_columns_by_range_requests: ActiveRequests::new("data_columns_by_range"), + payload_envelopes_by_range_requests: ActiveRequests::new("payload_envelopes_by_range"), custody_by_root_requests: <_>::default(), components_by_range_requests: FnvHashMap::default(), custody_backfill_data_column_batch_requests: FnvHashMap::default(), @@ -335,6 +348,7 @@ impl SyncNetworkContext { blocks_by_range_requests, blobs_by_range_requests, data_columns_by_range_requests, + payload_envelopes_by_range_requests, // custody_by_root_requests is a meta request of data_columns_by_root_requests custody_by_root_requests: _, // components_by_range_requests is a meta request of various _by_range requests @@ -374,6 +388,14 @@ impl SyncNetworkContext { .active_requests_of_peer(peer_id) .into_iter() .map(|req_id| SyncRequestId::DataColumnsByRange(*req_id)); + let payload_envelope_by_range_ids = payload_envelopes_by_range_requests + .active_requests_of_peer(peer_id) + .into_iter() + .map(|req_id| SyncRequestId::PayloadEnvelopesByRange(*req_id)); + let envelope_by_root_ids = payload_envelopes_by_root_requests + .active_requests_of_peer(peer_id) + .into_iter() + .map(|id| SyncRequestId::SinglePayloadEnvelope { id: *id }); blocks_by_root_ids .chain(blobs_by_root_ids) .chain(payload_envelopes_by_root_ids) @@ -381,6 +403,8 @@ impl SyncNetworkContext { .chain(blocks_by_range_ids) .chain(blobs_by_range_ids) .chain(data_column_by_range_ids) + .chain(payload_envelope_by_range_ids) + .chain(envelope_by_root_ids) .collect() } @@ -438,6 +462,7 @@ impl SyncNetworkContext { blocks_by_range_requests, blobs_by_range_requests, data_columns_by_range_requests, + payload_envelopes_by_range_requests, // custody_by_root_requests is a meta request of data_columns_by_root_requests custody_by_root_requests: _, // components_by_range_requests is a meta request of various _by_range requests @@ -461,6 +486,8 @@ impl SyncNetworkContext { .chain(blocks_by_range_requests.iter_request_peers()) .chain(blobs_by_range_requests.iter_request_peers()) .chain(data_columns_by_range_requests.iter_request_peers()) + .chain(payload_envelopes_by_range_requests.iter_request_peers()) + .chain(payload_envelopes_by_root_requests.iter_request_peers()) { *active_request_count_by_peer.entry(peer_id).or_default() += 1; } @@ -593,24 +620,26 @@ impl SyncNetworkContext { }; // Attempt to find all required custody peers before sending any request or creating an ID - let columns_by_range_peers_to_request = - if matches!(batch_type, ByRangeRequestType::BlocksAndColumns) { - let epoch = Slot::new(*request.start_slot()).epoch(T::EthSpec::slots_per_epoch()); - let column_indexes = self - .chain - .sampling_columns_for_epoch(epoch) - .iter() - .cloned() - .collect(); - Some(self.select_columns_by_range_peers_to_request( - &column_indexes, - column_peers, - active_request_count_by_peer, - peers_to_deprioritize, - )?) - } else { - None - }; + let columns_by_range_peers_to_request = if matches!( + batch_type, + ByRangeRequestType::BlocksAndColumns | ByRangeRequestType::BlocksAndEnvelopesAndColumns + ) { + let epoch = Slot::new(*request.start_slot()).epoch(T::EthSpec::slots_per_epoch()); + let column_indexes = self + .chain + .sampling_columns_for_epoch(epoch) + .iter() + .cloned() + .collect(); + Some(self.select_columns_by_range_peers_to_request( + &column_indexes, + column_peers, + active_request_count_by_peer, + peers_to_deprioritize, + )?) + } else { + None + }; // Create the overall components_by_range request ID before its individual components let id = ComponentsByRangeRequestId { @@ -675,6 +704,28 @@ impl SyncNetworkContext { .transpose()?; let epoch = Slot::new(*request.start_slot()).epoch(T::EthSpec::slots_per_epoch()); + + // Send envelope request for Gloas epochs + let payloads_req_id = + if matches!(batch_type, ByRangeRequestType::BlocksAndEnvelopesAndColumns) { + Some(self.send_payload_envelopes_by_range_request( + block_peer, + PayloadEnvelopesByRangeRequest { + start_slot: *request.start_slot(), + count: *request.count(), + }, + id, + new_range_request_span!( + self, + "outgoing_envelopes_by_range", + range_request_span.clone(), + block_peer + ), + )?) + } else { + None + }; + let info = RangeBlockComponentsRequest::new( blocks_req_id, blobs_req_id, @@ -684,6 +735,7 @@ impl SyncNetworkContext { self.chain.sampling_columns_for_epoch(epoch).to_vec(), ) }), + payloads_req_id, range_request_span, ); self.components_by_range_requests.insert(id, info); @@ -786,6 +838,17 @@ impl SyncNetworkContext { }) }) } + RangeBlockComponent::PayloadEnvelope(req_id, resp) => { + resp.and_then(|(envelopes, _)| { + request + .add_payload_envelopes(req_id, envelopes) + .map_err(|e| { + RpcResponseError::BlockComponentCouplingError( + CouplingError::InternalError(e), + ) + }) + }) + } } } { entry.remove(); @@ -943,9 +1006,8 @@ impl SyncNetworkContext { Ok(LookupRequestResult::RequestSent(id.req_id)) } - /// Request a payload envelope for a block root via PayloadEnvelopesByRoot RPC. - #[allow(dead_code)] - pub fn payload_lookup_request( + /// Request a payload envelope for `block_root` from a peer. + pub fn envelope_lookup_request( &mut self, lookup_id: SingleLookupId, lookup_peers: Arc>>, @@ -959,6 +1021,7 @@ impl SyncNetworkContext { )); } + let active_request_count_by_peer = self.active_request_count_by_peer(); let Some(peer_id) = lookup_peers .read() @@ -1005,14 +1068,17 @@ impl SyncNetworkContext { "Sync RPC request sent" ); + let request_span = debug_span!( + parent: Span::current(), + "lh_outgoing_envelope_by_root_request", + %block_root, + ); self.payload_envelopes_by_root_requests.insert( id, peer_id, - // true = enforce that the peer returns a response. We only request a single envelope - // and the peer must have it. true, PayloadEnvelopesByRootRequestItems::new(request), - Span::none(), + request_span, ); Ok(LookupRequestResult::RequestSent(id.req_id)) @@ -1391,6 +1457,57 @@ impl SyncNetworkContext { Ok((id, requested_columns)) } + fn send_payload_envelopes_by_range_request( + &mut self, + peer_id: PeerId, + request: PayloadEnvelopesByRangeRequest, + parent_request_id: ComponentsByRangeRequestId, + request_span: Span, + ) -> Result { + let id = PayloadEnvelopesByRangeRequestId { + id: self.next_id(), + parent_request_id, + }; + + self.send_network_msg(NetworkMessage::SendRequest { + peer_id, + request: RequestType::PayloadEnvelopesByRange(request.clone()), + app_request_id: AppRequestId::Sync(SyncRequestId::PayloadEnvelopesByRange(id)), + }) + .map_err(|_| RpcRequestSendError::InternalError("network send error".to_owned()))?; + + debug!( + method = "PayloadEnvelopesByRange", + slots = request.count, + epoch = %Slot::new(request.start_slot).epoch(T::EthSpec::slots_per_epoch()), + peer = %peer_id, + %id, + "Sync RPC request sent" + ); + + self.payload_envelopes_by_range_requests.insert( + id, + peer_id, + false, + PayloadEnvelopesByRangeRequestItems::new(request), + request_span, + ); + Ok(id) + } + + #[allow(clippy::type_complexity)] + pub(crate) fn on_payload_envelopes_by_range_response( + &mut self, + id: PayloadEnvelopesByRangeRequestId, + peer_id: PeerId, + rpc_event: RpcEvent>>, + ) -> Option>>>> { + let resp = self + .payload_envelopes_by_range_requests + .on_response(id, rpc_event); + self.on_rpc_response_result(resp, peer_id) + } + pub fn is_execution_engine_online(&self) -> bool { self.execution_engine_state == EngineState::Online } @@ -1472,6 +1589,12 @@ impl SyncNetworkContext { ); if self + .chain + .data_availability_checker + .envelopes_required_for_epoch(epoch) + { + ByRangeRequestType::BlocksAndEnvelopesAndColumns + } else if self .chain .data_availability_checker .data_columns_required_for_epoch(epoch) @@ -1538,6 +1661,27 @@ impl SyncNetworkContext { self.on_rpc_response_result(resp, peer_id) } + pub(crate) fn on_single_envelope_response( + &mut self, + id: SingleLookupReqId, + peer_id: PeerId, + rpc_event: RpcEvent>>, + ) -> Option>>> { + let resp = self + .payload_envelopes_by_root_requests + .on_response(id, rpc_event); + let resp = resp.map(|res| { + res.and_then(|(mut envelopes, seen_timestamp)| { + match envelopes.pop() { + Some(envelope) => Ok((envelope, seen_timestamp)), + // Should never happen, request items enforces at least 1 chunk. + None => Err(LookupVerifyError::NotEnoughResponsesReturned { actual: 0 }.into()), + } + }) + }); + self.on_rpc_response_result(resp, peer_id) + } + pub(crate) fn on_single_blob_response( &mut self, id: SingleLookupReqId, @@ -1734,6 +1878,33 @@ impl SyncNetworkContext { }) } + pub fn send_envelope_for_processing( + &self, + id: Id, + envelope: Arc>, + seen_timestamp: Duration, + block_root: Hash256, + ) -> Result<(), SendErrorProcessor> { + let beacon_processor = self + .beacon_processor_if_enabled() + .ok_or(SendErrorProcessor::ProcessorNotAvailable)?; + + debug!(?block_root, ?id, "Sending payload envelope for processing"); + beacon_processor + .send_rpc_payload_envelope( + envelope, + seen_timestamp, + BlockProcessType::SinglePayloadEnvelope { id, block_root }, + ) + .map_err(|e| { + error!( + error = ?e, + "Failed to send sync envelope to processor" + ); + SendErrorProcessor::SendError + }) + } + pub fn send_blobs_for_processing( &self, id: Id, @@ -1941,6 +2112,14 @@ impl SyncNetworkContext { "data_columns_by_range", self.data_columns_by_range_requests.len(), ), + ( + "payload_envelopes_by_range", + self.payload_envelopes_by_range_requests.len(), + ), + ( + "payload_envelopes_by_root", + self.payload_envelopes_by_root_requests.len(), + ), ("custody_by_root", self.custody_by_root_requests.len()), ( "components_by_range", diff --git a/beacon_node/network/src/sync/network_context/requests.rs b/beacon_node/network/src/sync/network_context/requests.rs index 8c091eca80..872b3293da 100644 --- a/beacon_node/network/src/sync/network_context/requests.rs +++ b/beacon_node/network/src/sync/network_context/requests.rs @@ -16,6 +16,7 @@ pub use data_columns_by_range::DataColumnsByRangeRequestItems; pub use data_columns_by_root::{ DataColumnsByRootRequestItems, DataColumnsByRootSingleBlockRequest, }; +pub use payload_envelopes_by_range::PayloadEnvelopesByRangeRequestItems; pub use payload_envelopes_by_root::{ PayloadEnvelopesByRootRequestItems, PayloadEnvelopesByRootSingleRequest, }; @@ -30,6 +31,7 @@ mod blocks_by_range; mod blocks_by_root; mod data_columns_by_range; mod data_columns_by_root; +mod payload_envelopes_by_range; mod payload_envelopes_by_root; #[derive(Debug, PartialEq, Eq, IntoStaticStr)] diff --git a/beacon_node/network/src/sync/network_context/requests/payload_envelopes_by_range.rs b/beacon_node/network/src/sync/network_context/requests/payload_envelopes_by_range.rs new file mode 100644 index 0000000000..3d4ea8248b --- /dev/null +++ b/beacon_node/network/src/sync/network_context/requests/payload_envelopes_by_range.rs @@ -0,0 +1,42 @@ +use super::{ActiveRequestItems, LookupVerifyError}; +use lighthouse_network::rpc::methods::PayloadEnvelopesByRangeRequest; +use std::sync::Arc; +use types::{EthSpec, SignedExecutionPayloadEnvelope}; + +/// Accumulates results of a payload_envelopes_by_range request. Only returns items after +/// receiving the stream termination. +pub struct PayloadEnvelopesByRangeRequestItems { + request: PayloadEnvelopesByRangeRequest, + items: Vec>>, +} + +impl PayloadEnvelopesByRangeRequestItems { + pub fn new(request: PayloadEnvelopesByRangeRequest) -> Self { + Self { + request, + items: vec![], + } + } +} + +impl ActiveRequestItems for PayloadEnvelopesByRangeRequestItems { + type Item = Arc>; + + fn add(&mut self, envelope: Self::Item) -> Result { + let slot = envelope.slot(); + if slot < self.request.start_slot || slot >= self.request.start_slot + self.request.count { + return Err(LookupVerifyError::UnrequestedSlot(slot)); + } + if self.items.iter().any(|existing| existing.slot() == slot) { + return Err(LookupVerifyError::DuplicatedData(slot, 0)); + } + + self.items.push(envelope); + + Ok(false) + } + + fn consume(&mut self) -> Vec { + std::mem::take(&mut self.items) + } +} diff --git a/beacon_node/network/src/sync/range_sync/range.rs b/beacon_node/network/src/sync/range_sync/range.rs index 6509ac3cb3..80fee1c5ca 100644 --- a/beacon_node/network/src/sync/range_sync/range.rs +++ b/beacon_node/network/src/sync/range_sync/range.rs @@ -93,6 +93,22 @@ where } } + /// For Gloas, start range sync one epoch earlier so the first batch fetches the + /// parent block's payload envelope. Without this, the first block in the batch + /// fails `load_parent` because the preceding block's envelope isn't in the store. + fn gloas_adjusted_start_epoch(&self, epoch: Epoch) -> Epoch { + if self + .beacon_chain + .spec + .gloas_fork_epoch + .is_some_and(|gloas_epoch| epoch > gloas_epoch) + { + epoch.saturating_sub(1_u64) + } else { + epoch + } + } + #[cfg(test)] pub(crate) fn __failed_chains(&mut self) -> Vec { self.failed_chains.keys().copied().collect() @@ -156,8 +172,13 @@ where // Note: We keep current head chains. These can continue syncing whilst we complete // this new finalized chain. + // Start one epoch earlier for Gloas so the first batch includes + // the parent block's envelope. Without this, the first block in the + // batch fails because `load_parent` can't find the parent's envelope. + let start_epoch = self.gloas_adjusted_start_epoch(local_info.finalized_epoch); + self.chains.add_peer_or_create_chain( - local_info.finalized_epoch, + start_epoch, remote_info.finalized_root, target_head_slot, peer_id, @@ -188,6 +209,7 @@ where let start_epoch = std::cmp::min(local_info.head_slot, remote_finalized_slot) .epoch(T::EthSpec::slots_per_epoch()); + let start_epoch = self.gloas_adjusted_start_epoch(start_epoch); self.chains.add_peer_or_create_chain( start_epoch, remote_info.head_root, diff --git a/beacon_node/network/src/sync/tests/lookups.rs b/beacon_node/network/src/sync/tests/lookups.rs index c1b2793491..8b7a1bd37b 100644 --- a/beacon_node/network/src/sync/tests/lookups.rs +++ b/beacon_node/network/src/sync/tests/lookups.rs @@ -37,7 +37,8 @@ use tokio::sync::mpsc; use tracing::info; use types::{ BlobSidecar, BlockImportSource, ColumnIndex, DataColumnSidecar, EthSpec, ForkContext, ForkName, - Hash256, MinimalEthSpec as E, SignedBeaconBlock, Slot, + Hash256, MinimalEthSpec as E, SignedBeaconBlock, SignedExecutionPayloadEnvelope, Slot, + test_utils::{SeedableRng, XorShiftRng}, }; const D: Duration = Duration::new(0, 0); @@ -84,6 +85,9 @@ pub struct SimulateConfig { ee_offline_for_n_range_responses: Option, /// Disconnect all peers after this many successful BlocksByRange responses. successful_range_responses_before_disconnect: Option, + /// Number of `PayloadEnvelopesByRoot` responses that return an envelope for a + /// different block_root than requested. + return_wrong_envelopes_n_times: usize, } impl SimulateConfig { @@ -115,6 +119,11 @@ impl SimulateConfig { self } + fn return_wrong_envelope_once(mut self) -> Self { + self.return_wrong_envelopes_n_times = 1; + self + } + fn return_wrong_sidecar_for_block_once(mut self) -> Self { self.return_wrong_sidecar_for_block_n_times = 1; self @@ -208,6 +217,9 @@ pub(crate) struct TestRigConfig { fulu_test_type: FuluTestType, /// Override the node custody type derived from `fulu_test_type` node_custody_type_override: Option, + /// Override the number of validators in the harness genesis state. Defaults to 1. + /// Some forks (e.g. Gloas) cannot initialise a state with a single validator. + validator_count_override: Option, } impl TestRig { @@ -221,9 +233,9 @@ impl TestRig { ); // Initialise a new beacon chain - let harness = BeaconChainHarness::>::builder(E) + let mut builder = BeaconChainHarness::>::builder(E) .spec(spec.clone()) - .deterministic_keypairs(1) + .deterministic_keypairs(test_rig_config.validator_count_override.unwrap_or(1)) .fresh_ephemeral_store() .mock_execution_layer() .testing_slot_clock(clock.clone()) @@ -231,8 +243,17 @@ impl TestRig { test_rig_config .node_custody_type_override .unwrap_or_else(|| test_rig_config.fulu_test_type.we_node_custody_type()), - ) - .build(); + ); + // Post-Electra forks need validators with effective balance close to + // `max_effective_balance_electra` for balance-weighted committee + // selection (sync committee, PTC) to converge during genesis. + if spec.electra_fork_epoch == Some(types::Epoch::new(0)) { + let max_eb = spec.max_effective_balance_electra; + builder = builder.with_genesis_state_builder(move |b| { + b.set_initial_balance_fn(Box::new(move |_| max_eb)) + }); + } + let harness = builder.build(); let chain = harness.chain.clone(); let fork_context = Arc::new(ForkContext::new::( @@ -303,6 +324,7 @@ impl TestRig { fork_name, network_blocks_by_root: <_>::default(), network_blocks_by_slot: <_>::default(), + network_envelopes_by_root: <_>::default(), penalties: <_>::default(), seen_lookups: <_>::default(), requests: <_>::default(), @@ -317,6 +339,7 @@ impl TestRig { Self::new(TestRigConfig { fulu_test_type: FuluTestType::WeFullnodeThemSupernode, node_custody_type_override: None, + validator_count_override: None, }) } @@ -325,6 +348,7 @@ impl TestRig { Self::new(TestRigConfig { fulu_test_type: FuluTestType::WeFullnodeThemSupernode, node_custody_type_override: Some(node_custody_type), + validator_count_override: None, }) } @@ -427,9 +451,9 @@ impl TestRig { process_fn.await } } - Work::RpcBlobs { process_fn } | Work::RpcCustodyColumn(process_fn) => { - process_fn.await - } + Work::RpcBlobs { process_fn } + | Work::RpcCustodyColumn(process_fn) + | Work::RpcPayloadEnvelope { process_fn } => process_fn.await, Work::ChainSegment { process_fn, process_id: (chain_id, batch_epoch), @@ -669,6 +693,45 @@ impl TestRig { self.send_rpc_columns_response(req_id, peer_id, &columns); } + (RequestType::PayloadEnvelopesByRoot(req), AppRequestId::Sync(req_id)) => { + if self.complete_strategy.return_no_data_n_times > 0 { + self.complete_strategy.return_no_data_n_times -= 1; + return self.send_rpc_envelopes_response(req_id, peer_id, &[]); + } + + if self.complete_strategy.return_wrong_envelopes_n_times > 0 { + self.complete_strategy.return_wrong_envelopes_n_times -= 1; + // Return any envelope that doesn't match the request, so the + // request items layer raises `UnrequestedBlockRoot`. + let requested = req + .beacon_block_roots + .iter() + .copied() + .collect::>(); + let wrong = self + .network_envelopes_by_root + .iter() + .find(|(root, _)| !requested.contains(*root)) + .map(|(_, envelope)| envelope.clone()) + .expect("test fixture must produce at least one extra envelope"); + return self.send_rpc_envelopes_response(req_id, peer_id, &[wrong]); + } + + let envelopes = req + .beacon_block_roots + .iter() + .map(|block_root| { + self.network_envelopes_by_root + .get(block_root) + .unwrap_or_else(|| { + panic!("Test consumer requested unknown envelope: {block_root:?}") + }) + .clone() + }) + .collect::>(); + self.send_rpc_envelopes_response(req_id, peer_id, &envelopes); + } + (RequestType::BlocksByRange(req), AppRequestId::Sync(req_id)) => { if self.complete_strategy.skip_by_range_routes { return; @@ -892,6 +955,36 @@ impl TestRig { }); } + fn send_rpc_envelopes_response( + &mut self, + sync_request_id: SyncRequestId, + peer_id: PeerId, + envelopes: &[Arc>], + ) { + let block_roots = envelopes + .iter() + .map(|e| e.beacon_block_root()) + .collect::>(); + self.log(&format!( + "Completing request {sync_request_id:?} to {peer_id} with envelopes for {block_roots:?}" + )); + + for envelope in envelopes { + self.push_sync_message(SyncMessage::RpcPayloadEnvelope { + sync_request_id, + peer_id, + envelope: Some(envelope.clone()), + seen_timestamp: D, + }); + } + self.push_sync_message(SyncMessage::RpcPayloadEnvelope { + sync_request_id, + peer_id, + envelope: None, + seen_timestamp: D, + }); + } + fn send_rpc_columns_response( &mut self, sync_request_id: SyncRequestId, @@ -934,16 +1027,25 @@ impl TestRig { pub(super) async fn build_chain(&mut self, block_count: usize) -> Hash256 { let mut blocks = vec![]; - // Initialise a new beacon chain - let external_harness = BeaconChainHarness::>::builder(E) + // Initialise a new beacon chain. Match the local harness's validator count and + // balance hooks so post-Electra forks (where genesis-time committee selection is + // balance-weighted) can initialise. + let validator_count = self.harness.validator_keypairs.len(); + let mut builder = BeaconChainHarness::>::builder(E) .spec(self.harness.spec.clone()) - .deterministic_keypairs(1) + .deterministic_keypairs(validator_count) .fresh_ephemeral_store() .mock_execution_layer() .testing_slot_clock(self.harness.chain.slot_clock.clone()) // Make the external harness a supernode so all columns are available - .node_custody_type(NodeCustodyType::Supernode) - .build(); + .node_custody_type(NodeCustodyType::Supernode); + if self.harness.spec.electra_fork_epoch == Some(types::Epoch::new(0)) { + let max_eb = self.harness.spec.max_effective_balance_electra; + builder = builder.with_genesis_state_builder(move |b| { + b.set_initial_balance_fn(Box::new(move |_| max_eb)) + }); + } + let external_harness = builder.build(); // Ensure all blocks have data. Otherwise, the triggers for unknown blob parent and unknown // data column parent fail. external_harness @@ -972,6 +1074,16 @@ impl TestRig { self.network_blocks_by_root .insert(block_root, block.clone()); self.network_blocks_by_slot.insert(block_slot, block); + // Post-Gloas, also capture the execution payload envelope so peers can serve it. + if self.is_after_gloas() + && let Ok(Some(envelope)) = external_harness + .chain + .store + .get_payload_envelope(&block_root) + { + self.network_envelopes_by_root + .insert(block_root, Arc::new(envelope)); + } self.log(&format!( "Produced block {} index {i} in external harness", block_slot, @@ -1000,6 +1112,21 @@ impl TestRig { self.re_insert_block(Arc::new(block), blobs, columns); } + /// Replace the cached envelope's signature for `block_root` with one signed by an + /// unrelated key, so it fails verification against the proposer's pubkey. + fn corrupt_envelope_signature_for(&mut self, block_root: Hash256) { + let envelope = self + .network_envelopes_by_root + .get(&block_root) + .expect("no envelope cached for block_root") + .as_ref() + .clone(); + let mut envelope = envelope; + envelope.signature = self.valid_signature(); + self.network_envelopes_by_root + .insert(block_root, Arc::new(envelope)); + } + fn valid_signature(&mut self) -> bls::Signature { let keypair = bls::Keypair::random(); let msg = Hash256::random(); @@ -1176,6 +1303,32 @@ impl TestRig { self.harness.chain.recompute_head_at_current_slot().await; } + /// Persist a Gloas execution payload envelope into the local chain and mark the + /// block as "payload received" in fork choice. Mimics the side-effects of the + /// gossip-import path, including the `GossipEnvelopeImported` sync notification. + /// The caller is responsible for ensuring the corresponding beacon block is + /// already imported. + async fn import_envelope_for_block_root(&mut self, block_root: Hash256) { + let envelope = self + .network_envelopes_by_root + .get(&block_root) + .unwrap_or_else(|| panic!("no envelope cached for {block_root:?}")) + .as_ref() + .clone(); + self.harness + .chain + .store + .put_payload_envelope(&block_root, &envelope) + .expect("should store envelope"); + self.harness + .chain + .canonical_head + .fork_choice_write_lock() + .on_valid_payload_envelope_received(block_root) + .expect("should update fork choice with envelope"); + self.push_sync_message(SyncMessage::GossipEnvelopeImported { block_root }); + } + /// Import a block directly into the chain without going through lookup sync async fn import_block_by_root(&mut self, block_root: Hash256) { let range_sync_block = self @@ -1442,6 +1595,7 @@ impl TestRig { Self::new(TestRigConfig { fulu_test_type, node_custody_type_override: None, + validator_count_override: None, }) }) } @@ -1458,6 +1612,22 @@ impl TestRig { self.fork_name.fulu_enabled() } + pub fn is_after_gloas(&self) -> bool { + self.fork_name.gloas_enabled() + } + + fn new_after_gloas() -> Option { + // Gloas requires more than 1 validator to initialise the genesis state + // (committee/sampling computations fail with `InvalidIndicesCount`). + genesis_fork().gloas_enabled().then(|| { + Self::new(TestRigConfig { + fulu_test_type: FuluTestType::WeFullnodeThemSupernode, + node_custody_type_override: None, + validator_count_override: Some(1024), + }) + }) + } + fn trigger_unknown_parent_block(&mut self, peer_id: PeerId, block: Arc>) { let block_root = block.canonical_root(); self.send_sync_message(SyncMessage::UnknownParentBlock(peer_id, block, block_root)) @@ -1481,6 +1651,18 @@ impl TestRig { )); } + /// Trigger an envelope-unknown lookup for the last block in the chain. Caller is + /// expected to have already imported the parent block (via `import_blocks_up_to_slot`) + /// without registering its envelope. + fn trigger_with_last_unknown_parent_envelope(&mut self) { + let peer_id = self.new_connected_supernode_peer(); + let last_block = self.get_last_block().block_cloned(); + let block_root = last_block.canonical_root(); + self.send_sync_message(SyncMessage::UnknownParentEnvelope( + peer_id, last_block, block_root, + )); + } + fn rand_block(&mut self) -> SignedBeaconBlock { self.rand_block_and_blobs(NumBlobs::None).0 } @@ -2637,3 +2819,172 @@ async fn crypto_on_fail_with_bad_column_kzg_proof() { r.assert_penalties_of_type("lookup_custody_column_processing_failure"); } } + +// --------------------------------------------------------------------------- +// Gloas: parent envelope unknown lookup +// --------------------------------------------------------------------------- +// +// These tests exercise the lookup-sync state machine introduced in PR #9039: +// when a gossip block's parent execution payload envelope is missing, +// `SyncManager` is expected to create two single-block lookups — an envelope-only +// lookup for the parent block_root and a "child" lookup that holds the gossip +// block and waits on `AwaitingParent::Envelope(parent_root)`. The envelope-only +// lookup issues a `PayloadEnvelopesByRoot` RPC; on completion it unblocks the +// child via `continue_envelope_child_lookups`. +// +// The tests below cover lookup creation, RPC routing, and drop-cascade +// behaviour. The end-to-end happy path is gated on +// `process_execution_payload_envelope` supporting `AvailabilityPending` (today +// it returns `InternalError("Pending payload envelope not yet implemented")`), +// which is tracked separately. See `process_rpc_envelope` in `sync_methods.rs`. + +/// Builds a 2-block gloas chain in the external harness and locally imports block 1 +/// (parent) WITHOUT registering its envelope, leaving `is_payload_received(parent_root)` +/// false — the precondition for `BlockError::ParentEnvelopeUnknown`. +async fn setup_unknown_parent_envelope_scenario() -> Option { + let mut r = TestRig::new_after_gloas()?; + r.build_chain(2).await; + r.import_blocks_up_to_slot(1).await; + Some(r) +} + +fn payload_envelope_request_count(rig: &TestRig) -> usize { + rig.requests + .iter() + .filter(|(request, _)| matches!(request, RequestType::PayloadEnvelopesByRoot(_))) + .count() +} + +/// Triggering `UnknownParentEnvelope` creates exactly two lookups: an envelope-only +/// lookup for the parent and a child lookup for the gossip block awaiting that envelope. +#[tokio::test] +async fn unknown_parent_envelope_creates_two_lookups() { + let Some(mut r) = setup_unknown_parent_envelope_scenario().await else { + return; + }; + r.trigger_with_last_unknown_parent_envelope(); + r.assert_single_lookups_count(2); +} + +/// Repeated `UnknownParentEnvelope` triggers for the same parent must not spawn extra +/// lookups (peers are merged into the existing envelope lookup). +#[tokio::test] +async fn happy_path_unknown_parent_envelope_multiple_triggers() { + let Some(mut r) = setup_unknown_parent_envelope_scenario().await else { + return; + }; + r.trigger_with_last_unknown_parent_envelope(); + r.trigger_with_last_unknown_parent_envelope(); + r.assert_single_lookups_count(2); +} + +/// The envelope-only lookup must dispatch a `PayloadEnvelopesByRoot` RPC for the +/// parent block_root. +#[tokio::test] +async fn envelope_lookup_issues_by_root_rpc() { + let Some(mut r) = setup_unknown_parent_envelope_scenario().await else { + return; + }; + r.trigger_with_last_unknown_parent_envelope(); + r.simulate(SimulateConfig::new()).await; + assert_eq!( + payload_envelope_request_count(&r), + 1, + "expected exactly one PayloadEnvelopesByRoot request" + ); +} + +/// One transient RPC error on the envelope request → lookup retries with the same peer +/// and completes successfully. Mirrors the `bad_peer_rpc_failure` shape used for blocks. +#[tokio::test] +async fn bad_peer_envelope_rpc_failure() { + let Some(mut r) = setup_unknown_parent_envelope_scenario().await else { + return; + }; + r.trigger_with_last_unknown_parent_envelope(); + r.simulate(SimulateConfig::new().return_rpc_error(RPCError::IoError("test".into()))) + .await; + r.assert_successful_lookup_sync(); + r.assert_head_slot(2); +} + +/// Peer responds once with an envelope for a different block_root than requested. +/// The request-items layer raises `UnrequestedBlockRoot`, the peer is penalised, and +/// the lookup retries successfully on the next request. +#[tokio::test] +async fn bad_peer_wrong_envelope_response() { + let Some(mut r) = setup_unknown_parent_envelope_scenario().await else { + return; + }; + r.trigger_with_last_unknown_parent_envelope(); + r.simulate(SimulateConfig::new().return_wrong_envelope_once()) + .await; + r.assert_penalties_of_type("UnrequestedBlockRoot"); + r.assert_successful_lookup_sync(); + r.assert_head_slot(2); +} + +/// Trigger `UnknownParentEnvelope` when the parent's payload envelope is already +/// in fork choice. Sync should treat the trigger as a no-op and create no lookups. +#[tokio::test] +async fn envelope_already_received_skips_lookup() { + let Some(mut r) = setup_unknown_parent_envelope_scenario().await else { + return; + }; + let parent_root = r.get_last_block().block_cloned().parent_root(); + r.import_envelope_for_block_root(parent_root).await; + r.trigger_with_last_unknown_parent_envelope(); + r.assert_single_lookups_count(0); +} + +/// End-to-end: an envelope-only RPC lookup completes, the cached child block is +/// processed, and the head advances to the gossip block. +#[tokio::test] +async fn happy_path_unknown_parent_envelope() { + let Some(mut r) = setup_unknown_parent_envelope_scenario().await else { + return; + }; + r.trigger_with_last_unknown_parent_envelope(); + r.simulate(SimulateConfig::happy_path()).await; + r.assert_successful_lookup_sync(); + r.assert_head_slot(2); + r.assert_no_penalties(); +} + +/// While an envelope-only RPC lookup is pending, the same envelope is imported +/// via the gossip path. The child lookup should still unblock and import. +#[tokio::test] +async fn happy_path_unknown_parent_envelope_via_gossip() { + let Some(mut r) = setup_unknown_parent_envelope_scenario().await else { + return; + }; + let parent_root = r.get_last_block().block_cloned().parent_root(); + r.trigger_with_last_unknown_parent_envelope(); + // Import the envelope via the local gossip path before any RPC response arrives. + r.import_envelope_for_block_root(parent_root).await; + r.simulate(SimulateConfig::happy_path()).await; + r.assert_successful_lookup_sync(); + r.assert_head_slot(2); +} + +/// Peer returns the requested envelope but with a corrupted signature. Gossip +/// verification rejects it; the lookup retries (single peer → exhaust → drop) +/// and reports `lookup_envelope_processing_failure` against the peer. +#[tokio::test] +async fn crypto_on_fail_with_bad_envelope_signature() { + let Some(mut r) = setup_unknown_parent_envelope_scenario().await else { + return; + }; + let parent_root = r.get_last_block().block_cloned().parent_root(); + r.corrupt_envelope_signature_for(parent_root); + r.trigger_with_last_unknown_parent_envelope(); + r.simulate(SimulateConfig::happy_path()).await; + if cfg!(feature = "fake_crypto") { + // Under fake_crypto, signature checks are no-ops, so a "corrupted" + // signature still passes. Skip — analogous to the existing + // `crypto_on_fail_with_invalid_block_signature` test. + return; + } + r.assert_failed_lookup_sync(); + r.assert_penalties_of_type("lookup_envelope_processing_failure"); +} diff --git a/beacon_node/network/src/sync/tests/mod.rs b/beacon_node/network/src/sync/tests/mod.rs index 4e185cc081..83c08cc8b8 100644 --- a/beacon_node/network/src/sync/tests/mod.rs +++ b/beacon_node/network/src/sync/tests/mod.rs @@ -21,7 +21,7 @@ use tokio::sync::mpsc; use tracing_subscriber::fmt::MakeWriter; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; -use types::{ForkName, Hash256, MinimalEthSpec as E, Slot}; +use types::{ForkName, Hash256, MinimalEthSpec as E, SignedExecutionPayloadEnvelope, Slot}; mod lookups; mod range; @@ -77,6 +77,8 @@ struct TestRig { /// Blocks that will be used in the test but may not be known to `harness` yet. network_blocks_by_root: HashMap>, network_blocks_by_slot: HashMap>, + /// Execution payload envelopes (Gloas) keyed by beacon block root, available to peers. + network_envelopes_by_root: HashMap>>, penalties: Vec, /// All seen lookups through the test run seen_lookups: HashMap, diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index 2de8ce7d81..b57905baf2 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -416,11 +416,24 @@ where let (execution_status, execution_payload_parent_hash, execution_payload_block_hash) = if let Ok(signed_bid) = anchor_block.message().body().signed_execution_payload_bid() { - // Gloas: execution status is irrelevant post-Gloas; payload validation - // is decoupled from beacon blocks. + // At Gloas genesis the block bid is empty (all zeros) per spec, but the + // state holds the EL genesis hash in `latest_block_hash`. Use it so the + // first forkchoice update sends a valid head to the EL. + let parent_hash = if anchor_block.slot() == spec.genesis_slot + && anchor_state.slot() == spec.genesis_slot + && signed_bid.message.parent_block_hash.into_root().is_zero() + && signed_bid.message.block_hash.into_root().is_zero() + { + *anchor_state + .latest_block_hash() + .map_err(Error::BeaconStateError)? + } else { + signed_bid.message.parent_block_hash + }; + ( ExecutionStatus::irrelevant(), - Some(signed_bid.message.parent_block_hash), + Some(parent_hash), Some(signed_bid.message.block_hash), ) } else if let Ok(execution_payload) = anchor_block.message().execution_payload() { @@ -571,10 +584,10 @@ where b.execution_status .block_hash() .or(match head_payload_status { - PayloadStatus::Full => b.execution_payload_block_hash, - PayloadStatus::Pending | PayloadStatus::Empty => { - b.execution_payload_parent_hash + PayloadStatus::Full | PayloadStatus::Pending => { + b.execution_payload_block_hash } + PayloadStatus::Empty => b.execution_payload_parent_hash, }) }); let justified_root = self.justified_checkpoint().root;