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).
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:
- The PTC (signaling whether the payload was timely and data-available)
- The validator (choosing
parent_block_hashbased onshould_extend_payload) - A penalty in
get_weight(zeroing blocks that skip confirmed-good payloads)
FFG operates on checkpoint roots, not payload status. The key functions:
get_checkpoint_blocknow usesget_ancestor(store, root, slot)returningRoot(wasForkChoiceNode.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.
- Old: Fork choice tree has virtual nodes. Attesters signal FULL/EMPTY via
data.index. Votes are counted per-branch viais_supporting_vote.get_headwalks a virtual tree withget_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.
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.
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.
-
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.
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.
is_payload_data_available— unchangedon_execution_payloadstill callsis_data_available— unchanged- PTC DA voting (
payload_data_availability_vote) — unchanged store.payloadstracking — unchanged
Verdict: No regression.
is_parent_strongGloas 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.
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:
- Block explicitly skips parent's payload (builds EMPTY)
- PTC majority confirmed the payload was timely
- 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.
else:
# EMPTY: must continue from same EL chain tip as parent
assert bid.parent_block_hash == parent_bid.parent_block_hashThis 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_hashwhen building EMPTY
Verdict: Strict improvement. Closes a validation gap.
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_rootstill constrains which block is attesteddata.slotis signed over in the attestation — can't be forgedget_ptc(state, data.slot)uses the claimed slot for committee selection, and signature verification prevents mismatcheson_payload_attestation_messagechecksdata.slot == get_current_slot(store)for wire messages
Verdict: Bug fix. Allows PTC attestations to work correctly with skipped slots.
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.
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.
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.
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 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.
| 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 |
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:
- Verify
get_weighthandles the fork boundary (first Gloas block with pre-Gloas parent) — addhasattrguard if needed - Confirm
data.indexis not consumed by any STF path outside fork choice - Consider adding a spec note explaining why the strict PTC threshold (no fallbacks) is preferred over the old fallback behavior
Analysis by @lodekeeper