diff options
| author | Adam Soltys <[email protected]> | 2026-09-19 21:53:30 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-21 19:23:57 -0700 |
| commit | a0c892f080c767176deb1309d714deee32ac23bc (patch) | |
| tree | b7accf574aeed2867b7179b62f0ae1e31dc5c20c /mintdemux.ts | |
| parent | a2216320de0d6b4eb25cc699456d5d995f267590 (diff) | |
| download | coinos-server-a0c892f080c767176deb1309d714deee32ac23bc.tar.gz coinos-server-a0c892f080c767176deb1309d714deee32ac23bc.zip | |
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) <[email protected]>
Diffstat (limited to 'mintdemux.ts')
| -rw-r--r-- | mintdemux.ts | 61 |
1 files 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<string>(); let oldKeysets = new Set<string>(); // 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<string[]> { +const lastKeysets: { NEW?: any[]; OLD?: any[] } = {}; +async function fetchKeysets(base: string): Promise<any[]> { 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<string>(); @@ -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]; |
