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.js310
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); }