diff options
Diffstat (limited to 'mintdemux.ts')
| -rw-r--r-- | mintdemux.ts | 329 |
1 files changed, 329 insertions, 0 deletions
diff --git a/mintdemux.ts b/mintdemux.ts new file mode 100644 index 00000000..d961b05b --- /dev/null +++ b/mintdemux.ts @@ -0,0 +1,329 @@ +// mintdemux — transparent two-mint front for mint.coinos.io. +// +// Primary is the current mint (NEW: fresh keys, fully normal). The retired mint +// (OLD: panic mode, recovery-only) is fronted transparently so wallets still +// holding pre-2026-07-20 ecash can MELT it at mint.coinos.io without changing +// their mint URL. Routing: +// - /v1/keysets -> UNION of both mints' keysets (wallet sees old + new) +// - /v1/keys/<id> -> the mint that owns that keyset +// - /v1/info, /v1/keys -> NEW (primary identity + active keys) +// - /v1/melt/quote/bolt11 -> NEW; remember the invoice keyed by the quote id +// - /v1/melt/bolt11 -> routed by the submitted proofs' keyset: OLD-keyset +// proofs get a fresh OLD melt quote (from the remembered invoice) and are +// executed on OLD (where panic mode blocks blacklisted attacker ecash); +// change outputs are STRIPPED on that path (wallets blind them to the NEW +// active keyset, which OLD can't sign — OLD would reject the whole melt), +// and the OLD quote id in the response is mapped back to the caller's; +// everything else executes on NEW +// - GET /v1/melt/quote/bolt11/<id> -> OLD (id rewritten) if that melt was +// executed on OLD, else NEW — so wallets can poll a recovery melt +// - /v1/checkstate -> both mints, merged (spent if either says spent) +// - /v1/ws -> websocket proxy to NEW (subscriptions) +// - everything else (swap, mint, restore, mint quote) -> NEW +// The OLD mint also stands alone at mintrecovery.coinos.io (direct). +// +// Client-IP forwarding: cloudflared stamps cf-connecting-ip on every request; +// demux forwards it upstream so the mints' per-IP rate limiter buckets by the +// real client instead of lumping all of mint.coinos.io into demux's socket IP. +// Partner hosts that funnel many end users through one IP (lightning-address +// aggregators etc.) can be listed in <LOG_DIR>/whitelist.json: +// { "ips": ["1.2.3.4", "2600:..."], "pool": 10 } +// Their bucket key rotates across <pool> synthetic keys, so their effective +// allowance is pool x the mint's per-IP limit. Reloaded every 60s, no restart. +// +// Env: NEW_MINT (http://mint:3338), OLD_MINT (http://mint-old:3338), +// LOG_DIR (/demux), PORT (3341) +import { createHash } from "crypto"; +import { appendFileSync, mkdirSync, readFileSync, existsSync, writeFileSync } from "fs"; + +const NEW = process.env.NEW_MINT || "http://mint:3338"; +const OLD = process.env.OLD_MINT || "http://mint-old:3338"; +const DIR = process.env.LOG_DIR || "/demux"; +const PORT = Number(process.env.PORT || 3341); +mkdirSync(DIR, { recursive: true }); + +const CORS: Record<string, string> = { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, POST, OPTIONS", + "access-control-allow-headers": "*", + "access-control-expose-headers": "*", +}; +const J = { ...CORS, "content-type": "application/json" }; +const log = (m: string) => { try { appendFileSync(`${DIR}/demux.log`, `${new Date().toISOString()} ${m}\n`); } catch {} console.log("demux:", m); }; +const parse = (s: string) => { try { return JSON.parse(s); } catch { return null; } }; + +// --- keyset ownership, refreshed hourly --- +let newKeysets = new Set<string>(); +let oldKeysets = new Set<string>(); +// Our own hourly routing refresh — forwarded as loopback so the mints' per-IP +// limiter (which exempts 127.0.0.1) doesn't count it against the demux socket. +const lastKeysets: { NEW?: any[]; OLD?: any[] } = {}; +async function fetchKeysets(base: string): Promise<any[]> { + try { + const r = await fetch(`${base}/v1/keysets`, { headers: { "cf-connecting-ip": "127.0.0.1" } }); + if (!r.ok) throw new Error(`status ${r.status}`); + const j: any = await r.json(); return (j.keysets || []) as any[]; + } + catch (e: any) { console.error("keyset fetch failed", base, e.message); return []; } +} +async function refreshKeysets() { + const [n, o] = await Promise.all([fetchKeysets(NEW), fetchKeysets(OLD)]); + // This refresh is exempt from the mints' per-IP limiter (loopback key), so it + // also seeds the last-good lists the client-facing /v1/keysets falls back on + // when a wallet's own fetch is rate-limited — otherwise a demux restart could + // serve a list with no old keysets in it until some client fetch succeeded. + if (n.length) { newKeysets = new Set(n.map((k: any) => String(k.id))); lastKeysets.NEW = n; } + if (o.length) { oldKeysets = new Set(o.map((k: any) => String(k.id))); lastKeysets.OLD = o; } +} +await refreshKeysets(); +setInterval(refreshKeysets, 3600_000); +// Empty routing tables are dangerous: with no old keysets every old-ecash melt +// looks new and goes to the wrong mint. A restart that races the mints coming +// up (a compose restart of both) leaves exactly that, so retry every 15s until +// both sides are known instead of waiting out the hour. +const warmup = setInterval(async () => { + if (newKeysets.size && oldKeysets.size) return clearInterval(warmup); + await refreshKeysets(); + if (newKeysets.size && oldKeysets.size) { clearInterval(warmup); log(`keysets warmed: new=[${[...newKeysets]}] old=[${[...oldKeysets]}]`); } +}, 15_000); + +// --- melt quote -> invoice memory (so an OLD-keyset melt can be re-quoted on OLD) --- +const QFILE = `${DIR}/meltquotes.json`; +type QInfo = { invoice: string; unit: string; ts: number }; +let quoteMem: Record<string, QInfo> = {}; +if (existsSync(QFILE)) quoteMem = parse(readFileSync(QFILE, "utf8")) || {}; +const QTTL = 2 * 3600_000; +function rememberQuote(id: string, info: QInfo) { + quoteMem[id] = info; + const cut = Date.now() - QTTL; + for (const k of Object.keys(quoteMem)) if ((quoteMem[k].ts || 0) < cut) delete quoteMem[k]; + try { writeFileSync(QFILE, JSON.stringify(quoteMem)); } catch {} +} + +// --- NEW-quote-id -> OLD-quote-id map for melts executed on OLD, so status +// polls (GET /v1/melt/quote/bolt11/<id>) can be routed to the mint that ran +// the melt. Kept long: a pending lightning payment can be polled much later. +const MFILE = `${DIR}/oldmelts.json`; +type MInfo = { old: string; ts: number }; +let oldMelts: Record<string, MInfo> = {}; +if (existsSync(MFILE)) oldMelts = parse(readFileSync(MFILE, "utf8")) || {}; +const MTTL = 7 * 24 * 3600_000; +function rememberOldMelt(newId: string, oldId: string) { + oldMelts[newId] = { old: oldId, ts: Date.now() }; + const cut = Date.now() - MTTL; + for (const k of Object.keys(oldMelts)) if ((oldMelts[k].ts || 0) < cut) delete oldMelts[k]; + try { writeFileSync(MFILE, JSON.stringify(oldMelts)); } catch {} +} + +// --- dropped old inputs (mixed melts) --- +// The wallet treats these as spent; the mint never took them, so they sit +// UNSPENT on OLD forever. Keep an accounting trail: the Y (hash of the secret) +// identifies a proof to the mint without storing the bearer secret itself. +const DFILE = `${DIR}/dropped-old-inputs.jsonl`; +function recordDropped(proofs: any[], ctx: { ip: string; quote: string }) { + for (const p of proofs) { + try { + const y = createHash("sha256").update(String(p?.secret ?? "")).digest("hex"); + appendFileSync(DFILE, JSON.stringify({ + ts: new Date().toISOString(), ...ctx, + id: p?.id, amount: p?.amount, secret_sha256: y, dleq: !!p?.dleq, + }) + "\n"); + } catch {} + } +} + +// --- client-ip forwarding + partner whitelist --- +const WFILE = `${DIR}/whitelist.json`; +let wlIps = new Set<string>(); +let wlPool = 10; +function loadWhitelist() { + if (!existsSync(WFILE)) return; + const j = parse(readFileSync(WFILE, "utf8")); + if (!j) return; + if (Array.isArray(j.ips)) wlIps = new Set(j.ips.map((s: any) => String(s).toLowerCase())); + if (Number.isFinite(j.pool) && j.pool >= 1) wlPool = Math.floor(j.pool); +} +loadWhitelist(); +setInterval(loadWhitelist, 60_000); + +const wlCounters: Record<string, number> = {}; +function bucketKey(req: Request): string { + const ip = ( + req.headers.get("cf-connecting-ip") || + (req.headers.get("x-forwarded-for") || "").split(",")[0].trim() + ).toLowerCase(); + if (!ip || !wlIps.has(ip)) return ip; + const n = (wlCounters[ip] = ((wlCounters[ip] || 0) + 1) % wlPool); + return `${ip}#${n}`; +} +// headers for an upstream fetch: json content-type when a body is sent, and +// the client bucket key as cf-connecting-ip (checked first by the mint's +// rate limiter; the raw socket IP is used when we have nothing to forward) +const fwd = (key: string, hasBody = false): Record<string, string> => ({ + ...(hasBody ? { "content-type": "application/json" } : {}), + ...(key ? { "cf-connecting-ip": key } : {}), +}); + +console.log(`mintdemux on :${PORT} NEW=${NEW} OLD=${OLD} new=[${[...newKeysets]}] old=[${[...oldKeysets]}] whitelist=[${[...wlIps]}] pool=${wlPool}`); + +const keysetsInBody = (body: any): string[] => { + const ids = new Set<string>(); + for (const arr of [body?.inputs, body?.proofs]) if (Array.isArray(arr)) for (const it of arr) { const id = String(it?.id ?? ""); if (id) ids.add(id); } + return [...ids]; +}; +const respond = (upstream: Response, text: string) => + new Response(text, { status: upstream.status, headers: { ...CORS, "content-type": upstream.headers.get("content-type") || "application/json" } }); + +async function proxy(base: string, method: string, path: string, body?: ArrayBuffer | string, headers?: Record<string, string>): Promise<Response> { + const r = await fetch(`${base}${path}`, { method, headers, body: body as any }); + return respond(r, await r.text()); +} + +const WSNEW = NEW.replace(/^http/, "ws"); +type WsData = { key: string; up?: WebSocket; q: (string | Uint8Array)[] }; + +Bun.serve({ + port: PORT, hostname: "0.0.0.0", idleTimeout: 0, + async fetch(req, server) { + const url = new URL(req.url); + const path = url.pathname; + const ps = path + url.search; + if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS }); + const key = bucketKey(req); + + if (path === "/v1/ws") { + if (server.upgrade(req, { data: { key, q: [] } as WsData })) return undefined as any; + return new Response(JSON.stringify({ detail: "websocket upgrade required" }), { status: 400, headers: J }); + } + + // --- GET routes --- + if (req.method === "GET") { + if (path === "/v1/keysets") { + // Forward the client's bucket key: without it every wallet's keyset + // fetch counted against the demux's own socket IP at both mints, the + // shared 60/min bucket tripped, and the swallowed 429s came back to + // every wallet as an empty keyset list. A failed upstream now serves + // that mint's last-good list instead. + const get = async (base: string) => { + const r = await fetch(`${base}/v1/keysets`, { headers: fwd(key) }); + if (!r.ok) throw new Error(`status ${r.status}`); + return (await r.json() as any).keysets as any[]; + }; + const [rn, ro] = await Promise.all([ + get(NEW).then(k => (lastKeysets.NEW = k), e => { console.error("keysets NEW", e.message); return lastKeysets.NEW; }), + get(OLD).then(k => (lastKeysets.OLD = k), e => { console.error("keysets OLD", e.message); return lastKeysets.OLD; }), + ]); + if (!rn && !ro) return new Response(JSON.stringify({ detail: "keysets unavailable" }), { status: 503, headers: J }); + const seen = new Set<string>(); const keysets: any[] = []; + // NEW mint keysets keep their flags. OLD (recovery) keysets are forced + // active:false — they're redeem/melt-only, so wallets never mint or swap + // into them. Prevents advertising two active "sat" keysets at + // mint.coinos.io (the old mint is panic-locked anyway; this is the + // client-facing view). + for (const k of (rn || [])) { const id = String(k.id); if (!seen.has(id)) { seen.add(id); keysets.push(k); } } + for (const k of (ro || [])) { const id = String(k.id); if (!seen.has(id)) { seen.add(id); keysets.push({ ...k, active: false }); } } + return new Response(JSON.stringify({ keysets }), { headers: J }); + } + const km = path.match(/^\/v1\/keys\/(.+)$/); + if (km) return proxy(oldKeysets.has(km[1]) ? OLD : NEW, "GET", ps, undefined, fwd(key)); + const qm = path.match(/^\/v1\/melt\/quote\/bolt11\/([^/]+)$/); + if (qm && oldMelts[qm[1]]) { + const r = await fetch(`${OLD}/v1/melt/quote/bolt11/${oldMelts[qm[1]].old}`, { headers: fwd(key) }); + const txt = await r.text(); const j = parse(txt); + if (j?.quote) j.quote = qm[1]; // caller knows its own id, not OLD's + return new Response(j ? JSON.stringify(j) : txt, { status: r.status, headers: J }); + } + return proxy(NEW, "GET", ps, undefined, fwd(key)); // /v1/info, /v1/keys, anything else GET + } + + // --- POST routes --- + const raw = await req.arrayBuffer(); + const body = parse(new TextDecoder().decode(raw)); + + if (path === "/v1/checkstate") { + const [rn, ro] = await Promise.all([ + fetch(`${NEW}/v1/checkstate`, { method: "POST", headers: fwd(key, true), body: raw }).then(r => r.json()).catch(() => ({ states: [] })), + fetch(`${OLD}/v1/checkstate`, { method: "POST", headers: fwd(key, true), body: raw }).then(r => r.json()).catch(() => ({ states: [] })), + ]); + const byY: Record<string, any> = {}; + for (const s of [...((rn as any).states || []), ...((ro as any).states || [])]) { const cur = byY[s.Y]; if (!cur || s.state === "SPENT") byY[s.Y] = s; } + return new Response(JSON.stringify({ states: Object.values(byY) }), { headers: J }); + } + + if (path === "/v1/melt/quote/bolt11") { + const r = await fetch(`${NEW}/v1/melt/quote/bolt11`, { method: "POST", headers: fwd(key, true), body: raw }); + const txt = await r.text(); const j = parse(txt); + if (r.ok && j?.quote && body?.request) rememberQuote(j.quote, { invoice: body.request, unit: body.unit || "sat", ts: Date.now() }); + return respond(r, txt); + } + + if (path === "/v1/melt/bolt11") { + const ids = keysetsInBody(body); + const hasOld = ids.some(id => oldKeysets.has(id)); + const hasNew = ids.some(id => newKeysets.has(id)); + // Mixed selection: a wallet holding leftovers of both eras picks some of + // each, and the two mints can't co-sign one melt. Rejecting it (the old + // 12003) left such wallets permanently stuck — the auditor's among them, + // which is why mint.coinos.io reads "offline". Drop the OLD inputs and + // melt the NEW ones alone: the old proofs stay UNSPENT at the old mint, + // but the wallet will consider them spent, so record what was dropped. + // Old ecash is only redeemable through the panic-mode recovery path + // anyway (it needs DLEQ + blinding factor, which these inputs lack). + if (hasOld && hasNew) { + const ip = req.headers.get("cf-connecting-ip") || ""; + const keep = (arr: any[]) => arr.filter((p: any) => !oldKeysets.has(String(p?.id))); + const dropped = (body.inputs || []).filter((p: any) => oldKeysets.has(String(p?.id))); + recordDropped(dropped, { ip, quote: String(body.quote || "") }); + const merged = { ...body, inputs: keep(body.inputs || []) }; + const r = await proxy(NEW, "POST", ps, JSON.stringify(merged), fwd(key, true)); + log(`melt mixed: dropped ${dropped.length} old input(s) worth ${dropped.reduce((a: number, p: any) => a + (p?.amount || 0), 0)} sat, melted ${merged.inputs.length} new on NEW status=${r.status} ip=${ip}`); + return r; + } + if (hasOld) { + const ip = req.headers.get("cf-connecting-ip") || ""; + const info = quoteMem[body.quote]; + if (!info) { log(`melt->OLD refused (quote unknown/expired) keysets=${ids.join(",")} ip=${ip}`); return new Response(JSON.stringify({ detail: "Melt quote expired or unknown for old-ecash recovery; request a new melt quote and try again.", code: 12004 }), { status: 400, headers: J }); } + // re-quote on OLD for the same invoice, then execute there (panic mode gates it) + const oq = await fetch(`${OLD}/v1/melt/quote/bolt11`, { method: "POST", headers: fwd(key, true), body: JSON.stringify({ request: info.invoice, unit: info.unit }) }); + const oqt = await oq.text(); const oqj = parse(oqt); + if (!oq.ok || !oqj?.quote) { log(`OLD re-quote failed for melt: ${oqt.slice(0, 120)}`); return respond(oq, oqt); } + rememberOldMelt(String(body.quote), String(oqj.quote)); + // Strip change outputs: wallets blind them to the NEW active keyset, + // which OLD doesn't know and rejects before even reading the proofs + // ("keyset id unknown"). Recovery melts forfeit fee-return change. + const { outputs, ...bodyNoChange } = body; + const r = await fetch(`${OLD}/v1/melt/bolt11`, { method: "POST", headers: fwd(key, true), body: JSON.stringify({ ...bodyNoChange, quote: oqj.quote }) }); + const txt = await r.text(); const j = parse(txt); + log(`melt->OLD(recovery) keysets=${ids.join(",")} status=${r.status} ip=${ip}${outputs ? " outputs-stripped" : ""}`); + if (j?.quote) { j.quote = String(body.quote); return new Response(JSON.stringify(j), { status: r.status, headers: J }); } + return respond(r, txt); + } + // new (or unknown) keysets -> NEW + return proxy(NEW, "POST", ps, raw, fwd(key, true)); + } + + // swap, mint, mint/quote, restore, everything else -> NEW (normal ops) + return proxy(NEW, "POST", ps, raw, fwd(key, true)); + }, + // --- /v1/ws: pipe each client websocket to its own upstream connection on + // NEW, forwarding the bucket key so the mint's ws limiter sees the client --- + websocket: { + open(ws) { + const data = ws.data as WsData; + const up = new WebSocket(`${WSNEW}/v1/ws`, { headers: fwd(data.key) } as any); + data.up = up; + up.onopen = () => { for (const m of data.q) up.send(m); data.q = []; }; + up.onmessage = (e) => { try { ws.send(e.data as any); } catch {} }; + up.onclose = () => { try { ws.close(); } catch {} }; + up.onerror = () => { try { ws.close(); } catch {} }; + }, + message(ws, msg) { + const data = ws.data as WsData; + if (data.up && data.up.readyState === 1) data.up.send(msg as any); + else data.q.push(msg as any); + }, + close(ws) { + try { (ws.data as WsData).up?.close(); } catch {} + }, + }, +}); |
