fix(blockchain): select proof by marginal coverage when proposer aggregation is off - #505
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.
## 🗒️ Description / Motivation
Restarting a node without `--checkpoint-sync-url` destroyed its chain.
`fetch_initial_state` gated the on-disk state lookup on that flag:
```rust
if checkpoint_urls.is_empty() {
info!("No checkpoint sync URL provided, initializing from genesis state");
let genesis_state = State::from_genesis(genesis.genesis_time, validators);
return Ok(Store::from_anchor_state(backend, genesis_state));
};
// ... only past this point was Store::from_db_state tried
```
So a redeploy against a populated RocksDB wrote a slot-0 genesis anchor
over a perfectly current chain, and operators had to pass a checkpoint
URL purely as a *fallback trigger* even when the DB was fresh and the
URL was never fetched.
This makes on-disk state authoritative: `--checkpoint-sync-url` becomes
a fallback for when there is nothing resumable, not a precondition for
reading what is there.
## What Changed
**`bin/ethlambda/src/main.rs`** — `fetch_initial_state` tries
`Store::from_db_state` *before* the empty-URL early return:
```
gap = current_slot − store.head_slot()
gap ≤ MAX_RESUMABLE_DB_STATE_AGE → resume from DB info!
gap > MAX, no checkpoint URLs → resume from DB warn! (new)
gap > MAX, checkpoint URLs set → fall through to checkpoint sync
no resumable DB, checkpoint URLs → checkpoint sync
no resumable DB, no URLs → genesis
```
Also removes the `info!(url_count, "Starting checkpoint sync")` that was
emitted *before* the DB was consulted. It fired on every successful
resume, so grepping a boot log for `"Starting checkpoint sync"`
false-positived on nodes that never synced. Each outcome now logs
exactly one line, at the point the decision is made:
| Boot log line | Outcome |
| --- | --- |
| `Resuming from existing DB head_slot=… current_slot=… gap=…` | Resumed
from disk, nothing downloaded |
| `DB is stale; resuming anyway head_slot=… current_slot=… gap=…` |
Resumed past the window, no URL to prefer |
| `DB is stale; checkpoint sync head_slot=… current_slot=… gap=…` | Past
the window, a URL took over |
| `Starting checkpoint sync checkpoint_urls=[…]` | Downloading a
checkpoint |
| `No checkpoint sync URL provided, initializing from genesis state` |
Started from genesis |
**`bin/ethlambda/src/cli.rs`** — `--checkpoint-sync-url` help text no
longer claims it "skips genesis initialization"; it is documented as a
fallback.
**`docs/checkpoint_sync.md`** — new *Restarts and Existing State*
section: precedence table, the resume window and why it is measured
against the head rather than the finalized checkpoint, the P2P-catch-up
caveat, and why an all-URLs-fail abort is intentional.
## Correctness / Behavior Guarantees
| DB state (matching `GENESIS_TIME`) | `--checkpoint-sync-url` | before
| after |
| --- | --- | --- | --- |
| absent | omitted | genesis | genesis |
| absent | set | checkpoint sync | checkpoint sync |
| fresh (head-lag ≤ 450) | omitted | **genesis, resets to slot 0** |
**resume** |
| fresh | set | resume | resume |
| stale (head-lag > 450) | omitted | **genesis, resets to slot 0** |
**resume + warning** |
| stale | set | checkpoint sync | checkpoint sync |
- `MAX_RESUMABLE_DB_STATE_AGE` keeps its value and its meaning in the
URL-present case; it now only decides *whether a checkpoint is
preferable to what we already have*, never whether the DB is readable.
- Staleness is still measured against the head (`current_slot -
head_slot`), so a node whose head is current resumes during a finality
stall.
- **Stale DB + no URL resumes rather than refusing to boot.** No
checkpoint URL was configured, so there is no anchor to switch to and
the node runs against the data directory it was given. The warning
exists because range sync may not close a gap this large: peers prune
block signatures past `SIGNATURE_PRUNING_RANGE` (~1 day), so beyond that
horizon they cannot serve the missing history and the node needs a
checkpoint URL. Refusing to start instead would break unattended
restarts after a routine 31-minute outage.
- **Stale DB + URLs set + every URL failing still aborts.** Deliberate,
and documented as such: configuring the flag asks for a specific anchor,
so an unreachable source is a misconfiguration to surface at boot rather
than paper over by starting a node that is hours behind. Omitting the
flag is how you ask for "resume whatever is on disk"; that path never
aborts.
- **No new flag.** Omitting `--checkpoint-sync-url` no longer means
"start from genesis" when a DB exists; to deliberately start over,
remove the data directory. That is already the documented idiom for a
clean checkpoint sync, and an `--ignore-existing-db` flag would only
reintroduce the write-genesis-over-live-data footgun behind a flag.
- **Unchanged / out of scope:** a `GENESIS_TIME` mismatch still degrades
silently (`from_db_state` logs `"Persisted DB has a different
genesis_time; treating as empty"`), so with no URL the node writes
genesis over a foreign-network DB. Pre-existing behavior, addressed
separately in #556;
`initializes_from_genesis_when_db_genesis_time_differs` pins it here as
a known hazard rather than a desired invariant.
## Tests Added / Run
Six unit tests in `bin/ethlambda/src/main.rs` driving
`fetch_initial_state` against `InMemoryBackend`:
| Test | Gap | Asserts |
| --- | --- | --- |
| `initializes_from_genesis_when_db_is_empty` | — | head slot 0 |
| `resumes_from_fresh_db_without_checkpoint_url` | `MAX / 2` | head slot
is the seeded slot, not 0 |
| `resumes_from_stale_db_without_checkpoint_url` | `MAX + 100` | resumes
despite `gap > MAX_RESUMABLE_DB_STATE_AGE` |
| `resumes_from_fresh_db_with_checkpoint_url` | `= MAX` | resume wins
over a URL; nothing is dialed |
| `falls_through_to_checkpoint_sync_when_db_is_stale` | `MAX + 1` | past
the window the URL takes over, and an unreachable one aborts |
| `initializes_from_genesis_when_db_genesis_time_differs` | — | head
slot 0 (DB treated as empty) |
The seeded anchor sits above slot 0 because a genesis re-init also
yields head slot 0; that is what makes "resumed" distinguishable from
"started over". Staleness is induced purely by choosing `genesis_time`
(`current_slot` derives from the wall clock against it), so no clock
injection.
The two no-URL resume tests cannot pin the threshold on their own: both
no-URL branches return the same store, so inverting the comparison
leaves them green. The pair that can are the two with a URL set, where
the outcomes differ. Verified by mutation:
| Mutation | Result |
| --- | --- |
| `gap <= MAX` → `gap > MAX` | both URL tests fail, the four others pass
|
| `gap <= MAX` → `gap < MAX` | the `= MAX` boundary test fails |
Those two use `#[tokio::test(start_paused = true)]` so the checkpoint
retry backoff (5 attempts × 5s) costs no wall clock; the connection
refusal against `http://127.0.0.1:1` is immediate. That needs tokio's
`test-util` feature as a **dev**-dependency, so it never reaches the
shipped binary.
Commands run:
```
cargo test -p ethlambda --profile release-fast --bin ethlambda # 33 passed
make fmt && make lint && make test # all clean (550 passed, 7 pre-existing ignored)
```
Local multi-client devnet verification is in progress; I'll post the
boot logs showing a keep-DB restart with no `--checkpoint-sync-url` as a
comment.
## Related Issues / PRs
- Related to #505 (head-lag resume gate, which this builds on)
- #556 rejects a data directory from another network, covering the
`GENESIS_TIME`-mismatch hazard this PR only pins
- #560 carries the unrelated `CLAUDE.md` RPC-port note that was
originally in this branch
- #559 tracks a pre-existing bug this PR makes easier to hit: the duty
sync gate reports Synced while a node backfills from a stale resume, so
it attests and proposes on an old head. Not addressed here
- Logging a `Store::from_db_state` read error instead of discarding it
(the `Err` arm of `if let Ok(Some(_))`, unreachable today) is left to a
follow-up PR
## ✅ Verification Checklist
- [x] Ran `make fmt` — clean
- [x] Ran `make lint` (clippy with `-D warnings`) — clean
- [x] Ran `make test` (`cargo test --workspace --profile release-fast`)
— all passing
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.