// 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'}`, }, }; }