From 02601cbbc171c7fc4de1c84b1fe98caa02da784b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:42:49 -0300 Subject: [PATCH 1/3] feat(blockchain): cap aggregation job children to 2 (binary merge) select_proofs_greedily picked as many existing proofs as fit the greedy coverage criterion, with no upper bound. Uncapped child counts have previously driven debug builds to stack-overflow in leanVM's rec_aggregation, and an unbounded N-way merge isn't far from leanVM's own hard cap (MAX_RECURSIONS = 16), at which point a group would fail to aggregate outright. Cap each aggregation job at 2 children, forcing pairwise (binary) merges. Proofs left uncovered by the cap stay in the new/known buffers and get merged in a later aggregation round instead of being dropped. --- crates/blockchain/src/aggregation.rs | 77 ++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 4 deletions(-) diff --git a/crates/blockchain/src/aggregation.rs b/crates/blockchain/src/aggregation.rs index 8fe3e2d2..b2a70032 100644 --- a/crates/blockchain/src/aggregation.rs +++ b/crates/blockchain/src/aggregation.rs @@ -321,12 +321,22 @@ pub fn finalize_aggregation_session(store: &Store) { metrics::update_gossip_signatures(store.gossip_signatures_count()); } +/// Maximum number of existing proofs reused as children in a single +/// aggregation job. Caps each round at a pairwise (binary) merge instead of an +/// N-way merge: leanVM's recursive verification cost (and recursion depth) +/// scales with the child count, and uncapped greedy selection has previously +/// driven debug builds to stack-overflow in `rec_aggregation`. Proofs left +/// uncovered by the cap aren't lost — they remain in the new/known buffers and +/// are picked up by `select_proofs_greedily` again in a later round. +const MAX_AGGREGATION_CHILDREN: usize = 2; + /// Greedy set-cover selection of proofs to maximize validator coverage. /// /// Processes proof sets in priority order (new before known). Within each set, -/// repeatedly picks the proof covering the most uncovered validators until -/// no proof adds new coverage. This keeps the number of children minimal -/// while maximizing the validators we can skip re-aggregating from scratch. +/// repeatedly picks the proof covering the most uncovered validators until no +/// proof adds new coverage or [`MAX_AGGREGATION_CHILDREN`] children have been +/// selected, whichever comes first — the cap applies to the combined total +/// across both sets, not per set. fn select_proofs_greedily( new_proofs: &[TypeOneMultiSignature], known_proofs: &[TypeOneMultiSignature], @@ -337,7 +347,7 @@ fn select_proofs_greedily( for proof_set in [new_proofs, known_proofs] { let mut remaining: Vec<&TypeOneMultiSignature> = proof_set.iter().collect(); - while !remaining.is_empty() { + while selected.len() < MAX_AGGREGATION_CHILDREN && !remaining.is_empty() { let best_idx = remaining .iter() .enumerate() @@ -361,6 +371,10 @@ fn select_proofs_greedily( selected.push(remaining.swap_remove(best_idx).clone()); covered.extend(new_coverage); } + + if selected.len() >= MAX_AGGREGATION_CHILDREN { + break; + } } (selected, covered) @@ -470,3 +484,58 @@ pub(crate) fn run_aggregation_worker( cancelled: cancel.is_cancelled(), }); } + +#[cfg(test)] +mod tests { + use super::*; + use ethlambda_types::attestation::AggregationBits; + + /// Build a proof with a distinct, non-overlapping participant. + fn proof_for_validator(vid: u64) -> TypeOneMultiSignature { + let mut bits = AggregationBits::with_length(vid as usize + 1).unwrap(); + bits.set(vid as usize, true).unwrap(); + TypeOneMultiSignature::empty(bits) + } + + #[test] + fn select_proofs_greedily_caps_at_max_children_within_one_set() { + // Three non-overlapping proofs all in `new_proofs`: every one of them + // adds new coverage, but only MAX_AGGREGATION_CHILDREN may be selected. + let new_proofs = vec![ + proof_for_validator(0), + proof_for_validator(1), + proof_for_validator(2), + ]; + + let (selected, covered) = select_proofs_greedily(&new_proofs, &[]); + + assert_eq!(selected.len(), MAX_AGGREGATION_CHILDREN); + assert_eq!(covered.len(), MAX_AGGREGATION_CHILDREN); + } + + #[test] + fn select_proofs_greedily_caps_across_new_and_known_combined() { + // One proof already selected from `new_proofs` should leave room for + // exactly one more from `known_proofs`, not MAX_AGGREGATION_CHILDREN + // more — the cap is on the combined total, not per set. + let new_proofs = vec![proof_for_validator(0)]; + let known_proofs = vec![proof_for_validator(1), proof_for_validator(2)]; + + let (selected, covered) = select_proofs_greedily(&new_proofs, &known_proofs); + + assert_eq!(selected.len(), MAX_AGGREGATION_CHILDREN); + assert_eq!(covered.len(), MAX_AGGREGATION_CHILDREN); + } + + #[test] + fn select_proofs_greedily_stops_early_when_no_new_coverage() { + // Fewer than the cap worth of useful proofs: selection should stop + // once no remaining proof adds coverage, same as before the cap. + let new_proofs = vec![proof_for_validator(0), proof_for_validator(0)]; + + let (selected, covered) = select_proofs_greedily(&new_proofs, &[]); + + assert_eq!(selected.len(), 1); + assert_eq!(covered.len(), 1); + } +} From dcba9dae96521b3a0ced865d67b3824668060fc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:46:05 -0300 Subject: [PATCH 2/3] test: remove unit tests --- crates/blockchain/src/aggregation.rs | 55 ---------------------------- 1 file changed, 55 deletions(-) diff --git a/crates/blockchain/src/aggregation.rs b/crates/blockchain/src/aggregation.rs index b2a70032..83d6ca5f 100644 --- a/crates/blockchain/src/aggregation.rs +++ b/crates/blockchain/src/aggregation.rs @@ -484,58 +484,3 @@ pub(crate) fn run_aggregation_worker( cancelled: cancel.is_cancelled(), }); } - -#[cfg(test)] -mod tests { - use super::*; - use ethlambda_types::attestation::AggregationBits; - - /// Build a proof with a distinct, non-overlapping participant. - fn proof_for_validator(vid: u64) -> TypeOneMultiSignature { - let mut bits = AggregationBits::with_length(vid as usize + 1).unwrap(); - bits.set(vid as usize, true).unwrap(); - TypeOneMultiSignature::empty(bits) - } - - #[test] - fn select_proofs_greedily_caps_at_max_children_within_one_set() { - // Three non-overlapping proofs all in `new_proofs`: every one of them - // adds new coverage, but only MAX_AGGREGATION_CHILDREN may be selected. - let new_proofs = vec![ - proof_for_validator(0), - proof_for_validator(1), - proof_for_validator(2), - ]; - - let (selected, covered) = select_proofs_greedily(&new_proofs, &[]); - - assert_eq!(selected.len(), MAX_AGGREGATION_CHILDREN); - assert_eq!(covered.len(), MAX_AGGREGATION_CHILDREN); - } - - #[test] - fn select_proofs_greedily_caps_across_new_and_known_combined() { - // One proof already selected from `new_proofs` should leave room for - // exactly one more from `known_proofs`, not MAX_AGGREGATION_CHILDREN - // more — the cap is on the combined total, not per set. - let new_proofs = vec![proof_for_validator(0)]; - let known_proofs = vec![proof_for_validator(1), proof_for_validator(2)]; - - let (selected, covered) = select_proofs_greedily(&new_proofs, &known_proofs); - - assert_eq!(selected.len(), MAX_AGGREGATION_CHILDREN); - assert_eq!(covered.len(), MAX_AGGREGATION_CHILDREN); - } - - #[test] - fn select_proofs_greedily_stops_early_when_no_new_coverage() { - // Fewer than the cap worth of useful proofs: selection should stop - // once no remaining proof adds coverage, same as before the cap. - let new_proofs = vec![proof_for_validator(0), proof_for_validator(0)]; - - let (selected, covered) = select_proofs_greedily(&new_proofs, &[]); - - assert_eq!(selected.len(), 1); - assert_eq!(covered.len(), 1); - } -} From a93dc4230bb005dc6ea3a653c47bed151a4adce3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:49:10 -0300 Subject: [PATCH 3/3] docs: improve documentation --- crates/blockchain/src/aggregation.rs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/crates/blockchain/src/aggregation.rs b/crates/blockchain/src/aggregation.rs index 83d6ca5f..b9f35dd8 100644 --- a/crates/blockchain/src/aggregation.rs +++ b/crates/blockchain/src/aggregation.rs @@ -322,21 +322,17 @@ pub fn finalize_aggregation_session(store: &Store) { } /// Maximum number of existing proofs reused as children in a single -/// aggregation job. Caps each round at a pairwise (binary) merge instead of an -/// N-way merge: leanVM's recursive verification cost (and recursion depth) -/// scales with the child count, and uncapped greedy selection has previously -/// driven debug builds to stack-overflow in `rec_aggregation`. Proofs left -/// uncovered by the cap aren't lost — they remain in the new/known buffers and -/// are picked up by `select_proofs_greedily` again in a later round. +/// aggregation job. Recursive aggregation is costly, so we limit the +/// number of children to avoid unbounded aggregation times. const MAX_AGGREGATION_CHILDREN: usize = 2; /// Greedy set-cover selection of proofs to maximize validator coverage. /// /// Processes proof sets in priority order (new before known). Within each set, /// repeatedly picks the proof covering the most uncovered validators until no -/// proof adds new coverage or [`MAX_AGGREGATION_CHILDREN`] children have been -/// selected, whichever comes first — the cap applies to the combined total -/// across both sets, not per set. +/// proof adds new coverage. +/// +/// Caps the number of proofs selected at [`MAX_AGGREGATION_CHILDREN`]. fn select_proofs_greedily( new_proofs: &[TypeOneMultiSignature], known_proofs: &[TypeOneMultiSignature],