From 6394de43d4e596aaeddd2b262bb7a595679d8fbc Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 14 Sep 2026 08:53:35 -0700 Subject: A zap answered by two tabs no longer reports as failed. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every open tab of the same wallet sees every wallet-connect request. Two hidden tabs on one laptop both tried to pay the same zap: the ASP refused the second with "payment already in progress", and that error reached Amethyst half a second before the first tab's preimage. A Web Lock per request now lets one tab (or the worker) answer while the rest stay quiet, and a device that does meet "already in progress" waits for the sibling payment's outcome — relaying its preimage only if the payer never speaks — instead of shouting a failure over sats that went through. The losing side also stops writing a failed row into history for it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QYJfZ4A1TDCEY4Foq3CApR --- tools/nwc-test.js | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) (limited to 'tools') diff --git a/tools/nwc-test.js b/tools/nwc-test.js index 379df6c..5bb08e2 100644 --- a/tools/nwc-test.js +++ b/tools/nwc-test.js @@ -13,12 +13,14 @@ const store = {}; globalThis.localStorage = { getItem:k=>k in store?store[k]:null, setItem:(k,v)=>{store[k]=v;}, removeItem:k=>{delete store[k];} }; let subHandler = null; +let priorReplies = []; const published = []; // fake relay transport: capture the subscription handler and any replies const nwcTransport = { subscribe: (relays, filter, on) => { subHandler = on; return () => { subHandler = null; }; }, publish: async (relays, evt) => { published.push(evt); return true; }, - query: async () => [], // no other device has answered anything in tests + query: async () => priorReplies, // what other devices have answered (none, unless a test says so) + errGraceMs: 20, // the sibling-first hold, shortened for the harness }; let paid = []; const wallet = { @@ -107,6 +109,30 @@ check('second payment succeeds', !!r?.result?.preimage, JSON.stringify(r?.error check('spend accumulates across payments', spent === 52, String(spent)); // 2 × (25 + 1 fee) from the mock seam } +console.log('\n[sibling payment in flight]'); +{ + // The ASP refuses a second HTLC for an invoice another device is paying. + // This device must relay THAT payment's outcome, not report a failure. + const payer = hooks.arkPayInvoice; + hooks.arkPayInvoice = async () => { throw new Error('grpc bark_server.ArkService/RequestLightningPayHtlcCosign: status 3: error making payment: bad user input: payment already in progress for this invoice'); }; + hooks.arkLnOutcome = async () => ({ status: 'success', preimage: '12'.repeat(32) }); + r = await request('pay_invoice', { invoice: INV21 }); + check('sibling success relayed with its preimage', r?.result?.preimage === '12'.repeat(32), JSON.stringify(r)); + const spent = JSON.parse(store['fs:nwc']).conns.find((x) => x.id === 'test1').spentToday; + check('relayed payment not counted as our spend', spent === 52, String(spent)); + // ...and stays quiet when the payer's own reply is already on the relay + priorReplies = [{ id: 'x' }]; + const n = published.length; + r = await request('pay_invoice', { invoice: INV21 }); + check('silent when the payer already replied', published.length === n); + priorReplies = []; + hooks.arkLnOutcome = async () => ({ status: 'failed' }); + r = await request('pay_invoice', { invoice: INV21 }); + check('sibling failure surfaces as an error', /already in progress/.test(r?.error?.message || ''), JSON.stringify(r)); + hooks.arkPayInvoice = payer; + delete hooks.arkLnOutcome; +} + console.log('\n[nip04 fallback]'); r = await request('get_balance', {}, 'nip04'); check('nip04 request answered', r?.result?.balance === 42000*1000); -- cgit v1.2.3