Skip to content

Instantly share code, notes, and snippets.

@lodekeeper
Created April 14, 2026 00:58
Show Gist options
  • Select an option

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

Select an option

Save lodekeeper/aabe2800d2850a364d402a2279632406 to your computer and use it in GitHub Desktop.
Fork Choice Simplification Analysis — wemeetagain/consensus-specs#2

Fork Choice Simplification Analysis — wemeetagain/consensus-specs#2

Summary

This PR removes the virtual node tree architecture from the Gloas (ePBS / EIP-7732) fork choice and replaces it with a simpler penalty-based mechanism. The change is -389 / +57 lines across fork-choice.md, beacon-chain.md, and validator.md.

TL;DR: The simplification is correct in principle — it preserves FFG safety, LMD-GHOST integrity, and DA guarantees. The core insight (deferred payload = one post-state per block root, making virtual nodes unnecessary) is sound. There are two behavioral changes worth scrutinizing (strict PTC threshold, should_extend_payload fallback removal) and one potential gap (fork boundary handling in get_weight).


Core Insight

With deferred payload processing, process_execution_payload no longer mutates beacon state. Each block root has exactly one canonical post-state. The FULL/EMPTY distinction only affects the next proposer's choice of parent_block_hash, not the state transition itself.

Therefore, the virtual node tree (which modeled PENDING/EMPTY/FULL as separate fork choice branches) is no longer necessary for correct fork choice. The decision of "extend payload or not" can be handled entirely by:

  1. The PTC (signaling whether the payload was timely and data-available)
  2. The validator (choosing parent_block_hash based on should_extend_payload)
  3. A penalty in get_weight (zeroing blocks that skip confirmed-good payloads)

Invariant-by-Invariant Analysis

1. FFG (Casper Finality) — SAFE

FFG operates on checkpoint roots, not payload status. The key functions:

  • get_checkpoint_block now uses get_ancestor(store, root, slot) returning Root (was ForkChoiceNode.root)
  • update_checkpoints, compute_pulled_up_tip — unchanged
  • Justification/finalization logic — unchanged

Since FFG only ever used the .root field from ForkChoiceNode, the removal is transparent to FFG.

Verdict: No regression.

2. LMD-GHOST (Fork Choice) — SAFE with behavioral change

What changed:

  • Old: Fork choice tree has virtual nodes. Attesters signal FULL/EMPTY via data.index. Votes are counted per-branch via is_supporting_vote. get_head walks a virtual tree with get_node_children.
  • New: Fork choice tree is standard (one node per block root). All attestations vote for the root. Blocks that skip PTC-confirmed payloads get zero weight.

Why this is correct:

The virtual tree existed because pre-deferred-payload, different payload states could lead to different post-states. With deferred payload, this is no longer true. The FULL/EMPTY decision is a proposer-layer concern, not a fork-choice-layer concern.

The zero-weight penalty mechanism is actually stronger than vote splitting:

  • Old: If 60% voted FULL and 40% voted EMPTY, the FULL branch gets 60% weight and EMPTY gets 40%. But these are the same block root — the fork choice was artificially splitting a unified vote.
  • New: The block gets 100% of its attestation weight. The FULL/EMPTY decision is made by the next proposer based on PTC consensus. If the proposer makes the "wrong" choice (skips a confirmed-good payload), that block gets zeroed.

This eliminates vote dilution — in the old spec, honest validators' votes were split between FULL and EMPTY virtual nodes, potentially weakening the honest majority's signal.

Edge case — competing blocks at same slot:

Two blocks B1 and B2 at the same slot, one building FULL and one EMPTY on the same parent. In the new spec, if the parent's payload was PTC-confirmed, B2 (EMPTY) gets zero weight. B1 (FULL) gets normal weight. The honest fork choice correctly prefers B1.

Verdict: No regression. Arguably improved.

3. PTC Behavior — SAFE with strictness change

What changed:

  • Old should_extend_payload:

    return (
        (is_payload_timely(root) and is_payload_data_available(root))
        or proposer_root == Root()
        or store.blocks[proposer_root].parent_root != root
        or is_parent_node_full(store, store.blocks[proposer_root])
    )

    Fallbacks meant: extend even without PTC majority if proposer boost conditions were met.

  • New should_extend_payload:

    return is_payload_timely(root) and is_payload_data_available(root)

    Strict PTC majority required. No fallbacks.

Analysis:

The old fallbacks could cause inconsistency — different nodes might evaluate should_extend_payload differently based on their local proposer_boost_root state (which depends on timing of block receipt). The new version is deterministic given the same PTC votes, which is preferable for consensus.

Griefing concern: With 1/3 adversarial stake, can the attacker prevent PTC majority?

  • 1/3 of 512 PTC = ~170 adversarial members voting False
  • 342 honest members voting True > 256 threshold
  • Attack fails under honest-majority PTC assumption

The threshold only fails if >256 members vote False, requiring near-majority adversarial control or severe network partition (~50% honest PTC members not receiving the payload in time).

Verdict: Behavioral change — stricter. But more consistent and still safe under 1/3 adversary assumption.

4. Data Availability — SAFE

  • is_payload_data_available — unchanged
  • on_execution_payload still calls is_data_available — unchanged
  • PTC DA voting (payload_data_availability_vote) — unchanged
  • store.payloads tracking — unchanged

Verdict: No regression.

5. Reorg Resistance — SAFE (phase0 fallback)

What changed:

  • is_parent_strong Gloas override removed
  • Phase0 version: get_weight(store, parent_root) > parent_threshold
  • Old Gloas version: get_attestation_score(store, ForkChoiceNode(...), state) > threshold

The Gloas override was only needed because the old get_weight took ForkChoiceNode instead of Root. The PR's new get_weight(store, root) signature is compatible with the phase0 is_parent_strong.

The phase0 is_parent_strong calls get_weight(store, parent_root), which in the new code includes attestation score + proposer boost (and the zero-weight penalty if applicable). This is equivalent to (or slightly more inclusive than) the old Gloas version which only used get_attestation_score.

should_override_forkchoice_update (bellatrix) uses is_parent_strong — confirmed it has no Gloas override, so it uses the phase0 base directly.

Verdict: No regression. Phase0 fallback is compatible.

6. Zero-Weight Penalty — SOUND

if (
    bid.parent_block_hash != parent_bid.block_hash      # builds EMPTY
    and is_payload_timely(store, parent_root)            # PTC confirmed timely
    and is_payload_data_available(store, parent_root)    # PTC confirmed DA
):
    return Gwei(0)

This fires only when ALL three conditions hold:

  1. Block explicitly skips parent's payload (builds EMPTY)
  2. PTC majority confirmed the payload was timely
  3. PTC majority confirmed the data was available

Why it's sound: If the PTC (a random 512-member committee) confirms the payload was timely and data-available, an honest proposer has no reason to skip it. A block that skips a confirmed-good payload is either:

  • Adversarial (trying to censor the builder)
  • Stale (built before PTC votes arrived)

In both cases, zeroing its weight is correct — it prevents the adversary from unilaterally censoring payloads that the supermajority of PTC has endorsed.

No cascade risk: The penalty only fires when the parent's PTC confirmed the payload. If a block builds EMPTY because PTC didn't confirm, there's no penalty. So consecutive empty blocks (due to legitimate PTC non-confirmation) don't cascade.

Verdict: Sound mechanism. Strictly improves censorship resistance.

7. on_block EMPTY Validation — IMPROVEMENT

else:
    # EMPTY: must continue from same EL chain tip as parent
    assert bid.parent_block_hash == parent_bid.parent_block_hash

This is new — the old spec had no explicit validation that EMPTY blocks continue from the correct EL chain tip. It prevents:

  • EL chain forks when payloads are skipped
  • A malicious proposer claiming an arbitrary parent_block_hash when building EMPTY

Verdict: Strict improvement. Closes a validation gap.

8. process_payload_attestation Slot Check Removal — SAFE

Removed: assert data.slot + 1 == state.slot

This check enforced that PTC attestations reference the immediately preceding slot. It was overly restrictive — it prevented valid PTC attestations when there are skipped slots between parent and child.

Why it's safe:

  • data.beacon_block_root == state.latest_block_header.parent_root still constrains which block is attested
  • data.slot is signed over in the attestation — can't be forged
  • get_ptc(state, data.slot) uses the claimed slot for committee selection, and signature verification prevents mismatches
  • on_payload_attestation_message checks data.slot == get_current_slot(store) for wire messages

Verdict: Bug fix. Allows PTC attestations to work correctly with skipped slots.

9. LatestMessage Reversion to Epoch-Based — SAFE

The slot-based LatestMessage (with payload_present) was needed for is_supporting_vote to determine which virtual branch a vote supports. Without is_supporting_vote, slot-level granularity is unnecessary.

Validators attest once per epoch, so epoch-based deduplication is sufficient. The phase0 get_attestation_score uses get_ancestor(store, message.root, block.slot) which doesn't need the slot from the message.

Verdict: No regression.


Potential Concerns

A. Fork Boundary — First Gloas Block

Issue: The new get_weight accesses parent.body.signed_execution_payload_bid.message. For the first Gloas block, the parent is a pre-Gloas block without this field.

Current handling in on_block: The code checks hasattr(parent_block.body, "signed_execution_payload_bid") before accessing it. But get_weight does NOT have this guard — it unconditionally accesses store.blocks[parent_root].body.signed_execution_payload_bid.message.

Risk: get_weight could crash on a Gloas block whose parent is pre-Gloas.

Mitigation: In practice, the first Gloas block is the anchor block of the new fork choice store, so its parent would not be in store.blocks. But this depends on initialization — worth verifying. This should be explicitly guarded or documented.

B. should_extend_payload Strictness

The removal of fallback conditions means:

  • If PTC doesn't reach majority due to honest latency (not adversary), the payload gets dropped
  • The old spec would have extended via fallback conditions
  • This is a liveness regression for builders in high-latency scenarios

Severity: Low. The PTC threshold is 256/512 (simple majority). In normal network conditions, honest PTC members should easily reach this. The scenario where honest members can't reach majority implies >50% of a random 512-member committee didn't see the payload in time — this indicates a genuine network issue, not just latency.

C. validate_on_attestation Index Checks Removed

The old spec validated attestation.data.index in [0, 1] and checked that index=1 (FULL vote) only occurred when the payload was known. Without these checks, an attester could set data.index to any value.

Risk: If data.index is used elsewhere (e.g., committee selection in later forks), removing this validation could allow unexpected values. However, in the new model, data.index has no fork-choice significance — it's not consumed by any fork choice function.

Severity: Low. But worth confirming that data.index isn't used in any beacon-chain STF path that could be affected.


ChatGPT Pro Analysis (Oracle Bridge)

ChatGPT Pro was invoked for both defender and devil's advocate analyses. In all attempts (4 runs, timeouts up to 3600s), it entered extended thinking mode but only produced thinking summaries without generating full responses:

  • Defender: "I'm checking the current Gloas/EIP-7732 spec and fork-choice definitions first so I can ground the defense in the actual FFG/LMD-GHOST/ePBS invariants. Then I'll map each removed mechanism to the invariant it was approximating and show why the simplified rules still preserve correctness point by point."
  • Devil's Advocate: "I'm grounding this in the actual Gloas/ePBS fork-choice rules and intended invariants first, then I'll stress-test your simplification under 1/3 adversarial stake and enumerate concrete failure modes."

The thinking summaries suggest ChatGPT's approach aligns with this analysis — invariant-by-invariant verification against the actual spec, stress-testing under adversarial assumptions.


Overall Verdict

Property Status Notes
FFG Safety SAFE Only used .root from ForkChoiceNode — transparent removal
LMD-GHOST SAFE Zero-weight penalty replaces virtual tree. Eliminates vote dilution
PTC Behavior SAFE (stricter) No fallbacks = more deterministic. Still safe under 1/3 adversary
Data Availability SAFE No changes to DA checks
Reorg Resistance SAFE Phase0 is_parent_strong fallback is compatible
Builder Censorship IMPROVED Zero-weight penalty prevents skipping confirmed payloads
Slot Check Removal BUG FIX Allows PTC attestations with skipped slots
on_block EMPTY IMPROVEMENT New EL chain continuity check
Fork Boundary NEEDS REVIEW get_weight may need hasattr guard for pre-Gloas parents

Recommendation

The simplification is correct and recommended. The -389/+57 line change eliminates significant complexity while preserving all safety invariants. The zero-weight penalty is a cleaner mechanism than the virtual node tree for enforcing payload inclusion.

Action items before merge:

  1. Verify get_weight handles the fork boundary (first Gloas block with pre-Gloas parent) — add hasattr guard if needed
  2. Confirm data.index is not consumed by any STF path outside fork choice
  3. Consider adding a spec note explaining why the strict PTC threshold (no fallbacks) is preferred over the old fallback behavior

Analysis by @lodekeeper

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