aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-14 08:50:31 -0700
committerAdam Soltys <[email protected]>2026-09-14 08:50:31 -0700
commitcdda2d34d26bd8fb310867022130e4a59f67d13c (patch)
treeac13a2d26bcaf3df5d72e69c5afe03e5778915c2
parentd8a361bdcfaa8cc5b39d9675e74b0e1d2e42be2c (diff)
downloadcoinos-server-cdda2d34d26bd8fb310867022130e4a59f67d13c.tar.gz
coinos-server-cdda2d34d26bd8fb310867022130e4a59f67d13c.zip
ecash: make claims work against the v1 keyset mint
cashu-ts 2.9.0 only derives v0 keyset ids, so CashuWallet rejected the mint's current keyset and receive() could never run. Build the wallet from keys preloaded off our own mint instead of verifying, decode tokens with the keysets (2.9.0 shortens v1 ids in the tokens it encodes, and won't expand them otherwise), and treat an unreadable house-pool value as empty rather than throwing — the pool key had been found holding a bare integer, which surfaced as "n.startsWith is not a function" on every redeem. The pool write is awaited so a dropped set can't strand freshly swapped proofs. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_0166Ci2XdCair1B9ych7w8dq
-rw-r--r--lib/ecash.ts83
-rw-r--r--routes/info.ts9
2 files changed, 67 insertions, 25 deletions
diff --git a/lib/ecash.ts b/lib/ecash.ts
index 9ce8b4e5..b1d8b7fb 100644
--- a/lib/ecash.ts
+++ b/lib/ecash.ts
@@ -1,6 +1,7 @@
import config from "$config";
-import { g, s } from "$lib/db";
+import { db, g } from "$lib/db";
import { lnb } from "$lib/ln";
+import { warn } from "$lib/logging";
import { safeGot } from "$lib/safe-fetch";
import { fail, wait } from "$lib/utils";
import {
@@ -11,13 +12,39 @@ import {
PaymentRequestTransportType,
getDecodedToken,
getEncodedToken,
- getEncodedTokenV4,
} from "@cashu/cashu-ts";
const { URL } = process.env;
const m = new CashuMint(config.mintUrl);
-const w = new CashuWallet(m);
+
+// 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({
@@ -25,6 +52,29 @@ const enc = (proofs) =>
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
@@ -68,37 +118,37 @@ export async function get(id) {
}
export async function claim(token) {
- const { mint } = getDecodedToken(token);
+ const { mint } = await decode(token);
if (await ext(mint)) fail("Unable to receive from other mints");
+ const { w } = await wallet();
return withCashLock(async () => {
- const { proofs: current } = getDecodedToken(await g("cash"));
+ const current = await pool();
const rcvd = await w.receive(token);
- await s("cash", enc([...current, ...rcvd]));
+ await setPool([...current, ...rcvd]);
return rcvd.reduce((a, b) => a + b.amount, 0);
});
}
export async function mint(amount) {
- const { keysets } = await m.getKeySets();
- const w = new CashuWallet(m, { keysets });
+ const { w } = await wallet();
return withCashLock(async () => {
- const { proofs } = getDecodedToken(await g("cash"));
+ const proofs = await pool();
const { send, keep } = await w.send(amount, proofs);
const rcvd = await w.receive(enc(send));
- const change = enc(keep);
- await s("cash", change);
+ await setPool(keep);
return enc(rcvd);
});
}
export async function check(token) {
- const { mint, proofs } = getDecodedToken(token);
+ const { mint, proofs } = await decode(token);
const total = proofs.reduce((a, b) => a + b.amount, 0);
const external = await ext(mint);
+ const { w } = await wallet();
let spent = 0;
for (const [i, p] of (await w.checkProofsStates(proofs)).entries()) {
if (p.state === "SPENT") spent += proofs[i].amount;
@@ -110,6 +160,7 @@ export async function check(token) {
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);
@@ -119,13 +170,7 @@ export async function init(amount = 100000) {
});
const proofs = await w.mintProofs(amount, quote);
-
- const cash = getEncodedTokenV4({
- mint: config.mintUrl,
- proofs,
- });
-
- await s("cash", cash);
+ await setPool(proofs);
} catch (e) {}
}
diff --git a/routes/info.ts b/routes/info.ts
index a0ac7693..aae130bf 100644
--- a/routes/info.ts
+++ b/routes/info.ts
@@ -1,8 +1,8 @@
import { archive } from "$lib/db";
-import { db, g } from "$lib/db";
+import { db } from "$lib/db";
import { getHealthStatus } from "$lib/health";
import ln from "$lib/ln";
-import { getDecodedToken } from "@cashu/cashu-ts";
+import { pool } from "$lib/ecash";
export default {
async health(_, res) {
@@ -28,10 +28,7 @@ export default {
);
const lnwallet = parseInt(funds.outputs.reduce((a, b) => a + b.value, 0));
- const cash = getDecodedToken(await g("cash")).proofs.reduce(
- (a, b) => a + b.amount,
- 0,
- );
+ const cash = (await pool()).reduce((a, b) => a + b.amount, 0);
const info = {
cash,