commit cb98cc6ca5eb923de6551b203eee7e51b5b4fef9
parent ae44f3187c0428ff9c84c881e194df9f1642d06b
Author: Florian Dold <dold@taler.net>
Date: Thu, 6 Aug 2026 19:02:48 +0200
harness: check which denominations of a /keys response get stored
Issue: https://bugs.taler.net/n/11334
Diffstat:
2 files changed, 159 insertions(+), 0 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-exchange-denom-storage.ts b/packages/taler-harness/src/integrationtests/test-exchange-denom-storage.ts
@@ -0,0 +1,157 @@
+/*
+ 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/>
+ */
+
+/**
+ * Imports.
+ */
+import { Duration, j2s } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import {
+ applyTimeTravelV2,
+ createSimpleTestkudosEnvironmentV3,
+ createWalletDaemonWithClient,
+ withdrawViaBankV3,
+} from "../harness/environments.js";
+import { GlobalTestState } from "../harness/harness.js";
+
+/**
+ * Check which denominations of a /keys response the wallet writes to its
+ * database.
+ *
+ * An exchange keeps advertising a denomination long after it stops issuing
+ * it, so most of a /keys response is denominations no wallet can withdraw
+ * from any more. A wallet seeing them for the first time has no use for
+ * them; a wallet that holds coins of one needs it for as long as it holds
+ * them, and dropping it would count those coins as vanished.
+ */
+export async function runExchangeDenomStorageTest(
+ t: GlobalTestState,
+): Promise<void> {
+ // Set up test environment
+
+ const { walletClient, bankClient, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ // Withdraw digital cash into the wallet, so that it holds coins of the
+ // denominations the exchange currently issues.
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+ await wres.withdrawalFinishedCond;
+
+ const balanceBefore = await walletClient.call(
+ WalletApiOperation.GetBalances,
+ {},
+ );
+
+ const statsFirstGen = await walletClient.call(
+ WalletApiOperation.TestingGetDenomStats,
+ { exchangeBaseUrl: exchange.baseUrl },
+ );
+ console.log(`stats of the first generation: ${j2s(statsFirstGen)}`);
+ t.assertTrue(statsFirstGen.numKnown > 0);
+
+ t.logStep("withdrawal done");
+
+ // Move past the withdrawal expiration of those denominations. The exchange
+ // issues a second generation and keeps advertising the first, which stays
+ // depositable for two years.
+
+ const travelMs = Duration.toMilliseconds(Duration.fromSpec({ days: 10 }));
+ await applyTimeTravelV2(travelMs, { exchange, merchant, walletClient });
+
+ await walletClient.call(WalletApiOperation.UpdateExchangeEntry, {
+ exchangeBaseUrl: exchange.baseUrl,
+ force: true,
+ });
+ await walletClient.call(WalletApiOperation.TestingWaitExchangeReady, {
+ exchangeBaseUrl: exchange.baseUrl,
+ forceUpdate: true,
+ });
+
+ const statsBothGens = await walletClient.call(
+ WalletApiOperation.TestingGetDenomStats,
+ { exchangeBaseUrl: exchange.baseUrl },
+ );
+ console.log(`stats of both generations: ${j2s(statsBothGens)}`);
+
+ // The first generation is not withdrawable any more, but this wallet holds
+ // coins of it: it keeps the denominations, still marked as offered, and
+ // writes nothing off.
+ const secondGenSize = statsBothGens.numKnown - statsFirstGen.numKnown;
+ t.assertTrue(secondGenSize > 0);
+ t.assertDeepEqual(statsBothGens.numOffered, statsBothGens.numKnown);
+
+ const balanceAfter = await walletClient.call(
+ WalletApiOperation.GetBalances,
+ {},
+ );
+ t.assertDeepEqual(balanceAfter, balanceBefore);
+
+ const txs = await walletClient.call(WalletApiOperation.GetTransactions, {
+ sort: "stable-ascending",
+ });
+ for (const tx of txs.transactions) {
+ t.assertTrue(tx.type !== "denom-loss");
+ }
+
+ t.logStep("second generation known to the first wallet");
+
+ // A wallet meeting the exchange for the first time now gets the same /keys
+ // response, but has no coins of the first generation and no use for it.
+
+ const { walletClient: freshWalletClient } =
+ await createWalletDaemonWithClient(t, { name: "wallet-fresh" });
+ await freshWalletClient.call(WalletApiOperation.TestingSetTimetravel, {
+ offsetMs: travelMs,
+ });
+
+ await freshWalletClient.call(WalletApiOperation.AddExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+ await freshWalletClient.call(WalletApiOperation.TestingWaitExchangeReady, {
+ exchangeBaseUrl: exchange.baseUrl,
+ forceUpdate: true,
+ });
+
+ const statsFresh = await freshWalletClient.call(
+ WalletApiOperation.TestingGetDenomStats,
+ { exchangeBaseUrl: exchange.baseUrl },
+ );
+ console.log(`stats of the fresh wallet: ${j2s(statsFresh)}`);
+
+ // Exactly the denominations that are still withdrawable, i.e. the ones the
+ // first wallet gained on top of the generation it had.
+ t.assertDeepEqual(statsFresh.numKnown, secondGenSize);
+ t.assertDeepEqual(statsFresh.numOffered, statsFresh.numKnown);
+
+ t.logStep("fresh wallet stored only the withdrawable denominations");
+
+ // What it stored is enough to withdraw with.
+ const wres2 = await withdrawViaBankV3(t, {
+ walletClient: freshWalletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:10",
+ });
+ await wres2.withdrawalFinishedCond;
+}
+
+runExchangeDenomStorageTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -63,6 +63,7 @@ import { runDonauMinusTTest } from "./test-donau-minus-t.js";
import { runDonauMultiTest } from "./test-donau-multi.js";
import { runDonauTest } from "./test-donau.js";
import { runExchangeBaseUrlCompletionTest } from "./test-exchange-base-url-completion.js";
+import { runExchangeDenomStorageTest } from "./test-exchange-denom-storage.js";
import { runExchangeDepositTest } from "./test-exchange-deposit.js";
import { runExchangeEphemeralTest } from "./test-exchange-ephemeral.js";
import { runExchangeKeysCherrypickTest } from "./test-exchange-keys-cherrypick.js";
@@ -289,6 +290,7 @@ const allTests: TestMainFunction[] = [
runSimplePaymentTest,
runExchangeManagementFaultTest,
runExchangeKeysCherrypickTest,
+ runExchangeDenomStorageTest,
runExchangeTimetravelTest,
runFeeRegressionTest,
runForcedSelectionTest,