# 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