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.js147
1 files changed, 113 insertions, 34 deletions
diff --git a/src/nwc-respond.js b/src/nwc-respond.js
index 5e9106d..12101f5 100644
--- a/src/nwc-respond.js
+++ b/src/nwc-respond.js
@@ -24,6 +24,14 @@ import { budgetRenewsAt, renewalPeriod, spentInWindow } from './nwc-budget.js';
import { renewPolicy } from './ark/manager.js';
const MAX_AGE_SEC = 120;
+// A request the worker can't act on (keys or coins short in the mirror, an
+// old mirror) wakes the user — and, unless someone answers first, tells the
+// other app so (PullCap read the silence as "no answer" and suspended the
+// subscription; with an error it can tell the payer their wallet is waiting
+// for them). The notifier holds the reply this long before sending it.
+const DECLINE_AFTER_MS = 20_000;
+const FAIL_AFTER_MS = 5_000; // the notifier's shortest hold
+const WAIT_MSG = 'Coinos is waiting for its owner to open the app and approve. Look this request up again later.';
const PAY_WAIT_MS = 20_000; // inside the SW's ~30s budget, with reply margin
const nowSec = () => Math.floor(Date.now() / 1000);
const errRes = (t, code, message) => ({ result_type: t, error: { code, message } });
@@ -83,6 +91,7 @@ export async function bgManager(rec, walletKey, saveFn, log = null) {
account: shimAccount(rec), storage,
arkUrl: rec.ark.arkUrl, esploraUrl: rec.ark.esploraUrl, network: rec.ark.network,
honorsExpired: !!rec.ark.honorsExpired,
+ lnOldestFirst: () => !!rec.custody && rec.custody !== 'custodial',
}).init();
}
@@ -133,11 +142,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,11 +211,14 @@ 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;
- if (nowSec() - (ev.created_at || 0) > MAX_AGE_SEC) return false;
+ // Older than the request window (a push that sat at APNs while the phone
+ // was out of reach): the other app has moved on — nothing to answer, and
+ // no "Payment request" to show for it. The wake still tends what's in flight.
+ if (nowSec() - (ev.created_at || 0) > MAX_AGE_SEC) { log(`request ${String(ev.id).slice(0, 8)} is ${nowSec() - ev.created_at} s old — ignored`); return true; }
const target = (ev.tags?.find((t) => t[0] === 'p') || [])[1];
if (!target) return false;
@@ -246,6 +262,23 @@ 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 || {};
+ // Wake the user, out loud: the wait reply is left with the notifier, which
+ // sends it only if nobody (a sibling device, the woken user) answers in
+ // time — so it can never land on top of a real payment.
+ const declineLater = async (why) => {
+ log(why + ' — waking the user; the app is told to wait');
+ try {
+ const evt = await buildReply(conn, ev, scheme, errRes(method || 'error', 'OTHER', WAIT_MSG));
+ await fetchFn(`${notifier}/decline`, {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ event: evt, afterMs: DECLINE_AFTER_MS }),
+ });
+ } catch (e) { log('could not leave the wait reply: ' + (e && e.message)); }
+ return false;
+ };
+ // 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) {
@@ -294,9 +327,9 @@ export async function respondFromBg(data, {
if (method === 'make_invoice') {
const sat = Math.floor((params.amount || 0) / 1000);
if (!sat) { await publish(errRes('make_invoice', 'OTHER', 'amount required (msat)')); return true; }
- if (!rec.keys5 || !rec.key4 || !rec.mgr) return false; // old mirror: wake the user
- if (!rec.keys5[String(rec.mgr.nextLnRecvIndex || 0)]) return false; // preimage window exhausted
- if (!rec.keys[String(rec.mgr.nextKeyIndex || 1)]) return false; // claim key window exhausted
+ if (!rec.keys5 || !rec.key4 || !rec.mgr) return declineLater('old mirror, no receive keys');
+ if (!rec.keys5[String(rec.mgr.nextLnRecvIndex || 0)]) return declineLater('preimage window exhausted');
+ if (!rec.keys[String(rec.mgr.nextKeyIndex || 1)]) return declineLater('key window exhausted');
let a;
try {
const mgr = await bgManager(rec, walletKey, saveFn);
@@ -322,23 +355,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 declineLater('old mirror, no state to look up');
+ // 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,22 +471,41 @@ 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;
- // 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.
+ 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 — a sibling device's slower SUCCESS
+ // reply must win the client's eyes instead of being shouted over. The
+ // error is left with the notifier, which sends it only if nobody answers
+ // in FAIL_AFTER_MS: waiting here instead ran past iOS's ten seconds after
+ // a failed send, and the other app heard nothing at all (PullCap,
+ // 2026-10-29). Waiting here is the fallback when the notifier won't take it.
const failQuietly = async (message) => {
+ const code = /insufficient/i.test(message || '') ? 'INSUFFICIENT_BALANCE' : 'PAYMENT_FAILED';
+ try {
+ const evt = await buildReply(conn, ev, scheme, errRes('pay_invoice', code, message));
+ const r = await fetchFn(`${notifier}/decline`, {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ event: evt, afterMs: FAIL_AFTER_MS }),
+ });
+ if (r.ok) { log(`payment failed (${message}) — ${code} goes out unless someone answers first`); return true; }
+ } catch {}
await new Promise((r) => setTimeout(r, errGraceMs));
if (!(await answered())) {
- await publish(errRes('pay_invoice', 'INTERNAL', message)).catch(() => {});
+ await publish(errRes('pay_invoice', code, message)).catch(() => {});
}
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 } });
+ // 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 +525,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);
@@ -472,13 +550,13 @@ export async function respondFromBg(data, {
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; }
+ if (spendable < dec.amountSat) return declineLater(`${spendable} sat spendable in the mirror, ${dec.amountSat} asked`);
// 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 (!rec.keys[String(rec.mgr.nextKeyIndex || 1)]) return declineLater('key window exhausted');
+ try { await ready(); } catch (e) { return declineLater('ark init failed: ' + e.message); }
if (!(await claim())) { log('another device claimed this request — standing down'); return true; }
// whose send this is, for the wake that finishes it should this one not
@@ -544,7 +622,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);