From 43a35d0fe6abc56b6594015ae1028842c04a2c19 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 25 Oct 2026 07:46:55 -0700 Subject: 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 Claude-Session: https://claude.ai/code/session_01QjVvE4YAcK3AGwV7kZXeN4 --- src/ark/manager.js | 26 +++++++++++- src/nwc-bg.js | 32 +++++++++++++++ src/nwc-lock.js | 13 ++++-- src/nwc-respond.js | 87 +++++++++++++++++++++++++++++----------- src/sw-nwc.js | 19 ++++++--- tools/ln-pay-double-wake.test.js | 74 ++++++++++++++++++++++++++++++++++ tools/nwc-sw-test.js | 18 +++++++++ tools/sw-push-quota.test.js | 2 +- 8 files changed, 236 insertions(+), 35 deletions(-) create mode 100644 tools/ln-pay-double-wake.test.js diff --git a/src/ark/manager.js b/src/ark/manager.js index a1c1390..810808c 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -1682,9 +1682,16 @@ export class ArkManager { action.step = 'initiated'; this._save(); } 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 + // state) initiated it first. That payment is this one — follow it. + // (PullCap 2026-10-24 23:21: a paid send was marked 'revoking' here.) + if (e instanceof GrpcError && /state: ln-spent/i.test(e.message)) { + action.step = 'initiated'; + this._save(); // INVALID_ARGUMENT: the server itself deems the invoice unpayable — // retrying can never succeed, revoke the HTLC instead - if (e instanceof GrpcError && e.grpcStatus === 3) { + } else if (e instanceof GrpcError && e.grpcStatus === 3) { action.error = e.message; action.step = 'revoking'; this._save(); @@ -1711,7 +1718,22 @@ export class ArkManager { this._save(); } } - if (action.step === 'revoking') await this._revokeLnPay(action); + if (action.step === 'revoking') { + // Ask first: a send can land in 'revoking' over a stale copy of the + // state while the payment went through (or is still going) — a + // settled payment is settled here too, and one in flight waits. + const res = await checkLightningPayment(this.arkUrl, hex.decode(action.paymentHash), false).catch(() => null); + if (res && res.status === 'success' && res.preimage + && hex.encode(sha256(hex.decode(res.preimage))) === action.paymentHash) { + this._settleLnPay(action, res.preimage); + return; + } + if (res && res.status === 'pending' && action.htlcExpiry) { + const tip = await this.chain.tipHeight().catch(() => 0); + if (tip && tip <= action.htlcExpiry) { action.step = 'initiated'; delete action.error; this._save(); return; } + } + await this._revokeLnPay(action); + } } _settleLnPay(action, preimageHex) { diff --git a/src/nwc-bg.js b/src/nwc-bg.js index 2c73206..ab083d9 100644 --- a/src/nwc-bg.js +++ b/src/nwc-bg.js @@ -81,6 +81,38 @@ export async function readBgLog() { export async function clearBgLog() { return tx('readwrite', (s) => { s.delete(LOG_KEY); }); } +// A lease on the worker's wallet work, kept in this store. Two pushes seconds +// apart ran side by side on iOS, each in its own worker (two "worker started" +// lines), and nothing kept them apart: both finished one send, and the +// second, over a stale copy, marked a paid send 'revoking'. The store is +// the one thing both see, and a readwrite transaction is atomic across them. +// A lease outlives a stopped worker only for its ttl. +export async function takeLease(name, { ttl = 12_000, wait = 5_000 } = {}) { + const key = '__lease:' + name; + const me = Math.random().toString(36).slice(2); + const start = Date.now(); + for (;;) { + let got = false; + try { + got = await tx('readwrite', async (s) => { + const cur = await get(s, key); + if (cur && cur.until > Date.now() && cur.owner !== me) return false; + s.put({ owner: me, until: Date.now() + ttl }, key); + return true; + }); + } catch { got = true; } // no store, no lease: run + if (got) break; + if (Date.now() - start > wait) break; // a holder that overstays: go anyway + await new Promise((r) => setTimeout(r, 250)); + } + const waited = Date.now() - start; + const release = () => tx('readwrite', async (s) => { + const cur = await get(s, key); + if (cur && cur.owner === me) s.delete(key); + }).catch(() => {}); + return { waited, release }; +} + // The service worker doesn't know wallet cache keys — it finds the right // record by the connection's service pubkey across all of them. export async function allBgs() { diff --git a/src/nwc-lock.js b/src/nwc-lock.js index a7b10b4..bd8586a 100644 --- a/src/nwc-lock.js +++ b/src/nwc-lock.js @@ -12,10 +12,17 @@ // same send at once would sign it twice (a client's retry lands seconds // after its lookup). A waiting push keeps its waitUntil and reads the mirror // fresh once it gets the lock. -export async function withWorkLock(fn) { +// The Web Lock alone didn't hold on iOS (two workers at once, neither +// waited), so the lease in the worker's store (nwc-bg.js) is taken inside it. +export async function withWorkLock(fn, { lease = null } = {}) { const locks = typeof navigator !== 'undefined' && navigator.locks; - if (!locks) return fn(); - return locks.request('nwc-bg-work', fn); + const run = async () => { + if (!lease) return fn(0); + const l = await lease(); + try { return await fn(l.waited); } finally { await l.release(); } + }; + if (!locks) return run(); + return locks.request('nwc-bg-work', run); } export async function withRequestLock(id, fn, held = () => undefined) { 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); diff --git a/src/sw-nwc.js b/src/sw-nwc.js index 78bb2bd..3a48cff 100644 --- a/src/sw-nwc.js +++ b/src/sw-nwc.js @@ -6,7 +6,7 @@ import { respondFromBg, tendRenewals, tendLnPays } from './nwc-respond.js'; import { withRequestLock, withWorkLock } from './nwc-lock.js'; -import { appendBgLog } from './nwc-bg.js'; +import { appendBgLog, takeLease } from './nwc-bg.js'; import { bgAutoWithdraw } from './bg-autowithdraw.js'; import { allInboxes, classifyDm, shouldNotifyDm, classifyCall } from './dm-inbox.js'; @@ -15,7 +15,7 @@ const NOTIFIER = 'https://nwcpush.coinos.io'; // written, so a worker the phone stops mid-push leaves its last step behind. const stamp = () => new Date().toISOString().replace('T', ' ').slice(0, 19); const diary = (m) => { console.log('[sw-nwc]', m); appendBgLog(`${stamp()} ${m}`); }; -diary('worker started'); +diary(`worker started${typeof navigator !== 'undefined' && navigator.locks ? '' : ' (no Web Locks)'}`); const CALL_CACHE = 'coinos-call'; // a call offer a push brought, for the app the tap opens // where a call's offer wrap is fetched from: the notifier, or (the app served // on this machine, as the tests do) this origin @@ -179,6 +179,11 @@ self.addEventListener('push', (e) => { // four payments, no line for the fourth, then no more wakes). On an // Apple endpoint the stand-in goes up FIRST; the outcome replaces it. const apple = await applePush(); + // What a push may take. iOS stops the worker about ten seconds in (PullCap's + // diary of 2026-10-24: every wake's last line 8–12 s after its push, in the + // middle of a payment), whatever the waitUntil — so there everything is + // planned inside nine; elsewhere the usual thirty. + const budgetEnd = startedAt + (apple ? 9_000 : 27_000); const dropStandIn = async () => { const open = await self.registration.getNotifications({ tag: 'coinos-bg' }).catch(() => []); for (const n of open) n.close(); @@ -204,10 +209,10 @@ self.addEventListener('push', (e) => { // has run, cover coins without one. After the answer, inside what is // left of the push budget. const tend = async () => { - const late = await tendLnPays({ deadline: startedAt + 27_000, log: diary }).catch((err) => { diary('earlier sends failed: ' + (err && err.message)); return []; }); + const late = await tendLnPays({ deadline: budgetEnd, log: diary }).catch((err) => { diary('earlier sends failed: ' + (err && err.message)); return []; }); for (const n of late) await notice(n); if (late.length && apple) await dropStandIn(); - await tendRenewals({ deadline: startedAt + 27_000, log: (m) => diary('renew: ' + m) }) + await tendRenewals({ deadline: budgetEnd, log: (m) => diary('renew: ' + m) }) .catch((err) => diary('renewals failed: ' + (err && err.message))); }; // an NWC wake-up may also have moved money in; check the rule after @@ -217,10 +222,11 @@ self.addEventListener('push', (e) => { await withWorkLock(async () => { const waited = Date.now() - startedAt; if (waited > 500) diary(`waited ${waited} ms for an earlier push to finish`); + if (Date.now() > budgetEnd - 2_000) { diary('no time left after waiting — leaving it to the next try'); return; } let handled = false; try { handled = await withRequestLock(data.event && data.event.id, - () => respondFromBg(data, { notifier: NOTIFIER, log: diary }), + () => respondFromBg(data, { notifier: NOTIFIER, log: diary, deadline: budgetEnd }), () => { diary('another context holds this request'); return true; }); } catch (err) { diary('auto-answer failed: ' + (err && err.message)); @@ -243,6 +249,7 @@ self.addEventListener('push', (e) => { await tend(); return; } + if (Date.now() > budgetEnd) { diary('out of time — leaving it to the next try'); return; } // A kind-21001 offer request is money coming IN (someone asking us to // mint an invoice) — "an app wants to pay" would read as the opposite. const incoming = data.event && data.event.kind === 21001; @@ -257,7 +264,7 @@ self.addEventListener('push', (e) => { }); if (apple) await dropStandIn(); await tend(); - }); + }, { lease: () => takeLease('work', { wait: Math.max(0, budgetEnd - Date.now() - 3_000) }) }); diary(`push done in ${Date.now() - startedAt} ms`); }); }); diff --git a/tools/ln-pay-double-wake.test.js b/tools/ln-pay-double-wake.test.js new file mode 100644 index 0000000..18a1943 --- /dev/null +++ b/tools/ln-pay-double-wake.test.js @@ -0,0 +1,74 @@ +// Two wakes of the phone's worker, seconds apart, each over its own copy of +// the wallet, both drive one Lightning send (PullCap, 2026-10-24 23:21). The +// second's InitiateLightningPayment is refused — the HTLC is already 'ln-spent' +// to this very payment — and that once marked a PAID send 'revoking'. Now the +// send follows the payment instead, and a send in 'revoking' asks the ASP +// before revoking: paid → settled; still in flight → back to waiting. +// Run: bun test tools/ln-pay-double-wake.test.js +import { test, expect, mock } from 'bun:test'; +import { hex } from '@scure/base'; +import { sha256 } from '@noble/hashes/sha256'; +import * as realLn from '../src/ark/lightning.js'; +import * as realProto from '../src/ark/proto.js'; +import * as realSend from '../src/ark/send.js'; + +const PRE = 'ab'.repeat(32); +const HASH = hex.encode(sha256(hex.decode(PRE))); +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; }, + 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) } }) })); +mock.module('../src/ark/send.js', () => ({ ...realSend, registerVtxoTransactions: async () => {}, + buildArkoorSend: () => ({}), genUserNonces: () => ({}), cosignPartBytes: () => new Uint8Array(1) })); +const { ArkManager } = await import('../src/ark/manager.js'); +const { GrpcError } = realProto; + +function mgrWith(step) { + const action = { id: 'lnpay-1700000000000', type: 'ln-pay', step, paymentHash: HASH, amountSat: 1, htlcExpiry: 1000, htlcBytesList: ['00'], htlcVtxoIds: ['htlc1'], changeBytesList: [], invoice: 'lnbc1' }; + const m = Object.create(ArkManager.prototype); + const own = { + arkUrl: 'http://ark.test', chain: { tipHeight: async () => 900 }, + state: { actions: [action], vtxos: [{ id: 'htlc1', state: 'pending', amountSat: 1 }], movements: [] }, + _save: () => {}, _lnDriving: new Set(), _spentRetried: new Set(), + _key: () => ({ pubkey: new Uint8Array(33) }), _mailboxKey: () => ({ pubkey: new Uint8Array(33) }), + }; + for (const [k, v] of Object.entries(own)) Object.defineProperty(m, k, { value: v, writable: true, configurable: true }); + return { m, action }; +} + +test("a second wake's initiate refused as 'ln-spent' follows the payment to done", async () => { + Object.assign(asp, { initiate: new GrpcError(3, 'bad user input: vtxo b47f…:0 is not spendable (state: ln-spent)', 'bark_server.ArkService/InitiateLightningPayment'), check: { status: 'success', preimage: PRE }, revoked: 0 }); + const { m, action } = mgrWith('cosigned'); + await m._driveLnPay(action); + expect(action.step).toBe('done'); + expect(action.preimage).toBe(PRE); + expect(asp.revoked).toBe(0); + expect(m.state.movements.filter((x) => x.type === 'ln-send' && x.status === 'complete').length).toBe(1); +}); + +test("a send in 'revoking' that the ASP paid settles, nothing revoked", async () => { + Object.assign(asp, { initiate: null, check: { status: 'success', preimage: PRE }, revoked: 0 }); + const { m, action } = mgrWith('revoking'); + await m._driveLnPay(action); + expect(action.step).toBe('done'); + expect(asp.revoked).toBe(0); +}); + +test("a send in 'revoking' still in flight before its expiry goes back to waiting", async () => { + Object.assign(asp, { initiate: null, check: { status: 'pending' }, revoked: 0 }); + const { m, action } = mgrWith('revoking'); + await m._driveLnPay(action); + expect(action.step).toBe('initiated'); + expect(asp.revoked).toBe(0); +}); + +test("a send the ASP never saw (not found) is still revoked", async () => { + Object.assign(asp, { initiate: null, check: new GrpcError(5, 'not found: payment attempt not found', 'x'), revoked: 0 }); + const { m, action } = mgrWith('revoking'); + await m._driveLnPay(action).catch(() => {}); + expect(asp.revoked).toBe(1); +}); diff --git a/tools/nwc-sw-test.js b/tools/nwc-sw-test.js index 816b9ce..5a15d48 100644 --- a/tools/nwc-sw-test.js +++ b/tools/nwc-sw-test.js @@ -118,6 +118,24 @@ console.log('\n[merchant methods (BTCPay)]'); const txs = decryptReply(published[2]).result?.transactions || []; check('list_transactions includes receives', txs.some((t) => t.type === 'incoming' && t.amount === 21000), JSON.stringify(txs)); } +{ + // PullCap 2026-10-24: lookups of a send in flight came back "not found". + // Every copy of the connection is asked; an outgoing send answers as one. + const INV = 'lnbc210n1p4xuk2wpp506wkjr0xk3677nu7je9c55vq4lzlkyd0ztcq2mlvumap0zpe3alqhp5ppg7g8qwpdv34hgpymhw446y37duzwcn388yp3pw05n7tlulyn2scqzysxqrrssrzjqv3dpepm8kfdxrk3sl6wzqdf49s9c0h9ljtjrek6c08r6aejlwcnur0dwyqqvusqqqqqqqlgqqqq86qqjqsp5dc0jrq94ke2f4dzx8c2dwqsc6a65eu56dt2j599l7kxp7q2hs6zq9qxpqysgqdjeft8gkl0uga24e502pvcp5vgsfap3dxuutcpgfaj33fffuqs9psmnrklshp3fg3py7vlnzsea90vj9ahqq5t9xuy67u3pk0sfnheqpn95f2g'; + const H = '7e9d690de6b475ef4f9e964b8a5180afc5fb11af12f0056fece6fa1788398f7e'; + const fresher = baseRec(); fresher.updated = 2; fresher.ark = { ...fresher.ark, network: 'signet' }; + const live = baseRec(); live.updated = 1; + live.mgr.actions.push({ id: 'lnpay-1', type: 'ln-pay', step: 'done', paymentHash: H, preimage: 'cd'.repeat(32), amountSat: 21, invoice: INV }); + const { deps, published, decryptReply } = harness({ recs: [{ walletKey: 'old', rec: fresher }, { walletKey: 'w1', rec: live }] }); + await respondFromBg({ type: 'nwc', event: ev(makeReq('lookup_invoice', { payment_hash: H })) }, deps); + const r = decryptReply(published[0]).result || {}; + check('lookup_invoice finds a send in the older copy of the connection → outgoing, settled, its preimage', r.type === 'outgoing' && r.state === 'settled' && r.preimage === 'cd'.repeat(32), JSON.stringify(r)); + live.mgr.actions[0].step = 'initiated'; delete live.mgr.actions[0].preimage; + const t0 = Date.now(); + await respondFromBg({ type: 'nwc', event: ev(makeReq('lookup_invoice', { payment_hash: H })) }, { ...deps, deadline: Date.now() + 2500 }); + const r2 = decryptReply(published[1]).result || {}; + check('…one still in flight (ASP unreachable here) → outgoing, pending, inside the push budget', r2.type === 'outgoing' && r2.state === 'pending' && Date.now() - t0 < 4000, JSON.stringify(r2) + ` ${Date.now() - t0} ms`); +} { const { deps } = harness(); // a mirror without receive keys check('make_invoice on an old mirror → wake the user', (await respondFromBg({ type: 'nwc', event: ev(makeReq('make_invoice', { amount: 21000 })) }, deps)) === false); diff --git a/tools/sw-push-quota.test.js b/tools/sw-push-quota.test.js index 049dc1f..cf4a7bf 100644 --- a/tools/sw-push-quota.test.js +++ b/tools/sw-push-quota.test.js @@ -6,7 +6,7 @@ import { test, expect, mock } from 'bun:test'; let respond = async () => true; 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/nwc-bg.js', () => ({ appendBgLog: async () => {}, takeLease: async () => ({ waited: 0, release: async () => {} }) })); mock.module('../src/bg-autowithdraw.js', () => ({ bgAutoWithdraw: async () => {} })); mock.module('../src/dm-inbox.js', () => ({ allInboxes: async () => [{ muted: ['m'.repeat(64)] }], -- cgit v1.2.3