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
|
// White Noise in the app: this device's Marmot standing (its MLS signature
// key, proof and published KeyPackage), its groups, and their relay traffic.
// Protocol rules live in marmot.js and storage in marmot-store.js; the
// messages feature hears about everything through `on(type, group, data)`.
//
// One client per (wallet, nostr identity). MLS state is per DEVICE: a second
// device of the same account is a second leaf with its own KeyPackage, and
// only the device whose KeyPackage an inviter picked is in that group.
import * as M from './marmot.js';
import { marmotStore } from './marmot-store.js';
import { subscribeOn, queryOn, publishOn, fetchInboxRelays, liveRelayList, PROFILE_RELAYS, finalizeEvent } from './nostr.js';
import { wrapDM } from './dm.js';
import { dlog } from './debug.js';
// Where the groups we found live. Signed into group state, so every member
// reads and writes the same list. Not relay.damus.io or relay.nostr.band:
// White Noise (MDK's RETIRED_RELAY_HOSTS) never dials those.
export const GROUP_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol', 'wss://relay.primal.net'];
// Where White Noise looks people up. It will not invite an account whose
// relay lists (kinds 10002 and 10050) it cannot find, and one whose profile
// (kind 0) it cannot find is shown under a made-up name ("Rapid Flamingo").
// It looks on its own relays and the purplepag.es index — not on ours.
const DIRECTORY_RELAYS = ['wss://relay.eu.whitenoise.chat', 'wss://relay.us.whitenoise.chat', 'wss://purplepag.es'];
const DAY = 86400;
const KP_REFRESH = 30 * DAY; // republish well inside the 84-day lifetime
const REACH_TTL = 10 * 60_000;
const now = () => Math.floor(Date.now() / 1000);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAYS, subClosed }) {
const store = marmotStore(scope);
const groups = new Map(); // mls group id (hex) -> record
const subs = new Map(); // route id -> { unsub, gid }
const reach = new Map(); // pubkey -> { at, p }
const inbox = new Map(); // gid -> queued 445 events
let dev = null, stopped = false, loaded = null, kpBusy = null, rebuilding = false, announcedAt = 0;
const dirty = new Set();
let flushTimer = 0, rotateTimer = 0;
const emit = (type, g, data) => { try { on(type, g, data); } catch (e) { dlog('marmot: listener', e); } };
// ---- storage ----
function load() {
if (!loaded)
loaded = store.all().then((all) => {
dev = all.get('dev') || null;
for (const [k, v] of all) if (k.startsWith('g:') && v && v.tip) groups.set(v.id, v);
}).catch((e) => { dlog('marmot: store', e); });
return loaded;
}
const touch = (g) => {
dirty.add(g.id);
clearTimeout(flushTimer);
flushTimer = setTimeout(flush, 400);
};
async function flush() {
clearTimeout(flushTimer);
const ids = [...dirty];
dirty.clear();
await Promise.all(ids.map((id) => groups.has(id) ? store.put('g:' + id, groups.get(id)) : null)).catch((e) => dlog('marmot: save', e));
}
// before anything of ours leaves: a ratchet that moved must be on disk first
const persist = (g) => { dirty.delete(g.id); return store.put('g:' + g.id, g); };
const saveDev = () => store.put('dev', dev);
// ---- this device ----
async function signer({ interactive = false } = {}) {
const id = await identity();
if (!id || !id.signer || id.pubkey !== pubkey) return null;
if (id.signer instanceof Uint8Array) return { raw: id.signer, sign: async (e) => finalizeEvent(e, id.signer) };
// an interactive signer is a trip through another app per call: never unasked
if (id.signer.interactive && !interactive) return null;
return { raw: null, adapter: id.signer, sign: (e) => id.signer.signEvent(e) };
}
async function ownWriteRelays() {
const lists = await queryOn(PROFILE_RELAYS, { kinds: [10002], authors: [pubkey] }, 2500);
const newest = lists.sort((a, b) => b.created_at - a.created_at)[0];
return newest ? writeRelays(newest) : [];
}
const writeRelays = (ev) => ev.tags.filter((t) => t[0] === 'r' && t[1] && (!t[2] || t[2] === 'write')).map((t) => t[1]).filter(M.relayOk);
// Make sure other people can invite us: a device record, and a current
// KeyPackage where our NIP-65 write relays say to look. Silent unless
// `interactive`; returns whether we are reachable.
function ready(opts = {}) {
if (!kpBusy) kpBusy = ensure(opts).catch((e) => { dlog('marmot: keypackage', e); return false; }).finally(() => {
kpBusy = null;
// a little later: a brand-new wallet is still publishing its lists
setTimeout(announce, 8000);
});
return kpBusy;
}
async function ensure(opts) {
await load();
const cur = dev && dev.kps && dev.kps.find((k) => k.eventId);
if (cur && cur.notAfter - now() > 14 * DAY && now() - cur.at < KP_REFRESH) return true;
const s = await signer(opts);
if (!s) return false;
if (!dev) { dev = { ...(await M.newDevice(s.sign, pubkey)), kps: [] }; await saveDev(); }
return publishKeyPackage(s);
}
// Put the account's profile and relay lists where White Noise will find
// them. These are the events the account already signed, passed along
// unchanged — so no signer is asked, and a login npub's lists stay exactly
// as its other clients wrote them. Asked for freely (at boot, whenever
// Chat is on screen) and looked at once every two minutes at most: an event goes
// out when it is new (a renamed profile) or was last sent over a week ago.
async function announce() {
if (stopped || !dev || Date.now() - announcedAt < 120_000) return;
announcedAt = Date.now();
try {
const evs = await queryOn(PROFILE_RELAYS, { kinds: [0, 10002, 10050], authors: [pubkey] }, 3500);
const sent = dev.sent || (dev.sent = {});
let changed = false;
for (const kind of [0, 10002, 10050]) {
const ev = evs.filter((e) => e.kind === kind).sort((a, b) => b.created_at - a.created_at)[0];
if (!ev) continue;
const last = sent[kind];
if (last && last.id === ev.id && now() - last.at < 7 * DAY) continue;
if (await publishOn(DIRECTORY_RELAYS, ev)) { sent[kind] = { id: ev.id, at: now() }; changed = true; }
}
if (changed) await saveDev();
} catch (e) { dlog('marmot: announce', e); }
}
async function publishKeyPackage(s) {
const kp = M.newKeyPackage(dev);
// the private half is on disk before the public half is findable
dev.kps.push({ ...kp, eventId: null, at: now() });
await saveDev();
const ev = await s.sign(M.keyPackageEvent(dev, kp));
// White Noise looks KeyPackages up through the account's kind 10002 write
// relays (the sync feature publishes that list for a seed-derived
// identity; a login npub's list is its other clients' business).
const own = await ownWriteRelays();
const ok = await publishOn([...new Set([...own.slice(0, 6), ...PROFILE_RELAYS])], ev);
const rec = dev.kps.find((k) => k.ref === kp.ref);
if (!ok) { dev.kps = dev.kps.filter((k) => k !== rec); await saveDev(); return false; }
rec.eventId = ev.id;
// the slot now holds the replacement: older packages' init keys are done
dev.kps = dev.kps.filter((k) => k === rec);
await saveDev();
return true;
}
// Somebody else's KeyPackage, or null when they cannot be invited.
function lookup(pk) {
const hit = reach.get(pk);
if (hit && Date.now() - hit.at < REACH_TTL) return hit.p;
const p = (async () => {
const lists = await queryOn(PROFILE_RELAYS, { kinds: [10002], authors: [pk] }, 2500);
const newest = lists.sort((a, b) => b.created_at - a.created_at)[0];
const where = [...new Set([...(newest ? writeRelays(newest).slice(0, 5) : []), ...PROFILE_RELAYS])];
const evs = await queryOn(where, { kinds: [M.KIND.KEY_PACKAGE], authors: [pk] }, 2500);
return M.pickKeyPackage(evs.map((e) => M.readKeyPackageEvent(e)).filter(Boolean));
})().catch(() => null);
reach.set(pk, { at: Date.now(), p });
return p;
}
// ---- relay traffic ----
const routeRelays = (g) => (M.groupView(g.tip).routing || {}).relays || relays;
function watch(g) {
if (stopped || g.removed) return;
for (const r of M.routes(g)) {
if (subs.has(r.id)) continue;
// a relay that drops the subscription is the caller's watchdog's business:
// a group that misses a commit is stuck until it hears it
const unsub = subscribeOn(r.relays, { kinds: [M.KIND.GROUP], '#h': [r.id], since: M.since(g), limit: 500 }, (ev) => queue(g.id, ev),
subClosed ? { onclose: () => { if (!rebuilding && !stopped) subClosed(); } } : {});
subs.set(r.id, { unsub, gid: g.id, relays: r.relays, live: liveRelayList(r.relays).length });
}
}
function unwatch(gid) {
rebuilding = true;
try { for (const [id, s] of subs) if (s.gid === gid) { try { s.unsub(); } catch {} subs.delete(id); } } finally { rebuilding = false; }
}
// Relays replay history newest-first and commits only apply oldest-first:
// collect a moment, then feed the batch in timestamp order.
function queue(gid, ev) {
let q = inbox.get(gid);
if (!q) { inbox.set(gid, (q = [])); setTimeout(() => drain(gid), 150); }
q.push(ev);
}
function drain(gid) {
const q = inbox.get(gid) || [];
inbox.delete(gid);
const g = groups.get(gid);
if (!g || stopped) return;
q.sort((a, b) => a.created_at - b.created_at);
for (const ev of q) {
let out;
try { out = M.receive(g, ev); } catch (e) { dlog('marmot: receive', e); continue; }
heard(g, out);
}
touch(g);
}
function heard(g, out) {
for (const o of out) {
if (o.id) emit('message', g, o);
else if (o.epoch !== undefined) { watch(g); emit('state', g); }
else if (o.removed) { g.removed = true; unwatch(g.id); emit('removed', g); }
else if (o.withdrawn) emit('withdrawn', g, o.withdrawn);
else if (o.selfRemove !== undefined) letGo(g);
else if (o.invalid) dlog('marmot: rejected input', o.invalid);
}
}
// A member asked to leave: any remaining member may commit it. Wait a
// random beat so a whole group does not answer at once, then look again.
function letGo(g) {
setTimeout(() => {
if (stopped || g.removed || g.leaving || g.pending) return;
if (!(g.tip.pending || []).length) return;
commit(g, []).catch((e) => dlog('marmot: self-remove commit', e));
}, 1500 + Math.random() * 4000);
}
// Publish-before-apply: the commit becomes group state only once a relay
// has it. A failed publish is retried with the SAME bytes, and given up
// only when the relays demonstrably never took it.
async function commit(g, proposals) {
const p = M.stage(g, { proposals });
await persist(g);
const where = routeRelays(g);
let ok = false;
for (let i = 0; i < 3 && !ok; i++) {
if (i) await sleep(1500 * i);
ok = await publishOn(where, p.ev);
}
if (!ok) ok = (await queryOn(where, { ids: [p.ev.id] }, 2500)).length > 0;
// it may have settled while we waited: confirmed by its own echo, or
// beaten by a peer's commit from the same epoch
if (g.pending === p) {
heard(g, ok ? M.confirm(g) : M.drop(g));
await persist(g);
}
if (!g.via || g.via.digest !== p.edge.digest) throw new Error(ok ? 'another change got there first' : 'relays did not accept the change');
return p;
}
async function deliverWelcome(g, welcome, cands) {
const s = await signer({ interactive: true });
if (!s) throw new Error('no signer');
const where = routeRelays(g);
for (const c of cands) {
const wrap = await wrapDM(s.raw || s.adapter, c.pubkey, M.welcomeRumor(pubkey, welcome, c.id, where));
const inboxRelays = await fetchInboxRelays(c.pubkey).catch(() => []);
publishOn([...new Set([...inboxRelays.slice(0, 5), ...where])], wrap);
}
}
// ---- what the app can do ----
async function candidates(pks) {
const cands = await Promise.all(pks.map(lookup));
const missing = pks.filter((_, i) => !cands[i]);
if (missing.length) { const e = new Error('not reachable'); e.missing = missing; throw e; }
return cands;
}
// A direct chat (no name, both sides admins) or a named group.
async function create({ name = '', members }) {
if (!(await ready({ interactive: true }))) throw new Error('no signer');
const cands = await candidates(members);
const direct = !name && members.length === 1;
const { g, welcome } = M.found(dev, { name, relays, invitees: cands, adminKeys: direct ? members : [] });
groups.set(g.id, g);
await persist(g);
await deliverWelcome(g, welcome, cands);
watch(g);
emit('state', g);
return g;
}
// `inner` comes from event(): the app shows it before it has left.
async function send(g, inner) {
const ev = M.send(g, inner);
await persist(g);
const ok = await publishOn(routeRelays(g), ev);
if (!ok) { g.log = g.log.filter((m) => m.id !== inner.id); touch(g); }
return ok;
}
async function add(g, pks) {
const cands = await candidates(pks);
for (const c of cands) if (!M.fits(g.tip, c)) throw new Error('their app cannot join this group');
const p = await commit(g, M.invite(cands));
// the Welcome only after the commit is out: joiners must not get ahead of members
await deliverWelcome(g, p.welcome, cands);
}
const kick = (g, pk) => commit(g, M.remove(g, pk));
const rename = (g, name) => commit(g, [M.setProfile(name, M.groupView(g.tip).about)]);
// Leaving: an admin first hands the group to whoever stays (an admin cannot
// self-remove), then the SelfRemove proposal goes out for a peer to commit.
async function leave(g) {
if (!g.removed && !g.leaving && M.accounts(g.tip).length > 1) {
const v = M.groupView(g.tip);
if (v.admins.includes(pubkey)) {
const rest = v.admins.filter((a) => a !== pubkey);
await commit(g, [M.setAdmins(rest.length ? rest : M.accounts(g.tip).filter((a) => a !== pubkey))]);
}
const ev = M.leave(g);
await persist(g);
await publishOn(routeRelays(g), ev);
}
forget(g);
}
function forget(g) {
unwatch(g.id);
groups.delete(g.id);
dirty.delete(g.id);
store.del('g:' + g.id).catch(() => {});
emit('gone', g);
}
// A Welcome from our inbox. Joining is automatic (the group's contents are
// what tell us who is asking); `fresh` marks it for the app to confirm.
async function welcome(rumor) {
await load();
if (!dev || stopped) return null;
let j;
try { j = M.join(dev, rumor, dev.kps); } catch (e) { dlog('marmot: welcome', e); return null; }
const old = groups.get(j.g.id);
// a retained copy is never silently replaced; a group we were removed from can be rejoined
if (old && !old.removed) return null;
if (old) j.g.log = old.log;
groups.set(j.g.id, j.g);
await persist(j.g);
watch(j.g);
emit('joined', j.g);
// The package that got us in is spent: put a new one in the slot — but
// not at once. Several invitations may have used it while we were away,
// and its private half must still be here when the next Welcome opens.
clearTimeout(rotateTimer);
rotateTimer = setTimeout(() => {
if (!stopped) signer().then((s) => s && publishKeyPackage(s)).catch(() => {});
}, 45_000);
// …and move our leaf off the keys that package exposed
setTimeout(() => {
const g = groups.get(j.g.id);
if (g && !stopped && !g.removed && !g.pending) commit(g, []).catch((e) => dlog('marmot: self-update', e));
}, 4000 + Math.random() * 6000);
return j.g;
}
function accept(g) { g.fresh = false; touch(g); emit('state', g); }
async function start() {
await load();
if (stopped) return;
for (const g of groups.values()) {
watch(g);
// a commit that was on its way out when the app closed: same bytes, again
if (g.pending) {
const p = g.pending;
publishOn(routeRelays(g), p.ev).then(async (ok) => {
if (g.pending !== p) return;
if (!ok) ok = (await queryOn(routeRelays(g), { ids: [p.ev.id] }, 2500)).length > 0;
if (g.pending !== p) return;
heard(g, ok ? M.confirm(g) : M.drop(g));
touch(g);
});
}
}
emit('loaded', null);
}
function resubscribe() {
rebuilding = true; // our own closes are not a relay giving up on us
try { for (const s of subs.values()) { try { s.unsub(); } catch {} } } finally { rebuilding = false; }
subs.clear();
for (const g of groups.values()) watch(g);
}
function stop() {
stopped = true;
rebuilding = true;
clearTimeout(rotateTimer);
for (const s of subs.values()) { try { s.unsub(); } catch {} }
subs.clear();
flush();
}
return {
pubkey, groups, load, start, stop, resubscribe, ready, announce, lookup, welcome, accept,
create, send, add, kick, rename, leave, forget,
event: (kind, content, tags) => M.appEvent(pubkey, kind, content, tags),
// attachments: seal for the epoch a message is about to go out in, and
// open with the secret of the epoch it came in
media: {
read: M.readMediaTag,
tag: M.mediaTag,
seal: (g, plain, meta) => ({ epoch: g.tip.epoch, ...M.sealMedia(M.mediaSecret(g.tip), plain, meta) }),
open(g, epoch, cipher, ref) {
const secret = M.mediaSecretAt(g, epoch) || (g.tip.epoch === epoch ? M.mediaSecret(g.tip) : null);
if (!secret) throw new Error('media key for this epoch is gone');
return M.openMedia(secret, cipher, ref);
},
},
view: (g) => M.groupView(g.tip),
peer: (g) => M.directPeer(g),
accounts: (g) => M.accounts(g.tip),
isAdmin: (g) => M.isAdmin(g),
// A relay that was being skipped as dead when a group subscribed is
// back: that subscription is listening on fewer relays than it could.
starved: () => [...subs.values()].some((s) => liveRelayList(s.relays).length > s.live),
// has this device ever been given its standing (a signed proof)?
isSetUp: () => !!dev,
relaysInUse: () => [...new Set([...groups.values()].filter((g) => !g.removed).flatMap(routeRelays))],
};
}
|