From 4dede72e66f35ec2db918a116b863bae020c950c Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Mon, 23 Mar 2026 15:47:48 +0700 Subject: [PATCH 01/22] feat: add envelopes to BeaconChain.processChainSegments() --- .../src/api/impl/lodestar/index.ts | 2 +- .../beacon-node/src/chain/blocks/index.ts | 22 +++++++++++++------ packages/beacon-node/src/chain/chain.ts | 10 ++++++--- packages/beacon-node/src/chain/interface.ts | 7 +++++- packages/beacon-node/src/sync/range/chain.ts | 10 ++++++--- packages/beacon-node/src/sync/range/range.ts | 4 ++-- .../perf/chain/verifyImportBlocks.test.ts | 2 +- .../test/unit/sync/range/chain.test.ts | 4 ++-- 8 files changed, 41 insertions(+), 20 deletions(-) diff --git a/packages/beacon-node/src/api/impl/lodestar/index.ts b/packages/beacon-node/src/api/impl/lodestar/index.ts index 023f75fb0342..f33d4be63c77 100644 --- a/packages/beacon-node/src/api/impl/lodestar/index.ts +++ b/packages/beacon-node/src/api/impl/lodestar/index.ts @@ -111,7 +111,7 @@ export function getLodestarApi({ return { // biome-ignore lint/complexity/useLiteralKeys: The `blockProcessor` is a protected attribute data: (chain as BeaconChain)["blockProcessor"].jobQueue.getItems().map((item) => { - const [blockInputs, opts] = item.args; + const [blockInputs, _envelopes, opts] = item.args; return { blockSlots: blockInputs.map((blockInput) => blockInput.slot), jobOpts: opts, diff --git a/packages/beacon-node/src/chain/blocks/index.ts b/packages/beacon-node/src/chain/blocks/index.ts index 5fca016f2c80..0e5c0c18d4bf 100644 --- a/packages/beacon-node/src/chain/blocks/index.ts +++ b/packages/beacon-node/src/chain/blocks/index.ts @@ -1,4 +1,4 @@ -import {SignedBeaconBlock} from "@lodestar/types"; +import {SignedBeaconBlock, Slot, gloas} from "@lodestar/types"; import {isErrorAborted, toRootHex} from "@lodestar/utils"; import {Metrics} from "../../metrics/metrics.js"; import {nextEventLoop} from "../../util/eventLoop.js"; @@ -21,20 +21,27 @@ const QUEUE_MAX_LENGTH = 256; * BlockProcessor processes block jobs in a queued fashion, one after the other. */ export class BlockProcessor { - readonly jobQueue: JobItemQueue<[IBlockInput[], ImportBlockOpts], void>; + readonly jobQueue: JobItemQueue<[IBlockInput[], Map | null, ImportBlockOpts], void>; constructor(chain: BeaconChain, metrics: Metrics | null, opts: BlockProcessOpts, signal: AbortSignal) { - this.jobQueue = new JobItemQueue<[IBlockInput[], ImportBlockOpts], void>( - (job, importOpts) => { - return processBlocks.call(chain, job, {...opts, ...importOpts}); + this.jobQueue = new JobItemQueue< + [IBlockInput[], Map | null, ImportBlockOpts], + void + >( + (job, envelopes, importOpts) => { + return processBlocks.call(chain, job, envelopes, {...opts, ...importOpts}); }, {maxLength: QUEUE_MAX_LENGTH, noYieldIfOneItem: true, signal}, metrics?.blockProcessorQueue ?? undefined ); } - async processBlocksJob(job: IBlockInput[], opts: ImportBlockOpts = {}): Promise { - await this.jobQueue.push(job, opts); + async processBlocksJob( + job: IBlockInput[], + envelopes: Map | null, + opts: ImportBlockOpts = {} + ): Promise { + await this.jobQueue.push(job, envelopes, opts); } } @@ -51,6 +58,7 @@ export class BlockProcessor { export async function processBlocks( this: BeaconChain, blocks: IBlockInput[], + _envelopes: Map | null, opts: BlockProcessOpts & ImportBlockOpts ): Promise { if (blocks.length === 0) { diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index ea2b9870eea1..ef1b962bfbd0 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -1049,11 +1049,15 @@ export class BeaconChain implements IBeaconChain { } async processBlock(block: IBlockInput, opts?: ImportBlockOpts): Promise { - return this.blockProcessor.processBlocksJob([block], opts); + return this.blockProcessor.processBlocksJob([block], null, opts); } - async processChainSegment(blocks: IBlockInput[], opts?: ImportBlockOpts): Promise { - return this.blockProcessor.processBlocksJob(blocks, opts); + async processChainSegment( + blocks: IBlockInput[], + envelopes: Map | null, + opts?: ImportBlockOpts + ): Promise { + return this.blockProcessor.processBlocksJob(blocks, envelopes, opts); } async processExecutionPayload(payloadInput: PayloadEnvelopeInput, opts?: ImportPayloadOpts): Promise { diff --git a/packages/beacon-node/src/chain/interface.ts b/packages/beacon-node/src/chain/interface.ts index d6d31e6a2a4e..4a6585de919e 100644 --- a/packages/beacon-node/src/chain/interface.ts +++ b/packages/beacon-node/src/chain/interface.ts @@ -18,6 +18,7 @@ import { altair, capella, deneb, + gloas, phase0, rewards, } from "@lodestar/types"; @@ -241,7 +242,11 @@ export interface IBeaconChain { /** Process a block until complete */ processBlock(block: IBlockInput, opts?: ImportBlockOpts): Promise; /** Process a chain of blocks until complete */ - processChainSegment(blocks: IBlockInput[], opts?: ImportBlockOpts): Promise; + processChainSegment( + blocks: IBlockInput[], + envelopes: Map | null, + opts?: ImportBlockOpts + ): Promise; /** Process execution payload envelope: verify, import to fork choice, and persist to DB */ processExecutionPayload(payloadInput: PayloadEnvelopeInput, opts?: ImportPayloadOpts): Promise; diff --git a/packages/beacon-node/src/sync/range/chain.ts b/packages/beacon-node/src/sync/range/chain.ts index 911ce93b5bb0..c2d7aeff7f91 100644 --- a/packages/beacon-node/src/sync/range/chain.ts +++ b/packages/beacon-node/src/sync/range/chain.ts @@ -1,5 +1,5 @@ import {ChainForkConfig} from "@lodestar/config"; -import {Epoch, Root, Slot} from "@lodestar/types"; +import {Epoch, Root, Slot, gloas} from "@lodestar/types"; import {ErrorAborted, LodestarError, Logger, toRootHex} from "@lodestar/utils"; import {isBlockInputBlobs, isBlockInputColumns} from "../../chain/blocks/blockInput/blockInput.js"; import {BlockInputErrorCode} from "../../chain/blocks/blockInput/errors.js"; @@ -44,7 +44,11 @@ export type SyncChainFns = { * Must return if ALL blocks are processed successfully * If SOME blocks are processed must throw BlockProcessorError() */ - processChainSegment: (blocks: IBlockInput[], syncType: RangeSyncType) => Promise; + processChainSegment: ( + blocks: IBlockInput[], + envelopes: Map | null, + syncType: RangeSyncType + ) => Promise; /** Must download blocks, and validate their range */ downloadByRange: ( peer: PeerSyncMeta, @@ -581,7 +585,7 @@ export class SyncChain { const blocks = batch.startProcessing(); // wrapError ensures to never call both batch success() and batch error() - const res = await wrapError(this.processChainSegment(blocks, this.syncType)); + const res = await wrapError(this.processChainSegment(blocks, null, this.syncType)); if (!res.err) { batch.processingSuccess(); diff --git a/packages/beacon-node/src/sync/range/range.ts b/packages/beacon-node/src/sync/range/range.ts index 64c284c65936..6ea00972fda5 100644 --- a/packages/beacon-node/src/sync/range/range.ts +++ b/packages/beacon-node/src/sync/range/range.ts @@ -172,7 +172,7 @@ export class RangeSync extends (EventEmitter as {new (): RangeSyncEmitter}) { } /** Convenience method for `SyncChain` */ - private processChainSegment: SyncChainFns["processChainSegment"] = async (blocks, syncType) => { + private processChainSegment: SyncChainFns["processChainSegment"] = async (blocks, _envelopes, syncType) => { // Not trusted, verify signatures const flags: ImportBlockOpts = { // Only skip importing attestations for finalized sync. For head sync attestation are valuable. @@ -194,7 +194,7 @@ export class RangeSync extends (EventEmitter as {new (): RangeSyncEmitter}) { // Should only be used for debugging or testing for (const block of blocks) await this.chain.processBlock(block, flags); } else { - await this.chain.processChainSegment(blocks, flags); + await this.chain.processChainSegment(blocks, null, flags); } }; diff --git a/packages/beacon-node/test/perf/chain/verifyImportBlocks.test.ts b/packages/beacon-node/test/perf/chain/verifyImportBlocks.test.ts index 0793b05f069b..fa6c9c291a94 100644 --- a/packages/beacon-node/test/perf/chain/verifyImportBlocks.test.ts +++ b/packages/beacon-node/test/perf/chain/verifyImportBlocks.test.ts @@ -126,7 +126,7 @@ describe.skip("verify+import blocks - range sync perf test", () => { }); }); - await chain.processChainSegment(blocksImport, { + await chain.processChainSegment(blocksImport, null, { // Only skip importing attestations for finalized sync. For head sync attestation are valuable. // Importing attestations also triggers a head update, see https://github.com/ChainSafe/lodestar/issues/3804 // TODO: Review if this is okay, can we prevent some attacks by importing attestations? diff --git a/packages/beacon-node/test/unit/sync/range/chain.test.ts b/packages/beacon-node/test/unit/sync/range/chain.test.ts index 246db737b897..dda40168f2f8 100644 --- a/packages/beacon-node/test/unit/sync/range/chain.test.ts +++ b/packages/beacon-node/test/unit/sync/range/chain.test.ts @@ -218,9 +218,9 @@ describe("sync / range / chain", () => { function logSyncChainFns(logger: Logger, fns: SyncChainFns): SyncChainFns { return { - processChainSegment(blocks, syncType) { + processChainSegment(blocks, envelopes, syncType) { logger.debug("mock processChainSegment", {blocks: blocks.map((b) => b.slot).join(",")}); - return fns.processChainSegment(blocks, syncType); + return fns.processChainSegment(blocks, envelopes, syncType); }, downloadByRange(peer, request, syncType) { logger.debug("mock downloadBeaconBlocksByRange", request.state.status); From d1d7f39f0f41c703035117e7a432980d9d0ae787 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Mon, 23 Mar 2026 17:10:00 +0700 Subject: [PATCH 02/22] feat: implement gloas assertLinearChainSegment() --- .../beacon-node/src/chain/blocks/index.ts | 8 +- .../src/chain/blocks/utils/chainSegment.ts | 91 +++++++-- .../src/chain/errors/blockError.ts | 8 +- .../blocks/assertLinearChainSegment.test.ts | 193 ++++++++++++++++++ 4 files changed, 277 insertions(+), 23 deletions(-) create mode 100644 packages/beacon-node/test/unit/chain/blocks/assertLinearChainSegment.test.ts diff --git a/packages/beacon-node/src/chain/blocks/index.ts b/packages/beacon-node/src/chain/blocks/index.ts index 0e5c0c18d4bf..f06c360dfc7a 100644 --- a/packages/beacon-node/src/chain/blocks/index.ts +++ b/packages/beacon-node/src/chain/blocks/index.ts @@ -58,17 +58,13 @@ export class BlockProcessor { export async function processBlocks( this: BeaconChain, blocks: IBlockInput[], - _envelopes: Map | null, + envelopes: Map | null, opts: BlockProcessOpts & ImportBlockOpts ): Promise { if (blocks.length === 0) { return; // TODO: or throw? } - if (blocks.length > 1) { - assertLinearChainSegment(this.config, blocks); - } - try { const {relevantBlocks, parentSlots, parentBlock} = verifyBlocksSanityChecks(this, blocks, opts); @@ -78,6 +74,8 @@ export async function processBlocks( return; } + assertLinearChainSegment(this.config, relevantBlocks, envelopes, parentBlock); + // Fully verify a block to be imported immediately after. Does not produce any side-effects besides adding intermediate // states in the state cache through regen. const {postStates, dataAvailabilityStatuses, proposerBalanceDeltas, segmentExecStatus, indexedAttestationsByBlock} = diff --git a/packages/beacon-node/src/chain/blocks/utils/chainSegment.ts b/packages/beacon-node/src/chain/blocks/utils/chainSegment.ts index 5c9b4d8b9d56..1c833c647187 100644 --- a/packages/beacon-node/src/chain/blocks/utils/chainSegment.ts +++ b/packages/beacon-node/src/chain/blocks/utils/chainSegment.ts @@ -1,29 +1,86 @@ +import {ProtoBlock} from "@lodestar/fork-choice"; import {ChainForkConfig} from "@lodestar/config"; -import {ssz} from "@lodestar/types"; +import {Slot, gloas, isGloasBeaconBlock, ssz} from "@lodestar/types"; +import {toRootHex} from "@lodestar/utils"; import {BlockError, BlockErrorCode} from "../../errors/index.js"; import {IBlockInput} from "../blockInput/types.js"; /** - * Assert this chain segment of blocks is linear with slot numbers and hashes + * Assert this chain segment of blocks is linear with slot numbers and hashes, + * and that the provided envelopes are consistent with their respective blocks. + * + * Must be called after verifyBlocksSanityChecks so that parentBlock (from forkchoice) + * is available to seed the execution hash chain. + * + * For each block: + * - Verifies parent root + slot linearity + * - For gloas: verifies bid.parentBlockHash matches the tracked execution hash + * - If an envelope exists for this slot: verifies it references this block's root + * - Advances the tracked execution hash (FULL if envelope present, EMPTY if not) */ +export function assertLinearChainSegment( + config: ChainForkConfig, + blocks: IBlockInput[], + envelopes: Map | null, + parentBlock: ProtoBlock +): void { + // Track the expected execution payload block hash through the segment. + // Starts from the known forkchoice parent's execution hash. + // - FULL variant (envelope present for slot): advances to envelope.payload.blockHash + // - EMPTY variant (no envelope for slot): execution hash is unchanged + // null only for pre-merge parents, which cannot precede gloas blocks. + let currentExecHash: string | null = parentBlock.executionPayloadBlockHash; -export function assertLinearChainSegment(config: ChainForkConfig, blocks: IBlockInput[]): void { - for (let i = 0; i < blocks.length - 1; i++) { + for (let i = 0; i < blocks.length; i++) { const block = blocks[i].getBlock(); - const child = blocks[i + 1].getBlock(); - // If this block has a child in this chain segment, ensure that its parent root matches - // the root of this block. - if ( - !ssz.Root.equals( - config.getForkTypes(block.message.slot).BeaconBlock.hashTreeRoot(block.message), - child.message.parentRoot - ) - ) { - throw new BlockError(block, {code: BlockErrorCode.NON_LINEAR_PARENT_ROOTS}); + const slot = block.message.slot; + + if (i > 0) { + const prevBlock = blocks[i - 1].getBlock(); + // Ensure parent root matches the previous block's root + if ( + !ssz.Root.equals( + config.getForkTypes(prevBlock.message.slot).BeaconBlock.hashTreeRoot(prevBlock.message), + block.message.parentRoot + ) + ) { + throw new BlockError(block, {code: BlockErrorCode.NON_LINEAR_PARENT_ROOTS}); + } + // Ensure slots are strictly increasing + if (slot <= prevBlock.message.slot) { + throw new BlockError(block, {code: BlockErrorCode.NON_LINEAR_SLOTS}); + } } - // Ensure that the slots are strictly increasing throughout the chain segment. - if (child.message.slot <= block.message.slot) { - throw new BlockError(block, {code: BlockErrorCode.NON_LINEAR_SLOTS}); + + if (isGloasBeaconBlock(block.message) && currentExecHash !== null) { + // Verify the bid's parentBlockHash matches the tracked execution hash. + // This ensures the block was built on the correct FULL or EMPTY variant of its parent. + const bidParentHash = toRootHex(block.message.body.signedExecutionPayloadBid.message.parentBlockHash); + if (bidParentHash !== currentExecHash) { + throw new BlockError(block, { + code: BlockErrorCode.BID_PARENT_HASH_MISMATCH, + bidParentHash, + expectedHash: currentExecHash, + }); + } + + const envelope = envelopes?.get(slot); + if (envelope !== undefined) { + // Verify the envelope references this block's root + const blockRoot = toRootHex(config.getForkTypes(slot).BeaconBlock.hashTreeRoot(block.message)); + const envelopeBlockRoot = toRootHex(envelope.message.beaconBlockRoot); + if (blockRoot !== envelopeBlockRoot) { + throw new BlockError(block, { + code: BlockErrorCode.ENVELOPE_BEACON_BLOCK_ROOT_MISMATCH, + envelopeBlockRoot, + blockRoot, + }); + } + + // FULL variant: advance execution hash to the delivered payload's block hash + currentExecHash = toRootHex(envelope.message.payload.blockHash); + } + // EMPTY variant: currentExecHash unchanged } } } diff --git a/packages/beacon-node/src/chain/errors/blockError.ts b/packages/beacon-node/src/chain/errors/blockError.ts index 49224927e4da..3e4a47126bf3 100644 --- a/packages/beacon-node/src/chain/errors/blockError.ts +++ b/packages/beacon-node/src/chain/errors/blockError.ts @@ -70,6 +70,10 @@ export enum BlockErrorCode { TOO_MANY_KZG_COMMITMENTS = "BLOCK_ERROR_TOO_MANY_KZG_COMMITMENTS", /** Bid parent block root does not match block parent root */ BID_PARENT_ROOT_MISMATCH = "BLOCK_ERROR_BID_PARENT_ROOT_MISMATCH", + /** Block bid's parentBlockHash does not match the expected execution hash derived from the parent chain */ + BID_PARENT_HASH_MISMATCH = "BLOCK_ERROR_BID_PARENT_HASH_MISMATCH", + /** Envelope's beacon_block_root does not match the block's hash tree root */ + ENVELOPE_BEACON_BLOCK_ROOT_MISMATCH = "BLOCK_ERROR_ENVELOPE_BEACON_BLOCK_ROOT_MISMATCH", } type ExecutionErrorStatus = Exclude< @@ -114,7 +118,9 @@ export type BlockErrorType = | {code: BlockErrorCode.EXECUTION_ENGINE_ERROR; execStatus: ExecutionErrorStatus; errorMessage: string} | {code: BlockErrorCode.DATA_UNAVAILABLE} | {code: BlockErrorCode.TOO_MANY_KZG_COMMITMENTS; blobKzgCommitmentsLen: number; commitmentLimit: number} - | {code: BlockErrorCode.BID_PARENT_ROOT_MISMATCH; bidParentRoot: RootHex; blockParentRoot: RootHex}; + | {code: BlockErrorCode.BID_PARENT_ROOT_MISMATCH; bidParentRoot: RootHex; blockParentRoot: RootHex} + | {code: BlockErrorCode.BID_PARENT_HASH_MISMATCH; bidParentHash: RootHex; expectedHash: RootHex} + | {code: BlockErrorCode.ENVELOPE_BEACON_BLOCK_ROOT_MISMATCH; envelopeBlockRoot: RootHex; blockRoot: RootHex}; export class BlockGossipError extends GossipActionError {} diff --git a/packages/beacon-node/test/unit/chain/blocks/assertLinearChainSegment.test.ts b/packages/beacon-node/test/unit/chain/blocks/assertLinearChainSegment.test.ts new file mode 100644 index 000000000000..ba35166e9067 --- /dev/null +++ b/packages/beacon-node/test/unit/chain/blocks/assertLinearChainSegment.test.ts @@ -0,0 +1,193 @@ +import {describe, it} from "vitest"; +import {createChainForkConfig, defaultChainConfig} from "@lodestar/config"; +import {ProtoBlock} from "@lodestar/fork-choice"; +import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; +import {Slot, gloas, ssz} from "@lodestar/types"; +import {toRootHex} from "@lodestar/utils"; +import {BlockInputNoData} from "../../../../src/chain/blocks/blockInput/blockInput.js"; +import {BlockInputSource} from "../../../../src/chain/blocks/blockInput/types.js"; +import {IBlockInput} from "../../../../src/chain/blocks/blockInput/types.js"; +import {assertLinearChainSegment} from "../../../../src/chain/blocks/utils/chainSegment.js"; +import {BlockErrorCode} from "../../../../src/chain/errors/index.js"; +import {expectThrowsLodestarError} from "../../../utils/errors.js"; +import { ForkName } from "@lodestar/params"; + +const GLOAS_FORK_EPOCH = 1; +const config = createChainForkConfig({ + ...defaultChainConfig, + CAPELLA_FORK_EPOCH: 0, + DENEB_FORK_EPOCH: 0, + ELECTRA_FORK_EPOCH: 0, + FULU_FORK_EPOCH: 0, + GLOAS_FORK_EPOCH, +}); +const GLOAS_SLOT = computeStartSlotAtEpoch(GLOAS_FORK_EPOCH); + +const HASH_A = new Uint8Array(32).fill(0xaa); +const HASH_B = new Uint8Array(32).fill(0xbb); +const HASH_C = new Uint8Array(32).fill(0xcc); +const HASH_WRONG = new Uint8Array(32).fill(0xde); + +/** Build a gloas block input with specific parentRoot and bid.parentBlockHash */ +function makeBlockInput(slot: Slot, parentRoot: Uint8Array, parentBlockHash: Uint8Array): IBlockInput { + const block = ssz.gloas.SignedBeaconBlock.defaultValue(); + block.message.slot = slot; + block.message.parentRoot = parentRoot; + block.message.body.signedExecutionPayloadBid.message.parentBlockHash = parentBlockHash; + const blockRootHex = toRootHex(config.getForkTypes(slot).BeaconBlock.hashTreeRoot(block.message)); + return BlockInputNoData.createFromBlock({ + block, + blockRootHex, + forkName: ForkName.gloas, + daOutOfRange: false, + source: BlockInputSource.byRange, + seenTimestampSec: 0, + }); +} + +/** Compute the hash tree root of a block input's block */ +function blockRoot(blockInput: IBlockInput): Uint8Array { + const block = blockInput.getBlock(); + return config.getForkTypes(block.message.slot).BeaconBlock.hashTreeRoot(block.message); +} + +/** Build a valid envelope that references the given block and carries a given payload block hash */ +function makeEnvelope(blockInput: IBlockInput, payloadBlockHash: Uint8Array): gloas.SignedExecutionPayloadEnvelope { + const envelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue(); + envelope.message.beaconBlockRoot = blockRoot(blockInput); + envelope.message.slot = blockInput.getBlock().message.slot; + envelope.message.payload.blockHash = payloadBlockHash; + return envelope; +} + +/** Build a mock parent ProtoBlock seeded with the given execution payload block hash */ +function makeParentBlock(execHash: Uint8Array): ProtoBlock { + return {executionPayloadBlockHash: toRootHex(execHash)} as Partial as ProtoBlock; +} + +describe("chain / blocks / assertLinearChainSegment", () => { + // parentBlock represents the forkchoice-known parent of the first block in all segments below. + // Its execution hash is HASH_A, so all first blocks must have bid.parentBlockHash = HASH_A. + const parentBlock = makeParentBlock(HASH_A); + + describe("block linearity", () => { + it("ok - single block", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + assertLinearChainSegment(config, [block0], null, parentBlock); + }); + + it("ok - two blocks with matching parent roots and increasing slots", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + const block1 = makeBlockInput(GLOAS_SLOT + 1, blockRoot(block0), HASH_A); + assertLinearChainSegment(config, [block0, block1], null, parentBlock); + }); + + it("NON_LINEAR_PARENT_ROOTS - second block has wrong parentRoot", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + const block1 = makeBlockInput(GLOAS_SLOT + 1, HASH_WRONG, HASH_A); + expectThrowsLodestarError( + () => assertLinearChainSegment(config, [block0, block1], null, parentBlock), + BlockErrorCode.NON_LINEAR_PARENT_ROOTS + ); + }); + + it("NON_LINEAR_SLOTS - second block has same slot as first", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + const block1 = makeBlockInput(GLOAS_SLOT, blockRoot(block0), HASH_A); + expectThrowsLodestarError( + () => assertLinearChainSegment(config, [block0, block1], null, parentBlock), + BlockErrorCode.NON_LINEAR_SLOTS + ); + }); + }); + + describe("execution hash chain (bid.parentBlockHash)", () => { + it("ok - single block with correct bid.parentBlockHash", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + assertLinearChainSegment(config, [block0], null, parentBlock); + }); + + it("BID_PARENT_HASH_MISMATCH - first block bid.parentBlockHash doesn't match forkchoice parent hash", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_B); // parentBlock has HASH_A + expectThrowsLodestarError( + () => assertLinearChainSegment(config, [block0], null, parentBlock), + BlockErrorCode.BID_PARENT_HASH_MISMATCH + ); + }); + + it("ok - EMPTY chain (no envelopes): execution hash propagates unchanged across segment", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + // block1 expects HASH_A because block0 was EMPTY (no envelope → no payload hash change) + const block1 = makeBlockInput(GLOAS_SLOT + 1, blockRoot(block0), HASH_A); + assertLinearChainSegment(config, [block0, block1], null, parentBlock); + }); + + it("ok - FULL slot (envelope present): execution hash advances to envelope payload hash", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + // Envelope for block0 delivers HASH_B, so block1 must use HASH_B as parentBlockHash + const block1 = makeBlockInput(GLOAS_SLOT + 1, blockRoot(block0), HASH_B); + const envelopes = new Map([[GLOAS_SLOT, makeEnvelope(block0, HASH_B)]]); + assertLinearChainSegment(config, [block0, block1], envelopes, parentBlock); + }); + + it("BID_PARENT_HASH_MISMATCH on second block - expected EMPTY but block references FULL hash", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + // No envelope for block0 → EMPTY → next block must still use HASH_A, not HASH_B + const block1 = makeBlockInput(GLOAS_SLOT + 1, blockRoot(block0), HASH_B); + expectThrowsLodestarError( + () => assertLinearChainSegment(config, [block0, block1], null, parentBlock), + BlockErrorCode.BID_PARENT_HASH_MISMATCH + ); + }); + + it("ok - FULL then EMPTY: third block reuses the envelope hash from the first slot", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + // block0 FULL: delivers HASH_B + const block1 = makeBlockInput(GLOAS_SLOT + 1, blockRoot(block0), HASH_B); + // block1 EMPTY: hash stays HASH_B + const block2 = makeBlockInput(GLOAS_SLOT + 2, blockRoot(block1), HASH_B); + const envelopes = new Map([[GLOAS_SLOT, makeEnvelope(block0, HASH_B)]]); + assertLinearChainSegment(config, [block0, block1, block2], envelopes, parentBlock); + }); + + it("ok - FULL then FULL: each block advances the execution hash", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + // block0 FULL: delivers HASH_B + const block1 = makeBlockInput(GLOAS_SLOT + 1, blockRoot(block0), HASH_B); + // block1 FULL: delivers HASH_C + const block2 = makeBlockInput(GLOAS_SLOT + 2, blockRoot(block1), HASH_C); + const envelopes = new Map([ + [GLOAS_SLOT, makeEnvelope(block0, HASH_B)], + [GLOAS_SLOT + 1, makeEnvelope(block1, HASH_C)], + ]); + assertLinearChainSegment(config, [block0, block1, block2], envelopes, parentBlock); + }); + }); + + describe("envelope beacon_block_root validation", () => { + it("ok - envelope beaconBlockRoot matches the block's hash tree root", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + const envelopes = new Map([[GLOAS_SLOT, makeEnvelope(block0, HASH_B)]]); + assertLinearChainSegment(config, [block0], envelopes, parentBlock); + }); + + it("ENVELOPE_BEACON_BLOCK_ROOT_MISMATCH - envelope references a different block root", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + const envelope = makeEnvelope(block0, HASH_B); + envelope.message.beaconBlockRoot = HASH_WRONG; // tamper the root + const envelopes = new Map([[GLOAS_SLOT, envelope]]); + expectThrowsLodestarError( + () => assertLinearChainSegment(config, [block0], envelopes, parentBlock), + BlockErrorCode.ENVELOPE_BEACON_BLOCK_ROOT_MISMATCH + ); + }); + + it("ok - envelope for a slot not in segment is silently ignored", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + // Orphan envelope for a slot outside the segment — no error expected + const orphanEnvelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue(); + const envelopes = new Map([[GLOAS_SLOT + 99, orphanEnvelope]]); + assertLinearChainSegment(config, [block0], envelopes, parentBlock); + }); + }); +}); From 71c386c030ea034ea26a2373a746327be40dc172 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Tue, 24 Mar 2026 09:43:36 +0700 Subject: [PATCH 03/22] feat: enhance verifyBlocksExecutionPayload() for gloas --- .../src/api/impl/beacon/blocks/index.ts | 2 +- .../beacon-node/src/chain/blocks/index.ts | 7 +- .../src/chain/blocks/utils/chainSegment.ts | 2 +- .../src/chain/blocks/verifyBlock.ts | 13 ++- .../blocks/verifyBlocksExecutionPayloads.ts | 89 +++++++++++++------ .../blocks/assertLinearChainSegment.test.ts | 5 +- 6 files changed, 81 insertions(+), 37 deletions(-) diff --git a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts index e9e4bd78111c..4700134a4062 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -227,7 +227,7 @@ export function getBeaconBlockApi({ } try { - await verifyBlocksInEpoch.call(chain as BeaconChain, parentBlock, [blockForImport], { + await verifyBlocksInEpoch.call(chain as BeaconChain, parentBlock, [blockForImport], null, { ...opts, verifyOnly: true, skipVerifyBlockSignatures: true, diff --git a/packages/beacon-node/src/chain/blocks/index.ts b/packages/beacon-node/src/chain/blocks/index.ts index f06c360dfc7a..711ad9764086 100644 --- a/packages/beacon-node/src/chain/blocks/index.ts +++ b/packages/beacon-node/src/chain/blocks/index.ts @@ -21,7 +21,10 @@ const QUEUE_MAX_LENGTH = 256; * BlockProcessor processes block jobs in a queued fashion, one after the other. */ export class BlockProcessor { - readonly jobQueue: JobItemQueue<[IBlockInput[], Map | null, ImportBlockOpts], void>; + readonly jobQueue: JobItemQueue< + [IBlockInput[], Map | null, ImportBlockOpts], + void + >; constructor(chain: BeaconChain, metrics: Metrics | null, opts: BlockProcessOpts, signal: AbortSignal) { this.jobQueue = new JobItemQueue< @@ -79,7 +82,7 @@ export async function processBlocks( // Fully verify a block to be imported immediately after. Does not produce any side-effects besides adding intermediate // states in the state cache through regen. const {postStates, dataAvailabilityStatuses, proposerBalanceDeltas, segmentExecStatus, indexedAttestationsByBlock} = - await verifyBlocksInEpoch.call(this, parentBlock, relevantBlocks, opts); + await verifyBlocksInEpoch.call(this, parentBlock, relevantBlocks, envelopes, opts); // If segmentExecStatus has lvhForkchoice then, the entire segment should be invalid // and we need to further propagate diff --git a/packages/beacon-node/src/chain/blocks/utils/chainSegment.ts b/packages/beacon-node/src/chain/blocks/utils/chainSegment.ts index 1c833c647187..ebebc2199155 100644 --- a/packages/beacon-node/src/chain/blocks/utils/chainSegment.ts +++ b/packages/beacon-node/src/chain/blocks/utils/chainSegment.ts @@ -1,5 +1,5 @@ -import {ProtoBlock} from "@lodestar/fork-choice"; import {ChainForkConfig} from "@lodestar/config"; +import {ProtoBlock} from "@lodestar/fork-choice"; import {Slot, gloas, isGloasBeaconBlock, ssz} from "@lodestar/types"; import {toRootHex} from "@lodestar/utils"; import {BlockError, BlockErrorCode} from "../../errors/index.js"; diff --git a/packages/beacon-node/src/chain/blocks/verifyBlock.ts b/packages/beacon-node/src/chain/blocks/verifyBlock.ts index 42e0a381314c..5e74ab5a5d3f 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlock.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlock.ts @@ -1,7 +1,7 @@ import {ExecutionStatus, ProtoBlock} from "@lodestar/fork-choice"; import {ForkName, isForkPostFulu} from "@lodestar/params"; import {DataAvailabilityStatus, IBeaconStateView, computeEpochAtSlot} from "@lodestar/state-transition"; -import {IndexedAttestation, deneb} from "@lodestar/types"; +import {IndexedAttestation, Slot, deneb, gloas} from "@lodestar/types"; import type {BeaconChain} from "../chain.js"; import {BlockError, BlockErrorCode} from "../errors/index.js"; import {BlockProcessOpts} from "../options.js"; @@ -32,6 +32,7 @@ export async function verifyBlocksInEpoch( this: BeaconChain, parentBlock: ProtoBlock, blockInputs: IBlockInput[], + envelopes: Map | null, opts: BlockProcessOpts & ImportBlockOpts ): Promise<{ postStates: IBeaconStateView[]; @@ -94,7 +95,15 @@ export async function verifyBlocksInEpoch( // Start execution payload verification first (async request to execution client) const verifyExecutionPayloadsPromise = opts.skipVerifyExecutionPayload !== true - ? verifyBlocksExecutionPayload(this, parentBlock, blockInputs, preState0, abortController.signal, opts) + ? verifyBlocksExecutionPayload( + this, + parentBlock, + blockInputs, + envelopes, + preState0, + abortController.signal, + opts + ) : Promise.resolve({ execAborted: null, executionStatuses: blocks.map((_blk) => ExecutionStatus.Syncing), diff --git a/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts b/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts index fee85c9a9823..f0ca61c665b5 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts @@ -8,11 +8,15 @@ import { ProtoBlock, } from "@lodestar/fork-choice"; import {ForkSeq} from "@lodestar/params"; -import {IBeaconStateView, isExecutionBlockBodyType} from "@lodestar/state-transition"; -import {bellatrix, electra} from "@lodestar/types"; +import { + IBeaconStateView, + isExecutionBlockBodyType, +} from "@lodestar/state-transition"; +import {ExecutionPayload, ExecutionRequests, Root, Slot, bellatrix, electra, gloas} from "@lodestar/types"; import {ErrorAborted, Logger, toRootHex} from "@lodestar/utils"; -import {ExecutionPayloadStatus, IExecutionEngine} from "../../execution/engine/interface.js"; +import {ExecutionPayloadStatus, IExecutionEngine, VersionedHashes} from "../../execution/engine/interface.js"; import {Metrics} from "../../metrics/metrics.js"; +import {kzgCommitmentToVersionedHash} from "../../util/blobs.js"; import {IClock} from "../../util/clock.js"; import {BlockError, BlockErrorCode} from "../errors/index.js"; import {BlockProcessOpts} from "../options.js"; @@ -58,6 +62,7 @@ export async function verifyBlocksExecutionPayload( chain: VerifyBlockExecutionPayloadModules, parentBlock: ProtoBlock, blockInputs: IBlockInput[], + signedEnvelopes: Map | null, preState0: IBeaconStateView, signal: AbortSignal, opts: BlockProcessOpts & ImportBlockOpts @@ -96,7 +101,8 @@ export async function verifyBlocksExecutionPayload( if (signal.aborted) { throw new ErrorAborted("verifyBlockExecutionPayloads"); } - const verifyResponse = await verifyBlockExecutionPayload(chain, blockInput, preState0); + const signedEnvelope = signedEnvelopes?.get(blockInput.slot) ?? null; + const verifyResponse = await verifyBlockExecutionPayload(chain, blockInput, signedEnvelope, preState0); // If execError has happened, then we need to extract the segmentExecStatus and return if (verifyResponse.execError !== null) { @@ -141,46 +147,73 @@ export async function verifyBlocksExecutionPayload( export async function verifyBlockExecutionPayload( chain: VerifyBlockExecutionPayloadModules, blockInput: IBlockInput, + signedEnvelope: gloas.SignedExecutionPayloadEnvelope | null, preState0: IBeaconStateView ): Promise { const block = blockInput.getBlock(); - // Gloas block doesn't have execution payload. Return right away - if (isBlockInputNoData(blockInput)) { + // Gloas block doesn't have execution payload in block body. This happens at gossip time, or range sync with no payload. + if (isBlockInputNoData(blockInput) && signedEnvelope === null) { + const clockSlot = chain.clock.currentSlot; + const logMsg = + clockSlot === blockInput.slot + ? "Skipping engine api newPayload for gloas block" + : "Skipping engine api newPayload for gloas block without payload"; + chain.logger.verbose(logMsg, {slot: blockInput.slot, root: blockInput.blockRootHex, clockSlot}); return {executionStatus: ExecutionStatus.PayloadSeparated, lvhResponse: undefined, execError: null}; } - /** Not null if execution is enabled */ - const executionPayloadEnabled = - preState0.isExecutionStateType && - isExecutionBlockBodyType(block.message.body) && - preState0.isExecutionEnabled(block.message) - ? block.message.body.executionPayload - : null; + const fork = blockInput.forkName; - if (!executionPayloadEnabled) { - // Pre-merge block, no execution payload to verify - return {executionStatus: ExecutionStatus.PreMerge, lvhResponse: undefined, execError: null}; - } + // For gloas, execution payload and execution requests come from the signed envelope; + // blob kzg commitments come from the execution payload bid in the block body. + // For other forks, all three come from the block body. + let executionPayload: ExecutionPayload; + let versionedHashes: VersionedHashes | undefined; + let parentBlockRoot: Root | undefined; + let executionRequests: ExecutionRequests | undefined; - // TODO: Handle better notifyNewPayload() returning error is syncing - const fork = blockInput.forkName; - const versionedHashes = - isBlockInputBlobs(blockInput) || isBlockInputColumns(blockInput) ? blockInput.getVersionedHashes() : undefined; - const parentBlockRoot = ForkSeq[fork] >= ForkSeq.deneb ? block.message.parentRoot : undefined; - const executionRequests = - ForkSeq[fork] >= ForkSeq.electra ? (block.message.body as electra.BeaconBlockBody).executionRequests : undefined; + if (isBlockInputNoData(blockInput)) { + // Should not happen - we returned early above if signedEnvelope is null + if (signedEnvelope === null) + throw Error(`signedEnvelope is null for gloas block slot=${blockInput.slot} root=${blockInput.blockRootHex}`); + executionPayload = signedEnvelope.message.payload; + versionedHashes = blockInput.getBlobKzgCommitments().map(kzgCommitmentToVersionedHash); + parentBlockRoot = block.message.parentRoot; // gloas is always post-deneb + executionRequests = signedEnvelope.message.executionRequests; + } else { + /** pre-gloas, not null if execution is enabled */ + const executionPayloadEnabled = + preState0.isExecutionStateType && + isExecutionBlockBodyType(block.message.body) && + preState0.isExecutionEnabled(block.message) + ? block.message.body.executionPayload + : null; + + if (!executionPayloadEnabled) { + // Pre-merge block, no execution payload to verify + return {executionStatus: ExecutionStatus.PreMerge, lvhResponse: undefined, execError: null}; + } + + executionPayload = executionPayloadEnabled; + // TODO: Handle better notifyNewPayload() returning error is syncing + versionedHashes = + isBlockInputBlobs(blockInput) || isBlockInputColumns(blockInput) ? blockInput.getVersionedHashes() : undefined; + parentBlockRoot = ForkSeq[fork] >= ForkSeq.deneb ? block.message.parentRoot : undefined; + executionRequests = + ForkSeq[fork] >= ForkSeq.electra ? (block.message.body as electra.BeaconBlockBody).executionRequests : undefined; + } - const logCtx = {slot: blockInput.slot, executionBlock: executionPayloadEnabled.blockNumber}; - chain.logger.debug("Call engine api newPayload", logCtx); + const logCtx = {slot: blockInput.slot, executionBlock: executionPayload.blockNumber}; + chain.logger.verbose("Call engine api newPayload", logCtx); const execResult = await chain.executionEngine.notifyNewPayload( fork, - executionPayloadEnabled, + executionPayload, versionedHashes, parentBlockRoot, executionRequests ); - chain.logger.debug("Receive engine api newPayload result", {...logCtx, status: execResult.status}); + chain.logger.verbose("Receive engine api newPayload result", {...logCtx, status: execResult.status}); chain.metrics?.engineNotifyNewPayloadResult.inc({result: execResult.status}); diff --git a/packages/beacon-node/test/unit/chain/blocks/assertLinearChainSegment.test.ts b/packages/beacon-node/test/unit/chain/blocks/assertLinearChainSegment.test.ts index ba35166e9067..5ed2be81a49d 100644 --- a/packages/beacon-node/test/unit/chain/blocks/assertLinearChainSegment.test.ts +++ b/packages/beacon-node/test/unit/chain/blocks/assertLinearChainSegment.test.ts @@ -1,16 +1,15 @@ import {describe, it} from "vitest"; import {createChainForkConfig, defaultChainConfig} from "@lodestar/config"; import {ProtoBlock} from "@lodestar/fork-choice"; +import {ForkName} from "@lodestar/params"; import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; import {Slot, gloas, ssz} from "@lodestar/types"; import {toRootHex} from "@lodestar/utils"; import {BlockInputNoData} from "../../../../src/chain/blocks/blockInput/blockInput.js"; -import {BlockInputSource} from "../../../../src/chain/blocks/blockInput/types.js"; -import {IBlockInput} from "../../../../src/chain/blocks/blockInput/types.js"; +import {BlockInputSource, IBlockInput} from "../../../../src/chain/blocks/blockInput/types.js"; import {assertLinearChainSegment} from "../../../../src/chain/blocks/utils/chainSegment.js"; import {BlockErrorCode} from "../../../../src/chain/errors/index.js"; import {expectThrowsLodestarError} from "../../../utils/errors.js"; -import { ForkName } from "@lodestar/params"; const GLOAS_FORK_EPOCH = 1; const config = createChainForkConfig({ From 8c0f1633239afd9be7503ad4c18354b0cdafecc3 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Tue, 24 Mar 2026 10:49:07 +0700 Subject: [PATCH 04/22] feat: enhance verifyBlocksStateTransitionOnly() for gloas --- .../beacon-node/src/chain/blocks/index.ts | 11 ++- .../src/chain/blocks/verifyBlock.ts | 15 ++- .../blocks/verifyBlocksExecutionPayloads.ts | 5 +- .../blocks/verifyBlocksStateTransitionOnly.ts | 98 ++++++++++++++++--- .../state-transition/src/stateTransition.ts | 1 + 5 files changed, 107 insertions(+), 23 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/index.ts b/packages/beacon-node/src/chain/blocks/index.ts index 711ad9764086..e08b4f8d6323 100644 --- a/packages/beacon-node/src/chain/blocks/index.ts +++ b/packages/beacon-node/src/chain/blocks/index.ts @@ -81,8 +81,13 @@ export async function processBlocks( // Fully verify a block to be imported immediately after. Does not produce any side-effects besides adding intermediate // states in the state cache through regen. - const {postStates, dataAvailabilityStatuses, proposerBalanceDeltas, segmentExecStatus, indexedAttestationsByBlock} = - await verifyBlocksInEpoch.call(this, parentBlock, relevantBlocks, envelopes, opts); + const { + postBlockStates, + dataAvailabilityStatuses, + proposerBalanceDeltas, + segmentExecStatus, + indexedAttestationsByBlock, + } = await verifyBlocksInEpoch.call(this, parentBlock, relevantBlocks, envelopes, opts); // If segmentExecStatus has lvhForkchoice then, the entire segment should be invalid // and we need to further propagate @@ -97,7 +102,7 @@ export async function processBlocks( const fullyVerifiedBlocks = relevantBlocks.map( (block, i): FullyVerifiedBlock => ({ blockInput: block, - postBlockState: postStates[i], + postBlockState: postBlockStates[i], postEnvelopeState: null, parentBlockSlot: parentSlots[i], executionStatus: executionStatuses[i], diff --git a/packages/beacon-node/src/chain/blocks/verifyBlock.ts b/packages/beacon-node/src/chain/blocks/verifyBlock.ts index 5e74ab5a5d3f..a6587fa575a9 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlock.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlock.ts @@ -35,11 +35,12 @@ export async function verifyBlocksInEpoch( envelopes: Map | null, opts: BlockProcessOpts & ImportBlockOpts ): Promise<{ - postStates: IBeaconStateView[]; + postBlockStates: IBeaconStateView[]; proposerBalanceDeltas: number[]; segmentExecStatus: SegmentExecStatus; dataAvailabilityStatuses: DataAvailabilityStatus[]; indexedAttestationsByBlock: IndexedAttestation[][]; + postEnvelopeStates: Map; }> { const blocks = blockInputs.map((blockInput) => blockInput.getBlock()); const lastBlock = blocks.at(-1); @@ -123,7 +124,7 @@ export async function verifyBlocksInEpoch( const [ segmentExecStatus, {dataAvailabilityStatuses, availableTime}, - {postStates, proposerBalanceDeltas, verifyStateTime}, + {postBlockStates, proposerBalanceDeltas, verifyStateTime, postEnvelopeStates}, {verifySignaturesTime}, ] = await Promise.all([ verifyExecutionPayloadsPromise, @@ -136,6 +137,7 @@ export async function verifyBlocksInEpoch( verifyBlocksStateTransitionOnly( preState0, blockInputs, + envelopes, // hack availability for state transition eval as availability is separately determined blocks.map(() => DataAvailabilityStatus.Available), this.logger, @@ -238,7 +240,14 @@ export async function verifyBlocksInEpoch( ); } - return {postStates, dataAvailabilityStatuses, proposerBalanceDeltas, segmentExecStatus, indexedAttestationsByBlock}; + return { + postBlockStates, + dataAvailabilityStatuses, + proposerBalanceDeltas, + segmentExecStatus, + indexedAttestationsByBlock, + postEnvelopeStates, + }; } finally { abortController.abort(); } diff --git a/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts b/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts index f0ca61c665b5..74c427fd11e5 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts @@ -8,10 +8,7 @@ import { ProtoBlock, } from "@lodestar/fork-choice"; import {ForkSeq} from "@lodestar/params"; -import { - IBeaconStateView, - isExecutionBlockBodyType, -} from "@lodestar/state-transition"; +import {IBeaconStateView, isExecutionBlockBodyType} from "@lodestar/state-transition"; import {ExecutionPayload, ExecutionRequests, Root, Slot, bellatrix, electra, gloas} from "@lodestar/types"; import {ErrorAborted, Logger, toRootHex} from "@lodestar/utils"; import {ExecutionPayloadStatus, IExecutionEngine, VersionedHashes} from "../../execution/engine/interface.js"; diff --git a/packages/beacon-node/src/chain/blocks/verifyBlocksStateTransitionOnly.ts b/packages/beacon-node/src/chain/blocks/verifyBlocksStateTransitionOnly.ts index 8a9ffa3b1fed..fc695b298348 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlocksStateTransitionOnly.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlocksStateTransitionOnly.ts @@ -4,7 +4,8 @@ import { IBeaconStateView, StateHashTreeRootSource, } from "@lodestar/state-transition"; -import {ErrorAborted, Logger, byteArrayEquals} from "@lodestar/utils"; +import {Slot, gloas} from "@lodestar/types"; +import {ErrorAborted, Logger, byteArrayEquals, toRootHex} from "@lodestar/utils"; import {Metrics} from "../../metrics/index.js"; import {nextEventLoop} from "../../util/eventLoop.js"; import {BlockError, BlockErrorCode} from "../errors/index.js"; @@ -14,37 +15,78 @@ import {IBlockInput} from "./blockInput/index.js"; import {ImportBlockOpts} from "./types.js"; /** - * Verifies 1 or more blocks are fully valid running the full state transition; from a linear sequence of blocks. + * Verifies 1 or more blocks/envelopes are fully valid running the full state transition; from a linear sequence of blocks/envelopes. * * - Advance state to block's slot - per_slot_processing() * - For each block: * - STFN - per_block_processing() * - Check state root matches + * - For gloas blocks with an envelope: run processExecutionPayloadEnvelope() and check envelope state root + * - Pre-state selection for gloas: use post-envelope state of previous block if proposer built on FULL path + * (bid.parentBlockHash matches previous envelope payload.blockHash), otherwise use post-block state */ export async function verifyBlocksStateTransitionOnly( preState0: IBeaconStateView, blocks: IBlockInput[], + envelopes: Map | null, dataAvailabilityStatuses: DataAvailabilityStatus[], logger: Logger, metrics: Metrics | null, validatorMonitor: ValidatorMonitor | null, signal: AbortSignal, opts: BlockProcessOpts & ImportBlockOpts -): Promise<{postStates: IBeaconStateView[]; proposerBalanceDeltas: number[]; verifyStateTime: number}> { - const postStates: IBeaconStateView[] = []; +): Promise<{ + postBlockStates: IBeaconStateView[]; + proposerBalanceDeltas: number[]; + verifyStateTime: number; + postEnvelopeStates: Map; +}> { + const postBlockStates: IBeaconStateView[] = []; const proposerBalanceDeltas: number[] = []; + const postEnvelopeStates = new Map(); const recvToValLatency = Date.now() / 1000 - (opts.seenTimestampSec ?? Date.now() / 1000); for (let i = 0; i < blocks.length; i++) { const {validProposerSignature, validSignatures} = opts; const block = blocks[i].getBlock(); - const preState = i === 0 ? preState0 : postStates[i - 1]; const dataAvailabilityStatus = dataAvailabilityStatuses[i]; + let preState: IBeaconStateView; + if (i === 0) { + preState = preState0; + } else { + const prevSlot = blocks[i - 1].getBlock().message.slot; + const prevPostEnvelopeState = postEnvelopeStates.get(prevSlot); + // If previous slot had an envelope and its latestBlockHash matches + // this block's bid parentBlockHash, the proposer built on the FULL path + if ( + prevPostEnvelopeState != null && + byteArrayEquals( + prevPostEnvelopeState.latestBlockHash, + (block.message.body as gloas.BeaconBlockBody).signedExecutionPayloadBid.message.parentBlockHash + ) + ) { + preState = prevPostEnvelopeState; + } else { + // EMPTY path + if (prevPostEnvelopeState != null) { + // the envelope is orphaned + logger.debug("Previous block had an execution payload envelope but this block did not build on it", { + slot: block.message.slot, + prevEnvelopeBlockHash: toRootHex(prevPostEnvelopeState.latestBlockHash), + currentBidParentHash: toRootHex( + (block.message.body as gloas.BeaconBlockBody).signedExecutionPayloadBid.message.parentBlockHash + ), + }); + } + preState = postBlockStates[i - 1]; + } + } + // STFN - per_slot_processing() + per_block_processing() // NOTE: `regen.getPreState()` should have dialed forward the state already caching checkpoint states const useBlsBatchVerify = !opts?.disableBlsBatchVerify; - const postState = preState.stateTransition( + const postBlockState = preState.stateTransition( block, { // NOTE: Assume valid for now while sending payload to execution engine in parallel @@ -64,25 +106,55 @@ export async function verifyBlocksStateTransitionOnly( const hashTreeRootTimer = metrics?.stateHashTreeRootTime.startTimer({ source: StateHashTreeRootSource.blockTransition, }); - const stateRoot = postState.hashTreeRoot(); + const stateRootAfterStateTransition = postBlockState.hashTreeRoot(); hashTreeRootTimer?.(); // Check state root matches - if (!byteArrayEquals(block.message.stateRoot, stateRoot)) { + if (!byteArrayEquals(block.message.stateRoot, stateRootAfterStateTransition)) { throw new BlockError(block, { code: BlockErrorCode.INVALID_STATE_ROOT, - root: postState.hashTreeRoot(), + root: postBlockState.hashTreeRoot(), expectedRoot: block.message.stateRoot, preState, - postState, + postState: postBlockState, }); } - postStates[i] = postState; + postBlockStates[i] = postBlockState; // For metric block profitability const proposerIndex = block.message.proposerIndex; - proposerBalanceDeltas[i] = postState.getBalance(proposerIndex) - preState.getBalance(proposerIndex); + proposerBalanceDeltas[i] = postBlockState.getBalance(proposerIndex) - preState.getBalance(proposerIndex); + + const slot = block.message.slot; + const signedEnvelope = envelopes?.get(slot) ?? null; + if (signedEnvelope !== null) { + // verifyStateRoot: false — we verify manually below with BlockError for proper error typing + const postEnvelopeState = postBlockState.processExecutionPayloadEnvelope(signedEnvelope, { + verifySignature: false, + verifyStateRoot: false, + }); + + const hashTreeRootTimerEnvelope = metrics?.stateHashTreeRootTime.startTimer({ + source: StateHashTreeRootSource.envelopeTransition, + }); + const stateRootAfterEnvelope = postEnvelopeState.hashTreeRoot(); + hashTreeRootTimerEnvelope?.(); + + if (!byteArrayEquals(signedEnvelope.message.stateRoot, stateRootAfterEnvelope)) { + throw new BlockError(block, { + code: BlockErrorCode.INVALID_STATE_ROOT, + root: stateRootAfterEnvelope, + expectedRoot: signedEnvelope.message.stateRoot, + preState: postBlockState, + postState: postEnvelopeState, + }); + } + + postEnvelopeStates.set(slot, postEnvelopeState); + } else { + postEnvelopeStates.set(slot, null); + } // If blocks are invalid in execution the main promise could resolve before this loop ends. // In that case stop processing blocks and return early. @@ -108,5 +180,5 @@ export async function verifyBlocksStateTransitionOnly( logger.debug("Verified block state transition", {slot, recvToValLatency, recvToValidation, validationTime}); } - return {postStates, proposerBalanceDeltas, verifyStateTime}; + return {postBlockStates, proposerBalanceDeltas, verifyStateTime, postEnvelopeStates}; } diff --git a/packages/state-transition/src/stateTransition.ts b/packages/state-transition/src/stateTransition.ts index d9993a2220ae..51679b4d31dd 100644 --- a/packages/state-transition/src/stateTransition.ts +++ b/packages/state-transition/src/stateTransition.ts @@ -77,6 +77,7 @@ export enum StateHashTreeRootSource { regenState = "regen_state", computeNewStateRoot = "compute_new_state_root", computeEnvelopeStateRoot = "compute_envelope_state_root", + envelopeTransition = "envelope_transition", } /** From 486f43b4dd43467e3cb1a5bdf44ce2e9f9fe5d5b Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Tue, 24 Mar 2026 11:00:52 +0700 Subject: [PATCH 05/22] feat: enhance verifyBlocksSignatures() for gloas --- .../src/chain/blocks/verifyBlock.ts | 2 + .../chain/blocks/verifyBlocksSignatures.ts | 42 +++++++++++++++---- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/verifyBlock.ts b/packages/beacon-node/src/chain/blocks/verifyBlock.ts index a6587fa575a9..611fadfd42bb 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlock.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlock.ts @@ -151,11 +151,13 @@ export async function verifyBlocksInEpoch( opts.skipVerifyBlockSignatures !== true ? verifyBlocksSignatures( this.config, + this.pubkeyCache, this.bls, this.logger, this.metrics, preState0, blocks, + envelopes, indexedAttestationsByBlock, opts ) diff --git a/packages/beacon-node/src/chain/blocks/verifyBlocksSignatures.ts b/packages/beacon-node/src/chain/blocks/verifyBlocksSignatures.ts index 4bb29912ae3c..cd5bacc10304 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlocksSignatures.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlocksSignatures.ts @@ -1,6 +1,11 @@ import {BeaconConfig} from "@lodestar/config"; -import {IBeaconStateView, getBlockSignatureSets} from "@lodestar/state-transition"; -import {IndexedAttestation, SignedBeaconBlock} from "@lodestar/types"; +import { + IBeaconStateView, + PubkeyCache, + getBlockSignatureSets, + getExecutionPayloadEnvelopeSignatureSet, +} from "@lodestar/state-transition"; +import {IndexedAttestation, SignedBeaconBlock, Slot, gloas} from "@lodestar/types"; import {Logger} from "@lodestar/utils"; import {Metrics} from "../../metrics/metrics.js"; import {nextEventLoop} from "../../util/eventLoop.js"; @@ -17,11 +22,13 @@ import {ImportBlockOpts} from "./types.js"; */ export async function verifyBlocksSignatures( config: BeaconConfig, + pubkeyCache: PubkeyCache, bls: IBlsVerifier, logger: Logger, metrics: Metrics | null, preState0: IBeaconStateView, blocks: SignedBeaconBlock[], + envelopes: Map | null, indexedAttestationsByBlock: IndexedAttestation[][], opts: ImportBlockOpts ): Promise<{verifySignaturesTime: number}> { @@ -38,13 +45,30 @@ export async function verifyBlocksSignatures( isValidPromises[i] = opts.validSignatures ? // Skip all signature verification Promise.resolve(true) - : // - // Verify signatures per block to track which block is invalid - bls.verifySignatureSets( - getBlockSignatureSets(config, currentSyncCommitteeIndexed, preState0, block, indexedAttestationsByBlock[i], { - skipProposerSignature: opts.validProposerSignature, - }) - ); + : // Verify signatures per block + envelope to track which block is invalid + (() => { + const signatureSets = getBlockSignatureSets( + config, + currentSyncCommitteeIndexed, + preState0, + block, + indexedAttestationsByBlock[i], + {skipProposerSignature: opts.validProposerSignature} + ); + const envelope = envelopes?.get(block.message.slot); + if (envelope) { + signatureSets.push( + getExecutionPayloadEnvelopeSignatureSet( + config, + pubkeyCache, + preState0, + envelope, + block.message.proposerIndex + ) + ); + } + return bls.verifySignatureSets(signatureSets); + })(); // getBlockSignatureSets() takes 45ms in benchmarks for 2022Q2 mainnet blocks (100 sigs). When syncing a 32 blocks // segments it will block the event loop for 1400 ms, which is too much. This call will allow the event loop to From 46f69a3a0317e839c4395753707a0bf904574b88 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Tue, 24 Mar 2026 11:31:55 +0700 Subject: [PATCH 06/22] feat: enhance importBlock() for gloas --- .../src/chain/blocks/importBlock.ts | 19 ++++++++++++++++++- .../beacon-node/src/chain/blocks/index.ts | 3 ++- .../beacon-node/src/chain/blocks/types.ts | 1 + 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index e19448131f58..63e338fa0c72 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -155,6 +155,23 @@ export async function importBlock( }); } + // For Gloas blocks whose envelope was pre-verified during state transition (sync/batch path), + // immediately transition the block to FULL status in fork choice and cache the payload state. + // Mirrors steps 6–7 of importExecutionPayload, but reuses the already-computed postEnvelopeState. + if (fullyVerifiedBlock.postEnvelopeState !== null) { + // TODO GLOAS: this.unfinalizedPayloadEnvelopeWrites.push(payloadInput) + // need a payloadInput in fullyVerifiedBlock + const {postEnvelopeState} = fullyVerifiedBlock; + this.regen.processPayloadState(postEnvelopeState); + this.forkChoice.onExecutionPayload( + blockRootHex, + toRootHex(postEnvelopeState.latestBlockHash), + // TODO GLOAS: this is not right but we don't need to track it as part of consensus spec, lighthouse also does not track it + 0, + toRootHex(postEnvelopeState.hashTreeRoot()) + ); + } + this.metrics?.importBlock.bySource.inc({source: source.source}); this.logger.verbose("Added block to forkchoice and state cache", {slot: blockSlot, root: blockRootHex}); @@ -479,7 +496,7 @@ export async function importBlock( } if (!postBlockState.isStateValidatorsNodesPopulated()) { - this.logger.verbose("After importBlock caching postState without SSZ cache", {slot: postBlockState.slot}); + this.logger.verbose("After importBlock caching postBlockState without SSZ cache", {slot: postBlockState.slot}); } // Cache shufflings when crossing an epoch boundary diff --git a/packages/beacon-node/src/chain/blocks/index.ts b/packages/beacon-node/src/chain/blocks/index.ts index e08b4f8d6323..0d7f942e7e98 100644 --- a/packages/beacon-node/src/chain/blocks/index.ts +++ b/packages/beacon-node/src/chain/blocks/index.ts @@ -87,6 +87,7 @@ export async function processBlocks( proposerBalanceDeltas, segmentExecStatus, indexedAttestationsByBlock, + postEnvelopeStates, } = await verifyBlocksInEpoch.call(this, parentBlock, relevantBlocks, envelopes, opts); // If segmentExecStatus has lvhForkchoice then, the entire segment should be invalid @@ -103,7 +104,7 @@ export async function processBlocks( (block, i): FullyVerifiedBlock => ({ blockInput: block, postBlockState: postBlockStates[i], - postEnvelopeState: null, + postEnvelopeState: postEnvelopeStates.get(block.getBlock().message.slot) ?? null, parentBlockSlot: parentSlots[i], executionStatus: executionStatuses[i], // start supporting optimistic syncing/processing diff --git a/packages/beacon-node/src/chain/blocks/types.ts b/packages/beacon-node/src/chain/blocks/types.ts index 8da3f46f8422..8862b3fbd215 100644 --- a/packages/beacon-node/src/chain/blocks/types.ts +++ b/packages/beacon-node/src/chain/blocks/types.ts @@ -91,6 +91,7 @@ export type ImportBlockOpts = { type FullyVerifiedBlockBase = { blockInput: IBlockInput; postBlockState: IBeaconStateView; + postEnvelopeState: IBeaconStateView | null; parentBlockSlot: Slot; proposerBalanceDelta: number; dataAvailabilityStatus: DataAvailabilityStatus; From 1dbc3effcbba08f420542479d88be912c11ed2e7 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Fri, 27 Mar 2026 09:37:56 +0700 Subject: [PATCH 07/22] chore: add TODO GLOAS comments --- packages/beacon-node/src/chain/blocks/verifyBlock.ts | 2 ++ packages/beacon-node/src/chain/chain.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/packages/beacon-node/src/chain/blocks/verifyBlock.ts b/packages/beacon-node/src/chain/blocks/verifyBlock.ts index 611fadfd42bb..b5283a315c9a 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlock.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlock.ts @@ -32,6 +32,7 @@ export async function verifyBlocksInEpoch( this: BeaconChain, parentBlock: ProtoBlock, blockInputs: IBlockInput[], + // TODO GLOAS: pass PayloadEnvelopeInput instead of gloas.SignedExecutionPayloadEnvelope envelopes: Map | null, opts: BlockProcessOpts & ImportBlockOpts ): Promise<{ @@ -130,6 +131,7 @@ export async function verifyBlocksInEpoch( verifyExecutionPayloadsPromise, // data availability for the blobs + // TODO GLOAS: modify this once we pass PayloadEnvelopeInput instead of gloas.SignedExecutionPayloadEnvelope verifyBlocksDataAvailability(blockInputs, abortController.signal), // Run state transition only diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index ef1b962bfbd0..5571fa28461a 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -1054,6 +1054,7 @@ export class BeaconChain implements IBeaconChain { async processChainSegment( blocks: IBlockInput[], + // TODO GLOAS: need PayloadEnvelopeInput instead for data availability check + persist in `importBlock` flow envelopes: Map | null, opts?: ImportBlockOpts ): Promise { From af63e3f74f7afc6456bb8e4e7b3e234dab16de96 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Fri, 27 Mar 2026 11:31:31 +0700 Subject: [PATCH 08/22] fix: use PayloadSeparated for gloas forkchoice.onBlock() --- .../src/chain/blocks/importBlock.ts | 3 ++- .../beacon-node/src/chain/blocks/index.ts | 24 ++++++++++++++----- .../beacon-node/src/chain/blocks/types.ts | 1 - 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index 63e338fa0c72..662e19176a8f 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -168,7 +168,8 @@ export async function importBlock( toRootHex(postEnvelopeState.latestBlockHash), // TODO GLOAS: this is not right but we don't need to track it as part of consensus spec, lighthouse also does not track it 0, - toRootHex(postEnvelopeState.hashTreeRoot()) + toRootHex(postEnvelopeState.hashTreeRoot()), + fullyVerifiedBlock.executionStatus ); } diff --git a/packages/beacon-node/src/chain/blocks/index.ts b/packages/beacon-node/src/chain/blocks/index.ts index 0d7f942e7e98..c73206532a73 100644 --- a/packages/beacon-node/src/chain/blocks/index.ts +++ b/packages/beacon-node/src/chain/blocks/index.ts @@ -1,3 +1,4 @@ +import {ExecutionStatus} from "@lodestar/fork-choice"; import {SignedBeaconBlock, Slot, gloas} from "@lodestar/types"; import {isErrorAborted, toRootHex} from "@lodestar/utils"; import {Metrics} from "../../metrics/metrics.js"; @@ -100,21 +101,32 @@ export async function processBlocks( } const {executionStatuses} = segmentExecStatus; - const fullyVerifiedBlocks = relevantBlocks.map( - (block, i): FullyVerifiedBlock => ({ + const fullyVerifiedBlocks = relevantBlocks.map((block, i): FullyVerifiedBlock => { + const postEnvelopeState = postEnvelopeStates.get(block.getBlock().message.slot) ?? null; + const executionStatus = executionStatuses[i]; + const baseFields = { blockInput: block, postBlockState: postBlockStates[i], - postEnvelopeState: postEnvelopeStates.get(block.getBlock().message.slot) ?? null, parentBlockSlot: parentSlots[i], - executionStatus: executionStatuses[i], // start supporting optimistic syncing/processing dataAvailabilityStatus: dataAvailabilityStatuses[i], proposerBalanceDelta: proposerBalanceDeltas[i], indexedAttestations: indexedAttestationsByBlock[i], // TODO: Make this param mandatory and capture in gossip seenTimestampSec: opts.seenTimestampSec ?? Math.floor(Date.now() / 1000), - }) - ); + }; + + if (postEnvelopeState !== null) { + if (executionStatus !== ExecutionStatus.Valid && executionStatus !== ExecutionStatus.Syncing) { + throw new Error( + `postEnvelopeState is set but executionStatus is ${executionStatus}, expected Valid or Syncing. slot=${block.getBlock().message.slot} blockIndex=${i}` + ); + } + return {...baseFields, postEnvelopeState, executionStatus}; + } + + return {...baseFields, postEnvelopeState: null, executionStatus}; + }); for (const fullyVerifiedBlock of fullyVerifiedBlocks) { // TODO: Consider batching importBlock too if it takes significant time diff --git a/packages/beacon-node/src/chain/blocks/types.ts b/packages/beacon-node/src/chain/blocks/types.ts index 8862b3fbd215..8da3f46f8422 100644 --- a/packages/beacon-node/src/chain/blocks/types.ts +++ b/packages/beacon-node/src/chain/blocks/types.ts @@ -91,7 +91,6 @@ export type ImportBlockOpts = { type FullyVerifiedBlockBase = { blockInput: IBlockInput; postBlockState: IBeaconStateView; - postEnvelopeState: IBeaconStateView | null; parentBlockSlot: Slot; proposerBalanceDelta: number; dataAvailabilityStatus: DataAvailabilityStatus; From dee75e568341aca75b6d35b6314a3b406d51ecb7 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Fri, 27 Mar 2026 13:35:22 +0700 Subject: [PATCH 09/22] feat: populate cp state cache --- packages/beacon-node/src/chain/blocks/importBlock.ts | 4 ++++ packages/beacon-node/src/chain/regen/queued.ts | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index 662e19176a8f..babed1da4611 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -163,6 +163,10 @@ export async function importBlock( // need a payloadInput in fullyVerifiedBlock const {postEnvelopeState} = fullyVerifiedBlock; this.regen.processPayloadState(postEnvelopeState); + if (postEnvelopeState.slot % SLOTS_PER_EPOCH === 0) { + const {checkpoint} = postEnvelopeState.computeAnchorCheckpoint(); + this.regen.addCheckpointState(checkpoint, postEnvelopeState, true); + } this.forkChoice.onExecutionPayload( blockRootHex, toRootHex(postEnvelopeState.latestBlockHash), diff --git a/packages/beacon-node/src/chain/regen/queued.ts b/packages/beacon-node/src/chain/regen/queued.ts index 43852a4a8b21..2ff150870b99 100644 --- a/packages/beacon-node/src/chain/regen/queued.ts +++ b/packages/beacon-node/src/chain/regen/queued.ts @@ -190,7 +190,6 @@ export class QueuedStateRegenerator implements IStateRegenerator { this.blockStateCache.add(payloadState); } - // TODO GLOAS: This should also be called when importing execution payload after we implement it addCheckpointState(cp: phase0.Checkpoint, item: IBeaconStateView, payloadPresent: boolean): void { this.checkpointStateCache.add(cp, item, payloadPresent); } From 63bf3c084122a46b40c16254c2e104c78b5d9847 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Fri, 27 Mar 2026 13:40:29 +0700 Subject: [PATCH 10/22] chore: type safe for verifyBlocksStateTransitionOnly() --- .../blocks/verifyBlocksStateTransitionOnly.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/verifyBlocksStateTransitionOnly.ts b/packages/beacon-node/src/chain/blocks/verifyBlocksStateTransitionOnly.ts index fc695b298348..1d0be30ba1b0 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlocksStateTransitionOnly.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlocksStateTransitionOnly.ts @@ -4,7 +4,7 @@ import { IBeaconStateView, StateHashTreeRootSource, } from "@lodestar/state-transition"; -import {Slot, gloas} from "@lodestar/types"; +import {Slot, gloas, isGloasBeaconBlock} from "@lodestar/types"; import {ErrorAborted, Logger, byteArrayEquals, toRootHex} from "@lodestar/utils"; import {Metrics} from "../../metrics/index.js"; import {nextEventLoop} from "../../util/eventLoop.js"; @@ -61,22 +61,22 @@ export async function verifyBlocksStateTransitionOnly( // this block's bid parentBlockHash, the proposer built on the FULL path if ( prevPostEnvelopeState != null && + isGloasBeaconBlock(block.message) && byteArrayEquals( prevPostEnvelopeState.latestBlockHash, - (block.message.body as gloas.BeaconBlockBody).signedExecutionPayloadBid.message.parentBlockHash + block.message.body.signedExecutionPayloadBid.message.parentBlockHash ) ) { + // gloas FULL path - use post-envelope state of previous block as pre-state for this block preState = prevPostEnvelopeState; } else { - // EMPTY path - if (prevPostEnvelopeState != null) { + // EMPTY path or pre-gloas block + if (prevPostEnvelopeState != null && isGloasBeaconBlock(block.message)) { // the envelope is orphaned logger.debug("Previous block had an execution payload envelope but this block did not build on it", { slot: block.message.slot, prevEnvelopeBlockHash: toRootHex(prevPostEnvelopeState.latestBlockHash), - currentBidParentHash: toRootHex( - (block.message.body as gloas.BeaconBlockBody).signedExecutionPayloadBid.message.parentBlockHash - ), + currentBidParentHash: toRootHex(block.message.body.signedExecutionPayloadBid.message.parentBlockHash), }); } preState = postBlockStates[i - 1]; From 33d7e4c2b22a43a3f9e59b77c5c0ffa7c036c485 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Tue, 31 Mar 2026 14:05:45 +0700 Subject: [PATCH 11/22] fix: handle payload exists in seen cache --- .../src/chain/blocks/importBlock.ts | 25 +++++++++++++------ .../blocks/verifyBlocksExecutionPayloads.ts | 2 +- .../seenCache/seenPayloadEnvelopeInput.ts | 11 ++++---- .../src/metrics/metrics/lodestar.ts | 6 ++--- 4 files changed, 28 insertions(+), 16 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index babed1da4611..877b5d4ca17d 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -140,19 +140,30 @@ export async function importBlock( // For Gloas blocks, create PayloadEnvelopeInput so it's available for later payload import if (fork >= ForkSeq.gloas) { - this.seenPayloadEnvelopeInputCache.add({ + const {created} = this.seenPayloadEnvelopeInputCache.add({ blockRootHex, block: block as SignedBeaconBlock, sampledColumns: this.custodyConfig.sampledColumns, custodyColumns: this.custodyConfig.custodyColumns, timeCreatedSec: fullyVerifiedBlock.seenTimestampSec, }); - this.logger.debug("Created PayloadEnvelopeInput for block", { - slot: blockSlot, - root: blockRootHex, - source: source.source, - ...(opts.seenTimestampSec !== undefined ? {recvToImport: Date.now() / 1000 - opts.seenTimestampSec} : {}), - }); + + // at gossip time, we usually create a PayloadEnvelopeInput here + // however, at range sync, we may have already created a PayloadEnvelopeInput + if (created) { + this.logger.debug("Created PayloadEnvelopeInput for block", { + slot: blockSlot, + root: blockRootHex, + source: source.source, + ...(opts.seenTimestampSec !== undefined ? {recvToImport: Date.now() / 1000 - opts.seenTimestampSec} : {}), + }); + } else { + this.logger.debug("PayloadEnvelopeInput already exists for block", { + slot: blockSlot, + root: blockRootHex, + source: source.source, + }); + } } // For Gloas blocks whose envelope was pre-verified during state transition (sync/batch path), diff --git a/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts b/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts index 74c427fd11e5..69633b2813fb 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts @@ -201,7 +201,7 @@ export async function verifyBlockExecutionPayload( ForkSeq[fork] >= ForkSeq.electra ? (block.message.body as electra.BeaconBlockBody).executionRequests : undefined; } - const logCtx = {slot: blockInput.slot, executionBlock: executionPayload.blockNumber}; + const logCtx = {slot: blockInput.slot, root: blockInput.blockRootHex, executionBlock: executionPayload.blockNumber}; chain.logger.verbose("Call engine api newPayload", logCtx); const execResult = await chain.executionEngine.notifyNewPayload( fork, diff --git a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts index 1932a4c38f42..790269a17b34 100644 --- a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts @@ -70,14 +70,15 @@ export class SeenPayloadEnvelopeInput { this.logger?.debug("SeenPayloadEnvelopeInput.onFinalized deleted cached entries", {deletedCount}); }; - add(props: CreateFromBlockProps): PayloadEnvelopeInput { - if (this.payloadInputs.has(props.blockRootHex)) { - throw new Error(`PayloadEnvelopeInput already exists for block ${props.blockRootHex}`); + add(props: CreateFromBlockProps): {input: PayloadEnvelopeInput; created: boolean} { + const existing = this.payloadInputs.get(props.blockRootHex); + if (existing !== undefined) { + return {input: existing, created: false}; } const input = PayloadEnvelopeInput.createFromBlock(props); this.payloadInputs.set(props.blockRootHex, input); - this.metrics?.seenCache.payloadEnvelopeInput.created.inc(); - return input; + this.metrics?.seenCache.payloadEnvelopeInput.createdByBlock.inc(); + return {input, created: true}; } get(blockRootHex: RootHex): PayloadEnvelopeInput | undefined { diff --git a/packages/beacon-node/src/metrics/metrics/lodestar.ts b/packages/beacon-node/src/metrics/metrics/lodestar.ts index 00087979fc9a..c7210fdafb83 100644 --- a/packages/beacon-node/src/metrics/metrics/lodestar.ts +++ b/packages/beacon-node/src/metrics/metrics/lodestar.ts @@ -1567,9 +1567,9 @@ export function createLodestarMetrics( name: "lodestar_seen_payload_envelope_input_cache_serialized_object_refs", help: "Number of serialized-cache object refs retained by cached PayloadEnvelopeInputs", }), - created: register.counter({ - name: "lodestar_seen_payload_envelope_input_cache_items_created_total", - help: "Number of PayloadEnvelopeInputs created", + createdByBlock: register.counter({ + name: "lodestar_seen_payload_envelope_input_cache_items_created_by_block_total", + help: "Number of PayloadEnvelopeInputs created via a block being processed first", }), }, }, From 1a79fc58c6b3889abd3c51f38682c8db6bc2854e Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Tue, 31 Mar 2026 14:21:17 +0700 Subject: [PATCH 12/22] fix: handle gloas block in getSegmentErrorResponse() --- .../chain/blocks/verifyBlocksExecutionPayloads.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts b/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts index 69633b2813fb..9d437ab38227 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts @@ -9,7 +9,7 @@ import { } from "@lodestar/fork-choice"; import {ForkSeq} from "@lodestar/params"; import {IBeaconStateView, isExecutionBlockBodyType} from "@lodestar/state-transition"; -import {ExecutionPayload, ExecutionRequests, Root, Slot, bellatrix, electra, gloas} from "@lodestar/types"; +import {ExecutionPayload, ExecutionRequests, Root, Slot, bellatrix, electra, gloas, isGloasBeaconBlock} from "@lodestar/types"; import {ErrorAborted, Logger, toRootHex} from "@lodestar/utils"; import {ExecutionPayloadStatus, IExecutionEngine, VersionedHashes} from "../../execution/engine/interface.js"; import {Metrics} from "../../metrics/metrics.js"; @@ -193,7 +193,7 @@ export async function verifyBlockExecutionPayload( } executionPayload = executionPayloadEnabled; - // TODO: Handle better notifyNewPayload() returning error is syncing + // TODO GLOAs: Handle better notifyNewPayload() returning error is syncing versionedHashes = isBlockInputBlobs(blockInput) || isBlockInputColumns(blockInput) ? blockInput.getVersionedHashes() : undefined; parentBlockRoot = ForkSeq[fork] >= ForkSeq.deneb ? block.message.parentRoot : undefined; @@ -288,10 +288,12 @@ function getSegmentErrorResponse( let lvhFound = false; for (let mayBeLVHIndex = blockIndex - 1; mayBeLVHIndex >= 0; mayBeLVHIndex--) { const block = blocks[mayBeLVHIndex].getBlock(); - if ( - toRootHex((block.message.body as bellatrix.BeaconBlockBody).executionPayload.blockHash) === - lvhResponse.latestValidExecHash - ) { + // For gloas blocks the payload may not arrive; fork choice tracks parentBlockHash + // from the bid as the executionPayloadBlockHash. Use the same field here. + const blockHashHex = isGloasBeaconBlock(block.message) + ? toRootHex(block.message.body.signedExecutionPayloadBid.message.parentBlockHash) + : toRootHex((block.message.body as bellatrix.BeaconBlockBody).executionPayload.blockHash); + if (blockHashHex === lvhResponse.latestValidExecHash) { lvhFound = true; break; } From 86d1d4b74856d13b301e89f00e2a03ba77a06432 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Tue, 31 Mar 2026 15:53:15 +0700 Subject: [PATCH 13/22] fix: lint --- .../src/chain/blocks/verifyBlocksExecutionPayloads.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts b/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts index 9d437ab38227..b90690ab37bb 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts @@ -9,7 +9,16 @@ import { } from "@lodestar/fork-choice"; import {ForkSeq} from "@lodestar/params"; import {IBeaconStateView, isExecutionBlockBodyType} from "@lodestar/state-transition"; -import {ExecutionPayload, ExecutionRequests, Root, Slot, bellatrix, electra, gloas, isGloasBeaconBlock} from "@lodestar/types"; +import { + ExecutionPayload, + ExecutionRequests, + Root, + Slot, + bellatrix, + electra, + gloas, + isGloasBeaconBlock, +} from "@lodestar/types"; import {ErrorAborted, Logger, toRootHex} from "@lodestar/utils"; import {ExecutionPayloadStatus, IExecutionEngine, VersionedHashes} from "../../execution/engine/interface.js"; import {Metrics} from "../../metrics/metrics.js"; From 6ee49c2f6f4004083d50421b0e7e116dc73e5e6a Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Thu, 2 Apr 2026 10:10:46 +0700 Subject: [PATCH 14/22] fix: correct block hash in getSegmentErrorResponse() --- .../chain/blocks/verifyBlocksExecutionPayloads.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts b/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts index b90690ab37bb..eb7ddc448378 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts @@ -112,7 +112,7 @@ export async function verifyBlocksExecutionPayload( // If execError has happened, then we need to extract the segmentExecStatus and return if (verifyResponse.execError !== null) { - return getSegmentErrorResponse({verifyResponse, blockIndex}, parentBlock, blockInputs); + return getSegmentErrorResponse({verifyResponse, blockIndex}, parentBlock, blockInputs, signedEnvelopes); } // If we are here then its because executionStatus is one of BlockExecutionStatus @@ -284,7 +284,8 @@ export async function verifyBlockExecutionPayload( function getSegmentErrorResponse( {verifyResponse, blockIndex}: {verifyResponse: VerifyExecutionErrorResponse; blockIndex: number}, parentBlock: ProtoBlock, - blocks: IBlockInput[] + blocks: IBlockInput[], + signedEnvelopes: Map | null ): SegmentExecStatus { const {executionStatus, lvhResponse, execError} = verifyResponse; let invalidSegmentLVH: LVHInvalidResponse | undefined = undefined; @@ -296,11 +297,13 @@ function getSegmentErrorResponse( ) { let lvhFound = false; for (let mayBeLVHIndex = blockIndex - 1; mayBeLVHIndex >= 0; mayBeLVHIndex--) { - const block = blocks[mayBeLVHIndex].getBlock(); - // For gloas blocks the payload may not arrive; fork choice tracks parentBlockHash - // from the bid as the executionPayloadBlockHash. Use the same field here. + const blockInput = blocks[mayBeLVHIndex]; + const block = blockInput.getBlock(); const blockHashHex = isGloasBeaconBlock(block.message) - ? toRootHex(block.message.body.signedExecutionPayloadBid.message.parentBlockHash) + ? toRootHex( + signedEnvelopes?.get(blockInput.slot)?.message.payload.blockHash ?? + block.message.body.signedExecutionPayloadBid.message.parentBlockHash + ) : toRootHex((block.message.body as bellatrix.BeaconBlockBody).executionPayload.blockHash); if (blockHashHex === lvhResponse.latestValidExecHash) { lvhFound = true; From 8d2a0710aca6e88b7ca529025b19ab503c5bbbbe Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Thu, 2 Apr 2026 10:26:35 +0700 Subject: [PATCH 15/22] fix: enhance FullyVerifiedBlock to store payload envelope --- .../src/chain/blocks/importBlock.ts | 3 +-- .../beacon-node/src/chain/blocks/index.ts | 7 +++--- .../beacon-node/src/chain/blocks/types.ts | 3 ++- .../src/chain/blocks/verifyBlock.ts | 5 +++- .../blocks/verifyBlocksStateTransitionOnly.ts | 24 ++++++++++++------- 5 files changed, 26 insertions(+), 16 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index 877b5d4ca17d..23c140de29c6 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -181,8 +181,7 @@ export async function importBlock( this.forkChoice.onExecutionPayload( blockRootHex, toRootHex(postEnvelopeState.latestBlockHash), - // TODO GLOAS: this is not right but we don't need to track it as part of consensus spec, lighthouse also does not track it - 0, + fullyVerifiedBlock.signedEnvelope.message.payload.blockNumber, toRootHex(postEnvelopeState.hashTreeRoot()), fullyVerifiedBlock.executionStatus ); diff --git a/packages/beacon-node/src/chain/blocks/index.ts b/packages/beacon-node/src/chain/blocks/index.ts index c73206532a73..9f0ccfeba687 100644 --- a/packages/beacon-node/src/chain/blocks/index.ts +++ b/packages/beacon-node/src/chain/blocks/index.ts @@ -102,7 +102,7 @@ export async function processBlocks( const {executionStatuses} = segmentExecStatus; const fullyVerifiedBlocks = relevantBlocks.map((block, i): FullyVerifiedBlock => { - const postEnvelopeState = postEnvelopeStates.get(block.getBlock().message.slot) ?? null; + const envelopeResult = postEnvelopeStates.get(block.getBlock().message.slot) ?? null; const executionStatus = executionStatuses[i]; const baseFields = { blockInput: block, @@ -116,13 +116,14 @@ export async function processBlocks( seenTimestampSec: opts.seenTimestampSec ?? Math.floor(Date.now() / 1000), }; - if (postEnvelopeState !== null) { + if (envelopeResult !== null) { + const {postEnvelopeState, signedEnvelope} = envelopeResult; if (executionStatus !== ExecutionStatus.Valid && executionStatus !== ExecutionStatus.Syncing) { throw new Error( `postEnvelopeState is set but executionStatus is ${executionStatus}, expected Valid or Syncing. slot=${block.getBlock().message.slot} blockIndex=${i}` ); } - return {...baseFields, postEnvelopeState, executionStatus}; + return {...baseFields, postEnvelopeState, signedEnvelope, executionStatus}; } return {...baseFields, postEnvelopeState: null, executionStatus}; diff --git a/packages/beacon-node/src/chain/blocks/types.ts b/packages/beacon-node/src/chain/blocks/types.ts index 8da3f46f8422..64b328b5a87c 100644 --- a/packages/beacon-node/src/chain/blocks/types.ts +++ b/packages/beacon-node/src/chain/blocks/types.ts @@ -2,7 +2,7 @@ import type {ChainForkConfig} from "@lodestar/config"; import {BlockExecutionStatus, PayloadExecutionStatus} from "@lodestar/fork-choice"; import {ForkSeq} from "@lodestar/params"; import {DataAvailabilityStatus, IBeaconStateView, computeEpochAtSlot} from "@lodestar/state-transition"; -import type {IndexedAttestation, Slot, fulu} from "@lodestar/types"; +import type {IndexedAttestation, Slot, fulu, gloas} from "@lodestar/types"; import {IBlockInput} from "./blockInput/types.js"; export enum GossipedInputType { @@ -117,6 +117,7 @@ export type FullyVerifiedBlock = FullyVerifiedBlockBase & } | { postEnvelopeState: IBeaconStateView; + signedEnvelope: gloas.SignedExecutionPayloadEnvelope; executionStatus: PayloadExecutionStatus; } ); diff --git a/packages/beacon-node/src/chain/blocks/verifyBlock.ts b/packages/beacon-node/src/chain/blocks/verifyBlock.ts index b5283a315c9a..ff9df95beb1b 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlock.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlock.ts @@ -41,7 +41,10 @@ export async function verifyBlocksInEpoch( segmentExecStatus: SegmentExecStatus; dataAvailabilityStatuses: DataAvailabilityStatus[]; indexedAttestationsByBlock: IndexedAttestation[][]; - postEnvelopeStates: Map; + postEnvelopeStates: Map< + Slot, + {postEnvelopeState: IBeaconStateView; signedEnvelope: gloas.SignedExecutionPayloadEnvelope} | null + >; }> { const blocks = blockInputs.map((blockInput) => blockInput.getBlock()); const lastBlock = blocks.at(-1); diff --git a/packages/beacon-node/src/chain/blocks/verifyBlocksStateTransitionOnly.ts b/packages/beacon-node/src/chain/blocks/verifyBlocksStateTransitionOnly.ts index 1d0be30ba1b0..1c4ed6e1d4e3 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlocksStateTransitionOnly.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlocksStateTransitionOnly.ts @@ -39,11 +39,17 @@ export async function verifyBlocksStateTransitionOnly( postBlockStates: IBeaconStateView[]; proposerBalanceDeltas: number[]; verifyStateTime: number; - postEnvelopeStates: Map; + postEnvelopeStates: Map< + Slot, + {postEnvelopeState: IBeaconStateView; signedEnvelope: gloas.SignedExecutionPayloadEnvelope} | null + >; }> { const postBlockStates: IBeaconStateView[] = []; const proposerBalanceDeltas: number[] = []; - const postEnvelopeStates = new Map(); + const postEnvelopeStates = new Map< + Slot, + {postEnvelopeState: IBeaconStateView; signedEnvelope: gloas.SignedExecutionPayloadEnvelope} | null + >(); const recvToValLatency = Date.now() / 1000 - (opts.seenTimestampSec ?? Date.now() / 1000); for (let i = 0; i < blocks.length; i++) { @@ -56,26 +62,26 @@ export async function verifyBlocksStateTransitionOnly( preState = preState0; } else { const prevSlot = blocks[i - 1].getBlock().message.slot; - const prevPostEnvelopeState = postEnvelopeStates.get(prevSlot); + const prevEnvelopeResult = postEnvelopeStates.get(prevSlot) ?? null; // If previous slot had an envelope and its latestBlockHash matches // this block's bid parentBlockHash, the proposer built on the FULL path if ( - prevPostEnvelopeState != null && + prevEnvelopeResult != null && isGloasBeaconBlock(block.message) && byteArrayEquals( - prevPostEnvelopeState.latestBlockHash, + prevEnvelopeResult.postEnvelopeState.latestBlockHash, block.message.body.signedExecutionPayloadBid.message.parentBlockHash ) ) { // gloas FULL path - use post-envelope state of previous block as pre-state for this block - preState = prevPostEnvelopeState; + preState = prevEnvelopeResult.postEnvelopeState; } else { // EMPTY path or pre-gloas block - if (prevPostEnvelopeState != null && isGloasBeaconBlock(block.message)) { + if (prevEnvelopeResult != null && isGloasBeaconBlock(block.message)) { // the envelope is orphaned logger.debug("Previous block had an execution payload envelope but this block did not build on it", { slot: block.message.slot, - prevEnvelopeBlockHash: toRootHex(prevPostEnvelopeState.latestBlockHash), + prevEnvelopeBlockHash: toRootHex(prevEnvelopeResult.postEnvelopeState.latestBlockHash), currentBidParentHash: toRootHex(block.message.body.signedExecutionPayloadBid.message.parentBlockHash), }); } @@ -151,7 +157,7 @@ export async function verifyBlocksStateTransitionOnly( }); } - postEnvelopeStates.set(slot, postEnvelopeState); + postEnvelopeStates.set(slot, {postEnvelopeState, signedEnvelope}); } else { postEnvelopeStates.set(slot, null); } From 42d30798ec149c0158ab1f017064f8bd9dc14ae0 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Thu, 2 Apr 2026 11:20:52 +0700 Subject: [PATCH 16/22] refactor: processChainSegment to accept PayloadEnvelopeInput --- .../src/chain/blocks/importBlock.ts | 27 ++++++------ .../beacon-node/src/chain/blocks/index.ts | 40 +++++++++--------- .../beacon-node/src/chain/blocks/types.ts | 10 +++-- .../src/chain/blocks/utils/chainSegment.ts | 15 ++++--- .../src/chain/blocks/verifyBlock.ts | 21 +++++----- .../blocks/verifyBlocksExecutionPayloads.ts | 12 +++--- .../chain/blocks/verifyBlocksSignatures.ts | 12 +++--- .../blocks/verifyBlocksStateTransitionOnly.ts | 40 +++++++++--------- packages/beacon-node/src/chain/chain.ts | 5 +-- packages/beacon-node/src/chain/interface.ts | 3 +- .../blocks/assertLinearChainSegment.test.ts | 42 ++++++++++++------- 11 files changed, 127 insertions(+), 100 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index 23c140de29c6..6af36a73ffaf 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -168,21 +168,24 @@ export async function importBlock( // For Gloas blocks whose envelope was pre-verified during state transition (sync/batch path), // immediately transition the block to FULL status in fork choice and cache the payload state. - // Mirrors steps 6–7 of importExecutionPayload, but reuses the already-computed postEnvelopeState. - if (fullyVerifiedBlock.postEnvelopeState !== null) { - // TODO GLOAS: this.unfinalizedPayloadEnvelopeWrites.push(payloadInput) - // need a payloadInput in fullyVerifiedBlock - const {postEnvelopeState} = fullyVerifiedBlock; - this.regen.processPayloadState(postEnvelopeState); - if (postEnvelopeState.slot % SLOTS_PER_EPOCH === 0) { - const {checkpoint} = postEnvelopeState.computeAnchorCheckpoint(); - this.regen.addCheckpointState(checkpoint, postEnvelopeState, true); + // Mirrors steps 6–7 of importExecutionPayload, but reuses the already-computed postPayloadEnvelopeState. + if (fullyVerifiedBlock.postPayloadEnvelopeState !== null) { + const {postPayloadEnvelopeState, payloadEnvelopeInput} = fullyVerifiedBlock; + this.unfinalizedPayloadEnvelopeWrites.push(payloadEnvelopeInput).catch((e) => { + if (!isQueueErrorAborted(e)) { + this.logger.error("Error pushing payload envelope to write queue", {slot: blockSlot}, e as Error); + } + }); + this.regen.processPayloadState(postPayloadEnvelopeState); + if (postPayloadEnvelopeState.slot % SLOTS_PER_EPOCH === 0) { + const {checkpoint} = postPayloadEnvelopeState.computeAnchorCheckpoint(); + this.regen.addCheckpointState(checkpoint, postPayloadEnvelopeState, true); } this.forkChoice.onExecutionPayload( blockRootHex, - toRootHex(postEnvelopeState.latestBlockHash), - fullyVerifiedBlock.signedEnvelope.message.payload.blockNumber, - toRootHex(postEnvelopeState.hashTreeRoot()), + toRootHex(postPayloadEnvelopeState.latestBlockHash), + fullyVerifiedBlock.payloadEnvelope.message.payload.blockNumber, + toRootHex(postPayloadEnvelopeState.hashTreeRoot()), fullyVerifiedBlock.executionStatus ); } diff --git a/packages/beacon-node/src/chain/blocks/index.ts b/packages/beacon-node/src/chain/blocks/index.ts index 9f0ccfeba687..aaee4c621e79 100644 --- a/packages/beacon-node/src/chain/blocks/index.ts +++ b/packages/beacon-node/src/chain/blocks/index.ts @@ -1,5 +1,5 @@ import {ExecutionStatus} from "@lodestar/fork-choice"; -import {SignedBeaconBlock, Slot, gloas} from "@lodestar/types"; +import {SignedBeaconBlock, Slot} from "@lodestar/types"; import {isErrorAborted, toRootHex} from "@lodestar/utils"; import {Metrics} from "../../metrics/metrics.js"; import {nextEventLoop} from "../../util/eventLoop.js"; @@ -9,6 +9,7 @@ import {BlockError, BlockErrorCode, isBlockErrorAborted} from "../errors/index.j import {BlockProcessOpts} from "../options.js"; import {IBlockInput} from "./blockInput/types.js"; import {importBlock} from "./importBlock.js"; +import {PayloadEnvelopeInput} from "./payloadEnvelopeInput/payloadEnvelopeInput.js"; import {FullyVerifiedBlock, ImportBlockOpts} from "./types.js"; import {assertLinearChainSegment} from "./utils/chainSegment.js"; import {verifyBlocksInEpoch} from "./verifyBlock.js"; @@ -22,16 +23,10 @@ const QUEUE_MAX_LENGTH = 256; * BlockProcessor processes block jobs in a queued fashion, one after the other. */ export class BlockProcessor { - readonly jobQueue: JobItemQueue< - [IBlockInput[], Map | null, ImportBlockOpts], - void - >; + readonly jobQueue: JobItemQueue<[IBlockInput[], Map | null, ImportBlockOpts], void>; constructor(chain: BeaconChain, metrics: Metrics | null, opts: BlockProcessOpts, signal: AbortSignal) { - this.jobQueue = new JobItemQueue< - [IBlockInput[], Map | null, ImportBlockOpts], - void - >( + this.jobQueue = new JobItemQueue<[IBlockInput[], Map | null, ImportBlockOpts], void>( (job, envelopes, importOpts) => { return processBlocks.call(chain, job, envelopes, {...opts, ...importOpts}); }, @@ -42,7 +37,7 @@ export class BlockProcessor { async processBlocksJob( job: IBlockInput[], - envelopes: Map | null, + envelopes: Map | null, opts: ImportBlockOpts = {} ): Promise { await this.jobQueue.push(job, envelopes, opts); @@ -62,7 +57,7 @@ export class BlockProcessor { export async function processBlocks( this: BeaconChain, blocks: IBlockInput[], - envelopes: Map | null, + payloadEnvelopes: Map | null, opts: BlockProcessOpts & ImportBlockOpts ): Promise { if (blocks.length === 0) { @@ -78,7 +73,7 @@ export async function processBlocks( return; } - assertLinearChainSegment(this.config, relevantBlocks, envelopes, parentBlock); + assertLinearChainSegment(this.config, relevantBlocks, payloadEnvelopes, parentBlock); // Fully verify a block to be imported immediately after. Does not produce any side-effects besides adding intermediate // states in the state cache through regen. @@ -88,8 +83,8 @@ export async function processBlocks( proposerBalanceDeltas, segmentExecStatus, indexedAttestationsByBlock, - postEnvelopeStates, - } = await verifyBlocksInEpoch.call(this, parentBlock, relevantBlocks, envelopes, opts); + postPayloadEnvelopeStates, + } = await verifyBlocksInEpoch.call(this, parentBlock, relevantBlocks, payloadEnvelopes, opts); // If segmentExecStatus has lvhForkchoice then, the entire segment should be invalid // and we need to further propagate @@ -102,7 +97,8 @@ export async function processBlocks( const {executionStatuses} = segmentExecStatus; const fullyVerifiedBlocks = relevantBlocks.map((block, i): FullyVerifiedBlock => { - const envelopeResult = postEnvelopeStates.get(block.getBlock().message.slot) ?? null; + const slot = block.getBlock().message.slot; + const payloadEnvelopeResult = postPayloadEnvelopeStates.get(slot) ?? null; const executionStatus = executionStatuses[i]; const baseFields = { blockInput: block, @@ -116,17 +112,21 @@ export async function processBlocks( seenTimestampSec: opts.seenTimestampSec ?? Math.floor(Date.now() / 1000), }; - if (envelopeResult !== null) { - const {postEnvelopeState, signedEnvelope} = envelopeResult; + if (payloadEnvelopeResult !== null) { + const {postPayloadEnvelopeState, payloadEnvelope} = payloadEnvelopeResult; if (executionStatus !== ExecutionStatus.Valid && executionStatus !== ExecutionStatus.Syncing) { throw new Error( - `postEnvelopeState is set but executionStatus is ${executionStatus}, expected Valid or Syncing. slot=${block.getBlock().message.slot} blockIndex=${i}` + `postPayloadEnvelopeState is set but executionStatus is ${executionStatus}, expected Valid or Syncing. slot=${slot} blockIndex=${i}` ); } - return {...baseFields, postEnvelopeState, signedEnvelope, executionStatus}; + const payloadEnvelopeInput = payloadEnvelopes?.get(slot); + if (!payloadEnvelopeInput) { + throw new Error(`Expected PayloadEnvelopeInput for slot ${slot} with payloadEnvelopeResult`); + } + return {...baseFields, postPayloadEnvelopeState, payloadEnvelope, payloadEnvelopeInput, executionStatus}; } - return {...baseFields, postEnvelopeState: null, executionStatus}; + return {...baseFields, postPayloadEnvelopeState: null, executionStatus}; }); for (const fullyVerifiedBlock of fullyVerifiedBlocks) { diff --git a/packages/beacon-node/src/chain/blocks/types.ts b/packages/beacon-node/src/chain/blocks/types.ts index 64b328b5a87c..45961d6660ad 100644 --- a/packages/beacon-node/src/chain/blocks/types.ts +++ b/packages/beacon-node/src/chain/blocks/types.ts @@ -4,6 +4,7 @@ import {ForkSeq} from "@lodestar/params"; import {DataAvailabilityStatus, IBeaconStateView, computeEpochAtSlot} from "@lodestar/state-transition"; import type {IndexedAttestation, Slot, fulu, gloas} from "@lodestar/types"; import {IBlockInput} from "./blockInput/types.js"; +import type {PayloadEnvelopeInput} from "./payloadEnvelopeInput/payloadEnvelopeInput.js"; export enum GossipedInputType { block = "block", @@ -103,7 +104,7 @@ type FullyVerifiedBlockBase = { /** * A wrapper around a `SignedBeaconBlock` that indicates that this block is fully verified and ready to import. * - * Discriminated union on `postEnvelopeState`: + * Discriminated union on `postPayloadEnvelopeState`: * - `null` → block has no pre-verified envelope; `executionStatus` is any `BlockExecutionStatus` * - non-null → envelope was pre-verified during state transition; `executionStatus` is narrowed to * `Valid | Syncing` (matching what `forkChoice.onExecutionPayload` expects) @@ -111,13 +112,14 @@ type FullyVerifiedBlockBase = { export type FullyVerifiedBlock = FullyVerifiedBlockBase & ( | { - postEnvelopeState: null; + postPayloadEnvelopeState: null; /** If the execution payload couldn't be verified because of EL syncing status, used in optimistic sync or for merge block */ executionStatus: BlockExecutionStatus; } | { - postEnvelopeState: IBeaconStateView; - signedEnvelope: gloas.SignedExecutionPayloadEnvelope; + postPayloadEnvelopeState: IBeaconStateView; + payloadEnvelope: gloas.SignedExecutionPayloadEnvelope; + payloadEnvelopeInput: PayloadEnvelopeInput; executionStatus: PayloadExecutionStatus; } ); diff --git a/packages/beacon-node/src/chain/blocks/utils/chainSegment.ts b/packages/beacon-node/src/chain/blocks/utils/chainSegment.ts index ebebc2199155..2ca5d543e1bd 100644 --- a/packages/beacon-node/src/chain/blocks/utils/chainSegment.ts +++ b/packages/beacon-node/src/chain/blocks/utils/chainSegment.ts @@ -1,9 +1,10 @@ import {ChainForkConfig} from "@lodestar/config"; import {ProtoBlock} from "@lodestar/fork-choice"; -import {Slot, gloas, isGloasBeaconBlock, ssz} from "@lodestar/types"; +import {Slot, isGloasBeaconBlock, ssz} from "@lodestar/types"; import {toRootHex} from "@lodestar/utils"; import {BlockError, BlockErrorCode} from "../../errors/index.js"; import {IBlockInput} from "../blockInput/types.js"; +import {PayloadEnvelopeInput} from "../payloadEnvelopeInput/payloadEnvelopeInput.js"; /** * Assert this chain segment of blocks is linear with slot numbers and hashes, @@ -21,7 +22,7 @@ import {IBlockInput} from "../blockInput/types.js"; export function assertLinearChainSegment( config: ChainForkConfig, blocks: IBlockInput[], - envelopes: Map | null, + payloadEnvelopes: Map | null, parentBlock: ProtoBlock ): void { // Track the expected execution payload block hash through the segment. @@ -64,11 +65,13 @@ export function assertLinearChainSegment( }); } - const envelope = envelopes?.get(slot); - if (envelope !== undefined) { + const payloadEnvelope = payloadEnvelopes?.get(slot)?.hasPayloadEnvelope() + ? payloadEnvelopes.get(slot)!.getPayloadEnvelope() + : undefined; + if (payloadEnvelope !== undefined && payloadEnvelope !== null) { // Verify the envelope references this block's root const blockRoot = toRootHex(config.getForkTypes(slot).BeaconBlock.hashTreeRoot(block.message)); - const envelopeBlockRoot = toRootHex(envelope.message.beaconBlockRoot); + const envelopeBlockRoot = toRootHex(payloadEnvelope.message.beaconBlockRoot); if (blockRoot !== envelopeBlockRoot) { throw new BlockError(block, { code: BlockErrorCode.ENVELOPE_BEACON_BLOCK_ROOT_MISMATCH, @@ -78,7 +81,7 @@ export function assertLinearChainSegment( } // FULL variant: advance execution hash to the delivered payload's block hash - currentExecHash = toRootHex(envelope.message.payload.blockHash); + currentExecHash = toRootHex(payloadEnvelope.message.payload.blockHash); } // EMPTY variant: currentExecHash unchanged } diff --git a/packages/beacon-node/src/chain/blocks/verifyBlock.ts b/packages/beacon-node/src/chain/blocks/verifyBlock.ts index ff9df95beb1b..d552c2b4167f 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlock.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlock.ts @@ -7,6 +7,7 @@ import {BlockError, BlockErrorCode} from "../errors/index.js"; import {BlockProcessOpts} from "../options.js"; import {RegenCaller} from "../regen/index.js"; import {DAType, IBlockInput} from "./blockInput/index.js"; +import {PayloadEnvelopeInput} from "./payloadEnvelopeInput/payloadEnvelopeInput.js"; import {ImportBlockOpts} from "./types.js"; import {DENEB_BLOWFISH_BANNER} from "./utils/blowfishBanner.js"; import {ELECTRA_GIRAFFE_BANNER} from "./utils/giraffeBanner.js"; @@ -32,8 +33,7 @@ export async function verifyBlocksInEpoch( this: BeaconChain, parentBlock: ProtoBlock, blockInputs: IBlockInput[], - // TODO GLOAS: pass PayloadEnvelopeInput instead of gloas.SignedExecutionPayloadEnvelope - envelopes: Map | null, + payloadEnvelopes: Map | null, opts: BlockProcessOpts & ImportBlockOpts ): Promise<{ postBlockStates: IBeaconStateView[]; @@ -41,9 +41,9 @@ export async function verifyBlocksInEpoch( segmentExecStatus: SegmentExecStatus; dataAvailabilityStatuses: DataAvailabilityStatus[]; indexedAttestationsByBlock: IndexedAttestation[][]; - postEnvelopeStates: Map< + postPayloadEnvelopeStates: Map< Slot, - {postEnvelopeState: IBeaconStateView; signedEnvelope: gloas.SignedExecutionPayloadEnvelope} | null + {postPayloadEnvelopeState: IBeaconStateView; payloadEnvelope: gloas.SignedExecutionPayloadEnvelope} | null >; }> { const blocks = blockInputs.map((blockInput) => blockInput.getBlock()); @@ -104,7 +104,7 @@ export async function verifyBlocksInEpoch( this, parentBlock, blockInputs, - envelopes, + payloadEnvelopes, preState0, abortController.signal, opts @@ -128,13 +128,14 @@ export async function verifyBlocksInEpoch( const [ segmentExecStatus, {dataAvailabilityStatuses, availableTime}, - {postBlockStates, proposerBalanceDeltas, verifyStateTime, postEnvelopeStates}, + {postBlockStates, proposerBalanceDeltas, verifyStateTime, postPayloadEnvelopeStates}, {verifySignaturesTime}, ] = await Promise.all([ verifyExecutionPayloadsPromise, // data availability for the blobs - // TODO GLOAS: modify this once we pass PayloadEnvelopeInput instead of gloas.SignedExecutionPayloadEnvelope + // TODO GLOAS: modify this once we have verifyPayloadDataAvailability() + // see https://github.com/ChainSafe/lodestar/issues/9150 verifyBlocksDataAvailability(blockInputs, abortController.signal), // Run state transition only @@ -142,7 +143,7 @@ export async function verifyBlocksInEpoch( verifyBlocksStateTransitionOnly( preState0, blockInputs, - envelopes, + payloadEnvelopes, // hack availability for state transition eval as availability is separately determined blocks.map(() => DataAvailabilityStatus.Available), this.logger, @@ -162,7 +163,7 @@ export async function verifyBlocksInEpoch( this.metrics, preState0, blocks, - envelopes, + payloadEnvelopes, indexedAttestationsByBlock, opts ) @@ -253,7 +254,7 @@ export async function verifyBlocksInEpoch( proposerBalanceDeltas, segmentExecStatus, indexedAttestationsByBlock, - postEnvelopeStates, + postPayloadEnvelopeStates, }; } finally { abortController.abort(); diff --git a/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts b/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts index eb7ddc448378..caf34b38f394 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts @@ -28,6 +28,7 @@ import {BlockError, BlockErrorCode} from "../errors/index.js"; import {BlockProcessOpts} from "../options.js"; import {isBlockInputBlobs, isBlockInputColumns, isBlockInputNoData} from "./blockInput/blockInput.js"; import {IBlockInput} from "./blockInput/types.js"; +import {PayloadEnvelopeInput} from "./payloadEnvelopeInput/payloadEnvelopeInput.js"; import {ImportBlockOpts} from "./types.js"; export type VerifyBlockExecutionPayloadModules = { @@ -68,7 +69,7 @@ export async function verifyBlocksExecutionPayload( chain: VerifyBlockExecutionPayloadModules, parentBlock: ProtoBlock, blockInputs: IBlockInput[], - signedEnvelopes: Map | null, + payloadEnvelopes: Map | null, preState0: IBeaconStateView, signal: AbortSignal, opts: BlockProcessOpts & ImportBlockOpts @@ -107,12 +108,13 @@ export async function verifyBlocksExecutionPayload( if (signal.aborted) { throw new ErrorAborted("verifyBlockExecutionPayloads"); } - const signedEnvelope = signedEnvelopes?.get(blockInput.slot) ?? null; + const payloadInput = payloadEnvelopes?.get(blockInput.slot) ?? null; + const signedEnvelope = payloadInput?.hasPayloadEnvelope() ? payloadInput.getPayloadEnvelope() : null; const verifyResponse = await verifyBlockExecutionPayload(chain, blockInput, signedEnvelope, preState0); // If execError has happened, then we need to extract the segmentExecStatus and return if (verifyResponse.execError !== null) { - return getSegmentErrorResponse({verifyResponse, blockIndex}, parentBlock, blockInputs, signedEnvelopes); + return getSegmentErrorResponse({verifyResponse, blockIndex}, parentBlock, blockInputs, payloadEnvelopes); } // If we are here then its because executionStatus is one of BlockExecutionStatus @@ -285,7 +287,7 @@ function getSegmentErrorResponse( {verifyResponse, blockIndex}: {verifyResponse: VerifyExecutionErrorResponse; blockIndex: number}, parentBlock: ProtoBlock, blocks: IBlockInput[], - signedEnvelopes: Map | null + payloadEnvelopes: Map | null ): SegmentExecStatus { const {executionStatus, lvhResponse, execError} = verifyResponse; let invalidSegmentLVH: LVHInvalidResponse | undefined = undefined; @@ -301,7 +303,7 @@ function getSegmentErrorResponse( const block = blockInput.getBlock(); const blockHashHex = isGloasBeaconBlock(block.message) ? toRootHex( - signedEnvelopes?.get(blockInput.slot)?.message.payload.blockHash ?? + payloadEnvelopes?.get(blockInput.slot)?.getPayloadEnvelope()?.message.payload.blockHash ?? block.message.body.signedExecutionPayloadBid.message.parentBlockHash ) : toRootHex((block.message.body as bellatrix.BeaconBlockBody).executionPayload.blockHash); diff --git a/packages/beacon-node/src/chain/blocks/verifyBlocksSignatures.ts b/packages/beacon-node/src/chain/blocks/verifyBlocksSignatures.ts index cd5bacc10304..5561eea76876 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlocksSignatures.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlocksSignatures.ts @@ -5,12 +5,13 @@ import { getBlockSignatureSets, getExecutionPayloadEnvelopeSignatureSet, } from "@lodestar/state-transition"; -import {IndexedAttestation, SignedBeaconBlock, Slot, gloas} from "@lodestar/types"; +import {IndexedAttestation, SignedBeaconBlock, Slot} from "@lodestar/types"; import {Logger} from "@lodestar/utils"; import {Metrics} from "../../metrics/metrics.js"; import {nextEventLoop} from "../../util/eventLoop.js"; import {IBlsVerifier} from "../bls/index.js"; import {BlockError, BlockErrorCode} from "../errors/blockError.js"; +import {PayloadEnvelopeInput} from "./payloadEnvelopeInput/payloadEnvelopeInput.js"; import {ImportBlockOpts} from "./types.js"; /** @@ -28,7 +29,7 @@ export async function verifyBlocksSignatures( metrics: Metrics | null, preState0: IBeaconStateView, blocks: SignedBeaconBlock[], - envelopes: Map | null, + payloadEnvelopes: Map | null, indexedAttestationsByBlock: IndexedAttestation[][], opts: ImportBlockOpts ): Promise<{verifySignaturesTime: number}> { @@ -55,14 +56,15 @@ export async function verifyBlocksSignatures( indexedAttestationsByBlock[i], {skipProposerSignature: opts.validProposerSignature} ); - const envelope = envelopes?.get(block.message.slot); - if (envelope) { + const payloadInput = payloadEnvelopes?.get(block.message.slot); + const payloadEnvelope = payloadInput?.hasPayloadEnvelope() ? payloadInput.getPayloadEnvelope() : undefined; + if (payloadEnvelope) { signatureSets.push( getExecutionPayloadEnvelopeSignatureSet( config, pubkeyCache, preState0, - envelope, + payloadEnvelope, block.message.proposerIndex ) ); diff --git a/packages/beacon-node/src/chain/blocks/verifyBlocksStateTransitionOnly.ts b/packages/beacon-node/src/chain/blocks/verifyBlocksStateTransitionOnly.ts index 1c4ed6e1d4e3..91f00122af9f 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlocksStateTransitionOnly.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlocksStateTransitionOnly.ts @@ -12,6 +12,7 @@ import {BlockError, BlockErrorCode} from "../errors/index.js"; import {BlockProcessOpts} from "../options.js"; import {ValidatorMonitor} from "../validatorMonitor.js"; import {IBlockInput} from "./blockInput/index.js"; +import {PayloadEnvelopeInput} from "./payloadEnvelopeInput/payloadEnvelopeInput.js"; import {ImportBlockOpts} from "./types.js"; /** @@ -28,7 +29,7 @@ import {ImportBlockOpts} from "./types.js"; export async function verifyBlocksStateTransitionOnly( preState0: IBeaconStateView, blocks: IBlockInput[], - envelopes: Map | null, + payloadEnvelopes: Map | null, dataAvailabilityStatuses: DataAvailabilityStatus[], logger: Logger, metrics: Metrics | null, @@ -39,16 +40,16 @@ export async function verifyBlocksStateTransitionOnly( postBlockStates: IBeaconStateView[]; proposerBalanceDeltas: number[]; verifyStateTime: number; - postEnvelopeStates: Map< + postPayloadEnvelopeStates: Map< Slot, - {postEnvelopeState: IBeaconStateView; signedEnvelope: gloas.SignedExecutionPayloadEnvelope} | null + {postPayloadEnvelopeState: IBeaconStateView; payloadEnvelope: gloas.SignedExecutionPayloadEnvelope} | null >; }> { const postBlockStates: IBeaconStateView[] = []; const proposerBalanceDeltas: number[] = []; - const postEnvelopeStates = new Map< + const postPayloadEnvelopeStates = new Map< Slot, - {postEnvelopeState: IBeaconStateView; signedEnvelope: gloas.SignedExecutionPayloadEnvelope} | null + {postPayloadEnvelopeState: IBeaconStateView; payloadEnvelope: gloas.SignedExecutionPayloadEnvelope} | null >(); const recvToValLatency = Date.now() / 1000 - (opts.seenTimestampSec ?? Date.now() / 1000); @@ -62,26 +63,26 @@ export async function verifyBlocksStateTransitionOnly( preState = preState0; } else { const prevSlot = blocks[i - 1].getBlock().message.slot; - const prevEnvelopeResult = postEnvelopeStates.get(prevSlot) ?? null; + const prevEnvelopeResult = postPayloadEnvelopeStates.get(prevSlot) ?? null; // If previous slot had an envelope and its latestBlockHash matches // this block's bid parentBlockHash, the proposer built on the FULL path if ( prevEnvelopeResult != null && isGloasBeaconBlock(block.message) && byteArrayEquals( - prevEnvelopeResult.postEnvelopeState.latestBlockHash, + prevEnvelopeResult.postPayloadEnvelopeState.latestBlockHash, block.message.body.signedExecutionPayloadBid.message.parentBlockHash ) ) { // gloas FULL path - use post-envelope state of previous block as pre-state for this block - preState = prevEnvelopeResult.postEnvelopeState; + preState = prevEnvelopeResult.postPayloadEnvelopeState; } else { // EMPTY path or pre-gloas block if (prevEnvelopeResult != null && isGloasBeaconBlock(block.message)) { // the envelope is orphaned logger.debug("Previous block had an execution payload envelope but this block did not build on it", { slot: block.message.slot, - prevEnvelopeBlockHash: toRootHex(prevEnvelopeResult.postEnvelopeState.latestBlockHash), + prevEnvelopeBlockHash: toRootHex(prevEnvelopeResult.postPayloadEnvelopeState.latestBlockHash), currentBidParentHash: toRootHex(block.message.body.signedExecutionPayloadBid.message.parentBlockHash), }); } @@ -133,10 +134,11 @@ export async function verifyBlocksStateTransitionOnly( proposerBalanceDeltas[i] = postBlockState.getBalance(proposerIndex) - preState.getBalance(proposerIndex); const slot = block.message.slot; - const signedEnvelope = envelopes?.get(slot) ?? null; - if (signedEnvelope !== null) { + const payloadInput = payloadEnvelopes?.get(slot) ?? null; + const payloadEnvelope = payloadInput?.hasPayloadEnvelope() ? payloadInput.getPayloadEnvelope() : null; + if (payloadEnvelope !== null) { // verifyStateRoot: false — we verify manually below with BlockError for proper error typing - const postEnvelopeState = postBlockState.processExecutionPayloadEnvelope(signedEnvelope, { + const postPayloadEnvelopeState = postBlockState.processExecutionPayloadEnvelope(payloadEnvelope, { verifySignature: false, verifyStateRoot: false, }); @@ -144,22 +146,22 @@ export async function verifyBlocksStateTransitionOnly( const hashTreeRootTimerEnvelope = metrics?.stateHashTreeRootTime.startTimer({ source: StateHashTreeRootSource.envelopeTransition, }); - const stateRootAfterEnvelope = postEnvelopeState.hashTreeRoot(); + const stateRootAfterEnvelope = postPayloadEnvelopeState.hashTreeRoot(); hashTreeRootTimerEnvelope?.(); - if (!byteArrayEquals(signedEnvelope.message.stateRoot, stateRootAfterEnvelope)) { + if (!byteArrayEquals(payloadEnvelope.message.stateRoot, stateRootAfterEnvelope)) { throw new BlockError(block, { code: BlockErrorCode.INVALID_STATE_ROOT, root: stateRootAfterEnvelope, - expectedRoot: signedEnvelope.message.stateRoot, + expectedRoot: payloadEnvelope.message.stateRoot, preState: postBlockState, - postState: postEnvelopeState, + postState: postPayloadEnvelopeState, }); } - postEnvelopeStates.set(slot, {postEnvelopeState, signedEnvelope}); + postPayloadEnvelopeStates.set(slot, {postPayloadEnvelopeState, payloadEnvelope}); } else { - postEnvelopeStates.set(slot, null); + postPayloadEnvelopeStates.set(slot, null); } // If blocks are invalid in execution the main promise could resolve before this loop ends. @@ -186,5 +188,5 @@ export async function verifyBlocksStateTransitionOnly( logger.debug("Verified block state transition", {slot, recvToValLatency, recvToValidation, validationTime}); } - return {postBlockStates, proposerBalanceDeltas, verifyStateTime, postEnvelopeStates}; + return {postBlockStates, proposerBalanceDeltas, verifyStateTime, postPayloadEnvelopeStates}; } diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 5571fa28461a..d4d8f2d4523f 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -1054,11 +1054,10 @@ export class BeaconChain implements IBeaconChain { async processChainSegment( blocks: IBlockInput[], - // TODO GLOAS: need PayloadEnvelopeInput instead for data availability check + persist in `importBlock` flow - envelopes: Map | null, + payloadEnvelopes: Map | null, opts?: ImportBlockOpts ): Promise { - return this.blockProcessor.processBlocksJob(blocks, envelopes, opts); + return this.blockProcessor.processBlocksJob(blocks, payloadEnvelopes, opts); } async processExecutionPayload(payloadInput: PayloadEnvelopeInput, opts?: ImportPayloadOpts): Promise { diff --git a/packages/beacon-node/src/chain/interface.ts b/packages/beacon-node/src/chain/interface.ts index 4a6585de919e..5c1484a630f5 100644 --- a/packages/beacon-node/src/chain/interface.ts +++ b/packages/beacon-node/src/chain/interface.ts @@ -18,7 +18,6 @@ import { altair, capella, deneb, - gloas, phase0, rewards, } from "@lodestar/types"; @@ -244,7 +243,7 @@ export interface IBeaconChain { /** Process a chain of blocks until complete */ processChainSegment( blocks: IBlockInput[], - envelopes: Map | null, + payloadEnvelopes: Map | null, opts?: ImportBlockOpts ): Promise; diff --git a/packages/beacon-node/test/unit/chain/blocks/assertLinearChainSegment.test.ts b/packages/beacon-node/test/unit/chain/blocks/assertLinearChainSegment.test.ts index 5ed2be81a49d..85080e436bd8 100644 --- a/packages/beacon-node/test/unit/chain/blocks/assertLinearChainSegment.test.ts +++ b/packages/beacon-node/test/unit/chain/blocks/assertLinearChainSegment.test.ts @@ -7,6 +7,8 @@ import {Slot, gloas, ssz} from "@lodestar/types"; import {toRootHex} from "@lodestar/utils"; import {BlockInputNoData} from "../../../../src/chain/blocks/blockInput/blockInput.js"; import {BlockInputSource, IBlockInput} from "../../../../src/chain/blocks/blockInput/types.js"; +import {PayloadEnvelopeInput} from "../../../../src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.js"; +import {PayloadEnvelopeInputSource} from "../../../../src/chain/blocks/payloadEnvelopeInput/types.js"; import {assertLinearChainSegment} from "../../../../src/chain/blocks/utils/chainSegment.js"; import {BlockErrorCode} from "../../../../src/chain/errors/index.js"; import {expectThrowsLodestarError} from "../../../utils/errors.js"; @@ -50,13 +52,22 @@ function blockRoot(blockInput: IBlockInput): Uint8Array { return config.getForkTypes(block.message.slot).BeaconBlock.hashTreeRoot(block.message); } -/** Build a valid envelope that references the given block and carries a given payload block hash */ -function makeEnvelope(blockInput: IBlockInput, payloadBlockHash: Uint8Array): gloas.SignedExecutionPayloadEnvelope { +/** Build a PayloadEnvelopeInput that references the given block and carries a given payload block hash */ +function makePayloadEnvelopeInput(blockInput: IBlockInput, payloadBlockHash: Uint8Array): PayloadEnvelopeInput { + const block = blockInput.getBlock() as gloas.SignedBeaconBlock; + const input = PayloadEnvelopeInput.createFromBlock({ + block, + blockRootHex: toRootHex(blockRoot(blockInput)), + sampledColumns: [], + custodyColumns: [], + timeCreatedSec: 0, + }); const envelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue(); envelope.message.beaconBlockRoot = blockRoot(blockInput); envelope.message.slot = blockInput.getBlock().message.slot; envelope.message.payload.blockHash = payloadBlockHash; - return envelope; + input.addPayloadEnvelope({envelope, source: PayloadEnvelopeInputSource.byRange, seenTimestampSec: 0}); + return input; } /** Build a mock parent ProtoBlock seeded with the given execution payload block hash */ @@ -125,7 +136,7 @@ describe("chain / blocks / assertLinearChainSegment", () => { const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); // Envelope for block0 delivers HASH_B, so block1 must use HASH_B as parentBlockHash const block1 = makeBlockInput(GLOAS_SLOT + 1, blockRoot(block0), HASH_B); - const envelopes = new Map([[GLOAS_SLOT, makeEnvelope(block0, HASH_B)]]); + const envelopes = new Map([[GLOAS_SLOT, makePayloadEnvelopeInput(block0, HASH_B)]]); assertLinearChainSegment(config, [block0, block1], envelopes, parentBlock); }); @@ -145,7 +156,7 @@ describe("chain / blocks / assertLinearChainSegment", () => { const block1 = makeBlockInput(GLOAS_SLOT + 1, blockRoot(block0), HASH_B); // block1 EMPTY: hash stays HASH_B const block2 = makeBlockInput(GLOAS_SLOT + 2, blockRoot(block1), HASH_B); - const envelopes = new Map([[GLOAS_SLOT, makeEnvelope(block0, HASH_B)]]); + const envelopes = new Map([[GLOAS_SLOT, makePayloadEnvelopeInput(block0, HASH_B)]]); assertLinearChainSegment(config, [block0, block1, block2], envelopes, parentBlock); }); @@ -156,8 +167,8 @@ describe("chain / blocks / assertLinearChainSegment", () => { // block1 FULL: delivers HASH_C const block2 = makeBlockInput(GLOAS_SLOT + 2, blockRoot(block1), HASH_C); const envelopes = new Map([ - [GLOAS_SLOT, makeEnvelope(block0, HASH_B)], - [GLOAS_SLOT + 1, makeEnvelope(block1, HASH_C)], + [GLOAS_SLOT, makePayloadEnvelopeInput(block0, HASH_B)], + [GLOAS_SLOT + 1, makePayloadEnvelopeInput(block1, HASH_C)], ]); assertLinearChainSegment(config, [block0, block1, block2], envelopes, parentBlock); }); @@ -166,15 +177,17 @@ describe("chain / blocks / assertLinearChainSegment", () => { describe("envelope beacon_block_root validation", () => { it("ok - envelope beaconBlockRoot matches the block's hash tree root", () => { const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); - const envelopes = new Map([[GLOAS_SLOT, makeEnvelope(block0, HASH_B)]]); + const envelopes = new Map([[GLOAS_SLOT, makePayloadEnvelopeInput(block0, HASH_B)]]); assertLinearChainSegment(config, [block0], envelopes, parentBlock); }); it("ENVELOPE_BEACON_BLOCK_ROOT_MISMATCH - envelope references a different block root", () => { const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); - const envelope = makeEnvelope(block0, HASH_B); - envelope.message.beaconBlockRoot = HASH_WRONG; // tamper the root - const envelopes = new Map([[GLOAS_SLOT, envelope]]); + // Build a PayloadEnvelopeInput for a *different* block (different parentRoot → different HTR) + // so its stored beaconBlockRoot won't match block0's root + const blockOther = makeBlockInput(GLOAS_SLOT, HASH_WRONG, HASH_A); + const wrongInput = makePayloadEnvelopeInput(blockOther, HASH_B); + const envelopes = new Map([[GLOAS_SLOT, wrongInput]]); expectThrowsLodestarError( () => assertLinearChainSegment(config, [block0], envelopes, parentBlock), BlockErrorCode.ENVELOPE_BEACON_BLOCK_ROOT_MISMATCH @@ -183,9 +196,10 @@ describe("chain / blocks / assertLinearChainSegment", () => { it("ok - envelope for a slot not in segment is silently ignored", () => { const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); - // Orphan envelope for a slot outside the segment — no error expected - const orphanEnvelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue(); - const envelopes = new Map([[GLOAS_SLOT + 99, orphanEnvelope]]); + // Orphan PayloadEnvelopeInput for a slot outside the segment — no error expected + const orphanBlock = makeBlockInput(GLOAS_SLOT + 99, new Uint8Array(32), HASH_A); + const orphanInput = makePayloadEnvelopeInput(orphanBlock, HASH_B); + const envelopes = new Map([[GLOAS_SLOT + 99, orphanInput]]); assertLinearChainSegment(config, [block0], envelopes, parentBlock); }); }); From ebb8526fe7f2e1e0c6f98d77957373e972297c01 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Thu, 2 Apr 2026 13:18:49 +0700 Subject: [PATCH 17/22] fix: reuse importExecutionPayload() --- .../src/chain/blocks/importBlock.ts | 26 +--- .../chain/blocks/importExecutionPayload.ts | 124 +++++++++++++----- .../beacon-node/src/chain/blocks/index.ts | 5 + .../chain/blocks/payloadEnvelopeProcessor.ts | 4 +- 4 files changed, 102 insertions(+), 57 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index 6af36a73ffaf..05ca5154d1ce 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, + PayloadStatus, getSafeExecutionBlockHash, isGloasBlock, } from "@lodestar/fork-choice"; @@ -166,30 +167,6 @@ export async function importBlock( } } - // For Gloas blocks whose envelope was pre-verified during state transition (sync/batch path), - // immediately transition the block to FULL status in fork choice and cache the payload state. - // Mirrors steps 6–7 of importExecutionPayload, but reuses the already-computed postPayloadEnvelopeState. - if (fullyVerifiedBlock.postPayloadEnvelopeState !== null) { - const {postPayloadEnvelopeState, payloadEnvelopeInput} = fullyVerifiedBlock; - this.unfinalizedPayloadEnvelopeWrites.push(payloadEnvelopeInput).catch((e) => { - if (!isQueueErrorAborted(e)) { - this.logger.error("Error pushing payload envelope to write queue", {slot: blockSlot}, e as Error); - } - }); - this.regen.processPayloadState(postPayloadEnvelopeState); - if (postPayloadEnvelopeState.slot % SLOTS_PER_EPOCH === 0) { - const {checkpoint} = postPayloadEnvelopeState.computeAnchorCheckpoint(); - this.regen.addCheckpointState(checkpoint, postPayloadEnvelopeState, true); - } - this.forkChoice.onExecutionPayload( - blockRootHex, - toRootHex(postPayloadEnvelopeState.latestBlockHash), - fullyVerifiedBlock.payloadEnvelope.message.payload.blockNumber, - toRootHex(postPayloadEnvelopeState.hashTreeRoot()), - fullyVerifiedBlock.executionStatus - ); - } - this.metrics?.importBlock.bySource.inc({source: source.source}); this.logger.verbose("Added block to forkchoice and state cache", {slot: blockSlot, root: blockRootHex}); @@ -357,6 +334,7 @@ export async function importBlock( this.logger.verbose("New chain head", { slot: newHead.slot, root: newHead.blockRoot, + payloadStatus: newHead.payloadStatus === PayloadStatus.FULL ? "full" : "empty", delaySec, }); diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index ebda5c6975a9..c7d58c9232ee 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -1,11 +1,13 @@ import {routes} from "@lodestar/api"; -import {ExecutionStatus, PayloadExecutionStatus} from "@lodestar/fork-choice"; +import {ExecutionStatus, PayloadExecutionStatus, PayloadStatus} from "@lodestar/fork-choice"; import {SLOTS_PER_EPOCH} from "@lodestar/params"; -import {getExecutionPayloadEnvelopeSignatureSet} from "@lodestar/state-transition"; +import {IBeaconStateView, getExecutionPayloadEnvelopeSignatureSet} from "@lodestar/state-transition"; import {byteArrayEquals, fromHex, toRootHex} from "@lodestar/utils"; import {ExecutionPayloadStatus} from "../../execution/index.js"; +import {isOptimisticBlock} from "../../util/forkChoice.js"; import {isQueueErrorAborted} from "../../util/queue/index.js"; import {BeaconChain} from "../chain.js"; +import {ForkchoiceCaller} from "../forkChoice/index.js"; import {RegenCaller} from "../regen/interface.js"; import {PayloadEnvelopeInput} from "../seenCache/seenPayloadEnvelopeInput.js"; import {ImportPayloadOpts} from "./types.js"; @@ -65,33 +67,34 @@ function toForkChoiceExecutionStatus(status: ExecutionPayloadStatus): PayloadExe } } +type VerifyExecutionPayloadResult = { + postPayloadState: IBeaconStateView; + execStatus: PayloadExecutionStatus; +}; + /** - * Import an execution payload envelope after all data is available. - * - * This function: - * 1. Emits `execution_payload_available` if payload is for current slot - * 2. Gets the ProtoBlock from fork choice - * 3. Applies write-queue backpressure (waitForSpace) early, before verification - * 4. Regenerates the block state - * 5. Runs EL verification (notifyNewPayload) in parallel with signature verification and processExecutionPayloadEnvelope - * 6. Persists verified payload envelope to hot DB - * 7. Updates fork choice - * 8. Caches the post-execution payload state - * 9. Records metrics for column sources - * 10. Emits `execution_payload` for recent enough payloads after successful import + * Verify an execution payload envelope: + * 1. Emit `execution_payload_available` if payload is for current slot + * 2. Get the ProtoBlock from fork choice + * 3. Regenerate the block state + * 4. Run EL verification, signature verification, and state transition in parallel + * 5. Validate results (signature, EL status, state root) * + * Returns the verified post-payload state and fork-choice execution status. */ -export async function importExecutionPayload( +export async function verifyExecutionPayload( this: BeaconChain, payloadInput: PayloadEnvelopeInput, opts: ImportPayloadOpts = {} -): Promise { +): Promise { const signedEnvelope = payloadInput.getPayloadEnvelope(); const envelope = signedEnvelope.message; const blockRootHex = payloadInput.blockRootHex; - const blockHashHex = payloadInput.getBlockHashHex(); const fork = this.config.getForkName(envelope.slot); + // TODO GLOAS: also wait for payload data availability + // see https://github.com/ChainSafe/lodestar/issues/9150 + // 1. Emit `execution_payload_available` event at the start of import. At this point the payload input // is already complete, so the payload and required data are available for payload attestation. // This event is only about availability, not validity of the execution payload, hence we can emit @@ -112,11 +115,7 @@ export async function importExecutionPayload( }); } - // 3. Apply backpressure from the write queue early, before doing verification work. - // The actual DB write is deferred until after verification succeeds. - await this.unfinalizedPayloadEnvelopeWrites.waitForSpace(); - - // 4. Get pre-state for processExecutionPayloadEnvelope + // 3. Get pre-state for processExecutionPayloadEnvelope // We need the block state (post-block, pre-payload) to process the envelope const blockState = await this.regen.getBlockSlotState( protoBlock, @@ -125,8 +124,8 @@ export async function importExecutionPayload( RegenCaller.processBlock ); - // 5. Run verification steps in parallel - // Note: No data availability check needed here - importExecutionPayload is only + // 4. Run verification steps in parallel + // Note: No data availability check needed here - verifyExecutionPayload is only // called when payloadInput.isComplete() is true, so all data is already available. const [execResult, signatureValid, postPayloadResult] = await Promise.all([ this.executionEngine.notifyNewPayload( @@ -213,7 +212,37 @@ export async function importExecutionPayload( }); } - // 6. Persist payload envelope to hot DB (performed asynchronously to avoid blocking) + return {postPayloadState, execStatus: toForkChoiceExecutionStatus(execResult.status)}; +} + +/** + * Import a pre-verified execution payload envelope: + * 6. Applies write-queue backpressure (waitForSpace) + * 7. Persists payload envelope to hot DB + * 8. Updates fork choice + * 9. Caches the post-payload state + * 10. Records metrics for column sources + * 11. Emits `execution_payload` for recent enough payloads + * + * Assumes verification already passed. Accepts pre-computed postPayloadState and execStatus, + * so it can be reused from both processExecutionPayload() and the pre-verified processChainSegment path. + */ +export async function importExecutionPayload( + this: BeaconChain, + payloadInput: PayloadEnvelopeInput, + postPayloadState: IBeaconStateView, + execStatus: PayloadExecutionStatus +): Promise { + const signedEnvelope = payloadInput.getPayloadEnvelope(); + const envelope = signedEnvelope.message; + const blockRootHex = payloadInput.blockRootHex; + const blockHashHex = payloadInput.getBlockHashHex(); + const postPayloadStateRoot = postPayloadState.hashTreeRoot(); + + // 6. Apply backpressure from the write queue before doing persistence work. + await this.unfinalizedPayloadEnvelopeWrites.waitForSpace(); + + // 7. Persist payload envelope to hot DB (performed asynchronously to avoid blocking) this.unfinalizedPayloadEnvelopeWrites.push(payloadInput).catch((e) => { if (!isQueueErrorAborted(e)) { this.logger.error( @@ -224,29 +253,46 @@ export async function importExecutionPayload( } }); - // 7. Update fork choice + // 8. Update fork choice + const oldHead = this.forkChoice.getHead(); this.forkChoice.onExecutionPayload( blockRootHex, blockHashHex, envelope.payload.blockNumber, toRootHex(postPayloadStateRoot), - toForkChoiceExecutionStatus(execResult.status) + execStatus ); + const newHead = this.recomputeForkChoiceHead(ForkchoiceCaller.importBlock); + + if (newHead.blockRoot !== oldHead.blockRoot) { + this.regen.updateHeadState(newHead, postPayloadState); + this.logger.verbose("New chain head after execution payload import", { + slot: newHead.slot, + root: newHead.blockRoot, + payloadStatus: newHead.payloadStatus === PayloadStatus.FULL ? "full" : "empty", + executionOptimistic: isOptimisticBlock(newHead), + }); + if (this.metrics) { + this.metrics.headSlot.set(newHead.slot); + } + this.onNewHead(newHead); + this.metrics?.forkChoice.changedHead.inc(); + } - // 8. Cache payload state + // 9. Cache payload state this.regen.processPayloadState(postPayloadState); if (postPayloadState.slot % SLOTS_PER_EPOCH === 0) { const {checkpoint} = postPayloadState.computeAnchorCheckpoint(); this.regen.addCheckpointState(checkpoint, postPayloadState, true); } - // 9. Record metrics for payload envelope and column sources + // 10. Record metrics for payload envelope and column sources this.metrics?.importPayload.bySource.inc({source: payloadInput.getPayloadEnvelopeSource().source}); for (const {source} of payloadInput.getSampledColumnsWithSource()) { this.metrics?.importPayload.columnsBySource.inc({source}); } - // 10. Emit event after payload is fully verified and imported to fork choice, only for recent enough payloads + // 11. Emit event after payload is fully verified and imported to fork choice, only for recent enough payloads if (this.clock.currentSlot - envelope.slot < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { this.emitter.emit(routes.events.EventType.executionPayload, { slot: envelope.slot, @@ -265,3 +311,19 @@ export async function importExecutionPayload( blockHash: blockHashHex, }); } + +/** + * Process an execution payload envelope end-to-end: + * verifies it (steps 1-5) then imports it (steps 6-11). + * + * Used by PayloadEnvelopeProcessor for the normal gossip/req-resp path. + * For the pre-verified processChainSegment path, call importExecutionPayload() directly. + */ +export async function processExecutionPayload( + this: BeaconChain, + payloadInput: PayloadEnvelopeInput, + opts: ImportPayloadOpts = {} +): Promise { + const {postPayloadState, execStatus} = await verifyExecutionPayload.call(this, payloadInput, opts); + await importExecutionPayload.call(this, payloadInput, postPayloadState, execStatus); +} diff --git a/packages/beacon-node/src/chain/blocks/index.ts b/packages/beacon-node/src/chain/blocks/index.ts index aaee4c621e79..cea862605963 100644 --- a/packages/beacon-node/src/chain/blocks/index.ts +++ b/packages/beacon-node/src/chain/blocks/index.ts @@ -9,6 +9,7 @@ import {BlockError, BlockErrorCode, isBlockErrorAborted} from "../errors/index.j import {BlockProcessOpts} from "../options.js"; import {IBlockInput} from "./blockInput/types.js"; import {importBlock} from "./importBlock.js"; +import {importExecutionPayload} from "./importExecutionPayload.js"; import {PayloadEnvelopeInput} from "./payloadEnvelopeInput/payloadEnvelopeInput.js"; import {FullyVerifiedBlock, ImportBlockOpts} from "./types.js"; import {assertLinearChainSegment} from "./utils/chainSegment.js"; @@ -132,6 +133,10 @@ export async function processBlocks( for (const fullyVerifiedBlock of fullyVerifiedBlocks) { // TODO: Consider batching importBlock too if it takes significant time await importBlock.call(this, fullyVerifiedBlock, opts); + if (fullyVerifiedBlock.postPayloadEnvelopeState !== null) { + const {postPayloadEnvelopeState, payloadEnvelopeInput, executionStatus} = fullyVerifiedBlock; + await importExecutionPayload.call(this, payloadEnvelopeInput, postPayloadEnvelopeState, executionStatus); + } await nextEventLoop(); } } catch (e) { diff --git a/packages/beacon-node/src/chain/blocks/payloadEnvelopeProcessor.ts b/packages/beacon-node/src/chain/blocks/payloadEnvelopeProcessor.ts index e50b53bda93e..1e1edf78eecb 100644 --- a/packages/beacon-node/src/chain/blocks/payloadEnvelopeProcessor.ts +++ b/packages/beacon-node/src/chain/blocks/payloadEnvelopeProcessor.ts @@ -2,7 +2,7 @@ import {Metrics} from "../../metrics/metrics.js"; import {JobItemQueue} from "../../util/queue/index.js"; import type {BeaconChain} from "../chain.js"; import {PayloadEnvelopeInput} from "../seenCache/seenPayloadEnvelopeInput.js"; -import {importExecutionPayload} from "./importExecutionPayload.js"; +import {processExecutionPayload} from "./importExecutionPayload.js"; import {ImportPayloadOpts} from "./types.js"; // TODO GLOAS: Set to be equal to DEFAULT_MAX_PENDING_UNFINALIZED_PAYLOAD_ENVELOPE_WRITES for now @@ -25,7 +25,7 @@ export class PayloadEnvelopeProcessor { this.jobQueue = new JobItemQueue<[PayloadEnvelopeInput, ImportPayloadOpts], void>( (payloadInput, opts) => { this.importStatus.set(payloadInput, PayloadEnvelopeImportStatus.importing); - return importExecutionPayload.call(chain, payloadInput, opts); + return processExecutionPayload.call(chain, payloadInput, opts); }, {maxLength: QUEUE_MAX_LENGTH, noYieldIfOneItem: true, signal}, metrics?.payloadEnvelopeProcessorQueue ?? undefined From af5ab38c5bbeeb67b1e82ea67f70f8f1b4682c16 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Thu, 2 Apr 2026 13:53:11 +0700 Subject: [PATCH 18/22] fix: processChainSegment() api in Sync --- packages/beacon-node/src/sync/range/chain.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/beacon-node/src/sync/range/chain.ts b/packages/beacon-node/src/sync/range/chain.ts index c2d7aeff7f91..02cadbc76e72 100644 --- a/packages/beacon-node/src/sync/range/chain.ts +++ b/packages/beacon-node/src/sync/range/chain.ts @@ -1,11 +1,12 @@ import {ChainForkConfig} from "@lodestar/config"; -import {Epoch, Root, Slot, gloas} from "@lodestar/types"; +import {Epoch, Root, Slot} from "@lodestar/types"; import {ErrorAborted, LodestarError, Logger, toRootHex} from "@lodestar/utils"; import {isBlockInputBlobs, isBlockInputColumns} from "../../chain/blocks/blockInput/blockInput.js"; import {BlockInputErrorCode} from "../../chain/blocks/blockInput/errors.js"; import {IBlockInput} from "../../chain/blocks/blockInput/types.js"; import {BlobSidecarErrorCode} from "../../chain/errors/blobSidecarError.js"; import {DataColumnSidecarErrorCode} from "../../chain/errors/dataColumnSidecarError.js"; +import {PayloadEnvelopeInput} from "../../chain/seenCache/seenPayloadEnvelopeInput.js"; import {Metrics} from "../../metrics/metrics.js"; import {PeerAction, prettyPrintPeerIdStr} from "../../network/index.js"; import {PeerSyncMeta} from "../../network/peers/peersData.js"; @@ -46,7 +47,7 @@ export type SyncChainFns = { */ processChainSegment: ( blocks: IBlockInput[], - envelopes: Map | null, + envelopes: Map | null, syncType: RangeSyncType ) => Promise; /** Must download blocks, and validate their range */ From 72439179f38ecc5be9f66cd8e090089a39ce57ed Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Thu, 2 Apr 2026 15:57:28 +0700 Subject: [PATCH 19/22] fix: do not trigger getBlobs() in improtBlock() for range sync --- .../src/chain/blocks/importBlock.ts | 21 ++++++++++--------- .../beacon-node/src/chain/blocks/index.ts | 1 + .../beacon-node/src/chain/blocks/types.ts | 2 ++ 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index c6aafe6ff189..4331327a0e6b 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -171,17 +171,18 @@ export async function importBlock( // which is all the information we need so there is no reason to delay until execution payload arrives // TODO GLOAS: If we want EL retries after this initial attempt, add an explicit retry policy here // (for example later in the slot). Do not couple retries to incoming gossip columns. - this.getBlobsTracker.triggerGetBlobs(payloadInput, () => { - // TODO GLOAS: come up with a better mechanism to trigger processExecutionPayload after data becomes available, - // similar to how pre-gloas uses waitForBlockAndAllData with a cutoff timeout and incompleteBlockInput event - this.processExecutionPayload(payloadInput, {validSignature: true}).catch((e) => { - this.logger.debug( - "Error processing execution payload after getBlobs", - {slot: blockSlot, root: blockRootHex}, - e as Error - ); + if (!fullyVerifiedBlock.fromRangeSync) + this.getBlobsTracker.triggerGetBlobs(payloadInput, () => { + // TODO GLOAS: come up with a better mechanism to trigger processExecutionPayload after data becomes available, + // similar to how pre-gloas uses waitForBlockAndAllData with a cutoff timeout and incompleteBlockInput event + this.processExecutionPayload(payloadInput, {validSignature: true}).catch((e) => { + this.logger.debug( + "Error processing execution payload after getBlobs", + {slot: blockSlot, root: blockRootHex}, + e as Error + ); + }); }); - }); } this.metrics?.importBlock.bySource.inc({source: source.source}); diff --git a/packages/beacon-node/src/chain/blocks/index.ts b/packages/beacon-node/src/chain/blocks/index.ts index cea862605963..c5e4d977def4 100644 --- a/packages/beacon-node/src/chain/blocks/index.ts +++ b/packages/beacon-node/src/chain/blocks/index.ts @@ -111,6 +111,7 @@ export async function processBlocks( indexedAttestations: indexedAttestationsByBlock[i], // TODO: Make this param mandatory and capture in gossip seenTimestampSec: opts.seenTimestampSec ?? Math.floor(Date.now() / 1000), + fromRangeSync: opts.fromRangeSync, }; if (payloadEnvelopeResult !== null) { diff --git a/packages/beacon-node/src/chain/blocks/types.ts b/packages/beacon-node/src/chain/blocks/types.ts index 45961d6660ad..2f89b5d29bf0 100644 --- a/packages/beacon-node/src/chain/blocks/types.ts +++ b/packages/beacon-node/src/chain/blocks/types.ts @@ -99,6 +99,8 @@ type FullyVerifiedBlockBase = { indexedAttestations: IndexedAttestation[]; /** Seen timestamp seconds */ seenTimestampSec: number; + /** Whether this block came from range sync; used to skip unnecessary EL data fetches */ + fromRangeSync?: boolean; }; /** From 741f4a4436ccec839cd3ec02cf4535d7eba08e1e Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Mon, 6 Apr 2026 11:37:59 +0700 Subject: [PATCH 20/22] fix: relax assertLinearChainSegment, log orphaned envelope --- .../beacon-node/src/chain/blocks/index.ts | 15 +++- .../src/chain/blocks/utils/chainSegment.ts | 37 +++++++-- .../blocks/assertLinearChainSegment.test.ts | 81 ++++++++++++++----- 3 files changed, 103 insertions(+), 30 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/index.ts b/packages/beacon-node/src/chain/blocks/index.ts index c5e4d977def4..2d28e91dc257 100644 --- a/packages/beacon-node/src/chain/blocks/index.ts +++ b/packages/beacon-node/src/chain/blocks/index.ts @@ -74,7 +74,20 @@ export async function processBlocks( return; } - assertLinearChainSegment(this.config, relevantBlocks, payloadEnvelopes, parentBlock); + const {warnings: orphanedPayloads} = assertLinearChainSegment( + this.config, + relevantBlocks, + payloadEnvelopes, + parentBlock + ); + if (orphanedPayloads != null) { + for (const orphaned of orphanedPayloads) { + this.logger.debug("Orphaned payload envelope in chain segment", { + slot: orphaned.slot, + blockRoot: orphaned.payloadEnvelopeInput.blockRootHex, + }); + } + } // Fully verify a block to be imported immediately after. Does not produce any side-effects besides adding intermediate // states in the state cache through regen. diff --git a/packages/beacon-node/src/chain/blocks/utils/chainSegment.ts b/packages/beacon-node/src/chain/blocks/utils/chainSegment.ts index 93de1e06e237..0e7c7814df57 100644 --- a/packages/beacon-node/src/chain/blocks/utils/chainSegment.ts +++ b/packages/beacon-node/src/chain/blocks/utils/chainSegment.ts @@ -6,6 +6,13 @@ import {BlockError, BlockErrorCode} from "../../errors/index.js"; import {IBlockInput} from "../blockInput/types.js"; import {PayloadEnvelopeInput} from "../payloadEnvelopeInput/payloadEnvelopeInput.js"; +export type OrphanedPayloadEnvelope = { + slot: Slot; + payloadEnvelopeInput: PayloadEnvelopeInput; +}; + +export type ChainSegmentResult = {warnings: OrphanedPayloadEnvelope[] | null}; + /** * Assert this chain segment of blocks is linear with slot numbers and hashes, * and that the provided envelopes are consistent with their respective blocks. @@ -24,13 +31,20 @@ export function assertLinearChainSegment( blocks: IBlockInput[], payloadEnvelopes: Map | null, parentBlock: ProtoBlock -): void { +): ChainSegmentResult { + const warnings: OrphanedPayloadEnvelope[] = []; + // Track the expected execution payload block hash through the segment. // Starts from the known forkchoice parent's execution hash. // - FULL variant (envelope present for slot): advances to envelope.payload.blockHash // - EMPTY variant (no envelope for slot): execution hash is unchanged // null only for pre-merge parents, which cannot precede gloas blocks. let currentExecHash: string | null = parentBlock.executionPayloadBlockHash; + // Track the execution hash before the last FULL advancement so we can recover + // if the next block reveals that envelope was orphaned. + let prevExecHash: string | null = currentExecHash; + // The slot whose envelope last advanced currentExecHash (for warning context). + let lastFullSlot: Slot | null = null; for (let i = 0; i < blocks.length; i++) { const block = blocks[i].getBlock(); @@ -58,15 +72,18 @@ export function assertLinearChainSegment( // This ensures the block was built on the correct FULL or EMPTY variant of its parent. const bidParentHash = toRootHex(block.message.body.signedExecutionPayloadBid.message.parentBlockHash); if (bidParentHash !== currentExecHash) { - throw new BlockError(block, { - code: BlockErrorCode.BID_PARENT_HASH_MISMATCH, - bidParentHash, - expectedHash: currentExecHash, - }); + // The previous slot's envelope was orphaned — fall back to prevExecHash + if (lastFullSlot !== null && payloadEnvelopes !== null) { + const orphanedInput = payloadEnvelopes.get(lastFullSlot); + if (orphanedInput != null) { + warnings.push({slot: lastFullSlot, payloadEnvelopeInput: orphanedInput}); + } + } + currentExecHash = prevExecHash; } const payloadEnvelope = payloadEnvelopes?.get(slot)?.getPayloadEnvelope(); - if (payloadEnvelope !== undefined && payloadEnvelope !== null) { + if (payloadEnvelope != null) { // Verify the envelope references this block's root const blockRoot = toRootHex(config.getForkTypes(slot).BeaconBlock.hashTreeRoot(block.message)); const envelopeBlockRoot = toRootHex(payloadEnvelope.message.beaconBlockRoot); @@ -78,10 +95,14 @@ export function assertLinearChainSegment( }); } - // FULL variant: advance execution hash to the delivered payload's block hash + // FULL variant: save state before advancing, then advance + prevExecHash = currentExecHash; + lastFullSlot = slot; currentExecHash = toRootHex(payloadEnvelope.message.payload.blockHash); } // EMPTY variant: currentExecHash unchanged } } + + return {warnings: warnings.length > 0 ? warnings : null}; } diff --git a/packages/beacon-node/test/unit/chain/blocks/assertLinearChainSegment.test.ts b/packages/beacon-node/test/unit/chain/blocks/assertLinearChainSegment.test.ts index 40857a302133..827d2572f2eb 100644 --- a/packages/beacon-node/test/unit/chain/blocks/assertLinearChainSegment.test.ts +++ b/packages/beacon-node/test/unit/chain/blocks/assertLinearChainSegment.test.ts @@ -1,4 +1,4 @@ -import {describe, it} from "vitest"; +import {describe, expect, it} from "vitest"; import {createChainForkConfig, defaultChainConfig} from "@lodestar/config"; import {ProtoBlock} from "@lodestar/fork-choice"; import {ForkName} from "@lodestar/params"; @@ -84,13 +84,15 @@ describe("chain / blocks / assertLinearChainSegment", () => { describe("block linearity", () => { it("ok - single block", () => { const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); - assertLinearChainSegment(config, [block0], null, parentBlock); + const {warnings} = assertLinearChainSegment(config, [block0], null, parentBlock); + expect(warnings).toBeNull(); }); it("ok - two blocks with matching parent roots and increasing slots", () => { const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); const block1 = makeBlockInput(GLOAS_SLOT + 1, blockRoot(block0), HASH_A); - assertLinearChainSegment(config, [block0, block1], null, parentBlock); + const {warnings} = assertLinearChainSegment(config, [block0, block1], null, parentBlock); + expect(warnings).toBeNull(); }); it("NON_LINEAR_PARENT_ROOTS - second block has wrong parentRoot", () => { @@ -115,22 +117,23 @@ describe("chain / blocks / assertLinearChainSegment", () => { describe("execution hash chain (bid.parentBlockHash)", () => { it("ok - single block with correct bid.parentBlockHash", () => { const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); - assertLinearChainSegment(config, [block0], null, parentBlock); + const {warnings} = assertLinearChainSegment(config, [block0], null, parentBlock); + expect(warnings).toBeNull(); }); - it("BID_PARENT_HASH_MISMATCH - first block bid.parentBlockHash doesn't match forkchoice parent hash", () => { + it("BID_PARENT_HASH_MISMATCH on first block - no lastFullSlot so no warning emitted", () => { const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_B); // parentBlock has HASH_A - expectThrowsLodestarError( - () => assertLinearChainSegment(config, [block0], null, parentBlock), - BlockErrorCode.BID_PARENT_HASH_MISMATCH - ); + // No prior envelope to blame, so fallback happens silently (no warning) + const {warnings} = assertLinearChainSegment(config, [block0], null, parentBlock); + expect(warnings).toBeNull(); }); it("ok - EMPTY chain (no envelopes): execution hash propagates unchanged across segment", () => { const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); // block1 expects HASH_A because block0 was EMPTY (no envelope → no payload hash change) const block1 = makeBlockInput(GLOAS_SLOT + 1, blockRoot(block0), HASH_A); - assertLinearChainSegment(config, [block0, block1], null, parentBlock); + const {warnings} = assertLinearChainSegment(config, [block0, block1], null, parentBlock); + expect(warnings).toBeNull(); }); it("ok - FULL slot (envelope present): execution hash advances to envelope payload hash", () => { @@ -138,17 +141,17 @@ describe("chain / blocks / assertLinearChainSegment", () => { // Envelope for block0 delivers HASH_B, so block1 must use HASH_B as parentBlockHash const block1 = makeBlockInput(GLOAS_SLOT + 1, blockRoot(block0), HASH_B); const envelopes = new Map([[GLOAS_SLOT, makePayloadEnvelopeInput(block0, HASH_B)]]); - assertLinearChainSegment(config, [block0, block1], envelopes, parentBlock); + const {warnings} = assertLinearChainSegment(config, [block0, block1], envelopes, parentBlock); + expect(warnings).toBeNull(); }); - it("BID_PARENT_HASH_MISMATCH on second block - expected EMPTY but block references FULL hash", () => { + it("orphaned envelope recovery - second block references FULL hash but no envelope existed", () => { const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); - // No envelope for block0 → EMPTY → next block must still use HASH_A, not HASH_B + // No envelope for block0 → EMPTY → next block uses HASH_B which mismatches + // No lastFullSlot to blame, so fallback happens silently const block1 = makeBlockInput(GLOAS_SLOT + 1, blockRoot(block0), HASH_B); - expectThrowsLodestarError( - () => assertLinearChainSegment(config, [block0, block1], null, parentBlock), - BlockErrorCode.BID_PARENT_HASH_MISMATCH - ); + const {warnings} = assertLinearChainSegment(config, [block0, block1], null, parentBlock); + expect(warnings).toBeNull(); }); it("ok - FULL then EMPTY: third block reuses the envelope hash from the first slot", () => { @@ -158,7 +161,8 @@ describe("chain / blocks / assertLinearChainSegment", () => { // block1 EMPTY: hash stays HASH_B const block2 = makeBlockInput(GLOAS_SLOT + 2, blockRoot(block1), HASH_B); const envelopes = new Map([[GLOAS_SLOT, makePayloadEnvelopeInput(block0, HASH_B)]]); - assertLinearChainSegment(config, [block0, block1, block2], envelopes, parentBlock); + const {warnings} = assertLinearChainSegment(config, [block0, block1, block2], envelopes, parentBlock); + expect(warnings).toBeNull(); }); it("ok - FULL then FULL: each block advances the execution hash", () => { @@ -171,7 +175,40 @@ describe("chain / blocks / assertLinearChainSegment", () => { [GLOAS_SLOT, makePayloadEnvelopeInput(block0, HASH_B)], [GLOAS_SLOT + 1, makePayloadEnvelopeInput(block1, HASH_C)], ]); - assertLinearChainSegment(config, [block0, block1, block2], envelopes, parentBlock); + const {warnings} = assertLinearChainSegment(config, [block0, block1, block2], envelopes, parentBlock); + expect(warnings).toBeNull(); + }); + + it("orphaned envelope - FULL then block references EMPTY hash, warns with orphaned envelope", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + const envelope0 = makePayloadEnvelopeInput(block0, HASH_B); + // block0 FULL: delivers HASH_B, but block1 references HASH_A (EMPTY variant) + const block1 = makeBlockInput(GLOAS_SLOT + 1, blockRoot(block0), HASH_A); + const envelopes = new Map([[GLOAS_SLOT, envelope0]]); + const {warnings} = assertLinearChainSegment(config, [block0, block1], envelopes, parentBlock); + expect(warnings).toHaveLength(1); + expect(warnings?.[0].slot).toBe(GLOAS_SLOT); + expect(warnings?.[0].payloadEnvelopeInput).toBe(envelope0); + }); + + it("consecutive orphaned envelopes - two FULL slots, third block references original hash", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + const envelope0 = makePayloadEnvelopeInput(block0, HASH_B); + // block0 FULL → HASH_B, but block1 references HASH_A → orphan envelope0, fallback to HASH_A + const block1 = makeBlockInput(GLOAS_SLOT + 1, blockRoot(block0), HASH_A); + const envelope1 = makePayloadEnvelopeInput(block1, HASH_C); + // block1 FULL → HASH_C, but block2 references HASH_A → orphan envelope1, fallback to HASH_A + const block2 = makeBlockInput(GLOAS_SLOT + 2, blockRoot(block1), HASH_A); + const envelopes = new Map([ + [GLOAS_SLOT, envelope0], + [GLOAS_SLOT + 1, envelope1], + ]); + const {warnings} = assertLinearChainSegment(config, [block0, block1, block2], envelopes, parentBlock); + expect(warnings).toHaveLength(2); + expect(warnings?.[0].slot).toBe(GLOAS_SLOT); + expect(warnings?.[0].payloadEnvelopeInput).toBe(envelope0); + expect(warnings?.[1].slot).toBe(GLOAS_SLOT + 1); + expect(warnings?.[1].payloadEnvelopeInput).toBe(envelope1); }); }); @@ -179,7 +216,8 @@ describe("chain / blocks / assertLinearChainSegment", () => { it("ok - envelope beaconBlockRoot matches the block's hash tree root", () => { const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); const envelopes = new Map([[GLOAS_SLOT, makePayloadEnvelopeInput(block0, HASH_B)]]); - assertLinearChainSegment(config, [block0], envelopes, parentBlock); + const {warnings} = assertLinearChainSegment(config, [block0], envelopes, parentBlock); + expect(warnings).toBeNull(); }); it("ENVELOPE_BEACON_BLOCK_ROOT_MISMATCH - envelope references a different block root", () => { @@ -201,7 +239,8 @@ describe("chain / blocks / assertLinearChainSegment", () => { const orphanBlock = makeBlockInput(GLOAS_SLOT + 99, new Uint8Array(32), HASH_A); const orphanInput = makePayloadEnvelopeInput(orphanBlock, HASH_B); const envelopes = new Map([[GLOAS_SLOT + 99, orphanInput]]); - assertLinearChainSegment(config, [block0], envelopes, parentBlock); + const {warnings} = assertLinearChainSegment(config, [block0], envelopes, parentBlock); + expect(warnings).toBeNull(); }); }); }); From 03a8917e1792bf7be2ca8fd454a3415b970a4ead Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Tue, 7 Apr 2026 16:57:18 +0700 Subject: [PATCH 21/22] fix: add INVALID_PAYLOAD_STATE_ROOT --- .../beacon-node/src/chain/blocks/index.ts | 2 +- .../beacon-node/src/chain/blocks/types.ts | 2 +- .../src/chain/blocks/verifyBlock.ts | 2 +- .../blocks/verifyBlocksStateTransitionOnly.ts | 24 ++++++++++--------- .../src/chain/errors/blockError.ts | 18 ++++++++++---- 5 files changed, 30 insertions(+), 18 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/index.ts b/packages/beacon-node/src/chain/blocks/index.ts index 1ac923fe0f36..e0c53f83477a 100644 --- a/packages/beacon-node/src/chain/blocks/index.ts +++ b/packages/beacon-node/src/chain/blocks/index.ts @@ -172,7 +172,7 @@ export async function processBlocks( const forkTypes = this.config.getForkTypes(blockSlot); this.persistInvalidSszValue(forkTypes.SignedBeaconBlock, signedBlock, `${blockSlot}_invalid_signature`); this.persistInvalidSszBytes("BeaconState", state.serialize(), `${state.slot}_invalid_signature`); - } else if (err.type.code === BlockErrorCode.INVALID_STATE_ROOT) { + } else if (err.type.code === BlockErrorCode.INVALID_BLOCK_STATE_ROOT) { const {signedBlock} = err; const blockSlot = signedBlock.message.slot; const {preState, postState} = err.type; diff --git a/packages/beacon-node/src/chain/blocks/types.ts b/packages/beacon-node/src/chain/blocks/types.ts index 855cb23bd62e..6b5824ae080a 100644 --- a/packages/beacon-node/src/chain/blocks/types.ts +++ b/packages/beacon-node/src/chain/blocks/types.ts @@ -7,7 +7,7 @@ import { IBeaconStateViewGloas, computeEpochAtSlot, } from "@lodestar/state-transition"; -import type {IndexedAttestation, Slot, fulu, gloas} from "@lodestar/types"; +import type {IndexedAttestation, Slot, fulu} from "@lodestar/types"; import {IBlockInput} from "./blockInput/types.js"; import type {PayloadEnvelopeInput} from "./payloadEnvelopeInput/payloadEnvelopeInput.js"; diff --git a/packages/beacon-node/src/chain/blocks/verifyBlock.ts b/packages/beacon-node/src/chain/blocks/verifyBlock.ts index 254029d995e1..9c88ea7f4494 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlock.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlock.ts @@ -6,7 +6,7 @@ import { IBeaconStateViewGloas, computeEpochAtSlot, } from "@lodestar/state-transition"; -import {IndexedAttestation, Slot, deneb, gloas} from "@lodestar/types"; +import {IndexedAttestation, Slot, deneb} from "@lodestar/types"; import type {BeaconChain} from "../chain.js"; import {BlockError, BlockErrorCode} from "../errors/index.js"; import {BlockProcessOpts} from "../options.js"; diff --git a/packages/beacon-node/src/chain/blocks/verifyBlocksStateTransitionOnly.ts b/packages/beacon-node/src/chain/blocks/verifyBlocksStateTransitionOnly.ts index db227c24dc70..c65a3444485c 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlocksStateTransitionOnly.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlocksStateTransitionOnly.ts @@ -6,7 +6,7 @@ import { StateHashTreeRootSource, isStatePostGloas, } from "@lodestar/state-transition"; -import {Slot, gloas, isGloasBeaconBlock} from "@lodestar/types"; +import {Slot, isGloasBeaconBlock} from "@lodestar/types"; import {ErrorAborted, Logger, byteArrayEquals, toRootHex} from "@lodestar/utils"; import {Metrics} from "../../metrics/index.js"; import {nextEventLoop} from "../../util/eventLoop.js"; @@ -121,7 +121,7 @@ export async function verifyBlocksStateTransitionOnly( // Check state root matches if (!byteArrayEquals(block.message.stateRoot, stateRootAfterStateTransition)) { throw new BlockError(block, { - code: BlockErrorCode.INVALID_STATE_ROOT, + code: BlockErrorCode.INVALID_BLOCK_STATE_ROOT, root: postBlockState.hashTreeRoot(), expectedRoot: block.message.stateRoot, preState, @@ -129,6 +129,12 @@ export async function verifyBlocksStateTransitionOnly( }); } + // If blocks are invalid in execution the main promise could resolve before this loop ends. + // In that case stop processing blocks and return early. + if (signal.aborted) { + throw new ErrorAborted("verifyBlockStateTransitionOnly"); + } + postBlockStates[i] = postBlockState; // For metric block profitability @@ -137,7 +143,9 @@ export async function verifyBlocksStateTransitionOnly( const slot = block.message.slot; const payloadEnvelopeInput = payloadEnvelopes?.get(slot) ?? null; - const payloadEnvelope = payloadEnvelopeInput?.hasPayloadEnvelope() ? payloadEnvelopeInput.getPayloadEnvelope() : null; + const payloadEnvelope = payloadEnvelopeInput?.hasPayloadEnvelope() + ? payloadEnvelopeInput.getPayloadEnvelope() + : null; if (payloadEnvelope !== null && isStatePostGloas(postBlockState)) { // verifyStateRoot: false — we verify manually below with BlockError for proper error typing const postPayloadState = postBlockState.processExecutionPayloadEnvelope(payloadEnvelope, { @@ -145,12 +153,6 @@ export async function verifyBlocksStateTransitionOnly( verifyStateRoot: false, }); - if (!isStatePostGloas(postPayloadState)) { - throw Error( - `Expected gloas+ post-envelope-state for execution payload envelope, got fork=${postBlockState.forkName}` - ); - } - const hashTreeRootTimerEnvelope = metrics?.stateHashTreeRootTime.startTimer({ source: StateHashTreeRootSource.envelopeTransition, }); @@ -159,7 +161,7 @@ export async function verifyBlocksStateTransitionOnly( if (!byteArrayEquals(payloadEnvelope.message.stateRoot, stateRootAfterEnvelope)) { throw new BlockError(block, { - code: BlockErrorCode.INVALID_STATE_ROOT, + code: BlockErrorCode.INVALID_PAYLOAD_STATE_ROOT, root: stateRootAfterEnvelope, expectedRoot: payloadEnvelope.message.stateRoot, preState: postBlockState, @@ -172,7 +174,7 @@ export async function verifyBlocksStateTransitionOnly( throw Error("Expected PayloadEnvelopeInput"); } - postPayloadStates.set(slot, {postPayloadState, payloadEnvelopeInput}); + postPayloadStates.set(slot, {postPayloadState: postPayloadState as IBeaconStateViewGloas, payloadEnvelopeInput}); } else { postPayloadStates.set(slot, null); } diff --git a/packages/beacon-node/src/chain/errors/blockError.ts b/packages/beacon-node/src/chain/errors/blockError.ts index 0ab9193ae536..ae3192a5da36 100644 --- a/packages/beacon-node/src/chain/errors/blockError.ts +++ b/packages/beacon-node/src/chain/errors/blockError.ts @@ -33,7 +33,9 @@ export enum BlockErrorCode { /** A signature in the block is invalid (exactly which is unknown). */ INVALID_SIGNATURE = "BLOCK_ERROR_INVALID_SIGNATURE", /** Block transition returns invalid state root. */ - INVALID_STATE_ROOT = "BLOCK_ERROR_INVALID_STATE_ROOT", + INVALID_BLOCK_STATE_ROOT = "BLOCK_ERROR_INVALID_BLOCK_STATE_ROOT", + /** Payload transition returns invalid state root. */ + INVALID_PAYLOAD_STATE_ROOT = "BLOCK_ERROR_INVALID_PAYLOAD_STATE_ROOT", /** Block (its parent) is not a descendant of current finalized block */ NOT_FINALIZED_DESCENDANT = "BLOCK_ERROR_NOT_FINALIZED_DESCENDANT", /** The provided block is from an later slot than its parent. */ @@ -73,7 +75,7 @@ export enum BlockErrorCode { /** Block bid's parentBlockHash does not match the expected execution hash derived from the parent chain */ BID_PARENT_HASH_MISMATCH = "BLOCK_ERROR_BID_PARENT_HASH_MISMATCH", /** Envelope's beacon_block_root does not match the block's hash tree root */ - ENVELOPE_BEACON_BLOCK_ROOT_MISMATCH = "BLOCK_ERROR_ENVELOPE_BEACON_BLOCK_ROOT_MISMATCH", + ENVELOPE_BEACON_BLOCK_ROOT_MISMATCH = "BLOCK_ERROR_ENVELOPE_BLOCK_ROOT_MISMATCH", /** The block's parent execution payload (defined by bid.parent_block_hash) has not been seen */ PARENT_PAYLOAD_UNKNOWN = "BLOCK_ERROR_PARENT_PAYLOAD_UNKNOWN", } @@ -99,7 +101,14 @@ export type BlockErrorType = | {code: BlockErrorCode.UNKNOWN_PROPOSER; proposerIndex: ValidatorIndex} | {code: BlockErrorCode.INVALID_SIGNATURE; state: IBeaconStateView} | { - code: BlockErrorCode.INVALID_STATE_ROOT; + code: BlockErrorCode.INVALID_BLOCK_STATE_ROOT; + root: Uint8Array; + expectedRoot: Uint8Array; + preState: IBeaconStateView; + postState: IBeaconStateView; + } + | { + code: BlockErrorCode.INVALID_PAYLOAD_STATE_ROOT; root: Uint8Array; expectedRoot: Uint8Array; preState: IBeaconStateView; @@ -164,7 +173,8 @@ export function renderBlockErrorType(type: BlockErrorType): Record Date: Thu, 9 Apr 2026 13:47:04 +0700 Subject: [PATCH 22/22] chore: add comments --- .../beacon-node/src/chain/blocks/importExecutionPayload.ts | 3 +++ .../src/chain/blocks/verifyBlocksExecutionPayloads.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index f3b628bc04c5..ed886728e016 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -278,6 +278,9 @@ export async function importExecutionPayload( toRootHex(postPayloadStateRoot), execStatus ); + // TODO GLOAS: if this is called from gossip, the new head may not be correct because it does not go through + // tie-breaker rules since we're not yet at the next slot. Need to find a way to do that? + // see also https://github.com/ChainSafe/lodestar/pull/9164 const newHead = this.recomputeForkChoiceHead(ForkchoiceCaller.importBlock); if (newHead.blockRoot !== oldHead.blockRoot) { diff --git a/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts b/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts index 9d6e1e0cf75a..f6ed57311c19 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts @@ -305,6 +305,7 @@ function getSegmentErrorResponse( const blockHashHex = isGloasBeaconBlock(block.message) ? toRootHex( payloadEnvelopes?.get(blockInput.slot)?.getPayloadEnvelope()?.message.payload.blockHash ?? + // should be parentBlockHash because there is no payload in this case block.message.body.signedExecutionPayloadBid.message.parentBlockHash ) : toRootHex((block.message.body as bellatrix.BeaconBlockBody).executionPayload.blockHash);