aboutsummaryrefslogtreecommitdiffstats
path: root/mintdemux.ts
blob: 3cf05144634e1baf8e3cce4d1ae8bfb3fffd1857 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
// 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 { 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>();
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() {
  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);

// --- 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 {}
}

// --- 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") {
        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[] = [];
        // 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 as any).keysets || [])) { const id = String(k.id); if (!seen.has(id)) { seen.add(id); keysets.push(k); } }
        for (const k of ((ro as any).keysets || [])) { 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));
      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 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 {}
    },
  },
});