@lodestar/beacon-node 1.43.0-dev.87cbe69c66 → 1.43.0-dev.899087b3b2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/lib/chain/blocks/blockInput/blockInput.d.ts +3 -0
  2. package/lib/chain/blocks/blockInput/blockInput.d.ts.map +1 -1
  3. package/lib/chain/blocks/blockInput/blockInput.js +4 -1
  4. package/lib/chain/blocks/blockInput/blockInput.js.map +1 -1
  5. package/lib/chain/blocks/importBlock.d.ts.map +1 -1
  6. package/lib/chain/blocks/importBlock.js +0 -16
  7. package/lib/chain/blocks/importBlock.js.map +1 -1
  8. package/lib/chain/blocks/importExecutionPayload.d.ts +4 -0
  9. package/lib/chain/blocks/importExecutionPayload.d.ts.map +1 -1
  10. package/lib/chain/blocks/importExecutionPayload.js +12 -1
  11. package/lib/chain/blocks/importExecutionPayload.js.map +1 -1
  12. package/lib/chain/blocks/index.d.ts.map +1 -1
  13. package/lib/chain/blocks/index.js +6 -7
  14. package/lib/chain/blocks/index.js.map +1 -1
  15. package/lib/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.d.ts +11 -1
  16. package/lib/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.d.ts.map +1 -1
  17. package/lib/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.js +23 -1
  18. package/lib/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.js.map +1 -1
  19. package/lib/chain/blocks/payloadEnvelopeInput/types.d.ts +16 -0
  20. package/lib/chain/blocks/payloadEnvelopeInput/types.d.ts.map +1 -1
  21. package/lib/chain/blocks/verifyExecutionPayloadEnvelope.d.ts.map +1 -1
  22. package/lib/chain/blocks/verifyExecutionPayloadEnvelope.js +5 -4
  23. package/lib/chain/blocks/verifyExecutionPayloadEnvelope.js.map +1 -1
  24. package/lib/chain/chain.d.ts.map +1 -1
  25. package/lib/chain/chain.js +25 -10
  26. package/lib/chain/chain.js.map +1 -1
  27. package/lib/chain/initState.d.ts.map +1 -1
  28. package/lib/chain/initState.js +6 -1
  29. package/lib/chain/initState.js.map +1 -1
  30. package/lib/chain/prepareNextSlot.js +1 -1
  31. package/lib/chain/prepareNextSlot.js.map +1 -1
  32. package/lib/chain/seenCache/seenPayloadEnvelopeInput.d.ts +14 -10
  33. package/lib/chain/seenCache/seenPayloadEnvelopeInput.d.ts.map +1 -1
  34. package/lib/chain/seenCache/seenPayloadEnvelopeInput.js +51 -17
  35. package/lib/chain/seenCache/seenPayloadEnvelopeInput.js.map +1 -1
  36. package/lib/chain/seenCache/seenProposerPreferences.d.ts +1 -1
  37. package/lib/chain/seenCache/seenProposerPreferences.d.ts.map +1 -1
  38. package/lib/chain/seenCache/seenProposerPreferences.js +6 -11
  39. package/lib/chain/seenCache/seenProposerPreferences.js.map +1 -1
  40. package/lib/network/processor/gossipHandlers.d.ts.map +1 -1
  41. package/lib/network/processor/gossipHandlers.js +14 -0
  42. package/lib/network/processor/gossipHandlers.js.map +1 -1
  43. package/lib/sync/constants.d.ts +3 -1
  44. package/lib/sync/constants.d.ts.map +1 -1
  45. package/lib/sync/constants.js +3 -4
  46. package/lib/sync/constants.js.map +1 -1
  47. package/lib/sync/range/batch.d.ts +12 -3
  48. package/lib/sync/range/batch.d.ts.map +1 -1
  49. package/lib/sync/range/batch.js +163 -33
  50. package/lib/sync/range/batch.js.map +1 -1
  51. package/lib/sync/range/chain.d.ts +13 -2
  52. package/lib/sync/range/chain.d.ts.map +1 -1
  53. package/lib/sync/range/chain.js +38 -4
  54. package/lib/sync/range/chain.js.map +1 -1
  55. package/lib/sync/range/range.d.ts.map +1 -1
  56. package/lib/sync/range/range.js +14 -3
  57. package/lib/sync/range/range.js.map +1 -1
  58. package/lib/sync/sync.d.ts.map +1 -1
  59. package/lib/sync/sync.js +13 -0
  60. package/lib/sync/sync.js.map +1 -1
  61. package/lib/sync/unknownBlock.d.ts +7 -0
  62. package/lib/sync/unknownBlock.d.ts.map +1 -1
  63. package/lib/sync/unknownBlock.js +138 -10
  64. package/lib/sync/unknownBlock.js.map +1 -1
  65. package/lib/sync/utils/downloadByRange.d.ts +29 -8
  66. package/lib/sync/utils/downloadByRange.d.ts.map +1 -1
  67. package/lib/sync/utils/downloadByRange.js +87 -42
  68. package/lib/sync/utils/downloadByRange.js.map +1 -1
  69. package/package.json +16 -16
  70. package/src/chain/blocks/blockInput/blockInput.ts +4 -1
  71. package/src/chain/blocks/importBlock.ts +0 -18
  72. package/src/chain/blocks/importExecutionPayload.ts +18 -6
  73. package/src/chain/blocks/index.ts +5 -7
  74. package/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts +31 -2
  75. package/src/chain/blocks/payloadEnvelopeInput/types.ts +17 -0
  76. package/src/chain/blocks/verifyExecutionPayloadEnvelope.ts +7 -4
  77. package/src/chain/chain.ts +29 -9
  78. package/src/chain/initState.ts +9 -1
  79. package/src/chain/prepareNextSlot.ts +1 -1
  80. package/src/chain/seenCache/seenPayloadEnvelopeInput.ts +66 -20
  81. package/src/chain/seenCache/seenProposerPreferences.ts +7 -12
  82. package/src/network/processor/gossipHandlers.ts +18 -0
  83. package/src/sync/constants.ts +4 -4
  84. package/src/sync/range/batch.ts +187 -38
  85. package/src/sync/range/chain.ts +52 -5
  86. package/src/sync/range/range.ts +16 -3
  87. package/src/sync/sync.ts +13 -1
  88. package/src/sync/unknownBlock.ts +170 -10
  89. package/src/sync/utils/downloadByRange.ts +148 -44
@@ -5,16 +5,16 @@ import {MapDef} from "@lodestar/utils";
5
5
  * Tracks signed proposer preferences we've already seen per (dependent_root, proposal_slot, validator_index).
6
6
  */
7
7
  export class SeenProposerPreferences {
8
- private readonly validatorBySlotByDependentRoot = new MapDef<RootHex, Map<Slot, ValidatorIndex>>(
9
- () => new Map<Slot, ValidatorIndex>()
8
+ private readonly validatorByDependentRootBySlot = new MapDef<Slot, Map<RootHex, ValidatorIndex>>(
9
+ () => new Map<RootHex, ValidatorIndex>()
10
10
  );
11
11
 
12
12
  isKnown(dependentRoot: RootHex, proposalSlot: Slot, validatorIndex: ValidatorIndex): boolean {
13
- return this.validatorBySlotByDependentRoot.get(dependentRoot)?.get(proposalSlot) === validatorIndex;
13
+ return this.validatorByDependentRootBySlot.get(proposalSlot)?.get(dependentRoot) === validatorIndex;
14
14
  }
15
15
 
16
16
  add(dependentRoot: RootHex, proposalSlot: Slot, validatorIndex: ValidatorIndex): void {
17
- this.validatorBySlotByDependentRoot.getOrDefault(dependentRoot).set(proposalSlot, validatorIndex);
17
+ this.validatorByDependentRootBySlot.getOrDefault(proposalSlot).set(dependentRoot, validatorIndex);
18
18
  }
19
19
 
20
20
  /**
@@ -23,14 +23,9 @@ export class SeenProposerPreferences {
23
23
  * on each slot tick.
24
24
  */
25
25
  prune(currentSlot: Slot): void {
26
- for (const [dependentRoot, slotMap] of this.validatorBySlotByDependentRoot.entries()) {
27
- for (const slot of slotMap.keys()) {
28
- if (slot < currentSlot) {
29
- slotMap.delete(slot);
30
- }
31
- }
32
- if (slotMap.size === 0) {
33
- this.validatorBySlotByDependentRoot.delete(dependentRoot);
26
+ for (const slot of this.validatorByDependentRootBySlot.keys()) {
27
+ if (slot < currentSlot) {
28
+ this.validatorByDependentRootBySlot.delete(slot);
34
29
  }
35
30
  }
36
31
  }
@@ -607,6 +607,24 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand
607
607
  // Returns the delay between the start of `block.slot` and `current time`
608
608
  const delaySec = chain.clock.secFromSlot(slot);
609
609
  metrics?.gossipBlock.elapsedTimeTillProcessed.observe(delaySec);
610
+
611
+ if (isForkPostGloas(blockInput.forkName)) {
612
+ const payloadInput = chain.seenPayloadEnvelopeInputCache.get(blockInput.blockRootHex);
613
+ // This payloadInput should have been created just after gossip validation
614
+ if (!payloadInput) {
615
+ throw Error(
616
+ `PayloadEnvelopeInput not seeded for block ${blockInput.blockRootHex} during gossip processing`
617
+ );
618
+ }
619
+
620
+ // Immediately attempt fetch of data columns from execution engine as the bid contains kzg commitments
621
+ // which is all the information we need so there is no reason to delay until execution payload arrives
622
+ // TODO GLOAS: If we want EL retries after this initial attempt, add an explicit retry policy here
623
+ // (for example later in the slot). Do not couple retries to incoming gossip columns.
624
+ // Columns fetched here feed payloadInput.addColumn, which resolves waitForAllData for any
625
+ // in-flight importExecutionPayload. No processExecutionPayload trigger needed from this path.
626
+ chain.getBlobsTracker.triggerGetBlobs(payloadInput);
627
+ }
610
628
  })
611
629
  .catch((e) => {
612
630
  // Adjust verbosity based on error type
@@ -5,10 +5,10 @@ export const PARALLEL_HEAD_CHAINS = 2;
5
5
  export const MIN_FINALIZED_CHAIN_VALIDATED_EPOCHS = 10;
6
6
 
7
7
  /** The number of times to retry a batch before it is considered failed. */
8
- // export const MAX_BATCH_DOWNLOAD_ATTEMPTS = 5;
9
- // this constant is increased a lot for peerDAS because we may have many failed download due to rate limit not implemented yet
10
- // TODO: change it back to 5 when this issue is implemented https://github.com/ChainSafe/lodestar/issues/8033
11
- export const MAX_BATCH_DOWNLOAD_ATTEMPTS = 20;
8
+ export const MAX_BATCH_DOWNLOAD_ATTEMPTS = 5;
9
+
10
+ /** Backoff before assigning more range-sync batches to a peer that rate-limited us. */
11
+ export const RATE_LIMITED_PEER_BACKOFF_MS = 5_000;
12
12
 
13
13
  /**
14
14
  * Consider batch faulty after downloading and processing this number of times
@@ -1,18 +1,19 @@
1
1
  import {ChainForkConfig} from "@lodestar/config";
2
2
  import {ForkName, isForkPostDeneb, isForkPostFulu, isForkPostGloas} from "@lodestar/params";
3
- import {Epoch, RootHex, Slot, phase0} from "@lodestar/types";
4
- import {LodestarError, prettyPrintIndices} from "@lodestar/utils";
3
+ import {Epoch, RootHex, SignedBeaconBlock, Slot, gloas, phase0} from "@lodestar/types";
4
+ import {LodestarError, byteArrayEquals, prettyPrintIndices, toRootHex} from "@lodestar/utils";
5
5
  import {isBlockInputColumns} from "../../chain/blocks/blockInput/blockInput.js";
6
6
  import {IBlockInput} from "../../chain/blocks/blockInput/types.js";
7
7
  import {isDaOutOfRange} from "../../chain/blocks/blockInput/utils.js";
8
8
  import {PayloadEnvelopeInput} from "../../chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.js";
9
9
  import {BlockError, BlockErrorCode} from "../../chain/errors/index.js";
10
+ import {ZERO_HASH} from "../../constants/constants.js";
10
11
  import {PeerSyncMeta} from "../../network/peers/peersData.js";
11
12
  import {IClock} from "../../util/clock.js";
12
13
  import {CustodyConfig} from "../../util/dataColumns.js";
13
14
  import {PeerIdStr} from "../../util/peerId.js";
14
15
  import {MAX_BATCH_DOWNLOAD_ATTEMPTS, MAX_BATCH_PROCESSING_ATTEMPTS} from "../constants.js";
15
- import {DownloadByRangeRequests} from "../utils/downloadByRange.js";
16
+ import {DownloadByRangeRequests, ParentPayloadCommitments} from "../utils/downloadByRange.js";
16
17
  import {getBatchSlotRange, hashBlocks} from "./utils/index.js";
17
18
 
18
19
  /**
@@ -141,8 +142,18 @@ export class Batch {
141
142
  private readonly config: ChainForkConfig;
142
143
  private readonly clock: IClock;
143
144
  private readonly custodyConfig: CustodyConfig;
144
-
145
- constructor(startEpoch: Epoch, config: ChainForkConfig, clock: IClock, custodyConfig: CustodyConfig) {
145
+ private readonly isFirstBatchInChain: boolean;
146
+ private readonly latestBid: gloas.ExecutionPayloadBid | undefined;
147
+
148
+ constructor(
149
+ startEpoch: Epoch,
150
+ config: ChainForkConfig,
151
+ clock: IClock,
152
+ custodyConfig: CustodyConfig,
153
+ isFirstBatchInChain: boolean,
154
+ latestBid: gloas.ExecutionPayloadBid | undefined,
155
+ targetSlot: Slot
156
+ ) {
146
157
  this.config = config;
147
158
  this.clock = clock;
148
159
  this.custodyConfig = custodyConfig;
@@ -151,10 +162,40 @@ export class Batch {
151
162
  this.forkName = this.config.getForkName(startSlot);
152
163
  this.startEpoch = startEpoch;
153
164
  this.startSlot = startSlot;
154
- this.count = count;
165
+ this.count = Math.min(count, targetSlot - startSlot + 1);
166
+ this.isFirstBatchInChain = isFirstBatchInChain;
167
+ this.latestBid = latestBid;
155
168
  this.requests = this.getRequests([]);
156
169
  }
157
170
 
171
+ private shouldDownloadParentEnvelope(firstBlock?: SignedBeaconBlock): boolean {
172
+ if (!this.isFirstBatchInChain) return false;
173
+
174
+ if (this.startSlot === 0 || !isForkPostGloas(this.config.getForkName(this.startSlot - 1))) {
175
+ return false;
176
+ }
177
+
178
+ // we only know if we should download parent envelope if firstBlock is downloaded
179
+ if (firstBlock === undefined) return false;
180
+ if (this.latestBid === undefined) return false;
181
+ const firstBlockBidParentHash = (firstBlock.message.body as gloas.BeaconBlockBody).signedExecutionPayloadBid.message
182
+ .parentBlockHash;
183
+ return byteArrayEquals(firstBlockBidParentHash, this.latestBid.blockHash);
184
+ }
185
+
186
+ getParentPayloadCommitments(parentBlockRoot: Uint8Array): ParentPayloadCommitments {
187
+ if (this.latestBid === undefined) {
188
+ throw new Error(
189
+ `Coding error: getParentPayloadCommitments called without latestBid for parentBlockRoot=${toRootHex(parentBlockRoot)}`
190
+ );
191
+ }
192
+ return {
193
+ blockRoot: parentBlockRoot,
194
+ blockRootHex: toRootHex(parentBlockRoot),
195
+ kzgCommitments: this.latestBid.blobKzgCommitments,
196
+ };
197
+ }
198
+
158
199
  /**
159
200
  * Builds ByRange requests for block, blobs and columns
160
201
  */
@@ -202,6 +243,7 @@ export class Batch {
202
243
  const envelopesBySlot = this.state.payloadEnvelopes ?? new Map<Slot, PayloadEnvelopeInput>();
203
244
 
204
245
  // ensure blocks are in slot-wise order
246
+ const isPostGloas = isForkPostGloas(this.forkName);
205
247
  for (const blockInput of blocks) {
206
248
  const blockSlot = blockInput.slot;
207
249
  // check if block/data is present (hasBlock/hasAllData). If present then check if startSlot is the same as
@@ -217,21 +259,36 @@ export class Batch {
217
259
  if (blockInput.hasBlock() && blockStartSlot === blockSlot) {
218
260
  blockStartSlot = blockSlot + 1;
219
261
  }
220
- if (
221
- blockInput.hasBlock() &&
222
- envelopeStartSlot === blockSlot &&
223
- envelopesBySlot.get(blockSlot)?.hasPayloadEnvelope()
224
- ) {
225
- envelopeStartSlot = blockSlot + 1;
226
- }
227
- if (!blockInput.hasAllData()) {
228
- if (isBlockInputColumns(blockInput)) {
229
- for (const index of blockInput.getMissingSampledColumnMeta().missing) {
262
+
263
+ // Range sync uses hasComputedAllData (all sampled columns physically present), not hasAllData
264
+ // which flips at the reconstruction threshold. Sync never triggers reconstruction, so accepting
265
+ // a half-downloaded block here makes writeBlockInputToDb later block on waitForComputedAllData.
266
+ if (isPostGloas) {
267
+ // Post-Gloas: column data lives on PayloadEnvelopeInput, not on BlockInputNoData.
268
+ const payloadInput = envelopesBySlot.get(blockSlot);
269
+ if (blockInput.hasBlock() && envelopeStartSlot === blockSlot && payloadInput?.hasPayloadEnvelope()) {
270
+ envelopeStartSlot = blockSlot + 1;
271
+ }
272
+ if (payloadInput && !payloadInput.hasComputedAllData()) {
273
+ for (const index of payloadInput.getMissingSampledColumnMeta().missing) {
230
274
  neededColumns.add(index);
231
275
  }
276
+ } else if (payloadInput?.hasComputedAllData() && dataStartSlot === blockSlot) {
277
+ // Only advance dataStartSlot when we know columns for this slot are complete. If
278
+ // payloadInput is missing entirely we cannot tell, so stop here so the next round
279
+ // re-requests columns (and envelopes) starting at this slot.
280
+ dataStartSlot = blockSlot + 1;
281
+ }
282
+ } else {
283
+ if (isBlockInputColumns(blockInput) ? !blockInput.hasComputedAllData() : !blockInput.hasAllData()) {
284
+ if (isBlockInputColumns(blockInput)) {
285
+ for (const index of blockInput.getMissingSampledColumnMeta().missing) {
286
+ neededColumns.add(index);
287
+ }
288
+ }
289
+ } else if (dataStartSlot === blockSlot) {
290
+ dataStartSlot = blockSlot + 1;
232
291
  }
233
- } else if (dataStartSlot === blockSlot) {
234
- dataStartSlot = blockSlot + 1;
235
292
  }
236
293
  }
237
294
 
@@ -251,11 +308,15 @@ export class Batch {
251
308
  // range of 40 - 63, startSlot will be inclusive but subtraction will exclusive so need to + 1
252
309
  const count = endSlot - dataStartSlot + 1;
253
310
  if (isForkPostFulu(this.forkName) && withinValidRequestWindow) {
254
- requests.columnsRequest = {
255
- count,
256
- startSlot: dataStartSlot,
257
- columns: Array.from(neededColumns),
258
- };
311
+ // Skip the column re-request when we have no specific column indices outstanding.
312
+ // Peer rejects an empty `columns` list
313
+ if (neededColumns.size > 0) {
314
+ requests.columnsRequest = {
315
+ count,
316
+ startSlot: dataStartSlot,
317
+ columns: Array.from(neededColumns),
318
+ };
319
+ }
259
320
  } else if (isForkPostDeneb(this.forkName) && withinValidRequestWindow) {
260
321
  requests.blobsRequest = {
261
322
  count,
@@ -272,6 +333,36 @@ export class Batch {
272
333
  };
273
334
  }
274
335
 
336
+ // Only the first batch of a SyncChain may need the dangling-parent payload by-root.
337
+ if (blocks.length > 0 && this.shouldDownloadParentEnvelope(blocks[0].getBlock())) {
338
+ // shouldDownloadParentEnvelope() = true means there are at least 1 block
339
+ const parentRoot = blocks[0].getBlock().message.parentRoot;
340
+ if (!byteArrayEquals(parentRoot, ZERO_HASH)) {
341
+ const parentRootHex = toRootHex(parentRoot);
342
+ let parentPayloadInput: PayloadEnvelopeInput | undefined;
343
+ if (this.state.payloadEnvelopes) {
344
+ for (const pi of this.state.payloadEnvelopes.values()) {
345
+ if (pi.blockRootHex === parentRootHex) {
346
+ parentPayloadInput = pi;
347
+ break;
348
+ }
349
+ }
350
+ }
351
+
352
+ const needsEnvelope = !parentPayloadInput?.hasPayloadEnvelope();
353
+ const missingColumns = parentPayloadInput
354
+ ? parentPayloadInput.getMissingSampledColumnMeta().missing
355
+ : this.custodyConfig.sampledColumns;
356
+
357
+ if (needsEnvelope || missingColumns.length > 0) {
358
+ requests.parentPayloadRequest = {
359
+ ...(needsEnvelope ? {envelopeBlockRoot: parentRoot} : {}),
360
+ ...(missingColumns.length > 0 ? {blockRoot: parentRoot, columns: missingColumns} : {}),
361
+ };
362
+ }
363
+ }
364
+ }
365
+
275
366
  return requests;
276
367
  }
277
368
 
@@ -283,24 +374,28 @@ export class Batch {
283
374
  return this.requests;
284
375
  }
285
376
 
286
- // post-fulu we need to ensure that we only request columns that the peer has advertised
287
- const {columnsRequest} = this.requests;
288
- if (columnsRequest == null) {
289
- return this.requests;
290
- }
377
+ // post-fulu we need to ensure that we only request columns that the peer has advertised.
378
+ const {columnsRequest, parentPayloadRequest} = this.requests;
291
379
 
292
380
  const peerColumns = new Set(peer.custodyColumns ?? []);
293
- const requestedColumns = columnsRequest.columns.filter((c) => peerColumns.has(c));
294
- if (requestedColumns.length === columnsRequest.columns.length) {
295
- return this.requests;
296
- }
381
+ const filteredColumnsRequest =
382
+ columnsRequest != null ? columnsRequest.columns.filter((c) => peerColumns.has(c)) : null;
383
+ const parentColumns = parentPayloadRequest?.columns;
384
+ const filteredParentColumns = parentColumns != null ? parentColumns.filter((c) => peerColumns.has(c)) : null;
385
+
386
+ const updatedColumnRequest =
387
+ columnsRequest != null && filteredColumnsRequest != null
388
+ ? {columnsRequest: {...columnsRequest, columns: filteredColumnsRequest}}
389
+ : {};
390
+ const updatedParentPayloadRequest =
391
+ parentPayloadRequest != null && filteredParentColumns != null
392
+ ? {parentPayloadRequest: {...parentPayloadRequest, columns: filteredParentColumns}}
393
+ : {};
297
394
 
298
395
  return {
299
396
  ...this.requests,
300
- columnsRequest: {
301
- ...columnsRequest,
302
- columns: requestedColumns,
303
- },
397
+ ...updatedColumnRequest,
398
+ ...updatedParentPayloadRequest,
304
399
  };
305
400
  }
306
401
 
@@ -379,7 +474,11 @@ export class Batch {
379
474
  const slots = new Set<number>();
380
475
  for (const block of blocks) {
381
476
  slots.add(block.slot);
382
- if (!block.hasBlockAndAllData()) {
477
+ const dataComplete = isBlockInputColumns(block)
478
+ ? // by_range needs to download all columns
479
+ block.hasBlock() && block.hasComputedAllData()
480
+ : block.hasBlockAndAllData();
481
+ if (!dataComplete) {
383
482
  allComplete = false;
384
483
  }
385
484
  }
@@ -395,11 +494,45 @@ export class Batch {
395
494
  }
396
495
  const newPayloadEnvelopes = payloadEnvelopes ?? this.state.payloadEnvelopes;
397
496
 
497
+ if (allComplete && isForkPostGloas(this.forkName)) {
498
+ for (const block of blocks) {
499
+ const payloadInput = newPayloadEnvelopes?.get(block.slot);
500
+ // by_range needs every block's envelope and all sampled columns.
501
+ if (!payloadInput?.hasPayloadEnvelope() || !payloadInput.hasComputedAllData()) {
502
+ allComplete = false;
503
+ break;
504
+ }
505
+ }
506
+ }
507
+
508
+ // First batch of a sync chain must additionally have the dangling-parent payload fully
509
+ // present, otherwise `processBlocks` will throw PARENT_PAYLOAD_UNKNOWN. The parent's
510
+ // `PayloadEnvelopeInput` is identified by `blockRootHex` matching `blocks[0].parentRoot`.
511
+ if (allComplete && blocks.length > 0 && this.shouldDownloadParentEnvelope(blocks[0].getBlock())) {
512
+ const parentRoot = blocks[0].getBlock().message.parentRoot;
513
+ // Genesis has no parent payload — nothing to wait for.
514
+ if (!byteArrayEquals(parentRoot, ZERO_HASH)) {
515
+ const parentRootHex = toRootHex(parentRoot);
516
+ let parentPayloadComplete = false;
517
+ if (newPayloadEnvelopes) {
518
+ for (const payloadInput of newPayloadEnvelopes.values()) {
519
+ if (payloadInput.blockRootHex === parentRootHex) {
520
+ parentPayloadComplete = payloadInput.hasPayloadEnvelope() && payloadInput.hasComputedAllData();
521
+ break;
522
+ }
523
+ }
524
+ }
525
+ if (!parentPayloadComplete) {
526
+ allComplete = false;
527
+ }
528
+ }
529
+ }
530
+
398
531
  if (allComplete) {
399
532
  this.state = {status: BatchStatus.AwaitingProcessing, blocks, payloadEnvelopes: newPayloadEnvelopes};
400
533
  } else {
401
- this.requests = this.getRequests(blocks);
402
534
  this.state = {status: BatchStatus.AwaitingDownload, blocks, payloadEnvelopes: newPayloadEnvelopes};
535
+ this.requests = this.getRequests(blocks);
403
536
  }
404
537
 
405
538
  return this.state as DownloadSuccessState;
@@ -425,6 +558,22 @@ export class Batch {
425
558
  };
426
559
  }
427
560
 
561
+ /**
562
+ * Downloading -> AwaitingDownload (without counting as a failed attempt).
563
+ * Used when the peer rate-limited us — the request was never actually served.
564
+ */
565
+ downloadingRateLimited(): void {
566
+ if (this.state.status !== BatchStatus.Downloading) {
567
+ throw new BatchError(this.wrongStatusErrorType(BatchStatus.Downloading));
568
+ }
569
+
570
+ this.state = {
571
+ status: BatchStatus.AwaitingDownload,
572
+ blocks: this.state.blocks,
573
+ payloadEnvelopes: this.state.payloadEnvelopes,
574
+ };
575
+ }
576
+
428
577
  /**
429
578
  * AwaitingProcessing -> Processing
430
579
  */
@@ -1,5 +1,6 @@
1
1
  import {ChainForkConfig} from "@lodestar/config";
2
- import {Epoch, Root, Slot} from "@lodestar/types";
2
+ import {RequestErrorCode} from "@lodestar/reqresp";
3
+ import {Epoch, Root, Slot, gloas} from "@lodestar/types";
3
4
  import {ErrorAborted, LodestarError, Logger, prettyPrintIndices, toRootHex} from "@lodestar/utils";
4
5
  import {isBlockInputBlobs, isBlockInputColumns} from "../../chain/blocks/blockInput/blockInput.js";
5
6
  import {BlockInputErrorCode} from "../../chain/blocks/blockInput/errors.js";
@@ -15,7 +16,12 @@ import {CustodyConfig} from "../../util/dataColumns.js";
15
16
  import {ItTrigger} from "../../util/itTrigger.js";
16
17
  import {PeerIdStr} from "../../util/peerId.js";
17
18
  import {WarnResult, wrapError} from "../../util/wrapError.js";
18
- import {BATCH_BUFFER_SIZE, EPOCHS_PER_BATCH, MAX_LOOK_AHEAD_EPOCHS} from "../constants.js";
19
+ import {
20
+ BATCH_BUFFER_SIZE,
21
+ EPOCHS_PER_BATCH,
22
+ MAX_LOOK_AHEAD_EPOCHS,
23
+ RATE_LIMITED_PEER_BACKOFF_MS,
24
+ } from "../constants.js";
19
25
  import {DownloadByRangeError, DownloadByRangeErrorCode} from "../utils/downloadByRange.js";
20
26
  import {RangeSyncType} from "../utils/remoteSyncType.js";
21
27
  import {Batch, BatchError, BatchErrorCode, BatchMetadata, BatchStatus} from "./batch.js";
@@ -139,20 +145,32 @@ export class SyncChain {
139
145
  private readonly batchProcessor = new ItTrigger();
140
146
  /** Sorted map of batches undergoing some kind of processing. */
141
147
  private readonly batches = new Map<Epoch, Batch>();
148
+ /**
149
+ * `true` until the first `Batch` is constructed via `includeNextBatch`
150
+ */
151
+ private isFirstBatch = true;
142
152
  private readonly peerset = new Map<PeerIdStr, ChainTarget>();
153
+ /**
154
+ * Tracks peers that have rate-limited us, mapped to the timestamp (ms) until which we should avoid them.
155
+ * This is a sync-layer optimization to avoid assigning batches to backed-off peers.
156
+ * The reqresp SelfRateLimiter independently enforces backoff at the protocol level as a safety net.
157
+ */
158
+ private readonly rateLimitedPeers = new Map<PeerIdStr, number>();
143
159
 
144
160
  private readonly logger: Logger;
145
161
  private readonly config: ChainForkConfig;
146
162
  private readonly clock: IClock;
147
163
  private readonly metrics: Metrics | null;
148
164
  private readonly custodyConfig: CustodyConfig;
165
+ private readonly latestBid: gloas.ExecutionPayloadBid | undefined;
149
166
 
150
167
  constructor(
151
168
  initialBatchEpoch: Epoch,
152
169
  initialTarget: ChainTarget,
153
170
  syncType: RangeSyncType,
154
171
  fns: SyncChainFns,
155
- modules: SyncChainModules
172
+ modules: SyncChainModules,
173
+ latestBid: gloas.ExecutionPayloadBid | undefined
156
174
  ) {
157
175
  const {config, clock, custodyConfig, logger, metrics} = modules;
158
176
  this.firstBatchEpoch = initialBatchEpoch;
@@ -168,6 +186,7 @@ export class SyncChain {
168
186
  this.clock = clock;
169
187
  this.metrics = metrics;
170
188
  this.custodyConfig = custodyConfig;
189
+ this.latestBid = latestBid;
171
190
  this.logger = logger;
172
191
  this.logId = `${syncType}-${nextChainId++}`;
173
192
 
@@ -248,6 +267,7 @@ export class SyncChain {
248
267
  */
249
268
  removePeer(peerId: PeerIdStr): boolean {
250
269
  const deleted = this.peerset.delete(peerId);
270
+ this.rateLimitedPeers.delete(peerId);
251
271
  this.computeTarget();
252
272
  return deleted;
253
273
  }
@@ -383,8 +403,18 @@ export class SyncChain {
383
403
  return;
384
404
  }
385
405
 
406
+ const now = Date.now();
386
407
  const peersSyncInfo: PeerSyncInfo[] = [];
387
408
  for (const [peerId, target] of this.peerset.entries()) {
409
+ // Skip peers that are currently in rate-limit backoff
410
+ const rateLimitedUntil = this.rateLimitedPeers.get(peerId);
411
+ if (rateLimitedUntil !== undefined) {
412
+ if (now < rateLimitedUntil) {
413
+ continue;
414
+ }
415
+ this.rateLimitedPeers.delete(peerId);
416
+ }
417
+
388
418
  try {
389
419
  peersSyncInfo.push({...this.getConnectedPeerSyncMeta(peerId), target});
390
420
  } catch (e) {
@@ -458,7 +488,17 @@ export class SyncChain {
458
488
  return null;
459
489
  }
460
490
 
461
- const batch = new Batch(startEpoch, this.config, this.clock, this.custodyConfig);
491
+ const batch = new Batch(
492
+ startEpoch,
493
+ this.config,
494
+ this.clock,
495
+ this.custodyConfig,
496
+ this.isFirstBatch,
497
+ // `latestBid` is only meaningful for the first batch's parent-payload check
498
+ this.isFirstBatch ? this.latestBid : undefined,
499
+ this.target.slot
500
+ );
501
+ this.isFirstBatch = false;
462
502
  this.batches.set(startEpoch, batch);
463
503
  return batch;
464
504
  }
@@ -516,7 +556,14 @@ export class SyncChain {
516
556
  {id: this.logId, ...batch.getMetadata(), peer: prettyPrintPeerIdStr(peer.peerId)},
517
557
  res.err
518
558
  );
519
- batch.downloadingError(peer.peerId); // Throws after MAX_DOWNLOAD_ATTEMPTS
559
+ if (errCode === RequestErrorCode.RESP_RATE_LIMITED || errCode === RequestErrorCode.REQUEST_SELF_RATE_LIMITED) {
560
+ // Peer rate-limited us — don't count as a failed download attempt and mark peer for backoff
561
+ this.rateLimitedPeers.set(peer.peerId, Date.now() + RATE_LIMITED_PEER_BACKOFF_MS);
562
+ batch.downloadingRateLimited();
563
+ this.triggerBatchDownloader();
564
+ } else {
565
+ batch.downloadingError(peer.peerId); // Throws after MAX_DOWNLOAD_ATTEMPTS
566
+ }
520
567
  } else {
521
568
  this.logger.verbose("Batch download success", {
522
569
  id: this.logId,
@@ -1,7 +1,7 @@
1
1
  import {EventEmitter} from "node:events";
2
2
  import {StrictEventEmitter} from "strict-event-emitter-types";
3
3
  import {BeaconConfig} from "@lodestar/config";
4
- import {computeStartSlotAtEpoch} from "@lodestar/state-transition";
4
+ import {IBeaconStateViewGloas, computeStartSlotAtEpoch, isStatePostGloas} from "@lodestar/state-transition";
5
5
  import {Epoch, Status, fulu} from "@lodestar/types";
6
6
  import {Logger, toRootHex} from "@lodestar/utils";
7
7
  import {IBlockInput} from "../../chain/blocks/blockInput/types.js";
@@ -206,14 +206,18 @@ export class RangeSync extends (EventEmitter as {new (): RangeSyncEmitter}) {
206
206
 
207
207
  private downloadByRange: SyncChainFns["downloadByRange"] = async (peer, batch) => {
208
208
  const batchBlocks = batch.getBlocks();
209
+ const requests = batch.getRequestsForPeer(peer);
210
+ const parentRoot = requests.parentPayloadRequest?.envelopeBlockRoot ?? requests.parentPayloadRequest?.blockRoot;
211
+ const parentPayloadCommitments = parentRoot ? batch.getParentPayloadCommitments(parentRoot) : undefined;
209
212
  const {result, warnings} = await downloadByRange({
210
213
  config: this.config,
211
214
  network: this.network,
212
215
  logger: this.logger,
213
216
  peerIdStr: peer.peerId,
214
217
  batchBlocks,
218
+ parentPayloadCommitments,
215
219
  peerDasMetrics: this.chain.metrics?.peerDas,
216
- ...batch.getRequestsForPeer(peer),
220
+ ...requests,
217
221
  });
218
222
  const {responses, payloadEnvelopes: downloadedPayloadEnvelopes} = result;
219
223
  const {blocks, payloadEnvelopes} = cacheByRangeResponses({
@@ -258,6 +262,14 @@ export class RangeSync extends (EventEmitter as {new (): RangeSyncEmitter}) {
258
262
  private addPeerOrCreateChain(startEpoch: Epoch, target: ChainTarget, peer: PeerIdStr, syncType: RangeSyncType): void {
259
263
  let syncChain = this.chains.get(syncType);
260
264
  if (!syncChain) {
265
+ // The first batch of a new sync chain may need to detect whether the parent block was an
266
+ // gloas "empty" block (no envelope produced). It does so by comparing the first
267
+ // downloaded block's `bid.parentBlockHash` against the head state's `latestExecutionPayloadBid.blockHash`.
268
+ const headState = this.chain.getHeadState();
269
+ const latestBid = isStatePostGloas(headState)
270
+ ? (headState as IBeaconStateViewGloas).latestExecutionPayloadBid
271
+ : undefined;
272
+
261
273
  syncChain = new SyncChain(
262
274
  startEpoch,
263
275
  target,
@@ -276,7 +288,8 @@ export class RangeSync extends (EventEmitter as {new (): RangeSyncEmitter}) {
276
288
  logger: this.logger,
277
289
  custodyConfig: this.chain.custodyConfig,
278
290
  metrics: this.metrics,
279
- }
291
+ },
292
+ latestBid
280
293
  );
281
294
  this.chains.set(syncType, syncChain);
282
295
 
package/src/sync/sync.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import {SLOTS_PER_EPOCH} from "@lodestar/params";
2
2
  import {Slot} from "@lodestar/types";
3
- import {Logger} from "@lodestar/utils";
3
+ import {Logger, toRootHex} from "@lodestar/utils";
4
4
  import {IBeaconChain} from "../chain/index.js";
5
5
  import {GENESIS_SLOT} from "../constants/constants.js";
6
6
  import {ExecutionEngineState} from "../execution/index.js";
@@ -188,6 +188,18 @@ export class BeaconSync implements IBeaconSync {
188
188
  private addPeer = (data: NetworkEventData[NetworkEvent.peerConnected]): void => {
189
189
  const localStatus = this.chain.getStatus();
190
190
  const syncType = getPeerSyncType(localStatus, data.status, this.chain.forkChoice, this.slotImportTolerance);
191
+ this.logger.verbose("Peer sync type classified", {
192
+ peer: data.peer,
193
+ syncType,
194
+ localFinalizedEpoch: localStatus.finalizedEpoch,
195
+ localFinalizedRoot: toRootHex(localStatus.finalizedRoot),
196
+ localHeadSlot: localStatus.headSlot,
197
+ localHeadRoot: toRootHex(localStatus.headRoot),
198
+ remoteFinalizedEpoch: data.status.finalizedEpoch,
199
+ remoteFinalizedRoot: toRootHex(data.status.finalizedRoot),
200
+ remoteHeadSlot: data.status.headSlot,
201
+ remoteHeadRoot: toRootHex(data.status.headRoot),
202
+ });
191
203
 
192
204
  // For metrics only
193
205
  this.peerSyncType.set(data.peer, syncType);