diff options
| author | Adam Soltys <[email protected]> | 2026-09-14 09:06:02 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-14 09:06:02 -0700 |
| commit | 671d2a96474f59ca9234c876ce6604d03de95727 (patch) | |
| tree | d732896288985033c0531b70b7bcad0cc388cb00 | |
| parent | 6394de43d4e596aaeddd2b262bb7a595679d8fbc (diff) | |
| download | coinosv3-671d2a96474f59ca9234c876ce6604d03de95727.tar.gz coinosv3-671d2a96474f59ca9234c876ce6604d03de95727.zip | |
One device pays a zap, whichever of them heard it first.
Every awake device of a wallet hears a wallet-connect pay request within
the same second, and the per-tab lock only reaches one browser profile:
two profiles on one laptop each locked an HTLC for the same 21-sat zap.
The push notifier now hands each pay request to the first device that
claims it; a device that loses the claim stands down silently, and a
device that cannot reach the notifier answers as before so nobody goes
quiet over a hiccup. The worker path claims the same way.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QYJfZ4A1TDCEY4Foq3CApR
| -rw-r--r-- | nwcpush/server.js | 33 | ||||
| -rw-r--r-- | src/features/nwc.js | 29 | ||||
| -rw-r--r-- | src/nwc-respond.js | 14 | ||||
| -rw-r--r-- | tools/nwc-sw-test.js | 3 | ||||
| -rw-r--r-- | tools/nwc-test.js | 13 |
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); |
