Merge pull request #68 from ashen0x/fix/cashu-short-keyset-tokens

fix(wallet): handle short keyset Cashu tokens
This commit is contained in:
redshift
2026-08-13 23:20:17 +01:00
committed by GitHub
3 changed files with 132 additions and 12 deletions
+4 -4
View File
@@ -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;
}
+22 -8
View File
@@ -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<CocodClient, "receiveCashu">,
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;
}
}
+106
View File
@@ -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);
});
});