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.js78
1 files changed, 58 insertions, 20 deletions
diff --git a/src/nwc-respond.js b/src/nwc-respond.js
index 98fd19c..311b928 100644
--- a/src/nwc-respond.js
+++ b/src/nwc-respond.js
@@ -85,15 +85,21 @@ export async function bgManager(rec, walletKey, saveFn, log = null) {
log(`ln-pay ${String(a.paymentHash || '').slice(0, 8)} → ${a.step}${a.error ? ': ' + a.error : ''}`);
}
}
+ // the worker's hand on the mirror, for an app alive behind it: it
+ // folds this state in before it next writes its own over it
+ rec.workerAt = Date.now();
Promise.resolve(saveFn(walletKey, rec)).catch(() => {});
},
};
- return new ArkManager({
+ const m = new ArkManager({
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();
+ });
+ // the chain tip a payment needs, asked for while the manager starts
+ try { m.warmTip().catch(() => {}); } catch { /* asked again when needed */ }
+ return m.init();
}
// ---- sends in flight across wakes ------------------------------------------
@@ -300,6 +306,8 @@ export async function respondFromBg(data, {
// ...and hands each pay request to the first device that claims it, so
// two awake devices don't each lock an HTLC for one zap. Unreachable →
// proceed: a lone worker must not go silent over a notifier hiccup.
+ // (the same call says when a reply already exists: one round trip where
+ // "answered?" and "mine?" used to be two, one after the other)
const claim = async () => {
try {
const r = await fetchFn(`${notifier}/claim`, {
@@ -307,9 +315,25 @@ export async function respondFromBg(data, {
body: JSON.stringify({ event: ev.id, by: `sw-${walletKey}`.slice(0, 64) }),
});
if (!r.ok) return true;
- return (await r.json()).ok !== false;
+ const j = await r.json();
+ if (j.answered) return 'answered';
+ return j.ok !== false;
} catch { return true; }
};
+ // a claim this worker can't make good on (the wallet's server unreachable)
+ // is handed back, so the app the user opens isn't told to stand down
+ const unclaim = () => {
+ try {
+ Promise.resolve(fetchFn(`${notifier}/claim`, {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ event: ev.id, by: `sw-${walletKey}`.slice(0, 64), release: true }),
+ })).catch(() => {});
+ } catch { /* it lapses by itself */ }
+ };
+ const lostClaim = (c) => {
+ log(c === 'answered' ? 'already answered elsewhere' : 'another device claimed this request — standing down');
+ return true;
+ };
if (method === 'get_info') {
await publish({
@@ -433,12 +457,21 @@ export async function respondFromBg(data, {
}
// ---- pay_invoice ------------------------------------------------------
- if (await answered()) { log('already answered elsewhere'); return true; }
-
+ // What the mirror alone can decide comes first, with no network at all;
+ // then the claim (which also says if someone already answered) goes out
+ // beside the manager's start. Each of these used to wait for the one
+ // before it: a zap answered from a closed phone spent about six seconds
+ // getting to the payment (398ja, 2026-11-10: "a lot more slower").
+ // A refusal never goes out over another device's answer.
+ const refuse = async (code, message) => {
+ if (await answered()) { log('already answered elsewhere'); return true; }
+ await publish(errRes('pay_invoice', code, message));
+ return true;
+ };
const invoice = params.invoice;
const dec = invoice && maybeBolt11(invoice);
- if (!dec) { await publish(errRes('pay_invoice', 'OTHER', 'not a bolt11 invoice')); return true; }
- if (!dec.amountSat) { await publish(errRes('pay_invoice', 'OTHER', 'zero-amount invoices are not supported')); return true; }
+ if (!dec) return refuse('OTHER', 'not a bolt11 invoice');
+ if (!dec.amountSat) return refuse('OTHER', 'zero-amount invoices are not supported');
// The bolt11 names its network; only a record on that network can pay it.
// A mismatched copy fails in milliseconds with a nonsense error that beats
// a sibling device's real payment to the client — so stand down instead,
@@ -451,17 +484,11 @@ export async function respondFromBg(data, {
return answered();
}
({ walletKey, rec, conn } = fit);
- if (dec.amountSat > conn.maxSat) {
- await publish(errRes('pay_invoice', 'QUOTA_EXCEEDED', `over the ${conn.maxSat} sat per-payment limit`));
- return true;
- }
+ if (dec.amountSat > conn.maxSat) return refuse('QUOTA_EXCEEDED', `over the ${conn.maxSat} sat per-payment limit`);
// What the app spent this window rides on the mirrored connection; the
// worker's own spends the app has not yet folded in are counted from its log.
const { left } = spentInWindow(conn, rec.spends || []);
- if (dec.amountSat > left) {
- await publish(errRes('pay_invoice', 'QUOTA_EXCEEDED', `over the remaining ${renewalPeriod(conn.period)} budget (${left} sat)`));
- return true;
- }
+ if (dec.amountSat > left) return refuse('QUOTA_EXCEEDED', `over the remaining ${renewalPeriod(conn.period)} budget (${left} sat)`);
// 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
@@ -511,7 +538,8 @@ export async function respondFromBg(data, {
return sentNotice(rec, a, c, dec.amountSat);
};
if (same) {
- if (!(await claim())) { log('another device claimed this request — standing down'); return true; }
+ const c = await claim();
+ if (c !== true) return lostClaim(c);
log(`a send for this invoice is already ${same.step} — finishing it`);
const wasDone = same.step === 'done';
let a = same;
@@ -530,6 +558,7 @@ export async function respondFromBg(data, {
// 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) {
+ if (await answered()) { log('already answered elsewhere'); return true; }
try {
await ready();
for (const a of stuck) {
@@ -558,12 +587,21 @@ export async function respondFromBg(data, {
// 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)]) 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; }
+ // the claim and the manager's start (begun above), side by side
+ const claimP = claim();
+ try { await ready(); } catch (e) {
+ const c = await claimP;
+ if (c !== true) return lostClaim(c);
+ unclaim();
+ return declineLater('ark init failed: ' + e.message);
+ }
+ const mine = await claimP;
+ if (mine !== true) return lostClaim(mine);
// whose send this is, for the wake that finishes it should this one not
notePending(rec, dec.paymentHash, conn);
- await saveFn(walletKey, rec);
+ // (not waited on: the manager's own save of the new send, a moment on,
+ // writes this same record)
+ Promise.resolve(saveFn(walletKey, rec)).catch(() => {});
log(`paying ${dec.amountSat} sat`);
let a = null;
for (let attempt = 0; ; attempt++) {