diff options
Diffstat (limited to 'src/nwc-respond.js')
| -rw-r--r-- | src/nwc-respond.js | 78 |
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++) { |
