diff options
| -rw-r--r-- | src/ark/manager.js | 10 | ||||
| -rw-r--r-- | src/features/ark.js | 2 | ||||
| -rw-r--r-- | src/features/nwc.js | 38 | ||||
| -rw-r--r-- | src/i18n.js | 3 | ||||
| -rw-r--r-- | src/nwc-bg.js | 29 | ||||
| -rw-r--r-- | src/nwc-lock.js | 11 | ||||
| -rw-r--r-- | src/nwc-respond.js | 227 | ||||
| -rw-r--r-- | src/sw-nwc.js | 109 | ||||
| -rw-r--r-- | tools/nwc-sw-test.js | 84 | ||||
| -rw-r--r-- | tools/sw-push-quota.test.js | 16 |
10 files changed, 427 insertions, 102 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js index caece44..a1c1390 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -1496,9 +1496,13 @@ export class ArkManager { if (expectNet && dec.network && dec.network !== expectNet) throw new Error(`invoice is for ${dec.network}, wallet is on ${expectNet}`); const amountSat = dec.amountSat ?? userAmountSat; if (!amountSat || amountSat <= 0) throw new Error('invoice has no amount'); - if (this.state.actions.some((a) => - a.type === 'ln-pay' && a.paymentHash === dec.paymentHash && a.step !== 'failed')) { - throw new Error('this invoice was already paid or is being paid'); + const prior = this.state.actions.find((a) => + a.type === 'ln-pay' && a.paymentHash === dec.paymentHash && a.step !== 'failed'); + if (prior) { + // two messages, so a caller can tell a settled payment from one still + // on its way (an NWC client once read "already paid or is being paid" + // as paid, hours before the money moved) + throw new Error(prior.step === 'done' ? 'this invoice was already paid' : 'a payment for this invoice is already in progress'); } const tip = await this.chain.tipHeight(); const routing = routingFeeSat != null ? routingFeeSat : await this.lnRouteFee(invoice, amountSat); diff --git a/src/features/ark.js b/src/features/ark.js index 739dd5e..beaf049 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -1011,7 +1011,7 @@ export function arkFeature(ctx) { offer, autowithdraw: await awMirror(prev), }); - if (prev) rec.spends = prev.spends; // the worker's log survives rewrites + if (prev) { rec.spends = prev.spends; rec.pendingPays = prev.pendingPays; } // the worker's log survives rewrites if (prefs) rec.prefs = prefs; // notification choices the worker honours await saveBg(wallet._cacheKey(), rec); await disarmSiblingRecords(wallet._cacheKey(), rec).catch(() => {}); diff --git a/src/features/nwc.js b/src/features/nwc.js index 9c71a7f..a2730f5 100644 --- a/src/features/nwc.js +++ b/src/features/nwc.js @@ -30,7 +30,7 @@ import { t } from '../i18n.js'; import { dlog } from '../debug.js'; import { qrSvg } from '../qr.js'; import { getNetwork } from '../api.js'; -import { loadBg, saveBg, clearBg } from '../nwc-bg.js'; +import { loadBg, saveBg, clearBg, readBgLog } from '../nwc-bg.js'; import { encodeNoffer } from '../noffer.js'; import { maybeBolt11 } from '../ark/lightning.js'; import { withRequestLock } from '../nwc-lock.js'; @@ -56,7 +56,7 @@ const nowSec = () => Math.floor(Date.now() / 1000); const errRes = (code, message) => ({ error: { code, message } }); export function nwcFeature(ctx) { - const { h, ui, render, wallet, hook, fmtAmount, unitLabel, copyBtn, toast } = ctx; + const { h, ui, render, wallet, hook, fmtAmount, unitLabel, copyBtn, copy, toast } = ctx; // Relay transport, injectable so the protocol can be driven in tests // without a live relay. const { @@ -478,11 +478,21 @@ export function nwcFeature(ctx) { try { res = await hook('arkPayInvoice', invoice, { maxAmountSat: c.maxSat }); } catch (e) { - // The ASP already holds a payment for this invoice: another device - // of this wallet took the same request first. Its outcome is ours — - // wait for it rather than report a failure over a zap that is - // paying. The payer records the spend; this side only relays. - if (!/already in progress/i.test(e.message || '')) throw e; + // A payment for this invoice already exists: ours, begun on an + // earlier request (the worker started it while closed, say), or + // another device's — the ASP refuses a second HTLC for one invoice. + // Its outcome is the answer, never an error whose text says "paid": + // a client once read "already paid or is being paid" as paid, hours + // before the money moved. The payer records the spend; this side + // only relays. + if (!/already in progress|already paid/i.test(e.message || '')) throw e; + const mine = await hook('arkLnLookup', { paymentHash: dec.paymentHash }).catch(() => null); + if (mine && mine.type === 'outgoing' && mine.state === 'settled' && mine.preimage) { + return { sibling: true, result: { preimage: mine.preimage, fees_paid: 0 } }; + } + if (mine && mine.type === 'outgoing' && mine.state === 'pending') { + return errRes('OTHER', 'a payment for this invoice is still in progress; ask again in a moment'); + } const out = await hook('arkLnOutcome', dec.paymentHash, 60000); if (!out || out.status !== 'success') throw e; return { sibling: true, result: { preimage: out.preimage, fees_paid: 0 } }; @@ -1093,7 +1103,19 @@ export function nwcFeature(ctx) { h('button', { class: 'btn-sm', onClick: () => { const s = load(); s.paySentNotify = !on; save(s); writeBg(); render(); } }, on ? t('nwcOn') : t('nwcOff'))), - h('div', { class: 'small faint' }, t('notifPaySentHint')))]; + h('div', { class: 'small faint' }, t('notifPaySentHint'))), + // the worker's diary, for the report that says where a closed wallet + // got to (a phone stops it without a trace) + h('div', { class: 'card col', style: 'gap:10px' }, + h('div', { class: 'row between' }, + h('span', { class: 'small' }, t('nwcWorkerLog')), + h('button', { class: 'btn-sm', onClick: async () => { + const lines = await readBgLog(); + if (!lines.length) { toast(t('nwcWorkerLogEmpty')); return; } + await copy(lines.join('\n')); + toast(t('copied')); + } }, t('copy'))), + h('div', { class: 'small faint' }, t('nwcWorkerLogHint')))]; }, }; } diff --git a/src/i18n.js b/src/i18n.js index 5fb2cb1..4514175 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -1597,6 +1597,9 @@ const STR = { notifChatHint: 'Community chat is opt-in per room. Turn it on from a room’s menu.', notifPaySent: 'Payments sent by connected apps', notifPaySentHint: 'A heads-up when a connected app (a nostr client zapping, for example) spends from this wallet while it’s closed.', + nwcWorkerLog: 'Background activity log', + nwcWorkerLogHint: 'What the wallet did while it was closed: wake-ups, payments, notifications. Copy it when reporting a problem.', + nwcWorkerLogEmpty: 'Nothing recorded yet', nwcNotifHint: 'You’ll be asked to allow notifications. The connection uses them to answer when Coinos is closed.', nwcNotifNeeded: 'Without notifications the connection couldn’t answer when Coinos is closed, so it wasn’t added. Allow notifications and try again.', nwcNotifBlocked: 'Notifications are blocked for this site. Allow them in your browser settings, then add the connection again.', diff --git a/src/nwc-bg.js b/src/nwc-bg.js index 3349126..2c73206 100644 --- a/src/nwc-bg.js +++ b/src/nwc-bg.js @@ -57,6 +57,30 @@ export async function saveBg(walletKey, rec) { export async function clearBg(walletKey) { return tx('readwrite', (s) => { s.delete(walletKey); }); } + +// The worker's diary: what it did while the app was closed — each push, the +// steps of a payment, every notification shown or refused. A phone stops a +// worker without a trace, and the diary's last line says where it got to. +// Settings → Notifications → Copy hands it to whoever is looking into it. +// Appended line by line (a worker that dies mid-push keeps what it wrote), +// one writer at a time, a few hundred lines kept. +const LOG_KEY = '__log'; +const LOG_MAX = 400; +let logChain = Promise.resolve(); +export function appendBgLog(line) { + logChain = logChain.then(() => tx('readwrite', async (s) => { + const cur = (await get(s, LOG_KEY)) || []; + cur.push(line); + s.put(cur.length > LOG_MAX ? cur.slice(-LOG_MAX) : cur, LOG_KEY); + })).catch(() => {}); + return logChain; +} +export async function readBgLog() { + try { return (await tx('readonly', (s) => get(s, LOG_KEY))) || []; } catch { return []; } +} +export async function clearBgLog() { + return tx('readwrite', (s) => { s.delete(LOG_KEY); }); +} // The service worker doesn't know wallet cache keys — it finds the right // record by the connection's service pubkey across all of them. export async function allBgs() { @@ -67,7 +91,8 @@ export async function allBgs() { cur.onsuccess = () => { const c = cur.result; if (!c) return res(out); - out.push({ walletKey: c.key, rec: c.value }); + // the worker's diary shares the store under a reserved key + if (!(typeof c.key === 'string' && c.key.startsWith('__'))) out.push({ walletKey: c.key, rec: c.value }); c.continue(); }; cur.onerror = () => rej(cur.error); @@ -119,7 +144,7 @@ export function buildBg({ ark, mgrState, keyFor, connections, offer, autowithdra // worker counts what the app spent; a revoked one rides along flagged, to // be refused rather than ignored. connections: (connections || []).map((c) => ({ - id: c.id, servicePk: c.servicePk, serviceSk: c.serviceSk, clientPk: c.clientPk, + id: c.id, name: c.name || '', servicePk: c.servicePk, serviceSk: c.serviceSk, clientPk: c.clientPk, maxSat: c.maxSat, dailySat: c.dailySat, period: c.period || 'day', spentDate: c.spentDate || null, spentToday: c.spentToday || 0, revoked: !!c.revoked, diff --git a/src/nwc-lock.js b/src/nwc-lock.js index c4fec08..a7b10b4 100644 --- a/src/nwc-lock.js +++ b/src/nwc-lock.js @@ -7,6 +7,17 @@ // the sats had gone. Web Locks are shared by every same-origin context, so // the first to claim a request's lock answers it and the rest stay quiet. // Where locks are unavailable (tests, ancient engines) the caller just runs. +// The worker's wallet work, one push at a time. Every push handler builds +// its own manager over the one shared mirror, and two of them driving the +// same send at once would sign it twice (a client's retry lands seconds +// after its lookup). A waiting push keeps its waitUntil and reads the mirror +// fresh once it gets the lock. +export async function withWorkLock(fn) { + const locks = typeof navigator !== 'undefined' && navigator.locks; + if (!locks) return fn(); + return locks.request('nwc-bg-work', fn); +} + export async function withRequestLock(id, fn, held = () => undefined) { const locks = typeof navigator !== 'undefined' && navigator.locks; if (!locks || !id) return fn(); diff --git a/src/nwc-respond.js b/src/nwc-respond.js index 10692c9..5e9106d 100644 --- a/src/nwc-respond.js +++ b/src/nwc-respond.js @@ -61,10 +61,23 @@ export function shimAccount(rec) { return { deriveChild: (chain) => ({ deriveChild: (i) => ({ privateKey: byChain(chain, i) }) }) }; } -export async function bgManager(rec, walletKey, saveFn) { +export async function bgManager(rec, walletKey, saveFn, log = null) { + // the diary follows each send's steps through the mirror's saves: a worker + // the phone stops mid-payment leaves its last step on record + const steps = new Map(((rec.mgr && rec.mgr.actions) || []).filter((a) => a.type === 'ln-pay').map((a) => [a.id, a.step])); const storage = { load: () => rec.mgr, - save: (s) => { rec.mgr = s; saveFn(walletKey, rec).catch(() => {}); }, + save: (s) => { + rec.mgr = s; + if (log) { + for (const a of s.actions || []) { + if (a.type !== 'ln-pay' || steps.get(a.id) === a.step) continue; + steps.set(a.id, a.step); + log(`ln-pay ${String(a.paymentHash || '').slice(0, 8)} → ${a.step}${a.error ? ': ' + a.error : ''}`); + } + } + Promise.resolve(saveFn(walletKey, rec)).catch(() => {}); + }, }; return new ArkManager({ account: shimAccount(rec), storage, @@ -73,6 +86,63 @@ export async function bgManager(rec, walletKey, saveFn) { }).init(); } +// ---- sends in flight across wakes ------------------------------------------ +// The phone stops a worker without warning: between reserving a coin and the +// ASP's cosign, or between the ASP paying and our reply. The action stays in +// the mirror at its last step, its coin reserved, and only the app used to +// finish it. These let the worker finish it on a later wake instead. +const pendingLnPays = (rec) => ((rec.mgr && rec.mgr.actions) || []) + .filter((a) => a.type === 'ln-pay' && !['done', 'failed'].includes(a.step)); +// which connection a send belongs to, kept beside the mirror: the action is +// the manager's and knows nothing of NWC +function notePending(rec, hash, conn) { + rec.pendingPays = (rec.pendingPays || []).filter((p) => p.hash !== hash).slice(-50); + rec.pendingPays.push({ hash, connId: conn.id, ts: Date.now() }); +} +function forgetPending(rec, hash) { + if (rec.pendingPays) rec.pendingPays = rec.pendingPays.filter((p) => p.hash !== hash); +} +function connOf(rec, a) { + const p = (rec.pendingPays || []).find((x) => x.hash === a.paymentHash); + return (p && (rec.connections || []).find((c) => c.id === p.connId)) || null; +} +// A settled send goes on the worker's spend log once; the app absorbs it +// into budgets and history on next open. +function recordBgSpend(rec, a, conn, fallback = {}) { + const hash = a.paymentHash || fallback.paymentHash; + forgetPending(rec, hash); + rec.spends = rec.spends || []; + if (hash && rec.spends.some((s) => s.paymentHash === hash)) return; + rec.spends.push({ + connId: conn ? conn.id : null, amountSat: a.amountSat || fallback.amountSat, feeSat: a.feeSat || 0, + paymentHash: hash, preimage: a.preimage, invoice: a.invoice || fallback.invoice, ts: Date.now(), + }); + if (rec.spends.length > 200) rec.spends = rec.spends.slice(-200); +} +// Money left while the app was closed — that deserves a heads-up, unless +// the user turned "payments sent" notifications off in Settings. +function sentNotice(rec, a, conn, fallbackSat = 0) { + if (rec.prefs && rec.prefs.paySent === false) return true; + const paidSat = a.amountSat || fallbackSat; + const feeNote = a.feeSat ? ` (+${a.feeSat} fee)` : ''; + return { + notify: { + title: 'Payment sent', tag: 'nwc-sent', + body: `-${paidSat.toLocaleString()} sats${feeNote} via ${(conn && conn.name) || 'a connected app'}`, + }, + }; +} +// Drive a Lightning send on until it settles, fails, or the budget is gone. +async function finishLnPay(mgr, id, deadline) { + let 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); + } + return a; +} + // 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 @@ -333,17 +403,19 @@ export async function respondFromBg(data, { await publish(errRes('pay_invoice', 'QUOTA_EXCEEDED', `over the remaining ${renewalPeriod(conn.period)} budget (${left} 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 send this worker began on an earlier wake and never finished (PullCap's + // night of 2026-10-23: three hours of fifteen). Its coin sat reserved in + // the mirror, so a fresh attempt at the SAME invoice was refused as + // "already paid or is being paid" — which the client read as paid, hours + // before the money moved — and one at any OTHER invoice found no spendable + // coin and woke the user. Finish what was begun first: the same invoice + // gets that payment's outcome, another invoice gets the coin back. + const stuck = pendingLnPays(rec); + const same = stuck.find((a) => a.paymentHash === dec.paymentHash) + || (rec.mgr.actions || []).find((a) => a.type === 'ln-pay' && a.paymentHash === dec.paymentHash && a.step === 'done'); + let mgr = null; + const ready = async () => mgr || (mgr = await bgManager(rec, walletKey, saveFn, log)); + const deadline = Date.now() + PAY_WAIT_MS; // 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. @@ -354,22 +426,75 @@ export async function respondFromBg(data, { } return true; }; + const settled = async (a, c, { record = true } = {}) => { + if (record) recordBgSpend(rec, a, c, { paymentHash: dec.paymentHash, amountSat: dec.amountSat, invoice }); + await saveFn(walletKey, rec); + await publish({ result_type: 'pay_invoice', result: { preimage: a.preimage, fees_paid: (a.feeSat || 0) * 1000 } }); + return sentNotice(rec, a, c, dec.amountSat); + }; + if (same) { + if (!(await claim())) { log('another device claimed this request — standing down'); return true; } + log(`a send for this invoice is already ${same.step} — finishing it`); + const wasDone = same.step === 'done'; + let a = same; + if (!wasDone) { + try { await ready(); } catch (e) { log('ark init failed: ' + e.message); return false; } + a = await finishLnPay(mgr, same.id, deadline); + } + // settled earlier (by the app, perhaps): its preimage again, nothing new + // on the spend log + if (a && a.step === 'done') return settled(a, conn, { record: !wasDone }); + if (a && a.step === 'failed') { forgetPending(rec, dec.paymentHash); return failQuietly(a.error || 'payment failed'); } + log('that send is still in flight — waking the user to finish it'); + return false; + } + const notices = []; + if (stuck.length) { + try { + await ready(); + for (const a of stuck) { + if (Date.now() > deadline - 8000) break; + const r = await finishLnPay(mgr, a.id, Math.min(deadline - 6000, Date.now() + 10000)); + if (!r || r.step !== 'done') { + log(`ln-pay ${String(a.paymentHash || '').slice(0, 8)} from an earlier wake is still ${r ? r.step : 'gone'}`); + if (r && r.step === 'failed') forgetPending(rec, a.paymentHash); + continue; + } + const c = connOf(rec, r); + recordBgSpend(rec, r, c); + log(`finished a ${r.amountSat} sat send from an earlier wake`); + const n = sentNotice(rec, r, c); + if (n !== true) notices.push(n.notify); + } + await saveFn(walletKey, rec); + } catch (e) { log('ark init failed: ' + e.message); } + } + const withNotices = (out) => (notices.length && out ? { ...(out === true ? {} : out), notices } : out); + + const spendable = spendableSat(rec); + if (spendable < dec.amountSat) { log(`${spendable} sat spendable in the mirror, ${dec.amountSat} asked — waking the user`); return false; } + + // 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; } + try { await ready(); } catch (e) { log('ark init failed: ' + e.message); return false; } + if (!(await claim())) { log('another device claimed this request — standing down'); return true; } - const deadline = Date.now() + PAY_WAIT_MS; + // whose send this is, for the wake that finishes it should this one not + notePending(rec, dec.paymentHash, conn); + await saveFn(walletKey, rec); + log(`paying ${dec.amountSat} sat`); let a = null; for (let attempt = 0; ; attempt++) { let id; try { id = await mgr.payLnInvoice(invoice); } catch (e) { + forgetPending(rec, dec.paymentHash); 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 = await finishLnPay(mgr, id, deadline); // 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. @@ -384,6 +509,7 @@ export async function respondFromBg(data, { // 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'); + forgetPending(rec, dec.paymentHash); 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; } @@ -394,38 +520,49 @@ export async function respondFromBg(data, { return true; } if (!a || a.step === 'failed') { + forgetPending(rec, dec.paymentHash); 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. + // still in flight as our budget runs out: the next wake (or the app, on + // open) finishes it — 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'}`, - }, - }; + return withNotices(await settled(a, conn)); +} + +// Sends begun on an earlier wake and never finished (see pay_invoice): any +// wake drives each on, within its budget, and hands back the "Payment sent" +// lines for the ones that settle. Without this a stuck send waited for the +// app to open, its coin reserved all the while. +export async function tendLnPays({ recordsFn = allBgs, saveFn = saveBg, log = () => {}, deadline = Date.now() + 8000 } = {}) { + const notices = []; + for (const { walletKey, rec } of await recordsFn()) { + if (!rec || rec.v < 3 || !rec.mgr || !rec.ark) continue; + const stuck = pendingLnPays(rec); + if (!stuck.length || Date.now() > deadline - 3000) continue; + try { + const mgr = await bgManager(rec, walletKey, saveFn, log); + for (const a of stuck) { + if (Date.now() > deadline - 3000) break; + const r = await finishLnPay(mgr, a.id, deadline); + if (!r || r.step !== 'done') { + log(`ln-pay ${String(a.paymentHash || '').slice(0, 8)} from an earlier wake is still ${r ? r.step : 'gone'}`); + if (r && r.step === 'failed') forgetPending(rec, a.paymentHash); + continue; + } + const c = connOf(rec, r); + recordBgSpend(rec, r, c); + log(`finished a ${r.amountSat} sat send from an earlier wake`); + const n = sentNotice(rec, r, c); + if (n !== true) notices.push(n.notify); + } + await saveFn(walletKey, rec); + } catch (e) { log('earlier sends: ' + e.message); } + } + return notices; } // Renewals while the app is closed. The app registers a standing order with diff --git a/src/sw-nwc.js b/src/sw-nwc.js index 211c4f9..78bb2bd 100644 --- a/src/sw-nwc.js +++ b/src/sw-nwc.js @@ -4,12 +4,18 @@ // nwc-respond.js), and only falls back to the wake-the-user notification // when it can't. -import { respondFromBg, tendRenewals } from './nwc-respond.js'; -import { withRequestLock } from './nwc-lock.js'; +import { respondFromBg, tendRenewals, tendLnPays } from './nwc-respond.js'; +import { withRequestLock, withWorkLock } from './nwc-lock.js'; +import { appendBgLog } from './nwc-bg.js'; import { bgAutoWithdraw } from './bg-autowithdraw.js'; import { allInboxes, classifyDm, shouldNotifyDm, classifyCall } from './dm-inbox.js'; const NOTIFIER = 'https://nwcpush.coinos.io'; +// The worker's diary (nwc-bg.js): every line also lands in IndexedDB as it is +// written, so a worker the phone stops mid-push leaves its last step behind. +const stamp = () => new Date().toISOString().replace('T', ' ').slice(0, 19); +const diary = (m) => { console.log('[sw-nwc]', m); appendBgLog(`${stamp()} ${m}`); }; +diary('worker started'); const CALL_CACHE = 'coinos-call'; // a call offer a push brought, for the app the tap opens // where a call's offer wrap is fetched from: the notifier, or (the app served // on this machine, as the tests do) this origin @@ -52,11 +58,12 @@ self.addEventListener('push', (e) => { // (PullCap's night of 2026-10-22: paid four times, no "Payment sent" seen.) // Try the full options; refused, try again with the plainest ones. const show = async (title, opts) => { - try { await self.registration.showNotification(title, opts); showed = true; return; } - catch (e) { console.warn('[sw] notification refused:', e && e.message); } - try { await self.registration.showNotification(title, { body: opts && opts.body, tag: opts && opts.tag, data: opts && opts.data }); showed = true; } - catch (e) { console.warn('[sw] plain notification refused too:', e && e.message); } + try { await self.registration.showNotification(title, opts); showed = true; diary(`notification shown: ${title}`); return; } + catch (e) { diary(`notification refused (${title}): ${e && e.message}`); } + try { await self.registration.showNotification(title, { body: opts && opts.body, tag: opts && opts.tag, data: opts && opts.data }); showed = true; diary(`plain notification shown: ${title}`); } + catch (e) { diary(`plain notification refused too (${title}): ${e && e.message}`); } }; + diary(`push ${data.type || '?'}${data.reason ? '/' + data.reason : ''}${data.event && data.event.id ? ' ' + data.event.id.slice(0, 8) : ''}`); // the stand-in, as soon as a path knows it has nothing to say (a long tail // of work after it mustn't keep the push waiting for its notification) const quiet = async () => { @@ -176,7 +183,7 @@ self.addEventListener('push', (e) => { const open = await self.registration.getNotifications({ tag: 'coinos-bg' }).catch(() => []); for (const n of open) n.close(); }; - if (apple && await standIn(false).catch(() => false)) showed = true; + if (apple && await standIn(false).catch((e) => { diary('stand-in refused: ' + (e && e.message)); return false; })) { showed = true; diary('stand-in shown'); } // An open window handles requests itself with full wallet state — nudge // it and stay out of the way. On iOS only one on screen counts: a // backgrounded app is suspended, so a nudge to it went unanswered. @@ -185,49 +192,73 @@ self.addEventListener('push', (e) => { if (clients.length) { for (const c of clients) c.postMessage({ type: 'nwc-wake', servicePubkey: data.servicePubkey }); if (apple) await dropStandIn(); // the window on screen shows it live + diary('window on screen — handed over'); return; } + const notice = (n) => show(n.title, { + body: n.body, icon: 'icon-192.png', badge: 'badge-96.png', + tag: n.tag || 'nwc-incoming', renotify: !apple, data: { url: './' }, + }); // A wake is the only moment a closed wallet gets to look after its coins: - // claim renewals whose round has run, cover coins without one. After the - // answer, inside what is left of the push budget. - const tend = () => tendRenewals({ deadline: startedAt + 27_000, log: (m) => console.log('[sw-renew]', m) }) - .catch((err) => console.warn('[sw-renew] failed:', err && err.message)); + // finish sends an earlier wake left in flight, claim renewals whose round + // has run, cover coins without one. After the answer, inside what is + // left of the push budget. + const tend = async () => { + const late = await tendLnPays({ deadline: startedAt + 27_000, log: diary }).catch((err) => { diary('earlier sends failed: ' + (err && err.message)); return []; }); + for (const n of late) await notice(n); + if (late.length && apple) await dropStandIn(); + await tendRenewals({ deadline: startedAt + 27_000, log: (m) => diary('renew: ' + m) }) + .catch((err) => diary('renewals failed: ' + (err && err.message))); + }; // an NWC wake-up may also have moved money in; check the rule after setTimeout(() => { bgAutoWithdraw({ log: (m) => console.log('[sw-aw]', m) }).catch(() => {}); }, 0); - let handled = false; - try { - handled = await withRequestLock(data.event && data.event.id, - () => respondFromBg(data, { notifier: NOTIFIER, log: (m) => console.log('[sw-nwc]', m) }), - () => { console.log('[sw-nwc] another context holds this request'); return true; }); - } catch (err) { - console.warn('[sw-nwc] auto-answer failed:', err && err.message); - } - // handled-with-a-heads-up: e.g. an invoice was minted while closed and - // the user should open coinos to complete the receive - if (handled && handled.notify) { - await show(handled.notify.title, { - body: handled.notify.body, + // one push at a time over the shared mirror (a client's retry lands + // seconds after its lookup, and each push builds its own manager) + await withWorkLock(async () => { + const waited = Date.now() - startedAt; + if (waited > 500) diary(`waited ${waited} ms for an earlier push to finish`); + let handled = false; + try { + handled = await withRequestLock(data.event && data.event.id, + () => respondFromBg(data, { notifier: NOTIFIER, log: diary }), + () => { diary('another context holds this request'); return true; }); + } catch (err) { + diary('auto-answer failed: ' + (err && err.message)); + } + // sends from an earlier wake that settled on the way to this answer + const late = (handled && handled.notices) || []; + for (const n of late) await notice(n); + // handled-with-a-heads-up: e.g. an invoice was minted while closed and + // the user should open coinos to complete the receive + if (handled && handled.notify) { + await notice(handled.notify); + if (apple) await dropStandIn(); + await tend(); + return; + } + if (handled) { + if (late.length && apple) await dropStandIn(); + diary('answered quietly'); + await quiet(); + await tend(); + return; + } + // A kind-21001 offer request is money coming IN (someone asking us to + // mint an invoice) — "an app wants to pay" would read as the opposite. + const incoming = data.event && data.event.kind === 21001; + diary(incoming ? 'waking the user: incoming payment' : 'waking the user: payment request'); + await show(incoming ? 'Incoming payment' : 'Payment request', { + body: incoming + ? 'Someone is trying to send you money. Open coinos so your wallet can receive it.' + : 'An app is asking your wallet to pay. Open Coinos to approve.', icon: 'icon-192.png', badge: 'badge-96.png', - tag: handled.notify.tag || 'nwc-incoming', renotify: !apple, + tag: 'nwc-' + (data.servicePubkey || 'req'), renotify: false, data: { url: './' }, }); if (apple) await dropStandIn(); await tend(); - return; - } - if (handled) { await quiet(); await tend(); return; } - // A kind-21001 offer request is money coming IN (someone asking us to - // mint an invoice) — "an app wants to pay" would read as the opposite. - const incoming = data.event && data.event.kind === 21001; - await show(incoming ? 'Incoming payment' : 'Payment request', { - body: incoming - ? 'Someone is trying to send you money. Open coinos so your wallet can receive it.' - : 'An app is asking your wallet to pay. Open Coinos to approve.', - icon: 'icon-192.png', badge: 'badge-96.png', - tag: 'nwc-' + (data.servicePubkey || 'req'), renotify: false, - data: { url: './' }, }); - if (apple) await dropStandIn(); + diary(`push done in ${Date.now() - startedAt} ms`); }); }); diff --git a/tools/nwc-sw-test.js b/tools/nwc-sw-test.js index 1ce9944..816b9ce 100644 --- a/tools/nwc-sw-test.js +++ b/tools/nwc-sw-test.js @@ -5,7 +5,8 @@ import { hex } from '@scure/base'; import * as nip04 from 'nostr-tools/nip04'; import * as nip44 from 'nostr-tools/nip44'; import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'; -import { respondFromBg } from '../src/nwc-respond.js'; +import { respondFromBg, tendLnPays } from '../src/nwc-respond.js'; +import { maybeBolt11 } from '../src/ark/lightning.js'; let fails = 0; const check = (name, ok, detail = '') => { @@ -203,6 +204,87 @@ console.log('\n[pay outcome]'); } } +console.log('\n[sends begun on an earlier wake]'); +// The worker died mid-payment on an earlier wake: the action sits in the +// mirror at 'created' with its coin reserved (PullCap, 2026-10-23). +{ + const { ArkManager } = await import('../src/ark/manager.js'); + const HASH = maybeBolt11(INV21).paymentHash; + const PRE = 'cd'.repeat(32); + const names = ['init', 'payLnInvoice', 'awaitLnPay', 'lnAction', 'driveLn']; + const orig = Object.fromEntries(names.map((k) => [k, ArkManager.prototype[k]])); + ArkManager.prototype.init = async function () { this.state = this.storage.load(); return this; }; + // a fresh attempt at an invoice with an action on the books is refused… + ArkManager.prototype.payLnInvoice = async function (invoice) { + const hash = maybeBolt11(invoice).paymentHash; + if (this.state.actions.some((a) => a.type === 'ln-pay' && a.paymentHash === hash && a.step !== 'failed')) throw new Error('a payment for this invoice is already in progress'); + const a = { id: 'lnpay-new', type: 'ln-pay', step: 'done', invoice, paymentHash: hash, amountSat: 21, feeSat: 0, preimage: 'ab'.repeat(32) }; + this.state.actions.push(a); this.storage.save(this.state); + return a.id; + }; + // …and a drive takes the stuck one through to done, freeing its change + ArkManager.prototype.awaitLnPay = async function (id) { return this.driveLn(id); }; + ArkManager.prototype.driveLn = async function (id) { + const a = this.state.actions.find((x) => x.id === id); + if (a && a.step !== 'done') { + a.step = 'done'; a.preimage = PRE; + for (const v of this.state.vtxos) if (v.state === 'pending') v.state = 'spendable'; + this.storage.save(this.state); + } + return a; + }; + ArkManager.prototype.lnAction = function (id) { return this.state.actions.find((x) => x.id === id); }; + const stuckRec = (hash) => { + const rec = baseRec(); + rec.mgr.vtxos[0].state = 'pending'; + rec.mgr.actions.push({ id: 'lnpay-stuck', type: 'ln-pay', step: 'created', invoice: INV21, paymentHash: hash, amountSat: 21, feeSat: 0 }); + rec.pendingPays = [{ hash, connId: 'c1', ts: 1 }]; + return rec; + }; + try { + { + // the SAME invoice asked again: finish that send, answer with ITS preimage + const rec = stuckRec(HASH); + const log = []; + const { deps, published, decryptReply } = harness({ rec }); + const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, { ...deps, log: (m) => log.push(m) }); + check('same invoice → the stuck send is finished and answered with its preimage', published.length === 1 && decryptReply(published[0]).result?.preimage === PRE, JSON.stringify(handled)); + check('…recorded once on the spend log, under its connection', rec.spends.length === 1 && rec.spends[0].connId === 'c1' && rec.spends[0].preimage === PRE && rec.pendingPays.length === 0, JSON.stringify(rec.spends)); + check('…with a "Payment sent" heads-up', handled && handled.notify && handled.notify.title === 'Payment sent'); + check('…and the diary names the step it reached', log.some((m) => /already created/.test(m)) && log.some((m) => /→ done/.test(m)), log.join(' | ')); + } + { + // a send that settled earlier (in the app, say): its preimage again, + // nothing new on the spend log + const rec = baseRec(); + rec.mgr.actions.push({ id: 'lnpay-old', type: 'ln-pay', step: 'done', invoice: INV21, paymentHash: HASH, amountSat: 21, feeSat: 0, preimage: PRE }); + const { deps, published, decryptReply } = harness({ rec }); + await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); + check('an invoice paid earlier answers with the same preimage', published.length === 1 && decryptReply(published[0]).result?.preimage === PRE); + check('…without a second spend', (rec.spends || []).length === 0); + } + { + // a stuck send for ANOTHER invoice holds the only coin: finish it first, + // then pay the new request from the freed coin + const rec = stuckRec('ee'.repeat(32)); + const { deps, published, decryptReply } = harness({ rec }); + const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); + check('another invoice → the earlier send is finished, then this one paid', published.length === 1 && decryptReply(published[0]).result?.preimage === 'ab'.repeat(32), JSON.stringify(handled)); + check('…both on the spend log', rec.spends.length === 2 && rec.spends[0].preimage === PRE && rec.spends[1].preimage === 'ab'.repeat(32)); + check('…the earlier one gets its own "Payment sent" line', handled && handled.notices && handled.notices.length === 1 && handled.notify && handled.notify.title === 'Payment sent', JSON.stringify(handled)); + } + { + // any wake at all finishes it (a lookup, a push for something else) + const rec = stuckRec(HASH); + const notices = await tendLnPays({ recordsFn: async () => [{ walletKey: 'w1', rec }], saveFn: async () => {} }); + check('tendLnPays finishes a stuck send and reports it', notices.length === 1 && notices[0].title === 'Payment sent' && rec.spends.length === 1 && rec.spends[0].connId === 'c1', JSON.stringify(notices)); + check('…and a second wake has nothing left to do', (await tendLnPays({ recordsFn: async () => [{ walletKey: 'w1', rec }], saveFn: async () => {} })).length === 0); + } + } finally { + Object.assign(ArkManager.prototype, orig); + } +} + console.log('\n[network fit]'); { // a mutinynet-era mirror must not answer a mainnet invoice with a nonsense diff --git a/tools/sw-push-quota.test.js b/tools/sw-push-quota.test.js index e7d00f2..049dc1f 100644 --- a/tools/sw-push-quota.test.js +++ b/tools/sw-push-quota.test.js @@ -4,8 +4,9 @@ import { test, expect, mock } from 'bun:test'; let respond = async () => true; -mock.module('../src/nwc-respond.js', () => ({ respondFromBg: (...a) => respond(...a), tendRenewals: async () => {} })); -mock.module('../src/nwc-lock.js', () => ({ withRequestLock: async (_id, fn) => fn() })); +mock.module('../src/nwc-respond.js', () => ({ respondFromBg: (...a) => respond(...a), tendRenewals: async () => {}, tendLnPays: async () => [] })); +mock.module('../src/nwc-lock.js', () => ({ withRequestLock: async (_id, fn) => fn(), withWorkLock: async (fn) => fn() })); +mock.module('../src/nwc-bg.js', () => ({ appendBgLog: async () => {} })); mock.module('../src/bg-autowithdraw.js', () => ({ bgAutoWithdraw: async () => {} })); mock.module('../src/dm-inbox.js', () => ({ allInboxes: async () => [{ muted: ['m'.repeat(64)] }], @@ -50,6 +51,13 @@ test('apple: every quiet path shows the stand-in', async () => { expect(closed.length).toBe(0); }); +test('apple: a send finished from an earlier wake shows its own line and drops the stand-in', async () => { + endpoint = 'https://web.push.apple.com/abc'; windows = []; + respond = async () => ({ notices: [{ title: 'Payment sent', body: '-21 sats via a connected app', tag: 'nwc-sent' }] }); + expect(await push(nwc)).toEqual(['Coinos', 'Payment sent']); + expect(closed.length).toBe(1); +}); + test('apple: an app on screen gets the stand-in closed at once', async () => { windows = [visible]; expect(await push({ type: 'notify', reason: 'payment', amountSat: 5 })).toEqual(['Coinos']); @@ -60,7 +68,9 @@ test('apple: a suspended background app does not swallow the request', async () windows = [hidden]; let asked = 0; respond = async () => { asked++; return { notify: { title: 'Payment sent', body: '' } }; }; - expect(await push(nwc)).toEqual(['Payment sent']); + // the stand-in goes up first and the outcome replaces it (91dbee4) + expect(await push(nwc)).toEqual(['Coinos', 'Payment sent']); + expect(closed.length).toBe(1); expect(asked).toBe(1); }); |
