@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,663 +1,673 @@
1
- /**
2
- * Cohort-topic **mock-transport mesh harness** — the in-process, many-logical-node test substrate for
3
- * the cohort-topic layer (`docs/cohort-topic.md`). It stands up an `N`-node mesh of real-Ed25519-keyed
4
- * {@link CohortTopicHost}s over a mock libp2p transport and a shared FRET facade that routes the five
5
- * cohort-topic protocols (`register`, `cohort-gossip`, `promote`, `membership`, `sign`) plus FRET's
6
- * `routeAct` / `assembleCohort` directly between the in-process node engines — no real sockets, so a
7
- * 50–200-node mesh runs fast and deterministically.
8
- *
9
- * This is the extracted, generalized form of the harness first written inline in
10
- * `test/cohort-topic/live-tier.spec.ts`; that milestone spec and the at-scale suites
11
- * (`cohort-topic-scale-*.spec.ts`) both drive it. It is a *sibling* of the cluster
12
- * {@link import("./mesh-harness.js")} (which builds `ClusterMember` / coordinator-repo nodes over a
13
- * different mock transport) — the two share no infrastructure, so they stay separate modules under
14
- * `src/testing/` rather than one file.
15
- *
16
- * **What it is NOT.** It is not a wall-clock simulator. TTL eviction, renewal touches, gossip rounds,
17
- * and demotion hysteresis are all driven by an *explicit* `now` passed to the engine methods
18
- * (`handleRegister(reg, ctx, now)`, `gossipRound(now)`, `sweepStale(now)`, `demotionTick(now)`), so a
19
- * suite advances virtual time by choosing timestamps — never by sleeping. The only real-time waits are
20
- * the tiny async-settle polls ({@link waitFor}) the in-process gossip handlers need to drain.
21
- */
22
-
23
- import { generateKeyPair } from '@libp2p/crypto/keys';
24
- import { peerIdFromPrivateKey } from '@libp2p/peer-id';
25
- import type { PrivateKey, PeerId } from '@libp2p/interface';
26
- import { hashPeerId, type RouteAndMaybeActV1, type NearAnchorV1 } from 'p2p-fret';
27
- import {
28
- RingHash,
29
- createSlotAssigner,
30
- createTierAddressing,
31
- coreProfile,
32
- edgeProfile,
33
- bytesEqual,
34
- bytesToB64url,
35
- b64urlToBytes,
36
- encodeCohortMessage,
37
- decodeCohortMessage,
38
- decodeRegisterReplyV1,
39
- cohortGossipSigningPayload,
40
- registerSigningPayload,
41
- renewSigningPayload,
42
- type CohortGossipV1,
43
- type NodeProfile,
44
- type PromotionConfig,
45
- type RegisterResult,
46
- type RegisterV1,
47
- type RenewV1,
48
- type RingCoord,
49
- type Tier,
50
- type WalkTrace,
51
- } from '@optimystic/db-core';
52
- import { createCohortTopicHost, type CohortTopicAntiDosOptions, type CohortTopicHost, type CoordEngine } from '../cohort-topic/host.js';
53
- import { peerIdToBytes, bytesToPeerIdString } from '../cohort-topic/peer-codec.js';
54
- import { signPeer } from '../cohort-topic/peer-sig.js';
55
- import { DEFAULT_COHORT_TOPIC_PROTOCOLS as PROTOCOLS } from '../cohort-topic/protocols.js';
56
-
57
- export { PROTOCOLS };
58
-
59
- export { delay } from '@optimystic/db-core/test';
60
- import { delay } from '@optimystic/db-core/test';
61
-
62
- // NOTE: backward-compat wrapper returning boolean; downstream tickets (3-8) replace each
63
- // call site with the throw-on-timeout canonical waitFor from @optimystic/db-core/test.
64
- export async function waitFor(predicate: () => boolean | Promise<boolean>, timeoutMs = 2_000, intervalMs = 5): Promise<boolean> {
65
- const deadline = Date.now() + timeoutMs;
66
- while (Date.now() < deadline) {
67
- if (await predicate()) return true;
68
- await delay(intervalMs);
69
- }
70
- return !!(await predicate());
71
- }
72
-
73
- // --- members (real Ed25519 keys) ---
74
-
75
- /** A real cohort node identity: its libp2p key, peer-id, peer-id string, dialable member bytes, ring position. */
76
- export interface Member {
77
- readonly key: PrivateKey;
78
- readonly peerId: PeerId;
79
- readonly idStr: string;
80
- /** Dialable member id (UTF-8 of the peer-id string) — the `participantCoord` / signer wire form. */
81
- readonly bytes: Uint8Array;
82
- /** Ring position `H(peerId)`what FRET routes / assembles around. */
83
- readonly ringPos: RingCoord;
84
- }
85
-
86
- export async function makeMember(): Promise<Member> {
87
- const key = await generateKeyPair('Ed25519');
88
- const peerId = peerIdFromPrivateKey(key);
89
- return { key, peerId, idStr: peerId.toString(), bytes: peerIdToBytes(peerId), ringPos: await hashPeerId(peerId) };
90
- }
91
-
92
- export async function makeMembers(n: number): Promise<Member[]> {
93
- const out: Member[] = [];
94
- for (let i = 0; i < n; i++) {
95
- out.push(await makeMember());
96
- }
97
- return out;
98
- }
99
-
100
- // --- in-process duplex stream (what readAllBounded iterates) ---
101
-
102
- /**
103
- * One end of an in-memory duplex pipe. `send` enqueues a frame onto the *peer's* inbox; `close`
104
- * (half-close-write) signals EOF to the peer's reader. The async iterator yields this end's inbox until
105
- * the peer closed its write and the buffer drains so `p2p-fret`'s `readAllBounded` reads exactly the
106
- * frames the other end wrote, then completes promptly on EOF (no idle-timeout wait).
107
- */
108
- export class MockStreamEnd {
109
- private readonly inbox: Uint8Array[] = [];
110
- private inboundClosed = false;
111
- private waiter: (() => void) | undefined;
112
- public peer!: MockStreamEnd;
113
-
114
- send(frame: Uint8Array): void {
115
- this.peer.accept(frame);
116
- }
117
-
118
- close(): Promise<void> {
119
- this.peer.endInbound();
120
- return Promise.resolve();
121
- }
122
-
123
- abort(_err?: unknown): void {
124
- this.peer.endInbound();
125
- }
126
-
127
- private accept(frame: Uint8Array): void {
128
- this.inbox.push(frame);
129
- this.wake();
130
- }
131
-
132
- private endInbound(): void {
133
- this.inboundClosed = true;
134
- this.wake();
135
- }
136
-
137
- private wake(): void {
138
- const w = this.waiter;
139
- this.waiter = undefined;
140
- w?.();
141
- }
142
-
143
- async *[Symbol.asyncIterator](): AsyncGenerator<Uint8Array> {
144
- for (;;) {
145
- if (this.inbox.length > 0) {
146
- yield this.inbox.shift()!;
147
- continue;
148
- }
149
- if (this.inboundClosed) {
150
- return;
151
- }
152
- await new Promise<void>((resolve) => {
153
- this.waiter = resolve;
154
- });
155
- }
156
- }
157
- }
158
-
159
- export function streamPair(): [MockStreamEnd, MockStreamEnd] {
160
- const a = new MockStreamEnd();
161
- const b = new MockStreamEnd();
162
- a.peer = b;
163
- b.peer = a;
164
- return [a, b];
165
- }
166
-
167
- // --- in-process libp2p stand-in ---
168
-
169
- type ProtocolHandler = (stream: MockStreamEnd, connection: { remotePeer: PeerId }) => void;
170
- const asList = (p: string | string[]): string[] => (Array.isArray(p) ? p : [p]);
171
-
172
- /**
173
- * A minimal libp2p node the cohort-topic host runs on. `dialProtocol` resolves the target node in the
174
- * shared registry, hands its protocol handler one end of a fresh duplex, and returns the other — so a
175
- * `requestResponse` / `sendOneWay` from one host drives the target host's real protocol handler. A node
176
- * in the shared `down` set rejects inbound dials (crash / unreachable simulation).
177
- */
178
- export class MockNode {
179
- public readonly handlers = new Map<string, ProtocolHandler>();
180
-
181
- constructor(
182
- public readonly peerId: PeerId,
183
- private readonly registry: Map<string, MockNode>,
184
- private readonly down: Set<string>,
185
- ) {}
186
-
187
- handle(protocol: string | string[], handler: ProtocolHandler): Promise<void> {
188
- for (const p of asList(protocol)) {
189
- this.handlers.set(p, handler);
190
- }
191
- return Promise.resolve();
192
- }
193
-
194
- unhandle(protocol: string | string[]): Promise<void> {
195
- for (const p of asList(protocol)) {
196
- this.handlers.delete(p);
197
- }
198
- return Promise.resolve();
199
- }
200
-
201
- getConnections(_peer?: PeerId): unknown[] {
202
- return [];
203
- }
204
-
205
- dialProtocol(peer: PeerId, protocols: string | string[]): Promise<MockStreamEnd> {
206
- const targetId = peer.toString();
207
- if (this.down.has(targetId)) {
208
- return Promise.reject(new Error(`peer ${targetId} is down`));
209
- }
210
- const target = this.registry.get(targetId);
211
- if (target === undefined) {
212
- return Promise.reject(new Error(`unknown peer ${targetId}`));
213
- }
214
- const handler = target.handlers.get(asList(protocols)[0]!);
215
- if (handler === undefined) {
216
- return Promise.reject(new Error(`no handler for ${asList(protocols)[0]} on ${targetId}`));
217
- }
218
- const [dialerEnd, handlerEnd] = streamPair();
219
- handler(handlerEnd, { remotePeer: this.peerId });
220
- return Promise.resolve(dialerEnd);
221
- }
222
-
223
- /** Inject a one-way frame as if `from` had sent it over `protocol` (the inbound transport seam). */
224
- receive(protocol: string, frame: Uint8Array, from: PeerId): void {
225
- const handler = this.handlers.get(protocol);
226
- if (handler === undefined) {
227
- throw new Error(`no handler for ${protocol}`);
228
- }
229
- const [dialerEnd, handlerEnd] = streamPair();
230
- handler(handlerEnd, { remotePeer: from });
231
- dialerEnd.send(frame);
232
- void dialerEnd.close();
233
- }
234
- }
235
-
236
- // --- mesh + shared FRET ---
237
-
238
- export interface HostNode {
239
- readonly member: Member;
240
- readonly node: MockNode;
241
- readonly host: CohortTopicHost;
242
- }
243
-
244
- export interface MeshOptions {
245
- readonly wantK: number;
246
- readonly minSigs: number;
247
- /** Lowered `cap_promote` to drive promotion with a small participant count (live-tier / promotion suites). */
248
- readonly capPromote?: number;
249
- /**
250
- * Extra {@link PromotionConfig} fields merged onto every node's lifecycle (alongside {@link capPromote}).
251
- * Lets a virtual-time harness neutralise the wall-clock-rate heuristics (e.g. `tPromoteLookaheadMs: 0`
252
- * to disable slope-based pre-promotion, which is meaningless when `now` is a fixed virtual instant).
253
- */
254
- readonly promotion?: Partial<PromotionConfig>;
255
- /** Peers that reject inbound dials (crash / unreachable). They stay in FRET assembly (epoch unchanged). */
256
- readonly downNodes?: readonly string[];
257
- /** FRET network-size estimate (drives the walk start tier `d_max`). Default 256 `d_max = 1`. */
258
- readonly sizeEstimate?: number;
259
- /** Per-node tier profile by index. Default all {@link coreProfile}. `'edge'` → {@link edgeProfile} (T0/T1 only). */
260
- readonly profiles?: readonly ('edge' | 'core')[];
261
- /** Gossip-driver cadence (ms). Default parks the timer far out so suites pump gossip deterministically. */
262
- readonly gossipIntervalMs?: number;
263
- /** Anti-DoS wiring applied to every node (e.g. a reputation view to force cold-root bootstrap denial). */
264
- readonly antiDos?: CohortTopicAntiDosOptions;
265
- }
266
-
267
- /** One routed probe: the coord key it was issued at and the reply classification the walk saw. */
268
- export interface RouteTraceEntry {
269
- readonly key: string;
270
- readonly result: RegisterResult;
271
- /**
272
- * Whether the routed frame carried a participant signature. A participant's own walk probes are always
273
- * signed; the cold-start forwarder→parent link frame is unsigned (`signature: ""`, the interim gap the
274
- * `cohort-topic-parent-child-link` follow-on closes). {@link walkTraceFrom} keeps only signed entries so
275
- * a follow-on instantiation's background parent-link RPC does not pollute a walk's reconstructed trace.
276
- */
277
- readonly signed: boolean;
278
- }
279
-
280
- export class CohortMesh {
281
- readonly nodes: HostNode[] = [];
282
- /** Coords every `routeAct` was keyed at (a walk's probe trail); a test clears + inspects it. */
283
- readonly routeKeys: string[] = [];
284
- /** Per-probe (key, reply-result) trace — richer than {@link routeKeys} for anti-flood walk assertions. */
285
- readonly routeTrace: RouteTraceEntry[] = [];
286
- private readonly registry = new Map<string, MockNode>();
287
- private readonly activity = new Map<string, (activity: string, cohort: string[], minSigs: number, correlationId: string) => Promise<{ commitCertificate: string }>>();
288
- private readonly down: Set<string>;
289
- /** Members removed from FRET assembly (a membership change for rotation tests). They stay dialable. */
290
- private readonly excluded = new Set<string>();
291
-
292
- constructor(private readonly members: Member[], private readonly sizeEstimate: number, down: readonly string[]) {
293
- this.down = new Set(down);
294
- }
295
-
296
- /**
297
- * Drop `idStr` from FRET cohort assembly the cohort serving any coord that included it now resolves to
298
- * a different member set (a new `cohortEpoch`), which is the membership change that drives an epoch
299
- * rotation. Unlike {@link crashNode}, the node stays dialable (it still answers `/sign`), so the outgoing
300
- * cohort can co-sign the hand-off. Used by the rotation-attestation tests.
301
- */
302
- excludeFromAssembly(idStr: string): void {
303
- this.excluded.add(idStr);
304
- }
305
-
306
- /** Restore `idStr` to FRET cohort assembly. */
307
- includeInAssembly(idStr: string): void {
308
- this.excluded.delete(idStr);
309
- }
310
-
311
- /** Deterministic FRET assembly: live (non-excluded) members sorted by XOR distance of ring position to `coord`. */
312
- private sortedByDistance(coord: Uint8Array): Member[] {
313
- return [...this.members].filter((m) => !this.excluded.has(m.idStr)).sort((a, b) => xorCompare(a.ringPos, b.ringPos, coord));
314
- }
315
-
316
- assembleCohort(coord: Uint8Array, wants: number): string[] {
317
- return this.sortedByDistance(coord).slice(0, wants).map((m) => m.idStr);
318
- }
319
-
320
- /** The single node nearest `coord` — where `routeAct` runs the activity (the routed primary). */
321
- nearest(coord: Uint8Array): Member {
322
- return this.sortedByDistance(coord)[0]!;
323
- }
324
-
325
- nodeNearest(coord: Uint8Array): HostNode {
326
- const id = this.nearest(coord).idStr;
327
- return this.nodes.find((n) => n.member.idStr === id)!;
328
- }
329
-
330
- nodeOf(idStr: string): HostNode {
331
- return this.nodes.find((n) => n.member.idStr === idStr)!;
332
- }
333
-
334
- /** Crash `idStr`: it rejects inbound dials but stays in FRET assembly, so `cohortEpoch` is unchanged. */
335
- crashNode(idStr: string): void {
336
- this.down.add(idStr);
337
- }
338
-
339
- /** Revive a previously-crashed node. */
340
- reviveNode(idStr: string): void {
341
- this.down.delete(idStr);
342
- }
343
-
344
- private async routeAct(msg: RouteAndMaybeActV1): Promise<NearAnchorV1 | { commitCertificate: string }> {
345
- const key = b64urlToBytes(msg.key);
346
- this.routeKeys.push(msg.key);
347
- const signed = routedFrameIsSigned(msg.activity);
348
- const target = this.nearest(key);
349
- const handler = this.activity.get(target.idStr);
350
- if (handler === undefined || this.down.has(target.idStr)) {
351
- // No in-cluster activity to run (cold / unreachable target) → a bare anchor hint; the walk
352
- // treats it as `no_state`.
353
- this.routeTrace.push({ key: msg.key, result: 'no_state', signed });
354
- return { v: 1, anchors: [], cohort_hint: [], estimated_cluster_size: this.members.length, confidence: 1 };
355
- }
356
- const cohort = this.assembleCohort(key, msg.want_k);
357
- const reply = await handler(msg.activity ?? '', cohort, msg.min_sigs, msg.correlation_id);
358
- this.routeTrace.push({ key: msg.key, result: replyResult(reply), signed });
359
- return reply;
360
- }
361
-
362
- /** A FRET facade for one node, delegating routing/assembly to the shared mesh. */
363
- fretFor(idStr: string): unknown {
364
- return {
365
- assembleCohort: (coord: Uint8Array, wants: number): string[] => this.assembleCohort(coord, wants),
366
- setActivityHandler: (h: (activity: string, cohort: string[], minSigs: number, correlationId: string) => Promise<{ commitCertificate: string }>): void => {
367
- this.activity.set(idStr, h);
368
- },
369
- routeAct: (msg: RouteAndMaybeActV1): Promise<NearAnchorV1 | { commitCertificate: string }> => this.routeAct(msg),
370
- getNetworkSizeEstimate: (): { size_estimate: number; confidence: number; sources: number } => ({ size_estimate: this.sizeEstimate, confidence: 1, sources: 1 }),
371
- };
372
- }
373
-
374
- registerNode(member: Member): MockNode {
375
- const node = new MockNode(member.peerId, this.registry, this.down);
376
- this.registry.set(member.idStr, node);
377
- return node;
378
- }
379
-
380
- clearRouteLog(): void {
381
- this.routeKeys.length = 0;
382
- this.routeTrace.length = 0;
383
- }
384
-
385
- async stop(): Promise<void> {
386
- await Promise.all(this.nodes.map((n) => n.host.stop()));
387
- }
388
- }
389
-
390
- /**
391
- * Whether the routed `RegisterV1` frame carries a participant signature. Decodes the activity best-effort
392
- * (an undecodable / absent frame is treated as signed, so only a genuinely unsigned frame — the cold-start
393
- * forwarder→parent link — is flagged). Lets {@link walkTraceFrom} drop the background parent-link RPC a
394
- * `followOn` instantiation fires, which would otherwise alias a walk's own probe coord.
395
- */
396
- function routedFrameIsSigned(activity: string | undefined): boolean {
397
- if (activity === undefined || activity === '') {
398
- return true;
399
- }
400
- try {
401
- const frame = decodeCohortMessage(b64urlToBytes(activity)) as { signature?: unknown };
402
- return typeof frame.signature !== 'string' || frame.signature.length > 0;
403
- } catch {
404
- return true;
405
- }
406
- }
407
-
408
- /** The reply classification a `routeAct` resolved with: decode the commit certificate's `RegisterReplyV1`. */
409
- function replyResult(reply: NearAnchorV1 | { commitCertificate: string }): RegisterResult {
410
- if ('commitCertificate' in reply) {
411
- try {
412
- return decodeRegisterReplyV1(b64urlToBytes(reply.commitCertificate)).result;
413
- } catch {
414
- return 'no_state';
415
- }
416
- }
417
- return 'no_state';
418
- }
419
-
420
- /** Compare XOR distance of `a` vs `b` to `target` (big-endian) — a total order over distinct ring positions. */
421
- export function xorCompare(a: Uint8Array, b: Uint8Array, target: Uint8Array): number {
422
- for (let i = 0; i < target.length; i++) {
423
- const da = (a[i] ?? 0) ^ target[i]!;
424
- const db = (b[i] ?? 0) ^ target[i]!;
425
- if (da !== db) {
426
- return da - db;
427
- }
428
- }
429
- return 0;
430
- }
431
-
432
- function profileAt(profiles: readonly ('edge' | 'core')[] | undefined, index: number): NodeProfile {
433
- return profiles?.[index] === 'edge' ? edgeProfile() : coreProfile();
434
- }
435
-
436
- /** Build and start an N-node cohort mesh: one real-keyed node + FRET facade + cohort-topic host each. */
437
- export async function buildMesh(members: Member[], opts: MeshOptions): Promise<CohortMesh> {
438
- const mesh = new CohortMesh(members, opts.sizeEstimate ?? 256, opts.downNodes ?? []);
439
- let index = 0;
440
- for (const member of members) {
441
- const node = mesh.registerNode(member);
442
- const host = await createCohortTopicHost(node as never, mesh.fretFor(member.idStr) as never, {
443
- privateKey: member.key,
444
- wantK: opts.wantK,
445
- minSigs: opts.minSigs,
446
- profile: profileAt(opts.profiles, index),
447
- // Park the periodic driver by default; tests pump gossip / membership / promotion deterministically.
448
- gossipIntervalMs: opts.gossipIntervalMs ?? 3_600_000,
449
- // Virtual time: tests drive publish `stabilizedAt` from explicit (often future-advanced) timestamps,
450
- // not wall clock, so the `/sign` membership endorser's far-future `stabilizedAt` bound must not trip
451
- // on them. An infinite clock disables that bound while leaving the finiteness check intact.
452
- now: (): number => Number.POSITIVE_INFINITY,
453
- ...((opts.capPromote === undefined && opts.promotion === undefined)
454
- ? {}
455
- : { promotion: { ...(opts.capPromote === undefined ? {} : { capPromote: opts.capPromote }), ...(opts.promotion ?? {}) } }),
456
- ...(opts.antiDos === undefined ? {} : { antiDos: opts.antiDos }),
457
- });
458
- mesh.nodes.push({ member, node, host });
459
- index++;
460
- }
461
- return mesh;
462
- }
463
-
464
- // --- signed frame builders (real participant peer-key signatures) ---
465
-
466
- export async function signedWillingness(from: Member, coord: Uint8Array, epoch: Uint8Array, now: number, willingnessBits = 'f'): Promise<Uint8Array> {
467
- const g: CohortGossipV1 = {
468
- v: 1,
469
- fromMember: bytesToB64url(from.bytes),
470
- coord: bytesToB64url(coord),
471
- cohortEpoch: bytesToB64url(epoch),
472
- treeTier: 0,
473
- willingnessBits, // default 'f' → willing at every tier
474
- loadBuckets: [0, 0, 0, 0],
475
- windowSeconds: 60,
476
- topicSummaries: [],
477
- timestamp: now,
478
- signature: '',
479
- };
480
- g.signature = bytesToB64url(await signPeer(from.key, cohortGossipSigningPayload(g)));
481
- return encodeCohortMessage(g);
482
- }
483
-
484
- export interface SignedRegisterOptions {
485
- readonly tier?: number;
486
- readonly treeTier?: number;
487
- readonly bootstrap?: boolean;
488
- /** Follow-on cold-start re-issue (treeTier >= 1); mutually exclusive with bootstrap, so pass `bootstrap: false`. */
489
- readonly followOn?: boolean;
490
- readonly ttl?: number;
491
- }
492
-
493
- export async function signedRegister(participant: Member, topic: Uint8Array, now: number, correlationId: string, opts: SignedRegisterOptions = {}): Promise<RegisterV1> {
494
- const body: Omit<RegisterV1, 'signature'> = {
495
- v: 1,
496
- topicId: bytesToB64url(topic),
497
- tier: opts.tier ?? 0,
498
- treeTier: opts.treeTier ?? 0,
499
- participantCoord: bytesToB64url(participant.bytes),
500
- ttl: opts.ttl ?? 90_000,
501
- bootstrap: opts.bootstrap ?? true,
502
- ...(opts.followOn ? { followOn: true } : {}),
503
- timestamp: now,
504
- correlationId: bytesToB64url(new TextEncoder().encode(correlationId)),
505
- };
506
- return { ...body, signature: bytesToB64url(await signPeer(participant.key, registerSigningPayload(body))) };
507
- }
508
-
509
- /** A plain ping (no `reattach`) — touches `lastPing` only when it lands on the computed primary / override. */
510
- export async function signedPing(participant: Member, topic: Uint8Array, now: number, correlationId: string): Promise<RenewV1> {
511
- const body: Omit<RenewV1, 'signature'> = {
512
- v: 1,
513
- topicId: bytesToB64url(topic),
514
- participantId: bytesToB64url(participant.bytes),
515
- correlationId: bytesToB64url(new TextEncoder().encode(correlationId)),
516
- timestamp: now,
517
- };
518
- return { ...body, signature: bytesToB64url(await signPeer(participant.key, renewSigningPayload(body))) };
519
- }
520
-
521
- /** A signed crash-failover re-attach (`reattach: true` in the signed body) — promotes a backup. */
522
- export async function signedReattach(participant: Member, topic: Uint8Array, now: number, correlationId = 'reattach'): Promise<RenewV1> {
523
- const body: Omit<RenewV1, 'signature'> = {
524
- v: 1,
525
- topicId: bytesToB64url(topic),
526
- participantId: bytesToB64url(participant.bytes),
527
- correlationId: bytesToB64url(new TextEncoder().encode(correlationId)),
528
- timestamp: now,
529
- reattach: true,
530
- };
531
- return { ...body, signature: bytesToB64url(await signPeer(participant.key, renewSigningPayload(body))) };
532
- }
533
-
534
- export const slots = createSlotAssigner(new RingHash());
535
- export const addressing = createTierAddressing(new RingHash());
536
-
537
- // --- walk-trace reconstruction (feeds the db-core anti-flood invariant predicates) ---
538
-
539
- /**
540
- * Map each tier coordinate `coord_d(participant, topic)` for `d ∈ [0, dMax]` to its tier `d` (base64url
541
- * key tier). A real walk's routed keys are matched against this map to recover the per-probe tier, so
542
- * the {@link import("@optimystic/db-core").WalkTrace}-shaped trace can be fed to `outwardMovesArePromoted`
543
- * / `inwardStepsFollowNoState` / `retriesRestartAtDMax`. `coord_0` is participant-independent (it equals
544
- * `coord0(topic)`); the bootstrap re-issue at the root reuses that same key, so both root probes map to 0.
545
- */
546
- export function coordTierMap(participant: Member, topic: Uint8Array, dMax: number, tierAddr = addressing): Map<string, number> {
547
- const map = new Map<string, number>();
548
- // Walk inward so the participant-independent coord_0 wins the key if a deeper coord ever aliased it.
549
- for (let d = dMax; d >= 0; d--) {
550
- map.set(bytesToB64url(tierAddr.coord(d, participant.bytes, topic)), d);
551
- }
552
- return map;
553
- }
554
-
555
- /**
556
- * Reconstruct a {@link WalkTrace} from the mesh's recorded `routeTrace`, keeping only this walk's coords.
557
- * Unsigned frames are excluded: a `followOn` cold-start instantiates a child whose background
558
- * forwarder→parent link RPC routes to the (participant-independent) `coord_0` this walk also probes, so
559
- * without the filter that link's `no_state` would alias the walk's own root probe and fabricate a spurious
560
- * inward/outward move. Only the participant's own signed probes belong to the walk trace.
561
- */
562
- export function walkTraceFrom(routeTrace: readonly RouteTraceEntry[], tierMap: Map<string, number>, dMax: number): WalkTrace {
563
- const probes = routeTrace
564
- .filter((e) => tierMap.has(e.key) && e.signed)
565
- .map((e) => ({ treeTier: tierMap.get(e.key)!, result: e.result }));
566
- return { dMax, probes };
567
- }
568
-
569
- /**
570
- * Generate a real-keyed participant whose deterministic slot-**primary** (under `engine`'s cohort epoch
571
- * + member set) is `primaryNode`. The cohort-side renewal only serves a plain ping / `reattach` with
572
- * `ok` (the path that touches the record into the gossip deltas) when it lands on the participant's
573
- * computed primary or a backup; for an arbitrary participant the node nearest `coord_0` is that primary
574
- * only ~1/k of the time, so seeding/replication via a fixed deciding node is non-deterministic without
575
- * pinning the participant to it.
576
- */
577
- export async function participantPrimaryAt(primaryNode: HostNode, engine: CoordEngine): Promise<Member> {
578
- const { members, cohortEpoch } = engine.cohort();
579
- for (;;) {
580
- const p = await makeMember();
581
- if (bytesEqual(slots.assignSlots(p.bytes, cohortEpoch, members).primary, primaryNode.member.bytes)) {
582
- return p;
583
- }
584
- }
585
- }
586
-
587
- /**
588
- * Generate a real-keyed participant whose computed primary is `primaryNode` **and** whose `backups[0]`
589
- * is `backupNode` (under `engine`'s epoch + member set). The crash-failover suite needs a participant
590
- * whose first warm backup is a known sibling so a `reattach` landing there promotes deterministically.
591
- */
592
- export async function participantPrimaryBackupAt(primaryNode: HostNode, backupNode: HostNode, engine: CoordEngine): Promise<Member> {
593
- const { members, cohortEpoch } = engine.cohort();
594
- for (;;) {
595
- const p = await makeMember();
596
- const slot = slots.assignSlots(p.bytes, cohortEpoch, members);
597
- if (bytesEqual(slot.primary, primaryNode.member.bytes) && slot.backups[0] !== undefined && bytesEqual(slot.backups[0], backupNode.member.bytes)) {
598
- return p;
599
- }
600
- }
601
- }
602
-
603
- // --- topic setup (instantiate coord-0 engines on the cohort + seed willingness quorum) ---
604
-
605
- export interface TopicSetup {
606
- readonly coord0: RingCoord;
607
- /** Engine on every coord-0 cohort member, keyed by member id string. */
608
- readonly engines: Map<string, CoordEngine>;
609
- /** The routed primary for `coord_0` (where `routeAct` lands a bootstrap register). */
610
- readonly deciding: HostNode;
611
- readonly decidingEngine: CoordEngine;
612
- /** The coord-0 cohort member id strings (the `wantK` nearest to `coord_0`). */
613
- readonly cohortIds: readonly string[];
614
- }
615
-
616
- /**
617
- * Instantiate the tier-0 coord engine for `topic` on every **coord-0 cohort member** and seed each one's
618
- * coord-0 gossip view with every *other* cohort member's willingness, so any cohort member (in
619
- * particular the routed primary) meets the willingness quorum and can admit. Mirrors the willingness
620
- * bootstrap the gossip-cadence tests do — an idle engine builds no willingness frame, so the first
621
- * registration needs a seed. Operating on the cohort (not all `N` nodes) keeps setup `O(wantK²)` at
622
- * scale; for a whole-network cohort (`wantK = N`) it covers every node, matching the live-tier milestone.
623
- */
624
- /**
625
- * Pump one gossip round on **every live coord engine across every node**, then let the async inbound
626
- * `/cohort-gossip` handlers settle. This is the cold-bootstrap counterpart to {@link setupTopic}'s manual
627
- * willingness pre-seed: with no seed, the idle-but-willing willingness heartbeat (change A) plus cold-sibling
628
- * engine instantiation (change B) must carry a fresh cohort from cold to a willingness quorum on their own,
629
- * and this drives the rounds that make that happen. Call it repeatedly each call is one "wave": engines a
630
- * previous wave's heartbeats just instantiated on siblings only get pumped (and so reciprocate their own
631
- * willingness) on the next wave.
632
- */
633
- export async function pumpMeshGossip(mesh: CohortMesh, now: number, settleMs = 30): Promise<void> {
634
- await Promise.all(mesh.nodes.flatMap((n) => n.host.registry.all().map((e) => e.gossipRound(now))));
635
- await delay(settleMs);
636
- }
637
-
638
- export async function setupTopic(mesh: CohortMesh, topic: Uint8Array, tierAddr = addressing): Promise<TopicSetup> {
639
- const coord0 = tierAddr.coord0(topic);
640
- const seedParticipant = mesh.nodes[0]!.member.bytes; // dummy participantCoord (unused at tier 0)
641
- // Resolve the cohort the host actually assembles around coord_0 from any node (they all agree).
642
- const decidingNode = mesh.nodeNearest(coord0);
643
- const probeEngine = decidingNode.host.registry.forCoord(coord0, 0 as Tier, seedParticipant);
644
- const cohortIds = probeEngine.cohort().members.map((m) => bytesToPeerIdString(m));
645
- const cohortNodes = cohortIds.map((id) => mesh.nodeOf(id)).filter((n): n is HostNode => n !== undefined);
646
-
647
- const engines = new Map<string, CoordEngine>();
648
- for (const node of cohortNodes) {
649
- engines.set(node.member.idStr, node.host.registry.forCoord(coord0, 0 as Tier, seedParticipant));
650
- }
651
- const now = Date.now();
652
- for (const node of cohortNodes) {
653
- const epoch = engines.get(node.member.idStr)!.cohort().cohortEpoch;
654
- for (const other of cohortNodes) {
655
- if (other.member.idStr === node.member.idStr) {
656
- continue;
657
- }
658
- node.node.receive(PROTOCOLS.gossip, await signedWillingness(other.member, coord0, epoch, now), other.member.peerId);
659
- }
660
- }
661
- await delay(20); // let the async gossip handlers merge the willingness contributions
662
- return { coord0, engines, deciding: decidingNode, decidingEngine: engines.get(decidingNode.member.idStr)!, cohortIds };
663
- }
1
+ /**
2
+ * Cohort-topic **mock-transport mesh harness** — the in-process, many-logical-node test substrate for
3
+ * the cohort-topic layer (`docs/cohort-topic.md`). It stands up an `N`-node mesh of real-Ed25519-keyed
4
+ * {@link CohortTopicHost}s over a mock libp2p transport and a shared FRET facade that routes the five
5
+ * cohort-topic protocols (`register`, `cohort-gossip`, `promote`, `membership`, `sign`) plus FRET's
6
+ * `routeAct` / `assembleCohort` directly between the in-process node engines — no real sockets, so a
7
+ * 50–200-node mesh runs fast and deterministically.
8
+ *
9
+ * This is the extracted, generalized form of the harness first written inline in
10
+ * `test/cohort-topic/live-tier.spec.ts`; that milestone spec and the at-scale suites
11
+ * (`cohort-topic-scale-*.spec.ts`) both drive it. It is a *sibling* of the cluster
12
+ * {@link import("./mesh-harness.js")} (which builds `ClusterMember` / coordinator-repo nodes over a
13
+ * different mock transport) — the two share no infrastructure, so they stay separate modules under
14
+ * `src/testing/` rather than one file.
15
+ *
16
+ * **What it is NOT.** It is not a wall-clock simulator. TTL eviction, renewal touches, gossip rounds,
17
+ * and demotion hysteresis are all driven by an *explicit* `now` passed to the engine methods
18
+ * (`handleRegister(reg, ctx, now)`, `gossipRound(now)`, `sweepStale(now)`, `demotionTick(now)`), so a
19
+ * suite advances virtual time by choosing timestamps — never by sleeping. The only real-time waits are
20
+ * the tiny async-settle polls ({@link waitFor}) the in-process gossip handlers need to drain.
21
+ */
22
+
23
+ import { generateKeyPair } from '@libp2p/crypto/keys';
24
+ import { peerIdFromPrivateKey } from '@libp2p/peer-id';
25
+ import type { PrivateKey, PeerId } from '@libp2p/interface';
26
+ import * as lp from 'it-length-prefixed';
27
+ import type { Uint8ArrayList } from 'uint8arraylist';
28
+ import { hashPeerId, type RouteAndMaybeActV1, type NearAnchorV1 } from 'p2p-fret';
29
+ import {
30
+ RingHash,
31
+ createSlotAssigner,
32
+ createTierAddressing,
33
+ coreProfile,
34
+ edgeProfile,
35
+ bytesEqual,
36
+ bytesToB64url,
37
+ b64urlToBytes,
38
+ encodeCohortMessage,
39
+ decodeCohortMessage,
40
+ decodeRegisterReplyV1,
41
+ cohortGossipSigningPayload,
42
+ registerSigningPayload,
43
+ renewSigningPayload,
44
+ type CohortGossipV1,
45
+ type NodeProfile,
46
+ type PromotionConfig,
47
+ type RegisterResult,
48
+ type RegisterV1,
49
+ type RenewV1,
50
+ type RingCoord,
51
+ type Tier,
52
+ type WalkTrace,
53
+ } from '@optimystic/db-core';
54
+ import { createCohortTopicHost, type CohortTopicAntiDosOptions, type CohortTopicHost, type CoordEngine } from '../cohort-topic/host.js';
55
+ import { peerIdToBytes, bytesToPeerIdString } from '../cohort-topic/peer-codec.js';
56
+ import { signPeer } from '../cohort-topic/peer-sig.js';
57
+ import { DEFAULT_COHORT_TOPIC_PROTOCOLS as PROTOCOLS } from '../cohort-topic/protocols.js';
58
+
59
+ export { PROTOCOLS };
60
+
61
+ export { delay } from '@optimystic/db-core/test';
62
+ import { delay } from '@optimystic/db-core/test';
63
+
64
+ // NOTE: backward-compat wrapper returning boolean; downstream tickets (3-8) replace each
65
+ // call site with the throw-on-timeout canonical waitFor from @optimystic/db-core/test.
66
+ export async function waitFor(predicate: () => boolean | Promise<boolean>, timeoutMs = 2_000, intervalMs = 5): Promise<boolean> {
67
+ const deadline = Date.now() + timeoutMs;
68
+ while (Date.now() < deadline) {
69
+ if (await predicate()) return true;
70
+ await delay(intervalMs);
71
+ }
72
+ return !!(await predicate());
73
+ }
74
+
75
+ // --- members (real Ed25519 keys) ---
76
+
77
+ /** A real cohort node identity: its libp2p key, peer-id, peer-id string, dialable member bytes, ring position. */
78
+ export interface Member {
79
+ readonly key: PrivateKey;
80
+ readonly peerId: PeerId;
81
+ readonly idStr: string;
82
+ /** Dialable member id (UTF-8 of the peer-id string) — the `participantCoord` / signer wire form. */
83
+ readonly bytes: Uint8Array;
84
+ /** Ring position `H(peerId)` — what FRET routes / assembles around. */
85
+ readonly ringPos: RingCoord;
86
+ }
87
+
88
+ export async function makeMember(): Promise<Member> {
89
+ const key = await generateKeyPair('Ed25519');
90
+ const peerId = peerIdFromPrivateKey(key);
91
+ return { key, peerId, idStr: peerId.toString(), bytes: peerIdToBytes(peerId), ringPos: await hashPeerId(peerId) };
92
+ }
93
+
94
+ export async function makeMembers(n: number): Promise<Member[]> {
95
+ const out: Member[] = [];
96
+ for (let i = 0; i < n; i++) {
97
+ out.push(await makeMember());
98
+ }
99
+ return out;
100
+ }
101
+
102
+ // --- in-process duplex stream (what readFramed iterates) ---
103
+
104
+ /**
105
+ * One end of an in-memory duplex pipe. `send` enqueues a chunk onto the *peer's* inbox; `close`
106
+ * (half-close-write) signals EOF to the peer's reader. The async iterator yields this end's inbox, so
107
+ * `p2p-fret`'s `readFramed` decodes exactly one varint-length-prefixed frame from the chunks the other
108
+ * end wrote — the varint prefix delimits the frame; EOF before a whole frame is a truncation error.
109
+ *
110
+ * Chunks are `Uint8Array | Uint8ArrayList` because `sendFramed` writes the prefix + body as one
111
+ * `Uint8ArrayList`; `send` returns `true` ("queue has room"), matching the `stream.send` signature
112
+ * `sendFramed` passes through. Deliberately NOT carrying `closeRead` / `addEventListener` /
113
+ * `removeEventListener` / `push` / `log`: `readFramed` duck-types the full libp2p message-stream
114
+ * surface onto its `byteStream` path, and this stub must stay on the plain-iterable path.
115
+ */
116
+ export class MockStreamEnd {
117
+ private readonly inbox: (Uint8Array | Uint8ArrayList)[] = [];
118
+ private inboundClosed = false;
119
+ private waiter: (() => void) | undefined;
120
+ public peer!: MockStreamEnd;
121
+
122
+ send(frame: Uint8Array | Uint8ArrayList): boolean {
123
+ this.peer.accept(frame);
124
+ return true;
125
+ }
126
+
127
+ close(): Promise<void> {
128
+ this.peer.endInbound();
129
+ return Promise.resolve();
130
+ }
131
+
132
+ abort(_err?: unknown): void {
133
+ this.peer.endInbound();
134
+ }
135
+
136
+ private accept(frame: Uint8Array | Uint8ArrayList): void {
137
+ this.inbox.push(frame);
138
+ this.wake();
139
+ }
140
+
141
+ private endInbound(): void {
142
+ this.inboundClosed = true;
143
+ this.wake();
144
+ }
145
+
146
+ private wake(): void {
147
+ const w = this.waiter;
148
+ this.waiter = undefined;
149
+ w?.();
150
+ }
151
+
152
+ async *[Symbol.asyncIterator](): AsyncGenerator<Uint8Array | Uint8ArrayList> {
153
+ for (;;) {
154
+ if (this.inbox.length > 0) {
155
+ yield this.inbox.shift()!;
156
+ continue;
157
+ }
158
+ if (this.inboundClosed) {
159
+ return;
160
+ }
161
+ await new Promise<void>((resolve) => {
162
+ this.waiter = resolve;
163
+ });
164
+ }
165
+ }
166
+ }
167
+
168
+ export function streamPair(): [MockStreamEnd, MockStreamEnd] {
169
+ const a = new MockStreamEnd();
170
+ const b = new MockStreamEnd();
171
+ a.peer = b;
172
+ b.peer = a;
173
+ return [a, b];
174
+ }
175
+
176
+ // --- in-process libp2p stand-in ---
177
+
178
+ type ProtocolHandler = (stream: MockStreamEnd, connection: { remotePeer: PeerId }) => void;
179
+ const asList = (p: string | string[]): string[] => (Array.isArray(p) ? p : [p]);
180
+
181
+ /**
182
+ * A minimal libp2p node the cohort-topic host runs on. `dialProtocol` resolves the target node in the
183
+ * shared registry, hands its protocol handler one end of a fresh duplex, and returns the other — so a
184
+ * `requestResponse` / `sendOneWay` from one host drives the target host's real protocol handler. A node
185
+ * in the shared `down` set rejects inbound dials (crash / unreachable simulation).
186
+ */
187
+ export class MockNode {
188
+ public readonly handlers = new Map<string, ProtocolHandler>();
189
+
190
+ constructor(
191
+ public readonly peerId: PeerId,
192
+ private readonly registry: Map<string, MockNode>,
193
+ private readonly down: Set<string>,
194
+ ) {}
195
+
196
+ handle(protocol: string | string[], handler: ProtocolHandler): Promise<void> {
197
+ for (const p of asList(protocol)) {
198
+ this.handlers.set(p, handler);
199
+ }
200
+ return Promise.resolve();
201
+ }
202
+
203
+ unhandle(protocol: string | string[]): Promise<void> {
204
+ for (const p of asList(protocol)) {
205
+ this.handlers.delete(p);
206
+ }
207
+ return Promise.resolve();
208
+ }
209
+
210
+ getConnections(_peer?: PeerId): unknown[] {
211
+ return [];
212
+ }
213
+
214
+ dialProtocol(peer: PeerId, protocols: string | string[]): Promise<MockStreamEnd> {
215
+ const targetId = peer.toString();
216
+ if (this.down.has(targetId)) {
217
+ return Promise.reject(new Error(`peer ${targetId} is down`));
218
+ }
219
+ const target = this.registry.get(targetId);
220
+ if (target === undefined) {
221
+ return Promise.reject(new Error(`unknown peer ${targetId}`));
222
+ }
223
+ const handler = target.handlers.get(asList(protocols)[0]!);
224
+ if (handler === undefined) {
225
+ return Promise.reject(new Error(`no handler for ${asList(protocols)[0]} on ${targetId}`));
226
+ }
227
+ const [dialerEnd, handlerEnd] = streamPair();
228
+ handler(handlerEnd, { remotePeer: this.peerId });
229
+ return Promise.resolve(dialerEnd);
230
+ }
231
+
232
+ /** Inject a one-way frame as if `from` had sent it over `protocol` (the inbound transport seam). */
233
+ receive(protocol: string, frame: Uint8Array, from: PeerId): void {
234
+ const handler = this.handlers.get(protocol);
235
+ if (handler === undefined) {
236
+ throw new Error(`no handler for ${protocol}`);
237
+ }
238
+ const [dialerEnd, handlerEnd] = streamPair();
239
+ handler(handlerEnd, { remotePeer: from });
240
+ // Frame exactly as `sendFramed` does — the handler's `readFramed` expects a varint prefix.
241
+ dialerEnd.send(lp.encode.single(frame));
242
+ void dialerEnd.close();
243
+ }
244
+ }
245
+
246
+ // --- mesh + shared FRET ---
247
+
248
+ export interface HostNode {
249
+ readonly member: Member;
250
+ readonly node: MockNode;
251
+ readonly host: CohortTopicHost;
252
+ }
253
+
254
+ export interface MeshOptions {
255
+ readonly wantK: number;
256
+ readonly minSigs: number;
257
+ /** Lowered `cap_promote` to drive promotion with a small participant count (live-tier / promotion suites). */
258
+ readonly capPromote?: number;
259
+ /**
260
+ * Extra {@link PromotionConfig} fields merged onto every node's lifecycle (alongside {@link capPromote}).
261
+ * Lets a virtual-time harness neutralise the wall-clock-rate heuristics (e.g. `tPromoteLookaheadMs: 0`
262
+ * to disable slope-based pre-promotion, which is meaningless when `now` is a fixed virtual instant).
263
+ */
264
+ readonly promotion?: Partial<PromotionConfig>;
265
+ /** Peers that reject inbound dials (crash / unreachable). They stay in FRET assembly (epoch unchanged). */
266
+ readonly downNodes?: readonly string[];
267
+ /** FRET network-size estimate (drives the walk start tier `d_max`). Default 256 `d_max = 1`. */
268
+ readonly sizeEstimate?: number;
269
+ /** Per-node tier profile by index. Default all {@link coreProfile}. `'edge'` → {@link edgeProfile} (T0/T1 only). */
270
+ readonly profiles?: readonly ('edge' | 'core')[];
271
+ /** Gossip-driver cadence (ms). Default parks the timer far out so suites pump gossip deterministically. */
272
+ readonly gossipIntervalMs?: number;
273
+ /** Anti-DoS wiring applied to every node (e.g. a reputation view to force cold-root bootstrap denial). */
274
+ readonly antiDos?: CohortTopicAntiDosOptions;
275
+ }
276
+
277
+ /** One routed probe: the coord key it was issued at and the reply classification the walk saw. */
278
+ export interface RouteTraceEntry {
279
+ readonly key: string;
280
+ readonly result: RegisterResult;
281
+ /**
282
+ * Whether the routed frame carried a participant signature. A participant's own walk probes are always
283
+ * signed; the cold-start forwarder→parent link frame is unsigned (`signature: ""`, the interim gap the
284
+ * `cohort-topic-parent-child-link` follow-on closes). {@link walkTraceFrom} keeps only signed entries so
285
+ * a follow-on instantiation's background parent-link RPC does not pollute a walk's reconstructed trace.
286
+ */
287
+ readonly signed: boolean;
288
+ }
289
+
290
+ export class CohortMesh {
291
+ readonly nodes: HostNode[] = [];
292
+ /** Coords every `routeAct` was keyed at (a walk's probe trail); a test clears + inspects it. */
293
+ readonly routeKeys: string[] = [];
294
+ /** Per-probe (key, reply-result) trace — richer than {@link routeKeys} for anti-flood walk assertions. */
295
+ readonly routeTrace: RouteTraceEntry[] = [];
296
+ private readonly registry = new Map<string, MockNode>();
297
+ private readonly activity = new Map<string, (activity: string, cohort: string[], minSigs: number, correlationId: string) => Promise<{ commitCertificate: string }>>();
298
+ private readonly down: Set<string>;
299
+ /** Members removed from FRET assembly (a membership change for rotation tests). They stay dialable. */
300
+ private readonly excluded = new Set<string>();
301
+
302
+ constructor(private readonly members: Member[], private readonly sizeEstimate: number, down: readonly string[]) {
303
+ this.down = new Set(down);
304
+ }
305
+
306
+ /**
307
+ * Drop `idStr` from FRET cohort assembly — the cohort serving any coord that included it now resolves to
308
+ * a different member set (a new `cohortEpoch`), which is the membership change that drives an epoch
309
+ * rotation. Unlike {@link crashNode}, the node stays dialable (it still answers `/sign`), so the outgoing
310
+ * cohort can co-sign the hand-off. Used by the rotation-attestation tests.
311
+ */
312
+ excludeFromAssembly(idStr: string): void {
313
+ this.excluded.add(idStr);
314
+ }
315
+
316
+ /** Restore `idStr` to FRET cohort assembly. */
317
+ includeInAssembly(idStr: string): void {
318
+ this.excluded.delete(idStr);
319
+ }
320
+
321
+ /** Deterministic FRET assembly: live (non-excluded) members sorted by XOR distance of ring position to `coord`. */
322
+ private sortedByDistance(coord: Uint8Array): Member[] {
323
+ return [...this.members].filter((m) => !this.excluded.has(m.idStr)).sort((a, b) => xorCompare(a.ringPos, b.ringPos, coord));
324
+ }
325
+
326
+ assembleCohort(coord: Uint8Array, wants: number): string[] {
327
+ return this.sortedByDistance(coord).slice(0, wants).map((m) => m.idStr);
328
+ }
329
+
330
+ /** The single node nearest `coord` — where `routeAct` runs the activity (the routed primary). */
331
+ nearest(coord: Uint8Array): Member {
332
+ return this.sortedByDistance(coord)[0]!;
333
+ }
334
+
335
+ nodeNearest(coord: Uint8Array): HostNode {
336
+ const id = this.nearest(coord).idStr;
337
+ return this.nodes.find((n) => n.member.idStr === id)!;
338
+ }
339
+
340
+ nodeOf(idStr: string): HostNode {
341
+ return this.nodes.find((n) => n.member.idStr === idStr)!;
342
+ }
343
+
344
+ /** Crash `idStr`: it rejects inbound dials but stays in FRET assembly, so `cohortEpoch` is unchanged. */
345
+ crashNode(idStr: string): void {
346
+ this.down.add(idStr);
347
+ }
348
+
349
+ /** Revive a previously-crashed node. */
350
+ reviveNode(idStr: string): void {
351
+ this.down.delete(idStr);
352
+ }
353
+
354
+ private async routeAct(msg: RouteAndMaybeActV1): Promise<NearAnchorV1 | { commitCertificate: string }> {
355
+ const key = b64urlToBytes(msg.key);
356
+ this.routeKeys.push(msg.key);
357
+ const signed = routedFrameIsSigned(msg.activity);
358
+ const target = this.nearest(key);
359
+ const handler = this.activity.get(target.idStr);
360
+ if (handler === undefined || this.down.has(target.idStr)) {
361
+ // No in-cluster activity to run (cold / unreachable target) → a bare anchor hint; the walk
362
+ // treats it as `no_state`.
363
+ this.routeTrace.push({ key: msg.key, result: 'no_state', signed });
364
+ return { v: 1, anchors: [], cohort_hint: [], estimated_cluster_size: this.members.length, confidence: 1 };
365
+ }
366
+ const cohort = this.assembleCohort(key, msg.want_k);
367
+ const reply = await handler(msg.activity ?? '', cohort, msg.min_sigs, msg.correlation_id);
368
+ this.routeTrace.push({ key: msg.key, result: replyResult(reply), signed });
369
+ return reply;
370
+ }
371
+
372
+ /** A FRET facade for one node, delegating routing/assembly to the shared mesh. */
373
+ fretFor(idStr: string): unknown {
374
+ return {
375
+ assembleCohort: (coord: Uint8Array, wants: number): string[] => this.assembleCohort(coord, wants),
376
+ setActivityHandler: (h: (activity: string, cohort: string[], minSigs: number, correlationId: string) => Promise<{ commitCertificate: string }>): void => {
377
+ this.activity.set(idStr, h);
378
+ },
379
+ routeAct: (msg: RouteAndMaybeActV1): Promise<NearAnchorV1 | { commitCertificate: string }> => this.routeAct(msg),
380
+ getNetworkSizeEstimate: (): { size_estimate: number; confidence: number; sources: number } => ({ size_estimate: this.sizeEstimate, confidence: 1, sources: 1 }),
381
+ };
382
+ }
383
+
384
+ registerNode(member: Member): MockNode {
385
+ const node = new MockNode(member.peerId, this.registry, this.down);
386
+ this.registry.set(member.idStr, node);
387
+ return node;
388
+ }
389
+
390
+ clearRouteLog(): void {
391
+ this.routeKeys.length = 0;
392
+ this.routeTrace.length = 0;
393
+ }
394
+
395
+ async stop(): Promise<void> {
396
+ await Promise.all(this.nodes.map((n) => n.host.stop()));
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Whether the routed `RegisterV1` frame carries a participant signature. Decodes the activity best-effort
402
+ * (an undecodable / absent frame is treated as signed, so only a genuinely unsigned frame the cold-start
403
+ * forwarder→parent link — is flagged). Lets {@link walkTraceFrom} drop the background parent-link RPC a
404
+ * `followOn` instantiation fires, which would otherwise alias a walk's own probe coord.
405
+ */
406
+ function routedFrameIsSigned(activity: string | undefined): boolean {
407
+ if (activity === undefined || activity === '') {
408
+ return true;
409
+ }
410
+ try {
411
+ const frame = decodeCohortMessage(b64urlToBytes(activity)) as { signature?: unknown };
412
+ return typeof frame.signature !== 'string' || frame.signature.length > 0;
413
+ } catch {
414
+ return true;
415
+ }
416
+ }
417
+
418
+ /** The reply classification a `routeAct` resolved with: decode the commit certificate's `RegisterReplyV1`. */
419
+ function replyResult(reply: NearAnchorV1 | { commitCertificate: string }): RegisterResult {
420
+ if ('commitCertificate' in reply) {
421
+ try {
422
+ return decodeRegisterReplyV1(b64urlToBytes(reply.commitCertificate)).result;
423
+ } catch {
424
+ return 'no_state';
425
+ }
426
+ }
427
+ return 'no_state';
428
+ }
429
+
430
+ /** Compare XOR distance of `a` vs `b` to `target` (big-endian) — a total order over distinct ring positions. */
431
+ export function xorCompare(a: Uint8Array, b: Uint8Array, target: Uint8Array): number {
432
+ for (let i = 0; i < target.length; i++) {
433
+ const da = (a[i] ?? 0) ^ target[i]!;
434
+ const db = (b[i] ?? 0) ^ target[i]!;
435
+ if (da !== db) {
436
+ return da - db;
437
+ }
438
+ }
439
+ return 0;
440
+ }
441
+
442
+ function profileAt(profiles: readonly ('edge' | 'core')[] | undefined, index: number): NodeProfile {
443
+ return profiles?.[index] === 'edge' ? edgeProfile() : coreProfile();
444
+ }
445
+
446
+ /** Build and start an N-node cohort mesh: one real-keyed node + FRET facade + cohort-topic host each. */
447
+ export async function buildMesh(members: Member[], opts: MeshOptions): Promise<CohortMesh> {
448
+ const mesh = new CohortMesh(members, opts.sizeEstimate ?? 256, opts.downNodes ?? []);
449
+ let index = 0;
450
+ for (const member of members) {
451
+ const node = mesh.registerNode(member);
452
+ const host = await createCohortTopicHost(node as never, mesh.fretFor(member.idStr) as never, {
453
+ privateKey: member.key,
454
+ wantK: opts.wantK,
455
+ minSigs: opts.minSigs,
456
+ profile: profileAt(opts.profiles, index),
457
+ // Park the periodic driver by default; tests pump gossip / membership / promotion deterministically.
458
+ gossipIntervalMs: opts.gossipIntervalMs ?? 3_600_000,
459
+ // Virtual time: tests drive publish `stabilizedAt` from explicit (often future-advanced) timestamps,
460
+ // not wall clock, so the `/sign` membership endorser's far-future `stabilizedAt` bound must not trip
461
+ // on them. An infinite clock disables that bound while leaving the finiteness check intact.
462
+ now: (): number => Number.POSITIVE_INFINITY,
463
+ ...((opts.capPromote === undefined && opts.promotion === undefined)
464
+ ? {}
465
+ : { promotion: { ...(opts.capPromote === undefined ? {} : { capPromote: opts.capPromote }), ...(opts.promotion ?? {}) } }),
466
+ ...(opts.antiDos === undefined ? {} : { antiDos: opts.antiDos }),
467
+ });
468
+ mesh.nodes.push({ member, node, host });
469
+ index++;
470
+ }
471
+ return mesh;
472
+ }
473
+
474
+ // --- signed frame builders (real participant peer-key signatures) ---
475
+
476
+ export async function signedWillingness(from: Member, coord: Uint8Array, epoch: Uint8Array, now: number, willingnessBits = 'f'): Promise<Uint8Array> {
477
+ const g: CohortGossipV1 = {
478
+ v: 1,
479
+ fromMember: bytesToB64url(from.bytes),
480
+ coord: bytesToB64url(coord),
481
+ cohortEpoch: bytesToB64url(epoch),
482
+ treeTier: 0,
483
+ willingnessBits, // default 'f' → willing at every tier
484
+ loadBuckets: [0, 0, 0, 0],
485
+ windowSeconds: 60,
486
+ topicSummaries: [],
487
+ timestamp: now,
488
+ signature: '',
489
+ };
490
+ g.signature = bytesToB64url(await signPeer(from.key, cohortGossipSigningPayload(g)));
491
+ return encodeCohortMessage(g);
492
+ }
493
+
494
+ export interface SignedRegisterOptions {
495
+ readonly tier?: number;
496
+ readonly treeTier?: number;
497
+ readonly bootstrap?: boolean;
498
+ /** Follow-on cold-start re-issue (treeTier >= 1); mutually exclusive with bootstrap, so pass `bootstrap: false`. */
499
+ readonly followOn?: boolean;
500
+ readonly ttl?: number;
501
+ }
502
+
503
+ export async function signedRegister(participant: Member, topic: Uint8Array, now: number, correlationId: string, opts: SignedRegisterOptions = {}): Promise<RegisterV1> {
504
+ const body: Omit<RegisterV1, 'signature'> = {
505
+ v: 1,
506
+ topicId: bytesToB64url(topic),
507
+ tier: opts.tier ?? 0,
508
+ treeTier: opts.treeTier ?? 0,
509
+ participantCoord: bytesToB64url(participant.bytes),
510
+ ttl: opts.ttl ?? 90_000,
511
+ bootstrap: opts.bootstrap ?? true,
512
+ ...(opts.followOn ? { followOn: true } : {}),
513
+ timestamp: now,
514
+ correlationId: bytesToB64url(new TextEncoder().encode(correlationId)),
515
+ };
516
+ return { ...body, signature: bytesToB64url(await signPeer(participant.key, registerSigningPayload(body))) };
517
+ }
518
+
519
+ /** A plain ping (no `reattach`) — touches `lastPing` only when it lands on the computed primary / override. */
520
+ export async function signedPing(participant: Member, topic: Uint8Array, now: number, correlationId: string): Promise<RenewV1> {
521
+ const body: Omit<RenewV1, 'signature'> = {
522
+ v: 1,
523
+ topicId: bytesToB64url(topic),
524
+ participantId: bytesToB64url(participant.bytes),
525
+ correlationId: bytesToB64url(new TextEncoder().encode(correlationId)),
526
+ timestamp: now,
527
+ };
528
+ return { ...body, signature: bytesToB64url(await signPeer(participant.key, renewSigningPayload(body))) };
529
+ }
530
+
531
+ /** A signed crash-failover re-attach (`reattach: true` in the signed body) — promotes a backup. */
532
+ export async function signedReattach(participant: Member, topic: Uint8Array, now: number, correlationId = 'reattach'): Promise<RenewV1> {
533
+ const body: Omit<RenewV1, 'signature'> = {
534
+ v: 1,
535
+ topicId: bytesToB64url(topic),
536
+ participantId: bytesToB64url(participant.bytes),
537
+ correlationId: bytesToB64url(new TextEncoder().encode(correlationId)),
538
+ timestamp: now,
539
+ reattach: true,
540
+ };
541
+ return { ...body, signature: bytesToB64url(await signPeer(participant.key, renewSigningPayload(body))) };
542
+ }
543
+
544
+ export const slots = createSlotAssigner(new RingHash());
545
+ export const addressing = createTierAddressing(new RingHash());
546
+
547
+ // --- walk-trace reconstruction (feeds the db-core anti-flood invariant predicates) ---
548
+
549
+ /**
550
+ * Map each tier coordinate `coord_d(participant, topic)` for `d ∈ [0, dMax]` to its tier `d` (base64url
551
+ * key → tier). A real walk's routed keys are matched against this map to recover the per-probe tier, so
552
+ * the {@link import("@optimystic/db-core").WalkTrace}-shaped trace can be fed to `outwardMovesArePromoted`
553
+ * / `inwardStepsFollowNoState` / `retriesRestartAtDMax`. `coord_0` is participant-independent (it equals
554
+ * `coord0(topic)`); the bootstrap re-issue at the root reuses that same key, so both root probes map to 0.
555
+ */
556
+ export function coordTierMap(participant: Member, topic: Uint8Array, dMax: number, tierAddr = addressing): Map<string, number> {
557
+ const map = new Map<string, number>();
558
+ // Walk inward so the participant-independent coord_0 wins the key if a deeper coord ever aliased it.
559
+ for (let d = dMax; d >= 0; d--) {
560
+ map.set(bytesToB64url(tierAddr.coord(d, participant.bytes, topic)), d);
561
+ }
562
+ return map;
563
+ }
564
+
565
+ /**
566
+ * Reconstruct a {@link WalkTrace} from the mesh's recorded `routeTrace`, keeping only this walk's coords.
567
+ * Unsigned frames are excluded: a `followOn` cold-start instantiates a child whose background
568
+ * forwarder→parent link RPC routes to the (participant-independent) `coord_0` this walk also probes, so
569
+ * without the filter that link's `no_state` would alias the walk's own root probe and fabricate a spurious
570
+ * inward/outward move. Only the participant's own signed probes belong to the walk trace.
571
+ */
572
+ export function walkTraceFrom(routeTrace: readonly RouteTraceEntry[], tierMap: Map<string, number>, dMax: number): WalkTrace {
573
+ const probes = routeTrace
574
+ .filter((e) => tierMap.has(e.key) && e.signed)
575
+ .map((e) => ({ treeTier: tierMap.get(e.key)!, result: e.result }));
576
+ return { dMax, probes };
577
+ }
578
+
579
+ /**
580
+ * Generate a real-keyed participant whose deterministic slot-**primary** (under `engine`'s cohort epoch
581
+ * + member set) is `primaryNode`. The cohort-side renewal only serves a plain ping / `reattach` with
582
+ * `ok` (the path that touches the record into the gossip deltas) when it lands on the participant's
583
+ * computed primary or a backup; for an arbitrary participant the node nearest `coord_0` is that primary
584
+ * only ~1/k of the time, so seeding/replication via a fixed deciding node is non-deterministic without
585
+ * pinning the participant to it.
586
+ */
587
+ export async function participantPrimaryAt(primaryNode: HostNode, engine: CoordEngine): Promise<Member> {
588
+ const { members, cohortEpoch } = engine.cohort();
589
+ for (;;) {
590
+ const p = await makeMember();
591
+ if (bytesEqual(slots.assignSlots(p.bytes, cohortEpoch, members).primary, primaryNode.member.bytes)) {
592
+ return p;
593
+ }
594
+ }
595
+ }
596
+
597
+ /**
598
+ * Generate a real-keyed participant whose computed primary is `primaryNode` **and** whose `backups[0]`
599
+ * is `backupNode` (under `engine`'s epoch + member set). The crash-failover suite needs a participant
600
+ * whose first warm backup is a known sibling so a `reattach` landing there promotes deterministically.
601
+ */
602
+ export async function participantPrimaryBackupAt(primaryNode: HostNode, backupNode: HostNode, engine: CoordEngine): Promise<Member> {
603
+ const { members, cohortEpoch } = engine.cohort();
604
+ for (;;) {
605
+ const p = await makeMember();
606
+ const slot = slots.assignSlots(p.bytes, cohortEpoch, members);
607
+ if (bytesEqual(slot.primary, primaryNode.member.bytes) && slot.backups[0] !== undefined && bytesEqual(slot.backups[0], backupNode.member.bytes)) {
608
+ return p;
609
+ }
610
+ }
611
+ }
612
+
613
+ // --- topic setup (instantiate coord-0 engines on the cohort + seed willingness quorum) ---
614
+
615
+ export interface TopicSetup {
616
+ readonly coord0: RingCoord;
617
+ /** Engine on every coord-0 cohort member, keyed by member id string. */
618
+ readonly engines: Map<string, CoordEngine>;
619
+ /** The routed primary for `coord_0` (where `routeAct` lands a bootstrap register). */
620
+ readonly deciding: HostNode;
621
+ readonly decidingEngine: CoordEngine;
622
+ /** The coord-0 cohort member id strings (the `wantK` nearest to `coord_0`). */
623
+ readonly cohortIds: readonly string[];
624
+ }
625
+
626
+ /**
627
+ * Instantiate the tier-0 coord engine for `topic` on every **coord-0 cohort member** and seed each one's
628
+ * coord-0 gossip view with every *other* cohort member's willingness, so any cohort member (in
629
+ * particular the routed primary) meets the willingness quorum and can admit. Mirrors the willingness
630
+ * bootstrap the gossip-cadence tests do an idle engine builds no willingness frame, so the first
631
+ * registration needs a seed. Operating on the cohort (not all `N` nodes) keeps setup `O(wantK²)` at
632
+ * scale; for a whole-network cohort (`wantK = N`) it covers every node, matching the live-tier milestone.
633
+ */
634
+ /**
635
+ * Pump one gossip round on **every live coord engine across every node**, then let the async inbound
636
+ * `/cohort-gossip` handlers settle. This is the cold-bootstrap counterpart to {@link setupTopic}'s manual
637
+ * willingness pre-seed: with no seed, the idle-but-willing willingness heartbeat (change A) plus cold-sibling
638
+ * engine instantiation (change B) must carry a fresh cohort from cold to a willingness quorum on their own,
639
+ * and this drives the rounds that make that happen. Call it repeatedly — each call is one "wave": engines a
640
+ * previous wave's heartbeats just instantiated on siblings only get pumped (and so reciprocate their own
641
+ * willingness) on the next wave.
642
+ */
643
+ export async function pumpMeshGossip(mesh: CohortMesh, now: number, settleMs = 30): Promise<void> {
644
+ await Promise.all(mesh.nodes.flatMap((n) => n.host.registry.all().map((e) => e.gossipRound(now))));
645
+ await delay(settleMs);
646
+ }
647
+
648
+ export async function setupTopic(mesh: CohortMesh, topic: Uint8Array, tierAddr = addressing): Promise<TopicSetup> {
649
+ const coord0 = tierAddr.coord0(topic);
650
+ const seedParticipant = mesh.nodes[0]!.member.bytes; // dummy participantCoord (unused at tier 0)
651
+ // Resolve the cohort the host actually assembles around coord_0 from any node (they all agree).
652
+ const decidingNode = mesh.nodeNearest(coord0);
653
+ const probeEngine = decidingNode.host.registry.forCoord(coord0, 0 as Tier, seedParticipant);
654
+ const cohortIds = probeEngine.cohort().members.map((m) => bytesToPeerIdString(m));
655
+ const cohortNodes = cohortIds.map((id) => mesh.nodeOf(id)).filter((n): n is HostNode => n !== undefined);
656
+
657
+ const engines = new Map<string, CoordEngine>();
658
+ for (const node of cohortNodes) {
659
+ engines.set(node.member.idStr, node.host.registry.forCoord(coord0, 0 as Tier, seedParticipant));
660
+ }
661
+ const now = Date.now();
662
+ for (const node of cohortNodes) {
663
+ const epoch = engines.get(node.member.idStr)!.cohort().cohortEpoch;
664
+ for (const other of cohortNodes) {
665
+ if (other.member.idStr === node.member.idStr) {
666
+ continue;
667
+ }
668
+ node.node.receive(PROTOCOLS.gossip, await signedWillingness(other.member, coord0, epoch, now), other.member.peerId);
669
+ }
670
+ }
671
+ await delay(20); // let the async gossip handlers merge the willingness contributions
672
+ return { coord0, engines, deciding: decidingNode, decidingEngine: engines.get(decidingNode.member.idStr)!, cohortIds };
673
+ }