aboutsummaryrefslogtreecommitdiffstats
path: root/src/nwc-respond.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/nwc-respond.js')
-rw-r--r--src/nwc-respond.js227
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