From 382330fd3950049ff4525ebef4ee5f7ee9910e31 Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Wed, 6 May 2026 13:10:39 +0000 Subject: [PATCH 1/2] fix: invalidate fork-choice head on FCU INVALID with latestValidHash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Engine API spec, when `engine_forkchoiceUpdated` returns INVALID with a latestValidHash, the consensus client must mark the offending head and its ancestors back to (but not including) the LVH as invalid in fork choice and recompute the head. Lodestar was discarding `latestValidHash` and throwing a plain `Error`, leaving the bad branch in place. The result: a CL whose fork-choice picks an invalid EL fork stays wedged — every slot re-fires the same FCU, EL keeps responding INVALID, and the tree never abandons the bad branch. Observed on bal-devnet-6 with `lodestar-nethermind-1` (Lodestar WG topic 9934): block 5597 has two siblings off parent 0x05a771b (5596) — canonical 0x12efd8b (geth, valid) vs orphan 0x19dc0a (ethrex, invalid HeaderGasUsedMismatch). Snooper confirmed nethermind responds INVALID with latestValidHash=0x05a771b; lodestar's fork choice never abandoned the orphan. The pre-gloas newPayload INVALID path already invalidates via `segmentExecStatus.invalidSegmentLVH` in `chain/blocks/index.ts`. Gloas bypasses that path (`verifyBlockExecutionPayload` returns Syncing for gloas blocks; the actual newPayload happens in the new gloas-only `importExecutionPayload.ts`), so the FCU INVALID handler is the only line of defense. This change makes that handler do its job. - `engine/http.ts`: `notifyForkchoiceUpdate` now throws a typed `ForkchoiceUpdateError` carrying `headBlockHash`, `latestValidHash`, and `validationError` instead of dropping the LVH. - `chain/blocks/utils/forkchoiceUpdateInvalid.ts`: new helper that calls `forkChoice.validateLatestHash` with the invalid head + LVH, swallowing any internal failure so the import path keeps moving. - Both post-import FCU `.catch` handlers (`importBlock.ts`, `importExecutionPayload.ts`) detect the typed error and route to the helper. After invalidation, fork-choice score recompute drops the bad head; the next block's FCU naturally targets a different head. - Block production's FCU caller (`produceBlockBody.ts`) is unchanged in behavior — it still propagates the (now typed) error up. Tests: new unit tests for `engine/http` throwing the typed error on INVALID (with both null and non-null LVH) and for the helper plumbing the right `LVHInvalidResponse`. Lint, type-check, and the existing chain/blocks / executionEngine / fork-choice unit suites all pass. Follow-up: gloas-specific newPayload INVALID handling in `importExecutionPayload.ts` should also call `validateLatestHash` to match the pre-gloas safety net. That requires extending fork-choice's `PayloadExecutionStatus` to include Invalid (so a FULL variant can be added as Invalid before propagation) and is left as a separate change. 🤖 Generated with AI assistance --- .../src/chain/blocks/importBlock.ts | 16 ++-- .../chain/blocks/importExecutionPayload.ts | 7 +- .../blocks/utils/forkchoiceUpdateInvalid.ts | 49 ++++++++++++ .../beacon-node/src/execution/engine/http.ts | 17 +++- .../src/execution/engine/interface.ts | 24 ++++++ .../utils/forkchoiceUpdateInvalid.test.ts | 77 +++++++++++++++++++ .../test/unit/executionEngine/http.test.ts | 61 +++++++++++++++ 7 files changed, 241 insertions(+), 10 deletions(-) create mode 100644 packages/beacon-node/src/chain/blocks/utils/forkchoiceUpdateInvalid.ts create mode 100644 packages/beacon-node/test/unit/chain/blocks/utils/forkchoiceUpdateInvalid.test.ts diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index c8819268d3bc..9bfff5b3d3ea 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -23,6 +23,7 @@ import { import {Attestation, BeaconBlock, altair, capella, electra, isGloasBeaconBlock, phase0, ssz} from "@lodestar/types"; import {isErrorAborted, toRootHex} from "@lodestar/utils"; import {ZERO_HASH_HEX} from "../../constants/index.js"; +import {isForkchoiceUpdateInvalidError} from "../../execution/engine/interface.js"; import {callInNextEventLoop} from "../../util/eventLoop.js"; import {isOptimisticBlock} from "../../util/forkChoice.js"; import {isQueueErrorAborted} from "../../util/queue/index.js"; @@ -34,6 +35,7 @@ import {toCheckpointHex} from "../stateCache/persistentCheckpointsCache.js"; import {isBlockInputBlobs, isBlockInputColumns} from "./blockInput/blockInput.js"; import {AttestationImportOpt, FullyVerifiedBlock, ImportBlockOpts} from "./types.js"; import {getCheckpointFromState} from "./utils/checkpoint.js"; +import {invalidateForkchoiceHeadFromFcuInvalid} from "./utils/forkchoiceUpdateInvalid.js"; /** * Fork-choice allows to import attestations from current (0) or past (1) epoch. @@ -430,7 +432,8 @@ export async function importBlock( * - `headBlockHash !== null` -> Pre BELLATRIX_EPOCH * - `headBlockHash !== ZERO_HASH` -> Pre TTD */ - const headBlockHash = this.forkChoice.getHead().executionPayloadBlockHash ?? ZERO_HASH_HEX; + const fcuHead = this.forkChoice.getHead(); + const headBlockHash = fcuHead.executionPayloadBlockHash ?? ZERO_HASH_HEX; /** * After BELLATRIX_EPOCH and TTD it's okay to send a zero hash block hash for the finalized block. This will happen if * the current finalized block does not contain any execution payload at all (pre MERGE_EPOCH) or if it contains a @@ -440,13 +443,12 @@ export async function importBlock( const finalizedBlockHash = this.forkChoice.getFinalizedBlock().executionPayloadBlockHash ?? ZERO_HASH_HEX; if (headBlockHash !== ZERO_HASH_HEX) { this.executionEngine - .notifyForkchoiceUpdate( - this.config.getForkName(this.forkChoice.getHead().slot), - headBlockHash, - safeBlockHash, - finalizedBlockHash - ) + .notifyForkchoiceUpdate(this.config.getForkName(fcuHead.slot), headBlockHash, safeBlockHash, finalizedBlockHash) .catch((e) => { + if (isForkchoiceUpdateInvalidError(e)) { + invalidateForkchoiceHeadFromFcuInvalid(this, fcuHead.blockRoot, headBlockHash, e); + return; + } if (!isErrorAborted(e) && !isQueueErrorAborted(e)) { this.logger.error("Error pushing notifyForkchoiceUpdate()", {headBlockHash, finalizedBlockHash}, e); } diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index e8b5ccf5b8e2..524cf055dad0 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -3,12 +3,13 @@ import {ExecutionStatus, PayloadExecutionStatus, getSafeExecutionBlockHash} from 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 {ExecutionPayloadStatus, isForkchoiceUpdateInvalidError} from "../../execution/index.js"; import {isQueueErrorAborted} from "../../util/queue/index.js"; import {BeaconChain} from "../chain.js"; import {RegenCaller} from "../regen/interface.js"; import {PayloadEnvelopeInput} from "../seenCache/seenPayloadEnvelopeInput.js"; import {ImportPayloadOpts} from "./types.js"; +import {invalidateForkchoiceHeadFromFcuInvalid} from "./utils/forkchoiceUpdateInvalid.js"; import { verifyExecutionPayloadEnvelope, verifyExecutionPayloadEnvelopeSignature, @@ -235,6 +236,10 @@ export async function importExecutionPayload( const safeBlockHash = getSafeExecutionBlockHash(this.forkChoice); const finalizedBlockHash = this.forkChoice.getFinalizedBlock().executionPayloadBlockHash ?? ZERO_HASH_HEX; this.executionEngine.notifyForkchoiceUpdate(fork, blockHashHex, safeBlockHash, finalizedBlockHash).catch((e) => { + if (isForkchoiceUpdateInvalidError(e)) { + invalidateForkchoiceHeadFromFcuInvalid(this, head.blockRoot, blockHashHex, e); + return; + } if (!isErrorAborted(e) && !isQueueErrorAborted(e)) { this.logger.error("Error pushing notifyForkchoiceUpdate()", {blockHashHex, finalizedBlockHash}, e); } diff --git a/packages/beacon-node/src/chain/blocks/utils/forkchoiceUpdateInvalid.ts b/packages/beacon-node/src/chain/blocks/utils/forkchoiceUpdateInvalid.ts new file mode 100644 index 000000000000..203bc7368bd3 --- /dev/null +++ b/packages/beacon-node/src/chain/blocks/utils/forkchoiceUpdateInvalid.ts @@ -0,0 +1,49 @@ +import {ExecutionStatus} from "@lodestar/fork-choice"; +import {RootHex} from "@lodestar/types"; +import type {ForkchoiceUpdateError} from "../../../execution/engine/interface.js"; +import type {BeaconChain} from "../../chain.js"; + +/** + * Engine API spec: when EL responds INVALID to `engine_forkchoiceUpdated`, the CL must + * mark the offending head and its ancestors back to (but not including) `latestValidHash` + * as INVALID in fork choice and recompute the head. Without this, the CL's tree never + * abandons the bad branch — every slot it re-fires the same FCU, EL keeps responding + * INVALID, and the node stays wedged at the same head while the network advances. + * + * `validateLatestHash` walks up from `invalidateFromParentBlockRoot` (the invalid block + * itself, despite the name) marking ancestors invalid until reaching the LVH, then + * marks all descendants invalid in a second pass and recomputes scores. + */ +export function invalidateForkchoiceHeadFromFcuInvalid( + chain: BeaconChain, + headBlockRoot: RootHex, + headBlockHash: RootHex, + e: ForkchoiceUpdateError +): void { + const latestValidHash = e.type.latestValidHash; + chain.logger.warn( + "Invalidating head after FCU INVALID response", + { + headBlockRoot, + headBlockHash, + latestValidHash: latestValidHash ?? "null", + validationError: e.type.validationError ?? "", + }, + e + ); + + try { + chain.forkChoice.validateLatestHash({ + executionStatus: ExecutionStatus.Invalid, + latestValidExecHash: latestValidHash, + invalidateFromParentBlockRoot: headBlockRoot, + invalidateFromParentBlockHash: headBlockHash, + }); + } catch (err) { + chain.logger.error( + "Failed to invalidate head after FCU INVALID response", + {headBlockRoot, headBlockHash, latestValidHash: latestValidHash ?? "null"}, + err as Error + ); + } +} diff --git a/packages/beacon-node/src/execution/engine/http.ts b/packages/beacon-node/src/execution/engine/http.ts index 8a82b8f42d22..a447fdea6dd9 100644 --- a/packages/beacon-node/src/execution/engine/http.ts +++ b/packages/beacon-node/src/execution/engine/http.ts @@ -14,6 +14,8 @@ import { ExecutePayloadResponse, ExecutionEngineState, ExecutionPayloadStatus, + ForkchoiceUpdateError, + ForkchoiceUpdateErrorCode, IExecutionEngine, PayloadAttributes, PayloadId, @@ -370,7 +372,7 @@ export class ExecutionEngineHttp implements IExecutionEngine { }) as Promise; const { - payloadStatus: {status, latestValidHash: _latestValidHash, validationError}, + payloadStatus: {status, latestValidHash, validationError}, payloadId, } = await request; @@ -398,7 +400,18 @@ export class ExecutionEngineHttp implements IExecutionEngine { return null; case ExecutionPayloadStatus.INVALID: - throw Error( + // Surface latestValidHash so the caller can invalidate the offending head and its + // ancestors back to (but not including) the LVH in the fork-choice tree, per Engine + // API spec. Without this, a CL whose fork-choice picks an invalid EL fork stays + // wedged: every slot re-fires the same FCU, EL keeps responding INVALID, and the + // tree never abandons the bad branch. + throw new ForkchoiceUpdateError( + { + code: ForkchoiceUpdateErrorCode.INVALID, + headBlockHash, + latestValidHash: latestValidHash ?? null, + validationError: validationError ?? null, + }, `Invalid ${payloadAttributes ? "prepare payload" : "forkchoice request"}, validationError=${ validationError ?? "" }` diff --git a/packages/beacon-node/src/execution/engine/interface.ts b/packages/beacon-node/src/execution/engine/interface.ts index c8b7cdba6816..69309945dc39 100644 --- a/packages/beacon-node/src/execution/engine/interface.ts +++ b/packages/beacon-node/src/execution/engine/interface.ts @@ -10,6 +10,7 @@ import { import {BlobsBundle, ExecutionPayload, ExecutionRequests, Root, RootHex, Wei, capella} from "@lodestar/types"; import {BlobAndProof} from "@lodestar/types/deneb"; import {BlobAndProofV2} from "@lodestar/types/fulu"; +import {LodestarError} from "@lodestar/utils"; import {PayloadId, PayloadIdCache, WithdrawalV1} from "./payloadIdCache.js"; import {ExecutionPayloadBody} from "./types.js"; import {DATA} from "./utils.js"; @@ -79,6 +80,29 @@ export type ForkChoiceUpdateStatus = | ExecutionPayloadStatus.INVALID | ExecutionPayloadStatus.SYNCING; +/** + * Thrown by `notifyForkchoiceUpdate` when the EL responds with INVALID. Carries the + * `latestValidHash` and the `headBlockHash` we asked it to set, so the caller can + * invalidate the offending head + ancestors back to (but not including) the LVH in + * the fork-choice tree per the Engine API spec. + */ +export enum ForkchoiceUpdateErrorCode { + INVALID = "FORKCHOICE_UPDATE_ERROR_INVALID", +} + +export type ForkchoiceUpdateErrorType = { + code: ForkchoiceUpdateErrorCode.INVALID; + headBlockHash: RootHex; + latestValidHash: RootHex | null; + validationError: string | null; +}; + +export class ForkchoiceUpdateError extends LodestarError {} + +export function isForkchoiceUpdateInvalidError(e: unknown): e is ForkchoiceUpdateError { + return e instanceof ForkchoiceUpdateError && e.type.code === ForkchoiceUpdateErrorCode.INVALID; +} + export type PayloadAttributes = { timestamp: number; prevRandao: Uint8Array; diff --git a/packages/beacon-node/test/unit/chain/blocks/utils/forkchoiceUpdateInvalid.test.ts b/packages/beacon-node/test/unit/chain/blocks/utils/forkchoiceUpdateInvalid.test.ts new file mode 100644 index 000000000000..6f283ed12062 --- /dev/null +++ b/packages/beacon-node/test/unit/chain/blocks/utils/forkchoiceUpdateInvalid.test.ts @@ -0,0 +1,77 @@ +import {describe, expect, it, vi} from "vitest"; +import {ExecutionStatus} from "@lodestar/fork-choice"; +import {invalidateForkchoiceHeadFromFcuInvalid} from "../../../../../src/chain/blocks/utils/forkchoiceUpdateInvalid.js"; +import type {BeaconChain} from "../../../../../src/chain/chain.js"; +import {ForkchoiceUpdateError, ForkchoiceUpdateErrorCode} from "../../../../../src/execution/engine/interface.js"; + +describe("chain / blocks / utils / invalidateForkchoiceHeadFromFcuInvalid", () => { + const headBlockRoot = "0xbeac0a000000000000000000000000000000000000000000000000000000000a"; + const headBlockHash = "0x19dc0a000000000000000000000000000000000000000000000000000000000a"; + const lvh = "0x05a771b000000000000000000000000000000000000000000000000000000a05"; + + function makeChain(): {chain: BeaconChain; validateLatestHash: ReturnType} { + const validateLatestHash = vi.fn(); + const logger = {warn: vi.fn(), error: vi.fn()}; + const chain = { + forkChoice: {validateLatestHash}, + logger, + } as unknown as BeaconChain; + return {chain, validateLatestHash}; + } + + it("calls forkChoice.validateLatestHash with the head as the invalid block and EL LVH", () => { + const {chain, validateLatestHash} = makeChain(); + const e = new ForkchoiceUpdateError({ + code: ForkchoiceUpdateErrorCode.INVALID, + headBlockHash, + latestValidHash: lvh, + validationError: "HeaderGasUsedMismatch", + }); + + invalidateForkchoiceHeadFromFcuInvalid(chain, headBlockRoot, headBlockHash, e); + + expect(validateLatestHash).toHaveBeenCalledTimes(1); + expect(validateLatestHash).toHaveBeenCalledWith({ + executionStatus: ExecutionStatus.Invalid, + latestValidExecHash: lvh, + invalidateFromParentBlockRoot: headBlockRoot, + invalidateFromParentBlockHash: headBlockHash, + }); + }); + + it("forwards null LVH unchanged so protoArray can apply its 'unknown LVH' policy", () => { + const {chain, validateLatestHash} = makeChain(); + const e = new ForkchoiceUpdateError({ + code: ForkchoiceUpdateErrorCode.INVALID, + headBlockHash, + latestValidHash: null, + validationError: null, + }); + + invalidateForkchoiceHeadFromFcuInvalid(chain, headBlockRoot, headBlockHash, e); + + expect(validateLatestHash).toHaveBeenCalledWith( + expect.objectContaining({ + executionStatus: ExecutionStatus.Invalid, + latestValidExecHash: null, + invalidateFromParentBlockRoot: headBlockRoot, + invalidateFromParentBlockHash: headBlockHash, + }) + ); + }); + + it("swallows validateLatestHash failures so the FCU catch handler does not crash the import path", () => { + const {chain, validateLatestHash} = makeChain(); + validateLatestHash.mockImplementationOnce(() => { + throw new Error("invalidateFromParentBlockRoot not in forkchoice"); + }); + const e = new ForkchoiceUpdateError({ + code: ForkchoiceUpdateErrorCode.INVALID, + headBlockHash, + latestValidHash: lvh, + validationError: null, + }); + + expect(() => invalidateForkchoiceHeadFromFcuInvalid(chain, headBlockRoot, headBlockHash, e)).not.toThrow(); + }); +}); diff --git a/packages/beacon-node/test/unit/executionEngine/http.test.ts b/packages/beacon-node/test/unit/executionEngine/http.test.ts index 86e4f39254a4..0f08629cc64c 100644 --- a/packages/beacon-node/test/unit/executionEngine/http.test.ts +++ b/packages/beacon-node/test/unit/executionEngine/http.test.ts @@ -3,6 +3,11 @@ import {afterAll, beforeAll, describe, expect, it} from "vitest"; import {Logger} from "@lodestar/logger"; import {ForkName} from "@lodestar/params"; import {defaultExecutionEngineHttpOpts} from "../../../src/execution/engine/http.js"; +import { + ForkchoiceUpdateError, + ForkchoiceUpdateErrorCode, + isForkchoiceUpdateInvalidError, +} from "../../../src/execution/engine/interface.js"; import { parseExecutionPayload, serializeExecutionPayload, @@ -166,6 +171,62 @@ describe("ExecutionEngine / http", () => { expect(reqJsonRpcPayload).toEqual(request); }); + it("notifyForkchoiceUpdate INVALID throws ForkchoiceUpdateError carrying latestValidHash", async () => { + const headBlockHash = "0x19dc0a000000000000000000000000000000000000000000000000000000000a"; + const parentBlockHash = "0x05a771b000000000000000000000000000000000000000000000000000000a05"; + const validationError = "HeaderGasUsedMismatch"; + + returnValue = { + jsonrpc: "2.0", + id: 67, + result: { + payloadStatus: {status: "INVALID", latestValidHash: parentBlockHash, validationError}, + payloadId: null, + }, + }; + + let caught: unknown; + try { + await executionEngine.notifyForkchoiceUpdate(ForkName.bellatrix, headBlockHash, headBlockHash, headBlockHash); + } catch (e) { + caught = e; + } + + expect(caught).toBeInstanceOf(ForkchoiceUpdateError); + expect(isForkchoiceUpdateInvalidError(caught)).toBe(true); + if (!(caught instanceof ForkchoiceUpdateError)) throw caught; + expect(caught.type.code).toBe(ForkchoiceUpdateErrorCode.INVALID); + expect(caught.type.headBlockHash).toBe(headBlockHash); + expect(caught.type.latestValidHash).toBe(parentBlockHash); + expect(caught.type.validationError).toBe(validationError); + }); + + it("notifyForkchoiceUpdate INVALID with null latestValidHash surfaces null in error", async () => { + const headBlockHash = "0x19dc0a000000000000000000000000000000000000000000000000000000000b"; + + returnValue = { + jsonrpc: "2.0", + id: 67, + result: { + payloadStatus: {status: "INVALID", latestValidHash: null, validationError: null}, + payloadId: null, + }, + }; + + let caught: unknown; + try { + await executionEngine.notifyForkchoiceUpdate(ForkName.bellatrix, headBlockHash, headBlockHash, headBlockHash); + } catch (e) { + caught = e; + } + + expect(isForkchoiceUpdateInvalidError(caught)).toBe(true); + if (!(caught instanceof ForkchoiceUpdateError)) throw caught; + expect(caught.type.headBlockHash).toBe(headBlockHash); + expect(caught.type.latestValidHash).toBeNull(); + expect(caught.type.validationError).toBeNull(); + }); + it("getPayloadBodiesByHash", async () => { /** * curl -X GET -H "Content-Type: application/json" --data '{"jsonrpc":"2.0","method":"engine_getPayloadBodiesByHashV1","params":[ From ef2a182dd3dac94b91839c24ba6dda68fc679055 Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Wed, 6 May 2026 17:40:08 +0000 Subject: [PATCH 2/2] fix: recompute fork-choice head after FCU INVALID invalidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Gemini review comment on PR #9332. After `validateLatestHash` marks the bad head + ancestors invalid, `protoArray.applyScoreChanges` updates internal scores but does not refresh `ForkChoice.head`. Without an explicit recompute, the cached head stays on the now-invalid block until the next `prepareNextSlot` (~slot+8s) or block import (~slot+12s), during which a validator could attest or propose against the stale head. Add `ForkchoiceCaller.forkchoiceUpdateInvalid` and call `chain.recomputeForkChoiceHead(...)` from the helper after invalidation succeeds. Log when the head actually switches; gracefully swallow recompute failures so the FCU catch handler does not crash the import path. 🤖 Generated with AI assistance --- .../blocks/utils/forkchoiceUpdateInvalid.ts | 24 +++++- .../beacon-node/src/chain/forkChoice/index.ts | 1 + .../utils/forkchoiceUpdateInvalid.test.ts | 74 +++++++++++++++++-- 3 files changed, 93 insertions(+), 6 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/utils/forkchoiceUpdateInvalid.ts b/packages/beacon-node/src/chain/blocks/utils/forkchoiceUpdateInvalid.ts index 203bc7368bd3..9815e4b735a1 100644 --- a/packages/beacon-node/src/chain/blocks/utils/forkchoiceUpdateInvalid.ts +++ b/packages/beacon-node/src/chain/blocks/utils/forkchoiceUpdateInvalid.ts @@ -2,6 +2,7 @@ import {ExecutionStatus} from "@lodestar/fork-choice"; import {RootHex} from "@lodestar/types"; import type {ForkchoiceUpdateError} from "../../../execution/engine/interface.js"; import type {BeaconChain} from "../../chain.js"; +import {ForkchoiceCaller} from "../../forkChoice/index.js"; /** * Engine API spec: when EL responds INVALID to `engine_forkchoiceUpdated`, the CL must @@ -12,7 +13,10 @@ import type {BeaconChain} from "../../chain.js"; * * `validateLatestHash` walks up from `invalidateFromParentBlockRoot` (the invalid block * itself, despite the name) marking ancestors invalid until reaching the LVH, then - * marks all descendants invalid in a second pass and recomputes scores. + * marks all descendants invalid in a second pass and recomputes scores. After that we + * trigger an explicit head recompute so subsequent attestations, proposals, and APIs + * see the corrected head immediately rather than waiting for the next `prepareNextSlot` + * or block import. */ export function invalidateForkchoiceHeadFromFcuInvalid( chain: BeaconChain, @@ -45,5 +49,23 @@ export function invalidateForkchoiceHeadFromFcuInvalid( {headBlockRoot, headBlockHash, latestValidHash: latestValidHash ?? "null"}, err as Error ); + return; + } + + try { + const newHead = chain.recomputeForkChoiceHead(ForkchoiceCaller.forkchoiceUpdateInvalid); + if (newHead.blockRoot !== headBlockRoot) { + chain.logger.info("Switched head after FCU INVALID invalidation", { + oldHeadBlockRoot: headBlockRoot, + newHeadBlockRoot: newHead.blockRoot, + newHeadSlot: newHead.slot, + }); + } + } catch (err) { + chain.logger.error( + "Failed to recompute head after FCU INVALID invalidation", + {headBlockRoot, headBlockHash}, + err as Error + ); } } diff --git a/packages/beacon-node/src/chain/forkChoice/index.ts b/packages/beacon-node/src/chain/forkChoice/index.ts index 312d7c270054..92fe2af2b0a8 100644 --- a/packages/beacon-node/src/chain/forkChoice/index.ts +++ b/packages/beacon-node/src/chain/forkChoice/index.ts @@ -32,6 +32,7 @@ export type ForkChoiceOpts = RawForkChoiceOpts & { export enum ForkchoiceCaller { prepareNextSlot = "prepare_next_slot", importBlock = "import_block", + forkchoiceUpdateInvalid = "forkchoice_update_invalid", } /** diff --git a/packages/beacon-node/test/unit/chain/blocks/utils/forkchoiceUpdateInvalid.test.ts b/packages/beacon-node/test/unit/chain/blocks/utils/forkchoiceUpdateInvalid.test.ts index 6f283ed12062..a75025f1663d 100644 --- a/packages/beacon-node/test/unit/chain/blocks/utils/forkchoiceUpdateInvalid.test.ts +++ b/packages/beacon-node/test/unit/chain/blocks/utils/forkchoiceUpdateInvalid.test.ts @@ -2,21 +2,30 @@ import {describe, expect, it, vi} from "vitest"; import {ExecutionStatus} from "@lodestar/fork-choice"; import {invalidateForkchoiceHeadFromFcuInvalid} from "../../../../../src/chain/blocks/utils/forkchoiceUpdateInvalid.js"; import type {BeaconChain} from "../../../../../src/chain/chain.js"; +import {ForkchoiceCaller} from "../../../../../src/chain/forkChoice/index.js"; import {ForkchoiceUpdateError, ForkchoiceUpdateErrorCode} from "../../../../../src/execution/engine/interface.js"; describe("chain / blocks / utils / invalidateForkchoiceHeadFromFcuInvalid", () => { const headBlockRoot = "0xbeac0a000000000000000000000000000000000000000000000000000000000a"; const headBlockHash = "0x19dc0a000000000000000000000000000000000000000000000000000000000a"; const lvh = "0x05a771b000000000000000000000000000000000000000000000000000000a05"; + const newHeadBlockRoot = "0xbeac12e000000000000000000000000000000000000000000000000000000a12"; - function makeChain(): {chain: BeaconChain; validateLatestHash: ReturnType} { + function makeChain(): { + chain: BeaconChain; + validateLatestHash: ReturnType; + recomputeForkChoiceHead: ReturnType; + logger: {warn: ReturnType; error: ReturnType; info: ReturnType}; + } { const validateLatestHash = vi.fn(); - const logger = {warn: vi.fn(), error: vi.fn()}; + const recomputeForkChoiceHead = vi.fn().mockReturnValue({blockRoot: newHeadBlockRoot, slot: 5598}); + const logger = {warn: vi.fn(), error: vi.fn(), info: vi.fn()}; const chain = { forkChoice: {validateLatestHash}, logger, + recomputeForkChoiceHead, } as unknown as BeaconChain; - return {chain, validateLatestHash}; + return {chain, validateLatestHash, recomputeForkChoiceHead, logger}; } it("calls forkChoice.validateLatestHash with the head as the invalid block and EL LVH", () => { @@ -39,6 +48,45 @@ describe("chain / blocks / utils / invalidateForkchoiceHeadFromFcuInvalid", () = }); }); + it("recomputes head after invalidation so attestations and proposals see the corrected head", () => { + const {chain, recomputeForkChoiceHead, logger} = makeChain(); + const e = new ForkchoiceUpdateError({ + code: ForkchoiceUpdateErrorCode.INVALID, + headBlockHash, + latestValidHash: lvh, + validationError: null, + }); + + invalidateForkchoiceHeadFromFcuInvalid(chain, headBlockRoot, headBlockHash, e); + + expect(recomputeForkChoiceHead).toHaveBeenCalledTimes(1); + expect(recomputeForkChoiceHead).toHaveBeenCalledWith(ForkchoiceCaller.forkchoiceUpdateInvalid); + // head changed → info log noting the switch + expect(logger.info).toHaveBeenCalledWith( + "Switched head after FCU INVALID invalidation", + expect.objectContaining({ + oldHeadBlockRoot: headBlockRoot, + newHeadBlockRoot, + }) + ); + }); + + it("does not log a switch if head recompute returns the same root (no descendant alternative)", () => { + const {chain, recomputeForkChoiceHead, logger} = makeChain(); + recomputeForkChoiceHead.mockReturnValueOnce({blockRoot: headBlockRoot, slot: 5597}); + const e = new ForkchoiceUpdateError({ + code: ForkchoiceUpdateErrorCode.INVALID, + headBlockHash, + latestValidHash: lvh, + validationError: null, + }); + + invalidateForkchoiceHeadFromFcuInvalid(chain, headBlockRoot, headBlockHash, e); + + expect(recomputeForkChoiceHead).toHaveBeenCalledTimes(1); + expect(logger.info).not.toHaveBeenCalled(); + }); + it("forwards null LVH unchanged so protoArray can apply its 'unknown LVH' policy", () => { const {chain, validateLatestHash} = makeChain(); const e = new ForkchoiceUpdateError({ @@ -60,8 +108,8 @@ describe("chain / blocks / utils / invalidateForkchoiceHeadFromFcuInvalid", () = ); }); - it("swallows validateLatestHash failures so the FCU catch handler does not crash the import path", () => { - const {chain, validateLatestHash} = makeChain(); + it("swallows validateLatestHash failures and skips head recompute so the FCU catch handler does not crash the import path", () => { + const {chain, validateLatestHash, recomputeForkChoiceHead} = makeChain(); validateLatestHash.mockImplementationOnce(() => { throw new Error("invalidateFromParentBlockRoot not in forkchoice"); }); @@ -72,6 +120,22 @@ describe("chain / blocks / utils / invalidateForkchoiceHeadFromFcuInvalid", () = validationError: null, }); + expect(() => invalidateForkchoiceHeadFromFcuInvalid(chain, headBlockRoot, headBlockHash, e)).not.toThrow(); + expect(recomputeForkChoiceHead).not.toHaveBeenCalled(); + }); + + it("swallows recomputeForkChoiceHead failures so the FCU catch handler does not crash the import path", () => { + const {chain, recomputeForkChoiceHead} = makeChain(); + recomputeForkChoiceHead.mockImplementationOnce(() => { + throw new Error("findHead failed"); + }); + const e = new ForkchoiceUpdateError({ + code: ForkchoiceUpdateErrorCode.INVALID, + headBlockHash, + latestValidHash: lvh, + validationError: null, + }); + expect(() => invalidateForkchoiceHeadFromFcuInvalid(chain, headBlockRoot, headBlockHash, e)).not.toThrow(); }); });