aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-16 04:53:22 -0700
committerAdam Soltys <[email protected]>2026-09-16 04:53:22 -0700
commitc72fa2a5ccf1cc04073f9ff12606491971dadd36 (patch)
treee58124db24fb734c4a443b4ef20e9f25efe78c24
parent19f73ae9cf2afffbbb2abc71eb80c6d5bdccca52 (diff)
downloadcoinos-server-c72fa2a5ccf1cc04073f9ff12606491971dadd36.tar.gz
coinos-server-c72fa2a5ccf1cc04073f9ff12606491971dadd36.zip
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 <[email protected]>
-rw-r--r--index.ts2
-rw-r--r--lib/ecash.ts229
-rw-r--r--lib/nwc.ts79
-rw-r--r--routes/ecash.ts143
-rw-r--r--routes/info.ts4
-rw-r--r--routes/payments.ts2
-rw-r--r--test/security.test.ts2
7 files changed, 170 insertions, 291 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;
-};
-
-// 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,
- });
+// 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.
-// 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 [];
+// 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;
};
-// 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<void> = Promise.resolve();
-const withCashLock = async <T>(fn: () => Promise<T>): Promise<T> => {
- 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");
-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 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,
});
+
+ 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,
- }),
- );
+ const invoice = await getInvoice(id);
+ if (!invoice) fail("Invoice not found");
+ const user = await getUser(invoice.uid);
+ if (!user) fail("User not found");
- await credit({ hash: id, amount, memo, ref, type });
+ 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<void> = Promise.resolve();
const withLock = async <T>(fn: () => Promise<T>): Promise<T> => {
const prev = lock;