aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore4
-rw-r--r--meltguard.ts80
-rw-r--r--mintdemux.ts95
3 files changed, 179 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
index b6da50d9..d4537595 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,3 +21,7 @@ printer
failedlogins.txt
.claude
req*
+
+# local backups (may contain secrets — never commit)
+*.bak
+*.bak-*
diff --git a/meltguard.ts b/meltguard.ts
new file mode 100644
index 00000000..faf9fa65
--- /dev/null
+++ b/meltguard.ts
@@ -0,0 +1,80 @@
+// meltguard — out-of-band spend limiter for the panic-mode mint.
+//
+// Sits INLINE between the mint and cl's CLNRest: the mint is pointed at
+// MINT_CLNREST_URL=http://meltguard:3011 instead of http://cl:3010. Every melt
+// is a POST /v1/pay; the guard decodes the invoice amount (via cl's own /v1/decode,
+// so no hand-rolled bolt11 parsing) and enforces two caps the mint cannot bypass —
+// even if panic mode has a bug or the mint is compromised, it physically cannot
+// move money except through this guard:
+//
+// 1. PER-MELT CAP — reject any single pay over MELT_MAX_SAT (default 1,000,000).
+// 2. CUMULATIVE FREEZE — track total successfully paid; once it would exceed
+// MELT_CUM_MAX_SAT (default 3,000,000), write a STICKY freeze flag and reject
+// ALL further pays until an operator reviews and clears it.
+//
+// State lives in plain files OUTSIDE any db (like the nobal failsafe), so a db
+// compromise can't move them and a restart can't forget them.
+//
+// Everything that is not POST /v1/pay is proxied through untouched, so mint
+// startup (getinfo, listinvoices, waitanyinvoice, invoice, listpays…) works normally.
+//
+// Env: CL_URL (http://cl:3010), MELT_MAX_SAT, MELT_CUM_MAX_SAT, GUARD_DIR (/guard),
+// PORT (3011). Run: GUARD_DIR=./guard bun meltguard.ts
+import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync } from "fs";
+
+const CL = process.env.CL_URL || "http://cl:3010";
+const PER = Number(process.env.MELT_MAX_SAT || 1_000_000);
+const CUM = Number(process.env.MELT_CUM_MAX_SAT || 3_000_000);
+const DIR = process.env.GUARD_DIR || "/guard";
+const PORT = Number(process.env.PORT || 3011);
+mkdirSync(DIR, { recursive: true });
+const CUM_FILE = `${DIR}/cumulative_sat`;
+const FROZEN_FILE = `${DIR}/FROZEN`;
+const LOG = `${DIR}/meltguard.log`;
+
+const readCum = () => (existsSync(CUM_FILE) ? Number(readFileSync(CUM_FILE, "utf8").trim()) || 0 : 0);
+const writeCum = (n: number) => writeFileSync(CUM_FILE, String(n));
+const isFrozen = () => existsSync(FROZEN_FILE);
+const log = (m: string) => { try { appendFileSync(LOG, `${new Date().toISOString()} ${m}\n`); } catch {} console.error("meltguard:", m); };
+const freeze = (why: string) => { writeFileSync(FROZEN_FILE, why + "\n"); log(`FROZEN — ${why} — operator must review and clear ${FROZEN_FILE}`); };
+const err = (status: number, message: string) => new Response(JSON.stringify({ error: message }), { status, headers: { "content-type": "application/json" } });
+
+async function decodeSat(bolt11: string, rune: string | null): Promise<number | null> {
+ try {
+ const r = await fetch(`${CL}/v1/decode`, { method: "POST", headers: { "content-type": "application/json", ...(rune ? { Rune: rune } : {}) }, body: JSON.stringify({ string: bolt11 }) });
+ const j: any = await r.json();
+ if (j.amount_msat != null) return Math.ceil(Number(j.amount_msat) / 1000);
+ return null; // amountless invoice — no embedded amount to cap on; treat as undecidable
+ } catch { return null; }
+}
+
+console.log(`meltguard on :${PORT} -> ${CL} | per-melt<=${PER} sat, cumulative freeze at ${CUM} sat | state ${DIR} | frozen=${isFrozen()} cum=${readCum()}`);
+
+Bun.serve({
+ port: PORT, hostname: "0.0.0.0", idleTimeout: 0,
+ async fetch(req) {
+ const url = new URL(req.url);
+ const rune = req.headers.get("Rune");
+ const isPay = req.method === "POST" && url.pathname === "/v1/pay";
+
+ if (isPay) {
+ if (isFrozen()) return err(403, "meltguard: FROZEN — operator review required");
+ const body: any = await req.json().catch(() => ({}));
+ const amt = await decodeSat(body.bolt11 || "", rune);
+ if (amt == null) return err(400, "meltguard: could not determine invoice amount — refusing to forward");
+ if (amt > PER) { log(`REJECT per-melt ${amt} > ${PER}`); return err(403, `meltguard: per-melt cap ${PER} sat exceeded (${amt})`); }
+ const cum = readCum();
+ if (cum + amt > CUM) { freeze(`cumulative ${cum}+${amt} > ${CUM}`); return err(403, `meltguard: cumulative cap ${CUM} sat — FROZEN, operator review required`); }
+ // forward the pay; count toward cumulative only if cl reports success
+ const resp = await fetch(`${CL}/v1/pay`, { method: "POST", headers: { "content-type": "application/json", ...(rune ? { Rune: rune } : {}) }, body: JSON.stringify(body) });
+ const text = await resp.text();
+ if (resp.ok) { writeCum(cum + amt); log(`PAID ${amt} sat (cumulative ${cum + amt}/${CUM})`); }
+ return new Response(text, { status: resp.status, headers: { "content-type": "application/json" } });
+ }
+
+ // transparent proxy for all other CLNRest calls
+ const body = req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
+ const resp = await fetch(`${CL}${url.pathname}${url.search}`, { method: req.method, headers: { ...(req.headers.get("content-type") ? { "content-type": req.headers.get("content-type")! } : {}), ...(rune ? { Rune: rune } : {}) }, body });
+ return new Response(await resp.arrayBuffer(), { status: resp.status, headers: { "content-type": resp.headers.get("content-type") || "application/json" } });
+ },
+});
diff --git a/mintdemux.ts b/mintdemux.ts
new file mode 100644
index 00000000..79e0fdc3
--- /dev/null
+++ b/mintdemux.ts
@@ -0,0 +1,95 @@
+// mintdemux — sits in front of the OLD (panic-mode) mint at 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.
+//
+// Everything else is proxied through to the old mint untouched.
+//
+// Env: OLD_MINT (http://mint-old:3338), NEW_MINT (http://mint:3338),
+// LOG_DIR (/demux), PORT (3341)
+import { appendFileSync, mkdirSync } from "fs";
+
+const OLD = process.env.OLD_MINT || "http://mint-old:3338";
+const NEW = process.env.NEW_MINT || "http://mint: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)";
+
+// keyset ids of the NEW mint; refreshed hourly in case it rotates
+let newKeysets = new Set<string>();
+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);
+ }
+}
+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);
+};
+
+// endpoints whose bodies carry proof/output keyset ids worth inspecting
+const INSPECT = new Set(["/v1/swap", "/v1/melt/bolt11", "/v1/restore", "/v1/mint/bolt11"]);
+
+function findNewMintIds(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);
+ }
+ }
+ return [...ids];
+}
+
+Bun.serve({
+ port: PORT, hostname: "0.0.0.0", idleTimeout: 0,
+ async fetch(req) {
+ const url = new URL(req.url);
+
+ 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" } });
+ }
+ 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" } });
+ }
+
+ // 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" } });
+ },
+});