Skip to content

Keep ChainHead epoch and slot consistent - #200

Open
cyc60 wants to merge 1 commit into
masterfrom
fix/chain-head-epoch-slot-invariant
Open

Keep ChainHead epoch and slot consistent#200
cyc60 wants to merge 1 commit into
masterfrom
fix/chain-head-epoch-slot-invariant

Conversation

@cyc60

@cyc60 cyc60 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Description

ChainHead has independent epoch and slot fields and nothing keeps them consistent. Two of the four head-fetching functions can return a head where epoch != slot // slots_per_epoch:

  • get_chain_epoch_head(epoch=E) starts at E * slots_per_epoch and walks backwards, returning ChainHead(epoch=E, slot=slot_id - i). When epoch E's first slot was not proposed, the result claims epoch E while the slot actually lies in epoch E-1.
  • get_chain_justified_head has the same shape: it pins epoch to the current_justified checkpoint and walks backwards from that epoch's boundary, so the returned slot may belong to the previous epoch.

get_chain_finalized_head and get_chain_latest_head both derive epoch = slot // slots_per_epoch and were already correct.

This is not theoretical. It caused a permanent consensus bug in v3-oracle: PendingDepositScanner keyed its work on chain_head.epoch while validator balances are read at chain_head.slot, and the snapshot-restore path reconstructs its checkpoint from update_timestamp, which only yields a slot. A restored oracle rebuilt the checkpoint one epoch below the one the voting oracles had stored, rescanned that epoch, and double-counted the deposits processed at the boundary — leaving total_deposits permanently too high and that oracle signing a different Merkle root than every peer. It only bites where the stepped-back path is live, i.e. WORKER_EPOCH_DIVISOR > 1 (Gnosis). stakewise/v3-oracle#636 guards the scanner independently; this PR removes the underlying trap.

get_chain_epoch_head

Now walks forward within the requested epoch (slot_id + i), so the returned slot always lies inside that epoch and the invariant holds by construction. In the common case — the epoch's first slot was proposed — this is identical to the previous behaviour, since it matches at i = 0. Only the missed-first-slot case changes, and it changes from "last block of the previous epoch" to "first proposed block of the requested epoch", which is what the function name promises. The 404 skip, the pre-Shapella eth1_data fallback, and the terminal RuntimeError are all preserved.

get_chain_justified_head

Keeps the backwards walk — walking forward here would step past the justified boundary into non-justified slots — and instead derives epoch from the slot that was actually found.

This hunk is a deliberate semantic change and is separable. When the justified epoch's first slot was missed, .epoch now reports the epoch the slot really belongs to rather than the justified checkpoint epoch. Any consumer reading .epoch as "the justified checkpoint epoch" while .slot sat in an earlier epoch had a latent instance of the same bug, but I could not audit consumers outside v3-oracle (which does not use this function). Happy to drop this hunk and its test if you would rather handle it separately.

Also

  • ChainHead gains a docstring stating the invariant. No field or API changes; consumers construct it directly.
  • Version bumped to v0.13.5.
  • Adds sw_utils/tests/test_consensus.py, which had no coverage before: both walk directions, the all-slots-missed RuntimeError, the pre-Shapella fallback and its BlockNotFound continuation, and a parametrized invariant check (32- and 16-slot epochs) across all four head-fetching functions. Six of these fail against the pre-fix implementation.

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.

1 participant