@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,492 +1,492 @@
1
- /**
2
- * Matchmaking `QueryV1` RPC — cohort serve side over real libp2p (`docs/matchmaking.md` §Seeker query).
3
- *
4
- * This file holds BOTH halves of the seeker query transport. **Serve half**
5
- * ({@link createMatchmakingQueryHandler}): a remote seeker dials a cohort with a
6
- * {@link QueryV1} and this handler answers with the cohort's locally-held provider/seeker registrations,
7
- * signed by the node's peer key. It is the production binding that the in-process mock harness
8
- * (`testing/matchmaking-mesh-harness.ts` `queryCohort`) stubbed — the pure {@link handleMatchmakingQuery}
9
- * (filter + truncation + entry building + single-member reply signature) is unchanged; this only resolves
10
- * the serving engine off the live {@link CoordRegistry} and rides the cohort-topic
11
- * {@link handleRequestResponse} stream lifecycle (read one bounded frame → reply one frame).
12
- *
13
- * **Client half** ({@link createLibp2pMatchmakingTransport} / {@link createLibp2pMatchmakingSeekerSession}):
14
- * the real-socket {@link SeekerWalkTransport} the seeker walk client drives. It dials the FRET-routed
15
- * primary's cohort-topic `/register` (a signed seeker `RegisterV1`, self-vouched at tier 0) and the
16
- * matchmaking `/query` directly (the production analogue of the in-process mock harness
17
- * `buildWalkTransport` + `queryCohort`), maps a no-reply / dial failure to a benign empty advisory reply,
18
- * and re-validates every forwarded entry's `registrationSig` itself ({@link verifyEntry}) — the cohort
19
- * vouches only for "what I held", never provider authenticity.
20
- *
21
- * **Layering.** Matchmaking sits *above* the cohort-topic substrate (it depends on
22
- * `decodeMatchAppPayload`), so it owns its own protocol family ({@link MatchmakingProtocols}) and is wired
23
- * at the composition root (`libp2p-node-base.ts`) using only the host's public surface
24
- * (`registry.findServing` / `registry.findByCoord`, `engine.records` / `engine.topicTraffic` /
25
- * `engine.cohort`). Nothing here reaches into `host.ts` internals — it mirrors the reactivity precedent.
26
- *
27
- * **Single-tier-0 serve.** Matchmaking serves a single tier-0 cohort (the cohort-topic single-tier-0
28
- * milestone); a *serving* tier-`d ≥ 1` query is gated on the promotion follow-ons, exactly as the seeker
29
- * walk is mock-tier-tagged-unimplemented. So this resolves the tier-0 engine for `coord_0(topicId)` only.
30
- *
31
- * **No-engine = no reply (anti-DoS).** A query for a topic this node holds no serving engine for produces
32
- * **no reply frame** — the handler never instantiates a `CoordEngine` from an inbound query (that would be
33
- * a DoS amplifier). The seeker maps a no-reply to a benign empty advisory result (the seeker ticket owns
34
- * that mapping).
35
- *
36
- * **Per-coord scoping.** The reply is built from exactly the `coord_0(topicId)` engine's store, nothing
37
- * cross-coord — matching how the register handler recomputes a served coord per frame. The serve side does
38
- * NOT re-verify each entry's `registrationSig` (that is the seeker's re-validation via `verifyProviderEntry`);
39
- * it forwards `rec` fields verbatim through `evaluateQuery` and must never fabricate or alter `registrationSig`.
40
- */
41
-
42
- import type { Libp2p } from "libp2p";
43
- import type { PeerId, PrivateKey } from "@libp2p/interface";
44
- import type { FretService } from "p2p-fret";
45
- import { peerIdFromString } from "@libp2p/peer-id";
46
- import { randomBytes } from "@noble/hashes/utils.js";
47
- import {
48
- b64urlToBytes,
49
- bytesToB64url,
50
- createTierAddressing,
51
- decodeCohortMessage,
52
- decodeQueryReplyV1,
53
- decodeQueryV1,
54
- encodeCohortMessage,
55
- encodeQueryReplyV1,
56
- encodeQueryV1,
57
- makeDMaxComputer,
58
- registerSigningPayload,
59
- bootstrapBoundImage,
60
- serializeBootstrapEvidenceEnvelope,
61
- validateRegisterReplyV1,
62
- MatchmakingSeeker,
63
- RingHash,
64
- Tier,
65
- DEFAULT_FANOUT,
66
- QUERY_LIMIT_MAX,
67
- SEEKER_TTL_MS,
68
- type CapabilityFilter,
69
- type EntrySigVerifier,
70
- type HangOutConfig,
71
- type MatchTopicAnchor,
72
- type QueryReplyV1,
73
- type QueryV1,
74
- type RegisterReplyV1,
75
- type RegisterV1,
76
- type TierAddressing,
77
- } from "@optimystic/db-core";
78
- import type { CoordRegistry } from "../cohort-topic/host.js";
79
- import { peerIdToBytes } from "../cohort-topic/peer-codec.js";
80
- import { signPeer, verifyPeerSig } from "../cohort-topic/peer-sig.js";
81
- import { FretSizeEstimator } from "../cohort-topic/size-estimator.js";
82
- import { handleRequestResponse, requestResponse, DEFAULT_STREAM_MAX_BYTES } from "../cohort-topic/stream-util.js";
83
- import { DEFAULT_COHORT_TOPIC_PROTOCOLS } from "../cohort-topic/protocols.js";
84
- import { handleMatchmakingQuery } from "./query-handler.js";
85
- import { PROTOCOL_MATCHMAKING_QUERY, DEFAULT_MATCHMAKING_PROTOCOLS } from "./protocols.js";
86
- import { MatchmakingSeekerSession, type MatchmakingSeekerSessionDeps } from "./module.js";
87
- import type { SeekerWalkTransport, SeekerProbeReply } from "./seeker-walk-client.js";
88
- import { createLogger } from "../logger.js";
89
-
90
- const log = createLogger("matchmaking-query");
91
-
92
- /** Everything the matchmaking `QueryV1` serve handler needs from the live node, all injected. */
93
- export interface MatchmakingQueryServeDeps {
94
- /** The host's per-served-coord cohort registry (`host.registry`) — read-only lookups, never `forCoord`. */
95
- readonly registry: CoordRegistry;
96
- /**
97
- * Tier addressing used to derive `coord_0(topicId)` for the fallback engine lookup. Build one from
98
- * `createTierAddressing(createRingHash())` — byte-identical to the host's internal addressing for the
99
- * tier-0 coord (which is peer- and fanout-independent), exactly as the reactivity wiring does.
100
- */
101
- readonly addressing: ReturnType<typeof createTierAddressing>;
102
- /**
103
- * Sign the canonical {@link import("@optimystic/db-core").queryReplySigningPayload} with the node's peer
104
- * key; resolves the base64url signature (e.g. `async p => bytesToB64url(await signPeer(nodeKey, p))`).
105
- */
106
- readonly sign: (payload: Uint8Array) => Promise<string>;
107
- /**
108
- * Anti-DoS rate-limit seam (owned by backlog `matchmaking-query-rate-limit`). Default-allow when
109
- * omitted. Gate on **`from`** — the connection's verified `remotePeer` — NOT the self-asserted
110
- * `query.requesterId`. Return `false` to drop the query with no reply.
111
- */
112
- readonly gate?: (from: PeerId, topicId: Uint8Array) => boolean;
113
- /** Per-frame ceiling for encode/decode; default {@link DEFAULT_STREAM_MAX_BYTES}. */
114
- readonly maxBytes?: number;
115
- /** Wall clock (unix ms) stamped on the query-accounting bump; default {@link Date.now}. Injectable for tests. */
116
- readonly clock?: () => number;
117
- }
118
-
119
- /**
120
- * Build the matchmaking query serve callback for {@link handleRequestResponse}: decode (bounded) → resolve
121
- * the serving tier-0 engine → build + sign the reply, or `undefined` for **no reply** (a decode failure, a
122
- * gate rejection, no serving engine, or any build/sign/encode error). It never throws out of the stream
123
- * handler — every failure is logged and dropped to a clean no-reply, mirroring the cohort-topic / reactivity
124
- * serve handlers (which wrap the whole serve body, not just the decode).
125
- */
126
- export function createMatchmakingQueryHandler(
127
- deps: MatchmakingQueryServeDeps,
128
- ): (frame: Uint8Array, from: PeerId) => Promise<Uint8Array | undefined> {
129
- const maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
130
- return async (frame: Uint8Array, from: PeerId): Promise<Uint8Array | undefined> => {
131
- try {
132
- // `decodeQueryV1` = `validateQueryV1(decodeCohortMessage(...))` — bounds `limit` to QUERY_LIMIT_MAX,
133
- // so the handler passes `query` through unchanged (never re-clamps `limit`).
134
- const query = decodeQueryV1(frame, maxBytes);
135
- const topicId = b64urlToBytes(query.topicId);
136
-
137
- // Anti-DoS rate-limit seam (matchmaking-query-rate-limit). Default-allow; gate on the connection's
138
- // verified `from` peer, never the self-asserted `query.requesterId`.
139
- if (deps.gate !== undefined && !deps.gate(from, topicId)) {
140
- log("matchmaking query serve: rate-limited query from %s (no reply)", from.toString());
141
- return undefined;
142
- }
143
-
144
- // Resolve the serving tier-0 engine. `findServing(topicId, 0)` keys on `treeTier === 0 &&
145
- // servesTopic(topicId)` (true on the routed primary once it has admitted/replicated a registration);
146
- // `findByCoord(coord_0)` is the fallback for an instantiated-but-currently-recordless engine. We never
147
- // `forCoord` here — instantiating a CoordEngine from an inbound query would be a DoS amplifier.
148
- const coord0 = deps.addressing.coord0(topicId);
149
- const engine = deps.registry.findServing(topicId, 0) ?? deps.registry.findByCoord(coord0);
150
- if (engine === undefined) {
151
- // No serving engine on this node (seeker dialed a non-primary / pre-replication). No reply; the
152
- // seeker side treats this as an empty advisory result.
153
- return undefined;
154
- }
155
-
156
- // Query accounting (matchmaking-query-accounting-seam): a served query bumps `queriesPerMin` for the
157
- // topic on the served coord's TrafficCounters; surfaces in a later reply's snapshot (lags one round).
158
- // Sits AFTER the gate + no-engine guards (a dropped / unserved query never inflates the barometer) and
159
- // BEFORE the reply build (a query that resolved a serving engine is counted on serve, even if signing
160
- // later fails transiently). A decode failure threw before `topicId` resolved, so it never counts.
161
- engine.recordQuery(topicId, (deps.clock ?? Date.now)());
162
-
163
- // Build the reply from a single synchronous read (records + traffic + epoch) so a concurrent gossip
164
- // round cannot tear the snapshot between read and sign. `handleMatchmakingQuery` forwards each record's
165
- // fields verbatim through the pure `evaluateQuery` (capability filter + limit truncation + entry
166
- // building, including each provider's `registrationSig`) and single-member-signs the canonical reply.
167
- const reply = await handleMatchmakingQuery(query, {
168
- records: engine.records(topicId),
169
- topicTraffic: engine.topicTraffic(topicId),
170
- cohortEpoch: engine.cohort().cohortEpoch,
171
- sign: deps.sign,
172
- log,
173
- });
174
- return encodeQueryReplyV1(reply, maxBytes);
175
- } catch (err) {
176
- // Any failure — a malformed/foreign query (decode), an oversize reply (encode), or a transient
177
- // `sign` rejection — must never throw out of the stream handler: log + no reply. The outer
178
- // `handleRequestResponse` would otherwise abort the stream; a clean no-reply lets the seeker treat
179
- // it as a benign empty advisory result. Mirrors the reactivity recover serve handler exactly.
180
- log("matchmaking query serve: dropping query (no reply): %o", err);
181
- return undefined;
182
- }
183
- };
184
- }
185
-
186
- /**
187
- * Register the inbound matchmaking query protocol handler on `node` (request-reply: one {@link QueryV1}
188
- * frame in, one {@link import("@optimystic/db-core").QueryReplyV1} frame back, or no reply).
189
- */
190
- export function registerMatchmakingQueryHandler(
191
- node: Libp2p,
192
- protocol: string = PROTOCOL_MATCHMAKING_QUERY,
193
- deps: MatchmakingQueryServeDeps,
194
- ): void {
195
- const maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
196
- handleRequestResponse(node, protocol, createMatchmakingQueryHandler(deps), maxBytes);
197
- }
198
-
199
- // =================================================================================================
200
- // Client side: the real-libp2p SeekerWalkTransport + one-shot query + d_max estimate + entry verifier.
201
- // =================================================================================================
202
-
203
- /**
204
- * A self-routed-primary local-serve hook. `fret.assembleCohort(coord, k)[0]` may resolve to the seeker
205
- * itself; libp2p cannot dial self, so when the routed primary is `selfPeerId` the transport routes the
206
- * register/query here instead. Absent ⇒ a self-primary register/query throws a clear error (the gap is
207
- * loud, not a silent hang). The gated e2e seeker is deliberately a remote node, so its happy path never
208
- * self-dials; the production factory must still not hang on a self-primary.
209
- */
210
- export interface MatchmakingSelfServe {
211
- /** Serve a seeker register whose FRET-routed primary is this node. */
212
- register?(reg: RegisterV1): Promise<RegisterReplyV1>;
213
- /** Serve a query whose FRET-routed primary is this node; `undefined` ⇒ treated as an empty reply. */
214
- query?(q: QueryV1): Promise<QueryReplyV1 | undefined>;
215
- }
216
-
217
- /** Construction inputs for {@link createLibp2pMatchmakingTransport}. */
218
- export interface Libp2pMatchmakingTransportDeps {
219
- /** The live libp2p node the seeker dials cohorts from. */
220
- readonly node: Libp2p;
221
- /**
222
- * FRET engine: routes the cohort primary (`assembleCohort`) and feeds the `d_max` size estimate
223
- * (`getNetworkSizeEstimate`). The libp2p `node.services.fret` *wrapper* is accepted directly — it
224
- * keeps the size-estimate engine behind a lazy `ensure()`, which this factory unwraps internally.
225
- */
226
- readonly fret: FretService;
227
- /** This node's peer-id string — the query `requesterId`, the seeker `participantCoord`, the self-dial guard. */
228
- readonly selfPeerId: string;
229
- /** The seeker's node key — signs its own register frames + the tier-0 self-vouch reputation endorsement. */
230
- readonly key: PrivateKey;
231
- /** Cohort size `k` (FRET `assembleCohort` wants). */
232
- readonly wantK: number;
233
- /** Tier addressing; default {@link createTierAddressing}`(new RingHash())` — byte-identical to the host. */
234
- readonly addressing?: TierAddressing;
235
- /** Fan-out `F` for the `d_max` computer; default {@link DEFAULT_FANOUT}. */
236
- readonly fanout?: number;
237
- /** Per-frame ceiling; default {@link DEFAULT_STREAM_MAX_BYTES}. */
238
- readonly maxBytes?: number;
239
- /** Protocol id overrides; default the cohort-topic `register` + matchmaking `query` canonical ids. */
240
- readonly protocols?: { readonly register?: string; readonly query?: string };
241
- /** Seeker registration TTL carried on the walk's register frames (ms); default {@link SEEKER_TTL_MS}. */
242
- readonly seekerTtlMs?: number;
243
- /** Advertised demand carried in the seeker register payload (advisory; distinct from the walk's wantCount). Default 1. */
244
- readonly seekerWantCount?: number;
245
- /** Contact hint carried in the seeker register payload; default {@link Libp2pMatchmakingTransportDeps.selfPeerId}. */
246
- readonly contactHint?: string;
247
- /** Optional capability filter applied to the seeker register payload + the walk's `/query` frames. */
248
- readonly filter?: CapabilityFilter;
249
- /** Local-serve hook for a self-routed primary; absent ⇒ a self-primary register/query throws (loud, not silent). */
250
- readonly selfServe?: MatchmakingSelfServe;
251
- }
252
-
253
- /** The seeker-side seams a {@link MatchmakingSeekerSession} / the seeker walk client consume over a live node. */
254
- export interface Libp2pMatchmakingTransport {
255
- /** Build the walk transport (register/query/renew/withdraw at a tree tier) for a topic. */
256
- walkTransport(topicId: Uint8Array): SeekerWalkTransport;
257
- /** Issue a one-shot `QueryV1` (resolves the cohort from `q.topicId`'s tier-0 coord). */
258
- queryCohort(q: QueryV1): Promise<QueryReplyV1>;
259
- /** Estimate `d_max` for a topic (FRET size estimate → the db-core `d_max` computer). */
260
- estimateDMax(topicId: Uint8Array): Promise<number>;
261
- /** Per-entry signature verifier (`verifyPeerSig` over the participant's Ed25519 peer key). */
262
- readonly verifyEntry: EntrySigVerifier;
263
- }
264
-
265
- /**
266
- * Unwrap the libp2p `node.services.fret` wrapper (which exposes `assembleCohort` / `routeAct` but keeps
267
- * the size-estimate engine behind a lazy `ensure()`) to the full FRET engine the `d_max` estimator needs.
268
- * A raw engine (no `ensure`, e.g. an injected test double) is returned as-is. Mirrors the node-base
269
- * `resolveFretEngine`; both observe the same underlying routing store, so `assembleCohort` agrees.
270
- */
271
- function resolveFretEngine(fret: FretService): FretService {
272
- const candidate = fret as unknown as { ensure?: () => FretService };
273
- return typeof candidate.ensure === "function" ? candidate.ensure() : fret;
274
- }
275
-
276
- /**
277
- * Build the real-libp2p seeker query transport: the production analogue of the in-process mock harness's
278
- * `buildWalkTransport` + `queryCohort`. Routing model — dial the FRET-routed primary directly: for a tier
279
- * `d`, `coord = d === 0 ? coord0(topicId) : coordD(d, seekerBytes, topicId)` and the primary is
280
- * `assembleCohort(coord, wantK)[0]` (the same primary the host's direct-dial `/register` path serves).
281
- */
282
- export function createLibp2pMatchmakingTransport(deps: Libp2pMatchmakingTransportDeps): Libp2pMatchmakingTransport {
283
- const { node, selfPeerId, key, wantK } = deps;
284
- const fret = resolveFretEngine(deps.fret);
285
- const addressing = deps.addressing ?? createTierAddressing(new RingHash());
286
- const fanout = deps.fanout ?? DEFAULT_FANOUT;
287
- const maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
288
- const registerProtocol = deps.protocols?.register ?? DEFAULT_COHORT_TOPIC_PROTOCOLS.register;
289
- const queryProtocol = deps.protocols?.query ?? DEFAULT_MATCHMAKING_PROTOCOLS.query;
290
- const seekerTtlMs = deps.seekerTtlMs ?? SEEKER_TTL_MS;
291
- const seekerWantCount = deps.seekerWantCount ?? 1;
292
- const contactHint = deps.contactHint ?? selfPeerId;
293
- const filter = deps.filter;
294
- const selfServe = deps.selfServe;
295
- const seekerBytes = peerIdToBytes(peerIdFromString(selfPeerId));
296
- const signImage = async (payload: Uint8Array): Promise<string> => bytesToB64url(await signPeer(key, payload));
297
- const dMaxComputer = makeDMaxComputer({ estimator: new FretSizeEstimator(fret), F: fanout });
298
-
299
- const verifyEntry: EntrySigVerifier = (signerId, payload, signature) => verifyPeerSig(signerId, payload, signature);
300
-
301
- /** A benign empty advisory reply. Client-internal (never encoded/validated); the walk reads only `.providers`. */
302
- const emptyReply = (): QueryReplyV1 => ({
303
- v: 1,
304
- providers: [],
305
- truncated: false,
306
- cohortEpoch: "",
307
- topicTraffic: { windowSeconds: 0, arrivalsPerMin: 0, queriesPerMin: 0, directParticipants: 0, childCohortCount: 0 },
308
- signature: "",
309
- });
310
-
311
- /** The FRET-routed primary peer-id for a cohort coord (`assembleCohort(coord, k)[0]`), or `undefined`. */
312
- const primaryFor = (coord: Uint8Array): string | undefined => fret.assembleCohort(coord, wantK)[0];
313
-
314
- /** Build a `QueryV1` for `topicId` carrying the transport's advisory filter. */
315
- const buildQuery = (topicId: Uint8Array): QueryV1 => ({
316
- v: 1,
317
- topicId: bytesToB64url(topicId),
318
- includeProviders: true,
319
- includeSeekers: false,
320
- limit: QUERY_LIMIT_MAX,
321
- requesterId: selfPeerId,
322
- timestamp: Date.now(),
323
- signature: "AA",
324
- ...(filter !== undefined ? { filter } : {}),
325
- });
326
-
327
- /** Dial the tier-0 primary for `q.topicId`'s cohort and decode the reply; benign empty reply on no-frame/failure. */
328
- const dialQuery = async (q: QueryV1): Promise<QueryReplyV1> => {
329
- const topicId = b64urlToBytes(q.topicId);
330
- const primary = primaryFor(addressing.coord0(topicId));
331
- if (primary === undefined) {
332
- return emptyReply();
333
- }
334
- if (primary === selfPeerId) {
335
- if (selfServe?.query !== undefined) {
336
- return (await selfServe.query(q)) ?? emptyReply();
337
- }
338
- throw new Error("matchmaking query: FRET routed the cohort primary to self; provide deps.selfServe.query");
339
- }
340
- try {
341
- const frame = await requestResponse(node, peerIdFromString(primary), queryProtocol, encodeQueryV1(q, maxBytes), maxBytes);
342
- // The serve handler returns no frame for a topic it does not serve; map it (and any decode/dial
343
- // failure) to a benign empty reply so SeekerWalkClient.collect keeps walking rather than throwing.
344
- return frame.length === 0 ? emptyReply() : decodeQueryReplyV1(frame, maxBytes);
345
- } catch (err) {
346
- log("matchmaking query: dial/decode failed for primary %s (empty reply): %o", primary, err);
347
- return emptyReply();
348
- }
349
- };
350
-
351
- /** Build a signed seeker `RegisterV1` at `treeTier` (tier-0 carries the self-vouch bootstrap evidence). */
352
- const buildSeekerRegister = async (topicId: Uint8Array, treeTier: number, appPayload: Uint8Array): Promise<RegisterV1> => {
353
- const baseBody: Omit<RegisterV1, "signature"> = {
354
- v: 1,
355
- topicId: bytesToB64url(topicId),
356
- tier: Tier.T2,
357
- treeTier,
358
- participantCoord: bytesToB64url(seekerBytes),
359
- ttl: seekerTtlMs,
360
- // A tier-0 probe is the cold-root bootstrap (needs evidence on a configured node); a tier-`d>0`
361
- // probe is a plain walk step that falls through `no_state` on a cohort that does not serve the topic.
362
- bootstrap: treeTier === 0,
363
- timestamp: Date.now(),
364
- correlationId: bytesToB64url(randomBytes(16)),
365
- appPayload: bytesToB64url(appPayload),
366
- };
367
- // A configured production node gates a T2 `bootstrap: true` register on bootstrap evidence, so the
368
- // tier-0 register attaches a self-vouch reputation endorsement (the seeker peer-key-signs its own
369
- // `bootstrapBoundImage` as referee), exactly as the provider/reactivity integration tests do.
370
- // `bootstrapBoundImage` binds only (topicId, tier, participantCoord, timestamp), so the endorsement is
371
- // attached to the body BEFORE the final register sign (`registerSigningPayload` covers it).
372
- const body: Omit<RegisterV1, "signature"> = treeTier === 0
373
- ? {
374
- ...baseBody,
375
- bootstrapEvidence: serializeBootstrapEvidenceEnvelope({
376
- v: 1,
377
- reputation: { referee: bytesToB64url(seekerBytes), sig: bytesToB64url(await signPeer(key, bootstrapBoundImage(baseBody))) },
378
- }),
379
- }
380
- : baseBody;
381
- return { ...body, signature: bytesToB64url(await signPeer(key, registerSigningPayload(body))) };
382
- };
383
-
384
- /** Dial the routed primary's `/register` with the signed seeker frame and decode the `RegisterReplyV1`. */
385
- const dialRegister = async (primary: string, reg: RegisterV1): Promise<RegisterReplyV1> => {
386
- if (primary === selfPeerId) {
387
- if (selfServe?.register !== undefined) {
388
- return selfServe.register(reg);
389
- }
390
- throw new Error("matchmaking register: FRET routed the cohort primary to self; provide deps.selfServe.register");
391
- }
392
- const frame = await requestResponse(node, peerIdFromString(primary), registerProtocol, encodeCohortMessage(reg, maxBytes), maxBytes);
393
- return validateRegisterReplyV1(decodeCohortMessage(frame, maxBytes));
394
- };
395
-
396
- /** Map a `RegisterReplyV1` to the walk's {@link SeekerProbeReply} (pass `result`; copy traffic/targetTier). */
397
- const toProbeReply = (reply: RegisterReplyV1): SeekerProbeReply => {
398
- const out: { result: SeekerProbeReply["result"]; topicTraffic?: QueryReplyV1["topicTraffic"]; targetTier?: number } = {
399
- result: reply.result,
400
- };
401
- if ((reply.result === "accepted" || reply.result === "promoted") && reply.topicTraffic !== undefined) {
402
- out.topicTraffic = reply.topicTraffic;
403
- }
404
- if (reply.targetTier !== undefined) {
405
- out.targetTier = reply.targetTier;
406
- }
407
- return out;
408
- };
409
-
410
- const walkTransport = (topicId: Uint8Array): SeekerWalkTransport => {
411
- const seekerState = new MatchmakingSeeker({
412
- topicId,
413
- wantCount: seekerWantCount,
414
- contactHint,
415
- sign: signImage,
416
- ...(filter !== undefined ? { filter } : {}),
417
- });
418
- return {
419
- register: async (treeTier: number): Promise<SeekerProbeReply> => {
420
- const coord = addressing.coord(treeTier, seekerBytes, topicId);
421
- const primary = primaryFor(coord);
422
- if (primary === undefined) {
423
- // FRET has not assembled a cohort for this coord yet: treat as cold (the walk steps on).
424
- return { result: "no_state" };
425
- }
426
- const reg = await buildSeekerRegister(topicId, treeTier, await seekerState.appPayloadBytes());
427
- return toProbeReply(await dialRegister(primary, reg));
428
- },
429
- // The serve handler resolves the tier-0 engine only (single-tier-0 milestone), so the query always
430
- // targets the topic's tier-0 cohort regardless of the walk tier — matching the mock harness + one-shot.
431
- query: async (_treeTier: number): Promise<QueryReplyV1> => dialQuery(buildQuery(topicId)),
432
- // Hang-out keep-alive: the seeker's own query does not depend on its seeker record, and the brief
433
- // record lives in the cohort store for the walk's duration, so a re-touch is unnecessary for the
434
- // single-tier-0 milestone (mirrors the mock harness). A real renew would re-send a `RenewV1` ping.
435
- renew: async (): Promise<void> => { /* no-op (documented) */ },
436
- // Single-tier-0 walks reach the root and never escalate past it, so withdraw is effectively unreached;
437
- // the brief seeker record otherwise ages out by TTL (mirrors the mock harness).
438
- withdraw: async (): Promise<void> => { /* no-op (documented) */ },
439
- };
440
- };
441
-
442
- const queryCohort = async (q: QueryV1): Promise<QueryReplyV1> => dialQuery(q);
443
- const estimateDMax = async (_topicId: Uint8Array): Promise<number> => dMaxComputer.dMax();
444
-
445
- return { walkTransport, queryCohort, estimateDMax, verifyEntry };
446
- }
447
-
448
- /** Construction inputs for {@link createLibp2pMatchmakingSeekerSession} (transport deps + session knobs). */
449
- export interface Libp2pMatchmakingSeekerSessionDeps extends Libp2pMatchmakingTransportDeps {
450
- /** Participant-facing cohort-topic substrate API (the seeker's brief T2 registration via `session.register`). */
451
- readonly service: MatchmakingSeekerSessionDeps["service"];
452
- /** Topic anchor; defaults to db-core's ring-hash anchor. */
453
- readonly anchor?: MatchTopicAnchor;
454
- /** Hang-out decision config (passed to the walk). */
455
- readonly config?: HangOutConfig;
456
- /** Assumed competing-seeker mean `wantCount` (passed to the walk). */
457
- readonly meanWantCount?: number;
458
- /** Wall clock (unix ms); injectable for tests. */
459
- readonly clock?: () => number;
460
- /** Sleep for the requery cadence; injectable for tests. */
461
- readonly sleep?: (ms: number) => Promise<void>;
462
- /** TTL for `session.register`'s brief seeker registration (ms); default the manager's seeker TTL. */
463
- readonly registrationTtlMs?: number;
464
- }
465
-
466
- /**
467
- * Build a {@link MatchmakingSeekerSession} driveable over a live node: wires
468
- * {@link createLibp2pMatchmakingTransport} into the session's injected substrate seams, so the public
469
- * session layer (not just the lower-level walk client) runs over real sockets. `sweepPorts` stays
470
- * UNBOUND — the multi-cohort sweep needs the promoted-tree aggregate-count RPC (a separate follow-on) —
471
- * so `session.walk` is walk-only, the correct single-tier-0 behavior. The seeker register image is signed
472
- * with the node key (the same key the transport signs its walk frames with).
473
- */
474
- export function createLibp2pMatchmakingSeekerSession(deps: Libp2pMatchmakingSeekerSessionDeps): MatchmakingSeekerSession {
475
- const transport = createLibp2pMatchmakingTransport(deps);
476
- const sign = async (payload: Uint8Array): Promise<string> => bytesToB64url(await signPeer(deps.key, payload));
477
- const sessionDeps: MatchmakingSeekerSessionDeps = {
478
- service: deps.service,
479
- sign,
480
- verifyEntry: transport.verifyEntry,
481
- walkTransport: (topicId) => transport.walkTransport(topicId),
482
- queryCohort: (q) => transport.queryCohort(q),
483
- estimateDMax: (topicId) => transport.estimateDMax(topicId),
484
- ...(deps.anchor !== undefined ? { anchor: deps.anchor } : {}),
485
- ...(deps.config !== undefined ? { config: deps.config } : {}),
486
- ...(deps.meanWantCount !== undefined ? { meanWantCount: deps.meanWantCount } : {}),
487
- ...(deps.clock !== undefined ? { clock: deps.clock } : {}),
488
- ...(deps.sleep !== undefined ? { sleep: deps.sleep } : {}),
489
- ...(deps.registrationTtlMs !== undefined ? { ttlMs: deps.registrationTtlMs } : {}),
490
- };
491
- return new MatchmakingSeekerSession(sessionDeps);
492
- }
1
+ /**
2
+ * Matchmaking `QueryV1` RPC — cohort serve side over real libp2p (`docs/matchmaking.md` §Seeker query).
3
+ *
4
+ * This file holds BOTH halves of the seeker query transport. **Serve half**
5
+ * ({@link createMatchmakingQueryHandler}): a remote seeker dials a cohort with a
6
+ * {@link QueryV1} and this handler answers with the cohort's locally-held provider/seeker registrations,
7
+ * signed by the node's peer key. It is the production binding that the in-process mock harness
8
+ * (`testing/matchmaking-mesh-harness.ts` `queryCohort`) stubbed — the pure {@link handleMatchmakingQuery}
9
+ * (filter + truncation + entry building + single-member reply signature) is unchanged; this only resolves
10
+ * the serving engine off the live {@link CoordRegistry} and rides the cohort-topic
11
+ * {@link handleRequestResponse} stream lifecycle (read one bounded frame → reply one frame).
12
+ *
13
+ * **Client half** ({@link createLibp2pMatchmakingTransport} / {@link createLibp2pMatchmakingSeekerSession}):
14
+ * the real-socket {@link SeekerWalkTransport} the seeker walk client drives. It dials the FRET-routed
15
+ * primary's cohort-topic `/register` (a signed seeker `RegisterV1`, self-vouched at tier 0) and the
16
+ * matchmaking `/query` directly (the production analogue of the in-process mock harness
17
+ * `buildWalkTransport` + `queryCohort`), maps a no-reply / dial failure to a benign empty advisory reply,
18
+ * and re-validates every forwarded entry's `registrationSig` itself ({@link verifyEntry}) — the cohort
19
+ * vouches only for "what I held", never provider authenticity.
20
+ *
21
+ * **Layering.** Matchmaking sits *above* the cohort-topic substrate (it depends on
22
+ * `decodeMatchAppPayload`), so it owns its own protocol family ({@link MatchmakingProtocols}) and is wired
23
+ * at the composition root (`libp2p-node-base.ts`) using only the host's public surface
24
+ * (`registry.findServing` / `registry.findByCoord`, `engine.records` / `engine.topicTraffic` /
25
+ * `engine.cohort`). Nothing here reaches into `host.ts` internals — it mirrors the reactivity precedent.
26
+ *
27
+ * **Single-tier-0 serve.** Matchmaking serves a single tier-0 cohort (the cohort-topic single-tier-0
28
+ * milestone); a *serving* tier-`d ≥ 1` query is gated on the promotion follow-ons, exactly as the seeker
29
+ * walk is mock-tier-tagged-unimplemented. So this resolves the tier-0 engine for `coord_0(topicId)` only.
30
+ *
31
+ * **No-engine = no reply (anti-DoS).** A query for a topic this node holds no serving engine for produces
32
+ * **no reply frame** — the handler never instantiates a `CoordEngine` from an inbound query (that would be
33
+ * a DoS amplifier). The seeker maps a no-reply to a benign empty advisory result (the seeker ticket owns
34
+ * that mapping).
35
+ *
36
+ * **Per-coord scoping.** The reply is built from exactly the `coord_0(topicId)` engine's store, nothing
37
+ * cross-coord — matching how the register handler recomputes a served coord per frame. The serve side does
38
+ * NOT re-verify each entry's `registrationSig` (that is the seeker's re-validation via `verifyProviderEntry`);
39
+ * it forwards `rec` fields verbatim through `evaluateQuery` and must never fabricate or alter `registrationSig`.
40
+ */
41
+
42
+ import type { Libp2p } from "libp2p";
43
+ import type { PeerId, PrivateKey } from "@libp2p/interface";
44
+ import type { FretService } from "p2p-fret";
45
+ import { peerIdFromString } from "@libp2p/peer-id";
46
+ import { randomBytes } from "@noble/hashes/utils.js";
47
+ import {
48
+ b64urlToBytes,
49
+ bytesToB64url,
50
+ createTierAddressing,
51
+ decodeCohortMessage,
52
+ decodeQueryReplyV1,
53
+ decodeQueryV1,
54
+ encodeCohortMessage,
55
+ encodeQueryReplyV1,
56
+ encodeQueryV1,
57
+ makeDMaxComputer,
58
+ registerSigningPayload,
59
+ bootstrapBoundImage,
60
+ serializeBootstrapEvidenceEnvelope,
61
+ validateRegisterReplyV1,
62
+ MatchmakingSeeker,
63
+ RingHash,
64
+ Tier,
65
+ DEFAULT_FANOUT,
66
+ QUERY_LIMIT_MAX,
67
+ SEEKER_TTL_MS,
68
+ type CapabilityFilter,
69
+ type EntrySigVerifier,
70
+ type HangOutConfig,
71
+ type MatchTopicAnchor,
72
+ type QueryReplyV1,
73
+ type QueryV1,
74
+ type RegisterReplyV1,
75
+ type RegisterV1,
76
+ type TierAddressing,
77
+ } from "@optimystic/db-core";
78
+ import type { CoordRegistry } from "../cohort-topic/host.js";
79
+ import { peerIdToBytes } from "../cohort-topic/peer-codec.js";
80
+ import { signPeer, verifyPeerSig } from "../cohort-topic/peer-sig.js";
81
+ import { FretSizeEstimator } from "../cohort-topic/size-estimator.js";
82
+ import { handleRequestResponse, requestResponse, DEFAULT_STREAM_MAX_BYTES } from "../cohort-topic/stream-util.js";
83
+ import { DEFAULT_COHORT_TOPIC_PROTOCOLS } from "../cohort-topic/protocols.js";
84
+ import { handleMatchmakingQuery } from "./query-handler.js";
85
+ import { PROTOCOL_MATCHMAKING_QUERY, DEFAULT_MATCHMAKING_PROTOCOLS } from "./protocols.js";
86
+ import { MatchmakingSeekerSession, type MatchmakingSeekerSessionDeps } from "./module.js";
87
+ import type { SeekerWalkTransport, SeekerProbeReply } from "./seeker-walk-client.js";
88
+ import { createLogger } from "../logger.js";
89
+
90
+ const log = createLogger("matchmaking-query");
91
+
92
+ /** Everything the matchmaking `QueryV1` serve handler needs from the live node, all injected. */
93
+ export interface MatchmakingQueryServeDeps {
94
+ /** The host's per-served-coord cohort registry (`host.registry`) — read-only lookups, never `forCoord`. */
95
+ readonly registry: CoordRegistry;
96
+ /**
97
+ * Tier addressing used to derive `coord_0(topicId)` for the fallback engine lookup. Build one from
98
+ * `createTierAddressing(createRingHash())` — byte-identical to the host's internal addressing for the
99
+ * tier-0 coord (which is peer- and fanout-independent), exactly as the reactivity wiring does.
100
+ */
101
+ readonly addressing: ReturnType<typeof createTierAddressing>;
102
+ /**
103
+ * Sign the canonical {@link import("@optimystic/db-core").queryReplySigningPayload} with the node's peer
104
+ * key; resolves the base64url signature (e.g. `async p => bytesToB64url(await signPeer(nodeKey, p))`).
105
+ */
106
+ readonly sign: (payload: Uint8Array) => Promise<string>;
107
+ /**
108
+ * Anti-DoS rate-limit seam (owned by backlog `matchmaking-query-rate-limit`). Default-allow when
109
+ * omitted. Gate on **`from`** — the connection's verified `remotePeer` — NOT the self-asserted
110
+ * `query.requesterId`. Return `false` to drop the query with no reply.
111
+ */
112
+ readonly gate?: (from: PeerId, topicId: Uint8Array) => boolean;
113
+ /** Per-frame ceiling for encode/decode; default {@link DEFAULT_STREAM_MAX_BYTES}. */
114
+ readonly maxBytes?: number;
115
+ /** Wall clock (unix ms) stamped on the query-accounting bump; default {@link Date.now}. Injectable for tests. */
116
+ readonly clock?: () => number;
117
+ }
118
+
119
+ /**
120
+ * Build the matchmaking query serve callback for {@link handleRequestResponse}: decode (bounded) → resolve
121
+ * the serving tier-0 engine → build + sign the reply, or `undefined` for **no reply** (a decode failure, a
122
+ * gate rejection, no serving engine, or any build/sign/encode error). It never throws out of the stream
123
+ * handler — every failure is logged and dropped to a clean no-reply, mirroring the cohort-topic / reactivity
124
+ * serve handlers (which wrap the whole serve body, not just the decode).
125
+ */
126
+ export function createMatchmakingQueryHandler(
127
+ deps: MatchmakingQueryServeDeps,
128
+ ): (frame: Uint8Array, from: PeerId) => Promise<Uint8Array | undefined> {
129
+ const maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
130
+ return async (frame: Uint8Array, from: PeerId): Promise<Uint8Array | undefined> => {
131
+ try {
132
+ // `decodeQueryV1` = `validateQueryV1(decodeCohortMessage(...))` — bounds `limit` to QUERY_LIMIT_MAX,
133
+ // so the handler passes `query` through unchanged (never re-clamps `limit`).
134
+ const query = decodeQueryV1(frame, maxBytes);
135
+ const topicId = b64urlToBytes(query.topicId);
136
+
137
+ // Anti-DoS rate-limit seam (matchmaking-query-rate-limit). Default-allow; gate on the connection's
138
+ // verified `from` peer, never the self-asserted `query.requesterId`.
139
+ if (deps.gate !== undefined && !deps.gate(from, topicId)) {
140
+ log("matchmaking query serve: rate-limited query from %s (no reply)", from.toString());
141
+ return undefined;
142
+ }
143
+
144
+ // Resolve the serving tier-0 engine. `findServing(topicId, 0)` keys on `treeTier === 0 &&
145
+ // servesTopic(topicId)` (true on the routed primary once it has admitted/replicated a registration);
146
+ // `findByCoord(coord_0)` is the fallback for an instantiated-but-currently-recordless engine. We never
147
+ // `forCoord` here — instantiating a CoordEngine from an inbound query would be a DoS amplifier.
148
+ const coord0 = deps.addressing.coord0(topicId);
149
+ const engine = deps.registry.findServing(topicId, 0) ?? deps.registry.findByCoord(coord0);
150
+ if (engine === undefined) {
151
+ // No serving engine on this node (seeker dialed a non-primary / pre-replication). No reply; the
152
+ // seeker side treats this as an empty advisory result.
153
+ return undefined;
154
+ }
155
+
156
+ // Query accounting (matchmaking-query-accounting-seam): a served query bumps `queriesPerMin` for the
157
+ // topic on the served coord's TrafficCounters; surfaces in a later reply's snapshot (lags one round).
158
+ // Sits AFTER the gate + no-engine guards (a dropped / unserved query never inflates the barometer) and
159
+ // BEFORE the reply build (a query that resolved a serving engine is counted on serve, even if signing
160
+ // later fails transiently). A decode failure threw before `topicId` resolved, so it never counts.
161
+ engine.recordQuery(topicId, (deps.clock ?? Date.now)());
162
+
163
+ // Build the reply from a single synchronous read (records + traffic + epoch) so a concurrent gossip
164
+ // round cannot tear the snapshot between read and sign. `handleMatchmakingQuery` forwards each record's
165
+ // fields verbatim through the pure `evaluateQuery` (capability filter + limit truncation + entry
166
+ // building, including each provider's `registrationSig`) and single-member-signs the canonical reply.
167
+ const reply = await handleMatchmakingQuery(query, {
168
+ records: engine.records(topicId),
169
+ topicTraffic: engine.topicTraffic(topicId),
170
+ cohortEpoch: engine.cohort().cohortEpoch,
171
+ sign: deps.sign,
172
+ log,
173
+ });
174
+ return encodeQueryReplyV1(reply, maxBytes);
175
+ } catch (err) {
176
+ // Any failure — a malformed/foreign query (decode), an oversize reply (encode), or a transient
177
+ // `sign` rejection — must never throw out of the stream handler: log + no reply. Throwing would
178
+ // make `handleRequestResponse` abort the stream; returning `undefined` makes it reply with an
179
+ // explicit zero-length frame, which the seeker maps to a benign empty advisory result.
180
+ log("matchmaking query serve: dropping query (no reply): %o", err);
181
+ return undefined;
182
+ }
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Register the inbound matchmaking query protocol handler on `node` (request-reply: one {@link QueryV1}
188
+ * frame in, one {@link import("@optimystic/db-core").QueryReplyV1} frame back, or no reply).
189
+ */
190
+ export function registerMatchmakingQueryHandler(
191
+ node: Libp2p,
192
+ protocol: string = PROTOCOL_MATCHMAKING_QUERY,
193
+ deps: MatchmakingQueryServeDeps,
194
+ ): void {
195
+ const maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
196
+ handleRequestResponse(node, protocol, createMatchmakingQueryHandler(deps), maxBytes);
197
+ }
198
+
199
+ // =================================================================================================
200
+ // Client side: the real-libp2p SeekerWalkTransport + one-shot query + d_max estimate + entry verifier.
201
+ // =================================================================================================
202
+
203
+ /**
204
+ * A self-routed-primary local-serve hook. `fret.assembleCohort(coord, k)[0]` may resolve to the seeker
205
+ * itself; libp2p cannot dial self, so when the routed primary is `selfPeerId` the transport routes the
206
+ * register/query here instead. Absent ⇒ a self-primary register/query throws a clear error (the gap is
207
+ * loud, not a silent hang). The gated e2e seeker is deliberately a remote node, so its happy path never
208
+ * self-dials; the production factory must still not hang on a self-primary.
209
+ */
210
+ export interface MatchmakingSelfServe {
211
+ /** Serve a seeker register whose FRET-routed primary is this node. */
212
+ register?(reg: RegisterV1): Promise<RegisterReplyV1>;
213
+ /** Serve a query whose FRET-routed primary is this node; `undefined` ⇒ treated as an empty reply. */
214
+ query?(q: QueryV1): Promise<QueryReplyV1 | undefined>;
215
+ }
216
+
217
+ /** Construction inputs for {@link createLibp2pMatchmakingTransport}. */
218
+ export interface Libp2pMatchmakingTransportDeps {
219
+ /** The live libp2p node the seeker dials cohorts from. */
220
+ readonly node: Libp2p;
221
+ /**
222
+ * FRET engine: routes the cohort primary (`assembleCohort`) and feeds the `d_max` size estimate
223
+ * (`getNetworkSizeEstimate`). The libp2p `node.services.fret` *wrapper* is accepted directly — it
224
+ * keeps the size-estimate engine behind a lazy `ensure()`, which this factory unwraps internally.
225
+ */
226
+ readonly fret: FretService;
227
+ /** This node's peer-id string — the query `requesterId`, the seeker `participantCoord`, the self-dial guard. */
228
+ readonly selfPeerId: string;
229
+ /** The seeker's node key — signs its own register frames + the tier-0 self-vouch reputation endorsement. */
230
+ readonly key: PrivateKey;
231
+ /** Cohort size `k` (FRET `assembleCohort` wants). */
232
+ readonly wantK: number;
233
+ /** Tier addressing; default {@link createTierAddressing}`(new RingHash())` — byte-identical to the host. */
234
+ readonly addressing?: TierAddressing;
235
+ /** Fan-out `F` for the `d_max` computer; default {@link DEFAULT_FANOUT}. */
236
+ readonly fanout?: number;
237
+ /** Per-frame ceiling; default {@link DEFAULT_STREAM_MAX_BYTES}. */
238
+ readonly maxBytes?: number;
239
+ /** Protocol id overrides; default the cohort-topic `register` + matchmaking `query` canonical ids. */
240
+ readonly protocols?: { readonly register?: string; readonly query?: string };
241
+ /** Seeker registration TTL carried on the walk's register frames (ms); default {@link SEEKER_TTL_MS}. */
242
+ readonly seekerTtlMs?: number;
243
+ /** Advertised demand carried in the seeker register payload (advisory; distinct from the walk's wantCount). Default 1. */
244
+ readonly seekerWantCount?: number;
245
+ /** Contact hint carried in the seeker register payload; default {@link Libp2pMatchmakingTransportDeps.selfPeerId}. */
246
+ readonly contactHint?: string;
247
+ /** Optional capability filter applied to the seeker register payload + the walk's `/query` frames. */
248
+ readonly filter?: CapabilityFilter;
249
+ /** Local-serve hook for a self-routed primary; absent ⇒ a self-primary register/query throws (loud, not silent). */
250
+ readonly selfServe?: MatchmakingSelfServe;
251
+ }
252
+
253
+ /** The seeker-side seams a {@link MatchmakingSeekerSession} / the seeker walk client consume over a live node. */
254
+ export interface Libp2pMatchmakingTransport {
255
+ /** Build the walk transport (register/query/renew/withdraw at a tree tier) for a topic. */
256
+ walkTransport(topicId: Uint8Array): SeekerWalkTransport;
257
+ /** Issue a one-shot `QueryV1` (resolves the cohort from `q.topicId`'s tier-0 coord). */
258
+ queryCohort(q: QueryV1): Promise<QueryReplyV1>;
259
+ /** Estimate `d_max` for a topic (FRET size estimate → the db-core `d_max` computer). */
260
+ estimateDMax(topicId: Uint8Array): Promise<number>;
261
+ /** Per-entry signature verifier (`verifyPeerSig` over the participant's Ed25519 peer key). */
262
+ readonly verifyEntry: EntrySigVerifier;
263
+ }
264
+
265
+ /**
266
+ * Unwrap the libp2p `node.services.fret` wrapper (which exposes `assembleCohort` / `routeAct` but keeps
267
+ * the size-estimate engine behind a lazy `ensure()`) to the full FRET engine the `d_max` estimator needs.
268
+ * A raw engine (no `ensure`, e.g. an injected test double) is returned as-is. Mirrors the node-base
269
+ * `resolveFretEngine`; both observe the same underlying routing store, so `assembleCohort` agrees.
270
+ */
271
+ function resolveFretEngine(fret: FretService): FretService {
272
+ const candidate = fret as unknown as { ensure?: () => FretService };
273
+ return typeof candidate.ensure === "function" ? candidate.ensure() : fret;
274
+ }
275
+
276
+ /**
277
+ * Build the real-libp2p seeker query transport: the production analogue of the in-process mock harness's
278
+ * `buildWalkTransport` + `queryCohort`. Routing model — dial the FRET-routed primary directly: for a tier
279
+ * `d`, `coord = d === 0 ? coord0(topicId) : coordD(d, seekerBytes, topicId)` and the primary is
280
+ * `assembleCohort(coord, wantK)[0]` (the same primary the host's direct-dial `/register` path serves).
281
+ */
282
+ export function createLibp2pMatchmakingTransport(deps: Libp2pMatchmakingTransportDeps): Libp2pMatchmakingTransport {
283
+ const { node, selfPeerId, key, wantK } = deps;
284
+ const fret = resolveFretEngine(deps.fret);
285
+ const addressing = deps.addressing ?? createTierAddressing(new RingHash());
286
+ const fanout = deps.fanout ?? DEFAULT_FANOUT;
287
+ const maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
288
+ const registerProtocol = deps.protocols?.register ?? DEFAULT_COHORT_TOPIC_PROTOCOLS.register;
289
+ const queryProtocol = deps.protocols?.query ?? DEFAULT_MATCHMAKING_PROTOCOLS.query;
290
+ const seekerTtlMs = deps.seekerTtlMs ?? SEEKER_TTL_MS;
291
+ const seekerWantCount = deps.seekerWantCount ?? 1;
292
+ const contactHint = deps.contactHint ?? selfPeerId;
293
+ const filter = deps.filter;
294
+ const selfServe = deps.selfServe;
295
+ const seekerBytes = peerIdToBytes(peerIdFromString(selfPeerId));
296
+ const signImage = async (payload: Uint8Array): Promise<string> => bytesToB64url(await signPeer(key, payload));
297
+ const dMaxComputer = makeDMaxComputer({ estimator: new FretSizeEstimator(fret), F: fanout });
298
+
299
+ const verifyEntry: EntrySigVerifier = (signerId, payload, signature) => verifyPeerSig(signerId, payload, signature);
300
+
301
+ /** A benign empty advisory reply. Client-internal (never encoded/validated); the walk reads only `.providers`. */
302
+ const emptyReply = (): QueryReplyV1 => ({
303
+ v: 1,
304
+ providers: [],
305
+ truncated: false,
306
+ cohortEpoch: "",
307
+ topicTraffic: { windowSeconds: 0, arrivalsPerMin: 0, queriesPerMin: 0, directParticipants: 0, childCohortCount: 0 },
308
+ signature: "",
309
+ });
310
+
311
+ /** The FRET-routed primary peer-id for a cohort coord (`assembleCohort(coord, k)[0]`), or `undefined`. */
312
+ const primaryFor = (coord: Uint8Array): string | undefined => fret.assembleCohort(coord, wantK)[0];
313
+
314
+ /** Build a `QueryV1` for `topicId` carrying the transport's advisory filter. */
315
+ const buildQuery = (topicId: Uint8Array): QueryV1 => ({
316
+ v: 1,
317
+ topicId: bytesToB64url(topicId),
318
+ includeProviders: true,
319
+ includeSeekers: false,
320
+ limit: QUERY_LIMIT_MAX,
321
+ requesterId: selfPeerId,
322
+ timestamp: Date.now(),
323
+ signature: "AA",
324
+ ...(filter !== undefined ? { filter } : {}),
325
+ });
326
+
327
+ /** Dial the tier-0 primary for `q.topicId`'s cohort and decode the reply; benign empty reply on no-frame/failure. */
328
+ const dialQuery = async (q: QueryV1): Promise<QueryReplyV1> => {
329
+ const topicId = b64urlToBytes(q.topicId);
330
+ const primary = primaryFor(addressing.coord0(topicId));
331
+ if (primary === undefined) {
332
+ return emptyReply();
333
+ }
334
+ if (primary === selfPeerId) {
335
+ if (selfServe?.query !== undefined) {
336
+ return (await selfServe.query(q)) ?? emptyReply();
337
+ }
338
+ throw new Error("matchmaking query: FRET routed the cohort primary to self; provide deps.selfServe.query");
339
+ }
340
+ try {
341
+ const frame = await requestResponse(node, peerIdFromString(primary), queryProtocol, encodeQueryV1(q, maxBytes), maxBytes);
342
+ // The serve handler returns no frame for a topic it does not serve; map it (and any decode/dial
343
+ // failure) to a benign empty reply so SeekerWalkClient.collect keeps walking rather than throwing.
344
+ return frame.length === 0 ? emptyReply() : decodeQueryReplyV1(frame, maxBytes);
345
+ } catch (err) {
346
+ log("matchmaking query: dial/decode failed for primary %s (empty reply): %o", primary, err);
347
+ return emptyReply();
348
+ }
349
+ };
350
+
351
+ /** Build a signed seeker `RegisterV1` at `treeTier` (tier-0 carries the self-vouch bootstrap evidence). */
352
+ const buildSeekerRegister = async (topicId: Uint8Array, treeTier: number, appPayload: Uint8Array): Promise<RegisterV1> => {
353
+ const baseBody: Omit<RegisterV1, "signature"> = {
354
+ v: 1,
355
+ topicId: bytesToB64url(topicId),
356
+ tier: Tier.T2,
357
+ treeTier,
358
+ participantCoord: bytesToB64url(seekerBytes),
359
+ ttl: seekerTtlMs,
360
+ // A tier-0 probe is the cold-root bootstrap (needs evidence on a configured node); a tier-`d>0`
361
+ // probe is a plain walk step that falls through `no_state` on a cohort that does not serve the topic.
362
+ bootstrap: treeTier === 0,
363
+ timestamp: Date.now(),
364
+ correlationId: bytesToB64url(randomBytes(16)),
365
+ appPayload: bytesToB64url(appPayload),
366
+ };
367
+ // A configured production node gates a T2 `bootstrap: true` register on bootstrap evidence, so the
368
+ // tier-0 register attaches a self-vouch reputation endorsement (the seeker peer-key-signs its own
369
+ // `bootstrapBoundImage` as referee), exactly as the provider/reactivity integration tests do.
370
+ // `bootstrapBoundImage` binds only (topicId, tier, participantCoord, timestamp), so the endorsement is
371
+ // attached to the body BEFORE the final register sign (`registerSigningPayload` covers it).
372
+ const body: Omit<RegisterV1, "signature"> = treeTier === 0
373
+ ? {
374
+ ...baseBody,
375
+ bootstrapEvidence: serializeBootstrapEvidenceEnvelope({
376
+ v: 1,
377
+ reputation: { referee: bytesToB64url(seekerBytes), sig: bytesToB64url(await signPeer(key, bootstrapBoundImage(baseBody))) },
378
+ }),
379
+ }
380
+ : baseBody;
381
+ return { ...body, signature: bytesToB64url(await signPeer(key, registerSigningPayload(body))) };
382
+ };
383
+
384
+ /** Dial the routed primary's `/register` with the signed seeker frame and decode the `RegisterReplyV1`. */
385
+ const dialRegister = async (primary: string, reg: RegisterV1): Promise<RegisterReplyV1> => {
386
+ if (primary === selfPeerId) {
387
+ if (selfServe?.register !== undefined) {
388
+ return selfServe.register(reg);
389
+ }
390
+ throw new Error("matchmaking register: FRET routed the cohort primary to self; provide deps.selfServe.register");
391
+ }
392
+ const frame = await requestResponse(node, peerIdFromString(primary), registerProtocol, encodeCohortMessage(reg, maxBytes), maxBytes);
393
+ return validateRegisterReplyV1(decodeCohortMessage(frame, maxBytes));
394
+ };
395
+
396
+ /** Map a `RegisterReplyV1` to the walk's {@link SeekerProbeReply} (pass `result`; copy traffic/targetTier). */
397
+ const toProbeReply = (reply: RegisterReplyV1): SeekerProbeReply => {
398
+ const out: { result: SeekerProbeReply["result"]; topicTraffic?: QueryReplyV1["topicTraffic"]; targetTier?: number } = {
399
+ result: reply.result,
400
+ };
401
+ if ((reply.result === "accepted" || reply.result === "promoted") && reply.topicTraffic !== undefined) {
402
+ out.topicTraffic = reply.topicTraffic;
403
+ }
404
+ if (reply.targetTier !== undefined) {
405
+ out.targetTier = reply.targetTier;
406
+ }
407
+ return out;
408
+ };
409
+
410
+ const walkTransport = (topicId: Uint8Array): SeekerWalkTransport => {
411
+ const seekerState = new MatchmakingSeeker({
412
+ topicId,
413
+ wantCount: seekerWantCount,
414
+ contactHint,
415
+ sign: signImage,
416
+ ...(filter !== undefined ? { filter } : {}),
417
+ });
418
+ return {
419
+ register: async (treeTier: number): Promise<SeekerProbeReply> => {
420
+ const coord = addressing.coord(treeTier, seekerBytes, topicId);
421
+ const primary = primaryFor(coord);
422
+ if (primary === undefined) {
423
+ // FRET has not assembled a cohort for this coord yet: treat as cold (the walk steps on).
424
+ return { result: "no_state" };
425
+ }
426
+ const reg = await buildSeekerRegister(topicId, treeTier, await seekerState.appPayloadBytes());
427
+ return toProbeReply(await dialRegister(primary, reg));
428
+ },
429
+ // The serve handler resolves the tier-0 engine only (single-tier-0 milestone), so the query always
430
+ // targets the topic's tier-0 cohort regardless of the walk tier — matching the mock harness + one-shot.
431
+ query: async (_treeTier: number): Promise<QueryReplyV1> => dialQuery(buildQuery(topicId)),
432
+ // Hang-out keep-alive: the seeker's own query does not depend on its seeker record, and the brief
433
+ // record lives in the cohort store for the walk's duration, so a re-touch is unnecessary for the
434
+ // single-tier-0 milestone (mirrors the mock harness). A real renew would re-send a `RenewV1` ping.
435
+ renew: async (): Promise<void> => { /* no-op (documented) */ },
436
+ // Single-tier-0 walks reach the root and never escalate past it, so withdraw is effectively unreached;
437
+ // the brief seeker record otherwise ages out by TTL (mirrors the mock harness).
438
+ withdraw: async (): Promise<void> => { /* no-op (documented) */ },
439
+ };
440
+ };
441
+
442
+ const queryCohort = async (q: QueryV1): Promise<QueryReplyV1> => dialQuery(q);
443
+ const estimateDMax = async (_topicId: Uint8Array): Promise<number> => dMaxComputer.dMax();
444
+
445
+ return { walkTransport, queryCohort, estimateDMax, verifyEntry };
446
+ }
447
+
448
+ /** Construction inputs for {@link createLibp2pMatchmakingSeekerSession} (transport deps + session knobs). */
449
+ export interface Libp2pMatchmakingSeekerSessionDeps extends Libp2pMatchmakingTransportDeps {
450
+ /** Participant-facing cohort-topic substrate API (the seeker's brief T2 registration via `session.register`). */
451
+ readonly service: MatchmakingSeekerSessionDeps["service"];
452
+ /** Topic anchor; defaults to db-core's ring-hash anchor. */
453
+ readonly anchor?: MatchTopicAnchor;
454
+ /** Hang-out decision config (passed to the walk). */
455
+ readonly config?: HangOutConfig;
456
+ /** Assumed competing-seeker mean `wantCount` (passed to the walk). */
457
+ readonly meanWantCount?: number;
458
+ /** Wall clock (unix ms); injectable for tests. */
459
+ readonly clock?: () => number;
460
+ /** Sleep for the requery cadence; injectable for tests. */
461
+ readonly sleep?: (ms: number) => Promise<void>;
462
+ /** TTL for `session.register`'s brief seeker registration (ms); default the manager's seeker TTL. */
463
+ readonly registrationTtlMs?: number;
464
+ }
465
+
466
+ /**
467
+ * Build a {@link MatchmakingSeekerSession} driveable over a live node: wires
468
+ * {@link createLibp2pMatchmakingTransport} into the session's injected substrate seams, so the public
469
+ * session layer (not just the lower-level walk client) runs over real sockets. `sweepPorts` stays
470
+ * UNBOUND — the multi-cohort sweep needs the promoted-tree aggregate-count RPC (a separate follow-on) —
471
+ * so `session.walk` is walk-only, the correct single-tier-0 behavior. The seeker register image is signed
472
+ * with the node key (the same key the transport signs its walk frames with).
473
+ */
474
+ export function createLibp2pMatchmakingSeekerSession(deps: Libp2pMatchmakingSeekerSessionDeps): MatchmakingSeekerSession {
475
+ const transport = createLibp2pMatchmakingTransport(deps);
476
+ const sign = async (payload: Uint8Array): Promise<string> => bytesToB64url(await signPeer(deps.key, payload));
477
+ const sessionDeps: MatchmakingSeekerSessionDeps = {
478
+ service: deps.service,
479
+ sign,
480
+ verifyEntry: transport.verifyEntry,
481
+ walkTransport: (topicId) => transport.walkTransport(topicId),
482
+ queryCohort: (q) => transport.queryCohort(q),
483
+ estimateDMax: (topicId) => transport.estimateDMax(topicId),
484
+ ...(deps.anchor !== undefined ? { anchor: deps.anchor } : {}),
485
+ ...(deps.config !== undefined ? { config: deps.config } : {}),
486
+ ...(deps.meanWantCount !== undefined ? { meanWantCount: deps.meanWantCount } : {}),
487
+ ...(deps.clock !== undefined ? { clock: deps.clock } : {}),
488
+ ...(deps.sleep !== undefined ? { sleep: deps.sleep } : {}),
489
+ ...(deps.registrationTtlMs !== undefined ? { ttlMs: deps.registrationTtlMs } : {}),
490
+ };
491
+ return new MatchmakingSeekerSession(sessionDeps);
492
+ }