From 9bc6eb6ce586900d3142539c857506ec02150003 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Fri, 17 Apr 2026 19:22:05 -0700 Subject: [PATCH 1/7] feat: implement should_apply_proposer_boost for gloas Implement the `should_apply_proposer_boost` logic from consensus-specs commit 71d1151 (PR #4807). This addresses the builder reveal safety concern where a colluding next-slot proposer could use proposer boost to override a legitimately revealed block. Changes: - Add `ptcTimeliness` and `proposerIndex` fields to ProtoBlock - Add `isBlockPtcTimely` to track PTC deadline timeliness - Add `shouldApplyProposerBoost` which withholds boost when the parent is a weak, equivocating block from the previous slot - Add `findEquivocatingBlocks` in ProtoArray to detect proposer equivocations by scanning for PTC-timely blocks at the same slot from the same proposer - Gate proposer boost in `getWeight` on `shouldApplyProposerBoost()` - Pre-gloas blocks retain unconditional boost (backward compatible) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../beacon-node/src/chain/forkChoice/index.ts | 4 ++ .../fork-choice/src/forkChoice/forkChoice.ts | 64 ++++++++++++++++++- .../fork-choice/src/protoArray/interface.ts | 7 ++ .../fork-choice/src/protoArray/protoArray.ts | 24 +++++++ 4 files changed, 98 insertions(+), 1 deletion(-) diff --git a/packages/beacon-node/src/chain/forkChoice/index.ts b/packages/beacon-node/src/chain/forkChoice/index.ts index e7aaefe24b31..dd952f218fc0 100644 --- a/packages/beacon-node/src/chain/forkChoice/index.ts +++ b/packages/beacon-node/src/chain/forkChoice/index.ts @@ -138,6 +138,8 @@ export function initializeForkChoiceFromFinalizedState( stateRoot: toRootHex(blockHeader.stateRoot), blockRoot: toRootHex(checkpoint.root), timeliness: true, // Optimistically assume is timely + ptcTimeliness: true, // Spec: block_timeliness for anchor = [True, True] + proposerIndex: blockHeader.proposerIndex, justifiedEpoch: justifiedCheckpoint.epoch, justifiedRoot: toRootHex(justifiedCheckpoint.root), @@ -233,6 +235,8 @@ export function initializeForkChoiceFromUnfinalizedState( blockRoot: headRoot, targetRoot: headRoot, timeliness: true, // Optimistically assume is timely + ptcTimeliness: true, // Spec: block_timeliness for anchor = [True, True] + proposerIndex: blockHeader.proposerIndex, justifiedEpoch: justifiedCheckpoint.epoch, justifiedRoot: toRootHex(justifiedCheckpoint.root), diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index ac223f321db7..734df867aefd 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -508,7 +508,7 @@ export class ForkChoice implements IForkChoice { * starting from the proposerIndex */ let proposerBoost: {root: RootHex; score: number} | null = null; - if (this.opts?.proposerBoost && this.proposerBoostRoot) { + if (this.opts?.proposerBoost && this.proposerBoostRoot && this.shouldApplyProposerBoost()) { const proposerBoostScore = this.justifiedProposerBoostScore ?? getCommitteeFraction(this.fcStore.justified.totalBalance, { @@ -786,6 +786,8 @@ export class ForkChoice implements IForkChoice { targetRoot: toRootHex(targetRoot), stateRoot: toRootHex(block.stateRoot), timeliness: isTimely, + ptcTimeliness: this.isBlockPtcTimely(block, blockDelaySec), + proposerIndex: block.proposerIndex, justifiedEpoch: stateJustifiedEpoch, justifiedRoot: toRootHex(state.currentJustifiedCheckpoint.root), @@ -1436,6 +1438,66 @@ export class ForkChoice implements IForkChoice { return this.fcStore.currentSlot === block.slot && isBeforeLateBlockCutoff; } + /** + * Check if block arrived before the PTC deadline. + * Spec: gloas/fork-choice.md#record_block_timeliness (block_timeliness[PTC_TIMELINESS_INDEX]) + */ + private isBlockPtcTimely(block: BeaconBlock, blockDelaySec: number): boolean { + const isCurrentSlot = this.fcStore.currentSlot === block.slot; + const ptcThresholdMs = this.config.getSlotComponentDurationMs(this.config.PAYLOAD_ATTESTATION_DUE_BPS); + return isCurrentSlot && blockDelaySec * 1000 < ptcThresholdMs; + } + + /** + * Spec: gloas/fork-choice.md#new-should_apply_proposer_boost + * Determines whether proposer boost should apply for gloas blocks. + * Returns true for pre-gloas blocks (unconditional boost). + */ + private shouldApplyProposerBoost(): boolean { + if (!this.proposerBoostRoot) { + return false; + } + + const boostedBlock = this.getBlockHexDefaultStatus(this.proposerBoostRoot); + if (!boostedBlock || !isGloasBlock(boostedBlock)) { + // Pre-gloas blocks always get boost + return true; + } + + const parentBlock = this.getBlockHexDefaultStatus(boostedBlock.parentRoot); + if (!parentBlock) { + return true; + } + + const slot = boostedBlock.slot; + + // Apply proposer boost if parent is not from the previous slot + if (parentBlock.slot + 1 < slot) { + return true; + } + + // Apply proposer boost if parent is not weak + const reorgThreshold = getCommitteeFraction(this.fcStore.justified.totalBalance, { + slotsPerEpoch: SLOTS_PER_EPOCH, + committeePercent: this.config.REORG_HEAD_WEIGHT_THRESHOLD, + }); + const parentNode = this.protoArray.getNode(parentBlock.blockRoot, PayloadStatus.PENDING); + if (parentNode === undefined || parentNode.weight >= reorgThreshold) { + // Parent is not weak + return true; + } + + // Parent is weak and from the previous slot: apply boost if there are no equivocations + // Look for other PTC-timely blocks at the same slot from the same proposer + const equivocations = this.protoArray.findEquivocatingBlocks( + parentBlock.proposerIndex, + parentBlock.slot, + parentBlock.blockRoot + ); + + return equivocations.length === 0; + } + /** * https://github.com/ethereum/consensus-specs/blob/v1.5.0/specs/phase0/fork-choice.md#is_proposing_on_time */ diff --git a/packages/fork-choice/src/protoArray/interface.ts b/packages/fork-choice/src/protoArray/interface.ts index cae46fdacd55..6ab1c88f26c7 100644 --- a/packages/fork-choice/src/protoArray/interface.ts +++ b/packages/fork-choice/src/protoArray/interface.ts @@ -135,6 +135,13 @@ export type ProtoBlock = BlockExtraMeta & { // Indicate whether block arrives in a timely manner ie. before the 4 second mark timeliness: boolean; + // Indicate whether block arrives before the PTC deadline + // Spec: gloas/fork-choice.md#record_block_timeliness (block_timeliness[PTC_TIMELINESS_INDEX]) + ptcTimeliness: boolean; + + // The index of the block proposer + proposerIndex: number; + /** Payload status for this node (Gloas fork). Always FULL in pre-gloas */ payloadStatus: PayloadStatus; diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 0d98df50bb82..46d916e13b46 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -1868,6 +1868,30 @@ export class ProtoArray { return node; } + /** + * Find blocks at the given slot from the same proposer that are PTC-timely, + * excluding the given block root. + * Spec: gloas/fork-choice.md#new-should_apply_proposer_boost (equivocations check) + */ + findEquivocatingBlocks(proposerIndex: number, slot: Slot, excludeRoot: RootHex): ProtoNode[] { + const result: ProtoNode[] = []; + for (const [root, variantOrArr] of this.indices.entries()) { + if (root === excludeRoot) continue; + const nodeIndex = Array.isArray(variantOrArr) ? variantOrArr[0] : variantOrArr; + if (nodeIndex === undefined) continue; + const node = this.nodes[nodeIndex]; + if ( + node !== undefined && + node.slot === slot && + node.proposerIndex === proposerIndex && + node.ptcTimeliness + ) { + result.push(node); + } + } + return result; + } + private getNodesBetween(upperIndex: number, lowerIndex: number): ProtoNode[] { const result = []; for (let index = upperIndex - 1; index > lowerIndex; index--) { From 8aafc5eae8e17363d8baabcfc751e0b752c3c33e Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:14:43 -0700 Subject: [PATCH 2/7] fix CI Co-Authored-By: Claude Fable 5 --- .../perf/chain/opPools/aggregatedAttestationPool.test.ts | 4 ++++ .../beacon-node/test/spec/presets/fork_choice.test.ts | 9 +-------- .../chain/seenCache/seenPayloadEnvelopeInput.test.ts | 2 ++ packages/beacon-node/test/utils/state.ts | 2 ++ .../beacon-node/test/utils/validationData/attestation.ts | 2 ++ packages/fork-choice/src/protoArray/protoArray.ts | 7 +------ packages/fork-choice/test/perf/forkChoice/util.ts | 2 ++ .../test/unit/forkChoice/fastConfirmationTestUtils.ts | 2 ++ .../fork-choice/test/unit/forkChoice/forkChoice.test.ts | 4 ++++ .../test/unit/forkChoice/getProposerHead.test.ts | 6 ++++++ .../forkChoice/shouldOverrideForkChoiceUpdate.test.ts | 6 ++++++ .../test/unit/protoArray/executionStatusUpdates.test.ts | 2 ++ .../test/unit/protoArray/getCommonAncestor.test.ts | 4 ++++ packages/fork-choice/test/unit/protoArray/gloas.test.ts | 2 ++ .../fork-choice/test/unit/protoArray/protoArray.test.ts | 6 ++++++ 15 files changed, 46 insertions(+), 14 deletions(-) diff --git a/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts b/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts index a3874b240d5e..527af1afc6cd 100644 --- a/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts +++ b/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts @@ -67,6 +67,8 @@ describe.skip(`getAttestationsForBlock vc=${vc}`, () => { executionStatus: ExecutionStatus.PreMerge, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, dataAvailabilityStatus: DataAvailabilityStatus.PreData, parentBlockHash: null, @@ -95,6 +97,8 @@ describe.skip(`getAttestationsForBlock vc=${vc}`, () => { executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, dataAvailabilityStatus: DataAvailabilityStatus.PreData, parentBlockHash: null, diff --git a/packages/beacon-node/test/spec/presets/fork_choice.test.ts b/packages/beacon-node/test/spec/presets/fork_choice.test.ts index bdc72a6b4e61..ac5c2b1ef04e 100644 --- a/packages/beacon-node/test/spec/presets/fork_choice.test.ts +++ b/packages/beacon-node/test/spec/presets/fork_choice.test.ts @@ -732,14 +732,7 @@ const forkChoiceTest = // // This skip can be removed once a kzg lib with run-time minimal blob size setup is released and // integrated - shouldSkip: (_testcase, name, _index) => - name.includes("invalid_incorrect_proof") || - // TODO GLOAS: These tests will be unskipped by https://github.com/ChainSafe/lodestar/pull/9233 - (name.includes("gloas") && - (name.includes("simple_attempted_reorg_without_enough_ffg_votes") || - name.includes("include_votes_another_empty_chain_with_enough_ffg_votes_current_epoch") || - name.includes("include_votes_another_empty_chain_with_enough_ffg_votes_previous_epoch") || - name.includes("include_votes_another_empty_chain_without_enough_ffg_votes_current_epoch"))), + shouldSkip: (_testcase, name, _index) => name.includes("invalid_incorrect_proof"), }, }; }; diff --git a/packages/beacon-node/test/unit/chain/seenCache/seenPayloadEnvelopeInput.test.ts b/packages/beacon-node/test/unit/chain/seenCache/seenPayloadEnvelopeInput.test.ts index 44d577ee44b0..2129ca685a5e 100644 --- a/packages/beacon-node/test/unit/chain/seenCache/seenPayloadEnvelopeInput.test.ts +++ b/packages/beacon-node/test/unit/chain/seenCache/seenPayloadEnvelopeInput.test.ts @@ -81,6 +81,8 @@ describe("SeenPayloadEnvelopeInput", () => { unrealizedFinalizedEpoch: 0, unrealizedFinalizedRoot: blockRoot, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge, dataAvailabilityStatus: DataAvailabilityStatus.PreData, diff --git a/packages/beacon-node/test/utils/state.ts b/packages/beacon-node/test/utils/state.ts index d5ee1595e637..bd5dbeb130ab 100644 --- a/packages/beacon-node/test/utils/state.ts +++ b/packages/beacon-node/test/utils/state.ts @@ -172,6 +172,8 @@ export const zeroProtoBlock: ProtoBlock = { unrealizedFinalizedRoot: ZERO_HASH_HEX, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, diff --git a/packages/beacon-node/test/utils/validationData/attestation.ts b/packages/beacon-node/test/utils/validationData/attestation.ts index 1894a9b89b39..fed3ff461c1b 100644 --- a/packages/beacon-node/test/utils/validationData/attestation.ts +++ b/packages/beacon-node/test/utils/validationData/attestation.ts @@ -76,6 +76,8 @@ export function getAttestationValidData(opts: AttestationValidDataOpts): { unrealizedFinalizedRoot: ZERO_HASH_HEX, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge, diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 7e655626b9d9..29f0bb9156ba 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -2051,12 +2051,7 @@ export class ProtoArray { const nodeIndex = Array.isArray(variantOrArr) ? variantOrArr[0] : variantOrArr; if (nodeIndex === undefined) continue; const node = this.nodes[nodeIndex]; - if ( - node !== undefined && - node.slot === slot && - node.proposerIndex === proposerIndex && - node.ptcTimeliness - ) { + if (node !== undefined && node.slot === slot && node.proposerIndex === proposerIndex && node.ptcTimeliness) { result.push(node); } } diff --git a/packages/fork-choice/test/perf/forkChoice/util.ts b/packages/fork-choice/test/perf/forkChoice/util.ts index 3422d58dbf11..61e9d330b57a 100644 --- a/packages/fork-choice/test/perf/forkChoice/util.ts +++ b/packages/fork-choice/test/perf/forkChoice/util.ts @@ -149,6 +149,8 @@ export function initializeForkChoice(opts: Opts): ForkChoice { executionStatus: ExecutionStatus.PreMerge, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, dataAvailabilityStatus: DataAvailabilityStatus.PreData, parentBlockHash: null, diff --git a/packages/fork-choice/test/unit/forkChoice/fastConfirmationTestUtils.ts b/packages/fork-choice/test/unit/forkChoice/fastConfirmationTestUtils.ts index d60a413196e2..02146ac9799a 100644 --- a/packages/fork-choice/test/unit/forkChoice/fastConfirmationTestUtils.ts +++ b/packages/fork-choice/test/unit/forkChoice/fastConfirmationTestUtils.ts @@ -53,6 +53,8 @@ export function makeBlock( parentBlockHash: null, payloadStatus: PayloadStatus.FULL, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, }; if (executionStatus === ExecutionStatus.PreMerge) { diff --git a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts index 36e646102adb..4a4e75a4f6f9 100644 --- a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts @@ -48,6 +48,8 @@ describe("Forkchoice", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, } as Omit, genesisSlot ); @@ -148,6 +150,8 @@ describe("Forkchoice", () => { executionStatus: ExecutionStatus.PreMerge, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, dataAvailabilityStatus: DataAvailabilityStatus.PreData, parentBlockHash: null, diff --git a/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts b/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts index 5218841ceab4..82610e84fc5e 100644 --- a/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts @@ -48,6 +48,8 @@ describe("Forkchoice / GetProposerHead", () => { executionStatus: ExecutionStatus.PreMerge, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, dataAvailabilityStatus: DataAvailabilityStatus.PreData, parentBlockHash: null, @@ -74,6 +76,8 @@ describe("Forkchoice / GetProposerHead", () => { executionStatus: ExecutionStatus.PreMerge, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, weight: 29, dataAvailabilityStatus: DataAvailabilityStatus.PreData, @@ -102,6 +106,8 @@ describe("Forkchoice / GetProposerHead", () => { executionStatus: ExecutionStatus.PreMerge, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, weight: 212, // 240 - 29 + 1 dataAvailabilityStatus: DataAvailabilityStatus.PreData, diff --git a/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts b/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts index a270bc55e65e..ab1cc5a8e0ac 100644 --- a/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts @@ -48,6 +48,8 @@ describe("Forkchoice / shouldOverrideForkChoiceUpdate", () => { executionStatus: ExecutionStatus.PreMerge, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, dataAvailabilityStatus: DataAvailabilityStatus.PreData, parentBlockHash: null, @@ -74,6 +76,8 @@ describe("Forkchoice / shouldOverrideForkChoiceUpdate", () => { executionStatus: ExecutionStatus.PreMerge, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, weight: 29, dataAvailabilityStatus: DataAvailabilityStatus.PreData, @@ -102,6 +106,8 @@ describe("Forkchoice / shouldOverrideForkChoiceUpdate", () => { executionStatus: ExecutionStatus.PreMerge, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, weight: 212, // 240 - 29 + 1 dataAvailabilityStatus: DataAvailabilityStatus.PreData, diff --git a/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts b/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts index 75f7e959ac4f..c470c9593255 100644 --- a/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts +++ b/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts @@ -120,6 +120,8 @@ function setupForkChoice(): ProtoArray { unrealizedFinalizedRoot: "-", timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, ...executionData, diff --git a/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts b/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts index e8349f4ea426..2396d1bc958c 100644 --- a/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts +++ b/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts @@ -42,6 +42,8 @@ describe("getCommonAncestor", () => { unrealizedFinalizedRoot: "-", timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, @@ -71,6 +73,8 @@ describe("getCommonAncestor", () => { unrealizedFinalizedRoot: "-", timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, diff --git a/packages/fork-choice/test/unit/protoArray/gloas.test.ts b/packages/fork-choice/test/unit/protoArray/gloas.test.ts index 0724f38ad71f..126d80b33d7a 100644 --- a/packages/fork-choice/test/unit/protoArray/gloas.test.ts +++ b/packages/fork-choice/test/unit/protoArray/gloas.test.ts @@ -49,6 +49,8 @@ describe("Gloas Fork Choice", () => { unrealizedFinalizedEpoch: genesisEpoch, unrealizedFinalizedRoot: genesisRoot, timeliness: true, + ptcTimeliness: false, + proposerIndex: 0, executionPayloadBlockHash: blockRoot, // Use blockRoot as execution hash executionPayloadNumber: slot, executionPayloadGasLimit: 30000000, diff --git a/packages/fork-choice/test/unit/protoArray/protoArray.test.ts b/packages/fork-choice/test/unit/protoArray/protoArray.test.ts index f23332f7a292..0de7f5f669cc 100644 --- a/packages/fork-choice/test/unit/protoArray/protoArray.test.ts +++ b/packages/fork-choice/test/unit/protoArray/protoArray.test.ts @@ -31,6 +31,8 @@ describe("ProtoArray", () => { unrealizedFinalizedRoot: stateRoot, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, @@ -60,6 +62,8 @@ describe("ProtoArray", () => { unrealizedFinalizedRoot: stateRoot, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, @@ -90,6 +94,8 @@ describe("ProtoArray", () => { unrealizedFinalizedRoot: stateRoot, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, From 09174a21bccb855c563a433b519e043761e34c3d Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:17:58 -0700 Subject: [PATCH 3/7] fix: use parent's canonical payload status in shouldApplyProposerBoost Hardcoding PayloadStatus.PENDING in the weak-parent lookup crashes at the gloas fork transition: the first gloas block's parent is pre-gloas (FULL-only), and getNodeIndexByRootAndStatus throws INVALID_NODE_INDEX when PENDING is requested for a pre-gloas root. The adjacent-parent guard does not short-circuit this path at the boundary. Use parentBlock.payloadStatus instead, matching the pattern already used for the strong-variant lookup elsewhere in this file. Co-Authored-By: Claude Fable 5 --- packages/fork-choice/src/forkChoice/forkChoice.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 77270186a7bd..a54beea4252e 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -1587,7 +1587,7 @@ export class ForkChoice implements IForkChoice { slotsPerEpoch: SLOTS_PER_EPOCH, committeePercent: this.config.REORG_HEAD_WEIGHT_THRESHOLD, }); - const parentNode = this.protoArray.getNode(parentBlock.blockRoot, PayloadStatus.PENDING); + const parentNode = this.protoArray.getNode(parentBlock.blockRoot, parentBlock.payloadStatus); if (parentNode === undefined || parentNode.weight >= reorgThreshold) { // Parent is not weak return true; From d2bb9684f211f517206d46ca2e27b95935455edd Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:38:49 -0700 Subject: [PATCH 4/7] docs: refine ptcTimeliness/timeliness spec comments Co-Authored-By: Claude Fable 5 --- packages/fork-choice/src/forkChoice/forkChoice.ts | 1 - packages/fork-choice/src/protoArray/interface.ts | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index a54beea4252e..bfee86c995cb 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -1546,7 +1546,6 @@ export class ForkChoice implements IForkChoice { /** * Check if block arrived before the PTC deadline. - * Spec: gloas/fork-choice.md#record_block_timeliness (block_timeliness[PTC_TIMELINESS_INDEX]) */ private isBlockPtcTimely(block: BeaconBlock, blockDelaySec: number): boolean { const isCurrentSlot = this.fcStore.currentSlot === block.slot; diff --git a/packages/fork-choice/src/protoArray/interface.ts b/packages/fork-choice/src/protoArray/interface.ts index 2f5d97dff1fe..3b37f305a0d7 100644 --- a/packages/fork-choice/src/protoArray/interface.ts +++ b/packages/fork-choice/src/protoArray/interface.ts @@ -142,10 +142,11 @@ export type ProtoBlock = BlockExtraMeta & { unrealizedFinalizedRoot: RootHex; // Indicate whether block arrives in a timely manner ie. before the 4 second mark + // Spec: Store.block_timeliness[ATTESTATION_TIMELINESS_INDEX] timeliness: boolean; // Indicate whether block arrives before the PTC deadline - // Spec: gloas/fork-choice.md#record_block_timeliness (block_timeliness[PTC_TIMELINESS_INDEX]) + // Spec: Store.block_timeliness[PTC_TIMELINESS_INDEX] ptcTimeliness: boolean; // The index of the block proposer From 28e0fbc8b6ea285903fd73e1dc23eb6c0ae14433 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:09:09 -0700 Subject: [PATCH 5/7] refactor: reuse isHeadWeak() for the weak-parent check in shouldApplyProposerBoost is_head_weak() landed via #9654 with the exact gloas semantics (boost-excluded attestation score + committee-scoped equivocator balance add-back), so the weak-parent check can call it directly instead of approximating with raw node.weight against the reorg threshold. Also adapt findEquivocatingBlocks to the current VariantIndices layout via getDefaultNodeIndex, and add the new ProtoBlock fields to test fixtures that were added on unstable since the last merge. Co-Authored-By: Claude Fable 5 --- .../beacon-node/test/utils/typeGenerator.ts | 2 + .../fork-choice/src/forkChoice/forkChoice.ts | 45 ++++++++--------- .../fork-choice/src/protoArray/interface.ts | 9 ++-- .../fork-choice/src/protoArray/protoArray.ts | 48 +++++++++++-------- .../unit/forkChoice/proposerHeadTestUtils.ts | 2 + .../unit/protoArray/attestationScore.test.ts | 2 + .../unit/protoArray/getViableHeads.test.ts | 2 + .../test/unit/protoArray/gloas.test.ts | 2 +- 8 files changed, 64 insertions(+), 48 deletions(-) diff --git a/packages/beacon-node/test/utils/typeGenerator.ts b/packages/beacon-node/test/utils/typeGenerator.ts index 7886df877050..7a2732782c0a 100644 --- a/packages/beacon-node/test/utils/typeGenerator.ts +++ b/packages/beacon-node/test/utils/typeGenerator.ts @@ -40,6 +40,8 @@ export function generateProtoBlock(overrides: Partial = {}): ProtoBl unrealizedFinalizedRoot: ZERO_HASH_HEX, timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 022b08cfc362..a664c524d76e 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -1586,7 +1586,8 @@ export class ForkChoice implements IForkChoice { // 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) { - // this is called for head so we should always have this in forkchoice, otherwise we have a serious error + // this is called for head or the boosted block's parent, both of which should always be + // in forkchoice, otherwise we have a serious error throw new ForkChoiceError({code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK, root: blockRoot}); } @@ -1606,7 +1607,7 @@ export class ForkChoice implements IForkChoice { // always 0. Return before fetching the state and walking the block's committees. if (equivocatingIndices.size > 0) { const state = this.fcStore.stateGetter({stateRoot: node.stateRoot}); - // Only ever called on the head, so the state is always cached. + // Only ever called on the head or the boosted block's parent, so the state is always cached. // A miss is a broken invariant, not a recoverable state. if (state === null) { throw new ForkChoiceError({ @@ -1671,18 +1672,26 @@ export class ForkChoice implements IForkChoice { } /** - * Check if block arrived before the PTC deadline. + * Return true if the block arrived before the PTC (payload-timeliness committee) deadline, + * ie. block_timeliness[PTC_TIMELINESS_INDEX]. + * + * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.12/specs/gloas/fork-choice.md#modified-record_block_timeliness + * + * Child class can overwrite this for testing purpose. */ - private isBlockPtcTimely(block: BeaconBlock, blockDelaySec: number): boolean { - const isCurrentSlot = this.fcStore.currentSlot === block.slot; + protected isBlockPtcTimely(block: BeaconBlock, blockDelaySec: number): boolean { const ptcThresholdMs = this.config.getSlotComponentDurationMs(this.config.PAYLOAD_ATTESTATION_DUE_BPS); - return isCurrentSlot && blockDelaySec * 1000 < ptcThresholdMs; + return this.fcStore.currentSlot === block.slot && blockDelaySec * 1000 < ptcThresholdMs; } /** - * Spec: gloas/fork-choice.md#new-should_apply_proposer_boost - * Determines whether proposer boost should apply for gloas blocks. - * Returns true for pre-gloas blocks (unconditional boost). + * Determine whether proposer boost should be applied to `proposerBoostRoot`. + * + * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.12/specs/gloas/fork-choice.md#new-should_apply_proposer_boost + * + * Pre-gloas blocks always receive the boost (unconditional, backward compatible). For gloas + * blocks the boost is withheld when the parent is a weak block from the previous slot and the + * proposer of that parent equivocated (published another PTC-timely block at the same slot). */ private shouldApplyProposerBoost(): boolean { if (!this.proposerBoostRoot) { @@ -1690,8 +1699,8 @@ export class ForkChoice implements IForkChoice { } const boostedBlock = this.getBlockHexDefaultStatus(this.proposerBoostRoot); + // Pre-gloas blocks always get boost if (!boostedBlock || !isGloasBlock(boostedBlock)) { - // Pre-gloas blocks always get boost return true; } @@ -1700,26 +1709,18 @@ export class ForkChoice implements IForkChoice { return true; } - const slot = boostedBlock.slot; - // Apply proposer boost if parent is not from the previous slot - if (parentBlock.slot + 1 < slot) { + if (parentBlock.slot + 1 < boostedBlock.slot) { return true; } // Apply proposer boost if parent is not weak - const reorgThreshold = getCommitteeFraction(this.fcStore.justified.totalBalance, { - slotsPerEpoch: SLOTS_PER_EPOCH, - committeePercent: this.config.REORG_HEAD_WEIGHT_THRESHOLD, - }); - const parentNode = this.protoArray.getNode(parentBlock.blockRoot, parentBlock.payloadStatus); - if (parentNode === undefined || parentNode.weight >= reorgThreshold) { - // Parent is not weak + if (!this.isHeadWeak(parentBlock.blockRoot)) { return true; } - // Parent is weak and from the previous slot: apply boost if there are no equivocations - // Look for other PTC-timely blocks at the same slot from the same proposer + // Parent is weak and from the previous slot: apply boost only if there are no early + // equivocations, ie. no other PTC-timely block at the parent's slot from the same proposer. const equivocations = this.protoArray.findEquivocatingBlocks( parentBlock.proposerIndex, parentBlock.slot, diff --git a/packages/fork-choice/src/protoArray/interface.ts b/packages/fork-choice/src/protoArray/interface.ts index fe74d5b19fe5..3c58d88b016f 100644 --- a/packages/fork-choice/src/protoArray/interface.ts +++ b/packages/fork-choice/src/protoArray/interface.ts @@ -1,5 +1,5 @@ import {DataAvailabilityStatus} from "@lodestar/state-transition"; -import {Epoch, RootHex, Slot, UintNum64} from "@lodestar/types"; +import {Epoch, RootHex, Slot, UintNum64, ValidatorIndex} from "@lodestar/types"; // RootHex is a root as a hex string // Used for lightweight and easy comparison @@ -142,15 +142,14 @@ export type ProtoBlock = BlockExtraMeta & { unrealizedFinalizedRoot: RootHex; // Indicate whether block arrives in a timely manner ie. before the 4 second mark - // Spec: Store.block_timeliness[ATTESTATION_TIMELINESS_INDEX] timeliness: boolean; // Indicate whether block arrives before the PTC deadline - // Spec: Store.block_timeliness[PTC_TIMELINESS_INDEX] + // Spec: gloas/fork-choice.md#modified-record_block_timeliness (block_timeliness[PTC_TIMELINESS_INDEX]) ptcTimeliness: boolean; - // The index of the block proposer - proposerIndex: number; + // The index of the block proposer. Used by should_apply_proposer_boost to detect proposer equivocations + proposerIndex: ValidatorIndex; /** Payload status for this node (Gloas fork). Always FULL in pre-gloas */ payloadStatus: PayloadStatus; diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 9cf2afd12b83..7ec1e25dba55 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -1,7 +1,7 @@ import {BitArray} from "@chainsafe/ssz"; import {GENESIS_EPOCH, PTC_SIZE} from "@lodestar/params"; import {DataAvailabilityStatus, computeEpochAtSlot, computeStartSlotAtEpoch} from "@lodestar/state-transition"; -import {Epoch, RootHex, Slot} from "@lodestar/types"; +import {Epoch, RootHex, Slot, ValidatorIndex} from "@lodestar/types"; import {bitCount, toRootHex} from "@lodestar/utils"; import {ForkChoiceError, ForkChoiceErrorCode} from "../forkChoice/errors.js"; import {LVHExecError, LVHExecErrorCode, ProtoArrayError, ProtoArrayErrorCode} from "./errors.js"; @@ -1983,6 +1983,33 @@ export class ProtoArray { return this.getNodeByIndex(nodeIndex); } + /** + * Find blocks at `slot` proposed by `proposerIndex` that are PTC-timely, excluding `excludeRoot`. + * Used by `should_apply_proposer_boost` to detect proposer equivocations. + * + * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.12/specs/gloas/fork-choice.md#new-should_apply_proposer_boost + * + * Iterates unique block roots (via the canonical variant) since `slot`, `proposerIndex` and + * `ptcTimeliness` are block-level properties identical across payload-status variants. + */ + findEquivocatingBlocks(proposerIndex: ValidatorIndex, slot: Slot, excludeRoot: RootHex): ProtoNode[] { + const result: ProtoNode[] = []; + for (const root of this.indices.keys()) { + if (root === excludeRoot) { + continue; + } + const nodeIndex = this.getDefaultNodeIndex(root); + if (nodeIndex === undefined) { + continue; + } + const node = this.nodes[nodeIndex]; + if (node !== undefined && node.slot === slot && node.proposerIndex === proposerIndex && node.ptcTimeliness) { + result.push(node); + } + } + return result; + } + /** * Return MUTABLE ProtoBlock for blockRoot with explicit payload status * @@ -2124,25 +2151,6 @@ export class ProtoArray { return node; } - /** - * Find blocks at the given slot from the same proposer that are PTC-timely, - * excluding the given block root. - * Spec: gloas/fork-choice.md#new-should_apply_proposer_boost (equivocations check) - */ - findEquivocatingBlocks(proposerIndex: number, slot: Slot, excludeRoot: RootHex): ProtoNode[] { - const result: ProtoNode[] = []; - for (const [root, variantOrArr] of this.indices.entries()) { - if (root === excludeRoot) continue; - const nodeIndex = Array.isArray(variantOrArr) ? variantOrArr[0] : variantOrArr; - if (nodeIndex === undefined) continue; - const node = this.nodes[nodeIndex]; - if (node !== undefined && node.slot === slot && node.proposerIndex === proposerIndex && node.ptcTimeliness) { - result.push(node); - } - } - return result; - } - private getNodesBetween(upperIndex: number, lowerIndex: number): ProtoNode[] { const result = []; for (let index = upperIndex - 1; index > lowerIndex; index--) { diff --git a/packages/fork-choice/test/unit/forkChoice/proposerHeadTestUtils.ts b/packages/fork-choice/test/unit/forkChoice/proposerHeadTestUtils.ts index 42ba1941eac6..a00d29a6fcd4 100644 --- a/packages/fork-choice/test/unit/forkChoice/proposerHeadTestUtils.ts +++ b/packages/fork-choice/test/unit/forkChoice/proposerHeadTestUtils.ts @@ -57,6 +57,8 @@ export function toProtoBlock(slot: Slot, parentRoot: RootHex, isGloas: boolean): unrealizedFinalizedRoot: getBlockRoot(genesisSlot), timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, executionPayloadBlockHash: getPayloadBlockHash(slot), executionPayloadNumber: slot, diff --git a/packages/fork-choice/test/unit/protoArray/attestationScore.test.ts b/packages/fork-choice/test/unit/protoArray/attestationScore.test.ts index a238858f3d83..67fce63e67b5 100644 --- a/packages/fork-choice/test/unit/protoArray/attestationScore.test.ts +++ b/packages/fork-choice/test/unit/protoArray/attestationScore.test.ts @@ -37,6 +37,8 @@ function toProtoBlock(slot: number, blockRoot: RootHex, parentRoot: RootHex): Pr unrealizedFinalizedRoot: "-", timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge, diff --git a/packages/fork-choice/test/unit/protoArray/getViableHeads.test.ts b/packages/fork-choice/test/unit/protoArray/getViableHeads.test.ts index 769f6c2ad45a..f5f4a4bcd485 100644 --- a/packages/fork-choice/test/unit/protoArray/getViableHeads.test.ts +++ b/packages/fork-choice/test/unit/protoArray/getViableHeads.test.ts @@ -26,6 +26,8 @@ function blockFields(overrides: { unrealizedFinalizedRoot: "0", timeliness: false, + ptcTimeliness: false, + proposerIndex: 0, ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, diff --git a/packages/fork-choice/test/unit/protoArray/gloas.test.ts b/packages/fork-choice/test/unit/protoArray/gloas.test.ts index cda56145115f..13c43a3d406c 100644 --- a/packages/fork-choice/test/unit/protoArray/gloas.test.ts +++ b/packages/fork-choice/test/unit/protoArray/gloas.test.ts @@ -49,7 +49,7 @@ describe("Gloas Fork Choice", () => { unrealizedFinalizedEpoch: genesisEpoch, unrealizedFinalizedRoot: genesisRoot, timeliness: true, - ptcTimeliness: false, + ptcTimeliness: true, proposerIndex: 0, executionPayloadBlockHash: blockRoot, // Use blockRoot as execution hash executionPayloadNumber: slot, From 6b87bda6e859f0964d51292f89e8a4c721782099 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:04:32 -0700 Subject: [PATCH 6/7] fix: decide gloas proposer boost after applying attestation deltas should_apply_proposer_boost judges the parent via is_head_weak against the attestations known to the store, but the decision ran before applyScoreChanges() landed this round's deltas, so it read the previous round's attestation scores. A parent crossing the weak threshold on the pending batch kept the boost withheld, and a newly detected equivocator kept its discounted vote counted. For a gloas boosted block, split the score update in two passes: attestation deltas first, then the boost decision, then the boost deltas. Pre-gloas keeps the single pass since the boost is unconditional there. Also replace findEquivocatingBlocks() with hasEquivocatingBlock(): the only caller tests emptiness, so return on first match instead of allocating a list. The two ordering tests fail on the previous commit and pass on this one; the spec vectors cannot catch this because the check step recomputes the head, self-healing the transient decision before assertions. Co-Authored-By: Claude Fable 5 --- .../fork-choice/src/forkChoice/forkChoice.ts | 73 ++++--- .../fork-choice/src/protoArray/protoArray.ts | 11 +- .../unit/forkChoice/proposerHeadTestUtils.ts | 13 +- .../shouldApplyProposerBoost.test.ts | 202 ++++++++++++++++++ 4 files changed, 266 insertions(+), 33 deletions(-) create mode 100644 packages/fork-choice/test/unit/forkChoice/shouldApplyProposerBoost.test.ts diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index a664c524d76e..1cec366293d3 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -571,32 +571,41 @@ export class ForkChoice implements IForkChoice { computeDeltasMetrics?.newVoteValidators.set(newVoteValidators); this.balances = newBalances; - /** - * The structure in line with deltas to propagate boost up the branch - * starting from the proposerIndex - */ - let proposerBoost: {root: RootHex; score: number} | null = null; - if (this.opts?.proposerBoost && this.proposerBoostRoot && this.shouldApplyProposerBoost()) { - const proposerBoostScore = - this.justifiedProposerBoostScore ?? - getCommitteeFraction(this.fcStore.justified.totalBalance, { - slotsPerEpoch: SLOTS_PER_EPOCH, - committeePercent: this.config.PROPOSER_SCORE_BOOST, - }); - proposerBoost = {root: this.proposerBoostRoot, score: proposerBoostScore}; - this.justifiedProposerBoostScore = proposerBoostScore; - } const currentSlot = this.fcStore.currentSlot; - this.protoArray.applyScoreChanges({ - attestationDeltas, - proposerBoost, + const checkpoints = { justifiedEpoch: this.fcStore.justified.checkpoint.epoch, justifiedRoot: this.fcStore.justified.checkpoint.rootHex, finalizedEpoch: this.fcStore.finalizedCheckpoint.epoch, finalizedRoot: this.fcStore.finalizedCheckpoint.rootHex, currentSlot, - }); + }; + + const boostedBlock = + this.opts?.proposerBoost && this.proposerBoostRoot ? this.getBlockHexDefaultStatus(this.proposerBoostRoot) : null; + + if (boostedBlock && isGloasBlock(boostedBlock)) { + // should_apply_proposer_boost judges the parent against the attestations known to the store, + // via is_head_weak (which also assumes equivocators' votes were already discounted before + // their balance is added back). Apply the attestation deltas first so the decision reads + // post-delta scores, then apply the boost in a second pass. + // https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.12/specs/gloas/fork-choice.md#new-should_apply_proposer_boost + this.protoArray.applyScoreChanges({attestationDeltas, proposerBoost: null, ...checkpoints}); + const proposerBoost = this.shouldApplyProposerBoost() ? this.getProposerBoost() : null; + this.protoArray.applyScoreChanges({ + attestationDeltas: new Array(this.protoArray.nodes.length).fill(0), + proposerBoost, + ...checkpoints, + }); + } else { + // Pre-gloas the boost is unconditional, so attestation deltas and boost deltas can propagate + // up the branch in a single pass + this.protoArray.applyScoreChanges({ + attestationDeltas, + proposerBoost: boostedBlock ? this.getProposerBoost() : null, + ...checkpoints, + }); + } // findHead returns the ProtoNode representing the head const head = this.protoArray.findHead(this.fcStore.justified.checkpoint.rootHex, currentSlot); @@ -1721,13 +1730,27 @@ export class ForkChoice implements IForkChoice { // Parent is weak and from the previous slot: apply boost only if there are no early // equivocations, ie. no other PTC-timely block at the parent's slot from the same proposer. - const equivocations = this.protoArray.findEquivocatingBlocks( - parentBlock.proposerIndex, - parentBlock.slot, - parentBlock.blockRoot - ); + return !this.protoArray.hasEquivocatingBlock(parentBlock.proposerIndex, parentBlock.slot, parentBlock.blockRoot); + } + + /** + * The proposer boost to apply to `proposerBoostRoot`, propagated up the branch by + * applyScoreChanges() together with the deltas. + */ + private getProposerBoost(): {root: RootHex; score: number} | null { + if (!this.proposerBoostRoot) { + return null; + } + + const proposerBoostScore = + this.justifiedProposerBoostScore ?? + getCommitteeFraction(this.fcStore.justified.totalBalance, { + slotsPerEpoch: SLOTS_PER_EPOCH, + committeePercent: this.config.PROPOSER_SCORE_BOOST, + }); + this.justifiedProposerBoostScore = proposerBoostScore; - return equivocations.length === 0; + return {root: this.proposerBoostRoot, score: proposerBoostScore}; } /** diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 7ec1e25dba55..22ba58f54dd3 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -1984,16 +1984,15 @@ export class ProtoArray { } /** - * Find blocks at `slot` proposed by `proposerIndex` that are PTC-timely, excluding `excludeRoot`. - * Used by `should_apply_proposer_boost` to detect proposer equivocations. + * Return true if a block other than `excludeRoot` at `slot` was proposed by `proposerIndex` and + * is PTC-timely. Used by `should_apply_proposer_boost` to detect proposer equivocations. * * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.12/specs/gloas/fork-choice.md#new-should_apply_proposer_boost * * Iterates unique block roots (via the canonical variant) since `slot`, `proposerIndex` and * `ptcTimeliness` are block-level properties identical across payload-status variants. */ - findEquivocatingBlocks(proposerIndex: ValidatorIndex, slot: Slot, excludeRoot: RootHex): ProtoNode[] { - const result: ProtoNode[] = []; + hasEquivocatingBlock(proposerIndex: ValidatorIndex, slot: Slot, excludeRoot: RootHex): boolean { for (const root of this.indices.keys()) { if (root === excludeRoot) { continue; @@ -2004,10 +2003,10 @@ export class ProtoArray { } const node = this.nodes[nodeIndex]; if (node !== undefined && node.slot === slot && node.proposerIndex === proposerIndex && node.ptcTimeliness) { - result.push(node); + return true; } } - return result; + return false; } /** diff --git a/packages/fork-choice/test/unit/forkChoice/proposerHeadTestUtils.ts b/packages/fork-choice/test/unit/forkChoice/proposerHeadTestUtils.ts index a00d29a6fcd4..c433ddeaedfa 100644 --- a/packages/fork-choice/test/unit/forkChoice/proposerHeadTestUtils.ts +++ b/packages/fork-choice/test/unit/forkChoice/proposerHeadTestUtils.ts @@ -39,7 +39,12 @@ export function getPayloadBlockHash(slot: Slot): RootHex { * isGloasBlock() keys off parentBlockHash being non-null, so `isGloas` decides whether the block * carries the three payload variants (PENDING/EMPTY/FULL) or just FULL. */ -export function toProtoBlock(slot: Slot, parentRoot: RootHex, isGloas: boolean): ProtoBlock { +export function toProtoBlock( + slot: Slot, + parentRoot: RootHex, + isGloas: boolean, + overrides: Partial = {} +): ProtoBlock { return { slot, blockRoot: getBlockRoot(slot), @@ -68,7 +73,11 @@ export function toProtoBlock(slot: Slot, parentRoot: RootHex, isGloas: boolean): parentBlockHash: isGloas ? getPayloadBlockHash(slot - 1) : null, payloadStatus: PayloadStatus.FULL, - }; + + ...overrides, + // ProtoBlock is a union over the execution fields; spreading Partial loses the + // narrowing even though every base object here is the post-merge shape + } as ProtoBlock; } /** A state whose only slot committee is every validator, so equivocators always count */ diff --git a/packages/fork-choice/test/unit/forkChoice/shouldApplyProposerBoost.test.ts b/packages/fork-choice/test/unit/forkChoice/shouldApplyProposerBoost.test.ts new file mode 100644 index 000000000000..96f9db6e74d4 --- /dev/null +++ b/packages/fork-choice/test/unit/forkChoice/shouldApplyProposerBoost.test.ts @@ -0,0 +1,202 @@ +import {describe, expect, it} from "vitest"; +import {IBeaconStateView} from "@lodestar/state-transition"; +import {RootHex, Slot, ValidatorIndex} from "@lodestar/types"; +import {ForkChoice, IForkChoiceStore, PayloadStatus, ProtoArray} from "../../../src/index.js"; +import {getBlockRoot} from "../../utils/index.js"; +import { + BALANCE_INCREMENT, + VALIDATOR_COUNT, + genesisEpoch, + genesisSlot, + getPayloadBlockHash, + gloasConfig, + headSlot, + makeStore, + parentSlot, + toProtoBlock, +} from "./proposerHeadTestUtils.js"; + +/** See isHeadWeak.test.ts: floor(floor((32 * 150) / 32) * 20 / 100) = 30 */ +const REORG_THRESHOLD = 30; +/** PROPOSER_SCORE_BOOST is 40%: floor(floor((32 * 150) / 32) * 40 / 100) = 60 */ +const BOOST_SCORE = 60; + +const PARENT_PROPOSER = 7; +const SIBLING_ROOT = "0xsibling"; + +/** A state whose slot committees are empty, so equivocator balances are never added back */ +function mockStateEmptyCommittee(): IBeaconStateView { + return { + getBeaconCommitteeCountPerSlot: () => 1, + getBeaconCommittee: () => Uint32Array.from([]), + effectiveBalanceIncrements: new Uint16Array(Array(VALIDATOR_COUNT).fill(BALANCE_INCREMENT)), + } as unknown as IBeaconStateView; +} + +function setBoostRoot(forkChoice: ForkChoice, root: RootHex): void { + (forkChoice as unknown as {proposerBoostRoot: RootHex | null}).proposerBoostRoot = root; +} + +/** Queue a not-yet-applied vote, as onAttestation would, so updateHead() computes its delta */ +function setPendingVote(forkChoice: ForkChoice, validatorIndex: ValidatorIndex, nodeIndex: number): void { + (forkChoice as unknown as {voteNextIndices: number[]}).voteNextIndices[validatorIndex] = nodeIndex; +} + +/** The boost currently held by a block = the gap between its total weight and its attestation score */ +function appliedBoost(protoArray: ProtoArray, root: RootHex): number { + const node = protoArray.getNode(root, PayloadStatus.PENDING); + if (node === undefined) throw Error(`missing PENDING node for ${root}`); + return node.weight - node.attestationScore; +} + +/** + * Build a gloas chain genesis -> parent -> child where the child holds proposer boost, plus an + * optional sibling of the parent (same slot) to act as the proposer equivocation. + * Votes passed here are applied to scores before the measured updateHead(), emulating state from a + * previous head update; use setPendingVote for votes the measured updateHead() itself must apply. + */ +function setup({ + sibling = null, + parentVotes = 0, + store = makeStore(), + childSlot = headSlot, +}: { + sibling?: {ptcTimeliness: boolean; proposerIndex?: ValidatorIndex} | null; + parentVotes?: number; + store?: IForkChoiceStore; + childSlot?: Slot; +} = {}): {forkChoice: ForkChoice; protoArray: ProtoArray; parentRoot: RootHex; childRoot: RootHex} { + const genesisRoot = getBlockRoot(genesisSlot); + const parentRoot = getBlockRoot(parentSlot); + const childRoot = getBlockRoot(childSlot); + + const protoArray = ProtoArray.initialize(toProtoBlock(genesisSlot, genesisRoot, false), genesisSlot); + protoArray.onBlock(toProtoBlock(parentSlot, genesisRoot, true, {proposerIndex: PARENT_PROPOSER}), parentSlot, null); + if (sibling) { + protoArray.onBlock( + toProtoBlock(parentSlot, genesisRoot, true, { + blockRoot: SIBLING_ROOT, + proposerIndex: sibling.proposerIndex ?? PARENT_PROPOSER, + ptcTimeliness: sibling.ptcTimeliness, + executionPayloadBlockHash: "0xpayload_sibling", + }), + parentSlot, + null + ); + } + protoArray.onBlock( + toProtoBlock(childSlot, parentRoot, true, {parentBlockHash: getPayloadBlockHash(parentSlot)}), + childSlot, + null + ); + + const forkChoice = new ForkChoice(gloasConfig, store, protoArray, VALIDATOR_COUNT, null, {proposerBoost: true}); + + if (parentVotes > 0) { + protoArray.applyScoreChanges({ + attestationDeltas: protoArray.nodes.map((node) => + node.blockRoot === parentRoot && node.payloadStatus === PayloadStatus.PENDING ? parentVotes : 0 + ), + proposerBoost: null, + justifiedEpoch: genesisEpoch, + justifiedRoot: genesisRoot, + finalizedEpoch: genesisEpoch, + finalizedRoot: genesisRoot, + currentSlot: store.currentSlot, + }); + } + + setBoostRoot(forkChoice, childRoot); + + return {forkChoice, protoArray, parentRoot, childRoot}; +} + +function pendingNodeIndex(protoArray: ProtoArray, root: RootHex): number { + const index = protoArray.nodes.findIndex( + (node) => node.blockRoot === root && node.payloadStatus === PayloadStatus.PENDING + ); + if (index < 0) throw Error(`missing PENDING node for ${root}`); + return index; +} + +describe("Forkchoice / shouldApplyProposerBoost", () => { + it("withholds boost when the parent is weak, adjacent, and its proposer equivocated PTC-timely", () => { + const {forkChoice, protoArray, childRoot} = setup({sibling: {ptcTimeliness: true}}); + + forkChoice.updateHead(); + + expect(appliedBoost(protoArray, childRoot)).toBe(0); + }); + + it("applies boost when the equivocating sibling is not PTC-timely", () => { + const {forkChoice, protoArray, childRoot} = setup({sibling: {ptcTimeliness: false}}); + + forkChoice.updateHead(); + + expect(appliedBoost(protoArray, childRoot)).toBe(BOOST_SCORE); + }); + + it("applies boost when the sibling was proposed by a different proposer", () => { + const {forkChoice, protoArray, childRoot} = setup({ + sibling: {ptcTimeliness: true, proposerIndex: PARENT_PROPOSER + 1}, + }); + + forkChoice.updateHead(); + + expect(appliedBoost(protoArray, childRoot)).toBe(BOOST_SCORE); + }); + + it("applies boost when the parent is not weak", () => { + const {forkChoice, protoArray, childRoot} = setup({ + sibling: {ptcTimeliness: true}, + parentVotes: REORG_THRESHOLD, + }); + + forkChoice.updateHead(); + + expect(appliedBoost(protoArray, childRoot)).toBe(BOOST_SCORE); + }); + + it("applies boost when the parent is not from the previous slot", () => { + const {forkChoice, protoArray, childRoot} = setup({sibling: {ptcTimeliness: true}, childSlot: headSlot + 1}); + + forkChoice.updateHead(); + + expect(appliedBoost(protoArray, childRoot)).toBe(BOOST_SCORE); + }); + + it("applies this round's attestation deltas before deciding the boost", () => { + // The parent looks weak on the scores cached from the previous head update, but the vote + // pending in this very updateHead() carries it over the threshold. The boost decision must see + // the post-delta score and apply the boost, spec get_attestation_score being vote-current. + const {forkChoice, protoArray, parentRoot, childRoot} = setup({sibling: {ptcTimeliness: true}}); + setPendingVote(forkChoice, 0, pendingNodeIndex(protoArray, parentRoot)); + + forkChoice.updateHead(); + + expect(appliedBoost(protoArray, childRoot)).toBe(BOOST_SCORE); + }); + + it("removes a newly detected equivocator's vote before judging the parent weak", () => { + // Validator 0's vote is the parent's entire attestation score. Marking it equivocating and + // updating the head once must first discount the vote (parent score 150 -> 0), and with the + // equivocator outside the parent slot's committees nothing is added back: the parent is weak + // and the boost is withheld. Reading the stale pre-delta score would double-keep the vote and + // wrongly apply the boost. + const equivocatingIndices = new Set(); + const store = makeStore({equivocatingIndices, state: mockStateEmptyCommittee()}); + const {forkChoice, protoArray, parentRoot, childRoot} = setup({sibling: {ptcTimeliness: true}, store}); + + // First head update applies the vote, as a previous slot would have + setPendingVote(forkChoice, 0, pendingNodeIndex(protoArray, parentRoot)); + forkChoice.updateHead(); + expect(appliedBoost(protoArray, childRoot)).toBe(BOOST_SCORE); + + // Now the validator is discovered equivocating; the measured single updateHead() must both + // discount the vote and withhold the boost + equivocatingIndices.add(0); + forkChoice.updateHead(); + + expect(appliedBoost(protoArray, childRoot)).toBe(0); + }); +}); From 90677efad65745824e23a60f2c37ba3a1436554b Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:17:12 -0700 Subject: [PATCH 7/7] test: add gloas boosted updateHead benchmark Measures the two-pass applyScoreChanges overhead against the single-pass pre-gloas row: 2.96 ms/op vs 2.72 ms/op at vc 600k bc 64 (~9%), paid only while a gloas block holds proposer boost. Co-Authored-By: Claude Fable 5 --- .../test/perf/forkChoice/updateHead.test.ts | 20 +++++++-- .../fork-choice/test/perf/forkChoice/util.ts | 43 +++++++++++++++---- 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/packages/fork-choice/test/perf/forkChoice/updateHead.test.ts b/packages/fork-choice/test/perf/forkChoice/updateHead.test.ts index 60c18d7d73fc..1979eb412c7d 100644 --- a/packages/fork-choice/test/perf/forkChoice/updateHead.test.ts +++ b/packages/fork-choice/test/perf/forkChoice/updateHead.test.ts @@ -22,9 +22,19 @@ describe("forkchoice updateHead", () => { runUpdateHeadBenchmark({initialValidatorCount: 600_000, initialBlockCount: 64, initialEquivocatedCount}); } + // A boosted gloas block splits applyScoreChanges in two passes (attestation deltas, then the + // should_apply_proposer_boost decision, then boost deltas); measure that overhead against the + // single-pass `vc 600000 bc 64 eq 0` row above + runUpdateHeadBenchmark({ + initialValidatorCount: 600_000, + initialBlockCount: 64, + initialEquivocatedCount: 0, + gloasBoosted: true, + }); + function runUpdateHeadBenchmark(opts: Opts): void { bench({ - id: `forkChoice updateHead vc ${opts.initialValidatorCount} bc ${opts.initialBlockCount} eq ${opts.initialEquivocatedCount}`, + id: `forkChoice updateHead vc ${opts.initialValidatorCount} bc ${opts.initialBlockCount} eq ${opts.initialEquivocatedCount}${opts.gloasBoosted ? " gloas boosted" : ""}`, before: () => { const forkChoice = initializeForkChoice(opts); @@ -43,7 +53,7 @@ describe("forkchoice updateHead", () => { }, beforeEach: (data) => { // Flip all votes every run - everyoneVotes(data.currentVoteIs1 ? data.vote2 : data.vote1, data.forkChoice); + everyoneVotes(data.currentVoteIs1 ? data.vote2 : data.vote1, data.forkChoice, opts); data.currentVoteIs1 = !data.currentVoteIs1; return data; }, @@ -54,9 +64,11 @@ describe("forkchoice updateHead", () => { } }); -function everyoneVotes(vote: ProtoBlock, forkChoice: ForkChoice): void { +function everyoneVotes(vote: ProtoBlock, forkChoice: ForkChoice, opts: Opts): void { const nextRoot = vote.blockRoot; + // gloas blocks only carry PENDING/EMPTY variants until the envelope is revealed + const payloadStatus = opts.gloasBoosted ? PayloadStatus.PENDING : PayloadStatus.FULL; for (let i = 0; i < forkChoice["balances"].length; i++) { - forkChoice["addLatestMessage"](i, vote.slot, nextRoot, PayloadStatus.FULL); + forkChoice["addLatestMessage"](i, vote.slot, nextRoot, payloadStatus); } } diff --git a/packages/fork-choice/test/perf/forkChoice/util.ts b/packages/fork-choice/test/perf/forkChoice/util.ts index 61e9d330b57a..ad739dd966b1 100644 --- a/packages/fork-choice/test/perf/forkChoice/util.ts +++ b/packages/fork-choice/test/perf/forkChoice/util.ts @@ -12,6 +12,7 @@ import { ProtoBlock, } from "../../../src/index.js"; import {makeState} from "../../unit/forkChoice/fastConfirmationTestUtils.js"; +import {gloasConfig} from "../../unit/forkChoice/proposerHeadTestUtils.js"; const genesisSlot = 0; const genesisEpoch = 0; @@ -26,6 +27,8 @@ export type Opts = { initialValidatorCount: number; initialEquivocatedCount: number; fastConfirmation?: boolean; + /** Build a gloas chain (PENDING/EMPTY variants) and give the last block proposer boost */ + gloasBoosted?: boolean; }; export function initializeForkChoice(opts: Opts): ForkChoice { @@ -117,13 +120,23 @@ export function initializeForkChoice(opts: Opts): ForkChoice { stateGetter: () => stubState, }; - const forkchoice = new ForkChoice(config, fcStore, protoArr, opts.initialValidatorCount, null, { - fastConfirmation: opts.fastConfirmation, - }); + const gloasBoosted = opts.gloasBoosted === true; + const forkchoice = new ForkChoice( + gloasBoosted ? gloasConfig : config, + fcStore, + protoArr, + opts.initialValidatorCount, + null, + { + fastConfirmation: opts.fastConfirmation, + proposerBoost: gloasBoosted, + } + ); let parentBlockRoot = genesisRoot; + let blockRoot = genesisRoot; for (let slot = 1; slot < opts.initialBlockCount; slot++) { - const blockRoot = slotRoot(slot); + blockRoot = slotRoot(slot); // Set unrealizedJustifiedEpoch to 1 for blocks in epoch 1+ // so that headJustification.epoch + 1 >= currentEpoch passes in loop 2 const blockEpoch = Math.floor(slot / SLOTS_PER_EPOCH); @@ -145,21 +158,33 @@ export function initializeForkChoice(opts: Opts): ForkChoice { unrealizedFinalizedEpoch: genesisEpoch, unrealizedFinalizedRoot: genesisRoot, - executionPayloadBlockHash: null, - executionStatus: ExecutionStatus.PreMerge, + executionPayloadBlockHash: gloasBoosted ? payloadHash(slot) : null, + executionStatus: gloasBoosted ? ExecutionStatus.Valid : ExecutionStatus.PreMerge, timeliness: false, ptcTimeliness: false, proposerIndex: 0, - dataAvailabilityStatus: DataAvailabilityStatus.PreData, + dataAvailabilityStatus: gloasBoosted ? DataAvailabilityStatus.Available : DataAvailabilityStatus.PreData, - parentBlockHash: null, + // The parent's own payload hash links this block to the parent's PENDING/EMPTY variant (the + // FULL variant only exists once the envelope is revealed, which this harness does not simulate) + parentBlockHash: gloasBoosted ? payloadHash(slot - 1) : null, payloadStatus: PayloadStatus.FULL, - }; + // ProtoBlock is a union over the execution fields; the conditionals lose the narrowing + } as ProtoBlock; protoArr.onBlock(block, block.slot, null); parentBlockRoot = blockRoot; } + if (gloasBoosted) { + // Boost the tip so every updateHead() takes the gloas two-pass applyScoreChanges path + forkchoice["proposerBoostRoot"] = blockRoot; + } + return forkchoice; } + +function payloadHash(slot: number): string { + return `0xpayload${slot}`; +}