p2p-fret 1.0.0-beta.4 → 1.0.0

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 (42) hide show
  1. package/dist/src/index.d.ts +32 -3
  2. package/dist/src/rpc/neighbors.d.ts +3 -1
  3. package/dist/src/rpc/neighbors.js +3 -1
  4. package/dist/src/rpc/request.js +1 -1
  5. package/dist/src/rpc/validate.d.ts +35 -16
  6. package/dist/src/rpc/validate.js +84 -17
  7. package/dist/src/service/address-records.d.ts +136 -0
  8. package/dist/src/service/address-records.js +262 -0
  9. package/dist/src/service/fret-service.d.ts +106 -11
  10. package/dist/src/service/fret-service.js +324 -26
  11. package/dist/src/service/libp2p-fret-service.d.ts +8 -1
  12. package/dist/src/service/libp2p-fret-service.js +3 -1
  13. package/dist/src/service/peer-discovery.js +5 -5
  14. package/dist/src/store/digitree-store.d.ts +41 -4
  15. package/dist/src/store/digitree-store.js +33 -6
  16. package/dist/src/store/relevance.d.ts +8 -0
  17. package/dist/src/store/relevance.js +8 -0
  18. package/package.json +3 -1
  19. package/src/index.ts +33 -3
  20. package/src/ring/ring-walk.ts +110 -110
  21. package/src/rpc/leave.ts +77 -77
  22. package/src/rpc/maybe-act.ts +85 -85
  23. package/src/rpc/neighbors.ts +156 -154
  24. package/src/rpc/outcome.ts +66 -66
  25. package/src/rpc/ping.ts +100 -100
  26. package/src/rpc/request.ts +310 -310
  27. package/src/rpc/validate.ts +79 -17
  28. package/src/selector/next-hop.ts +325 -325
  29. package/src/service/address-records.ts +275 -0
  30. package/src/service/dedup-cache.ts +53 -53
  31. package/src/service/fret-service.ts +312 -27
  32. package/src/service/libp2p-fret-service.ts +239 -231
  33. package/src/service/live-member.ts +29 -29
  34. package/src/service/peer-discovery.ts +205 -205
  35. package/src/service/probe-backoff.ts +181 -181
  36. package/src/service/size-observer.ts +227 -227
  37. package/src/store/digitree-store.ts +699 -642
  38. package/src/store/relevance.ts +201 -193
  39. package/src/utils/deadline.ts +85 -85
  40. package/src/utils/expiring-map.ts +164 -164
  41. package/src/utils/pool.ts +79 -79
  42. package/src/utils/token-bucket.ts +70 -70
@@ -1,5 +1,6 @@
1
1
  import type { Libp2p } from 'libp2p';
2
- import type { SerializedPeerEntry, SerializedTable } from './store/digitree-store.js';
2
+ import type { PrivateKey } from '@libp2p/interface';
3
+ import type { SerializedAddressRecord, SerializedPeerEntry, SerializedTable } from './store/digitree-store.js';
3
4
  import { FretService as FretServiceClass } from './service/fret-service.js';
4
5
  export type FretMode = 'active' | 'passive';
5
6
  export interface FretConfig {
@@ -17,6 +18,28 @@ export interface FretConfig {
17
18
  * spacing window count once, so the run is genuinely spread over time.
18
19
  */
19
20
  deadAfterFailures?: number;
21
+ /**
22
+ * This node's libp2p private key — the key `node.peerId` was derived from.
23
+ *
24
+ * Used to seal this node's own signed address record (a libp2p `PeerRecord` envelope) for the
25
+ * `hints` field of its outgoing neighbour snapshots, so peers that only ever hear of this node
26
+ * through FRET can still dial it. libp2p hands the key to services as the `privateKey`
27
+ * component, so `Libp2pFretService` fills this in automatically; a caller constructing the core
28
+ * service directly passes it here. The constructor throws if the key does not derive
29
+ * `node.peerId`. Absent → this node advertises no record for itself (logged once at `start()`);
30
+ * forwarding other peers' records and consuming received ones are unaffected.
31
+ */
32
+ privateKey?: PrivateKey;
33
+ }
34
+ /**
35
+ * One signed address record riding on a neighbour snapshot: `record` is the base64url encoding
36
+ * of a marshaled libp2p `RecordEnvelope` over a `PeerRecord` whose signer and payload peer id are
37
+ * both `id`. The receiver verifies all of that before the record touches its peerStore — see
38
+ * `FretService.ingestAddressHints`.
39
+ */
40
+ export interface AddressHintV1 {
41
+ id: string;
42
+ record: string;
20
43
  }
21
44
  export interface NeighborSnapshotV1 {
22
45
  v: 1;
@@ -33,6 +56,12 @@ export interface NeighborSnapshotV1 {
33
56
  confidence?: number;
34
57
  sig: string;
35
58
  metadata?: Record<string, unknown>;
59
+ /**
60
+ * Signed address records for peers this snapshot names (`from`, the id lists, the sample).
61
+ * Optional and unversioned: a reader that predates it ignores the field. An array rather than
62
+ * an id-keyed object so it truncates like the other lists and has no `__proto__`-key hazard.
63
+ */
64
+ hints?: AddressHintV1[];
36
65
  }
37
66
  export interface RouteAndMaybeActV1 {
38
67
  v: 1;
@@ -121,7 +150,7 @@ export interface FretService {
121
150
  exportTable(): SerializedTable;
122
151
  importTable(table: SerializedTable): Promise<number>;
123
152
  }
124
- export type { SerializedPeerEntry, SerializedTable };
153
+ export type { SerializedAddressRecord, SerializedPeerEntry, SerializedTable };
125
154
  export { FretServiceClass as FretServiceImpl };
126
155
  export { FretPeerDiscovery, type DiscoverySnapshotSource, type FretPeerDiscoveryInput, type FretPeerDiscoveryConfig } from './service/peer-discovery.js';
127
156
  export { Libp2pFretService, fretService } from './service/libp2p-fret-service.js';
@@ -129,7 +158,7 @@ export { hashKey, hashPeerId } from './ring/hash.js';
129
158
  export type { RingCoord } from './ring/hash.js';
130
159
  export { clockwiseDistance, minDistance, lexLess } from './ring/distance.js';
131
160
  export { DigitreeStore } from './store/digitree-store.js';
132
- export type { PeerEntry, PeerPatch, PeerState, MembershipState, RingCursor, RingWalkPage } from './store/digitree-store.js';
161
+ export type { PeerEntry, PeerAddressRecord, PeerPatch, PeerState, MembershipState, RingCursor, RingWalkPage } from './store/digitree-store.js';
133
162
  export { estimateSizeAndConfidence } from './estimate/size-estimator.js';
134
163
  export type { SizeEstimate, SizeEstimateOptions } from './estimate/size-estimator.js';
135
164
  export { assembleCohort } from './service/cohort.js';
@@ -35,7 +35,9 @@ export declare function fetchNeighbors(node: Libp2p, peerIdOrStr: string, protoc
35
35
  * that deliberately target *non-connected* peers — the "tell peers we just learned about"
36
36
  * paths, whose whole point is reaching someone we are not talking to yet — pass `dial: true`.
37
37
  * Those callers own the reachability check: dialing a peer libp2p holds no address for can
38
- * only fail (FRET's wire format carries peer ids, never multiaddrs).
38
+ * only fail. The dial itself is by bare peer id; the addresses it resolves to are whatever the
39
+ * peerStore holds, which since address hints includes the signed records FRET verified out of
40
+ * earlier snapshots (`FretService.ingestAddressHints`).
39
41
  */
40
42
  export declare function announceNeighbors(node: Libp2p, peerIdOrStr: string, snapshot: NeighborSnapshotV1, protocol?: string, opts?: {
41
43
  dial?: boolean;
@@ -106,7 +106,9 @@ export async function fetchNeighbors(node, peerIdOrStr, protocol = PROTOCOL_NEIG
106
106
  * that deliberately target *non-connected* peers — the "tell peers we just learned about"
107
107
  * paths, whose whole point is reaching someone we are not talking to yet — pass `dial: true`.
108
108
  * Those callers own the reachability check: dialing a peer libp2p holds no address for can
109
- * only fail (FRET's wire format carries peer ids, never multiaddrs).
109
+ * only fail. The dial itself is by bare peer id; the addresses it resolves to are whatever the
110
+ * peerStore holds, which since address hints includes the signed records FRET verified out of
111
+ * earlier snapshots (`FretService.ingestAddressHints`).
110
112
  */
111
113
  export async function announceNeighbors(node, peerIdOrStr, snapshot, protocol = PROTOCOL_NEIGHBORS_ANNOUNCE, opts = {}) {
112
114
  // Write-only: `ok` proves the body reached the transport, nothing more. No swallow — the
@@ -4,7 +4,7 @@ import { abortReasonError, deadline } from '../utils/deadline.js';
4
4
  /**
5
5
  * Default declared-length cap for a reply when the caller passes none: 8 KiB. It tracks no other
6
6
  * cap in the system — every FRET sender passes its own explicitly (`sendPing` 1024, the neighbors
7
- * senders `MAX_NEIGHBORS_BYTES` (16 KiB), maybeAct 144 KiB), so this value is only ever reached by a
7
+ * senders `MAX_NEIGHBORS_BYTES` (64 KiB), maybeAct 144 KiB), so this value is only ever reached by a
8
8
  * direct consumer of `rpcRequest` who forgot one. 8 KiB is chosen as a conservative floor for a
9
9
  * reply whose shape we do not know: large enough that a small JSON reply is never truncated,
10
10
  * small enough that a forgotten cap still bounds the read.
@@ -41,42 +41,61 @@ export declare const MAYBE_ACT_OVERHEAD_BYTES: number;
41
41
  * Per-message byte cap for both neighbors protocols (request/reply and announce). **One number
42
42
  * for both profiles**, because this is an *acceptance* limit: it bounds the largest message a
43
43
  * *peer* may send us, and Edge and Core peers talk to each other, so it has to cover the largest
44
- * snapshot any profile can legitimately emit. Sizing it per-profile — as it was, Core 16 KiB /
44
+ * snapshot any profile can legitimately emit. Sizing it per-profile — as it once was, Core 16 KiB /
45
45
  * Edge 8 KiB — made a legal Core snapshot unreadable by every Edge peer.
46
46
  *
47
47
  * Derived, not picked. The Core worst legal emission, measured by encoding the shape
48
48
  * `FretService.snapshot` produces (53-char peer ids, a 32-byte base64url coordinate per sample
49
49
  * entry, `metadata` grown until `JSON.stringify(metadata)` hits
50
- * {@link MAX_SNAPSHOT_METADATA_BYTES_CORE} exactly, and 256 bytes reserved for the still-empty
51
- * `sig` field):
50
+ * {@link MAX_SNAPSHOT_METADATA_BYTES_CORE} exactly, 256 bytes reserved for the still-empty `sig`
51
+ * field, and a `hints` array packed to {@link MAX_SNAPSHOT_HINT_BYTES_CORE}):
52
52
  *
53
53
  * 16 successors + 16 predecessors + 8 sample entries (the *merge* caps, which are wider than
54
54
  * today's emission caps of 12/12/8) .................................... 3,371 bytes
55
55
  * + 8 KiB metadata (including the `,"metadata":` key) .................. 8,204 bytes
56
- * = 11,575 bytes
56
+ * = 11,575 bytes of fixed fields
57
+ * + 48 KiB of hints (the whole array, plus its `,"hints":[]` framing) .... 49,163 bytes
58
+ * = 60,738 bytes
57
59
  *
58
- * 16 KiB leaves ~4.8 KiB of headroom over that. The 256-byte `sig` reservation is already inside
59
- * the number, so landing message signatures does not disturb it.
60
+ * 64 KiB leaves ~4.7 KiB of headroom over that. Sereus used the same 64 KiB for the same payload.
60
61
  *
61
62
  * The invariant this constant carries, pinned by `test/rpc.codec-properties.spec.ts`: **the
62
- * largest legal emission of *any* profile encodes under this cap.** Re-splitting it per profile
63
- * breaks that and fails there.
63
+ * largest legal emission of *any* profile — fixed fields, metadata allowance and hint budget
64
+ * together — encodes under this cap.** Re-splitting it per profile breaks that and fails there.
64
65
  */
65
66
  export declare const MAX_NEIGHBORS_BYTES: number;
66
67
  /** Cap on the encoded size of the caller-supplied `metadata` a Core node attaches to its
67
68
  * outgoing snapshot. This is an **emission budget** — the Core profile's own choice of how much
68
69
  * application metadata it will carry — not an acceptance limit; acceptance is
69
- * {@link MAX_NEIGHBORS_BYTES}, one network-wide number. The invariant the two budgets must keep
70
- * is `largest emission of any profile <= MAX_NEIGHBORS_BYTES`: at the merge caps the Core fixed
71
- * fields cost 3,371 bytes, so this allowance plus them lands at 11,575, comfortably under.
72
- * Over-cap metadata is omitted from the snapshot rather than truncated — see
73
- * `FretService.snapshot`. */
70
+ * {@link MAX_NEIGHBORS_BYTES}, one network-wide number. The invariant the emission budgets must
71
+ * keep is `largest emission of any profile <= MAX_NEIGHBORS_BYTES`: at the merge caps the Core
72
+ * fixed fields cost 3,371 bytes, so this allowance plus them lands at 11,575, and the hint budget
73
+ * ({@link MAX_SNAPSHOT_HINT_BYTES_CORE}) on top of that at 60,738 — under. Over-cap metadata is
74
+ * omitted from the snapshot rather than truncated — see `FretService.snapshot`. */
74
75
  export declare const MAX_SNAPSHOT_METADATA_BYTES_CORE: number;
75
76
  /** Same, Edge profile: an emission budget, half of Core's because an edge node carries less.
76
- * Its worst legal emission is 6,301 bytes, also under {@link MAX_NEIGHBORS_BYTES} — which is the
77
- * only bound either budget has to satisfy. Deliberately *not* sized against a per-profile
78
- * acceptance cap; there is no such thing. */
77
+ * Its worst legal emission is 6,301 bytes of fixed fields plus {@link MAX_SNAPSHOT_HINT_BYTES_EDGE}
78
+ * of hints, also under {@link MAX_NEIGHBORS_BYTES} — which is the only bound any budget has to
79
+ * satisfy. Deliberately *not* sized against a per-profile acceptance cap; there is no such thing. */
79
80
  export declare const MAX_SNAPSHOT_METADATA_BYTES_EDGE: number;
81
+ /**
82
+ * Cap on one base64url-encoded address record in a snapshot's `hints`, in characters (one byte
83
+ * each — base64url is ASCII). An Ed25519 envelope over a handful of addresses encodes to roughly
84
+ * 300–700 characters; 2048 leaves room for an RSA key and the eight addresses a self record may
85
+ * carry (`MAX_SELF_RECORD_ADDRS` in `service/address-records.ts`). Applied on both sides: the parser drops a longer record, and
86
+ * `SelfAddressRecord` omits its own record rather than emit one the receiver would drop.
87
+ */
88
+ export declare const MAX_ADDRESS_RECORD_CHARS = 2048;
89
+ /**
90
+ * Emission budget for the whole `hints` array of a Core snapshot — the sum over hints of
91
+ * `JSON.stringify(hint).length + 1` (the `+ 1` is the separating comma), which is what
92
+ * `FretService.snapshot` counts. Profile-split like the metadata allowance, and for the same
93
+ * reason: it is this node's own choice of how much it forwards, not a limit on what it accepts.
94
+ * Worst case: 11,575 (fixed fields) + 49,152 + the field's own framing < {@link MAX_NEIGHBORS_BYTES}.
95
+ */
96
+ export declare const MAX_SNAPSHOT_HINT_BYTES_CORE: number;
97
+ /** Same, Edge profile: an edge node forwards less. 6,301 + 16,384 + framing is far under the cap. */
98
+ export declare const MAX_SNAPSHOT_HINT_BYTES_EDGE: number;
80
99
  /**
81
100
  * Structural validity of an inbound `RouteAndMaybeAct` — everything downstream code touches
82
101
  * without checking, and nothing more. The caller runs it immediately after taking the rate-limit
@@ -71,42 +71,61 @@ export const MAYBE_ACT_OVERHEAD_BYTES = 16 * 1024;
71
71
  * Per-message byte cap for both neighbors protocols (request/reply and announce). **One number
72
72
  * for both profiles**, because this is an *acceptance* limit: it bounds the largest message a
73
73
  * *peer* may send us, and Edge and Core peers talk to each other, so it has to cover the largest
74
- * snapshot any profile can legitimately emit. Sizing it per-profile — as it was, Core 16 KiB /
74
+ * snapshot any profile can legitimately emit. Sizing it per-profile — as it once was, Core 16 KiB /
75
75
  * Edge 8 KiB — made a legal Core snapshot unreadable by every Edge peer.
76
76
  *
77
77
  * Derived, not picked. The Core worst legal emission, measured by encoding the shape
78
78
  * `FretService.snapshot` produces (53-char peer ids, a 32-byte base64url coordinate per sample
79
79
  * entry, `metadata` grown until `JSON.stringify(metadata)` hits
80
- * {@link MAX_SNAPSHOT_METADATA_BYTES_CORE} exactly, and 256 bytes reserved for the still-empty
81
- * `sig` field):
80
+ * {@link MAX_SNAPSHOT_METADATA_BYTES_CORE} exactly, 256 bytes reserved for the still-empty `sig`
81
+ * field, and a `hints` array packed to {@link MAX_SNAPSHOT_HINT_BYTES_CORE}):
82
82
  *
83
83
  * 16 successors + 16 predecessors + 8 sample entries (the *merge* caps, which are wider than
84
84
  * today's emission caps of 12/12/8) .................................... 3,371 bytes
85
85
  * + 8 KiB metadata (including the `,"metadata":` key) .................. 8,204 bytes
86
- * = 11,575 bytes
86
+ * = 11,575 bytes of fixed fields
87
+ * + 48 KiB of hints (the whole array, plus its `,"hints":[]` framing) .... 49,163 bytes
88
+ * = 60,738 bytes
87
89
  *
88
- * 16 KiB leaves ~4.8 KiB of headroom over that. The 256-byte `sig` reservation is already inside
89
- * the number, so landing message signatures does not disturb it.
90
+ * 64 KiB leaves ~4.7 KiB of headroom over that. Sereus used the same 64 KiB for the same payload.
90
91
  *
91
92
  * The invariant this constant carries, pinned by `test/rpc.codec-properties.spec.ts`: **the
92
- * largest legal emission of *any* profile encodes under this cap.** Re-splitting it per profile
93
- * breaks that and fails there.
93
+ * largest legal emission of *any* profile — fixed fields, metadata allowance and hint budget
94
+ * together — encodes under this cap.** Re-splitting it per profile breaks that and fails there.
94
95
  */
95
- export const MAX_NEIGHBORS_BYTES = 16 * 1024;
96
+ export const MAX_NEIGHBORS_BYTES = 64 * 1024;
96
97
  /** Cap on the encoded size of the caller-supplied `metadata` a Core node attaches to its
97
98
  * outgoing snapshot. This is an **emission budget** — the Core profile's own choice of how much
98
99
  * application metadata it will carry — not an acceptance limit; acceptance is
99
- * {@link MAX_NEIGHBORS_BYTES}, one network-wide number. The invariant the two budgets must keep
100
- * is `largest emission of any profile <= MAX_NEIGHBORS_BYTES`: at the merge caps the Core fixed
101
- * fields cost 3,371 bytes, so this allowance plus them lands at 11,575, comfortably under.
102
- * Over-cap metadata is omitted from the snapshot rather than truncated — see
103
- * `FretService.snapshot`. */
100
+ * {@link MAX_NEIGHBORS_BYTES}, one network-wide number. The invariant the emission budgets must
101
+ * keep is `largest emission of any profile <= MAX_NEIGHBORS_BYTES`: at the merge caps the Core
102
+ * fixed fields cost 3,371 bytes, so this allowance plus them lands at 11,575, and the hint budget
103
+ * ({@link MAX_SNAPSHOT_HINT_BYTES_CORE}) on top of that at 60,738 — under. Over-cap metadata is
104
+ * omitted from the snapshot rather than truncated — see `FretService.snapshot`. */
104
105
  export const MAX_SNAPSHOT_METADATA_BYTES_CORE = 8 * 1024;
105
106
  /** Same, Edge profile: an emission budget, half of Core's because an edge node carries less.
106
- * Its worst legal emission is 6,301 bytes, also under {@link MAX_NEIGHBORS_BYTES} — which is the
107
- * only bound either budget has to satisfy. Deliberately *not* sized against a per-profile
108
- * acceptance cap; there is no such thing. */
107
+ * Its worst legal emission is 6,301 bytes of fixed fields plus {@link MAX_SNAPSHOT_HINT_BYTES_EDGE}
108
+ * of hints, also under {@link MAX_NEIGHBORS_BYTES} — which is the only bound any budget has to
109
+ * satisfy. Deliberately *not* sized against a per-profile acceptance cap; there is no such thing. */
109
110
  export const MAX_SNAPSHOT_METADATA_BYTES_EDGE = 4 * 1024;
111
+ /**
112
+ * Cap on one base64url-encoded address record in a snapshot's `hints`, in characters (one byte
113
+ * each — base64url is ASCII). An Ed25519 envelope over a handful of addresses encodes to roughly
114
+ * 300–700 characters; 2048 leaves room for an RSA key and the eight addresses a self record may
115
+ * carry (`MAX_SELF_RECORD_ADDRS` in `service/address-records.ts`). Applied on both sides: the parser drops a longer record, and
116
+ * `SelfAddressRecord` omits its own record rather than emit one the receiver would drop.
117
+ */
118
+ export const MAX_ADDRESS_RECORD_CHARS = 2048;
119
+ /**
120
+ * Emission budget for the whole `hints` array of a Core snapshot — the sum over hints of
121
+ * `JSON.stringify(hint).length + 1` (the `+ 1` is the separating comma), which is what
122
+ * `FretService.snapshot` counts. Profile-split like the metadata allowance, and for the same
123
+ * reason: it is this node's own choice of how much it forwards, not a limit on what it accepts.
124
+ * Worst case: 11,575 (fixed fields) + 49,152 + the field's own framing < {@link MAX_NEIGHBORS_BYTES}.
125
+ */
126
+ export const MAX_SNAPSHOT_HINT_BYTES_CORE = 48 * 1024;
127
+ /** Same, Edge profile: an edge node forwards less. 6,301 + 16,384 + framing is far under the cap. */
128
+ export const MAX_SNAPSHOT_HINT_BYTES_EDGE = 16 * 1024;
110
129
  /**
111
130
  * Structural validity of an inbound `RouteAndMaybeAct` — everything downstream code touches
112
131
  * without checking, and nothing more. The caller runs it immediately after taking the rate-limit
@@ -247,10 +266,58 @@ export function makeSnapshotParser(caps) {
247
266
  delete out.metadata;
248
267
  else
249
268
  out.metadata = msg.metadata;
269
+ // Hints are tied to the ids this message names *after* truncation, so the merge caps above
270
+ // bound the receiver's verify work as well as its hash-and-upsert work: a message cannot
271
+ // carry more records than it carries ids, whatever its `hints` array holds.
272
+ const named = new Set([msg.from, ...out.successors, ...out.predecessors, ...out.sample.map((s) => s.id)]);
273
+ const hints = parseHints(msg.hints, named, msg.from);
274
+ if (hints.length === 0)
275
+ delete out.hints;
276
+ else
277
+ out.hints = hints;
250
278
  // `sig` is deliberately unchecked: message signing is unimplemented, so nothing reads it.
251
279
  return out;
252
280
  };
253
281
  }
282
+ /**
283
+ * The address hints, vetted per entry against the snapshot's named set — see
284
+ * {@link makeSnapshotParser}. An entry survives when it is an object whose `id` is a named id not
285
+ * already hinted and whose `record` is a base64url string of at most {@link MAX_ADDRESS_RECORD_CHARS}
286
+ * characters. Structural only: the decode proves the string is base64url, nothing about what it
287
+ * encodes — envelope structure and the signature are checked at ingestion, where the store entry
288
+ * and the peerStore are. Same skip-and-log-per-entry rule as the sample; never throws.
289
+ *
290
+ * The count is bounded by the named set's size through the `seen` set, and the loop stops once
291
+ * every named id has a hint, so a long junk array costs a per-entry type check and nothing more.
292
+ */
293
+ function parseHints(value, named, from) {
294
+ if (!Array.isArray(value))
295
+ return [];
296
+ const out = [];
297
+ const seen = new Set();
298
+ for (const entry of value) {
299
+ if (seen.size >= named.size)
300
+ break;
301
+ if (!isPlainObject(entry))
302
+ continue;
303
+ if (typeof entry.id !== 'string' || !named.has(entry.id) || seen.has(entry.id))
304
+ continue;
305
+ if (typeof entry.record !== 'string' || entry.record.length > MAX_ADDRESS_RECORD_CHARS)
306
+ continue;
307
+ try {
308
+ u8FromString(entry.record, 'base64url');
309
+ }
310
+ catch (err) {
311
+ // NOTE: one line per dropped entry, bounded by the named set — the same terms as the
312
+ // sample's per-entry line above.
313
+ log.error('snapshot from %s: dropping address hint for %s with undecodable record - %e', from, entry.id, err);
314
+ continue;
315
+ }
316
+ seen.add(entry.id);
317
+ out.push({ id: entry.id, record: entry.record });
318
+ }
319
+ return out;
320
+ }
254
321
  /**
255
322
  * The sparsity sample, truncated then vetted per entry. A `coord` is checked by *decoding* it
256
323
  * through {@link base64urlToCoord}, which already rejects anything that is not exactly 32 bytes —
@@ -0,0 +1,136 @@
1
+ import type { Multiaddr } from '@multiformats/multiaddr';
2
+ import type { Libp2p } from 'libp2p';
3
+ import type { PrivateKey } from '@libp2p/interface';
4
+ import { type SerializedPeerEntry } from '../store/digitree-store.js';
5
+ /**
6
+ * Signed address records — what FRET carries in a snapshot's `hints` so a peer known only
7
+ * through gossip can still be dialed.
8
+ *
9
+ * The record is libp2p's own: a `RecordEnvelope` (signed, domain-separated) over a `PeerRecord`
10
+ * (`peerId`, `multiaddrs`, `seqNumber`). Verification is `peerStore.consumePeerRecord`, which
11
+ * checks the signature and the signer, refuses a sequence number not newer than the one it holds,
12
+ * and replaces the peer's addresses with the record's — the same code path identify feeds. What
13
+ * this module adds is the part libp2p leaves to the caller: a cheap structural peek so the common
14
+ * path (a neighbour re-sending records we already hold) never reaches the signature check, and a
15
+ * sealer for *this* node's record, since libp2p keeps no self record in the peerStore (identify
16
+ * seals one per request and discards it).
17
+ */
18
+ /** How many of this node's own addresses a self record carries — bounds the record's size. */
19
+ export declare const MAX_SELF_RECORD_ADDRS = 8;
20
+ /**
21
+ * How long a record for a peer we are **not** connected to keeps being forwarded after we last
22
+ * accepted it. Measured on our own clock (`PeerEntry.addressRecord.confirmedAt`) — never on a
23
+ * timestamp a remote supplied — so clock skew between peers cannot extend it. A stale record
24
+ * cannot be refreshed by re-hearing it either (an equal sequence number is refused), so a record
25
+ * keeps circulating only while some node is connected to its subject or the subject re-seals.
26
+ */
27
+ export declare const ADDRESS_HINT_FORWARD_MAX_AGE_MS: number;
28
+ /**
29
+ * How long a peer's record survives in an exported routing table, measured from when we last
30
+ * confirmed it (a peer connected at export time counts as confirmed at that moment). Older
31
+ * records are left out of the export and dropped on import.
32
+ *
33
+ * Far longer than the forwarding window, because the two answer different questions: forwarding
34
+ * vouches for a record to *other* peers, while a persisted record only seeds *our own* first
35
+ * dials after a restart — the case where nothing else knows the address at all (gotchoices/sereus#18).
36
+ * The cost of keeping one too long is a dead address, and a failed relayed dial costs seconds
37
+ * (Sereus measured up to 14 s at a 3 s link round trip), so every stale record slows a restart;
38
+ * 14 days still covers a node switched off for a holiday. No separate count cap: at most one
39
+ * record rides on each routing-table entry, so the table's capacity already bounds them.
40
+ */
41
+ export declare const ADDRESS_RECORD_PERSIST_MAX_AGE_MS: number;
42
+ /**
43
+ * The export half of the persist-age rule, for one entry the store serialized: a peer connected
44
+ * at export time is confirmed *now* (a live connection is the confirmation), and a record last
45
+ * confirmed more than {@link ADDRESS_RECORD_PERSIST_MAX_AGE_MS} ago is left out.
46
+ */
47
+ export declare function freshenExportedAddressRecord(entry: SerializedPeerEntry, now: number, isConnected: (id: string) => boolean): SerializedPeerEntry;
48
+ /**
49
+ * The import-time check of one persisted entry's address record — structural, no signature
50
+ * check, run over the whole table before anything is written. The signature is verified once,
51
+ * later, inside `consumePeerRecord`.
52
+ *
53
+ * - **Corrupt** → throws, so the caller refuses the whole table: an envelope that is not base64url
54
+ * or does not decode as a signed peer record, a `confirmedAt` that is not a finite number, or a
55
+ * signer or payload peer id that is not the entry's own id. Our own export cannot produce any of
56
+ * these, so the file was damaged.
57
+ * - **Stale** (last confirmed more than {@link ADDRESS_RECORD_PERSIST_MAX_AGE_MS} ago) → the record
58
+ * is dropped and the entry kept. Age is not damage.
59
+ * - A `confirmedAt` later than `now` is clamped to `now`: our clock went backwards since the
60
+ * export, or the file was edited — and an edited file is the table-signing work's concern
61
+ * (`tickets/backlog/23-debt-routing-table-export-signing.md`), not something an age check can see.
62
+ */
63
+ export declare function vetPersistedAddressRecord(entry: SerializedPeerEntry, now: number): SerializedPeerEntry;
64
+ /** The base64url string a record travels as on the wire. */
65
+ export declare function encodeAddressRecord(envelope: Uint8Array): string;
66
+ /** Inverse of {@link encodeAddressRecord}. Throws on a string that is not base64url. */
67
+ export declare function decodeAddressRecord(record: string): Uint8Array;
68
+ /** What {@link peekPeerRecord} reads off an envelope without verifying it. */
69
+ export interface PeekedPeerRecord {
70
+ /** Peer id derived from the envelope's public key — who signed it. */
71
+ signer: string;
72
+ /** Peer id the payload names — whose addresses it claims to be. */
73
+ peerId: string;
74
+ seq: bigint;
75
+ }
76
+ /**
77
+ * Decode an envelope and its peer record **structurally** — no signature check, no domain check,
78
+ * never throws. `undefined` means the bytes do not parse as an envelope over a peer record.
79
+ *
80
+ * Two uses. The identity pre-check: a receiver must refuse a record whose `signer` or `peerId`
81
+ * is not the id it was labelled with *before* it reaches `consumePeerRecord`, because that call
82
+ * checks only the signer against `expectedPeer` and then patches the *payload's* peer id blindly —
83
+ * so a peer could sign a record that rewrites another peer's addresses. And the sequence pre-check:
84
+ * comparing `seq` against the record already held is what keeps a re-sent record free of crypto.
85
+ */
86
+ export declare function peekPeerRecord(bytes: Uint8Array): PeekedPeerRecord | undefined;
87
+ /**
88
+ * This node's addresses as its self record should state them.
89
+ *
90
+ * Projected the way identify does — `decapsulateCode(CODE_P2P)` strips the trailing `/p2p/<self>`
91
+ * the address manager appends, since a `PeerRecord` names its peer once, in `peerId` — deduped,
92
+ * and then two rules of our own:
93
+ *
94
+ * - A circuit address with **no relay hop** (the bare `/p2p-circuit` listen placeholder, reported
95
+ * from start-up until a reservation lands) is dropped: nobody can dial it.
96
+ * - **Reserved** circuit addresses (`…/p2p/<relay>/p2p-circuit`) come first. For a NAT-only peer
97
+ * they are the only addresses a third party can use, so they must survive the cap.
98
+ *
99
+ * Nothing else is filtered — no public/private test. The address manager already applied the
100
+ * host's announce / noAnnounce configuration, and a private address is harmless in a record
101
+ * (a dial to it fails fast) where a missing circuit address is the whole bug this exists to fix.
102
+ * Capped at {@link MAX_SELF_RECORD_ADDRS}.
103
+ */
104
+ export declare function selfRecordAddrs(node: Libp2p): Multiaddr[];
105
+ /**
106
+ * This node's own signed address record, sealed lazily and cached until its address list changes.
107
+ *
108
+ * Lazy rather than event-driven on purpose: a snapshot build asks {@link current}, which compares
109
+ * the projected address list against the one last sealed and re-seals only on a difference. That
110
+ * catches a reservation landing after `start()` (the next snapshot carries a fresher record with a
111
+ * higher sequence number, which receivers accept over the earlier one) without a listener that
112
+ * `stop()` would have to detach.
113
+ */
114
+ export declare class SelfAddressRecord {
115
+ /** The address list the cached record describes, as one string. `''` = nothing sealed yet
116
+ * — which is also the key of an empty address list, and both mean "no record". */
117
+ private sealedFor;
118
+ private cached;
119
+ private lastSeq;
120
+ /**
121
+ * Bumped at the start of every seal. A seal that finds it changed after its signature
122
+ * returns was overtaken by a seal for a *newer* address list, so it must not commit: two
123
+ * snapshot builds straddling a reservation landing would otherwise let the earlier (lower
124
+ * sequence number) record land in the cache last and ride the next snapshot.
125
+ */
126
+ private generation;
127
+ /** A seal in progress, so two concurrent snapshot builds share one signature. */
128
+ private inflight;
129
+ /**
130
+ * The base64url record for `node`'s current addresses, or `undefined` when there is nothing
131
+ * to advertise: no dialable address yet (a relay-only node before its reservation lands), or a
132
+ * record that would not fit {@link MAX_ADDRESS_RECORD_CHARS}.
133
+ */
134
+ current(node: Libp2p, privateKey: PrivateKey): Promise<string | undefined>;
135
+ private seal;
136
+ }