@lodestar/beacon-node 1.47.0-dev.931ecbec99 → 1.47.0-dev.9572bcc698

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 (138) hide show
  1. package/lib/api/impl/beacon/blocks/index.d.ts.map +1 -1
  2. package/lib/api/impl/beacon/blocks/index.js +3 -2
  3. package/lib/api/impl/beacon/blocks/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 +21 -2
  6. package/lib/api/impl/beacon/pool/index.js.map +1 -1
  7. package/lib/api/impl/events/index.d.ts.map +1 -1
  8. package/lib/api/impl/events/index.js +2 -0
  9. package/lib/api/impl/events/index.js.map +1 -1
  10. package/lib/api/rest/base.d.ts.map +1 -1
  11. package/lib/api/rest/base.js +7 -0
  12. package/lib/api/rest/base.js.map +1 -1
  13. package/lib/chain/blocks/importExecutionPayload.d.ts +3 -2
  14. package/lib/chain/blocks/importExecutionPayload.d.ts.map +1 -1
  15. package/lib/chain/blocks/importExecutionPayload.js +10 -8
  16. package/lib/chain/blocks/importExecutionPayload.js.map +1 -1
  17. package/lib/chain/blocks/index.d.ts.map +1 -1
  18. package/lib/chain/blocks/index.js +14 -6
  19. package/lib/chain/blocks/index.js.map +1 -1
  20. package/lib/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.d.ts +2 -1
  21. package/lib/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.d.ts.map +1 -1
  22. package/lib/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.js +6 -2
  23. package/lib/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.js.map +1 -1
  24. package/lib/chain/blocks/payloadEnvelopeInput/types.d.ts +14 -5
  25. package/lib/chain/blocks/payloadEnvelopeInput/types.d.ts.map +1 -1
  26. package/lib/chain/blocks/payloadEnvelopeInput/types.js +5 -0
  27. package/lib/chain/blocks/payloadEnvelopeInput/types.js.map +1 -1
  28. package/lib/chain/blocks/verifyBlocksStateTransitionOnly.d.ts.map +1 -1
  29. package/lib/chain/blocks/verifyBlocksStateTransitionOnly.js +22 -12
  30. package/lib/chain/blocks/verifyBlocksStateTransitionOnly.js.map +1 -1
  31. package/lib/chain/chain.d.ts +2 -1
  32. package/lib/chain/chain.d.ts.map +1 -1
  33. package/lib/chain/chain.js +10 -2
  34. package/lib/chain/chain.js.map +1 -1
  35. package/lib/chain/errors/blockError.d.ts +1 -0
  36. package/lib/chain/errors/blockError.d.ts.map +1 -1
  37. package/lib/chain/errors/blockError.js +6 -1
  38. package/lib/chain/errors/blockError.js.map +1 -1
  39. package/lib/chain/interface.d.ts +2 -1
  40. package/lib/chain/interface.d.ts.map +1 -1
  41. package/lib/chain/interface.js.map +1 -1
  42. package/lib/chain/opPools/deferredVoluntaryExitPool.d.ts +16 -0
  43. package/lib/chain/opPools/deferredVoluntaryExitPool.d.ts.map +1 -0
  44. package/lib/chain/opPools/deferredVoluntaryExitPool.js +57 -0
  45. package/lib/chain/opPools/deferredVoluntaryExitPool.js.map +1 -0
  46. package/lib/chain/opPools/index.d.ts +1 -0
  47. package/lib/chain/opPools/index.d.ts.map +1 -1
  48. package/lib/chain/opPools/index.js +1 -0
  49. package/lib/chain/opPools/index.js.map +1 -1
  50. package/lib/chain/regen/interface.d.ts +2 -0
  51. package/lib/chain/regen/interface.d.ts.map +1 -1
  52. package/lib/chain/regen/interface.js +2 -0
  53. package/lib/chain/regen/interface.js.map +1 -1
  54. package/lib/chain/seenCache/seenPayloadEnvelopeInput.d.ts +28 -1
  55. package/lib/chain/seenCache/seenPayloadEnvelopeInput.d.ts.map +1 -1
  56. package/lib/chain/seenCache/seenPayloadEnvelopeInput.js +121 -13
  57. package/lib/chain/seenCache/seenPayloadEnvelopeInput.js.map +1 -1
  58. package/lib/chain/validation/voluntaryExit.d.ts +9 -1
  59. package/lib/chain/validation/voluntaryExit.d.ts.map +1 -1
  60. package/lib/chain/validation/voluntaryExit.js +46 -1
  61. package/lib/chain/validation/voluntaryExit.js.map +1 -1
  62. package/lib/metrics/metrics/lodestar.d.ts +17 -4
  63. package/lib/metrics/metrics/lodestar.d.ts.map +1 -1
  64. package/lib/metrics/metrics/lodestar.js +20 -2
  65. package/lib/metrics/metrics/lodestar.js.map +1 -1
  66. package/lib/network/gossip/topic.d.ts +13 -0
  67. package/lib/network/gossip/topic.d.ts.map +1 -1
  68. package/lib/network/gossip/topic.js +37 -0
  69. package/lib/network/gossip/topic.js.map +1 -1
  70. package/lib/network/network.d.ts.map +1 -1
  71. package/lib/network/network.js +22 -43
  72. package/lib/network/network.js.map +1 -1
  73. package/lib/network/processor/gossipHandlers.d.ts.map +1 -1
  74. package/lib/network/processor/gossipHandlers.js +5 -1
  75. package/lib/network/processor/gossipHandlers.js.map +1 -1
  76. package/lib/network/util.d.ts +0 -1
  77. package/lib/network/util.d.ts.map +1 -1
  78. package/lib/network/util.js +0 -4
  79. package/lib/network/util.js.map +1 -1
  80. package/lib/node/deferredVoluntaryExitPublisher.d.ts +15 -0
  81. package/lib/node/deferredVoluntaryExitPublisher.d.ts.map +1 -0
  82. package/lib/node/deferredVoluntaryExitPublisher.js +38 -0
  83. package/lib/node/deferredVoluntaryExitPublisher.js.map +1 -0
  84. package/lib/node/nodejs.d.ts.map +1 -1
  85. package/lib/node/nodejs.js +2 -0
  86. package/lib/node/nodejs.js.map +1 -1
  87. package/lib/sync/range/batch.d.ts +23 -5
  88. package/lib/sync/range/batch.d.ts.map +1 -1
  89. package/lib/sync/range/batch.js +61 -22
  90. package/lib/sync/range/batch.js.map +1 -1
  91. package/lib/sync/range/chain.d.ts +2 -1
  92. package/lib/sync/range/chain.d.ts.map +1 -1
  93. package/lib/sync/range/chain.js +23 -10
  94. package/lib/sync/range/chain.js.map +1 -1
  95. package/lib/sync/types.d.ts +32 -0
  96. package/lib/sync/types.d.ts.map +1 -1
  97. package/lib/sync/types.js +33 -0
  98. package/lib/sync/types.js.map +1 -1
  99. package/lib/sync/unknownBlock.d.ts.map +1 -1
  100. package/lib/sync/unknownBlock.js +95 -51
  101. package/lib/sync/unknownBlock.js.map +1 -1
  102. package/lib/sync/utils/downloadByRange.d.ts.map +1 -1
  103. package/lib/sync/utils/downloadByRange.js +16 -11
  104. package/lib/sync/utils/downloadByRange.js.map +1 -1
  105. package/lib/sync/utils/downloadByRoot.d.ts.map +1 -1
  106. package/lib/sync/utils/downloadByRoot.js +3 -1
  107. package/lib/sync/utils/downloadByRoot.js.map +1 -1
  108. package/package.json +34 -34
  109. package/src/api/impl/beacon/blocks/index.ts +3 -2
  110. package/src/api/impl/beacon/pool/index.ts +22 -2
  111. package/src/api/impl/events/index.ts +3 -1
  112. package/src/api/rest/base.ts +7 -0
  113. package/src/chain/blocks/importExecutionPayload.ts +12 -10
  114. package/src/chain/blocks/index.ts +18 -6
  115. package/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts +8 -2
  116. package/src/chain/blocks/payloadEnvelopeInput/types.ts +17 -4
  117. package/src/chain/blocks/verifyBlocksStateTransitionOnly.ts +25 -16
  118. package/src/chain/chain.ts +10 -1
  119. package/src/chain/errors/blockError.ts +8 -2
  120. package/src/chain/interface.ts +2 -0
  121. package/src/chain/opPools/deferredVoluntaryExitPool.ts +65 -0
  122. package/src/chain/opPools/index.ts +1 -0
  123. package/src/chain/regen/interface.ts +2 -0
  124. package/src/chain/seenCache/seenPayloadEnvelopeInput.ts +143 -14
  125. package/src/chain/validation/voluntaryExit.ts +59 -2
  126. package/src/metrics/metrics/lodestar.ts +27 -6
  127. package/src/network/gossip/topic.ts +38 -0
  128. package/src/network/network.ts +27 -55
  129. package/src/network/processor/gossipHandlers.ts +5 -1
  130. package/src/network/util.ts +0 -5
  131. package/src/node/deferredVoluntaryExitPublisher.ts +54 -0
  132. package/src/node/nodejs.ts +3 -0
  133. package/src/sync/range/batch.ts +83 -28
  134. package/src/sync/range/chain.ts +26 -11
  135. package/src/sync/types.ts +33 -0
  136. package/src/sync/unknownBlock.ts +105 -55
  137. package/src/sync/utils/downloadByRange.ts +21 -12
  138. package/src/sync/utils/downloadByRoot.ts +3 -1
@@ -1,5 +1,6 @@
1
1
  export {AggregatedAttestationPool} from "./aggregatedAttestationPool.js";
2
2
  export {AttestationPool} from "./attestationPool.js";
3
+ export {DeferredVoluntaryExitPool} from "./deferredVoluntaryExitPool.js";
3
4
  export {ExecutionPayloadBidPool} from "./executionPayloadBidPool.js";
4
5
  export {OpPool} from "./opPool.js";
5
6
  export {PayloadAttestationPool} from "./payloadAttestationPool.js";
@@ -21,6 +21,8 @@ export enum RegenCaller {
21
21
  validateGossipAggregateAndProof = "validateGossipAggregateAndProof",
22
22
  validateGossipAttestation = "validateGossipAttestation",
23
23
  validateGossipVoluntaryExit = "validateGossipVoluntaryExit",
24
+ validateApiVoluntaryExit = "validateApiVoluntaryExit",
25
+ publishDeferredVoluntaryExits = "publishDeferredVoluntaryExits",
24
26
  validateGossipExecutionPayloadBid = "validateGossipExecutionPayloadBid",
25
27
  validateGossipPayloadAttestationMessage = "validateGossipPayloadAttestationMessage",
26
28
  validateGossipProposerPreferences = "validateGossipProposerPreferences",
@@ -1,18 +1,32 @@
1
1
  import {ChainForkConfig} from "@lodestar/config";
2
2
  import {CheckpointWithHex, IForkChoice, PayloadStatus, ProtoBlock} from "@lodestar/fork-choice";
3
+ import {ForkPostGloas, SLOTS_PER_EPOCH, isForkPostGloas} from "@lodestar/params";
3
4
  import {computeStartSlotAtEpoch} from "@lodestar/state-transition";
4
- import {RootHex} from "@lodestar/types";
5
- import {Logger} from "@lodestar/utils";
5
+ import {RootHex, SignedBeaconBlock} from "@lodestar/types";
6
+ import {Logger, fromHex} from "@lodestar/utils";
7
+ import {IBeaconDb} from "../../db/index.js";
6
8
  import {Metrics} from "../../metrics/metrics.js";
9
+ import {MAX_LOOK_AHEAD_EPOCHS} from "../../sync/constants.js";
7
10
  import {IClock} from "../../util/clock.js";
11
+ import {CustodyConfig} from "../../util/dataColumns.js";
8
12
  import {SerializedCache} from "../../util/serializedCache.js";
9
13
  import {isDaOutOfRange} from "../blocks/blockInput/index.js";
10
- import {CreateFromBidProps, CreateFromBlockProps, PayloadEnvelopeInput} from "../blocks/payloadEnvelopeInput/index.js";
14
+ import {
15
+ CreateFromBidProps,
16
+ CreateFromBlockProps,
17
+ PayloadEnvelopeInput,
18
+ PayloadEnvelopeInputPruneReason,
19
+ PayloadEnvelopeInputSource,
20
+ } from "../blocks/payloadEnvelopeInput/index.js";
11
21
  import {ChainEvent, ChainEventEmitter} from "../emitter.js";
22
+ import {SeenBlockInput} from "./seenGossipBlockInput.js";
12
23
 
13
24
  export type {PayloadEnvelopeInputState} from "../blocks/payloadEnvelopeInput/index.js";
14
25
  export {PayloadEnvelopeInput} from "../blocks/payloadEnvelopeInput/index.js";
15
26
 
27
+ // Bound this cache by this max size, this is the same to SeenBlockInput
28
+ const MAX_PAYLOAD_ENVELOPE_INPUT_CACHE_SIZE = (MAX_LOOK_AHEAD_EPOCHS + 1) * SLOTS_PER_EPOCH;
29
+
16
30
  export type SeenPayloadEnvelopeInputModules = {
17
31
  config: ChainForkConfig;
18
32
  clock: IClock;
@@ -20,6 +34,9 @@ export type SeenPayloadEnvelopeInputModules = {
20
34
  chainEvents: ChainEventEmitter;
21
35
  signal: AbortSignal;
22
36
  serializedCache: SerializedCache;
37
+ db: IBeaconDb;
38
+ seenBlockInputCache: SeenBlockInput;
39
+ custodyConfig: CustodyConfig;
23
40
  metrics: Metrics | null;
24
41
  logger?: Logger;
25
42
  };
@@ -40,9 +57,14 @@ export class SeenPayloadEnvelopeInput {
40
57
  private readonly chainEvents: ChainEventEmitter;
41
58
  private readonly signal: AbortSignal;
42
59
  private readonly serializedCache: SerializedCache;
60
+ private readonly db: IBeaconDb;
61
+ private readonly seenBlockInputCache: SeenBlockInput;
62
+ private readonly custodyConfig: CustodyConfig;
43
63
  private readonly metrics: Metrics | null;
44
64
  private readonly logger?: Logger;
45
65
  private payloadInputs = new Map<RootHex, PayloadEnvelopeInput>();
66
+ // Dedup concurrent DB reloads of the same root so callers share one reconstructed object.
67
+ private readonly reloading = new Map<RootHex, Promise<PayloadEnvelopeInput | undefined>>();
46
68
 
47
69
  constructor({
48
70
  config,
@@ -51,6 +73,9 @@ export class SeenPayloadEnvelopeInput {
51
73
  chainEvents,
52
74
  signal,
53
75
  serializedCache,
76
+ db,
77
+ seenBlockInputCache,
78
+ custodyConfig,
54
79
  metrics,
55
80
  logger,
56
81
  }: SeenPayloadEnvelopeInputModules) {
@@ -60,6 +85,9 @@ export class SeenPayloadEnvelopeInput {
60
85
  this.chainEvents = chainEvents;
61
86
  this.signal = signal;
62
87
  this.serializedCache = serializedCache;
88
+ this.db = db;
89
+ this.seenBlockInputCache = seenBlockInputCache;
90
+ this.custodyConfig = custodyConfig;
63
91
  this.metrics = metrics;
64
92
  this.logger = logger;
65
93
 
@@ -86,7 +114,7 @@ export class SeenPayloadEnvelopeInput {
86
114
  let deletedCount = 0;
87
115
  for (const [, input] of this.payloadInputs) {
88
116
  if (input.slot < finalizedSlot) {
89
- this.evictPayloadInput(input);
117
+ this.evictPayloadInput(input, "finalized");
90
118
  deletedCount++;
91
119
  }
92
120
  }
@@ -110,12 +138,13 @@ export class SeenPayloadEnvelopeInput {
110
138
  const daOutOfRange = isDaOutOfRange(this.config, props.forkName, props.block.message.slot, this.clock.currentEpoch);
111
139
  const input = PayloadEnvelopeInput.createFromBlock({...props, daOutOfRange});
112
140
  this.payloadInputs.set(props.blockRootHex, input);
113
- this.metrics?.seenCache.payloadEnvelopeInput.created.inc();
141
+ this.metrics?.seenCache.payloadEnvelopeInput.created.inc({source: props.source});
114
142
  this.logger?.verbose("SeenPayloadEnvelopeInput.add created new entry", {
115
143
  slot: input.slot,
116
144
  root: props.blockRootHex,
117
145
  daOutOfRange,
118
146
  });
147
+ this.pruneToMaxSize();
119
148
  return input;
120
149
  }
121
150
 
@@ -131,12 +160,13 @@ export class SeenPayloadEnvelopeInput {
131
160
  const daOutOfRange = isDaOutOfRange(this.config, props.forkName, props.slot, this.clock.currentEpoch);
132
161
  const input = PayloadEnvelopeInput.createFromBid({...props, daOutOfRange});
133
162
  this.payloadInputs.set(props.blockRootHex, input);
134
- this.metrics?.seenCache.payloadEnvelopeInput.created.inc();
163
+ this.metrics?.seenCache.payloadEnvelopeInput.created.inc({source: props.source});
135
164
  this.logger?.verbose("SeenPayloadEnvelopeInput.addFromBid created new entry", {
136
165
  slot: input.slot,
137
166
  root: props.blockRootHex,
138
167
  daOutOfRange,
139
168
  });
169
+ this.pruneToMaxSize();
140
170
  return input;
141
171
  }
142
172
 
@@ -144,6 +174,80 @@ export class SeenPayloadEnvelopeInput {
144
174
  return this.payloadInputs.get(blockRootHex);
145
175
  }
146
176
 
177
+ /**
178
+ * Like `get()`, but on a cache miss reconstruct the shell (bid + versionedHashes) from the block
179
+ * in `seenBlockInputCache` or the hot DB.
180
+ * This api is meant for BlockInputSync when a late/weird payloads for old blocks
181
+ *
182
+ * NOTE: the reconstructed entry is always EMPTY even when the block is actually FULL (its
183
+ * payload envelope + columns persisted in the DB). The consumer should consult fork choice if it needs to.
184
+ */
185
+ async getOrReload(blockRootHex: RootHex): Promise<PayloadEnvelopeInput | undefined> {
186
+ const existing = this.payloadInputs.get(blockRootHex);
187
+ if (existing !== undefined) {
188
+ return existing;
189
+ }
190
+
191
+ // Without this dedup, two concurrent misses each db.block.get + createFromBlock a DIFFERENT shell
192
+ // for the same root; processPayloadEnvelopeJob's WeakMap (keyed by object) can't dedup the import.
193
+ const inflight = this.reloading.get(blockRootHex);
194
+ if (inflight !== undefined) {
195
+ return inflight;
196
+ }
197
+ const promise = this.reloadFromDb(blockRootHex);
198
+ this.reloading.set(blockRootHex, promise);
199
+ try {
200
+ return await promise;
201
+ } finally {
202
+ this.reloading.delete(blockRootHex);
203
+ }
204
+ }
205
+
206
+ private async reloadFromDb(blockRootHex: RootHex): Promise<PayloadEnvelopeInput | undefined> {
207
+ // Only recover unfinalized, fork-choice-known blocks. Do not read the finalized archive. The gate
208
+ // also filters out un-imported blocks that may sit in seenBlockInputCache before validation.
209
+ if (!this.forkChoice.hasBlockHex(blockRootHex)) {
210
+ return undefined;
211
+ }
212
+
213
+ // In-memory first: persistBlockInput writes db.block THEN prunes seenBlockInputCache, so a
214
+ // just-imported block whose async write (unfinalizedBlockWrites) has not flushed yet is still here;
215
+ // older blocks (already pruned from the cache) fall through to the hot db.
216
+ const cachedBlockInput = this.seenBlockInputCache.get(blockRootHex);
217
+ const block = cachedBlockInput?.hasBlock()
218
+ ? cachedBlockInput.getBlock()
219
+ : await this.db.block.get(fromHex(blockRootHex));
220
+ if (block == null) {
221
+ return undefined;
222
+ }
223
+
224
+ const forkName = this.config.getForkName(block.message.slot);
225
+ if (!isForkPostGloas(forkName)) {
226
+ return undefined;
227
+ }
228
+
229
+ const daOutOfRange = isDaOutOfRange(this.config, forkName, block.message.slot, this.clock.currentEpoch);
230
+ const input = PayloadEnvelopeInput.createFromBlock({
231
+ blockRootHex,
232
+ block: block as SignedBeaconBlock<ForkPostGloas>,
233
+ forkName,
234
+ sampledColumns: this.custodyConfig.sampledColumns,
235
+ custodyColumns: this.custodyConfig.custodyColumns,
236
+ seenTimestampSec: Date.now() / 1000,
237
+ source: PayloadEnvelopeInputSource.reload,
238
+ daOutOfRange,
239
+ });
240
+ this.payloadInputs.set(blockRootHex, input);
241
+ this.metrics?.seenCache.payloadEnvelopeInput.created.inc({source: PayloadEnvelopeInputSource.reload});
242
+ this.logger?.verbose("SeenPayloadEnvelopeInput.getOrReload reconstructed entry from db", {
243
+ slot: input.slot,
244
+ root: blockRootHex,
245
+ });
246
+ // Set above (entry is at the back of the insertion order), so the cap never evicts what we just reloaded.
247
+ this.pruneToMaxSize();
248
+ return input;
249
+ }
250
+
147
251
  hasPayload(blockRootHex: RootHex): boolean {
148
252
  return this.payloadInputs.get(blockRootHex)?.hasPayloadEnvelope() ?? false;
149
253
  }
@@ -155,8 +259,7 @@ export class SeenPayloadEnvelopeInput {
155
259
  prune(blockRootHex: RootHex): void {
156
260
  const input = this.payloadInputs.get(blockRootHex);
157
261
  if (input) {
158
- this.evictPayloadInput(input);
159
- this.logger?.verbose("SeenPayloadEnvelopeInput.prune deleted", {slot: input.slot, root: blockRootHex});
262
+ this.evictPayloadInput(input, "prune");
160
263
  }
161
264
  }
162
265
 
@@ -170,17 +273,43 @@ export class SeenPayloadEnvelopeInput {
170
273
  // ...and don't evict while columns are still being gathered: writeDataColumnsToDb awaits the
171
274
  // same hasComputedAllData() before persisting. Such entries are pruned by a later call.
172
275
  if (input?.hasComputedAllData()) {
173
- this.evictPayloadInput(input);
174
- this.logger?.verbose("SeenPayloadEnvelopeInput.pruneBelowParent deleted", {
175
- slot: block.slot,
176
- root: block.blockRoot,
177
- });
276
+ this.evictPayloadInput(input, "belowParent");
178
277
  }
179
278
  }
180
279
  }
181
280
  }
182
281
 
183
- private evictPayloadInput(payloadInput: PayloadEnvelopeInput): void {
282
+ /**
283
+ * Bound this cache to have at most MAX_PAYLOAD_ENVELOPE_INPUT_CACHE_SIZE items.
284
+ * Evicts by insertion order so a just-reloaded old-slot entry is not evicted right after.
285
+ * On unstable network, we may need to query an evicted item, UnknownBlockInput will recover
286
+ * it from db via getOrReload() api. Runs after every single insert, so it evicts ~1 per call.
287
+ */
288
+ private pruneToMaxSize(): void {
289
+ let evicted = 0;
290
+ for (const input of this.payloadInputs.values()) {
291
+ if (this.payloadInputs.size <= MAX_PAYLOAD_ENVELOPE_INPUT_CACHE_SIZE) {
292
+ break;
293
+ }
294
+ this.evictPayloadInput(input, "cap");
295
+ evicted++;
296
+ }
297
+ if (evicted > 0) {
298
+ this.logger?.debug("SeenPayloadEnvelopeInput.pruneToMaxSize evicted", {
299
+ evicted,
300
+ size: this.payloadInputs.size,
301
+ max: MAX_PAYLOAD_ENVELOPE_INPUT_CACHE_SIZE,
302
+ });
303
+ }
304
+ }
305
+
306
+ private evictPayloadInput(payloadInput: PayloadEnvelopeInput, reason: PayloadEnvelopeInputPruneReason): void {
307
+ this.metrics?.seenCache.payloadEnvelopeInput.pruned.inc({reason});
308
+ this.logger?.debug("SeenPayloadEnvelopeInput evicted", {
309
+ slot: payloadInput.slot,
310
+ root: payloadInput.blockRootHex,
311
+ reason,
312
+ });
184
313
  this.serializedCache.delete(payloadInput.getSerializedCacheKeys());
185
314
  this.payloadInputs.delete(payloadInput.blockRootHex);
186
315
  }
@@ -9,12 +9,69 @@ import {
9
9
  import {IBeaconChain} from "../index.js";
10
10
  import {RegenCaller} from "../regen/index.js";
11
11
 
12
+ export type ApiVoluntaryExitResult = {status: "published"} | {status: "deferred"; validity: VoluntaryExitValidity};
13
+
14
+ // Variants that can become valid in a future epoch without user action.
15
+ // - earlyEpoch: exit.message.epoch is in the future; valid once current epoch catches up
16
+ // - shortTimeActive: validator active < SHARD_COMMITTEE_PERIOD; valid once enough time passes
17
+ // - pendingWithdrawals: Electra; valid once pending partial withdrawals drain
18
+ // Note: VoluntaryExitValidity.inactive is intentionally excluded. It conflates
19
+ // "validator does not exist" (permanent) with "validator not yet activated"
20
+ // (transient), and cleanly classifying it requires splitting the enum variant
21
+ // upstream. Left for a future follow-up.
22
+
23
+ export function isTransientExitValidity(v: VoluntaryExitValidity): boolean {
24
+ switch (v) {
25
+ case VoluntaryExitValidity.earlyEpoch:
26
+ case VoluntaryExitValidity.shortTimeActive:
27
+ case VoluntaryExitValidity.pendingWithdrawals:
28
+ return true;
29
+ case VoluntaryExitValidity.valid:
30
+ case VoluntaryExitValidity.inactive:
31
+ case VoluntaryExitValidity.alreadyExited:
32
+ case VoluntaryExitValidity.invalidSignature:
33
+ return false;
34
+ }
35
+ }
36
+
37
+ // Comments for each call are present inside `validateVoluntaryExit`.
12
38
  export async function validateApiVoluntaryExit(
13
39
  chain: IBeaconChain,
14
40
  voluntaryExit: phase0.SignedVoluntaryExit
15
- ): Promise<void> {
41
+ ): Promise<ApiVoluntaryExitResult> {
16
42
  const prioritizeBls = true;
17
- return validateVoluntaryExit(chain, voluntaryExit, prioritizeBls);
43
+
44
+ if (
45
+ chain.opPool.hasSeenVoluntaryExit(voluntaryExit.message.validatorIndex) ||
46
+ chain.deferredVoluntaryExitPool.has(voluntaryExit.message.validatorIndex)
47
+ ) {
48
+ throw new VoluntaryExitError(GossipAction.IGNORE, {
49
+ code: VoluntaryExitErrorCode.ALREADY_EXISTS,
50
+ });
51
+ }
52
+
53
+ const state = await chain.getHeadStateAtCurrentEpoch(RegenCaller.validateApiVoluntaryExit);
54
+ const validity = state.getVoluntaryExitValidity(voluntaryExit, false);
55
+
56
+ if (validity !== VoluntaryExitValidity.valid && !isTransientExitValidity(validity)) {
57
+ throw new VoluntaryExitError(GossipAction.REJECT, {
58
+ code: voluntaryExitValidityToErrorCode(validity),
59
+ });
60
+ }
61
+
62
+ const signatureSet = getVoluntaryExitSignatureSet(chain.config, state, voluntaryExit);
63
+ if (!(await chain.bls.verifySignatureSets([signatureSet], {batchable: true, priority: prioritizeBls}))) {
64
+ throw new VoluntaryExitError(GossipAction.REJECT, {
65
+ code: VoluntaryExitErrorCode.INVALID_SIGNATURE,
66
+ });
67
+ }
68
+
69
+ if (validity !== VoluntaryExitValidity.valid) {
70
+ // Transient failure — signature is good, defer
71
+ return {status: "deferred", validity};
72
+ }
73
+
74
+ return {status: "published"};
18
75
  }
19
76
 
20
77
  export async function validateGossipVoluntaryExit(
@@ -4,7 +4,10 @@ import {ArchiveStoreTask} from "../../chain/archiveStore/archiveStore.js";
4
4
  import {FrequencyStateArchiveStep} from "../../chain/archiveStore/strategies/frequencyStateArchiveStrategy.js";
5
5
  import {BlockInputSource} from "../../chain/blocks/blockInput/index.js";
6
6
  import {PayloadErrorCode} from "../../chain/blocks/importExecutionPayload.js";
7
- import {PayloadEnvelopeInputSource} from "../../chain/blocks/payloadEnvelopeInput/index.js";
7
+ import {
8
+ PayloadEnvelopeInputPruneReason,
9
+ PayloadEnvelopeInputSource,
10
+ } from "../../chain/blocks/payloadEnvelopeInput/index.js";
8
11
  import {JobQueueItemType} from "../../chain/bls/index.js";
9
12
  import {AttestationErrorCode, BlockErrorCode} from "../../chain/errors/index.js";
10
13
  import {
@@ -21,7 +24,7 @@ import {ExecutionPayloadStatus} from "../../execution/index.js";
21
24
  import {GossipType} from "../../network/index.js";
22
25
  import {CannotAcceptWorkReason, ReprocessRejectReason} from "../../network/processor/index.js";
23
26
  import {BackfillSyncMethod} from "../../sync/backfill/backfill.js";
24
- import {PendingBlockType} from "../../sync/types.js";
27
+ import {DroppedItemReason, PendingBlockType} from "../../sync/types.js";
25
28
  import {PeerSyncType, RangeSyncType} from "../../sync/utils/remoteSyncType.js";
26
29
  import {AllocSource} from "../../util/bufferPool.js";
27
30
  import {DataColumnReconstructionCode} from "../../util/dataColumns.js";
@@ -677,9 +680,15 @@ export function createLodestarMetrics(
677
680
  name: "lodestar_sync_unknown_block_downloaded_blocks_error_total",
678
681
  help: "Total number of downloaded blocks errors in UnknownBlockSync",
679
682
  }),
680
- removedBlocks: register.gauge({
683
+ removedBlocks: register.gauge<{reason: DroppedItemReason}>({
681
684
  name: "lodestar_sync_unknown_block_removed_blocks_total",
682
- help: "Total number of removed bad blocks in UnknownBlockSync",
685
+ help: "Total pending blocks dropped from BlockInputSync without completing, by reason",
686
+ labelNames: ["reason"],
687
+ }),
688
+ removedPayloads: register.gauge<{reason: DroppedItemReason}>({
689
+ name: "lodestar_sync_unknown_block_removed_payloads_total",
690
+ help: "Total pending payloads dropped from BlockInputSync without completing, by reason",
691
+ labelNames: ["reason"],
683
692
  }),
684
693
  elapsedTimeTillReceived: register.histogram({
685
694
  name: "lodestar_sync_unknown_block_elapsed_time_till_received",
@@ -1225,6 +1234,12 @@ export function createLodestarMetrics(
1225
1234
  name: "lodestar_oppool_attester_slashing_pool_size",
1226
1235
  help: "Current size of the AttesterSlashingPool",
1227
1236
  }),
1237
+ deferredVoluntaryExitPool: {
1238
+ size: register.gauge({
1239
+ name: "lodestar_oppool_deferred_voluntary_exit_pool_size",
1240
+ help: "Current size of the DeferredVoluntaryExitPool",
1241
+ }),
1242
+ },
1228
1243
  proposerSlashingPoolSize: register.gauge({
1229
1244
  name: "lodestar_oppool_proposer_slashing_pool_size",
1230
1245
  help: "Current size of the ProposerSlashingPool",
@@ -1643,9 +1658,15 @@ export function createLodestarMetrics(
1643
1658
  name: "lodestar_seen_payload_envelope_input_cache_serialized_object_refs",
1644
1659
  help: "Number of serialized-cache object refs retained by cached PayloadEnvelopeInputs",
1645
1660
  }),
1646
- created: register.counter({
1661
+ created: register.counter<{source: PayloadEnvelopeInputSource}>({
1647
1662
  name: "lodestar_seen_payload_envelope_input_cache_items_created_total",
1648
- help: "Number of PayloadEnvelopeInputs created",
1663
+ help: "Number of PayloadEnvelopeInputs created by source",
1664
+ labelNames: ["source"],
1665
+ }),
1666
+ pruned: register.counter<{reason: PayloadEnvelopeInputPruneReason}>({
1667
+ name: "lodestar_seen_payload_envelope_input_cache_items_pruned_total",
1668
+ help: "Number of PayloadEnvelopeInputs evicted by reason",
1669
+ labelNames: ["reason"],
1649
1670
  }),
1650
1671
  },
1651
1672
  },
@@ -414,3 +414,41 @@ export const gossipTopicIgnoreDuplicatePublishError: Record<GossipType, boolean>
414
414
  [GossipType.execution_payload_bid]: true,
415
415
  [GossipType.proposer_preferences]: true,
416
416
  };
417
+
418
+ /**
419
+ * Whether a publish that reached zero subscribed peers is an acceptable outcome for a topic.
420
+ *
421
+ * Publishing to a topic with no subscribed peers throws `PublishError.NoPeersSubscribedToTopic`, which
422
+ * propagates to the caller and, for messages submitted through the beacon API, is surfaced to the
423
+ * validator client. That is the right default: a message that reached nobody is a failed broadcast, and a
424
+ * VC configured with several beacon nodes can fall back to another one.
425
+ *
426
+ * Only topics that have a concrete reason to tolerate it opt out here, ie. are set to `true`, and the
427
+ * reason is named on the entry. Everything else is `false` and keeps raising. Note this is applied per
428
+ * publish, a topic set to `false` still honors the global `--network.allowPublishToZeroPeers` flag.
429
+ */
430
+ export const gossipTopicAllowPublishToZeroPeers: Record<GossipType, boolean> = {
431
+ [GossipType.beacon_block]: false,
432
+ [GossipType.blob_sidecar]: false,
433
+ // data_column_sidecar: we ensure having all topic peers via prioritizePeers(). Even with 0 peers on the
434
+ // topic the overall publish can still succeed, because a supernode rebuilds and publishes the missing
435
+ // columns for us, so track sent peers as 0 instead of raising
436
+ [GossipType.data_column_sidecar]: true,
437
+ [GossipType.beacon_aggregate_and_proof]: false,
438
+ [GossipType.beacon_attestation]: false,
439
+ [GossipType.voluntary_exit]: false,
440
+ [GossipType.proposer_slashing]: false,
441
+ [GossipType.attester_slashing]: false,
442
+ [GossipType.sync_committee_contribution_and_proof]: false,
443
+ [GossipType.sync_committee]: false,
444
+ // light_client_finality_update and light_client_optimistic_update: non-mandatory route on most of the
445
+ // network as of Oct 2022, there may be no peer subscribed to the topic at all. Reason carried over from
446
+ // the handlers in network.ts, which used to swallow the error instead
447
+ [GossipType.light_client_finality_update]: true,
448
+ [GossipType.light_client_optimistic_update]: true,
449
+ [GossipType.bls_to_execution_change]: false,
450
+ [GossipType.execution_payload]: false,
451
+ [GossipType.payload_attestation_message]: false,
452
+ [GossipType.execution_payload_bid]: false,
453
+ [GossipType.proposer_preferences]: false,
454
+ };
@@ -49,7 +49,12 @@ import {INetworkCore, NetworkCore, WorkerNetworkCore} from "./core/index.js";
49
49
  import {INetworkEventBus, NetworkEvent, NetworkEventBus, NetworkEventData} from "./events.js";
50
50
  import {getActiveForkBoundaries} from "./forks.js";
51
51
  import {GossipHandlers, GossipTopicMap, GossipType, GossipTypeMap} from "./gossip/index.js";
52
- import {getGossipSSZType, gossipTopicIgnoreDuplicatePublishError, stringifyGossipTopic} from "./gossip/topic.js";
52
+ import {
53
+ getGossipSSZType,
54
+ gossipTopicAllowPublishToZeroPeers,
55
+ gossipTopicIgnoreDuplicatePublishError,
56
+ stringifyGossipTopic,
57
+ } from "./gossip/topic.js";
53
58
  import {INetwork} from "./interface.js";
54
59
  import {NetworkOptions} from "./options.js";
55
60
  import {PeerAction, PeerScoreStats} from "./peers/index.js";
@@ -65,7 +70,7 @@ import {
65
70
  } from "./reqresp/utils/collect.js";
66
71
  import {collectSequentialBlocksInRange} from "./reqresp/utils/collectSequentialBlocksInRange.js";
67
72
  import {CommitteeSubscription} from "./subnets/index.js";
68
- import {isPublishDuplicateError, isPublishToZeroPeersError, prettyPrintPeerIdStr} from "./util.js";
73
+ import {isPublishDuplicateError, prettyPrintPeerIdStr} from "./util.js";
69
74
 
70
75
  type NetworkModules = {
71
76
  opts: NetworkOptions;
@@ -352,9 +357,7 @@ export class Network implements INetwork {
352
357
  const epoch = computeEpochAtSlot(signedBlock.message.slot);
353
358
  const boundary = this.config.getForkBoundaryAtEpoch(epoch);
354
359
 
355
- return this.publishGossip<GossipType.beacon_block>({type: GossipType.beacon_block, boundary}, signedBlock, {
356
- ignoreDuplicatePublishError: true,
357
- });
360
+ return this.publishGossip<GossipType.beacon_block>({type: GossipType.beacon_block, boundary}, signedBlock);
358
361
  }
359
362
 
360
363
  async publishBlobSidecar(blobSidecar: deneb.BlobSidecar): Promise<number> {
@@ -363,9 +366,7 @@ export class Network implements INetwork {
363
366
 
364
367
  const subnet = blobSidecar.index;
365
368
 
366
- return this.publishGossip<GossipType.blob_sidecar>({type: GossipType.blob_sidecar, boundary, subnet}, blobSidecar, {
367
- ignoreDuplicatePublishError: true,
368
- });
369
+ return this.publishGossip<GossipType.blob_sidecar>({type: GossipType.blob_sidecar, boundary, subnet}, blobSidecar);
369
370
  }
370
371
 
371
372
  async publishDataColumnSidecar(
@@ -381,14 +382,7 @@ export class Network implements INetwork {
381
382
  try {
382
383
  const sentPeers = await this.publishGossip<GossipType.data_column_sidecar>(
383
384
  {type: GossipType.data_column_sidecar, boundary, subnet},
384
- dataColumnSidecar,
385
- {
386
- // we ensure having all topic peers via prioritizePeers() function
387
- // in the worse case, if there is 0 peer on the topic, the overall publish operation could be still a success
388
- // because supernode will rebuild and publish missing data column sidecars for us
389
- // hence we want to track sent peers as 0 instead of an error
390
- allowPublishToZeroTopicPeers: true,
391
- }
385
+ dataColumnSidecar
392
386
  );
393
387
  return {sentPeers, alreadyPublished: false};
394
388
  } catch (e) {
@@ -405,8 +399,7 @@ export class Network implements INetwork {
405
399
 
406
400
  return this.publishGossip<GossipType.beacon_aggregate_and_proof>(
407
401
  {type: GossipType.beacon_aggregate_and_proof, boundary},
408
- aggregateAndProof,
409
- {ignoreDuplicatePublishError: true}
402
+ aggregateAndProof
410
403
  );
411
404
  }
412
405
 
@@ -416,8 +409,7 @@ export class Network implements INetwork {
416
409
 
417
410
  return this.publishGossip<GossipType.beacon_attestation>(
418
411
  {type: GossipType.beacon_attestation, boundary, subnet},
419
- attestation,
420
- {ignoreDuplicatePublishError: true}
412
+ attestation
421
413
  );
422
414
  }
423
415
 
@@ -425,9 +417,7 @@ export class Network implements INetwork {
425
417
  const epoch = voluntaryExit.message.epoch;
426
418
  const boundary = this.config.getForkBoundaryAtEpoch(epoch);
427
419
 
428
- return this.publishGossip<GossipType.voluntary_exit>({type: GossipType.voluntary_exit, boundary}, voluntaryExit, {
429
- ignoreDuplicatePublishError: true,
430
- });
420
+ return this.publishGossip<GossipType.voluntary_exit>({type: GossipType.voluntary_exit, boundary}, voluntaryExit);
431
421
  }
432
422
 
433
423
  async publishBlsToExecutionChange(blsToExecutionChange: capella.SignedBLSToExecutionChange): Promise<number> {
@@ -438,8 +428,7 @@ export class Network implements INetwork {
438
428
  if (fork >= ForkSeq.capella) {
439
429
  const publishPromise = this.publishGossip<GossipType.bls_to_execution_change>(
440
430
  {type: GossipType.bls_to_execution_change, boundary},
441
- blsToExecutionChange,
442
- {ignoreDuplicatePublishError: true}
431
+ blsToExecutionChange
443
432
  );
444
433
  publishChanges.push(publishPromise);
445
434
  }
@@ -457,8 +446,7 @@ export class Network implements INetwork {
457
446
 
458
447
  return this.publishGossip<GossipType.proposer_slashing>(
459
448
  {type: GossipType.proposer_slashing, boundary},
460
- proposerSlashing,
461
- {ignoreDuplicatePublishError: true}
449
+ proposerSlashing
462
450
  );
463
451
  }
464
452
 
@@ -478,10 +466,7 @@ export class Network implements INetwork {
478
466
 
479
467
  return this.publishGossip<GossipType.sync_committee>(
480
468
  {type: GossipType.sync_committee, boundary, subnet},
481
- signature,
482
- {
483
- ignoreDuplicatePublishError: true,
484
- }
469
+ signature
485
470
  );
486
471
  }
487
472
 
@@ -491,8 +476,7 @@ export class Network implements INetwork {
491
476
 
492
477
  return this.publishGossip<GossipType.sync_committee_contribution_and_proof>(
493
478
  {type: GossipType.sync_committee_contribution_and_proof, boundary},
494
- contributionAndProof,
495
- {ignoreDuplicatePublishError: true}
479
+ contributionAndProof
496
480
  );
497
481
  }
498
482
 
@@ -522,8 +506,7 @@ export class Network implements INetwork {
522
506
 
523
507
  return this.publishGossip<GossipType.execution_payload>(
524
508
  {type: GossipType.execution_payload, boundary},
525
- signedEnvelope,
526
- {ignoreDuplicatePublishError: true}
509
+ signedEnvelope
527
510
  );
528
511
  }
529
512
 
@@ -533,8 +516,7 @@ export class Network implements INetwork {
533
516
 
534
517
  return this.publishGossip<GossipType.execution_payload_bid>(
535
518
  {type: GossipType.execution_payload_bid, boundary},
536
- signedBid,
537
- {ignoreDuplicatePublishError: true}
519
+ signedBid
538
520
  );
539
521
  }
540
522
 
@@ -544,8 +526,7 @@ export class Network implements INetwork {
544
526
 
545
527
  return this.publishGossip<GossipType.payload_attestation_message>(
546
528
  {type: GossipType.payload_attestation_message, boundary},
547
- payloadAttestationMessage,
548
- {ignoreDuplicatePublishError: true}
529
+ payloadAttestationMessage
549
530
  );
550
531
  }
551
532
 
@@ -555,22 +536,21 @@ export class Network implements INetwork {
555
536
 
556
537
  return this.publishGossip<GossipType.proposer_preferences>(
557
538
  {type: GossipType.proposer_preferences, boundary},
558
- signedProposerPreferences,
559
- {ignoreDuplicatePublishError: true}
539
+ signedProposerPreferences
560
540
  );
561
541
  }
562
542
 
563
543
  private async publishGossip<K extends GossipType>(
564
544
  topic: GossipTopicMap[K],
565
- object: GossipTypeMap[K],
566
- opts?: PublishOpts | undefined
545
+ object: GossipTypeMap[K]
567
546
  ): Promise<number> {
568
547
  const topicStr = stringifyGossipTopic(this.config, topic);
569
548
  const sszType = getGossipSSZType(topic);
570
549
  const messageData = (sszType.serialize as (object: GossipTypeMap[GossipType]) => Uint8Array)(object);
571
- opts = {
572
- ...opts,
550
+ const opts: PublishOpts = {
573
551
  ignoreDuplicatePublishError: gossipTopicIgnoreDuplicatePublishError[topic.type],
552
+ // Leave undefined unless the topic opts out, so `--network.allowPublishToZeroPeers` still applies
553
+ allowPublishToZeroTopicPeers: gossipTopicAllowPublishToZeroPeers[topic.type] ? true : undefined,
574
554
  };
575
555
  const sentPeers = await this.core.publishGossip(topicStr, messageData, opts);
576
556
 
@@ -813,11 +793,7 @@ export class Network implements INetwork {
813
793
  await this.waitForSyncMessageCutoff(finalityUpdate.signatureSlot);
814
794
  await this.publishLightClientFinalityUpdate(finalityUpdate);
815
795
  } catch (e) {
816
- // Non-mandatory route on most of network as of Oct 2022. May not have found any peers on topic yet
817
- // Remove once https://github.com/ChainSafe/js-libp2p-gossipsub/issues/367
818
- if (!isPublishToZeroPeersError(e as Error)) {
819
- this.logger.debug("Error on BeaconGossipHandler.onLightclientFinalityUpdate", {}, e as Error);
820
- }
796
+ this.logger.debug("Error on BeaconGossipHandler.onLightclientFinalityUpdate", {}, e as Error);
821
797
  }
822
798
  };
823
799
 
@@ -830,11 +806,7 @@ export class Network implements INetwork {
830
806
  await this.waitForSyncMessageCutoff(optimisticUpdate.signatureSlot);
831
807
  await this.publishLightClientOptimisticUpdate(optimisticUpdate);
832
808
  } catch (e) {
833
- // Non-mandatory route on most of network as of Oct 2022. May not have found any peers on topic yet
834
- // Remove once https://github.com/ChainSafe/js-libp2p-gossipsub/issues/367
835
- if (!isPublishToZeroPeersError(e as Error)) {
836
- this.logger.debug("Error on BeaconGossipHandler.onLightclientOptimisticUpdate", {}, e as Error);
837
- }
809
+ this.logger.debug("Error on BeaconGossipHandler.onLightclientOptimisticUpdate", {}, e as Error);
838
810
  }
839
811
  };
840
812