fix(blockchain): make proof selection tie-breaking deterministic - #590
Conversation
extend_proofs_greedily kept its remaining candidate proofs in a HashSet<usize> and picked the best-coverage proof with max_by_key over the set's randomized iteration order, so equal-coverage ties were broken arbitrarily per process: the same store state could produce blocks with different aggregation bits from one run to the next. Iterate candidates in index order and break coverage ties toward the lowest index (pool insertion order), making block building reproducible for a given pool. The regression test builds a six-way coverage tie, so an arbitrary order cannot reproduce pool order by luck; against the previous code it fails on most runs. Found while building an offline block-building benchmark, whose same-seed determinism check reported differing block roots across runs of an identical workload.
🤖 Kimi Code ReviewOverall Assessment: This PR correctly fixes a consensus-critical determinism bug. The change ensures that block construction is deterministic across all nodes by replacing hash-iteration-order dependency with explicit index-based tie-breaking. Critical Consensus Fix (Positive)
Code Correctness
Performance
Testing
Nitpick
Verdict: Approve after verifying Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt |
🤖 Codex Code Review
I did not find a correctness, security, or consensus-layer bug in the production change itself. The Validation note: I could not run Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewI now have enough to write the review. Review:
|
…daclass#592) ## 🗒️ Description / Motivation Follow-up to lambdaclass#590, which made the coverage tie-break in `extend_proofs_greedily` deterministic and left the loop around it with three exits for two conditions. ## What Changed | File | Change | |------|--------| | `crates/blockchain/src/block_builder.rs` | `while !remaining_indices.is_empty()` made the `None` arm of the best-candidate lookup unreachable, and that arm in turn made the `proofs.is_empty()` early return redundant. Now one `loop` with one exit — no candidate, or none that adds coverage — and `best_count` no longer needs a binding just to be compared against zero. The comment records that candidates must stay in ascending order, so the `retain` is not later "optimized" into a `swap_remove` that would reintroduce the arbitrary tie-break lambdaclass#590 removed | ## Correctness / Behavior Guarantees Pure refactor — same selection, same order, same stopping point. The removed guards were unreachable or redundant, not load-bearing. ## Tests Added / Run - `extend_proofs_greedily_selects_nothing_from_an_empty_pool` pins the path the early return used to shortcut. - `make fmt`, `make lint`, and the 68 `ethlambda-blockchain` lib tests — all clean on current main. ## Related Issues / PRs - Follow-up to lambdaclass#590 ## ✅ 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
🗒️ Description / Motivation
extend_proofs_greedilykept its remaining candidate proofs in aHashSet<usize>andpicked the best-coverage proof with
max_by_keyover the set's randomized iterationorder, so equal-coverage ties were broken arbitrarily per process: the same store state
could produce blocks with different aggregation bits from one run to the next.
Found while building the offline block-building benchmark (#497), whose same-seed
determinism check reported differing block roots across runs of an identical workload.
Split out of that PR because it is a standalone node-behavior fix, unrelated to the
harness.
What Changed
crates/blockchain/src/block_builder.rsremaining_indicesis aVec<usize>iterated in index order; coverage ties break toward the lowest index (pool insertion order) viamax_by_key((count, Reverse(idx)))Correctness / Behavior Guarantees
every round. Only the choice among equal-coverage candidates changes, and that
choice was previously random.
baseline-vs-optimized benchmark comparison meaningful.
Tests Added / Run
extend_proofs_greedily_breaks_coverage_ties_by_pool_order: six disjoint proofs ofidentical coverage, so every round is again a six-way tie and selection order is
decided purely by the tie-break. An arbitrary order cannot match pool order by luck
(1 in 720); against the previous code the test fails on most runs.
make fmt,make lint,make test— all clean.Related Issues / PRs
✅ Verification Checklist
make fmt— cleanmake lint(clippy with-D warnings) — cleanmake test(cargo test --workspace --profile release-fast) — all passing