Notes on how the Payload Timeliness Committee (PTC), the payload_present bit
on attestations, and should_extend_payload interact to produce a payload-level
reorg under gloas/ePBS, and what an honest slot N+1 proposer should actually
do. References: consensus-specs PR #4450 and specs/gloas/fork-choice.md /
specs/gloas/validator.md.
should_extend_payloadhas a Condition 2 bypass (proposer_boost_root == Root()) that is almost always TRUE for a proposer at a slot boundary. So the spec's answer to "extend FULL or EMPTY?" collapses to: extend FULL iff you verified the envelope locally.- During slot N+1,
get_weightforcesweight(B-FULL) == weight(B-EMPTY) == 0for the block B at slot N. That's not coincidence — it's a hard-coded rule that creates a "PTC decision window". Attestation weight can't decide FULL vs EMPTY during that window; the tiebreaker does. get_payload_status_tiebreakerusesshould_extend_payloadto pick FULL vs EMPTY, which reads the PTC votes viais_payload_timely. This is where the PTC actually earns its keep — disciplining a payload-reorg attempt by a malicious proposer/builder.- A smart honest proposer should look at the PTC votes too and extend EMPTY
when PTC voted not-timely, even though
should_extend_payload(per Condition 2) would tell them FULL. This is a validator-strategy optimization, not a protocol violation.
def should_extend_payload(store: Store, root: Root) -> bool:
if not is_payload_verified(store, root):
return False
proposer_root = store.proposer_boost_root
return (
(is_payload_timely(store, root) and is_payload_data_available(store, root))
or proposer_root == Root()
or store.blocks[proposer_root].parent_root != root
or is_parent_node_full(store, store.blocks[proposer_root])
)Returns FALSE only when the top guard fails (envelope not locally verified) OR when all four OR-conditions fail:
- PTC voted timely AND data was available
proposer_boost_root == Root()- Boosted block's
parent_root != root(boosted block is on a different chain) - Boosted block already chose FULL (
is_parent_node_full)
At a slot boundary, on_tick_per_slot clears proposer_boost_root to Root(),
so Condition 2 is TRUE for a proposer preparing at the start of their slot.
Given the envelope is locally verified, the spec's answer reduces to extend
FULL.
Not coincidence. get_weight enforces it:
def get_weight(store, node):
if node.payload_status == PENDING or store.blocks[node.root].slot + 1 != get_current_slot(store):
# compute actual attestation + boost weight
...
else:
return Gwei(0)For a block B at slot N, during slot N+1: payload_status != PENDING and
block.slot + 1 == current_slot → return Gwei(0). Both FULL and EMPTY
variants are exactly zero → weight tie + same root → the sort key in get_head
falls through to get_payload_status_tiebreaker, which reads PTC via
should_extend_payload.
Design intent: during slot N+1 the PTC votes are still arriving / being
gossiped. Forcing attestation weight to 0 on FULL/EMPTY creates a clean window
where only the PTC-driven tiebreaker decides the payload status. Once slot N+2
starts, block.slot+1 != current_slot, real weights kick in, and the
tiebreaker's choice is reinforced by attestations that have since flowed in.
if node.root == message.root:
if node.payload_status == PENDING:
return True
if message.slot == block.slot:
return False # same-slot attestations: out
if message.payload_present:
return node.payload_status == FULL
else:
return node.payload_status == EMPTY
else:
ancestor = get_ancestor(store, message.root, block.slot)
return node.root == ancestor.root and (
node.payload_status == PENDING
or node.payload_status == ancestor.payload_status
)Two branches:
- Direct vote (
node.root == message.root,message.slot > block.slot):payload_presentpicks FULL vs EMPTY. This happens when a later-slot attestation votes directly for an earlier block — typically when the intervening slot was missed/empty and attesters fell back to the previous head. - Ancestor vote (
node.root != message.root): walks up frommessage.roottoblock.slot, uses the ancestor'spayload_status, which is set byget_parent_payload_status(descendant)— i.e., what the descendant extended in its bid.payload_presentis not read in this branch.
Implication for the normal back-to-back case (B at N, B' at N+1): N+1 attestations vote for B' (not B), weight flows to B-FULL or B-EMPTY based on what B' extended in its bid. The PTC vote doesn't directly add attestation weight.
Attack: slot-N+1 proposer colludes with a builder to build B' on EMPTY of B, orphaning B's payload and stealing its MEV for themselves.
What happens with an honest network and honest PTC majority:
- Attacker's B' has
bid.parent_block_hash = (B's parent's payload block_hash)→ commits to EMPTY. State transition accepts this. - Honest attester at slot N+1 runs
get_head. Children of B-PENDING are B-EMPTY and B-FULL. Both have weight 0. - Tiebreaker runs:
should_extend_payload(store, B_root)→is_payload_verified(B)= TRUE (they have the envelope)- Condition 1:
is_payload_timely(B)reads PTC votes → TRUE (PTC majority saw the envelope) - → returns TRUE → B-FULL tiebreaker = 2, B-EMPTY = 1 → B-FULL wins.
- Traverse to B-FULL. Its children are blocks with
get_parent_payload_status == FULL. B' has EMPTY → B' is not a child of B-FULL → B-FULL has no children → head is B-FULL (a leaf). - Honest attesters attest to
B_rootdirectly withpayload_present=True. That's the direct-vote branch; their weight flows to B-FULL. - At slot N+2+,
weight(B-FULL)= all those honest attestations;weight(B-EMPTY)= only the boost on the attacker's B' (flowing via ancestor). B-FULL outweighs B-EMPTY → B' gets orphaned. - Attacker loses their block and their attempted MEV steal.
For the attack to succeed, PTC majority has to vote not-timely. With balance-weighted PTC sampling, that requires meaningful PTC corruption — consistent with the honest-majority assumption for the rest of the protocol.
Without the PTC, the tiebreaker would have no consensus-level signal to work with, and the attacker's B' (boosted during slot N+1) would win head through accumulated boost + attestation weight routed via B-EMPTY.
The spec, via should_extend_payload, says:
| Envelope locally verified | Spec decision |
|---|---|
| Yes | Extend FULL (Condition 2 bypass) |
| No | Extend EMPTY (top guard) |
But a smart proposer should consult the PTC votes directly, because the attesters' tiebreaker will:
has_envelope = is_payload_verified(store, parent_root)
ptc_says_timely = is_payload_timely(store, parent_root)
if has_envelope and ptc_says_timely:
extend_full()
elif has_envelope and not ptc_says_timely:
# override spec's Condition 2 bypass — attesters' tiebreaker will pick EMPTY
extend_empty()
else:
extend_empty()You saw envelope, PTC majority voted not-timely.
- Per spec: extend FULL.
- Reality: tiebreaker on attesters picks B-EMPTY. A FULL block B' sits on a branch they don't traverse → your block is orphaned.
- Rational: extend EMPTY. The block you produce lands on the branch attesters actually traverse → stays canonical → you get your proposer reward.
You didn't see envelope, PTC voted timely.
- Per spec: extend EMPTY (top guard forces it).
- Reality: tiebreaker picks B-FULL, attesters attest to B_root (not your B') → your block gets orphaned regardless.
- The proposer's only lever is to make sure their local view is accurate, i.e. wait briefly for the envelope before proposing.
- Wait for the envelope: yes, modestly. Propose too early and you risk extending EMPTY by accident when the envelope was actually propagated — you'll be on the wrong branch.
- Wait for PTC votes: yes, if you want the PTC-aware override above. PTC votes gossip around 75% of slot N, so they should be largely collected by the start of slot N+1. Don't delay past the ~4s attesting interval — you'd lose proposer boost.
The CL-side logic above decides whether to reorg a payload. The EL needs to be able to act on that decision. Pre-PR #770, it couldn't.
Step 2 of engine_forkchoiceUpdated (applied to all fcU versions,
backwards-compatible):
- Before: if
headBlockHashis aVALIDancestor of the EL's canonical head, skip the update, returnVALID, and do not start a payload build. - After: only skip if it's too distant (>32 blocks behind). Otherwise reorg to the ancestor and start the payload build. Between-branch reorgs are still unlimited in depth — the 32-block cap only applies to ancestor reorgs.
When a slot-N+1 proposer extends EMPTY of B:
- The EL already processed B's envelope via
newPayload, so the EL's head is atB.envelope.block_hash. - The CL's fork-choice head is now B-EMPTY, so the CL wants the EL to build on
B's parent's payload (the grandparent
block_hash). - CL issues
fcU(headBlockHash = grandparent_hash, payloadAttributes = …). grandparent_hashis an ancestor of the EL's current head.- Pre-PR770: EL returns
VALIDwithpayloadId: null— proposer gets no build, dead in the water. - Post-PR770: EL reorgs its head back to the grandparent and returns a
payloadId. Proposer can actually build EMPTY.
- Slot N+1 proposer building EMPTY (spec-driven when Conditions 2–4 fail, or the rational PTC-aware override from the previous section).
- Slot N+2 proposer when slot N+1's tiebreaker picked B-EMPTY — parent is B, but the canonical execution state is the grandparent's payload.
- Mid-slot N+1 FCU updates as PTC votes arrive and the tiebreaker flips from FULL to EMPTY — CL needs to update the EL's block-building target, which requires the ancestor reorg.
The original skip-logic existed for CL-restart / catch-up scenarios: when the CL replays old FCUs, no need to thrash the EL. Gloas payload-reorgs are only ever 1 block deep (head → parent). 32 is a generous bound that preserves the catch-up optimization while covering the gloas case.
Without PR #770, every EMPTY extension at the CL level would fail at the engine-API boundary even though the CL logic is correct.
| Slot N+1 state | What decides B-FULL vs B-EMPTY |
|---|---|
| Normal (slot N+1 block B' exists) | Attesters' tiebreaker via should_extend_payload (PTC). In slot N+1, attestation weight is 0 on FULL/EMPTY variants; the tiebreaker is the sole decider. |
| Missed/empty (no slot N+1 block) | payload_present bits on N+1 attestations voting directly for B; PTC feeds should_extend_payload as a consistent secondary signal. |
The PTC is the consensus-level signal that keeps slot-N+1 attesters aligned under ePBS. It's what prevents a malicious proposer + builder from unilaterally reorganizing out a timely-delivered payload to steal its MEV. Execution-apis PR #770 is the EL-side plumbing that lets the EL act on a payload-reorg decision by reorging to the head's ancestor.