From c72fa2a5ccf1cc04073f9ff12606491971dadd36 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Wed, 16 Sep 2026 04:53:22 -0700 Subject: ecash: redeem tokens by melting to the user's own invoice Redeeming ecash is now a plain NUT-05 melt into a Lightning invoice owned by the redeeming user, credited by the regular lightning listener. Drop the house proof pool and its lock, the /mint and /melt routes, and the special-casing of a mint account in NWC and payment routing. Tokens are decoded against the mint's keyset list so short v1 ids resolve, and an empty keyset list is never cached. Co-Authored-By: Claude Fable 5.1 --- index.ts | 2 - lib/ecash.ts | 231 ++++++++++++++++++++++++-------------------------- lib/nwc.ts | 79 ++++++++--------- routes/ecash.ts | 145 +++++-------------------------- routes/info.ts | 4 - routes/payments.ts | 2 +- test/security.test.ts | 2 +- 7 files changed, 172 insertions(+), 293 deletions(-) diff --git a/index.ts b/index.ts index b50b92de..ef353486 100644 --- a/index.ts +++ b/index.ts @@ -186,8 +186,6 @@ app.get("/bolt12", fixBolt12); app.get("/cash/:id/:version", ecash.get); app.post("/cash", ecash.save); app.post("/claim", auth, ecash.claim); -app.post("/mint", auth, ecash.mint); -app.post("/melt", auth, ecash.melt); app.post("/ecash/:id", ecash.receive); app.get("/replay/:index", (req, res) => { diff --git a/lib/ecash.ts b/lib/ecash.ts index b1d8b7fb..e9e099c3 100644 --- a/lib/ecash.ts +++ b/lib/ecash.ts @@ -1,101 +1,44 @@ import config from "$config"; -import { db, g } from "$lib/db"; -import { lnb } from "$lib/ln"; +import { g } from "$lib/db"; +import { generate } from "$lib/invoices"; import { warn } from "$lib/logging"; import { safeGot } from "$lib/safe-fetch"; -import { fail, wait } from "$lib/utils"; +import { PaymentType } from "$lib/types"; +import { fail, getPayment, sleep, wait } from "$lib/utils"; import { CashuMint, CashuWallet, - MintQuoteState, PaymentRequest, PaymentRequestTransportType, getDecodedToken, - getEncodedToken, } from "@cashu/cashu-ts"; - const { URL } = process.env; const m = new CashuMint(config.mintUrl); -// The mint issues NUT-02 v1 keyset ids (`01…`, 33 bytes). cashu-ts 2.9.0 only -// derives v0 ids, so CashuWallet.getKeys() rejects every keyset with "Couldn't -// verify keyset ID" and receive()/send() can never run — ecash claims have been -// failing since the July keyset cutover. (4.x derives v1 ids, but not the way -// this nutshell build does, so upgrading wouldn't help either.) config.mintUrl -// is our own mint, so skip the derivation check: CashuMint.getKeys() returns -// the keys unverified, and a wallet constructed with them preloaded uses them -// as-is. Re-fetched periodically so a keyset rotation is picked up. -const KEYS_TTL = 10 * 60 * 1000; -let cached: { w: CashuWallet; keysets: any[]; at: number } | undefined; -const wallet = async () => { - if (cached && Date.now() - cached.at < KEYS_TTL) return cached; - const [{ keysets: keys }, { keysets }] = await Promise.all([ - m.getKeys(), - m.getKeySets(), - ]); - const w = new CashuWallet(m, { keys, keysets }); - cached = { w, keysets, at: Date.now() }; - return cached; +// Redeeming ecash is a plain NUT-05 melt: the proofs pay a Lightning invoice +// that belongs to the redeeming user, and the settlement is credited by the +// regular lightning listener like any other incoming payment. The mint's node +// is our own, so the melt is a self-payment on cl — nothing is held in a house +// wallet and no proofs are swapped or stored on our side. + +// cashu-ts 2.9.0 writes v1 keyset ids into V4 tokens in their 8-byte short form +// and refuses to decode a short id unless it's handed the mint's keysets to +// expand it against. config.mintUrl fronts every keyset we've ever issued, so +// its list is enough to decode any token of ours. +const KEYSETS_TTL = 10 * 60 * 1000; +let cached: { keysets: any[]; at: number } | undefined; +const keysets = async () => { + if (cached && Date.now() - cached.at < KEYSETS_TTL) return cached.keysets; + const { keysets } = await m.getKeySets(); + // Never cache an empty list: a token can't be decoded or fee-checked + // against it, and the next call should try the mint again. + if (!keysets?.length) fail("Mint keysets unavailable"); + cached = { keysets, at: Date.now() }; + return keysets; }; -// 2.9.0 writes v1 keyset ids into V4 tokens in their 8-byte short form, and -// refuses to decode a short id unless it's handed the mint's keysets to expand -// it against — so every token we encode (the house pool included) has to be -// decoded through here rather than with a bare getDecodedToken(). -const decode = async (token) => - getDecodedToken(token, (await wallet()).keysets); - -const enc = (proofs) => - getEncodedToken({ - mint: config.mintUrl, - proofs, - }); - -// The house wallet is a single encoded token under the `cash` key. g() JSON- -// parses whatever is stored there, and the key has been found holding a bare -// integer — getDecodedToken(number) then threw "n.startsWith is not a function" -// on every claim. Only trust the value when it decodes; otherwise start from -// no proofs and let the next write replace it. -export const pool = async () => { - const v = await g("cash"); - if (typeof v === "string") { - try { - return (await decode(v)).proofs; - } catch (e: any) { - warn("cash pool token unreadable, treating as empty:", e.message); - } - } else { - warn("cash pool key is not a token, treating as empty:", typeof v); - } - return []; -}; - -// s() is fire-and-forget; a dropped write here would silently strand the -// proofs we just swapped in, so await the set directly. -const setPool = (proofs) => db.set("cash", JSON.stringify(enc(proofs))); - -// Serialize every read-modify-write of the shared `cash` house-wallet token. The -// mint swap sits between reading the current proofs and writing the merged set, -// so two concurrent claim()/mint() calls would each read the same `current` and -// the later write would clobber the earlier — the earlier call's freshly-swapped -// proofs vanish from the stored token while both users stay credited (an -// insolvency leak). This mutex is in-process; if the server ever runs -// multi-process, move to a redis lock (WATCH/MULTI or SET NX). -let cashLock: Promise = Promise.resolve(); -const withCashLock = async (fn: () => Promise): Promise => { - const prev = cashLock; - let release: () => void = () => {}; - cashLock = new Promise((res) => { - release = res; - }); - await prev; - try { - return await fn(); - } finally { - release(); - } -}; +const decode = async (token) => getDecodedToken(token, await keysets()); // `mint` comes straight out of an attacker-supplied cashu token, and this is // the first thing we do with it — so fetching it with cashu-ts's plain fetch @@ -112,68 +55,112 @@ const ext = async (mint) => { return issuerPk !== ourPk; }; +const sum = (proofs) => proofs.reduce((a, p) => a + p.amount, 0); + +// NUT-02 input fee: ceil of the summed per-proof ppk. +const inputFee = (proofs, ks) => + Math.ceil( + proofs.reduce( + (a, p) => a + (ks.find((k) => k.id === p.id)?.input_fee_ppk || 0), + 0, + ) / 1000, + ); + +// nutshell's Lightning fee reserve: max(lightning_reserve_fee_min = 2 sat, +// lightning_fee_percent = 1%), rounded up to whole sats. The actual routing fee +// of a self-payment is zero, but without NUT-08 change outputs the mint keeps +// the reserve — it's our mint, so nothing leaves the system. +const reserve = (amount) => Math.max(2, Math.ceil(amount / 100)); + export async function get(id) { const token = await g(`cash:${id}`); return token; } -export async function claim(token) { - const { mint } = await decode(token); - +// Melt `token` into a Lightning invoice owned by `user`. With `invoice` (a +// NUT-18 payment-request invoice of type ecash) the Lightning invoice takes +// over that id, so whatever is waiting on it sees the payment land. Returns +// the amount the user receives: the token's value less the mint's input fee +// and Lightning fee reserve. +export async function redeem({ token, user, invoice = undefined, memo = undefined }) { + const { mint, proofs } = await decode(token); + if (!proofs?.length) fail("Token has no proofs"); if (await ext(mint)) fail("Unable to receive from other mints"); - const { w } = await wallet(); - return withCashLock(async () => { - const current = await pool(); - const rcvd = await w.receive(token); - await setPool([...current, ...rcvd]); - return rcvd.reduce((a, b) => a + b.amount, 0); + // Cheap pre-check so a spent token fails before an invoice is created for + // it (the melt would reject it anyway, but not before leaving an unpaid + // invoice on the user's account). + const states = await new CashuWallet(m).checkProofsStates(proofs); + if (states.some((p) => p.state !== "UNSPENT")) fail("Token already spent"); + + const ks = await keysets(); + const total = sum(proofs); + const fees = inputFee(proofs, ks); + let amount = total - fees - 2; + while (amount > 0 && amount + reserve(amount) + fees > total) amount--; + if (amount < 1) fail("Token is too small to redeem"); + + invoice = await generate({ + invoice: { + ...(invoice ?? {}), + id: invoice?.id, + amount, + // The invoice amount is exactly what the melt can cover: no tip on top, + // and no fiat re-pricing of it. + tip: null, + fiat: undefined, + bolt11: undefined, + bolt12: undefined, + memo: memo ?? invoice?.memo, + type: PaymentType.lightning, + }, + user, }); -} -export async function mint(amount) { - const { w } = await wallet(); - return withCashLock(async () => { - const proofs = await pool(); - const { send, keep } = await w.send(amount, proofs); - const rcvd = await w.receive(enc(send)); - await setPool(keep); - return enc(rcvd); - }); + const quote = await m.createMeltQuote({ unit: "sat", request: invoice.text }); + if (quote.amount + quote.fee_reserve + fees > total) + fail("Mint fee reserve exceeds the token's value"); + + let r = await m.melt({ quote: quote.quote, inputs: proofs }); + for (let i = 0; r.state === "PENDING" && i < 60; i++) { + await sleep(1000); + r = await m.checkMeltQuote(quote.quote); + } + if (r.state === "PENDING") + fail("Redemption is still settling; the funds will arrive once it does"); + if (r.state !== "PAID") fail("Redemption failed"); + + // The lightning listener credits the settlement. Give it a moment so the + // caller's payment list already shows it, but the credit doesn't depend on + // this wait. + try { + await wait(() => getPayment(invoice.hash), 50, 200); + } catch { + warn("ecash redeemed but credit not yet visible", user.username, invoice.hash); + } + + return amount; } export async function check(token) { const { mint, proofs } = await decode(token); - const total = proofs.reduce((a, b) => a + b.amount, 0); + const total = sum(proofs); const external = await ext(mint); - const { w } = await wallet(); + // Only ask our own mint about proof states; an external mint URL is never + // contacted with cashu-ts's unpinned fetch. let spent = 0; - for (const [i, p] of (await w.checkProofsStates(proofs)).entries()) { - if (p.state === "SPENT") spent += proofs[i].amount; + if (!external) { + const w = new CashuWallet(m); + for (const [i, p] of (await w.checkProofsStates(proofs)).entries()) { + if (p.state === "SPENT") spent += proofs[i].amount; + } } return { total, spent, mint, external }; } -export async function init(amount = 100000) { - try { - await new Promise((r) => setTimeout(r, 2000)); - const { w } = await wallet(); - const { quote, request } = await w.createMintQuote(amount); - await lnb.pay(request); - - await wait(async () => { - const { state } = await w.checkMintQuote(quote); - return state === MintQuoteState.PAID; - }); - - const proofs = await w.mintProofs(amount, quote); - await setPool(proofs); - } catch (e) {} -} - export function request(uuid, amount, memo) { const target = `${URL}/api/ecash/${uuid}`; diff --git a/lib/nwc.ts b/lib/nwc.ts index a15d6c86..5dba6e97 100644 --- a/lib/nwc.ts +++ b/lib/nwc.ts @@ -657,9 +657,12 @@ const handle = (method, params, ev, app, user) => if (payee === id) { const invoice = await getInvoice(pr); - const recipient = await g(`user:${invoice.uid}`); + const recipient = await g(`user:${invoice?.uid}`); - if (recipient?.username !== "mint") { + // No coinos invoice record means the bolt11 is on our node but not + // ours to settle internally (a mint quote, say) — pay it over + // Lightning below like any other. + if (invoice) { const { id: pid } = await sendInternal({ amount, invoice, @@ -823,48 +826,46 @@ const handle = (method, params, ev, app, user) => if (invoice) { const recipient = await g(`user:${invoice.uid}`); - if (recipient?.username !== "mint") { - // Internal settlement never builds a bolt12 invoice request, so the - // spec's deliver-or-reject rule for payer_note is met by handing the - // note to the recipient as the payment memo (bolt11 + payer_note was - // already rejected above) - const { id: pid } = await sendInternal({ - amount, - invoice, - memo: payer_note, - recipient, - sender: user, - maxTotal: remaining, - }); + // Internal settlement never builds a bolt12 invoice request, so the + // spec's deliver-or-reject rule for payer_note is met by handing the + // note to the recipient as the payment memo (bolt11 + payer_note was + // already rejected above) + const { id: pid } = await sendInternal({ + amount, + invoice, + memo: payer_note, + recipient, + sender: user, + maxTotal: remaining, + }); - await db.lPush(`${pubkey}:payments`, pid); + await db.lPush(`${pubkey}:payments`, pid); - if (invoice.memo?.includes("9734")) { - const { invoices } = await ln.listinvoices({ invstring: pr }); - const inv = invoices[0]; - if (inv) { - inv.payment_preimage = pid; - inv.paid_at = created_at; - try { - await handleZap(inv, user.pubkey); - } catch (e) { - console.log("zap receipt failed", e); - } + if (invoice.memo?.includes("9734")) { + const { invoices } = await ln.listinvoices({ invstring: pr }); + const inv = invoices[0]; + if (inv) { + inv.payment_preimage = pid; + inv.paid_at = created_at; + try { + await handleZap(inv, user.pubkey); + } catch (e) { + console.log("zap receipt failed", e); } } - - // Internal settlement never touches CLN, so there's no real - // preimage or payer proof to hand back - return result({ - transaction_id: pid, - state: "settled", - instruction_type, - amount: amountMsat, - fees_paid: 0, - created_at, - settled_at: Math.floor(Date.now() / 1000), - }); } + + // Internal settlement never touches CLN, so there's no real + // preimage or payer proof to hand back + return result({ + transaction_id: pid, + state: "settled", + instruction_type, + amount: amountMsat, + fees_paid: 0, + created_at, + settled_at: Math.floor(Date.now() / 1000), + }); } try { diff --git a/routes/ecash.ts b/routes/ecash.ts index 4a1806a3..d089ed15 100644 --- a/routes/ecash.ts +++ b/routes/ecash.ts @@ -1,31 +1,10 @@ -import { db, g, s } from "$lib/db"; -import { check, claim, get, mint } from "$lib/ecash"; -import { err, l } from "$lib/logging"; -import { credit, debit } from "$lib/payments"; -import { emit } from "$lib/sockets"; -import { bail, fail, getInvoice } from "$lib/utils"; +import { s } from "$lib/db"; +import { check, get, redeem } from "$lib/ecash"; +import { err } from "$lib/logging"; +import { bail, fail, getInvoice, getUser } from "$lib/utils"; import { getEncodedToken } from "@cashu/cashu-ts"; import { v4 } from "uuid"; -import { PaymentType } from "$lib/types"; -const { ecash: type } = PaymentType; - -Error.stackTraceLimit = 100; // Set this to the desired limit - -const sendCash = async ({ amount, user }) => { - const id = v4(); - const hash = v4(); - - const p = await debit({ hash, amount, user, type }); - const token = await mint(parseInt(amount)); - p.memo = id; - const { id: pid } = p; - await s(`payment:${pid}`, p); - s(`cash:${id}`, token); - - return { id, token, pid }; -}; - export default { async save(req, res) { const { @@ -47,6 +26,7 @@ export default { } = req; try { const token = await get(id); + if (!token) fail("Ecash not found"); const status = await check(token); res.send({ token, status }); } catch (e) { @@ -61,114 +41,31 @@ export default { user, } = req; try { - const amount = await claim(token); - - let memo; - const hash = v4(); - const { currency, id: uid } = user; - const rates = await g("rates"); - await s(`invoice:${hash}`, { - currency, - id: hash, - hash, - rate: rates[currency], - uid, - received: 0, - }); - - await credit({ hash, amount, memo, ref: user.id, type }); - - res.send({ ok: true }); + const amount = await redeem({ token, user }); + res.send({ ok: true, amount }); } catch (e) { err(e.message); bail(res, e.message); } }, - async mint(req, res) { - const { body, user } = req; - const amount = parseInt(body.amount); - fail("disabled"); - res.send(await sendCash({ amount, user })); - }, - - async melt(req, res) { - let { - body: { amount, bolt11: hash, preimage }, - user, - } = req; - try { - const ref = preimage; - const { lightning: type } = PaymentType; - if (user.username !== "mint") fail("unauthorized"); - - amount = Math.round(amount / 1000); - // A negative (or NaN) amount here is a direct ledger-inflation primitive: - // db.debit subtracts it, so -N ADDS N to the balance, and the stored - // record gets amount: -(-N). The caller gate above limits this to the - // mint service account, but that account is not a trust boundary we want - // the ledger's integrity resting on — mirror the check debit() makes in - // lib/payments.ts before any balance write. - if (!Number.isFinite(amount) || amount <= 0) - fail("Amount must be greater than zero"); - const { id: uid, currency } = user; - const ourfee = await db.debit( - `balance:${uid}`, - `credit:${type}:${uid}`, - "Insufficient funds", - amount || 0, - 0, - 0, - 0, - 0 - ); - - const rates = await g("rates"); - const rate = rates[currency]; - - if (ourfee.err) fail(ourfee.err); - - const id = v4(); - const p = { - id, - amount: -amount, - hash, - ourfee, - uid, - confirmed: true, - rate, - currency, - type, - ref, - created: Date.now(), - }; - - await s(`payment:${hash}`, id); - await s(`payment:${id}`, p); - await db.lPush(`${uid}:payments`, id); - - l(user.username, "sent", type, amount); - emit(user.id, "payment", p); - - res.send(p); - } catch (e) { - bail(res, e.message); - } - }, - + // NUT-18 payment-request transport: a wallet POSTs proofs for one of our + // ecash-type invoices, which are melted into a Lightning invoice for the + // invoice's owner under the same id. async receive(req, res) { try { const { id, proofs, mint, memo } = req.body; - const { uid: ref } = await getInvoice(id); - - const amount = await claim( - getEncodedToken({ - mint, - proofs, - }), - ); - - await credit({ hash: id, amount, memo, ref, type }); + const invoice = await getInvoice(id); + if (!invoice) fail("Invoice not found"); + const user = await getUser(invoice.uid); + if (!user) fail("User not found"); + + await redeem({ + token: getEncodedToken({ mint, proofs }), + user, + invoice, + memo, + }); res.send({ id }); } catch (e) { diff --git a/routes/info.ts b/routes/info.ts index aae130bf..4ab11872 100644 --- a/routes/info.ts +++ b/routes/info.ts @@ -2,7 +2,6 @@ import { archive } from "$lib/db"; import { db } from "$lib/db"; import { getHealthStatus } from "$lib/health"; import ln from "$lib/ln"; -import { pool } from "$lib/ecash"; export default { async health(_, res) { @@ -28,10 +27,7 @@ export default { ); const lnwallet = parseInt(funds.outputs.reduce((a, b) => a + b.value, 0)); - const cash = (await pool()).reduce((a, b) => a + b.amount, 0); - const info = { - cash, lnchannel, lnwallet, }; diff --git a/routes/payments.ts b/routes/payments.ts index accb09fd..cf0248fa 100644 --- a/routes/payments.ts +++ b/routes/payments.ts @@ -66,7 +66,7 @@ export default { const invoice = await getInvoice(payreq || hash); const recipient = invoice ? await getUser(invoice.uid) : undefined; if (payreq) { - if (invoice && recipient.username !== "mint") { + if (invoice) { if (invoice.aid === user.id) fail("Cannot send to self"); hash = payreq; if (!amount) ({ amount } = invoice); diff --git a/test/security.test.ts b/test/security.test.ts index 1ef7cabb..9311cf5d 100644 --- a/test/security.test.ts +++ b/test/security.test.ts @@ -206,7 +206,7 @@ describe("fnd-006 — deleteSelf guard sums every account", () => { describe("S8 / fnd-007 — the spend/cash mutex serializes critical sections", () => { test("no two sections run concurrently and FIFO order holds", async () => { - // Same shape as withBudgetLock (nwc.ts) and withCashLock (ecash.ts). + // Same shape as withBudgetLock (nwc.ts). let lock: Promise = Promise.resolve(); const withLock = async (fn: () => Promise): Promise => { const prev = lock; -- cgit v1.2.3