aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/ark/manager.js23
-rw-r--r--src/features/ark.js6
-rw-r--r--src/features/nwc.js42
-rw-r--r--src/nwc-lock.js14
-rw-r--r--src/nwc-respond.js13
-rw-r--r--src/sw-nwc.js8
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);
}