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

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 (170) hide show
  1. package/lib/api/impl/beacon/index.d.ts +1 -1
  2. package/lib/api/impl/beacon/index.d.ts.map +1 -1
  3. package/lib/api/impl/beacon/index.js.map +1 -1
  4. package/lib/api/impl/beacon/pool/index.d.ts.map +1 -1
  5. package/lib/api/impl/beacon/pool/index.js +6 -43
  6. package/lib/api/impl/beacon/pool/index.js.map +1 -1
  7. package/lib/api/impl/beacon/state/index.d.ts +1 -1
  8. package/lib/api/impl/beacon/state/index.d.ts.map +1 -1
  9. package/lib/api/impl/beacon/state/index.js +48 -5
  10. package/lib/api/impl/beacon/state/index.js.map +1 -1
  11. package/lib/api/impl/beacon/state/utils.d.ts +13 -3
  12. package/lib/api/impl/beacon/state/utils.d.ts.map +1 -1
  13. package/lib/api/impl/beacon/state/utils.js +48 -4
  14. package/lib/api/impl/beacon/state/utils.js.map +1 -1
  15. package/lib/api/impl/debug/index.d.ts +1 -1
  16. package/lib/api/impl/debug/index.d.ts.map +1 -1
  17. package/lib/api/impl/debug/index.js +2 -2
  18. package/lib/api/impl/debug/index.js.map +1 -1
  19. package/lib/api/impl/lodestar/index.js +2 -2
  20. package/lib/api/impl/lodestar/index.js.map +1 -1
  21. package/lib/api/impl/proof/index.d.ts +1 -1
  22. package/lib/api/impl/proof/index.d.ts.map +1 -1
  23. package/lib/api/impl/proof/index.js +2 -2
  24. package/lib/api/impl/proof/index.js.map +1 -1
  25. package/lib/api/impl/utils.d.ts +23 -0
  26. package/lib/api/impl/utils.d.ts.map +1 -1
  27. package/lib/api/impl/utils.js +44 -1
  28. package/lib/api/impl/utils.js.map +1 -1
  29. package/lib/api/impl/validator/index.d.ts +0 -14
  30. package/lib/api/impl/validator/index.d.ts.map +1 -1
  31. package/lib/api/impl/validator/index.js +62 -61
  32. package/lib/api/impl/validator/index.js.map +1 -1
  33. package/lib/chain/blocks/importBlock.d.ts.map +1 -1
  34. package/lib/chain/blocks/importBlock.js +10 -8
  35. package/lib/chain/blocks/importBlock.js.map +1 -1
  36. package/lib/chain/blocks/payloadEnvelopeProcessor.d.ts +5 -2
  37. package/lib/chain/blocks/payloadEnvelopeProcessor.d.ts.map +1 -1
  38. package/lib/chain/blocks/payloadEnvelopeProcessor.js +17 -17
  39. package/lib/chain/blocks/payloadEnvelopeProcessor.js.map +1 -1
  40. package/lib/chain/chain.d.ts +1 -2
  41. package/lib/chain/chain.d.ts.map +1 -1
  42. package/lib/chain/chain.js +1 -3
  43. package/lib/chain/chain.js.map +1 -1
  44. package/lib/chain/interface.d.ts +1 -2
  45. package/lib/chain/interface.d.ts.map +1 -1
  46. package/lib/chain/interface.js.map +1 -1
  47. package/lib/chain/opPools/proposerPreferencesPool.d.ts +2 -1
  48. package/lib/chain/opPools/proposerPreferencesPool.d.ts.map +1 -1
  49. package/lib/chain/opPools/proposerPreferencesPool.js +3 -0
  50. package/lib/chain/opPools/proposerPreferencesPool.js.map +1 -1
  51. package/lib/chain/options.d.ts +1 -0
  52. package/lib/chain/options.d.ts.map +1 -1
  53. package/lib/chain/options.js +1 -0
  54. package/lib/chain/options.js.map +1 -1
  55. package/lib/chain/produceBlock/produceBlockBody.d.ts.map +1 -1
  56. package/lib/chain/produceBlock/produceBlockBody.js +4 -12
  57. package/lib/chain/produceBlock/produceBlockBody.js.map +1 -1
  58. package/lib/chain/seenCache/index.d.ts +0 -1
  59. package/lib/chain/seenCache/index.d.ts.map +1 -1
  60. package/lib/chain/seenCache/index.js +0 -1
  61. package/lib/chain/seenCache/index.js.map +1 -1
  62. package/lib/chain/seenCache/seenPayloadEnvelopeInput.d.ts +1 -0
  63. package/lib/chain/seenCache/seenPayloadEnvelopeInput.d.ts.map +1 -1
  64. package/lib/chain/seenCache/seenPayloadEnvelopeInput.js +7 -0
  65. package/lib/chain/seenCache/seenPayloadEnvelopeInput.js.map +1 -1
  66. package/lib/chain/shufflingCache.d.ts +6 -0
  67. package/lib/chain/shufflingCache.d.ts.map +1 -1
  68. package/lib/chain/shufflingCache.js +21 -6
  69. package/lib/chain/shufflingCache.js.map +1 -1
  70. package/lib/chain/stateCache/persistentCheckpointsCache.d.ts.map +1 -1
  71. package/lib/chain/stateCache/persistentCheckpointsCache.js +3 -2
  72. package/lib/chain/stateCache/persistentCheckpointsCache.js.map +1 -1
  73. package/lib/chain/validation/executionPayloadBid.js +11 -10
  74. package/lib/chain/validation/executionPayloadBid.js.map +1 -1
  75. package/lib/chain/validation/proposerPreferences.d.ts.map +1 -1
  76. package/lib/chain/validation/proposerPreferences.js +14 -5
  77. package/lib/chain/validation/proposerPreferences.js.map +1 -1
  78. package/lib/network/core/metrics.d.ts +65 -0
  79. package/lib/network/core/metrics.d.ts.map +1 -1
  80. package/lib/network/core/metrics.js +94 -0
  81. package/lib/network/core/metrics.js.map +1 -1
  82. package/lib/network/discv5/utils.d.ts.map +1 -1
  83. package/lib/network/discv5/utils.js +12 -3
  84. package/lib/network/discv5/utils.js.map +1 -1
  85. package/lib/network/gossip/encoding.d.ts.map +1 -1
  86. package/lib/network/gossip/encoding.js +7 -0
  87. package/lib/network/gossip/encoding.js.map +1 -1
  88. package/lib/network/gossip/gossipsub.d.ts.map +1 -1
  89. package/lib/network/gossip/gossipsub.js +2 -1
  90. package/lib/network/gossip/gossipsub.js.map +1 -1
  91. package/lib/network/gossip/topic.d.ts +6 -1
  92. package/lib/network/gossip/topic.d.ts.map +1 -1
  93. package/lib/network/gossip/topic.js +23 -3
  94. package/lib/network/gossip/topic.js.map +1 -1
  95. package/lib/network/peers/discover.d.ts.map +1 -1
  96. package/lib/network/peers/discover.js +17 -12
  97. package/lib/network/peers/discover.js.map +1 -1
  98. package/lib/network/peers/peerManager.d.ts.map +1 -1
  99. package/lib/network/peers/peerManager.js +10 -0
  100. package/lib/network/peers/peerManager.js.map +1 -1
  101. package/lib/network/peers/score/score.d.ts +3 -1
  102. package/lib/network/peers/score/score.d.ts.map +1 -1
  103. package/lib/network/peers/score/score.js +6 -1
  104. package/lib/network/peers/score/score.js.map +1 -1
  105. package/lib/network/peers/score/store.d.ts.map +1 -1
  106. package/lib/network/peers/score/store.js +6 -1
  107. package/lib/network/peers/score/store.js.map +1 -1
  108. package/lib/network/peers/utils/prioritizePeers.d.ts.map +1 -1
  109. package/lib/network/peers/utils/prioritizePeers.js +14 -0
  110. package/lib/network/peers/utils/prioritizePeers.js.map +1 -1
  111. package/lib/network/processor/gossipHandlers.d.ts.map +1 -1
  112. package/lib/network/processor/gossipHandlers.js +32 -16
  113. package/lib/network/processor/gossipHandlers.js.map +1 -1
  114. package/lib/sync/range/chain.d.ts +4 -1
  115. package/lib/sync/range/chain.d.ts.map +1 -1
  116. package/lib/sync/range/chain.js +3 -3
  117. package/lib/sync/range/chain.js.map +1 -1
  118. package/lib/sync/range/range.d.ts.map +1 -1
  119. package/lib/sync/range/range.js +10 -0
  120. package/lib/sync/range/range.js.map +1 -1
  121. package/lib/sync/utils/downloadByRange.d.ts.map +1 -1
  122. package/lib/sync/utils/downloadByRange.js +25 -19
  123. package/lib/sync/utils/downloadByRange.js.map +1 -1
  124. package/lib/util/graffiti.d.ts +15 -0
  125. package/lib/util/graffiti.d.ts.map +1 -1
  126. package/lib/util/graffiti.js +55 -0
  127. package/lib/util/graffiti.js.map +1 -1
  128. package/package.json +18 -18
  129. package/src/api/impl/beacon/index.ts +1 -1
  130. package/src/api/impl/beacon/pool/index.ts +5 -53
  131. package/src/api/impl/beacon/state/index.ts +55 -3
  132. package/src/api/impl/beacon/state/utils.ts +68 -4
  133. package/src/api/impl/debug/index.ts +3 -2
  134. package/src/api/impl/lodestar/index.ts +2 -2
  135. package/src/api/impl/proof/index.ts +2 -2
  136. package/src/api/impl/utils.ts +51 -1
  137. package/src/api/impl/validator/index.ts +70 -66
  138. package/src/chain/blocks/importBlock.ts +19 -10
  139. package/src/chain/blocks/payloadEnvelopeProcessor.ts +17 -17
  140. package/src/chain/chain.ts +0 -3
  141. package/src/chain/interface.ts +0 -2
  142. package/src/chain/opPools/proposerPreferencesPool.ts +5 -1
  143. package/src/chain/options.ts +2 -0
  144. package/src/chain/produceBlock/produceBlockBody.ts +9 -17
  145. package/src/chain/seenCache/index.ts +0 -1
  146. package/src/chain/seenCache/seenPayloadEnvelopeInput.ts +8 -0
  147. package/src/chain/shufflingCache.ts +23 -7
  148. package/src/chain/stateCache/persistentCheckpointsCache.ts +3 -2
  149. package/src/chain/validation/executionPayloadBid.ts +14 -9
  150. package/src/chain/validation/proposerPreferences.ts +15 -5
  151. package/src/network/core/metrics.ts +94 -0
  152. package/src/network/discv5/utils.ts +11 -3
  153. package/src/network/gossip/encoding.ts +7 -0
  154. package/src/network/gossip/gossipsub.ts +2 -1
  155. package/src/network/gossip/topic.ts +28 -4
  156. package/src/network/peers/discover.ts +16 -11
  157. package/src/network/peers/peerManager.ts +11 -0
  158. package/src/network/peers/score/score.ts +8 -1
  159. package/src/network/peers/score/store.ts +8 -1
  160. package/src/network/peers/utils/prioritizePeers.ts +15 -0
  161. package/src/network/processor/gossipHandlers.ts +35 -16
  162. package/src/sync/range/chain.ts +4 -5
  163. package/src/sync/range/range.ts +10 -0
  164. package/src/sync/utils/downloadByRange.ts +26 -19
  165. package/src/util/graffiti.ts +78 -0
  166. package/lib/chain/seenCache/seenProposerPreferences.d.ts +0 -16
  167. package/lib/chain/seenCache/seenProposerPreferences.d.ts.map +0 -1
  168. package/lib/chain/seenCache/seenProposerPreferences.js +0 -26
  169. package/lib/chain/seenCache/seenProposerPreferences.js.map +0 -1
  170. package/src/chain/seenCache/seenProposerPreferences.ts +0 -32
@@ -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,
@@ -43,6 +43,7 @@ import {
43
43
  ValidatorIndex,
44
44
  Wei,
45
45
  altair,
46
+ bellatrix,
46
47
  capella,
47
48
  deneb,
48
49
  electra,
@@ -830,28 +831,19 @@ export function getPayloadAttributesForSSE(
830
831
  feeRecipient,
831
832
  });
832
833
 
833
- let parentBlockNumber: number;
834
- if (isForkPostGloas(fork)) {
835
- const parentBlock = chain.forkChoice.getBlockHexAndBlockHash(
836
- toRootHex(parentBlockRoot),
837
- toRootHex(parentBlockHash)
838
- );
839
- if (parentBlock?.executionPayloadBlockHash == null) {
840
- throw Error(`Parent block not found in fork choice root=${toRootHex(parentBlockRoot)}`);
841
- }
842
- parentBlockNumber = parentBlock.executionPayloadNumber;
843
- } else {
844
- parentBlockNumber = prepareState.payloadBlockNumber;
845
- }
846
-
847
- const ssePayloadAttributes: SSEPayloadAttributes = {
834
+ const ssePayloadAttributes = {
848
835
  proposerIndex: prepareState.getBeaconProposer(prepareSlot),
849
836
  proposalSlot: prepareSlot,
850
- parentBlockNumber,
851
837
  parentBlockRoot,
852
838
  parentBlockHash,
853
839
  payloadAttributes,
854
- };
840
+ } as SSEPayloadAttributes;
841
+
842
+ if (!isForkPostGloas(fork)) {
843
+ // Removed in Gloas, builders can get the block number from the EL via the block hash if required
844
+ (ssePayloadAttributes as bellatrix.SSEPayloadAttributes).parentBlockNumber = prepareState.payloadBlockNumber;
845
+ }
846
+
855
847
  return ssePayloadAttributes;
856
848
  }
857
849
 
@@ -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", {
@@ -37,9 +37,11 @@ async function validateExecutionPayloadBid(
37
37
  const parentBlockRootHex = toRootHex(bid.parentBlockRoot);
38
38
  const parentBlockHashHex = toRootHex(bid.parentBlockHash);
39
39
 
40
- // [IGNORE] `bid.slot` is the current slot or the next slot.
41
- const currentSlot = chain.clock.currentSlot;
42
- if (bid.slot !== currentSlot && bid.slot !== currentSlot + 1) {
40
+ // [IGNORE] `bid.slot` is the current slot, or the next slot (`bid.slot - 1` is current), allowing for `MAXIMUM_GOSSIP_CLOCK_DISPARITY`.
41
+ if (
42
+ !chain.clock.isCurrentSlotGivenGossipDisparity(bid.slot) &&
43
+ !chain.clock.isCurrentSlotGivenGossipDisparity(bid.slot - 1)
44
+ ) {
43
45
  throw new ExecutionPayloadBidError(GossipAction.IGNORE, {
44
46
  code: ExecutionPayloadBidErrorCode.INVALID_SLOT,
45
47
  builderIndex: bid.builderIndex,
@@ -122,17 +124,20 @@ async function validateExecutionPayloadBid(
122
124
  throw new Error(`Expected gloas+ state for execution payload bid validation, got fork=${state.forkName}`);
123
125
  }
124
126
 
125
- // [REJECT] `bid.builder_index` is a valid/active builder index -- i.e.
126
- // `is_active_builder(state, bid.builder_index)` returns `True`.
127
- let builder: gloas.Builder;
128
- try {
129
- builder = state.getBuilder(bid.builderIndex);
130
- } catch {
127
+ // [REJECT] `bid.builder_index` is within bounds -- i.e. `bid.builder_index < len(state.builders)`.
128
+ // `state.getBuilder` returns a lazy SSZ `getReadonly` view that is not bounds-checked eagerly; an
129
+ // out-of-range index only throws (`LeafNode has no right node`) on deferred field access (e.g. inside
130
+ // `isActiveBuilder`), escaping a try/catch around `getBuilder`. Check the length explicitly instead.
131
+ if (bid.builderIndex >= state.getBuildersLength()) {
131
132
  throw new ExecutionPayloadBidError(GossipAction.REJECT, {
132
133
  code: ExecutionPayloadBidErrorCode.BUILDER_NOT_ELIGIBLE,
133
134
  builderIndex: bid.builderIndex,
134
135
  });
135
136
  }
137
+
138
+ // [REJECT] `bid.builder_index` is a valid/active builder index -- i.e.
139
+ // `is_active_builder(state, bid.builder_index)` returns `True`.
140
+ const builder = state.getBuilder(bid.builderIndex);
136
141
  if (!isActiveBuilder(builder, state.finalizedCheckpoint.epoch)) {
137
142
  throw new ExecutionPayloadBidError(GossipAction.REJECT, {
138
143
  code: ExecutionPayloadBidErrorCode.BUILDER_NOT_ELIGIBLE,
@@ -32,8 +32,9 @@ export async function validateGossipProposerPreferences(
32
32
  });
33
33
  }
34
34
 
35
- // [IGNORE] `preferences.proposal_slot` has not already passed.
36
- const currentSlot = chain.clock.currentSlot;
35
+ // [IGNORE] `preferences.proposal_slot` has not already passed, i.e. `proposal_slot > current_slot`,
36
+ // allowing for `MAXIMUM_GOSSIP_CLOCK_DISPARITY`.
37
+ const currentSlot = chain.clock.currentSlotWithGossipDisparity;
37
38
  if (proposalSlot <= currentSlot) {
38
39
  throw new ProposerPreferencesError(GossipAction.IGNORE, {
39
40
  code: ProposerPreferencesErrorCode.PROPOSAL_SLOT_PASSED,
@@ -81,7 +82,7 @@ export async function validateGossipProposerPreferences(
81
82
  }
82
83
 
83
84
  // [IGNORE] First valid message for (dependent_root, proposal_slot, validator_index).
84
- if (chain.seenProposerPreferences.isKnown(dependentRootHex, proposalSlot, validatorIndex)) {
85
+ if (chain.proposerPreferencesPool.isKnown(proposalSlot, dependentRootHex, validatorIndex)) {
85
86
  throw new ProposerPreferencesError(GossipAction.IGNORE, {
86
87
  code: ProposerPreferencesErrorCode.ALREADY_KNOWN,
87
88
  proposalSlot,
@@ -105,6 +106,15 @@ export async function validateGossipProposerPreferences(
105
106
  });
106
107
  }
107
108
 
108
- // Valid
109
- chain.seenProposerPreferences.add(dependentRootHex, proposalSlot, validatorIndex);
109
+ // Repeated check - deals with race-condition between preferences submissions
110
+ if (chain.proposerPreferencesPool.isKnown(proposalSlot, dependentRootHex, validatorIndex)) {
111
+ throw new ProposerPreferencesError(GossipAction.IGNORE, {
112
+ code: ProposerPreferencesErrorCode.ALREADY_KNOWN,
113
+ proposalSlot,
114
+ validatorIndex,
115
+ dependentRoot: dependentRootHex,
116
+ });
117
+ }
118
+
119
+ chain.proposerPreferencesPool.add(signedProposerPreferences);
110
120
  }
@@ -127,6 +127,100 @@ export function createNetworkCoreMetrics(register: RegistryMetricCreator) {
127
127
  name: "lodestar_peer_manager_starved_bool",
128
128
  help: "Whether lodestar is starved of data while syncing",
129
129
  }),
130
+ /**
131
+ * Core selection/pruning phase of the heartbeat, split out of heartbeatDuration so it
132
+ * can be attributed on its own. Divide by peersEvaluated for time-per-peer.
133
+ */
134
+ prioritizePeersDuration: register.histogram({
135
+ name: "lodestar_peer_manager_prioritize_peers_seconds",
136
+ help: "prioritizePeers function duration in seconds, the core peer selection/pruning algorithm",
137
+ buckets: [0.0005, 0.001, 0.005, 0.01, 0.05, 0.1],
138
+ }),
139
+ /**
140
+ * Score decay + map prune over every tracked peer, split out of heartbeatDuration.
141
+ * This loop iterates the whole score store, so divide by scoreMapSize (not
142
+ * peersEvaluated) for time-per-peer.
143
+ */
144
+ scoreUpdateDuration: register.histogram({
145
+ name: "lodestar_peer_score_update_seconds",
146
+ help: "Peer score store update (decay + prune over all peers) duration in seconds",
147
+ buckets: [0.0005, 0.001, 0.005, 0.01, 0.05, 0.1],
148
+ }),
149
+ /**
150
+ * Denominator for prioritizePeersDuration: it scales with the number of connected
151
+ * healthy peers, so time-per-peer (prioritizePeersDuration / this) is the comparable
152
+ * figure. For scoreUpdateDuration use scoreMapSize instead.
153
+ */
154
+ peersEvaluated: register.histogram({
155
+ name: "lodestar_peer_manager_peers_evaluated_count",
156
+ help: "Number of connected healthy peers evaluated by prioritizePeers per heartbeat, denominator for prioritize_peers_seconds",
157
+ buckets: [0, 25, 50, 75, 100, 150, 200],
158
+ }),
159
+ /**
160
+ * Every peer the manager intends to disconnect. Covers both the top-of-heartbeat
161
+ * bad-score disconnects ("banned"/"score_too_low") and the prioritization reasons;
162
+ * the older peersRequestedToDisconnect gauge only recorded the latter. Counts intent,
163
+ * not completion (goodbye is fire-and-forget).
164
+ */
165
+ peersPruned: register.counter<{reason: string}>({
166
+ name: "lodestar_peer_manager_peers_pruned_total",
167
+ help: "Total peers the peer manager intends to disconnect, labeled by reason (incl. bad-score and prioritization reasons)",
168
+ labelNames: ["reason"],
169
+ }),
170
+ /**
171
+ * Actual peer count per active subnet, to check the min-peers-per-subnet invariant.
172
+ * Buckets straddle the target (TARGET_SUBNET_PEERS = 6).
173
+ */
174
+ peersPerActiveSubnet: register.histogram<{type: SubnetType}>({
175
+ name: "lodestar_peer_manager_peers_per_active_subnet",
176
+ help: "Histogram of connected peer count per active subnet, labeled by subnet type",
177
+ labelNames: ["type"],
178
+ buckets: [0, 2, 4, 6, 8, 12],
179
+ }),
180
+ /**
181
+ * Live outbound ratio, to check the OUTBOUND_PEERS_RATIO (10%) invariant (issue #2215).
182
+ * Denominator is connected healthy peers (banned/disconnected already removed).
183
+ */
184
+ outboundPeersRatio: register.gauge({
185
+ name: "lodestar_peer_manager_outbound_peers_ratio",
186
+ help: "Ratio of outbound peers to total connected healthy peers, verifies the outbound peers invariant",
187
+ }),
188
+ /**
189
+ * Score state crossings (Healthy/Disconnected/Banned). Sensitive to the decay formula,
190
+ * thresholds, and gossip-score weighting.
191
+ */
192
+ scoreStateTransitions: register.counter<{from: string; to: string}>({
193
+ name: "lodestar_peer_score_state_transitions_total",
194
+ help: "Total peer score state transitions, labeled by from and to state (Healthy/Disconnected/Banned)",
195
+ labelNames: ["from", "to"],
196
+ }),
197
+ /**
198
+ * Entry count of the score store (not bytes). Reflects the prune-to-MAX_ENTRIES /
199
+ * SCORE_THRESHOLD retention logic. Also the denominator for scoreUpdateDuration
200
+ * time-per-peer (that loop iterates the whole store, not just connected peers).
201
+ */
202
+ scoreMapSize: register.gauge({
203
+ name: "lodestar_peer_manager_score_map_size",
204
+ help: "Current number of entries in the peer score store",
205
+ }),
206
+ /**
207
+ * Entry count of the connectedPeers map (not bytes). Should track libp2p_peers; a
208
+ * persistent gap indicates the connection leak guarded by leakedConnectionsCount.
209
+ */
210
+ connectedPeersMapSize: register.gauge({
211
+ name: "lodestar_peer_manager_connected_peers_map_size",
212
+ help: "Current number of entries in the peer manager connectedPeers map",
213
+ }),
214
+ /**
215
+ * assertPeerRelevance outcome per Status exchange: "relevant", an irrelevant reason
216
+ * code, or "error". Fires on every status evaluation (inbound requests and our own
217
+ * requestStatus responses both route through onStatus).
218
+ */
219
+ relevanceCheck: register.counter<{result: string}>({
220
+ name: "lodestar_peer_relevance_check_total",
221
+ help: "Total peer relevance checks on Status, labeled by result (relevant or irrelevant reason code)",
222
+ labelNames: ["result"],
223
+ }),
130
224
  },
131
225
  leakedConnectionsCount: register.gauge({
132
226
  name: "lodestar_peer_manager_leaked_connections_count",
@@ -13,9 +13,17 @@ export enum ENRRelevance {
13
13
  }
14
14
 
15
15
  export function enrRelevance(enr: ENR, config: BeaconConfig, clock: IClock): ENRRelevance {
16
- // We are not interested in peers that don't advertise at least one transport (tcp or quic)
17
- const multiaddrTCP = enr.getLocationMultiaddr(ENRKey.tcp);
18
- const multiaddrQUIC = enr.getLocationMultiaddr(ENRKey.quic);
16
+ // We are not interested in peers that don't advertise at least one transport (tcp or quic).
17
+ let multiaddrTCP: ReturnType<ENR["getLocationMultiaddr"]>;
18
+ let multiaddrQUIC: ReturnType<ENR["getLocationMultiaddr"]>;
19
+ // Malformed ENR endpoint fields can make multiaddr conversion throw.
20
+ // Treat endpoints that cannot be converted as unusable.
21
+ try {
22
+ multiaddrTCP = enr.getLocationMultiaddr(ENRKey.tcp);
23
+ multiaddrQUIC = enr.getLocationMultiaddr(ENRKey.quic);
24
+ } catch {
25
+ return ENRRelevance.no_transport;
26
+ }
19
27
  if (!multiaddrTCP && !multiaddrQUIC) {
20
28
  return ENRRelevance.no_transport;
21
29
  }
@@ -52,6 +52,13 @@ export function fastMsgIdFn(rpcMsg: RPC.Message): string {
52
52
  }
53
53
 
54
54
  export function msgIdToStrFn(msgId: Uint8Array): string {
55
+ // Spec mandates a 20-byte gossipsub message-id, but control-message IDs from peers
56
+ // can be any length. Reject non-20-byte IDs to avoid a Buffer.set() RangeError on
57
+ // longer IDs and stale shared-buffer bytes on shorter ones.
58
+ // Ref: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.10/specs/phase0/p2p-interface.md?plain=1#L486
59
+ if (msgId.length !== 20) {
60
+ throw Error(`Expect msgId to be 20 bytes, got ${msgId.length}`);
61
+ }
55
62
  // this is the same logic to `toHex(msgId)` with better performance
56
63
  sharedMsgIdBuf.set(msgId);
57
64
  return `0x${sharedMsgIdBuf.toString("hex")}`;
@@ -35,7 +35,7 @@ import {
35
35
  computeGossipPeerScoreParams,
36
36
  gossipScoreThresholds,
37
37
  } from "./scoringParameters.js";
38
- import {GossipTopicCache, getCoreTopicsAtFork, stringifyGossipTopic} from "./topic.js";
38
+ import {GossipTopicCache, getAllowedTopics, getCoreTopicsAtFork, stringifyGossipTopic} from "./topic.js";
39
39
 
40
40
  /** As specified in https://github.com/ethereum/consensus-specs/blob/v1.1.10/specs/phase0/p2p-interface.md */
41
41
  const GOSSIPSUB_HEARTBEAT_INTERVAL = 0.7 * 1000;
@@ -140,6 +140,7 @@ export class Eth2Gossipsub {
140
140
  const gossipsubInstance = gossipsub({
141
141
  globalSignaturePolicy: StrictNoSign,
142
142
  allowPublishToZeroTopicPeers: allowPublishToZeroPeers,
143
+ allowedTopics: getAllowedTopics(networkConfig),
143
144
  D: gossipsubD ?? GOSSIP_D,
144
145
  Dlo: gossipsubDLow ?? GOSSIP_D_LOW,
145
146
  Dhi: gossipsubDHigh ?? GOSSIP_D_HIGH,
@@ -246,7 +246,7 @@ export function parseGossipTopic(forkDigestContext: ForkDigestContext, topicStr:
246
246
  export function getCoreTopicsAtFork(
247
247
  networkConfig: NetworkConfig,
248
248
  fork: ForkName,
249
- opts: {subscribeAllSubnets?: boolean; disableLightClientServer?: boolean}
249
+ opts: {subscribeAllSubnets?: boolean; subscribeAllColumnSubnets?: boolean; disableLightClientServer?: boolean}
250
250
  ): GossipTopicTypeMap[keyof GossipTopicTypeMap][] {
251
251
  // Common topics for all forks
252
252
  const topics: GossipTopicTypeMap[keyof GossipTopicTypeMap][] = [
@@ -266,7 +266,7 @@ export function getCoreTopicsAtFork(
266
266
 
267
267
  // After fulu also track data_column_sidecar_{index}
268
268
  if (ForkSeq[fork] >= ForkSeq.fulu) {
269
- topics.push(...getDataColumnSidecarTopics(networkConfig));
269
+ topics.push(...getDataColumnSidecarTopics(networkConfig, opts.subscribeAllColumnSubnets));
270
270
  }
271
271
 
272
272
  // After Deneb and before Fulu also track blob_sidecar_{subnet_id}
@@ -309,15 +309,39 @@ export function getCoreTopicsAtFork(
309
309
  return topics;
310
310
  }
311
311
 
312
+ /**
313
+ * Build the complete set of valid gossip topic strings across every fork boundary.
314
+ */
315
+ export function getAllowedTopics(networkConfig: NetworkConfig): Set<string> {
316
+ const {config} = networkConfig;
317
+ const allowedTopics = new Set<string>();
318
+
319
+ for (const boundary of config.forkBoundariesAscendingEpochOrder) {
320
+ const topics = getCoreTopicsAtFork(networkConfig, boundary.fork, {
321
+ subscribeAllSubnets: true,
322
+ subscribeAllColumnSubnets: true,
323
+ disableLightClientServer: false,
324
+ });
325
+ for (const topic of topics) {
326
+ allowedTopics.add(stringifyGossipTopic(config, {...topic, boundary}));
327
+ }
328
+ }
329
+
330
+ return allowedTopics;
331
+ }
332
+
312
333
  /**
313
334
  * Pick data column subnets to subscribe to post-fulu.
314
335
  */
315
336
  export function getDataColumnSidecarTopics(
316
- networkConfig: NetworkConfig
337
+ networkConfig: NetworkConfig,
338
+ subscribeAllColumnSubnets = false
317
339
  ): GossipTopicTypeMap[keyof GossipTopicTypeMap][] {
318
340
  const topics: GossipTopicTypeMap[keyof GossipTopicTypeMap][] = [];
319
341
 
320
- const subnets = networkConfig.custodyConfig.sampledSubnets;
342
+ const subnets = subscribeAllColumnSubnets
343
+ ? Array.from({length: networkConfig.config.DATA_COLUMN_SIDECAR_SUBNET_COUNT}, (_, i) => i)
344
+ : networkConfig.custodyConfig.sampledSubnets;
321
345
  for (const subnet of subnets) {
322
346
  topics.push({type: GossipType.data_column_sidecar, subnet});
323
347
  }
@@ -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> {
@@ -415,8 +415,10 @@ export class PeerManager {
415
415
  );
416
416
  if (irrelevantReasonType === null) {
417
417
  isIrrelevant = false;
418
+ this.metrics?.peerManager.relevanceCheck.inc({result: "relevant"});
418
419
  } else {
419
420
  isIrrelevant = true;
421
+ this.metrics?.peerManager.relevanceCheck.inc({result: irrelevantReasonType.code});
420
422
  this.logger.debug("Irrelevant peer", {
421
423
  peer: prettyPrintPeerId(peer),
422
424
  reason: renderIrrelevantPeerType(irrelevantReasonType),
@@ -424,6 +426,7 @@ export class PeerManager {
424
426
  }
425
427
  } catch (e) {
426
428
  this.logger.error("Irrelevant peer - unexpected error", {peer: prettyPrintPeerId(peer)}, e as Error);
429
+ this.metrics?.peerManager.relevanceCheck.inc({result: "error"});
427
430
  isIrrelevant = true;
428
431
  }
429
432
 
@@ -552,15 +555,18 @@ export class PeerManager {
552
555
  for (const peer of connectedPeers) {
553
556
  switch (this.peerRpcScores.getScoreState(peer)) {
554
557
  case ScoreState.Banned:
558
+ this.metrics?.peerManager.peersPruned.inc({reason: "banned"});
555
559
  void this.goodbyeAndDisconnect(peer, GoodByeReasonCode.BANNED);
556
560
  break;
557
561
  case ScoreState.Disconnected:
562
+ this.metrics?.peerManager.peersPruned.inc({reason: "score_too_low"});
558
563
  void this.goodbyeAndDisconnect(peer, GoodByeReasonCode.SCORE_TOO_LOW);
559
564
  break;
560
565
  case ScoreState.Healthy:
561
566
  connectedHealthyPeers.push(peer);
562
567
  }
563
568
  }
569
+ this.metrics?.peerManager.peersEvaluated.observe(connectedHealthyPeers.length);
564
570
 
565
571
  const status = this.statusCache.get();
566
572
  const starved =
@@ -572,6 +578,7 @@ export class PeerManager {
572
578
  this.metrics?.peerManager.starved.set(starved ? 1 : 0);
573
579
  const forkSeq = this.config.getForkSeq(this.clock.currentSlot);
574
580
 
581
+ const prioritizeTimer = this.metrics?.peerManager.prioritizePeersDuration.startTimer();
575
582
  const {peersToDisconnect, peersToConnect, attnetQueries, syncnetQueries, custodyGroupQueries} = prioritizePeers(
576
583
  connectedHealthyPeers.map((peer) => {
577
584
  const peerData = this.connectedPeers.get(peer.toString());
@@ -601,6 +608,7 @@ export class PeerManager {
601
608
  this.config,
602
609
  this.metrics
603
610
  );
611
+ prioritizeTimer?.();
604
612
 
605
613
  const queriesMerged: SubnetDiscvQueryMs[] = [];
606
614
  for (const {type, queries} of [
@@ -632,6 +640,7 @@ export class PeerManager {
632
640
  // disconnect first to have more slots before we dial new peers
633
641
  for (const [reason, peers] of peersToDisconnect) {
634
642
  this.metrics?.peersRequestedToDisconnect.inc({reason}, peers.length);
643
+ this.metrics?.peerManager.peersPruned.inc({reason}, peers.length);
635
644
  for (const peer of peers) {
636
645
  void this.goodbyeAndDisconnect(peer, GoodByeReasonCode.TOO_MANY_PEERS);
637
646
  }
@@ -659,6 +668,8 @@ export class PeerManager {
659
668
  }
660
669
  }
661
670
 
671
+ this.metrics?.peerManager.connectedPeersMapSize.set(this.connectedPeers.size);
672
+
662
673
  timer?.();
663
674
 
664
675
  this.logger.debug("peerManager heartbeat result", {
@@ -1,4 +1,5 @@
1
1
  import {GoodByeReasonCode} from "../../../constants/network.js";
2
+ import {NetworkCoreMetrics} from "../../core/metrics.js";
2
3
  import {
3
4
  COOL_DOWN_BEFORE_DECAY_MS,
4
5
  DEFAULT_SCORE,
@@ -23,13 +24,15 @@ export class RealScore implements IPeerScore {
23
24
  /** The final score, computed from the above */
24
25
  private score: number;
25
26
  private lastUpdate: number;
27
+ private readonly metrics: NetworkCoreMetrics | null;
26
28
 
27
- constructor() {
29
+ constructor(metrics: NetworkCoreMetrics | null = null) {
28
30
  this.lodestarScore = DEFAULT_SCORE;
29
31
  this.gossipScore = DEFAULT_SCORE;
30
32
  this.score = DEFAULT_SCORE;
31
33
  this.ignoreNegativeGossipScore = false;
32
34
  this.lastUpdate = Date.now();
35
+ this.metrics = metrics;
33
36
  }
34
37
 
35
38
  isCoolingDown(): boolean {
@@ -136,6 +139,10 @@ export class RealScore implements IPeerScore {
136
139
  this.recomputeScore();
137
140
  const newState = scoreToState(this.score);
138
141
 
142
+ if (prevState !== newState) {
143
+ this.metrics?.peerManager.scoreStateTransitions.inc({from: prevState, to: newState});
144
+ }
145
+
139
146
  if (prevState !== ScoreState.Banned && newState === ScoreState.Banned) {
140
147
  // ban this peer for at least BANNED_BEFORE_DECAY_MS seconds
141
148
  this.lastUpdate = Date.now() + COOL_DOWN_BEFORE_DECAY_MS;
@@ -32,7 +32,9 @@ export class PeerRpcScoreStore implements IPeerRpcScoreStore {
32
32
  constructor(opts: PeerRpcScoreOpts = {}, metrics: NetworkCoreMetrics | null = null, logger: Logger | null = null) {
33
33
  this.metrics = metrics;
34
34
  this.logger = logger;
35
- this.scores = opts.disablePeerScoring ? new MapDef(() => new MaxScore()) : new MapDef(() => new RealScore());
35
+ this.scores = opts.disablePeerScoring
36
+ ? new MapDef(() => new MaxScore())
37
+ : new MapDef(() => new RealScore(this.metrics));
36
38
  }
37
39
 
38
40
  getScore(peer: PeerId): number {
@@ -75,6 +77,8 @@ export class PeerRpcScoreStore implements IPeerRpcScoreStore {
75
77
  }
76
78
 
77
79
  update(): void {
80
+ const timer = this.metrics?.peerManager.scoreUpdateDuration.startTimer();
81
+
78
82
  // Bound size of data structures
79
83
  pruneSetToMax(this.scores, MAX_ENTRIES);
80
84
 
@@ -86,6 +90,9 @@ export class PeerRpcScoreStore implements IPeerRpcScoreStore {
86
90
  this.scores.delete(peerIdStr);
87
91
  }
88
92
  }
93
+
94
+ this.metrics?.peerManager.scoreMapSize.set(this.scores.size);
95
+ timer?.();
89
96
  }
90
97
 
91
98
  updateGossipsubScore(peerId: PeerIdStr, newScore: number, ignore: boolean): void {