aboutsummaryrefslogtreecommitdiffstats
path: root/nwcpush
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-14 09:06:02 -0700
committerAdam Soltys <[email protected]>2026-09-14 09:06:02 -0700
commit671d2a96474f59ca9234c876ce6604d03de95727 (patch)
treed732896288985033c0531b70b7bcad0cc388cb00 /nwcpush
parent6394de43d4e596aaeddd2b262bb7a595679d8fbc (diff)
downloadcoinosv3-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.js33
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') {