aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-11-10 07:35:30 -0700
committerAdam Soltys <[email protected]>2026-11-10 07:35:34 -0700
commit520aa7a1e39dbe9d8e0fd44b5cc3ce06e79f5abf (patch)
tree46d1fcb8c9c13cf62cf3bb7decd35906e3e2749e
parentf6970a3be5eb1019675debe47b0eedd77892a933 (diff)
downloadcoinosv3-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]>
-rw-r--r--src/ark/manager.js43
-rw-r--r--src/features/ark.js39
-rw-r--r--src/features/nwc.js14
-rw-r--r--src/nwc-respond.js78
-rw-r--r--src/sw-nwc.js11
-rw-r--r--tools/ln-pay-double-wake.test.js39
-rw-r--r--tools/nwc-sw-test.js5
-rw-r--r--tools/sw-push-quota.test.js27
8 files changed, 223 insertions, 33 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: './' },
diff --git a/tools/ln-pay-double-wake.test.js b/tools/ln-pay-double-wake.test.js
index 18a1943..c29d625 100644
--- a/tools/ln-pay-double-wake.test.js
+++ b/tools/ln-pay-double-wake.test.js
@@ -18,7 +18,7 @@ const asp = { initiate: null, check: null, revoked: 0, checks: 0 };
mock.module('../src/ark/lightning.js', () => ({
...realLn,
initiateLightningPayment: async () => { if (asp.initiate) throw asp.initiate; },
- checkLightningPayment: async () => { asp.checks++; if (asp.check instanceof Error) throw asp.check; return asp.check; },
+ checkLightningPayment: async (_u, _h, wait) => { asp.checks++; (asp.waits = asp.waits || []).push(!!wait); if (asp.check instanceof Error) throw asp.check; return asp.check; },
requestLightningPayHtlcRevocation: async () => { asp.revoked++; throw new Error('revocation should not be asked for'); },
}));
mock.module('../src/ark/proto.js', () => ({ ...realProto, decodeVtxo: () => ({ id: 'htlc1', point: { raw: new Uint8Array(36) } }) }));
@@ -72,3 +72,40 @@ test("a send the ASP never saw (not found) is still revoked", async () => {
await m._driveLnPay(action).catch(() => {});
expect(asp.revoked).toBe(1);
});
+
+// A zap's wait is made of round trips (398ja, 2026-11-10: NWC zaps "a lot more
+// slower"). A send just handed to the ASP used to ask its status at once
+// ("pending"), fetch the chain tip to go with that, hold a call until it
+// settled, then ask once more for the preimage it had just been given: four
+// round trips where the one held call does.
+test('a fresh send: initiate, then the one held call settles it', async () => {
+ Object.assign(asp, { initiate: null, check: { status: 'success', preimage: PRE }, revoked: 0, checks: 0, waits: [] });
+ const { m, action } = mgrWith('cosigned');
+ let tips = 0;
+ m.chain = { tipHeight: async () => { tips++; return 900; } };
+ await m._driveLnPay(action);
+ expect(action.step).toBe('initiated'); // handed over, nothing asked yet
+ expect(asp.checks).toBe(0);
+ expect(tips).toBe(0);
+ const a = await m.awaitLnPay(action.id, 5000);
+ expect(a.step).toBe('done');
+ expect(a.preimage).toBe(PRE);
+ expect(asp.waits).toEqual([true]); // the held call alone
+ expect(m.state.movements.filter((x) => x.type === 'ln-send' && x.status === 'complete').length).toBe(1);
+});
+
+test('a held call that comes back failed is checked the ordinary way (and revoked)', async () => {
+ Object.assign(asp, { initiate: null, check: { status: 'failed' }, revoked: 0, checks: 0, waits: [] });
+ const { m, action } = mgrWith('initiated');
+ m._revokeLnPay = async (x) => { x.step = 'failed'; };
+ const a = await m.awaitLnPay(action.id, 5000);
+ expect(asp.waits[0]).toBe(true);
+ expect(a.step).toBe('failed');
+});
+
+test('a held call with a preimage that does not match the hash settles nothing', async () => {
+ Object.assign(asp, { initiate: null, check: { status: 'success', preimage: 'cd'.repeat(32) }, revoked: 0, checks: 0, waits: [] });
+ const { m, action } = mgrWith('initiated');
+ const a = await m.awaitLnPay(action.id, 5000);
+ expect(a.step).not.toBe('done');
+});
diff --git a/tools/nwc-sw-test.js b/tools/nwc-sw-test.js
index 7e85ee1..18528a2 100644
--- a/tools/nwc-sw-test.js
+++ b/tools/nwc-sw-test.js
@@ -58,7 +58,10 @@ function harness({ rec = baseRec(), recs = null, answered = false } = {}) {
return { ok: true, json: async () => ({ ok: true, scheduled: true }) };
}
if (url.includes('/claim')) {
- return { ok: true, json: async () => ({ ok: true }) };
+ // as the notifier does: a request already replied to can't be claimed
+ const was = typeof answered === 'function' ? answered() : answered;
+ if (JSON.parse(opts.body).release) return { ok: true, json: async () => ({ ok: true, released: true }) };
+ return { ok: true, json: async () => (was ? { ok: false, answered: true } : { ok: true }) };
}
if (url.includes('/answered')) {
return { ok: true, json: async () => ({ answered: typeof answered === 'function' ? answered() : answered }) };
diff --git a/tools/sw-push-quota.test.js b/tools/sw-push-quota.test.js
index 544c72e..798b913 100644
--- a/tools/sw-push-quota.test.js
+++ b/tools/sw-push-quota.test.js
@@ -82,8 +82,33 @@ test('elsewhere: quiet stays quiet', async () => {
let asked = 0;
respond = async () => { asked++; return true; };
expect(await push(nwc)).toEqual([]);
- expect(asked).toBe(0); // the open (if hidden) window answers on Android
+ expect(asked).toBe(0); // on a computer the open (if hidden) window answers
windows = [];
respond = async () => false;
expect(await push(nwc)).toEqual(['Spend request']);
});
+
+// An Android phone freezes an app behind another one, minutes in: its relay
+// sockets are dead and the worker's nudge sits unread until it is opened, so
+// a zap sent from another app spun until then (wake to payment: 15 s at the
+// median on Android, 2026-11-10). On a phone only a window on screen is
+// handed the request; behind another app, the worker answers (and nudges the
+// window anyway — the request's lock decides if it is still awake).
+test('an Android phone: the app behind another one does not swallow the request', async () => {
+ endpoint = 'https://fcm.googleapis.com/fcm/send/abc';
+ self.navigator = { userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Mobile Safari/537.36' };
+ try {
+ let asked = 0, nudged = 0;
+ respond = async () => { asked++; return true; };
+ windows = [{ visibilityState: 'hidden', postMessage: () => { nudged++; } }];
+ expect(await push(nwc)).toEqual([]);
+ expect(asked).toBe(1);
+ expect(nudged).toBe(1);
+ // on screen, it answers itself with everything it knows
+ asked = 0; nudged = 0;
+ windows = [{ visibilityState: 'visible', postMessage: () => { nudged++; } }];
+ expect(await push(nwc)).toEqual([]);
+ expect(asked).toBe(0);
+ expect(nudged).toBe(1);
+ } finally { delete self.navigator; }
+});