diff options
| author | Adam Soltys <[email protected]> | 2026-10-24 07:54:32 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-24 07:54:32 -0700 |
| commit | eec7abcfa329b6b405e69ffff54ae0a312f66860 (patch) | |
| tree | 69e563bb6bd61ed0a53406f632281778c5d2f823 | |
| parent | ce96364662451556e9ae6fe8222eba4ea23bd27c (diff) | |
| download | coinosv3-eec7abcfa329b6b405e69ffff54ae0a312f66860.tar.gz coinosv3-eec7abcfa329b6b405e69ffff54ae0a312f66860.zip | |
A closed wallet finishes the Lightning sends an earlier wake left in flight, instead of calling the invoice "already paid" with no preimage and locking its coin until the app opens
PullCap's night of 2026-10-23 (hourly 1-sat NWC requests to a closed iPhone
PWA): three of fifteen hours went wrong the same way. The push woke the
worker, it reserved the coin and wrote the ln-pay action to the mirror, and
the phone stopped it before the ASP was ever asked (no cosign in captaind's
log for those minutes). The client's retry ten minutes later hit the
manager's duplicate check, "this invoice was already paid or is being paid",
published as an INTERNAL error that the client read as paid — hours before
the money moved. And since the stuck action held the wallet's only coin,
the next hour's request (a different invoice) found nothing spendable and
fell to "Open Coinos to approve". Everything settled at once when the app
opened at 07:12 and resumed the actions.
- nwc-respond.js: pay_invoice first looks for a send already on the books
for this invoice — finishing one in flight and answering with its
preimage, repeating the preimage of one already done (nothing new on the
spend log) — and before paying a new invoice drives any other stuck send
to completion so its coin comes back. tendLnPays() does the same on every
wake, lookups included, and hands back "Payment sent" lines for what
settled. rec.pendingPays remembers which connection a send belongs to
(the action is the manager's); the app's mirror rewrite carries it.
- manager.js: the duplicate check says "already paid" only for a settled
send, "already in progress" for one on its way.
- features/nwc.js: the open app answers a repeated invoice from the local
action (preimage when settled, an honest "still in progress" otherwise)
before asking the ASP about a sibling device.
- sw-nwc.js: one push at a time over the shared mirror (Web Lock), so a
client's lookup + retry seconds apart can't double-drive one send. A
diary of what the worker did — each push, each ln-pay step as the mirror
saves it, every notification shown or refused, the fallback taken — is
appended line by line to IndexedDB (nwc-bg.js appendBgLog), so a worker
the phone stops mid-push leaves its last step behind. Settings →
Notifications → "Background activity log" copies it.
- tests: nwc-sw-test covers the four resumed-send cases; sw-push-quota
mocks the new seams and its "suspended app" case now expects the
stand-in-first order shipped in 91dbee4 (it had been failing since).
Co-Authored-By: Claude Fable 5.1 <[email protected]>
| -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); }); |
