@lodestar/beacon-node 1.45.0-dev.3100972235 → 1.45.0-dev.31b495a1d4

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 (136) hide show
  1. package/lib/api/impl/beacon/pool/index.d.ts.map +1 -1
  2. package/lib/api/impl/beacon/pool/index.js +2 -43
  3. package/lib/api/impl/beacon/pool/index.js.map +1 -1
  4. package/lib/api/impl/beacon/state/index.d.ts.map +1 -1
  5. package/lib/api/impl/beacon/state/index.js +46 -3
  6. package/lib/api/impl/beacon/state/index.js.map +1 -1
  7. package/lib/api/impl/beacon/state/utils.d.ts +11 -2
  8. package/lib/api/impl/beacon/state/utils.d.ts.map +1 -1
  9. package/lib/api/impl/beacon/state/utils.js +39 -3
  10. package/lib/api/impl/beacon/state/utils.js.map +1 -1
  11. package/lib/api/impl/validator/index.d.ts.map +1 -1
  12. package/lib/api/impl/validator/index.js +46 -6
  13. package/lib/api/impl/validator/index.js.map +1 -1
  14. package/lib/chain/blocks/importBlock.js +1 -1
  15. package/lib/chain/blocks/importBlock.js.map +1 -1
  16. package/lib/chain/blocks/payloadEnvelopeProcessor.d.ts +5 -2
  17. package/lib/chain/blocks/payloadEnvelopeProcessor.d.ts.map +1 -1
  18. package/lib/chain/blocks/payloadEnvelopeProcessor.js +17 -17
  19. package/lib/chain/blocks/payloadEnvelopeProcessor.js.map +1 -1
  20. package/lib/chain/chain.d.ts +1 -2
  21. package/lib/chain/chain.d.ts.map +1 -1
  22. package/lib/chain/chain.js +1 -3
  23. package/lib/chain/chain.js.map +1 -1
  24. package/lib/chain/interface.d.ts +1 -2
  25. package/lib/chain/interface.d.ts.map +1 -1
  26. package/lib/chain/interface.js.map +1 -1
  27. package/lib/chain/opPools/proposerPreferencesPool.d.ts +2 -1
  28. package/lib/chain/opPools/proposerPreferencesPool.d.ts.map +1 -1
  29. package/lib/chain/opPools/proposerPreferencesPool.js +3 -0
  30. package/lib/chain/opPools/proposerPreferencesPool.js.map +1 -1
  31. package/lib/chain/options.d.ts +1 -0
  32. package/lib/chain/options.d.ts.map +1 -1
  33. package/lib/chain/options.js +1 -0
  34. package/lib/chain/options.js.map +1 -1
  35. package/lib/chain/produceBlock/produceBlockBody.d.ts.map +1 -1
  36. package/lib/chain/produceBlock/produceBlockBody.js +4 -12
  37. package/lib/chain/produceBlock/produceBlockBody.js.map +1 -1
  38. package/lib/chain/seenCache/index.d.ts +0 -1
  39. package/lib/chain/seenCache/index.d.ts.map +1 -1
  40. package/lib/chain/seenCache/index.js +0 -1
  41. package/lib/chain/seenCache/index.js.map +1 -1
  42. package/lib/chain/seenCache/seenPayloadEnvelopeInput.d.ts +1 -0
  43. package/lib/chain/seenCache/seenPayloadEnvelopeInput.d.ts.map +1 -1
  44. package/lib/chain/seenCache/seenPayloadEnvelopeInput.js +7 -0
  45. package/lib/chain/seenCache/seenPayloadEnvelopeInput.js.map +1 -1
  46. package/lib/chain/stateCache/persistentCheckpointsCache.d.ts.map +1 -1
  47. package/lib/chain/stateCache/persistentCheckpointsCache.js +3 -2
  48. package/lib/chain/stateCache/persistentCheckpointsCache.js.map +1 -1
  49. package/lib/chain/validation/executionPayloadBid.js +11 -10
  50. package/lib/chain/validation/executionPayloadBid.js.map +1 -1
  51. package/lib/chain/validation/proposerPreferences.d.ts.map +1 -1
  52. package/lib/chain/validation/proposerPreferences.js +14 -5
  53. package/lib/chain/validation/proposerPreferences.js.map +1 -1
  54. package/lib/network/core/metrics.d.ts +65 -0
  55. package/lib/network/core/metrics.d.ts.map +1 -1
  56. package/lib/network/core/metrics.js +94 -0
  57. package/lib/network/core/metrics.js.map +1 -1
  58. package/lib/network/discv5/utils.d.ts.map +1 -1
  59. package/lib/network/discv5/utils.js +12 -3
  60. package/lib/network/discv5/utils.js.map +1 -1
  61. package/lib/network/gossip/encoding.d.ts.map +1 -1
  62. package/lib/network/gossip/encoding.js +7 -0
  63. package/lib/network/gossip/encoding.js.map +1 -1
  64. package/lib/network/gossip/gossipsub.d.ts.map +1 -1
  65. package/lib/network/gossip/gossipsub.js +2 -1
  66. package/lib/network/gossip/gossipsub.js.map +1 -1
  67. package/lib/network/gossip/topic.d.ts +6 -1
  68. package/lib/network/gossip/topic.d.ts.map +1 -1
  69. package/lib/network/gossip/topic.js +23 -3
  70. package/lib/network/gossip/topic.js.map +1 -1
  71. package/lib/network/peers/peerManager.d.ts.map +1 -1
  72. package/lib/network/peers/peerManager.js +10 -0
  73. package/lib/network/peers/peerManager.js.map +1 -1
  74. package/lib/network/peers/score/score.d.ts +3 -1
  75. package/lib/network/peers/score/score.d.ts.map +1 -1
  76. package/lib/network/peers/score/score.js +6 -1
  77. package/lib/network/peers/score/score.js.map +1 -1
  78. package/lib/network/peers/score/store.d.ts.map +1 -1
  79. package/lib/network/peers/score/store.js +6 -1
  80. package/lib/network/peers/score/store.js.map +1 -1
  81. package/lib/network/peers/utils/prioritizePeers.d.ts.map +1 -1
  82. package/lib/network/peers/utils/prioritizePeers.js +14 -0
  83. package/lib/network/peers/utils/prioritizePeers.js.map +1 -1
  84. package/lib/network/processor/gossipHandlers.d.ts.map +1 -1
  85. package/lib/network/processor/gossipHandlers.js +28 -16
  86. package/lib/network/processor/gossipHandlers.js.map +1 -1
  87. package/lib/sync/range/chain.d.ts +4 -1
  88. package/lib/sync/range/chain.d.ts.map +1 -1
  89. package/lib/sync/range/chain.js +3 -3
  90. package/lib/sync/range/chain.js.map +1 -1
  91. package/lib/sync/range/range.d.ts.map +1 -1
  92. package/lib/sync/range/range.js +10 -0
  93. package/lib/sync/range/range.js.map +1 -1
  94. package/lib/sync/utils/downloadByRange.d.ts.map +1 -1
  95. package/lib/sync/utils/downloadByRange.js +25 -19
  96. package/lib/sync/utils/downloadByRange.js.map +1 -1
  97. package/lib/util/graffiti.d.ts +15 -0
  98. package/lib/util/graffiti.d.ts.map +1 -1
  99. package/lib/util/graffiti.js +55 -0
  100. package/lib/util/graffiti.js.map +1 -1
  101. package/package.json +14 -14
  102. package/src/api/impl/beacon/pool/index.ts +0 -53
  103. package/src/api/impl/beacon/state/index.ts +52 -1
  104. package/src/api/impl/beacon/state/utils.ts +57 -4
  105. package/src/api/impl/validator/index.ts +54 -5
  106. package/src/chain/blocks/importBlock.ts +1 -1
  107. package/src/chain/blocks/payloadEnvelopeProcessor.ts +17 -17
  108. package/src/chain/chain.ts +0 -3
  109. package/src/chain/interface.ts +0 -2
  110. package/src/chain/opPools/proposerPreferencesPool.ts +5 -1
  111. package/src/chain/options.ts +2 -0
  112. package/src/chain/produceBlock/produceBlockBody.ts +9 -17
  113. package/src/chain/seenCache/index.ts +0 -1
  114. package/src/chain/seenCache/seenPayloadEnvelopeInput.ts +8 -0
  115. package/src/chain/stateCache/persistentCheckpointsCache.ts +3 -2
  116. package/src/chain/validation/executionPayloadBid.ts +14 -9
  117. package/src/chain/validation/proposerPreferences.ts +15 -5
  118. package/src/network/core/metrics.ts +94 -0
  119. package/src/network/discv5/utils.ts +11 -3
  120. package/src/network/gossip/encoding.ts +7 -0
  121. package/src/network/gossip/gossipsub.ts +2 -1
  122. package/src/network/gossip/topic.ts +28 -4
  123. package/src/network/peers/peerManager.ts +11 -0
  124. package/src/network/peers/score/score.ts +8 -1
  125. package/src/network/peers/score/store.ts +8 -1
  126. package/src/network/peers/utils/prioritizePeers.ts +15 -0
  127. package/src/network/processor/gossipHandlers.ts +30 -16
  128. package/src/sync/range/chain.ts +4 -5
  129. package/src/sync/range/range.ts +10 -0
  130. package/src/sync/utils/downloadByRange.ts +26 -19
  131. package/src/util/graffiti.ts +78 -0
  132. package/lib/chain/seenCache/seenProposerPreferences.d.ts +0 -16
  133. package/lib/chain/seenCache/seenProposerPreferences.d.ts.map +0 -1
  134. package/lib/chain/seenCache/seenProposerPreferences.js +0 -26
  135. package/lib/chain/seenCache/seenProposerPreferences.js.map +0 -1
  136. package/src/chain/seenCache/seenProposerPreferences.ts +0 -32
@@ -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
  }
@@ -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 {
@@ -7,6 +7,7 @@ import {MapDef} from "@lodestar/utils";
7
7
  import {shuffle} from "../../../util/shuffle.js";
8
8
  import {sortBy} from "../../../util/sortBy.js";
9
9
  import {NetworkCoreMetrics} from "../../core/metrics.js";
10
+ import {SubnetType} from "../../metadata.js";
10
11
  import {RequestedSubnet} from "./subnetMap.js";
11
12
 
12
13
  /** Target number of peers we'd like to have connected to a given long-lived subnet */
@@ -189,6 +190,18 @@ export function prioritizePeers(
189
190
 
190
191
  const connectedPeerCount = connectedPeers.length;
191
192
 
193
+ // Track the outbound peers invariant (we keep at least OUTBOUND_PEERS_RATIO outbound peers).
194
+ // Report 0 when there are no peers so the gauge doesn't go stale at its last value.
195
+ if (metrics) {
196
+ let outboundPeerCount = 0;
197
+ for (const peer of connectedPeers) {
198
+ if (peer.direction === "outbound") {
199
+ outboundPeerCount++;
200
+ }
201
+ }
202
+ metrics.peerManager.outboundPeersRatio.set(connectedPeerCount > 0 ? outboundPeerCount / connectedPeerCount : 0);
203
+ }
204
+
192
205
  if (connectedPeerCount < targetPeers) {
193
206
  // Need more peers.
194
207
  // Instead of attempting to connect the exact amount necessary this will overshoot a little since the success
@@ -256,6 +269,7 @@ function requestSubnetPeers(
256
269
 
257
270
  for (const {subnet, toSlot} of activeAttnets) {
258
271
  const peersInSubnet = peersPerSubnet.get(subnet) ?? 0;
272
+ metrics?.peerManager.peersPerActiveSubnet.observe({type: SubnetType.attnets}, peersInSubnet);
259
273
  if (peersInSubnet < targetSubnetPeers) {
260
274
  // We need more peers
261
275
  attnetQueries.push({subnet, toSlot, maxPeersToDiscover: targetSubnetPeers - peersInSubnet});
@@ -282,6 +296,7 @@ function requestSubnetPeers(
282
296
 
283
297
  for (const {subnet, toSlot} of activeSyncnets) {
284
298
  const peersInSubnet = peersPerSubnet.get(subnet) ?? 0;
299
+ metrics?.peerManager.peersPerActiveSubnet.observe({type: SubnetType.syncnets}, peersInSubnet);
285
300
  if (peersInSubnet < targetSubnetPeers) {
286
301
  // We need more peers
287
302
  syncnetQueries.push({subnet, toSlot, maxPeersToDiscover: targetSubnetPeers - peersInSubnet});
@@ -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,9 +1273,8 @@ 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
- version: ForkName.gloas,
1277
+ version: config.getForkName(signedProposerPreferences.message.proposalSlot),
1264
1278
  data: signedProposerPreferences,
1265
1279
  });
1266
1280
  },
@@ -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