# Payload-Aware FFG Checkpoints for Gloas (ePBS) **Authors:** Tuyen Nguyen **Status:** Draft **Created:** 2026-04-08 **Requires:** EIP-7732 (ePBS / Gloas) --- ## Table of Contents 1. [Abstract](#abstract) 2. [What is a Checkpoint?](#what-is-a-checkpoint) 3. [Visual Explanation](#visual-explanation) 4. [Motivation](#motivation) --- ## Abstract This proposal enhances the FFG `Checkpoint` type in Gloas to carry execution payload status information. In ePBS, each beacon block's execution payload can be either **FULL** (revealed by the builder) or **EMPTY** (withheld). For **skipped-slot checkpoints** — where the first slot of an epoch has no proposed block — the checkpoint root refers to a block from a previous epoch whose payload status has already been resolved, but this information is lost in the current `Checkpoint(epoch, root)` encoding. We propose a new `GloasCheckpoint` container that adds a `payload_status` field to carry this resolved status forward. --- ## What is a Checkpoint? A **checkpoint** is a fundamental structure in Casper FFG (Friendly Finality Gadget). It identifies the consensus state at a specific point in time using an `(epoch, root)` pair: ```python class Checkpoint(Container): epoch: Epoch root: Root ``` The `root` is the block root at the **first slot** of the epoch (slot 0 of that epoch). Validators cast FFG votes using checkpoints as `source` (last justified) and `target` (current epoch), and the protocol uses these votes to **justify** and **finalize** checkpoints. There are two cases for how a checkpoint's root is determined: ### Regular Checkpoint (Non-Skipped Slot) A block was proposed at slot 0 of epoch N. The checkpoint root is that block's root. ``` Checkpoint = (epoch=N, root=hash(B0_N)) ``` The block `B0_N` is in the current epoch. Its payload status is observable. ### Skipped-Slot Checkpoint No block was proposed at slot 0 of epoch N. Per `get_block_root_at_slot`, the checkpoint root is **inherited** from the last block before the epoch boundary — a block from a previous epoch. ``` Checkpoint = (epoch=N, root=hash(B31)) where B31 is from epoch N-1 ``` The block `B31` is from a prior epoch. Its payload status was already resolved. ### The Gloas Problem: Payload Status in Checkpoints Starting from Gloas (EIP-7732 / ePBS), each slot has a dual state: **EMPTY** (beacon block only, payload withheld) vs **FULL** (beacon block + execution payload envelope revealed). The PTC (Payload Timeliness Committee) votes on payload presence, and fork choice tracks this via `PayloadStatus`: ``` PAYLOAD_STATUS_EMPTY = 0 # Payload withheld (block only) PAYLOAD_STATUS_FULL = 1 # Payload revealed (block + envelope) PAYLOAD_STATUS_PENDING = 2 # Not yet determined ``` We propose to add `payload_status: PayloadStatus` to the checkpoint. Its semantics differ by checkpoint type: **For regular checkpoints (non-skipped slot):** `payload_status` is **always `PAYLOAD_STATUS_EMPTY`**. You cannot have FULL status here because this field's context is exclusively for skipped-slot checkpoints. Using a fixed value for regular checkpoints also helps FFG vote consistency — slot 0 attesters and slot N attesters within the same epoch will always produce the same target checkpoint `hash_tree_root`, since payload status is deterministically EMPTY for non-skipped checkpoints. **For skipped-slot checkpoints:** `payload_status` is either `PAYLOAD_STATUS_EMPTY` or `PAYLOAD_STATUS_FULL` depending on the checkpoint root's actual payload state. Attesters must traverse their fork choice view to determine the payload status of the block at the checkpoint root. ### Understanding `payload_status` as a Cut-Off Time Another way to understand the `payload_status` of a `GloasCheckpoint` is through the concept of a **cut-off time** — the moment at which we snapshot the payload status of the checkpoint root: ``` Regular Checkpoint (block exists at slot 0 of epoch N): Epoch N-1 Epoch N ···──[B30]──[B31]─────────────[B0_N]──[B1_N]──··· │ cut-off ┤ │ ▼ B0_N just arrived as a beacon block. The execution payload has NOT been revealed yet at this point. payload_status = EMPTY (always) The payload envelope comes AFTER the block, so at the cut-off moment the checkpoint is always in EMPTY state. Skipped-Slot Checkpoint (no block at slot 0 of epoch N): Epoch N-1 Epoch N ···──[B30]──[B31]──────────────X──[B1_N]──[B2_N]──··· │ │ │ cut-off ┤ (epoch boundary) │ │ ▼ ▼ B31 is the At the epoch boundary, B31's checkpoint payload status is already resolved root from epoch N-1's PTC vote. payload_status = FULL or EMPTY (whatever was decided for B31) ``` The cut-off time is: - **Regular checkpoint:** the moment the block arrives at slot 0 — payload has not been revealed yet, so it's always EMPTY - **Skipped-slot checkpoint:** the epoch boundary itself — the checkpoint root's payload status was decided in a prior epoch and is already final This is why `payload_status` is always `PAYLOAD_STATUS_EMPTY` for regular checkpoints: at the cut-off moment, the execution payload envelope has not arrived yet. --- ## Visual Explanation ### Case A: Regular Checkpoint (Not Skipped) ``` Epoch N-1 Epoch N ┌─────────────────────────┐ ┌─────────────────────────────────┐ │ │ │ │ ···──[B29]──[B30]──[B31]──────────[B0_N]──[B1_N]──[B2_N]──··· │ ▼ Block exists at slot 0 of epoch N Checkpoint = (epoch=N, root=B0_N) GloasCheckpoint: epoch = N root = B0_N payload_status = PAYLOAD_STATUS_EMPTY (always, for regular checkpoints) Why always EMPTY? - This field is only meaningful for skipped-slot checkpoints - For regular checkpoints, it is always EMPTY by definition - Using a fixed value ensures all attesters in the epoch produce the same checkpoint hash_tree_root regardless of when they attest ``` ### Case B1: Skipped-Slot Checkpoint — Payload Was Revealed (FULL) ``` Epoch N-1 Epoch N ┌─────────────────────────┐ ┌─────────────────────────────────┐ │ │ │ │ ···──[B29]──[B30]──[B31]──────────X──[B1_N]──[B2_N]──··· │ │ │ ▼ │ Slot 0 of epoch N: SKIPPED (no block) │ Checkpoint root inherited from B31 │ ▼ B31 is from epoch N-1 Builder revealed the execution payload for B31 PTC voted FULL for B31 in epoch N-1 GloasCheckpoint: epoch = N root = B31 (from epoch N-1) payload_status = PAYLOAD_STATUS_FULL How does the attester determine payload_status? - Traverse from HEAD in their fork choice view - Find the ancestor at the checkpoint root (B31) - B31's payload was revealed → FULL - Include PAYLOAD_STATUS_FULL in the GloasCheckpoint target vote ``` ### Case B2: Skipped-Slot Checkpoint — Payload Was Withheld (EMPTY) ``` Epoch N-1 Epoch N ┌─────────────────────────┐ ┌─────────────────────────────────┐ │ │ │ │ ···──[B29]──[B30]──[B31]──────────X──[B1_N]──[B2_N]──··· │ │ │ ▼ │ Slot 0 of epoch N: SKIPPED (no block) │ Checkpoint root inherited from B31 │ ▼ B31 is from epoch N-1 Builder withheld the execution payload for B31 PTC voted EMPTY for B31 in epoch N-1 GloasCheckpoint: epoch = N root = B31 (from epoch N-1) payload_status = PAYLOAD_STATUS_EMPTY How does the attester determine payload_status? - Traverse from HEAD in their fork choice view - Find the ancestor at the checkpoint root (B31) - B31's payload was withheld → EMPTY - Include PAYLOAD_STATUS_EMPTY in the GloasCheckpoint target vote Note: This value (EMPTY) is the same as the regular checkpoint default. The fork choice disambiguates by checking whether the block at `root` is actually at the epoch boundary slot or from a previous epoch. ``` ### The Fork Problem (Current Behavior Without Fix) Without `payload_status` in the Checkpoint, given a **finalized skipped-slot checkpoint**, we don't know which `BeaconState` to return: ``` Finalized Checkpoint = (epoch=N, root=B31) B31 is from epoch N-1, slot was skipped at epoch N boundary B31 has two state variants: [B31] ← post-block state; some may consider this the finalized ┌──┴──┐ state, but its slot is not at the checkpoint epoch B31(EMPTY) B31(FULL) │ │ ▼ ▼ State_E State_F ← two different BeaconStates! (no payload) (with payload) │ │ ▼ ▼ dial to dial to epoch N epoch N ← which one is the finalized state? Without payload_status in the checkpoint, we cannot determine which variant (EMPTY or FULL) was finalized. - Is it the FULL variant dialing to epoch N? - Is it the EMPTY variant dialing to epoch N? We simply don't know, because there was no vote at the skipped slot. ``` This is **not an issue for regular (non-skipped) checkpoints**. It's the post-block state (payload is not applied), and it's backward compatible. ### With This Fix ``` Finalized Checkpoint = GloasCheckpoint(epoch=N, root=B31, payload_status=FULL) [B31] ┌──┴──┐ B31(EMPTY) B31(FULL) ← payload_status tells us: FULL │ │ ▼ ▼ State_E State_F ← we know to use State_F │ ▼ dial to epoch N ← this is the finalized state The GloasCheckpoint carries the resolved payload status, so we can unambiguously identify the correct BeaconState. ``` --- ## Motivation ### Why Enhance FFG with Payload Status Now #### The Post-Block State Alternative (and Why It's Not Enough) One proposal for handling skipped-slot checkpoints is to simply return the **post-block state** of the checkpoint's root block. For example, if the finalized checkpoint is `(epoch=N, root=B31)` where B31 is from epoch N-1, we could return B31's post-block state as the "finalized state." This approach has significant drawbacks: - **Slot mismatch:** The post-block state of B31 has a slot in epoch N-1, not epoch N. Pre-Gloas, all checkpoint states have a slot respective to the checkpoint epoch. This is a **nice invariant**: you can extract the finalized checkpoint from the finalized state itself (the state's slot tells you the epoch). Returning a post-block state from a different epoch breaks this invariant. - **Backward incompatibility:** All existing tooling, APIs, and client logic assumes the finalized state's slot matches the finalized epoch. Breaking this assumption has wide-ranging consequences. #### The `store_target_checkpoint_state` Bug The Phase0 function `store_target_checkpoint_state` (which computes and caches the state at a checkpoint) is **already a bug in the ePBS context**. It creates a `checkpoint_states[checkpoint]` entry keyed by `Checkpoint(epoch, root)`, but in ePBS there are two possible states for any given `(epoch, root)` pair — the FULL variant and the EMPTY variant. Without `payload_status` inside the checkpoint, the store cannot distinguish which variant to cache or return. #### The Solution By embedding `payload_status` directly in the checkpoint: - The checkpoint state is unambiguous: `GloasCheckpoint(epoch, root, payload_status)` uniquely identifies exactly one `BeaconState` - The finalized state can be computed by dialing the correct variant (FULL or EMPTY) to the checkpoint epoch — preserving the pre-Gloas invariant that the finalized state's slot matches the finalized epoch - `store.checkpoint_states` is keyed by `GloasCheckpoint`, which correctly distinguishes FULL vs EMPTY variants