From 33b5c934abd49701e5ee136512bcfa8dddc390e3 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Mon, 29 Jun 2026 17:02:23 -0400 Subject: [PATCH 1/4] Implement backfill for envelope --- .../src/data_availability_checker.rs | 25 ++++++ .../beacon_chain/src/historical_blocks.rs | 47 +++++++++- beacon_node/beacon_chain/tests/store_tests.rs | 17 +++- .../network_beacon_processor/sync_methods.rs | 90 ++++++++++++------- 4 files changed, 141 insertions(+), 38 deletions(-) diff --git a/beacon_node/beacon_chain/src/data_availability_checker.rs b/beacon_node/beacon_chain/src/data_availability_checker.rs index e559dc7689a..b0cecf3eca1 100644 --- a/beacon_node/beacon_chain/src/data_availability_checker.rs +++ b/beacon_node/beacon_chain/src/data_availability_checker.rs @@ -35,6 +35,7 @@ use crate::metrics::{ KZG_DATA_COLUMN_RECONSTRUCTION_ATTEMPTS, KZG_DATA_COLUMN_RECONSTRUCTION_FAILURES, }; use crate::observed_data_sidecars::ObservationStrategy; +use crate::payload_envelope_verification::AvailableEnvelope; pub use error::{Error as AvailabilityCheckError, ErrorCategory as AvailabilityCheckErrorCategory}; /// The LRU Cache stores `PendingComponents`, which store block and its associated blob data: @@ -507,6 +508,30 @@ impl DataAvailabilityChecker { Ok(()) } + /// Batch verify the KZG proofs for the data columns carried by Gloas payload envelopes. + /// + /// For Gloas blocks the data columns are coupled to the payload envelope rather than the + /// block itself, so they are not covered by `batch_verify_kzg_for_available_blocks` (which + /// sees `AvailableBlockData::NoData` for Gloas). Each envelope's columns are verified against + /// the `blob_kzg_commitments` in its corresponding block's execution payload bid. + /// + /// Used during Gloas backfill sync to verify envelope columns before persisting them. + pub fn batch_verify_kzg_for_available_envelopes<'a>( + &self, + blocks_and_envelopes: impl Iterator< + Item = ( + &'a AvailableBlock, + &'a AvailableEnvelope, + ), + >, + ) -> Result<(), AvailabilityCheckError> { + for (available_block, envelope) in blocks_and_envelopes { + verify_columns_against_block(&self.kzg, available_block.block(), &envelope.columns)?; + } + + Ok(()) + } + /// Determines the blob requirements for a block. If the block is pre-deneb, no blobs are required. /// If the epoch is from prior to the data availability boundary, no blobs are required. pub fn blobs_required_for_epoch(&self, epoch: Epoch) -> bool { diff --git a/beacon_node/beacon_chain/src/historical_blocks.rs b/beacon_node/beacon_chain/src/historical_blocks.rs index bfda52558e4..ff26e9821e2 100644 --- a/beacon_node/beacon_chain/src/historical_blocks.rs +++ b/beacon_node/beacon_chain/src/historical_blocks.rs @@ -1,4 +1,5 @@ use crate::data_availability_checker::{AvailableBlock, AvailableBlockData}; +use crate::payload_envelope_verification::AvailableEnvelope; use crate::{BeaconChain, BeaconChainTypes, WhenSlotSkipped, metrics}; use fixed_bytes::FixedBytesExtended; use itertools::Itertools; @@ -15,6 +16,12 @@ use strum::IntoStaticStr; use tracing::{debug, debug_span, instrument}; use types::{Hash256, Slot}; +/// An available block together with its optional Gloas payload envelope, as produced by +/// `RangeSyncBlock::into_available_block` and consumed by `import_historical_block_batch`. +/// +/// The envelope is `None` for pre-Gloas blocks (and for Gloas blocks with no payload data). +pub type AvailableBlockWithEnvelope = (AvailableBlock, Option>); + /// Use a longer timeout on the pubkey cache. /// /// It's ok if historical sync is stalled due to writes from forwards block processing. @@ -37,6 +44,9 @@ pub enum HistoricalBlockError { IndexOutOfBounds, /// Logic error: should never occur. MissingOldestBlockRoot { slot: Slot }, + /// A Gloas block that expects blob data was provided without its payload envelope, so the + /// required data columns cannot be verified or persisted. Caller should retry/penalize. + MissingEnvelope { block_root: Hash256 }, /// Internal store error StoreError(StoreError), } @@ -70,7 +80,7 @@ impl BeaconChain { #[instrument(skip_all)] pub fn import_historical_block_batch( &self, - mut blocks: Vec>, + mut blocks: Vec>, ) -> Result { let anchor_info = self.store.get_anchor_info(); let blob_info = self.store.get_blob_info(); @@ -80,7 +90,7 @@ impl BeaconChain { // // This allows for reimport of the blobs/columns for the finalized block after checkpoint // sync. - let num_relevant = blocks.partition_point(|available_block| { + let num_relevant = blocks.partition_point(|(available_block, _envelope)| { available_block.block().slot() <= anchor_info.oldest_block_slot }); @@ -112,7 +122,7 @@ impl BeaconChain { let mut hot_batch = Vec::with_capacity(blocks_to_import.len()); let mut signed_blocks = Vec::with_capacity(blocks_to_import.len()); - for available_block in blocks_to_import.into_iter().rev() { + for (available_block, envelope) in blocks_to_import.into_iter().rev() { let (block_root, block, block_data) = available_block.deconstruct(); if block.slot() == anchor_info.oldest_block_slot { @@ -171,6 +181,37 @@ impl BeaconChain { blob_batch.extend(self.store.convert_to_kv_batch(vec![op])?); } + // Persist the Gloas payload envelope and its data columns. For Gloas blocks the data + // columns are carried by the envelope rather than the block's `block_data`, so they + // must be stored from here. + match envelope { + Some(envelope) => { + let (signed_envelope, columns) = envelope.deconstruct(); + if !columns.is_empty() { + new_oldest_data_column_slot = Some(block.slot()); + if let Some(op) = self.get_blobs_or_columns_store_op( + block_root, + block.slot(), + AvailableBlockData::DataColumns(columns), + ) { + blob_batch.extend(self.store.convert_to_kv_batch(vec![op])?); + } + } + self.store.payload_envelope_as_kv_store_ops( + &block_root, + &signed_envelope, + &mut hot_batch, + ); + } + None => { + // A Gloas block that expects blobs must be accompanied by its envelope, + // otherwise the required data columns cannot be verified or persisted. + if block.num_expected_blobs() > 0 { + return Err(HistoricalBlockError::MissingEnvelope { block_root }); + } + } + } + // 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() { debug!(%slot, ?block_root, "Storing frozen block to root mapping"); diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index 4d392ef5249..369f433ef96 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -3334,7 +3334,12 @@ async fn weak_subjectivity_sync_test( // Importing the invalid batch should error. assert!(matches!( beacon_chain - .import_historical_block_batch(batch_with_invalid_first_block) + .import_historical_block_batch( + batch_with_invalid_first_block + .into_iter() + .map(|block| (block, None)) + .collect(), + ) .unwrap_err(), HistoricalBlockError::InvalidSignature )); @@ -3354,7 +3359,10 @@ async fn weak_subjectivity_sync_test( ); // Importing the batch with valid signatures should succeed. - let available_blocks_batch1 = batch.iter().map(clone_block).collect::>(); + let available_blocks_batch1 = batch + .iter() + .map(|block| (clone_block(block), None)) + .collect::>(); beacon_chain .import_historical_block_batch(available_blocks_batch1) .unwrap(); @@ -3382,7 +3390,10 @@ async fn weak_subjectivity_sync_test( ); // Resupplying the blocks should not fail, they can be safely ignored. - let available_blocks_batch2 = batch.iter().map(clone_block).collect::>(); + let available_blocks_batch2 = batch + .iter() + .map(|block| (clone_block(block), None)) + .collect::>(); beacon_chain .import_historical_block_batch(available_blocks_batch2) .unwrap(); 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 c376f1844bd..55e7e6bbc3d 100644 --- a/beacon_node/network/src/network_beacon_processor/sync_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/sync_methods.rs @@ -654,13 +654,13 @@ impl NetworkBeaconProcessor { downloaded_blocks: Vec>, ) -> (usize, Result<(), ChainSegmentFailed>) { let total_blocks = downloaded_blocks.len(); - let available_blocks = match downloaded_blocks + + // Split each block into its `AvailableBlock` and (for Gloas) the coupled payload envelope. + // The envelope carries the data columns for Gloas blocks, so keep it paired with the block + // in order to verify (and later persist) it. + let blocks_and_envelopes = match downloaded_blocks .into_iter() - .map(|block| { - block - .into_available_block() - .map(|(available, _envelope)| available) - }) + .map(|block| block.into_available_block()) .collect::, _>>() { Ok(blocks) => blocks, @@ -675,35 +675,48 @@ impl NetworkBeaconProcessor { } }; - // TODO(gloas) when implementing backfill sync for gloas - // we need a batch verify kzg function in the new da checker - match self + let available_blocks = blocks_and_envelopes + .iter() + .map(|(available, _envelope)| available.clone()) + .collect::>(); + + // Map an availability check error to a chain segment failure. A `StoreError` is internal + // (no peer penalty); anything else is treated as a low-tolerance peer error. + let availability_err_to_segment_failed = |e: AvailabilityCheckError| match e { + AvailabilityCheckError::StoreError(_) => ChainSegmentFailed { + peer_action: None, + message: "Failed to check block availability".into(), + }, + e => ChainSegmentFailed { + peer_action: Some(PeerAction::LowToleranceError), + message: format!("Failed to check block availability : {:?}", e), + }, + }; + + // Verify the KZG proofs for the block data (blobs / Fulu columns). + if let Err(e) = self .chain .data_availability_checker .batch_verify_kzg_for_available_blocks(&available_blocks) { - Ok(()) => {} - Err(e) => match e { - AvailabilityCheckError::StoreError(_) => { - return ( - 0, - Err(ChainSegmentFailed { - peer_action: None, - message: "Failed to check block availability".into(), - }), - ); - } - e => { - return ( - 0, - Err(ChainSegmentFailed { - peer_action: Some(PeerAction::LowToleranceError), - message: format!("Failed to check block availability : {:?}", e), - }), - ); - } - }, - }; + return (0, Err(availability_err_to_segment_failed(e))); + } + + // Verify the KZG proofs for the data columns carried by Gloas payload envelopes. For Gloas + // blocks the columns live on the envelope rather than the block, so they are not covered by + // `batch_verify_kzg_for_available_blocks` above. The verified envelopes and their columns + // are persisted later by `import_historical_block_batch`. + if let Err(e) = self + .chain + .data_availability_checker + .batch_verify_kzg_for_available_envelopes( + blocks_and_envelopes + .iter() + .filter_map(|(block, envelope)| envelope.as_ref().map(|e| (block, e))), + ) + { + return (0, Err(availability_err_to_segment_failed(e))); + } if available_blocks.len() != total_blocks { return ( @@ -719,7 +732,10 @@ impl NetworkBeaconProcessor { ); } - match self.chain.import_historical_block_batch(available_blocks) { + match self + .chain + .import_historical_block_batch(blocks_and_envelopes) + { Ok(imported_blocks) => { metrics::inc_counter( &metrics::BEACON_PROCESSOR_BACKFILL_CHAIN_SEGMENT_SUCCESS_TOTAL, @@ -762,6 +778,16 @@ impl NetworkBeaconProcessor { // This is an internal error, do not penalize the peer. None } + HistoricalBlockError::MissingEnvelope { block_root } => { + debug!( + ?block_root, + error = "missing_envelope", + "Backfill batch processing error" + ); + // The peer is faulty if they send a Gloas block that expects blobs + // without its payload envelope. + Some(PeerAction::LowToleranceError) + } HistoricalBlockError::ValidatorPubkeyCacheTimeout => { warn!( From bd75324fe4fc49f20ef2a0bc192bc99b46f7e86d Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Wed, 1 Jul 2026 18:33:13 -0400 Subject: [PATCH 2/4] Fix pre gloas path for 0-blob count --- .../beacon_chain/src/historical_blocks.rs | 60 ++++++++++++++++++- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/beacon_node/beacon_chain/src/historical_blocks.rs b/beacon_node/beacon_chain/src/historical_blocks.rs index ff26e9821e2..968e557d654 100644 --- a/beacon_node/beacon_chain/src/historical_blocks.rs +++ b/beacon_node/beacon_chain/src/historical_blocks.rs @@ -117,6 +117,36 @@ impl BeaconChain { let mut new_oldest_blob_slot = blob_info.oldest_blob_slot; let mut new_oldest_data_column_slot = data_column_info.oldest_data_column_slot; + // Track the child block's bid `parent_block_hash` while iterating backwards, in order to + // determine each Gloas block's payload status. Per `process_parent_execution_payload` in + // the spec, a block's payload was revealed on chain (the block is "full") iff its child's + // bid `parent_block_hash` equals its own bid `block_hash`. A withheld ("empty") payload + // never has an envelope or data columns, so none can be required during backfill. + // + // Initialize from the current anchor block, which is the child of the newest block in the + // batch. If the newest block *is* the anchor block being re-imported, this yields a + // self-comparison that is always false, making its envelope optional — its data was + // already stored during checkpoint sync. + let mut child_bid_parent_hash = blocks_to_import + .last() + .filter(|(available_block, _envelope)| { + available_block.block().fork_name_unchecked().gloas_enabled() + }) + .and_then(|_| { + self.block_root_at_slot(anchor_info.oldest_block_slot, WhenSlotSkipped::None) + .ok() + .flatten() + }) + .and_then(|root| self.get_blinded_block(&root).ok().flatten()) + .and_then(|child_block| { + child_block + .message() + .body() + .signed_execution_payload_bid() + .ok() + .map(|bid| bid.message.parent_block_hash) + }); + let mut blob_batch = Vec::::new(); let mut cold_batch = Vec::with_capacity(blocks_to_import.len()); let mut hot_batch = Vec::with_capacity(blocks_to_import.len()); @@ -181,6 +211,19 @@ impl BeaconChain { blob_batch.extend(self.store.convert_to_kv_batch(vec![op])?); } + // Whether this block's execution payload was revealed on chain ("full" block). Only + // ever true for Gloas blocks, as earlier forks have no bid. See the comment on + // `child_bid_parent_hash` above. + let payload_revealed = block + .message() + .body() + .signed_execution_payload_bid() + .ok() + .zip(child_bid_parent_hash) + .is_some_and(|(bid, child_parent_hash)| { + bid.message.block_hash == child_parent_hash + }); + // Persist the Gloas payload envelope and its data columns. For Gloas blocks the data // columns are carried by the envelope rather than the block's `block_data`, so they // must be stored from here. @@ -204,9 +247,13 @@ impl BeaconChain { ); } None => { - // A Gloas block that expects blobs must be accompanied by its envelope, - // otherwise the required data columns cannot be verified or persisted. - if block.num_expected_blobs() > 0 { + // A Gloas block whose payload was revealed must be accompanied by its + // envelope: the spec requires envelopes to be served over the same retention + // window as blocks, and we must persist it to serve it over RPC and for + // state reconstruction — even when it carries no blobs. Blocks with withheld + // ("empty") payloads never have an envelope, and pre-Gloas blocks carry + // their blob data in `block_data`. + if payload_revealed { return Err(HistoricalBlockError::MissingEnvelope { block_root }); } } @@ -224,6 +271,13 @@ impl BeaconChain { prev_block_slot = block.slot(); expected_block_root = block.message().parent_root(); + // This block is the child of the next (older) block in the iteration. + child_bid_parent_hash = block + .message() + .body() + .signed_execution_payload_bid() + .ok() + .map(|bid| bid.message.parent_block_hash); signed_blocks.push(block); // If we've reached genesis, add the genesis block root to the batch for all slots From 19d99b472649621c122d1bf1630b8d9a1cbc930d Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Wed, 1 Jul 2026 18:33:53 -0400 Subject: [PATCH 3/4] Adopt tests to envelope paradigm --- beacon_node/beacon_chain/tests/store_tests.rs | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index 21082afc996..3d094600b4e 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -3304,37 +3304,38 @@ async fn weak_subjectivity_sync_test( let range_sync_block = harness .build_range_sync_block_from_store_blobs(Some(block_root), Arc::new(full_block)); - let (fully_available_block, _envelope) = + let (fully_available_block, envelope) = range_sync_block.into_available_block().unwrap(); harness .chain .data_availability_checker .verify_kzg_for_available_block(&fully_available_block) .expect("should verify kzg"); - available_blocks.push(fully_available_block); + available_blocks.push((fully_available_block, envelope)); } // Corrupt the signature on the 1st block to ensure that the backfill processor is checking // signatures correctly. Regression test for https://github.com/sigp/lighthouse/pull/5120. - let mut batch_with_invalid_first_block = - available_blocks.iter().map(clone_block).collect::>(); + let mut batch_with_invalid_first_block = available_blocks + .iter() + .map(|(block, envelope)| (clone_block(block), envelope.clone())) + .collect::>(); batch_with_invalid_first_block[0] = { - let (_, block, data) = clone_block(&available_blocks[0]).deconstruct(); + let (first_block, first_envelope) = &available_blocks[0]; + let (_, block, data) = clone_block(first_block).deconstruct(); let mut corrupt_block = (*block).clone(); *corrupt_block.signature_mut() = Signature::empty(); - AvailableBlock::new(Arc::new(corrupt_block), data, &beacon_chain.custody_context) - .expect("available block") + ( + AvailableBlock::new(Arc::new(corrupt_block), data, &beacon_chain.custody_context) + .expect("available block"), + first_envelope.clone(), + ) }; // Importing the invalid batch should error. assert!(matches!( beacon_chain - .import_historical_block_batch( - batch_with_invalid_first_block - .into_iter() - .map(|block| (block, None)) - .collect(), - ) + .import_historical_block_batch(batch_with_invalid_first_block) .unwrap_err(), HistoricalBlockError::InvalidSignature )); @@ -3345,7 +3346,7 @@ async fn weak_subjectivity_sync_test( for batch in available_blocks.rchunks(batch_size) { let available_blocks_slots = batch .iter() - .map(|block| (block.block().slot(), block.block().canonical_root())) + .map(|(block, _envelope)| (block.block().slot(), block.block().canonical_root())) .collect::>(); info!( ?available_blocks_slots, @@ -3356,7 +3357,7 @@ async fn weak_subjectivity_sync_test( // Importing the batch with valid signatures should succeed. let available_blocks_batch1 = batch .iter() - .map(|block| (clone_block(block), None)) + .map(|(block, envelope)| (clone_block(block), envelope.clone())) .collect::>(); beacon_chain .import_historical_block_batch(available_blocks_batch1) @@ -3365,7 +3366,7 @@ async fn weak_subjectivity_sync_test( // We should be able to load the block root at the `oldest_block_slot`. // // This is a regression test for: https://github.com/sigp/lighthouse/issues/7690 - let oldest_block_imported = &batch[0]; + let (oldest_block_imported, _envelope) = &batch[0]; let (oldest_block_slot, oldest_block_root) = if oldest_block_imported.block().parent_root() == beacon_chain.genesis_block_root { (Slot::new(0), beacon_chain.genesis_block_root) @@ -3387,7 +3388,7 @@ async fn weak_subjectivity_sync_test( // Resupplying the blocks should not fail, they can be safely ignored. let available_blocks_batch2 = batch .iter() - .map(|block| (clone_block(block), None)) + .map(|(block, envelope)| (clone_block(block), envelope.clone())) .collect::>(); beacon_chain .import_historical_block_batch(available_blocks_batch2) From 76b4ab6735d0da7497fce85234e1ebf66760dd00 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Fri, 3 Jul 2026 19:54:31 -0400 Subject: [PATCH 4/4] Blob coupling fix --- .../src/sync/block_sidecar_coupling.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/beacon_node/network/src/sync/block_sidecar_coupling.rs b/beacon_node/network/src/sync/block_sidecar_coupling.rs index a45a6cf2b13..fbbd8140bad 100644 --- a/beacon_node/network/src/sync/block_sidecar_coupling.rs +++ b/beacon_node/network/src/sync/block_sidecar_coupling.rs @@ -207,6 +207,24 @@ impl RangeBlockComponentsRequest { let Some((blocks, block_peer)) = self.blocks_request.to_finished() else { return Ok(()); }; + + // For Gloas batches, wait for the payload envelope response before requesting custody + // columns: a block whose payload was withheld ("empty") never has an envelope or data + // columns on the network, and bid commitments alone cannot distinguish it from a "full" + // block. The envelope response tells us which payloads were actually revealed, so custody + // columns are only requested for those. If the envelope peer lies by omission, the + // reveal-status check in `import_historical_block_batch` catches it and penalizes them. + let revealed_block_roots = match &self.payloads_request { + Some(ByRangeRequest::Active(_)) => return Ok(()), + Some(ByRangeRequest::Complete(envelopes)) => Some( + envelopes + .iter() + .map(|envelope| envelope.beacon_block_root()) + .collect::>(), + ), + None => None, + }; + let RangeBlockDataRequest::DataColumns(state @ DataColumnsRequest::NotStarted) = &mut self.block_data_request else { @@ -219,6 +237,11 @@ impl RangeBlockComponentsRequest { .iter() .filter(|block| block.num_expected_blobs() > 0) .map(|block| get_block_root(block)) + .filter(|root| { + revealed_block_roots + .as_ref() + .is_none_or(|revealed| revealed.contains(root)) + }) .collect::>(); if block_roots.is_empty() {