Skip to content

feat: implement epbs fork choice - #8739

Merged
ensi321 merged 66 commits into
unstablefrom
nc/epbs-fc
Mar 4, 2026
Merged

feat: implement epbs fork choice#8739
ensi321 merged 66 commits into
unstablefrom
nc/epbs-fc

Conversation

@ensi321

@ensi321 ensi321 commented Jan 13, 2026

Copy link
Copy Markdown
Member

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

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

// 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):

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

// 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):

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

// 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):

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

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello @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 ProtoArray and ForkChoice logic to accommodate the new paradigm where blocks can exist in different payload states (PENDING, EMPTY, FULL) and their selection is influenced by Payload Timeliness Committee (PTC) votes. The changes enable the system to track and react to the availability and timeliness of execution payloads, ensuring that the fork choice rule correctly navigates the ePBS-enabled chain.

Highlights

  • ePBS (Gloas) Fork Choice Implementation: Introduced the core logic for the enshrined Proposer-Builder Separation (ePBS) fork choice, referred to as 'Gloas', which fundamentally changes how beacon blocks are processed and selected based on their execution payload status.
  • Block Payload Status Variants: Implemented new 'PayloadStatus' states (PENDING, EMPTY, FULL) for blocks, allowing the fork choice to differentiate between blocks based on the availability and timeliness of their execution payloads. The ProtoArray now stores and indexes these variants using compound keys.
  • Payload Timeliness Committee (PTC) Voting: Added mechanisms to track votes from a Payload Timeliness Committee (PTC) to determine if an execution payload is 'timely', influencing the fork choice decision for Gloas blocks.
  • Updated Fork Choice Head Selection Logic: Modified the findHead and maybeUpdateBestChildAndDescendant methods in ProtoArray to incorporate payload status, PTC votes, and a specific tie-breaker rule for EMPTY vs. FULL variants in recent slots, aligning with the Gloas fork choice specification.
  • New API for ePBS Events: Exposed new public methods notifyPtcMessage and onExecutionPayload in the ForkChoice interface and implementation to allow external components to inform the fork choice about PTC votes and the arrival of execution payloads.
  • Configurable Gloas Fork Activation: The ProtoArray constructor now accepts a GLOAS_FORK_EPOCH configuration, enabling the system to transition between pre-Gloas and Gloas fork behaviors at a specified epoch.

🧠 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 Assist

The 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 /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

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 .gemini/ folder in the base of the repository. Detailed instructions can be found here.

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

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/fork-choice/src/protoArray/protoArray.ts Outdated
Comment thread packages/fork-choice/src/protoArray/protoArray.ts Outdated
Comment thread packages/fork-choice/src/protoArray/protoArray.ts Outdated
Comment thread packages/fork-choice/src/protoArray/protoArray.ts Outdated
@github-actions

github-actions Bot commented Jan 13, 2026

Copy link
Copy Markdown
Contributor

Performance Report

🚀🚀 Significant benchmark improvement detected

Benchmark suite Current: 0f01a57 Previous: 192806a Ratio
pass gossip attestations to forkchoice per slot 414.53 us/op 2.4371 ms/op 0.17
Full benchmark results
Benchmark suite Current: 0f01a57 Previous: 192806a Ratio
getPubkeys - index2pubkey - req 1000 vs - 250000 vc 849.96 us/op 1.0508 ms/op 0.81
getPubkeys - validatorsArr - req 1000 vs - 250000 vc 38.920 us/op 35.911 us/op 1.08
BLS verify - blst 907.16 us/op 972.00 us/op 0.93
BLS verifyMultipleSignatures 3 - blst 1.3110 ms/op 1.2378 ms/op 1.06
BLS verifyMultipleSignatures 8 - blst 2.0651 ms/op 1.8568 ms/op 1.11
BLS verifyMultipleSignatures 32 - blst 4.4424 ms/op 5.7489 ms/op 0.77
BLS verifyMultipleSignatures 64 - blst 8.3076 ms/op 10.847 ms/op 0.77
BLS verifyMultipleSignatures 128 - blst 16.008 ms/op 17.368 ms/op 0.92
BLS deserializing 10000 signatures 608.48 ms/op 694.36 ms/op 0.88
BLS deserializing 100000 signatures 6.2178 s/op 6.9286 s/op 0.90
BLS verifyMultipleSignatures - same message - 3 - blst 933.51 us/op 888.64 us/op 1.05
BLS verifyMultipleSignatures - same message - 8 - blst 1.1063 ms/op 1.2464 ms/op 0.89
BLS verifyMultipleSignatures - same message - 32 - blst 1.6864 ms/op 1.6937 ms/op 1.00
BLS verifyMultipleSignatures - same message - 64 - blst 2.5290 ms/op 2.5840 ms/op 0.98
BLS verifyMultipleSignatures - same message - 128 - blst 4.1192 ms/op 4.4002 ms/op 0.94
BLS aggregatePubkeys 32 - blst 17.929 us/op 19.163 us/op 0.94
BLS aggregatePubkeys 128 - blst 63.219 us/op 68.408 us/op 0.92
getSlashingsAndExits - default max 53.739 us/op 65.341 us/op 0.82
getSlashingsAndExits - 2k 327.27 us/op 331.39 us/op 0.99
isKnown best case - 1 super set check 416.00 ns/op 218.00 ns/op 1.91
isKnown normal case - 2 super set checks 399.00 ns/op 217.00 ns/op 1.84
isKnown worse case - 16 super set checks 412.00 ns/op 217.00 ns/op 1.90
validate api signedAggregateAndProof - struct 1.9723 ms/op 2.1011 ms/op 0.94
validate gossip signedAggregateAndProof - struct 1.9921 ms/op 2.5206 ms/op 0.79
batch validate gossip attestation - vc 640000 - chunk 32 111.88 us/op 121.02 us/op 0.92
batch validate gossip attestation - vc 640000 - chunk 64 96.968 us/op 103.11 us/op 0.94
batch validate gossip attestation - vc 640000 - chunk 128 88.570 us/op 96.225 us/op 0.92
batch validate gossip attestation - vc 640000 - chunk 256 86.970 us/op 92.729 us/op 0.94
bytes32 toHexString 529.00 ns/op 345.00 ns/op 1.53
bytes32 Buffer.toString(hex) 415.00 ns/op 225.00 ns/op 1.84
bytes32 Buffer.toString(hex) from Uint8Array 493.00 ns/op 316.00 ns/op 1.56
bytes32 Buffer.toString(hex) + 0x 408.00 ns/op 235.00 ns/op 1.74
Return object 10000 times 0.23700 ns/op 0.22900 ns/op 1.03
Throw Error 10000 times 3.5196 us/op 4.1450 us/op 0.85
toHex 103.96 ns/op 137.76 ns/op 0.75
Buffer.from 96.158 ns/op 125.53 ns/op 0.77
shared Buffer 66.688 ns/op 74.595 ns/op 0.89
fastMsgIdFn sha256 / 200 bytes 1.7550 us/op 1.8230 us/op 0.96
fastMsgIdFn h32 xxhash / 200 bytes 389.00 ns/op 186.00 ns/op 2.09
fastMsgIdFn h64 xxhash / 200 bytes 456.00 ns/op 253.00 ns/op 1.80
fastMsgIdFn sha256 / 1000 bytes 5.3540 us/op 5.8790 us/op 0.91
fastMsgIdFn h32 xxhash / 1000 bytes 488.00 ns/op 301.00 ns/op 1.62
fastMsgIdFn h64 xxhash / 1000 bytes 501.00 ns/op 395.00 ns/op 1.27
fastMsgIdFn sha256 / 10000 bytes 42.571 us/op 52.083 us/op 0.82
fastMsgIdFn h32 xxhash / 10000 bytes 1.5120 us/op 1.3880 us/op 1.09
fastMsgIdFn h64 xxhash / 10000 bytes 1.0840 us/op 1.1050 us/op 0.98
send data - 1000 256B messages 4.7278 ms/op 5.4476 ms/op 0.87
send data - 1000 512B messages 5.0227 ms/op 5.1062 ms/op 0.98
send data - 1000 1024B messages 5.3466 ms/op 5.4000 ms/op 0.99
send data - 1000 1200B messages 5.2699 ms/op 6.7200 ms/op 0.78
send data - 1000 2048B messages 5.1627 ms/op 5.9971 ms/op 0.86
send data - 1000 4096B messages 6.7992 ms/op 6.2294 ms/op 1.09
send data - 1000 16384B messages 34.144 ms/op 43.704 ms/op 0.78
send data - 1000 65536B messages 130.42 ms/op 103.93 ms/op 1.25
enrSubnets - fastDeserialize 64 bits 1.0560 us/op 2.1140 us/op 0.50
enrSubnets - ssz BitVector 64 bits 525.00 ns/op 337.00 ns/op 1.56
enrSubnets - fastDeserialize 4 bits 333.00 ns/op 124.00 ns/op 2.69
enrSubnets - ssz BitVector 4 bits 516.00 ns/op 346.00 ns/op 1.49
prioritizePeers score -10:0 att 32-0.1 sync 2-0 208.92 us/op 273.75 us/op 0.76
prioritizePeers score 0:0 att 32-0.25 sync 2-0.25 237.81 us/op 310.42 us/op 0.77
prioritizePeers score 0:0 att 32-0.5 sync 2-0.5 338.03 us/op 462.93 us/op 0.73
prioritizePeers score 0:0 att 64-0.75 sync 4-0.75 611.37 us/op 723.08 us/op 0.85
prioritizePeers score 0:0 att 64-1 sync 4-1 732.91 us/op 979.25 us/op 0.75
array of 16000 items push then shift 1.2544 us/op 1.6089 us/op 0.78
LinkedList of 16000 items push then shift 9.1130 ns/op 7.1950 ns/op 1.27
array of 16000 items push then pop 74.090 ns/op 74.564 ns/op 0.99
LinkedList of 16000 items push then pop 6.6460 ns/op 6.9260 ns/op 0.96
array of 24000 items push then shift 1.8547 us/op 2.3329 us/op 0.79
LinkedList of 24000 items push then shift 7.7580 ns/op 7.3230 ns/op 1.06
array of 24000 items push then pop 109.65 ns/op 103.58 ns/op 1.06
LinkedList of 24000 items push then pop 6.9190 ns/op 6.9300 ns/op 1.00
intersect bitArray bitLen 8 4.8230 ns/op 5.5890 ns/op 0.86
intersect array and set length 8 30.167 ns/op 32.792 ns/op 0.92
intersect bitArray bitLen 128 26.208 ns/op 27.369 ns/op 0.96
intersect array and set length 128 504.49 ns/op 543.09 ns/op 0.93
bitArray.getTrueBitIndexes() bitLen 128 1.3080 us/op 1.2360 us/op 1.06
bitArray.getTrueBitIndexes() bitLen 248 2.0340 us/op 1.9140 us/op 1.06
bitArray.getTrueBitIndexes() bitLen 512 4.8560 us/op 3.6990 us/op 1.31
Full columns - reconstruct all 6 blobs 234.94 us/op 228.37 us/op 1.03
Full columns - reconstruct half of the blobs out of 6 105.12 us/op 110.73 us/op 0.95
Full columns - reconstruct single blob out of 6 29.369 us/op 30.982 us/op 0.95
Half columns - reconstruct all 6 blobs 251.28 ms/op 255.61 ms/op 0.98
Half columns - reconstruct half of the blobs out of 6 128.39 ms/op 131.60 ms/op 0.98
Half columns - reconstruct single blob out of 6 46.730 ms/op 47.650 ms/op 0.98
Full columns - reconstruct all 10 blobs 336.69 us/op 401.93 us/op 0.84
Full columns - reconstruct half of the blobs out of 10 198.36 us/op 159.77 us/op 1.24
Full columns - reconstruct single blob out of 10 28.274 us/op 40.227 us/op 0.70
Half columns - reconstruct all 10 blobs 400.73 ms/op 426.95 ms/op 0.94
Half columns - reconstruct half of the blobs out of 10 212.13 ms/op 217.26 ms/op 0.98
Half columns - reconstruct single blob out of 10 52.238 ms/op 47.833 ms/op 1.09
Full columns - reconstruct all 20 blobs 851.81 us/op 653.26 us/op 1.30
Full columns - reconstruct half of the blobs out of 20 305.24 us/op 291.62 us/op 1.05
Full columns - reconstruct single blob out of 20 33.494 us/op 41.720 us/op 0.80
Half columns - reconstruct all 20 blobs 819.58 ms/op 848.55 ms/op 0.97
Half columns - reconstruct half of the blobs out of 20 406.83 ms/op 426.03 ms/op 0.95
Half columns - reconstruct single blob out of 20 45.399 ms/op 47.843 ms/op 0.95
Set add up to 64 items then delete first 1.5276 us/op 1.9736 us/op 0.77
OrderedSet add up to 64 items then delete first 2.3888 us/op 2.9563 us/op 0.81
Set add up to 64 items then delete last 1.7481 us/op 2.1719 us/op 0.80
OrderedSet add up to 64 items then delete last 2.7185 us/op 3.1154 us/op 0.87
Set add up to 64 items then delete middle 1.8027 us/op 2.1943 us/op 0.82
OrderedSet add up to 64 items then delete middle 4.1779 us/op 4.6222 us/op 0.90
Set add up to 128 items then delete first 3.4374 us/op 4.6270 us/op 0.74
OrderedSet add up to 128 items then delete first 5.2885 us/op 6.8058 us/op 0.78
Set add up to 128 items then delete last 3.4378 us/op 4.3938 us/op 0.78
OrderedSet add up to 128 items then delete last 5.3606 us/op 6.3719 us/op 0.84
Set add up to 128 items then delete middle 3.4552 us/op 4.2993 us/op 0.80
OrderedSet add up to 128 items then delete middle 10.922 us/op 12.421 us/op 0.88
Set add up to 256 items then delete first 6.7991 us/op 9.3093 us/op 0.73
OrderedSet add up to 256 items then delete first 10.919 us/op 14.431 us/op 0.76
Set add up to 256 items then delete last 6.8450 us/op 8.9502 us/op 0.76
OrderedSet add up to 256 items then delete last 11.116 us/op 13.518 us/op 0.82
Set add up to 256 items then delete middle 6.8233 us/op 8.7118 us/op 0.78
OrderedSet add up to 256 items then delete middle 33.997 us/op 39.573 us/op 0.86
pass gossip attestations to forkchoice per slot 414.53 us/op 2.4371 ms/op 0.17
computeDeltas 1400000 validators 0% inactive 11.449 ms/op 14.091 ms/op 0.81
computeDeltas 1400000 validators 10% inactive 10.770 ms/op 13.186 ms/op 0.82
computeDeltas 1400000 validators 20% inactive 9.9968 ms/op 12.526 ms/op 0.80
computeDeltas 1400000 validators 50% inactive 7.7156 ms/op 9.6335 ms/op 0.80
computeDeltas 2100000 validators 0% inactive 17.187 ms/op 21.155 ms/op 0.81
computeDeltas 2100000 validators 10% inactive 16.004 ms/op 19.770 ms/op 0.81
computeDeltas 2100000 validators 20% inactive 11.655 ms/op 18.416 ms/op 0.63
computeDeltas 2100000 validators 50% inactive 8.6831 ms/op 14.428 ms/op 0.60
altair processAttestation - 250000 vs - 7PWei normalcase 1.6639 ms/op 1.7095 ms/op 0.97
altair processAttestation - 250000 vs - 7PWei worstcase 2.3839 ms/op 2.8501 ms/op 0.84
altair processAttestation - setStatus - 1/6 committees join 91.585 us/op 113.28 us/op 0.81
altair processAttestation - setStatus - 1/3 committees join 183.40 us/op 220.67 us/op 0.83
altair processAttestation - setStatus - 1/2 committees join 261.26 us/op 307.38 us/op 0.85
altair processAttestation - setStatus - 2/3 committees join 341.81 us/op 398.17 us/op 0.86
altair processAttestation - setStatus - 4/5 committees join 478.40 us/op 552.27 us/op 0.87
altair processAttestation - setStatus - 100% committees join 584.45 us/op 666.54 us/op 0.88
altair processBlock - 250000 vs - 7PWei normalcase 3.2540 ms/op 3.7340 ms/op 0.87
altair processBlock - 250000 vs - 7PWei normalcase hashState 12.495 ms/op 18.484 ms/op 0.68
altair processBlock - 250000 vs - 7PWei worstcase 24.221 ms/op 25.633 ms/op 0.94
altair processBlock - 250000 vs - 7PWei worstcase hashState 55.787 ms/op 57.500 ms/op 0.97
phase0 processBlock - 250000 vs - 7PWei normalcase 1.2189 ms/op 1.5093 ms/op 0.81
phase0 processBlock - 250000 vs - 7PWei worstcase 21.536 ms/op 23.325 ms/op 0.92
altair processEth1Data - 250000 vs - 7PWei normalcase 293.40 us/op 367.49 us/op 0.80
getExpectedWithdrawals 250000 eb:1,eth1:1,we:0,wn:0,smpl:16 3.2780 us/op 8.3590 us/op 0.39
getExpectedWithdrawals 250000 eb:0.95,eth1:0.1,we:0.05,wn:0,smpl:220 33.118 us/op 32.055 us/op 1.03
getExpectedWithdrawals 250000 eb:0.95,eth1:0.3,we:0.05,wn:0,smpl:43 12.277 us/op 16.327 us/op 0.75
getExpectedWithdrawals 250000 eb:0.95,eth1:0.7,we:0.05,wn:0,smpl:19 6.0930 us/op 11.026 us/op 0.55
getExpectedWithdrawals 250000 eb:0.1,eth1:0.1,we:0,wn:0,smpl:1021 139.66 us/op 180.62 us/op 0.77
getExpectedWithdrawals 250000 eb:0.03,eth1:0.03,we:0,wn:0,smpl:11778 1.2768 ms/op 1.7504 ms/op 0.73
getExpectedWithdrawals 250000 eb:0.01,eth1:0.01,we:0,wn:0,smpl:16384 1.6811 ms/op 2.3103 ms/op 0.73
getExpectedWithdrawals 250000 eb:0,eth1:0,we:0,wn:0,smpl:16384 1.8263 ms/op 2.3232 ms/op 0.79
getExpectedWithdrawals 250000 eb:0,eth1:0,we:0,wn:0,nocache,smpl:16384 3.6012 ms/op 5.5549 ms/op 0.65
getExpectedWithdrawals 250000 eb:0,eth1:1,we:0,wn:0,smpl:16384 1.9016 ms/op 2.8440 ms/op 0.67
getExpectedWithdrawals 250000 eb:0,eth1:1,we:0,wn:0,nocache,smpl:16384 3.9666 ms/op 5.5219 ms/op 0.72
Tree 40 250000 create 287.95 ms/op 391.99 ms/op 0.73
Tree 40 250000 get(125000) 85.945 ns/op 133.91 ns/op 0.64
Tree 40 250000 set(125000) 943.62 ns/op 1.2201 us/op 0.77
Tree 40 250000 toArray() 10.259 ms/op 17.077 ms/op 0.60
Tree 40 250000 iterate all - toArray() + loop 10.243 ms/op 17.453 ms/op 0.59
Tree 40 250000 iterate all - get(i) 31.631 ms/op 46.864 ms/op 0.67
Array 250000 create 2.0255 ms/op 2.5560 ms/op 0.79
Array 250000 clone - spread 612.74 us/op 838.72 us/op 0.73
Array 250000 get(125000) 0.51100 ns/op 0.37400 ns/op 1.37
Array 250000 set(125000) 0.52200 ns/op 0.41300 ns/op 1.26
Array 250000 iterate all - loop 60.119 us/op 62.038 us/op 0.97
phase0 afterProcessEpoch - 250000 vs - 7PWei 36.478 ms/op 42.372 ms/op 0.86
Array.fill - length 1000000 1.9260 ms/op 3.1606 ms/op 0.61
Array push - length 1000000 9.9330 ms/op 11.585 ms/op 0.86
Array.get 0.19365 ns/op 0.22150 ns/op 0.87
Uint8Array.get 0.19562 ns/op 0.22296 ns/op 0.88
phase0 beforeProcessEpoch - 250000 vs - 7PWei 13.498 ms/op 14.848 ms/op 0.91
altair processEpoch - mainnet_e81889 289.44 ms/op 286.37 ms/op 1.01
mainnet_e81889 - altair beforeProcessEpoch 19.570 ms/op 18.650 ms/op 1.05
mainnet_e81889 - altair processJustificationAndFinalization 6.8350 us/op 6.5490 us/op 1.04
mainnet_e81889 - altair processInactivityUpdates 3.2518 ms/op 3.8293 ms/op 0.85
mainnet_e81889 - altair processRewardsAndPenalties 17.181 ms/op 21.584 ms/op 0.80
mainnet_e81889 - altair processRegistryUpdates 840.00 ns/op 744.00 ns/op 1.13
mainnet_e81889 - altair processSlashings 382.00 ns/op 182.00 ns/op 2.10
mainnet_e81889 - altair processEth1DataReset 386.00 ns/op 188.00 ns/op 2.05
mainnet_e81889 - altair processEffectiveBalanceUpdates 2.2263 ms/op 2.9044 ms/op 0.77
mainnet_e81889 - altair processSlashingsReset 972.00 ns/op 826.00 ns/op 1.18
mainnet_e81889 - altair processRandaoMixesReset 1.5440 us/op 1.3350 us/op 1.16
mainnet_e81889 - altair processHistoricalRootsUpdate 387.00 ns/op 173.00 ns/op 2.24
mainnet_e81889 - altair processParticipationFlagUpdates 746.00 ns/op 502.00 ns/op 1.49
mainnet_e81889 - altair processSyncCommitteeUpdates 354.00 ns/op 144.00 ns/op 2.46
mainnet_e81889 - altair afterProcessEpoch 41.245 ms/op 44.281 ms/op 0.93
capella processEpoch - mainnet_e217614 800.23 ms/op 854.40 ms/op 0.94
mainnet_e217614 - capella beforeProcessEpoch 59.389 ms/op 80.701 ms/op 0.74
mainnet_e217614 - capella processJustificationAndFinalization 5.1580 us/op 5.4180 us/op 0.95
mainnet_e217614 - capella processInactivityUpdates 12.891 ms/op 18.610 ms/op 0.69
mainnet_e217614 - capella processRewardsAndPenalties 84.120 ms/op 112.00 ms/op 0.75
mainnet_e217614 - capella processRegistryUpdates 4.8140 us/op 5.7780 us/op 0.83
mainnet_e217614 - capella processSlashings 375.00 ns/op 193.00 ns/op 1.94
mainnet_e217614 - capella processEth1DataReset 368.00 ns/op 198.00 ns/op 1.86
mainnet_e217614 - capella processEffectiveBalanceUpdates 19.637 ms/op 25.403 ms/op 0.77
mainnet_e217614 - capella processSlashingsReset 948.00 ns/op 812.00 ns/op 1.17
mainnet_e217614 - capella processRandaoMixesReset 1.2100 us/op 1.1920 us/op 1.02
mainnet_e217614 - capella processHistoricalRootsUpdate 369.00 ns/op 198.00 ns/op 1.86
mainnet_e217614 - capella processParticipationFlagUpdates 681.00 ns/op 541.00 ns/op 1.26
mainnet_e217614 - capella afterProcessEpoch 110.84 ms/op 116.19 ms/op 0.95
phase0 processEpoch - mainnet_e58758 206.09 ms/op 249.13 ms/op 0.83
mainnet_e58758 - phase0 beforeProcessEpoch 42.523 ms/op 53.435 ms/op 0.80
mainnet_e58758 - phase0 processJustificationAndFinalization 4.8550 us/op 6.2350 us/op 0.78
mainnet_e58758 - phase0 processRewardsAndPenalties 15.874 ms/op 23.378 ms/op 0.68
mainnet_e58758 - phase0 processRegistryUpdates 3.5430 us/op 3.1350 us/op 1.13
mainnet_e58758 - phase0 processSlashings 400.00 ns/op 222.00 ns/op 1.80
mainnet_e58758 - phase0 processEth1DataReset 400.00 ns/op 173.00 ns/op 2.31
mainnet_e58758 - phase0 processEffectiveBalanceUpdates 3.8034 ms/op 1.7745 ms/op 2.14
mainnet_e58758 - phase0 processSlashingsReset 1.1950 us/op 974.00 ns/op 1.23
mainnet_e58758 - phase0 processRandaoMixesReset 1.2400 us/op 1.1470 us/op 1.08
mainnet_e58758 - phase0 processHistoricalRootsUpdate 400.00 ns/op 188.00 ns/op 2.13
mainnet_e58758 - phase0 processParticipationRecordUpdates 1.2430 us/op 878.00 ns/op 1.42
mainnet_e58758 - phase0 afterProcessEpoch 33.792 ms/op 35.741 ms/op 0.95
phase0 processEffectiveBalanceUpdates - 250000 normalcase 942.72 us/op 2.0653 ms/op 0.46
phase0 processEffectiveBalanceUpdates - 250000 worstcase 0.5 1.8063 ms/op 2.1226 ms/op 0.85
altair processInactivityUpdates - 250000 normalcase 11.202 ms/op 15.058 ms/op 0.74
altair processInactivityUpdates - 250000 worstcase 11.215 ms/op 16.233 ms/op 0.69
phase0 processRegistryUpdates - 250000 normalcase 4.3430 us/op 5.1440 us/op 0.84
phase0 processRegistryUpdates - 250000 badcase_full_deposits 295.40 us/op 275.43 us/op 1.07
phase0 processRegistryUpdates - 250000 worstcase 0.5 61.296 ms/op 68.296 ms/op 0.90
altair processRewardsAndPenalties - 250000 normalcase 14.506 ms/op 19.491 ms/op 0.74
altair processRewardsAndPenalties - 250000 worstcase 12.638 ms/op 17.431 ms/op 0.73
phase0 getAttestationDeltas - 250000 normalcase 4.9043 ms/op 7.2228 ms/op 0.68
phase0 getAttestationDeltas - 250000 worstcase 4.6994 ms/op 6.7334 ms/op 0.70
phase0 processSlashings - 250000 worstcase 90.910 us/op 87.366 us/op 1.04
altair processSyncCommitteeUpdates - 250000 9.8293 ms/op 10.976 ms/op 0.90
BeaconState.hashTreeRoot - No change 427.00 ns/op 232.00 ns/op 1.84
BeaconState.hashTreeRoot - 1 full validator 57.765 us/op 84.975 us/op 0.68
BeaconState.hashTreeRoot - 32 full validator 982.86 us/op 962.04 us/op 1.02
BeaconState.hashTreeRoot - 512 full validator 7.2700 ms/op 7.2053 ms/op 1.01
BeaconState.hashTreeRoot - 1 validator.effectiveBalance 91.862 us/op 85.538 us/op 1.07
BeaconState.hashTreeRoot - 32 validator.effectiveBalance 1.6748 ms/op 1.5696 ms/op 1.07
BeaconState.hashTreeRoot - 512 validator.effectiveBalance 13.968 ms/op 14.429 ms/op 0.97
BeaconState.hashTreeRoot - 1 balances 65.284 us/op 83.170 us/op 0.78
BeaconState.hashTreeRoot - 32 balances 617.23 us/op 674.14 us/op 0.92
BeaconState.hashTreeRoot - 512 balances 4.5764 ms/op 5.6299 ms/op 0.81
BeaconState.hashTreeRoot - 250000 balances 97.658 ms/op 124.37 ms/op 0.79
aggregationBits - 2048 els - zipIndexesInBitList 20.656 us/op 21.006 us/op 0.98
regular array get 100000 times 22.612 us/op 24.934 us/op 0.91
wrappedArray get 100000 times 22.554 us/op 24.776 us/op 0.91
arrayWithProxy get 100000 times 11.341 ms/op 18.502 ms/op 0.61
ssz.Root.equals 21.926 ns/op 23.326 ns/op 0.94
byteArrayEquals 21.669 ns/op 22.902 ns/op 0.95
Buffer.compare 9.5500 ns/op 9.7760 ns/op 0.98
processSlot - 1 slots 7.9950 us/op 9.3880 us/op 0.85
processSlot - 32 slots 1.5696 ms/op 2.0086 ms/op 0.78
getEffectiveBalanceIncrementsZeroInactive - 250000 vs - 7PWei 4.5443 ms/op 3.8702 ms/op 1.17
getCommitteeAssignments - req 1 vs - 250000 vc 1.6622 ms/op 1.8599 ms/op 0.89
getCommitteeAssignments - req 100 vs - 250000 vc 3.4104 ms/op 3.6525 ms/op 0.93
getCommitteeAssignments - req 1000 vs - 250000 vc 3.6556 ms/op 3.9092 ms/op 0.94
findModifiedValidators - 10000 modified validators 632.22 ms/op 521.82 ms/op 1.21
findModifiedValidators - 1000 modified validators 395.76 ms/op 496.37 ms/op 0.80
findModifiedValidators - 100 modified validators 153.44 ms/op 295.39 ms/op 0.52
findModifiedValidators - 10 modified validators 171.97 ms/op 137.46 ms/op 1.25
findModifiedValidators - 1 modified validators 144.15 ms/op 163.91 ms/op 0.88
findModifiedValidators - no difference 144.89 ms/op 187.60 ms/op 0.77
migrate state 1500000 validators, 3400 modified, 2000 new 1.0985 s/op 1.0442 s/op 1.05
RootCache.getBlockRootAtSlot - 250000 vs - 7PWei 6.0900 ns/op 4.2700 ns/op 1.43
state getBlockRootAtSlot - 250000 vs - 7PWei 435.76 ns/op 600.05 ns/op 0.73
computeProposerIndex 100000 validators 1.3439 ms/op 1.5474 ms/op 0.87
getNextSyncCommitteeIndices 1000 validators 103.98 ms/op 116.79 ms/op 0.89
getNextSyncCommitteeIndices 10000 validators 104.30 ms/op 114.76 ms/op 0.91
getNextSyncCommitteeIndices 100000 validators 105.01 ms/op 114.03 ms/op 0.92
computeProposers - vc 250000 555.48 us/op 648.23 us/op 0.86
computeEpochShuffling - vc 250000 38.914 ms/op 41.958 ms/op 0.93
getNextSyncCommittee - vc 250000 10.093 ms/op 10.547 ms/op 0.96
nodejs block root to RootHex using toHex 107.58 ns/op 141.45 ns/op 0.76
nodejs block root to RootHex using toRootHex 68.305 ns/op 93.483 ns/op 0.73
nodejs fromHex(blob) 218.01 us/op 390.10 us/op 0.56
nodejs fromHexInto(blob) 631.82 us/op 705.47 us/op 0.90
nodejs block root to RootHex using the deprecated toHexString 512.16 ns/op 547.67 ns/op 0.94
nodejs byteArrayEquals 32 bytes (block root) 30.716 ns/op 28.776 ns/op 1.07
nodejs byteArrayEquals 48 bytes (pubkey) 43.748 ns/op 41.165 ns/op 1.06
nodejs byteArrayEquals 96 bytes (signature) 32.842 ns/op 40.561 ns/op 0.81
nodejs byteArrayEquals 1024 bytes 52.539 ns/op 46.638 ns/op 1.13
nodejs byteArrayEquals 131072 bytes (blob) 1.7462 us/op 1.8961 us/op 0.92
browser block root to RootHex using toHex 146.64 ns/op 165.41 ns/op 0.89
browser block root to RootHex using toRootHex 133.53 ns/op 155.23 ns/op 0.86
browser fromHex(blob) 1.0514 ms/op 1.1536 ms/op 0.91
browser fromHexInto(blob) 621.73 us/op 705.96 us/op 0.88
browser block root to RootHex using the deprecated toHexString 351.51 ns/op 396.37 ns/op 0.89
browser byteArrayEquals 32 bytes (block root) 29.370 ns/op 31.428 ns/op 0.93
browser byteArrayEquals 48 bytes (pubkey) 39.835 ns/op 43.752 ns/op 0.91
browser byteArrayEquals 96 bytes (signature) 74.646 ns/op 85.950 ns/op 0.87
browser byteArrayEquals 1024 bytes 749.49 ns/op 810.49 ns/op 0.92
browser byteArrayEquals 131072 bytes (blob) 92.651 us/op 102.27 us/op 0.91

by benchmarkbot/action

Comment thread packages/fork-choice/src/protoArray/interface.ts Outdated
Comment thread packages/fork-choice/src/protoArray/protoArray.ts Outdated
Comment thread packages/fork-choice/src/protoArray/protoArray.ts Outdated
Comment thread packages/fork-choice/src/protoArray/protoArray.ts Outdated
if (existedFullIndex !== undefined) {
const existedFullNode = this.nodes[existedFullIndex];
if (existedFullNode) {
// Pre-Gloas: execution payloads are part of the block, no separate event

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread packages/fork-choice/src/protoArray/interface.ts Outdated
Comment thread packages/fork-choice/src/forkChoice/forkChoice.ts
Comment thread packages/fork-choice/src/forkChoice/forkChoice.ts Outdated
Comment thread packages/fork-choice/src/protoArray/computeDeltas.ts
Comment thread packages/fork-choice/src/forkChoice/forkChoice.ts
twoeths and others added 2 commits February 6, 2026 23:14
**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>
@lodekeeper

lodekeeper commented Feb 20, 2026

Copy link
Copy Markdown
Contributor

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:

  1. LatestMessage update uses slot ordering (not epoch)
  2. Enforce Gloas attestation constraint: index == 0 when attestation.slot == block.slot
  3. Pass proposerBoostRoot into payload tiebreaker path
  4. Restrict EMPTY/FULL special tiebreak edge-case to slot n-1 only (not current slot)

Validation run in #8931:

  • pnpm --filter @lodestar/state-transition... build
  • pnpm --filter @lodestar/fork-choice test:unit -- --runInBand
  • pnpm --filter @lodestar/fork-choice lint

Happy to iterate further if you want these squashed/cherry-picked into #8739 directly.

lodekeeper added a commit to lodekeeper/lodestar that referenced this pull request Feb 20, 2026
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.
nflaig added a commit that referenced this pull request Feb 20, 2026
nflaig added a commit that referenced this pull request Feb 20, 2026
nflaig added a commit that referenced this pull request Feb 20, 2026

@twoeths twoeths left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getPayloadStatusTiebreaker is always called with null proposerBoostRoot, fixed in #8944

twoeths and others added 4 commits February 23, 2026 16:45
…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 lodekeeper left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread packages/fork-choice/src/forkChoice/forkChoice.ts
Comment thread packages/fork-choice/src/forkChoice/forkChoice.ts
Comment thread packages/fork-choice/src/forkChoice/forkChoice.ts Outdated
Comment thread packages/fork-choice/src/protoArray/protoArray.ts Outdated
Comment thread packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts Outdated
Comment thread packages/fork-choice/src/forkChoice/forkChoice.ts Outdated
Comment thread packages/fork-choice/src/protoArray/protoArray.ts
Comment thread packages/fork-choice/src/protoArray/protoArray.ts
Comment thread packages/fork-choice/src/protoArray/protoArray.ts
ensi321 and others added 2 commits February 26, 2026 20:20
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
twoeths
twoeths previously approved these changes Mar 2, 2026

@twoeths twoeths left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

metrics from a hoodi "subscrib-all-subnets" node (the last 3 days run this latest code)

Image Image

Comment thread packages/fork-choice/src/protoArray/protoArray.ts Outdated
Comment thread packages/fork-choice/test/unit/protoArray/gloas.test.ts Outdated
Comment thread packages/fork-choice/src/forkChoice/interface.ts Outdated
Comment thread packages/fork-choice/src/forkChoice/interface.ts
Comment thread packages/fork-choice/src/forkChoice/interface.ts
Comment thread packages/fork-choice/src/protoArray/protoArray.ts
Comment thread packages/fork-choice/src/protoArray/protoArray.ts Outdated
Comment thread packages/fork-choice/src/protoArray/protoArray.ts Outdated

@nflaig nflaig left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@ensi321
ensi321 merged commit 09945f6 into unstable Mar 4, 2026
18 checks passed
@ensi321
ensi321 deleted the nc/epbs-fc branch March 4, 2026 00:41
@codecov

codecov Bot commented Mar 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 52.30%. Comparing base (192806a) to head (20e3981).
⚠️ Report is 1 commits behind head on unstable.

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:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

lodekeeper pushed a commit to lodekeeper/lodestar that referenced this pull request Mar 13, 2026
# 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>
@wemeetagain

Copy link
Copy Markdown
Member

🎉 This PR is included in v1.41.0 🎉

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants