Skip to content

Commit e47aab4

Browse files
ensi321claude
andcommitted
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) <noreply@anthropic.com>
1 parent 0b07339 commit e47aab4

13 files changed

Lines changed: 266 additions & 66 deletions

File tree

packages/beacon-node/src/chain/blocks/importExecutionPayload.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,12 @@ export async function importExecutionPayload(
153153

154154
// 5a. Pure envelope verification (no state mutation)
155155
try {
156-
blockState.verifyExecutionPayloadEnvelope(signedEnvelope, {verifySignature: false});
156+
// When validSignature is true, the envelope came from gossip/API where both
157+
// signature and executionRequestsRoot were already verified — skip re-hashing
158+
blockState.verifyExecutionPayloadEnvelope(signedEnvelope, {
159+
verifySignature: false,
160+
verifyExecutionRequestsRoot: !opts.validSignature,
161+
});
157162
} catch (e) {
158163
throw new PayloadError(
159164
{

packages/beacon-node/src/chain/chain.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import {BlockProcessor, ImportBlockOpts} from "./blocks/index.js";
7373
import {PayloadEnvelopeProcessor} from "./blocks/payloadEnvelopeProcessor.js";
7474
import {ImportPayloadOpts} from "./blocks/types.js";
7575
import {persistBlockInput} from "./blocks/writeBlockInputToDb.js";
76+
import {PayloadEnvelopeInputSource} from "./blocks/payloadEnvelopeInput/types.js";
7677
import {persistPayloadEnvelopeInput} from "./blocks/writePayloadEnvelopeInputToDb.js";
7778
import {BlsMultiThreadWorkerPool, BlsSingleThreadVerifier, IBlsVerifier} from "./bls/index.js";
7879
import {ColumnReconstructionTracker} from "./ColumnReconstructionTracker.js";
@@ -1100,8 +1101,35 @@ export class BeaconChain implements IBeaconChain {
11001101
return this.blockProcessor.processBlocksJob([block], opts);
11011102
}
11021103

1103-
async processChainSegment(blocks: IBlockInput[], opts?: ImportBlockOpts): Promise<void> {
1104-
return this.blockProcessor.processBlocksJob(blocks, opts);
1104+
async processChainSegment(
1105+
blocks: IBlockInput[],
1106+
payloadEnvelopes: Map<Slot, gloas.SignedExecutionPayloadEnvelope> | null,
1107+
opts?: ImportBlockOpts
1108+
): Promise<void> {
1109+
await this.blockProcessor.processBlocksJob(blocks, opts);
1110+
1111+
// After blocks are imported, add downloaded envelopes to their PayloadEnvelopeInput
1112+
// and trigger processing. Each block's importBlock() already created a PayloadEnvelopeInput
1113+
// in seenPayloadEnvelopeInputCache.
1114+
if (payloadEnvelopes) {
1115+
for (const [slot, envelope] of payloadEnvelopes) {
1116+
const blockRootHex = toRootHex(envelope.message.beaconBlockRoot);
1117+
const payloadInput = this.seenPayloadEnvelopeInputCache.get(blockRootHex);
1118+
if (!payloadInput || payloadInput.hasPayloadEnvelope()) continue;
1119+
1120+
payloadInput.addPayloadEnvelope({
1121+
envelope,
1122+
source: PayloadEnvelopeInputSource.byRange,
1123+
seenTimestampSec: Date.now() / 1000,
1124+
});
1125+
1126+
if (payloadInput.isComplete()) {
1127+
this.processExecutionPayload(payloadInput, {validSignature: false}).catch((e) => {
1128+
this.logger.debug("Error processing envelope from range sync", {slot, root: blockRootHex}, e as Error);
1129+
});
1130+
}
1131+
}
1132+
}
11051133
}
11061134

11071135
async processExecutionPayload(payloadInput: PayloadEnvelopeInput, opts?: ImportPayloadOpts): Promise<void> {

packages/beacon-node/src/chain/interface.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,11 @@ export interface IBeaconChain {
248248
/** Process a block until complete */
249249
processBlock(block: IBlockInput, opts?: ImportBlockOpts): Promise<void>;
250250
/** Process a chain of blocks until complete */
251-
processChainSegment(blocks: IBlockInput[], opts?: ImportBlockOpts): Promise<void>;
251+
processChainSegment(
252+
blocks: IBlockInput[],
253+
payloadEnvelopes: Map<Slot, gloas.SignedExecutionPayloadEnvelope> | null,
254+
opts?: ImportBlockOpts
255+
): Promise<void>;
252256

253257
/** Process execution payload envelope: verify, import to fork choice, and persist to DB */
254258
processExecutionPayload(payloadInput: PayloadEnvelopeInput, opts?: ImportPayloadOpts): Promise<void>;

packages/beacon-node/src/sync/range/batch.ts

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {ChainForkConfig} from "@lodestar/config";
2-
import {ForkName, isForkPostDeneb, isForkPostFulu} from "@lodestar/params";
3-
import {Epoch, RootHex, Slot, phase0} from "@lodestar/types";
2+
import {ForkName, isForkPostDeneb, isForkPostFulu, isForkPostGloas} from "@lodestar/params";
3+
import {Epoch, RootHex, Slot, gloas, phase0} from "@lodestar/types";
44
import {LodestarError} from "@lodestar/utils";
55
import {isBlockInputColumns} from "../../chain/blocks/blockInput/blockInput.js";
66
import {IBlockInput} from "../../chain/blocks/blockInput/types.js";
@@ -46,19 +46,21 @@ export type Attempt = {
4646
export type AwaitingDownloadState = {
4747
status: BatchStatus.AwaitingDownload;
4848
blocks: IBlockInput[];
49+
payloadEnvelopes: Map<Slot, gloas.SignedExecutionPayloadEnvelope> | null;
4950
};
5051

5152
export type DownloadSuccessState = {
5253
status: BatchStatus.AwaitingProcessing;
5354
blocks: IBlockInput[];
55+
payloadEnvelopes: Map<Slot, gloas.SignedExecutionPayloadEnvelope> | null;
5456
};
5557

5658
export type BatchState =
5759
| AwaitingDownloadState
58-
| {status: BatchStatus.Downloading; peer: PeerIdStr; blocks: IBlockInput[]}
60+
| {status: BatchStatus.Downloading; peer: PeerIdStr; blocks: IBlockInput[]; payloadEnvelopes: Map<Slot, gloas.SignedExecutionPayloadEnvelope> | null}
5961
| DownloadSuccessState
60-
| {status: BatchStatus.Processing; blocks: IBlockInput[]; attempt: Attempt}
61-
| {status: BatchStatus.AwaitingValidation; blocks: IBlockInput[]; attempt: Attempt};
62+
| {status: BatchStatus.Processing; blocks: IBlockInput[]; payloadEnvelopes: Map<Slot, gloas.SignedExecutionPayloadEnvelope> | null; attempt: Attempt}
63+
| {status: BatchStatus.AwaitingValidation; blocks: IBlockInput[]; payloadEnvelopes: Map<Slot, gloas.SignedExecutionPayloadEnvelope> | null; attempt: Attempt};
6264

6365
export type BatchMetadata = {
6466
startEpoch: Epoch;
@@ -85,7 +87,7 @@ export class Batch {
8587
/** Block, blob and column requests that are used to determine the best peer and are used in downloadByRange */
8688
requests: DownloadByRangeRequests;
8789
/** State of the batch. */
88-
state: BatchState = {status: BatchStatus.AwaitingDownload, blocks: []};
90+
state: BatchState = {status: BatchStatus.AwaitingDownload, blocks: [], payloadEnvelopes: null};
8991
/** Peers that provided good data */
9092
goodPeers: PeerIdStr[] = [];
9193
/** The `Attempts` that have been made and failed to send us this batch. */
@@ -129,6 +131,10 @@ export class Batch {
129131
count: this.count,
130132
step: 1,
131133
};
134+
const envelopesRequest: gloas.ExecutionPayloadEnvelopesByRangeRequest | undefined = isForkPostGloas(this.forkName)
135+
? {startSlot: this.startSlot, count: this.count}
136+
: undefined;
137+
132138
if (isForkPostFulu(this.forkName) && withinValidRequestWindow) {
133139
return {
134140
blocksRequest,
@@ -137,6 +143,7 @@ export class Batch {
137143
count: this.count,
138144
columns: this.custodyConfig.sampledColumns,
139145
},
146+
envelopesRequest,
140147
};
141148
}
142149
if (isForkPostDeneb(this.forkName) && withinValidRequestWindow) {
@@ -146,10 +153,12 @@ export class Batch {
146153
startSlot: this.startSlot,
147154
count: this.count,
148155
},
156+
envelopesRequest,
149157
};
150158
}
151159
return {
152160
blocksRequest,
161+
envelopesRequest,
153162
};
154163
}
155164

@@ -186,6 +195,18 @@ export class Batch {
186195
}
187196
}
188197

198+
// Track envelope start slot for post-Gloas forks
199+
let envelopeStartSlot = this.startSlot;
200+
if (isForkPostGloas(this.forkName)) {
201+
for (const blockInput of blocks) {
202+
const blockSlot = blockInput.slot;
203+
// Envelopes track separately - advance start slot for contiguous downloaded envelopes
204+
if (envelopeStartSlot === blockSlot) {
205+
envelopeStartSlot = blockSlot + 1;
206+
}
207+
}
208+
}
209+
189210
// if the blockStartSlot or dataStartSlot is after the desired endSlot then no request will be made for the batch
190211
// because it is complete
191212
const endSlot = this.startSlot + this.count - 1;
@@ -216,6 +237,13 @@ export class Batch {
216237
// dataSlot will still have a value but do not create a request for preDeneb forks
217238
}
218239

240+
if (isForkPostGloas(this.forkName) && envelopeStartSlot <= endSlot) {
241+
requests.envelopesRequest = {
242+
startSlot: envelopeStartSlot,
243+
count: endSlot - envelopeStartSlot + 1,
244+
};
245+
}
246+
219247
return requests;
220248
}
221249

@@ -263,6 +291,10 @@ export class Batch {
263291
return this.state.blocks;
264292
}
265293

294+
getPayloadEnvelopes(): Map<Slot, gloas.SignedExecutionPayloadEnvelope> | null {
295+
return this.state.payloadEnvelopes;
296+
}
297+
266298
/**
267299
* AwaitingDownload -> Downloading
268300
*/
@@ -271,13 +303,13 @@ export class Batch {
271303
throw new BatchError(this.wrongStatusErrorType(BatchStatus.AwaitingDownload));
272304
}
273305

274-
this.state = {status: BatchStatus.Downloading, peer, blocks: this.state.blocks};
306+
this.state = {status: BatchStatus.Downloading, peer, blocks: this.state.blocks, payloadEnvelopes: this.state.payloadEnvelopes};
275307
}
276308

277309
/**
278310
* Downloading -> AwaitingProcessing
279311
*/
280-
downloadingSuccess(peer: PeerIdStr, blocks: IBlockInput[]): DownloadSuccessState {
312+
downloadingSuccess(peer: PeerIdStr, blocks: IBlockInput[], payloadEnvelopes: Map<Slot, gloas.SignedExecutionPayloadEnvelope> | null): DownloadSuccessState {
281313
if (this.state.status !== BatchStatus.Downloading) {
282314
throw new BatchError(this.wrongStatusErrorType(BatchStatus.Downloading));
283315
}
@@ -305,11 +337,13 @@ export class Batch {
305337
status: this.state.status,
306338
});
307339
}
340+
const newPayloadEnvelopes = payloadEnvelopes ?? this.state.payloadEnvelopes;
341+
308342
if (allComplete) {
309-
this.state = {status: BatchStatus.AwaitingProcessing, blocks};
343+
this.state = {status: BatchStatus.AwaitingProcessing, blocks, payloadEnvelopes: newPayloadEnvelopes};
310344
} else {
311345
this.requests = this.getRequests(blocks);
312-
this.state = {status: BatchStatus.AwaitingDownload, blocks};
346+
this.state = {status: BatchStatus.AwaitingDownload, blocks, payloadEnvelopes: newPayloadEnvelopes};
313347
}
314348

315349
return this.state as DownloadSuccessState;
@@ -328,25 +362,26 @@ export class Batch {
328362
throw new BatchError(this.errorType({code: BatchErrorCode.MAX_DOWNLOAD_ATTEMPTS}));
329363
}
330364

331-
this.state = {status: BatchStatus.AwaitingDownload, blocks: this.state.blocks};
365+
this.state = {status: BatchStatus.AwaitingDownload, blocks: this.state.blocks, payloadEnvelopes: this.state.payloadEnvelopes};
332366
}
333367

334368
/**
335369
* AwaitingProcessing -> Processing
336370
*/
337-
startProcessing(): IBlockInput[] {
371+
startProcessing(): {blocks: IBlockInput[]; payloadEnvelopes: Map<Slot, gloas.SignedExecutionPayloadEnvelope> | null} {
338372
if (this.state.status !== BatchStatus.AwaitingProcessing) {
339373
throw new BatchError(this.wrongStatusErrorType(BatchStatus.AwaitingProcessing));
340374
}
341375

342376
const blocks = this.state.blocks;
377+
const payloadEnvelopes = this.state.payloadEnvelopes;
343378
const hash = hashBlocks(blocks, this.config); // tracks blocks to report peer on processing error
344379
// Reset goodPeers in case another download attempt needs to be made. When Attempt is successful or not the peers
345380
// that the data came from will be handled by the Attempt that goes for processing
346381
const peers = this.goodPeers;
347382
this.goodPeers = [];
348-
this.state = {status: BatchStatus.Processing, blocks, attempt: {peers, hash}};
349-
return blocks;
383+
this.state = {status: BatchStatus.Processing, blocks, payloadEnvelopes, attempt: {peers, hash}};
384+
return {blocks, payloadEnvelopes};
350385
}
351386

352387
/**
@@ -357,7 +392,7 @@ export class Batch {
357392
throw new BatchError(this.wrongStatusErrorType(BatchStatus.Processing));
358393
}
359394

360-
this.state = {status: BatchStatus.AwaitingValidation, blocks: this.state.blocks, attempt: this.state.attempt};
395+
this.state = {status: BatchStatus.AwaitingValidation, blocks: this.state.blocks, payloadEnvelopes: this.state.payloadEnvelopes, attempt: this.state.attempt};
361396
}
362397

363398
/**
@@ -408,7 +443,7 @@ export class Batch {
408443

409444
// remove any downloaded blocks and re-attempt
410445
// TODO(fulu): need to remove the bad blocks from the SeenBlockInputCache
411-
this.state = {status: BatchStatus.AwaitingDownload, blocks: []};
446+
this.state = {status: BatchStatus.AwaitingDownload, blocks: [], payloadEnvelopes: null};
412447
}
413448

414449
private onProcessingError(attempt: Attempt): void {
@@ -419,7 +454,7 @@ export class Batch {
419454

420455
// remove any downloaded blocks and re-attempt
421456
// TODO(fulu): need to remove the bad blocks from the SeenBlockInputCache
422-
this.state = {status: BatchStatus.AwaitingDownload, blocks: []};
457+
this.state = {status: BatchStatus.AwaitingDownload, blocks: [], payloadEnvelopes: null};
423458
}
424459

425460
/** Helper to construct typed BatchError. Stack traces are correct as the error is thrown above */

packages/beacon-node/src/sync/range/chain.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {ChainForkConfig} from "@lodestar/config";
2-
import {Epoch, Root, Slot} from "@lodestar/types";
2+
import {Epoch, Root, Slot, gloas} from "@lodestar/types";
33
import {ErrorAborted, LodestarError, Logger, toRootHex} from "@lodestar/utils";
44
import {isBlockInputBlobs, isBlockInputColumns} from "../../chain/blocks/blockInput/blockInput.js";
55
import {BlockInputErrorCode} from "../../chain/blocks/blockInput/errors.js";
@@ -44,13 +44,19 @@ export type SyncChainFns = {
4444
* Must return if ALL blocks are processed successfully
4545
* If SOME blocks are processed must throw BlockProcessorError()
4646
*/
47-
processChainSegment: (blocks: IBlockInput[], syncType: RangeSyncType) => Promise<void>;
47+
processChainSegment: (
48+
blocks: IBlockInput[],
49+
payloadEnvelopes: Map<Slot, gloas.SignedExecutionPayloadEnvelope> | null,
50+
syncType: RangeSyncType
51+
) => Promise<void>;
4852
/** Must download blocks, and validate their range */
4953
downloadByRange: (
5054
peer: PeerSyncMeta,
5155
batch: Batch,
5256
syncType: RangeSyncType
53-
) => Promise<WarnResult<IBlockInput[], DownloadByRangeError>>;
57+
) => Promise<
58+
WarnResult<{blocks: IBlockInput[]; payloadEnvelopes: Map<Slot, gloas.SignedExecutionPayloadEnvelope> | null}, DownloadByRangeError>
59+
>;
5460
/** Report peer for negative actions. Decouples from the full network instance */
5561
reportPeer: (peer: PeerIdStr, action: PeerAction, actionName: string) => void;
5662
/** Gets current peer custodyColumns and earliestAvailableSlot */
@@ -516,7 +522,8 @@ export class SyncChain {
516522
});
517523
this.metrics?.syncRange.downloadByRange.success.inc();
518524
const {warnings, result} = res.result;
519-
const downloadSuccessOutput = batch.downloadingSuccess(peer.peerId, result);
525+
const {blocks: downloadedBlocks, payloadEnvelopes} = result;
526+
const downloadSuccessOutput = batch.downloadingSuccess(peer.peerId, downloadedBlocks, payloadEnvelopes);
520527
const logMeta: Record<string, number> = {
521528
blockCount: downloadSuccessOutput.blocks.length,
522529
};
@@ -578,10 +585,10 @@ export class SyncChain {
578585
* Sends `batch` to the processor. Note: batch may be empty
579586
*/
580587
private async processBatch(batch: Batch): Promise<void> {
581-
const blocks = batch.startProcessing();
588+
const {blocks, payloadEnvelopes} = batch.startProcessing();
582589

583590
// wrapError ensures to never call both batch success() and batch error()
584-
const res = await wrapError(this.processChainSegment(blocks, this.syncType));
591+
const res = await wrapError(this.processChainSegment(blocks, payloadEnvelopes, this.syncType));
585592

586593
if (!res.err) {
587594
batch.processingSuccess();

packages/beacon-node/src/sync/range/range.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,11 @@ export class RangeSync extends (EventEmitter as {new (): RangeSyncEmitter}) {
172172
}
173173

174174
/** Convenience method for `SyncChain` */
175-
private processChainSegment: SyncChainFns["processChainSegment"] = async (blocks, syncType) => {
175+
private processChainSegment: SyncChainFns["processChainSegment"] = async (
176+
blocks,
177+
payloadEnvelopes,
178+
syncType
179+
) => {
176180
// Not trusted, verify signatures
177181
const flags: ImportBlockOpts = {
178182
// 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}) {
194198
// Should only be used for debugging or testing
195199
for (const block of blocks) await this.chain.processBlock(block, flags);
196200
} else {
197-
await this.chain.processChainSegment(blocks, flags);
201+
await this.chain.processChainSegment(blocks, payloadEnvelopes, flags);
198202
}
199203
};
200204

@@ -209,13 +213,14 @@ export class RangeSync extends (EventEmitter as {new (): RangeSyncEmitter}) {
209213
peerDasMetrics: this.chain.metrics?.peerDas,
210214
...batch.getRequestsForPeer(peer),
211215
});
212-
const cached = cacheByRangeResponses({
216+
const {responses, payloadEnvelopes} = result;
217+
const blocks = cacheByRangeResponses({
213218
cache: this.chain.seenBlockInputCache,
214219
peerIdStr: peer.peerId,
215-
responses: result,
220+
responses,
216221
batchBlocks,
217222
});
218-
return {result: cached, warnings};
223+
return {result: {blocks, payloadEnvelopes}, warnings};
219224
};
220225

221226
private pruneBlockInputs: SyncChainFns["pruneBlockInputs"] = (blocks: IBlockInput[]) => {

0 commit comments

Comments
 (0)