1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679 | import { installClock } from "./det-clock.mjs";
/**
* Implementation-agnostic doubles + scripted scenarios.
*
* Every function here is driven by BOTH `test/vectors/generate.mjs` (against the
* TypeScript build, to produce the fixtures) and `test/*.test.mjs` (against the
* committed ClojureScript dist, to check them). Nothing in this file knows
* which implementation it is running: the classes come in as arguments and
* private members are reached through a `priv(obj, name)` adapter — `__t[name]`
* for the TS build (see helpers/expose.mjs), `obj["_" + name]` for the port.
*
* `Date.now` is pinned while a script runs, so timings (beacon cadence, stale
* sweep, stranger drop, redial backoff) land in the transcript as exact numbers.
*/
// ---------------------------------------------------------------- clock ----
/**
* `withClock` is now a thin wrapper over helpers/det-clock.mjs, which owns the
* macrotask timers as well as `Date.now`. The wrapper keeps the pre-3b calling
* convention exactly — `fn(tick, now)`, where `tick` advances the clock and
* fires NOTHING — so every vector recorded through it stays byte-identical; the
* clock handle arrives as a third argument for scripts that want `advance`,
* `pending` or `ops`.
*/
export function withClock(t0, fn) {
const clock = installClock({ t0 });
try {
return fn(clock.tick, clock.now, clock);
} finally {
clock.uninstall();
}
}
/** Async variant — the scripts are promise-based. */
export async function withClockAsync(t0, fn) {
const clock = installClock({ t0 });
try {
return await fn(clock.tick, clock.now, clock);
} finally {
clock.uninstall();
}
}
/** Silence Math.random so the jittered redial backoff is reproducible. */
export function withFixedRandom(value, fn) {
const real = Math.random;
Math.random = () => value;
try {
return fn();
} finally {
Math.random = real;
}
}
// -------------------------------------------------------------- libp2p ----
/**
* The slice of libp2p that lib/net.ts actually touches. Connections are plain
* records the script pushes/pops; every call the code makes is recorded so the
* transcript covers side effects (dial addresses, hangUps, published topics,
* opened streams) and not just resulting state.
*/
export function fakeLibp2p(myId) {
const calls = [];
const conns = [];
const listeners = new Map();
const handlers = new Map();
const psListeners = new Map();
const node = {
peerId: { toString: () => myId },
getConnections: () => conns.slice(),
getPeers: () => conns.map((c) => c.remotePeer),
dial: async (ma) => {
calls.push(["dial", String(ma)]);
return { remotePeer: { toString: () => "dialed" } };
},
dialProtocol: async (target, protocol) => {
calls.push(["dialProtocol", String(target), protocol]);
if (node.failDialProtocol) throw new Error("unreachable");
return { status: "open", id: `s${calls.length}` };
},
hangUp: async (peer) => {
calls.push(["hangUp", String(peer)]);
},
handle: async (protocol) => {
calls.push(["handle", protocol]);
handlers.set(protocol, true);
},
addEventListener: (evt, fn) => {
if (!listeners.has(evt)) listeners.set(evt, []);
listeners.get(evt).push(fn);
},
stop: async () => calls.push(["stop"]),
services: {
ping: { ping: async () => 1 },
pubsub: {
subscribe: (t) => calls.push(["subscribe", t]),
publish: async (t, bytes) => {
calls.push(["publish", t, bytes.length]);
return { recipients: [] };
},
addEventListener: (evt, fn) => {
if (!psListeners.has(evt)) psListeners.set(evt, []);
psListeners.get(evt).push(fn);
},
},
},
};
return {
node,
calls,
/** Add an open connection; `addr` decides direct (/webrtc) vs relayed. */
connect(peer, addr) {
conns.push({
remotePeer: { toString: () => peer },
status: "open",
remoteAddr: { toString: () => addr },
});
},
disconnect(peer) {
for (let i = conns.length - 1; i >= 0; i--) if (conns[i].remotePeer.toString() === peer) conns.splice(i, 1);
},
connOf(peer, addr, status = "open") {
return { remotePeer: { toString: () => peer }, status, remoteAddr: { toString: () => addr } };
},
};
}
const te = new TextEncoder();
/** Let the fire-and-forget promise chains inside the code under test settle. */
export const tickMicro = async (n = 8) => {
for (let i = 0; i < n; i++) await Promise.resolve();
};
// ------------------------------------------------------------ net script ----
const RELAY = "/dns4/signal.example/tcp/443/tls/ws/p2p/12D3KooRELAY";
/**
* The membership state machine end to end (PROTOCOL.md §4a/§4b): a stranger
* that never proves membership, a member proving it with a directed beacon, the
* WebRTC upgrade, name changes, `bye`, the stale sweep and the stranger drop.
*/
export async function netScript({ Net, RoomCrypto, priv }) {
const T0 = 1_700_000_000_000;
return withClockAsync(T0, async (tick) => {
const log = [];
const rcMe = await RoomCrypto.create("s".repeat(43), "chat.example/v2", "12D3KooME");
const rcMember = await RoomCrypto.create("s".repeat(43), "chat.example/v2", "12D3KooMEMBER");
const rcOther = await RoomCrypto.create("z".repeat(43), "chat.example/v2", "12D3KooOTHER");
const hub = fakeLibp2p("12D3KooME");
const events = [];
const ev = {
peerState: (p, s, n) => events.push(["peerState", p, s, n ?? null]),
peerGone: (p) => events.push(["peerGone", p]),
message: (f, p) => events.push(["message", f, p]),
binary: (f, d) => events.push(["binary", f, [...d]]),
peerReady: (p) => events.push(["peerReady", p]),
status: (u) => events.push(["status", u]),
};
const net = new Net(hub.node, rcMe, ev, () => "me", RELAY);
const p = (name) => priv(net, name);
// debugState carries `name: undefined` for unnamed peers, which JSON drops
// on one side and can surface as null on the other — flatten it explicitly.
const snap = (label) =>
log.push({
label,
events: events.splice(0),
state: net.debugState().map((r) => [r.id, r.state, r.name ?? null, !!r.ready, !!r.verified, r.conns]),
open: net.openPeers(),
});
snap("fresh");
// Discovery is the entry point that starts the stranger-drop clock (#recFor).
// A stranger is dialed over the BARE circuit only — never /webrtc (§4b).
await p("dialPeer")("12D3KooSTRANGER");
hub.connect("12D3KooSTRANGER", `${RELAY}/p2p-circuit/p2p/12D3KooSTRANGER`);
p("onConnChange")(hub.connOf("12D3KooSTRANGER", `${RELAY}/p2p-circuit/p2p/12D3KooSTRANGER`));
await tickMicro();
snap("stranger-connected");
// ... and a foreign-room beacon it sends does not decrypt
const foreign = await rcOther.sealMsg({ kind: "presence", op: "beacon", name: "nope", ts: Date.now() });
await p("onTopicMessage")("12D3KooSTRANGER", te.encode(JSON.stringify(foreign)));
snap("stranger-foreign-beacon");
tick(21_000);
p("sweep")();
snap("stranger-swept");
// a real member: discovered, dialed over the bare circuit, then proves it
await p("dialPeer")("12D3KooMEMBER");
hub.connect("12D3KooMEMBER", `${RELAY}/p2p-circuit/p2p/12D3KooMEMBER`);
p("onConnChange")(hub.connOf("12D3KooMEMBER", `${RELAY}/p2p-circuit/p2p/12D3KooMEMBER`));
await tickMicro();
snap("member-circuit");
const beacon = await rcMember.sealMsg({ kind: "presence", op: "beacon", name: "friend", ts: Date.now() });
await p("onTopicMessage")("12D3KooMEMBER", te.encode(JSON.stringify(beacon)));
await tickMicro();
snap("member-verified");
// WebRTC upgrade lands
hub.disconnect("12D3KooMEMBER");
hub.connect("12D3KooMEMBER", `${RELAY}/p2p-circuit/webrtc/p2p/12D3KooMEMBER`);
p("onConnChange")(hub.connOf("12D3KooMEMBER", `${RELAY}/p2p-circuit/webrtc/p2p/12D3KooMEMBER`));
await tickMicro();
snap("member-direct");
// a directed app message from the member (frame kind 0x00)
tick(1000);
const env = await rcMember.sealMsg({ kind: "hello", name: "friend" });
const jsonBody = te.encode(JSON.stringify(env));
const frame0 = new Uint8Array(1 + jsonBody.length);
frame0[0] = 0x00;
frame0.set(jsonBody, 1);
await p("onMsgFrame")("12D3KooMEMBER", frame0);
await tickMicro();
snap("member-directed-json");
// a directed binary payload (frame kind 0x01)
const sealed = new Uint8Array(await rcMember.sealMsgBinary(new Uint8Array([1, 2, 3, 4])));
const frame1 = new Uint8Array(1 + sealed.length);
frame1[0] = 0x01;
frame1.set(sealed, 1);
await p("onMsgFrame")("12D3KooMEMBER", frame1);
snap("member-directed-binary");
// garbage frames: too short, unknown kind, undecryptable body
await p("onMsgFrame")("12D3KooMEMBER", new Uint8Array([0x00]));
await p("onMsgFrame")("12D3KooMEMBER", new Uint8Array([0x7f, 1, 2, 3]));
await p("onMsgFrame")("12D3KooMEMBER", new Uint8Array([0x00, 123, 34, 125]));
snap("member-garbage-frames");
// name change over the topic
tick(11_000);
const renamed = await rcMember.sealMsg({ kind: "presence", op: "beacon", name: "renamed", ts: Date.now() });
await p("onTopicMessage")("12D3KooMEMBER", te.encode(JSON.stringify(renamed)));
await tickMicro();
snap("member-renamed");
// stale: no decryptable traffic for 31 s
tick(31_000);
p("sweep")();
snap("member-stale");
// a second member says bye explicitly
hub.connect("12D3KooBYE", `${RELAY}/p2p-circuit/webrtc/p2p/12D3KooBYE`);
const rcBye = await RoomCrypto.create("s".repeat(43), "chat.example/v2", "12D3KooBYE");
const hi = await rcBye.sealMsg({ kind: "presence", op: "beacon", name: "leaver", ts: Date.now() });
await p("onTopicMessage")("12D3KooBYE", te.encode(JSON.stringify(hi)));
await tickMicro();
snap("bye-peer-joined");
const bye = await rcBye.sealMsg({ kind: "presence", op: "bye", name: "leaver", ts: Date.now() });
await p("onTopicMessage")("12D3KooBYE", te.encode(JSON.stringify(bye)));
snap("bye-peer-left");
return { transcript: log, calls: hub.calls.map((c) => c.map(String)) };
});
}
/** Relay lifecycle: ensureRelay, ping-driven hangUp, jittered backoff. */
export async function relayScript({ Net, RoomCrypto, priv }) {
const T0 = 1_700_000_000_000;
return withClockAsync(T0, async (tick) => {
const out = [];
const rc = await RoomCrypto.create("s".repeat(43), "chat.example/v2", "12D3KooME");
const hub = fakeLibp2p("12D3KooME");
const status = [];
const net = new Net(hub.node, rc, {
peerState() {}, peerGone() {}, message() {}, binary() {}, peerReady() {},
status: (u) => status.push(u),
}, () => "me", RELAY);
const p = (name) => priv(net, name);
out.push({ label: "relayPeer", value: net.relayUp });
p("ensureRelay")();
await Promise.resolve();
out.push({ label: "after-dial", status: status.splice(0), calls: hub.calls.splice(0) });
hub.connect("12D3KooRELAY", `${RELAY}`);
p("ensureRelay")();
out.push({ label: "already-connected", status: status.splice(0), calls: hub.calls.splice(0), up: net.relayUp });
hub.disconnect("12D3KooRELAY");
p("onConnChange")(hub.connOf("12D3KooRELAY", RELAY, "closed"));
out.push({ label: "relay-lost", status: status.splice(0), up: net.relayUp });
// Backoff sequence. Math.random is pinned at 0.5 → delay = base * 1.0, and
// the fake setTimeout returns 0 — falsy, so #redialTimer never blocks the
// next schedule and one loop walks the whole 500·2ⁿ (cap 15 s) ladder.
// (That falsy-0 detail is exactly the JS-truthiness trap a CLJS port has to
// reproduce: `0` is truthy in Clojure, so the guard must test JS truthiness.)
// A FRESH Net: the one above already has a live redial timer pending from
// #onConnChange, and #redialTimer is exactly what blocks a second schedule.
const hub2 = fakeLibp2p("12D3KooME2");
const net2 = new Net(hub2.node, rc, {
peerState() {}, peerGone() {}, message() {}, binary() {}, peerReady() {}, status() {},
}, () => "me", RELAY);
const delays = withFixedRandom(0.5, () => {
const seen = [];
const realSetTimeout = globalThis.setTimeout;
globalThis.setTimeout = (_fn, ms) => {
seen.push(ms);
return 0;
};
try {
for (let i = 0; i < 8; i++) priv(net2, "scheduleRedial")();
} finally {
globalThis.setTimeout = realSetTimeout;
}
return seen;
});
out.push({ label: "backoff", delays });
tick(0);
return out;
});
}
// ------------------------------------------------------------- orbit/helia ----
/**
* The slice of @orbitdb/core + helia that lib/log.ts touches. Entries are plain
* records; `storage` and `blockstore` are Maps. Enough to drive the entry
* projection, the author binding, the local-reachability pre-check and the
* image DAG walk without a network or a real IPFS node.
*/
export function fakeStack({ entries = [], identities = {}, blocks = new Map() } = {}) {
const calls = [];
const joined = new Set(entries.map((e) => e.hash));
const db = {
address: "/orbitdb/zdpuFAKE",
name: "fake",
add: async (value) => {
calls.push(["add", JSON.stringify(value)]);
return `hash-${calls.length}`;
},
log: {
iterator: ({ amount } = {}) => ({
// newest -> oldest, exactly what the oplog iterator yields
[Symbol.asyncIterator]() {
const list = entries.slice().reverse().slice(0, amount ?? entries.length);
let i = 0;
return { next: async () => (i < list.length ? { value: list[i++], done: false } : { done: true }) };
},
}),
has: async (hash) => joined.has(hash),
joinEntry: async (entry) => {
calls.push(["joinEntry", entry.hash]);
if (entry.hash === "REJECT") throw new Error("access denied");
if (joined.has(entry.hash)) return false;
joined.add(entry.hash);
entries.push(entry);
return true;
},
storage: {
get: async (hash) => blocks.get(hash),
put: async (hash, bytes) => {
calls.push(["storage.put", hash, bytes.length]);
blocks.set(hash, bytes);
},
},
},
sync: { start: async () => calls.push(["sync.start"]), stop: async () => {} },
events: { on: (evt) => calls.push(["events.on", evt]), off: () => {} },
close: async () => calls.push(["db.close"]),
drop: async () => calls.push(["db.drop"]),
};
const orbitdb = {
id: "orbit-fake",
// `hash` is a base58btc CID over the record's dag-cbor block, exactly the
// form myIdentityBlock() re-parses (an unparseable one must yield null).
identity: {
id: "myAuthor",
publicKey: "myKey",
hash: "zdj7WWeQ43G6JJvLWQWZpyHuAMq6uYWRjkBXFad11vE2LHhQ7",
bytes: new Uint8Array([9, 9]),
},
identities: { getIdentity: async (h) => identities[h] },
stop: async () => calls.push(["orbitdb.stop"]),
};
const store = new Map(); // cid string -> bytes
const pins = new Set();
const helia = {
blockstore: {
has: async (cid) => store.has(cid.toString()),
get: async (cid) => store.get(cid.toString()),
put: async (cid, bytes) => {
calls.push(["blockstore.put", cid.toString(), bytes.length]);
store.set(cid.toString(), bytes);
},
},
pins: {
add: (cid) => asyncGen([cid], () => calls.push(["pins.add", cid.toString()]) && pins.add(cid.toString())),
rm: (cid) => asyncGen([cid], () => calls.push(["pins.rm", cid.toString()]) && pins.delete(cid.toString())),
},
gc: async () => calls.push(["gc"]),
logger: { forComponent: () => Object.assign(() => {}, { error: () => {}, trace: () => {}, enabled: false }) },
};
return { db, orbitdb, helia, calls, store, pins, entries, joined };
}
function asyncGen(items, onStart) {
return {
[Symbol.asyncIterator]() {
onStart?.();
let i = 0;
return { next: async () => (i < items.length ? { value: items[i++], done: false } : { done: true }) };
},
};
}
const mkEntry = (o) => ({ next: [], refs: [], clock: { id: "c", time: 0 }, v: 2, ...o });
/**
* The log's projection and verification surface: how a stored entry becomes a
* LogEntryMeta, when the author id survives the signer↔identity binding, the
* sweep for stragglers behind a branch join, and the ingest result table.
*/
export async function logScript({ RoomLog, RoomCrypto, makeLogEncryption, priv }) {
const out = [];
const rc = await RoomCrypto.create("l".repeat(43), "chat.example/v2", "12D3KooME");
const encryption = await makeLogEncryption(rc);
const imgCipher = await rc.imgCipher();
const identities = {
// a sueta identity whose publicKey matches the entry key -> author survives
idOK: { id: "b64urlPub", publicKey: "deviceKeyA", type: "sueta" },
// same identity referenced by an entry signed with a DIFFERENT key -> ""
idForged: { id: "victimPub", publicKey: "deviceKeyVICTIM", type: "sueta" },
// OrbitDB's own default provider
idPublicKey: { id: "devIdB", publicKey: "deviceKeyB", type: "publickey" },
// an unknown provider type is not trusted for authorship
idWeird: { id: "whoPub", publicKey: "deviceKeyC", type: "ethereum" },
};
const entries = [
mkEntry({ hash: "e1", identity: "idOK", key: "deviceKeyA", clock: { id: "c", time: 1 },
payload: { op: "ADD", key: null, value: { t: "chat", ts: 10, name: "a", text: "one" } } }),
mkEntry({ hash: "e2", identity: "idForged", key: "deviceKeyATTACKER", clock: { id: "c", time: 2 },
payload: { op: "ADD", key: null, value: { t: "chat", ts: 20, name: "victim", text: "forged" } } }),
mkEntry({ hash: "e3", identity: "idPublicKey", key: "deviceKeyB", clock: { id: "c", time: 3 },
payload: { op: "ADD", key: null, value: { t: "react", ts: 30, name: "b", target: "e1", emoji: "🔥", op: "add" } } }),
mkEntry({ hash: "e4", identity: "idWeird", key: "deviceKeyC", clock: { id: "c", time: 4 },
payload: { op: "ADD", key: null, value: { t: "name", ts: 40, name: "c" } } }),
// payload not wrapped by the events DB (defensive branch)
mkEntry({ hash: "e5", identity: "idOK", key: "deviceKeyA", clock: { id: "c", time: 5 },
payload: { t: "call", ts: 50, name: "a" } }),
// non-object / typeless payloads must be dropped, not emitted
mkEntry({ hash: "e6", identity: "idOK", key: "deviceKeyA", payload: { op: "ADD", value: 42 } }),
mkEntry({ hash: "e7", identity: "idOK", key: "deviceKeyA", payload: { op: "ADD", value: { ts: 1 } } }),
// no identity ref at all, and an identity ref with no signing key
mkEntry({ hash: "e8", key: "deviceKeyA", payload: { op: "ADD", value: { t: "chat", ts: 80, name: "x", text: "anon" } } }),
mkEntry({ hash: "e9", identity: "idOK", payload: { op: "ADD", value: { t: "chat", ts: 90, name: "y", text: "nokey" } } }),
];
const stack = fakeStack({ entries: entries.slice(), identities });
const log = new RoomLog(stack.db, stack.orbitdb, stack.helia, imgCipher, encryption);
const emitted = [];
log.onEntry = (e) => emitted.push({ hash: e.hash, from: e.from, clock: e.clock, op: e.op });
log.onError = (err) => emitted.push({ error: String(err.message ?? err) });
out.push({ label: "address", value: log.address });
out.push({ label: "myAuthorId", value: log.myAuthorId });
const n = await log.loadTail(500);
out.push({ label: "loadTail", count: n, emitted: emitted.splice(0) });
// second pass emits nothing: #seen dedupes by hash
const n2 = await log.loadTail(500);
out.push({ label: "loadTail-again", count: n2, emitted: emitted.splice(0) });
// entryMeta: the display-only projection for an entry that cannot join yet
const unjoinable = mkEntry({ hash: "eX", identity: "idOK", key: "deviceKeyA", clock: { id: "c", time: 7 },
payload: { op: "ADD", value: { t: "chat", ts: 70, name: "a", text: "pending" } } });
out.push({ label: "entryMeta-identity-not-local", value: await log.entryMeta(unjoinable) });
out.push({ label: "entryMeta-bad-payload", value: await log.entryMeta(mkEntry({ hash: "eY", payload: null })) });
// ingest: duplicate / deferred / rejected / joined
const { CID } = await import("multiformats/cid");
const { base58btc } = await import("multiformats/bases/base58");
// An entry whose identity ref is neither a stored block nor already in the
// author cache is DEFERRED — never fetched over bitswap with a 30 s timeout.
const unknownIdCid = "zdj7WWeQ43G6JJvLWQWZpyHuAMq6uYWRjkBXFad11vE2LHhQ7";
out.push({ label: "ingest-duplicate", value: await log.ingestEntry(entries[0]) });
out.push({
label: "ingest-deferred-identity-block-absent",
value: await log.ingestEntry(mkEntry({ ...unjoinable, hash: "eDEF", identity: unknownIdCid })),
});
// an identity already resolved this session (author cache) counts as local
out.push({ label: "ingest-joined-identity-cached", value: await log.ingestEntry(unjoinable) });
// ... and so does one whose block is in the local blockstore. NOTE the
// blockstore is keyed by cid.toString() — the DEFAULT (base32) encoding —
// while the entry's identity ref is base58btc: the code parses with
// base58btc and looks up by the re-encoded string, which is what a real
// Helia blockstore does too.
const idCid = CID.parse(unknownIdCid, base58btc);
stack.store.set(idCid.toString(), new Uint8Array([1]));
out.push({
label: "ingest-joined-identity-stored",
value: await log.ingestEntry(mkEntry({ ...unjoinable, hash: "eZ", identity: unknownIdCid })),
});
out.push({
label: "ingest-rejected",
value: await log.ingestEntry(mkEntry({ hash: "REJECT", identity: unknownIdCid, key: "k" })),
});
// an entry with no identity ref at all is deferred, never joined
out.push({ label: "ingest-no-identity-ref", value: await log.ingestEntry(mkEntry({ hash: "eNOID", key: "k" })) });
await log.emitUnseen();
out.push({ label: "emitUnseen", emitted: emitted.splice(0) });
// append + the onAppended hook (which must never fail an append)
const hooks = [];
log.onAppended = (h) => {
hooks.push(h);
throw new Error("mailbox is down");
};
out.push({ label: "append", hash: await log.append({ t: "chat", ts: 1, name: "me", text: "hi" }), hooks: hooks.slice() });
// sealedEntryBytes / putEntryBlock / hasEntry
await log.putEntryBlock("blk1", new Uint8Array([7, 7, 7]));
out.push({ label: "sealedEntryBytes", value: [...((await log.sealedEntryBytes("blk1")) ?? [])] });
out.push({ label: "sealedEntryBytes-missing", value: await log.sealedEntryBytes("nope") });
out.push({ label: "hasEntry", yes: await log.hasEntry("e1"), no: await log.hasEntry("nope") });
// myIdentityBlock: hash is parsed as base58btc
const mib = log.myIdentityBlock();
out.push({ label: "myIdentityBlock", cid: mib?.cid?.toString() ?? null, bytes: [...(mib?.bytes ?? [])] });
// image DAG walk: root + raw leaves is fine, root + dag-pb child is refused
const dagPb = await import("@ipld/dag-pb");
const { sha256 } = await import("multiformats/hashes/sha2");
const raw = { code: 0x55, name: "raw" };
const leafBytes = new Uint8Array([1, 2, 3]);
const leafCid = CID.createV1(raw.code, await sha256.digest(leafBytes));
const rootBytes = dagPb.encode({ Data: undefined, Links: [{ Hash: leafCid, Name: "", Tsize: 3 }] });
const rootCid = CID.createV1(0x70, await sha256.digest(rootBytes));
stack.store.set(leafCid.toString(), leafBytes);
stack.store.set(rootCid.toString(), rootBytes);
const dag = await log.imageDagBlocks(rootCid.toString());
out.push({ label: "imageDagBlocks", order: dag.map((b) => b.cid.toString()), rootLast: dag.at(-1)?.cid?.toString() === rootCid.toString() });
out.push({ label: "imageDagBlocks-missing", value: await log.imageDagBlocks(leafCid.toString().replace(/.$/, "a")) });
// a two-level dag-pb DAG is refused outright (partial DAGs must not ship)
const midBytes = dagPb.encode({ Data: undefined, Links: [{ Hash: rootCid, Name: "", Tsize: 3 }] });
const midCid = CID.createV1(0x70, await sha256.digest(midBytes));
stack.store.set(midCid.toString(), midBytes);
out.push({ label: "imageDagBlocks-too-deep", value: await log.imageDagBlocks(midCid.toString()) });
out.push({ label: "pinImageIfLocal", value: await log.pinImageIfLocal(rootCid.toString()) });
out.push({ label: "pinImageIfLocal-absent", value: await log.pinImageIfLocal(midCid.toString()) });
// images: seal -> unixfs -> read back is covered by the round-trip test; here
// only the raw block plumbing (no unixfs) is pinned
out.push({ label: "rawBlock", value: [...((await log.rawBlock(leafCid)) ?? [])] });
await log.putRawBlock(CID.parse(leafCid.toString()), new Uint8Array([4, 5]));
await log.putIdentityBlock(CID.parse(leafCid.toString()), new Uint8Array([6]));
await log.pruneImages(new Set([rootCid.toString()]), [rootCid.toString(), leafCid.toString()]);
await log.close();
out.push({ label: "calls", value: stack.calls.map((c) => c.map(String)) });
void base58btc;
void priv;
return out;
}
// ----------------------------------------------------------- chat / board ----
/** The reaction fold, message projection and history ordering (§5). */
export async function chatScript({ ChatClient, priv }) {
const out = [];
const client = new ChatClient(null, null, "ROOMID", "me", null, "chat.example/v2");
const events = [];
client.on("message", (m) => events.push(["message", m.id, m.name, m.text ?? null, reactionsOf(m)]));
client.on("reaction", (t, e, by, op) => events.push(["reaction", t, e, by, op]));
client.on("peer", (id, p) => events.push(["peer", id, p.state, p.name ?? null, p.ident?.pub ?? null]));
client.on("peerGone", (id) => events.push(["peerGone", id]));
const apply = priv(client, "applyEntry");
const E = (hash, from, clock, op) => ({ hash, from, clock, op });
// out-of-order arrival: a reaction lands before the message it targets, then
// the message folds the pending reaction in
apply(E("r1", "authorB", 5, { t: "react", ts: 5, name: "b", target: "m1", emoji: "🔥", op: "add" }));
out.push({ label: "reaction-before-message", events: events.splice(0) });
apply(E("m1", "authorA", 1, { t: "chat", ts: 100, name: "a", text: "hello" }));
out.push({ label: "message-folds-pending-reaction", events: events.splice(0) });
// last-op-wins per (author, target, emoji) by (clock, hash)
apply(E("r2", "authorB", 6, { t: "react", ts: 6, name: "b", target: "m1", emoji: "🔥", op: "remove" }));
out.push({ label: "reaction-remove-newer-clock", events: events.splice(0), history: hist(client) });
apply(E("r3", "authorB", 4, { t: "react", ts: 4, name: "b", target: "m1", emoji: "🔥", op: "add" }));
out.push({ label: "reaction-stale-clock-ignored", events: events.splice(0), history: hist(client) });
apply(E("r0", "authorB", 6, { t: "react", ts: 6, name: "b", target: "m1", emoji: "🔥", op: "add" }));
out.push({ label: "reaction-same-clock-lower-hash-wins", events: events.splice(0), history: hist(client) });
// an unattributed author ("" from the binding check) never lands in a set
apply(E("r4", "", 9, { t: "react", ts: 9, name: "?", target: "m1", emoji: "👍", op: "add" }));
out.push({ label: "reaction-empty-author", events: events.splice(0), history: hist(client) });
// img + thread + ordering by (ts, id)
apply(E("m3", "authorA", 2, { t: "img", ts: 300, name: "a", cid: "bafyIMG", mime: "image/webp", bytes: 12, w: 4, h: 5, thread: "m1" }));
apply(E("m2", "authorC", 3, { t: "chat", ts: 200, name: "c", text: "second", thread: "m1" }));
apply(E("m0", "authorC", 3, { t: "chat", ts: 100, name: "c", text: "tie-with-m1" }));
out.push({ label: "img-and-threads", events: events.splice(0), history: hist(client) });
// ops the projector ignores
apply(E("n1", "authorA", 7, { t: "name", ts: 700, name: "renamed" }));
apply(E("c1", "authorA", 8, { t: "call", ts: 800, name: "a" }));
apply(E("l1", "authorA", 9, { t: "legacy", ts: 900, msgs: [] }));
out.push({ label: "ignored-ops", events: events.splice(0), historyLen: client.history().length });
// live payloads: hello/name shape gating
const onLive = priv(client, "onLive");
onLive("peer1", { kind: "hello", name: " a very long name that exceeds the forty character clamp " });
onLive("peer2", { kind: "name", name: "bee" });
onLive("peer3", { kind: "draw", id: "x" });
onLive("peer4", null);
onLive("peer5", { kind: "hello" });
out.push({ label: "live-payloads", events: events.splice(0), peers: peersOf(client) });
const onPeerState = priv(client, "onPeerState");
onPeerState("peer2", "direct", "beeName");
onPeerState("peer9", "relayed", undefined);
out.push({ label: "peer-state", events: events.splice(0), peers: peersOf(client) });
return out;
}
const reactionsOf = (m) => [...m.reactions.entries()].map(([e, by]) => [e, [...by].sort()]).sort();
const hist = (c) => c.history().map((m) => [m.id, m.ts, m.text ?? `img:${m.img?.cid}`, m.thread ?? null, reactionsOf(m)]);
const peersOf = (c) => [...c.peers().entries()].map(([id, p]) => [id, p.state, p.name ?? null, p.ident?.pub ?? null, p.ident?.fp?.emoji ?? null]).sort();
/** The board element fold: add/edit/del, terminal tombstones, stroke encoding. */
export async function boardScript({ BoardClient, priv }) {
const out = [];
const client = new BoardClient(null, null, "ROOMID", "me");
const events = [];
client.on("element", (el, from) => events.push(["element", el.id, el.k, from]));
client.on("edited", (id, patch) => events.push(["edited", id, patch]));
client.on("deleted", (ids) => events.push(["deleted", ids]));
const apply = priv(client, "applyEntry");
const E = (hash, from, op) => ({ hash, from, clock: 0, op });
apply(E("a1", "authorA", { t: "add", ts: 1, name: "a", el: { id: "s1", k: "stroke", x: 1, y: 2, pts: [3, 4], w: 2, c: 0 } }));
apply(E("a2", "authorB", { t: "add", ts: 2, name: "b", el: { id: "t1", k: "text", x: 5, y: 6, text: "hi", size: 16, c: 3 } }));
out.push({ label: "adds", events: events.splice(0), elements: els(client) });
apply(E("e1", "authorA", { t: "edit", ts: 3, name: "a", id: "s1", p: { w: 8, c: 5 } }));
apply(E("e2", "authorA", { t: "edit", ts: 4, name: "a", id: "missing", p: { w: 1 } }));
apply(E("e3", "authorA", { t: "edit", ts: 5, name: "a", id: "t1", p: null }));
out.push({ label: "edits", events: events.splice(0), elements: els(client) });
apply(E("d1", "authorB", { t: "del", ts: 6, name: "b", ids: ["s1"] }));
out.push({ label: "delete", events: events.splice(0), elements: els(client) });
// tombstones are terminal: a re-add of a deleted id is ignored forever
apply(E("a3", "authorA", { t: "add", ts: 7, name: "a", el: { id: "s1", k: "stroke", x: 0, y: 0, pts: [], w: 1, c: 1 } }));
out.push({ label: "readd-after-delete", events: events.splice(0), elements: els(client) });
apply(E("d2", "authorB", { t: "del", ts: 8, name: "b" }));
apply(E("a4", "authorA", { t: "add", ts: 9, name: "a", el: { k: "stroke" } }));
apply(E("a5", "authorA", { t: "add", ts: 10, name: "a" }));
apply(E("u1", "authorA", { t: "wat", ts: 11 }));
out.push({ label: "malformed-ops", events: events.splice(0), elements: els(client) });
return out;
}
const els = (c) => c.elements().map((e) => JSON.parse(JSON.stringify(e))).sort((a, b) => (a.id < b.id ? -1 : 1));
|