aboutsummaryrefslogtreecommitdiffstats
path: root/tools/marmot-interop-test.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-02 21:13:27 -0700
committerAdam Soltys <[email protected]>2026-10-02 21:13:27 -0700
commit0d698629a75f21046d6e544b34c42e0b2803b5a9 (patch)
tree1f211e1a71be848e8c1dc09d76d6ce148b651214 /tools/marmot-interop-test.js
parent544c61e8c27652c71501277b10875eeead0a402c (diff)
downloadcoinosv3-0d698629a75f21046d6e544b34c42e0b2803b5a9.tar.gz
coinosv3-0d698629a75f21046d6e544b34c42e0b2803b5a9.zip
White Noise (Marmot) chats: MLS group messaging in native JS
The Chat tab now speaks the protocol the White Noise app speaks, beside Concord communities and NIP-17 DMs, without anyone having to pick one: - A two-person group with no name is a direct message. It lands in the same thread as that person's NIP-17 messages, and a reply goes back the way their last message came. A first message goes by White Noise only to someone whose KeyPackage another app published (a coinos wallet reads NIP-17 on all its devices; an MLS group reaches one). - Groups get rows of their own in the Messages list, a New group button, an invitation card, members, add/remove/rename for admins, leave. - Every wallet publishes a KeyPackage (kind 30443), so it can be invited. src/mls.js is RFC 9420 for ciphersuite 0x0001 on the noble libs (no WebAssembly), with the extensions-draft pieces Marmot needs (app_data_dictionary, app_data_update, app_ephemeral, self_remove). src/marmot.js is the Marmot layer: components, the account identity proof, the 30443/444/445 event shapes and the group engine (publish-before-apply, retained epochs, same-epoch tie-break). State is per device, in IndexedDB (src/marmot-store.js). The whole stack is one lazy chunk (72 KB). Tested against the RFC 9420 vectors (tools/mls-vectors-test.js) and against the Rust reference, MDK's wn CLI, at the protocol level (tools/marmot-interop-test.js) and through the real UI (tools/marmot-chat-test.js). Not yet: attachments in these chats, push for group messages, the app-message witness boost in fork selection. Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'tools/marmot-interop-test.js')
-rw-r--r--tools/marmot-interop-test.js243
1 files changed, 243 insertions, 0 deletions
diff --git a/tools/marmot-interop-test.js b/tools/marmot-interop-test.js
new file mode 100644
index 0000000..17b3e12
--- /dev/null
+++ b/tools/marmot-interop-test.js
@@ -0,0 +1,243 @@
+// White Noise interop: src/marmot.js + src/mls.js against the Rust reference
+// (MDK's `wn` CLI + `wnd` daemon) over a local relay.
+//
+// bun run tools/marmot-interop-test.js
+//
+// Needs, and skips cleanly without:
+// - a relay on RELAY (default ws://127.0.0.1:27777; the MDK repo's
+// `just relay-up`, or any strfry)
+// - `wn` on PATH built from github.com/marmot-protocol/mdk
+// (cargo install --path crates/cli --locked --bins), with a daemon running:
+// export WN_HOME=… WN_SECRET_STORE=file WN_ALLOW_LOOPBACK_RELAYS=1
+// wn daemon start --discovery-relays $RELAY --default-account-relays $RELAY
+//
+// Covers both directions: wn invites us (Welcome → join → messages → our
+// self-update commit), and we found a group, invite wn accounts, rename,
+// add and remove members, and commit a wn member's SelfRemove.
+
+import { spawnSync } from 'node:child_process';
+import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
+import * as nip19 from 'nostr-tools/nip19';
+import { wrapDM, unwrapDM } from '../src/dm.js';
+import * as M from '../src/marmot.js';
+
+const RELAY = process.env.RELAY || 'ws://127.0.0.1:27777';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let failed = 0;
+const ok = (cond, label, extra) => {
+ if (cond) console.log(' ok ' + label);
+ else { failed++; console.log(' FAIL ' + label + (extra ? ' — ' + extra : '')); }
+};
+
+function wn(args, { account, input } = {}) {
+ const r = spawnSync('wn', ['--json', ...(account ? ['--account', account] : []), ...args], { encoding: 'utf8', input });
+ const line = (r.stdout || '').trim().split('\n').filter(Boolean).at(-1) || '';
+ try {
+ const j = JSON.parse(line);
+ if (!j.ok) return { error: j.error, raw: line };
+ return j.result;
+ } catch { return { error: { message: (r.stderr || r.stdout || 'no output').trim() } }; }
+}
+
+// ---- a tiny relay client ----
+let ws, subs = new Map(), acks = new Map(), sid = 0;
+function connect() {
+ return new Promise((resolve, reject) => {
+ ws = new WebSocket(RELAY);
+ ws.onopen = resolve;
+ ws.onerror = () => reject(new Error('no relay at ' + RELAY));
+ ws.onmessage = (m) => {
+ const d = JSON.parse(m.data);
+ if (d[0] === 'EVENT') subs.get(d[1])?.events.push(d[2]);
+ else if (d[0] === 'EOSE') subs.get(d[1])?.done();
+ else if (d[0] === 'OK') acks.get(d[1])?.(d[2] ? null : new Error(d[3] || 'rejected'));
+ };
+ });
+}
+function query(filter) {
+ return new Promise((resolve) => {
+ const id = 'q' + ++sid, events = [];
+ const done = () => { subs.delete(id); ws.send(JSON.stringify(['CLOSE', id])); resolve(events); };
+ subs.set(id, { events, done });
+ ws.send(JSON.stringify(['REQ', id, filter]));
+ setTimeout(done, 3000);
+ });
+}
+function publish(ev) {
+ return new Promise((resolve, reject) => {
+ acks.set(ev.id, (err) => { acks.delete(ev.id); err ? reject(err) : resolve(true); });
+ ws.send(JSON.stringify(['EVENT', ev]));
+ setTimeout(() => reject(new Error('publish timed out')), 4000);
+ });
+}
+
+// ---- our side: one account, one device ----
+const sk = generateSecretKey(), pk = getPublicKey(sk);
+const sign = async (e) => finalizeEvent(e, sk);
+const now = () => Math.floor(Date.now() / 1000);
+let device, kps = [];
+
+async function publishKeyPackage() {
+ const kp = M.newKeyPackage(device);
+ const ev = await sign(M.keyPackageEvent(device, kp));
+ await publish(ev);
+ kps.push({ ...kp, eventId: ev.id });
+ return ev;
+}
+
+const seenWraps = new Set();
+async function welcomes() {
+ const out = [];
+ for (const wrap of await query({ kinds: [1059], '#p': [pk] })) {
+ if (seenWraps.has(wrap.id)) continue;
+ seenWraps.add(wrap.id);
+ const got = await unwrapDM(wrap, sk);
+ if (got && got.rumor.kind === 444) out.push(got.rumor);
+ }
+ return out;
+}
+
+// pull everything on the group's routing addresses through the engine
+async function sync(g) {
+ const out = [];
+ for (const r of M.routes(g)) {
+ const evs = (await query({ kinds: [445], '#h': [r.id] })).sort((a, b) => a.created_at - b.created_at);
+ for (const ev of evs) out.push(...M.receive(g, ev));
+ }
+ if (process.env.DEBUG) for (const o of out) if (!o.id) console.log(' ·', JSON.stringify(o), 'epoch', g.tip.epoch, 'held', g.held.length);
+ return out;
+}
+const texts = (g) => g.log.filter((m) => m.kind === 9).map((m) => m.content);
+
+async function commit(g, proposals) {
+ const p = M.stage(g, { proposals });
+ await publish(p.ev);
+ M.confirm(g);
+ return p;
+}
+
+async function waitFor(fn, label, ms = 15000) {
+ const until = Date.now() + ms;
+ for (;;) {
+ const v = await fn();
+ if (v) return v;
+ if (Date.now() > until) { ok(false, label, 'timed out'); return null; }
+ await sleep(700);
+ }
+}
+
+async function main() {
+ try { await connect(); } catch (e) { console.log('SKIP: ' + e.message); return; }
+ const who = wn(['whoami']);
+ if (who.error) { console.log('SKIP: wn daemon not reachable (' + who.error.message + ')'); return; }
+
+ const mk = () => { const r = wn(['create-identity']); if (r.error) throw new Error('create-identity: ' + JSON.stringify(r.error)); return r.account_id; };
+ const alice = mk(), bob = mk();
+ console.log('wn accounts', alice.slice(0, 8), bob.slice(0, 8), '· us', pk.slice(0, 8));
+
+ // an account other clients can find: relay lists + a KeyPackage
+ await publish(await sign({ kind: 10002, created_at: now(), tags: [['r', RELAY]], content: '' }));
+ await publish(await sign({ kind: 10050, created_at: now(), tags: [['relay', RELAY]], content: '' }));
+ device = await M.newDevice(sign, pk);
+ const kpEv = await publishKeyPackage();
+ ok(!!M.readKeyPackageEvent(kpEv), 'our KeyPackage event validates locally');
+
+ console.log('\n1. wn reads our KeyPackage');
+ const check = wn(['keys', 'check', pk]);
+ ok(!check.error && JSON.stringify(check).includes('true'), 'wn keys check accepts it', JSON.stringify(check).slice(0, 300));
+
+ console.log('\n2. wn founds a group and invites us');
+ const made = wn(['groups', 'create', 'interop', pk], { account: alice });
+ ok(!made.error, 'wn groups create', JSON.stringify(made.error || ''));
+ const wnGroup = made.group_id || made.group?.group_id || made.group_id_hex;
+ const rumor = await waitFor(async () => (await welcomes())[0], 'Welcome arrives');
+ if (!rumor) return;
+ let g;
+ try {
+ const j = M.join(device, rumor, kps);
+ g = j.g;
+ ok(true, 'joined from wn Welcome (epoch ' + g.tip.epoch + ', ' + M.accounts(g.tip).length + ' accounts)');
+ ok(M.groupView(g.tip).name === 'interop', 'group name reads back', M.groupView(g.tip).name);
+ ok(g.inviter === alice, 'inviter is the wn account');
+ } catch (e) { ok(false, 'join from wn Welcome', e.stack); return; }
+ await publishKeyPackage(); // rotate the consumed package
+
+ console.log('\n3. messages both ways');
+ wn(['messages', 'send', wnGroup, 'hello from wn'], { account: alice });
+ await waitFor(async () => { await sync(g); return texts(g).includes('hello from wn'); }, 'wn → us application message');
+ ok(texts(g).includes('hello from wn'), 'we decrypt wn\'s message');
+ await publish(M.send(g, M.appEvent(pk, 9, 'hello from js')));
+ const seen = await waitFor(async () => JSON.stringify(wn(['messages', 'list', wnGroup, '--limit', '20'], { account: alice })).includes('hello from js'), 'us → wn application message');
+ ok(!!seen, 'wn decrypts our message');
+
+ console.log('\n4. our self-update commit, then traffic in the new epoch');
+ const e0 = g.tip.epoch;
+ await commit(g, []);
+ ok(g.tip.epoch === e0 + 1, 'self-update applied locally');
+ await sleep(1500);
+ wn(['messages', 'send', wnGroup, 'after your update'], { account: alice });
+ await waitFor(async () => { await sync(g); return texts(g).includes('after your update'); }, 'wn follows our commit');
+ ok(texts(g).includes('after your update'), 'wn accepted our commit (message in the new epoch decrypts)');
+
+ console.log('\n5. wn changes the group; we follow');
+ wn(['groups', 'rename', wnGroup, 'interop-renamed'], { account: alice });
+ await waitFor(async () => { await sync(g); return M.groupView(g.tip).name === 'interop-renamed'; }, 'rename commit from wn');
+ ok(M.groupView(g.tip).name === 'interop-renamed', 'we process wn\'s AppDataUpdate commit');
+ wn(['groups', 'add-members', wnGroup, bob], { account: alice });
+ await waitFor(async () => { await sync(g); return M.accounts(g.tip).includes(bob); }, 'add commit from wn');
+ ok(M.accounts(g.tip).includes(bob), 'we process wn\'s Add commit');
+ await publish(M.send(g, M.appEvent(pk, 9, 'welcome bob')));
+ ok(!!await waitFor(async () => JSON.stringify(wn(['messages', 'list', wnGroup, '--limit', '20'], { account: bob })).includes('welcome bob'), 'bob reads us'), 'a member wn added reads our message');
+
+ console.log('\n6. we found a group and invite a wn account');
+ const fetchKp = async (who) => M.pickKeyPackage((await query({ kinds: [30443], authors: [who] })).map((e) => M.readKeyPackageEvent(e)).filter(Boolean));
+ const aliceKp = await fetchKp(alice);
+ ok(!!aliceKp, 'wn KeyPackage validates in JS');
+ if (!aliceKp) return;
+ const f = M.found(device, { name: 'from-js', relays: [RELAY], invitees: [aliceKp] });
+ const mine = f.g;
+ await publish(await wrapDM(sk, alice, M.welcomeRumor(pk, f.welcome, aliceKp.id, [RELAY])));
+ const mineId = mine.id;
+ const listed = await waitFor(async () => JSON.stringify(wn(['chats', 'list'], { account: alice })).includes(mineId) || JSON.stringify(wn(['groups', 'invites'], { account: alice })).includes(mineId), 'wn joins our group');
+ ok(!!listed, 'wn processes our Welcome');
+ wn(['groups', 'accept', mineId], { account: alice });
+ await publish(M.send(mine, M.appEvent(pk, 9, 'first post')));
+ ok(!!await waitFor(async () => JSON.stringify(wn(['messages', 'list', mineId, '--limit', '20'], { account: alice })).includes('first post'), 'wn reads our group'), 'wn decrypts a message in our group');
+ wn(['messages', 'send', mineId, 'reply from wn'], { account: alice });
+ await waitFor(async () => { await sync(mine); return texts(mine).includes('reply from wn'); }, 'wn → our group');
+ ok(texts(mine).includes('reply from wn'), 'we decrypt wn in our group');
+
+ console.log('\n7. our commits in our group: add, rename, remove');
+ const bobKp = await fetchKp(bob);
+ const add = await commit(mine, M.invite([bobKp]));
+ await publish(await wrapDM(sk, bob, M.welcomeRumor(pk, add.welcome, bobKp.id, [RELAY])));
+ ok(!!await waitFor(async () => JSON.stringify(wn(['chats', 'list'], { account: bob })).includes(mineId) || JSON.stringify(wn(['groups', 'invites'], { account: bob })).includes(mineId), 'bob joins'), 'wn joins from our Add commit\'s Welcome');
+ wn(['groups', 'accept', mineId], { account: bob });
+ await commit(mine, [M.setProfile('renamed-by-js')]);
+ ok(!!await waitFor(async () => JSON.stringify(wn(['groups', 'show', mineId], { account: alice })).includes('renamed-by-js'), 'wn sees rename'), 'wn processes our AppDataUpdate commit');
+ wn(['messages', 'send', mineId, 'bob here'], { account: bob });
+ await waitFor(async () => { await sync(mine); return texts(mine).includes('bob here'); }, 'bob → us');
+ ok(texts(mine).includes('bob here'), 'three-member traffic after our commits');
+
+ console.log('\n8. a wn member leaves (SelfRemove); we commit it');
+ const left = wn(['groups', 'leave', mineId], { account: bob });
+ ok(!left.error, 'wn groups leave', JSON.stringify(left.error || ''));
+ const asked = await waitFor(async () => (await sync(mine)).find((o) => o.selfRemove !== undefined) || (mine.tip.pending || []).length > 0, 'SelfRemove proposal arrives');
+ if (asked) {
+ await commit(mine, []);
+ ok(!M.accounts(mine.tip).includes(bob), 'bob is out after our SelfRemove commit');
+ wn(['messages', 'send', mineId, 'two again'], { account: alice });
+ await waitFor(async () => { await sync(mine); return texts(mine).includes('two again'); }, 'alice follows the SelfRemove commit');
+ ok(texts(mine).includes('two again'), 'wn accepted our SelfRemove commit');
+ }
+
+ console.log('\n9. we remove a member');
+ await commit(mine, M.remove(mine, alice));
+ ok(M.accounts(mine.tip).length === 1, 'alice removed locally');
+ ok(!!await waitFor(async () => { const s = JSON.stringify(wn(['chats', 'list', '--include-archived'], { account: alice })); return /removed|left|inactive/i.test(s) || !s.includes(mineId); }, 'wn notices removal', 12000), 'wn realizes its removal');
+}
+
+main().then(() => {
+ console.log(failed ? `\n${failed} FAILED` : '\nall passed');
+ process.exit(failed ? 1 : 0);
+}).catch((e) => { console.error(e); process.exit(1); });