From e076ba2ceb96e0631f333c33b4c052bf9e4bdac7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:21:55 -0300 Subject: [PATCH 1/5] feat(blockchain): gate proposer attestation aggregation behind a flag build_block unconditionally compacted attestations sharing the same AttestationData by recursively aggregating their Type-1 multi-signatures (leanSpec #510). That per-data leanVM aggregation dominates the build cost for blocks carrying attestations, yet it is not required for a valid block: the state transition unions votes by target root and the attestation-to-proof correspondence stays 1:1 whether or not duplicates are merged. Add --enable-proposer-aggregation (default off) to make this compaction opt-in. When disabled, the proposer ships the selected proofs unmerged: the block carries more attestation entries but skips the aggregation work. The flag threads CliOptions -> BlockChain::spawn -> the actor -> produce_block_with_signatures -> build_block. --- bin/ethlambda/src/cli.rs | 13 +++ bin/ethlambda/src/main.rs | 1 + crates/blockchain/src/block_builder.rs | 152 ++++++++++++++++++++++++- crates/blockchain/src/lib.rs | 20 +++- crates/blockchain/src/store.rs | 2 + 5 files changed, 180 insertions(+), 8 deletions(-) diff --git a/bin/ethlambda/src/cli.rs b/bin/ethlambda/src/cli.rs index 848a2446..d2ed746c 100644 --- a/bin/ethlambda/src/cli.rs +++ b/bin/ethlambda/src/cli.rs @@ -84,4 +84,17 @@ pub(crate) struct CliOptions { /// but it no longer suppresses any duty: the gate becomes observe-only. #[arg(long, default_value = "false")] pub(crate) disable_duty_sync_gate: bool, + /// Enable proposer-side aggregation of attestation proofs when building a + /// block. + /// + /// When set, `build_block` compacts attestations sharing the same + /// `AttestationData` by recursively aggregating their Type-1 + /// multi-signatures into a single proof per data entry (leanSpec #510). + /// This shrinks the block but costs a leanVM aggregation per duplicated + /// data entry. When unset (the default), duplicate-data entries are left + /// unmerged: the block carries more attestation entries but skips the + /// per-data aggregation work. Either form is valid; the state transition + /// unions votes by target root regardless. + #[arg(long, default_value = "false")] + pub(crate) enable_proposer_aggregation: bool, } diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index d85b3e8d..591cdcfd 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -215,6 +215,7 @@ async fn main() -> eyre::Result<()> { aggregator.clone(), attestation_committee_count, !options.disable_duty_sync_gate, + options.enable_proposer_aggregation, ); // Note: SwarmConfig.is_aggregator is intentionally a plain bool, not the diff --git a/crates/blockchain/src/block_builder.rs b/crates/blockchain/src/block_builder.rs index 722d98d7..421ac74c 100644 --- a/crates/blockchain/src/block_builder.rs +++ b/crates/blockchain/src/block_builder.rs @@ -45,9 +45,18 @@ pub struct PostBlockCheckpoints { /// Build a valid block on top of this state. /// -/// Selects attestations via `select_attestations`, compacts duplicate -/// `AttestationData` entries, and runs the STF once to seal the state root. -/// The proposer signature is NOT included; it is appended by the caller. +/// Selects attestations via `select_attestations`, optionally compacts +/// duplicate `AttestationData` entries (see `enable_proposer_aggregation`), +/// and runs the STF once to seal the state root. The proposer signature is NOT +/// included; it is appended by the caller. +/// +/// When `enable_proposer_aggregation` is set, entries sharing the same +/// `AttestationData` are merged into one via recursive Type-1 aggregation +/// (leanSpec #510), shrinking the block at the cost of a leanVM aggregation +/// per duplicated data entry. When unset, duplicate-data entries are left +/// as-is: the block carries more attestation entries but skips that work. +/// Both forms are valid; the state transition unions votes by target root and +/// the attestation-to-proof correspondence stays 1:1 either way. pub(crate) fn build_block( head_state: &State, slot: u64, @@ -55,6 +64,7 @@ pub(crate) fn build_block( parent_root: H256, known_block_roots: &HashSet, aggregated_payloads: &HashMap)>, + enable_proposer_aggregation: bool, ) -> Result<(Block, Vec, PostBlockCheckpoints), StoreError> { info!(slot, proposer_index, "Building block"); @@ -72,8 +82,14 @@ pub(crate) fn build_block( // Compact: merge proofs sharing the same AttestationData via recursive // aggregation so each AttestationData appears at most once (leanSpec #510). + // Gated by `enable_proposer_aggregation`: when disabled, the selected + // entries are used unmerged (each carries its own Type-1 proof). let compact_start = Instant::now(); - let compacted = compact_attestations(selected, head_state)?; + let compacted = if enable_proposer_aggregation { + compact_attestations(selected, head_state)? + } else { + selected + }; metrics::observe_block_proposal_phase("compact", compact_start.elapsed()); let (aggregated_attestations, aggregated_signatures): (Vec<_>, Vec<_>) = @@ -905,6 +921,7 @@ mod tests { parent_root, &known_block_roots, &aggregated_payloads, + true, ) .expect("build_block should succeed"); @@ -941,6 +958,131 @@ mod tests { ); } + /// With proposer aggregation disabled, `build_block` must leave entries + /// sharing the same `AttestationData` unmerged: each selected proof stays + /// its own attestation entry with its own Type-1 signature (1:1), rather + /// than being compacted into a single recursively-aggregated proof. This + /// path performs no leanVM aggregation, so empty proof blobs suffice. + #[test] + fn build_block_without_proposer_aggregation_keeps_duplicate_data_unmerged() { + use ethlambda_types::{ + block::BlockHeader, + state::{ChainConfig, JustificationValidators, JustifiedSlots}, + }; + use libssz_types::SszList; + + const NUM_VALIDATORS: usize = 4; + const HEAD_SLOT: u64 = 11; + const TARGET_SLOT: u64 = 5; + + let validators: Vec<_> = (0..NUM_VALIDATORS) + .map(|i| ethlambda_types::state::Validator { + attestation_pubkey: [i as u8; 52], + proposal_pubkey: [i as u8; 52], + index: i as u64, + }) + .collect(); + + let hashes: Vec = (0..HEAD_SLOT).map(|i| H256([(i + 1) as u8; 32])).collect(); + let historical_block_hashes = SszList::try_from(hashes.clone()).unwrap(); + + let head_header = BlockHeader { + slot: HEAD_SLOT, + proposer_index: 0, + parent_root: H256::ZERO, + state_root: H256::ZERO, + body_root: BlockBody::default().hash_tree_root(), + }; + + let head_state = State { + config: ChainConfig { genesis_time: 1000 }, + slot: HEAD_SLOT, + latest_block_header: head_header, + latest_justified: Checkpoint::default(), + latest_finalized: Checkpoint::default(), + historical_block_hashes, + justified_slots: JustifiedSlots::new(), + validators: SszList::try_from(validators).unwrap(), + justifications_roots: Default::default(), + justifications_validators: JustificationValidators::new(), + }; + + let mut header_for_root = head_state.latest_block_header.clone(); + header_for_root.state_root = head_state.hash_tree_root(); + let parent_root = header_for_root.hash_tree_root(); + + let slot = HEAD_SLOT + 1; + let proposer_index = slot % NUM_VALIDATORS as u64; + + let source = Checkpoint { + root: hashes[0], + slot: 0, + }; + let target = Checkpoint { + root: hashes[TARGET_SLOT as usize], + slot: TARGET_SLOT, + }; + let head = Checkpoint { + root: hashes[0], + slot: 0, + }; + + let mut known_block_roots = HashSet::new(); + known_block_roots.insert(parent_root); + known_block_roots.insert(hashes[0]); + + // A single AttestationData carrying two disjoint-coverage proofs (one + // for validator 0, one for validator 1). Both are selected by + // `extend_proofs_greedily` since each adds a new voter. + let att_data = AttestationData { + slot: TARGET_SLOT, + head, + target, + source, + }; + let data_root = att_data.hash_tree_root(); + let proofs = vec![ + TypeOneMultiSignature::empty(make_bits(&[0])), + TypeOneMultiSignature::empty(make_bits(&[1])), + ]; + + let mut aggregated_payloads: HashMap)> = + HashMap::new(); + aggregated_payloads.insert(data_root, (att_data.clone(), proofs)); + + let (block, signatures, _post_checkpoints) = build_block( + &head_state, + slot, + proposer_index, + parent_root, + &known_block_roots, + &aggregated_payloads, + false, + ) + .expect("build_block should succeed"); + + // Both proofs survive as separate entries (no compaction merge), and + // the attestation-to-signature correspondence stays 1:1. + assert_eq!( + block.body.attestations.len(), + 2, + "duplicate-data entries must not be merged when aggregation is disabled" + ); + assert_eq!( + signatures.len(), + 2, + "one Type-1 proof per attestation entry" + ); + assert!( + block + .body + .attestations + .iter() + .all(|att| att.data == att_data), + "both entries should carry the shared AttestationData" + ); + } + /// Regression test for leanSpec PR #716: build_block must absorb /// gap-closing attestations whose source is justified on the head /// chain but older than `latest_justified` (e.g., a sibling fork @@ -1048,6 +1190,7 @@ mod tests { parent_root, &known_block_roots, &aggregated_payloads, + true, ) .expect("build_block should succeed"); @@ -1180,6 +1323,7 @@ mod tests { parent_root, &known_block_roots, &aggregated_payloads, + true, ) .expect("build_block should succeed"); diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index 0fceede2..af58cf55 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -82,6 +82,7 @@ impl BlockChain { aggregator: AggregatorController, attestation_committee_count: u64, gate_duties: bool, + enable_proposer_aggregation: bool, ) -> BlockChain { metrics::set_is_aggregator(aggregator.is_enabled()); metrics::set_node_sync_status(metrics::SyncStatus::Idle); @@ -106,6 +107,7 @@ impl BlockChain { current_aggregation: None, last_tick_instant: None, attestation_committee_count, + enable_proposer_aggregation, pre_merge_coverage: None, sync_status: SyncStatusTracker::new(gate_duties), } @@ -166,6 +168,13 @@ pub struct BlockChainServer { /// attestation aggregate coverage emission. attestation_committee_count: u64, + /// Whether to compact same-data attestations during block building by + /// recursively aggregating their Type-1 proofs (leanSpec #510). Seeded + /// from the CLI `--enable-proposer-aggregation` flag at spawn. When false + /// (the default) the proposer leaves duplicate-data entries unmerged, + /// skipping the per-data leanVM aggregation. + enable_proposer_aggregation: bool, + /// Pre-merge `new_payloads` snapshot for the attestation aggregate coverage /// report. Captured at the end-of-slot promote (interval 4), read at the /// next slot boundary. Owned solely by the actor and only touched from the @@ -493,10 +502,13 @@ impl BlockChainServer { // by the idempotency guard in `on_tick`, since the store clock is already // here. let timing = metrics::time_block_building(); - let Ok((block, type_one_proofs, _post_checkpoints)) = - store::produce_block_with_signatures(&mut self.store, slot, validator_id) - .inspect_err(|err| error!(%slot, %validator_id, %err, "Failed to build block")) - else { + let Ok((block, type_one_proofs, _post_checkpoints)) = store::produce_block_with_signatures( + &mut self.store, + slot, + validator_id, + self.enable_proposer_aggregation, + ) + .inspect_err(|err| error!(%slot, %validator_id, %err, "Failed to build block")) else { metrics::inc_block_building_failures(); return; }; diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 65bec555..a290becd 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -789,6 +789,7 @@ pub fn produce_block_with_signatures( store: &mut Store, slot: u64, validator_index: u64, + enable_proposer_aggregation: bool, ) -> Result<(Block, Vec, PostBlockCheckpoints), StoreError> { // Get parent block and state to build upon let head_root = get_proposal_head(store, slot); @@ -822,6 +823,7 @@ pub fn produce_block_with_signatures( head_root, &known_block_roots, &aggregated_payloads, + enable_proposer_aggregation, )? }; From d23f2a69c3f93c474be99a2e8cf3a273969578af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:33:01 -0300 Subject: [PATCH 2/5] fix(blockchain): keep best proof per data when aggregation is off The first cut left same-data entries unmerged, but a block may carry at most one entry per AttestationData: on_block rejects duplicates with StoreError::DuplicateAttestationData before signature verification, so such blocks would be invalid (and rejected by every other client too). When proposer aggregation is disabled, collapse same-data proofs by keeping the single best-coverage proof (max participant count) and dropping the rest, rather than merging them via leanVM. This preserves the one-entry-per-data invariant and the 1:1 attestation/proof correspondence while still skipping the aggregation work; the cost is lower per-entry coverage. --- bin/ethlambda/src/cli.rs | 16 +-- crates/blockchain/src/block_builder.rs | 139 ++++++++++++++++++------- crates/blockchain/src/lib.rs | 12 ++- 3 files changed, 116 insertions(+), 51 deletions(-) diff --git a/bin/ethlambda/src/cli.rs b/bin/ethlambda/src/cli.rs index d2ed746c..f4f97cab 100644 --- a/bin/ethlambda/src/cli.rs +++ b/bin/ethlambda/src/cli.rs @@ -87,14 +87,14 @@ pub(crate) struct CliOptions { /// Enable proposer-side aggregation of attestation proofs when building a /// block. /// - /// When set, `build_block` compacts attestations sharing the same - /// `AttestationData` by recursively aggregating their Type-1 - /// multi-signatures into a single proof per data entry (leanSpec #510). - /// This shrinks the block but costs a leanVM aggregation per duplicated - /// data entry. When unset (the default), duplicate-data entries are left - /// unmerged: the block carries more attestation entries but skips the - /// per-data aggregation work. Either form is valid; the state transition - /// unions votes by target root regardless. + /// A block may carry at most one entry per `AttestationData`, so the + /// proposer must collapse same-data proofs either way. When set, + /// `build_block` merges them via recursive Type-1 aggregation into a single + /// union-coverage proof per data (leanSpec #510), maximizing voter coverage + /// at the cost of a leanVM aggregation per duplicated data entry. When unset + /// (the default), it instead keeps only the single best-coverage proof per + /// data and drops the rest, skipping the leanVM work at the cost of lower + /// coverage. #[arg(long, default_value = "false")] pub(crate) enable_proposer_aggregation: bool, } diff --git a/crates/blockchain/src/block_builder.rs b/crates/blockchain/src/block_builder.rs index 421ac74c..6a219bc8 100644 --- a/crates/blockchain/src/block_builder.rs +++ b/crates/blockchain/src/block_builder.rs @@ -45,18 +45,23 @@ pub struct PostBlockCheckpoints { /// Build a valid block on top of this state. /// -/// Selects attestations via `select_attestations`, optionally compacts -/// duplicate `AttestationData` entries (see `enable_proposer_aggregation`), -/// and runs the STF once to seal the state root. The proposer signature is NOT -/// included; it is appended by the caller. +/// 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. /// -/// When `enable_proposer_aggregation` is set, entries sharing the same -/// `AttestationData` are merged into one via recursive Type-1 aggregation -/// (leanSpec #510), shrinking the block at the cost of a leanVM aggregation -/// per duplicated data entry. When unset, duplicate-data entries are left -/// as-is: the block carries more attestation entries but skips that work. -/// Both forms are valid; the state transition unions votes by target root and -/// the attestation-to-proof correspondence stays 1:1 either way. +/// The collapse strategy is gated by `enable_proposer_aggregation`: +/// - **enabled**: same-data proofs are merged via recursive Type-1 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 build_block( head_state: &State, slot: u64, @@ -80,15 +85,17 @@ pub(crate) fn build_block( let child_payloads_consumed = selected.len(); - // Compact: merge proofs sharing the same AttestationData via recursive - // aggregation so each AttestationData appears at most once (leanSpec #510). - // Gated by `enable_proposer_aggregation`: when disabled, the selected - // entries are used unmerged (each carries its own Type-1 proof). + // 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 Type-1 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. let compact_start = Instant::now(); let compacted = if enable_proposer_aggregation { compact_attestations(selected, head_state)? } else { - selected + keep_best_proof_per_data(selected) }; metrics::observe_block_proposal_phase("compact", compact_start.elapsed()); @@ -619,6 +626,58 @@ fn compact_attestations( Ok(compacted) } +/// Reduce same-data entries to a single best proof each, without aggregation. +/// +/// The block format permits at most one entry per `AttestationData`: `on_block` +/// rejects duplicates (`StoreError::DuplicateAttestationData`). When proposer +/// aggregation is disabled we therefore cannot keep every selected proof, nor +/// can we merge them. For each group sharing an `AttestationData` we keep the +/// single proof covering the most validators (ties broken by first occurrence) +/// and drop the rest. No leanVM aggregation runs; coverage is whatever the best +/// individual proof already had, which is the cost of skipping aggregation. +fn keep_best_proof_per_data( + entries: Vec<(AggregatedAttestation, TypeOneMultiSignature)>, +) -> Vec<(AggregatedAttestation, TypeOneMultiSignature)> { + if entries.len() <= 1 { + return entries; + } + + // Preserve first-occurrence order of distinct AttestationData; for each, + // track the index of the best (most participants) entry seen so far. + let mut order: Vec = Vec::new(); + let mut best_index: HashMap = HashMap::new(); + for (i, (att, _)) in entries.iter().enumerate() { + match best_index.entry(att.data.clone()) { + std::collections::hash_map::Entry::Vacant(e) => { + order.push(e.key().clone()); + e.insert(i); + } + std::collections::hash_map::Entry::Occupied(mut e) => { + let current_best = entries[*e.get()].0.aggregation_bits.count_ones(); + if att.aggregation_bits.count_ones() > current_best { + e.insert(i); + } + } + } + } + + // Fast path: every AttestationData already appeared exactly once. + if order.len() == entries.len() { + return entries; + } + + let mut items: Vec> = + entries.into_iter().map(Some).collect(); + order + .iter() + .map(|data| { + items[best_index[data]] + .take() + .expect("best index taken once") + }) + .collect() +} + /// Greedily select proofs maximizing new validator coverage. /// /// For a single attestation data entry, picks proofs that cover the most @@ -958,13 +1017,13 @@ mod tests { ); } - /// With proposer aggregation disabled, `build_block` must leave entries - /// sharing the same `AttestationData` unmerged: each selected proof stays - /// its own attestation entry with its own Type-1 signature (1:1), rather - /// than being compacted into a single recursively-aggregated proof. This - /// path performs no leanVM aggregation, so empty proof blobs suffice. + /// With proposer aggregation disabled, `build_block` must still emit at + /// most one entry per `AttestationData` (`on_block` rejects duplicates), + /// keeping the single best-coverage proof and dropping the rest rather than + /// recursively aggregating them. This path performs no leanVM aggregation, + /// so empty proof blobs suffice. #[test] - fn build_block_without_proposer_aggregation_keeps_duplicate_data_unmerged() { + fn build_block_without_proposer_aggregation_keeps_single_best_proof_per_data() { use ethlambda_types::{ block::BlockHeader, state::{ChainConfig, JustificationValidators, JustifiedSlots}, @@ -1031,9 +1090,10 @@ mod tests { known_block_roots.insert(parent_root); known_block_roots.insert(hashes[0]); - // A single AttestationData carrying two disjoint-coverage proofs (one - // for validator 0, one for validator 1). Both are selected by - // `extend_proofs_greedily` since each adds a new voter. + // A single AttestationData carrying two proofs of different coverage: + // one for validator 0 (count 1) and one for validators {1, 2} + // (count 2). Both are selected by `extend_proofs_greedily` since each + // adds new voters, so the disabled path must reduce them to one. let att_data = AttestationData { slot: TARGET_SLOT, head, @@ -1043,7 +1103,7 @@ mod tests { let data_root = att_data.hash_tree_root(); let proofs = vec![ TypeOneMultiSignature::empty(make_bits(&[0])), - TypeOneMultiSignature::empty(make_bits(&[1])), + TypeOneMultiSignature::empty(make_bits(&[1, 2])), ]; let mut aggregated_payloads: HashMap)> = @@ -1061,25 +1121,28 @@ mod tests { ) .expect("build_block should succeed"); - // Both proofs survive as separate entries (no compaction merge), and - // the attestation-to-signature correspondence stays 1:1. + // Exactly one entry per AttestationData survives (no duplicate would + // pass `on_block`), the correspondence stays 1:1, and the entry kept is + // the higher-coverage proof ({1, 2}, two participants). assert_eq!( block.body.attestations.len(), - 2, - "duplicate-data entries must not be merged when aggregation is disabled" + 1, + "a block must carry at most one entry per AttestationData" ); assert_eq!( signatures.len(), - 2, + 1, "one Type-1 proof per attestation entry" ); - assert!( - block - .body - .attestations - .iter() - .all(|att| att.data == att_data), - "both entries should carry the shared AttestationData" + let kept = &block.body.attestations[0]; + assert_eq!( + kept.data, att_data, + "the kept entry carries the shared data" + ); + assert_eq!( + kept.aggregation_bits.count_ones(), + 2, + "the best-coverage proof ({{1, 2}}) is kept over the smaller one ({{0}})" ); } diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index af58cf55..c3ee9604 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -168,11 +168,13 @@ pub struct BlockChainServer { /// attestation aggregate coverage emission. attestation_committee_count: u64, - /// Whether to compact same-data attestations during block building by - /// recursively aggregating their Type-1 proofs (leanSpec #510). Seeded - /// from the CLI `--enable-proposer-aggregation` flag at spawn. When false - /// (the default) the proposer leaves duplicate-data entries unmerged, - /// skipping the per-data leanVM aggregation. + /// How the proposer collapses same-data attestations during block building + /// (a block may carry at most one entry per `AttestationData`). When true, + /// same-data proofs are merged via recursive Type-1 aggregation into a + /// union-coverage proof (leanSpec #510); when false (the default), only the + /// single best-coverage proof per data is kept, skipping the per-data + /// leanVM aggregation. Seeded from the CLI `--enable-proposer-aggregation` + /// flag at spawn. enable_proposer_aggregation: bool, /// Pre-merge `new_payloads` snapshot for the attestation aggregate coverage From 421054d8f44e4a0e3b475df55a5ada4bff36340c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:47:48 -0300 Subject: [PATCH 3/5] feat(blockchain): log attestation compaction start/end and skip Bracket the compaction phase in build_block with info logs so the two paths are observable when A/B-ing the flag. The enabled path logs a start line (entries + duplicates to compact) and an end line (resulting entry count); the disabled path logs a single line noting compaction is skipped, with the same entries + duplicates counts. --- crates/blockchain/src/block_builder.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/blockchain/src/block_builder.rs b/crates/blockchain/src/block_builder.rs index 6a219bc8..d5fe09bd 100644 --- a/crates/blockchain/src/block_builder.rs +++ b/crates/blockchain/src/block_builder.rs @@ -91,10 +91,26 @@ pub(crate) fn build_block( // AttestationData are merged via recursive Type-1 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. + let entries = selected.len(); + let distinct_data = selected + .iter() + .map(|(att, _)| &att.data) + .collect::>() + .len(); + let duplicates = entries - distinct_data; + let compact_start = Instant::now(); let compacted = if enable_proposer_aggregation { - compact_attestations(selected, head_state)? + info!(slot, entries, duplicates, "Compacting attestations"); + let compacted = compact_attestations(selected, head_state)?; + info!( + slot, + entries = compacted.len(), + "Finished compacting attestations" + ); + compacted } else { + info!(slot, entries, duplicates, "Skipping attestation compaction"); keep_best_proof_per_data(selected) }; metrics::observe_block_proposal_phase("compact", compact_start.elapsed()); From 629e3acd973764e42ae37fcabd01063a59d1e2e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:12:01 -0300 Subject: [PATCH 4/5] refactor(blockchain): log compaction counts from within the collapse fns Move the compaction logging into compact_attestations and keep_best_proof_per_data so each reuses the distinct-AttestationData list (order) it already builds, instead of build_block redoing the work with a separate HashSet pass. Report entries and unique counts (the unique count is order.len(), the same value the functions use to drive their fast paths) rather than a derived duplicate count. --- crates/blockchain/src/block_builder.rs | 58 ++++++++++++-------------- 1 file changed, 27 insertions(+), 31 deletions(-) diff --git a/crates/blockchain/src/block_builder.rs b/crates/blockchain/src/block_builder.rs index d5fe09bd..5386b5f5 100644 --- a/crates/blockchain/src/block_builder.rs +++ b/crates/blockchain/src/block_builder.rs @@ -90,28 +90,13 @@ pub(crate) fn build_block( // Gated by `enable_proposer_aggregation`: when enabled, proofs sharing an // AttestationData are merged via recursive Type-1 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. - let entries = selected.len(); - let distinct_data = selected - .iter() - .map(|(att, _)| &att.data) - .collect::>() - .len(); - let duplicates = entries - distinct_data; - + // 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 enable_proposer_aggregation { - info!(slot, entries, duplicates, "Compacting attestations"); - let compacted = compact_attestations(selected, head_state)?; - info!( - slot, - entries = compacted.len(), - "Finished compacting attestations" - ); - compacted + compact_attestations(selected, head_state, slot)? } else { - info!(slot, entries, duplicates, "Skipping attestation compaction"); - keep_best_proof_per_data(selected) + keep_best_proof_per_data(selected, slot) }; metrics::observe_block_proposal_phase("compact", compact_start.elapsed()); @@ -556,11 +541,8 @@ fn build_running_votes(state: &State) -> HashMap> { fn compact_attestations( entries: Vec<(AggregatedAttestation, TypeOneMultiSignature)>, head_state: &State, + block_slot: u64, ) -> Result, StoreError> { - if entries.len() <= 1 { - return Ok(entries); - } - // Group indices by AttestationData, preserving first-occurrence order let mut order: Vec = Vec::new(); let mut groups: HashMap> = HashMap::new(); @@ -576,8 +558,16 @@ fn compact_attestations( } } - // Fast path: no duplicates + info!( + slot = block_slot, + entries = entries.len(), + unique = order.len(), + "Compacting attestations" + ); + + // Fast path: every AttestationData already appears once (covers ≤1 entry). if order.len() == entries.len() { + info!(slot = block_slot, "Finished compacting attestations"); return Ok(entries); } @@ -639,6 +629,7 @@ fn compact_attestations( compacted.push((merged_att, merged_proof)); } + info!(slot = block_slot, "Finished compacting attestations"); Ok(compacted) } @@ -653,11 +644,8 @@ fn compact_attestations( /// individual proof already had, which is the cost of skipping aggregation. fn keep_best_proof_per_data( entries: Vec<(AggregatedAttestation, TypeOneMultiSignature)>, + block_slot: u64, ) -> Vec<(AggregatedAttestation, TypeOneMultiSignature)> { - if entries.len() <= 1 { - return entries; - } - // Preserve first-occurrence order of distinct AttestationData; for each, // track the index of the best (most participants) entry seen so far. let mut order: Vec = Vec::new(); @@ -677,7 +665,15 @@ fn keep_best_proof_per_data( } } - // Fast path: every AttestationData already appeared exactly once. + info!( + slot = block_slot, + entries = entries.len(), + unique = order.len(), + "Skipping attestation compaction" + ); + + // Fast path: every AttestationData already appeared exactly once (covers + // ≤1 entry). if order.len() == entries.len() { return entries; } @@ -1450,7 +1446,7 @@ mod tests { ]; let state = State::from_genesis(1000, vec![]); - let out = compact_attestations(entries, &state).unwrap(); + let out = compact_attestations(entries, &state, 0).unwrap(); assert_eq!(out.len(), 2); assert_eq!(out[0].0.data, data_a); assert_eq!(out[1].0.data, data_b); @@ -1491,7 +1487,7 @@ mod tests { ]; let state = State::from_genesis(1000, vec![]); - let out = compact_attestations(entries, &state).unwrap(); + let out = compact_attestations(entries, &state, 0).unwrap(); assert_eq!(out.len(), 3); assert_eq!(out[0].0.data, data_a); assert_eq!(out[1].0.data, data_b); From 1125e84358938837b95d8427a273283cf840a94e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:05:42 -0300 Subject: [PATCH 5/5] refactor(blockchain): only log compaction when there is work to do Move the "Compacting attestations" / "Skipping attestation compaction" logs to after the no-duplicates fast-path return, and drop the log the fast path emitted. Builds whose attestations are already one-per-data (the common case, including 0/1-entry blocks) now log nothing for this phase; the logs fire only when same-data entries actually need collapsing. --- crates/blockchain/src/block_builder.rs | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/crates/blockchain/src/block_builder.rs b/crates/blockchain/src/block_builder.rs index 5386b5f5..208481c2 100644 --- a/crates/blockchain/src/block_builder.rs +++ b/crates/blockchain/src/block_builder.rs @@ -558,6 +558,11 @@ fn compact_attestations( } } + // Fast path: every AttestationData already appears once (covers ≤1 entry). + if order.len() == entries.len() { + return Ok(entries); + } + info!( slot = block_slot, entries = entries.len(), @@ -565,12 +570,6 @@ fn compact_attestations( "Compacting attestations" ); - // Fast path: every AttestationData already appears once (covers ≤1 entry). - if order.len() == entries.len() { - info!(slot = block_slot, "Finished compacting attestations"); - return Ok(entries); - } - // Wrap in Option so we can .take() items by index without cloning let mut items: Vec> = entries.into_iter().map(Some).collect(); @@ -665,6 +664,12 @@ fn keep_best_proof_per_data( } } + // Fast path: every AttestationData already appeared exactly once (covers + // ≤1 entry). + if order.len() == entries.len() { + return entries; + } + info!( slot = block_slot, entries = entries.len(), @@ -672,12 +677,6 @@ fn keep_best_proof_per_data( "Skipping attestation compaction" ); - // Fast path: every AttestationData already appeared exactly once (covers - // ≤1 entry). - if order.len() == entries.len() { - return entries; - } - let mut items: Vec> = entries.into_iter().map(Some).collect(); order