Skip to content

Use original finalized slot for justifiability and finalization gap checks#212

Closed
pablodeymo wants to merge 1 commit intomainfrom
fix/use-original-finalized-slot-in-attestation-processing
Closed

Use original finalized slot for justifiability and finalization gap checks#212
pablodeymo wants to merge 1 commit intomainfrom
fix/use-original-finalized-slot-in-attestation-processing

Conversation

@pablodeymo
Copy link
Collaborator

Motivation

A spec-to-code compliance audit identified a consensus-critical divergence between ethlambda and the leanSpec Python reference implementation in how process_attestations references the finalized slot.

The leanSpec (state.py) uses two distinct references to the finalized slot during attestation processing:

Reference Mutability Used for
self.latest_finalized.slot Immutable (original value at start of process_attestations) Justifiability check — Rule 6 (state.py L516), Finalization gap check (state.py L570)
finalized_slot (local variable) Mutable (updated when finalization advances at L575) is_slot_justified — Rules 1-2 (state.py L459, L466), set_justified (L550), window shift (L584)

The Rust code was using state.latest_finalized.slot for all of these. Because state is &mut State, this value changes when finalization advances mid-processing (at the former lib.rs L373), making it behave like the spec's local mutable finalized_slot everywhere — including the two places where the spec deliberately uses the original immutable value.

The divergence

When a block contains multiple attestation groups and the first group triggers finalization (advancing latest_finalized.slot), subsequent attestations are checked against the new finalized slot instead of the original. This affects:

  1. Vote validation Rule 6 (slot_is_justifiable_after): A target slot's justifiability depends on its delta from the finalized slot. Changing the finalized slot changes the delta, which can flip the justifiability result (e.g., delta=11 is not justifiable, but delta=6 is — it's a pronic number).

  2. Finalization gap check: Whether finalization can advance depends on there being no justifiable slots in the gap between source and target. Using the wrong finalized slot reference changes which slots are considered justifiable in that gap.

Concrete example

Consider finalized_slot=0, and a block with two attestation groups:

  • Group 1 triggers finalization, advancing latest_finalized.slot from 0 to 5
  • Group 2 has a target at slot 11

For Rule 6 on Group 2:

  • Spec (original finalized=0): delta = 11 - 0 = 11 → NOT justifiable (not ≤5, not a perfect square, not pronic) → vote rejected
  • Code before fix (updated finalized=5): delta = 11 - 5 = 6 → IS justifiable (6 = 2×3, pronic) → vote accepted

This produces different post-states (different justified_slots, latest_justified, latest_finalized), leading to different state roots and a consensus split between implementations.

Exploitability

A block builder who includes attestations in a specific order can trigger this divergence. The attacker constructs a block where one attestation group causes finalization, and a subsequent group's validity changes based on which finalized slot reference is used.

Description

The fix captures the original finalized slot before the attestation loop and passes it explicitly to the two functions that need it:

  • process_attestations: Captures let original_finalized_slot = state.latest_finalized.slot; before the loop
  • is_valid_vote: Accepts original_finalized_slot as a parameter, uses it for Rule 6 (slot_is_justifiable_after). Rules 1-2 (is_slot_justified) continue using state.latest_finalized.slot (mutable), correctly matching the spec's local finalized_slot
  • try_finalize: Accepts original_finalized_slot as a parameter, uses it for the finalization gap check

Spec references

  • leanSpec/src/lean_spec/subspecs/containers/state/state.py
    • L428-430: latest_finalized = self.latest_finalized / finalized_slot = latest_finalized.slot (local mutable copy)
    • L459, L466: justified_slots.is_slot_justified(finalized_slot, ...) (local mutable — Rules 1-2)
    • L516: target.slot.is_justifiable_after(self.latest_finalized.slot) (original immutable — Rule 6)
    • L570: Slot(slot).is_justifiable_after(self.latest_finalized.slot) (original immutable — gap check)
    • L575: finalized_slot = latest_finalized.slot (local mutable updated after finalization)

How to Test

  1. cargo test --workspace --release — all 108 tests pass (5 ignored: slow crypto tests)
  2. cargo clippy --workspace -- -D warnings — clean
  3. cargo fmt --all -- --check — clean
  4. The divergence requires a block with multiple attestation groups where the first triggers finalization — this is a rare but constructible scenario in multi-validator devnets. A targeted spec test exercising this path would further strengthen coverage.

…hecks

The spec uses self.latest_finalized.slot (immutable) for the justifiability
check (Rule 6) and the finalization gap check, while using a separate local
mutable finalized_slot for is_slot_justified (Rules 1-2) and window shifts.

The code was using state.latest_finalized.slot (mutable) for all of these,
which diverges from the spec when finalization advances mid-processing: the
first attestation group can trigger finalization, changing latest_finalized.slot
and altering the justifiability check for subsequent attestations.

Capture original_finalized_slot before the attestation loop and pass it to
is_valid_vote (Rule 6) and try_finalize (gap check) to match the spec.
@github-actions
Copy link

🤖 Kimi Code Review

Review Summary

The PR correctly implements the consensus spec requirement to use the original finalized slot (before attestation processing) for justifiability checks. This prevents a critical consensus bug where finalization could incorrectly advance based on attestations processed within the same state transition.

Issues Found

1. Critical Bug in try_finalize (line 386-390)

The range check ((source.slot + 1)..target.slot) is exclusive of target.slot, but the spec requires checking inclusive range. This could miss valid finalization cases.

Fix:

// Change line 386-387 from:
if ((source.slot + 1)..target.slot)
    .any(|slot| slot_is_justifiable_after(slot, original_finalized_slot))

// To:
if ((source.slot + 1)..=target.slot)
    .any(|slot| slot_is_justifiable_after(slot, original_finalized_slot))

2. Performance Concern in is_valid_vote (line 341-344)

The historical_block_hashes lookup is performed twice (lines 341 and 344) for the same root. This could be optimized.

Fix:

// Cache the lookup result:
let source_exists = state.historical_block_hashes.contains_key(&source.root);
let target_exists = state.historical_block_hashes.contains_key(&target.root);

if !source_exists || !target_exists {
    return false;
}

3. Code Clarity (line 252)

The comment for is_valid_vote should be updated to reflect the new parameter.

Fix:

// Update the comment to include:
// 6. Target slot is justifiable after the original finalized slot (immutable during processing)

Positive Notes

  • Correctly captures original_finalized_slot at the start of processing
  • Properly passes it through all validation functions
  • Maintains immutability of the finalized slot reference during attestation processing
  • No memory safety issues or panics introduced

Automated review by Kimi (Moonshot AI) · custom prompt

@github-actions
Copy link

🤖 Codex Code Review

No blocking findings in this diff.

  1. Correctness: the switch to original_finalized_slot in vote validity and finalization-gap checks matches the intended “immutable finalized boundary during attestation batch” behavior and avoids order-dependent outcomes inside a single process_attestations run.
    References: lib.rs#L219, lib.rs#L254, lib.rs#L296, lib.rs#L364, lib.rs#L388.

  2. Security/perf/memory-safety: no new unsafe patterns, no new panic surface from this change, and no meaningful performance regression (single captured u64, passed through call chain).

  3. Testing gap (recommended): add a regression test where latest_finalized advances mid-batch and later attestations are still evaluated against the pre-batch finalized slot for Rule 6 / gap checks.
    Good target areas: lib.rs#L208, lib.rs#L321, lib.rs#L376.

Validation note: I could not run tests in this environment due sandbox/toolchain constraints (rustup temp permission issue and offline git dependency fetch for spawned-concurrency).


Automated review by OpenAI Codex · custom prompt

@github-actions
Copy link

🤖 Claude Code Review

Here is the review:


Review: Use original finalized slot for justifiability and finalization gap checks

Summary: Consensus-critical bug fix. The change is correct, minimal, and well-aligned with the leanSpec reference implementation.


Correctness

The core fix is sound. The spec (state.py) uses two distinct finalized-slot references during process_attestations:

  • self.latest_finalized.slot — the original pre-loop value, used for Rule 6 (is_justifiable_after) and the finalization gap check.
  • finalized_slot local variable — mutable, updated after each finalization within the loop, used for is_slot_justified (Rules 1-2) and set_justified.

The Rust code now correctly maps these:

  • original_finalized_slot → corresponds to Python's self.latest_finalized.slot (immutable pre-loop snapshot). ✓
  • state.latest_finalized.slot → corresponds to Python's local finalized_slot (mutated by try_finalize). ✓

Specifically:

  • is_valid_vote Rule 6 (lib.rs:364): correctly switched from state.latest_finalized.slot to original_finalized_slot. ✓
  • try_finalize gap check (lib.rs:388): correctly switched. ✓
  • is_slot_justified Rules 1-2 (lib.rs:330, lib.rs:340): correctly left using state.latest_finalized.slot. ✓
  • set_justified (lib.rs:278-282): correctly left using state.latest_finalized.slot. ✓

The invariant also holds for multiple finalization events within a single call to process_attestations: every invocation of try_finalize uses the same original_finalized_slot (pre-loop), matching the spec's frozen self.latest_finalized.slot, while state.latest_finalized.slot advances correctly as each finalization is applied.


Pre-existing concern (not introduced by this PR)

lib.rs:415:

justifications.retain(|root, _| {
    let slot = root_to_slot[root];
    slot > state.latest_finalized.slot
});

The index operator root_to_slot[root] will panic if root is present in justifications but absent from root_to_slot. This is defensible in practice — justifications_roots should only contain roots that appear in historical_block_hashes beyond the finalized slot — but it is not statically guaranteed. Consider a future root_to_slot.get(root) with a logged warning or error return if the invariant can ever be violated. This is out of scope for this PR but worth tracking.


Testing gap

The PR description correctly notes the divergence requires a block with multiple attestation groups where the first triggers finalization — a rare but constructible scenario. No regression test is added. A targeted spec test exercising this exact path (two attestation groups, first causes finalization, second group's validity changes based on which finalized slot is used) would close this coverage gap and prevent accidental reintroduction.


Minor notes

  • The comment block added at lib.rs:215-219 is accurate and clearly explains the invariant. Good.
  • try_finalize now has 6 parameters. This is fine; extracting a struct would be over-engineering for this case.
  • original_finalized_slot is a clear, unambiguous name.
  • The concrete example in the PR description (delta=11 vs delta=6 with finalized_slot 0 vs 5) is a good illustration of exploitability.

Overall

The fix is correct, the implementation is clean, and the comments are accurate. The exploitability analysis is on point — a block builder controlling attestation ordering could trigger a consensus split across clients. Approve pending the suggestion to add a targeted regression test.


Automated review by Claude (Anthropic) · custom prompt

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 13, 2026

Greptile Summary

This PR restores a frozen original_finalized_slot snapshot (captured before the attestation loop) and passes it to is_valid_vote (for Rule 6 / slot_is_justifiable_after) and try_finalize (for the finalization gap check), while keeping the live state.latest_finalized.slot for is_slot_justified (Rules 1–2). The motivation is a claimed spec-compliance audit showing that the leanSpec Python reference uses self.latest_finalized.slot — which is immutable during the loop — for those two checks.

  • Critical spec conflict: This PR directly reverses the behavior introduced by commit 9c7f5f6 (merged one day prior), which itself was an explicit spec-alignment fix referencing leanSpec PR #443. That PR stated the spec was changed to use the updated latest_finalized.slot. The two changes are mutually contradictory; one of them must be misreading the current spec state, and merging without resolution risks a consensus split.
  • Multi-call semantics of try_finalize: try_finalize can be called multiple times within one process_attestations invocation. After the first successful finalization, state.latest_finalized.slot advances, but all subsequent gap checks still use the frozen original_finalized_slot. Whether this is correct depends entirely on whether the Python spec mutates self.latest_finalized within the loop or only after it.
  • No test coverage for the divergence scenario: The PR acknowledges a targeted fixture (multi-attestation block where the first group triggers finalization) does not yet exist. The current 108 tests cannot distinguish the old from the new behavior for this edge case.

Confidence Score: 2/5

  • Not safe to merge until the direct conflict with the spec update in commit 9c7f5f6 / leanSpec PR #443 is explicitly resolved.
  • The change is internally consistent and the PR description provides a well-reasoned analysis. However, it is in direct conflict with a deliberately-merged fix (commit 9c7f5f6) from the day before, which was itself an emergency spec-alignment change that removed the original_finalized_slot snapshot. Without confirmation that leanSpec PR #443 has been reverted or superseded — and without a test fixture covering the triggering scenario — it is impossible to determine which implementation is correct. A wrong merge here causes a consensus split, which is the exact failure mode the PR is trying to prevent.
  • crates/blockchain/state_transition/src/lib.rs — specifically the original_finalized_slot capture at line 219 and its use in try_finalize at line 388, which conflict with commit 9c7f5f6.

Important Files Changed

Filename Overview
crates/blockchain/state_transition/src/lib.rs Re-introduces original_finalized_slot snapshot for Rule 6 and finalization gap checks — directly conflicts with the behavior added by commit 9c7f5f6 (PR #203, merged one day prior) which explicitly followed leanSpec PR #443 to use the live state.latest_finalized.slot; spec authority needs resolution before merging.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[process_attestations starts] --> B["Snapshot: original_finalized_slot = state.latest_finalized.slot"]
    B --> C[For each attestation in block]
    C --> D["is_valid_vote(state, source, target, original_finalized_slot)"]

    D --> E["Rules 1-2: is_slot_justified\nuses state.latest_finalized.slot\n(live / mutable)"]
    D --> F["Rule 6: slot_is_justifiable_after\nuses original_finalized_slot\n(frozen snapshot)"]

    E --> G{Vote valid?}
    F --> G

    G -- No --> C
    G -- Yes --> H[Record vote, check supermajority]

    H --> I{Supermajority reached?}
    I -- No --> C
    I -- Yes --> J["set_justified (uses live state.latest_finalized.slot)"]
    J --> K["try_finalize(state, source, target, original_finalized_slot, ...)"]

    K --> L["Gap check: slot_is_justifiable_after\nuses original_finalized_slot\n(frozen snapshot)"]
    L --> M{Any justifiable slot\nin gap?}
    M -- Yes --> N[Abort finalization]
    N --> C
    M -- No --> O["state.latest_finalized = source\n(live state UPDATED here)"]
    O --> P[Shift justified_slots window]
    P --> Q[Prune stale justifications]
    Q --> C

    C --> R[serialize_justifications]

    style B fill:#f9c74f,stroke:#f3722c
    style F fill:#f9c74f,stroke:#f3722c
    style L fill:#f9c74f,stroke:#f3722c
    style E fill:#90be6d,stroke:#43aa8b
    style J fill:#90be6d,stroke:#43aa8b
    style O fill:#e63946,stroke:#c1121f,color:#fff
Loading

Comments Outside Diff (1)

  1. crates/blockchain/state_transition/src/lib.rs, line 249-256 (link)

    No regression test for the triggering scenario

    The PR description itself notes: "A targeted spec test exercising this path would further strengthen coverage." The specific scenario — a block with multiple attestation groups where the first group triggers finalization — is the only observable way to distinguish the old behavior from the new. Without a test fixture, the existing 108 tests cannot verify the fix is correct nor catch a future accidental regression.

    Consider adding a test fixture to leanSpec/fixtures/consensus/state_transition/ (or a Rust unit test) that:

    1. Sets latest_finalized.slot = 0.
    2. Includes a block with two attestation groups: group 1 triggers finalization (advancing to slot 5), group 2 has a target at slot 11.
    3. Asserts the expected post-state differs from the pre-fix behavior.
    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: crates/blockchain/state_transition/src/lib.rs
    Line: 249-256
    
    Comment:
    **No regression test for the triggering scenario**
    
    The PR description itself notes: *"A targeted spec test exercising this path would further strengthen coverage."* The specific scenario — a block with multiple attestation groups where the first group triggers finalization — is the only observable way to distinguish the old behavior from the new. Without a test fixture, the existing 108 tests cannot verify the fix is correct nor catch a future accidental regression.
    
    Consider adding a test fixture to `leanSpec/fixtures/consensus/state_transition/` (or a Rust unit test) that:
    1. Sets `latest_finalized.slot = 0`.
    2. Includes a block with two attestation groups: group 1 triggers finalization (advancing to slot 5), group 2 has a target at slot 11.
    3. Asserts the expected post-state differs from the pre-fix behavior.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: crates/blockchain/state_transition/src/lib.rs
Line: 215-219

Comment:
**Directly conflicts with the recently-merged commit #203 (`9c7f5f6`)**

This PR re-introduces `original_finalized_slot` and uses it for Rule 6 and the finalization gap check — but commit `9c7f5f6` (merged yesterday, March 12) *explicitly removed this snapshot* to align with [leanSpec PR #443](https://github.com/leanEthereum/leanSpec/pull/443). That commit's message states:

> "The above PR in leanSpec removed the use of the `original_finalized_slot` snapshot and changed it to using the updated `latest_finalized.slot` from state."

The two PRs are directly contradictory about what the canonical spec requires:

| Commit #203 / leanSpec PR #443 | This PR #212 |
|---|---|
| Use live `state.latest_finalized.slot` for Rule 6 & gap check | Use frozen `original_finalized_slot` for Rule 6 & gap check |

If leanSpec PR #443 is still merged and canonical, then this PR reintroduces the divergence that #203 was rushed in (pre-devnet) to fix, and will cause state root mismatches against other clients that follow that spec update.

Before merging, the PR needs to explicitly address one of:
1. leanSpec PR #443 has been **reverted** or superseded — link to the current leanSpec commit that confirms the original-snapshot semantics.
2. The spec is being updated back to original-snapshot semantics — the PR should reference the leanSpec PR that makes this change.
3. Other client implementations have been audited and agree with this reading of the spec.

Without resolving this, merging risks a consensus split in the opposite direction from the one described in the PR description.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: crates/blockchain/state_transition/src/lib.rs
Line: 387-391

Comment:
**Gap check uses frozen slot across multiple `try_finalize` calls**

`try_finalize` can be invoked more than once within a single `process_attestations` call (once per attestation that crosses the supermajority threshold). After the first successful call, `state.latest_finalized.slot` is advanced, but subsequent calls still use the same `original_finalized_slot`.

Consider a block with three attestation groups:
- Group 1 justifies slot 5 → `try_finalize` is called, `state.latest_finalized.slot` advances from 0 → 5.
- Group 2 justifies slot 12 → `try_finalize` is called with `original_finalized_slot = 0`. The gap `(5+1)..12 = [6,7,8,9,10,11]` is checked against `original_finalized_slot=0`. `delta=6` is pronic → justifiable → finalization blocked.
- If instead we used the *updated* `state.latest_finalized.slot=5`, delta for slot 6 would be 1 ≤ 5 → justifiable → same result, but for slot 7 delta=2 (pronic: no; square: no; ≤5: no… wait 2 ≤ 5 → yes). Different justifiability windows could yield different finalization decisions.

The PR description carefully explains the two-reference distinction in the Python spec, but it's worth explicitly verifying that the Python `self.latest_finalized` is **never mutated** mid-loop (i.e., the spec writes the final result back to `self.latest_finalized` only after the full loop completes, keeping `self.latest_finalized.slot` constant at L516 and L570). If Python does update `self.latest_finalized` within the loop, then subsequent iterations would see the new slot — the same behavior that `state.latest_finalized.slot` exhibits in the Rust code — and `original_finalized_slot` would be wrong here too.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: crates/blockchain/state_transition/src/lib.rs
Line: 249-256

Comment:
**No regression test for the triggering scenario**

The PR description itself notes: *"A targeted spec test exercising this path would further strengthen coverage."* The specific scenario — a block with multiple attestation groups where the first group triggers finalization — is the only observable way to distinguish the old behavior from the new. Without a test fixture, the existing 108 tests cannot verify the fix is correct nor catch a future accidental regression.

Consider adding a test fixture to `leanSpec/fixtures/consensus/state_transition/` (or a Rust unit test) that:
1. Sets `latest_finalized.slot = 0`.
2. Includes a block with two attestation groups: group 1 triggers finalization (advancing to slot 5), group 2 has a target at slot 11.
3. Asserts the expected post-state differs from the pre-fix behavior.

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: f58af17

Comment on lines +215 to +219
// Capture the original finalized slot before attestation processing.
// The spec uses self.latest_finalized.slot (immutable) for justifiability
// checks (Rule 6) and finalization gap checks, while using a local mutable
// finalized_slot for is_slot_justified (Rules 1-2) and window shifts.
let original_finalized_slot = state.latest_finalized.slot;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Directly conflicts with the recently-merged commit #203 (9c7f5f6)

This PR re-introduces original_finalized_slot and uses it for Rule 6 and the finalization gap check — but commit 9c7f5f6 (merged yesterday, March 12) explicitly removed this snapshot to align with leanSpec PR #443. That commit's message states:

"The above PR in leanSpec removed the use of the original_finalized_slot snapshot and changed it to using the updated latest_finalized.slot from state."

The two PRs are directly contradictory about what the canonical spec requires:

Commit #203 / leanSpec PR #443 This PR #212
Use live state.latest_finalized.slot for Rule 6 & gap check Use frozen original_finalized_slot for Rule 6 & gap check

If leanSpec PR #443 is still merged and canonical, then this PR reintroduces the divergence that #203 was rushed in (pre-devnet) to fix, and will cause state root mismatches against other clients that follow that spec update.

Before merging, the PR needs to explicitly address one of:

  1. leanSpec PR #443 has been reverted or superseded — link to the current leanSpec commit that confirms the original-snapshot semantics.
  2. The spec is being updated back to original-snapshot semantics — the PR should reference the leanSpec PR that makes this change.
  3. Other client implementations have been audited and agree with this reading of the spec.

Without resolving this, merging risks a consensus split in the opposite direction from the one described in the PR description.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/blockchain/state_transition/src/lib.rs
Line: 215-219

Comment:
**Directly conflicts with the recently-merged commit #203 (`9c7f5f6`)**

This PR re-introduces `original_finalized_slot` and uses it for Rule 6 and the finalization gap check — but commit `9c7f5f6` (merged yesterday, March 12) *explicitly removed this snapshot* to align with [leanSpec PR #443](https://github.com/leanEthereum/leanSpec/pull/443). That commit's message states:

> "The above PR in leanSpec removed the use of the `original_finalized_slot` snapshot and changed it to using the updated `latest_finalized.slot` from state."

The two PRs are directly contradictory about what the canonical spec requires:

| Commit #203 / leanSpec PR #443 | This PR #212 |
|---|---|
| Use live `state.latest_finalized.slot` for Rule 6 & gap check | Use frozen `original_finalized_slot` for Rule 6 & gap check |

If leanSpec PR #443 is still merged and canonical, then this PR reintroduces the divergence that #203 was rushed in (pre-devnet) to fix, and will cause state root mismatches against other clients that follow that spec update.

Before merging, the PR needs to explicitly address one of:
1. leanSpec PR #443 has been **reverted** or superseded — link to the current leanSpec commit that confirms the original-snapshot semantics.
2. The spec is being updated back to original-snapshot semantics — the PR should reference the leanSpec PR that makes this change.
3. Other client implementations have been audited and agree with this reading of the spec.

Without resolving this, merging risks a consensus split in the opposite direction from the one described in the PR description.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 387 to 391
if ((source.slot + 1)..target.slot)
.any(|slot| slot_is_justifiable_after(slot, state.latest_finalized.slot))
.any(|slot| slot_is_justifiable_after(slot, original_finalized_slot))
{
metrics::inc_finalizations("error");
return;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gap check uses frozen slot across multiple try_finalize calls

try_finalize can be invoked more than once within a single process_attestations call (once per attestation that crosses the supermajority threshold). After the first successful call, state.latest_finalized.slot is advanced, but subsequent calls still use the same original_finalized_slot.

Consider a block with three attestation groups:

  • Group 1 justifies slot 5 → try_finalize is called, state.latest_finalized.slot advances from 0 → 5.
  • Group 2 justifies slot 12 → try_finalize is called with original_finalized_slot = 0. The gap (5+1)..12 = [6,7,8,9,10,11] is checked against original_finalized_slot=0. delta=6 is pronic → justifiable → finalization blocked.
  • If instead we used the updated state.latest_finalized.slot=5, delta for slot 6 would be 1 ≤ 5 → justifiable → same result, but for slot 7 delta=2 (pronic: no; square: no; ≤5: no… wait 2 ≤ 5 → yes). Different justifiability windows could yield different finalization decisions.

The PR description carefully explains the two-reference distinction in the Python spec, but it's worth explicitly verifying that the Python self.latest_finalized is never mutated mid-loop (i.e., the spec writes the final result back to self.latest_finalized only after the full loop completes, keeping self.latest_finalized.slot constant at L516 and L570). If Python does update self.latest_finalized within the loop, then subsequent iterations would see the new slot — the same behavior that state.latest_finalized.slot exhibits in the Rust code — and original_finalized_slot would be wrong here too.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/blockchain/state_transition/src/lib.rs
Line: 387-391

Comment:
**Gap check uses frozen slot across multiple `try_finalize` calls**

`try_finalize` can be invoked more than once within a single `process_attestations` call (once per attestation that crosses the supermajority threshold). After the first successful call, `state.latest_finalized.slot` is advanced, but subsequent calls still use the same `original_finalized_slot`.

Consider a block with three attestation groups:
- Group 1 justifies slot 5 → `try_finalize` is called, `state.latest_finalized.slot` advances from 0 → 5.
- Group 2 justifies slot 12 → `try_finalize` is called with `original_finalized_slot = 0`. The gap `(5+1)..12 = [6,7,8,9,10,11]` is checked against `original_finalized_slot=0`. `delta=6` is pronic → justifiable → finalization blocked.
- If instead we used the *updated* `state.latest_finalized.slot=5`, delta for slot 6 would be 1 ≤ 5 → justifiable → same result, but for slot 7 delta=2 (pronic: no; square: no; ≤5: no… wait 2 ≤ 5 → yes). Different justifiability windows could yield different finalization decisions.

The PR description carefully explains the two-reference distinction in the Python spec, but it's worth explicitly verifying that the Python `self.latest_finalized` is **never mutated** mid-loop (i.e., the spec writes the final result back to `self.latest_finalized` only after the full loop completes, keeping `self.latest_finalized.slot` constant at L516 and L570). If Python does update `self.latest_finalized` within the loop, then subsequent iterations would see the new slot — the same behavior that `state.latest_finalized.slot` exhibits in the Rust code — and `original_finalized_slot` would be wrong here too.

How can I resolve this? If you propose a fix, please make it concise.

@MegaRedHand MegaRedHand deleted the fix/use-original-finalized-slot-in-attestation-processing branch March 13, 2026 18:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants