feat: implement epbs fork choice - #8739
Conversation
Summary of ChangesHello @ensi321, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request delivers the foundational implementation for the ePBS (enshrined Proposer-Builder Separation) fork choice, internally referred to as 'Gloas'. It significantly refactors the Highlights
🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console. Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request implements the ePBS (enshrined Proposer-Builder Separation) fork choice rule, also known as Gloas. The changes are extensive and touch many parts of the fork choice logic, introducing concepts like block variants (PENDING, EMPTY, FULL) and new tie-breaking rules. The implementation correctly adapts the ProtoArray and ForkChoice classes to handle these new concepts. I've found a few critical issues related to the new tie-breaking logic where necessary context was not being passed down, and a minor issue with some unused code. Overall, this is a solid implementation of a complex feature, and the new tests are comprehensive.
Performance Report🚀🚀 Significant benchmark improvement detected
Full benchmark results
|
| if (existedFullIndex !== undefined) { | ||
| const existedFullNode = this.nodes[existedFullIndex]; | ||
| if (existedFullNode) { | ||
| // Pre-Gloas: execution payloads are part of the block, no separate event |
There was a problem hiding this comment.
throw error if it is the case, we should only have exactly 1 payload per slot/root
also needs to ensure we have PENDING node + EMPTY node, throw error if not
this is equivalent to
# The corresponding beacon block root needs to be known
assert envelope.beacon_block_root in store.block_states**Motivation** - getParentPayloadStatus() is confusing and likely not correct right now, we can just leverage the `getBlockHexAndBlockHash()` instead **Description** - move `getBlockHexAndBlockHash()` from forkchoice to ProtoArray - one small change is not to check PENDING as it's not useful for beacon-node - refactor `getParentPayloadStatus()` to call `getBlockHexAndBlockHash()` - add `getParent()` to also call `getBlockHexAndBlockHash()` - `forkchoice.onBlock()` needs to find exact parent via `protoArray.getParent()` because we always have success state-transition before calling forkchoice - enhance error codes, more comments part of #8739 cc @ensi321 @nflaig --------- Co-authored-by: Tuyen Nguyen <twoeths@users.noreply.github.com>
|
I did a deep pass against the Gloas fork-choice spec (v1.7.0-alpha.2) and found a few spec-alignment gaps. Opened follow-up PR with fixes + tests: #8931 Main fixes in #8931:
Validation run in #8931:
Happy to iterate further if you want these squashed/cherry-picked into #8739 directly. |
Revert proposerBoostRoot pass-through in maybeUpdateBestChildAndDescendant and getPayloadStatusTiebreaker calls. The proposer boost flow is being handled separately from the Gloas fork choice PR (ChainSafe#8739). Addresses review feedback from @ensi321.
…eaker (#8944) ## Summary Fixes a bug in the Gloas (ePBS) fork choice where `proposerBoostRoot` was hardcoded to `null` at every call site of `getPayloadStatusTiebreaker` inside `maybeUpdateBestChildAndDescendant`. **Root cause:** `shouldExtendPayload` checks four conditions in order. Condition 2 is `proposerBoostRoot === null → return true`. With `null` always passed, FULL always wins the EMPTY vs FULL tiebreaker for previous-slot blocks, regardless of PTC votes. This breaks the core ePBS invariant: when a payload is not timely, the chain should extend via EMPTY. **Fix:** Thread `proposerBoostRoot` explicitly through the call chain: - `maybeUpdateBestChildAndDescendant(parentIndex, childIndex, currentSlot, proposerBoostRoot)` - `applyScoreChanges` → passes `proposerBoost?.root ?? null` - `ProtoArray.onBlock` and `ProtoArray.onExecutionPayload` → accept and forward the param - `ForkChoice.onBlock` and `ForkChoice.onExecutionPayload` → pass `this.proposerBoostRoot` The `getPayloadStatusTiebreaker` body (`return shouldExtend ? 2 : 0`) is already spec-correct and is unchanged. ## Test plan - [x] All existing fork-choice unit tests pass (`pnpm vitest run packages/fork-choice`) - [ ] Add a test case to `gloas.test.ts` that sets a `proposerBoostRoot` and verifies EMPTY is preferred when `shouldExtendPayload` returns false (payload not timely, proposer boost applies to this block) 🤖 Generated with [Claude Code](https://claude.com/claude-code) cc @ensi321 @nflaig --------- Co-authored-by: Tuyen Nguyen <twoeths@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…as edge case (#8951) **Motivation** `isEmptyVsFullEdgeCase()` is not in the spec — it was an implementation helper that bypassed the standard weight → blockRoot tiebreaking order before calling `getPayloadStatusTiebreaker()`. Per spec, pre-Gloas nodes must always be tiebroken by weight first, then blockRoot. The guard was obscuring this and making the code harder to follow. **Description** - Removes the private `isEmptyVsFullEdgeCase()` helper from `ProtoArray` - Removes the `isEdgeCase` guard that was short-circuiting weight and blockRoot comparisons - The guard was structurally unnecessary: two nodes that reach `getPayloadStatusTiebreaker()` already have equal weight and equal blockRoot by natural fall-through; that scenario can only arise for Gloas blocks (EMPTY vs FULL variants of the same block) - Adds explicit `isGloasBlock()` assertions before the tiebreaker to make the invariant clear and fail loudly if violated - Condenses weight/root if-else branches to ternaries for readability Spec reference: https://github.com/ethereum/consensus-specs/blob/69a2582d5d62c914b24894bdb65f4bd5d4e49ae4/specs/gloas/fork-choice.md?plain=1#L442 **AI Assistance Disclosure** 🤖 Generated with [Claude Code](https://claude.com/claude-code) cc @nflaig @ensi321 --------- Co-authored-by: Tuyen Nguyen <twoeths@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
lodekeeper
left a comment
There was a problem hiding this comment.
ePBS Fork Choice Review
Impressive work — this is a clean implementation of a very complex spec change. The multi-variant node model, PTC voting, and fork transition handling are well-structured with good test coverage in gloas.test.ts.
A few items below, ranging from potential correctness issues to readability suggestions. The most important ones are the epoch-vs-slot vote comparison and the FULL variant timing issue — both flagged independently by multiple review passes.
Legend: 🔴 must-fix | 🟡 should-fix | 🟢 suggestion
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## unstable #8739 +/- ##
============================================
+ Coverage 52.26% 52.30% +0.03%
============================================
Files 848 848
Lines 62926 62689 -237
Branches 4639 4615 -24
============================================
- Hits 32889 32790 -99
+ Misses 29970 29833 -137
+ Partials 67 66 -1 🚀 New features to boost your workflow:
|
# Gloas Fork Choice Implementation (ePBS) This PR implements most of the [Gloas fork choice specification](https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.0/specs/gloas/fork-choice.md). Blocks can now exist in multiple payload status variants (PENDING, EMPTY, FULL), allowing the beacon chain to progress before execution payloads arrive. For high level concept please read https://devlog.lodestar.casa/gloas-fork-choice ## Key Concept: Payload Status Variants **New enum** (`packages/fork-choice/src/protoArray/interface.ts:27-32`): ```typescript enum PayloadStatus { PENDING = 0, // Canonical variant, created when beacon block arrives EMPTY = 1, // Block without execution payload FULL = 2 // Block with execution payload } ``` - **Pre-Gloas**: Only FULL exists (payload embedded in block) - **Gloas**: PENDING + EMPTY created initially, FULL added when payload arrives --- ## Major Changes ### 1. Multi-Variant Node Storage **ProtoArray indices structure** (`packages/fork-choice/src/protoArray/protoArray.ts:41-50`): ```typescript // Before: indices = Map<RootHex, number> // After: indices = Map<RootHex, number[]> // Array: [PENDING_INDEX, EMPTY_INDEX, FULL_INDEX] ``` **Pre-Gloas**: `variants[0]` = FULL index (single variant) **Gloas**: `variants[PayloadStatus.PENDING/EMPTY/FULL]` = respective indices ### 2. Block Creation (`onBlock`) **Pre-Gloas** (`packages/fork-choice/src/protoArray/protoArray.ts:428-451`): - Creates single FULL node - Parent = parent block's FULL **Gloas** (`packages/fork-choice/src/protoArray/protoArray.ts:352-428`): - Creates PENDING + EMPTY nodes - PENDING parent = parent block's EMPTY or FULL (inter-block edge, determined by `parentBlockHash`) - EMPTY parent = own PENDING (intra-block edge) - Initializes PTC votes **Fork transition**: First Gloas block's PENDING correctly points to last Fulu block's FULL. ### 3. Payload Arrival (`onExecutionPayload`) When execution payload arrives (`packages/fork-choice/src/protoArray/protoArray.ts:519-585`): - Creates FULL variant as sibling to EMPTY - FULL parent = own PENDING (intra-block edge) - Updates bestChild pointers ### 4. PTC (Payload Timeliness Committee) **New voting mechanism** (`packages/fork-choice/src/protoArray/protoArray.ts:122-151, 753-823`): ```typescript ptcVote = Map<RootHex, boolean[]> // PTC_SIZE votes per block notifyPtcMessage(blockRoot, ptcIndices[], payloadPresent) isPayloadTimely(blockRoot) // true if >50% voted yes AND FULL exists ``` Used by `shouldExtendPayload()` to determine FULL vs EMPTY preference. ### 5. Parent Payload Status Determination **`getParentPayloadStatus()`** (`packages/fork-choice/src/protoArray/protoArray.ts:173-203`): ```typescript // Compare parentBlockHash from child's bid with parent's execution hash if (block.parentBlockHash == parent.executionPayloadBlockHash) return FULL // Child extends parent with payload else return EMPTY // Child extends parent without payload ``` ### 6. Head Selection Changes **`findHead()`** (`packages/fork-choice/src/protoArray/protoArray.ts:882-946`): - Now returns **ProtoNode** instead of `RootHex` - Starts from justified checkpoint's default variant - May return PENDING/EMPTY/FULL variant **`getAncestor()`** (`packages/fork-choice/src/protoArray/protoArray.ts:1327-1401`): - Now returns **ProtoNode** instead of `RootHex` - Determines correct parent variant based on `parentBlockHash` ### 7. EMPTY vs FULL Tiebreaker For comparing variants of **same block** from slot n-1 or n (`packages/fork-choice/src/protoArray/protoArray.ts:854-874, 1132-1299`): ```typescript getPayloadStatusTiebreaker(node, currentSlot, proposerBoostRoot): if (node.payloadStatus == PENDING) return 0 if (node.slot + 1 != currentSlot) return node.payloadStatus // For slot n-1: use should_extend_payload() logic if (node.payloadStatus == EMPTY) return 1 return shouldExtendPayload(node.blockRoot, proposerBoostRoot) ? 2 : 0 ``` **`shouldExtendPayload()`** returns true if: 1. Payload is timely (PTC >50%), OR 2. No proposer boost, OR 3. Proposer boost parent ≠ this block, OR 4. Proposer boost extends FULL parent ### 8. Vote Tracking (Epoch → Slot) **Changed to slot-based tracking** (`packages/fork-choice/src/forkChoice/forkChoice.ts:99-126`): ```typescript // Before: voteNextEpochs: Epoch[] // After: voteNextSlots: Slot[] addLatestMessage(validatorIndex, nextSlot, nextRoot, nextPayloadStatus) // Queued attestations now track payload status per validator queuedAttestations: Map<Slot, Map<RootHex, Map<ValidatorIndex, PayloadStatus>>> ``` **Attestation interpretation** (Gloas) (`packages/fork-choice/src/forkChoice/forkChoice.ts:836-876`): - `slot == block.slot` → vote PENDING - `slot > block.slot && index == 0` → vote EMPTY - `slot > block.slot && index == 1` → vote FULL **Note**: `voteCurrentIndices` and `voteNextIndices` now point to **exact variant node indices**, not just block indices. ### 9. Tree Traversal (Omit Variants) All tree walking methods **filter to default variants only** to avoid noise from EMPTY/FULL siblings (`packages/fork-choice/src/protoArray/protoArray.ts:1430-1632`): ```typescript private isDefaultVariant = (node: ProtoNode): boolean => { return node.payloadStatus === this.getDefaultVariant(node.blockRoot); }; ``` **Affected methods**: - `iterateAncestorNodes()` - Only yields PENDING (Gloas) or FULL (pre-Gloas) - `getAllAncestorNodes()`, `getAllNonAncestorNodes()` - Filter to default variants - `forwardIterateDescendants()` - Uses minimum variant index **Why**: EMPTY/FULL are payload variants, not distinct branches. Including them would make chains 3x longer with redundant entries. Tree operations care about **beacon block relationships**, not payload status. ### 10. Pruning Pruning now (`packages/fork-choice/src/protoArray/protoArray.ts:964-1022`): - Removes all variants of finalized blocks - Adjusts all variant indices in the Map - Cleans up PTC votes for pruned blocks ### 11. Fork Choice API Changes - Renamed getBlock() → getBlockDefaultStatus() throughout the codebase for calls that don't specify a variant - Updated getBlock() signature to require explicit PayloadStatus parameter: getBlock(blockRoot, payloadStatus) - Added new getBlockHexAndBlockHash() method to find blocks matching both beacon block root and execution payload block ha --- ## Node Relationships ``` Pre-Gloas: Block A [FULL] → Block B [FULL] → Block C [FULL] Gloas: Block A [PENDING] → Block B [PENDING] → Block C [PENDING] ↓ ↓ ↓ [EMPTY] [EMPTY] [EMPTY] ↓ ↓ ↓ [FULL] [FULL] [FULL] Inter-block: PENDING → parent's EMPTY/FULL (based on parentBlockHash) Intra-block: EMPTY/FULL → own PENDING ``` --- ## Testing New test file `packages/fork-choice/test/unit/protoArray/gloas.test.ts` covers: - Pre-Gloas backward compatibility - Gloas block creation (PENDING + EMPTY) - Fork transition (Fulu → Gloas) - `onExecutionPayload()` (FULL creation) - PTC voting and timeliness - Parent relationships - EMPTY vs FULL tiebreaker --- ## Outstanding Items - Proposer boost --------- Co-authored-by: twoeths <10568965+twoeths@users.noreply.github.com> Co-authored-by: Tuyen Nguyen <twoeths@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
|
🎉 This PR is included in v1.41.0 🎉 |


Gloas Fork Choice Implementation (ePBS)
This PR implements most of the Gloas fork choice specification. Blocks can now exist in multiple payload status variants (PENDING, EMPTY, FULL), allowing the beacon chain to progress before execution payloads arrive.
For high level concept please read https://devlog.lodestar.casa/gloas-fork-choice
Key Concept: Payload Status Variants
New enum (
packages/fork-choice/src/protoArray/interface.ts:27-32):Major Changes
1. Multi-Variant Node Storage
ProtoArray indices structure (
packages/fork-choice/src/protoArray/protoArray.ts:41-50):Pre-Gloas:
variants[0]= FULL index (single variant)Gloas:
variants[PayloadStatus.PENDING/EMPTY/FULL]= respective indices2. Block Creation (
onBlock)Pre-Gloas (
packages/fork-choice/src/protoArray/protoArray.ts:428-451):Gloas (
packages/fork-choice/src/protoArray/protoArray.ts:352-428):parentBlockHash)Fork transition: First Gloas block's PENDING correctly points to last Fulu block's FULL.
3. Payload Arrival (
onExecutionPayload)When execution payload arrives (
packages/fork-choice/src/protoArray/protoArray.ts:519-585):4. PTC (Payload Timeliness Committee)
New voting mechanism (
packages/fork-choice/src/protoArray/protoArray.ts:122-151, 753-823):Used by
shouldExtendPayload()to determine FULL vs EMPTY preference.5. Parent Payload Status Determination
getParentPayloadStatus()(packages/fork-choice/src/protoArray/protoArray.ts:173-203):6. Head Selection Changes
findHead()(packages/fork-choice/src/protoArray/protoArray.ts:882-946):RootHexgetAncestor()(packages/fork-choice/src/protoArray/protoArray.ts:1327-1401):RootHexparentBlockHash7. EMPTY vs FULL Tiebreaker
For comparing variants of same block from slot n-1 or n (
packages/fork-choice/src/protoArray/protoArray.ts:854-874, 1132-1299):shouldExtendPayload()returns true if:8. Vote Tracking (Epoch → Slot)
Changed to slot-based tracking (
packages/fork-choice/src/forkChoice/forkChoice.ts:99-126):Attestation interpretation (Gloas) (
packages/fork-choice/src/forkChoice/forkChoice.ts:836-876):slot == block.slot→ vote PENDINGslot > block.slot && index == 0→ vote EMPTYslot > block.slot && index == 1→ vote FULLNote:
voteCurrentIndicesandvoteNextIndicesnow point to exact variant node indices, not just block indices.9. Tree Traversal (Omit Variants)
All tree walking methods filter to default variants only to avoid noise from EMPTY/FULL siblings (
packages/fork-choice/src/protoArray/protoArray.ts:1430-1632):Affected methods:
iterateAncestorNodes()- Only yields PENDING (Gloas) or FULL (pre-Gloas)getAllAncestorNodes(),getAllNonAncestorNodes()- Filter to default variantsforwardIterateDescendants()- Uses minimum variant indexWhy: EMPTY/FULL are payload variants, not distinct branches. Including them would make chains 3x longer with redundant entries. Tree operations care about beacon block relationships, not payload status.
10. Pruning
Pruning now (
packages/fork-choice/src/protoArray/protoArray.ts:964-1022):11. Fork Choice API Changes
Node Relationships
Testing
New test file
packages/fork-choice/test/unit/protoArray/gloas.test.tscovers:onExecutionPayload()(FULL creation)Outstanding Items