From 8b15011010f7494d576acb1c86a30ae82e3765dd Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Fri, 17 Apr 2026 19:22:05 -0700 Subject: [PATCH 01/24] feat: implement should_apply_proposer_boost for gloas Implement the `should_apply_proposer_boost` logic from consensus-specs commit 71d1151 (PR #4807). This addresses the builder reveal safety concern where a colluding next-slot proposer could use proposer boost to override a legitimately revealed block. Changes: - Add `ptcTimeliness` and `proposerIndex` fields to ProtoBlock - Add `isBlockPtcTimely` to track PTC deadline timeliness - Add `shouldApplyProposerBoost` which withholds boost when the parent is a weak, equivocating block from the previous slot - Add `findEquivocatingBlocks` in ProtoArray to detect proposer equivocations by scanning for PTC-timely blocks at the same slot from the same proposer - Gate proposer boost in `getWeight` on `shouldApplyProposerBoost()` - Pre-gloas blocks retain unconditional boost (backward compatible) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../beacon-node/src/chain/forkChoice/index.ts | 4 ++ .../fork-choice/src/forkChoice/forkChoice.ts | 64 ++++++++++++++++++- .../fork-choice/src/protoArray/interface.ts | 7 ++ .../fork-choice/src/protoArray/protoArray.ts | 24 +++++++ 4 files changed, 98 insertions(+), 1 deletion(-) diff --git a/packages/beacon-node/src/chain/forkChoice/index.ts b/packages/beacon-node/src/chain/forkChoice/index.ts index 6e274acd2175..6039af2549b9 100644 --- a/packages/beacon-node/src/chain/forkChoice/index.ts +++ b/packages/beacon-node/src/chain/forkChoice/index.ts @@ -138,6 +138,8 @@ export function initializeForkChoiceFromFinalizedState( stateRoot: toRootHex(blockHeader.stateRoot), blockRoot: toRootHex(checkpoint.root), timeliness: true, // Optimistically assume is timely + ptcTimeliness: true, // Spec: block_timeliness for anchor = [True, True] + proposerIndex: blockHeader.proposerIndex, justifiedEpoch: justifiedCheckpoint.epoch, justifiedRoot: toRootHex(justifiedCheckpoint.root), @@ -235,6 +237,8 @@ export function initializeForkChoiceFromUnfinalizedState( blockRoot: headRoot, targetRoot: headRoot, timeliness: true, // Optimistically assume is timely + ptcTimeliness: true, // Spec: block_timeliness for anchor = [True, True] + proposerIndex: blockHeader.proposerIndex, justifiedEpoch: justifiedCheckpoint.epoch, justifiedRoot: toRootHex(justifiedCheckpoint.root), diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 49226737b9fe..877ce6c52fd2 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -516,7 +516,7 @@ export class ForkChoice implements IForkChoice { * starting from the proposerIndex */ let proposerBoost: {root: RootHex; score: number} | null = null; - if (this.opts?.proposerBoost && this.proposerBoostRoot) { + if (this.opts?.proposerBoost && this.proposerBoostRoot && this.shouldApplyProposerBoost()) { const proposerBoostScore = this.justifiedProposerBoostScore ?? getCommitteeFraction(this.fcStore.justified.totalBalance, { @@ -794,6 +794,8 @@ export class ForkChoice implements IForkChoice { targetRoot: toRootHex(targetRoot), stateRoot: toRootHex(block.stateRoot), timeliness: isTimely, + ptcTimeliness: this.isBlockPtcTimely(block, blockDelaySec), + proposerIndex: block.proposerIndex, justifiedEpoch: stateJustifiedEpoch, justifiedRoot: toRootHex(state.currentJustifiedCheckpoint.root), @@ -1444,6 +1446,66 @@ export class ForkChoice implements IForkChoice { return this.fcStore.currentSlot === block.slot && isBeforeLateBlockCutoff; } + /** + * Check if block arrived before the PTC deadline. + * Spec: gloas/fork-choice.md#record_block_timeliness (block_timeliness[PTC_TIMELINESS_INDEX]) + */ + private isBlockPtcTimely(block: BeaconBlock, blockDelaySec: number): boolean { + const isCurrentSlot = this.fcStore.currentSlot === block.slot; + const ptcThresholdMs = this.config.getSlotComponentDurationMs(this.config.PAYLOAD_ATTESTATION_DUE_BPS); + return isCurrentSlot && blockDelaySec * 1000 < ptcThresholdMs; + } + + /** + * Spec: gloas/fork-choice.md#new-should_apply_proposer_boost + * Determines whether proposer boost should apply for gloas blocks. + * Returns true for pre-gloas blocks (unconditional boost). + */ + private shouldApplyProposerBoost(): boolean { + if (!this.proposerBoostRoot) { + return false; + } + + const boostedBlock = this.getBlockHexDefaultStatus(this.proposerBoostRoot); + if (!boostedBlock || !isGloasBlock(boostedBlock)) { + // Pre-gloas blocks always get boost + return true; + } + + const parentBlock = this.getBlockHexDefaultStatus(boostedBlock.parentRoot); + if (!parentBlock) { + return true; + } + + const slot = boostedBlock.slot; + + // Apply proposer boost if parent is not from the previous slot + if (parentBlock.slot + 1 < slot) { + return true; + } + + // Apply proposer boost if parent is not weak + const reorgThreshold = getCommitteeFraction(this.fcStore.justified.totalBalance, { + slotsPerEpoch: SLOTS_PER_EPOCH, + committeePercent: this.config.REORG_HEAD_WEIGHT_THRESHOLD, + }); + const parentNode = this.protoArray.getNode(parentBlock.blockRoot, PayloadStatus.PENDING); + if (parentNode === undefined || parentNode.weight >= reorgThreshold) { + // Parent is not weak + return true; + } + + // Parent is weak and from the previous slot: apply boost if there are no equivocations + // Look for other PTC-timely blocks at the same slot from the same proposer + const equivocations = this.protoArray.findEquivocatingBlocks( + parentBlock.proposerIndex, + parentBlock.slot, + parentBlock.blockRoot + ); + + return equivocations.length === 0; + } + /** * https://github.com/ethereum/consensus-specs/blob/v1.5.0/specs/phase0/fork-choice.md#is_proposing_on_time */ diff --git a/packages/fork-choice/src/protoArray/interface.ts b/packages/fork-choice/src/protoArray/interface.ts index cae46fdacd55..6ab1c88f26c7 100644 --- a/packages/fork-choice/src/protoArray/interface.ts +++ b/packages/fork-choice/src/protoArray/interface.ts @@ -135,6 +135,13 @@ export type ProtoBlock = BlockExtraMeta & { // Indicate whether block arrives in a timely manner ie. before the 4 second mark timeliness: boolean; + // Indicate whether block arrives before the PTC deadline + // Spec: gloas/fork-choice.md#record_block_timeliness (block_timeliness[PTC_TIMELINESS_INDEX]) + ptcTimeliness: boolean; + + // The index of the block proposer + proposerIndex: number; + /** Payload status for this node (Gloas fork). Always FULL in pre-gloas */ payloadStatus: PayloadStatus; diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 4d239d52e8db..68e99e3bee3f 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -1872,6 +1872,30 @@ export class ProtoArray { return node; } + /** + * Find blocks at the given slot from the same proposer that are PTC-timely, + * excluding the given block root. + * Spec: gloas/fork-choice.md#new-should_apply_proposer_boost (equivocations check) + */ + findEquivocatingBlocks(proposerIndex: number, slot: Slot, excludeRoot: RootHex): ProtoNode[] { + const result: ProtoNode[] = []; + for (const [root, variantOrArr] of this.indices.entries()) { + if (root === excludeRoot) continue; + const nodeIndex = Array.isArray(variantOrArr) ? variantOrArr[0] : variantOrArr; + if (nodeIndex === undefined) continue; + const node = this.nodes[nodeIndex]; + if ( + node !== undefined && + node.slot === slot && + node.proposerIndex === proposerIndex && + node.ptcTimeliness + ) { + result.push(node); + } + } + return result; + } + private getNodesBetween(upperIndex: number, lowerIndex: number): ProtoNode[] { const result = []; for (let index = upperIndex - 1; index > lowerIndex; index--) { From 8fcc417dd9181fa891142f63f7e57137c90ecac0 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 02/24] 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 6621c7bae0d8d8e4a28ad10e81d365c24aae5f61 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 03/24] 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 2e0bc3a7e66ceee85a05ef45ded15772a7d1723d 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 04/24] 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 | 96 ++++++------------- .../src/stateView/beaconStateView.ts | 13 +-- .../src/stateView/interface.ts | 4 +- 3 files changed, 36 insertions(+), 77 deletions(-) diff --git a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts index c7f858a908d0..b78e90d39071 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,81 +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 { - const {verifySignature = true, verifyStateRoot = true} = opts ?? {}; +): void { + const {verifySignature = true} = opts ?? {}; const envelope = signedEnvelope.message; - const payload = envelope.payload; - const fork = state.config.getForkSeq(envelope.slot); if (verifySignature && !verifyExecutionPayloadEnvelopeSignature(state, signedEnvelope)) { throw Error(`Execution payload envelope has invalid signature builderIndex=${envelope.builderIndex}`); } - // .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(); - - 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; + validateExecutionPayloadEnvelope(state, envelope); } function validateExecutionPayloadEnvelope( @@ -90,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)}` ); } @@ -143,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( @@ -157,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 81835b5064d91c3803ef41cc67097928d9f755f9 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 05/24] 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 877ce6c52fd2..89f2111879a1 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -990,7 +990,6 @@ export class ForkChoice implements IForkChoice { blockRoot: RootHex, executionPayloadBlockHash: RootHex, executionPayloadNumber: number, - executionPayloadStateRoot: RootHex, executionStatus: PayloadExecutionStatus ): void { this.protoArray.onExecutionPayload( @@ -998,7 +997,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 68e99e3bee3f..1c0163368ed2 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 6d503b7192c24c95cf31d934c90b103a55090120 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 06/24] 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) --- packages/api/src/beacon/routes/events.ts | 4 - .../src/api/impl/beacon/blocks/index.ts | 4 +- .../src/api/impl/validator/index.ts | 3 - .../chain/blocks/importExecutionPayload.ts | 110 ++++++------------ .../src/network/processor/gossipHandlers.ts | 1 - 5 files changed, 38 insertions(+), 84 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 fa8e523d3f1c..645b3c64387c 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,7 @@ export function getBeaconBlockApi({ const fork = config.getForkName(slot); const blockRootHex = toRootHex(envelope.beaconBlockRoot); const blockHashHex = toRootHex(envelope.payload.blockHash); - const stateRootHex = toRootHex(envelope.stateRoot); + // stateRoot removed from envelope in consensus-specs#5094 if (!isForkPostGloas(fork)) { throw new ApiError(400, `publishExecutionPayloadEnvelope not supported for pre-gloas fork=${fork}`); @@ -740,7 +740,6 @@ export function getBeaconBlockApi({ slot, blockRoot: blockRootHex, blockHash: blockHashHex, - stateRoot: stateRootHex, builderIndex: envelope.builderIndex, isSelfBuild, dataColumns: dataColumnSidecars.length, @@ -768,7 +767,6 @@ export function getBeaconBlockApi({ builderIndex: envelope.builderIndex, blockHash: blockHashHex, blockRoot: blockRootHex, - stateRoot: stateRootHex, }); const sentPeersArr = await publishPromise; diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 607479db091f..9d4cd0d3d53d 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -1649,9 +1649,6 @@ export function getValidatorApi( 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..c4791f407c53 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -1,8 +1,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} from "@lodestar/utils"; import {ExecutionPayloadStatus} from "../../execution/index.js"; import {isQueueErrorAborted} from "../../util/queue/index.js"; import {BeaconChain} from "../chain.js"; @@ -69,18 +68,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, @@ -94,10 +95,7 @@ export async function importExecutionPayload( const blockHashHex = payloadInput.getBlockHashHex(); 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. + // 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.slot, @@ -138,7 +136,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 +157,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,17 +203,7 @@ export async function importExecutionPayload( }); } - // 5c. Verify envelope state root matches post-state - 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) + // 6. Persist payload envelope to hot DB this.unfinalizedPayloadEnvelopeWrites.push(payloadInput).catch((e) => { if (!isQueueErrorAborted(e)) { this.logger.error( @@ -234,38 +214,23 @@ 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}); } - const stateRootHex = toRootHex(envelope.stateRoot); - - // 10. Emit event after payload is fully verified and imported to fork choice, only for recent enough payloads + // 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.slot, builderIndex: envelope.builderIndex, blockHash: blockHashHex, blockRoot: blockRootHex, - stateRoot: stateRootHex, // TODO GLOAS: revisit once we support optimistic import executionOptimistic: false, }); @@ -276,6 +241,5 @@ export async function importExecutionPayload( builderIndex: envelope.builderIndex, blockRoot: blockRootHex, blockHash: blockHashHex, - stateRoot: stateRootHex, }); } diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index 32debf5db525..bf01a9e6e0a7 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -1118,7 +1118,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) => { From 5e460d59c461c382b14f7d5d2b2389931f3f53cb 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 07/24] 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 | 6 ++++++ .../chain/validation/executionPayloadEnvelope.ts | 14 ++++++++++++-- .../test/spec/presets/operations.test.ts | 5 ++--- packages/validator/src/services/block.ts | 3 +-- 7 files changed, 43 insertions(+), 8 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 e8004ab94519..2b123355ed5e 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, @@ -280,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 256b5f567b7a..ef58992a6bbb 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; 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 f265712d8c8d7d4a9e1a70f01f24634fa06daaad 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 08/24] 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 ee90b262cc9e1d653807f2c29fb02e085ea46b6e Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 14 Apr 2026 22:26:25 -0700 Subject: [PATCH 09/24] feat: range sync envelope support for Gloas (supersedes #9155) Range sync now fetches and validates execution payload envelopes alongside blocks for Gloas forks. With deferred processing, blocks can sync optimistically without envelopes, but envelopes are fetched in parallel for fork-choice FULL/EMPTY variant accuracy. Changes: - Batch carries payloadEnvelopes across all state transitions - downloadByRange fetches envelopes via sendExecutionPayloadEnvelopesByRange - validateEnvelopesByRangeResponse verifies beaconBlockRoot matches blocks - processChainSegment accepts payloadEnvelopes parameter - SyncChainFns types updated for envelope data flow - Add INVALID_ENVELOPE_BEACON_BLOCK_ROOT error code Co-Authored-By: Claude Opus 4.6 (1M context) --- .../chain/blocks/importExecutionPayload.ts | 7 +- packages/beacon-node/src/chain/chain.ts | 32 ++++- packages/beacon-node/src/chain/interface.ts | 6 +- packages/beacon-node/src/sync/range/batch.ts | 69 +++++++--- packages/beacon-node/src/sync/range/chain.ts | 19 ++- packages/beacon-node/src/sync/range/range.ts | 15 ++- .../src/sync/utils/downloadByRange.ts | 123 ++++++++++++++++-- .../perf/chain/verifyImportBlocks.test.ts | 2 +- .../test/unit/sync/range/batch.test.ts | 26 ++-- .../test/unit/sync/range/chain.test.ts | 8 +- .../unit/sync/range/utils/batches.test.ts | 2 +- .../sync/range/utils/peerBalancer.test.ts | 2 +- .../block/processExecutionPayloadEnvelope.ts | 21 +-- 13 files changed, 266 insertions(+), 66 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index c4791f407c53..220e61253eea 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -161,7 +161,12 @@ export async function importExecutionPayload( // 5a. Pure envelope verification (no state mutation) 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 + blockState.verifyExecutionPayloadEnvelope(signedEnvelope, { + verifySignature: false, + verifyExecutionRequestsRoot: !opts.validSignature, + }); } catch (e) { throw new PayloadError( { diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 417a65fdf37a..434e78c9dbd7 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -73,6 +73,7 @@ import {BlockProcessor, ImportBlockOpts} from "./blocks/index.js"; import {PayloadEnvelopeProcessor} from "./blocks/payloadEnvelopeProcessor.js"; import {ImportPayloadOpts} from "./blocks/types.js"; import {persistBlockInput} from "./blocks/writeBlockInputToDb.js"; +import {PayloadEnvelopeInputSource} from "./blocks/payloadEnvelopeInput/types.js"; import {persistPayloadEnvelopeInput} from "./blocks/writePayloadEnvelopeInputToDb.js"; import {BlsMultiThreadWorkerPool, BlsSingleThreadVerifier, IBlsVerifier} from "./bls/index.js"; import {ColumnReconstructionTracker} from "./ColumnReconstructionTracker.js"; @@ -1100,8 +1101,35 @@ export class BeaconChain implements IBeaconChain { return this.blockProcessor.processBlocksJob([block], opts); } - async processChainSegment(blocks: IBlockInput[], opts?: ImportBlockOpts): Promise { - return this.blockProcessor.processBlocksJob(blocks, opts); + async processChainSegment( + blocks: IBlockInput[], + payloadEnvelopes: Map | null, + opts?: ImportBlockOpts + ): Promise { + await this.blockProcessor.processBlocksJob(blocks, opts); + + // After blocks are imported, add downloaded envelopes to their PayloadEnvelopeInput + // and trigger processing. Each block's importBlock() already created a PayloadEnvelopeInput + // in seenPayloadEnvelopeInputCache. + if (payloadEnvelopes) { + for (const [slot, envelope] of payloadEnvelopes) { + const blockRootHex = toRootHex(envelope.message.beaconBlockRoot); + const payloadInput = this.seenPayloadEnvelopeInputCache.get(blockRootHex); + if (!payloadInput || payloadInput.hasPayloadEnvelope()) continue; + + payloadInput.addPayloadEnvelope({ + envelope, + source: PayloadEnvelopeInputSource.byRange, + seenTimestampSec: Date.now() / 1000, + }); + + if (payloadInput.isComplete()) { + this.processExecutionPayload(payloadInput, {validSignature: false}).catch((e) => { + this.logger.debug("Error processing envelope from range sync", {slot, root: blockRootHex}, e as Error); + }); + } + } + } } async processExecutionPayload(payloadInput: PayloadEnvelopeInput, opts?: ImportPayloadOpts): Promise { diff --git a/packages/beacon-node/src/chain/interface.ts b/packages/beacon-node/src/chain/interface.ts index 1bb42bfddd30..e97d436e3aa5 100644 --- a/packages/beacon-node/src/chain/interface.ts +++ b/packages/beacon-node/src/chain/interface.ts @@ -248,7 +248,11 @@ export interface IBeaconChain { /** Process a block until complete */ processBlock(block: IBlockInput, opts?: ImportBlockOpts): Promise; /** Process a chain of blocks until complete */ - processChainSegment(blocks: IBlockInput[], opts?: ImportBlockOpts): Promise; + processChainSegment( + blocks: IBlockInput[], + payloadEnvelopes: Map | null, + opts?: ImportBlockOpts + ): Promise; /** Process execution payload envelope: verify, import to fork choice, and persist to DB */ processExecutionPayload(payloadInput: PayloadEnvelopeInput, opts?: ImportPayloadOpts): Promise; diff --git a/packages/beacon-node/src/sync/range/batch.ts b/packages/beacon-node/src/sync/range/batch.ts index cf03667acedb..afdaacab1303 100644 --- a/packages/beacon-node/src/sync/range/batch.ts +++ b/packages/beacon-node/src/sync/range/batch.ts @@ -1,6 +1,6 @@ import {ChainForkConfig} from "@lodestar/config"; -import {ForkName, isForkPostDeneb, isForkPostFulu} from "@lodestar/params"; -import {Epoch, RootHex, Slot, phase0} from "@lodestar/types"; +import {ForkName, isForkPostDeneb, isForkPostFulu, isForkPostGloas} from "@lodestar/params"; +import {Epoch, RootHex, Slot, gloas, phase0} from "@lodestar/types"; import {LodestarError} from "@lodestar/utils"; import {isBlockInputColumns} from "../../chain/blocks/blockInput/blockInput.js"; import {IBlockInput} from "../../chain/blocks/blockInput/types.js"; @@ -46,19 +46,21 @@ export type Attempt = { export type AwaitingDownloadState = { status: BatchStatus.AwaitingDownload; blocks: IBlockInput[]; + payloadEnvelopes: Map | null; }; export type DownloadSuccessState = { status: BatchStatus.AwaitingProcessing; blocks: IBlockInput[]; + payloadEnvelopes: Map | null; }; export type BatchState = | AwaitingDownloadState - | {status: BatchStatus.Downloading; peer: PeerIdStr; blocks: IBlockInput[]} + | {status: BatchStatus.Downloading; peer: PeerIdStr; blocks: IBlockInput[]; payloadEnvelopes: Map | null} | DownloadSuccessState - | {status: BatchStatus.Processing; blocks: IBlockInput[]; attempt: Attempt} - | {status: BatchStatus.AwaitingValidation; blocks: IBlockInput[]; attempt: Attempt}; + | {status: BatchStatus.Processing; blocks: IBlockInput[]; payloadEnvelopes: Map | null; attempt: Attempt} + | {status: BatchStatus.AwaitingValidation; blocks: IBlockInput[]; payloadEnvelopes: Map | null; attempt: Attempt}; export type BatchMetadata = { startEpoch: Epoch; @@ -85,7 +87,7 @@ export class Batch { /** Block, blob and column requests that are used to determine the best peer and are used in downloadByRange */ requests: DownloadByRangeRequests; /** State of the batch. */ - state: BatchState = {status: BatchStatus.AwaitingDownload, blocks: []}; + state: BatchState = {status: BatchStatus.AwaitingDownload, blocks: [], payloadEnvelopes: null}; /** Peers that provided good data */ goodPeers: PeerIdStr[] = []; /** The `Attempts` that have been made and failed to send us this batch. */ @@ -129,6 +131,10 @@ export class Batch { count: this.count, step: 1, }; + const envelopesRequest: gloas.ExecutionPayloadEnvelopesByRangeRequest | undefined = isForkPostGloas(this.forkName) + ? {startSlot: this.startSlot, count: this.count} + : undefined; + if (isForkPostFulu(this.forkName) && withinValidRequestWindow) { return { blocksRequest, @@ -137,6 +143,7 @@ export class Batch { count: this.count, columns: this.custodyConfig.sampledColumns, }, + envelopesRequest, }; } if (isForkPostDeneb(this.forkName) && withinValidRequestWindow) { @@ -146,10 +153,12 @@ export class Batch { startSlot: this.startSlot, count: this.count, }, + envelopesRequest, }; } return { blocksRequest, + envelopesRequest, }; } @@ -186,6 +195,18 @@ export class Batch { } } + // Track envelope start slot for post-Gloas forks + let envelopeStartSlot = this.startSlot; + if (isForkPostGloas(this.forkName)) { + for (const blockInput of blocks) { + const blockSlot = blockInput.slot; + // Envelopes track separately - advance start slot for contiguous downloaded envelopes + if (envelopeStartSlot === blockSlot) { + envelopeStartSlot = blockSlot + 1; + } + } + } + // if the blockStartSlot or dataStartSlot is after the desired endSlot then no request will be made for the batch // because it is complete const endSlot = this.startSlot + this.count - 1; @@ -216,6 +237,13 @@ export class Batch { // dataSlot will still have a value but do not create a request for preDeneb forks } + if (isForkPostGloas(this.forkName) && envelopeStartSlot <= endSlot) { + requests.envelopesRequest = { + startSlot: envelopeStartSlot, + count: endSlot - envelopeStartSlot + 1, + }; + } + return requests; } @@ -263,6 +291,10 @@ export class Batch { return this.state.blocks; } + getPayloadEnvelopes(): Map | null { + return this.state.payloadEnvelopes; + } + /** * AwaitingDownload -> Downloading */ @@ -271,13 +303,13 @@ export class Batch { throw new BatchError(this.wrongStatusErrorType(BatchStatus.AwaitingDownload)); } - this.state = {status: BatchStatus.Downloading, peer, blocks: this.state.blocks}; + this.state = {status: BatchStatus.Downloading, peer, blocks: this.state.blocks, payloadEnvelopes: this.state.payloadEnvelopes}; } /** * Downloading -> AwaitingProcessing */ - downloadingSuccess(peer: PeerIdStr, blocks: IBlockInput[]): DownloadSuccessState { + downloadingSuccess(peer: PeerIdStr, blocks: IBlockInput[], payloadEnvelopes: Map | null): DownloadSuccessState { if (this.state.status !== BatchStatus.Downloading) { throw new BatchError(this.wrongStatusErrorType(BatchStatus.Downloading)); } @@ -305,11 +337,13 @@ export class Batch { status: this.state.status, }); } + const newPayloadEnvelopes = payloadEnvelopes ?? this.state.payloadEnvelopes; + if (allComplete) { - this.state = {status: BatchStatus.AwaitingProcessing, blocks}; + this.state = {status: BatchStatus.AwaitingProcessing, blocks, payloadEnvelopes: newPayloadEnvelopes}; } else { this.requests = this.getRequests(blocks); - this.state = {status: BatchStatus.AwaitingDownload, blocks}; + this.state = {status: BatchStatus.AwaitingDownload, blocks, payloadEnvelopes: newPayloadEnvelopes}; } return this.state as DownloadSuccessState; @@ -328,25 +362,26 @@ export class Batch { throw new BatchError(this.errorType({code: BatchErrorCode.MAX_DOWNLOAD_ATTEMPTS})); } - this.state = {status: BatchStatus.AwaitingDownload, blocks: this.state.blocks}; + this.state = {status: BatchStatus.AwaitingDownload, blocks: this.state.blocks, payloadEnvelopes: this.state.payloadEnvelopes}; } /** * AwaitingProcessing -> Processing */ - startProcessing(): IBlockInput[] { + startProcessing(): {blocks: IBlockInput[]; payloadEnvelopes: Map | null} { if (this.state.status !== BatchStatus.AwaitingProcessing) { throw new BatchError(this.wrongStatusErrorType(BatchStatus.AwaitingProcessing)); } const blocks = this.state.blocks; + const payloadEnvelopes = this.state.payloadEnvelopes; const hash = hashBlocks(blocks, this.config); // tracks blocks to report peer on processing error // Reset goodPeers in case another download attempt needs to be made. When Attempt is successful or not the peers // that the data came from will be handled by the Attempt that goes for processing const peers = this.goodPeers; this.goodPeers = []; - this.state = {status: BatchStatus.Processing, blocks, attempt: {peers, hash}}; - return blocks; + this.state = {status: BatchStatus.Processing, blocks, payloadEnvelopes, attempt: {peers, hash}}; + return {blocks, payloadEnvelopes}; } /** @@ -357,7 +392,7 @@ export class Batch { throw new BatchError(this.wrongStatusErrorType(BatchStatus.Processing)); } - this.state = {status: BatchStatus.AwaitingValidation, blocks: this.state.blocks, attempt: this.state.attempt}; + this.state = {status: BatchStatus.AwaitingValidation, blocks: this.state.blocks, payloadEnvelopes: this.state.payloadEnvelopes, attempt: this.state.attempt}; } /** @@ -408,7 +443,7 @@ export class Batch { // remove any downloaded blocks and re-attempt // TODO(fulu): need to remove the bad blocks from the SeenBlockInputCache - this.state = {status: BatchStatus.AwaitingDownload, blocks: []}; + this.state = {status: BatchStatus.AwaitingDownload, blocks: [], payloadEnvelopes: null}; } private onProcessingError(attempt: Attempt): void { @@ -419,7 +454,7 @@ export class Batch { // remove any downloaded blocks and re-attempt // TODO(fulu): need to remove the bad blocks from the SeenBlockInputCache - this.state = {status: BatchStatus.AwaitingDownload, blocks: []}; + this.state = {status: BatchStatus.AwaitingDownload, blocks: [], payloadEnvelopes: null}; } /** Helper to construct typed BatchError. Stack traces are correct as the error is thrown above */ diff --git a/packages/beacon-node/src/sync/range/chain.ts b/packages/beacon-node/src/sync/range/chain.ts index 911ce93b5bb0..dab8e6251d91 100644 --- a/packages/beacon-node/src/sync/range/chain.ts +++ b/packages/beacon-node/src/sync/range/chain.ts @@ -1,5 +1,5 @@ import {ChainForkConfig} from "@lodestar/config"; -import {Epoch, Root, Slot} from "@lodestar/types"; +import {Epoch, Root, Slot, gloas} from "@lodestar/types"; import {ErrorAborted, LodestarError, Logger, toRootHex} from "@lodestar/utils"; import {isBlockInputBlobs, isBlockInputColumns} from "../../chain/blocks/blockInput/blockInput.js"; import {BlockInputErrorCode} from "../../chain/blocks/blockInput/errors.js"; @@ -44,13 +44,19 @@ export type SyncChainFns = { * Must return if ALL blocks are processed successfully * If SOME blocks are processed must throw BlockProcessorError() */ - processChainSegment: (blocks: IBlockInput[], syncType: RangeSyncType) => Promise; + processChainSegment: ( + blocks: IBlockInput[], + payloadEnvelopes: Map | null, + syncType: RangeSyncType + ) => Promise; /** Must download blocks, and validate their range */ downloadByRange: ( peer: PeerSyncMeta, batch: Batch, syncType: RangeSyncType - ) => Promise>; + ) => Promise< + WarnResult<{blocks: IBlockInput[]; payloadEnvelopes: Map | null}, DownloadByRangeError> + >; /** Report peer for negative actions. Decouples from the full network instance */ reportPeer: (peer: PeerIdStr, action: PeerAction, actionName: string) => void; /** Gets current peer custodyColumns and earliestAvailableSlot */ @@ -516,7 +522,8 @@ export class SyncChain { }); this.metrics?.syncRange.downloadByRange.success.inc(); const {warnings, result} = res.result; - const downloadSuccessOutput = batch.downloadingSuccess(peer.peerId, result); + const {blocks: downloadedBlocks, payloadEnvelopes} = result; + const downloadSuccessOutput = batch.downloadingSuccess(peer.peerId, downloadedBlocks, payloadEnvelopes); const logMeta: Record = { blockCount: downloadSuccessOutput.blocks.length, }; @@ -578,10 +585,10 @@ export class SyncChain { * Sends `batch` to the processor. Note: batch may be empty */ private async processBatch(batch: Batch): Promise { - const blocks = batch.startProcessing(); + const {blocks, payloadEnvelopes} = batch.startProcessing(); // wrapError ensures to never call both batch success() and batch error() - const res = await wrapError(this.processChainSegment(blocks, this.syncType)); + const res = await wrapError(this.processChainSegment(blocks, payloadEnvelopes, this.syncType)); if (!res.err) { batch.processingSuccess(); diff --git a/packages/beacon-node/src/sync/range/range.ts b/packages/beacon-node/src/sync/range/range.ts index 64c284c65936..8c1fc88f90ce 100644 --- a/packages/beacon-node/src/sync/range/range.ts +++ b/packages/beacon-node/src/sync/range/range.ts @@ -172,7 +172,11 @@ export class RangeSync extends (EventEmitter as {new (): RangeSyncEmitter}) { } /** Convenience method for `SyncChain` */ - private processChainSegment: SyncChainFns["processChainSegment"] = async (blocks, syncType) => { + private processChainSegment: SyncChainFns["processChainSegment"] = async ( + blocks, + payloadEnvelopes, + syncType + ) => { // Not trusted, verify signatures const flags: ImportBlockOpts = { // Only skip importing attestations for finalized sync. For head sync attestation are valuable. @@ -194,7 +198,7 @@ export class RangeSync extends (EventEmitter as {new (): RangeSyncEmitter}) { // Should only be used for debugging or testing for (const block of blocks) await this.chain.processBlock(block, flags); } else { - await this.chain.processChainSegment(blocks, flags); + await this.chain.processChainSegment(blocks, payloadEnvelopes, flags); } }; @@ -209,13 +213,14 @@ export class RangeSync extends (EventEmitter as {new (): RangeSyncEmitter}) { peerDasMetrics: this.chain.metrics?.peerDas, ...batch.getRequestsForPeer(peer), }); - const cached = cacheByRangeResponses({ + const {responses, payloadEnvelopes} = result; + const blocks = cacheByRangeResponses({ cache: this.chain.seenBlockInputCache, peerIdStr: peer.peerId, - responses: result, + responses, batchBlocks, }); - return {result: cached, warnings}; + return {result: {blocks, payloadEnvelopes}, warnings}; }; private pruneBlockInputs: SyncChainFns["pruneBlockInputs"] = (blocks: IBlockInput[]) => { diff --git a/packages/beacon-node/src/sync/utils/downloadByRange.ts b/packages/beacon-node/src/sync/utils/downloadByRange.ts index 8d554db255bd..8907f33ec88c 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, @@ -22,12 +22,14 @@ export type DownloadByRangeRequests = { blocksRequest?: phase0.BeaconBlocksByRangeRequest; blobsRequest?: deneb.BlobSidecarsByRangeRequest; columnsRequest?: fulu.DataColumnSidecarsByRangeRequest; + envelopesRequest?: gloas.ExecutionPayloadEnvelopesByRangeRequest; }; export type DownloadByRangeResponses = { blocks?: SignedBeaconBlock[]; blobSidecars?: deneb.BlobSidecars; columnSidecars?: fulu.DataColumnSidecar[]; + payloadEnvelopes?: gloas.SignedExecutionPayloadEnvelope[]; }; export type DownloadAndCacheByRangeProps = DownloadByRangeRequests & { @@ -198,8 +200,14 @@ export async function downloadByRange({ blocksRequest, blobsRequest, columnsRequest, + envelopesRequest, peerDasMetrics, -}: DownloadAndCacheByRangeProps): Promise> { +}: DownloadAndCacheByRangeProps): Promise< + WarnResult< + {responses: ValidatedResponses; payloadEnvelopes: Map | null}, + DownloadByRangeError + > +> { let response: DownloadByRangeResponses; try { response = await requestByRange({ @@ -208,6 +216,7 @@ export async function downloadByRange({ blocksRequest, blobsRequest, columnsRequest, + envelopesRequest, }); } catch (err) { throw new DownloadByRangeError({ @@ -217,17 +226,16 @@ export async function downloadByRange({ }); } - const validated = await validateResponses({ + return validateResponses({ config, batchBlocks, blocksRequest, blobsRequest, columnsRequest, + envelopesRequest, peerDasMetrics, ...response, }); - - return validated; } /** @@ -239,6 +247,7 @@ export async function requestByRange({ blocksRequest, blobsRequest, columnsRequest, + envelopesRequest, }: DownloadByRangeRequests & { network: INetwork; peerIdStr: PeerIdStr; @@ -246,6 +255,7 @@ export async function requestByRange({ let blocks: undefined | SignedBeaconBlock[]; let blobSidecars: undefined | deneb.BlobSidecars; let columnSidecars: undefined | fulu.DataColumnSidecar[]; + let payloadEnvelopes: undefined | gloas.SignedExecutionPayloadEnvelope[]; const requests: Promise[] = []; @@ -273,12 +283,21 @@ export async function requestByRange({ ); } + if (envelopesRequest) { + requests.push( + network.sendExecutionPayloadEnvelopesByRange(peerIdStr, envelopesRequest).then((envelopeResponse) => { + payloadEnvelopes = envelopeResponse; + }) + ); + } + await Promise.all(requests); return { blocks, blobSidecars, columnSidecars, + payloadEnvelopes, }; } @@ -291,16 +310,23 @@ export async function validateResponses({ blocksRequest, blobsRequest, columnsRequest, + envelopesRequest, blocks, blobSidecars, columnSidecars, + payloadEnvelopes, peerDasMetrics, }: DownloadByRangeRequests & DownloadByRangeResponses & { config: ChainForkConfig; batchBlocks?: IBlockInput[]; peerDasMetrics?: BeaconMetrics["peerDas"] | null; - }): Promise> { + }): Promise< + WarnResult< + {responses: ValidatedResponses; payloadEnvelopes: Map | null}, + DownloadByRangeError + > +> { // Blocks are always required for blob/column validation // If a blocksRequest is provided, blocks have just been downloaded // If no blocksRequest is provided, batchBlocks must have been provided from cache @@ -326,8 +352,21 @@ export async function validateResponses({ } const dataRequest = blobsRequest ?? columnsRequest; + if (!dataRequest && !envelopesRequest) { + return {result: {responses: validatedResponses, payloadEnvelopes: null}, warnings}; + } + if (!dataRequest) { - return {result: validatedResponses, warnings}; + // Only envelope validation needed + let validatedPayloadEnvelopes: Map | null = null; + if (envelopesRequest) { + validatedPayloadEnvelopes = validateEnvelopesByRangeResponse( + validatedResponses.validatedBlocks ?? [], + batchBlocks, + payloadEnvelopes ?? [] + ); + } + return {result: {responses: validatedResponses, payloadEnvelopes: validatedPayloadEnvelopes}, warnings}; } const blocksForDataValidation = getBlocksForDataValidation( @@ -385,7 +424,17 @@ export async function validateResponses({ warnings = validatedColumnSidecarsResult.warnings; } - return {result: validatedResponses, warnings}; + // Validate envelopes if an envelopes request was made + let validatedPayloadEnvelopes: Map | null = null; + if (envelopesRequest) { + validatedPayloadEnvelopes = validateEnvelopesByRangeResponse( + validatedResponses.validatedBlocks ?? [], + batchBlocks, + payloadEnvelopes ?? [] + ); + } + + return {result: {responses: validatedResponses, payloadEnvelopes: validatedPayloadEnvelopes}, warnings}; } /** @@ -882,6 +931,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 beaconBlockRoot does not match the block's root */ + INVALID_ENVELOPE_BEACON_BLOCK_ROOT = "DOWNLOAD_BY_RANGE_ERROR_INVALID_ENVELOPE_BEACON_BLOCK_ROOT", } export type DownloadByRangeErrorType = @@ -973,6 +1025,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.slot; + 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/test/perf/chain/verifyImportBlocks.test.ts b/packages/beacon-node/test/perf/chain/verifyImportBlocks.test.ts index 0793b05f069b..fa6c9c291a94 100644 --- a/packages/beacon-node/test/perf/chain/verifyImportBlocks.test.ts +++ b/packages/beacon-node/test/perf/chain/verifyImportBlocks.test.ts @@ -126,7 +126,7 @@ describe.skip("verify+import blocks - range sync perf test", () => { }); }); - await chain.processChainSegment(blocksImport, { + await chain.processChainSegment(blocksImport, null, { // Only skip importing attestations for finalized sync. For head sync attestation are valuable. // Importing attestations also triggers a head update, see https://github.com/ChainSafe/lodestar/issues/3804 // TODO: Review if this is okay, can we prevent some attacks by importing attestations? diff --git a/packages/beacon-node/test/unit/sync/range/batch.test.ts b/packages/beacon-node/test/unit/sync/range/batch.test.ts index 089f0b68bec0..86af12348b01 100644 --- a/packages/beacon-node/test/unit/sync/range/batch.test.ts +++ b/packages/beacon-node/test/unit/sync/range/batch.test.ts @@ -285,16 +285,20 @@ describe("sync / range / batch", async () => { // retry download: AwaitingDownload -> Downloading // downloadingSuccess: Downloading -> AwaitingProcessing batch.startDownloading(peer); - batch.downloadingSuccess(peer, [ - BlockInputPreData.createFromBlock({ - block: ssz.capella.SignedBeaconBlock.defaultValue(), - blockRootHex: "0x1234", - source: BlockInputSource.byRoot, - seenTimestampSec: Date.now() / 1000, - forkName: ForkName.capella, - daOutOfRange: false, - }), - ]); + batch.downloadingSuccess( + peer, + [ + BlockInputPreData.createFromBlock({ + block: ssz.capella.SignedBeaconBlock.defaultValue(), + blockRootHex: "0x1234", + source: BlockInputSource.byRoot, + seenTimestampSec: Date.now() / 1000, + forkName: ForkName.capella, + daOutOfRange: false, + }), + ], + null + ); expect(batch.state.status).toBe(BatchStatus.AwaitingProcessing); // startProcessing: AwaitingProcessing -> Processing @@ -334,7 +338,7 @@ describe("sync / range / batch", async () => { const batch = new Batch(startEpoch, config, clock, custodyConfig); expectThrowsLodestarError( - () => batch.downloadingSuccess(peer, []), + () => batch.downloadingSuccess(peer, [], null), new BatchError({ code: BatchErrorCode.WRONG_STATUS, startEpoch, diff --git a/packages/beacon-node/test/unit/sync/range/chain.test.ts b/packages/beacon-node/test/unit/sync/range/chain.test.ts index 246db737b897..394ac13e1f88 100644 --- a/packages/beacon-node/test/unit/sync/range/chain.test.ts +++ b/packages/beacon-node/test/unit/sync/range/chain.test.ts @@ -117,7 +117,7 @@ describe("sync / range / chain", () => { }) ); } - return {result: blocks, warnings: null}; + return {result: {blocks, payloadEnvelopes: null}, warnings: null}; }; const target: ChainTarget = {slot: computeStartSlotAtEpoch(targetEpoch), root: ZERO_HASH}; @@ -172,7 +172,7 @@ describe("sync / range / chain", () => { }) ); } - return {result: blocks, warnings: null}; + return {result: {blocks, payloadEnvelopes: null}, warnings: null}; }; const target: ChainTarget = {slot: computeStartSlotAtEpoch(targetEpoch), root: ZERO_HASH}; @@ -218,9 +218,9 @@ describe("sync / range / chain", () => { function logSyncChainFns(logger: Logger, fns: SyncChainFns): SyncChainFns { return { - processChainSegment(blocks, syncType) { + processChainSegment(blocks, payloadEnvelopes, syncType) { logger.debug("mock processChainSegment", {blocks: blocks.map((b) => b.slot).join(",")}); - return fns.processChainSegment(blocks, syncType); + return fns.processChainSegment(blocks, payloadEnvelopes, syncType); }, downloadByRange(peer, request, syncType) { logger.debug("mock downloadBeaconBlocksByRange", request.state.status); diff --git a/packages/beacon-node/test/unit/sync/range/utils/batches.test.ts b/packages/beacon-node/test/unit/sync/range/utils/batches.test.ts index a33b255b1fa3..d3f5b849d8f6 100644 --- a/packages/beacon-node/test/unit/sync/range/utils/batches.test.ts +++ b/packages/beacon-node/test/unit/sync/range/utils/batches.test.ts @@ -229,7 +229,7 @@ describe("sync / range / batches", () => { batch.startDownloading(peer); if (status === BatchStatus.Downloading) return batch; - batch.downloadingSuccess(peer, []); + batch.downloadingSuccess(peer, [], null); if (status === BatchStatus.AwaitingProcessing) return batch; batch.startProcessing(); diff --git a/packages/beacon-node/test/unit/sync/range/utils/peerBalancer.test.ts b/packages/beacon-node/test/unit/sync/range/utils/peerBalancer.test.ts index bcac6004b288..74bf280aebb0 100644 --- a/packages/beacon-node/test/unit/sync/range/utils/peerBalancer.test.ts +++ b/packages/beacon-node/test/unit/sync/range/utils/peerBalancer.test.ts @@ -186,7 +186,7 @@ describe("sync / range / peerBalancer", () => { sampledColumns: [0, 1, 2, 3], }); console.log(blockInput.hasAllData()); - const x = batch0.downloadingSuccess(peer1.peerId, [blockInput]); + const x = batch0.downloadingSuccess(peer1.peerId, [blockInput], null); console.log("x", x); // peer2 and peer3 are the same but peer3 has a lower target slot than the previous download diff --git a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts index b78e90d39071..ffd37ded65ac 100644 --- a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts +++ b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts @@ -8,6 +8,7 @@ import {verifySignatureSet} from "../util/signatureSets.js"; export type ProcessExecutionPayloadEnvelopeOpts = { verifySignature?: boolean; + verifyExecutionRequestsRoot?: boolean; }; /** @@ -23,19 +24,20 @@ export function processExecutionPayloadEnvelope( signedEnvelope: gloas.SignedExecutionPayloadEnvelope, opts?: ProcessExecutionPayloadEnvelopeOpts ): void { - const {verifySignature = true} = opts ?? {}; + const {verifySignature = true, verifyExecutionRequestsRoot = 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); + validateExecutionPayloadEnvelope(state, envelope, verifyExecutionRequestsRoot); } function validateExecutionPayloadEnvelope( state: CachedBeaconStateGloas, - envelope: gloas.ExecutionPayloadEnvelope + envelope: gloas.ExecutionPayloadEnvelope, + verifyExecutionRequestsRoot: boolean ): void { const payload = envelope.payload; @@ -100,11 +102,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)}` - ); + // Can be skipped if already verified during gossip validation + if (verifyExecutionRequestsRoot) { + 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 From 5e81b3d6437853be5331e81c368508a8da141454 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 14 Apr 2026 23:19:20 -0700 Subject: [PATCH 10/24] feat: unknown payload sync for incoming blocks (supersedes #9102) When a gossip block claims its parent was FULL but the parent's envelope hasn't been seen, the block is queued and the parent envelope is fetched via sendExecutionPayloadEnvelopesByRoot. Changes: - Add PARENT_PAYLOAD_UNKNOWN block error code and gossip validation - Add PendingPayloadEnvelope type for tracking missing envelopes - BlockInputSync: pendingPayloads map, triggerPayloadSearch, fetchPayloadEnvelope, onUnknownPayloadEnvelope handler - Subscribe to unknownEnvelopeBlockRoot and executionPayloadAvailable events - Add metrics for payload fetch tracking - Handle PARENT_PAYLOAD_UNKNOWN in processBlock error recovery Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/chain/errors/blockError.ts | 2 +- .../beacon-node/src/chain/validation/block.ts | 1 + .../src/metrics/metrics/lodestar.ts | 17 +++ .../src/network/processor/gossipHandlers.ts | 17 +++ packages/beacon-node/src/sync/types.ts | 10 ++ packages/beacon-node/src/sync/unknownBlock.ts | 135 +++++++++++++++++- 6 files changed, 180 insertions(+), 2 deletions(-) diff --git a/packages/beacon-node/src/chain/errors/blockError.ts b/packages/beacon-node/src/chain/errors/blockError.ts index 9d8e07c02a56..dccf9926d546 100644 --- a/packages/beacon-node/src/chain/errors/blockError.ts +++ b/packages/beacon-node/src/chain/errors/blockError.ts @@ -120,7 +120,7 @@ export type BlockErrorType = | {code: BlockErrorCode.TOO_MANY_KZG_COMMITMENTS; blobKzgCommitmentsLen: number; commitmentLimit: number} | {code: BlockErrorCode.BID_PARENT_ROOT_MISMATCH; bidParentRoot: RootHex; blockParentRoot: RootHex} | {code: BlockErrorCode.PARENT_EXECUTION_INVALID; parentRoot: RootHex} - | {code: BlockErrorCode.PARENT_PAYLOAD_UNKNOWN; parentBlockHash: RootHex}; + | {code: BlockErrorCode.PARENT_PAYLOAD_UNKNOWN; parentRoot: RootHex; parentBlockHash: RootHex}; export class BlockGossipError extends GossipActionError {} diff --git a/packages/beacon-node/src/chain/validation/block.ts b/packages/beacon-node/src/chain/validation/block.ts index 33b9daef36ad..1c79e2717e21 100644 --- a/packages/beacon-node/src/chain/validation/block.ts +++ b/packages/beacon-node/src/chain/validation/block.ts @@ -103,6 +103,7 @@ export async function validateGossipBlock( if (chain.forkChoice.getBlockHexAndBlockHash(parentRoot, parentBlockHashHex) === null) { throw new BlockGossipError(GossipAction.IGNORE, { code: BlockErrorCode.PARENT_PAYLOAD_UNKNOWN, + parentRoot, parentBlockHash: parentBlockHashHex, }); } diff --git a/packages/beacon-node/src/metrics/metrics/lodestar.ts b/packages/beacon-node/src/metrics/metrics/lodestar.ts index 358054bdd8e9..bb62ed95d535 100644 --- a/packages/beacon-node/src/metrics/metrics/lodestar.ts +++ b/packages/beacon-node/src/metrics/metrics/lodestar.ts @@ -680,6 +680,23 @@ export function createLodestarMetrics( labelNames: ["code", "client"], }), }, + pendingPayloads: register.gauge({ + name: "lodestar_sync_unknown_block_pending_payloads_size", + help: "Current size of BlockInputSync pending payloads cache", + }), + payloadRequests: register.gauge<{source: BlockInputSource}>({ + name: "lodestar_sync_unknown_block_payload_requests_total", + help: "Total number of payload envelope fetch requests triggered", + labelNames: ["source"], + }), + payloadFetchSuccess: register.gauge({ + name: "lodestar_sync_unknown_block_payload_fetch_success_total", + help: "Total number of successful payload envelope fetches", + }), + payloadFetchError: register.gauge({ + name: "lodestar_sync_unknown_block_payload_fetch_error_total", + help: "Total number of errored payload envelope fetches", + }), peerBalancer: { peersMetaCount: register.gauge({ name: "lodestar_sync_unknown_block_peer_balancer_peers_meta_count", diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index bf01a9e6e0a7..6a24b4f0090d 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -198,6 +198,23 @@ 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_PAYLOAD_UNKNOWN && blockInput) { + logger.debug("Gossip block has parent payload unknown", {slot, root: blockShortHex, code: e.type.code}); + // Track the child block for processing after parent envelope arrives + chain.emitter.emit(ChainEvent.blockUnknownParent, { + blockInput, + peer: peerIdStr, + source: BlockInputSource.gossip, + }); + // Trigger parent envelope fetch + chain.emitter.emit(ChainEvent.unknownEnvelopeBlockRoot, { + rootHex: e.type.parentRoot, + peer: peerIdStr, + source: BlockInputSource.gossip, + }); + throw e; + } + if (e.type.code === BlockErrorCode.PARENT_UNKNOWN && blockInput) { chain.emitter.emit(ChainEvent.blockUnknownParent, { blockInput, diff --git a/packages/beacon-node/src/sync/types.ts b/packages/beacon-node/src/sync/types.ts index ca36095c5ca8..4330ec8e7c24 100644 --- a/packages/beacon-node/src/sync/types.ts +++ b/packages/beacon-node/src/sync/types.ts @@ -1,5 +1,6 @@ import {RootHex, Slot} from "@lodestar/types"; import {IBlockInput} from "../chain/blocks/blockInput/index.js"; +import {PeerIdStr} from "../util/peerId.js"; export enum PendingBlockType { /** @@ -55,3 +56,12 @@ export function getBlockInputSyncCacheItemRootHex(block: BlockInputSyncCacheItem export function getBlockInputSyncCacheItemSlot(block: BlockInputSyncCacheItem): Slot | string { return isPendingBlockInput(block) ? block.blockInput.slot : "unknown"; } + +export type PendingPayloadEnvelope = { + status: "pending" | "fetching"; + blockRootHex: RootHex; + slot: Slot; + attempts: number; + peerIdStrings: Set; + timeAddedSec: number; +}; diff --git a/packages/beacon-node/src/sync/unknownBlock.ts b/packages/beacon-node/src/sync/unknownBlock.ts index 4875911f28b6..076b1cd7c7ae 100644 --- a/packages/beacon-node/src/sync/unknownBlock.ts +++ b/packages/beacon-node/src/sync/unknownBlock.ts @@ -1,11 +1,13 @@ import {ChainForkConfig} from "@lodestar/config"; +import {PayloadStatus} from "@lodestar/fork-choice"; 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 {Logger, fromHex, prettyPrintIndices, pruneSetToMax, sleep} from "@lodestar/utils"; import {isBlockInputBlobs, isBlockInputColumns} from "../chain/blocks/blockInput/blockInput.js"; import {BlockInputSource, IBlockInput} from "../chain/blocks/blockInput/types.js"; +import {PayloadEnvelopeInputSource} from "../chain/blocks/payloadEnvelopeInput/index.js"; import {BlockError, BlockErrorCode} from "../chain/errors/index.js"; import {ChainEvent, ChainEventData, IBeaconChain} from "../chain/index.js"; import {Metrics} from "../metrics/index.js"; @@ -22,6 +24,7 @@ import { PendingBlockInput, PendingBlockInputStatus, PendingBlockType, + PendingPayloadEnvelope, getBlockInputSyncCacheItemRootHex, getBlockInputSyncCacheItemSlot, isPendingBlockInput, @@ -32,6 +35,8 @@ import {getAllDescendantBlocks, getDescendantBlocks, getUnknownAndAncestorBlocks const MAX_ATTEMPTS_PER_BLOCK = 5; const MAX_KNOWN_BAD_BLOCKS = 500; const MAX_PENDING_BLOCKS = 100; +const MAX_PENDING_PAYLOADS = 100; +const MAX_ATTEMPTS_PER_PAYLOAD = 5; enum FetchResult { SuccessResolved = "success_resolved", @@ -78,6 +83,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; @@ -101,6 +107,9 @@ export class BlockInputSync { metrics.blockInputSync.knownBadBlocks.addCollect(() => metrics.blockInputSync.knownBadBlocks.set(this.knownBadBlocks.size) ); + metrics.blockInputSync.pendingPayloads?.addCollect(() => + metrics.blockInputSync.pendingPayloads?.set(this.pendingPayloads.size) + ); } } @@ -116,6 +125,7 @@ export class BlockInputSync { this.chain.emitter.on(ChainEvent.unknownBlockRoot, this.onUnknownBlockRoot); this.chain.emitter.on(ChainEvent.incompleteBlockInput, this.onIncompleteBlockInput); this.chain.emitter.on(ChainEvent.blockUnknownParent, this.onUnknownParent); + this.chain.emitter.on(ChainEvent.unknownEnvelopeBlockRoot, this.onUnknownPayloadEnvelope); this.network.events.on(NetworkEvent.peerConnected, this.onPeerConnected); this.network.events.on(NetworkEvent.peerDisconnected, this.onPeerDisconnected); this.subscribedToNetworkEvents = true; @@ -127,6 +137,7 @@ export class BlockInputSync { this.chain.emitter.off(ChainEvent.unknownBlockRoot, this.onUnknownBlockRoot); this.chain.emitter.off(ChainEvent.incompleteBlockInput, this.onIncompleteBlockInput); this.chain.emitter.off(ChainEvent.blockUnknownParent, this.onUnknownParent); + this.chain.emitter.off(ChainEvent.unknownEnvelopeBlockRoot, this.onUnknownPayloadEnvelope); this.network.events.off(NetworkEvent.peerConnected, this.onPeerConnected); this.network.events.off(NetworkEvent.peerDisconnected, this.onPeerDisconnected); this.subscribedToNetworkEvents = false; @@ -183,6 +194,21 @@ export class BlockInputSync { } }; + /** + * Process an unknownEnvelopeBlockRoot event - fetch missing payload envelope for a known block. + */ + private onUnknownPayloadEnvelope = (data: ChainEventData[ChainEvent.unknownEnvelopeBlockRoot]): void => { + try { + const {rootHex: blockRootHex, peer} = data; + const block = this.chain.forkChoice.getBlockHexDefaultStatus(blockRootHex); + if (!block) return; + this.addPendingPayload(blockRootHex, block.slot, peer); + this.metrics?.blockInputSync.payloadRequests?.inc({source: data.source}); + } catch (e) { + this.logger.debug("Error handling unknownPayloadEnvelope event", {}, e as Error); + } + }; + private addByRootHex = (rootHex: RootHex, peerIdStr?: PeerIdStr): void => { let pendingBlock = this.pendingBlocks.get(rootHex); if (!pendingBlock) { @@ -248,6 +274,7 @@ export class BlockInputSync { const peerSyncMeta = this.network.getConnectedPeerSyncMeta(peerId); this.peerBalancer.onPeerConnected(data.peer, peerSyncMeta); this.triggerUnknownBlockSearch(); + this.triggerPayloadSearch(); } catch (e) { this.logger.debug("Error handling peerConnected event", {}, e as Error); } @@ -258,6 +285,105 @@ export class BlockInputSync { this.peerBalancer.onPeerDisconnected(peerId); }; + addPendingPayload(rootHex: RootHex, slot: number, peer?: PeerIdStr): void { + const payloadInput = this.chain.seenPayloadEnvelopeInputCache.get(rootHex); + if (payloadInput?.hasPayloadEnvelope()) return; + if (this.chain.forkChoice.getBlockHex(rootHex, PayloadStatus.FULL)) return; + if (this.pendingPayloads.size >= MAX_PENDING_PAYLOADS) return; + + let pending = this.pendingPayloads.get(rootHex); + if (!pending) { + pending = { + status: "pending", + blockRootHex: rootHex, + slot, + attempts: 0, + peerIdStrings: new Set(), + timeAddedSec: Date.now() / 1000, + }; + this.pendingPayloads.set(rootHex, pending); + } + if (peer) pending.peerIdStrings.add(peer); + this.triggerPayloadSearch(); + } + + private triggerPayloadSearch = (): void => { + if (this.pendingPayloads.size === 0) return; + if (this.network.getConnectedPeers().length === 0) return; + + const finalizedSlot = this.chain.forkChoice.getFinalizedBlock().slot; + + for (const [rootHex, pending] of this.pendingPayloads) { + if (pending.slot <= finalizedSlot) { + this.pendingPayloads.delete(rootHex); + continue; + } + if ( + this.chain.seenPayloadEnvelopeInputCache.get(rootHex)?.hasPayloadEnvelope() || + this.chain.forkChoice.getBlockHex(rootHex, PayloadStatus.FULL) + ) { + this.pendingPayloads.delete(rootHex); + continue; + } + if (!this.chain.forkChoice.hasBlockHexUnsafe(rootHex)) continue; + if (pending.status !== "pending") continue; + if (pending.attempts >= MAX_ATTEMPTS_PER_PAYLOAD) { + this.pendingPayloads.delete(rootHex); + continue; + } + this.fetchPayloadEnvelope(pending).catch((e) => { + this.logger.debug("Unexpected error - fetchPayloadEnvelope", {root: pending.blockRootHex}, e); + }); + } + }; + + private async fetchPayloadEnvelope(pending: PendingPayloadEnvelope): Promise { + pending.status = "fetching"; + pending.attempts++; + try { + const peerMeta = this.peerBalancer.bestPeerForPendingColumns(new Set(), new Set()); + if (!peerMeta) { + pending.status = "pending"; + return; + } + const {peerId: peer} = peerMeta; + + const envelopes = await this.network.sendExecutionPayloadEnvelopesByRoot(peer, [fromHex(pending.blockRootHex)]); + if (envelopes.length === 0) { + pending.status = "pending"; + return; + } + + const envelope = envelopes[0]; + const payloadInput = this.chain.seenPayloadEnvelopeInputCache.get(pending.blockRootHex); + if (!payloadInput) { + this.logger.debug("PayloadEnvelopeInput missing for fetched envelope", {root: pending.blockRootHex}); + pending.status = "pending"; + return; + } + + payloadInput.addPayloadEnvelope({ + envelope, + source: PayloadEnvelopeInputSource.byRoot, + seenTimestampSec: Date.now() / 1000, + peerIdStr: peer, + }); + + if (payloadInput.isComplete()) { + await this.chain.processExecutionPayload(payloadInput); + } + + this.pendingPayloads.delete(pending.blockRootHex); + this.metrics?.blockInputSync.payloadFetchSuccess?.inc(); + // Re-trigger block search since pending blocks may now be processable + this.triggerUnknownBlockSearch(); + } catch (e) { + this.logger.debug("Error fetching payload envelope", {root: pending.blockRootHex}, e as Error); + pending.status = "pending"; + this.metrics?.blockInputSync.payloadFetchError?.inc(); + } + } + /** * Gather tip parent blocks with unknown parent and do a search for all of them */ @@ -456,6 +582,12 @@ export class BlockInputSync { pendingBlock.status = PendingBlockInputStatus.downloaded; break; + case BlockErrorCode.PARENT_PAYLOAD_UNKNOWN: + this.logger.debug("Block parent payload unknown", errorData, res.err); + this.addPendingPayload(res.err.type.parentRoot, pendingBlock.blockInput.slot - 1); + pendingBlock.status = PendingBlockInputStatus.downloaded; + 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 @@ -671,6 +803,7 @@ 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.logger.debug("Removing bad/unknown/incomplete BlockInputSyncCacheItem", { slot, From 125777cd5894a59426484b67ff2568e056ea567c Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 14 Apr 2026 23:32:54 -0700 Subject: [PATCH 11/24] feat: Gloas DataColumnSidecar validation in range sync Handle both Fulu and Gloas column shapes in validateColumnsByRangeResponse. Fulu columns use signedBlockHeader.message.slot, Gloas columns use slot directly. Dispatch to validateGloasBlockDataColumnSidecars vs validateFuluBlockDataColumnSidecars based on fork. Gloas columns in cacheByRangeResponses are skipped (routed to PayloadEnvelopeInput by the caller, not to IBlockInput). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/sync/utils/downloadByRange.ts | 69 +++++++++++-------- 1 file changed, 42 insertions(+), 27 deletions(-) diff --git a/packages/beacon-node/src/sync/utils/downloadByRange.ts b/packages/beacon-node/src/sync/utils/downloadByRange.ts index 8907f33ec88c..cb88a9e3fd68 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 {ForkPostDeneb, ForkPostFulu, ForkPreFulu, isForkPostFulu, isForkPostGloas} from "@lodestar/params"; +import {DataColumnSidecar, SignedBeaconBlock, Slot, deneb, fulu, gloas, isGloasDataColumnSidecar, phase0} from "@lodestar/types"; import {LodestarError, Logger, byteArrayEquals, fromHex, prettyPrintIndices, toRootHex} from "@lodestar/utils"; import { BlockInputSource, @@ -11,7 +11,7 @@ import { } from "../../chain/blocks/blockInput/index.js"; import {SeenBlockInput} from "../../chain/seenCache/seenGossipBlockInput.js"; import {validateBlockBlobSidecars} from "../../chain/validation/blobSidecar.js"; -import {validateFuluBlockDataColumnSidecars} from "../../chain/validation/dataColumnSidecar.js"; +import {validateFuluBlockDataColumnSidecars, validateGloasBlockDataColumnSidecars} from "../../chain/validation/dataColumnSidecar.js"; import {BeaconMetrics} from "../../metrics/metrics/beacon.js"; import {INetwork} from "../../network/index.js"; import {getBlobKzgCommitments} from "../../util/dataColumns.js"; @@ -28,7 +28,7 @@ export type DownloadByRangeRequests = { export type DownloadByRangeResponses = { blocks?: SignedBeaconBlock[]; blobSidecars?: deneb.BlobSidecars; - columnSidecars?: fulu.DataColumnSidecar[]; + columnSidecars?: DataColumnSidecar[]; payloadEnvelopes?: gloas.SignedExecutionPayloadEnvelope[]; }; @@ -60,7 +60,7 @@ export type ValidatedBlobSidecars = { export type ValidatedColumnSidecars = { blockRoot: Uint8Array; - columnSidecars: fulu.DataColumnSidecar[]; + columnSidecars: DataColumnSidecar[]; }; export type ValidatedResponses = { @@ -152,12 +152,17 @@ export function cacheByRangeResponses({ } for (const {blockRoot, columnSidecars} of responses.validatedColumnSidecars ?? []) { - const dataSlot = columnSidecars.at(0)?.signedBlockHeader.message.slot; - if (dataSlot === undefined) { + const firstColumn = columnSidecars[0]; + if (!firstColumn) { throw new Error( `Coding Error: empty columnSidecars returned for blockRoot=${toRootHex(blockRoot)} from validation functions` ); } + // Gloas columns are added to PayloadEnvelopeInput by the caller, not to IBlockInput + if (isGloasDataColumnSidecar(firstColumn)) continue; + + const fuluColumns = columnSidecars as fulu.DataColumnSidecar[]; + const dataSlot = firstColumn.signedBlockHeader.message.slot; const existing = updatedBatchBlocks.get(dataSlot); const blockRootHex = toRootHex(blockRoot); @@ -174,7 +179,7 @@ export function cacheByRangeResponses({ actual: existing.type, }); } - for (const columnSidecar of columnSidecars) { + for (const columnSidecar of fuluColumns) { // will throw if root hex does not match (meaning we are following the wrong chain) existing.addColumn( { @@ -254,7 +259,7 @@ export async function requestByRange({ }): Promise { let blocks: undefined | SignedBeaconBlock[]; let blobSidecars: undefined | deneb.BlobSidecars; - let columnSidecars: undefined | fulu.DataColumnSidecar[]; + let columnSidecars: undefined | DataColumnSidecar[]; let payloadEnvelopes: undefined | gloas.SignedExecutionPayloadEnvelope[]; const requests: Promise[] = []; @@ -278,7 +283,7 @@ export async function requestByRange({ if (columnsRequest) { requests.push( network.sendDataColumnSidecarsByRange(peerIdStr, columnsRequest).then((columnResponse) => { - columnSidecars = columnResponse as fulu.DataColumnSidecar[]; + columnSidecars = columnResponse; }) ); } @@ -664,19 +669,19 @@ export async function validateColumnsByRangeResponse( config: ChainForkConfig, request: fulu.DataColumnSidecarsByRangeRequest, blocks: ValidatedBlock[], - columnSidecars: fulu.DataColumnSidecar[], + columnSidecars: DataColumnSidecar[], peerDasMetrics?: BeaconMetrics["peerDas"] | null ): Promise> { const warnings: DownloadByRangeError[] = []; - // TODO GLOAS: Extend by range column sync to support gloas.DataColumnSidecar and - // validate against the block bid commitments instead of the fulu signed header shape - const seenColumns = new Map>(); + const seenColumns = new Map>(); let currentSlot = -1; let currentIndex = -1; // Check for duplicates and order for (const columnSidecar of columnSidecars) { - const slot = columnSidecar.signedBlockHeader.message.slot; + const slot = isGloasDataColumnSidecar(columnSidecar) + ? columnSidecar.slot + : columnSidecar.signedBlockHeader.message.slot; let seenSlotColumns = seenColumns.get(slot); if (!seenSlotColumns) { seenSlotColumns = new Map(); @@ -735,20 +740,20 @@ export async function validateColumnsByRangeResponse( const slot = block.message.slot; const rootHex = toRootHex(blockRoot); const forkName = config.getForkName(slot); - const columnSidecarsMap: Map = seenColumns.get(slot) ?? new Map(); + const columnSidecarsMap: Map = seenColumns.get(slot) ?? new Map(); const columnSidecars = Array.from(columnSidecarsMap.values()).sort((a, b) => a.index - b.index); let blobCount: number; if (!isForkPostFulu(forkName)) { - const dataSlot = columnSidecars.at(0)?.signedBlockHeader.message.slot; throw new DownloadByRangeError({ code: DownloadByRangeErrorCode.MISMATCH_BLOCK_FORK, slot, blockFork: forkName, - dataFork: dataSlot ? config.getForkName(dataSlot) : "unknown", + dataFork: "unknown", }); } - blobCount = getBlobKzgCommitments(forkName, block as SignedBeaconBlock).length; + const kzgCommitments = getBlobKzgCommitments(forkName, block as SignedBeaconBlock); + blobCount = kzgCommitments.length; if (columnSidecars.length === 0) { if (!blobCount) { @@ -817,15 +822,25 @@ export async function validateColumnsByRangeResponse( ); } + const validatePromise = isForkPostGloas(forkName) + ? validateGloasBlockDataColumnSidecars( + slot, + blockRoot, + kzgCommitments, + columnSidecars as gloas.DataColumnSidecar[], + peerDasMetrics + ) + : validateFuluBlockDataColumnSidecars( + null, // do not pass chain here so we do not validate header signature + slot, + blockRoot, + blobCount, + columnSidecars as fulu.DataColumnSidecar[], + peerDasMetrics + ); + validationPromises.push( - validateFuluBlockDataColumnSidecars( - null, // do not pass chain here so we do not validate header signature - slot, - blockRoot, - blobCount, - columnSidecars, - peerDasMetrics - ).then(() => ({ + validatePromise.then(() => ({ blockRoot, columnSidecars, })) From c790c8638e03e4740a383fede3274099b069c346 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:32:34 -0700 Subject: [PATCH 12/24] fix: guard payload-seen check on known block and use seenPayloadEnvelope - Only check payload-seen when block is known; unknown blocks are handled later by verifyHeadBlockAndTargetRoot which enables the API retry path for unknown roots - Use chain.seenPayloadEnvelope which checks both the gossip cache (seenPayloadEnvelopeInputCache) and fork choice FULL variant Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/beacon-node/src/chain/validation/aggregateAndProof.ts | 1 + packages/beacon-node/src/chain/validation/attestation.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/beacon-node/src/chain/validation/aggregateAndProof.ts b/packages/beacon-node/src/chain/validation/aggregateAndProof.ts index 25cb272cb8fa..91032d12255a 100644 --- a/packages/beacon-node/src/chain/validation/aggregateAndProof.ts +++ b/packages/beacon-node/src/chain/validation/aggregateAndProof.ts @@ -96,6 +96,7 @@ async function validateAggregateAndProof( // the corresponding execution payload for `block` has been seen (a client MAY queue // attestations for processing once the payload is retrieved and SHOULD request the // payload envelope via `ExecutionPayloadEnvelopesByRoot`). + // Only check when block is known; unknown blocks are handled by `verifyHeadBlockAndTargetRoot` if (block !== null && attData.index === 1 && !chain.seenPayloadEnvelope(toRootHex(attData.beaconBlockRoot))) { throw new AttestationError(GossipAction.IGNORE, { code: AttestationErrorCode.EXECUTION_PAYLOAD_NOT_SEEN, diff --git a/packages/beacon-node/src/chain/validation/attestation.ts b/packages/beacon-node/src/chain/validation/attestation.ts index f3b7b8863f57..1d09a76c547b 100644 --- a/packages/beacon-node/src/chain/validation/attestation.ts +++ b/packages/beacon-node/src/chain/validation/attestation.ts @@ -322,6 +322,7 @@ async function validateAttestationNoSignatureCheck( // the corresponding execution payload for `block` has been seen (a client MAY queue // attestations for processing once the payload is retrieved and SHOULD request the // payload envelope via `ExecutionPayloadEnvelopesByRoot`). + // Only check when block is known; unknown blocks are handled by `verifyHeadBlockAndTargetRoot` if (block !== null && attData.index === 1 && !chain.seenPayloadEnvelope(toRootHex(attData.beaconBlockRoot))) { throw new AttestationError(GossipAction.IGNORE, { code: AttestationErrorCode.EXECUTION_PAYLOAD_NOT_SEEN, From 2be23e55ce473c2b36423037e99b7c98a530e06e Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 13 Apr 2026 18:40:17 -0700 Subject: [PATCH 13/24] Update comments --- packages/beacon-node/src/chain/validation/aggregateAndProof.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/beacon-node/src/chain/validation/aggregateAndProof.ts b/packages/beacon-node/src/chain/validation/aggregateAndProof.ts index 91032d12255a..25cb272cb8fa 100644 --- a/packages/beacon-node/src/chain/validation/aggregateAndProof.ts +++ b/packages/beacon-node/src/chain/validation/aggregateAndProof.ts @@ -96,7 +96,6 @@ async function validateAggregateAndProof( // the corresponding execution payload for `block` has been seen (a client MAY queue // attestations for processing once the payload is retrieved and SHOULD request the // payload envelope via `ExecutionPayloadEnvelopesByRoot`). - // Only check when block is known; unknown blocks are handled by `verifyHeadBlockAndTargetRoot` if (block !== null && attData.index === 1 && !chain.seenPayloadEnvelope(toRootHex(attData.beaconBlockRoot))) { throw new AttestationError(GossipAction.IGNORE, { code: AttestationErrorCode.EXECUTION_PAYLOAD_NOT_SEEN, From 967fdd25cdf371ac6fa00d6c9312631c3decf947 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 13 Apr 2026 18:41:37 -0700 Subject: [PATCH 14/24] Update comments --- packages/beacon-node/src/chain/validation/attestation.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/beacon-node/src/chain/validation/attestation.ts b/packages/beacon-node/src/chain/validation/attestation.ts index 1d09a76c547b..f3b7b8863f57 100644 --- a/packages/beacon-node/src/chain/validation/attestation.ts +++ b/packages/beacon-node/src/chain/validation/attestation.ts @@ -322,7 +322,6 @@ async function validateAttestationNoSignatureCheck( // the corresponding execution payload for `block` has been seen (a client MAY queue // attestations for processing once the payload is retrieved and SHOULD request the // payload envelope via `ExecutionPayloadEnvelopesByRoot`). - // Only check when block is known; unknown blocks are handled by `verifyHeadBlockAndTargetRoot` if (block !== null && attData.index === 1 && !chain.seenPayloadEnvelope(toRootHex(attData.beaconBlockRoot))) { throw new AttestationError(GossipAction.IGNORE, { code: AttestationErrorCode.EXECUTION_PAYLOAD_NOT_SEEN, From f273fd31f4b18717db9fa26ec1d318f0f86975f2 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 15/24] 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 1e4aac4fa951f66785b6531262dbc6ad2bf8ba6b 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 16/24] Address comments & follow up on spec change --- .../chain/blocks/importExecutionPayload.ts | 26 ++-- .../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 | 144 ------------------ .../block/processParentExecutionPayload.ts | 44 +++--- .../src/block/processWithdrawals.ts | 2 +- .../src/stateView/beaconStateView.ts | 19 ++- .../src/stateView/interface.ts | 11 +- 13 files changed, 258 insertions(+), 214 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 220e61253eea..38390dbc260e 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -1,6 +1,7 @@ import {routes} from "@lodestar/api"; import {ExecutionStatus, PayloadExecutionStatus} from "@lodestar/fork-choice"; -import {getExecutionPayloadEnvelopeSignatureSet, isStatePostGloas} from "@lodestar/state-transition"; +import {isStatePostGloas} from "@lodestar/state-transition"; +import {verifyExecutionPayloadEnvelope, verifyExecutionPayloadEnvelopeSignature} from "./verifyExecutionPayloadEnvelope.js"; import {fromHex} from "@lodestar/utils"; import {ExecutionPayloadStatus} from "../../execution/index.js"; import {isQueueErrorAborted} from "../../util/queue/index.js"; @@ -147,24 +148,21 @@ 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 { // When validSignature is true, the envelope came from gossip/API where both // signature and executionRequestsRoot were already verified — skip re-hashing - blockState.verifyExecutionPayloadEnvelope(signedEnvelope, { - verifySignature: false, + verifyExecutionPayloadEnvelope(this.config, blockState, envelope, { verifyExecutionRequestsRoot: !opts.validSignature, }); } catch (e) { 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 434e78c9dbd7..dc5a05b38c25 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -874,15 +874,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 89f2111879a1..16898a0a686e 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -1084,6 +1084,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 ffd37ded65ac..000000000000 --- a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts +++ /dev/null @@ -1,144 +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; - verifyExecutionRequestsRoot?: 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, verifyExecutionRequestsRoot = 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, verifyExecutionRequestsRoot); -} - -function validateExecutionPayloadEnvelope( - state: CachedBeaconStateGloas, - envelope: gloas.ExecutionPayloadEnvelope, - verifyExecutionRequestsRoot: boolean -): 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 (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 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) - // Can be skipped if already verified during gossip validation - if (verifyExecutionRequestsRoot) { - 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 f5d78ba0a30b9b0f3b72d0b5746ae31ae90d6857 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:37:54 -0700 Subject: [PATCH 17/24] Swap latestBlockHash and latestExecutionPayloadBid --- packages/types/src/gloas/sszTypes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/types/src/gloas/sszTypes.ts b/packages/types/src/gloas/sszTypes.ts index 329a359177a3..5572a1170a75 100644 --- a/packages/types/src/gloas/sszTypes.ts +++ b/packages/types/src/gloas/sszTypes.ts @@ -268,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, // New in GLOAS:EIP7732 // Withdrawals nextWithdrawalIndex: capellaSsz.BeaconState.fields.nextWithdrawalIndex, nextWithdrawalValidatorIndex: capellaSsz.BeaconState.fields.nextWithdrawalValidatorIndex, @@ -289,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 }, From 5ac939e512b9b6ec12b5286d76d3cddb5fbcc9e5 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 18/24] 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 9c60ed915694f4c7b47d4af8dac816d93bf4b38c 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 19/24] 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 e0da2d83ffda49ec1e1a306786cf71818c81002f 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 20/24] 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 | 1 - .../chain/blocks/importExecutionPayload.ts | 14 +++++----- .../blocks/verifyExecutionPayloadEnvelope.ts | 4 +-- .../validation/executionPayloadEnvelope.ts | 16 +++++------ .../executionPayloadEnvelopeArchive.ts | 2 +- .../beacon-node/src/execution/engine/mock.ts | 6 ++++- .../beacon-node/src/execution/engine/types.ts | 4 +-- packages/beacon-node/src/network/network.ts | 2 +- .../src/network/processor/gossipHandlers.ts | 5 ++-- .../src/sync/utils/downloadByRange.ts | 2 +- 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 ++--- 19 files changed, 65 insertions(+), 42 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 645b3c64387c..4ecc158f1120 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 9d4cd0d3d53d..52a124759272 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -1648,7 +1648,6 @@ export function getValidatorApi( executionRequests: executionRequests, builderIndex: BUILDER_INDEX_SELF_BUILD, beaconBlockRoot, - slot, }; 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 38390dbc260e..851533c06596 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -94,12 +94,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 +211,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 +228,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 +240,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..cecfbc25dc4d 100644 --- a/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts @@ -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/validation/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts index ef58992a6bbb..831db7d8f0bd 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, }); } @@ -124,7 +124,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/execution/engine/types.ts b/packages/beacon-node/src/execution/engine/types.ts index 36e6737f1e34..6ae763dcb0cd 100644 --- a/packages/beacon-node/src/execution/engine/types.ts +++ b/packages/beacon-node/src/execution/engine/types.ts @@ -424,7 +424,7 @@ export function serializePayloadAttributes(data: PayloadAttributes): PayloadAttr suggestedFeeRecipient: data.suggestedFeeRecipient, withdrawals: data.withdrawals?.map(serializeWithdrawal), parentBeaconBlockRoot: data.parentBeaconBlockRoot ? bytesToData(data.parentBeaconBlockRoot) : undefined, - slotNumber: data.slotNumber !== undefined ? numToQuantity(data.slotNumber) : undefined, + slotNumber: data.slotNumber != null ? numToQuantity(data.slotNumber) : undefined, }; } @@ -441,7 +441,7 @@ export function deserializePayloadAttributes(data: PayloadAttributesRpc): Payloa suggestedFeeRecipient: data.suggestedFeeRecipient, withdrawals: data.withdrawals?.map((withdrawal) => deserializeWithdrawal(withdrawal)), parentBeaconBlockRoot: data.parentBeaconBlockRoot ? dataToBytes(data.parentBeaconBlockRoot, 32) : undefined, - slotNumber: data.slotNumber !== undefined ? quantityToNum(data.slotNumber) : undefined, + slotNumber: data.slotNumber != null ? quantityToNum(data.slotNumber) : undefined, }; } 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 6a24b4f0090d..68fa5c29df9e 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -1082,7 +1082,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 @@ -1105,7 +1106,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 cb88a9e3fd68..73047ea56b64 100644 --- a/packages/beacon-node/src/sync/utils/downloadByRange.ts +++ b/packages/beacon-node/src/sync/utils/downloadByRange.ts @@ -1075,7 +1075,7 @@ export function validateEnvelopesByRangeResponse( const payloadEnvelopeMap = new Map(); for (const payloadEnvelope of payloadEnvelopes) { - const slot = payloadEnvelope.message.slot; + const slot = payloadEnvelope.message.payload.slotNumber; const batchBlockRoot = batchBlockRoots.get(slot); // Envelopes for slots not in the batch are silently ignored (orphaned payloads) 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 1c0163368ed2..aef92d48975e 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 5572a1170a75..a00ce94ae7f0 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 e30cbf2bd1403a81544c461821f6c495217c80f7 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 21/24] 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 | 18 +------- .../src/block/processWithdrawals.ts | 14 ++++-- .../src/util/computeAnchorCheckpoint.ts | 21 +++------ 5 files changed, 53 insertions(+), 50 deletions(-) diff --git a/packages/beacon-node/src/chain/forkChoice/index.ts b/packages/beacon-node/src/chain/forkChoice/index.ts index 6039af2549b9..7d1854666e75 100644 --- a/packages/beacon-node/src/chain/forkChoice/index.ts +++ b/packages/beacon-node/src/chain/forkChoice/index.ts @@ -163,7 +163,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 @@ -264,7 +264,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 4795a3979714..f27eb445fae8 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"; @@ -383,6 +389,32 @@ const forkChoiceTest = const blockNumber = envelope.message.payload.blockNumber; const stateRoot = toHex(envelope.message.stateRoot); + // 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, @@ -588,17 +620,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 aef92d48975e..f9ce79e700e7 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -111,25 +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, - 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 e821ddfad6c3c0db1b29e1113da3d71a96a56576 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:06:18 -0700 Subject: [PATCH 22/24] Add definition of remaining engine API --- .../beacon-node/src/execution/engine/http.ts | 9 ++--- .../beacon-node/src/execution/engine/mock.ts | 2 ++ .../beacon-node/src/execution/engine/types.ts | 36 ++++++++++++------- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/packages/beacon-node/src/execution/engine/http.ts b/packages/beacon-node/src/execution/engine/http.ts index 8a82b8f42d22..615ca3623cc1 100644 --- a/packages/beacon-node/src/execution/engine/http.ts +++ b/packages/beacon-node/src/execution/engine/http.ts @@ -466,8 +466,8 @@ export class ExecutionEngineHttp implements IExecutionEngine { this.payloadIdCache.prune(); } - async getPayloadBodiesByHash(_fork: ForkName, blockHashes: RootHex[]): Promise<(ExecutionPayloadBody | null)[]> { - const method = "engine_getPayloadBodiesByHashV1"; + async getPayloadBodiesByHash(fork: ForkName, blockHashes: RootHex[]): Promise<(ExecutionPayloadBody | null)[]> { + const method = ForkSeq[fork] >= ForkSeq.gloas ? "engine_getPayloadBodiesByHashV2" : "engine_getPayloadBodiesByHashV1"; assertReqSizeLimit(blockHashes.length, 32); const response = await this.rpc.fetchWithRetries< EngineApiRpcReturnTypes[typeof method], @@ -477,11 +477,12 @@ export class ExecutionEngineHttp implements IExecutionEngine { } async getPayloadBodiesByRange( - _fork: ForkName, + fork: ForkName, startBlockNumber: number, blockCount: number ): Promise<(ExecutionPayloadBody | null)[]> { - const method = "engine_getPayloadBodiesByRangeV1"; + const method = + ForkSeq[fork] >= ForkSeq.gloas ? "engine_getPayloadBodiesByRangeV2" : "engine_getPayloadBodiesByRangeV1"; assertReqSizeLimit(blockCount, 32); const start = numToQuantity(startBlockNumber); const count = numToQuantity(blockCount); diff --git a/packages/beacon-node/src/execution/engine/mock.ts b/packages/beacon-node/src/execution/engine/mock.ts index d452b8c189c8..28f394d78bc3 100644 --- a/packages/beacon-node/src/execution/engine/mock.ts +++ b/packages/beacon-node/src/execution/engine/mock.ts @@ -144,7 +144,9 @@ export class ExecutionEngineMockBackend implements JsonRpcBackend { engine_getPayloadV5: this.getPayloadV5.bind(this), engine_getPayloadV6: this.getPayloadV5.bind(this), engine_getPayloadBodiesByHashV1: this.getPayloadBodiesByHash.bind(this), + engine_getPayloadBodiesByHashV2: this.getPayloadBodiesByHash.bind(this), engine_getPayloadBodiesByRangeV1: this.getPayloadBodiesByRange.bind(this), + engine_getPayloadBodiesByRangeV2: this.getPayloadBodiesByRange.bind(this), engine_getClientVersionV1: this.getClientVersionV1.bind(this), engine_getBlobsV1: this.getBlobs.bind(this), engine_getBlobsV2: this.getBlobsV2.bind(this), diff --git a/packages/beacon-node/src/execution/engine/types.ts b/packages/beacon-node/src/execution/engine/types.ts index 6ae763dcb0cd..3344a02274fa 100644 --- a/packages/beacon-node/src/execution/engine/types.ts +++ b/packages/beacon-node/src/execution/engine/types.ts @@ -87,12 +87,14 @@ export type EngineApiRpcParamTypes = { * 1. Array of DATA - Array of block_hash field values of the ExecutionPayload structure * */ engine_getPayloadBodiesByHashV1: DATA[][]; + engine_getPayloadBodiesByHashV2: DATA[][]; /** * 1. start: QUANTITY, 64 bits - Starting block number * 2. count: QUANTITY, 64 bits - Number of blocks to return */ engine_getPayloadBodiesByRangeV1: [start: QUANTITY, count: QUANTITY]; + engine_getPayloadBodiesByRangeV2: [start: QUANTITY, count: QUANTITY]; /** * Object - Instance of ClientVersion @@ -146,8 +148,10 @@ export type EngineApiRpcReturnTypes = { engine_getPayloadV6: ExecutionPayloadResponse; engine_getPayloadBodiesByHashV1: (ExecutionPayloadBodyRpc | null)[]; + engine_getPayloadBodiesByHashV2: (ExecutionPayloadBodyRpc | null)[]; engine_getPayloadBodiesByRangeV1: (ExecutionPayloadBodyRpc | null)[]; + engine_getPayloadBodiesByRangeV2: (ExecutionPayloadBodyRpc | null)[]; engine_getClientVersionV1: ClientVersionRpc[]; @@ -168,11 +172,13 @@ type ExecutionPayloadResponse = ExecutionPayloadRpcWithValue; export type ExecutionPayloadBodyRpc = { transactions: DATA[]; withdrawals: WithdrawalV1[] | null | undefined; + blockAccessList?: DATA | null; // GLOAS:EIP-7928 }; export type ExecutionPayloadBody = { transactions: bellatrix.Transaction[]; withdrawals: capella.Withdrawals | null; + blockAccessList?: Uint8Array | null; // GLOAS:EIP-7928 }; export type ExecutionPayloadRpc = { @@ -605,21 +611,27 @@ export function deserializeExecutionRequests(serialized: ExecutionRequestsRpc): } export function deserializeExecutionPayloadBody(data: ExecutionPayloadBodyRpc | null): ExecutionPayloadBody | null { - return data - ? { - transactions: data.transactions.map((tran) => dataToBytes(tran, null)), - withdrawals: data.withdrawals ? data.withdrawals.map(deserializeWithdrawal) : null, - } - : null; + if (data == null) return null; + const body: ExecutionPayloadBody = { + transactions: data.transactions.map((tran) => dataToBytes(tran, null)), + withdrawals: data.withdrawals ? data.withdrawals.map(deserializeWithdrawal) : null, + }; + if (data.blockAccessList != null) { + body.blockAccessList = dataToBytes(data.blockAccessList, null); + } + return body; } export function serializeExecutionPayloadBody(data: ExecutionPayloadBody | null): ExecutionPayloadBodyRpc | null { - return data - ? { - transactions: data.transactions.map((tran) => bytesToData(tran)), - withdrawals: data.withdrawals ? data.withdrawals.map(serializeWithdrawal) : null, - } - : null; + if (data == null) return null; + const body: ExecutionPayloadBodyRpc = { + transactions: data.transactions.map((tran) => bytesToData(tran)), + withdrawals: data.withdrawals ? data.withdrawals.map(serializeWithdrawal) : null, + }; + if (data.blockAccessList != null) { + body.blockAccessList = bytesToData(data.blockAccessList); + } + return body; } export function deserializeBlobAndProofs(data: BlobAndProofRpc | null): BlobAndProof | null { From cb59e633cfa1643f403d93ba1f629f7e889066c6 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Tue, 21 Apr 2026 18:06:36 +0200 Subject: [PATCH 23/24] feat: cache the last 2 PayloadEnvelopeInputs --- .../blocks/writePayloadEnvelopeInputToDb.ts | 7 --- packages/beacon-node/src/chain/chain.ts | 51 ++++++++++++++----- .../beacon-node/src/chain/prepareNextSlot.ts | 14 +++++ .../chain/produceBlock/produceBlockBody.ts | 21 ++++---- .../seenCache/seenPayloadEnvelopeInput.ts | 47 +++++++++-------- .../validation/executionPayloadEnvelope.ts | 6 ++- .../src/stateView/beaconStateView.ts | 4 +- .../src/stateView/interface.ts | 2 +- 8 files changed, 97 insertions(+), 55 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts b/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts index d5c07a563590..f5deccde72ee 100644 --- a/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts +++ b/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts @@ -44,12 +44,5 @@ export async function persistPayloadEnvelopeInput( }, e ); - }) - .finally(() => { - this.seenPayloadEnvelopeInputCache.prune(payloadInput.blockRootHex); - this.logger.debug("Pruned payload envelope input", { - slot: payloadInput.slot, - root: payloadInput.blockRootHex, - }); }); } diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index dc5a05b38c25..c2309c42ee50 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -2,7 +2,13 @@ import path from "node:path"; import {PrivateKey} from "@libp2p/interface"; import {Type} from "@chainsafe/ssz"; import {BeaconConfig} from "@lodestar/config"; -import {CheckpointWithPayloadStatus, IForkChoice, ProtoBlock, UpdateHeadOpt} from "@lodestar/fork-choice"; +import { + CheckpointWithPayloadStatus, + IForkChoice, + PayloadStatus, + ProtoBlock, + UpdateHeadOpt, +} from "@lodestar/fork-choice"; import {LoggerNode} from "@lodestar/logger/node"; import { EFFECTIVE_BALANCE_INCREMENT, @@ -875,21 +881,42 @@ 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). + * If parent was FULL and PTC voted timely, returns execution requests from the cached envelope + * (with DB fallback if the entry was pruned). Otherwise returns empty execution requests + * (build on EMPTY variant). */ - getParentExecutionRequests(parentBlockRootHex: RootHex): electra.ExecutionRequests { + async getParentExecutionRequests(parentBlockRootHex: RootHex): Promise { if ( - this.forkChoice.hasPayloadHexUnsafe(parentBlockRootHex) && - this.forkChoice.shouldExtendPayload(parentBlockRootHex) + !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(); + } + + // Pre-gloas parents are stored as `PayloadStatus.FULL` by default in fork choice but have no + // execution payload envelope (their payload is inlined in the block). For the fork-boundary + // case (first gloas block built on the last pre-gloas block), fall back to empty requests. + const parentBlock = this.forkChoice.getBlockHex(parentBlockRootHex, PayloadStatus.FULL); + if (parentBlock && !isForkPostGloas(this.config.getForkName(parentBlock.slot))) { + return ssz.electra.ExecutionRequests.defaultValue(); } - // Parent was EMPTY, payload not verified, or PTC didn't vote timely — return empty requests - return ssz.electra.ExecutionRequests.defaultValue(); + + const payloadInput = this.seenPayloadEnvelopeInputCache.get(parentBlockRootHex); + if (payloadInput?.hasPayloadEnvelope()) { + return payloadInput.getPayloadEnvelope().message.executionRequests; + } + + // Cache miss (e.g., entry pruned in prepareNextSlot). Fall back to DB. + const envelopeFromDb = await this.db.executionPayloadEnvelope.get(fromHex(parentBlockRootHex)); + if (envelopeFromDb) { + return envelopeFromDb.message.executionRequests; + } + + // Invariant: fork choice says parent is FULL gloas, so envelope must exist in cache or DB. + throw new Error( + `getParentExecutionRequests: fork choice reports FULL parent ${parentBlockRootHex} but envelope is missing in cache and DB` + ); } async getExecutionPayloadEnvelope( diff --git a/packages/beacon-node/src/chain/prepareNextSlot.ts b/packages/beacon-node/src/chain/prepareNextSlot.ts index ec3dfc3286c0..44ccefcc7cd6 100644 --- a/packages/beacon-node/src/chain/prepareNextSlot.ts +++ b/packages/beacon-node/src/chain/prepareNextSlot.ts @@ -203,6 +203,20 @@ export class PrepareNextSlotScheduler { }); } + if (ForkSeq[fork] >= ForkSeq.gloas) { + // Cutoff = slot of the parent of the block we'll actually build on (post-reorg). + // Steady state: cache holds just 2 entries — head (parent for next-slot production) + // and head.parent (proposer-boost-reorg fallback). Anything older is evicted. + const finalHead = this.chain.forkChoice.getBlockHexDefaultStatus(updatedHeadRoot); + const finalHeadParent = finalHead && this.chain.forkChoice.getBlockHexDefaultStatus(finalHead.parentRoot); + if (finalHeadParent) { + this.chain.seenPayloadEnvelopeInputCache.pruneBelow(finalHeadParent.slot); + } + } + + if (!isStatePostBellatrix(updatedPrepareState)) { + throw new Error("Expected Bellatrix state for payload attributes"); + } this.computeStateHashTreeRoot(updatedPrepareState, isEpochTransition); // If emitPayloadAttributes is true emit a SSE payloadAttributes event diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index a756b900fe9a..2fc40bff0108 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -286,7 +286,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(parentBlock.blockRoot); + gloasBody.parentExecutionRequests = await this.getParentExecutionRequests(parentBlock.blockRoot); blockBody = gloasBody as AssembledBodyType; // Store execution payload data required to construct execution payload envelope later @@ -616,7 +616,7 @@ export async function prepareExecutionPayload( executionEngine: IExecutionEngine; config: ChainForkConfig; forkChoice?: IForkChoice; - seenPayloadEnvelopeInputCache?: {get(rootHex: string): {hasPayloadEnvelope(): boolean; getPayloadEnvelope(): gloas.SignedExecutionPayloadEnvelope} | undefined}; + getParentExecutionRequests?: (parentBlockRootHex: RootHex) => Promise; }, logger: Logger, fork: ForkPostBellatrix, @@ -633,7 +633,7 @@ export async function prepareExecutionPayload( // 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) { + if (isForkPostGloas(fork) && chain.forkChoice && chain.getParentExecutionRequests) { const gloasState = state as unknown as { latestExecutionPayloadBid: { slot: number; @@ -647,14 +647,13 @@ export async function prepareExecutionPayload( 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; - } + // Build on FULL variant: fetch parent execution requests (cache → DB fallback), + // apply parent payload to compute correct withdrawals, use bid.blockHash as EL head + // (per spec's prepare_execution_payload) + const executionRequests = await chain.getParentExecutionRequests(parentRootHex); + const gloasView = state as unknown as IBeaconStateViewGloas; + withdrawalsOverride = gloasView.getExpectedWithdrawalsForFullParent(executionRequests); + parentHash = gloasState.latestExecutionPayloadBid.blockHash; } else { // EMPTY parent: use bid.parentBlockHash as the EL head parentHash = gloasState.latestExecutionPayloadBid.parentBlockHash; diff --git a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts index e36147638061..338fa55d0066 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"; @@ -21,8 +21,20 @@ 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 block is processed. Two pruning paths: + * - `prepareNextSlot` calls `pruneBelow(headParentSlot)` every slot once the head we'll build + * on is known. + * - `onFinalized` calls `pruneBelow(finalizedSlot)` on every finalization for bulk cleanup. + * + * Steady state (linear chain, healthy progression): the cache holds ~2 entries — the head + * (parent for next-slot production) and its parent (proposer-boost-reorg fallback). It can + * transiently hold more during forks, range-sync bursts, or when `prepareNextSlot` skips + * ticks; subsequent ticks settle it back. + * + * Consumers that miss the cache fall back to DB (`chain.getParentExecutionRequests` / + * `getExecutionPayloadEnvelope`). The authoritative view of "does this block / payload exist + * in the canonical chain" is `forkChoice` — this cache is a latency optimisation for the + * synchronous fast path, not a source of truth. */ export class SeenPayloadEnvelopeInput { private readonly chainEvents: ChainEventEmitter; @@ -58,16 +70,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 { @@ -88,17 +91,21 @@ export class SeenPayloadEnvelopeInput { return this.payloadInputs.get(blockRootHex)?.hasPayloadEnvelope() ?? false; } - prune(blockRootHex: RootHex): void { - const payloadInput = this.payloadInputs.get(blockRootHex); - if (payloadInput) { - this.evictPayloadInput(payloadInput); - } - } - size(): number { return this.payloadInputs.size; } + pruneBelow(slot: Slot): void { + let deletedCount = 0; + for (const [, input] of this.payloadInputs) { + if (input.slot < slot) { + this.evictPayloadInput(input); + deletedCount++; + } + } + this.logger?.debug("SeenPayloadEnvelopeInput.pruneBelow deleted entries", {slot, deletedCount}); + } + 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 831db7d8f0bd..234f6521b6c1 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts @@ -47,9 +47,10 @@ async function validateExecutionPayloadEnvelope( // [IGNORE] The node has not seen another valid // `SignedExecutionPayloadEnvelope` for this block root from this builder. + // Fork choice flips to PayloadStatus.FULL during envelope import, so it is the authoritative + // duplicate-detection signal. 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,6 +58,7 @@ async function validateExecutionPayloadEnvelope( }); } + const payloadInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex); if (!payloadInput) { // PayloadEnvelopeInput should have been created during block import throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { diff --git a/packages/state-transition/src/stateView/beaconStateView.ts b/packages/state-transition/src/stateView/beaconStateView.ts index c9eed49599b7..67c622a0b5f0 100644 --- a/packages/state-transition/src/stateView/beaconStateView.ts +++ b/packages/state-transition/src/stateView/beaconStateView.ts @@ -783,13 +783,13 @@ export class BeaconStateView implements IBeaconStateViewLatestFork { return new BeaconStateView(newState); } - getExpectedWithdrawalsForFullParent(envelope: gloas.SignedExecutionPayloadEnvelope): capella.Withdrawal[] { + getExpectedWithdrawalsForFullParent(executionRequests: electra.ExecutionRequests): 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"); } const stateCopy = this.cachedState.clone(true) as CachedBeaconStateGloas; - applyParentExecutionPayload(stateCopy, stateCopy.latestExecutionPayloadBid, envelope.message.executionRequests); + applyParentExecutionPayload(stateCopy, stateCopy.latestExecutionPayloadBid, 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 45672a11aa24..324fc69e10a6 100644 --- a/packages/state-transition/src/stateView/interface.ts +++ b/packages/state-transition/src/stateView/interface.ts @@ -258,7 +258,7 @@ export interface IBeaconStateViewGloas extends IBeaconStateViewFulu { * 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[]; + getExpectedWithdrawalsForFullParent(executionRequests: electra.ExecutionRequests): capella.Withdrawal[]; } /** From 89e60aefe0c7957440fa76e6bedc13b156ba6cf9 Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:13:42 -0700 Subject: [PATCH 24/24] fix build Fix ssz calculation fix --- .../src/chain/produceBlock/produceBlockBody.ts | 2 +- packages/beacon-node/src/util/sszBytes.ts | 13 +++++-------- .../test/spec/presets/fork_choice.test.ts | 2 -- packages/fork-choice/src/forkChoice/forkChoice.ts | 4 ---- .../src/slot/upgradeStateToGloas.ts | 9 ++++++--- packages/types/src/gloas/sszTypes.ts | 2 ++ packages/types/src/gloas/types.ts | 1 - 7 files changed, 14 insertions(+), 19 deletions(-) diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index 2fc40bff0108..5df5b7de4858 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -627,7 +627,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 diff --git a/packages/beacon-node/src/util/sszBytes.ts b/packages/beacon-node/src/util/sszBytes.ts index 23f20ee80d55..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,18 +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 + 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/spec/presets/fork_choice.test.ts b/packages/beacon-node/test/spec/presets/fork_choice.test.ts index f27eb445fae8..a03dab18d814 100644 --- a/packages/beacon-node/test/spec/presets/fork_choice.test.ts +++ b/packages/beacon-node/test/spec/presets/fork_choice.test.ts @@ -387,7 +387,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); // Verify envelope against the post-block state (spec: verify_execution_payload_envelope) const protoBlock = (chain.forkChoice as ForkChoice).getBlockHexDefaultStatus(beaconBlockRoot); @@ -426,7 +425,6 @@ const forkChoiceTest = beaconBlockRoot, blockHash, blockNumber, - stateRoot, ExecutionStatus.Valid ); if (!isValid) throw Error("Expect error since this is a negative test"); diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 16898a0a686e..89f2111879a1 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -1084,10 +1084,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/state-transition/src/slot/upgradeStateToGloas.ts b/packages/state-transition/src/slot/upgradeStateToGloas.ts index 9c29884ff353..c116bbf109af 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; @@ -63,7 +64,9 @@ export function upgradeStateToGloas(stateFulu: CachedBeaconStateFulu): CachedBea stateGloasView.pendingPartialWithdrawals = stateGloasCloned.pendingPartialWithdrawals; stateGloasView.pendingConsolidations = stateGloasCloned.pendingConsolidations; stateGloasView.proposerLookahead = stateGloasCloned.proposerLookahead; - stateGloasView.ptcWindow = ssz.gloas.PtcWindow.toViewDU(initializePtcWindow(stateFulu)); + stateGloasView.ptcWindow = ssz.gloas.PtcWindow.toViewDU( + initializePtcWindow(stateFulu).map((committee) => Array.from(committee)) + ); for (let i = 0; i < SLOTS_PER_HISTORICAL_ROOT; i++) { stateGloasView.executionPayloadAvailability.set(i, true); diff --git a/packages/types/src/gloas/sszTypes.ts b/packages/types/src/gloas/sszTypes.ts index a00ce94ae7f0..0d25f71bcaf5 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, // New in consensus-specs#5094 }, {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, // New in consensus-specs#5094 }, {typeName: "BeaconBlockBody", jsonCase: "eth2", cachePermanentRootStruct: true} ); diff --git a/packages/types/src/gloas/types.ts b/packages/types/src/gloas/types.ts index 81e2a978ecc4..114900031aa4 100644 --- a/packages/types/src/gloas/types.ts +++ b/packages/types/src/gloas/types.ts @@ -12,7 +12,6 @@ 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;