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 /nwcpush | |
| 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
Diffstat (limited to 'nwcpush')
| -rw-r--r-- | nwcpush/server.js | 33 |
1 files changed, 28 insertions, 5 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') { |
