diff options
| author | Adam Soltys <[email protected]> | 2026-11-10 07:35:30 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-11-10 07:35:34 -0700 |
| commit | 520aa7a1e39dbe9d8e0fd44b5cc3ce06e79f5abf (patch) | |
| tree | 46d1fcb8c9c13cf62cf3bb7decd35906e3e2749e /src | |
| parent | f6970a3be5eb1019675debe47b0eedd77892a933 (diff) | |
| download | coinosv3-520aa7a1e39dbe9d8e0fd44b5cc3ce06e79f5abf.tar.gz coinosv3-520aa7a1e39dbe9d8e0fd44b5cc3ce06e79f5abf.zip | |
A zap answered over NWC waits on far fewer round trips, and a phone with Coinos behind another app answers from its worker. "I'm on V3 … I find nwc zaps a lot more slower than usual" (398ja, 2026-11-10). Measured from the notifier's wakes and the ASP's log over four days: a closed app took about six seconds from the push to its cosign (the Lightning part after that is one and a half), an Android phone fifteen at the median, up to thirty. Where it went: every call to the ASP and the notifier was two round trips (no Access-Control-Max-Age, so Chrome re-asked the CORS preflight after five seconds; a Cloudflare response-header rule now sets 7200 on OPTIONS for ark.coinos.io, ark-staging and nwcpush), and the worker made them one after another: answered?, GetArkInfo, Handshake, claim, a save, the chain tip, then the payment, then status ("pending"), the tip again, the held status call, and status once more for the preimage it had just been handed. Now: what the mirror alone decides (invoice, network, budgets, coins, keys) comes first with no network; the claim (which now also says "already answered", coinosv3-server 3c5e9c3) goes out beside the manager's start, whose info call, handshake and tip run together (init, warmTip/_tipFresh); a send just initiated returns to its caller instead of asking at once, and awaitLnPay settles from the held call's own answer. A worker-style send on regtest: eight calls, six in sequence, where there were thirteen. The app's own answer no longer waits a second on a phone that is on screen (only one behind another app holds back for a computer).
Android freezes an app behind another one, and the worker's nudge to it sat unread until it was opened: on a phone the worker now hands a request only to a window on screen (as it already did on iOS), nudges a hidden one in case it is awake, and answers itself; the request's lock decides between them. For that the mirror is no longer written over the worker's hand: the worker stamps its writes (workerAt), the app writes under the worker's own lock (nwc-bg-work) and folds the worker's state in first (never mid-payment), carries the stamp, absorbs it when it comes back on screen (arkBgAbsorb), and refreshes the mirror as it goes behind another app.
Tests: ln-pay-double-wake (the one held call settles; a failed or mismatched answer doesn't), sw-push-quota (an Android phone's hidden window), nwc-sw-test's notifier stand-in answers /claim as the real one; tools/ark/ln-e2e-test's pay half against regtest captaind 0.8 + CLN
Co-Authored-By: Claude Fable 5.2 <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/ark/manager.js | 43 | ||||
| -rw-r--r-- | src/features/ark.js | 39 | ||||
| -rw-r--r-- | src/features/nwc.js | 14 | ||||
| -rw-r--r-- | src/nwc-respond.js | 78 | ||||
| -rw-r--r-- | src/sw-nwc.js | 11 |
5 files changed, 155 insertions, 30 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js index 0370a77..8aa8707 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -310,6 +310,9 @@ export class ArkManager { seen.add(m.vtxoId); return true; }); + // (the handshake goes out beside the info call, not after it: a wake of + // the phone's worker pays for every round trip in the zap it answers) + const hsP = handshake(this.arkUrl).catch(() => null); try { this.info = await getArkInfo(this.arkUrl); this.offline = false; @@ -323,7 +326,7 @@ export class ArkManager { this.info = this.infoHint; this.offline = true; } - const hs = await handshake(this.arkUrl).catch(() => null); + const hs = await hsP; this.psa = hs?.psa; this.serverPub = hex.decode(this.info.serverPubkey); @@ -387,6 +390,20 @@ export class ArkManager { return Date.now() - age * 600_000; } + // The tip for a payment about to be built: one asked for moments ago (a + // worker warms it while the manager starts) is as good as asking again. + warmTip() { + const p = this.chain.tipHeight().then((h) => { this._tipH = h; this._tipAt = Date.now(); this._tipNow = Date.now(); return h; }); + this._tipWarm = p; + p.catch(() => {}).finally(() => { if (this._tipWarm === p) this._tipWarm = null; }); + return p; + } + async _tipFresh() { + if (this._tipNow && Date.now() - this._tipNow < 5000 && this._tipH) return this._tipH; + if (this._tipWarm) { try { return await this._tipWarm; } catch { /* ask again */ } } + return this.chain.tipHeight(); + } + async _tipMemo() { if (this._tipAt && Date.now() - this._tipAt < 60_000) return this._tipH; try { @@ -1421,10 +1438,17 @@ export class ArkManager { async awaitLnPay(id, timeoutMs = 90_000) { const a = this.lnAction(id); if (!a || a.step !== 'initiated') return this.driveLn(id); - await Promise.race([ + const res = await Promise.race([ checkLightningPayment(this.arkUrl, hex.decode(a.paymentHash), true).catch(() => null), new Promise((r) => setTimeout(r, timeoutMs)), ]); + // the held call's own answer settles it: asking once more for the same + // preimage was a round trip between the payment and its reply + if (res && res.status === 'success' && res.preimage && a.step === 'initiated' + && hex.encode(sha256(hex.decode(res.preimage))) === a.paymentHash) { + this._settleLnPay(a, res.preimage); + return a; + } return this.driveLn(id); } @@ -1556,8 +1580,14 @@ export class ArkManager { // as paid, hours before the money moved) throw new Error(prior.step === 'done' ? 'this invoice was already paid' : 'a payment for this invoice is already in progress'); } - const tip = await this.chain.tipHeight(); - const routing = routingFeeSat != null ? routingFeeSat : await this.lnRouteFee(invoice, amountSat); + const [tip, routing] = await Promise.all([ + this._tipFresh(), + routingFeeSat != null ? routingFeeSat : this.lnRouteFee(invoice, amountSat), + ]); + // the two awaits above let a second call for the same invoice in + const twin = this.state.actions.find((a) => + a.type === 'ln-pay' && a.paymentHash === dec.paymentHash && a.step !== 'failed'); + if (twin) throw new Error(twin.step === 'done' ? 'this invoice was already paid' : 'a payment for this invoice is already in progress'); // smallest single vtxo that covers amount + routing + its expiry-dependent // fee — else gather largest-first: each input funds its own HTLC vtxo with // the same payment hash, and the server sums them into one payment. @@ -1721,6 +1751,11 @@ export class ArkManager { }); action.step = 'initiated'; this._save(); + // Just handed to the server: nothing to learn from asking this + // instant (it answered "pending", and the chain tip was fetched to + // go with it — two round trips before the caller's held call could + // even start). The caller waits on it (awaitLnPay) or polls. + return; } catch (e) { // the HTLC already went to this payment: another context (a second // wake of the phone's worker, seconds apart, over its own copy of the diff --git a/src/features/ark.js b/src/features/ark.js index f5ba9d8..075cf4b 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -1052,10 +1052,29 @@ export function arkFeature(ctx) { return v; }; } - async function writeBgMirror(connections, offer, prefs) { + // The worker can now pay while this app is alive behind another one (a + // phone with Coinos in the background: the page is frozen or throttled, + // the worker is not). Its change coin and its spent coins then exist only + // in the mirror — so the mirror is never written over the worker's hand: + // one writer at a time (the lock the worker holds for a whole push), and + // whatever the worker did since we last looked is folded in first. + let bgAbsorbedAt = 0; // the worker stamp (rec.workerAt) this app has folded in + const withBgLock = (fn) => (typeof navigator !== 'undefined' && navigator.locks + ? navigator.locks.request('nwc-bg-work', fn) : fn()); + const bgBusy = (mgr) => !!(mgr && mgr._lnDriving && mgr._lnDriving.size); + async function writeBgMirror(connections, offer, prefs) { return withBgLock(() => writeBgMirrorLocked(connections, offer, prefs)); } + async function writeBgMirrorLocked(connections, offer, prefs) { const cfg = getArkConfig(); if (!cfg || !ark || !ark.info || !ark.state) return; - const prev = await loadBg(wallet._cacheKey()); + let prev = await loadBg(wallet._cacheKey()); + if (prev && prev.v >= 3 && prev.mgr && (prev.workerAt || 0) > bgAbsorbedAt) { + // a payment of ours in flight holds the very records a merge replaces: + // leave the worker's state standing and write at the next call + if (bgBusy(ark)) return; + await mergeBgWorkerState(ark).catch((e) => console.warn('ark: worker merge failed', e && e.message)); + if ((prev.workerAt || 0) > bgAbsorbedAt) return; // not folded in: don't write over it + prev = await loadBg(wallet._cacheKey()); + } if (connections === undefined) connections = (prev && prev.connections) || []; if (offer === undefined) offer = (prev && prev.offer) || null; if (prefs === undefined) prefs = (prev && prev.prefs) || undefined; @@ -1068,7 +1087,7 @@ export function arkFeature(ctx) { offer, autowithdraw: await awMirror(prev), }); - if (prev) { rec.spends = prev.spends; rec.pendingPays = prev.pendingPays; } // the worker's log survives rewrites + if (prev) { rec.spends = prev.spends; rec.pendingPays = prev.pendingPays; if (prev.workerAt) rec.workerAt = prev.workerAt; } // the worker's log survives rewrites if (prefs) rec.prefs = prefs; // notification choices the worker honours await saveBg(wallet._cacheKey(), rec); await disarmSiblingRecords(wallet._cacheKey(), rec).catch(() => {}); @@ -1088,6 +1107,7 @@ export function arkFeature(ctx) { } if (!rec.mgr) return; bootPhase('ark-merge'); + const stamp = rec.workerAt || 0; const merged = mergeArkStates(mgr.state, rec.mgr); // the worker's view of OUR vtxos is newer for anything it spent const workerState = new Map((rec.mgr.vtxos || []).map((v) => [v.id, v.state])); @@ -1097,6 +1117,7 @@ export function arkFeature(ctx) { merged.nextKeyIndex = Math.max(merged.nextKeyIndex || 1, rec.mgr.nextKeyIndex || 1); mgr.state = merged; mgr._save(); + if (mgr === ark) bgAbsorbedAt = Math.max(bgAbsorbedAt, stamp); bootPhase('ark-merged'); await mgr.reconcile().catch(() => {}); bootPhase('ark-reconciled'); @@ -4166,6 +4187,18 @@ export function arkFeature(ctx) { // nwc.js calls this (with its connections) whenever the mirror should // refresh: on enable, on connection changes, after payments, on a timer. async arkBgWrite(connections, offer, prefs) { return writeBgMirror(connections, offer, prefs); }, + // Back on screen: what the worker paid while this app sat behind another + // one is folded in now, not at the next start. true when there was any. + async arkBgAbsorb() { + if (!ark || !ark.state) return false; + return withBgLock(async () => { + const rec = await loadBg(wallet._cacheKey()); + if (!rec || rec.v < 3 || !rec.mgr || (rec.workerAt || 0) <= bgAbsorbedAt || bgBusy(ark)) return false; + await mergeBgWorkerState(ark); + render(); + return true; + }); + }, async arkBgSpendableSat() { const rec = await loadBg(wallet._cacheKey()); return ((rec && rec.mgr && rec.mgr.vtxos) || []) diff --git a/src/features/nwc.js b/src/features/nwc.js index dc87388..9768740 100644 --- a/src/features/nwc.js +++ b/src/features/nwc.js @@ -388,7 +388,10 @@ export function nwcFeature(ctx) { return !!(prior && prior.length); }; if (age > 5 && await lookBack()) return dlog('nwc: skip — request already answered elsewhere', ev.id.slice(0, 8)); - if (mobile && age <= 5) await new Promise((r) => setTimeout(r, 1000)); + // (a phone with this app on screen is as awake as any computer: only + // one behind another app holds back — every zap answered by an open + // phone used to wait this second for nobody) + if (mobile && age <= 5 && typeof document !== 'undefined' && document.hidden) await new Promise((r) => setTimeout(r, 1000)); claimed = await claim(ev.id); if (claimed === false) return dlog('nwc: standing down — another device claimed', ev.id.slice(0, 8)); if (claimed === null && age <= 5) { @@ -981,7 +984,14 @@ export function nwcFeature(ctx) { if (typeof window !== 'undefined') { window.addEventListener('online', () => listen()); document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible' && conns().length) { listen(); refreshRegistration(); } + if (document.visibilityState === 'visible' && conns().length) { + listen(); refreshRegistration(); + // the worker may have paid while this app sat behind another one + Promise.resolve(hook('arkBgAbsorb')).then((had) => { if (had) return reconcileBg(); }).catch(() => {}); + } + // going behind another app: the worker answers from the mirror now, so + // it gets this moment's coins, not the last minute-and-a-half's + if (document.visibilityState === 'hidden' && conns().length) writeBg(); }); } } 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++) { diff --git a/src/sw-nwc.js b/src/sw-nwc.js index a4e4e56..b0ef49f 100644 --- a/src/sw-nwc.js +++ b/src/sw-nwc.js @@ -187,14 +187,23 @@ self.addEventListener('push', (e) => { // An open window handles requests itself with full wallet state — nudge // it and stay out of the way. On iOS only one on screen counts: a // backgrounded app is suspended, so a nudge to it went unanswered. + // Android does the same to an app behind another one, minutes in: the + // page is frozen, its relay sockets dead, and the nudge sat unread until + // the app was opened — the zap spun for as long (wake to payment: 15 s + // at the median on Android phones against 6 for a closed app, 2026-11-10). + // So on any phone only a window on screen is handed the request; one + // behind another app is nudged too, in case it is still awake, and the + // request's lock decides between it and this worker. + const phone = apple || /Mobi|Android/i.test((self.navigator && self.navigator.userAgent) || ''); const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); - const clients = all.filter((c) => !apple || c.visibilityState === 'visible'); + const clients = all.filter((c) => !phone || c.visibilityState === 'visible'); if (clients.length) { for (const c of clients) c.postMessage({ type: 'nwc-wake', servicePubkey: data.servicePubkey }); if (apple) await dropStandIn(); // the window on screen shows it live diary('window on screen — handed over'); return; } + if (!apple) for (const c of all) { try { c.postMessage({ type: 'nwc-wake', servicePubkey: data.servicePubkey }); } catch (_) {} } const notice = (n) => show(n.title, { body: n.body, icon: 'icon-192.png', badge: 'badge-96.png', tag: n.tag || 'nwc-incoming', renotify: !apple, data: { url: './' }, |
