Skip to content

feat(blockchain): cap attestation packing at 2/3 supermajority#472

Open
MegaRedHand wants to merge 3 commits into
mainfrom
feat/supermajority-attestation-cutoff
Open

feat(blockchain): cap attestation packing at 2/3 supermajority#472
MegaRedHand wants to merge 3 commits into
mainfrom
feat/supermajority-attestation-cutoff

Conversation

@MegaRedHand

Copy link
Copy Markdown
Collaborator

What

Block building stops packing attestation proofs for an AttestationData entry as soon as its voters cross the 2/3 supermajority, instead of stitching partial proofs together until ~100% coverage.

Why

select_attestationsextend_proofs_greedily greedily covered ~all available voters per entry. Any entry left with multiple partial proofs then paid for a recursive proof merge (aggregate_proofs, the leanVM SNARK) in compact_attestations. Per profiling, that compaction is the dominant cost of building blocks that carry attestations.

Consensus only needs a 2/3 supermajority: once a target reaches it, the target is justified and is_valid_vote rejects further votes for it. So every proof packed beyond 2/3 was added to an expensive merge for zero consensus gain.

How

One uniform cutoff in extend_proofs_greedily, no tier branching:

  • Stop packing once prior_voters + covered cross 2/3 (is_supermajority helper, shared with score_entry). prior_voters = validators that already voted for this target in the projected state or earlier rounds.
  • When the largest single proof already exceeds 2/3, only that proof is selected → compact_attestations takes its single-proof fast path and skips aggregate_proofs entirely.
  • A sub-supermajority (Build-tier) entry can't reach 2/3 by definition, so the cutoff never fires and it keeps full coverage, unchanged.
  • extend_proofs_greedily now returns the voters it actually packed; select_attestations updates its vote projection from that set (not score_entry's full union) so the projection stays consistent with the real block.
  • Skipped proofs are traced at trace level (reason = supermajority_reached | no_new_coverage).

Safety

Concern Why dropping voters above 2/3 is safe
Fork choice LMD GHOST weight is sourced from gossip votes (extract_latest_known_attestations), not block-included attestations. Head weight is unchanged.
Finalization Justification is binary at 2/3; reaching exactly 2/3 justifies, and post-justification votes are rejected.
Rewards None exist in lean consensus; no penalty for excluding a validator.

Tradeoff: a non-justifying target accumulates one proposer's selection per block rather than the full gossip union, converging over slots. No safety impact.

Tests

  • New: single proof already >2/3 → 1 proof; two needed to cross → 2, third dropped; sub-2/3 entry → full coverage; prior voters count toward the cutoff.
  • Updated extend_proofs_greedily_* tests for the new signature (high validator_count so they keep testing overlap/exhaustion).
  • Existing build_block_* integration tests unchanged.
  • make fmt, make lint, and cargo test -p ethlambda-blockchain --lib (42 pass) all green.

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).
@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

The PR correctly moves the "new voters" calculation from score_entry into extend_proofs_greedily and adds the 2/3 supermajority cutoff optimization. This ensures the block builder stops packing attestations for a target once justification is guaranteed, reducing unnecessary signature aggregation overhead.

Correctness & Logic

  1. Projection consistency (lines 178–186, 194):
    The change from new_voters (pre-calculated in score_entry) to covered (returned by extend_proofs_greedily) ensures the projected.current_votes tracks exactly what landed in the block. This fixes a subtle consistency issue where the projection might have over-estimated voters

Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

  • crates/blockchain/src/block_builder.rs:669extend_proofs_greedily still scores proofs against covered only, not prior_voters ∪ covered. That was mostly harmless before, but with the new early 2/3 cutoff it means the loop can pick a proof whose participants are largely already counted in prior_voters, then stop later than necessary or include an otherwise useless proof before a strictly better one. This does not look consensus-invalid, but it weakens the main optimization in this PR and can still inflate block size / recursive XMSS aggregation cost. I would filter both best_count and new_covered against prior_voters as well, and add a regression test where two competing proofs exist after seeding prior_voters.

No other correctness or security regressions stood out in the diff. The projection change in select_attestations is aligned with the STF’s “drop target bucket after justification” behavior, so I don’t see a fork-choice / justification mismatch from the cutoff itself.

I could not run the targeted tests here because cargo/rustup failed trying to create temp files under a read-only home directory.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@greptile-apps

greptile-apps Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR optimises attestation packing in block building by stopping proof accumulation for an AttestationData entry once its combined voter count crosses the 2/3 supermajority threshold, rather than greedily collecting ~100% coverage. Because compact_attestations pays a recursive SNARK merge (aggregate_proofs) for every proof beyond the first, any proof packed after the target is justified adds cost with zero consensus value.

  • extend_proofs_greedily gains a prior_voters + validator_count parameter and an is_supermajority early-exit; it now returns the validators it actually packed so select_attestations can keep its vote projection consistent with the real block instead of using score_entry's full-coverage scan.
  • score_entry and pick_best_candidate drop the previously-returned HashSet of new voters; scoring still examines the full proof set (needed for tier determination), but the projection update is decoupled to the actual packed set.
  • Build-tier entries (whose total coverage never reaches 2/3) are unaffected: the cutoff never fires for them and they keep full proof coverage as before.

Confidence Score: 4/5

The change is safe to merge; it reduces unnecessary recursive proof merges without affecting consensus correctness, and the new unit tests cover the key cutoff scenarios thoroughly.

The algorithm is internally consistent: score_entry's tier classification (which requires full coverage) and extend_proofs_greedily's early stopping (which stops at 2/3) are correctly decoupled, and the projection update uses the actually-packed voter set. The two observations flagged are trace accuracy and a bounded greedy suboptimality — both pre-existing or purely diagnostic in nature.

crates/blockchain/src/block_builder.rs — the trace new_voters field (line 200) reflects the full-coverage score rather than the capped packed count, which may mislead operators debugging voter-accumulation behaviour.

Important Files Changed

Filename Overview
crates/blockchain/src/block_builder.rs Implements 2/3 supermajority cutoff in extend_proofs_greedily; refactors score_entry/pick_best_candidate to drop the returned voter set; adds is_supermajority helper; updates projection to use actual packed voters; adds 4 new unit tests covering the key cutoff scenarios.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[select_attestations loop] --> B[pick_best_candidate]
    B --> C{Any candidate\nwith new voters?}
    C -- No --> Z[Done - converged]
    C -- Yes --> D[score_entry\nfor each candidate\nfull-proof scan]
    D --> E[Returns EntryScore\ntier + new_voters count]
    E --> F[Best score picked\nby ordering_key]
    F --> G[get prior_voters\nfrom projected state]
    G --> H[extend_proofs_greedily\nwith prior_voters + validator_count]
    H --> I{After each proof:\nprior_count + covered_new\n>= 2/3?}
    I -- No --> J[Pick next best proof\nby uncovered count]
    J --> I
    I -- Yes --> K[cutoff reached,\nstop packing]
    I -- Proofs exhausted --> L[All coverage packed\nBuild-tier path]
    K --> M[Return covered_new]
    L --> M
    M --> N[Update projected.current_votes\nwith covered_new]
    N --> O{Tier Justify\nor Finalize?}
    O -- Yes --> P[Project justification,\nremove target from votes]
    O -- No --> A
    P --> A
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[select_attestations loop] --> B[pick_best_candidate]
    B --> C{Any candidate\nwith new voters?}
    C -- No --> Z[Done - converged]
    C -- Yes --> D[score_entry\nfor each candidate\nfull-proof scan]
    D --> E[Returns EntryScore\ntier + new_voters count]
    E --> F[Best score picked\nby ordering_key]
    F --> G[get prior_voters\nfrom projected state]
    G --> H[extend_proofs_greedily\nwith prior_voters + validator_count]
    H --> I{After each proof:\nprior_count + covered_new\n>= 2/3?}
    I -- No --> J[Pick next best proof\nby uncovered count]
    J --> I
    I -- Yes --> K[cutoff reached,\nstop packing]
    I -- Proofs exhausted --> L[All coverage packed\nBuild-tier path]
    K --> M[Return covered_new]
    L --> M
    M --> N[Update projected.current_votes\nwith covered_new]
    N --> O{Tier Justify\nor Finalize?}
    O -- Yes --> P[Project justification,\nremove target from votes]
    O -- No --> A
    P --> A
Loading

Comments Outside Diff (1)

  1. crates/blockchain/src/block_builder.rs, line 199-207 (link)

    P2 new_voters trace field overstates actual packed coverage for Justify/Finalize entries

    score.new_voters comes from score_entry, which unions ALL proof participants minus prior_voters. For a Justify or Finalize entry, extend_proofs_greedily may stop short at the 2/3 cutoff, so the count of validators actually written to the block (i.e. covered_new.len()) can be substantially less. Anyone reading this trace to diagnose convergence or voter-accumulation behaviour will see an inflated new_voters figure while selected_proofs will correctly show fewer proofs were packed. Consider logging covered.len() (the return value of extend_proofs_greedily) instead of, or alongside, score.new_voters so the two fields remain consistent.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: crates/blockchain/src/block_builder.rs
    Line: 199-207
    
    Comment:
    **`new_voters` trace field overstates actual packed coverage for Justify/Finalize entries**
    
    `score.new_voters` comes from `score_entry`, which unions ALL proof participants minus `prior_voters`. For a Justify or Finalize entry, `extend_proofs_greedily` may stop short at the 2/3 cutoff, so the count of validators actually written to the block (i.e. `covered_new.len()`) can be substantially less. Anyone reading this trace to diagnose convergence or voter-accumulation behaviour will see an inflated `new_voters` figure while `selected_proofs` will correctly show fewer proofs were packed. Consider logging `covered.len()` (the return value of `extend_proofs_greedily`) instead of, or alongside, `score.new_voters` so the two fields remain consistent.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
crates/blockchain/src/block_builder.rs:199-207
**`new_voters` trace field overstates actual packed coverage for Justify/Finalize entries**

`score.new_voters` comes from `score_entry`, which unions ALL proof participants minus `prior_voters`. For a Justify or Finalize entry, `extend_proofs_greedily` may stop short at the 2/3 cutoff, so the count of validators actually written to the block (i.e. `covered_new.len()`) can be substantially less. Anyone reading this trace to diagnose convergence or voter-accumulation behaviour will see an inflated `new_voters` figure while `selected_proofs` will correctly show fewer proofs were packed. Consider logging `covered.len()` (the return value of `extend_proofs_greedily`) instead of, or alongside, `score.new_voters` so the two fields remain consistent.

### Issue 2 of 2
crates/blockchain/src/block_builder.rs:668-715
**Greedy ranks proofs by "uncovered by selected proofs," not by "net new to the target voter union"**

`best_count` is computed by filtering against `covered`, which includes validators that are already in `prior_voters` if they appear in a selected proof. This means a proof that is, say, 80% prior voters and 20% fresh validators can appear "larger" to the greedy than a proof that is 100% fresh validators. Concretely: if `prior_voters = {0,1,2,3}`, proof A = `{0,1,2,3,4}` (5 uncovered, 1 truly new) beats proof B = `{5,6,7,8}` (4 uncovered, 4 truly new) in the first pick. As a result the greedy may pack one extra proof before reaching the cutoff — exactly the scenario the PR is trying to minimise. The issue is bounded (at most one unnecessary extra merge per entry) and the previous code had the same property, but it is worth documenting as a known residual cost.

Reviews (1): Last reviewed commit: "feat(blockchain): cap attestation packin..." | Re-trigger Greptile

Comment thread crates/blockchain/src/block_builder.rs Outdated
Comment on lines +668 to +715
@@ -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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Greedy ranks proofs by "uncovered by selected proofs," not by "net new to the target voter union"

best_count is computed by filtering against covered, which includes validators that are already in prior_voters if they appear in a selected proof. This means a proof that is, say, 80% prior voters and 20% fresh validators can appear "larger" to the greedy than a proof that is 100% fresh validators. Concretely: if prior_voters = {0,1,2,3}, proof A = {0,1,2,3,4} (5 uncovered, 1 truly new) beats proof B = {5,6,7,8} (4 uncovered, 4 truly new) in the first pick. As a result the greedy may pack one extra proof before reaching the cutoff — exactly the scenario the PR is trying to minimise. The issue is bounded (at most one unnecessary extra merge per entry) and the previous code had the same property, but it is worth documenting as a known residual cost.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/blockchain/src/block_builder.rs
Line: 668-715

Comment:
**Greedy ranks proofs by "uncovered by selected proofs," not by "net new to the target voter union"**

`best_count` is computed by filtering against `covered`, which includes validators that are already in `prior_voters` if they appear in a selected proof. This means a proof that is, say, 80% prior voters and 20% fresh validators can appear "larger" to the greedy than a proof that is 100% fresh validators. Concretely: if `prior_voters = {0,1,2,3}`, proof A = `{0,1,2,3,4}` (5 uncovered, 1 truly new) beats proof B = `{5,6,7,8}` (4 uncovered, 4 truly new) in the first pick. As a result the greedy may pack one extra proof before reaching the cutoff — exactly the scenario the PR is trying to minimise. The issue is bounded (at most one unnecessary extra merge per entry) and the previous code had the same property, but it is worth documenting as a known residual cost.

How can I resolve this? If you propose a fix, please make it concise.

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

All verifications complete. Here is the review.


Code Review: feat(blockchain): cap attestation packing at 2/3 supermajority

This PR adds a supermajority cutoff to extend_proofs_greedily so block building stops packing attestation proofs once a target has enough votes to justify (2/3), avoiding expensive recursive SNARK merges for zero consensus gain. The refactor also cleans up score_entry and pick_best_candidate to stop returning the new_voters set, updating the vote projection instead from extend_proofs_greedily's return value. The consensus safety argument is sound and well-documented.


Findings

crates/blockchain/src/block_builder.rs:722-727 — Trace block computes eagerly in the hot path, defeating the optimization for production builds

new_for_proof is a let binding computed before the trace!() macro, so it runs unconditionally:

if !remaining_indices.is_empty() {
    let data_root = att_data.hash_tree_root();       // always executed
    for &idx in &remaining_indices {
        let new_for_proof = proofs[idx]
            .participant_indices()
            .filter(|vid| !covered.contains(vid))
            .count();                                 // always executed
        trace!(...);                                  // only this is guarded

The trace!() macro guards its formatted output against STATIC_MAX_LEVEL, but the two let bindings above it run on every block build that hits the supermajority cutoff — which is exactly the common case this PR optimises for. hash_tree_root() is a full Merkle hash over AttestationData; the participant_indices().filter().count() loop is O(participants × skipped_proofs). In production with trace disabled, this is pure waste.

Fix: wrap the entire block in if tracing::enabled!(tracing::Level::TRACE) { ... }.


crates/blockchain/src/block_builder.rs:429 vs state_transition/src/lib.rs:325is_supermajority duplicates the justification threshold inline in the STF

The new block_builder.rs helper:

fn is_supermajority(count: usize, validator_count: usize) -> bool {
    3 * count >= 2 * validator_count
}

is identical to the inline expression in state_transition/src/lib.rs:325:

if 3 * vote_count >= 2 * validator_count {

These are the two places in the codebase that implement the consensus justification threshold. There is no shared definition in ethlambda-types or anywhere else. A spec change (or a precision fix, e.g. to strict >) would silently diverge block-building behaviour from the STF if only one site is updated.

The named is_supermajority helper is a net improvement; it should be extracted to a shared location (e.g. ethlambda-types) and the state_transition inline replaced with a call to it.


crates/blockchain/src/block_builder.rs:665cutoff_reached is redundant

cutoff_reached is set to true immediately before a break and read only in the trace label. But the two loop exit paths are mutually exclusive with respect to remaining-proof coverage:

  • best_count == 0 break: ALL remaining proofs have new_for_proof == 0 (the greedy picked the max; if max is 0, all are 0).
  • Supermajority break: the only reason a proof with new_for_proof > 0 is still in remaining_indices.

So if cutoff_reached && new_for_proof > 0 at line 728 is equivalent to if new_for_proof > 0 alone. The flag and the conjunction can be dropped.


crates/blockchain/src/block_builder.rs:381score_entry allocates a HashSet<u64> solely for its count

Before the PR, score_entry returned new_voters: HashSet<u64> to callers; now that set is discarded and only new_voters.len() appears in the return value. Cross-proof deduplication is genuinely needed (a validator ID can appear in multiple proofs), so a plain counter won't work as a drop-in — but a counter paired with a HashSet used only for .contains() checks (never stored beyond this function) would be equivalent and communicates the intent more clearly: the caller receives a count, not a set.

This is a minor allocation concern on the per-candidate-per-round scoring path, not a correctness issue.


Automated review by Claude (Anthropic) · sonnet · custom prompt

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant