commit 23e092a4473f0ae532b075b2f1f4c5c5bba169bb
parent d67bbf3e71054e267cfb3a0eb4c81cb2bfc42cb6
Author: Florian Dold <dold@taler.net>
Date: Wed, 9 Sep 2026 13:23:29 +0200
wallet-core: diagnose coin histories for an exchange
Diffstat:
7 files changed, 1086 insertions(+), 0 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-wallettesting.ts b/packages/taler-harness/src/integrationtests/test-wallettesting.ts
@@ -88,6 +88,56 @@ export async function runWallettestingTest(t: GlobalTestState) {
t.assertDeepEqual(txTypes, ["withdrawal", "payment"]);
+ await walletClient.call(WalletApiOperation.TestingWaitRefreshesFinal, {});
+ const coinHealth = await walletClient.call(
+ WalletApiOperation.TestingCheckCoins,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ onlyFresh: false,
+ },
+ );
+ t.assertTrue(coinHealth.numCoins > 0);
+ t.assertDeepEqual(coinHealth.issues, []);
+ t.assertDeepEqual(coinHealth.numChecked, coinHealth.numCoins);
+
+ const freshHealth = await walletClient.call(
+ WalletApiOperation.TestingCheckCoins,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ },
+ );
+ const checkedCoinDump = await walletClient.call(
+ WalletApiOperation.DumpCoins,
+ {},
+ );
+ t.assertDeepEqual(
+ freshHealth.numCoins,
+ checkedCoinDump.coins.filter(
+ (c) =>
+ c.exchangeBaseUrl === exchange.baseUrl &&
+ c.coinStatus === CoinStatus.Fresh,
+ ).length,
+ );
+ t.assertTrue(freshHealth.numCoins < coinHealth.numCoins);
+ t.assertDeepEqual(freshHealth.issues, []);
+ t.assertDeepEqual(freshHealth.numChecked, freshHealth.numCoins);
+ const expectedMaterial = Amounts.stringify(
+ Amounts.sumOrZero(
+ "TESTKUDOS",
+ checkedCoinDump.coins
+ .filter(
+ (c) =>
+ c.exchangeBaseUrl === exchange.baseUrl &&
+ c.coinStatus === CoinStatus.Fresh,
+ )
+ .map((c) => c.denomValue),
+ ).amount,
+ );
+ t.assertDeepEqual(freshHealth.expectedMaterialBalance, expectedMaterial);
+ t.assertDeepEqual(freshHealth.actualMaterialBalance, expectedMaterial);
+ t.assertDeepEqual(coinHealth.expectedMaterialBalance, expectedMaterial);
+ t.assertDeepEqual(coinHealth.actualMaterialBalance, expectedMaterial);
+
await walletClient.call(WalletApiOperation.ClearDb, {});
await walletClient.call(WalletApiOperation.WithdrawTestBalance, {
diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts
@@ -70,6 +70,7 @@ import { BlindedDonationReceiptKeyPair } from "./types-donau.js";
import { WithdrawalOperationStatusFlag } from "./types-taler-bank-integration.js";
import {
AmountString,
+ CoinSpendHistoryItem,
CurrencySpecification,
EddsaPrivateKeyString,
EddsaPublicKeyString,
@@ -4275,6 +4276,69 @@ export interface TestingGetDenomStatsRequest {
exchangeBaseUrl: string;
}
+/** Validate exchange coin histories and compare balances of unspent coins. */
+export interface TestingCheckCoinsRequest {
+ /** Canonicalized before selecting coins. Only this exchange is contacted. */
+ exchangeBaseUrl: string;
+ /**
+ * Default true: check only CoinStatus.Fresh. False validates every coin
+ * status, comparing denomination value only for fresh or suspended-fresh
+ * coins. Local operation history is never consulted.
+ */
+ onlyFresh?: boolean;
+}
+
+export const codecForTestingCheckCoinsRequest =
+ (): Codec<TestingCheckCoinsRequest> =>
+ buildCodecForObject<TestingCheckCoinsRequest>()
+ .property("exchangeBaseUrl", codecForString())
+ .property("onlyFresh", codecOptional(codecForBoolean()))
+ .build("TestingCheckCoinsRequest");
+
+/** Public evidence only: never contains coin private keys or refresh secrets. */
+export interface TestingCheckCoinsIssue {
+ coinPub: string;
+ denomPubHash: string;
+ category: "mismatch" | "error" | "incomplete";
+ reason:
+ | "balance-difference"
+ | "invalid-history"
+ | "request-failed"
+ | "missing-local-data"
+ | "local-data-changed";
+ description: string;
+ expected?: Record<string, string | number | boolean>;
+ actual?: Record<string, string | number | boolean>;
+ /** On balance differences: exchange history in offset order, including credits. */
+ exchangeOperations?: Array<
+ [operation: CoinSpendHistoryItem["type"], amount: AmountString]
+ >;
+}
+
+export interface TestingCheckCoinsResponse {
+ exchangeBaseUrl: string;
+ /**
+ * Denomination value of fresh coins under the exchange's current master key
+ * in the initial snapshot. Excludes suspended coins and pending refresh
+ * outputs, even with onlyFresh=false. Null if local data is unavailable.
+ */
+ expectedMaterialBalance: AmountString | null;
+ /**
+ * Verified remaining exchange balance of those same coins. Null if any
+ * relevant history could not be verified or the material balance changed
+ * during the check. Never a partial total. Unused/unknown coins contribute
+ * their full denomination value; an unknown exchange has no currency and
+ * both totals are null.
+ */
+ actualMaterialBalance: AmountString | null;
+ /** Coins selected by the exchange and onlyFresh filter in the initial snapshot. */
+ numCoins: number;
+ /** Validated histories with stable coin data, including balance mismatches. */
+ numChecked: number;
+ /** Balance differences, invalid exchange histories, and unavailable coin data. */
+ issues: TestingCheckCoinsIssue[];
+}
+
export interface TestingGetDenomStatsResponse {
numKnown: number;
numOffered: number;
diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts
@@ -3591,6 +3591,31 @@ advancedCli
});
advancedCli
+ .subcommand("checkCoins", "check-coins", {
+ help: "Check fresh coin histories at an exchange and print mismatches as JSON.",
+ })
+ .requiredOption("exchange", ["--exchange"], clk.STRING, {
+ help: "Base URL of the exchange whose coins should be checked.",
+ })
+ .flag("allCoins", ["--all-coins"], {
+ help: "Check every coin status instead of only fresh coins.",
+ })
+ .action(async (args) => {
+ await runCliAction(() =>
+ withWallet(args, { lazyTaskLoop: true }, async (wallet) => {
+ const result = await wallet.client.call(
+ WalletApiOperation.TestingCheckCoins,
+ {
+ exchangeBaseUrl: args.checkCoins.exchange,
+ onlyFresh: !args.checkCoins.allCoins,
+ },
+ );
+ console.log(j2s(result));
+ }),
+ );
+ });
+
+advancedCli
.subcommand("performanceStats", "performance-stats", {
help: "Print performance stats.",
})
diff --git a/packages/taler-wallet-core/src/requests.ts b/packages/taler-wallet-core/src/requests.ts
@@ -260,6 +260,7 @@ import {
codecForTaldirRegistrationRequest,
codecForTestPayArgs,
codecForTestingCorruptWithdrawalCoinSelRequest,
+ codecForTestingCheckCoinsRequest,
codecForTestingGetDenomStatsRequest,
codecForTestingGetReserveHistoryRequest,
codecForTestingPlanMigrateExchangeBaseUrlRequest,
@@ -471,6 +472,7 @@ import {
rematerializeTransactionsAtCurrentVersion,
walletExchangeClient,
} from "./wallet.js";
+import { testingCheckCoins } from "./testing-check-coins.js";
import { parseExchangeWireAccountPayto } from "./exchange-payto.js";
const logger = new Logger("requests.ts");
@@ -2701,6 +2703,10 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = {
codec: codecForTestingGetDenomStatsRequest(),
handler: handleTestingGetDenomStats,
},
+ [WalletApiOperation.TestingCheckCoins]: {
+ codec: codecForTestingCheckCoinsRequest(),
+ handler: testingCheckCoins,
+ },
[WalletApiOperation.ListExchanges]: {
codec: codecForListExchangesRequest(),
handler: listExchanges,
diff --git a/packages/taler-wallet-core/src/testing-check-coins.test.ts b/packages/taler-wallet-core/src/testing-check-coins.test.ts
@@ -0,0 +1,634 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import {
+ AmountString,
+ CancellationToken,
+ CoinHistoryResponse,
+ CoinSpendHistoryItem,
+ CoinStatus,
+ DenomKeyType,
+ TalerErrorCode,
+ codecForTestingCheckCoinsRequest,
+} from "@gnu-taler/taler-util";
+import { HttpRequestLibrary, HttpResponse } from "@gnu-taler/taler-util/http";
+import {
+ CoinSourceType,
+ WalletCoin,
+ WalletDenomination,
+ WalletExchangeDetails,
+} from "./db/records.js";
+import { WalletDbTransaction } from "./db/transaction.js";
+import { WalletExecutionContext } from "./wallet.js";
+import { testingCheckCoins } from "./testing-check-coins.js";
+
+const url = "https://exchange.example/";
+const a = (s: string) => `TESTKUDOS:${s}` as AmountString;
+function coin(pub = "coin", exchangeBaseUrl = url): WalletCoin {
+ return {
+ coinPub: pub,
+ coinPriv: "private-key",
+ denomPubHash: "denom",
+ exchangeMasterPub: "master",
+ exchangeBaseUrl,
+ status: CoinStatus.Fresh,
+ maxAge: 0,
+ coinSource: { type: CoinSourceType.Withdraw, reservePub: "reserve" },
+ } as WalletCoin;
+}
+function denomination(): WalletDenomination {
+ return {
+ denomPubHash: "denom",
+ exchangeMasterPub: "master",
+ value: a("10"),
+ exchangeBaseUrl: url,
+ denomPub: { cipher: DenomKeyType.Rsa, rsa_public_key: "key" },
+ fees: {
+ feeDeposit: a("0.1"),
+ feeRefund: a("0.02"),
+ feeRefresh: a("0.2"),
+ feeWithdraw: a("0.1"),
+ },
+ stampStart: 0,
+ stampExpireDeposit: Number.MAX_SAFE_INTEGER,
+ stampExpireWithdraw: Number.MAX_SAFE_INTEGER,
+ stampExpireLegal: Number.MAX_SAFE_INTEGER,
+ } as WalletDenomination;
+}
+function deposit(
+ overrides: Partial<CoinSpendHistoryItem> = {},
+): CoinSpendHistoryItem {
+ return {
+ type: "DEPOSIT",
+ history_offset: 1,
+ amount: a("3"),
+ deposit_fee: a("0.1"),
+ merchant_pub: "merchant",
+ h_contract_terms: "contract",
+ h_wire: "wire",
+ h_denom_pub: "denom",
+ coin_sig: "sig",
+ timestamp: { t_s: 10 },
+ ...overrides,
+ } as CoinSpendHistoryItem;
+}
+
+function fixture() {
+ const data = {
+ coins: [coin()],
+ exchangeDetails: {
+ rowId: 1,
+ currency: "TESTKUDOS",
+ masterPublicKey: "master",
+ } as WalletExchangeDetails | undefined,
+ denoms: [denomination()],
+ };
+ const responses = new Map<string, { status: number; body: unknown }>([
+ [
+ "coin",
+ {
+ status: 404,
+ body: { code: TalerErrorCode.EXCHANGE_GENERIC_COIN_UNKNOWN },
+ },
+ ],
+ ]);
+ const requests: string[] = [];
+ let onRequest: (() => void) | undefined;
+ let validSignatures = true;
+ let signatureChecks = 0;
+ const reads: Record<string, (...args: any[]) => unknown> = {
+ getCoinsByExchange: (u: string) =>
+ data.coins.filter((c) => c.exchangeBaseUrl === u),
+ getDenominationsByRefs: (refs: WalletCoin[]) =>
+ data.denoms.filter((d) =>
+ refs.some(
+ (c) =>
+ c.denomPubHash === d.denomPubHash &&
+ c.exchangeMasterPub === d.exchangeMasterPub,
+ ),
+ ),
+ getExchangeDetails: (u: string) =>
+ u === url ? data.exchangeDetails : undefined,
+ getExchangeSignKeysByDetailsRowId: () => [
+ {
+ signkeyPub: "exchange-key",
+ stampStart: 0,
+ stampExpire: Number.MAX_SAFE_INTEGER,
+ },
+ ],
+ };
+ const tx = new Proxy(
+ {},
+ {
+ get: (_, name: string) => {
+ assert(
+ name in reads,
+ `Unexpected database call (including writes): ${name}`,
+ );
+ return async (...args: unknown[]) =>
+ structuredClone(reads[name](...args));
+ },
+ },
+ ) as WalletDbTransaction;
+ const http: HttpRequestLibrary = {
+ async fetch(u, options): Promise<HttpResponse> {
+ assert.equal(options?.method ?? "GET", "GET");
+ assert.equal(
+ options?.headers?.["Taler-Coin-History-Signature"],
+ "history-sig",
+ );
+ assert(u.startsWith(url));
+ requests.push(u);
+ onRequest?.();
+ const pub = u.split("/").at(-2)!;
+ const response = responses.get(pub);
+ if (!response) throw Error("Network unavailable");
+ return {
+ requestUrl: u,
+ requestMethod: "GET",
+ status: response.status,
+ headers: {
+ get: (k: string) =>
+ k === "content-type" ? "application/json" : null,
+ } as HttpResponse["headers"],
+ json: async () => structuredClone(response.body),
+ text: async () => JSON.stringify(response.body),
+ bytes: async () => new Uint8Array(),
+ };
+ },
+ };
+ const wex = {
+ http,
+ cancellationToken: CancellationToken.CONTINUE,
+ ws: { longpollQueue: undefined },
+ runWalletDbTx: async <T>(f: (t: WalletDbTransaction) => Promise<T>) =>
+ f(tx),
+ cryptoApi: {
+ signCoinHistoryRequest: async (r: {
+ coinPriv: string;
+ startOffset: number;
+ }) => {
+ assert.equal(r.startOffset, 0);
+ assert(r.coinPriv);
+ return { sig: "history-sig" };
+ },
+ isValidCoinHistory: async () => {
+ signatureChecks++;
+ return { valid: validSignatures };
+ },
+ },
+ } as unknown as WalletExecutionContext;
+ const history = (
+ items: CoinSpendHistoryItem[],
+ balance = a("7"),
+ pub = "coin",
+ ) => {
+ responses.set(pub, {
+ status: 200,
+ body: {
+ history: items,
+ balance,
+ h_denom_pub: "denom",
+ } satisfies CoinHistoryResponse,
+ });
+ };
+ const run = (onlyFresh: boolean | undefined = undefined) =>
+ testingCheckCoins(wex, { exchangeBaseUrl: url.slice(0, -1), onlyFresh });
+ return {
+ data,
+ responses,
+ requests,
+ wex,
+ history,
+ run,
+ onRequest: (f: () => void) => {
+ onRequest = f;
+ },
+ signatures: (valid: boolean) => {
+ validSignatures = valid;
+ },
+ signatureChecks: () => signatureChecks,
+ };
+}
+
+test("request requires an exchange URL", async () => {
+ assert.throws(() => codecForTestingCheckCoinsRequest().decode({}));
+ assert.throws(() =>
+ codecForTestingCheckCoinsRequest().decode({ exchangeBaseUrl: 1 }),
+ );
+ await assert.rejects(
+ testingCheckCoins(fixture().wex, { exchangeBaseUrl: "file:///tmp" }),
+ );
+});
+
+test("unused coins and an empty exchange pass; another exchange is never contacted", async () => {
+ const f = fixture();
+ f.data.coins.push(coin("other", "https://other.example/"));
+ const result = await f.run();
+ assert.deepEqual(result, {
+ exchangeBaseUrl: url,
+ numCoins: 1,
+ numChecked: 1,
+ expectedMaterialBalance: a("10"),
+ actualMaterialBalance: a("10"),
+ issues: [],
+ });
+ assert.deepEqual(f.requests, [url + "coins/coin/history"]);
+ const empty = await testingCheckCoins(f.wex, {
+ exchangeBaseUrl: "https://empty.example/",
+ });
+ assert.equal(empty.numCoins, 0);
+ assert.deepEqual(empty.issues, []);
+});
+
+test("failure for one coin does not prevent checking subsequent coins", async () => {
+ const f = fixture();
+ f.data.coins.push(coin("second"));
+ f.responses.set("coin", {
+ status: 500,
+ body: { code: TalerErrorCode.GENERIC_DB_FETCH_FAILED },
+ });
+ f.responses.set("second", {
+ status: 404,
+ body: { code: TalerErrorCode.EXCHANGE_GENERIC_COIN_UNKNOWN },
+ });
+ const result = await f.run();
+ assert.equal(result.numCoins, 2);
+ assert.equal(result.numChecked, 1);
+ assert.equal(result.issues[0].reason, "request-failed");
+ assert.equal(f.requests.length, 2);
+});
+
+test("unrelated 404, decoding failures and network failures are errors", async () => {
+ for (const response of [
+ undefined,
+ { status: 404, body: {} },
+ { status: 200, body: { history: [] } },
+ ]) {
+ const f = fixture();
+ f.responses.clear();
+ if (response) f.responses.set("coin", response);
+ const result = await f.run();
+ assert.equal(result.numChecked, 0);
+ assert.equal(result.issues[0].category, "error");
+ }
+});
+
+test("invalid signatures, duplicate offsets, denomination and balance conflicts are rejected", async () => {
+ for (const variant of ["signature", "offset", "denom", "balance"]) {
+ const f = fixture();
+ f.history([deposit()]);
+ if (variant === "signature") f.signatures(false);
+ if (variant === "offset") f.history([deposit(), deposit()], a("4"));
+ if (variant === "denom")
+ (f.responses.get("coin")!.body as CoinHistoryResponse).h_denom_pub =
+ "other";
+ if (variant === "balance") f.history([deposit()], a("1"));
+ const result = await f.run();
+ assert.equal(result.numChecked, 0);
+ assert.equal(result.issues[0].reason, "invalid-history");
+ }
+});
+
+test("all coin states are fetched and denomination lookup respects the master key", async () => {
+ const f = fixture();
+ f.data.coins[0].status = CoinStatus.Dormant;
+ f.history([deposit()]);
+ f.data.denoms.unshift({
+ ...denomination(),
+ exchangeMasterPub: "different-master",
+ value: a("20"),
+ });
+ assert.deepEqual((await f.run(false)).issues, []);
+ for (const status of [CoinStatus.FreshSuspended, CoinStatus.DenomLoss]) {
+ f.data.coins.push({ ...coin(status), status });
+ f.responses.set(status, {
+ status: 404,
+ body: { code: TalerErrorCode.EXCHANGE_GENERIC_COIN_UNKNOWN },
+ });
+ }
+ const result = await f.run(false);
+ assert.equal(result.numCoins, 3);
+ assert.equal(result.numChecked, 3);
+});
+
+test("an unaccounted reserve contribution is summarized without exposing private material", async () => {
+ const f = fixture();
+ f.history(
+ [
+ {
+ type: "RESERVE-OPEN-DEPOSIT",
+ history_offset: 1,
+ coin_contribution: a("1"),
+ reserve_sig: "public-reserve-signature",
+ coin_sig: "coin-signature",
+ },
+ ],
+ a("9"),
+ );
+ const result = await f.run();
+ assert.deepEqual(
+ result.issues.map((i) => i.reason),
+ ["balance-difference"],
+ );
+ assert.deepEqual(result.issues[0].exchangeOperations, [
+ ["RESERVE-OPEN-DEPOSIT", a("1")],
+ ]);
+ assert(!JSON.stringify(result).includes("private-key"));
+ assert(!JSON.stringify(result).includes("coin-signature"));
+});
+
+test("onlyFresh defaults to true and excludes suspended, dormant and lost coins", async () => {
+ for (const onlyFresh of [undefined, true, false]) {
+ const f = fixture();
+ for (const status of [
+ CoinStatus.FreshSuspended,
+ CoinStatus.Dormant,
+ CoinStatus.DenomLoss,
+ ]) {
+ f.data.coins.push({ ...coin(status), status });
+ f.responses.set(status, {
+ status: 404,
+ body: { code: TalerErrorCode.EXCHANGE_GENERIC_COIN_UNKNOWN },
+ });
+ }
+ const result = await testingCheckCoins(f.wex, {
+ exchangeBaseUrl: url,
+ ...(onlyFresh === undefined ? {} : { onlyFresh }),
+ });
+ assert.equal(result.numCoins, onlyFresh === false ? 4 : 1);
+ assert.equal(f.requests.length, result.numCoins);
+ if (onlyFresh !== false) {
+ assert.deepEqual(f.requests, [url + "coins/coin/history"]);
+ assert.equal(result.numChecked, 1);
+ assert.deepEqual(result.issues, []);
+ }
+ }
+});
+
+test("an exchange without fresh coins makes no history requests by default", async () => {
+ const f = fixture();
+ f.data.coins[0].status = CoinStatus.Dormant;
+ f.history([deposit()]);
+ const result = await testingCheckCoins(f.wex, { exchangeBaseUrl: url });
+ assert.deepEqual(result, {
+ exchangeBaseUrl: url,
+ numCoins: 0,
+ numChecked: 0,
+ expectedMaterialBalance: a("0"),
+ actualMaterialBalance: a("0"),
+ issues: [],
+ });
+ assert.deepEqual(f.requests, []);
+ assert.equal(f.signatureChecks(), 0);
+ assert.deepEqual((await f.run(false)).issues, []);
+ assert.equal(f.requests.length, 1);
+});
+
+test("a selected fresh coin that becomes dormant is reported as changed", async () => {
+ const f = fixture();
+ f.onRequest(() => {
+ f.data.coins[0].status = CoinStatus.Dormant;
+ });
+ const result = await testingCheckCoins(f.wex, { exchangeBaseUrl: url });
+ assert.equal(result.numCoins, 1);
+ assert.equal(result.numChecked, 0);
+ assert.deepEqual(
+ result.issues.map((i) => i.reason),
+ ["local-data-changed"],
+ );
+});
+
+test("onlyFresh request option accepts booleans and rejects non-boolean values", () => {
+ const codec = codecForTestingCheckCoinsRequest();
+ for (const onlyFresh of [true, false]) {
+ assert.equal(
+ codec.decode({ exchangeBaseUrl: url, onlyFresh }).onlyFresh,
+ onlyFresh,
+ );
+ }
+ assert.equal(codec.decode({ exchangeBaseUrl: url }).onlyFresh, undefined);
+ for (const onlyFresh of ["false", 0]) {
+ assert.throws(() => codec.decode({ exchangeBaseUrl: url, onlyFresh }));
+ }
+});
+
+test("balance difference summary includes refunds as well as spending in history order", async () => {
+ const f = fixture();
+ f.history(
+ [
+ {
+ type: "REFUND",
+ history_offset: 2,
+ amount: a("1.98"),
+ refund_fee: a("0.02"),
+ merchant_pub: "merchant",
+ h_contract_terms: "contract",
+ rtransaction_id: 1,
+ merchant_sig: "sig",
+ },
+ deposit(),
+ ],
+ a("8.98"),
+ );
+ const result = await f.run();
+ assert.deepEqual(
+ result.issues.map((i) => i.reason),
+ ["balance-difference"],
+ );
+ assert.deepEqual(result.issues[0].exchangeOperations, [
+ ["DEPOSIT", a("3")],
+ ["REFUND", a("1.98")],
+ ]);
+});
+
+test("balance difference summary includes recoup credits and preserves repeated operations", async () => {
+ const f = fixture();
+ f.history(
+ [1, 2].map((n) => ({
+ type: "RECOUP-REFRESH-RECEIVER",
+ history_offset: n,
+ amount: a("1"),
+ coin_pub: `child${n}`,
+ timestamp: { t_s: 10 },
+ exchange_pub: "exchange-key",
+ exchange_sig: "sig",
+ })),
+ a("12"),
+ );
+ const result = await f.run();
+ assert.deepEqual(
+ result.issues.map((i) => i.reason),
+ ["balance-difference"],
+ );
+ assert.deepEqual(result.issues[0].exchangeOperations, [
+ ["RECOUP-REFRESH-RECEIVER", a("1")],
+ ["RECOUP-REFRESH-RECEIVER", a("1")],
+ ]);
+});
+
+test("material totals compare fresh denomination value with verified exchange value", async () => {
+ const f = fixture();
+ f.data.coins.push(coin("second"));
+ f.responses.set("second", {
+ status: 404,
+ body: { code: TalerErrorCode.EXCHANGE_GENERIC_COIN_UNKNOWN },
+ });
+ f.history([deposit()], a("7"));
+ const result = await testingCheckCoins(f.wex, { exchangeBaseUrl: url });
+ assert.equal(result.expectedMaterialBalance, a("20"));
+ assert.equal(result.actualMaterialBalance, a("17"));
+ assert(result.issues.some((i) => i.reason === "balance-difference"));
+});
+
+test("all-coins mode excludes dormant, suspended, lost and legacy-key coins from material totals", async () => {
+ const f = fixture();
+ for (const status of [
+ CoinStatus.Dormant,
+ CoinStatus.FreshSuspended,
+ CoinStatus.DenomLoss,
+ ])
+ f.data.coins.push({ ...coin(status), status });
+ f.data.coins.push({ ...coin("legacy"), exchangeMasterPub: "old-master" });
+ f.data.denoms.push({ ...denomination(), exchangeMasterPub: "old-master" });
+ // Even request failures for non-material coins must not hide known totals.
+ const result = await f.run(false);
+ assert.equal(result.numCoins, 5);
+ assert.equal(result.expectedMaterialBalance, a("10"));
+ assert.equal(result.actualMaterialBalance, a("10"));
+ assert.equal(
+ result.issues.filter((i) => i.reason === "request-failed").length,
+ 4,
+ );
+});
+
+test("material actual total is unavailable if any relevant history fails", async () => {
+ for (const mode of ["network", "signature", "balance"]) {
+ const f = fixture();
+ f.data.coins.push(coin("second"));
+ f.responses.set("second", {
+ status: 404,
+ body: { code: TalerErrorCode.EXCHANGE_GENERIC_COIN_UNKNOWN },
+ });
+ if (mode === "network") f.responses.delete("coin");
+ else {
+ f.history([deposit()], mode === "balance" ? a("9") : a("7"));
+ if (mode === "signature") f.signatures(false);
+ }
+ const result = await f.run();
+ assert.equal(result.expectedMaterialBalance, a("20"));
+ assert.equal(result.actualMaterialBalance, null);
+ }
+});
+
+test("material total is unavailable if the fresh coin set or current exchange key changes", async () => {
+ for (const change of ["spend", "new-coin", "key"]) {
+ const f = fixture();
+ f.onRequest(() => {
+ if (change === "spend") f.data.coins[0].status = CoinStatus.Dormant;
+ if (change === "new-coin") f.data.coins.push(coin("new"));
+ if (change === "key")
+ f.data.exchangeDetails!.masterPublicKey = "new-master";
+ });
+ const result = await f.run();
+ assert.equal(result.expectedMaterialBalance, a("10"));
+ assert.equal(result.actualMaterialBalance, null);
+ }
+});
+
+test("material zero has the exchange currency; an unknown exchange has null totals", async () => {
+ const f = fixture();
+ f.data.coins = [];
+ const known = await f.run();
+ assert.equal(known.expectedMaterialBalance, a("0"));
+ assert.equal(known.actualMaterialBalance, a("0"));
+ f.data.exchangeDetails = undefined;
+ const unknown = await f.run();
+ assert.equal(unknown.expectedMaterialBalance, null);
+ assert.equal(unknown.actualMaterialBalance, null);
+ assert.deepEqual(f.requests, []);
+});
+
+test("missing or inconsistent denomination values never produce partial material totals", async () => {
+ for (const mode of ["missing", "currency", "overflow"]) {
+ const f = fixture();
+ if (mode === "missing") f.data.denoms = [];
+ if (mode === "currency")
+ f.data.denoms[0].value = "OTHER:10" as AmountString;
+ if (mode === "overflow") {
+ f.data.denoms[0].value = a("4503599627370496");
+ f.data.coins.push(coin("second"));
+ f.responses.set("second", {
+ status: 404,
+ body: { code: TalerErrorCode.EXCHANGE_GENERIC_COIN_UNKNOWN },
+ });
+ }
+ const result = await f.run();
+ assert.equal(result.expectedMaterialBalance, null);
+ assert.equal(result.actualMaterialBalance, null);
+ }
+});
+test("spent coin histories are validated without reconstructing local operations", async () => {
+ for (const status of [CoinStatus.Dormant, CoinStatus.DenomLoss]) {
+ const f = fixture();
+ f.data.coins[0].status = status;
+ f.history([
+ deposit({ merchant_pub: "any-merchant", amount: a("3.00000000") }),
+ ]);
+ const result = await f.run(false);
+ assert.equal(result.numChecked, 1);
+ assert.deepEqual(result.issues, []);
+ assert.equal(result.expectedMaterialBalance, a("0"));
+ assert.equal(result.actualMaterialBalance, a("0"));
+ assert.equal(f.signatureChecks(), 1);
+ }
+});
+
+test("unspent coin balance differences use denomination value", async () => {
+ for (const status of [CoinStatus.Fresh, CoinStatus.FreshSuspended]) {
+ const f = fixture();
+ f.data.coins[0].status = status;
+ f.history([deposit()]);
+ const result = await f.run(false);
+ assert.equal(result.numChecked, 1);
+ assert.equal(result.issues.length, 1);
+ assert.equal(result.issues[0].reason, "balance-difference");
+ assert.deepEqual(result.issues[0].expected, { balance: a("10") });
+ assert.deepEqual(result.issues[0].actual, { balance: a("7") });
+ assert.deepEqual(result.issues[0].exchangeOperations, [
+ ["DEPOSIT", a("3")],
+ ]);
+ }
+});
+
+test("coin deletion and denomination changes invalidate checked balances", async () => {
+ for (const change of ["coin", "denomination"]) {
+ const f = fixture();
+ f.onRequest(() => {
+ if (change === "coin") f.data.coins = [];
+ else f.data.denoms[0].value = a("20");
+ });
+ const result = await f.run();
+ assert.equal(result.numChecked, 0);
+ assert.equal(result.actualMaterialBalance, null);
+ assert.deepEqual(
+ result.issues.map((i) => i.reason),
+ ["local-data-changed"],
+ );
+ }
+});
diff --git a/packages/taler-wallet-core/src/testing-check-coins.ts b/packages/taler-wallet-core/src/testing-check-coins.ts
@@ -0,0 +1,297 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import {
+ AmountString,
+ Amounts,
+ canonicalizeBaseUrl,
+ canonicalJson,
+ CoinHistoryResponse,
+ CoinStatus,
+ DenominationInfo,
+ HttpStatusCode,
+ TalerError,
+ TalerErrorCode,
+ TestingCheckCoinsIssue,
+ TestingCheckCoinsRequest,
+ TestingCheckCoinsResponse,
+} from "@gnu-taler/taler-util";
+import { WalletCoin, WalletDenomination } from "./db/records.js";
+import { WalletDbTransaction } from "./db/transaction.js";
+import { requireValidExchangeCoinHistory } from "./exchange-signatures.js";
+import { validateAndRecomputeCoinHistoryBalance } from "./refresh.js";
+import {
+ WalletExecutionContext,
+ denomRefKey,
+ walletExchangeClient,
+} from "./wallet.js";
+
+type Finding = Omit<TestingCheckCoinsIssue, "coinPub" | "denomPubHash">;
+
+/** Snapshot only coin and denomination records; never load local operation history. */
+async function loadSnapshot(
+ tx: WalletDbTransaction,
+ exchangeBaseUrl: string,
+ onlyFresh: boolean,
+) {
+ const coins = (await tx.getCoinsByExchange(exchangeBaseUrl))
+ .filter((c) => !onlyFresh || c.status === CoinStatus.Fresh)
+ .sort((a, b) => a.coinPub.localeCompare(b.coinPub));
+ const denoms = new Map(
+ (await tx.getDenominationsByRefs(coins)).map((d) => [denomRefKey(d), d]),
+ );
+ const exchangeDetails = await tx.getExchangeDetails(exchangeBaseUrl);
+ return { coins, denoms, exchangeDetails };
+}
+type Snapshot = Awaited<ReturnType<typeof loadSnapshot>>;
+
+function coinFingerprint(snapshot: Snapshot, coin: WalletCoin): string {
+ return canonicalJson({
+ coin,
+ denomination: snapshot.denoms.get(denomRefKey(coin)),
+ });
+}
+
+function materialCoins(snapshot: Snapshot): WalletCoin[] {
+ return snapshot.coins.filter(
+ (c) =>
+ c.status === CoinStatus.Fresh &&
+ c.exchangeMasterPub === snapshot.exchangeDetails?.masterPublicKey,
+ );
+}
+
+/** Do not expose a partial or saturated total as a wallet balance. */
+function sumMaterialBalance(
+ currency: string | undefined,
+ amounts: (AmountString | undefined)[],
+): AmountString | null {
+ if (!currency) return null;
+ try {
+ let total = Amounts.zeroOfCurrency(currency);
+ for (const amount of amounts) {
+ if (amount === undefined) return null;
+ const sum = Amounts.add(total, amount);
+ if (sum.saturated) return null;
+ total = sum.amount;
+ }
+ return Amounts.stringify(total);
+ } catch {
+ return null;
+ }
+}
+
+export async function testingCheckCoins(
+ wex: WalletExecutionContext,
+ req: TestingCheckCoinsRequest,
+): Promise<TestingCheckCoinsResponse> {
+ let exchangeBaseUrl: string;
+ try {
+ exchangeBaseUrl = canonicalizeBaseUrl(req.exchangeBaseUrl);
+ if (!/^https?:\/\//.test(exchangeBaseUrl) || !req.exchangeBaseUrl.trim())
+ throw Error();
+ } catch {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ { parameter: "exchangeBaseUrl" },
+ "Invalid exchange base URL",
+ );
+ }
+ const onlyFresh = req.onlyFresh ?? true;
+ const snapshot = await wex.runWalletDbTx((tx) =>
+ loadSnapshot(tx, exchangeBaseUrl, onlyFresh),
+ );
+ const material = materialCoins(snapshot);
+ const currency = snapshot.exchangeDetails?.currency;
+ const expectedMaterialBalance = sumMaterialBalance(
+ currency,
+ material.map((c) => snapshot.denoms.get(denomRefKey(c))?.value),
+ );
+ const result: TestingCheckCoinsResponse = {
+ exchangeBaseUrl,
+ expectedMaterialBalance,
+ actualMaterialBalance: material.length
+ ? null
+ : sumMaterialBalance(currency, []),
+ numCoins: snapshot.coins.length,
+ numChecked: 0,
+ issues: [],
+ };
+ if (!snapshot.coins.length) return result;
+ const records = new Map<
+ string,
+ { fingerprint: string; issues: Finding[]; balance?: AmountString }
+ >();
+ const client = walletExchangeClient(exchangeBaseUrl, wex);
+ for (const coin of snapshot.coins) {
+ const findings: Finding[] = [];
+ records.set(coin.coinPub, {
+ fingerprint: coinFingerprint(snapshot, coin),
+ issues: findings,
+ });
+ let response: CoinHistoryResponse | undefined;
+ try {
+ const sig = await wex.cryptoApi.signCoinHistoryRequest({
+ coinPriv: coin.coinPriv,
+ coinPub: coin.coinPub,
+ startOffset: 0,
+ });
+ const resp = await client.getCoinHistory(coin.coinPub, sig.sig);
+ if (resp.case === "ok") response = resp.body;
+ else if (
+ !(
+ resp.case === HttpStatusCode.NotFound &&
+ resp.detail?.code === TalerErrorCode.EXCHANGE_GENERIC_COIN_UNKNOWN
+ )
+ ) {
+ findings.push({
+ category: "error",
+ reason: "request-failed",
+ description: "Exchange coin history request failed",
+ actual: {
+ httpStatus: resp.case,
+ ...(resp.detail?.code !== undefined
+ ? { errorCode: resp.detail.code }
+ : {}),
+ },
+ });
+ continue;
+ }
+ } catch {
+ findings.push({
+ category: "error",
+ reason: "request-failed",
+ description: "Could not retrieve or decode the exchange coin history",
+ });
+ continue;
+ }
+ const d = snapshot.denoms.get(denomRefKey(coin));
+ if (!d) {
+ findings.push({
+ category: "incomplete",
+ reason: "missing-local-data",
+ description: "Coin denomination is no longer retained",
+ });
+ continue;
+ }
+ let denom: DenominationInfo;
+ try {
+ denom = WalletDenomination.toDenomInfo(d);
+ } catch {
+ findings.push({
+ category: "incomplete",
+ reason: "missing-local-data",
+ description: "Local denomination data could not be decoded",
+ });
+ continue;
+ }
+ if (!response)
+ response = {
+ h_denom_pub: coin.denomPubHash,
+ balance: denom.value,
+ history: [],
+ };
+ try {
+ // Offsets, not wire array order, specify chronological balance changes.
+ response.history.sort((a, b) => a.history_offset - b.history_offset);
+ await requireValidExchangeCoinHistory(wex, {
+ exchangeBaseUrl,
+ coinPub: coin.coinPub,
+ denomination: denom,
+ response,
+ });
+ records.get(coin.coinPub)!.balance =
+ validateAndRecomputeCoinHistoryBalance(
+ coin.denomPubHash,
+ denom.value,
+ response,
+ );
+ } catch {
+ findings.push({
+ category: "error",
+ reason: "invalid-history",
+ description:
+ "Exchange history fails signature, denomination, fee, or balance validation",
+ });
+ continue;
+ }
+ // Fresh and suspended-fresh coins have never been allocated for spending.
+ // Other statuses do not imply a remaining balance; no local operation
+ // records are read or reconstructed to infer one.
+ if (
+ (coin.status === CoinStatus.Fresh ||
+ coin.status === CoinStatus.FreshSuspended) &&
+ Amounts.cmp(denom.value, response.balance) !== 0
+ ) {
+ findings.push({
+ category: "mismatch",
+ reason: "balance-difference",
+ description:
+ "Exchange balance differs from the unspent coin's denomination value",
+ expected: { balance: denom.value },
+ actual: { balance: response.balance },
+ exchangeOperations: response.history.map((item) => [
+ item.type,
+ item.type === "RESERVE-OPEN-DEPOSIT"
+ ? item.coin_contribution
+ : item.amount,
+ ]),
+ });
+ }
+ }
+ const after = await wex.runWalletDbTx((tx) =>
+ loadSnapshot(tx, exchangeBaseUrl, onlyFresh),
+ );
+ const afterCoins = new Map(after.coins.map((c) => [c.coinPub, c]));
+ for (const coin of snapshot.coins) {
+ const r = records.get(coin.coinPub)!;
+ const currentCoin = afterCoins.get(coin.coinPub);
+ if (!currentCoin || coinFingerprint(after, currentCoin) !== r.fingerprint) {
+ // Discard comparisons based on a snapshot that changed during the request.
+ delete r.balance;
+ r.issues = [
+ {
+ category: "incomplete",
+ reason: "local-data-changed",
+ description:
+ "Coin or denomination changed while exchange histories were being checked",
+ },
+ ];
+ }
+ if (r.balance !== undefined) result.numChecked++;
+ result.issues.push(
+ ...r.issues.map((i) => ({
+ coinPub: coin.coinPub,
+ denomPubHash: coin.denomPubHash,
+ ...i,
+ })),
+ );
+ }
+ const materialPubs = (coins: WalletCoin[]) =>
+ coins.map((c) => c.coinPub).sort();
+ if (
+ snapshot.exchangeDetails?.masterPublicKey ===
+ after.exchangeDetails?.masterPublicKey &&
+ currency === after.exchangeDetails?.currency &&
+ canonicalJson(materialPubs(material)) ===
+ canonicalJson(materialPubs(materialCoins(after)))
+ ) {
+ result.actualMaterialBalance = sumMaterialBalance(
+ currency,
+ material.map((c) => records.get(c.coinPub)?.balance),
+ );
+ } else result.actualMaterialBalance = null;
+ return result;
+}
diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts
@@ -201,6 +201,8 @@ import {
TestPayArgs,
TestPayResult,
TestingCorruptWithdrawalCoinSelRequest,
+ TestingCheckCoinsRequest,
+ TestingCheckCoinsResponse,
TestingGetDenomStatsRequest,
TestingGetDenomStatsResponse,
TestingGetFlightRecordsResponse,
@@ -413,6 +415,7 @@ export enum WalletApiOperation {
TestingGetDbStats = "testingGetDbStats",
TestingSetTimetravel = "testingSetTimetravel",
TestingGetDenomStats = "testingGetDenomStats",
+ TestingCheckCoins = "testingCheckCoins",
TestingPing = "testingPing",
TestingGetReserveHistory = "testingGetReserveHistory",
TestingResetAllRetries = "testingResetAllRetries",
@@ -1721,6 +1724,12 @@ export type TestingGetDenomStatsOp = {
response: TestingGetDenomStatsResponse;
};
+export type TestingCheckCoinsOp = {
+ op: WalletApiOperation.TestingCheckCoins;
+ request: TestingCheckCoinsRequest;
+ response: TestingCheckCoinsResponse;
+};
+
export type TestingRunFixupOp = {
op: WalletApiOperation.TestingRunFixup;
request: RunFixupRequest;
@@ -2201,6 +2210,7 @@ export type WalletOperations = {
[WalletApiOperation.RemoveGlobalCurrencyExchange]: RemoveGlobalCurrencyExchangeOp;
[WalletApiOperation.ListAssociatedRefreshes]: ListAssociatedRefreshesOp;
[WalletApiOperation.TestingGetDenomStats]: TestingGetDenomStatsOp;
+ [WalletApiOperation.TestingCheckCoins]: TestingCheckCoinsOp;
[WalletApiOperation.TestingRunFixup]: TestingRunFixupOp;
[WalletApiOperation.TestingPing]: TestingPingOp;
[WalletApiOperation.Shutdown]: ShutdownOp;