Gloas: fix test failures (KZG verifier wiring, harness columns, WSS sync)

Brings the FORK_NAME=gloas beacon_chain test suite from 31 failures to green:

- v1 KZG batch verifier couldn't verify Gloas columns. Added
  verify_columns_against_block helper that picks commitments per fork
  (Fulu: inline on column; Gloas: signed_execution_payload_bid).
- BeaconChainHarness::process_envelope didn't persist columns. Now mirrors
  what production does in import_available_execution_payload_envelope.
- get_or_reconstruct_blobs returned an error for Gloas. Now short-circuits to
  Ok(None); WSS test copies columns from source to dest directly.
- update_data_column_signed_header (block_verification tests) only handled
  Fulu shape. Added a Gloas branch that re-keys to canonical_root.
- BlockError::EnvelopeBlockRootUnknown changed to tuple variant.
- Removed duplicate process_payload_envelope_availability.
This commit is contained in:
dapplion
2026-05-01 03:46:10 +02:00
parent aa531bac22
commit 73ba76312e
11 changed files with 205 additions and 50 deletions

View File

@@ -1331,6 +1331,14 @@ impl<T: BeaconChainTypes> BeaconChain<T> {
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)? {
@@ -3442,7 +3450,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> {
.gloas_enabled()
{
let bid = load_gloas_payload_bid(block_root, self)?
.ok_or(BlockError::EnvelopeBlockRootUnknown { block_root })?;
.ok_or(BlockError::EnvelopeBlockRootUnknown(block_root))?;
let availability = self
.pending_payload_cache
.put_kzg_verified_custody_data_columns(
@@ -3680,7 +3688,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> {
if is_gloas {
let bid = load_gloas_payload_bid(block_root, self)?
.ok_or(BlockError::EnvelopeBlockRootUnknown { block_root })?;
.ok_or(BlockError::EnvelopeBlockRootUnknown(block_root))?;
let pending_payload_cache = self.pending_payload_cache.clone();
let result = self
.task_executor
@@ -4005,7 +4013,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> {
.gloas_enabled()
{
let bid = load_gloas_payload_bid(block_root, self)?
.ok_or(BlockError::EnvelopeBlockRootUnknown { block_root })?;
.ok_or(BlockError::EnvelopeBlockRootUnknown(block_root))?;
let availability = self
.pending_payload_cache
.put_gossip_verified_data_columns(block_root, bid, data_columns)?;
@@ -4111,7 +4119,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> {
.gloas_enabled()
{
let bid = load_gloas_payload_bid(block_root, self)?
.ok_or(BlockError::EnvelopeBlockRootUnknown { block_root })?;
.ok_or(BlockError::EnvelopeBlockRootUnknown(block_root))?;
let availability = self
.pending_payload_cache
.put_kzg_verified_custody_data_columns(block_root, bid, &data_columns)
@@ -4155,7 +4163,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> {
.gloas_enabled()
{
let bid = load_gloas_payload_bid(block_root, self)?
.ok_or(BlockError::EnvelopeBlockRootUnknown { block_root })?;
.ok_or(BlockError::EnvelopeBlockRootUnknown(block_root))?;
let availability = self
.pending_payload_cache
.put_rpc_custody_columns(block_root, bid, custody_columns)
@@ -7750,7 +7758,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> {
)
}
pub(crate) fn get_blobs_or_columns_store_op(
pub fn get_blobs_or_columns_store_op(
&self,
block_root: Hash256,
block_slot: Slot,

View File

@@ -287,7 +287,7 @@ pub enum BlockError {
/// https://github.com/sigp/lighthouse/issues/4546
AvailabilityCheck(AvailabilityCheckError),
/// The payload envelope's block root is unknown.
EnvelopeBlockRootUnknown { block_root: Hash256 },
EnvelopeBlockRootUnknown(Hash256),
/// Optimistic sync is not supported for Gloas payload envelopes.
OptimisticSyncNotSupported { block_root: Hash256 },
/// A Blob with a slot after PeerDAS is received and is not required to be imported.

View File

@@ -33,6 +33,7 @@ use crate::data_column_verification::{
GossipVerifiedDataColumn, KzgVerifiedCustodyDataColumn, KzgVerifiedDataColumn,
verify_kzg_for_data_column_list,
};
use crate::kzg_utils::validate_full_data_columns_with_commitments;
use crate::metrics::{
KZG_DATA_COLUMN_RECONSTRUCTION_ATTEMPTS, KZG_DATA_COLUMN_RECONSTRUCTION_FAILURES,
};
@@ -490,8 +491,7 @@ impl<T: BeaconChainTypes> DataAvailabilityChecker<T> {
AvailableBlockData::Blobs(blobs) => verify_kzg_for_blob_list(blobs.iter(), &self.kzg)
.map_err(AvailabilityCheckError::InvalidBlobs),
AvailableBlockData::DataColumns(columns) => {
verify_kzg_for_data_column_list(columns.iter(), &self.kzg)
.map_err(AvailabilityCheckError::InvalidColumn)
verify_columns_against_block(&self.kzg, available_block.block(), columns)
}
}
}
@@ -504,13 +504,17 @@ impl<T: BeaconChainTypes> DataAvailabilityChecker<T> {
available_blocks: &[AvailableBlock<T::EthSpec>],
) -> Result<(), AvailabilityCheckError> {
let mut all_blobs = Vec::new();
let mut all_data_columns = Vec::new();
for available_block in available_blocks {
match available_block.data().to_owned() {
match available_block.data() {
AvailableBlockData::NoData => {}
AvailableBlockData::Blobs(blobs) => all_blobs.extend(blobs),
AvailableBlockData::DataColumns(columns) => all_data_columns.extend(columns),
AvailableBlockData::Blobs(blobs) => all_blobs.extend(blobs.iter().cloned()),
AvailableBlockData::DataColumns(columns) => {
// Each block has its own commitments. For Gloas they live in the bid; for
// Fulu they live inline on the column. Verify per block and let the helper
// pick the right path.
verify_columns_against_block(&self.kzg, available_block.block(), columns)?;
}
}
}
@@ -519,11 +523,6 @@ impl<T: BeaconChainTypes> DataAvailabilityChecker<T> {
.map_err(AvailabilityCheckError::InvalidBlobs)?;
}
if !all_data_columns.is_empty() {
verify_kzg_for_data_column_list(all_data_columns.iter(), &self.kzg)
.map_err(AvailabilityCheckError::InvalidColumn)?;
}
Ok(())
}
@@ -679,6 +678,35 @@ impl<T: BeaconChainTypes> DataAvailabilityChecker<T> {
}
}
/// Verify a batch of data columns belonging to a single block, picking the right commitment
/// source for the block's fork (Fulu: inline on column; Gloas: from the embedded payload bid).
fn verify_columns_against_block<E: EthSpec>(
kzg: &Kzg,
block: &SignedBeaconBlock<E>,
columns: &[Arc<DataColumnSidecar<E>>],
) -> Result<(), AvailabilityCheckError> {
if columns.is_empty() {
return Ok(());
}
if block.fork_name_unchecked().gloas_enabled() {
let commitments = block
.message()
.body()
.signed_execution_payload_bid()
.map(|bid| bid.message.blob_kzg_commitments.clone())
.map_err(|_| {
AvailabilityCheckError::Unexpected(
"Gloas block missing signed_execution_payload_bid".to_string(),
)
})?;
validate_full_data_columns_with_commitments(kzg, columns.iter(), commitments.as_ref())
.map_err(AvailabilityCheckError::InvalidColumn)
} else {
verify_kzg_for_data_column_list(columns.iter(), kzg)
.map_err(AvailabilityCheckError::InvalidColumn)
}
}
/// Helper struct to group data availability checker metrics.
pub struct DataAvailabilityCheckerMetrics {
pub block_cache_size: usize,

View File

@@ -676,12 +676,19 @@ pub fn reconstruct_blobs<E: EthSpec>(
let blob_indices: Vec<usize> = match blob_indices_opt {
Some(indices) => indices.into_iter().map(|i| i as usize).collect(),
None => {
// TODO(gloas): support blob reconstruction for Gloas
// https://github.com/sigp/lighthouse/issues/7413
let num_of_blobs = first_data_column
.kzg_commitments()
.map_err(|_| "Gloas blob reconstruction not yet supported".to_string())?
.len();
// 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()
}
};

View File

@@ -163,7 +163,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> {
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 })?;
.ok_or(BlockError::EnvelopeBlockRootUnknown(block_root))?;
let availability = self
.pending_payload_cache
.put_executed_payload_envelope(bid, envelope)?;
@@ -253,7 +253,7 @@ impl<T: BeaconChainTypes> BeaconChain<T> {
// been imported. We don't want to repeat work importing a block that is already imported.
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 });
return Err(BlockError::EnvelopeBlockRootUnknown(block_root));
}
// TODO(gloas) add defensive check to see if payload envelope is already in fork choice

View File

@@ -15,6 +15,14 @@ impl<E: EthSpec> Default for PendingColumn<E> {
}
impl<E: EthSpec> PendingColumn<E> {
/// Allocate a `PendingColumn` whose `cells` vec has space for `blob_count` entries, all
/// initialised to `None`. Required so that `insert(idx, ...)` can write into `cells[idx]`.
pub fn new_with_capacity(blob_count: usize) -> Self {
Self {
cells: vec![None; blob_count],
}
}
pub fn insert(&mut self, index: usize, cell: &Cell<E>, proof: &KzgProof) {
if let Some(existing_cell) = self.cells.get_mut(index)
&& existing_cell.is_none()

View File

@@ -79,12 +79,16 @@ impl<E: EthSpec> PendingComponents<E> {
&mut self,
kzg_verified_data_columns: &[KzgVerifiedCustodyDataColumn<E>],
) -> Result<(), AvailabilityCheckError> {
let num_blobs_expected = self.num_blobs_expected();
for data_column in kzg_verified_data_columns {
let data_column = data_column.as_data_column();
// The Vec-backed `PendingColumn` keys cells by index, so we have to allocate up to
// `num_blobs_expected` entries before inserting; otherwise `cells.get_mut(idx)` returns
// None and the insert is a no-op.
let col = self
.verified_data_columns
.entry(*data_column.index())
.or_default();
.or_insert_with(|| PendingColumn::new_with_capacity(num_blobs_expected));
for (cell_idx, (cell, proof)) in data_column
.column()
.iter()

View File

@@ -2832,11 +2832,42 @@ where
.await
.expect("newPayload should succeed");
// Store the envelope.
// Store the envelope and the data columns derived from the block.
//
// Production stores columns inside `import_available_execution_payload_envelope` after
// the cache is satisfied. The harness sidesteps that flow but must still persist columns
// or the `DataColumnMissing` invariant fires for any block with `num_expected_blobs > 0`.
let block = self
.chain
.store
.get_blinded_block(&block_root)
.expect("should read block from store")
.expect("block should exist in store");
let mut ops = vec![];
let block_with_full_payload = self
.chain
.store
.make_full_block(&block_root, block.clone())
.expect("should reconstruct full block");
let columns =
generate_data_column_sidecars_from_block(&block_with_full_payload, &self.spec);
if !columns.is_empty()
&& let Some(store_op) = self.chain.get_blobs_or_columns_store_op(
block_root,
block.slot(),
AvailableBlockData::DataColumns(columns),
)
{
ops.push(store_op);
}
ops.push(store::StoreOp::PutPayloadEnvelope(
block_root,
std::sync::Arc::new(signed_envelope),
));
self.chain
.store
.put_payload_envelope(&block_root, &signed_envelope)
.expect("should store envelope");
.do_atomically_with_block_and_blobs_cache(ops)
.expect("should persist envelope and columns");
// Update fork choice so it knows the payload was received.
self.chain
@@ -2857,11 +2888,10 @@ where
block: Arc<SignedBeaconBlock<E>>,
) -> RangeSyncBlock<E> {
let block_root = block_root.unwrap_or_else(|| get_block_root(&block));
let has_blobs = block
.message()
.body()
.blob_kzg_commitments()
.is_ok_and(|c| !c.is_empty());
// For Gloas, kzg commitments live in the bid (`signed_execution_payload_bid`), so the
// body's `blob_kzg_commitments()` accessor returns Err. `num_expected_blobs` already
// handles both shapes.
let has_blobs = block.num_expected_blobs() > 0;
if !has_blobs {
return RangeSyncBlock::new(
block,

View File

@@ -323,18 +323,34 @@ fn update_data_column_signed_header<E: EthSpec>(
) {
for old_custody_column_sidecar in data_columns.as_mut_slice() {
let old_column_sidecar = old_custody_column_sidecar.as_data_column();
let new_column_sidecar = Arc::new(DataColumnSidecar::Fulu(DataColumnSidecarFulu {
index: *old_column_sidecar.index(),
column: old_column_sidecar.column().clone(),
kzg_commitments: old_column_sidecar.kzg_commitments().unwrap().clone(),
kzg_proofs: old_column_sidecar.kzg_proofs().clone(),
signed_block_header: signed_block.signed_block_header(),
kzg_commitments_inclusion_proof: signed_block
.message()
.body()
.kzg_commitments_merkle_proof()
.unwrap(),
}));
let new_column_sidecar = match old_column_sidecar.as_ref() {
DataColumnSidecar::Fulu(_) => {
Arc::new(DataColumnSidecar::Fulu(DataColumnSidecarFulu {
index: *old_column_sidecar.index(),
column: old_column_sidecar.column().clone(),
kzg_commitments: old_column_sidecar.kzg_commitments().unwrap().clone(),
kzg_proofs: old_column_sidecar.kzg_proofs().clone(),
signed_block_header: signed_block.signed_block_header(),
kzg_commitments_inclusion_proof: signed_block
.message()
.body()
.kzg_commitments_merkle_proof()
.unwrap(),
}))
}
// Gloas columns reference the block by `beacon_block_root` instead of holding the
// block header inline, so updating the parent root just means re-keying the column to
// the new canonical root.
DataColumnSidecar::Gloas(g) => {
Arc::new(DataColumnSidecar::Gloas(types::DataColumnSidecarGloas {
index: g.index,
column: g.column.clone(),
kzg_proofs: g.kzg_proofs.clone(),
slot: g.slot,
beacon_block_root: signed_block.canonical_root(),
}))
}
};
*old_custody_column_sidecar = CustodyDataColumn::from_asserted_custody(new_column_sidecar);
}
}

View File

@@ -3133,6 +3133,29 @@ async fn weak_subjectivity_sync_test(
let beacon_chain = Arc::new(beacon_chain);
let wss_block_root = wss_block.canonical_root();
// For Gloas, blobs aren't a standalone shape — the WSS data is the column sidecar set, which
// `get_or_reconstruct_blobs` returns `None` for. Copy the WSS block's columns straight from
// the source store so that the destination has them after checkpoint sync, matching what
// network-driven WSS would produce in production.
if wss_block.fork_name_unchecked().gloas_enabled()
&& let Ok(Some(source_columns)) = harness
.chain
.store
.get_data_columns(&wss_block_root, ForkName::Gloas)
&& !source_columns.is_empty()
&& let Some(store_op) = beacon_chain.get_blobs_or_columns_store_op(
wss_block_root,
wss_block.slot(),
beacon_chain::block_verification_types::AvailableBlockData::DataColumns(source_columns),
)
{
beacon_chain
.store
.do_atomically_with_block_and_blobs_cache(vec![store_op])
.unwrap();
}
let store_wss_block = harness
.chain
.get_block(&wss_block_root)
@@ -3200,12 +3223,43 @@ async fn weak_subjectivity_sync_test(
.await
.unwrap();
// Store the envelope and apply it to fork choice.
// Store the envelope, its columns, and apply to fork choice.
if let Some(envelope) = &snapshot.execution_envelope {
// Persist data columns for Gloas blocks. This mirrors what production does in
// `import_available_execution_payload_envelope` and what the harness now does in
// `process_envelope` — the WSS forward-sync loop bypasses both, so do it directly.
let mut ops = vec![];
let columns_block = beacon_chain
.store
.get_blinded_block(&block_root)
.unwrap()
.and_then(|b| beacon_chain.store.make_full_block(&block_root, b).ok());
if let Some(full_block) = columns_block {
let columns = beacon_chain::test_utils::generate_data_column_sidecars_from_block(
&full_block,
&beacon_chain.spec,
);
if !columns.is_empty()
&& let Some(store_op) = beacon_chain.get_blobs_or_columns_store_op(
block_root,
full_block.slot(),
beacon_chain::block_verification_types::AvailableBlockData::DataColumns(
columns,
),
)
{
ops.push(store_op);
}
}
ops.push(store::StoreOp::PutPayloadEnvelope(
block_root,
std::sync::Arc::new(envelope.as_ref().clone()),
));
beacon_chain
.store
.put_payload_envelope(&block_root, envelope)
.do_atomically_with_block_and_blobs_cache(ops)
.unwrap();
// Update fork choice so head selection accounts for Full payload status.
beacon_chain
.canonical_head

View File

@@ -1848,7 +1848,7 @@ impl<T: BeaconChainTypes> NetworkBeaconProcessor<T> {
// BlobNotRequired is unreachable. Only constructed in `process_gossip_blob`
Err(e @ BlockError::InternalError(_))
| Err(e @ BlockError::BlobNotRequired(_))
| Err(e @ BlockError::EnvelopeBlockRootUnknown { .. })
| Err(e @ BlockError::EnvelopeBlockRootUnknown(_))
| Err(e @ BlockError::OptimisticSyncNotSupported { .. }) => {
error!(error = %e, "Internal block gossip validation error");
return None;