From aa6087940a0d83ecbfddc619bb8e995a088e42c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 26 May 2026 19:15:39 -0300 Subject: [PATCH 1/4] chore: remove comment --- crates/common/types/src/block.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/common/types/src/block.rs b/crates/common/types/src/block.rs index 3b5f0d83..f4e15dc5 100644 --- a/crates/common/types/src/block.rs +++ b/crates/common/types/src/block.rs @@ -126,9 +126,6 @@ pub type ByteList512KiB = ByteList<524_288>; /// Maximum number of distinct `AttestationData` entries permitted in a single /// block. Canonical home for the cap shared across `ethlambda-blockchain`, /// `ethlambda-test-fixtures`, and the wire types in this crate. -/// -/// See: leanSpec PR #717, which lowered the cap from 16 to 8 alongside the -/// merged block-proof refactor. pub const MAX_ATTESTATIONS_DATA: usize = 8; /// A Type-1 single-message proof aggregating signatures from many validators. From ddcd863c192ad2e0f86c926974e1dff802987401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 27 May 2026 13:17:39 -0300 Subject: [PATCH 2/4] fix(blockchain): register block-attestation data without proof-less placeholders Block import previously inserted T1::empty(bits) placeholders (participant bits, empty proof bytes) into the known payload pool. Block building read the same pool and fed those empty bytes into the Type-2 merge, where lean-multisig decompression failed ("child proof deserialization failed at index N"). Match the spec's on_block: register each block attestation's data key with an empty proof set instead of a proof-less placeholder. No empty-byte proof ever enters the pool, so the merge can no longer be handed one. Block-borne votes now contribute zero fork-choice weight at import; the weight returns once reaggregation recovers real Type-1 proofs into the pool (deferred by up to one slot), matching the spec. Known follow-up: ethlambda's import-only forkchoice harness expects block votes in "known" immediately after import (the spec's harness simulates the proposer build to populate them). 13 forkchoice spec tests now fail on this expectation; the harness/reaggregation wiring will be addressed separately. --- crates/blockchain/src/store.rs | 30 ++++++++++++++------------- crates/storage/src/store.rs | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index be632076..69f08bd5 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -495,30 +495,32 @@ fn on_block_core( store.insert_signed_block(block_root, signed_block.clone()); store.insert_state(block_root, post_state); - // Process block body attestations and feed them into the payload buffer - // so fork choice's LMD GHOST overlay can see block-only votes. + // Register each block attestation's data in the known payload buffer with + // no proofs, mirroring the spec's on_block. // - // Per-attestation participant bitfields come straight from - // `block.body.attestations[i].aggregation_bits`. Standalone Type-1 - // proof bytes are not recoverable from a block at import time; - // downstream re-aggregation has to come from the gossip channel or be - // recovered by SNARK-splitting `signed_block.proof` via - // `split_type_2_by_message`. The entries inserted here are info-only, - // used only for fork-choice vote bookkeeping. + // The block carries one merged Type-2 proof binding all its attestations; + // that proof is verified as a whole and not decomposed here. Standalone + // Type-1 bytes for an individual attestation are not recoverable at import + // time, so we only reserve the data key. Real per-attestation proofs reach + // the pool later through reaggregation (SNARK-splitting `signed_block.proof` + // via `split_type_2_by_message`) and the gossip channel. + // + // Consequence: a block's own attestations contribute zero fork-choice weight + // to the head computation triggered by this import. The recovered Type-1 + // proofs land in the new pool and migrate to known at the next acceptance + // tick, so block-imported vote weight is deferred by up to one slot. let aggregated_attestations = &block.body.attestations; - let mut known_entries: Vec<(HashedAttestationData, TypeOneMultiSignature)> = + let mut known_data: Vec = Vec::with_capacity(aggregated_attestations.len()); for att in aggregated_attestations.iter() { - let hashed = HashedAttestationData::new(att.data.clone()); - let type_one = TypeOneMultiSignature::empty(att.aggregation_bits.clone()); - known_entries.push((hashed, type_one)); + known_data.push(HashedAttestationData::new(att.data.clone())); // Count each participating validator as a valid attestation. let count = validator_indices(&att.aggregation_bits).count() as u64; metrics::inc_attestations_valid(count); } - store.insert_known_aggregated_payloads_batch(known_entries); + store.register_known_aggregated_data_batch(known_data); // Update forkchoice head based on new block and attestations update_head(store, false); diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index 4efdedea..106b126f 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -202,6 +202,31 @@ impl PayloadBuffer { } } + /// Register an attestation data key carrying no proofs. + /// + /// Mirrors the spec, which records each block attestation's data with an + /// empty proof set at import time. The bare key reserves its insertion-order + /// slot so equivocation tie-breaking stays deterministic once real proofs + /// arrive later through reaggregation. + /// + /// No-op when the data is already present, so a real proof is never replaced + /// by a later bare key. Carries no proof bytes, so it adds zero fork-choice + /// weight until reaggregation fills it. + fn register_data(&mut self, hashed: HashedAttestationData) { + let (data_root, att_data) = hashed.into_parts(); + if self.data.contains_key(&data_root) { + return; + } + self.data.insert( + data_root, + PayloadEntry { + data: att_data, + proofs: Vec::new(), + }, + ); + self.order.push_back(data_root); + } + /// Take all entries, leaving the buffer empty. /// /// Drains in insertion order (via `self.order`) so downstream consumers @@ -1193,6 +1218,19 @@ impl Store { self.known_payloads.lock().unwrap().push_batch(entries); } + /// Register block-attestation data keys in the known buffer with no proofs. + /// + /// Spec parity: on_block records each block attestation's data with an empty + /// proof set. These entries carry no proof bytes, so they contribute zero + /// fork-choice weight; the weight arrives once reaggregation recovers real + /// Type-1 proofs for the same data into the pool. + pub fn register_known_aggregated_data_batch(&mut self, entries: Vec) { + let mut buf = self.known_payloads.lock().unwrap(); + for hashed in entries { + buf.register_data(hashed); + } + } + // ============ New Aggregated Payloads ============ // // "New" aggregated payloads are pending — not yet counted in fork choice. From a81cbce8e31f391e53d72e43be6fb8e86a3d8984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 27 May 2026 14:29:41 -0300 Subject: [PATCH 3/4] test(blockchain): deconstruct imported blocks in the forkchoice harness Option A made on_block spec-faithful: it registers each block attestation's data key with an empty proof set, so block-borne votes contribute no fork-choice weight at import. A real node recovers that weight by SNARK-splitting the block's merged Type-2 proof into per-attestation Type-1s (reaggregation); leanSpec's forkchoice harness gets the same effect by simulating the proposer build straight into the known pool. ethlambda's harness only imports blocks (on_block_without_verification) with blank proofs, so it did neither and 13 head/reorg/safe-target fixtures lost their vote source. Reconstruct structurally after import: for each block attestation, insert a Type-1 carrying its aggregation_bits into the known pool (fork choice reads participants, not proof bytes; the harness never feeds these to a Type-2 merge). Recompute the head afterward, since on_block ran its head update before these votes existed. Expose update_head for the harness recompute. All 84 forkchoice tests pass. --- crates/blockchain/src/store.rs | 2 +- .../blockchain/tests/forkchoice_spectests.rs | 36 ++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 69f08bd5..c01921c3 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -37,7 +37,7 @@ fn accept_new_attestations(store: &mut Store, log_tree: bool) { /// /// When `log_tree` is true, also computes block weights and logs an ASCII /// fork choice tree to the terminal. -fn update_head(store: &mut Store, log_tree: bool) { +pub fn update_head(store: &mut Store, log_tree: bool) { let blocks = store.get_live_chain(); let attestations = store.extract_latest_known_attestations(); let old_head = store.head(); diff --git a/crates/blockchain/tests/forkchoice_spectests.rs b/crates/blockchain/tests/forkchoice_spectests.rs index d0bd9f82..500c6bd2 100644 --- a/crates/blockchain/tests/forkchoice_spectests.rs +++ b/crates/blockchain/tests/forkchoice_spectests.rs @@ -7,7 +7,9 @@ use std::{ use ethlambda_blockchain::{MILLISECONDS_PER_INTERVAL, MILLISECONDS_PER_SLOT, store}; use ethlambda_storage::{Store, backend::InMemoryBackend}; use ethlambda_types::{ - attestation::{AttestationData, SignedAggregatedAttestation, SignedAttestation}, + attestation::{ + AttestationData, HashedAttestationData, SignedAggregatedAttestation, SignedAttestation, + }, block::{Block, TypeOneMultiSignature}, primitives::{ByteList, H256, HashTreeRoot as _}, state::{State, anchor_pair_is_consistent}, @@ -97,7 +99,39 @@ fn run(path: &Path) -> datatest_stable::Result<()> { // NOTE: the has_proposal argument is set to true, following the spec store::on_tick(&mut store, block_time_ms, true); let result = store::on_block_without_verification(&mut store, signed_block); + let import_ok = result.is_ok(); assert_step_outcome(step_idx, step.valid, result)?; + + // Deconstruct the imported block into per-attestation Type-1s, + // mirroring the node's post-import reaggregation. The real node + // SNARK-splits the block's merged Type-2 proof and folds the + // recovered Type-1s into the pool so block-borne votes carry + // fork-choice weight; leanSpec's fork-choice harness gets the + // same effect by simulating the proposer build. Fixture blocks + // are blank (no real proof to split), so reconstruct structurally + // from the body's aggregation_bits — fork choice reads only the + // participant set, not the proof bytes. The recovered entries go + // straight into the known pool to match the proposer-view store + // the fixtures encode. + if import_ok { + let block = block_data.to_block(); + let entries: Vec<(HashedAttestationData, TypeOneMultiSignature)> = block + .body + .attestations + .iter() + .map(|att| { + ( + HashedAttestationData::new(att.data.clone()), + TypeOneMultiSignature::empty(att.aggregation_bits.clone()), + ) + }) + .collect(); + store.insert_known_aggregated_payloads_batch(entries); + // on_block already ran the head update before these votes + // existed; recompute so the head reflects the block's own + // attestations, matching the proposer-view store. + store::update_head(&mut store, false); + } } "tick" => { // Fixtures use either `time` (UNIX seconds) or `interval` From 1e3f897f810cffbca5acbe3374f1c9daf53d7b19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 27 May 2026 18:52:36 -0300 Subject: [PATCH 4/4] fix(blockchain): drop block-attestation data registration at import on_block registered each block attestation's data in the known pool with an empty proof set, mirroring leanSpec's on_block. That registration is inert in our weight model: fork-choice weight is derived from proof participant bits, so an empty proof set contributes nothing, and the block builder skips entries with no proofs. Its only side effect was reserving an insertion-order slot, which reaggregation re-establishes anyway. The empty entries did, however, bypass the payload buffer's eviction cap: register_data inserted into the data map without incrementing total_proofs, so a non-finalizing chain could grow known_payloads unbounded. Remove the registration entirely. Block-borne votes were already zero-weight at import; weight still arrives once reaggregation recovers real Type-1 proofs and gossip delivers them. This eliminates the unbounded-growth path by construction and deletes the now-dead register_data helpers. --- crates/blockchain/src/store.rs | 29 +++++++------------------- crates/storage/src/store.rs | 38 ---------------------------------- 2 files changed, 7 insertions(+), 60 deletions(-) diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index c01921c3..255ffd1b 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -495,33 +495,18 @@ fn on_block_core( store.insert_signed_block(block_root, signed_block.clone()); store.insert_state(block_root, post_state); - // Register each block attestation's data in the known payload buffer with - // no proofs, mirroring the spec's on_block. - // - // The block carries one merged Type-2 proof binding all its attestations; - // that proof is verified as a whole and not decomposed here. Standalone - // Type-1 bytes for an individual attestation are not recoverable at import - // time, so we only reserve the data key. Real per-attestation proofs reach - // the pool later through reaggregation (SNARK-splitting `signed_block.proof` - // via `split_type_2_by_message`) and the gossip channel. - // - // Consequence: a block's own attestations contribute zero fork-choice weight - // to the head computation triggered by this import. The recovered Type-1 - // proofs land in the new pool and migrate to known at the next acceptance - // tick, so block-imported vote weight is deferred by up to one slot. - let aggregated_attestations = &block.body.attestations; - - let mut known_data: Vec = - Vec::with_capacity(aggregated_attestations.len()); - for att in aggregated_attestations.iter() { - known_data.push(HashedAttestationData::new(att.data.clone())); + // A block ships one merged Type-2 proof binding all its attestations; it is + // verified as a whole and never decomposed at import. Standalone per-attestation + // Type-1 proofs are not recoverable here, so block-borne votes carry no + // fork-choice weight until reaggregation (SNARK-splitting the block proof via + // `split_type_2_by_message`) and gossip deliver real Type-1s into the pool. + // Block-imported vote weight is therefore deferred by up to one slot. + for att in block.body.attestations.iter() { // Count each participating validator as a valid attestation. let count = validator_indices(&att.aggregation_bits).count() as u64; metrics::inc_attestations_valid(count); } - store.register_known_aggregated_data_batch(known_data); - // Update forkchoice head based on new block and attestations update_head(store, false); diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index 106b126f..4efdedea 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -202,31 +202,6 @@ impl PayloadBuffer { } } - /// Register an attestation data key carrying no proofs. - /// - /// Mirrors the spec, which records each block attestation's data with an - /// empty proof set at import time. The bare key reserves its insertion-order - /// slot so equivocation tie-breaking stays deterministic once real proofs - /// arrive later through reaggregation. - /// - /// No-op when the data is already present, so a real proof is never replaced - /// by a later bare key. Carries no proof bytes, so it adds zero fork-choice - /// weight until reaggregation fills it. - fn register_data(&mut self, hashed: HashedAttestationData) { - let (data_root, att_data) = hashed.into_parts(); - if self.data.contains_key(&data_root) { - return; - } - self.data.insert( - data_root, - PayloadEntry { - data: att_data, - proofs: Vec::new(), - }, - ); - self.order.push_back(data_root); - } - /// Take all entries, leaving the buffer empty. /// /// Drains in insertion order (via `self.order`) so downstream consumers @@ -1218,19 +1193,6 @@ impl Store { self.known_payloads.lock().unwrap().push_batch(entries); } - /// Register block-attestation data keys in the known buffer with no proofs. - /// - /// Spec parity: on_block records each block attestation's data with an empty - /// proof set. These entries carry no proof bytes, so they contribute zero - /// fork-choice weight; the weight arrives once reaggregation recovers real - /// Type-1 proofs for the same data into the pool. - pub fn register_known_aggregated_data_batch(&mut self, entries: Vec) { - let mut buf = self.known_payloads.lock().unwrap(); - for hashed in entries { - buf.register_data(hashed); - } - } - // ============ New Aggregated Payloads ============ // // "New" aggregated payloads are pending — not yet counted in fork choice.