From 9c187b56397f56fb359a3b2ff6b29f084b1b299b Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Tue, 21 Apr 2026 19:47:14 +0000 Subject: [PATCH 1/9] feat(notifier): show previous slot payload status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `prev-payload:` row to the node notifier that surfaces the execution payload status of the block the next proposer would build on top of — the parent of the current head. This is the payload that will be referenced by the next block's parent hash. - Pre-Gloas: parent's single variant is always FULL, shows block number + hash. - Gloas: parent may be FULL (payload revealed) or EMPTY (PTC voted not-present). 🤖 Generated with AI assistance --- packages/beacon-node/src/node/notifier.ts | 81 +++++++++++++++++++++-- 1 file changed, 77 insertions(+), 4 deletions(-) diff --git a/packages/beacon-node/src/node/notifier.ts b/packages/beacon-node/src/node/notifier.ts index 37a59a9c28d4..c93842b8c89a 100644 --- a/packages/beacon-node/src/node/notifier.ts +++ b/packages/beacon-node/src/node/notifier.ts @@ -1,5 +1,5 @@ import {BeaconConfig} from "@lodestar/config"; -import {ExecutionStatus, ProtoBlock} from "@lodestar/fork-choice"; +import {ExecutionStatus, IForkChoice, PayloadStatus, ProtoBlock} from "@lodestar/fork-choice"; import {EPOCHS_PER_SYNC_COMMITTEE_PERIOD, SLOTS_PER_EPOCH} from "@lodestar/params"; import { IBeaconStateView, @@ -7,7 +7,7 @@ import { computeStartSlotAtEpoch, isStatePostBellatrix, } from "@lodestar/state-transition"; -import {Epoch} from "@lodestar/types"; +import {Epoch, RootHex} from "@lodestar/types"; import {ErrorAborted, Logger, prettyBytes, prettyBytesShort, sleep} from "@lodestar/utils"; import {IBeaconChain} from "../chain/index.js"; import {ExecutionEngineState} from "../execution/index.js"; @@ -83,6 +83,7 @@ export async function runNodeNotifier(modules: NodeNotifierModules): Promise Date: Tue, 21 Apr 2026 22:56:46 +0000 Subject: [PATCH 2/9] test: cover envelope validation state provenance --- .../executionPayloadEnvelope.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 packages/beacon-node/test/unit/chain/validation/executionPayloadEnvelope.test.ts diff --git a/packages/beacon-node/test/unit/chain/validation/executionPayloadEnvelope.test.ts b/packages/beacon-node/test/unit/chain/validation/executionPayloadEnvelope.test.ts new file mode 100644 index 000000000000..d6d8a7ad2eaf --- /dev/null +++ b/packages/beacon-node/test/unit/chain/validation/executionPayloadEnvelope.test.ts @@ -0,0 +1,26 @@ +import {describe, expect, it, vi} from "vitest"; +import {validateExecutionPayloadEnvelope} from "../../../../src/chain/validation/executionPayloadEnvelope.js"; + +describe("validateExecutionPayloadEnvelope", () => { + it("threads blockInput state into envelope validation", async () => { + const state = {tag: "expected-state"}; + const processExecutionPayloadEnvelope = vi.fn().mockReturnValue({ok: true}); + + const chain: any = { + processExecutionPayloadEnvelope, + }; + + const blockInput: any = { + preData: {state}, + postData: {}, + block: {}, + }; + + const envelope: any = {}; + + await validateExecutionPayloadEnvelope(chain, blockInput, envelope); + + expect(processExecutionPayloadEnvelope).toHaveBeenCalled(); + expect(processExecutionPayloadEnvelope.mock.calls[0][0]).toBe(state); + }); +}); From 50238bb9681834c0f92ff8d07b72f89e3be86e49 Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Tue, 21 Apr 2026 23:13:03 +0000 Subject: [PATCH 3/9] refactor(notifier): drop prev-payload row, map PayloadSeparated to valid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nico's feedback on PR #19: once a gloas block is imported, its parent execution payload is guaranteed imported+validated, so there is no operator-facing reason to resolve the parent variant and print FULL/ EMPTY/PENDING. Fork-choice already stores the bid's parent_block_hash and the parent variant's block number on the head ProtoBlock at import time, so exec-block: ( ) renders the correct parent anchor for gloas PENDING/EMPTY heads with no lookup. The only remaining display issue was that executionStatus for gloas non-FULL heads is PayloadSeparated, which .toLowerCase() would render as "payloadseparated" - ugly and misleading. Map it to "valid" because the parent anchor's payload was validated before the block could be imported. Result: pre-gloas notifier format unchanged, and gloas output matches that format across PENDING/EMPTY/FULL states with the parent anchor or head's own payload in the (number hash) tuple as appropriate. Missed payloads are visible via EL-block-number stagnation across slots. Removes getPrevSlotPayloadInfo and getResolvedParentBlock. 🤖 Generated with AI assistance --- packages/beacon-node/src/node/notifier.ts | 89 +++-------------------- 1 file changed, 11 insertions(+), 78 deletions(-) diff --git a/packages/beacon-node/src/node/notifier.ts b/packages/beacon-node/src/node/notifier.ts index c93842b8c89a..be1f7b7fa136 100644 --- a/packages/beacon-node/src/node/notifier.ts +++ b/packages/beacon-node/src/node/notifier.ts @@ -1,5 +1,5 @@ import {BeaconConfig} from "@lodestar/config"; -import {ExecutionStatus, IForkChoice, PayloadStatus, ProtoBlock} from "@lodestar/fork-choice"; +import {ExecutionStatus, ProtoBlock} from "@lodestar/fork-choice"; import {EPOCHS_PER_SYNC_COMMITTEE_PERIOD, SLOTS_PER_EPOCH} from "@lodestar/params"; import { IBeaconStateView, @@ -7,7 +7,7 @@ import { computeStartSlotAtEpoch, isStatePostBellatrix, } from "@lodestar/state-transition"; -import {Epoch, RootHex} from "@lodestar/types"; +import {Epoch} from "@lodestar/types"; import {ErrorAborted, Logger, prettyBytes, prettyBytesShort, sleep} from "@lodestar/utils"; import {IBeaconChain} from "../chain/index.js"; import {ExecutionEngineState} from "../execution/index.js"; @@ -83,7 +83,6 @@ export async function runNodeNotifier(modules: NodeNotifierModules): Promise Date: Tue, 21 Apr 2026 23:22:01 +0000 Subject: [PATCH 4/9] refactor(notifier): label gloas exec-block with parent payload status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the PayloadSeparated->"valid" mapping with a lookup of the parent variant the head built on, using head.parentBlockHash (the bid's parent_block_hash). Render the exec-block status as "empty" when the parent's payloadStatus was EMPTY, "full" otherwise. This preserves missed-payload visibility in the notifier: two consecutive logs with the same exec-block hash and "empty" label signal a string of missed payloads, since a block building on an EMPTY parent inherits the grandparent's anchor as its bid.parent_block_hash. Pre-gloas behavior is unchanged (head.parentBlockHash is null pre-gloas, so the existing executionStatus.toLowerCase() path is preserved). 🤖 Generated with AI assistance --- packages/beacon-node/src/node/notifier.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/beacon-node/src/node/notifier.ts b/packages/beacon-node/src/node/notifier.ts index be1f7b7fa136..979ee4448948 100644 --- a/packages/beacon-node/src/node/notifier.ts +++ b/packages/beacon-node/src/node/notifier.ts @@ -1,5 +1,5 @@ import {BeaconConfig} from "@lodestar/config"; -import {ExecutionStatus, ProtoBlock} from "@lodestar/fork-choice"; +import {ExecutionStatus, IForkChoice, PayloadStatus, ProtoBlock} from "@lodestar/fork-choice"; import {EPOCHS_PER_SYNC_COMMITTEE_PERIOD, SLOTS_PER_EPOCH} from "@lodestar/params"; import { IBeaconStateView, @@ -82,7 +82,7 @@ export async function runNodeNotifier(modules: NodeNotifierModules): Promise 0 ? (skippedSlots > 1000 ? `${headInfo.slot} ` : `(slot -${skippedSlots}) `) : ""; const headRow = `head: ${headDiffInfo}${prettyBytes(headInfo.blockRoot)}`; - const executionInfo = getHeadExecutionInfo(config, clockEpoch, headState, headInfo); + const executionInfo = getHeadExecutionInfo(chain.forkChoice, config, clockEpoch, headState, headInfo); const finalizedCheckpointRow = `finalized: ${prettyBytes(finalizedRoot)}:${finalizedEpoch}`; let nodeState: string[]; @@ -158,6 +158,7 @@ function timeToNextHalfSlot(config: BeaconConfig, chain: IBeaconChain, isFirstTi } function getHeadExecutionInfo( + forkChoice: IForkChoice, config: BeaconConfig, clockEpoch: Epoch, headState: IBeaconStateView, @@ -167,13 +168,17 @@ function getHeadExecutionInfo( return []; } - // For Gloas PENDING/EMPTY heads, fork-choice stores the bid's parent_block_hash and the - // parent variant's block number on the head ProtoBlock, so the existing fields already - // describe the execution block the next block would build on. Render PayloadSeparated - // as "valid" because the parent anchor's payload must have been imported and validated - // before this block could be imported at all. - const executionStatusStr = - headInfo.executionStatus === ExecutionStatus.PayloadSeparated ? "valid" : headInfo.executionStatus.toLowerCase(); + // For Gloas heads, render the status as the parent's payload status: "full" when the parent + // payload was revealed, "empty" when it was missed. head.parentBlockHash is the bid's + // parent_block_hash, which identifies the exact parent variant this block built on. Two + // consecutive logs with the same hash and "empty" label signal a string of missed payloads. + let executionStatusStr: string; + if (headInfo.parentBlockHash !== null) { + const parentVariant = forkChoice.getBlockHexAndBlockHash(headInfo.parentRoot, headInfo.parentBlockHash); + executionStatusStr = parentVariant?.payloadStatus === PayloadStatus.EMPTY ? "empty" : "full"; + } else { + executionStatusStr = headInfo.executionStatus.toLowerCase(); + } // Add execution status to notifier only if head is on/post bellatrix if (isStatePostBellatrix(headState) && headState.isExecutionStateType) { From 33c5c6e890f7f896b3b2f8fc77f740c0a1caf959 Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Tue, 21 Apr 2026 23:33:51 +0000 Subject: [PATCH 5/9] refactor(notifier): preserve EL validation label, suffix /empty for gloas empty-parent heads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Combine the pre-Gloas valid/syncing label (EL validation signal) with the Gloas parent-variant signal. PayloadSeparated heads render as 'valid' since the displayed exec-block is the parent's already-imported anchor. When the head built on an EMPTY parent variant, suffix '/empty' so two consecutive logs with the same hash and '/empty' reveal a string of missed payloads. 🤖 Generated with AI assistance --- packages/beacon-node/src/node/notifier.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/beacon-node/src/node/notifier.ts b/packages/beacon-node/src/node/notifier.ts index 979ee4448948..adac0d57b0e5 100644 --- a/packages/beacon-node/src/node/notifier.ts +++ b/packages/beacon-node/src/node/notifier.ts @@ -168,16 +168,21 @@ function getHeadExecutionInfo( return []; } - // For Gloas heads, render the status as the parent's payload status: "full" when the parent - // payload was revealed, "empty" when it was missed. head.parentBlockHash is the bid's - // parent_block_hash, which identifies the exact parent variant this block built on. Two - // consecutive logs with the same hash and "empty" label signal a string of missed payloads. - let executionStatusStr: string; + // Base label preserves the pre-Gloas EL validation signal (valid/syncing). For Gloas + // non-FULL heads (PayloadSeparated), the exec-block shown is the parent's imported + // anchor, which is always validated by the time we log, so render as "valid". + let executionStatusStr = + headInfo.executionStatus === ExecutionStatus.PayloadSeparated ? "valid" : headInfo.executionStatus.toLowerCase(); + + // Gloas heads carry head.parentBlockHash (the bid's parent_block_hash). Look up the exact + // parent variant this head built on; if it was EMPTY, suffix the label. Two consecutive + // logs with the same hash and "/empty" signal a string of missed payloads, since a block + // extending an EMPTY parent inherits the grandparent's anchor as its bid.parent_block_hash. if (headInfo.parentBlockHash !== null) { const parentVariant = forkChoice.getBlockHexAndBlockHash(headInfo.parentRoot, headInfo.parentBlockHash); - executionStatusStr = parentVariant?.payloadStatus === PayloadStatus.EMPTY ? "empty" : "full"; - } else { - executionStatusStr = headInfo.executionStatus.toLowerCase(); + if (parentVariant?.payloadStatus === PayloadStatus.EMPTY) { + executionStatusStr = `${executionStatusStr}/empty`; + } } // Add execution status to notifier only if head is on/post bellatrix From 60334a47356d184ed91ad746ccf053eea9b9c3fa Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Tue, 21 Apr 2026 23:41:00 +0000 Subject: [PATCH 6/9] test: cover payload envelope state provenance --- .../blocks/importExecutionPayload.test.ts | 113 ++++++++++++++++++ .../executionPayloadEnvelope.test.ts | 68 ++++++++--- 2 files changed, 164 insertions(+), 17 deletions(-) create mode 100644 packages/beacon-node/test/unit/chain/blocks/importExecutionPayload.test.ts diff --git a/packages/beacon-node/test/unit/chain/blocks/importExecutionPayload.test.ts b/packages/beacon-node/test/unit/chain/blocks/importExecutionPayload.test.ts new file mode 100644 index 000000000000..b4fe03deabf7 --- /dev/null +++ b/packages/beacon-node/test/unit/chain/blocks/importExecutionPayload.test.ts @@ -0,0 +1,113 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import {ForkName} from "@lodestar/params"; +import * as stateTransition from "@lodestar/state-transition"; +import {SignedBeaconBlock, ssz} from "@lodestar/types"; +import {toRootHex} from "@lodestar/utils"; +import {importExecutionPayload} from "../../../../src/chain/blocks/importExecutionPayload.js"; +import {PayloadEnvelopeInput} from "../../../../src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.js"; +import {PayloadEnvelopeInputSource} from "../../../../src/chain/blocks/payloadEnvelopeInput/types.js"; +import {RegenCaller} from "../../../../src/chain/regen/interface.js"; +import {ExecutionPayloadStatus} from "../../../../src/execution/index.js"; +import {MockedBeaconChain, getMockedBeaconChain} from "../../../mocks/mockedBeaconChain.js"; + +function buildPayloadEnvelopeInput(): PayloadEnvelopeInput { + const block = ssz.gloas.SignedBeaconBlock.defaultValue(); + block.message.slot = 1; + block.message.proposerIndex = 23; + + const blockRoot = ssz.gloas.BeaconBlock.hashTreeRoot(block.message); + const blockRootHex = toRootHex(blockRoot); + + const payloadInput = PayloadEnvelopeInput.createFromBlock({ + blockRootHex, + block: block as SignedBeaconBlock, + forkName: ForkName.gloas, + sampledColumns: [], + custodyColumns: [], + timeCreatedSec: Date.now() / 1000, + }); + + const signedEnvelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue(); + signedEnvelope.message.beaconBlockRoot = blockRoot; + signedEnvelope.message.slot = block.message.slot; + signedEnvelope.message.builderIndex = payloadInput.getBuilderIndex(); + signedEnvelope.message.payload.blockHash = block.message.body.signedExecutionPayloadBid.message.blockHash; + signedEnvelope.message.stateRoot = Buffer.alloc(32, 0x33); + + payloadInput.addPayloadEnvelope({ + envelope: signedEnvelope, + source: PayloadEnvelopeInputSource.gossip, + seenTimestampSec: Date.now() / 1000, + }); + + return payloadInput; +} + +describe("importExecutionPayload", () => { + let chain: MockedBeaconChain; + + beforeEach(() => { + chain = getMockedBeaconChain(); + vi.spyOn(stateTransition, "isStatePostGloas").mockReturnValue(true); + vi.spyOn(stateTransition, "getExecutionPayloadEnvelopeSignatureSet").mockReturnValue({} as never); + + chain.executionEngine.notifyNewPayload = vi.fn().mockResolvedValue({ + status: ExecutionPayloadStatus.VALID, + }) as never; + chain.unfinalizedPayloadEnvelopeWrites = { + waitForSpace: vi.fn().mockResolvedValue(undefined), + push: vi.fn().mockResolvedValue(undefined), + } as never; + chain.forkChoice.onExecutionPayload = vi.fn() as never; + chain.regen.processState = vi.fn() as never; + chain.regen.addCheckpointState = vi.fn() as never; + chain.metrics = null as never; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("threads regen block-slot state into signature verification and payload processing", async () => { + const payloadInput = buildPayloadEnvelopeInput(); + const signedEnvelope = payloadInput.getPayloadEnvelope(); + const protoBlock = { + slot: signedEnvelope.message.slot, + parentRoot: "0x" + "44".repeat(32), + }; + const blockState = { + forkName: "gloas", + processExecutionPayloadEnvelope: vi.fn(), + }; + const postPayloadState = { + slot: 1, + hashTreeRoot: vi.fn().mockReturnValue(signedEnvelope.message.stateRoot), + computeAnchorCheckpoint: vi.fn(), + }; + blockState.processExecutionPayloadEnvelope.mockReturnValue(postPayloadState); + + chain.forkChoice.getBlockHexDefaultStatus.mockReturnValue(protoBlock as never); + chain.regen.getBlockSlotState.mockResolvedValue(blockState as never); + chain.bls.verifySignatureSets.mockResolvedValue(true); + + await importExecutionPayload.call(chain, payloadInput, new AbortController().signal); + + expect(chain.regen.getBlockSlotState).toHaveBeenCalledWith( + protoBlock, + protoBlock.slot, + {dontTransferCache: true}, + RegenCaller.processBlock + ); + expect(stateTransition.getExecutionPayloadEnvelopeSignatureSet).toHaveBeenCalledWith( + chain.config, + chain.pubkeyCache, + blockState, + signedEnvelope, + payloadInput.proposerIndex + ); + expect(blockState.processExecutionPayloadEnvelope).toHaveBeenCalledWith(signedEnvelope, { + verifySignature: false, + verifyStateRoot: false, + }); + }); +}); diff --git a/packages/beacon-node/test/unit/chain/validation/executionPayloadEnvelope.test.ts b/packages/beacon-node/test/unit/chain/validation/executionPayloadEnvelope.test.ts index d6d8a7ad2eaf..8efc3028c11d 100644 --- a/packages/beacon-node/test/unit/chain/validation/executionPayloadEnvelope.test.ts +++ b/packages/beacon-node/test/unit/chain/validation/executionPayloadEnvelope.test.ts @@ -1,26 +1,60 @@ -import {describe, expect, it, vi} from "vitest"; -import {validateExecutionPayloadEnvelope} from "../../../../src/chain/validation/executionPayloadEnvelope.js"; +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import * as stateTransition from "@lodestar/state-transition"; +import {ssz} from "@lodestar/types"; +import {toRootHex} from "@lodestar/utils"; +import {RegenCaller} from "../../../../src/chain/regen/interface.js"; +import {validateGossipExecutionPayloadEnvelope} from "../../../../src/chain/validation/executionPayloadEnvelope.js"; +import {MockedBeaconChain, getMockedBeaconChain} from "../../../mocks/mockedBeaconChain.js"; -describe("validateExecutionPayloadEnvelope", () => { - it("threads blockInput state into envelope validation", async () => { - const state = {tag: "expected-state"}; - const processExecutionPayloadEnvelope = vi.fn().mockReturnValue({ok: true}); +describe("validateGossipExecutionPayloadEnvelope", () => { + let chain: MockedBeaconChain; - const chain: any = { - processExecutionPayloadEnvelope, - }; + beforeEach(() => { + chain = getMockedBeaconChain(); + vi.spyOn(stateTransition, "isStatePostGloas").mockReturnValue(true); + vi.spyOn(stateTransition, "getExecutionPayloadEnvelopeSignatureSet").mockReturnValue({} as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("uses the block state loaded from the forkchoice block stateRoot for signature verification", async () => { + const signedEnvelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue(); + signedEnvelope.message.slot = 1; + signedEnvelope.message.builderIndex = 7; + signedEnvelope.message.payload.blockHash = Buffer.alloc(32, 0x22); - const blockInput: any = { - preData: {state}, - postData: {}, - block: {}, + const block = { + slot: signedEnvelope.message.slot, + stateRoot: "0x" + "11".repeat(32), + }; + const payloadInput = { + proposerIndex: 13, + hasPayloadEnvelope: () => false, + getBuilderIndex: () => signedEnvelope.message.builderIndex, + getBlockHashHex: () => toRootHex(signedEnvelope.message.payload.blockHash), }; + const blockState = {forkName: "gloas"} as never; - const envelope: any = {}; + chain.forkChoice.getBlockDefaultStatus.mockReturnValue(block as never); + chain.forkChoice.getBlockHex.mockReturnValue(null as never); + chain.forkChoice.getFinalizedCheckpoint.mockReturnValue({epoch: 0} as never); + chain.seenPayloadEnvelopeInputCache = { + get: vi.fn().mockReturnValue(payloadInput), + } as never; + chain.regen.getState.mockResolvedValue(blockState); + chain.bls.verifySignatureSets.mockResolvedValue(true); - await validateExecutionPayloadEnvelope(chain, blockInput, envelope); + await validateGossipExecutionPayloadEnvelope(chain, signedEnvelope); - expect(processExecutionPayloadEnvelope).toHaveBeenCalled(); - expect(processExecutionPayloadEnvelope.mock.calls[0][0]).toBe(state); + expect(chain.regen.getState).toHaveBeenCalledWith(block.stateRoot, RegenCaller.validateGossipPayloadEnvelope); + expect(stateTransition.getExecutionPayloadEnvelopeSignatureSet).toHaveBeenCalledWith( + chain.config, + chain.pubkeyCache, + blockState, + signedEnvelope, + payloadInput.proposerIndex + ); }); }); From af0e057a3d2f89eeac1b5435c796054b48db4efc Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Tue, 21 Apr 2026 23:44:50 +0000 Subject: [PATCH 7/9] test: cover payload envelope api handoff --- .../publishExecutionPayloadEnvelope.test.ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts diff --git a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts new file mode 100644 index 000000000000..04e882139192 --- /dev/null +++ b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts @@ -0,0 +1,79 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import {PayloadStatus} from "@lodestar/fork-choice"; +import {ForkName} from "@lodestar/params"; +import {SignedBeaconBlock, ssz} from "@lodestar/types"; +import {toRootHex} from "@lodestar/utils"; +import {getBeaconBlockApi} from "../../../../../../src/api/impl/beacon/blocks/index.js"; +import {PayloadEnvelopeInput} from "../../../../../../src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.js"; +import {PayloadEnvelopeInputSource} from "../../../../../../src/chain/blocks/payloadEnvelopeInput/types.js"; +import * as validationModule from "../../../../../../src/chain/validation/executionPayloadEnvelope.js"; +import {ApiTestModules, getApiTestModules} from "../../../../../utils/api.js"; + +function buildPayloadInputAndEnvelope(): { + payloadInput: PayloadEnvelopeInput; + signedEnvelope: ReturnType; + blockRootHex: string; +} { + const block = ssz.gloas.SignedBeaconBlock.defaultValue(); + block.message.slot = 1; + block.message.proposerIndex = 23; + + const blockRoot = ssz.gloas.BeaconBlock.hashTreeRoot(block.message); + const blockRootHex = toRootHex(blockRoot); + + const payloadInput = PayloadEnvelopeInput.createFromBlock({ + blockRootHex, + block: block as SignedBeaconBlock, + forkName: ForkName.gloas, + sampledColumns: [], + custodyColumns: [], + timeCreatedSec: Date.now() / 1000, + }); + + const signedEnvelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue(); + signedEnvelope.message.beaconBlockRoot = blockRoot; + signedEnvelope.message.slot = block.message.slot; + signedEnvelope.message.builderIndex = payloadInput.getBuilderIndex(); + signedEnvelope.message.payload.blockHash = block.message.body.signedExecutionPayloadBid.message.blockHash; + signedEnvelope.message.stateRoot = Buffer.alloc(32, 0x33); + + return {payloadInput, signedEnvelope, blockRootHex}; +} + +describe("api - beacon - publishExecutionPayloadEnvelope", () => { + let modules: ApiTestModules; + let api: ReturnType; + + beforeEach(() => { + modules = getApiTestModules(); + api = getBeaconBlockApi(modules); + vi.spyOn(modules.config, "getForkName").mockReturnValue(ForkName.gloas); + vi.spyOn(validationModule, "validateApiExecutionPayloadEnvelope").mockResolvedValue(); + + modules.network.publishSignedExecutionPayloadEnvelope = vi.fn().mockResolvedValue(2) as never; + modules.chain.processExecutionPayload = vi.fn().mockResolvedValue(undefined) as never; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("reuses the cached payload input and passes it to processExecutionPayload after API validation", async () => { + const {payloadInput, signedEnvelope, blockRootHex} = buildPayloadInputAndEnvelope(); + + modules.forkChoice.getBlockHex.mockReturnValue({slot: signedEnvelope.message.slot} as never); + modules.chain.seenPayloadEnvelopeInputCache = { + get: vi.fn().mockReturnValue(payloadInput), + } as never; + + await api.publishExecutionPayloadEnvelope({signedExecutionPayloadEnvelope: signedEnvelope}); + + expect(validationModule.validateApiExecutionPayloadEnvelope).toHaveBeenCalledWith(modules.chain, signedEnvelope); + expect(modules.forkChoice.getBlockHex).toHaveBeenCalledWith(blockRootHex, PayloadStatus.EMPTY); + expect(payloadInput.hasPayloadEnvelope()).toBe(true); + expect(payloadInput.getPayloadEnvelope()).toBe(signedEnvelope); + expect(payloadInput.getPayloadEnvelopeSource().source).toBe(PayloadEnvelopeInputSource.api); + expect(modules.chain.processExecutionPayload).toHaveBeenCalledWith(payloadInput, {validSignature: true}); + expect(modules.network.publishSignedExecutionPayloadEnvelope).toHaveBeenCalledWith(signedEnvelope); + }); +}); From 4e87492e383c22a2180221ac4f34464648a6ff8a Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Tue, 21 Apr 2026 23:47:01 +0000 Subject: [PATCH 8/9] refactor(notifier): surface empty-parent as trailing annotation, not label suffix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the EMPTY-parent signal out of the status label into a trailing 'empty-parent' annotation after the tuple. Keeps the historical valid(...)/syncing(...)/invalid(...) grammar intact, avoids conflating EL validation state with the orthogonal fork-choice parent variant, and preserves existing prefix-based tooling. Output: Pre-Gloas / Gloas FULL-parent: exec-block: valid(N hash) Gloas EMPTY-parent: exec-block: valid(N hash) empty-parent 🤖 Generated with AI assistance --- packages/beacon-node/src/node/notifier.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/beacon-node/src/node/notifier.ts b/packages/beacon-node/src/node/notifier.ts index adac0d57b0e5..9b86da53ce0e 100644 --- a/packages/beacon-node/src/node/notifier.ts +++ b/packages/beacon-node/src/node/notifier.ts @@ -168,20 +168,22 @@ function getHeadExecutionInfo( return []; } - // Base label preserves the pre-Gloas EL validation signal (valid/syncing). For Gloas - // non-FULL heads (PayloadSeparated), the exec-block shown is the parent's imported - // anchor, which is always validated by the time we log, so render as "valid". - let executionStatusStr = + // Label preserves the pre-Gloas EL validation signal (valid/syncing). For Gloas non-FULL + // heads (PayloadSeparated), the exec-block shown is the parent's imported anchor, which is + // always validated by the time we log, so render as "valid". + const executionStatusStr = headInfo.executionStatus === ExecutionStatus.PayloadSeparated ? "valid" : headInfo.executionStatus.toLowerCase(); // Gloas heads carry head.parentBlockHash (the bid's parent_block_hash). Look up the exact - // parent variant this head built on; if it was EMPTY, suffix the label. Two consecutive - // logs with the same hash and "/empty" signal a string of missed payloads, since a block - // extending an EMPTY parent inherits the grandparent's anchor as its bid.parent_block_hash. + // parent variant this head built on; if it was EMPTY, append "empty-parent" after the tuple. + // Two consecutive logs with the same hash and "empty-parent" signal a string of missed + // payloads, since a block extending an EMPTY parent inherits the grandparent's anchor as its + // bid.parent_block_hash. + let emptyParentSuffix = ""; if (headInfo.parentBlockHash !== null) { const parentVariant = forkChoice.getBlockHexAndBlockHash(headInfo.parentRoot, headInfo.parentBlockHash); if (parentVariant?.payloadStatus === PayloadStatus.EMPTY) { - executionStatusStr = `${executionStatusStr}/empty`; + emptyParentSuffix = " empty-parent"; } } @@ -195,10 +197,10 @@ function getHeadExecutionInfo( return [ `exec-block: ${executionStatusStr}(${executionPayloadNumberInfo} ${prettyBytesShort( executionPayloadHashInfo - )})`, + )})${emptyParentSuffix}`, ]; } - return [`exec-block: ${executionStatusStr}`]; + return [`exec-block: ${executionStatusStr}${emptyParentSuffix}`]; } return []; From ab55d4bf97a7f5ef6b1dfec999ae6ccba5443f60 Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Wed, 22 Apr 2026 00:04:49 +0000 Subject: [PATCH 9/9] fix(notifier): keep gloas exec-block pre-gloas-shaped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplify PR #19 back to the notifier behavior Nico asked for: - keep a single exec-block row shaped like pre-Gloas output - normalize PayloadSeparated -> valid instead of surfacing an internal status string - drop the empty-parent annotation and the unrelated envelope/import test drift - add a focused notifier unit test covering pre-Gloas, Gloas PayloadSeparated, and Gloas FULL output 🤖 Generated with AI assistance --- packages/beacon-node/src/node/notifier.ts | 30 ++-- .../publishExecutionPayloadEnvelope.test.ts | 79 ----------- .../blocks/importExecutionPayload.test.ts | 113 --------------- .../executionPayloadEnvelope.test.ts | 60 -------- .../test/unit/node/notifier.test.ts | 132 ++++++++++++++++++ 5 files changed, 141 insertions(+), 273 deletions(-) delete mode 100644 packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts delete mode 100644 packages/beacon-node/test/unit/chain/blocks/importExecutionPayload.test.ts delete mode 100644 packages/beacon-node/test/unit/chain/validation/executionPayloadEnvelope.test.ts create mode 100644 packages/beacon-node/test/unit/node/notifier.test.ts diff --git a/packages/beacon-node/src/node/notifier.ts b/packages/beacon-node/src/node/notifier.ts index 9b86da53ce0e..fe2049f0e889 100644 --- a/packages/beacon-node/src/node/notifier.ts +++ b/packages/beacon-node/src/node/notifier.ts @@ -1,5 +1,5 @@ import {BeaconConfig} from "@lodestar/config"; -import {ExecutionStatus, IForkChoice, PayloadStatus, ProtoBlock} from "@lodestar/fork-choice"; +import {ExecutionStatus, ProtoBlock} from "@lodestar/fork-choice"; import {EPOCHS_PER_SYNC_COMMITTEE_PERIOD, SLOTS_PER_EPOCH} from "@lodestar/params"; import { IBeaconStateView, @@ -82,7 +82,7 @@ export async function runNodeNotifier(modules: NodeNotifierModules): Promise 0 ? (skippedSlots > 1000 ? `${headInfo.slot} ` : `(slot -${skippedSlots}) `) : ""; const headRow = `head: ${headDiffInfo}${prettyBytes(headInfo.blockRoot)}`; - const executionInfo = getHeadExecutionInfo(chain.forkChoice, config, clockEpoch, headState, headInfo); + const executionInfo = getHeadExecutionInfo(config, clockEpoch, headState, headInfo); const finalizedCheckpointRow = `finalized: ${prettyBytes(finalizedRoot)}:${finalizedEpoch}`; let nodeState: string[]; @@ -158,7 +158,6 @@ function timeToNextHalfSlot(config: BeaconConfig, chain: IBeaconChain, isFirstTi } function getHeadExecutionInfo( - forkChoice: IForkChoice, config: BeaconConfig, clockEpoch: Epoch, headState: IBeaconStateView, @@ -168,25 +167,14 @@ function getHeadExecutionInfo( return []; } - // Label preserves the pre-Gloas EL validation signal (valid/syncing). For Gloas non-FULL - // heads (PayloadSeparated), the exec-block shown is the parent's imported anchor, which is - // always validated by the time we log, so render as "valid". + // Keep the Gloas exec-block row operator-facing and shaped like pre-Gloas output. + // For imported Gloas heads the row already points at the inherited execution anchor via + // headInfo.executionPayloadBlockHash/Number; the internal PayloadSeparated status is not a + // user-facing execution verdict, so normalize it to "valid" instead of surfacing fork-choice + // bookkeeping in the log line. const executionStatusStr = headInfo.executionStatus === ExecutionStatus.PayloadSeparated ? "valid" : headInfo.executionStatus.toLowerCase(); - // Gloas heads carry head.parentBlockHash (the bid's parent_block_hash). Look up the exact - // parent variant this head built on; if it was EMPTY, append "empty-parent" after the tuple. - // Two consecutive logs with the same hash and "empty-parent" signal a string of missed - // payloads, since a block extending an EMPTY parent inherits the grandparent's anchor as its - // bid.parent_block_hash. - let emptyParentSuffix = ""; - if (headInfo.parentBlockHash !== null) { - const parentVariant = forkChoice.getBlockHexAndBlockHash(headInfo.parentRoot, headInfo.parentBlockHash); - if (parentVariant?.payloadStatus === PayloadStatus.EMPTY) { - emptyParentSuffix = " empty-parent"; - } - } - // Add execution status to notifier only if head is on/post bellatrix if (isStatePostBellatrix(headState) && headState.isExecutionStateType) { if (headState.isMergeTransitionComplete) { @@ -197,10 +185,10 @@ function getHeadExecutionInfo( return [ `exec-block: ${executionStatusStr}(${executionPayloadNumberInfo} ${prettyBytesShort( executionPayloadHashInfo - )})${emptyParentSuffix}`, + )})`, ]; } - return [`exec-block: ${executionStatusStr}${emptyParentSuffix}`]; + return [`exec-block: ${executionStatusStr}`]; } return []; diff --git a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts deleted file mode 100644 index 04e882139192..000000000000 --- a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; -import {PayloadStatus} from "@lodestar/fork-choice"; -import {ForkName} from "@lodestar/params"; -import {SignedBeaconBlock, ssz} from "@lodestar/types"; -import {toRootHex} from "@lodestar/utils"; -import {getBeaconBlockApi} from "../../../../../../src/api/impl/beacon/blocks/index.js"; -import {PayloadEnvelopeInput} from "../../../../../../src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.js"; -import {PayloadEnvelopeInputSource} from "../../../../../../src/chain/blocks/payloadEnvelopeInput/types.js"; -import * as validationModule from "../../../../../../src/chain/validation/executionPayloadEnvelope.js"; -import {ApiTestModules, getApiTestModules} from "../../../../../utils/api.js"; - -function buildPayloadInputAndEnvelope(): { - payloadInput: PayloadEnvelopeInput; - signedEnvelope: ReturnType; - blockRootHex: string; -} { - const block = ssz.gloas.SignedBeaconBlock.defaultValue(); - block.message.slot = 1; - block.message.proposerIndex = 23; - - const blockRoot = ssz.gloas.BeaconBlock.hashTreeRoot(block.message); - const blockRootHex = toRootHex(blockRoot); - - const payloadInput = PayloadEnvelopeInput.createFromBlock({ - blockRootHex, - block: block as SignedBeaconBlock, - forkName: ForkName.gloas, - sampledColumns: [], - custodyColumns: [], - timeCreatedSec: Date.now() / 1000, - }); - - const signedEnvelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue(); - signedEnvelope.message.beaconBlockRoot = blockRoot; - signedEnvelope.message.slot = block.message.slot; - signedEnvelope.message.builderIndex = payloadInput.getBuilderIndex(); - signedEnvelope.message.payload.blockHash = block.message.body.signedExecutionPayloadBid.message.blockHash; - signedEnvelope.message.stateRoot = Buffer.alloc(32, 0x33); - - return {payloadInput, signedEnvelope, blockRootHex}; -} - -describe("api - beacon - publishExecutionPayloadEnvelope", () => { - let modules: ApiTestModules; - let api: ReturnType; - - beforeEach(() => { - modules = getApiTestModules(); - api = getBeaconBlockApi(modules); - vi.spyOn(modules.config, "getForkName").mockReturnValue(ForkName.gloas); - vi.spyOn(validationModule, "validateApiExecutionPayloadEnvelope").mockResolvedValue(); - - modules.network.publishSignedExecutionPayloadEnvelope = vi.fn().mockResolvedValue(2) as never; - modules.chain.processExecutionPayload = vi.fn().mockResolvedValue(undefined) as never; - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("reuses the cached payload input and passes it to processExecutionPayload after API validation", async () => { - const {payloadInput, signedEnvelope, blockRootHex} = buildPayloadInputAndEnvelope(); - - modules.forkChoice.getBlockHex.mockReturnValue({slot: signedEnvelope.message.slot} as never); - modules.chain.seenPayloadEnvelopeInputCache = { - get: vi.fn().mockReturnValue(payloadInput), - } as never; - - await api.publishExecutionPayloadEnvelope({signedExecutionPayloadEnvelope: signedEnvelope}); - - expect(validationModule.validateApiExecutionPayloadEnvelope).toHaveBeenCalledWith(modules.chain, signedEnvelope); - expect(modules.forkChoice.getBlockHex).toHaveBeenCalledWith(blockRootHex, PayloadStatus.EMPTY); - expect(payloadInput.hasPayloadEnvelope()).toBe(true); - expect(payloadInput.getPayloadEnvelope()).toBe(signedEnvelope); - expect(payloadInput.getPayloadEnvelopeSource().source).toBe(PayloadEnvelopeInputSource.api); - expect(modules.chain.processExecutionPayload).toHaveBeenCalledWith(payloadInput, {validSignature: true}); - expect(modules.network.publishSignedExecutionPayloadEnvelope).toHaveBeenCalledWith(signedEnvelope); - }); -}); diff --git a/packages/beacon-node/test/unit/chain/blocks/importExecutionPayload.test.ts b/packages/beacon-node/test/unit/chain/blocks/importExecutionPayload.test.ts deleted file mode 100644 index b4fe03deabf7..000000000000 --- a/packages/beacon-node/test/unit/chain/blocks/importExecutionPayload.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; -import {ForkName} from "@lodestar/params"; -import * as stateTransition from "@lodestar/state-transition"; -import {SignedBeaconBlock, ssz} from "@lodestar/types"; -import {toRootHex} from "@lodestar/utils"; -import {importExecutionPayload} from "../../../../src/chain/blocks/importExecutionPayload.js"; -import {PayloadEnvelopeInput} from "../../../../src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.js"; -import {PayloadEnvelopeInputSource} from "../../../../src/chain/blocks/payloadEnvelopeInput/types.js"; -import {RegenCaller} from "../../../../src/chain/regen/interface.js"; -import {ExecutionPayloadStatus} from "../../../../src/execution/index.js"; -import {MockedBeaconChain, getMockedBeaconChain} from "../../../mocks/mockedBeaconChain.js"; - -function buildPayloadEnvelopeInput(): PayloadEnvelopeInput { - const block = ssz.gloas.SignedBeaconBlock.defaultValue(); - block.message.slot = 1; - block.message.proposerIndex = 23; - - const blockRoot = ssz.gloas.BeaconBlock.hashTreeRoot(block.message); - const blockRootHex = toRootHex(blockRoot); - - const payloadInput = PayloadEnvelopeInput.createFromBlock({ - blockRootHex, - block: block as SignedBeaconBlock, - forkName: ForkName.gloas, - sampledColumns: [], - custodyColumns: [], - timeCreatedSec: Date.now() / 1000, - }); - - const signedEnvelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue(); - signedEnvelope.message.beaconBlockRoot = blockRoot; - signedEnvelope.message.slot = block.message.slot; - signedEnvelope.message.builderIndex = payloadInput.getBuilderIndex(); - signedEnvelope.message.payload.blockHash = block.message.body.signedExecutionPayloadBid.message.blockHash; - signedEnvelope.message.stateRoot = Buffer.alloc(32, 0x33); - - payloadInput.addPayloadEnvelope({ - envelope: signedEnvelope, - source: PayloadEnvelopeInputSource.gossip, - seenTimestampSec: Date.now() / 1000, - }); - - return payloadInput; -} - -describe("importExecutionPayload", () => { - let chain: MockedBeaconChain; - - beforeEach(() => { - chain = getMockedBeaconChain(); - vi.spyOn(stateTransition, "isStatePostGloas").mockReturnValue(true); - vi.spyOn(stateTransition, "getExecutionPayloadEnvelopeSignatureSet").mockReturnValue({} as never); - - chain.executionEngine.notifyNewPayload = vi.fn().mockResolvedValue({ - status: ExecutionPayloadStatus.VALID, - }) as never; - chain.unfinalizedPayloadEnvelopeWrites = { - waitForSpace: vi.fn().mockResolvedValue(undefined), - push: vi.fn().mockResolvedValue(undefined), - } as never; - chain.forkChoice.onExecutionPayload = vi.fn() as never; - chain.regen.processState = vi.fn() as never; - chain.regen.addCheckpointState = vi.fn() as never; - chain.metrics = null as never; - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("threads regen block-slot state into signature verification and payload processing", async () => { - const payloadInput = buildPayloadEnvelopeInput(); - const signedEnvelope = payloadInput.getPayloadEnvelope(); - const protoBlock = { - slot: signedEnvelope.message.slot, - parentRoot: "0x" + "44".repeat(32), - }; - const blockState = { - forkName: "gloas", - processExecutionPayloadEnvelope: vi.fn(), - }; - const postPayloadState = { - slot: 1, - hashTreeRoot: vi.fn().mockReturnValue(signedEnvelope.message.stateRoot), - computeAnchorCheckpoint: vi.fn(), - }; - blockState.processExecutionPayloadEnvelope.mockReturnValue(postPayloadState); - - chain.forkChoice.getBlockHexDefaultStatus.mockReturnValue(protoBlock as never); - chain.regen.getBlockSlotState.mockResolvedValue(blockState as never); - chain.bls.verifySignatureSets.mockResolvedValue(true); - - await importExecutionPayload.call(chain, payloadInput, new AbortController().signal); - - expect(chain.regen.getBlockSlotState).toHaveBeenCalledWith( - protoBlock, - protoBlock.slot, - {dontTransferCache: true}, - RegenCaller.processBlock - ); - expect(stateTransition.getExecutionPayloadEnvelopeSignatureSet).toHaveBeenCalledWith( - chain.config, - chain.pubkeyCache, - blockState, - signedEnvelope, - payloadInput.proposerIndex - ); - expect(blockState.processExecutionPayloadEnvelope).toHaveBeenCalledWith(signedEnvelope, { - verifySignature: false, - verifyStateRoot: false, - }); - }); -}); diff --git a/packages/beacon-node/test/unit/chain/validation/executionPayloadEnvelope.test.ts b/packages/beacon-node/test/unit/chain/validation/executionPayloadEnvelope.test.ts deleted file mode 100644 index 8efc3028c11d..000000000000 --- a/packages/beacon-node/test/unit/chain/validation/executionPayloadEnvelope.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; -import * as stateTransition from "@lodestar/state-transition"; -import {ssz} from "@lodestar/types"; -import {toRootHex} from "@lodestar/utils"; -import {RegenCaller} from "../../../../src/chain/regen/interface.js"; -import {validateGossipExecutionPayloadEnvelope} from "../../../../src/chain/validation/executionPayloadEnvelope.js"; -import {MockedBeaconChain, getMockedBeaconChain} from "../../../mocks/mockedBeaconChain.js"; - -describe("validateGossipExecutionPayloadEnvelope", () => { - let chain: MockedBeaconChain; - - beforeEach(() => { - chain = getMockedBeaconChain(); - vi.spyOn(stateTransition, "isStatePostGloas").mockReturnValue(true); - vi.spyOn(stateTransition, "getExecutionPayloadEnvelopeSignatureSet").mockReturnValue({} as never); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("uses the block state loaded from the forkchoice block stateRoot for signature verification", async () => { - const signedEnvelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue(); - signedEnvelope.message.slot = 1; - signedEnvelope.message.builderIndex = 7; - signedEnvelope.message.payload.blockHash = Buffer.alloc(32, 0x22); - - const block = { - slot: signedEnvelope.message.slot, - stateRoot: "0x" + "11".repeat(32), - }; - const payloadInput = { - proposerIndex: 13, - hasPayloadEnvelope: () => false, - getBuilderIndex: () => signedEnvelope.message.builderIndex, - getBlockHashHex: () => toRootHex(signedEnvelope.message.payload.blockHash), - }; - const blockState = {forkName: "gloas"} as never; - - chain.forkChoice.getBlockDefaultStatus.mockReturnValue(block as never); - chain.forkChoice.getBlockHex.mockReturnValue(null as never); - chain.forkChoice.getFinalizedCheckpoint.mockReturnValue({epoch: 0} as never); - chain.seenPayloadEnvelopeInputCache = { - get: vi.fn().mockReturnValue(payloadInput), - } as never; - chain.regen.getState.mockResolvedValue(blockState); - chain.bls.verifySignatureSets.mockResolvedValue(true); - - await validateGossipExecutionPayloadEnvelope(chain, signedEnvelope); - - expect(chain.regen.getState).toHaveBeenCalledWith(block.stateRoot, RegenCaller.validateGossipPayloadEnvelope); - expect(stateTransition.getExecutionPayloadEnvelopeSignatureSet).toHaveBeenCalledWith( - chain.config, - chain.pubkeyCache, - blockState, - signedEnvelope, - payloadInput.proposerIndex - ); - }); -}); diff --git a/packages/beacon-node/test/unit/node/notifier.test.ts b/packages/beacon-node/test/unit/node/notifier.test.ts new file mode 100644 index 000000000000..b56dc7abf3ed --- /dev/null +++ b/packages/beacon-node/test/unit/node/notifier.test.ts @@ -0,0 +1,132 @@ +import {describe, expect, it, vi} from "vitest"; +import type {BeaconConfig} from "@lodestar/config"; +import type {ProtoBlock} from "@lodestar/fork-choice"; +import type {IBeaconStateView} from "@lodestar/state-transition"; +import type {Logger} from "@lodestar/utils"; +import {prettyBytesShort} from "@lodestar/utils"; +import type {IBeaconChain} from "../../../src/chain/index.js"; +import type {INetwork} from "../../../src/network/index.js"; +import {runNodeNotifier} from "../../../src/node/notifier.js"; +import type {IBeaconSync} from "../../../src/sync/index.js"; + +const config = {ALTAIR_FORK_EPOCH: 0, BELLATRIX_FORK_EPOCH: 0, SLOT_DURATION_MS: 12_000} as BeaconConfig; +const EXECUTION_STATUS_VALID = "Valid" as Exclude; +const EXECUTION_STATUS_PAYLOAD_SEPARATED = "PayloadSeparated" as Exclude; +const PAYLOAD_STATUS_PENDING = 0 as ProtoBlock["payloadStatus"]; +const PAYLOAD_STATUS_FULL = 2 as ProtoBlock["payloadStatus"]; + +describe("runNodeNotifier", () => { + it("keeps pre-Gloas exec-block output unchanged", async () => { + const execHash = rootHex("33"); + const headInfo = makeProtoBlock({ + executionPayloadBlockHash: execHash, + executionPayloadNumber: 7, + executionStatus: EXECUTION_STATUS_VALID, + }); + + const {logLine} = await runNotifierOnce(headInfo, makeHeadState("capella")); + + expect(logLine).toContain(`exec-block: valid(7 ${prettyBytesShort(execHash)})`); + expect(logLine).not.toContain("empty-parent"); + expect(logLine).not.toContain("prev-payload:"); + }); + + it("renders Gloas PayloadSeparated heads as the same pre-Gloas-shaped exec-block row", async () => { + const anchorHash = rootHex("44"); + const headInfo = makeProtoBlock({ + executionPayloadBlockHash: anchorHash, + executionPayloadNumber: 12, + executionStatus: EXECUTION_STATUS_PAYLOAD_SEPARATED, + payloadStatus: PAYLOAD_STATUS_PENDING, + parentBlockHash: rootHex("aa"), + }); + + const {logLine} = await runNotifierOnce(headInfo, makeHeadState("gloas")); + + expect(logLine).toContain(`exec-block: valid(12 ${prettyBytesShort(anchorHash)})`); + expect(logLine).not.toContain("payloadseparated"); + expect(logLine).not.toContain("empty-parent"); + expect(logLine).not.toContain("prev-payload:"); + }); + + it("keeps Gloas FULL heads byte-identical to pre-Gloas exec-block formatting", async () => { + const execHash = rootHex("55"); + const headInfo = makeProtoBlock({ + executionPayloadBlockHash: execHash, + executionPayloadNumber: 13, + executionStatus: EXECUTION_STATUS_VALID, + payloadStatus: PAYLOAD_STATUS_FULL, + parentBlockHash: rootHex("bb"), + }); + + const {logLine} = await runNotifierOnce(headInfo, makeHeadState("gloas")); + + expect(logLine).toContain(`exec-block: valid(13 ${prettyBytesShort(execHash)})`); + expect(logLine).not.toContain("empty-parent"); + }); +}); + +async function runNotifierOnce(headInfo: ProtoBlock, headState: IBeaconStateView): Promise<{logLine: string}> { + const abortController = new AbortController(); + const logger = { + info: vi.fn(() => abortController.abort()), + warn: vi.fn(), + error: vi.fn(), + }; + + const chain = { + clock: {currentSlot: headInfo.slot}, + executionEngine: {state: "ONLINE"}, + forkChoice: {getHead: vi.fn(() => headInfo)}, + getHeadState: vi.fn(() => headState), + genesisTime: Math.floor(Date.now() / 1000) - 1000, + } as unknown as IBeaconChain; + + const network = {getConnectedPeerCount: vi.fn(() => 2)} as unknown as INetwork; + const sync = {state: "Synced"} as unknown as IBeaconSync; + + await runNodeNotifier({ + network, + chain, + sync, + config, + logger: logger as unknown as Logger, + signal: abortController.signal, + }); + + const logLine = logger.info.mock.calls[0]?.[0]; + if (typeof logLine !== "string") throw new Error("Expected notifier to emit a log line"); + return {logLine}; +} + +function makeHeadState(forkName: IBeaconStateView["forkName"]): IBeaconStateView { + return { + forkName, + finalizedCheckpoint: {epoch: 1, root: Buffer.alloc(32, 0xe1)}, + isExecutionStateType: true, + isMergeTransitionComplete: true, + } as unknown as IBeaconStateView; +} + +function makeProtoBlock(overrides: Partial = {}): ProtoBlock { + return { + slot: 1, + blockRoot: rootHex("11"), + parentRoot: rootHex("22"), + stateRoot: rootHex("99"), + targetRoot: rootHex("77"), + justifiedEpoch: 0, + finalizedEpoch: 0, + executionPayloadBlockHash: rootHex("33"), + executionPayloadNumber: 1, + executionStatus: EXECUTION_STATUS_VALID, + payloadStatus: PAYLOAD_STATUS_FULL, + parentBlockHash: null, + dataAvailabilityStatus: "NotRequired" as ProtoBlock["dataAvailabilityStatus"], + ...overrides, + } as ProtoBlock; +} + +function rootHex(hexByte: string): `0x${string}` { + return `0x${hexByte.repeat(32)}` as `0x${string}`; +}