aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/nwc.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-14 08:53:35 -0700
committerAdam Soltys <[email protected]>2026-09-14 08:53:35 -0700
commit6394de43d4e596aaeddd2b262bb7a595679d8fbc (patch)
tree391f79322f788955985ad149d02e60111aa6ea42 /src/features/nwc.js
parent4131e5ee24c4891caffdd8f4ca903ee973d104f7 (diff)
downloadcoinosv3-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/features/nwc.js')
-rw-r--r--src/features/nwc.js42
1 files changed, 37 insertions, 5 deletions
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);