diff options
| author | Adam Soltys <[email protected]> | 2026-07-31 01:04:16 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-31 01:04:16 -0700 |
| commit | ec42b8be8ec4ff697e72f765e023db9741f7adef (patch) | |
| tree | 8663906da974657780ba726b48d3c1e7052fec12 | |
| parent | 60f9c91b61ce069fb472369eb6c16bf1a984a3d6 (diff) | |
| download | coinos-server-ec42b8be8ec4ff697e72f765e023db9741f7adef.tar.gz coinos-server-ec42b8be8ec4ff697e72f765e023db9741f7adef.zip | |
mintdemux: transparent two-mint proxy for mint.coinos.io
Front the current mint (fresh keys, normal ops) as primary, and transparently
route pre-shutdown ecash melts to the retired panic-mode mint so wallets can melt
old ecash at mint.coinos.io without changing their URL:
- /v1/keysets is the union of both mints' keysets
- /v1/keys/<id> and melt execute route by the keyset owner
- a melt quote is taken on the primary and the invoice remembered, so an
old-keyset melt is re-quoted and executed on the recovery mint (where panic
mode gates blacklisted funds)
- /v1/checkstate is merged across both; CORS added for web wallets
The recovery mint also stands alone at mintrecovery.coinos.io.
| -rw-r--r-- | mintdemux.ts | 181 |
1 files changed, 118 insertions, 63 deletions
diff --git a/mintdemux.ts b/mintdemux.ts index 79e0fdc3..4ae1a6fd 100644 --- a/mintdemux.ts +++ b/mintdemux.ts @@ -1,95 +1,150 @@ -// mintdemux — sits in front of the OLD (panic-mode) mint at mint.coinos.io. +// mintdemux — transparent two-mint front for mint.coinos.io. // -// After the 2026-07-30 hostname swap, wallets that minted at the NEW mint during -// its ~1 day at mint.coinos.io still point here. Their proofs reference the new -// mint's keysets, which the old mint doesn't know — instead of a cryptic -// "keyset unknown" they get a clear tell-them-where-to-go error, and each hit is -// logged so we can size the cohort. +// 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); +// everything else executes on NEW +// - /v1/checkstate -> both mints, merged (spent if either says spent) +// - everything else (swap, mint, restore, mint quote) -> NEW +// The OLD mint also stands alone at mintrecovery.coinos.io (direct). // -// Everything else is proxied through to the old mint untouched. -// -// Env: OLD_MINT (http://mint-old:3338), NEW_MINT (http://mint:3338), +// Env: NEW_MINT (http://mint:3338), OLD_MINT (http://mint-old:3338), // LOG_DIR (/demux), PORT (3341) -import { appendFileSync, mkdirSync } from "fs"; +import { appendFileSync, mkdirSync, readFileSync, existsSync, writeFileSync } from "fs"; -const OLD = process.env.OLD_MINT || "http://mint-old:3338"; 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 REDIRECT_DETAIL = - "This ecash is from the current coinos mint, not the old one. " + - "Change this mint's URL in your wallet to https://newmint.coinos.io and try again. " + - "(mint.coinos.io now serves only pre-2026-07-20 ecash recovery)"; +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 ids of the NEW mint; refreshed hourly in case it rotates +// --- keyset ownership, refreshed hourly --- let newKeysets = new Set<string>(); +let oldKeysets = new Set<string>(); +async function fetchKeysets(base: string): Promise<string[]> { + try { const r = await fetch(`${base}/v1/keysets`); const j: any = await r.json(); return (j.keysets || []).map((k: any) => String(k.id)); } + catch (e: any) { console.error("keyset fetch failed", base, e.message); return []; } +} async function refreshKeysets() { - try { - const r = await fetch(`${NEW}/v1/keysets`); - const j: any = await r.json(); - const ids = (j.keysets || []).map((k: any) => String(k.id)); - if (ids.length) newKeysets = new Set(ids); - } catch (e: any) { - console.error("keyset refresh failed:", e.message); - } + const [n, o] = await Promise.all([fetchKeysets(NEW), fetchKeysets(OLD)]); + if (n.length) newKeysets = new Set(n); + if (o.length) oldKeysets = new Set(o); } await refreshKeysets(); setInterval(refreshKeysets, 3600_000); -console.log(`mintdemux on :${PORT} -> ${OLD} | new-mint keysets: [${[...newKeysets].join(", ")}]`); -const log = (m: string) => { - try { appendFileSync(`${DIR}/newmint-hits.log`, `${new Date().toISOString()} ${m}\n`); } catch {} - console.log("demux:", m); -}; +// --- 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 {} +} -// endpoints whose bodies carry proof/output keyset ids worth inspecting -const INSPECT = new Set(["/v1/swap", "/v1/melt/bolt11", "/v1/restore", "/v1/mint/bolt11"]); +console.log(`mintdemux on :${PORT} NEW=${NEW} OLD=${OLD} new=[${[...newKeysets]}] old=[${[...oldKeysets]}]`); -function findNewMintIds(body: any): string[] { +const keysetsInBody = (body: any): string[] => { const ids = new Set<string>(); - for (const arr of [body?.inputs, body?.outputs, body?.proofs]) { - if (!Array.isArray(arr)) continue; - for (const it of arr) { - const id = String(it?.id ?? ""); - if (newKeysets.has(id)) ids.add(id); - } - } + 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): Promise<Response> { + const r = await fetch(`${base}${path}`, { method, headers: body != null ? { "content-type": "application/json" } : undefined, body: body as any }); + return respond(r, await r.text()); } Bun.serve({ port: PORT, hostname: "0.0.0.0", idleTimeout: 0, async fetch(req) { 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 }); + + // --- GET routes --- + if (req.method === "GET") { + if (path === "/v1/keysets") { + const [rn, ro] = await Promise.all([fetch(`${NEW}/v1/keysets`).then(r => r.json()).catch(() => ({})), fetch(`${OLD}/v1/keysets`).then(r => r.json()).catch(() => ({}))]); + const seen = new Set<string>(); const keysets: any[] = []; + for (const k of [...((rn as any).keysets || []), ...((ro as any).keysets || [])]) { const id = String(k.id); if (!seen.has(id)) { seen.add(id); keysets.push(k); } } + 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); + return proxy(NEW, "GET", ps); // /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: { "content-type": "application/json" }, body: raw }).then(r => r.json()).catch(() => ({ states: [] })), + fetch(`${OLD}/v1/checkstate`, { method: "POST", headers: { "content-type": "application/json" }, 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: { "content-type": "application/json" }, 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 (req.method === "POST" && INSPECT.has(url.pathname)) { - const raw = await req.arrayBuffer(); - let body: any = {}; - try { body = JSON.parse(new TextDecoder().decode(raw)); } catch {} - const hit = findNewMintIds(body); - if (hit.length) { - const n = (body.inputs || body.proofs || body.outputs || []).length; - log(`${url.pathname} blocked: ${n} items on new-mint keyset(s) ${hit.join(",")} ua="${req.headers.get("user-agent") || "?"}"`); - return new Response(JSON.stringify({ detail: REDIRECT_DETAIL, code: 12002 }), - { status: 400, headers: { "content-type": "application/json" } }); + 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)); + if (hasOld && hasNew) + return new Response(JSON.stringify({ detail: "Cannot melt old and new ecash in one operation; melt them separately.", code: 12003 }), { status: 400, headers: J }); + if (hasOld) { + const info = quoteMem[body.quote]; + if (!info) 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: { "content-type": "application/json" }, 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); } + const execBody = JSON.stringify({ ...body, quote: oqj.quote }); + const r = await fetch(`${OLD}/v1/melt/bolt11`, { method: "POST", headers: { "content-type": "application/json" }, body: execBody }); + const txt = await r.text(); + log(`melt->OLD(recovery) keysets=${ids.join(",")} status=${r.status}`); + return respond(r, txt); } - const resp = await fetch(`${OLD}${url.pathname}${url.search}`, { - method: "POST", - headers: { "content-type": req.headers.get("content-type") || "application/json" }, - body: raw, - }); - return new Response(await resp.arrayBuffer(), { status: resp.status, headers: { "content-type": resp.headers.get("content-type") || "application/json" } }); + // new (or unknown) keysets -> NEW + return proxy(NEW, "POST", ps, raw); } - // transparent proxy for everything else - const body = req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer(); - const resp = await fetch(`${OLD}${url.pathname}${url.search}`, { - method: req.method, - headers: { ...(req.headers.get("content-type") ? { "content-type": req.headers.get("content-type")! } : {}) }, - body, - }); - return new Response(await resp.arrayBuffer(), { status: resp.status, headers: { "content-type": resp.headers.get("content-type") || "application/json" } }); + // swap, mint, mint/quote, restore, everything else -> NEW (normal ops) + return proxy(NEW, "POST", ps, raw); }, }); |
