aboutsummaryrefslogtreecommitdiffstats
path: root/lib/ecash.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/ecash.ts')
-rw-r--r--lib/ecash.ts200
1 files changed, 136 insertions, 64 deletions
diff --git a/lib/ecash.ts b/lib/ecash.ts
index bcd2b7f4..599bd7b0 100644
--- a/lib/ecash.ts
+++ b/lib/ecash.ts
@@ -1,100 +1,172 @@
import config from "$config";
-import { g, s } from "$lib/db";
-import { lnb } from "$lib/ln";
-import { fail, wait } from "$lib/utils";
+import { g } from "$lib/db";
+import { generate } from "$lib/invoices";
+import { warn } from "$lib/logging";
+import { PaymentType } from "$lib/types";
+import { fail, getPayment, sleep, wait } from "$lib/utils";
import {
CashuMint,
CashuWallet,
- MintQuoteState,
PaymentRequest,
PaymentRequestTransportType,
getDecodedToken,
- getEncodedToken,
- getEncodedTokenV4,
} from "@cashu/cashu-ts";
-
const { URL } = process.env;
const m = new CashuMint(config.mintUrl);
-const w = new CashuWallet(m);
-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.
+
+// 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;
+};
+
+// A short keyset id that doesn't expand against our keysets belongs to some
+// other mint; say so instead of surfacing cashu-ts's mapping error.
+const decode = async (token) => {
+ try {
+ return getDecodedToken(token, await keysets());
+ } catch (e) {
+ if (/short keyset id/i.test(e.message))
+ fail("Unable to receive from other mints");
+ throw e;
+ }
+};
-const ext = async (mint) => {
- const issuer = new CashuMint(mint);
- const { pubkey: issuerPk } = await issuer.getInfo();
- const { pubkey: ourPk } = await m.getInfo();
- return issuerPk !== ourPk;
+// A token is ours when every proof is on one of our keysets. Keyset ids are
+// derived from the mint's public keys, so they identify the issuer; the token's
+// `mint` URL is just a label the sending wallet wrote in (tokens of ours turn
+// up with stray ports and paths on it) and is never fetched. A miss refreshes
+// the cached list once in case a keyset was rotated in since it was fetched.
+const ext = async (proofs) => {
+ const ours = (ks) => {
+ const ids = new Set(ks.map((k) => k.id));
+ return proofs.every((p) => ids.has(p.id));
+ };
+ if (ours(await keysets())) return false;
+ cached = undefined;
+ return !ours(await keysets());
};
+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 { proofs: current } = getDecodedToken(await g("cash"));
- const { mint } = getDecodedToken(token);
-
- if (await ext(mint)) fail("Unable to receive from other mints");
+// 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 { proofs } = await decode(token);
+ if (!proofs?.length) fail("Token has no proofs");
+ if (await ext(proofs)) fail("Unable to receive from other mints");
- const rcvd = await w.receive(token);
+ // 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");
- await s("cash", enc([...current, ...rcvd]));
- return rcvd.reduce((a, b) => a + b.amount, 0);
-}
+ 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");
-export async function mint(amount) {
- const { keysets } = await m.getKeySets();
- const w = new CashuWallet(m, { keysets });
- const { proofs } = getDecodedToken(await g("cash"));
- const { send, keep } = await w.send(amount, proofs);
- const rcvd = await w.receive(enc(send));
- const change = enc(keep);
- await s("cash", change);
- return enc(rcvd);
-}
-
-export async function check(token) {
- const { mint, proofs } = getDecodedToken(token);
- const total = proofs.reduce((a, b) => a + b.amount, 0);
+ 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 external = await ext(mint);
+ 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 spent = 0;
- for (const [i, p] of (await w.checkProofsStates(proofs)).entries()) {
- if (p.state === "SPENT") spent += proofs[i].amount;
+ 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");
- return { total, spent, mint, external };
-}
-
-export async function init(amount = 100000) {
+ // 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 new Promise((r) => setTimeout(r, 2000));
- const { quote, request } = await w.createMintQuote(amount);
- await lnb.pay(request);
+ await wait(() => getPayment(invoice.hash), 50, 200);
+ } catch {
+ warn("ecash redeemed but credit not yet visible", user.username, invoice.hash);
+ }
- await wait(async () => {
- const { state } = await w.checkMintQuote(quote);
- return state === MintQuoteState.PAID;
- });
+ return amount;
+}
- const proofs = await w.mintProofs(amount, quote);
+export async function check(token) {
+ const { mint, proofs } = await decode(token);
+ const total = sum(proofs);
- const cash = getEncodedTokenV4({
- mint: config.mintUrl,
- proofs,
- });
+ const external = await ext(proofs);
- await s("cash", cash);
- } catch (e) {
- console.log(e);
+ // Only ask our own mint about proof states.
+ let spent = 0;
+ 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 function request(uuid, amount, memo) {