From 520aa7a1e39dbe9d8e0fd44b5cc3ce06e79f5abf Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 10 Nov 2026 07:35:30 -0700 Subject: 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 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.2 --- tools/ln-pay-double-wake.test.js | 39 ++++++++++++++++++++++++++++++++++++++- tools/nwc-sw-test.js | 5 ++++- tools/sw-push-quota.test.js | 27 ++++++++++++++++++++++++++- 3 files changed, 68 insertions(+), 3 deletions(-) (limited to 'tools') 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; } +}); -- cgit v1.2.3