From 0967bb6485d0cc5764a8cc57446a798cb49131f4 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 21:01:07 +0100 Subject: [PATCH 1/5] feat: wire parent execution requests in block production --- packages/beacon-node/src/chain/chain.ts | 13 ++++++ packages/beacon-node/src/chain/interface.ts | 2 + .../beacon-node/src/chain/prepareNextSlot.ts | 27 ++++++++++-- .../chain/produceBlock/produceBlockBody.ts | 41 ++++++++++++++----- .../validation/executionPayloadEnvelope.ts | 5 ++- .../src/stateView/beaconStateView.ts | 4 +- .../src/stateView/interface.ts | 2 +- 7 files changed, 75 insertions(+), 19 deletions(-) diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 3054d54a0ad8..43dbe7a3ef75 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, @@ -886,6 +887,18 @@ export class BeaconChain implements IBeaconChain { ); } + /** Caller must ensure parent's payload is FULL (see `shouldExtendPayload`). */ + async getParentExecutionRequests( + parentBlockSlot: Slot, + parentBlockRootHex: RootHex + ): Promise { + const envelope = await this.getExecutionPayloadEnvelope(parentBlockSlot, parentBlockRootHex); + if (envelope === null) { + throw Error(`Parent execution payload envelope not found slot=${parentBlockSlot} root=${parentBlockRootHex}`); + } + return envelope.message.executionRequests; + } + async getDataColumnSidecars(blockSlot: Slot, blockRootHex: string): Promise { const fork = this.config.getForkName(blockSlot); diff --git a/packages/beacon-node/src/chain/interface.ts b/packages/beacon-node/src/chain/interface.ts index 3b21ef24ece7..ea4b19465e80 100644 --- a/packages/beacon-node/src/chain/interface.ts +++ b/packages/beacon-node/src/chain/interface.ts @@ -18,6 +18,7 @@ import { altair, capella, deneb, + electra, gloas, phase0, rewards, @@ -231,6 +232,7 @@ export interface IBeaconChain { blockSlot: Slot, blockRootHex: string ): Promise; + getParentExecutionRequests(parentBlockSlot: Slot, parentBlockRootHex: RootHex): Promise; produceCommonBlockBody(blockAttributes: BlockAttributes): Promise; produceBlock(blockAttributes: BlockAttributes & {commonBlockBodyPromise: Promise}): Promise<{ diff --git a/packages/beacon-node/src/chain/prepareNextSlot.ts b/packages/beacon-node/src/chain/prepareNextSlot.ts index 0f5e4293934f..21d6ab476503 100644 --- a/packages/beacon-node/src/chain/prepareNextSlot.ts +++ b/packages/beacon-node/src/chain/prepareNextSlot.ts @@ -10,7 +10,7 @@ import { isStatePostBellatrix, isStatePostGloas, } from "@lodestar/state-transition"; -import {Bytes32, Slot} from "@lodestar/types"; +import {Bytes32, Slot, electra} from "@lodestar/types"; import {Logger, fromHex, isErrorAborted, sleep} from "@lodestar/utils"; import {GENESIS_SLOT, ZERO_HASH_HEX} from "../constants/constants.js"; import {BuilderStatus} from "../execution/builder/http.js"; @@ -165,14 +165,19 @@ export class PrepareNextSlotScheduler { } let parentBlockHash: Bytes32; + let isExtendingPayload = false; if (isStatePostGloas(updatedPrepareState)) { - parentBlockHash = this.chain.forkChoice.shouldExtendPayload(updatedHead.blockRoot) + isExtendingPayload = this.chain.forkChoice.shouldExtendPayload(updatedHead.blockRoot); + parentBlockHash = isExtendingPayload ? updatedPrepareState.latestExecutionPayloadBid.blockHash : updatedPrepareState.latestExecutionPayloadBid.parentBlockHash; } else { parentBlockHash = updatedPrepareState.latestExecutionPayloadHeader.blockHash; } + // Fetched only when we're proposing on a full parent to avoid the cost on followers. + let parentExecutionRequests: electra.ExecutionRequests | undefined; + if (feeRecipient) { const preparationTime = computeTimeAtSlot(this.config, prepareSlot, this.chain.genesisTime) - Date.now() / 1000; @@ -182,6 +187,13 @@ export class PrepareNextSlotScheduler { const finalizedBlockHash = this.chain.forkChoice.getFinalizedBlock().executionPayloadBlockHash ?? ZERO_HASH_HEX; + if (isExtendingPayload) { + parentExecutionRequests = await this.chain.getParentExecutionRequests( + updatedHead.slot, + updatedHead.blockRoot + ); + } + // awaiting here instead of throwing an async call because there is no other task // left for scheduler and this gives nice semantics to catch and log errors in the // try/catch wrapper here. @@ -194,7 +206,8 @@ export class PrepareNextSlotScheduler { safeBlockHash, finalizedBlockHash, updatedPrepareState, - feeRecipient + feeRecipient, + parentExecutionRequests ); this.logger.verbose("PrepareNextSlotScheduler prepared new payload", { prepareSlot, @@ -220,6 +233,13 @@ export class PrepareNextSlotScheduler { this.chain.opts.emitPayloadAttributes === true && this.chain.emitter.listenerCount(routes.events.EventType.payloadAttributes) ) { + // if we didn't fetch above (not proposing), SSE still needs it here + if (!parentExecutionRequests && isExtendingPayload) { + parentExecutionRequests = await this.chain.getParentExecutionRequests( + updatedHead.slot, + updatedHead.blockRoot + ); + } const data = getPayloadAttributesForSSE(fork as ForkPostBellatrix, this.chain, { prepareState: updatedPrepareState, prepareSlot, @@ -228,6 +248,7 @@ export class PrepareNextSlotScheduler { // The likely consumers of this API are builders and will anyway ignore the // feeRecipient, so just pass zero hash for now till a real use case arises feeRecipient: "0x0000000000000000000000000000000000000000000000000000000000000000", + parentExecutionRequests, }); this.chain.emitter.emit(routes.events.EventType.payloadAttributes, {data, version: fork}); } diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index 4db6e0c00f68..cee4ebdf0b08 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -214,9 +214,13 @@ export async function produceBlockBody( }); // Get execution payload from EL - const parentBlockHash = this.forkChoice.shouldExtendPayload(toRootHex(parentBlockRoot)) + const isExtendingPayload = this.forkChoice.shouldExtendPayload(toRootHex(parentBlockRoot)); + const parentBlockHash = isExtendingPayload ? currentState.latestExecutionPayloadBid.blockHash : currentState.latestExecutionPayloadBid.parentBlockHash; + const parentExecutionRequests = isExtendingPayload + ? await this.getParentExecutionRequests(parentBlock.slot, parentBlock.blockRoot) + : ssz.electra.ExecutionRequests.defaultValue(); const prepareRes = await prepareExecutionPayload( this, this.logger, @@ -226,7 +230,8 @@ export async function produceBlockBody( safeBlockHash, finalizedBlockHash ?? ZERO_HASH_HEX, currentState, - feeRecipient + feeRecipient, + parentExecutionRequests ); const {prepType, payloadId} = prepareRes; @@ -282,7 +287,7 @@ export async function produceBlockBody( gloasBody.signedExecutionPayloadBid = signedBid; // TODO GLOAS: Get payload attestations from pool for previous slot gloasBody.payloadAttestations = []; - // TODO GLOAS: set parentExecutionRequests in the block body + gloasBody.parentExecutionRequests = parentExecutionRequests; blockBody = gloasBody as AssembledBodyType; // Store execution payload data required to construct execution payload envelope later @@ -619,7 +624,8 @@ export async function prepareExecutionPayload( safeBlockHash: RootHex, finalizedBlockHash: RootHex, state: IBeaconStateViewBellatrix, - suggestedFeeRecipient: string + suggestedFeeRecipient: string, + parentExecutionRequests?: electra.ExecutionRequests ): Promise<{prepType: PayloadPreparationType; payloadId: PayloadId}> { const timestamp = computeTimeAtSlot(chain.config, state.slot, state.genesisTime); const prevRandao = state.getRandaoMix(state.epoch); @@ -656,6 +662,7 @@ export async function prepareExecutionPayload( parentBlockRoot, parentBlockHash, feeRecipient: suggestedFeeRecipient, + parentExecutionRequests, }); payloadId = await chain.executionEngine.notifyForkchoiceUpdate( @@ -714,12 +721,14 @@ export function getPayloadAttributesForSSE( parentBlockRoot, parentBlockHash, feeRecipient, + parentExecutionRequests, }: { prepareState: IBeaconStateViewBellatrix; prepareSlot: Slot; parentBlockRoot: Root; parentBlockHash: Bytes32; feeRecipient: string; + parentExecutionRequests?: electra.ExecutionRequests; } ): SSEPayloadAttributes { const payloadAttributes = preparePayloadAttributes(fork, chain, { @@ -728,6 +737,7 @@ export function getPayloadAttributesForSSE( parentBlockRoot, parentBlockHash, feeRecipient, + parentExecutionRequests, }); let parentBlockNumber: number; @@ -766,12 +776,14 @@ function preparePayloadAttributes( parentBlockRoot, parentBlockHash, feeRecipient, + parentExecutionRequests, }: { prepareState: IBeaconStateViewBellatrix; prepareSlot: Slot; parentBlockRoot: Root; parentBlockHash: Bytes32; feeRecipient: string; + parentExecutionRequests?: electra.ExecutionRequests; } ): SSEPayloadAttributes["payloadAttributes"] { const timestamp = computeTimeAtSlot(chain.config, prepareSlot, prepareState.genesisTime); @@ -789,13 +801,20 @@ function preparePayloadAttributes( 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 - // was not delivered). The next payload must carry those same withdrawals to - // restore CL/EL consistency, otherwise validators permanently lose that balance. - (payloadAttributes as capella.SSEPayloadAttributes["payloadAttributes"]).withdrawals = isExtendingPayload - ? prepareState.getExpectedWithdrawals().expectedWithdrawals - : prepareState.payloadExpectedWithdrawals; + if (isExtendingPayload) { + if (parentExecutionRequests === undefined) { + throw new Error("parentExecutionRequests required when extending full parent in gloas"); + } + (payloadAttributes as capella.SSEPayloadAttributes["payloadAttributes"]).withdrawals = + prepareState.getExpectedWithdrawalsForFullParent(parentExecutionRequests); + } else { + // When the parent block is empty, state.payloadExpectedWithdrawals holds a batch + // already deducted from CL balances but never credited on the EL (the envelope + // was not delivered). The next payload must carry those same withdrawals to + // restore CL/EL consistency, otherwise validators permanently lose that balance. + (payloadAttributes as capella.SSEPayloadAttributes["payloadAttributes"]).withdrawals = + prepareState.payloadExpectedWithdrawals; + } } else { // withdrawals logic is now fork aware as it changes on electra fork post capella (payloadAttributes as capella.SSEPayloadAttributes["payloadAttributes"]).withdrawals = diff --git a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts index 862f86ca6784..6499c51e354c 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts @@ -47,9 +47,9 @@ async function validateExecutionPayloadEnvelope( // [IGNORE] The node has not seen another valid // `SignedExecutionPayloadEnvelope` for this block root from this builder. + // forkChoice is the source of truth — cache entries can legitimately be pruned after import. 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 +57,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 5e2b575d7c18..f6ee0017823f 100644 --- a/packages/state-transition/src/stateView/beaconStateView.ts +++ b/packages/state-transition/src/stateView/beaconStateView.ts @@ -786,7 +786,7 @@ export class BeaconStateView implements IBeaconStateViewLatestFork { /** * Spec: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/gloas/validator.md#executionpayload */ - getExpectedWithdrawalsForFullParent(envelope: gloas.SignedExecutionPayloadEnvelope): capella.Withdrawal[] { + getExpectedWithdrawalsForFullParent(executionRequests: electra.ExecutionRequests): capella.Withdrawal[] { const fork = this.config.getForkSeq(this.cachedState.slot); if (fork < ForkSeq.gloas) { throw new Error("getExpectedWithdrawalsForFullParent is not available before Gloas"); @@ -794,7 +794,7 @@ export class BeaconStateView implements IBeaconStateViewLatestFork { // Make a copy of the state to avoid mutability issues const stateCopy = this.cachedState.clone(true) as CachedBeaconStateGloas; // Apply parent payload before computing withdrawals - applyParentExecutionPayload(stateCopy, envelope.message.executionRequests); + applyParentExecutionPayload(stateCopy, executionRequests); return getExpectedWithdrawals(fork, stateCopy).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 98ab8fd7491bca1c79b852f1cd25f881e32e1af1 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 22:02:52 +0100 Subject: [PATCH 2/5] cleanup --- packages/beacon-node/src/chain/chain.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 43dbe7a3ef75..033441881827 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -887,7 +887,6 @@ export class BeaconChain implements IBeaconChain { ); } - /** Caller must ensure parent's payload is FULL (see `shouldExtendPayload`). */ async getParentExecutionRequests( parentBlockSlot: Slot, parentBlockRootHex: RootHex From 7638225bc025108f8f9ab421c60adc9d668f6d18 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 22:07:39 +0100 Subject: [PATCH 3/5] review --- packages/beacon-node/src/chain/chain.ts | 2 +- packages/beacon-node/src/chain/prepareNextSlot.ts | 2 +- packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 033441881827..921ac115c081 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -893,7 +893,7 @@ export class BeaconChain implements IBeaconChain { ): Promise { const envelope = await this.getExecutionPayloadEnvelope(parentBlockSlot, parentBlockRootHex); if (envelope === null) { - throw Error(`Parent execution payload envelope not found slot=${parentBlockSlot} root=${parentBlockRootHex}`); + throw Error(`Parent execution payload envelope not found slot=${parentBlockSlot}, root=${parentBlockRootHex}`); } return envelope.message.executionRequests; } diff --git a/packages/beacon-node/src/chain/prepareNextSlot.ts b/packages/beacon-node/src/chain/prepareNextSlot.ts index 0737db229b82..da2853403f84 100644 --- a/packages/beacon-node/src/chain/prepareNextSlot.ts +++ b/packages/beacon-node/src/chain/prepareNextSlot.ts @@ -175,7 +175,7 @@ export class PrepareNextSlotScheduler { parentBlockHash = updatedPrepareState.latestExecutionPayloadHeader.blockHash; } - // Fetched only when we're proposing on a full parent to avoid the cost on followers. + // Only needed when proposing on a full parent let parentExecutionRequests: electra.ExecutionRequests | undefined; if (feeRecipient) { diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index cee4ebdf0b08..f92922906136 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -803,7 +803,7 @@ function preparePayloadAttributes( const isExtendingPayload = byteArrayEquals(parentBlockHash, prepareState.latestExecutionPayloadBid.blockHash); if (isExtendingPayload) { if (parentExecutionRequests === undefined) { - throw new Error("parentExecutionRequests required when extending full parent in gloas"); + throw new Error("parentExecutionRequests required when extending full parent"); } (payloadAttributes as capella.SSEPayloadAttributes["payloadAttributes"]).withdrawals = prepareState.getExpectedWithdrawalsForFullParent(parentExecutionRequests); From c84ab45f1a30c1f275582d59f99cafaae82105c9 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 22:20:48 +0100 Subject: [PATCH 4/5] comments --- packages/beacon-node/src/chain/prepareNextSlot.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/src/chain/prepareNextSlot.ts b/packages/beacon-node/src/chain/prepareNextSlot.ts index da2853403f84..a57126faf9bd 100644 --- a/packages/beacon-node/src/chain/prepareNextSlot.ts +++ b/packages/beacon-node/src/chain/prepareNextSlot.ts @@ -175,7 +175,7 @@ export class PrepareNextSlotScheduler { parentBlockHash = updatedPrepareState.latestExecutionPayloadHeader.blockHash; } - // Only needed when proposing on a full parent + // Reused by the SSE emit below to avoid a second DB lookup on cache miss let parentExecutionRequests: electra.ExecutionRequests | undefined; if (feeRecipient) { From 76bff9eff48227d56a0a6db19bf411e67c88ac50 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 23 Apr 2026 22:28:48 +0100 Subject: [PATCH 5/5] unrelated --- .../src/chain/validation/executionPayloadEnvelope.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts index 6499c51e354c..862f86ca6784 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts @@ -47,9 +47,9 @@ async function validateExecutionPayloadEnvelope( // [IGNORE] The node has not seen another valid // `SignedExecutionPayloadEnvelope` for this block root from this builder. - // forkChoice is the source of truth — cache entries can legitimately be pruned after import. const envelopeBlock = chain.forkChoice.getBlockHex(blockRootHex, PayloadStatus.FULL); - if (envelopeBlock) { + const payloadInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex); + if (envelopeBlock || payloadInput?.hasPayloadEnvelope()) { throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { code: ExecutionPayloadEnvelopeErrorCode.ENVELOPE_ALREADY_KNOWN, blockRoot: blockRootHex, @@ -57,7 +57,6 @@ async function validateExecutionPayloadEnvelope( }); } - const payloadInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex); if (!payloadInput) { // PayloadEnvelopeInput should have been created during block import throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, {