From 8e8285b78485b9acb0201c927935e214483a4d36 Mon Sep 17 00:00:00 2001 From: Ashen <310210685+ashen0x@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:03:22 +0530 Subject: [PATCH] fix(wallet): read Cashu metadata before receive --- src/daemon/http/index.ts | 8 +- src/daemon/wallet/index.ts | 30 +++++-- tests/wallet/short-keyset-token.test.ts | 106 ++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 12 deletions(-) create mode 100644 tests/wallet/short-keyset-token.test.ts diff --git a/src/daemon/http/index.ts b/src/daemon/http/index.ts index d6dade6..cbf9d2e 100644 --- a/src/daemon/http/index.ts +++ b/src/daemon/http/index.ts @@ -17,7 +17,7 @@ import { type CocodClient, type CocodState, } from "../wallet/cocod-client"; -import { decodeCashuTokenAmount } from "../wallet"; +import { receiveCashuToken } from "../wallet"; import { getClientsFromStore } from "../../utils/clients"; import { getUsageSummary } from "./usage-summary"; @@ -389,9 +389,9 @@ export function createDaemonRequestHandler(deps: { await respond(res, async () => { const body = await readJsonBody(req); const token = getRequiredStringField(body, "token"); - const message = await deps.walletClient.receiveCashu(token); - const { amount, unit } = decodeCashuTokenAmount(token); - return { output: { message, amount, unit } }; + return { + output: await receiveCashuToken(deps.walletClient, token), + }; }); return; } diff --git a/src/daemon/wallet/index.ts b/src/daemon/wallet/index.ts index ce8123f..a031a10 100644 --- a/src/daemon/wallet/index.ts +++ b/src/daemon/wallet/index.ts @@ -1,4 +1,4 @@ -import { getDecodedToken, Amount } from "@cashu/cashu-ts"; +import { getTokenMetadata } from "@cashu/cashu-ts"; import { InsufficientBalanceError } from "@routstr/sdk"; import { WalletConnect } from "applesauce-wallet-connect"; import { RelayPool } from "applesauce-relay"; @@ -10,13 +10,28 @@ export function decodeCashuTokenAmount(token: string): { amount: number; unit: "sat" | "msat"; } { - const decoded = getDecodedToken(token, []); - const amount = - decoded?.proofs?.reduce((sum, proof) => sum + proof.amount.toNumber(), 0) ?? 0; - const unit = decoded?.unit === "msat" ? "msat" : "sat"; + // TokenV4 may contain an 8-byte short keyset ID. Fully decoding its + // proofs requires the mint's full keyset IDs, but amount and unit do not. + // getTokenMetadata intentionally extracts those fields without trying to + // map short IDs, unlike getDecodedToken(token, []). + const metadata = getTokenMetadata(token); + const amount = metadata.amount.toNumber(); + const unit = metadata.unit === "msat" ? "msat" : "sat"; return { amount, unit }; } +export async function receiveCashuToken( + client: Pick, + token: string, +): Promise<{ message: string; amount: number; unit: "sat" | "msat" }> { + // Validate the token before handing it to a state-changing wallet call. This + // prevents a successful receive from being reported as a failure if local + // metadata parsing ever rejects a future token format. + const { amount, unit } = decodeCashuTokenAmount(token); + const message = await client.receiveCashu(token); + return { message, amount, unit }; +} + export interface WalletAdapterOptions { cocodPath?: string | null; walletClient?: CocodClient; @@ -296,8 +311,7 @@ export async function createWalletAdapter( message?: string; }> { try { - const message = await client.receiveCashu(token); - const { amount, unit } = decodeCashuTokenAmount(token); + const { amount, unit, message } = await receiveCashuToken(client, token); return { success: true, amount, unit, message }; } catch (error) { const errorMessage = @@ -343,4 +357,4 @@ export async function createWalletAdapter( } return walletAdapter; -} \ No newline at end of file +} diff --git a/tests/wallet/short-keyset-token.test.ts b/tests/wallet/short-keyset-token.test.ts new file mode 100644 index 0000000..cc3ee30 --- /dev/null +++ b/tests/wallet/short-keyset-token.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, mock } from "bun:test"; +import { + Amount, + getDecodedToken, + getEncodedToken, +} from "@cashu/cashu-ts"; +import { + createWalletAdapter, + decodeCashuTokenAmount, +} from "../../src/daemon/wallet"; +import type { CocodClient } from "../../src/daemon/wallet/cocod-client"; + +// A full modern keyset ID with the same format as Minibits' post-migration +// active keyset. getEncodedToken stores only its first eight bytes in TokenV4. +const FULL_KEYSET_ID = + "01fc0ec0e59cd6fa01b7a88f8cd77fce81fd1e64bca67d752e984992b7a3c3a821"; +const LEGACY_KEYSET_ID = "00107937db0cc865"; + +function makeToken({ + amounts = [5], + keysetId = FULL_KEYSET_ID, + unit = "sat", +}: { + amounts?: number[]; + keysetId?: string; + unit?: string; +} = {}): string { + return getEncodedToken({ + mint: "https://mint.minibits.cash/Bitcoin", + unit, + proofs: amounts.map((amount, index) => ({ + id: keysetId, + amount: Amount.from(amount), + secret: `short-keyset-regression-fixture-${index}`, + C: `02${(index + 1).toString(16).padStart(2, "0").repeat(32)}`, + })), + }); +} + +function makeWalletClient( + receiveCashu: CocodClient["receiveCashu"], +): CocodClient { + // createWalletAdapter is intentionally lazy; this receive-path test only + // needs the one capability exercised by receiveToken. + return { receiveCashu } as CocodClient; +} + +describe("short keyset TokenV4 compatibility", () => { + it("reads amount metadata without resolving the shortened proof keyset ID", () => { + const token = makeToken({ amounts: [1, 4] }); + + // Guard the fixture itself: a full proof decode with no mint keysets must + // exercise the same short-ID failure that triggered this regression. + expect(() => getDecodedToken(token, [])).toThrow(/short keyset ID/i); + expect(decodeCashuTokenAmount(token)).toEqual({ + amount: 5, + unit: "sat", + }); + }); + + for (const tokenCase of [ + { + name: "modern keyset with msat unit", + input: { amounts: [500, 1000], unit: "msat" }, + expected: { amount: 1500, unit: "msat" as const }, + }, + { + name: "legacy v0 keyset", + input: { amounts: [2, 8], keysetId: LEGACY_KEYSET_ID }, + expected: { amount: 10, unit: "sat" as const }, + }, + ]) { + it(`reads ${tokenCase.name} metadata`, () => { + expect(decodeCashuTokenAmount(makeToken(tokenCase.input))).toEqual( + tokenCase.expected, + ); + }); + } + + it("reports success after cocod receives a short-keyset token", async () => { + const receiveCashu = mock(async () => "Received 5"); + const walletClient = makeWalletClient(receiveCashu); + const adapter = await createWalletAdapter({ walletClient }); + + const result = await adapter.receiveToken(makeToken()); + + expect(receiveCashu).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + success: true, + amount: 5, + unit: "sat", + message: "Received 5", + }); + }); + + it("does not call the wallet when token metadata is invalid", async () => { + const receiveCashu = mock(async () => "should not be called"); + const walletClient = makeWalletClient(receiveCashu); + const adapter = await createWalletAdapter({ walletClient }); + + const result = await adapter.receiveToken("cashuBnot-a-valid-token"); + + expect(receiveCashu).not.toHaveBeenCalled(); + expect(result.success).toBe(false); + }); +});