aboutsummaryrefslogtreecommitdiffstats
path: root/src/nwc-respond.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-25 07:46:55 -0700
committerAdam Soltys <[email protected]>2026-10-25 07:46:55 -0700
commit43a35d0fe6abc56b6594015ae1028842c04a2c19 (patch)
treebe56c2bb8f71826df0360b0842ee1bd7fa4b3a3b /src/nwc-respond.js
parent9270bc52a33ef007d57d15b5e6aee95248c368a2 (diff)
downloadcoinosv3-43a35d0fe6abc56b6594015ae1028842c04a2c19.tar.gz
coinosv3-43a35d0fe6abc56b6594015ae1028842c04a2c19.zip
A closed wallet's NWC payments fit iOS's ten-second wake: a paid send is never marked for revocation by a second wake, lookups report outgoing sends and finish them, and pushes no longer run side by side
PullCap's night of 2026-10-24: every wake's diary stopped 8-12 s after its push (iOS stops the worker there, not at 30 s). Two wakes two seconds apart both drove one send; the second's InitiateLightningPayment was refused ("state: ln-spent") and the paid send went to 'revoking', where every later wake spent its whole budget on it, so no request was answered after midnight. - manager: an initiate refused as ln-spent follows the payment ('initiated'); a send in 'revoking' asks the ASP first (paid: settle; in flight before expiry: back to waiting; not found: revoke) - worker: 9 s budget on Apple endpoints (27 s elsewhere) for answers, earlier sends and renewals; every ASP step held to it - lookup_invoice asks every copy of the connection and drives an outgoing send in flight, answering settled with the preimage - a lease in the worker's IndexedDB store serialises pushes (the Web Lock didn't); diary notes a missing Web Locks API - the reply and the mirror save run together; stuck sends are finished before a new payment only when they hold its coins Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01QjVvE4YAcK3AGwV7kZXeN4
Diffstat (limited to 'src/nwc-respond.js')
-rw-r--r--src/nwc-respond.js87
1 files changed, 64 insertions, 23 deletions
diff --git a/src/nwc-respond.js b/src/nwc-respond.js
index 5e9106d..0778813 100644
--- a/src/nwc-respond.js
+++ b/src/nwc-respond.js
@@ -133,11 +133,15 @@ function sentNotice(rec, a, conn, fallbackSat = 0) {
};
}
// 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) {
- 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(() => {});
+ 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;
@@ -198,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;
@@ -246,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) {
@@ -322,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 } });
@@ -414,8 +445,11 @@ export async function respondFromBg(data, {
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;
+ 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.
@@ -428,8 +462,12 @@ export async function respondFromBg(data, {
};
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 } });
+ // 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) {
@@ -449,12 +487,14 @@ export async function respondFromBg(data, {
return false;
}
const notices = [];
- if (stuck.length) {
+ // 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 - 8000) break;
- const r = await finishLnPay(mgr, a.id, Math.min(deadline - 6000, Date.now() + 10000));
+ 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);
@@ -544,7 +584,8 @@ export async function tendLnPays({ recordsFn = allBgs, saveFn = saveBg, log = ()
const stuck = pendingLnPays(rec);
if (!stuck.length || Date.now() > deadline - 3000) continue;
try {
- const mgr = await bgManager(rec, walletKey, saveFn, log);
+ 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);