aboutsummaryrefslogtreecommitdiffstats
path: root/tools/marmot-multidevice-test.js
blob: 264fbcbe492fd136a817277ef2cb6dfc8354f9df (plain) (blame)
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
// Multi-device White Noise groups: one account on two coinos devices, against
// the Rust reference (MDK's `wn`), on public relays with throwaway identities.
//
//   bun tools/marmot-multidevice-test.js
//
// Needs `wn` (github.com/marmot-protocol/mdk, crates/cli) on PATH or at WN,
// with its daemon on public relays:
//   export WN_HOME=… WN_SECRET_STORE=file WN_SOCKET=/tmp/wn.sock
//   wn daemon start --discovery-relays wss://nos.lol,wss://relay.primal.net \
//                   --default-account-relays wss://nos.lol,wss://relay.primal.net
//
// Covers: both devices publish KeyPackages (one slot each); a wn invitation
// reaches whichever device(s) wn adds; a group founded on device A adds
// device B by itself (the admin's sibling add) through our own inbox; wn
// reads and writes a group holding two leaves of one npub; both devices read.
import { spawnSync } from 'node:child_process';
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
import { npubEncode } from 'nostr-tools/nip19';
// MD_DEBUG=1 shows the client's dlog lines (sibling adds and why they skip)
if (process.env.MD_DEBUG && typeof localStorage === 'undefined') {
  globalThis.localStorage = { getItem: (k) => (k === 'coinos-debug' ? '1' : null), setItem() {}, removeItem() {} };
}
const { marmotClient } = await import('../src/marmot-client.js');
const { subscribeOn, publishOn } = await import('../src/nostr.js');
const { unwrapDM } = await import('../src/dm.js');

const WN = process.env.WN || 'wn';
const RELAYS = (process.env.RELAYS || 'wss://relay.coinos.io,wss://nos.lol,wss://relay.primal.net').split(',');
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 } = {}) {
  const r = spawnSync(WN, ['--json', ...(account ? ['--account', account] : []), ...args], { encoding: 'utf8', timeout: 60_000 });
  const line = (r.stdout || '').trim().split('\n').filter(Boolean).at(-1) || '';
  try { const j = JSON.parse(line); return j.ok ? j.result : { error: j.error }; } catch { return { error: { message: (r.stderr || r.stdout || 'no output').trim().slice(0, 300) } }; }
}
const waitFor = async (fn, label, ms = 45_000) => { const t = Date.now(); while (Date.now() - t < ms) { const v = await fn(); if (v) return v; await sleep(1500); } console.log('  … timed out waiting for ' + label); return null; };
const memStore = () => { const m = new Map(); return { all: async () => new Map(m), put: async (k, v) => { m.set(k, structuredClone(v)); }, del: async (k) => { m.delete(k); }, wipe: async () => m.clear() }; };

const who = wn(['whoami']);
if (who.error) { console.log('SKIP: wn daemon not reachable (' + who.error.message + ')'); process.exit(0); }

// one account, two devices
const sk = generateSecretKey(), pk = getPublicKey(sk);
const identity = async () => ({ pubkey: pk, signer: sk });
const log = { A: [], B: [] };
const mk = (name) => marmotClient({
  scope: 'test-' + name, pubkey: pk, identity, relays: RELAYS, store: memStore(),
  on: (type, g, data) => { if (type === 'message') log[name].push(data); if (type === 'joined') console.log(`  [${name}] joined ${g.id.slice(0, 8)} (invited by ${(g.inviter || '').slice(0, 8)})`); },
});
const A = mk('A'), B = mk('B');
// the account's relay lists, so wn can route to us (the sync feature publishes these for a real wallet)
for (const kind of [10002, 10050]) await publishOn(RELAYS, finalizeEvent({ kind, created_at: Math.floor(Date.now() / 1000), tags: RELAYS.map((r) => [kind === 10002 ? 'r' : 'relay', r]), content: '' }, sk));
// the DM inbox: every Welcome for our npub goes to both devices, as the app's inbox does
// no `since`: a gift wrap's created_at is randomized into the past (NIP-59)
subscribeOn(RELAYS, { kinds: [1059], '#p': [pk] }, async (ev) => {
  let got; try { got = await unwrapDM(ev, sk); } catch { return; }
  const rumor = got && got.rumor;
  if (!rumor || rumor.kind !== 444) return;
  for (const c of [A, B]) c.welcome(rumor).catch(() => {});
});

try {
  await A.start(); await B.start();
  ok(await A.ready({ interactive: true }), 'device A published a KeyPackage');
  ok(await B.ready({ interactive: true }), 'device B published a KeyPackage');
  await sleep(2000);
  const mine = await A.lookupAll(pk);
  ok(mine.length === 2 && new Set(mine.map((c) => c.slot)).size === 2, 'the account shows two devices (two slots)', JSON.stringify(mine.map((c) => c.slot.slice(0, 8))));

  // ---- 1. wn invites the account: which devices get in? ----
  const alice = wn(['create-identity']).account_id;
  const check = wn(['keys', 'check', pk]);
  ok(check && !check.error, 'wn finds our relay lists + KeyPackages', JSON.stringify(check).slice(0, 200));
  const made = wn(['groups', 'create', 'multidev', pk], { account: alice });
  ok(!made.error, 'wn created a group with our npub', JSON.stringify(made.error || '').slice(0, 200));
  const wnGroup = made.group_id || made.group?.group_id || made.group_id_hex;
  const joinedCount = await waitFor(async () => { const n = [A, B].filter((c) => [...c.groups.values()].some((g) => !g.removed)).length; return n ? n : null; }, 'a device joins the wn group', 40_000);
  await sleep(6000); // a second Welcome may follow
  const inWn = [A, B].filter((c) => [...c.groups.values()].some((g) => !g.removed)).map((c) => (c === A ? 'A' : 'B'));
  console.log('  wn invited devices:', inWn.join(',') || 'none', '(MDK adds', inWn.length, 'of our 2 KeyPackages)');
  ok(inWn.length >= 1, 'at least one device joined the wn group');

  // ---- 2. our group: device A founds it, invites wn, and adds device B on its own ----
  const g = await A.create({ name: 'two devices', members: [alice] }) // account_id is the hex pubkey.catch((e) => { console.log('  create failed:', e.message); return null; });
  ok(!!g, 'device A founded a group with the wn account');
  if (g) {
    await A.siblingAdds();
    const gB = await waitFor(async () => [...B.groups.values()].find((x) => x.id === g.id && !x.removed) || null, 'device B joins through the sibling add');
    ok(!!gB, 'device B joined device A\'s group from our own inbox');
    if (gB) {
      ok(gB.inviter === pk, 'the Welcome came from our own account', gB.inviter && gB.inviter.slice(0, 8));
      B.accept(gB);
      ok(A.accounts(g).length === 2 && A.accounts(g).includes(pk), 'two accounts, three leaves', `accounts=${A.accounts(g).length}`);
    }
    // wn joins and must still work with two leaves of one npub in the tree
    const seen = await waitFor(async () => JSON.stringify(wn(['chats', 'list'], { account: alice })).includes(g.id) || JSON.stringify(wn(['groups', 'invites'], { account: alice })).includes(g.id) || null, 'wn sees our group');
    ok(!!seen, 'wn received the Welcome to our group');
    wn(['groups', 'accept', g.id], { account: alice });
    await sleep(3000);
    const show = wn(['groups', 'show', g.id], { account: alice });
    console.log('  wn groups show:', JSON.stringify(show).slice(0, 400));
    const posted = wn(['messages', 'send', g.id, 'hello both devices'], { account: alice });
    ok(!posted.error, 'wn sent into the three-leaf group', JSON.stringify(posted.error || ''));
    ok(!!await waitFor(async () => log.A.some((m) => m.content === 'hello both devices') || null, 'A reads wn'), 'device A reads wn');
    ok(!!await waitFor(async () => log.B.some((m) => m.content === 'hello both devices') || null, 'B reads wn'), 'device B reads wn');
    if (gB) {
      ok(await B.send(gB, B.event(9, 'from device B', [])), 'device B sent');
      ok(!!await waitFor(async () => JSON.stringify(wn(['messages', 'list', g.id, '--limit', '20'], { account: alice })).includes('from device B') || null, 'wn reads B'), 'wn reads device B');
      ok(!!await waitFor(async () => log.A.some((m) => m.content === 'from device B') || null, 'A reads B'), 'device A reads device B');
    }
    ok(await A.send(g, A.event(9, 'from device A', [])), 'device A sent');
    ok(!!await waitFor(async () => JSON.stringify(wn(['messages', 'list', g.id, '--limit', '20'], { account: alice })).includes('from device A') || null, 'wn reads A'), 'wn reads device A');
  }
  console.log(failed ? `\n${failed} FAILED` : '\nMULTI-DEVICE OK');
} catch (e) { console.log('\nCRASH', e.stack || e.message); failed++; }
finally { A.stop(); B.stop(); await sleep(500); process.exit(failed ? 1 : 0); }