From 5473bb570bb528b247f15d780bef00abe66272ba Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Wed, 25 Feb 2026 23:53:27 -0800 Subject: [PATCH 01/38] init --- packages/api/src/beacon/routes/events.ts | 14 + .../api/test/unit/beacon/testData/events.ts | 4 + .../src/api/impl/beacon/blocks/index.ts | 47 ++- .../src/chain/blocks/importBlock.ts | 25 +- .../chain/blocks/importExecutionPayload.ts | 165 +++++++++++ .../blocks/payloadEnvelopeInput/index.ts | 2 + .../payloadEnvelopeInput.ts | 274 ++++++++++++++++++ .../blocks/payloadEnvelopeInput/types.ts | 46 +++ .../blocks/writePayloadEnvelopeInputToDb.ts | 90 ++++++ packages/beacon-node/src/chain/chain.ts | 17 +- packages/beacon-node/src/chain/interface.ts | 11 +- .../beacon-node/src/chain/seenCache/index.ts | 2 +- .../seenCache/seenExecutionPayloadEnvelope.ts | 34 --- .../seenCache/seenPayloadEnvelopeInput.ts | 109 +++++++ .../validation/executionPayloadEnvelope.ts | 31 +- .../beacon-node/src/chain/validatorMonitor.ts | 10 + .../src/metrics/metrics/lodestar.ts | 19 ++ .../network/processor/extractSlotRootFns.ts | 23 +- .../src/network/processor/gossipHandlers.ts | 40 ++- packages/beacon-node/src/util/sszBytes.ts | 100 ++++++- .../fork-choice/src/forkChoice/forkChoice.ts | 10 - .../fork-choice/src/protoArray/interface.ts | 4 - 22 files changed, 980 insertions(+), 97 deletions(-) create mode 100644 packages/beacon-node/src/chain/blocks/importExecutionPayload.ts create mode 100644 packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/index.ts create mode 100644 packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts create mode 100644 packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/types.ts create mode 100644 packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts delete mode 100644 packages/beacon-node/src/chain/seenCache/seenExecutionPayloadEnvelope.ts create mode 100644 packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts diff --git a/packages/api/src/beacon/routes/events.ts b/packages/api/src/beacon/routes/events.ts index 4b1dfbe33d60..06ae34700eb9 100644 --- a/packages/api/src/beacon/routes/events.ts +++ b/packages/api/src/beacon/routes/events.ts @@ -88,6 +88,8 @@ export enum EventType { blobSidecar = "blob_sidecar", /** The node has received a valid DataColumnSidecar (from P2P or API) */ dataColumnSidecar = "data_column_sidecar", + /** The node has verified that the execution payload and blobs for a block are available */ + executionPayloadAvailable = "execution_payload_available", } export const eventTypes: {[K in EventType]: K} = { @@ -108,6 +110,7 @@ export const eventTypes: {[K in EventType]: K} = { [EventType.payloadAttributes]: EventType.payloadAttributes, [EventType.blobSidecar]: EventType.blobSidecar, [EventType.dataColumnSidecar]: EventType.dataColumnSidecar, + [EventType.executionPayloadAvailable]: EventType.executionPayloadAvailable, }; export type EventData = { @@ -157,6 +160,10 @@ export type EventData = { [EventType.payloadAttributes]: {version: ForkName; data: SSEPayloadAttributes}; [EventType.blobSidecar]: BlobSidecarSSE; [EventType.dataColumnSidecar]: DataColumnSidecarSSE; + [EventType.executionPayloadAvailable]: { + slot: Slot; + blockRoot: RootHex; + }; }; export type BeaconEvent = {[K in EventType]: {type: K; message: EventData[K]}}[EventType]; @@ -311,6 +318,13 @@ export function getTypeByEvent(config: ChainForkConfig): {[K in EventType]: Type [EventType.payloadAttributes]: WithVersion((fork) => getPostBellatrixForkTypes(fork).SSEPayloadAttributes), [EventType.blobSidecar]: blobSidecarSSE, [EventType.dataColumnSidecar]: dataColumnSidecarSSE, + [EventType.executionPayloadAvailable]: new ContainerType( + { + slot: ssz.Slot, + blockRoot: stringType, + }, + {jsonCase: "eth2"} + ), [EventType.lightClientOptimisticUpdate]: WithVersion( (fork) => getPostAltairForkTypes(fork).LightClientOptimisticUpdate diff --git a/packages/api/test/unit/beacon/testData/events.ts b/packages/api/test/unit/beacon/testData/events.ts index d9da1503107c..dbab2401a8f8 100644 --- a/packages/api/test/unit/beacon/testData/events.ts +++ b/packages/api/test/unit/beacon/testData/events.ts @@ -274,4 +274,8 @@ export const eventTestData: EventData = { "0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505", ], }), + [EventType.executionPayloadAvailable]: { + slot: 10, + blockRoot: "0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf", + }, }; 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 37d34f6c3345..842b70021b56 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -34,6 +34,7 @@ import { } from "@lodestar/types"; import {fromHex, sleep, toHex, toRootHex} from "@lodestar/utils"; import {BlockInputSource, isBlockInputBlobs, isBlockInputColumns} from "../../../../chain/blocks/blockInput/index.js"; +import {PayloadEnvelopeInputSource} from "../../../../chain/blocks/payloadEnvelopeInput/index.ts"; import {ImportBlockOpts} from "../../../../chain/blocks/types.js"; import {verifyBlocksInEpoch} from "../../../../chain/blocks/verifyBlock.js"; import {BeaconChain} from "../../../../chain/chain.js"; @@ -675,6 +676,7 @@ export function getBeaconBlockApi({ } if (cachedResult.cells && cachedResult.blobsBundle.commitments.length > 0) { + const timer = metrics?.peerDas.dataColumnSidecarComputationTime.startTimer(); const cellsAndProofs = cachedResult.cells.map((rowCells, rowIndex) => ({ cells: rowCells, proofs: cachedResult.blobsBundle.proofs.slice( @@ -684,24 +686,48 @@ export function getBeaconBlockApi({ })); dataColumnSidecars = getDataColumnSidecarsForGloas(slot, envelope.beaconBlockRoot, cellsAndProofs); + timer?.(); } } else { // TODO GLOAS: will this api be used by builders or only for self-building? } - // TODO GLOAS: Verify execution payload envelope signature - // For self-builds, the proposer signs with their own validator key - // For external builders, verify using the builder's registered pubkey - // Use verify_execution_payload_envelope_signature(state, signed_envelope) + const payloadInput = chain.seenPayloadEnvelopeInput.get(blockRootHex); + if (!payloadInput) { + throw new ApiError(404, `PayloadEnvelopeInput not found for block root ${blockRootHex}`); + } - // TODO GLOAS: Process execution payload via state transition - // Call process_execution_payload(state, signed_envelope, execution_engine) + if (payloadInput.hasPayloadEnvelope()) { + throw new ApiError(409, `Payload envelope already received for block root ${blockRootHex}`); + } - // TODO GLOAS: Update fork choice with the execution payload - // Call on_execution_payload(store, signed_envelope) to update fork choice state + payloadInput.addPayloadEnvelope({ + envelope: signedExecutionPayloadEnvelope, + source: PayloadEnvelopeInputSource.api, + seenTimestampSec, + peerIdStr: undefined, + }); - // TODO GLOAS: Add envelope and data columns to block input via seenBlockInputCache - // and trigger block import (Gloas block import requires both beacon block and envelope) + // For self-builds, add data columns from cached block production result + if (isSelfBuild && dataColumnSidecars.length > 0) { + for (const columnSidecar of dataColumnSidecars) { + payloadInput.addColumn({ + columnSidecar, + source: PayloadEnvelopeInputSource.api, + seenTimestampSec, + peerIdStr: undefined, + }); + } + } + + if (payloadInput.isComplete()) { + await chain.importExecutionPayload(payloadInput); + chain.persistPayloadEnvelope(payloadInput); + chain.emitter.emit(routes.events.EventType.executionPayloadAvailable, { + slot, + blockRoot: blockRootHex, + }); + } const valLogMeta = { slot, @@ -719,6 +745,7 @@ export function getBeaconBlockApi({ const delaySec = seenTimestampSec - computeTimeAtSlot(config, slot, chain.genesisTime); metrics?.gossipExecutionPayloadEnvelope.elapsedTimeTillReceived.observe({source: OpSource.api}, delaySec); + chain.validatorMonitor?.registerExecutionPayloadEnvelope(OpSource.api, delaySec, signedExecutionPayloadEnvelope); chain.logger.info("Publishing execution payload envelope", valLogMeta); diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index 7225441df389..69bce99f3479 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -8,7 +8,15 @@ import { NotReorgedReason, getSafeExecutionBlockHash, } from "@lodestar/fork-choice"; -import {ForkPostAltair, ForkPostElectra, ForkSeq, MAX_SEED_LOOKAHEAD, SLOTS_PER_EPOCH} from "@lodestar/params"; +import { + ForkPostAltair, + ForkPostElectra, + ForkPostGloas, + ForkSeq, + MAX_SEED_LOOKAHEAD, + SLOTS_PER_EPOCH, + isForkPostGloas, +} from "@lodestar/params"; import { CachedBeaconStateAltair, EpochCache, @@ -20,7 +28,7 @@ import { isStartSlotOfEpoch, isStateValidatorsNodesPopulated, } from "@lodestar/state-transition"; -import {Attestation, BeaconBlock, altair, capella, electra, phase0, ssz} from "@lodestar/types"; +import {Attestation, BeaconBlock, SignedBeaconBlock, altair, capella, electra, phase0, ssz} from "@lodestar/types"; import {isErrorAborted, toRootHex} from "@lodestar/utils"; import {ZERO_HASH_HEX} from "../../constants/index.js"; import {callInNextEventLoop} from "../../util/eventLoop.js"; @@ -118,6 +126,19 @@ export async function importBlock( // Some block event handlers require state being in state cache so need to do this before emitting EventType.block this.regen.processState(blockRootHex, postState); + // For Gloas blocks, create PayloadEnvelopeInput so it's available for later payload import + const forkName = this.config.getForkName(blockSlot); + if (isForkPostGloas(forkName)) { + this.seenPayloadEnvelopeInput.add({ + blockRootHex, + block: block as SignedBeaconBlock, + sampledColumns: this.custodyConfig.sampledColumns, + custodyColumns: this.custodyConfig.custodyColumns, + timeCreatedSec: fullyVerifiedBlock.seenTimestampSec, + }); + this.logger.debug("Created PayloadEnvelopeInput for Gloas block", {slot: blockSlot, root: blockRootHex}); + } + this.metrics?.importBlock.bySource.inc({source: source.source}); this.logger.verbose("Added block to forkchoice and state cache", {slot: blockSlot, root: blockRootHex}); diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts new file mode 100644 index 000000000000..c94d5e318bc4 --- /dev/null +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -0,0 +1,165 @@ +import {ForkName} from "@lodestar/params"; +import {CachedBeaconStateGloas} from "@lodestar/state-transition"; +import {processExecutionPayloadEnvelope} from "@lodestar/state-transition/block"; +import {fromHex} from "@lodestar/utils"; +import {ExecutionPayloadStatus} from "../../execution/index.js"; +import {BeaconChain} from "../chain.js"; +import {RegenCaller} from "../regen/interface.js"; +import {PayloadEnvelopeInput} from "../seenCache/seenPayloadEnvelopeInput.js"; + +export enum PayloadErrorCode { + EXECUTION_ENGINE_INVALID = "PAYLOAD_ERROR_EXECUTION_ENGINE_INVALID", + EXECUTION_ENGINE_ERROR = "PAYLOAD_ERROR_EXECUTION_ENGINE_ERROR", + BLOCK_NOT_IN_FORK_CHOICE = "PAYLOAD_ERROR_BLOCK_NOT_IN_FORK_CHOICE", + STATE_TRANSITION_ERROR = "PAYLOAD_ERROR_STATE_TRANSITION_ERROR", +} + +export type PayloadErrorType = + | { + code: PayloadErrorCode.EXECUTION_ENGINE_INVALID; + execStatus: ExecutionPayloadStatus; + errorMessage: string; + } + | { + code: PayloadErrorCode.EXECUTION_ENGINE_ERROR; + execStatus: ExecutionPayloadStatus; + errorMessage: string; + } + | { + code: PayloadErrorCode.BLOCK_NOT_IN_FORK_CHOICE; + blockRootHex: string; + } + | { + code: PayloadErrorCode.STATE_TRANSITION_ERROR; + message: string; + }; + +export class PayloadError extends Error { + type: PayloadErrorType; + + constructor(type: PayloadErrorType, message?: string) { + super(message ?? type.code); + this.type = type; + } +} + +export type ImportPayloadResult = { + success: boolean; +}; + +/** + * Import an execution payload envelope after all data is available. + * + * This function: + * 1. Gets the ProtoBlock from fork choice + * 2. Regenerates the block state + * 3. Runs EL verification (notifyNewPayload) in parallel with state transition + * 4. Updates fork choice from PENDING → FULL status + * 5. Caches the post-state + * 6. Records metrics for column sources + * + * Note: The actual DB write happens asynchronously via writePayloadEnvelopeInputToDb + */ +export async function importExecutionPayload( + this: BeaconChain, + payloadInput: PayloadEnvelopeInput +): Promise { + const envelope = payloadInput.getPayloadEnvelope(); + const blockRootHex = payloadInput.blockRootHex; + + // 1. Get ProtoBlock for parent root lookup + const protoBlock = this.forkChoice.getBlockHex(blockRootHex); + if (!protoBlock) { + throw new PayloadError({ + code: PayloadErrorCode.BLOCK_NOT_IN_FORK_CHOICE, + blockRootHex, + }); + } + + // 2. Get pre-state for state transition + // We need the block state (post-block, pre-payload) to process the envelope + const blockState = (await this.regen.getBlockSlotState( + protoBlock, + protoBlock.slot, + {dontTransferCache: true}, + RegenCaller.processBlock + )) as CachedBeaconStateGloas; + + // 3. Run verification steps in parallel (like verifyBlocksInEpoch) + // Note: No data availability check needed here - importExecutionPayload is only + // called when payloadInput.isComplete() is true, so all data is already available. + const [execResult, _postPayloadState] = await Promise.all([ + // EL verification - notifyNewPayload + this.executionEngine.notifyNewPayload( + ForkName.gloas, + envelope.message.payload, + payloadInput.getVersionedHashes(), + fromHex(protoBlock.parentRoot), + envelope.message.executionRequests + ), + + // Process execution payload envelope (state transition) + // Note: signature verification is done as part of processExecutionPayloadEnvelope when verify=true + (async () => { + try { + // Clone state to avoid mutating the cached state + const mutableState = blockState.clone(); + processExecutionPayloadEnvelope(mutableState, envelope, true); + return {postPayloadState: mutableState}; + } catch (e) { + throw new PayloadError( + { + code: PayloadErrorCode.STATE_TRANSITION_ERROR, + message: (e as Error).message, + }, + `State transition error: ${(e as Error).message}` + ); + } + })(), + ]); + + // 4. Handle EL response + if (execResult.status === ExecutionPayloadStatus.INVALID) { + throw new PayloadError({ + code: PayloadErrorCode.EXECUTION_ENGINE_INVALID, + execStatus: execResult.status, + errorMessage: execResult.validationError ?? "", + }); + } + + if ( + execResult.status === ExecutionPayloadStatus.INVALID_BLOCK_HASH || + execResult.status === ExecutionPayloadStatus.ELERROR || + execResult.status === ExecutionPayloadStatus.UNAVAILABLE + ) { + throw new PayloadError({ + code: PayloadErrorCode.EXECUTION_ENGINE_ERROR, + execStatus: execResult.status, + errorMessage: execResult.validationError ?? "", + }); + } + + // VALID, ACCEPTED, or SYNCING - proceed with import + + // 5. Update fork choice: PENDING → FULL + // TODO GLOAS: Update API when nc/epbs-fc merged + // this.forkChoice.onExecutionPayload( + // envelope.message.beaconBlockRoot, + // _executionPayloadState.hashTreeRoot() + // ); + + // 6. Cache payload state + // TODO GLOAS: Enable when PR #8868 merged (adds processPayloadState) + // this.regen.processPayloadState(_postPayloadState); + + // 7. 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}); + } + + // 8. Write payload envelope to DB (handled separately, see writePayloadEnvelopeInputToDb) + // The write + prune happens asynchronously after import completes + + return {success: true}; +} diff --git a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/index.ts b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/index.ts new file mode 100644 index 000000000000..368f98324f22 --- /dev/null +++ b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/index.ts @@ -0,0 +1,2 @@ +export * from "./payloadEnvelopeInput.js"; +export * from "./types.js"; diff --git a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts new file mode 100644 index 000000000000..843bf3ad07ec --- /dev/null +++ b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts @@ -0,0 +1,274 @@ +import {ColumnIndex, RootHex, Slot, ValidatorIndex, deneb, gloas} from "@lodestar/types"; +import {toRootHex} from "@lodestar/utils"; +import {VersionedHashes} from "../../../execution/index.js"; +import {kzgCommitmentToVersionedHash} from "../../../util/blobs.js"; +import {AddPayloadEnvelopeProps, ColumnWithSource, CreateFromBlockProps, SourceMeta} from "./types.js"; + +/** + * Discriminated union for PayloadEnvelopeInput state. + * + * 4 possible states: + * 1. No payload, no columns (initial state, or after adding columns without payload) + * 2. No payload, all columns (waiting for payload) + * 3. Has payload, no columns (waiting for columns) + * 4. Complete (has payload and all columns) + */ +export type PayloadEnvelopeInputState = + | { + hasPayload: false; + hasAllColumns: false; + } + | { + hasPayload: false; + hasAllColumns: true; + } + | { + hasPayload: true; + hasAllColumns: false; + payloadEnvelope: gloas.SignedExecutionPayloadEnvelope; + payloadEnvelopeSource: SourceMeta; + } + | { + hasPayload: true; + hasAllColumns: true; + payloadEnvelope: gloas.SignedExecutionPayloadEnvelope; + payloadEnvelopeSource: SourceMeta; + timeCompleteSec: number; + }; + +type PromiseParts = { + promise: Promise; + resolve: (value: T) => void; + reject: (e: Error) => void; +}; + +function createPromise(): PromiseParts { + let resolve!: (value: T) => void; + let reject!: (e: Error) => void; + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + return {promise, resolve, reject}; +} + +/** + * Tracks bid + payload envelope + data columns for a Gloas block. + * + * Created during block import from signedExecutionPayloadBid in block body. + * Always has bid (required for creation). + * + * Completion requires: payload envelope + all sampled columns + * (bid is always present from creation) + */ +export class PayloadEnvelopeInput { + readonly blockRootHex: RootHex; + readonly slot: Slot; + readonly bid: gloas.ExecutionPayloadBid; + readonly versionedHashes: VersionedHashes; + + private columnsCache = new Map(); + + private readonly sampledColumns: ColumnIndex[]; + private readonly custodyColumns: ColumnIndex[]; + + private timeCreatedSec: number; + + // Promise for waiting + private readonly dataPromise: PromiseParts; + + state: PayloadEnvelopeInputState; + + private constructor(props: { + blockRootHex: RootHex; + slot: Slot; + bid: gloas.ExecutionPayloadBid; + sampledColumns: ColumnIndex[]; + custodyColumns: ColumnIndex[]; + timeCreatedSec: number; + }) { + this.blockRootHex = props.blockRootHex; + this.slot = props.slot; + this.bid = props.bid; + this.versionedHashes = props.bid.blobKzgCommitments.map(kzgCommitmentToVersionedHash); + this.sampledColumns = props.sampledColumns; + this.custodyColumns = props.custodyColumns; + this.timeCreatedSec = props.timeCreatedSec; + this.dataPromise = createPromise(); + + // Check if all columns already satisfied (no blobs = no columns needed) + const noBlobs = props.bid.blobKzgCommitments.length === 0; + const noSampledColumns = props.sampledColumns.length === 0; + const hasAllColumns = noBlobs || noSampledColumns; + + this.state = hasAllColumns ? {hasPayload: false, hasAllColumns: true} : {hasPayload: false, hasAllColumns: false}; + } + + static createFromBlock(props: CreateFromBlockProps): PayloadEnvelopeInput { + const bid = (props.block.message.body as gloas.BeaconBlockBody).signedExecutionPayloadBid.message; + return new PayloadEnvelopeInput({ + blockRootHex: props.blockRootHex, + slot: props.block.message.slot, + bid, + sampledColumns: props.sampledColumns, + custodyColumns: props.custodyColumns, + timeCreatedSec: props.timeCreatedSec, + }); + } + + getBid(): gloas.ExecutionPayloadBid { + return this.bid; + } + + getBuilderIndex(): ValidatorIndex { + return this.bid.builderIndex; + } + + getBlockHashHex(): RootHex { + return toRootHex(this.bid.blockHash); + } + + getBlobKzgCommitments(): deneb.BlobKzgCommitments { + return this.bid.blobKzgCommitments; + } + + addPayloadEnvelope(props: AddPayloadEnvelopeProps): void { + if (this.state.hasPayload) { + throw new Error("Payload envelope already set"); + } + // Validate beacon_block_root matches + if (toRootHex(props.envelope.message.beaconBlockRoot) !== this.blockRootHex) { + throw new Error("Payload envelope beacon_block_root mismatch"); + } + + const source: SourceMeta = { + source: props.source, + seenTimestampSec: props.seenTimestampSec, + peerIdStr: props.peerIdStr, + }; + + // Transition state: hasPayload becomes true + if (this.state.hasAllColumns) { + // Complete state + this.state = { + hasPayload: true, + hasAllColumns: true, + payloadEnvelope: props.envelope, + payloadEnvelopeSource: source, + timeCompleteSec: props.seenTimestampSec, + }; + this.dataPromise.resolve(props.envelope); + } else { + // Has payload, waiting for columns + this.state = { + hasPayload: true, + hasAllColumns: false, + payloadEnvelope: props.envelope, + payloadEnvelopeSource: source, + }; + } + } + + addColumn(columnWithSource: ColumnWithSource): void { + const {columnSidecar, seenTimestampSec} = columnWithSource; + this.columnsCache.set(columnSidecar.index, columnWithSource); + + // Check if we now have all sampled columns + const hasAllSampledColumns = this.sampledColumns.every((idx) => this.columnsCache.has(idx)); + const noBlobs = this.bid.blobKzgCommitments.length === 0; + const hasAllColumns = hasAllSampledColumns || noBlobs || this.sampledColumns.length === 0; + + if (!hasAllColumns) { + // Still waiting for more columns, state unchanged + return; + } + + // hasAllColumns is now true, transition state + if (this.state.hasPayload) { + // Complete state + this.state = { + hasPayload: true, + hasAllColumns: true, + payloadEnvelope: this.state.payloadEnvelope, + payloadEnvelopeSource: this.state.payloadEnvelopeSource, + timeCompleteSec: seenTimestampSec, + }; + this.dataPromise.resolve(this.state.payloadEnvelope); + } else { + // No payload yet, all columns ready + this.state = { + hasPayload: false, + hasAllColumns: true, + }; + } + } + + // --- Other getters --- + + getVersionedHashes(): VersionedHashes { + return this.versionedHashes; + } + + hasPayloadEnvelope(): boolean { + return this.state.hasPayload; + } + + getPayloadEnvelope(): gloas.SignedExecutionPayloadEnvelope { + if (!this.state.hasPayload) throw new Error("Payload envelope not set"); + return this.state.payloadEnvelope; + } + + getPayloadEnvelopeSource(): SourceMeta { + if (!this.state.hasPayload) throw new Error("Payload envelope source not set"); + return this.state.payloadEnvelopeSource; + } + + getSampledColumns(): gloas.DataColumnSidecars { + return this.sampledColumns + .filter((idx) => this.columnsCache.has(idx)) + .map((idx) => this.columnsCache.get(idx)!.columnSidecar); + } + + getSampledColumnsWithSource(): ColumnWithSource[] { + return this.sampledColumns.filter((idx) => this.columnsCache.has(idx)).map((idx) => this.columnsCache.get(idx)!); + } + + getCustodyColumns(): gloas.DataColumnSidecars { + return this.custodyColumns + .filter((idx) => this.columnsCache.has(idx)) + .map((idx) => this.columnsCache.get(idx)!.columnSidecar); + } + + getTimeCreated(): number { + return this.timeCreatedSec; + } + + getTimeComplete(): number { + if (!this.state.hasPayload || !this.state.hasAllColumns) throw new Error("Not yet complete"); + return this.state.timeCompleteSec; + } + + isComplete(): boolean { + return this.state.hasPayload && this.state.hasAllColumns; + } + + async waitForData(): Promise { + return this.dataPromise.promise; + } + + getLogMeta(): { + slot: number; + blockRoot: string; + hasPayload: boolean; + columnsCount: number; + sampledColumnsCount: number; + } { + return { + slot: this.slot, + blockRoot: this.blockRootHex, + hasPayload: this.state.hasPayload, + columnsCount: this.columnsCache.size, + sampledColumnsCount: this.sampledColumns.length, + }; + } +} diff --git a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/types.ts b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/types.ts new file mode 100644 index 000000000000..3e35849b23eb --- /dev/null +++ b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/types.ts @@ -0,0 +1,46 @@ +import {ForkPostGloas} from "@lodestar/params"; +import {ColumnIndex, RootHex, SignedBeaconBlock, gloas} from "@lodestar/types"; + +export enum PayloadEnvelopeInputSource { + gossip = "gossip", + api = "api", + engine = "engine", + byRange = "req_resp_by_range", + byRoot = "req_resp_by_root", + recovery = "recovery", +} + +/** + * Metadata about the source of data for PayloadEnvelopeInput. + */ +export type SourceMeta = { + source: PayloadEnvelopeInputSource; + seenTimestampSec: number; + peerIdStr?: string; +}; + +/** + * Gloas data column sidecar with source metadata. + * Uses gloas.DataColumnSidecar (not fulu.DataColumnSidecar). + */ +export type ColumnWithSource = SourceMeta & { + columnSidecar: gloas.DataColumnSidecar; +}; + +/** + * Props for creating a PayloadEnvelopeInput from a block. + */ +export type CreateFromBlockProps = { + blockRootHex: RootHex; + block: SignedBeaconBlock; + sampledColumns: ColumnIndex[]; + custodyColumns: ColumnIndex[]; + timeCreatedSec: number; +}; + +/** + * Props for adding a payload envelope to PayloadEnvelopeInput. + */ +export type AddPayloadEnvelopeProps = SourceMeta & { + envelope: gloas.SignedExecutionPayloadEnvelope; +}; diff --git a/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts b/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts new file mode 100644 index 000000000000..401bf17b5880 --- /dev/null +++ b/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts @@ -0,0 +1,90 @@ +import {fromHex} from "@lodestar/utils"; +import {BeaconChain} from "../chain.js"; +import {PayloadEnvelopeInput} from "../seenCache/seenPayloadEnvelopeInput.js"; + +/** + * Persists payload envelope data to DB. This operation must be eventually completed if a payload is imported. + * Else the node will be in an inconsistent state that can lead to being stuck. + * + * This operation may be performed before, during or after importing to the fork choice. As long as errors + * are handled properly for eventual consistency. + */ +export async function writePayloadEnvelopeInputToDb( + this: BeaconChain, + payloadInput: PayloadEnvelopeInput +): Promise { + const envelope = payloadInput.getPayloadEnvelope(); + const blockRootHex = payloadInput.blockRootHex; + const blockRoot = fromHex(blockRootHex); + + const fnPromises: Promise[] = []; + + const envelopeBytes = this.serializedCache.get(envelope); + if (envelopeBytes) { + fnPromises.push( + this.db.executionPayloadEnvelope.putBinary(this.db.executionPayloadEnvelope.getId(envelope), envelopeBytes) + ); + } else { + fnPromises.push(this.db.executionPayloadEnvelope.add(envelope)); + } + + // payloadInput.isComplete() must be true in order to reach this function. + // So we should have all kzg commitments here. + const blobsLen = payloadInput.getBlobKzgCommitments().length; + if (blobsLen > 0) { + const {custodyColumns} = this.custodyConfig; + const dataColumnSidecars = payloadInput.getCustodyColumns(); + + const binaryPuts = []; + const nonbinaryPuts = []; + for (const dataColumnSidecar of dataColumnSidecars) { + const serialized = this.serializedCache.get(dataColumnSidecar); + if (serialized) { + binaryPuts.push({key: dataColumnSidecar.index, value: serialized}); + } else { + nonbinaryPuts.push(dataColumnSidecar); + } + } + fnPromises.push(this.db.dataColumnSidecar.putManyBinary(blockRoot, binaryPuts)); + fnPromises.push(this.db.dataColumnSidecar.putMany(blockRoot, nonbinaryPuts)); + + this.logger.debug("Persisting payload dataColumnSidecars to hot DB", { + slot: payloadInput.slot, + root: blockRootHex, + dataColumnSidecars: dataColumnSidecars.length, + numBlobs: blobsLen, + custodyColumns: custodyColumns.length, + }); + } + + await Promise.all(fnPromises); + this.logger.debug("Persisted payload envelope to db", { + slot: payloadInput.slot, + root: blockRootHex, + }); +} + +export async function persistPayloadEnvelopeInput( + this: BeaconChain, + payloadInput: PayloadEnvelopeInput +): Promise { + await writePayloadEnvelopeInputToDb + .call(this, payloadInput) + .catch((e) => { + this.logger.error( + "Error persisting payload envelope in hot db", + { + slot: payloadInput.slot, + root: payloadInput.blockRootHex, + }, + e + ); + }) + .finally(() => { + this.seenPayloadEnvelopeInput.delete(payloadInput.blockRootHex); + this.logger.debug("Pruned payload envelope input", { + slot: payloadInput.slot, + root: payloadInput.blockRootHex, + }); + }); +} diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index a0a29bcc0c0e..745641db0720 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -76,8 +76,10 @@ import {ArchiveStore} from "./archiveStore/archiveStore.js"; import {CheckpointBalancesCache} from "./balancesCache.js"; import {BeaconProposerCache} from "./beaconProposerCache.js"; import {IBlockInput, isBlockInputBlobs, isBlockInputColumns} from "./blocks/blockInput/index.js"; +import {importExecutionPayload} from "./blocks/importExecutionPayload.js"; import {BlockProcessor, ImportBlockOpts} from "./blocks/index.js"; import {persistBlockInputs} from "./blocks/writeBlockInputToDb.ts"; +import {persistPayloadEnvelopeInput} from "./blocks/writePayloadEnvelopeInputToDb.js"; import {BlsMultiThreadWorkerPool, BlsSingleThreadVerifier, IBlsVerifier} from "./bls/index.js"; import {ColumnReconstructionTracker} from "./ColumnReconstructionTracker.js"; import {ChainEvent, ChainEventEmitter} from "./emitter.js"; @@ -107,8 +109,8 @@ import { SeenBlockProposers, SeenContributionAndProof, SeenExecutionPayloadBids, - SeenExecutionPayloadEnvelopes, SeenPayloadAttesters, + SeenPayloadEnvelopeInput, SeenSyncCommitteeMessages, } from "./seenCache/index.js"; import {SeenAggregatedAttestations} from "./seenCache/seenAggregateAndProof.js"; @@ -180,13 +182,13 @@ export class BeaconChain implements IBeaconChain { readonly seenAggregators = new SeenAggregators(); readonly seenPayloadAttesters = new SeenPayloadAttesters(); readonly seenAggregatedAttestations: SeenAggregatedAttestations; - readonly seenExecutionPayloadEnvelopes = new SeenExecutionPayloadEnvelopes(); readonly seenExecutionPayloadBids = new SeenExecutionPayloadBids(); readonly seenBlockProposers = new SeenBlockProposers(); readonly seenSyncCommitteeMessages = new SeenSyncCommitteeMessages(); readonly seenContributionAndProof: SeenContributionAndProof; readonly seenAttestationDatas: SeenAttestationDatas; readonly seenBlockInputCache: SeenBlockInput; + readonly seenPayloadEnvelopeInput: SeenPayloadEnvelopeInput; // Seen cache for liveness checks readonly seenBlockAttesters = new SeenBlockAttesters(); @@ -323,6 +325,12 @@ export class BeaconChain implements IBeaconChain { metrics, logger, }); + this.seenPayloadEnvelopeInput = new SeenPayloadEnvelopeInput({ + chainEvents: emitter, + signal, + metrics, + logger, + }); this._earliestAvailableSlot = anchorState.slot; @@ -996,6 +1004,10 @@ export class BeaconChain implements IBeaconChain { return this.blockProcessor.processBlocksJob(blocks, opts); } + importExecutionPayload = importExecutionPayload.bind(this); + + persistPayloadEnvelope = persistPayloadEnvelopeInput.bind(this); + getStatus(): Status { const head = this.forkChoice.getHead(); const finalizedCheckpoint = this.forkChoice.getFinalizedCheckpoint(); @@ -1377,7 +1389,6 @@ export class BeaconChain implements IBeaconChain { this.logger.verbose("Fork choice finalized", {epoch: cp.epoch, root: cp.rootHex}); const finalizedSlot = computeStartSlotAtEpoch(cp.epoch); this.seenBlockProposers.prune(finalizedSlot); - this.seenExecutionPayloadEnvelopes.prune(finalizedSlot); // Update validator custody to account for effective balance changes await this.updateValidatorsCustodyRequirement(cp); diff --git a/packages/beacon-node/src/chain/interface.ts b/packages/beacon-node/src/chain/interface.ts index cc300e9a5dd7..b17e16b8c9b6 100644 --- a/packages/beacon-node/src/chain/interface.ts +++ b/packages/beacon-node/src/chain/interface.ts @@ -32,6 +32,7 @@ import {IArchiveStore} from "./archiveStore/interface.js"; import {CheckpointBalancesCache} from "./balancesCache.js"; import {BeaconProposerCache, ProposerPreparationData} from "./beaconProposerCache.js"; import {IBlockInput} from "./blocks/blockInput/index.js"; +import {ImportPayloadResult} from "./blocks/importExecutionPayload.js"; import {ImportBlockOpts} from "./blocks/types.js"; import {IBlsVerifier} from "./bls/index.js"; import {ColumnReconstructionTracker} from "./ColumnReconstructionTracker.js"; @@ -58,7 +59,6 @@ import { SeenBlockProposers, SeenContributionAndProof, SeenExecutionPayloadBids, - SeenExecutionPayloadEnvelopes, SeenPayloadAttesters, SeenSyncCommitteeMessages, } from "./seenCache/index.js"; @@ -66,6 +66,7 @@ import {SeenAggregatedAttestations} from "./seenCache/seenAggregateAndProof.js"; import {SeenAttestationDatas} from "./seenCache/seenAttestationData.js"; import {SeenBlockAttesters} from "./seenCache/seenBlockAttesters.js"; import {SeenBlockInput} from "./seenCache/seenGossipBlockInput.js"; +import {PayloadEnvelopeInput, SeenPayloadEnvelopeInput} from "./seenCache/seenPayloadEnvelopeInput.js"; import {ShufflingCache} from "./shufflingCache.js"; import {ValidatorMonitor} from "./validatorMonitor.js"; @@ -128,13 +129,13 @@ export interface IBeaconChain { readonly seenAggregators: SeenAggregators; readonly seenPayloadAttesters: SeenPayloadAttesters; readonly seenAggregatedAttestations: SeenAggregatedAttestations; - readonly seenExecutionPayloadEnvelopes: SeenExecutionPayloadEnvelopes; readonly seenExecutionPayloadBids: SeenExecutionPayloadBids; readonly seenBlockProposers: SeenBlockProposers; readonly seenSyncCommitteeMessages: SeenSyncCommitteeMessages; readonly seenContributionAndProof: SeenContributionAndProof; readonly seenAttestationDatas: SeenAttestationDatas; readonly seenBlockInputCache: SeenBlockInput; + readonly seenPayloadEnvelopeInput: SeenPayloadEnvelopeInput; // Seen cache for liveness checks readonly seenBlockAttesters: SeenBlockAttesters; @@ -242,6 +243,12 @@ export interface IBeaconChain { /** Process a chain of blocks until complete */ processChainSegment(blocks: IBlockInput[], opts?: ImportBlockOpts): Promise; + /** Import execution payload envelope to EL and fork choice after data is available */ + importExecutionPayload(payloadInput: PayloadEnvelopeInput): Promise; + + /** Persist payload envelope to DB and prune from seen cache */ + persistPayloadEnvelope(payloadInput: PayloadEnvelopeInput): Promise; + getStatus(): Status; recomputeForkChoiceHead(caller: ForkchoiceCaller): ProtoBlock; diff --git a/packages/beacon-node/src/chain/seenCache/index.ts b/packages/beacon-node/src/chain/seenCache/index.ts index f16ae79f7f2e..23782e14385b 100644 --- a/packages/beacon-node/src/chain/seenCache/index.ts +++ b/packages/beacon-node/src/chain/seenCache/index.ts @@ -3,5 +3,5 @@ export {SeenBlockProposers} from "./seenBlockProposers.js"; export {SeenSyncCommitteeMessages} from "./seenCommittee.js"; export {SeenContributionAndProof} from "./seenCommitteeContribution.js"; export {SeenExecutionPayloadBids} from "./seenExecutionPayloadBids.js"; -export {SeenExecutionPayloadEnvelopes} from "./seenExecutionPayloadEnvelope.js"; export {SeenBlockInput} from "./seenGossipBlockInput.js"; +export {PayloadEnvelopeInput, SeenPayloadEnvelopeInput} from "./seenPayloadEnvelopeInput.js"; diff --git a/packages/beacon-node/src/chain/seenCache/seenExecutionPayloadEnvelope.ts b/packages/beacon-node/src/chain/seenCache/seenExecutionPayloadEnvelope.ts deleted file mode 100644 index cbd389d29449..000000000000 --- a/packages/beacon-node/src/chain/seenCache/seenExecutionPayloadEnvelope.ts +++ /dev/null @@ -1,34 +0,0 @@ -import {RootHex, Slot} from "@lodestar/types"; - -/** - * Cache to prevent processing multiple execution payload envelopes for the same block root. - * Only one builder qualifies to submit an execution payload for a given slot. - * We only keep track of envelopes of unfinalized slots. - * [IGNORE] The node has not seen another valid `SignedExecutionPayloadEnvelope` for this block root. - */ -export class SeenExecutionPayloadEnvelopes { - private readonly slotByBlockRoot = new Map(); - private finalizedSlot: Slot = 0; - - isKnown(blockRoot: RootHex): boolean { - return this.slotByBlockRoot.has(blockRoot); - } - - add(blockRoot: RootHex, slot: Slot): void { - if (slot < this.finalizedSlot) { - throw Error(`slot ${slot} < finalizedSlot ${this.finalizedSlot}`); - } - - this.slotByBlockRoot.set(blockRoot, slot); - } - - prune(finalizedSlot: Slot): void { - this.finalizedSlot = finalizedSlot; - - for (const [blockRoot, slot] of this.slotByBlockRoot.entries()) { - if (slot < finalizedSlot) { - this.slotByBlockRoot.delete(blockRoot); - } - } - } -} diff --git a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts new file mode 100644 index 000000000000..8682c567da37 --- /dev/null +++ b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts @@ -0,0 +1,109 @@ +import {CheckpointWithHex} from "@lodestar/fork-choice"; +import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; +import {RootHex} from "@lodestar/types"; +import {Logger} from "@lodestar/utils"; +import {Metrics} from "../../metrics/metrics.js"; +import {CreateFromBlockProps, PayloadEnvelopeInput} from "../blocks/payloadEnvelopeInput/index.js"; +import {ChainEvent, ChainEventEmitter} from "../emitter.js"; + +export type {PayloadEnvelopeInputState} from "../blocks/payloadEnvelopeInput/index.js"; +// Re-export for convenience +export {PayloadEnvelopeInput} from "../blocks/payloadEnvelopeInput/index.js"; + +/** + * Modules required for SeenPayloadEnvelopeInput. + */ +export type SeenPayloadEnvelopeInputModules = { + chainEvents: ChainEventEmitter; + signal: AbortSignal; + metrics: Metrics | null; + logger?: Logger; +}; + +/** + * Cache for tracking PayloadEnvelopeInput instances, keyed by beacon block root. + * + * Created during block import when a Gloas block is processed. + * Pruned on finalization and after payload is written to DB. + */ +export class SeenPayloadEnvelopeInput { + private readonly chainEvents: ChainEventEmitter; + private readonly signal: AbortSignal; + private readonly logger?: Logger; + private payloadInputs = new Map(); + + constructor({chainEvents, signal, metrics, logger}: SeenPayloadEnvelopeInputModules) { + this.chainEvents = chainEvents; + this.signal = signal; + this.logger = logger; + + if (metrics) { + metrics.seenCache.payloadEnvelopeInput.count.addCollect(() => + metrics.seenCache.payloadEnvelopeInput.count.set(this.payloadInputs.size) + ); + } + + this.chainEvents.on(ChainEvent.forkChoiceFinalized, this.onFinalized); + this.signal.addEventListener("abort", () => { + this.chainEvents.off(ChainEvent.forkChoiceFinalized, this.onFinalized); + }); + } + + /** + * Handle finalization - prune entries for finalized slots. + */ + private onFinalized = (checkpoint: CheckpointWithHex): void => { + // Prune all entries with slot <= finalized slot + const finalizedSlot = computeStartSlotAtEpoch(checkpoint.epoch); + let deletedCount = 0; + for (const [rootHex, input] of this.payloadInputs) { + if (input.slot <= finalizedSlot) { + this.payloadInputs.delete(rootHex); + deletedCount++; + } + } + this.logger?.debug(`SeenPayloadEnvelopeInput.onFinalized deleted ${deletedCount} cached entries`); + }; + + /** + * Create and store a new PayloadEnvelopeInput during block import. + * Throws if input already exists for this block root. + */ + add(props: CreateFromBlockProps): PayloadEnvelopeInput { + if (this.payloadInputs.has(props.blockRootHex)) { + throw new Error(`PayloadEnvelopeInput already exists for block ${props.blockRootHex}`); + } + const input = PayloadEnvelopeInput.createFromBlock(props); + this.payloadInputs.set(props.blockRootHex, input); + return input; + } + + /** + * Get existing PayloadEnvelopeInput by block root. + * Returns undefined if block hasn't been imported yet. + */ + get(blockRootHex: RootHex): PayloadEnvelopeInput | undefined { + return this.payloadInputs.get(blockRootHex); + } + + /** + * Check if PayloadEnvelopeInput exists for block root. + */ + has(blockRootHex: RootHex): boolean { + return this.payloadInputs.has(blockRootHex); + } + + /** + * Remove PayloadEnvelopeInput after payload is written to DB. + */ + delete(blockRootHex: RootHex): boolean { + return this.payloadInputs.delete(blockRootHex); + } + + /** + * Get the current size of the cache. + */ + size(): number { + return this.payloadInputs.size; + } +} diff --git a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts index d1935d1986c2..89d131cb7860 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts @@ -47,7 +47,18 @@ async function validateExecutionPayloadEnvelope( // [IGNORE] The node has not seen another valid // `SignedExecutionPayloadEnvelope` for this block root from this builder. - if (chain.seenExecutionPayloadEnvelopes.isKnown(blockRootHex)) { + const payloadInput = chain.seenPayloadEnvelopeInput.get(blockRootHex); + if (!payloadInput) { + // PayloadEnvelopeInput should have been created during block import + throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { + code: ExecutionPayloadEnvelopeErrorCode.CACHE_FAIL, + blockRoot: blockRootHex, + }); + } + + // [IGNORE] The node has not seen another valid + // `SignedExecutionPayloadEnvelope` for this block root from this builder. + if (payloadInput.hasPayloadEnvelope()) { throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { code: ExecutionPayloadEnvelopeErrorCode.ENVELOPE_ALREADY_KNOWN, blockRoot: blockRootHex, @@ -79,29 +90,21 @@ async function validateExecutionPayloadEnvelope( }); } - if (block.builderIndex === undefined || block.blockHashHex === undefined) { - // This indicates this block is a pre-gloas block which is wrong - throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { - code: ExecutionPayloadEnvelopeErrorCode.CACHE_FAIL, - blockRoot: blockRootHex, - }); - } - // [REJECT] `envelope.builder_index == bid.builder_index` - if (envelope.builderIndex !== block.builderIndex) { + if (envelope.builderIndex !== payloadInput.getBuilderIndex()) { throw new ExecutionPayloadEnvelopeError(GossipAction.REJECT, { code: ExecutionPayloadEnvelopeErrorCode.BUILDER_INDEX_MISMATCH, envelopeBuilderIndex: envelope.builderIndex, - bidBuilderIndex: block.builderIndex, + bidBuilderIndex: payloadInput.getBuilderIndex(), }); } // [REJECT] `payload.block_hash == bid.block_hash` - if (toRootHex(payload.blockHash) !== block.blockHashHex) { + if (toRootHex(payload.blockHash) !== payloadInput.getBlockHashHex()) { throw new ExecutionPayloadEnvelopeError(GossipAction.REJECT, { code: ExecutionPayloadEnvelopeErrorCode.BLOCK_HASH_MISMATCH, envelopeBlockHash: toRootHex(payload.blockHash), - bidBlockHash: block.blockHashHex, + bidBlockHash: payloadInput.getBlockHashHex(), }); } @@ -118,6 +121,4 @@ async function validateExecutionPayloadEnvelope( code: ExecutionPayloadEnvelopeErrorCode.INVALID_SIGNATURE, }); } - - chain.seenExecutionPayloadEnvelopes.add(blockRootHex, envelope.slot); } diff --git a/packages/beacon-node/src/chain/validatorMonitor.ts b/packages/beacon-node/src/chain/validatorMonitor.ts index 0b7a2c6a0381..dbb17065f518 100644 --- a/packages/beacon-node/src/chain/validatorMonitor.ts +++ b/packages/beacon-node/src/chain/validatorMonitor.ts @@ -23,6 +23,7 @@ import { ValidatorIndex, altair, deneb, + gloas, } from "@lodestar/types"; import {LogData, LogHandler, LogLevel, Logger, MapDef, MapDefMax, prettyPrintIndices, toRootHex} from "@lodestar/utils"; import {GENESIS_SLOT} from "../constants/constants.js"; @@ -61,6 +62,11 @@ export type ValidatorMonitor = { ): void; registerBeaconBlock(src: OpSource, delaySec: Seconds, block: BeaconBlock): void; registerBlobSidecar(src: OpSource, seenTimestampSec: Seconds, blob: deneb.BlobSidecar): void; + registerExecutionPayloadEnvelope( + src: OpSource, + delaySec: Seconds, + envelope: gloas.SignedExecutionPayloadEnvelope + ): void; registerImportedBlock(block: BeaconBlock, data: {proposerBalanceDelta: number}): void; onPoolSubmitUnaggregatedAttestation( seenTimestampSec: number, @@ -450,6 +456,10 @@ export function createValidatorMonitor( //TODO: freetheblobs }, + registerExecutionPayloadEnvelope(_src, _delaySec, _envelope) { + // TODO GLOAS: implement execution payload envelope monitoring + }, + registerImportedBlock(block, {proposerBalanceDelta}) { const validator = validators.get(block.proposerIndex); if (validator) { diff --git a/packages/beacon-node/src/metrics/metrics/lodestar.ts b/packages/beacon-node/src/metrics/metrics/lodestar.ts index f6abe18eb96a..69c07f39405d 100644 --- a/packages/beacon-node/src/metrics/metrics/lodestar.ts +++ b/packages/beacon-node/src/metrics/metrics/lodestar.ts @@ -3,6 +3,7 @@ import {NotReorgedReason} from "@lodestar/fork-choice"; import {ArchiveStoreTask} from "../../chain/archiveStore/archiveStore.js"; import {FrequencyStateArchiveStep} from "../../chain/archiveStore/strategies/frequencyStateArchiveStrategy.js"; import {BlockInputSource} from "../../chain/blocks/blockInput/index.js"; +import {PayloadEnvelopeInputSource} from "../../chain/blocks/payloadEnvelopeInput/index.js"; import {JobQueueItemType} from "../../chain/bls/index.js"; import {AttestationErrorCode, BlockErrorCode} from "../../chain/errors/index.js"; import { @@ -923,6 +924,18 @@ export function createLodestarMetrics( labelNames: ["reason"], }), }, + importPayload: { + bySource: register.gauge<{source: PayloadEnvelopeInputSource}>({ + name: "lodestar_import_payload_by_source_total", + help: "Total number of imported execution payload envelopes by source", + labelNames: ["source"], + }), + columnsBySource: register.gauge<{source: PayloadEnvelopeInputSource}>({ + name: "lodestar_import_payload_columns_by_source_total", + help: "Total number of payload-attached columns (sampled columns for Gloas) by source", + labelNames: ["source"], + }), + }, engineNotifyNewPayloadResult: register.gauge<{result: ExecutionPayloadStatus}>({ name: "lodestar_execution_engine_notify_new_payload_result_total", help: "The total result of calling notifyNewPayload execution engine api", @@ -1485,6 +1498,12 @@ export function createLodestarMetrics( help: "Number of BlockInputs created via a blob being seen first", }), }, + payloadEnvelopeInput: { + count: register.gauge({ + name: "lodestar_seen_payload_envelope_input_cache_size", + help: "Number of cached PayloadEnvelopeInputs", + }), + }, }, processFinalizedCheckpoint: { diff --git a/packages/beacon-node/src/network/processor/extractSlotRootFns.ts b/packages/beacon-node/src/network/processor/extractSlotRootFns.ts index 1cf09716bbc6..304e2c54f236 100644 --- a/packages/beacon-node/src/network/processor/extractSlotRootFns.ts +++ b/packages/beacon-node/src/network/processor/extractSlotRootFns.ts @@ -1,11 +1,14 @@ -import {ForkName} from "@lodestar/params"; +import {ForkName, isForkPostGloas} from "@lodestar/params"; import {SlotOptionalRoot, SlotRootHex} from "@lodestar/types"; import { + getBeaconBlockRootFromDataColumnSidecarSerialized, + getBeaconBlockRootFromExecutionPayloadEnvelopeSerialized, getBlockRootFromBeaconAttestationSerialized, getBlockRootFromSignedAggregateAndProofSerialized, getSlotFromBeaconAttestationSerialized, getSlotFromBlobSidecarSerialized, getSlotFromDataColumnSidecarSerialized, + getSlotFromExecutionPayloadEnvelopeSerialized, getSlotFromSignedAggregateAndProofSerialized, getSlotFromSignedBeaconBlockSerialized, } from "../../util/sszBytes.js"; @@ -52,13 +55,23 @@ export function createExtractBlockSlotRootFns(): ExtractSlotRootFns { } return {slot}; }, - [GossipType.data_column_sidecar]: (data: Uint8Array): SlotOptionalRoot | null => { - const slot = getSlotFromDataColumnSidecarSerialized(data); - + [GossipType.data_column_sidecar]: (data: Uint8Array, fork: ForkName): SlotOptionalRoot | null => { + const slot = getSlotFromDataColumnSidecarSerialized(data, fork); if (slot === null) { return null; } - return {slot}; + + const root = isForkPostGloas(fork) ? getBeaconBlockRootFromDataColumnSidecarSerialized(data) : null; + return root !== null ? {slot, root} : {slot}; + }, + [GossipType.execution_payload]: (data: Uint8Array): SlotRootHex | null => { + const slot = getSlotFromExecutionPayloadEnvelopeSerialized(data); + const root = getBeaconBlockRootFromExecutionPayloadEnvelopeSerialized(data); + + if (slot === null || root === null) { + return null; + } + return {slot, root}; }, }; } diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index 3aac4ed945f6..9203e4ceaeb1 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -30,6 +30,7 @@ import { IBlockInput, isBlockInputColumns, } from "../../chain/blocks/blockInput/index.js"; +import {PayloadEnvelopeInputSource} from "../../chain/blocks/payloadEnvelopeInput/index.ts"; import {BlobSidecarValidation} from "../../chain/blocks/types.js"; import {ChainEvent} from "../../chain/emitter.js"; import { @@ -616,6 +617,16 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand }); }); } + + // TODO GLOAS: In Gloas, also add column to PayloadEnvelopeInput and check completion: + // const payloadInput = chain.seenPayloadEnvelopeInput.get(blockRootHex); + // if (payloadInput) { + // payloadInput.addColumn({columnSidecar, source: BlockInputSource.gossip, seenTimestampSec, peerIdStr}); + // if (payloadInput.isComplete()) { + // await chain.importExecutionPayload(payloadInput); + // chain.persistPayloadEnvelope(payloadInput); + // } + // } }, [GossipType.beacon_aggregate_and_proof]: async ({ @@ -826,6 +837,7 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand [GossipType.execution_payload]: async ({ gossipData, topic, + peerIdStr, seenTimestampSec, }: GossipHandlerParamGeneric) => { const {serializedData} = gossipData; @@ -835,8 +847,34 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand const slot = executionPayloadEnvelope.message.slot; const delaySec = seenTimestampSec - computeTimeAtSlot(config, slot, chain.genesisTime); metrics?.gossipExecutionPayloadEnvelope.elapsedTimeTillReceived.observe({source: OpSource.gossip}, delaySec); + chain.validatorMonitor?.registerExecutionPayloadEnvelope(OpSource.gossip, delaySec, executionPayloadEnvelope); + + const blockRootHex = toRootHex(executionPayloadEnvelope.message.beaconBlockRoot); + const payloadInput = chain.seenPayloadEnvelopeInput.get(blockRootHex); + + if (!payloadInput) { + // This shouldn't happen because beacon block should have been imported and thus payload input should have been created. + logger.warn("PayloadEnvelopeInput not found after validation", {blockRootHex, slot}); + return; + } + + chain.serializedCache.set(executionPayloadEnvelope, serializedData); + + payloadInput.addPayloadEnvelope({ + envelope: executionPayloadEnvelope, + source: PayloadEnvelopeInputSource.gossip, + seenTimestampSec, + peerIdStr, + }); - // TODO GLOAS: Handle valid envelope. Need an import flow that calls `processExecutionPayloadEnvelope` and fork choice + if (payloadInput.isComplete()) { + await chain.importExecutionPayload(payloadInput); + chain.persistPayloadEnvelope(payloadInput); + chain.emitter.emit(routes.events.EventType.executionPayloadAvailable, { + slot, + blockRoot: blockRootHex, + }); + } }, [GossipType.payload_attestation_message]: async ({ gossipData, diff --git a/packages/beacon-node/src/util/sszBytes.ts b/packages/beacon-node/src/util/sszBytes.ts index 8ba5ddf3291e..d33bd898033d 100644 --- a/packages/beacon-node/src/util/sszBytes.ts +++ b/packages/beacon-node/src/util/sszBytes.ts @@ -8,6 +8,7 @@ import { ForkSeq, MAX_COMMITTEES_PER_SLOT, isForkPostElectra, + isForkPostGloas, } from "@lodestar/params"; import {BLSSignature, CommitteeIndex, RootHex, Slot, ValidatorIndex, ssz} from "@lodestar/types"; @@ -398,23 +399,102 @@ export function getSlotFromBlobSidecarSerialized(data: Uint8Array): Slot | null } /** + * Pre-Gloas DataColumnSidecar: * { - index: ColumnIndex [ fixed - 8 bytes], - column: DataColumn BYTES_PER_FIELD_ELEMENT * FIELD_ELEMENTS_PER_CELL * , - kzgCommitments: denebSsz.BlobKzgCommitments, - kzgProofs: denebSsz.KZGProofs, - signedBlockHeader: phase0Ssz.SignedBeaconBlockHeader, - kzgCommitmentsInclusionProof: KzgCommitmentsInclusionProof, + * index: ColumnIndex [fixed - 8 bytes], + * column: DataColumn (offset - 4 bytes), + * kzgCommitments: (offset - 4 bytes), + * kzgProofs: (offset - 4 bytes), + * signedBlockHeader: (offset - 4 bytes) -> slot at variable offset after fixed header + * kzgCommitmentsInclusionProof: (offset - 4 bytes), + * } + * Post-Gloas DataColumnSidecar: + * { + * index: ColumnIndex [8 bytes], + * column: DataColumn (offset - 4 bytes), + * kzgProofs: (offset - 4 bytes), + * slot: Slot [8 bytes] - at offset 16, + * beaconBlockRoot: Root [32 bytes] - at offset 24, + * } + */ +const SLOT_BYTES_POSITION_IN_SIGNED_DATA_COLUMN_SIDECAR_PRE_GLOAS = 20; +const SLOT_BYTES_POSITION_IN_SIGNED_DATA_COLUMN_SIDECAR_POST_GLOAS = 16; +const BEACON_BLOCK_ROOT_POSITION_IN_GLOAS_DATA_COLUMN_SIDECAR = 24; + +export function getSlotFromDataColumnSidecarSerialized(data: Uint8Array, fork: ForkName): Slot | null { + const offset = isForkPostGloas(fork) + ? SLOT_BYTES_POSITION_IN_SIGNED_DATA_COLUMN_SIDECAR_POST_GLOAS + : SLOT_BYTES_POSITION_IN_SIGNED_DATA_COLUMN_SIDECAR_PRE_GLOAS; + + if (data.length < offset + SLOT_SIZE) { + return null; } + + return getSlotFromOffset(data, offset); +} + +export function getBeaconBlockRootFromDataColumnSidecarSerialized(data: Uint8Array): RootHex | null { + if (data.length < BEACON_BLOCK_ROOT_POSITION_IN_GLOAS_DATA_COLUMN_SIDECAR + ROOT_SIZE) { + return null; + } + + blockRootBuf.set( + data.subarray( + BEACON_BLOCK_ROOT_POSITION_IN_GLOAS_DATA_COLUMN_SIDECAR, + BEACON_BLOCK_ROOT_POSITION_IN_GLOAS_DATA_COLUMN_SIDECAR + ROOT_SIZE + ) + ); + return "0x" + blockRootBuf.toString("hex"); +} + +/** + * SignedExecutionPayloadEnvelope SSZ Layout: + * ├─ 4 bytes: message offset (points to byte 100) + * ├─ 96 bytes: signature + * └─ ExecutionPayloadEnvelope (starts at byte 100): + * ├─ 4 bytes: payload offset + * ├─ 4 bytes: executionRequests offset + * ├─ 8 bytes: builderIndex (offset 108-115) + * ├─ 32 bytes: beaconBlockRoot (offset 116-147) + * ├─ 8 bytes: slot (offset 148-155) + * └─ 32 bytes: stateRoot (offset 156-187) */ +const SIGNED_EXECUTION_PAYLOAD_ENVELOPE_MESSAGE_OFFSET = 4; +const SIGNED_EXECUTION_PAYLOAD_ENVELOPE_SIGNATURE_SIZE = 96; +const EXECUTION_PAYLOAD_ENVELOPE_PAYLOAD_OFFSET = 4; +const EXECUTION_PAYLOAD_ENVELOPE_REQUESTS_OFFSET = 4; +const EXECUTION_PAYLOAD_ENVELOPE_BUILDER_INDEX_SIZE = 8; + +const BEACON_BLOCK_ROOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE = + SIGNED_EXECUTION_PAYLOAD_ENVELOPE_MESSAGE_OFFSET + + SIGNED_EXECUTION_PAYLOAD_ENVELOPE_SIGNATURE_SIZE + + EXECUTION_PAYLOAD_ENVELOPE_PAYLOAD_OFFSET + + EXECUTION_PAYLOAD_ENVELOPE_REQUESTS_OFFSET + + EXECUTION_PAYLOAD_ENVELOPE_BUILDER_INDEX_SIZE; // 116 + +const SLOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE = + BEACON_BLOCK_ROOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE + ROOT_SIZE; // 148 + +export function getSlotFromExecutionPayloadEnvelopeSerialized(data: Uint8Array): Slot | null { + if (data.length < SLOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE + SLOT_SIZE) { + return null; + } -const SLOT_BYTES_POSITION_IN_SIGNED_DATA_COLUMN_SIDECAR = 20; -export function getSlotFromDataColumnSidecarSerialized(data: Uint8Array): Slot | null { - if (data.length < SLOT_BYTES_POSITION_IN_SIGNED_DATA_COLUMN_SIDECAR + SLOT_SIZE) { + return getSlotFromOffset(data, SLOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE); +} + +export function getBeaconBlockRootFromExecutionPayloadEnvelopeSerialized(data: Uint8Array): RootHex | null { + if (data.length < BEACON_BLOCK_ROOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE + ROOT_SIZE) { return null; } - return getSlotFromOffset(data, SLOT_BYTES_POSITION_IN_SIGNED_DATA_COLUMN_SIDECAR); + blockRootBuf.set( + data.subarray( + BEACON_BLOCK_ROOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE, + BEACON_BLOCK_ROOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE + ROOT_SIZE + ) + ); + return "0x" + blockRootBuf.toString("hex"); } /** diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 9e0548e1ae93..1d20276038a2 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -23,7 +23,6 @@ import { RootHex, Slot, ValidatorIndex, - isGloasBeaconBlock, phase0, ssz, } from "@lodestar/types"; @@ -757,15 +756,6 @@ export class ForkChoice implements IForkChoice { executionStatus: this.getPreMergeExecStatus(executionStatus), dataAvailabilityStatus: this.getPreMergeDataStatus(dataAvailabilityStatus), }), - ...(isGloasBeaconBlock(block) - ? { - builderIndex: block.body.signedExecutionPayloadBid.message.builderIndex, - blockHashHex: toRootHex(block.body.signedExecutionPayloadBid.message.blockHash), - } - : { - builderIndex: undefined, - blockHashHex: undefined, - }), }; this.protoArray.onBlock(protoBlock, currentSlot); diff --git a/packages/fork-choice/src/protoArray/interface.ts b/packages/fork-choice/src/protoArray/interface.ts index 480ac7ffe04f..424bc6f6342f 100644 --- a/packages/fork-choice/src/protoArray/interface.ts +++ b/packages/fork-choice/src/protoArray/interface.ts @@ -101,10 +101,6 @@ export type ProtoBlock = BlockExtraMeta & { // Indicate whether block arrives in a timely manner ie. before the 4 second mark timeliness: boolean; - - // GLOAS: The followings are from bids. Used for execution payload gossip validation - builderIndex?: number; - blockHashHex?: RootHex; }; /** From 9c91667f8286b44e54a6ae9b629635b45d0b9d02 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Thu, 26 Feb 2026 23:42:17 -0800 Subject: [PATCH 02/38] init Co-Authored-By: Claude Opus 4.6 --- .../src/api/impl/beacon/blocks/index.ts | 9 +++---- .../chain/blocks/importExecutionPayload.ts | 26 ++++++++++++++----- .../payloadEnvelopeInput.ts | 4 +++ .../blocks/writePayloadEnvelopeInputToDb.ts | 3 +++ 4 files changed, 30 insertions(+), 12 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 842b70021b56..891434b0f5d1 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -720,6 +720,9 @@ export function getBeaconBlockApi({ } } + // TODO GLOAS: Unlike publishBlock which gossips and imports in parallel, we import before gossip here. + // The publishExecutionPayloadEnvelope spec says success = "gossip validation + broadcast", so we may + // want to gossip first. Need spec clarification on whether import failure should prevent broadcast. if (payloadInput.isComplete()) { await chain.importExecutionPayload(payloadInput); chain.persistPayloadEnvelope(payloadInput); @@ -737,12 +740,6 @@ export function getBeaconBlockApi({ dataColumns: dataColumnSidecars.length, }; - // If called near a slot boundary (e.g. late in slot N-1), hold briefly so gossip aligns with slot N. - const msToBlockSlot = computeTimeAtSlot(config, slot, chain.genesisTime) * 1000 - Date.now(); - if (msToBlockSlot <= MAX_API_CLOCK_DISPARITY_MS && msToBlockSlot > 0) { - await sleep(msToBlockSlot); - } - const delaySec = seenTimestampSec - computeTimeAtSlot(config, slot, chain.genesisTime); metrics?.gossipExecutionPayloadEnvelope.elapsedTimeTillReceived.observe({source: OpSource.api}, delaySec); chain.validatorMonitor?.registerExecutionPayloadEnvelope(OpSource.api, delaySec, signedExecutionPayloadEnvelope); diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index c94d5e318bc4..f3fd65d877bc 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -1,7 +1,7 @@ import {ForkName} from "@lodestar/params"; import {CachedBeaconStateGloas} from "@lodestar/state-transition"; import {processExecutionPayloadEnvelope} from "@lodestar/state-transition/block"; -import {fromHex} from "@lodestar/utils"; +import {byteArrayEquals, fromHex, toRootHex} from "@lodestar/utils"; import {ExecutionPayloadStatus} from "../../execution/index.js"; import {BeaconChain} from "../chain.js"; import {RegenCaller} from "../regen/interface.js"; @@ -88,7 +88,7 @@ export async function importExecutionPayload( // 3. Run verification steps in parallel (like verifyBlocksInEpoch) // Note: No data availability check needed here - importExecutionPayload is only // called when payloadInput.isComplete() is true, so all data is already available. - const [execResult, _postPayloadState] = await Promise.all([ + const [execResult, postPayloadResult] = await Promise.all([ // EL verification - notifyNewPayload this.executionEngine.notifyNewPayload( ForkName.gloas, @@ -99,12 +99,13 @@ export async function importExecutionPayload( ), // Process execution payload envelope (state transition) - // Note: signature verification is done as part of processExecutionPayloadEnvelope when verify=true + // Signature already verified during gossip/API validation, so pass verify=false. + // State root check is done manually below (matching block pipeline pattern). (async () => { try { // Clone state to avoid mutating the cached state const mutableState = blockState.clone(); - processExecutionPayloadEnvelope(mutableState, envelope, true); + processExecutionPayloadEnvelope(mutableState, envelope, false); return {postPayloadState: mutableState}; } catch (e) { throw new PayloadError( @@ -141,16 +142,29 @@ export async function importExecutionPayload( // VALID, ACCEPTED, or SYNCING - proceed with import + // 4b. Verify envelope state root matches post-state (done separately from state transition, like block pipeline) + const postPayloadState = postPayloadResult.postPayloadState; + const stateRoot = postPayloadState.hashTreeRoot(); + if (!byteArrayEquals(envelope.message.stateRoot, stateRoot)) { + throw new PayloadError( + { + code: PayloadErrorCode.STATE_TRANSITION_ERROR, + message: `Envelope state root mismatch expected=${toRootHex(envelope.message.stateRoot)} actual=${toRootHex(stateRoot)}`, + }, + `Envelope state root mismatch expected=${toRootHex(envelope.message.stateRoot)} actual=${toRootHex(stateRoot)}` + ); + } + // 5. Update fork choice: PENDING → FULL // TODO GLOAS: Update API when nc/epbs-fc merged // this.forkChoice.onExecutionPayload( // envelope.message.beaconBlockRoot, - // _executionPayloadState.hashTreeRoot() + // postPayloadState.hashTreeRoot() // ); // 6. Cache payload state // TODO GLOAS: Enable when PR #8868 merged (adds processPayloadState) - // this.regen.processPayloadState(_postPayloadState); + // this.regen.processPayloadState(postPayloadState); // 7. Record metrics for payload envelope and column sources this.metrics?.importPayload.bySource.inc({source: payloadInput.getPayloadEnvelopeSource().source}); diff --git a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts index 843bf3ad07ec..248780d278bd 100644 --- a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts @@ -260,6 +260,8 @@ export class PayloadEnvelopeInput { slot: number; blockRoot: string; hasPayload: boolean; + hasAllColumns: boolean; + isComplete: boolean; columnsCount: number; sampledColumnsCount: number; } { @@ -267,6 +269,8 @@ export class PayloadEnvelopeInput { slot: this.slot, blockRoot: this.blockRootHex, hasPayload: this.state.hasPayload, + hasAllColumns: this.state.hasAllColumns, + isComplete: this.isComplete(), columnsCount: this.columnsCache.size, sampledColumnsCount: this.sampledColumns.length, }; diff --git a/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts b/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts index 401bf17b5880..212764363ff7 100644 --- a/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts +++ b/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts @@ -82,6 +82,9 @@ export async function persistPayloadEnvelopeInput( }) .finally(() => { this.seenPayloadEnvelopeInput.delete(payloadInput.blockRootHex); + // Without forcefully clearing this cache, we would rely on WeakMap to evict memory which is not reliable. + // Clear here (after the DB write) so that writePayloadEnvelopeInputToDb can still use the cached serialized bytes. + this.serializedCache.clear(); this.logger.debug("Pruned payload envelope input", { slot: payloadInput.slot, root: payloadInput.blockRootHex, From 9681ba4ecb162bb9297a35927c2d9bb7c8a9646e Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:24:32 -0800 Subject: [PATCH 03/38] init --- .../src/api/impl/beacon/blocks/index.ts | 11 ++--- .../chain/blocks/importExecutionPayload.ts | 40 ++++++++++++++++--- .../beacon-node/src/chain/blocks/types.ts | 8 ++++ packages/beacon-node/src/chain/interface.ts | 4 +- .../chain/produceBlock/computeNewStateRoot.ts | 2 +- .../src/network/processor/gossipHandlers.ts | 5 ++- .../test/spec/presets/operations.test.ts | 2 +- .../block/processExecutionPayloadEnvelope.ts | 7 ++-- 8 files changed, 59 insertions(+), 20 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 891434b0f5d1..8270c97089c3 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -48,6 +48,7 @@ import { ProduceFullGloas, } from "../../../../chain/produceBlock/index.js"; import {validateGossipBlock} from "../../../../chain/validation/block.js"; +import {validateApiExecutionPayloadEnvelope} from "../../../../chain/validation/executionPayloadEnvelope.js"; import {OpSource} from "../../../../chain/validatorMonitor.js"; import { computePreFuluKzgCommitmentsInclusionProof, @@ -692,15 +693,14 @@ export function getBeaconBlockApi({ // TODO GLOAS: will this api be used by builders or only for self-building? } + // Validate envelope (including signature verification) before processing + await validateApiExecutionPayloadEnvelope(chain, signedExecutionPayloadEnvelope); + const payloadInput = chain.seenPayloadEnvelopeInput.get(blockRootHex); if (!payloadInput) { throw new ApiError(404, `PayloadEnvelopeInput not found for block root ${blockRootHex}`); } - if (payloadInput.hasPayloadEnvelope()) { - throw new ApiError(409, `Payload envelope already received for block root ${blockRootHex}`); - } - payloadInput.addPayloadEnvelope({ envelope: signedExecutionPayloadEnvelope, source: PayloadEnvelopeInputSource.api, @@ -724,7 +724,8 @@ export function getBeaconBlockApi({ // The publishExecutionPayloadEnvelope spec says success = "gossip validation + broadcast", so we may // want to gossip first. Need spec clarification on whether import failure should prevent broadcast. if (payloadInput.isComplete()) { - await chain.importExecutionPayload(payloadInput); + // Signature already verified in validateApiExecutionPayloadEnvelope + await chain.importExecutionPayload(payloadInput, {validSignature: true}); chain.persistPayloadEnvelope(payloadInput); chain.emitter.emit(routes.events.EventType.executionPayloadAvailable, { slot, diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index f3fd65d877bc..04af4492c0c4 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -1,17 +1,24 @@ +import {PublicKey} from "@chainsafe/blst"; import {ForkName} from "@lodestar/params"; -import {CachedBeaconStateGloas} from "@lodestar/state-transition"; +import { + CachedBeaconStateGloas, + createSingleSignatureSetFromComponents, + getExecutionPayloadEnvelopeSigningRoot, +} from "@lodestar/state-transition"; import {processExecutionPayloadEnvelope} from "@lodestar/state-transition/block"; import {byteArrayEquals, fromHex, toRootHex} from "@lodestar/utils"; import {ExecutionPayloadStatus} from "../../execution/index.js"; import {BeaconChain} from "../chain.js"; import {RegenCaller} from "../regen/interface.js"; import {PayloadEnvelopeInput} from "../seenCache/seenPayloadEnvelopeInput.js"; +import {ImportPayloadOpts} from "./types.js"; export enum PayloadErrorCode { EXECUTION_ENGINE_INVALID = "PAYLOAD_ERROR_EXECUTION_ENGINE_INVALID", EXECUTION_ENGINE_ERROR = "PAYLOAD_ERROR_EXECUTION_ENGINE_ERROR", BLOCK_NOT_IN_FORK_CHOICE = "PAYLOAD_ERROR_BLOCK_NOT_IN_FORK_CHOICE", STATE_TRANSITION_ERROR = "PAYLOAD_ERROR_STATE_TRANSITION_ERROR", + INVALID_SIGNATURE = "PAYLOAD_ERROR_INVALID_SIGNATURE", } export type PayloadErrorType = @@ -32,6 +39,9 @@ export type PayloadErrorType = | { code: PayloadErrorCode.STATE_TRANSITION_ERROR; message: string; + } + | { + code: PayloadErrorCode.INVALID_SIGNATURE; }; export class PayloadError extends Error { @@ -62,7 +72,8 @@ export type ImportPayloadResult = { */ export async function importExecutionPayload( this: BeaconChain, - payloadInput: PayloadEnvelopeInput + payloadInput: PayloadEnvelopeInput, + opts: ImportPayloadOpts = {} ): Promise { const envelope = payloadInput.getPayloadEnvelope(); const blockRootHex = payloadInput.blockRootHex; @@ -88,7 +99,7 @@ export async function importExecutionPayload( // 3. Run verification steps in parallel (like verifyBlocksInEpoch) // Note: No data availability check needed here - importExecutionPayload is only // called when payloadInput.isComplete() is true, so all data is already available. - const [execResult, postPayloadResult] = await Promise.all([ + const [execResult, signatureValid, postPayloadResult] = await Promise.all([ // EL verification - notifyNewPayload this.executionEngine.notifyNewPayload( ForkName.gloas, @@ -98,14 +109,26 @@ export async function importExecutionPayload( envelope.message.executionRequests ), + // Signature verification (skip if already verified during gossip/API validation) + opts.validSignature === true + ? Promise.resolve(true) + : (async () => { + const signatureSet = createSingleSignatureSetFromComponents( + PublicKey.fromBytes(blockState.builders.getReadonly(envelope.message.builderIndex).pubkey), + getExecutionPayloadEnvelopeSigningRoot(this.config, envelope.message), + envelope.signature + ); + return this.bls.verifySignatureSets([signatureSet]); + })(), + // Process execution payload envelope (state transition) - // Signature already verified during gossip/API validation, so pass verify=false. - // State root check is done manually below (matching block pipeline pattern). + // Signature verified separately above (matching block pipeline pattern). + // State root check is done separately below with better error typing (matching block pipeline pattern). (async () => { try { // Clone state to avoid mutating the cached state const mutableState = blockState.clone(); - processExecutionPayloadEnvelope(mutableState, envelope, false); + processExecutionPayloadEnvelope(mutableState, envelope, {verifySignature: false, verifyStateRoot: false}); return {postPayloadState: mutableState}; } catch (e) { throw new PayloadError( @@ -119,6 +142,11 @@ export async function importExecutionPayload( })(), ]); + // 3b. Check signature verification result + if (!signatureValid) { + throw new PayloadError({code: PayloadErrorCode.INVALID_SIGNATURE}); + } + // 4. Handle EL response if (execResult.status === ExecutionPayloadStatus.INVALID) { throw new PayloadError({ diff --git a/packages/beacon-node/src/chain/blocks/types.ts b/packages/beacon-node/src/chain/blocks/types.ts index 50ed0076515c..52a6741d29c6 100644 --- a/packages/beacon-node/src/chain/blocks/types.ts +++ b/packages/beacon-node/src/chain/blocks/types.ts @@ -41,6 +41,14 @@ export enum BlobSidecarValidation { Full, } +export type ImportPayloadOpts = { + /** + * Set to true if envelope signature was already verified (e.g., during gossip/API validation). + * When false/undefined, signature will be verified during import. + */ + validSignature?: boolean; +}; + export type ImportBlockOpts = { /** * TEMP: Review if this is safe, Lighthouse always imports attestations even in finalized sync. diff --git a/packages/beacon-node/src/chain/interface.ts b/packages/beacon-node/src/chain/interface.ts index b17e16b8c9b6..1131672f93ce 100644 --- a/packages/beacon-node/src/chain/interface.ts +++ b/packages/beacon-node/src/chain/interface.ts @@ -33,7 +33,7 @@ import {CheckpointBalancesCache} from "./balancesCache.js"; import {BeaconProposerCache, ProposerPreparationData} from "./beaconProposerCache.js"; import {IBlockInput} from "./blocks/blockInput/index.js"; import {ImportPayloadResult} from "./blocks/importExecutionPayload.js"; -import {ImportBlockOpts} from "./blocks/types.js"; +import {ImportBlockOpts, ImportPayloadOpts} from "./blocks/types.js"; import {IBlsVerifier} from "./bls/index.js"; import {ColumnReconstructionTracker} from "./ColumnReconstructionTracker.js"; import {ChainEventEmitter} from "./emitter.js"; @@ -244,7 +244,7 @@ export interface IBeaconChain { processChainSegment(blocks: IBlockInput[], opts?: ImportBlockOpts): Promise; /** Import execution payload envelope to EL and fork choice after data is available */ - importExecutionPayload(payloadInput: PayloadEnvelopeInput): Promise; + importExecutionPayload(payloadInput: PayloadEnvelopeInput, opts?: ImportPayloadOpts): Promise; /** Persist payload envelope to DB and prune from seen cache */ persistPayloadEnvelope(payloadInput: PayloadEnvelopeInput): Promise; diff --git a/packages/beacon-node/src/chain/produceBlock/computeNewStateRoot.ts b/packages/beacon-node/src/chain/produceBlock/computeNewStateRoot.ts index 55c44501db74..904ba74f7c0e 100644 --- a/packages/beacon-node/src/chain/produceBlock/computeNewStateRoot.ts +++ b/packages/beacon-node/src/chain/produceBlock/computeNewStateRoot.ts @@ -74,7 +74,7 @@ export function computeEnvelopeStateRoot( }; const processEnvelopeTimer = metrics?.blockPayload.executionPayloadEnvelopeProcessingTime.startTimer(); - processExecutionPayloadEnvelope(postBlockState, signedEnvelope, false); + processExecutionPayloadEnvelope(postBlockState, signedEnvelope, {verifySignature: false, verifyStateRoot: true}); processEnvelopeTimer?.(); const hashTreeRootTimer = metrics?.stateHashTreeRootTime.startTimer({ diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index 9203e4ceaeb1..69d258e3de5a 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -623,7 +623,8 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand // if (payloadInput) { // payloadInput.addColumn({columnSidecar, source: BlockInputSource.gossip, seenTimestampSec, peerIdStr}); // if (payloadInput.isComplete()) { - // await chain.importExecutionPayload(payloadInput); + // // Signature already verified during gossip validation + // await chain.importExecutionPayload(payloadInput, {validSignature: true}); // chain.persistPayloadEnvelope(payloadInput); // } // } @@ -868,7 +869,7 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand }); if (payloadInput.isComplete()) { - await chain.importExecutionPayload(payloadInput); + await chain.importExecutionPayload(payloadInput, {validSignature: true}); chain.persistPayloadEnvelope(payloadInput); chain.emitter.emit(routes.events.EventType.executionPayloadAvailable, { slot, diff --git a/packages/beacon-node/test/spec/presets/operations.test.ts b/packages/beacon-node/test/spec/presets/operations.test.ts index 99019c685272..26506a3d2c19 100644 --- a/packages/beacon-node/test/spec/presets/operations.test.ts +++ b/packages/beacon-node/test/spec/presets/operations.test.ts @@ -78,7 +78,7 @@ const operationFns: Record> = ) => { const fork = state.config.getForkSeq(state.slot); if (fork >= ForkSeq.gloas) { - blockFns.processExecutionPayloadEnvelope(state as CachedBeaconStateGloas, testCase.signed_envelope, true); + blockFns.processExecutionPayloadEnvelope(state as CachedBeaconStateGloas, testCase.signed_envelope, {verifySignature: true, verifyStateRoot: true}); } else { blockFns.processExecutionPayload(fork, state as CachedBeaconStateBellatrix, testCase.body, { executionPayloadStatus: testCase.execution.execution_valid diff --git a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts index 04188dafb979..af4c8aea2b53 100644 --- a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts +++ b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts @@ -17,13 +17,14 @@ import {processWithdrawalRequest} from "./processWithdrawalRequest.ts"; export function processExecutionPayloadEnvelope( state: CachedBeaconStateGloas, signedEnvelope: gloas.SignedExecutionPayloadEnvelope, - verify: boolean + opts: {verifySignature?: boolean; verifyStateRoot?: boolean} = {} ): void { + const {verifySignature = true, verifyStateRoot = true} = opts; const envelope = signedEnvelope.message; const payload = envelope.payload; const fork = state.config.getForkSeq(envelope.slot); - if (verify && !verifyExecutionPayloadEnvelopeSignature(state, signedEnvelope)) { + if (verifySignature && !verifyExecutionPayloadEnvelopeSignature(state, signedEnvelope)) { throw Error(`Execution payload envelope has invalid signature builderIndex=${envelope.builderIndex}`); } @@ -58,7 +59,7 @@ export function processExecutionPayloadEnvelope( state.executionPayloadAvailability.set(state.slot % SLOTS_PER_HISTORICAL_ROOT, true); state.latestBlockHash = payload.blockHash; - if (verify && !byteArrayEquals(envelope.stateRoot, state.hashTreeRoot())) { + if (verifyStateRoot && !byteArrayEquals(envelope.stateRoot, state.hashTreeRoot())) { throw new Error( `Envelope's state root does not match state envelope=${toRootHex(envelope.stateRoot)} state=${toRootHex(state.hashTreeRoot())}` ); From 048be5ef1a5b3a47af1f92a6116f7d796f0a190c Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:38:38 -0800 Subject: [PATCH 04/38] Fix race condition --- .../src/api/impl/beacon/blocks/index.ts | 2 +- .../payloadEnvelopeInput.ts | 28 +++++++++++++++++-- .../src/network/processor/gossipHandlers.ts | 4 +-- .../test/spec/presets/operations.test.ts | 5 +++- 4 files changed, 32 insertions(+), 7 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 8270c97089c3..f79f1d1725d6 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -723,7 +723,7 @@ export function getBeaconBlockApi({ // TODO GLOAS: Unlike publishBlock which gossips and imports in parallel, we import before gossip here. // The publishExecutionPayloadEnvelope spec says success = "gossip validation + broadcast", so we may // want to gossip first. Need spec clarification on whether import failure should prevent broadcast. - if (payloadInput.isComplete()) { + if (payloadInput.shouldImport()) { // Signature already verified in validateApiExecutionPayloadEnvelope await chain.importExecutionPayload(payloadInput, {validSignature: true}); chain.persistPayloadEnvelope(payloadInput); diff --git a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts index 248780d278bd..828ceff0f6ed 100644 --- a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts @@ -72,6 +72,9 @@ export class PayloadEnvelopeInput { private readonly sampledColumns: ColumnIndex[]; private readonly custodyColumns: ColumnIndex[]; + /** Guard against double import - only one caller can claim the import */ + private importClaimed = false; + private timeCreatedSec: number; // Promise for waiting @@ -226,17 +229,22 @@ export class PayloadEnvelopeInput { getSampledColumns(): gloas.DataColumnSidecars { return this.sampledColumns .filter((idx) => this.columnsCache.has(idx)) - .map((idx) => this.columnsCache.get(idx)!.columnSidecar); + .map((idx) => this.columnsCache.get(idx)?.columnSidecar) + .filter((col): col is gloas.DataColumnSidecar => col !== undefined); } getSampledColumnsWithSource(): ColumnWithSource[] { - return this.sampledColumns.filter((idx) => this.columnsCache.has(idx)).map((idx) => this.columnsCache.get(idx)!); + return this.sampledColumns + .filter((idx) => this.columnsCache.has(idx)) + .map((idx) => this.columnsCache.get(idx)) + .filter((col): col is ColumnWithSource => col !== undefined); } getCustodyColumns(): gloas.DataColumnSidecars { return this.custodyColumns .filter((idx) => this.columnsCache.has(idx)) - .map((idx) => this.columnsCache.get(idx)!.columnSidecar); + .map((idx) => this.columnsCache.get(idx)?.columnSidecar) + .filter((col): col is gloas.DataColumnSidecar => col !== undefined); } getTimeCreated(): number { @@ -252,6 +260,20 @@ export class PayloadEnvelopeInput { return this.state.hasPayload && this.state.hasAllColumns; } + /** + * Check if this caller should import the payload. + * Returns true only once - guards against race condition where multiple + * gossip handlers (envelope + columns from different peers) could each + * observe isComplete() === true and trigger concurrent imports. + */ + shouldImport(): boolean { + if (this.isComplete() && !this.importClaimed) { + this.importClaimed = true; + return true; + } + return false; + } + async waitForData(): Promise { return this.dataPromise.promise; } diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index 69d258e3de5a..763ea382ac35 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -622,7 +622,7 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand // const payloadInput = chain.seenPayloadEnvelopeInput.get(blockRootHex); // if (payloadInput) { // payloadInput.addColumn({columnSidecar, source: BlockInputSource.gossip, seenTimestampSec, peerIdStr}); - // if (payloadInput.isComplete()) { + // if (payloadInput.shouldImport()) { // // Signature already verified during gossip validation // await chain.importExecutionPayload(payloadInput, {validSignature: true}); // chain.persistPayloadEnvelope(payloadInput); @@ -868,7 +868,7 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand peerIdStr, }); - if (payloadInput.isComplete()) { + if (payloadInput.shouldImport()) { await chain.importExecutionPayload(payloadInput, {validSignature: true}); chain.persistPayloadEnvelope(payloadInput); chain.emitter.emit(routes.events.EventType.executionPayloadAvailable, { diff --git a/packages/beacon-node/test/spec/presets/operations.test.ts b/packages/beacon-node/test/spec/presets/operations.test.ts index 26506a3d2c19..d815fc8eaaf4 100644 --- a/packages/beacon-node/test/spec/presets/operations.test.ts +++ b/packages/beacon-node/test/spec/presets/operations.test.ts @@ -78,7 +78,10 @@ const operationFns: Record> = ) => { const fork = state.config.getForkSeq(state.slot); if (fork >= ForkSeq.gloas) { - blockFns.processExecutionPayloadEnvelope(state as CachedBeaconStateGloas, testCase.signed_envelope, {verifySignature: true, verifyStateRoot: true}); + blockFns.processExecutionPayloadEnvelope(state as CachedBeaconStateGloas, testCase.signed_envelope, { + verifySignature: true, + verifyStateRoot: true, + }); } else { blockFns.processExecutionPayload(fork, state as CachedBeaconStateBellatrix, testCase.body, { executionPayloadStatus: testCase.execution.execution_valid From 4be9bfbb300cbf222b5c09b406d0c691dc539f0d Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:10:52 -0800 Subject: [PATCH 05/38] fix: use block post-state for envelope signature verification Use getBlockSlotState instead of getHeadState for builder pubkey lookup during execution payload envelope validation. This ensures signature verification uses the correct builder registry state at the block's slot, rather than potentially stale head state that could differ due to reorgs or head changes. Co-Authored-By: Claude Opus 4.5 --- .../chain/errors/executionPayloadEnvelope.ts | 2 ++ .../validation/executionPayloadEnvelope.ts | 26 ++++++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts index 051f07e04ba1..f309c5f46c98 100644 --- a/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts @@ -4,6 +4,7 @@ import {GossipActionError} from "./gossipValidation.js"; export enum ExecutionPayloadEnvelopeErrorCode { BELONG_TO_FINALIZED_BLOCK = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_BELONG_TO_FINALIZED_BLOCK", BLOCK_ROOT_UNKNOWN = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_BLOCK_ROOT_UNKNOWN", + PARENT_UNKNOWN = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_PARENT_UNKNOWN", ENVELOPE_ALREADY_KNOWN = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_ALREADY_KNOWN", INVALID_BLOCK = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_INVALID_BLOCK", SLOT_MISMATCH = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_SLOT_MISMATCH", @@ -15,6 +16,7 @@ export enum ExecutionPayloadEnvelopeErrorCode { export type ExecutionPayloadEnvelopeErrorType = | {code: ExecutionPayloadEnvelopeErrorCode.BELONG_TO_FINALIZED_BLOCK; envelopeSlot: Slot; finalizedSlot: Slot} | {code: ExecutionPayloadEnvelopeErrorCode.BLOCK_ROOT_UNKNOWN; blockRoot: RootHex} + | {code: ExecutionPayloadEnvelopeErrorCode.PARENT_UNKNOWN; parentRoot: RootHex; slot: Slot} | { code: ExecutionPayloadEnvelopeErrorCode.ENVELOPE_ALREADY_KNOWN; blockRoot: RootHex; diff --git a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts index 89d131cb7860..03738f0085f7 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts @@ -9,6 +9,7 @@ import {gloas} from "@lodestar/types"; import {toRootHex} from "@lodestar/utils"; import {ExecutionPayloadEnvelopeError, ExecutionPayloadEnvelopeErrorCode, GossipAction} from "../errors/index.js"; import {IBeaconChain} from "../index.js"; +import {RegenCaller} from "../regen/index.js"; export async function validateApiExecutionPayloadEnvelope( chain: IBeaconChain, @@ -35,8 +36,6 @@ async function validateExecutionPayloadEnvelope( // [IGNORE] The envelope's block root `envelope.block_root` has been seen (via // gossip or non-gossip sources) (a client MAY queue payload for processing once // the block is retrieved). - // TODO GLOAS: Need to review this, we should queue the envelope for later - // processing if the block is not yet known, otherwise we would ignore it here const block = chain.forkChoice.getBlock(envelope.beaconBlockRoot); if (block === null) { throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { @@ -109,9 +108,28 @@ async function validateExecutionPayloadEnvelope( } // [REJECT] `signed_execution_payload_envelope.signature` is valid with respect to the builder's public key. - const state = chain.getHeadState() as CachedBeaconStateGloas; + const parentRoot = block.parentRoot; + const parentBlock = chain.forkChoice.getBlockHex(parentRoot); + if (parentBlock === null) { + throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { + code: ExecutionPayloadEnvelopeErrorCode.PARENT_UNKNOWN, + parentRoot, + slot: envelope.slot, + }); + } + + const blockState = await chain.regen + .getBlockSlotState(parentBlock, block.slot, {dontTransferCache: true}, RegenCaller.validateGossipBlock) + .catch(() => { + throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { + code: ExecutionPayloadEnvelopeErrorCode.PARENT_UNKNOWN, + parentRoot, + slot: envelope.slot, + }); + }); + const signatureSet = createSingleSignatureSetFromComponents( - PublicKey.fromBytes(state.builders.getReadonly(envelope.builderIndex).pubkey), + PublicKey.fromBytes((blockState as CachedBeaconStateGloas).builders.getReadonly(envelope.builderIndex).pubkey), getExecutionPayloadEnvelopeSigningRoot(chain.config, envelope), executionPayloadEnvelope.signature ); From 91c6787dd2966e24eb40a4e6a82105bb37d56a96 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:42:38 -0800 Subject: [PATCH 06/38] clean up Co-Authored-By: Claude Opus 4.5 --- .../src/api/impl/beacon/blocks/index.ts | 10 +- .../chain/blocks/importExecutionPayload.ts | 27 ++--- .../payloadEnvelopeInput.ts | 19 ---- .../blocks/payloadEnvelopeInput/types.ts | 13 --- .../src/chain/blocks/writeBlockInputToDb.ts | 1 + .../blocks/writePayloadEnvelopeInputToDb.ts | 5 +- .../seenCache/seenPayloadEnvelopeInput.ts | 24 ----- .../test/unit/util/sszBytes.test.ts | 100 ++++++++++++++++++ 8 files changed, 118 insertions(+), 81 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 f79f1d1725d6..15063062056c 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -693,9 +693,14 @@ export function getBeaconBlockApi({ // TODO GLOAS: will this api be used by builders or only for self-building? } - // Validate envelope (including signature verification) before processing await validateApiExecutionPayloadEnvelope(chain, signedExecutionPayloadEnvelope); + // If called near a slot boundary (e.g. late in slot N-1), hold briefly so gossip aligns with slot N. + const msToBlockSlot = computeTimeAtSlot(config, slot, chain.genesisTime) * 1000 - Date.now(); + if (msToBlockSlot <= MAX_API_CLOCK_DISPARITY_MS && msToBlockSlot > 0) { + await sleep(msToBlockSlot); + } + const payloadInput = chain.seenPayloadEnvelopeInput.get(blockRootHex); if (!payloadInput) { throw new ApiError(404, `PayloadEnvelopeInput not found for block root ${blockRootHex}`); @@ -708,8 +713,7 @@ export function getBeaconBlockApi({ peerIdStr: undefined, }); - // For self-builds, add data columns from cached block production result - if (isSelfBuild && dataColumnSidecars.length > 0) { + if (dataColumnSidecars.length > 0) { for (const columnSidecar of dataColumnSidecars) { payloadInput.addColumn({ columnSidecar, diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index 04af4492c0c4..c308d9722310 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -63,12 +63,11 @@ export type ImportPayloadResult = { * This function: * 1. Gets the ProtoBlock from fork choice * 2. Regenerates the block state - * 3. Runs EL verification (notifyNewPayload) in parallel with state transition - * 4. Updates fork choice from PENDING → FULL status - * 5. Caches the post-state + * 3. Runs EL verification (notifyNewPayload) in parallel with signature verification and processExecutionPayloadEnvelope + * 4. Updates fork choice + * 5. Caches the post-execution payload state * 6. Records metrics for column sources * - * Note: The actual DB write happens asynchronously via writePayloadEnvelopeInputToDb */ export async function importExecutionPayload( this: BeaconChain, @@ -87,7 +86,7 @@ export async function importExecutionPayload( }); } - // 2. Get pre-state for state transition + // 2. 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, @@ -96,11 +95,10 @@ export async function importExecutionPayload( RegenCaller.processBlock )) as CachedBeaconStateGloas; - // 3. Run verification steps in parallel (like verifyBlocksInEpoch) + // 3. Run verification steps in parallel // Note: No data availability check needed here - importExecutionPayload is only // called when payloadInput.isComplete() is true, so all data is already available. const [execResult, signatureValid, postPayloadResult] = await Promise.all([ - // EL verification - notifyNewPayload this.executionEngine.notifyNewPayload( ForkName.gloas, envelope.message.payload, @@ -109,7 +107,6 @@ export async function importExecutionPayload( envelope.message.executionRequests ), - // Signature verification (skip if already verified during gossip/API validation) opts.validSignature === true ? Promise.resolve(true) : (async () => { @@ -121,8 +118,7 @@ export async function importExecutionPayload( return this.bls.verifySignatureSets([signatureSet]); })(), - // Process execution payload envelope (state transition) - // Signature verified separately above (matching block pipeline pattern). + // Signature verified separately above. // State root check is done separately below with better error typing (matching block pipeline pattern). (async () => { try { @@ -168,9 +164,7 @@ export async function importExecutionPayload( }); } - // VALID, ACCEPTED, or SYNCING - proceed with import - - // 4b. Verify envelope state root matches post-state (done separately from state transition, like block pipeline) + // 4b. Verify envelope state root matches post-state const postPayloadState = postPayloadResult.postPayloadState; const stateRoot = postPayloadState.hashTreeRoot(); if (!byteArrayEquals(envelope.message.stateRoot, stateRoot)) { @@ -183,8 +177,8 @@ export async function importExecutionPayload( ); } - // 5. Update fork choice: PENDING → FULL - // TODO GLOAS: Update API when nc/epbs-fc merged + // 5. Update fork choice + // TODO GLOAS: Update API when #8739 merged (gloas fork choice) // this.forkChoice.onExecutionPayload( // envelope.message.beaconBlockRoot, // postPayloadState.hashTreeRoot() @@ -200,8 +194,5 @@ export async function importExecutionPayload( this.metrics?.importPayload.columnsBySource.inc({source}); } - // 8. Write payload envelope to DB (handled separately, see writePayloadEnvelopeInputToDb) - // The write + prune happens asynchronously after import completes - return {success: true}; } diff --git a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts index 828ceff0f6ed..d24c93c5c6c7 100644 --- a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts @@ -4,15 +4,6 @@ import {VersionedHashes} from "../../../execution/index.js"; import {kzgCommitmentToVersionedHash} from "../../../util/blobs.js"; import {AddPayloadEnvelopeProps, ColumnWithSource, CreateFromBlockProps, SourceMeta} from "./types.js"; -/** - * Discriminated union for PayloadEnvelopeInput state. - * - * 4 possible states: - * 1. No payload, no columns (initial state, or after adding columns without payload) - * 2. No payload, all columns (waiting for payload) - * 3. Has payload, no columns (waiting for columns) - * 4. Complete (has payload and all columns) - */ export type PayloadEnvelopeInputState = | { hasPayload: false; @@ -59,7 +50,6 @@ function createPromise(): PromiseParts { * Always has bid (required for creation). * * Completion requires: payload envelope + all sampled columns - * (bid is always present from creation) */ export class PayloadEnvelopeInput { readonly blockRootHex: RootHex; @@ -77,7 +67,6 @@ export class PayloadEnvelopeInput { private timeCreatedSec: number; - // Promise for waiting private readonly dataPromise: PromiseParts; state: PayloadEnvelopeInputState; @@ -99,7 +88,6 @@ export class PayloadEnvelopeInput { this.timeCreatedSec = props.timeCreatedSec; this.dataPromise = createPromise(); - // Check if all columns already satisfied (no blobs = no columns needed) const noBlobs = props.bid.blobKzgCommitments.length === 0; const noSampledColumns = props.sampledColumns.length === 0; const hasAllColumns = noBlobs || noSampledColumns; @@ -139,7 +127,6 @@ export class PayloadEnvelopeInput { if (this.state.hasPayload) { throw new Error("Payload envelope already set"); } - // Validate beacon_block_root matches if (toRootHex(props.envelope.message.beaconBlockRoot) !== this.blockRootHex) { throw new Error("Payload envelope beacon_block_root mismatch"); } @@ -150,7 +137,6 @@ export class PayloadEnvelopeInput { peerIdStr: props.peerIdStr, }; - // Transition state: hasPayload becomes true if (this.state.hasAllColumns) { // Complete state this.state = { @@ -176,17 +162,14 @@ export class PayloadEnvelopeInput { const {columnSidecar, seenTimestampSec} = columnWithSource; this.columnsCache.set(columnSidecar.index, columnWithSource); - // Check if we now have all sampled columns const hasAllSampledColumns = this.sampledColumns.every((idx) => this.columnsCache.has(idx)); const noBlobs = this.bid.blobKzgCommitments.length === 0; const hasAllColumns = hasAllSampledColumns || noBlobs || this.sampledColumns.length === 0; if (!hasAllColumns) { - // Still waiting for more columns, state unchanged return; } - // hasAllColumns is now true, transition state if (this.state.hasPayload) { // Complete state this.state = { @@ -206,8 +189,6 @@ export class PayloadEnvelopeInput { } } - // --- Other getters --- - getVersionedHashes(): VersionedHashes { return this.versionedHashes; } diff --git a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/types.ts b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/types.ts index 3e35849b23eb..b4683a245b1f 100644 --- a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/types.ts +++ b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/types.ts @@ -10,26 +10,16 @@ export enum PayloadEnvelopeInputSource { recovery = "recovery", } -/** - * Metadata about the source of data for PayloadEnvelopeInput. - */ export type SourceMeta = { source: PayloadEnvelopeInputSource; seenTimestampSec: number; peerIdStr?: string; }; -/** - * Gloas data column sidecar with source metadata. - * Uses gloas.DataColumnSidecar (not fulu.DataColumnSidecar). - */ export type ColumnWithSource = SourceMeta & { columnSidecar: gloas.DataColumnSidecar; }; -/** - * Props for creating a PayloadEnvelopeInput from a block. - */ export type CreateFromBlockProps = { blockRootHex: RootHex; block: SignedBeaconBlock; @@ -38,9 +28,6 @@ export type CreateFromBlockProps = { timeCreatedSec: number; }; -/** - * Props for adding a payload envelope to PayloadEnvelopeInput. - */ export type AddPayloadEnvelopeProps = SourceMeta & { envelope: gloas.SignedExecutionPayloadEnvelope; }; diff --git a/packages/beacon-node/src/chain/blocks/writeBlockInputToDb.ts b/packages/beacon-node/src/chain/blocks/writeBlockInputToDb.ts index bcc30c1e40c5..041c781120f5 100644 --- a/packages/beacon-node/src/chain/blocks/writeBlockInputToDb.ts +++ b/packages/beacon-node/src/chain/blocks/writeBlockInputToDb.ts @@ -127,6 +127,7 @@ export async function persistBlockInputs(this: BeaconChain, blockInputs: IBlockI } // Without forcefully clearing this cache, we would rely on WeakMap to evict memory which is not reliable. // Clear here (after the DB write) so that writeBlockInputToDb can still use the cached serialized bytes. + // TODO GLOAS: We probably need a better SerializedCache with proper eviction instead of clearing entire cache on every write. this.serializedCache.clear(); if (blockInputs.length === 1) { this.logger.debug("Pruned block input", { diff --git a/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts b/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts index 212764363ff7..1efdf90aec83 100644 --- a/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts +++ b/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts @@ -4,10 +4,6 @@ import {PayloadEnvelopeInput} from "../seenCache/seenPayloadEnvelopeInput.js"; /** * Persists payload envelope data to DB. This operation must be eventually completed if a payload is imported. - * Else the node will be in an inconsistent state that can lead to being stuck. - * - * This operation may be performed before, during or after importing to the fork choice. As long as errors - * are handled properly for eventual consistency. */ export async function writePayloadEnvelopeInputToDb( this: BeaconChain, @@ -84,6 +80,7 @@ export async function persistPayloadEnvelopeInput( this.seenPayloadEnvelopeInput.delete(payloadInput.blockRootHex); // Without forcefully clearing this cache, we would rely on WeakMap to evict memory which is not reliable. // Clear here (after the DB write) so that writePayloadEnvelopeInputToDb can still use the cached serialized bytes. + // TODO GLOAS: We probably need a better SerializedCache with proper eviction instead of clearing entire cache on every write. this.serializedCache.clear(); this.logger.debug("Pruned payload envelope input", { slot: payloadInput.slot, diff --git a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts index 8682c567da37..6a88caf66c1f 100644 --- a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts @@ -7,12 +7,8 @@ import {CreateFromBlockProps, PayloadEnvelopeInput} from "../blocks/payloadEnvel import {ChainEvent, ChainEventEmitter} from "../emitter.js"; export type {PayloadEnvelopeInputState} from "../blocks/payloadEnvelopeInput/index.js"; -// Re-export for convenience export {PayloadEnvelopeInput} from "../blocks/payloadEnvelopeInput/index.js"; -/** - * Modules required for SeenPayloadEnvelopeInput. - */ export type SeenPayloadEnvelopeInputModules = { chainEvents: ChainEventEmitter; signal: AbortSignal; @@ -49,9 +45,6 @@ export class SeenPayloadEnvelopeInput { }); } - /** - * Handle finalization - prune entries for finalized slots. - */ private onFinalized = (checkpoint: CheckpointWithHex): void => { // Prune all entries with slot <= finalized slot const finalizedSlot = computeStartSlotAtEpoch(checkpoint.epoch); @@ -65,10 +58,6 @@ export class SeenPayloadEnvelopeInput { this.logger?.debug(`SeenPayloadEnvelopeInput.onFinalized deleted ${deletedCount} cached entries`); }; - /** - * Create and store a new PayloadEnvelopeInput during block import. - * Throws if input already exists for this block root. - */ add(props: CreateFromBlockProps): PayloadEnvelopeInput { if (this.payloadInputs.has(props.blockRootHex)) { throw new Error(`PayloadEnvelopeInput already exists for block ${props.blockRootHex}`); @@ -78,31 +67,18 @@ export class SeenPayloadEnvelopeInput { return input; } - /** - * Get existing PayloadEnvelopeInput by block root. - * Returns undefined if block hasn't been imported yet. - */ get(blockRootHex: RootHex): PayloadEnvelopeInput | undefined { return this.payloadInputs.get(blockRootHex); } - /** - * Check if PayloadEnvelopeInput exists for block root. - */ has(blockRootHex: RootHex): boolean { return this.payloadInputs.has(blockRootHex); } - /** - * Remove PayloadEnvelopeInput after payload is written to DB. - */ delete(blockRootHex: RootHex): boolean { return this.payloadInputs.delete(blockRootHex); } - /** - * Get the current size of the cache. - */ size(): number { return this.payloadInputs.size; } diff --git a/packages/beacon-node/test/unit/util/sszBytes.test.ts b/packages/beacon-node/test/unit/util/sszBytes.test.ts index 141aa2eca196..e30bde3735ff 100644 --- a/packages/beacon-node/test/unit/util/sszBytes.test.ts +++ b/packages/beacon-node/test/unit/util/sszBytes.test.ts @@ -26,6 +26,8 @@ import { getAttDataFromSignedAggregateAndProofPhase0, getAttDataFromSingleAttestationSerialized, getAttesterIndexFromSingleAttestationSerialized, + getBeaconBlockRootFromDataColumnSidecarSerialized, + getBeaconBlockRootFromExecutionPayloadEnvelopeSerialized, getBlobKzgCommitmentsCountFromSignedBeaconBlockSerialized, getBlockRootFromAttestationSerialized, getBlockRootFromSignedAggregateAndProofSerialized, @@ -38,6 +40,8 @@ import { getSlotFromAttestationSerialized, getSlotFromBeaconStateSerialized, getSlotFromBlobSidecarSerialized, + getSlotFromDataColumnSidecarSerialized, + getSlotFromExecutionPayloadEnvelopeSerialized, getSlotFromSignedAggregateAndProofSerialized, getSlotFromSignedBeaconBlockSerialized, getSlotFromSingleAttestationSerialized, @@ -478,3 +482,99 @@ function blobSidecarFromValues(slot: Slot): deneb.BlobSidecar { blobSidecar.signedBlockHeader.message.slot = slot; return blobSidecar; } + +describe("SignedExecutionPayloadEnvelope SSZ serialized picking", () => { + const testCases: {slot: Slot; blockRoot: RootHex}[] = [ + {slot: 0, blockRoot: "0x" + "00".repeat(32)}, + {slot: 1_000_000, blockRoot: "0x" + "aa".repeat(32)}, + {slot: 4_294_967_295, blockRoot: "0x" + "ff".repeat(32)}, // max uint32 + ]; + + for (const {slot, blockRoot} of testCases) { + it(`slot=${slot}`, () => { + const envelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue(); + envelope.message.slot = slot; + envelope.message.beaconBlockRoot = fromHex(blockRoot); + const bytes = ssz.gloas.SignedExecutionPayloadEnvelope.serialize(envelope); + + expect(getSlotFromExecutionPayloadEnvelopeSerialized(bytes)).toBe(slot); + expect(getBeaconBlockRootFromExecutionPayloadEnvelopeSerialized(bytes)).toBe(blockRoot); + }); + } + + it("getSlotFromExecutionPayloadEnvelopeSerialized - invalid data", () => { + // Slot is at offset 148, need at least 156 bytes + const invalidSizes = [0, 50, 100, 155]; + for (const size of invalidSizes) { + expect(getSlotFromExecutionPayloadEnvelopeSerialized(Buffer.alloc(size))).toBeNull(); + } + }); + + it("getBeaconBlockRootFromExecutionPayloadEnvelopeSerialized - invalid data", () => { + // Block root is at offset 116, need at least 148 bytes + const invalidSizes = [0, 50, 100, 147]; + for (const size of invalidSizes) { + expect(getBeaconBlockRootFromExecutionPayloadEnvelopeSerialized(Buffer.alloc(size))).toBeNull(); + } + }); +}); + +describe("DataColumnSidecar SSZ serialized picking (fork-aware)", () => { + describe("Fulu (pre-Gloas)", () => { + const testCases: {slot: Slot}[] = [{slot: 0}, {slot: 500_000}, {slot: 4_294_967_295}]; + + for (const {slot} of testCases) { + it(`slot=${slot}`, () => { + const sidecar = ssz.fulu.DataColumnSidecar.defaultValue(); + sidecar.signedBlockHeader.message.slot = slot; + const bytes = ssz.fulu.DataColumnSidecar.serialize(sidecar); + + expect(getSlotFromDataColumnSidecarSerialized(bytes, ForkName.fulu)).toBe(slot); + }); + } + + it("getSlotFromDataColumnSidecarSerialized - invalid data", () => { + // Slot is at offset 20 for pre-Gloas, need at least 28 bytes + const invalidSizes = [0, 10, 27]; + for (const size of invalidSizes) { + expect(getSlotFromDataColumnSidecarSerialized(Buffer.alloc(size), ForkName.fulu)).toBeNull(); + } + }); + }); + + describe("Gloas", () => { + const testCases: {slot: Slot; blockRoot: RootHex}[] = [ + {slot: 0, blockRoot: "0x" + "00".repeat(32)}, + {slot: 600_000, blockRoot: "0x" + "bb".repeat(32)}, + {slot: 4_294_967_295, blockRoot: "0x" + "ff".repeat(32)}, + ]; + + for (const {slot, blockRoot} of testCases) { + it(`slot=${slot}`, () => { + const sidecar = ssz.gloas.DataColumnSidecar.defaultValue(); + sidecar.slot = slot; + sidecar.beaconBlockRoot = fromHex(blockRoot); + const bytes = ssz.gloas.DataColumnSidecar.serialize(sidecar); + + expect(getSlotFromDataColumnSidecarSerialized(bytes, ForkName.gloas)).toBe(slot); + expect(getBeaconBlockRootFromDataColumnSidecarSerialized(bytes)).toBe(blockRoot); + }); + } + + it("getSlotFromDataColumnSidecarSerialized - invalid data", () => { + // Slot is at offset 16 for Gloas, need at least 24 bytes + const invalidSizes = [0, 10, 23]; + for (const size of invalidSizes) { + expect(getSlotFromDataColumnSidecarSerialized(Buffer.alloc(size), ForkName.gloas)).toBeNull(); + } + }); + + it("getBeaconBlockRootFromDataColumnSidecarSerialized - invalid data", () => { + // Block root is at offset 24 for Gloas, need at least 56 bytes + const invalidSizes = [0, 20, 55]; + for (const size of invalidSizes) { + expect(getBeaconBlockRootFromDataColumnSidecarSerialized(Buffer.alloc(size))).toBeNull(); + } + }); + }); +}); From a920a05ccda4505ebd8f84f253129a0a754eb5b4 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:05:24 -0800 Subject: [PATCH 07/38] add todo --- packages/beacon-node/src/chain/blocks/importExecutionPayload.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index c308d9722310..a7763f6418a9 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -187,6 +187,8 @@ export async function importExecutionPayload( // 6. Cache payload state // TODO GLOAS: Enable when PR #8868 merged (adds processPayloadState) // this.regen.processPayloadState(postPayloadState); + // if epoch boundary also call + // this.regen.addCheckpointState(cp, checkpointState, true); // 7. Record metrics for payload envelope and column sources this.metrics?.importPayload.bySource.inc({source: payloadInput.getPayloadEnvelopeSource().source}); From 548cf77ea5435a214a0904c661428f43e61d93c3 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:51:28 -0800 Subject: [PATCH 08/38] fix merge Co-Authored-By: Claude Opus 4.6 --- packages/beacon-node/src/chain/blocks/importBlock.ts | 12 +++++++++++- .../src/chain/blocks/importExecutionPayload.ts | 2 +- packages/beacon-node/src/chain/forkChoice/index.ts | 10 ---------- .../src/chain/validation/executionPayloadEnvelope.ts | 12 +++++++++++- packages/fork-choice/src/forkChoice/forkChoice.ts | 5 +---- packages/fork-choice/src/protoArray/interface.ts | 9 --------- 6 files changed, 24 insertions(+), 26 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index fa8e1e83d0dd..8837fb23aef3 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -28,7 +28,17 @@ import { isStartSlotOfEpoch, isStateValidatorsNodesPopulated, } from "@lodestar/state-transition"; -import {Attestation, BeaconBlock, SignedBeaconBlock, altair, capella, electra, isGloasBeaconBlock, phase0, ssz} from "@lodestar/types"; +import { + Attestation, + BeaconBlock, + SignedBeaconBlock, + altair, + capella, + electra, + isGloasBeaconBlock, + phase0, + ssz, +} from "@lodestar/types"; import {isErrorAborted, toRootHex} from "@lodestar/utils"; import {ZERO_HASH_HEX} from "../../constants/index.js"; import {callInNextEventLoop} from "../../util/eventLoop.js"; diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index a7763f6418a9..a4dae80d0175 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -78,7 +78,7 @@ export async function importExecutionPayload( const blockRootHex = payloadInput.blockRootHex; // 1. Get ProtoBlock for parent root lookup - const protoBlock = this.forkChoice.getBlockHex(blockRootHex); + const protoBlock = this.forkChoice.getBlockHexDefaultStatus(blockRootHex); if (!protoBlock) { throw new PayloadError({ code: PayloadErrorCode.BLOCK_NOT_IN_FORK_CHOICE, diff --git a/packages/beacon-node/src/chain/forkChoice/index.ts b/packages/beacon-node/src/chain/forkChoice/index.ts index f37721a270cb..20b654bd1472 100644 --- a/packages/beacon-node/src/chain/forkChoice/index.ts +++ b/packages/beacon-node/src/chain/forkChoice/index.ts @@ -158,10 +158,6 @@ export function initializeForkChoiceFromFinalizedState( dataAvailabilityStatus: DataAvailabilityStatus.PreData, payloadStatus: isForkPostGloas ? PayloadStatus.PENDING : PayloadStatus.FULL, // TODO GLOAS: Post-gloas how do we know if the checkpoint payload is FULL or EMPTY? - builderIndex: isForkPostGloas ? (state as CachedBeaconStateGloas).latestExecutionPayloadBid.builderIndex : null, - blockHashFromBid: isForkPostGloas - ? toRootHex((state as CachedBeaconStateGloas).latestExecutionPayloadBid.blockHash) - : null, parentBlockHash: isForkPostGloas ? toRootHex((state as CachedBeaconStateGloas).latestBlockHash) : null, }, currentSlot @@ -255,12 +251,6 @@ export function initializeForkChoiceFromUnfinalizedState( dataAvailabilityStatus: DataAvailabilityStatus.PreData, payloadStatus: isForkPostGloas ? PayloadStatus.PENDING : PayloadStatus.FULL, // TODO GLOAS: Post-gloas how do we know if the checkpoint payload is FULL or EMPTY? - builderIndex: isForkPostGloas - ? (unfinalizedState as CachedBeaconStateGloas).latestExecutionPayloadBid.builderIndex - : null, - blockHashFromBid: isForkPostGloas - ? toRootHex((unfinalizedState as CachedBeaconStateGloas).latestExecutionPayloadBid.blockHash) - : null, parentBlockHash: isForkPostGloas ? toRootHex((unfinalizedState as CachedBeaconStateGloas).latestBlockHash) : null, }; diff --git a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts index dbf3b7f71910..4359889054a2 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts @@ -111,7 +111,16 @@ async function validateExecutionPayloadEnvelope( // [REJECT] `signed_execution_payload_envelope.signature` is valid with respect to the builder's public key. const parentRoot = block.parentRoot; - const parentBlock = chain.forkChoice.getBlockHex(parentRoot); + const parentHash = block.parentBlockHash; + if (parentHash === null) { + throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { + code: ExecutionPayloadEnvelopeErrorCode.PARENT_UNKNOWN, + parentRoot, + slot: envelope.slot, + }); + } + + const parentBlock = chain.forkChoice.getBlockHexAndBlockHash(parentRoot, parentHash); if (parentBlock === null) { throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { code: ExecutionPayloadEnvelopeErrorCode.PARENT_UNKNOWN, @@ -120,6 +129,7 @@ async function validateExecutionPayloadEnvelope( }); } + // Get pre-state to get correct builder's pubkey. const blockState = await chain.regen .getBlockSlotState(parentBlock, block.slot, {dontTransferCache: true}, RegenCaller.validateGossipBlock) .catch(() => { diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 4c6af2cab959..6b76d79a2e1d 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -24,6 +24,7 @@ import { RootHex, Slot, ValidatorIndex, + isGloasBeaconBlock, phase0, ssz, } from "@lodestar/types"; @@ -833,10 +834,6 @@ export class ForkChoice implements IForkChoice { }), payloadStatus: isGloasBeaconBlock(block) ? PayloadStatus.PENDING : PayloadStatus.FULL, - builderIndex: isGloasBeaconBlock(block) ? block.body.signedExecutionPayloadBid.message.builderIndex : null, - blockHashFromBid: isGloasBeaconBlock(block) - ? toRootHex(block.body.signedExecutionPayloadBid.message.blockHash) - : null, parentBlockHash: parentHashHex, }; diff --git a/packages/fork-choice/src/protoArray/interface.ts b/packages/fork-choice/src/protoArray/interface.ts index b83739843b25..02e9890297fc 100644 --- a/packages/fork-choice/src/protoArray/interface.ts +++ b/packages/fork-choice/src/protoArray/interface.ts @@ -123,22 +123,13 @@ export type ProtoBlock = BlockExtraMeta & { // Indicate whether block arrives in a timely manner ie. before the 4 second mark timeliness: boolean; -<<<<<<< HEAD -======= /** Payload status for this node (Gloas fork). Always FULL in pre-gloas */ payloadStatus: PayloadStatus; - // GLOAS: The followings are from bids. They are null in pre-gloas - // Used for execution payload gossip validation - builderIndex: number | null; - // Used for execution payload gossip validation. Not to be confused with executionPayloadBlockHash - blockHashFromBid: RootHex | null; - // Used to determine if this block extends EMPTY or FULL parent variant // Spec: gloas/fork-choice.md#new-get_parent_payload_status parentBlockHash: RootHex | null; ->>>>>>> unstable }; /** From 569eb98dce13059df19c60adf6e1c19f792d3947 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:00:49 -0700 Subject: [PATCH 09/38] Fix merge --- .../perf/chain/opPools/aggregatedAttestationPool.test.ts | 4 ---- packages/beacon-node/test/utils/state.ts | 2 -- .../beacon-node/test/utils/validationData/attestation.ts | 2 -- packages/fork-choice/test/perf/forkChoice/util.ts | 2 -- .../fork-choice/test/unit/forkChoice/forkChoice.test.ts | 2 -- .../test/unit/forkChoice/getProposerHead.test.ts | 6 ------ .../unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts | 6 ------ .../test/unit/protoArray/executionStatusUpdates.test.ts | 2 -- .../test/unit/protoArray/getCommonAncestor.test.ts | 4 ---- packages/fork-choice/test/unit/protoArray/gloas.test.ts | 2 -- .../fork-choice/test/unit/protoArray/protoArray.test.ts | 6 ------ 11 files changed, 38 deletions(-) diff --git a/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts b/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts index 785c92fc44ef..c12cc6151f74 100644 --- a/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts +++ b/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts @@ -71,8 +71,6 @@ describe(`getAttestationsForBlock vc=${vc}`, () => { parentBlockHash: null, payloadStatus: 2, // PayloadStatus.FULL - builderIndex: null, - blockHashFromBid: null, }, originalState.slot ); @@ -101,8 +99,6 @@ describe(`getAttestationsForBlock vc=${vc}`, () => { parentBlockHash: null, payloadStatus: 2, // PayloadStatus.FULL - builderIndex: null, - blockHashFromBid: null, }, slot, null diff --git a/packages/beacon-node/test/utils/state.ts b/packages/beacon-node/test/utils/state.ts index bab23523b29b..bcb324302673 100644 --- a/packages/beacon-node/test/utils/state.ts +++ b/packages/beacon-node/test/utils/state.ts @@ -176,7 +176,5 @@ export const zeroProtoBlock: ProtoBlock = { ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, parentBlockHash: null, }; diff --git a/packages/beacon-node/test/utils/validationData/attestation.ts b/packages/beacon-node/test/utils/validationData/attestation.ts index 18175f596b19..3d1dc74caa74 100644 --- a/packages/beacon-node/test/utils/validationData/attestation.ts +++ b/packages/beacon-node/test/utils/validationData/attestation.ts @@ -82,8 +82,6 @@ export function getAttestationValidData(opts: AttestationValidDataOpts): { parentBlockHash: null, payloadStatus: 2, // PayloadStatus.FULL - builderIndex: null, - blockHashFromBid: null, }; const shufflingCache = new ShufflingCache(null, null, {}, [ diff --git a/packages/fork-choice/test/perf/forkChoice/util.ts b/packages/fork-choice/test/perf/forkChoice/util.ts index 9cc0cfe9a1fe..85d1918abc4a 100644 --- a/packages/fork-choice/test/perf/forkChoice/util.ts +++ b/packages/fork-choice/test/perf/forkChoice/util.ts @@ -110,8 +110,6 @@ export function initializeForkChoice(opts: Opts): ForkChoice { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }; protoArr.onBlock(block, block.slot, null); diff --git a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts index c2ab249d3093..df68c53632dd 100644 --- a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts @@ -133,8 +133,6 @@ describe("Forkchoice", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }; }; diff --git a/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts b/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts index 0d556343e5b7..8c89b5d35dba 100644 --- a/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts @@ -52,8 +52,6 @@ describe("Forkchoice / GetProposerHead", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }; const baseHeadBlock: ProtoBlockWithWeight = { @@ -82,8 +80,6 @@ describe("Forkchoice / GetProposerHead", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }; const baseParentHeadBlock: ProtoBlockWithWeight = { @@ -111,8 +107,6 @@ describe("Forkchoice / GetProposerHead", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }; const fcStore: IForkChoiceStore = { diff --git a/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts b/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts index a613962a0740..4cd4544c0240 100644 --- a/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts @@ -52,8 +52,6 @@ describe("Forkchoice / shouldOverrideForkChoiceUpdate", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }; const baseHeadBlock: ProtoBlockWithWeight = { @@ -82,8 +80,6 @@ describe("Forkchoice / shouldOverrideForkChoiceUpdate", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }; const baseParentHeadBlock: ProtoBlockWithWeight = { @@ -111,8 +107,6 @@ describe("Forkchoice / shouldOverrideForkChoiceUpdate", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }; const fcStore: IForkChoiceStore = { diff --git a/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts b/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts index 9e8b79261ad0..8a0a544baeb4 100644 --- a/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts +++ b/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts @@ -124,8 +124,6 @@ function setupForkChoice(): ProtoArray { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }, block.slot, null diff --git a/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts b/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts index c72f97ebf65a..e8349f4ea426 100644 --- a/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts +++ b/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts @@ -48,8 +48,6 @@ describe("getCommonAncestor", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }, 0 ); @@ -79,8 +77,6 @@ describe("getCommonAncestor", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }, block.slot, null diff --git a/packages/fork-choice/test/unit/protoArray/gloas.test.ts b/packages/fork-choice/test/unit/protoArray/gloas.test.ts index b1e5bb84d684..8648633e725d 100644 --- a/packages/fork-choice/test/unit/protoArray/gloas.test.ts +++ b/packages/fork-choice/test/unit/protoArray/gloas.test.ts @@ -53,8 +53,6 @@ describe("Gloas Fork Choice", () => { dataAvailabilityStatus: DataAvailabilityStatus.Available, parentBlockHash: parentBlockHash === undefined ? null : parentBlockHash, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }; } diff --git a/packages/fork-choice/test/unit/protoArray/protoArray.test.ts b/packages/fork-choice/test/unit/protoArray/protoArray.test.ts index 61567ea73204..f23332f7a292 100644 --- a/packages/fork-choice/test/unit/protoArray/protoArray.test.ts +++ b/packages/fork-choice/test/unit/protoArray/protoArray.test.ts @@ -37,8 +37,6 @@ describe("ProtoArray", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }, genesisSlot ); @@ -68,8 +66,6 @@ describe("ProtoArray", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }, genesisSlot + 1, null @@ -100,8 +96,6 @@ describe("ProtoArray", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }, genesisSlot + 1, null From aa3ac88e957765db61fe43216e7a993a2fcc78c2 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:51:12 -0700 Subject: [PATCH 10/38] partially address comments --- packages/beacon-node/src/chain/blocks/importBlock.ts | 4 +++- .../src/chain/blocks/importExecutionPayload.ts | 8 ++++++-- .../blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts | 4 ++++ .../src/chain/validation/executionPayloadEnvelope.ts | 9 ++++++++- .../beacon-node/src/network/processor/gossipHandlers.ts | 3 +-- 5 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index f052698c7266..a8df93e2f6a7 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -146,7 +146,9 @@ export async function importBlock( custodyColumns: this.custodyConfig.custodyColumns, timeCreatedSec: fullyVerifiedBlock.seenTimestampSec, }); - this.logger.debug("Created PayloadEnvelopeInput for Gloas block", {slot: blockSlot, root: blockRootHex}); + if (opts.seenTimestampSec !== undefined) { + this.logger.debug("Created PayloadEnvelopeInput for Gloas block", {slot: blockSlot, root: blockRootHex}); + } } this.metrics?.importBlock.bySource.inc({source: source.source}); diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index a4dae80d0175..ff93c4f24a7a 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -1,5 +1,5 @@ import {PublicKey} from "@chainsafe/blst"; -import {ForkName} from "@lodestar/params"; +import {BUILDER_INDEX_SELF_BUILD, ForkName} from "@lodestar/params"; import { CachedBeaconStateGloas, createSingleSignatureSetFromComponents, @@ -110,8 +110,12 @@ export async function importExecutionPayload( opts.validSignature === true ? Promise.resolve(true) : (async () => { + const builderPubkey = + envelope.message.builderIndex === BUILDER_INDEX_SELF_BUILD + ? blockState.epochCtx.pubkeyCache.getOrThrow(payloadInput.proposerIndex) + : PublicKey.fromBytes(blockState.builders.getReadonly(envelope.message.builderIndex).pubkey); const signatureSet = createSingleSignatureSetFromComponents( - PublicKey.fromBytes(blockState.builders.getReadonly(envelope.message.builderIndex).pubkey), + builderPubkey, getExecutionPayloadEnvelopeSigningRoot(this.config, envelope.message), envelope.signature ); diff --git a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts index d24c93c5c6c7..b3a7e654d1da 100644 --- a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts @@ -54,6 +54,7 @@ function createPromise(): PromiseParts { export class PayloadEnvelopeInput { readonly blockRootHex: RootHex; readonly slot: Slot; + readonly proposerIndex: ValidatorIndex; readonly bid: gloas.ExecutionPayloadBid; readonly versionedHashes: VersionedHashes; @@ -74,6 +75,7 @@ export class PayloadEnvelopeInput { private constructor(props: { blockRootHex: RootHex; slot: Slot; + proposerIndex: ValidatorIndex; bid: gloas.ExecutionPayloadBid; sampledColumns: ColumnIndex[]; custodyColumns: ColumnIndex[]; @@ -81,6 +83,7 @@ export class PayloadEnvelopeInput { }) { this.blockRootHex = props.blockRootHex; this.slot = props.slot; + this.proposerIndex = props.proposerIndex; this.bid = props.bid; this.versionedHashes = props.bid.blobKzgCommitments.map(kzgCommitmentToVersionedHash); this.sampledColumns = props.sampledColumns; @@ -100,6 +103,7 @@ export class PayloadEnvelopeInput { return new PayloadEnvelopeInput({ blockRootHex: props.blockRootHex, slot: props.block.message.slot, + proposerIndex: props.block.message.proposerIndex, bid, sampledColumns: props.sampledColumns, custodyColumns: props.custodyColumns, diff --git a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts index 4359889054a2..c259083c936e 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts @@ -1,4 +1,5 @@ import {PublicKey} from "@chainsafe/blst"; +import {BUILDER_INDEX_SELF_BUILD} from "@lodestar/params"; import { CachedBeaconStateGloas, computeStartSlotAtEpoch, @@ -140,8 +141,14 @@ async function validateExecutionPayloadEnvelope( }); }); + const state = blockState as CachedBeaconStateGloas; + const builderPubkey = + envelope.builderIndex === BUILDER_INDEX_SELF_BUILD + ? state.epochCtx.pubkeyCache.getOrThrow(payloadInput.proposerIndex) + : PublicKey.fromBytes(state.builders.getReadonly(envelope.builderIndex).pubkey); + const signatureSet = createSingleSignatureSetFromComponents( - PublicKey.fromBytes((blockState as CachedBeaconStateGloas).builders.getReadonly(envelope.builderIndex).pubkey), + builderPubkey, getExecutionPayloadEnvelopeSigningRoot(chain.config, envelope), executionPayloadEnvelope.signature ); diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index 577c67c44bcd..10fb6eeaf601 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -855,8 +855,7 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand if (!payloadInput) { // This shouldn't happen because beacon block should have been imported and thus payload input should have been created. - logger.warn("PayloadEnvelopeInput not found after validation", {blockRootHex, slot}); - return; + throw Error(`PayloadEnvelopeInput not found after validation blockRoot=${blockRootHex} slot=${slot}`); } chain.serializedCache.set(executionPayloadEnvelope, serializedData); From 640220691a321a2bb1d2ad51a6bf0e0011151161 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:16:44 -0700 Subject: [PATCH 11/38] address comments Co-Authored-By: Claude Opus 4.6 --- .../beacon-node/src/chain/blocks/importExecutionPayload.ts | 7 ++++++- .../src/chain/blocks/writePayloadEnvelopeInputToDb.ts | 4 ++++ .../src/chain/errors/executionPayloadEnvelope.ts | 6 ++++-- .../src/chain/seenCache/seenPayloadEnvelopeInput.ts | 4 ++-- .../src/chain/validation/executionPayloadEnvelope.ts | 4 ++-- 5 files changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index ff93c4f24a7a..9315cfb1a664 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -126,7 +126,6 @@ export async function importExecutionPayload( // State root check is done separately below with better error typing (matching block pipeline pattern). (async () => { try { - // Clone state to avoid mutating the cached state const mutableState = blockState.clone(); processExecutionPayloadEnvelope(mutableState, envelope, {verifySignature: false, verifyStateRoot: false}); return {postPayloadState: mutableState}; @@ -200,5 +199,11 @@ export async function importExecutionPayload( this.metrics?.importPayload.columnsBySource.inc({source}); } + this.logger.verbose("Execution payload imported", { + slot: payloadInput.slot, + beaconBlockRoot: blockRootHex, + blockHash: payloadInput.getBlockHashHex(), + }); + return {success: true}; } diff --git a/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts b/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts index 1efdf90aec83..49ad3c0ca2e7 100644 --- a/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts +++ b/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts @@ -4,6 +4,10 @@ import {PayloadEnvelopeInput} from "../seenCache/seenPayloadEnvelopeInput.js"; /** * Persists payload envelope data to DB. This operation must be eventually completed if a payload is imported. + * + * TODO: Persist envelope metadata (stateRoot, executionRequests, builderIndex, etc.) without the full + * execution payload body — only keep the blockHash reference. The EL already stores the payload. + * See https://github.com/ChainSafe/lodestar/issues/5671 */ export async function writePayloadEnvelopeInputToDb( this: BeaconChain, diff --git a/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts index 513513550f56..2df9452aed4c 100644 --- a/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts @@ -5,18 +5,20 @@ export enum ExecutionPayloadEnvelopeErrorCode { BELONG_TO_FINALIZED_BLOCK = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_BELONG_TO_FINALIZED_BLOCK", BLOCK_ROOT_UNKNOWN = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_BLOCK_ROOT_UNKNOWN", PARENT_UNKNOWN = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_PARENT_UNKNOWN", + UNKNOWN_PARENT_STATE = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_UNKNOWN_PARENT_STATE", ENVELOPE_ALREADY_KNOWN = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_ALREADY_KNOWN", INVALID_BLOCK = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_INVALID_BLOCK", SLOT_MISMATCH = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_SLOT_MISMATCH", BUILDER_INDEX_MISMATCH = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_BUILDER_INDEX_MISMATCH", BLOCK_HASH_MISMATCH = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_BLOCK_HASH_MISMATCH", INVALID_SIGNATURE = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_INVALID_SIGNATURE", - CACHE_FAIL = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_CACHE_FAIL", + PAYLOAD_ENVELOPE_INPUT_MISSING = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_PAYLOAD_ENVELOPE_INPUT_MISSING", } export type ExecutionPayloadEnvelopeErrorType = | {code: ExecutionPayloadEnvelopeErrorCode.BELONG_TO_FINALIZED_BLOCK; envelopeSlot: Slot; finalizedSlot: Slot} | {code: ExecutionPayloadEnvelopeErrorCode.BLOCK_ROOT_UNKNOWN; blockRoot: RootHex} | {code: ExecutionPayloadEnvelopeErrorCode.PARENT_UNKNOWN; parentRoot: RootHex; slot: Slot} + | {code: ExecutionPayloadEnvelopeErrorCode.UNKNOWN_PARENT_STATE; parentRoot: RootHex; slot: Slot} | { code: ExecutionPayloadEnvelopeErrorCode.ENVELOPE_ALREADY_KNOWN; blockRoot: RootHex; @@ -35,6 +37,6 @@ export type ExecutionPayloadEnvelopeErrorType = bidBlockHash: RootHex | null; } | {code: ExecutionPayloadEnvelopeErrorCode.INVALID_SIGNATURE} - | {code: ExecutionPayloadEnvelopeErrorCode.CACHE_FAIL; blockRoot: RootHex}; + | {code: ExecutionPayloadEnvelopeErrorCode.PAYLOAD_ENVELOPE_INPUT_MISSING; blockRoot: RootHex}; export class ExecutionPayloadEnvelopeError extends GossipActionError {} diff --git a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts index 6a88caf66c1f..ff358a35c271 100644 --- a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts @@ -46,11 +46,11 @@ export class SeenPayloadEnvelopeInput { } private onFinalized = (checkpoint: CheckpointWithHex): void => { - // Prune all entries with slot <= finalized slot + // Prune all entries with slot < finalized slot const finalizedSlot = computeStartSlotAtEpoch(checkpoint.epoch); let deletedCount = 0; for (const [rootHex, input] of this.payloadInputs) { - if (input.slot <= finalizedSlot) { + if (input.slot < finalizedSlot) { this.payloadInputs.delete(rootHex); deletedCount++; } diff --git a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts index c259083c936e..af7edab62b0d 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts @@ -53,7 +53,7 @@ async function validateExecutionPayloadEnvelope( if (!payloadInput) { // PayloadEnvelopeInput should have been created during block import throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { - code: ExecutionPayloadEnvelopeErrorCode.CACHE_FAIL, + code: ExecutionPayloadEnvelopeErrorCode.PAYLOAD_ENVELOPE_INPUT_MISSING, blockRoot: blockRootHex, }); } @@ -135,7 +135,7 @@ async function validateExecutionPayloadEnvelope( .getBlockSlotState(parentBlock, block.slot, {dontTransferCache: true}, RegenCaller.validateGossipBlock) .catch(() => { throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { - code: ExecutionPayloadEnvelopeErrorCode.PARENT_UNKNOWN, + code: ExecutionPayloadEnvelopeErrorCode.UNKNOWN_PARENT_STATE, parentRoot, slot: envelope.slot, }); From 4b499ca23fdf6aaea239e16ffc7f837fd0a2a88a Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:09:23 -0700 Subject: [PATCH 12/38] fix: merge + incorporate changes from #9007 Co-Authored-By: Claude Opus 4.6 --- .../payloadEnvelopeInput.ts | 14 ++++++++++++ .../blocks/writePayloadEnvelopeInputToDb.ts | 6 +---- packages/beacon-node/src/chain/chain.ts | 1 + .../seenCache/seenPayloadEnvelopeInput.ts | 22 ++++++++++++++----- .../block/processExecutionPayloadEnvelope.ts | 6 ++--- 5 files changed, 36 insertions(+), 13 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts index b3a7e654d1da..81471b7f295c 100644 --- a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts @@ -263,6 +263,20 @@ export class PayloadEnvelopeInput { return this.dataPromise.promise; } + getSerializedCacheKeys(): object[] { + const objects: object[] = []; + + if (this.state.hasPayload) { + objects.push(this.state.payloadEnvelope); + } + + for (const {columnSidecar} of this.columnsCache.values()) { + objects.push(columnSidecar); + } + + return objects; + } + getLogMeta(): { slot: number; blockRoot: string; diff --git a/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts b/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts index 49ad3c0ca2e7..8a5e5dd58920 100644 --- a/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts +++ b/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts @@ -81,11 +81,7 @@ export async function persistPayloadEnvelopeInput( ); }) .finally(() => { - this.seenPayloadEnvelopeInput.delete(payloadInput.blockRootHex); - // Without forcefully clearing this cache, we would rely on WeakMap to evict memory which is not reliable. - // Clear here (after the DB write) so that writePayloadEnvelopeInputToDb can still use the cached serialized bytes. - // TODO GLOAS: We probably need a better SerializedCache with proper eviction instead of clearing entire cache on every write. - this.serializedCache.clear(); + this.seenPayloadEnvelopeInput.prune(payloadInput.blockRootHex); this.logger.debug("Pruned payload envelope input", { slot: payloadInput.slot, root: payloadInput.blockRootHex, diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 12fa1f6ecc65..67e7c3f8934d 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -336,6 +336,7 @@ export class BeaconChain implements IBeaconChain { this.seenPayloadEnvelopeInput = new SeenPayloadEnvelopeInput({ chainEvents: emitter, signal, + serializedCache: this.serializedCache, metrics, logger, }); diff --git a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts index ff358a35c271..f0d94858ef95 100644 --- a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts @@ -3,6 +3,7 @@ import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; import {RootHex} from "@lodestar/types"; import {Logger} from "@lodestar/utils"; import {Metrics} from "../../metrics/metrics.js"; +import {SerializedCache} from "../../util/serializedCache.js"; import {CreateFromBlockProps, PayloadEnvelopeInput} from "../blocks/payloadEnvelopeInput/index.js"; import {ChainEvent, ChainEventEmitter} from "../emitter.js"; @@ -12,6 +13,7 @@ export {PayloadEnvelopeInput} from "../blocks/payloadEnvelopeInput/index.js"; export type SeenPayloadEnvelopeInputModules = { chainEvents: ChainEventEmitter; signal: AbortSignal; + serializedCache: SerializedCache; metrics: Metrics | null; logger?: Logger; }; @@ -25,12 +27,14 @@ export type SeenPayloadEnvelopeInputModules = { export class SeenPayloadEnvelopeInput { private readonly chainEvents: ChainEventEmitter; private readonly signal: AbortSignal; + private readonly serializedCache: SerializedCache; private readonly logger?: Logger; private payloadInputs = new Map(); - constructor({chainEvents, signal, metrics, logger}: SeenPayloadEnvelopeInputModules) { + constructor({chainEvents, signal, serializedCache, metrics, logger}: SeenPayloadEnvelopeInputModules) { this.chainEvents = chainEvents; this.signal = signal; + this.serializedCache = serializedCache; this.logger = logger; if (metrics) { @@ -49,9 +53,9 @@ export class SeenPayloadEnvelopeInput { // Prune all entries with slot < finalized slot const finalizedSlot = computeStartSlotAtEpoch(checkpoint.epoch); let deletedCount = 0; - for (const [rootHex, input] of this.payloadInputs) { + for (const [, input] of this.payloadInputs) { if (input.slot < finalizedSlot) { - this.payloadInputs.delete(rootHex); + this.evictPayloadInput(input); deletedCount++; } } @@ -75,11 +79,19 @@ export class SeenPayloadEnvelopeInput { return this.payloadInputs.has(blockRootHex); } - delete(blockRootHex: RootHex): boolean { - return this.payloadInputs.delete(blockRootHex); + prune(blockRootHex: RootHex): void { + const payloadInput = this.payloadInputs.get(blockRootHex); + if (payloadInput) { + this.evictPayloadInput(payloadInput); + } } size(): number { return this.payloadInputs.size; } + + private evictPayloadInput(payloadInput: PayloadEnvelopeInput): void { + this.serializedCache.delete(payloadInput.getSerializedCacheKeys()); + this.payloadInputs.delete(payloadInput.blockRootHex); + } } diff --git a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts index 7bfbe85af4b8..955f10e96175 100644 --- a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts +++ b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts @@ -14,8 +14,8 @@ import {processDepositRequest} from "./processDepositRequest.ts"; import {processWithdrawalRequest} from "./processWithdrawalRequest.ts"; export type ProcessExecutionPayloadEnvelopeOpts = { - verifySignature?: boolean, - verifyStateRoot?: boolean, + verifySignature?: boolean; + verifyStateRoot?: boolean; dontTransferCache?: boolean; }; @@ -27,7 +27,7 @@ export function processExecutionPayloadEnvelope( signedEnvelope: gloas.SignedExecutionPayloadEnvelope, opts?: ProcessExecutionPayloadEnvelopeOpts ): CachedBeaconStateGloas { - const {verifySignature = true, verifyStateRoot = true} = opts; + const {verifySignature = true, verifyStateRoot = true} = opts ?? {}; const envelope = signedEnvelope.message; const payload = envelope.payload; const fork = state.config.getForkSeq(envelope.slot); From ef6a471f6590309736299699d5054cb1c2283f43 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:12:16 -0700 Subject: [PATCH 13/38] Address comments --- .../beacon-node/src/chain/blocks/importExecutionPayload.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index 9315cfb1a664..add8f3bdbe17 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -126,9 +126,7 @@ export async function importExecutionPayload( // State root check is done separately below with better error typing (matching block pipeline pattern). (async () => { try { - const mutableState = blockState.clone(); - processExecutionPayloadEnvelope(mutableState, envelope, {verifySignature: false, verifyStateRoot: false}); - return {postPayloadState: mutableState}; + return {postPayloadState: processExecutionPayloadEnvelope(blockState, envelope, {verifySignature: false, verifyStateRoot: false})}; } catch (e) { throw new PayloadError( { From 4654d10dde5a2d0cae506a0a49f629b7100862cb Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:21:00 -0700 Subject: [PATCH 14/38] import payload to fork choice --- .../chain/blocks/importExecutionPayload.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index add8f3bdbe17..18f5a13ade69 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -167,23 +167,24 @@ export async function importExecutionPayload( // 4b. Verify envelope state root matches post-state const postPayloadState = postPayloadResult.postPayloadState; - const stateRoot = postPayloadState.hashTreeRoot(); - if (!byteArrayEquals(envelope.message.stateRoot, stateRoot)) { + const postPayloadStateRoot = postPayloadState.hashTreeRoot(); + if (!byteArrayEquals(envelope.message.stateRoot, postPayloadStateRoot)) { throw new PayloadError( { code: PayloadErrorCode.STATE_TRANSITION_ERROR, - message: `Envelope state root mismatch expected=${toRootHex(envelope.message.stateRoot)} actual=${toRootHex(stateRoot)}`, + message: `Envelope state root mismatch expected=${toRootHex(envelope.message.stateRoot)} actual=${toRootHex(postPayloadStateRoot)}`, }, - `Envelope state root mismatch expected=${toRootHex(envelope.message.stateRoot)} actual=${toRootHex(stateRoot)}` + `Envelope state root mismatch expected=${toRootHex(envelope.message.stateRoot)} actual=${toRootHex(postPayloadStateRoot)}` ); } // 5. Update fork choice - // TODO GLOAS: Update API when #8739 merged (gloas fork choice) - // this.forkChoice.onExecutionPayload( - // envelope.message.beaconBlockRoot, - // postPayloadState.hashTreeRoot() - // ); + this.forkChoice.onExecutionPayload( + blockRootHex, + payloadInput.getBlockHashHex(), + envelope.message.payload.blockNumber, + toRootHex(postPayloadStateRoot) + ); // 6. Cache payload state // TODO GLOAS: Enable when PR #8868 merged (adds processPayloadState) From e88259936671e75081fdbe7a4caaca0885f716b1 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Wed, 11 Mar 2026 00:02:50 -0700 Subject: [PATCH 15/38] Use IDataColumnsInput --- .../src/chain/blocks/blockInput/types.ts | 6 +-- .../chain/blocks/importExecutionPayload.ts | 7 ++- .../payloadEnvelopeInput.ts | 37 +++++++++++---- .../blocks/writePayloadEnvelopeInputToDb.ts | 47 +++---------------- 4 files changed, 44 insertions(+), 53 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/blockInput/types.ts b/packages/beacon-node/src/chain/blocks/blockInput/types.ts index e932360a9269..06ab97acb45a 100644 --- a/packages/beacon-node/src/chain/blocks/blockInput/types.ts +++ b/packages/beacon-node/src/chain/blocks/blockInput/types.ts @@ -1,5 +1,5 @@ import {ForkName} from "@lodestar/params"; -import {ColumnIndex, RootHex, SignedBeaconBlock, Slot, deneb, fulu} from "@lodestar/types"; +import {ColumnIndex, DataColumnSidecars, RootHex, SignedBeaconBlock, Slot, deneb, fulu} from "@lodestar/types"; import {VersionedHashes} from "../../../execution/index.js"; export enum DAType { @@ -107,9 +107,9 @@ export type MissingColumnMeta = { export interface IDataColumnsInput { readonly slot: Slot; readonly blockRootHex: string; - getCustodyColumns(): fulu.DataColumnSidecars; + getCustodyColumns(): DataColumnSidecars; hasComputedAllData(): boolean; - waitForComputedAllData(timeout: number, signal?: AbortSignal): Promise; + waitForComputedAllData(timeout: number, signal?: AbortSignal): Promise; } /** diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index 18f5a13ade69..8334880cd200 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -126,7 +126,12 @@ export async function importExecutionPayload( // State root check is done separately below with better error typing (matching block pipeline pattern). (async () => { try { - return {postPayloadState: processExecutionPayloadEnvelope(blockState, envelope, {verifySignature: false, verifyStateRoot: false})}; + return { + postPayloadState: processExecutionPayloadEnvelope(blockState, envelope, { + verifySignature: false, + verifyStateRoot: false, + }), + }; } catch (e) { throw new PayloadError( { diff --git a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts index 81471b7f295c..096c5304e80a 100644 --- a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts @@ -1,5 +1,5 @@ -import {ColumnIndex, RootHex, Slot, ValidatorIndex, deneb, gloas} from "@lodestar/types"; -import {toRootHex} from "@lodestar/utils"; +import {ColumnIndex, DataColumnSidecars, RootHex, Slot, ValidatorIndex, deneb, gloas} from "@lodestar/types"; +import {toRootHex, withTimeout} from "@lodestar/utils"; import {VersionedHashes} from "../../../execution/index.js"; import {kzgCommitmentToVersionedHash} from "../../../util/blobs.js"; import {AddPayloadEnvelopeProps, ColumnWithSource, CreateFromBlockProps, SourceMeta} from "./types.js"; @@ -68,7 +68,8 @@ export class PayloadEnvelopeInput { private timeCreatedSec: number; - private readonly dataPromise: PromiseParts; + private readonly payloadEnvelopeDataPromise: PromiseParts; + private readonly columnsDataPromise: PromiseParts; state: PayloadEnvelopeInputState; @@ -89,13 +90,19 @@ export class PayloadEnvelopeInput { this.sampledColumns = props.sampledColumns; this.custodyColumns = props.custodyColumns; this.timeCreatedSec = props.timeCreatedSec; - this.dataPromise = createPromise(); + this.payloadEnvelopeDataPromise = createPromise(); + this.columnsDataPromise = createPromise(); const noBlobs = props.bid.blobKzgCommitments.length === 0; const noSampledColumns = props.sampledColumns.length === 0; const hasAllColumns = noBlobs || noSampledColumns; - this.state = hasAllColumns ? {hasPayload: false, hasAllColumns: true} : {hasPayload: false, hasAllColumns: false}; + if (hasAllColumns) { + this.state = {hasPayload: false, hasAllColumns: true}; + this.columnsDataPromise.resolve(this.getSampledColumns()); + } else { + this.state = {hasPayload: false, hasAllColumns: false}; + } } static createFromBlock(props: CreateFromBlockProps): PayloadEnvelopeInput { @@ -150,7 +157,7 @@ export class PayloadEnvelopeInput { payloadEnvelopeSource: source, timeCompleteSec: props.seenTimestampSec, }; - this.dataPromise.resolve(props.envelope); + this.payloadEnvelopeDataPromise.resolve(props.envelope); } else { // Has payload, waiting for columns this.state = { @@ -174,6 +181,9 @@ export class PayloadEnvelopeInput { return; } + // All sampled columns received - resolve columns promise + this.columnsDataPromise.resolve(this.getSampledColumns()); + if (this.state.hasPayload) { // Complete state this.state = { @@ -183,7 +193,7 @@ export class PayloadEnvelopeInput { payloadEnvelopeSource: this.state.payloadEnvelopeSource, timeCompleteSec: seenTimestampSec, }; - this.dataPromise.resolve(this.state.payloadEnvelope); + this.payloadEnvelopeDataPromise.resolve(this.state.payloadEnvelope); } else { // No payload yet, all columns ready this.state = { @@ -232,6 +242,17 @@ export class PayloadEnvelopeInput { .filter((col): col is gloas.DataColumnSidecar => col !== undefined); } + hasComputedAllData(): boolean { + return this.state.hasAllColumns; + } + + waitForComputedAllData(timeout: number, signal?: AbortSignal): Promise { + if (this.state.hasAllColumns) { + return Promise.resolve(this.getSampledColumns()); + } + return withTimeout(() => this.columnsDataPromise.promise, timeout, signal); + } + getTimeCreated(): number { return this.timeCreatedSec; } @@ -260,7 +281,7 @@ export class PayloadEnvelopeInput { } async waitForData(): Promise { - return this.dataPromise.promise; + return this.payloadEnvelopeDataPromise.promise; } getSerializedCacheKeys(): object[] { diff --git a/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts b/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts index 8a5e5dd58920..fd5cdafcb47a 100644 --- a/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts +++ b/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts @@ -1,6 +1,6 @@ -import {fromHex} from "@lodestar/utils"; import {BeaconChain} from "../chain.js"; import {PayloadEnvelopeInput} from "../seenCache/seenPayloadEnvelopeInput.js"; +import {writeDataColumnsToDb} from "./writeBlockInputToDb.js"; /** * Persists payload envelope data to DB. This operation must be eventually completed if a payload is imported. @@ -15,49 +15,14 @@ export async function writePayloadEnvelopeInputToDb( ): Promise { const envelope = payloadInput.getPayloadEnvelope(); const blockRootHex = payloadInput.blockRootHex; - const blockRoot = fromHex(blockRootHex); - - const fnPromises: Promise[] = []; const envelopeBytes = this.serializedCache.get(envelope); - if (envelopeBytes) { - fnPromises.push( - this.db.executionPayloadEnvelope.putBinary(this.db.executionPayloadEnvelope.getId(envelope), envelopeBytes) - ); - } else { - fnPromises.push(this.db.executionPayloadEnvelope.add(envelope)); - } - - // payloadInput.isComplete() must be true in order to reach this function. - // So we should have all kzg commitments here. - const blobsLen = payloadInput.getBlobKzgCommitments().length; - if (blobsLen > 0) { - const {custodyColumns} = this.custodyConfig; - const dataColumnSidecars = payloadInput.getCustodyColumns(); - - const binaryPuts = []; - const nonbinaryPuts = []; - for (const dataColumnSidecar of dataColumnSidecars) { - const serialized = this.serializedCache.get(dataColumnSidecar); - if (serialized) { - binaryPuts.push({key: dataColumnSidecar.index, value: serialized}); - } else { - nonbinaryPuts.push(dataColumnSidecar); - } - } - fnPromises.push(this.db.dataColumnSidecar.putManyBinary(blockRoot, binaryPuts)); - fnPromises.push(this.db.dataColumnSidecar.putMany(blockRoot, nonbinaryPuts)); - - this.logger.debug("Persisting payload dataColumnSidecars to hot DB", { - slot: payloadInput.slot, - root: blockRootHex, - dataColumnSidecars: dataColumnSidecars.length, - numBlobs: blobsLen, - custodyColumns: custodyColumns.length, - }); - } + const envelopePromise = envelopeBytes + ? this.db.executionPayloadEnvelope.putBinary(this.db.executionPayloadEnvelope.getId(envelope), envelopeBytes) + : this.db.executionPayloadEnvelope.add(envelope); - await Promise.all(fnPromises); + // Write envelope and data columns in parallel (reuses shared column writing logic) + await Promise.all([envelopePromise, writeDataColumnsToDb.call(this, payloadInput)]); this.logger.debug("Persisted payload envelope to db", { slot: payloadInput.slot, root: blockRootHex, From c981ebaa54db366b31f7c757d07a9e5d130f421f Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Wed, 11 Mar 2026 23:44:15 -0700 Subject: [PATCH 16/38] Use queue for payload persisting --- .../src/api/impl/beacon/blocks/index.ts | 1 - .../chain/blocks/importExecutionPayload.ts | 30 ++++++++++++++----- packages/beacon-node/src/chain/chain.ts | 21 +++++++++++-- packages/beacon-node/src/chain/interface.ts | 3 -- .../src/metrics/metrics/lodestar.ts | 25 ++++++++++++++++ .../src/network/processor/gossipHandlers.ts | 2 -- 6 files changed, 66 insertions(+), 16 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 12180f9a4e9b..f9f17ccaf2bf 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -731,7 +731,6 @@ export function getBeaconBlockApi({ if (payloadInput.shouldImport()) { // Signature already verified in validateApiExecutionPayloadEnvelope await chain.importExecutionPayload(payloadInput, {validSignature: true}); - chain.persistPayloadEnvelope(payloadInput); chain.emitter.emit(routes.events.EventType.executionPayloadAvailable, { slot, blockRoot: blockRootHex, diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index 8334880cd200..6bb03a056804 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -8,6 +8,7 @@ import { import {processExecutionPayloadEnvelope} from "@lodestar/state-transition/block"; import {byteArrayEquals, fromHex, toRootHex} from "@lodestar/utils"; import {ExecutionPayloadStatus} from "../../execution/index.js"; +import {isQueueErrorAborted} from "../../util/queue/index.js"; import {BeaconChain} from "../chain.js"; import {RegenCaller} from "../regen/interface.js"; import {PayloadEnvelopeInput} from "../seenCache/seenPayloadEnvelopeInput.js"; @@ -86,7 +87,20 @@ export async function importExecutionPayload( }); } - // 2. Get pre-state for processExecutionPayloadEnvelope + // 2. Persist payload envelope to hot DB (performed asynchronously to avoid blocking) + // Wait for space in the write queue to apply backpressure during sync. + await this.unfinalizedPayloadEnvelopeWrites.waitForSpace(); + this.unfinalizedPayloadEnvelopeWrites.push(payloadInput).catch((e) => { + if (!isQueueErrorAborted(e)) { + this.logger.error( + "Error pushing payload envelope to unfinalized write queue", + {slot: payloadInput.slot, root: blockRootHex}, + e as Error + ); + } + }); + + // 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, @@ -95,7 +109,7 @@ export async function importExecutionPayload( RegenCaller.processBlock )) as CachedBeaconStateGloas; - // 3. Run verification steps in parallel + // 4. Run verification steps in parallel // Note: No data availability check needed here - importExecutionPayload is only // called when payloadInput.isComplete() is true, so all data is already available. const [execResult, signatureValid, postPayloadResult] = await Promise.all([ @@ -144,12 +158,12 @@ export async function importExecutionPayload( })(), ]); - // 3b. Check signature verification result + // 4b. Check signature verification result if (!signatureValid) { throw new PayloadError({code: PayloadErrorCode.INVALID_SIGNATURE}); } - // 4. Handle EL response + // 5. Handle EL response if (execResult.status === ExecutionPayloadStatus.INVALID) { throw new PayloadError({ code: PayloadErrorCode.EXECUTION_ENGINE_INVALID, @@ -170,7 +184,7 @@ export async function importExecutionPayload( }); } - // 4b. Verify envelope state root matches post-state + // 5b. Verify envelope state root matches post-state const postPayloadState = postPayloadResult.postPayloadState; const postPayloadStateRoot = postPayloadState.hashTreeRoot(); if (!byteArrayEquals(envelope.message.stateRoot, postPayloadStateRoot)) { @@ -183,7 +197,7 @@ export async function importExecutionPayload( ); } - // 5. Update fork choice + // 6. Update fork choice this.forkChoice.onExecutionPayload( blockRootHex, payloadInput.getBlockHashHex(), @@ -191,13 +205,13 @@ export async function importExecutionPayload( toRootHex(postPayloadStateRoot) ); - // 6. Cache payload state + // 7. Cache payload state // TODO GLOAS: Enable when PR #8868 merged (adds processPayloadState) // this.regen.processPayloadState(postPayloadState); // if epoch boundary also call // this.regen.addCheckpointState(cp, checkpointState, true); - // 7. Record metrics for payload envelope and column sources + // 8. 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}); diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 951ecd98f3d2..ff22ab53a495 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -116,6 +116,7 @@ import { SeenContributionAndProof, SeenExecutionPayloadBids, SeenPayloadAttesters, + PayloadEnvelopeInput, SeenPayloadEnvelopeInput, SeenSyncCommitteeMessages, } from "./seenCache/index.js"; @@ -149,6 +150,13 @@ const DEFAULT_MAX_CACHED_PRODUCED_RESULTS = 4; */ const DEFAULT_MAX_PENDING_UNFINALIZED_BLOCK_WRITES = 16; +/** + * The maximum number of pending unfinalized payload envelope writes to the database before backpressure is applied. + * Similar to block writes, payload envelope write queue entries hold references to payload inputs + * (including columns) keeping them in memory. Keep moderate to avoid OOM during sync. + */ +const DEFAULT_MAX_PENDING_UNFINALIZED_PAYLOAD_ENVELOPE_WRITES = 16; + export class BeaconChain implements IBeaconChain { readonly genesisTime: UintNum64; readonly genesisValidatorsRoot: Root; @@ -173,6 +181,7 @@ export class BeaconChain implements IBeaconChain { readonly reprocessController: ReprocessController; readonly archiveStore: ArchiveStore; readonly unfinalizedBlockWrites: JobItemQueue<[IBlockInput], void>; + readonly unfinalizedPayloadEnvelopeWrites: JobItemQueue<[PayloadEnvelopeInput], void>; // Ops pool readonly attestationPool: AttestationPool; @@ -452,6 +461,15 @@ export class BeaconChain implements IBeaconChain { metrics?.unfinalizedBlockWritesQueue ); + this.unfinalizedPayloadEnvelopeWrites = new JobItemQueue( + persistPayloadEnvelopeInput.bind(this), + { + maxLength: DEFAULT_MAX_PENDING_UNFINALIZED_PAYLOAD_ENVELOPE_WRITES, + signal, + }, + metrics?.unfinalizedPayloadEnvelopeWritesQueue + ); + // always run PrepareNextSlotScheduler except for fork_choice spec tests if (!opts?.disablePrepareNextSlot) { new PrepareNextSlotScheduler(this, this.config, metrics, this.logger, signal); @@ -482,6 +500,7 @@ export class BeaconChain implements IBeaconChain { // we can abort any ongoing unfinalized block writes. // TODO: persist fork choice to disk and allow unfinalized block writes to complete. this.unfinalizedBlockWrites.dropAllJobs(); + this.unfinalizedPayloadEnvelopeWrites.dropAllJobs(); this.abortController.abort(); } @@ -1013,8 +1032,6 @@ export class BeaconChain implements IBeaconChain { importExecutionPayload = importExecutionPayload.bind(this); - persistPayloadEnvelope = persistPayloadEnvelopeInput.bind(this); - getStatus(): Status { const head = this.forkChoice.getHead(); const finalizedCheckpoint = this.forkChoice.getFinalizedCheckpoint(); diff --git a/packages/beacon-node/src/chain/interface.ts b/packages/beacon-node/src/chain/interface.ts index 1131672f93ce..994279a49e78 100644 --- a/packages/beacon-node/src/chain/interface.ts +++ b/packages/beacon-node/src/chain/interface.ts @@ -246,9 +246,6 @@ export interface IBeaconChain { /** Import execution payload envelope to EL and fork choice after data is available */ importExecutionPayload(payloadInput: PayloadEnvelopeInput, opts?: ImportPayloadOpts): Promise; - /** Persist payload envelope to DB and prune from seen cache */ - persistPayloadEnvelope(payloadInput: PayloadEnvelopeInput): Promise; - getStatus(): Status; recomputeForkChoiceHead(caller: ForkchoiceCaller): ProtoBlock; diff --git a/packages/beacon-node/src/metrics/metrics/lodestar.ts b/packages/beacon-node/src/metrics/metrics/lodestar.ts index cf6341d854a8..11c2cba07ebc 100644 --- a/packages/beacon-node/src/metrics/metrics/lodestar.ts +++ b/packages/beacon-node/src/metrics/metrics/lodestar.ts @@ -238,6 +238,31 @@ export function createLodestarMetrics( }), }, + unfinalizedPayloadEnvelopeWritesQueue: { + length: register.gauge({ + name: "lodestar_unfinalized_payload_envelope_writes_queue_length", + help: "Count of total unfinalized payload envelope writes queue length", + }), + droppedJobs: register.gauge({ + name: "lodestar_unfinalized_payload_envelope_writes_queue_dropped_jobs_total", + help: "Count of total unfinalized payload envelope writes queue dropped jobs", + }), + jobTime: register.histogram({ + name: "lodestar_unfinalized_payload_envelope_writes_queue_job_time_seconds", + help: "Time to process unfinalized payload envelope writes queue job in seconds", + buckets: [0.01, 0.1, 1, 4, 12], + }), + jobWaitTime: register.histogram({ + name: "lodestar_unfinalized_payload_envelope_writes_queue_job_wait_time_seconds", + help: "Time from job added to the unfinalized payload envelope writes queue to starting in seconds", + buckets: [0.01, 0.1, 1, 4, 12], + }), + concurrency: register.gauge({ + name: "lodestar_unfinalized_payload_envelope_writes_queue_concurrency", + help: "Current concurrency of unfinalized payload envelope writes queue", + }), + }, + engineHttpProcessorQueue: { length: register.gauge({ name: "lodestar_engine_http_processor_queue_length", diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index 642fb69330d7..109c9f8a3bb4 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -625,7 +625,6 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand // if (payloadInput.shouldImport()) { // // Signature already verified during gossip validation // await chain.importExecutionPayload(payloadInput, {validSignature: true}); - // chain.persistPayloadEnvelope(payloadInput); // } // } }, @@ -869,7 +868,6 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand if (payloadInput.shouldImport()) { await chain.importExecutionPayload(payloadInput, {validSignature: true}); - chain.persistPayloadEnvelope(payloadInput); chain.emitter.emit(routes.events.EventType.executionPayloadAvailable, { slot, blockRoot: blockRootHex, From 41048b98c9f5d0159c286292823479c55496e244 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Thu, 12 Mar 2026 00:53:17 -0700 Subject: [PATCH 17/38] Add PayloadEnvelopeProcessor --- .../src/api/impl/beacon/blocks/index.ts | 7 ++-- .../chain/blocks/importExecutionPayload.ts | 19 +++++++---- .../chain/blocks/payloadEnvelopeProcessor.ts | 33 +++++++++++++++++++ packages/beacon-node/src/chain/chain.ts | 9 +++-- packages/beacon-node/src/chain/interface.ts | 5 ++- .../src/metrics/metrics/lodestar.ts | 25 ++++++++++++++ .../src/network/processor/gossipHandlers.ts | 10 ++---- 7 files changed, 85 insertions(+), 23 deletions(-) create mode 100644 packages/beacon-node/src/chain/blocks/payloadEnvelopeProcessor.ts 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 f9f17ccaf2bf..9e756c3bb700 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -730,11 +730,8 @@ export function getBeaconBlockApi({ // want to gossip first. Need spec clarification on whether import failure should prevent broadcast. if (payloadInput.shouldImport()) { // Signature already verified in validateApiExecutionPayloadEnvelope - await chain.importExecutionPayload(payloadInput, {validSignature: true}); - chain.emitter.emit(routes.events.EventType.executionPayloadAvailable, { - slot, - blockRoot: blockRootHex, - }); + // TODO GLOAS: Emit execution_payload_gossip event for gossip receipt. + chain.processExecutionPayload(payloadInput, {validSignature: true}); } const valLogMeta = { diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index 6bb03a056804..9158d2f0e277 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -1,3 +1,4 @@ +import {routes} from "@lodestar/api"; import {PublicKey} from "@chainsafe/blst"; import {BUILDER_INDEX_SELF_BUILD, ForkName} from "@lodestar/params"; import { @@ -14,6 +15,8 @@ import {RegenCaller} from "../regen/interface.js"; import {PayloadEnvelopeInput} from "../seenCache/seenPayloadEnvelopeInput.js"; import {ImportPayloadOpts} from "./types.js"; +const EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS = 64; + export enum PayloadErrorCode { EXECUTION_ENGINE_INVALID = "PAYLOAD_ERROR_EXECUTION_ENGINE_INVALID", EXECUTION_ENGINE_ERROR = "PAYLOAD_ERROR_EXECUTION_ENGINE_ERROR", @@ -54,10 +57,6 @@ export class PayloadError extends Error { } } -export type ImportPayloadResult = { - success: boolean; -}; - /** * Import an execution payload envelope after all data is available. * @@ -74,7 +73,7 @@ export async function importExecutionPayload( this: BeaconChain, payloadInput: PayloadEnvelopeInput, opts: ImportPayloadOpts = {} -): Promise { +): Promise { const envelope = payloadInput.getPayloadEnvelope(); const blockRootHex = payloadInput.blockRootHex; @@ -223,5 +222,13 @@ export async function importExecutionPayload( blockHash: payloadInput.getBlockHashHex(), }); - return {success: true}; + // 9. Emit event after payload is fully verified and imported to fork choice, only for recent enough payloads + const currentSlot = this.clock.currentSlot; + if (currentSlot - payloadInput.slot < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { + this.emitter.emit(routes.events.EventType.executionPayloadAvailable, { + slot: payloadInput.slot, + blockRoot: blockRootHex, + }); + } + } diff --git a/packages/beacon-node/src/chain/blocks/payloadEnvelopeProcessor.ts b/packages/beacon-node/src/chain/blocks/payloadEnvelopeProcessor.ts new file mode 100644 index 000000000000..5a83652843e6 --- /dev/null +++ b/packages/beacon-node/src/chain/blocks/payloadEnvelopeProcessor.ts @@ -0,0 +1,33 @@ +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 {ImportPayloadOpts} from "./types.js"; + +// Set to be equal to DEFAULT_MAX_PENDING_UNFINALIZED_PAYLOAD_ENVELOPE_WRITES for now +const QUEUE_MAX_LENGTH = 16; + +/** + * PayloadEnvelopeProcessor processes payload envelope jobs in a queued fashion, one after the other. + */ +export class PayloadEnvelopeProcessor { + readonly jobQueue: JobItemQueue<[PayloadEnvelopeInput, ImportPayloadOpts], void>; + + constructor(chain: BeaconChain, metrics: Metrics | null, signal: AbortSignal) { + this.jobQueue = new JobItemQueue<[PayloadEnvelopeInput, ImportPayloadOpts], void>( + (payloadInput, opts) => { + return importExecutionPayload.call(chain, payloadInput, opts); + }, + {maxLength: QUEUE_MAX_LENGTH, noYieldIfOneItem: true, signal}, + metrics?.payloadEnvelopeProcessorQueue ?? undefined + ); + } + + async processPayloadEnvelopeJob( + payloadInput: PayloadEnvelopeInput, + opts: ImportPayloadOpts = {} + ): Promise { + await this.jobQueue.push(payloadInput, opts); + } +} diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index ff22ab53a495..d7cdb87a0b02 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -82,8 +82,9 @@ import {ArchiveStore} from "./archiveStore/archiveStore.js"; import {CheckpointBalancesCache} from "./balancesCache.js"; import {BeaconProposerCache} from "./beaconProposerCache.js"; import {IBlockInput, isBlockInputBlobs, isBlockInputColumns} from "./blocks/blockInput/index.js"; -import {importExecutionPayload} from "./blocks/importExecutionPayload.js"; import {BlockProcessor, ImportBlockOpts} from "./blocks/index.js"; +import {PayloadEnvelopeProcessor} from "./blocks/payloadEnvelopeProcessor.js"; +import {ImportPayloadOpts} from "./blocks/types.js"; import {persistBlockInput} from "./blocks/writeBlockInputToDb.ts"; import {persistPayloadEnvelopeInput} from "./blocks/writePayloadEnvelopeInputToDb.js"; import {BlsMultiThreadWorkerPool, BlsSingleThreadVerifier, IBlsVerifier} from "./bls/index.js"; @@ -231,6 +232,7 @@ export class BeaconChain implements IBeaconChain { readonly opts: IChainOptions; protected readonly blockProcessor: BlockProcessor; + protected readonly payloadEnvelopeProcessor: PayloadEnvelopeProcessor; protected readonly db: IBeaconDb; // this is only available if nHistoricalStates is enabled private readonly cpStateDatastore?: CPStateDatastore; @@ -425,6 +427,7 @@ export class BeaconChain implements IBeaconChain { this.reprocessController = new ReprocessController(this.metrics); this.blockProcessor = new BlockProcessor(this, metrics, opts, signal); + this.payloadEnvelopeProcessor = new PayloadEnvelopeProcessor(this, metrics, signal); this.forkChoice = forkChoice; this.clock = clock; @@ -1030,7 +1033,9 @@ export class BeaconChain implements IBeaconChain { return this.blockProcessor.processBlocksJob(blocks, opts); } - importExecutionPayload = importExecutionPayload.bind(this); + async processExecutionPayload(payloadInput: PayloadEnvelopeInput, opts?: ImportPayloadOpts): Promise { + return this.payloadEnvelopeProcessor.processPayloadEnvelopeJob(payloadInput, opts); + } getStatus(): Status { const head = this.forkChoice.getHead(); diff --git a/packages/beacon-node/src/chain/interface.ts b/packages/beacon-node/src/chain/interface.ts index 994279a49e78..a2ae682e0f8e 100644 --- a/packages/beacon-node/src/chain/interface.ts +++ b/packages/beacon-node/src/chain/interface.ts @@ -32,7 +32,6 @@ import {IArchiveStore} from "./archiveStore/interface.js"; import {CheckpointBalancesCache} from "./balancesCache.js"; import {BeaconProposerCache, ProposerPreparationData} from "./beaconProposerCache.js"; import {IBlockInput} from "./blocks/blockInput/index.js"; -import {ImportPayloadResult} from "./blocks/importExecutionPayload.js"; import {ImportBlockOpts, ImportPayloadOpts} from "./blocks/types.js"; import {IBlsVerifier} from "./bls/index.js"; import {ColumnReconstructionTracker} from "./ColumnReconstructionTracker.js"; @@ -243,8 +242,8 @@ export interface IBeaconChain { /** Process a chain of blocks until complete */ processChainSegment(blocks: IBlockInput[], opts?: ImportBlockOpts): Promise; - /** Import execution payload envelope to EL and fork choice after data is available */ - importExecutionPayload(payloadInput: PayloadEnvelopeInput, opts?: ImportPayloadOpts): Promise; + /** Process execution payload envelope: verify, import to fork choice, and persist to DB */ + processExecutionPayload(payloadInput: PayloadEnvelopeInput, opts?: ImportPayloadOpts): Promise; getStatus(): Status; diff --git a/packages/beacon-node/src/metrics/metrics/lodestar.ts b/packages/beacon-node/src/metrics/metrics/lodestar.ts index 11c2cba07ebc..37b4f1c59798 100644 --- a/packages/beacon-node/src/metrics/metrics/lodestar.ts +++ b/packages/beacon-node/src/metrics/metrics/lodestar.ts @@ -238,6 +238,31 @@ export function createLodestarMetrics( }), }, + payloadEnvelopeProcessorQueue: { + length: register.gauge({ + name: "lodestar_payload_envelope_processor_queue_length", + help: "Count of total payload envelope processor queue length", + }), + droppedJobs: register.gauge({ + name: "lodestar_payload_envelope_processor_queue_dropped_jobs_total", + help: "Count of total payload envelope processor queue dropped jobs", + }), + jobTime: register.histogram({ + name: "lodestar_payload_envelope_processor_queue_job_time_seconds", + help: "Time to process payload envelope processor queue job in seconds", + buckets: [0.01, 0.1, 1, 4, 12], + }), + jobWaitTime: register.histogram({ + name: "lodestar_payload_envelope_processor_queue_job_wait_time_seconds", + help: "Time from job added to the payload envelope processor queue to starting in seconds", + buckets: [0.01, 0.1, 1, 4, 12], + }), + concurrency: register.gauge({ + name: "lodestar_payload_envelope_processor_queue_concurrency", + help: "Current concurrency of payload envelope processor queue", + }), + }, + unfinalizedPayloadEnvelopeWritesQueue: { length: register.gauge({ name: "lodestar_unfinalized_payload_envelope_writes_queue_length", diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index 109c9f8a3bb4..8b74e6248199 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -623,8 +623,7 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand // if (payloadInput) { // payloadInput.addColumn({columnSidecar, source: BlockInputSource.gossip, seenTimestampSec, peerIdStr}); // if (payloadInput.shouldImport()) { - // // Signature already verified during gossip validation - // await chain.importExecutionPayload(payloadInput, {validSignature: true}); + // chain.processExecutionPayload(payloadInput, {validSignature: true}); // } // } }, @@ -867,11 +866,8 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand }); if (payloadInput.shouldImport()) { - await chain.importExecutionPayload(payloadInput, {validSignature: true}); - chain.emitter.emit(routes.events.EventType.executionPayloadAvailable, { - slot, - blockRoot: blockRootHex, - }); + // TODO GLOAS: Emit execution_payload_gossip event for gossip receipt. + chain.processExecutionPayload(payloadInput, {validSignature: true}); } }, [GossipType.payload_attestation_message]: async ({ From 109383117dae00c31f76a9d35ddd295e3a624f38 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Thu, 12 Mar 2026 01:09:19 -0700 Subject: [PATCH 18/38] lint Co-Authored-By: Claude Opus 4.6 --- .../beacon-node/src/chain/blocks/importExecutionPayload.ts | 3 +-- .../beacon-node/src/chain/blocks/payloadEnvelopeProcessor.ts | 5 +---- packages/beacon-node/src/chain/chain.ts | 2 +- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index 9158d2f0e277..6532f90327c7 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -1,5 +1,5 @@ -import {routes} from "@lodestar/api"; import {PublicKey} from "@chainsafe/blst"; +import {routes} from "@lodestar/api"; import {BUILDER_INDEX_SELF_BUILD, ForkName} from "@lodestar/params"; import { CachedBeaconStateGloas, @@ -230,5 +230,4 @@ export async function importExecutionPayload( blockRoot: blockRootHex, }); } - } diff --git a/packages/beacon-node/src/chain/blocks/payloadEnvelopeProcessor.ts b/packages/beacon-node/src/chain/blocks/payloadEnvelopeProcessor.ts index 5a83652843e6..29885c253df2 100644 --- a/packages/beacon-node/src/chain/blocks/payloadEnvelopeProcessor.ts +++ b/packages/beacon-node/src/chain/blocks/payloadEnvelopeProcessor.ts @@ -24,10 +24,7 @@ export class PayloadEnvelopeProcessor { ); } - async processPayloadEnvelopeJob( - payloadInput: PayloadEnvelopeInput, - opts: ImportPayloadOpts = {} - ): Promise { + async processPayloadEnvelopeJob(payloadInput: PayloadEnvelopeInput, opts: ImportPayloadOpts = {}): Promise { await this.jobQueue.push(payloadInput, opts); } } diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index d7cdb87a0b02..0456ac7b7f2b 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -111,13 +111,13 @@ import {BlockAttributes, produceBlockBody, produceCommonBlockBody} from "./produ import {QueuedStateRegenerator, RegenCaller} from "./regen/index.js"; import {ReprocessController} from "./reprocess.js"; import { + PayloadEnvelopeInput, SeenAggregators, SeenAttesters, SeenBlockProposers, SeenContributionAndProof, SeenExecutionPayloadBids, SeenPayloadAttesters, - PayloadEnvelopeInput, SeenPayloadEnvelopeInput, SeenSyncCommitteeMessages, } from "./seenCache/index.js"; From 56729bf8cd262e98ade1a65e44d77e5981804317 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:47:43 -0700 Subject: [PATCH 19/38] hasAllData and hasComputedAllData --- .../payloadEnvelopeInput.ts | 73 ++++++++++++------- 1 file changed, 47 insertions(+), 26 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts index 096c5304e80a..82b05f78c29d 100644 --- a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts @@ -1,3 +1,4 @@ +import {NUMBER_OF_COLUMNS} from "@lodestar/params"; import {ColumnIndex, DataColumnSidecars, RootHex, Slot, ValidatorIndex, deneb, gloas} from "@lodestar/types"; import {toRootHex, withTimeout} from "@lodestar/utils"; import {VersionedHashes} from "../../../execution/index.js"; @@ -7,21 +8,25 @@ import {AddPayloadEnvelopeProps, ColumnWithSource, CreateFromBlockProps, SourceM export type PayloadEnvelopeInputState = | { hasPayload: false; - hasAllColumns: false; + hasAllData: false; + hasComputedAllData: false; } | { hasPayload: false; - hasAllColumns: true; + hasAllData: true; + hasComputedAllData: boolean; } | { hasPayload: true; - hasAllColumns: false; + hasAllData: false; + hasComputedAllData: false; payloadEnvelope: gloas.SignedExecutionPayloadEnvelope; payloadEnvelopeSource: SourceMeta; } | { hasPayload: true; - hasAllColumns: true; + hasAllData: true; + hasComputedAllData: boolean; payloadEnvelope: gloas.SignedExecutionPayloadEnvelope; payloadEnvelopeSource: SourceMeta; timeCompleteSec: number; @@ -95,13 +100,13 @@ export class PayloadEnvelopeInput { const noBlobs = props.bid.blobKzgCommitments.length === 0; const noSampledColumns = props.sampledColumns.length === 0; - const hasAllColumns = noBlobs || noSampledColumns; + const hasAllData = noBlobs || noSampledColumns; - if (hasAllColumns) { - this.state = {hasPayload: false, hasAllColumns: true}; + if (hasAllData) { + this.state = {hasPayload: false, hasAllData: true, hasComputedAllData: true}; this.columnsDataPromise.resolve(this.getSampledColumns()); } else { - this.state = {hasPayload: false, hasAllColumns: false}; + this.state = {hasPayload: false, hasAllData: false, hasComputedAllData: false}; } } @@ -148,11 +153,12 @@ export class PayloadEnvelopeInput { peerIdStr: props.peerIdStr, }; - if (this.state.hasAllColumns) { + if (this.state.hasAllData) { // Complete state this.state = { hasPayload: true, - hasAllColumns: true, + hasAllData: true, + hasComputedAllData: this.state.hasComputedAllData, payloadEnvelope: props.envelope, payloadEnvelopeSource: source, timeCompleteSec: props.seenTimestampSec, @@ -162,7 +168,8 @@ export class PayloadEnvelopeInput { // Has payload, waiting for columns this.state = { hasPayload: true, - hasAllColumns: false, + hasAllData: false, + hasComputedAllData: false, payloadEnvelope: props.envelope, payloadEnvelopeSource: source, }; @@ -173,32 +180,44 @@ export class PayloadEnvelopeInput { const {columnSidecar, seenTimestampSec} = columnWithSource; this.columnsCache.set(columnSidecar.index, columnWithSource); - const hasAllSampledColumns = this.sampledColumns.every((idx) => this.columnsCache.has(idx)); - const noBlobs = this.bid.blobKzgCommitments.length === 0; - const hasAllColumns = hasAllSampledColumns || noBlobs || this.sampledColumns.length === 0; + const sampledColumns = this.getSampledColumns(); + const hasAllData = + // already hasAllData + this.state.hasAllData || + // has all sampled columns + sampledColumns.length === this.sampledColumns.length || + // has enough columns to reconstruct the rest + this.columnsCache.size >= NUMBER_OF_COLUMNS / 2; - if (!hasAllColumns) { + const hasComputedAllData = + // has all sampled columns + sampledColumns.length === this.sampledColumns.length; + + if (!hasAllData) { return; } - // All sampled columns received - resolve columns promise - this.columnsDataPromise.resolve(this.getSampledColumns()); + if (hasComputedAllData) { + this.columnsDataPromise.resolve(sampledColumns); + } if (this.state.hasPayload) { // Complete state this.state = { hasPayload: true, - hasAllColumns: true, + hasAllData: true, + hasComputedAllData: hasComputedAllData || this.state.hasComputedAllData, payloadEnvelope: this.state.payloadEnvelope, payloadEnvelopeSource: this.state.payloadEnvelopeSource, timeCompleteSec: seenTimestampSec, }; this.payloadEnvelopeDataPromise.resolve(this.state.payloadEnvelope); } else { - // No payload yet, all columns ready + // No payload yet, all data ready this.state = { hasPayload: false, - hasAllColumns: true, + hasAllData: true, + hasComputedAllData: hasComputedAllData || this.state.hasComputedAllData, }; } } @@ -243,11 +262,11 @@ export class PayloadEnvelopeInput { } hasComputedAllData(): boolean { - return this.state.hasAllColumns; + return this.state.hasComputedAllData; } waitForComputedAllData(timeout: number, signal?: AbortSignal): Promise { - if (this.state.hasAllColumns) { + if (this.state.hasComputedAllData) { return Promise.resolve(this.getSampledColumns()); } return withTimeout(() => this.columnsDataPromise.promise, timeout, signal); @@ -258,12 +277,12 @@ export class PayloadEnvelopeInput { } getTimeComplete(): number { - if (!this.state.hasPayload || !this.state.hasAllColumns) throw new Error("Not yet complete"); + if (!this.state.hasPayload || !this.state.hasAllData) throw new Error("Not yet complete"); return this.state.timeCompleteSec; } isComplete(): boolean { - return this.state.hasPayload && this.state.hasAllColumns; + return this.state.hasPayload && this.state.hasAllData; } /** @@ -302,7 +321,8 @@ export class PayloadEnvelopeInput { slot: number; blockRoot: string; hasPayload: boolean; - hasAllColumns: boolean; + hasAllData: boolean; + hasComputedAllData: boolean; isComplete: boolean; columnsCount: number; sampledColumnsCount: number; @@ -311,7 +331,8 @@ export class PayloadEnvelopeInput { slot: this.slot, blockRoot: this.blockRootHex, hasPayload: this.state.hasPayload, - hasAllColumns: this.state.hasAllColumns, + hasAllData: this.state.hasAllData, + hasComputedAllData: this.state.hasComputedAllData, isComplete: this.isComplete(), columnsCount: this.columnsCache.size, sampledColumnsCount: this.sampledColumns.length, From 6f8ae6216446e44037ae55631f9e0076703204ea Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:07:35 -0700 Subject: [PATCH 20/38] address comments - Move validateApiExecutionPayloadEnvelope earlier, before data column construction - Run gossip and import in parallel via publishPromises, mirroring publishBlock flow Co-Authored-By: Claude Opus 4.6 --- .../src/api/impl/beacon/blocks/index.ts | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 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 9e756c3bb700..ba3576b54b77 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -661,6 +661,8 @@ export function getBeaconBlockApi({ throw new ApiError(400, `Envelope slot ${slot} does not match block slot ${block.slot}`); } + await validateApiExecutionPayloadEnvelope(chain, signedExecutionPayloadEnvelope); + const isSelfBuild = envelope.builderIndex === BUILDER_INDEX_SELF_BUILD; let dataColumnSidecars: gloas.DataColumnSidecars = []; @@ -694,8 +696,6 @@ export function getBeaconBlockApi({ // TODO GLOAS: will this api be used by builders or only for self-building? } - await validateApiExecutionPayloadEnvelope(chain, signedExecutionPayloadEnvelope); - // If called near a slot boundary (e.g. late in slot N-1), hold briefly so gossip aligns with slot N. const msToBlockSlot = computeTimeAtSlot(config, slot, chain.genesisTime) * 1000 - Date.now(); if (msToBlockSlot <= MAX_API_CLOCK_DISPARITY_MS && msToBlockSlot > 0) { @@ -725,15 +725,6 @@ export function getBeaconBlockApi({ } } - // TODO GLOAS: Unlike publishBlock which gossips and imports in parallel, we import before gossip here. - // The publishExecutionPayloadEnvelope spec says success = "gossip validation + broadcast", so we may - // want to gossip first. Need spec clarification on whether import failure should prevent broadcast. - if (payloadInput.shouldImport()) { - // Signature already verified in validateApiExecutionPayloadEnvelope - // TODO GLOAS: Emit execution_payload_gossip event for gossip receipt. - chain.processExecutionPayload(payloadInput, {validSignature: true}); - } - const valLogMeta = { slot, blockRoot: blockRootHex, @@ -748,12 +739,18 @@ export function getBeaconBlockApi({ chain.logger.info("Publishing execution payload envelope", valLogMeta); - // Publish envelope and data columns const publishPromises = [ // Gossip the signed execution payload envelope first () => network.publishSignedExecutionPayloadEnvelope(signedExecutionPayloadEnvelope), // For self-builds, publish all data column sidecars ...dataColumnSidecars.map((dataColumnSidecar) => () => network.publishDataColumnSidecar(dataColumnSidecar)), + // Import execution payload. Signature already verified above + () => { + if (payloadInput.shouldImport()) { + chain.processExecutionPayload(payloadInput, {validSignature: true}); + } + return Promise.resolve(); + }, ]; const sentPeersArr = await promiseAllMaybeAsync(publishPromises); From 8172f1dda4cc0c6688223c4fdce81901cc30ad1d Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Fri, 13 Mar 2026 00:18:43 -0700 Subject: [PATCH 21/38] Apply suggestions from code review Co-authored-by: twoeths <10568965+twoeths@users.noreply.github.com> --- packages/beacon-node/src/chain/blocks/importExecutionPayload.ts | 2 +- .../beacon-node/src/chain/produceBlock/computeNewStateRoot.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index 6532f90327c7..a3329ebe48a1 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -218,7 +218,7 @@ export async function importExecutionPayload( this.logger.verbose("Execution payload imported", { slot: payloadInput.slot, - beaconBlockRoot: blockRootHex, + root: blockRootHex, blockHash: payloadInput.getBlockHashHex(), }); diff --git a/packages/beacon-node/src/chain/produceBlock/computeNewStateRoot.ts b/packages/beacon-node/src/chain/produceBlock/computeNewStateRoot.ts index 0b7e6510083f..3acec79ab8ac 100644 --- a/packages/beacon-node/src/chain/produceBlock/computeNewStateRoot.ts +++ b/packages/beacon-node/src/chain/produceBlock/computeNewStateRoot.ts @@ -75,7 +75,7 @@ export function computeEnvelopeStateRoot( const processEnvelopeTimer = metrics?.blockPayload.executionPayloadEnvelopeProcessingTime.startTimer(); const postEnvelopeState = processExecutionPayloadEnvelope(postBlockState, signedEnvelope, { verifySignature: false, - verifyStateRoot: true, + verifyStateRoot: false, dontTransferCache: true, }); processEnvelopeTimer?.(); From 69dc238d68c6a864c0156ed8376a77eae1802ee7 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Fri, 13 Mar 2026 00:43:23 -0700 Subject: [PATCH 22/38] Address comments --- .../chain/blocks/importExecutionPayload.ts | 22 +++----- .../chain/produceBlock/computeNewStateRoot.ts | 3 ++ .../validation/executionPayloadEnvelope.ts | 21 +++----- .../block/processExecutionPayloadEnvelope.ts | 54 +++++-------------- .../signatureSets/executionPayloadEnvelope.ts | 26 ++++++++- 5 files changed, 55 insertions(+), 71 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index a3329ebe48a1..44b4ce56254d 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -1,11 +1,6 @@ -import {PublicKey} from "@chainsafe/blst"; import {routes} from "@lodestar/api"; -import {BUILDER_INDEX_SELF_BUILD, ForkName} from "@lodestar/params"; -import { - CachedBeaconStateGloas, - createSingleSignatureSetFromComponents, - getExecutionPayloadEnvelopeSigningRoot, -} from "@lodestar/state-transition"; +import {ForkName} from "@lodestar/params"; +import {CachedBeaconStateGloas, getExecutionPayloadEnvelopeSignatureSet} from "@lodestar/state-transition"; import {processExecutionPayloadEnvelope} from "@lodestar/state-transition/block"; import {byteArrayEquals, fromHex, toRootHex} from "@lodestar/utils"; import {ExecutionPayloadStatus} from "../../execution/index.js"; @@ -123,14 +118,11 @@ export async function importExecutionPayload( opts.validSignature === true ? Promise.resolve(true) : (async () => { - const builderPubkey = - envelope.message.builderIndex === BUILDER_INDEX_SELF_BUILD - ? blockState.epochCtx.pubkeyCache.getOrThrow(payloadInput.proposerIndex) - : PublicKey.fromBytes(blockState.builders.getReadonly(envelope.message.builderIndex).pubkey); - const signatureSet = createSingleSignatureSetFromComponents( - builderPubkey, - getExecutionPayloadEnvelopeSigningRoot(this.config, envelope.message), - envelope.signature + const signatureSet = getExecutionPayloadEnvelopeSignatureSet( + this.config, + blockState, + envelope, + payloadInput.proposerIndex ); return this.bls.verifySignatureSets([signatureSet]); })(), diff --git a/packages/beacon-node/src/chain/produceBlock/computeNewStateRoot.ts b/packages/beacon-node/src/chain/produceBlock/computeNewStateRoot.ts index 3acec79ab8ac..6e9d847681f0 100644 --- a/packages/beacon-node/src/chain/produceBlock/computeNewStateRoot.ts +++ b/packages/beacon-node/src/chain/produceBlock/computeNewStateRoot.ts @@ -74,8 +74,11 @@ export function computeEnvelopeStateRoot( const processEnvelopeTimer = metrics?.blockPayload.executionPayloadEnvelopeProcessingTime.startTimer(); const postEnvelopeState = processExecutionPayloadEnvelope(postBlockState, signedEnvelope, { + // verifySignature: false | signature is zero-ed (G2_POINT_AT_INFINITY), skip verification verifySignature: false, + // verifyStateRoot: false | state root is being computed here, the envelope doesn't have it yet verifyStateRoot: false, + // Preserve cache in source state, since the resulting state is not added to the state cache dontTransferCache: true, }); processEnvelopeTimer?.(); diff --git a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts index af7edab62b0d..0188651ca075 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts @@ -1,10 +1,7 @@ -import {PublicKey} from "@chainsafe/blst"; -import {BUILDER_INDEX_SELF_BUILD} from "@lodestar/params"; import { CachedBeaconStateGloas, computeStartSlotAtEpoch, - createSingleSignatureSetFromComponents, - getExecutionPayloadEnvelopeSigningRoot, + getExecutionPayloadEnvelopeSignatureSet, } from "@lodestar/state-transition"; import {gloas} from "@lodestar/types"; import {toRootHex} from "@lodestar/utils"; @@ -142,18 +139,14 @@ async function validateExecutionPayloadEnvelope( }); const state = blockState as CachedBeaconStateGloas; - const builderPubkey = - envelope.builderIndex === BUILDER_INDEX_SELF_BUILD - ? state.epochCtx.pubkeyCache.getOrThrow(payloadInput.proposerIndex) - : PublicKey.fromBytes(state.builders.getReadonly(envelope.builderIndex).pubkey); - - const signatureSet = createSingleSignatureSetFromComponents( - builderPubkey, - getExecutionPayloadEnvelopeSigningRoot(chain.config, envelope), - executionPayloadEnvelope.signature + const signatureSet = getExecutionPayloadEnvelopeSignatureSet( + chain.config, + state, + executionPayloadEnvelope, + payloadInput.proposerIndex ); - if (!(await chain.bls.verifySignatureSets([signatureSet]))) { + if (!(await chain.bls.verifySignatureSets([signatureSet], {verifyOnMainThread: true}))) { throw new ExecutionPayloadEnvelopeError(GossipAction.REJECT, { code: ExecutionPayloadEnvelopeErrorCode.INVALID_SIGNATURE, }); diff --git a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts index 88d6fef629bb..4a199c504fc3 100644 --- a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts +++ b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts @@ -1,14 +1,10 @@ -import {PublicKey, Signature, verify} from "@chainsafe/blst"; -import { - BUILDER_INDEX_SELF_BUILD, - DOMAIN_BEACON_BUILDER, - SLOTS_PER_EPOCH, - SLOTS_PER_HISTORICAL_ROOT, -} from "@lodestar/params"; +import {SLOTS_PER_EPOCH, SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; import {gloas, ssz} from "@lodestar/types"; import {byteArrayEquals, toHex, toRootHex} from "@lodestar/utils"; +import {getExecutionPayloadEnvelopeSignatureSet} from "../signatureSets/executionPayloadEnvelope.js"; import {CachedBeaconStateGloas} from "../types.js"; -import {computeSigningRoot, computeTimeAtSlot} from "../util/index.js"; +import {computeTimeAtSlot} from "../util/index.js"; +import {verifySignatureSet} from "../util/signatureSets.js"; import {processConsolidationRequest} from "./processConsolidationRequest.js"; import {processDepositRequest} from "./processDepositRequest.js"; import {processWithdrawalRequest} from "./processWithdrawalRequest.js"; @@ -32,8 +28,16 @@ export function processExecutionPayloadEnvelope( const payload = envelope.payload; const fork = state.config.getForkSeq(envelope.slot); - if (verifySignature && !verifyExecutionPayloadEnvelopeSignature(state, signedEnvelope)) { - throw Error(`Execution payload envelope has invalid signature builderIndex=${envelope.builderIndex}`); + if (verifySignature) { + const signatureSet = getExecutionPayloadEnvelopeSignatureSet( + state.config, + state, + signedEnvelope, + state.latestBlockHeader.proposerIndex + ); + if (!verifySignatureSet(signatureSet)) { + throw Error(`Execution payload envelope has invalid signature builderIndex=${envelope.builderIndex}`); + } } // .clone() before mutating state, similar to stateTransition() @@ -157,33 +161,3 @@ function validateExecutionPayloadEnvelope( // Skipped: Verify the execution payload is valid } - -function verifyExecutionPayloadEnvelopeSignature( - state: CachedBeaconStateGloas, - signedEnvelope: gloas.SignedExecutionPayloadEnvelope -): boolean { - const builderIndex = signedEnvelope.message.builderIndex; - - const domain = state.config.getDomain(state.slot, DOMAIN_BEACON_BUILDER); - const signingRoot = computeSigningRoot(ssz.gloas.ExecutionPayloadEnvelope, signedEnvelope.message, domain); - - try { - let publicKey: PublicKey; - - if (builderIndex === BUILDER_INDEX_SELF_BUILD) { - const validatorIndex = state.latestBlockHeader.proposerIndex; - const proposerPubkey = state.epochCtx.pubkeyCache.get(validatorIndex); - if (!proposerPubkey) { - return false; - } - publicKey = proposerPubkey; - } else { - publicKey = PublicKey.fromBytes(state.builders.getReadonly(builderIndex).pubkey); - } - const signature = Signature.fromBytes(signedEnvelope.signature, true); - - return verify(signingRoot, publicKey, signature); - } catch (_e) { - return false; // Catch all BLS errors: failed key validation, failed signature validation, invalid signature - } -} diff --git a/packages/state-transition/src/signatureSets/executionPayloadEnvelope.ts b/packages/state-transition/src/signatureSets/executionPayloadEnvelope.ts index 40c21f25fd77..b67246bc3c37 100644 --- a/packages/state-transition/src/signatureSets/executionPayloadEnvelope.ts +++ b/packages/state-transition/src/signatureSets/executionPayloadEnvelope.ts @@ -1,7 +1,10 @@ +import {PublicKey} from "@chainsafe/blst"; import {BeaconConfig} from "@lodestar/config"; -import {DOMAIN_BEACON_BUILDER} from "@lodestar/params"; -import {gloas, ssz} from "@lodestar/types"; +import {BUILDER_INDEX_SELF_BUILD, DOMAIN_BEACON_BUILDER} from "@lodestar/params"; +import {ValidatorIndex, gloas, ssz} from "@lodestar/types"; +import {CachedBeaconStateGloas} from "../types.js"; import {computeSigningRoot} from "../util/index.js"; +import {type SingleSignatureSet, createSingleSignatureSetFromComponents} from "../util/signatureSets.js"; export function getExecutionPayloadEnvelopeSigningRoot( config: BeaconConfig, @@ -11,3 +14,22 @@ export function getExecutionPayloadEnvelopeSigningRoot( return computeSigningRoot(ssz.gloas.ExecutionPayloadEnvelope, envelope, domain); } + +export function getExecutionPayloadEnvelopeSignatureSet( + config: BeaconConfig, + state: CachedBeaconStateGloas, + signedEnvelope: gloas.SignedExecutionPayloadEnvelope, + proposerIndex: ValidatorIndex +): SingleSignatureSet { + const envelope = signedEnvelope.message; + const builderPubkey = + envelope.builderIndex === BUILDER_INDEX_SELF_BUILD + ? state.epochCtx.pubkeyCache.getOrThrow(proposerIndex) + : PublicKey.fromBytes(state.builders.getReadonly(envelope.builderIndex).pubkey); + + return createSingleSignatureSetFromComponents( + builderPubkey, + getExecutionPayloadEnvelopeSigningRoot(config, envelope), + signedEnvelope.signature + ); +} From 158da5993d2d518a5013b5bb2fde9dcb8cfb3ca1 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Fri, 13 Mar 2026 01:00:02 -0700 Subject: [PATCH 23/38] address comments Co-Authored-By: Claude Opus 4.6 --- .../beacon-node/src/chain/seenCache/seenGossipBlockInput.ts | 4 ++-- .../src/chain/seenCache/seenPayloadEnvelopeInput.ts | 2 +- packages/beacon-node/src/network/processor/gossipHandlers.ts | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/beacon-node/src/chain/seenCache/seenGossipBlockInput.ts b/packages/beacon-node/src/chain/seenCache/seenGossipBlockInput.ts index 7570c2a03ffd..336256678962 100644 --- a/packages/beacon-node/src/chain/seenCache/seenGossipBlockInput.ts +++ b/packages/beacon-node/src/chain/seenCache/seenGossipBlockInput.ts @@ -180,7 +180,7 @@ export class SeenBlockInput { blockInput = this.blockInputs.get(parentRootHex ?? ""); parentRootHex = blockInput?.parentRootHex; } - this.logger?.debug(`BlockInputCache.prune deleted ${deletedCount} cached BlockInputs`); + this.logger?.debug("BlockInputCache.prune deleted cached BlockInputs", {deletedCount}); this.pruneToMaxSize(); } @@ -193,7 +193,7 @@ export class SeenBlockInput { this.evictBlockInput(blockInput); } } - this.logger?.debug(`BlockInputCache.onFinalized deleted ${deletedCount} cached BlockInputs`); + this.logger?.debug("BlockInputCache.onFinalized deleted cached BlockInputs", {deletedCount}); this.pruneToMaxSize(); }; diff --git a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts index f0d94858ef95..34e1579aa70d 100644 --- a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts @@ -59,7 +59,7 @@ export class SeenPayloadEnvelopeInput { deletedCount++; } } - this.logger?.debug(`SeenPayloadEnvelopeInput.onFinalized deleted ${deletedCount} cached entries`); + this.logger?.debug("SeenPayloadEnvelopeInput.onFinalized deleted cached entries", {deletedCount}); }; add(props: CreateFromBlockProps): PayloadEnvelopeInput { diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index 8b74e6248199..4e9cc492d05e 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -841,6 +841,7 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand }: GossipHandlerParamGeneric) => { const {serializedData} = gossipData; const executionPayloadEnvelope = sszDeserialize(topic, serializedData); + // TODO GLOAS: handle BLOCK_ROOT_UNKNOWN error to trigger sync await validateGossipExecutionPayloadEnvelope(chain, executionPayloadEnvelope); const slot = executionPayloadEnvelope.message.slot; From 5dfd69bf43ff33438e60ce490be2491b8a8e3d44 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:49:29 -0700 Subject: [PATCH 24/38] Fix ethspecify --- .../block/processExecutionPayloadEnvelope.ts | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts index 4a199c504fc3..367951e56a01 100644 --- a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts +++ b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts @@ -28,16 +28,8 @@ export function processExecutionPayloadEnvelope( const payload = envelope.payload; const fork = state.config.getForkSeq(envelope.slot); - if (verifySignature) { - const signatureSet = getExecutionPayloadEnvelopeSignatureSet( - state.config, - state, - signedEnvelope, - state.latestBlockHeader.proposerIndex - ); - if (!verifySignatureSet(signatureSet)) { - throw Error(`Execution payload envelope has invalid signature builderIndex=${envelope.builderIndex}`); - } + if (verifySignature && !verifyExecutionPayloadEnvelopeSignature(state, signedEnvelope)) { + throw Error(`Execution payload envelope has invalid signature builderIndex=${envelope.builderIndex}`); } // .clone() before mutating state, similar to stateTransition() @@ -161,3 +153,16 @@ function validateExecutionPayloadEnvelope( // Skipped: Verify the execution payload is valid } + +function verifyExecutionPayloadEnvelopeSignature( + state: CachedBeaconStateGloas, + signedEnvelope: gloas.SignedExecutionPayloadEnvelope +): boolean { + const signatureSet = getExecutionPayloadEnvelopeSignatureSet( + state.config, + state, + signedEnvelope, + state.latestBlockHeader.proposerIndex + ); + return verifySignatureSet(signatureSet); +} From e43f632122a8a41764d6e885e11862910b385f75 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 15 Mar 2026 17:19:38 +0000 Subject: [PATCH 25/38] Fix build --- packages/beacon-node/src/api/impl/beacon/blocks/index.ts | 2 +- packages/beacon-node/src/chain/chain.ts | 2 +- .../src/signatureSets/executionPayloadEnvelope.ts | 4 ++-- .../state-transition/src/stateView/beaconStateView.ts | 8 ++++++-- packages/state-transition/src/stateView/interface.ts | 6 +++++- 5 files changed, 15 insertions(+), 7 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 ba3576b54b77..a92216598547 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -35,7 +35,7 @@ import { } from "@lodestar/types"; import {fromHex, sleep, toHex, toRootHex} from "@lodestar/utils"; import {BlockInputSource, isBlockInputBlobs, isBlockInputColumns} from "../../../../chain/blocks/blockInput/index.js"; -import {PayloadEnvelopeInputSource} from "../../../../chain/blocks/payloadEnvelopeInput/index.ts"; +import {PayloadEnvelopeInputSource} from "../../../../chain/blocks/payloadEnvelopeInput/index.js"; import {ImportBlockOpts} from "../../../../chain/blocks/types.js"; import {verifyBlocksInEpoch} from "../../../../chain/blocks/verifyBlock.js"; import {BeaconChain} from "../../../../chain/chain.js"; diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 0456ac7b7f2b..a45211467f82 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -85,7 +85,7 @@ import {IBlockInput, isBlockInputBlobs, isBlockInputColumns} from "./blocks/bloc import {BlockProcessor, ImportBlockOpts} from "./blocks/index.js"; import {PayloadEnvelopeProcessor} from "./blocks/payloadEnvelopeProcessor.js"; import {ImportPayloadOpts} from "./blocks/types.js"; -import {persistBlockInput} from "./blocks/writeBlockInputToDb.ts"; +import {persistBlockInput} from "./blocks/writeBlockInputToDb.js"; import {persistPayloadEnvelopeInput} from "./blocks/writePayloadEnvelopeInputToDb.js"; import {BlsMultiThreadWorkerPool, BlsSingleThreadVerifier, IBlsVerifier} from "./bls/index.js"; import {ColumnReconstructionTracker} from "./ColumnReconstructionTracker.js"; diff --git a/packages/state-transition/src/signatureSets/executionPayloadEnvelope.ts b/packages/state-transition/src/signatureSets/executionPayloadEnvelope.ts index b67246bc3c37..244b6a0deaa7 100644 --- a/packages/state-transition/src/signatureSets/executionPayloadEnvelope.ts +++ b/packages/state-transition/src/signatureSets/executionPayloadEnvelope.ts @@ -22,13 +22,13 @@ export function getExecutionPayloadEnvelopeSignatureSet( proposerIndex: ValidatorIndex ): SingleSignatureSet { const envelope = signedEnvelope.message; - const builderPubkey = + const pubkey = envelope.builderIndex === BUILDER_INDEX_SELF_BUILD ? state.epochCtx.pubkeyCache.getOrThrow(proposerIndex) : PublicKey.fromBytes(state.builders.getReadonly(envelope.builderIndex).pubkey); return createSingleSignatureSetFromComponents( - builderPubkey, + pubkey, getExecutionPayloadEnvelopeSigningRoot(config, envelope), signedEnvelope.signature ); diff --git a/packages/state-transition/src/stateView/beaconStateView.ts b/packages/state-transition/src/stateView/beaconStateView.ts index 47f7f772c111..fbc60b204fbe 100644 --- a/packages/state-transition/src/stateView/beaconStateView.ts +++ b/packages/state-transition/src/stateView/beaconStateView.ts @@ -28,6 +28,7 @@ import { } from "@lodestar/types"; import {Checkpoint, Fork} from "@lodestar/types/phase0"; import {processExecutionPayloadEnvelope} from "../block/index.js"; +import {ProcessExecutionPayloadEnvelopeOpts} from "../block/processExecutionPayloadEnvelope.ts"; import {VoluntaryExitValidity, getVoluntaryExitValidity} from "../block/processVoluntaryExit.js"; import {getExpectedWithdrawals} from "../block/processWithdrawals.js"; import {EffectiveBalanceIncrements} from "../cache/effectiveBalanceIncrements.js"; @@ -761,12 +762,15 @@ export class BeaconStateView implements IBeaconStateView { return new BeaconStateView(newState); } - processExecutionPayloadEnvelope(signedEnvelope: gloas.SignedExecutionPayloadEnvelope, verify: boolean): void { + processExecutionPayloadEnvelope( + signedEnvelope: gloas.SignedExecutionPayloadEnvelope, + opts?: ProcessExecutionPayloadEnvelopeOpts + ): void { const fork = this.config.getForkName(this.cachedState.slot); if (!isForkPostGloas(fork)) { throw Error(`processExecutionPayloadEnvelope is only available for gloas+ forks, got fork=${fork}`); } - processExecutionPayloadEnvelope(this.cachedState as CachedBeaconStateGloas, signedEnvelope, verify); + processExecutionPayloadEnvelope(this.cachedState as CachedBeaconStateGloas, signedEnvelope, opts); } } diff --git a/packages/state-transition/src/stateView/interface.ts b/packages/state-transition/src/stateView/interface.ts index bef62b4bcfdb..52839a041ce6 100644 --- a/packages/state-transition/src/stateView/interface.ts +++ b/packages/state-transition/src/stateView/interface.ts @@ -23,6 +23,7 @@ import { rewards, } from "@lodestar/types"; import {Checkpoint, Fork} from "@lodestar/types/phase0"; +import {ProcessExecutionPayloadEnvelopeOpts} from "../block/processExecutionPayloadEnvelope.js"; import {VoluntaryExitValidity} from "../block/processVoluntaryExit.js"; import {EffectiveBalanceIncrements} from "../cache/effectiveBalanceIncrements.js"; import {EpochTransitionCacheOpts} from "../cache/epochTransitionCache.js"; @@ -209,5 +210,8 @@ export interface IBeaconStateView { epochTransitionCacheOpts?: EpochTransitionCacheOpts & {dontTransferCache?: boolean}, modules?: StateTransitionModules ): IBeaconStateView; - processExecutionPayloadEnvelope(signedEnvelope: gloas.SignedExecutionPayloadEnvelope, verify: boolean): void; + processExecutionPayloadEnvelope( + signedEnvelope: gloas.SignedExecutionPayloadEnvelope, + opts?: ProcessExecutionPayloadEnvelopeOpts + ): void; } From bbfb962675869250faa030bb25b1fc7ee4661097 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sun, 15 Mar 2026 17:20:01 +0000 Subject: [PATCH 26/38] Remove execution_payload_available from ignored topics --- packages/api/test/unit/beacon/oapiSpec.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/api/test/unit/beacon/oapiSpec.test.ts b/packages/api/test/unit/beacon/oapiSpec.test.ts index 590942f8f992..1e72ddf585cf 100644 --- a/packages/api/test/unit/beacon/oapiSpec.test.ts +++ b/packages/api/test/unit/beacon/oapiSpec.test.ts @@ -78,7 +78,6 @@ runTestCheckAgainstSpec(openApiJson, definitions, testDatas, ignoredOperations, const ignoredTopics: string[] = [ // TODO GLOAS: required by v5.0.0-alpha.0 - "execution_payload_available", "execution_payload_bid", "payload_attestation_message", ]; From bfbe600e8b7d0d12fe28fc7bf2e9e3f7e77a9d8a Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 18 Mar 2026 07:57:43 +0000 Subject: [PATCH 27/38] refactor: handle payload import dedupe in processor (#9041) --- .../src/api/impl/beacon/blocks/index.ts | 7 +--- .../payloadEnvelopeInput.ts | 17 ---------- .../chain/blocks/payloadEnvelopeProcessor.ts | 33 ++++++++++++++++++- .../src/network/processor/gossipHandlers.ts | 12 +++---- 4 files changed, 37 insertions(+), 32 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 a92216598547..a35645bebc99 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -745,12 +745,7 @@ export function getBeaconBlockApi({ // For self-builds, publish all data column sidecars ...dataColumnSidecars.map((dataColumnSidecar) => () => network.publishDataColumnSidecar(dataColumnSidecar)), // Import execution payload. Signature already verified above - () => { - if (payloadInput.shouldImport()) { - chain.processExecutionPayload(payloadInput, {validSignature: true}); - } - return Promise.resolve(); - }, + () => chain.processExecutionPayload(payloadInput, {validSignature: true}), ]; const sentPeersArr = await promiseAllMaybeAsync(publishPromises); diff --git a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts index 82b05f78c29d..f65da24f78c9 100644 --- a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts @@ -68,9 +68,6 @@ export class PayloadEnvelopeInput { private readonly sampledColumns: ColumnIndex[]; private readonly custodyColumns: ColumnIndex[]; - /** Guard against double import - only one caller can claim the import */ - private importClaimed = false; - private timeCreatedSec: number; private readonly payloadEnvelopeDataPromise: PromiseParts; @@ -285,20 +282,6 @@ export class PayloadEnvelopeInput { return this.state.hasPayload && this.state.hasAllData; } - /** - * Check if this caller should import the payload. - * Returns true only once - guards against race condition where multiple - * gossip handlers (envelope + columns from different peers) could each - * observe isComplete() === true and trigger concurrent imports. - */ - shouldImport(): boolean { - if (this.isComplete() && !this.importClaimed) { - this.importClaimed = true; - return true; - } - return false; - } - async waitForData(): Promise { return this.payloadEnvelopeDataPromise.promise; } diff --git a/packages/beacon-node/src/chain/blocks/payloadEnvelopeProcessor.ts b/packages/beacon-node/src/chain/blocks/payloadEnvelopeProcessor.ts index 29885c253df2..8dc5db182d1d 100644 --- a/packages/beacon-node/src/chain/blocks/payloadEnvelopeProcessor.ts +++ b/packages/beacon-node/src/chain/blocks/payloadEnvelopeProcessor.ts @@ -8,15 +8,23 @@ import {ImportPayloadOpts} from "./types.js"; // Set to be equal to DEFAULT_MAX_PENDING_UNFINALIZED_PAYLOAD_ENVELOPE_WRITES for now const QUEUE_MAX_LENGTH = 16; +enum PayloadEnvelopeImportStatus { + queued = "queued", + importing = "importing", + imported = "imported", +} + /** * PayloadEnvelopeProcessor processes payload envelope jobs in a queued fashion, one after the other. */ export class PayloadEnvelopeProcessor { readonly jobQueue: JobItemQueue<[PayloadEnvelopeInput, ImportPayloadOpts], void>; + private readonly importStatus = new WeakMap(); constructor(chain: BeaconChain, metrics: Metrics | null, signal: AbortSignal) { this.jobQueue = new JobItemQueue<[PayloadEnvelopeInput, ImportPayloadOpts], void>( (payloadInput, opts) => { + this.importStatus.set(payloadInput, PayloadEnvelopeImportStatus.importing); return importExecutionPayload.call(chain, payloadInput, opts); }, {maxLength: QUEUE_MAX_LENGTH, noYieldIfOneItem: true, signal}, @@ -25,6 +33,29 @@ export class PayloadEnvelopeProcessor { } async processPayloadEnvelopeJob(payloadInput: PayloadEnvelopeInput, opts: ImportPayloadOpts = {}): Promise { - await this.jobQueue.push(payloadInput, opts); + if (!payloadInput.isComplete()) { + return; + } + + if (this.importStatus.get(payloadInput) !== undefined) { + return; + } + + await this.jobQueue.waitForSpace(); + + // Re-check after await, as another call may have queued this payload. + if (this.importStatus.get(payloadInput) !== undefined) { + return; + } + + this.importStatus.set(payloadInput, PayloadEnvelopeImportStatus.queued); + + try { + await this.jobQueue.push(payloadInput, opts); + this.importStatus.set(payloadInput, PayloadEnvelopeImportStatus.imported); + } catch (e) { + this.importStatus.delete(payloadInput); + throw e; + } } } diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index 4e9cc492d05e..740f4793cd23 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -618,13 +618,11 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand }); } - // TODO GLOAS: In Gloas, also add column to PayloadEnvelopeInput and check completion: + // TODO GLOAS: In Gloas, also add column to PayloadEnvelopeInput and notify the payload processor: // const payloadInput = chain.seenPayloadEnvelopeInput.get(blockRootHex); // if (payloadInput) { // payloadInput.addColumn({columnSidecar, source: BlockInputSource.gossip, seenTimestampSec, peerIdStr}); - // if (payloadInput.shouldImport()) { - // chain.processExecutionPayload(payloadInput, {validSignature: true}); - // } + // chain.processExecutionPayload(payloadInput, {validSignature: true}); // } }, @@ -866,10 +864,8 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand peerIdStr, }); - if (payloadInput.shouldImport()) { - // TODO GLOAS: Emit execution_payload_gossip event for gossip receipt. - chain.processExecutionPayload(payloadInput, {validSignature: true}); - } + // TODO GLOAS: Emit execution_payload_gossip event for gossip receipt. + chain.processExecutionPayload(payloadInput, {validSignature: true}); }, [GossipType.payload_attestation_message]: async ({ gossipData, From 2603cf0a1df2c1624a4147338181867d8f3825a1 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:04:24 -0700 Subject: [PATCH 28/38] Update packages/beacon-node/src/chain/blocks/importBlock.ts Co-authored-by: Nico Flaig --- packages/beacon-node/src/chain/blocks/importBlock.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index a8df93e2f6a7..beb5f72a21d1 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -147,7 +147,7 @@ export async function importBlock( timeCreatedSec: fullyVerifiedBlock.seenTimestampSec, }); if (opts.seenTimestampSec !== undefined) { - this.logger.debug("Created PayloadEnvelopeInput for Gloas block", {slot: blockSlot, root: blockRootHex}); + this.logger.debug("Created PayloadEnvelopeInput for block", {slot: blockSlot, root: blockRootHex}); } } From c9132b3d94eec766a4a25114611d375692e24134 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:36:38 -0700 Subject: [PATCH 29/38] Apply suggestions from code review Co-authored-by: Nico Flaig --- .../beacon-node/src/chain/blocks/payloadEnvelopeProcessor.ts | 2 +- .../src/chain/blocks/writePayloadEnvelopeInputToDb.ts | 2 +- .../beacon-node/src/chain/produceBlock/computeNewStateRoot.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/payloadEnvelopeProcessor.ts b/packages/beacon-node/src/chain/blocks/payloadEnvelopeProcessor.ts index 8dc5db182d1d..e50b53bda93e 100644 --- a/packages/beacon-node/src/chain/blocks/payloadEnvelopeProcessor.ts +++ b/packages/beacon-node/src/chain/blocks/payloadEnvelopeProcessor.ts @@ -5,7 +5,7 @@ import {PayloadEnvelopeInput} from "../seenCache/seenPayloadEnvelopeInput.js"; import {importExecutionPayload} from "./importExecutionPayload.js"; import {ImportPayloadOpts} from "./types.js"; -// Set to be equal to DEFAULT_MAX_PENDING_UNFINALIZED_PAYLOAD_ENVELOPE_WRITES for now +// TODO GLOAS: Set to be equal to DEFAULT_MAX_PENDING_UNFINALIZED_PAYLOAD_ENVELOPE_WRITES for now const QUEUE_MAX_LENGTH = 16; enum PayloadEnvelopeImportStatus { diff --git a/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts b/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts index fd5cdafcb47a..23add75bdcc5 100644 --- a/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts +++ b/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts @@ -5,7 +5,7 @@ import {writeDataColumnsToDb} from "./writeBlockInputToDb.js"; /** * Persists payload envelope data to DB. This operation must be eventually completed if a payload is imported. * - * TODO: Persist envelope metadata (stateRoot, executionRequests, builderIndex, etc.) without the full + * TODO GLOAS: Persist envelope metadata (stateRoot, executionRequests, builderIndex, etc.) without the full * execution payload body — only keep the blockHash reference. The EL already stores the payload. * See https://github.com/ChainSafe/lodestar/issues/5671 */ diff --git a/packages/beacon-node/src/chain/produceBlock/computeNewStateRoot.ts b/packages/beacon-node/src/chain/produceBlock/computeNewStateRoot.ts index 6e9d847681f0..f150c0275f54 100644 --- a/packages/beacon-node/src/chain/produceBlock/computeNewStateRoot.ts +++ b/packages/beacon-node/src/chain/produceBlock/computeNewStateRoot.ts @@ -74,9 +74,9 @@ export function computeEnvelopeStateRoot( const processEnvelopeTimer = metrics?.blockPayload.executionPayloadEnvelopeProcessingTime.startTimer(); const postEnvelopeState = processExecutionPayloadEnvelope(postBlockState, signedEnvelope, { - // verifySignature: false | signature is zero-ed (G2_POINT_AT_INFINITY), skip verification + // Signature is zero-ed (G2_POINT_AT_INFINITY), skip verification verifySignature: false, - // verifyStateRoot: false | state root is being computed here, the envelope doesn't have it yet + // State root is being computed here, the envelope doesn't have it yet verifyStateRoot: false, // Preserve cache in source state, since the resulting state is not added to the state cache dontTransferCache: true, From 7a7415313ffd1ea3bd0482de52bbc7bcdb10180d Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:30:57 -0700 Subject: [PATCH 30/38] address comments --- .../src/chain/blocks/importBlock.ts | 4 +- .../chain/blocks/importExecutionPayload.ts | 46 +++++++++++-------- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index beb5f72a21d1..26ad6e23568d 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -15,7 +15,6 @@ import { ForkSeq, MAX_SEED_LOOKAHEAD, SLOTS_PER_EPOCH, - isForkPostGloas, } from "@lodestar/params"; import { CachedBeaconStateAltair, @@ -137,8 +136,7 @@ export async function importBlock( this.regen.processState(blockRootHex, postState); // For Gloas blocks, create PayloadEnvelopeInput so it's available for later payload import - const forkName = this.config.getForkName(blockSlot); - if (isForkPostGloas(forkName)) { + if (fork >= ForkSeq.gloas) { this.seenPayloadEnvelopeInput.add({ blockRootHex, block: block as SignedBeaconBlock, diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index 44b4ce56254d..2b7735ded61a 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -155,24 +155,34 @@ export async function importExecutionPayload( } // 5. Handle EL response - if (execResult.status === ExecutionPayloadStatus.INVALID) { - throw new PayloadError({ - code: PayloadErrorCode.EXECUTION_ENGINE_INVALID, - execStatus: execResult.status, - errorMessage: execResult.validationError ?? "", - }); - } - - if ( - execResult.status === ExecutionPayloadStatus.INVALID_BLOCK_HASH || - execResult.status === ExecutionPayloadStatus.ELERROR || - execResult.status === ExecutionPayloadStatus.UNAVAILABLE - ) { - throw new PayloadError({ - code: PayloadErrorCode.EXECUTION_ENGINE_ERROR, - execStatus: execResult.status, - errorMessage: execResult.validationError ?? "", - }); + switch (execResult.status) { + case ExecutionPayloadStatus.VALID: + break; + + case ExecutionPayloadStatus.INVALID: + throw new PayloadError({ + code: PayloadErrorCode.EXECUTION_ENGINE_INVALID, + execStatus: execResult.status, + errorMessage: execResult.validationError ?? "", + }); + + case ExecutionPayloadStatus.ACCEPTED: + case ExecutionPayloadStatus.SYNCING: + // TODO GLOAS: Handle optimistic import for payload - for now treat as error + throw new PayloadError({ + code: PayloadErrorCode.EXECUTION_ENGINE_ERROR, + execStatus: execResult.status, + errorMessage: execResult.validationError ?? "EL syncing, payload not yet validated", + }); + + case ExecutionPayloadStatus.INVALID_BLOCK_HASH: + case ExecutionPayloadStatus.ELERROR: + case ExecutionPayloadStatus.UNAVAILABLE: + throw new PayloadError({ + code: PayloadErrorCode.EXECUTION_ENGINE_ERROR, + execStatus: execResult.status, + errorMessage: execResult.validationError ?? "", + }); } // 5b. Verify envelope state root matches post-state From 996a990c649e55b675c9b22d0e0cac17808ce111 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Thu, 19 Mar 2026 00:33:31 -0700 Subject: [PATCH 31/38] Address comments --- .../chain/blocks/importExecutionPayload.ts | 20 ++++++++++--------- .../beacon-node/src/chain/regen/interface.ts | 1 + .../seenCache/seenPayloadEnvelopeInput.ts | 17 ++++++++++++---- .../validation/executionPayloadEnvelope.ts | 11 ++++++++-- .../src/metrics/metrics/lodestar.ts | 8 ++++++++ .../block/processExecutionPayloadEnvelope.ts | 4 +++- .../signatureSets/executionPayloadEnvelope.ts | 10 ++++++---- .../src/stateView/beaconStateView.ts | 9 +++++++-- .../src/stateView/interface.ts | 2 +- 9 files changed, 59 insertions(+), 23 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index 2b7735ded61a..d75d8b79b33e 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -1,6 +1,10 @@ import {routes} from "@lodestar/api"; import {ForkName} from "@lodestar/params"; -import {CachedBeaconStateGloas, getExecutionPayloadEnvelopeSignatureSet} from "@lodestar/state-transition"; +import { + BeaconStateView, + CachedBeaconStateGloas, + getExecutionPayloadEnvelopeSignatureSet, +} from "@lodestar/state-transition"; import {processExecutionPayloadEnvelope} from "@lodestar/state-transition/block"; import {byteArrayEquals, fromHex, toRootHex} from "@lodestar/utils"; import {ExecutionPayloadStatus} from "../../execution/index.js"; @@ -120,7 +124,8 @@ export async function importExecutionPayload( : (async () => { const signatureSet = getExecutionPayloadEnvelopeSignatureSet( this.config, - blockState, + blockState.epochCtx.pubkeyCache, + new BeaconStateView(blockState), envelope, payloadInput.proposerIndex ); @@ -189,13 +194,10 @@ export async function importExecutionPayload( const postPayloadState = postPayloadResult.postPayloadState; const postPayloadStateRoot = postPayloadState.hashTreeRoot(); if (!byteArrayEquals(envelope.message.stateRoot, postPayloadStateRoot)) { - throw new PayloadError( - { - code: PayloadErrorCode.STATE_TRANSITION_ERROR, - message: `Envelope state root mismatch expected=${toRootHex(envelope.message.stateRoot)} actual=${toRootHex(postPayloadStateRoot)}`, - }, - `Envelope state root mismatch expected=${toRootHex(envelope.message.stateRoot)} actual=${toRootHex(postPayloadStateRoot)}` - ); + throw new PayloadError({ + code: PayloadErrorCode.STATE_TRANSITION_ERROR, + message: `Envelope state root mismatch expected=${toRootHex(envelope.message.stateRoot)} actual=${toRootHex(postPayloadStateRoot)}`, + }); } // 6. Update fork choice diff --git a/packages/beacon-node/src/chain/regen/interface.ts b/packages/beacon-node/src/chain/regen/interface.ts index 61b68fa55625..04e1715535ae 100644 --- a/packages/beacon-node/src/chain/regen/interface.ts +++ b/packages/beacon-node/src/chain/regen/interface.ts @@ -11,6 +11,7 @@ export enum RegenCaller { validateGossipBlock = "validateGossipBlock", validateGossipBlob = "validateGossipBlob", validateGossipDataColumn = "validateGossipDataColumn", + validateGossipExecutionPayloadEnvelope = "validateGossipExecutionPayloadEnvelope", precomputeEpoch = "precomputeEpoch", predictProposerHead = "predictProposerHead", produceAttestationData = "produceAttestationData", diff --git a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts index 34e1579aa70d..1932a4c38f42 100644 --- a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts @@ -21,13 +21,14 @@ export type SeenPayloadEnvelopeInputModules = { /** * Cache for tracking PayloadEnvelopeInput instances, keyed by beacon block root. * - * Created during block import when a Gloas block is processed. + * Created during block import when a block is processed. * Pruned on finalization and after payload is written to DB. */ export class SeenPayloadEnvelopeInput { private readonly chainEvents: ChainEventEmitter; private readonly signal: AbortSignal; private readonly serializedCache: SerializedCache; + private readonly metrics: Metrics | null; private readonly logger?: Logger; private payloadInputs = new Map(); @@ -35,12 +36,19 @@ export class SeenPayloadEnvelopeInput { this.chainEvents = chainEvents; this.signal = signal; this.serializedCache = serializedCache; + this.metrics = metrics; this.logger = logger; if (metrics) { - metrics.seenCache.payloadEnvelopeInput.count.addCollect(() => - metrics.seenCache.payloadEnvelopeInput.count.set(this.payloadInputs.size) - ); + metrics.seenCache.payloadEnvelopeInput.count.addCollect(() => { + metrics.seenCache.payloadEnvelopeInput.count.set(this.payloadInputs.size); + metrics.seenCache.payloadEnvelopeInput.serializedObjectRefs.set( + Array.from(this.payloadInputs.values()).reduce( + (count, payloadInput) => count + payloadInput.getSerializedCacheKeys().length, + 0 + ) + ); + }); } this.chainEvents.on(ChainEvent.forkChoiceFinalized, this.onFinalized); @@ -68,6 +76,7 @@ export class SeenPayloadEnvelopeInput { } const input = PayloadEnvelopeInput.createFromBlock(props); this.payloadInputs.set(props.blockRootHex, input); + this.metrics?.seenCache.payloadEnvelopeInput.created.inc(); return input; } diff --git a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts index 0188651ca075..d3a2e312b6d7 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts @@ -1,4 +1,5 @@ import { + BeaconStateView, CachedBeaconStateGloas, computeStartSlotAtEpoch, getExecutionPayloadEnvelopeSignatureSet, @@ -129,7 +130,12 @@ async function validateExecutionPayloadEnvelope( // Get pre-state to get correct builder's pubkey. const blockState = await chain.regen - .getBlockSlotState(parentBlock, block.slot, {dontTransferCache: true}, RegenCaller.validateGossipBlock) + .getBlockSlotState( + parentBlock, + block.slot, + {dontTransferCache: true}, + RegenCaller.validateGossipExecutionPayloadEnvelope + ) .catch(() => { throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { code: ExecutionPayloadEnvelopeErrorCode.UNKNOWN_PARENT_STATE, @@ -141,7 +147,8 @@ async function validateExecutionPayloadEnvelope( const state = blockState as CachedBeaconStateGloas; const signatureSet = getExecutionPayloadEnvelopeSignatureSet( chain.config, - state, + state.epochCtx.pubkeyCache, + new BeaconStateView(state), executionPayloadEnvelope, payloadInput.proposerIndex ); diff --git a/packages/beacon-node/src/metrics/metrics/lodestar.ts b/packages/beacon-node/src/metrics/metrics/lodestar.ts index a49893d2ddcb..ce6367749474 100644 --- a/packages/beacon-node/src/metrics/metrics/lodestar.ts +++ b/packages/beacon-node/src/metrics/metrics/lodestar.ts @@ -1561,6 +1561,14 @@ export function createLodestarMetrics( name: "lodestar_seen_payload_envelope_input_cache_size", help: "Number of cached PayloadEnvelopeInputs", }), + serializedObjectRefs: register.gauge({ + 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", + }), }, }, diff --git a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts index 367951e56a01..5330e41129b9 100644 --- a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts +++ b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts @@ -2,6 +2,7 @@ import {SLOTS_PER_EPOCH, SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; import {gloas, ssz} from "@lodestar/types"; import {byteArrayEquals, toHex, toRootHex} from "@lodestar/utils"; import {getExecutionPayloadEnvelopeSignatureSet} from "../signatureSets/executionPayloadEnvelope.js"; +import {BeaconStateView} from "../stateView/beaconStateView.js"; import {CachedBeaconStateGloas} from "../types.js"; import {computeTimeAtSlot} from "../util/index.js"; import {verifySignatureSet} from "../util/signatureSets.js"; @@ -160,7 +161,8 @@ function verifyExecutionPayloadEnvelopeSignature( ): boolean { const signatureSet = getExecutionPayloadEnvelopeSignatureSet( state.config, - state, + state.epochCtx.pubkeyCache, + new BeaconStateView(state), signedEnvelope, state.latestBlockHeader.proposerIndex ); diff --git a/packages/state-transition/src/signatureSets/executionPayloadEnvelope.ts b/packages/state-transition/src/signatureSets/executionPayloadEnvelope.ts index 244b6a0deaa7..6107ef8eceae 100644 --- a/packages/state-transition/src/signatureSets/executionPayloadEnvelope.ts +++ b/packages/state-transition/src/signatureSets/executionPayloadEnvelope.ts @@ -2,7 +2,8 @@ import {PublicKey} from "@chainsafe/blst"; import {BeaconConfig} from "@lodestar/config"; import {BUILDER_INDEX_SELF_BUILD, DOMAIN_BEACON_BUILDER} from "@lodestar/params"; import {ValidatorIndex, gloas, ssz} from "@lodestar/types"; -import {CachedBeaconStateGloas} from "../types.js"; +import {PubkeyCache} from "../cache/pubkeyCache.js"; +import {IBeaconStateView} from "../stateView/interface.js"; import {computeSigningRoot} from "../util/index.js"; import {type SingleSignatureSet, createSingleSignatureSetFromComponents} from "../util/signatureSets.js"; @@ -17,15 +18,16 @@ export function getExecutionPayloadEnvelopeSigningRoot( export function getExecutionPayloadEnvelopeSignatureSet( config: BeaconConfig, - state: CachedBeaconStateGloas, + pubkeyCache: PubkeyCache, + state: IBeaconStateView, signedEnvelope: gloas.SignedExecutionPayloadEnvelope, proposerIndex: ValidatorIndex ): SingleSignatureSet { const envelope = signedEnvelope.message; const pubkey = envelope.builderIndex === BUILDER_INDEX_SELF_BUILD - ? state.epochCtx.pubkeyCache.getOrThrow(proposerIndex) - : PublicKey.fromBytes(state.builders.getReadonly(envelope.builderIndex).pubkey); + ? pubkeyCache.getOrThrow(proposerIndex) + : PublicKey.fromBytes(state.getBuilder(envelope.builderIndex).pubkey); return createSingleSignatureSetFromComponents( pubkey, diff --git a/packages/state-transition/src/stateView/beaconStateView.ts b/packages/state-transition/src/stateView/beaconStateView.ts index fbc60b204fbe..88684ce3aa6f 100644 --- a/packages/state-transition/src/stateView/beaconStateView.ts +++ b/packages/state-transition/src/stateView/beaconStateView.ts @@ -765,12 +765,17 @@ export class BeaconStateView implements IBeaconStateView { processExecutionPayloadEnvelope( signedEnvelope: gloas.SignedExecutionPayloadEnvelope, opts?: ProcessExecutionPayloadEnvelopeOpts - ): void { + ): BeaconStateView { const fork = this.config.getForkName(this.cachedState.slot); if (!isForkPostGloas(fork)) { throw Error(`processExecutionPayloadEnvelope is only available for gloas+ forks, got fork=${fork}`); } - processExecutionPayloadEnvelope(this.cachedState as CachedBeaconStateGloas, signedEnvelope, opts); + const postPayloadState = processExecutionPayloadEnvelope( + this.cachedState as CachedBeaconStateGloas, + signedEnvelope, + opts + ); + return new BeaconStateView(postPayloadState); } } diff --git a/packages/state-transition/src/stateView/interface.ts b/packages/state-transition/src/stateView/interface.ts index 52839a041ce6..9ab6b56ff0e1 100644 --- a/packages/state-transition/src/stateView/interface.ts +++ b/packages/state-transition/src/stateView/interface.ts @@ -213,5 +213,5 @@ export interface IBeaconStateView { processExecutionPayloadEnvelope( signedEnvelope: gloas.SignedExecutionPayloadEnvelope, opts?: ProcessExecutionPayloadEnvelopeOpts - ): void; + ): IBeaconStateView; } From 993efa28bfef28835828e8768d64a3fdf648333e Mon Sep 17 00:00:00 2001 From: twoeths <10568965+twoeths@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:00:31 +0700 Subject: [PATCH 32/38] fix: review payload envelope import pipeline PR #8962 (#9063) Co-authored-by: Tuyen Nguyen --- .../src/api/impl/beacon/blocks/index.ts | 2 + .../src/chain/blocks/importBlock.ts | 6 ++- .../payloadEnvelopeInput.ts | 13 ++--- packages/beacon-node/src/chain/chain.ts | 4 +- .../chain/errors/executionPayloadEnvelope.ts | 4 +- .../beacon-node/src/chain/regen/interface.ts | 1 + .../validation/executionPayloadEnvelope.ts | 51 +++++-------------- .../src/network/processor/gossipHandlers.ts | 11 +++- 8 files changed, 38 insertions(+), 54 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 a35645bebc99..1f9f03be15e7 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -702,6 +702,8 @@ export function getBeaconBlockApi({ await sleep(msToBlockSlot); } + // TODO GLOAS: if block and payload are submitted in parallel, payloadInput may not yet exist. + // A queuing mechanism is needed to handle this case. See https://github.com/ChainSafe/lodestar/issues/8915 const payloadInput = chain.seenPayloadEnvelopeInput.get(blockRootHex); if (!payloadInput) { throw new ApiError(404, `PayloadEnvelopeInput not found for block root ${blockRootHex}`); diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index 26ad6e23568d..2795487c3033 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -145,7 +145,11 @@ export async function importBlock( timeCreatedSec: fullyVerifiedBlock.seenTimestampSec, }); if (opts.seenTimestampSec !== undefined) { - this.logger.debug("Created PayloadEnvelopeInput for block", {slot: blockSlot, root: blockRootHex}); + this.logger.debug("Created PayloadEnvelopeInput for gossip block", { + slot: blockSlot, + root: blockRootHex, + recvToImport: Date.now() / 1000 - opts.seenTimestampSec, + }); } } diff --git a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts index f65da24f78c9..e819293328f6 100644 --- a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts @@ -138,7 +138,7 @@ export class PayloadEnvelopeInput { addPayloadEnvelope(props: AddPayloadEnvelopeProps): void { if (this.state.hasPayload) { - throw new Error("Payload envelope already set"); + throw new Error(`Payload envelope already set for block ${this.blockRootHex}`); } if (toRootHex(props.envelope.message.beaconBlockRoot) !== this.blockRootHex) { throw new Error("Payload envelope beacon_block_root mismatch"); @@ -240,22 +240,17 @@ export class PayloadEnvelopeInput { getSampledColumns(): gloas.DataColumnSidecars { return this.sampledColumns .filter((idx) => this.columnsCache.has(idx)) - .map((idx) => this.columnsCache.get(idx)?.columnSidecar) - .filter((col): col is gloas.DataColumnSidecar => col !== undefined); + .map((idx) => this.columnsCache.get(idx)!.columnSidecar); } getSampledColumnsWithSource(): ColumnWithSource[] { - return this.sampledColumns - .filter((idx) => this.columnsCache.has(idx)) - .map((idx) => this.columnsCache.get(idx)) - .filter((col): col is ColumnWithSource => col !== undefined); + return this.sampledColumns.filter((idx) => this.columnsCache.has(idx)).map((idx) => this.columnsCache.get(idx)!); } getCustodyColumns(): gloas.DataColumnSidecars { return this.custodyColumns .filter((idx) => this.columnsCache.has(idx)) - .map((idx) => this.columnsCache.get(idx)?.columnSidecar) - .filter((col): col is gloas.DataColumnSidecar => col !== undefined); + .map((idx) => this.columnsCache.get(idx)!.columnSidecar); } hasComputedAllData(): boolean { diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index a45211467f82..b58cee9725ba 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -153,8 +153,8 @@ const DEFAULT_MAX_PENDING_UNFINALIZED_BLOCK_WRITES = 16; /** * The maximum number of pending unfinalized payload envelope writes to the database before backpressure is applied. - * Similar to block writes, payload envelope write queue entries hold references to payload inputs - * (including columns) keeping them in memory. Keep moderate to avoid OOM during sync. + * Payload envelope write queue entries hold references to payload inputs (including columns), + * keeping them in memory. Keep moderate to avoid OOM during sync. */ const DEFAULT_MAX_PENDING_UNFINALIZED_PAYLOAD_ENVELOPE_WRITES = 16; diff --git a/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts index 2df9452aed4c..cf7a0fe4a002 100644 --- a/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts @@ -5,7 +5,7 @@ export enum ExecutionPayloadEnvelopeErrorCode { BELONG_TO_FINALIZED_BLOCK = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_BELONG_TO_FINALIZED_BLOCK", BLOCK_ROOT_UNKNOWN = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_BLOCK_ROOT_UNKNOWN", PARENT_UNKNOWN = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_PARENT_UNKNOWN", - UNKNOWN_PARENT_STATE = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_UNKNOWN_PARENT_STATE", + UNKNOWN_BLOCK_STATE = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_UNKNOWN_BLOCK_STATE", ENVELOPE_ALREADY_KNOWN = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_ALREADY_KNOWN", INVALID_BLOCK = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_INVALID_BLOCK", SLOT_MISMATCH = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_SLOT_MISMATCH", @@ -18,7 +18,7 @@ export type ExecutionPayloadEnvelopeErrorType = | {code: ExecutionPayloadEnvelopeErrorCode.BELONG_TO_FINALIZED_BLOCK; envelopeSlot: Slot; finalizedSlot: Slot} | {code: ExecutionPayloadEnvelopeErrorCode.BLOCK_ROOT_UNKNOWN; blockRoot: RootHex} | {code: ExecutionPayloadEnvelopeErrorCode.PARENT_UNKNOWN; parentRoot: RootHex; slot: Slot} - | {code: ExecutionPayloadEnvelopeErrorCode.UNKNOWN_PARENT_STATE; parentRoot: RootHex; slot: Slot} + | {code: ExecutionPayloadEnvelopeErrorCode.UNKNOWN_BLOCK_STATE; blockRoot: RootHex; slot: Slot} | { code: ExecutionPayloadEnvelopeErrorCode.ENVELOPE_ALREADY_KNOWN; blockRoot: RootHex; diff --git a/packages/beacon-node/src/chain/regen/interface.ts b/packages/beacon-node/src/chain/regen/interface.ts index 04e1715535ae..08cd03be5252 100644 --- a/packages/beacon-node/src/chain/regen/interface.ts +++ b/packages/beacon-node/src/chain/regen/interface.ts @@ -9,6 +9,7 @@ export enum RegenCaller { processBlock = "processBlock", produceBlock = "produceBlock", validateGossipBlock = "validateGossipBlock", + validateGossipPayloadEnvelope = "validateGossipPayloadEnvelope", validateGossipBlob = "validateGossipBlob", validateGossipDataColumn = "validateGossipDataColumn", validateGossipExecutionPayloadEnvelope = "validateGossipExecutionPayloadEnvelope", diff --git a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts index d3a2e312b6d7..e41b895c9e38 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts @@ -1,3 +1,4 @@ +import {PayloadStatus} from "@lodestar/fork-choice"; import { BeaconStateView, CachedBeaconStateGloas, @@ -47,22 +48,21 @@ async function validateExecutionPayloadEnvelope( // [IGNORE] The node has not seen another valid // `SignedExecutionPayloadEnvelope` for this block root from this builder. + const envelopeBlock = chain.forkChoice.getBlockHex(blockRootHex, PayloadStatus.FULL); const payloadInput = chain.seenPayloadEnvelopeInput.get(blockRootHex); - if (!payloadInput) { - // PayloadEnvelopeInput should have been created during block import + if (envelopeBlock || payloadInput?.hasPayloadEnvelope()) { throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { - code: ExecutionPayloadEnvelopeErrorCode.PAYLOAD_ENVELOPE_INPUT_MISSING, + code: ExecutionPayloadEnvelopeErrorCode.ENVELOPE_ALREADY_KNOWN, blockRoot: blockRootHex, + slot: envelope.slot, }); } - // [IGNORE] The node has not seen another valid - // `SignedExecutionPayloadEnvelope` for this block root from this builder. - if (payloadInput.hasPayloadEnvelope()) { + if (!payloadInput) { + // PayloadEnvelopeInput should have been created during block import throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { - code: ExecutionPayloadEnvelopeErrorCode.ENVELOPE_ALREADY_KNOWN, + code: ExecutionPayloadEnvelopeErrorCode.PAYLOAD_ENVELOPE_INPUT_MISSING, blockRoot: blockRootHex, - slot: envelope.slot, }); } @@ -108,38 +108,13 @@ async function validateExecutionPayloadEnvelope( }); } - // [REJECT] `signed_execution_payload_envelope.signature` is valid with respect to the builder's public key. - const parentRoot = block.parentRoot; - const parentHash = block.parentBlockHash; - if (parentHash === null) { - throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { - code: ExecutionPayloadEnvelopeErrorCode.PARENT_UNKNOWN, - parentRoot, - slot: envelope.slot, - }); - } - - const parentBlock = chain.forkChoice.getBlockHexAndBlockHash(parentRoot, parentHash); - if (parentBlock === null) { - throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { - code: ExecutionPayloadEnvelopeErrorCode.PARENT_UNKNOWN, - parentRoot, - slot: envelope.slot, - }); - } - - // Get pre-state to get correct builder's pubkey. + // Get the post block state which is the pre-payload state to verify the builder's signature. const blockState = await chain.regen - .getBlockSlotState( - parentBlock, - block.slot, - {dontTransferCache: true}, - RegenCaller.validateGossipExecutionPayloadEnvelope - ) + .getState(block.stateRoot, RegenCaller.validateGossipPayloadEnvelope) .catch(() => { throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { - code: ExecutionPayloadEnvelopeErrorCode.UNKNOWN_PARENT_STATE, - parentRoot, + code: ExecutionPayloadEnvelopeErrorCode.UNKNOWN_BLOCK_STATE, + blockRoot: blockRootHex, slot: envelope.slot, }); }); @@ -147,7 +122,7 @@ async function validateExecutionPayloadEnvelope( const state = blockState as CachedBeaconStateGloas; const signatureSet = getExecutionPayloadEnvelopeSignatureSet( chain.config, - state.epochCtx.pubkeyCache, + chain.pubkeyCache, new BeaconStateView(state), executionPayloadEnvelope, payloadInput.proposerIndex diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index 740f4793cd23..f7028ac2b08e 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -43,6 +43,8 @@ import { BlockGossipError, DataColumnSidecarErrorCode, DataColumnSidecarGossipError, + ExecutionPayloadEnvelopeError, + ExecutionPayloadEnvelopeErrorCode, GossipAction, GossipActionError, SyncCommitteeError, @@ -852,7 +854,10 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand if (!payloadInput) { // This shouldn't happen because beacon block should have been imported and thus payload input should have been created. - throw Error(`PayloadEnvelopeInput not found after validation blockRoot=${blockRootHex} slot=${slot}`); + throw new ExecutionPayloadEnvelopeError(GossipAction.REJECT, { + code: ExecutionPayloadEnvelopeErrorCode.PAYLOAD_ENVELOPE_INPUT_MISSING, + blockRoot: blockRootHex, + }); } chain.serializedCache.set(executionPayloadEnvelope, serializedData); @@ -865,7 +870,9 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand }); // TODO GLOAS: Emit execution_payload_gossip event for gossip receipt. - chain.processExecutionPayload(payloadInput, {validSignature: true}); + chain.processExecutionPayload(payloadInput, {validSignature: true}).catch((e) => { + chain.logger.debug("Error processing execution payload from gossip", {slot, root: blockRootHex}, e as Error); + }); }, [GossipType.payload_attestation_message]: async ({ gossipData, From a4f7a1a23272c71bb0357fa8831557d79448ff4c Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:59:01 -0700 Subject: [PATCH 33/38] address comments Co-Authored-By: Claude Opus 4.6 --- .../chain/blocks/importExecutionPayload.ts | 36 ++++++++++--------- .../src/network/processor/gossipHandlers.ts | 2 +- .../src/stateView/beaconStateView.ts | 2 +- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index d75d8b79b33e..c7c1234cac24 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -61,11 +61,13 @@ export class PayloadError extends Error { * * This function: * 1. Gets the ProtoBlock from fork choice - * 2. Regenerates the block state - * 3. Runs EL verification (notifyNewPayload) in parallel with signature verification and processExecutionPayloadEnvelope - * 4. Updates fork choice - * 5. Caches the post-execution payload state - * 6. Records metrics for column sources + * 2. Applies write-queue backpressure (waitForSpace) early, before verification + * 3. Regenerates the block state + * 4. Runs EL verification (notifyNewPayload) in parallel with signature verification and processExecutionPayloadEnvelope + * 5. Persists verified payload envelope to hot DB + * 6. Updates fork choice + * 7. Caches the post-execution payload state + * 8. Records metrics for column sources * */ export async function importExecutionPayload( @@ -85,18 +87,9 @@ export async function importExecutionPayload( }); } - // 2. Persist payload envelope to hot DB (performed asynchronously to avoid blocking) - // Wait for space in the write queue to apply backpressure during sync. + // 2. 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(); - this.unfinalizedPayloadEnvelopeWrites.push(payloadInput).catch((e) => { - if (!isQueueErrorAborted(e)) { - this.logger.error( - "Error pushing payload envelope to unfinalized write queue", - {slot: payloadInput.slot, root: blockRootHex}, - e as Error - ); - } - }); // 3. Get pre-state for processExecutionPayloadEnvelope // We need the block state (post-block, pre-payload) to process the envelope @@ -200,6 +193,17 @@ export async function importExecutionPayload( }); } + // 5c. Persist payload envelope to hot DB (performed asynchronously to avoid blocking) + this.unfinalizedPayloadEnvelopeWrites.push(payloadInput).catch((e) => { + if (!isQueueErrorAborted(e)) { + this.logger.error( + "Error pushing payload envelope to unfinalized write queue", + {slot: payloadInput.slot, root: blockRootHex}, + e as Error + ); + } + }); + // 6. Update fork choice this.forkChoice.onExecutionPayload( blockRootHex, diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index f7028ac2b08e..d485768ef3d3 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -30,7 +30,7 @@ import { IBlockInput, isBlockInputColumns, } from "../../chain/blocks/blockInput/index.js"; -import {PayloadEnvelopeInputSource} from "../../chain/blocks/payloadEnvelopeInput/index.ts"; +import {PayloadEnvelopeInputSource} from "../../chain/blocks/payloadEnvelopeInput/index.js"; import {BlobSidecarValidation} from "../../chain/blocks/types.js"; import {ChainEvent} from "../../chain/emitter.js"; import { diff --git a/packages/state-transition/src/stateView/beaconStateView.ts b/packages/state-transition/src/stateView/beaconStateView.ts index 88684ce3aa6f..12ce6ce0457f 100644 --- a/packages/state-transition/src/stateView/beaconStateView.ts +++ b/packages/state-transition/src/stateView/beaconStateView.ts @@ -28,7 +28,7 @@ import { } from "@lodestar/types"; import {Checkpoint, Fork} from "@lodestar/types/phase0"; import {processExecutionPayloadEnvelope} from "../block/index.js"; -import {ProcessExecutionPayloadEnvelopeOpts} from "../block/processExecutionPayloadEnvelope.ts"; +import {ProcessExecutionPayloadEnvelopeOpts} from "../block/processExecutionPayloadEnvelope.js"; import {VoluntaryExitValidity, getVoluntaryExitValidity} from "../block/processVoluntaryExit.js"; import {getExpectedWithdrawals} from "../block/processWithdrawals.js"; import {EffectiveBalanceIncrements} from "../cache/effectiveBalanceIncrements.js"; From 4aa950d983354cd018519d124344f34209f3cf2c Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 20 Mar 2026 14:50:32 +0000 Subject: [PATCH 34/38] Remove type hacks in payloadEnvelopeInput.ts --- .../payloadEnvelopeInput.ts | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts index e819293328f6..313b7dce8f74 100644 --- a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts @@ -238,19 +238,36 @@ export class PayloadEnvelopeInput { } getSampledColumns(): gloas.DataColumnSidecars { - return this.sampledColumns - .filter((idx) => this.columnsCache.has(idx)) - .map((idx) => this.columnsCache.get(idx)!.columnSidecar); + const columns: gloas.DataColumnSidecars = []; + for (const index of this.sampledColumns) { + const column = this.columnsCache.get(index); + if (column) { + columns.push(column.columnSidecar); + } + } + return columns; } getSampledColumnsWithSource(): ColumnWithSource[] { - return this.sampledColumns.filter((idx) => this.columnsCache.has(idx)).map((idx) => this.columnsCache.get(idx)!); + const columns: ColumnWithSource[] = []; + for (const index of this.sampledColumns) { + const column = this.columnsCache.get(index); + if (column) { + columns.push(column); + } + } + return columns; } getCustodyColumns(): gloas.DataColumnSidecars { - return this.custodyColumns - .filter((idx) => this.columnsCache.has(idx)) - .map((idx) => this.columnsCache.get(idx)!.columnSidecar); + const columns: gloas.DataColumnSidecars = []; + for (const index of this.custodyColumns) { + const column = this.columnsCache.get(index); + if (column) { + columns.push(column.columnSidecar); + } + } + return columns; } hasComputedAllData(): boolean { From eff762331437e1e2a5f86e3b3ef6364bbe5fe20a Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 20 Mar 2026 15:22:40 +0000 Subject: [PATCH 35/38] always log payload envelope input creation --- .../beacon-node/src/chain/blocks/importBlock.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index 60a80c05dc2b..d7d0d0528b7f 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -149,13 +149,12 @@ export async function importBlock( custodyColumns: this.custodyConfig.custodyColumns, timeCreatedSec: fullyVerifiedBlock.seenTimestampSec, }); - if (opts.seenTimestampSec !== undefined) { - this.logger.debug("Created PayloadEnvelopeInput for gossip block", { - slot: blockSlot, - root: blockRootHex, - recvToImport: Date.now() / 1000 - opts.seenTimestampSec, - }); - } + this.logger.debug("Created PayloadEnvelopeInput for block", { + slot: blockSlot, + root: blockRootHex, + source: source.source, + ...(opts.seenTimestampSec !== undefined ? {recvToImport: Date.now() / 1000 - opts.seenTimestampSec} : {}), + }); } this.metrics?.importBlock.bySource.inc({source: source.source}); From 1cdc69336de2aafeb937f2baa724cab8b8e68f54 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 20 Mar 2026 15:34:25 +0000 Subject: [PATCH 36/38] Fix order of gossip checks --- .../validation/executionPayloadEnvelope.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts index e41b895c9e38..d57d6422aefd 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts @@ -46,23 +46,23 @@ async function validateExecutionPayloadEnvelope( }); } - // [IGNORE] The node has not seen another valid - // `SignedExecutionPayloadEnvelope` for this block root from this builder. - const envelopeBlock = chain.forkChoice.getBlockHex(blockRootHex, PayloadStatus.FULL); const payloadInput = chain.seenPayloadEnvelopeInput.get(blockRootHex); - if (envelopeBlock || payloadInput?.hasPayloadEnvelope()) { + if (!payloadInput) { + // PayloadEnvelopeInput should have been created during block import throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { - code: ExecutionPayloadEnvelopeErrorCode.ENVELOPE_ALREADY_KNOWN, + code: ExecutionPayloadEnvelopeErrorCode.PAYLOAD_ENVELOPE_INPUT_MISSING, blockRoot: blockRootHex, - slot: envelope.slot, }); } - if (!payloadInput) { - // PayloadEnvelopeInput should have been created during block import + // [IGNORE] The node has not seen another valid + // `SignedExecutionPayloadEnvelope` for this block root from this builder. + const envelopeBlock = chain.forkChoice.getBlockHex(blockRootHex, PayloadStatus.FULL); + if (envelopeBlock || payloadInput.hasPayloadEnvelope()) { throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { - code: ExecutionPayloadEnvelopeErrorCode.PAYLOAD_ENVELOPE_INPUT_MISSING, + code: ExecutionPayloadEnvelopeErrorCode.ENVELOPE_ALREADY_KNOWN, blockRoot: blockRootHex, + slot: envelope.slot, }); } From 4946033f508363202412cfc6869bd613c891e617 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 20 Mar 2026 15:38:02 +0000 Subject: [PATCH 37/38] Revert "Fix order of gossip checks" This reverts commit 1cdc69336de2aafeb937f2baa724cab8b8e68f54. ah I think this was correct cc @twoeths, it doesn't change much it will be either way treated as `IGNORE` but if we pruned `payloadInput` then we would use code `PAYLOAD_ENVELOPE_INPUT_MISSING` even though it should be `ENVELOPE_ALREADY_KNOWN`, I guess that was the initial concern I raised. So I am reverting this commit. --- .../validation/executionPayloadEnvelope.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts index d57d6422aefd..e41b895c9e38 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts @@ -46,23 +46,23 @@ async function validateExecutionPayloadEnvelope( }); } + // [IGNORE] The node has not seen another valid + // `SignedExecutionPayloadEnvelope` for this block root from this builder. + const envelopeBlock = chain.forkChoice.getBlockHex(blockRootHex, PayloadStatus.FULL); const payloadInput = chain.seenPayloadEnvelopeInput.get(blockRootHex); - if (!payloadInput) { - // PayloadEnvelopeInput should have been created during block import + if (envelopeBlock || payloadInput?.hasPayloadEnvelope()) { throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { - code: ExecutionPayloadEnvelopeErrorCode.PAYLOAD_ENVELOPE_INPUT_MISSING, + code: ExecutionPayloadEnvelopeErrorCode.ENVELOPE_ALREADY_KNOWN, blockRoot: blockRootHex, + slot: envelope.slot, }); } - // [IGNORE] The node has not seen another valid - // `SignedExecutionPayloadEnvelope` for this block root from this builder. - const envelopeBlock = chain.forkChoice.getBlockHex(blockRootHex, PayloadStatus.FULL); - if (envelopeBlock || payloadInput.hasPayloadEnvelope()) { + if (!payloadInput) { + // PayloadEnvelopeInput should have been created during block import throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { - code: ExecutionPayloadEnvelopeErrorCode.ENVELOPE_ALREADY_KNOWN, + code: ExecutionPayloadEnvelopeErrorCode.PAYLOAD_ENVELOPE_INPUT_MISSING, blockRoot: blockRootHex, - slot: envelope.slot, }); } From 81b5cd244883d714cf1cfe8a2e8ff4d5fe8515f2 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 20 Mar 2026 15:50:29 +0000 Subject: [PATCH 38/38] Adjust naming to match seenBlockInputCache --- packages/beacon-node/src/api/impl/beacon/blocks/index.ts | 2 +- packages/beacon-node/src/chain/blocks/importBlock.ts | 2 +- .../src/chain/blocks/writePayloadEnvelopeInputToDb.ts | 2 +- packages/beacon-node/src/chain/chain.ts | 4 ++-- packages/beacon-node/src/chain/interface.ts | 2 +- .../src/chain/validation/executionPayloadEnvelope.ts | 2 +- packages/beacon-node/src/network/processor/gossipHandlers.ts | 2 +- 7 files changed, 8 insertions(+), 8 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 684d5d12117a..ad216e5a3b72 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -704,7 +704,7 @@ export function getBeaconBlockApi({ // TODO GLOAS: if block and payload are submitted in parallel, payloadInput may not yet exist. // A queuing mechanism is needed to handle this case. See https://github.com/ChainSafe/lodestar/issues/8915 - const payloadInput = chain.seenPayloadEnvelopeInput.get(blockRootHex); + const payloadInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex); if (!payloadInput) { throw new ApiError(404, `PayloadEnvelopeInput not found for block root ${blockRootHex}`); } diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index d7d0d0528b7f..ef6980e374fe 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -142,7 +142,7 @@ export async function importBlock( // For Gloas blocks, create PayloadEnvelopeInput so it's available for later payload import if (fork >= ForkSeq.gloas) { - this.seenPayloadEnvelopeInput.add({ + this.seenPayloadEnvelopeInputCache.add({ blockRootHex, block: block as SignedBeaconBlock, sampledColumns: this.custodyConfig.sampledColumns, diff --git a/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts b/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts index 23add75bdcc5..425bd83adca8 100644 --- a/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts +++ b/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts @@ -46,7 +46,7 @@ export async function persistPayloadEnvelopeInput( ); }) .finally(() => { - this.seenPayloadEnvelopeInput.prune(payloadInput.blockRootHex); + this.seenPayloadEnvelopeInputCache.prune(payloadInput.blockRootHex); this.logger.debug("Pruned payload envelope input", { slot: payloadInput.slot, root: payloadInput.blockRootHex, diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 80b817c116b5..fc6d683c197c 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -205,7 +205,7 @@ export class BeaconChain implements IBeaconChain { readonly seenContributionAndProof: SeenContributionAndProof; readonly seenAttestationDatas: SeenAttestationDatas; readonly seenBlockInputCache: SeenBlockInput; - readonly seenPayloadEnvelopeInput: SeenPayloadEnvelopeInput; + readonly seenPayloadEnvelopeInputCache: SeenPayloadEnvelopeInput; // Seen cache for liveness checks readonly seenBlockAttesters = new SeenBlockAttesters(); @@ -347,7 +347,7 @@ export class BeaconChain implements IBeaconChain { metrics, logger, }); - this.seenPayloadEnvelopeInput = new SeenPayloadEnvelopeInput({ + this.seenPayloadEnvelopeInputCache = new SeenPayloadEnvelopeInput({ chainEvents: emitter, signal, serializedCache: this.serializedCache, diff --git a/packages/beacon-node/src/chain/interface.ts b/packages/beacon-node/src/chain/interface.ts index 087f2a7ad669..f9d78cb8d51a 100644 --- a/packages/beacon-node/src/chain/interface.ts +++ b/packages/beacon-node/src/chain/interface.ts @@ -134,7 +134,7 @@ export interface IBeaconChain { readonly seenContributionAndProof: SeenContributionAndProof; readonly seenAttestationDatas: SeenAttestationDatas; readonly seenBlockInputCache: SeenBlockInput; - readonly seenPayloadEnvelopeInput: SeenPayloadEnvelopeInput; + readonly seenPayloadEnvelopeInputCache: SeenPayloadEnvelopeInput; // Seen cache for liveness checks readonly seenBlockAttesters: SeenBlockAttesters; diff --git a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts index e41b895c9e38..c9715d59cb68 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts @@ -49,7 +49,7 @@ async function validateExecutionPayloadEnvelope( // [IGNORE] The node has not seen another valid // `SignedExecutionPayloadEnvelope` for this block root from this builder. const envelopeBlock = chain.forkChoice.getBlockHex(blockRootHex, PayloadStatus.FULL); - const payloadInput = chain.seenPayloadEnvelopeInput.get(blockRootHex); + const payloadInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex); if (envelopeBlock || payloadInput?.hasPayloadEnvelope()) { throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { code: ExecutionPayloadEnvelopeErrorCode.ENVELOPE_ALREADY_KNOWN, diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index d485768ef3d3..2dc0aab0f262 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -850,7 +850,7 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand chain.validatorMonitor?.registerExecutionPayloadEnvelope(OpSource.gossip, delaySec, executionPayloadEnvelope); const blockRootHex = toRootHex(executionPayloadEnvelope.message.beaconBlockRoot); - const payloadInput = chain.seenPayloadEnvelopeInput.get(blockRootHex); + const payloadInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex); if (!payloadInput) { // This shouldn't happen because beacon block should have been imported and thus payload input should have been created.