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
|
// NWC auto-answer while the app is closed. Runs inside the service worker's
// push handler: the push payload carries the encrypted request event, the
// background record (IndexedDB) carries the connection service keys and a
// mirror of the wallet's ark state, and the reply goes back through the
// notifier's /publish endpoint — no WebSocket, no seed.
//
// Trust: the notifier transports ciphertext both ways. Requests arrive
// nip04/nip44-encrypted and signed by the client; replies leave encrypted and
// signed by the per-connection service key. The notifier can neither read nor
// forge either. Payments run the ASP's own Lightning send directly over
// fetch — the same path the open app uses.
//
// Anything this module can't handle returns false, and the caller falls back
// to a notification that opens the wallet.
import { hex } from '@scure/base';
import * as nip04 from 'nostr-tools/nip04';
import * as nip44 from 'nostr-tools/nip44';
import { finalizeEvent } from 'nostr-tools/pure';
import { ArkManager } from './ark/manager.js';
import { maybeBolt11 } from './ark/lightning.js';
import { allBgs, saveBg } from './nwc-bg.js';
const MAX_AGE_SEC = 120;
const PAY_WAIT_MS = 20_000; // inside the SW's ~30s budget, with reply margin
const nowSec = () => Math.floor(Date.now() / 1000);
const errRes = (t, code, message) => ({ result_type: t, error: { code, message } });
const decrypt = async (scheme, skHex, pk, ct) => (scheme === 'nip44_v2'
? nip44.decrypt(ct, nip44.getConversationKey(hex.decode(skHex), pk))
: Promise.resolve(nip04.decrypt(hex.decode(skHex), pk, ct)));
async function buildReply(conn, ev, scheme, payload) {
const sk = hex.decode(conn.serviceSk);
const content = scheme === 'nip44_v2'
? nip44.encrypt(JSON.stringify(payload), nip44.getConversationKey(sk, ev.pubkey))
: await Promise.resolve(nip04.encrypt(sk, ev.pubkey, JSON.stringify(payload)));
return finalizeEvent({
kind: 23195, created_at: nowSec(),
tags: [['p', ev.pubkey], ['e', ev.id], ['encryption', scheme]],
content,
}, sk);
}
const spendableSat = (rec) =>
((rec.mgr && rec.mgr.vtxos) || []).filter((v) => v.state === 'spendable')
.reduce((n, v) => n + (v.amountSat || 0), 0);
// The worker can't derive keys — the mirror carries them per chain:
// 3 = coins/change, 4/0 = mailbox, 5 = receive preimages.
export function shimAccount(rec) {
const byChain = (chain, i) => {
const k = chain === 5 ? rec.keys5?.[String(i)]
: chain === 4 ? rec.key4
: rec.keys[String(i)];
if (!k) throw new Error(`chain ${chain} index ${i} outside the mirrored window`);
return hex.decode(k);
};
return { deriveChild: (chain) => ({ deriveChild: (i) => ({ privateKey: byChain(chain, i) }) }) };
}
export async function bgManager(rec, walletKey, saveFn) {
const storage = {
load: () => rec.mgr,
save: (s) => { rec.mgr = s; saveFn(walletKey, rec).catch(() => {}); },
};
return new ArkManager({
account: shimAccount(rec), storage,
arkUrl: rec.ark.arkUrl, esploraUrl: rec.ark.esploraUrl, network: rec.ark.network,
}).init();
}
// A CLINK offer request (kind 21001): mint an invoice against the mirrored
// wallet and reply. The payment itself arrives later over the ASP's hold
// invoice — the app claims it on next open, so we also ask the caller to
// raise a "money incoming" notification.
async function respondOffer(ev, rec, walletKey, { notifier, fetchFn, saveFn, log }) {
const offer = rec.offer;
if (!offer || !offer.sk) return false;
const sk = hex.decode(offer.sk);
let req;
try { req = JSON.parse(nip44.decrypt(ev.content, nip44.getConversationKey(sk, ev.pubkey))); } catch { return false; }
const publish = async (payload) => {
const content = nip44.encrypt(JSON.stringify(payload), nip44.getConversationKey(sk, ev.pubkey));
const evt = finalizeEvent({
kind: 21001, created_at: nowSec(),
tags: [['p', ev.pubkey], ['e', ev.id], ['clink_version', '1']],
content,
}, sk);
const r = await fetchFn(`${notifier}/publish`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ event: evt }),
});
if (!r.ok) throw new Error(`publish refused: ${r.status}`);
};
try {
const answered = await fetchFn(`${notifier}/answered?event=${ev.id}`).then((r) => r.json()).catch(() => ({}));
if (answered.answered) return true;
const sat = Math.floor(req.amount_sats || 0);
if (!sat || sat < 1) { await publish({ error: 'Invalid Amount', code: 5, range: { min: 1, max: 1000000 } }); return true; }
if (!rec.keys5 || !rec.key4) return false; // old mirror: wake the user
if (!rec.keys5[String(rec.mgr.nextLnRecvIndex || 0)]) return false; // preimage window exhausted
const mgr = await bgManager(rec, walletKey, saveFn);
const a = await mgr.createLnInvoice(sat, (req.description || '').slice(0, 100));
// pending zap receipts are published by the app when it claims — stash
// what it needs alongside the mirror
if (req.zap) {
rec.zapPending = rec.zapPending || [];
rec.zapPending.push({ zap: req.zap, invoice: a.invoice, ts: Date.now() });
if (rec.zapPending.length > 50) rec.zapPending = rec.zapPending.slice(-50);
}
await saveFn(walletKey, rec);
await publish({ bolt11: a.invoice });
log(`minted ${sat} sat offer invoice while closed`);
return { notify: { title: 'Incoming payment', body: 'Someone is paying you. Open Coinos to receive it.' } };
} catch (e) {
log('offer mint failed: ' + e.message);
await publish({ error: 'Temporary Failure', code: 2 }).catch(() => {});
return true;
}
}
// data: the push payload { type:'nwc', servicePubkey, event }. Returns true
// when the request was fully dealt with (reply published or deliberately
// left to another device); false means "wake the user instead".
export async function respondFromBg(data, {
notifier, fetchFn = fetch, recordsFn = allBgs, saveFn = saveBg, log = () => {},
errGraceMs = 3000,
} = {}) {
const ev = data && data.event;
if (!ev || !ev.id || !ev.pubkey) return false;
if (nowSec() - (ev.created_at || 0) > MAX_AGE_SEC) return false;
const target = (ev.tags?.find((t) => t[0] === 'p') || [])[1];
if (!target) return false;
// CLINK offer requests route by the offer service pubkey — freshest record
// wins if stale copies linger.
if (ev.kind === 21001) {
const offers = (await recordsFn())
.filter((r) => r.rec.v >= 3 && r.rec.offer?.pk === target)
.sort((a, b) => (b.rec.updated || 0) - (a.rec.updated || 0));
if (!offers.length) return false;
return respondOffer(ev, offers[0].rec, offers[0].walletKey, { notifier, fetchFn, saveFn, log });
}
if (ev.kind !== 23194) return false;
// The same connection can survive under several wallet records (an old
// network's mirror beside the live one — they share the service keys).
// Gather every copy, freshest first; pay_invoice narrows further to the
// record whose wallet is on the invoice's network.
const matches = [];
for (const r of await recordsFn()) {
const c = (r.rec.connections || []).find((x) => x.servicePk === target);
if (c && r.rec.v >= 3) matches.push({ walletKey: r.walletKey, rec: r.rec, conn: c });
}
matches.sort((a, b) => (b.rec.updated || 0) - (a.rec.updated || 0));
if (!matches.length) return false;
let { walletKey, rec, conn } = matches[0];
if (ev.pubkey !== conn.clientPk) return false;
const scheme = ev.tags?.find((x) => x[0] === 'encryption')?.[1] === 'nip44_v2' ? 'nip44_v2' : 'nip04';
let req;
try { req = JSON.parse(await decrypt(scheme, conn.serviceSk, ev.pubkey, ev.content)); } catch { return false; }
const method = req?.method;
const params = req?.params || {};
const publish = async (payload) => {
const evt = await buildReply(conn, ev, scheme, payload);
const r = await fetchFn(`${notifier}/publish`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ event: evt }),
});
if (!r.ok) throw new Error(`publish refused: ${r.status}`);
return true;
};
// Another device (an open tab elsewhere) may have answered already — a
// paid zap must never be shouted over with a late error, and paying twice
// wastes funds-in-flight. The notifier tracks recent reply e-tags.
const answered = async () => {
try {
const r = await fetchFn(`${notifier}/answered?event=${ev.id}`);
return !!(await r.json()).answered;
} catch { return false; }
};
// ...and hands each pay request to the first device that claims it, so
// two awake devices don't each lock an HTLC for one zap. Unreachable →
// proceed: a lone worker must not go silent over a notifier hiccup.
const claim = async () => {
try {
const r = await fetchFn(`${notifier}/claim`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ event: ev.id, by: `sw-${walletKey}`.slice(0, 64) }),
});
if (!r.ok) return true;
return (await r.json()).ok !== false;
} catch { return true; }
};
if (method === 'get_info') {
await publish({
result_type: 'get_info',
result: {
alias: 'Coinos', color: '#15171a',
network: rec.ark.network === 'mainnet' ? 'mainnet' : rec.ark.network,
block_height: 0, block_hash: '',
methods: ['get_info', 'get_balance', 'pay_invoice', 'make_invoice', 'lookup_invoice', 'list_transactions'],
},
});
return true;
}
// A merchant (BTCPay) mints an invoice per checkout and polls it until
// paid. Minting works like a CLINK offer; a lookup that finds the payer's
// HTLCs waiting claims them here, so the sale settles with the app closed.
if (method === 'make_invoice') {
const sat = Math.floor((params.amount || 0) / 1000);
if (!sat) { await publish(errRes('make_invoice', 'OTHER', 'amount required (msat)')); return true; }
if (!rec.keys5 || !rec.key4 || !rec.mgr) return false; // old mirror: wake the user
if (!rec.keys5[String(rec.mgr.nextLnRecvIndex || 0)]) return false; // preimage window exhausted
if (!rec.keys[String(rec.mgr.nextKeyIndex || 1)]) return false; // claim key window exhausted
let a;
try {
const mgr = await bgManager(rec, walletKey, saveFn);
a = await mgr.createLnInvoice(sat, String(params.description || '').slice(0, 100));
await saveFn(walletKey, rec);
} catch (e) {
await publish(errRes('make_invoice', 'INTERNAL', e.message || 'failed')).catch(() => {});
return true;
}
await publish({
result_type: 'make_invoice',
result: {
type: 'incoming', state: 'pending', invoice: a.invoice, payment_hash: a.paymentHash,
amount: sat * 1000, created_at: nowSec(), description: params.description || '',
expires_at: a.expiresAt ? Math.floor(a.expiresAt / 1000) : undefined,
},
});
log(`minted ${sat} sat invoice while closed`);
return true;
}
if (method === 'lookup_invoice') {
if (!params.payment_hash && !params.invoice) {
await publish(errRes('lookup_invoice', 'OTHER', 'payment_hash or invoice required'));
return true;
}
if (!rec.mgr) return false;
const q = { paymentHash: params.payment_hash, invoice: params.invoice };
// read straight off the mirror; the ASP is only needed to claim
const view = Object.assign(Object.create(ArkManager.prototype), { state: rec.mgr, account: shimAccount(rec) });
let tx = view.lnLookup(q);
const a = tx && tx.state === 'pending' && tx.type === 'incoming'
&& (rec.mgr.actions || []).find((x) => x.type === 'ln-recv' && x.paymentHash === tx.payment_hash);
if (a) {
try {
const mgr = await bgManager(rec, walletKey, saveFn);
await Promise.race([mgr.driveLn(a.id).catch((e) => log('claim: ' + e.message)), new Promise((r) => setTimeout(r, PAY_WAIT_MS))]);
tx = mgr.lnLookup(q);
if (tx.state === 'settled') log(`received ${tx.amount / 1000} sat while closed`);
} catch (e) { log('ark init failed: ' + e.message); }
}
await publish(tx ? { result_type: 'lookup_invoice', result: tx } : errRes('lookup_invoice', 'NOT_FOUND', 'invoice not found'));
return true;
}
if (method === 'get_balance') {
await publish({ result_type: 'get_balance', result: { balance: spendableSat(rec) * 1000 } });
return true;
}
if (method === 'list_transactions') {
// the mirrored wallet's Lightning history, both ways, as the open app reports it
const txs = ((rec.mgr && rec.mgr.movements) || [])
.filter((m) => (m.type === 'ln-send' || m.type === 'ln-receive') && m.status === 'complete')
.slice(-(params.limit || 20))
.map((m) => ({
type: m.type === 'ln-receive' ? 'incoming' : 'outgoing',
state: 'settled', invoice: m.invoice || '', preimage: m.preimage || '',
amount: (m.amountSat || 0) * 1000, fees_paid: 0,
created_at: Math.floor((m.ts || Date.now()) / 1000),
settled_at: Math.floor((m.ts || Date.now()) / 1000),
description: '',
}));
await publish({ result_type: 'list_transactions', result: { transactions: txs } });
return true;
}
if (method !== 'pay_invoice') {
await publish(errRes(method, 'NOT_IMPLEMENTED', 'not available while the wallet is closed'));
return true;
}
// ---- pay_invoice ------------------------------------------------------
if (await answered()) { log('already answered elsewhere'); return true; }
const invoice = params.invoice;
const dec = invoice && maybeBolt11(invoice);
if (!dec) { await publish(errRes('pay_invoice', 'OTHER', 'not a bolt11 invoice')); return true; }
if (!dec.amountSat) { await publish(errRes('pay_invoice', 'OTHER', 'zero-amount invoices are not supported')); return true; }
// The bolt11 names its network; only a record on that network can pay it.
// A mismatched copy fails in milliseconds with a nonsense error that beats
// a sibling device's real payment to the client — so stand down instead,
// and only wake the user if nobody else answered.
const netOf = (n) => (n === 'mutinynet' ? 'signet' : n);
const fit = matches.find((m) => netOf(m.rec.ark && m.rec.ark.network) === dec.network);
if (!fit) {
log(`no ${dec.network} record for this connection — standing down`);
await new Promise((r) => setTimeout(r, errGraceMs));
return answered();
}
({ walletKey, rec, conn } = fit);
if (dec.amountSat > conn.maxSat) {
await publish(errRes('pay_invoice', 'QUOTA_EXCEEDED', `over the ${conn.maxSat} sat per-payment limit`));
return true;
}
const today = new Date().toISOString().slice(0, 10);
const spentToday = (rec.spends || [])
.filter((s) => s.connId === conn.id && new Date(s.ts).toISOString().slice(0, 10) === today)
.reduce((n, s) => n + (s.amountSat || 0) + (s.feeSat || 0), 0);
if (spentToday + dec.amountSat > conn.dailySat) {
await publish(errRes('pay_invoice', 'QUOTA_EXCEEDED', `over the remaining daily budget (${Math.max(0, conn.dailySat - spentToday)} sat)`));
return true;
}
if (spendableSat(rec) < dec.amountSat) return false; // stale/empty mirror: wake the user
// The ASP's own Lightning send, over the mirrored state. Change and HTLC
// outputs allocate from nextKeyIndex, which must stay inside the key
// window the app pre-derived; if it ran out, wake the user instead.
if (!rec.keys[String(rec.mgr.nextKeyIndex || 1)]) { log('key window exhausted'); return false; }
let mgr;
try {
mgr = await bgManager(rec, walletKey, saveFn);
} catch (e) { log('ark init failed: ' + e.message); return false; }
// A local failure carries no urgency — wait out the grace period and
// re-check before publishing, so a sibling device's slower SUCCESS reply
// wins the client's eyes instead of being shouted over.
const failQuietly = async (message) => {
await new Promise((r) => setTimeout(r, errGraceMs));
if (!(await answered())) {
await publish(errRes('pay_invoice', 'INTERNAL', message)).catch(() => {});
}
return true;
};
if (!(await claim())) { log('another device claimed this request — standing down'); return true; }
const deadline = Date.now() + PAY_WAIT_MS;
let a = null;
for (let attempt = 0; ; attempt++) {
let id;
try {
id = await mgr.payLnInvoice(invoice);
} catch (e) {
return failQuietly(e.message);
}
a = await mgr.awaitLnPay(id, Math.max(1000, deadline - Date.now())).catch(() => mgr.lnAction(id));
while (a && !['done', 'failed'].includes(a.step) && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 1500));
await mgr.driveLn(id).catch(() => {});
a = mgr.lnAction(id);
}
// A ghost coin (spent elsewhere, this mirror never learned) dies on the
// launchpad in milliseconds. Reconcile against the server — flipping the
// ghost — and retry once from live coins; the budget has barely started.
if (attempt === 0 && a && a.step === 'failed' && /not spendable|already spent/i.test(a.error || '')) {
log('a mirrored coin was already spent — reconciling and retrying');
await mgr.reconcile().catch(() => {});
continue;
}
break;
}
if (a && a.step === 'failed' && /already in progress/i.test(a.error || '')) {
// Another device of this wallet started this same payment first. Its
// outcome is ours: wait on it, and only speak if the payer stays quiet.
log('a sibling device is paying this invoice — waiting on its outcome');
const out = await mgr.lnOutcome(dec.paymentHash, Math.max(3000, deadline - Date.now()));
if (out.status === 'failed') return failQuietly(a.error);
if (out.status !== 'success') { log('sibling payment still pending — leaving it to them'); return true; }
await new Promise((r) => setTimeout(r, errGraceMs));
if (!(await answered())) {
await publish({ result_type: 'pay_invoice', result: { preimage: out.preimage, fees_paid: 0 } });
}
return true;
}
if (!a || a.step === 'failed') {
return failQuietly((a && a.error) || 'payment failed');
}
if (a.step !== 'done') {
// still in flight as our budget runs out: the app resumes it from the
// mirrored state on next open — wake the user rather than guess.
log('payment still pending at SW deadline');
return false;
}
rec.spends = rec.spends || [];
rec.spends.push({
connId: conn.id, amountSat: a.amountSat || dec.amountSat, feeSat: a.feeSat || 0,
paymentHash: dec.paymentHash, preimage: a.preimage, invoice, ts: Date.now(),
});
if (rec.spends.length > 200) rec.spends = rec.spends.slice(-200);
await saveFn(walletKey, rec);
await publish({
result_type: 'pay_invoice',
result: { preimage: a.preimage, fees_paid: (a.feeSat || 0) * 1000 },
});
log(`paid ${a.amountSat || dec.amountSat} sat via the ASP from the background mirror`);
// Money left while the app was closed — that deserves a heads-up, unless
// the user turned "payments sent" notifications off in Settings.
if (rec.prefs && rec.prefs.paySent === false) return true;
const paidSat = a.amountSat || dec.amountSat;
const feeNote = a.feeSat ? ` (+${a.feeSat} fee)` : '';
return {
notify: {
title: 'Payment sent', tag: 'nwc-sent',
body: `-${paidSat.toLocaleString()} sats${feeNote} via ${conn.name || 'a connected app'}`,
},
};
}
|