From a0c892f080c767176deb1309d714deee32ac23bc Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 19 Sep 2026 21:53:30 -0700 Subject: mintdemux: melt the new half of a mixed selection instead of refusing A wallet holding leftovers of both eras selects proofs from each, and the two mints cannot co-sign one melt, so such wallets were stuck for good behind the "melt them separately" error. Drop the old inputs and melt the new ones. The old proofs stay unspent at the old mint while the wallet treats them as spent, so each one is recorded with its keyset, amount, secret hash and whether it carried DLEQ; they were only redeemable through the recovery path anyway. Also stop the routing tables from going empty. The hourly refresh is exempt from the mints' per-ip limiter, so it now seeds the last-good keyset lists the client-facing /v1/keysets falls back on, and a start that races the mints coming up retries every 15s rather than waiting out the hour -- an empty old list makes every old-ecash melt look new and routes it to the wrong mint. Co-Authored-By: Claude Opus 5 (1M context) --- mintdemux.ts | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/mintdemux.ts b/mintdemux.ts index 1f3f1f07..d961b05b 100644 --- a/mintdemux.ts +++ b/mintdemux.ts @@ -33,6 +33,7 @@ // // 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"; @@ -56,22 +57,35 @@ let newKeysets = new Set(); let oldKeysets = new Set(); // 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. -async function fetchKeysets(base: string): Promise { +const lastKeysets: { NEW?: any[]; OLD?: any[] } = {}; +async function fetchKeysets(base: string): Promise { 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 || []).map((k: any) => String(k.id)); + 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)]); - if (n.length) newKeysets = new Set(n); - if (o.length) oldKeysets = new Set(o); + // 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; } } -const lastKeysets: { NEW?: any[]; OLD?: any[] } = {}; 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`; @@ -101,6 +115,23 @@ function rememberOldMelt(newId: string, oldId: string) { 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(); @@ -230,8 +261,24 @@ Bun.serve({ 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 }); + // 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]; -- cgit v1.2.3