aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-07 23:48:18 -0700
committerAdam Soltys <[email protected]>2026-08-07 23:48:26 -0700
commitdea17487cf4fe63d029b760749e8675183042d3c (patch)
tree7fb271a61585bc4563c820fffde9129b4f698210
parent8fd55bea7d657a4b0f792cc70a5efc8ac1133bfb (diff)
downloadcoinos-server-dea17487cf4fe63d029b760749e8675183042d3c.tar.gz
coinos-server-dea17487cf4fe63d029b760749e8675183042d3c.zip
mintdemux: fix old-ecash recovery melts
Melts routed to the retired mint were failing before their proofs were even read: wallets blind their fee-return change outputs to the current active keyset, which the old mint doesn't know and rejects outright. Strip change outputs on that path (recovery melts forfeit change). Also map the caller's melt quote id to the old mint's re-quoted id so melt responses and GET status polls report against the id the wallet knows, and log the connecting IP on the recovery path. Co-Authored-By: Claude Fable 5 <[email protected]>
-rw-r--r--mintdemux.ts43
1 files changed, 38 insertions, 5 deletions
diff --git a/mintdemux.ts b/mintdemux.ts
index b205a39c..b0894579 100644
--- a/mintdemux.ts
+++ b/mintdemux.ts
@@ -11,7 +11,12 @@
// - /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)
// - everything else (swap, mint, restore, mint quote) -> NEW
// The OLD mint also stands alone at mintrecovery.coinos.io (direct).
@@ -64,6 +69,21 @@ function rememberQuote(id: string, info: QInfo) {
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 {}
+}
+
console.log(`mintdemux on :${PORT} NEW=${NEW} OLD=${OLD} new=[${[...newKeysets]}] old=[${[...oldKeysets]}]`);
const keysetsInBody = (body: any): string[] => {
@@ -103,6 +123,13 @@ Bun.serve({
}
const km = path.match(/^\/v1\/keys\/(.+)$/);
if (km) return proxy(oldKeysets.has(km[1]) ? OLD : NEW, "GET", ps);
+ 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}`);
+ 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); // /v1/info, /v1/keys, anything else GET
}
@@ -134,16 +161,22 @@ Bun.serve({
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) 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 });
+ 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: { "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}`);
+ 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: { "content-type": "application/json" }, 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