@aztec-labs/p2p 6.0.0-rc.2 → 6.0.0-rc.4

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 (126) hide show
  1. package/dest/bootstrap/bootstrap.d.ts +2 -2
  2. package/dest/bootstrap/bootstrap.d.ts.map +1 -1
  3. package/dest/bootstrap/bootstrap.js +1 -3
  4. package/dest/client/p2p_client.js +1 -2
  5. package/dest/config.d.ts +1 -11
  6. package/dest/config.d.ts.map +1 -1
  7. package/dest/config.js +0 -16
  8. package/dest/mem_pools/tx_pool_v2/deleted_pool.d.ts +1 -1
  9. package/dest/mem_pools/tx_pool_v2/deleted_pool.d.ts.map +1 -1
  10. package/dest/mem_pools/tx_pool_v2/deleted_pool.js +1 -0
  11. package/dest/mem_pools/tx_pool_v2/eviction/eviction_manager.d.ts +2 -1
  12. package/dest/mem_pools/tx_pool_v2/eviction/eviction_manager.d.ts.map +1 -1
  13. package/dest/mem_pools/tx_pool_v2/eviction/eviction_manager.js +25 -1
  14. package/dest/mem_pools/tx_pool_v2/eviction/interfaces.d.ts +3 -3
  15. package/dest/mem_pools/tx_pool_v2/eviction/interfaces.d.ts.map +1 -1
  16. package/dest/mem_pools/tx_pool_v2/eviction/low_priority_pre_add_rule.d.ts +2 -1
  17. package/dest/mem_pools/tx_pool_v2/eviction/low_priority_pre_add_rule.d.ts.map +1 -1
  18. package/dest/mem_pools/tx_pool_v2/eviction/low_priority_pre_add_rule.js +10 -1
  19. package/dest/mem_pools/tx_pool_v2/interfaces.d.ts +6 -1
  20. package/dest/mem_pools/tx_pool_v2/interfaces.d.ts.map +1 -1
  21. package/dest/mem_pools/tx_pool_v2/tx_pool_v2_impl.d.ts +1 -1
  22. package/dest/mem_pools/tx_pool_v2/tx_pool_v2_impl.d.ts.map +1 -1
  23. package/dest/mem_pools/tx_pool_v2/tx_pool_v2_impl.js +39 -8
  24. package/dest/msg_validators/attestation_validator/attestation_validator.d.ts +1 -1
  25. package/dest/msg_validators/attestation_validator/attestation_validator.d.ts.map +1 -1
  26. package/dest/msg_validators/attestation_validator/attestation_validator.js +25 -13
  27. package/dest/msg_validators/epoch_cache_lookup.d.ts +15 -12
  28. package/dest/msg_validators/epoch_cache_lookup.d.ts.map +1 -1
  29. package/dest/msg_validators/epoch_cache_lookup.js +27 -18
  30. package/dest/msg_validators/proposal_validator/proposal_validator.d.ts +1 -1
  31. package/dest/msg_validators/proposal_validator/proposal_validator.d.ts.map +1 -1
  32. package/dest/msg_validators/proposal_validator/proposal_validator.js +23 -15
  33. package/dest/msg_validators/tx_validator/data_validator.d.ts +1 -1
  34. package/dest/msg_validators/tx_validator/data_validator.d.ts.map +1 -1
  35. package/dest/msg_validators/tx_validator/data_validator.js +61 -13
  36. package/dest/services/discv5/discV5_service.d.ts +2 -2
  37. package/dest/services/discv5/discV5_service.d.ts.map +1 -1
  38. package/dest/services/discv5/discV5_service.js +2 -3
  39. package/dest/services/dummy_service.d.ts +2 -1
  40. package/dest/services/dummy_service.d.ts.map +1 -1
  41. package/dest/services/dummy_service.js +1 -0
  42. package/dest/services/encoding.d.ts +2 -1
  43. package/dest/services/encoding.d.ts.map +1 -1
  44. package/dest/services/encoding.js +1 -0
  45. package/dest/services/libp2p/libp2p_service.d.ts +6 -2
  46. package/dest/services/libp2p/libp2p_service.d.ts.map +1 -1
  47. package/dest/services/libp2p/libp2p_service.js +38 -9
  48. package/dest/services/peer-manager/peer_manager.d.ts +1 -1
  49. package/dest/services/peer-manager/peer_manager.d.ts.map +1 -1
  50. package/dest/services/peer-manager/peer_manager.js +5 -1
  51. package/dest/services/peer-manager/peer_scoring.d.ts +13 -1
  52. package/dest/services/peer-manager/peer_scoring.d.ts.map +1 -1
  53. package/dest/services/peer-manager/peer_scoring.js +38 -0
  54. package/dest/services/reqresp/protocols/auth.d.ts +2 -1
  55. package/dest/services/reqresp/protocols/auth.d.ts.map +1 -1
  56. package/dest/services/reqresp/protocols/auth.js +1 -0
  57. package/dest/services/reqresp/protocols/block_txs/bitvector.d.ts +2 -1
  58. package/dest/services/reqresp/protocols/block_txs/bitvector.d.ts.map +1 -1
  59. package/dest/services/reqresp/protocols/block_txs/bitvector.js +1 -0
  60. package/dest/services/reqresp/protocols/block_txs/block_txs_handler.d.ts +1 -1
  61. package/dest/services/reqresp/protocols/block_txs/block_txs_handler.d.ts.map +1 -1
  62. package/dest/services/reqresp/protocols/block_txs/block_txs_handler.js +13 -4
  63. package/dest/services/reqresp/protocols/goodbye.d.ts +2 -1
  64. package/dest/services/reqresp/protocols/goodbye.d.ts.map +1 -1
  65. package/dest/services/reqresp/protocols/goodbye.js +1 -0
  66. package/dest/services/reqresp/protocols/ping.d.ts +2 -1
  67. package/dest/services/reqresp/protocols/ping.d.ts.map +1 -1
  68. package/dest/services/reqresp/protocols/ping.js +1 -0
  69. package/dest/services/reqresp/reqresp.d.ts +2 -1
  70. package/dest/services/reqresp/reqresp.d.ts.map +1 -1
  71. package/dest/services/reqresp/reqresp.js +1 -0
  72. package/dest/test-helpers/mock-pubsub.d.ts +2 -1
  73. package/dest/test-helpers/mock-pubsub.d.ts.map +1 -1
  74. package/dest/test-helpers/mock-pubsub.js +1 -0
  75. package/dest/test-helpers/reqresp-nodes.d.ts +1 -1
  76. package/dest/test-helpers/reqresp-nodes.d.ts.map +1 -1
  77. package/dest/test-helpers/reqresp-nodes.js +1 -0
  78. package/dest/test-helpers/testbench-utils.js +18 -6
  79. package/dest/testbench/p2p_client_testbench_worker.d.ts +1 -1
  80. package/dest/testbench/p2p_client_testbench_worker.d.ts.map +1 -1
  81. package/dest/testbench/p2p_client_testbench_worker.js +1 -0
  82. package/dest/testbench/worker_client_manager.d.ts +1 -1
  83. package/dest/testbench/worker_client_manager.d.ts.map +1 -1
  84. package/dest/testbench/worker_client_manager.js +2 -1
  85. package/dest/util.d.ts +1 -1
  86. package/dest/util.d.ts.map +1 -1
  87. package/dest/util.js +1 -0
  88. package/dest/versioning.d.ts +1 -1
  89. package/dest/versioning.d.ts.map +1 -1
  90. package/dest/versioning.js +1 -0
  91. package/package.json +16 -16
  92. package/src/bootstrap/bootstrap.ts +1 -2
  93. package/src/client/p2p_client.ts +1 -1
  94. package/src/config.ts +0 -11
  95. package/src/mem_pools/tx_pool_v2/README.md +2 -0
  96. package/src/mem_pools/tx_pool_v2/deleted_pool.ts +1 -0
  97. package/src/mem_pools/tx_pool_v2/eviction/eviction_manager.ts +28 -1
  98. package/src/mem_pools/tx_pool_v2/eviction/interfaces.ts +2 -2
  99. package/src/mem_pools/tx_pool_v2/eviction/low_priority_pre_add_rule.ts +11 -1
  100. package/src/mem_pools/tx_pool_v2/interfaces.ts +5 -0
  101. package/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts +46 -11
  102. package/src/msg_validators/attestation_validator/README.md +4 -3
  103. package/src/msg_validators/attestation_validator/attestation_validator.ts +23 -11
  104. package/src/msg_validators/epoch_cache_lookup.ts +33 -15
  105. package/src/msg_validators/proposal_validator/README.md +5 -4
  106. package/src/msg_validators/proposal_validator/proposal_validator.ts +23 -14
  107. package/src/msg_validators/tx_validator/data_validator.ts +63 -18
  108. package/src/services/discv5/discV5_service.ts +2 -2
  109. package/src/services/dummy_service.ts +1 -0
  110. package/src/services/encoding.ts +1 -0
  111. package/src/services/libp2p/libp2p_service.ts +37 -8
  112. package/src/services/peer-manager/peer_manager.ts +5 -1
  113. package/src/services/peer-manager/peer_scoring.ts +43 -0
  114. package/src/services/reqresp/protocols/auth.ts +1 -0
  115. package/src/services/reqresp/protocols/block_txs/bitvector.ts +1 -0
  116. package/src/services/reqresp/protocols/block_txs/block_txs_handler.ts +17 -4
  117. package/src/services/reqresp/protocols/goodbye.ts +1 -0
  118. package/src/services/reqresp/protocols/ping.ts +2 -0
  119. package/src/services/reqresp/reqresp.ts +1 -0
  120. package/src/test-helpers/mock-pubsub.ts +1 -0
  121. package/src/test-helpers/reqresp-nodes.ts +1 -0
  122. package/src/test-helpers/testbench-utils.ts +6 -6
  123. package/src/testbench/p2p_client_testbench_worker.ts +1 -0
  124. package/src/testbench/worker_client_manager.ts +2 -1
  125. package/src/util.ts +1 -0
  126. package/src/versioning.ts +1 -0
@@ -131,6 +131,8 @@ export class TxPoolV2Impl {
131
131
  this.#evictionManager = new EvictionManager(this.#createPoolOperations(), log);
132
132
 
133
133
  // Pre-add rules (run during addPendingTxs) - work with TxMetaData
134
+ // Order matters: each rule sees the pool without the txs that earlier rules picked for eviction.
135
+ // Each eviction pick is final: later rules cannot roll back an eviction.
134
136
  this.#evictionManager.registerPreAddRule(new NullifierConflictRule());
135
137
  this.#evictionManager.registerPreAddRule(new FeePayerBalancePreAddRule());
136
138
  this.#evictionManager.registerPreAddRule(
@@ -240,11 +242,17 @@ export class TxPoolV2Impl {
240
242
  const ignored: TxHash[] = [];
241
243
  const rejected: TxHash[] = [];
242
244
  const errors = new Map<string, TxPoolRejectionError>();
245
+ // Per-rejected-tx failure reasons, kept so receiver-local state drift can be told apart from a
246
+ // sender-attributable rejection: a tx that went stale against local state is not the relayer's fault.
247
+ const rejectionReasons = new Map<string, string[]>();
243
248
  const acceptedPending = new Set<string>();
244
249
 
245
250
  // Phase 1: Pre-compute all throwable I/O outside the transaction.
246
251
  // If any pre-computation throws, the entire call fails before mutations happen.
247
- const precomputed = new Map<string, { meta: TxMetaData; minedBlockId: L2BlockId | undefined; isValid: boolean }>();
252
+ const precomputed = new Map<
253
+ string,
254
+ { meta: TxMetaData; minedBlockId: L2BlockId | undefined; isValid: boolean; reason?: string[] }
255
+ >();
248
256
 
249
257
  const validator = await this.#createTxValidator();
250
258
 
@@ -257,11 +265,14 @@ export class TxPoolV2Impl {
257
265
 
258
266
  // Validate non-mined txs (mined and pre-protected txs bypass validation inside the transaction)
259
267
  let isValid = true;
268
+ let reason: string[] | undefined;
260
269
  if (!minedBlockId) {
261
- isValid = await this.#validateMeta(meta, validator);
270
+ const validation = await this.#validateMeta(meta, validator);
271
+ isValid = validation.valid;
272
+ reason = validation.reason;
262
273
  }
263
274
 
264
- precomputed.set(txHashStr, { meta, minedBlockId, isValid });
275
+ precomputed.set(txHashStr, { meta, minedBlockId, isValid, reason });
265
276
  }
266
277
 
267
278
  // Phase 2: Apply mutations inside the transaction using only pre-computed results,
@@ -283,7 +294,7 @@ export class TxPoolV2Impl {
283
294
  continue;
284
295
  }
285
296
 
286
- const { meta, minedBlockId, isValid } = precomputed.get(txHashStr)!;
297
+ const { meta, minedBlockId, isValid, reason } = precomputed.get(txHashStr)!;
287
298
  const preProtectedSlot = this.#indices.getProtectionSlot(txHashStr);
288
299
 
289
300
  if (minedBlockId) {
@@ -297,6 +308,9 @@ export class TxPoolV2Impl {
297
308
  } else if (!isValid) {
298
309
  // Failed pre-computed validation
299
310
  rejected.push(txHash);
311
+ if (reason && reason.length > 0) {
312
+ rejectionReasons.set(txHashStr, reason);
313
+ }
300
314
  } else {
301
315
  // Regular pending tx - run pre-add rules using pre-computed metadata
302
316
  const result = await this.#tryAddRegularPendingTx(
@@ -338,7 +352,13 @@ export class TxPoolV2Impl {
338
352
  this.#instrumentation.recordRejected(rejected.length);
339
353
  }
340
354
 
341
- return { accepted, ignored, rejected, ...(errors.size > 0 ? { errors } : {}) };
355
+ return {
356
+ accepted,
357
+ ignored,
358
+ rejected,
359
+ ...(errors.size > 0 ? { errors } : {}),
360
+ ...(rejectionReasons.size > 0 ? { rejectionReasons } : {}),
361
+ };
342
362
  }
343
363
 
344
364
  /** Adds a validated pending tx, running pre-add rules and evicting conflicts. */
@@ -863,7 +883,18 @@ export class TxPoolV2Impl {
863
883
  if (!buffer) {
864
884
  return undefined;
865
885
  }
866
- return opts.includeProof === false ? Tx.fromBuffer(buffer) : this.#loadTxWithProof(txHashStr, buffer);
886
+ if (opts.includeProof === false) {
887
+ return Tx.fromBuffer(buffer);
888
+ }
889
+ // The body and its separately-stored proof are read in two steps, not atomically: an eviction
890
+ // can delete the proof row after the body read. Serving a proofless tx makes the honest requester
891
+ // penalise us, so drop it. A present-but-empty proof is a valid sentinel; only a missing row is
892
+ // the evicted case.
893
+ const proofBuffer = await this.#proofsDB.getAsync(txHashStr);
894
+ if (!proofBuffer) {
895
+ return undefined;
896
+ }
897
+ return Tx.fromBuffers(buffer, proofBuffer);
867
898
  }
868
899
 
869
900
  async getTxsByHash(txHashes: TxHash[], opts: { includeProof?: boolean } = {}): Promise<(Tx | undefined)[]> {
@@ -1122,15 +1153,19 @@ export class TxPoolV2Impl {
1122
1153
  // ============================================================================
1123
1154
 
1124
1155
  /** Validates transaction metadata, returning true if valid */
1125
- async #validateMeta(meta: TxMetaData, validator?: TxValidator<TxMetaData>, context?: string): Promise<boolean> {
1156
+ async #validateMeta(
1157
+ meta: TxMetaData,
1158
+ validator?: TxValidator<TxMetaData>,
1159
+ context?: string,
1160
+ ): Promise<{ valid: boolean; reason?: string[] }> {
1126
1161
  const txValidator = validator ?? (await this.#createTxValidator());
1127
1162
  const result = await txValidator.validateTx(meta);
1128
1163
  if (result.result !== 'valid') {
1129
1164
  const contextStr = context ? ` ${context}` : '';
1130
1165
  this.#log.info(`Tx ${meta.txHash}${contextStr} failed validation: ${result.reason?.join(', ')}`);
1131
- return false;
1166
+ return { valid: false, reason: result.reason };
1132
1167
  }
1133
- return true;
1168
+ return { valid: true };
1134
1169
  }
1135
1170
 
1136
1171
  /** Validates metadata directly */
@@ -1142,7 +1177,7 @@ export class TxPoolV2Impl {
1142
1177
  const invalid: string[] = [];
1143
1178
  const validator = await this.#createTxValidator();
1144
1179
  for (const meta of metas) {
1145
- if (await this.#validateMeta(meta, validator, context)) {
1180
+ if ((await this.#validateMeta(meta, validator, context)).valid) {
1146
1181
  valid.push(meta);
1147
1182
  } else {
1148
1183
  invalid.push(meta.txHash);
@@ -1291,7 +1326,7 @@ export class TxPoolV2Impl {
1291
1326
  },
1292
1327
  getFeePayerPendingTxs: (feePayer: string) => this.#indices.getFeePayerPendingTxs(feePayer),
1293
1328
  getPendingTxCount: () => this.#indices.getPendingTxCount(),
1294
- getLowestPriorityPendingTx: () => this.#indices.getLowestPriorityPendingTx(),
1329
+ iteratePendingByPriority: (order: 'asc' | 'desc') => this.#indices.iteratePendingByPriority(order),
1295
1330
  };
1296
1331
  }
1297
1332
  }
@@ -10,11 +10,12 @@ This module validates `CheckpointAttestation` gossipsub messages. Attestations a
10
10
  |---|------|-------------|----------|------|
11
11
  | 1 | **Slot timeliness**: `currentSlot` or `nextSlot`. Previous slot within 500ms: IGNORE. Older: REJECT. | REJECT or IGNORE | HighToleranceError | `attestation_validator.ts` |
12
12
  | 2 | **Attester signature**: `getSender()` must recover valid address | REJECT | LowToleranceError | same |
13
- | 3 | **Attester in committee**: recovered address in committee for slot | REJECT | HighToleranceError | same |
14
- | 4 | **Proposer exists**: `getProposerAttesterAddressInSlot` must return defined | REJECT | HighToleranceError | same |
13
+ | 3 | **Attester in committee**: recovered address in the slot's committee (an `open` or `none` committee has no members) | REJECT | HighToleranceError | same |
14
+ | 4 | **Proposer exists**: `lookupProposer` must not be `open` | REJECT | HighToleranceError | same |
15
15
  | 5 | **Proposer signature**: `getProposer()` must recover valid address | REJECT | LowToleranceError | same |
16
16
  | 6 | **Proposer matches expected**: recovered proposer = expected for slot | REJECT | HighToleranceError | same |
17
- | 7 | **NoCommitteeError**: committee unavailable | REJECT | LowToleranceError | same |
17
+ | 7 | **No committee**: proposer lookup is `none` | REJECT | LowToleranceError | same |
18
+ | 8 | **Committee not stable**: either lookup is `unstable` (seed not fixed on L1), or it throws locally | IGNORE | none | same |
18
19
 
19
20
  **Fisherman mode extension** (`FishermanAttestationValidator`): if a checkpoint proposal for the same archive exists in pool, the attestation's `ConsensusPayload` must `.equals()` the stored proposal's payload. On mismatch: REJECT + LowToleranceError.
20
21
 
@@ -1,4 +1,4 @@
1
- import type { EpochCacheInterface } from '@aztec-labs/epoch-cache';
1
+ import type { CommitteeMembershipLookup, EpochCacheInterface, ProposerLookup } from '@aztec-labs/epoch-cache';
2
2
  import { type Logger, createLogger } from '@aztec-labs/foundation/log';
3
3
  import {
4
4
  type CheckpointAttestation,
@@ -10,7 +10,7 @@ import {
10
10
  } from '@aztec-labs/stdlib/p2p';
11
11
  import type { ConsensusTimetable } from '@aztec-labs/stdlib/timetable';
12
12
 
13
- import { mapEpochCacheLookupFailure } from '../epoch_cache_lookup.js';
13
+ import { ignoreUnstableCommittee, mapEpochCacheLookupFailure, rejectNoCommittee } from '../epoch_cache_lookup.js';
14
14
  import { classifyReceiveWindowArrival } from '../receive_window.js';
15
15
 
16
16
  export class CheckpointAttestationValidator implements P2PValidator<CheckpointAttestation> {
@@ -84,13 +84,17 @@ export class CheckpointAttestationValidator implements P2PValidator<CheckpointAt
84
84
  }
85
85
 
86
86
  // Verify the attester is in the committee for this slot
87
- let inCommittee: boolean;
87
+ let membership: CommitteeMembershipLookup<boolean>;
88
88
  try {
89
- inCommittee = await this.epochCache.isInCommittee(slotNumber, attester);
89
+ membership = await this.epochCache.lookupMembership(slotNumber, attester);
90
90
  } catch (e) {
91
91
  return mapEpochCacheLookupFailure(e, this.logger, 'checkpoint attestation', slotNumber);
92
92
  }
93
- if (!inCommittee) {
93
+ if (membership.state === 'unstable') {
94
+ return ignoreUnstableCommittee(membership, this.logger, 'checkpoint attestation', slotNumber);
95
+ }
96
+ // An open committee and a missing one have no members, so the attester cannot be one.
97
+ if (membership.state !== 'members' || !membership.inCommittee) {
94
98
  this.logger.warn(`Attester ${attester.toString()} is not in committee for slot ${slotNumber}`);
95
99
  return { result: 'reject', severity: PeerErrorSeverity.HighToleranceError };
96
100
  }
@@ -99,16 +103,24 @@ export class CheckpointAttestationValidator implements P2PValidator<CheckpointAt
99
103
  // We look up the proposer for the specific slot rather than using currentSlot/nextSlot
100
104
  // since timing differences could cause mismatches
101
105
  const proposer = message.getProposer();
102
- let expectedProposer;
106
+ let proposerLookup: ProposerLookup;
103
107
  try {
104
- expectedProposer = await this.epochCache.getProposerAttesterAddressInSlot(slotNumber);
108
+ proposerLookup = await this.epochCache.lookupProposer(slotNumber);
105
109
  } catch (e) {
106
110
  return mapEpochCacheLookupFailure(e, this.logger, 'checkpoint attestation', slotNumber);
107
111
  }
108
- if (!expectedProposer) {
109
- this.logger.warn(`No proposer defined for slot ${slotNumber}`);
110
- return { result: 'reject', severity: PeerErrorSeverity.HighToleranceError };
112
+ switch (proposerLookup.state) {
113
+ case 'unstable':
114
+ return ignoreUnstableCommittee(proposerLookup, this.logger, 'checkpoint attestation', slotNumber);
115
+ case 'none':
116
+ return rejectNoCommittee();
117
+ case 'open':
118
+ this.logger.warn(`No proposer defined for slot ${slotNumber}`);
119
+ return { result: 'reject', severity: PeerErrorSeverity.HighToleranceError };
120
+ case 'members':
121
+ break;
111
122
  }
123
+ const expectedProposer = proposerLookup.proposer;
112
124
  if (!proposer) {
113
125
  this.logger.warn(`Invalid proposer signature in checkpoint attestation for slot ${slotNumber}`);
114
126
  return { result: 'reject', severity: PeerErrorSeverity.LowToleranceError };
@@ -116,7 +128,7 @@ export class CheckpointAttestationValidator implements P2PValidator<CheckpointAt
116
128
  if (!proposer.equals(expectedProposer)) {
117
129
  this.logger.warn(
118
130
  `Proposer signature mismatch in checkpoint attestation. ` +
119
- `Expected ${expectedProposer?.toString() ?? 'none'} but got ${proposer.toString()} for slot ${slotNumber}`,
131
+ `Expected ${expectedProposer.toString()} but got ${proposer.toString()} for slot ${slotNumber}`,
120
132
  );
121
133
  return { result: 'reject', severity: PeerErrorSeverity.HighToleranceError };
122
134
  }
@@ -1,20 +1,41 @@
1
- import { NoCommitteeError } from '@aztec-labs/ethereum/contracts';
1
+ import type { UnstableCommittee } from '@aztec-labs/epoch-cache';
2
2
  import type { SlotNumber } from '@aztec-labs/foundation/branded-types';
3
3
  import type { Logger } from '@aztec-labs/foundation/log';
4
4
  import { PeerErrorSeverity, type ValidationResult } from '@aztec-labs/stdlib/p2p';
5
5
 
6
+ // Verdicts shared by the proposal and attestation validators, so the "a local epoch-cache failure is never the
7
+ // relaying peer's fault" rule lives in one place.
8
+
9
+ /**
10
+ * A slot with no committee on L1 is the peer's fault — they should not be sending us a proposal or attestation for it
11
+ * — so it rejects. It is a routine peer-fault reject and needs no log line.
12
+ */
13
+ export function rejectNoCommittee(): ValidationResult {
14
+ return { result: 'reject', severity: PeerErrorSeverity.LowToleranceError };
15
+ }
16
+
17
+ /**
18
+ * A committee whose seed is not fixed on L1 yet says nothing about the relaying peer, and is expected around epoch
19
+ * boundaries, so it ignores without a warning.
20
+ */
21
+ export function ignoreUnstableCommittee(
22
+ lookup: UnstableCommittee,
23
+ logger: Logger,
24
+ subject: string,
25
+ slotNumber: SlotNumber,
26
+ ): ValidationResult {
27
+ logger.debug(`Ignoring ${subject} for slot ${slotNumber}: its committee is not stable on L1 yet`, {
28
+ slotNumber,
29
+ epoch: lookup.epoch,
30
+ l1BlockNumber: lookup.l1BlockNumber,
31
+ });
32
+ return { result: 'ignore' };
33
+ }
34
+
6
35
  /**
7
- * Maps a failure of an epoch-cache lookup during p2p validation to a verdict, shared by the proposal
8
- * and attestation validators so the "a local epoch-cache failure is never the relaying peer's fault"
9
- * rule lives in one place.
10
- *
11
- * A missing committee (`NoCommitteeError`) is the peer's fault — they should not be sending us a
12
- * proposal/attestation for a slot with no committee — so it rejects. Any other failure is
13
- * receiver-local (an L1 RPC outage, or this node being behind) and says nothing about the relaying
14
- * peer, so it ignores rather than penalizes. Only the local failure is warned; a missing committee is
15
- * a routine peer-fault reject and needs no log line.
16
- *
17
- * `subject` names the gossip object for the log ("proposal" / "checkpoint attestation").
36
+ * A lookup that throws failed for a receiver-local reason (an L1 RPC outage, or this node being behind), so it ignores
37
+ * rather than penalizes, and warns. `subject` names the gossip object for the log ("proposal" / "checkpoint
38
+ * attestation").
18
39
  */
19
40
  export function mapEpochCacheLookupFailure(
20
41
  e: unknown,
@@ -22,9 +43,6 @@ export function mapEpochCacheLookupFailure(
22
43
  subject: string,
23
44
  slotNumber: SlotNumber,
24
45
  ): ValidationResult {
25
- if (e instanceof NoCommitteeError) {
26
- return { result: 'reject', severity: PeerErrorSeverity.LowToleranceError };
27
- }
28
46
  logger.warn(`Ignoring ${subject} for slot ${slotNumber} after a local epoch-cache lookup failure`, {
29
47
  slotNumber,
30
48
  error: e instanceof Error ? e.message : String(e),
@@ -17,9 +17,10 @@ File: `proposal_validator.ts`
17
17
  | 3 | **Txs permitted**: if `disableTransactions`, must have 0 txHashes and 0 embedded txs | REJECT | MidToleranceError |
18
18
  | 4 | **Max txs**: `txHashes.length <= MAX_TXS_PER_CHECKPOINT` (based on blob capacity), further restricted by `maxTxsPerBlock` when configured. Also applies to checkpoint-embedded blocks. | REJECT | MidToleranceError |
19
19
  | 5 | **Embedded txs in txHashes**: every embedded tx's hash must appear in `txHashes` | REJECT | MidToleranceError |
20
- | 6 | **Proposer check**: signer must match expected proposer for slot (skipped if committee size = 0) | REJECT | MidToleranceError |
20
+ | 6 | **Proposer check**: signer must match expected proposer for slot (skipped when the committee is `open`, i.e. size 0) | REJECT | MidToleranceError |
21
21
  | 7 | **Tx hash integrity**: each embedded tx's recomputed hash must match declared hash | REJECT | LowToleranceError |
22
- | 8 | **NoCommitteeError**: epoch cache cannot determine committee | REJECT | LowToleranceError |
22
+ | 8 | **No committee**: proposer lookup is `none` | REJECT | LowToleranceError |
23
+ | 9 | **Committee not stable**: proposer lookup is `unstable` (seed not fixed on L1), or it throws locally | IGNORE | none |
23
24
 
24
25
  Deserialization guards: `BlockProposal.fromBuffer` and `SignedTxs.fromBuffer` both enforce `txCount <= MAX_TXS_PER_BLOCK` (65536). Violation -> REJECT + LowToleranceError.
25
26
 
@@ -29,7 +30,7 @@ Deserialization guards: `BlockProposal.fromBuffer` and `SignedTxs.fromBuffer` bo
29
30
  |---|------|-------------|
30
31
  | 9 | **Duplicate**: same archive root already stored | IGNORE (no penalty) |
31
32
  | 10 | **Per-position cap**: max 2 proposals per (slot, indexWithinCheckpoint) | IGNORE (receiver-local drop: no penalty, no re-broadcast) |
32
- | 11 | **Equivocation**: >1 distinct proposal for same (slot, index) | ACCEPT (rebroadcast for detection). At count=2: `duplicateProposalCallback` fires -> slash event (`OffenseType.DUPLICATE_PROPOSAL`, configured via `slashDuplicateProposalPenalty`) |
33
+ | 11 | **Equivocation**: >1 distinct proposal for same (slot, index) | ACCEPT (rebroadcast for detection), not processed. At count=2: `duplicateProposalCallback` fires and the slot is marked as equivocated on the spot. No slash here: the count is per position, not per signer. The slasher's `BroadcastedProposalWatcher` later attributes `OffenseType.DUPLICATE_PROPOSAL` (configured via `slashDuplicateProposalPenalty`) to a signer only if that signer signed two different payloads at one position |
33
34
 
34
35
  ### Stage 3: Validator-Client Processing (BlockProposalHandler)
35
36
 
@@ -51,7 +52,7 @@ Only runs on validator nodes. Non-validator nodes use a default handler that tri
51
52
  | 23 | **Re-execution**: no failed txs | `failed_txs` (ReExFailedTxsError) -- **SLASHABLE** |
52
53
  | 24 | **Re-execution**: archive root and header match proposal | `state_mismatch` (ReExStateMismatchError) -- **SLASHABLE** |
53
54
 
54
- **Escape hatch**: during escape hatch periods (`isEscapeHatchOpenAtSlot`), re-execution and slashing are both disabled, and the proposal is rejected locally.
55
+ **Escape hatch**: during escape hatch periods (`lookupEscapeHatchAtSlot`), re-execution and slashing are both disabled, and the proposal is rejected locally.
55
56
 
56
57
  **Conditional re-execution**: rules 22-24 only run when at least one condition is true: `fishermanMode` enabled, `slashBroadcastedInvalidBlockPenalty > 0`, committee membership, `alwaysReexecuteBlockProposals`, or `blobClient.canUpload()`.
57
58
 
@@ -1,4 +1,4 @@
1
- import type { EpochCacheInterface } from '@aztec-labs/epoch-cache';
1
+ import type { EpochCacheInterface, ProposerLookup } from '@aztec-labs/epoch-cache';
2
2
  import { type Logger, createLogger } from '@aztec-labs/foundation/log';
3
3
  import { MAX_ATTESTABLE_BLOCKS_PER_CHECKPOINT, MAX_TXS_PER_CHECKPOINT } from '@aztec-labs/stdlib/deserialization';
4
4
  import {
@@ -11,7 +11,7 @@ import {
11
11
  } from '@aztec-labs/stdlib/p2p';
12
12
  import type { ConsensusTimetable } from '@aztec-labs/stdlib/timetable';
13
13
 
14
- import { mapEpochCacheLookupFailure } from '../epoch_cache_lookup.js';
14
+ import { ignoreUnstableCommittee, mapEpochCacheLookupFailure, rejectNoCommittee } from '../epoch_cache_lookup.js';
15
15
  import { classifyReceiveWindowArrival } from '../receive_window.js';
16
16
 
17
17
  /** Validates header-level and tx-level fields of block and checkpoint proposals. */
@@ -117,22 +117,31 @@ export class ProposalValidator {
117
117
  }
118
118
  }
119
119
 
120
- // An undefined proposer means an empty committee (anyone may propose), so skip the
121
- // proposer-equality check and keep validating. Only this lookup can fail on a receiver-local
122
- // cause (L1 RPC down, or this node behind); mapEpochCacheLookupFailure ignores that (it is not the
123
- // relaying peer's fault) and rejects a missing committee (which is).
124
- let expectedProposer;
120
+ // Only this lookup can fail on a receiver-local cause (L1 RPC down, or this node behind), which
121
+ // mapEpochCacheLookupFailure ignores as it is not the relaying peer's fault.
122
+ let expectedProposer: ProposerLookup;
125
123
  try {
126
- expectedProposer = await this.epochCache.getProposerAttesterAddressInSlot(slotNumber);
124
+ expectedProposer = await this.epochCache.lookupProposer(slotNumber);
127
125
  } catch (e) {
128
126
  return mapEpochCacheLookupFailure(e, this.logger, 'proposal', slotNumber);
129
127
  }
130
- if (expectedProposer !== undefined && !proposer.equals(expectedProposer)) {
131
- this.logger.warn(`Penalizing peer for invalid proposer for current slot ${slotNumber}`, {
132
- expectedProposer,
133
- proposer: proposer.toString(),
134
- });
135
- return { result: 'reject', severity: PeerErrorSeverity.MidToleranceError };
128
+ switch (expectedProposer.state) {
129
+ case 'unstable':
130
+ return ignoreUnstableCommittee(expectedProposer, this.logger, 'proposal', slotNumber);
131
+ case 'none':
132
+ return rejectNoCommittee();
133
+ case 'open':
134
+ // Anyone may propose, so there is no proposer to check the signer against.
135
+ break;
136
+ case 'members':
137
+ if (!proposer.equals(expectedProposer.proposer)) {
138
+ this.logger.warn(`Penalizing peer for invalid proposer for current slot ${slotNumber}`, {
139
+ expectedProposer: expectedProposer.proposer,
140
+ proposer: proposer.toString(),
141
+ });
142
+ return { result: 'reject', severity: PeerErrorSeverity.MidToleranceError };
143
+ }
144
+ break;
136
145
  }
137
146
 
138
147
  return { result: 'accept' };
@@ -53,34 +53,68 @@ export class DataTxValidator implements TxValidator<Tx> {
53
53
  return undefined;
54
54
  }
55
55
 
56
+ /**
57
+ * Checks the supplied public calldata entries against the calldata hashes of the public call requests. Every request
58
+ * must have an entry, and every entry must hash to its own hash and be used by some request. Several requests may
59
+ * share one entry, so a hash may appear at most as many times as requests use it. Both the calldata the calls receive
60
+ * and the supplied entries are capped. All of this is checked before hashing any entry.
61
+ */
56
62
  async #hasCorrectCalldata(tx: Tx): Promise<string | undefined> {
57
- if (tx.publicFunctionCalldata.length !== tx.numberOfPublicCalls()) {
58
- const reason = TX_ERROR_CALLDATA_COUNT_MISMATCH;
59
- this.#log.verbose(
60
- `Rejecting tx ${tx.getTxHash().toString()}. Reason: ${reason}. Expected ${tx.numberOfPublicCalls()}. Got ${
61
- tx.publicFunctionCalldata.length
62
- }.`,
63
- );
64
- return reason;
63
+ const txHash = tx.getTxHash().toString();
64
+ const calldataMap = tx.getCalldataMap();
65
+
66
+ const requestCountByHash = new Map<string, number>();
67
+ for (const [index, { request }] of tx.getPublicCallRequestsWithCalldata().entries()) {
68
+ const hash = request.calldataHash.toString();
69
+ if (!calldataMap.has(hash)) {
70
+ const reason = TX_ERROR_INCORRECT_CALLDATA;
71
+ this.#log.verbose(`Rejecting tx ${txHash}. Reason: ${reason}. Call request has no calldata entry.`, {
72
+ txHash,
73
+ callRequestIndex: index,
74
+ });
75
+ return reason;
76
+ }
77
+ requestCountByHash.set(hash, (requestCountByHash.get(hash) ?? 0) + 1);
65
78
  }
66
79
 
67
- if (tx.getTotalPublicCalldataCount() > MAX_FR_CALLDATA_TO_ALL_ENQUEUED_CALLS) {
80
+ // A wrongly labeled entry can make the calls receive less calldata than is supplied, so both totals are capped.
81
+ const deliveredCount = tx.getDeliveredPublicCalldataCount();
82
+ const suppliedCount = tx.getTotalPublicCalldataCount();
83
+ if (
84
+ deliveredCount > MAX_FR_CALLDATA_TO_ALL_ENQUEUED_CALLS ||
85
+ suppliedCount > MAX_FR_CALLDATA_TO_ALL_ENQUEUED_CALLS
86
+ ) {
68
87
  const reason = TX_ERROR_CALLDATA_COUNT_TOO_LARGE;
69
88
  this.#log.verbose(
70
- `Rejecting tx ${tx
71
- .getTxHash()
72
- .toString()}. Reason: ${reason}. Expected no greater than ${MAX_FR_CALLDATA_TO_ALL_ENQUEUED_CALLS} fields. Got ${tx.getTotalPublicCalldataCount()}.`,
89
+ `Rejecting tx ${txHash}. Reason: ${reason}. Expected no greater than ${MAX_FR_CALLDATA_TO_ALL_ENQUEUED_CALLS} fields.`,
90
+ { txHash, deliveredCount, suppliedCount },
73
91
  );
74
92
  return reason;
75
93
  }
76
94
 
77
- const callRequests = tx.getPublicCallRequestsWithCalldata();
78
- for (let i = 0; i < callRequests.length; i++) {
79
- const { request, calldata } = callRequests[i];
80
- const hash = await computeCalldataHash(calldata);
81
- if (!hash.equals(request.calldataHash)) {
95
+ const entryCountByHash = new Map<string, number>();
96
+ for (const [index, cd] of tx.publicFunctionCalldata.entries()) {
97
+ const hash = cd.hash.toString();
98
+ const entryCount = (entryCountByHash.get(hash) ?? 0) + 1;
99
+ const requestCount = requestCountByHash.get(hash) ?? 0;
100
+ if (entryCount > requestCount) {
101
+ const reason = TX_ERROR_CALLDATA_COUNT_MISMATCH;
102
+ this.#log.verbose(
103
+ `Rejecting tx ${txHash}. Reason: ${reason}. Calldata entry is listed more times than call requests use it.`,
104
+ { txHash, calldataIndex: index, requestCount },
105
+ );
106
+ return reason;
107
+ }
108
+ entryCountByHash.set(hash, entryCount);
109
+ }
110
+
111
+ for (const [index, cd] of tx.publicFunctionCalldata.entries()) {
112
+ if (!(await computeCalldataHash(cd.values)).equals(cd.hash)) {
82
113
  const reason = TX_ERROR_INCORRECT_CALLDATA;
83
- this.#log.verbose(`Rejecting tx ${tx.getTxHash().toString()}. Reason: ${reason}. Call request index: ${i}.`);
114
+ this.#log.verbose(`Rejecting tx ${txHash}. Reason: ${reason}. Calldata entry does not match its hash.`, {
115
+ txHash,
116
+ calldataIndex: index,
117
+ });
84
118
  return reason;
85
119
  }
86
120
  }
@@ -121,6 +155,17 @@ export class DataTxValidator implements TxValidator<Tx> {
121
155
  }
122
156
  }
123
157
 
158
+ // The private kernel tail requires every contract class log within the claimed count to have a non-zero length,
159
+ // and the tx base treats a zero-length log as absent, so a tx carrying one can never be proven.
160
+ if (logHash.logHash.length === 0) {
161
+ const txHash = tx.getTxHash().toString();
162
+ this.#log.verbose(`Rejecting tx ${txHash} because of a zero-length contract class log at index ${i}.`, {
163
+ txHash,
164
+ logIndex: i,
165
+ });
166
+ return TX_ERROR_CONTRACT_CLASS_LOG_LENGTH;
167
+ }
168
+
124
169
  // The tx base requires exactly the trimmed length; any other length makes the tx unprovable.
125
170
  const expectedLength = 1 + tx.contractClassLogFields[i].fields.findLastIndex(f => !f.isZero());
126
171
  if (logHash.logHash.length !== expectedLength) {
@@ -8,9 +8,10 @@ import type { PeerId } from '@libp2p/interface';
8
8
  import { type Multiaddr, multiaddr } from '@multiformats/multiaddr';
9
9
  import { Discv5, type Discv5EventEmitter, type IDiscv5CreateOptions } from '@nethermindeth/discv5';
10
10
  import { ENR, SignableENR } from '@nethermindeth/enr';
11
+ import { Buffer } from 'buffer';
11
12
  import EventEmitter from 'events';
12
13
 
13
- import { DISCV5_RATE_LIMITER_OPTS, type P2PConfig } from '../../config.js';
14
+ import type { P2PConfig } from '../../config.js';
14
15
  import { createNodeENR } from '../../enr/generate-enr.js';
15
16
  import { AZTEC_ENR_KEY, Discv5Event, PeerEvent } from '../../types/index.js';
16
17
  import { convertToMultiaddr } from '../../util.js';
@@ -126,7 +127,6 @@ export class DiscV5Service extends EventEmitter implements PeerDiscoveryService
126
127
  ...configOverrides.config,
127
128
  },
128
129
  metricsRegistry,
129
- rateLimiterOpts: DISCV5_RATE_LIMITER_OPTS,
130
130
  });
131
131
 
132
132
  // Hook onto the onEstablished method to check the peer's version from the ENR,
@@ -4,6 +4,7 @@ import type { Gossipable, PeerErrorSeverity, TopicType } from '@aztec-labs/stdli
4
4
  import { Tx, TxHash } from '@aztec-labs/stdlib/tx';
5
5
  import type { PeerId } from '@libp2p/interface';
6
6
  import type { ENR } from '@nethermindeth/enr';
7
+ import { Buffer } from 'buffer';
7
8
  import EventEmitter from 'events';
8
9
 
9
10
  import type { PeerManagerInterface } from './peer-manager/interface.js';
@@ -3,6 +3,7 @@ import { createLogger } from '@aztec-labs/foundation/log';
3
3
  import { MAX_TX_SIZE_KB, TopicType, getTopicFromString } from '@aztec-labs/stdlib/p2p';
4
4
  import type { DataTransform } from '@chainsafe/libp2p-gossipsub/types';
5
5
  import type { Message } from '@libp2p/interface';
6
+ import { Buffer } from 'buffer';
6
7
  import { webcrypto } from 'node:crypto';
7
8
  import { compressSync, uncompressSync } from 'snappy';
8
9
 
@@ -33,7 +33,7 @@ import {
33
33
  } from '@aztec-labs/stdlib/p2p';
34
34
  import { ConsensusTimetable, getDefaultCheckpointProposalSyncGrace } from '@aztec-labs/stdlib/timetable';
35
35
  import { MerkleTreeId } from '@aztec-labs/stdlib/trees';
36
- import { Tx, type TxValidationResult } from '@aztec-labs/stdlib/tx';
36
+ import { Tx, type TxValidationResult, isReceiverLocalStateDrift } from '@aztec-labs/stdlib/tx';
37
37
  import type { UInt64 } from '@aztec-labs/stdlib/types';
38
38
  import { InvalidBlockProposalTxsError } from '@aztec-labs/stdlib/validators';
39
39
  import { compressComponentVersions } from '@aztec-labs/stdlib/versioning';
@@ -62,6 +62,7 @@ import type { AddressManager, ConnectionManager } from '@libp2p/interface-intern
62
62
  import { tcp } from '@libp2p/tcp';
63
63
  import { multiaddr } from '@multiformats/multiaddr';
64
64
  import { ENR } from '@nethermindeth/enr';
65
+ import { Buffer } from 'buffer';
65
66
  import { createLibp2p } from 'libp2p';
66
67
 
67
68
  import type { P2PConfig } from '../../config.js';
@@ -183,7 +184,10 @@ export class LibP2PService extends WithTracer implements P2PService {
183
184
  private protocolVersion = '';
184
185
  private topicStrings: Record<TopicType, string> = {} as Record<TopicType, string>;
185
186
 
186
- /** Callback invoked when a duplicate proposal is detected (triggers slashing). */
187
+ /**
188
+ * Callback invoked when a second proposal is seen at one position, so the slot is marked as equivocated. It does not
189
+ * slash: the slasher attributes DUPLICATE_PROPOSAL per signer from the retained proposals.
190
+ */
187
191
  private duplicateProposalCallback?: (info: {
188
192
  slot: SlotNumber;
189
193
  proposer: EthAddress;
@@ -818,15 +822,25 @@ export class LibP2PService extends WithTracer implements P2PService {
818
822
  * empty committee every signer is accepted, so two distinct honest proposers for the same slot look
819
823
  * like equivocation; without an expected proposer there is no one to attribute the offense to.
820
824
  * Mirrors the guard in checkpoint_equivocation_watcher, which refuses to slash when the slot has no
821
- * proposer.
825
+ * proposer. While the slot's committee is not stable on L1 there is no expected proposer yet either.
822
826
  */
823
827
  private async canAttributeDuplicateProposal(slot: SlotNumber): Promise<boolean> {
824
- const expectedProposer = await this.epochCache.getProposerAttesterAddressInSlot(slot);
825
- if (expectedProposer === undefined) {
826
- this.logger.warn(`Not attributing duplicate proposal at slot ${slot}: no expected proposer (empty committee)`);
827
- return false;
828
+ const expectedProposer = await this.epochCache.lookupProposer(slot);
829
+ switch (expectedProposer.state) {
830
+ case 'members':
831
+ return true;
832
+ case 'open':
833
+ case 'unstable':
834
+ this.logger.warn(
835
+ `Not attributing duplicate proposal at slot ${slot}: no expected proposer (empty or not yet stable committee)`,
836
+ { slot, committeeState: expectedProposer.state },
837
+ );
838
+ return false;
839
+ case 'none':
840
+ // The proposal validator rejects a slot without a committee before duplicates are counted, so this needs the
841
+ // committee to vanish in between. Throwing makes the gossip validator reject the message, as it does for that.
842
+ throw new Error(`No committee on L1 for slot ${slot}`);
828
843
  }
829
- return true;
830
844
  }
831
845
 
832
846
  /**
@@ -1222,9 +1236,24 @@ export class LibP2PService extends WithTracer implements P2PService {
1222
1236
  } else if (wasIgnored) {
1223
1237
  return { result: TopicValidatorResult.Ignore, obj: tx };
1224
1238
  } else {
1239
+ // The final pool insert rebuilt a fresh validator and rejected a tx that passed the earlier
1240
+ // checks and proof. If the rejection is receiver-local state drift, the relayer forwarded a tx
1241
+ // valid from its own view - ignore without penalizing it; only a sender-attributable reject penalizes.
1242
+ const rejectionReasons = addResult.rejectionReasons?.get(txHash.toString()) ?? [];
1243
+ if (isReceiverLocalStateDrift(rejectionReasons)) {
1244
+ this.logger.verbose(
1245
+ `Ignoring gossiped tx ${txHash.toString()}: pool rejected on receiver-local state drift`,
1246
+ {
1247
+ source: source.toString(),
1248
+ reasons: rejectionReasons,
1249
+ },
1250
+ );
1251
+ return { result: TopicValidatorResult.Ignore, obj: tx };
1252
+ }
1225
1253
  this.logger.warn(`Gossiped tx ${txHash.toString()} unexpectedly rejected by pool`, {
1226
1254
  source: source.toString(),
1227
1255
  txHash: txHash.toString(),
1256
+ reasons: rejectionReasons,
1228
1257
  });
1229
1258
  return { result: TopicValidatorResult.Reject, severity: PeerErrorSeverity.HighToleranceError };
1230
1259
  }
@@ -3,6 +3,7 @@ import { compactArray } from '@aztec-labs/foundation/collection';
3
3
  import { makeEthSignDigest, tryRecoverAddress } from '@aztec-labs/foundation/crypto/secp256k1-signer';
4
4
  import { Fr } from '@aztec-labs/foundation/curves/bn254';
5
5
  import type { EthAddress } from '@aztec-labs/foundation/eth-address';
6
+ import { inspect } from '@aztec-labs/foundation/inspect';
6
7
  import { createLogger } from '@aztec-labs/foundation/log';
7
8
  import { bufferToHex } from '@aztec-labs/foundation/string';
8
9
  import { DateProvider } from '@aztec-labs/foundation/timer';
@@ -13,7 +14,7 @@ import type { Connection, PeerId } from '@libp2p/interface';
13
14
  import { peerIdFromString } from '@libp2p/peer-id';
14
15
  import type { Multiaddr } from '@multiformats/multiaddr';
15
16
  import { ENR } from '@nethermindeth/enr';
16
- import { inspect } from 'util';
17
+ import { Buffer } from 'buffer';
17
18
 
18
19
  import type { P2PConfig } from '../../config.js';
19
20
  import { PeerEvent } from '../../types/index.js';
@@ -424,6 +425,9 @@ export class PeerManager implements PeerManagerInterface {
424
425
  this.authenticatedPeerIdToValidatorAddress.delete(peerIdStr);
425
426
  }
426
427
 
428
+ // Keep a disconnected non-healthy peer's score so a reconnect resumes its penalty rather than starting
429
+ // clean. The score and ban maps are bounded (PeerScoring retention caps), so retaining them cannot grow
430
+ // the heartbeat-scanned state without limit under identity churn. A healthy peer carries no penalty to keep.
427
431
  if (this.peerScoring.getScoreState(peerIdStr) === PeerScoreState.Healthy) {
428
432
  this.peerScoring.removePeer(peerIdStr);
429
433
  }