From fd1baeb19c41cb56d42c00d30cb43a8a9624637b 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, 2 Sep 2026 14:52:10 -0300 Subject: [PATCH 1/2] feat(types): carry proposer signature outside the block proof `SignedBlock.proof` becomes a two-field `BlockProof`: before: block-proof = aggregate([prop-sig, att0, att1]) after: block-proof = (prop-sig, aggregate([att0, att1])) before (no atts): aggregate([prop-sig]) after (no atts): (prop-sig, empty-proof) The proposer signature was wrapped as a singleton Type-1 and merged into one block Type-2 alongside every attestation, which had two costs: even a block with zero attestations needed a prover call, and the merge could only run once the block root was known. Splitting the two: - lets the attestation aggregate be built without the block root, which is what makes a gossiped block body proof possible; - removes all prover work from the empty-attestation case; - verifies the proposer signature with the hash-based XMSS verifier directly, so it never enters the lean-multisig prover or verifier. The signature reuses the existing fixed-size `XmssSignature` already carried by `SignedAttestation`, and `sign_block_root` already returns one, so the proposer carries it verbatim; genesis anchors use the existing `blank_xmss_signature()` placeholder. This diverges from leanSpec's single-merged-proof wire format, so the signature and SSZ fixtures no longer apply. Ported from #467. --- bin/ethlambda/src/benchmark/mod.rs | 4 +- crates/blockchain/src/aggregation.rs | 4 +- crates/blockchain/src/block_builder.rs | 21 ++- crates/blockchain/src/events.rs | 4 +- crates/blockchain/src/lib.rs | 143 +++++++-------- crates/blockchain/src/reaggregate.rs | 22 +-- crates/blockchain/src/spec_test_runner.rs | 8 + crates/blockchain/src/store.rs | 169 +++++++++++------- .../common/test-fixtures/src/fork_choice.rs | 4 +- .../test-fixtures/src/verify_signatures.rs | 13 +- crates/common/types/src/block.rs | 147 +++++++++++++-- crates/net/p2p/src/req_resp/handlers.rs | 4 +- crates/net/rpc/src/lib.rs | 8 +- crates/storage/src/store.rs | 14 +- 14 files changed, 359 insertions(+), 206 deletions(-) diff --git a/bin/ethlambda/src/benchmark/mod.rs b/bin/ethlambda/src/benchmark/mod.rs index 869ddcce..fe77b395 100644 --- a/bin/ethlambda/src/benchmark/mod.rs +++ b/bin/ethlambda/src/benchmark/mod.rs @@ -20,7 +20,7 @@ use ethlambda_blockchain::block_builder::ProposerConfig; use ethlambda_blockchain::metrics::BLOCK_PROPOSAL_ATTESTATION_BUILD_PHASES; use ethlambda_blockchain::store::{on_block_without_verification, produce_block_with_signatures}; use ethlambda_storage::{NEW_PAYLOAD_CAP, Store}; -use ethlambda_types::block::{MultiMessageAggregate, SignedBlock}; +use ethlambda_types::block::{BlockProof, SignedBlock}; use ethlambda_types::primitives::HashTreeRoot as _; use eyre::WrapErr as _; @@ -247,7 +247,7 @@ fn build_one_slot( // index. let signed_block = SignedBlock { message: block, - proof: MultiMessageAggregate::default(), + proof: BlockProof::default(), }; on_block_without_verification(store, signed_block) .wrap_err_with(|| format!("importing the built block failed at slot {slot}"))?; diff --git a/crates/blockchain/src/aggregation.rs b/crates/blockchain/src/aggregation.rs index 6b2db710..d2492b77 100644 --- a/crates/blockchain/src/aggregation.rs +++ b/crates/blockchain/src/aggregation.rs @@ -989,7 +989,7 @@ mod tests { use super::*; use ethlambda_storage::backend::InMemoryBackend; use ethlambda_types::{ - block::{Block, BlockBody, BlockHeader, MultiMessageAggregate, SignedBlock}, + block::{Block, BlockBody, BlockHeader, BlockProof, SignedBlock}, checkpoint::Checkpoint, state::{ChainConfig, JustificationValidators, JustifiedSlots, State}, }; @@ -1086,7 +1086,7 @@ mod tests { state_root: H256::ZERO, body: BlockBody::default(), }, - proof: MultiMessageAggregate::default(), + proof: BlockProof::default(), }; store .insert_signed_block(root, signed_block) diff --git a/crates/blockchain/src/block_builder.rs b/crates/blockchain/src/block_builder.rs index 547aa22f..406aad57 100644 --- a/crates/blockchain/src/block_builder.rs +++ b/crates/blockchain/src/block_builder.rs @@ -891,8 +891,12 @@ fn trace_skipped_attestation(reason: &'static str, att: &AttestationData, data_r mod tests { use super::*; use ethlambda_types::{ - attestation::{AggregatedAttestation, AggregationBits, AttestationData}, - block::{ByteList512KiB, MultiMessageAggregate, SignedBlock, SingleMessageAggregate}, + attestation::{ + AggregatedAttestation, AggregationBits, AttestationData, blank_xmss_signature, + }, + block::{ + BlockProof, ByteList512KiB, MultiMessageAggregate, SignedBlock, SingleMessageAggregate, + }, checkpoint::Checkpoint, state::State, }; @@ -1101,11 +1105,16 @@ mod tests { ); // Substitute a worst-case-size proof to model what `propose_block` - // would attach. The actual SNARK can't be built without lean-multisig, - // but the size cap (`ByteList512KiB`) bounds the worst case. + // would attach: a 512 KiB attestation aggregate plus the fixed-size + // proposer signature. The actual SNARK can't be built without + // lean-multisig, but the size cap bounds the worst case. let _ = signatures; - let proof = MultiMessageAggregate::new( - ByteList512KiB::try_from(vec![0xAB; 512 * 1024]).expect("worst-case proof fits in cap"), + let proof = BlockProof::new( + blank_xmss_signature(), + MultiMessageAggregate::new( + ByteList512KiB::try_from(vec![0xAB; 512 * 1024]) + .expect("worst-case proof fits in cap"), + ), ); let signed_block = SignedBlock { message: block, diff --git a/crates/blockchain/src/events.rs b/crates/blockchain/src/events.rs index ffb63f46..03b65995 100644 --- a/crates/blockchain/src/events.rs +++ b/crates/blockchain/src/events.rs @@ -323,7 +323,7 @@ mod tests { use super::*; use ethlambda_storage::{ForkCheckpoints, backend::InMemoryBackend}; use ethlambda_types::{ - block::{Block, BlockBody, MultiMessageAggregate, SignedBlock}, + block::{Block, BlockBody, BlockProof, SignedBlock}, state::State, }; use std::sync::Arc; @@ -455,7 +455,7 @@ mod tests { state_root, body: BlockBody::default(), }, - proof: MultiMessageAggregate::default(), + proof: BlockProof::default(), }; store .insert_signed_block(root, signed_block) diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index 2d310373..af884d5c 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -1,7 +1,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::time::{Duration, Instant, SystemTime}; -use ethlambda_crypto::signature::{ValidatorPublicKey, ValidatorSignature}; +use ethlambda_crypto::signature::ValidatorPublicKey; use ethlambda_network_api::{BlockChainToP2PRef, BlockSource, InitP2P}; use ethlambda_state_transition::is_proposer; use ethlambda_storage::{ALL_TABLES, Store}; @@ -9,7 +9,7 @@ use ethlambda_types::{ ShortRoot, aggregator::AggregatorController, attestation::{SignedAggregatedAttestation, SignedAttestation}, - block::{ByteList512KiB, MultiMessageAggregate, SignedBlock}, + block::{BlockProof, ByteList512KiB, MultiMessageAggregate, SignedBlock}, primitives::{H256, HashTreeRoot as _}, }; @@ -641,105 +641,86 @@ impl BlockChainServer { return; }; - // Wrap the proposer's raw XMSS signature into a singleton - // single-message aggregate SNARK, then merge it with every attestation - // single-message aggregate into the single multi-message aggregate. + // Assemble SignedBlock: carry the proposer's raw XMSS signature as a + // standalone field, and aggregate the attestation single-message + // aggregates (only) into the block's attestation multi-message + // aggregate. The proposer no longer enters the aggregate, so a block + // with no attestations needs no prover work and the attestation + // multi-message aggregate can be built independently of the block root. let head_state = self.store.head_state(); let validators = &head_state.validators; - let Some(proposer_validator) = validators.get(validator_id as usize) else { + if validators.get(validator_id as usize).is_none() { error!(%slot, %validator_id, "Proposer index out of range when assembling block"); metrics::inc_block_building_failures(); return; - }; - - // Decode the proposer's proposal pubkey once and reuse it both for the - // singleton single-message aggregate wrap and for the multi-message - // aggregate merge inputs. - let Ok(proposer_pubkey) = ValidatorPublicKey::from_bytes( - &proposer_validator.proposal_pubkey, - ) - .inspect_err( - |err| error!(%slot, %validator_id, %err, "Failed to decode proposer proposal pubkey"), - ) else { - metrics::inc_block_building_failures(); - return; - }; + } - let Ok(proposer_validator_signature) = - ValidatorSignature::from_bytes(&proposer_signature).inspect_err(|err| { - error!(%slot, %validator_id, %err, "Failed to decode proposer signature bytes") - }) - else { - metrics::inc_block_building_failures(); - return; - }; - let Ok(proposer_proof_bytes) = ethlambda_crypto::aggregate_signatures( - vec![proposer_pubkey.clone()], - vec![proposer_validator_signature], - &block_root, - slot as u32, - ) - .inspect_err( - |err| error!(%slot, %validator_id, %err, "Failed to wrap proposer signature as single-message aggregate"), - ) else { - metrics::inc_block_building_failures(); - return; - }; + // `sign_block_root` already returns an `XmssSignature`, so the proposer + // signature is carried verbatim — no packing or prover work needed. - let mut merge_inputs: Vec<(Vec, ByteList512KiB)> = - Vec::with_capacity(single_message_aggregates.len() + 1); - let mut resolve_failed = false; - for sma in &single_message_aggregates { - let mut pubkeys = Vec::new(); - for vid in sma.participant_indices() { - let Some(validator) = validators.get(vid as usize) else { - error!(%slot, %validator_id, vid, "Participant out of range while resolving pubkeys"); - resolve_failed = true; - break; - }; - match ValidatorPublicKey::from_bytes(&validator.attestation_pubkey) { - Ok(pk) => pubkeys.push(pk), - Err(err) => { - error!(%slot, %validator_id, vid, %err, "Failed to decode attestation pubkey"); + // Aggregate the attestation single-message aggregates into a single + // multi-message aggregate. With no attestations the aggregate is empty: + // the proposer signature stands alone, mirroring `(prop-sig, + // empty-proof)`. + let attestation_proof = if single_message_aggregates.is_empty() { + MultiMessageAggregate::default() + } else { + let mut merge_inputs: Vec<(Vec, ByteList512KiB)> = + Vec::with_capacity(single_message_aggregates.len()); + let mut resolve_failed = false; + for sma in &single_message_aggregates { + let mut pubkeys = Vec::new(); + for vid in sma.participant_indices() { + let Some(validator) = validators.get(vid as usize) else { + error!(%slot, %validator_id, vid, "Participant out of range while resolving pubkeys"); resolve_failed = true; break; + }; + match ValidatorPublicKey::from_bytes(&validator.attestation_pubkey) { + Ok(pk) => pubkeys.push(pk), + Err(err) => { + error!(%slot, %validator_id, vid, %err, "Failed to decode attestation pubkey"); + resolve_failed = true; + break; + } } } + if resolve_failed { + break; + } + merge_inputs.push((pubkeys, sma.proof.clone())); } if resolve_failed { - break; - } - merge_inputs.push((pubkeys, sma.proof.clone())); - } - if resolve_failed { - metrics::inc_block_building_failures(); - return; - } - merge_inputs.push((vec![proposer_pubkey], proposer_proof_bytes)); - - // Merge yields raw lean-multisig type-2 bytes. Per-component - // participants are rederived at verify time from - // `block.body.attestations[i].aggregation_bits` plus - // `block.proposer_index`, so nothing else needs persisting. - let merged_bytes = match ethlambda_crypto::merge_type_1s_into_type_2(merge_inputs) { - Ok(bytes) => bytes, - Err(err) => { - error!(%slot, %validator_id, %err, "Failed to merge Type-1s into Type-2"); metrics::inc_block_building_failures(); return; } - }; - let proof = match MultiMessageAggregate::from_bytes(merged_bytes.iter().as_slice()) { - Ok(p) => p, - Err(err) => { - error!(%slot, %validator_id, %err, "Failed to build multi-message aggregate"); - metrics::inc_block_building_failures(); - return; + + // Merge yields raw lean-multisig type-2 bytes. Per-component + // participants are rederived at verify time from + // `block.body.attestations[i].aggregation_bits`, so nothing else + // needs persisting. + let merged_bytes = match ethlambda_crypto::merge_type_1s_into_type_2(merge_inputs) { + Ok(bytes) => bytes, + Err(err) => { + error!(%slot, %validator_id, %err, "Failed to merge Type-1s into Type-2"); + metrics::inc_block_building_failures(); + return; + } + }; + match MultiMessageAggregate::from_bytes(merged_bytes.iter().as_slice()) { + Ok(p) => p, + Err(err) => { + error!(%slot, %validator_id, %err, "Failed to build multi-message aggregate"); + metrics::inc_block_building_failures(); + return; + } } }; + // `single_message_aggregates` is no longer needed past this point. + drop(single_message_aggregates); let signed_block = SignedBlock { message: block, - proof, + proof: BlockProof::new(proposer_signature, attestation_proof), }; // Stop timing here: the build is done, and the alignment wait below must diff --git a/crates/blockchain/src/reaggregate.rs b/crates/blockchain/src/reaggregate.rs index 64e762e7..c9f4a855 100644 --- a/crates/blockchain/src/reaggregate.rs +++ b/crates/blockchain/src/reaggregate.rs @@ -71,11 +71,12 @@ pub fn reaggregate_from_block( let validators = &parent_state.validators; let num_validators = validators.len() as u64; - // Per-component pubkeys: one entry per body attestation in order, then - // the proposer entry. Layout is invariant per block, so it's resolved - // once and reused for every split call below. + // Per-component pubkeys: one entry per body attestation in order. The + // attestation aggregate no longer carries a proposer component (the + // proposer signature lives outside it), so the layout is attestations + // only. Resolved once and reused for every split call below. let mut pubkeys_per_component: Vec> = - Vec::with_capacity(attestations.len() + 1); + Vec::with_capacity(attestations.len()); for att in &attestations { let mut pubkeys = Vec::new(); for vid in validator_indices(&att.aggregation_bits) { @@ -93,15 +94,6 @@ pub fn reaggregate_from_block( } pubkeys_per_component.push(pubkeys); } - if block.proposer_index >= num_validators { - return Vec::new(); - } - let Ok(proposer_pubkey) = - ValidatorPublicKey::from_bytes(&validators[block.proposer_index as usize].proposal_pubkey) - else { - return Vec::new(); - }; - pubkeys_per_component.push(vec![proposer_pubkey]); let candidates = select_candidates(store, &attestations); if candidates.is_empty() { @@ -123,8 +115,8 @@ pub fn reaggregate_from_block( }; // Step 1: SNARK-split this attestation's component out of the block's - // merged multi-message aggregate proof. - let merged_bytes = signed_block.proof.proof_bytes(); + // attestation multi-message aggregate proof. + let merged_bytes = signed_block.proof.attestation_proof.proof_bytes(); let split_bytes = match ethlambda_crypto::split_type_2_by_message( merged_bytes, pubkeys_per_component.clone(), diff --git a/crates/blockchain/src/spec_test_runner.rs b/crates/blockchain/src/spec_test_runner.rs index c3ab67af..2ba953a1 100644 --- a/crates/blockchain/src/spec_test_runner.rs +++ b/crates/blockchain/src/spec_test_runner.rs @@ -83,6 +83,14 @@ pub fn rejection_reason(err: &StoreError) -> Option { StoreError::AttestationTooFarInFuture { .. } => RejectionReason::AttestationTooFarInFuture, StoreError::AggregateVerificationFailed(_) => RejectionReason::InvalidSignature, StoreError::BlockProofVerificationFailed(_) => RejectionReason::InvalidBlockProof, + // The proposer signature and the attestation aggregate are both + // components of the block proof, so a failure in either is the same + // spec rejection even though we carry them as separate wire fields. An + // attestation-free block carries no aggregate at all, so stray proof + // bytes are a malformed block proof rather than a distinct reason. + StoreError::ProposerSignatureDecodingFailed + | StoreError::ProposerSignatureVerificationFailed + | StoreError::UnexpectedAttestationProof => RejectionReason::InvalidBlockProof, StoreError::EmptyAggregationBits => RejectionReason::EmptyAggregationBits, StoreError::NotProposer { .. } => RejectionReason::WrongProposer, StoreError::DuplicateAttestationData { .. } => RejectionReason::DuplicateAttestationData, diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index e732b9ea..930b4bd9 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -1012,6 +1012,20 @@ pub enum StoreError { #[error("Validator signature verification failed")] SignatureVerificationFailed, + /// Kept apart from [`Self::SignatureDecodingFailed`] because the proposer + /// signature is a component of the block proof, so the spec reports its + /// failure as an invalid block proof rather than an invalid signature. + #[error("Block proposer signature could not be decoded")] + ProposerSignatureDecodingFailed, + + /// See [`Self::ProposerSignatureDecodingFailed`] for why this is distinct + /// from [`Self::SignatureVerificationFailed`]. + #[error("Block proposer signature verification failed")] + ProposerSignatureVerificationFailed, + + #[error("Block carries an attestation proof but has no attestations")] + UnexpectedAttestationProof, + #[error("Block slot {0} exceeds u32 range")] SlotOutOfRange(u64), @@ -1114,13 +1128,18 @@ pub enum StoreError { BlockTooFarInFuture { block_slot: u64, current_slot: u64 }, } -/// Full verification of a signed block's merged multi-message aggregate proof. +/// Full verification of a signed block's proof. +/// +/// The proof has two independent parts: /// -/// Structural pre-checks (fast fail) ensure the merged proof's `info` list lines -/// up with the block body (one entry per attestation plus a trailing proposer -/// entry; messages, slots, and participants match what the body declares). -/// On success, the lean-multisig devnet5 `verify_type_2` primitive runs the -/// SNARK verifier over the merged proof bytes against the resolved pubkey set. +/// 1. The proposer's raw XMSS signature over the block root, verified directly +/// against the proposer's `proposal_pubkey` with the hash-based verifier. +/// 2. The attestation aggregate: a lean-multisig Type-2 over the body +/// attestations only. Structural pre-checks (fast fail) ensure its `info` +/// list lines up with the block body (one entry per attestation; messages, +/// slots, and participants match what the body declares), then the +/// `verify_type_2` SNARK verifier runs over the proof bytes. A block with no +/// attestations carries no aggregate. /// /// Exposed publicly so RPC handlers (notably the Hive test-driver /// `verify_signatures/run` endpoint) can run the exact same verification path @@ -1160,35 +1179,20 @@ pub fn verify_block_signatures( } let block_root = block.hash_tree_root(); - let structural_elapsed = total_start.elapsed(); - - // Resolve pubkeys per multi-message aggregate component for verify_type_2 and rederive the - // expected (message, slot) bindings from the block body. Attestation - // components use each participant's attestation_pubkey; the trailing - // proposer component uses the proposal_pubkey of `block.proposer_index`. - let expected_components = attestations.len() + 1; - let mut pubkeys_per_component: Vec> = - Vec::with_capacity(expected_components); - let mut expected_bindings: Vec<(H256, u32)> = Vec::with_capacity(expected_components); - - for attestation in attestations.iter() { - let mut pubkeys = Vec::new(); - for vid in validator_indices(&attestation.aggregation_bits) { - let out_of_range = StoreError::AttesterIndexOutOfRange { - validator_index: vid, - num_validators, - }; - let validator = validators.get(vid as usize).ok_or(out_of_range)?; - let pk = ValidatorPublicKey::from_bytes(&validator.attestation_pubkey) - .map_err(|_| StoreError::PubkeyDecodingFailed(vid))?; - pubkeys.push(pk); - } - pubkeys_per_component.push(pubkeys); - let slot_u32 = u32::try_from(attestation.data.slot) - .map_err(|_| StoreError::SlotOutOfRange(attestation.data.slot))?; - expected_bindings.push((attestation.data.hash_tree_root(), slot_u32)); - } - + // Slot narrowing is a range check, so it closes out the structural segment + // rather than landing between two timers and escaping both. + let block_slot_u32 = + u32::try_from(block.slot).map_err(|_| StoreError::SlotOutOfRange(block.slot))?; + // One instant ends the structural segment and starts the crypto one, so the + // two reported components sum to `total_elapsed` with no gap between them. + let structural_end = std::time::Instant::now(); + let structural_elapsed = structural_end.duration_since(total_start); + + // 1. Verify the proposer's raw XMSS signature over the block root. It is + // carried outside the attestation aggregate, so it is checked directly + // against the proposer's proposal pubkey with the hash-based verifier. + // Counted in `crypto_elapsed` below along with the aggregate, so this + // cost is reported rather than falling between the timers. let proposer_out_of_range = StoreError::ProposerIndexOutOfRange { proposer_index: block.proposer_index, num_validators, @@ -1198,30 +1202,69 @@ pub fn verify_block_signatures( .ok_or(proposer_out_of_range)?; let proposer_pubkey = ValidatorPublicKey::from_bytes(&proposer_validator.proposal_pubkey) .map_err(|_| StoreError::PubkeyDecodingFailed(block.proposer_index))?; - pubkeys_per_component.push(vec![proposer_pubkey]); - let block_slot_u32 = - u32::try_from(block.slot).map_err(|_| StoreError::SlotOutOfRange(block.slot))?; - expected_bindings.push((block_root, block_slot_u32)); + let proposer_signature = ValidatorSignature::from_bytes(&signed_block.proof.proposer_signature) + .map_err(|_| StoreError::ProposerSignatureDecodingFailed)?; + if !proposer_signature.is_valid(&proposer_pubkey, block_slot_u32, &block_root) { + return Err(StoreError::ProposerSignatureVerificationFailed); + } - let merged_bytes = signed_block.proof.proof_bytes(); + // 2. Verify the attestation aggregate (Type-2 over the body attestations + // only). A block with no attestations carries no aggregate; reject a + // stray proof rather than silently ignoring it. + if attestations.is_empty() { + if !signed_block + .proof + .attestation_proof + .proof_bytes() + .is_empty() + { + return Err(StoreError::UnexpectedAttestationProof); + } + } else { + // Resolve pubkeys per Type-2 component and rederive the expected + // (message, slot) bindings from the block body. Each component uses its + // participants' attestation_pubkeys. + let mut pubkeys_per_component: Vec> = + Vec::with_capacity(attestations.len()); + let mut expected_bindings: Vec<(H256, u32)> = Vec::with_capacity(attestations.len()); + + for attestation in attestations.iter() { + let mut pubkeys = Vec::new(); + for vid in validator_indices(&attestation.aggregation_bits) { + let out_of_range = StoreError::AttesterIndexOutOfRange { + validator_index: vid, + num_validators, + }; + let validator = validators.get(vid as usize).ok_or(out_of_range)?; + let pk = ValidatorPublicKey::from_bytes(&validator.attestation_pubkey) + .map_err(|_| StoreError::PubkeyDecodingFailed(vid))?; + pubkeys.push(pk); + } + pubkeys_per_component.push(pubkeys); + let slot_u32 = u32::try_from(attestation.data.slot) + .map_err(|_| StoreError::SlotOutOfRange(attestation.data.slot))?; + expected_bindings.push((attestation.data.hash_tree_root(), slot_u32)); + } - let crypto_start = std::time::Instant::now(); - ethlambda_crypto::verify_type_2_signature( - merged_bytes, - pubkeys_per_component, - &expected_bindings, - ) - .map_err(StoreError::BlockProofVerificationFailed)?; - let crypto_elapsed = crypto_start.elapsed(); + let merged_bytes = signed_block.proof.attestation_proof.proof_bytes(); + ethlambda_crypto::verify_type_2_signature( + merged_bytes, + pubkeys_per_component, + &expected_bindings, + ) + .map_err(StoreError::BlockProofVerificationFailed)?; + } + let total_end = std::time::Instant::now(); + let crypto_elapsed = total_end.duration_since(structural_end); + let total_elapsed = total_end.duration_since(total_start); - let total_elapsed = total_start.elapsed(); info!( slot = block.slot, attestation_count = attestations.len(), ?structural_elapsed, ?crypto_elapsed, ?total_elapsed, - "Block multi-message aggregate proof verified" + "Block proof verified" ); Ok(()) @@ -1283,24 +1326,24 @@ mod tests { use ethlambda_types::{ attestation::{AggregatedAttestation, AggregationBits, AttestationData}, block::{ - AggregatedAttestations, BlockBody, MultiMessageAggregate, SignedBlock, - SingleMessageAggregate, + AggregatedAttestations, BlockBody, BlockProof, SignedBlock, SingleMessageAggregate, }, checkpoint::Checkpoint, state::State, }; - /// Test helper: placeholder block proof bytes. + /// Test helper: placeholder block proof. /// - /// In production the merged proof is the raw `compress_without_pubkeys()` - /// output of `merge_many_type_1`, which can only be built by the - /// lean-multisig prover. Tests that don't go through - /// `verify_block_signatures` use an empty blob. + /// In production the attestation aggregate is the raw + /// `compress_without_pubkeys()` output of `merge_many_type_1`, which can + /// only be built by the lean-multisig prover, and the proposer signature is + /// a real XMSS signature. Tests that don't go through + /// `verify_block_signatures` use an empty proof. fn make_signed_block_proof( _proposer_index: u64, _attestation_proofs: Vec, - ) -> MultiMessageAggregate { - MultiMessageAggregate::default() + ) -> BlockProof { + BlockProof::default() } fn make_bits(indices: &[usize]) -> AggregationBits { @@ -1776,7 +1819,7 @@ mod tests { }; let signed_block = SignedBlock { message: block, - proof: MultiMessageAggregate::default(), + proof: BlockProof::default(), }; let result = on_block_without_verification(&mut store, signed_block); @@ -1817,7 +1860,7 @@ mod tests { }; let signed_block = SignedBlock { message: block, - proof: MultiMessageAggregate::default(), + proof: BlockProof::default(), }; let result = on_block_without_verification(&mut store, signed_block); @@ -1875,7 +1918,7 @@ mod tests { state_root: H256::ZERO, body: BlockBody { attestations }, }, - proof: MultiMessageAggregate::default(), + proof: BlockProof::default(), }; let result = verify_block_signatures(&state, &out_of_range_attester); assert!( @@ -1897,7 +1940,7 @@ mod tests { state_root: H256::ZERO, body: BlockBody::default(), }, - proof: MultiMessageAggregate::default(), + proof: BlockProof::default(), }; let result = verify_block_signatures(&state, &out_of_range_proposer); assert!( diff --git a/crates/common/test-fixtures/src/fork_choice.rs b/crates/common/test-fixtures/src/fork_choice.rs index c6c9e58c..d11b76dc 100644 --- a/crates/common/test-fixtures/src/fork_choice.rs +++ b/crates/common/test-fixtures/src/fork_choice.rs @@ -8,7 +8,7 @@ use crate::{ TestState, deser_xmss_hex, }; use ethlambda_types::attestation::XmssSignature; -use ethlambda_types::block::{MultiMessageAggregate, SignedBlock}; +use ethlambda_types::block::{BlockProof, SignedBlock}; use ethlambda_types::primitives::H256; use serde::{Deserialize, Deserializer}; use std::collections::HashMap; @@ -206,7 +206,7 @@ impl BlockStepData { pub fn to_blank_signed_block(&self) -> SignedBlock { SignedBlock { message: self.to_block(), - proof: MultiMessageAggregate::default(), + proof: BlockProof::default(), } } } diff --git a/crates/common/test-fixtures/src/verify_signatures.rs b/crates/common/test-fixtures/src/verify_signatures.rs index a425ac81..edac8b9f 100644 --- a/crates/common/test-fixtures/src/verify_signatures.rs +++ b/crates/common/test-fixtures/src/verify_signatures.rs @@ -11,7 +11,8 @@ //! proof: { proof: { data: "0x" } } use crate::{Block, RejectionReason, TestInfo, TestState}; -use ethlambda_types::block::{MultiMessageAggregate, SignedBlock}; +use ethlambda_types::attestation::blank_xmss_signature; +use ethlambda_types::block::{BlockProof, MultiMessageAggregate, SignedBlock}; use serde::Deserialize; use std::collections::HashMap; use std::fmt; @@ -137,17 +138,23 @@ impl TestSignedBlock { /// /// The container carries the raw lean-multisig wire in the /// `MultiMessageAggregate` stored by `SignedBlock.proof`. + /// + /// NOTE: these fixtures use the leanSpec #799 layout (proposer folded into + /// one merged Type-2). This client now carries the proposer signature + /// outside the attestation aggregate, so the merged bytes land in + /// `attestation_proof` with an empty proposer signature. The verify spec + /// tests therefore fail against these fixtures until they are regenerated. pub fn try_into_signed_block_with_proofs(self) -> Result { let bytes = self .proof .decode() .map_err(|err| SignedBlockConvertError::InvalidProofHex(err.to_string()))?; let len = bytes.len(); - let proof = MultiMessageAggregate::from_bytes(&bytes) + let attestation_proof = MultiMessageAggregate::from_bytes(&bytes) .map_err(|_| SignedBlockConvertError::ProofTooLarge(len))?; Ok(SignedBlock { message: self.block.into(), - proof, + proof: BlockProof::new(blank_xmss_signature(), attestation_proof), }) } } diff --git a/crates/common/types/src/block.rs b/crates/common/types/src/block.rs index 5c5508a2..7718ab1b 100644 --- a/crates/common/types/src/block.rs +++ b/crates/common/types/src/block.rs @@ -4,22 +4,27 @@ use libssz_derive::{HashTreeRoot, SszDecode, SszEncode}; use libssz_types::SszList; use crate::{ - attestation::{AggregatedAttestation, AggregationBits, validator_indices}, + attestation::{ + AggregatedAttestation, AggregationBits, XmssSignature, blank_xmss_signature, + validator_indices, + }, primitives::{self, ByteList, H256}, }; // Convenience trait for calling hash_tree_root() without a hasher argument use primitives::HashTreeRoot as _; -/// Envelope carrying a block and the single merged proof binding every -/// signature it depends on. +/// Envelope carrying a block and its [`BlockProof`]. +/// +/// The proof keeps the proposer's raw signature separate from the attestation +/// aggregate (see [`BlockProof`]). /// ///
/// /// `HashTreeRoot` is intentionally not derived: consumers never hash a /// `SignedBlock` directly — they always hash the inner `Block`. Keeping the /// envelope structurally minimal also means the on-chain root is independent -/// of how the merged proof is serialised. +/// of how the proof is serialised. /// ///
#[derive(Clone, SszEncode, SszDecode)] @@ -27,16 +32,23 @@ pub struct SignedBlock { /// The block being signed. pub message: Block, - /// Single full-block proof covering attestations and the proposer signature. - pub proof: MultiMessageAggregate, + /// Full-block proof: proposer signature + attestation aggregate. + pub proof: BlockProof, } -// Manual Debug impl because the merged proof bytes are large and opaque. +// Manual Debug impl because the proof bytes are large and opaque. impl core::fmt::Debug for SignedBlock { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { f.debug_struct("SignedBlock") .field("message", &self.message) - .field("proof", &format_args!("<{} bytes>", self.proof.proof.len())) + .field( + "proposer_signature", + &format_args!("<{} bytes>", self.proof.proposer_signature.len()), + ) + .field( + "attestation_proof", + &format_args!("<{} bytes>", self.proof.attestation_proof.proof.len()), + ) .finish() } } @@ -88,6 +100,70 @@ pub enum MultiMessageAggregateError { ProofTooLarge(usize), } +// ============================================================================ +// Block proof (proposer signature outside the attestation aggregate) +// ============================================================================ + +/// A full-block proof: the proposer's raw signature plus the attestation +/// aggregate, carried as two independent fields. +/// +/// ```text +/// attestations = [att0, att1] -> (proposer_signature, aggregate([att0, att1])) +/// attestations = [] -> (proposer_signature, empty-proof) +/// ``` +/// +/// `proposer_signature` is the proposer's raw XMSS signature over the block +/// root — the same fixed-size [`XmssSignature`] wire type carried by +/// `SignedAttestation`. It is verified directly against the proposer's +/// `proposal_pubkey` with the hash-based XMSS verifier, so it never enters the +/// lean-multisig prover/verifier. +/// +/// `attestation_proof` is the lean-multisig Type-2 over the block body's +/// attestations *only* — the proposer is no longer one of its components, so +/// it is empty when the block carries no attestations. +/// +///
+/// +/// `HashTreeRoot` is intentionally not derived (as on `SignedAttestation`): +/// `XmssSignature` is a fixed-size byte vector here, but the spec Merkleizes +/// the signature as a container, so a derived root would diverge. Nothing +/// hashes a `BlockProof`. +/// +///
+#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct BlockProof { + /// The proposer's raw XMSS signature over the block root. + pub proposer_signature: XmssSignature, + /// Type-2 aggregate over the body attestations (empty if there are none). + pub attestation_proof: MultiMessageAggregate, +} + +impl BlockProof { + /// Build a proof from a proposer signature and an attestation aggregate. + pub fn new( + proposer_signature: XmssSignature, + attestation_proof: MultiMessageAggregate, + ) -> Self { + Self { + proposer_signature, + attestation_proof, + } + } +} + +impl Default for BlockProof { + /// A blank proof: the structurally-valid all-zero XMSS placeholder used by + /// genesis-style anchor blocks (see [`blank_xmss_signature`]) plus an empty + /// attestation aggregate. `XmssSignature` is fixed-size and has no empty + /// form, so the blank doubles as the genesis placeholder. + fn default() -> Self { + Self { + proposer_signature: blank_xmss_signature(), + attestation_proof: MultiMessageAggregate::default(), + } + } +} + // ============================================================================ // Single-message aggregate // ============================================================================ @@ -95,13 +171,12 @@ pub enum MultiMessageAggregateError { // Wire format mirrors leanSpec PR #717: `SingleMessageAggregate` is a flat // `{ participants, proof }` pair. The signed `message` and `slot` are NOT // carried on the envelope — verifiers rederive each component's binding -// from the surrounding block body (attestation `data` + slot for body -// components, block root + slot for the proposer component). +// from the surrounding block body (attestation `data` + slot). // -// `MultiMessageAggregate` carries the raw lean-multisig type-2 bytes. -// Component participant bitfields come from -// `block.body.attestations[i].aggregation_bits` (and `block.proposer_index` for -// the trailing proposer entry). +// `MultiMessageAggregate` carries the raw lean-multisig type-2 bytes for the +// body attestations only; the proposer signature is carried separately in +// `BlockProof::proposer_signature`. Component participant bitfields come from +// `block.body.attestations[i].aggregation_bits`. /// Maximum number of distinct `AttestationData` entries permitted in a single /// block. Canonical home for the cap shared across `ethlambda-blockchain`, @@ -307,11 +382,13 @@ mod tests { }; let signed = SignedBlock { message: block, - proof: MultiMessageAggregate::default(), + proof: BlockProof::default(), }; let bytes = signed.to_ssz(); let decoded = SignedBlock::from_ssz_bytes(&bytes).expect("decode"); - assert_eq!(decoded.proof.proof.len(), 0); + // Default proof: empty attestation aggregate + the blank XMSS placeholder. + assert_eq!(decoded.proof.attestation_proof.proof.len(), 0); + assert_eq!(decoded.proof.proposer_signature, blank_xmss_signature()); assert_eq!(decoded.message.slot, signed.message.slot); assert_eq!( decoded.message.proposer_index, @@ -330,4 +407,42 @@ mod tests { assert_eq!(&encoded[4..], proof_bytes); assert_eq!(aggregate.proof_bytes(), proof_bytes); } + + #[test] + fn signed_block_ssz_round_trip_with_proposer_signature() { + let block = Block { + slot: 9, + proposer_index: 2, + parent_root: H256::ZERO, + state_root: H256::ZERO, + body: BlockBody::default(), + }; + // A distinctive, full-size XMSS signature blob (fixed `SIGNATURE_SIZE`). + let proposer_bytes: Vec = (0..crate::attestation::SIGNATURE_SIZE) + .map(|i| (i % 251) as u8) + .collect(); + let proposer_signature = XmssSignature::try_from(proposer_bytes.clone()).unwrap(); + let attestation_bytes: Vec = (0..64).collect(); + let signed = SignedBlock { + message: block, + proof: BlockProof::new( + proposer_signature, + MultiMessageAggregate::from_bytes(&attestation_bytes).unwrap(), + ), + }; + + let bytes = signed.to_ssz(); + let decoded = SignedBlock::from_ssz_bytes(&bytes).expect("decode"); + + assert_eq!( + &*decoded.proof.proposer_signature, + proposer_bytes.as_slice() + ); + assert_eq!( + decoded.proof.attestation_proof.proof_bytes(), + attestation_bytes + ); + assert_eq!(decoded.message.slot, 9); + assert_eq!(decoded.message.proposer_index, 2); + } } diff --git a/crates/net/p2p/src/req_resp/handlers.rs b/crates/net/p2p/src/req_resp/handlers.rs index ab7421f5..50ee8ae0 100644 --- a/crates/net/p2p/src/req_resp/handlers.rs +++ b/crates/net/p2p/src/req_resp/handlers.rs @@ -590,7 +590,7 @@ mod tests { use super::*; use ethlambda_storage::{ForkCheckpoints, backend::InMemoryBackend}; use ethlambda_types::{ - block::{Block, BlockBody, MultiMessageAggregate}, + block::{Block, BlockBody, BlockProof}, state::State, }; use std::sync::Arc; @@ -604,7 +604,7 @@ mod tests { state_root: H256::ZERO, body: BlockBody::default(), }, - proof: MultiMessageAggregate::default(), + proof: BlockProof::default(), } } diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index 6674b0b7..6f2ea84c 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -460,7 +460,7 @@ mod tests { #[tokio::test] async fn test_get_latest_finalized_block() { use ethlambda_types::{ - block::{Block, BlockBody, MultiMessageAggregate, SignedBlock}, + block::{Block, BlockBody, BlockProof, SignedBlock}, checkpoint::Checkpoint, primitives::{H256, HashTreeRoot as _}, }; @@ -484,7 +484,7 @@ mod tests { let block_root = block.header().hash_tree_root(); let signed_block = SignedBlock { message: block, - proof: MultiMessageAggregate::default(), + proof: BlockProof::default(), }; // Persist the signed block and mark it as the latest finalized checkpoint. @@ -528,7 +528,7 @@ mod tests { #[tokio::test] async fn test_get_latest_finalized_block_serves_genesis_with_placeholder_proof() { - use ethlambda_types::block::{MultiMessageAggregate, SignedBlock}; + use ethlambda_types::block::{BlockProof, SignedBlock}; use libssz::SszEncode; // Genesis-anchored store: `init_store` writes the header + state but no @@ -553,7 +553,7 @@ mod tests { .unwrap(); let expected = SignedBlock { message: genesis_block.message.clone(), - proof: MultiMessageAggregate::default(), + proof: BlockProof::default(), }; let expected_ssz = expected.to_ssz(); diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index ac341084..b2dbf58f 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -13,9 +13,7 @@ use ethlambda_types::{ AggregatedAttestation, AggregationBits, AttestationData, HashedAttestationData, bits_is_subset, validator_indices, }, - block::{ - Block, BlockBody, BlockHeader, MultiMessageAggregate, SignedBlock, SingleMessageAggregate, - }, + block::{Block, BlockBody, BlockHeader, BlockProof, SignedBlock, SingleMessageAggregate}, checkpoint::Checkpoint, constants::INTERVALS_PER_SLOT, genesis::GenesisConfig, @@ -1253,12 +1251,12 @@ impl Store { let sig_key = encode_slot_root_key(header.slot, root); let proof = match view.get(Table::BlockProof, &sig_key).expect("get") { Some(proof_bytes) => { - MultiMessageAggregate::from_ssz_bytes(&proof_bytes).expect("valid block proof") + BlockProof::from_ssz_bytes(&proof_bytes).expect("valid block proof") } // Synthesis only covers the genesis-style anchor (slot 0). For any // other slot a missing proof (pruned finalized block, or genuine // corruption) surfaces as `None` rather than a fabricated block. - None if header.slot == 0 => MultiMessageAggregate::default(), + None if header.slot == 0 => BlockProof::default(), None => return None, }; @@ -1875,7 +1873,7 @@ mod tests { state_root: H256::ZERO, body: BlockBody::default(), }, - proof: MultiMessageAggregate::default(), + proof: BlockProof::default(), } } @@ -1894,7 +1892,7 @@ mod tests { attestations: attestations.try_into().unwrap(), }, }, - proof: MultiMessageAggregate::default(), + proof: BlockProof::default(), } } @@ -3018,7 +3016,7 @@ mod tests { .expect("genesis block must be retrievable with synthetic proof"); assert_eq!(signed.message.slot, 0); - assert_eq!(signed.proof, MultiMessageAggregate::default()); + assert_eq!(signed.proof, BlockProof::default()); } /// The synthesis branch must be confined to the slot-0 anchor: a From d8714f3b9d0fb999952ba0defa2c68d1f9c1df3a 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, 2 Sep 2026 15:52:47 -0300 Subject: [PATCH 2/2] feat(blockchain): gossip candidate block bodies for proposers to adopt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The costly part of proposing was never picking attestations, it was merging their proofs into the single aggregate a block body carries. With the proposer signature out of that aggregate, the merge no longer involves the block root — so it does not need the proposer, and does not need the slot. Aggregators now do it instead. During the head-update interval the aggregation worker packs a candidate body for the next slot out of the pool as it stands, merges its attestation Type-1s into one Type-2, and the actor gossips the pair as a `BlockBodyProof` on a new topic: struct BlockBodyProof { block_body: BlockBody, proof: MultiMessageAggregate } /leanconsensus/{fork_digest}/block_body_proof/ssz_snappy The proposer packs nothing. It keeps a bounded buffer of the candidates that arrive — its own worker's included — and at the slot boundary adopts the most valuable one, or signs an empty block. What is left of proposing is a state transition, one aggregate verification and one signature, so the proposal moves back to the interval-0 tick the protocol puts it in; the interval-4 prebuild existed only to give the merge headroom. The proposer keeps the last word on what it signs: - a candidate voting for a block this node cannot place on the chain it is extending is dropped. The state transition does not check those roots, so a body packed against another node's view would otherwise be carried verbatim: valid, and worthless; - a candidate whose attestations its own state transition rejects is dropped, and the state root is computed from that transition rather than trusted; - a gossiped candidate's aggregate is verified before signing, not after. XMSS keys are one-time, so a proposer gets one signature per slot and cannot try a candidate, fail the import, and try another; - a candidate is adopted only if it justifies more, finalizes more, or adds voters the state does not already hold. Otherwise the empty body wins, which keeps stale candidates out of blocks rather than merely valid. An empty block is a real option, not a failure: with the proposer signature outside the proof, an attestation-less block carries no aggregate and needs no prover call at all. Two details a devnet run dictated. The candidate buffer is never cleared on a tick and nothing is aged out of it: the merge that produces a candidate takes seconds, so a candidate routinely lands a slot after the one it was packed for, and clearing at an interval boundary races the very batch it makes room for. A stale candidate cannot win anyway — it adds no voters the state lacks, so it scores below an empty body. And when the buffer is empty at the boundary the proposer waits PROPOSAL_CANDIDATE_GRACE before settling for an empty block, since that batch is usually still in flight. `lean_block_building_time_seconds` now covers assembly only. The merge it used to include is `lean_block_body_proof_building_time_seconds` on whichever node built the candidate, and `lean_block_body_source_total` reports how often adoption actually happens. --- CLAUDE.md | 18 +- crates/blockchain/src/aggregation.rs | 211 +++++-- crates/blockchain/src/block_builder.rs | 172 ++++-- crates/blockchain/src/body_proof.rs | 686 +++++++++++++++++++++++ crates/blockchain/src/lib.rs | 392 +++++++------ crates/blockchain/src/metrics.rs | 95 ++++ crates/blockchain/src/store.rs | 40 ++ crates/common/types/src/block.rs | 35 ++ crates/net/api/src/lib.rs | 8 +- crates/net/p2p/src/gossipsub/handler.rs | 57 +- crates/net/p2p/src/gossipsub/messages.rs | 11 + crates/net/p2p/src/gossipsub/mod.rs | 5 +- crates/net/p2p/src/lib.rs | 25 +- crates/net/p2p/src/metrics.rs | 30 + docs/architecture.md | 25 +- docs/benchmarking.md | 11 +- docs/data_storage.md | 7 +- docs/metrics.md | 19 + docs/slots_and_intervals.md | 40 +- docs/spec_deviations.md | 21 + 20 files changed, 1604 insertions(+), 304 deletions(-) create mode 100644 crates/blockchain/src/body_proof.rs diff --git a/CLAUDE.md b/CLAUDE.md index a4b14c91..0e793111 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,8 @@ crates/ ├─ src/lib.rs # BlockChain actor, tick events, validator duties ├─ src/store.rs # Fork choice store, block/attestation processing ├─ src/block_builder.rs # Block assembly (pre-built at previous slot's interval 4) - ├─ src/aggregation.rs # Interval-2 signature aggregation worker + ├─ src/aggregation.rs # Always-on signature aggregation worker (+ body-proof jobs) + ├─ src/body_proof.rs # Candidate block bodies: build, buffer, and the proposer's choice ├─ src/reaggregate.rs # Re-aggregation of block-borne votes on import ├─ src/sync_status.rs # Sync-gate tracker (suppresses duties while syncing) ├─ src/key_manager.rs # Validator key management and signing @@ -54,12 +55,15 @@ crates/ ### Tick-Based Validator Duties (4-second slots, 5 intervals per slot) ``` -Interval 0: Block published (at the slot boundary). The build+publish code path is merged into the previous slot's interval 4 (see below) and aligned to publish here; no attestation acceptance happens at interval 0. +Interval 0: Accept attestations (if proposing), then assemble+publish our block from the buffered BlockBodyProof candidates (or an empty body) Interval 1: Attestation production (all validators, including proposer) -Interval 2: Aggregation (aggregators create proofs from gossip signatures) +Interval 2: Publish the aggregates the always-on worker produced since the last such interval Interval 3: Safe target update (fork choice) -Interval 4: Accept accumulated attestations; build the NEXT slot's block and publish it aligned to that slot's interval 0 (build and publish merged into this tick) +Interval 4: Accept accumulated attestations; the worker packs the NEXT slot's candidate BlockBodyProof, gossiped as it finishes ``` +Aggregation itself is NOT interval-bound: one always-on `spawn_blocking` worker holds a +`Store` clone and proves the best job it can find, continuously (`aggregation.rs`). The +intervals above only govern *publication*. ### Attestation Pipeline ``` @@ -274,8 +278,8 @@ actual_slot = finalized_slot + 1 + relative_index ### Protocols - **Transport**: QUIC over UDP (TLS 1.3) -- **Gossipsub**: Blocks + Attestations (snappy raw compression) - - Topic: `/leanconsensus/{fork_digest}/{block|aggregation|attestation_N}/ssz_snappy` +- **Gossipsub**: Blocks + Attestations + candidate block bodies (snappy raw compression) + - Topic: `/leanconsensus/{fork_digest}/{block|aggregation|block_body_proof|attestation_N}/ssz_snappy` - `fork_digest` is a 4-byte hex string (no `0x` prefix); currently the dummy `12345678` agreed across clients - Mesh size: 8 (6-12 bounds), heartbeat: 700ms - **Req/Resp**: Status, BlocksByRoot, BlocksByRange (snappy frame compression + varint length) @@ -343,7 +347,7 @@ incremental, and line-tables-only debuginfo, so rebuilds are much faster than ### Aggregator Flag Required for Finalization - At least one node **must** be started with `--is-aggregator` to finalize blocks - Without this flag, attestations pass signature verification and are logged as "Attestation processed", but the signature is never stored for aggregation (the `is_aggregator` gate in `on_gossip_attestation`, `store.rs`), so blocks are always built with `attestation_count=0` -- The attestation pipeline: gossip → verify signature → store gossip signature (only if `is_aggregator`) → aggregate at interval 2 → promote to known → pack into blocks +- The attestation pipeline: gossip → verify signature → store gossip signature (only if `is_aggregator`) → aggregate on the always-on worker → publish at interval 2 → promote to known → packed into a candidate body proof at interval 4 - **Symptom**: `justified_slot=0` and `finalized_slot=0` indefinitely despite healthy block production and attestation gossip ### Runtime Aggregator Toggle (Hot-Standby Model) diff --git a/crates/blockchain/src/aggregation.rs b/crates/blockchain/src/aggregation.rs index d2492b77..7e67f795 100644 --- a/crates/blockchain/src/aggregation.rs +++ b/crates/blockchain/src/aggregation.rs @@ -34,8 +34,14 @@ //! slot's committee aggregation. //! //! The actor can also park the worker outright: it raises the pause flag -//! around its own block build, so the prover is not shared with it (see +//! around its own proposal, so the prover is not shared with it (see //! [`AggregationWorker::pause`]). +//! +//! During the head-update interval the worker has one further duty: build the +//! candidate [`BlockBodyProof`] for the upcoming slot (see +//! [`body_proof::build_body_proof`]) and hand it to the actor, which gossips it +//! for that slot's proposer to adopt. It takes priority over aggregation there, +//! since it is the one job with a deadline. use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -49,7 +55,7 @@ use ethlambda_types::{ ShortRoot, aggregator::AggregatorController, attestation::{AggregationBits, AttestationData, HashedAttestationData}, - block::{ByteList512KiB, SingleMessageAggregate}, + block::{BlockBodyProof, ByteList512KiB, SingleMessageAggregate}, primitives::H256, state::Validator, }; @@ -58,8 +64,9 @@ use spawned_concurrency::tasks::ActorRef; use tokio_util::sync::CancellationToken; use tracing::{info, trace, warn}; -use crate::block_builder::{self, EntryScore}; -use crate::{MILLISECONDS_PER_INTERVAL, MILLISECONDS_PER_SLOT, metrics}; +use crate::block_builder::{self, EntryScore, ProposerConfig}; +use crate::body_proof; +use crate::{INTERVALS_PER_SLOT, MILLISECONDS_PER_INTERVAL, MILLISECONDS_PER_SLOT, metrics}; /// How long the worker waits before re-reading the pool when it found nothing /// to do — no eligible job, the pause flag raised, or no aggregation duty. @@ -101,6 +108,11 @@ const _: () = assert!( "EARLY_AGGREGATION_WINDOW must not reach past the slot boundary" ); +/// Offset within the slot at which the head-update interval — the slot's last +/// — begins. From here the worker's first duty is the next slot's candidate +/// body proof. +const HEAD_UPDATE_OFFSET_MS: u64 = (INTERVALS_PER_SLOT - 1) * MILLISECONDS_PER_INTERVAL; + /// A single pre-prepared aggregation group. /// /// Built on the actor thread from a store snapshot; consumed by an off-thread @@ -204,15 +216,17 @@ impl Drop for PauseGuard { } } -/// Startup-fixed inputs the worker's vote-propagation gate needs. Both come -/// from the CLI and never change at runtime, so the worker owns a copy instead -/// of reaching back into the actor. +/// Startup-fixed inputs the worker needs. All come from the CLI and never +/// change at runtime, so the worker owns a copy instead of reaching back into +/// the actor. #[derive(Clone)] pub(crate) struct WorkerConfig { /// Number of attestation committees (= subnet count). pub(crate) attestation_committee_count: u64, /// Attestation subnets this node subscribes to. pub(crate) subscribed_subnets: HashSet, + /// Body-packing policy, shared with the proposer path. + pub(crate) proposer_config: ProposerConfig, } /// One successful aggregate streamed back from the worker. @@ -225,6 +239,27 @@ impl Message for AggregateProduced { type Result = (); } +/// A candidate body proof the worker built for `slot`. +pub(crate) struct BodyProofProduced { + /// Slot the body was packed for: the one whose proposer may adopt it. + pub(crate) slot: u64, + pub(crate) body_proof: BlockBodyProof, + /// Wall time the merge took, observed on the worker thread. + pub(crate) elapsed: Duration, +} +impl Message for BodyProofProduced { + type Result = (); +} + +/// What the worker does with a turn of its loop. +enum WorkerJob { + /// Prove one aggregation group. Boxed: a job carries its whole aggregation + /// material, which dwarfs the other variant. + Aggregate(Box), + /// Build the candidate body proof for `slot`. + BodyProof { slot: u64 }, +} + /// Validator ids this worker has already produced a proof for, keyed by /// attestation data root, for the slot in [`Self::slot`]. /// @@ -878,13 +913,14 @@ pub(crate) fn spawn_aggregation_worker( /// Worker loop — runs on its own thread for the actor's lifetime. /// -/// Each round re-reads the pool through the store handle, picks the best job -/// ([`select_best_job`]), proves it, and hands the result to the actor as an -/// [`AggregateProduced`] message. With nothing to do — nothing eligible, -/// paused for a block build, or no aggregation duty — it sleeps +/// Each round re-reads the pool through the store handle, takes the job worth +/// doing right now ([`next_job`]), and hands the result to the actor: an +/// [`AggregateProduced`] for a proved group, a [`BodyProofProduced`] for the +/// upcoming slot's candidate body. With nothing to do — nothing eligible, +/// paused for a proposal, or no aggregation duty — it sleeps /// [`WORKER_IDLE_POLL`] and looks again. /// -/// `aggregate_mixed` cannot be interrupted, so both cancellation and the pause +/// leanVM proofs cannot be interrupted, so both cancellation and the pause /// flag are only observed between jobs. fn run_aggregation_worker( store: Store, @@ -898,6 +934,9 @@ fn run_aggregation_worker( let genesis_time_ms = store.config().genesis_time * 1000; let mut emitted = EmittedCoverage::default(); + // Slot the last candidate body proof was packed for, so the head-update + // interval produces one candidate rather than a stream of them. + let mut body_proof_slot: Option = None; while !cancel.is_cancelled() { let Some(job) = next_job( @@ -907,60 +946,119 @@ fn run_aggregation_worker( &paused, &config, &mut emitted, + body_proof_slot, ) else { std::thread::sleep(WORKER_IDLE_POLL); continue; }; - let slot = job.slot; - let raw_sigs = job.raw_ids.len(); - let children = job.children.len(); - let data_root = job.hashed.root(); - // Recorded whether or not the proof succeeds: a failed job re-reads - // identically, so without this the loop would retry it at full prover - // cost until a new signature arrives. - let attempted: Vec = job.coverage().into_iter().collect(); - - let job_start = Instant::now(); - let output = aggregate_job(job); - let elapsed = job_start.elapsed(); - emitted.record(data_root, &attempted); - - let Some(output) = output else { - warn!( - slot, - raw_sigs, - children, - ?elapsed, - "Committee signature aggregation failed" - ); - metrics::inc_aggregator_skipped_other(1); - continue; + let delivered = match job { + WorkerJob::Aggregate(job) => run_aggregate_job(*job, &actor, &mut emitted), + WorkerJob::BodyProof { slot } => { + // Marked before the build, not after: a failed or empty build + // would otherwise be retried for the rest of the interval. + body_proof_slot = Some(slot); + run_body_proof_job(slot, &store, &config, &actor) + } }; - info!( + if !delivered { + // Actor is gone; nothing would consume further work. + break; + } + } + + info!("Aggregation worker stopped"); +} + +/// Prove one aggregation group and send it to the actor. Returns false when +/// the actor is gone. +fn run_aggregate_job( + job: AggregationJob, + actor: &ActorRef, + emitted: &mut EmittedCoverage, +) -> bool { + let slot = job.slot; + let raw_sigs = job.raw_ids.len(); + let children = job.children.len(); + let data_root = job.hashed.root(); + // Recorded whether or not the proof succeeds: a failed job re-reads + // identically, so without this the loop would retry it at full prover cost + // until a new signature arrives. + let attempted: Vec = job.coverage().into_iter().collect(); + + let job_start = Instant::now(); + let output = aggregate_job(job); + let elapsed = job_start.elapsed(); + emitted.record(data_root, &attempted); + + let Some(output) = output else { + warn!( slot, raw_sigs, children, - participants = output.participants.len(), ?elapsed, - "Committee signature aggregated" + "Committee signature aggregation failed" ); + metrics::inc_aggregator_skipped_other(1); + return true; + }; - if actor.send(AggregateProduced { output, elapsed }).is_err() { - // Actor is gone; nothing would consume further aggregates. - break; - } - } + info!( + slot, + raw_sigs, + children, + participants = output.participants.len(), + ?elapsed, + "Committee signature aggregated" + ); - info!("Aggregation worker stopped"); + actor.send(AggregateProduced { output, elapsed }).is_ok() +} + +/// Build the candidate body proof for `slot` and send it to the actor. Returns +/// false when the actor is gone. +fn run_body_proof_job( + slot: u64, + store: &Store, + config: &WorkerConfig, + actor: &ActorRef, +) -> bool { + let job_start = Instant::now(); + let Some(body_proof) = body_proof::build_body_proof(store, slot, config.proposer_config) else { + return true; + }; + let elapsed = job_start.elapsed(); + + info!( + %slot, + attestation_count = body_proof.block_body.attestations.len(), + proof_bytes = body_proof.proof.proof.len(), + ?elapsed, + "Block body proof built" + ); + metrics::observe_body_proof_building(elapsed); + + actor + .send(BodyProofProduced { + slot, + body_proof, + elapsed, + }) + .is_ok() } -/// One round of job selection: honor the role flag and the pause flag, derive -/// the slot and the [`JobPolicy`] from the wall clock, then ask -/// [`select_best_job`] for the winner. `None` means "nothing to do right now", -/// which inside the early window is a deliberate answer rather than an idle -/// one. +/// One round of job selection: honor the role flag and the pause flag, then +/// derive from the wall clock what is worth doing. +/// +/// In the head-update interval the next slot's candidate body proof comes +/// first, unless one was already built for that slot: it is the job with a +/// deadline (the proposer assembles before the slot boundary), while +/// aggregation work keeps just as well for the next round. Otherwise the best +/// aggregation job the [`JobPolicy`] admits wins. `None` means "nothing to do +/// right now", which inside the early window is a deliberate answer rather +/// than an idle one. +#[allow(clippy::too_many_arguments)] fn next_job( store: &Store, genesis_time_ms: u64, @@ -968,7 +1066,8 @@ fn next_job( paused: &AtomicBool, config: &WorkerConfig, emitted: &mut EmittedCoverage, -) -> Option { + body_proof_slot: Option, +) -> Option { if !aggregator.is_enabled() || paused.load(Ordering::Acquire) { return None; } @@ -978,10 +1077,16 @@ fn next_job( let slot = ms_since_genesis / MILLISECONDS_PER_SLOT; let ms_into_slot = ms_since_genesis % MILLISECONDS_PER_SLOT; + if ms_into_slot >= HEAD_UPDATE_OFFSET_MS && body_proof_slot != Some(slot + 1) { + return Some(WorkerJob::BodyProof { slot: slot + 1 }); + } + emitted.roll_to(slot); let policy = job_policy(ms_into_slot, store, config); select_best_job(store, slot, policy, emitted) + .map(Box::new) + .map(WorkerJob::Aggregate) } #[cfg(test)] @@ -1740,6 +1845,10 @@ mod tests { let config = WorkerConfig { attestation_committee_count: 4, subscribed_subnets: HashSet::from([0, 1]), + proposer_config: ProposerConfig { + enable_proposer_aggregation: false, + max_attestations_per_block: 1, + }, }; // 10 validators over 4 committees: subnets 0 and 1 hold 3 each, so a // group gathering both needs 4 of those 6. diff --git a/crates/blockchain/src/block_builder.rs b/crates/blockchain/src/block_builder.rs index 406aad57..6fa392ce 100644 --- a/crates/blockchain/src/block_builder.rs +++ b/crates/blockchain/src/block_builder.rs @@ -60,23 +60,9 @@ pub struct ProposerConfig { /// Build a valid block on top of this state. /// -/// Selects attestations via `select_attestations`, collapses entries sharing -/// the same `AttestationData` down to one (a block may carry at most one entry -/// per data; `on_block` rejects duplicates), and runs the STF once to seal the -/// state root. The proposer signature is NOT included; it is appended by the -/// caller. -/// -/// The collapse strategy is gated by `enable_proposer_aggregation`: -/// - **enabled**: same-data proofs are merged via recursive single-message -/// aggregation into a single union-coverage proof (leanSpec #510). Maximizes voter -/// coverage per entry at the cost of a leanVM aggregation per duplicated -/// data entry. -/// - **disabled** (default): the single best-coverage proof per data is kept -/// and the rest dropped. Skips the leanVM work; coverage is bounded by the -/// best individual proof. -/// -/// Either way the output has one entry per `AttestationData` and the -/// attestation-to-proof correspondence stays 1:1. +/// Picks the body's attestations via [`select_and_compact`], then runs the STF +/// once to seal the state root. The proposer signature is NOT included; it is +/// appended by the caller. /// /// `config.max_attestations_per_block` bounds how many distinct /// `AttestationData` entries are packed (a proposer-side self-limit). It is @@ -93,6 +79,96 @@ pub(crate) fn build_block( ) -> Result<(Block, Vec, PostBlockCheckpoints), StoreError> { info!(slot, proposer_index, "Building block"); + let (attestations, aggregated_signatures) = select_and_compact( + head_state, + slot, + parent_root, + known_block_roots, + aggregated_payloads, + config, + )?; + + let (final_block, post_checkpoints) = seal_block( + head_state, + slot, + proposer_index, + parent_root, + BlockBody { attestations }, + )?; + + metrics::observe_block_proposal_attestation_data_selected(final_block.body.attestations.len()); + metrics::observe_block_proposal_aggregates_selected(aggregated_signatures.len()); + + Ok((final_block, aggregated_signatures, post_checkpoints)) +} + +/// Seal a block around `body`: run the state transition once to compute the +/// state root, and report the post-state checkpoints. +/// +/// The body need not be one this node packed. Running the STF is what makes +/// adopting a gossiped [`ethlambda_types::block::BlockBodyProof`] safe: an +/// `Err` here means those attestations are not valid on top of `head_state`, +/// and the state root is computed from the transition rather than trusted. +pub(crate) fn seal_block( + head_state: &State, + slot: u64, + proposer_index: u64, + parent_root: H256, + body: BlockBody, +) -> Result<(Block, PostBlockCheckpoints), StoreError> { + let mut block = Block { + slot, + proposer_index, + parent_root, + state_root: H256::ZERO, + body, + }; + let mut post_state = head_state.clone(); + // ethlambda runs the STF once after selection (it projects justification + // incrementally instead of re-running the STF per loop round), so this is + // a single `stf_simulate` observation per build. + let stf_start = Instant::now(); + process_slots(&mut post_state, slot)?; + process_block(&mut post_state, &block)?; + metrics::observe_block_proposal_phase("stf_simulate", stf_start.elapsed()); + block.state_root = post_state.hash_tree_root(); + + let post_checkpoints = PostBlockCheckpoints { + justified: post_state.latest_justified, + finalized: post_state.latest_finalized, + }; + + Ok((block, post_checkpoints)) +} + +/// Pick the attestations a block at `slot` should carry, and the proof that +/// goes with each. +/// +/// Selection (`select_attestations`) followed by the collapse every block needs +/// — one entry per `AttestationData`, since `on_block` rejects duplicates — +/// with no state transition and no block assembled, so it serves both the +/// proposer (`build_block`) and the aggregation worker building a candidate +/// body proof. +/// +/// The collapse strategy is gated by `enable_proposer_aggregation`: +/// - **enabled**: same-data proofs are merged via recursive single-message +/// aggregation into a single union-coverage proof (leanSpec #510). Maximizes +/// voter coverage per entry at the cost of a leanVM aggregation per +/// duplicated data entry. +/// - **disabled** (default): the single best-coverage proof per data is kept +/// and the rest dropped. Skips the leanVM work; coverage is bounded by the +/// best individual proof. +/// +/// Either way the output has one entry per `AttestationData` and the +/// attestation-to-proof correspondence stays 1:1. +pub(crate) fn select_and_compact( + head_state: &State, + slot: u64, + parent_root: H256, + known_block_roots: &HashSet, + aggregated_payloads: &HashMap)>, + config: ProposerConfig, +) -> Result<(AggregatedAttestations, Vec), StoreError> { let select_start = Instant::now(); let selected = select_attestations( head_state, @@ -106,13 +182,6 @@ pub(crate) fn build_block( let child_payloads_consumed = selected.len(); - // Each AttestationData may appear at most once per block (`on_block` - // rejects duplicates), so same-data entries must be collapsed to one. - // Gated by `enable_proposer_aggregation`: when enabled, proofs sharing an - // AttestationData are merged via recursive single-message aggregation into - // a union-coverage proof (leanSpec #510); when disabled, we skip that leanVM - // work and keep only the single best-coverage proof per data. Both paths - // log the entry / unique-entry counts they already compute. let compact_start = Instant::now(); let compacted = if config.enable_proposer_aggregation { compact_attestations(selected, head_state, slot)? @@ -121,40 +190,32 @@ pub(crate) fn build_block( keep_best_proof_per_data(selected, &running_votes, slot) }; metrics::observe_block_proposal_phase("compact", compact_start.elapsed()); + metrics::inc_block_proposal_child_payloads_consumed(child_payloads_consumed as u64); let (aggregated_attestations, aggregated_signatures): (Vec<_>, Vec<_>) = compacted.into_iter().unzip(); - let attestations: AggregatedAttestations = aggregated_attestations .try_into() .expect("attestation count exceeds limit"); - let mut final_block = Block { - slot, - proposer_index, - parent_root, - state_root: H256::ZERO, - body: BlockBody { attestations }, - }; - let mut post_state = head_state.clone(); - // ethlambda runs the STF once after selection (it projects justification - // incrementally instead of re-running the STF per loop round), so this is - // a single `stf_simulate` observation per build. - let stf_start = Instant::now(); - process_slots(&mut post_state, slot)?; - process_block(&mut post_state, &final_block)?; - metrics::observe_block_proposal_phase("stf_simulate", stf_start.elapsed()); - final_block.state_root = post_state.hash_tree_root(); - - metrics::inc_block_proposal_child_payloads_consumed(child_payloads_consumed as u64); - metrics::observe_block_proposal_attestation_data_selected(final_block.body.attestations.len()); - metrics::observe_block_proposal_aggregates_selected(aggregated_signatures.len()); - let post_checkpoints = PostBlockCheckpoints { - justified: post_state.latest_justified, - finalized: post_state.latest_finalized, - }; + Ok((attestations, aggregated_signatures)) +} - Ok((final_block, aggregated_signatures, post_checkpoints)) +/// The chain view `process_block_header` would produce on a candidate block at +/// `slot`: covering `[0, slot - 1]` with `parent_root` at the parent's slot and +/// `ZERO_HASH` for the empty slots in between. +/// +/// Lets a caller validate a vote's head/source/target roots against the chain +/// the block would extend, instead of waiting for the state transition — which +/// does not check them at all, and would happily carry a vote for a root this +/// node has never seen. +pub(crate) fn extended_chain_view(head_state: &State, slot: u64, parent_root: H256) -> Vec { + let parent_slot = head_state.latest_block_header.slot; + let num_empty_slots = slot.saturating_sub(parent_slot).saturating_sub(1) as usize; + let mut hashes: Vec = head_state.historical_block_hashes.iter().copied().collect(); + hashes.push(parent_root); + hashes.extend(std::iter::repeat_n(H256::ZERO, num_empty_slots)); + hashes } /// Tiered greedy attestation selection for block proposal. @@ -181,16 +242,7 @@ fn select_attestations( return selected; } - // Chain view that `process_block_header` would produce on the candidate - // block: covering [0, slot - 1] with parent_root at parent.slot and - // ZERO_HASH for empty slots in between. Lets us validate source/target - // roots without waiting for the STF to drop mismatches. - let parent_slot = head_state.latest_block_header.slot; - let num_empty_slots = slot.saturating_sub(parent_slot).saturating_sub(1) as usize; - let mut extended_historical_block_hashes: Vec = - head_state.historical_block_hashes.iter().copied().collect(); - extended_historical_block_hashes.push(parent_root); - extended_historical_block_hashes.extend(std::iter::repeat_n(H256::ZERO, num_empty_slots)); + let extended_historical_block_hashes = extended_chain_view(head_state, slot, parent_root); let chain = ChainContext { aggregated_payloads, diff --git a/crates/blockchain/src/body_proof.rs b/crates/blockchain/src/body_proof.rs new file mode 100644 index 00000000..f517ecdf --- /dev/null +++ b/crates/blockchain/src/body_proof.rs @@ -0,0 +1,686 @@ +//! Block body proofs: candidate bodies a proposer can adopt instead of +//! building one itself. +//! +//! A [`BlockBodyProof`] pairs a candidate [`BlockBody`] with the Type-2 +//! aggregate binding its attestations. Since the proposer signature now sits +//! outside that aggregate (`BlockProof`), the aggregate no longer depends on +//! the block root, so any node can build one before the block exists — and the +//! merge, which is the expensive part of proposing, moves off the proposer's +//! critical path onto the aggregation worker and the gossip layer. +//! +//! This module owns both halves. [`build_body_proof`] is what the aggregation +//! worker runs during the head-update interval; [`BodyProofBuffer`] is the +//! bounded set of candidates a node collects each slot, from its own worker and +//! from gossip, and [`choose_body`] is how the slot's proposer picks one — or +//! decides an empty body is worth more. + +use std::collections::{HashSet, VecDeque}; + +use ethlambda_crypto::signature::ValidatorPublicKey; +use ethlambda_state_transition::attestation_data_matches_chain; +use ethlambda_storage::Store; +use ethlambda_types::{ + attestation::validator_indices, + block::{ + Block, BlockBody, BlockBodyProof, ByteList512KiB, MultiMessageAggregate, + MultiMessageAggregateError, SingleMessageAggregate, + }, + primitives::{H256, HashTreeRoot as _}, + state::{State, Validator}, +}; +use spawned_concurrency::message::Message; +use tracing::{info, trace, warn}; + +use crate::block_builder::{self, PostBlockCheckpoints, ProposerConfig}; +use crate::metrics; +use crate::store::StoreError; + +/// Maximum candidates kept at once. One body proof per aggregator per slot +/// reaches a node, and only the freshest batch is worth anything to the next +/// proposer, so the buffer is a small ring rather than a growing pool. +pub(crate) const MAX_BODY_PROOF_CANDIDATES: usize = 8; + +/// Build a candidate body for `slot` off the store, and the Type-2 aggregate +/// binding its attestations. +/// +/// Runs on the aggregation worker, which is not the proposer and must not +/// mutate the store: unlike `produce_block_with_signatures` it takes the +/// current fork-choice head as the parent instead of advancing the store's +/// clock to `slot`, and it assembles no block — a body proof commits to no +/// parent, no state root and no proposer, which is exactly why it can be built +/// by a node that will not propose. Whoever adopts it re-validates the body +/// against its own state. +/// +/// Returns `None` when the pool yields no attestations: an empty body needs no +/// proof, and the proposer's empty-block fallback covers that case for free. +pub(crate) fn build_body_proof( + store: &Store, + slot: u64, + config: ProposerConfig, +) -> Option { + let parent_root = store.head().expect("head read works"); + let head_state = store + .get_state(&parent_root) + .expect("head state read works")?; + let aggregated_payloads = store.known_aggregated_payloads(); + let known_block_roots = store.get_block_roots().expect("block roots read works"); + + let (attestations, aggregates) = block_builder::select_and_compact( + &head_state, + slot, + parent_root, + &known_block_roots, + &aggregated_payloads, + config, + ) + .inspect_err(|err| warn!(%slot, %err, "Failed to select attestations for a body proof")) + .ok()?; + + if aggregates.is_empty() { + trace!(%slot, "No attestations to build a body proof from"); + return None; + } + + let proof = merge_attestation_aggregates(&head_state.validators, &aggregates) + .inspect_err(|err| warn!(%slot, %err, "Failed to build a body proof aggregate")) + .ok()?; + + Some(BlockBodyProof { + block_body: BlockBody { attestations }, + proof, + }) +} + +/// Merge per-attestation single-message aggregates into the one Type-2 a block +/// body carries. +/// +/// The components are the body's attestations and nothing else — with the +/// proposer signature outside the proof, no block root enters here, which is +/// what lets this run before the block exists. +fn merge_attestation_aggregates( + validators: &[Validator], + aggregates: &[SingleMessageAggregate], +) -> Result { + let mut merge_inputs: Vec<(Vec, ByteList512KiB)> = + Vec::with_capacity(aggregates.len()); + + for aggregate in aggregates { + let mut pubkeys = Vec::new(); + for vid in aggregate.participant_indices() { + let validator = validators + .get(vid as usize) + .ok_or(BodyProofError::ParticipantOutOfRange(vid))?; + let pubkey = ValidatorPublicKey::from_bytes(&validator.attestation_pubkey) + .map_err(|_| BodyProofError::PubkeyDecoding(vid))?; + pubkeys.push(pubkey); + } + merge_inputs.push((pubkeys, aggregate.proof.clone())); + } + + let merged = ethlambda_crypto::merge_type_1s_into_type_2(merge_inputs) + .map_err(|err| BodyProofError::Merge(err.to_string()))?; + + Ok(MultiMessageAggregate::from_bytes(merged.iter().as_slice())?) +} + +/// Why a body proof could not be built. Every variant means "propose without +/// this candidate": the proposer's own fallback still produces a valid block. +#[derive(Debug, thiserror::Error)] +pub(crate) enum BodyProofError { + #[error("attestation participant {0} is beyond the validator registry")] + ParticipantOutOfRange(u64), + #[error("could not decode the attestation pubkey of validator {0}")] + PubkeyDecoding(u64), + #[error("could not merge the attestation Type-1s into a Type-2: {0}")] + Merge(String), + #[error("merged attestation proof does not fit the block proof: {0}")] + ProofTooLarge(#[from] MultiMessageAggregateError), +} + +/// Self-message that assembles and publishes `slot`'s block. +/// +/// Scheduled by the block-publication tick when the candidate buffer is still +/// empty: the merge that produces a candidate spans the interval boundary, so +/// the batch for this slot often lands a couple of hundred milliseconds into +/// it. A message rather than an in-handler wait, because the actor has to keep +/// processing gossip in the meantime — that is what it is waiting for. +pub(crate) struct AssembleProposal { + pub(crate) slot: u64, + pub(crate) validator_id: u64, +} +impl Message for AssembleProposal { + type Result = (); +} + +/// A candidate body the proposer may adopt. +pub(crate) struct BodyProofCandidate { + pub(crate) body_proof: BlockBodyProof, + /// Whether the aggregate has already been established as valid: true for + /// one our own worker built, false for one that arrived on gossip. + /// + /// A proposer signs the block root exactly once per slot — the XMSS key is + /// one-time — so a candidate's proof has to be verified *before* signing, + /// not by importing the signed block and seeing whether it sticks. This + /// flag is what spares us that verification on our own proofs. + pub(crate) verified: bool, +} + +/// The body a proposer decided to build its block around, sealed and ready to +/// sign. +pub(crate) struct ChosenBody { + /// The block, `state_root` sealed by the state transition. + pub(crate) block: Block, + /// The aggregate binding the block's attestations: the adopted candidate's + /// proof, or an empty one for an empty body. + pub(crate) attestation_proof: MultiMessageAggregate, + /// Whether a candidate body proof was adopted (as opposed to falling back + /// to an empty body). + pub(crate) adopted: bool, +} + +/// How a sealed candidate compares to another. Higher is better: +/// finalization first, then justification, then how many voters the body adds +/// that the pre-state did not already have, then — all else equal — the +/// smaller body. +/// +/// The new-voter term is what keeps a stale candidate out: its attestations +/// are already reflected in the state, so it adds nothing and loses to the +/// empty body it ties on checkpoints. +#[derive(PartialEq, Eq, PartialOrd, Ord)] +struct BodyValue { + finalized_slot: u64, + justified_slot: u64, + new_voters: usize, + /// Negated so that fewer attestations sorts higher. + fewer_attestations: isize, +} + +/// Choose the body for a block at `slot` from the buffered candidates, +/// falling back to an empty body. +/// +/// Each candidate is screened against the chain the block would extend, sealed +/// against `head_state` — the state transition both applies its attestations +/// and computes the state root — and scored by [`BodyValue`]. The best +/// candidate that beats the empty body is adopted, with three caveats: +/// +/// - a candidate carrying a vote that does not sit on that chain is dropped. +/// The state transition does not check those roots, so a body packed against +/// another node's view would otherwise be carried verbatim — valid, and +/// worthless; +/// - a candidate that arrived on gossip has its aggregate verified before it +/// is adopted, since the proposer signs the block root only once (XMSS keys +/// are one-time) and so cannot discover a bad proof by trying to import the +/// signed block; +/// - a candidate whose state transition or verification fails is dropped and +/// the next-best one considered. +/// +/// The empty-body fallback always succeeds and needs no prover work at all: +/// with the proposer signature outside the proof, an attestation-less block +/// carries no aggregate. +pub(crate) fn choose_body( + head_state: &State, + slot: u64, + proposer_index: u64, + parent_root: H256, + candidates: &BodyProofBuffer, +) -> Result { + metrics::observe_body_proof_candidates(candidates.len()); + + let chain_view = block_builder::extended_chain_view(head_state, slot, parent_root); + + let (empty_block, empty_post) = block_builder::seal_block( + head_state, + slot, + proposer_index, + parent_root, + BlockBody::default(), + )?; + let empty_value = BodyValue { + finalized_slot: empty_post.finalized.slot, + justified_slot: empty_post.justified.slot, + new_voters: 0, + fewer_attestations: 0, + }; + + let validator_count = head_state.validators.len(); + let mut ranked: Vec<(BodyValue, &BodyProofCandidate, Block, PostBlockCheckpoints)> = Vec::new(); + + for candidate in candidates.iter() { + let body = candidate.body_proof.block_body.clone(); + let attestation_count = body.attestations.len(); + if !body_votes_on_chain(&body, &chain_view) { + trace!( + %slot, + attestation_count, + "Rejected a candidate body proof: it votes off our chain" + ); + metrics::inc_body_proof_rejected("off_chain_vote"); + continue; + } + let new_voters = count_new_voters(head_state, &body, validator_count); + let sealed = block_builder::seal_block(head_state, slot, proposer_index, parent_root, body); + let (block, post) = match sealed { + Ok(sealed) => sealed, + Err(err) => { + // Expected, not exceptional: the candidate was packed against + // another node's view of the chain. + trace!(%slot, attestation_count, %err, "Rejected a candidate body proof"); + metrics::inc_body_proof_rejected("state_transition"); + continue; + } + }; + let value = BodyValue { + finalized_slot: post.finalized.slot, + justified_slot: post.justified.slot, + new_voters, + fewer_attestations: -(attestation_count as isize), + }; + if value <= empty_value { + trace!( + %slot, + attestation_count, + new_voters, + "Candidate body proof is worth no more than an empty body" + ); + continue; + } + ranked.push((value, candidate, block, post)); + } + + ranked.sort_by(|a, b| b.0.cmp(&a.0)); + + for (value, candidate, block, _post) in ranked { + if !candidate.verified + && let Err(err) = verify_body_proof(head_state, &candidate.body_proof) + { + warn!(%slot, %err, "Candidate body proof failed verification"); + metrics::inc_body_proof_rejected("verification"); + continue; + } + + info!( + %slot, + attestation_count = block.body.attestations.len(), + new_voters = value.new_voters, + justified_slot = value.justified_slot, + finalized_slot = value.finalized_slot, + from_gossip = !candidate.verified, + "Adopted a candidate body proof" + ); + metrics::inc_block_body_from_proof(); + return Ok(ChosenBody { + block, + attestation_proof: candidate.body_proof.proof.clone(), + adopted: true, + }); + } + + info!( + %slot, + candidates = candidates.len(), + "No usable candidate body proof; proposing an empty block" + ); + metrics::inc_block_body_empty(); + Ok(ChosenBody { + block: empty_block, + attestation_proof: MultiMessageAggregate::default(), + adopted: false, + }) +} + +/// Whether every vote in a body sits on the chain the block would extend: +/// each one's source, target and head root found at its own slot in +/// `chain_view`. +/// +/// Deliberately narrower than the block builder's `entry_passes_filters`, +/// which also drops entries that are merely unhelpful — a target already +/// justified, a source not yet justified. Those are per-entry verdicts, and a +/// body is all-or-nothing: its proof binds exactly these attestations, so one +/// stale entry would cost the whole candidate and, often, leave the slot with +/// an empty block. A stale vote is already discounted by the new-voter score; +/// an off-chain vote is the one a proposer must not carry, and the state +/// transition would carry it happily. +fn body_votes_on_chain(body: &BlockBody, chain_view: &[H256]) -> bool { + body.attestations + .iter() + .all(|attestation| attestation_data_matches_chain(chain_view, &attestation.data)) +} + +/// Count the validators a body's attestations add on top of `head_state`. +/// +/// Uses the block builder's projection so the count means the same thing it +/// does during selection: per target root, voters the running set does not +/// already hold. +fn count_new_voters(head_state: &State, body: &BlockBody, validator_count: usize) -> usize { + let mut projected = block_builder::ProjectedState::from_head_state(head_state); + let mut total = 0usize; + + for attestation in body.attestations.iter() { + let coverage: HashSet = validator_indices(&attestation.aggregation_bits).collect(); + let Some((score, new_voters)) = + projected.score_entry(&attestation.data, &coverage, validator_count) + else { + continue; + }; + total += new_voters.len(); + projected.advance(score.tier, &attestation.data, new_voters); + } + + total +} + +/// Verify a candidate's aggregate against the body it claims to bind: one +/// Type-2 component per attestation, each bound to that attestation's data +/// root and slot. +/// +/// The same check `verify_block_signatures` runs on import, minus the proposer +/// signature (which does not exist yet). +fn verify_body_proof(head_state: &State, body_proof: &BlockBodyProof) -> Result<(), StoreError> { + let attestations = &body_proof.block_body.attestations; + let validators = &head_state.validators; + let num_validators = validators.len() as u64; + + let mut pubkeys_per_component: Vec> = + Vec::with_capacity(attestations.len()); + let mut expected_bindings: Vec<(H256, u32)> = Vec::with_capacity(attestations.len()); + + for attestation in attestations.iter() { + let mut pubkeys = Vec::new(); + for vid in validator_indices(&attestation.aggregation_bits) { + let validator = + validators + .get(vid as usize) + .ok_or(StoreError::AttesterIndexOutOfRange { + validator_index: vid, + num_validators, + })?; + let pubkey = ValidatorPublicKey::from_bytes(&validator.attestation_pubkey) + .map_err(|_| StoreError::PubkeyDecodingFailed(vid))?; + pubkeys.push(pubkey); + } + pubkeys_per_component.push(pubkeys); + let slot = u32::try_from(attestation.data.slot) + .map_err(|_| StoreError::SlotOutOfRange(attestation.data.slot))?; + expected_bindings.push((attestation.data.hash_tree_root(), slot)); + } + + let _timing = metrics::time_pq_sig_aggregated_signatures_verification(); + ethlambda_crypto::verify_type_2_signature( + body_proof.proof.proof_bytes(), + pubkeys_per_component, + &expected_bindings, + ) + .map_err(StoreError::BlockProofVerificationFailed) +} + +/// Bounded, newest-first buffer of proposal candidates. +#[derive(Default)] +pub(crate) struct BodyProofBuffer { + candidates: VecDeque, +} + +impl BodyProofBuffer { + /// Record a candidate built by our own aggregation worker. + pub(crate) fn push_local(&mut self, body_proof: BlockBodyProof) { + self.push(BodyProofCandidate { + body_proof, + verified: true, + }); + } + + /// Record a candidate that arrived on gossip. Its aggregate is not + /// verified here: verification costs a full Type-2 check, and only the + /// slot's proposer ever needs the answer. + pub(crate) fn push_gossip(&mut self, body_proof: BlockBodyProof) { + self.push(BodyProofCandidate { + body_proof, + verified: false, + }); + } + + fn push(&mut self, candidate: BodyProofCandidate) { + self.candidates.push_front(candidate); + while self.candidates.len() > MAX_BODY_PROOF_CANDIDATES { + let dropped = self.candidates.pop_back(); + trace!( + dropped_attestations = dropped.map(|c| c.body_proof.block_body.attestations.len()), + "Evicted the oldest block body proof candidate" + ); + } + } + + /// Candidates newest first. + /// + /// Nothing is aged out by slot, and the buffer is never cleared on a tick. + /// Two reasons. A clear at an interval boundary races the batch it is + /// making room for, since our own worker's candidate can land either side + /// of it. And a candidate is not worthless for being a slot or two old: + /// the merge that produces one takes seconds, so candidates routinely + /// arrive a slot late, and while the blocks in between were empty their + /// votes are still the newest anyone has. What a stale candidate cannot do + /// is win: it adds no voters the state lacks, so `choose_body` scores it + /// below an empty body. The ring bound is what keeps this finite. + pub(crate) fn iter(&self) -> impl Iterator { + self.candidates.iter() + } + + pub(crate) fn len(&self) -> usize { + self.candidates.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ethlambda_types::{ + attestation::{AggregatedAttestation, AggregationBits, AttestationData}, + block::{BlockHeader, MultiMessageAggregate}, + checkpoint::Checkpoint, + state::{ChainConfig, JustificationValidators, JustifiedSlots, Validator}, + }; + use libssz_types::SszList; + + const NUM_VALIDATORS: usize = 4; + /// Slot of the head the candidates are packed on top of; the block under + /// construction is the next one. + const HEAD_SLOT: u64 = 1; + const BLOCK_SLOT: u64 = HEAD_SLOT + 1; + /// Round-robin proposer for [`BLOCK_SLOT`]; the state transition rejects + /// any other index. + const PROPOSER: u64 = BLOCK_SLOT % NUM_VALIDATORS as u64; + + fn genesis_root() -> H256 { + H256([1u8; 32]) + } + + /// The head block's root, as the state transition derives it: the hash of + /// `latest_block_header` once `process_slots` has filled in its state + /// root. Deriving it the same way the transition does is the only way a + /// fixture parent root matches. + fn head_root() -> H256 { + let mut state = head_state(); + ethlambda_state_transition::process_slots(&mut state, BLOCK_SLOT) + .expect("advancing one slot works"); + state.latest_block_header.hash_tree_root() + } + + /// A chain of two blocks: genesis at slot 0 and the head at [`HEAD_SLOT`]. + /// `historical_block_hashes` covers `[0, HEAD_SLOT - 1]` — the header push + /// records the parent, never the block's own root — so the head root lands + /// there only once a block builds on it. + fn head_state() -> State { + State { + config: ChainConfig { genesis_time: 1000 }, + slot: HEAD_SLOT, + latest_block_header: BlockHeader { + slot: HEAD_SLOT, + proposer_index: 0, + parent_root: genesis_root(), + state_root: H256::ZERO, + body_root: H256::ZERO, + }, + latest_justified: Checkpoint::default(), + latest_finalized: Checkpoint::default(), + historical_block_hashes: SszList::try_from(vec![genesis_root()]).unwrap(), + justified_slots: JustifiedSlots::new(), + validators: SszList::try_from( + (0..NUM_VALIDATORS) + .map(|i| Validator { + attestation_pubkey: [i as u8; 52], + proposal_pubkey: [i as u8; 52], + index: i as u64, + }) + .collect::>(), + ) + .unwrap(), + justifications_roots: Default::default(), + justifications_validators: JustificationValidators::new(), + } + } + + fn bits(indices: &[usize]) -> AggregationBits { + let max = indices.iter().copied().max().unwrap_or(0); + let mut bits = AggregationBits::with_length(max + 1).unwrap(); + for &i in indices { + bits.set(i, true).unwrap(); + } + bits + } + + /// A vote for the head, sourced at genesis: valid on top of + /// [`head_state`] and worth new voters. + fn head_vote(voters: &[usize]) -> AggregatedAttestation { + AggregatedAttestation { + aggregation_bits: bits(voters), + data: AttestationData { + slot: HEAD_SLOT, + head: Checkpoint { + root: head_root(), + slot: HEAD_SLOT, + }, + target: Checkpoint { + root: head_root(), + slot: HEAD_SLOT, + }, + source: Checkpoint { + root: genesis_root(), + slot: 0, + }, + }, + } + } + + /// A vote naming a head that is not on this chain, which the state + /// transition rejects. + fn off_chain_vote() -> AggregatedAttestation { + let mut attestation = head_vote(&[0]); + attestation.data.head.root = H256([9u8; 32]); + attestation.data.target.root = H256([9u8; 32]); + attestation + } + + fn candidate(attestations: Vec) -> BlockBodyProof { + BlockBodyProof { + block_body: BlockBody { + attestations: attestations.try_into().unwrap(), + }, + proof: MultiMessageAggregate::default(), + } + } + + fn choose(candidates: &BodyProofBuffer) -> ChosenBody { + choose_body(&head_state(), BLOCK_SLOT, PROPOSER, head_root(), candidates) + .expect("sealing an empty body always works") + } + + #[test] + fn choose_body_falls_back_to_an_empty_body() { + let chosen = choose(&BodyProofBuffer::default()); + + assert!(!chosen.adopted); + assert_eq!(chosen.block.body.attestations.len(), 0); + assert!( + chosen.attestation_proof.proof_bytes().is_empty(), + "an attestation-less block carries no aggregate" + ); + } + + #[test] + fn choose_body_adopts_a_candidate_that_adds_voters() { + let mut candidates = BodyProofBuffer::default(); + candidates.push_local(candidate(vec![head_vote(&[0, 1])])); + + let chosen = choose(&candidates); + + assert!(chosen.adopted); + assert_eq!(chosen.block.body.attestations.len(), 1); + } + + /// The state transition would carry a vote for an unknown root happily, so + /// this is the screen that keeps a body packed against another node's view + /// out of our block. + #[test] + fn choose_body_rejects_a_candidate_voting_off_chain() { + let mut candidates = BodyProofBuffer::default(); + candidates.push_local(candidate(vec![off_chain_vote()])); + + let chosen = choose(&candidates); + + assert!(!chosen.adopted); + } + + /// A body that adds no voters is worth no more than an empty one, so it + /// loses the tie rather than bloating the block. + #[test] + fn choose_body_ignores_a_candidate_with_no_new_voters() { + let mut candidates = BodyProofBuffer::default(); + candidates.push_local(candidate(vec![head_vote(&[])])); + + let chosen = choose(&candidates); + + assert!(!chosen.adopted); + } + + #[test] + fn choose_body_prefers_the_candidate_with_more_new_voters() { + let mut candidates = BodyProofBuffer::default(); + candidates.push_local(candidate(vec![head_vote(&[0])])); + candidates.push_local(candidate(vec![head_vote(&[0, 1, 2])])); + + let chosen = choose(&candidates); + + assert!(chosen.adopted); + let adopted = chosen + .block + .body + .attestations + .iter() + .next() + .expect("the adopted body carries its attestation"); + assert_eq!( + validator_indices(&adopted.aggregation_bits).count(), + 3, + "the wider candidate wins" + ); + } + + #[test] + fn buffer_keeps_the_newest_candidates() { + let mut buffer = BodyProofBuffer::default(); + for _ in 0..MAX_BODY_PROOF_CANDIDATES { + buffer.push_gossip(candidate(Vec::new())); + } + buffer.push_local(candidate(Vec::new())); + + assert_eq!(buffer.len(), MAX_BODY_PROOF_CANDIDATES); + assert!( + buffer.iter().next().expect("non-empty").verified, + "the newest candidate is at the front" + ); + assert_eq!( + buffer.iter().filter(|c| c.verified).count(), + 1, + "only the locally built candidate counts as verified" + ); + } +} diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index af884d5c..8afeeecd 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -1,7 +1,6 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::time::{Duration, Instant, SystemTime}; -use ethlambda_crypto::signature::ValidatorPublicKey; use ethlambda_network_api::{BlockChainToP2PRef, BlockSource, InitP2P}; use ethlambda_state_transition::is_proposer; use ethlambda_storage::{ALL_TABLES, Store}; @@ -9,11 +8,12 @@ use ethlambda_types::{ ShortRoot, aggregator::AggregatorController, attestation::{SignedAggregatedAttestation, SignedAttestation}, - block::{BlockProof, ByteList512KiB, MultiMessageAggregate, SignedBlock}, + block::{BlockBodyProof, BlockProof, SignedBlock}, primitives::{H256, HashTreeRoot as _}, }; -use crate::aggregation::{AggregateProduced, AggregationWorker, WorkerConfig}; +use crate::aggregation::{AggregateProduced, AggregationWorker, BodyProofProduced, WorkerConfig}; +use crate::body_proof::{AssembleProposal, BodyProofBuffer}; use crate::key_manager::ValidatorKeyPair; use crate::sync_status::SyncStatusTracker; use spawned_concurrency::actor; @@ -30,6 +30,7 @@ pub use events::{ChainEvent, EventBus, Topic, UnknownTopic}; pub mod aggregation; pub mod block_builder; +pub(crate) mod body_proof; pub(crate) mod coverage; pub mod events; pub(crate) mod fork_choice_tree; @@ -71,6 +72,18 @@ pub use ethlambda_types::constants::{ INTERVALS_PER_SLOT, MILLISECONDS_PER_INTERVAL, MILLISECONDS_PER_SLOT, }; pub use sync_status::SyncStatusController; +/// How long the proposer waits at the slot boundary for a candidate body proof +/// when it has none yet. +/// +/// The merge that produces a candidate starts at the previous head-update +/// interval and routinely runs a couple of hundred milliseconds past the slot +/// boundary, so a proposer that assembled the instant its tick fired would +/// keep finding an empty buffer and publishing empty blocks. Waiting is much +/// cheaper than that: attestations are not due until interval 1, and a block +/// with the slot's votes in it is worth a few hundred milliseconds of +/// propagation. +const PROPOSAL_CANDIDATE_GRACE: Duration = Duration::from_millis(400); + /// Future-slot tolerance for gossip attestations, expressed in intervals. /// /// Bounds the clock skew the time check is willing to absorb when admitting a @@ -178,6 +191,8 @@ impl BlockChain { aggregator, pending_block_parents: HashMap::new(), aggregation_worker: None, + body_proof_candidates: BodyProofBuffer::default(), + pending_body_proofs: Vec::new(), pending_aggregates: Vec::new(), last_tick_instant: None, attestation_committee_count, @@ -238,6 +253,16 @@ pub struct BlockChainServer { /// treat `None` as "not up yet". aggregation_worker: Option, + /// Candidate bodies the proposer may adopt for the upcoming slot: what our + /// own worker built plus what arrived on gossip. Cleared once a proposal + /// has been assembled, since a body carries one slot's votes. + body_proof_candidates: BodyProofBuffer, + + /// Candidate body proofs the worker produced and we have not gossiped yet. + /// Published during the head-update interval, the interval they are built + /// in, so the next slot's proposer sees them in time. + pending_body_proofs: Vec, + /// Aggregates produced by the worker and not yet gossiped. They are /// applied to the store the moment they arrive (so the pool and the /// worker's next selection round see them) but published only at the @@ -366,19 +391,46 @@ impl BlockChainServer { // at tick time), so it doubles as the wall-clock slot for the gate. pre_tick.diff_and_emit(&self.store, &self.events, slot); - // Per-interval duties for this tick. Intervals 0 (block publish) and 3 - // (safe-target update) are driven inside `store::on_tick` above, so they - // carry only a note below. + // Per-interval duties for this tick. Interval 3 (safe-target update) is + // driven inside `store::on_tick` above, so it carries only a note below. match interval { // ==== interval 0 ==== // - // No actor work at interval 0. The block is published here - // conceptually (at the slot boundary), but the build+publish code - // path runs at interval 4 of the previous slot — where it also - // advances the store to this slot's interval 0 before building (see - // `propose_block`). The real interval-0 tick is then skipped by the - // idempotency guard above, since the store clock is already here. - SlotInterval::BlockPublication => {} + // Assemble and publish our block, if we are this slot's proposer. + // + // Back at the slot boundary the protocol puts it, rather than + // prebuilt at the previous interval 4: the proposer no longer packs + // a body, it adopts one of the candidate body proofs gossiped + // during that interval, and those keep arriving until the boundary. + // Assembly is a state transition, a verification and a signature — + // no prover work — so it no longer needs an interval of headroom. + SlotInterval::BlockPublication => { + let proposer = (slot > 0) + .then(|| self.get_our_proposer(slot)) + .flatten() + .filter(|_| self.sync_status.duties_allowed()); + + if let Some(validator_id) = proposer { + if self.body_proof_candidates.len() > 0 { + self.assemble_proposal(slot, validator_id).await; + } else { + // Nothing to propose yet: the merge that produces a + // candidate spans the boundary, so this slot's batch is + // most likely still in flight. Come back for it rather + // than settling for an empty block. + info!( + %slot, + grace_ms = PROPOSAL_CANDIDATE_GRACE.as_millis() as u64, + "No candidate body proof yet; waiting before assembling" + ); + send_after( + PROPOSAL_CANDIDATE_GRACE, + _ctx.clone(), + AssembleProposal { slot, validator_id }, + ); + } + } + } // ==== interval 1 ==== // @@ -432,30 +484,20 @@ impl BlockChainServer { // ==== interval 4 ==== // - // Build and publish the NEXT slot's block here, one interval early, - // so the heavy leanVM work happens during this otherwise-idle - // interval. `propose_block` blocks the actor for the build and aligns - // publication to the slot boundary. Doing the whole proposal here — - // rather than stashing it for the interval-0 tick — keeps it robust: - // `on_tick` skips the interval-0 tick whenever this build overruns - // its interval. + // The candidate body proofs for the next slot are built here, on the + // worker, and published as they arrive (see the `BodyProofProduced` + // handler). This is the interval whose votes the next block carries: + // the store's promote ran just above, so the pool the worker packs + // from is the one the block will be judged against. SlotInterval::EndOfSlot => { - let next_slot = slot + 1; - let next_proposer = self - .get_our_proposer(next_slot) - .filter(|_| self.sync_status.duties_allowed()); - - if let Some(validator_id) = next_proposer { - // Park the aggregation worker for the build: both run - // leanVM proofs, and the block is the one with a deadline. - // The guard lowers the flag again on the way out, including - // on `propose_block`'s early returns. - let _pause = self - .aggregation_worker - .as_ref() - .map(AggregationWorker::pause); - self.propose_block(next_slot, validator_id).await; - } + // The buffer is not cleared here: our own worker's candidate can + // land either side of this tick, so a clear would race the batch + // it is making room for. Staleness is judged per read instead + // (`BodyProofBuffer::iter_fresh`). + // + // Anything the worker produced too late for its own interval + // goes out now, having missed the proposer it was built for. + self.publish_pending_body_proofs(slot); } } @@ -508,6 +550,54 @@ impl BlockChainServer { info!(%slot, count, "Published buffered aggregates"); } + /// Pause the aggregation worker and assemble this slot's block. + /// + /// Verifying a candidate's aggregate is leanVM work too, and the block is + /// the one with a deadline, so the worker sits out the assembly. The guard + /// lowers the flag again on the way out, including on `propose_block`'s + /// early returns. + async fn assemble_proposal(&mut self, slot: u64, validator_id: u64) { + let _pause = self + .aggregation_worker + .as_ref() + .map(AggregationWorker::pause); + self.propose_block(slot, validator_id).await; + } + + /// Gossip the candidate body proofs the worker produced, then clear the + /// buffer. + /// + /// Unlike an aggregate, a body proof has one slot in which it is worth + /// anything: the proposer it is meant for assembles before the next slot + /// opens. So an undeliverable one is dropped rather than held. + fn publish_pending_body_proofs(&mut self, slot: u64) { + let pending = std::mem::take(&mut self.pending_body_proofs); + if pending.is_empty() { + return; + } + let count = pending.len(); + + let Some(p2p) = self.p2p.as_ref() else { + debug!(%slot, count, "Dropping candidate body proofs: no P2P yet"); + return; + }; + + for body_proof in pending { + let _ = p2p + .publish_block_body_proof(body_proof) + .inspect_err(|err| error!(%err, "Failed to publish block body proof")); + } + info!(%slot, count, "Published candidate body proofs"); + } + + /// The slot the wall clock is in, which is what stamps a candidate body + /// proof's arrival: the store's clock only advances on ticks, and a + /// candidate can arrive between two of them. + fn wall_clock_slot(&self) -> u64 { + let genesis_time_ms = self.store.config().genesis_time * 1000; + unix_now_ms().saturating_sub(genesis_time_ms) / MILLISECONDS_PER_SLOT + } + /// Returns the validator ID if any of our validators is the proposer for this slot. fn get_our_proposer(&self, slot: u64) -> Option { let head_state = self.store.head_state(); @@ -566,63 +656,57 @@ impl BlockChainServer { } } - /// Build the target slot's block and publish it, one interval early. + /// Assemble this slot's block from the candidate body proofs on hand and + /// publish it. + /// + /// Runs at the slot's own interval-0 tick. The proposer packs no body + /// itself: it adopts the most valuable candidate + /// (`body_proof::choose_body`), or signs an empty block when none is worth + /// more than one. What is left costs a state transition per candidate, one + /// aggregate verification and one signature — no prover work, and none at + /// all for an empty body — which is why this no longer needs to be + /// prebuilt an interval early. /// - /// Runs at the previous slot's interval 4, blocking the actor for the build - /// (the expensive part is the leanVM single-message → multi-message - /// aggregate merge). It first - /// advances the store to the target slot's interval 0 (accepting - /// attestations) so the block is built on exactly the interval-0 state a - /// non-prebuilding proposer would see, then builds and publishes — aligned - /// to the slot boundary: if the build finishes before the slot opens we wait - /// out the remainder so the block is not published early; if it overran (the - /// common case under load) we publish at once. The whole proposal is - /// self-contained here, so it never depends on the interval-0 tick — which - /// `handle_tick` skips whenever this build overruns its interval. + /// It re-runs the store's advance to this slot's interval 0 (accepting + /// attestations) so the candidates are judged against exactly the state the + /// block is built on, and so a tick that arrived late still proposes on the + /// right state. Both steps are idempotent. async fn propose_block(&mut self, slot: u64, validator_id: u64) { info!(%slot, %validator_id, "We are the proposer for this slot"); let genesis_time_ms = self.store.config().genesis_time * 1000; let slot_start_ms = genesis_time_ms + slot * MILLISECONDS_PER_SLOT; - // Build the block. `produce_block_with_signatures` advances the store to - // this slot's interval 0 (accepting attestations) before building — one - // interval ahead of the interval-4 tick we are running in — so the block - // is built on the interval-0 state rather than the previous slot's end - // state. Building early is safe because we publish below (nothing is - // stashed for a later tick), and the real interval-0 tick is then skipped - // by the idempotency guard in `on_tick`, since the store clock is already - // here. - // - // That interval-0 catch-up can move head/justified/finalized (it is the - // same attestation-acceptance step a non-proposing node runs at its - // interval-0 tick). Snapshot around the build so those moves surface as - // chain events here, matching an observer node; otherwise they would - // land outside every snapshot window and be silently absorbed into the - // later block-import diff's baseline. + // The interval-0 catch-up inside `produce_block_from_candidates` can + // move head/justified/finalized (it is the same attestation-acceptance + // step a non-proposing node runs at its interval-0 tick). Snapshot + // around it so those moves surface as chain events here, matching an + // observer node; otherwise they would land outside every snapshot + // window and be silently absorbed into the later block-import diff's + // baseline. let pre_build = ChainEventSnapshot::capture(&self.store); let timing = metrics::time_block_building(); - let build_result = store::produce_block_with_signatures( + let chosen = store::produce_block_from_candidates( &mut self.store, slot, validator_id, - self.proposer_config, + &self.body_proof_candidates, ) - .inspect_err(|err| error!(%slot, %validator_id, %err, "Failed to build block")); - - // `get_proposal_head` advances the store (interval-0 catch-up) inside - // `produce_block_with_signatures` *before* the build can fail, so emit - // the resulting head/checkpoint moves on both paths — a build failure - // must not strand a real finalization move outside every snapshot - // window. Ordered before the freshly built block's own import (which - // emits its `block` + head/checkpoint events). The catch-up advanced - // the store to `slot`'s interval 0, so the head-recency gate uses `slot`. + .inspect_err(|err| error!(%slot, %validator_id, %err, "Failed to assemble block")); + + // The catch-up runs before the assembly can fail, so emit the resulting + // head/checkpoint moves on both paths — a failure must not strand a + // real finalization move outside every snapshot window. Ordered before + // the freshly built block's own import (which emits its `block` + + // head/checkpoint events). The catch-up advanced the store to `slot`'s + // interval 0, so the head-recency gate uses `slot`. pre_build.diff_and_emit(&self.store, &self.events, slot); - let Ok((block, single_message_aggregates, _post_checkpoints)) = build_result else { + let Ok(chosen) = chosen else { metrics::inc_block_building_failures(); return; }; + let block = chosen.block; coverage::emit_proposal_coverage( &self.store, @@ -630,7 +714,9 @@ impl BlockChainServer { block.body.attestations.iter(), ); - // Sign the block root with the proposal key + // Sign the block root with the proposal key. Exactly once per slot: the + // XMSS key is one-time, which is why an adopted candidate's aggregate + // is verified before we get here rather than by trying the import. let block_root = block.hash_tree_root(); let Ok(proposer_signature) = self .key_manager @@ -641,100 +727,32 @@ impl BlockChainServer { return; }; - // Assemble SignedBlock: carry the proposer's raw XMSS signature as a - // standalone field, and aggregate the attestation single-message - // aggregates (only) into the block's attestation multi-message - // aggregate. The proposer no longer enters the aggregate, so a block - // with no attestations needs no prover work and the attestation - // multi-message aggregate can be built independently of the block root. - let head_state = self.store.head_state(); - let validators = &head_state.validators; - if validators.get(validator_id as usize).is_none() { - error!(%slot, %validator_id, "Proposer index out of range when assembling block"); - metrics::inc_block_building_failures(); - return; - } - - // `sign_block_root` already returns an `XmssSignature`, so the proposer - // signature is carried verbatim — no packing or prover work needed. - - // Aggregate the attestation single-message aggregates into a single - // multi-message aggregate. With no attestations the aggregate is empty: - // the proposer signature stands alone, mirroring `(prop-sig, - // empty-proof)`. - let attestation_proof = if single_message_aggregates.is_empty() { - MultiMessageAggregate::default() - } else { - let mut merge_inputs: Vec<(Vec, ByteList512KiB)> = - Vec::with_capacity(single_message_aggregates.len()); - let mut resolve_failed = false; - for sma in &single_message_aggregates { - let mut pubkeys = Vec::new(); - for vid in sma.participant_indices() { - let Some(validator) = validators.get(vid as usize) else { - error!(%slot, %validator_id, vid, "Participant out of range while resolving pubkeys"); - resolve_failed = true; - break; - }; - match ValidatorPublicKey::from_bytes(&validator.attestation_pubkey) { - Ok(pk) => pubkeys.push(pk), - Err(err) => { - error!(%slot, %validator_id, vid, %err, "Failed to decode attestation pubkey"); - resolve_failed = true; - break; - } - } - } - if resolve_failed { - break; - } - merge_inputs.push((pubkeys, sma.proof.clone())); - } - if resolve_failed { - metrics::inc_block_building_failures(); - return; - } - - // Merge yields raw lean-multisig type-2 bytes. Per-component - // participants are rederived at verify time from - // `block.body.attestations[i].aggregation_bits`, so nothing else - // needs persisting. - let merged_bytes = match ethlambda_crypto::merge_type_1s_into_type_2(merge_inputs) { - Ok(bytes) => bytes, - Err(err) => { - error!(%slot, %validator_id, %err, "Failed to merge Type-1s into Type-2"); - metrics::inc_block_building_failures(); - return; - } - }; - match MultiMessageAggregate::from_bytes(merged_bytes.iter().as_slice()) { - Ok(p) => p, - Err(err) => { - error!(%slot, %validator_id, %err, "Failed to build multi-message aggregate"); - metrics::inc_block_building_failures(); - return; - } - } - }; - // `single_message_aggregates` is no longer needed past this point. - drop(single_message_aggregates); + // The proposer signature is carried raw, outside the aggregate, so + // assembling the envelope needs no prover work — for an empty body, + // none at all. let signed_block = SignedBlock { message: block, - proof: BlockProof::new(proposer_signature, attestation_proof), + proof: BlockProof::new(proposer_signature, chosen.attestation_proof), }; - // Stop timing here: the build is done, and the alignment wait below must - // not count toward the block-building metric. + // Stop timing here: the assembly is done, and the alignment wait below + // must not count toward the block-building metric. drop(timing); - info!(%slot, %validator_id, "Finished building block"); + info!( + %slot, + %validator_id, + adopted_body_proof = chosen.adopted, + attestation_count = signed_block.message.body.attestations.len(), + "Finished assembling block" + ); let now_ms = unix_now_ms(); - // Align publication to the slot boundary. If the build finished before - // the slot opened, wait out the remainder so the block is not published - // early; if it overran, publish immediately. - if now_ms < genesis_time_ms + slot * crate::MILLISECONDS_PER_SLOT { + // Never publish ahead of the slot boundary. Assembly runs at the + // interval-0 tick, so this only bites when the tick fired early against + // a wall clock that has since drifted back. + if now_ms < slot_start_ms { let wait_ms = slot_start_ms.saturating_sub(now_ms); tokio::time::sleep(Duration::from_millis(wait_ms)).await; } @@ -791,9 +809,7 @@ impl BlockChainServer { } // Block import has no ready-made "now" slot like `on_tick`'s, so // compute the wall-clock slot fresh for the head-recency gate. - let genesis_time_ms = self.store.config().genesis_time * 1000; - let wall_clock_slot = unix_now_ms().saturating_sub(genesis_time_ms) / MILLISECONDS_PER_SLOT; - pre_import.diff_and_emit(&self.store, &self.events, wall_clock_slot); + pre_import.diff_and_emit(&self.store, &self.events, self.wall_clock_slot()); metrics::update_head_slot(self.store.head_slot()); let latest_justified_slot = self @@ -1196,6 +1212,7 @@ impl BlockChainServer { WorkerConfig { attestation_committee_count: self.attestation_committee_count, subscribed_subnets: self.subscribed_subnets.clone(), + proposer_config: self.proposer_config, }, )); } @@ -1215,7 +1232,7 @@ impl BlockChainServer { // --- Manual Handler impls for network-api messages --- use ethlambda_network_api::p2p_to_block_chain::{ - NewAggregatedAttestation, NewAttestation, NewBlock, + NewAggregatedAttestation, NewAttestation, NewBlock, NewBlockBodyProof, }; impl Handler for BlockChainServer { @@ -1264,6 +1281,51 @@ impl Handler for BlockChainServer { } } +impl Handler for BlockChainServer { + async fn handle(&mut self, msg: AssembleProposal, _ctx: &Context) { + self.assemble_proposal(msg.slot, msg.validator_id).await; + } +} + +impl Handler for BlockChainServer { + async fn handle(&mut self, msg: BodyProofProduced, _ctx: &Context) { + let attestation_count = msg.body_proof.block_body.attestations.len(); + info!( + slot = msg.slot, + attestation_count, + elapsed = ?msg.elapsed, + "Built a candidate body proof" + ); + + // Our own candidate counts as verified: we merged the aggregate, so the + // proposer path can skip the Type-2 check on it. Buffered separately + // from the publication copy, which the gossip call consumes. + self.body_proof_candidates + .push_local(msg.body_proof.clone()); + self.pending_body_proofs.push(msg.body_proof); + + // Publish as soon as it exists. The merge routinely runs seconds past + // the head-update interval it started in, so holding it for the next + // head-update tick would waste it entirely. + self.publish_pending_body_proofs(self.wall_clock_slot()); + } +} + +impl Handler for BlockChainServer { + async fn handle(&mut self, msg: NewBlockBodyProof, _ctx: &Context) { + let attestation_count = msg.body_proof.block_body.attestations.len(); + // Not verified here: a Type-2 check costs about as much as verifying a + // block, and only the slot's proposer ever needs the answer. It pays + // for the one candidate it decides to adopt. + self.body_proof_candidates.push_gossip(msg.body_proof); + trace!( + attestation_count, + candidates = self.body_proof_candidates.len(), + "Buffered a gossiped block body proof" + ); + } +} + impl Handler for BlockChainServer { async fn handle(&mut self, msg: NewAggregatedAttestation, _ctx: &Context) { let arrival_ms = unix_now_ms(); diff --git a/crates/blockchain/src/metrics.rs b/crates/blockchain/src/metrics.rs index e37f7c1b..3362ab43 100644 --- a/crates/blockchain/src/metrics.rs +++ b/crates/blockchain/src/metrics.rs @@ -387,6 +387,46 @@ static LEAN_AGGREGATED_PROOF_SIZE_BYTES: std::sync::LazyLock = .unwrap() }); +static LEAN_BLOCK_BODY_PROOF_BUILDING_TIME_SECONDS: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + register_histogram!( + "lean_block_body_proof_building_time_seconds", + "Time taken to build a candidate block body proof", + vec![0.1, 0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0] + ) + .unwrap() + }); + +static LEAN_BLOCK_BODY_PROOF_CANDIDATES: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + register_histogram!( + "lean_block_body_proof_candidates", + "Candidate block body proofs a proposer had to choose from", + vec![0.0, 1.0, 2.0, 3.0, 4.0, 6.0, 8.0] + ) + .unwrap() + }); + +static LEAN_BLOCK_BODY_SOURCE_TOTAL: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + register_int_counter_vec!( + "lean_block_body_source_total", + "Where a proposed block's body came from, by source", + &["source"] + ) + .unwrap() + }); + +static LEAN_BLOCK_BODY_PROOF_REJECTED_TOTAL: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + register_int_counter_vec!( + "lean_block_body_proof_rejected_total", + "Candidate block body proofs a proposer rejected, by reason", + &["reason"] + ) + .unwrap() + }); + static LEAN_COMMITTEE_SIGNATURES_AGGREGATION_TIME_SECONDS: std::sync::LazyLock = std::sync::LazyLock::new(|| { register_histogram!( @@ -748,6 +788,17 @@ const AGGREGATOR_SKIP_REASONS: &[&str] = &[ "other", ]; +/// Label values for `lean_block_body_source_total`: a proposer either adopted +/// a gossiped candidate body or fell back to an empty one. Both are seeded at +/// zero so a dashboard can read the ratio from the first block. +const BLOCK_BODY_SOURCES: &[&str] = &["body_proof", "empty"]; + +/// Label values for `lean_block_body_proof_rejected_total`: a candidate +/// carrying a vote this node cannot place on the chain the block would extend, +/// one whose attestations its own state transition rejected, and one whose +/// aggregate failed verification. +const BODY_PROOF_REJECT_REASONS: &[&str] = &["off_chain_vote", "state_transition", "verification"]; + static LEAN_AGGREGATOR_SKIPPED_TOTAL: std::sync::LazyLock = std::sync::LazyLock::new(|| { register_int_counter_vec!( @@ -858,6 +909,16 @@ pub fn init() { for &reason in AGGREGATOR_SKIP_REASONS { LEAN_AGGREGATOR_SKIPPED_TOTAL.with_label_values(&[reason]); } + // Block body proofs: both body sources and both reject reasons are seeded + // so a dashboard can read the adoption ratio from the first block. + std::sync::LazyLock::force(&LEAN_BLOCK_BODY_PROOF_BUILDING_TIME_SECONDS); + std::sync::LazyLock::force(&LEAN_BLOCK_BODY_PROOF_CANDIDATES); + for &source in BLOCK_BODY_SOURCES { + LEAN_BLOCK_BODY_SOURCE_TOTAL.with_label_values(&[source]); + } + for &reason in BODY_PROOF_REJECT_REASONS { + LEAN_BLOCK_BODY_PROOF_REJECTED_TOTAL.with_label_values(&[reason]); + } } // --- Public API --- @@ -1000,6 +1061,40 @@ pub fn observe_committee_signatures_aggregation(elapsed: std::time::Duration) { LEAN_COMMITTEE_SIGNATURES_AGGREGATION_TIME_SECONDS.observe(elapsed.as_secs_f64()); } +/// Observe how long the worker took to build a candidate body proof: the +/// merge of the body's attestation Type-1s into one Type-2. +pub fn observe_body_proof_building(elapsed: Duration) { + LEAN_BLOCK_BODY_PROOF_BUILDING_TIME_SECONDS.observe(elapsed.as_secs_f64()); +} + +/// Observe how many candidate body proofs a proposer had to choose from. +/// A zero reading means the block could only be empty. +pub fn observe_body_proof_candidates(count: usize) { + LEAN_BLOCK_BODY_PROOF_CANDIDATES.observe(count as f64); +} + +/// A proposed block's body came from a candidate body proof. +pub fn inc_block_body_from_proof() { + LEAN_BLOCK_BODY_SOURCE_TOTAL + .with_label_values(&["body_proof"]) + .inc(); +} + +/// A proposed block carried an empty body: no candidate was usable. +pub fn inc_block_body_empty() { + LEAN_BLOCK_BODY_SOURCE_TOTAL + .with_label_values(&["empty"]) + .inc(); +} + +/// A candidate body proof a proposer rejected, by reason (see +/// [`BODY_PROOF_REJECT_REASONS`]). +pub fn inc_body_proof_rejected(reason: &str) { + LEAN_BLOCK_BODY_PROOF_REJECTED_TOTAL + .with_label_values(&[reason]) + .inc(); +} + /// One vote-aggregation interval passed with this node holding no aggregation /// duty. Bookkeeping label that lets dashboards separate "no duty" from /// genuine misses. diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 930b4bd9..9a235720 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -20,6 +20,7 @@ use crate::{ GOSSIP_DISPARITY_INTERVALS, INTERVALS_PER_SLOT, MAX_ATTESTATIONS_DATA, MILLISECONDS_PER_INTERVAL, MILLISECONDS_PER_SLOT, SlotInterval, block_builder::{PostBlockCheckpoints, ProposerConfig, build_block}, + body_proof::{self, BodyProofBuffer, ChosenBody}, metrics, }; @@ -900,8 +901,47 @@ fn get_proposal_head(store: &mut Store, slot: u64) -> H256 { store.head().expect("store head exists") } +/// Produce the block for `slot` from the buffered candidate body proofs. +/// +/// The proposer's path: it packs no body of its own any more. Advancing the +/// store to `slot`'s interval 0 (which promotes the pending attestations) +/// still happens here, so the candidates are judged against the same state the +/// block will be built on, then [`body_proof::choose_body`] picks the body and +/// seals the block. +pub(crate) fn produce_block_from_candidates( + store: &mut Store, + slot: u64, + validator_index: u64, + candidates: &BodyProofBuffer, +) -> Result { + let head_root = get_proposal_head(store, slot); + let head_state = store + .get_state(&head_root) + .expect("head state exists") + .ok_or(StoreError::MissingParentState { + parent_root: head_root, + slot, + })?; + + let num_validators = head_state.validators.len() as u64; + if !is_proposer(validator_index, slot, num_validators) { + return Err(StoreError::NotProposer { + validator_index, + slot, + }); + } + + body_proof::choose_body(&head_state, slot, validator_index, head_root, candidates) +} + /// Produce a block and per-aggregated-attestation signature payloads for the target slot. /// +/// Packs a body straight from this node's own pool, which is what the +/// aggregation worker does for a candidate body proof (via +/// `body_proof::build_body_proof`) and what the offline block-building +/// benchmark measures. A live proposer instead adopts a candidate body through +/// [`produce_block_from_candidates`]. +/// /// Returns the finalized block and attestation signature payloads aligned /// with `block.body.attestations`. pub fn produce_block_with_signatures( diff --git a/crates/common/types/src/block.rs b/crates/common/types/src/block.rs index 7718ab1b..2025848c 100644 --- a/crates/common/types/src/block.rs +++ b/crates/common/types/src/block.rs @@ -164,6 +164,41 @@ impl Default for BlockProof { } } +// ============================================================================ +// Block body proof +// ============================================================================ + +/// A candidate block body together with the aggregate that binds its +/// attestations: everything a proposer needs for a block except the header and +/// its own signature. +/// +/// Only possible because the proposer signature sits outside the aggregate +/// (see [`BlockProof`]): the Type-2 over a body's attestations does not depend +/// on the block root, so it can be built by a node that is not the proposer, +/// before the block exists. Aggregators gossip these so the slot's proposer +/// can adopt one instead of running the merge itself. +/// +/// A proposer that adopts one carries `proof` verbatim as +/// [`BlockProof::attestation_proof`], so the pair is only meaningful together: +/// the proof binds exactly these attestations, in this order. +#[derive(Clone, SszEncode, SszDecode)] +pub struct BlockBodyProof { + /// The candidate body, carrying the attestations the proof binds. + pub block_body: BlockBody, + /// Type-2 aggregate over `block_body`'s attestations. + pub proof: MultiMessageAggregate, +} + +// Manual Debug impl because the proof bytes are large and opaque. +impl core::fmt::Debug for BlockBodyProof { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("BlockBodyProof") + .field("attestations", &self.block_body.attestations.len()) + .field("proof", &format_args!("<{} bytes>", self.proof.proof.len())) + .finish() + } +} + // ============================================================================ // Single-message aggregate // ============================================================================ diff --git a/crates/net/api/src/lib.rs b/crates/net/api/src/lib.rs index d6ec647d..cd2a3dfb 100644 --- a/crates/net/api/src/lib.rs +++ b/crates/net/api/src/lib.rs @@ -1,6 +1,6 @@ use ethlambda_types::{ attestation::{SignedAggregatedAttestation, SignedAttestation}, - block::SignedBlock, + block::{BlockBodyProof, SignedBlock}, primitives::H256, }; use spawned_concurrency::error::ActorError; @@ -17,6 +17,9 @@ pub trait BlockChainToP2P: Send + Sync { &self, attestation: SignedAggregatedAttestation, ) -> Result<(), ActorError>; + /// Gossip a candidate block body and the aggregate binding its + /// attestations, for the next slot's proposer to adopt. + fn publish_block_body_proof(&self, body_proof: BlockBodyProof) -> Result<(), ActorError>; fn fetch_block(&self, root: H256) -> Result<(), ActorError>; } @@ -43,6 +46,9 @@ pub trait P2PToBlockChain: Send + Sync { &self, attestation: SignedAggregatedAttestation, ) -> Result<(), ActorError>; + /// A candidate block body plus attestation aggregate seen on gossip. Kept + /// as a proposal candidate; see `BlockChainServer`'s body-proof buffer. + fn new_block_body_proof(&self, body_proof: BlockBodyProof) -> Result<(), ActorError>; } // --- Init messages --- diff --git a/crates/net/p2p/src/gossipsub/handler.rs b/crates/net/p2p/src/gossipsub/handler.rs index 1ba5cea3..9b28ff70 100644 --- a/crates/net/p2p/src/gossipsub/handler.rs +++ b/crates/net/p2p/src/gossipsub/handler.rs @@ -2,7 +2,7 @@ use ethlambda_network_api::BlockSource; use ethlambda_types::{ ShortRoot, attestation::{SignedAggregatedAttestation, SignedAttestation}, - block::SignedBlock, + block::{BlockBodyProof, SignedBlock}, primitives::HashTreeRoot as _, }; use libp2p::gossipsub::Event; @@ -12,8 +12,8 @@ use tracing::{error, info, trace}; use super::{ encoding::{compress_message, decompress_message}, messages::{ - AGGREGATION_TOPIC_KIND, ATTESTATION_SUBNET_TOPIC_PREFIX, BLOCK_TOPIC_KIND, - attestation_subnet_topic, + AGGREGATION_TOPIC_KIND, ATTESTATION_SUBNET_TOPIC_PREFIX, BLOCK_BODY_PROOF_TOPIC_KIND, + BLOCK_TOPIC_KIND, attestation_subnet_topic, }, }; use crate::{P2PServer, metrics}; @@ -96,6 +96,35 @@ pub async fn handle_gossipsub_message(server: &mut P2PServer, event: Event) { ); } } + Some(BLOCK_BODY_PROOF_TOPIC_KIND) => { + trace!( + kind = "block_body_proof", + peer_count, "P2P message received" + ); + let compressed_len = message.data.len(); + let Ok(uncompressed_data) = decompress_message(&message.data) + .inspect_err(|err| error!(%err, "Failed to decompress gossipped block body proof")) + else { + return; + }; + metrics::observe_gossip_block_body_proof_size(uncompressed_data.len(), compressed_len); + + let Ok(body_proof) = BlockBodyProof::from_ssz_bytes(&uncompressed_data) + .inspect_err(|err| error!(?err, "Failed to decode gossipped block body proof")) + else { + return; + }; + info!( + attestation_count = body_proof.block_body.attestations.len(), + proof_bytes = body_proof.proof.proof.len(), + "Received block body proof from gossip" + ); + if let Some(ref blockchain) = server.blockchain { + let _ = blockchain.new_block_body_proof(body_proof).inspect_err( + |err| error!(%err, "Failed to forward block body proof to blockchain"), + ); + } + } Some(kind) if kind.starts_with(ATTESTATION_SUBNET_TOPIC_PREFIX) => { trace!(kind = "attestation", peer_count, "P2P message received"); let compressed_len = message.data.len(); @@ -197,6 +226,28 @@ pub async fn publish_block(server: &mut P2PServer, signed_block: SignedBlock) { ); } +pub async fn publish_block_body_proof(server: &mut P2PServer, body_proof: BlockBodyProof) { + let attestation_count = body_proof.block_body.attestations.len(); + + // Encode to SSZ + let ssz_bytes = body_proof.to_ssz(); + + // Compress with raw snappy + let compressed = compress_message(&ssz_bytes); + + metrics::observe_gossip_block_body_proof_size(ssz_bytes.len(), compressed.len()); + + // Publish to the block-body-proof topic + server + .swarm_handle + .publish(server.block_body_proof_topic.clone(), compressed); + info!( + attestation_count, + proof_bytes = body_proof.proof.proof.len(), + "Published block body proof to gossipsub" + ); +} + pub async fn publish_aggregated_attestation( server: &mut P2PServer, attestation: SignedAggregatedAttestation, diff --git a/crates/net/p2p/src/gossipsub/messages.rs b/crates/net/p2p/src/gossipsub/messages.rs index 11664750..31dd8c7e 100644 --- a/crates/net/p2p/src/gossipsub/messages.rs +++ b/crates/net/p2p/src/gossipsub/messages.rs @@ -10,6 +10,10 @@ pub const ATTESTATION_SUBNET_TOPIC_PREFIX: &str = "attestation"; /// /// Full topic format: `/leanconsensus/{FORK_DIGEST}/aggregation/ssz_snappy` pub const AGGREGATION_TOPIC_KIND: &str = "aggregation"; +/// Topic kind for candidate block body + attestation aggregate gossip. +/// +/// Full topic format: `/leanconsensus/{FORK_DIGEST}/block_body_proof/ssz_snappy` +pub const BLOCK_BODY_PROOF_TOPIC_KIND: &str = "block_body_proof"; /// Build the block gossipsub topic. pub fn block_topic() -> libp2p::gossipsub::IdentTopic { @@ -25,6 +29,13 @@ pub fn aggregation_topic() -> libp2p::gossipsub::IdentTopic { )) } +/// Build the block-body-proof gossipsub topic. +pub fn block_body_proof_topic() -> libp2p::gossipsub::IdentTopic { + libp2p::gossipsub::IdentTopic::new(format!( + "/leanconsensus/{FORK_DIGEST}/{BLOCK_BODY_PROOF_TOPIC_KIND}/ssz_snappy" + )) +} + /// Build an attestation subnet gossipsub topic for the given subnet. pub fn attestation_subnet_topic(subnet_id: u64) -> libp2p::gossipsub::IdentTopic { libp2p::gossipsub::IdentTopic::new(format!( diff --git a/crates/net/p2p/src/gossipsub/mod.rs b/crates/net/p2p/src/gossipsub/mod.rs index b50ea4fd..a252ebc8 100644 --- a/crates/net/p2p/src/gossipsub/mod.rs +++ b/crates/net/p2p/src/gossipsub/mod.rs @@ -5,5 +5,8 @@ mod messages; pub use encoding::decompress_message; pub use handler::{ handle_gossipsub_message, publish_aggregated_attestation, publish_attestation, publish_block, + publish_block_body_proof, +}; +pub use messages::{ + aggregation_topic, attestation_subnet_topic, block_body_proof_topic, block_topic, }; -pub use messages::{aggregation_topic, attestation_subnet_topic, block_topic}; diff --git a/crates/net/p2p/src/lib.rs b/crates/net/p2p/src/lib.rs index dd74bc1e..f92b8ab1 100644 --- a/crates/net/p2p/src/lib.rs +++ b/crates/net/p2p/src/lib.rs @@ -9,6 +9,7 @@ use ethlambda_network_api::{ InitBlockChain, P2PToBlockChainRef, block_chain_to_p2p::{ FetchBlock, PublishAggregatedAttestation, PublishAttestation, PublishBlock, + PublishBlockBodyProof, }, }; use ethlambda_storage::Store; @@ -42,8 +43,9 @@ use crate::{ spawn_discovery, }, gossipsub::{ - aggregation_topic, attestation_subnet_topic, block_topic, publish_aggregated_attestation, - publish_attestation, publish_block, + aggregation_topic, attestation_subnet_topic, block_body_proof_topic, block_topic, + publish_aggregated_attestation, publish_attestation, publish_block, + publish_block_body_proof, }, req_resp::{ BLOCKS_BY_RANGE_PROTOCOL_V1, BLOCKS_BY_ROOT_PROTOCOL_V1, Codec, @@ -219,6 +221,7 @@ pub struct BuiltSwarm { pub(crate) attestation_committee_count: u64, pub(crate) block_topic: libp2p::gossipsub::IdentTopic, pub(crate) aggregation_topic: libp2p::gossipsub::IdentTopic, + pub(crate) block_body_proof_topic: libp2p::gossipsub::IdentTopic, pub(crate) bootnode_addrs: HashMap, } @@ -355,6 +358,15 @@ pub fn build_swarm( .subscribe(&aggregation_topic) .unwrap(); + // Subscribe to the block-body-proof topic (all nodes: any node may be the + // next slot's proposer, and a proposer adopts a body proof it received). + let block_body_proof_topic = block_body_proof_topic(); + swarm + .behaviour_mut() + .gossipsub + .subscribe(&block_body_proof_topic) + .unwrap(); + // The committee metric should reflect validator membership only, not // aggregator-only subscriptions. let metric_subnet = config @@ -382,6 +394,7 @@ pub fn build_swarm( attestation_committee_count: config.attestation_committee_count, block_topic, aggregation_topic, + block_body_proof_topic, bootnode_addrs, }) } @@ -431,6 +444,7 @@ impl P2P { attestation_committee_count: built.attestation_committee_count, block_topic: built.block_topic, aggregation_topic: built.aggregation_topic, + block_body_proof_topic: built.block_body_proof_topic, connected_peers: HashSet::new(), pending_root_requests: HashMap::new(), outbound_requests: HashMap::new(), @@ -475,6 +489,7 @@ pub struct P2PServer { pub(crate) attestation_committee_count: u64, pub(crate) block_topic: libp2p::gossipsub::IdentTopic, pub(crate) aggregation_topic: libp2p::gossipsub::IdentTopic, + pub(crate) block_body_proof_topic: libp2p::gossipsub::IdentTopic, pub(crate) connected_peers: HashSet, pub(crate) pending_root_requests: HashMap, @@ -594,6 +609,12 @@ impl Handler for P2PServer { } } +impl Handler for P2PServer { + async fn handle(&mut self, msg: PublishBlockBodyProof, _ctx: &Context) { + publish_block_body_proof(self, msg.body_proof).await; + } +} + impl Handler for P2PServer { async fn handle(&mut self, msg: FetchBlock, _ctx: &Context) { let root = msg.root; diff --git a/crates/net/p2p/src/metrics.rs b/crates/net/p2p/src/metrics.rs index 7159ffc7..82037df7 100644 --- a/crates/net/p2p/src/metrics.rs +++ b/crates/net/p2p/src/metrics.rs @@ -96,6 +96,25 @@ static LEAN_GOSSIP_AGGREGATION_SIZE_BYTES: LazyLock = LazyLock::ne .unwrap() }); +static LEAN_GOSSIP_BLOCK_BODY_PROOF_SIZE_BYTES: LazyLock = LazyLock::new(|| { + register_histogram_vec!( + "lean_gossip_block_body_proof_size_bytes", + "Bytes size of a gossip block body proof message", + &["compression"], + vec![ + 10_000.0, + 50_000.0, + 100_000.0, + 250_000.0, + 500_000.0, + 1_000_000.0, + 2_000_000.0, + 5_000_000.0 + ] + ) + .unwrap() +}); + /// Observe the size of a gossip block message, recording both the raw SSZ /// size and the snappy-compressed on-wire size. pub fn observe_gossip_block_size(raw: usize, snappy: usize) { @@ -118,6 +137,17 @@ pub fn observe_gossip_attestation_size(raw: usize, snappy: usize) { .observe(snappy as f64); } +/// Observe the size of a gossip block body proof message, recording both the +/// raw SSZ size and the snappy-compressed on-wire size. +pub fn observe_gossip_block_body_proof_size(raw: usize, snappy: usize) { + LEAN_GOSSIP_BLOCK_BODY_PROOF_SIZE_BYTES + .with_label_values(&["raw"]) + .observe(raw as f64); + LEAN_GOSSIP_BLOCK_BODY_PROOF_SIZE_BYTES + .with_label_values(&["snappy"]) + .observe(snappy as f64); +} + /// Observe the size of a gossip aggregated attestation message, recording both /// the raw SSZ size and the snappy-compressed on-wire size. pub fn observe_gossip_aggregation_size(raw: usize, snappy: usize) { diff --git a/docs/architecture.md b/docs/architecture.md index ed59e183..9b0eaa57 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -67,11 +67,11 @@ order. That walk and the actor split the duties: | Interval | In `store::on_tick` | In the actor | | --- | --- | --- | -| 0 | accept new attestations, if we propose this slot | nothing: the build ran at the previous interval 4 | +| 0 | accept new attestations, if we propose this slot | assemble and publish our block from the candidate body proofs on hand | | 1 | nothing | produce attestations | | 2 | nothing | publish the aggregates the worker produced | | 3 | update the safe target | nothing | -| 4 | accept accumulated attestations | build and publish the next slot's block | +| 4 | accept accumulated attestations | build and gossip the next slot's candidate body proof (on the worker) | See [Slots and Intervals](./slots_and_intervals.md) for what each duty means at the protocol level, and why the proposer builds one interval early. @@ -118,6 +118,27 @@ delay the aggregate the slot is waiting on. From interval 2 on, whatever is in h aggregated. Separately, the actor raises a pause flag around its own block build, since that competes for the same prover. +### Proposing from a gossiped body + +The heavy part of proposing was never picking attestations, it was merging their proofs +into the one aggregate a block body carries. That merge does not involve the block root — +the proposer's signature rides beside the aggregate in `BlockProof`, not inside it — so +whoever holds the proofs can do it, before the block exists. + +ethlambda splits proposing along that line. During the head-update interval each aggregator +packs a candidate body for the next slot, merges its proofs on the aggregation worker, and +gossips the pair as a `BlockBodyProof` (`crates/blockchain/src/body_proof.rs`). The next +slot's proposer keeps a bounded buffer of what arrives — its own worker's candidate +included — and at the slot boundary adopts the most valuable one that survives its checks: +votes it can place on the chain it is extending, attestations its own state transition +accepts, an aggregate that verifies. Nothing qualifying means an empty block, which is a +real option rather than a failure: an attestation-less block carries no aggregate and needs +no prover call. + +The proposer verifies before it signs, not after. XMSS signing keys are one-time, so a +proposer gets exactly one signature per slot and cannot discover a bad candidate by +importing the block and seeing whether it sticks. + Block import runs a second, smaller aggregation path. `reaggregate.rs` splits an imported block's merged proof back into per-attestation aggregates and folds them into the local pool, which is how a node that only saw a vote inside a block gets its fork-choice weight. diff --git a/docs/benchmarking.md b/docs/benchmarking.md index 924437b7..62e9e84d 100644 --- a/docs/benchmarking.md +++ b/docs/benchmarking.md @@ -1,7 +1,14 @@ # Benchmarking block building -`ethlambda benchmark` measures block building the way the node performs it when -it proposes, against a reproducible synthetic workload, with no devnet running. +`ethlambda benchmark` measures block building — packing a body out of the +attestation pool and sealing it — against a reproducible synthetic workload, +with no devnet running. + +That path is now the aggregation worker's, not the proposer's: since +[block body proofs](./slots_and_intervals.md#block-body-proofs), a proposer +adopts a candidate body someone else packed. What the benchmark measures is +unchanged, but read its numbers as the cost of producing a candidate body proof +rather than the cost of a proposal. Block building is otherwise only observable through the Prometheus histograms a live node exports. Those are noisy, depend on whatever the network happened to diff --git a/docs/data_storage.md b/docs/data_storage.md index 8b9a7568..522d86c6 100644 --- a/docs/data_storage.md +++ b/docs/data_storage.md @@ -120,7 +120,7 @@ The eight variants of the `Table` enum (`crates/storage/src/api/tables.rs`): | ----------------- | ----------- | ----------------------------------------- | -------------------------------- | | `BlockHeaders` | root | `BlockHeader` | never | | `BlockBodies` | root | `BlockBody` | never | -| `BlockProof` | slot ‖ root | aggregate proof (`MultiMessageAggregate`) | yes: finalized older than ~1 day | +| `BlockProof` | slot ‖ root | `BlockProof` (proposer signature + attestation aggregate) | yes: finalized older than ~1 day | | `BlockRoots` | slot | block root (`H256`) | never | | `States` | root | full `State` snapshot | never | | `StateDiffs` | root | `StateDiff` | never | @@ -160,8 +160,9 @@ anchors, whose bodies are either empty or unavailable. Never pruned. ### BlockProof -`slot ‖ root → MultiMessageAggregate`. This table stores the block's **merged -aggregate proof blob**. It is keyed by `slot ‖ root` so that pruning can scan in +`slot ‖ root → BlockProof`. This table stores the block's **proof pair**: the +proposer's raw XMSS signature over the block root, plus the aggregate over the +body's attestations. It is keyed by `slot ‖ root` so that pruning can scan in slot order and stop early. Stored separately from headers/bodies because the genesis block has no proof. diff --git a/docs/metrics.md b/docs/metrics.md index 2b3fdfe9..6ec9ee22 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -116,10 +116,29 @@ The metrics below are not part of the [leanMetrics specification](https://github |------|------|-------|-------------------------|--------|---------| | `lean_aggregated_proof_size_bytes` | Histogram | Bytes size of an aggregated signature proof's `proof_data` field | On aggregated signature production | | 1024, 4096, 16384, 65536, 131072, 262144, 524288, 1048576 | +### Block Body Proofs + +Candidate block bodies and the aggregate binding their attestations, gossiped by +aggregators during the head-update interval for the next slot's proposer to adopt. See +[Slots and Intervals](./slots_and_intervals.md#interval-4-head-update). + +| Name | Type | Usage | Sample collection event | Labels | Buckets | +|------|------|-------|-------------------------|--------|---------| +| `lean_block_body_proof_building_time_seconds` | Histogram | Time taken to build a candidate block body proof (the attestation Type-1 → Type-2 merge) | On body proof production | | 0.1, 0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4 | +| `lean_block_body_proof_candidates` | Histogram | Candidate body proofs a proposer had to choose from; `0` means the block could only be empty | On block production | | 0, 1, 2, 3, 4, 6, 8 | +| `lean_block_body_source_total` | Counter | Where a proposed block's body came from | On block production | source=body_proof,empty | | +| `lean_block_body_proof_rejected_total` | Counter | Candidate body proofs a proposer rejected | On block production | reason=off_chain_vote,state_transition,verification | | + +Since the proposer no longer packs a body, `lean_block_building_time_seconds` now measures +assembly only — choosing a candidate, verifying it, signing — and the merge it used to +include shows up in `lean_block_body_proof_building_time_seconds` on whichever node built +the candidate. + ### Network Sizes | Name | Type | Usage | Sample collection event | Labels | Buckets | |------|------|-------|-------------------------|--------|---------| +| `lean_gossip_block_body_proof_size_bytes` | Histogram | Bytes size of a gossip block body proof message (raw SSZ or snappy on-wire) | On gossip body proof send/receive | compression=raw,snappy | 10000, 50000, 100000, 250000, 500000, 1000000, 2000000, 5000000 | | `lean_gossip_block_size_bytes` | Histogram | Bytes size of a gossip block message (raw SSZ or snappy on-wire) | On gossip block send/receive | compression=raw,snappy | 10000, 50000, 100000, 250000, 500000, 1000000, 2000000, 5000000 | | `lean_gossip_attestation_size_bytes` | Histogram | Bytes size of a gossip attestation message (raw SSZ or snappy on-wire) | On gossip attestation send/receive | compression=raw,snappy | 512, 1024, 2048, 4096, 8192, 16384 | | `lean_gossip_aggregation_size_bytes` | Histogram | Bytes size of a gossip aggregated attestation message (raw SSZ or snappy on-wire) | On gossip aggregation send/receive | compression=raw,snappy | 1024, 4096, 16384, 65536, 131072, 262144, 524288, 1048576 | diff --git a/docs/slots_and_intervals.md b/docs/slots_and_intervals.md index bed15172..8c91ad83 100644 --- a/docs/slots_and_intervals.md +++ b/docs/slots_and_intervals.md @@ -9,7 +9,7 @@ Every duty a validator owes the chain is due in one of them: | 1 | t+800 ms | [Vote propagation](#interval-1-vote-propagation) | every validator | a signed attestation, on its subnet topic | | 2 | t+1600 ms | [Vote aggregation](#interval-2-vote-aggregation) | aggregators | an aggregated attestation, on the `aggregation` topic | | 3 | t+2400 ms | [Safe target computation](#interval-3-safe-target-computation) | every validator | nothing: local bookkeeping | -| 4 | t+3200 ms | [Head update](#interval-4-head-update) | every validator | nothing: local bookkeeping | +| 4 | t+3200 ms | [Head update](#interval-4-head-update) | every validator | in ethlambda: a candidate block body proof, on the `block_body_proof` topic | ```text ONE SLOT (4000 ms) @@ -20,7 +20,7 @@ Every duty a validator owes the chain is due in one of them: │ block │ vote │ vote │safe target │ head │ │ proposal │propagation │aggregation │computation │ update │ └────────────┴────────────┴────────────┴────────────┴────────────┘ - ◄───────────── gossiped ─────────────▶ ◄───── local only ───────▶ + ◄───────────── gossiped ─────────────▶ ◄─ local ─▶ ◄─ gossiped ─▶ ``` The grid comes from a genesis timestamp every node shares, so the schedule needs no @@ -52,11 +52,13 @@ Genesis occupies slot 0, so proposals start at slot 1, and nothing forces a slot filled: a proposer that is offline or too slow leaves an empty slot, and the next block simply points its parent root at an older block. -> **In ethlambda:** block proposal is merged into the previous slot's head-update -> interval: the proposer advances its store to the next slot, builds the block there, -> and holds publication until the slot boundary. That buys the build one extra interval -> of headroom and leaves no actor work at the block-proposal tick itself. The aggregation -> worker is paused for the duration, so the build does not share the prover with it. +> **In ethlambda:** the proposer packs no body of its own. It adopts the most valuable +> of the candidate [block body proofs](#block-body-proofs) gossiped during the previous +> interval — validated against its own state, and only if it is worth more than an empty +> body — or else signs an empty block, which costs no prover work at all. What is left of +> proposing is a state transition, one aggregate verification and one signature, so it +> runs at this tick rather than being prebuilt an interval early. The aggregation worker +> is paused for the duration, so the assembly does not share the prover with it. ## Interval 1: Vote propagation @@ -119,3 +121,27 @@ what keeps a validator's fork-choice view from shifting under it mid-slot. The s is the exception: it reads the unpromoted buffer directly, which is how it stays a view of this slot alone. See [why staged promotion](./lmd_ghost.md#why-staged-promotion) for the reasoning. + +> **In ethlambda:** the promote is followed by a second duty for aggregators, described +> below: pack a candidate body for the next slot and gossip it as a block body proof. + +## Block body proofs + +An ethlambda addition, not in leanSpec. The costly part of proposing is not picking +attestations, it is merging their proofs into the single aggregate a block body carries. +That merge does not depend on the block root — the proposer's signature is carried beside +the aggregate, not inside it — so it need not be done by the proposer, and need not wait +for the slot to open. + +So the promote at interval 4 is followed by a second duty for aggregators: pack a candidate +body for the next slot out of the pool as it now stands, merge its proofs, and gossip the +pair as a `BlockBodyProof` on its own topic. The next slot's proposer collects whatever +arrives and, at the slot boundary, adopts the most valuable candidate that survives its own +checks. + +The proposer keeps the last word. A candidate is dropped if any of its votes does not sit +on the chain the block extends, if its attestations do not survive the state transition, or +if its aggregate fails verification; and it is adopted only if it justifies more, finalizes +more, or adds voters the state does not already have. Otherwise the block +goes out empty, which is cheap enough to be a real option: an attestation-less block +carries no aggregate and needs no prover call. diff --git a/docs/spec_deviations.md b/docs/spec_deviations.md index 48907f61..369e7611 100644 --- a/docs/spec_deviations.md +++ b/docs/spec_deviations.md @@ -18,6 +18,27 @@ grid. - **leanSpec:** `aggregate()` is called inline and synchronously from `tick_interval`, at interval 2 only. It walks every attestation data with fresh evidence, with no worker, no gate, and no separation between producing an aggregate and publishing it. - **Equivalence:** the worker produces the same aggregates over a slot, at different times; what a block may carry is unchanged. Where the two can differ is count: a slot whose proving overruns publishes fewer aggregates than the synchronous path would, which affects how many votes are included rather than signature validity. +## Proposer signature outside the block proof + +The block proof is a pair — the proposer's raw signature and the attestation aggregate — +rather than one merged proof over both. + +- **ethlambda:** `SignedBlock.proof` is a `BlockProof { proposer_signature, attestation_proof }` (`crates/common/types/src/block.rs`). `proposer_signature` is the raw XMSS signature over the block root, verified directly against the proposer's `proposal_pubkey` with the hash-based verifier; `attestation_proof` is the lean-multisig Type-2 over the body's attestations only, and is empty when the block carries none (`verify_block_signatures`, `crates/blockchain/src/store.rs`). +- **leanSpec:** the proposer signature is wrapped as a singleton Type-1 and merged into a single block Type-2 alongside every attestation. +- **Why:** the merged form makes the proposer signature the reason a block needs a prover at all — even an attestation-less one — and it ties the merge to the block root, so nothing can be merged before the block exists. Splitting removes prover work from the empty case entirely and is what makes a gossiped block body proof possible. +- **Consequence:** this is a wire-format divergence. The signature and SSZ fixtures no longer apply, and a node running this cannot interop with one that does not. + +## Block body proofs, and a proposer that packs no body + +Candidate bodies are built by aggregators and gossiped; the proposer adopts one instead of +packing its own. + +- **ethlambda:** during the head-update interval the aggregation worker packs a candidate body for the next slot and merges its attestation proofs into one Type-2, and the actor gossips the pair as a `BlockBodyProof` on `/leanconsensus/{fork_digest}/block_body_proof/ssz_snappy` (`crates/blockchain/src/body_proof.rs`). At the slot boundary the proposer scores the candidates it has collected and adopts the most valuable one — or signs an empty block if none beats one (`choose_body`). +- **leanSpec:** the proposer selects attestations from its own pool, merges their proofs itself, and does both inside its proposal slot. +- **Why:** the merge is the one part of proposing that costs seconds, and it does not need the proposer, the block root, or the slot. Moving it to the aggregators that already hold the proofs takes it off the critical path; the proposer is left with a state transition, a verification and a signature. +- **Safety:** the proposer keeps the last word. A candidate is dropped if any of its votes does not sit on the chain the block extends (`attestation_data_matches_chain` — the state transition does not check those roots), if its attestations do not survive that transition, or if its aggregate fails verification; the state root is computed from the transition rather than trusted. Verification happens before signing: XMSS keys are one-time, so a proposer cannot try a candidate, fail the import, and try another. The screen deliberately stops there: a body is all-or-nothing, so dropping one for a merely stale entry would cost the whole block, and staleness is already discounted by the adoption score. +- **Consequence:** a proposer whose candidates all fail, or that received none, proposes an empty block instead of packing what its own pool holds. On a chain with no aggregator gossiping body proofs, every block is empty. + ## Attestation scoring on block building Attestations are scored and selected when packing a block, rather than taken in