diff options
| author | Adam Soltys <[email protected]> | 2026-09-14 08:53:35 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-14 08:53:35 -0700 |
| commit | 6394de43d4e596aaeddd2b262bb7a595679d8fbc (patch) | |
| tree | 391f79322f788955985ad149d02e60111aa6ea42 /src | |
| parent | 4131e5ee24c4891caffdd8f4ca903ee973d104f7 (diff) | |
| download | coinosv3-6394de43d4e596aaeddd2b262bb7a595679d8fbc.tar.gz coinosv3-6394de43d4e596aaeddd2b262bb7a595679d8fbc.zip | |
A zap answered by two tabs no longer reports as failed.
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QYJfZ4A1TDCEY4Foq3CApR
Diffstat (limited to 'src')
| -rw-r--r-- | src/ark/manager.js | 23 | ||||
| -rw-r--r-- | src/features/ark.js | 6 | ||||
| -rw-r--r-- | src/features/nwc.js | 42 | ||||
| -rw-r--r-- | src/nwc-lock.js | 14 | ||||
| -rw-r--r-- | src/nwc-respond.js | 13 | ||||
| -rw-r--r-- | src/sw-nwc.js | 8 |
6 files changed, 96 insertions, 10 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js index 4dd39e4..5775d65 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -957,6 +957,22 @@ export class ArkManager { _expiryMargin() { return this.network === 'regtest' ? 12 : 144; } + // The settled outcome of a payment by hash, polled until the ASP's node + // decides or the wait runs out. For a payment another device of this + // wallet started (the server refuses a second HTLC for the same invoice) + // this is the only way to learn whether it paid. + async lnOutcome(paymentHash, timeoutMs = 60000) { + const deadline = Date.now() + timeoutMs; + for (;;) { + const res = await checkLightningPayment(this.arkUrl, hex.decode(paymentHash), false).catch(() => null); + if (res && res.status === 'success' && res.preimage + && hex.encode(sha256(hex.decode(res.preimage))) === paymentHash) return res; + if (res && res.status === 'failed') return res; + if (Date.now() >= deadline) return { status: 'pending' }; + await new Promise((r) => setTimeout(r, 1500)); + } + } + lnAction(id) { return this.state.actions.find((a) => a.id === id && a.type.startsWith('ln-')); } @@ -1140,7 +1156,12 @@ export class ArkManager { action.step = 'failed'; action.error = e.message; this._recordExpiryRejection(action); - this._movement({ type: 'ln-send', amountSat: action.amountSat, status: 'failed', detail: e.message }); + // "already in progress" means another device of this wallet is + // paying this very invoice — that payment's row is the history, + // a failed row beside it would read as a lost zap. + if (!/already in progress/i.test(e.message)) { + this._movement({ type: 'ln-send', amountSat: action.amountSat, status: 'failed', detail: e.message }); + } this._save(); return; } diff --git a/src/features/ark.js b/src/features/ark.js index 222b312..e9c93b1 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -3181,6 +3181,12 @@ export function arkFeature(ctx) { startLnPay(invoice, meta) { return startArkLnPay(invoice, meta); }, // ---- headless seam for the NWC wallet service ---- arkPayInvoice(invoice, opts) { return payInvoiceHeadless(invoice, opts); }, + // The settled outcome of a payment another device of this wallet + // started (the ASP refuses a second HTLC for the same invoice). + async arkLnOutcome(paymentHash, timeoutMs) { + const mgr = await connectArk(); + return mgr.lnOutcome(paymentHash, timeoutMs); + }, arkSpendableSat() { const b = arkBalance(); return b ? b.spendableSat : 0; }, arkReady() { return arkAvailable(); }, async arkMakeInvoice(amountSat, description) { diff --git a/src/features/nwc.js b/src/features/nwc.js index 656c254..73ab781 100644 --- a/src/features/nwc.js +++ b/src/features/nwc.js @@ -32,6 +32,7 @@ import { getNetwork } from '../api.js'; import { loadBg, saveBg, clearBg } from '../nwc-bg.js'; import { encodeNoffer } from '../noffer.js'; import { maybeBolt11 } from '../ark/lightning.js'; +import { withRequestLock } from '../nwc-lock.js'; const REQ_KIND = 23194; const RES_KIND = 23195; @@ -51,7 +52,12 @@ export function nwcFeature(ctx) { const { h, ui, render, wallet, hook, fmtAmount, unitLabel, copyBtn, toast } = ctx; // Relay transport, injectable so the protocol can be driven in tests // without a live relay. - const { subscribe = subscribeOn, publish = publishOn, query = queryOn } = ctx.nwcTransport || {}; + const { + subscribe = subscribeOn, publish = publishOn, query = queryOn, + // how long a pay_invoice error (or a sibling's success) waits for the + // device that actually paid to speak first + errGraceMs = 3000, + } = ctx.nwcTransport || {}; // ---- persisted connections ------------------------------------------- // { id, name, secret (client sk hex), clientPk, servicePk, serviceSk, @@ -263,6 +269,12 @@ export function nwcFeature(ctx) { if (ev.created_at && nowSec() - ev.created_at > MAX_AGE_SEC) { return console.log('nwc: guard drop — too old', nowSec() - ev.created_at, 's'); } + // Same wallet, several tabs: the first to claim the request answers it. + return withRequestLock(ev.id, () => serve(c, ev), + () => console.log('nwc: skip — another tab or the worker holds', ev.id.slice(0, 8))); + } + + async function serve(c, ev) { // only the client this connection was issued to if (ev.pubkey !== c.clientPk) { return console.log(`nwc: guard drop — pubkey mismatch got=${ev.pubkey.slice(0, 8)} want=${c.clientPk.slice(0, 8)}`); @@ -323,13 +335,18 @@ export function nwcFeature(ctx) { // device's success: two tabs can pass the pre-check together, and the // one that loses (stale state, mid-init, refused vtxo) would otherwise // reply an error AFTER the winner's preimage, which some clients trust. + // The same hold covers a success learned from a sibling's payment: the + // device that paid owns the reply, this one only fills in if it never + // comes. const sendChecked = async (payload) => { - if (method === 'pay_invoice' && payload.error) { + if (method === 'pay_invoice' && (payload.error || payload.sibling)) { + await new Promise((r) => setTimeout(r, errGraceMs)); const prior = await query(NWC_RELAYS, { kinds: [RES_KIND], '#e': [ev.id] }, 1500).catch(() => []); if (prior && prior.length) { - return console.log('nwc: suppressing error — request answered elsewhere', ev.id.slice(0, 8)); + return console.log('nwc: staying quiet — request answered elsewhere', ev.id.slice(0, 8)); } } + delete payload.sibling; await reply(c, ev, scheme, payload); }; try { @@ -372,7 +389,6 @@ export function nwcFeature(ctx) { // to answer; sendChecked then suppresses this if anyone did. const netOf = (n) => (n === 'mutinynet' ? 'signet' : n); if (dec.network && netOf(getNetwork()) !== dec.network) { - await new Promise((r) => setTimeout(r, 3000)); return errRes('INTERNAL', `invoice is for ${dec.network}, wallet is on ${netOf(getNetwork())}`); } const { left } = spendRoom(c); @@ -382,7 +398,19 @@ export function nwcFeature(ctx) { if (dec.amountSat > left) { return errRes('QUOTA_EXCEEDED', `over the remaining daily budget (${left} sat)`); } - const res = await hook('arkPayInvoice', invoice, { maxAmountSat: c.maxSat }); + let res; + try { + res = await hook('arkPayInvoice', invoice, { maxAmountSat: c.maxSat }); + } catch (e) { + // The ASP already holds a payment for this invoice: another device + // of this wallet took the same request first. Its outcome is ours — + // wait for it rather than report a failure over a zap that is + // paying. The payer records the spend; this side only relays. + if (!/already in progress/i.test(e.message || '')) throw e; + const out = await hook('arkLnOutcome', dec.paymentHash, 60000); + if (!out || out.status !== 'success') throw e; + return { sibling: true, result: { preimage: out.preimage, fees_paid: 0 } }; + } // the ark seam throws on failure, so reaching here means it settled recordSpend(c, (res.amountSat || 0) + (res.feeSat || 0)); notifySent(c, res.amountSat || 0, res.feeSat || 0); @@ -445,6 +473,10 @@ export function nwcFeature(ctx) { if (handled.has(ev.id)) return; handled.add(ev.id); if (ev.created_at && nowSec() - ev.created_at > MAX_AGE_SEC) return; + return withRequestLock(ev.id, () => serveOffer(ev)); + } + + async function serveOffer(ev) { const o = load().offer; if (!o) return; const sk = hex.decode(o.sk); diff --git a/src/nwc-lock.js b/src/nwc-lock.js new file mode 100644 index 0000000..c4fec08 --- /dev/null +++ b/src/nwc-lock.js @@ -0,0 +1,14 @@ +// One request, one answerer per origin. +// +// Every open tab of the same wallet (and its service worker) sees every NWC +// request. Without exclusion two hidden tabs on one laptop both paid a zap: +// the loser's "payment already in progress" error reached the client half a +// second before the winner's preimage, and the zap showed as failed while +// the sats had gone. Web Locks are shared by every same-origin context, so +// the first to claim a request's lock answers it and the rest stay quiet. +// Where locks are unavailable (tests, ancient engines) the caller just runs. +export async function withRequestLock(id, fn, held = () => undefined) { + const locks = typeof navigator !== 'undefined' && navigator.locks; + if (!locks || !id) return fn(); + return locks.request(`nwc-req:${id}`, { ifAvailable: true }, (lock) => (lock ? fn() : held())); +} diff --git a/src/nwc-respond.js b/src/nwc-respond.js index 532b13e..9fdfcff 100644 --- a/src/nwc-respond.js +++ b/src/nwc-respond.js @@ -293,6 +293,19 @@ export async function respondFromBg(data, { } break; } + if (a && a.step === 'failed' && /already in progress/i.test(a.error || '')) { + // Another device of this wallet started this same payment first. Its + // outcome is ours: wait on it, and only speak if the payer stays quiet. + log('a sibling device is paying this invoice — waiting on its outcome'); + const out = await mgr.lnOutcome(dec.paymentHash, Math.max(3000, deadline - Date.now())); + if (out.status === 'failed') return failQuietly(a.error); + if (out.status !== 'success') { log('sibling payment still pending — leaving it to them'); return true; } + await new Promise((r) => setTimeout(r, errGraceMs)); + if (!(await answered())) { + await publish({ result_type: 'pay_invoice', result: { preimage: out.preimage, fees_paid: 0 } }); + } + return true; + } if (!a || a.step === 'failed') { return failQuietly((a && a.error) || 'payment failed'); } diff --git a/src/sw-nwc.js b/src/sw-nwc.js index 2566a5c..a166368 100644 --- a/src/sw-nwc.js +++ b/src/sw-nwc.js @@ -5,6 +5,7 @@ // when it can't. import { respondFromBg } from './nwc-respond.js'; +import { withRequestLock } from './nwc-lock.js'; import { bgAutoWithdraw } from './bg-autowithdraw.js'; import { allInboxes, classifyDm, shouldNotifyDm } from './dm-inbox.js'; @@ -67,10 +68,9 @@ self.addEventListener('push', (e) => { setTimeout(() => { bgAutoWithdraw({ log: (m) => console.log('[sw-aw]', m) }).catch(() => {}); }, 0); let handled = false; try { - handled = await respondFromBg(data, { - notifier: NOTIFIER, - log: (m) => console.log('[sw-nwc]', m), - }); + handled = await withRequestLock(data.event && data.event.id, + () => respondFromBg(data, { notifier: NOTIFIER, log: (m) => console.log('[sw-nwc]', m) }), + () => { console.log('[sw-nwc] another context holds this request'); return true; }); } catch (err) { console.warn('[sw-nwc] auto-answer failed:', err && err.message); } |
