fix: tiered attestation scoring for block building#382
Conversation
Replace the target.slot ASC sort + STF-advance fixed point with a score-pick-project greedy loop modeled on Prysm's `sortByProfitability`. Each candidate AttestationData is scored against a projected post-state: - Tier 1: applying the entry finalizes its source (3SF-mini: no justifiable slot in (source.slot, target.slot) given projected finalized_slot, so source and target are consecutive justified checkpoints) - Tier 2: applying the entry justifies its target (crosses 2/3) - Tier 3: adds marginal new voters toward the target's 2/3 supermajority Ordering within a tier prefers more new voters, then smaller target.slot (older chain progress first). The state's `justifications_validators` flattened bitlist seeds a running per-target-root voter set; selecting an entry updates that set, and tier 1/2 selections project justification/finalization onto a projected `justified_slots` / finalized slot so dependent entries become eligible on the next round without re-running the STF. Final STF still runs once at the end for state_root. Splits the logic into focused helpers: `entry_passes_filters` for eligibility checks, `score_entry` for tier assignment, `pick_best_candidate` for the per-round scan, and `select_attestations` for the round loop. Static inputs (candidate pool, chain view, validator count) and mutable projection state (justified slots, finalized slot, voter map) are grouped into `ChainContext` and `ProjectedState`.
Three small cleanups in build_block's scoring pipeline: - Extract `is_genesis_self_vote` to a free fn instead of duplicating the same predicate in `entry_passes_filters` and `score_entry`. - Avoid cloning `prior_voters` HashSet in `score_entry`. Iterate proofs with `contains()` lookups against the existing prior set and collect only the new voters. Cuts allocator pressure on the proposer hot path (clone × candidates × rounds disappears). - Return the new-voters set from `score_entry` / `pick_best_candidate` so `select_attestations` can extend `current_votes` directly, instead of re-scanning aggregation bits on the selected entry to recover the same set. Also collapses the two duplicate tier-3 returns in `score_entry` to a single tier-ladder + single return.
Move build_block and its helpers out of store.rs into a dedicated block_builder module: PostBlockCheckpoints, build_block (the public entry point at the top of the file), select_attestations, pick_best_candidate, ChainContext, ProjectedState, entry_passes_filters, score_entry, EntryScore, build_running_votes, compact_attestations, extend_proofs_greedily, union_aggregation_bits, is_genesis_self_vote, trace_skipped_attestation, plus the related tests. store.rs now imports build_block + PostBlockCheckpoints from block_builder and keeps the on-block import path, attestation validation, verify_block_signatures, and the live store actor.
🤖 Codex Code Review
I couldn’t run the Rust tests here: Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewNow I have enough of the diff to write a thorough review. Let me produce the review output. PR 382 Review: Tiered Attestation Scoring for Block BuildingOverviewThis PR replaces the old
The refactoring is generally clean and well-motivated. Correctness AnalysisProjection vs. STF consistency The tiered algorithm projects justification/finalization incrementally ( Tier 1 finalization predicate ( let finalizes = crosses_2_3
&& (att_data.source.slot + 1..att_data.target.slot)
.all(|s| !slot_is_justifiable_after(s, projected_finalized_slot));When
Potential IssuesPotential Issue 1 — for (i, root) in state.justifications_roots.iter().enumerate() {
// ...
votes.insert(*root, voters); // overwrites if root appears twice
}If Suggested fix: votes.entry(*root).or_default().extend(voters);Potential Issue 2 — Missing test for tier 1 finalization elevation There are tests for the cap ( Potential Issue 3 — Validator count = 0 edge case ( let crosses_2_3 = 3 * total >= 2 * validator_count;If Potential Issue 4 — The Minor / Style Notes
SummaryThe algorithm is correct, the STF safety net is good design, and the module extraction is clean. The main actionable items before merging:
Everything else is minor. The performance improvement (eliminating the inner STF loop) is significant and the projection invariants hold. Automated review by Claude (Anthropic) · sonnet · custom prompt |
Greptile SummaryThis PR replaces the old
Confidence Score: 4/5Safe to merge with two minor hardening suggestions; the final STF run acts as a correctness backstop for any projection divergence. The tiered greedy algorithm is logically sound: new_voters deduplication via HashSet, incremental projection updates, and the finalizes predicate all match the 3SF-mini spec. The two suggestions are defensive quality improvements rather than fixes for observable wrong behavior. crates/blockchain/src/block_builder.rs — the score_entry and tier-1 projection code near lines 197–202 and 352–353 are worth a second look for the guards described in the review comments.
|
| Filename | Overview |
|---|---|
| crates/blockchain/src/block_builder.rs | New 1084-line module containing the tiered greedy attestation selection algorithm. Core logic is sound: new_voters deduplication, projection updates, and finalization predicate all appear correct. Two minor guards are worth adding (zero-validator-count supermajority check and a debug_assert for tier-1 monotonicity). |
| crates/blockchain/src/store.rs | Old build_block, compact_attestations, extend_proofs_greedily, and union_aggregation_bits functions removed; now delegates to block_builder module. Remaining store logic is unchanged and looks clean. |
| crates/blockchain/src/lib.rs | Adds pub mod block_builder declaration. Trivial, correct change. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[build_block] --> B[select_attestations]
B --> C[build_running_votes\nseeds current_votes\nfrom state.justifications_validators]
C --> D{Round loop\nup to MAX_ATTESTATIONS_DATA=16}
D --> E[pick_best_candidate\niterate aggregated_payloads]
E --> F{entry_passes_filters}
F -- fail --> G[trace skipped]
G --> E
F -- pass --> H[score_entry\ncompute new_voters\ntier 1/2/3]
H -- zero new_voters --> G
H -- scored --> I[compare ordering_key\npick best]
I --> D
D -- best candidate found --> J[extend_proofs_greedily\ngreedy proof selection]
J --> K[update projected.current_votes\nwith new_voters]
K --> L{tier <= 2?}
L -- yes --> M[set_justified target.slot\nremove target bucket]
M --> N{tier == 1?}
L -- no --> D
N -- yes --> O[shift_window by delta\nadvance finalized_slot]
O --> D
N -- no --> D
D -- no candidate / cap hit --> P[compact_attestations\nmerge same-data proofs]
P --> Q[process_slots + process_block\nSTF seals state_root]
Q --> R[return Block + PostBlockCheckpoints]
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:352-353
`crosses_2_3` is trivially `true` when `validator_count == 0` because `3 * total >= 0` holds for any non-negative `total`. A chain with zero validators is not a production scenario, but tests that construct a `State` with an empty validator set (e.g. `State::from_genesis(t, vec![])`) will silently mis-tier every entry with at least one participant as tier 2 or tier 1, rather than tier 3. Adding an explicit guard makes the invariant visible and future-proofs unit tests that intentionally use small validator sets.
```suggestion
let total = prior_count + new_voters.len();
let crosses_2_3 = validator_count > 0 && 3 * total >= 2 * validator_count;
```
### Issue 2 of 2
crates/blockchain/src/block_builder.rs:197-202
The tier-1 branch silently accepts `att_data.source.slot < projected.finalized_slot` via `saturating_sub`, which would clamp `delta` to zero while still overwriting `projected.finalized_slot` with the smaller (incorrect) source slot. The combination of `entry_passes_filters` and the `finalizes` range check guarantees `source.slot >= projected.finalized_slot` whenever tier == 1, but this invariant is invisible at the call site. A `debug_assert` makes the guarantee explicit and will cheaply catch regressions if the tier-assignment logic ever changes.
```suggestion
if score.tier == 1 {
let new_finalized = att_data.source.slot;
debug_assert!(
new_finalized >= projected.finalized_slot,
"tier-1 finalization must be monotone: new={new_finalized} < current={}",
projected.finalized_slot
);
let delta = new_finalized.saturating_sub(projected.finalized_slot) as usize;
justified_slots_ops::shift_window(&mut projected.justified_slots, delta);
projected.finalized_slot = new_finalized;
}
```
Reviews (2): Last reviewed commit: "Merge branch 'main' into build-block-sco..." | Re-trigger Greptile
🤖 Kimi Code ReviewOverall this is a high-quality refactor that improves separation of concerns and replaces the iterative STF-loop with a more efficient tiered greedy algorithm. The logic for 3SF-mini justification/finalization projection appears correct. Critical Issues None. High Priority
Medium Priority
Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt |
🤖 Codex Code Review
I did not run Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewNow I have a thorough understanding of the changes. Let me write the review. Review: PR #382 — Tiered Attestation Scoring for Block BuildingOverall: The algorithm is well-conceived and the implementation is correct. The refactoring to Item 1 — Non-deterministic proof selection in
|
Add the two predicates the STF's is_valid_vote enforces but build_block's filter omitted: - target.slot > source.slot (skipped for the genesis self-vote, which is a fork-choice-only carve-out) - slot_is_justifiable_after(target.slot, projected_finalized_slot) Without these, a non-genesis entry with target.slot == source.slot would score as tier 1 (empty (source+1..target) range → finalizes trivially true) and project a finalized slot the STF will never adopt. An unjustifiable target slot could similarly be projected as justified, unlocking dependent entries against a phantom post-state and wasting the 16-entry budget. Also add a regression test exercising the in-loop projection of justified_slots across rounds: round 1 justifies slot 1, round 2 selects an attestation with source=1 that would have failed the source_not_justified filter against the initial state. Addresses PR #382 review feedback (Codex × 2, Claude).
Replace `tier: u8` with a `#[repr(u8)]` enum {Finalize = 1, Justify = 2,
Build = 3}. Variants are declared in priority order so derived `Ord`
preserves the "lower wins" semantic used by `ordering_key`. Trace
output now shows `tier = Finalize` instead of `tier = 1`.
Description / Motivation
Replaces the
target.slotASC sort + STF-advance fixed point inbuild_blockwith a tiered greedy modeled on Prysm'ssortByProfitability. The previous loop committed attestations in arbitrary chain order and re-ran the STF after each batch to detect justification advance, which:MAX_ATTESTATIONS_DATA = 16cap.The new selection algorithm scores each candidate against a projected post-state and picks the highest-tier entry per round.
Scoring tiers
(source.slot, target.slot)is still justifiable perslot_is_justifiable_after, so source and target are consecutive justified checkpoints in the projected post-stateWithin a tier: more
new_voterswins, then smallertarget_slot(older chain progress first), then smalleratt_slot, thendata_rootfor determinism.What Changed
Three commits on this branch:
6336ce75ccc938d59d41bKey code:
crates/blockchain/src/block_builder.rscontainingbuild_block,select_attestations,pick_best_candidate,score_entry,entry_passes_filters, theEntryScore/ChainContext/ProjectedStatetypes, pluscompact_attestationsandextend_proofs_greedily(moved out ofstore.rs).build_running_votes(state)deserializes the flattenedstate.justifications_validatorsbitlist intoHashMap<H256, HashSet<u64>>— the analog of Prysm/Lighthouse's participation flags. Selection seeds from this and updates it incrementally.justified_slotsandfinalized_slothappens in-loop after each tier 1/2 selection so dependent attestations (whose source is the just-justified slot) become eligible on the next round without running the STF.state_rootand validate that the proposed block is sound.Correctness / Behavior Guarantees
process_slots+process_blockstill run on the selected attestations beforestate_rootis set. Any divergence between projection and the actual STF produces an error here.entry_passes_filtersenforces the same predicates the previous loop did (head known, source justified, chain-match, target not already justified) plus the genesis self-vote bypass.(source.slot + 1..target.slot).all(|s| !slot_is_justifiable_after(s, projected_finalized_slot))— matchestry_finalizeexactly, including the implicit "source and target must be consecutive justified checkpoints" constraint (any already-justified slot between them is by construction still justifiable).MAX_ATTESTATIONS_DATA = 16distinctAttestationDataentries.(source.slot == 0 && target.slot == 0)entries are scored as tier 3 (fork-choice only) and bypass the "target already justified" filter.Tests Added / Run
ethlambda-blockchain(incl.build_block_caps_attestation_data_entriesandbuild_block_absorbs_older_but_justified_source), 84 fork-choice spec tests, 11 signature spec tests. Fullmake test= 415 passed / 0 failed / 6 ignored.block_builder::tests.Related Issues / PRs
proposerAtts.sortByProfitabilityinbeacon-chain/operations/attestations/.Verification Checklist
make fmt— cleanmake lint(clippy with-D warnings) — cleancargo test --workspace --release— all passing