diff options
Diffstat (limited to 'src/nwc-respond.js')
| -rw-r--r-- | src/nwc-respond.js | 310 |
1 files changed, 249 insertions, 61 deletions
diff --git a/src/nwc-respond.js b/src/nwc-respond.js index ec19923..0778813 100644 --- a/src/nwc-respond.js +++ b/src/nwc-respond.js @@ -21,7 +21,7 @@ import { ArkManager } from './ark/manager.js'; import { maybeBolt11 } from './ark/lightning.js'; import { allBgs, saveBg } from './nwc-bg.js'; import { budgetRenewsAt, renewalPeriod, spentInWindow } from './nwc-budget.js'; -import { backstopMargin } from './ark/manager.js'; +import { renewPolicy } from './ark/manager.js'; const MAX_AGE_SEC = 120; const PAY_WAIT_MS = 20_000; // inside the SW's ~30s budget, with reply margin @@ -61,17 +61,92 @@ 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, arkUrl: rec.ark.arkUrl, esploraUrl: rec.ark.esploraUrl, network: rec.ark.network, + honorsExpired: !!rec.ark.honorsExpired, }).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. +// Every step is held to the deadline: a call to the ASP that hangs must not +// carry the worker past its budget (iOS stops it, mid-line, with no trace). +async function finishLnPay(mgr, id, deadline) { + const by = (p) => Promise.race([p, new Promise((r) => setTimeout(r, Math.max(0, deadline - Date.now())))]); + let a = await by(mgr.awaitLnPay(id, Math.max(500, deadline - Date.now())).catch(() => null)); + a = mgr.lnAction(id); + while (a && !['done', 'failed'].includes(a.step) && Date.now() < deadline - 1500) { + await new Promise((r) => setTimeout(r, 1000)); + await by(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 @@ -127,7 +202,7 @@ async function respondOffer(ev, rec, walletKey, { notifier, fetchFn, saveFn, log // left to another device); false means "wake the user instead". export async function respondFromBg(data, { notifier, fetchFn = fetch, recordsFn = allBgs, saveFn = saveBg, log = () => {}, - errGraceMs = 3000, + errGraceMs = 3000, deadline: until = null, } = {}) { const ev = data && data.event; if (!ev || !ev.id || !ev.pubkey) return false; @@ -175,6 +250,9 @@ export async function respondFromBg(data, { try { req = JSON.parse(await decrypt(scheme, conn.serviceSk, ev.pubkey, ev.content)); } catch { return false; } const method = req?.method; const params = req?.params || {}; + // the push's budget (on iOS about ten seconds from the push, not thirty) + const deadline = until || Date.now() + PAY_WAIT_MS; + let lookupNotice = null; // Revoked, lately: refuse out loud — a silent worker looks like a phone // that is off, which is the one thing the other app cannot tell apart. if (conn.revoked) { @@ -251,23 +329,47 @@ export async function respondFromBg(data, { await publish(errRes('lookup_invoice', 'OTHER', 'payment_hash or invoice required')); return true; } - if (!rec.mgr) return false; const q = { paymentHash: params.payment_hash, invoice: params.invoice }; - // read straight off the mirror; the ASP is only needed to claim - const view = Object.assign(Object.create(ArkManager.prototype), { state: rec.mgr, account: shimAccount(rec) }); - let tx = view.lnLookup(q); - const a = tx && tx.state === 'pending' && tx.type === 'incoming' - && (rec.mgr.actions || []).find((x) => x.type === 'ln-recv' && x.paymentHash === tx.payment_hash); + // read straight off the mirror; the ASP is only needed to claim. Every + // copy of the connection is asked (the freshest may be another network's + // mirror: PullCap's lookups of a send in flight came back "not found"). + let tx = null; + for (const m of matches) { + if (!m.rec.mgr) continue; + const view = Object.assign(Object.create(ArkManager.prototype), { state: m.rec.mgr, account: shimAccount(m.rec) }); + tx = view.lnLookup(q); + if (tx) { ({ walletKey, rec, conn } = m); break; } + } + if (!tx && !rec.mgr) return false; + // a payment of ours still on its way (incoming: claim it; outgoing: a + // send an earlier wake began — drive it on, so the lookup itself can + // answer 'settled' with the preimage instead of a second pay request) + const a = tx && tx.state === 'pending' + && (rec.mgr.actions || []).find((x) => x.type === (tx.type === 'incoming' ? 'ln-recv' : 'ln-pay') && x.paymentHash === tx.payment_hash + && !['done', 'failed'].includes(x.step)); if (a) { try { - const mgr = await bgManager(rec, walletKey, saveFn); - await Promise.race([mgr.driveLn(a.id).catch((e) => log('claim: ' + e.message)), new Promise((r) => setTimeout(r, PAY_WAIT_MS))]); + const mgr = await bgManager(rec, walletKey, saveFn, log); + if (a.type === 'ln-pay') { + const r = await finishLnPay(mgr, a.id, Math.min(deadline, Date.now() + 6000)); + if (r && r.step === 'done') { + const c = connOf(rec, r) || conn; + recordBgSpend(rec, r, c); + log(`finished a ${r.amountSat} sat send from an earlier wake`); + const n = sentNotice(rec, r, c); + if (n !== true) lookupNotice = n.notify; + } else if (r && r.step === 'failed') forgetPending(rec, a.paymentHash); + await saveFn(walletKey, rec); + } else { + await Promise.race([mgr.driveLn(a.id).catch((e) => log('claim: ' + e.message)), new Promise((r) => setTimeout(r, Math.max(0, deadline - Date.now())))]); + } tx = mgr.lnLookup(q); - if (tx.state === 'settled') log(`received ${tx.amount / 1000} sat while closed`); + if (tx.state === 'settled' && tx.type === 'incoming') log(`received ${tx.amount / 1000} sat while closed`); } catch (e) { log('ark init failed: ' + e.message); } } await publish(tx ? { result_type: 'lookup_invoice', result: tx } : errRes('lookup_invoice', 'NOT_FOUND', 'invoice not found')); - return true; + log(`lookup: ${tx ? tx.type + ' ' + tx.state : 'not found'}`); + return lookupNotice ? { notify: lookupNotice } : true; } if (method === 'get_balance') { await publish({ result_type: 'get_balance', result: { balance: spendableSat(rec) * 1000 } }); @@ -332,17 +434,22 @@ 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; + let mgrP = null; + const ready = async () => mgr || (mgr = await (mgrP || (mgrP = bgManager(rec, walletKey, saveFn, log)))); + // the manager's start (the ASP's info, the chain tip) runs while the + // notifier is asked whether anyone else answered + ready().catch(() => {}); // 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. @@ -353,22 +460,81 @@ 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 }); + // the mirror and the reply at once: every second counts on iOS, and the + // "Payment sent" line comes after both + await Promise.all([ + saveFn(walletKey, rec), + publish({ result_type: 'pay_invoice', result: { preimage: a.preimage, fees_paid: (a.feeSat || 0) * 1000 } }).then(() => log('reply sent')), + ]); + 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 = []; + // Only when they hold the coins this payment needs: otherwise the answer + // comes first and the wake's tending finishes them after. + if (stuck.length && spendableSat(rec) < dec.amountSat) { + try { + await ready(); + for (const a of stuck) { + if (Date.now() > deadline - 4000) break; + const r = await finishLnPay(mgr, a.id, Math.min(deadline - 3000, Date.now() + 5000)); + 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. @@ -383,6 +549,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; } @@ -393,38 +560,50 @@ 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 Promise.race([bgManager(rec, walletKey, saveFn, log), + new Promise((_, rej) => setTimeout(() => rej(new Error('out of time')), Math.max(0, deadline - Date.now())))]); + 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 @@ -460,12 +639,21 @@ export async function tendRenewals({ recordsFn = allBgs, saveFn = saveBg, log = if (a.step === 'done') { n++; log('claimed a renewal while closed'); } } // coins without an order: register one, as long as the output key the - // order burns is inside the mirrored window (the worker can't derive) + // order burns is inside the mirrored window (the worker can't derive). + // Only under a custody tier that renews at all — the default lets coins + // run past their timelock in coinos's hands, by choice, and pays nothing. + const policy = renewPolicy(rec.custody || 'custodial', rec.ark.network); let room = 0; while (rec.keys[String((mgr.state.nextKeyIndex || 1) + room)] && room < 4) room++; - if (room) { - const got = await bounded(mgr.ensureScheduledRefreshes(margin ?? backstopMargin(rec.ark.network), room)).catch((e) => { log('renewal order: ' + e.message); return 0; }); - if (got) { n += got; log(`registered ${got} renewal order(s) while closed`); } + if (room && policy) { + const got = await bounded(mgr.ensureScheduledRefreshes(margin ?? policy.margin, room, policy.grid)).catch((e) => { log('renewal order: ' + e.message); return 0; }); + if (got) { n += got; room -= got; log(`registered ${got} renewal order(s) while closed`); } + } + // Under every tier: a coin near the server's depth cap must be + // flattened to stay spendable (the base fee is waived for it). + if (room > 0) { + const got = await bounded(mgr.ensureDeepRefreshes(room)).catch((e) => { log('deep refresh order: ' + e.message); return 0; }); + if (got) { n += got; log(`registered ${got} deep-coin renewal order(s) while closed`); } } await saveFn(walletKey, rec); } catch (e) { log('renewals: ' + e.message); } |
