aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--nwcpush/server.js33
-rw-r--r--src/features/nwc.js29
-rw-r--r--src/nwc-respond.js14
-rw-r--r--tools/nwc-sw-test.js3
-rw-r--r--tools/nwc-test.js13
5 files changed, 85 insertions, 7 deletions
diff --git a/nwcpush/server.js b/nwcpush/server.js
index d441bdd..85fc7b5 100644
--- a/nwcpush/server.js
+++ b/nwcpush/server.js
@@ -255,13 +255,21 @@ const RES_KIND = 23195;
let answeredSub = null;
const recentReqs = new Map(); // request event id -> ts
const answeredIds = new Map(); // request event id -> ts a 23195 e-tagged it
+// First come, first pays: request event id -> { by, ts }. Every awake device
+// of a wallet sees every pay request, and two spending at once each lock an
+// HTLC for the same invoice — the loser's refusal then reached the client as
+// a failure. A device claims a request before spending; a claim held by
+// someone else means stand down. Memory only, minutes of life, no keys.
+const claims = new Map();
function noteRequest(id) {
recentReqs.set(id, Date.now());
- if (recentReqs.size > 2000) {
- const cut = Date.now() - 300_000;
- for (const [k, t] of recentReqs) if (t < cut) recentReqs.delete(k);
- for (const [k, t] of answeredIds) if (t < cut) answeredIds.delete(k);
- }
+ if (recentReqs.size > 2000) pruneRequests();
+}
+function pruneRequests() {
+ const cut = Date.now() - 300_000;
+ for (const [k, t] of recentReqs) if (t < cut) recentReqs.delete(k);
+ for (const [k, t] of answeredIds) if (t < cut) answeredIds.delete(k);
+ for (const [k, c] of claims) if (c.ts < cut) claims.delete(k);
}
// De-dupe: the same event arrives from several relays.
@@ -379,6 +387,21 @@ const server = Bun.serve({
return json({ ok: true, devices: n });
}
+ // Claim a pay request before spending on it. The first claimant (or the
+ // same one again) gets ok:true; anyone else ok:false and stands down.
+ if (url.pathname === '/claim' && req.method === 'POST') {
+ const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-forwarded-for') || 'local';
+ if (!rateOk(ip)) return json({ error: 'rate limited' }, 429);
+ const body = await req.json().catch(() => null);
+ const id = body?.event, by = String(body?.by || '').slice(0, 64);
+ if (!/^[0-9a-f]{64}$/.test(id || '') || !by) return json({ error: 'event and by required' }, 400);
+ if (claims.size > 5000) pruneRequests();
+ const cur = claims.get(id);
+ if (cur && cur.by !== by && Date.now() - cur.ts < 300_000) return json({ ok: false, by: cur.by });
+ claims.set(id, { by, ts: Date.now() });
+ return json({ ok: true });
+ }
+
// Was a request we pushed already answered by someone? Lets a worker
// avoid double-paying (or shouting an error over) another device's reply.
if (url.pathname === '/answered') {
diff --git a/src/features/nwc.js b/src/features/nwc.js
index 73ab781..0b6cf08 100644
--- a/src/features/nwc.js
+++ b/src/features/nwc.js
@@ -57,8 +57,28 @@ export function nwcFeature(ctx) {
// how long a pay_invoice error (or a sibling's success) waits for the
// device that actually paid to speak first
errGraceMs = 3000,
+ claim = claimOn,
} = ctx.nwcTransport || {};
+ // Cross-device leadership for a pay request. Every awake device of the
+ // wallet hears every request within the same second; the tab lock only
+ // covers one browser profile, and two profiles (or two machines) each
+ // locked an HTLC for the same zap. The notifier hands each request to the
+ // first device that asks. Unreachable notifier → answer anyway: a lone
+ // device must never go silent over a hiccup here.
+ const claimAs = Math.random().toString(36).slice(2, 12);
+ async function claimOn(id) {
+ try {
+ const r = await fetch(`${NOTIFIER}/claim`, {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ event: id, by: claimAs }),
+ signal: AbortSignal.timeout ? AbortSignal.timeout(4000) : undefined,
+ });
+ if (!r.ok) return true;
+ return (await r.json()).ok !== false;
+ } catch { return true; }
+ }
+
// ---- persisted connections -------------------------------------------
// { id, name, secret (client sk hex), clientPk, servicePk, serviceSk,
// maxSat, dailySat, spentToday, spentDate, created, lastUsed, revoked }
@@ -350,7 +370,8 @@ export function nwcFeature(ctx) {
await reply(c, ev, scheme, payload);
};
try {
- const out = await handle(c, method, params);
+ const out = await handle(c, method, params, () => claim(ev.id));
+ if (out && out.silent) return console.log('nwc: standing down — another device claimed', ev.id.slice(0, 8));
await sendChecked({ result_type: method, ...out });
} catch (e) {
await sendChecked({ result_type: method, ...errRes('INTERNAL', e.message || 'failed') });
@@ -358,7 +379,7 @@ export function nwcFeature(ctx) {
render();
}
- async function handle(c, method, params) {
+ async function handle(c, method, params, claimReq) {
if (!hook('arkReady')) return errRes('UNAUTHORIZED', 'Ark is not configured in this wallet');
if (method === 'get_info') {
@@ -398,6 +419,10 @@ export function nwcFeature(ctx) {
if (dec.amountSat > left) {
return errRes('QUOTA_EXCEEDED', `over the remaining daily budget (${left} sat)`);
}
+ // Everything local has passed; now make sure this device is the one
+ // that pays. Claiming before the checks would block a sibling behind
+ // a refusal it would not itself have given.
+ if (claimReq && !(await claimReq())) return { silent: true };
let res;
try {
res = await hook('arkPayInvoice', invoice, { maxAmountSat: c.maxSat });
diff --git a/src/nwc-respond.js b/src/nwc-respond.js
index 9fdfcff..bab25b0 100644
--- a/src/nwc-respond.js
+++ b/src/nwc-respond.js
@@ -183,6 +183,19 @@ export async function respondFromBg(data, {
return !!(await r.json()).answered;
} catch { return false; }
};
+ // ...and hands each pay request to the first device that claims it, so
+ // two awake devices don't each lock an HTLC for one zap. Unreachable →
+ // proceed: a lone worker must not go silent over a notifier hiccup.
+ const claim = async () => {
+ try {
+ const r = await fetchFn(`${notifier}/claim`, {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ event: ev.id, by: `sw-${walletKey}`.slice(0, 64) }),
+ });
+ if (!r.ok) return true;
+ return (await r.json()).ok !== false;
+ } catch { return true; }
+ };
if (method === 'get_info') {
await publish({
@@ -268,6 +281,7 @@ export async function respondFromBg(data, {
}
return true;
};
+ if (!(await claim())) { log('another device claimed this request — standing down'); return true; }
const deadline = Date.now() + PAY_WAIT_MS;
let a = null;
for (let attempt = 0; ; attempt++) {
diff --git a/tools/nwc-sw-test.js b/tools/nwc-sw-test.js
index acf3027..ae79e18 100644
--- a/tools/nwc-sw-test.js
+++ b/tools/nwc-sw-test.js
@@ -51,6 +51,9 @@ function harness({ rec = baseRec(), recs = null, answered = false } = {}) {
published.push(JSON.parse(opts.body).event);
return { ok: true, json: async () => ({ ok: true }) };
}
+ if (url.includes('/claim')) {
+ return { ok: true, json: async () => ({ ok: true }) };
+ }
if (url.includes('/answered')) {
return { ok: true, json: async () => ({ answered: typeof answered === 'function' ? answered() : answered }) };
}
diff --git a/tools/nwc-test.js b/tools/nwc-test.js
index 5bb08e2..b68a89b 100644
--- a/tools/nwc-test.js
+++ b/tools/nwc-test.js
@@ -14,6 +14,7 @@ globalThis.localStorage = { getItem:k=>k in store?store[k]:null, setItem:(k,v)=>
let subHandler = null;
let priorReplies = [];
+let claimOk = true;
const published = [];
// fake relay transport: capture the subscription handler and any replies
const nwcTransport = {
@@ -21,6 +22,7 @@ const nwcTransport = {
publish: async (relays, evt) => { published.push(evt); return true; },
query: async () => priorReplies, // what other devices have answered (none, unless a test says so)
errGraceMs: 20, // the sibling-first hold, shortened for the harness
+ claim: async () => claimOk, // the notifier's first-come-first-pays verdict
};
let paid = [];
const wallet = {
@@ -133,6 +135,17 @@ console.log('\n[sibling payment in flight]');
delete hooks.arkLnOutcome;
}
+console.log('\n[cross-device claim]');
+{
+ claimOk = false;
+ const n = published.length, wasPaid = paid.length;
+ await request('pay_invoice', { invoice: INV21 });
+ check('unclaimed pay request stays silent and unpaid', published.length === n && paid.length === wasPaid);
+ claimOk = true;
+ r = await request('get_balance', {});
+ check('non-pay methods need no claim', r?.result?.balance === 42000*1000);
+}
+
console.log('\n[nip04 fallback]');
r = await request('get_balance', {}, 'nip04');
check('nip04 request answered', r?.result?.balance === 42000*1000);