From 4bd53389a84fb10c1f18c6575d6bf995a5033af7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:01:08 -0300 Subject: [PATCH 1/2] feat(blockchain): cap attestation packing at 2/3 supermajority Block building stitched partial proofs together until ~100% voter coverage per AttestationData, so any entry with multiple partial proofs paid for a recursive proof merge (aggregate_proofs) in compact_attestations: the dominant cost of building blocks that carry attestations. Consensus only needs a 2/3 supermajority; once a target crosses it the target is justified and further votes are rejected. extend_proofs_greedily now stops packing proofs as soon as prior + covered voters cross 2/3, so an entry whose largest proof alone exceeds 2/3 keeps a single proof and compaction skips aggregate_proofs entirely. Sub-supermajority (Build-tier) entries never trip the cutoff and keep full coverage. This is safe because fork-choice weight is gossip-sourced (not block-sourced) and there are no inclusion rewards, so dropping voters above 2/3 changes neither head selection nor finalization. extend_proofs_greedily returns the voters it actually packed so the selection projection stays consistent with the block; skipped proofs are traced (supermajority_reached / no_new_coverage). --- crates/blockchain/src/block_builder.rs | 273 +++++++++++++++++++++---- 1 file changed, 229 insertions(+), 44 deletions(-) diff --git a/crates/blockchain/src/block_builder.rs b/crates/blockchain/src/block_builder.rs index 722d98d7..68b3bc11 100644 --- a/crates/blockchain/src/block_builder.rs +++ b/crates/blockchain/src/block_builder.rs @@ -163,7 +163,7 @@ fn select_attestations( let mut processed_data_roots: HashSet = HashSet::new(); for _round in 0..MAX_ATTESTATIONS_DATA { - let Some((data_root, score, new_voters)) = + let Some((data_root, score)) = pick_best_candidate(&chain, &processed_data_roots, &projected) else { trace!( @@ -178,14 +178,23 @@ fn select_attestations( metrics::inc_block_proposal_attestation_builds(); let before = selected.len(); - extend_proofs_greedily(proofs, &mut selected, att_data); - let target_root = att_data.target.root; + let prior_voters = projected.current_votes.get(&target_root); + // Pack proofs up to the 2/3 cutoff; `covered` is exactly what landed in + // the block, so the projection below stays consistent with the STF. + let covered = extend_proofs_greedily( + proofs, + &mut selected, + att_data, + prior_voters, + chain.validator_count, + ); + projected .current_votes .entry(target_root) .or_default() - .extend(new_voters); + .extend(covered); trace!( tier = ?score.tier, @@ -229,15 +238,15 @@ fn select_attestations( /// /// Skips entries already processed, those failing `entry_passes_filters` /// (logging the reason), and those with zero new voters. Among remaining -/// entries, returns `(data_root, score, new_voters)` for the entry with the -/// best `EntryScore::ordering_key` (lower is better). Caller re-indexes +/// entries, returns `(data_root, score)` for the entry with the best +/// `EntryScore::ordering_key` (lower is better). Caller re-indexes /// `chain.aggregated_payloads[&data_root]` for `att_data` and `proofs`. fn pick_best_candidate( chain: &ChainContext<'_>, processed_data_roots: &HashSet, projected: &ProjectedState, -) -> Option<(H256, EntryScore, HashSet)> { - let mut best: Option<(H256, EntryScore, HashSet)> = None; +) -> Option<(H256, EntryScore)> { + let mut best: Option<(H256, EntryScore)> = None; let mut best_key: Option = None; for (data_root, (att_data, proofs)) in chain.aggregated_payloads { @@ -255,7 +264,7 @@ fn pick_best_candidate( continue; } - let Some((score, new_voters)) = score_entry( + let Some(score) = score_entry( att_data, proofs, &projected.current_votes, @@ -268,7 +277,7 @@ fn pick_best_candidate( let candidate_key = score.ordering_key(*data_root); if best_key.as_ref().is_none_or(|k| candidate_key < *k) { - best = Some((*data_root, score, new_voters)); + best = Some((*data_root, score)); best_key = Some(candidate_key); } } @@ -349,26 +358,26 @@ fn entry_passes_filters( /// Score a single candidate entry under the current projected state. /// /// Returns `None` if the entry has zero new validators relative to the -/// running voter set for its `target.root` (no marginal value, drop). On -/// `Some`, the returned `HashSet` is the set of new voters contributed by -/// this entry (caller uses it to update the running voter map without -/// re-scanning aggregation bits). A genesis self-vote cannot justify or -/// finalize and is always scored as tier 3. +/// running voter set for its `target.root` (no marginal value, drop). A +/// genesis self-vote cannot justify or finalize and is always scored as tier 3. +/// +/// Only the score is returned; the actual voters packed into the block are +/// determined later by `extend_proofs_greedily` (which may stop short of full +/// coverage at the supermajority cutoff), so the caller updates its projection +/// from that function's return value rather than from this scan. fn score_entry( att_data: &AttestationData, proofs: &[TypeOneMultiSignature], current_votes: &HashMap>, projected_finalized_slot: u64, validator_count: usize, -) -> Option<(EntryScore, HashSet)> { +) -> Option { let prior_voters = current_votes.get(&att_data.target.root); let prior_count = prior_voters.map_or(0, HashSet::len); // Collect voters that this entry adds on top of prior_voters. Avoids // cloning prior_voters; the inner contains() makes this O(participants) - // per candidate per round. `extend_proofs_greedily` selects proofs until - // none contribute new voters, so its final coverage equals this set - // unioned with prior_voters. + // per candidate per round. let mut new_voters: HashSet = HashSet::new(); for proof in proofs { for vid in proof.participant_indices() { @@ -382,7 +391,7 @@ fn score_entry( } let total = prior_count + new_voters.len(); - let crosses_2_3 = 3 * total >= 2 * validator_count; + let crosses_2_3 = is_supermajority(total, validator_count); // 3SF-mini finalization requires the source to lie past the finalized // boundary (a source at or behind it is already final and must not @@ -403,15 +412,21 @@ fn score_entry( Tier::Justify }; - Some(( - EntryScore { - tier, - new_voters: new_voters.len(), - target_slot: att_data.target.slot, - att_slot: att_data.slot, - }, - new_voters, - )) + Some(EntryScore { + tier, + new_voters: new_voters.len(), + target_slot: att_data.target.slot, + att_slot: att_data.slot, + }) +} + +/// True when `count` validators form a 2/3 supermajority of `validator_count`. +/// +/// This is the consensus threshold for justifying a target. Block building uses +/// it both to tier candidate entries (`score_entry`) and to stop packing proofs +/// for an entry once the target crosses it (`extend_proofs_greedily`). +fn is_supermajority(count: usize, validator_count: usize) -> bool { + 3 * count >= 2 * validator_count } /// Selection tier for a candidate `AttestationData` entry. @@ -603,30 +618,51 @@ fn compact_attestations( Ok(compacted) } -/// Greedily select proofs maximizing new validator coverage. +/// Greedily select proofs maximizing new validator coverage, stopping at the +/// 2/3 supermajority. /// /// For a single attestation data entry, picks proofs that cover the most -/// uncovered validators. A proof is selected as long as it adds at least -/// one previously-uncovered validator; partially-overlapping participants -/// between selected proofs are allowed. `compact_attestations` later feeds -/// these proofs as children to `aggregate_proofs`, which delegates to -/// `xmss_aggregate` — that function tracks duplicate pubkeys across -/// children via its `dup_pub_keys` machinery, so overlap is supported by -/// the underlying aggregation scheme. +/// uncovered validators. A proof is selected as long as it adds at least one +/// previously-uncovered validator; partially-overlapping participants between +/// selected proofs are allowed. +/// +/// Selection stops early once the target crosses a 2/3 supermajority: counting +/// `prior_voters` (validators that already voted for this target in the +/// projected state or earlier rounds) plus the validators packed here. Beyond +/// 2/3 the target is already justified, so every additional proof would only +/// add a child to the recursive merge in `compact_attestations` for no +/// consensus gain. A sub-supermajority (Build-tier) entry never trips the +/// cutoff and keeps full coverage as before. /// -/// Each selected proof is appended to `selected` paired with its -/// corresponding AggregatedAttestation. +/// `compact_attestations` later feeds the selected proofs as children to +/// `aggregate_proofs`, which delegates to `xmss_aggregate` — that function +/// tracks duplicate pubkeys across children via its `dup_pub_keys` machinery, +/// so overlap is supported by the underlying aggregation scheme. +/// +/// Each selected proof is appended to `selected` paired with its corresponding +/// AggregatedAttestation. Returns the set of validators newly covered here +/// (excluding `prior_voters`) so the caller can keep its vote projection +/// consistent with what actually landed in the block. fn extend_proofs_greedily( proofs: &[TypeOneMultiSignature], selected: &mut Vec<(AggregatedAttestation, TypeOneMultiSignature)>, att_data: &AttestationData, -) { + prior_voters: Option<&HashSet>, + validator_count: usize, +) -> HashSet { + // Validators packed here that did not already vote for this target. Counts + // toward the supermajority union exactly once and is returned to the caller. + let mut covered_new: HashSet = HashSet::new(); if proofs.is_empty() { - return; + return covered_new; } + let prior_count = prior_voters.map_or(0, HashSet::len); + // All validators covered by selected proofs (including those already in + // prior_voters), used to score each candidate by its uncovered additions. let mut covered: HashSet = HashSet::new(); let mut remaining_indices: HashSet = (0..proofs.len()).collect(); + let mut cutoff_reached = false; while !remaining_indices.is_empty() { // Pick proof covering the most uncovered validators (count only, no allocation) @@ -664,10 +700,48 @@ fn extend_proofs_greedily( metrics::inc_pq_sig_aggregated_signatures(); metrics::inc_pq_sig_attestations_in_aggregated_signatures(new_covered.len() as u64); + for &vid in &new_covered { + if prior_voters.is_none_or(|prior| !prior.contains(&vid)) { + covered_new.insert(vid); + } + } covered.extend(new_covered); selected.push((att, proof.clone())); remaining_indices.remove(&best_idx); + + if is_supermajority(prior_count + covered_new.len(), validator_count) { + cutoff_reached = true; + break; + } + } + + // Trace any proof we did not select, distinguishing the supermajority cutoff + // (proof still had coverage to add) from ordinary exhaustion (its voters are + // already covered). + if !remaining_indices.is_empty() { + let data_root = att_data.hash_tree_root(); + for &idx in &remaining_indices { + let new_for_proof = proofs[idx] + .participant_indices() + .filter(|vid| !covered.contains(vid)) + .count(); + let reason = if cutoff_reached && new_for_proof > 0 { + "supermajority_reached" + } else { + "no_new_coverage" + }; + trace!( + reason, + target_slot = att_data.target.slot, + target_root = %ShortRoot(&att_data.target.root.0), + data_root = %ShortRoot(&data_root.0), + new_voters = new_for_proof, + "skipped proof" + ); + } } + + covered_new } /// Compute the bitwise union (OR) of two AggregationBits bitfields. @@ -765,7 +839,7 @@ mod tests { // Supermajority (3 of 4) so the entry crosses 2/3. let proofs = vec![TypeOneMultiSignature::empty(make_bits(&[0, 1, 2]))]; - let (score, _) = score_entry( + let score = score_entry( &att_data, &proofs, &HashMap::new(), @@ -1281,6 +1355,9 @@ mod tests { /// extends `covered`. The resulting overlap is handled downstream by /// `aggregate_proofs` → `xmss_aggregate` (which tracks duplicate pubkeys /// across children via its `dup_pub_keys` machinery). + /// + /// `validator_count` is set far above the covered set so the 2/3 cutoff + /// never fires: this isolates the overlap-handling behavior under test. #[test] fn extend_proofs_greedily_allows_overlap_when_it_adds_coverage() { let data = make_att_data(1); @@ -1295,7 +1372,13 @@ mod tests { let proof_c = TypeOneMultiSignature::empty(make_bits(&[1, 2])); let mut selected = Vec::new(); - extend_proofs_greedily(&[proof_a, proof_b, proof_c], &mut selected, &data); + let covered_new = extend_proofs_greedily( + &[proof_a, proof_b, proof_c], + &mut selected, + &data, + None, + 1000, + ); assert_eq!( selected.len(), @@ -1308,6 +1391,8 @@ mod tests { .flat_map(|(_, p)| p.participant_indices()) .collect(); assert_eq!(covered, HashSet::from([0, 1, 2, 3, 4])); + // With no prior voters, the returned set equals the full coverage. + assert_eq!(covered_new, HashSet::from([0, 1, 2, 3, 4])); // Attestation bits mirror the proof's participants for each entry. for (att, proof) in &selected { @@ -1329,10 +1414,110 @@ mod tests { let proof_b = TypeOneMultiSignature::empty(make_bits(&[1, 2])); let mut selected = Vec::new(); - extend_proofs_greedily(&[proof_a, proof_b], &mut selected, &data); + // High validator_count keeps the cutoff off; this exercises pure + // greedy exhaustion. + let covered_new = + extend_proofs_greedily(&[proof_a, proof_b], &mut selected, &data, None, 1000); assert_eq!(selected.len(), 1); let covered: HashSet = selected[0].1.participant_indices().collect(); assert_eq!(covered, HashSet::from([0, 1, 2, 3])); + assert_eq!(covered_new, HashSet::from([0, 1, 2, 3])); + } + + /// When the largest single proof already covers a 2/3 supermajority, the + /// cutoff stops after that one proof: no further proof is packed, so + /// `compact_attestations` takes its single-proof fast path and skips the + /// expensive recursive aggregation entirely. + #[test] + fn extend_proofs_greedily_stops_at_supermajority_single_proof() { + let data = make_att_data(1); + + // 4 validators; ceil(2*4/3) = 3 voters is a supermajority. + // A = {0, 1, 2} (3 validators — crosses 2/3 on its own) + // B = {3} (would add validator 3, but is skipped) + let proof_a = TypeOneMultiSignature::empty(make_bits(&[0, 1, 2])); + let proof_b = TypeOneMultiSignature::empty(make_bits(&[3])); + + let mut selected = Vec::new(); + let covered_new = + extend_proofs_greedily(&[proof_a, proof_b], &mut selected, &data, None, 4); + + assert_eq!(selected.len(), 1, "supermajority reached after one proof"); + let covered: HashSet = selected[0].1.participant_indices().collect(); + assert_eq!(covered, HashSet::from([0, 1, 2])); + assert_eq!(covered_new, HashSet::from([0, 1, 2])); + } + + /// When no single proof crosses 2/3, the cutoff allows the minimal merge: + /// proofs are packed until the union crosses the threshold, then selection + /// stops, dropping a third proof that would only add coverage beyond 2/3. + #[test] + fn extend_proofs_greedily_minimal_merge_then_stops() { + let data = make_att_data(1); + + // 6 validators; ceil(2*6/3) = 4 voters is a supermajority. Distinct + // sizes keep the greedy order deterministic. + // A = {0, 1, 2} (3 — picked first, union 3 < 4) + // B = {3, 4} (2 — union 5 >= 4, cutoff fires) + // C = {5} (1 — would add validator 5, but is skipped) + let proof_a = TypeOneMultiSignature::empty(make_bits(&[0, 1, 2])); + let proof_b = TypeOneMultiSignature::empty(make_bits(&[3, 4])); + let proof_c = TypeOneMultiSignature::empty(make_bits(&[5])); + + let mut selected = Vec::new(); + let covered_new = + extend_proofs_greedily(&[proof_a, proof_b, proof_c], &mut selected, &data, None, 6); + + assert_eq!(selected.len(), 2, "two proofs needed to cross 2/3"); + assert_eq!(covered_new, HashSet::from([0, 1, 2, 3, 4])); + } + + /// A sub-supermajority (Build-tier) entry never trips the cutoff and keeps + /// full coverage exactly as before: all proofs that add coverage are packed. + #[test] + fn extend_proofs_greedily_below_supermajority_keeps_full_coverage() { + let data = make_att_data(1); + + // 100 validators; the available coverage (7 voters) is far below 2/3, + // so the cutoff never fires. Distinct per-step coverage keeps the greedy + // order deterministic: A adds 4, then B adds {4,5} (2), then C adds 1. + let proof_a = TypeOneMultiSignature::empty(make_bits(&[0, 1, 2, 3])); + let proof_b = TypeOneMultiSignature::empty(make_bits(&[3, 4, 5])); + let proof_c = TypeOneMultiSignature::empty(make_bits(&[6])); + + let mut selected = Vec::new(); + let covered_new = extend_proofs_greedily( + &[proof_a, proof_b, proof_c], + &mut selected, + &data, + None, + 100, + ); + + assert_eq!(selected.len(), 3, "Build-tier entry keeps full coverage"); + assert_eq!(covered_new, HashSet::from([0, 1, 2, 3, 4, 5, 6])); + } + + /// Prior voters (already counted for this target in the projected state) + /// count toward the supermajority union, so fewer new voters are needed to + /// trip the cutoff. + #[test] + fn extend_proofs_greedily_counts_prior_voters_toward_cutoff() { + let data = make_att_data(1); + + // 6 validators; supermajority = 4. Prior voters {0, 1} already count, so + // a single proof adding {2, 3, 4} reaches 5 >= 4 and stops, dropping B. + let prior: HashSet = HashSet::from([0, 1]); + let proof_a = TypeOneMultiSignature::empty(make_bits(&[2, 3, 4])); + let proof_b = TypeOneMultiSignature::empty(make_bits(&[5])); + + let mut selected = Vec::new(); + let covered_new = + extend_proofs_greedily(&[proof_a, proof_b], &mut selected, &data, Some(&prior), 6); + + assert_eq!(selected.len(), 1, "prior voters push the union over 2/3"); + // Returned set excludes prior voters; it is only what this call added. + assert_eq!(covered_new, HashSet::from([2, 3, 4])); } } From 007a22c5622dff1af9c78b2fc419b5f1b9a683d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:28:11 -0300 Subject: [PATCH 2/2] fix(blockchain): rank proofs by net-new voters; review follow-ups Address automated and agent review feedback on the supermajority cutoff: - extend_proofs_greedily ranked candidate proofs by participants not in the previously-packed set, ignoring prior_voters. With the new cutoff that let the greedy pack a redundant prior-heavy proof before crossing 2/3, weakening the optimization. Rank by additions to the full target union (prior_voters + packed) instead, so an all-new proof beats a prior-heavy one and a proof whose voters are already counted is never packed for a wasted merge child. - Gate the skipped-proof trace block on trace level being enabled so the per-proof recount and hash_tree_root are skipped on the hot path. - "selected" trace now reports packed_voters (what actually landed) alongside candidate_new_voters (the full-coverage score), which alone was misleading. - Note the registry-bounded validator_count on is_supermajority. Tests: a net-new ranking regression test, and a build_block end-to-end test proving a cutoff-capped entry still justifies its target through the real STF while staying on compaction's single-proof fast path. --- crates/blockchain/src/block_builder.rs | 228 ++++++++++++++++++++++--- 1 file changed, 202 insertions(+), 26 deletions(-) diff --git a/crates/blockchain/src/block_builder.rs b/crates/blockchain/src/block_builder.rs index 68b3bc11..4ee97bc8 100644 --- a/crates/blockchain/src/block_builder.rs +++ b/crates/blockchain/src/block_builder.rs @@ -190,6 +190,9 @@ fn select_attestations( chain.validator_count, ); + // `packed_voters` is what actually landed in the block (capped at the + // cutoff); `candidate_new_voters` is the full-coverage count scoring saw. + let packed_voters = covered.len(); projected .current_votes .entry(target_root) @@ -198,7 +201,8 @@ fn select_attestations( trace!( tier = ?score.tier, - new_voters = score.new_voters, + candidate_new_voters = score.new_voters, + packed_voters, target_slot = score.target_slot, target_root = %ShortRoot(&target_root.0), data_root = %ShortRoot(&data_root.0), @@ -424,7 +428,11 @@ fn score_entry( /// /// This is the consensus threshold for justifying a target. Block building uses /// it both to tier candidate entries (`score_entry`) and to stop packing proofs -/// for an entry once the target crosses it (`extend_proofs_greedily`). +/// for an entry once the target crosses it (`extend_proofs_greedily`). It must +/// match the state transition's own justification check exactly. +/// +/// `validator_count` is bounded by the validator registry, so `3 * count` +/// cannot overflow `usize`. fn is_supermajority(count: usize, validator_count: usize) -> bool { 3 * count >= 2 * validator_count } @@ -621,10 +629,13 @@ fn compact_attestations( /// Greedily select proofs maximizing new validator coverage, stopping at the /// 2/3 supermajority. /// -/// For a single attestation data entry, picks proofs that cover the most -/// uncovered validators. A proof is selected as long as it adds at least one -/// previously-uncovered validator; partially-overlapping participants between -/// selected proofs are allowed. +/// For a single attestation data entry, picks the proof adding the most +/// validators to the target's voter union (`prior_voters` plus everything +/// packed so far). A proof is selected as long as it adds at least one new +/// validator; partially-overlapping participants between selected proofs are +/// allowed. Ranking against the full union (rather than only previously-packed +/// proofs) avoids packing a proof whose voters are mostly already counted, +/// which would be a redundant child in the merge below. /// /// Selection stops early once the target crosses a 2/3 supermajority: counting /// `prior_voters` (validators that already voted for this target in the @@ -650,28 +661,31 @@ fn extend_proofs_greedily( prior_voters: Option<&HashSet>, validator_count: usize, ) -> HashSet { - // Validators packed here that did not already vote for this target. Counts - // toward the supermajority union exactly once and is returned to the caller. + // Validators newly packed here (excluding prior_voters); returned to the + // caller so it can keep its vote projection consistent with the block. let mut covered_new: HashSet = HashSet::new(); if proofs.is_empty() { return covered_new; } - let prior_count = prior_voters.map_or(0, HashSet::len); - // All validators covered by selected proofs (including those already in - // prior_voters), used to score each candidate by its uncovered additions. - let mut covered: HashSet = HashSet::new(); + // Running union of validators counted toward this target: prior voters plus + // everything packed so far. Candidates are ranked by what they add to THIS + // union (not just to previously-packed proofs), so a proof whose voters are + // mostly already counted -- e.g. prior voters -- doesn't look large and is + // not packed for a redundant merge. + let mut union: HashSet = prior_voters.cloned().unwrap_or_default(); let mut remaining_indices: HashSet = (0..proofs.len()).collect(); let mut cutoff_reached = false; while !remaining_indices.is_empty() { - // Pick proof covering the most uncovered validators (count only, no allocation) + // Pick the proof adding the most validators to the union (count only, no + // allocation). let best = remaining_indices .iter() .map(|&idx| { let count = proofs[idx] .participant_indices() - .filter(|vid| !covered.contains(vid)) + .filter(|vid| !union.contains(vid)) .count(); (idx, count) }) @@ -686,10 +700,11 @@ fn extend_proofs_greedily( let proof = &proofs[best_idx]; - // Collect coverage only for the winning proof + // Validators this proof adds to the union (all genuinely new: not prior, + // not already packed). let new_covered: Vec = proof .participant_indices() - .filter(|vid| !covered.contains(vid)) + .filter(|vid| !union.contains(vid)) .collect(); let att = AggregatedAttestation { @@ -701,29 +716,29 @@ fn extend_proofs_greedily( metrics::inc_pq_sig_attestations_in_aggregated_signatures(new_covered.len() as u64); for &vid in &new_covered { - if prior_voters.is_none_or(|prior| !prior.contains(&vid)) { - covered_new.insert(vid); - } + covered_new.insert(vid); + union.insert(vid); } - covered.extend(new_covered); selected.push((att, proof.clone())); remaining_indices.remove(&best_idx); - if is_supermajority(prior_count + covered_new.len(), validator_count) { + // union.len() == prior_voters.len() + covered_new.len(): the total + // validators voting for this target in the projected post-state. + if is_supermajority(union.len(), validator_count) { cutoff_reached = true; break; } } - // Trace any proof we did not select, distinguishing the supermajority cutoff - // (proof still had coverage to add) from ordinary exhaustion (its voters are - // already covered). - if !remaining_indices.is_empty() { + // Trace any proof we did not pack, distinguishing the supermajority cutoff + // (proof still had net-new voters) from ordinary exhaustion (already + // covered). Gated so the per-proof recount is skipped when trace is off. + if !remaining_indices.is_empty() && tracing::enabled!(tracing::Level::TRACE) { let data_root = att_data.hash_tree_root(); for &idx in &remaining_indices { let new_for_proof = proofs[idx] .participant_indices() - .filter(|vid| !covered.contains(vid)) + .filter(|vid| !union.contains(vid)) .count(); let reason = if cutoff_reached && new_for_proof > 0 { "supermajority_reached" @@ -1520,4 +1535,165 @@ mod tests { // Returned set excludes prior voters; it is only what this call added. assert_eq!(covered_new, HashSet::from([2, 3, 4])); } + + /// Proofs are ranked by what they add to the target's voter union (which + /// includes `prior_voters`), not just by raw participant count. A proof that + /// is mostly prior voters must lose to one that is all-new, otherwise the + /// greedy packs a redundant extra proof before the cutoff fires. + #[test] + fn extend_proofs_greedily_ranks_by_net_new_over_prior() { + let data = make_att_data(1); + + // 9 validators; supermajority = 6. Prior voters {0,1,2,3}. + // A = {0,1,2,3,4} (5 participants, but only validator 4 is net-new) + // B = {5,6,7,8} (4 participants, all net-new) + // Ranking by raw count would pick A first (5 > 4) and then need B too. + // Ranking by net-new picks B first (4 > 1); union = {0,1,2,3,5,6,7,8} = 8 + // >= 6, so the cutoff fires and A is never packed. + let prior: HashSet = HashSet::from([0, 1, 2, 3]); + let proof_a = TypeOneMultiSignature::empty(make_bits(&[0, 1, 2, 3, 4])); + let proof_b = TypeOneMultiSignature::empty(make_bits(&[5, 6, 7, 8])); + + let mut selected = Vec::new(); + let covered_new = + extend_proofs_greedily(&[proof_a, proof_b], &mut selected, &data, Some(&prior), 9); + + assert_eq!(selected.len(), 1, "only the all-new proof is needed"); + let packed: HashSet = selected[0].1.participant_indices().collect(); + assert_eq!( + packed, + HashSet::from([5, 6, 7, 8]), + "the all-new proof B was packed, not A" + ); + assert_eq!(covered_new, HashSet::from([5, 6, 7, 8])); + } + + /// End-to-end through `build_block`: an entry whose proofs together exceed + /// 2/3 must pack only the supermajority proof (cutoff drops the extra), so + /// `compact_attestations` stays on its single-proof fast path (no recursive + /// aggregation), and the capped block still justifies the target when run + /// through the real state transition. + #[test] + fn build_block_cutoff_packs_minimum_and_still_justifies() { + use ethlambda_types::{ + block::BlockHeader, + state::{ChainConfig, JustificationValidators, JustifiedSlots}, + }; + use libssz_types::SszList; + + const NUM_VALIDATORS: usize = 50; + const SUPERMAJORITY: usize = 34; // ceil(2 * 50 / 3) + const HEAD_SLOT: u64 = 5; + const TARGET_SLOT: u64 = 1; + + 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 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: SszList::try_from(hashes.clone()).unwrap(), + 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 att_data = AttestationData { + slot, + head: Checkpoint { + root: hashes[0], + slot: 0, + }, + target: Checkpoint { + root: hashes[TARGET_SLOT as usize], + slot: TARGET_SLOT, + }, + source: Checkpoint { + root: hashes[0], + slot: 0, + }, + }; + let data_root = att_data.hash_tree_root(); + + // A supermajority proof (34/50) plus a small extra. The cutoff packs only + // the supermajority proof, so compaction never calls aggregate_proofs. + let mut big_bits = AggregationBits::with_length(NUM_VALIDATORS).unwrap(); + for i in 0..SUPERMAJORITY { + big_bits.set(i, true).unwrap(); + } + let big = TypeOneMultiSignature::new(big_bits, SszList::try_from(vec![0xAB; 64]).unwrap()); + + let mut small_bits = AggregationBits::with_length(NUM_VALIDATORS).unwrap(); + small_bits.set(SUPERMAJORITY, true).unwrap(); + small_bits.set(SUPERMAJORITY + 1, true).unwrap(); + let small = + TypeOneMultiSignature::new(small_bits, SszList::try_from(vec![0xCD; 64]).unwrap()); + + let mut aggregated_payloads = HashMap::new(); + aggregated_payloads.insert(data_root, (att_data.clone(), vec![big, small])); + + let mut known_block_roots = HashSet::new(); + known_block_roots.insert(parent_root); + known_block_roots.insert(hashes[0]); + + let (block, _signatures, post_checkpoints) = build_block( + &head_state, + slot, + proposer_index, + parent_root, + &known_block_roots, + &aggregated_payloads, + ) + .expect("build_block should succeed"); + + // One attestation for the data, carrying only the supermajority proof's + // bits -- the extra proof was dropped by the cutoff. + assert_eq!(block.body.attestations.len(), 1); + let packed = &block + .body + .attestations + .iter() + .next() + .unwrap() + .aggregation_bits; + let packed_count = (0..NUM_VALIDATORS) + .filter(|&i| packed.get(i).unwrap_or(false)) + .count(); + assert_eq!( + packed_count, SUPERMAJORITY, + "cutoff should pack only the supermajority proof, not the extra voters" + ); + + // The capped block still justifies the target through the real STF. + assert_eq!(post_checkpoints.justified.slot, TARGET_SLOT); + assert_eq!( + post_checkpoints.justified.root, + hashes[TARGET_SLOT as usize] + ); + } }