mirror of
https://github.com/Routstr/routstrd.git
synced 2026-09-14 02:55:07 +00:00
Merge pull request #68 from ashen0x/fix/cashu-short-keyset-tokens
fix(wallet): handle short keyset Cashu tokens
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user