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 --- src/ark/manager.js | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) (limited to 'src/ark') 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; } -- cgit v1.2.3