@optimystic/db-p2p 0.24.0 → 0.24.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 (60) hide show
  1. package/dist/src/cluster/service.d.ts +8 -0
  2. package/dist/src/cluster/service.d.ts.map +1 -1
  3. package/dist/src/cluster/service.js +16 -4
  4. package/dist/src/cluster/service.js.map +1 -1
  5. package/dist/src/cohort-topic/host.js +34 -11
  6. package/dist/src/cohort-topic/host.js.map +1 -1
  7. package/dist/src/cohort-topic/stream-util.d.ts +25 -11
  8. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  9. package/dist/src/cohort-topic/stream-util.js +31 -19
  10. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  11. package/dist/src/libp2p-key-network.d.ts +68 -0
  12. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  13. package/dist/src/libp2p-key-network.js +123 -14
  14. package/dist/src/libp2p-key-network.js.map +1 -1
  15. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  16. package/dist/src/libp2p-node-base.js +8 -5
  17. package/dist/src/libp2p-node-base.js.map +1 -1
  18. package/dist/src/logger.d.ts +2 -2
  19. package/dist/src/logger.js +2 -2
  20. package/dist/src/matchmaking/query-transport.js +3 -3
  21. package/dist/src/matchmaking/query-transport.js.map +1 -1
  22. package/dist/src/peer-address-book.d.ts +69 -0
  23. package/dist/src/peer-address-book.d.ts.map +1 -1
  24. package/dist/src/peer-address-book.js +110 -15
  25. package/dist/src/peer-address-book.js.map +1 -1
  26. package/dist/src/reactivity/notify-transport.d.ts +4 -4
  27. package/dist/src/reactivity/notify-transport.js +6 -6
  28. package/dist/src/reactivity/notify-transport.js.map +1 -1
  29. package/dist/src/reactivity/push-state-gossip.js +2 -2
  30. package/dist/src/reactivity/push-state-gossip.js.map +1 -1
  31. package/dist/src/reactivity/recover-transport.d.ts +6 -2
  32. package/dist/src/reactivity/recover-transport.d.ts.map +1 -1
  33. package/dist/src/reactivity/recover-transport.js +7 -3
  34. package/dist/src/reactivity/recover-transport.js.map +1 -1
  35. package/dist/src/repo/service.d.ts +6 -0
  36. package/dist/src/repo/service.d.ts.map +1 -1
  37. package/dist/src/repo/service.js +12 -2
  38. package/dist/src/repo/service.js.map +1 -1
  39. package/dist/src/routing/libp2p-known-peers.d.ts.map +1 -1
  40. package/dist/src/routing/libp2p-known-peers.js +5 -0
  41. package/dist/src/routing/libp2p-known-peers.js.map +1 -1
  42. package/dist/src/testing/cohort-topic-mesh-harness.d.ts +13 -6
  43. package/dist/src/testing/cohort-topic-mesh-harness.d.ts.map +1 -1
  44. package/dist/src/testing/cohort-topic-mesh-harness.js +15 -6
  45. package/dist/src/testing/cohort-topic-mesh-harness.js.map +1 -1
  46. package/package.json +3 -3
  47. package/src/cluster/service.ts +305 -293
  48. package/src/cohort-topic/host.ts +2932 -2901
  49. package/src/cohort-topic/stream-util.ts +147 -135
  50. package/src/libp2p-key-network.ts +1235 -1120
  51. package/src/libp2p-node-base.ts +1678 -1675
  52. package/src/logger.ts +27 -27
  53. package/src/matchmaking/query-transport.ts +492 -492
  54. package/src/peer-address-book.ts +266 -149
  55. package/src/reactivity/notify-transport.ts +144 -144
  56. package/src/reactivity/push-state-gossip.ts +291 -291
  57. package/src/reactivity/recover-transport.ts +412 -408
  58. package/src/repo/service.ts +323 -313
  59. package/src/routing/libp2p-known-peers.ts +31 -26
  60. package/src/testing/cohort-topic-mesh-harness.ts +673 -663
@@ -1,149 +1,266 @@
1
- import type { PeerId } from '@libp2p/interface'
2
- import { peerIdFromString } from '@libp2p/peer-id'
3
- import { multiaddr, type Multiaddr } from '@multiformats/multiaddr'
4
-
5
- /**
6
- * Cap on addresses merged per peer from one application-level message.
7
- *
8
- * Without a cap, a crafted cluster record or redirect payload could stuff the address
9
- * book and turn every cohort member into a dial amplifier aimed at an address of the
10
- * sender's choosing. This bounds the per-peer cost;
11
- * {@link MAX_LEARNED_PEERS_PER_RECORD} bounds how many peers one record may introduce.
12
- */
13
- export const MAX_MERGED_ADDRS_PER_PEER = 8
14
-
15
- /**
16
- * Cap on how many distinct peers one cluster record may teach us addresses for.
17
- *
18
- * A record's peer map is NOT self-limiting: `ClusterService.processOperation` learns from it
19
- * before `checkRedirect` and before `cluster.update` validates a single signature, and inbound
20
- * stream authorization is opt-in (`authorizeInboundStream` is undefined by default), so the map
21
- * is attacker-authored at that point. One 1 MiB control message (`MAX_CONTROL_MESSAGE_BYTES`)
22
- * holds on the order of a thousand fabricated `{ id, multiaddrs, publicKey }` entries, each of
23
- * which would otherwise become a persisted peerStore record. Real cohorts are `clusterSize`
24
- * peers — single digits — so this is generous margin, not a functional limit.
25
- */
26
- export const MAX_LEARNED_PEERS_PER_RECORD = 64
27
-
28
- /** The narrow slice of libp2p this module needs — a peer id and (optionally) a peerStore writer. */
29
- export interface PeerAddressBookHost {
30
- peerId: PeerId
31
- peerStore?: {
32
- merge?: (id: PeerId, data: { multiaddrs: Multiaddr[] }) => Promise<unknown>
33
- }
34
- }
35
-
36
- /** Log sink shaped like both `debug` loggers and libp2p's `Logger`. */
37
- export type AddressLog = (fmt: string, ...args: unknown[]) => void
38
-
39
- /**
40
- * Keep only the entries that parse as multiaddrs, logging (but not throwing on) the rest.
41
- *
42
- * The single validator for address strings arriving from the wire or from a connection —
43
- * `Libp2pKeyPeerNetwork.parseMultiaddrs` delegates here so there is one definition of
44
- * "an address string we are willing to carry".
45
- */
46
- export function validMultiaddrStrings(addrs: string[], log: AddressLog): string[] {
47
- const out: string[] = []
48
- for (const a of addrs) {
49
- try {
50
- // An empty string parses as the root multiaddr `/` syntactically fine, addresses
51
- // nothing, and encodes to zero bytes. Reject it so a blank entry can't occupy a slot
52
- // in the address book (or in the per-message cap).
53
- if (multiaddr(a).bytes.length === 0) {
54
- log('WARN: multiaddr addresses nothing %s', a)
55
- continue
56
- }
57
- out.push(a)
58
- } catch (err) {
59
- log('WARN: invalid multiaddr %s %o', a, err)
60
- }
61
- }
62
- return out
63
- }
64
-
65
- /**
66
- * Write dialable addresses for `peerId` into the libp2p address book, from addresses
67
- * carried by an application-level message.
68
- *
69
- * Trust boundary: a merged multiaddr only makes a dial *attempt* possible. The dialed
70
- * peer still authenticates by peer id at the noise handshake, so an address taken from
71
- * a record we have not otherwise verified can waste a dial but can never impersonate.
72
- * That is precisely why it is safe to consume addresses from an unverified message —
73
- * and why the cost, not the authenticity, is what needs bounding (see
74
- * {@link MAX_MERGED_ADDRS_PER_PEER}).
75
- */
76
- export function mergePeerAddresses(
77
- host: PeerAddressBookHost,
78
- peerId: PeerId,
79
- addrs: string[],
80
- log: AddressLog
81
- ): void {
82
- // A self entry is meaningless to our own dialer and, for a relay-only self, self-referential.
83
- if (peerId.toString() === host.peerId.toString()) return
84
- if (addrs.length === 0) return
85
-
86
- const merge = host.peerStore?.merge
87
- if (typeof merge !== 'function') return
88
-
89
- const valid = validMultiaddrStrings(addrs, log)
90
- if (valid.length === 0) return
91
- if (valid.length > MAX_MERGED_ADDRS_PER_PEER) {
92
- log('peer-address-book:capped peer=%s offered=%d kept=%d',
93
- peerId.toString().substring(0, 12), valid.length, MAX_MERGED_ADDRS_PER_PEER)
94
- }
95
- const multiaddrs = valid.slice(0, MAX_MERGED_ADDRS_PER_PEER).map(a => multiaddr(a))
96
-
97
- log('peer-address-book:merge peer=%s addrs=%d', peerId.toString().substring(0, 12), multiaddrs.length)
98
- // `merge` is async and nothing downstream awaits the address book — the very next dial
99
- // either sees the entry or falls back to the same failure it had before. Log a rejection
100
- // rather than swallowing it: a persistently failing peerStore is exactly the condition
101
- // that would make this whole mechanism silently inert.
102
- void Promise.resolve(merge.call(host.peerStore, peerId, { multiaddrs }))
103
- .catch((err: unknown) => log('WARN: peerStore.merge failed peer=%s %o', peerId.toString().substring(0, 12), err))
104
- }
105
-
106
- /** The peer map a `ClusterRecord` carries, as it arrives off the wire (nothing about it is trusted). */
107
- export type RecordPeerMap = Record<string, { multiaddrs?: string[] } | undefined>
108
-
109
- /**
110
- * Offer the addresses a cluster record carries for its cohort to an address-book `sink`, one
111
- * peer at a time.
112
- *
113
- * The one traversal shared by both record ingress points — `ClusterService` (inbound, from the
114
- * coordinator) and `ClusterClient` (outbound, from a member's reply) — so the entries a record is
115
- * allowed to introduce are bounded in one place rather than two. Entries with no addresses, with
116
- * an id equal to `skipId`, or with an unparseable id are dropped; everything past
117
- * {@link MAX_LEARNED_PEERS_PER_RECORD} candidates is dropped with a log line. The per-address
118
- * validation, the per-peer cap, and the trust boundary live behind `sink`
119
- * (see {@link mergePeerAddresses}).
120
- */
121
- export function mergeRecordPeerAddresses(
122
- peers: RecordPeerMap | undefined,
123
- sink: (peerId: PeerId, addrs: string[]) => void,
124
- log: AddressLog,
125
- skipId?: string
126
- ): void {
127
- let offered = 0
128
- for (const [idStr, peer] of Object.entries(peers ?? {})) {
129
- const addrs = peer?.multiaddrs ?? []
130
- if (addrs.length === 0 || idStr === skipId) continue
131
- if (offered >= MAX_LEARNED_PEERS_PER_RECORD) {
132
- // Count candidates, not successes, so a record full of unparseable ids cannot spend
133
- // unbounded parse attempts and log lines either.
134
- log('peer-address-book:record-capped kept=%d', MAX_LEARNED_PEERS_PER_RECORD)
135
- return
136
- }
137
- offered += 1
138
- let pid: PeerId
139
- try {
140
- pid = peerIdFromString(idStr)
141
- } catch (err) {
142
- // An id we cannot parse is not dialable by any route; the consensus path surfaces the
143
- // resulting membership failure on its own.
144
- log('WARN: record carried an unparseable peer id %s %o', idStr, err)
145
- continue
146
- }
147
- sink(pid, addrs)
148
- }
149
- }
1
+ import type { PeerId } from '@libp2p/interface'
2
+ import { peerIdFromString } from '@libp2p/peer-id'
3
+ import { multiaddr, type Component, type Multiaddr } from '@multiformats/multiaddr'
4
+
5
+ /**
6
+ * Cap on addresses merged per peer from one application-level message.
7
+ *
8
+ * Without a cap, a crafted cluster record or redirect payload could stuff the address
9
+ * book and turn every cohort member into a dial amplifier aimed at an address of the
10
+ * sender's choosing. This bounds the per-peer cost;
11
+ * {@link MAX_LEARNED_PEERS_PER_RECORD} bounds how many peers one record may introduce.
12
+ */
13
+ export const MAX_MERGED_ADDRS_PER_PEER = 8
14
+
15
+ /**
16
+ * Cap on how many distinct peers one cluster record may teach us addresses for.
17
+ *
18
+ * A record's peer map is NOT self-limiting: `ClusterService.processOperation` learns from it
19
+ * before `checkRedirect` and before `cluster.update` validates a single signature, and inbound
20
+ * stream authorization is opt-in (`authorizeInboundStream` is undefined by default), so the map
21
+ * is attacker-authored at that point. One 1 MiB control message (`MAX_CONTROL_MESSAGE_BYTES`)
22
+ * holds on the order of a thousand fabricated `{ id, multiaddrs, publicKey }` entries, each of
23
+ * which would otherwise become a persisted peerStore record. Real cohorts are `clusterSize`
24
+ * peers — single digits — so this is generous margin, not a functional limit.
25
+ */
26
+ export const MAX_LEARNED_PEERS_PER_RECORD = 64
27
+
28
+ /** The narrow slice of libp2p this module needs — a peer id and (optionally) a peerStore writer. */
29
+ export interface PeerAddressBookHost {
30
+ peerId: PeerId
31
+ peerStore?: {
32
+ merge?: (id: PeerId, data: { multiaddrs: Multiaddr[] }) => Promise<unknown>
33
+ }
34
+ }
35
+
36
+ /** Log sink shaped like both `debug` loggers and libp2p's `Logger`. */
37
+ export type AddressLog = (fmt: string, ...args: unknown[]) => void
38
+
39
+ /**
40
+ * Keep only the entries that parse as multiaddrs, logging (but not throwing on) the rest.
41
+ *
42
+ * The single validator for address strings arriving from the wire or from a connection —
43
+ * `Libp2pKeyPeerNetwork.parseMultiaddrs` delegates here so there is one definition of
44
+ * "an address string we are willing to carry".
45
+ */
46
+ export function validMultiaddrStrings(addrs: string[], log: AddressLog): string[] {
47
+ return addrs.filter(a => isCarriableMultiaddrString(a, log))
48
+ }
49
+
50
+ /** One address string's verdict, shared by {@link validMultiaddrStrings} and {@link publishableConnectionAddr}. */
51
+ function isCarriableMultiaddrString(addr: string, log: AddressLog): boolean {
52
+ try {
53
+ // An empty string parses as the root multiaddr `/` syntactically fine, addresses
54
+ // nothing, and encodes to zero bytes. Reject it so a blank entry can't occupy a slot
55
+ // in the address book (or in the per-message cap).
56
+ if (multiaddr(addr).bytes.length === 0) {
57
+ log('WARN: multiaddr addresses nothing %s', addr)
58
+ return false
59
+ }
60
+ return true
61
+ } catch (err) {
62
+ log('WARN: invalid multiaddr %s %o', addr, err)
63
+ return false
64
+ }
65
+ }
66
+
67
+ /**
68
+ * The slice of a libp2p `Connection` that decides whether its remote address may be published.
69
+ *
70
+ * `direction` is populated on every connection libp2p creates; it is optional here only because
71
+ * unit stubs build connection literals by hand and a missing `direction` is deliberately treated
72
+ * as NOT publishable, so a stub cannot silently opt back into the pre-fix behavior.
73
+ */
74
+ export interface DirectionalConnection {
75
+ direction?: 'inbound' | 'outbound'
76
+ remoteAddr?: { toString?: () => string }
77
+ }
78
+
79
+ /**
80
+ * A live connection's remote address, when it is one we may publish to a **third** party —
81
+ * otherwise `undefined`.
82
+ *
83
+ * The companion to {@link validMultiaddrStrings}: that answers "an address string we are willing
84
+ * to carry", this answers "an address we are willing to hand to someone else". They are not the
85
+ * same question, because an inbound connection's `remoteAddr` is not an address at all in the
86
+ * sense a third party needs. For an **outbound** connection it is the address we dialed — a real
87
+ * listen (or circuit) address that anyone can reach the peer on. For an **inbound** one it is the
88
+ * far side's *ephemeral source socket*: the port their operating system picked for this single
89
+ * connection. It is reachable by nobody else, it is indistinguishable from a listen address once
90
+ * it is on the wire, and it takes a slot against {@link MAX_MERGED_ADDRS_PER_PEER} in every peer
91
+ * that merges it. So the cure has to be here, at the producer.
92
+ *
93
+ * An inbound-only peer loses nothing by this: its own advertised addresses reach us through
94
+ * `identify`/`identifyPush` and are published from the peerStore instead.
95
+ */
96
+ export function publishableConnectionAddr(conn: DirectionalConnection, log: AddressLog): string | undefined {
97
+ if (conn.direction !== 'outbound') return undefined
98
+ const addr = conn.remoteAddr?.toString?.()
99
+ if (addr === undefined) return undefined
100
+ return isCarriableMultiaddrString(addr, log) ? addr : undefined
101
+ }
102
+
103
+ /**
104
+ * How useful the addresses we hold for a peer are **to this node's own dialer**.
105
+ *
106
+ * - `none` we hold no address at all. Nobody has told us how to reach the peer.
107
+ * - `self-relay-only` — we hold addresses, but every one of them reaches the peer by relaying
108
+ * through *us*. Useful to everyone except us: to use one we would have to relay to the peer
109
+ * through ourselves.
110
+ * - `dialable` at least one address does not route through us, so a dial can be attempted.
111
+ */
112
+ export type SelfDialability = 'none' | 'self-relay-only' | 'dialable'
113
+
114
+ /**
115
+ * Does `addr` reach its target by relaying through `relayPeerId`?
116
+ *
117
+ * A circuit multiaddr names its relay in the `p2p` component immediately BEFORE the
118
+ * `p2p-circuit` marker — `/<transport>/p2p/<relay>/p2p-circuit[/p2p/<target>]`. So the question is
119
+ * answered by walking the address's components, not by testing the string for `/p2p/<id>/p2p-circuit`:
120
+ * the peer id after the marker (appended by libp2p's dial queue), a bare `/p2p-circuit` with no
121
+ * relay named, and multi-hop addresses with two circuit markers all read differently as text but
122
+ * classify correctly as components. True if ANY hop relays through `relayPeerId` — a chain that
123
+ * passes through us at any point is one we cannot open ourselves.
124
+ *
125
+ * Called with our own peer id, this is the "can WE dial this?" question. It is deliberately NOT
126
+ * the same question as {@link publishableConnectionAddr}'s: a self-relay address is perfectly
127
+ * publishable — a cohort sibling reaching a peer through our relay is the working path — and is
128
+ * simply unusable by the one node the circuit terminates on.
129
+ */
130
+ export function routesThroughRelay(addr: string, relayPeerId: string, log: AddressLog): boolean {
131
+ let components: Component[]
132
+ try {
133
+ components = multiaddr(addr).getComponents()
134
+ } catch (err) {
135
+ // Fail open. An address we cannot parse is not evidence of a self-relay loop, and the
136
+ // caller's fallback — dial it and let libp2p reject it — is the pre-existing behavior.
137
+ log('WARN: invalid multiaddr %s %o', addr, err)
138
+ return false
139
+ }
140
+ return components.some((component, i) =>
141
+ component.name === 'p2p-circuit' && isRelayComponent(components[i - 1], relayPeerId))
142
+ }
143
+
144
+ /**
145
+ * True when `component` is the `p2p` hop naming `relayPeerId` (absent/other component → false).
146
+ *
147
+ * `relayPeerId` is always a `PeerId.toString()`, i.e. base58btc. A multiaddr's `p2p` value usually
148
+ * is too — but it may equally be written as a CIDv1 libp2p-key string, and `@multiformats/multiaddr`
149
+ * keeps whichever form it was given rather than normalizing. String equality alone would therefore
150
+ * miss a CIDv1-form self-relay address arriving from the wire (`mergePeerAddresses` accepts any
151
+ * parseable multiaddr). The canonical compare runs only when the cheap one fails, and only for the
152
+ * single component sitting in front of a circuit marker, so the parse is bounded to circuit
153
+ * addresses rather than paid per address.
154
+ */
155
+ function isRelayComponent(component: Component | undefined, relayPeerId: string): boolean {
156
+ if (component?.name !== 'p2p' || component.value === undefined) return false
157
+ if (component.value === relayPeerId) return true
158
+ try {
159
+ return peerIdFromString(component.value).toString() === relayPeerId
160
+ } catch {
161
+ // multiaddr accepted the component but we cannot read it back as a peer id; we simply
162
+ // cannot claim it is ours, and the address stays dialable-as-far-as-we-know.
163
+ return false
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Classify what the addresses we hold for one peer are worth to our own dialer.
169
+ *
170
+ * `self-relay-only` is the state a relay lands in for its own reservation holders: the address
171
+ * such a client advertises — and therefore the one we learn through `identifyPush` and store — is
172
+ * `/<our transport addr>/p2p/<our peer id>/p2p-circuit`. Dialing it is guaranteed to fail, and the
173
+ * failure is indistinguishable from `none` in libp2p's error text, so the two are separated here
174
+ * instead. Nothing can repair it from our side: once the client's connection drops only the client
175
+ * can re-initiate, so the useful response is to fail fast and let the caller move on.
176
+ */
177
+ export function classifySelfDialability(addrs: string[], selfPeerId: string, log: AddressLog): SelfDialability {
178
+ if (addrs.length === 0) return 'none'
179
+ return addrs.some(addr => !routesThroughRelay(addr, selfPeerId, log)) ? 'dialable' : 'self-relay-only'
180
+ }
181
+
182
+ /**
183
+ * Write dialable addresses for `peerId` into the libp2p address book, from addresses
184
+ * carried by an application-level message.
185
+ *
186
+ * Trust boundary: a merged multiaddr only makes a dial *attempt* possible. The dialed
187
+ * peer still authenticates by peer id at the noise handshake, so an address taken from
188
+ * a record we have not otherwise verified can waste a dial but can never impersonate.
189
+ * That is precisely why it is safe to consume addresses from an unverified message —
190
+ * and why the cost, not the authenticity, is what needs bounding (see
191
+ * {@link MAX_MERGED_ADDRS_PER_PEER}).
192
+ */
193
+ export function mergePeerAddresses(
194
+ host: PeerAddressBookHost,
195
+ peerId: PeerId,
196
+ addrs: string[],
197
+ log: AddressLog
198
+ ): void {
199
+ // A self entry is meaningless to our own dialer and, for a relay-only self, self-referential.
200
+ if (peerId.toString() === host.peerId.toString()) return
201
+ if (addrs.length === 0) return
202
+
203
+ const merge = host.peerStore?.merge
204
+ if (typeof merge !== 'function') return
205
+
206
+ const valid = validMultiaddrStrings(addrs, log)
207
+ if (valid.length === 0) return
208
+ if (valid.length > MAX_MERGED_ADDRS_PER_PEER) {
209
+ log('peer-address-book:capped peer=%s offered=%d kept=%d',
210
+ peerId.toString().substring(0, 12), valid.length, MAX_MERGED_ADDRS_PER_PEER)
211
+ }
212
+ const multiaddrs = valid.slice(0, MAX_MERGED_ADDRS_PER_PEER).map(a => multiaddr(a))
213
+
214
+ log('peer-address-book:merge peer=%s addrs=%d', peerId.toString().substring(0, 12), multiaddrs.length)
215
+ // `merge` is async and nothing downstream awaits the address book — the very next dial
216
+ // either sees the entry or falls back to the same failure it had before. Log a rejection
217
+ // rather than swallowing it: a persistently failing peerStore is exactly the condition
218
+ // that would make this whole mechanism silently inert.
219
+ void Promise.resolve(merge.call(host.peerStore, peerId, { multiaddrs }))
220
+ .catch((err: unknown) => log('WARN: peerStore.merge failed peer=%s %o', peerId.toString().substring(0, 12), err))
221
+ }
222
+
223
+ /** The peer map a `ClusterRecord` carries, as it arrives off the wire (nothing about it is trusted). */
224
+ export type RecordPeerMap = Record<string, { multiaddrs?: string[] } | undefined>
225
+
226
+ /**
227
+ * Offer the addresses a cluster record carries for its cohort to an address-book `sink`, one
228
+ * peer at a time.
229
+ *
230
+ * The one traversal shared by both record ingress points — `ClusterService` (inbound, from the
231
+ * coordinator) and `ClusterClient` (outbound, from a member's reply) — so the entries a record is
232
+ * allowed to introduce are bounded in one place rather than two. Entries with no addresses, with
233
+ * an id equal to `skipId`, or with an unparseable id are dropped; everything past
234
+ * {@link MAX_LEARNED_PEERS_PER_RECORD} candidates is dropped with a log line. The per-address
235
+ * validation, the per-peer cap, and the trust boundary live behind `sink`
236
+ * (see {@link mergePeerAddresses}).
237
+ */
238
+ export function mergeRecordPeerAddresses(
239
+ peers: RecordPeerMap | undefined,
240
+ sink: (peerId: PeerId, addrs: string[]) => void,
241
+ log: AddressLog,
242
+ skipId?: string
243
+ ): void {
244
+ let offered = 0
245
+ for (const [idStr, peer] of Object.entries(peers ?? {})) {
246
+ const addrs = peer?.multiaddrs ?? []
247
+ if (addrs.length === 0 || idStr === skipId) continue
248
+ if (offered >= MAX_LEARNED_PEERS_PER_RECORD) {
249
+ // Count candidates, not successes, so a record full of unparseable ids cannot spend
250
+ // unbounded parse attempts and log lines either.
251
+ log('peer-address-book:record-capped kept=%d', MAX_LEARNED_PEERS_PER_RECORD)
252
+ return
253
+ }
254
+ offered += 1
255
+ let pid: PeerId
256
+ try {
257
+ pid = peerIdFromString(idStr)
258
+ } catch (err) {
259
+ // An id we cannot parse is not dialable by any route; the consensus path surfaces the
260
+ // resulting membership failure on its own.
261
+ log('WARN: record carried an unparseable peer id %s %o', idStr, err)
262
+ continue
263
+ }
264
+ sink(pid, addrs)
265
+ }
266
+ }