@lodestar/beacon-node 1.44.0-dev.86fc005b68 → 1.44.0-dev.9666fc891a

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 (108) hide show
  1. package/lib/api/impl/beacon/blocks/index.d.ts.map +1 -1
  2. package/lib/api/impl/beacon/blocks/index.js +13 -5
  3. package/lib/api/impl/beacon/blocks/index.js.map +1 -1
  4. package/lib/api/impl/config/constants.d.ts +1 -0
  5. package/lib/api/impl/config/constants.d.ts.map +1 -1
  6. package/lib/api/impl/config/constants.js +2 -1
  7. package/lib/api/impl/config/constants.js.map +1 -1
  8. package/lib/api/impl/lodestar/index.d.ts.map +1 -1
  9. package/lib/api/impl/lodestar/index.js +28 -0
  10. package/lib/api/impl/lodestar/index.js.map +1 -1
  11. package/lib/chain/archiveStore/archiveStore.d.ts +0 -1
  12. package/lib/chain/archiveStore/archiveStore.d.ts.map +1 -1
  13. package/lib/chain/archiveStore/archiveStore.js +0 -4
  14. package/lib/chain/archiveStore/archiveStore.js.map +1 -1
  15. package/lib/chain/chain.d.ts.map +1 -1
  16. package/lib/chain/chain.js +8 -1
  17. package/lib/chain/chain.js.map +1 -1
  18. package/lib/chain/errors/blockError.d.ts +0 -7
  19. package/lib/chain/errors/blockError.d.ts.map +1 -1
  20. package/lib/chain/errors/blockError.js +0 -3
  21. package/lib/chain/errors/blockError.js.map +1 -1
  22. package/lib/chain/forkChoice/index.d.ts +4 -4
  23. package/lib/chain/forkChoice/index.d.ts.map +1 -1
  24. package/lib/chain/forkChoice/index.js +7 -7
  25. package/lib/chain/forkChoice/index.js.map +1 -1
  26. package/lib/chain/options.d.ts.map +1 -1
  27. package/lib/chain/options.js +1 -0
  28. package/lib/chain/options.js.map +1 -1
  29. package/lib/chain/regen/interface.d.ts +0 -1
  30. package/lib/chain/regen/interface.d.ts.map +1 -1
  31. package/lib/chain/regen/queued.d.ts +0 -1
  32. package/lib/chain/regen/queued.d.ts.map +1 -1
  33. package/lib/chain/regen/queued.js +0 -4
  34. package/lib/chain/regen/queued.js.map +1 -1
  35. package/lib/chain/stateCache/fifoBlockStateCache.d.ts +0 -5
  36. package/lib/chain/stateCache/fifoBlockStateCache.d.ts.map +1 -1
  37. package/lib/chain/stateCache/fifoBlockStateCache.js +0 -5
  38. package/lib/chain/stateCache/fifoBlockStateCache.js.map +1 -1
  39. package/lib/chain/stateCache/persistentCheckpointsCache.d.ts +1 -4
  40. package/lib/chain/stateCache/persistentCheckpointsCache.d.ts.map +1 -1
  41. package/lib/chain/stateCache/persistentCheckpointsCache.js +5 -2
  42. package/lib/chain/stateCache/persistentCheckpointsCache.js.map +1 -1
  43. package/lib/chain/stateCache/types.d.ts +0 -2
  44. package/lib/chain/stateCache/types.d.ts.map +1 -1
  45. package/lib/chain/stateCache/types.js.map +1 -1
  46. package/lib/chain/validation/block.d.ts +5 -1
  47. package/lib/chain/validation/block.d.ts.map +1 -1
  48. package/lib/chain/validation/block.js +4 -14
  49. package/lib/chain/validation/block.js.map +1 -1
  50. package/lib/chain/validation/executionPayloadEnvelope.js +0 -2
  51. package/lib/chain/validation/executionPayloadEnvelope.js.map +1 -1
  52. package/lib/metrics/metrics/lodestar.d.ts +1 -0
  53. package/lib/metrics/metrics/lodestar.d.ts.map +1 -1
  54. package/lib/metrics/metrics/lodestar.js +5 -0
  55. package/lib/metrics/metrics/lodestar.js.map +1 -1
  56. package/lib/network/processor/gossipHandlers.d.ts.map +1 -1
  57. package/lib/network/processor/gossipHandlers.js +9 -2
  58. package/lib/network/processor/gossipHandlers.js.map +1 -1
  59. package/lib/network/processor/index.d.ts +2 -2
  60. package/lib/network/processor/index.d.ts.map +1 -1
  61. package/lib/network/processor/index.js +24 -22
  62. package/lib/network/processor/index.js.map +1 -1
  63. package/lib/network/reqresp/handlers/beaconBlocksByRange.d.ts.map +1 -1
  64. package/lib/network/reqresp/handlers/beaconBlocksByRange.js +9 -5
  65. package/lib/network/reqresp/handlers/beaconBlocksByRange.js.map +1 -1
  66. package/lib/network/reqresp/handlers/dataColumnSidecarsByRange.d.ts.map +1 -1
  67. package/lib/network/reqresp/handlers/dataColumnSidecarsByRange.js +8 -3
  68. package/lib/network/reqresp/handlers/dataColumnSidecarsByRange.js.map +1 -1
  69. package/lib/network/reqresp/handlers/dataColumnSidecarsByRoot.js +1 -1
  70. package/lib/network/reqresp/handlers/dataColumnSidecarsByRoot.js.map +1 -1
  71. package/lib/network/reqresp/handlers/executionPayloadEnvelopesByRange.d.ts +2 -1
  72. package/lib/network/reqresp/handlers/executionPayloadEnvelopesByRange.d.ts.map +1 -1
  73. package/lib/network/reqresp/handlers/executionPayloadEnvelopesByRange.js +16 -6
  74. package/lib/network/reqresp/handlers/executionPayloadEnvelopesByRange.js.map +1 -1
  75. package/lib/network/reqresp/handlers/index.js +2 -2
  76. package/lib/network/reqresp/handlers/index.js.map +1 -1
  77. package/lib/network/reqresp/utils/dataColumnResponseValidation.d.ts.map +1 -1
  78. package/lib/network/reqresp/utils/dataColumnResponseValidation.js +14 -3
  79. package/lib/network/reqresp/utils/dataColumnResponseValidation.js.map +1 -1
  80. package/lib/util/dataColumns.d.ts.map +1 -1
  81. package/lib/util/dataColumns.js +16 -11
  82. package/lib/util/dataColumns.js.map +1 -1
  83. package/package.json +14 -16
  84. package/src/api/impl/beacon/blocks/index.ts +13 -5
  85. package/src/api/impl/config/constants.ts +2 -0
  86. package/src/api/impl/lodestar/index.ts +30 -0
  87. package/src/chain/archiveStore/archiveStore.ts +0 -5
  88. package/src/chain/chain.ts +10 -1
  89. package/src/chain/errors/blockError.ts +0 -4
  90. package/src/chain/forkChoice/index.ts +8 -0
  91. package/src/chain/options.ts +1 -0
  92. package/src/chain/regen/interface.ts +0 -1
  93. package/src/chain/regen/queued.ts +0 -5
  94. package/src/chain/stateCache/fifoBlockStateCache.ts +0 -6
  95. package/src/chain/stateCache/persistentCheckpointsCache.ts +6 -2
  96. package/src/chain/stateCache/types.ts +0 -2
  97. package/src/chain/validation/block.ts +12 -16
  98. package/src/chain/validation/executionPayloadEnvelope.ts +0 -2
  99. package/src/metrics/metrics/lodestar.ts +6 -0
  100. package/src/network/processor/gossipHandlers.ts +9 -2
  101. package/src/network/processor/index.ts +26 -26
  102. package/src/network/reqresp/handlers/beaconBlocksByRange.ts +12 -5
  103. package/src/network/reqresp/handlers/dataColumnSidecarsByRange.ts +11 -3
  104. package/src/network/reqresp/handlers/dataColumnSidecarsByRoot.ts +1 -1
  105. package/src/network/reqresp/handlers/executionPayloadEnvelopesByRange.ts +22 -6
  106. package/src/network/reqresp/handlers/index.ts +2 -2
  107. package/src/network/reqresp/utils/dataColumnResponseValidation.ts +13 -3
  108. package/src/util/dataColumns.ts +17 -12
@@ -186,9 +186,11 @@ export class NetworkProcessor {
186
186
  // we may not receive the block for messages like Attestation and SignedAggregateAndProof messages, in that case PendingGossipsubMessage needs
187
187
  // to be stored in this Map and reprocessed once the block comes
188
188
  private readonly awaitingMessagesByBlockRoot: MapDef<RootHex, Set<PendingGossipsubMessage>>;
189
+ private awaitingBlockMessageCount = 0;
189
190
  // we may not receive the payload for messages that require the FULL payload variant to be processed,
190
191
  // in that case PendingGossipsubMessage needs to be stored in this Map and reprocessed once the payload comes
191
192
  private readonly awaitingMessagesByPayloadBlockRoot: MapDef<RootHex, Set<PendingGossipsubMessage>>;
193
+ private awaitingPayloadMessageCount = 0;
192
194
  private unknownBlocksBySlot = new MapDef<Slot, Set<RootHex>>(() => new Set());
193
195
  private unknownEnvelopesBySlot = new MapDef<Slot, Set<RootHex>>(() => new Set());
194
196
 
@@ -228,8 +230,8 @@ export class NetworkProcessor {
228
230
  metrics.gossipValidationQueue.keySize.set({topic}, this.gossipQueues[topic].keySize);
229
231
  metrics.gossipValidationQueue.concurrency.set({topic}, this.gossipTopicConcurrency[topic]);
230
232
  }
231
- metrics.awaitingBlockGossipMessages.countPerSlot.set(this.unknownBlockGossipsubMessagesCount);
232
- metrics.awaitingPayloadGossipMessages.countPerSlot.set(this.unknownPayloadGossipsubMessagesCount);
233
+ metrics.awaitingBlockGossipMessages.countPerSlot.set(this.awaitingBlockMessageCount);
234
+ metrics.awaitingPayloadGossipMessages.countPerSlot.set(this.awaitingPayloadMessageCount);
233
235
  // specific metric for beacon_attestation topic
234
236
  metrics.gossipValidationQueue.keyAge.reset();
235
237
  for (const ageMs of this.gossipQueues.beacon_attestation.getDataAgeMs()) {
@@ -497,7 +499,7 @@ export class NetworkProcessor {
497
499
  this.pushPendingGossipsubMessageToQueue(message);
498
500
  break;
499
501
  case PreprocessAction.AwaitBlock: {
500
- if (this.unknownBlockGossipsubMessagesCount > MAX_QUEUED_UNKNOWN_BLOCK_GOSSIP_OBJECTS) {
502
+ if (this.awaitingBlockMessageCount > MAX_QUEUED_UNKNOWN_BLOCK_GOSSIP_OBJECTS) {
501
503
  // No need to report the dropped job to gossip. It will be eventually pruned from the mcache
502
504
  this.metrics?.awaitingBlockGossipMessages.reject.inc({
503
505
  reason: ReprocessRejectReason.reached_limit,
@@ -509,10 +511,11 @@ export class NetworkProcessor {
509
511
  this.metrics?.awaitingBlockGossipMessages.queue.inc({topic: topicType});
510
512
  const awaitingGossipsubMessages = this.awaitingMessagesByBlockRoot.getOrDefault(preprocessResult.root);
511
513
  awaitingGossipsubMessages.add(message);
514
+ this.awaitingBlockMessageCount++;
512
515
  break;
513
516
  }
514
517
  case PreprocessAction.AwaitEnvelope: {
515
- if (this.unknownPayloadGossipsubMessagesCount > MAX_QUEUED_UNKNOWN_PAYLOAD_GOSSIP_OBJECTS) {
518
+ if (this.awaitingPayloadMessageCount > MAX_QUEUED_UNKNOWN_PAYLOAD_GOSSIP_OBJECTS) {
516
519
  this.metrics?.awaitingPayloadGossipMessages.reject.inc({
517
520
  reason: ReprocessRejectReason.reached_limit,
518
521
  topic: topicType,
@@ -525,6 +528,7 @@ export class NetworkProcessor {
525
528
  preprocessResult.root
526
529
  );
527
530
  awaitingPayloadGossipsubMessages.add(message);
531
+ this.awaitingPayloadMessageCount++;
528
532
  break;
529
533
  }
530
534
  }
@@ -548,6 +552,12 @@ export class NetworkProcessor {
548
552
  return;
549
553
  }
550
554
 
555
+ // Atomically remove from map and update counter before async iteration to
556
+ // prevent double-decrement race with onClockSlot during yield points below
557
+ if (this.awaitingMessagesByBlockRoot.delete(rootHex)) {
558
+ this.awaitingBlockMessageCount -= waitingGossipsubMessages.size;
559
+ }
560
+
551
561
  const nowSec = Date.now() / 1000;
552
562
  let count = 0;
553
563
  // TODO: we can group attestations to process in batches but since we have the SeenAttestationDatas
@@ -567,8 +577,6 @@ export class NetworkProcessor {
567
577
  await sleep(AWAITING_GOSSIP_OBJECTS_YIELD_EVERY_MS);
568
578
  }
569
579
  }
570
-
571
- this.awaitingMessagesByBlockRoot.delete(rootHex);
572
580
  };
573
581
 
574
582
  private onPayloadEnvelopeProcessed = async ({blockRoot: rootHex}: {blockRoot: RootHex}): Promise<void> => {
@@ -577,6 +585,12 @@ export class NetworkProcessor {
577
585
  return;
578
586
  }
579
587
 
588
+ // Atomically remove from map and update counter before async iteration to
589
+ // prevent double-decrement race with onClockSlot during yield points below
590
+ if (this.awaitingMessagesByPayloadBlockRoot.delete(rootHex)) {
591
+ this.awaitingPayloadMessageCount -= waitingGossipsubMessages.size;
592
+ }
593
+
580
594
  const nowSec = Date.now() / 1000;
581
595
  let count = 0;
582
596
  for (const message of waitingGossipsubMessages) {
@@ -593,8 +607,6 @@ export class NetworkProcessor {
593
607
  await sleep(AWAITING_GOSSIP_OBJECTS_YIELD_EVERY_MS);
594
608
  }
595
609
  }
596
-
597
- this.awaitingMessagesByPayloadBlockRoot.delete(rootHex);
598
610
  };
599
611
 
600
612
  private onClockSlot = (clockSlot: Slot): void => {
@@ -618,7 +630,9 @@ export class NetworkProcessor {
618
630
  );
619
631
  // No need to report the dropped job to gossip. It will be eventually pruned from the mcache
620
632
  }
621
- this.awaitingMessagesByBlockRoot.delete(rootHex);
633
+ if (this.awaitingMessagesByBlockRoot.delete(rootHex)) {
634
+ this.awaitingBlockMessageCount -= gossipMessages.size;
635
+ }
622
636
  }
623
637
  }
624
638
  this.unknownBlocksBySlot.delete(slot);
@@ -641,7 +655,9 @@ export class NetworkProcessor {
641
655
  );
642
656
  // No need to report the dropped job to gossip. It will be eventually pruned from the mcache
643
657
  }
644
- this.awaitingMessagesByPayloadBlockRoot.delete(rootHex);
658
+ if (this.awaitingMessagesByPayloadBlockRoot.delete(rootHex)) {
659
+ this.awaitingPayloadMessageCount -= gossipMessages.size;
660
+ }
645
661
  }
646
662
  }
647
663
  this.unknownEnvelopesBySlot.delete(slot);
@@ -784,20 +800,4 @@ export class NetworkProcessor {
784
800
 
785
801
  return null;
786
802
  }
787
-
788
- private get unknownBlockGossipsubMessagesCount(): number {
789
- let count = 0;
790
- for (const messages of this.awaitingMessagesByBlockRoot.values()) {
791
- count += messages.size;
792
- }
793
- return count;
794
- }
795
-
796
- private get unknownPayloadGossipsubMessagesCount(): number {
797
- let count = 0;
798
- for (const messages of this.awaitingMessagesByPayloadBlockRoot.values()) {
799
- count += messages.size;
800
- }
801
- return count;
802
- }
803
803
  }
@@ -1,6 +1,6 @@
1
1
  import {PeerId} from "@libp2p/interface";
2
2
  import {BeaconConfig} from "@lodestar/config";
3
- import {GENESIS_SLOT, isForkPostDeneb, isForkPostFulu} from "@lodestar/params";
3
+ import {GENESIS_SLOT, isForkPostDeneb} from "@lodestar/params";
4
4
  import {RespStatus, ResponseError, ResponseOutgoing} from "@lodestar/reqresp";
5
5
  import {computeEpochAtSlot} from "@lodestar/state-transition";
6
6
  import {deneb, phase0} from "@lodestar/types";
@@ -29,13 +29,20 @@ export async function* onBeaconBlocksByRange(
29
29
  // starts above it to avoid duplicate yields. See archiveBlocks.ts for the migration logic.
30
30
  const archiveMaxSlot = finalizedSlot;
31
31
 
32
- const forkName = chain.config.getForkName(startSlot);
33
- if (isForkPostFulu(forkName) && startSlot < chain.earliestAvailableSlot) {
34
- chain.logger.verbose("Peer did not respect earliestAvailableSlot for BeaconBlocksByRange", {
32
+ // endSlot is exclusive, so highest served slot is endSlot - 1.
33
+ // Throw only when the entire requested range is below earliestAvailableSlot.
34
+ if (endSlot - 1 < chain.earliestAvailableSlot) {
35
+ chain.logger.verbose("Peer requested range before earliestAvailableSlot for BeaconBlocksByRange", {
35
36
  peer: prettyPrintPeerId(peerId),
36
37
  client: peerClient,
38
+ startSlot,
39
+ count,
40
+ earliestAvailableSlot: chain.earliestAvailableSlot,
37
41
  });
38
- return;
42
+ throw new ResponseError(
43
+ RespStatus.RESOURCE_UNAVAILABLE,
44
+ `Requested range is before earliestAvailableSlot startSlot=${startSlot} count=${count} earliestAvailableSlot=${chain.earliestAvailableSlot}`
45
+ );
39
46
  }
40
47
 
41
48
  // Finalized range of blocks
@@ -34,12 +34,20 @@ export async function* onDataColumnSidecarsByRange(
34
34
  return;
35
35
  }
36
36
 
37
- if (startSlot < chain.earliestAvailableSlot) {
38
- chain.logger.verbose("Peer did not respect earliestAvailableSlot for DataColumnSidecarsByRange", {
37
+ // endSlot is exclusive, so highest served slot is endSlot - 1.
38
+ // Throw only when the entire requested range is below earliestAvailableSlot.
39
+ if (endSlot - 1 < chain.earliestAvailableSlot) {
40
+ chain.logger.verbose("Peer requested range before earliestAvailableSlot for DataColumnSidecarsByRange", {
39
41
  peer: prettyPrintPeerId(peerId),
40
42
  client: peerClient,
43
+ startSlot,
44
+ count,
45
+ earliestAvailableSlot: chain.earliestAvailableSlot,
41
46
  });
42
- return;
47
+ throw new ResponseError(
48
+ RespStatus.RESOURCE_UNAVAILABLE,
49
+ `Requested range is before earliestAvailableSlot startSlot=${startSlot} count=${count} earliestAvailableSlot=${chain.earliestAvailableSlot}`
50
+ );
43
51
  }
44
52
 
45
53
  const finalized = db.dataColumnSidecarArchive;
@@ -30,7 +30,7 @@ export async function* onDataColumnSidecarsByRoot(
30
30
  const {blockRoot, columns: requestedColumns} = dataColumnsByRootIdentifier;
31
31
  const availableColumns = validateRequestedDataColumns(chain, requestedColumns);
32
32
  if (availableColumns.length === 0) {
33
- return;
33
+ continue;
34
34
  }
35
35
 
36
36
  const blockRootHex = toRootHex(blockRoot);
@@ -1,3 +1,4 @@
1
+ import {PeerId} from "@libp2p/interface";
1
2
  import {ChainConfig} from "@lodestar/config";
2
3
  import {PayloadStatus} from "@lodestar/fork-choice";
3
4
  import {GENESIS_SLOT} from "@lodestar/params";
@@ -6,23 +7,38 @@ import {computeEpochAtSlot} from "@lodestar/state-transition";
6
7
  import {gloas} from "@lodestar/types";
7
8
  import {IBeaconChain} from "../../../chain/index.js";
8
9
  import {IBeaconDb} from "../../../db/index.js";
10
+ import {prettyPrintPeerId} from "../../util.js";
9
11
 
10
12
  export async function* onExecutionPayloadEnvelopesByRange(
11
13
  request: gloas.ExecutionPayloadEnvelopesByRangeRequest,
12
14
  chain: IBeaconChain,
13
- db: IBeaconDb
15
+ db: IBeaconDb,
16
+ peerId: PeerId,
17
+ peerClient: string
14
18
  ): AsyncIterable<ResponseOutgoing> {
15
19
  const {startSlot, count} = validateExecutionPayloadEnvelopesByRangeRequest(chain.config, request);
16
20
  const endSlot = startSlot + count;
17
21
 
18
- if (startSlot < chain.earliestAvailableSlot) {
19
- return;
22
+ // endSlot is exclusive, so highest served slot is endSlot - 1.
23
+ // Throw only when the entire requested range is below earliestAvailableSlot.
24
+ if (endSlot - 1 < chain.earliestAvailableSlot) {
25
+ chain.logger.verbose("Peer requested range before earliestAvailableSlot for ExecutionPayloadEnvelopesByRange", {
26
+ peer: prettyPrintPeerId(peerId),
27
+ client: peerClient,
28
+ startSlot,
29
+ count,
30
+ earliestAvailableSlot: chain.earliestAvailableSlot,
31
+ });
32
+ throw new ResponseError(
33
+ RespStatus.RESOURCE_UNAVAILABLE,
34
+ `Requested range is before earliestAvailableSlot startSlot=${startSlot} count=${count} earliestAvailableSlot=${chain.earliestAvailableSlot}`
35
+ );
20
36
  }
21
37
 
22
38
  const finalized = db.executionPayloadEnvelopeArchive;
23
- const finalizedSlot = chain.forkChoice.getFinalizedCheckpointSlot();
24
- // The current finalized block's envelope is still in the hot db; archive migration happens
25
- // in the next finalization run (see migrateExecutionPayloadEnvelopesFromHotToColdDb).
39
+ // Use the finalized block's actual slot as the checkpoint epoch-boundary slot may be skipped
40
+ const finalizedSlot = chain.forkChoice.getFinalizedBlock().slot;
41
+ // The finalized block's envelope stays in the hot db until the next finalization run
26
42
  const archiveMaxSlot = finalizedSlot - 1;
27
43
 
28
44
  // Finalized range of envelopes
@@ -74,9 +74,9 @@ export function getReqRespHandlers({db, chain}: {db: IBeaconDb; chain: IBeaconCh
74
74
  const body = ExecutionPayloadEnvelopesByRootRequestType(chain.config).deserialize(req.data);
75
75
  return onExecutionPayloadEnvelopesByRoot(body, chain, db, peerId, peerClient);
76
76
  },
77
- [ReqRespMethod.ExecutionPayloadEnvelopesByRange]: (req) => {
77
+ [ReqRespMethod.ExecutionPayloadEnvelopesByRange]: (req, peerId, peerClient) => {
78
78
  const body = ssz.gloas.ExecutionPayloadEnvelopesByRangeRequest.deserialize(req.data);
79
- return onExecutionPayloadEnvelopesByRange(body, chain, db);
79
+ return onExecutionPayloadEnvelopesByRange(body, chain, db, peerId, peerClient);
80
80
  },
81
81
 
82
82
  [ReqRespMethod.LightClientBootstrap]: (req) => {
@@ -79,9 +79,19 @@ export function validateRequestedDataColumns(chain: IBeaconChain, requestedColum
79
79
  throw new ResponseError(RespStatus.INVALID_REQUEST, "dataColumnSidecar requested without column indices");
80
80
  }
81
81
 
82
- const custodyColumns = chain.custodyConfig.custodyColumns;
83
- const availableColumns = requestedColumns.filter((c) => custodyColumns.includes(c));
84
- const missingColumns = requestedColumns.filter((c) => !custodyColumns.includes(c));
82
+ const {custodyColumns, custodyColumnsIndex} = chain.custodyConfig;
83
+ const availableColumns: ColumnIndex[] = [];
84
+ const missingColumns: ColumnIndex[] = [];
85
+ for (const c of requestedColumns) {
86
+ // `c` is peer-controlled and SSZ-deserialized as `uint64`, so it may exceed
87
+ // `NUMBER_OF_COLUMNS - 1`; `Uint8Array` returns `undefined` for OOB reads,
88
+ // and `undefined !== 0` would silently classify OOB indices as custodied.
89
+ if ((custodyColumnsIndex[c] ?? 0) !== 0) {
90
+ availableColumns.push(c);
91
+ } else {
92
+ missingColumns.push(c);
93
+ }
94
+ }
85
95
 
86
96
  if (missingColumns.length > 0) {
87
97
  chain.logger.verbose("Requested dataColumnSidecar for non-custody columns", {
@@ -470,15 +470,17 @@ export async function recoverDataColumnSidecars(
470
470
  return DataColumnReconstructionCode.SuccessLate;
471
471
  }
472
472
 
473
- // Once the node obtains a column through reconstruction,
474
- // the node MUST expose the new column as if it had received it over the network.
475
- // If the node is subscribed to the subnet corresponding to the column,
476
- // it MUST send the reconstructed DataColumnSidecar to its topic mesh neighbors.
477
- // If instead the node is not subscribed to the corresponding subnet,
478
- // it SHOULD still expose the availability of the DataColumnSidecar as part of the gossip emission process.
479
- // After exposing the reconstructed DataColumnSidecar to the network,
480
- // the node MAY delete the DataColumnSidecar if it is not part of the node's custody requirement.
481
- const sidecarsToPublish = [];
473
+ // Per consensus-specs PR #4657, only publish reconstructed columns the node is
474
+ // subscribed to (custody + sampling). Eagerly cross-seeding non-subscribed
475
+ // columns floods the network with duplicates because the sender has no
476
+ // visibility into which peers already saw the message via the topic mesh.
477
+ // This matches the getBlobsV2 path in `getDataColumnSidecarsFromExecution` and
478
+ // aligns with Lighthouse/Prysm. Capture missing sampled indices before adding
479
+ // any reconstructed columns so they are not filtered out by the subsequent
480
+ // `addColumn` calls.
481
+ const missingSampledColumns = new Set(input.getMissingSampledColumnMeta().missing);
482
+ const sidecarsReconstructed: DataColumnSidecar[] = [];
483
+ const sidecarsToPublish: DataColumnSidecar[] = [];
482
484
  for (const columnSidecar of fullSidecars) {
483
485
  if (!input.hasColumn(columnSidecar.index)) {
484
486
  if (input instanceof PayloadEnvelopeInput) {
@@ -501,11 +503,14 @@ export async function recoverDataColumnSidecars(
501
503
  source: BlockInputSource.recovery,
502
504
  });
503
505
  }
504
- sidecarsToPublish.push(columnSidecar);
506
+ sidecarsReconstructed.push(columnSidecar);
507
+ if (missingSampledColumns.has(columnSidecar.index)) {
508
+ sidecarsToPublish.push(columnSidecar);
509
+ }
505
510
  }
506
511
  }
507
- metrics?.peerDas.reconstructedColumns.inc(sidecarsToPublish.length);
508
- metrics?.dataColumns.bySource.inc({source: BlockInputSource.recovery}, sidecarsToPublish.length);
512
+ metrics?.peerDas.reconstructedColumns.inc(sidecarsReconstructed.length);
513
+ metrics?.dataColumns.bySource.inc({source: BlockInputSource.recovery}, sidecarsReconstructed.length);
509
514
  emitter.emit(ChainEvent.publishDataColumns, sidecarsToPublish);
510
515
  // TODO: Can we record dataColumns.sentPeersPerSubnet metric somehow
511
516
  return DataColumnReconstructionCode.SuccessResolved;