EIP-8333 Aligning checkpoints to the epoch boundary block (Align Checkpoint with Epoch Boundary Block)

Declined for Inclusion · Consensus & staking · EIP status Withdrawn · created 2026-07-06

The block that the FFG checkpoint of epoch N points to moves from the first block of epoch N to the block just before it (the boundary block). The checkpoint's epoch number stays the same, and only which block it points to changes.

Why it is needed

Every epoch, Ethereum validators cast a target vote on which block to treat as a candidate for finalization. Today this target is the block in the epoch's first slot, and all attestations within the epoch point to the same target. Committees in later slots already know the target when they vote, but the first-slot committee must vote right after the target block is proposed and before the attestation deadline. If the block arrives late, some vote for the new block and some for the previous block. Only the side that matches the checkpoint the chain adopts counts toward justification, the other votes are discarded, and those validators lose their target reward. On the real network, missed target votes cluster in the first slot of each epoch, and roughly 1/SLOTS_PER_EPOCH of the FFG weight is exposed every epoch. The current behavior is not an intended design but a long-standing off-by-one error (consensus-specs issues 2174, 652). As a result, "epoch N finalized" in practice means only the first block of epoch N is finalized, the rest of its blocks can still be reverted, and explorers and tools that show finality per epoch are also inaccurate.

What changes

Today

After EIP-8333

Who is affected

In depth

Checkpoint slot definition: get_checkpoint_slot

The new helper get_checkpoint_slot(epoch) returns the slot that pins the epoch's checkpoint. After the activation epoch (EIP8333_FORK_EPOCH), it is the slot just before the epoch starts, that is, Slot(compute_start_slot_at_epoch(epoch) - 1). For epochs before activation, it returns compute_start_slot_at_epoch(epoch) as before. GENESIS_EPOCH, which has no slot before it, is pinned to GENESIS_SLOT. This helper takes only an epoch. Both the state accessor get_checkpoint_root and the fork choice's get_checkpoint_block go through this helper, so the same checkpoint cannot be interpreted differently in two places.

Checkpoint root definition: get_checkpoint_root

get_checkpoint_root(state, epoch) returns the root of the most recent block at or before get_checkpoint_slot(epoch). The implementation is the single line get_block_root_at_slot(state, get_checkpoint_slot(epoch)). After activation, this value is the boundary block. On a healthy network it is the last block of the previous epoch, and if the final slots of the previous epoch are empty, it is an earlier block. The genesis epoch has no previous epoch, so it resolves to the genesis block. When the first slot of epoch N is empty, the existing get_block_root(state, N) already returns the boundary block, so this proposal does not change behavior in such epochs. In effect, it makes the fallback rule for missed slots the default rather than the exception, and behavior differs only when the first slot has a block.

Changes to state transition functions

Two functions obtain the checkpoint root through get_checkpoint_root(state, epoch) instead of get_block_root(state, epoch). In get_attestation_participation_flag_indices, the target match check compares data.target.root with get_checkpoint_root(state, data.target.epoch). The source and head match checks and the timeliness conditions are unchanged. In weigh_justification_and_finalization, the root of the newly recorded justified checkpoint is taken from get_checkpoint_root. The justification bits and finalization rules do not change. Because no container changes, serialization, the gossip format, and the slashing protection database are also unaffected.

Fork choice and honest validators

The fork choice's get_checkpoint_block(store, root, epoch) finds and returns the ancestor at the get_checkpoint_slot(epoch) position on the root chain. on_attestation validation, filter_block_tree, and the gossip conditions on target blocks that use it need no further changes. In the validator guide, the FFG target is set to Checkpoint(epoch=get_current_epoch(head_state), root=get_checkpoint_root(head_state, get_current_epoch(head_state))). The source vote is unchanged. The special case of using the head block itself as the target in the first slot of an epoch is removed. Nothing changes on the execution layer.

Fork transition rules

get_checkpoint_slot MUST resolve epochs before the activation epoch using the old method, compute_start_slot_at_epoch(epoch). First, checkpoints recorded before activation follow the old method. on_block and filter_block_tree require that the finalized checkpoint root match the get_checkpoint_block result for the parent of each incoming block and for every valid branch, respectively. If the finalized epoch were re-resolved under the new rule, this match would break and block import and head computation would fail. Second, attestations whose target epoch is before activation can still be included for one epoch after activation. They must be evaluated with the old method so that validity and rewards are preserved. This change is not backward compatible with the consensus layer, so a hard fork is required.

Consistency with the shuffling dependent root

Feeding the boundary block into the state transition function yields a deterministic chain state at the start of the epoch, because no block after it exists before the epoch starts. The boundary block is already fixed before the epoch starts, and choosing a block later than it would make the checkpoint depend on that epoch's block production. Also, the checkpoint root of epoch N equals the shuffling dependent root for the duty epoch to which the lookahead points at N, that is, get_block_root_at_slot(state, compute_start_slot_at_epoch(N) - 1). From the fork choice perspective, it is the root that get_shuffling_dependent_root(store, root, N + MIN_SEED_LOOKAHEAD) selects for the same chain view. FFG checkpoints thus use the same boundary anchor as the shuffling-dependent duty checks.

Security considerations and test cases

FFG safety is not affected. Justification, finalization and slashing operate on checkpoint epochs, and the epochs do not change. The remaining attack surface is a deliberately late boundary block. This can split the target vote for the first slot of the next epoch, but it is the same attack that a late first-slot block makes possible today, and it becomes harder. The propagation margin grows from the time until the attestation deadline to more than one slot. Hiding the boundary block entirely gains nothing, because the target falls back to the earlier block that has already propagated. The test cases cover whether get_checkpoint_root differs from get_block_root when both the boundary block and the first-slot block exist, whether the two are equal when the first slot is empty, whether the search goes to an earlier block when the end of the previous epoch is empty, whether GENESIS_EPOCH returns the genesis block root, whether only a boundary block target receives participation flags, whether on_attestation accepts a boundary block target and rejects a first block target, and whether each branch that diverges at the boundary finds a different boundary block. Fork transition vectors are also listed.

Status: Withdrawn

The source status is Withdrawn. According to the call records, this EIP was deferred to the next ACDC at ACDC #184 on 08.06 and accepted as a Proposed for Inclusion (PFI) for Hegotá at ACDC #185 on 08.20. At ACDC #188 on 10.01, the Lodestar team withdrew it (DFI). After looking into decoupled consensus, they concluded it was not worth pursuing now. As a result, this specification is not scheduled to be included in any current fork.

Original EIP · ← EIPs