@lodestar/beacon-node 1.45.0-dev.d7859e978b → 1.45.0-dev.e10fb5e991

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 (93) hide show
  1. package/lib/api/impl/beacon/pool/index.d.ts.map +1 -1
  2. package/lib/api/impl/beacon/pool/index.js +0 -1
  3. package/lib/api/impl/beacon/pool/index.js.map +1 -1
  4. package/lib/api/impl/validator/index.d.ts.map +1 -1
  5. package/lib/api/impl/validator/index.js +9 -3
  6. package/lib/api/impl/validator/index.js.map +1 -1
  7. package/lib/chain/blocks/importBlock.d.ts.map +1 -1
  8. package/lib/chain/blocks/importBlock.js +9 -4
  9. package/lib/chain/blocks/importBlock.js.map +1 -1
  10. package/lib/chain/blocks/payloadEnvelopeProcessor.d.ts +5 -2
  11. package/lib/chain/blocks/payloadEnvelopeProcessor.d.ts.map +1 -1
  12. package/lib/chain/blocks/payloadEnvelopeProcessor.js +17 -17
  13. package/lib/chain/blocks/payloadEnvelopeProcessor.js.map +1 -1
  14. package/lib/chain/chain.d.ts +1 -2
  15. package/lib/chain/chain.d.ts.map +1 -1
  16. package/lib/chain/chain.js +1 -3
  17. package/lib/chain/chain.js.map +1 -1
  18. package/lib/chain/interface.d.ts +1 -2
  19. package/lib/chain/interface.d.ts.map +1 -1
  20. package/lib/chain/interface.js.map +1 -1
  21. package/lib/chain/opPools/proposerPreferencesPool.d.ts +2 -1
  22. package/lib/chain/opPools/proposerPreferencesPool.d.ts.map +1 -1
  23. package/lib/chain/opPools/proposerPreferencesPool.js +3 -0
  24. package/lib/chain/opPools/proposerPreferencesPool.js.map +1 -1
  25. package/lib/chain/options.d.ts +1 -0
  26. package/lib/chain/options.d.ts.map +1 -1
  27. package/lib/chain/options.js +1 -0
  28. package/lib/chain/options.js.map +1 -1
  29. package/lib/chain/seenCache/index.d.ts +0 -1
  30. package/lib/chain/seenCache/index.d.ts.map +1 -1
  31. package/lib/chain/seenCache/index.js +0 -1
  32. package/lib/chain/seenCache/index.js.map +1 -1
  33. package/lib/chain/seenCache/seenPayloadEnvelopeInput.d.ts +1 -0
  34. package/lib/chain/seenCache/seenPayloadEnvelopeInput.d.ts.map +1 -1
  35. package/lib/chain/seenCache/seenPayloadEnvelopeInput.js +7 -0
  36. package/lib/chain/seenCache/seenPayloadEnvelopeInput.js.map +1 -1
  37. package/lib/chain/shufflingCache.d.ts +6 -0
  38. package/lib/chain/shufflingCache.d.ts.map +1 -1
  39. package/lib/chain/shufflingCache.js +21 -6
  40. package/lib/chain/shufflingCache.js.map +1 -1
  41. package/lib/chain/stateCache/persistentCheckpointsCache.d.ts.map +1 -1
  42. package/lib/chain/stateCache/persistentCheckpointsCache.js +3 -2
  43. package/lib/chain/stateCache/persistentCheckpointsCache.js.map +1 -1
  44. package/lib/chain/validation/proposerPreferences.d.ts.map +1 -1
  45. package/lib/chain/validation/proposerPreferences.js +11 -3
  46. package/lib/chain/validation/proposerPreferences.js.map +1 -1
  47. package/lib/network/gossip/topic.d.ts +19 -776
  48. package/lib/network/gossip/topic.d.ts.map +1 -1
  49. package/lib/network/peers/discover.d.ts.map +1 -1
  50. package/lib/network/peers/discover.js +17 -12
  51. package/lib/network/peers/discover.js.map +1 -1
  52. package/lib/network/processor/gossipHandlers.d.ts.map +1 -1
  53. package/lib/network/processor/gossipHandlers.js +26 -14
  54. package/lib/network/processor/gossipHandlers.js.map +1 -1
  55. package/lib/sync/range/chain.d.ts +4 -1
  56. package/lib/sync/range/chain.d.ts.map +1 -1
  57. package/lib/sync/range/chain.js +3 -3
  58. package/lib/sync/range/chain.js.map +1 -1
  59. package/lib/sync/range/range.d.ts.map +1 -1
  60. package/lib/sync/range/range.js +10 -0
  61. package/lib/sync/range/range.js.map +1 -1
  62. package/lib/sync/utils/downloadByRange.d.ts.map +1 -1
  63. package/lib/sync/utils/downloadByRange.js +25 -19
  64. package/lib/sync/utils/downloadByRange.js.map +1 -1
  65. package/lib/util/graffiti.d.ts +15 -0
  66. package/lib/util/graffiti.d.ts.map +1 -1
  67. package/lib/util/graffiti.js +55 -0
  68. package/lib/util/graffiti.js.map +1 -1
  69. package/package.json +14 -14
  70. package/src/api/impl/beacon/pool/index.ts +0 -1
  71. package/src/api/impl/validator/index.ts +9 -3
  72. package/src/chain/blocks/importBlock.ts +18 -4
  73. package/src/chain/blocks/payloadEnvelopeProcessor.ts +17 -17
  74. package/src/chain/chain.ts +0 -3
  75. package/src/chain/interface.ts +0 -2
  76. package/src/chain/opPools/proposerPreferencesPool.ts +5 -1
  77. package/src/chain/options.ts +2 -0
  78. package/src/chain/seenCache/index.ts +0 -1
  79. package/src/chain/seenCache/seenPayloadEnvelopeInput.ts +8 -0
  80. package/src/chain/shufflingCache.ts +23 -7
  81. package/src/chain/stateCache/persistentCheckpointsCache.ts +3 -2
  82. package/src/chain/validation/proposerPreferences.ts +12 -3
  83. package/src/network/peers/discover.ts +16 -11
  84. package/src/network/processor/gossipHandlers.ts +29 -15
  85. package/src/sync/range/chain.ts +4 -5
  86. package/src/sync/range/range.ts +10 -0
  87. package/src/sync/utils/downloadByRange.ts +26 -19
  88. package/src/util/graffiti.ts +78 -0
  89. package/lib/chain/seenCache/seenProposerPreferences.d.ts +0 -16
  90. package/lib/chain/seenCache/seenProposerPreferences.d.ts.map +0 -1
  91. package/lib/chain/seenCache/seenProposerPreferences.js +0 -26
  92. package/lib/chain/seenCache/seenProposerPreferences.js.map +0 -1
  93. package/src/chain/seenCache/seenProposerPreferences.ts +0 -32
@@ -82,7 +82,7 @@ import {CommitteeSubscription} from "../../../network/subnets/index.js";
82
82
  import {SyncState} from "../../../sync/index.js";
83
83
  import {callInNextEventLoop} from "../../../util/eventLoop.js";
84
84
  import {isOptimisticBlock} from "../../../util/forkChoice.js";
85
- import {getDefaultGraffiti, toGraffitiBytes} from "../../../util/graffiti.js";
85
+ import {getBlockGraffiti, toGraffitiBytes} from "../../../util/graffiti.js";
86
86
  import {getLodestarClientVersion} from "../../../util/metadata.js";
87
87
  import {ApiOptions} from "../../options.js";
88
88
  import {getStateResponseWithRegen} from "../beacon/state/utils.js";
@@ -611,7 +611,10 @@ export function getValidatorApi(
611
611
  }
612
612
 
613
613
  const graffitiBytes = toGraffitiBytes(
614
- graffiti ?? getDefaultGraffiti(getLodestarClientVersion(opts), chain.executionEngine.clientVersion, opts)
614
+ getBlockGraffiti(graffiti, getLodestarClientVersion(opts), chain.executionEngine.clientVersion, {
615
+ private: opts.private,
616
+ graffitiAppend: chain.opts.graffitiAppend,
617
+ })
615
618
  );
616
619
 
617
620
  const loggerContext = {
@@ -921,7 +924,10 @@ export function getValidatorApi(
921
924
  metrics?.blockProductionSlotDelta.set(slot - parentSlot);
922
925
 
923
926
  const graffitiBytes = toGraffitiBytes(
924
- graffiti ?? getDefaultGraffiti(getLodestarClientVersion(opts), chain.executionEngine.clientVersion, opts)
927
+ getBlockGraffiti(graffiti, getLodestarClientVersion(opts), chain.executionEngine.clientVersion, {
928
+ private: opts.private,
929
+ graffitiAppend: chain.opts.graffitiAppend,
930
+ })
925
931
  );
926
932
 
927
933
  // TODO GLOAS: respect builderSelection (MaxProfit, BuilderAlways, ExecutionAlways, etc.) to let
@@ -9,7 +9,14 @@ import {
9
9
  NotReorgedReason,
10
10
  getSafeExecutionBlockHash,
11
11
  } from "@lodestar/fork-choice";
12
- import {ForkPostAltair, ForkPostElectra, ForkSeq, MAX_SEED_LOOKAHEAD, SLOTS_PER_EPOCH} from "@lodestar/params";
12
+ import {
13
+ ForkPostAltair,
14
+ ForkPostElectra,
15
+ ForkSeq,
16
+ GENESIS_EPOCH,
17
+ MAX_SEED_LOOKAHEAD,
18
+ SLOTS_PER_EPOCH,
19
+ } from "@lodestar/params";
13
20
  import {
14
21
  IBeaconStateView,
15
22
  RootCache,
@@ -419,7 +426,7 @@ export async function importBlock(
419
426
  }
420
427
  } catch (e) {
421
428
  if (isStartSlotOfEpoch(proposalSlot)) {
422
- notOverrideFcuReason = NotReorgedReason.NotShufflingStable;
429
+ notOverrideFcuReason = NotReorgedReason.AtEpochBoundary;
423
430
  } else {
424
431
  this.logger.warn("Unable to get beacon proposer. Do not override fcu.", {proposalSlot}, e as Error);
425
432
  }
@@ -483,8 +490,15 @@ export async function importBlock(
483
490
  // Cache shufflings when crossing an epoch boundary
484
491
  const parentEpoch = computeEpochAtSlot(parentBlockSlot);
485
492
  if (parentEpoch < blockEpoch) {
486
- this.shufflingCache.processState(postState);
487
- this.logger.verbose("Processed shuffling for next epoch", {parentEpoch, blockEpoch, slot: blockSlot});
493
+ const previousEpoch = blockEpoch === GENESIS_EPOCH ? GENESIS_EPOCH : blockEpoch - 1;
494
+ if (
495
+ !this.shufflingCache.has(previousEpoch, postState.previousDecisionRoot) ||
496
+ !this.shufflingCache.has(blockEpoch, postState.currentDecisionRoot) ||
497
+ !this.shufflingCache.has(blockEpoch + 1, postState.nextDecisionRoot)
498
+ ) {
499
+ this.shufflingCache.processState(postState);
500
+ this.logger.verbose("Processed shuffling for next epoch", {parentEpoch, blockEpoch, slot: blockSlot});
501
+ }
488
502
  }
489
503
 
490
504
  if (blockSlot % SLOTS_PER_EPOCH === 0) {
@@ -8,28 +8,24 @@ import {ImportPayloadOpts} from "./types.js";
8
8
  // TODO GLOAS: Set to be equal to DEFAULT_MAX_PENDING_UNFINALIZED_PAYLOAD_ENVELOPE_WRITES for now
9
9
  const QUEUE_MAX_LENGTH = 16;
10
10
 
11
- enum PayloadEnvelopeImportStatus {
12
- queued = "queued",
13
- importing = "importing",
14
- imported = "imported",
15
- }
16
-
17
11
  /**
18
12
  * PayloadEnvelopeProcessor processes payload envelope jobs in a queued fashion, one after the other.
19
13
  *
20
14
  * Jobs are enqueued only on envelope arrival (gossip or API). The envelope may reach us before
21
15
  * the sampled data columns; importExecutionPayload awaits `verifyPayloadsDataAvailability`
22
16
  * internally, so a queued job can pend for up to `PAYLOAD_DATA_AVAILABILITY_TIMEOUT` while
23
- * waiting for columns. Duplicate triggers for the same payloadInput are deduped via `importStatus`.
17
+ * waiting for columns. Duplicate triggers for the same payloadInput are deduped by sharing the
18
+ * in-flight import promise: every caller observes the real import outcome. Resolving duplicates
19
+ * early instead would let BlockInputSync mark a still-pending payload as processed, re-queue it,
20
+ * and spin re-processing the same root until OOM while the import is blocked (e.g. EL syncing).
24
21
  */
25
22
  export class PayloadEnvelopeProcessor {
26
23
  readonly jobQueue: JobItemQueue<[PayloadEnvelopeInput, ImportPayloadOpts], void>;
27
- private readonly importStatus = new WeakMap<PayloadEnvelopeInput, PayloadEnvelopeImportStatus>();
24
+ private readonly imports = new WeakMap<PayloadEnvelopeInput, Promise<void>>();
28
25
 
29
26
  constructor(chain: BeaconChain, metrics: Metrics | null, signal: AbortSignal) {
30
27
  this.jobQueue = new JobItemQueue<[PayloadEnvelopeInput, ImportPayloadOpts], void>(
31
28
  (payloadInput, opts) => {
32
- this.importStatus.set(payloadInput, PayloadEnvelopeImportStatus.importing);
33
29
  return processExecutionPayload.call(chain, payloadInput, signal, opts);
34
30
  },
35
31
  {maxLength: QUEUE_MAX_LENGTH, noYieldIfOneItem: true, signal},
@@ -38,24 +34,28 @@ export class PayloadEnvelopeProcessor {
38
34
  }
39
35
 
40
36
  async processPayloadEnvelopeJob(payloadInput: PayloadEnvelopeInput, opts: ImportPayloadOpts = {}): Promise<void> {
41
- if (this.importStatus.get(payloadInput) !== undefined) {
42
- return;
37
+ const existing = this.imports.get(payloadInput);
38
+ if (existing) {
39
+ return existing;
43
40
  }
44
41
 
45
42
  await this.jobQueue.waitForSpace();
46
43
 
47
44
  // Re-check after await, as another call may have queued this payload.
48
- if (this.importStatus.get(payloadInput) !== undefined) {
49
- return;
45
+ const queued = this.imports.get(payloadInput);
46
+ if (queued) {
47
+ return queued;
50
48
  }
51
49
 
52
- this.importStatus.set(payloadInput, PayloadEnvelopeImportStatus.queued);
50
+ const importPromise = this.jobQueue.push(payloadInput, opts);
51
+ this.imports.set(payloadInput, importPromise);
53
52
 
54
53
  try {
55
- await this.jobQueue.push(payloadInput, opts);
56
- this.importStatus.set(payloadInput, PayloadEnvelopeImportStatus.imported);
54
+ await importPromise;
57
55
  } catch (e) {
58
- this.importStatus.delete(payloadInput);
56
+ // Drop the failed import so a later attempt can retry once the failure cause is resolved
57
+ // (e.g. BLOCK_NOT_IN_FORK_CHOICE after the block lands in fork choice)
58
+ this.imports.delete(payloadInput);
59
59
  throw e;
60
60
  }
61
61
  }
@@ -107,7 +107,6 @@ import {
107
107
  SeenExecutionPayloadBids,
108
108
  SeenPayloadAttesters,
109
109
  SeenPayloadEnvelopeInput,
110
- SeenProposerPreferences,
111
110
  SeenSyncCommitteeMessages,
112
111
  } from "./seenCache/index.js";
113
112
  import {SeenAggregatedAttestations} from "./seenCache/seenAggregateAndProof.js";
@@ -189,7 +188,6 @@ export class BeaconChain implements IBeaconChain {
189
188
  readonly seenPayloadAttesters = new SeenPayloadAttesters();
190
189
  readonly seenAggregatedAttestations: SeenAggregatedAttestations;
191
190
  readonly seenExecutionPayloadBids = new SeenExecutionPayloadBids();
192
- readonly seenProposerPreferences = new SeenProposerPreferences();
193
191
  readonly seenBlockProposers = new SeenBlockProposers();
194
192
  readonly seenSyncCommitteeMessages = new SeenSyncCommitteeMessages();
195
193
  readonly seenContributionAndProof: SeenContributionAndProof;
@@ -1473,7 +1471,6 @@ export class BeaconChain implements IBeaconChain {
1473
1471
  this.payloadAttestationPool.prune(slot);
1474
1472
  this.executionPayloadBidPool.prune(slot);
1475
1473
  this.seenExecutionPayloadBids.prune(slot);
1476
- this.seenProposerPreferences.prune(slot);
1477
1474
  this.proposerPreferencesPool.prune(slot);
1478
1475
  this.seenAttestationDatas.onSlot(slot);
1479
1476
  this.reprocessController.onSlot(slot);
@@ -61,7 +61,6 @@ import {
61
61
  SeenContributionAndProof,
62
62
  SeenExecutionPayloadBids,
63
63
  SeenPayloadAttesters,
64
- SeenProposerPreferences,
65
64
  SeenSyncCommitteeMessages,
66
65
  } from "./seenCache/index.js";
67
66
  import {SeenAggregatedAttestations} from "./seenCache/seenAggregateAndProof.js";
@@ -133,7 +132,6 @@ export interface IBeaconChain {
133
132
  readonly seenPayloadAttesters: SeenPayloadAttesters;
134
133
  readonly seenAggregatedAttestations: SeenAggregatedAttestations;
135
134
  readonly seenExecutionPayloadBids: SeenExecutionPayloadBids;
136
- readonly seenProposerPreferences: SeenProposerPreferences;
137
135
  readonly seenBlockProposers: SeenBlockProposers;
138
136
  readonly seenSyncCommitteeMessages: SeenSyncCommitteeMessages;
139
137
  readonly seenContributionAndProof: SeenContributionAndProof;
@@ -1,4 +1,4 @@
1
- import {RootHex, Slot, gloas} from "@lodestar/types";
1
+ import {RootHex, Slot, ValidatorIndex, gloas} from "@lodestar/types";
2
2
  import {toRootHex} from "@lodestar/utils";
3
3
 
4
4
  /**
@@ -22,6 +22,10 @@ export class ProposerPreferencesPool {
22
22
  return this.bySlot.get(slot)?.get(dependentRootHex) ?? null;
23
23
  }
24
24
 
25
+ isKnown(proposalSlot: Slot, dependentRoot: RootHex, validatorIndex: ValidatorIndex): boolean {
26
+ return this.get(proposalSlot, dependentRoot)?.message.validatorIndex === validatorIndex;
27
+ }
28
+
25
29
  add(signed: gloas.SignedProposerPreferences): void {
26
30
  const {proposalSlot, dependentRoot} = signed.message;
27
31
  const rootHex = toRootHex(dependentRoot);
@@ -35,6 +35,7 @@ export type IChainOptions = BlockProcessOpts &
35
35
  persistOrphanedBlocksDir?: string;
36
36
  skipCreateStateCacheIfAvailable?: boolean;
37
37
  suggestedFeeRecipient: string;
38
+ graffitiAppend?: boolean;
38
39
  maxSkipSlots?: number;
39
40
  /** Ensure blobs returned by the execution engine are valid */
40
41
  sanityCheckExecutionEngineBlobs?: boolean;
@@ -107,6 +108,7 @@ export const defaultChainOptions: IChainOptions = {
107
108
  computeUnrealized: true,
108
109
  fastConfirmation: false,
109
110
  suggestedFeeRecipient: defaultValidatorOptions.suggestedFeeRecipient,
111
+ graffitiAppend: true,
110
112
  serveHistoricalState: false,
111
113
  assertCorrectProgressiveBalances: false,
112
114
  archiveStateEpochFrequency: 1024,
@@ -5,4 +5,3 @@ export {SeenContributionAndProof} from "./seenCommitteeContribution.js";
5
5
  export {SeenExecutionPayloadBids} from "./seenExecutionPayloadBids.js";
6
6
  export {SeenBlockInput} from "./seenGossipBlockInput.js";
7
7
  export {PayloadEnvelopeInput, SeenPayloadEnvelopeInput} from "./seenPayloadEnvelopeInput.js";
8
- export {SeenProposerPreferences} from "./seenProposerPreferences.js";
@@ -152,6 +152,14 @@ export class SeenPayloadEnvelopeInput {
152
152
  return this.payloadInputs.size;
153
153
  }
154
154
 
155
+ prune(blockRootHex: RootHex): void {
156
+ const input = this.payloadInputs.get(blockRootHex);
157
+ if (input) {
158
+ this.evictPayloadInput(input);
159
+ this.logger?.verbose("SeenPayloadEnvelopeInput.prune deleted", {slot: input.slot, root: blockRootHex});
160
+ }
161
+ }
162
+
155
163
  pruneBelowParent(parentBlock: ProtoBlock): void {
156
164
  for (const block of this.forkChoice.getAllAncestorBlocks(parentBlock.blockRoot, parentBlock.payloadStatus)) {
157
165
  // Only evict once the payload is FULL (revealed/imported) — on an EMPTY/PENDING branch we may
@@ -1,4 +1,4 @@
1
- import {ForkSeq} from "@lodestar/params";
1
+ import {ForkSeq, GENESIS_EPOCH} from "@lodestar/params";
2
2
  import {
3
3
  EpochShuffling,
4
4
  IBeaconStateView,
@@ -155,19 +155,35 @@ export class ShufflingCache {
155
155
  return null;
156
156
  }
157
157
 
158
+ /**
159
+ * Check if a shuffling is cached by (epoch, decisionRoot).
160
+ * For native bindings, this avoids having to memoize heavy epoch shufflings within
161
+ * `NativeBeaconStateView`.
162
+ */
163
+ has(epoch: Epoch, decisionRoot: RootHex): boolean {
164
+ const cacheItem = this.itemsByDecisionRootByEpoch.getOrDefault(epoch).get(decisionRoot);
165
+ return cacheItem !== undefined && isShufflingCacheItem(cacheItem);
166
+ }
167
+
158
168
  /**
159
169
  * Process a state to extract and cache all shufflings (previous, current, next).
160
170
  * Uses the stored decision roots from epochCtx.
161
171
  */
162
172
  processState(state: IBeaconStateView): void {
163
- // Cache previous shuffling
164
- this.set(state.getPreviousShuffling(), state.previousDecisionRoot);
173
+ const currentEpoch = state.epoch;
174
+ const previousEpoch = currentEpoch === GENESIS_EPOCH ? GENESIS_EPOCH : currentEpoch - 1;
165
175
 
166
- // Cache current shuffling
167
- this.set(state.getCurrentShuffling(), state.currentDecisionRoot);
176
+ if (!this.has(previousEpoch, state.previousDecisionRoot)) {
177
+ this.set(state.getPreviousShuffling(), state.previousDecisionRoot);
178
+ }
179
+
180
+ if (!this.has(currentEpoch, state.currentDecisionRoot)) {
181
+ this.set(state.getCurrentShuffling(), state.currentDecisionRoot);
182
+ }
168
183
 
169
- // Cache next shuffling
170
- this.set(state.getNextShuffling(), state.nextDecisionRoot);
184
+ if (!this.has(currentEpoch + 1, state.nextDecisionRoot)) {
185
+ this.set(state.getNextShuffling(), state.nextDecisionRoot);
186
+ }
171
187
  }
172
188
 
173
189
  getIndexedAttestation(
@@ -331,8 +331,9 @@ export class PersistentCheckpointStateCache implements CheckpointStateCache {
331
331
  const key = toCacheKey(cpHex);
332
332
  const cacheItem = this.cache.get(key);
333
333
  this.metrics?.cpStateCache.adds.inc();
334
- if (cacheItem !== undefined && isPersistedCacheItem(cacheItem)) {
335
- const persistedKey = cacheItem.value;
334
+ // keep an existing persistedKey (persisted or reloaded-in-memory); dropping it orphans the on-disk file
335
+ const persistedKey = cacheItem && (isPersistedCacheItem(cacheItem) ? cacheItem.value : cacheItem.persistedKey);
336
+ if (persistedKey !== undefined) {
336
337
  // was persisted to disk, set back to memory
337
338
  this.cache.set(key, {type: CacheItemType.inMemory, state, persistedKey});
338
339
  this.logger.verbose("Added checkpoint state to memory but a persisted key existed", {
@@ -81,7 +81,7 @@ export async function validateGossipProposerPreferences(
81
81
  }
82
82
 
83
83
  // [IGNORE] First valid message for (dependent_root, proposal_slot, validator_index).
84
- if (chain.seenProposerPreferences.isKnown(dependentRootHex, proposalSlot, validatorIndex)) {
84
+ if (chain.proposerPreferencesPool.isKnown(proposalSlot, dependentRootHex, validatorIndex)) {
85
85
  throw new ProposerPreferencesError(GossipAction.IGNORE, {
86
86
  code: ProposerPreferencesErrorCode.ALREADY_KNOWN,
87
87
  proposalSlot,
@@ -105,6 +105,15 @@ export async function validateGossipProposerPreferences(
105
105
  });
106
106
  }
107
107
 
108
- // Valid
109
- chain.seenProposerPreferences.add(dependentRootHex, proposalSlot, validatorIndex);
108
+ // Repeated check - deals with race-condition between preferences submissions
109
+ if (chain.proposerPreferencesPool.isKnown(proposalSlot, dependentRootHex, validatorIndex)) {
110
+ throw new ProposerPreferencesError(GossipAction.IGNORE, {
111
+ code: ProposerPreferencesErrorCode.ALREADY_KNOWN,
112
+ proposalSlot,
113
+ validatorIndex,
114
+ dependentRoot: dependentRootHex,
115
+ });
116
+ }
117
+
118
+ chain.proposerPreferencesPool.add(signedProposerPreferences);
110
119
  }
@@ -141,6 +141,22 @@ export class PeerDiscovery {
141
141
  this.discv5FirstQueryDelayMs = opts.discv5FirstQueryDelayMs;
142
142
  this.connectToDiscv5BootnodesOnStart = opts.connectToDiscv5Bootnodes;
143
143
 
144
+ // Transport tags vary by library: @libp2p/tcp uses '@libp2p/tcp', @chainsafe/libp2p-quic uses 'quic'
145
+ // Normalize to simple 'tcp' / 'quic' strings for matching.
146
+ // Must be initialized before the discovery listeners are registered and the bootENRs are processed
147
+ // below, since handleDiscoveredPeer() reads this.transports. Previously this was assigned at the end
148
+ // of the constructor, so when network.connectToDiscv5Bootnodes is enabled every bootENR was processed
149
+ // while this.transports was still undefined, throwing "Cannot read properties of undefined (reading
150
+ // 'includes')" and preventing the node from dialing its bootnodes on startup.
151
+ this.transports = libp2p.services.components.transportManager
152
+ .getTransports()
153
+ .map((t) => t[Symbol.toStringTag])
154
+ .map((tag) => {
155
+ if (tag?.includes("tcp")) return "tcp";
156
+ if (tag?.includes("quic")) return "quic";
157
+ return tag;
158
+ });
159
+
144
160
  this.libp2p.addEventListener("peer:discovery", this.onDiscoveredPeer);
145
161
  this.discv5.on("discovered", this.onDiscoveredENR);
146
162
 
@@ -183,17 +199,6 @@ export class PeerDiscovery {
183
199
  }
184
200
  });
185
201
  }
186
-
187
- // Transport tags vary by library: @libp2p/tcp uses '@libp2p/tcp', @chainsafe/libp2p-quic uses 'quic'
188
- // Normalize to simple 'tcp' / 'quic' strings for matching
189
- this.transports = libp2p.services.components.transportManager
190
- .getTransports()
191
- .map((t) => t[Symbol.toStringTag])
192
- .map((tag) => {
193
- if (tag?.includes("tcp")) return "tcp";
194
- if (tag?.includes("quic")) return "quic";
195
- return tag;
196
- });
197
202
  }
198
203
 
199
204
  static async init(modules: PeerDiscoveryModules, opts: PeerDiscoveryOpts): Promise<PeerDiscovery> {
@@ -165,6 +165,7 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand
165
165
  // tracked in https://github.com/ChainSafe/lodestar/issues/7957
166
166
 
167
167
  const logCtx = {
168
+ slot,
168
169
  currentSlot: chain.clock.currentSlot,
169
170
  peerId: peerIdStr,
170
171
  delaySec,
@@ -184,20 +185,24 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand
184
185
  seenTimestampSec,
185
186
  peerIdStr,
186
187
  });
188
+
189
+ // Optimistically seed the payload-envelope cache too, mirroring seenBlockInputCache above.
190
+ // This ensures we have PayloadEnvelopeInput, even through "PARENT_UNKNOWN" error
191
+ // see https://github.com/ChainSafe/lodestar/issues/9475
192
+ if (isForkPostGloas(fork)) {
193
+ chain.seenPayloadEnvelopeInputCache.add({
194
+ blockRootHex,
195
+ block: signedBlock as SignedBeaconBlock<ForkPostGloas>,
196
+ forkName: fork,
197
+ sampledColumns: chain.custodyConfig.sampledColumns,
198
+ custodyColumns: chain.custodyConfig.custodyColumns,
199
+ timeCreatedSec: seenTimestampSec,
200
+ });
201
+ }
202
+
187
203
  try {
188
204
  const {skippedSlots} = await validateGossipBlock(config, chain, signedBlock, fork);
189
205
 
190
- if (isForkPostGloas(fork)) {
191
- chain.seenPayloadEnvelopeInputCache.add({
192
- blockRootHex,
193
- block: signedBlock as SignedBeaconBlock<ForkPostGloas>,
194
- forkName: fork,
195
- sampledColumns: chain.custodyConfig.sampledColumns,
196
- custodyColumns: chain.custodyConfig.custodyColumns,
197
- timeCreatedSec: seenTimestampSec,
198
- });
199
- }
200
-
201
206
  const blockInputMeta = blockInput.getLogMeta();
202
207
 
203
208
  const recvToValidation = Date.now() / 1000 - seenTimestampSec;
@@ -234,12 +239,22 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand
234
239
  throw e;
235
240
  }
236
241
 
237
- if (e.action === GossipAction.REJECT) {
238
- chain.persistInvalidSszValue(forkTypes.SignedBeaconBlock, signedBlock, `gossip_reject_slot_${slot}`);
242
+ // IGNORE means the block is acceptable (e.g. FUTURE_SLOT, ALREADY_KNOWN), just not propagated.
243
+ // Keep the optimistically-added cache entries; they are pruned on finalization. Only REJECT
244
+ // (provably invalid) and unexpected errors prune below.
245
+ if (e.action === GossipAction.IGNORE) {
246
+ throw e;
239
247
  }
248
+
249
+ chain.persistInvalidSszValue(forkTypes.SignedBeaconBlock, signedBlock, `gossip_reject_slot_${slot}`);
240
250
  }
241
251
 
252
+ // REJECT or unexpected (non-BlockGossipError) error: drop the optimistically-added entries from
253
+ // both caches, keeping them consistent.
242
254
  chain.seenBlockInputCache.prune(blockRootHex);
255
+ if (isForkPostGloas(fork)) {
256
+ chain.seenPayloadEnvelopeInputCache.prune(blockRootHex);
257
+ }
243
258
  throw e;
244
259
  }
245
260
  }
@@ -1127,9 +1142,9 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand
1127
1142
  const delaySec = chain.clock.secFromSlot(slot, seenTimestampSec);
1128
1143
 
1129
1144
  logger.debug("Received gossip payload envelope", {
1145
+ slot,
1130
1146
  currentSlot: chain.clock.currentSlot,
1131
1147
  peerId: peerIdStr,
1132
- slot,
1133
1148
  blockRoot: toRootHex(envelope.beaconBlockRoot),
1134
1149
  delaySec,
1135
1150
  });
@@ -1258,7 +1273,6 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand
1258
1273
  const signedProposerPreferences = sszDeserialize(topic, serializedData);
1259
1274
  await validateGossipProposerPreferences(chain, signedProposerPreferences);
1260
1275
 
1261
- chain.proposerPreferencesPool.add(signedProposerPreferences);
1262
1276
  chain.emitter.emit(routes.events.EventType.proposerPreferences, {
1263
1277
  version: ForkName.gloas,
1264
1278
  data: signedProposerPreferences,
@@ -186,10 +186,6 @@ export class SyncChain {
186
186
  this.logger = logger;
187
187
  this.logId = `${syncType}-${nextChainId++}`;
188
188
 
189
- if (metrics) {
190
- metrics.syncRange.headSyncPeers.addCollect(() => this.scrapeMetrics(metrics));
191
- }
192
-
193
189
  // Trigger event on parent class
194
190
  this.sync().then(
195
191
  () => fns.onEnd(null, this.target),
@@ -775,7 +771,10 @@ export class SyncChain {
775
771
  });
776
772
  }
777
773
 
778
- private scrapeMetrics(metrics: Metrics): void {
774
+ /**
775
+ * Called by `RangeSync`'s to avoid collecting metrics of removed chains.
776
+ */
777
+ scrapeMetrics(metrics: Metrics): void {
779
778
  const syncPeersMetric =
780
779
  this.syncType === RangeSyncType.Finalized
781
780
  ? metrics.syncRange.finalizedSyncPeers
@@ -105,6 +105,16 @@ export class RangeSync extends (EventEmitter as {new (): RangeSyncEmitter}) {
105
105
 
106
106
  if (metrics) {
107
107
  metrics.syncStatus.addCollect(() => this.scrapeMetrics(metrics));
108
+ metrics.syncRange.headSyncPeers.addCollect(() => {
109
+ // Gauges retain their last set value, so a removed chain would keep reporting stale
110
+ // per-column peer counts (finalizedSyncPeers especially, as it is rarely recreated once
111
+ // synced)
112
+ metrics.syncRange.headSyncPeers.reset();
113
+ metrics.syncRange.finalizedSyncPeers.reset();
114
+ for (const syncChain of this.chains.values()) {
115
+ syncChain.scrapeMetrics(metrics);
116
+ }
117
+ });
108
118
  }
109
119
  }
110
120
 
@@ -142,6 +142,7 @@ export function cacheByRangeResponses({
142
142
  const blockRootHex = toRootHex(blockRoot);
143
143
 
144
144
  const existing = updatedBatchBlocks.get(block.message.slot);
145
+ let blockInput: IBlockInput;
145
146
  if (existing) {
146
147
  // In practice this code block shouldn't be reached because we shouldn't be refetching a block we already have, see Batch#getRequests.
147
148
  // Will throw if root hex does not match (meaning we are following the wrong chain)
@@ -155,8 +156,9 @@ export function cacheByRangeResponses({
155
156
  },
156
157
  {throwOnDuplicateAdd: false}
157
158
  );
159
+ blockInput = existing;
158
160
  } else {
159
- const blockInput = cache.getByBlock({
161
+ blockInput = cache.getByBlock({
160
162
  block,
161
163
  blockRootHex,
162
164
  source,
@@ -165,6 +167,21 @@ export function cacheByRangeResponses({
165
167
  });
166
168
  updatedBatchBlocks.set(blockInput.slot, blockInput);
167
169
  }
170
+
171
+ // Seed seenPayloadEnvelopeInputCache the instant we have the block, before the blob loop (or any
172
+ // later step) can throw and abort the batch — otherwise a gloas block would sit in
173
+ // seenBlockInputCache but unseeded here, and payload-by-root sync would later throw "Missing
174
+ // PayloadEnvelopeInput for known block" (see issue #9306). add() is idempotent.
175
+ if (isForkPostGloas(blockInput.forkName)) {
176
+ seenPayloadEnvelopeInputCache.add({
177
+ blockRootHex: blockInput.blockRootHex,
178
+ block: blockInput.getBlock() as SignedBeaconBlock<ForkPostGloas>,
179
+ forkName: blockInput.forkName,
180
+ sampledColumns: custodyConfig.sampledColumns,
181
+ custodyColumns: custodyConfig.custodyColumns,
182
+ timeCreatedSec: seenTimestampSec,
183
+ });
184
+ }
168
185
  }
169
186
 
170
187
  for (const {blockRoot, blobSidecars} of responses.validatedBlobSidecars ?? []) {
@@ -205,22 +222,6 @@ export function cacheByRangeResponses({
205
222
  }
206
223
  }
207
224
 
208
- // Seed seenPayloadEnvelopeInputCache for every gloas block in the batch, regardless of whether
209
- // the peer returned its envelope. Without this, a block returned without its envelope would be
210
- // imported with no cache entry, and later payload-by-root sync would throw
211
- // "Missing PayloadEnvelopeInput for known block" (see issue #9306).
212
- for (const blockInput of updatedBatchBlocks.values()) {
213
- if (!blockInput.hasBlock() || !isForkPostGloas(blockInput.forkName)) continue;
214
- seenPayloadEnvelopeInputCache.add({
215
- blockRootHex: blockInput.blockRootHex,
216
- block: blockInput.getBlock() as SignedBeaconBlock<ForkPostGloas>,
217
- forkName: blockInput.forkName,
218
- sampledColumns: custodyConfig.sampledColumns,
219
- custodyColumns: custodyConfig.custodyColumns,
220
- timeCreatedSec: seenTimestampSec,
221
- });
222
- }
223
-
224
225
  let payloadEnvelopes: Map<Slot, PayloadEnvelopeInput> | null =
225
226
  existingPayloadEnvelopes !== null ? new Map(existingPayloadEnvelopes) : null;
226
227
  if (downloadedPayloadEnvelopes !== null) {
@@ -229,8 +230,8 @@ export function cacheByRangeResponses({
229
230
  const envelopeBlockRootHex = toRootHex(envelope.message.beaconBlockRoot);
230
231
  const payloadInput = seenPayloadEnvelopeInputCache.get(envelopeBlockRootHex);
231
232
  if (payloadInput === undefined) {
232
- // Unreachable given the loop above seeded an entry for every gloas block in the batch.
233
- // for the parent block, it's populated at BeaconChain init
233
+ // Unreachable given the validatedBlocks loop above seeded an entry for every gloas block in
234
+ // the batch. for the parent block, it's populated at BeaconChain init
234
235
  throw new Error(`Missing PayloadEnvelopeInput for block ${envelopeBlockRootHex}`);
235
236
  }
236
237
 
@@ -980,6 +981,12 @@ export async function validateColumnsByRangeResponse(
980
981
  missingIndices: prettyPrintIndices(request.columns),
981
982
  })
982
983
  );
984
+ // Gloas: the bid commits to blobs even for payloads that get orphaned, whose columns are
985
+ // pruned and unservable. Skip instead of truncating + re-requesting forever; DA is enforced
986
+ // on envelope processing. Pre-Gloas commitments are canonical, so keep strict.
987
+ if (isForkPostGloas(forkName)) {
988
+ continue;
989
+ }
983
990
  break;
984
991
  }
985
992