From 71354ee0ab628461f5ec19e4961c94b040990fb3 Mon Sep 17 00:00:00 2001 From: Cayman Date: Mon, 20 Apr 2026 19:35:17 -0400 Subject: [PATCH 01/67] feat: add unknown payload envelope sync flow --- .../src/api/impl/beacon/blocks/index.ts | 12 +- .../blocks/payloadEnvelopeInput/index.ts | 1 + .../recoverPayloadEnvelopeInput.ts | 47 + .../seenCache/seenPayloadEnvelopeInput.ts | 3 +- .../validation/executionPayloadEnvelope.ts | 15 +- .../src/metrics/metrics/lodestar.ts | 4 + .../src/network/processor/gossipHandlers.ts | 21 +- packages/beacon-node/src/sync/types.ts | 72 ++ packages/beacon-node/src/sync/unknownBlock.ts | 823 +++++++++++- .../test/unit/sync/unknownBlock.test.ts | 1125 ++++++++++++++++- 10 files changed, 2043 insertions(+), 80 deletions(-) create mode 100644 packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/recoverPayloadEnvelopeInput.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 fa8e523d3f1c..4876bbe15146 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,10 @@ 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.js"; +import { + PayloadEnvelopeInputSource, + getOrRecoverPayloadEnvelopeInput, +} 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"; @@ -311,7 +314,10 @@ export function getBeaconBlockApi({ chain .processBlock(blockForImport, opts) .catch((e) => { - if (e instanceof BlockError && e.type.code === BlockErrorCode.PARENT_UNKNOWN) { + if ( + e instanceof BlockError && + (e.type.code === BlockErrorCode.PARENT_UNKNOWN || e.type.code === BlockErrorCode.PARENT_PAYLOAD_UNKNOWN) + ) { chain.emitter.emit(ChainEvent.blockUnknownParent, { blockInput: blockForImport, peer: IDENTITY_PEER_ID, @@ -713,7 +719,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.seenPayloadEnvelopeInputCache.get(blockRootHex); + const payloadInput = await getOrRecoverPayloadEnvelopeInput(chain, blockRootHex); if (!payloadInput) { throw new ApiError(404, `PayloadEnvelopeInput not found for block root ${blockRootHex}`); } diff --git a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/index.ts b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/index.ts index 368f98324f22..c99136eb0de2 100644 --- a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/index.ts +++ b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/index.ts @@ -1,2 +1,3 @@ export * from "./payloadEnvelopeInput.js"; +export * from "./recoverPayloadEnvelopeInput.js"; export * from "./types.js"; diff --git a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/recoverPayloadEnvelopeInput.ts b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/recoverPayloadEnvelopeInput.ts new file mode 100644 index 000000000000..0c79e3e88e03 --- /dev/null +++ b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/recoverPayloadEnvelopeInput.ts @@ -0,0 +1,47 @@ +import {ForkPostGloas, isForkPostGloas} from "@lodestar/params"; +import {RootHex, SignedBeaconBlock} from "@lodestar/types"; +import {IBeaconChain} from "../../interface.js"; +import {PayloadEnvelopeInput} from "./payloadEnvelopeInput.js"; + +/** + * Rebuild a missing PayloadEnvelopeInput for an already-imported block. + * + * This keeps the seen cache disposable: callers can recover the bid metadata needed for + * late payload envelopes / data columns after the cache entry was pruned. + */ +export async function getOrRecoverPayloadEnvelopeInput( + chain: IBeaconChain, + blockRootHex: RootHex +): Promise { + const cachedInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex); + if (cachedInput) { + return cachedInput; + } + + const blockResult = await chain.getBlockByRoot(blockRootHex); + if (!blockResult) { + return undefined; + } + + const forkName = chain.config.getForkName(blockResult.block.message.slot); + if (!isForkPostGloas(forkName)) { + return undefined; + } + + try { + return chain.seenPayloadEnvelopeInputCache.add({ + blockRootHex, + block: blockResult.block as SignedBeaconBlock, + forkName, + sampledColumns: chain.custodyConfig.sampledColumns, + custodyColumns: chain.custodyConfig.custodyColumns, + timeCreatedSec: Date.now() / 1000, + }); + } catch (e) { + const recoveredInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex); + if (recoveredInput) { + return recoveredInput; + } + throw e; + } +} diff --git a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts index e36147638061..98b76f853c40 100644 --- a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts @@ -4,7 +4,8 @@ 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 {PayloadEnvelopeInput} from "../blocks/payloadEnvelopeInput/payloadEnvelopeInput.js"; +import {CreateFromBlockProps} from "../blocks/payloadEnvelopeInput/types.js"; import {ChainEvent, ChainEventEmitter} from "../emitter.js"; export type {PayloadEnvelopeInputState} from "../blocks/payloadEnvelopeInput/index.js"; diff --git a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts index 256b5f567b7a..6690e3c8283b 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts @@ -6,6 +6,7 @@ import { } from "@lodestar/state-transition"; import {gloas} from "@lodestar/types"; import {toRootHex} from "@lodestar/utils"; +import {getOrRecoverPayloadEnvelopeInput} from "../blocks/payloadEnvelopeInput/index.js"; import {ExecutionPayloadEnvelopeError, ExecutionPayloadEnvelopeErrorCode, GossipAction} from "../errors/index.js"; import {IBeaconChain} from "../index.js"; import {RegenCaller} from "../regen/index.js"; @@ -48,8 +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.seenPayloadEnvelopeInputCache.get(blockRootHex); - if (envelopeBlock || payloadInput?.hasPayloadEnvelope()) { + if (envelopeBlock) { throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { code: ExecutionPayloadEnvelopeErrorCode.ENVELOPE_ALREADY_KNOWN, blockRoot: blockRootHex, @@ -57,14 +57,23 @@ async function validateExecutionPayloadEnvelope( }); } + const payloadInput = await getOrRecoverPayloadEnvelopeInput(chain, blockRootHex); if (!payloadInput) { - // PayloadEnvelopeInput should have been created during block import + // PayloadEnvelopeInput should be recoverable from a known block. throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { code: ExecutionPayloadEnvelopeErrorCode.PAYLOAD_ENVELOPE_INPUT_MISSING, blockRoot: blockRootHex, }); } + if (payloadInput.hasPayloadEnvelope()) { + throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { + code: ExecutionPayloadEnvelopeErrorCode.ENVELOPE_ALREADY_KNOWN, + blockRoot: blockRootHex, + slot: envelope.slot, + }); + } + // [IGNORE] The envelope is from a slot greater than or equal to the latest finalized slot -- i.e. validate that `envelope.slot >= compute_start_slot_at_epoch(store.finalized_checkpoint.epoch)` const finalizedCheckpoint = chain.forkChoice.getFinalizedCheckpoint(); const finalizedSlot = computeStartSlotAtEpoch(finalizedCheckpoint.epoch); diff --git a/packages/beacon-node/src/metrics/metrics/lodestar.ts b/packages/beacon-node/src/metrics/metrics/lodestar.ts index 358054bdd8e9..44df4161692d 100644 --- a/packages/beacon-node/src/metrics/metrics/lodestar.ts +++ b/packages/beacon-node/src/metrics/metrics/lodestar.ts @@ -613,6 +613,10 @@ export function createLodestarMetrics( name: "lodestar_sync_unknown_block_pending_blocks_size", help: "Current size of UnknownBlockSync pending blocks cache", }), + pendingPayloads: register.gauge({ + name: "lodestar_sync_unknown_block_pending_payloads_size", + help: "Current size of UnknownBlockSync pending payloads cache", + }), knownBadBlocks: register.gauge({ name: "lodestar_sync_unknown_block_known_bad_blocks_size", help: "Current size of UnknownBlockSync known bad blocks cache", diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index 32debf5db525..4591c1af0b10 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -35,7 +35,11 @@ import { IBlockInput, isBlockInputColumns, } from "../../chain/blocks/blockInput/index.js"; -import {PayloadEnvelopeInput, PayloadEnvelopeInputSource} from "../../chain/blocks/payloadEnvelopeInput/index.js"; +import { + PayloadEnvelopeInput, + PayloadEnvelopeInputSource, + getOrRecoverPayloadEnvelopeInput, +} from "../../chain/blocks/payloadEnvelopeInput/index.js"; import {BlobSidecarValidation} from "../../chain/blocks/types.js"; import {ChainEvent} from "../../chain/emitter.js"; import { @@ -198,7 +202,10 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand } catch (e) { if (e instanceof BlockGossipError) { logger.debug("Gossip block has error", {slot, root: blockShortHex, code: e.type.code}); - if (e.type.code === BlockErrorCode.PARENT_UNKNOWN && blockInput) { + if ( + (e.type.code === BlockErrorCode.PARENT_UNKNOWN || e.type.code === BlockErrorCode.PARENT_PAYLOAD_UNKNOWN) && + blockInput + ) { chain.emitter.emit(ChainEvent.blockUnknownParent, { blockInput, peer: peerIdStr, @@ -439,11 +446,11 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand }); } - const payloadInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex); + const payloadInput = await getOrRecoverPayloadEnvelopeInput(chain, blockRootHex); if (!payloadInput) { - // This should not happen for gossip because the network processor queues `data_column_sidecar` - // until block import creates the corresponding PayloadEnvelopeInput. + // This should not happen for gossip because the block should already be known by the time + // payload columns are processed, and the PayloadEnvelopeInput is recoverable from that block. throw new DataColumnSidecarGossipError(GossipAction.IGNORE, { code: DataColumnSidecarErrorCode.PAYLOAD_ENVELOPE_INPUT_MISSING, slot, @@ -1094,10 +1101,10 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand chain.validatorMonitor?.registerExecutionPayloadEnvelope(OpSource.gossip, delaySec, signedEnvelope); const blockRootHex = toRootHex(envelope.beaconBlockRoot); - const payloadInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex); + const payloadInput = await getOrRecoverPayloadEnvelopeInput(chain, blockRootHex); if (!payloadInput) { - // This shouldn't happen because beacon block should have been imported and thus payload input should have been created. + // This shouldn't happen because the block should already be known and its payload input is recoverable. throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { code: ExecutionPayloadEnvelopeErrorCode.PAYLOAD_ENVELOPE_INPUT_MISSING, blockRoot: blockRootHex, diff --git a/packages/beacon-node/src/sync/types.ts b/packages/beacon-node/src/sync/types.ts index ca36095c5ca8..d2ddb22a79eb 100644 --- a/packages/beacon-node/src/sync/types.ts +++ b/packages/beacon-node/src/sync/types.ts @@ -1,5 +1,8 @@ import {RootHex, Slot} from "@lodestar/types"; +import {SignedExecutionPayloadEnvelope} from "@lodestar/types/gloas"; +import {toRootHex} from "@lodestar/utils"; import {IBlockInput} from "../chain/blocks/blockInput/index.js"; +import {PayloadEnvelopeInput} from "../chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.js"; export enum PendingBlockType { /** @@ -26,6 +29,14 @@ export enum PendingBlockInputStatus { processing = "processing", } +export enum PendingPayloadInputStatus { + pending = "pending", + fetching = "fetching", + waitingForBlock = "waiting_for_block", + downloaded = "downloaded", + processing = "processing", +} + export type PendingBlockInput = { status: PendingBlockInputStatus; blockInput: IBlockInput; @@ -44,10 +55,47 @@ export type PendingRootHex = { export type BlockInputSyncCacheItem = PendingBlockInput | PendingRootHex; +export type PendingPayloadInput = { + status: + | PendingPayloadInputStatus.pending + | PendingPayloadInputStatus.fetching + | PendingPayloadInputStatus.downloaded + | PendingPayloadInputStatus.processing; + payloadInput: PayloadEnvelopeInput; + timeAddedSec: number; + timeSyncedSec?: number; + peerIdStrings: Set; +}; + +export type PendingPayloadRootHex = { + status: PendingPayloadInputStatus.pending | PendingPayloadInputStatus.fetching; + rootHex: RootHex; + timeAddedSec: number; + timeSyncedSec?: number; + peerIdStrings: Set; +}; + +export type PendingPayloadEnvelope = { + status: PendingPayloadInputStatus.waitingForBlock; + envelope: SignedExecutionPayloadEnvelope; + timeAddedSec: number; + peerIdStrings: Set; +}; + +export type PayloadSyncCacheItem = PendingPayloadInput | PendingPayloadRootHex | PendingPayloadEnvelope; + export function isPendingBlockInput(pending: BlockInputSyncCacheItem): pending is PendingBlockInput { return "blockInput" in pending; } +export function isPendingPayloadInput(pending: PayloadSyncCacheItem): pending is PendingPayloadInput { + return "payloadInput" in pending; +} + +export function isPendingPayloadEnvelope(pending: PayloadSyncCacheItem): pending is PendingPayloadEnvelope { + return "envelope" in pending; +} + export function getBlockInputSyncCacheItemRootHex(block: BlockInputSyncCacheItem): RootHex { return isPendingBlockInput(block) ? block.blockInput.blockRootHex : block.rootHex; } @@ -55,3 +103,27 @@ export function getBlockInputSyncCacheItemRootHex(block: BlockInputSyncCacheItem export function getBlockInputSyncCacheItemSlot(block: BlockInputSyncCacheItem): Slot | string { return isPendingBlockInput(block) ? block.blockInput.slot : "unknown"; } + +export function getPayloadSyncCacheItemRootHex(payload: PayloadSyncCacheItem): RootHex { + if (isPendingPayloadInput(payload)) { + return payload.payloadInput.blockRootHex; + } + + if (isPendingPayloadEnvelope(payload)) { + return toRootHex(payload.envelope.message.beaconBlockRoot); + } + + return payload.rootHex; +} + +export function getPayloadSyncCacheItemSlot(payload: PayloadSyncCacheItem): Slot | string { + if (isPendingPayloadInput(payload)) { + return payload.payloadInput.slot; + } + + if (isPendingPayloadEnvelope(payload)) { + return payload.envelope.message.slot; + } + + return "unknown"; +} diff --git a/packages/beacon-node/src/sync/unknownBlock.ts b/packages/beacon-node/src/sync/unknownBlock.ts index 4875911f28b6..5cf36067d510 100644 --- a/packages/beacon-node/src/sync/unknownBlock.ts +++ b/packages/beacon-node/src/sync/unknownBlock.ts @@ -1,13 +1,22 @@ +import {routes} from "@lodestar/api"; import {ChainForkConfig} from "@lodestar/config"; import {ForkSeq} from "@lodestar/params"; import {RequestError, RequestErrorCode} from "@lodestar/reqresp"; import {computeTimeAtSlot} from "@lodestar/state-transition"; -import {RootHex} from "@lodestar/types"; -import {Logger, prettyPrintIndices, pruneSetToMax, sleep} from "@lodestar/utils"; +import {RootHex, gloas} from "@lodestar/types"; +import {Logger, fromHex, prettyPrintIndices, pruneSetToMax, sleep, toRootHex} from "@lodestar/utils"; import {isBlockInputBlobs, isBlockInputColumns} from "../chain/blocks/blockInput/blockInput.js"; import {BlockInputSource, IBlockInput} from "../chain/blocks/blockInput/types.js"; +import {PayloadError, PayloadErrorCode} from "../chain/blocks/importExecutionPayload.js"; +import { + PayloadEnvelopeInput, + PayloadEnvelopeInputSource, + getOrRecoverPayloadEnvelopeInput, +} from "../chain/blocks/payloadEnvelopeInput/index.js"; import {BlockError, BlockErrorCode} from "../chain/errors/index.js"; import {ChainEvent, ChainEventData, IBeaconChain} from "../chain/index.js"; +import {validateGloasBlockDataColumnSidecars} from "../chain/validation/dataColumnSidecar.js"; +import {validateGossipExecutionPayloadEnvelope} from "../chain/validation/executionPayloadEnvelope.js"; import {Metrics} from "../metrics/index.js"; import {INetwork, NetworkEvent, NetworkEventData, prettyPrintPeerIdStr} from "../network/index.js"; import {PeerSyncMeta} from "../network/peers/peersData.js"; @@ -19,20 +28,31 @@ import {MAX_CONCURRENT_REQUESTS} from "./constants.js"; import {SyncOptions} from "./options.js"; import { BlockInputSyncCacheItem, + PayloadSyncCacheItem, PendingBlockInput, PendingBlockInputStatus, PendingBlockType, + PendingPayloadEnvelope, + PendingPayloadInput, + PendingPayloadInputStatus, + PendingPayloadRootHex, getBlockInputSyncCacheItemRootHex, getBlockInputSyncCacheItemSlot, + getPayloadSyncCacheItemRootHex, + getPayloadSyncCacheItemSlot, isPendingBlockInput, + isPendingPayloadEnvelope, + isPendingPayloadInput, } from "./types.js"; import {DownloadByRootError, downloadByRoot} from "./utils/downloadByRoot.js"; -import {getAllDescendantBlocks, getDescendantBlocks, getUnknownAndAncestorBlocks} from "./utils/pendingBlocksTree.js"; +import {getAllDescendantBlocks, getUnknownAndAncestorBlocks} from "./utils/pendingBlocksTree.js"; const MAX_ATTEMPTS_PER_BLOCK = 5; const MAX_KNOWN_BAD_BLOCKS = 500; const MAX_PENDING_BLOCKS = 100; +type AdvancePendingBlockResult = "ready" | "queued_parent_block" | "queued_parent_payload" | "blocked" | "removed"; + enum FetchResult { SuccessResolved = "success_resolved", SuccessMissingParent = "success_missing_parent", @@ -78,6 +98,7 @@ export class BlockInputSync { * block RootHex -> PendingBlock. To avoid finding same root at the same time */ private readonly pendingBlocks = new Map(); + private readonly pendingPayloads = new Map(); private readonly knownBadBlocks = new Set(); private readonly maxPendingBlocks; private subscribedToNetworkEvents = false; @@ -98,6 +119,9 @@ export class BlockInputSync { metrics.blockInputSync.pendingBlocks.addCollect(() => metrics.blockInputSync.pendingBlocks.set(this.pendingBlocks.size) ); + metrics.blockInputSync.pendingPayloads.addCollect(() => + metrics.blockInputSync.pendingPayloads.set(this.pendingPayloads.size) + ); metrics.blockInputSync.knownBadBlocks.addCollect(() => metrics.blockInputSync.knownBadBlocks.set(this.knownBadBlocks.size) ); @@ -114,8 +138,13 @@ export class BlockInputSync { if (!this.subscribedToNetworkEvents) { this.logger.verbose("BlockInputSync enabled."); this.chain.emitter.on(ChainEvent.unknownBlockRoot, this.onUnknownBlockRoot); + this.chain.emitter.on(ChainEvent.unknownEnvelopeBlockRoot, this.onUnknownEnvelopeBlockRoot); this.chain.emitter.on(ChainEvent.incompleteBlockInput, this.onIncompleteBlockInput); + this.chain.emitter.on(ChainEvent.incompletePayloadEnvelope, this.onIncompletePayloadEnvelope); this.chain.emitter.on(ChainEvent.blockUnknownParent, this.onUnknownParent); + this.chain.emitter.on(ChainEvent.envelopeUnknownBlock, this.onEnvelopeUnknownBlock); + this.chain.emitter.on(routes.events.EventType.block, this.onBlockImported); + this.chain.emitter.on(routes.events.EventType.executionPayload, this.onPayloadImported); this.network.events.on(NetworkEvent.peerConnected, this.onPeerConnected); this.network.events.on(NetworkEvent.peerDisconnected, this.onPeerDisconnected); this.subscribedToNetworkEvents = true; @@ -125,8 +154,13 @@ export class BlockInputSync { unsubscribeFromNetwork(): void { this.logger.verbose("BlockInputSync disabled."); this.chain.emitter.off(ChainEvent.unknownBlockRoot, this.onUnknownBlockRoot); + this.chain.emitter.off(ChainEvent.unknownEnvelopeBlockRoot, this.onUnknownEnvelopeBlockRoot); this.chain.emitter.off(ChainEvent.incompleteBlockInput, this.onIncompleteBlockInput); + this.chain.emitter.off(ChainEvent.incompletePayloadEnvelope, this.onIncompletePayloadEnvelope); this.chain.emitter.off(ChainEvent.blockUnknownParent, this.onUnknownParent); + this.chain.emitter.off(ChainEvent.envelopeUnknownBlock, this.onEnvelopeUnknownBlock); + this.chain.emitter.off(routes.events.EventType.block, this.onBlockImported); + this.chain.emitter.off(routes.events.EventType.executionPayload, this.onPayloadImported); this.network.events.off(NetworkEvent.peerConnected, this.onPeerConnected); this.network.events.off(NetworkEvent.peerDisconnected, this.onPeerDisconnected); this.subscribedToNetworkEvents = false; @@ -168,12 +202,55 @@ export class BlockInputSync { } }; + private onUnknownEnvelopeBlockRoot = (data: ChainEventData[ChainEvent.unknownEnvelopeBlockRoot]): void => { + try { + this.addByPayloadRootHex(data.rootHex, data.peer); + this.triggerUnknownBlockSearch(); + this.metrics?.blockInputSync.requests.inc({type: PendingBlockType.UNKNOWN_DATA}); + this.metrics?.blockInputSync.source.inc({source: data.source}); + } catch (e) { + this.logger.debug("Error handling unknownEnvelopeBlockRoot event", {}, e as Error); + } + }; + + private onIncompletePayloadEnvelope = (data: ChainEventData[ChainEvent.incompletePayloadEnvelope]): void => { + try { + this.addByPayloadInput(data.payloadInput, data.peer); + this.triggerUnknownBlockSearch(); + this.metrics?.blockInputSync.requests.inc({type: PendingBlockType.UNKNOWN_DATA}); + this.metrics?.blockInputSync.source.inc({source: data.source}); + } catch (e) { + this.logger.debug("Error handling incompletePayloadEnvelope event", {}, e as Error); + } + }; + /** * Process an unknownBlockParent event and register the block in `pendingBlocks` Map. */ private onUnknownParent = (data: ChainEventData[ChainEvent.blockUnknownParent]): void => { try { - this.addByRootHex(data.blockInput.parentRootHex, data.peer); + const missingDependency = this.getMissingBlockDependency(data.blockInput); + if (missingDependency.kind === "invalidParentPayload") { + this.addByBlockInput(data.blockInput, data.peer); + + const pendingBlock = this.pendingBlocks.get(data.blockInput.blockRootHex); + if (pendingBlock && isPendingBlockInput(pendingBlock)) { + this.logger.debug("Ignoring block with conflicting parent payload hash", { + slot: pendingBlock.blockInput.slot, + root: pendingBlock.blockInput.blockRootHex, + parentRoot: missingDependency.parentRootHex, + parentBlockHash: missingDependency.parentBlockHashHex, + }); + this.removeAndDownScoreAllDescendants(pendingBlock); + } + return; + } + + if (missingDependency.kind === "parentPayload") { + this.addByPayloadRootHex(missingDependency.rootHex, data.peer); + } else if (missingDependency.kind === "parentBlock") { + this.addByRootHex(missingDependency.rootHex, data.peer); + } this.addByBlockInput(data.blockInput, data.peer); this.triggerUnknownBlockSearch(); this.metrics?.blockInputSync.requests.inc({type: PendingBlockType.UNKNOWN_PARENT}); @@ -183,8 +260,35 @@ export class BlockInputSync { } }; - private addByRootHex = (rootHex: RootHex, peerIdStr?: PeerIdStr): void => { + private onEnvelopeUnknownBlock = (data: ChainEventData[ChainEvent.envelopeUnknownBlock]): void => { + try { + const blockRootHex = toRootHex(data.envelope.message.beaconBlockRoot); + this.addByRootHex(blockRootHex, data.peer); + this.addByPayloadEnvelope(data.envelope, data.peer); + this.triggerUnknownBlockSearch(); + this.metrics?.blockInputSync.requests.inc({type: PendingBlockType.UNKNOWN_DATA}); + this.metrics?.blockInputSync.source.inc({source: data.source}); + } catch (e) { + this.logger.debug("Error handling envelopeUnknownBlock event", {}, e as Error); + } + }; + + private onBlockImported = (): void => { + if (this.pendingPayloads.size > 0) { + this.triggerUnknownBlockSearch(); + } + }; + + private onPayloadImported = ({ + blockRoot, + }: routes.events.EventData[routes.events.EventType.executionPayload]): void => { + this.pendingPayloads.delete(blockRoot); + this.triggerUnknownBlockSearch(); + }; + + private addByRootHex = (rootHex: RootHex, peerIdStr?: PeerIdStr): boolean => { let pendingBlock = this.pendingBlocks.get(rootHex); + let added = false; if (!pendingBlock) { pendingBlock = { status: PendingBlockInputStatus.pending, @@ -193,6 +297,7 @@ export class BlockInputSync { timeAddedSec: Date.now() / 1000, }; this.pendingBlocks.set(rootHex, pendingBlock); + added = true; this.logger.verbose("Added new rootHex to BlockInputSync.pendingBlocks", { root: pendingBlock.rootHex, @@ -210,6 +315,7 @@ export class BlockInputSync { if (prunedItemCount > 0) { this.logger.verbose(`Pruned ${prunedItemCount} items from BlockInputSync.pendingBlocks`); } + return added; }; private addByBlockInput = (blockInput: IBlockInput, peerIdStr?: string): void => { @@ -242,6 +348,89 @@ export class BlockInputSync { } }; + private addByPayloadRootHex = (rootHex: RootHex, peerIdStr?: PeerIdStr): boolean => { + let pendingPayload = this.pendingPayloads.get(rootHex); + let added = false; + if (!pendingPayload) { + pendingPayload = { + status: PendingPayloadInputStatus.pending, + rootHex, + peerIdStrings: new Set(), + timeAddedSec: Date.now() / 1000, + }; + this.pendingPayloads.set(rootHex, pendingPayload); + added = true; + + this.logger.verbose("Added new payload rootHex to BlockInputSync.pendingPayloads", { + root: rootHex, + peerIdStr: peerIdStr ?? "unknown peer", + }); + } + + if (peerIdStr) { + pendingPayload.peerIdStrings.add(peerIdStr); + } + + const prunedItemCount = pruneSetToMax(this.pendingPayloads, this.maxPendingBlocks); + if (prunedItemCount > 0) { + this.logger.verbose(`Pruned ${prunedItemCount} items from BlockInputSync.pendingPayloads`); + } + return added; + }; + + private addByPayloadEnvelope = (envelope: gloas.SignedExecutionPayloadEnvelope, peerIdStr?: PeerIdStr): void => { + const rootHex = toRootHex(envelope.message.beaconBlockRoot); + const existingPendingPayload = this.pendingPayloads.get(rootHex); + let pendingPayload = this.pendingPayloads.get(rootHex); + if (!pendingPayload || !isPendingPayloadEnvelope(pendingPayload)) { + pendingPayload = { + status: PendingPayloadInputStatus.waitingForBlock, + envelope, + peerIdStrings: new Set(existingPendingPayload?.peerIdStrings ?? []), + timeAddedSec: existingPendingPayload?.timeAddedSec ?? Date.now() / 1000, + }; + this.pendingPayloads.set(rootHex, pendingPayload); + + this.logger.verbose("Added payload envelope to BlockInputSync.pendingPayloads", { + slot: envelope.message.slot, + root: rootHex, + }); + } else { + pendingPayload.envelope = envelope; + } + + if (peerIdStr) { + pendingPayload.peerIdStrings.add(peerIdStr); + } + + const prunedItemCount = pruneSetToMax(this.pendingPayloads, this.maxPendingBlocks); + if (prunedItemCount > 0) { + this.logger.verbose(`Pruned ${prunedItemCount} items from BlockInputSync.pendingPayloads`); + } + }; + + private addByPayloadInput = ( + payloadInput: PayloadEnvelopeInput, + peerIdStr?: PeerIdStr, + envelope?: gloas.SignedExecutionPayloadEnvelope + ): void => { + const pendingPayload = this.toPendingPayloadInput( + payloadInput, + this.pendingPayloads.get(payloadInput.blockRootHex), + envelope + ); + + if (peerIdStr) { + pendingPayload.peerIdStrings.add(peerIdStr); + } + + this.pendingPayloads.set(payloadInput.blockRootHex, pendingPayload); + const prunedItemCount = pruneSetToMax(this.pendingPayloads, this.maxPendingBlocks); + if (prunedItemCount > 0) { + this.logger.verbose(`Pruned ${prunedItemCount} items from BlockInputSync.pendingPayloads`); + } + }; + private onPeerConnected = (data: NetworkEventData[NetworkEvent.peerConnected]): void => { try { const peerId = data.peer; @@ -258,52 +447,184 @@ export class BlockInputSync { this.peerBalancer.onPeerDisconnected(peerId); }; + private getMissingBlockDependency( + blockInput: IBlockInput + ): + | {kind: "ready"} + | {kind: "parentBlock" | "parentPayload"; rootHex: RootHex} + | {kind: "invalidParentPayload"; parentRootHex: RootHex; parentBlockHashHex: RootHex} { + const parentRootHex = blockInput.parentRootHex; + if (!this.chain.forkChoice.hasBlockHex(parentRootHex)) { + return {kind: "parentBlock", rootHex: parentRootHex}; + } + + if (this.config.getForkSeq(blockInput.slot) < ForkSeq.gloas) { + return {kind: "ready"}; + } + + if (!blockInput.hasBlock()) { + throw new Error(`Expected blockInput with block for post-gloas dependency check root=${blockInput.blockRootHex}`); + } + + const block = blockInput.getBlock() as gloas.SignedBeaconBlock; + const parentBlockHashHex = toRootHex(block.message.body.signedExecutionPayloadBid.message.parentBlockHash); + if (this.chain.forkChoice.getBlockHexAndBlockHash(parentRootHex, parentBlockHashHex) !== null) { + return {kind: "ready"}; + } + + if (this.chain.forkChoice.hasPayloadHexUnsafe(parentRootHex)) { + return {kind: "invalidParentPayload", parentRootHex, parentBlockHashHex}; + } + + const parentPayloadInput = this.chain.seenPayloadEnvelopeInputCache.get(parentRootHex); + if (parentPayloadInput) { + if (parentPayloadInput.getBlockHashHex() === parentBlockHashHex) { + return {kind: "parentPayload", rootHex: parentRootHex}; + } + + return {kind: "invalidParentPayload", parentRootHex, parentBlockHashHex}; + } + + return {kind: "parentPayload", rootHex: parentRootHex}; + } + + private advancePendingBlock(pendingBlock: PendingBlockInput): AdvancePendingBlockResult { + const missingDependency = this.getMissingBlockDependency(pendingBlock.blockInput); + + switch (missingDependency.kind) { + case "ready": + return "ready"; + + case "parentBlock": { + let added = this.addByRootHex(missingDependency.rootHex); + for (const peerIdStr of pendingBlock.peerIdStrings) { + added = this.addByRootHex(missingDependency.rootHex, peerIdStr) || added; + } + return added ? "queued_parent_block" : "blocked"; + } + + case "parentPayload": { + let added = this.addByPayloadRootHex(missingDependency.rootHex); + for (const peerIdStr of pendingBlock.peerIdStrings) { + added = this.addByPayloadRootHex(missingDependency.rootHex, peerIdStr) || added; + } + return added ? "queued_parent_payload" : "blocked"; + } + + case "invalidParentPayload": + this.logger.debug("Removing block with conflicting parent payload hash", { + slot: pendingBlock.blockInput.slot, + root: pendingBlock.blockInput.blockRootHex, + parentRoot: missingDependency.parentRootHex, + parentBlockHash: missingDependency.parentBlockHashHex, + }); + this.removeAndDownScoreAllDescendants(pendingBlock); + return "removed"; + } + } + + private toPendingPayloadInput( + payloadInput: PayloadEnvelopeInput, + previous?: PayloadSyncCacheItem, + envelope?: gloas.SignedExecutionPayloadEnvelope + ): PendingPayloadInput { + if (envelope && !payloadInput.hasPayloadEnvelope()) { + payloadInput.addPayloadEnvelope({ + envelope, + source: PayloadEnvelopeInputSource.byRoot, + seenTimestampSec: Date.now() / 1000, + }); + } + + return { + status: payloadInput.isComplete() ? PendingPayloadInputStatus.downloaded : PendingPayloadInputStatus.pending, + payloadInput, + timeAddedSec: previous?.timeAddedSec ?? Date.now() / 1000, + timeSyncedSec: payloadInput.isComplete() ? Date.now() / 1000 : undefined, + peerIdStrings: new Set(previous?.peerIdStrings ?? []), + }; + } + /** * Gather tip parent blocks with unknown parent and do a search for all of them */ private triggerUnknownBlockSearch = (): void => { // Cheap early stop to prevent calling the network.getConnectedPeers() - if (this.pendingBlocks.size === 0) { + if (this.pendingBlocks.size === 0 && this.pendingPayloads.size === 0) { return; } - // If the node loses all peers with pending unknown blocks, the sync will stall + // If the node loses all peers with pending unknown blocks or payloads, the sync will stall const connectedPeers = this.network.getConnectedPeers(); - if (connectedPeers.length === 0) { - this.logger.debug("No connected peers, skipping unknown block search."); - return; - } + const hasConnectedPeers = connectedPeers.length > 0; const {unknowns, ancestors} = getUnknownAndAncestorBlocks(this.pendingBlocks); - // it's rare when there is no unknown block - // see https://github.com/ChainSafe/lodestar/issues/5649#issuecomment-1594213550 - if (unknowns.length === 0) { - let processedBlocks = 0; - - for (const block of ancestors) { - // when this happens, it's likely the block and parent block are processed by head sync - if (this.chain.forkChoice.hasBlockHex(block.blockInput.parentRootHex)) { - processedBlocks++; - this.processBlock(block).catch((e) => { - this.logger.debug("Unexpected error - process old downloaded block", {}, e); + let processedBlocks = 0; + let shouldRerunBlockSearch = false; + + for (const block of ancestors) { + const advanceResult = this.advancePendingBlock(block); + if (advanceResult === "ready") { + processedBlocks++; + this.processReadyBlock(block).catch((e) => { + this.logger.debug("Unexpected error - process old downloaded block", {}, e); + }); + } else if (advanceResult === "queued_parent_block") { + shouldRerunBlockSearch = true; + } + } + + if (unknowns.length > 0) { + if (!hasConnectedPeers) { + this.logger.debug("No connected peers, skipping unknown block download."); + } else { + // Most of the time there is exactly 1 unknown block + for (const block of unknowns) { + this.downloadBlock(block).catch((e) => { + this.logger.debug("Unexpected error - downloadBlock", {root: getBlockInputSyncCacheItemRootHex(block)}, e); }); } } - + } else if (ancestors.length > 0) { + // It's rare when there is no unknown block + // see https://github.com/ChainSafe/lodestar/issues/5649#issuecomment-1594213550 this.logger.verbose("No unknown block, process ancestor downloaded blocks", { pendingBlocks: this.pendingBlocks.size, ancestorBlocks: ancestors.length, processedBlocks, }); - return; } - // most of the time there is exactly 1 unknown block - for (const block of unknowns) { - this.downloadBlock(block).catch((e) => { - this.logger.debug("Unexpected error - downloadBlock", {root: getBlockInputSyncCacheItemRootHex(block)}, e); + for (const payload of Array.from(this.pendingPayloads.values())) { + if (isPendingPayloadInput(payload) && payload.status === PendingPayloadInputStatus.downloaded) { + this.processPayload(payload).catch((e) => { + this.logger.debug("Unexpected error - process downloaded payload", {}, e); + }); + continue; + } + + if (isPendingPayloadEnvelope(payload)) { + this.reconcilePayloadEnvelope(payload).catch((e) => { + this.logger.debug("Unexpected error - reconcile pending payload envelope", {}, e); + }); + continue; + } + + if (!hasConnectedPeers) { + this.logger.debug("No connected peers, skipping unknown payload download.", { + root: getPayloadSyncCacheItemRootHex(payload), + }); + continue; + } + + this.downloadPayload(payload).catch((e) => { + this.logger.debug("Unexpected error - downloadPayload", {root: getPayloadSyncCacheItemRootHex(payload)}, e); }); } + + if (shouldRerunBlockSearch) { + this.triggerUnknownBlockSearch(); + } }; private async downloadBlock(block: BlockInputSyncCacheItem): Promise { @@ -342,10 +663,16 @@ export class BlockInputSync { this.logger.verbose("Downloaded unknown block", logCtx2); if (parentInForkChoice) { - // Bingo! Process block. Add to pending blocks anyway for recycle the cache that prevents duplicate processing - this.processBlock(pending).catch((e) => { - this.logger.debug("Unexpected error - process newly downloaded block", logCtx2, e); - }); + // If the direct parent is already in fork choice, let the block state machine decide if + // the next step is block import, parent payload recovery, or branch removal. + const advanceResult = this.advancePendingBlock(pending); + if (advanceResult === "ready") { + this.processReadyBlock(pending).catch((e) => { + this.logger.debug("Unexpected error - process newly downloaded block", logCtx2, e); + }); + } else if (advanceResult === "queued_parent_block" || advanceResult === "queued_parent_payload") { + this.triggerUnknownBlockSearch(); + } } else if (blockSlot <= finalizedSlot) { // the common ancestor of the downloading chain and canonical chain should be at least the finalized slot and // we should found it through forkchoice. If not, we should penalize all peers sending us this block chain @@ -368,26 +695,11 @@ export class BlockInputSync { } /** - * Send block to the processor awaiting completition. If processed successfully, send all children to the processor. - * On error, remove and downscore all descendants. - * This function could run recursively for all descendant blocks + * Import a block that has already passed the local dependency checks in BlockInputSync. + * On error, remove and downscore descendants as appropriate for the failure type. */ - private async processBlock(pendingBlock: PendingBlockInput): Promise { - // pending block status is `downloaded` right after `downloadBlock` - // but could be `pending` if added by `onUnknownBlockParent` event and this function is called recursively + private async processReadyBlock(pendingBlock: PendingBlockInput): Promise { if (pendingBlock.status !== PendingBlockInputStatus.downloaded) { - if (pendingBlock.status === PendingBlockInputStatus.pending) { - const connectedPeers = this.network.getConnectedPeers(); - if (connectedPeers.length === 0) { - this.logger.debug("No connected peers, skipping download block", { - slot: pendingBlock.blockInput.slot, - blockRoot: pendingBlock.blockInput.blockRootHex, - }); - return; - } - // if the download is a success we'll call `processBlock()` for this block - await this.downloadBlock(pendingBlock); - } return; } @@ -432,15 +744,9 @@ export class BlockInputSync { if (!res.err) { // no need to update status to "processed", delete anyway this.pendingBlocks.delete(pendingBlock.blockInput.blockRootHex); - - // Send child blocks to the processor - for (const descendantBlock of getDescendantBlocks(pendingBlock.blockInput.blockRootHex, this.pendingBlocks)) { - if (isPendingBlockInput(descendantBlock)) { - this.processBlock(descendantBlock).catch((e) => { - this.logger.debug("Unexpected error - process descendant block", {}, e); - }); - } - } + // Re-enter the scheduler so descendants blocked on either parent blocks or parent payloads + // are advanced through the same dependency checks as every other pending item. + this.triggerUnknownBlockSearch(); } else { const errorData = {slot: pendingBlock.blockInput.slot, root: pendingBlock.blockInput.blockRootHex}; if (res.err instanceof BlockError) { @@ -456,6 +762,40 @@ export class BlockInputSync { pendingBlock.status = PendingBlockInputStatus.downloaded; break; + case BlockErrorCode.PARENT_PAYLOAD_UNKNOWN: { + const missingDependency = this.getMissingBlockDependency(pendingBlock.blockInput); + if (missingDependency.kind === "invalidParentPayload") { + this.logger.debug( + "Attempted to process block with conflicting parent payload hash", + { + ...errorData, + parentRoot: missingDependency.parentRootHex, + parentBlockHash: missingDependency.parentBlockHashHex, + }, + res.err + ); + this.removeAndDownScoreAllDescendants(pendingBlock); + break; + } + + this.logger.debug( + "Attempted to process block but its parent payload was still unknown", + errorData, + res.err + ); + for (const peerIdStr of pendingBlock.peerIdStrings) { + this.addByPayloadRootHex( + missingDependency.kind === "parentPayload" + ? missingDependency.rootHex + : pendingBlock.blockInput.parentRootHex, + peerIdStr + ); + } + pendingBlock.status = PendingBlockInputStatus.downloaded; + this.triggerUnknownBlockSearch(); + break; + } + case BlockErrorCode.EXECUTION_ENGINE_ERROR: // Removing the block(s) without penalizing the peers, hoping for EL to // recover on a latter download + verify attempt @@ -477,6 +817,348 @@ export class BlockInputSync { } } + private async reconcilePayloadEnvelope(pendingPayload: PendingPayloadEnvelope): Promise { + const rootHex = getPayloadSyncCacheItemRootHex(pendingPayload); + if (this.chain.forkChoice.hasPayloadHexUnsafe(rootHex)) { + this.pendingPayloads.delete(rootHex); + return; + } + + const payloadInput = await getOrRecoverPayloadEnvelopeInput(this.chain, rootHex); + if (!payloadInput) { + if (!this.chain.forkChoice.hasBlockHex(rootHex)) { + if (!this.pendingBlocks.has(rootHex)) { + this.addByRootHex(rootHex); + } + + const pendingBlock = this.pendingBlocks.get(rootHex); + if (pendingBlock && this.network.getConnectedPeers().length > 0) { + await this.downloadBlock(pendingBlock); + } + } + return; + } + + if (!payloadInput.hasPayloadEnvelope()) { + const validationResult = await wrapError( + validateGossipExecutionPayloadEnvelope(this.chain, pendingPayload.envelope) + ); + if (validationResult.err) { + this.logger.debug( + "Pending payload envelope failed validation after block import, refetching by root", + {slot: pendingPayload.envelope.message.slot, root: rootHex}, + validationResult.err + ); + + const pendingPayloadByRoot: PendingPayloadRootHex = { + status: PendingPayloadInputStatus.pending, + rootHex, + timeAddedSec: pendingPayload.timeAddedSec, + peerIdStrings: new Set(pendingPayload.peerIdStrings), + }; + this.pendingPayloads.set(rootHex, pendingPayloadByRoot); + + if (this.network.getConnectedPeers().length > 0) { + await this.downloadPayload(pendingPayloadByRoot); + } + return; + } + } + + const upgradedPayload = this.toPendingPayloadInput(payloadInput, pendingPayload, pendingPayload.envelope); + this.pendingPayloads.set(rootHex, upgradedPayload); + + if (upgradedPayload.status === PendingPayloadInputStatus.downloaded) { + await this.processPayload(upgradedPayload); + return; + } + + await this.downloadPayload(upgradedPayload); + } + + private async downloadPayload(payload: PayloadSyncCacheItem): Promise { + if (isPendingPayloadEnvelope(payload)) { + await this.reconcilePayloadEnvelope(payload); + return; + } + + const rootHex = getPayloadSyncCacheItemRootHex(payload); + if (this.chain.forkChoice.hasPayloadHexUnsafe(rootHex)) { + this.pendingPayloads.delete(rootHex); + return; + } + + if (payload.status !== PendingPayloadInputStatus.pending) { + return; + } + + const logCtx = { + slot: getPayloadSyncCacheItemSlot(payload), + root: rootHex, + pendingPayloads: this.pendingPayloads.size, + }; + + this.logger.verbose("BlockInputSync.downloadPayload()", logCtx); + + payload.status = PendingPayloadInputStatus.fetching; + + const res = await wrapError(this.fetchPayloadInput(payload)); + if (!res.err) { + const pendingPayload = res.result; + this.pendingPayloads.set(getPayloadSyncCacheItemRootHex(pendingPayload), pendingPayload); + + if (isPendingPayloadEnvelope(pendingPayload)) { + await this.reconcilePayloadEnvelope(pendingPayload); + } else if (pendingPayload.status === PendingPayloadInputStatus.downloaded) { + await this.processPayload(pendingPayload); + } + return; + } + + this.logger.debug("Ignoring unknown payload root after failed download", logCtx, res.err); + if (!isPendingPayloadEnvelope(payload)) { + payload.status = PendingPayloadInputStatus.pending; + } + } + + private async processPayload(pendingPayload: PendingPayloadInput): Promise { + if (pendingPayload.status !== PendingPayloadInputStatus.downloaded) { + if (pendingPayload.status === PendingPayloadInputStatus.pending) { + const connectedPeers = this.network.getConnectedPeers(); + if (connectedPeers.length === 0) { + this.logger.debug("No connected peers, skipping payload download", { + slot: pendingPayload.payloadInput.slot, + blockRoot: pendingPayload.payloadInput.blockRootHex, + }); + return; + } + await this.downloadPayload(pendingPayload); + } + return; + } + + const rootHex = pendingPayload.payloadInput.blockRootHex; + if (this.chain.forkChoice.hasPayloadHexUnsafe(rootHex)) { + this.pendingPayloads.delete(rootHex); + return; + } + + pendingPayload.status = PendingPayloadInputStatus.processing; + + const res = await wrapError(this.chain.processExecutionPayload(pendingPayload.payloadInput)); + if (!res.err) { + this.pendingPayloads.delete(rootHex); + this.triggerUnknownBlockSearch(); + return; + } + + const errorData = {slot: pendingPayload.payloadInput.slot, root: rootHex}; + if (res.err instanceof PayloadError) { + switch (res.err.type.code) { + case PayloadErrorCode.BLOCK_NOT_IN_FORK_CHOICE: + this.addByRootHex(rootHex); + pendingPayload.status = PendingPayloadInputStatus.downloaded; + this.triggerUnknownBlockSearch(); + break; + + case PayloadErrorCode.EXECUTION_ENGINE_ERROR: + this.logger.debug("Execution engine error while processing payload from unknown sync", errorData, res.err); + pendingPayload.status = PendingPayloadInputStatus.downloaded; + break; + + case PayloadErrorCode.EXECUTION_ENGINE_INVALID: + case PayloadErrorCode.INVALID_SIGNATURE: + case PayloadErrorCode.STATE_TRANSITION_ERROR: + this.logger.debug("Error processing payload from unknown sync", errorData, res.err); + this.removePendingPayloadAndDescendants(rootHex); + break; + + default: + this.logger.debug("Error processing payload from unknown sync", errorData, res.err); + this.pendingPayloads.delete(rootHex); + } + return; + } + + this.logger.debug("Unknown error processing payload from unknown sync", errorData, res.err); + pendingPayload.status = PendingPayloadInputStatus.downloaded; + } + + private async fetchPayloadInput( + cacheItem: PayloadSyncCacheItem + ): Promise { + const rootHex = getPayloadSyncCacheItemRootHex(cacheItem); + const blockRoot = fromHex(rootHex); + const excludedPeers = new Set(); + + let slot = getPayloadSyncCacheItemSlot(cacheItem); + let payloadInput = isPendingPayloadInput(cacheItem) + ? cacheItem.payloadInput + : await getOrRecoverPayloadEnvelopeInput(this.chain, rootHex); + let envelope = isPendingPayloadEnvelope(cacheItem) + ? cacheItem.envelope + : payloadInput?.hasPayloadEnvelope() + ? payloadInput.getPayloadEnvelope() + : undefined; + + let i = 0; + while (i++ < this.getMaxDownloadAttempts()) { + const pendingColumns = payloadInput?.hasAllData() + ? new Set() + : new Set(payloadInput?.getMissingSampledColumnMeta().missing ?? []); + const peerMeta = this.peerBalancer.bestPeerForPendingColumns(pendingColumns, excludedPeers); + if (peerMeta === null) { + throw Error( + `Error fetching payload by root slot=${slot} root=${rootHex} after ${i}: cannot find peer with needed columns=${prettyPrintIndices(Array.from(pendingColumns))}` + ); + } + + const {peerId, client: peerClient} = peerMeta; + cacheItem.peerIdStrings.add(peerId); + + try { + if (!envelope) { + envelope = await this.fetchExecutionPayloadEnvelope(peerId, blockRoot, rootHex); + slot = envelope.message.slot; + } + + payloadInput ??= await getOrRecoverPayloadEnvelopeInput(this.chain, rootHex); + if (!payloadInput) { + return { + status: PendingPayloadInputStatus.waitingForBlock, + envelope, + timeAddedSec: cacheItem.timeAddedSec, + peerIdStrings: cacheItem.peerIdStrings, + }; + } + + if (!payloadInput.hasPayloadEnvelope()) { + await validateGossipExecutionPayloadEnvelope(this.chain, envelope); + } + + let pendingPayload = this.toPendingPayloadInput(payloadInput, cacheItem, envelope); + if (!pendingPayload.payloadInput.hasAllData()) { + const missing = pendingPayload.payloadInput.getMissingSampledColumnMeta().missing; + if (missing.length > 0) { + const columnSidecars = await this.fetchPayloadColumns(peerMeta, pendingPayload.payloadInput, missing); + const seenTimestampSec = Date.now() / 1000; + for (const columnSidecar of columnSidecars) { + if (pendingPayload.payloadInput.hasColumn(columnSidecar.index)) { + continue; + } + + pendingPayload.payloadInput.addColumn({ + columnSidecar, + source: PayloadEnvelopeInputSource.byRoot, + seenTimestampSec, + peerIdStr: peerId, + }); + } + pendingPayload = this.toPendingPayloadInput(pendingPayload.payloadInput, pendingPayload); + } + } + + this.logger.verbose("BlockInputSync.fetchPayloadInput: successful download", { + slot, + rootHex, + peerId, + peerClient, + hasPayload: pendingPayload.payloadInput.hasPayloadEnvelope(), + hasAllData: pendingPayload.payloadInput.hasAllData(), + }); + + if (pendingPayload.status === PendingPayloadInputStatus.downloaded) { + return pendingPayload; + } + + cacheItem = pendingPayload; + payloadInput = pendingPayload.payloadInput; + } catch (e) { + this.logger.debug( + "Error downloading payload in BlockInputSync.fetchPayloadInput", + {slot, rootHex, attempt: i, peer: peerId, peerClient}, + e as Error + ); + + if (e instanceof RequestError) { + switch (e.type.code) { + case RequestErrorCode.REQUEST_RATE_LIMITED: + case RequestErrorCode.REQUEST_TIMEOUT: + break; + default: + excludedPeers.add(peerId); + break; + } + } else { + excludedPeers.add(peerId); + } + } finally { + this.peerBalancer.onRequestCompleted(peerId); + } + } + + throw Error(`Error fetching payload with slot=${slot} root=${rootHex} after ${i - 1} attempts.`); + } + + private async fetchExecutionPayloadEnvelope( + peerIdStr: PeerIdStr, + blockRoot: Uint8Array, + rootHex: RootHex + ): Promise { + const response = await this.network.sendExecutionPayloadEnvelopesByRoot(peerIdStr, [blockRoot]); + const envelope = response.at(0); + if (!envelope) { + throw new Error(`Missing execution payload envelope for root=${rootHex}`); + } + + const receivedRootHex = toRootHex(envelope.message.beaconBlockRoot); + if (receivedRootHex !== rootHex) { + throw new Error(`Execution payload envelope root mismatch requested=${rootHex} received=${receivedRootHex}`); + } + + return envelope; + } + + private async fetchPayloadColumns( + peerMeta: PeerSyncMeta, + payloadInput: PayloadEnvelopeInput, + missing: number[] + ): Promise { + const {peerId: peerIdStr} = peerMeta; + const peerColumns = new Set(peerMeta.custodyColumns ?? []); + const requestedColumns = missing.filter((columnIndex) => peerColumns.has(columnIndex)); + if (requestedColumns.length === 0) { + return []; + } + + const columnSidecars = (await this.network.sendDataColumnSidecarsByRoot(peerIdStr, [ + {blockRoot: fromHex(payloadInput.blockRootHex), columns: requestedColumns}, + ])) as gloas.DataColumnSidecar[]; + + if (columnSidecars.length === 0) { + throw new Error(`No data column sidecars returned for payload root=${payloadInput.blockRootHex}`); + } + + const requestedColumnsSet = new Set(requestedColumns); + const extraColumns = columnSidecars.filter((columnSidecar) => !requestedColumnsSet.has(columnSidecar.index)); + if (extraColumns.length > 0) { + throw new Error( + `Received unexpected payload data columns indices=${prettyPrintIndices(extraColumns.map((column) => column.index))}` + ); + } + + // PayloadEnvelopeInput already carries the block slot, root, and commitments, so reuse the + // block-based Gloas validator rather than maintaining a second payload-specific variant. + await validateGloasBlockDataColumnSidecars( + payloadInput.slot, + fromHex(payloadInput.blockRootHex), + payloadInput.getBlobKzgCommitments(), + columnSidecars, + this.chain.metrics?.peerDas + ); + return columnSidecars; + } + /** * From a set of shuffled peers: * - fetch the block @@ -660,6 +1342,27 @@ export class BlockInputSync { pruneSetToMax(this.knownBadBlocks, MAX_KNOWN_BAD_BLOCKS); } + private removePendingPayloadAndDescendants(rootHex: RootHex): void { + this.chain.seenPayloadEnvelopeInputCache.prune(rootHex); + this.pendingPayloads.delete(rootHex); + + const badPendingBlocks = getAllDescendantBlocks(rootHex, this.pendingBlocks); + this.metrics?.blockInputSync.removedBlocks.inc(badPendingBlocks.length); + + for (const block of badPendingBlocks) { + const descendantRootHex = getBlockInputSyncCacheItemRootHex(block); + this.pendingBlocks.delete(descendantRootHex); + this.pendingPayloads.delete(descendantRootHex); + this.chain.seenBlockInputCache.prune(descendantRootHex); + this.chain.seenPayloadEnvelopeInputCache.prune(descendantRootHex); + this.logger.debug("Removing pending descendant after invalid parent payload", { + slot: getBlockInputSyncCacheItemSlot(block), + blockRoot: descendantRootHex, + parentPayloadRoot: rootHex, + }); + } + } + private removeAllDescendants(block: BlockInputSyncCacheItem): BlockInputSyncCacheItem[] { const rootHex = getBlockInputSyncCacheItemRootHex(block); const slot = getBlockInputSyncCacheItemSlot(block); @@ -671,7 +1374,9 @@ export class BlockInputSync { for (const block of badPendingBlocks) { const rootHex = getBlockInputSyncCacheItemRootHex(block); this.pendingBlocks.delete(rootHex); + this.pendingPayloads.delete(rootHex); this.chain.seenBlockInputCache.prune(rootHex); + this.chain.seenPayloadEnvelopeInputCache.prune(rootHex); this.logger.debug("Removing bad/unknown/incomplete BlockInputSyncCacheItem", { slot, blockRoot: rootHex, diff --git a/packages/beacon-node/test/unit/sync/unknownBlock.test.ts b/packages/beacon-node/test/unit/sync/unknownBlock.test.ts index a12d46e42444..afd5e06b5eb6 100644 --- a/packages/beacon-node/test/unit/sync/unknownBlock.test.ts +++ b/packages/beacon-node/test/unit/sync/unknownBlock.test.ts @@ -1,18 +1,28 @@ import EventEmitter from "node:events"; import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; import {toHexString} from "@chainsafe/ssz"; -import {createChainForkConfig} from "@lodestar/config"; +import {routes} from "@lodestar/api"; +import {createBeaconConfig, createChainForkConfig} from "@lodestar/config"; import {config as minimalConfig} from "@lodestar/config/default"; import {IForkChoice, ProtoBlock} from "@lodestar/fork-choice"; import {testLogger} from "@lodestar/logger/test-utils"; -import {ssz} from "@lodestar/types"; -import {notNullish, sleep} from "@lodestar/utils"; +import {ForkName} from "@lodestar/params"; +import {SignedBeaconBlock, gloas, ssz} from "@lodestar/types"; +import {notNullish, sleep, toRootHex} from "@lodestar/utils"; import {BlockInputPreData} from "../../../src/chain/blocks/blockInput/blockInput.js"; import {BlockInputSource} from "../../../src/chain/blocks/blockInput/types.js"; +import {PayloadError, PayloadErrorCode} from "../../../src/chain/blocks/importExecutionPayload.js"; +import {PayloadEnvelopeInput} from "../../../src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.js"; +import { + CreateFromBlockProps, + PayloadEnvelopeInputSource, +} from "../../../src/chain/blocks/payloadEnvelopeInput/types.js"; import {BlockError, BlockErrorCode} from "../../../src/chain/errors/blockError.js"; import {ChainEvent, ChainEventEmitter, IBeaconChain} from "../../../src/chain/index.js"; import {SeenBlockProposers} from "../../../src/chain/seenCache/seenBlockProposers.js"; import {SeenBlockInput} from "../../../src/chain/seenCache/seenGossipBlockInput.js"; +import {validateGloasBlockDataColumnSidecars} from "../../../src/chain/validation/dataColumnSidecar.js"; +import {validateGossipExecutionPayloadEnvelope} from "../../../src/chain/validation/executionPayloadEnvelope.js"; import {INetwork, NetworkEvent, NetworkEventBus} from "../../../src/network/index.js"; import {PeerSyncMeta} from "../../../src/network/peers/peersData.js"; import {defaultSyncOptions} from "../../../src/sync/options.js"; @@ -23,6 +33,71 @@ import {ClockStopped} from "../../mocks/clock.js"; import {MockedBeaconChain, getMockedBeaconChain} from "../../mocks/mockedBeaconChain.js"; import {getRandPeerIdStr, getRandPeerSyncMeta} from "../../utils/peer.js"; +vi.mock("../../../src/chain/validation/executionPayloadEnvelope.js", () => ({ + validateGossipExecutionPayloadEnvelope: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../../../src/chain/validation/dataColumnSidecar.js", async (importActual) => { + const mod = await importActual(); + return { + ...mod, + validateGloasBlockDataColumnSidecars: vi.fn().mockResolvedValue(undefined), + }; +}); + +function buildPayloadFixture({ + blobCount, + blockHash, + sampledColumns, + slot, +}: { + blobCount: number; + blockHash?: Uint8Array; + sampledColumns: number[]; + slot: number; +}): { + block: gloas.SignedBeaconBlock; + blockRootHex: string; + blockRoot: Uint8Array; + payloadInput: PayloadEnvelopeInput; + envelope: gloas.SignedExecutionPayloadEnvelope; + columnSidecars: gloas.DataColumnSidecar[]; +} { + const block = ssz.gloas.SignedBeaconBlock.defaultValue(); + block.message.slot = slot; + block.message.body.signedExecutionPayloadBid.message.blobKzgCommitments = Array.from({length: blobCount}, () => + Buffer.alloc(48, 0x11) + ); + if (blockHash) { + block.message.body.signedExecutionPayloadBid.message.blockHash = blockHash; + } + + const blockRoot = ssz.gloas.BeaconBlock.hashTreeRoot(block.message); + const blockRootHex = toRootHex(blockRoot); + const payloadInput = PayloadEnvelopeInput.createFromBlock({ + blockRootHex, + block: block as SignedBeaconBlock, + forkName: ForkName.gloas, + sampledColumns, + custodyColumns: sampledColumns, + timeCreatedSec: Date.now() / 1000, + }); + + const envelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue(); + envelope.message.beaconBlockRoot = blockRoot; + envelope.message.slot = slot; + + const columnSidecars = sampledColumns.map((index) => { + const columnSidecar = ssz.gloas.DataColumnSidecar.defaultValue(); + columnSidecar.beaconBlockRoot = blockRoot; + columnSidecar.slot = slot; + columnSidecar.index = index; + return columnSidecar; + }); + + return {block, blockRootHex, blockRoot, payloadInput, envelope, columnSidecars}; +} + describe("sync by UnknownBlockSync", {timeout: 20_000}, () => { const logger = testLogger(); const slotSec = 0.3; @@ -266,11 +341,12 @@ describe("sync by UnknownBlockSync", {timeout: 20_000}, () => { // (peer reporting is currently disabled in removeAndDownScoreAllDescendants) expect(processBlockSpy).not.toHaveBeenCalled(); } else if (maxPendingBlocks !== undefined) { - // With maxPendingBlocks=1 and unknownParent event, addByRootHex adds parent (blockB), - // then addByBlockInput adds blockC, exceeding the limit. Pruning removes the oldest - // entry (blockB), so blockC can't resolve its parent chain and no blocks get processed. + // With maxPendingBlocks=1 and unknownParent event, the scheduler can re-queue one pruned + // parent root at a time, so it partially recovers the chain. It still cannot retain enough + // pending state to import the full descendant chain, so only the earliest ancestor lands in + // fork choice. await sleep(500); - expect(Array.from(forkChoiceKnownRoots.values())).toEqual([blockRootHex0]); + expect(Array.from(forkChoiceKnownRoots.values())).toEqual([blockRootHex0, blockRootHexA]); } else { // Wait for all blocks to be in ForkChoice store await blockCProcessed; @@ -341,15 +417,1050 @@ describe("UnknownBlockSync", () => { if (expected) { expect(events.listenerCount(ChainEvent.unknownBlockRoot)).toBe(1); expect(events.listenerCount(ChainEvent.blockUnknownParent)).toBe(1); + expect(events.listenerCount(ChainEvent.unknownEnvelopeBlockRoot)).toBe(1); + expect(events.listenerCount(ChainEvent.envelopeUnknownBlock)).toBe(1); + expect(events.listenerCount(ChainEvent.incompletePayloadEnvelope)).toBe(1); + expect(events.listenerCount(routes.events.EventType.block)).toBe(1); + expect(events.listenerCount(routes.events.EventType.executionPayload)).toBe(1); expect(service.isSubscribedToNetwork()).toBe(true); } else { expect(events.listenerCount(ChainEvent.unknownBlockRoot)).toBe(0); expect(events.listenerCount(ChainEvent.blockUnknownParent)).toBe(0); + expect(events.listenerCount(ChainEvent.unknownEnvelopeBlockRoot)).toBe(0); + expect(events.listenerCount(ChainEvent.envelopeUnknownBlock)).toBe(0); + expect(events.listenerCount(ChainEvent.incompletePayloadEnvelope)).toBe(0); + expect(events.listenerCount(routes.events.EventType.block)).toBe(0); + expect(events.listenerCount(routes.events.EventType.executionPayload)).toBe(0); expect(service.isSubscribedToNetwork()).toBe(false); } }); } }); + + describe("payload sync flows", () => { + const gloasConfig = createBeaconConfig( + {...minimalConfig, FULU_FORK_EPOCH: 0, GLOAS_FORK_EPOCH: 0}, + Buffer.alloc(32, 0) + ); + + beforeEach(() => { + vi.useFakeTimers({shouldAdvanceTime: true}); + vi.mocked(validateGossipExecutionPayloadEnvelope).mockClear(); + vi.mocked(validateGloasBlockDataColumnSidecars).mockClear(); + }); + + it("fetches and processes unknown envelope by root when payload input exists", async () => { + const peer = await getRandPeerIdStr(); + const {blockRoot, blockRootHex, payloadInput, envelope, columnSidecars} = buildPayloadFixture({ + blobCount: 1, + sampledColumns: [0], + slot: 1, + }); + + const networkEvents = new NetworkEventBus(); + const sendExecutionPayloadEnvelopesByRoot = vi.fn().mockResolvedValue([envelope]); + const sendDataColumnSidecarsByRoot = vi.fn().mockResolvedValue(columnSidecars); + const network: Partial = { + events: networkEvents, + getConnectedPeers: () => [peer], + getConnectedPeerSyncMeta: () => ({ + peerId: peer, + client: "payload-test-client", + custodyColumns: [0], + earliestAvailableSlot: 0, + }), + custodyConfig: {sampledColumns: [0], sampleGroups: [[0]]} as unknown as CustodyConfig, + sendExecutionPayloadEnvelopesByRoot, + sendDataColumnSidecarsByRoot, + }; + + const processExecutionPayload = vi.fn().mockResolvedValue(undefined); + const emitter = new ChainEventEmitter(); + const chain: Partial = { + emitter, + clock: new ClockStopped(0), + config: gloasConfig, + genesisTime: 0, + metrics: null, + processExecutionPayload, + seenPayloadEnvelopeInputCache: { + get: vi.fn().mockImplementation((root: string) => (root === blockRootHex ? payloadInput : undefined)), + prune: vi.fn(), + } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + seenBlockInputCache: {prune: vi.fn()} as unknown as SeenBlockInput, + seenBlockProposers: {isKnown: vi.fn().mockReturnValue(false)} as unknown as SeenBlockProposers, + forkChoice: { + hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), + hasBlockHex: vi.fn().mockReturnValue(true), + getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), + } as unknown as IForkChoice, + }; + + service = new BlockInputSync( + gloasConfig, + network as INetwork, + chain as IBeaconChain, + logger, + null, + defaultSyncOptions + ); + service.subscribeToNetwork(); + + networkEvents.emit(NetworkEvent.peerConnected, { + peer, + status: {} as never, + custodyColumns: [0], + clientAgent: "payload-test-client", + }); + + emitter.emit(ChainEvent.unknownEnvelopeBlockRoot, { + rootHex: blockRootHex, + peer, + source: BlockInputSource.gossip, + }); + + await sleep(50); + + expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenCalledTimes(1); + expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenCalledWith(peer, [blockRoot]); + expect(sendDataColumnSidecarsByRoot).toHaveBeenCalledTimes(1); + expect(sendDataColumnSidecarsByRoot).toHaveBeenCalledWith(peer, [{blockRoot, columns: [0]}]); + expect(validateGossipExecutionPayloadEnvelope).toHaveBeenCalledOnce(); + expect(validateGloasBlockDataColumnSidecars).toHaveBeenCalledOnce(); + expect(processExecutionPayload).toHaveBeenCalledTimes(1); + expect(processExecutionPayload).toHaveBeenCalledWith(payloadInput); + expect(payloadInput.hasPayloadEnvelope()).toBe(true); + expect(payloadInput.hasAllData()).toBe(true); + }); + + it("continues fetching sampled columns across peers until payload input is complete", async () => { + const peerA = await getRandPeerIdStr(); + const peerB = await getRandPeerIdStr(); + const {blockRoot, blockRootHex, payloadInput, envelope, columnSidecars} = buildPayloadFixture({ + blobCount: 1, + sampledColumns: [0, 1], + slot: 1, + }); + + const networkEvents = new NetworkEventBus(); + const sendExecutionPayloadEnvelopesByRoot = vi.fn().mockResolvedValue([envelope]); + const sendDataColumnSidecarsByRoot = vi + .fn() + .mockImplementation(async (peerId: string, requests: {blockRoot: Uint8Array; columns: number[]}[]) => { + const [{blockRoot: requestedRoot, columns}] = requests; + expect(requestedRoot).toEqual(blockRoot); + expect(columns).toHaveLength(1); + + if (peerId === peerA) { + expect(columns).toEqual([0]); + return [columnSidecars[0]]; + } + + expect(peerId).toBe(peerB); + expect(columns).toEqual([1]); + return [columnSidecars[1]]; + }); + + const processExecutionPayload = vi.fn().mockResolvedValue(undefined); + const emitter = new ChainEventEmitter(); + const chain: Partial = { + emitter, + clock: new ClockStopped(0), + config: gloasConfig, + genesisTime: 0, + metrics: null, + processExecutionPayload, + seenPayloadEnvelopeInputCache: { + get: vi.fn().mockImplementation((root: string) => (root === blockRootHex ? payloadInput : undefined)), + prune: vi.fn(), + } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + seenBlockInputCache: {prune: vi.fn()} as unknown as SeenBlockInput, + seenBlockProposers: {isKnown: vi.fn().mockReturnValue(false)} as unknown as SeenBlockProposers, + forkChoice: { + hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), + hasBlockHex: vi.fn().mockReturnValue(true), + getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), + } as unknown as IForkChoice, + }; + + const network: Partial = { + events: networkEvents, + getConnectedPeers: () => [peerA, peerB], + getConnectedPeerSyncMeta: (peerId: string) => ({ + peerId, + client: `payload-test-client-${peerId === peerA ? "a" : "b"}`, + custodyColumns: peerId === peerA ? [0] : [1], + earliestAvailableSlot: 0, + }), + custodyConfig: {sampledColumns: [0, 1], sampleGroups: [[0], [1]]} as unknown as CustodyConfig, + sendExecutionPayloadEnvelopesByRoot, + sendDataColumnSidecarsByRoot, + }; + + service = new BlockInputSync( + gloasConfig, + network as INetwork, + chain as IBeaconChain, + logger, + null, + defaultSyncOptions + ); + service.subscribeToNetwork(); + + for (const peer of [peerA, peerB]) { + networkEvents.emit(NetworkEvent.peerConnected, { + peer, + status: {} as never, + custodyColumns: peer === peerA ? [0] : [1], + clientAgent: `payload-test-client-${peer === peerA ? "a" : "b"}`, + }); + } + + emitter.emit(ChainEvent.unknownEnvelopeBlockRoot, { + rootHex: blockRootHex, + peer: peerA, + source: BlockInputSource.gossip, + }); + + await sleep(50); + + expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenCalledTimes(1); + expect(sendDataColumnSidecarsByRoot).toHaveBeenCalledTimes(2); + expect(sendDataColumnSidecarsByRoot.mock.calls.map(([peerId]) => peerId)).toEqual( + expect.arrayContaining([peerA, peerB]) + ); + expect(processExecutionPayload).toHaveBeenCalledTimes(1); + expect(processExecutionPayload).toHaveBeenCalledWith(payloadInput); + expect(payloadInput.hasPayloadEnvelope()).toBe(true); + expect(payloadInput.hasAllData()).toBe(true); + }); + + it("downloads the block immediately after fetching an envelope for an unknown root", async () => { + const peer = await getRandPeerIdStr(); + const {block, blockRoot, blockRootHex, payloadInput, envelope} = buildPayloadFixture({ + blobCount: 0, + sampledColumns: [], + slot: 1, + }); + const parentRootHex = toRootHex(block.message.parentRoot); + + let cachedPayloadInput: PayloadEnvelopeInput | undefined; + const knownRoots = new Set([parentRootHex]); + + const networkEvents = new NetworkEventBus(); + const sendExecutionPayloadEnvelopesByRoot = vi.fn().mockResolvedValue([envelope]); + const sendBeaconBlocksByRoot = vi.fn().mockResolvedValue([block]); + const processExecutionPayload = vi.fn().mockResolvedValue(undefined); + const emitter = new ChainEventEmitter(); + const processBlock = vi.fn().mockImplementation(async () => { + cachedPayloadInput = payloadInput; + knownRoots.add(blockRootHex); + emitter.emit(routes.events.EventType.block, {slot: 1, block: blockRootHex, executionOptimistic: false}); + }); + + const chain: Partial = { + emitter, + clock: new ClockStopped(0), + config: gloasConfig, + genesisTime: 0, + metrics: null, + processBlock, + processExecutionPayload, + getBlockByRoot: vi.fn().mockResolvedValue(null), + seenPayloadEnvelopeInputCache: { + get: vi.fn().mockImplementation((root: string) => (root === blockRootHex ? cachedPayloadInput : undefined)), + prune: vi.fn(), + } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + seenBlockInputCache: { + getByBlock: ({ + block, + blockRootHex, + seenTimestampSec, + source, + }: { + block: SignedBeaconBlock; + blockRootHex: string; + seenTimestampSec: number; + source: BlockInputSource; + }) => + BlockInputPreData.createFromBlock({ + block, + blockRootHex, + forkName: gloasConfig.getForkName(block.message.slot), + daOutOfRange: false, + seenTimestampSec, + source, + }), + prune: vi.fn(), + } as unknown as SeenBlockInput, + seenBlockProposers: {isKnown: vi.fn().mockReturnValue(false)} as unknown as SeenBlockProposers, + forkChoice: { + hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), + hasBlockHex: vi.fn().mockImplementation((root: string) => knownRoots.has(root)), + getBlockHexAndBlockHash: vi + .fn() + .mockImplementation((root: string, hash: string) => + root === parentRootHex && + hash === toHexString(block.message.body.signedExecutionPayloadBid.message.parentBlockHash) + ? ({slot: 0} as ProtoBlock) + : null + ), + getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), + } as unknown as IForkChoice, + }; + + const network: Partial = { + events: networkEvents, + getConnectedPeers: () => [peer], + getConnectedPeerSyncMeta: () => ({ + peerId: peer, + client: "payload-test-client", + custodyColumns: [], + earliestAvailableSlot: 0, + }), + custodyConfig: {sampledColumns: [], sampleGroups: [[]]} as unknown as CustodyConfig, + sendExecutionPayloadEnvelopesByRoot, + sendBeaconBlocksByRoot, + }; + + service = new BlockInputSync( + gloasConfig, + network as INetwork, + chain as IBeaconChain, + logger, + null, + defaultSyncOptions + ); + service.subscribeToNetwork(); + + networkEvents.emit(NetworkEvent.peerConnected, { + peer, + status: {} as never, + custodyColumns: [], + clientAgent: "payload-test-client", + }); + + emitter.emit(ChainEvent.unknownEnvelopeBlockRoot, { + rootHex: blockRootHex, + peer, + source: BlockInputSource.gossip, + }); + + await sleep(50); + + expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenCalledTimes(1); + expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenCalledWith(peer, [blockRoot]); + expect(sendBeaconBlocksByRoot).toHaveBeenCalledTimes(1); + expect(sendBeaconBlocksByRoot).toHaveBeenCalledWith(peer, [blockRoot]); + expect(processBlock).toHaveBeenCalledTimes(1); + expect(validateGossipExecutionPayloadEnvelope).toHaveBeenCalledOnce(); + expect(processExecutionPayload).toHaveBeenCalledWith(payloadInput); + }); + + it("waits for block after envelopeUnknownBlock and processes payload on block import", async () => { + const peer = await getRandPeerIdStr(); + const {blockRootHex, payloadInput, envelope} = buildPayloadFixture({ + blobCount: 0, + sampledColumns: [], + slot: 1, + }); + + let cachedPayloadInput: PayloadEnvelopeInput | undefined; + const processExecutionPayload = vi.fn().mockResolvedValue(undefined); + const emitter = new ChainEventEmitter(); + const chain: Partial = { + emitter, + clock: new ClockStopped(0), + config: gloasConfig, + genesisTime: 0, + metrics: null, + processExecutionPayload, + getBlockByRoot: vi.fn().mockResolvedValue(null), + seenPayloadEnvelopeInputCache: { + get: vi.fn().mockImplementation((root: string) => (root === blockRootHex ? cachedPayloadInput : undefined)), + prune: vi.fn(), + } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + seenBlockInputCache: {prune: vi.fn()} as unknown as SeenBlockInput, + seenBlockProposers: {isKnown: vi.fn().mockReturnValue(false)} as unknown as SeenBlockProposers, + forkChoice: { + hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), + hasBlockHex: vi.fn().mockReturnValue(false), + getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), + } as unknown as IForkChoice, + }; + + const network: Partial = { + events: new NetworkEventBus(), + getConnectedPeers: () => [], + custodyConfig: {sampledColumns: [], sampleGroups: [[]]} as unknown as CustodyConfig, + }; + + service = new BlockInputSync( + gloasConfig, + network as INetwork, + chain as IBeaconChain, + logger, + null, + defaultSyncOptions + ); + service.subscribeToNetwork(); + + emitter.emit(ChainEvent.envelopeUnknownBlock, { + envelope, + peer, + source: BlockInputSource.gossip, + }); + + await sleep(20); + expect(processExecutionPayload).not.toHaveBeenCalled(); + + cachedPayloadInput = payloadInput; + emitter.emit(routes.events.EventType.block, {slot: 1, block: blockRootHex, executionOptimistic: false}); + + await sleep(50); + + expect(validateGossipExecutionPayloadEnvelope).toHaveBeenCalledOnce(); + expect(processExecutionPayload).toHaveBeenCalledTimes(1); + expect(processExecutionPayload).toHaveBeenCalledWith(payloadInput); + expect(payloadInput.hasPayloadEnvelope()).toBe(true); + }); + + it("refetches by root if a queued envelope fails validation after block import", async () => { + const peer = await getRandPeerIdStr(); + const {blockRoot, blockRootHex, payloadInput, envelope} = buildPayloadFixture({ + blobCount: 0, + sampledColumns: [], + slot: 1, + }); + + const invalidEnvelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue(); + invalidEnvelope.message.beaconBlockRoot = blockRoot; + invalidEnvelope.message.slot = 1; + + vi.mocked(validateGossipExecutionPayloadEnvelope).mockImplementationOnce(async (_chain, signedEnvelope) => { + if (signedEnvelope === invalidEnvelope) { + throw new Error("invalid queued envelope"); + } + }); + + let cachedPayloadInput: PayloadEnvelopeInput | undefined; + const processExecutionPayload = vi.fn().mockResolvedValue(undefined); + const emitter = new ChainEventEmitter(); + const chain: Partial = { + emitter, + clock: new ClockStopped(0), + config: gloasConfig, + genesisTime: 0, + metrics: null, + processExecutionPayload, + getBlockByRoot: vi.fn().mockResolvedValue(null), + seenPayloadEnvelopeInputCache: { + get: vi.fn().mockImplementation((root: string) => (root === blockRootHex ? cachedPayloadInput : undefined)), + prune: vi.fn(), + } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + seenBlockInputCache: {prune: vi.fn()} as unknown as SeenBlockInput, + seenBlockProposers: {isKnown: vi.fn().mockReturnValue(false)} as unknown as SeenBlockProposers, + forkChoice: { + hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), + hasBlockHex: vi.fn().mockReturnValue(true), + getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), + } as unknown as IForkChoice, + }; + + const networkEvents = new NetworkEventBus(); + const sendExecutionPayloadEnvelopesByRoot = vi + .fn() + .mockResolvedValueOnce([invalidEnvelope]) + .mockResolvedValueOnce([envelope]); + const network: Partial = { + events: networkEvents, + getConnectedPeers: () => [peer], + getConnectedPeerSyncMeta: () => ({ + peerId: peer, + client: "payload-test-client", + custodyColumns: [], + earliestAvailableSlot: 0, + }), + custodyConfig: {sampledColumns: [], sampleGroups: [[]]} as unknown as CustodyConfig, + sendExecutionPayloadEnvelopesByRoot, + }; + + service = new BlockInputSync( + gloasConfig, + network as INetwork, + chain as IBeaconChain, + logger, + null, + defaultSyncOptions + ); + service.subscribeToNetwork(); + + networkEvents.emit(NetworkEvent.peerConnected, { + peer, + status: {} as never, + custodyColumns: [], + clientAgent: "payload-test-client", + }); + + emitter.emit(ChainEvent.unknownEnvelopeBlockRoot, { + rootHex: blockRootHex, + peer, + source: BlockInputSource.gossip, + }); + + await sleep(20); + + expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenCalledTimes(1); + expect(processExecutionPayload).not.toHaveBeenCalled(); + + cachedPayloadInput = payloadInput; + emitter.emit(routes.events.EventType.block, {slot: 1, block: blockRootHex, executionOptimistic: false}); + + await sleep(50); + + expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenCalledTimes(2); + expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenNthCalledWith(1, peer, [blockRoot]); + expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenNthCalledWith(2, peer, [blockRoot]); + expect(validateGossipExecutionPayloadEnvelope).toHaveBeenCalledTimes(2); + expect(processExecutionPayload).toHaveBeenCalledTimes(1); + expect(processExecutionPayload).toHaveBeenCalledWith(payloadInput); + expect(payloadInput.hasPayloadEnvelope()).toBe(true); + }); + + it("refetches a replacement envelope after payload import rejects the cached one", async () => { + const peer = await getRandPeerIdStr(); + const { + block, + blockRoot, + blockRootHex, + payloadInput, + envelope: invalidEnvelope, + } = buildPayloadFixture({ + blobCount: 0, + sampledColumns: [], + slot: 1, + }); + + const recoveryEnvelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue(); + recoveryEnvelope.message.beaconBlockRoot = blockRoot; + recoveryEnvelope.message.slot = 1; + + const processExecutionPayload = vi + .fn() + .mockRejectedValueOnce( + new PayloadError({ + code: PayloadErrorCode.STATE_TRANSITION_ERROR, + message: "bad payload envelope", + }) + ) + .mockResolvedValueOnce(undefined); + let cachedPayloadInput: PayloadEnvelopeInput | undefined = payloadInput; + const prunePayloadInput = vi.fn().mockImplementation((root: string) => { + if (root === blockRootHex) { + cachedPayloadInput = undefined; + } + }); + const addPayloadInput = vi.fn().mockImplementation((props: CreateFromBlockProps) => { + cachedPayloadInput = PayloadEnvelopeInput.createFromBlock(props); + return cachedPayloadInput; + }); + const emitter = new ChainEventEmitter(); + const chain: Partial = { + emitter, + clock: new ClockStopped(0), + config: gloasConfig, + genesisTime: 0, + metrics: null, + processExecutionPayload, + custodyConfig: {sampledColumns: [], custodyColumns: []} as unknown as CustodyConfig, + getBlockByRoot: vi.fn().mockResolvedValue({block, executionOptimistic: false, finalized: false}), + seenPayloadEnvelopeInputCache: { + add: addPayloadInput, + get: vi.fn().mockImplementation((root: string) => (root === blockRootHex ? cachedPayloadInput : undefined)), + prune: prunePayloadInput, + } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + seenBlockInputCache: {prune: vi.fn()} as unknown as SeenBlockInput, + seenBlockProposers: {isKnown: vi.fn().mockReturnValue(false)} as unknown as SeenBlockProposers, + forkChoice: { + hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), + hasBlockHex: vi.fn().mockReturnValue(true), + getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), + } as unknown as IForkChoice, + }; + + const networkEvents = new NetworkEventBus(); + const sendExecutionPayloadEnvelopesByRoot = vi + .fn() + .mockResolvedValueOnce([invalidEnvelope]) + .mockResolvedValueOnce([recoveryEnvelope]); + const network: Partial = { + events: networkEvents, + getConnectedPeers: () => [peer], + getConnectedPeerSyncMeta: () => ({ + peerId: peer, + client: "payload-test-client", + custodyColumns: [], + earliestAvailableSlot: 0, + }), + custodyConfig: {sampledColumns: [], sampleGroups: [[]]} as unknown as CustodyConfig, + sendExecutionPayloadEnvelopesByRoot, + }; + + service = new BlockInputSync( + gloasConfig, + network as INetwork, + chain as IBeaconChain, + logger, + null, + defaultSyncOptions + ); + service.subscribeToNetwork(); + + networkEvents.emit(NetworkEvent.peerConnected, { + peer, + status: {} as never, + custodyColumns: [], + clientAgent: "payload-test-client", + }); + + emitter.emit(ChainEvent.unknownEnvelopeBlockRoot, { + rootHex: blockRootHex, + peer, + source: BlockInputSource.gossip, + }); + + await sleep(20); + + expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenCalledTimes(1); + expect(processExecutionPayload).toHaveBeenCalledTimes(1); + expect(prunePayloadInput).toHaveBeenCalledWith(blockRootHex); + expect(cachedPayloadInput).toBeUndefined(); + + emitter.emit(ChainEvent.unknownEnvelopeBlockRoot, { + rootHex: blockRootHex, + peer, + source: BlockInputSource.gossip, + }); + + await sleep(50); + + expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenCalledTimes(2); + expect(processExecutionPayload).toHaveBeenCalledTimes(2); + expect(addPayloadInput).toHaveBeenCalledTimes(1); + expect(cachedPayloadInput).toBeDefined(); + expect(processExecutionPayload).toHaveBeenNthCalledWith(2, cachedPayloadInput); + expect(cachedPayloadInput?.hasPayloadEnvelope()).toBe(true); + expect(cachedPayloadInput?.getPayloadEnvelope()).toBe(recoveryEnvelope); + }); + + it("processes incomplete payload envelope input without network fetch", async () => { + const peer = await getRandPeerIdStr(); + const {payloadInput, envelope} = buildPayloadFixture({blobCount: 0, sampledColumns: [], slot: 1}); + payloadInput.addPayloadEnvelope({ + envelope, + source: PayloadEnvelopeInputSource.gossip, + seenTimestampSec: Date.now() / 1000, + }); + + const network: Partial = { + events: new NetworkEventBus(), + getConnectedPeers: () => [], + custodyConfig: {sampledColumns: [], sampleGroups: [[]]} as unknown as CustodyConfig, + }; + const processExecutionPayload = vi.fn().mockResolvedValue(undefined); + const emitter = new ChainEventEmitter(); + const chain: Partial = { + emitter, + clock: new ClockStopped(0), + config: gloasConfig, + genesisTime: 0, + metrics: null, + processExecutionPayload, + seenPayloadEnvelopeInputCache: { + get: vi.fn().mockReturnValue(payloadInput), + prune: vi.fn(), + } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + seenBlockInputCache: {prune: vi.fn()} as unknown as SeenBlockInput, + seenBlockProposers: {isKnown: vi.fn().mockReturnValue(false)} as unknown as SeenBlockProposers, + forkChoice: { + hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), + hasBlockHex: vi.fn().mockReturnValue(true), + getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), + } as unknown as IForkChoice, + }; + + service = new BlockInputSync( + gloasConfig, + network as INetwork, + chain as IBeaconChain, + logger, + null, + defaultSyncOptions + ); + service.subscribeToNetwork(); + + emitter.emit(ChainEvent.incompletePayloadEnvelope, { + payloadInput, + peer, + source: BlockInputSource.gossip, + }); + + await sleep(20); + + expect(processExecutionPayload).toHaveBeenCalledTimes(1); + expect(processExecutionPayload).toHaveBeenCalledWith(payloadInput); + }); + + it("recovers parent payload for unknown parent block when parent block is already known", async () => { + const peer = await getRandPeerIdStr(); + const parentPayloadHash = Buffer.alloc(32, 0x33); + const { + blockRoot: parentRoot, + blockRootHex: parentRootHex, + payloadInput, + envelope, + } = buildPayloadFixture({ + blobCount: 0, + blockHash: parentPayloadHash, + sampledColumns: [], + slot: 1, + }); + + const childBlock = ssz.gloas.SignedBeaconBlock.defaultValue(); + childBlock.message.slot = 2; + childBlock.message.parentRoot = parentRoot; + childBlock.message.body.signedExecutionPayloadBid.message.parentBlockRoot = parentRoot; + childBlock.message.body.signedExecutionPayloadBid.message.parentBlockHash = parentPayloadHash; + const childBlockRootHex = toRootHex(ssz.gloas.BeaconBlock.hashTreeRoot(childBlock.message)); + const childBlockInput = BlockInputPreData.createFromBlock({ + block: childBlock, + blockRootHex: childBlockRootHex, + forkName: gloasConfig.getForkName(childBlock.message.slot), + daOutOfRange: false, + seenTimestampSec: Date.now() / 1000, + source: BlockInputSource.gossip, + }); + + let hasParentPayload = false; + const networkEvents = new NetworkEventBus(); + const sendExecutionPayloadEnvelopesByRoot = vi.fn().mockResolvedValue([envelope]); + const sendBeaconBlocksByRoot = vi.fn(); + const processExecutionPayload = vi.fn().mockImplementation(async () => { + hasParentPayload = true; + }); + const processBlock = vi.fn().mockResolvedValue(undefined); + + const network: Partial = { + events: networkEvents, + getConnectedPeers: () => [peer], + getConnectedPeerSyncMeta: () => ({ + peerId: peer, + client: "payload-test-client", + custodyColumns: [], + earliestAvailableSlot: 0, + }), + custodyConfig: {sampledColumns: [], sampleGroups: [[]]} as unknown as CustodyConfig, + sendExecutionPayloadEnvelopesByRoot, + sendBeaconBlocksByRoot, + }; + + const emitter = new ChainEventEmitter(); + const chain: Partial = { + emitter, + clock: new ClockStopped(0), + config: gloasConfig, + genesisTime: 0, + metrics: null, + processExecutionPayload, + processBlock, + seenPayloadEnvelopeInputCache: { + get: vi.fn().mockImplementation((root: string) => (root === parentRootHex ? payloadInput : undefined)), + prune: vi.fn(), + } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + seenBlockInputCache: {prune: vi.fn()} as unknown as SeenBlockInput, + seenBlockProposers: {isKnown: vi.fn().mockReturnValue(false)} as unknown as SeenBlockProposers, + forkChoice: { + hasPayloadHexUnsafe: vi.fn().mockImplementation((root: string) => root === parentRootHex && hasParentPayload), + hasBlockHex: vi.fn().mockImplementation((root: string) => root === parentRootHex), + getBlockHexDefaultStatus: vi + .fn() + .mockImplementation((root: string) => (root === parentRootHex ? ({slot: 1} as ProtoBlock) : null)), + getBlockHexAndBlockHash: vi + .fn() + .mockImplementation((root: string, hash: string) => + root === parentRootHex && hash === toHexString(parentPayloadHash) && hasParentPayload + ? ({slot: 1} as ProtoBlock) + : null + ), + getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), + } as unknown as IForkChoice, + }; + + service = new BlockInputSync( + gloasConfig, + network as INetwork, + chain as IBeaconChain, + logger, + null, + defaultSyncOptions + ); + service.subscribeToNetwork(); + + networkEvents.emit(NetworkEvent.peerConnected, { + peer, + status: {} as never, + custodyColumns: [], + clientAgent: "payload-test-client", + }); + + emitter.emit(ChainEvent.blockUnknownParent, { + blockInput: childBlockInput, + peer, + source: BlockInputSource.gossip, + }); + + await sleep(50); + + expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenCalledTimes(1); + expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenCalledWith(peer, [parentRoot]); + expect(sendBeaconBlocksByRoot).not.toHaveBeenCalled(); + expect(processExecutionPayload).toHaveBeenCalledTimes(1); + expect(processExecutionPayload).toHaveBeenCalledWith(payloadInput); + expect(processBlock).toHaveBeenCalledTimes(1); + expect(processBlock).toHaveBeenCalledWith( + childBlockInput, + expect.objectContaining({ignoreIfKnown: true, ignoreIfFinalized: true, blsVerifyOnMainThread: true}) + ); + }); + + it("drops a child block when its parent payload hash conflicts with the known parent block", async () => { + const peer = await getRandPeerIdStr(); + + const parentBlock = ssz.gloas.SignedBeaconBlock.defaultValue(); + parentBlock.message.slot = 1; + parentBlock.message.body.signedExecutionPayloadBid.message.parentBlockHash = Buffer.alloc(32, 0x11); + parentBlock.message.body.signedExecutionPayloadBid.message.blockHash = Buffer.alloc(32, 0x22); + const parentRoot = ssz.gloas.BeaconBlock.hashTreeRoot(parentBlock.message); + const parentRootHex = toRootHex(parentRoot); + const parentPayloadInput = PayloadEnvelopeInput.createFromBlock({ + blockRootHex: parentRootHex, + block: parentBlock as SignedBeaconBlock, + forkName: ForkName.gloas, + sampledColumns: [], + custodyColumns: [], + timeCreatedSec: Date.now() / 1000, + }); + + const childBlock = ssz.gloas.SignedBeaconBlock.defaultValue(); + childBlock.message.slot = 2; + childBlock.message.parentRoot = parentRoot; + childBlock.message.body.signedExecutionPayloadBid.message.parentBlockRoot = parentRoot; + childBlock.message.body.signedExecutionPayloadBid.message.parentBlockHash = Buffer.alloc(32, 0x33); + const childBlockRootHex = toRootHex(ssz.gloas.BeaconBlock.hashTreeRoot(childBlock.message)); + const childBlockInput = BlockInputPreData.createFromBlock({ + block: childBlock, + blockRootHex: childBlockRootHex, + forkName: gloasConfig.getForkName(childBlock.message.slot), + daOutOfRange: false, + seenTimestampSec: Date.now() / 1000, + source: BlockInputSource.gossip, + }); + + const networkEvents = new NetworkEventBus(); + const sendExecutionPayloadEnvelopesByRoot = vi.fn(); + const processBlock = vi.fn(); + const seenBlockInputPrune = vi.fn(); + const seenPayloadPrune = vi.fn(); + + const network: Partial = { + events: networkEvents, + getConnectedPeers: () => [peer], + getConnectedPeerSyncMeta: () => ({ + peerId: peer, + client: "payload-test-client", + custodyColumns: [], + earliestAvailableSlot: 0, + }), + custodyConfig: {sampledColumns: [], sampleGroups: [[]]} as unknown as CustodyConfig, + sendExecutionPayloadEnvelopesByRoot, + }; + + const emitter = new ChainEventEmitter(); + const chain: Partial = { + emitter, + clock: new ClockStopped(0), + config: gloasConfig, + genesisTime: 0, + metrics: null, + processBlock, + seenPayloadEnvelopeInputCache: { + get: vi.fn().mockImplementation((root: string) => (root === parentRootHex ? parentPayloadInput : undefined)), + prune: seenPayloadPrune, + } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + seenBlockInputCache: {prune: seenBlockInputPrune} as unknown as SeenBlockInput, + seenBlockProposers: {isKnown: vi.fn().mockReturnValue(false)} as unknown as SeenBlockProposers, + forkChoice: { + hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), + hasBlockHex: vi.fn().mockImplementation((root: string) => root === parentRootHex), + getBlockHexAndBlockHash: vi.fn().mockReturnValue(null), + getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), + } as unknown as IForkChoice, + }; + + service = new BlockInputSync( + gloasConfig, + network as INetwork, + chain as IBeaconChain, + logger, + null, + defaultSyncOptions + ); + service.subscribeToNetwork(); + + networkEvents.emit(NetworkEvent.peerConnected, { + peer, + status: {} as never, + custodyColumns: [], + clientAgent: "payload-test-client", + }); + + emitter.emit(ChainEvent.blockUnknownParent, { + blockInput: childBlockInput, + peer, + source: BlockInputSource.gossip, + }); + + await sleep(20); + + expect(sendExecutionPayloadEnvelopesByRoot).not.toHaveBeenCalled(); + expect(processBlock).not.toHaveBeenCalled(); + expect(seenBlockInputPrune).toHaveBeenCalledWith(childBlockRootHex); + expect(seenPayloadPrune).toHaveBeenCalledWith(childBlockRootHex); + }); + + it("removes pending descendants after invalid parent payload", async () => { + const peer = await getRandPeerIdStr(); + const parentPayloadHash = Buffer.alloc(32, 0x33); + const { + blockRoot: parentRoot, + blockRootHex: parentRootHex, + payloadInput, + envelope, + } = buildPayloadFixture({ + blobCount: 0, + blockHash: parentPayloadHash, + sampledColumns: [], + slot: 1, + }); + + const childBlock = ssz.gloas.SignedBeaconBlock.defaultValue(); + childBlock.message.slot = 2; + childBlock.message.parentRoot = parentRoot; + childBlock.message.body.signedExecutionPayloadBid.message.parentBlockRoot = parentRoot; + childBlock.message.body.signedExecutionPayloadBid.message.parentBlockHash = parentPayloadHash; + const childBlockRootHex = toRootHex(ssz.gloas.BeaconBlock.hashTreeRoot(childBlock.message)); + const childBlockInput = BlockInputPreData.createFromBlock({ + block: childBlock, + blockRootHex: childBlockRootHex, + forkName: gloasConfig.getForkName(childBlock.message.slot), + daOutOfRange: false, + seenTimestampSec: Date.now() / 1000, + source: BlockInputSource.gossip, + }); + + const networkEvents = new NetworkEventBus(); + const sendExecutionPayloadEnvelopesByRoot = vi.fn().mockResolvedValue([envelope]); + const processExecutionPayload = vi + .fn() + .mockRejectedValue(new PayloadError({code: PayloadErrorCode.INVALID_SIGNATURE})); + const processBlock = vi.fn().mockResolvedValue(undefined); + + const network: Partial = { + events: networkEvents, + getConnectedPeers: () => [peer], + getConnectedPeerSyncMeta: () => ({ + peerId: peer, + client: "payload-test-client", + custodyColumns: [], + earliestAvailableSlot: 0, + }), + custodyConfig: {sampledColumns: [], sampleGroups: [[]]} as unknown as CustodyConfig, + sendExecutionPayloadEnvelopesByRoot, + }; + + const emitter = new ChainEventEmitter(); + const seenPayloadPrune = vi.fn(); + const chain: Partial = { + emitter, + clock: new ClockStopped(0), + config: gloasConfig, + genesisTime: 0, + metrics: null, + processExecutionPayload, + processBlock, + seenPayloadEnvelopeInputCache: { + get: vi.fn().mockImplementation((root: string) => (root === parentRootHex ? payloadInput : undefined)), + prune: seenPayloadPrune, + } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + seenBlockInputCache: {prune: vi.fn()} as unknown as SeenBlockInput, + seenBlockProposers: {isKnown: vi.fn().mockReturnValue(false)} as unknown as SeenBlockProposers, + forkChoice: { + hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), + hasBlockHex: vi.fn().mockImplementation((root: string) => root === parentRootHex), + getBlockHexDefaultStatus: vi + .fn() + .mockImplementation((root: string) => (root === parentRootHex ? ({slot: 1} as ProtoBlock) : null)), + getBlockHexAndBlockHash: vi.fn().mockReturnValue(null), + getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), + } as unknown as IForkChoice, + }; + + service = new BlockInputSync( + gloasConfig, + network as INetwork, + chain as IBeaconChain, + logger, + null, + defaultSyncOptions + ); + service.subscribeToNetwork(); + + networkEvents.emit(NetworkEvent.peerConnected, { + peer, + status: {} as never, + custodyColumns: [], + clientAgent: "payload-test-client", + }); + + emitter.emit(ChainEvent.blockUnknownParent, { + blockInput: childBlockInput, + peer, + source: BlockInputSource.gossip, + }); + + await sleep(50); + + expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenCalledTimes(1); + expect(processExecutionPayload).toHaveBeenCalledTimes(1); + expect(processBlock).not.toHaveBeenCalled(); + expect(seenPayloadPrune).toHaveBeenCalledWith(parentRootHex); + + emitter.emit(routes.events.EventType.executionPayload, { + slot: 99, + builderIndex: 0, + blockHash: toRootHex(Buffer.alloc(32, 0x44)), + blockRoot: toRootHex(Buffer.alloc(32, 0x55)), + stateRoot: toRootHex(Buffer.alloc(32, 0x66)), + executionOptimistic: false, + }); + + await sleep(20); + + expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenCalledTimes(1); + expect(processExecutionPayload).toHaveBeenCalledTimes(1); + expect(processBlock).not.toHaveBeenCalled(); + }); + }); }); describe("UnknownBlockPeerBalancer", async () => { From 107664716626bed73061b2937b952e33f8d96a94 Mon Sep 17 00:00:00 2001 From: Cayman Date: Tue, 21 Apr 2026 09:43:27 -0400 Subject: [PATCH 02/67] fix: requeue incomplete gloas block inputs --- packages/beacon-node/src/sync/unknownBlock.ts | 35 +++- .../test/unit/sync/unknownBlock.test.ts | 185 +++++++++++++++++- 2 files changed, 214 insertions(+), 6 deletions(-) diff --git a/packages/beacon-node/src/sync/unknownBlock.ts b/packages/beacon-node/src/sync/unknownBlock.ts index 5cf36067d510..335f9e084fb4 100644 --- a/packages/beacon-node/src/sync/unknownBlock.ts +++ b/packages/beacon-node/src/sync/unknownBlock.ts @@ -51,7 +51,13 @@ const MAX_ATTEMPTS_PER_BLOCK = 5; const MAX_KNOWN_BAD_BLOCKS = 500; const MAX_PENDING_BLOCKS = 100; -type AdvancePendingBlockResult = "ready" | "queued_parent_block" | "queued_parent_payload" | "blocked" | "removed"; +type AdvancePendingBlockResult = + | "ready" + | "queued_block" + | "queued_parent_block" + | "queued_parent_payload" + | "blocked" + | "removed"; enum FetchResult { SuccessResolved = "success_resolved", @@ -451,7 +457,7 @@ export class BlockInputSync { blockInput: IBlockInput ): | {kind: "ready"} - | {kind: "parentBlock" | "parentPayload"; rootHex: RootHex} + | {kind: "block" | "parentBlock" | "parentPayload"; rootHex: RootHex} | {kind: "invalidParentPayload"; parentRootHex: RootHex; parentBlockHashHex: RootHex} { const parentRootHex = blockInput.parentRootHex; if (!this.chain.forkChoice.hasBlockHex(parentRootHex)) { @@ -463,7 +469,7 @@ export class BlockInputSync { } if (!blockInput.hasBlock()) { - throw new Error(`Expected blockInput with block for post-gloas dependency check root=${blockInput.blockRootHex}`); + return {kind: "block", rootHex: blockInput.blockRootHex}; } const block = blockInput.getBlock() as gloas.SignedBeaconBlock; @@ -495,6 +501,10 @@ export class BlockInputSync { case "ready": return "ready"; + case "block": + pendingBlock.status = PendingBlockInputStatus.pending; + return "queued_block"; + case "parentBlock": { let added = this.addByRootHex(missingDependency.rootHex); for (const peerIdStr of pendingBlock.peerIdStrings) { @@ -569,7 +579,7 @@ export class BlockInputSync { this.processReadyBlock(block).catch((e) => { this.logger.debug("Unexpected error - process old downloaded block", {}, e); }); - } else if (advanceResult === "queued_parent_block") { + } else if (advanceResult === "queued_block" || advanceResult === "queued_parent_block") { shouldRerunBlockSearch = true; } } @@ -670,7 +680,11 @@ export class BlockInputSync { this.processReadyBlock(pending).catch((e) => { this.logger.debug("Unexpected error - process newly downloaded block", logCtx2, e); }); - } else if (advanceResult === "queued_parent_block" || advanceResult === "queued_parent_payload") { + } else if ( + advanceResult === "queued_block" || + advanceResult === "queued_parent_block" || + advanceResult === "queued_parent_payload" + ) { this.triggerUnknownBlockSearch(); } } else if (blockSlot <= finalizedSlot) { @@ -778,6 +792,17 @@ export class BlockInputSync { break; } + if (missingDependency.kind === "block") { + this.logger.debug( + "Attempted to process block before its full body was available", + errorData, + res.err + ); + pendingBlock.status = PendingBlockInputStatus.pending; + this.triggerUnknownBlockSearch(); + break; + } + this.logger.debug( "Attempted to process block but its parent payload was still unknown", errorData, diff --git a/packages/beacon-node/test/unit/sync/unknownBlock.test.ts b/packages/beacon-node/test/unit/sync/unknownBlock.test.ts index afd5e06b5eb6..d84d2ebe7366 100644 --- a/packages/beacon-node/test/unit/sync/unknownBlock.test.ts +++ b/packages/beacon-node/test/unit/sync/unknownBlock.test.ts @@ -10,7 +10,7 @@ import {ForkName} from "@lodestar/params"; import {SignedBeaconBlock, gloas, ssz} from "@lodestar/types"; import {notNullish, sleep, toRootHex} from "@lodestar/utils"; import {BlockInputPreData} from "../../../src/chain/blocks/blockInput/blockInput.js"; -import {BlockInputSource} from "../../../src/chain/blocks/blockInput/types.js"; +import {BlockInputSource, DAType, IBlockInput} from "../../../src/chain/blocks/blockInput/types.js"; import {PayloadError, PayloadErrorCode} from "../../../src/chain/blocks/importExecutionPayload.js"; import {PayloadEnvelopeInput} from "../../../src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.js"; import { @@ -27,6 +27,7 @@ import {INetwork, NetworkEvent, NetworkEventBus} from "../../../src/network/inde import {PeerSyncMeta} from "../../../src/network/peers/peersData.js"; import {defaultSyncOptions} from "../../../src/sync/options.js"; import {BlockInputSync, UnknownBlockPeerBalancer} from "../../../src/sync/unknownBlock.js"; +import {BlockInputSyncCacheItem, PendingBlockInputStatus} from "../../../src/sync/types.js"; import {CustodyConfig} from "../../../src/util/dataColumns.js"; import {PeerIdStr} from "../../../src/util/peerId.js"; import {ClockStopped} from "../../mocks/clock.js"; @@ -98,6 +99,102 @@ function buildPayloadFixture({ return {block, blockRootHex, blockRoot, payloadInput, envelope, columnSidecars}; } +function buildIncompleteGloasBlockInput({ + parentRoot, + parentBlockHash, + slot, +}: { + parentRoot: Uint8Array; + parentBlockHash: Uint8Array; + slot: number; +}): { + block: gloas.SignedBeaconBlock; + blockRootHex: string; + parentBlockHashHex: string; + parentRootHex: string; + blockInput: IBlockInput; +} { + const block = ssz.gloas.SignedBeaconBlock.defaultValue(); + block.message.slot = slot; + block.message.parentRoot = parentRoot; + block.message.body.signedExecutionPayloadBid.message.parentBlockHash = parentBlockHash; + + const blockRootHex = toRootHex(ssz.gloas.BeaconBlock.hashTreeRoot(block.message)); + const parentRootHex = toRootHex(parentRoot); + const parentBlockHashHex = toRootHex(parentBlockHash); + + let currentBlock: SignedBeaconBlock | undefined; + let timeCompleteSec = 0; + let blockSource = { + source: BlockInputSource.byRoot, + seenTimestampSec: 0, + peerIdStr: undefined as string | undefined, + }; + + const blockInput: IBlockInput = { + type: DAType.NoData, + daOutOfRange: false, + timeCreatedSec: 0, + forkName: ForkName.gloas, + slot, + blockRootHex, + parentRootHex, + addBlock(props): void { + currentBlock = props.block; + timeCompleteSec = props.seenTimestampSec; + blockSource = { + source: props.source, + seenTimestampSec: props.seenTimestampSec, + peerIdStr: props.peerIdStr, + }; + }, + hasBlock(): boolean { + return currentBlock !== undefined; + }, + getBlock(): SignedBeaconBlock { + if (!currentBlock) { + throw new Error("Missing block"); + } + return currentBlock; + }, + getBlockSource() { + if (!currentBlock) { + throw new Error("Missing block source"); + } + return blockSource; + }, + hasAllData(): boolean { + return true; + }, + hasBlockAndAllData(): boolean { + return currentBlock !== undefined; + }, + getLogMeta() { + return {slot, blockRoot: blockRootHex, timeCreatedSec: 0}; + }, + getTimeComplete(): number { + if (!currentBlock) { + throw new Error("Missing completion time"); + } + return timeCompleteSec; + }, + getSerializedCacheKeys(): object[] { + return currentBlock ? [currentBlock] : []; + }, + waitForBlock(): Promise> { + return currentBlock ? Promise.resolve(currentBlock) : Promise.reject(new Error("Missing block")); + }, + waitForAllData(): Promise { + return Promise.resolve(null); + }, + waitForBlockAndAllData(): Promise> { + return currentBlock ? Promise.resolve(blockInput) : Promise.reject(new Error("Missing block")); + }, + }; + + return {block, blockRootHex, parentBlockHashHex, parentRootHex, blockInput}; +} + describe("sync by UnknownBlockSync", {timeout: 20_000}, () => { const logger = testLogger(); const slotSec = 0.3; @@ -1461,6 +1558,92 @@ describe("UnknownBlockSync", () => { expect(processBlock).not.toHaveBeenCalled(); }); }); + + it("re-queues downloaded gloas ancestors that are still missing the block body", async () => { + const gloasConfig = createBeaconConfig( + {...minimalConfig, FULU_FORK_EPOCH: 0, GLOAS_FORK_EPOCH: 0}, + Buffer.alloc(32, 0) + ); + const peer = await getRandPeerIdStr(); + const parentRoot = Buffer.alloc(32, 0x11); + const parentBlockHash = Buffer.alloc(32, 0x22); + const {block, blockInput, blockRootHex, parentBlockHashHex, parentRootHex} = buildIncompleteGloasBlockInput({ + parentRoot, + parentBlockHash, + slot: 1, + }); + + const sendBeaconBlocksByRoot = vi.fn().mockResolvedValue([block]); + + const processBlock = vi.fn().mockResolvedValue(undefined); + const networkEvents = new NetworkEventBus(); + network = { + events: networkEvents, + getConnectedPeers: () => [peer], + getConnectedPeerSyncMeta: () => ({ + peerId: peer, + client: "gloas-test-client", + custodyColumns: [], + earliestAvailableSlot: 0, + }), + custodyConfig: { + sampledColumns: [], + sampleGroups: Array.from({length: gloasConfig.SAMPLES_PER_SLOT}, () => []), + } as unknown as CustodyConfig, + sendBeaconBlocksByRoot, + } as Partial as INetwork; + + const chainForTest: Partial = { + emitter: new ChainEventEmitter(), + config: gloasConfig, + clock: new ClockStopped(0), + genesisTime: 0, + metrics: null, + processBlock, + forkChoice: { + getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), + hasBlockHex: vi.fn().mockImplementation((rootHex: string) => rootHex === parentRootHex), + getBlockHexAndBlockHash: vi.fn().mockImplementation((rootHex: string, blockHashHex: string) => + rootHex === parentRootHex && blockHashHex === parentBlockHashHex ? ({} as ProtoBlock) : null + ), + hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), + } as unknown as IForkChoice, + seenPayloadEnvelopeInputCache: { + get: vi.fn(), + prune: vi.fn(), + } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + seenBlockInputCache: {prune: vi.fn()} as unknown as SeenBlockInput, + seenBlockProposers: { + isKnown: vi.fn().mockReturnValue(false), + } as unknown as SeenBlockProposers, + }; + + service = new BlockInputSync(gloasConfig, network, chainForTest as IBeaconChain, logger, null, defaultSyncOptions); + service.subscribeToNetwork(); + + const pendingBlocks = (service as unknown as {pendingBlocks: Map}).pendingBlocks; + pendingBlocks.set(blockRootHex, { + status: PendingBlockInputStatus.downloaded, + blockInput, + timeAddedSec: 0, + peerIdStrings: new Set([peer]), + }); + + networkEvents.emit(NetworkEvent.peerConnected, { + peer, + status: {} as never, + custodyColumns: [], + clientAgent: "gloas-test-client", + }); + + await sleep(20); + + expect(sendBeaconBlocksByRoot).toHaveBeenCalledOnce(); + expect(processBlock).toHaveBeenCalledOnce(); + expect(pendingBlocks.has(blockRootHex)).toBe(false); + + service.close(); + }); }); describe("UnknownBlockPeerBalancer", async () => { From bb509e757c5e23960780b22f17f75f8c2c076dc4 Mon Sep 17 00:00:00 2001 From: Cayman Date: Tue, 21 Apr 2026 10:13:14 -0400 Subject: [PATCH 03/67] chore: lint fix --- packages/beacon-node/src/sync/unknownBlock.ts | 6 +----- .../beacon-node/test/unit/sync/unknownBlock.test.ts | 10 ++++++---- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/beacon-node/src/sync/unknownBlock.ts b/packages/beacon-node/src/sync/unknownBlock.ts index 335f9e084fb4..e4121e6339cf 100644 --- a/packages/beacon-node/src/sync/unknownBlock.ts +++ b/packages/beacon-node/src/sync/unknownBlock.ts @@ -793,11 +793,7 @@ export class BlockInputSync { } if (missingDependency.kind === "block") { - this.logger.debug( - "Attempted to process block before its full body was available", - errorData, - res.err - ); + this.logger.debug("Attempted to process block before its full body was available", errorData, res.err); pendingBlock.status = PendingBlockInputStatus.pending; this.triggerUnknownBlockSearch(); break; diff --git a/packages/beacon-node/test/unit/sync/unknownBlock.test.ts b/packages/beacon-node/test/unit/sync/unknownBlock.test.ts index d84d2ebe7366..85006a4d1e26 100644 --- a/packages/beacon-node/test/unit/sync/unknownBlock.test.ts +++ b/packages/beacon-node/test/unit/sync/unknownBlock.test.ts @@ -26,8 +26,8 @@ import {validateGossipExecutionPayloadEnvelope} from "../../../src/chain/validat import {INetwork, NetworkEvent, NetworkEventBus} from "../../../src/network/index.js"; import {PeerSyncMeta} from "../../../src/network/peers/peersData.js"; import {defaultSyncOptions} from "../../../src/sync/options.js"; -import {BlockInputSync, UnknownBlockPeerBalancer} from "../../../src/sync/unknownBlock.js"; import {BlockInputSyncCacheItem, PendingBlockInputStatus} from "../../../src/sync/types.js"; +import {BlockInputSync, UnknownBlockPeerBalancer} from "../../../src/sync/unknownBlock.js"; import {CustodyConfig} from "../../../src/util/dataColumns.js"; import {PeerIdStr} from "../../../src/util/peerId.js"; import {ClockStopped} from "../../mocks/clock.js"; @@ -1603,9 +1603,11 @@ describe("UnknownBlockSync", () => { forkChoice: { getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), hasBlockHex: vi.fn().mockImplementation((rootHex: string) => rootHex === parentRootHex), - getBlockHexAndBlockHash: vi.fn().mockImplementation((rootHex: string, blockHashHex: string) => - rootHex === parentRootHex && blockHashHex === parentBlockHashHex ? ({} as ProtoBlock) : null - ), + getBlockHexAndBlockHash: vi + .fn() + .mockImplementation((rootHex: string, blockHashHex: string) => + rootHex === parentRootHex && blockHashHex === parentBlockHashHex ? ({} as ProtoBlock) : null + ), hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), } as unknown as IForkChoice, seenPayloadEnvelopeInputCache: { From 555d6c3a4c56d5c411ce35ce82352cb070d38ca0 Mon Sep 17 00:00:00 2001 From: Cayman Date: Tue, 21 Apr 2026 10:44:23 -0400 Subject: [PATCH 04/67] chore: add some comments --- packages/beacon-node/src/sync/unknownBlock.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/beacon-node/src/sync/unknownBlock.ts b/packages/beacon-node/src/sync/unknownBlock.ts index e4121e6339cf..0bde8ddcf2c5 100644 --- a/packages/beacon-node/src/sync/unknownBlock.ts +++ b/packages/beacon-node/src/sync/unknownBlock.ts @@ -104,6 +104,7 @@ export class BlockInputSync { * block RootHex -> PendingBlock. To avoid finding same root at the same time */ private readonly pendingBlocks = new Map(); + // Payload recovery is keyed by beacon block root as well, so block and payload queues can unblock each other. private readonly pendingPayloads = new Map(); private readonly knownBadBlocks = new Set(); private readonly maxPendingBlocks; @@ -453,6 +454,10 @@ export class BlockInputSync { this.peerBalancer.onPeerDisconnected(peerId); }; + /** + * Post-gloas, a locally complete block can still be blocked on its parent's execution payload lineage. + * Distinguish which dependency is missing so the scheduler can enqueue the right follow-up work. + */ private getMissingBlockDependency( blockInput: IBlockInput ): @@ -538,6 +543,8 @@ export class BlockInputSync { previous?: PayloadSyncCacheItem, envelope?: gloas.SignedExecutionPayloadEnvelope ): PendingPayloadInput { + // Normalize every payload recovery path into the same cache shape while preserving first-seen + // timing and peer provenance from any earlier by-root or envelope-only entry. if (envelope && !payloadInput.hasPayloadEnvelope()) { payloadInput.addPayloadEnvelope({ envelope, @@ -605,6 +612,7 @@ export class BlockInputSync { }); } + // Blocks can unblock payloads and payloads can unblock blocks, so every scheduler pass services both queues. for (const payload of Array.from(this.pendingPayloads.values())) { if (isPendingPayloadInput(payload) && payload.status === PendingPayloadInputStatus.downloaded) { this.processPayload(payload).catch((e) => { @@ -838,6 +846,11 @@ export class BlockInputSync { } } + /** + * Reconcile an envelope-first payload entry once enough local block context exists to rebuild + * a PayloadEnvelopeInput. This may queue block recovery, validate the speculative envelope, or + * downgrade back to by-root fetching when the cached envelope does not match the recovered block. + */ private async reconcilePayloadEnvelope(pendingPayload: PendingPayloadEnvelope): Promise { const rootHex = getPayloadSyncCacheItemRootHex(pendingPayload); if (this.chain.forkChoice.hasPayloadHexUnsafe(rootHex)) { @@ -848,6 +861,7 @@ export class BlockInputSync { const payloadInput = await getOrRecoverPayloadEnvelopeInput(this.chain, rootHex); if (!payloadInput) { if (!this.chain.forkChoice.hasBlockHex(rootHex)) { + // Column commitments live on the block body, so an envelope-only entry has to pull the block first. if (!this.pendingBlocks.has(rootHex)) { this.addByRootHex(rootHex); } @@ -977,6 +991,8 @@ export class BlockInputSync { if (res.err instanceof PayloadError) { switch (res.err.type.code) { case PayloadErrorCode.BLOCK_NOT_IN_FORK_CHOICE: + // Payload recovery discovered the block dependency before the block queue did. Re-enqueue the + // block and keep the payload ready so the scheduler can retry once the block reaches fork choice. this.addByRootHex(rootHex); pendingPayload.status = PendingPayloadInputStatus.downloaded; this.triggerUnknownBlockSearch(); @@ -1005,6 +1021,11 @@ export class BlockInputSync { pendingPayload.status = PendingPayloadInputStatus.downloaded; } + /** + * Download payload material keyed by beacon block root. Unlike block recovery, payload recovery may + * already have a locally cached envelope or partial columns, so each attempt starts from local state + * and only asks peers for the remaining pieces. + */ private async fetchPayloadInput( cacheItem: PayloadSyncCacheItem ): Promise { @@ -1045,6 +1066,7 @@ export class BlockInputSync { payloadInput ??= await getOrRecoverPayloadEnvelopeInput(this.chain, rootHex); if (!payloadInput) { + // Keep the validated envelope around, but wait for the block body before turning it into a full payload input. return { status: PendingPayloadInputStatus.waitingForBlock, envelope, @@ -1363,6 +1385,7 @@ export class BlockInputSync { pruneSetToMax(this.knownBadBlocks, MAX_KNOWN_BAD_BLOCKS); } + // Once a parent payload is invalid, every descendant waiting on that payload lineage becomes unrecoverable too. private removePendingPayloadAndDescendants(rootHex: RootHex): void { this.chain.seenPayloadEnvelopeInputCache.prune(rootHex); this.pendingPayloads.delete(rootHex); From 06e9a8a69cbe09c7d0c64108f0b2afc0db9277a0 Mon Sep 17 00:00:00 2001 From: Cayman Date: Tue, 21 Apr 2026 11:56:28 -0400 Subject: [PATCH 05/67] test: improve unknown block payload sync coverage --- .../test/unit/sync/unknownBlock.test.ts | 991 ++++++++---------- 1 file changed, 464 insertions(+), 527 deletions(-) diff --git a/packages/beacon-node/test/unit/sync/unknownBlock.test.ts b/packages/beacon-node/test/unit/sync/unknownBlock.test.ts index 85006a4d1e26..ec82586f4f30 100644 --- a/packages/beacon-node/test/unit/sync/unknownBlock.test.ts +++ b/packages/beacon-node/test/unit/sync/unknownBlock.test.ts @@ -23,6 +23,7 @@ import {SeenBlockProposers} from "../../../src/chain/seenCache/seenBlockProposer import {SeenBlockInput} from "../../../src/chain/seenCache/seenGossipBlockInput.js"; import {validateGloasBlockDataColumnSidecars} from "../../../src/chain/validation/dataColumnSidecar.js"; import {validateGossipExecutionPayloadEnvelope} from "../../../src/chain/validation/executionPayloadEnvelope.js"; +import {ExecutionPayloadStatus} from "../../../src/execution/index.js"; import {INetwork, NetworkEvent, NetworkEventBus} from "../../../src/network/index.js"; import {PeerSyncMeta} from "../../../src/network/peers/peersData.js"; import {defaultSyncOptions} from "../../../src/sync/options.js"; @@ -539,49 +540,43 @@ describe("UnknownBlockSync", () => { {...minimalConfig, FULU_FORK_EPOCH: 0, GLOAS_FORK_EPOCH: 0}, Buffer.alloc(32, 0) ); + type PayloadSyncTestPeer = { + peerId: PeerIdStr; + client?: string; + custodyColumns?: number[]; + earliestAvailableSlot?: number; + }; - beforeEach(() => { - vi.useFakeTimers({shouldAdvanceTime: true}); - vi.mocked(validateGossipExecutionPayloadEnvelope).mockClear(); - vi.mocked(validateGloasBlockDataColumnSidecars).mockClear(); - }); - - it("fetches and processes unknown envelope by root when payload input exists", async () => { - const peer = await getRandPeerIdStr(); - const {blockRoot, blockRootHex, payloadInput, envelope, columnSidecars} = buildPayloadFixture({ - blobCount: 1, - sampledColumns: [0], - slot: 1, - }); - + function setupPayloadSyncTest({ + chainOverrides, + custodyConfig = {sampledColumns: [], sampleGroups: [[]]} as unknown as CustodyConfig, + networkOverrides, + peers = [], + }: { + chainOverrides?: Partial; + custodyConfig?: CustodyConfig; + networkOverrides?: Partial; + peers?: PayloadSyncTestPeer[]; + }): { + chain: IBeaconChain; + emitter: ChainEventEmitter; + network: INetwork; + networkEvents: NetworkEventBus; + } { + const emitter = new ChainEventEmitter(); const networkEvents = new NetworkEventBus(); - const sendExecutionPayloadEnvelopesByRoot = vi.fn().mockResolvedValue([envelope]); - const sendDataColumnSidecarsByRoot = vi.fn().mockResolvedValue(columnSidecars); - const network: Partial = { - events: networkEvents, - getConnectedPeers: () => [peer], - getConnectedPeerSyncMeta: () => ({ - peerId: peer, - client: "payload-test-client", - custodyColumns: [0], - earliestAvailableSlot: 0, - }), - custodyConfig: {sampledColumns: [0], sampleGroups: [[0]]} as unknown as CustodyConfig, - sendExecutionPayloadEnvelopesByRoot, - sendDataColumnSidecarsByRoot, - }; + const peersById = new Map(peers.map((peer) => [peer.peerId, peer])); - const processExecutionPayload = vi.fn().mockResolvedValue(undefined); - const emitter = new ChainEventEmitter(); - const chain: Partial = { + const chain = { emitter, clock: new ClockStopped(0), config: gloasConfig, genesisTime: 0, metrics: null, - processExecutionPayload, + getBlockByRoot: vi.fn().mockResolvedValue(null), + processExecutionPayload: vi.fn().mockResolvedValue(undefined), seenPayloadEnvelopeInputCache: { - get: vi.fn().mockImplementation((root: string) => (root === blockRootHex ? payloadInput : undefined)), + get: vi.fn().mockReturnValue(undefined), prune: vi.fn(), } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], seenBlockInputCache: {prune: vi.fn()} as unknown as SeenBlockInput, @@ -591,23 +586,75 @@ describe("UnknownBlockSync", () => { hasBlockHex: vi.fn().mockReturnValue(true), getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), } as unknown as IForkChoice, - }; + ...chainOverrides, + } as IBeaconChain; - service = new BlockInputSync( - gloasConfig, - network as INetwork, - chain as IBeaconChain, - logger, - null, - defaultSyncOptions - ); + const network = { + events: networkEvents, + getConnectedPeers: () => peers.map(({peerId}) => peerId), + getConnectedPeerSyncMeta: (peerId: string) => { + const peer = peersById.get(peerId); + if (!peer) { + throw new Error(`Unknown peer ${peerId}`); + } + + return { + peerId, + client: peer.client ?? "payload-test-client", + custodyColumns: peer.custodyColumns ?? [], + earliestAvailableSlot: peer.earliestAvailableSlot ?? 0, + }; + }, + custodyConfig, + ...networkOverrides, + } as INetwork; + + service = new BlockInputSync(gloasConfig, network, chain, logger, null, defaultSyncOptions); service.subscribeToNetwork(); - networkEvents.emit(NetworkEvent.peerConnected, { - peer, - status: {} as never, - custodyColumns: [0], - clientAgent: "payload-test-client", + for (const peer of peers) { + networkEvents.emit(NetworkEvent.peerConnected, { + peer: peer.peerId, + status: {} as never, + custodyColumns: peer.custodyColumns ?? [], + clientAgent: peer.client ?? "payload-test-client", + }); + } + + return {chain, emitter, network, networkEvents}; + } + + beforeEach(() => { + vi.useFakeTimers({shouldAdvanceTime: true}); + vi.mocked(validateGossipExecutionPayloadEnvelope).mockClear(); + vi.mocked(validateGloasBlockDataColumnSidecars).mockClear(); + }); + + it("fetches and processes unknown envelope by root when payload input exists", async () => { + const peer = await getRandPeerIdStr(); + const {blockRoot, blockRootHex, payloadInput, envelope, columnSidecars} = buildPayloadFixture({ + blobCount: 1, + sampledColumns: [0], + slot: 1, + }); + + const sendExecutionPayloadEnvelopesByRoot = vi.fn().mockResolvedValue([envelope]); + const sendDataColumnSidecarsByRoot = vi.fn().mockResolvedValue(columnSidecars); + const processExecutionPayload = vi.fn().mockResolvedValue(undefined); + const {emitter} = setupPayloadSyncTest({ + chainOverrides: { + processExecutionPayload, + seenPayloadEnvelopeInputCache: { + get: vi.fn().mockImplementation((root: string) => (root === blockRootHex ? payloadInput : undefined)), + prune: vi.fn(), + } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + }, + custodyConfig: {sampledColumns: [0], sampleGroups: [[0]]} as unknown as CustodyConfig, + networkOverrides: { + sendExecutionPayloadEnvelopesByRoot, + sendDataColumnSidecarsByRoot, + }, + peers: [{peerId: peer, custodyColumns: [0]}], }); emitter.emit(ChainEvent.unknownEnvelopeBlockRoot, { @@ -639,7 +686,6 @@ describe("UnknownBlockSync", () => { slot: 1, }); - const networkEvents = new NetworkEventBus(); const sendExecutionPayloadEnvelopesByRoot = vi.fn().mockResolvedValue([envelope]); const sendDataColumnSidecarsByRoot = vi .fn() @@ -659,59 +705,24 @@ describe("UnknownBlockSync", () => { }); const processExecutionPayload = vi.fn().mockResolvedValue(undefined); - const emitter = new ChainEventEmitter(); - const chain: Partial = { - emitter, - clock: new ClockStopped(0), - config: gloasConfig, - genesisTime: 0, - metrics: null, - processExecutionPayload, - seenPayloadEnvelopeInputCache: { - get: vi.fn().mockImplementation((root: string) => (root === blockRootHex ? payloadInput : undefined)), - prune: vi.fn(), - } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], - seenBlockInputCache: {prune: vi.fn()} as unknown as SeenBlockInput, - seenBlockProposers: {isKnown: vi.fn().mockReturnValue(false)} as unknown as SeenBlockProposers, - forkChoice: { - hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), - hasBlockHex: vi.fn().mockReturnValue(true), - getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), - } as unknown as IForkChoice, - }; - - const network: Partial = { - events: networkEvents, - getConnectedPeers: () => [peerA, peerB], - getConnectedPeerSyncMeta: (peerId: string) => ({ - peerId, - client: `payload-test-client-${peerId === peerA ? "a" : "b"}`, - custodyColumns: peerId === peerA ? [0] : [1], - earliestAvailableSlot: 0, - }), + const {emitter} = setupPayloadSyncTest({ + chainOverrides: { + processExecutionPayload, + seenPayloadEnvelopeInputCache: { + get: vi.fn().mockImplementation((root: string) => (root === blockRootHex ? payloadInput : undefined)), + prune: vi.fn(), + } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + }, custodyConfig: {sampledColumns: [0, 1], sampleGroups: [[0], [1]]} as unknown as CustodyConfig, - sendExecutionPayloadEnvelopesByRoot, - sendDataColumnSidecarsByRoot, - }; - - service = new BlockInputSync( - gloasConfig, - network as INetwork, - chain as IBeaconChain, - logger, - null, - defaultSyncOptions - ); - service.subscribeToNetwork(); - - for (const peer of [peerA, peerB]) { - networkEvents.emit(NetworkEvent.peerConnected, { - peer, - status: {} as never, - custodyColumns: peer === peerA ? [0] : [1], - clientAgent: `payload-test-client-${peer === peerA ? "a" : "b"}`, - }); - } + networkOverrides: { + sendExecutionPayloadEnvelopesByRoot, + sendDataColumnSidecarsByRoot, + }, + peers: [ + {peerId: peerA, client: "payload-test-client-a", custodyColumns: [0]}, + {peerId: peerB, client: "payload-test-client-b", custodyColumns: [1]}, + ], + }); emitter.emit(ChainEvent.unknownEnvelopeBlockRoot, { rootHex: blockRootHex, @@ -744,97 +755,63 @@ describe("UnknownBlockSync", () => { let cachedPayloadInput: PayloadEnvelopeInput | undefined; const knownRoots = new Set([parentRootHex]); - const networkEvents = new NetworkEventBus(); const sendExecutionPayloadEnvelopesByRoot = vi.fn().mockResolvedValue([envelope]); const sendBeaconBlocksByRoot = vi.fn().mockResolvedValue([block]); const processExecutionPayload = vi.fn().mockResolvedValue(undefined); - const emitter = new ChainEventEmitter(); const processBlock = vi.fn().mockImplementation(async () => { cachedPayloadInput = payloadInput; knownRoots.add(blockRootHex); emitter.emit(routes.events.EventType.block, {slot: 1, block: blockRootHex, executionOptimistic: false}); }); - - const chain: Partial = { - emitter, - clock: new ClockStopped(0), - config: gloasConfig, - genesisTime: 0, - metrics: null, - processBlock, - processExecutionPayload, - getBlockByRoot: vi.fn().mockResolvedValue(null), - seenPayloadEnvelopeInputCache: { - get: vi.fn().mockImplementation((root: string) => (root === blockRootHex ? cachedPayloadInput : undefined)), - prune: vi.fn(), - } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], - seenBlockInputCache: { - getByBlock: ({ - block, - blockRootHex, - seenTimestampSec, - source, - }: { - block: SignedBeaconBlock; - blockRootHex: string; - seenTimestampSec: number; - source: BlockInputSource; - }) => - BlockInputPreData.createFromBlock({ + const {emitter} = setupPayloadSyncTest({ + chainOverrides: { + processBlock, + processExecutionPayload, + seenPayloadEnvelopeInputCache: { + get: vi.fn().mockImplementation((root: string) => (root === blockRootHex ? cachedPayloadInput : undefined)), + prune: vi.fn(), + } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + seenBlockInputCache: { + getByBlock: ({ block, blockRootHex, - forkName: gloasConfig.getForkName(block.message.slot), - daOutOfRange: false, seenTimestampSec, source, - }), - prune: vi.fn(), - } as unknown as SeenBlockInput, - seenBlockProposers: {isKnown: vi.fn().mockReturnValue(false)} as unknown as SeenBlockProposers, - forkChoice: { - hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), - hasBlockHex: vi.fn().mockImplementation((root: string) => knownRoots.has(root)), - getBlockHexAndBlockHash: vi - .fn() - .mockImplementation((root: string, hash: string) => - root === parentRootHex && - hash === toHexString(block.message.body.signedExecutionPayloadBid.message.parentBlockHash) - ? ({slot: 0} as ProtoBlock) - : null - ), - getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), - } as unknown as IForkChoice, - }; - - const network: Partial = { - events: networkEvents, - getConnectedPeers: () => [peer], - getConnectedPeerSyncMeta: () => ({ - peerId: peer, - client: "payload-test-client", - custodyColumns: [], - earliestAvailableSlot: 0, - }), - custodyConfig: {sampledColumns: [], sampleGroups: [[]]} as unknown as CustodyConfig, - sendExecutionPayloadEnvelopesByRoot, - sendBeaconBlocksByRoot, - }; - - service = new BlockInputSync( - gloasConfig, - network as INetwork, - chain as IBeaconChain, - logger, - null, - defaultSyncOptions - ); - service.subscribeToNetwork(); - - networkEvents.emit(NetworkEvent.peerConnected, { - peer, - status: {} as never, - custodyColumns: [], - clientAgent: "payload-test-client", + }: { + block: SignedBeaconBlock; + blockRootHex: string; + seenTimestampSec: number; + source: BlockInputSource; + }) => + BlockInputPreData.createFromBlock({ + block, + blockRootHex, + forkName: gloasConfig.getForkName(block.message.slot), + daOutOfRange: false, + seenTimestampSec, + source, + }), + prune: vi.fn(), + } as unknown as SeenBlockInput, + forkChoice: { + hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), + hasBlockHex: vi.fn().mockImplementation((root: string) => knownRoots.has(root)), + getBlockHexAndBlockHash: vi + .fn() + .mockImplementation((root: string, hash: string) => + root === parentRootHex && + hash === toHexString(block.message.body.signedExecutionPayloadBid.message.parentBlockHash) + ? ({slot: 0} as ProtoBlock) + : null + ), + getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), + } as unknown as IForkChoice, + }, + networkOverrides: { + sendExecutionPayloadEnvelopesByRoot, + sendBeaconBlocksByRoot, + }, + peers: [{peerId: peer}], }); emitter.emit(ChainEvent.unknownEnvelopeBlockRoot, { @@ -854,6 +831,103 @@ describe("UnknownBlockSync", () => { expect(processExecutionPayload).toHaveBeenCalledWith(payloadInput); }); + it("downloads the block and retries payload import when EL reports block not in fork choice", async () => { + const peer = await getRandPeerIdStr(); + const {block, blockRoot, blockRootHex, payloadInput, envelope} = buildPayloadFixture({ + blobCount: 0, + sampledColumns: [], + slot: 1, + }); + const parentRootHex = toRootHex(block.message.parentRoot); + const knownRoots = new Set([parentRootHex]); + + const sendExecutionPayloadEnvelopesByRoot = vi.fn().mockResolvedValue([envelope]); + const sendBeaconBlocksByRoot = vi.fn().mockResolvedValue([block]); + const processExecutionPayload = vi + .fn() + .mockRejectedValueOnce( + new PayloadError({ + code: PayloadErrorCode.BLOCK_NOT_IN_FORK_CHOICE, + blockRootHex, + }) + ) + .mockResolvedValueOnce(undefined); + + let emitter!: ChainEventEmitter; + const processBlock = vi.fn().mockImplementation(async () => { + knownRoots.add(blockRootHex); + emitter.emit(routes.events.EventType.block, {slot: 1, block: blockRootHex, executionOptimistic: false}); + }); + + ({emitter} = setupPayloadSyncTest({ + chainOverrides: { + processBlock, + processExecutionPayload, + seenPayloadEnvelopeInputCache: { + get: vi.fn().mockImplementation((root: string) => (root === blockRootHex ? payloadInput : undefined)), + prune: vi.fn(), + } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + seenBlockInputCache: { + getByBlock: ({ + block, + blockRootHex, + seenTimestampSec, + source, + }: { + block: SignedBeaconBlock; + blockRootHex: string; + seenTimestampSec: number; + source: BlockInputSource; + }) => + BlockInputPreData.createFromBlock({ + block, + blockRootHex, + forkName: gloasConfig.getForkName(block.message.slot), + daOutOfRange: false, + seenTimestampSec, + source, + }), + prune: vi.fn(), + } as unknown as SeenBlockInput, + forkChoice: { + hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), + hasBlockHex: vi.fn().mockImplementation((root: string) => knownRoots.has(root)), + getBlockHexAndBlockHash: vi + .fn() + .mockImplementation((root: string, hash: string) => + root === parentRootHex && + hash === toHexString(block.message.body.signedExecutionPayloadBid.message.parentBlockHash) + ? ({slot: 0} as ProtoBlock) + : null + ), + getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), + } as unknown as IForkChoice, + }, + networkOverrides: { + sendExecutionPayloadEnvelopesByRoot, + sendBeaconBlocksByRoot, + }, + peers: [{peerId: peer}], + })); + + emitter.emit(ChainEvent.unknownEnvelopeBlockRoot, { + rootHex: blockRootHex, + peer, + source: BlockInputSource.gossip, + }); + + await sleep(80); + + expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenCalledTimes(1); + expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenCalledWith(peer, [blockRoot]); + expect(sendBeaconBlocksByRoot).toHaveBeenCalledTimes(1); + expect(sendBeaconBlocksByRoot).toHaveBeenCalledWith(peer, [blockRoot]); + expect(processBlock).toHaveBeenCalledTimes(1); + expect(processExecutionPayload).toHaveBeenCalledTimes(2); + expect(processExecutionPayload).toHaveBeenNthCalledWith(1, payloadInput); + expect(processExecutionPayload).toHaveBeenNthCalledWith(2, payloadInput); + }); + it("waits for block after envelopeUnknownBlock and processes payload on block import", async () => { const peer = await getRandPeerIdStr(); const {blockRootHex, payloadInput, envelope} = buildPayloadFixture({ @@ -864,43 +938,20 @@ describe("UnknownBlockSync", () => { let cachedPayloadInput: PayloadEnvelopeInput | undefined; const processExecutionPayload = vi.fn().mockResolvedValue(undefined); - const emitter = new ChainEventEmitter(); - const chain: Partial = { - emitter, - clock: new ClockStopped(0), - config: gloasConfig, - genesisTime: 0, - metrics: null, - processExecutionPayload, - getBlockByRoot: vi.fn().mockResolvedValue(null), - seenPayloadEnvelopeInputCache: { - get: vi.fn().mockImplementation((root: string) => (root === blockRootHex ? cachedPayloadInput : undefined)), - prune: vi.fn(), - } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], - seenBlockInputCache: {prune: vi.fn()} as unknown as SeenBlockInput, - seenBlockProposers: {isKnown: vi.fn().mockReturnValue(false)} as unknown as SeenBlockProposers, - forkChoice: { - hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), - hasBlockHex: vi.fn().mockReturnValue(false), - getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), - } as unknown as IForkChoice, - }; - - const network: Partial = { - events: new NetworkEventBus(), - getConnectedPeers: () => [], - custodyConfig: {sampledColumns: [], sampleGroups: [[]]} as unknown as CustodyConfig, - }; - - service = new BlockInputSync( - gloasConfig, - network as INetwork, - chain as IBeaconChain, - logger, - null, - defaultSyncOptions - ); - service.subscribeToNetwork(); + const {emitter} = setupPayloadSyncTest({ + chainOverrides: { + processExecutionPayload, + seenPayloadEnvelopeInputCache: { + get: vi.fn().mockImplementation((root: string) => (root === blockRootHex ? cachedPayloadInput : undefined)), + prune: vi.fn(), + } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + forkChoice: { + hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), + hasBlockHex: vi.fn().mockReturnValue(false), + getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), + } as unknown as IForkChoice, + }, + }); emitter.emit(ChainEvent.envelopeUnknownBlock, { envelope, @@ -942,61 +993,20 @@ describe("UnknownBlockSync", () => { let cachedPayloadInput: PayloadEnvelopeInput | undefined; const processExecutionPayload = vi.fn().mockResolvedValue(undefined); - const emitter = new ChainEventEmitter(); - const chain: Partial = { - emitter, - clock: new ClockStopped(0), - config: gloasConfig, - genesisTime: 0, - metrics: null, - processExecutionPayload, - getBlockByRoot: vi.fn().mockResolvedValue(null), - seenPayloadEnvelopeInputCache: { - get: vi.fn().mockImplementation((root: string) => (root === blockRootHex ? cachedPayloadInput : undefined)), - prune: vi.fn(), - } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], - seenBlockInputCache: {prune: vi.fn()} as unknown as SeenBlockInput, - seenBlockProposers: {isKnown: vi.fn().mockReturnValue(false)} as unknown as SeenBlockProposers, - forkChoice: { - hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), - hasBlockHex: vi.fn().mockReturnValue(true), - getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), - } as unknown as IForkChoice, - }; - - const networkEvents = new NetworkEventBus(); const sendExecutionPayloadEnvelopesByRoot = vi .fn() .mockResolvedValueOnce([invalidEnvelope]) .mockResolvedValueOnce([envelope]); - const network: Partial = { - events: networkEvents, - getConnectedPeers: () => [peer], - getConnectedPeerSyncMeta: () => ({ - peerId: peer, - client: "payload-test-client", - custodyColumns: [], - earliestAvailableSlot: 0, - }), - custodyConfig: {sampledColumns: [], sampleGroups: [[]]} as unknown as CustodyConfig, - sendExecutionPayloadEnvelopesByRoot, - }; - - service = new BlockInputSync( - gloasConfig, - network as INetwork, - chain as IBeaconChain, - logger, - null, - defaultSyncOptions - ); - service.subscribeToNetwork(); - - networkEvents.emit(NetworkEvent.peerConnected, { - peer, - status: {} as never, - custodyColumns: [], - clientAgent: "payload-test-client", + const {emitter} = setupPayloadSyncTest({ + chainOverrides: { + processExecutionPayload, + seenPayloadEnvelopeInputCache: { + get: vi.fn().mockImplementation((root: string) => (root === blockRootHex ? cachedPayloadInput : undefined)), + prune: vi.fn(), + } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + }, + networkOverrides: {sendExecutionPayloadEnvelopesByRoot}, + peers: [{peerId: peer}], }); emitter.emit(ChainEvent.unknownEnvelopeBlockRoot, { @@ -1061,63 +1071,23 @@ describe("UnknownBlockSync", () => { cachedPayloadInput = PayloadEnvelopeInput.createFromBlock(props); return cachedPayloadInput; }); - const emitter = new ChainEventEmitter(); - const chain: Partial = { - emitter, - clock: new ClockStopped(0), - config: gloasConfig, - genesisTime: 0, - metrics: null, - processExecutionPayload, - custodyConfig: {sampledColumns: [], custodyColumns: []} as unknown as CustodyConfig, - getBlockByRoot: vi.fn().mockResolvedValue({block, executionOptimistic: false, finalized: false}), - seenPayloadEnvelopeInputCache: { - add: addPayloadInput, - get: vi.fn().mockImplementation((root: string) => (root === blockRootHex ? cachedPayloadInput : undefined)), - prune: prunePayloadInput, - } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], - seenBlockInputCache: {prune: vi.fn()} as unknown as SeenBlockInput, - seenBlockProposers: {isKnown: vi.fn().mockReturnValue(false)} as unknown as SeenBlockProposers, - forkChoice: { - hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), - hasBlockHex: vi.fn().mockReturnValue(true), - getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), - } as unknown as IForkChoice, - }; - - const networkEvents = new NetworkEventBus(); const sendExecutionPayloadEnvelopesByRoot = vi .fn() .mockResolvedValueOnce([invalidEnvelope]) .mockResolvedValueOnce([recoveryEnvelope]); - const network: Partial = { - events: networkEvents, - getConnectedPeers: () => [peer], - getConnectedPeerSyncMeta: () => ({ - peerId: peer, - client: "payload-test-client", - custodyColumns: [], - earliestAvailableSlot: 0, - }), - custodyConfig: {sampledColumns: [], sampleGroups: [[]]} as unknown as CustodyConfig, - sendExecutionPayloadEnvelopesByRoot, - }; - - service = new BlockInputSync( - gloasConfig, - network as INetwork, - chain as IBeaconChain, - logger, - null, - defaultSyncOptions - ); - service.subscribeToNetwork(); - - networkEvents.emit(NetworkEvent.peerConnected, { - peer, - status: {} as never, - custodyColumns: [], - clientAgent: "payload-test-client", + const {emitter} = setupPayloadSyncTest({ + chainOverrides: { + processExecutionPayload, + custodyConfig: {sampledColumns: [], custodyColumns: []} as unknown as CustodyConfig, + getBlockByRoot: vi.fn().mockResolvedValue({block, executionOptimistic: false, finalized: false}), + seenPayloadEnvelopeInputCache: { + add: addPayloadInput, + get: vi.fn().mockImplementation((root: string) => (root === blockRootHex ? cachedPayloadInput : undefined)), + prune: prunePayloadInput, + } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + }, + networkOverrides: {sendExecutionPayloadEnvelopesByRoot}, + peers: [{peerId: peer}], }); emitter.emit(ChainEvent.unknownEnvelopeBlockRoot, { @@ -1150,6 +1120,100 @@ describe("UnknownBlockSync", () => { expect(cachedPayloadInput?.getPayloadEnvelope()).toBe(recoveryEnvelope); }); + it("retries payload processing on a later scheduler pass after an execution engine error", async () => { + const peer = await getRandPeerIdStr(); + const {blockRootHex, payloadInput, envelope} = buildPayloadFixture({ + blobCount: 0, + sampledColumns: [], + slot: 1, + }); + + const sendExecutionPayloadEnvelopesByRoot = vi.fn().mockResolvedValue([envelope]); + const processExecutionPayload = vi + .fn() + .mockRejectedValueOnce( + new PayloadError({ + code: PayloadErrorCode.EXECUTION_ENGINE_ERROR, + execStatus: ExecutionPayloadStatus.ELERROR, + errorMessage: "execution engine offline", + }) + ) + .mockResolvedValueOnce(undefined); + + const {emitter} = setupPayloadSyncTest({ + chainOverrides: { + processExecutionPayload, + seenPayloadEnvelopeInputCache: { + get: vi.fn().mockImplementation((root: string) => (root === blockRootHex ? payloadInput : undefined)), + prune: vi.fn(), + } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + }, + networkOverrides: {sendExecutionPayloadEnvelopesByRoot}, + peers: [{peerId: peer}], + }); + + emitter.emit(ChainEvent.unknownEnvelopeBlockRoot, { + rootHex: blockRootHex, + peer, + source: BlockInputSource.gossip, + }); + + await sleep(20); + + expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenCalledTimes(1); + expect(processExecutionPayload).toHaveBeenCalledTimes(1); + + emitter.emit(routes.events.EventType.block, {slot: 1, block: blockRootHex, executionOptimistic: false}); + + await sleep(20); + + expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenCalledTimes(1); + expect(processExecutionPayload).toHaveBeenCalledTimes(2); + expect(processExecutionPayload).toHaveBeenNthCalledWith(1, payloadInput); + expect(processExecutionPayload).toHaveBeenNthCalledWith(2, payloadInput); + }); + + it("ignores a fetched payload envelope whose block root does not match the requested root", async () => { + const peer = await getRandPeerIdStr(); + const {blockRoot, blockRootHex, payloadInput} = buildPayloadFixture({ + blobCount: 0, + sampledColumns: [], + slot: 1, + }); + const {envelope: mismatchedEnvelope} = buildPayloadFixture({ + blobCount: 0, + sampledColumns: [], + slot: 2, + }); + + const sendExecutionPayloadEnvelopesByRoot = vi.fn().mockResolvedValue([mismatchedEnvelope]); + const processExecutionPayload = vi.fn().mockResolvedValue(undefined); + const {emitter} = setupPayloadSyncTest({ + chainOverrides: { + processExecutionPayload, + seenPayloadEnvelopeInputCache: { + get: vi.fn().mockImplementation((root: string) => (root === blockRootHex ? payloadInput : undefined)), + prune: vi.fn(), + } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + }, + networkOverrides: {sendExecutionPayloadEnvelopesByRoot}, + peers: [{peerId: peer}], + }); + + emitter.emit(ChainEvent.unknownEnvelopeBlockRoot, { + rootHex: blockRootHex, + peer, + source: BlockInputSource.gossip, + }); + + await sleep(50); + + expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenCalledTimes(1); + expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenCalledWith(peer, [blockRoot]); + expect(validateGossipExecutionPayloadEnvelope).not.toHaveBeenCalled(); + expect(processExecutionPayload).not.toHaveBeenCalled(); + }); + it("processes incomplete payload envelope input without network fetch", async () => { const peer = await getRandPeerIdStr(); const {payloadInput, envelope} = buildPayloadFixture({blobCount: 0, sampledColumns: [], slot: 1}); @@ -1159,42 +1223,16 @@ describe("UnknownBlockSync", () => { seenTimestampSec: Date.now() / 1000, }); - const network: Partial = { - events: new NetworkEventBus(), - getConnectedPeers: () => [], - custodyConfig: {sampledColumns: [], sampleGroups: [[]]} as unknown as CustodyConfig, - }; const processExecutionPayload = vi.fn().mockResolvedValue(undefined); - const emitter = new ChainEventEmitter(); - const chain: Partial = { - emitter, - clock: new ClockStopped(0), - config: gloasConfig, - genesisTime: 0, - metrics: null, - processExecutionPayload, - seenPayloadEnvelopeInputCache: { - get: vi.fn().mockReturnValue(payloadInput), - prune: vi.fn(), - } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], - seenBlockInputCache: {prune: vi.fn()} as unknown as SeenBlockInput, - seenBlockProposers: {isKnown: vi.fn().mockReturnValue(false)} as unknown as SeenBlockProposers, - forkChoice: { - hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), - hasBlockHex: vi.fn().mockReturnValue(true), - getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), - } as unknown as IForkChoice, - }; - - service = new BlockInputSync( - gloasConfig, - network as INetwork, - chain as IBeaconChain, - logger, - null, - defaultSyncOptions - ); - service.subscribeToNetwork(); + const {emitter} = setupPayloadSyncTest({ + chainOverrides: { + processExecutionPayload, + seenPayloadEnvelopeInputCache: { + get: vi.fn().mockReturnValue(payloadInput), + prune: vi.fn(), + } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + }, + }); emitter.emit(ChainEvent.incompletePayloadEnvelope, { payloadInput, @@ -1239,75 +1277,43 @@ describe("UnknownBlockSync", () => { }); let hasParentPayload = false; - const networkEvents = new NetworkEventBus(); const sendExecutionPayloadEnvelopesByRoot = vi.fn().mockResolvedValue([envelope]); const sendBeaconBlocksByRoot = vi.fn(); const processExecutionPayload = vi.fn().mockImplementation(async () => { hasParentPayload = true; }); const processBlock = vi.fn().mockResolvedValue(undefined); - - const network: Partial = { - events: networkEvents, - getConnectedPeers: () => [peer], - getConnectedPeerSyncMeta: () => ({ - peerId: peer, - client: "payload-test-client", - custodyColumns: [], - earliestAvailableSlot: 0, - }), - custodyConfig: {sampledColumns: [], sampleGroups: [[]]} as unknown as CustodyConfig, - sendExecutionPayloadEnvelopesByRoot, - sendBeaconBlocksByRoot, - }; - - const emitter = new ChainEventEmitter(); - const chain: Partial = { - emitter, - clock: new ClockStopped(0), - config: gloasConfig, - genesisTime: 0, - metrics: null, - processExecutionPayload, - processBlock, - seenPayloadEnvelopeInputCache: { - get: vi.fn().mockImplementation((root: string) => (root === parentRootHex ? payloadInput : undefined)), - prune: vi.fn(), - } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], - seenBlockInputCache: {prune: vi.fn()} as unknown as SeenBlockInput, - seenBlockProposers: {isKnown: vi.fn().mockReturnValue(false)} as unknown as SeenBlockProposers, - forkChoice: { - hasPayloadHexUnsafe: vi.fn().mockImplementation((root: string) => root === parentRootHex && hasParentPayload), - hasBlockHex: vi.fn().mockImplementation((root: string) => root === parentRootHex), - getBlockHexDefaultStatus: vi - .fn() - .mockImplementation((root: string) => (root === parentRootHex ? ({slot: 1} as ProtoBlock) : null)), - getBlockHexAndBlockHash: vi - .fn() - .mockImplementation((root: string, hash: string) => - root === parentRootHex && hash === toHexString(parentPayloadHash) && hasParentPayload - ? ({slot: 1} as ProtoBlock) - : null - ), - getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), - } as unknown as IForkChoice, - }; - - service = new BlockInputSync( - gloasConfig, - network as INetwork, - chain as IBeaconChain, - logger, - null, - defaultSyncOptions - ); - service.subscribeToNetwork(); - - networkEvents.emit(NetworkEvent.peerConnected, { - peer, - status: {} as never, - custodyColumns: [], - clientAgent: "payload-test-client", + const {emitter} = setupPayloadSyncTest({ + chainOverrides: { + processExecutionPayload, + processBlock, + seenPayloadEnvelopeInputCache: { + get: vi.fn().mockImplementation((root: string) => (root === parentRootHex ? payloadInput : undefined)), + prune: vi.fn(), + } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + forkChoice: { + hasPayloadHexUnsafe: vi + .fn() + .mockImplementation((root: string) => root === parentRootHex && hasParentPayload), + hasBlockHex: vi.fn().mockImplementation((root: string) => root === parentRootHex), + getBlockHexDefaultStatus: vi + .fn() + .mockImplementation((root: string) => (root === parentRootHex ? ({slot: 1} as ProtoBlock) : null)), + getBlockHexAndBlockHash: vi + .fn() + .mockImplementation((root: string, hash: string) => + root === parentRootHex && hash === toHexString(parentPayloadHash) && hasParentPayload + ? ({slot: 1} as ProtoBlock) + : null + ), + getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), + } as unknown as IForkChoice, + }, + networkOverrides: { + sendExecutionPayloadEnvelopesByRoot, + sendBeaconBlocksByRoot, + }, + peers: [{peerId: peer}], }); emitter.emit(ChainEvent.blockUnknownParent, { @@ -1363,62 +1369,29 @@ describe("UnknownBlockSync", () => { source: BlockInputSource.gossip, }); - const networkEvents = new NetworkEventBus(); const sendExecutionPayloadEnvelopesByRoot = vi.fn(); const processBlock = vi.fn(); const seenBlockInputPrune = vi.fn(); const seenPayloadPrune = vi.fn(); - - const network: Partial = { - events: networkEvents, - getConnectedPeers: () => [peer], - getConnectedPeerSyncMeta: () => ({ - peerId: peer, - client: "payload-test-client", - custodyColumns: [], - earliestAvailableSlot: 0, - }), - custodyConfig: {sampledColumns: [], sampleGroups: [[]]} as unknown as CustodyConfig, - sendExecutionPayloadEnvelopesByRoot, - }; - - const emitter = new ChainEventEmitter(); - const chain: Partial = { - emitter, - clock: new ClockStopped(0), - config: gloasConfig, - genesisTime: 0, - metrics: null, - processBlock, - seenPayloadEnvelopeInputCache: { - get: vi.fn().mockImplementation((root: string) => (root === parentRootHex ? parentPayloadInput : undefined)), - prune: seenPayloadPrune, - } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], - seenBlockInputCache: {prune: seenBlockInputPrune} as unknown as SeenBlockInput, - seenBlockProposers: {isKnown: vi.fn().mockReturnValue(false)} as unknown as SeenBlockProposers, - forkChoice: { - hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), - hasBlockHex: vi.fn().mockImplementation((root: string) => root === parentRootHex), - getBlockHexAndBlockHash: vi.fn().mockReturnValue(null), - getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), - } as unknown as IForkChoice, - }; - - service = new BlockInputSync( - gloasConfig, - network as INetwork, - chain as IBeaconChain, - logger, - null, - defaultSyncOptions - ); - service.subscribeToNetwork(); - - networkEvents.emit(NetworkEvent.peerConnected, { - peer, - status: {} as never, - custodyColumns: [], - clientAgent: "payload-test-client", + const {emitter} = setupPayloadSyncTest({ + chainOverrides: { + processBlock, + seenPayloadEnvelopeInputCache: { + get: vi + .fn() + .mockImplementation((root: string) => (root === parentRootHex ? parentPayloadInput : undefined)), + prune: seenPayloadPrune, + } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + seenBlockInputCache: {prune: seenBlockInputPrune} as unknown as SeenBlockInput, + forkChoice: { + hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), + hasBlockHex: vi.fn().mockImplementation((root: string) => root === parentRootHex), + getBlockHexAndBlockHash: vi.fn().mockReturnValue(null), + getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), + } as unknown as IForkChoice, + }, + networkOverrides: {sendExecutionPayloadEnvelopesByRoot}, + peers: [{peerId: peer}], }); emitter.emit(ChainEvent.blockUnknownParent, { @@ -1465,68 +1438,32 @@ describe("UnknownBlockSync", () => { source: BlockInputSource.gossip, }); - const networkEvents = new NetworkEventBus(); const sendExecutionPayloadEnvelopesByRoot = vi.fn().mockResolvedValue([envelope]); const processExecutionPayload = vi .fn() .mockRejectedValue(new PayloadError({code: PayloadErrorCode.INVALID_SIGNATURE})); const processBlock = vi.fn().mockResolvedValue(undefined); - - const network: Partial = { - events: networkEvents, - getConnectedPeers: () => [peer], - getConnectedPeerSyncMeta: () => ({ - peerId: peer, - client: "payload-test-client", - custodyColumns: [], - earliestAvailableSlot: 0, - }), - custodyConfig: {sampledColumns: [], sampleGroups: [[]]} as unknown as CustodyConfig, - sendExecutionPayloadEnvelopesByRoot, - }; - - const emitter = new ChainEventEmitter(); const seenPayloadPrune = vi.fn(); - const chain: Partial = { - emitter, - clock: new ClockStopped(0), - config: gloasConfig, - genesisTime: 0, - metrics: null, - processExecutionPayload, - processBlock, - seenPayloadEnvelopeInputCache: { - get: vi.fn().mockImplementation((root: string) => (root === parentRootHex ? payloadInput : undefined)), - prune: seenPayloadPrune, - } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], - seenBlockInputCache: {prune: vi.fn()} as unknown as SeenBlockInput, - seenBlockProposers: {isKnown: vi.fn().mockReturnValue(false)} as unknown as SeenBlockProposers, - forkChoice: { - hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), - hasBlockHex: vi.fn().mockImplementation((root: string) => root === parentRootHex), - getBlockHexDefaultStatus: vi - .fn() - .mockImplementation((root: string) => (root === parentRootHex ? ({slot: 1} as ProtoBlock) : null)), - getBlockHexAndBlockHash: vi.fn().mockReturnValue(null), - getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), - } as unknown as IForkChoice, - }; - - service = new BlockInputSync( - gloasConfig, - network as INetwork, - chain as IBeaconChain, - logger, - null, - defaultSyncOptions - ); - service.subscribeToNetwork(); - - networkEvents.emit(NetworkEvent.peerConnected, { - peer, - status: {} as never, - custodyColumns: [], - clientAgent: "payload-test-client", + const {emitter} = setupPayloadSyncTest({ + chainOverrides: { + processExecutionPayload, + processBlock, + seenPayloadEnvelopeInputCache: { + get: vi.fn().mockImplementation((root: string) => (root === parentRootHex ? payloadInput : undefined)), + prune: seenPayloadPrune, + } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + forkChoice: { + hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), + hasBlockHex: vi.fn().mockImplementation((root: string) => root === parentRootHex), + getBlockHexDefaultStatus: vi + .fn() + .mockImplementation((root: string) => (root === parentRootHex ? ({slot: 1} as ProtoBlock) : null)), + getBlockHexAndBlockHash: vi.fn().mockReturnValue(null), + getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), + } as unknown as IForkChoice, + }, + networkOverrides: {sendExecutionPayloadEnvelopesByRoot}, + peers: [{peerId: peer}], }); emitter.emit(ChainEvent.blockUnknownParent, { From 16356ac29af705ccd6668280f6acede9de68d040 Mon Sep 17 00:00:00 2001 From: Cayman Date: Tue, 21 Apr 2026 14:20:51 -0400 Subject: [PATCH 06/67] fix: preserve queued payload envelopes --- packages/beacon-node/src/sync/unknownBlock.ts | 7 ++- .../test/unit/sync/unknownBlock.test.ts | 46 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/beacon-node/src/sync/unknownBlock.ts b/packages/beacon-node/src/sync/unknownBlock.ts index 0bde8ddcf2c5..3572af1e3b9c 100644 --- a/packages/beacon-node/src/sync/unknownBlock.ts +++ b/packages/beacon-node/src/sync/unknownBlock.ts @@ -545,9 +545,12 @@ export class BlockInputSync { ): PendingPayloadInput { // Normalize every payload recovery path into the same cache shape while preserving first-seen // timing and peer provenance from any earlier by-root or envelope-only entry. - if (envelope && !payloadInput.hasPayloadEnvelope()) { + const recoveredEnvelope = + envelope ?? (previous && isPendingPayloadEnvelope(previous) ? previous.envelope : undefined); + + if (recoveredEnvelope && !payloadInput.hasPayloadEnvelope()) { payloadInput.addPayloadEnvelope({ - envelope, + envelope: recoveredEnvelope, source: PayloadEnvelopeInputSource.byRoot, seenTimestampSec: Date.now() / 1000, }); diff --git a/packages/beacon-node/test/unit/sync/unknownBlock.test.ts b/packages/beacon-node/test/unit/sync/unknownBlock.test.ts index ec82586f4f30..8e2e401669db 100644 --- a/packages/beacon-node/test/unit/sync/unknownBlock.test.ts +++ b/packages/beacon-node/test/unit/sync/unknownBlock.test.ts @@ -973,6 +973,52 @@ describe("UnknownBlockSync", () => { expect(payloadInput.hasPayloadEnvelope()).toBe(true); }); + it("reuses a queued envelope when incomplete payload input arrives for the same root", async () => { + const peer = await getRandPeerIdStr(); + const {payloadInput, envelope} = buildPayloadFixture({ + blobCount: 0, + sampledColumns: [], + slot: 1, + }); + + const sendExecutionPayloadEnvelopesByRoot = vi.fn(); + const processExecutionPayload = vi.fn().mockResolvedValue(undefined); + const {emitter} = setupPayloadSyncTest({ + chainOverrides: { + processExecutionPayload, + seenPayloadEnvelopeInputCache: { + get: vi.fn().mockReturnValue(undefined), + prune: vi.fn(), + } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + }, + networkOverrides: {sendExecutionPayloadEnvelopesByRoot}, + }); + + emitter.emit(ChainEvent.envelopeUnknownBlock, { + envelope, + peer, + source: BlockInputSource.gossip, + }); + + await sleep(20); + + expect(processExecutionPayload).not.toHaveBeenCalled(); + + emitter.emit(ChainEvent.incompletePayloadEnvelope, { + payloadInput, + peer, + source: BlockInputSource.gossip, + }); + + await sleep(20); + + expect(sendExecutionPayloadEnvelopesByRoot).not.toHaveBeenCalled(); + expect(processExecutionPayload).toHaveBeenCalledTimes(1); + expect(processExecutionPayload).toHaveBeenCalledWith(payloadInput); + expect(payloadInput.hasPayloadEnvelope()).toBe(true); + expect(payloadInput.getPayloadEnvelope()).toBe(envelope); + }); + it("refetches by root if a queued envelope fails validation after block import", async () => { const peer = await getRandPeerIdStr(); const {blockRoot, blockRootHex, payloadInput, envelope} = buildPayloadFixture({ From a79196404049569b42686ab18c047ed45489a4c9 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:10:15 -0700 Subject: [PATCH 07/67] feat: implement EIP-7843 slot_number in ExecutionPayload (specs#4840) Move slot from ExecutionPayloadEnvelope to ExecutionPayload as slotNumber (uint64) and add slotNumber to PayloadAttributes per consensus-specs#4840. Updates all verification, gossip validation, signing, serialization, and SSZ byte extraction accordingly. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../api/src/beacon/routes/beacon/block.ts | 4 +- .../src/api/impl/beacon/blocks/index.ts | 2 +- .../src/api/impl/validator/index.ts | 4 -- .../chain/blocks/importExecutionPayload.ts | 14 ++--- .../validation/executionPayloadEnvelope.ts | 16 ++--- .../executionPayloadEnvelopeArchive.ts | 2 +- .../beacon-node/src/execution/engine/mock.ts | 6 +- packages/beacon-node/src/network/network.ts | 2 +- .../src/network/processor/gossipHandlers.ts | 5 +- .../src/sync/utils/downloadByRange.ts | 60 ++++++++++++++++++- packages/beacon-node/src/util/sszBytes.ts | 27 ++++++++- .../test/unit/util/sszBytes.test.ts | 6 +- .../fork-choice/src/protoArray/protoArray.ts | 1 - .../signatureSets/executionPayloadEnvelope.ts | 2 +- packages/types/src/gloas/sszTypes.ts | 2 - packages/types/src/gloas/types.ts | 1 + .../validator/src/services/validatorStore.ts | 6 +- 17 files changed, 119 insertions(+), 41 deletions(-) diff --git a/packages/api/src/beacon/routes/beacon/block.ts b/packages/api/src/beacon/routes/beacon/block.ts index 6ac6377fa9d1..c4199f505bb2 100644 --- a/packages/api/src/beacon/routes/beacon/block.ts +++ b/packages/api/src/beacon/routes/beacon/block.ts @@ -606,7 +606,7 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions { - const fork = config.getForkName(signedExecutionPayloadEnvelope.message.slot); + const fork = config.getForkName(signedExecutionPayloadEnvelope.message.payload.slotNumber); return { body: getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelope.toJson(signedExecutionPayloadEnvelope), headers: { @@ -621,7 +621,7 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions { - const fork = config.getForkName(signedExecutionPayloadEnvelope.message.slot); + const fork = config.getForkName(signedExecutionPayloadEnvelope.message.payload.slotNumber); return { body: getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelope.serialize(signedExecutionPayloadEnvelope), headers: { 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 fa8e523d3f1c..b6b584b24ee2 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -651,7 +651,7 @@ export function getBeaconBlockApi({ async publishExecutionPayloadEnvelope({signedExecutionPayloadEnvelope}) { const seenTimestampSec = Date.now() / 1000; const envelope = signedExecutionPayloadEnvelope.message; - const slot = envelope.slot; + const slot = envelope.payload.slotNumber; const fork = config.getForkName(slot); const blockRootHex = toRootHex(envelope.beaconBlockRoot); const blockHashHex = toRootHex(envelope.payload.blockHash); diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 607479db091f..52a124759272 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -1648,10 +1648,6 @@ export function getValidatorApi( executionRequests: executionRequests, builderIndex: BUILDER_INDEX_SELF_BUILD, beaconBlockRoot, - slot, - // TODO GLOAS: stateRoot is no longer computed during block production. - // This field will be removed when we implement defer payload processing - stateRoot: ZERO_HASH, }; logger.info("Produced execution payload envelope", { diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index 613a9223f74f..662098ed67da 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -92,15 +92,15 @@ export async function importExecutionPayload( const envelope = signedEnvelope.message; const blockRootHex = payloadInput.blockRootHex; const blockHashHex = payloadInput.getBlockHashHex(); - const fork = this.config.getForkName(envelope.slot); + const fork = this.config.getForkName(envelope.payload.slotNumber); // 1. Emit `execution_payload_available` event at the start of import. At this point the payload input // is already complete, so the payload and required data are available for payload attestation. // This event is only about availability, not validity of the execution payload, hence we can emit // it before getting a response from the execution client on whether the payload is valid or not. - if (this.clock.currentSlot - envelope.slot < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { + if (this.clock.currentSlot - envelope.payload.slotNumber < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { this.emitter.emit(routes.events.EventType.executionPayloadAvailable, { - slot: envelope.slot, + slot: envelope.payload.slotNumber, blockRoot: blockRootHex, }); } @@ -228,7 +228,7 @@ export async function importExecutionPayload( if (!isQueueErrorAborted(e)) { this.logger.error( "Error pushing payload envelope to unfinalized write queue", - {slot: envelope.slot, blockRoot: blockRootHex}, + {slot: envelope.payload.slotNumber, blockRoot: blockRootHex}, e as Error ); } @@ -259,9 +259,9 @@ export async function importExecutionPayload( const stateRootHex = toRootHex(envelope.stateRoot); // 10. Emit event after payload is fully verified and imported to fork choice, only for recent enough payloads - if (this.clock.currentSlot - envelope.slot < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { + if (this.clock.currentSlot - envelope.payload.slotNumber < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { this.emitter.emit(routes.events.EventType.executionPayload, { - slot: envelope.slot, + slot: envelope.payload.slotNumber, builderIndex: envelope.builderIndex, blockHash: blockHashHex, blockRoot: blockRootHex, @@ -272,7 +272,7 @@ export async function importExecutionPayload( } this.logger.verbose("Execution payload imported", { - slot: envelope.slot, + slot: envelope.payload.slotNumber, builderIndex: envelope.builderIndex, blockRoot: blockRootHex, blockHash: blockHashHex, diff --git a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts index 256b5f567b7a..fc62d61cca86 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( throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { code: ExecutionPayloadEnvelopeErrorCode.ENVELOPE_ALREADY_KNOWN, blockRoot: blockRootHex, - slot: envelope.slot, + slot: payload.slotNumber, }); } @@ -65,13 +65,13 @@ async function validateExecutionPayloadEnvelope( }); } - // [IGNORE] The envelope is from a slot greater than or equal to the latest finalized slot -- i.e. validate that `envelope.slot >= compute_start_slot_at_epoch(store.finalized_checkpoint.epoch)` + // [IGNORE] The envelope is from a slot greater than or equal to the latest finalized slot -- i.e. validate that `payload.slotNumber >= compute_start_slot_at_epoch(store.finalized_checkpoint.epoch)` const finalizedCheckpoint = chain.forkChoice.getFinalizedCheckpoint(); const finalizedSlot = computeStartSlotAtEpoch(finalizedCheckpoint.epoch); - if (envelope.slot < finalizedSlot) { + if (payload.slotNumber < finalizedSlot) { throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { code: ExecutionPayloadEnvelopeErrorCode.BELONG_TO_FINALIZED_BLOCK, - envelopeSlot: envelope.slot, + envelopeSlot: payload.slotNumber, finalizedSlot, }); } @@ -80,11 +80,11 @@ async function validateExecutionPayloadEnvelope( // TODO GLOAS: implement this. Technically if we cannot get proto block from fork choice, // it is possible that the block didn't pass the validation - // [REJECT] `block.slot` equals `envelope.slot`. - if (block.slot !== envelope.slot) { + // [REJECT] `block.slot` equals `payload.slotNumber`. + if (block.slot !== payload.slotNumber) { throw new ExecutionPayloadEnvelopeError(GossipAction.REJECT, { code: ExecutionPayloadEnvelopeErrorCode.SLOT_MISMATCH, - envelopeSlot: envelope.slot, + envelopeSlot: payload.slotNumber, blockSlot: block.slot, }); } @@ -114,7 +114,7 @@ async function validateExecutionPayloadEnvelope( throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { code: ExecutionPayloadEnvelopeErrorCode.UNKNOWN_BLOCK_STATE, blockRoot: blockRootHex, - slot: envelope.slot, + slot: payload.slotNumber, }); }); if (!isStatePostGloas(blockState)) { diff --git a/packages/beacon-node/src/db/repositories/executionPayloadEnvelopeArchive.ts b/packages/beacon-node/src/db/repositories/executionPayloadEnvelopeArchive.ts index 5b282b1b1bd7..439f2de1f822 100644 --- a/packages/beacon-node/src/db/repositories/executionPayloadEnvelopeArchive.ts +++ b/packages/beacon-node/src/db/repositories/executionPayloadEnvelopeArchive.ts @@ -19,7 +19,7 @@ export class ExecutionPayloadEnvelopeArchiveRepository extends Repository).withdrawals = ssz.capella.Withdrawals.defaultValue(); } + if (ForkSeq[fork] >= ForkSeq.gloas && payloadAttributes.slotNumber != null) { + (executionPayload as gloas.ExecutionPayload).slotNumber = payloadAttributes.slotNumber; + } + this.preparingPayloads.set(payloadId, { executionPayload: serializeExecutionPayload(fork, executionPayload), blobsBundle: serializeBlobsBundle({ diff --git a/packages/beacon-node/src/network/network.ts b/packages/beacon-node/src/network/network.ts index 623fca31039c..4cd24034df98 100644 --- a/packages/beacon-node/src/network/network.ts +++ b/packages/beacon-node/src/network/network.ts @@ -505,7 +505,7 @@ export class Network implements INetwork { } async publishSignedExecutionPayloadEnvelope(signedEnvelope: gloas.SignedExecutionPayloadEnvelope): Promise { - const epoch = computeEpochAtSlot(signedEnvelope.message.slot); + const epoch = computeEpochAtSlot(signedEnvelope.message.payload.slotNumber); const boundary = this.config.getForkBoundaryAtEpoch(epoch); return this.publishGossip( diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index 32debf5db525..65d403ccaf18 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -1065,7 +1065,8 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand await validateGossipExecutionPayloadEnvelope(chain, signedEnvelope); } catch (e) { if (e instanceof ExecutionPayloadEnvelopeError) { - const {slot, beaconBlockRoot} = signedEnvelope.message; + const {beaconBlockRoot} = signedEnvelope.message; + const slot = signedEnvelope.message.payload.slotNumber; logger.debug("Gossip envelope has error", {slot, root: toRootHex(beaconBlockRoot), code: e.type.code}); if (e.type.code === ExecutionPayloadEnvelopeErrorCode.BLOCK_ROOT_UNKNOWN) { // TODO GLOAS: UnknownBlockSync to handle this @@ -1088,7 +1089,7 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand throw e; } - const slot = envelope.slot; + const slot = envelope.payload.slotNumber; const delaySec = seenTimestampSec - computeTimeAtSlot(config, slot, chain.genesisTime); metrics?.gossipExecutionPayloadEnvelope.elapsedTimeTillReceived.observe({source: OpSource.gossip}, delaySec); chain.validatorMonitor?.registerExecutionPayloadEnvelope(OpSource.gossip, delaySec, signedEnvelope); diff --git a/packages/beacon-node/src/sync/utils/downloadByRange.ts b/packages/beacon-node/src/sync/utils/downloadByRange.ts index 8d554db255bd..17ae0c80eac1 100644 --- a/packages/beacon-node/src/sync/utils/downloadByRange.ts +++ b/packages/beacon-node/src/sync/utils/downloadByRange.ts @@ -1,6 +1,6 @@ import {ChainForkConfig} from "@lodestar/config"; import {ForkPostDeneb, ForkPostFulu, ForkPreFulu, isForkPostFulu} from "@lodestar/params"; -import {SignedBeaconBlock, Slot, deneb, fulu, phase0} from "@lodestar/types"; +import {SignedBeaconBlock, Slot, deneb, fulu, gloas, phase0} from "@lodestar/types"; import {LodestarError, Logger, byteArrayEquals, fromHex, prettyPrintIndices, toRootHex} from "@lodestar/utils"; import { BlockInputSource, @@ -882,6 +882,9 @@ export enum DownloadByRangeErrorCode { /** Cached block input type mismatches new data */ MISMATCH_BLOCK_FORK = "DOWNLOAD_BY_RANGE_ERROR_MISMATCH_BLOCK_FORK", MISMATCH_BLOCK_INPUT_TYPE = "DOWNLOAD_BY_RANGE_ERROR_MISMATCH_BLOCK_INPUT_TYPE", + + /** Envelope's beaconBlockRoot does not match the block root */ + INVALID_ENVELOPE_BEACON_BLOCK_ROOT = "DOWNLOAD_BY_RANGE_ERROR_INVALID_ENVELOPE_BEACON_BLOCK_ROOT", } export type DownloadByRangeErrorType = @@ -973,6 +976,61 @@ export type DownloadByRangeErrorType = blockRoot: string; expected: DAType; actual: DAType; + } + | { + code: DownloadByRangeErrorCode.INVALID_ENVELOPE_BEACON_BLOCK_ROOT; + slot: Slot; + expected: string; + actual: string; }; export class DownloadByRangeError extends LodestarError {} + +/** + * Validates SignedExecutionPayloadEnvelopes received for a range request. + * For each envelope whose slot appears in the downloaded blocks, verifies that + * envelope.message.beaconBlockRoot matches the corresponding block's root. + * Envelopes for slots not in the batch (orphaned payloads) are silently ignored. + */ +export function validateEnvelopesByRangeResponse( + validatedBlocks: ValidatedBlock[], + batchBlocks: IBlockInput[] | undefined, + payloadEnvelopes: gloas.SignedExecutionPayloadEnvelope[] +): Map { + // Build a map of slot -> blockRoot for all blocks in the batch + const batchBlockRoots = new Map(); + if (batchBlocks) { + for (const blockInput of batchBlocks) { + batchBlockRoots.set(blockInput.slot, fromHex(blockInput.blockRootHex)); + } + } + for (const {block, blockRoot} of validatedBlocks) { + batchBlockRoots.set(block.message.slot, blockRoot); + } + + const payloadEnvelopeMap = new Map(); + + for (const payloadEnvelope of payloadEnvelopes) { + const slot = payloadEnvelope.message.payload.slotNumber; + const batchBlockRoot = batchBlockRoots.get(slot); + + // Envelopes for slots not in the batch are silently ignored (orphaned payloads) + if (batchBlockRoot === undefined) { + continue; + } + + // Verify beaconBlockRoot matches the block's root + if (!byteArrayEquals(payloadEnvelope.message.beaconBlockRoot, batchBlockRoot)) { + throw new DownloadByRangeError({ + code: DownloadByRangeErrorCode.INVALID_ENVELOPE_BEACON_BLOCK_ROOT, + slot, + expected: toRootHex(batchBlockRoot), + actual: toRootHex(payloadEnvelope.message.beaconBlockRoot), + }); + } + + payloadEnvelopeMap.set(slot, payloadEnvelope); + } + + return payloadEnvelopeMap; +} diff --git a/packages/beacon-node/src/util/sszBytes.ts b/packages/beacon-node/src/util/sszBytes.ts index 725d2fa6954c..23f20ee80d55 100644 --- a/packages/beacon-node/src/util/sszBytes.ts +++ b/packages/beacon-node/src/util/sszBytes.ts @@ -560,8 +560,8 @@ export function getBeaconBlockRootFromDataColumnSidecarSerialized(data: Uint8Arr * ├─ 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) + * └─ variable: payload data (starts at envelope + 48) + * └─ ExecutionPayload fixed portion includes slotNumber at offset 528 */ const SIGNED_EXECUTION_PAYLOAD_ENVELOPE_MESSAGE_OFFSET = 4; const SIGNED_EXECUTION_PAYLOAD_ENVELOPE_SIGNATURE_SIZE = 96; @@ -576,8 +576,29 @@ const BEACON_BLOCK_ROOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE = EXECUTION_PAYLOAD_ENVELOPE_REQUESTS_OFFSET + EXECUTION_PAYLOAD_ENVELOPE_BUILDER_INDEX_SIZE; // 116 +// Envelope fixed portion (without slot): payload_offset(4) + requests_offset(4) + builderIndex(8) + beaconBlockRoot(32) = 48 +const EXECUTION_PAYLOAD_ENVELOPE_FIXED_SIZE = + EXECUTION_PAYLOAD_ENVELOPE_PAYLOAD_OFFSET + + EXECUTION_PAYLOAD_ENVELOPE_REQUESTS_OFFSET + + EXECUTION_PAYLOAD_ENVELOPE_BUILDER_INDEX_SIZE + + ROOT_SIZE; // 48 + +// slotNumber offset within ExecutionPayload fixed portion: +// parentHash(32) + feeRecipient(20) + stateRoot(32) + receiptsRoot(32) + logsBloom(256) + +// prevRandao(32) + blockNumber(8) + gasLimit(8) + gasUsed(8) + timestamp(8) + +// extraData_offset(4) + baseFeePerGas(32) + blockHash(32) + transactions_offset(4) + +// withdrawals_offset(4) + blobGasUsed(8) + excessBlobGas(8) = 528 +const SLOT_NUMBER_OFFSET_IN_EXECUTION_PAYLOAD = 528; + +// Payload data starts right after the envelope's fixed portion +const ENVELOPE_START_IN_SIGNED = + SIGNED_EXECUTION_PAYLOAD_ENVELOPE_MESSAGE_OFFSET + + SIGNED_EXECUTION_PAYLOAD_ENVELOPE_SIGNATURE_SIZE; // 100 + const SLOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE = - BEACON_BLOCK_ROOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE + ROOT_SIZE; // 148 + ENVELOPE_START_IN_SIGNED + + EXECUTION_PAYLOAD_ENVELOPE_FIXED_SIZE + + SLOT_NUMBER_OFFSET_IN_EXECUTION_PAYLOAD; // 100 + 48 + 528 = 676 export function getSlotFromExecutionPayloadEnvelopeSerialized(data: Uint8Array): Slot | null { if (data.length < SLOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE + SLOT_SIZE) { diff --git a/packages/beacon-node/test/unit/util/sszBytes.test.ts b/packages/beacon-node/test/unit/util/sszBytes.test.ts index b58562abd8cd..2fa572d9eca3 100644 --- a/packages/beacon-node/test/unit/util/sszBytes.test.ts +++ b/packages/beacon-node/test/unit/util/sszBytes.test.ts @@ -571,7 +571,7 @@ describe("SignedExecutionPayloadEnvelope SSZ serialized picking", () => { for (const {slot, blockRoot} of testCases) { it(`slot=${slot}`, () => { const envelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue(); - envelope.message.slot = slot; + envelope.message.payload.slotNumber = slot; envelope.message.beaconBlockRoot = fromHex(blockRoot); const bytes = ssz.gloas.SignedExecutionPayloadEnvelope.serialize(envelope); @@ -581,8 +581,8 @@ describe("SignedExecutionPayloadEnvelope SSZ serialized picking", () => { } it("getSlotFromExecutionPayloadEnvelopeSerialized - invalid data", () => { - // Slot is at offset 148, need at least 156 bytes - const invalidSizes = [0, 50, 100, 155]; + // slotNumber is at offset 676 within the serialized payload, need at least 684 bytes + const invalidSizes = [0, 50, 100, 683]; for (const size of invalidSizes) { expect(getSlotFromExecutionPayloadEnvelopeSerialized(Buffer.alloc(size))).toBeNull(); } diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 4d239d52e8db..0141a9034a56 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -127,7 +127,6 @@ export class ProtoArray { block.executionPayloadBlockHash, (block as {executionPayloadNumber?: number}).executionPayloadNumber ?? 0, block.stateRoot, - null, ExecutionStatus.Valid ); } diff --git a/packages/state-transition/src/signatureSets/executionPayloadEnvelope.ts b/packages/state-transition/src/signatureSets/executionPayloadEnvelope.ts index 806a18ff69f6..42855cf01c87 100644 --- a/packages/state-transition/src/signatureSets/executionPayloadEnvelope.ts +++ b/packages/state-transition/src/signatureSets/executionPayloadEnvelope.ts @@ -11,7 +11,7 @@ export function getExecutionPayloadEnvelopeSigningRoot( config: BeaconConfig, envelope: gloas.ExecutionPayloadEnvelope ): Uint8Array { - const domain = config.getDomain(envelope.slot, DOMAIN_BEACON_BUILDER); + const domain = config.getDomain(envelope.payload.slotNumber, DOMAIN_BEACON_BUILDER); return computeSigningRoot(ssz.gloas.ExecutionPayloadEnvelope, envelope, domain); } diff --git a/packages/types/src/gloas/sszTypes.ts b/packages/types/src/gloas/sszTypes.ts index 329a359177a3..ffcd6e771d3d 100644 --- a/packages/types/src/gloas/sszTypes.ts +++ b/packages/types/src/gloas/sszTypes.ts @@ -180,8 +180,6 @@ export const ExecutionPayloadEnvelope = new ContainerType( executionRequests: electraSsz.ExecutionRequests, builderIndex: BuilderIndex, beaconBlockRoot: Root, - slot: Slot, - stateRoot: Root, }, {typeName: "ExecutionPayloadEnvelope", jsonCase: "eth2"} ); diff --git a/packages/types/src/gloas/types.ts b/packages/types/src/gloas/types.ts index 114900031aa4..81e2a978ecc4 100644 --- a/packages/types/src/gloas/types.ts +++ b/packages/types/src/gloas/types.ts @@ -12,6 +12,7 @@ export type PayloadAttestationMessage = ValueOf; export type ProposerPreferences = ValueOf; export type SignedProposerPreferences = ValueOf; +export type ExecutionPayload = ValueOf; export type ExecutionPayloadBid = ValueOf; export type SignedExecutionPayloadBid = ValueOf; export type BlockAccessList = ValueOf; diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index 4716d3498756..ea84e9f15833 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -496,11 +496,11 @@ export class ValidatorStore { logger?: LoggerVc ): Promise { // Make sure the envelope slot is not higher than the current slot to avoid potential attacks. - if (envelope.slot > currentSlot) { - throw Error(`Not signing envelope with slot ${envelope.slot} greater than current slot ${currentSlot}`); + if (envelope.payload.slotNumber > currentSlot) { + throw Error(`Not signing envelope with slot ${envelope.payload.slotNumber} greater than current slot ${currentSlot}`); } - const signingSlot = envelope.slot; + const signingSlot = envelope.payload.slotNumber; const domain = this.config.getDomain(signingSlot, DOMAIN_BEACON_BUILDER); const signingRoot = computeSigningRoot(ssz.gloas.ExecutionPayloadEnvelope, envelope, domain); From 3958681a50212aa082f167c5585db3291ad565f7 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:31:15 -0700 Subject: [PATCH 08/67] Revert changes introduced by accident --- .../src/sync/utils/downloadByRange.ts | 60 +------------------ 1 file changed, 1 insertion(+), 59 deletions(-) diff --git a/packages/beacon-node/src/sync/utils/downloadByRange.ts b/packages/beacon-node/src/sync/utils/downloadByRange.ts index 17ae0c80eac1..8d554db255bd 100644 --- a/packages/beacon-node/src/sync/utils/downloadByRange.ts +++ b/packages/beacon-node/src/sync/utils/downloadByRange.ts @@ -1,6 +1,6 @@ import {ChainForkConfig} from "@lodestar/config"; import {ForkPostDeneb, ForkPostFulu, ForkPreFulu, isForkPostFulu} from "@lodestar/params"; -import {SignedBeaconBlock, Slot, deneb, fulu, gloas, phase0} from "@lodestar/types"; +import {SignedBeaconBlock, Slot, deneb, fulu, phase0} from "@lodestar/types"; import {LodestarError, Logger, byteArrayEquals, fromHex, prettyPrintIndices, toRootHex} from "@lodestar/utils"; import { BlockInputSource, @@ -882,9 +882,6 @@ export enum DownloadByRangeErrorCode { /** Cached block input type mismatches new data */ MISMATCH_BLOCK_FORK = "DOWNLOAD_BY_RANGE_ERROR_MISMATCH_BLOCK_FORK", MISMATCH_BLOCK_INPUT_TYPE = "DOWNLOAD_BY_RANGE_ERROR_MISMATCH_BLOCK_INPUT_TYPE", - - /** Envelope's beaconBlockRoot does not match the block root */ - INVALID_ENVELOPE_BEACON_BLOCK_ROOT = "DOWNLOAD_BY_RANGE_ERROR_INVALID_ENVELOPE_BEACON_BLOCK_ROOT", } export type DownloadByRangeErrorType = @@ -976,61 +973,6 @@ export type DownloadByRangeErrorType = blockRoot: string; expected: DAType; actual: DAType; - } - | { - code: DownloadByRangeErrorCode.INVALID_ENVELOPE_BEACON_BLOCK_ROOT; - slot: Slot; - expected: string; - actual: string; }; export class DownloadByRangeError extends LodestarError {} - -/** - * Validates SignedExecutionPayloadEnvelopes received for a range request. - * For each envelope whose slot appears in the downloaded blocks, verifies that - * envelope.message.beaconBlockRoot matches the corresponding block's root. - * Envelopes for slots not in the batch (orphaned payloads) are silently ignored. - */ -export function validateEnvelopesByRangeResponse( - validatedBlocks: ValidatedBlock[], - batchBlocks: IBlockInput[] | undefined, - payloadEnvelopes: gloas.SignedExecutionPayloadEnvelope[] -): Map { - // Build a map of slot -> blockRoot for all blocks in the batch - const batchBlockRoots = new Map(); - if (batchBlocks) { - for (const blockInput of batchBlocks) { - batchBlockRoots.set(blockInput.slot, fromHex(blockInput.blockRootHex)); - } - } - for (const {block, blockRoot} of validatedBlocks) { - batchBlockRoots.set(block.message.slot, blockRoot); - } - - const payloadEnvelopeMap = new Map(); - - for (const payloadEnvelope of payloadEnvelopes) { - const slot = payloadEnvelope.message.payload.slotNumber; - const batchBlockRoot = batchBlockRoots.get(slot); - - // Envelopes for slots not in the batch are silently ignored (orphaned payloads) - if (batchBlockRoot === undefined) { - continue; - } - - // Verify beaconBlockRoot matches the block's root - if (!byteArrayEquals(payloadEnvelope.message.beaconBlockRoot, batchBlockRoot)) { - throw new DownloadByRangeError({ - code: DownloadByRangeErrorCode.INVALID_ENVELOPE_BEACON_BLOCK_ROOT, - slot, - expected: toRootHex(batchBlockRoot), - actual: toRootHex(payloadEnvelope.message.beaconBlockRoot), - }); - } - - payloadEnvelopeMap.set(slot, payloadEnvelope); - } - - return payloadEnvelopeMap; -} From a912bc84d18e65390d07e69a3f2cb963e775ddce Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:52:10 -0700 Subject: [PATCH 09/67] Bump version, skip fast_confirmation --- packages/api/test/unit/beacon/testData/events.ts | 2 -- packages/beacon-node/test/spec/utils/specTestIterator.ts | 2 +- spec-tests-version.json | 2 +- specrefs/.ethspecify.yml | 2 +- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/api/test/unit/beacon/testData/events.ts b/packages/api/test/unit/beacon/testData/events.ts index 8de345a96411..a6c8c06ad055 100644 --- a/packages/api/test/unit/beacon/testData/events.ts +++ b/packages/api/test/unit/beacon/testData/events.ts @@ -279,7 +279,6 @@ export const eventTestData: EventData = { builderIndex: 42, blockHash: "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", blockRoot: "0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf", - stateRoot: "0x600e852a08c1200654ddf11025f1ceacb3c2e74bdd5c630cde0838b2591b69f9", executionOptimistic: false, }, [EventType.executionPayloadGossip]: { @@ -287,7 +286,6 @@ export const eventTestData: EventData = { builderIndex: 42, blockHash: "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", blockRoot: "0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf", - stateRoot: "0x600e852a08c1200654ddf11025f1ceacb3c2e74bdd5c630cde0838b2591b69f9", }, [EventType.executionPayloadAvailable]: { slot: 10, diff --git a/packages/beacon-node/test/spec/utils/specTestIterator.ts b/packages/beacon-node/test/spec/utils/specTestIterator.ts index 1616cdf5040b..ce6b66f452db 100644 --- a/packages/beacon-node/test/spec/utils/specTestIterator.ts +++ b/packages/beacon-node/test/spec/utils/specTestIterator.ts @@ -87,7 +87,7 @@ export const defaultSkipOpts: SkipOpts = { /^gloas\/fork_choice\/on_execution_payload\/.*$/, ], skippedTests: [], - skippedRunners: [], + skippedRunners: ["fast_confirmation"], }; /** diff --git a/spec-tests-version.json b/spec-tests-version.json index 8e29f2192c02..64560a60b747 100644 --- a/spec-tests-version.json +++ b/spec-tests-version.json @@ -1,6 +1,6 @@ { "ethereumConsensusSpecsTests": { - "specVersion": "v1.7.0-alpha.4", + "specVersion": "v1.7.0-alpha.5", "specTestsRepoUrl": "https://github.com/ethereum/consensus-specs", "outputDirBase": "spec-tests", "testsToDownload": [ diff --git a/specrefs/.ethspecify.yml b/specrefs/.ethspecify.yml index 41b05a145e51..9b773fc0fcf6 100644 --- a/specrefs/.ethspecify.yml +++ b/specrefs/.ethspecify.yml @@ -1,4 +1,4 @@ -version: v1.7.0-alpha.4 +version: v1.7.0-alpha.5 style: full specrefs: From 03c43490d2a8758c4b51a37b323f09aa4e19809d Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:58:40 -0700 Subject: [PATCH 10/67] Rest of the changes --- packages/api/src/beacon/routes/events.ts | 4 ---- .../src/api/impl/beacon/blocks/index.ts | 3 --- .../src/chain/blocks/importExecutionPayload.ts | 14 ++------------ .../src/chain/produceBlock/produceBlockBody.ts | 2 ++ .../src/network/processor/gossipHandlers.ts | 1 - packages/fork-choice/src/protoArray/protoArray.ts | 1 + .../src/block/processExecutionPayloadEnvelope.ts | 11 +++-------- packages/types/src/gloas/sszTypes.ts | 6 ++++-- packages/types/src/gloas/types.ts | 1 - packages/validator/src/services/block.ts | 3 +-- 10 files changed, 13 insertions(+), 33 deletions(-) diff --git a/packages/api/src/beacon/routes/events.ts b/packages/api/src/beacon/routes/events.ts index 9c2a28458684..e631f0ced8bd 100644 --- a/packages/api/src/beacon/routes/events.ts +++ b/packages/api/src/beacon/routes/events.ts @@ -186,7 +186,6 @@ export type EventData = { builderIndex: BuilderIndex; blockHash: RootHex; blockRoot: RootHex; - stateRoot: RootHex; executionOptimistic: boolean; }; [EventType.executionPayloadGossip]: { @@ -194,7 +193,6 @@ export type EventData = { builderIndex: BuilderIndex; blockHash: RootHex; blockRoot: RootHex; - stateRoot: RootHex; }; [EventType.executionPayloadAvailable]: { slot: Slot; @@ -376,7 +374,6 @@ export function getTypeByEvent(config: ChainForkConfig): {[K in EventType]: Type builderIndex: ssz.BuilderIndex, blockHash: stringType, blockRoot: stringType, - stateRoot: stringType, executionOptimistic: ssz.Boolean, }, {jsonCase: "eth2"} @@ -387,7 +384,6 @@ export function getTypeByEvent(config: ChainForkConfig): {[K in EventType]: Type builderIndex: ssz.BuilderIndex, blockHash: stringType, blockRoot: stringType, - stateRoot: stringType, }, {jsonCase: "eth2"} ), 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 b6b584b24ee2..73bf3b4caab7 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -655,7 +655,6 @@ export function getBeaconBlockApi({ const fork = config.getForkName(slot); const blockRootHex = toRootHex(envelope.beaconBlockRoot); const blockHashHex = toRootHex(envelope.payload.blockHash); - const stateRootHex = toRootHex(envelope.stateRoot); if (!isForkPostGloas(fork)) { throw new ApiError(400, `publishExecutionPayloadEnvelope not supported for pre-gloas fork=${fork}`); @@ -740,7 +739,6 @@ export function getBeaconBlockApi({ slot, blockRoot: blockRootHex, blockHash: blockHashHex, - stateRoot: stateRootHex, builderIndex: envelope.builderIndex, isSelfBuild, dataColumns: dataColumnSidecars.length, @@ -768,7 +766,6 @@ export function getBeaconBlockApi({ builderIndex: envelope.builderIndex, blockHash: blockHashHex, blockRoot: blockRootHex, - stateRoot: stateRootHex, }); const sentPeersArr = await publishPromise; diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index 662098ed67da..c25f83329a9b 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -2,7 +2,7 @@ import {routes} from "@lodestar/api"; import {ExecutionStatus, PayloadExecutionStatus} from "@lodestar/fork-choice"; import {SLOTS_PER_EPOCH} from "@lodestar/params"; import {getExecutionPayloadEnvelopeSignatureSet, isStatePostGloas} from "@lodestar/state-transition"; -import {byteArrayEquals, fromHex, toRootHex} from "@lodestar/utils"; +import {fromHex, toRootHex} from "@lodestar/utils"; import {ExecutionPayloadStatus} from "../../execution/index.js"; import {isQueueErrorAborted} from "../../util/queue/index.js"; import {BeaconChain} from "../chain.js"; @@ -213,15 +213,9 @@ export async function importExecutionPayload( }); } - // 5c. Verify envelope state root matches post-state + // 5c. Compute post-payload state root const postPayloadState = postPayloadResult.postPayloadState; const postPayloadStateRoot = postPayloadState.hashTreeRoot(); - if (!byteArrayEquals(envelope.stateRoot, postPayloadStateRoot)) { - throw new PayloadError({ - code: PayloadErrorCode.STATE_TRANSITION_ERROR, - message: `Envelope state root mismatch expected=${toRootHex(envelope.stateRoot)} actual=${toRootHex(postPayloadStateRoot)}`, - }); - } // 6. Persist payload envelope to hot DB (performed asynchronously to avoid blocking) this.unfinalizedPayloadEnvelopeWrites.push(payloadInput).catch((e) => { @@ -256,8 +250,6 @@ export async function importExecutionPayload( this.metrics?.importPayload.columnsBySource.inc({source}); } - const stateRootHex = toRootHex(envelope.stateRoot); - // 10. Emit event after payload is fully verified and imported to fork choice, only for recent enough payloads if (this.clock.currentSlot - envelope.payload.slotNumber < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { this.emitter.emit(routes.events.EventType.executionPayload, { @@ -265,7 +257,6 @@ export async function importExecutionPayload( builderIndex: envelope.builderIndex, blockHash: blockHashHex, blockRoot: blockRootHex, - stateRoot: stateRootHex, // TODO GLOAS: revisit once we support optimistic import executionOptimistic: false, }); @@ -276,6 +267,5 @@ export async function importExecutionPayload( builderIndex: envelope.builderIndex, blockRoot: blockRootHex, blockHash: blockHashHex, - stateRoot: stateRootHex, }); } diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index e8004ab94519..a2edacdf77b5 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -46,6 +46,7 @@ import { electra, fulu, gloas, + ssz, } from "@lodestar/types"; import {Logger, byteArrayEquals, fromHex, sleep, toHex, toPubkeyHex, toRootHex} from "@lodestar/utils"; import {ZERO_HASH_HEX} from "../../constants/index.js"; @@ -269,6 +270,7 @@ export async function produceBlockBody( value: 0, executionPayment: 0, blobKzgCommitments: blobsBundle.commitments, + executionRequestsRoot: ssz.electra.ExecutionRequests.hashTreeRoot(executionRequests), }; const signedBid: gloas.SignedExecutionPayloadBid = { message: bid, diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index 65d403ccaf18..b9607e77f9d5 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -1119,7 +1119,6 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand builderIndex: envelope.builderIndex, blockHash: toRootHex(envelope.payload.blockHash), blockRoot: blockRootHex, - stateRoot: toRootHex(envelope.stateRoot), }); chain.processExecutionPayload(payloadInput, {validSignature: true}).catch((e) => { diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 0141a9034a56..4d239d52e8db 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -127,6 +127,7 @@ export class ProtoArray { block.executionPayloadBlockHash, (block as {executionPayloadNumber?: number}).executionPayloadNumber ?? 0, block.stateRoot, + null, ExecutionStatus.Valid ); } diff --git a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts index c7f858a908d0..b410c0a87fa3 100644 --- a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts +++ b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts @@ -27,7 +27,7 @@ export function processExecutionPayloadEnvelope( const {verifySignature = true, verifyStateRoot = true} = opts ?? {}; const envelope = signedEnvelope.message; const payload = envelope.payload; - const fork = state.config.getForkSeq(envelope.slot); + const fork = state.config.getForkSeq(payload.slotNumber); if (verifySignature && !verifyExecutionPayloadEnvelopeSignature(state, signedEnvelope)) { throw Error(`Execution payload envelope has invalid signature builderIndex=${envelope.builderIndex}`); @@ -74,11 +74,6 @@ export function processExecutionPayloadEnvelope( postState.commit(); - if (verifyStateRoot && !byteArrayEquals(envelope.stateRoot, postState.hashTreeRoot())) { - throw new Error( - `Envelope's state root does not match state envelope=${toRootHex(envelope.stateRoot)} state=${toRootHex(postState.hashTreeRoot())}` - ); - } return postState; } @@ -102,8 +97,8 @@ function validateExecutionPayloadEnvelope( ); } - if (envelope.slot !== state.slot) { - throw new Error(`Slot mismatch between envelope and state envelope=${envelope.slot} state=${state.slot}`); + if (payload.slotNumber !== state.slot) { + throw new Error(`Slot mismatch between payload and state payload=${payload.slotNumber} state=${state.slot}`); } // Verify consistency with the committed bid diff --git a/packages/types/src/gloas/sszTypes.ts b/packages/types/src/gloas/sszTypes.ts index ffcd6e771d3d..6f90dfc04e02 100644 --- a/packages/types/src/gloas/sszTypes.ts +++ b/packages/types/src/gloas/sszTypes.ts @@ -151,6 +151,7 @@ export const ExecutionPayloadBid = new ContainerType( value: UintNum64, executionPayment: UintNum64, blobKzgCommitments: denebSsz.BlobKzgCommitments, + executionRequestsRoot: Root, }, {typeName: "ExecutionPayloadBid", jsonCase: "eth2"} ); @@ -209,6 +210,7 @@ export const BeaconBlockBody = new ContainerType( // executionRequests: ExecutionRequests, // Removed in GLOAS:EIP7732 signedExecutionPayloadBid: SignedExecutionPayloadBid, // New in GLOAS:EIP7732 payloadAttestations: new ListCompositeType(PayloadAttestation, MAX_PAYLOAD_ATTESTATIONS), // New in GLOAS:EIP7732 + parentExecutionRequests: electraSsz.ExecutionRequests, }, {typeName: "BeaconBlockBody", jsonCase: "eth2", cachePermanentRootStruct: true} ); @@ -266,7 +268,7 @@ export const BeaconState = new ContainerType( nextSyncCommittee: altairSsz.SyncCommittee, // Execution // latestExecutionPayloadHeader: ExecutionPayloadHeader, // Removed in GLOAS:EIP7732 - latestExecutionPayloadBid: ExecutionPayloadBid, // New in GLOAS:EIP7732 + latestBlockHash: Bytes32, // Withdrawals nextWithdrawalIndex: capellaSsz.BeaconState.fields.nextWithdrawalIndex, nextWithdrawalValidatorIndex: capellaSsz.BeaconState.fields.nextWithdrawalValidatorIndex, @@ -287,7 +289,7 @@ export const BeaconState = new ContainerType( executionPayloadAvailability: new BitVectorType(SLOTS_PER_HISTORICAL_ROOT), // New in GLOAS:EIP7732 builderPendingPayments: new VectorCompositeType(BuilderPendingPayment, 2 * SLOTS_PER_EPOCH), // New in GLOAS:EIP7732 builderPendingWithdrawals: new ListCompositeType(BuilderPendingWithdrawal, BUILDER_PENDING_WITHDRAWALS_LIMIT), // New in GLOAS:EIP7732 - latestBlockHash: Bytes32, // New in GLOAS:EIP7732 + latestExecutionPayloadBid: ExecutionPayloadBid, payloadExpectedWithdrawals: capellaSsz.Withdrawals, // New in GLOAS:EIP7732 ptcWindow: PtcWindow, // New in GLOAS:EIP7732 }, diff --git a/packages/types/src/gloas/types.ts b/packages/types/src/gloas/types.ts index 81e2a978ecc4..de7949e54651 100644 --- a/packages/types/src/gloas/types.ts +++ b/packages/types/src/gloas/types.ts @@ -16,7 +16,6 @@ export type ExecutionPayload = ValueOf; export type ExecutionPayloadBid = ValueOf; export type SignedExecutionPayloadBid = ValueOf; export type BlockAccessList = ValueOf; -export type ExecutionPayload = ValueOf; export type ExecutionPayloadEnvelope = ValueOf; export type SignedExecutionPayloadEnvelope = ValueOf; export type BeaconBlockBody = ValueOf; diff --git a/packages/validator/src/services/block.ts b/packages/validator/src/services/block.ts index a3f198273d5e..ebe04f09971a 100644 --- a/packages/validator/src/services/block.ts +++ b/packages/validator/src/services/block.ts @@ -241,9 +241,8 @@ export class BlockProposingService { beaconBlockRoot, }); const envelope = envelopeRes.value(); - const stateRootHex = toRootHex(envelope.stateRoot); - this.logger.debug("Retrieved execution payload envelope", {...debugLogCtx, stateRoot: stateRootHex}); + this.logger.debug("Retrieved execution payload envelope", debugLogCtx); // Step 4: Sign and publish the envelope const signedEnvelope = await this.validatorStore.signExecutionPayloadEnvelope(pubkey, envelope, slot, this.logger); From d3d74a948005952ed77fe36950f605f5ab050181 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:16:22 -0700 Subject: [PATCH 11/67] Skip some tests --- .../test/spec/utils/specTestIterator.ts | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/beacon-node/test/spec/utils/specTestIterator.ts b/packages/beacon-node/test/spec/utils/specTestIterator.ts index ce6b66f452db..0dd85ca17d18 100644 --- a/packages/beacon-node/test/spec/utils/specTestIterator.ts +++ b/packages/beacon-node/test/spec/utils/specTestIterator.ts @@ -77,17 +77,19 @@ export const defaultSkipOpts: SkipOpts = { // cell level DAS is ready /^fulu\/ssz_static\/PartialDataColumn(Header|PartsMetadata|Sidecar)\/.*$/, /^gloas\/ssz_static\/PartialDataColumn(Header|PartsMetadata|Sidecar)\/.*$/, - // TODO GLOAS: EIP-7928 (blockAccessList) and EIP-7843 (slotNumber) are added to gloas ExecutionPayload - // in Lodestar ahead of their integration into the consensus-specs gloas fork, so the - // upstream fixtures encode ExecutionPayload without these fields. Unskip once the spec tests include them. - /^gloas\/ssz_static\/ExecutionPayload\/.*$/, - /^gloas\/ssz_static\/ExecutionPayloadEnvelope\/.*$/, - /^gloas\/ssz_static\/SignedExecutionPayloadEnvelope\/.*$/, - /^gloas\/operations\/execution_payload\/.*$/, - /^gloas\/fork_choice\/on_execution_payload\/.*$/, + // TODO GLOAS: Unskip these tests as we get closer to alpha.5 ready + /^gloas\/fork_choice\/.*$/, + /^gloas\/fork\/.*$/, + /^gloas\/transition\/.*$/, + /^gloas\/operations\/parent_execution_payload\/.*$/, ], - skippedTests: [], - skippedRunners: ["fast_confirmation"], + skippedTests: [ + // TODO GLOAS: Unskip these tests as we get closer to alpha.5 ready + /^gloas\/sanity\/blocks\/pyspec_tests\/builder_payment_after_missed_epochs$/, + /^gloas\/operations\/withdrawals\/pyspec_tests\/zero_hash_genesis_skips_withdrawals$/, + ], + // TODO GLOAS: Investigate why networking tests are failing since alpha.5 + skippedRunners: ["fast_confirmation", "networking"], }; /** From 40b5f9f09c27c18d831cc48d61ee9f7d67a893b5 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:22:23 -0700 Subject: [PATCH 12/67] lint --- packages/beacon-node/src/util/sszBytes.ts | 7 ++----- packages/beacon-node/test/spec/presets/fork_choice.test.ts | 3 +-- .../chain/blocks/verifyPayloadsDataAvailability.test.ts | 2 -- packages/validator/src/services/validatorStore.ts | 4 +++- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/beacon-node/src/util/sszBytes.ts b/packages/beacon-node/src/util/sszBytes.ts index 23f20ee80d55..bb0d988a0ccb 100644 --- a/packages/beacon-node/src/util/sszBytes.ts +++ b/packages/beacon-node/src/util/sszBytes.ts @@ -592,13 +592,10 @@ const SLOT_NUMBER_OFFSET_IN_EXECUTION_PAYLOAD = 528; // Payload data starts right after the envelope's fixed portion const ENVELOPE_START_IN_SIGNED = - SIGNED_EXECUTION_PAYLOAD_ENVELOPE_MESSAGE_OFFSET + - SIGNED_EXECUTION_PAYLOAD_ENVELOPE_SIGNATURE_SIZE; // 100 + SIGNED_EXECUTION_PAYLOAD_ENVELOPE_MESSAGE_OFFSET + SIGNED_EXECUTION_PAYLOAD_ENVELOPE_SIGNATURE_SIZE; // 100 const SLOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE = - ENVELOPE_START_IN_SIGNED + - EXECUTION_PAYLOAD_ENVELOPE_FIXED_SIZE + - SLOT_NUMBER_OFFSET_IN_EXECUTION_PAYLOAD; // 100 + 48 + 528 = 676 + ENVELOPE_START_IN_SIGNED + EXECUTION_PAYLOAD_ENVELOPE_FIXED_SIZE + SLOT_NUMBER_OFFSET_IN_EXECUTION_PAYLOAD; // 100 + 48 + 528 = 676 export function getSlotFromExecutionPayloadEnvelopeSerialized(data: Uint8Array): Slot | null { if (data.length < SLOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE + SLOT_SIZE) { diff --git a/packages/beacon-node/test/spec/presets/fork_choice.test.ts b/packages/beacon-node/test/spec/presets/fork_choice.test.ts index 4795a3979714..6988c9ba279c 100644 --- a/packages/beacon-node/test/spec/presets/fork_choice.test.ts +++ b/packages/beacon-node/test/spec/presets/fork_choice.test.ts @@ -381,7 +381,6 @@ const forkChoiceTest = const beaconBlockRoot = toHex(envelope.message.beaconBlockRoot); const blockHash = toHex(envelope.message.payload.blockHash); const blockNumber = envelope.message.payload.blockNumber; - const stateRoot = toHex(envelope.message.stateRoot); // Add predefined VALID status for the payload's block hash so the EL mock accepts it executionEngineBackend.addPredefinedPayloadStatus(blockHash, { @@ -394,7 +393,7 @@ const forkChoiceTest = beaconBlockRoot, blockHash, blockNumber, - stateRoot, + "0x", ExecutionStatus.Valid ); if (!isValid) throw Error("Expect error since this is a negative test"); diff --git a/packages/beacon-node/test/unit/chain/blocks/verifyPayloadsDataAvailability.test.ts b/packages/beacon-node/test/unit/chain/blocks/verifyPayloadsDataAvailability.test.ts index d42b42bb34b8..8eda8ccb43ed 100644 --- a/packages/beacon-node/test/unit/chain/blocks/verifyPayloadsDataAvailability.test.ts +++ b/packages/beacon-node/test/unit/chain/blocks/verifyPayloadsDataAvailability.test.ts @@ -33,7 +33,6 @@ function buildPayloadEnvelopeInput({blobCount, sampledColumns}: {blobCount: numb const signedEnvelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue(); signedEnvelope.message.beaconBlockRoot = blockRoot; - signedEnvelope.message.slot = block.message.slot; payloadInput.addPayloadEnvelope({ envelope: signedEnvelope, @@ -177,7 +176,6 @@ describe("PayloadEnvelopeInput.waitForEnvelopeAndAllData", () => { const signedEnvelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue(); signedEnvelope.message.beaconBlockRoot = blockRoot; - signedEnvelope.message.slot = block.message.slot; return {payloadInput, signedEnvelope}; } diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index ea84e9f15833..39eb8ddd44bb 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -497,7 +497,9 @@ export class ValidatorStore { ): Promise { // Make sure the envelope slot is not higher than the current slot to avoid potential attacks. if (envelope.payload.slotNumber > currentSlot) { - throw Error(`Not signing envelope with slot ${envelope.payload.slotNumber} greater than current slot ${currentSlot}`); + throw Error( + `Not signing envelope with slot ${envelope.payload.slotNumber} greater than current slot ${currentSlot}` + ); } const signingSlot = envelope.payload.slotNumber; From da7cc4172f9976cb7a560474364a0d075ce80d3f Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:38:39 -0700 Subject: [PATCH 13/67] ethspecify --- packages/state-transition/src/util/gloas.ts | 1 + packages/types/src/gloas/sszTypes.ts | 8 +- specrefs/.ethspecify.yml | 4 - specrefs/configs.yml | 7 + specrefs/constants.yml | 7 + specrefs/containers.yml | 56 +- specrefs/dataclasses.yml | 39 +- specrefs/functions.yml | 1462 +++++++++++++++---- specrefs/types.yml | 7 + 9 files changed, 1285 insertions(+), 306 deletions(-) diff --git a/packages/state-transition/src/util/gloas.ts b/packages/state-transition/src/util/gloas.ts index 6f1115697ed8..6f1989acda0b 100644 --- a/packages/state-transition/src/util/gloas.ts +++ b/packages/state-transition/src/util/gloas.ts @@ -172,6 +172,7 @@ export function isAttestationSameSlotRootCache(rootCache: RootCache, data: Attes return isMatchingBlockRoot && isCurrentBlockRoot; } +// TODO GLOAS: This function no longer exists in v1.7.0-alpha.5 specs. Remove it when appropriate to do so export function isParentBlockFull(state: CachedBeaconStateGloas): boolean { return byteArrayEquals(state.latestExecutionPayloadBid.blockHash, state.latestBlockHash); } diff --git a/packages/types/src/gloas/sszTypes.ts b/packages/types/src/gloas/sszTypes.ts index 6f90dfc04e02..59fd84ebb1ec 100644 --- a/packages/types/src/gloas/sszTypes.ts +++ b/packages/types/src/gloas/sszTypes.ts @@ -210,7 +210,7 @@ export const BeaconBlockBody = new ContainerType( // executionRequests: ExecutionRequests, // Removed in GLOAS:EIP7732 signedExecutionPayloadBid: SignedExecutionPayloadBid, // New in GLOAS:EIP7732 payloadAttestations: new ListCompositeType(PayloadAttestation, MAX_PAYLOAD_ATTESTATIONS), // New in GLOAS:EIP7732 - parentExecutionRequests: electraSsz.ExecutionRequests, + parentExecutionRequests: electraSsz.ExecutionRequests, // New in GLOAS:EIP7732 }, {typeName: "BeaconBlockBody", jsonCase: "eth2", cachePermanentRootStruct: true} ); @@ -268,7 +268,7 @@ export const BeaconState = new ContainerType( nextSyncCommittee: altairSsz.SyncCommittee, // Execution // latestExecutionPayloadHeader: ExecutionPayloadHeader, // Removed in GLOAS:EIP7732 - latestBlockHash: Bytes32, + latestBlockHash: Bytes32, // New in GLOAS:EIP7732 // Withdrawals nextWithdrawalIndex: capellaSsz.BeaconState.fields.nextWithdrawalIndex, nextWithdrawalValidatorIndex: capellaSsz.BeaconState.fields.nextWithdrawalValidatorIndex, @@ -289,12 +289,12 @@ export const BeaconState = new ContainerType( executionPayloadAvailability: new BitVectorType(SLOTS_PER_HISTORICAL_ROOT), // New in GLOAS:EIP7732 builderPendingPayments: new VectorCompositeType(BuilderPendingPayment, 2 * SLOTS_PER_EPOCH), // New in GLOAS:EIP7732 builderPendingWithdrawals: new ListCompositeType(BuilderPendingWithdrawal, BUILDER_PENDING_WITHDRAWALS_LIMIT), // New in GLOAS:EIP7732 - latestExecutionPayloadBid: ExecutionPayloadBid, + latestExecutionPayloadBid: ExecutionPayloadBid, // New in GLOAS:EIP7732 payloadExpectedWithdrawals: capellaSsz.Withdrawals, // New in GLOAS:EIP7732 ptcWindow: PtcWindow, // New in GLOAS:EIP7732 }, {typeName: "BeaconState", jsonCase: "eth2"} -); +); // New in GLOAS:EIP7732 export const DataColumnSidecar = new ContainerType( { diff --git a/specrefs/.ethspecify.yml b/specrefs/.ethspecify.yml index 9b773fc0fcf6..e40c5a2a1e9b 100644 --- a/specrefs/.ethspecify.yml +++ b/specrefs/.ethspecify.yml @@ -142,7 +142,6 @@ exceptions: - max_compressed_len#phase0 - max_message_size#phase0 - record_block_timeliness#phase0 - - saturating_sub#phase0 - seconds_to_milliseconds#phase0 - store_target_checkpoint_state#phase0 - update_proposer_boost_root#phase0 @@ -281,7 +280,6 @@ exceptions: # gloas - add_builder_to_registry#gloas - apply_withdrawals#gloas - - compute_balance_weighted_acceptance#gloas - compute_balance_weighted_selection#gloas - compute_fork_version#gloas - compute_proposer_indices#gloas @@ -317,7 +315,6 @@ exceptions: - is_valid_proposal_slot#gloas - notify_ptc_messages#gloas - on_block#gloas - - on_execution_payload#gloas - on_payload_attestation_message#gloas - prepare_execution_payload#gloas - process_attestation#gloas @@ -364,7 +361,6 @@ exceptions: - is_inclusion_list_bits_inclusive#heze - is_payload_inclusion_list_satisfied#heze - is_valid_inclusion_list_signature#heze - - on_execution_payload#heze - on_inclusion_list#heze - prepare_execution_payload#heze - process_inclusion_list#heze diff --git a/specrefs/configs.yml b/specrefs/configs.yml index c767599dfc58..9d582ecc38dc 100644 --- a/specrefs/configs.yml +++ b/specrefs/configs.yml @@ -170,6 +170,13 @@ CHURN_LIMIT_QUOTIENT: uint64 = 65536 +- name: CONFIRMATION_BYZANTINE_THRESHOLD#phase0 + sources: [] + spec: | + + CONFIRMATION_BYZANTINE_THRESHOLD: uint64 = 25 + + - name: CONTRIBUTION_DUE_BPS#altair sources: - file: packages/config/src/chainConfig/configs/mainnet.ts diff --git a/specrefs/constants.yml b/specrefs/constants.yml index 0d23d38d2be2..f326a95a0f6f 100644 --- a/specrefs/constants.yml +++ b/specrefs/constants.yml @@ -107,6 +107,13 @@ BYTES_PER_PROOF: uint64 = 48 +- name: COMMITTEE_WEIGHT_ESTIMATION_ADJUSTMENT_FACTOR#phase0 + sources: [] + spec: | + + COMMITTEE_WEIGHT_ESTIMATION_ADJUSTMENT_FACTOR: uint64 = 5 + + - name: COMPOUNDING_WITHDRAWAL_PREFIX#electra sources: - file: packages/params/src/index.ts diff --git a/specrefs/containers.yml b/specrefs/containers.yml index f4008da2944f..e674c487259b 100644 --- a/specrefs/containers.yml +++ b/specrefs/containers.yml @@ -244,7 +244,7 @@ - file: packages/types/src/gloas/sszTypes.ts search: export const BeaconBlockBody = spec: | - + class BeaconBlockBody(Container): randao_reveal: BLSSignature eth1_data: Eth1Data @@ -266,6 +266,8 @@ signed_execution_payload_bid: SignedExecutionPayloadBid # [New in Gloas:EIP7732] payload_attestations: List[PayloadAttestation, MAX_PAYLOAD_ATTESTATIONS] + # [New in Gloas:EIP7732] + parent_execution_requests: ExecutionRequests - name: BeaconBlockHeader#phase0 @@ -572,7 +574,7 @@ - file: packages/types/src/gloas/sszTypes.ts search: export const BeaconState = spec: | - + class BeaconState(Container): genesis_time: uint64 genesis_validators_root: Root @@ -601,7 +603,7 @@ # [Modified in Gloas:EIP7732] # Removed `latest_execution_payload_header` # [New in Gloas:EIP7732] - latest_execution_payload_bid: ExecutionPayloadBid + latest_block_hash: Hash32 next_withdrawal_index: WithdrawalIndex next_withdrawal_validator_index: ValidatorIndex historical_summaries: List[HistoricalSummary, HISTORICAL_ROOTS_LIMIT] @@ -626,7 +628,7 @@ # [New in Gloas:EIP7732] builder_pending_withdrawals: List[BuilderPendingWithdrawal, BUILDER_PENDING_WITHDRAWALS_LIMIT] # [New in Gloas:EIP7732] - latest_block_hash: Hash32 + latest_execution_payload_bid: ExecutionPayloadBid # [New in Gloas:EIP7732] payload_expected_withdrawals: List[Withdrawal, MAX_WITHDRAWALS_PER_PAYLOAD] # [New in Gloas:EIP7732] @@ -636,7 +638,7 @@ - name: BeaconState#heze sources: [] spec: | - + class BeaconState(Container): genesis_time: uint64 genesis_validators_root: Root @@ -662,8 +664,7 @@ inactivity_scores: List[uint64, VALIDATOR_REGISTRY_LIMIT] current_sync_committee: SyncCommittee next_sync_committee: SyncCommittee - # [Modified in Heze:EIP7805] - latest_execution_payload_bid: ExecutionPayloadBid + latest_block_hash: Hash32 next_withdrawal_index: WithdrawalIndex next_withdrawal_validator_index: ValidatorIndex historical_summaries: List[HistoricalSummary, HISTORICAL_ROOTS_LIMIT] @@ -682,7 +683,8 @@ execution_payload_availability: Bitvector[SLOTS_PER_HISTORICAL_ROOT] builder_pending_payments: Vector[BuilderPendingPayment, 2 * SLOTS_PER_EPOCH] builder_pending_withdrawals: List[BuilderPendingWithdrawal, BUILDER_PENDING_WITHDRAWALS_LIMIT] - latest_block_hash: Hash32 + # [Modified in Heze:EIP7805] + latest_execution_payload_bid: ExecutionPayloadBid payload_expected_withdrawals: List[Withdrawal, MAX_WITHDRAWALS_PER_PAYLOAD] ptc_window: Vector[Vector[ValidatorIndex, PTC_SIZE], (2 + MIN_SEED_LOOKAHEAD) * SLOTS_PER_EPOCH] @@ -984,12 +986,40 @@ excess_blob_gas: uint64 +- name: ExecutionPayload#gloas + sources: [] + spec: | + + class ExecutionPayload(Container): + parent_hash: Hash32 + fee_recipient: ExecutionAddress + state_root: Bytes32 + receipts_root: Bytes32 + logs_bloom: ByteVector[BYTES_PER_LOGS_BLOOM] + prev_randao: Bytes32 + block_number: uint64 + gas_limit: uint64 + gas_used: uint64 + timestamp: uint64 + extra_data: ByteList[MAX_EXTRA_DATA_BYTES] + base_fee_per_gas: uint256 + block_hash: Hash32 + transactions: List[Transaction, MAX_TRANSACTIONS_PER_PAYLOAD] + withdrawals: List[Withdrawal, MAX_WITHDRAWALS_PER_PAYLOAD] + blob_gas_used: uint64 + excess_blob_gas: uint64 + # [New in Gloas:EIP7928] + block_access_list: BlockAccessList + # [New in Gloas:EIP7843] + slot_number: uint64 + + - name: ExecutionPayloadBid#gloas sources: - file: packages/types/src/gloas/sszTypes.ts search: export const ExecutionPayloadBid = spec: | - + class ExecutionPayloadBid(Container): parent_block_hash: Hash32 parent_block_root: Root @@ -1002,12 +1032,13 @@ value: Gwei execution_payment: Gwei blob_kzg_commitments: List[KZGCommitment, MAX_BLOB_COMMITMENTS_PER_BLOCK] + execution_requests_root: Root - name: ExecutionPayloadBid#heze sources: [] spec: | - + class ExecutionPayloadBid(Container): parent_block_hash: Hash32 parent_block_root: Root @@ -1020,6 +1051,7 @@ value: Gwei execution_payment: Gwei blob_kzg_commitments: List[KZGCommitment, MAX_BLOB_COMMITMENTS_PER_BLOCK] + execution_requests_root: Root # [New in Heze:EIP7805] inclusion_list_bits: Bitvector[INCLUSION_LIST_COMMITTEE_SIZE] @@ -1029,14 +1061,12 @@ - file: packages/types/src/gloas/sszTypes.ts search: export const ExecutionPayloadEnvelope = spec: | - + class ExecutionPayloadEnvelope(Container): payload: ExecutionPayload execution_requests: ExecutionRequests builder_index: BuilderIndex beacon_block_root: Root - slot: Slot - state_root: Root - name: ExecutionPayloadHeader#bellatrix diff --git a/specrefs/dataclasses.yml b/specrefs/dataclasses.yml index 559fe34b21e3..816b1612943c 100644 --- a/specrefs/dataclasses.yml +++ b/specrefs/dataclasses.yml @@ -68,6 +68,20 @@ processed_sweep_withdrawals_count: uint64 +- name: FastConfirmationStore#phase0 + sources: [] + spec: | + + class FastConfirmationStore(object): + store: Store + confirmed_root: Root + previous_epoch_observed_justified_checkpoint: Checkpoint + current_epoch_observed_justified_checkpoint: Checkpoint + previous_epoch_greatest_unrealized_checkpoint: Checkpoint + previous_slot_head: Root + current_slot_head: Root + + - name: GetInclusionListResponse#heze sources: [] spec: | @@ -310,16 +324,31 @@ parent_beacon_block_root: Root +- name: PayloadAttributes#gloas + sources: [] + spec: | + + class PayloadAttributes(object): + timestamp: uint64 + prev_randao: Bytes32 + suggested_fee_recipient: ExecutionAddress + withdrawals: Sequence[Withdrawal] + parent_beacon_block_root: Root + # [New in Gloas:EIP7843] + slot_number: uint64 + + - name: PayloadAttributes#heze sources: [] spec: | - + class PayloadAttributes(object): timestamp: uint64 prev_randao: Bytes32 suggested_fee_recipient: ExecutionAddress withdrawals: Sequence[Withdrawal] parent_beacon_block_root: Root + slot_number: uint64 # [New in Heze:EIP7805] inclusion_list_transactions: Sequence[Transaction] @@ -350,7 +379,7 @@ - name: Store#gloas sources: [] spec: | - + class Store(object): time: uint64 genesis_time: uint64 @@ -369,7 +398,7 @@ latest_messages: Dict[ValidatorIndex, LatestMessage] = field(default_factory=dict) unrealized_justifications: Dict[Root, Checkpoint] = field(default_factory=dict) # [New in Gloas:EIP7732] - payload_states: Dict[Root, BeaconState] = field(default_factory=dict) + payloads: Dict[Root, ExecutionPayloadEnvelope] = field(default_factory=dict) # [New in Gloas:EIP7732] payload_timeliness_vote: Dict[Root, Vector[boolean, PTC_SIZE]] = field(default_factory=dict) # [New in Gloas:EIP7732] @@ -381,7 +410,7 @@ - name: Store#heze sources: [] spec: | - + class Store(object): time: uint64 genesis_time: uint64 @@ -399,7 +428,7 @@ checkpoint_states: Dict[Checkpoint, BeaconState] = field(default_factory=dict) latest_messages: Dict[ValidatorIndex, LatestMessage] = field(default_factory=dict) unrealized_justifications: Dict[Root, Checkpoint] = field(default_factory=dict) - payload_states: Dict[Root, BeaconState] = field(default_factory=dict) + payloads: Dict[Root, ExecutionPayloadEnvelope] = field(default_factory=dict) payload_timeliness_vote: Dict[Root, Vector[boolean, PTC_SIZE]] = field(default_factory=dict) payload_data_availability_vote: Dict[Root, Vector[boolean, PTC_SIZE]] = field( default_factory=dict diff --git a/specrefs/functions.yml b/specrefs/functions.yml index 709db3a6a3fc..264fb1179eab 100644 --- a/specrefs/functions.yml +++ b/specrefs/functions.yml @@ -86,6 +86,19 @@ set_or_append_list(state.inactivity_scores, index, uint64(0)) +- name: adjust_committee_weight_estimate_to_ensure_safety#phase0 + sources: [] + spec: | + + def adjust_committee_weight_estimate_to_ensure_safety(estimate: Gwei) -> Gwei: + """ + Return adjusted ``estimate`` of the weight of a committee for a sequence of slots + spanning an epoch boundary that does not cover any full epoch. + """ + ceil = (estimate + 999) // 1000 + return Gwei(ceil * (1000 + COMMITTEE_WEIGHT_ESTIMATION_ADJUSTMENT_FACTOR)) + + - name: apply_deposit#phase0 sources: - file: packages/state-transition/src/block/processDeposit.ts @@ -201,6 +214,49 @@ store.optimistic_header = store.finalized_header +- name: apply_parent_execution_payload#gloas + sources: [] + spec: | + + def apply_parent_execution_payload( + state: BeaconState, + parent_bid: ExecutionPayloadBid, + requests: ExecutionRequests, + ) -> None: + parent_slot = parent_bid.slot + parent_epoch = compute_epoch_at_slot(parent_slot) + + # Process execution requests from parent's payload. The execution + # requests are processed at state.slot (child's slot), not the parent's slot. + def for_ops(operations: Sequence[Any], fn: Callable[[BeaconState, Any], None]) -> None: + for operation in operations: + fn(state, operation) + + for_ops(requests.deposits, process_deposit_request) + for_ops(requests.withdrawals, process_withdrawal_request) + for_ops(requests.consolidations, process_consolidation_request) + + # Settle the builder payment + if parent_epoch == get_current_epoch(state): + payment_index = SLOTS_PER_EPOCH + parent_slot % SLOTS_PER_EPOCH + settle_builder_payment(state, payment_index) + elif parent_epoch == get_previous_epoch(state): + payment_index = parent_slot % SLOTS_PER_EPOCH + settle_builder_payment(state, payment_index) + elif parent_bid.value > 0: + state.builder_pending_withdrawals.append( + BuilderPendingWithdrawal( + fee_recipient=parent_bid.fee_recipient, + amount=parent_bid.value, + builder_index=parent_bid.builder_index, + ) + ) + + # Update parent payload availability and latest block hash + state.execution_payload_availability[parent_slot % SLOTS_PER_HISTORICAL_ROOT] = 0b1 + state.latest_block_hash = parent_bid.block_hash + + - name: apply_pending_deposit#electra sources: - file: packages/state-transition/src/epoch/processPendingDeposits.ts @@ -440,6 +496,34 @@ return Epoch(epoch + 1 + MAX_SEED_LOOKAHEAD) +- name: compute_adversarial_weight#phase0 + sources: [] + spec: | + + def compute_adversarial_weight( + store: Store, + balance_source: BeaconState, + start_slot: Slot, + end_slot: Slot, + ) -> Gwei: + """ + Return maximum possible adversarial weight in the committees of the slots + between ``start_slot`` and ``end_slot`` (inclusive of both). + """ + total_active_balance = get_total_active_balance(balance_source) + maximum_weight = estimate_committee_weight_between_slots( + total_active_balance, start_slot, end_slot + ) + max_adversarial_weight = maximum_weight // 100 * CONFIRMATION_BYZANTINE_THRESHOLD + + # Discount total weight of equivocating validators + equivocation_score = get_equivocation_score(store, balance_source, start_slot, end_slot) + if max_adversarial_weight > equivocation_score: + return Gwei(max_adversarial_weight - equivocation_score) + else: + return Gwei(0) + + - name: compute_attestation_subnet_prefix_bits#phase0 sources: - file: packages/params/src/index.ts @@ -453,29 +537,10 @@ return uint64(ceillog2(ATTESTATION_SUBNET_COUNT) + ATTESTATION_SUBNET_EXTRA_BITS) -- name: compute_balance_weighted_acceptance#gloas - sources: [] - spec: | - - def compute_balance_weighted_acceptance( - state: BeaconState, index: ValidatorIndex, seed: Bytes32, i: uint64 - ) -> bool: - """ - Return whether to accept the selection of the validator ``index``, with probability - proportional to its ``effective_balance``, and randomness given by ``seed`` and ``i``. - """ - MAX_RANDOM_VALUE = 2**16 - 1 - random_bytes = hash(seed + uint_to_bytes(i // 16)) - offset = i % 16 * 2 - random_value = bytes_to_uint64(random_bytes[offset : offset + 2]) - effective_balance = state.validators[index].effective_balance - return effective_balance * MAX_RANDOM_VALUE >= MAX_EFFECTIVE_BALANCE_ELECTRA * random_value - - - name: compute_balance_weighted_selection#gloas sources: [] spec: | - + def compute_balance_weighted_selection( state: BeaconState, indices: Sequence[ValidatorIndex], @@ -489,17 +554,24 @@ are themselves sampled from ``indices`` by shuffling it, otherwise ``indices`` is traversed in order. """ + MAX_RANDOM_VALUE = 2**16 - 1 total = uint64(len(indices)) assert total > 0 + effective_balances = [state.validators[index].effective_balance for index in indices] selected: List[ValidatorIndex] = [] i = uint64(0) while len(selected) < size: + offset = i % 16 * 2 + if offset == 0: + random_bytes = hash(seed + uint_to_bytes(i // 16)) next_index = i % total if shuffle_indices: next_index = compute_shuffled_index(next_index, total, seed) - candidate_index = indices[next_index] - if compute_balance_weighted_acceptance(state, candidate_index, seed, i): - selected.append(candidate_index) + weight = effective_balances[next_index] * MAX_RANDOM_VALUE + random_value = bytes_to_uint64(random_bytes[offset : offset + 2]) + threshold = MAX_EFFECTIVE_BALANCE_ELECTRA * random_value + if weight >= threshold: + selected.append(indices[next_index]) i += 1 return selected @@ -595,6 +667,41 @@ return Domain(domain_type + fork_data_root[:28]) +- name: compute_empty_slot_support_discount#phase0 + sources: [] + spec: | + + def compute_empty_slot_support_discount( + store: Store, balance_source: BeaconState, block_root: Root + ) -> Gwei: + """ + Return weight that can be discounted during the safety threshold computation + if there are empty slots preceding the block. + """ + block = store.blocks[block_root] + parent_block = store.blocks[block.parent_root] + # No empty slot + if parent_block.slot + 1 == block.slot: + return Gwei(0) + + # Discount votes supporting the parent block if they are from the committees of empty slots + parent_support_in_empty_slots = get_block_support_between_slots( + store, + balance_source, + block.parent_root, + Slot(parent_block.slot + 1), + Slot(block.slot - 1), + ) + # Adversarial weight is not discounted + adversarial_weight = compute_adversarial_weight( + store, balance_source, Slot(parent_block.slot + 1), Slot(block.slot - 1) + ) + if parent_support_in_empty_slots > adversarial_weight: + return parent_support_in_empty_slots - adversarial_weight + else: + return Gwei(0) + + - name: compute_epoch_at_slot#phase0 sources: - file: packages/state-transition/src/util/epoch.ts @@ -885,6 +992,46 @@ return GENESIS_FORK_VERSION +- name: compute_honest_ffg_support_for_current_target#phase0 + sources: [] + spec: | + + def compute_honest_ffg_support_for_current_target(store: Store) -> Gwei: + """ + Compute honest FFG support of the current epoch target. + """ + current_slot = get_current_slot(store) + current_epoch = compute_epoch_at_slot(current_slot) + balance_source = get_pulled_up_head_state(store) + total_active_balance = get_total_active_balance(balance_source) + + # Compute FFG support for the target + ffg_support_for_checkpoint = get_current_target_score(store) + + # Compute the total FFG weight up to, but excluding, the current slot + ffg_weight_till_now = estimate_committee_weight_between_slots( + total_active_balance, compute_start_slot_at_epoch(current_epoch), Slot(current_slot - 1) + ) + + # Compute remaining honest FFG weight + remaining_ffg_weight = total_active_balance - ffg_weight_till_now + remaining_honest_ffg_weight = Gwei( + remaining_ffg_weight // 100 * (100 - CONFIRMATION_BYZANTINE_THRESHOLD) + ) + + # Compute potential adversarial weight + adversarial_weight = compute_adversarial_weight( + store, balance_source, compute_start_slot_at_epoch(current_epoch), Slot(current_slot - 1) + ) + + # Compute min honest FFG support + min_honest_ffg_support = ffg_support_for_checkpoint - min( + adversarial_weight, ffg_support_for_checkpoint + ) + + return Gwei(min_honest_ffg_support + remaining_honest_ffg_weight) + + - name: compute_matrix#fulu sources: - file: packages/beacon-node/src/util/dataColumns.ts @@ -1146,32 +1293,76 @@ update_checkpoints(store, state.current_justified_checkpoint, state.finalized_checkpoint) +- name: compute_safety_threshold#phase0 + sources: [] + spec: | + + def compute_safety_threshold(store: Store, block_root: Root, balance_source: BeaconState) -> Gwei: + """ + Compute the LMD-GHOST safety threshold for ``block_root``. + """ + current_slot = get_current_slot(store) + block = store.blocks[block_root] + parent_block = store.blocks[block.parent_root] + + total_active_balance = get_total_active_balance(balance_source) + proposer_score = compute_proposer_score(balance_source) + maximum_support = estimate_committee_weight_between_slots( + total_active_balance, Slot(parent_block.slot + 1), Slot(current_slot - 1) + ) + support_discount = get_support_discount(store, balance_source, block_root) + adversarial_weight = get_adversarial_weight(store, balance_source, block_root) + + # Return (maximum_support + proposer_score - support_discount) // 2 + adversarial_weight + # with an underflow guard + if support_discount < maximum_support + proposer_score + 2 * adversarial_weight: + return (maximum_support + proposer_score + 2 * adversarial_weight - support_discount) // 2 + else: + return Gwei(0) + + - name: compute_shuffled_index#phase0 sources: - file: packages/state-transition/src/util/seed.ts search: export function computeShuffledIndex( spec: | - + def compute_shuffled_index(index: uint64, index_count: uint64, seed: Bytes32) -> uint64: """ Return the shuffled index corresponding to ``seed`` (and ``index_count``). """ assert index < index_count + return compute_shuffled_permutation(index_count, seed)[index] + +- name: compute_shuffled_permutation#phase0 + sources: [] + spec: | + + def compute_shuffled_permutation(index_count: uint64, seed: Bytes32) -> Sequence[uint64]: + """ + Return the full shuffled permutation corresponding to ``seed`` (and ``index_count``). + """ # Swap or not (https://link.springer.com/content/pdf/10.1007%2F978-3-642-32009-5_1.pdf) # See the 'generalized domain' algorithm on page 3 + indices = [uint64(i) for i in range(index_count)] for current_round in range(SHUFFLE_ROUND_COUNT): - pivot = bytes_to_uint64(hash(seed + uint_to_bytes(uint8(current_round)))[0:8]) % index_count - flip = (pivot + index_count - index) % index_count - position = max(index, flip) - source = hash( - seed + uint_to_bytes(uint8(current_round)) + uint_to_bytes(uint32(position // 256)) - ) - byte = uint8(source[(position % 256) // 8]) - bit = (byte >> (position % 8)) % 2 - index = flip if bit else index - - return index + round_bytes = current_round.to_bytes(1, "little") + pivot = int.from_bytes(hash(seed + round_bytes)[0:8], "little") % index_count + source_by_bucket: Dict[uint64, Bytes32] = {} + for i in range(index_count): + flip = (pivot + index_count - indices[i]) % index_count + position = max(indices[i], flip) + position_bucket = position // 256 + if position_bucket not in source_by_bucket: + source_by_bucket[position_bucket] = hash( + seed + round_bytes + position_bucket.to_bytes(4, "little") + ) + source = source_by_bucket[position_bucket] + byte_val = source[(position % 256) // 8] + bit = (byte_val >> int(position % 8)) % 2 + indices[i] = flip if bit else indices[i] + return indices - name: compute_signed_block_header#deneb @@ -1595,6 +1786,54 @@ state.balances[index] = 0 if delta > state.balances[index] else state.balances[index] - delta +- name: estimate_committee_weight_between_slots#phase0 + sources: [] + spec: | + + def estimate_committee_weight_between_slots( + total_active_balance: Gwei, start_slot: Slot, end_slot: Slot + ) -> Gwei: + """ + Return estimate of the total weight of committees + between ``start_slot`` and ``end_slot`` (inclusive of both). + """ + + # Sanity check + if start_slot > end_slot: + return Gwei(0) + + # If an entire epoch is covered by the range, return the total active balance + if is_full_validator_set_covered(start_slot, end_slot): + return total_active_balance + + start_epoch = compute_epoch_at_slot(start_slot) + end_epoch = compute_epoch_at_slot(end_slot) + committee_weight = total_active_balance // SLOTS_PER_EPOCH + if start_epoch == end_epoch: + return committee_weight * (end_slot - start_slot + 1) + else: + # First, calculate the number of committees in the end epoch + num_slots_in_end_epoch = compute_slots_since_epoch_start(end_slot) + 1 + # Next, calculate the number of slots remaining in the end epoch + remaining_slots_in_end_epoch = SLOTS_PER_EPOCH - num_slots_in_end_epoch + # Then, calculate the number of slots in the start epoch + num_slots_in_start_epoch = SLOTS_PER_EPOCH - compute_slots_since_epoch_start(start_slot) + + start_epoch_weight = committee_weight * num_slots_in_start_epoch + end_epoch_weight = committee_weight * num_slots_in_end_epoch + + # A range that spans an epoch boundary, but does not span any full epoch + # needs pro-rata calculation, see https://gist.github.com/saltiniroberto/9ee53d29c33878d79417abb2b4468c20 + # start_epoch_weight_pro_rated = start_epoch_weight * (1 - num_slots_in_end_epoch / SLOTS_PER_EPOCH) + start_epoch_weight_pro_rated = ( + start_epoch_weight // SLOTS_PER_EPOCH * remaining_slots_in_end_epoch + ) + + return adjust_committee_weight_estimate_to_ensure_safety( + Gwei(start_epoch_weight_pro_rated + end_epoch_weight) + ) + + - name: eth_aggregate_pubkeys#altair sources: [] spec: | @@ -1707,6 +1946,101 @@ return FINALIZED_ROOT_GINDEX +- name: find_latest_confirmed_descendant#phase0 + sources: [] + spec: | + + def find_latest_confirmed_descendant( + fcr_store: FastConfirmationStore, latest_confirmed_root: Root + ) -> Root: + """ + Return the most recent confirmed block in the suffix of the canonical chain + starting from ``latest_confirmed_root``. + """ + store = fcr_store.store + head = get_head(store) + current_epoch = get_current_store_epoch(store) + confirmed_root = latest_confirmed_root + + if ( + get_block_epoch(store, confirmed_root) + 1 == current_epoch + and get_voting_source(store, fcr_store.previous_slot_head).epoch + 2 >= current_epoch + and ( + is_start_slot_at_epoch(get_current_slot(store)) + or ( + will_no_conflicting_checkpoint_be_justified(store) + and ( + store.unrealized_justifications[fcr_store.previous_slot_head].epoch + 1 + >= current_epoch + or store.unrealized_justifications[head].epoch + 1 >= current_epoch + ) + ) + ) + ): + # Get suffix of the canonical chain + canonical_roots = get_ancestor_roots(store, head, confirmed_root) + + # Starting with the child of the latest_confirmed_root + # move towards the head in attempt to advance the confirmed block + # and stop when the first unconfirmed descendant is encountered + for block_root in canonical_roots: + block_epoch = get_block_epoch(store, block_root) + + # If the current epoch is reached, exit the loop + # as this code is meant to confirm blocks from the previous epoch + if block_epoch == current_epoch: + break + + # The algorithm can only rely on the previous head + # if it is a descendant of the block that is attempted to be confirmed + if not is_ancestor(store, fcr_store.previous_slot_head, block_root): + break + + if not is_one_confirmed(store, get_current_balance_source(fcr_store), block_root): + break + + confirmed_root = block_root + + if ( + is_start_slot_at_epoch(get_current_slot(store)) + or store.unrealized_justifications[head].epoch + 1 >= current_epoch + ): + # Get suffix of the canonical chain + canonical_roots = get_ancestor_roots(store, head, confirmed_root) + + tentative_confirmed_root = confirmed_root + + for block_root in canonical_roots: + block_epoch = get_block_epoch(store, block_root) + tentative_confirmed_epoch = get_block_epoch(store, tentative_confirmed_root) + + # The following condition can only be true the first time + # the algorithm advances to a block from the current epoch + if block_epoch > tentative_confirmed_epoch: + # To confirm blocks from the current epoch ensure that + # current epoch target will be justified + if not will_current_target_be_justified(store): + break + + if not is_one_confirmed(store, get_current_balance_source(fcr_store), block_root): + break + + tentative_confirmed_root = block_root + + # The tentative_confirmed_root can only be confirmed + # if it is for sure not going to be reorged out in either the current or next epoch. + if get_block_epoch(store, tentative_confirmed_root) == current_epoch or ( + get_voting_source(store, tentative_confirmed_root).epoch + 2 >= current_epoch + and ( + is_start_slot_at_epoch(get_current_slot(store)) + or will_no_conflicting_checkpoint_be_justified(store) + ) + ): + confirmed_root = tentative_confirmed_root + + return confirmed_root + + - name: get_activation_exit_churn_limit#electra sources: - file: packages/state-transition/src/util/validator.ts @@ -1735,6 +2069,24 @@ ] +- name: get_adversarial_weight#phase0 + sources: [] + spec: | + + def get_adversarial_weight(store: Store, balance_source: BeaconState, block_root: Root) -> Gwei: + """ + Return maximum adversarial weight that can support the block. + """ + current_slot = get_current_slot(store) + block = store.blocks[block_root] + if get_block_epoch(store, block_root) > get_block_epoch(store, block.parent_root): + # Use the first epoch slot as the start slot when crossing epoch boundary + start_slot = compute_start_slot_at_epoch(get_block_epoch(store, block_root)) + return compute_adversarial_weight(store, balance_source, start_slot, Slot(current_slot - 1)) + else: + return compute_adversarial_weight(store, balance_source, block.slot, Slot(current_slot - 1)) + + - name: get_aggregate_and_proof#phase0 sources: - file: packages/validator/src/services/validatorStore.ts @@ -1773,8 +2125,8 @@ - file: packages/config/src/forkConfig/index.ts search: "getAggregateDueMs(fork: ForkName): number {" spec: | - - def get_aggregate_due_ms(epoch: Epoch) -> uint64: + + def get_aggregate_due_ms() -> uint64: return get_slot_component_duration_ms(AGGREGATE_DUE_BPS) @@ -1783,12 +2135,10 @@ - file: packages/config/src/forkConfig/index.ts search: "getAggregateDueMs(fork: ForkName): number {" spec: | - - def get_aggregate_due_ms(epoch: Epoch) -> uint64: - # [New in Gloas] - if epoch >= GLOAS_FORK_EPOCH: - return get_slot_component_duration_ms(AGGREGATE_DUE_BPS_GLOAS) - return get_slot_component_duration_ms(AGGREGATE_DUE_BPS) + + def get_aggregate_due_ms() -> uint64: + # [Modified in Gloas] + return get_slot_component_duration_ms(AGGREGATE_DUE_BPS_GLOAS) - name: get_aggregate_signature#phase0 @@ -1839,6 +2189,28 @@ ) +- name: get_ancestor_roots#phase0 + sources: [] + spec: | + + def get_ancestor_roots(store: Store, block_root: Root, terminal_root: Root) -> Sequence[Root]: + """ + Return a list of ancestors of ``block_root`` inclusive until ``terminal_root`` exclusive. + """ + root = block_root + ancestor_roots: list[Root] = [] + while store.blocks[root].slot > store.blocks[terminal_root].slot: + ancestor_roots.insert(0, root) + root = store.blocks[root].parent_root + + # Return when terminal_root is reached + if root == terminal_root: + return ancestor_roots + + # Return empty list if terminal_root is not in the chain of block_root + return [] + + - name: get_attestation_component_deltas#phase0 sources: [] spec: | @@ -1903,8 +2275,8 @@ - file: packages/config/src/forkConfig/index.ts search: "getAttestationDueMs(fork: ForkName): number {" spec: | - - def get_attestation_due_ms(epoch: Epoch) -> uint64: + + def get_attestation_due_ms() -> uint64: return get_slot_component_duration_ms(ATTESTATION_DUE_BPS) @@ -1913,12 +2285,10 @@ - file: packages/config/src/forkConfig/index.ts search: "getAttestationDueMs(fork: ForkName): number {" spec: | - - def get_attestation_due_ms(epoch: Epoch) -> uint64: - # [New in Gloas] - if epoch >= GLOAS_FORK_EPOCH: - return get_slot_component_duration_ms(ATTESTATION_DUE_BPS_GLOAS) - return get_slot_component_duration_ms(ATTESTATION_DUE_BPS) + + def get_attestation_due_ms() -> uint64: + # [Modified in Gloas] + return get_slot_component_duration_ms(ATTESTATION_DUE_BPS_GLOAS) - name: get_attestation_participation_flag_indices#altair @@ -2361,6 +2731,17 @@ ] +- name: get_block_epoch#phase0 + sources: [] + spec: | + + def get_block_epoch(store: Store, block_root: Root) -> Epoch: + """ + Return an epoch of the block. + """ + return compute_epoch_at_slot(store.blocks[block_root].slot) + + - name: get_block_root#phase0 sources: - file: packages/state-transition/src/util/blockRoot.ts @@ -2400,6 +2781,62 @@ return bls.Sign(privkey, signing_root) +- name: get_block_slot#phase0 + sources: [] + spec: | + + def get_block_slot(store: Store, block_root: Root) -> Slot: + """ + Return a slot of the block. + """ + return store.blocks[block_root].slot + + +- name: get_block_support_between_slots#phase0 + sources: [] + spec: | + + def get_block_support_between_slots( + store: Store, + balance_source: BeaconState, + block_root: Root, + start_slot: Slot, + end_slot: Slot, + ) -> Gwei: + """ + Return support of the block by validators assigned to slots + between ``start_slot`` and ``end_slot`` (inclusive of both). + """ + participants: Set[ValidatorIndex] = set() + for slot in range(start_slot, end_slot + 1): + participants.update(get_slot_committee(store, Slot(slot))) + + # Keep validators that were active at the balance_source epoch to be consistent + # with get_total_active_balance() computation, also filter out slashed validators + unslashed_and_active_indices = [ + i + for i in participants + if ( + not balance_source.validators[i].slashed + and is_active_validator(balance_source.validators[i], get_current_epoch(balance_source)) + ) + ] + + return Gwei( + sum( + balance_source.validators[i].effective_balance + for i in unslashed_and_active_indices + # Check that validator has voted in the support of the block + # and has not been slashed + if ( + i in store.latest_messages + and store.latest_messages[i].root == block_root + and i not in store.equivocating_indices + ) + ) + ) + + - name: get_builder_payment_quorum_threshold#gloas sources: - file: packages/state-transition/src/util/gloas.ts @@ -2515,6 +2952,17 @@ return get_ancestor(store, root, epoch_first_slot).root +- name: get_checkpoint_for_block#phase0 + sources: [] + spec: | + + def get_checkpoint_for_block(store: Store, block_root: Root, epoch: Epoch) -> Checkpoint: + """ + Return a checkpoint in the chain of the block at the ``epoch``. + """ + return Checkpoint(epoch=epoch, root=get_checkpoint_block(store, block_root, epoch)) + + - name: get_committee_assignment#phase0 sources: - file: packages/state-transition/src/cache/epochCache.ts @@ -2631,8 +3079,8 @@ - file: packages/config/src/forkConfig/index.ts search: "getSyncContributionDueMs(fork: ForkName): number {" spec: | - - def get_contribution_due_ms(epoch: Epoch) -> uint64: + + def get_contribution_due_ms() -> uint64: return get_slot_component_duration_ms(CONTRIBUTION_DUE_BPS) @@ -2641,12 +3089,19 @@ - file: packages/config/src/forkConfig/index.ts search: "getSyncContributionDueMs(fork: ForkName): number {" spec: | - - def get_contribution_due_ms(epoch: Epoch) -> uint64: - # [New in Gloas] - if epoch >= GLOAS_FORK_EPOCH: - return get_slot_component_duration_ms(CONTRIBUTION_DUE_BPS_GLOAS) - return get_slot_component_duration_ms(CONTRIBUTION_DUE_BPS) + + def get_contribution_due_ms() -> uint64: + # [Modified in Gloas] + return get_slot_component_duration_ms(CONTRIBUTION_DUE_BPS_GLOAS) + + +- name: get_current_balance_source#phase0 + sources: [] + spec: | + + def get_current_balance_source(fcr_store: FastConfirmationStore) -> BeaconState: + store = fcr_store.store + return store.checkpoint_states[fcr_store.current_epoch_observed_justified_checkpoint] - name: get_current_epoch#phase0 @@ -2680,6 +3135,52 @@ return compute_epoch_at_slot(get_current_slot(store)) +- name: get_current_target#phase0 + sources: [] + spec: | + + def get_current_target(store: Store) -> Checkpoint: + """ + Return current epoch target. + """ + head = get_head(store) + current_epoch = get_current_store_epoch(store) + return get_checkpoint_for_block(store, head, current_epoch) + + +- name: get_current_target_score#phase0 + sources: [] + spec: | + + def get_current_target_score(store: Store) -> Gwei: + """ + Return the estimate of FFG support of the current epoch target by using LMD-GHOST votes. + """ + target = get_current_target(store) + state = get_pulled_up_head_state(store) + unslashed_and_active_indices = [ + i + for i in get_active_validator_indices(state, get_current_epoch(state)) + if not state.validators[i].slashed + ] + return Gwei( + sum( + state.validators[i].effective_balance + for i in unslashed_and_active_indices + if ( + i in store.latest_messages + and i not in store.equivocating_indices + and target + == get_checkpoint_for_block( + store, + store.latest_messages[i].root, + get_latest_message_epoch(store.latest_messages[i]), + ) + ) + ) + ) + + - name: get_custody_groups#fulu sources: - file: packages/beacon-node/src/util/dataColumns.ts @@ -2945,6 +3446,37 @@ return bls.Sign(privkey, signing_root) +- name: get_equivocation_score#phase0 + sources: [] + spec: | + + def get_equivocation_score( + store: Store, + balance_source: BeaconState, + start_slot: Slot, + end_slot: Slot, + ) -> Gwei: + """ + Return total weight of equivocating participants of all committees + in the slots between ``start_slot`` and ``end_slot`` (inclusive of both). + """ + committee_indices: Set[ValidatorIndex] = set() + for slot in range(start_slot, end_slot + 1): + committee_indices.update(get_slot_committee(store, Slot(slot))) + + # Keep equivocating validators that were active at the balance_source epoch to be consistent + # with get_total_active_balance() computation + active_equivocating_indices = [ + i + for i in committee_indices.intersection(store.equivocating_indices) + if is_active_validator(balance_source.validators[i], get_current_epoch(balance_source)) + ] + + return Gwei( + sum(balance_source.validators[i].effective_balance for i in active_equivocating_indices) + ) + + - name: get_eth1_pending_deposit_count#electra sources: [] spec: | @@ -3245,6 +3777,22 @@ ) +- name: get_fast_confirmation_store#phase0 + sources: [] + spec: | + + def get_fast_confirmation_store(store: Store) -> FastConfirmationStore: + return FastConfirmationStore( + store=store, + confirmed_root=store.finalized_checkpoint.root, + previous_epoch_observed_justified_checkpoint=store.finalized_checkpoint, + current_epoch_observed_justified_checkpoint=store.finalized_checkpoint, + previous_epoch_greatest_unrealized_checkpoint=store.finalized_checkpoint, + previous_slot_head=store.finalized_checkpoint.root, + current_slot_head=store.finalized_checkpoint.root, + ) + + - name: get_filtered_block_tree#phase0 sources: [] spec: | @@ -3335,7 +3883,7 @@ - name: get_forkchoice_store#gloas sources: [] spec: | - + def get_forkchoice_store(anchor_state: BeaconState, anchor_block: BeaconBlock) -> Store: assert anchor_block.state_root == hash_tree_root(anchor_state) anchor_root = hash_tree_root(anchor_block) @@ -3359,7 +3907,7 @@ checkpoint_states={justified_checkpoint: copy(anchor_state)}, unrealized_justifications={anchor_root: justified_checkpoint}, # [New in Gloas:EIP7732] - payload_states={anchor_root: copy(anchor_state)}, + payloads={}, # [New in Gloas:EIP7732] payload_timeliness_vote={ anchor_root: Vector[boolean, PTC_SIZE](True for _ in range(PTC_SIZE)) @@ -3374,7 +3922,7 @@ - name: get_forkchoice_store#heze sources: [] spec: | - + def get_forkchoice_store(anchor_state: BeaconState, anchor_block: BeaconBlock) -> Store: assert anchor_block.state_root == hash_tree_root(anchor_state) anchor_root = hash_tree_root(anchor_block) @@ -3396,7 +3944,7 @@ block_timeliness={anchor_root: [True, True]}, checkpoint_states={justified_checkpoint: copy(anchor_state)}, unrealized_justifications={anchor_root: justified_checkpoint}, - payload_states={anchor_root: copy(anchor_state)}, + payloads={}, payload_timeliness_vote={ anchor_root: Vector[boolean, PTC_SIZE](True for _ in range(PTC_SIZE)) }, @@ -3683,8 +4231,8 @@ - name: get_inclusion_list_submission_due_ms#heze sources: [] spec: | - - def get_inclusion_list_submission_due_ms(epoch: Epoch) -> uint64: + + def get_inclusion_list_submission_due_ms() -> uint64: return get_slot_component_duration_ms(INCLUSION_LIST_SUBMISSION_DUE_BPS) @@ -3775,6 +4323,85 @@ ) +- name: get_latest_confirmed#phase0 + sources: [] + spec: | + + def get_latest_confirmed(fcr_store: FastConfirmationStore) -> Root: + """ + Return the most recent confirmed block by executing the FCR algorithm. + """ + store = fcr_store.store + confirmed_root = fcr_store.confirmed_root + current_epoch = get_current_store_epoch(store) + + # Revert to finalized block if either of the following is true: + # 1) the latest confirmed block's epoch is older than the previous epoch, + # 2) the latest confirmed block does not belong to the canonical chain, + # 3) the confirmed chain starting from the current epoch observed justified checkpoint + # cannot be re-confirmed at the start of the current epoch. + head = get_head(store) + if ( + get_block_epoch(store, confirmed_root) + 1 < current_epoch + or not is_ancestor(store, head, confirmed_root) + or ( + is_start_slot_at_epoch(get_current_slot(store)) + and not is_confirmed_chain_safe(fcr_store, confirmed_root) + ) + ): + confirmed_root = store.finalized_checkpoint.root + + # Restart the confirmation chain if each of the following conditions are true: + # 1) it is the start of the current epoch, + # 2) epoch of fcr_store.current_epoch_observed_justified_checkpoint.root equals to the previous epoch, + # 3) fcr_store.current_epoch_observed_justified_checkpoint equals to unrealized justification of the head, + # 4) confirmed block is older than the block of fcr_store.current_epoch_observed_justified_checkpoint. + is_epoch_start = is_start_slot_at_epoch(get_current_slot(store)) + observed_justified_block_slot = get_block_slot( + store, fcr_store.current_epoch_observed_justified_checkpoint.root + ) + is_observed_justified_block_epoch_ok = ( + compute_epoch_at_slot(observed_justified_block_slot) + 1 == current_epoch + ) + is_head_unrealized_justified_ok = ( + fcr_store.current_epoch_observed_justified_checkpoint + == store.unrealized_justifications[head] + ) + is_confirmed_block_stale = get_block_slot(store, confirmed_root) < observed_justified_block_slot + if ( + is_epoch_start + and is_observed_justified_block_epoch_ok + and is_head_unrealized_justified_ok + and is_confirmed_block_stale + ): + confirmed_root = fcr_store.current_epoch_observed_justified_checkpoint.root + + # Attempt to further advance the latest confirmed block + if get_block_epoch(store, confirmed_root) + 1 >= current_epoch: + return find_latest_confirmed_descendant(fcr_store, confirmed_root) + else: + return confirmed_root + + +- name: get_latest_message_epoch#phase0 + sources: [] + spec: | + + def get_latest_message_epoch(latest_message: LatestMessage) -> Epoch: + """ + Return epoch of the ``latest_message``. + """ + return latest_message.epoch + + +- name: get_latest_message_epoch#gloas + sources: [] + spec: | + + def get_latest_message_epoch(latest_message: LatestMessage) -> Epoch: + return compute_epoch_at_slot(latest_message.slot) + + - name: get_lc_execution_root#capella sources: [] spec: | @@ -3826,37 +4453,15 @@ - name: get_lc_execution_root#electra sources: [] spec: | - + def get_lc_execution_root(header: LightClientHeader) -> Root: epoch = compute_epoch_at_slot(header.beacon.slot) - # [New in Electra] - if epoch >= ELECTRA_FORK_EPOCH: - return hash_tree_root(header.execution) - - # [Modified in Electra] + # [New in Deneb] if epoch >= DENEB_FORK_EPOCH: - execution_header = deneb.ExecutionPayloadHeader( - parent_hash=header.execution.parent_hash, - fee_recipient=header.execution.fee_recipient, - state_root=header.execution.state_root, - receipts_root=header.execution.receipts_root, - logs_bloom=header.execution.logs_bloom, - prev_randao=header.execution.prev_randao, - block_number=header.execution.block_number, - gas_limit=header.execution.gas_limit, - gas_used=header.execution.gas_used, - timestamp=header.execution.timestamp, - extra_data=header.execution.extra_data, - base_fee_per_gas=header.execution.base_fee_per_gas, - block_hash=header.execution.block_hash, - transactions_root=header.execution.transactions_root, - withdrawals_root=header.execution.withdrawals_root, - blob_gas_used=header.execution.blob_gas_used, - excess_blob_gas=header.execution.excess_blob_gas, - ) - return hash_tree_root(execution_header) + return hash_tree_root(header.execution) + # [Modified in Deneb] if epoch >= CAPELLA_FORK_EPOCH: execution_header = capella.ExecutionPayloadHeader( parent_hash=header.execution.parent_hash, @@ -4041,13 +4646,13 @@ - name: get_node_children#gloas sources: [] spec: | - + def get_node_children( store: Store, blocks: Dict[Root, BeaconBlock], node: ForkChoiceNode ) -> Sequence[ForkChoiceNode]: if node.payload_status == PAYLOAD_STATUS_PENDING: children = [ForkChoiceNode(root=node.root, payload_status=PAYLOAD_STATUS_EMPTY)] - if node.root in store.payload_states: + if is_payload_verified(store, node.root): children.append(ForkChoiceNode(root=node.root, payload_status=PAYLOAD_STATUS_FULL)) return children else: @@ -4064,19 +4669,24 @@ - name: get_parent_payload_status#gloas sources: [] spec: | - + def get_parent_payload_status(store: Store, block: BeaconBlock) -> PayloadStatus: parent = store.blocks[block.parent_root] parent_block_hash = block.body.signed_execution_payload_bid.message.parent_block_hash message_block_hash = parent.body.signed_execution_payload_bid.message.block_hash + + # Check for uninitialized genesis block hash + if message_block_hash == Hash32(): + return PAYLOAD_STATUS_EMPTY + return PAYLOAD_STATUS_FULL if parent_block_hash == message_block_hash else PAYLOAD_STATUS_EMPTY - name: get_payload_attestation_due_ms#gloas sources: [] spec: | - - def get_payload_attestation_due_ms(epoch: Epoch) -> uint64: + + def get_payload_attestation_due_ms() -> uint64: return get_slot_component_duration_ms(PAYLOAD_ATTESTATION_DUE_BPS) @@ -4223,6 +4833,15 @@ return None +- name: get_previous_balance_source#phase0 + sources: [] + spec: | + + def get_previous_balance_source(fcr_store: FastConfirmationStore) -> BeaconState: + store = fcr_store.store + return store.checkpoint_states[fcr_store.previous_epoch_observed_justified_checkpoint] + + - name: get_previous_epoch#phase0 sources: - file: packages/state-transition/src/util/epoch.ts @@ -4301,8 +4920,8 @@ - name: get_proposer_inclusion_list_cutoff_ms#heze sources: [] spec: | - - def get_proposer_inclusion_list_cutoff_ms(epoch: Epoch) -> uint64: + + def get_proposer_inclusion_list_cutoff_ms() -> uint64: return get_slot_component_duration_ms(PROPOSER_INCLUSION_LIST_CUTOFF_BPS) @@ -4325,8 +4944,8 @@ - file: packages/config/src/forkConfig/index.ts search: "getProposerReorgCutoffMs(_fork: ForkName): number {" spec: | - - def get_proposer_reorg_cutoff_ms(epoch: Epoch) -> uint64: + + def get_proposer_reorg_cutoff_ms() -> uint64: return get_slot_component_duration_ms(PROPOSER_REORG_CUTOFF_BPS) @@ -4390,6 +5009,24 @@ return None +- name: get_pulled_up_head_state#phase0 + sources: [] + spec: | + + def get_pulled_up_head_state(store: Store) -> BeaconState: + """ + Return the state of the head pulled up to the current epoch if needed. + """ + head = get_head(store) + head_state = store.block_states[head] + if get_current_epoch(head_state) < get_current_store_epoch(store): + pulled_up_state = copy(head_state) + process_slots(pulled_up_state, compute_start_slot_at_epoch(get_current_store_epoch(store))) + return pulled_up_state + else: + return head_state + + - name: get_randao_mix#phase0 sources: - file: packages/state-transition/src/util/seed.ts @@ -4435,6 +5072,23 @@ return hash(domain_type + uint_to_bytes(epoch) + mix) +- name: get_slot_committee#phase0 + sources: [] + spec: | + + def get_slot_committee(store: Store, slot: Slot) -> Set[ValidatorIndex]: + """ + Return participants of all committees in ``slot``. + """ + head = get_head(store) + shuffling_source = store.block_states[head] + committees_count = get_committee_count_per_slot(shuffling_source, compute_epoch_at_slot(slot)) + participants: Set[ValidatorIndex] = set() + for i in range(committees_count): + participants.update(get_beacon_committee(shuffling_source, slot, CommitteeIndex(i))) + return participants + + - name: get_slot_component_duration_ms#phase0 sources: - file: packages/config/src/forkConfig/index.ts @@ -4492,6 +5146,17 @@ return uint64(generalized_index % 2 ** (floorlog2(generalized_index))) +- name: get_support_discount#phase0 + sources: [] + spec: | + + def get_support_discount(store: Store, balance_source: BeaconState, block_root: Root) -> Gwei: + """ + Return weight that can be discounted during the safety threshold computation for the block. + """ + return compute_empty_slot_support_discount(store, balance_source, block_root) + + - name: get_sync_committee_message#altair sources: - file: packages/validator/src/services/validatorStore.ts @@ -4537,8 +5202,8 @@ - file: packages/config/src/forkConfig/index.ts search: "getSyncMessageDueMs(fork: ForkName): number {" spec: | - - def get_sync_message_due_ms(epoch: Epoch) -> uint64: + + def get_sync_message_due_ms() -> uint64: return get_slot_component_duration_ms(SYNC_MESSAGE_DUE_BPS) @@ -4547,12 +5212,10 @@ - file: packages/config/src/forkConfig/index.ts search: "getSyncMessageDueMs(fork: ForkName): number {" spec: | - - def get_sync_message_due_ms(epoch: Epoch) -> uint64: - # [New in Gloas] - if epoch >= GLOAS_FORK_EPOCH: - return get_slot_component_duration_ms(SYNC_MESSAGE_DUE_BPS_GLOAS) - return get_slot_component_duration_ms(SYNC_MESSAGE_DUE_BPS) + + def get_sync_message_due_ms() -> uint64: + # [Modified in Gloas] + return get_slot_component_duration_ms(SYNC_MESSAGE_DUE_BPS_GLOAS) - name: get_sync_subcommittee_pubkeys#altair @@ -4905,8 +5568,8 @@ - name: get_view_freeze_cutoff_ms#heze sources: [] spec: | - - def get_view_freeze_cutoff_ms(epoch: Epoch) -> uint64: + + def get_view_freeze_cutoff_ms() -> uint64: return get_slot_component_duration_ms(VIEW_FREEZE_CUTOFF_BPS) @@ -5196,17 +5859,13 @@ - file: packages/state-transition/src/util/gloas.ts search: export function initiateBuilderExit( spec: | - + def initiate_builder_exit(state: BeaconState, builder_index: BuilderIndex) -> None: """ Initiate the exit of the builder with index ``index``. """ - # Return if builder already initiated exit - builder = state.builders[builder_index] - if builder.withdrawable_epoch != FAR_FUTURE_EPOCH: - return - # Set builder exit epoch + builder = state.builders[builder_index] builder.withdrawable_epoch = get_current_epoch(state) + MIN_BUILDER_WITHDRAWABILITY_DELAY @@ -5326,6 +5985,17 @@ return bytes_to_uint64(hash(slot_signature)[0:8]) % modulo == 0 +- name: is_ancestor#phase0 + sources: [] + spec: | + + def is_ancestor(store: Store, block_root: Root, ancestor_root: Root) -> bool: + """ + Return ``True`` if ``ancestor_root`` is an ancestor of ``block_root``. + """ + return get_ancestor(store, block_root, store.blocks[ancestor_root].slot) == ancestor_root + + - name: is_assigned_to_sync_committee#altair sources: [] spec: | @@ -5466,6 +6136,48 @@ return withdrawal_credentials[:1] == COMPOUNDING_WITHDRAWAL_PREFIX +- name: is_confirmed_chain_safe#phase0 + sources: [] + spec: | + + def is_confirmed_chain_safe(fcr_store: FastConfirmationStore, confirmed_root: Root) -> bool: + """ + Return ``True`` if and only if all blocks of the confirmed chain + starting from current_epoch_observed_justified_checkpoint are LMD-GHOST safe. + """ + store = fcr_store.store + # Check if the confirmed_root is descendant of current_epoch_observed_justified_checkpoint + if not is_ancestor( + store, confirmed_root, fcr_store.current_epoch_observed_justified_checkpoint.root + ): + return False + + current_epoch = get_current_store_epoch(store) + if fcr_store.current_epoch_observed_justified_checkpoint.epoch + 1 >= current_epoch: + # Exclude the justified checkpoint block if it is from the previous epoch + # as then this block will always be canonical in this case. + start_root_exclusive = fcr_store.current_epoch_observed_justified_checkpoint.root + else: + # Limit reconfirmation to the first block of the previous epoch + # as if it is successful, reconfirmation of the ancestors is implied. + ancestor_at_previous_epoch_start = get_ancestor( + store, confirmed_root, compute_start_slot_at_epoch(Epoch(current_epoch - 1)) + ) + if get_block_epoch(store, ancestor_at_previous_epoch_start) + 1 == current_epoch: + # The parent of the first block of the previous epoch + start_root_exclusive = store.blocks[ancestor_at_previous_epoch_start].parent_root + else: + # The last block of the epoch before the previous one + start_root_exclusive = ancestor_at_previous_epoch_start + + # Run is_one_confirmed for each block in the confirmed chain with the previous epoch balance source + chain_roots = get_ancestor_roots(store, confirmed_root, start_root_exclusive) + return all( + is_one_confirmed(store, get_previous_balance_source(fcr_store), root) + for root in chain_roots + ) + + - name: is_data_available#deneb sources: - file: packages/beacon-node/src/chain/blocks/verifyBlocksDataAvailability.ts @@ -5646,6 +6358,19 @@ return epochs_since_finalization <= REORG_MAX_EPOCHS_SINCE_FINALIZATION +- name: is_full_validator_set_covered#phase0 + sources: [] + spec: | + + def is_full_validator_set_covered(start_slot: Slot, end_slot: Slot) -> bool: + """ + Return ``True`` if the range between ``start_slot`` and ``end_slot`` (inclusive of both) includes an entire epoch. + """ + start_full_epoch = compute_epoch_at_slot(start_slot + (SLOTS_PER_EPOCH - 1)) + end_full_epoch = compute_epoch_at_slot(Slot(end_slot + 1)) + return start_full_epoch < end_full_epoch + + - name: is_fully_withdrawable_validator#capella sources: [] spec: | @@ -5796,6 +6521,19 @@ return store.next_sync_committee != SyncCommittee() +- name: is_one_confirmed#phase0 + sources: [] + spec: | + + def is_one_confirmed(store: Store, balance_source: BeaconState, block_root: Root) -> bool: + """ + Return ``True`` if and only if the block is LMD-GHOST safe. + """ + support = get_attestation_score(store, block_root, balance_source) + safety_threshold = compute_safety_threshold(store, block_root, balance_source) + return support > safety_threshold + + - name: is_optimistic#bellatrix sources: [] spec: | @@ -5820,16 +6558,6 @@ return False -- name: is_parent_block_full#gloas - sources: - - file: packages/state-transition/src/util/gloas.ts - search: export function isParentBlockFull( - spec: | - - def is_parent_block_full(state: BeaconState) -> bool: - return state.latest_execution_payload_bid.block_hash == state.latest_block_hash - - - name: is_parent_node_full#gloas sources: [] spec: | @@ -5907,7 +6635,7 @@ - name: is_payload_data_available#gloas sources: [] spec: | - + def is_payload_data_available(store: Store, root: Root) -> bool: """ Return whether the blob data for the beacon block with root ``root`` @@ -5918,7 +6646,7 @@ # If the payload is not locally available, the blob data # is not considered available regardless of the PTC vote - if root not in store.payload_states: + if not is_payload_verified(store, root): return False return sum(store.payload_data_availability_vote[root]) > DATA_AVAILABILITY_TIMELY_THRESHOLD @@ -5927,7 +6655,7 @@ - name: is_payload_inclusion_list_satisfied#heze sources: [] spec: | - + def is_payload_inclusion_list_satisfied(store: Store, root: Root) -> bool: """ Return whether the execution payload for the beacon block with root ``root`` @@ -5938,7 +6666,7 @@ # If the payload is not locally available, the payload # is not considered to satisfy the inclusion list constraints - if root not in store.payload_states: + if not is_payload_verified(store, root): return False return store.payload_inclusion_list_satisfaction[root] @@ -5947,7 +6675,7 @@ - name: is_payload_timely#gloas sources: [] spec: | - + def is_payload_timely(store: Store, root: Root) -> bool: """ Return whether the execution payload for the beacon block with root ``root`` @@ -5958,12 +6686,25 @@ # If the payload is not locally available, the payload # is not considered available regardless of the PTC vote - if root not in store.payload_states: + if not is_payload_verified(store, root): return False return sum(store.payload_timeliness_vote[root]) > PAYLOAD_TIMELY_THRESHOLD +- name: is_payload_verified#gloas + sources: [] + spec: | + + def is_payload_verified(store: Store, root: Root) -> bool: + """ + Return whether the execution payload envelope for the beacon block with + root ``root`` has been locally delivered and verified via + ``on_execution_payload_envelope``. + """ + return root in store.payloads + + - name: is_pending_validator#gloas sources: - file: packages/state-transition/src/block/processDepositRequest.ts @@ -6017,12 +6758,11 @@ - file: packages/fork-choice/src/forkChoice/forkChoice.ts search: "* https://github.com/ethereum/consensus-specs/blob/v1.5.0/specs/phase0/fork-choice.md#is_proposing_on_time" spec: | - + def is_proposing_on_time(store: Store) -> bool: seconds_since_genesis = store.time - store.genesis_time time_into_slot_ms = seconds_to_milliseconds(seconds_since_genesis) % SLOT_DURATION_MS - epoch = get_current_store_epoch(store) - proposer_reorg_cutoff_ms = get_proposer_reorg_cutoff_ms(epoch) + proposer_reorg_cutoff_ms = get_proposer_reorg_cutoff_ms() return time_into_slot_ms <= proposer_reorg_cutoff_ms @@ -6070,10 +6810,21 @@ ) +- name: is_start_slot_at_epoch#phase0 + sources: [] + spec: | + + def is_start_slot_at_epoch(slot: Slot) -> bool: + """ + Return ``True`` if ``slot`` is the start slot of an epoch. + """ + return compute_slots_since_epoch_start(slot) == 0 + + - name: is_supporting_vote#gloas sources: [] spec: | - + def is_supporting_vote(store: Store, node: ForkChoiceNode, message: LatestMessage) -> bool: """ Returns whether the vote ``message`` supports the chain containing the @@ -6083,7 +6834,8 @@ if node.root == message.root: if node.payload_status == PAYLOAD_STATUS_PENDING: return True - if message.slot <= block.slot: + assert message.slot >= block.slot + if message.slot == block.slot: return False if message.payload_present: return node.payload_status == PAYLOAD_STATUS_FULL @@ -6851,7 +7603,7 @@ - name: on_block#gloas sources: [] spec: | - + def on_block(store: Store, signed_block: SignedBeaconBlock) -> None: """ Run ``on_block`` upon receiving a new block. @@ -6860,17 +7612,10 @@ # Parent block must be known assert block.parent_root in store.block_states - # Check if this blocks builds on empty or full parent block - parent_block = store.blocks[block.parent_root] - bid = block.body.signed_execution_payload_bid.message - parent_bid = parent_block.body.signed_execution_payload_bid.message - # Make a copy of the state to avoid mutability issues + # If this block builds on the parent's full payload, that payload must + # have been verified by on_execution_payload_envelope if is_parent_node_full(store, block): - assert block.parent_root in store.payload_states - state = copy(store.payload_states[block.parent_root]) - else: - assert bid.parent_block_hash == parent_bid.parent_block_hash - state = copy(store.block_states[block.parent_root]) + assert is_payload_verified(store, block.parent_root) # Blocks cannot be in the future. If they are, their consideration must be delayed until they are in the past. current_slot = get_current_slot(store) @@ -6887,6 +7632,9 @@ ) assert store.finalized_checkpoint.root == finalized_checkpoint_block + # Make a copy of the state to avoid mutability issues + state = copy(store.block_states[block.parent_root]) + # Check the block is valid and compute the post-state block_root = hash_tree_root(block) state_transition(state, signed_block, True) @@ -6912,13 +7660,15 @@ compute_pulled_up_tip(store, block_root) -- name: on_execution_payload#gloas +- name: on_execution_payload_envelope#gloas sources: [] spec: | - - def on_execution_payload(store: Store, signed_envelope: SignedExecutionPayloadEnvelope) -> None: + + def on_execution_payload_envelope( + store: Store, signed_envelope: SignedExecutionPayloadEnvelope + ) -> None: """ - Run ``on_execution_payload`` upon receiving a new execution payload. + Run ``on_execution_payload_envelope`` upon receiving a new execution payload envelope. """ envelope = signed_envelope.message # The corresponding beacon block root needs to be known @@ -6928,23 +7678,24 @@ # If not, this payload MAY be queued and subsequently considered when blob data becomes available assert is_data_available(envelope.beacon_block_root) - # Make a copy of the state to avoid mutability issues - state = copy(store.block_states[envelope.beacon_block_root]) + state = store.block_states[envelope.beacon_block_root] - # Process the execution payload - process_execution_payload(state, signed_envelope, EXECUTION_ENGINE) + # Verify the execution payload envelope + verify_execution_payload_envelope(state, signed_envelope, EXECUTION_ENGINE) - # Add new state for this payload to the store - store.payload_states[envelope.beacon_block_root] = state + # Add execution payload envelope to the store + store.payloads[envelope.beacon_block_root] = envelope -- name: on_execution_payload#heze +- name: on_execution_payload_envelope#heze sources: [] spec: | - - def on_execution_payload(store: Store, signed_envelope: SignedExecutionPayloadEnvelope) -> None: + + def on_execution_payload_envelope( + store: Store, signed_envelope: SignedExecutionPayloadEnvelope + ) -> None: """ - Run ``on_execution_payload`` upon receiving a new execution payload. + Run ``on_execution_payload_envelope`` upon receiving a new execution payload envelope. """ envelope = signed_envelope.message # The corresponding beacon block root needs to be known @@ -6954,11 +7705,10 @@ # If not, this payload MAY be queued and subsequently considered when blob data becomes available assert is_data_available(envelope.beacon_block_root) - # Make a copy of the state to avoid mutability issues - state = copy(store.block_states[envelope.beacon_block_root]) + state = store.block_states[envelope.beacon_block_root] - # Process the execution payload - process_execution_payload(state, signed_envelope, EXECUTION_ENGINE) + # Verify the execution payload envelope + verify_execution_payload_envelope(state, signed_envelope, EXECUTION_ENGINE) # [New in Heze:EIP7805] # Check if this payload satisfies the inclusion list constraints @@ -6967,14 +7717,23 @@ store, state, envelope.beacon_block_root, envelope.payload, EXECUTION_ENGINE ) - # Add new state for this payload to the store - store.payload_states[envelope.beacon_block_root] = state + # Add execution payload envelope to the store + store.payloads[envelope.beacon_block_root] = envelope + + +- name: on_fast_confirmation#phase0 + sources: [] + spec: | + + def on_fast_confirmation(fcr_store: FastConfirmationStore) -> None: + update_fast_confirmation_variables(fcr_store) + fcr_store.confirmed_root = get_latest_confirmed(fcr_store) - name: on_inclusion_list#heze sources: [] spec: | - + def on_inclusion_list(store: Store, signed_inclusion_list: SignedInclusionList) -> None: """ Run ``on_inclusion_list`` upon receiving a new inclusion list. @@ -6983,8 +7742,7 @@ seconds_since_genesis = store.time - store.genesis_time time_into_slot_ms = seconds_to_milliseconds(seconds_since_genesis) % SLOT_DURATION_MS - epoch = get_current_store_epoch(store) - view_freeze_cutoff_ms = get_view_freeze_cutoff_ms(epoch) + view_freeze_cutoff_ms = get_view_freeze_cutoff_ms() is_before_view_freeze_cutoff = time_into_slot_ms < view_freeze_cutoff_ms process_inclusion_list(get_inclusion_list_store(), inclusion_list, is_before_view_freeze_cutoff) @@ -7285,25 +8043,45 @@ - name: prepare_execution_payload#gloas sources: [] spec: | - + def prepare_execution_payload( + # [New in Gloas:EIP7732] + store: Store, state: BeaconState, safe_block_hash: Hash32, finalized_block_hash: Hash32, suggested_fee_recipient: ExecutionAddress, execution_engine: ExecutionEngine, ) -> Optional[PayloadId]: + # [New in Gloas:EIP7732] + parent_bid = state.latest_execution_payload_bid + parent_root = hash_tree_root(state.latest_block_header) + if should_extend_payload(store, parent_root): + envelope = store.payloads[parent_root] + # Make a copy of the state to avoid mutability issues + state = copy(state) + # Apply parent payload before computing withdrawals + apply_parent_execution_payload(state, parent_bid, envelope.execution_requests) + withdrawals = get_expected_withdrawals(state).withdrawals + head_block_hash = parent_bid.block_hash + else: + withdrawals = state.payload_expected_withdrawals + head_block_hash = parent_bid.parent_block_hash + # Set the forkchoice head and initiate the payload build process payload_attributes = PayloadAttributes( timestamp=compute_time_at_slot(state, state.slot), prev_randao=get_randao_mix(state, get_current_epoch(state)), suggested_fee_recipient=suggested_fee_recipient, - withdrawals=get_expected_withdrawals(state).withdrawals, + # [Modified in Gloas:EIP7732] + withdrawals=withdrawals, parent_beacon_block_root=hash_tree_root(state.latest_block_header), + # [New in Gloas:EIP7843] + slot_number=state.slot, ) return execution_engine.notify_forkchoice_updated( # [Modified in Gloas:EIP7732] - head_block_hash=state.latest_block_hash, + head_block_hash=head_block_hash, safe_block_hash=safe_block_hash, finalized_block_hash=finalized_block_hash, payload_attributes=payload_attributes, @@ -7313,28 +8091,44 @@ - name: prepare_execution_payload#heze sources: [] spec: | - + def prepare_execution_payload( + store: Store, state: BeaconState, safe_block_hash: Hash32, finalized_block_hash: Hash32, suggested_fee_recipient: ExecutionAddress, execution_engine: ExecutionEngine, ) -> Optional[PayloadId]: + parent_bid = state.latest_execution_payload_bid + parent_root = hash_tree_root(state.latest_block_header) + if should_extend_payload(store, parent_root): + envelope = store.payloads[parent_root] + # Make a copy of the state to avoid mutability issues + state = copy(state) + # Apply parent payload before computing withdrawals + apply_parent_execution_payload(state, parent_bid, envelope.execution_requests) + withdrawals = get_expected_withdrawals(state).withdrawals + head_block_hash = parent_bid.block_hash + else: + withdrawals = state.payload_expected_withdrawals + head_block_hash = parent_bid.parent_block_hash + # Set the forkchoice head and initiate the payload build process payload_attributes = PayloadAttributes( timestamp=compute_time_at_slot(state, state.slot), prev_randao=get_randao_mix(state, get_current_epoch(state)), suggested_fee_recipient=suggested_fee_recipient, - withdrawals=get_expected_withdrawals(state).withdrawals, + withdrawals=withdrawals, parent_beacon_block_root=hash_tree_root(state.latest_block_header), + slot_number=state.slot, # [New in Heze:EIP7805] inclusion_list_transactions=get_inclusion_list_transactions( get_inclusion_list_store(), state, Slot(state.slot - 1) ), ) return execution_engine.notify_forkchoice_updated( - head_block_hash=state.latest_block_hash, + head_block_hash=head_block_hash, safe_block_hash=safe_block_hash, finalized_block_hash=finalized_block_hash, payload_attributes=payload_attributes, @@ -7729,8 +8523,10 @@ - name: process_block#gloas sources: [] spec: | - + def process_block(state: BeaconState, block: BeaconBlock) -> None: + # [New in Gloas:EIP7732] + process_parent_execution_payload(state, block) process_block_header(state, block) # [Modified in Gloas:EIP7732] process_withdrawals(state) @@ -8102,7 +8898,7 @@ - file: packages/state-transition/src/epoch/index.ts search: export function processEpoch( spec: | - + def process_epoch(state: BeaconState) -> None: # [Modified in Altair] process_justification_and_finalization(state) @@ -8118,6 +8914,8 @@ process_slashings_reset(state) process_randao_mixes_reset(state) process_historical_roots_update(state) + # [Modified in Altair] + # Removed `process_participation_record_updates` # [New in Altair] process_participation_flag_updates(state) # [New in Altair] @@ -8129,7 +8927,7 @@ - file: packages/state-transition/src/epoch/index.ts search: export function processEpoch( spec: | - + def process_epoch(state: BeaconState) -> None: process_justification_and_finalization(state) process_inactivity_updates(state) @@ -8140,7 +8938,9 @@ process_effective_balance_updates(state) process_slashings_reset(state) process_randao_mixes_reset(state) - # [Modified in Capella] + # [Modified in Altair] + # Removed `process_historical_roots_update` + # [New in Capella] process_historical_summaries_update(state) process_participation_flag_updates(state) process_sync_committee_updates(state) @@ -8522,89 +9322,60 @@ - name: process_execution_payload#gloas sources: [] spec: | - + def process_execution_payload( - state: BeaconState, - # [Modified in Gloas:EIP7732] - # Removed `body` - # [New in Gloas:EIP7732] - signed_envelope: SignedExecutionPayloadEnvelope, - execution_engine: ExecutionEngine, - # [New in Gloas:EIP7732] - verify: bool = True, + state: BeaconState, body: BeaconBlockBody, execution_engine: ExecutionEngine ) -> None: - envelope = signed_envelope.message - payload = envelope.payload - - # Verify signature - if verify: - assert verify_execution_payload_envelope_signature(state, signed_envelope) - - # Cache latest block header state root - previous_state_root = hash_tree_root(state) - if state.latest_block_header.state_root == Root(): - state.latest_block_header.state_root = previous_state_root - - # Verify consistency with the beacon block - assert envelope.beacon_block_root == hash_tree_root(state.latest_block_header) - assert envelope.slot == state.slot - - # Verify consistency with the committed bid - committed_bid = state.latest_execution_payload_bid - assert envelope.builder_index == committed_bid.builder_index - assert committed_bid.prev_randao == payload.prev_randao - - # Verify consistency with expected withdrawals - assert hash_tree_root(payload.withdrawals) == hash_tree_root(state.payload_expected_withdrawals) + payload = body.execution_payload - # Verify the gas_limit - assert committed_bid.gas_limit == payload.gas_limit - # Verify the block hash - assert committed_bid.block_hash == payload.block_hash - # Verify consistency of the parent hash with respect to the previous execution payload - assert payload.parent_hash == state.latest_block_hash + # Verify consistency of the parent hash with respect to the previous execution payload header + assert payload.parent_hash == state.latest_execution_payload_header.block_hash + # Verify prev_randao + assert payload.prev_randao == get_randao_mix(state, get_current_epoch(state)) # Verify timestamp assert payload.timestamp == compute_time_at_slot(state, state.slot) - # Verify the execution payload is valid + # [Modified in Fulu:EIP7892] + # Verify commitments are under limit + assert ( + len(body.blob_kzg_commitments) + <= get_blob_parameters(get_current_epoch(state)).max_blobs_per_block + ) + + # Compute list of versioned hashes versioned_hashes = [ - kzg_commitment_to_versioned_hash(commitment) - # [Modified in Gloas:EIP7732] - for commitment in committed_bid.blob_kzg_commitments + kzg_commitment_to_versioned_hash(commitment) for commitment in body.blob_kzg_commitments ] - requests = envelope.execution_requests + + # Verify the execution payload is valid assert execution_engine.verify_and_notify_new_payload( NewPayloadRequest( execution_payload=payload, versioned_hashes=versioned_hashes, parent_beacon_block_root=state.latest_block_header.parent_root, - execution_requests=requests, + execution_requests=body.execution_requests, ) ) - def for_ops(operations: Sequence[Any], fn: Callable[[BeaconState, Any], None]) -> None: - for operation in operations: - fn(state, operation) - - for_ops(requests.deposits, process_deposit_request) - for_ops(requests.withdrawals, process_withdrawal_request) - for_ops(requests.consolidations, process_consolidation_request) - - # Queue the builder payment - payment = state.builder_pending_payments[SLOTS_PER_EPOCH + state.slot % SLOTS_PER_EPOCH] - amount = payment.withdrawal.amount - if amount > 0: - state.builder_pending_withdrawals.append(payment.withdrawal) - state.builder_pending_payments[SLOTS_PER_EPOCH + state.slot % SLOTS_PER_EPOCH] = ( - BuilderPendingPayment() + # Cache execution payload header + state.latest_execution_payload_header = ExecutionPayloadHeader( + parent_hash=payload.parent_hash, + fee_recipient=payload.fee_recipient, + state_root=payload.state_root, + receipts_root=payload.receipts_root, + logs_bloom=payload.logs_bloom, + prev_randao=payload.prev_randao, + block_number=payload.block_number, + gas_limit=payload.gas_limit, + gas_used=payload.gas_used, + timestamp=payload.timestamp, + extra_data=payload.extra_data, + base_fee_per_gas=payload.base_fee_per_gas, + block_hash=payload.block_hash, + transactions_root=hash_tree_root(payload.transactions), + withdrawals_root=hash_tree_root(payload.withdrawals), + blob_gas_used=payload.blob_gas_used, + excess_blob_gas=payload.excess_blob_gas, ) - - # Cache the execution payload hash - state.execution_payload_availability[state.slot % SLOTS_PER_HISTORICAL_ROOT] = 0b1 - state.latest_block_hash = payload.block_hash - - # Verify the state root - if verify: - assert envelope.state_root == hash_tree_root(state) - name: process_execution_payload_bid#gloas @@ -9040,6 +9811,27 @@ for_ops(body.payload_attestations, process_payload_attestation) +- name: process_parent_execution_payload#gloas + sources: [] + spec: | + + def process_parent_execution_payload(state: BeaconState, block: BeaconBlock) -> None: + bid = block.body.signed_execution_payload_bid.message + parent_bid = state.latest_execution_payload_bid + requests = block.body.parent_execution_requests + + is_genesis_block = parent_bid.block_hash == Hash32() + is_parent_block_empty = bid.parent_block_hash != parent_bid.block_hash + if is_genesis_block or is_parent_block_empty: + # Parent was EMPTY -- no execution requests expected + assert requests == ExecutionRequests() + return + + # Parent was FULL -- verify the bid commitment and apply the payload + assert hash_tree_root(requests) == parent_bid.execution_requests_root + apply_parent_execution_payload(state, parent_bid, requests) + + - name: process_participation_flag_updates#altair sources: - file: packages/state-transition/src/epoch/processParticipationFlagUpdates.ts @@ -9986,7 +10778,7 @@ - name: process_withdrawals#gloas sources: [] spec: | - + def process_withdrawals( state: BeaconState, # [Modified in Gloas:EIP7732] @@ -9994,7 +10786,9 @@ ) -> None: # [New in Gloas:EIP7732] # Return early if the parent block is empty - if not is_parent_block_full(state): + is_genesis_block = state.latest_block_hash == Hash32() + is_parent_block_empty = state.latest_block_hash != state.latest_execution_payload_bid.block_hash + if is_genesis_block or is_parent_block_empty: return # Get expected withdrawals @@ -10043,13 +10837,12 @@ - name: record_block_timeliness#phase0 sources: [] spec: | - + def record_block_timeliness(store: Store, root: Root) -> None: block = store.blocks[root] seconds_since_genesis = store.time - store.genesis_time time_into_slot_ms = seconds_to_milliseconds(seconds_since_genesis) % SLOT_DURATION_MS - epoch = get_current_store_epoch(store) - attestation_threshold_ms = get_attestation_due_ms(epoch) + attestation_threshold_ms = get_attestation_due_ms() is_before_attesting_interval = time_into_slot_ms < attestation_threshold_ms is_timely = get_current_slot(store) == block.slot and is_before_attesting_interval store.block_timeliness[root] = is_timely @@ -10058,16 +10851,15 @@ - name: record_block_timeliness#gloas sources: [] spec: | - + def record_block_timeliness(store: Store, root: Root) -> None: block = store.blocks[root] seconds_since_genesis = store.time - store.genesis_time time_into_slot_ms = seconds_to_milliseconds(seconds_since_genesis) % SLOT_DURATION_MS - epoch = get_current_store_epoch(store) - attestation_threshold_ms = get_attestation_due_ms(epoch) + attestation_threshold_ms = get_attestation_due_ms() # [New in Gloas:EIP7732] is_current_slot = get_current_slot(store) == block.slot - ptc_threshold_ms = get_payload_attestation_due_ms(epoch) + ptc_threshold_ms = get_payload_attestation_due_ms() # [Modified in Gloas:EIP7732] store.block_timeliness[root] = [ is_current_slot and time_into_slot_ms < threshold @@ -10127,17 +10919,6 @@ return matrix -- name: saturating_sub#phase0 - sources: [] - spec: | - - def saturating_sub(a: int, b: int) -> int: - """ - Computes a - b, saturating at numeric bounds. - """ - return a - b if a > b else 0 - - - name: seconds_to_milliseconds#phase0 sources: [] spec: | @@ -10163,6 +10944,18 @@ list[index] = value +- name: settle_builder_payment#gloas + sources: [] + spec: | + + def settle_builder_payment(state: BeaconState, payment_index: uint64) -> None: + assert payment_index < len(state.builder_pending_payments) + payment = state.builder_pending_payments[payment_index] + if payment.withdrawal.amount > 0: + state.builder_pending_withdrawals.append(payment.withdrawal) + state.builder_pending_payments[payment_index] = BuilderPendingPayment() + + - name: should_apply_proposer_boost#gloas sources: [] spec: | @@ -10203,8 +10996,10 @@ - name: should_extend_payload#gloas sources: [] spec: | - + def should_extend_payload(store: Store, root: Root) -> bool: + if not is_payload_verified(store, root): + return False proposer_root = store.proposer_boost_root return ( (is_payload_timely(store, root) and is_payload_data_available(store, root)) @@ -10551,6 +11346,32 @@ store.finalized_checkpoint = finalized_checkpoint +- name: update_fast_confirmation_variables#phase0 + sources: [] + spec: | + + def update_fast_confirmation_variables(fcr_store: FastConfirmationStore) -> None: + # Update prev and curr slot head + store = fcr_store.store + fcr_store.previous_slot_head = fcr_store.current_slot_head + fcr_store.current_slot_head = get_head(store) + + # Update greatest unrealized justified checkpoint at the last slot of an epoch + if is_start_slot_at_epoch(Slot(get_current_slot(store) + 1)): + fcr_store.previous_epoch_greatest_unrealized_checkpoint = ( + store.unrealized_justified_checkpoint + ) + + # Update observed justified checkpoints at the start of an epoch + if is_start_slot_at_epoch(get_current_slot(store)): + fcr_store.previous_epoch_observed_justified_checkpoint = ( + fcr_store.current_epoch_observed_justified_checkpoint + ) + fcr_store.current_epoch_observed_justified_checkpoint = ( + fcr_store.previous_epoch_greatest_unrealized_checkpoint + ) + + - name: update_latest_messages#phase0 sources: - file: packages/fork-choice/src/forkChoice/forkChoice.ts @@ -11462,7 +12283,7 @@ - file: packages/state-transition/src/slot/upgradeStateToGloas.ts search: export function upgradeStateToGloas( spec: | - + def upgrade_to_gloas(pre: fulu.BeaconState) -> BeaconState: epoch = fulu.get_current_epoch(pre) @@ -11499,9 +12320,7 @@ # [Modified in Gloas:EIP7732] # Removed `latest_execution_payload_header` # [New in Gloas:EIP7732] - latest_execution_payload_bid=ExecutionPayloadBid( - block_hash=pre.latest_execution_payload_header.block_hash, - ), + latest_block_hash=pre.latest_execution_payload_header.block_hash, next_withdrawal_index=pre.next_withdrawal_index, next_withdrawal_validator_index=pre.next_withdrawal_validator_index, historical_summaries=pre.historical_summaries, @@ -11526,7 +12345,10 @@ # [New in Gloas:EIP7732] builder_pending_withdrawals=[], # [New in Gloas:EIP7732] - latest_block_hash=pre.latest_execution_payload_header.block_hash, + latest_execution_payload_bid=ExecutionPayloadBid( + block_hash=pre.latest_execution_payload_header.block_hash, + execution_requests_root=hash_tree_root(ExecutionRequests()), + ), # [New in Gloas:EIP7732] payload_expected_withdrawals=[], # [New in Gloas:EIP7732] @@ -11542,7 +12364,7 @@ - name: upgrade_to_heze#heze sources: [] spec: | - + def upgrade_to_heze(pre: gloas.BeaconState) -> BeaconState: epoch = gloas.get_current_epoch(pre) latest_execution_payload_bid = ExecutionPayloadBid( @@ -11557,6 +12379,7 @@ value=pre.latest_execution_payload_bid.value, execution_payment=pre.latest_execution_payload_bid.execution_payment, blob_kzg_commitments=pre.latest_execution_payload_bid.blob_kzg_commitments, + execution_requests_root=pre.latest_execution_payload_bid.execution_requests_root, # [New in Heze:EIP7805] inclusion_list_bits=Bitvector[INCLUSION_LIST_COMMITTEE_SIZE](), ) @@ -11591,8 +12414,7 @@ inactivity_scores=pre.inactivity_scores, current_sync_committee=pre.current_sync_committee, next_sync_committee=pre.next_sync_committee, - # [Modified in Heze:EIP7805] - latest_execution_payload_bid=latest_execution_payload_bid, + latest_block_hash=pre.latest_block_hash, next_withdrawal_index=pre.next_withdrawal_index, next_withdrawal_validator_index=pre.next_withdrawal_validator_index, historical_summaries=pre.historical_summaries, @@ -11611,7 +12433,8 @@ execution_payload_availability=pre.execution_payload_availability, builder_pending_payments=pre.builder_pending_payments, builder_pending_withdrawals=pre.builder_pending_withdrawals, - latest_block_hash=pre.latest_block_hash, + # [Modified in Heze:EIP7805] + latest_execution_payload_bid=latest_execution_payload_bid, payload_expected_withdrawals=pre.payload_expected_withdrawals, ptc_window=pre.ptc_window, ) @@ -11802,7 +12625,7 @@ - name: validate_on_attestation#gloas sources: [] spec: | - + def validate_on_attestation(store: Store, attestation: Attestation, is_from_block: bool) -> None: target = attestation.data.target @@ -11833,7 +12656,7 @@ # [New in Gloas:EIP7732] # If attesting for a full node, the payload must be known if attestation.data.index == 1: - assert attestation.data.beacon_block_root in store.payload_states + assert is_payload_verified(store, attestation.data.beacon_block_root) # LMD vote must be consistent with FFG vote target assert target.root == get_checkpoint_block( @@ -12042,6 +12865,52 @@ return bls.Verify(builder.pubkey, signing_root, signed_bid.signature) +- name: verify_execution_payload_envelope#gloas + sources: [] + spec: | + + def verify_execution_payload_envelope( + state: BeaconState, + signed_envelope: SignedExecutionPayloadEnvelope, + execution_engine: ExecutionEngine, + ) -> None: + envelope = signed_envelope.message + payload = envelope.payload + + # Verify signature + assert verify_execution_payload_envelope_signature(state, signed_envelope) + + # Verify consistency with the beacon block + header = copy(state.latest_block_header) + header.state_root = hash_tree_root(state) + assert envelope.beacon_block_root == hash_tree_root(header) + + # Verify consistency with the committed bid + bid = state.latest_execution_payload_bid + assert envelope.builder_index == bid.builder_index + assert payload.prev_randao == bid.prev_randao + assert payload.gas_limit == bid.gas_limit + assert payload.block_hash == bid.block_hash + assert hash_tree_root(envelope.execution_requests) == bid.execution_requests_root + + # Verify the execution payload is valid + assert payload.slot_number == state.slot + assert payload.parent_hash == state.latest_block_hash + assert payload.timestamp == compute_time_at_slot(state, state.slot) + assert hash_tree_root(payload.withdrawals) == hash_tree_root(state.payload_expected_withdrawals) + assert execution_engine.verify_and_notify_new_payload( + NewPayloadRequest( + execution_payload=payload, + versioned_hashes=[ + kzg_commitment_to_versioned_hash(commitment) + for commitment in bid.blob_kzg_commitments + ], + parent_beacon_block_root=state.latest_block_header.parent_root, + execution_requests=envelope.execution_requests, + ) + ) + + - name: verify_execution_payload_envelope_signature#gloas sources: - file: packages/state-transition/src/block/processExecutionPayloadEnvelope.ts @@ -12123,6 +12992,39 @@ state.finalized_checkpoint = old_current_justified_checkpoint +- name: will_current_target_be_justified#phase0 + sources: [] + spec: | + + def will_current_target_be_justified(store: Store) -> bool: + """ + Return ``True`` if and only if the current target will eventually be justified. + """ + state = get_pulled_up_head_state(store) + total_active_balance = get_total_active_balance(state) + honest_ffg_support = compute_honest_ffg_support_for_current_target(store) + return 3 * honest_ffg_support >= 2 * total_active_balance + + +- name: will_no_conflicting_checkpoint_be_justified#phase0 + sources: [] + spec: | + + def will_no_conflicting_checkpoint_be_justified(store: Store) -> bool: + """ + Return ``True`` if and only if no checkpoint conflicting with the current target can ever be justified. + """ + + # If the target is unrealized justified then no conflicting checkpoint can be justified + if get_current_target(store) == store.unrealized_justified_checkpoint: + return True + + state = get_pulled_up_head_state(store) + total_active_balance = get_total_active_balance(state) + honest_ffg_support = compute_honest_ffg_support_for_current_target(store) + return 3 * honest_ffg_support > 1 * total_active_balance + + - name: xor#phase0 sources: - file: packages/utils/src/bytes/browser.ts diff --git a/specrefs/types.yml b/specrefs/types.yml index c5d28cf5881d..b0f7623bad52 100644 --- a/specrefs/types.yml +++ b/specrefs/types.yml @@ -34,6 +34,13 @@ BlobIndex = uint64 +- name: BlockAccessList#gloas + sources: [] + spec: | + + BlockAccessList = ByteList[MAX_BYTES_PER_TRANSACTION] + + - name: BuilderIndex#gloas sources: - file: packages/types/src/primitive/sszTypes.ts From e49b6991ad85059b1805ae26e4291a8993b682f7 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:40:15 -0700 Subject: [PATCH 14/67] lint --- .../src/block/processExecutionPayloadEnvelope.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts index b410c0a87fa3..30f3788358c5 100644 --- a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts +++ b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts @@ -24,7 +24,7 @@ export function processExecutionPayloadEnvelope( signedEnvelope: gloas.SignedExecutionPayloadEnvelope, opts?: ProcessExecutionPayloadEnvelopeOpts ): CachedBeaconStateGloas { - const {verifySignature = true, verifyStateRoot = true} = opts ?? {}; + const {verifySignature = true} = opts ?? {}; const envelope = signedEnvelope.message; const payload = envelope.payload; const fork = state.config.getForkSeq(payload.slotNumber); @@ -74,7 +74,6 @@ export function processExecutionPayloadEnvelope( postState.commit(); - return postState; } From 813cb524643dfa4b6eed6a6351a9230dd0dfa8e6 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:53:28 -0700 Subject: [PATCH 15/67] ethspecify --- specrefs/.ethspecify.yml | 55 ++++++++++++++++++++++++++++++++++++++++ specrefs/containers.yml | 4 ++- specrefs/dataclasses.yml | 4 ++- specrefs/types.yml | 4 ++- 4 files changed, 64 insertions(+), 3 deletions(-) diff --git a/specrefs/.ethspecify.yml b/specrefs/.ethspecify.yml index e40c5a2a1e9b..4f0f00f8b4bd 100644 --- a/specrefs/.ethspecify.yml +++ b/specrefs/.ethspecify.yml @@ -51,6 +51,9 @@ exceptions: # heze (not implemented) - DOMAIN_INCLUSION_LIST_COMMITTEE#heze + # phase0 fast confirmation / not implemented + - COMMITTEE_WEIGHT_ESTIMATION_ADJUSTMENT_FACTOR#phase0 + containers: # gloas - ForkChoiceNode#gloas @@ -87,6 +90,9 @@ exceptions: - LatestMessage#gloas - Store#gloas + # phase0 fast confirmation / not implemented + - FastConfirmationStore#phase0 + # heze (not implemented) - GetInclusionListResponse#heze - InclusionListStore#heze @@ -368,7 +374,56 @@ exceptions: - should_extend_payload#heze - upgrade_to_heze#heze + # phase0 fast confirmation / not implemented + - adjust_committee_weight_estimate_to_ensure_safety#phase0 + - compute_adversarial_weight#phase0 + - compute_empty_slot_support_discount#phase0 + - compute_honest_ffg_support_for_current_target#phase0 + - compute_safety_threshold#phase0 + - compute_shuffled_permutation#phase0 + - estimate_committee_weight_between_slots#phase0 + - find_latest_confirmed_descendant#phase0 + - get_adversarial_weight#phase0 + - get_ancestor_roots#phase0 + - get_block_epoch#phase0 + - get_block_slot#phase0 + - get_block_support_between_slots#phase0 + - get_checkpoint_for_block#phase0 + - get_current_balance_source#phase0 + - get_current_target#phase0 + - get_current_target_score#phase0 + - get_equivocation_score#phase0 + - get_fast_confirmation_store#phase0 + - get_latest_confirmed#phase0 + - get_latest_message_epoch#phase0 + - get_latest_message_epoch#gloas + - get_previous_balance_source#phase0 + - get_pulled_up_head_state#phase0 + - get_slot_committee#phase0 + - get_support_discount#phase0 + - is_ancestor#phase0 + - is_confirmed_chain_safe#phase0 + - is_full_validator_set_covered#phase0 + - is_one_confirmed#phase0 + - is_start_slot_at_epoch#phase0 + - on_fast_confirmation#phase0 + - update_fast_confirmation_variables#phase0 + - will_current_target_be_justified#phase0 + - will_no_conflicting_checkpoint_be_justified#phase0 + + # gloas / heze empty sources to skip + - apply_parent_execution_payload#gloas + - is_payload_verified#gloas + - on_execution_payload_envelope#gloas + - on_execution_payload_envelope#heze + - process_parent_execution_payload#gloas + - settle_builder_payment#gloas + - verify_execution_payload_envelope#gloas + configs: + # phase0 fast confirmation / not implemented + - CONFIRMATION_BYZANTINE_THRESHOLD#phase0 + # heze (not implemented) - HEZE_FORK_EPOCH#heze - HEZE_FORK_VERSION#heze diff --git a/specrefs/containers.yml b/specrefs/containers.yml index e674c487259b..7968f72e000b 100644 --- a/specrefs/containers.yml +++ b/specrefs/containers.yml @@ -987,7 +987,9 @@ - name: ExecutionPayload#gloas - sources: [] + sources: + - file: packages/types/src/gloas/sszTypes.ts + search: export const ExecutionPayload = spec: | class ExecutionPayload(Container): diff --git a/specrefs/dataclasses.yml b/specrefs/dataclasses.yml index 816b1612943c..5b95a996a98c 100644 --- a/specrefs/dataclasses.yml +++ b/specrefs/dataclasses.yml @@ -325,7 +325,9 @@ - name: PayloadAttributes#gloas - sources: [] + sources: + - file: packages/types/src/gloas/sszTypes.ts + search: export const PayloadAttributes = spec: | class PayloadAttributes(object): diff --git a/specrefs/types.yml b/specrefs/types.yml index b0f7623bad52..6cecebadd0cd 100644 --- a/specrefs/types.yml +++ b/specrefs/types.yml @@ -35,7 +35,9 @@ - name: BlockAccessList#gloas - sources: [] + sources: + - file: packages/types/src/gloas/sszTypes.ts + search: export const BlockAccessList = spec: | BlockAccessList = ByteList[MAX_BYTES_PER_TRANSACTION] From 845bec97bbc53929b90a85db096d91c89c5b7e64 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:20:01 -0700 Subject: [PATCH 16/67] fix unit test --- packages/api/test/unit/beacon/oapiSpec.test.ts | 2 ++ packages/api/test/unit/beacon/testData/events.ts | 1 + packages/beacon-node/src/util/sszBytes.ts | 8 ++++---- packages/beacon-node/test/unit/util/sszBytes.test.ts | 4 ++-- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/api/test/unit/beacon/oapiSpec.test.ts b/packages/api/test/unit/beacon/oapiSpec.test.ts index 19d1432aaf2c..f4aae5dd52af 100644 --- a/packages/api/test/unit/beacon/oapiSpec.test.ts +++ b/packages/api/test/unit/beacon/oapiSpec.test.ts @@ -62,6 +62,7 @@ const ignoredOperations = [ "getPtcDuties", "producePayloadAttestationData", "getExecutionPayloadBid", + "getSignedExecutionPayloadEnvelope", ]; const ignoredProperties: Record = { @@ -78,6 +79,7 @@ runTestCheckAgainstSpec(openApiJson, definitions, testDatas, ignoredOperations, const ignoredTopics: string[] = [ // TODO GLOAS: required by v5.0.0-alpha.1 "payload_attestation_message", + "execution_payload_bid", ]; // eventstream types are defined as comments in the description of "examples". diff --git a/packages/api/test/unit/beacon/testData/events.ts b/packages/api/test/unit/beacon/testData/events.ts index a6c8c06ad055..9eebe475e13c 100644 --- a/packages/api/test/unit/beacon/testData/events.ts +++ b/packages/api/test/unit/beacon/testData/events.ts @@ -308,6 +308,7 @@ export const eventTestData: EventData = { blob_kzg_commitments: [ "0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2", ], + execution_requests_root: "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2", }, signature: "0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505", diff --git a/packages/beacon-node/src/util/sszBytes.ts b/packages/beacon-node/src/util/sszBytes.ts index bb0d988a0ccb..fa9bf569577a 100644 --- a/packages/beacon-node/src/util/sszBytes.ts +++ b/packages/beacon-node/src/util/sszBytes.ts @@ -561,7 +561,7 @@ export function getBeaconBlockRootFromDataColumnSidecarSerialized(data: Uint8Arr * ├─ 8 bytes: builderIndex (offset 108-115) * ├─ 32 bytes: beaconBlockRoot (offset 116-147) * └─ variable: payload data (starts at envelope + 48) - * └─ ExecutionPayload fixed portion includes slotNumber at offset 528 + * └─ ExecutionPayload fixed portion includes slotNumber at offset 532 */ const SIGNED_EXECUTION_PAYLOAD_ENVELOPE_MESSAGE_OFFSET = 4; const SIGNED_EXECUTION_PAYLOAD_ENVELOPE_SIGNATURE_SIZE = 96; @@ -587,15 +587,15 @@ const EXECUTION_PAYLOAD_ENVELOPE_FIXED_SIZE = // parentHash(32) + feeRecipient(20) + stateRoot(32) + receiptsRoot(32) + logsBloom(256) + // prevRandao(32) + blockNumber(8) + gasLimit(8) + gasUsed(8) + timestamp(8) + // extraData_offset(4) + baseFeePerGas(32) + blockHash(32) + transactions_offset(4) + -// withdrawals_offset(4) + blobGasUsed(8) + excessBlobGas(8) = 528 -const SLOT_NUMBER_OFFSET_IN_EXECUTION_PAYLOAD = 528; +// withdrawals_offset(4) + blobGasUsed(8) + excessBlobGas(8) + blockAccessList_offset(4) = 532 +const SLOT_NUMBER_OFFSET_IN_EXECUTION_PAYLOAD = 532; // Payload data starts right after the envelope's fixed portion const ENVELOPE_START_IN_SIGNED = SIGNED_EXECUTION_PAYLOAD_ENVELOPE_MESSAGE_OFFSET + SIGNED_EXECUTION_PAYLOAD_ENVELOPE_SIGNATURE_SIZE; // 100 const SLOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE = - ENVELOPE_START_IN_SIGNED + EXECUTION_PAYLOAD_ENVELOPE_FIXED_SIZE + SLOT_NUMBER_OFFSET_IN_EXECUTION_PAYLOAD; // 100 + 48 + 528 = 676 + ENVELOPE_START_IN_SIGNED + EXECUTION_PAYLOAD_ENVELOPE_FIXED_SIZE + SLOT_NUMBER_OFFSET_IN_EXECUTION_PAYLOAD; // 100 + 48 + 532 = 680 export function getSlotFromExecutionPayloadEnvelopeSerialized(data: Uint8Array): Slot | null { if (data.length < SLOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE + SLOT_SIZE) { diff --git a/packages/beacon-node/test/unit/util/sszBytes.test.ts b/packages/beacon-node/test/unit/util/sszBytes.test.ts index 2fa572d9eca3..6ab13f8df70c 100644 --- a/packages/beacon-node/test/unit/util/sszBytes.test.ts +++ b/packages/beacon-node/test/unit/util/sszBytes.test.ts @@ -581,8 +581,8 @@ describe("SignedExecutionPayloadEnvelope SSZ serialized picking", () => { } it("getSlotFromExecutionPayloadEnvelopeSerialized - invalid data", () => { - // slotNumber is at offset 676 within the serialized payload, need at least 684 bytes - const invalidSizes = [0, 50, 100, 683]; + // slotNumber is at offset 680 within the serialized payload, need at least 688 bytes + const invalidSizes = [0, 50, 100, 687]; for (const size of invalidSizes) { expect(getSlotFromExecutionPayloadEnvelopeSerialized(Buffer.alloc(size))).toBeNull(); } From 1d15ece3b095437435670379e19b12c4f4d0d4d8 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:22:14 -0700 Subject: [PATCH 17/67] fix e2e --- packages/config/test/e2e/ensure-config-is-synced.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/config/test/e2e/ensure-config-is-synced.test.ts b/packages/config/test/e2e/ensure-config-is-synced.test.ts index a37e793f3c55..2c1604f8e268 100644 --- a/packages/config/test/e2e/ensure-config-is-synced.test.ts +++ b/packages/config/test/e2e/ensure-config-is-synced.test.ts @@ -28,6 +28,8 @@ const ignoredRemoteConfigFields: (keyof ChainConfig)[] = [ // Future spec params not yet in Lodestar "EPOCHS_PER_SHUFFLING_PHASE" as keyof ChainConfig, "PROPOSER_SELECTION_GAP" as keyof ChainConfig, + // FCR params - not yet implemented in Lodestar + "CONFIRMATION_BYZANTINE_THRESHOLD" as keyof ChainConfig, // Future forks not yet implemented in Lodestar "HEZE_FORK_VERSION" as keyof ChainConfig, "HEZE_FORK_EPOCH" as keyof ChainConfig, From c3d429dc3db1bb6189a4aedb7b011424923f47d8 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:07:02 -0700 Subject: [PATCH 18/67] address comment --- packages/beacon-node/test/spec/presets/fork_choice.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/test/spec/presets/fork_choice.test.ts b/packages/beacon-node/test/spec/presets/fork_choice.test.ts index 6988c9ba279c..c37c4aec1eef 100644 --- a/packages/beacon-node/test/spec/presets/fork_choice.test.ts +++ b/packages/beacon-node/test/spec/presets/fork_choice.test.ts @@ -393,7 +393,7 @@ const forkChoiceTest = beaconBlockRoot, blockHash, blockNumber, - "0x", + ZERO_HASH_HEX, ExecutionStatus.Valid ); if (!isValid) throw Error("Expect error since this is a negative test"); From afbb9c1aa8a34fdf908bb6571ea2ac7c580f49ff Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:51:41 -0700 Subject: [PATCH 19/67] feat: add processParentExecutionPayload and remove withdrawal early return New function implements deferred parent execution payload processing (consensus-specs#5094). Moves execution requests, builder payment, and availability updates from envelope processing to block processing. Removes the isParentBlockFull early return in processWithdrawals since processParentExecutionPayload now runs before withdrawals. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../block/processParentExecutionPayload.ts | 108 ++++++++++++++++++ .../src/block/processWithdrawals.ts | 13 +-- 2 files changed, 111 insertions(+), 10 deletions(-) create mode 100644 packages/state-transition/src/block/processParentExecutionPayload.ts diff --git a/packages/state-transition/src/block/processParentExecutionPayload.ts b/packages/state-transition/src/block/processParentExecutionPayload.ts new file mode 100644 index 000000000000..30149c8fec14 --- /dev/null +++ b/packages/state-transition/src/block/processParentExecutionPayload.ts @@ -0,0 +1,108 @@ +import {ForkPostGloas, SLOTS_PER_EPOCH, SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; +import {BeaconBlock, ssz} from "@lodestar/types"; +import {byteArrayEquals, toRootHex} from "@lodestar/utils"; +import {CachedBeaconStateGloas} from "../types.js"; +import {computeEpochAtSlot} from "../util/epoch.js"; +import {processConsolidationRequest} from "./processConsolidationRequest.js"; +import {getPendingValidatorPubkeys, processDepositRequest} from "./processDepositRequest.js"; +import {processWithdrawalRequest} from "./processWithdrawalRequest.js"; + +/** + * Process parent execution payload effects as first step of processBlock. + * + * Spec: consensus-specs#5094 + * https://github.com/ethereum/consensus-specs/blob/26ed32e/specs/gloas/beacon-chain.md + */ +export function processParentExecutionPayload( + state: CachedBeaconStateGloas, + block: BeaconBlock +): void { + const bid = block.body.signedExecutionPayloadBid.message; + const parentBid = state.latestExecutionPayloadBid; + const requests = block.body.parentExecutionRequests; + + // True if this block built on the parent's full payload + const isParentFull = byteArrayEquals(bid.parentBlockHash, parentBid.blockHash); + + if (!isParentFull) { + // Parent was EMPTY -- no execution requests expected + assertEmptyExecutionRequests(requests); + return; + } + + // Parent was FULL -- verify the bid commitment and apply the payload + const requestsRoot = ssz.electra.ExecutionRequests.hashTreeRoot(requests); + if (!byteArrayEquals(requestsRoot, parentBid.executionRequestsRoot)) { + throw new Error( + `Parent execution requests root mismatch actual=${toRootHex(requestsRoot)} expected=${toRootHex(parentBid.executionRequestsRoot)}` + ); + } + + applyParentExecutionPayload(state, parentBid, requests); +} + +/** + * Apply parent execution payload effects to state. + * + * Spec: apply_parent_execution_payload + */ +function applyParentExecutionPayload( + state: CachedBeaconStateGloas, + parentBid: {slot: number; blockHash: Uint8Array; builderIndex: number}, + requests: {deposits: unknown[]; withdrawals: unknown[]; consolidations: unknown[]} +): void { + const fork = state.config.getForkSeq(state.slot); + const parentSlot = parentBid.slot; + const parentEpoch = computeEpochAtSlot(parentSlot); + const currentEpoch = computeEpochAtSlot(state.slot); + + // Process execution requests from parent's payload + // Execution requests are processed at state.slot (child's slot), not parent's slot + if (requests.deposits.length > 0) { + const pendingValidatorPubkeys = getPendingValidatorPubkeys(state.config, state); + for (const deposit of requests.deposits) { + processDepositRequest(fork, state, deposit, pendingValidatorPubkeys); + } + } + + for (const withdrawal of requests.withdrawals) { + processWithdrawalRequest(fork, state, withdrawal); + } + + for (const consolidation of requests.consolidations) { + processConsolidationRequest(state, consolidation); + } + + // Queue the builder payment + let paymentIndex: number | null; + if (parentEpoch === currentEpoch) { + paymentIndex = SLOTS_PER_EPOCH + (parentSlot % SLOTS_PER_EPOCH); + } else if (parentEpoch === currentEpoch - 1) { + paymentIndex = parentSlot % SLOTS_PER_EPOCH; + } else { + // Parent is older than previous epoch — payment already settled/evicted + paymentIndex = null; + } + + if (paymentIndex !== null) { + const payment = state.builderPendingPayments.get(paymentIndex).clone(); + if (payment.withdrawal.amount > 0) { + state.builderPendingWithdrawals.push(payment.withdrawal); + } + state.builderPendingPayments.set(paymentIndex, ssz.gloas.BuilderPendingPayment.defaultViewDU()); + } + + // Update parent payload availability and latest block hash + state.executionPayloadAvailability.set(parentSlot % SLOTS_PER_HISTORICAL_ROOT, true); + state.latestBlockHash = parentBid.blockHash; +} + +function assertEmptyExecutionRequests(requests: { + deposits: unknown[]; + withdrawals: unknown[]; + consolidations: unknown[]; +}): void { + if (requests.deposits.length !== 0 || requests.withdrawals.length !== 0 || requests.consolidations.length !== 0) { + throw new Error("Parent execution requests must be empty when parent block is EMPTY"); + } +} diff --git a/packages/state-transition/src/block/processWithdrawals.ts b/packages/state-transition/src/block/processWithdrawals.ts index e2b54f5e183a..ba731b51d141 100644 --- a/packages/state-transition/src/block/processWithdrawals.ts +++ b/packages/state-transition/src/block/processWithdrawals.ts @@ -11,12 +11,7 @@ import { import {BuilderIndex, ValidatorIndex, capella, ssz} from "@lodestar/types"; import {byteArrayEquals, toRootHex} from "@lodestar/utils"; import {CachedBeaconStateCapella, CachedBeaconStateElectra, CachedBeaconStateGloas} from "../types.js"; -import { - convertBuilderIndexToValidatorIndex, - convertValidatorIndexToBuilderIndex, - isBuilderIndex, - isParentBlockFull, -} from "../util/gloas.js"; +import {convertBuilderIndexToValidatorIndex, convertValidatorIndexToBuilderIndex, isBuilderIndex} from "../util/gloas.js"; import { decreaseBalance, getMaxEffectiveBalance, @@ -31,10 +26,8 @@ export function processWithdrawals( state: CachedBeaconStateCapella | CachedBeaconStateElectra | CachedBeaconStateGloas, payload?: capella.FullOrBlindedExecutionPayload ): void { - // Return early if the parent block is empty - if (fork >= ForkSeq.gloas && !isParentBlockFull(state as CachedBeaconStateGloas)) { - return; - } + // After consensus-specs#5094, processParentExecutionPayload has already handled parent effects + // before processWithdrawals is called, so no early return needed for Gloas. // processedBuilderWithdrawalsCount is withdrawals coming from builder payment since gloas (EIP-7732) // processedPartialWithdrawalsCount is withdrawals coming from EL since electra (EIP-7002) From 311eeca5ddf846b77e1ec9855bfd3e6e7de7a800 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:52:27 -0700 Subject: [PATCH 20/67] refactor: add processParentExecutionPayload as first step in processBlock New ordering for Gloas blocks: 1. processParentExecutionPayload (NEW - before header) 2. processBlockHeader 3. processWithdrawals (no longer has empty-parent early return) 4. processExecutionPayloadBid 5. ... rest unchanged Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/state-transition/src/block/index.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/state-transition/src/block/index.ts b/packages/state-transition/src/block/index.ts index 2dc24d48bd5b..71633419e828 100644 --- a/packages/state-transition/src/block/index.ts +++ b/packages/state-transition/src/block/index.ts @@ -16,6 +16,7 @@ import {processExecutionPayload} from "./processExecutionPayload.js"; import {processExecutionPayloadBid} from "./processExecutionPayloadBid.js"; import {processExecutionPayloadEnvelope} from "./processExecutionPayloadEnvelope.js"; import {processOperations} from "./processOperations.js"; +import {processParentExecutionPayload} from "./processParentExecutionPayload.js"; import {processPayloadAttestation} from "./processPayloadAttestation.js"; import {processRandao} from "./processRandao.js"; import {processSyncAggregate} from "./processSyncCommittee.js"; @@ -33,6 +34,7 @@ export { processExecutionPayloadBid, processPayloadAttestation, processExecutionPayloadEnvelope, + processParentExecutionPayload, }; export * from "./externalData.js"; @@ -51,10 +53,16 @@ export function processBlock( ): void { const {verifySignatures = true} = opts ?? {}; + // Process parent execution payload effects first (consensus-specs#5094) + // Must run before processBlockHeader and processExecutionPayloadBid + if (fork >= ForkSeq.gloas) { + processParentExecutionPayload(state as CachedBeaconStateGloas, block as BeaconBlock); + } + processBlockHeader(state, block); if (fork >= ForkSeq.gloas) { - // After gloas, processWithdrawals does not take a payload parameter + // After consensus-specs#5094, processParentExecutionPayload has already handled parent effects processWithdrawals(fork, state as CachedBeaconStateGloas); } else if (fork >= ForkSeq.capella) { const fullOrBlindedPayload = getFullOrBlindedPayload(block); From e2d3ad22c2515c17b6213f390e3ddb6c65b99eed Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:53:35 -0700 Subject: [PATCH 21/67] refactor: transform processExecutionPayloadEnvelope to pure verification Envelope verification no longer mutates state. All state effects (execution requests, builder payment, availability, latestBlockHash) have moved to processParentExecutionPayload in the next block. Changes: - Remove state cloning, execution request processing, builder payment, availability/latestBlockHash updates, and state root verification - Add executionRequestsRoot check against bid commitment - Return void instead of post-state - Remove verifyStateRoot and dontTransferCache options - Rename stateView method to verifyExecutionPayloadEnvelope Co-Authored-By: Claude Opus 4.6 (1M context) --- .../block/processExecutionPayloadEnvelope.ts | 88 ++++++------------- .../src/stateView/beaconStateView.ts | 13 +-- .../src/stateView/interface.ts | 4 +- 3 files changed, 35 insertions(+), 70 deletions(-) diff --git a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts index 30f3788358c5..1a0de2ea2126 100644 --- a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts +++ b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts @@ -1,4 +1,3 @@ -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"; @@ -6,75 +5,32 @@ import {BeaconStateView} from "../stateView/beaconStateView.js"; import {CachedBeaconStateGloas} from "../types.js"; import {computeTimeAtSlot} from "../util/index.js"; import {verifySignatureSet} from "../util/signatureSets.js"; -import {processConsolidationRequest} from "./processConsolidationRequest.js"; -import {getPendingValidatorPubkeys, processDepositRequest} from "./processDepositRequest.js"; -import {processWithdrawalRequest} from "./processWithdrawalRequest.js"; export type ProcessExecutionPayloadEnvelopeOpts = { verifySignature?: boolean; - verifyStateRoot?: boolean; - dontTransferCache?: boolean; }; -// Unlike other block processing functions which mutate state in-place, this function -// clones the state and returns the post-state, similar to stateTransition(). -// This function does not call execution engine to verify payload. Need to call it from other place. +/** + * Pure verification of execution payload envelope — no state mutation. + * Payload processing is deferred to the next beacon block via processParentExecutionPayload. + * + * This function does not call the execution engine. That must be done separately. + * + * Spec: consensus-specs#5094 verify_execution_payload_envelope + */ export function processExecutionPayloadEnvelope( state: CachedBeaconStateGloas, signedEnvelope: gloas.SignedExecutionPayloadEnvelope, opts?: ProcessExecutionPayloadEnvelopeOpts -): CachedBeaconStateGloas { +): void { const {verifySignature = true} = opts ?? {}; const envelope = signedEnvelope.message; - const payload = envelope.payload; - const fork = state.config.getForkSeq(payload.slotNumber); if (verifySignature && !verifyExecutionPayloadEnvelopeSignature(state, signedEnvelope)) { throw Error(`Execution payload envelope has invalid signature builderIndex=${envelope.builderIndex}`); } - // .clone() before mutating state, similar to stateTransition() - const postState = state.clone(opts?.dontTransferCache) as CachedBeaconStateGloas; - - validateExecutionPayloadEnvelope(postState, envelope); - - const requests = envelope.executionRequests; - - if (requests.deposits.length > 0) { - // Build cache of pending validator pubkeys once, shared across all deposit requests - const pendingValidatorPubkeys = getPendingValidatorPubkeys(postState.config, postState); - - for (const deposit of requests.deposits) { - processDepositRequest(fork, postState, deposit, pendingValidatorPubkeys); - } - } - - for (const withdrawal of requests.withdrawals) { - processWithdrawalRequest(fork, postState, withdrawal); - } - - for (const consolidation of requests.consolidations) { - processConsolidationRequest(postState, consolidation); - } - - // Queue the builder payment - const paymentIndex = SLOTS_PER_EPOCH + (postState.slot % SLOTS_PER_EPOCH); - const payment = postState.builderPendingPayments.get(paymentIndex).clone(); - const amount = payment.withdrawal.amount; - - if (amount > 0) { - postState.builderPendingWithdrawals.push(payment.withdrawal); - } - - postState.builderPendingPayments.set(paymentIndex, ssz.gloas.BuilderPendingPayment.defaultViewDU()); - - // Cache the execution payload hash - postState.executionPayloadAvailability.set(postState.slot % SLOTS_PER_HISTORICAL_ROOT, true); - postState.latestBlockHash = payload.blockHash; - - postState.commit(); - - return postState; + validateExecutionPayloadEnvelope(state, envelope); } function validateExecutionPayloadEnvelope( @@ -84,15 +40,21 @@ function validateExecutionPayloadEnvelope( const payload = envelope.payload; // Cache latest block header state root + // Note: we read but do NOT mutate state — we compute the header root on a copy + let headerRoot: Uint8Array; if (byteArrayEquals(state.latestBlockHeader.stateRoot, ssz.Root.defaultValue())) { - const previousStateRoot = state.hashTreeRoot(); - state.latestBlockHeader.stateRoot = previousStateRoot; + // Compute what the header root would be with the state root filled in + const header = ssz.phase0.BeaconBlockHeader.toViewDU(state.latestBlockHeader.toValue()); + header.stateRoot = state.hashTreeRoot(); + headerRoot = header.hashTreeRoot(); + } else { + headerRoot = state.latestBlockHeader.hashTreeRoot(); } // Verify consistency with the beacon block - if (!byteArrayEquals(envelope.beaconBlockRoot, state.latestBlockHeader.hashTreeRoot())) { + if (!byteArrayEquals(envelope.beaconBlockRoot, headerRoot)) { throw new Error( - `Envelope's block is not the latest block header envelope=${toRootHex(envelope.beaconBlockRoot)} latestBlockHeader=${toRootHex(state.latestBlockHeader.hashTreeRoot())}` + `Envelope's block is not the latest block header envelope=${toRootHex(envelope.beaconBlockRoot)} latestBlockHeader=${toRootHex(headerRoot)}` ); } @@ -137,6 +99,14 @@ function validateExecutionPayloadEnvelope( ); } + // Verify execution_requests_root matches bid commitment (consensus-specs#5094) + const requestsRoot = ssz.electra.ExecutionRequests.hashTreeRoot(envelope.executionRequests); + if (!byteArrayEquals(requestsRoot, committedBid.executionRequestsRoot)) { + throw new Error( + `Execution requests root mismatch envelope=${toRootHex(requestsRoot)} committedBid=${toRootHex(committedBid.executionRequestsRoot)}` + ); + } + // Verify consistency of the parent hash with respect to the previous execution payload if (!byteArrayEquals(payload.parentHash, state.latestBlockHash)) { throw new Error( @@ -151,7 +121,7 @@ function validateExecutionPayloadEnvelope( ); } - // Skipped: Verify the execution payload is valid + // Execution engine verification (verify_and_notify_new_payload) is done externally } function verifyExecutionPayloadEnvelopeSignature( diff --git a/packages/state-transition/src/stateView/beaconStateView.ts b/packages/state-transition/src/stateView/beaconStateView.ts index 578d223741ff..5a881d3cb840 100644 --- a/packages/state-transition/src/stateView/beaconStateView.ts +++ b/packages/state-transition/src/stateView/beaconStateView.ts @@ -784,19 +784,14 @@ export class BeaconStateView implements IBeaconStateViewLatestFork { return new BeaconStateView(newState); } - processExecutionPayloadEnvelope( + verifyExecutionPayloadEnvelope( signedEnvelope: gloas.SignedExecutionPayloadEnvelope, opts?: ProcessExecutionPayloadEnvelopeOpts - ): BeaconStateView { + ): void { const fork = this.config.getForkName(this.cachedState.slot); if (!isForkPostGloas(fork)) { - throw Error(`processExecutionPayloadEnvelope is only available for gloas+ forks, got fork=${fork}`); + throw Error(`verifyExecutionPayloadEnvelope is only available for gloas+ forks, got fork=${fork}`); } - const postPayloadState = processExecutionPayloadEnvelope( - this.cachedState as CachedBeaconStateGloas, - signedEnvelope, - opts - ); - return new BeaconStateView(postPayloadState); + 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 5eee21e3af9a..ad507316aad7 100644 --- a/packages/state-transition/src/stateView/interface.ts +++ b/packages/state-transition/src/stateView/interface.ts @@ -254,10 +254,10 @@ export interface IBeaconStateViewGloas extends IBeaconStateViewFulu { getBuilder(index: BuilderIndex): gloas.Builder; canBuilderCoverBid(builderIndex: BuilderIndex, bidAmount: number): boolean; getIndexInPayloadTimelinessCommittee(validatorIndex: ValidatorIndex, slot: Slot): number; - processExecutionPayloadEnvelope( + verifyExecutionPayloadEnvelope( signedEnvelope: gloas.SignedExecutionPayloadEnvelope, opts?: ProcessExecutionPayloadEnvelopeOpts - ): IBeaconStateView; + ): void; } /** From c3c73b489a5cb59a69b86402d80336f625b235ab Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:54:24 -0700 Subject: [PATCH 22/67] refactor: remove executionPayloadStateRoot from fork choice onExecutionPayload With deferred payload processing, the envelope no longer produces a separate post-state. The FULL variant node shares the same stateRoot as the PENDING node. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/fork-choice/src/forkChoice/forkChoice.ts | 2 -- packages/fork-choice/src/forkChoice/interface.ts | 2 -- packages/fork-choice/src/protoArray/protoArray.ts | 4 ++-- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 49226737b9fe..eeabef013348 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -988,7 +988,6 @@ export class ForkChoice implements IForkChoice { blockRoot: RootHex, executionPayloadBlockHash: RootHex, executionPayloadNumber: number, - executionPayloadStateRoot: RootHex, executionStatus: PayloadExecutionStatus ): void { this.protoArray.onExecutionPayload( @@ -996,7 +995,6 @@ export class ForkChoice implements IForkChoice { this.fcStore.currentSlot, executionPayloadBlockHash, executionPayloadNumber, - executionPayloadStateRoot, this.proposerBoostRoot, executionStatus ); diff --git a/packages/fork-choice/src/forkChoice/interface.ts b/packages/fork-choice/src/forkChoice/interface.ts index 62bb364d776d..2f8670dd054f 100644 --- a/packages/fork-choice/src/forkChoice/interface.ts +++ b/packages/fork-choice/src/forkChoice/interface.ts @@ -198,13 +198,11 @@ export interface IForkChoice { * @param blockRoot - The beacon block root for which the payload arrived * @param executionPayloadBlockHash - The block hash of the execution payload * @param executionPayloadNumber - The block number of the execution payload - * @param executionPayloadStateRoot - The execution payload state root ie. the root of post-state after processExecutionPayloadEnvelope() */ onExecutionPayload( blockRoot: RootHex, executionPayloadBlockHash: RootHex, executionPayloadNumber: number, - executionPayloadStateRoot: RootHex, executionStatus: PayloadExecutionStatus ): void; /** diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 4d239d52e8db..0aab4d33a1d4 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -572,7 +572,6 @@ export class ProtoArray { currentSlot: Slot, executionPayloadBlockHash: RootHex, executionPayloadNumber: number, - executionPayloadStateRoot: RootHex, proposerBoostRoot: RootHex | null, executionStatus: PayloadExecutionStatus ): void { @@ -617,6 +616,8 @@ export class ProtoArray { } // Create FULL variant as a child of PENDING (sibling to EMPTY) + // With deferred payload processing (consensus-specs#5094), FULL shares the same + // stateRoot as PENDING since envelope no longer produces a separate post-state const fullNode: ProtoNode = { ...pendingNode, parent: pendingIndex, // Points to own PENDING (same as EMPTY) @@ -628,7 +629,6 @@ export class ProtoArray { executionStatus, executionPayloadBlockHash, executionPayloadNumber, - stateRoot: executionPayloadStateRoot, }; const fullIndex = this.nodes.length; From bc621c3cfd860bbfa1dd489b5eb6b0161e1c3e04 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:56:42 -0700 Subject: [PATCH 23/67] refactor: simplify envelope import pipeline for deferred processing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - importExecutionPayload now does pure verification only — no state cloning, no post-payload state computation, no state root check, no regen.processState(), no checkpoint caching - Remove stateRoot from executionPayload and executionPayloadGossip events - Remove stateRoot from envelope construction in validator API - Remove stateRoot from fork choice onExecutionPayload call - Envelope verification runs via verifyExecutionPayloadEnvelope (void) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../chain/blocks/importExecutionPayload.ts | 94 +++++++------------ 1 file changed, 36 insertions(+), 58 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index c25f83329a9b..c1b507d4ca8b 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -1,8 +1,8 @@ import {routes} from "@lodestar/api"; import {ExecutionStatus, PayloadExecutionStatus} from "@lodestar/fork-choice"; -import {SLOTS_PER_EPOCH} from "@lodestar/params"; import {getExecutionPayloadEnvelopeSignatureSet, isStatePostGloas} from "@lodestar/state-transition"; -import {fromHex, toRootHex} from "@lodestar/utils"; +import {fromHex} from "@lodestar/utils"; +import {fromHex} from "@lodestar/utils"; import {ExecutionPayloadStatus} from "../../execution/index.js"; import {isQueueErrorAborted} from "../../util/queue/index.js"; import {BeaconChain} from "../chain.js"; @@ -69,18 +69,20 @@ function toForkChoiceExecutionStatus(status: ExecutionPayloadStatus): PayloadExe /** * Import an execution payload envelope after all data is available. * - * This function: - * 1. Emits `execution_payload_available` if payload is for current slot - * 2. Gets the ProtoBlock from fork choice - * 3. Applies write-queue backpressure (waitForSpace) early, before verification - * 4. Regenerates the block state - * 5. Runs EL verification (notifyNewPayload) in parallel with signature verification and processExecutionPayloadEnvelope - * 6. Persists verified payload envelope to hot DB - * 7. Updates fork choice - * 8. Caches the post-execution payload state - * 9. Records metrics for column sources - * 10. Emits `execution_payload` for recent enough payloads after successful import + * With deferred processing (consensus-specs#5094), the envelope is purely verified + * here — no state mutation. State effects are applied in the next block via + * processParentExecutionPayload. * + * Steps: + * 1. Emit `execution_payload_available` for payload attestation + * 2. Get the ProtoBlock from fork choice + * 3. Apply write-queue backpressure + * 4. Regenerate block state for envelope field validation + * 5. Run EL verification and signature verification in parallel, plus pure envelope verification + * 6. Persist verified payload envelope to hot DB + * 7. Update fork choice (no stateRoot — FULL shares PENDING's stateRoot) + * 8. Record metrics + * 9. Emit `execution_payload` event */ export async function importExecutionPayload( this: BeaconChain, @@ -138,7 +140,7 @@ export async function importExecutionPayload( } // 6. Run verification steps in parallel - const [execResult, signatureValid, postPayloadResult] = await Promise.all([ + const [execResult, signatureValid] = await Promise.all([ this.executionEngine.notifyNewPayload( fork, envelope.payload, @@ -159,35 +161,27 @@ export async function importExecutionPayload( ); return this.bls.verifySignatureSets([signatureSet]); })(), - - // Signature verified separately above. - // State root check is done separately below with better error typing (matching block pipeline pattern). - (async () => { - try { - return { - postPayloadState: blockState.processExecutionPayloadEnvelope(signedEnvelope, { - verifySignature: false, - verifyStateRoot: false, - }), - }; - } catch (e) { - throw new PayloadError( - { - code: PayloadErrorCode.STATE_TRANSITION_ERROR, - message: (e as Error).message, - }, - `State transition error: ${(e as Error).message}` - ); - } - })(), ]); - // 5a. Check signature verification result + // 5a. Pure envelope verification (no state mutation) + try { + blockState.verifyExecutionPayloadEnvelope(signedEnvelope, {verifySignature: false}); + } catch (e) { + throw new PayloadError( + { + code: PayloadErrorCode.STATE_TRANSITION_ERROR, + message: (e as Error).message, + }, + `Envelope verification error: ${(e as Error).message}` + ); + } + + // 5b. Check signature verification result if (!signatureValid) { throw new PayloadError({code: PayloadErrorCode.INVALID_SIGNATURE}); } - // 5b. Handle EL response + // 5c. Handle EL response switch (execResult.status) { case ExecutionPayloadStatus.VALID: break; @@ -213,11 +207,7 @@ export async function importExecutionPayload( }); } - // 5c. Compute post-payload state root - const postPayloadState = postPayloadResult.postPayloadState; - const postPayloadStateRoot = postPayloadState.hashTreeRoot(); - - // 6. Persist payload envelope to hot DB (performed asynchronously to avoid blocking) + // 6. Persist payload envelope to hot DB this.unfinalizedPayloadEnvelopeWrites.push(payloadInput).catch((e) => { if (!isQueueErrorAborted(e)) { this.logger.error( @@ -228,23 +218,11 @@ export async function importExecutionPayload( } }); - // 7. Update fork choice - this.forkChoice.onExecutionPayload( - blockRootHex, - blockHashHex, - envelope.payload.blockNumber, - toRootHex(postPayloadStateRoot), - toForkChoiceExecutionStatus(execResult.status) - ); - - // 8. Cache payload state - this.regen.processState(blockRootHex, postPayloadState); - if (postPayloadState.slot % SLOTS_PER_EPOCH === 0) { - const {checkpoint} = postPayloadState.computeAnchorCheckpoint(); - this.regen.addCheckpointState(checkpoint, postPayloadState); - } + // 7. Update fork choice — no separate stateRoot since envelope doesn't produce post-state + const execStatus = toForkChoiceExecutionStatus(execResult.status); + this.forkChoice.onExecutionPayload(blockRootHex, blockHashHex, envelope.payload.blockNumber, execStatus); - // 9. 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}); From f4d40219e9d02035485d9ada5a35db6f55cbdb85 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:01:04 -0700 Subject: [PATCH 24/67] feat: block production, gossip validation, and cleanup for deferred processing Block production: - Add executionRequestsRoot to ExecutionPayloadBid construction - Add parentExecutionRequests to BeaconBlockBody via getParentExecutionRequests() - Add getParentExecutionRequests() to BeaconChain (returns parent's execution requests from cached envelope, or empty if EMPTY parent) Gossip validation: - Add executionRequestsRoot check in envelope validation - Add EXECUTION_REQUESTS_ROOT_MISMATCH error code Cleanup: - Remove stateRoot from validator envelope logging - Fix spec test for void-returning processExecutionPayloadEnvelope - Update stale TODO comment in writePayloadEnvelopeInputToDb Co-Authored-By: Claude Opus 4.6 (1M context) --- .../chain/blocks/writePayloadEnvelopeInputToDb.ts | 2 +- packages/beacon-node/src/chain/chain.ts | 15 +++++++++++++++ .../src/chain/errors/executionPayloadEnvelope.ts | 6 ++++++ .../src/chain/produceBlock/produceBlockBody.ts | 4 ++++ .../chain/validation/executionPayloadEnvelope.ts | 14 ++++++++++++-- .../test/spec/presets/operations.test.ts | 5 ++--- 6 files changed, 40 insertions(+), 6 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts b/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts index 425bd83adca8..d5c07a563590 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 GLOAS: Persist envelope metadata (stateRoot, executionRequests, builderIndex, etc.) without the full + * TODO GLOAS: Persist envelope metadata (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/chain.ts b/packages/beacon-node/src/chain/chain.ts index 2ee877c9d7f3..417a65fdf37a 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -39,6 +39,7 @@ import { ValidatorIndex, Wei, deneb, + electra, gloas, isBlindedBeaconBlock, phase0, @@ -870,6 +871,20 @@ export class BeaconChain implements IBeaconChain { ); } + /** + * Get execution requests from parent's payload envelope for block production. + * If parent was FULL, returns the execution requests from the cached envelope. + * If parent was EMPTY or envelope unknown, returns empty execution requests. + */ + getParentExecutionRequests(parentBlockRootHex: RootHex): electra.ExecutionRequests { + const payloadInput = this.seenPayloadEnvelopeInputCache.get(parentBlockRootHex); + if (payloadInput?.hasPayloadEnvelope()) { + return payloadInput.getPayloadEnvelope().message.executionRequests; + } + // Parent was EMPTY or we don't have the envelope — return empty requests + return ssz.electra.ExecutionRequests.defaultValue(); + } + async getExecutionPayloadEnvelope( blockSlot: Slot, blockRootHex: string diff --git a/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts index cf7a0fe4a002..c8a8645d6dc5 100644 --- a/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts @@ -11,6 +11,7 @@ export enum ExecutionPayloadEnvelopeErrorCode { 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", + EXECUTION_REQUESTS_ROOT_MISMATCH = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_EXECUTION_REQUESTS_ROOT_MISMATCH", INVALID_SIGNATURE = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_INVALID_SIGNATURE", PAYLOAD_ENVELOPE_INPUT_MISSING = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_PAYLOAD_ENVELOPE_INPUT_MISSING", } @@ -36,6 +37,11 @@ export type ExecutionPayloadEnvelopeErrorType = envelopeBlockHash: RootHex; bidBlockHash: RootHex | null; } + | { + code: ExecutionPayloadEnvelopeErrorCode.EXECUTION_REQUESTS_ROOT_MISMATCH; + envelopeRequestsRoot: RootHex; + bidRequestsRoot: RootHex; + } | {code: ExecutionPayloadEnvelopeErrorCode.INVALID_SIGNATURE} | {code: ExecutionPayloadEnvelopeErrorCode.PAYLOAD_ENVELOPE_INPUT_MISSING; blockRoot: RootHex}; diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index a2edacdf77b5..2b123355ed5e 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -282,6 +282,10 @@ export async function produceBlockBody( gloasBody.signedExecutionPayloadBid = signedBid; // TODO GLOAS: Get payload attestations from pool for previous slot gloasBody.payloadAttestations = []; + // Determine parent execution requests for deferred processing (consensus-specs#5094) + // If parent was FULL: include execution requests from its envelope + // If parent was EMPTY: include empty execution requests + gloasBody.parentExecutionRequests = this.getParentExecutionRequests(parentBlockRootHex); blockBody = gloasBody as AssembledBodyType; // Store execution payload data required to construct execution payload envelope later diff --git a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts index fc62d61cca86..831db7d8f0bd 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts @@ -4,8 +4,8 @@ import { getExecutionPayloadEnvelopeSignatureSet, isStatePostGloas, } from "@lodestar/state-transition"; -import {gloas} from "@lodestar/types"; -import {toRootHex} from "@lodestar/utils"; +import {gloas, ssz} from "@lodestar/types"; +import {byteArrayEquals, toRootHex} from "@lodestar/utils"; import {ExecutionPayloadEnvelopeError, ExecutionPayloadEnvelopeErrorCode, GossipAction} from "../errors/index.js"; import {IBeaconChain} from "../index.js"; import {RegenCaller} from "../regen/index.js"; @@ -98,6 +98,16 @@ async function validateExecutionPayloadEnvelope( }); } + // [REJECT] `hash_tree_root(envelope.execution_requests) == bid.execution_requests_root` + const requestsRoot = ssz.electra.ExecutionRequests.hashTreeRoot(envelope.executionRequests); + if (!byteArrayEquals(requestsRoot, payloadInput.getBid().executionRequestsRoot)) { + throw new ExecutionPayloadEnvelopeError(GossipAction.REJECT, { + code: ExecutionPayloadEnvelopeErrorCode.EXECUTION_REQUESTS_ROOT_MISMATCH, + envelopeRequestsRoot: toRootHex(requestsRoot), + bidRequestsRoot: toRootHex(payloadInput.getBid().executionRequestsRoot), + }); + } + // [REJECT] `payload.block_hash == bid.block_hash` if (toRootHex(payload.blockHash) !== payloadInput.getBlockHashHex()) { throw new ExecutionPayloadEnvelopeError(GossipAction.REJECT, { diff --git a/packages/beacon-node/test/spec/presets/operations.test.ts b/packages/beacon-node/test/spec/presets/operations.test.ts index 48fd66e28c50..26fd54409134 100644 --- a/packages/beacon-node/test/spec/presets/operations.test.ts +++ b/packages/beacon-node/test/spec/presets/operations.test.ts @@ -78,10 +78,10 @@ const operationFns: Record> = ): CachedBeaconStateAllForks | void => { const fork = state.config.getForkSeq(state.slot); if (fork >= ForkSeq.gloas) { - return blockFns.processExecutionPayloadEnvelope(state as CachedBeaconStateGloas, testCase.signed_envelope, { + blockFns.processExecutionPayloadEnvelope(state as CachedBeaconStateGloas, testCase.signed_envelope, { verifySignature: true, - verifyStateRoot: true, }); + return; } blockFns.processExecutionPayload(fork, state as CachedBeaconStateBellatrix, testCase.body, { executionPayloadStatus: testCase.execution.execution_valid @@ -144,7 +144,6 @@ const operations: TestRunnerFn = (fork, const cachedState = createCachedBeaconStateTest(state, getConfig(fork, epoch)); const postState = operationFn(cachedState, testcase); - // processExecutionPayloadEnvelope returns the postState, other operations mutate the state in-place and return void if (postState !== undefined) { postState.commit(); return postState; From 63e5271ef6548c0184d9fdecd2e879479b3446b1 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:01:14 -0700 Subject: [PATCH 25/67] fix: type errors in processParentExecutionPayload and produceBlockBody - Use electra.ExecutionRequests type instead of unknown[] - Fix parentBlockRootHex -> parentBlock.blockRoot variable name Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/chain/produceBlock/produceBlockBody.ts | 2 +- .../src/block/processParentExecutionPayload.ts | 10 +++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index 2b123355ed5e..db9a57234c84 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -285,7 +285,7 @@ export async function produceBlockBody( // Determine parent execution requests for deferred processing (consensus-specs#5094) // If parent was FULL: include execution requests from its envelope // If parent was EMPTY: include empty execution requests - gloasBody.parentExecutionRequests = this.getParentExecutionRequests(parentBlockRootHex); + gloasBody.parentExecutionRequests = this.getParentExecutionRequests(parentBlock.blockRoot); blockBody = gloasBody as AssembledBodyType; // Store execution payload data required to construct execution payload envelope later diff --git a/packages/state-transition/src/block/processParentExecutionPayload.ts b/packages/state-transition/src/block/processParentExecutionPayload.ts index 30149c8fec14..0560f75003fc 100644 --- a/packages/state-transition/src/block/processParentExecutionPayload.ts +++ b/packages/state-transition/src/block/processParentExecutionPayload.ts @@ -1,5 +1,5 @@ import {ForkPostGloas, SLOTS_PER_EPOCH, SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; -import {BeaconBlock, ssz} from "@lodestar/types"; +import {BeaconBlock, electra, ssz} from "@lodestar/types"; import {byteArrayEquals, toRootHex} from "@lodestar/utils"; import {CachedBeaconStateGloas} from "../types.js"; import {computeEpochAtSlot} from "../util/epoch.js"; @@ -49,7 +49,7 @@ export function processParentExecutionPayload( function applyParentExecutionPayload( state: CachedBeaconStateGloas, parentBid: {slot: number; blockHash: Uint8Array; builderIndex: number}, - requests: {deposits: unknown[]; withdrawals: unknown[]; consolidations: unknown[]} + requests: electra.ExecutionRequests ): void { const fork = state.config.getForkSeq(state.slot); const parentSlot = parentBid.slot; @@ -97,11 +97,7 @@ function applyParentExecutionPayload( state.latestBlockHash = parentBid.blockHash; } -function assertEmptyExecutionRequests(requests: { - deposits: unknown[]; - withdrawals: unknown[]; - consolidations: unknown[]; -}): void { +function assertEmptyExecutionRequests(requests: electra.ExecutionRequests): void { if (requests.deposits.length !== 0 || requests.withdrawals.length !== 0 || requests.consolidations.length !== 0) { throw new Error("Parent execution requests must be empty when parent block is EMPTY"); } From 06ab9c19835fafe17d453276208c23abb4efd995 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:16:16 -0700 Subject: [PATCH 26/67] Fix spec test --- .../test/spec/presets/operations.test.ts | 8 ++++++++ .../src/block/processWithdrawals.ts | 15 ++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/beacon-node/test/spec/presets/operations.test.ts b/packages/beacon-node/test/spec/presets/operations.test.ts index 26fd54409134..d6cb02d5b2f2 100644 --- a/packages/beacon-node/test/spec/presets/operations.test.ts +++ b/packages/beacon-node/test/spec/presets/operations.test.ts @@ -10,6 +10,7 @@ import { CachedBeaconStateElectra, CachedBeaconStateGloas, ExecutionPayloadStatus, + processSlots, getBlockRootAtSlot, } from "@lodestar/state-transition"; import * as blockFns from "@lodestar/state-transition/block"; @@ -117,6 +118,13 @@ const operationFns: Record> = blockFns.processExecutionPayloadBid(state as CachedBeaconStateGloas, testCase.block); }, + parent_execution_payload: (state, testCase: {block: gloas.BeaconBlock}): CachedBeaconStateAllForks => { + // Spec test calls process_slots then process_parent_execution_payload + const postState = processSlots(state, testCase.block.slot); + blockFns.processParentExecutionPayload(postState as CachedBeaconStateGloas, testCase.block); + return postState; + }, + payload_attestation: (state, testCase: {payload_attestation: gloas.PayloadAttestation}) => { blockFns.processPayloadAttestation(state as CachedBeaconStateGloas, testCase.payload_attestation); }, diff --git a/packages/state-transition/src/block/processWithdrawals.ts b/packages/state-transition/src/block/processWithdrawals.ts index ba731b51d141..ad4036e0cd13 100644 --- a/packages/state-transition/src/block/processWithdrawals.ts +++ b/packages/state-transition/src/block/processWithdrawals.ts @@ -11,7 +11,12 @@ import { import {BuilderIndex, ValidatorIndex, capella, ssz} from "@lodestar/types"; import {byteArrayEquals, toRootHex} from "@lodestar/utils"; import {CachedBeaconStateCapella, CachedBeaconStateElectra, CachedBeaconStateGloas} from "../types.js"; -import {convertBuilderIndexToValidatorIndex, convertValidatorIndexToBuilderIndex, isBuilderIndex} from "../util/gloas.js"; +import { + convertBuilderIndexToValidatorIndex, + convertValidatorIndexToBuilderIndex, + isBuilderIndex, + isParentBlockFull, +} from "../util/gloas.js"; import { decreaseBalance, getMaxEffectiveBalance, @@ -26,8 +31,12 @@ export function processWithdrawals( state: CachedBeaconStateCapella | CachedBeaconStateElectra | CachedBeaconStateGloas, payload?: capella.FullOrBlindedExecutionPayload ): void { - // After consensus-specs#5094, processParentExecutionPayload has already handled parent effects - // before processWithdrawals is called, so no early return needed for Gloas. + // Return early if the parent block is empty. + // After processParentExecutionPayload runs, latestBlockHash is updated only if parent was FULL. + // If still mismatched, the parent was EMPTY and no withdrawals should be computed. + if (fork >= ForkSeq.gloas && !isParentBlockFull(state as CachedBeaconStateGloas)) { + return; + } // processedBuilderWithdrawalsCount is withdrawals coming from builder payment since gloas (EIP-7732) // processedPartialWithdrawalsCount is withdrawals coming from EL since electra (EIP-7002) From 6552bc8eca15fed01b741cf137462fa769e6f379 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:26:00 -0700 Subject: [PATCH 27/67] Address comments & follow up on spec change --- .../chain/blocks/importExecutionPayload.ts | 55 ++++--- .../blocks/verifyExecutionPayloadEnvelope.ts | 132 +++++++++++++++++ packages/beacon-node/src/chain/chain.ts | 18 ++- .../chain/produceBlock/produceBlockBody.ts | 50 ++++++- .../test/spec/presets/operations.test.ts | 14 +- .../fork-choice/src/forkChoice/forkChoice.ts | 4 + .../fork-choice/src/forkChoice/interface.ts | 6 + packages/state-transition/src/block/index.ts | 2 - .../block/processExecutionPayloadEnvelope.ts | 139 ------------------ .../block/processParentExecutionPayload.ts | 44 +++--- .../src/block/processWithdrawals.ts | 2 +- .../src/stateView/beaconStateView.ts | 19 ++- .../src/stateView/interface.ts | 11 +- 13 files changed, 273 insertions(+), 223 deletions(-) create mode 100644 packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts delete mode 100644 packages/state-transition/src/block/processExecutionPayloadEnvelope.ts diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index c1b507d4ca8b..84b033c6d1b2 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -1,7 +1,6 @@ import {routes} from "@lodestar/api"; import {ExecutionStatus, PayloadExecutionStatus} from "@lodestar/fork-choice"; -import {getExecutionPayloadEnvelopeSignatureSet, isStatePostGloas} from "@lodestar/state-transition"; -import {fromHex} from "@lodestar/utils"; +import {isStatePostGloas} from "@lodestar/state-transition"; import {fromHex} from "@lodestar/utils"; import {ExecutionPayloadStatus} from "../../execution/index.js"; import {isQueueErrorAborted} from "../../util/queue/index.js"; @@ -9,6 +8,7 @@ import {BeaconChain} from "../chain.js"; import {RegenCaller} from "../regen/interface.js"; import {PayloadEnvelopeInput} from "../seenCache/seenPayloadEnvelopeInput.js"; import {ImportPayloadOpts} from "./types.js"; +import {verifyExecutionPayloadEnvelope, verifyExecutionPayloadEnvelopeSignature} from "./verifyExecutionPayloadEnvelope.js"; import {verifyPayloadsDataAvailability} from "./verifyPayloadsDataAvailability.js"; const EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS = 64; @@ -94,15 +94,12 @@ export async function importExecutionPayload( const envelope = signedEnvelope.message; const blockRootHex = payloadInput.blockRootHex; const blockHashHex = payloadInput.getBlockHashHex(); - const fork = this.config.getForkName(envelope.payload.slotNumber); + const fork = this.config.getForkName(envelope.slot); - // 1. Emit `execution_payload_available` event at the start of import. At this point the payload input - // is already complete, so the payload and required data are available for payload attestation. - // This event is only about availability, not validity of the execution payload, hence we can emit - // it before getting a response from the execution client on whether the payload is valid or not. - if (this.clock.currentSlot - envelope.payload.slotNumber < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { + // 1. Emit `execution_payload_available` event at the start of import + if (this.clock.currentSlot - envelope.slot < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { this.emitter.emit(routes.events.EventType.executionPayloadAvailable, { - slot: envelope.payload.slotNumber, + slot: envelope.slot, blockRoot: blockRootHex, }); } @@ -124,8 +121,8 @@ export async function importExecutionPayload( // The actual DB write is deferred until after verification succeeds. await this.unfinalizedPayloadEnvelopeWrites.waitForSpace(); - // 5. Get pre-state for processExecutionPayloadEnvelope - // We need the block state (post-block, pre-payload) to process the envelope + // 5. Get pre-state for envelope verification + // We need the block state (post-block, pre-payload) to verify the envelope const blockState = await this.regen.getBlockSlotState( protoBlock, protoBlock.slot, @@ -151,21 +148,23 @@ export async function importExecutionPayload( opts.validSignature === true ? Promise.resolve(true) - : (async () => { - const signatureSet = getExecutionPayloadEnvelopeSignatureSet( - this.config, - this.pubkeyCache, - blockState, - signedEnvelope, - payloadInput.proposerIndex - ); - return this.bls.verifySignatureSets([signatureSet]); - })(), + : verifyExecutionPayloadEnvelopeSignature( + this.config, + blockState, + this.pubkeyCache, + signedEnvelope, + payloadInput.proposerIndex, + this.bls + ), ]); - // 5a. Pure envelope verification (no state mutation) + // 5a. Verify envelope fields against state (spec: verify_execution_payload_envelope) try { - blockState.verifyExecutionPayloadEnvelope(signedEnvelope, {verifySignature: false}); + // When validSignature is true, the envelope came from gossip/API where both + // signature and executionRequestsRoot were already verified — skip re-hashing + verifyExecutionPayloadEnvelope(this.config, blockState, envelope, { + verifyExecutionRequestsRoot: !opts.validSignature, + }); } catch (e) { throw new PayloadError( { @@ -212,7 +211,7 @@ export async function importExecutionPayload( if (!isQueueErrorAborted(e)) { this.logger.error( "Error pushing payload envelope to unfinalized write queue", - {slot: envelope.payload.slotNumber, blockRoot: blockRootHex}, + {slot: envelope.slot, blockRoot: blockRootHex}, e as Error ); } @@ -228,10 +227,10 @@ export async function importExecutionPayload( this.metrics?.importPayload.columnsBySource.inc({source}); } - // 10. Emit event after payload is fully verified and imported to fork choice, only for recent enough payloads - if (this.clock.currentSlot - envelope.payload.slotNumber < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { + // 9. Emit event after payload is fully verified and imported to fork choice + if (this.clock.currentSlot - envelope.slot < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { this.emitter.emit(routes.events.EventType.executionPayload, { - slot: envelope.payload.slotNumber, + slot: envelope.slot, builderIndex: envelope.builderIndex, blockHash: blockHashHex, blockRoot: blockRootHex, @@ -241,7 +240,7 @@ export async function importExecutionPayload( } this.logger.verbose("Execution payload imported", { - slot: envelope.payload.slotNumber, + slot: envelope.slot, builderIndex: envelope.builderIndex, blockRoot: blockRootHex, blockHash: blockHashHex, diff --git a/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts b/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts new file mode 100644 index 000000000000..428c22188408 --- /dev/null +++ b/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts @@ -0,0 +1,132 @@ +import {BeaconConfig} from "@lodestar/config"; +import {gloas, ssz} from "@lodestar/types"; +import {byteArrayEquals, toHex, toRootHex} from "@lodestar/utils"; +import { + type IBeaconStateViewGloas, + type PubkeyCache, + computeTimeAtSlot, + getExecutionPayloadEnvelopeSignatureSet, +} from "@lodestar/state-transition"; +import {IBlsVerifier} from "../bls/index.js"; + +export type VerifyExecutionPayloadEnvelopeOpts = { + verifyExecutionRequestsRoot?: boolean; +}; + +/** + * Verify execution payload envelope fields against the post-block state. + * Does NOT verify signature (done separately) or call the execution engine. + * + * Spec: gloas/fork-choice.md — verify_execution_payload_envelope + */ +export function verifyExecutionPayloadEnvelope( + config: BeaconConfig, + state: IBeaconStateViewGloas, + envelope: gloas.ExecutionPayloadEnvelope, + opts?: VerifyExecutionPayloadEnvelopeOpts +): void { + const {verifyExecutionRequestsRoot = true} = opts ?? {}; + const payload = envelope.payload; + + // Compute header root without mutating state + const headerValue = {...state.latestBlockHeader}; + if (byteArrayEquals(headerValue.stateRoot, ssz.Root.defaultValue())) { + headerValue.stateRoot = state.hashTreeRoot(); + } + const headerRoot = ssz.phase0.BeaconBlockHeader.hashTreeRoot(headerValue); + + // Verify consistency with the beacon block + if (!byteArrayEquals(envelope.beaconBlockRoot, headerRoot)) { + throw new Error( + `Envelope's block is not the latest block header envelope=${toRootHex(envelope.beaconBlockRoot)} latestBlockHeader=${toRootHex(headerRoot)}` + ); + } + + if (envelope.slot !== state.slot) { + throw new Error(`Slot mismatch between envelope and state envelope=${envelope.slot} state=${state.slot}`); + } + + // Verify consistency with the committed bid + const bid = state.latestExecutionPayloadBid; + if (envelope.builderIndex !== bid.builderIndex) { + throw new Error( + `Builder index mismatch between envelope and committed bid envelope=${envelope.builderIndex} bid=${bid.builderIndex}` + ); + } + + if (!byteArrayEquals(bid.prevRandao, payload.prevRandao)) { + throw new Error( + `Prev randao mismatch between bid and payload bid=${toHex(bid.prevRandao)} payload=${toHex(payload.prevRandao)}` + ); + } + + if (Number(bid.gasLimit) !== payload.gasLimit) { + throw new Error( + `Gas limit mismatch between payload and bid payload=${payload.gasLimit} bid=${Number(bid.gasLimit)}` + ); + } + + if (!byteArrayEquals(bid.blockHash, payload.blockHash)) { + throw new Error( + `Block hash mismatch between payload and bid payload=${toRootHex(payload.blockHash)} bid=${toRootHex(bid.blockHash)}` + ); + } + + // Verify execution_requests_root matches bid commitment + // Can be skipped if already verified during gossip validation + if (verifyExecutionRequestsRoot) { + const requestsRoot = ssz.electra.ExecutionRequests.hashTreeRoot(envelope.executionRequests); + if (!byteArrayEquals(requestsRoot, bid.executionRequestsRoot)) { + throw new Error( + `Execution requests root mismatch envelope=${toRootHex(requestsRoot)} bid=${toRootHex(bid.executionRequestsRoot)}` + ); + } + } + + // Verify the execution payload is valid + if (!byteArrayEquals(payload.parentHash, state.latestBlockHash)) { + throw new Error( + `Parent hash mismatch between payload and state payload=${toRootHex(payload.parentHash)} state=${toRootHex(state.latestBlockHash)}` + ); + } + + if (payload.timestamp !== computeTimeAtSlot(config, state.slot, state.genesisTime)) { + throw new Error( + `Timestamp mismatch between payload and state payload=${payload.timestamp} state=${computeTimeAtSlot(config, state.slot, state.genesisTime)}` + ); + } + + // Verify consistency with expected withdrawals + const payloadWithdrawalsRoot = ssz.capella.Withdrawals.hashTreeRoot(payload.withdrawals); + const expectedWithdrawalsRoot = ssz.capella.Withdrawals.hashTreeRoot(state.payloadExpectedWithdrawals); + if (!byteArrayEquals(payloadWithdrawalsRoot, expectedWithdrawalsRoot)) { + throw new Error( + `Withdrawals mismatch between payload and expected payload=${toRootHex(payloadWithdrawalsRoot)} expected=${toRootHex(expectedWithdrawalsRoot)}` + ); + } + + // Execution engine verification (verify_and_notify_new_payload) is done externally +} + +/** + * Verify the BLS signature of an execution payload envelope. + * + * Spec: gloas/fork-choice.md — verify_execution_payload_envelope_signature + */ +export async function verifyExecutionPayloadEnvelopeSignature( + config: BeaconConfig, + state: IBeaconStateViewGloas, + pubkeyCache: PubkeyCache, + signedEnvelope: gloas.SignedExecutionPayloadEnvelope, + proposerIndex: number, + bls: IBlsVerifier +): Promise { + const signatureSet = getExecutionPayloadEnvelopeSignatureSet( + config, + pubkeyCache, + state, + signedEnvelope, + proposerIndex + ); + return bls.verifySignatureSets([signatureSet]); +} diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 417a65fdf37a..5b9562973121 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -873,15 +873,21 @@ export class BeaconChain implements IBeaconChain { /** * Get execution requests from parent's payload envelope for block production. - * If parent was FULL, returns the execution requests from the cached envelope. - * If parent was EMPTY or envelope unknown, returns empty execution requests. + * Uses is_payload_verified AND should_extend_payload per spec's prepare_execution_payload. + * If parent was FULL and PTC voted timely, returns execution requests from the cached envelope. + * Otherwise returns empty execution requests (build on EMPTY variant). */ getParentExecutionRequests(parentBlockRootHex: RootHex): electra.ExecutionRequests { - const payloadInput = this.seenPayloadEnvelopeInputCache.get(parentBlockRootHex); - if (payloadInput?.hasPayloadEnvelope()) { - return payloadInput.getPayloadEnvelope().message.executionRequests; + if ( + this.forkChoice.hasPayloadHexUnsafe(parentBlockRootHex) && + this.forkChoice.shouldExtendPayload(parentBlockRootHex) + ) { + const payloadInput = this.seenPayloadEnvelopeInputCache.get(parentBlockRootHex); + if (payloadInput?.hasPayloadEnvelope()) { + return payloadInput.getPayloadEnvelope().message.executionRequests; + } } - // Parent was EMPTY or we don't have the envelope — return empty requests + // Parent was EMPTY, payload not verified, or PTC didn't vote timely — return empty requests return ssz.electra.ExecutionRequests.defaultValue(); } diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index db9a57234c84..a756b900fe9a 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -18,6 +18,7 @@ import { G2_POINT_AT_INFINITY, IBeaconStateView, type IBeaconStateViewBellatrix, + type IBeaconStateViewGloas, computeTimeAtSlot, isStatePostBellatrix, isStatePostCapella, @@ -614,6 +615,8 @@ export async function prepareExecutionPayload( chain: { executionEngine: IExecutionEngine; config: ChainForkConfig; + forkChoice?: IForkChoice; + seenPayloadEnvelopeInputCache?: {get(rootHex: string): {hasPayloadEnvelope(): boolean; getPayloadEnvelope(): gloas.SignedExecutionPayloadEnvelope} | undefined}; }, logger: Logger, fork: ForkPostBellatrix, @@ -624,11 +627,44 @@ export async function prepareExecutionPayload( state: IBeaconStateViewBellatrix, suggestedFeeRecipient: string ): Promise<{prepType: PayloadPreparationType; payloadId: PayloadId}> { + let parentHash = state.latestBlockHash; + let withdrawalsOverride: capella.Withdrawal[] | undefined; + + // For Gloas: determine FULL vs EMPTY parent per spec's prepare_execution_payload + // If extending FULL parent: apply parent payload to get correct withdrawals and use bid.blockHash + // If EMPTY parent: use state.payloadExpectedWithdrawals and bid.parentBlockHash + if (isForkPostGloas(fork) && chain.forkChoice && chain.seenPayloadEnvelopeInputCache) { + const gloasState = state as unknown as { + latestExecutionPayloadBid: { + slot: number; + blockHash: Uint8Array; + parentBlockHash: Uint8Array; + builderIndex: number; + value: number; + feeRecipient: Uint8Array; + }; + }; + const parentRootHex = toRootHex(parentBlockRoot); + + if (chain.forkChoice.shouldExtendPayload(parentRootHex)) { + const payloadInput = chain.seenPayloadEnvelopeInputCache.get(parentRootHex); + if (payloadInput?.hasPayloadEnvelope()) { + // Build on FULL variant: apply parent payload to compute correct withdrawals, + // use bid.blockHash as EL head (per spec's prepare_execution_payload) + const gloasView = state as unknown as IBeaconStateViewGloas; + withdrawalsOverride = gloasView.getExpectedWithdrawalsForFullParent(payloadInput.getPayloadEnvelope()); + parentHash = gloasState.latestExecutionPayloadBid.blockHash; + } + } else { + // EMPTY parent: use bid.parentBlockHash as the EL head + parentHash = gloasState.latestExecutionPayloadBid.parentBlockHash; + } + } const timestamp = computeTimeAtSlot(chain.config, state.slot, state.genesisTime); const prevRandao = state.getRandaoMix(state.epoch); const payloadIdCached = chain.executionEngine.payloadIdCache.get({ - headBlockHash: toRootHex(parentBlockHash), + headBlockHash: toRootHex(parentHash), finalizedBlockHash, timestamp: numToQuantity(timestamp), prevRandao: toHex(prevRandao), @@ -659,11 +695,12 @@ export async function prepareExecutionPayload( parentBlockRoot, parentBlockHash, feeRecipient: suggestedFeeRecipient, + withdrawalsOverride, }); payloadId = await chain.executionEngine.notifyForkchoiceUpdate( fork, - toRootHex(parentBlockHash), + toRootHex(parentHash), safeBlockHash, finalizedBlockHash, attributes @@ -769,12 +806,14 @@ function preparePayloadAttributes( parentBlockRoot, parentBlockHash, feeRecipient, + withdrawalsOverride, }: { prepareState: IBeaconStateViewBellatrix; prepareSlot: Slot; parentBlockRoot: Root; parentBlockHash: Bytes32; feeRecipient: string; + withdrawalsOverride?: capella.Withdrawal[]; } ): SSEPayloadAttributes["payloadAttributes"] { const timestamp = computeTimeAtSlot(chain.config, prepareSlot, prepareState.genesisTime); @@ -790,7 +829,10 @@ function preparePayloadAttributes( throw new Error("Expected Capella state for withdrawals"); } - if (isStatePostGloas(prepareState)) { + if (withdrawalsOverride) { + // FULL parent: withdrawals computed from state with parent payload applied + (payloadAttributes as capella.SSEPayloadAttributes["payloadAttributes"]).withdrawals = withdrawalsOverride; + } else if (isStatePostGloas(prepareState)) { const isExtendingPayload = byteArrayEquals(parentBlockHash, prepareState.latestExecutionPayloadBid.blockHash); // When the parent block is empty, state.payloadExpectedWithdrawals holds a batch // already deducted from CL balances but never credited on the EL (the envelope @@ -800,7 +842,7 @@ function preparePayloadAttributes( ? prepareState.getExpectedWithdrawals().expectedWithdrawals : prepareState.payloadExpectedWithdrawals; } else { - // withdrawals logic is now fork aware as it changes on electra fork post capella + // Pre-Gloas or Gloas with full parent but no override (shouldn't happen in normal flow) (payloadAttributes as capella.SSEPayloadAttributes["payloadAttributes"]).withdrawals = prepareState.getExpectedWithdrawals().expectedWithdrawals; } diff --git a/packages/beacon-node/test/spec/presets/operations.test.ts b/packages/beacon-node/test/spec/presets/operations.test.ts index d6cb02d5b2f2..4f97fd26d79f 100644 --- a/packages/beacon-node/test/spec/presets/operations.test.ts +++ b/packages/beacon-node/test/spec/presets/operations.test.ts @@ -1,6 +1,6 @@ import path from "node:path"; import {getConfig} from "@lodestar/config/test-utils"; -import {ACTIVE_PRESET, ForkName, ForkSeq} from "@lodestar/params"; +import {ACTIVE_PRESET, ForkName} from "@lodestar/params"; import {InputType} from "@lodestar/spec-test-util"; import { BeaconStateAllForks, @@ -72,18 +72,11 @@ const operationFns: Record> = execution_payload: ( state, testCase: { - body: bellatrix.BeaconBlockBody | gloas.BeaconBlockBody; - signed_envelope: gloas.SignedExecutionPayloadEnvelope; + body: bellatrix.BeaconBlockBody; execution: {execution_valid: boolean}; } - ): CachedBeaconStateAllForks | void => { + ) => { const fork = state.config.getForkSeq(state.slot); - if (fork >= ForkSeq.gloas) { - blockFns.processExecutionPayloadEnvelope(state as CachedBeaconStateGloas, testCase.signed_envelope, { - verifySignature: true, - }); - return; - } blockFns.processExecutionPayload(fork, state as CachedBeaconStateBellatrix, testCase.body, { executionPayloadStatus: testCase.execution.execution_valid ? ExecutionPayloadStatus.valid @@ -185,7 +178,6 @@ const operations: TestRunnerFn = (fork, deposit_request: ssz.electra.DepositRequest, consolidation_request: ssz.electra.ConsolidationRequest, payload_attestation: ssz.gloas.PayloadAttestation, - signed_envelope: ssz.gloas.SignedExecutionPayloadEnvelope, }, shouldError: (testCase) => testCase.post === undefined, getExpected: (testCase) => testCase.post, diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index eeabef013348..73544259d832 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -1082,6 +1082,10 @@ export class ForkChoice implements IForkChoice { return this.protoArray.hasPayload(blockRoot); } + shouldExtendPayload(blockRoot: RootHex): boolean { + return this.protoArray.shouldExtendPayload(blockRoot, this.proposerBoostRoot); + } + /** * Returns a MUTABLE `ProtoBlock` if the block is known **and** a descendant of the finalized root. */ diff --git a/packages/fork-choice/src/forkChoice/interface.ts b/packages/fork-choice/src/forkChoice/interface.ts index 2f8670dd054f..5b1a8f8328f7 100644 --- a/packages/fork-choice/src/forkChoice/interface.ts +++ b/packages/fork-choice/src/forkChoice/interface.ts @@ -230,6 +230,12 @@ export interface IForkChoice { */ hasPayloadUnsafe(blockRoot: Root): boolean; hasPayloadHexUnsafe(blockRoot: RootHex): boolean; + /** + * Whether to extend the payload for a given block root. + * Checks PTC timeliness and data availability, with fallback logic. + * Spec: gloas/fork-choice.md#should_extend_payload + */ + shouldExtendPayload(blockRoot: RootHex): boolean; getSlotsPresent(windowStart: number): number; /** * Returns a `ProtoBlock` if the block is known **and** a descendant of the finalized root. diff --git a/packages/state-transition/src/block/index.ts b/packages/state-transition/src/block/index.ts index 71633419e828..0822024a04ce 100644 --- a/packages/state-transition/src/block/index.ts +++ b/packages/state-transition/src/block/index.ts @@ -14,7 +14,6 @@ import {processBlockHeader} from "./processBlockHeader.js"; import {processEth1Data} from "./processEth1Data.js"; import {processExecutionPayload} from "./processExecutionPayload.js"; import {processExecutionPayloadBid} from "./processExecutionPayloadBid.js"; -import {processExecutionPayloadEnvelope} from "./processExecutionPayloadEnvelope.js"; import {processOperations} from "./processOperations.js"; import {processParentExecutionPayload} from "./processParentExecutionPayload.js"; import {processPayloadAttestation} from "./processPayloadAttestation.js"; @@ -33,7 +32,6 @@ export { processWithdrawals, processExecutionPayloadBid, processPayloadAttestation, - processExecutionPayloadEnvelope, processParentExecutionPayload, }; diff --git a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts deleted file mode 100644 index 1a0de2ea2126..000000000000 --- a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts +++ /dev/null @@ -1,139 +0,0 @@ -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"; - -export type ProcessExecutionPayloadEnvelopeOpts = { - verifySignature?: boolean; -}; - -/** - * Pure verification of execution payload envelope — no state mutation. - * Payload processing is deferred to the next beacon block via processParentExecutionPayload. - * - * This function does not call the execution engine. That must be done separately. - * - * Spec: consensus-specs#5094 verify_execution_payload_envelope - */ -export function processExecutionPayloadEnvelope( - state: CachedBeaconStateGloas, - signedEnvelope: gloas.SignedExecutionPayloadEnvelope, - opts?: ProcessExecutionPayloadEnvelopeOpts -): void { - const {verifySignature = true} = opts ?? {}; - const envelope = signedEnvelope.message; - - if (verifySignature && !verifyExecutionPayloadEnvelopeSignature(state, signedEnvelope)) { - throw Error(`Execution payload envelope has invalid signature builderIndex=${envelope.builderIndex}`); - } - - validateExecutionPayloadEnvelope(state, envelope); -} - -function validateExecutionPayloadEnvelope( - state: CachedBeaconStateGloas, - envelope: gloas.ExecutionPayloadEnvelope -): void { - const payload = envelope.payload; - - // Cache latest block header state root - // Note: we read but do NOT mutate state — we compute the header root on a copy - let headerRoot: Uint8Array; - if (byteArrayEquals(state.latestBlockHeader.stateRoot, ssz.Root.defaultValue())) { - // Compute what the header root would be with the state root filled in - const header = ssz.phase0.BeaconBlockHeader.toViewDU(state.latestBlockHeader.toValue()); - header.stateRoot = state.hashTreeRoot(); - headerRoot = header.hashTreeRoot(); - } else { - headerRoot = state.latestBlockHeader.hashTreeRoot(); - } - - // Verify consistency with the beacon block - if (!byteArrayEquals(envelope.beaconBlockRoot, headerRoot)) { - throw new Error( - `Envelope's block is not the latest block header envelope=${toRootHex(envelope.beaconBlockRoot)} latestBlockHeader=${toRootHex(headerRoot)}` - ); - } - - if (payload.slotNumber !== state.slot) { - throw new Error(`Slot mismatch between payload and state payload=${payload.slotNumber} state=${state.slot}`); - } - - // Verify consistency with the committed bid - const committedBid = state.latestExecutionPayloadBid; - if (envelope.builderIndex !== committedBid.builderIndex) { - throw new Error( - `Builder index mismatch between envelope and committed bid envelope=${envelope.builderIndex} committedBid=${committedBid.builderIndex}` - ); - } - - if (!byteArrayEquals(committedBid.prevRandao, payload.prevRandao)) { - throw new Error( - `Prev randao mismatch between committed bid and payload committedBid=${toHex(committedBid.prevRandao)} payload=${toHex(payload.prevRandao)}` - ); - } - - // Verify consistency with expected withdrawals - const payloadWithdrawalsRoot = ssz.capella.Withdrawals.hashTreeRoot(payload.withdrawals); - const expectedWithdrawalsRoot = state.payloadExpectedWithdrawals.hashTreeRoot(); - if (!byteArrayEquals(payloadWithdrawalsRoot, expectedWithdrawalsRoot)) { - throw new Error( - `Withdrawals mismatch between payload and expected withdrawals payload=${toRootHex(payloadWithdrawalsRoot)} expected=${toRootHex(expectedWithdrawalsRoot)}` - ); - } - - // Verify the gas_limit - if (Number(committedBid.gasLimit) !== payload.gasLimit) { - throw new Error( - `Gas limit mismatch between envelope's payload and committed bid envelope=${payload.gasLimit} committedBid=${Number(committedBid.gasLimit)}` - ); - } - - // Verify the block hash - if (!byteArrayEquals(committedBid.blockHash, payload.blockHash)) { - throw new Error( - `Block hash mismatch between envelope's payload and committed bid envelope=${toRootHex(payload.blockHash)} committedBid=${toRootHex(committedBid.blockHash)}` - ); - } - - // Verify execution_requests_root matches bid commitment (consensus-specs#5094) - const requestsRoot = ssz.electra.ExecutionRequests.hashTreeRoot(envelope.executionRequests); - if (!byteArrayEquals(requestsRoot, committedBid.executionRequestsRoot)) { - throw new Error( - `Execution requests root mismatch envelope=${toRootHex(requestsRoot)} committedBid=${toRootHex(committedBid.executionRequestsRoot)}` - ); - } - - // Verify consistency of the parent hash with respect to the previous execution payload - if (!byteArrayEquals(payload.parentHash, state.latestBlockHash)) { - throw new Error( - `Parent hash mismatch between envelope's payload and state envelope=${toRootHex(payload.parentHash)} state=${toRootHex(state.latestBlockHash)}` - ); - } - - // Verify timestamp - if (payload.timestamp !== computeTimeAtSlot(state.config, state.slot, state.genesisTime)) { - throw new Error( - `Timestamp mismatch between envelope's payload and state envelope=${payload.timestamp} state=${computeTimeAtSlot(state.config, state.slot, state.genesisTime)}` - ); - } - - // Execution engine verification (verify_and_notify_new_payload) is done externally -} - -function verifyExecutionPayloadEnvelopeSignature( - state: CachedBeaconStateGloas, - signedEnvelope: gloas.SignedExecutionPayloadEnvelope -): boolean { - const signatureSet = getExecutionPayloadEnvelopeSignatureSet( - state.config, - state.epochCtx.pubkeyCache, - new BeaconStateView(state), - signedEnvelope, - state.latestBlockHeader.proposerIndex - ); - return verifySignatureSet(signatureSet); -} diff --git a/packages/state-transition/src/block/processParentExecutionPayload.ts b/packages/state-transition/src/block/processParentExecutionPayload.ts index 0560f75003fc..b639dca10b3d 100644 --- a/packages/state-transition/src/block/processParentExecutionPayload.ts +++ b/packages/state-transition/src/block/processParentExecutionPayload.ts @@ -46,9 +46,21 @@ export function processParentExecutionPayload( * * Spec: apply_parent_execution_payload */ -function applyParentExecutionPayload( +/** + * Settle a builder payment at the given index. + * Spec: settle_builder_payment + */ +function settleBuilderPayment(state: CachedBeaconStateGloas, paymentIndex: number): void { + const payment = state.builderPendingPayments.get(paymentIndex).clone(); + if (payment.withdrawal.amount > 0) { + state.builderPendingWithdrawals.push(payment.withdrawal); + } + state.builderPendingPayments.set(paymentIndex, ssz.gloas.BuilderPendingPayment.defaultViewDU()); +} + +export function applyParentExecutionPayload( state: CachedBeaconStateGloas, - parentBid: {slot: number; blockHash: Uint8Array; builderIndex: number}, + parentBid: {slot: number; blockHash: Uint8Array; builderIndex: number; value: number; feeRecipient: Uint8Array}, requests: electra.ExecutionRequests ): void { const fork = state.config.getForkSeq(state.slot); @@ -73,23 +85,21 @@ function applyParentExecutionPayload( processConsolidationRequest(state, consolidation); } - // Queue the builder payment - let paymentIndex: number | null; + // Settle the builder payment if (parentEpoch === currentEpoch) { - paymentIndex = SLOTS_PER_EPOCH + (parentSlot % SLOTS_PER_EPOCH); + settleBuilderPayment(state, SLOTS_PER_EPOCH + (parentSlot % SLOTS_PER_EPOCH)); } else if (parentEpoch === currentEpoch - 1) { - paymentIndex = parentSlot % SLOTS_PER_EPOCH; - } else { - // Parent is older than previous epoch — payment already settled/evicted - paymentIndex = null; - } - - if (paymentIndex !== null) { - const payment = state.builderPendingPayments.get(paymentIndex).clone(); - if (payment.withdrawal.amount > 0) { - state.builderPendingWithdrawals.push(payment.withdrawal); - } - state.builderPendingPayments.set(paymentIndex, ssz.gloas.BuilderPendingPayment.defaultViewDU()); + settleBuilderPayment(state, parentSlot % SLOTS_PER_EPOCH); + } else if (parentBid.value > 0) { + // Parent is older than previous epoch — payment entry already settled/evicted. + // Directly append the withdrawal to ensure the builder gets paid. + state.builderPendingWithdrawals.push( + ssz.gloas.BuilderPendingWithdrawal.toViewDU({ + feeRecipient: parentBid.feeRecipient, + amount: parentBid.value, + builderIndex: parentBid.builderIndex, + }) + ); } // Update parent payload availability and latest block hash diff --git a/packages/state-transition/src/block/processWithdrawals.ts b/packages/state-transition/src/block/processWithdrawals.ts index ad4036e0cd13..c30faf880fe9 100644 --- a/packages/state-transition/src/block/processWithdrawals.ts +++ b/packages/state-transition/src/block/processWithdrawals.ts @@ -50,7 +50,7 @@ export function processWithdrawals( } = getExpectedWithdrawals(fork, state); const numWithdrawals = expectedWithdrawals.length; - // After gloas, withdrawals are verified later in processExecutionPayloadEnvelope + // After gloas, withdrawals are verified later in verifyExecutionPayloadEnvelope if (fork < ForkSeq.gloas) { if (payload === undefined) { throw Error("payload is required for pre-gloas processWithdrawals"); diff --git a/packages/state-transition/src/stateView/beaconStateView.ts b/packages/state-transition/src/stateView/beaconStateView.ts index 5a881d3cb840..c9eed49599b7 100644 --- a/packages/state-transition/src/stateView/beaconStateView.ts +++ b/packages/state-transition/src/stateView/beaconStateView.ts @@ -28,8 +28,7 @@ import { rewards, } from "@lodestar/types"; import {Checkpoint, Fork} from "@lodestar/types/phase0"; -import {processExecutionPayloadEnvelope} from "../block/index.js"; -import {ProcessExecutionPayloadEnvelopeOpts} from "../block/processExecutionPayloadEnvelope.js"; +import {applyParentExecutionPayload} from "../block/processParentExecutionPayload.js"; import {VoluntaryExitValidity, getVoluntaryExitValidity} from "../block/processVoluntaryExit.js"; import {getExpectedWithdrawals} from "../block/processWithdrawals.js"; import {EffectiveBalanceIncrements} from "../cache/effectiveBalanceIncrements.js"; @@ -784,14 +783,14 @@ export class BeaconStateView implements IBeaconStateViewLatestFork { return new BeaconStateView(newState); } - verifyExecutionPayloadEnvelope( - signedEnvelope: gloas.SignedExecutionPayloadEnvelope, - opts?: ProcessExecutionPayloadEnvelopeOpts - ): void { - const fork = this.config.getForkName(this.cachedState.slot); - if (!isForkPostGloas(fork)) { - throw Error(`verifyExecutionPayloadEnvelope is only available for gloas+ forks, got fork=${fork}`); + getExpectedWithdrawalsForFullParent(envelope: gloas.SignedExecutionPayloadEnvelope): capella.Withdrawal[] { + const fork = this.config.getForkSeq(this.cachedState.slot); + if (!isForkPostGloas(this.config.getForkName(this.cachedState.slot))) { + throw Error("getExpectedWithdrawalsForFullParent is only available for gloas+ forks"); } - processExecutionPayloadEnvelope(this.cachedState as CachedBeaconStateGloas, signedEnvelope, opts); + const stateCopy = this.cachedState.clone(true) as CachedBeaconStateGloas; + applyParentExecutionPayload(stateCopy, stateCopy.latestExecutionPayloadBid, envelope.message.executionRequests); + const {expectedWithdrawals} = getExpectedWithdrawals(fork, stateCopy); + return expectedWithdrawals; } } diff --git a/packages/state-transition/src/stateView/interface.ts b/packages/state-transition/src/stateView/interface.ts index ad507316aad7..45672a11aa24 100644 --- a/packages/state-transition/src/stateView/interface.ts +++ b/packages/state-transition/src/stateView/interface.ts @@ -41,7 +41,6 @@ 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"; @@ -254,10 +253,12 @@ export interface IBeaconStateViewGloas extends IBeaconStateViewFulu { getBuilder(index: BuilderIndex): gloas.Builder; canBuilderCoverBid(builderIndex: BuilderIndex, bidAmount: number): boolean; getIndexInPayloadTimelinessCommittee(validatorIndex: ValidatorIndex, slot: Slot): number; - verifyExecutionPayloadEnvelope( - signedEnvelope: gloas.SignedExecutionPayloadEnvelope, - opts?: ProcessExecutionPayloadEnvelopeOpts - ): void; + /** + * Compute expected withdrawals as if the parent was FULL. + * Clones the state, applies parent payload effects, then computes withdrawals. + * Used by prepare_execution_payload when building on FULL parent. + */ + getExpectedWithdrawalsForFullParent(envelope: gloas.SignedExecutionPayloadEnvelope): capella.Withdrawal[]; } /** From e10d287956b63d0d34c82da016d7c1a1b2e44f6d Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:19:22 -0700 Subject: [PATCH 28/67] fix upgrade state --- packages/state-transition/src/slot/upgradeStateToGloas.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/state-transition/src/slot/upgradeStateToGloas.ts b/packages/state-transition/src/slot/upgradeStateToGloas.ts index a269dba199c1..9c29884ff353 100644 --- a/packages/state-transition/src/slot/upgradeStateToGloas.ts +++ b/packages/state-transition/src/slot/upgradeStateToGloas.ts @@ -48,6 +48,8 @@ export function upgradeStateToGloas(stateFulu: CachedBeaconStateFulu): CachedBea stateGloasView.currentSyncCommittee = stateGloasCloned.currentSyncCommittee; stateGloasView.nextSyncCommittee = stateGloasCloned.nextSyncCommittee; stateGloasView.latestExecutionPayloadBid.blockHash = stateFulu.latestExecutionPayloadHeader.blockHash; + stateGloasView.latestExecutionPayloadBid.executionRequestsRoot = + ssz.electra.ExecutionRequests.hashTreeRoot(ssz.electra.ExecutionRequests.defaultValue()); stateGloasView.nextWithdrawalIndex = stateGloasCloned.nextWithdrawalIndex; stateGloasView.nextWithdrawalValidatorIndex = stateGloasCloned.nextWithdrawalValidatorIndex; stateGloasView.historicalSummaries = stateGloasCloned.historicalSummaries; From 5421487df576d33d086e05b366202426abecd0d9 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Sun, 19 Apr 2026 22:42:49 -0700 Subject: [PATCH 29/67] fix spec test --- .../beacon-node/src/chain/forkChoice/index.ts | 4 +- .../test/spec/presets/fork_choice.test.ts | 46 ++++++++++++++----- .../fork-choice/src/protoArray/protoArray.ts | 19 +------- .../src/block/processWithdrawals.ts | 14 ++++-- .../src/util/computeAnchorCheckpoint.ts | 21 +++------ 5 files changed, 53 insertions(+), 51 deletions(-) diff --git a/packages/beacon-node/src/chain/forkChoice/index.ts b/packages/beacon-node/src/chain/forkChoice/index.ts index 6e274acd2175..8f07db7a6c31 100644 --- a/packages/beacon-node/src/chain/forkChoice/index.ts +++ b/packages/beacon-node/src/chain/forkChoice/index.ts @@ -161,7 +161,7 @@ export function initializeForkChoiceFromFinalizedState( : {executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}), 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? + payloadStatus: isForkPostGloas ? PayloadStatus.EMPTY : PayloadStatus.FULL, // TODO GLOAS: Post-gloas how do we know if the checkpoint payload is FULL or EMPTY? parentBlockHash: isStatePostGloas(state) ? toRootHex(state.latestBlockHash) : null, }, currentSlot @@ -260,7 +260,7 @@ export function initializeForkChoiceFromUnfinalizedState( : {executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}), 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? + payloadStatus: isForkPostGloas ? PayloadStatus.EMPTY : PayloadStatus.FULL, // TODO GLOAS: Post-gloas how do we know if the checkpoint payload is FULL or EMPTY? parentBlockHash: isStatePostGloas(unfinalizedState) ? toRootHex(unfinalizedState.latestBlockHash) : null, }; diff --git a/packages/beacon-node/test/spec/presets/fork_choice.test.ts b/packages/beacon-node/test/spec/presets/fork_choice.test.ts index c37c4aec1eef..4538b95d4a6f 100644 --- a/packages/beacon-node/test/spec/presets/fork_choice.test.ts +++ b/packages/beacon-node/test/spec/presets/fork_choice.test.ts @@ -20,6 +20,7 @@ import {InputType} from "@lodestar/spec-test-util"; import { BeaconStateAllForks, BeaconStateView, + IBeaconStateViewGloas, createCachedBeaconState, createPubkeyCache, isExecutionStateType, @@ -47,8 +48,13 @@ import { BlockInputPreData, BlockInputSource, } from "../../../src/chain/blocks/blockInput/index.js"; +import { + verifyExecutionPayloadEnvelope, + verifyExecutionPayloadEnvelopeSignature, +} from "../../../src/chain/blocks/verifyExecutionPayloadEnvelope.js"; import {AttestationImportOpt, BlobSidecarValidation} from "../../../src/chain/blocks/types.js"; import {BeaconChain, ChainEvent} from "../../../src/chain/index.js"; +import {RegenCaller} from "../../../src/chain/regen/index.js"; import {defaultChainOptions} from "../../../src/chain/options.js"; import {validateFuluBlockDataColumnSidecars} from "../../../src/chain/validation/dataColumnSidecar.js"; import {ZERO_HASH_HEX} from "../../../src/constants/constants.js"; @@ -382,6 +388,32 @@ const forkChoiceTest = const blockHash = toHex(envelope.message.payload.blockHash); const blockNumber = envelope.message.payload.blockNumber; + // Verify envelope against the post-block state (spec: verify_execution_payload_envelope) + const protoBlock = (chain.forkChoice as ForkChoice).getBlockHexDefaultStatus(beaconBlockRoot); + if (!protoBlock) throw Error(`Block not found for root ${beaconBlockRoot}`); + const envelopeState = await chain.regen.getBlockSlotState( + protoBlock, + protoBlock.slot, + {dontTransferCache: true}, + RegenCaller.restApi + ); + verifyExecutionPayloadEnvelope( + beaconConfig, + envelopeState as IBeaconStateViewGloas, + envelope.message + ); + + // Verify signature + const sigValid = await verifyExecutionPayloadEnvelopeSignature( + beaconConfig, + envelopeState as IBeaconStateViewGloas, + pubkeyCache, + envelope, + envelopeState.latestBlockHeader.proposerIndex, + chain.bls + ); + if (!sigValid) throw Error("Invalid execution payload envelope signature"); + // Add predefined VALID status for the payload's block hash so the EL mock accepts it executionEngineBackend.addPredefinedPayloadStatus(blockHash, { status: ExecutionPayloadStatus.VALID, @@ -587,17 +619,9 @@ const forkChoiceTest = name.includes("voting_source_beyond_two_epoch") || name.includes("justified_update_always_if_better") || name.includes("justified_update_not_realized_finality") || - // TODO GLOAS: These tests will be unskipped by https://github.com/ChainSafe/lodestar/pull/9233 - (name.includes("gloas") && - (name.includes("simple_attempted_reorg_without_enough_ffg_votes") || - name.includes("include_votes_another_empty_chain_with_enough_ffg_votes_current_epoch") || - name.includes("include_votes_another_empty_chain_without_enough_ffg_votes_current_epoch"))) || - // TODO GLOAS: These two tests are affected by the wrong proposer boost cutoff time from the - // consensus-specs and thus have wrong expectation of proposer boost. Our implementation - // should pass these two tests after https://github.com/ethereum/consensus-specs/pull/5095 - // is included the spec release. - (name.includes("gloas/fork_choice/on_block") && - (name.endsWith("proposer_boost") || name.endsWith("proposer_boost_is_first_block"))), + // Spec test fixture bug in v1.7.0-alpha.5: wrong_withdrawals envelope SSZ data is + // byte-for-byte identical to the valid envelope, making it impossible to reject + name.endsWith("on_execution_payload_envelope__wrong_withdrawals"), }, }; }; diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 0aab4d33a1d4..5b162c14b644 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -111,26 +111,9 @@ export class ProtoArray { // Anchor block PTC votes must be all-true per spec get_forkchoice_store: // payload_timeliness_vote={anchor_root: Vector[boolean, PTC_SIZE](True for _ in range(PTC_SIZE))} - // Spec: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.4/specs/gloas/fork-choice.md#modified-get_forkchoice_store + // Spec: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/gloas/fork-choice.md#modified-get_forkchoice_store if (protoArray.ptcVotes.has(block.blockRoot)) { protoArray.ptcVotes.set(block.blockRoot, BitArray.fromBoolArray(Array.from({length: PTC_SIZE}, () => true))); - - // In the spec, we have payload_states = {anchor_root: anchor_state.copy()} - // which means the anchor's "payload" is considered received - // Without FULL, blocks extending FULL from the anchor would be orphaned. - // TODO GLOAS: This is a bug in the spec. Keep this to pass the current spec test - // for now. Need to remove this when we work on v1.7.0-alpha.5 - if (block.executionPayloadBlockHash !== null) { - protoArray.onExecutionPayload( - block.blockRoot, - currentSlot, - block.executionPayloadBlockHash, - (block as {executionPayloadNumber?: number}).executionPayloadNumber ?? 0, - block.stateRoot, - null, - ExecutionStatus.Valid - ); - } } return protoArray; diff --git a/packages/state-transition/src/block/processWithdrawals.ts b/packages/state-transition/src/block/processWithdrawals.ts index c30faf880fe9..e57041d1a1c4 100644 --- a/packages/state-transition/src/block/processWithdrawals.ts +++ b/packages/state-transition/src/block/processWithdrawals.ts @@ -10,6 +10,7 @@ import { } from "@lodestar/params"; import {BuilderIndex, ValidatorIndex, capella, ssz} from "@lodestar/types"; import {byteArrayEquals, toRootHex} from "@lodestar/utils"; +import {ZERO_HASH} from "../constants/index.js"; import {CachedBeaconStateCapella, CachedBeaconStateElectra, CachedBeaconStateGloas} from "../types.js"; import { convertBuilderIndexToValidatorIndex, @@ -31,11 +32,14 @@ export function processWithdrawals( state: CachedBeaconStateCapella | CachedBeaconStateElectra | CachedBeaconStateGloas, payload?: capella.FullOrBlindedExecutionPayload ): void { - // Return early if the parent block is empty. - // After processParentExecutionPayload runs, latestBlockHash is updated only if parent was FULL. - // If still mismatched, the parent was EMPTY and no withdrawals should be computed. - if (fork >= ForkSeq.gloas && !isParentBlockFull(state as CachedBeaconStateGloas)) { - return; + // Return early if this is genesis block or the parent block is empty. + if (fork >= ForkSeq.gloas) { + const stateGloas = state as CachedBeaconStateGloas; + const isGenesisBlock = byteArrayEquals(stateGloas.latestBlockHash, ZERO_HASH); + const isParentBlockEmpty = !isParentBlockFull(stateGloas); + if (isGenesisBlock || isParentBlockEmpty) { + return; + } } // processedBuilderWithdrawalsCount is withdrawals coming from builder payment since gloas (EIP-7732) diff --git a/packages/state-transition/src/util/computeAnchorCheckpoint.ts b/packages/state-transition/src/util/computeAnchorCheckpoint.ts index 1edb2ac57ca2..8bfe9a88efea 100644 --- a/packages/state-transition/src/util/computeAnchorCheckpoint.ts +++ b/packages/state-transition/src/util/computeAnchorCheckpoint.ts @@ -1,30 +1,21 @@ import {ChainForkConfig} from "@lodestar/config"; -import {GENESIS_SLOT, ZERO_HASH} from "@lodestar/params"; +import {ZERO_HASH} from "@lodestar/params"; import {phase0, ssz} from "@lodestar/types"; import {BeaconStateAllForks} from "../types.js"; -import {blockToHeader} from "./blockRoot.js"; import {computeCheckpointEpochAtStateSlot} from "./epoch.js"; export function computeAnchorCheckpoint( - config: ChainForkConfig, + _config: ChainForkConfig, anchorState: BeaconStateAllForks ): {checkpoint: phase0.Checkpoint; blockHeader: phase0.BeaconBlockHeader} { let blockHeader: phase0.BeaconBlockHeader; let root: Uint8Array; - const blockTypes = config.getForkTypes(anchorState.latestBlockHeader.slot); - if (anchorState.latestBlockHeader.slot === GENESIS_SLOT) { - const block = blockTypes.BeaconBlock.defaultValue(); - block.stateRoot = anchorState.hashTreeRoot(); - blockHeader = blockToHeader(config, block); - root = ssz.phase0.BeaconBlockHeader.hashTreeRoot(blockHeader); - } else { - blockHeader = ssz.phase0.BeaconBlockHeader.clone(anchorState.latestBlockHeader); - if (ssz.Root.equals(blockHeader.stateRoot, ZERO_HASH)) { - blockHeader.stateRoot = anchorState.hashTreeRoot(); - } - root = ssz.phase0.BeaconBlockHeader.hashTreeRoot(blockHeader); + blockHeader = ssz.phase0.BeaconBlockHeader.clone(anchorState.latestBlockHeader); + if (ssz.Root.equals(blockHeader.stateRoot, ZERO_HASH)) { + blockHeader.stateRoot = anchorState.hashTreeRoot(); } + root = ssz.phase0.BeaconBlockHeader.hashTreeRoot(blockHeader); return { checkpoint: { From b5937ecf5b223d95d6adcaead9fa9aeed3affe14 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Wed, 22 Apr 2026 00:08:03 -0700 Subject: [PATCH 30/67] Harmonize --- .../chain/blocks/importExecutionPayload.ts | 19 +++++++++++-------- .../blocks/verifyExecutionPayloadEnvelope.ts | 8 ++++---- .../chain/produceBlock/produceBlockBody.ts | 13 +++++++++---- .../test/spec/presets/fork_choice.test.ts | 5 +++++ .../test/spec/presets/operations.test.ts | 4 ++-- .../test/spec/utils/specTestIterator.ts | 8 -------- .../fork-choice/src/forkChoice/forkChoice.ts | 4 ---- .../fork-choice/src/forkChoice/interface.ts | 6 ------ .../block/processParentExecutionPayload.ts | 5 +---- .../src/slot/upgradeStateToGloas.ts | 5 +++-- .../src/util/computeAnchorCheckpoint.ts | 4 +--- 11 files changed, 36 insertions(+), 45 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index 84b033c6d1b2..7653032a1c07 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -8,7 +8,10 @@ import {BeaconChain} from "../chain.js"; import {RegenCaller} from "../regen/interface.js"; import {PayloadEnvelopeInput} from "../seenCache/seenPayloadEnvelopeInput.js"; import {ImportPayloadOpts} from "./types.js"; -import {verifyExecutionPayloadEnvelope, verifyExecutionPayloadEnvelopeSignature} from "./verifyExecutionPayloadEnvelope.js"; +import { + verifyExecutionPayloadEnvelope, + verifyExecutionPayloadEnvelopeSignature, +} from "./verifyExecutionPayloadEnvelope.js"; import {verifyPayloadsDataAvailability} from "./verifyPayloadsDataAvailability.js"; const EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS = 64; @@ -94,12 +97,12 @@ export async function importExecutionPayload( const envelope = signedEnvelope.message; const blockRootHex = payloadInput.blockRootHex; const blockHashHex = payloadInput.getBlockHashHex(); - const fork = this.config.getForkName(envelope.slot); + const fork = this.config.getForkName(envelope.payload.slotNumber); // 1. Emit `execution_payload_available` event at the start of import - if (this.clock.currentSlot - envelope.slot < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { + if (this.clock.currentSlot - envelope.payload.slotNumber < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { this.emitter.emit(routes.events.EventType.executionPayloadAvailable, { - slot: envelope.slot, + slot: envelope.payload.slotNumber, blockRoot: blockRootHex, }); } @@ -211,7 +214,7 @@ export async function importExecutionPayload( if (!isQueueErrorAborted(e)) { this.logger.error( "Error pushing payload envelope to unfinalized write queue", - {slot: envelope.slot, blockRoot: blockRootHex}, + {slot: envelope.payload.slotNumber, blockRoot: blockRootHex}, e as Error ); } @@ -228,9 +231,9 @@ export async function importExecutionPayload( } // 9. Emit event after payload is fully verified and imported to fork choice - if (this.clock.currentSlot - envelope.slot < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { + if (this.clock.currentSlot - envelope.payload.slotNumber < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { this.emitter.emit(routes.events.EventType.executionPayload, { - slot: envelope.slot, + slot: envelope.payload.slotNumber, builderIndex: envelope.builderIndex, blockHash: blockHashHex, blockRoot: blockRootHex, @@ -240,7 +243,7 @@ export async function importExecutionPayload( } this.logger.verbose("Execution payload imported", { - slot: envelope.slot, + slot: envelope.payload.slotNumber, builderIndex: envelope.builderIndex, blockRoot: blockRootHex, blockHash: blockHashHex, diff --git a/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts b/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts index 428c22188408..67cd6dfa8849 100644 --- a/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts @@ -1,12 +1,12 @@ import {BeaconConfig} from "@lodestar/config"; -import {gloas, ssz} from "@lodestar/types"; -import {byteArrayEquals, toHex, toRootHex} from "@lodestar/utils"; import { type IBeaconStateViewGloas, type PubkeyCache, computeTimeAtSlot, getExecutionPayloadEnvelopeSignatureSet, } from "@lodestar/state-transition"; +import {gloas, ssz} from "@lodestar/types"; +import {byteArrayEquals, toHex, toRootHex} from "@lodestar/utils"; import {IBlsVerifier} from "../bls/index.js"; export type VerifyExecutionPayloadEnvelopeOpts = { @@ -42,8 +42,8 @@ export function verifyExecutionPayloadEnvelope( ); } - if (envelope.slot !== state.slot) { - throw new Error(`Slot mismatch between envelope and state envelope=${envelope.slot} state=${state.slot}`); + if (payload.slotNumber !== state.slot) { + throw new Error(`Slot mismatch between payload and state payload=${payload.slotNumber} state=${state.slot}`); } // Verify consistency with the committed bid diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index a756b900fe9a..9dd1e7175723 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -18,7 +18,6 @@ import { G2_POINT_AT_INFINITY, IBeaconStateView, type IBeaconStateViewBellatrix, - type IBeaconStateViewGloas, computeTimeAtSlot, isStatePostBellatrix, isStatePostCapella, @@ -616,7 +615,11 @@ export async function prepareExecutionPayload( executionEngine: IExecutionEngine; config: ChainForkConfig; forkChoice?: IForkChoice; - seenPayloadEnvelopeInputCache?: {get(rootHex: string): {hasPayloadEnvelope(): boolean; getPayloadEnvelope(): gloas.SignedExecutionPayloadEnvelope} | undefined}; + seenPayloadEnvelopeInputCache?: { + get( + rootHex: string + ): {hasPayloadEnvelope(): boolean; getPayloadEnvelope(): gloas.SignedExecutionPayloadEnvelope} | undefined; + }; }, logger: Logger, fork: ForkPostBellatrix, @@ -627,7 +630,7 @@ export async function prepareExecutionPayload( state: IBeaconStateViewBellatrix, suggestedFeeRecipient: string ): Promise<{prepType: PayloadPreparationType; payloadId: PayloadId}> { - let parentHash = state.latestBlockHash; + let parentHash = parentBlockHash; let withdrawalsOverride: capella.Withdrawal[] | undefined; // For Gloas: determine FULL vs EMPTY parent per spec's prepare_execution_payload @@ -651,7 +654,9 @@ export async function prepareExecutionPayload( if (payloadInput?.hasPayloadEnvelope()) { // Build on FULL variant: apply parent payload to compute correct withdrawals, // use bid.blockHash as EL head (per spec's prepare_execution_payload) - const gloasView = state as unknown as IBeaconStateViewGloas; + const gloasView = state as unknown as { + getExpectedWithdrawalsForFullParent(envelope: gloas.SignedExecutionPayloadEnvelope): capella.Withdrawal[]; + }; withdrawalsOverride = gloasView.getExpectedWithdrawalsForFullParent(payloadInput.getPayloadEnvelope()); parentHash = gloasState.latestExecutionPayloadBid.blockHash; } diff --git a/packages/beacon-node/test/spec/presets/fork_choice.test.ts b/packages/beacon-node/test/spec/presets/fork_choice.test.ts index 4538b95d4a6f..9975fe8a5d64 100644 --- a/packages/beacon-node/test/spec/presets/fork_choice.test.ts +++ b/packages/beacon-node/test/spec/presets/fork_choice.test.ts @@ -619,6 +619,11 @@ const forkChoiceTest = name.includes("voting_source_beyond_two_epoch") || name.includes("justified_update_always_if_better") || name.includes("justified_update_not_realized_finality") || + // TODO GLOAS: These tests will be unskipped by https://github.com/ChainSafe/lodestar/pull/9233 + (name.includes("gloas") && + (name.includes("simple_attempted_reorg_without_enough_ffg_votes") || + name.includes("include_votes_another_empty_chain_with_enough_ffg_votes_current_epoch") || + name.includes("include_votes_another_empty_chain_without_enough_ffg_votes_current_epoch"))) || // Spec test fixture bug in v1.7.0-alpha.5: wrong_withdrawals envelope SSZ data is // byte-for-byte identical to the valid envelope, making it impossible to reject name.endsWith("on_execution_payload_envelope__wrong_withdrawals"), diff --git a/packages/beacon-node/test/spec/presets/operations.test.ts b/packages/beacon-node/test/spec/presets/operations.test.ts index 4f97fd26d79f..8faeeb543492 100644 --- a/packages/beacon-node/test/spec/presets/operations.test.ts +++ b/packages/beacon-node/test/spec/presets/operations.test.ts @@ -10,11 +10,11 @@ import { CachedBeaconStateElectra, CachedBeaconStateGloas, ExecutionPayloadStatus, - processSlots, getBlockRootAtSlot, + processSlots, } from "@lodestar/state-transition"; -import * as blockFns from "@lodestar/state-transition/block"; import {AttesterSlashing, altair, bellatrix, capella, electra, gloas, phase0, ssz, sszTypesFor} from "@lodestar/types"; +import * as blockFns from "../../../../state-transition/src/block/index.js"; import {createCachedBeaconStateTest} from "../../utils/cachedBeaconState.js"; import {ethereumConsensusSpecsTests} from "../specTestVersioning.js"; import {expectEqualBeaconState, inputTypeSszTreeViewDU} from "../utils/expectEqualBeaconState.js"; diff --git a/packages/beacon-node/test/spec/utils/specTestIterator.ts b/packages/beacon-node/test/spec/utils/specTestIterator.ts index 0dd85ca17d18..803f41432d55 100644 --- a/packages/beacon-node/test/spec/utils/specTestIterator.ts +++ b/packages/beacon-node/test/spec/utils/specTestIterator.ts @@ -77,16 +77,8 @@ export const defaultSkipOpts: SkipOpts = { // cell level DAS is ready /^fulu\/ssz_static\/PartialDataColumn(Header|PartsMetadata|Sidecar)\/.*$/, /^gloas\/ssz_static\/PartialDataColumn(Header|PartsMetadata|Sidecar)\/.*$/, - // TODO GLOAS: Unskip these tests as we get closer to alpha.5 ready - /^gloas\/fork_choice\/.*$/, - /^gloas\/fork\/.*$/, - /^gloas\/transition\/.*$/, - /^gloas\/operations\/parent_execution_payload\/.*$/, ], skippedTests: [ - // TODO GLOAS: Unskip these tests as we get closer to alpha.5 ready - /^gloas\/sanity\/blocks\/pyspec_tests\/builder_payment_after_missed_epochs$/, - /^gloas\/operations\/withdrawals\/pyspec_tests\/zero_hash_genesis_skips_withdrawals$/, ], // TODO GLOAS: Investigate why networking tests are failing since alpha.5 skippedRunners: ["fast_confirmation", "networking"], diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 73544259d832..eeabef013348 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -1082,10 +1082,6 @@ export class ForkChoice implements IForkChoice { return this.protoArray.hasPayload(blockRoot); } - shouldExtendPayload(blockRoot: RootHex): boolean { - return this.protoArray.shouldExtendPayload(blockRoot, this.proposerBoostRoot); - } - /** * Returns a MUTABLE `ProtoBlock` if the block is known **and** a descendant of the finalized root. */ diff --git a/packages/fork-choice/src/forkChoice/interface.ts b/packages/fork-choice/src/forkChoice/interface.ts index 5b1a8f8328f7..2f8670dd054f 100644 --- a/packages/fork-choice/src/forkChoice/interface.ts +++ b/packages/fork-choice/src/forkChoice/interface.ts @@ -230,12 +230,6 @@ export interface IForkChoice { */ hasPayloadUnsafe(blockRoot: Root): boolean; hasPayloadHexUnsafe(blockRoot: RootHex): boolean; - /** - * Whether to extend the payload for a given block root. - * Checks PTC timeliness and data availability, with fallback logic. - * Spec: gloas/fork-choice.md#should_extend_payload - */ - shouldExtendPayload(blockRoot: RootHex): boolean; getSlotsPresent(windowStart: number): number; /** * Returns a `ProtoBlock` if the block is known **and** a descendant of the finalized root. diff --git a/packages/state-transition/src/block/processParentExecutionPayload.ts b/packages/state-transition/src/block/processParentExecutionPayload.ts index b639dca10b3d..b88781923115 100644 --- a/packages/state-transition/src/block/processParentExecutionPayload.ts +++ b/packages/state-transition/src/block/processParentExecutionPayload.ts @@ -13,10 +13,7 @@ import {processWithdrawalRequest} from "./processWithdrawalRequest.js"; * Spec: consensus-specs#5094 * https://github.com/ethereum/consensus-specs/blob/26ed32e/specs/gloas/beacon-chain.md */ -export function processParentExecutionPayload( - state: CachedBeaconStateGloas, - block: BeaconBlock -): void { +export function processParentExecutionPayload(state: CachedBeaconStateGloas, block: BeaconBlock): void { const bid = block.body.signedExecutionPayloadBid.message; const parentBid = state.latestExecutionPayloadBid; const requests = block.body.parentExecutionRequests; diff --git a/packages/state-transition/src/slot/upgradeStateToGloas.ts b/packages/state-transition/src/slot/upgradeStateToGloas.ts index 9c29884ff353..f4bc7ad3ec55 100644 --- a/packages/state-transition/src/slot/upgradeStateToGloas.ts +++ b/packages/state-transition/src/slot/upgradeStateToGloas.ts @@ -48,8 +48,9 @@ export function upgradeStateToGloas(stateFulu: CachedBeaconStateFulu): CachedBea stateGloasView.currentSyncCommittee = stateGloasCloned.currentSyncCommittee; stateGloasView.nextSyncCommittee = stateGloasCloned.nextSyncCommittee; stateGloasView.latestExecutionPayloadBid.blockHash = stateFulu.latestExecutionPayloadHeader.blockHash; - stateGloasView.latestExecutionPayloadBid.executionRequestsRoot = - ssz.electra.ExecutionRequests.hashTreeRoot(ssz.electra.ExecutionRequests.defaultValue()); + stateGloasView.latestExecutionPayloadBid.executionRequestsRoot = ssz.electra.ExecutionRequests.hashTreeRoot( + ssz.electra.ExecutionRequests.defaultValue() + ); stateGloasView.nextWithdrawalIndex = stateGloasCloned.nextWithdrawalIndex; stateGloasView.nextWithdrawalValidatorIndex = stateGloasCloned.nextWithdrawalValidatorIndex; stateGloasView.historicalSummaries = stateGloasCloned.historicalSummaries; diff --git a/packages/state-transition/src/util/computeAnchorCheckpoint.ts b/packages/state-transition/src/util/computeAnchorCheckpoint.ts index 8bfe9a88efea..d262aa4500df 100644 --- a/packages/state-transition/src/util/computeAnchorCheckpoint.ts +++ b/packages/state-transition/src/util/computeAnchorCheckpoint.ts @@ -9,17 +9,15 @@ export function computeAnchorCheckpoint( anchorState: BeaconStateAllForks ): {checkpoint: phase0.Checkpoint; blockHeader: phase0.BeaconBlockHeader} { let blockHeader: phase0.BeaconBlockHeader; - let root: Uint8Array; blockHeader = ssz.phase0.BeaconBlockHeader.clone(anchorState.latestBlockHeader); if (ssz.Root.equals(blockHeader.stateRoot, ZERO_HASH)) { blockHeader.stateRoot = anchorState.hashTreeRoot(); } - root = ssz.phase0.BeaconBlockHeader.hashTreeRoot(blockHeader); return { checkpoint: { - root, + root: ssz.phase0.BeaconBlockHeader.hashTreeRoot(blockHeader), // the checkpoint epoch = computeEpochAtSlot(anchorState.slot) + 1 if slot is not at epoch boundary // this is similar to a process_slots() call epoch: computeCheckpointEpochAtStateSlot(anchorState.slot), From 941a7108b0c429545d3d0b0b4bc54df69642c4ee Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 22 Apr 2026 15:29:29 +0100 Subject: [PATCH 31/67] review --- .../chain/blocks/importExecutionPayload.ts | 11 ++-- .../beacon-node/src/chain/blocks/index.ts | 1 - .../beacon-node/src/chain/blocks/types.ts | 34 ++++-------- .../blocks/verifyExecutionPayloadEnvelope.ts | 30 ++++------- packages/beacon-node/src/chain/chain.ts | 2 +- .../chain/produceBlock/produceBlockBody.ts | 3 +- .../fork-choice/src/protoArray/protoArray.ts | 2 - packages/state-transition/src/block/index.ts | 10 ++-- .../block/processParentExecutionPayload.ts | 54 ++++++++++--------- packages/types/src/gloas/sszTypes.ts | 2 +- 10 files changed, 65 insertions(+), 84 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index 7653032a1c07..d76c9106d241 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -72,9 +72,8 @@ function toForkChoiceExecutionStatus(status: ExecutionPayloadStatus): PayloadExe /** * Import an execution payload envelope after all data is available. * - * With deferred processing (consensus-specs#5094), the envelope is purely verified - * here — no state mutation. State effects are applied in the next block via - * processParentExecutionPayload. + * The envelope is purely verified here, no state mutation. State effects from the + * payload are applied on the next block via processParentExecutionPayload. * * Steps: * 1. Emit `execution_payload_available` for payload attestation @@ -83,7 +82,7 @@ function toForkChoiceExecutionStatus(status: ExecutionPayloadStatus): PayloadExe * 4. Regenerate block state for envelope field validation * 5. Run EL verification and signature verification in parallel, plus pure envelope verification * 6. Persist verified payload envelope to hot DB - * 7. Update fork choice (no stateRoot — FULL shares PENDING's stateRoot) + * 7. Update fork choice (transitions PENDING -> FULL) * 8. Record metrics * 9. Emit `execution_payload` event */ @@ -164,7 +163,7 @@ export async function importExecutionPayload( // 5a. Verify envelope fields against state (spec: verify_execution_payload_envelope) try { // When validSignature is true, the envelope came from gossip/API where both - // signature and executionRequestsRoot were already verified — skip re-hashing + // signature and executionRequestsRoot were already verified, skip re-hashing verifyExecutionPayloadEnvelope(this.config, blockState, envelope, { verifyExecutionRequestsRoot: !opts.validSignature, }); @@ -220,7 +219,7 @@ export async function importExecutionPayload( } }); - // 7. Update fork choice — no separate stateRoot since envelope doesn't produce post-state + // 7. Update fork choice, transitions the block's PENDING variant to FULL const execStatus = toForkChoiceExecutionStatus(execResult.status); this.forkChoice.onExecutionPayload(blockRootHex, blockHashHex, envelope.payload.blockNumber, execStatus); diff --git a/packages/beacon-node/src/chain/blocks/index.ts b/packages/beacon-node/src/chain/blocks/index.ts index 787d283f758b..b9120acae350 100644 --- a/packages/beacon-node/src/chain/blocks/index.ts +++ b/packages/beacon-node/src/chain/blocks/index.ts @@ -89,7 +89,6 @@ export async function processBlocks( (block, i): FullyVerifiedBlock => ({ blockInput: block, postState: postStates[i], - postPayloadState: null, parentBlockSlot: parentSlots[i], executionStatus: executionStatuses[i], // start supporting optimistic syncing/processing diff --git a/packages/beacon-node/src/chain/blocks/types.ts b/packages/beacon-node/src/chain/blocks/types.ts index 83e8a023dc10..03d37ccb526c 100644 --- a/packages/beacon-node/src/chain/blocks/types.ts +++ b/packages/beacon-node/src/chain/blocks/types.ts @@ -1,5 +1,5 @@ import type {ChainForkConfig} from "@lodestar/config"; -import {BlockExecutionStatus, PayloadExecutionStatus} from "@lodestar/fork-choice"; +import {BlockExecutionStatus} from "@lodestar/fork-choice"; import {ForkSeq} from "@lodestar/params"; import {DataAvailabilityStatus, IBeaconStateView, computeEpochAtSlot} from "@lodestar/state-transition"; import type {IndexedAttestation, Slot, fulu} from "@lodestar/types"; @@ -88,7 +88,14 @@ export type ImportBlockOpts = { seenTimestampSec?: number; }; -type FullyVerifiedBlockBase = { +/** + * A wrapper around a `SignedBeaconBlock` that indicates that this block is fully verified and ready to import. + * + * `executionStatus` reflects the outcome of execution payload verification at block-import time: + * - pre-gloas: Valid | Syncing | PreMerge (from EL notifyNewPayload against the in-block payload) + * - post-gloas: PayloadSeparated (payload arrives separately as an envelope and is imported later) + */ +export type FullyVerifiedBlock = { blockInput: IBlockInput; postState: IBeaconStateView; parentBlockSlot: Slot; @@ -98,25 +105,6 @@ type FullyVerifiedBlockBase = { indexedAttestations: IndexedAttestation[]; /** Seen timestamp seconds */ seenTimestampSec: number; + /** If the execution payload couldn't be verified because of EL syncing status, used in optimistic sync or for merge block */ + executionStatus: BlockExecutionStatus; }; - -/** - * A wrapper around a `SignedBeaconBlock` that indicates that this block is fully verified and ready to import. - * - * Discriminated union on `postPayloadState`: - * - `null` → block has no pre-verified envelope; `executionStatus` is any `BlockExecutionStatus` - * - non-null → envelope was pre-verified during state transition; `executionStatus` is narrowed to - * `Valid | Syncing` (matching what `forkChoice.onExecutionPayload` expects) - */ -export type FullyVerifiedBlock = FullyVerifiedBlockBase & - ( - | { - postPayloadState: null; - /** If the execution payload couldn't be verified because of EL syncing status, used in optimistic sync or for merge block */ - executionStatus: BlockExecutionStatus; - } - | { - postPayloadState: IBeaconStateView; - executionStatus: PayloadExecutionStatus; - } - ); diff --git a/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts b/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts index 67cd6dfa8849..fafc5e60980e 100644 --- a/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts @@ -15,9 +15,12 @@ export type VerifyExecutionPayloadEnvelopeOpts = { /** * Verify execution payload envelope fields against the post-block state. - * Does NOT verify signature (done separately) or call the execution engine. * - * Spec: gloas/fork-choice.md — verify_execution_payload_envelope + * Signature verification and the execution engine call (`verify_and_notify_new_payload`) are + * performed outside this function, see `verifyExecutionPayloadEnvelopeSignature` and + * `importExecutionPayload` which run both in parallel with this check. + * + * Spec: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/gloas/fork-choice.md#new-verify_execution_payload_envelope */ export function verifyExecutionPayloadEnvelope( config: BeaconConfig, @@ -28,24 +31,18 @@ export function verifyExecutionPayloadEnvelope( const {verifyExecutionRequestsRoot = true} = opts ?? {}; const payload = envelope.payload; - // Compute header root without mutating state + // Verify consistency with the beacon block const headerValue = {...state.latestBlockHeader}; if (byteArrayEquals(headerValue.stateRoot, ssz.Root.defaultValue())) { headerValue.stateRoot = state.hashTreeRoot(); } const headerRoot = ssz.phase0.BeaconBlockHeader.hashTreeRoot(headerValue); - - // Verify consistency with the beacon block if (!byteArrayEquals(envelope.beaconBlockRoot, headerRoot)) { throw new Error( `Envelope's block is not the latest block header envelope=${toRootHex(envelope.beaconBlockRoot)} latestBlockHeader=${toRootHex(headerRoot)}` ); } - if (payload.slotNumber !== state.slot) { - throw new Error(`Slot mismatch between payload and state payload=${payload.slotNumber} state=${state.slot}`); - } - // Verify consistency with the committed bid const bid = state.latestExecutionPayloadBid; if (envelope.builderIndex !== bid.builderIndex) { @@ -53,26 +50,21 @@ export function verifyExecutionPayloadEnvelope( `Builder index mismatch between envelope and committed bid envelope=${envelope.builderIndex} bid=${bid.builderIndex}` ); } - if (!byteArrayEquals(bid.prevRandao, payload.prevRandao)) { throw new Error( `Prev randao mismatch between bid and payload bid=${toHex(bid.prevRandao)} payload=${toHex(payload.prevRandao)}` ); } - if (Number(bid.gasLimit) !== payload.gasLimit) { throw new Error( `Gas limit mismatch between payload and bid payload=${payload.gasLimit} bid=${Number(bid.gasLimit)}` ); } - if (!byteArrayEquals(bid.blockHash, payload.blockHash)) { throw new Error( `Block hash mismatch between payload and bid payload=${toRootHex(payload.blockHash)} bid=${toRootHex(bid.blockHash)}` ); } - - // Verify execution_requests_root matches bid commitment // Can be skipped if already verified during gossip validation if (verifyExecutionRequestsRoot) { const requestsRoot = ssz.electra.ExecutionRequests.hashTreeRoot(envelope.executionRequests); @@ -84,19 +76,19 @@ export function verifyExecutionPayloadEnvelope( } // Verify the execution payload is valid + if (payload.slotNumber !== state.slot) { + throw new Error(`Slot mismatch between payload and state payload=${payload.slotNumber} state=${state.slot}`); + } if (!byteArrayEquals(payload.parentHash, state.latestBlockHash)) { throw new Error( `Parent hash mismatch between payload and state payload=${toRootHex(payload.parentHash)} state=${toRootHex(state.latestBlockHash)}` ); } - if (payload.timestamp !== computeTimeAtSlot(config, state.slot, state.genesisTime)) { throw new Error( `Timestamp mismatch between payload and state payload=${payload.timestamp} state=${computeTimeAtSlot(config, state.slot, state.genesisTime)}` ); } - - // Verify consistency with expected withdrawals const payloadWithdrawalsRoot = ssz.capella.Withdrawals.hashTreeRoot(payload.withdrawals); const expectedWithdrawalsRoot = ssz.capella.Withdrawals.hashTreeRoot(state.payloadExpectedWithdrawals); if (!byteArrayEquals(payloadWithdrawalsRoot, expectedWithdrawalsRoot)) { @@ -104,14 +96,12 @@ export function verifyExecutionPayloadEnvelope( `Withdrawals mismatch between payload and expected payload=${toRootHex(payloadWithdrawalsRoot)} expected=${toRootHex(expectedWithdrawalsRoot)}` ); } - - // Execution engine verification (verify_and_notify_new_payload) is done externally } /** * Verify the BLS signature of an execution payload envelope. * - * Spec: gloas/fork-choice.md — verify_execution_payload_envelope_signature + * Spec: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/gloas/fork-choice.md#new-verify_execution_payload_envelope_signature */ export async function verifyExecutionPayloadEnvelopeSignature( config: BeaconConfig, diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 5b9562973121..310bae691894 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -887,7 +887,7 @@ export class BeaconChain implements IBeaconChain { return payloadInput.getPayloadEnvelope().message.executionRequests; } } - // Parent was EMPTY, payload not verified, or PTC didn't vote timely — return empty requests + // Parent was EMPTY, payload not verified, or PTC didn't vote timely, return empty requests return ssz.electra.ExecutionRequests.defaultValue(); } diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index 9dd1e7175723..59fe704f664b 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -282,7 +282,8 @@ export async function produceBlockBody( gloasBody.signedExecutionPayloadBid = signedBid; // TODO GLOAS: Get payload attestations from pool for previous slot gloasBody.payloadAttestations = []; - // Determine parent execution requests for deferred processing (consensus-specs#5094) + // Parent's execution requests are applied when this block is processed (via + // processParentExecutionPayload), so the proposer must commit to them here. // If parent was FULL: include execution requests from its envelope // If parent was EMPTY: include empty execution requests gloasBody.parentExecutionRequests = this.getParentExecutionRequests(parentBlock.blockRoot); diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 5b162c14b644..de70078a5e4d 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -599,8 +599,6 @@ export class ProtoArray { } // Create FULL variant as a child of PENDING (sibling to EMPTY) - // With deferred payload processing (consensus-specs#5094), FULL shares the same - // stateRoot as PENDING since envelope no longer produces a separate post-state const fullNode: ProtoNode = { ...pendingNode, parent: pendingIndex, // Points to own PENDING (same as EMPTY) diff --git a/packages/state-transition/src/block/index.ts b/packages/state-transition/src/block/index.ts index 0822024a04ce..84fbc929c091 100644 --- a/packages/state-transition/src/block/index.ts +++ b/packages/state-transition/src/block/index.ts @@ -51,8 +51,8 @@ export function processBlock( ): void { const {verifySignatures = true} = opts ?? {}; - // Process parent execution payload effects first (consensus-specs#5094) - // Must run before processBlockHeader and processExecutionPayloadBid + // Apply the parent's deferred payload effects before everything else. Must run before + // processBlockHeader and processExecutionPayloadBid so subsequent steps see the updated state. if (fork >= ForkSeq.gloas) { processParentExecutionPayload(state as CachedBeaconStateGloas, block as BeaconBlock); } @@ -60,7 +60,7 @@ export function processBlock( processBlockHeader(state, block); if (fork >= ForkSeq.gloas) { - // After consensus-specs#5094, processParentExecutionPayload has already handled parent effects + // Parent payload's execution requests were already applied by processParentExecutionPayload above processWithdrawals(fork, state as CachedBeaconStateGloas); } else if (fork >= ForkSeq.capella) { const fullOrBlindedPayload = getFullOrBlindedPayload(block); @@ -73,7 +73,9 @@ export function processBlock( // The call to the process_execution_payload must happen before the call to the process_randao as the former depends // on the randao_mix computed with the reveal of the previous block. - // TODO GLOAS: We call processExecutionPayload somewhere else post-gloas + // Post-gloas: process_execution_payload is not part of block processing. The parent's payload + // effects are applied earlier via processParentExecutionPayload, and each envelope is verified + // out-of-band via verifyExecutionPayloadEnvelope when it arrives. if ( fork < ForkSeq.gloas && fork >= ForkSeq.bellatrix && diff --git a/packages/state-transition/src/block/processParentExecutionPayload.ts b/packages/state-transition/src/block/processParentExecutionPayload.ts index b88781923115..176aea949fa8 100644 --- a/packages/state-transition/src/block/processParentExecutionPayload.ts +++ b/packages/state-transition/src/block/processParentExecutionPayload.ts @@ -8,20 +8,18 @@ import {getPendingValidatorPubkeys, processDepositRequest} from "./processDeposi import {processWithdrawalRequest} from "./processWithdrawalRequest.js"; /** - * Process parent execution payload effects as first step of processBlock. + * Process parent execution payload effects as the first step of processBlock. * - * Spec: consensus-specs#5094 - * https://github.com/ethereum/consensus-specs/blob/26ed32e/specs/gloas/beacon-chain.md + * Spec: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/gloas/beacon-chain.md#new-process_parent_execution_payload */ export function processParentExecutionPayload(state: CachedBeaconStateGloas, block: BeaconBlock): void { const bid = block.body.signedExecutionPayloadBid.message; const parentBid = state.latestExecutionPayloadBid; const requests = block.body.parentExecutionRequests; - // True if this block built on the parent's full payload - const isParentFull = byteArrayEquals(bid.parentBlockHash, parentBid.blockHash); - - if (!isParentFull) { + const isGenesisBlock = byteArrayEquals(parentBid.blockHash, ssz.Root.defaultValue()); + const isParentBlockEmpty = !byteArrayEquals(bid.parentBlockHash, parentBid.blockHash); + if (isGenesisBlock || isParentBlockEmpty) { // Parent was EMPTY -- no execution requests expected assertEmptyExecutionRequests(requests); return; @@ -39,22 +37,14 @@ export function processParentExecutionPayload(state: CachedBeaconStateGloas, blo } /** - * Apply parent execution payload effects to state. + * Process the parent's execution requests, queue the builder payment, update payload availability, + * and update the latest block hash. * - * Spec: apply_parent_execution_payload - */ -/** - * Settle a builder payment at the given index. - * Spec: settle_builder_payment + * Called from processParentExecutionPayload during block processing, and from the validator during + * block production before computing withdrawals. + * + * Spec: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/gloas/beacon-chain.md#new-apply_parent_execution_payload */ -function settleBuilderPayment(state: CachedBeaconStateGloas, paymentIndex: number): void { - const payment = state.builderPendingPayments.get(paymentIndex).clone(); - if (payment.withdrawal.amount > 0) { - state.builderPendingWithdrawals.push(payment.withdrawal); - } - state.builderPendingPayments.set(paymentIndex, ssz.gloas.BuilderPendingPayment.defaultViewDU()); -} - export function applyParentExecutionPayload( state: CachedBeaconStateGloas, parentBid: {slot: number; blockHash: Uint8Array; builderIndex: number; value: number; feeRecipient: Uint8Array}, @@ -65,8 +55,8 @@ export function applyParentExecutionPayload( const parentEpoch = computeEpochAtSlot(parentSlot); const currentEpoch = computeEpochAtSlot(state.slot); - // Process execution requests from parent's payload - // Execution requests are processed at state.slot (child's slot), not parent's slot + // Process execution requests from parent's payload. The execution + // requests are processed at state.slot (child's slot), not the parent's slot. if (requests.deposits.length > 0) { const pendingValidatorPubkeys = getPendingValidatorPubkeys(state.config, state); for (const deposit of requests.deposits) { @@ -88,8 +78,8 @@ export function applyParentExecutionPayload( } else if (parentEpoch === currentEpoch - 1) { settleBuilderPayment(state, parentSlot % SLOTS_PER_EPOCH); } else if (parentBid.value > 0) { - // Parent is older than previous epoch — payment entry already settled/evicted. - // Directly append the withdrawal to ensure the builder gets paid. + // Parent is older than the previous epoch, its payment entry has already been settled or + // evicted, so append the withdrawal directly to ensure the builder still gets paid state.builderPendingWithdrawals.push( ssz.gloas.BuilderPendingWithdrawal.toViewDU({ feeRecipient: parentBid.feeRecipient, @@ -104,6 +94,20 @@ export function applyParentExecutionPayload( state.latestBlockHash = parentBid.blockHash; } +/** + * Settle a builder payment at the given index: move its withdrawal (if any) to the + * pending withdrawals list and clear the payment slot. + * + * Spec: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/gloas/beacon-chain.md#new-settle_builder_payment + */ +function settleBuilderPayment(state: CachedBeaconStateGloas, paymentIndex: number): void { + const payment = state.builderPendingPayments.get(paymentIndex).clone(); + if (payment.withdrawal.amount > 0) { + state.builderPendingWithdrawals.push(payment.withdrawal); + } + state.builderPendingPayments.set(paymentIndex, ssz.gloas.BuilderPendingPayment.defaultViewDU()); +} + function assertEmptyExecutionRequests(requests: electra.ExecutionRequests): void { if (requests.deposits.length !== 0 || requests.withdrawals.length !== 0 || requests.consolidations.length !== 0) { throw new Error("Parent execution requests must be empty when parent block is EMPTY"); diff --git a/packages/types/src/gloas/sszTypes.ts b/packages/types/src/gloas/sszTypes.ts index 59fd84ebb1ec..c88815351308 100644 --- a/packages/types/src/gloas/sszTypes.ts +++ b/packages/types/src/gloas/sszTypes.ts @@ -294,7 +294,7 @@ export const BeaconState = new ContainerType( ptcWindow: PtcWindow, // New in GLOAS:EIP7732 }, {typeName: "BeaconState", jsonCase: "eth2"} -); // New in GLOAS:EIP7732 +); export const DataColumnSidecar = new ContainerType( { From 532ffccc6f57a70fbd9149bfd22f97553de17e50 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 22 Apr 2026 15:29:39 +0100 Subject: [PATCH 32/67] ethspecify --- specrefs/.ethspecify.yml | 4 ---- specrefs/functions.yml | 20 ++++++++++++++------ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/specrefs/.ethspecify.yml b/specrefs/.ethspecify.yml index 4f0f00f8b4bd..7d89c0abb44f 100644 --- a/specrefs/.ethspecify.yml +++ b/specrefs/.ethspecify.yml @@ -412,13 +412,9 @@ exceptions: - will_no_conflicting_checkpoint_be_justified#phase0 # gloas / heze empty sources to skip - - apply_parent_execution_payload#gloas - is_payload_verified#gloas - on_execution_payload_envelope#gloas - on_execution_payload_envelope#heze - - process_parent_execution_payload#gloas - - settle_builder_payment#gloas - - verify_execution_payload_envelope#gloas configs: # phase0 fast confirmation / not implemented diff --git a/specrefs/functions.yml b/specrefs/functions.yml index 264fb1179eab..ef4d692161e7 100644 --- a/specrefs/functions.yml +++ b/specrefs/functions.yml @@ -215,7 +215,9 @@ - name: apply_parent_execution_payload#gloas - sources: [] + sources: + - file: packages/state-transition/src/block/processParentExecutionPayload.ts + search: export function applyParentExecutionPayload( spec: | def apply_parent_execution_payload( @@ -9812,7 +9814,9 @@ - name: process_parent_execution_payload#gloas - sources: [] + sources: + - file: packages/state-transition/src/block/processParentExecutionPayload.ts + search: export function processParentExecutionPayload( spec: | def process_parent_execution_payload(state: BeaconState, block: BeaconBlock) -> None: @@ -10945,7 +10949,9 @@ - name: settle_builder_payment#gloas - sources: [] + sources: + - file: packages/state-transition/src/block/processParentExecutionPayload.ts + search: function settleBuilderPayment( spec: | def settle_builder_payment(state: BeaconState, payment_index: uint64) -> None: @@ -12866,7 +12872,9 @@ - name: verify_execution_payload_envelope#gloas - sources: [] + sources: + - file: packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts + search: export function verifyExecutionPayloadEnvelope( spec: | def verify_execution_payload_envelope( @@ -12913,8 +12921,8 @@ - name: verify_execution_payload_envelope_signature#gloas sources: - - file: packages/state-transition/src/block/processExecutionPayloadEnvelope.ts - search: function verifyExecutionPayloadEnvelopeSignature( + - file: packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts + search: export async function verifyExecutionPayloadEnvelopeSignature( spec: | def verify_execution_payload_envelope_signature( From 16941ffbb9801177e6e38cf9f54c8510c1637a30 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Wed, 22 Apr 2026 15:43:54 +0100 Subject: [PATCH 33/67] restore some comments --- .../src/chain/blocks/verifyExecutionPayloadEnvelope.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts b/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts index fafc5e60980e..ed31e4d33769 100644 --- a/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts @@ -31,7 +31,8 @@ export function verifyExecutionPayloadEnvelope( const {verifyExecutionRequestsRoot = true} = opts ?? {}; const payload = envelope.payload; - // Verify consistency with the beacon block + // Verify consistency with the beacon block. + // Compute header root on a copy of latestBlockHeader to avoid mutating state. const headerValue = {...state.latestBlockHeader}; if (byteArrayEquals(headerValue.stateRoot, ssz.Root.defaultValue())) { headerValue.stateRoot = state.hashTreeRoot(); @@ -65,7 +66,8 @@ export function verifyExecutionPayloadEnvelope( `Block hash mismatch between payload and bid payload=${toRootHex(payload.blockHash)} bid=${toRootHex(bid.blockHash)}` ); } - // Can be skipped if already verified during gossip validation + // Verify execution_requests_root matches bid commitment. + // Can be skipped if already verified during gossip validation. if (verifyExecutionRequestsRoot) { const requestsRoot = ssz.electra.ExecutionRequests.hashTreeRoot(envelope.executionRequests); if (!byteArrayEquals(requestsRoot, bid.executionRequestsRoot)) { @@ -89,6 +91,8 @@ export function verifyExecutionPayloadEnvelope( `Timestamp mismatch between payload and state payload=${payload.timestamp} state=${computeTimeAtSlot(config, state.slot, state.genesisTime)}` ); } + + // Verify consistency with expected withdrawals const payloadWithdrawalsRoot = ssz.capella.Withdrawals.hashTreeRoot(payload.withdrawals); const expectedWithdrawalsRoot = ssz.capella.Withdrawals.hashTreeRoot(state.payloadExpectedWithdrawals); if (!byteArrayEquals(payloadWithdrawalsRoot, expectedWithdrawalsRoot)) { @@ -96,6 +100,8 @@ export function verifyExecutionPayloadEnvelope( `Withdrawals mismatch between payload and expected payload=${toRootHex(payloadWithdrawalsRoot)} expected=${toRootHex(expectedWithdrawalsRoot)}` ); } + + // Execution engine verification (verify_and_notify_new_payload) is done externally by the caller } /** From e12a16edc261351ccbcbdedcf4a0cc88ef5fa7b3 Mon Sep 17 00:00:00 2001 From: Cayman Date: Wed, 22 Apr 2026 16:11:31 -0400 Subject: [PATCH 34/67] fix: address payload envelope review feedback --- .../src/api/impl/beacon/blocks/index.ts | 7 +- .../blocks/payloadEnvelopeInput/index.ts | 1 - .../recoverPayloadEnvelopeInput.ts | 47 ----- .../chain/blocks/verifyBlocksSanityChecks.ts | 22 ++- .../blocks/writePayloadEnvelopeInputToDb.ts | 25 +-- packages/beacon-node/src/chain/chain.ts | 1 + .../seenCache/seenPayloadEnvelopeInput.ts | 54 ++++-- .../validation/executionPayloadEnvelope.ts | 7 +- .../src/network/processor/gossipHandlers.ts | 13 +- packages/beacon-node/src/sync/types.ts | 2 +- packages/beacon-node/src/sync/unknownBlock.ts | 163 +++++++++--------- .../src/sync/utils/downloadByRoot.ts | 14 +- .../src/sync/utils/pendingBlocksTree.ts | 15 -- .../blocks/verifyBlocksSanityChecks.test.ts | 19 ++ .../seenPayloadEnvelopeInput.test.ts | 71 ++++++++ .../test/unit/sync/unknownBlock.test.ts | 126 ++------------ .../unit/sync/utils/downloadByRoot.test.ts | 37 ++++ .../unit/sync/utils/pendingBlocksTree.test.ts | 15 -- 18 files changed, 312 insertions(+), 327 deletions(-) delete mode 100644 packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/recoverPayloadEnvelopeInput.ts create mode 100644 packages/beacon-node/test/unit/chain/seenCache/seenPayloadEnvelopeInput.test.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 5f61a6dc96d1..5a0f16e27f31 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -35,10 +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, - getOrRecoverPayloadEnvelopeInput, -} from "../../../../chain/blocks/payloadEnvelopeInput/index.js"; +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"; @@ -718,7 +715,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 = await getOrRecoverPayloadEnvelopeInput(chain, 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/payloadEnvelopeInput/index.ts b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/index.ts index c99136eb0de2..368f98324f22 100644 --- a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/index.ts +++ b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/index.ts @@ -1,3 +1,2 @@ export * from "./payloadEnvelopeInput.js"; -export * from "./recoverPayloadEnvelopeInput.js"; export * from "./types.js"; diff --git a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/recoverPayloadEnvelopeInput.ts b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/recoverPayloadEnvelopeInput.ts deleted file mode 100644 index 0c79e3e88e03..000000000000 --- a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/recoverPayloadEnvelopeInput.ts +++ /dev/null @@ -1,47 +0,0 @@ -import {ForkPostGloas, isForkPostGloas} from "@lodestar/params"; -import {RootHex, SignedBeaconBlock} from "@lodestar/types"; -import {IBeaconChain} from "../../interface.js"; -import {PayloadEnvelopeInput} from "./payloadEnvelopeInput.js"; - -/** - * Rebuild a missing PayloadEnvelopeInput for an already-imported block. - * - * This keeps the seen cache disposable: callers can recover the bid metadata needed for - * late payload envelopes / data columns after the cache entry was pruned. - */ -export async function getOrRecoverPayloadEnvelopeInput( - chain: IBeaconChain, - blockRootHex: RootHex -): Promise { - const cachedInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex); - if (cachedInput) { - return cachedInput; - } - - const blockResult = await chain.getBlockByRoot(blockRootHex); - if (!blockResult) { - return undefined; - } - - const forkName = chain.config.getForkName(blockResult.block.message.slot); - if (!isForkPostGloas(forkName)) { - return undefined; - } - - try { - return chain.seenPayloadEnvelopeInputCache.add({ - blockRootHex, - block: blockResult.block as SignedBeaconBlock, - forkName, - sampledColumns: chain.custodyConfig.sampledColumns, - custodyColumns: chain.custodyConfig.custodyColumns, - timeCreatedSec: Date.now() / 1000, - }); - } catch (e) { - const recoveredInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex); - if (recoveredInput) { - return recoveredInput; - } - throw e; - } -} diff --git a/packages/beacon-node/src/chain/blocks/verifyBlocksSanityChecks.ts b/packages/beacon-node/src/chain/blocks/verifyBlocksSanityChecks.ts index 02d686818d2a..ca84924ae39e 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlocksSanityChecks.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlocksSanityChecks.ts @@ -90,15 +90,23 @@ export function verifyBlocksSanityChecks( } else { // When importing a block segment, only the first NON-IGNORED block must be known to the fork-choice. const parentRoot = toRootHex(block.message.parentRoot); - parentBlock = isGloasBeaconBlock(block.message) - ? chain.forkChoice.getBlockHexAndBlockHash( - parentRoot, - toRootHex(block.message.body.signedExecutionPayloadBid.message.parentBlockHash) - ) - : chain.forkChoice.getBlockHexDefaultStatus(parentRoot); - if (!parentBlock) { + const parentBlockDefaultStatus = chain.forkChoice.getBlockHexDefaultStatus(parentRoot); + if (!parentBlockDefaultStatus) { throw new BlockError(block, {code: BlockErrorCode.PARENT_UNKNOWN, parentRoot}); } + + parentBlock = parentBlockDefaultStatus; + if (isGloasBeaconBlock(block.message)) { + const parentBlockHash = toRootHex(block.message.body.signedExecutionPayloadBid.message.parentBlockHash); + const parentBlockWithPayload = chain.forkChoice.getBlockHexAndBlockHash(parentRoot, parentBlockHash); + if (!parentBlockWithPayload) { + throw new BlockError(block, { + code: BlockErrorCode.PARENT_PAYLOAD_UNKNOWN, + parentBlockHash, + }); + } + parentBlock = parentBlockWithPayload; + } // Parent is known to the fork-choice parentBlockSlot = parentBlock.slot; } diff --git a/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts b/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts index d5c07a563590..3421e6bd40ed 100644 --- a/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts +++ b/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts @@ -33,23 +33,14 @@ 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.seenPayloadEnvelopeInputCache.prune(payloadInput.blockRootHex); - this.logger.debug("Pruned payload envelope input", { + await writePayloadEnvelopeInputToDb.call(this, payloadInput).catch((e) => { + this.logger.error( + "Error persisting payload envelope in hot db", + { slot: payloadInput.slot, root: payloadInput.blockRootHex, - }); - }); + }, + e + ); + }); } diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 310bae691894..c4aff2c1f2cf 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -337,6 +337,7 @@ export class BeaconChain implements IBeaconChain { chainEvents: emitter, signal, serializedCache: this.serializedCache, + hasValidatedPayload: (blockRootHex) => this.forkChoice.hasPayloadHexUnsafe(blockRootHex), metrics, logger, }); diff --git a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts index 98b76f853c40..4db831f307d1 100644 --- a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts @@ -1,6 +1,6 @@ import {CheckpointWithHex} from "@lodestar/fork-choice"; import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; -import {RootHex} from "@lodestar/types"; +import {RootHex, Slot} from "@lodestar/types"; import {Logger} from "@lodestar/utils"; import {Metrics} from "../../metrics/metrics.js"; import {SerializedCache} from "../../util/serializedCache.js"; @@ -15,6 +15,7 @@ export type SeenPayloadEnvelopeInputModules = { chainEvents: ChainEventEmitter; signal: AbortSignal; serializedCache: SerializedCache; + hasValidatedPayload: (blockRootHex: RootHex) => boolean; metrics: Metrics | null; logger?: Logger; }; @@ -22,21 +23,32 @@ export type SeenPayloadEnvelopeInputModules = { /** * Cache for tracking PayloadEnvelopeInput instances, keyed by beacon block root. * - * Created during block import when a block is processed. - * Pruned on finalization and after payload is written to DB. + * Created during block import when a Gloas block is processed. Entries stay resident until the + * block's payload is validated in fork choice, so later envelope / column validation can reuse + * the same PayloadEnvelopeInput created by importBlock(). Once a payload is validated, older + * entries can be evicted on finalization. */ export class SeenPayloadEnvelopeInput { private readonly chainEvents: ChainEventEmitter; private readonly signal: AbortSignal; private readonly serializedCache: SerializedCache; + private readonly hasValidatedPayload: (blockRootHex: RootHex) => boolean; private readonly metrics: Metrics | null; private readonly logger?: Logger; private payloadInputs = new Map(); - constructor({chainEvents, signal, serializedCache, metrics, logger}: SeenPayloadEnvelopeInputModules) { + constructor({ + chainEvents, + signal, + serializedCache, + hasValidatedPayload, + metrics, + logger, + }: SeenPayloadEnvelopeInputModules) { this.chainEvents = chainEvents; this.signal = signal; this.serializedCache = serializedCache; + this.hasValidatedPayload = hasValidatedPayload; this.metrics = metrics; this.logger = logger; @@ -59,16 +71,7 @@ export class SeenPayloadEnvelopeInput { } private onFinalized = (checkpoint: CheckpointWithHex): void => { - // Prune all entries with slot < finalized slot - const finalizedSlot = computeStartSlotAtEpoch(checkpoint.epoch); - let deletedCount = 0; - for (const [, input] of this.payloadInputs) { - if (input.slot < finalizedSlot) { - this.evictPayloadInput(input); - deletedCount++; - } - } - this.logger?.debug("SeenPayloadEnvelopeInput.onFinalized deleted cached entries", {deletedCount}); + this.pruneBelow(computeStartSlotAtEpoch(checkpoint.epoch)); }; add(props: CreateFromBlockProps): PayloadEnvelopeInput { @@ -100,6 +103,29 @@ export class SeenPayloadEnvelopeInput { return this.payloadInputs.size; } + pruneBelow(slot: Slot): void { + let deletedCount = 0; + let retainedUnvalidatedCount = 0; + for (const [, input] of this.payloadInputs) { + if (input.slot >= slot) { + continue; + } + + if (!this.hasValidatedPayload(input.blockRootHex)) { + retainedUnvalidatedCount++; + continue; + } + + this.evictPayloadInput(input); + deletedCount++; + } + this.logger?.debug("SeenPayloadEnvelopeInput.pruneBelow deleted entries", { + slot, + deletedCount, + retainedUnvalidatedCount, + }); + } + private evictPayloadInput(payloadInput: PayloadEnvelopeInput): void { this.serializedCache.delete(payloadInput.getSerializedCacheKeys()); this.payloadInputs.delete(payloadInput.blockRootHex); diff --git a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts index 444ca2438838..428b40690992 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts @@ -6,7 +6,6 @@ import { } from "@lodestar/state-transition"; import {gloas, ssz} from "@lodestar/types"; import {byteArrayEquals, toRootHex} from "@lodestar/utils"; -import {getOrRecoverPayloadEnvelopeInput} from "../blocks/payloadEnvelopeInput/index.js"; import {ExecutionPayloadEnvelopeError, ExecutionPayloadEnvelopeErrorCode, GossipAction} from "../errors/index.js"; import {IBeaconChain} from "../index.js"; import {RegenCaller} from "../regen/index.js"; @@ -57,9 +56,9 @@ async function validateExecutionPayloadEnvelope( }); } - const payloadInput = await getOrRecoverPayloadEnvelopeInput(chain, blockRootHex); + const payloadInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex); if (!payloadInput) { - // PayloadEnvelopeInput should be recoverable from a known block. + // importBlock() is the only place that creates PayloadEnvelopeInput for a known Gloas block. throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { code: ExecutionPayloadEnvelopeErrorCode.PAYLOAD_ENVELOPE_INPUT_MISSING, blockRoot: blockRootHex, @@ -70,7 +69,7 @@ async function validateExecutionPayloadEnvelope( throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { code: ExecutionPayloadEnvelopeErrorCode.ENVELOPE_ALREADY_KNOWN, blockRoot: blockRootHex, - slot: envelope.slot, + slot: payload.slotNumber, }); } diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index 2711a4bf54fc..9f5f5c005eed 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -35,11 +35,7 @@ import { IBlockInput, isBlockInputColumns, } from "../../chain/blocks/blockInput/index.js"; -import { - PayloadEnvelopeInput, - PayloadEnvelopeInputSource, - getOrRecoverPayloadEnvelopeInput, -} from "../../chain/blocks/payloadEnvelopeInput/index.js"; +import {PayloadEnvelopeInput, PayloadEnvelopeInputSource} from "../../chain/blocks/payloadEnvelopeInput/index.js"; import {BlobSidecarValidation} from "../../chain/blocks/types.js"; import {ChainEvent} from "../../chain/emitter.js"; import { @@ -446,11 +442,11 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand }); } - const payloadInput = await getOrRecoverPayloadEnvelopeInput(chain, blockRootHex); + const payloadInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex); if (!payloadInput) { // This should not happen for gossip because the block should already be known by the time - // payload columns are processed, and the PayloadEnvelopeInput is recoverable from that block. + // payload columns are processed. throw new DataColumnSidecarGossipError(GossipAction.IGNORE, { code: DataColumnSidecarErrorCode.PAYLOAD_ENVELOPE_INPUT_MISSING, slot, @@ -1102,10 +1098,9 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand chain.validatorMonitor?.registerExecutionPayloadEnvelope(OpSource.gossip, delaySec, signedEnvelope); const blockRootHex = toRootHex(envelope.beaconBlockRoot); - const payloadInput = await getOrRecoverPayloadEnvelopeInput(chain, blockRootHex); + const payloadInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex); if (!payloadInput) { - // This shouldn't happen because the block should already be known and its payload input is recoverable. throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { code: ExecutionPayloadEnvelopeErrorCode.PAYLOAD_ENVELOPE_INPUT_MISSING, blockRoot: blockRootHex, diff --git a/packages/beacon-node/src/sync/types.ts b/packages/beacon-node/src/sync/types.ts index d2ddb22a79eb..b79a8e40e91a 100644 --- a/packages/beacon-node/src/sync/types.ts +++ b/packages/beacon-node/src/sync/types.ts @@ -122,7 +122,7 @@ export function getPayloadSyncCacheItemSlot(payload: PayloadSyncCacheItem): Slot } if (isPendingPayloadEnvelope(payload)) { - return payload.envelope.message.slot; + return payload.envelope.message.payload.slotNumber; } return "unknown"; diff --git a/packages/beacon-node/src/sync/unknownBlock.ts b/packages/beacon-node/src/sync/unknownBlock.ts index 3572af1e3b9c..826364b3a80f 100644 --- a/packages/beacon-node/src/sync/unknownBlock.ts +++ b/packages/beacon-node/src/sync/unknownBlock.ts @@ -8,11 +8,7 @@ import {Logger, fromHex, prettyPrintIndices, pruneSetToMax, sleep, toRootHex} fr import {isBlockInputBlobs, isBlockInputColumns} from "../chain/blocks/blockInput/blockInput.js"; import {BlockInputSource, IBlockInput} from "../chain/blocks/blockInput/types.js"; import {PayloadError, PayloadErrorCode} from "../chain/blocks/importExecutionPayload.js"; -import { - PayloadEnvelopeInput, - PayloadEnvelopeInputSource, - getOrRecoverPayloadEnvelopeInput, -} from "../chain/blocks/payloadEnvelopeInput/index.js"; +import {PayloadEnvelopeInput, PayloadEnvelopeInputSource} from "../chain/blocks/payloadEnvelopeInput/index.js"; import {BlockError, BlockErrorCode} from "../chain/errors/index.js"; import {ChainEvent, ChainEventData, IBeaconChain} from "../chain/index.js"; import {validateGloasBlockDataColumnSidecars} from "../chain/validation/dataColumnSidecar.js"; @@ -104,7 +100,7 @@ export class BlockInputSync { * block RootHex -> PendingBlock. To avoid finding same root at the same time */ private readonly pendingBlocks = new Map(); - // Payload recovery is keyed by beacon block root as well, so block and payload queues can unblock each other. + // Payload sync is keyed by beacon block root as well, so block and payload queues can unblock each other. private readonly pendingPayloads = new Map(); private readonly knownBadBlocks = new Set(); private readonly maxPendingBlocks; @@ -399,10 +395,14 @@ export class BlockInputSync { this.pendingPayloads.set(rootHex, pendingPayload); this.logger.verbose("Added payload envelope to BlockInputSync.pendingPayloads", { - slot: envelope.message.slot, + slot: envelope.message.payload.slotNumber, root: rootHex, }); } else { + this.logger.debug("Overwriting pending payload envelope for root already waiting for block", { + slot: envelope.message.payload.slotNumber, + root: rootHex, + }); pendingPayload.envelope = envelope; } @@ -469,14 +469,14 @@ export class BlockInputSync { return {kind: "parentBlock", rootHex: parentRootHex}; } - if (this.config.getForkSeq(blockInput.slot) < ForkSeq.gloas) { - return {kind: "ready"}; - } - if (!blockInput.hasBlock()) { return {kind: "block", rootHex: blockInput.blockRootHex}; } + if (this.config.getForkSeq(blockInput.slot) < ForkSeq.gloas) { + return {kind: "ready"}; + } + const block = blockInput.getBlock() as gloas.SignedBeaconBlock; const parentBlockHashHex = toRootHex(block.message.body.signedExecutionPayloadBid.message.parentBlockHash); if (this.chain.forkChoice.getBlockHexAndBlockHash(parentRootHex, parentBlockHashHex) !== null) { @@ -543,14 +543,13 @@ export class BlockInputSync { previous?: PayloadSyncCacheItem, envelope?: gloas.SignedExecutionPayloadEnvelope ): PendingPayloadInput { - // Normalize every payload recovery path into the same cache shape while preserving first-seen + // Normalize every payload queueing path into the same cache shape while preserving first-seen // timing and peer provenance from any earlier by-root or envelope-only entry. - const recoveredEnvelope = - envelope ?? (previous && isPendingPayloadEnvelope(previous) ? previous.envelope : undefined); + const queuedEnvelope = envelope ?? (previous && isPendingPayloadEnvelope(previous) ? previous.envelope : undefined); - if (recoveredEnvelope && !payloadInput.hasPayloadEnvelope()) { + if (queuedEnvelope && !payloadInput.hasPayloadEnvelope()) { payloadInput.addPayloadEnvelope({ - envelope: recoveredEnvelope, + envelope: queuedEnvelope, source: PayloadEnvelopeInputSource.byRoot, seenTimestampSec: Date.now() / 1000, }); @@ -584,13 +583,23 @@ export class BlockInputSync { for (const block of ancestors) { const advanceResult = this.advancePendingBlock(block); - if (advanceResult === "ready") { - processedBlocks++; - this.processReadyBlock(block).catch((e) => { - this.logger.debug("Unexpected error - process old downloaded block", {}, e); - }); - } else if (advanceResult === "queued_block" || advanceResult === "queued_parent_block") { - shouldRerunBlockSearch = true; + switch (advanceResult) { + case "ready": + processedBlocks++; + this.processReadyBlock(block).catch((e) => { + this.logger.debug("Unexpected error - process old downloaded block", {}, e); + }); + break; + + case "queued_block": + case "queued_parent_block": + shouldRerunBlockSearch = true; + break; + + case "queued_parent_payload": + case "blocked": + case "removed": + break; } } @@ -685,18 +694,24 @@ export class BlockInputSync { if (parentInForkChoice) { // If the direct parent is already in fork choice, let the block state machine decide if - // the next step is block import, parent payload recovery, or branch removal. + // the next step is block import, parent payload download, or branch removal. const advanceResult = this.advancePendingBlock(pending); - if (advanceResult === "ready") { - this.processReadyBlock(pending).catch((e) => { - this.logger.debug("Unexpected error - process newly downloaded block", logCtx2, e); - }); - } else if ( - advanceResult === "queued_block" || - advanceResult === "queued_parent_block" || - advanceResult === "queued_parent_payload" - ) { - this.triggerUnknownBlockSearch(); + switch (advanceResult) { + case "ready": + this.processReadyBlock(pending).catch((e) => { + this.logger.debug("Unexpected error - process newly downloaded block", logCtx2, e); + }); + break; + + case "queued_block": + case "queued_parent_block": + case "queued_parent_payload": + this.triggerUnknownBlockSearch(); + break; + + case "blocked": + case "removed": + break; } } else if (blockSlot <= finalizedSlot) { // the common ancestor of the downloading chain and canonical chain should be at least the finalized slot and @@ -787,46 +802,18 @@ export class BlockInputSync { pendingBlock.status = PendingBlockInputStatus.downloaded; break; - case BlockErrorCode.PARENT_PAYLOAD_UNKNOWN: { - const missingDependency = this.getMissingBlockDependency(pendingBlock.blockInput); - if (missingDependency.kind === "invalidParentPayload") { - this.logger.debug( - "Attempted to process block with conflicting parent payload hash", - { - ...errorData, - parentRoot: missingDependency.parentRootHex, - parentBlockHash: missingDependency.parentBlockHashHex, - }, - res.err - ); - this.removeAndDownScoreAllDescendants(pendingBlock); - break; - } - - if (missingDependency.kind === "block") { - this.logger.debug("Attempted to process block before its full body was available", errorData, res.err); - pendingBlock.status = PendingBlockInputStatus.pending; - this.triggerUnknownBlockSearch(); - break; - } - - this.logger.debug( - "Attempted to process block but its parent payload was still unknown", - errorData, + case BlockErrorCode.PARENT_PAYLOAD_UNKNOWN: + this.logger.error( + "processReadyBlock() hit unexpected parent payload dependency after readiness checks", + { + ...errorData, + parentRoot: pendingBlock.blockInput.parentRootHex, + parentBlockHash: res.err.type.parentBlockHash, + }, res.err ); - for (const peerIdStr of pendingBlock.peerIdStrings) { - this.addByPayloadRootHex( - missingDependency.kind === "parentPayload" - ? missingDependency.rootHex - : pendingBlock.blockInput.parentRootHex, - peerIdStr - ); - } pendingBlock.status = PendingBlockInputStatus.downloaded; - this.triggerUnknownBlockSearch(); break; - } case BlockErrorCode.EXECUTION_ENGINE_ERROR: // Removing the block(s) without penalizing the peers, hoping for EL to @@ -850,9 +837,9 @@ export class BlockInputSync { } /** - * Reconcile an envelope-first payload entry once enough local block context exists to rebuild - * a PayloadEnvelopeInput. This may queue block recovery, validate the speculative envelope, or - * downgrade back to by-root fetching when the cached envelope does not match the recovered block. + * Reconcile an envelope-first payload entry once the block import path has seeded its + * PayloadEnvelopeInput. This may queue block download, validate the speculative envelope, or + * downgrade back to by-root fetching when the cached envelope does not match the imported block. */ private async reconcilePayloadEnvelope(pendingPayload: PendingPayloadEnvelope): Promise { const rootHex = getPayloadSyncCacheItemRootHex(pendingPayload); @@ -861,7 +848,7 @@ export class BlockInputSync { return; } - const payloadInput = await getOrRecoverPayloadEnvelopeInput(this.chain, rootHex); + const payloadInput = this.chain.seenPayloadEnvelopeInputCache.get(rootHex); if (!payloadInput) { if (!this.chain.forkChoice.hasBlockHex(rootHex)) { // Column commitments live on the block body, so an envelope-only entry has to pull the block first. @@ -873,6 +860,10 @@ export class BlockInputSync { if (pendingBlock && this.network.getConnectedPeers().length > 0) { await this.downloadBlock(pendingBlock); } + } else { + this.logger.debug("Missing PayloadEnvelopeInput for known block while reconciling payload envelope", { + root: rootHex, + }); } return; } @@ -884,7 +875,7 @@ export class BlockInputSync { if (validationResult.err) { this.logger.debug( "Pending payload envelope failed validation after block import, refetching by root", - {slot: pendingPayload.envelope.message.slot, root: rootHex}, + {slot: pendingPayload.envelope.message.payload.slotNumber, root: rootHex}, validationResult.err ); @@ -994,7 +985,7 @@ export class BlockInputSync { if (res.err instanceof PayloadError) { switch (res.err.type.code) { case PayloadErrorCode.BLOCK_NOT_IN_FORK_CHOICE: - // Payload recovery discovered the block dependency before the block queue did. Re-enqueue the + // Payload sync discovered the block dependency before the block queue did. Re-enqueue the // block and keep the payload ready so the scheduler can retry once the block reaches fork choice. this.addByRootHex(rootHex); pendingPayload.status = PendingPayloadInputStatus.downloaded; @@ -1009,6 +1000,8 @@ export class BlockInputSync { case PayloadErrorCode.EXECUTION_ENGINE_INVALID: case PayloadErrorCode.INVALID_SIGNATURE: case PayloadErrorCode.STATE_TRANSITION_ERROR: + // TODO GLOAS: Decide how invalid payload inputs should eventually leave memory without + // reintroducing envelope replacement / recreation flows. this.logger.debug("Error processing payload from unknown sync", errorData, res.err); this.removePendingPayloadAndDescendants(rootHex); break; @@ -1025,7 +1018,7 @@ export class BlockInputSync { } /** - * Download payload material keyed by beacon block root. Unlike block recovery, payload recovery may + * Download payload material keyed by beacon block root. Unlike block download, payload sync may * already have a locally cached envelope or partial columns, so each attempt starts from local state * and only asks peers for the remaining pieces. */ @@ -1039,7 +1032,7 @@ export class BlockInputSync { let slot = getPayloadSyncCacheItemSlot(cacheItem); let payloadInput = isPendingPayloadInput(cacheItem) ? cacheItem.payloadInput - : await getOrRecoverPayloadEnvelopeInput(this.chain, rootHex); + : this.chain.seenPayloadEnvelopeInputCache.get(rootHex); let envelope = isPendingPayloadEnvelope(cacheItem) ? cacheItem.envelope : payloadInput?.hasPayloadEnvelope() @@ -1064,11 +1057,14 @@ export class BlockInputSync { try { if (!envelope) { envelope = await this.fetchExecutionPayloadEnvelope(peerId, blockRoot, rootHex); - slot = envelope.message.slot; + slot = envelope.message.payload.slotNumber; } - payloadInput ??= await getOrRecoverPayloadEnvelopeInput(this.chain, rootHex); + payloadInput ??= this.chain.seenPayloadEnvelopeInputCache.get(rootHex); if (!payloadInput) { + if (this.chain.forkChoice.hasBlockHex(rootHex)) { + throw new Error(`Missing PayloadEnvelopeInput for known block ${rootHex}`); + } // Keep the validated envelope around, but wait for the block body before turning it into a full payload input. return { status: PendingPayloadInputStatus.waitingForBlock, @@ -1390,7 +1386,8 @@ export class BlockInputSync { // Once a parent payload is invalid, every descendant waiting on that payload lineage becomes unrecoverable too. private removePendingPayloadAndDescendants(rootHex: RootHex): void { - this.chain.seenPayloadEnvelopeInputCache.prune(rootHex); + // Keep PayloadEnvelopeInput resident in the seen cache. importBlock() owns that object and + // later validation/finalization logic decides when it can leave memory. this.pendingPayloads.delete(rootHex); const badPendingBlocks = getAllDescendantBlocks(rootHex, this.pendingBlocks); @@ -1401,7 +1398,6 @@ export class BlockInputSync { this.pendingBlocks.delete(descendantRootHex); this.pendingPayloads.delete(descendantRootHex); this.chain.seenBlockInputCache.prune(descendantRootHex); - this.chain.seenPayloadEnvelopeInputCache.prune(descendantRootHex); this.logger.debug("Removing pending descendant after invalid parent payload", { slot: getBlockInputSyncCacheItemSlot(block), blockRoot: descendantRootHex, @@ -1423,7 +1419,8 @@ export class BlockInputSync { this.pendingBlocks.delete(rootHex); this.pendingPayloads.delete(rootHex); this.chain.seenBlockInputCache.prune(rootHex); - this.chain.seenPayloadEnvelopeInputCache.prune(rootHex); + // Keep PayloadEnvelopeInput resident in the seen cache for consistency with the + // importBlock()-owned lifecycle. this.logger.debug("Removing bad/unknown/incomplete BlockInputSyncCacheItem", { slot, blockRoot: rootHex, diff --git a/packages/beacon-node/src/sync/utils/downloadByRoot.ts b/packages/beacon-node/src/sync/utils/downloadByRoot.ts index 84c6388d79c7..c6a4def5bb1d 100644 --- a/packages/beacon-node/src/sync/utils/downloadByRoot.ts +++ b/packages/beacon-node/src/sync/utils/downloadByRoot.ts @@ -1,6 +1,13 @@ import {routes} from "@lodestar/api"; import {ChainForkConfig} from "@lodestar/config"; -import {ForkPostDeneb, ForkPostFulu, ForkPreFulu, isForkPostDeneb, isForkPostFulu} from "@lodestar/params"; +import { + ForkPostDeneb, + ForkPostFulu, + ForkPreFulu, + isForkPostDeneb, + isForkPostFulu, + isForkPostGloas, +} from "@lodestar/params"; import {BlobIndex, ColumnIndex, SignedBeaconBlock, Slot, deneb, fulu} from "@lodestar/types"; import {LodestarError, byteArrayEquals, fromHex, prettyPrintIndices, toHex, toRootHex} from "@lodestar/utils"; import {isBlockInputBlobs, isBlockInputColumns} from "../../chain/blocks/blockInput/blockInput.js"; @@ -263,7 +270,10 @@ export async function fetchByRoot({ blockRoot, }); const forkName = config.getForkName(block.message.slot); - if (isForkPostFulu(forkName)) { + if (isForkPostGloas(forkName)) { + // Post-gloas block sync only needs the block body. Payload columns stay on the + // payload/envelope path and are queued independently in the network processor. + } else if (isForkPostFulu(forkName)) { columnSidecarResult = await fetchAndValidateColumns({ config, chain, diff --git a/packages/beacon-node/src/sync/utils/pendingBlocksTree.ts b/packages/beacon-node/src/sync/utils/pendingBlocksTree.ts index 04c4d1346a3c..bc0bfa804e31 100644 --- a/packages/beacon-node/src/sync/utils/pendingBlocksTree.ts +++ b/packages/beacon-node/src/sync/utils/pendingBlocksTree.ts @@ -40,21 +40,6 @@ function addToDescendantBlocks( return descendantBlocks; } -export function getDescendantBlocks( - blockRootHex: RootHex, - blocks: Map -): BlockInputSyncCacheItem[] { - const descendantBlocks: BlockInputSyncCacheItem[] = []; - - for (const block of blocks.values()) { - if ((isPendingBlockInput(block) ? block.blockInput.parentRootHex : undefined) === blockRootHex) { - descendantBlocks.push(block); - } - } - - return descendantBlocks; -} - export type UnknownAndAncestorBlocks = { unknowns: BlockInputSyncCacheItem[]; ancestors: PendingBlockInput[]; diff --git a/packages/beacon-node/test/unit/chain/blocks/verifyBlocksSanityChecks.test.ts b/packages/beacon-node/test/unit/chain/blocks/verifyBlocksSanityChecks.test.ts index f1edb03d9789..485c2dfe5fe4 100644 --- a/packages/beacon-node/test/unit/chain/blocks/verifyBlocksSanityChecks.test.ts +++ b/packages/beacon-node/test/unit/chain/blocks/verifyBlocksSanityChecks.test.ts @@ -1,4 +1,5 @@ import {beforeEach, describe, expect, it} from "vitest"; +import {createChainForkConfig} from "@lodestar/config"; import {config} from "@lodestar/config/default"; import {IForkChoice, PayloadStatus, ProtoBlock} from "@lodestar/fork-choice"; import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; @@ -42,6 +43,24 @@ describe("chain / blocks / verifyBlocksSanityChecks", () => { expectThrowsLodestarError(() => verifyBlocksSanityChecks(modules, [block], {}), BlockErrorCode.PARENT_UNKNOWN); }); + it("PARENT_PAYLOAD_UNKNOWN", () => { + const gloasConfig = createChainForkConfig({ + ...config, + FULU_FORK_EPOCH: 0, + GLOAS_FORK_EPOCH: 0, + }); + const gloasBlock = ssz.gloas.SignedBeaconBlock.defaultValue(); + gloasBlock.message.slot = currentSlot; + + forkChoice.getBlockHexDefaultStatus.mockReturnValue({slot: 0} as ProtoBlock); + forkChoice.getBlockHexAndBlockHash.mockReturnValue(null); + + expectThrowsLodestarError( + () => verifyBlocksSanityChecks({...modules, config: gloasConfig}, [gloasBlock as SignedBeaconBlock], {}), + BlockErrorCode.PARENT_PAYLOAD_UNKNOWN + ); + }); + it("GENESIS_BLOCK", () => { block.message.slot = 0; expectThrowsLodestarError(() => verifyBlocksSanityChecks(modules, [block], {}), BlockErrorCode.GENESIS_BLOCK); diff --git a/packages/beacon-node/test/unit/chain/seenCache/seenPayloadEnvelopeInput.test.ts b/packages/beacon-node/test/unit/chain/seenCache/seenPayloadEnvelopeInput.test.ts new file mode 100644 index 000000000000..d228acf6ec09 --- /dev/null +++ b/packages/beacon-node/test/unit/chain/seenCache/seenPayloadEnvelopeInput.test.ts @@ -0,0 +1,71 @@ +import {beforeEach, describe, expect, it} from "vitest"; +import {testLogger} from "@lodestar/logger/test-utils"; +import {ForkName} from "@lodestar/params"; +import {ChainEventEmitter} from "../../../../src/chain/emitter.js"; +import {SeenPayloadEnvelopeInput} from "../../../../src/chain/seenCache/seenPayloadEnvelopeInput.js"; +import {SerializedCache} from "../../../../src/util/serializedCache.js"; +import {generateBlock} from "../../../utils/blocksAndData.js"; + +describe("SeenPayloadEnvelopeInput", () => { + let cache: SeenPayloadEnvelopeInput; + let abortController: AbortController; + let chainEvents: ChainEventEmitter; + let serializedCache: SerializedCache; + let validatedRoots: Set; + + beforeEach(() => { + chainEvents = new ChainEventEmitter(); + abortController = new AbortController(); + serializedCache = new SerializedCache(); + validatedRoots = new Set(); + + cache = new SeenPayloadEnvelopeInput({ + chainEvents, + signal: abortController.signal, + serializedCache, + hasValidatedPayload: (blockRootHex) => validatedRoots.has(blockRootHex), + metrics: null, + logger: testLogger(), + }); + }); + + function addPayloadInput(slot: number): string { + const {block, rootHex} = generateBlock({forkName: ForkName.gloas, slot}); + cache.add({ + blockRootHex: rootHex, + block, + forkName: ForkName.gloas, + sampledColumns: [], + custodyColumns: [], + timeCreatedSec: Date.now() / 1000, + }); + return rootHex; + } + + it("prune removes an explicit payload input", () => { + const rootHex = addPayloadInput(1); + + expect(cache.get(rootHex)).toBeDefined(); + + cache.prune(rootHex); + + expect(cache.get(rootHex)).toBeUndefined(); + }); + + it("pruneBelow keeps older payload inputs until their payload is validated", () => { + const rootHex = addPayloadInput(1); + + cache.pruneBelow(2); + + expect(cache.get(rootHex)).toBeDefined(); + }); + + it("pruneBelow removes older payload inputs once their payload is validated", () => { + const rootHex = addPayloadInput(1); + validatedRoots.add(rootHex); + + cache.pruneBelow(2); + + expect(cache.get(rootHex)).toBeUndefined(); + }); +}); diff --git a/packages/beacon-node/test/unit/sync/unknownBlock.test.ts b/packages/beacon-node/test/unit/sync/unknownBlock.test.ts index 8e2e401669db..a9019046083f 100644 --- a/packages/beacon-node/test/unit/sync/unknownBlock.test.ts +++ b/packages/beacon-node/test/unit/sync/unknownBlock.test.ts @@ -13,10 +13,7 @@ import {BlockInputPreData} from "../../../src/chain/blocks/blockInput/blockInput import {BlockInputSource, DAType, IBlockInput} from "../../../src/chain/blocks/blockInput/types.js"; import {PayloadError, PayloadErrorCode} from "../../../src/chain/blocks/importExecutionPayload.js"; import {PayloadEnvelopeInput} from "../../../src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.js"; -import { - CreateFromBlockProps, - PayloadEnvelopeInputSource, -} from "../../../src/chain/blocks/payloadEnvelopeInput/types.js"; +import {PayloadEnvelopeInputSource} from "../../../src/chain/blocks/payloadEnvelopeInput/types.js"; import {BlockError, BlockErrorCode} from "../../../src/chain/errors/blockError.js"; import {ChainEvent, ChainEventEmitter, IBeaconChain} from "../../../src/chain/index.js"; import {SeenBlockProposers} from "../../../src/chain/seenCache/seenBlockProposers.js"; @@ -87,7 +84,7 @@ function buildPayloadFixture({ const envelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue(); envelope.message.beaconBlockRoot = blockRoot; - envelope.message.slot = slot; + envelope.message.payload.slotNumber = slot; const columnSidecars = sampledColumns.map((index) => { const columnSidecar = ssz.gloas.DataColumnSidecar.defaultValue(); @@ -573,6 +570,7 @@ describe("UnknownBlockSync", () => { config: gloasConfig, genesisTime: 0, metrics: null, + serializedCache: {delete: vi.fn()} as unknown as IBeaconChain["serializedCache"], getBlockByRoot: vi.fn().mockResolvedValue(null), processExecutionPayload: vi.fn().mockResolvedValue(undefined), seenPayloadEnvelopeInputCache: { @@ -583,7 +581,7 @@ describe("UnknownBlockSync", () => { seenBlockProposers: {isKnown: vi.fn().mockReturnValue(false)} as unknown as SeenBlockProposers, forkChoice: { hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), - hasBlockHex: vi.fn().mockReturnValue(true), + hasBlockHex: vi.fn().mockReturnValue(false), getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), } as unknown as IForkChoice, ...chainOverrides, @@ -1029,7 +1027,7 @@ describe("UnknownBlockSync", () => { const invalidEnvelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue(); invalidEnvelope.message.beaconBlockRoot = blockRoot; - invalidEnvelope.message.slot = 1; + invalidEnvelope.message.payload.slotNumber = 1; vi.mocked(validateGossipExecutionPayloadEnvelope).mockImplementationOnce(async (_chain, signedEnvelope) => { if (signedEnvelope === invalidEnvelope) { @@ -1037,12 +1035,10 @@ describe("UnknownBlockSync", () => { } }); + let connected = false; let cachedPayloadInput: PayloadEnvelopeInput | undefined; const processExecutionPayload = vi.fn().mockResolvedValue(undefined); - const sendExecutionPayloadEnvelopesByRoot = vi - .fn() - .mockResolvedValueOnce([invalidEnvelope]) - .mockResolvedValueOnce([envelope]); + const sendExecutionPayloadEnvelopesByRoot = vi.fn().mockResolvedValueOnce([envelope]); const {emitter} = setupPayloadSyncTest({ chainOverrides: { processExecutionPayload, @@ -1051,121 +1047,38 @@ describe("UnknownBlockSync", () => { prune: vi.fn(), } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], }, - networkOverrides: {sendExecutionPayloadEnvelopesByRoot}, + networkOverrides: { + getConnectedPeers: () => (connected ? [peer] : []), + sendExecutionPayloadEnvelopesByRoot, + }, peers: [{peerId: peer}], }); - emitter.emit(ChainEvent.unknownEnvelopeBlockRoot, { - rootHex: blockRootHex, + emitter.emit(ChainEvent.envelopeUnknownBlock, { + envelope: invalidEnvelope, peer, source: BlockInputSource.gossip, }); await sleep(20); - expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenCalledTimes(1); + expect(sendExecutionPayloadEnvelopesByRoot).not.toHaveBeenCalled(); expect(processExecutionPayload).not.toHaveBeenCalled(); + connected = true; cachedPayloadInput = payloadInput; emitter.emit(routes.events.EventType.block, {slot: 1, block: blockRootHex, executionOptimistic: false}); await sleep(50); - expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenCalledTimes(2); + expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenCalledTimes(1); expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenNthCalledWith(1, peer, [blockRoot]); - expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenNthCalledWith(2, peer, [blockRoot]); expect(validateGossipExecutionPayloadEnvelope).toHaveBeenCalledTimes(2); expect(processExecutionPayload).toHaveBeenCalledTimes(1); expect(processExecutionPayload).toHaveBeenCalledWith(payloadInput); expect(payloadInput.hasPayloadEnvelope()).toBe(true); }); - it("refetches a replacement envelope after payload import rejects the cached one", async () => { - const peer = await getRandPeerIdStr(); - const { - block, - blockRoot, - blockRootHex, - payloadInput, - envelope: invalidEnvelope, - } = buildPayloadFixture({ - blobCount: 0, - sampledColumns: [], - slot: 1, - }); - - const recoveryEnvelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue(); - recoveryEnvelope.message.beaconBlockRoot = blockRoot; - recoveryEnvelope.message.slot = 1; - - const processExecutionPayload = vi - .fn() - .mockRejectedValueOnce( - new PayloadError({ - code: PayloadErrorCode.STATE_TRANSITION_ERROR, - message: "bad payload envelope", - }) - ) - .mockResolvedValueOnce(undefined); - let cachedPayloadInput: PayloadEnvelopeInput | undefined = payloadInput; - const prunePayloadInput = vi.fn().mockImplementation((root: string) => { - if (root === blockRootHex) { - cachedPayloadInput = undefined; - } - }); - const addPayloadInput = vi.fn().mockImplementation((props: CreateFromBlockProps) => { - cachedPayloadInput = PayloadEnvelopeInput.createFromBlock(props); - return cachedPayloadInput; - }); - const sendExecutionPayloadEnvelopesByRoot = vi - .fn() - .mockResolvedValueOnce([invalidEnvelope]) - .mockResolvedValueOnce([recoveryEnvelope]); - const {emitter} = setupPayloadSyncTest({ - chainOverrides: { - processExecutionPayload, - custodyConfig: {sampledColumns: [], custodyColumns: []} as unknown as CustodyConfig, - getBlockByRoot: vi.fn().mockResolvedValue({block, executionOptimistic: false, finalized: false}), - seenPayloadEnvelopeInputCache: { - add: addPayloadInput, - get: vi.fn().mockImplementation((root: string) => (root === blockRootHex ? cachedPayloadInput : undefined)), - prune: prunePayloadInput, - } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], - }, - networkOverrides: {sendExecutionPayloadEnvelopesByRoot}, - peers: [{peerId: peer}], - }); - - emitter.emit(ChainEvent.unknownEnvelopeBlockRoot, { - rootHex: blockRootHex, - peer, - source: BlockInputSource.gossip, - }); - - await sleep(20); - - expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenCalledTimes(1); - expect(processExecutionPayload).toHaveBeenCalledTimes(1); - expect(prunePayloadInput).toHaveBeenCalledWith(blockRootHex); - expect(cachedPayloadInput).toBeUndefined(); - - emitter.emit(ChainEvent.unknownEnvelopeBlockRoot, { - rootHex: blockRootHex, - peer, - source: BlockInputSource.gossip, - }); - - await sleep(50); - - expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenCalledTimes(2); - expect(processExecutionPayload).toHaveBeenCalledTimes(2); - expect(addPayloadInput).toHaveBeenCalledTimes(1); - expect(cachedPayloadInput).toBeDefined(); - expect(processExecutionPayload).toHaveBeenNthCalledWith(2, cachedPayloadInput); - expect(cachedPayloadInput?.hasPayloadEnvelope()).toBe(true); - expect(cachedPayloadInput?.getPayloadEnvelope()).toBe(recoveryEnvelope); - }); - it("retries payload processing on a later scheduler pass after an execution engine error", async () => { const peer = await getRandPeerIdStr(); const {blockRootHex, payloadInput, envelope} = buildPayloadFixture({ @@ -1292,7 +1205,7 @@ describe("UnknownBlockSync", () => { expect(processExecutionPayload).toHaveBeenCalledWith(payloadInput); }); - it("recovers parent payload for unknown parent block when parent block is already known", async () => { + it("downloads parent payload for unknown parent block when parent block is already known", async () => { const peer = await getRandPeerIdStr(); const parentPayloadHash = Buffer.alloc(32, 0x33); const { @@ -1451,7 +1364,7 @@ describe("UnknownBlockSync", () => { expect(sendExecutionPayloadEnvelopesByRoot).not.toHaveBeenCalled(); expect(processBlock).not.toHaveBeenCalled(); expect(seenBlockInputPrune).toHaveBeenCalledWith(childBlockRootHex); - expect(seenPayloadPrune).toHaveBeenCalledWith(childBlockRootHex); + expect(seenPayloadPrune).not.toHaveBeenCalled(); }); it("removes pending descendants after invalid parent payload", async () => { @@ -1523,14 +1436,13 @@ describe("UnknownBlockSync", () => { expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenCalledTimes(1); expect(processExecutionPayload).toHaveBeenCalledTimes(1); expect(processBlock).not.toHaveBeenCalled(); - expect(seenPayloadPrune).toHaveBeenCalledWith(parentRootHex); + expect(seenPayloadPrune).not.toHaveBeenCalled(); emitter.emit(routes.events.EventType.executionPayload, { slot: 99, builderIndex: 0, blockHash: toRootHex(Buffer.alloc(32, 0x44)), blockRoot: toRootHex(Buffer.alloc(32, 0x55)), - stateRoot: toRootHex(Buffer.alloc(32, 0x66)), executionOptimistic: false, }); diff --git a/packages/beacon-node/test/unit/sync/utils/downloadByRoot.test.ts b/packages/beacon-node/test/unit/sync/utils/downloadByRoot.test.ts index 281644b7d149..209756e312a9 100644 --- a/packages/beacon-node/test/unit/sync/utils/downloadByRoot.test.ts +++ b/packages/beacon-node/test/unit/sync/utils/downloadByRoot.test.ts @@ -7,12 +7,14 @@ import {BlobSidecarValidationError} from "../../../../src/chain/errors/blobSidec import {DataColumnSidecarValidationError} from "../../../../src/chain/errors/dataColumnSidecarError.js"; import {INetwork} from "../../../../src/network/index.js"; import {PeerSyncMeta} from "../../../../src/network/peers/peersData.js"; +import {PendingBlockInputStatus} from "../../../../src/sync/types.js"; import { DownloadByRootError, fetchAndValidateBlobs, fetchAndValidateBlock, fetchAndValidateColumns, fetchBlobsByRoot, + fetchByRoot, fetchColumnsByRoot, } from "../../../../src/sync/utils/downloadByRoot.js"; import {ROOT_SIZE} from "../../../../src/util/sszBytes.js"; @@ -320,6 +322,41 @@ describe("downloadByRoot.ts", () => { }); }); + describe("fetchByRoot", () => { + afterEach(() => { + vi.resetAllMocks(); + }); + + it("does not fetch columns for bare-root gloas block sync", async () => { + const gloasBlockWithColumns = generateBlockWithColumnSidecars({forkName: ForkName.gloas}); + const sendBeaconBlocksByRoot = vi.fn(() => Promise.resolve([gloasBlockWithColumns.block])); + const sendDataColumnSidecarsByRoot = vi.fn(); + network = { + sendBeaconBlocksByRoot, + sendDataColumnSidecarsByRoot, + } as unknown as INetwork; + + const response = await fetchByRoot({ + config, + chain: null, + network, + peerMeta, + blockRoot: gloasBlockWithColumns.blockRoot, + cacheItem: { + status: PendingBlockInputStatus.pending, + rootHex: gloasBlockWithColumns.rootHex, + timeAddedSec: 0, + peerIdStrings: new Set(), + }, + }); + + expect(sendBeaconBlocksByRoot).toHaveBeenCalledOnce(); + expect(sendDataColumnSidecarsByRoot).not.toHaveBeenCalled(); + expect(response.result.block).toEqual(gloasBlockWithColumns.block); + expect(response.result.columnSidecars).toBeUndefined(); + }); + }); + describe("fetchColumnsByRoot", () => { let fuluBlockWithColumns: BlockWithColumnsTestSet; beforeAll(() => { diff --git a/packages/beacon-node/test/unit/sync/utils/pendingBlocksTree.test.ts b/packages/beacon-node/test/unit/sync/utils/pendingBlocksTree.test.ts index 88749eca1101..05a1ebc6ba46 100644 --- a/packages/beacon-node/test/unit/sync/utils/pendingBlocksTree.test.ts +++ b/packages/beacon-node/test/unit/sync/utils/pendingBlocksTree.test.ts @@ -9,7 +9,6 @@ import { import { UnknownAndAncestorBlocks, getAllDescendantBlocks, - getDescendantBlocks, getUnknownAndAncestorBlocks, } from "../../../../src/sync/utils/pendingBlocksTree.js"; import {MockBlockInput} from "../../../utils/blockInput.js"; @@ -19,14 +18,12 @@ describe("sync / pendingBlocksTree", () => { id: string; blocks: {block: string; parent: string | null}[]; getAllDescendantBlocks: {block: string; res: string[]}[]; - getDescendantBlocks: {block: string; res: string[]}[]; getUnknownOrAncestorBlocks: {unknowns: string[]; ancestors: string[]}; }[] = [ { id: "empty case", blocks: [], getAllDescendantBlocks: [{block: "0A", res: []}], - getDescendantBlocks: [{block: "0A", res: []}], getUnknownOrAncestorBlocks: {unknowns: [], ancestors: []}, }, { @@ -45,12 +42,6 @@ describe("sync / pendingBlocksTree", () => { {block: "3C", res: ["4C"]}, {block: "3B", res: []}, ], - getDescendantBlocks: [ - {block: "0A", res: ["1A"]}, - {block: "1A", res: ["2A", "2B"]}, - {block: "3C", res: ["4C"]}, - {block: "3B", res: []}, - ], getUnknownOrAncestorBlocks: {unknowns: ["0A"], ancestors: ["4C"]}, }, ]; @@ -74,12 +65,6 @@ describe("sync / pendingBlocksTree", () => { }); } - for (const {block, res} of testCase.getDescendantBlocks) { - it(`getDescendantBlocks(${block})`, () => { - expect(toRes(getDescendantBlocks(block, blocks))).toEqual(res); - }); - } - it("getUnknownBlocks", () => { expect(toRes2(getUnknownAndAncestorBlocks(blocks))).toEqual(testCase.getUnknownOrAncestorBlocks); }); From 6b08376dcb15ec64a1a90c433757a1fef53be57d Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:53:39 -0700 Subject: [PATCH 35/67] Remove block production --- packages/beacon-node/src/chain/chain.ts | 21 ------- .../chain/produceBlock/produceBlockBody.ts | 61 ++----------------- 2 files changed, 5 insertions(+), 77 deletions(-) diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 310bae691894..2ee877c9d7f3 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -39,7 +39,6 @@ import { ValidatorIndex, Wei, deneb, - electra, gloas, isBlindedBeaconBlock, phase0, @@ -871,26 +870,6 @@ export class BeaconChain implements IBeaconChain { ); } - /** - * Get execution requests from parent's payload envelope for block production. - * Uses is_payload_verified AND should_extend_payload per spec's prepare_execution_payload. - * If parent was FULL and PTC voted timely, returns execution requests from the cached envelope. - * Otherwise returns empty execution requests (build on EMPTY variant). - */ - getParentExecutionRequests(parentBlockRootHex: RootHex): electra.ExecutionRequests { - if ( - this.forkChoice.hasPayloadHexUnsafe(parentBlockRootHex) && - this.forkChoice.shouldExtendPayload(parentBlockRootHex) - ) { - const payloadInput = this.seenPayloadEnvelopeInputCache.get(parentBlockRootHex); - if (payloadInput?.hasPayloadEnvelope()) { - return payloadInput.getPayloadEnvelope().message.executionRequests; - } - } - // Parent was EMPTY, payload not verified, or PTC didn't vote timely, return empty requests - return ssz.electra.ExecutionRequests.defaultValue(); - } - async getExecutionPayloadEnvelope( blockSlot: Slot, blockRootHex: string diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index 59fe704f664b..4db6e0c00f68 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -282,11 +282,7 @@ export async function produceBlockBody( gloasBody.signedExecutionPayloadBid = signedBid; // TODO GLOAS: Get payload attestations from pool for previous slot gloasBody.payloadAttestations = []; - // Parent's execution requests are applied when this block is processed (via - // processParentExecutionPayload), so the proposer must commit to them here. - // If parent was FULL: include execution requests from its envelope - // If parent was EMPTY: include empty execution requests - gloasBody.parentExecutionRequests = this.getParentExecutionRequests(parentBlock.blockRoot); + // TODO GLOAS: set parentExecutionRequests in the block body blockBody = gloasBody as AssembledBodyType; // Store execution payload data required to construct execution payload envelope later @@ -615,12 +611,6 @@ export async function prepareExecutionPayload( chain: { executionEngine: IExecutionEngine; config: ChainForkConfig; - forkChoice?: IForkChoice; - seenPayloadEnvelopeInputCache?: { - get( - rootHex: string - ): {hasPayloadEnvelope(): boolean; getPayloadEnvelope(): gloas.SignedExecutionPayloadEnvelope} | undefined; - }; }, logger: Logger, fork: ForkPostBellatrix, @@ -631,46 +621,11 @@ export async function prepareExecutionPayload( state: IBeaconStateViewBellatrix, suggestedFeeRecipient: string ): Promise<{prepType: PayloadPreparationType; payloadId: PayloadId}> { - let parentHash = parentBlockHash; - let withdrawalsOverride: capella.Withdrawal[] | undefined; - - // For Gloas: determine FULL vs EMPTY parent per spec's prepare_execution_payload - // If extending FULL parent: apply parent payload to get correct withdrawals and use bid.blockHash - // If EMPTY parent: use state.payloadExpectedWithdrawals and bid.parentBlockHash - if (isForkPostGloas(fork) && chain.forkChoice && chain.seenPayloadEnvelopeInputCache) { - const gloasState = state as unknown as { - latestExecutionPayloadBid: { - slot: number; - blockHash: Uint8Array; - parentBlockHash: Uint8Array; - builderIndex: number; - value: number; - feeRecipient: Uint8Array; - }; - }; - const parentRootHex = toRootHex(parentBlockRoot); - - if (chain.forkChoice.shouldExtendPayload(parentRootHex)) { - const payloadInput = chain.seenPayloadEnvelopeInputCache.get(parentRootHex); - if (payloadInput?.hasPayloadEnvelope()) { - // Build on FULL variant: apply parent payload to compute correct withdrawals, - // use bid.blockHash as EL head (per spec's prepare_execution_payload) - const gloasView = state as unknown as { - getExpectedWithdrawalsForFullParent(envelope: gloas.SignedExecutionPayloadEnvelope): capella.Withdrawal[]; - }; - withdrawalsOverride = gloasView.getExpectedWithdrawalsForFullParent(payloadInput.getPayloadEnvelope()); - parentHash = gloasState.latestExecutionPayloadBid.blockHash; - } - } else { - // EMPTY parent: use bid.parentBlockHash as the EL head - parentHash = gloasState.latestExecutionPayloadBid.parentBlockHash; - } - } const timestamp = computeTimeAtSlot(chain.config, state.slot, state.genesisTime); const prevRandao = state.getRandaoMix(state.epoch); const payloadIdCached = chain.executionEngine.payloadIdCache.get({ - headBlockHash: toRootHex(parentHash), + headBlockHash: toRootHex(parentBlockHash), finalizedBlockHash, timestamp: numToQuantity(timestamp), prevRandao: toHex(prevRandao), @@ -701,12 +656,11 @@ export async function prepareExecutionPayload( parentBlockRoot, parentBlockHash, feeRecipient: suggestedFeeRecipient, - withdrawalsOverride, }); payloadId = await chain.executionEngine.notifyForkchoiceUpdate( fork, - toRootHex(parentHash), + toRootHex(parentBlockHash), safeBlockHash, finalizedBlockHash, attributes @@ -812,14 +766,12 @@ function preparePayloadAttributes( parentBlockRoot, parentBlockHash, feeRecipient, - withdrawalsOverride, }: { prepareState: IBeaconStateViewBellatrix; prepareSlot: Slot; parentBlockRoot: Root; parentBlockHash: Bytes32; feeRecipient: string; - withdrawalsOverride?: capella.Withdrawal[]; } ): SSEPayloadAttributes["payloadAttributes"] { const timestamp = computeTimeAtSlot(chain.config, prepareSlot, prepareState.genesisTime); @@ -835,10 +787,7 @@ function preparePayloadAttributes( throw new Error("Expected Capella state for withdrawals"); } - if (withdrawalsOverride) { - // FULL parent: withdrawals computed from state with parent payload applied - (payloadAttributes as capella.SSEPayloadAttributes["payloadAttributes"]).withdrawals = withdrawalsOverride; - } else if (isStatePostGloas(prepareState)) { + if (isStatePostGloas(prepareState)) { const isExtendingPayload = byteArrayEquals(parentBlockHash, prepareState.latestExecutionPayloadBid.blockHash); // When the parent block is empty, state.payloadExpectedWithdrawals holds a batch // already deducted from CL balances but never credited on the EL (the envelope @@ -848,7 +797,7 @@ function preparePayloadAttributes( ? prepareState.getExpectedWithdrawals().expectedWithdrawals : prepareState.payloadExpectedWithdrawals; } else { - // Pre-Gloas or Gloas with full parent but no override (shouldn't happen in normal flow) + // withdrawals logic is now fork aware as it changes on electra fork post capella (payloadAttributes as capella.SSEPayloadAttributes["payloadAttributes"]).withdrawals = prepareState.getExpectedWithdrawals().expectedWithdrawals; } From 8967ae52c97f501f4b5ae17a9d3b1c8ac44a196c Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 12:03:29 +0100 Subject: [PATCH 36/67] refactor --- .../chain/blocks/importExecutionPayload.ts | 63 ++++++++++--------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index d76c9106d241..9d06a4965419 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -72,19 +72,20 @@ function toForkChoiceExecutionStatus(status: ExecutionPayloadStatus): PayloadExe /** * Import an execution payload envelope after all data is available. * - * The envelope is purely verified here, no state mutation. State effects from the - * payload are applied on the next block via processParentExecutionPayload. + * The envelope is only verified here, no state mutation. State effects from the payload + * are applied on the next block via processParentExecutionPayload. * * Steps: - * 1. Emit `execution_payload_available` for payload attestation + * 1. Emit `execution_payload_available` event for payload attestation * 2. Get the ProtoBlock from fork choice - * 3. Apply write-queue backpressure - * 4. Regenerate block state for envelope field validation - * 5. Run EL verification and signature verification in parallel, plus pure envelope verification - * 6. Persist verified payload envelope to hot DB - * 7. Update fork choice (transitions PENDING -> FULL) - * 8. Record metrics - * 9. Emit `execution_payload` event + * 3. Wait for data columns to be available + * 4. Apply write-queue backpressure before verification + * 5. Regenerate block state (post-block, pre-payload) for envelope verification + * 6. Run EL verification (notifyNewPayload) and BLS signature verification in parallel + * 7. Verify envelope fields against state (spec: verify_execution_payload_envelope) + * 8. Persist verified payload envelope to hot DB + * 9. Update fork choice (transitions the block's PENDING variant to FULL) + * 10. Record metrics and emit `execution_payload` event */ export async function importExecutionPayload( this: BeaconChain, @@ -94,14 +95,18 @@ export async function importExecutionPayload( ): Promise { const signedEnvelope = payloadInput.getPayloadEnvelope(); const envelope = signedEnvelope.message; + const slot = envelope.payload.slotNumber; const blockRootHex = payloadInput.blockRootHex; const blockHashHex = payloadInput.getBlockHashHex(); - const fork = this.config.getForkName(envelope.payload.slotNumber); + const fork = this.config.getForkName(slot); - // 1. Emit `execution_payload_available` event at the start of import - if (this.clock.currentSlot - envelope.payload.slotNumber < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { + // 1. Emit `execution_payload_available` event at the start of import. At this point the + // payload input is already complete, so the payload and required data are available for + // payload attestation. This event only signals availability (not validity), so we can emit + // it before getting a response from the EL on whether the payload is valid or not. + if (this.clock.currentSlot - slot < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { this.emitter.emit(routes.events.EventType.executionPayloadAvailable, { - slot: envelope.payload.slotNumber, + slot, blockRoot: blockRootHex, }); } @@ -123,8 +128,8 @@ export async function importExecutionPayload( // The actual DB write is deferred until after verification succeeds. await this.unfinalizedPayloadEnvelopeWrites.waitForSpace(); - // 5. Get pre-state for envelope verification - // We need the block state (post-block, pre-payload) to verify the envelope + // 5. Get pre-state for envelope verification. + // We need the block state (post-block, pre-payload) to verify the envelope. const blockState = await this.regen.getBlockSlotState( protoBlock, protoBlock.slot, @@ -138,7 +143,7 @@ export async function importExecutionPayload( }); } - // 6. Run verification steps in parallel + // 6. Run EL verification and BLS signature verification in parallel const [execResult, signatureValid] = await Promise.all([ this.executionEngine.notifyNewPayload( fork, @@ -160,10 +165,10 @@ export async function importExecutionPayload( ), ]); - // 5a. Verify envelope fields against state (spec: verify_execution_payload_envelope) + // 7. Verify envelope fields against state (spec: verify_execution_payload_envelope) try { // When validSignature is true, the envelope came from gossip/API where both - // signature and executionRequestsRoot were already verified, skip re-hashing + // signature and executionRequestsRoot were already verified, skip re-hashing. verifyExecutionPayloadEnvelope(this.config, blockState, envelope, { verifyExecutionRequestsRoot: !opts.validSignature, }); @@ -177,12 +182,12 @@ export async function importExecutionPayload( ); } - // 5b. Check signature verification result + // 7a. Check signature verification result if (!signatureValid) { throw new PayloadError({code: PayloadErrorCode.INVALID_SIGNATURE}); } - // 5c. Handle EL response + // 7b. Handle EL response switch (execResult.status) { case ExecutionPayloadStatus.VALID: break; @@ -208,31 +213,31 @@ export async function importExecutionPayload( }); } - // 6. Persist payload envelope to hot DB + // 8. 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: envelope.payload.slotNumber, blockRoot: blockRootHex}, + {slot, blockRoot: blockRootHex}, e as Error ); } }); - // 7. Update fork choice, transitions the block's PENDING variant to FULL + // 9. Update fork choice, transitions the block's PENDING variant to FULL const execStatus = toForkChoiceExecutionStatus(execResult.status); this.forkChoice.onExecutionPayload(blockRootHex, blockHashHex, envelope.payload.blockNumber, execStatus); - // 8. Record metrics for payload envelope and column sources + // 10. Record metrics for payload envelope and column sources, emit `execution_payload` + // event for recent enough payloads after successful import. this.metrics?.importPayload.bySource.inc({source: payloadInput.getPayloadEnvelopeSource().source}); for (const {source} of payloadInput.getSampledColumnsWithSource()) { this.metrics?.importPayload.columnsBySource.inc({source}); } - // 9. Emit event after payload is fully verified and imported to fork choice - if (this.clock.currentSlot - envelope.payload.slotNumber < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { + if (this.clock.currentSlot - slot < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { this.emitter.emit(routes.events.EventType.executionPayload, { - slot: envelope.payload.slotNumber, + slot, builderIndex: envelope.builderIndex, blockHash: blockHashHex, blockRoot: blockRootHex, @@ -242,7 +247,7 @@ export async function importExecutionPayload( } this.logger.verbose("Execution payload imported", { - slot: envelope.payload.slotNumber, + slot, builderIndex: envelope.builderIndex, blockRoot: blockRootHex, blockHash: blockHashHex, From 5b1e34459bc53a01f92eb63f9a4d1cfc3ed4ea60 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 12:25:41 +0100 Subject: [PATCH 37/67] simplify applyParentExecutionPayload function signature --- .../src/block/processParentExecutionPayload.ts | 9 +++------ .../state-transition/src/stateView/beaconStateView.ts | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/state-transition/src/block/processParentExecutionPayload.ts b/packages/state-transition/src/block/processParentExecutionPayload.ts index 176aea949fa8..a745b63ab2c3 100644 --- a/packages/state-transition/src/block/processParentExecutionPayload.ts +++ b/packages/state-transition/src/block/processParentExecutionPayload.ts @@ -33,7 +33,7 @@ export function processParentExecutionPayload(state: CachedBeaconStateGloas, blo ); } - applyParentExecutionPayload(state, parentBid, requests); + applyParentExecutionPayload(state, requests); } /** @@ -45,11 +45,8 @@ export function processParentExecutionPayload(state: CachedBeaconStateGloas, blo * * Spec: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/gloas/beacon-chain.md#new-apply_parent_execution_payload */ -export function applyParentExecutionPayload( - state: CachedBeaconStateGloas, - parentBid: {slot: number; blockHash: Uint8Array; builderIndex: number; value: number; feeRecipient: Uint8Array}, - requests: electra.ExecutionRequests -): void { +export function applyParentExecutionPayload(state: CachedBeaconStateGloas, requests: electra.ExecutionRequests): void { + const parentBid = state.latestExecutionPayloadBid; const fork = state.config.getForkSeq(state.slot); const parentSlot = parentBid.slot; const parentEpoch = computeEpochAtSlot(parentSlot); diff --git a/packages/state-transition/src/stateView/beaconStateView.ts b/packages/state-transition/src/stateView/beaconStateView.ts index c9eed49599b7..08ed99db0a84 100644 --- a/packages/state-transition/src/stateView/beaconStateView.ts +++ b/packages/state-transition/src/stateView/beaconStateView.ts @@ -789,7 +789,7 @@ export class BeaconStateView implements IBeaconStateViewLatestFork { throw Error("getExpectedWithdrawalsForFullParent is only available for gloas+ forks"); } const stateCopy = this.cachedState.clone(true) as CachedBeaconStateGloas; - applyParentExecutionPayload(stateCopy, stateCopy.latestExecutionPayloadBid, envelope.message.executionRequests); + applyParentExecutionPayload(stateCopy, envelope.message.executionRequests); const {expectedWithdrawals} = getExpectedWithdrawals(fork, stateCopy); return expectedWithdrawals; } From 9a9fe6b8d8f4ba945b45200f6715d1d3d3b24880 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 12:58:51 +0100 Subject: [PATCH 38/67] remove genesis block handling, will be separate pr --- .../src/block/processParentExecutionPayload.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/state-transition/src/block/processParentExecutionPayload.ts b/packages/state-transition/src/block/processParentExecutionPayload.ts index a745b63ab2c3..868dc7b18a67 100644 --- a/packages/state-transition/src/block/processParentExecutionPayload.ts +++ b/packages/state-transition/src/block/processParentExecutionPayload.ts @@ -17,9 +17,8 @@ export function processParentExecutionPayload(state: CachedBeaconStateGloas, blo const parentBid = state.latestExecutionPayloadBid; const requests = block.body.parentExecutionRequests; - const isGenesisBlock = byteArrayEquals(parentBid.blockHash, ssz.Root.defaultValue()); - const isParentBlockEmpty = !byteArrayEquals(bid.parentBlockHash, parentBid.blockHash); - if (isGenesisBlock || isParentBlockEmpty) { + const isParentBlockFull = byteArrayEquals(bid.parentBlockHash, parentBid.blockHash); + if (!isParentBlockFull) { // Parent was EMPTY -- no execution requests expected assertEmptyExecutionRequests(requests); return; From bf7009e5303e61d176a20ea242c6cd585bc7d44e Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 13:05:00 +0100 Subject: [PATCH 39/67] we don't know and it's wrong, removed confusing todo --- packages/beacon-node/src/chain/forkChoice/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/beacon-node/src/chain/forkChoice/index.ts b/packages/beacon-node/src/chain/forkChoice/index.ts index 3adcd5a19210..312d7c270054 100644 --- a/packages/beacon-node/src/chain/forkChoice/index.ts +++ b/packages/beacon-node/src/chain/forkChoice/index.ts @@ -148,7 +148,7 @@ export function initializeForkChoiceFromFinalizedState( : {executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}), dataAvailabilityStatus: DataAvailabilityStatus.PreData, - payloadStatus: isForkPostGloas ? PayloadStatus.EMPTY : PayloadStatus.FULL, // TODO GLOAS: Post-gloas how do we know if the checkpoint payload is FULL or EMPTY? + payloadStatus: isForkPostGloas ? PayloadStatus.PENDING : PayloadStatus.FULL, parentBlockHash: isStatePostGloas(state) ? toRootHex(state.latestBlockHash) : null, }, currentSlot @@ -240,7 +240,7 @@ export function initializeForkChoiceFromUnfinalizedState( : {executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}), dataAvailabilityStatus: DataAvailabilityStatus.PreData, - payloadStatus: isForkPostGloas ? PayloadStatus.EMPTY : PayloadStatus.FULL, // TODO GLOAS: Post-gloas how do we know if the checkpoint payload is FULL or EMPTY? + payloadStatus: isForkPostGloas ? PayloadStatus.PENDING : PayloadStatus.FULL, parentBlockHash: isStatePostGloas(unfinalizedState) ? toRootHex(unfinalizedState.latestBlockHash) : null, }; From d2424d522c1f7038b0f9d4e7ed03862f2b5e826e Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 13:36:12 +0100 Subject: [PATCH 40/67] review computeAnchorCheckpoint --- .../chain/opPools/aggregatedAttestationPool.test.ts | 2 +- .../test/unit/chain/forkChoice/forkChoice.test.ts | 8 ++++---- packages/beacon-node/test/utils/node/beacon.ts | 2 +- packages/cli/src/cmds/beacon/initBeaconState.ts | 6 +++--- packages/state-transition/src/cache/epochCache.ts | 6 +++--- .../src/stateView/beaconStateView.ts | 2 +- .../src/util/computeAnchorCheckpoint.ts | 13 +++++-------- .../state-transition/src/util/epochShuffling.ts | 9 ++------- .../test/unit/cachedBeaconState.test.ts | 6 +++--- 9 files changed, 23 insertions(+), 31 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 febf125248db..1972ba501572 100644 --- a/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts +++ b/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts @@ -35,7 +35,7 @@ describe.skip(`getAttestationsForBlock vc=${vc}`, () => { () => { originalState = generatePerfTestCachedStateElectra({goBackOneSlot: true, vc}); - const {blockHeader, checkpoint} = computeAnchorCheckpoint(originalState.config, originalState); + const {blockHeader, checkpoint} = computeAnchorCheckpoint(originalState); // TODO figure out why getBlockRootAtSlot(originalState, justifiedSlot) is not the same to justifiedCheckpoint.root const finalizedEpoch = originalState.finalizedCheckpoint.epoch; const finalizedCheckpoint = { diff --git a/packages/beacon-node/test/unit/chain/forkChoice/forkChoice.test.ts b/packages/beacon-node/test/unit/chain/forkChoice/forkChoice.test.ts index d611ef0efcd7..4708158966b6 100644 --- a/packages/beacon-node/test/unit/chain/forkChoice/forkChoice.test.ts +++ b/packages/beacon-node/test/unit/chain/forkChoice/forkChoice.test.ts @@ -83,7 +83,7 @@ describe("LodestarForkChoice", () => { * parent (37) - child (38) */ it.skip("getHead - should not consider orphaned block as head", () => { - const {blockHeader} = computeAnchorCheckpoint(config, anchorState); + const {blockHeader} = computeAnchorCheckpoint(anchorState); const finalizedRoot = ssz.phase0.BeaconBlockHeader.hashTreeRoot(blockHeader); const targetBlock = generateSignedBlockAtSlot(32); targetBlock.message.parentRoot = finalizedRoot; @@ -159,7 +159,7 @@ describe("LodestarForkChoice", () => { * finalized - slot 8 (finalized 1) - slot 12 - slot 16 (finalized 2) - slot 20 - slot 24 (finalized 3) - slot 28 - slot 32 (finalized 4) */ it("prune - should prune old blocks", () => { - const {blockHeader} = computeAnchorCheckpoint(config, anchorState); + const {blockHeader} = computeAnchorCheckpoint(anchorState); const finalizedRoot = ssz.phase0.BeaconBlockHeader.hashTreeRoot(blockHeader); const block08 = generateSignedBlockAtSlot(8); block08.message.parentRoot = finalizedRoot; @@ -226,7 +226,7 @@ describe("LodestarForkChoice", () => { * parent (34) - child (35) */ it("getAllNonAncestorBlocks - should get non ancestor nodes", () => { - const {blockHeader} = computeAnchorCheckpoint(config, anchorState); + const {blockHeader} = computeAnchorCheckpoint(anchorState); const finalizedRoot = ssz.phase0.BeaconBlockHeader.hashTreeRoot(blockHeader); const targetBlock = generateSignedBlockAtSlot(32); targetBlock.message.parentRoot = finalizedRoot; @@ -301,7 +301,7 @@ describe("LodestarForkChoice", () => { it.skip("should not filter blocks with unrealized checkpoints = store checkpoints", () => { const blockDelaySec = 0; // C9 is the justified/finalized block - const {blockHeader} = computeAnchorCheckpoint(config, anchorState); + const {blockHeader} = computeAnchorCheckpoint(anchorState); const finalizedRoot = ssz.phase0.BeaconBlockHeader.hashTreeRoot(blockHeader); // C10 const blockW = generateSignedBlockAtSlot(8); diff --git a/packages/beacon-node/test/utils/node/beacon.ts b/packages/beacon-node/test/utils/node/beacon.ts index 2748ac284f76..c6660d1227cc 100644 --- a/packages/beacon-node/test/utils/node/beacon.ts +++ b/packages/beacon-node/test/utils/node/beacon.ts @@ -77,7 +77,7 @@ export async function getDevBeaconNode( // derive wsCheckpoint if not provided if (!wsCheckpoint) { - const {checkpoint} = computeAnchorCheckpoint(config, anchorState); + const {checkpoint} = computeAnchorCheckpoint(anchorState); wsCheckpoint = {root: checkpoint.root, epoch: checkpoint.epoch}; logger.debug("Derived wsCheckpoint", {epoch: checkpoint.epoch}); } diff --git a/packages/cli/src/cmds/beacon/initBeaconState.ts b/packages/cli/src/cmds/beacon/initBeaconState.ts index 0f9fabd43098..c3899ad8b9c4 100644 --- a/packages/cli/src/cmds/beacon/initBeaconState.ts +++ b/packages/cli/src/cmds/beacon/initBeaconState.ts @@ -201,7 +201,7 @@ export async function initBeaconState( logger ); - const {checkpoint} = computeAnchorCheckpoint(chainForkConfig, stateAndCp.anchorState); + const {checkpoint} = computeAnchorCheckpoint(stateAndCp.anchorState); logger.info("Initialized checkpoint state", { slot: stateAndCp.anchorState.slot, @@ -230,7 +230,7 @@ export async function initBeaconState( logger ); - const {checkpoint} = computeAnchorCheckpoint(chainForkConfig, stateAndCp.anchorState); + const {checkpoint} = computeAnchorCheckpoint(stateAndCp.anchorState); logger.info("Initialized checkpoint state", { slot: stateAndCp.anchorState.slot, @@ -289,7 +289,7 @@ export async function initBeaconState( ); const lastProcessedSlot = stateAndCp.anchorState.latestBlockHeader.slot; - const {checkpoint} = computeAnchorCheckpoint(chainForkConfig, stateAndCp.anchorState); + const {checkpoint} = computeAnchorCheckpoint(stateAndCp.anchorState); logger.info("Initialized checkpoint state", { slot: stateAndCp.anchorState.slot, diff --git a/packages/state-transition/src/cache/epochCache.ts b/packages/state-transition/src/cache/epochCache.ts index cf0805be7bfc..d65086f7536b 100644 --- a/packages/state-transition/src/cache/epochCache.ts +++ b/packages/state-transition/src/cache/epochCache.ts @@ -346,11 +346,11 @@ export class EpochCache { // BeaconChain could provide a shuffling getter to avoid re-computing shuffling every epoch // in that case, we don't need to compute shufflings again const shufflingGetter = opts?.shufflingGetter; - const previousDecisionRoot = calculateShufflingDecisionRoot(config, state, previousEpoch); + const previousDecisionRoot = calculateShufflingDecisionRoot(state, previousEpoch); const cachedPreviousShuffling = shufflingGetter?.(previousEpoch, previousDecisionRoot); - const currentDecisionRoot = calculateShufflingDecisionRoot(config, state, currentEpoch); + const currentDecisionRoot = calculateShufflingDecisionRoot(state, currentEpoch); const cachedCurrentShuffling = shufflingGetter?.(currentEpoch, currentDecisionRoot); - const nextDecisionRoot = calculateShufflingDecisionRoot(config, state, nextEpoch); + const nextDecisionRoot = calculateShufflingDecisionRoot(state, nextEpoch); const cachedNextShuffling = shufflingGetter?.(nextEpoch, nextDecisionRoot); for (let i = 0; i < validatorCount; i++) { diff --git a/packages/state-transition/src/stateView/beaconStateView.ts b/packages/state-transition/src/stateView/beaconStateView.ts index 08ed99db0a84..62c74471c9e9 100644 --- a/packages/state-transition/src/stateView/beaconStateView.ts +++ b/packages/state-transition/src/stateView/beaconStateView.ts @@ -471,7 +471,7 @@ export class BeaconStateView implements IBeaconStateViewLatestFork { } computeAnchorCheckpoint(): {checkpoint: phase0.Checkpoint; blockHeader: phase0.BeaconBlockHeader} { - return computeAnchorCheckpoint(this.config, this.cachedState); + return computeAnchorCheckpoint(this.cachedState); } // Sync committees diff --git a/packages/state-transition/src/util/computeAnchorCheckpoint.ts b/packages/state-transition/src/util/computeAnchorCheckpoint.ts index d262aa4500df..92386ce8983a 100644 --- a/packages/state-transition/src/util/computeAnchorCheckpoint.ts +++ b/packages/state-transition/src/util/computeAnchorCheckpoint.ts @@ -1,16 +1,13 @@ -import {ChainForkConfig} from "@lodestar/config"; import {ZERO_HASH} from "@lodestar/params"; import {phase0, ssz} from "@lodestar/types"; import {BeaconStateAllForks} from "../types.js"; import {computeCheckpointEpochAtStateSlot} from "./epoch.js"; -export function computeAnchorCheckpoint( - _config: ChainForkConfig, - anchorState: BeaconStateAllForks -): {checkpoint: phase0.Checkpoint; blockHeader: phase0.BeaconBlockHeader} { - let blockHeader: phase0.BeaconBlockHeader; - - blockHeader = ssz.phase0.BeaconBlockHeader.clone(anchorState.latestBlockHeader); +export function computeAnchorCheckpoint(anchorState: BeaconStateAllForks): { + checkpoint: phase0.Checkpoint; + blockHeader: phase0.BeaconBlockHeader; +} { + const blockHeader = ssz.phase0.BeaconBlockHeader.clone(anchorState.latestBlockHeader); if (ssz.Root.equals(blockHeader.stateRoot, ZERO_HASH)) { blockHeader.stateRoot = anchorState.hashTreeRoot(); } diff --git a/packages/state-transition/src/util/epochShuffling.ts b/packages/state-transition/src/util/epochShuffling.ts index 24880ec83adf..00d0cb367cd2 100644 --- a/packages/state-transition/src/util/epochShuffling.ts +++ b/packages/state-transition/src/util/epochShuffling.ts @@ -1,5 +1,4 @@ import {asyncUnshuffleList, unshuffleList} from "@chainsafe/swap-or-not-shuffle"; -import {BeaconConfig} from "@lodestar/config"; import { DOMAIN_BEACON_ATTESTER, GENESIS_SLOT, @@ -133,12 +132,8 @@ export function calculateDecisionRoot(state: BeaconStateAllForks, epoch: Epoch): * - Special case close to genesis block, return the genesis block root * - This is similar to forkchoice.getDependentRoot() function, otherwise we cannot get cached shuffing in attestation verification when syncing from genesis. */ -export function calculateShufflingDecisionRoot( - config: BeaconConfig, - state: BeaconStateAllForks, - epoch: Epoch -): RootHex { +export function calculateShufflingDecisionRoot(state: BeaconStateAllForks, epoch: Epoch): RootHex { return state.slot > GENESIS_SLOT ? calculateDecisionRoot(state, epoch) - : toRootHex(ssz.phase0.BeaconBlockHeader.hashTreeRoot(computeAnchorCheckpoint(config, state).blockHeader)); + : toRootHex(ssz.phase0.BeaconBlockHeader.hashTreeRoot(computeAnchorCheckpoint(state).blockHeader)); } diff --git a/packages/state-transition/test/unit/cachedBeaconState.test.ts b/packages/state-transition/test/unit/cachedBeaconState.test.ts index 9f887634da1d..db4b02d4d250 100644 --- a/packages/state-transition/test/unit/cachedBeaconState.test.ts +++ b/packages/state-transition/test/unit/cachedBeaconState.test.ts @@ -162,21 +162,21 @@ describe("CachedBeaconState", () => { const shufflingGetter = (shufflingEpoch: Epoch, dependentRoot: RootHex): EpochShuffling | null => { if ( shufflingEpoch === seedState.epochCtx.epoch - 1 && - dependentRoot === calculateShufflingDecisionRoot(config, seedState, shufflingEpoch) + dependentRoot === calculateShufflingDecisionRoot(seedState, shufflingEpoch) ) { return seedState.epochCtx.previousShuffling; } if ( shufflingEpoch === seedState.epochCtx.epoch && - dependentRoot === calculateShufflingDecisionRoot(config, seedState, shufflingEpoch) + dependentRoot === calculateShufflingDecisionRoot(seedState, shufflingEpoch) ) { return seedState.epochCtx.currentShuffling; } if ( shufflingEpoch === seedState.epochCtx.epoch + 1 && - dependentRoot === calculateShufflingDecisionRoot(config, seedState, shufflingEpoch) + dependentRoot === calculateShufflingDecisionRoot(seedState, shufflingEpoch) ) { return seedState.epochCtx.nextShuffling; } From 9bf51039998a1a7e28d2ccb3067b0b936b9df1fe Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 13:40:11 +0100 Subject: [PATCH 41/67] review fork_choice.test.ts --- .../beacon-node/test/spec/presets/fork_choice.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/beacon-node/test/spec/presets/fork_choice.test.ts b/packages/beacon-node/test/spec/presets/fork_choice.test.ts index 699de1135891..d632f3167b6a 100644 --- a/packages/beacon-node/test/spec/presets/fork_choice.test.ts +++ b/packages/beacon-node/test/spec/presets/fork_choice.test.ts @@ -391,21 +391,21 @@ const forkChoiceTest = // Verify envelope against the post-block state (spec: verify_execution_payload_envelope) const protoBlock = (chain.forkChoice as ForkChoice).getBlockHexDefaultStatus(beaconBlockRoot); if (!protoBlock) throw Error(`Block not found for root ${beaconBlockRoot}`); - const envelopeState = await chain.regen.getBlockSlotState( + const blockState = await chain.regen.getBlockSlotState( protoBlock, protoBlock.slot, {dontTransferCache: true}, - RegenCaller.restApi + RegenCaller.processBlock ); - verifyExecutionPayloadEnvelope(beaconConfig, envelopeState as IBeaconStateViewGloas, envelope.message); + verifyExecutionPayloadEnvelope(beaconConfig, blockState as IBeaconStateViewGloas, envelope.message); // Verify signature const sigValid = await verifyExecutionPayloadEnvelopeSignature( beaconConfig, - envelopeState as IBeaconStateViewGloas, + blockState as IBeaconStateViewGloas, pubkeyCache, envelope, - envelopeState.latestBlockHeader.proposerIndex, + blockState.latestBlockHeader.proposerIndex, chain.bls ); if (!sigValid) throw Error("Invalid execution payload envelope signature"); From 08b27127418220c45b192db9843c62afeb6e004f Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 13:48:38 +0100 Subject: [PATCH 42/67] revert changes to computeAnchorCheckpoint --- .../opPools/aggregatedAttestationPool.test.ts | 2 +- .../unit/chain/forkChoice/forkChoice.test.ts | 8 ++--- .../beacon-node/test/utils/node/beacon.ts | 2 +- .../cli/src/cmds/beacon/initBeaconState.ts | 6 ++-- .../state-transition/src/cache/epochCache.ts | 6 ++-- .../src/stateView/beaconStateView.ts | 2 +- .../src/util/computeAnchorCheckpoint.ts | 32 +++++++++++++------ .../src/util/epochShuffling.ts | 9 ++++-- .../test/unit/cachedBeaconState.test.ts | 6 ++-- 9 files changed, 46 insertions(+), 27 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 1972ba501572..febf125248db 100644 --- a/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts +++ b/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts @@ -35,7 +35,7 @@ describe.skip(`getAttestationsForBlock vc=${vc}`, () => { () => { originalState = generatePerfTestCachedStateElectra({goBackOneSlot: true, vc}); - const {blockHeader, checkpoint} = computeAnchorCheckpoint(originalState); + const {blockHeader, checkpoint} = computeAnchorCheckpoint(originalState.config, originalState); // TODO figure out why getBlockRootAtSlot(originalState, justifiedSlot) is not the same to justifiedCheckpoint.root const finalizedEpoch = originalState.finalizedCheckpoint.epoch; const finalizedCheckpoint = { diff --git a/packages/beacon-node/test/unit/chain/forkChoice/forkChoice.test.ts b/packages/beacon-node/test/unit/chain/forkChoice/forkChoice.test.ts index 4708158966b6..d611ef0efcd7 100644 --- a/packages/beacon-node/test/unit/chain/forkChoice/forkChoice.test.ts +++ b/packages/beacon-node/test/unit/chain/forkChoice/forkChoice.test.ts @@ -83,7 +83,7 @@ describe("LodestarForkChoice", () => { * parent (37) - child (38) */ it.skip("getHead - should not consider orphaned block as head", () => { - const {blockHeader} = computeAnchorCheckpoint(anchorState); + const {blockHeader} = computeAnchorCheckpoint(config, anchorState); const finalizedRoot = ssz.phase0.BeaconBlockHeader.hashTreeRoot(blockHeader); const targetBlock = generateSignedBlockAtSlot(32); targetBlock.message.parentRoot = finalizedRoot; @@ -159,7 +159,7 @@ describe("LodestarForkChoice", () => { * finalized - slot 8 (finalized 1) - slot 12 - slot 16 (finalized 2) - slot 20 - slot 24 (finalized 3) - slot 28 - slot 32 (finalized 4) */ it("prune - should prune old blocks", () => { - const {blockHeader} = computeAnchorCheckpoint(anchorState); + const {blockHeader} = computeAnchorCheckpoint(config, anchorState); const finalizedRoot = ssz.phase0.BeaconBlockHeader.hashTreeRoot(blockHeader); const block08 = generateSignedBlockAtSlot(8); block08.message.parentRoot = finalizedRoot; @@ -226,7 +226,7 @@ describe("LodestarForkChoice", () => { * parent (34) - child (35) */ it("getAllNonAncestorBlocks - should get non ancestor nodes", () => { - const {blockHeader} = computeAnchorCheckpoint(anchorState); + const {blockHeader} = computeAnchorCheckpoint(config, anchorState); const finalizedRoot = ssz.phase0.BeaconBlockHeader.hashTreeRoot(blockHeader); const targetBlock = generateSignedBlockAtSlot(32); targetBlock.message.parentRoot = finalizedRoot; @@ -301,7 +301,7 @@ describe("LodestarForkChoice", () => { it.skip("should not filter blocks with unrealized checkpoints = store checkpoints", () => { const blockDelaySec = 0; // C9 is the justified/finalized block - const {blockHeader} = computeAnchorCheckpoint(anchorState); + const {blockHeader} = computeAnchorCheckpoint(config, anchorState); const finalizedRoot = ssz.phase0.BeaconBlockHeader.hashTreeRoot(blockHeader); // C10 const blockW = generateSignedBlockAtSlot(8); diff --git a/packages/beacon-node/test/utils/node/beacon.ts b/packages/beacon-node/test/utils/node/beacon.ts index c6660d1227cc..2748ac284f76 100644 --- a/packages/beacon-node/test/utils/node/beacon.ts +++ b/packages/beacon-node/test/utils/node/beacon.ts @@ -77,7 +77,7 @@ export async function getDevBeaconNode( // derive wsCheckpoint if not provided if (!wsCheckpoint) { - const {checkpoint} = computeAnchorCheckpoint(anchorState); + const {checkpoint} = computeAnchorCheckpoint(config, anchorState); wsCheckpoint = {root: checkpoint.root, epoch: checkpoint.epoch}; logger.debug("Derived wsCheckpoint", {epoch: checkpoint.epoch}); } diff --git a/packages/cli/src/cmds/beacon/initBeaconState.ts b/packages/cli/src/cmds/beacon/initBeaconState.ts index c3899ad8b9c4..0f9fabd43098 100644 --- a/packages/cli/src/cmds/beacon/initBeaconState.ts +++ b/packages/cli/src/cmds/beacon/initBeaconState.ts @@ -201,7 +201,7 @@ export async function initBeaconState( logger ); - const {checkpoint} = computeAnchorCheckpoint(stateAndCp.anchorState); + const {checkpoint} = computeAnchorCheckpoint(chainForkConfig, stateAndCp.anchorState); logger.info("Initialized checkpoint state", { slot: stateAndCp.anchorState.slot, @@ -230,7 +230,7 @@ export async function initBeaconState( logger ); - const {checkpoint} = computeAnchorCheckpoint(stateAndCp.anchorState); + const {checkpoint} = computeAnchorCheckpoint(chainForkConfig, stateAndCp.anchorState); logger.info("Initialized checkpoint state", { slot: stateAndCp.anchorState.slot, @@ -289,7 +289,7 @@ export async function initBeaconState( ); const lastProcessedSlot = stateAndCp.anchorState.latestBlockHeader.slot; - const {checkpoint} = computeAnchorCheckpoint(stateAndCp.anchorState); + const {checkpoint} = computeAnchorCheckpoint(chainForkConfig, stateAndCp.anchorState); logger.info("Initialized checkpoint state", { slot: stateAndCp.anchorState.slot, diff --git a/packages/state-transition/src/cache/epochCache.ts b/packages/state-transition/src/cache/epochCache.ts index d65086f7536b..cf0805be7bfc 100644 --- a/packages/state-transition/src/cache/epochCache.ts +++ b/packages/state-transition/src/cache/epochCache.ts @@ -346,11 +346,11 @@ export class EpochCache { // BeaconChain could provide a shuffling getter to avoid re-computing shuffling every epoch // in that case, we don't need to compute shufflings again const shufflingGetter = opts?.shufflingGetter; - const previousDecisionRoot = calculateShufflingDecisionRoot(state, previousEpoch); + const previousDecisionRoot = calculateShufflingDecisionRoot(config, state, previousEpoch); const cachedPreviousShuffling = shufflingGetter?.(previousEpoch, previousDecisionRoot); - const currentDecisionRoot = calculateShufflingDecisionRoot(state, currentEpoch); + const currentDecisionRoot = calculateShufflingDecisionRoot(config, state, currentEpoch); const cachedCurrentShuffling = shufflingGetter?.(currentEpoch, currentDecisionRoot); - const nextDecisionRoot = calculateShufflingDecisionRoot(state, nextEpoch); + const nextDecisionRoot = calculateShufflingDecisionRoot(config, state, nextEpoch); const cachedNextShuffling = shufflingGetter?.(nextEpoch, nextDecisionRoot); for (let i = 0; i < validatorCount; i++) { diff --git a/packages/state-transition/src/stateView/beaconStateView.ts b/packages/state-transition/src/stateView/beaconStateView.ts index 62c74471c9e9..08ed99db0a84 100644 --- a/packages/state-transition/src/stateView/beaconStateView.ts +++ b/packages/state-transition/src/stateView/beaconStateView.ts @@ -471,7 +471,7 @@ export class BeaconStateView implements IBeaconStateViewLatestFork { } computeAnchorCheckpoint(): {checkpoint: phase0.Checkpoint; blockHeader: phase0.BeaconBlockHeader} { - return computeAnchorCheckpoint(this.cachedState); + return computeAnchorCheckpoint(this.config, this.cachedState); } // Sync committees diff --git a/packages/state-transition/src/util/computeAnchorCheckpoint.ts b/packages/state-transition/src/util/computeAnchorCheckpoint.ts index 92386ce8983a..1edb2ac57ca2 100644 --- a/packages/state-transition/src/util/computeAnchorCheckpoint.ts +++ b/packages/state-transition/src/util/computeAnchorCheckpoint.ts @@ -1,20 +1,34 @@ -import {ZERO_HASH} from "@lodestar/params"; +import {ChainForkConfig} from "@lodestar/config"; +import {GENESIS_SLOT, ZERO_HASH} from "@lodestar/params"; import {phase0, ssz} from "@lodestar/types"; import {BeaconStateAllForks} from "../types.js"; +import {blockToHeader} from "./blockRoot.js"; import {computeCheckpointEpochAtStateSlot} from "./epoch.js"; -export function computeAnchorCheckpoint(anchorState: BeaconStateAllForks): { - checkpoint: phase0.Checkpoint; - blockHeader: phase0.BeaconBlockHeader; -} { - const blockHeader = ssz.phase0.BeaconBlockHeader.clone(anchorState.latestBlockHeader); - if (ssz.Root.equals(blockHeader.stateRoot, ZERO_HASH)) { - blockHeader.stateRoot = anchorState.hashTreeRoot(); +export function computeAnchorCheckpoint( + config: ChainForkConfig, + anchorState: BeaconStateAllForks +): {checkpoint: phase0.Checkpoint; blockHeader: phase0.BeaconBlockHeader} { + let blockHeader: phase0.BeaconBlockHeader; + let root: Uint8Array; + const blockTypes = config.getForkTypes(anchorState.latestBlockHeader.slot); + + if (anchorState.latestBlockHeader.slot === GENESIS_SLOT) { + const block = blockTypes.BeaconBlock.defaultValue(); + block.stateRoot = anchorState.hashTreeRoot(); + blockHeader = blockToHeader(config, block); + root = ssz.phase0.BeaconBlockHeader.hashTreeRoot(blockHeader); + } else { + blockHeader = ssz.phase0.BeaconBlockHeader.clone(anchorState.latestBlockHeader); + if (ssz.Root.equals(blockHeader.stateRoot, ZERO_HASH)) { + blockHeader.stateRoot = anchorState.hashTreeRoot(); + } + root = ssz.phase0.BeaconBlockHeader.hashTreeRoot(blockHeader); } return { checkpoint: { - root: ssz.phase0.BeaconBlockHeader.hashTreeRoot(blockHeader), + root, // the checkpoint epoch = computeEpochAtSlot(anchorState.slot) + 1 if slot is not at epoch boundary // this is similar to a process_slots() call epoch: computeCheckpointEpochAtStateSlot(anchorState.slot), diff --git a/packages/state-transition/src/util/epochShuffling.ts b/packages/state-transition/src/util/epochShuffling.ts index 00d0cb367cd2..24880ec83adf 100644 --- a/packages/state-transition/src/util/epochShuffling.ts +++ b/packages/state-transition/src/util/epochShuffling.ts @@ -1,4 +1,5 @@ import {asyncUnshuffleList, unshuffleList} from "@chainsafe/swap-or-not-shuffle"; +import {BeaconConfig} from "@lodestar/config"; import { DOMAIN_BEACON_ATTESTER, GENESIS_SLOT, @@ -132,8 +133,12 @@ export function calculateDecisionRoot(state: BeaconStateAllForks, epoch: Epoch): * - Special case close to genesis block, return the genesis block root * - This is similar to forkchoice.getDependentRoot() function, otherwise we cannot get cached shuffing in attestation verification when syncing from genesis. */ -export function calculateShufflingDecisionRoot(state: BeaconStateAllForks, epoch: Epoch): RootHex { +export function calculateShufflingDecisionRoot( + config: BeaconConfig, + state: BeaconStateAllForks, + epoch: Epoch +): RootHex { return state.slot > GENESIS_SLOT ? calculateDecisionRoot(state, epoch) - : toRootHex(ssz.phase0.BeaconBlockHeader.hashTreeRoot(computeAnchorCheckpoint(state).blockHeader)); + : toRootHex(ssz.phase0.BeaconBlockHeader.hashTreeRoot(computeAnchorCheckpoint(config, state).blockHeader)); } diff --git a/packages/state-transition/test/unit/cachedBeaconState.test.ts b/packages/state-transition/test/unit/cachedBeaconState.test.ts index db4b02d4d250..9f887634da1d 100644 --- a/packages/state-transition/test/unit/cachedBeaconState.test.ts +++ b/packages/state-transition/test/unit/cachedBeaconState.test.ts @@ -162,21 +162,21 @@ describe("CachedBeaconState", () => { const shufflingGetter = (shufflingEpoch: Epoch, dependentRoot: RootHex): EpochShuffling | null => { if ( shufflingEpoch === seedState.epochCtx.epoch - 1 && - dependentRoot === calculateShufflingDecisionRoot(seedState, shufflingEpoch) + dependentRoot === calculateShufflingDecisionRoot(config, seedState, shufflingEpoch) ) { return seedState.epochCtx.previousShuffling; } if ( shufflingEpoch === seedState.epochCtx.epoch && - dependentRoot === calculateShufflingDecisionRoot(seedState, shufflingEpoch) + dependentRoot === calculateShufflingDecisionRoot(config, seedState, shufflingEpoch) ) { return seedState.epochCtx.currentShuffling; } if ( shufflingEpoch === seedState.epochCtx.epoch + 1 && - dependentRoot === calculateShufflingDecisionRoot(seedState, shufflingEpoch) + dependentRoot === calculateShufflingDecisionRoot(config, seedState, shufflingEpoch) ) { return seedState.epochCtx.nextShuffling; } From e62a7a15c19fc668d4b6837a9dc4ec29bdfd635d Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 13:51:16 +0100 Subject: [PATCH 43/67] ahhh damn, we need that for spec tests --- .../src/util/computeAnchorCheckpoint.ts | 25 +++++-------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/packages/state-transition/src/util/computeAnchorCheckpoint.ts b/packages/state-transition/src/util/computeAnchorCheckpoint.ts index 1edb2ac57ca2..c4f97b9939f1 100644 --- a/packages/state-transition/src/util/computeAnchorCheckpoint.ts +++ b/packages/state-transition/src/util/computeAnchorCheckpoint.ts @@ -1,34 +1,21 @@ import {ChainForkConfig} from "@lodestar/config"; -import {GENESIS_SLOT, ZERO_HASH} from "@lodestar/params"; +import {ZERO_HASH} from "@lodestar/params"; import {phase0, ssz} from "@lodestar/types"; import {BeaconStateAllForks} from "../types.js"; -import {blockToHeader} from "./blockRoot.js"; import {computeCheckpointEpochAtStateSlot} from "./epoch.js"; export function computeAnchorCheckpoint( - config: ChainForkConfig, + _config: ChainForkConfig, anchorState: BeaconStateAllForks ): {checkpoint: phase0.Checkpoint; blockHeader: phase0.BeaconBlockHeader} { - let blockHeader: phase0.BeaconBlockHeader; - let root: Uint8Array; - const blockTypes = config.getForkTypes(anchorState.latestBlockHeader.slot); - - if (anchorState.latestBlockHeader.slot === GENESIS_SLOT) { - const block = blockTypes.BeaconBlock.defaultValue(); - block.stateRoot = anchorState.hashTreeRoot(); - blockHeader = blockToHeader(config, block); - root = ssz.phase0.BeaconBlockHeader.hashTreeRoot(blockHeader); - } else { - blockHeader = ssz.phase0.BeaconBlockHeader.clone(anchorState.latestBlockHeader); - if (ssz.Root.equals(blockHeader.stateRoot, ZERO_HASH)) { - blockHeader.stateRoot = anchorState.hashTreeRoot(); - } - root = ssz.phase0.BeaconBlockHeader.hashTreeRoot(blockHeader); + const blockHeader = ssz.phase0.BeaconBlockHeader.clone(anchorState.latestBlockHeader); + if (ssz.Root.equals(blockHeader.stateRoot, ZERO_HASH)) { + blockHeader.stateRoot = anchorState.hashTreeRoot(); } return { checkpoint: { - root, + root: ssz.phase0.BeaconBlockHeader.hashTreeRoot(blockHeader), // the checkpoint epoch = computeEpochAtSlot(anchorState.slot) + 1 if slot is not at epoch boundary // this is similar to a process_slots() call epoch: computeCheckpointEpochAtStateSlot(anchorState.slot), From 8acf7c81ef7a0ef831b4868eca82df84400e7730 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 13:54:21 +0100 Subject: [PATCH 44/67] remove type cast --- packages/beacon-node/test/spec/presets/fork_choice.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/test/spec/presets/fork_choice.test.ts b/packages/beacon-node/test/spec/presets/fork_choice.test.ts index d632f3167b6a..07fcc8103959 100644 --- a/packages/beacon-node/test/spec/presets/fork_choice.test.ts +++ b/packages/beacon-node/test/spec/presets/fork_choice.test.ts @@ -389,7 +389,7 @@ const forkChoiceTest = const blockNumber = envelope.message.payload.blockNumber; // Verify envelope against the post-block state (spec: verify_execution_payload_envelope) - const protoBlock = (chain.forkChoice as ForkChoice).getBlockHexDefaultStatus(beaconBlockRoot); + const protoBlock = chain.forkChoice.getBlockHexDefaultStatus(beaconBlockRoot); if (!protoBlock) throw Error(`Block not found for root ${beaconBlockRoot}`); const blockState = await chain.regen.getBlockSlotState( protoBlock, From 24a3da57f00416751be7ea507213957b3947ae17 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 14:04:41 +0100 Subject: [PATCH 45/67] clarify withdrawals --- packages/state-transition/src/block/processWithdrawals.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/state-transition/src/block/processWithdrawals.ts b/packages/state-transition/src/block/processWithdrawals.ts index e57041d1a1c4..55aa3e00b1c6 100644 --- a/packages/state-transition/src/block/processWithdrawals.ts +++ b/packages/state-transition/src/block/processWithdrawals.ts @@ -54,7 +54,9 @@ export function processWithdrawals( } = getExpectedWithdrawals(fork, state); const numWithdrawals = expectedWithdrawals.length; - // After gloas, withdrawals are verified later in verifyExecutionPayloadEnvelope + // Pre-gloas verifies the payload's withdrawals against expectedWithdrawals here. + // Post-gloas, the payload arrives later as an envelope and that consistency check + // happens in verifyExecutionPayloadEnvelope against state.payloadExpectedWithdrawals. if (fork < ForkSeq.gloas) { if (payload === undefined) { throw Error("payload is required for pre-gloas processWithdrawals"); From a04a4d0c4f654fd136e115fc46037e8781020f23 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 14:15:34 +0100 Subject: [PATCH 46/67] review getExpectedWithdrawalsForFullParent --- .../src/stateView/beaconStateView.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/state-transition/src/stateView/beaconStateView.ts b/packages/state-transition/src/stateView/beaconStateView.ts index 08ed99db0a84..5e2b575d7c18 100644 --- a/packages/state-transition/src/stateView/beaconStateView.ts +++ b/packages/state-transition/src/stateView/beaconStateView.ts @@ -1,7 +1,7 @@ import {CompactMultiProof, ProofType, Tree, createProof} from "@chainsafe/persistent-merkle-tree"; import {BitArray, ByteViews} from "@chainsafe/ssz"; import {BeaconConfig} from "@lodestar/config"; -import {ForkName, ForkSeq, SLOTS_PER_HISTORICAL_ROOT, isForkPostGloas} from "@lodestar/params"; +import {ForkName, ForkSeq, SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; import { BeaconBlock, BeaconState, @@ -783,14 +783,19 @@ export class BeaconStateView implements IBeaconStateViewLatestFork { return new BeaconStateView(newState); } + /** + * Spec: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/gloas/validator.md#executionpayload + */ getExpectedWithdrawalsForFullParent(envelope: gloas.SignedExecutionPayloadEnvelope): capella.Withdrawal[] { const fork = this.config.getForkSeq(this.cachedState.slot); - if (!isForkPostGloas(this.config.getForkName(this.cachedState.slot))) { - throw Error("getExpectedWithdrawalsForFullParent is only available for gloas+ forks"); + if (fork < ForkSeq.gloas) { + throw new Error("getExpectedWithdrawalsForFullParent is not available before Gloas"); } + // Make a copy of the state to avoid mutability issues const stateCopy = this.cachedState.clone(true) as CachedBeaconStateGloas; + // Apply parent payload before computing withdrawals applyParentExecutionPayload(stateCopy, envelope.message.executionRequests); - const {expectedWithdrawals} = getExpectedWithdrawals(fork, stateCopy); - return expectedWithdrawals; + + return getExpectedWithdrawals(fork, stateCopy).expectedWithdrawals; } } From 2fa1c4f33ee434d6f6ab14375f5ce5be4aedc667 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 14:42:17 +0100 Subject: [PATCH 47/67] review processParentExecutionPayload.ts --- .../src/block/processParentExecutionPayload.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/state-transition/src/block/processParentExecutionPayload.ts b/packages/state-transition/src/block/processParentExecutionPayload.ts index 868dc7b18a67..326157c7e154 100644 --- a/packages/state-transition/src/block/processParentExecutionPayload.ts +++ b/packages/state-transition/src/block/processParentExecutionPayload.ts @@ -97,6 +97,11 @@ export function applyParentExecutionPayload(state: CachedBeaconStateGloas, reque * Spec: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/gloas/beacon-chain.md#new-settle_builder_payment */ function settleBuilderPayment(state: CachedBeaconStateGloas, paymentIndex: number): void { + if (paymentIndex >= state.builderPendingPayments.length) { + throw new Error( + `Invalid builder payment index paymentIndex=${paymentIndex} limit=${state.builderPendingPayments.length}` + ); + } const payment = state.builderPendingPayments.get(paymentIndex).clone(); if (payment.withdrawal.amount > 0) { state.builderPendingWithdrawals.push(payment.withdrawal); From b89af0b8847f22009bda3945cd18bff166166434 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 15:10:43 +0100 Subject: [PATCH 48/67] review operations.test.ts --- .../beacon-node/test/spec/presets/operations.test.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/beacon-node/test/spec/presets/operations.test.ts b/packages/beacon-node/test/spec/presets/operations.test.ts index 8faeeb543492..d35e421096ee 100644 --- a/packages/beacon-node/test/spec/presets/operations.test.ts +++ b/packages/beacon-node/test/spec/presets/operations.test.ts @@ -11,10 +11,9 @@ import { CachedBeaconStateGloas, ExecutionPayloadStatus, getBlockRootAtSlot, - processSlots, } from "@lodestar/state-transition"; +import * as blockFns from "@lodestar/state-transition/block"; import {AttesterSlashing, altair, bellatrix, capella, electra, gloas, phase0, ssz, sszTypesFor} from "@lodestar/types"; -import * as blockFns from "../../../../state-transition/src/block/index.js"; import {createCachedBeaconStateTest} from "../../utils/cachedBeaconState.js"; import {ethereumConsensusSpecsTests} from "../specTestVersioning.js"; import {expectEqualBeaconState, inputTypeSszTreeViewDU} from "../utils/expectEqualBeaconState.js"; @@ -111,11 +110,8 @@ const operationFns: Record> = blockFns.processExecutionPayloadBid(state as CachedBeaconStateGloas, testCase.block); }, - parent_execution_payload: (state, testCase: {block: gloas.BeaconBlock}): CachedBeaconStateAllForks => { - // Spec test calls process_slots then process_parent_execution_payload - const postState = processSlots(state, testCase.block.slot); - blockFns.processParentExecutionPayload(postState as CachedBeaconStateGloas, testCase.block); - return postState; + parent_execution_payload: (state, testCase: {block: gloas.BeaconBlock}) => { + blockFns.processParentExecutionPayload(state as CachedBeaconStateGloas, testCase.block); }, payload_attestation: (state, testCase: {payload_attestation: gloas.PayloadAttestation}) => { From c2ef8aa46742fc0072c7e28a8cd955581e6f8d89 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 15:24:53 +0100 Subject: [PATCH 49/67] final pass on processParentExecutionPayload --- .../src/block/processParentExecutionPayload.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/state-transition/src/block/processParentExecutionPayload.ts b/packages/state-transition/src/block/processParentExecutionPayload.ts index 326157c7e154..69d185a7cbcc 100644 --- a/packages/state-transition/src/block/processParentExecutionPayload.ts +++ b/packages/state-transition/src/block/processParentExecutionPayload.ts @@ -45,8 +45,8 @@ export function processParentExecutionPayload(state: CachedBeaconStateGloas, blo * Spec: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/gloas/beacon-chain.md#new-apply_parent_execution_payload */ export function applyParentExecutionPayload(state: CachedBeaconStateGloas, requests: electra.ExecutionRequests): void { - const parentBid = state.latestExecutionPayloadBid; const fork = state.config.getForkSeq(state.slot); + const parentBid = state.latestExecutionPayloadBid; const parentSlot = parentBid.slot; const parentEpoch = computeEpochAtSlot(parentSlot); const currentEpoch = computeEpochAtSlot(state.slot); @@ -74,8 +74,8 @@ export function applyParentExecutionPayload(state: CachedBeaconStateGloas, reque } else if (parentEpoch === currentEpoch - 1) { settleBuilderPayment(state, parentSlot % SLOTS_PER_EPOCH); } else if (parentBid.value > 0) { - // Parent is older than the previous epoch, its payment entry has already been settled or - // evicted, so append the withdrawal directly to ensure the builder still gets paid + // Parent is older than the previous epoch, its payment entry has been evicted from + // builder_pending_payments. Append the withdrawal directly. state.builderPendingWithdrawals.push( ssz.gloas.BuilderPendingWithdrawal.toViewDU({ feeRecipient: parentBid.feeRecipient, From 78a72010b5caa0b610659661cfc5642f503b89eb Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 15:34:34 +0100 Subject: [PATCH 50/67] small nit --- .../src/chain/blocks/verifyExecutionPayloadEnvelope.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts b/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts index ed31e4d33769..671ed22eb2e7 100644 --- a/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts @@ -86,9 +86,10 @@ export function verifyExecutionPayloadEnvelope( `Parent hash mismatch between payload and state payload=${toRootHex(payload.parentHash)} state=${toRootHex(state.latestBlockHash)}` ); } - if (payload.timestamp !== computeTimeAtSlot(config, state.slot, state.genesisTime)) { + const expectedTimestamp = computeTimeAtSlot(config, state.slot, state.genesisTime); + if (payload.timestamp !== expectedTimestamp) { throw new Error( - `Timestamp mismatch between payload and state payload=${payload.timestamp} state=${computeTimeAtSlot(config, state.slot, state.genesisTime)}` + `Timestamp mismatch between payload and state payload=${payload.timestamp} state=${expectedTimestamp}` ); } From f8c73af4c0872401d0dc909a964c823fe78aa769 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 15:54:31 +0100 Subject: [PATCH 51/67] 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 831db7d8f0bd..862f86ca6784 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts @@ -98,6 +98,15 @@ async function validateExecutionPayloadEnvelope( }); } + // [REJECT] `payload.block_hash == bid.block_hash` + if (toRootHex(payload.blockHash) !== payloadInput.getBlockHashHex()) { + throw new ExecutionPayloadEnvelopeError(GossipAction.REJECT, { + code: ExecutionPayloadEnvelopeErrorCode.BLOCK_HASH_MISMATCH, + envelopeBlockHash: toRootHex(payload.blockHash), + bidBlockHash: payloadInput.getBlockHashHex(), + }); + } + // [REJECT] `hash_tree_root(envelope.execution_requests) == bid.execution_requests_root` const requestsRoot = ssz.electra.ExecutionRequests.hashTreeRoot(envelope.executionRequests); if (!byteArrayEquals(requestsRoot, payloadInput.getBid().executionRequestsRoot)) { @@ -108,15 +117,6 @@ async function validateExecutionPayloadEnvelope( }); } - // [REJECT] `payload.block_hash == bid.block_hash` - if (toRootHex(payload.blockHash) !== payloadInput.getBlockHashHex()) { - throw new ExecutionPayloadEnvelopeError(GossipAction.REJECT, { - code: ExecutionPayloadEnvelopeErrorCode.BLOCK_HASH_MISMATCH, - envelopeBlockHash: toRootHex(payload.blockHash), - bidBlockHash: payloadInput.getBlockHashHex(), - }); - } - // Get the block state to verify the builder's signature. const blockState = await chain.regen .getState(block.stateRoot, RegenCaller.validateGossipPayloadEnvelope) From e3999215144617a620ac3402c9227b564cfeed18 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 15:55:01 +0100 Subject: [PATCH 52/67] clarify comment --- packages/beacon-node/src/chain/blocks/types.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/types.ts b/packages/beacon-node/src/chain/blocks/types.ts index 03d37ccb526c..5d64e1a9bb4f 100644 --- a/packages/beacon-node/src/chain/blocks/types.ts +++ b/packages/beacon-node/src/chain/blocks/types.ts @@ -43,8 +43,9 @@ export enum BlobSidecarValidation { 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. + * Set to true when the envelope was already validated upstream (e.g., gossip/API validation): + * signature is trusted and execution_requests_root was already verified against the bid. + * When false/undefined, both are verified during import. */ validSignature?: boolean; }; From 56f686ce52e193dfb448454668479c57a4570b47 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 16:06:41 +0100 Subject: [PATCH 53/67] review import --- .../chain/blocks/importExecutionPayload.ts | 66 +++++++++---------- .../test/spec/presets/fork_choice.test.ts | 2 +- 2 files changed, 32 insertions(+), 36 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index 9d06a4965419..1a445370a3ec 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -20,7 +20,7 @@ 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", + ENVELOPE_VERIFICATION_ERROR = "PAYLOAD_ERROR_ENVELOPE_VERIFICATION_ERROR", INVALID_SIGNATURE = "PAYLOAD_ERROR_INVALID_SIGNATURE", } @@ -40,7 +40,7 @@ export type PayloadErrorType = blockRootHex: string; } | { - code: PayloadErrorCode.STATE_TRANSITION_ERROR; + code: PayloadErrorCode.ENVELOPE_VERIFICATION_ERROR; message: string; } | { @@ -80,12 +80,11 @@ function toForkChoiceExecutionStatus(status: ExecutionPayloadStatus): PayloadExe * 2. Get the ProtoBlock from fork choice * 3. Wait for data columns to be available * 4. Apply write-queue backpressure before verification - * 5. Regenerate block state (post-block, pre-payload) for envelope verification - * 6. Run EL verification (notifyNewPayload) and BLS signature verification in parallel - * 7. Verify envelope fields against state (spec: verify_execution_payload_envelope) - * 8. Persist verified payload envelope to hot DB - * 9. Update fork choice (transitions the block's PENDING variant to FULL) - * 10. Record metrics and emit `execution_payload` event + * 5. Regenerate state for envelope verification + * 6. Verify envelope (fields against state, signature, and EL — parallel where possible) + * 7. Persist verified payload envelope to hot DB + * 8. Update fork choice (transitions the block's PENDING variant to FULL) + * 9. Record metrics and emit `execution_payload` event */ export async function importExecutionPayload( this: BeaconChain, @@ -128,8 +127,7 @@ export async function importExecutionPayload( // The actual DB write is deferred until after verification succeeds. await this.unfinalizedPayloadEnvelopeWrites.waitForSpace(); - // 5. Get pre-state for envelope verification. - // We need the block state (post-block, pre-payload) to verify the envelope. + // 5. Regenerate state for envelope verification const blockState = await this.regen.getBlockSlotState( protoBlock, protoBlock.slot, @@ -138,12 +136,29 @@ export async function importExecutionPayload( ); if (!isStatePostGloas(blockState)) { throw new PayloadError({ - code: PayloadErrorCode.STATE_TRANSITION_ERROR, - message: `Expected gloas+ block state for payload import, got fork=${blockState.forkName}`, + code: PayloadErrorCode.ENVELOPE_VERIFICATION_ERROR, + message: `Expected gloas+ state for payload import, got fork=${blockState.forkName}`, }); } - // 6. Run EL verification and BLS signature verification in parallel + // 6. Verify envelope against state (spec: verify_execution_payload_envelope). Run the sync + // field checks first to fail fast before starting the EL + BLS work. When validSignature is + // true, the envelope came from gossip/API where both the signature and executionRequestsRoot + // were already verified, skip re-hashing executionRequestsRoot. + try { + verifyExecutionPayloadEnvelope(this.config, blockState, envelope, { + verifyExecutionRequestsRoot: !opts.validSignature, + }); + } catch (e) { + throw new PayloadError( + { + code: PayloadErrorCode.ENVELOPE_VERIFICATION_ERROR, + message: (e as Error).message, + }, + `Envelope verification error: ${(e as Error).message}` + ); + } + const [execResult, signatureValid] = await Promise.all([ this.executionEngine.notifyNewPayload( fork, @@ -165,29 +180,10 @@ export async function importExecutionPayload( ), ]); - // 7. Verify envelope fields against state (spec: verify_execution_payload_envelope) - try { - // When validSignature is true, the envelope came from gossip/API where both - // signature and executionRequestsRoot were already verified, skip re-hashing. - verifyExecutionPayloadEnvelope(this.config, blockState, envelope, { - verifyExecutionRequestsRoot: !opts.validSignature, - }); - } catch (e) { - throw new PayloadError( - { - code: PayloadErrorCode.STATE_TRANSITION_ERROR, - message: (e as Error).message, - }, - `Envelope verification error: ${(e as Error).message}` - ); - } - - // 7a. Check signature verification result if (!signatureValid) { throw new PayloadError({code: PayloadErrorCode.INVALID_SIGNATURE}); } - // 7b. Handle EL response switch (execResult.status) { case ExecutionPayloadStatus.VALID: break; @@ -213,7 +209,7 @@ export async function importExecutionPayload( }); } - // 8. Persist payload envelope to hot DB (performed asynchronously to avoid blocking) + // 7. Persist payload envelope to hot DB (performed asynchronously to avoid blocking) this.unfinalizedPayloadEnvelopeWrites.push(payloadInput).catch((e) => { if (!isQueueErrorAborted(e)) { this.logger.error( @@ -224,11 +220,11 @@ export async function importExecutionPayload( } }); - // 9. Update fork choice, transitions the block's PENDING variant to FULL + // 8. Update fork choice, transitions the block's PENDING variant to FULL const execStatus = toForkChoiceExecutionStatus(execResult.status); this.forkChoice.onExecutionPayload(blockRootHex, blockHashHex, envelope.payload.blockNumber, execStatus); - // 10. Record metrics for payload envelope and column sources, emit `execution_payload` + // 9. Record metrics for payload envelope and column sources, emit `execution_payload` // event for recent enough payloads after successful import. this.metrics?.importPayload.bySource.inc({source: payloadInput.getPayloadEnvelopeSource().source}); for (const {source} of payloadInput.getSampledColumnsWithSource()) { diff --git a/packages/beacon-node/test/spec/presets/fork_choice.test.ts b/packages/beacon-node/test/spec/presets/fork_choice.test.ts index 07fcc8103959..0719e85d19c4 100644 --- a/packages/beacon-node/test/spec/presets/fork_choice.test.ts +++ b/packages/beacon-node/test/spec/presets/fork_choice.test.ts @@ -388,7 +388,7 @@ const forkChoiceTest = const blockHash = toHex(envelope.message.payload.blockHash); const blockNumber = envelope.message.payload.blockNumber; - // Verify envelope against the post-block state (spec: verify_execution_payload_envelope) + // Verify envelope against the state const protoBlock = chain.forkChoice.getBlockHexDefaultStatus(beaconBlockRoot); if (!protoBlock) throw Error(`Block not found for root ${beaconBlockRoot}`); const blockState = await chain.regen.getBlockSlotState( From f78ca5173f01ad6ff13d827df6d60836f026d0d9 Mon Sep 17 00:00:00 2001 From: Cayman Date: Thu, 23 Apr 2026 11:16:54 -0400 Subject: [PATCH 54/67] test: run unknown block harness on gloas --- .../test/unit/sync/unknownBlock.test.ts | 161 ++++++++++++------ 1 file changed, 109 insertions(+), 52 deletions(-) diff --git a/packages/beacon-node/test/unit/sync/unknownBlock.test.ts b/packages/beacon-node/test/unit/sync/unknownBlock.test.ts index a9019046083f..58d71eeb48d0 100644 --- a/packages/beacon-node/test/unit/sync/unknownBlock.test.ts +++ b/packages/beacon-node/test/unit/sync/unknownBlock.test.ts @@ -1,15 +1,14 @@ import EventEmitter from "node:events"; import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; -import {toHexString} from "@chainsafe/ssz"; import {routes} from "@lodestar/api"; -import {createBeaconConfig, createChainForkConfig} from "@lodestar/config"; +import {createBeaconConfig} from "@lodestar/config"; import {config as minimalConfig} from "@lodestar/config/default"; import {IForkChoice, ProtoBlock} from "@lodestar/fork-choice"; import {testLogger} from "@lodestar/logger/test-utils"; import {ForkName} from "@lodestar/params"; import {SignedBeaconBlock, gloas, ssz} from "@lodestar/types"; import {notNullish, sleep, toRootHex} from "@lodestar/utils"; -import {BlockInputPreData} from "../../../src/chain/blocks/blockInput/blockInput.js"; +import {BlockInputNoData} from "../../../src/chain/blocks/blockInput/blockInput.js"; import {BlockInputSource, DAType, IBlockInput} from "../../../src/chain/blocks/blockInput/types.js"; import {PayloadError, PayloadErrorCode} from "../../../src/chain/blocks/importExecutionPayload.js"; import {PayloadEnvelopeInput} from "../../../src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.js"; @@ -97,6 +96,38 @@ function buildPayloadFixture({ return {block, blockRootHex, blockRoot, payloadInput, envelope, columnSidecars}; } +function createGloasBlockInput({ + block, + blockRootHex, + peerIdStr, + seenTimestampSec, + source, +}: { + block: gloas.SignedBeaconBlock; + blockRootHex: string; + peerIdStr?: PeerIdStr; + seenTimestampSec: number; + source: BlockInputSource; +}): BlockInputNoData { + return BlockInputNoData.createFromBlock({ + block, + blockRootHex, + forkName: ForkName.gloas, + daOutOfRange: false, + seenTimestampSec, + source, + peerIdStr, + }); +} + +function getGloasBlockRoot(block: gloas.SignedBeaconBlock): Uint8Array { + return ssz.gloas.BeaconBlock.hashTreeRoot(block.message); +} + +function getGloasBlockHashHex(block: gloas.SignedBeaconBlock): string { + return toRootHex(block.message.body.signedExecutionPayloadBid.message.blockHash); +} + function buildIncompleteGloasBlockInput({ parentRoot, parentBlockHash, @@ -196,10 +227,15 @@ function buildIncompleteGloasBlockInput({ describe("sync by UnknownBlockSync", {timeout: 20_000}, () => { const logger = testLogger(); const slotSec = 0.3; - const config = createChainForkConfig({ - ...minimalConfig, - SLOT_DURATION_MS: slotSec * 1000, - }); + const config = createBeaconConfig( + { + ...minimalConfig, + FULU_FORK_EPOCH: 0, + GLOAS_FORK_EPOCH: 0, + SLOT_DURATION_MS: slotSec * 1000, + }, + Buffer.alloc(32, 0) + ); beforeEach(() => { vi.useFakeTimers({shouldAdvanceTime: true}); @@ -273,22 +309,36 @@ describe("sync by UnknownBlockSync", {timeout: 20_000}, () => { } of testCases) { it(id, async () => { const peer = await getRandPeerIdStr(); - const blockA = ssz.phase0.SignedBeaconBlock.defaultValue(); - const blockB = ssz.phase0.SignedBeaconBlock.defaultValue(); - const blockC = ssz.phase0.SignedBeaconBlock.defaultValue(); + const blockA = ssz.gloas.SignedBeaconBlock.defaultValue(); + const blockB = ssz.gloas.SignedBeaconBlock.defaultValue(); + const blockC = ssz.gloas.SignedBeaconBlock.defaultValue(); blockA.message.slot = 1; blockB.message.slot = 2; blockC.message.slot = 3; const blockRoot0 = Buffer.alloc(32, 0x00); - const blockRootA = ssz.phase0.BeaconBlock.hashTreeRoot(blockA.message); + const blockHash0 = Buffer.alloc(32, 0x00); + const blockHashA = Buffer.alloc(32, 0xa1); + const blockHashB = Buffer.alloc(32, 0xb2); + const blockHashC = Buffer.alloc(32, 0xc3); + blockA.message.parentRoot = blockRoot0; + blockA.message.body.signedExecutionPayloadBid.message.parentBlockRoot = blockRoot0; + blockA.message.body.signedExecutionPayloadBid.message.parentBlockHash = blockHash0; + blockA.message.body.signedExecutionPayloadBid.message.blockHash = blockHashA; + const blockRootA = getGloasBlockRoot(blockA); blockB.message.parentRoot = blockRootA; - const blockRootB = ssz.phase0.BeaconBlock.hashTreeRoot(blockB.message); + blockB.message.body.signedExecutionPayloadBid.message.parentBlockRoot = blockRootA; + blockB.message.body.signedExecutionPayloadBid.message.parentBlockHash = blockHashA; + blockB.message.body.signedExecutionPayloadBid.message.blockHash = blockHashB; + const blockRootB = getGloasBlockRoot(blockB); blockC.message.parentRoot = blockRootB; - const blockRootC = ssz.phase0.BeaconBlock.hashTreeRoot(blockC.message); - const blockRootHex0 = toHexString(blockRoot0); - const blockRootHexA = toHexString(blockRootA); - const blockRootHexB = toHexString(blockRootB); - const blockRootHexC = toHexString(blockRootC); + blockC.message.body.signedExecutionPayloadBid.message.parentBlockRoot = blockRootB; + blockC.message.body.signedExecutionPayloadBid.message.parentBlockHash = blockHashB; + blockC.message.body.signedExecutionPayloadBid.message.blockHash = blockHashC; + const blockRootC = getGloasBlockRoot(blockC); + const blockRootHex0 = toRootHex(blockRoot0); + const blockRootHexA = toRootHex(blockRootA); + const blockRootHexB = toRootHex(blockRootB); + const blockRootHexC = toRootHex(blockRootC); const blocksByRoot = new Map([ [blockRootHexA, blockA], @@ -311,20 +361,27 @@ describe("sync by UnknownBlockSync", {timeout: 20_000}, () => { custodyColumns: [], earliestAvailableSlot: 0, }), - custodyConfig: {sampledColumns: []} as unknown as CustodyConfig, + custodyConfig: {sampledColumns: [], sampleGroups: [[]]} as unknown as CustodyConfig, sendBeaconBlocksByRoot: async (_peerId, roots) => { sendBeaconBlocksByRootResolveFn([_peerId, roots]); const correctBlocks = Array.from(roots) - .map((root) => blocksByRoot.get(toHexString(root))) + .map((root) => blocksByRoot.get(toRootHex(root))) .filter(notNullish); - return wrongBlockRoot ? [ssz.phase0.SignedBeaconBlock.defaultValue()] : correctBlocks; + return wrongBlockRoot ? [ssz.gloas.SignedBeaconBlock.defaultValue()] : correctBlocks; }, }; const forkChoiceKnownRoots = new Set([blockRootHex0]); - const forkChoice: Pick = { - hasBlock: (root) => forkChoiceKnownRoots.has(toHexString(root)), + const forkChoiceKnownPayloadHashes = new Map([[blockRootHex0, toRootHex(blockHash0)]]); + const forkChoice: Pick< + IForkChoice, + "getBlockHexAndBlockHash" | "getFinalizedBlock" | "hasBlock" | "hasBlockHex" | "hasPayloadHexUnsafe" + > = { + hasBlock: (root) => forkChoiceKnownRoots.has(toRootHex(root)), hasBlockHex: (rootHex) => forkChoiceKnownRoots.has(rootHex), + hasPayloadHexUnsafe: (rootHex) => forkChoiceKnownPayloadHashes.has(rootHex), + getBlockHexAndBlockHash: (rootHex, blockHashHex) => + forkChoiceKnownPayloadHashes.get(rootHex) === blockHashHex ? ({slot: 0} as ProtoBlock) : null, getFinalizedBlock: () => ({ slot: finalizedSlot, @@ -349,15 +406,25 @@ describe("sync by UnknownBlockSync", {timeout: 20_000}, () => { genesisTime: 0, processBlock: async (blockInput, opts) => { const block = blockInput.getBlock(); - if (!forkChoice.hasBlock(block.message.parentRoot)) throw Error("Unknown parent"); + const parentRootHex = toRootHex(block.message.parentRoot); + if (!forkChoice.hasBlockHex(parentRootHex)) throw Error("Unknown parent"); + + const parentBlockHash = toRootHex( + (block as gloas.SignedBeaconBlock).message.body.signedExecutionPayloadBid.message.parentBlockHash + ); + if (!forkChoice.getBlockHexAndBlockHash(parentRootHex, parentBlockHash)) { + throw new BlockError(block, {code: BlockErrorCode.PARENT_PAYLOAD_UNKNOWN, parentBlockHash}); + } + const blockSlot = block.message.slot; if (blockSlot <= finalizedSlot && !opts?.ignoreIfFinalized) { // same behavior to BeaconChain to reproduce https://github.com/ChainSafe/lodestar/issues/5650 throw new BlockError(block, {code: BlockErrorCode.WOULD_REVERT_FINALIZED_SLOT, blockSlot, finalizedSlot}); } // Simulate adding the block to the forkchoice - const blockRootHex = toHexString(ssz.phase0.BeaconBlock.hashTreeRoot(block.message)); + const blockRootHex = toRootHex(getGloasBlockRoot(block as gloas.SignedBeaconBlock)); forkChoiceKnownRoots.add(blockRootHex); + forkChoiceKnownPayloadHashes.set(blockRootHex, getGloasBlockHashHex(block as gloas.SignedBeaconBlock)); if (blockRootHex === blockRootHexC) blockCResolver(); if (blockRootHex === blockRootHexA) blockAResolver(); }, @@ -369,21 +436,23 @@ describe("sync by UnknownBlockSync", {timeout: 20_000}, () => { seenTimestampSec, source, }: { - block: any; + block: gloas.SignedBeaconBlock; blockRootHex: string; seenTimestampSec: number; source: BlockInputSource; }) => - BlockInputPreData.createFromBlock({ + createGloasBlockInput({ block, blockRootHex, - forkName: config.getForkName(block.message.slot), - daOutOfRange: false, seenTimestampSec, source, }), prune: () => {}, } as unknown as SeenBlockInput, + seenPayloadEnvelopeInputCache: { + get: vi.fn().mockReturnValue(undefined), + prune: vi.fn(), + } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], }; const setTimeoutSpy = vi.spyOn(global, "setTimeout"); @@ -397,18 +466,16 @@ describe("sync by UnknownBlockSync", {timeout: 20_000}, () => { // Register the peer in the peerBalancer via NetworkEvent.peerConnected networkEvents.emit(NetworkEvent.peerConnected, { peer, - status: {} as any, + status: {} as never, custodyColumns: [], clientAgent: "test-client", }); if (event === ChainEvent.blockUnknownParent) { emitter.emit(ChainEvent.blockUnknownParent, { - blockInput: BlockInputPreData.createFromBlock({ + blockInput: createGloasBlockInput({ block: blockC, blockRootHex: blockRootHexC, - forkName: config.getForkName(blockC.message.slot), - daOutOfRange: false, seenTimestampSec: Math.floor(Date.now() / 1000), source: BlockInputSource.gossip, }), @@ -776,16 +843,14 @@ describe("UnknownBlockSync", () => { seenTimestampSec, source, }: { - block: SignedBeaconBlock; + block: gloas.SignedBeaconBlock; blockRootHex: string; seenTimestampSec: number; source: BlockInputSource; }) => - BlockInputPreData.createFromBlock({ + createGloasBlockInput({ block, blockRootHex, - forkName: gloasConfig.getForkName(block.message.slot), - daOutOfRange: false, seenTimestampSec, source, }), @@ -798,7 +863,7 @@ describe("UnknownBlockSync", () => { .fn() .mockImplementation((root: string, hash: string) => root === parentRootHex && - hash === toHexString(block.message.body.signedExecutionPayloadBid.message.parentBlockHash) + hash === toRootHex(block.message.body.signedExecutionPayloadBid.message.parentBlockHash) ? ({slot: 0} as ProtoBlock) : null ), @@ -872,16 +937,14 @@ describe("UnknownBlockSync", () => { seenTimestampSec, source, }: { - block: SignedBeaconBlock; + block: gloas.SignedBeaconBlock; blockRootHex: string; seenTimestampSec: number; source: BlockInputSource; }) => - BlockInputPreData.createFromBlock({ + createGloasBlockInput({ block, blockRootHex, - forkName: gloasConfig.getForkName(block.message.slot), - daOutOfRange: false, seenTimestampSec, source, }), @@ -894,7 +957,7 @@ describe("UnknownBlockSync", () => { .fn() .mockImplementation((root: string, hash: string) => root === parentRootHex && - hash === toHexString(block.message.body.signedExecutionPayloadBid.message.parentBlockHash) + hash === toRootHex(block.message.body.signedExecutionPayloadBid.message.parentBlockHash) ? ({slot: 0} as ProtoBlock) : null ), @@ -1226,11 +1289,9 @@ describe("UnknownBlockSync", () => { childBlock.message.body.signedExecutionPayloadBid.message.parentBlockRoot = parentRoot; childBlock.message.body.signedExecutionPayloadBid.message.parentBlockHash = parentPayloadHash; const childBlockRootHex = toRootHex(ssz.gloas.BeaconBlock.hashTreeRoot(childBlock.message)); - const childBlockInput = BlockInputPreData.createFromBlock({ + const childBlockInput = createGloasBlockInput({ block: childBlock, blockRootHex: childBlockRootHex, - forkName: gloasConfig.getForkName(childBlock.message.slot), - daOutOfRange: false, seenTimestampSec: Date.now() / 1000, source: BlockInputSource.gossip, }); @@ -1261,7 +1322,7 @@ describe("UnknownBlockSync", () => { getBlockHexAndBlockHash: vi .fn() .mockImplementation((root: string, hash: string) => - root === parentRootHex && hash === toHexString(parentPayloadHash) && hasParentPayload + root === parentRootHex && hash === toRootHex(parentPayloadHash) && hasParentPayload ? ({slot: 1} as ProtoBlock) : null ), @@ -1319,11 +1380,9 @@ describe("UnknownBlockSync", () => { childBlock.message.body.signedExecutionPayloadBid.message.parentBlockRoot = parentRoot; childBlock.message.body.signedExecutionPayloadBid.message.parentBlockHash = Buffer.alloc(32, 0x33); const childBlockRootHex = toRootHex(ssz.gloas.BeaconBlock.hashTreeRoot(childBlock.message)); - const childBlockInput = BlockInputPreData.createFromBlock({ + const childBlockInput = createGloasBlockInput({ block: childBlock, blockRootHex: childBlockRootHex, - forkName: gloasConfig.getForkName(childBlock.message.slot), - daOutOfRange: false, seenTimestampSec: Date.now() / 1000, source: BlockInputSource.gossip, }); @@ -1388,11 +1447,9 @@ describe("UnknownBlockSync", () => { childBlock.message.body.signedExecutionPayloadBid.message.parentBlockRoot = parentRoot; childBlock.message.body.signedExecutionPayloadBid.message.parentBlockHash = parentPayloadHash; const childBlockRootHex = toRootHex(ssz.gloas.BeaconBlock.hashTreeRoot(childBlock.message)); - const childBlockInput = BlockInputPreData.createFromBlock({ + const childBlockInput = createGloasBlockInput({ block: childBlock, blockRootHex: childBlockRootHex, - forkName: gloasConfig.getForkName(childBlock.message.slot), - daOutOfRange: false, seenTimestampSec: Date.now() / 1000, source: BlockInputSource.gossip, }); From 772820469b11b35f3947defdf2a84d40d95b2454 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 16:18:56 +0100 Subject: [PATCH 55/67] Update packages/beacon-node/src/chain/blocks/types.ts --- packages/beacon-node/src/chain/blocks/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/src/chain/blocks/types.ts b/packages/beacon-node/src/chain/blocks/types.ts index 5d64e1a9bb4f..3599bd2f0b63 100644 --- a/packages/beacon-node/src/chain/blocks/types.ts +++ b/packages/beacon-node/src/chain/blocks/types.ts @@ -106,6 +106,6 @@ export type FullyVerifiedBlock = { indexedAttestations: IndexedAttestation[]; /** Seen timestamp seconds */ seenTimestampSec: number; - /** If the execution payload couldn't be verified because of EL syncing status, used in optimistic sync or for merge block */ + /** If the execution payload couldn't be verified because of EL syncing status, used in optimistic sync */ executionStatus: BlockExecutionStatus; }; From 11f9d1672c03975e2603527c6e67013d0a3ad017 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 16:19:08 +0100 Subject: [PATCH 56/67] Update packages/beacon-node/test/spec/presets/fork_choice.test.ts --- packages/beacon-node/test/spec/presets/fork_choice.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/test/spec/presets/fork_choice.test.ts b/packages/beacon-node/test/spec/presets/fork_choice.test.ts index 0719e85d19c4..71ede563c206 100644 --- a/packages/beacon-node/test/spec/presets/fork_choice.test.ts +++ b/packages/beacon-node/test/spec/presets/fork_choice.test.ts @@ -619,7 +619,7 @@ const forkChoiceTest = (name.includes("simple_attempted_reorg_without_enough_ffg_votes") || name.includes("include_votes_another_empty_chain_with_enough_ffg_votes_current_epoch") || name.includes("include_votes_another_empty_chain_without_enough_ffg_votes_current_epoch"))) || - // Spec test fixture bug in v1.7.0-alpha.5: wrong_withdrawals envelope SSZ data is + // TODO GLOAS: Spec test fixture bug in v1.7.0-alpha.5: wrong_withdrawals envelope SSZ data is // byte-for-byte identical to the valid envelope, making it impossible to reject name.endsWith("on_execution_payload_envelope__wrong_withdrawals"), }, From 2054188911e51822fa369f8775980c9934cdcd54 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 16:19:20 +0100 Subject: [PATCH 57/67] Update packages/state-transition/src/block/index.ts --- packages/state-transition/src/block/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/state-transition/src/block/index.ts b/packages/state-transition/src/block/index.ts index 84fbc929c091..9914aa76d535 100644 --- a/packages/state-transition/src/block/index.ts +++ b/packages/state-transition/src/block/index.ts @@ -74,8 +74,8 @@ export function processBlock( // The call to the process_execution_payload must happen before the call to the process_randao as the former depends // on the randao_mix computed with the reveal of the previous block. // Post-gloas: process_execution_payload is not part of block processing. The parent's payload - // effects are applied earlier via processParentExecutionPayload, and each envelope is verified - // out-of-band via verifyExecutionPayloadEnvelope when it arrives. + // effects are applied earlier via processParentExecutionPayload, and each execution payload is + // verified out-of-band via verifyExecutionPayloadEnvelope when it arrives. if ( fork < ForkSeq.gloas && fork >= ForkSeq.bellatrix && From 2a7d8e60b4fbbe13bef560acaaf76650bc324393 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 16:19:32 +0100 Subject: [PATCH 58/67] Update packages/state-transition/src/block/processWithdrawals.ts --- packages/state-transition/src/block/processWithdrawals.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/state-transition/src/block/processWithdrawals.ts b/packages/state-transition/src/block/processWithdrawals.ts index 55aa3e00b1c6..f667645044a7 100644 --- a/packages/state-transition/src/block/processWithdrawals.ts +++ b/packages/state-transition/src/block/processWithdrawals.ts @@ -32,7 +32,7 @@ export function processWithdrawals( state: CachedBeaconStateCapella | CachedBeaconStateElectra | CachedBeaconStateGloas, payload?: capella.FullOrBlindedExecutionPayload ): void { - // Return early if this is genesis block or the parent block is empty. + // Return early if this is genesis block or the parent block is empty if (fork >= ForkSeq.gloas) { const stateGloas = state as CachedBeaconStateGloas; const isGenesisBlock = byteArrayEquals(stateGloas.latestBlockHash, ZERO_HASH); From 372f4855dfb701c2d3d0bb1ff32b7b84dd1fd327 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 16:26:17 +0100 Subject: [PATCH 59/67] . --- .../chain/blocks/importExecutionPayload.ts | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index 1a445370a3ec..23e3f2e59713 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -79,12 +79,12 @@ function toForkChoiceExecutionStatus(status: ExecutionPayloadStatus): PayloadExe * 1. Emit `execution_payload_available` event for payload attestation * 2. Get the ProtoBlock from fork choice * 3. Wait for data columns to be available - * 4. Apply write-queue backpressure before verification - * 5. Regenerate state for envelope verification - * 6. Verify envelope (fields against state, signature, and EL — parallel where possible) - * 7. Persist verified payload envelope to hot DB - * 8. Update fork choice (transitions the block's PENDING variant to FULL) - * 9. Record metrics and emit `execution_payload` event + * 4. Regenerate state for envelope verification + * 5. Verify envelope (fields against state, signature, and EL in parallel where possible) + * 6. Persist verified payload envelope to hot DB (waits for write-queue space for backpressure) + * 7. Update fork choice (transitions the block's PENDING variant to FULL) + * 8. Record metrics for payload envelope and column sources + * 9. Emit `execution_payload` event */ export async function importExecutionPayload( this: BeaconChain, @@ -119,15 +119,11 @@ export async function importExecutionPayload( }); } - // 3. Wait for data columns to be available before claiming a write-queue slot. + // 3. Wait for data columns to be available. // The helper is shared with future gloas sync services; take the single-item batch form here. await verifyPayloadsDataAvailability([payloadInput], signal); - // 4. Apply backpressure from the write queue, before doing verification work. - // The actual DB write is deferred until after verification succeeds. - await this.unfinalizedPayloadEnvelopeWrites.waitForSpace(); - - // 5. Regenerate state for envelope verification + // 4. Regenerate state for envelope verification const blockState = await this.regen.getBlockSlotState( protoBlock, protoBlock.slot, @@ -141,7 +137,7 @@ export async function importExecutionPayload( }); } - // 6. Verify envelope against state (spec: verify_execution_payload_envelope). Run the sync + // 5. Verify envelope against state (spec: verify_execution_payload_envelope). Run the sync // field checks first to fail fast before starting the EL + BLS work. When validSignature is // true, the envelope came from gossip/API where both the signature and executionRequestsRoot // were already verified, skip re-hashing executionRequestsRoot. @@ -209,7 +205,9 @@ export async function importExecutionPayload( }); } - // 7. Persist payload envelope to hot DB (performed asynchronously to avoid blocking) + // 6. Persist payload envelope to hot DB. Wait for write-queue space here to apply backpressure + // on the import pipeline during sync, then perform the write asynchronously to avoid blocking. + await this.unfinalizedPayloadEnvelopeWrites.waitForSpace(); this.unfinalizedPayloadEnvelopeWrites.push(payloadInput).catch((e) => { if (!isQueueErrorAborted(e)) { this.logger.error( @@ -220,17 +218,17 @@ export async function importExecutionPayload( } }); - // 8. Update fork choice, transitions the block's PENDING variant to FULL + // 7. Update fork choice, transitions the block's PENDING variant to FULL const execStatus = toForkChoiceExecutionStatus(execResult.status); this.forkChoice.onExecutionPayload(blockRootHex, blockHashHex, envelope.payload.blockNumber, execStatus); - // 9. Record metrics for payload envelope and column sources, emit `execution_payload` - // event for recent enough payloads after successful import. + // 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}); } + // 9. Emit `execution_payload` event for recent enough payloads after successful import if (this.clock.currentSlot - slot < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { this.emitter.emit(routes.events.EventType.executionPayload, { slot, From 6218a8ebc540e27b094e895d1e39acee975123d8 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 16:32:22 +0100 Subject: [PATCH 60/67] wording --- .../beacon-node/src/chain/blocks/importExecutionPayload.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index 23e3f2e59713..a057f9dc2acd 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -137,10 +137,9 @@ export async function importExecutionPayload( }); } - // 5. Verify envelope against state (spec: verify_execution_payload_envelope). Run the sync - // field checks first to fail fast before starting the EL + BLS work. When validSignature is - // true, the envelope came from gossip/API where both the signature and executionRequestsRoot - // were already verified, skip re-hashing executionRequestsRoot. + // 5. Verify envelope fields against state first to fail fast before the EL + BLS work. + // When validSignature is true, gossip/API has already verified both the signature and the + // executionRequestsRoot, so we skip those checks here. try { verifyExecutionPayloadEnvelope(this.config, blockState, envelope, { verifyExecutionRequestsRoot: !opts.validSignature, From e450f5295c3472e1ecbffbe6c171f3591e502266 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 16:37:45 +0100 Subject: [PATCH 61/67] restore some comments --- 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 a057f9dc2acd..c63cb38c4528 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -175,10 +175,12 @@ export async function importExecutionPayload( ), ]); + // 5a. Check signature verification result if (!signatureValid) { throw new PayloadError({code: PayloadErrorCode.INVALID_SIGNATURE}); } + // 5b. Handle EL response switch (execResult.status) { case ExecutionPayloadStatus.VALID: break; From 232e294206f40a3b5fb0b3d426eb728a89a8935a Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 16:39:22 +0100 Subject: [PATCH 62/67] ok that's it --- .../beacon-node/src/chain/blocks/importExecutionPayload.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index c63cb38c4528..9a0abe45281e 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -154,6 +154,7 @@ export async function importExecutionPayload( ); } + // 5a. Run EL notifyNewPayload and signature verification in parallel const [execResult, signatureValid] = await Promise.all([ this.executionEngine.notifyNewPayload( fork, @@ -175,12 +176,12 @@ export async function importExecutionPayload( ), ]); - // 5a. Check signature verification result + // 5b. Check signature verification result if (!signatureValid) { throw new PayloadError({code: PayloadErrorCode.INVALID_SIGNATURE}); } - // 5b. Handle EL response + // 5c. Handle EL response switch (execResult.status) { case ExecutionPayloadStatus.VALID: break; From e7759b00b9430f330fcadd31d49f9e479050213a Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 16:41:21 +0100 Subject: [PATCH 63/67] not yet --- packages/beacon-node/src/chain/blocks/importExecutionPayload.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index 9a0abe45281e..fcad4de12f03 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -154,7 +154,7 @@ export async function importExecutionPayload( ); } - // 5a. Run EL notifyNewPayload and signature verification in parallel + // 5a. Run EL and signature verification in parallel const [execResult, signatureValid] = await Promise.all([ this.executionEngine.notifyNewPayload( fork, From c06c94618283d1b41c7b12726f567ec170eac9f7 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 16:43:56 +0100 Subject: [PATCH 64/67] why is that comment even modified... --- packages/beacon-node/src/chain/blocks/importExecutionPayload.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index fcad4de12f03..9334b97158c2 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -230,7 +230,7 @@ export async function importExecutionPayload( this.metrics?.importPayload.columnsBySource.inc({source}); } - // 9. Emit `execution_payload` event for recent enough payloads after successful import + // 9. Emit event after payload is fully verified and imported to fork choice, only for recent enough payloads if (this.clock.currentSlot - slot < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { this.emitter.emit(routes.events.EventType.executionPayload, { slot, From 037c066acafff7b26f9bcfe3087f5622bb0b4a8c Mon Sep 17 00:00:00 2001 From: Cayman Date: Thu, 23 Apr 2026 11:52:37 -0400 Subject: [PATCH 65/67] fix: address payload sync review feedback --- packages/beacon-node/src/sync/unknownBlock.ts | 54 ++++++++++--------- .../test/unit/sync/unknownBlock.test.ts | 38 ++++++++++++- 2 files changed, 65 insertions(+), 27 deletions(-) diff --git a/packages/beacon-node/src/sync/unknownBlock.ts b/packages/beacon-node/src/sync/unknownBlock.ts index 826364b3a80f..a2a9f31f21dc 100644 --- a/packages/beacon-node/src/sync/unknownBlock.ts +++ b/packages/beacon-node/src/sync/unknownBlock.ts @@ -951,49 +951,57 @@ export class BlockInputSync { } private async processPayload(pendingPayload: PendingPayloadInput): Promise { + const rootHex = pendingPayload.payloadInput.blockRootHex; + const logCtx = {slot: pendingPayload.payloadInput.slot, root: rootHex}; + if (pendingPayload.status !== PendingPayloadInputStatus.downloaded) { - if (pendingPayload.status === PendingPayloadInputStatus.pending) { - const connectedPeers = this.network.getConnectedPeers(); - if (connectedPeers.length === 0) { - this.logger.debug("No connected peers, skipping payload download", { - slot: pendingPayload.payloadInput.slot, - blockRoot: pendingPayload.payloadInput.blockRootHex, - }); - return; - } - await this.downloadPayload(pendingPayload); - } + this.logger.debug("Skipping payload processing before payload input is downloaded", { + ...logCtx, + status: pendingPayload.status, + }); return; } - const rootHex = pendingPayload.payloadInput.blockRootHex; if (this.chain.forkChoice.hasPayloadHexUnsafe(rootHex)) { + this.logger.debug("Payload already imported while processing unknown payload", logCtx); this.pendingPayloads.delete(rootHex); return; } + if (!this.chain.forkChoice.hasBlockHex(rootHex)) { + this.logger.debug("Payload input is ready before its block is in fork choice", logCtx); + const added = this.addByRootHex(rootHex); + pendingPayload.status = PendingPayloadInputStatus.downloaded; + if (added) { + this.triggerUnknownBlockSearch(); + } + return; + } + pendingPayload.status = PendingPayloadInputStatus.processing; const res = await wrapError(this.chain.processExecutionPayload(pendingPayload.payloadInput)); if (!res.err) { + this.logger.debug("Processed payload from unknown sync", logCtx); this.pendingPayloads.delete(rootHex); this.triggerUnknownBlockSearch(); return; } - const errorData = {slot: pendingPayload.payloadInput.slot, root: rootHex}; if (res.err instanceof PayloadError) { switch (res.err.type.code) { case PayloadErrorCode.BLOCK_NOT_IN_FORK_CHOICE: // Payload sync discovered the block dependency before the block queue did. Re-enqueue the // block and keep the payload ready so the scheduler can retry once the block reaches fork choice. - this.addByRootHex(rootHex); + if (this.addByRootHex(rootHex)) { + this.triggerUnknownBlockSearch(); + } + // Keep the payload out of any synchronous requeue pass; a later scheduler pass will retry it. pendingPayload.status = PendingPayloadInputStatus.downloaded; - this.triggerUnknownBlockSearch(); break; case PayloadErrorCode.EXECUTION_ENGINE_ERROR: - this.logger.debug("Execution engine error while processing payload from unknown sync", errorData, res.err); + this.logger.debug("Execution engine error while processing payload from unknown sync", logCtx, res.err); pendingPayload.status = PendingPayloadInputStatus.downloaded; break; @@ -1002,18 +1010,18 @@ export class BlockInputSync { case PayloadErrorCode.STATE_TRANSITION_ERROR: // TODO GLOAS: Decide how invalid payload inputs should eventually leave memory without // reintroducing envelope replacement / recreation flows. - this.logger.debug("Error processing payload from unknown sync", errorData, res.err); + this.logger.debug("Error processing payload from unknown sync", logCtx, res.err); this.removePendingPayloadAndDescendants(rootHex); break; default: - this.logger.debug("Error processing payload from unknown sync", errorData, res.err); + this.logger.debug("Error processing payload from unknown sync", logCtx, res.err); this.pendingPayloads.delete(rootHex); } return; } - this.logger.debug("Unknown error processing payload from unknown sync", errorData, res.err); + this.logger.debug("Unknown error processing payload from unknown sync", logCtx, res.err); pendingPayload.status = PendingPayloadInputStatus.downloaded; } @@ -1023,7 +1031,7 @@ export class BlockInputSync { * and only asks peers for the remaining pieces. */ private async fetchPayloadInput( - cacheItem: PayloadSyncCacheItem + cacheItem: PendingPayloadInput | PendingPayloadRootHex ): Promise { const rootHex = getPayloadSyncCacheItemRootHex(cacheItem); const blockRoot = fromHex(rootHex); @@ -1033,11 +1041,7 @@ export class BlockInputSync { let payloadInput = isPendingPayloadInput(cacheItem) ? cacheItem.payloadInput : this.chain.seenPayloadEnvelopeInputCache.get(rootHex); - let envelope = isPendingPayloadEnvelope(cacheItem) - ? cacheItem.envelope - : payloadInput?.hasPayloadEnvelope() - ? payloadInput.getPayloadEnvelope() - : undefined; + let envelope = payloadInput?.hasPayloadEnvelope() ? payloadInput.getPayloadEnvelope() : undefined; let i = 0; while (i++ < this.getMaxDownloadAttempts()) { diff --git a/packages/beacon-node/test/unit/sync/unknownBlock.test.ts b/packages/beacon-node/test/unit/sync/unknownBlock.test.ts index 58d71eeb48d0..99b1e4fcaea8 100644 --- a/packages/beacon-node/test/unit/sync/unknownBlock.test.ts +++ b/packages/beacon-node/test/unit/sync/unknownBlock.test.ts @@ -713,6 +713,11 @@ describe("UnknownBlockSync", () => { get: vi.fn().mockImplementation((root: string) => (root === blockRootHex ? payloadInput : undefined)), prune: vi.fn(), } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + forkChoice: { + hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), + hasBlockHex: vi.fn().mockImplementation((root: string) => root === blockRootHex), + getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), + } as unknown as IForkChoice, }, custodyConfig: {sampledColumns: [0], sampleGroups: [[0]]} as unknown as CustodyConfig, networkOverrides: { @@ -777,6 +782,11 @@ describe("UnknownBlockSync", () => { get: vi.fn().mockImplementation((root: string) => (root === blockRootHex ? payloadInput : undefined)), prune: vi.fn(), } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + forkChoice: { + hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), + hasBlockHex: vi.fn().mockImplementation((root: string) => root === blockRootHex), + getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), + } as unknown as IForkChoice, }, custodyConfig: {sampledColumns: [0, 1], sampleGroups: [[0], [1]]} as unknown as CustodyConfig, networkOverrides: { @@ -902,7 +912,7 @@ describe("UnknownBlockSync", () => { slot: 1, }); const parentRootHex = toRootHex(block.message.parentRoot); - const knownRoots = new Set([parentRootHex]); + const knownRoots = new Set([parentRootHex, blockRootHex]); const sendExecutionPayloadEnvelopesByRoot = vi.fn().mockResolvedValue([envelope]); const sendBeaconBlocksByRoot = vi.fn().mockResolvedValue([block]); @@ -998,6 +1008,7 @@ describe("UnknownBlockSync", () => { }); let cachedPayloadInput: PayloadEnvelopeInput | undefined; + let blockKnown = false; const processExecutionPayload = vi.fn().mockResolvedValue(undefined); const {emitter} = setupPayloadSyncTest({ chainOverrides: { @@ -1008,7 +1019,7 @@ describe("UnknownBlockSync", () => { } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], forkChoice: { hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), - hasBlockHex: vi.fn().mockReturnValue(false), + hasBlockHex: vi.fn().mockImplementation((root: string) => root === blockRootHex && blockKnown), getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), } as unknown as IForkChoice, }, @@ -1024,6 +1035,7 @@ describe("UnknownBlockSync", () => { expect(processExecutionPayload).not.toHaveBeenCalled(); cachedPayloadInput = payloadInput; + blockKnown = true; emitter.emit(routes.events.EventType.block, {slot: 1, block: blockRootHex, executionOptimistic: false}); await sleep(50); @@ -1051,6 +1063,11 @@ describe("UnknownBlockSync", () => { get: vi.fn().mockReturnValue(undefined), prune: vi.fn(), } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + forkChoice: { + hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), + hasBlockHex: vi.fn().mockImplementation((root: string) => root === payloadInput.blockRootHex), + getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), + } as unknown as IForkChoice, }, networkOverrides: {sendExecutionPayloadEnvelopesByRoot}, }); @@ -1100,6 +1117,7 @@ describe("UnknownBlockSync", () => { let connected = false; let cachedPayloadInput: PayloadEnvelopeInput | undefined; + let blockKnown = false; const processExecutionPayload = vi.fn().mockResolvedValue(undefined); const sendExecutionPayloadEnvelopesByRoot = vi.fn().mockResolvedValueOnce([envelope]); const {emitter} = setupPayloadSyncTest({ @@ -1109,6 +1127,11 @@ describe("UnknownBlockSync", () => { get: vi.fn().mockImplementation((root: string) => (root === blockRootHex ? cachedPayloadInput : undefined)), prune: vi.fn(), } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + forkChoice: { + hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), + hasBlockHex: vi.fn().mockImplementation((root: string) => root === blockRootHex && blockKnown), + getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), + } as unknown as IForkChoice, }, networkOverrides: { getConnectedPeers: () => (connected ? [peer] : []), @@ -1130,6 +1153,7 @@ describe("UnknownBlockSync", () => { connected = true; cachedPayloadInput = payloadInput; + blockKnown = true; emitter.emit(routes.events.EventType.block, {slot: 1, block: blockRootHex, executionOptimistic: false}); await sleep(50); @@ -1169,6 +1193,11 @@ describe("UnknownBlockSync", () => { get: vi.fn().mockImplementation((root: string) => (root === blockRootHex ? payloadInput : undefined)), prune: vi.fn(), } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + forkChoice: { + hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), + hasBlockHex: vi.fn().mockImplementation((root: string) => root === blockRootHex), + getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), + } as unknown as IForkChoice, }, networkOverrides: {sendExecutionPayloadEnvelopesByRoot}, peers: [{peerId: peer}], @@ -1253,6 +1282,11 @@ describe("UnknownBlockSync", () => { get: vi.fn().mockReturnValue(payloadInput), prune: vi.fn(), } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + forkChoice: { + hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), + hasBlockHex: vi.fn().mockImplementation((root: string) => root === payloadInput.blockRootHex), + getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), + } as unknown as IForkChoice, }, }); From a1dcf208b3857aeb24c7b106bebaa5cd3b469c5e Mon Sep 17 00:00:00 2001 From: Cayman Date: Thu, 23 Apr 2026 11:58:46 -0400 Subject: [PATCH 66/67] fix: handle envelope verification payload errors --- packages/beacon-node/src/sync/unknownBlock.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/src/sync/unknownBlock.ts b/packages/beacon-node/src/sync/unknownBlock.ts index a2a9f31f21dc..215240ee7fe5 100644 --- a/packages/beacon-node/src/sync/unknownBlock.ts +++ b/packages/beacon-node/src/sync/unknownBlock.ts @@ -1006,8 +1006,8 @@ export class BlockInputSync { break; case PayloadErrorCode.EXECUTION_ENGINE_INVALID: + case PayloadErrorCode.ENVELOPE_VERIFICATION_ERROR: case PayloadErrorCode.INVALID_SIGNATURE: - case PayloadErrorCode.STATE_TRANSITION_ERROR: // TODO GLOAS: Decide how invalid payload inputs should eventually leave memory without // reintroducing envelope replacement / recreation flows. this.logger.debug("Error processing payload from unknown sync", logCtx, res.err); From 3a71c0c2f2f3d61becb257f139552bb35d0fcea6 Mon Sep 17 00:00:00 2001 From: lodekeeper Date: Fri, 24 Apr 2026 06:46:04 +0000 Subject: [PATCH 67/67] --- packages/beacon-node/src/chain/chain.ts | 4 + .../beacon-node/src/chain/forkChoice/index.ts | 93 ++++++++++++------- .../fork-choice/src/forkChoice/forkChoice.ts | 10 ++ 3 files changed, 72 insertions(+), 35 deletions(-) diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 90353f22344e..59b08744e60d 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -892,6 +892,10 @@ export class BeaconChain implements IBeaconChain { parentBlockSlot: Slot, parentBlockRootHex: RootHex ): Promise { + // Gloas genesis has no payload envelope — treat the parent's requests as empty. + if (parentBlockSlot === GENESIS_SLOT) { + return ssz.electra.ExecutionRequests.defaultValue(); + } const envelope = await this.getExecutionPayloadEnvelope(parentBlockSlot, parentBlockRootHex); if (envelope === null) { throw Error(`Parent execution payload envelope not found slot=${parentBlockSlot}, root=${parentBlockRootHex}`); diff --git a/packages/beacon-node/src/chain/forkChoice/index.ts b/packages/beacon-node/src/chain/forkChoice/index.ts index 312d7c270054..6af9baaefe7e 100644 --- a/packages/beacon-node/src/chain/forkChoice/index.ts +++ b/packages/beacon-node/src/chain/forkChoice/index.ts @@ -103,6 +103,63 @@ export function initializeForkChoiceFromFinalizedState( const isForkPostGloas = computeEpochAtSlot(state.slot) >= config.GLOAS_FORK_EPOCH; + const anchorBlockRoot = toRootHex(checkpoint.root); + const protoArray = ProtoArray.initialize( + { + slot: blockHeader.slot, + parentRoot: toRootHex(blockHeader.parentRoot), + stateRoot: toRootHex(blockHeader.stateRoot), + blockRoot: anchorBlockRoot, + timeliness: true, // Optimistically assume is timely + + justifiedEpoch: justifiedCheckpoint.epoch, + justifiedRoot: toRootHex(justifiedCheckpoint.root), + finalizedEpoch: finalizedCheckpoint.epoch, + finalizedRoot: toRootHex(finalizedCheckpoint.root), + unrealizedJustifiedEpoch: justifiedCheckpoint.epoch, + unrealizedJustifiedRoot: toRootHex(justifiedCheckpoint.root), + unrealizedFinalizedEpoch: finalizedCheckpoint.epoch, + unrealizedFinalizedRoot: toRootHex(finalizedCheckpoint.root), + + ...(isStatePostBellatrix(state) && state.isExecutionStateType && state.isMergeTransitionComplete + ? { + executionPayloadBlockHash: isStatePostGloas(state) + ? toRootHex(state.latestBlockHash) + : toRootHex(state.latestExecutionPayloadHeader.blockHash), + // TODO GLOAS: executionPayloadNumber is not tracked in BeaconState post-gloas (EIP-7732 removed + // latestExecutionPayloadHeader). Using 0 as unavailable fallback until a solution is found. + executionPayloadNumber: isStatePostGloas(state) ? 0 : state.payloadBlockNumber, + executionStatus: blockHeader.slot === GENESIS_SLOT ? ExecutionStatus.Valid : ExecutionStatus.Syncing, + } + : {executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}), + + dataAvailabilityStatus: DataAvailabilityStatus.PreData, + payloadStatus: isForkPostGloas ? PayloadStatus.PENDING : PayloadStatus.FULL, + parentBlockHash: isStatePostGloas(state) ? toRootHex(state.latestBlockHash) : null, + }, + currentSlot + ); + + // Gloas anchor whose payload has already been fulfilled (e.g. genesis with EL genesis block, + // or a finalized anchor that was FULL at time of finalization) must expose a FULL variant so + // `forkChoice.hasPayload(anchorRoot)` returns true and next-slot FCU can extend the EL head. + // Spec: gloas/fork-choice.md `is_parent_block_full(state)` equivalent — parent is FULL when + // `state.latestBlockHash == state.latestExecutionPayloadBid.blockHash`. + if (isStatePostGloas(state) && state.isMergeTransitionComplete) { + const latestBlockHashHex = toRootHex(state.latestBlockHash); + const bidBlockHashHex = toRootHex(state.latestExecutionPayloadBid.blockHash); + if (latestBlockHashHex !== ZERO_HASH_HEX && latestBlockHashHex === bidBlockHashHex) { + protoArray.onExecutionPayload( + anchorBlockRoot, + currentSlot, + latestBlockHashHex, + 0, + null, + blockHeader.slot === GENESIS_SLOT ? ExecutionStatus.Valid : ExecutionStatus.Syncing + ); + } + } + return new forkchoiceConstructor( config, @@ -118,41 +175,7 @@ export function initializeForkChoiceFromFinalizedState( } ), - ProtoArray.initialize( - { - slot: blockHeader.slot, - parentRoot: toRootHex(blockHeader.parentRoot), - stateRoot: toRootHex(blockHeader.stateRoot), - blockRoot: toRootHex(checkpoint.root), - timeliness: true, // Optimistically assume is timely - - justifiedEpoch: justifiedCheckpoint.epoch, - justifiedRoot: toRootHex(justifiedCheckpoint.root), - finalizedEpoch: finalizedCheckpoint.epoch, - finalizedRoot: toRootHex(finalizedCheckpoint.root), - unrealizedJustifiedEpoch: justifiedCheckpoint.epoch, - unrealizedJustifiedRoot: toRootHex(justifiedCheckpoint.root), - unrealizedFinalizedEpoch: finalizedCheckpoint.epoch, - unrealizedFinalizedRoot: toRootHex(finalizedCheckpoint.root), - - ...(isStatePostBellatrix(state) && state.isExecutionStateType && state.isMergeTransitionComplete - ? { - executionPayloadBlockHash: isStatePostGloas(state) - ? toRootHex(state.latestBlockHash) - : toRootHex(state.latestExecutionPayloadHeader.blockHash), - // TODO GLOAS: executionPayloadNumber is not tracked in BeaconState post-gloas (EIP-7732 removed - // latestExecutionPayloadHeader). Using 0 as unavailable fallback until a solution is found. - executionPayloadNumber: isStatePostGloas(state) ? 0 : state.payloadBlockNumber, - executionStatus: blockHeader.slot === GENESIS_SLOT ? ExecutionStatus.Valid : ExecutionStatus.Syncing, - } - : {executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}), - - dataAvailabilityStatus: DataAvailabilityStatus.PreData, - payloadStatus: isForkPostGloas ? PayloadStatus.PENDING : PayloadStatus.FULL, - parentBlockHash: isStatePostGloas(state) ? toRootHex(state.latestBlockHash) : null, - }, - currentSlot - ), + protoArray, state.validatorCount, metrics, opts, diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 30b495edcbed..f7f5742ec3d5 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -268,6 +268,11 @@ export class ForkChoice implements IForkChoice { return {shouldOverrideFcu: false, reason: NotReorgedReason.ProposerBoostReorgDisabled}; } + // Anchor block (e.g. Gloas genesis) has no parent in the proto array — no reorg possible. + if (headBlock.parentRoot === HEX_ZERO_HASH) { + return {shouldOverrideFcu: false, reason: NotReorgedReason.ParentBlockNotAvailable}; + } + const parentBlock = this.protoArray.getBlock( headBlock.parentRoot, this.protoArray.getParentPayloadStatus(headBlock) @@ -388,6 +393,11 @@ export class ForkChoice implements IForkChoice { return {proposerHead, isHeadTimely, notReorgedReason: NotReorgedReason.ProposerBoostReorgDisabled}; } + // Anchor block (e.g. Gloas genesis) has no parent in the proto array — no reorg possible. + if (headBlock.parentRoot === HEX_ZERO_HASH) { + return {proposerHead, isHeadTimely, notReorgedReason: NotReorgedReason.ParentBlockNotAvailable}; + } + const parentBlock = this.protoArray.getBlock( headBlock.parentRoot, this.protoArray.getParentPayloadStatus(headBlock)