Skip to content

feat: implement epbs fork choice#8739

Open
ensi321 wants to merge 56 commits intounstablefrom
nc/epbs-fc
Open

feat: implement epbs fork choice#8739
ensi321 wants to merge 56 commits intounstablefrom
nc/epbs-fc

Conversation

@ensi321
Copy link
Contributor

@ensi321 ensi321 commented Jan 13, 2026

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

Copy link
Contributor

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

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.

@github-actions
Copy link
Contributor

github-actions bot commented Jan 13, 2026

Performance Report

🚀🚀 Significant benchmark improvement detected

Benchmark suite Current: fa8874c Previous: ba49cac Ratio
pass gossip attestations to forkchoice per slot 472.48 us/op 2.1151 ms/op 0.22
Full benchmark results
Benchmark suite Current: fa8874c Previous: ba49cac Ratio
getPubkeys - index2pubkey - req 1000 vs - 250000 vc 1.1464 ms/op 931.14 us/op 1.23
getPubkeys - validatorsArr - req 1000 vs - 250000 vc 37.045 us/op 32.121 us/op 1.15
BLS verify - blst 867.65 us/op 874.89 us/op 0.99
BLS verifyMultipleSignatures 3 - blst 1.3172 ms/op 1.2876 ms/op 1.02
BLS verifyMultipleSignatures 8 - blst 2.0865 ms/op 2.0457 ms/op 1.02
BLS verifyMultipleSignatures 32 - blst 6.6608 ms/op 4.4019 ms/op 1.51
BLS verifyMultipleSignatures 64 - blst 10.239 ms/op 7.9772 ms/op 1.28
BLS verifyMultipleSignatures 128 - blst 16.881 ms/op 15.568 ms/op 1.08
BLS deserializing 10000 signatures 658.95 ms/op 617.04 ms/op 1.07
BLS deserializing 100000 signatures 6.5889 s/op 5.9885 s/op 1.10
BLS verifyMultipleSignatures - same message - 3 - blst 925.83 us/op 938.99 us/op 0.99
BLS verifyMultipleSignatures - same message - 8 - blst 1.0866 ms/op 1.0891 ms/op 1.00
BLS verifyMultipleSignatures - same message - 32 - blst 1.6641 ms/op 1.8361 ms/op 0.91
BLS verifyMultipleSignatures - same message - 64 - blst 2.5560 ms/op 2.4294 ms/op 1.05
BLS verifyMultipleSignatures - same message - 128 - blst 4.2947 ms/op 4.0089 ms/op 1.07
BLS aggregatePubkeys 32 - blst 19.032 us/op 17.310 us/op 1.10
BLS aggregatePubkeys 128 - blst 66.370 us/op 59.852 us/op 1.11
getSlashingsAndExits - default max 64.000 us/op 39.962 us/op 1.60
getSlashingsAndExits - 2k 325.76 us/op 315.21 us/op 1.03
isKnown best case - 1 super set check 240.00 ns/op 392.00 ns/op 0.61
isKnown normal case - 2 super set checks 193.00 ns/op 389.00 ns/op 0.50
isKnown worse case - 16 super set checks 192.00 ns/op 389.00 ns/op 0.49
validate api signedAggregateAndProof - struct 2.5790 ms/op 1.4520 ms/op 1.78
validate gossip signedAggregateAndProof - struct 2.5845 ms/op 1.4619 ms/op 1.77
batch validate gossip attestation - vc 640000 - chunk 32 159.37 us/op 107.94 us/op 1.48
batch validate gossip attestation - vc 640000 - chunk 64 97.481 us/op 91.981 us/op 1.06
batch validate gossip attestation - vc 640000 - chunk 128 92.728 us/op 85.250 us/op 1.09
batch validate gossip attestation - vc 640000 - chunk 256 88.718 us/op 81.170 us/op 1.09
bytes32 toHexString 350.00 ns/op 515.00 ns/op 0.68
bytes32 Buffer.toString(hex) 293.00 ns/op 405.00 ns/op 0.72
bytes32 Buffer.toString(hex) from Uint8Array 391.00 ns/op 477.00 ns/op 0.82
bytes32 Buffer.toString(hex) + 0x 249.00 ns/op 407.00 ns/op 0.61
Return object 10000 times 0.22270 ns/op 0.22620 ns/op 0.98
Throw Error 10000 times 3.8862 us/op 3.2373 us/op 1.20
toHex 135.34 ns/op 102.22 ns/op 1.32
Buffer.from 115.73 ns/op 94.840 ns/op 1.22
shared Buffer 78.717 ns/op 66.435 ns/op 1.18
fastMsgIdFn sha256 / 200 bytes 1.7900 us/op 1.7070 us/op 1.05
fastMsgIdFn h32 xxhash / 200 bytes 192.00 ns/op 375.00 ns/op 0.51
fastMsgIdFn h64 xxhash / 200 bytes 245.00 ns/op 427.00 ns/op 0.57
fastMsgIdFn sha256 / 1000 bytes 5.7430 us/op 4.9840 us/op 1.15
fastMsgIdFn h32 xxhash / 1000 bytes 354.00 ns/op 457.00 ns/op 0.77
fastMsgIdFn h64 xxhash / 1000 bytes 305.00 ns/op 471.00 ns/op 0.65
fastMsgIdFn sha256 / 10000 bytes 50.027 us/op 41.518 us/op 1.20
fastMsgIdFn h32 xxhash / 10000 bytes 1.5740 us/op 1.4740 us/op 1.07
fastMsgIdFn h64 xxhash / 10000 bytes 1.1130 us/op 1.0440 us/op 1.07
send data - 1000 256B messages 12.882 ms/op 10.311 ms/op 1.25
send data - 1000 512B messages 16.126 ms/op 12.571 ms/op 1.28
send data - 1000 1024B messages 21.532 ms/op 17.458 ms/op 1.23
send data - 1000 1200B messages 19.962 ms/op 15.368 ms/op 1.30
send data - 1000 2048B messages 23.241 ms/op 14.884 ms/op 1.56
send data - 1000 4096B messages 23.620 ms/op 18.992 ms/op 1.24
send data - 1000 16384B messages 106.56 ms/op 144.63 ms/op 0.74
send data - 1000 65536B messages 293.84 ms/op 187.65 ms/op 1.57
enrSubnets - fastDeserialize 64 bits 852.00 ns/op 955.00 ns/op 0.89
enrSubnets - ssz BitVector 64 bits 323.00 ns/op 494.00 ns/op 0.65
enrSubnets - fastDeserialize 4 bits 132.00 ns/op 307.00 ns/op 0.43
enrSubnets - ssz BitVector 4 bits 457.00 ns/op 478.00 ns/op 0.96
prioritizePeers score -10:0 att 32-0.1 sync 2-0 247.98 us/op 258.85 us/op 0.96
prioritizePeers score 0:0 att 32-0.25 sync 2-0.25 251.89 us/op 218.93 us/op 1.15
prioritizePeers score 0:0 att 32-0.5 sync 2-0.5 445.15 us/op 430.79 us/op 1.03
prioritizePeers score 0:0 att 64-0.75 sync 4-0.75 909.98 us/op 731.76 us/op 1.24
prioritizePeers score 0:0 att 64-1 sync 4-1 1.0024 ms/op 652.80 us/op 1.54
array of 16000 items push then shift 1.5332 us/op 1.1695 us/op 1.31
LinkedList of 16000 items push then shift 7.1680 ns/op 7.3250 ns/op 0.98
array of 16000 items push then pop 74.382 ns/op 62.237 ns/op 1.20
LinkedList of 16000 items push then pop 6.8760 ns/op 5.9180 ns/op 1.16
array of 24000 items push then shift 2.3109 us/op 1.7452 us/op 1.32
LinkedList of 24000 items push then shift 7.2760 ns/op 6.8070 ns/op 1.07
array of 24000 items push then pop 103.71 ns/op 87.185 ns/op 1.19
LinkedList of 24000 items push then pop 6.9990 ns/op 5.8570 ns/op 1.19
intersect bitArray bitLen 8 5.4740 ns/op 4.4430 ns/op 1.23
intersect array and set length 8 32.004 ns/op 27.202 ns/op 1.18
intersect bitArray bitLen 128 27.401 ns/op 24.152 ns/op 1.13
intersect array and set length 128 524.28 ns/op 458.63 ns/op 1.14
bitArray.getTrueBitIndexes() bitLen 128 977.00 ns/op 1.1190 us/op 0.87
bitArray.getTrueBitIndexes() bitLen 248 1.7490 us/op 1.8660 us/op 0.94
bitArray.getTrueBitIndexes() bitLen 512 4.0380 us/op 3.6410 us/op 1.11
Full columns - reconstruct all 6 blobs 305.12 us/op 313.41 us/op 0.97
Full columns - reconstruct half of the blobs out of 6 99.592 us/op 96.040 us/op 1.04
Full columns - reconstruct single blob out of 6 29.049 us/op 35.913 us/op 0.81
Half columns - reconstruct all 6 blobs 278.11 ms/op 230.32 ms/op 1.21
Half columns - reconstruct half of the blobs out of 6 131.92 ms/op 119.49 ms/op 1.10
Half columns - reconstruct single blob out of 6 47.937 ms/op 45.009 ms/op 1.07
Full columns - reconstruct all 10 blobs 390.92 us/op 381.73 us/op 1.02
Full columns - reconstruct half of the blobs out of 10 158.11 us/op 179.22 us/op 0.88
Full columns - reconstruct single blob out of 10 30.005 us/op 36.930 us/op 0.81
Half columns - reconstruct all 10 blobs 426.66 ms/op 391.08 ms/op 1.09
Half columns - reconstruct half of the blobs out of 10 216.52 ms/op 199.06 ms/op 1.09
Half columns - reconstruct single blob out of 10 47.388 ms/op 44.949 ms/op 1.05
Full columns - reconstruct all 20 blobs 788.77 us/op 696.26 us/op 1.13
Full columns - reconstruct half of the blobs out of 20 358.05 us/op 292.83 us/op 1.22
Full columns - reconstruct single blob out of 20 30.056 us/op 39.728 us/op 0.76
Half columns - reconstruct all 20 blobs 851.53 ms/op 849.84 ms/op 1.00
Half columns - reconstruct half of the blobs out of 20 426.59 ms/op 488.63 ms/op 0.87
Half columns - reconstruct single blob out of 20 47.471 ms/op 44.298 ms/op 1.07
Set add up to 64 items then delete first 2.0165 us/op 1.6288 us/op 1.24
OrderedSet add up to 64 items then delete first 3.0044 us/op 2.3676 us/op 1.27
Set add up to 64 items then delete last 2.2756 us/op 1.7777 us/op 1.28
OrderedSet add up to 64 items then delete last 3.4658 us/op 2.8088 us/op 1.23
Set add up to 64 items then delete middle 2.2918 us/op 2.1421 us/op 1.07
OrderedSet add up to 64 items then delete middle 4.8757 us/op 4.3912 us/op 1.11
Set add up to 128 items then delete first 4.7112 us/op 3.4568 us/op 1.36
OrderedSet add up to 128 items then delete first 7.0660 us/op 5.6071 us/op 1.26
Set add up to 128 items then delete last 4.6762 us/op 3.4599 us/op 1.35
OrderedSet add up to 128 items then delete last 6.7322 us/op 5.6612 us/op 1.19
Set add up to 128 items then delete middle 4.4869 us/op 3.7814 us/op 1.19
OrderedSet add up to 128 items then delete middle 12.845 us/op 10.947 us/op 1.17
Set add up to 256 items then delete first 9.7758 us/op 6.8422 us/op 1.43
OrderedSet add up to 256 items then delete first 14.619 us/op 10.979 us/op 1.33
Set add up to 256 items then delete last 9.1335 us/op 7.1485 us/op 1.28
OrderedSet add up to 256 items then delete last 13.986 us/op 11.336 us/op 1.23
Set add up to 256 items then delete middle 9.0791 us/op 7.1267 us/op 1.27
OrderedSet add up to 256 items then delete middle 39.569 us/op 33.974 us/op 1.16
pass gossip attestations to forkchoice per slot 472.48 us/op 2.1151 ms/op 0.22
computeDeltas 1400000 validators 0% inactive 13.759 ms/op 12.007 ms/op 1.15
computeDeltas 1400000 validators 10% inactive 12.876 ms/op 12.953 ms/op 0.99
computeDeltas 1400000 validators 20% inactive 11.932 ms/op 11.257 ms/op 1.06
computeDeltas 1400000 validators 50% inactive 9.3253 ms/op 8.4424 ms/op 1.10
computeDeltas 2100000 validators 0% inactive 20.613 ms/op 19.748 ms/op 1.04
computeDeltas 2100000 validators 10% inactive 19.655 ms/op 18.706 ms/op 1.05
computeDeltas 2100000 validators 20% inactive 17.940 ms/op 17.163 ms/op 1.05
computeDeltas 2100000 validators 50% inactive 13.932 ms/op 13.079 ms/op 1.07
altair processAttestation - 250000 vs - 7PWei normalcase 1.7733 ms/op 1.6115 ms/op 1.10
altair processAttestation - 250000 vs - 7PWei worstcase 2.6027 ms/op 2.3672 ms/op 1.10
altair processAttestation - setStatus - 1/6 committees join 111.90 us/op 93.361 us/op 1.20
altair processAttestation - setStatus - 1/3 committees join 221.71 us/op 176.06 us/op 1.26
altair processAttestation - setStatus - 1/2 committees join 307.67 us/op 253.26 us/op 1.21
altair processAttestation - setStatus - 2/3 committees join 393.88 us/op 362.07 us/op 1.09
altair processAttestation - setStatus - 4/5 committees join 549.09 us/op 500.93 us/op 1.10
altair processAttestation - setStatus - 100% committees join 655.79 us/op 564.38 us/op 1.16
altair processBlock - 250000 vs - 7PWei normalcase 3.4238 ms/op 3.6746 ms/op 0.93
altair processBlock - 250000 vs - 7PWei normalcase hashState 21.545 ms/op 16.234 ms/op 1.33
altair processBlock - 250000 vs - 7PWei worstcase 23.289 ms/op 23.017 ms/op 1.01
altair processBlock - 250000 vs - 7PWei worstcase hashState 72.820 ms/op 55.198 ms/op 1.32
phase0 processBlock - 250000 vs - 7PWei normalcase 1.5369 ms/op 1.4470 ms/op 1.06
phase0 processBlock - 250000 vs - 7PWei worstcase 19.719 ms/op 21.701 ms/op 0.91
altair processEth1Data - 250000 vs - 7PWei normalcase 354.63 us/op 277.63 us/op 1.28
getExpectedWithdrawals 250000 eb:1,eth1:1,we:0,wn:0,smpl:16 7.2280 us/op 4.0490 us/op 1.79
getExpectedWithdrawals 250000 eb:0.95,eth1:0.1,we:0.05,wn:0,smpl:220 57.735 us/op 32.695 us/op 1.77
getExpectedWithdrawals 250000 eb:0.95,eth1:0.3,we:0.05,wn:0,smpl:43 12.451 us/op 9.4790 us/op 1.31
getExpectedWithdrawals 250000 eb:0.95,eth1:0.7,we:0.05,wn:0,smpl:19 6.6350 us/op 3.6440 us/op 1.82
getExpectedWithdrawals 250000 eb:0.1,eth1:0.1,we:0,wn:0,smpl:1021 201.25 us/op 146.65 us/op 1.37
getExpectedWithdrawals 250000 eb:0.03,eth1:0.03,we:0,wn:0,smpl:11778 1.6579 ms/op 1.3911 ms/op 1.19
getExpectedWithdrawals 250000 eb:0.01,eth1:0.01,we:0,wn:0,smpl:16384 2.0681 ms/op 1.8038 ms/op 1.15
getExpectedWithdrawals 250000 eb:0,eth1:0,we:0,wn:0,smpl:16384 2.1810 ms/op 1.8077 ms/op 1.21
getExpectedWithdrawals 250000 eb:0,eth1:0,we:0,wn:0,nocache,smpl:16384 4.1851 ms/op 3.5940 ms/op 1.16
getExpectedWithdrawals 250000 eb:0,eth1:1,we:0,wn:0,smpl:16384 2.5019 ms/op 2.0294 ms/op 1.23
getExpectedWithdrawals 250000 eb:0,eth1:1,we:0,wn:0,nocache,smpl:16384 4.5940 ms/op 3.8991 ms/op 1.18
Tree 40 250000 create 402.01 ms/op 312.11 ms/op 1.29
Tree 40 250000 get(125000) 124.12 ns/op 98.078 ns/op 1.27
Tree 40 250000 set(125000) 1.2054 us/op 1.0527 us/op 1.15
Tree 40 250000 toArray() 12.397 ms/op 9.2951 ms/op 1.33
Tree 40 250000 iterate all - toArray() + loop 12.490 ms/op 9.3586 ms/op 1.33
Tree 40 250000 iterate all - get(i) 41.973 ms/op 33.919 ms/op 1.24
Array 250000 create 2.4366 ms/op 2.0071 ms/op 1.21
Array 250000 clone - spread 797.29 us/op 611.04 us/op 1.30
Array 250000 get(125000) 0.34600 ns/op 0.49900 ns/op 0.69
Array 250000 set(125000) 0.49500 ns/op 0.50000 ns/op 0.99
Array 250000 iterate all - loop 59.113 us/op 55.721 us/op 1.06
phase0 afterProcessEpoch - 250000 vs - 7PWei 40.082 ms/op 37.603 ms/op 1.07
Array.fill - length 1000000 2.7681 ms/op 2.0704 ms/op 1.34
Array push - length 1000000 10.772 ms/op 6.9405 ms/op 1.55
Array.get 0.21043 ns/op 0.19929 ns/op 1.06
Uint8Array.get 0.21930 ns/op 0.20898 ns/op 1.05
phase0 beforeProcessEpoch - 250000 vs - 7PWei 15.529 ms/op 13.478 ms/op 1.15
altair processEpoch - mainnet_e81889 244.25 ms/op 224.36 ms/op 1.09
mainnet_e81889 - altair beforeProcessEpoch 16.408 ms/op 14.220 ms/op 1.15
mainnet_e81889 - altair processJustificationAndFinalization 5.2120 us/op 4.4440 us/op 1.17
mainnet_e81889 - altair processInactivityUpdates 3.6670 ms/op 3.4031 ms/op 1.08
mainnet_e81889 - altair processRewardsAndPenalties 16.985 ms/op 18.395 ms/op 0.92
mainnet_e81889 - altair processRegistryUpdates 618.00 ns/op 794.00 ns/op 0.78
mainnet_e81889 - altair processSlashings 228.00 ns/op 371.00 ns/op 0.61
mainnet_e81889 - altair processEth1DataReset 175.00 ns/op 366.00 ns/op 0.48
mainnet_e81889 - altair processEffectiveBalanceUpdates 2.1482 ms/op 1.2375 ms/op 1.74
mainnet_e81889 - altair processSlashingsReset 828.00 ns/op 939.00 ns/op 0.88
mainnet_e81889 - altair processRandaoMixesReset 1.0560 us/op 1.5190 us/op 0.70
mainnet_e81889 - altair processHistoricalRootsUpdate 165.00 ns/op 365.00 ns/op 0.45
mainnet_e81889 - altair processParticipationFlagUpdates 609.00 ns/op 713.00 ns/op 0.85
mainnet_e81889 - altair processSyncCommitteeUpdates 126.00 ns/op 323.00 ns/op 0.39
mainnet_e81889 - altair afterProcessEpoch 43.518 ms/op 36.653 ms/op 1.19
capella processEpoch - mainnet_e217614 857.19 ms/op 728.72 ms/op 1.18
mainnet_e217614 - capella beforeProcessEpoch 67.392 ms/op 63.981 ms/op 1.05
mainnet_e217614 - capella processJustificationAndFinalization 5.5510 us/op 5.1830 us/op 1.07
mainnet_e217614 - capella processInactivityUpdates 14.591 ms/op 11.115 ms/op 1.31
mainnet_e217614 - capella processRewardsAndPenalties 102.23 ms/op 89.282 ms/op 1.15
mainnet_e217614 - capella processRegistryUpdates 5.4910 us/op 4.7060 us/op 1.17
mainnet_e217614 - capella processSlashings 195.00 ns/op 361.00 ns/op 0.54
mainnet_e217614 - capella processEth1DataReset 164.00 ns/op 360.00 ns/op 0.46
mainnet_e217614 - capella processEffectiveBalanceUpdates 11.030 ms/op 23.695 ms/op 0.47
mainnet_e217614 - capella processSlashingsReset 780.00 ns/op 940.00 ns/op 0.83
mainnet_e217614 - capella processRandaoMixesReset 1.2970 us/op 1.1980 us/op 1.08
mainnet_e217614 - capella processHistoricalRootsUpdate 165.00 ns/op 366.00 ns/op 0.45
mainnet_e217614 - capella processParticipationFlagUpdates 588.00 ns/op 734.00 ns/op 0.80
mainnet_e217614 - capella afterProcessEpoch 110.70 ms/op 111.02 ms/op 1.00
phase0 processEpoch - mainnet_e58758 228.48 ms/op 190.81 ms/op 1.20
mainnet_e58758 - phase0 beforeProcessEpoch 42.386 ms/op 40.458 ms/op 1.05
mainnet_e58758 - phase0 processJustificationAndFinalization 5.0670 us/op 5.2480 us/op 0.97
mainnet_e58758 - phase0 processRewardsAndPenalties 17.014 ms/op 17.917 ms/op 0.95
mainnet_e58758 - phase0 processRegistryUpdates 2.7110 us/op 2.6540 us/op 1.02
mainnet_e58758 - phase0 processSlashings 219.00 ns/op 487.00 ns/op 0.45
mainnet_e58758 - phase0 processEth1DataReset 165.00 ns/op 383.00 ns/op 0.43
mainnet_e58758 - phase0 processEffectiveBalanceUpdates 902.71 us/op 895.77 us/op 1.01
mainnet_e58758 - phase0 processSlashingsReset 859.00 ns/op 1.1260 us/op 0.76
mainnet_e58758 - phase0 processRandaoMixesReset 1.0420 us/op 1.1900 us/op 0.88
mainnet_e58758 - phase0 processHistoricalRootsUpdate 194.00 ns/op 374.00 ns/op 0.52
mainnet_e58758 - phase0 processParticipationRecordUpdates 835.00 ns/op 1.3960 us/op 0.60
mainnet_e58758 - phase0 afterProcessEpoch 34.165 ms/op 32.957 ms/op 1.04
phase0 processEffectiveBalanceUpdates - 250000 normalcase 1.4932 ms/op 950.82 us/op 1.57
phase0 processEffectiveBalanceUpdates - 250000 worstcase 0.5 1.9406 ms/op 1.1550 ms/op 1.68
altair processInactivityUpdates - 250000 normalcase 12.160 ms/op 12.016 ms/op 1.01
altair processInactivityUpdates - 250000 worstcase 12.745 ms/op 11.352 ms/op 1.12
phase0 processRegistryUpdates - 250000 normalcase 4.6100 us/op 5.8610 us/op 0.79
phase0 processRegistryUpdates - 250000 badcase_full_deposits 368.99 us/op 242.48 us/op 1.52
phase0 processRegistryUpdates - 250000 worstcase 0.5 67.495 ms/op 58.204 ms/op 1.16
altair processRewardsAndPenalties - 250000 normalcase 17.055 ms/op 14.095 ms/op 1.21
altair processRewardsAndPenalties - 250000 worstcase 24.981 ms/op 17.632 ms/op 1.42
phase0 getAttestationDeltas - 250000 normalcase 5.6865 ms/op 5.0768 ms/op 1.12
phase0 getAttestationDeltas - 250000 worstcase 5.7189 ms/op 5.1363 ms/op 1.11
phase0 processSlashings - 250000 worstcase 109.38 us/op 94.217 us/op 1.16
altair processSyncCommitteeUpdates - 250000 10.948 ms/op 9.9327 ms/op 1.10
BeaconState.hashTreeRoot - No change 213.00 ns/op 506.00 ns/op 0.42
BeaconState.hashTreeRoot - 1 full validator 91.254 us/op 69.846 us/op 1.31
BeaconState.hashTreeRoot - 32 full validator 868.62 us/op 799.49 us/op 1.09
BeaconState.hashTreeRoot - 512 full validator 8.7524 ms/op 6.0718 ms/op 1.44
BeaconState.hashTreeRoot - 1 validator.effectiveBalance 107.79 us/op 89.108 us/op 1.21
BeaconState.hashTreeRoot - 32 validator.effectiveBalance 1.7613 ms/op 1.4803 ms/op 1.19
BeaconState.hashTreeRoot - 512 validator.effectiveBalance 20.116 ms/op 15.004 ms/op 1.34
BeaconState.hashTreeRoot - 1 balances 94.434 us/op 85.452 us/op 1.11
BeaconState.hashTreeRoot - 32 balances 864.05 us/op 897.70 us/op 0.96
BeaconState.hashTreeRoot - 512 balances 6.4808 ms/op 5.3552 ms/op 1.21
BeaconState.hashTreeRoot - 250000 balances 152.03 ms/op 165.99 ms/op 0.92
aggregationBits - 2048 els - zipIndexesInBitList 20.570 us/op 18.471 us/op 1.11
regular array get 100000 times 23.983 us/op 21.356 us/op 1.12
wrappedArray get 100000 times 23.832 us/op 21.048 us/op 1.13
arrayWithProxy get 100000 times 14.443 ms/op 8.9100 ms/op 1.62
ssz.Root.equals 23.165 ns/op 19.987 ns/op 1.16
byteArrayEquals 22.725 ns/op 19.568 ns/op 1.16
Buffer.compare 9.6880 ns/op 8.2410 ns/op 1.18
processSlot - 1 slots 11.392 us/op 9.8590 us/op 1.16
processSlot - 32 slots 2.4487 ms/op 2.6633 ms/op 0.92
getEffectiveBalanceIncrementsZeroInactive - 250000 vs - 7PWei 3.8698 ms/op 4.2193 ms/op 0.92
getCommitteeAssignments - req 1 vs - 250000 vc 1.8481 ms/op 1.5230 ms/op 1.21
getCommitteeAssignments - req 100 vs - 250000 vc 3.5780 ms/op 3.1016 ms/op 1.15
getCommitteeAssignments - req 1000 vs - 250000 vc 3.7981 ms/op 3.3647 ms/op 1.13
findModifiedValidators - 10000 modified validators 470.67 ms/op 508.78 ms/op 0.93
findModifiedValidators - 1000 modified validators 307.62 ms/op 396.46 ms/op 0.78
findModifiedValidators - 100 modified validators 251.78 ms/op 257.85 ms/op 0.98
findModifiedValidators - 10 modified validators 158.05 ms/op 190.57 ms/op 0.83
findModifiedValidators - 1 modified validators 116.99 ms/op 106.42 ms/op 1.10
findModifiedValidators - no difference 155.15 ms/op 104.35 ms/op 1.49
migrate state 1500000 validators, 3400 modified, 2000 new 967.98 ms/op 896.48 ms/op 1.08
RootCache.getBlockRootAtSlot - 250000 vs - 7PWei 5.9100 ns/op 5.7700 ns/op 1.02
state getBlockRootAtSlot - 250000 vs - 7PWei 846.06 ns/op 575.89 ns/op 1.47
computeProposerIndex 100000 validators 1.4729 ms/op 1.3312 ms/op 1.11
getNextSyncCommitteeIndices 1000 validators 113.09 ms/op 99.791 ms/op 1.13
getNextSyncCommitteeIndices 10000 validators 113.19 ms/op 99.795 ms/op 1.13
getNextSyncCommitteeIndices 100000 validators 113.48 ms/op 99.560 ms/op 1.14
computeProposers - vc 250000 600.14 us/op 525.54 us/op 1.14
computeEpochShuffling - vc 250000 40.388 ms/op 34.736 ms/op 1.16
getNextSyncCommittee - vc 250000 10.161 ms/op 8.5348 ms/op 1.19
nodejs block root to RootHex using toHex 141.93 ns/op 96.200 ns/op 1.48
nodejs block root to RootHex using toRootHex 78.900 ns/op 61.845 ns/op 1.28
nodejs fromHex(blob) 452.13 us/op 393.92 us/op 1.15
nodejs fromHexInto(blob) 690.90 us/op 581.26 us/op 1.19
nodejs block root to RootHex using the deprecated toHexString 531.73 ns/op 446.50 ns/op 1.19
nodejs byteArrayEquals 32 bytes (block root) 27.626 ns/op 24.066 ns/op 1.15
nodejs byteArrayEquals 48 bytes (pubkey) 39.556 ns/op 34.087 ns/op 1.16
nodejs byteArrayEquals 96 bytes (signature) 38.084 ns/op 31.416 ns/op 1.21
nodejs byteArrayEquals 1024 bytes 44.246 ns/op 37.529 ns/op 1.18
nodejs byteArrayEquals 131072 bytes (blob) 1.8325 us/op 1.5805 us/op 1.16
browser block root to RootHex using toHex 157.35 ns/op 128.49 ns/op 1.22
browser block root to RootHex using toRootHex 148.86 ns/op 117.86 ns/op 1.26
browser fromHex(blob) 1.2696 ms/op 735.11 us/op 1.73
browser fromHexInto(blob) 678.76 us/op 660.81 us/op 1.03
browser block root to RootHex using the deprecated toHexString 369.88 ns/op 527.65 ns/op 0.70
browser byteArrayEquals 32 bytes (block root) 30.319 ns/op 27.581 ns/op 1.10
browser byteArrayEquals 48 bytes (pubkey) 45.197 ns/op 38.799 ns/op 1.16
browser byteArrayEquals 96 bytes (signature) 82.765 ns/op 72.486 ns/op 1.14
browser byteArrayEquals 1024 bytes 775.42 ns/op 736.24 ns/op 1.05
browser byteArrayEquals 131072 bytes (blob) 98.406 us/op 92.546 us/op 1.06

by benchmarkbot/action

if (existedFullIndex !== undefined) {
const existedFullNode = this.nodes[existedFullIndex];
if (existedFullNode) {
// Pre-Gloas: execution payloads are part of the block, no separate event
Copy link
Contributor

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

twoeths added a commit that referenced this pull request Jan 30, 2026
**Motivation**

- `getBlockSlotState()` with only block root is ambiguous in the context
of ePBS because for each block there are 2 variants: EMPTY vs FULL

**Description**

- refactor it to accept a ProtoBlock as 1st param. When we implement
ePBS forkchoice in #8739 we have the context of variant there
- change all consumers to provide ProtoBlock
- later on we should enhance our state caches to get the correct
BeaconState based on that `ProtoBlock`
 
part of #8439

cc @ensi321 @nflaig

---------

Co-authored-by: Tuyen Nguyen <twoeths@users.noreply.github.com>
# Summary of Changes

## Overview
Updated fork choice to store and use payload status for both finalized
and justified checkpoints. This ensures `getFinalizedBlock()` and
`getJustifiedBlock()` return the correct block variant (EMPTY or FULL)
based on checkpoint state.

## 1. Added `CheckpointWithPayload` Type

**New type in `store.ts`:**
```typescript
export type CheckpointWithPayload = CheckpointWithHex & {payloadStatus: PayloadStatus};
```

This type extends `CheckpointWithHex` with a `payloadStatus` field to
track whether the checkpoint uses EMPTY or FULL block variant.

## 2. Updated ForkChoiceStore

**Constructor changes:**
- Now takes `justifiedPayloadStatus` and `finalizedPayloadStatus`
parameters
- Stores both finalized and justified checkpoints as
`CheckpointWithPayload`

**Interface changes:**
- `finalizedCheckpoint` and `unrealizedFinalizedCheckpoint` →
`CheckpointWithPayload`
- `justified` and `unrealizedJustified` → use `CheckpointWithPayload`
(via renamed types)

## 3. Renamed Balance Types

- `CheckpointHexWithBalance` → `CheckpointWithPayloadAndBalance`
- `CheckpointHexWithTotalBalance` →
`CheckpointWithPayloadAndTotalBalance`

Both now use `CheckpointWithPayload` instead of `CheckpointWithHex`.

## 4. Added `getCheckpointPayloadStatus()` Helper

**Purpose:** Determines payload status for a checkpoint by checking
`state.executionPayloadAvailability`

**Logic:**
- Pre-Gloas: always returns `FULL`
- Gloas: checks `state.executionPayloadAvailability` at checkpoint slot

**Signature:**
```typescript
export function getCheckpointPayloadStatus(
  state: CachedBeaconStateAllForks,
  checkpointEpoch: number
): PayloadStatus
```

## 5. Updated `onBlock()` Processing

**For justified checkpoint:**
- Calls `getCheckpointPayloadStatus()` to compute payload status
- Creates `CheckpointWithPayload` with computed status
- Updates both realized and unrealized justified checkpoints

**For finalized checkpoint:**
- Calls `getCheckpointPayloadStatus()` to compute payload status
- Creates `CheckpointWithPayload` with computed status
- Updates both realized and unrealized finalized checkpoints

## 6. Updated Fork Choice Methods

**`getFinalizedBlock()`:**
- Now uses `this.fcStore.finalizedCheckpoint.payloadStatus` instead of
always using `PayloadStatus.FULL`

**`getJustifiedBlock()`:**
- Now uses `this.fcStore.justified.checkpoint.payloadStatus` instead of
always using `PayloadStatus.FULL`

**`getFinalizedCheckpoint()` and `getJustifiedCheckpoint()`:**
- Return type changed to `CheckpointWithPayload`

## 7. Updated Initialization

**`initializeForkChoiceFromFinalizedState()`:**
- Computes `justifiedPayloadStatus` using `getCheckpointPayloadStatus()`
- Computes `finalizedPayloadStatus` using `getCheckpointPayloadStatus()`
- Passes both to `ForkChoiceStore` constructor

**`initializeForkChoiceFromUnfinalizedState()`:**
- Computes payload status for both justified and finalized checkpoints
- Passes both to `ForkChoiceStore` constructor
Copy link
Contributor

@twoeths twoeths left a comment

Choose a reason for hiding this comment

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

we need to check all *iterator*Ancestor*() logic not to use isDefaultVariant() there
as a consumer, it needs to know the exact correct EMPTY or FULL node at each slot (not PENDING) based on get_parent_payload_status() function so that it can query correct data from there. The logic should mimic get_ancestor() function in the spec

twoeths and others added 6 commits February 4, 2026 15:07
**Motivation**

- right now we model both pre-gloas and gloas variants as Array, which
has some disadvantages:
- array type is too general, it could be 1000 items while we only have
1/2/3 items
- item 0 of pre-gloas block is FULL variant index, while item 0 of gloas
blocks is PENDING variant index which is a little bit confusing to
maintain
- we check `lengh=1` as pre-gloas, which is correct based on the
implementation but ideally we can do better typing to know it

**Description**

- model pre-gloas variant index as number
- model gloas variants using tuple: `[number, number]` or `[number,
number, number]`

part of #8739

cc @ensi321 @nflaig

---------

Co-authored-by: Tuyen Nguyen <twoeths@users.noreply.github.com>
Co-authored-by: NC <17676176+ensi321@users.noreply.github.com>
**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>
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.

2 participants