feat: implement forkchoice is_head_weak()#9654
Conversation
There was a problem hiding this comment.
Code Review
This pull request refactors the fork choice implementation to support the Gloas fork's modified is_head_weak specification. It splits the tracking of node weights into two separate components: attestationScore (representing attester votes only) and weight (which includes proposer boost). The isHeadWeak logic is extracted into a private helper method in ForkChoice that handles both pre-Gloas and post-Gloas specifications, including adding back the weight of equivocating validators for post-Gloas blocks. Additionally, deltas has been renamed to attestationDeltas across the codebase for clarity, and comprehensive unit tests have been added to verify the new behavior. I have no feedback to provide.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
Performance Report✔️ no performance regression detected Full benchmark results
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## unstable #9654 +/- ##
============================================
- Coverage 52.49% 52.48% -0.02%
============================================
Files 848 848
Lines 60485 60458 -27
Branches 4466 4459 -7
============================================
- Hits 31754 31732 -22
+ Misses 28670 28665 -5
Partials 61 61 🚀 New features to boost your workflow:
|
| // The default variant is PENDING for gloas, FULL pre-gloas. PENDING is the variant gloas measures | ||
| // support on, ie. support for the beacon block root regardless of its payload status. | ||
| // Only ever called on a block already in fork choice, so a miss is a broken invariant. | ||
| const node = this.protoArray.getNodeDefaultStatus(blockRoot); |
There was a problem hiding this comment.
So we already have an established pattern of getDefaultNodeIndex + getNodeByIndex in the code ie.
lodestar/packages/fork-choice/src/protoArray/protoArray.ts
Lines 695 to 699 in ea1ba2d
lodestar/packages/fork-choice/src/protoArray/protoArray.ts
Lines 880 to 885 in ea1ba2d
and
lodestar/packages/fork-choice/src/protoArray/protoArray.ts
Lines 1175 to 1189 in ea1ba2d
So we either keep that pattern, or refactor so these 3 places also use this.protoArray.getNodeDefaultStatus
| const parentStrongVariant = isGloasBlock(parentBlock) ? PayloadStatus.PENDING : PayloadStatus.FULL; | ||
| const parentNode = this.protoArray.getNode(parentBlock.blockRoot, parentStrongVariant); | ||
| // If parentNode is unavailable, give up reorg | ||
| if (parentNode === undefined || parentNode.weight <= parentThreshold) { |
There was a problem hiding this comment.
Make sure is_parent_strong is using parentNode.attestationScore post-gloas, and keep parentNode.weight pre-gloas.
Because gloas's spec uses get_attestation_score
| for (let index = 0; index < state.getBeaconCommitteeCountPerSlot(epoch); index++) { | ||
| for (const validatorIndex of state.getBeaconCommittee(node.slot, index)) { | ||
| if (equivocatingIndices.has(validatorIndex)) { | ||
| headWeight += this.fcStore.justified.balances[validatorIndex] ?? 0; |
There was a problem hiding this comment.
We can't store's justified balances here because it's 0 for equivocating validators.
Because we zero them here
lodestar/packages/state-transition/src/util/balance.ts
Lines 72 to 75 in ea1ba2d
that ultimately get propagated to store's justified balances.
We need the justified state's effectiveBalanceIncrements to get the non-zero'ed balance of equivocators.
| node.weight += nodeDelta; | ||
|
|
||
| // Update the parent delta (if any) | ||
| const isInvalid = node.executionStatus === ExecutionStatus.Invalid; |
There was a problem hiding this comment.
We probably need a unit test to test the code branch of node.executionStatus === ExecutionStatus.Invalid
| // Only ever called on a block already in fork choice, so a miss is a broken invariant. | ||
| const node = this.protoArray.getNodeDefaultStatus(blockRoot); | ||
| if (node === undefined) { | ||
| throw new ForkChoiceError({code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK, root: blockRoot}); |
There was a problem hiding this comment.
Note that if we throw error from this function, the error is uncaught all the way up to produceBlockV4 and produceEngineOrBuilderBlock leading to the entire block not being produced. I think if there is error, we should not re-org. We probably need to add a NotReorgedReason.ErrorEncountered or something similar if this happens.
Same with BEACON_STATE_ERROR below
Motivation
getProposerHead()Description
getProposerHead()AI Assistance Disclosure