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 /src/nwc-respond.js | |
| 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]>
Diffstat (limited to 'src/nwc-respond.js')
| -rw-r--r-- | src/nwc-respond.js | 227 |
1 files changed, 182 insertions, 45 deletions
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 |
