Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
71354ee
feat: add unknown payload envelope sync flow
wemeetagain Apr 20, 2026
1076647
fix: requeue incomplete gloas block inputs
wemeetagain Apr 21, 2026
bb509e7
chore: lint fix
wemeetagain Apr 21, 2026
555d6c3
chore: add some comments
wemeetagain Apr 21, 2026
06e9a8a
test: improve unknown block payload sync coverage
wemeetagain Apr 21, 2026
16356ac
fix: preserve queued payload envelopes
wemeetagain Apr 21, 2026
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
b21400b
Merge remote-tracking branch 'origin/nc/defer-payload-processing' int…
wemeetagain Apr 22, 2026
e12a16e
fix: address payload envelope review feedback
wemeetagain 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
f78ca51
test: run unknown block harness on gloas
wemeetagain 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
037c066
fix: address payload sync review feedback
wemeetagain Apr 23, 2026
1d7e898
Merge branch 'nc/defer-payload-processing' into cayman/recover-pruned…
wemeetagain Apr 23, 2026
a1dcf20
fix: handle envelope verification payload errors
wemeetagain Apr 23, 2026
793b75a
Merge branch 'pr-9241' into feat/gloas-from-genesis
lodekeeper Apr 24, 2026
3a71c0c
lodekeeper Apr 24, 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
5 changes: 4 additions & 1 deletion packages/beacon-node/src/api/impl/beacon/blocks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,10 @@ export function getBeaconBlockApi({
chain
.processBlock(blockForImport, opts)
.catch((e) => {
if (e instanceof BlockError && e.type.code === BlockErrorCode.PARENT_UNKNOWN) {
if (
e instanceof BlockError &&
(e.type.code === BlockErrorCode.PARENT_UNKNOWN || e.type.code === BlockErrorCode.PARENT_PAYLOAD_UNKNOWN)
) {
chain.emitter.emit(ChainEvent.blockUnknownParent, {
blockInput: blockForImport,
peer: IDENTITY_PEER_ID,
Expand Down
22 changes: 15 additions & 7 deletions packages/beacon-node/src/chain/blocks/verifyBlocksSanityChecks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,15 +90,23 @@ export function verifyBlocksSanityChecks(
} else {
// When importing a block segment, only the first NON-IGNORED block must be known to the fork-choice.
const parentRoot = toRootHex(block.message.parentRoot);
parentBlock = isGloasBeaconBlock(block.message)
? chain.forkChoice.getBlockHexAndBlockHash(
parentRoot,
toRootHex(block.message.body.signedExecutionPayloadBid.message.parentBlockHash)
)
: chain.forkChoice.getBlockHexDefaultStatus(parentRoot);
if (!parentBlock) {
const parentBlockDefaultStatus = chain.forkChoice.getBlockHexDefaultStatus(parentRoot);
if (!parentBlockDefaultStatus) {
throw new BlockError(block, {code: BlockErrorCode.PARENT_UNKNOWN, parentRoot});
}

parentBlock = parentBlockDefaultStatus;
if (isGloasBeaconBlock(block.message)) {
const parentBlockHash = toRootHex(block.message.body.signedExecutionPayloadBid.message.parentBlockHash);
const parentBlockWithPayload = chain.forkChoice.getBlockHexAndBlockHash(parentRoot, parentBlockHash);
if (!parentBlockWithPayload) {
throw new BlockError(block, {
code: BlockErrorCode.PARENT_PAYLOAD_UNKNOWN,
parentBlockHash,
});
}
parentBlock = parentBlockWithPayload;
}
// Parent is known to the fork-choice
parentBlockSlot = parentBlock.slot;
}
Expand Down
5 changes: 5 additions & 0 deletions packages/beacon-node/src/chain/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,7 @@ export class BeaconChain implements IBeaconChain {
chainEvents: emitter,
signal,
serializedCache: this.serializedCache,
hasValidatedPayload: (blockRootHex) => this.forkChoice.hasPayloadHexUnsafe(blockRootHex),
metrics,
logger,
});
Expand Down Expand Up @@ -891,6 +892,10 @@ export class BeaconChain implements IBeaconChain {
parentBlockSlot: Slot,
parentBlockRootHex: RootHex
): Promise<electra.ExecutionRequests> {
// Gloas genesis has no payload envelope — treat the parent's requests as empty.
if (parentBlockSlot === GENESIS_SLOT) {
return ssz.electra.ExecutionRequests.defaultValue();
}
const envelope = await this.getExecutionPayloadEnvelope(parentBlockSlot, parentBlockRootHex);
if (envelope === null) {
throw Error(`Parent execution payload envelope not found slot=${parentBlockSlot}, root=${parentBlockRootHex}`);
Expand Down
93 changes: 58 additions & 35 deletions packages/beacon-node/src/chain/forkChoice/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,63 @@ export function initializeForkChoiceFromFinalizedState(

const isForkPostGloas = computeEpochAtSlot(state.slot) >= config.GLOAS_FORK_EPOCH;

const anchorBlockRoot = toRootHex(checkpoint.root);
const protoArray = ProtoArray.initialize(
{
slot: blockHeader.slot,
parentRoot: toRootHex(blockHeader.parentRoot),
stateRoot: toRootHex(blockHeader.stateRoot),
blockRoot: anchorBlockRoot,
timeliness: true, // Optimistically assume is timely

justifiedEpoch: justifiedCheckpoint.epoch,
justifiedRoot: toRootHex(justifiedCheckpoint.root),
finalizedEpoch: finalizedCheckpoint.epoch,
finalizedRoot: toRootHex(finalizedCheckpoint.root),
unrealizedJustifiedEpoch: justifiedCheckpoint.epoch,
unrealizedJustifiedRoot: toRootHex(justifiedCheckpoint.root),
unrealizedFinalizedEpoch: finalizedCheckpoint.epoch,
unrealizedFinalizedRoot: toRootHex(finalizedCheckpoint.root),

...(isStatePostBellatrix(state) && state.isExecutionStateType && state.isMergeTransitionComplete
? {
executionPayloadBlockHash: isStatePostGloas(state)
? toRootHex(state.latestBlockHash)
: toRootHex(state.latestExecutionPayloadHeader.blockHash),
// TODO GLOAS: executionPayloadNumber is not tracked in BeaconState post-gloas (EIP-7732 removed
// latestExecutionPayloadHeader). Using 0 as unavailable fallback until a solution is found.
executionPayloadNumber: isStatePostGloas(state) ? 0 : state.payloadBlockNumber,
executionStatus: blockHeader.slot === GENESIS_SLOT ? ExecutionStatus.Valid : ExecutionStatus.Syncing,
}
: {executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}),

dataAvailabilityStatus: DataAvailabilityStatus.PreData,
payloadStatus: isForkPostGloas ? PayloadStatus.PENDING : PayloadStatus.FULL,
parentBlockHash: isStatePostGloas(state) ? toRootHex(state.latestBlockHash) : null,
},
currentSlot
);

// Gloas anchor whose payload has already been fulfilled (e.g. genesis with EL genesis block,
// or a finalized anchor that was FULL at time of finalization) must expose a FULL variant so
// `forkChoice.hasPayload(anchorRoot)` returns true and next-slot FCU can extend the EL head.
// Spec: gloas/fork-choice.md `is_parent_block_full(state)` equivalent — parent is FULL when
// `state.latestBlockHash == state.latestExecutionPayloadBid.blockHash`.
if (isStatePostGloas(state) && state.isMergeTransitionComplete) {
const latestBlockHashHex = toRootHex(state.latestBlockHash);
const bidBlockHashHex = toRootHex(state.latestExecutionPayloadBid.blockHash);
if (latestBlockHashHex !== ZERO_HASH_HEX && latestBlockHashHex === bidBlockHashHex) {
protoArray.onExecutionPayload(
anchorBlockRoot,
currentSlot,
latestBlockHashHex,
0,
null,
blockHeader.slot === GENESIS_SLOT ? ExecutionStatus.Valid : ExecutionStatus.Syncing
);
}
}

return new forkchoiceConstructor(
config,

Expand All @@ -118,41 +175,7 @@ export function initializeForkChoiceFromFinalizedState(
}
),

ProtoArray.initialize(
{
slot: blockHeader.slot,
parentRoot: toRootHex(blockHeader.parentRoot),
stateRoot: toRootHex(blockHeader.stateRoot),
blockRoot: toRootHex(checkpoint.root),
timeliness: true, // Optimistically assume is timely

justifiedEpoch: justifiedCheckpoint.epoch,
justifiedRoot: toRootHex(justifiedCheckpoint.root),
finalizedEpoch: finalizedCheckpoint.epoch,
finalizedRoot: toRootHex(finalizedCheckpoint.root),
unrealizedJustifiedEpoch: justifiedCheckpoint.epoch,
unrealizedJustifiedRoot: toRootHex(justifiedCheckpoint.root),
unrealizedFinalizedEpoch: finalizedCheckpoint.epoch,
unrealizedFinalizedRoot: toRootHex(finalizedCheckpoint.root),

...(isStatePostBellatrix(state) && state.isExecutionStateType && state.isMergeTransitionComplete
? {
executionPayloadBlockHash: isStatePostGloas(state)
? toRootHex(state.latestBlockHash)
: toRootHex(state.latestExecutionPayloadHeader.blockHash),
// TODO GLOAS: executionPayloadNumber is not tracked in BeaconState post-gloas (EIP-7732 removed
// latestExecutionPayloadHeader). Using 0 as unavailable fallback until a solution is found.
executionPayloadNumber: isStatePostGloas(state) ? 0 : state.payloadBlockNumber,
executionStatus: blockHeader.slot === GENESIS_SLOT ? ExecutionStatus.Valid : ExecutionStatus.Syncing,
}
: {executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}),

dataAvailabilityStatus: DataAvailabilityStatus.PreData,
payloadStatus: isForkPostGloas ? PayloadStatus.PENDING : PayloadStatus.FULL,
parentBlockHash: isStatePostGloas(state) ? toRootHex(state.latestBlockHash) : null,
},
currentSlot
),
protoArray,
state.validatorCount,
metrics,
opts,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import {RootHex, Slot} from "@lodestar/types";
import {Logger} from "@lodestar/utils";
import {Metrics} from "../../metrics/metrics.js";
import {SerializedCache} from "../../util/serializedCache.js";
import {CreateFromBlockProps, PayloadEnvelopeInput} from "../blocks/payloadEnvelopeInput/index.js";
import {PayloadEnvelopeInput} from "../blocks/payloadEnvelopeInput/payloadEnvelopeInput.js";
import {CreateFromBlockProps} from "../blocks/payloadEnvelopeInput/types.js";
import {ChainEvent, ChainEventEmitter} from "../emitter.js";

export type {PayloadEnvelopeInputState} from "../blocks/payloadEnvelopeInput/index.js";
Expand All @@ -14,6 +15,7 @@ export type SeenPayloadEnvelopeInputModules = {
chainEvents: ChainEventEmitter;
signal: AbortSignal;
serializedCache: SerializedCache;
hasValidatedPayload: (blockRootHex: RootHex) => boolean;
metrics: Metrics | null;
logger?: Logger;
};
Expand All @@ -26,6 +28,9 @@ export type SeenPayloadEnvelopeInputModules = {
* on is known.
* - `onFinalized` calls `pruneBelow(finalizedSlot)` on every finalization for bulk cleanup.
*
* Entries whose payload has not yet been validated are retained across pruning so that later
* envelope / column validation can reuse the PayloadEnvelopeInput created by importBlock().
*
* 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
Expand All @@ -35,14 +40,23 @@ export class SeenPayloadEnvelopeInput {
private readonly chainEvents: ChainEventEmitter;
private readonly signal: AbortSignal;
private readonly serializedCache: SerializedCache;
private readonly hasValidatedPayload: (blockRootHex: RootHex) => boolean;
private readonly metrics: Metrics | null;
private readonly logger?: Logger;
private payloadInputs = new Map<RootHex, PayloadEnvelopeInput>();

constructor({chainEvents, signal, serializedCache, metrics, logger}: SeenPayloadEnvelopeInputModules) {
constructor({
chainEvents,
signal,
serializedCache,
hasValidatedPayload,
metrics,
logger,
}: SeenPayloadEnvelopeInputModules) {
this.chainEvents = chainEvents;
this.signal = signal;
this.serializedCache = serializedCache;
this.hasValidatedPayload = hasValidatedPayload;
this.metrics = metrics;
this.logger = logger;

Expand Down Expand Up @@ -92,13 +106,25 @@ export class SeenPayloadEnvelopeInput {

pruneBelow(slot: Slot): void {
let deletedCount = 0;
let retainedUnvalidatedCount = 0;
for (const [, input] of this.payloadInputs) {
if (input.slot < slot) {
this.evictPayloadInput(input);
deletedCount++;
if (input.slot >= slot) {
continue;
}

if (!this.hasValidatedPayload(input.blockRootHex)) {
retainedUnvalidatedCount++;
continue;
}

this.evictPayloadInput(input);
deletedCount++;
}
this.logger?.debug("SeenPayloadEnvelopeInput.pruneBelow deleted entries", {slot, deletedCount});
this.logger?.debug("SeenPayloadEnvelopeInput.pruneBelow deleted entries", {
slot,
deletedCount,
retainedUnvalidatedCount,
});
}

private evictPayloadInput(payloadInput: PayloadEnvelopeInput): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,23 +48,31 @@ async function validateExecutionPayloadEnvelope(
// [IGNORE] The node has not seen another valid
// `SignedExecutionPayloadEnvelope` for this block root from this builder.
const envelopeBlock = chain.forkChoice.getBlockHex(blockRootHex, PayloadStatus.FULL);
const payloadInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex);
if (envelopeBlock || payloadInput?.hasPayloadEnvelope()) {
if (envelopeBlock) {
throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, {
code: ExecutionPayloadEnvelopeErrorCode.ENVELOPE_ALREADY_KNOWN,
blockRoot: blockRootHex,
slot: payload.slotNumber,
});
}

const payloadInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex);
if (!payloadInput) {
// PayloadEnvelopeInput should have been created during block import
// importBlock() is the only place that creates PayloadEnvelopeInput for a known Gloas block.
throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, {
code: ExecutionPayloadEnvelopeErrorCode.PAYLOAD_ENVELOPE_INPUT_MISSING,
blockRoot: blockRootHex,
});
}

if (payloadInput.hasPayloadEnvelope()) {
throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, {
code: ExecutionPayloadEnvelopeErrorCode.ENVELOPE_ALREADY_KNOWN,
blockRoot: blockRootHex,
slot: payload.slotNumber,
});
}

// [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);
Expand Down
4 changes: 4 additions & 0 deletions packages/beacon-node/src/metrics/metrics/lodestar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,10 @@ export function createLodestarMetrics(
name: "lodestar_sync_unknown_block_pending_blocks_size",
help: "Current size of UnknownBlockSync pending blocks cache",
}),
pendingPayloads: register.gauge({
name: "lodestar_sync_unknown_block_pending_payloads_size",
help: "Current size of UnknownBlockSync pending payloads cache",
}),
knownBadBlocks: register.gauge({
name: "lodestar_sync_unknown_block_known_bad_blocks_size",
help: "Current size of UnknownBlockSync known bad blocks cache",
Expand Down
10 changes: 6 additions & 4 deletions packages/beacon-node/src/network/processor/gossipHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,10 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand
} catch (e) {
if (e instanceof BlockGossipError) {
logger.debug("Gossip block has error", {slot, root: blockShortHex, code: e.type.code});
if (e.type.code === BlockErrorCode.PARENT_UNKNOWN && blockInput) {
if (
(e.type.code === BlockErrorCode.PARENT_UNKNOWN || e.type.code === BlockErrorCode.PARENT_PAYLOAD_UNKNOWN) &&
blockInput
) {
chain.emitter.emit(ChainEvent.blockUnknownParent, {
blockInput,
peer: peerIdStr,
Expand Down Expand Up @@ -442,8 +445,8 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand
const payloadInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex);

if (!payloadInput) {
// This should not happen for gossip because the network processor queues `data_column_sidecar`
// until block import creates the corresponding PayloadEnvelopeInput.
// This should not happen for gossip because the block should already be known by the time
// payload columns are processed.
throw new DataColumnSidecarGossipError(GossipAction.IGNORE, {
code: DataColumnSidecarErrorCode.PAYLOAD_ENVELOPE_INPUT_MISSING,
slot,
Expand Down Expand Up @@ -1098,7 +1101,6 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand
const payloadInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex);

if (!payloadInput) {
// This shouldn't happen because beacon block should have been imported and thus payload input should have been created.
throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, {
code: ExecutionPayloadEnvelopeErrorCode.PAYLOAD_ENVELOPE_INPUT_MISSING,
blockRoot: blockRootHex,
Expand Down
Loading
Loading