aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-24 07:54:32 -0700
committerAdam Soltys <[email protected]>2026-10-24 07:54:32 -0700
commiteec7abcfa329b6b405e69ffff54ae0a312f66860 (patch)
tree69e563bb6bd61ed0a53406f632281778c5d2f823 /tools
parentce96364662451556e9ae6fe8222eba4ea23bd27c (diff)
downloadcoinosv3-eec7abcfa329b6b405e69ffff54ae0a312f66860.tar.gz
coinosv3-eec7abcfa329b6b405e69ffff54ae0a312f66860.zip
A closed wallet finishes the Lightning sends an earlier wake left in flight, instead of calling the invoice "already paid" with no preimage and locking its coin until the app opens
PullCap's night of 2026-10-23 (hourly 1-sat NWC requests to a closed iPhone PWA): three of fifteen hours went wrong the same way. The push woke the worker, it reserved the coin and wrote the ln-pay action to the mirror, and the phone stopped it before the ASP was ever asked (no cosign in captaind's log for those minutes). The client's retry ten minutes later hit the manager's duplicate check, "this invoice was already paid or is being paid", published as an INTERNAL error that the client read as paid — hours before the money moved. And since the stuck action held the wallet's only coin, the next hour's request (a different invoice) found nothing spendable and fell to "Open Coinos to approve". Everything settled at once when the app opened at 07:12 and resumed the actions. - nwc-respond.js: pay_invoice first looks for a send already on the books for this invoice — finishing one in flight and answering with its preimage, repeating the preimage of one already done (nothing new on the spend log) — and before paying a new invoice drives any other stuck send to completion so its coin comes back. tendLnPays() does the same on every wake, lookups included, and hands back "Payment sent" lines for what settled. rec.pendingPays remembers which connection a send belongs to (the action is the manager's); the app's mirror rewrite carries it. - manager.js: the duplicate check says "already paid" only for a settled send, "already in progress" for one on its way. - features/nwc.js: the open app answers a repeated invoice from the local action (preimage when settled, an honest "still in progress" otherwise) before asking the ASP about a sibling device. - sw-nwc.js: one push at a time over the shared mirror (Web Lock), so a client's lookup + retry seconds apart can't double-drive one send. A diary of what the worker did — each push, each ln-pay step as the mirror saves it, every notification shown or refused, the fallback taken — is appended line by line to IndexedDB (nwc-bg.js appendBgLog), so a worker the phone stops mid-push leaves its last step behind. Settings → Notifications → "Background activity log" copies it. - tests: nwc-sw-test covers the four resumed-send cases; sw-push-quota mocks the new seams and its "suspended app" case now expects the stand-in-first order shipped in 91dbee4 (it had been failing since). Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'tools')
-rw-r--r--tools/nwc-sw-test.js84
-rw-r--r--tools/sw-push-quota.test.js16
2 files changed, 96 insertions, 4 deletions
diff --git a/tools/nwc-sw-test.js b/tools/nwc-sw-test.js
index 1ce9944..816b9ce 100644
--- a/tools/nwc-sw-test.js
+++ b/tools/nwc-sw-test.js
@@ -5,7 +5,8 @@ import { hex } from '@scure/base';
import * as nip04 from 'nostr-tools/nip04';
import * as nip44 from 'nostr-tools/nip44';
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
-import { respondFromBg } from '../src/nwc-respond.js';
+import { respondFromBg, tendLnPays } from '../src/nwc-respond.js';
+import { maybeBolt11 } from '../src/ark/lightning.js';
let fails = 0;
const check = (name, ok, detail = '') => {
@@ -203,6 +204,87 @@ console.log('\n[pay outcome]');
}
}
+console.log('\n[sends begun on an earlier wake]');
+// The worker died mid-payment on an earlier wake: the action sits in the
+// mirror at 'created' with its coin reserved (PullCap, 2026-10-23).
+{
+ const { ArkManager } = await import('../src/ark/manager.js');
+ const HASH = maybeBolt11(INV21).paymentHash;
+ const PRE = 'cd'.repeat(32);
+ const names = ['init', 'payLnInvoice', 'awaitLnPay', 'lnAction', 'driveLn'];
+ const orig = Object.fromEntries(names.map((k) => [k, ArkManager.prototype[k]]));
+ ArkManager.prototype.init = async function () { this.state = this.storage.load(); return this; };
+ // a fresh attempt at an invoice with an action on the books is refused…
+ ArkManager.prototype.payLnInvoice = async function (invoice) {
+ const hash = maybeBolt11(invoice).paymentHash;
+ if (this.state.actions.some((a) => a.type === 'ln-pay' && a.paymentHash === hash && a.step !== 'failed')) throw new Error('a payment for this invoice is already in progress');
+ const a = { id: 'lnpay-new', type: 'ln-pay', step: 'done', invoice, paymentHash: hash, amountSat: 21, feeSat: 0, preimage: 'ab'.repeat(32) };
+ this.state.actions.push(a); this.storage.save(this.state);
+ return a.id;
+ };
+ // …and a drive takes the stuck one through to done, freeing its change
+ ArkManager.prototype.awaitLnPay = async function (id) { return this.driveLn(id); };
+ ArkManager.prototype.driveLn = async function (id) {
+ const a = this.state.actions.find((x) => x.id === id);
+ if (a && a.step !== 'done') {
+ a.step = 'done'; a.preimage = PRE;
+ for (const v of this.state.vtxos) if (v.state === 'pending') v.state = 'spendable';
+ this.storage.save(this.state);
+ }
+ return a;
+ };
+ ArkManager.prototype.lnAction = function (id) { return this.state.actions.find((x) => x.id === id); };
+ const stuckRec = (hash) => {
+ const rec = baseRec();
+ rec.mgr.vtxos[0].state = 'pending';
+ rec.mgr.actions.push({ id: 'lnpay-stuck', type: 'ln-pay', step: 'created', invoice: INV21, paymentHash: hash, amountSat: 21, feeSat: 0 });
+ rec.pendingPays = [{ hash, connId: 'c1', ts: 1 }];
+ return rec;
+ };
+ try {
+ {
+ // the SAME invoice asked again: finish that send, answer with ITS preimage
+ const rec = stuckRec(HASH);
+ const log = [];
+ const { deps, published, decryptReply } = harness({ rec });
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, { ...deps, log: (m) => log.push(m) });
+ check('same invoice → the stuck send is finished and answered with its preimage', published.length === 1 && decryptReply(published[0]).result?.preimage === PRE, JSON.stringify(handled));
+ check('…recorded once on the spend log, under its connection', rec.spends.length === 1 && rec.spends[0].connId === 'c1' && rec.spends[0].preimage === PRE && rec.pendingPays.length === 0, JSON.stringify(rec.spends));
+ check('…with a "Payment sent" heads-up', handled && handled.notify && handled.notify.title === 'Payment sent');
+ check('…and the diary names the step it reached', log.some((m) => /already created/.test(m)) && log.some((m) => /→ done/.test(m)), log.join(' | '));
+ }
+ {
+ // a send that settled earlier (in the app, say): its preimage again,
+ // nothing new on the spend log
+ const rec = baseRec();
+ rec.mgr.actions.push({ id: 'lnpay-old', type: 'ln-pay', step: 'done', invoice: INV21, paymentHash: HASH, amountSat: 21, feeSat: 0, preimage: PRE });
+ const { deps, published, decryptReply } = harness({ rec });
+ await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
+ check('an invoice paid earlier answers with the same preimage', published.length === 1 && decryptReply(published[0]).result?.preimage === PRE);
+ check('…without a second spend', (rec.spends || []).length === 0);
+ }
+ {
+ // a stuck send for ANOTHER invoice holds the only coin: finish it first,
+ // then pay the new request from the freed coin
+ const rec = stuckRec('ee'.repeat(32));
+ const { deps, published, decryptReply } = harness({ rec });
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
+ check('another invoice → the earlier send is finished, then this one paid', published.length === 1 && decryptReply(published[0]).result?.preimage === 'ab'.repeat(32), JSON.stringify(handled));
+ check('…both on the spend log', rec.spends.length === 2 && rec.spends[0].preimage === PRE && rec.spends[1].preimage === 'ab'.repeat(32));
+ check('…the earlier one gets its own "Payment sent" line', handled && handled.notices && handled.notices.length === 1 && handled.notify && handled.notify.title === 'Payment sent', JSON.stringify(handled));
+ }
+ {
+ // any wake at all finishes it (a lookup, a push for something else)
+ const rec = stuckRec(HASH);
+ const notices = await tendLnPays({ recordsFn: async () => [{ walletKey: 'w1', rec }], saveFn: async () => {} });
+ check('tendLnPays finishes a stuck send and reports it', notices.length === 1 && notices[0].title === 'Payment sent' && rec.spends.length === 1 && rec.spends[0].connId === 'c1', JSON.stringify(notices));
+ check('…and a second wake has nothing left to do', (await tendLnPays({ recordsFn: async () => [{ walletKey: 'w1', rec }], saveFn: async () => {} })).length === 0);
+ }
+ } finally {
+ Object.assign(ArkManager.prototype, orig);
+ }
+}
+
console.log('\n[network fit]');
{
// a mutinynet-era mirror must not answer a mainnet invoice with a nonsense
diff --git a/tools/sw-push-quota.test.js b/tools/sw-push-quota.test.js
index e7d00f2..049dc1f 100644
--- a/tools/sw-push-quota.test.js
+++ b/tools/sw-push-quota.test.js
@@ -4,8 +4,9 @@
import { test, expect, mock } from 'bun:test';
let respond = async () => true;
-mock.module('../src/nwc-respond.js', () => ({ respondFromBg: (...a) => respond(...a), tendRenewals: async () => {} }));
-mock.module('../src/nwc-lock.js', () => ({ withRequestLock: async (_id, fn) => fn() }));
+mock.module('../src/nwc-respond.js', () => ({ respondFromBg: (...a) => respond(...a), tendRenewals: async () => {}, tendLnPays: async () => [] }));
+mock.module('../src/nwc-lock.js', () => ({ withRequestLock: async (_id, fn) => fn(), withWorkLock: async (fn) => fn() }));
+mock.module('../src/nwc-bg.js', () => ({ appendBgLog: async () => {} }));
mock.module('../src/bg-autowithdraw.js', () => ({ bgAutoWithdraw: async () => {} }));
mock.module('../src/dm-inbox.js', () => ({
allInboxes: async () => [{ muted: ['m'.repeat(64)] }],
@@ -50,6 +51,13 @@ test('apple: every quiet path shows the stand-in', async () => {
expect(closed.length).toBe(0);
});
+test('apple: a send finished from an earlier wake shows its own line and drops the stand-in', async () => {
+ endpoint = 'https://web.push.apple.com/abc'; windows = [];
+ respond = async () => ({ notices: [{ title: 'Payment sent', body: '-21 sats via a connected app', tag: 'nwc-sent' }] });
+ expect(await push(nwc)).toEqual(['Coinos', 'Payment sent']);
+ expect(closed.length).toBe(1);
+});
+
test('apple: an app on screen gets the stand-in closed at once', async () => {
windows = [visible];
expect(await push({ type: 'notify', reason: 'payment', amountSat: 5 })).toEqual(['Coinos']);
@@ -60,7 +68,9 @@ test('apple: a suspended background app does not swallow the request', async ()
windows = [hidden];
let asked = 0;
respond = async () => { asked++; return { notify: { title: 'Payment sent', body: '' } }; };
- expect(await push(nwc)).toEqual(['Payment sent']);
+ // the stand-in goes up first and the outcome replaces it (91dbee4)
+ expect(await push(nwc)).toEqual(['Coinos', 'Payment sent']);
+ expect(closed.length).toBe(1);
expect(asked).toBe(1);
});