fix(blockchain): select proof by marginal coverage when proposer aggregation is off#505
fix(blockchain): select proof by marginal coverage when proposer aggregation is off#505MegaRedHand wants to merge 6 commits into
Conversation
…egation is off `keep_best_proof_per_data` collapses same-`AttestationData` proofs to one (a block carries at most one entry per data) when proposer aggregation is disabled. It picked the proof with the most participants by *absolute* count. That starves a small subnet: once the largest subnet's voters are already counted in-state for a target, that subnet keeps winning the collapse yet adds zero new coverage, while the smaller subnet holding the only still-missing votes is dropped every block. The target's in-state coverage caps below the largest single subnet's union and never reaches 2/3, so justification stalls. The aggregation-on path (`compact_attestations`) sidesteps this by merging all same-data proofs into one union-coverage proof. Without leanVM we cannot merge, but we can pick the single proof that adds the most NEW voters over the target's in-state voter set instead of the largest one. Ties fall back to absolute participant count, then first occurrence, so a fresh target keeps the densest proof exactly as before. This also makes the proof-level collapse consistent with `select_attestations`, which already scores the entry on the same marginal coverage.
The docs-cleanup commit reordered imports into an order the pinned rustfmt (1.92.0) does not produce, so `cargo fmt --all -- --check` (run in CI) failed. Re-run `cargo fmt --all` to restore canonical order.
🤖 Kimi Code ReviewOverall Assessment: The PR correctly fixes a liveness issue where smaller attestation subnets could be starved when proposer aggregation is disabled. The logic prioritizes marginal coverage (new voters) over absolute attestation size, aligning non-aggregating behavior with the union-coverage goal of Critical Issues
Minor Issues & Suggestions
Code Quality Positives
Consensus Safety
Action Required Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt |
🤖 Codex Code Review
No other material issues stood out in the diff. I couldn’t run Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewI have enough context from reading the diff and surrounding code. Here's my review. Review:
|
Greptile SummaryThis PR fixes a justification-stalling bug in the non-aggregation proposer path:
Confidence Score: 4/5Safe to merge; the change is isolated to the non-aggregation path and is well-covered by targeted unit tests plus the existing integration test. The core logic is sound and the fix matches the intent described in the PR. crates/blockchain/src/block_builder.rs — specifically the interaction between the
|
| Filename | Overview |
|---|---|
| crates/blockchain/src/block_builder.rs | Core change: keep_best_proof_per_data now selects by marginal voter coverage (new validators relative to in-state justifications_validators) instead of absolute participant count, fixing a justification-stalling bug in the non-aggregation path; two unit tests cover the new and fresh-target cases. Minor inefficiency: build_running_votes is computed twice in the non-aggregation path. The running_votes baseline is also not updated between different AttestationData groups that share the same target root, which is a pre-existing limitation. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[build_block] --> B{enable_proposer_aggregation?}
B -- yes --> C[compact_attestations\nmerge proofs via leanVM]
B -- no --> D[build_running_votes head_state\nper-target in-state voter set]
D --> E[keep_best_proof_per_data\nselected entries + running_votes]
E --> F[Group entries by AttestationData]
F --> G{Single entry\nper data?}
G -- yes --> H[Fast path: return as-is]
G -- no --> I[For each group:\nmax_by_key marginal new voters\n→ absolute count\n→ Reverse idx]
I --> J[Extract winning proof\nper AttestationData]
C --> K[Seal block with compacted attestations]
J --> K
H --> K
%%{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[build_block] --> B{enable_proposer_aggregation?}
B -- yes --> C[compact_attestations\nmerge proofs via leanVM]
B -- no --> D[build_running_votes head_state\nper-target in-state voter set]
D --> E[keep_best_proof_per_data\nselected entries + running_votes]
E --> F[Group entries by AttestationData]
F --> G{Single entry\nper data?}
G -- yes --> H[Fast path: return as-is]
G -- no --> I[For each group:\nmax_by_key marginal new voters\n→ absolute count\n→ Reverse idx]
I --> J[Extract winning proof\nper AttestationData]
C --> K[Seal block with compacted attestations]
J --> K
H --> K
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:120-121
**Redundant `build_running_votes` call in the non-aggregation path**
`build_running_votes(head_state)` is now called twice when `enable_proposer_aggregation` is false: once here (line 120) and once inside `select_attestations` at line 208, where the same `head_state` is used to seed `projected.current_votes`. For large validator sets (O(V × J) scan), this doubles the work. The projected state returned by `select_attestations` already contains the fully-built map; threading it out (or threading the initial map in) would avoid the duplicate computation.
### Issue 2 of 2
crates/blockchain/src/block_builder.rs:704-724
**`running_votes` not updated between data groups sharing the same target root**
When two distinct `AttestationData` entries share the same `target.root` (e.g., same target epoch but different `head` or `source`), both groups are scored against the same static `running_votes` baseline. If D1's winning proof covers validators `{3,7}`, those validators still appear as "new" when scoring D2's candidates, because `running_votes` is never incremented between groups. A proof covering `{3,7}` and one covering `{8,9}` look equally marginal, even though `{3,7}` has already been claimed in this block. This is an edge case (different `AttestationData`, same target root) and is not a regression introduced here, but documenting it near the loop would help future maintainers who might otherwise assume the baseline is always current.
Reviews (1): Last reviewed commit: "style: restore canonical rustfmt import ..." | Re-trigger Greptile
Two distinct AttestationData entries can share one target.root (differing only in slot, head, or source), and the STF unions all their voters onto that target. keep_best_proof_per_data scored each group's candidates only against the frozen in-state snapshot, so a proof whose voters an earlier group already supplied still looked like new coverage. Both groups could then keep the same subnet, leaving the target under-covered and a projected justify/finalize threshold potentially missed. Visit groups in first-occurrence (block) order and accumulate each winner's voters into a running claimed set; marginal coverage is now measured against in-state voters plus that set, mirroring the block's own vote accumulation.
Problem
When proposer aggregation is disabled (the default),
build_blockcannot merge multiple XMSS proofs for the sameAttestationDatainto one, but a block may carry at most one entry per data (on_blockrejects duplicates).keep_best_proof_per_datatherefore collapses each group of same-data proofs to a single survivor.It selected that survivor by absolute participant count (
aggregation_bits.count_ones()). That starves the smallest subnet:Once a 3-validator subnet's voters are already counted in-state for
T, re-including that same subnet adds zero new coverage, yet it keeps winning the collapse because it has the most participants. The 2-validator subnet{3,7}holding the only still-missing votes is dropped every block. In-state coverage forTcaps at 9/11, never crosses 2/3, and justification stalls.The aggregation-on path (
compact_attestations) does not have this problem: it merges all same-data proofs into one union-coverage proof, covering all 11.Fix
keep_best_proof_per_datanow keeps the proof adding the most new voters over the target's in-state voter set (built from state justifications, keyed bytarget.root), instead of the one with the largest absolute participant count.select_attestations, which already scores the entry on the same marginal coverage; previously the two disagreed.No leanVM aggregation is added; this only changes which single proof survives, so the aggregation-off path stays cheap. The aggregation-on path is untouched.
Tests
keep_best_proof_per_data_prefers_marginal_coverage_over_absolute_size(new): the small{3,7}subnet is kept over a{0,1,2}subnet already counted in-state. Written test-first; confirmed failing (left: 3, right: 2) before the fix.keep_best_proof_per_data_keeps_largest_when_target_is_fresh(new): empty in-state voters -> densest proof still wins (no regression).build_block_without_proposer_aggregation_keeps_single_best_proof_per_data(existing): still passes.cargo clippy --all-targets -- -D warningsclean; all 42ethlambda-blockchainlib tests pass.Notes
Draft: opening for review of the approach. An alternative would be to always enable proposer aggregation, but that pays the leanVM cost the flag exists to avoid; this fixes the cheaper default path directly.