diff options
| author | Adam Soltys <[email protected]> | 2026-10-25 07:46:55 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-25 07:46:55 -0700 |
| commit | 43a35d0fe6abc56b6594015ae1028842c04a2c19 (patch) | |
| tree | be56c2bb8f71826df0360b0842ee1bd7fa4b3a3b /src/nwc-respond.js | |
| parent | 9270bc52a33ef007d57d15b5e6aee95248c368a2 (diff) | |
| download | coinosv3-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.js | 87 |
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); |
