From 48c6ced0b2e9d404fb21c7b7d9393823a879f161 Mon Sep 17 00:00:00 2001 From: Cayman Date: Thu, 21 May 2026 11:56:37 -0400 Subject: [PATCH 1/5] feat: report parent_block_hash for safe/finalized post-Gloas Implements consensus-specs PR-5197. Post-Gloas, engine_forkchoiceUpdated must report the bid's parent_block_hash as both safeBlockHash and finalizedBlockHash rather than the block's own execution payload hash: under ePBS the safe/finalized block's own payload may not yet be confirmed canonical, but its parent EL block has been. Adds getFinalizedExecutionBlockHash to mirror getSafeExecutionBlockHash and switches all five FCU callsites to the new fork-aware helpers. --- .../src/chain/blocks/importBlock.ts | 3 +- .../chain/blocks/importExecutionPayload.ts | 10 +- .../beacon-node/src/chain/prepareNextSlot.ts | 7 +- .../chain/produceBlock/produceBlockBody.ts | 11 +- .../api/impl/validator/produceBlockV3.test.ts | 4 +- .../beacon-node/test/utils/typeGenerator.ts | 4 +- .../fork-choice/src/forkChoice/safeBlocks.ts | 34 +++-- .../test/unit/forkChoice/safeBlocks.test.ts | 118 ++++++++++++++++++ 8 files changed, 169 insertions(+), 22 deletions(-) create mode 100644 packages/fork-choice/test/unit/forkChoice/safeBlocks.test.ts diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index d30965b08d21..4368dd77fabe 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -7,6 +7,7 @@ import { ForkChoiceError, ForkChoiceErrorCode, NotReorgedReason, + getFinalizedExecutionBlockHash, getSafeExecutionBlockHash, } from "@lodestar/fork-choice"; import {ForkPostAltair, ForkPostElectra, ForkSeq, MAX_SEED_LOOKAHEAD, SLOTS_PER_EPOCH} from "@lodestar/params"; @@ -438,7 +439,7 @@ export async function importBlock( * zero block hash (pre TTD) */ const safeBlockHash = getSafeExecutionBlockHash(this.forkChoice); - const finalizedBlockHash = this.forkChoice.getFinalizedBlock().executionPayloadBlockHash ?? ZERO_HASH_HEX; + const finalizedBlockHash = getFinalizedExecutionBlockHash(this.forkChoice); if (headBlockHash !== ZERO_HASH_HEX) { this.executionEngine .notifyForkchoiceUpdate( diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index 4b3d815c5314..a82d4ae511b2 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -1,8 +1,12 @@ import {routes} from "@lodestar/api"; -import {ExecutionStatus, PayloadExecutionStatus, getSafeExecutionBlockHash} from "@lodestar/fork-choice"; +import { + ExecutionStatus, + PayloadExecutionStatus, + getFinalizedExecutionBlockHash, + getSafeExecutionBlockHash, +} from "@lodestar/fork-choice"; import {DataAvailabilityStatus, isStatePostGloas} from "@lodestar/state-transition"; import {isErrorAborted} from "@lodestar/utils"; -import {ZERO_HASH_HEX} from "../../constants/index.js"; import {ExecutionPayloadStatus} from "../../execution/index.js"; import {isQueueErrorAborted} from "../../util/queue/index.js"; import {BeaconChain} from "../chain.js"; @@ -246,7 +250,7 @@ export async function importExecutionPayload( const head = this.forkChoice.getHead(); if (!this.opts.disableImportExecutionFcU && blockRootHex === head.blockRoot) { const safeBlockHash = getSafeExecutionBlockHash(this.forkChoice); - const finalizedBlockHash = this.forkChoice.getFinalizedBlock().executionPayloadBlockHash ?? ZERO_HASH_HEX; + const finalizedBlockHash = getFinalizedExecutionBlockHash(this.forkChoice); this.executionEngine.notifyForkchoiceUpdate(fork, blockHashHex, safeBlockHash, finalizedBlockHash).catch((e) => { if (!isErrorAborted(e) && !isQueueErrorAborted(e)) { this.logger.error("Error pushing notifyForkchoiceUpdate()", {blockHashHex, finalizedBlockHash}, e); diff --git a/packages/beacon-node/src/chain/prepareNextSlot.ts b/packages/beacon-node/src/chain/prepareNextSlot.ts index 67fff1809e7e..e4207b6876b9 100644 --- a/packages/beacon-node/src/chain/prepareNextSlot.ts +++ b/packages/beacon-node/src/chain/prepareNextSlot.ts @@ -1,6 +1,6 @@ import {routes} from "@lodestar/api"; import {ChainForkConfig} from "@lodestar/config"; -import {getSafeExecutionBlockHash} from "@lodestar/fork-choice"; +import {getFinalizedExecutionBlockHash, getSafeExecutionBlockHash} from "@lodestar/fork-choice"; import {ForkPostBellatrix, ForkSeq, SLOTS_PER_EPOCH, isForkPostBellatrix} from "@lodestar/params"; import { IBeaconStateView, @@ -13,7 +13,7 @@ import { } from "@lodestar/state-transition"; import {Bytes32, Slot} from "@lodestar/types"; import {Logger, fromHex, isErrorAborted, sleep} from "@lodestar/utils"; -import {GENESIS_SLOT, ZERO_HASH_HEX} from "../constants/constants.js"; +import {GENESIS_SLOT} from "../constants/constants.js"; import {BuilderStatus} from "../execution/builder/http.js"; import {Metrics} from "../metrics/index.js"; import {ClockEvent} from "../util/clock.js"; @@ -193,8 +193,7 @@ export class PrepareNextSlotScheduler { this.metrics?.blockPayload.payloadAdvancePrepTime.observe(preparationTime); const safeBlockHash = getSafeExecutionBlockHash(this.chain.forkChoice); - const finalizedBlockHash = - this.chain.forkChoice.getFinalizedBlock().executionPayloadBlockHash ?? ZERO_HASH_HEX; + const finalizedBlockHash = getFinalizedExecutionBlockHash(this.chain.forkChoice); // awaiting here instead of throwing an async call because there is no other task // left for scheduler and this gives nice semantics to catch and log errors in the diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index 8392c65829b0..af552bbc225a 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -1,5 +1,10 @@ import {ChainForkConfig} from "@lodestar/config"; -import {IForkChoice, ProtoBlock, getSafeExecutionBlockHash} from "@lodestar/fork-choice"; +import { + IForkChoice, + ProtoBlock, + getFinalizedExecutionBlockHash, + getSafeExecutionBlockHash, +} from "@lodestar/fork-choice"; import { BUILDER_INDEX_SELF_BUILD, ForkName, @@ -262,7 +267,7 @@ export async function produceBlockBody( // full and blinded no longer makes sense in gloas, it might be a good idea to move // this into a completely separate function and have pre/post gloas more separated const safeBlockHash = getSafeExecutionBlockHash(this.forkChoice); - const finalizedBlockHash = this.forkChoice.getFinalizedBlock().executionPayloadBlockHash ?? ZERO_HASH_HEX; + const finalizedBlockHash = getFinalizedExecutionBlockHash(this.forkChoice); // TODO GLOAS: post-Gloas, proposer feeRecipient is also carried (signed) in // ProposerPreferencesPool. Consider using this unified cache instead // see https://github.com/ChainSafe/lodestar/issues/9379 @@ -404,7 +409,7 @@ export async function produceBlockBody( } const safeBlockHash = getSafeExecutionBlockHash(this.forkChoice); - const finalizedBlockHash = this.forkChoice.getFinalizedBlock().executionPayloadBlockHash ?? ZERO_HASH_HEX; + const finalizedBlockHash = getFinalizedExecutionBlockHash(this.forkChoice); const feeRecipient = requestedFeeRecipient ?? this.beaconProposerCache.getOrDefault(proposerIndex); const feeRecipientType = requestedFeeRecipient ? "requested" diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts index aedb4eb09899..5518220d39c4 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts @@ -246,8 +246,8 @@ describe("api/validator - produceBlockV3", () => { syncCommitteeBits: ssz.altair.SyncCommitteeBits.defaultValue(), syncCommitteeSignature: G2_POINT_AT_INFINITY, }); - modules.forkChoice.getJustifiedBlock.mockReturnValue({} as ProtoBlock); - modules.forkChoice.getFinalizedBlock.mockReturnValue({} as ProtoBlock); + modules.forkChoice.getJustifiedBlock.mockReturnValue(generateProtoBlock()); + modules.forkChoice.getFinalizedBlock.mockReturnValue(generateProtoBlock()); modules.chain["executionEngine"].payloadIdCache = new PayloadIdCache(); modules.chain["executionEngine"].notifyForkchoiceUpdate.mockResolvedValue("0x"); diff --git a/packages/beacon-node/test/utils/typeGenerator.ts b/packages/beacon-node/test/utils/typeGenerator.ts index 7886df877050..0be6c48ee1c7 100644 --- a/packages/beacon-node/test/utils/typeGenerator.ts +++ b/packages/beacon-node/test/utils/typeGenerator.ts @@ -1,4 +1,4 @@ -import {ExecutionStatus, ProtoBlock} from "@lodestar/fork-choice"; +import {ExecutionStatus, PayloadStatus, ProtoBlock} from "@lodestar/fork-choice"; import {DataAvailabilityStatus} from "@lodestar/state-transition"; import {Slot, phase0, ssz} from "@lodestar/types"; import {fromHex} from "@lodestar/utils"; @@ -40,6 +40,8 @@ export function generateProtoBlock(overrides: Partial = {}): ProtoBl unrealizedFinalizedRoot: ZERO_HASH_HEX, timeliness: false, + payloadStatus: PayloadStatus.FULL, + parentBlockHash: null, ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, diff --git a/packages/fork-choice/src/forkChoice/safeBlocks.ts b/packages/fork-choice/src/forkChoice/safeBlocks.ts index 52a45e49209f..7347a9a31ba7 100644 --- a/packages/fork-choice/src/forkChoice/safeBlocks.ts +++ b/packages/fork-choice/src/forkChoice/safeBlocks.ts @@ -1,5 +1,5 @@ -import {ZERO_HASH_HEX} from "@lodestar/params"; import {Root, RootHex} from "@lodestar/types"; +import {HEX_ZERO_HASH, ProtoBlock, isGloasBlock} from "../protoArray/interface.js"; import {IForkChoice} from "./interface.js"; /** @@ -7,21 +7,39 @@ import {IForkChoice} from "./interface.js"; * that is safe from re-orgs. Normally this block is pretty close to the head of canonical * chain which makes it valuable to expose a safe block to users. * - * https://github.com/ethereum/consensus-specs/blob/v1.6.0/fork_choice/safe-block.md#get_safe_beacon_block_root + * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.8/fork_choice/safe-block.md#get_safe_beacon_block_root */ export function getSafeBeaconBlockRoot(fc: IForkChoice): Root { return fc.getJustifiedCheckpoint().root; } /** - * Get execution payload hash for the safe block - * This function assumes that safe block is post Bellatrix and function should not be called otherwise. + * Get execution payload hash to report as `safeBlockHash` in `engine_forkchoiceUpdated`. * - * As our existing usage is aligned with above condition so not adding fork-check inside this function + * Pre-Gloas: the justified block's own payload hash. + * Post-Gloas: the justified block's bid `parent_block_hash` — under ePBS the block's own + * payload may not yet be confirmed canonical, so we report the parent EL block which has + * been (the bid commits to extending it). * - * - * https://github.com/ethereum/consensus-specs/blob/v1.6.0/fork_choice/safe-block.md#get_safe_execution_block_hash + * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.8/fork_choice/safe-block.md#get_safe_execution_block_hash */ export function getSafeExecutionBlockHash(forkChoice: IForkChoice): RootHex { - return forkChoice.getJustifiedBlock().executionPayloadBlockHash ?? ZERO_HASH_HEX; + return getExecutionBlockHashForFCU(forkChoice.getJustifiedBlock()); +} + +/** + * Get execution payload hash to report as `finalizedBlockHash` in `engine_forkchoiceUpdated`. + * Mirrors `getSafeExecutionBlockHash`: post-Gloas returns the bid `parent_block_hash`. + * + * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.8/specs/gloas/fork-choice.md#modified-notify_forkchoice_updated + */ +export function getFinalizedExecutionBlockHash(forkChoice: IForkChoice): RootHex { + return getExecutionBlockHashForFCU(forkChoice.getFinalizedBlock()); +} + +function getExecutionBlockHashForFCU(block: ProtoBlock): RootHex { + if (isGloasBlock(block)) { + return block.parentBlockHash as RootHex; + } + return block.executionPayloadBlockHash ?? HEX_ZERO_HASH; } diff --git a/packages/fork-choice/test/unit/forkChoice/safeBlocks.test.ts b/packages/fork-choice/test/unit/forkChoice/safeBlocks.test.ts new file mode 100644 index 000000000000..a6a6f6192e22 --- /dev/null +++ b/packages/fork-choice/test/unit/forkChoice/safeBlocks.test.ts @@ -0,0 +1,118 @@ +import {describe, expect, it} from "vitest"; +import {DataAvailabilityStatus} from "@lodestar/state-transition"; +import {RootHex} from "@lodestar/types"; +import {IForkChoice} from "../../../src/forkChoice/interface.js"; +import {getFinalizedExecutionBlockHash, getSafeExecutionBlockHash} from "../../../src/forkChoice/safeBlocks.js"; +import {ExecutionStatus, HEX_ZERO_HASH, PayloadStatus, ProtoBlock} from "../../../src/protoArray/interface.js"; + +function buildBlock(opts: { + blockRoot: RootHex; + executionPayloadBlockHash: RootHex | null; + parentBlockHash: RootHex | null; +}): ProtoBlock { + const common = { + slot: 0, + blockRoot: opts.blockRoot, + parentRoot: "0x00", + stateRoot: "0x00", + targetRoot: "0x00", + justifiedEpoch: 0, + justifiedRoot: "0x00", + finalizedEpoch: 0, + finalizedRoot: "0x00", + unrealizedJustifiedEpoch: 0, + unrealizedJustifiedRoot: "0x00", + unrealizedFinalizedEpoch: 0, + unrealizedFinalizedRoot: "0x00", + timeliness: true, + payloadStatus: PayloadStatus.FULL, + parentBlockHash: opts.parentBlockHash, + }; + if (opts.executionPayloadBlockHash === null) { + return { + ...common, + executionPayloadBlockHash: null, + executionStatus: ExecutionStatus.PreMerge, + dataAvailabilityStatus: DataAvailabilityStatus.PreData, + }; + } + return { + ...common, + executionPayloadBlockHash: opts.executionPayloadBlockHash, + executionPayloadNumber: 0, + executionPayloadGasLimit: 30_000_000, + executionStatus: ExecutionStatus.Valid, + dataAvailabilityStatus: DataAvailabilityStatus.Available, + }; +} + +function mockForkChoice(justified: ProtoBlock, finalized: ProtoBlock): IForkChoice { + return { + getJustifiedBlock: () => justified, + getFinalizedBlock: () => finalized, + } as unknown as IForkChoice; +} + +describe("safeBlocks - getSafeExecutionBlockHash", () => { + it("pre-Gloas: returns the justified block's own executionPayloadBlockHash", () => { + const justified = buildBlock({ + blockRoot: "0xaa", + executionPayloadBlockHash: "0xpayloadA", + parentBlockHash: null, + }); + const fc = mockForkChoice(justified, justified); + expect(getSafeExecutionBlockHash(fc)).toBe("0xpayloadA"); + }); + + it("pre-Bellatrix: returns ZERO_HASH_HEX when executionPayloadBlockHash is null", () => { + const justified = buildBlock({ + blockRoot: "0xaa", + executionPayloadBlockHash: null, + parentBlockHash: null, + }); + const fc = mockForkChoice(justified, justified); + expect(getSafeExecutionBlockHash(fc)).toBe(HEX_ZERO_HASH); + }); + + it("post-Gloas: returns the justified block's bid.parent_block_hash, not its own payload hash", () => { + const justified = buildBlock({ + blockRoot: "0xaa", + executionPayloadBlockHash: "0xpayloadA", + parentBlockHash: "0xparentEL", + }); + const fc = mockForkChoice(justified, justified); + expect(getSafeExecutionBlockHash(fc)).toBe("0xparentEL"); + }); +}); + +describe("safeBlocks - getFinalizedExecutionBlockHash", () => { + it("pre-Gloas: returns the finalized block's own executionPayloadBlockHash", () => { + const finalized = buildBlock({ + blockRoot: "0xbb", + executionPayloadBlockHash: "0xpayloadF", + parentBlockHash: null, + }); + const fc = mockForkChoice(finalized, finalized); + expect(getFinalizedExecutionBlockHash(fc)).toBe("0xpayloadF"); + }); + + it("pre-Bellatrix: returns ZERO_HASH_HEX when executionPayloadBlockHash is null", () => { + const finalized = buildBlock({ + blockRoot: "0xbb", + executionPayloadBlockHash: null, + parentBlockHash: null, + }); + const fc = mockForkChoice(finalized, finalized); + expect(getFinalizedExecutionBlockHash(fc)).toBe(HEX_ZERO_HASH); + }); + + it("post-Gloas: returns the finalized block's bid.parent_block_hash, not its own payload hash", () => { + const finalized = buildBlock({ + blockRoot: "0xbb", + executionPayloadBlockHash: "0xpayloadF", + parentBlockHash: "0xparentEL", + }); + const fc = mockForkChoice(finalized, finalized); + expect(getFinalizedExecutionBlockHash(fc)).toBe("0xparentEL"); + }); +}); From 00dbe3bd53d18e9fd1c126554104eb280ba7327e Mon Sep 17 00:00:00 2001 From: Cayman Date: Wed, 5 Aug 2026 16:28:53 -0400 Subject: [PATCH 2/5] fix(fork-choice): address execution hash review feedback --- .../src/chain/blocks/importBlock.ts | 4 +-- .../chain/blocks/importExecutionPayload.ts | 4 +-- .../beacon-node/src/chain/prepareNextSlot.ts | 4 +-- .../chain/produceBlock/produceBlockBody.ts | 8 ++--- .../api/impl/validator/produceBlockV3.test.ts | 2 +- .../fork-choice/src/forkChoice/safeBlocks.ts | 32 ++++++++++++----- .../fork-choice/src/protoArray/interface.ts | 2 +- .../test/unit/forkChoice/safeBlocks.test.ts | 34 ++++++++++++++++--- 8 files changed, 66 insertions(+), 24 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index a50136ff10d7..896de7942a2b 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -444,8 +444,8 @@ export async function importBlock( * the current finalized block does not contain any execution payload at all (pre MERGE_EPOCH) or if it contains a * zero block hash (pre TTD) */ - const safeBlockHash = getSafeExecutionBlockHash(this.forkChoice); - const finalizedBlockHash = getFinalizedExecutionBlockHash(this.forkChoice); + const safeBlockHash = getSafeExecutionBlockHash(this.forkChoice, this.logger); + const finalizedBlockHash = getFinalizedExecutionBlockHash(this.forkChoice, this.logger); if (headBlockHash !== ZERO_HASH_HEX) { this.executionEngine .notifyForkchoiceUpdate( diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index aba2e45f15f6..289e346994ec 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -249,8 +249,8 @@ export async function importExecutionPayload( // 7. Queue notifyForkchoiceUpdate to engine api const head = this.forkChoice.getHead(); if (!this.opts.disableImportExecutionFcU && blockRootHex === head.blockRoot) { - const safeBlockHash = getSafeExecutionBlockHash(this.forkChoice); - const finalizedBlockHash = getFinalizedExecutionBlockHash(this.forkChoice); + const safeBlockHash = getSafeExecutionBlockHash(this.forkChoice, this.logger); + const finalizedBlockHash = getFinalizedExecutionBlockHash(this.forkChoice, this.logger); this.executionEngine.notifyForkchoiceUpdate(fork, blockHashHex, safeBlockHash, finalizedBlockHash).catch((e) => { if (!isErrorAborted(e) && !isQueueErrorAborted(e)) { this.logger.error("Error pushing notifyForkchoiceUpdate()", {blockHashHex, finalizedBlockHash}, e); diff --git a/packages/beacon-node/src/chain/prepareNextSlot.ts b/packages/beacon-node/src/chain/prepareNextSlot.ts index ade2282c5ffc..0bb9e7e698ec 100644 --- a/packages/beacon-node/src/chain/prepareNextSlot.ts +++ b/packages/beacon-node/src/chain/prepareNextSlot.ts @@ -214,8 +214,8 @@ export class PrepareNextSlotScheduler { computeTimeAtSlot(this.config, prepareSlot, this.chain.genesisTime) - Date.now() / 1000; this.metrics?.blockPayload.payloadAdvancePrepTime.observe(preparationTime); - const safeBlockHash = getSafeExecutionBlockHash(this.chain.forkChoice); - const finalizedBlockHash = getFinalizedExecutionBlockHash(this.chain.forkChoice); + const safeBlockHash = getSafeExecutionBlockHash(this.chain.forkChoice, this.logger); + const finalizedBlockHash = getFinalizedExecutionBlockHash(this.chain.forkChoice, this.logger); // awaiting here instead of throwing an async call because there is no other task // left for scheduler and this gives nice semantics to catch and log errors in the diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index c35e4f1cc901..e631e9f9e760 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -270,8 +270,8 @@ export async function produceBlockBody( // TODO GLOAS: support non self-building here, the block type differentiation between // full and blinded no longer makes sense in gloas, it might be a good idea to move // this into a completely separate function and have pre/post gloas more separated - const safeBlockHash = getSafeExecutionBlockHash(this.forkChoice); - const finalizedBlockHash = getFinalizedExecutionBlockHash(this.forkChoice); + const safeBlockHash = getSafeExecutionBlockHash(this.forkChoice, this.logger); + const finalizedBlockHash = getFinalizedExecutionBlockHash(this.forkChoice, this.logger); // TODO GLOAS: post-Gloas, proposer feeRecipient is also carried (signed) in // ProposerPreferencesPool. Consider using this unified cache instead // see https://github.com/ChainSafe/lodestar/issues/9379 @@ -416,8 +416,8 @@ export async function produceBlockBody( throw new Error("Expected Bellatrix state for execution block production"); } - const safeBlockHash = getSafeExecutionBlockHash(this.forkChoice); - const finalizedBlockHash = getFinalizedExecutionBlockHash(this.forkChoice); + const safeBlockHash = getSafeExecutionBlockHash(this.forkChoice, this.logger); + const finalizedBlockHash = getFinalizedExecutionBlockHash(this.forkChoice, this.logger); const feeRecipient = requestedFeeRecipient ?? this.beaconProposerCache.getOrDefault(proposerIndex); const feeRecipientType = requestedFeeRecipient ? "requested" diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts index fa59d7532adb..e0ca532e3292 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts @@ -290,7 +290,7 @@ describe("api/validator - produceBlockV3", () => { syncCommitteeBits: ssz.altair.SyncCommitteeBits.defaultValue(), syncCommitteeSignature: G2_POINT_AT_INFINITY, }); - modules.forkChoice.getJustifiedBlock.mockReturnValue(generateProtoBlock()); + modules.forkChoice.getConfirmedBlock.mockReturnValue(generateProtoBlock()); modules.forkChoice.getFinalizedBlock.mockReturnValue(generateProtoBlock()); modules.chain["executionEngine"].payloadIdCache = new PayloadIdCache(); diff --git a/packages/fork-choice/src/forkChoice/safeBlocks.ts b/packages/fork-choice/src/forkChoice/safeBlocks.ts index bbff14347f7d..8a08a83fe6c1 100644 --- a/packages/fork-choice/src/forkChoice/safeBlocks.ts +++ b/packages/fork-choice/src/forkChoice/safeBlocks.ts @@ -1,6 +1,6 @@ import {ZERO_HASH_HEX} from "@lodestar/params"; import {Root, RootHex} from "@lodestar/types"; -import {fromHex} from "@lodestar/utils"; +import {LogLevel, Logger, fromHex} from "@lodestar/utils"; import {HEX_ZERO_HASH, ProtoBlock, isGloasBlock} from "../protoArray/interface.js"; import {IForkChoice} from "./interface.js"; @@ -29,9 +29,16 @@ export function getSafeBeaconBlockRoot(fc: IForkChoice): Root { * * https://github.com/ethereum/consensus-specs/blob/master/fork_choice/safe-block.md#get_safe_execution_block_hash */ -export function getSafeExecutionBlockHash(forkChoice: IForkChoice): RootHex { +export function getSafeExecutionBlockHash(forkChoice: IForkChoice, logger?: Pick): RootHex { const confirmedBlock = forkChoice.getConfirmedBlock(); - return confirmedBlock ? getExecutionBlockHashForFCU(confirmedBlock) : ZERO_HASH_HEX; + if (confirmedBlock === null) { + logger?.warn("Confirmed block not found; using zero safe execution block hash", { + confirmedRoot: forkChoice.getConfirmedRoot(), + }); + return ZERO_HASH_HEX; + } + + return getExecutionBlockHash(confirmedBlock, logger); } /** @@ -40,13 +47,22 @@ export function getSafeExecutionBlockHash(forkChoice: IForkChoice): RootHex { * * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.8/specs/gloas/fork-choice.md#modified-notify_forkchoice_updated */ -export function getFinalizedExecutionBlockHash(forkChoice: IForkChoice): RootHex { - return getExecutionBlockHashForFCU(forkChoice.getFinalizedBlock()); +export function getFinalizedExecutionBlockHash(forkChoice: IForkChoice, logger?: Pick): RootHex { + return getExecutionBlockHash(forkChoice.getFinalizedBlock(), logger); } -function getExecutionBlockHashForFCU(block: ProtoBlock): RootHex { +function getExecutionBlockHash(block: ProtoBlock, logger?: Pick): RootHex { if (isGloasBlock(block)) { - return block.parentBlockHash as RootHex; + return block.parentBlockHash; } - return block.executionPayloadBlockHash ?? HEX_ZERO_HASH; + + if (block.executionPayloadBlockHash === null) { + logger?.warn("Execution payload block hash not found; using zero hash", { + blockRoot: block.blockRoot, + slot: block.slot, + }); + return HEX_ZERO_HASH; + } + + return block.executionPayloadBlockHash; } diff --git a/packages/fork-choice/src/protoArray/interface.ts b/packages/fork-choice/src/protoArray/interface.ts index 11ba86a71f7e..5f48059e60a2 100644 --- a/packages/fork-choice/src/protoArray/interface.ts +++ b/packages/fork-choice/src/protoArray/interface.ts @@ -48,7 +48,7 @@ export enum PayloadStatus { /** * Check if a block is in the Gloas fork (ePBS enabled) */ -export function isGloasBlock(block: ProtoBlock): boolean { +export function isGloasBlock(block: ProtoBlock): block is ProtoBlock & {parentBlockHash: RootHex} { return block.parentBlockHash !== null; } diff --git a/packages/fork-choice/test/unit/forkChoice/safeBlocks.test.ts b/packages/fork-choice/test/unit/forkChoice/safeBlocks.test.ts index b9d49cb1eef4..ae8b27d84f68 100644 --- a/packages/fork-choice/test/unit/forkChoice/safeBlocks.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/safeBlocks.test.ts @@ -1,4 +1,4 @@ -import {describe, expect, it} from "vitest"; +import {describe, expect, it, vi} from "vitest"; import {DataAvailabilityStatus} from "@lodestar/state-transition"; import {RootHex} from "@lodestar/types"; import {IForkChoice} from "../../../src/forkChoice/interface.js"; @@ -46,9 +46,10 @@ function buildBlock(opts: { }; } -function mockForkChoice(confirmed: ProtoBlock, finalized: ProtoBlock): IForkChoice { +function mockForkChoice(confirmed: ProtoBlock | null, finalized: ProtoBlock): IForkChoice { return { getConfirmedBlock: () => confirmed, + getConfirmedRoot: () => confirmed?.blockRoot ?? "0xconfirmed", getFinalizedBlock: () => finalized, } as unknown as IForkChoice; } @@ -65,13 +66,18 @@ describe("safeBlocks - getSafeExecutionBlockHash", () => { }); it("pre-Bellatrix: returns ZERO_HASH_HEX when executionPayloadBlockHash is null", () => { + const warn = vi.fn(); const confirmed = buildBlock({ blockRoot: "0xaa", executionPayloadBlockHash: null, parentBlockHash: null, }); const fc = mockForkChoice(confirmed, confirmed); - expect(getSafeExecutionBlockHash(fc)).toBe(HEX_ZERO_HASH); + expect(getSafeExecutionBlockHash(fc, {warn})).toBe(HEX_ZERO_HASH); + expect(warn).toHaveBeenCalledWith("Execution payload block hash not found; using zero hash", { + blockRoot: confirmed.blockRoot, + slot: confirmed.slot, + }); }); it("post-Gloas: returns the confirmed block's bid.parent_block_hash, not its own payload hash", () => { @@ -83,6 +89,21 @@ describe("safeBlocks - getSafeExecutionBlockHash", () => { const fc = mockForkChoice(confirmed, confirmed); expect(getSafeExecutionBlockHash(fc)).toBe("0xparentEL"); }); + + it("returns ZERO_HASH_HEX and logs when the confirmed block is not found", () => { + const warn = vi.fn(); + const finalized = buildBlock({ + blockRoot: "0xbb", + executionPayloadBlockHash: "0xpayloadF", + parentBlockHash: null, + }); + const fc = mockForkChoice(null, finalized); + + expect(getSafeExecutionBlockHash(fc, {warn})).toBe(HEX_ZERO_HASH); + expect(warn).toHaveBeenCalledWith("Confirmed block not found; using zero safe execution block hash", { + confirmedRoot: "0xconfirmed", + }); + }); }); describe("safeBlocks - getFinalizedExecutionBlockHash", () => { @@ -97,13 +118,18 @@ describe("safeBlocks - getFinalizedExecutionBlockHash", () => { }); it("pre-Bellatrix: returns ZERO_HASH_HEX when executionPayloadBlockHash is null", () => { + const warn = vi.fn(); const finalized = buildBlock({ blockRoot: "0xbb", executionPayloadBlockHash: null, parentBlockHash: null, }); const fc = mockForkChoice(finalized, finalized); - expect(getFinalizedExecutionBlockHash(fc)).toBe(HEX_ZERO_HASH); + expect(getFinalizedExecutionBlockHash(fc, {warn})).toBe(HEX_ZERO_HASH); + expect(warn).toHaveBeenCalledWith("Execution payload block hash not found; using zero hash", { + blockRoot: finalized.blockRoot, + slot: finalized.slot, + }); }); it("post-Gloas: returns the finalized block's bid.parent_block_hash, not its own payload hash", () => { From eb3cbca0008551951cfdb75d758738487c5dd84d Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Thu, 6 Aug 2026 21:51:28 +0500 Subject: [PATCH 3/5] fix(fork-choice): zero safe hash for pre-gloas genesis anchor Genesis block body carries a default payload, so it is not an execution block per spec (is_execution_block); proto array's anchor hash comes from the state header and must not be reported as safe/finalized. Gloas genesis still reports the bid parent_block_hash (consensus-specs PR-5449 convention). - pin spec doc links to v1.7.0-alpha.13, fix broken anchors - mock getConfirmedBlock in prepareNextSlot test (FCU path threw, getFinalizedBlock never called) Co-Authored-By: Claude Fable 5 --- .../test/unit/chain/prepareNextSlot.test.ts | 3 +- .../fork-choice/src/forkChoice/safeBlocks.ts | 13 +++++-- .../test/unit/forkChoice/safeBlocks.test.ts | 39 ++++++++++++++++++- 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/packages/beacon-node/test/unit/chain/prepareNextSlot.test.ts b/packages/beacon-node/test/unit/chain/prepareNextSlot.test.ts index aac4fcdbf052..843c957af7b1 100644 --- a/packages/beacon-node/test/unit/chain/prepareNextSlot.test.ts +++ b/packages/beacon-node/test/unit/chain/prepareNextSlot.test.ts @@ -122,7 +122,8 @@ describe("PrepareNextSlot scheduler", () => { getForkStub.mockReturnValue(ForkName.bellatrix); chainStub.recomputeForkChoiceHead.mockReturnValue({...zeroProtoBlock, slot: SLOTS_PER_EPOCH - 3} as ProtoBlock); chainStub.predictProposerHead.mockReturnValue({...zeroProtoBlock, slot: SLOTS_PER_EPOCH - 3} as ProtoBlock); - forkChoiceStub.getFinalizedBlock.mockReturnValue({} as ProtoBlock); + forkChoiceStub.getConfirmedBlock.mockReturnValue({...zeroProtoBlock, slot: SLOTS_PER_EPOCH - 3} as ProtoBlock); + forkChoiceStub.getFinalizedBlock.mockReturnValue({...zeroProtoBlock, slot: SLOTS_PER_EPOCH - 3} as ProtoBlock); updateBuilderStatus.mockReturnValue(void 0); const state = generateCachedBellatrixState(); vi.spyOn(state.epochCtx, "getBeaconProposer").mockReturnValue(proposerIndex); diff --git a/packages/fork-choice/src/forkChoice/safeBlocks.ts b/packages/fork-choice/src/forkChoice/safeBlocks.ts index 8a08a83fe6c1..58501d73cdc9 100644 --- a/packages/fork-choice/src/forkChoice/safeBlocks.ts +++ b/packages/fork-choice/src/forkChoice/safeBlocks.ts @@ -1,4 +1,4 @@ -import {ZERO_HASH_HEX} from "@lodestar/params"; +import {GENESIS_SLOT, ZERO_HASH_HEX} from "@lodestar/params"; import {Root, RootHex} from "@lodestar/types"; import {LogLevel, Logger, fromHex} from "@lodestar/utils"; import {HEX_ZERO_HASH, ProtoBlock, isGloasBlock} from "../protoArray/interface.js"; @@ -27,7 +27,7 @@ export function getSafeBeaconBlockRoot(fc: IForkChoice): Root { * payload may not yet be confirmed canonical, so we report the parent EL block which has * been (the bid commits to extending it). * - * https://github.com/ethereum/consensus-specs/blob/master/fork_choice/safe-block.md#get_safe_execution_block_hash + * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.13/specs/bellatrix/fast-confirmation.md#new-get_safe_execution_block_hash */ export function getSafeExecutionBlockHash(forkChoice: IForkChoice, logger?: Pick): RootHex { const confirmedBlock = forkChoice.getConfirmedBlock(); @@ -45,7 +45,7 @@ export function getSafeExecutionBlockHash(forkChoice: IForkChoice, logger?: Pick * Get execution payload hash to report as `finalizedBlockHash` in `engine_forkchoiceUpdated`. * Mirrors `getSafeExecutionBlockHash`: post-Gloas returns the bid `parent_block_hash`. * - * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.8/specs/gloas/fork-choice.md#modified-notify_forkchoice_updated + * https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.13/specs/gloas/fork-choice.md#notify_forkchoice_updated */ export function getFinalizedExecutionBlockHash(forkChoice: IForkChoice, logger?: Pick): RootHex { return getExecutionBlockHash(forkChoice.getFinalizedBlock(), logger); @@ -56,6 +56,13 @@ function getExecutionBlockHash(block: ProtoBlock, logger?: Pick { confirmedRoot: "0xconfirmed", }); }); + + it("pre-Gloas genesis anchor: returns ZERO_HASH_HEX, not the state's payload header hash", () => { + const confirmed = buildBlock({ + blockRoot: "0xaa", + executionPayloadBlockHash: "0xfromStateHeader", + parentBlockHash: null, + slot: GENESIS_SLOT, + }); + const fc = mockForkChoice(confirmed, confirmed); + expect(getSafeExecutionBlockHash(fc)).toBe(HEX_ZERO_HASH); + }); + + it("Gloas genesis anchor: returns the bid.parent_block_hash", () => { + const confirmed = buildBlock({ + blockRoot: "0xaa", + executionPayloadBlockHash: "0xpayloadA", + parentBlockHash: "0xparentEL", + slot: GENESIS_SLOT, + }); + const fc = mockForkChoice(confirmed, confirmed); + expect(getSafeExecutionBlockHash(fc)).toBe("0xparentEL"); + }); }); describe("safeBlocks - getFinalizedExecutionBlockHash", () => { @@ -141,4 +165,15 @@ describe("safeBlocks - getFinalizedExecutionBlockHash", () => { const fc = mockForkChoice(finalized, finalized); expect(getFinalizedExecutionBlockHash(fc)).toBe("0xparentEL"); }); + + it("pre-Gloas genesis anchor: returns ZERO_HASH_HEX, not the state's payload header hash", () => { + const finalized = buildBlock({ + blockRoot: "0xbb", + executionPayloadBlockHash: "0xfromStateHeader", + parentBlockHash: null, + slot: GENESIS_SLOT, + }); + const fc = mockForkChoice(finalized, finalized); + expect(getFinalizedExecutionBlockHash(fc)).toBe(HEX_ZERO_HASH); + }); }); From ce6d9bbae0fc745147231b4e415802a4a6ef6892 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 7 Aug 2026 13:53:44 +0100 Subject: [PATCH 4/5] don't like ; in logs Co-authored-by: Nico Flaig --- packages/fork-choice/src/forkChoice/safeBlocks.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/fork-choice/src/forkChoice/safeBlocks.ts b/packages/fork-choice/src/forkChoice/safeBlocks.ts index 58501d73cdc9..e8a93a25c173 100644 --- a/packages/fork-choice/src/forkChoice/safeBlocks.ts +++ b/packages/fork-choice/src/forkChoice/safeBlocks.ts @@ -32,7 +32,7 @@ export function getSafeBeaconBlockRoot(fc: IForkChoice): Root { export function getSafeExecutionBlockHash(forkChoice: IForkChoice, logger?: Pick): RootHex { const confirmedBlock = forkChoice.getConfirmedBlock(); if (confirmedBlock === null) { - logger?.warn("Confirmed block not found; using zero safe execution block hash", { + logger?.warn("Confirmed block not found, using zero safe execution block hash", { confirmedRoot: forkChoice.getConfirmedRoot(), }); return ZERO_HASH_HEX; @@ -64,7 +64,7 @@ function getExecutionBlockHash(block: ProtoBlock, logger?: Pick Date: Fri, 7 Aug 2026 15:25:50 +0100 Subject: [PATCH 5/5] update safeBlocks test assertions to match log messages --- .../fork-choice/test/unit/forkChoice/safeBlocks.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/fork-choice/test/unit/forkChoice/safeBlocks.test.ts b/packages/fork-choice/test/unit/forkChoice/safeBlocks.test.ts index b5f6114371ac..72acd93a369b 100644 --- a/packages/fork-choice/test/unit/forkChoice/safeBlocks.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/safeBlocks.test.ts @@ -76,7 +76,7 @@ describe("safeBlocks - getSafeExecutionBlockHash", () => { }); const fc = mockForkChoice(confirmed, confirmed); expect(getSafeExecutionBlockHash(fc, {warn})).toBe(HEX_ZERO_HASH); - expect(warn).toHaveBeenCalledWith("Execution payload block hash not found; using zero hash", { + expect(warn).toHaveBeenCalledWith("Execution payload block hash not found, using zero hash", { blockRoot: confirmed.blockRoot, slot: confirmed.slot, }); @@ -102,7 +102,7 @@ describe("safeBlocks - getSafeExecutionBlockHash", () => { const fc = mockForkChoice(null, finalized); expect(getSafeExecutionBlockHash(fc, {warn})).toBe(HEX_ZERO_HASH); - expect(warn).toHaveBeenCalledWith("Confirmed block not found; using zero safe execution block hash", { + expect(warn).toHaveBeenCalledWith("Confirmed block not found, using zero safe execution block hash", { confirmedRoot: "0xconfirmed", }); }); @@ -150,7 +150,7 @@ describe("safeBlocks - getFinalizedExecutionBlockHash", () => { }); const fc = mockForkChoice(finalized, finalized); expect(getFinalizedExecutionBlockHash(fc, {warn})).toBe(HEX_ZERO_HASH); - expect(warn).toHaveBeenCalledWith("Execution payload block hash not found; using zero hash", { + expect(warn).toHaveBeenCalledWith("Execution payload block hash not found, using zero hash", { blockRoot: finalized.blockRoot, slot: finalized.slot, });