@aztec/p2p 5.0.0-rc.1 → 5.0.0-rc.2

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 (51) hide show
  1. package/dest/bootstrap/bootstrap.d.ts +1 -1
  2. package/dest/bootstrap/bootstrap.d.ts.map +1 -1
  3. package/dest/bootstrap/bootstrap.js +20 -12
  4. package/dest/client/interface.d.ts +9 -2
  5. package/dest/client/interface.d.ts.map +1 -1
  6. package/dest/client/p2p_client.d.ts +3 -2
  7. package/dest/client/p2p_client.d.ts.map +1 -1
  8. package/dest/client/p2p_client.js +5 -0
  9. package/dest/config.js +1 -1
  10. package/dest/mem_pools/tx_pool_v2/eviction/fee_payer_balance_eviction_rule.js +1 -1
  11. package/dest/mem_pools/tx_pool_v2/tx_pool_v2_impl.js +1 -1
  12. package/dest/msg_validators/proposal_validator/proposal_validator.d.ts +10 -6
  13. package/dest/msg_validators/proposal_validator/proposal_validator.d.ts.map +1 -1
  14. package/dest/msg_validators/proposal_validator/proposal_validator.js +17 -11
  15. package/dest/msg_validators/tx_validator/phases_validator.js +2 -2
  16. package/dest/services/discv5/discV5_service.d.ts +12 -2
  17. package/dest/services/discv5/discV5_service.d.ts.map +1 -1
  18. package/dest/services/discv5/discV5_service.js +84 -15
  19. package/dest/services/discv5/persisted_enr_store.d.ts +38 -0
  20. package/dest/services/discv5/persisted_enr_store.d.ts.map +1 -0
  21. package/dest/services/discv5/persisted_enr_store.js +145 -0
  22. package/dest/services/dummy_service.d.ts +6 -2
  23. package/dest/services/dummy_service.d.ts.map +1 -1
  24. package/dest/services/dummy_service.js +3 -0
  25. package/dest/services/encoding.d.ts +8 -8
  26. package/dest/services/encoding.d.ts.map +1 -1
  27. package/dest/services/encoding.js +11 -15
  28. package/dest/services/libp2p/libp2p_service.d.ts +10 -2
  29. package/dest/services/libp2p/libp2p_service.d.ts.map +1 -1
  30. package/dest/services/libp2p/libp2p_service.js +93 -33
  31. package/dest/services/peer-manager/peer_manager.d.ts +1 -1
  32. package/dest/services/peer-manager/peer_manager.d.ts.map +1 -1
  33. package/dest/services/peer-manager/peer_manager.js +21 -12
  34. package/dest/services/service.d.ts +17 -1
  35. package/dest/services/service.d.ts.map +1 -1
  36. package/package.json +16 -17
  37. package/src/bootstrap/bootstrap.ts +15 -9
  38. package/src/client/interface.ts +9 -0
  39. package/src/client/p2p_client.ts +7 -0
  40. package/src/config.ts +1 -1
  41. package/src/mem_pools/tx_pool_v2/eviction/fee_payer_balance_eviction_rule.ts +1 -1
  42. package/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts +1 -1
  43. package/src/msg_validators/proposal_validator/proposal_validator.ts +17 -14
  44. package/src/msg_validators/tx_validator/phases_validator.ts +2 -2
  45. package/src/services/discv5/discV5_service.ts +88 -9
  46. package/src/services/discv5/persisted_enr_store.ts +158 -0
  47. package/src/services/dummy_service.ts +6 -0
  48. package/src/services/encoding.ts +11 -21
  49. package/src/services/libp2p/libp2p_service.ts +104 -35
  50. package/src/services/peer-manager/peer_manager.ts +18 -11
  51. package/src/services/service.ts +19 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aztec/p2p",
3
- "version": "5.0.0-rc.1",
3
+ "version": "5.0.0-rc.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./dest/index.js",
@@ -67,18 +67,18 @@
67
67
  ]
68
68
  },
69
69
  "dependencies": {
70
- "@aztec/constants": "5.0.0-rc.1",
71
- "@aztec/epoch-cache": "5.0.0-rc.1",
72
- "@aztec/ethereum": "5.0.0-rc.1",
73
- "@aztec/foundation": "5.0.0-rc.1",
74
- "@aztec/kv-store": "5.0.0-rc.1",
75
- "@aztec/noir-contracts.js": "5.0.0-rc.1",
76
- "@aztec/noir-protocol-circuits-types": "5.0.0-rc.1",
77
- "@aztec/protocol-contracts": "5.0.0-rc.1",
78
- "@aztec/simulator": "5.0.0-rc.1",
79
- "@aztec/standard-contracts": "5.0.0-rc.1",
80
- "@aztec/stdlib": "5.0.0-rc.1",
81
- "@aztec/telemetry-client": "5.0.0-rc.1",
70
+ "@aztec/constants": "5.0.0-rc.2",
71
+ "@aztec/epoch-cache": "5.0.0-rc.2",
72
+ "@aztec/ethereum": "5.0.0-rc.2",
73
+ "@aztec/foundation": "5.0.0-rc.2",
74
+ "@aztec/kv-store": "5.0.0-rc.2",
75
+ "@aztec/noir-contracts.js": "5.0.0-rc.2",
76
+ "@aztec/noir-protocol-circuits-types": "5.0.0-rc.2",
77
+ "@aztec/protocol-contracts": "5.0.0-rc.2",
78
+ "@aztec/simulator": "5.0.0-rc.2",
79
+ "@aztec/standard-contracts": "5.0.0-rc.2",
80
+ "@aztec/stdlib": "5.0.0-rc.2",
81
+ "@aztec/telemetry-client": "5.0.0-rc.2",
82
82
  "@chainsafe/libp2p-gossipsub": "13.0.0",
83
83
  "@chainsafe/libp2p-noise": "^15.0.0",
84
84
  "@chainsafe/libp2p-yamux": "^6.0.2",
@@ -101,12 +101,11 @@
101
101
  "semver": "^7.6.0",
102
102
  "sha3": "^2.1.4",
103
103
  "snappy": "^7.2.2",
104
- "tslib": "^2.4.0",
105
- "xxhash-wasm": "^1.1.0"
104
+ "tslib": "^2.4.0"
106
105
  },
107
106
  "devDependencies": {
108
- "@aztec/archiver": "5.0.0-rc.1",
109
- "@aztec/world-state": "5.0.0-rc.1",
107
+ "@aztec/archiver": "5.0.0-rc.2",
108
+ "@aztec/world-state": "5.0.0-rc.2",
110
109
  "@jest/globals": "^30.0.0",
111
110
  "@types/jest": "^30.0.0",
112
111
  "@types/node": "^22.15.17",
@@ -84,15 +84,21 @@ export class BootstrapNode implements P2PBootstrapApi {
84
84
  this.logger.info('Advertised socket address updated', { addr: addr.toString() });
85
85
  });
86
86
  this.node.on('discovered', async (enr: SignableENR) => {
87
- const addr = await enr.getFullMultiaddr('udp');
88
- this.logger.verbose(`Discovered new peer`, { enr: enr.encodeTxt(), addr: addr?.toString() });
89
- // discv5's discovered() only updates routing table entries that already exist. Nodes that
90
- // established a session with an empty-IP ENR are never inserted, so even after their ENR
91
- // gains a valid socket address the routing table stays empty and FINDNODE always returns 0
92
- // peers. Calling addEnr() here does an insertOrUpdate regardless of prior state, fixing
93
- // the routing table so these nodes become discoverable to other peers.
94
- if (addr) {
95
- this.node.addEnr(enr);
87
+ try {
88
+ const addr = await enr.getFullMultiaddr('udp');
89
+ this.logger.verbose(`Discovered new peer`, { enr: enr.encodeTxt(), addr: addr?.toString() });
90
+ // discv5's discovered() only updates routing table entries that already exist. Nodes that
91
+ // established a session with an empty-IP ENR are never inserted, so even after their ENR
92
+ // gains a valid socket address the routing table stays empty and FINDNODE always returns 0
93
+ // peers. Calling addEnr() here does an insertOrUpdate regardless of prior state, fixing
94
+ // the routing table so these nodes become discoverable to other peers.
95
+ if (addr) {
96
+ this.node.addEnr(enr);
97
+ }
98
+ } catch (err) {
99
+ // A malformed ENR address field makes the parser throw. As an async listener this would crash
100
+ // the bootnode on an unhandled rejection — catch, log, and drop the ENR instead.
101
+ this.logger.warn(`Dropping discovered ENR with unparseable address`, { err });
96
102
  }
97
103
  });
98
104
 
@@ -13,6 +13,7 @@ import type { ReqRespSubProtocol, ReqRespSubProtocolHandler } from '../services/
13
13
  import type {
14
14
  DuplicateAttestationInfo,
15
15
  DuplicateProposalInfo,
16
+ OversizedProposalInfo,
16
17
  P2PBlockReceivedCallback,
17
18
  P2PCheckpointAttestationCallback,
18
19
  P2PCheckpointReceivedCallback,
@@ -97,6 +98,14 @@ export type P2P = P2PClient & {
97
98
  */
98
99
  registerDuplicateProposalCallback(callback: (info: DuplicateProposalInfo) => void): void;
99
100
 
101
+ /**
102
+ * Registers a callback invoked when an oversized block proposal (index at or beyond the consensus
103
+ * per-checkpoint block limit) is stored and re-broadcast as slashing evidence.
104
+ *
105
+ * @param callback - Function called with info about the oversized proposal
106
+ */
107
+ registerOversizedProposalCallback(callback: (info: OversizedProposalInfo) => void): void;
108
+
100
109
  /**
101
110
  * Registers a callback invoked when a duplicate attestation is detected (equivocation).
102
111
  * A validator signing attestations for different proposals at the same slot.
@@ -42,6 +42,7 @@ import { ReqRespSubProtocol, type ReqRespSubProtocolHandler } from '../services/
42
42
  import type {
43
43
  DuplicateAttestationInfo,
44
44
  DuplicateProposalInfo,
45
+ OversizedProposalInfo,
45
46
  P2PBlockReceivedCallback,
46
47
  P2PCheckpointReceivedCallback,
47
48
  P2PService,
@@ -178,6 +179,8 @@ export class P2PClient extends WithTracer implements P2P {
178
179
  break;
179
180
  case 'chain-checkpointed':
180
181
  break;
182
+ case 'chain-proposed':
183
+ break;
181
184
  default: {
182
185
  const _: never = event;
183
186
  break;
@@ -414,6 +417,10 @@ export class P2PClient extends WithTracer implements P2P {
414
417
  this.p2pService.registerDuplicateProposalCallback(callback);
415
418
  }
416
419
 
420
+ public registerOversizedProposalCallback(callback: (info: OversizedProposalInfo) => void): void {
421
+ this.p2pService.registerOversizedProposalCallback(callback);
422
+ }
423
+
417
424
  public registerDuplicateAttestationCallback(callback: (info: DuplicateAttestationInfo) => void): void {
418
425
  this.p2pService.registerDuplicateAttestationCallback(callback);
419
426
  }
package/src/config.ts CHANGED
@@ -740,7 +740,7 @@ export function parseAllowList(value: string): AllowedElement[] {
740
740
 
741
741
  if (typeString === 'I') {
742
742
  entries.push({
743
- address: AztecAddress.fromString(identifierString),
743
+ address: AztecAddress.fromStringUnsafe(identifierString),
744
744
  selector,
745
745
  ...flags,
746
746
  });
@@ -86,7 +86,7 @@ export class FeePayerBalanceEvictionRule implements EvictionRule {
86
86
  publicStateSource: DatabasePublicStateSource,
87
87
  pool: PoolOperations,
88
88
  ): Promise<string[]> {
89
- const feePayer = AztecAddress.fromString(feePayerStr);
89
+ const feePayer = AztecAddress.fromStringUnsafe(feePayerStr);
90
90
  const initialBalance = (
91
91
  await publicStateSource.storageRead(
92
92
  ProtocolContractAddress.FeeJuice,
@@ -1139,7 +1139,7 @@ export class TxPoolV2Impl {
1139
1139
  const publicStateSource = new DatabasePublicStateSource(db);
1140
1140
  const balance = await publicStateSource.storageRead(
1141
1141
  ProtocolContractAddress.FeeJuice,
1142
- await computeFeePayerBalanceStorageSlot(AztecAddress.fromString(feePayer)),
1142
+ await computeFeePayerBalanceStorageSlot(AztecAddress.fromStringUnsafe(feePayer)),
1143
1143
  );
1144
1144
  return balance.toBigInt();
1145
1145
  },
@@ -19,7 +19,6 @@ export class ProposalValidator {
19
19
  private logger: Logger;
20
20
  private txsPermitted: boolean;
21
21
  private maxTxsPerBlock?: number;
22
- private maxBlocksPerCheckpoint?: number;
23
22
  private skipSlotValidation: boolean;
24
23
  private signatureContext: CoordinationSignatureContext;
25
24
  private clockDisparityMs: number;
@@ -41,7 +40,6 @@ export class ProposalValidator {
41
40
  this.timetable = timetable;
42
41
  this.txsPermitted = opts.txsPermitted;
43
42
  this.maxTxsPerBlock = opts.maxTxsPerBlock;
44
- this.maxBlocksPerCheckpoint = opts.maxBlocksPerCheckpoint;
45
43
  this.skipSlotValidation = opts.skipSlotValidation ?? false;
46
44
  this.signatureContext = opts.signatureContext;
47
45
  this.clockDisparityMs = opts.clockDisparityMs;
@@ -107,8 +105,12 @@ export class ProposalValidator {
107
105
  return { result: 'reject', severity: PeerErrorSeverity.MidToleranceError };
108
106
  }
109
107
 
110
- // A block proposal whose index lands beyond the checkpoint block limit can never be part of a
111
- // checkpoint we would attest to, so reject it immediately at ingress.
108
+ // A block proposal whose index lands at or beyond the hard attestable ceiling is structurally
109
+ // impossible garbage, so reject it immediately at ingress. Indices in
110
+ // `[maxBlocksPerCheckpoint, MAX_ATTESTABLE_BLOCKS_PER_CHECKPOINT)` are over the consensus limit
111
+ // but structurally valid proposer misbehavior; they pass gossip validation here so the offending
112
+ // proposal can be retained and re-broadcast as slashing evidence (handled downstream in the p2p
113
+ // service), rather than penalizing the relaying peer.
112
114
  if ('indexWithinCheckpoint' in proposal) {
113
115
  const indexResult = this.validateBlockIndexWithinCheckpoint(proposal);
114
116
  if (indexResult.result !== 'accept') {
@@ -126,20 +128,21 @@ export class ProposalValidator {
126
128
  }
127
129
 
128
130
  /**
129
- * Rejects a block proposal whose index within its checkpoint lands at or beyond the per-checkpoint
130
- * block limit. `MAX_ATTESTABLE_BLOCKS_PER_CHECKPOINT` is a hard ceiling applied even when
131
- * `maxBlocksPerCheckpoint` is unset; a lower configured value tightens it further.
132
- * `indexWithinCheckpoint` is 0-based, so a ceiling of 72 rejects the 73rd block. Applies to standalone
131
+ * Rejects a block proposal whose index within its checkpoint lands at or beyond the hard attestable
132
+ * ceiling `MAX_ATTESTABLE_BLOCKS_PER_CHECKPOINT` (a structurally impossible index that can never be
133
+ * part of any checkpoint we could attest to). `indexWithinCheckpoint` is 0-based, so a ceiling of 72
134
+ * rejects the 73rd block.
135
+ *
136
+ * Indices in `[maxBlocksPerCheckpoint, MAX_ATTESTABLE_BLOCKS_PER_CHECKPOINT)` are over the configured
137
+ * consensus limit but still structurally valid: they are proposer misbehavior, not relaying-peer
138
+ * fault, so they are *not* rejected here. The p2p service retains and re-broadcasts the first such
139
+ * proposal per (slot, proposer) as slashing evidence and skips processing it. Applies to standalone
133
140
  * block proposals and to the terminal block embedded in a checkpoint proposal.
134
141
  */
135
142
  public validateBlockIndexWithinCheckpoint(proposal: BlockProposal): ValidationResult {
136
- const maxBlocksPerCheckpoint = Math.min(
137
- this.maxBlocksPerCheckpoint ?? MAX_ATTESTABLE_BLOCKS_PER_CHECKPOINT,
138
- MAX_ATTESTABLE_BLOCKS_PER_CHECKPOINT,
139
- );
140
- if (proposal.indexWithinCheckpoint >= maxBlocksPerCheckpoint) {
143
+ if (proposal.indexWithinCheckpoint >= MAX_ATTESTABLE_BLOCKS_PER_CHECKPOINT) {
141
144
  this.logger.warn(
142
- `Penalizing peer for proposal with indexWithinCheckpoint ${proposal.indexWithinCheckpoint} >= max ${maxBlocksPerCheckpoint}`,
145
+ `Penalizing peer for proposal with indexWithinCheckpoint ${proposal.indexWithinCheckpoint} >= attestable ceiling ${MAX_ATTESTABLE_BLOCKS_PER_CHECKPOINT}`,
143
146
  );
144
147
  return { result: 'reject', severity: PeerErrorSeverity.MidToleranceError };
145
148
  }
@@ -97,7 +97,7 @@ export class PhasesTxValidator implements TxValidator<Tx> {
97
97
  }
98
98
  if (
99
99
  entry.rejectNullMsgSender &&
100
- publicCall.request.msgSender.equals(AztecAddress.fromBigInt(NULL_MSG_SENDER_CONTRACT_ADDRESS))
100
+ publicCall.request.msgSender.equals(AztecAddress.fromBigIntUnsafe(NULL_MSG_SENDER_CONTRACT_ADDRESS))
101
101
  ) {
102
102
  return TX_ERROR_SETUP_NULL_MSG_SENDER;
103
103
  }
@@ -130,7 +130,7 @@ export class PhasesTxValidator implements TxValidator<Tx> {
130
130
  }
131
131
  if (
132
132
  entry.rejectNullMsgSender &&
133
- publicCall.request.msgSender.equals(AztecAddress.fromBigInt(NULL_MSG_SENDER_CONTRACT_ADDRESS))
133
+ publicCall.request.msgSender.equals(AztecAddress.fromBigIntUnsafe(NULL_MSG_SENDER_CONTRACT_ADDRESS))
134
134
  ) {
135
135
  return TX_ERROR_SETUP_NULL_MSG_SENDER;
136
136
  }
@@ -1,5 +1,7 @@
1
1
  import { createLogger } from '@aztec/foundation/log';
2
2
  import { sleep } from '@aztec/foundation/sleep';
3
+ import { DateProvider } from '@aztec/foundation/timer';
4
+ import type { AztecAsyncKVStore } from '@aztec/kv-store';
3
5
  import { type ComponentsVersions, checkCompressedComponentVersion } from '@aztec/stdlib/versioning';
4
6
  import { OtelMetricsAdapter, type TelemetryClient, getTelemetryClient } from '@aztec/telemetry-client';
5
7
 
@@ -14,9 +16,13 @@ import { createNodeENR } from '../../enr/generate-enr.js';
14
16
  import { AZTEC_ENR_KEY, Discv5Event, PeerEvent } from '../../types/index.js';
15
17
  import { convertToMultiaddr } from '../../util.js';
16
18
  import { type PeerDiscoveryService, PeerDiscoveryState } from '../service.js';
19
+ import { PersistedEnrStore } from './persisted_enr_store.js';
17
20
 
18
21
  const delayBeforeStart = 2000; // 2sec
19
22
 
23
+ /** Upper bound on persisted peer ENRs, to keep the store bounded as peers churn. */
24
+ const MAX_PERSISTED_PEER_ENRS = 100;
25
+
20
26
  /**
21
27
  * Peer discovery service using Discv5.
22
28
  */
@@ -35,14 +41,19 @@ export class DiscV5Service extends EventEmitter implements PeerDiscoveryService
35
41
  private bootstrapNodePeerIds: PeerId[] = [];
36
42
  public bootstrapNodeEnrs: ENR[] = [];
37
43
  private trustedPeerEnrs: ENR[] = [];
44
+ /** Node IDs of all config-provided peers (bootstrap, trusted, private); these are never persisted. */
45
+ private readonly configProvidedNodeIds: Set<string>;
38
46
 
39
47
  private startTime = 0;
40
48
 
41
49
  private currentIp: string | undefined;
42
50
 
51
+ /** Persistent store of discovered peer ENRs, used to re-seed discovery after a restart. */
52
+ private readonly persistedEnrs?: PersistedEnrStore;
53
+
43
54
  private handlers = {
44
55
  onMultiaddrUpdated: this.onMultiaddrUpdated.bind(this),
45
- onDiscovered: this.onDiscovered.bind(this),
56
+ onPeerDiscovered: this.onPeerDiscovered.bind(this),
46
57
  onEnrAdded: this.onEnrAdded.bind(this),
47
58
  };
48
59
 
@@ -52,16 +63,27 @@ export class DiscV5Service extends EventEmitter implements PeerDiscoveryService
52
63
  private readonly packageVersion: string,
53
64
  telemetry: TelemetryClient = getTelemetryClient(),
54
65
  private logger = createLogger('p2p:discv5_service'),
66
+ store?: AztecAsyncKVStore,
55
67
  configOverrides: Partial<IDiscv5CreateOptions> = {},
68
+ private readonly dateProvider: DateProvider = new DateProvider(),
56
69
  ) {
57
70
  super();
58
71
 
72
+ this.persistedEnrs = store
73
+ ? new PersistedEnrStore(store, MAX_PERSISTED_PEER_ENRS, createLogger(`${this.logger.module}:enr-store`))
74
+ : undefined;
75
+
59
76
  const { p2pIp, p2pPort, p2pBroadcastPort, bootstrapNodes, trustedPeers, privatePeers } = config;
60
77
 
61
78
  this.currentIp = p2pIp;
62
79
  this.bootstrapNodeEnrs = bootstrapNodes.map(x => ENR.decodeTxt(x));
63
80
  const privatePeerEnrs = new Set(privatePeers);
64
81
  this.trustedPeerEnrs = trustedPeers.filter(x => !privatePeerEnrs.has(x)).map(x => ENR.decodeTxt(x));
82
+ // Peers supplied via config are re-injected from config on every start, so they must never end up
83
+ // in the persisted (discovered-peer) store — private peers especially must not be written there.
84
+ this.configProvidedNodeIds = new Set(
85
+ [...bootstrapNodes, ...trustedPeers, ...privatePeers].map(x => ENR.decodeTxt(x).nodeId),
86
+ );
65
87
 
66
88
  // If no overridden broadcast port is provided, use the p2p port as the broadcast port
67
89
  if (!p2pBroadcastPort) {
@@ -126,7 +148,7 @@ export class DiscV5Service extends EventEmitter implements PeerDiscoveryService
126
148
  }
127
149
  };
128
150
 
129
- this.discv5.on(Discv5Event.DISCOVERED, this.handlers.onDiscovered);
151
+ this.discv5.on(Discv5Event.DISCOVERED, this.handlers.onPeerDiscovered);
130
152
  this.discv5.on(Discv5Event.ENR_ADDED, this.handlers.onEnrAdded);
131
153
  this.discv5.on(Discv5Event.MULTIADDR_UPDATED, this.handlers.onMultiaddrUpdated);
132
154
  }
@@ -168,7 +190,7 @@ export class DiscV5Service extends EventEmitter implements PeerDiscoveryService
168
190
  }
169
191
  this.logger.debug('Starting DiscV5');
170
192
  await this.discv5.start();
171
- this.startTime = Date.now();
193
+ this.startTime = this.dateProvider.now();
172
194
 
173
195
  const enrUpdateEnabled = this.config.queryForIp || !this.config.p2pIp;
174
196
  this.logger.info(`DiscV5 service started`, {
@@ -220,6 +242,26 @@ export class DiscV5Service extends EventEmitter implements PeerDiscoveryService
220
242
  this.discv5.addEnr(enr);
221
243
  }
222
244
  }
245
+
246
+ // Re-seed discovery from peers persisted on previous runs. This lets a node rejoin the network
247
+ // after a restart even when no bootstrap node is reachable: discv5 has no on-disk routing table,
248
+ // so without this its DHT would start empty and rediscovery would depend on inbound dials alone.
249
+ if (this.persistedEnrs) {
250
+ // Drop peers that are now config-provided (re-injected above) or fail version checks.
251
+ const enrs = await this.persistedEnrs.load(
252
+ enr => this.validateEnr(enr) && !this.configProvidedNodeIds.has(enr.nodeId),
253
+ );
254
+ for (const enr of enrs) {
255
+ try {
256
+ this.discv5.addEnr(enr);
257
+ } catch (err) {
258
+ this.logger.debug(`Error re-seeding persisted ENR ${enr.nodeId}`, err);
259
+ }
260
+ }
261
+ if (enrs.length > 0) {
262
+ this.logger.info(`Re-seeded discovery with ${enrs.length} persisted peer ENRs`);
263
+ }
264
+ }
223
265
  }
224
266
 
225
267
  public async runRandomNodesQuery(): Promise<void> {
@@ -229,8 +271,8 @@ export class DiscV5Service extends EventEmitter implements PeerDiscoveryService
229
271
 
230
272
  // First, wait some time before starting the peer discovery
231
273
  // reference: https://github.com/ChainSafe/lodestar/issues/3423
232
- const msSinceStart = Date.now() - this.startTime;
233
- if (Date.now() - this.startTime <= delayBeforeStart) {
274
+ const msSinceStart = this.dateProvider.now() - this.startTime;
275
+ if (this.dateProvider.now() - this.startTime <= delayBeforeStart) {
234
276
  await sleep(delayBeforeStart - msSinceStart);
235
277
  }
236
278
 
@@ -265,7 +307,7 @@ export class DiscV5Service extends EventEmitter implements PeerDiscoveryService
265
307
  if (this.currentState !== PeerDiscoveryState.RUNNING) {
266
308
  return;
267
309
  }
268
- await this.discv5.off(Discv5Event.DISCOVERED, this.handlers.onDiscovered);
310
+ await this.discv5.off(Discv5Event.DISCOVERED, this.handlers.onPeerDiscovered);
269
311
  await this.discv5.off(Discv5Event.ENR_ADDED, this.handlers.onEnrAdded);
270
312
  await this.discv5.off(Discv5Event.MULTIADDR_UPDATED, this.handlers.onMultiaddrUpdated);
271
313
 
@@ -275,12 +317,38 @@ export class DiscV5Service extends EventEmitter implements PeerDiscoveryService
275
317
  }
276
318
 
277
319
  private async onEnrAdded(enr: ENR) {
278
- const multiAddrTcp = await enr.getFullMultiaddr('tcp');
279
- const multiAddrUdp = await enr.getFullMultiaddr('udp');
280
- this.logger.debug(`Added ENR ${enr.encodeTxt()}`, { multiAddrTcp, multiAddrUdp, nodeId: enr.nodeId });
320
+ try {
321
+ const multiAddrTcp = await enr.getFullMultiaddr('tcp');
322
+ const multiAddrUdp = await enr.getFullMultiaddr('udp');
323
+ this.logger.debug(`Added ENR ${enr.encodeTxt()}`, { multiAddrTcp, multiAddrUdp, nodeId: enr.nodeId });
324
+ // A peer just entered the routing table — persist it so we can re-seed discovery after a restart.
325
+ if (this.persistedEnrs && this.shouldPersist(enr)) {
326
+ await this.persistedEnrs.persist(enr);
327
+ }
328
+ this.onDiscovered(enr);
329
+ } catch (err) {
330
+ // A malformed ENR address field (e.g. a 3-byte TCP port) makes @nethermindeth/enr throw while
331
+ // parsing the multiaddr. This is an async event listener, so an unhandled rejection would crash
332
+ // the process — catch, log, and drop the ENR instead.
333
+ this.logger.warn(`Dropping ENR with unparseable address`, { nodeId: enr.nodeId, err });
334
+ }
335
+ }
336
+
337
+ private async onPeerDiscovered(enr: ENR) {
338
+ // discv5 updates an existing routing-table entry on an ENR sequence bump without emitting enrAdded,
339
+ // so DISCOVERED is our only signal that a peer we already track may have changed address. Refresh
340
+ // the persisted copy (a no-op for peers we don't already store) so we don't keep dialing a stale ENR.
341
+ if (this.persistedEnrs && this.shouldPersist(enr)) {
342
+ await this.persistedEnrs.refresh(enr);
343
+ }
281
344
  this.onDiscovered(enr);
282
345
  }
283
346
 
347
+ /** Whether an ENR belongs in the persisted store: a validated, non-config peer that isn't ourselves. */
348
+ private shouldPersist(enr: ENR): boolean {
349
+ return !this.configProvidedNodeIds.has(enr.nodeId) && enr.nodeId !== this.enr.nodeId && this.validateEnr(enr);
350
+ }
351
+
284
352
  private isOurBootnode(enr: ENR) {
285
353
  return this.bootstrapNodeEnrs.some(x => x.nodeId === enr.nodeId);
286
354
  }
@@ -314,6 +382,17 @@ export class DiscV5Service extends EventEmitter implements PeerDiscoveryService
314
382
  return false;
315
383
  }
316
384
 
385
+ // A malformed TCP field (e.g. a non-2-byte port) makes @nethermindeth/enr throw while building the
386
+ // multiaddr. Reject such an ENR here so it's never emitted as a full peer or re-parsed by an
387
+ // unguarded listener (which would crash the process). A missing TCP addr is allowed — those peers
388
+ // are simply skipped at dial time.
389
+ try {
390
+ enr.getLocationMultiaddr('tcp');
391
+ } catch (err) {
392
+ this.logger.debug(`Peer node ${enr.nodeId} has an unparseable TCP address`, { err });
393
+ return false;
394
+ }
395
+
317
396
  // And check it has the correct version
318
397
  let compressedVersion;
319
398
  try {
@@ -0,0 +1,158 @@
1
+ import { type Logger, createLogger } from '@aztec/foundation/log';
2
+ import type { AztecAsyncKVStore, AztecAsyncMap } from '@aztec/kv-store';
3
+
4
+ import { ENR } from '@nethermindeth/enr';
5
+
6
+ /** Map name under which discovered peer ENRs are persisted so discovery can be re-seeded after a restart. */
7
+ const PERSISTED_ENRS_MAP_NAME = 'discovered_peer_enrs';
8
+
9
+ /**
10
+ * A persisted peer entry: the ENR text plus a monotonic sequence number recording when the peer was
11
+ * last seen. The sequence orders the store as a FIFO so eviction drops the oldest-seen peers first.
12
+ */
13
+ interface PersistedEnr {
14
+ enr: string;
15
+ seq: number;
16
+ }
17
+
18
+ function parsePersistedEnr(value: string): PersistedEnr | undefined {
19
+ try {
20
+ const parsed = JSON.parse(value);
21
+ if (typeof parsed?.enr === 'string' && typeof parsed?.seq === 'number') {
22
+ return parsed;
23
+ }
24
+ } catch {
25
+ // Unparseable entry; caller treats it as stale.
26
+ }
27
+ return undefined;
28
+ }
29
+
30
+ /**
31
+ * A bounded, transactional store of discovered peer ENRs used to re-seed discovery after a restart.
32
+ *
33
+ * Entries are keyed by nodeId (so re-seeing a peer dedups in place) and carry a monotonic sequence so
34
+ * the store behaves as a FIFO: when it exceeds its cap, the oldest-seen peers are evicted first. All
35
+ * writes run in a transaction so concurrent discovery events can't race the size check.
36
+ */
37
+ export class PersistedEnrStore {
38
+ private readonly enrs: AztecAsyncMap<string, string>;
39
+ /** Monotonic sequence stamped on each entry; seeded from the store's max in {@link load}. */
40
+ private seq = 0;
41
+
42
+ constructor(
43
+ private readonly store: AztecAsyncKVStore,
44
+ private readonly maxEntries: number,
45
+ private readonly logger: Logger = createLogger('p2p:discv5:enr-store'),
46
+ ) {
47
+ this.enrs = store.openMap(PERSISTED_ENRS_MAP_NAME);
48
+ }
49
+
50
+ /** Adds or replaces a peer's ENR, stamping it as most-recently-seen and evicting the oldest if over cap. */
51
+ public async persist(enr: ENR): Promise<void> {
52
+ const nodeId = enr.nodeId;
53
+ const enrTxt = enr.encodeTxt();
54
+ try {
55
+ await this.store.transactionAsync(async () => {
56
+ const seq = ++this.seq;
57
+ const others: { nodeId: string; seq: number }[] = [];
58
+ for await (const [id, value] of this.enrs.entriesAsync()) {
59
+ if (id !== nodeId) {
60
+ others.push({ nodeId: id, seq: parsePersistedEnr(value)?.seq ?? 0 });
61
+ }
62
+ }
63
+ const overflow = others.length + 1 - this.maxEntries;
64
+ if (overflow > 0) {
65
+ others.sort((a, b) => a.seq - b.seq); // oldest-seen (lowest sequence) first
66
+ for (let i = 0; i < overflow; i++) {
67
+ await this.enrs.delete(others[i].nodeId);
68
+ }
69
+ }
70
+ await this.enrs.set(nodeId, JSON.stringify({ enr: enrTxt, seq }));
71
+ });
72
+ } catch (err) {
73
+ this.logger.warn(`Failed to persist discovered ENR ${nodeId}`, err);
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Refreshes an already-persisted peer's stored ENR when a newer one (higher ENR sequence) is seen.
79
+ * A no-op for peers we don't already track — we never add new peers via this path, only update.
80
+ *
81
+ * This exists because discv5 updates an existing routing-table entry on an ENR sequence bump without
82
+ * emitting an `enrAdded` event, so a peer that changes address would otherwise keep its stale ENR
83
+ * persisted until evicted.
84
+ */
85
+ public async refresh(enr: ENR): Promise<void> {
86
+ const nodeId = enr.nodeId;
87
+ try {
88
+ await this.store.transactionAsync(async () => {
89
+ const existing = await this.enrs.getAsync(nodeId);
90
+ if (!existing) {
91
+ return;
92
+ }
93
+ // Two unrelated sequence numbers are in play:
94
+ // - the ENR sequence (`enr.seq` / `storedEnrSeq`) is set by the *peer* and bumped whenever it
95
+ // revises its record (e.g. changes address); we compare these to decide whether the incoming
96
+ // ENR is genuinely newer and worth keeping.
97
+ // - our storage sequence (`this.seq`) is an internal counter used only for FIFO eviction; we
98
+ // stamp a fresh one so this entry counts as most-recently-seen.
99
+ const storedEnrSeq = this.decodeStoredEnr(existing)?.seq;
100
+ if (storedEnrSeq !== undefined && enr.seq <= storedEnrSeq) {
101
+ return;
102
+ }
103
+ await this.enrs.set(nodeId, JSON.stringify({ enr: enr.encodeTxt(), seq: ++this.seq }));
104
+ });
105
+ } catch (err) {
106
+ this.logger.warn(`Failed to refresh persisted ENR ${nodeId}`, err);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Loads all persisted ENRs, decoding each and keeping those the `accept` predicate allows. Entries
112
+ * that fail to decode or are rejected are removed from the store. The FIFO sequence resumes from the
113
+ * highest persisted value so eviction order survives restarts.
114
+ */
115
+ public async load(accept: (enr: ENR) => boolean): Promise<ENR[]> {
116
+ const valid: ENR[] = [];
117
+ const stale: string[] = [];
118
+ let maxSeq = 0;
119
+ for await (const [nodeId, value] of this.enrs.entriesAsync()) {
120
+ const parsed = parsePersistedEnr(value);
121
+ let enr: ENR | undefined;
122
+ if (parsed) {
123
+ try {
124
+ enr = ENR.decodeTxt(parsed.enr);
125
+ } catch (err) {
126
+ this.logger.debug(`Dropping undecodable persisted ENR ${nodeId}`, err);
127
+ }
128
+ }
129
+ if (!parsed || !enr || !accept(enr)) {
130
+ stale.push(nodeId);
131
+ continue;
132
+ }
133
+ maxSeq = Math.max(maxSeq, parsed.seq);
134
+ valid.push(enr);
135
+ }
136
+ this.seq = maxSeq;
137
+ if (stale.length > 0) {
138
+ await this.store.transactionAsync(async () => {
139
+ for (const nodeId of stale) {
140
+ await this.enrs.delete(nodeId);
141
+ }
142
+ });
143
+ }
144
+ return valid;
145
+ }
146
+
147
+ private decodeStoredEnr(value: string): ENR | undefined {
148
+ const parsed = parsePersistedEnr(value);
149
+ if (!parsed) {
150
+ return undefined;
151
+ }
152
+ try {
153
+ return ENR.decodeTxt(parsed.enr);
154
+ } catch {
155
+ return undefined;
156
+ }
157
+ }
158
+ }
@@ -28,6 +28,7 @@ import {
28
28
  type P2PCheckpointReceivedCallback,
29
29
  type P2PDuplicateAttestationCallback,
30
30
  type P2PDuplicateProposalCallback,
31
+ type P2POversizedProposalCallback,
31
32
  type P2PService,
32
33
  type PeerDiscoveryService,
33
34
  PeerDiscoveryState,
@@ -98,6 +99,11 @@ export class DummyP2PService implements P2PService {
98
99
  */
99
100
  public registerDuplicateProposalCallback(_callback: P2PDuplicateProposalCallback): void {}
100
101
 
102
+ /**
103
+ * Register a callback for when an oversized proposal is stored
104
+ */
105
+ public registerOversizedProposalCallback(_callback: P2POversizedProposalCallback): void {}
106
+
101
107
  /**
102
108
  * Register a callback for when a duplicate attestation is detected
103
109
  */