Skip to content

Instantly share code, notes, and snippets.

@lodekeeper
Last active April 22, 2026 05:17
Show Gist options
  • Select an option

  • Save lodekeeper/ea84e3982a156ea61564931d499e80f7 to your computer and use it in GitHub Desktop.

Select an option

Save lodekeeper/ea84e3982a156ea61564931d499e80f7 to your computer and use it in GitHub Desktop.
Gloas/ePBS: PTC, should_extend_payload, and the honest proposer's decision

Gloas/ePBS: PTC, should_extend_payload, and the honest proposer's decision

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.

TL;DR

  • should_extend_payload has 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_weight forces weight(B-FULL) == weight(B-EMPTY) == 0 for 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_tiebreaker uses should_extend_payload to pick FULL vs EMPTY, which reads the PTC votes via is_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.

should_extend_payload

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:

  1. PTC voted timely AND data was available
  2. proposer_boost_root == Root()
  3. Boosted block's parent_root != root (boosted block is on a different chain)
  4. 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.

Why weight(B-FULL) == weight(B-EMPTY) == 0 during slot N+1

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_slotreturn 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.

is_supporting_vote — how attestation weight reaches FULL vs EMPTY

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_present picks 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 from message.root to block.slot, uses the ancestor's payload_status, which is set by get_parent_payload_status(descendant) — i.e., what the descendant extended in its bid. payload_present is 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.

Why the PTC matters: MEV collusion attack defense

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:

  1. Attacker's B' has bid.parent_block_hash = (B's parent's payload block_hash) → commits to EMPTY. State transition accepts this.
  2. Honest attester at slot N+1 runs get_head. Children of B-PENDING are B-EMPTY and B-FULL. Both have weight 0.
  3. 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.
  4. 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).
  5. Honest attesters attest to B_root directly with payload_present=True. That's the direct-vote branch; their weight flows to B-FULL.
  6. 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.
  7. 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.

Honest slot N+1 proposer — spec vs rational behavior

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()

Two interesting cases

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.

Should the proposer wait?

  • 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.

EL plumbing: execution-apis PR #770

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.

What PR #770 changes

Step 2 of engine_forkchoiceUpdated (applied to all fcU versions, backwards-compatible):

  • Before: if headBlockHash is a VALID ancestor of the EL's canonical head, skip the update, return VALID, 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.

Why gloas needs this

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 at B.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_hash is an ancestor of the EL's current head.
  • Pre-PR770: EL returns VALID with payloadId: 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.

When it's relevant

  1. Slot N+1 proposer building EMPTY (spec-driven when Conditions 2–4 fail, or the rational PTC-aware override from the previous section).
  2. 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.
  3. 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.

Why 32 blocks

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.

Summary

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment