// NWC push notifier — step 1 of making hal answer wallet-connect requests // while it is closed. // // hal is a web wallet: a service worker cannot hold a relay socket open, so // something has to tell the browser "a request arrived, wake up". That is all // this does. It watches the relays for NIP-47 requests (kind 23194) addressed // to the wallet-service pubkeys a device has registered, and sends that device // a Web Push. The service worker then does the actual work — fetch, decrypt, // pay, reply — in the browser, with the keys never leaving it. // // WHAT THIS SERVICE LEARNS: the wallet-service pubkeys you register, and the // timing of requests to them. That is a real metadata leak and is the price of // push. It never sees request contents (encrypted to keys it does not have), // never sees your wallet seed, and can never move funds. The worst it can do // is fail to wake you, or lie about a wake-up — both of which cost nothing. // // Deliberately dumb: no database, no accounts. Registrations live in one JSON // file and expire if a device stops refreshing them. import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { SimplePool } from 'nostr-tools/pool'; import { verifyEvent } from 'nostr-tools/pure'; import webpush from 'web-push'; // crude per-IP publish limiter: 20/min is far above a wallet's real reply rate const pubRate = new Map(); function rateOk(ip) { const now = Date.now(); const arr = (pubRate.get(ip) || []).filter((t) => t > now - 60_000); if (arr.length >= 20) return false; arr.push(now); pubRate.set(ip, arr); if (pubRate.size > 500) for (const [k, v] of pubRate) { if (!v.some((t) => t > now - 60_000)) pubRate.delete(k); } return true; } const CFG = JSON.parse(readFileSync(process.env.NWCPUSH_CONFIG || join(import.meta.dir, 'config.json'), 'utf8')); // { port, relays: [...], vapid: { publicKey, privateKey, subject }, // stateFile, maxPubkeysPerDevice, staleDays } const RELAYS = CFG.relays || ['wss://relay.coinos.io', 'wss://relay.damus.io', 'wss://nos.lol']; const STATE = CFG.stateFile || join(import.meta.dir, 'data', 'registrations.json'); const MAX_PK = CFG.maxPubkeysPerDevice || 20; const STALE_MS = (CFG.staleDays || 30) * 86400_000; const REQ_KIND = 23194; const OFFER_KIND = 21001; webpush.setVapidDetails(CFG.vapid.subject, CFG.vapid.publicKey, CFG.vapid.privateKey); // Two ways to reach a device. // // Web Push is the browser's, and on Android it is Firebase's — a phone // without Google Play Services (GrapheneOS and friends) can't subscribe at // all. UnifiedPush is the other way: the user runs a distributor, the app // gets an endpoint, and anyone wanting to reach the device POSTs the payload // to it. No encryption envelope and no VAPID — the endpoint is the secret, // which is why we never log one in full. // // A subscription marked `unifiedpush` is the second kind; everything else is // a browser's. One function, so every send path serves both. const isUP = (sub) => !!(sub && sub.unifiedpush && typeof sub.endpoint === 'string'); async function deliver(sub, payload, opts = {}) { if (!isUP(sub)) return webpush.sendNotification(sub, payload, opts); const res = await fetch(sub.endpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, body: payload, signal: AbortSignal.timeout(8000), }); if (!res.ok) { // shaped like web-push's errors so the callers' 404/410 pruning works const err = new Error(`unifiedpush ${res.status}`); err.statusCode = res.status; throw err; } return res; } const log = (...a) => console.log(new Date().toISOString(), ...a); // --------------------------------------------------------------------------- // registrations: { [servicePubkey]: [{ sub, id, updated }] } // --------------------------------------------------------------------------- let regs = {}; // user-facing notifications: { [endpointId]: { sub, ptags: [], authors: [], updated } } // ptags → DMs / zap receipts p-tagged at the user; authors → community // stream pubkeys (kind 1059 wraps authored by a channel's derived key). let notifyRegs = {}; (() => { try { const raw = JSON.parse(readFileSync(STATE, 'utf8')); if (raw && raw.nwc) { regs = raw.nwc; notifyRegs = raw.notify || {}; } else regs = raw || {}; // pre-notify state file } catch {} })(); function persist() { mkdirSync(dirname(STATE), { recursive: true }); const tmp = STATE + '.tmp'; writeFileSync(tmp, JSON.stringify({ nwc: regs, notify: notifyRegs })); renameSync(tmp, STATE); } // A device that stops refreshing drops off, so a stale endpoint can't keep us // subscribed to a pubkey forever. function prune() { const cutoff = Date.now() - STALE_MS; let dropped = 0; for (const pk of Object.keys(regs)) { regs[pk] = (regs[pk] || []).filter((r) => (r.updated || 0) > cutoff); if (!regs[pk].length) { delete regs[pk]; dropped++; } } for (const id of Object.keys(notifyRegs)) { if ((notifyRegs[id].updated || 0) <= cutoff) { delete notifyRegs[id]; dropped++; } } if (dropped) { persist(); log(`pruned ${dropped} stale registration(s)`); resubscribe(); } } const watchedPubkeys = () => Object.keys(regs); // --------------------------------------------------------------------------- // relay watcher // --------------------------------------------------------------------------- const pool = new SimplePool(); let sub = null; let lastResub = 0; const notifyPtags = () => [...new Set(Object.values(notifyRegs).flatMap((r) => r.ptags || []))]; const notifyAuthors = () => [...new Set(Object.values(notifyRegs).flatMap((r) => r.authors || []))]; let notifySub = null; function resubscribeNotify() { try { notifySub?.close(); } catch {} notifySub = null; const ptags = notifyPtags(); const authors = notifyAuthors(); if (!ptags.length && !authors.length) return; const filters = []; // DMs / direct invites / zap receipts land p-tagged at the user // ...as do replies and mentions (kind 1 naming the user) and reactions (7) if (ptags.length) filters.push({ kinds: [1059, 9735, 9737, 1, 7], '#p': ptags, since: Math.floor(Date.now() / 1000) }); // community chat: wraps authored by a channel's derived stream key if (authors.length) filters.push({ kinds: [1059], authors, since: Math.floor(Date.now() / 1000) }); // one sub per filter — the pool API takes a single filter object notifySub = filters.map((f) => pool.subscribeMany(RELAYS, f, { onevent: onNotifyEvent })); notifySub.close = function () { for (const x of this) { try { x.close(); } catch {} } }; log(`notify: watching ${ptags.length} ptag(s), ${authors.length} stream author(s)`); } // throttle: chat bursts collapse to one push per device per window // DMs are throttled barely at all now: the device drops the ones it decides // are noise (a stranger, or our own sent-copy), and a server-side cooldown // would let a discarded push swallow the friend's message that followed it. const COOLDOWN = { payment: 10_000, zap: 10_000, dm: 1_000, chat: 90_000, mention: 5_000, reaction: 30_000 }; const lastPush = new Map(); // endpointId:reason -> ts const seenNotifyEvents = new Map(); // event id -> ts function onNotifyEvent(ev) { if (seenNotifyEvents.has(ev.id)) return; seenNotifyEvents.set(ev.id, Date.now()); if (seenNotifyEvents.size > 4000) { const cut = Date.now() - 600_000; for (const [k, t] of seenNotifyEvents) if (t < cut) seenNotifyEvents.delete(k); } const p = ev.tags?.find((t) => t[0] === 'p')?.[1]; // A public reply or mention: the note is readable here, so the device // gets what it needs to say who and what — an excerpt, and whether it // answers a post (e tag) or just names the person. if (ev.kind === 1) { const ps = (ev.tags || []).filter((t) => t[0] === 'p' && t[1]).map((t) => t[1]); const reply = (ev.tags || []).some((t) => t[0] === 'e' && t[1]); const extra = { from: ev.pubkey, reply, text: String(ev.content || '').replace(/\s+/g, ' ').slice(0, 140) }; for (const [id, r] of Object.entries(notifyRegs)) { const mine = r.ptags || []; if (mine.includes(ev.pubkey)) continue; // their own post, not news if (ps.some((x) => mine.includes(x))) pushNotify(id, r, 'mention', extra).catch(() => {}); } return; } // A like or emoji on the user's post. Opt-in by an explicit flag: an app // too old to send one has no words for it and would call it chat. if (ev.kind === 7) { const ps = (ev.tags || []).filter((t) => t[0] === 'p' && t[1]).map((t) => t[1]); const extra = { from: ev.pubkey, emoji: String(ev.content || '+').slice(0, 16) }; for (const [id, r] of Object.entries(notifyRegs)) { const mine = r.ptags || []; if (r.reasons?.reaction !== true || mine.includes(ev.pubkey)) continue; if (ps.some((x) => mine.includes(x))) pushNotify(id, r, 'reaction', extra).catch(() => {}); } return; } const zap = ev.kind === 9735 || ev.kind === 9737; const reason = zap ? 'payment' : p && notifyPtags().includes(p) ? 'dm' : 'chat'; // A wrapped DM hides its sender from us by design, so we can't decide here // whether it's from a friend or a stranger — we hand the whole wrap to the // device, which holds the only key that can open it. Web Push tops out // around 4KB after encryption; anything larger goes as a bare nudge and the // device shows its generic notification. const extra = reason === 'dm' && JSON.stringify(ev).length < 3500 ? { wrap: ev } : {}; // a coinos zap receipt says how much in the clear const amt = ev.kind === 9737 ? parseInt(ev.tags?.find((t) => t[0] === 'amount')?.[1], 10) : 0; if (amt > 0) extra.amountSat = amt; for (const [id, r] of Object.entries(notifyRegs)) { const hit = reason === 'chat' ? (r.authors || []).includes(ev.pubkey) : p && (r.ptags || []).includes(p); // a zap is its own category for apps that know one; older apps keep // getting it as a payment if (hit) pushNotify(id, r, zap && typeof r.reasons?.zap === 'boolean' ? 'zap' : reason, extra).catch(() => {}); } } async function pushNotify(id, r, reason, extra) { if (r.reasons && r.reasons[reason] === false) return; // opted out of this category const key = id + ':' + reason; const last = lastPush.get(key) || 0; if (Date.now() - last < (COOLDOWN[reason] || 10_000)) return; lastPush.set(key, Date.now()); const payload = JSON.stringify({ type: 'notify', reason, ...extra }); // Android holds a 'normal' push while the phone dozes and batches it into // the next maintenance window — and drops it once TTL runs out first. A // phone in a pocket missed most zaps and mentions this way (a laptop never // dozes, so it got them all). Money and people talking to you go 'high' // and keep for a day; reactions and community chatter can wait. const loud = ['payment', 'zap', 'dm', 'mention'].includes(reason); const opts = loud ? { TTL: 86400, urgency: 'high' } : { TTL: 3600, urgency: 'normal' }; try { try { await deliver(r.sub, payload, opts); } catch (e) { // A payload the push service won't take (413, or the library refusing // the size) must not cost the notification itself — send the bare nudge // and let the device show its generic message. if (!extra.wrap || (e.statusCode && e.statusCode !== 413)) throw e; log(`notify(dm): payload rejected (${e.statusCode || e.message}), retrying bare`); await deliver(r.sub, JSON.stringify({ type: 'notify', reason }), opts); } log(`notify(${reason}) -> device ${id}`); } catch (e) { // 404/410 = gone; 403 = the sub was made against a different VAPID key // and can NEVER receive a push (a stale key rotation), so prune it too — // the client re-subscribes fresh against the current key on next open. if (e.statusCode === 404 || e.statusCode === 410 || e.statusCode === 403) { delete notifyRegs[id]; persist(); resubscribeNotify(); log(`dropped a dead notify endpoint ${id} (${e.statusCode})`); } else { log(`notify(${reason}) push failed for ${id}: ${e.statusCode || e.message}`); } } } function resubscribe() { resubscribeNotify(); const pks = watchedPubkeys(); try { sub?.close(); } catch {} sub = null; if (!pks.length) { log('nothing registered for nwc; not subscribed'); return; } lastResub = Date.now(); sub = pool.subscribeMany( RELAYS, // only live traffic: a replayed old request must not wake devices. // NOTE: nostr-tools ≥2.23 wants a single filter object, NOT an array — // an array serializes as an invalid REQ that relays reject with CLOSED. { kinds: [REQ_KIND, OFFER_KIND], '#p': pks, since: Math.floor(Date.now() / 1000) }, { onevent: (ev) => { const target = ev.tags?.find((t) => t[0] === 'p')?.[1]; if (target) { noteRequest(ev.id); wake(target, ev.id, ev).catch(() => {}); } }, }, ); // Track replies for the requests we forwarded, so an auto-answering worker // can ask "was this already answered by an open device elsewhere?" before // it pays. Only e-tags of requests we have seen are recorded. try { answeredSub?.close(); } catch {} answeredSub = pool.subscribeMany( RELAYS, { kinds: [RES_KIND, OFFER_KIND], since: Math.floor(Date.now() / 1000) }, { onevent: (ev) => { for (const t of ev.tags || []) { if (t[0] === 'e' && recentReqs.has(t[1])) answeredIds.set(t[1], Date.now()); } }, }, ); log(`subscribed for ${pks.length} service pubkey(s) on ${RELAYS.length} relays`); } // requests we pushed recently, and which of them got a reply 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) 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. const recentlyWoken = new Map(); async function wake(servicePk, eventId, ev) { const key = servicePk + ':' + eventId; if (recentlyWoken.has(key)) return; recentlyWoken.set(key, Date.now()); if (recentlyWoken.size > 2000) { const cut = Date.now() - 300_000; for (const [k, t] of recentlyWoken) if (t < cut) recentlyWoken.delete(k); } const targets = regs[servicePk] || []; if (!targets.length) return; // The payload carries the (still encrypted, still client-signed) request // event so an auto-answering service worker can act on it without holding // a relay socket. Web Push payloads cap around 4KB — an oversized event is // omitted and the worker falls back to waking the user. const clean = ev && { id: ev.id, pubkey: ev.pubkey, created_at: ev.created_at, kind: ev.kind, tags: ev.tags, content: ev.content, sig: ev.sig, }; let payload = JSON.stringify({ type: 'nwc', servicePubkey: servicePk, event: clean }); if (payload.length > 3800) payload = JSON.stringify({ type: 'nwc', servicePubkey: servicePk }); let ok = 0; for (const r of targets) { try { await deliver(r.sub, payload, { TTL: 60, urgency: 'high' }); ok++; } catch (e) { // 404/410 mean the browser dropped the subscription: forget it. if (e.statusCode === 404 || e.statusCode === 410) { regs[servicePk] = (regs[servicePk] || []).filter((x) => x.id !== r.id); if (!regs[servicePk].length) delete regs[servicePk]; persist(); log(`dropped an expired push endpoint for ${servicePk.slice(0, 12)}`); } else { log('push failed:', e.statusCode || e.message); } } } log(`woke ${ok}/${targets.length} device(s) for ${servicePk.slice(0, 12)}`); } // --------------------------------------------------------------------------- // HTTP // --------------------------------------------------------------------------- const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', 'access-control-allow-origin': '*', 'access-control-allow-headers': 'content-type', 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS', }, }); const server = Bun.serve({ port: CFG.port || 8797, async fetch(req) { const url = new URL(req.url); if (req.method === 'OPTIONS') return json({}); if (url.pathname === '/health') { return json({ ok: true, relays: RELAYS, watching: watchedPubkeys().length, subscribed: !!sub, lastResub, }); } // The browser needs this to create a PushSubscription. if (url.pathname === '/vapid') return json({ publicKey: CFG.vapid.publicKey }); // Publish-back for the auto-answering service worker: it cannot hold a // relay socket, so it hands us its (encrypted, service-key-signed) reply. // Strictly kind 23195, size-capped, signature-verified, rate-limited — // this is a reply pipe, not an open relay proxy. if (url.pathname === '/publish' && 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 ev = body?.event; if (!ev || ![RES_KIND, OFFER_KIND].includes(ev.kind)) return json({ error: 'kind 23195/21001 events only' }, 400); if (JSON.stringify(ev).length > 4096) return json({ error: 'event too large' }, 400); if (!verifyEvent(ev)) return json({ error: 'bad signature' }, 400); const eTag = (ev.tags || []).find((t) => t[0] === 'e')?.[1]; try { await Promise.allSettled(pool.publish(RELAYS, ev)); } catch {} if (eTag) answeredIds.set(eTag, Date.now()); log(`published a reply for request ${(eTag || '?').slice(0, 12)}`); return json({ ok: true }); } // Server-to-server: the names registrar reports a settled receive for a // pubkey. Token-gated; pushes 'payment received' to that user's devices. if (url.pathname === '/notify' && req.method === 'POST') { const body = await req.json().catch(() => null); if (!CFG.notifyToken || body?.token !== CFG.notifyToken) return json({ error: 'forbidden' }, 403); const pk = body?.pubkey; if (!/^[0-9a-f]{64}$/.test(pk || '')) return json({ error: 'pubkey required' }, 400); // Network scoping: the same pubkey exists on mainnet and staging, so a // payment goes only to devices on its own network. A device that hasn't // re-registered since this shipped has net=null and still gets it (so // no one goes dark mid-rollout); once both sides send a net, mismatches // are filtered. A caller that omits net pushes to everyone (legacy). const wantNet = typeof body.net === 'string' ? body.net : null; let n = 0; for (const [id, r] of Object.entries(notifyRegs)) { if (!(r.ptags || []).includes(pk)) continue; if (wantNet && r.net && r.net !== wantNet) continue; pushNotify(id, r, 'payment', { amountSat: Number(body.amountSat) || undefined }).catch(() => {}); n++; } 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') { const id = url.searchParams.get('event') || ''; return json({ answered: answeredIds.has(id) }); } // A device registers its push endpoint plus the wallet-service pubkeys it // wants woken for. Re-post periodically to stay alive. if (url.pathname === '/register' && req.method === 'POST') { const body = await req.json().catch(() => null); const sub_ = body?.subscription; const hasNwc = Array.isArray(body?.servicePubkeys); const pks = (body?.servicePubkeys || []).filter((p) => /^[0-9a-f]{64}$/.test(p)); // user-facing notification watch (messages + payments) — independent of // the NWC registration so either can refresh without clobbering the other if (body?.notify && sub_?.endpoint) { const hex = (a, cap) => [...new Set((a || []).filter((p) => /^[0-9a-f]{64}$/.test(p)))].slice(0, cap); const id = Bun.hash(sub_.endpoint).toString(36); // per-category opt-outs; absent means everything on (older clients) const reasons = {}; for (const k of ['payment', 'zap', 'dm', 'chat', 'mention', 'reaction']) { if (typeof body.notify.reasons?.[k] === 'boolean') reasons[k] = body.notify.reasons[k]; } notifyRegs[id] = { sub: sub_, updated: Date.now(), ptags: hex(body.notify.ptags, 8), authors: hex(body.notify.authors, 64), reasons, // the network this device is on: the same seed yields the same // pubkey on mainnet and staging, so a payment push is delivered // only to devices on the payment's network (see /notify) net: typeof body.notify.net === 'string' ? body.notify.net.slice(0, 16) : null, }; persist(); resubscribeNotify(); if (!hasNwc) return json({ ok: true, notify: true }); } if (!sub_?.endpoint || !pks.length) return json({ error: 'subscription and servicePubkeys required' }, 400); if (pks.length > MAX_PK) return json({ error: `at most ${MAX_PK} pubkeys` }, 400); // one id per endpoint so re-registering replaces rather than duplicates const id = Bun.hash(sub_.endpoint).toString(36); const known = new Set(pks); for (const pk of pks) { regs[pk] = (regs[pk] || []).filter((r) => r.id !== id); regs[pk].push({ sub: sub_, id, updated: Date.now() }); } // drop this device from pubkeys it no longer cares about for (const pk of Object.keys(regs)) { if (known.has(pk)) continue; const before = regs[pk].length; regs[pk] = regs[pk].filter((r) => r.id !== id); if (!regs[pk].length) delete regs[pk]; if (before !== (regs[pk]?.length ?? 0)) { /* changed */ } } persist(); resubscribe(); return json({ ok: true, watching: pks.length }); } if (url.pathname === '/register' && req.method === 'DELETE') { const body = await req.json().catch(() => null); if (!body?.endpoint) return json({ error: 'endpoint required' }, 400); const id = Bun.hash(body.endpoint).toString(36); for (const pk of Object.keys(regs)) { regs[pk] = regs[pk].filter((r) => r.id !== id); if (!regs[pk].length) delete regs[pk]; } delete notifyRegs[id]; persist(); resubscribe(); return json({ ok: true }); } return json({ error: 'not found' }, 404); }, }); log(`nwc push notifier on :${server.port}`); log(` relays: ${RELAYS.join(' ')}`); log(` watching ${watchedPubkeys().length} service pubkey(s)`); resubscribe(); setInterval(prune, 3600_000); // relays drop long-lived subs; re-arm periodically setInterval(() => { if (watchedPubkeys().length) resubscribe(); }, 15 * 60_000);