fix(state-transition): preserve monotonicity of latest_justified within a block#390
Conversation
…in a block `process_attestations` overwrote `state.latest_justified` unconditionally on every supermajority threshold crossing. When a single block carried multiple supermajority attestations whose targets were not in increasing slot order, a later attestation could clobber an earlier one and leave the post-state `latest_justified` below the highest justified target. The store latches `latest_justified` monotonically across all branches; once a forked block advanced the store ahead of the canonical post-state, every subsequent proposal failed `JustifiedDivergenceNotClosed` and the chain froze. Observed on devnet at slot 27984, where the canonical head carried three supermajority attestations targeting (in body order) slots 27978, 27981, and 27974 — all justifiable from the common source — and the post-state ended with `latest_justified.slot = 27974` instead of 27981. Guard the assignment so `latest_justified` only advances. `justified_slots` is still updated unconditionally, which is correct: multiple slots can be independently justified within a block. Regression test in the same file constructs a 4-validator state and feeds three supermajority attestations in non-monotonic target order; without the guard the test ends at slot 6 instead of the expected slot 9.
🤖 Kimi Code ReviewThis is a critical consensus bugfix. The change prevents Approval with minor notes: Code Correctness
Consensus Safety
Testing
Style/Nits
Security Assessment
The PR is ready to merge. Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt |
🤖 Codex Code ReviewNo findings.
Testing gap: I could not execute Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
Greptile SummaryThis PR fixes a monotonicity bug in
Confidence Score: 5/5Safe to merge — the change is a single-line guard that only narrows when The fix is minimal and precisely targeted: one conditional wrapping one assignment, leaving No files require special attention.
|
| Filename | Overview |
|---|---|
| crates/blockchain/state_transition/src/lib.rs | Single-line monotonicity guard added to process_attestations; comprehensive regression test with correct arithmetic justifiability checks across three out-of-order target slots. |
| crates/blockchain/state_transition/Cargo.toml | Adds libssz-types as a workspace dev-dependency to support SszList construction in the new test. |
| Cargo.lock | Lock file updated to reflect the new libssz-types dev-dependency; no unexpected crate additions. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[process_attestations: iterate over attestations] --> B{is_valid_vote?}
B -- No --> A
B -- Yes --> C[Accumulate votes for target.root]
C --> D{3 × vote_count ≥ 2 × validator_count?}
D -- No --> A
D -- Yes --> E{target.slot > state.latest_justified.slot?}
E -- Yes --> F[state.latest_justified = target]
E -- No / equal --> G[skip — guard preserves monotonicity]
F --> H[justified_slots_ops::set_justified — always called]
G --> H
H --> I[try_finalize — always called]
I --> A
Reviews (1): Last reviewed commit: "fix(state-transition): preserve monotoni..." | Re-trigger Greptile
🤖 Claude Code ReviewNow I have enough context to write a comprehensive review. PR Review: fix(state-transition): preserve monotonicity of
|
Equivalent to the prior `if target.slot > state.latest_justified.slot` guard, expressed in one line.
Description
We recently found a bug in the spec and made a PR patching it: leanEthereum/leanSpec#781. This PR updates our code matching those changes.
What changed
process_attestationsunconditionally assignedstate.latest_justified = targeton every supermajority threshold crossing. When a block carried multiple supermajority attestations whose targets were not in increasing slot order, a later attestation clobbered an earlier one and the post-state ended below the highest justified target.latest_justifiedmonotonically across all branches (crates/blockchain/src/store.rs:485-491). Once any branch advanced the store ahead of the canonical chain's post-state, every subsequent proposal failedJustifiedDivergenceNotClosedand the chain froze permanently.latest_justifiedonly advances.justified_slotscontinues to be updated unconditionally — multiple slots can be independently justified within a block.Observed failure
Devnet froze at slot 27969 (finalized) / 27978 (justified) / 27984 (head). Investigation:
0xef43cf66(slot 27984) carried 3 supermajority attestations in body order targeting slots 27978, 27981, 27974 (all justifiable fromsource=27972).process_attestations, post-statelatest_justified = (27974, ...)instead of(27981, ...), because the last-applied attestation overwrote the higher justifications.0xe88cff27at slot 27983 had advanced the store'slatest_justifiedto 27978.build_blockproducedjustified=27974and failed theJustifiedDivergenceNotClosedcheck atcrates/blockchain/src/store.rs:737. 37,787 proposal failures across 4 validators over 4 days before recovery.Test plan
latest_justified_does_not_regress_within_blockinstate_transition/src/lib.rs: 4-validator state, three supermajority attestations in body order targeting slots 4 → 9 → 6. Fails onmain(ends at slot 6); passes with the fix (stays at slot 9).cargo test -p ethlambda-state-transition --lib— 1/1 passcargo test -p ethlambda-state-transition --test stf_spectests— 51/51 passcargo clippy -p ethlambda-state-transition --all-targets -- -D warnings— clean