taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

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:
Mpackages/taler-harness/src/integrationtests/test-wallettesting.ts | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/types-taler-wallet.ts | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-cli/src/index.ts | 25+++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/requests.ts | 6++++++
Apackages/taler-wallet-core/src/testing-check-coins.test.ts | 634+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/testing-check-coins.ts | 297+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 10++++++++++
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;