Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
a791964
feat: implement EIP-7843 slot_number in ExecutionPayload (specs#4840)
ensi321 Apr 20, 2026
3958681
Revert changes introduced by accident
ensi321 Apr 21, 2026
a912bc8
Bump version, skip fast_confirmation
ensi321 Apr 20, 2026
03c4349
Rest of the changes
ensi321 Apr 21, 2026
d3d74a9
Skip some tests
ensi321 Apr 21, 2026
40b5f9f
lint
ensi321 Apr 21, 2026
da7cc41
ethspecify
ensi321 Apr 21, 2026
e49b699
lint
ensi321 Apr 21, 2026
813cb52
ethspecify
ensi321 Apr 21, 2026
845bec9
fix unit test
ensi321 Apr 22, 2026
1d15ece
fix e2e
ensi321 Apr 22, 2026
c3d429d
address comment
ensi321 Apr 22, 2026
afbb9c1
feat: add processParentExecutionPayload and remove withdrawal early r…
ensi321 Apr 15, 2026
311eeca
refactor: add processParentExecutionPayload as first step in processB…
ensi321 Apr 15, 2026
e2d3ad2
refactor: transform processExecutionPayloadEnvelope to pure verification
ensi321 Apr 15, 2026
c3c73b4
refactor: remove executionPayloadStateRoot from fork choice onExecuti…
ensi321 Apr 15, 2026
bc621c3
refactor: simplify envelope import pipeline for deferred processing
ensi321 Apr 15, 2026
f4d4021
feat: block production, gossip validation, and cleanup for deferred p…
ensi321 Apr 15, 2026
63e5271
fix: type errors in processParentExecutionPayload and produceBlockBody
ensi321 Apr 15, 2026
06ab9c1
Fix spec test
ensi321 Apr 16, 2026
6552bc8
Address comments & follow up on spec change
ensi321 Apr 16, 2026
e10d287
fix upgrade state
ensi321 Apr 17, 2026
5421487
fix spec test
ensi321 Apr 20, 2026
b5937ec
Harmonize
ensi321 Apr 22, 2026
a0bbecf
Merge remote-tracking branch 'origin/unstable' into nc/defer-payload-…
nflaig Apr 22, 2026
941a710
review
nflaig Apr 22, 2026
532ffcc
ethspecify
nflaig Apr 22, 2026
16941ff
restore some comments
nflaig Apr 22, 2026
6b08376
Remove block production
ensi321 Apr 22, 2026
8967ae5
refactor
nflaig Apr 23, 2026
5b1e344
simplify applyParentExecutionPayload function signature
nflaig Apr 23, 2026
9a9fe6b
remove genesis block handling, will be separate pr
nflaig Apr 23, 2026
b07b363
Merge branch 'unstable' into nc/defer-payload-processing
nflaig Apr 23, 2026
bf7009e
we don't know and it's wrong, removed confusing todo
nflaig Apr 23, 2026
d2424d5
review computeAnchorCheckpoint
nflaig Apr 23, 2026
9bf5103
review fork_choice.test.ts
nflaig Apr 23, 2026
08b2712
revert changes to computeAnchorCheckpoint
nflaig Apr 23, 2026
e62a7a1
ahhh damn, we need that for spec tests
nflaig Apr 23, 2026
8acf7c8
remove type cast
nflaig Apr 23, 2026
24a3da5
clarify withdrawals
nflaig Apr 23, 2026
a04a4d0
review getExpectedWithdrawalsForFullParent
nflaig Apr 23, 2026
2fa1c4f
review processParentExecutionPayload.ts
nflaig Apr 23, 2026
b89af0b
review operations.test.ts
nflaig Apr 23, 2026
c2ef8aa
final pass on processParentExecutionPayload
nflaig Apr 23, 2026
78a7201
small nit
nflaig Apr 23, 2026
f8c73af
fix order of gossip checks
nflaig Apr 23, 2026
e399921
clarify comment
nflaig Apr 23, 2026
56f686c
review import
nflaig Apr 23, 2026
7728204
Update packages/beacon-node/src/chain/blocks/types.ts
nflaig Apr 23, 2026
11f9d16
Update packages/beacon-node/test/spec/presets/fork_choice.test.ts
nflaig Apr 23, 2026
2054188
Update packages/state-transition/src/block/index.ts
nflaig Apr 23, 2026
2a7d8e6
Update packages/state-transition/src/block/processWithdrawals.ts
nflaig Apr 23, 2026
372f485
.
nflaig Apr 23, 2026
6218a8e
wording
nflaig Apr 23, 2026
e450f52
restore some comments
nflaig Apr 23, 2026
232e294
ok that's it
nflaig Apr 23, 2026
e7759b0
not yet
nflaig Apr 23, 2026
c06c946
why is that comment even modified...
nflaig Apr 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 73 additions & 93 deletions packages/beacon-node/src/chain/blocks/importExecutionPayload.ts

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would be good to have more eyes on importExecutionPayload later but it's good enough to merge this cc @wemeetagain @ensi321 @twoeths

Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import {routes} from "@lodestar/api";
import {ExecutionStatus, PayloadExecutionStatus} from "@lodestar/fork-choice";
import {SLOTS_PER_EPOCH} from "@lodestar/params";
import {getExecutionPayloadEnvelopeSignatureSet, isStatePostGloas} from "@lodestar/state-transition";
import {fromHex, toRootHex} from "@lodestar/utils";
import {isStatePostGloas} from "@lodestar/state-transition";
import {fromHex} from "@lodestar/utils";
import {ExecutionPayloadStatus} from "../../execution/index.js";
import {isQueueErrorAborted} from "../../util/queue/index.js";
import {BeaconChain} from "../chain.js";
import {RegenCaller} from "../regen/interface.js";
import {PayloadEnvelopeInput} from "../seenCache/seenPayloadEnvelopeInput.js";
import {ImportPayloadOpts} from "./types.js";
import {
verifyExecutionPayloadEnvelope,
verifyExecutionPayloadEnvelopeSignature,
} from "./verifyExecutionPayloadEnvelope.js";
import {verifyPayloadsDataAvailability} from "./verifyPayloadsDataAvailability.js";

const EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS = 64;
Expand All @@ -17,7 +20,7 @@ export enum PayloadErrorCode {
EXECUTION_ENGINE_INVALID = "PAYLOAD_ERROR_EXECUTION_ENGINE_INVALID",
EXECUTION_ENGINE_ERROR = "PAYLOAD_ERROR_EXECUTION_ENGINE_ERROR",
BLOCK_NOT_IN_FORK_CHOICE = "PAYLOAD_ERROR_BLOCK_NOT_IN_FORK_CHOICE",
STATE_TRANSITION_ERROR = "PAYLOAD_ERROR_STATE_TRANSITION_ERROR",
ENVELOPE_VERIFICATION_ERROR = "PAYLOAD_ERROR_ENVELOPE_VERIFICATION_ERROR",
INVALID_SIGNATURE = "PAYLOAD_ERROR_INVALID_SIGNATURE",
}

Expand All @@ -37,7 +40,7 @@ export type PayloadErrorType =
blockRootHex: string;
}
| {
code: PayloadErrorCode.STATE_TRANSITION_ERROR;
code: PayloadErrorCode.ENVELOPE_VERIFICATION_ERROR;
message: string;
}
| {
Expand Down Expand Up @@ -69,18 +72,19 @@ 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
* The envelope is only verified here, no state mutation. State effects from the payload
* are applied on the next block via processParentExecutionPayload.
*
* Steps:
* 1. Emit `execution_payload_available` event for payload attestation
* 2. Get the ProtoBlock from fork choice
* 3. Wait for data columns to be available
* 4. Regenerate state for envelope verification
* 5. Verify envelope (fields against state, signature, and EL in parallel where possible)
* 6. Persist verified payload envelope to hot DB (waits for write-queue space for backpressure)
* 7. Update fork choice (transitions the block's PENDING variant to FULL)
* 8. Record metrics for payload envelope and column sources
* 9. Emit `execution_payload` event
*/
export async function importExecutionPayload(
this: BeaconChain,
Expand All @@ -90,17 +94,18 @@ export async function importExecutionPayload(
): Promise<void> {
const signedEnvelope = payloadInput.getPayloadEnvelope();
const envelope = signedEnvelope.message;
const slot = envelope.payload.slotNumber;
const blockRootHex = payloadInput.blockRootHex;
const blockHashHex = payloadInput.getBlockHashHex();
const fork = this.config.getForkName(envelope.payload.slotNumber);
const fork = this.config.getForkName(slot);

// 1. Emit `execution_payload_available` event at the start of import. At this point the payload input
// is already complete, so the payload and required data are available for payload attestation.
// This event is only about availability, not validity of the execution payload, hence we can emit
// it before getting a response from the execution client on whether the payload is valid or not.
if (this.clock.currentSlot - envelope.payload.slotNumber < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) {
// 1. Emit `execution_payload_available` event at the start of import. At this point the
// payload input is already complete, so the payload and required data are available for
// payload attestation. This event only signals availability (not validity), so we can emit
// it before getting a response from the EL on whether the payload is valid or not.
if (this.clock.currentSlot - slot < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) {
this.emitter.emit(routes.events.EventType.executionPayloadAvailable, {
slot: envelope.payload.slotNumber,
slot,
blockRoot: blockRootHex,
});
}
Expand All @@ -114,16 +119,11 @@ export async function importExecutionPayload(
});
}

// 3. Wait for data columns to be available before claiming a write-queue slot.
// 3. Wait for data columns to be available.
// The helper is shared with future gloas sync services; take the single-item batch form here.
await verifyPayloadsDataAvailability([payloadInput], signal);

// 4. Apply backpressure from the write queue, before doing verification work.
// The actual DB write is deferred until after verification succeeds.
await this.unfinalizedPayloadEnvelopeWrites.waitForSpace();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand why we did this, this doesn't make sense to me, I moved that check down after verification


// 5. Get pre-state for processExecutionPayloadEnvelope
// We need the block state (post-block, pre-payload) to process the envelope
// 4. Regenerate state for envelope verification
const blockState = await this.regen.getBlockSlotState(
protoBlock,
protoBlock.slot,
Expand All @@ -132,13 +132,30 @@ export async function importExecutionPayload(
);
if (!isStatePostGloas(blockState)) {
throw new PayloadError({
code: PayloadErrorCode.STATE_TRANSITION_ERROR,
message: `Expected gloas+ block state for payload import, got fork=${blockState.forkName}`,
code: PayloadErrorCode.ENVELOPE_VERIFICATION_ERROR,
message: `Expected gloas+ state for payload import, got fork=${blockState.forkName}`,
});
}

// 5. Verify envelope fields against state first to fail fast before the EL + BLS work.
// When validSignature is true, gossip/API has already verified both the signature and the
// executionRequestsRoot, so we skip those checks here.
try {
verifyExecutionPayloadEnvelope(this.config, blockState, envelope, {
verifyExecutionRequestsRoot: !opts.validSignature,
});
} catch (e) {
throw new PayloadError(
{
code: PayloadErrorCode.ENVELOPE_VERIFICATION_ERROR,
message: (e as Error).message,
},
`Envelope verification error: ${(e as Error).message}`
);
}

// 6. Run verification steps in parallel
const [execResult, signatureValid, postPayloadResult] = await Promise.all([
// 5a. Run EL and signature verification in parallel
const [execResult, signatureValid] = await Promise.all([
this.executionEngine.notifyNewPayload(
fork,
envelope.payload,
Expand All @@ -149,45 +166,22 @@ 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]);
})(),

// 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}`
);
}
})(),
: verifyExecutionPayloadEnvelopeSignature(
this.config,
blockState,
this.pubkeyCache,
signedEnvelope,
payloadInput.proposerIndex,
this.bls
),
]);

// 5a. Check signature verification result
// 5b. Check signature verification result
if (!signatureValid) {
throw new PayloadError({code: PayloadErrorCode.INVALID_SIGNATURE});
}

// 5b. Handle EL response
// 5c. Handle EL response
switch (execResult.status) {
case ExecutionPayloadStatus.VALID:
break;
Expand All @@ -213,47 +207,33 @@ export async function importExecutionPayload(
});
}

// 5c. Compute post-payload state root
const postPayloadState = postPayloadResult.postPayloadState;
const postPayloadStateRoot = postPayloadState.hashTreeRoot();

// 6. Persist payload envelope to hot DB (performed asynchronously to avoid blocking)
// 6. Persist payload envelope to hot DB. Wait for write-queue space here to apply backpressure
// on the import pipeline during sync, then perform the write asynchronously to avoid blocking.
await this.unfinalizedPayloadEnvelopeWrites.waitForSpace();

@nflaig nflaig Apr 23, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we even wanna update fork choice before applying backpressure? I keep this for now but worth to reconsider

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should update fork choice before persisting it as fork choice is quite time sensitive. Writing to DB is not.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we do that already, the db write is async, this is more about when to apply backpressure which in normal case shouldn't even happen, mostly during sync this is relevant

this.unfinalizedPayloadEnvelopeWrites.push(payloadInput).catch((e) => {
if (!isQueueErrorAborted(e)) {
this.logger.error(
"Error pushing payload envelope to unfinalized write queue",
{slot: envelope.payload.slotNumber, blockRoot: blockRootHex},
{slot, blockRoot: blockRootHex},
e as Error
);
}
});

// 7. Update fork choice
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, transitions the block's PENDING variant to FULL
const execStatus = toForkChoiceExecutionStatus(execResult.status);
this.forkChoice.onExecutionPayload(blockRootHex, blockHashHex, envelope.payload.blockNumber, execStatus);

// 9. Record metrics for payload envelope and column sources
// 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});
}

// 10. Emit event after payload is fully verified and imported to fork choice, only for recent enough payloads
if (this.clock.currentSlot - envelope.payload.slotNumber < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) {
// 9. Emit event after payload is fully verified and imported to fork choice, only for recent enough payloads
if (this.clock.currentSlot - slot < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) {
this.emitter.emit(routes.events.EventType.executionPayload, {
slot: envelope.payload.slotNumber,
slot,
builderIndex: envelope.builderIndex,
blockHash: blockHashHex,
blockRoot: blockRootHex,
Expand All @@ -263,7 +243,7 @@ export async function importExecutionPayload(
}

this.logger.verbose("Execution payload imported", {
slot: envelope.payload.slotNumber,
slot,
builderIndex: envelope.builderIndex,
blockRoot: blockRootHex,
blockHash: blockHashHex,
Expand Down
1 change: 0 additions & 1 deletion packages/beacon-node/src/chain/blocks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ export async function processBlocks(
(block, i): FullyVerifiedBlock => ({
blockInput: block,
postState: postStates[i],
postPayloadState: null,
parentBlockSlot: parentSlots[i],
executionStatus: executionStatuses[i],
// start supporting optimistic syncing/processing
Expand Down
39 changes: 14 additions & 25 deletions packages/beacon-node/src/chain/blocks/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {ChainForkConfig} from "@lodestar/config";
import {BlockExecutionStatus, PayloadExecutionStatus} from "@lodestar/fork-choice";
import {BlockExecutionStatus} from "@lodestar/fork-choice";
import {ForkSeq} from "@lodestar/params";
import {DataAvailabilityStatus, IBeaconStateView, computeEpochAtSlot} from "@lodestar/state-transition";
import type {IndexedAttestation, Slot, fulu} from "@lodestar/types";
Expand Down Expand Up @@ -43,8 +43,9 @@ export enum BlobSidecarValidation {

export type ImportPayloadOpts = {
/**
* Set to true if envelope signature was already verified (e.g., during gossip/API validation).
* When false/undefined, signature will be verified during import.
* Set to true when the envelope was already validated upstream (e.g., gossip/API validation):
* signature is trusted and execution_requests_root was already verified against the bid.
* When false/undefined, both are verified during import.
*/
validSignature?: boolean;
};
Expand Down Expand Up @@ -88,7 +89,14 @@ export type ImportBlockOpts = {
seenTimestampSec?: number;
};

type FullyVerifiedBlockBase = {
/**
* A wrapper around a `SignedBeaconBlock` that indicates that this block is fully verified and ready to import.
*
* `executionStatus` reflects the outcome of execution payload verification at block-import time:
* - pre-gloas: Valid | Syncing | PreMerge (from EL notifyNewPayload against the in-block payload)
* - post-gloas: PayloadSeparated (payload arrives separately as an envelope and is imported later)
*/
export type FullyVerifiedBlock = {
blockInput: IBlockInput;
postState: IBeaconStateView;
parentBlockSlot: Slot;
Expand All @@ -98,25 +106,6 @@ type FullyVerifiedBlockBase = {
indexedAttestations: IndexedAttestation[];
/** Seen timestamp seconds */
seenTimestampSec: number;
/** If the execution payload couldn't be verified because of EL syncing status, used in optimistic sync */
executionStatus: BlockExecutionStatus;
};

/**
* A wrapper around a `SignedBeaconBlock` that indicates that this block is fully verified and ready to import.
*
* Discriminated union on `postPayloadState`:
* - `null` → block has no pre-verified envelope; `executionStatus` is any `BlockExecutionStatus`
* - non-null → envelope was pre-verified during state transition; `executionStatus` is narrowed to
* `Valid | Syncing` (matching what `forkChoice.onExecutionPayload` expects)
*/
export type FullyVerifiedBlock = FullyVerifiedBlockBase &
(
| {
postPayloadState: null;
/** If the execution payload couldn't be verified because of EL syncing status, used in optimistic sync or for merge block */
executionStatus: BlockExecutionStatus;
}
| {
postPayloadState: IBeaconStateView;
executionStatus: PayloadExecutionStatus;
}
);
Loading
Loading