@lodestar/beacon-node 1.43.0-dev.1213f9c92d → 1.43.0-dev.12d35509c0

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 (146) hide show
  1. package/lib/api/impl/beacon/blocks/index.d.ts.map +1 -1
  2. package/lib/api/impl/beacon/blocks/index.js +10 -0
  3. package/lib/api/impl/beacon/blocks/index.js.map +1 -1
  4. package/lib/api/impl/debug/index.d.ts.map +1 -1
  5. package/lib/api/impl/debug/index.js +0 -1
  6. package/lib/api/impl/debug/index.js.map +1 -1
  7. package/lib/api/impl/validator/index.d.ts.map +1 -1
  8. package/lib/api/impl/validator/index.js +2 -1
  9. package/lib/api/impl/validator/index.js.map +1 -1
  10. package/lib/chain/blocks/blockInput/blockInput.d.ts +3 -0
  11. package/lib/chain/blocks/blockInput/blockInput.d.ts.map +1 -1
  12. package/lib/chain/blocks/blockInput/blockInput.js +4 -1
  13. package/lib/chain/blocks/blockInput/blockInput.js.map +1 -1
  14. package/lib/chain/blocks/importBlock.d.ts.map +1 -1
  15. package/lib/chain/blocks/importBlock.js +16 -29
  16. package/lib/chain/blocks/importBlock.js.map +1 -1
  17. package/lib/chain/blocks/importExecutionPayload.d.ts.map +1 -1
  18. package/lib/chain/blocks/importExecutionPayload.js +3 -5
  19. package/lib/chain/blocks/importExecutionPayload.js.map +1 -1
  20. package/lib/chain/blocks/index.d.ts.map +1 -1
  21. package/lib/chain/blocks/index.js +30 -17
  22. package/lib/chain/blocks/index.js.map +1 -1
  23. package/lib/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.d.ts +3 -0
  24. package/lib/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.d.ts.map +1 -1
  25. package/lib/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.js +5 -1
  26. package/lib/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.js.map +1 -1
  27. package/lib/chain/blocks/types.d.ts +2 -1
  28. package/lib/chain/blocks/types.d.ts.map +1 -1
  29. package/lib/chain/blocks/utils/chainSegment.d.ts.map +1 -1
  30. package/lib/chain/blocks/utils/chainSegment.js +8 -0
  31. package/lib/chain/blocks/utils/chainSegment.js.map +1 -1
  32. package/lib/chain/blocks/verifyBlock.d.ts.map +1 -1
  33. package/lib/chain/blocks/verifyBlock.js +5 -6
  34. package/lib/chain/blocks/verifyBlock.js.map +1 -1
  35. package/lib/chain/blocks/verifyBlocksExecutionPayloads.d.ts +0 -4
  36. package/lib/chain/blocks/verifyBlocksExecutionPayloads.d.ts.map +1 -1
  37. package/lib/chain/blocks/verifyBlocksExecutionPayloads.js +5 -2
  38. package/lib/chain/blocks/verifyBlocksExecutionPayloads.js.map +1 -1
  39. package/lib/chain/blocks/verifyBlocksSanityChecks.d.ts +2 -1
  40. package/lib/chain/blocks/verifyBlocksSanityChecks.d.ts.map +1 -1
  41. package/lib/chain/blocks/verifyBlocksSanityChecks.js +16 -7
  42. package/lib/chain/blocks/verifyBlocksSanityChecks.js.map +1 -1
  43. package/lib/chain/blocks/verifyExecutionPayloadEnvelope.d.ts +2 -2
  44. package/lib/chain/blocks/verifyExecutionPayloadEnvelope.d.ts.map +1 -1
  45. package/lib/chain/blocks/verifyExecutionPayloadEnvelope.js +5 -2
  46. package/lib/chain/blocks/verifyExecutionPayloadEnvelope.js.map +1 -1
  47. package/lib/chain/chain.d.ts.map +1 -1
  48. package/lib/chain/chain.js +10 -9
  49. package/lib/chain/chain.js.map +1 -1
  50. package/lib/chain/errors/proposerPreferences.d.ts +8 -1
  51. package/lib/chain/errors/proposerPreferences.d.ts.map +1 -1
  52. package/lib/chain/errors/proposerPreferences.js +1 -0
  53. package/lib/chain/errors/proposerPreferences.js.map +1 -1
  54. package/lib/chain/prepareNextSlot.js +1 -1
  55. package/lib/chain/prepareNextSlot.js.map +1 -1
  56. package/lib/chain/produceBlock/produceBlockBody.d.ts +1 -0
  57. package/lib/chain/produceBlock/produceBlockBody.d.ts.map +1 -1
  58. package/lib/chain/produceBlock/produceBlockBody.js +2 -7
  59. package/lib/chain/produceBlock/produceBlockBody.js.map +1 -1
  60. package/lib/chain/regen/queued.d.ts.map +1 -1
  61. package/lib/chain/regen/queued.js +1 -4
  62. package/lib/chain/regen/queued.js.map +1 -1
  63. package/lib/chain/regen/regen.d.ts.map +1 -1
  64. package/lib/chain/regen/regen.js +1 -4
  65. package/lib/chain/regen/regen.js.map +1 -1
  66. package/lib/chain/seenCache/seenPayloadEnvelopeInput.d.ts +7 -4
  67. package/lib/chain/seenCache/seenPayloadEnvelopeInput.d.ts.map +1 -1
  68. package/lib/chain/seenCache/seenPayloadEnvelopeInput.js +42 -14
  69. package/lib/chain/seenCache/seenPayloadEnvelopeInput.js.map +1 -1
  70. package/lib/chain/seenCache/seenProposerPreferences.d.ts +8 -7
  71. package/lib/chain/seenCache/seenProposerPreferences.d.ts.map +1 -1
  72. package/lib/chain/seenCache/seenProposerPreferences.js +11 -10
  73. package/lib/chain/seenCache/seenProposerPreferences.js.map +1 -1
  74. package/lib/chain/validation/executionPayloadBid.js +11 -8
  75. package/lib/chain/validation/executionPayloadBid.js.map +1 -1
  76. package/lib/chain/validation/proposerPreferences.d.ts.map +1 -1
  77. package/lib/chain/validation/proposerPreferences.js +39 -17
  78. package/lib/chain/validation/proposerPreferences.js.map +1 -1
  79. package/lib/network/gossip/topic.d.ts +2 -0
  80. package/lib/network/gossip/topic.d.ts.map +1 -1
  81. package/lib/network/processor/gossipHandlers.d.ts.map +1 -1
  82. package/lib/network/processor/gossipHandlers.js +24 -0
  83. package/lib/network/processor/gossipHandlers.js.map +1 -1
  84. package/lib/node/nodejs.js +2 -2
  85. package/lib/node/nodejs.js.map +1 -1
  86. package/lib/node/notifier.js +1 -7
  87. package/lib/node/notifier.js.map +1 -1
  88. package/lib/sync/constants.d.ts +3 -1
  89. package/lib/sync/constants.d.ts.map +1 -1
  90. package/lib/sync/constants.js +3 -4
  91. package/lib/sync/constants.js.map +1 -1
  92. package/lib/sync/range/batch.d.ts +5 -0
  93. package/lib/sync/range/batch.d.ts.map +1 -1
  94. package/lib/sync/range/batch.js +68 -17
  95. package/lib/sync/range/batch.js.map +1 -1
  96. package/lib/sync/range/chain.d.ts +6 -0
  97. package/lib/sync/range/chain.d.ts.map +1 -1
  98. package/lib/sync/range/chain.js +27 -2
  99. package/lib/sync/range/chain.js.map +1 -1
  100. package/lib/sync/unknownBlock.d.ts.map +1 -1
  101. package/lib/sync/unknownBlock.js +2 -0
  102. package/lib/sync/unknownBlock.js.map +1 -1
  103. package/lib/sync/utils/downloadByRange.d.ts.map +1 -1
  104. package/lib/sync/utils/downloadByRange.js +36 -21
  105. package/lib/sync/utils/downloadByRange.js.map +1 -1
  106. package/lib/sync/utils/downloadByRoot.d.ts.map +1 -1
  107. package/lib/sync/utils/downloadByRoot.js +10 -0
  108. package/lib/sync/utils/downloadByRoot.js.map +1 -1
  109. package/lib/util/sszBytes.d.ts.map +1 -1
  110. package/lib/util/sszBytes.js +8 -6
  111. package/lib/util/sszBytes.js.map +1 -1
  112. package/package.json +15 -15
  113. package/src/api/impl/beacon/blocks/index.ts +13 -0
  114. package/src/api/impl/debug/index.ts +0 -1
  115. package/src/api/impl/validator/index.ts +2 -1
  116. package/src/chain/blocks/blockInput/blockInput.ts +4 -1
  117. package/src/chain/blocks/importBlock.ts +16 -49
  118. package/src/chain/blocks/importExecutionPayload.ts +3 -5
  119. package/src/chain/blocks/index.ts +20 -9
  120. package/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts +5 -1
  121. package/src/chain/blocks/types.ts +2 -1
  122. package/src/chain/blocks/utils/chainSegment.ts +8 -0
  123. package/src/chain/blocks/verifyBlock.ts +7 -5
  124. package/src/chain/blocks/verifyBlocksExecutionPayloads.ts +6 -4
  125. package/src/chain/blocks/verifyBlocksSanityChecks.ts +16 -6
  126. package/src/chain/blocks/verifyExecutionPayloadEnvelope.ts +7 -2
  127. package/src/chain/chain.ts +12 -9
  128. package/src/chain/errors/proposerPreferences.ts +9 -1
  129. package/src/chain/prepareNextSlot.ts +1 -1
  130. package/src/chain/produceBlock/produceBlockBody.ts +3 -7
  131. package/src/chain/regen/queued.ts +2 -7
  132. package/src/chain/regen/regen.ts +2 -7
  133. package/src/chain/seenCache/seenPayloadEnvelopeInput.ts +55 -16
  134. package/src/chain/seenCache/seenProposerPreferences.ts +14 -11
  135. package/src/chain/validation/executionPayloadBid.ts +11 -8
  136. package/src/chain/validation/proposerPreferences.ts +37 -18
  137. package/src/network/processor/gossipHandlers.ts +30 -0
  138. package/src/node/nodejs.ts +2 -2
  139. package/src/node/notifier.ts +1 -8
  140. package/src/sync/constants.ts +4 -4
  141. package/src/sync/range/batch.ts +70 -19
  142. package/src/sync/range/chain.ts +32 -2
  143. package/src/sync/unknownBlock.ts +2 -0
  144. package/src/sync/utils/downloadByRange.ts +37 -21
  145. package/src/sync/utils/downloadByRoot.ts +12 -0
  146. package/src/util/sszBytes.ts +8 -6
@@ -3,12 +3,11 @@ import {
3
3
  computeEpochAtSlot,
4
4
  createSingleSignatureSetFromComponents,
5
5
  getProposerPreferencesSigningRoot,
6
- isStatePostGloas,
7
6
  } from "@lodestar/state-transition";
8
- import {gloas} from "@lodestar/types";
7
+ import {ValidatorIndex, gloas} from "@lodestar/types";
8
+ import {toRootHex} from "@lodestar/utils";
9
9
  import {GossipAction, ProposerPreferencesError, ProposerPreferencesErrorCode} from "../errors/index.js";
10
10
  import {IBeaconChain} from "../index.js";
11
- import {RegenCaller} from "../regen/index.js";
12
11
 
13
12
  /**
14
13
  * Validates a gossiped `SignedProposerPreferences` per
@@ -19,7 +18,8 @@ export async function validateGossipProposerPreferences(
19
18
  signedProposerPreferences: gloas.SignedProposerPreferences
20
19
  ): Promise<void> {
21
20
  const preferences = signedProposerPreferences.message;
22
- const {proposalSlot, validatorIndex} = preferences;
21
+ const {proposalSlot, validatorIndex, dependentRoot} = preferences;
22
+ const dependentRootHex = toRootHex(dependentRoot);
23
23
  const proposalEpoch = computeEpochAtSlot(proposalSlot);
24
24
 
25
25
  // [IGNORE] `preferences.proposal_slot` is in the current or next epoch.
@@ -42,32 +42,51 @@ export async function validateGossipProposerPreferences(
42
42
  });
43
43
  }
44
44
 
45
- const state = await chain.getHeadStateAtCurrentEpoch(RegenCaller.validateGossipProposerPreferences);
46
- if (!isStatePostGloas(state)) {
47
- throw new Error(`Expected gloas+ state for proposer preferences validation, got fork=${state.forkName}`);
45
+ // [IGNORE] The block with root `dependent_root` has been seen by the node.
46
+ // Resolve the proposer lookahead for the message's branch via head state (fast path) or
47
+ // the previous-root checkpoint state (populated by `processSlotsToNearestCheckpoint` for
48
+ // any imported branch crossing into `proposalEpoch - 1`). The head-state path also handles
49
+ // narrow timing windows where the checkpoint state isn't yet populated.
50
+ const headState = chain.getHeadState();
51
+ let proposers: ValidatorIndex[] | null = null;
52
+ if (headState.epoch === proposalEpoch && headState.currentDecisionRoot === dependentRootHex) {
53
+ proposers = headState.currentProposers;
54
+ } else if (headState.epoch === proposalEpoch - 1 && headState.nextDecisionRoot === dependentRootHex) {
55
+ proposers = headState.nextProposers;
56
+ } else {
57
+ // Sync lookup only to not trigger disk reload from gossip input.
58
+ const checkpointState = chain.regen.getCheckpointStateSync({epoch: proposalEpoch - 1, rootHex: dependentRootHex});
59
+ if (checkpointState !== null) {
60
+ // State is at `proposalEpoch - 1`, so proposers for `proposalSlot` (next epoch from
61
+ // the state's perspective) live in `nextProposers`.
62
+ proposers = checkpointState.nextProposers;
63
+ }
64
+ }
65
+ if (proposers === null) {
66
+ throw new ProposerPreferencesError(GossipAction.IGNORE, {
67
+ code: ProposerPreferencesErrorCode.UNKNOWN_DEPENDENT_ROOT,
68
+ proposalSlot,
69
+ dependentRoot: dependentRootHex,
70
+ });
48
71
  }
49
72
 
50
- // [REJECT] `preferences.validator_index` is present at the correct slot in the current or next
51
- // epoch's portion of `state.proposer_lookahead` i.e. `is_valid_proposal_slot(state, preferences)`
52
- // returns True.
53
- const epochOffset = proposalEpoch - state.epoch;
54
- const proposers = epochOffset === 0 ? state.currentProposers : state.nextProposers;
55
- const expectedProposer = proposers[proposalSlot % SLOTS_PER_EPOCH];
56
- if (epochOffset < 0 || epochOffset > 1 || expectedProposer !== validatorIndex) {
73
+ // [REJECT] `is_valid_proposal_slot(state, preferences)` returns True.
74
+ if (proposers[proposalSlot % SLOTS_PER_EPOCH] !== validatorIndex) {
57
75
  throw new ProposerPreferencesError(GossipAction.REJECT, {
58
76
  code: ProposerPreferencesErrorCode.INVALID_PROPOSER,
59
77
  proposalSlot,
60
78
  validatorIndex,
79
+ dependentRoot: dependentRootHex,
61
80
  });
62
81
  }
63
82
 
64
- // [IGNORE] The `signed_proposer_preferences` is the first valid message received from the validator
65
- // with index `preferences.validator_index` and the given slot `preferences.proposal_slot`.
66
- if (chain.seenProposerPreferences.isKnown(proposalSlot, validatorIndex)) {
83
+ // [IGNORE] First valid message for (dependent_root, proposal_slot, validator_index).
84
+ if (chain.seenProposerPreferences.isKnown(dependentRootHex, proposalSlot, validatorIndex)) {
67
85
  throw new ProposerPreferencesError(GossipAction.IGNORE, {
68
86
  code: ProposerPreferencesErrorCode.ALREADY_KNOWN,
69
87
  proposalSlot,
70
88
  validatorIndex,
89
+ dependentRoot: dependentRootHex,
71
90
  });
72
91
  }
73
92
 
@@ -87,5 +106,5 @@ export async function validateGossipProposerPreferences(
87
106
  }
88
107
 
89
108
  // Valid
90
- chain.seenProposerPreferences.add(proposalSlot, validatorIndex);
109
+ chain.seenProposerPreferences.add(dependentRootHex, proposalSlot, validatorIndex);
91
110
  }
@@ -185,6 +185,18 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand
185
185
  });
186
186
  try {
187
187
  await validateGossipBlock(config, chain, signedBlock, fork);
188
+
189
+ if (isForkPostGloas(fork)) {
190
+ chain.seenPayloadEnvelopeInputCache.add({
191
+ blockRootHex,
192
+ block: signedBlock as SignedBeaconBlock<ForkPostGloas>,
193
+ forkName: fork,
194
+ sampledColumns: chain.custodyConfig.sampledColumns,
195
+ custodyColumns: chain.custodyConfig.custodyColumns,
196
+ timeCreatedSec: seenTimestampSec,
197
+ });
198
+ }
199
+
188
200
  const blockInputMeta = blockInput.getLogMeta();
189
201
 
190
202
  const recvToValidation = Date.now() / 1000 - seenTimestampSec;
@@ -595,6 +607,24 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand
595
607
  // Returns the delay between the start of `block.slot` and `current time`
596
608
  const delaySec = chain.clock.secFromSlot(slot);
597
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
+ }
598
628
  })
599
629
  .catch((e) => {
600
630
  // Adjust verbosity based on error type
@@ -221,7 +221,7 @@ export class BeaconNode {
221
221
 
222
222
  let executionEngineOpts = opts.executionEngine;
223
223
  if (opts.executionEngine.mode === "mock") {
224
- const eth1BlockHash =
224
+ const latestEth1BlockHash =
225
225
  isStatePostBellatrix(anchorState) && anchorState.isExecutionStateType
226
226
  ? isStatePostGloas(anchorState)
227
227
  ? toRootHex(anchorState.latestBlockHash)
@@ -230,7 +230,7 @@ export class BeaconNode {
230
230
  executionEngineOpts = {
231
231
  ...opts.executionEngine,
232
232
  genesisBlockHash: ZERO_HASH_HEX,
233
- eth1BlockHash,
233
+ eth1BlockHash: opts.executionEngine.eth1BlockHash ?? latestEth1BlockHash,
234
234
  genesisTime: anchorState.genesisTime,
235
235
  config,
236
236
  };
@@ -167,14 +167,7 @@ function getHeadExecutionInfo(
167
167
  return [];
168
168
  }
169
169
 
170
- // A PayloadSeparated head is a gloas beacon block imported before its payload envelope
171
- // arrives, in that case the exec-block row surfaces the inherited parent anchor (from the
172
- // bid), which is already validated. Normalize to "valid" to avoid leaking internal
173
- // fork-choice bookkeeping into the log. Once the payload envelope arrives and the FULL
174
- // variant becomes head, executionStatus is Valid/Syncing naturally.
175
- // TODO GLOAS: revisit once optimistic sync is implemented
176
- const executionStatusStr =
177
- headInfo.executionStatus === ExecutionStatus.PayloadSeparated ? "valid" : headInfo.executionStatus.toLowerCase();
170
+ const executionStatusStr = headInfo.executionStatus.toLowerCase();
178
171
 
179
172
  // Add execution status to notifier only if head is on/post bellatrix
180
173
  if (isStatePostBellatrix(headState) && headState.isExecutionStateType) {
@@ -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
@@ -202,6 +202,7 @@ export class Batch {
202
202
  const envelopesBySlot = this.state.payloadEnvelopes ?? new Map<Slot, PayloadEnvelopeInput>();
203
203
 
204
204
  // ensure blocks are in slot-wise order
205
+ const isPostGloas = isForkPostGloas(this.forkName);
205
206
  for (const blockInput of blocks) {
206
207
  const blockSlot = blockInput.slot;
207
208
  // check if block/data is present (hasBlock/hasAllData). If present then check if startSlot is the same as
@@ -217,21 +218,36 @@ export class Batch {
217
218
  if (blockInput.hasBlock() && blockStartSlot === blockSlot) {
218
219
  blockStartSlot = blockSlot + 1;
219
220
  }
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) {
221
+
222
+ // Range sync uses hasComputedAllData (all sampled columns physically present), not hasAllData
223
+ // which flips at the reconstruction threshold. Sync never triggers reconstruction, so accepting
224
+ // a half-downloaded block here makes writeBlockInputToDb later block on waitForComputedAllData.
225
+ if (isPostGloas) {
226
+ // Post-Gloas: column data lives on PayloadEnvelopeInput, not on BlockInputNoData.
227
+ const payloadInput = envelopesBySlot.get(blockSlot);
228
+ if (blockInput.hasBlock() && envelopeStartSlot === blockSlot && payloadInput?.hasPayloadEnvelope()) {
229
+ envelopeStartSlot = blockSlot + 1;
230
+ }
231
+ if (payloadInput && !payloadInput.hasComputedAllData()) {
232
+ for (const index of payloadInput.getMissingSampledColumnMeta().missing) {
230
233
  neededColumns.add(index);
231
234
  }
235
+ } else if (payloadInput?.hasComputedAllData() && dataStartSlot === blockSlot) {
236
+ // Only advance dataStartSlot when we know columns for this slot are complete. If
237
+ // payloadInput is missing entirely we cannot tell, so stop here so the next round
238
+ // re-requests columns (and envelopes) starting at this slot.
239
+ dataStartSlot = blockSlot + 1;
240
+ }
241
+ } else {
242
+ if (isBlockInputColumns(blockInput) ? !blockInput.hasComputedAllData() : !blockInput.hasAllData()) {
243
+ if (isBlockInputColumns(blockInput)) {
244
+ for (const index of blockInput.getMissingSampledColumnMeta().missing) {
245
+ neededColumns.add(index);
246
+ }
247
+ }
248
+ } else if (dataStartSlot === blockSlot) {
249
+ dataStartSlot = blockSlot + 1;
232
250
  }
233
- } else if (dataStartSlot === blockSlot) {
234
- dataStartSlot = blockSlot + 1;
235
251
  }
236
252
  }
237
253
 
@@ -251,11 +267,15 @@ export class Batch {
251
267
  // range of 40 - 63, startSlot will be inclusive but subtraction will exclusive so need to + 1
252
268
  const count = endSlot - dataStartSlot + 1;
253
269
  if (isForkPostFulu(this.forkName) && withinValidRequestWindow) {
254
- requests.columnsRequest = {
255
- count,
256
- startSlot: dataStartSlot,
257
- columns: Array.from(neededColumns),
258
- };
270
+ // Skip the column re-request when we have no specific column indices outstanding.
271
+ // Peer rejects an empty `columns` list
272
+ if (neededColumns.size > 0) {
273
+ requests.columnsRequest = {
274
+ count,
275
+ startSlot: dataStartSlot,
276
+ columns: Array.from(neededColumns),
277
+ };
278
+ }
259
279
  } else if (isForkPostDeneb(this.forkName) && withinValidRequestWindow) {
260
280
  requests.blobsRequest = {
261
281
  count,
@@ -379,7 +399,11 @@ export class Batch {
379
399
  const slots = new Set<number>();
380
400
  for (const block of blocks) {
381
401
  slots.add(block.slot);
382
- if (!block.hasBlockAndAllData()) {
402
+ const dataComplete = isBlockInputColumns(block)
403
+ ? // by_range needs to download all columns
404
+ block.hasBlock() && block.hasComputedAllData()
405
+ : block.hasBlockAndAllData();
406
+ if (!dataComplete) {
383
407
  allComplete = false;
384
408
  }
385
409
  }
@@ -395,11 +419,22 @@ export class Batch {
395
419
  }
396
420
  const newPayloadEnvelopes = payloadEnvelopes ?? this.state.payloadEnvelopes;
397
421
 
422
+ if (allComplete && isForkPostGloas(this.forkName)) {
423
+ for (const block of blocks) {
424
+ const payloadInput = newPayloadEnvelopes?.get(block.slot);
425
+ // by_range needs to download all columns
426
+ if (!payloadInput?.hasPayloadEnvelope() || !payloadInput.hasComputedAllData()) {
427
+ allComplete = false;
428
+ break;
429
+ }
430
+ }
431
+ }
432
+
398
433
  if (allComplete) {
399
434
  this.state = {status: BatchStatus.AwaitingProcessing, blocks, payloadEnvelopes: newPayloadEnvelopes};
400
435
  } else {
401
- this.requests = this.getRequests(blocks);
402
436
  this.state = {status: BatchStatus.AwaitingDownload, blocks, payloadEnvelopes: newPayloadEnvelopes};
437
+ this.requests = this.getRequests(blocks);
403
438
  }
404
439
 
405
440
  return this.state as DownloadSuccessState;
@@ -425,6 +460,22 @@ export class Batch {
425
460
  };
426
461
  }
427
462
 
463
+ /**
464
+ * Downloading -> AwaitingDownload (without counting as a failed attempt).
465
+ * Used when the peer rate-limited us — the request was never actually served.
466
+ */
467
+ downloadingRateLimited(): void {
468
+ if (this.state.status !== BatchStatus.Downloading) {
469
+ throw new BatchError(this.wrongStatusErrorType(BatchStatus.Downloading));
470
+ }
471
+
472
+ this.state = {
473
+ status: BatchStatus.AwaitingDownload,
474
+ blocks: this.state.blocks,
475
+ payloadEnvelopes: this.state.payloadEnvelopes,
476
+ };
477
+ }
478
+
428
479
  /**
429
480
  * AwaitingProcessing -> Processing
430
481
  */
@@ -1,4 +1,5 @@
1
1
  import {ChainForkConfig} from "@lodestar/config";
2
+ import {RequestErrorCode} from "@lodestar/reqresp";
2
3
  import {Epoch, Root, Slot} 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";
@@ -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";
@@ -140,6 +146,12 @@ export class SyncChain {
140
146
  /** Sorted map of batches undergoing some kind of processing. */
141
147
  private readonly batches = new Map<Epoch, Batch>();
142
148
  private readonly peerset = new Map<PeerIdStr, ChainTarget>();
149
+ /**
150
+ * Tracks peers that have rate-limited us, mapped to the timestamp (ms) until which we should avoid them.
151
+ * This is a sync-layer optimization to avoid assigning batches to backed-off peers.
152
+ * The reqresp SelfRateLimiter independently enforces backoff at the protocol level as a safety net.
153
+ */
154
+ private readonly rateLimitedPeers = new Map<PeerIdStr, number>();
143
155
 
144
156
  private readonly logger: Logger;
145
157
  private readonly config: ChainForkConfig;
@@ -248,6 +260,7 @@ export class SyncChain {
248
260
  */
249
261
  removePeer(peerId: PeerIdStr): boolean {
250
262
  const deleted = this.peerset.delete(peerId);
263
+ this.rateLimitedPeers.delete(peerId);
251
264
  this.computeTarget();
252
265
  return deleted;
253
266
  }
@@ -383,8 +396,18 @@ export class SyncChain {
383
396
  return;
384
397
  }
385
398
 
399
+ const now = Date.now();
386
400
  const peersSyncInfo: PeerSyncInfo[] = [];
387
401
  for (const [peerId, target] of this.peerset.entries()) {
402
+ // Skip peers that are currently in rate-limit backoff
403
+ const rateLimitedUntil = this.rateLimitedPeers.get(peerId);
404
+ if (rateLimitedUntil !== undefined) {
405
+ if (now < rateLimitedUntil) {
406
+ continue;
407
+ }
408
+ this.rateLimitedPeers.delete(peerId);
409
+ }
410
+
388
411
  try {
389
412
  peersSyncInfo.push({...this.getConnectedPeerSyncMeta(peerId), target});
390
413
  } catch (e) {
@@ -516,7 +539,14 @@ export class SyncChain {
516
539
  {id: this.logId, ...batch.getMetadata(), peer: prettyPrintPeerIdStr(peer.peerId)},
517
540
  res.err
518
541
  );
519
- batch.downloadingError(peer.peerId); // Throws after MAX_DOWNLOAD_ATTEMPTS
542
+ if (errCode === RequestErrorCode.RESP_RATE_LIMITED || errCode === RequestErrorCode.REQUEST_SELF_RATE_LIMITED) {
543
+ // Peer rate-limited us — don't count as a failed download attempt and mark peer for backoff
544
+ this.rateLimitedPeers.set(peer.peerId, Date.now() + RATE_LIMITED_PEER_BACKOFF_MS);
545
+ batch.downloadingRateLimited();
546
+ this.triggerBatchDownloader();
547
+ } else {
548
+ batch.downloadingError(peer.peerId); // Throws after MAX_DOWNLOAD_ATTEMPTS
549
+ }
520
550
  } else {
521
551
  this.logger.verbose("Batch download success", {
522
552
  id: this.logId,
@@ -1240,6 +1240,8 @@ export class BlockInputSync {
1240
1240
  downloadByRootMetrics?.error.inc({code: "req_resp", client: peerClient});
1241
1241
  switch (e.type.code) {
1242
1242
  case RequestErrorCode.REQUEST_RATE_LIMITED:
1243
+ case RequestErrorCode.RESP_RATE_LIMITED:
1244
+ case RequestErrorCode.REQUEST_SELF_RATE_LIMITED:
1243
1245
  case RequestErrorCode.REQUEST_TIMEOUT:
1244
1246
  // do not exclude peer for these errors
1245
1247
  break;
@@ -14,6 +14,7 @@ import {
14
14
  deneb,
15
15
  fulu,
16
16
  gloas,
17
+ isGloasBeaconBlock,
17
18
  isGloasDataColumnSidecar,
18
19
  phase0,
19
20
  } from "@lodestar/types";
@@ -185,33 +186,37 @@ export function cacheByRangeResponses({
185
186
  }
186
187
  }
187
188
 
188
- // Build payloadEnvelopes map for gloas: start from existing (partial download) state.
189
- // The entries are wrappers around (block + envelope + sampled columns) and also seeded into
190
- // seenPayloadEnvelopeInputCache so importBlock can find them without creating a duplicate.
191
- let payloadEnvelopes: Map<Slot, PayloadEnvelopeInput> | null = null;
192
- if (downloadedPayloadEnvelopes !== null) {
193
- payloadEnvelopes = new Map(existingPayloadEnvelopes ?? []);
189
+ // Seed seenPayloadEnvelopeInputCache for every gloas block in the batch, regardless of whether
190
+ // the peer returned its envelope. Without this, a block returned without its envelope would be
191
+ // imported with no cache entry, and later payload-by-root sync would throw
192
+ // "Missing PayloadEnvelopeInput for known block" (see issue #9306).
193
+ for (const blockInput of updatedBatchBlocks.values()) {
194
+ if (!blockInput.hasBlock() || !isForkPostGloas(blockInput.forkName)) continue;
195
+ seenPayloadEnvelopeInputCache.add({
196
+ blockRootHex: blockInput.blockRootHex,
197
+ block: blockInput.getBlock() as SignedBeaconBlock<ForkPostGloas>,
198
+ forkName: blockInput.forkName,
199
+ sampledColumns: custodyConfig.sampledColumns,
200
+ custodyColumns: custodyConfig.custodyColumns,
201
+ timeCreatedSec: seenTimestampSec,
202
+ });
203
+ }
194
204
 
205
+ let payloadEnvelopes: Map<Slot, PayloadEnvelopeInput> | null =
206
+ existingPayloadEnvelopes !== null ? new Map(existingPayloadEnvelopes) : null;
207
+ if (downloadedPayloadEnvelopes !== null) {
208
+ payloadEnvelopes ??= new Map();
195
209
  for (const [slot, envelope] of downloadedPayloadEnvelopes) {
196
210
  const blockInput = updatedBatchBlocks.get(slot);
197
211
  if (!blockInput?.hasBlock() || !isForkPostGloas(blockInput.forkName)) {
198
212
  // No block to pair this envelope with; drop silently
199
213
  continue;
200
214
  }
201
- const {blockRootHex} = blockInput;
202
215
 
203
- // Reuse any existing PayloadEnvelopeInput (e.g. gossip arrived first) to avoid
204
- // duplicate cache entries. If missing, create a fresh one from the block's bid.
205
- let payloadInput = seenPayloadEnvelopeInputCache.get(blockRootHex);
216
+ const payloadInput = seenPayloadEnvelopeInputCache.get(blockInput.blockRootHex);
206
217
  if (payloadInput === undefined) {
207
- payloadInput = seenPayloadEnvelopeInputCache.add({
208
- blockRootHex,
209
- block: blockInput.getBlock() as SignedBeaconBlock<ForkPostGloas>,
210
- forkName: blockInput.forkName,
211
- sampledColumns: custodyConfig.sampledColumns,
212
- custodyColumns: custodyConfig.custodyColumns,
213
- timeCreatedSec: seenTimestampSec,
214
- });
218
+ // Unreachable given the loop above seeded an entry for every gloas block in the batch.
219
+ continue;
215
220
  }
216
221
 
217
222
  if (!payloadInput.hasPayloadEnvelope()) {
@@ -355,7 +360,7 @@ export async function requestByRange({
355
360
  let blocks: undefined | SignedBeaconBlock[];
356
361
  let blobSidecars: undefined | deneb.BlobSidecars;
357
362
  let columnSidecars: undefined | DataColumnSidecar[];
358
- let payloadEnvelopes: undefined | gloas.SignedExecutionPayloadEnvelope[];
363
+ const payloadEnvelopes: gloas.SignedExecutionPayloadEnvelope[] = [];
359
364
 
360
365
  const requests: Promise<unknown>[] = [];
361
366
 
@@ -363,6 +368,17 @@ export async function requestByRange({
363
368
  requests.push(
364
369
  network.sendBeaconBlocksByRange(peerIdStr, blocksRequest).then((blockResponse) => {
365
370
  blocks = blockResponse;
371
+ const firstBlock = blockResponse.at(0);
372
+ if (firstBlock && isGloasBeaconBlock(firstBlock.message)) {
373
+ return network
374
+ .sendExecutionPayloadEnvelopesByRoot(peerIdStr, [
375
+ firstBlock.message.body.signedExecutionPayloadBid.message.parentBlockRoot,
376
+ ])
377
+ .then((envelopeResponse) => {
378
+ payloadEnvelopes?.unshift(...envelopeResponse);
379
+ });
380
+ }
381
+ return undefined;
366
382
  })
367
383
  );
368
384
  }
@@ -386,7 +402,7 @@ export async function requestByRange({
386
402
  if (envelopesRequest) {
387
403
  requests.push(
388
404
  network.sendExecutionPayloadEnvelopesByRange(peerIdStr, envelopesRequest).then((envelopeResponse) => {
389
- payloadEnvelopes = envelopeResponse;
405
+ payloadEnvelopes?.push(...envelopeResponse);
390
406
  })
391
407
  );
392
408
  }
@@ -1173,7 +1189,7 @@ export function validateEnvelopesByRangeResponse(
1173
1189
  const slot = payloadEnvelope.message.payload.slotNumber;
1174
1190
  const batchBlockRoot = batchBlockRoots.get(slot);
1175
1191
 
1176
- // Envelopes for slots not in the batch are silently ignored (orphaned payloads)
1192
+ // Envelopes for slots not in the batch are silently ignored (orphaned payloads or a parent payload)
1177
1193
  if (batchBlockRoot === undefined) {
1178
1194
  continue;
1179
1195
  }
@@ -3,6 +3,7 @@ import {ChainForkConfig} from "@lodestar/config";
3
3
  import {
4
4
  ForkPostDeneb,
5
5
  ForkPostFulu,
6
+ ForkPostGloas,
6
7
  ForkPreFulu,
7
8
  isForkPostDeneb,
8
9
  isForkPostFulu,
@@ -114,6 +115,17 @@ export async function downloadByRoot({
114
115
  });
115
116
  }
116
117
 
118
+ if (isForkPostGloas(blockInput.forkName)) {
119
+ chain.seenPayloadEnvelopeInputCache.add({
120
+ blockRootHex: rootHex,
121
+ block: blockInput.getBlock() as SignedBeaconBlock<ForkPostGloas>,
122
+ forkName: blockInput.forkName,
123
+ sampledColumns: chain.custodyConfig.sampledColumns,
124
+ custodyColumns: chain.custodyConfig.custodyColumns,
125
+ timeCreatedSec: Date.now() / 1000,
126
+ });
127
+ }
128
+
117
129
  const hasAllDataPreDownload = blockInput.hasBlockAndAllData();
118
130
 
119
131
  if (isBlockInputBlobs(blockInput) && !hasAllDataPreDownload) {
@@ -558,9 +558,10 @@ export function getBeaconBlockRootFromDataColumnSidecarSerialized(data: Uint8Arr
558
558
  * └─ ExecutionPayloadEnvelope (starts at byte 100):
559
559
  * ├─ 4 bytes: payload offset
560
560
  * ├─ 4 bytes: executionRequests offset
561
- * ├─ 8 bytes: builderIndex (offset 108-115)
562
- * ├─ 32 bytes: beaconBlockRoot (offset 116-147)
563
- * └─ variable: payload data (starts at envelope + 48)
561
+ * ├─ 8 bytes: builderIndex (offset 108-115)
562
+ * ├─ 32 bytes: beaconBlockRoot (offset 116-147)
563
+ * ├─ 32 bytes: parentBeaconBlockRoot (offset 148-179) new in Gloas alpha.6 (consensus-specs#5152)
564
+ * └─ variable: payload data (starts at envelope + 80)
564
565
  * └─ ExecutionPayload fixed portion includes slotNumber at offset 532
565
566
  */
566
567
  const SIGNED_EXECUTION_PAYLOAD_ENVELOPE_MESSAGE_OFFSET = 4;
@@ -576,12 +577,13 @@ const BEACON_BLOCK_ROOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE =
576
577
  EXECUTION_PAYLOAD_ENVELOPE_REQUESTS_OFFSET +
577
578
  EXECUTION_PAYLOAD_ENVELOPE_BUILDER_INDEX_SIZE; // 116
578
579
 
579
- // Envelope fixed portion (without slot): payload_offset(4) + requests_offset(4) + builderIndex(8) + beaconBlockRoot(32) = 48
580
+ // Envelope fixed portion: payload_offset(4) + requests_offset(4) + builderIndex(8) + beaconBlockRoot(32) + parentBeaconBlockRoot(32) = 80
580
581
  const EXECUTION_PAYLOAD_ENVELOPE_FIXED_SIZE =
581
582
  EXECUTION_PAYLOAD_ENVELOPE_PAYLOAD_OFFSET +
582
583
  EXECUTION_PAYLOAD_ENVELOPE_REQUESTS_OFFSET +
583
584
  EXECUTION_PAYLOAD_ENVELOPE_BUILDER_INDEX_SIZE +
584
- ROOT_SIZE; // 48
585
+ ROOT_SIZE +
586
+ ROOT_SIZE; // 80
585
587
 
586
588
  // slotNumber offset within ExecutionPayload fixed portion:
587
589
  // parentHash(32) + feeRecipient(20) + stateRoot(32) + receiptsRoot(32) + logsBloom(256) +
@@ -595,7 +597,7 @@ const ENVELOPE_START_IN_SIGNED =
595
597
  SIGNED_EXECUTION_PAYLOAD_ENVELOPE_MESSAGE_OFFSET + SIGNED_EXECUTION_PAYLOAD_ENVELOPE_SIGNATURE_SIZE; // 100
596
598
 
597
599
  const SLOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE =
598
- ENVELOPE_START_IN_SIGNED + EXECUTION_PAYLOAD_ENVELOPE_FIXED_SIZE + SLOT_NUMBER_OFFSET_IN_EXECUTION_PAYLOAD; // 100 + 48 + 532 = 680
600
+ ENVELOPE_START_IN_SIGNED + EXECUTION_PAYLOAD_ENVELOPE_FIXED_SIZE + SLOT_NUMBER_OFFSET_IN_EXECUTION_PAYLOAD; // 100 + 80 + 532 = 712
599
601
 
600
602
  export function getSlotFromExecutionPayloadEnvelopeSerialized(data: Uint8Array): Slot | null {
601
603
  if (data.length < SLOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE + SLOT_SIZE) {