commit f6b59b8b96fcdd4d87fb1e76f32f6b03dcb506be
parent 308b2b3dab853a9505e4cd9343ed0715e112e6be
Author: Florian Dold <dold@taler.net>
Date: Thu, 6 Aug 2026 18:26:41 +0200
harness: check that a cherry-picked /keys keeps the older denominations
Issue: https://bugs.taler.net/n/11715
Diffstat:
2 files changed, 314 insertions(+), 0 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-exchange-keys-cherrypick.ts b/packages/taler-harness/src/integrationtests/test-exchange-keys-cherrypick.ts
@@ -0,0 +1,312 @@
+/*
+ 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,
+ TalerCorebankApiClient,
+ URL,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ applyTimeTravelV2,
+ createWalletDaemonWithClient,
+ withdrawViaBankV3,
+} from "../harness/environments.js";
+import { FaultInjectedExchangeService } from "../harness/faultInjection.js";
+import {
+ BankService,
+ ExchangeService,
+ GlobalTestState,
+ getTestHarnessPaytoForLabel,
+ setupDb,
+} from "../harness/harness.js";
+
+/**
+ * One observed request to /keys.
+ */
+interface KeysRequestRecord {
+ /** Value of the last_issue_date query parameter, if any. */
+ lastIssueDate: string | undefined;
+ /** Size of the response body, to see cherry-picking actually save data. */
+ responseSize: number;
+}
+
+/**
+ * Check that the wallet cherry-picks the exchange's /keys response, and that
+ * a cherry-picked response -- which leaves out every denomination the wallet
+ * already knows about -- does not make it forget those denominations.
+ *
+ * Taking their absence for retirement would strand the coins the wallet holds
+ * of them, which is real money, so this is the property that matters.
+ */
+export async function runExchangeKeysCherrypickTest(
+ t: GlobalTestState,
+): Promise<void> {
+ // Set up test environment
+
+ const db = await setupDb(t);
+
+ const bank = await BankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const exchangeBankUsername = "exchange";
+ const exchangeBankPassword = "mypw-password";
+ const exchangePaytoUri = getTestHarnessPaytoForLabel(exchangeBankUsername);
+
+ await exchange.addBankAccount("1", {
+ wireGatewayAuth: {
+ type: "basic",
+ username: exchangeBankUsername,
+ password: exchangeBankPassword,
+ },
+ wireGatewayApiBaseUrl: new URL(
+ `accounts/${exchangeBankUsername}/taler-wire-gateway/`,
+ bank.corebankApiBaseUrl,
+ ).href,
+ accountPaytoUri: exchangePaytoUri,
+ });
+
+ // The wallet talks to the exchange through a proxy, so that the test can
+ // see the /keys requests it makes.
+ const proxiedExchange = new FaultInjectedExchangeService(t, exchange, 8091);
+ // Base URL must contain the port that the proxy is listening on, otherwise
+ // the wallet rejects the response as belonging to another exchange.
+ await exchange.modifyConfig(async (config) => {
+ config.setString("exchange", "base_url", "http://localhost:8091/");
+ });
+
+ bank.setSuggestedExchange(proxiedExchange, exchangePaytoUri);
+
+ await bank.start();
+ await bank.pingUntilAvailable();
+
+ const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, {
+ auth: {
+ username: "admin",
+ password: "admin-password",
+ },
+ });
+
+ await bankClient.registerAccountExtended({
+ name: "Exchange",
+ password: exchangeBankPassword,
+ username: exchangeBankUsername,
+ is_taler_exchange: true,
+ payto_uri: exchangePaytoUri,
+ });
+
+ exchange.addOfferedCoins(defaultCoinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ const keysRequests: KeysRequestRecord[] = [];
+
+ proxiedExchange.faultProxy.addFault({
+ async modifyResponse(ctx) {
+ const url = new URL(ctx.request.requestUrl);
+ if (url.pathname !== "/keys") {
+ return;
+ }
+ keysRequests.push({
+ lastIssueDate: url.searchParams.get("last_issue_date") ?? undefined,
+ responseSize: ctx.responseBody?.byteLength ?? 0,
+ });
+ },
+ });
+
+ const { walletClient } = await createWalletDaemonWithClient(t, {
+ name: "wallet",
+ });
+
+ t.logStep("setup done");
+
+ // Withdraw digital cash into the wallet. This adds the exchange entry and
+ // thus downloads /keys for the first time.
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange: proxiedExchange,
+ amount: "TESTKUDOS:20",
+ });
+ await wres.withdrawalFinishedCond;
+
+ t.assertTrue(keysRequests.length > 0);
+ // Nothing is known about the exchange yet, so there is nothing to
+ // cherry-pick from.
+ for (const req of keysRequests) {
+ t.assertTrue(req.lastIssueDate === undefined);
+ }
+
+ const balanceBefore = await walletClient.call(
+ WalletApiOperation.GetBalances,
+ {},
+ );
+
+ const statsInitial = await walletClient.call(
+ WalletApiOperation.TestingGetDenomStats,
+ { exchangeBaseUrl: proxiedExchange.baseUrl },
+ );
+ console.log(`denomination stats after withdrawal: ${j2s(statsInitial)}`);
+ t.assertTrue(statsInitial.numKnown > 0);
+
+ t.logStep("withdrawal done");
+
+ // Move both the wallet and the exchange past the withdrawal expiration of
+ // the current denominations, so that the exchange starts offering a second
+ // generation of them. The first generation stays in /keys -- it is still
+ // depositable for two years -- and it is what the coins in the wallet are
+ // of.
+
+ await applyTimeTravelV2(
+ Duration.toMilliseconds(Duration.fromSpec({ days: 10 })),
+ { exchange, walletClient },
+ );
+
+ // Let the wallet pick the new generation up. The scheduled update may well
+ // have run by itself once the wallet's clock moved, so this only makes sure
+ // the entry is current again, without assuming which update did it. The
+ // forced one downloads the whole response, which is the size to compare the
+ // cherry-picked one against -- the very first response is no yardstick, the
+ // exchange offered half as many denominations back then.
+ keysRequests.length = 0;
+ await walletClient.call(WalletApiOperation.UpdateExchangeEntry, {
+ exchangeBaseUrl: proxiedExchange.baseUrl,
+ force: true,
+ });
+ await walletClient.call(WalletApiOperation.TestingWaitExchangeReady, {
+ exchangeBaseUrl: proxiedExchange.baseUrl,
+ forceUpdate: true,
+ });
+
+ console.log(`/keys requests of the full update: ${j2s(keysRequests)}`);
+ const fullResponse = keysRequests.find((r) => r.lastIssueDate === undefined);
+ t.assertTrue(fullResponse !== undefined);
+
+ const statsBefore = await walletClient.call(
+ WalletApiOperation.TestingGetDenomStats,
+ { exchangeBaseUrl: proxiedExchange.baseUrl },
+ );
+ console.log(`denomination stats before cherry-picking: ${j2s(statsBefore)}`);
+ // Both generations are known, and both are still offered.
+ t.assertTrue(statsBefore.numKnown > statsInitial.numKnown);
+ t.assertDeepEqual(statsBefore.numOffered, statsBefore.numKnown);
+
+ t.logStep("second generation of denominations known");
+
+ // Move only the wallet forward, past the two hours it schedules the next
+ // update after. The exchange keeps the denominations the wallet knows, so
+ // the newest stamp_start it saw is a valid cherry-picking point.
+
+ await walletClient.call(WalletApiOperation.TestingSetTimetravel, {
+ offsetMs: Duration.toMilliseconds(
+ Duration.fromSpec({ days: 10, hours: 3 }),
+ ),
+ });
+
+ keysRequests.length = 0;
+ await walletClient.call(WalletApiOperation.UpdateExchangeEntry, {
+ exchangeBaseUrl: proxiedExchange.baseUrl,
+ force: false,
+ });
+ await walletClient.call(WalletApiOperation.TestingWaitExchangeReady, {
+ exchangeBaseUrl: proxiedExchange.baseUrl,
+ forceUpdate: true,
+ });
+
+ console.log(`/keys requests of the routine update: ${j2s(keysRequests)}`);
+
+ t.assertTrue(keysRequests.length > 0);
+ const cherryPicked = keysRequests[keysRequests.length - 1];
+ t.assertTrue(cherryPicked.lastIssueDate !== undefined);
+ // The whole point of the exercise: less data on the wire.
+ t.assertTrue(cherryPicked.responseSize < fullResponse!.responseSize);
+
+ t.logStep("cherry-picked update done");
+
+ const statsAfter = await walletClient.call(
+ WalletApiOperation.TestingGetDenomStats,
+ { exchangeBaseUrl: proxiedExchange.baseUrl },
+ );
+ console.log(`denomination stats after cherry-picking: ${j2s(statsAfter)}`);
+
+ // The denominations left out of the cherry-picked response are still
+ // known, and still offered.
+ t.assertDeepEqual(statsAfter.numKnown, statsBefore.numKnown);
+ t.assertDeepEqual(statsAfter.numOffered, statsBefore.numOffered);
+ t.assertDeepEqual(statsAfter.numLost, statsBefore.numLost);
+
+ // No coin was written off, so the balance is untouched and no denom-loss
+ // transaction happened.
+ 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");
+ }
+
+ // The wallet can still spend and withdraw from the exchange.
+ const wres2 = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange: proxiedExchange,
+ amount: "TESTKUDOS:10",
+ });
+ await wres2.withdrawalFinishedCond;
+
+ // A forced update is never cherry-picked: it is the wallet's own picture of
+ // the exchange that such an update exists to rebuild.
+ keysRequests.length = 0;
+ await walletClient.call(WalletApiOperation.UpdateExchangeEntry, {
+ exchangeBaseUrl: proxiedExchange.baseUrl,
+ force: true,
+ });
+ await walletClient.call(WalletApiOperation.TestingWaitExchangeReady, {
+ exchangeBaseUrl: proxiedExchange.baseUrl,
+ forceUpdate: true,
+ });
+
+ console.log(`/keys requests of the forced update: ${j2s(keysRequests)}`);
+ t.assertTrue(keysRequests.length > 0);
+ for (const req of keysRequests) {
+ t.assertTrue(req.lastIssueDate === undefined);
+ }
+}
+
+runExchangeKeysCherrypickTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -65,6 +65,7 @@ import { runDonauTest } from "./test-donau.js";
import { runExchangeBaseUrlCompletionTest } from "./test-exchange-base-url-completion.js";
import { runExchangeDepositTest } from "./test-exchange-deposit.js";
import { runExchangeEphemeralTest } from "./test-exchange-ephemeral.js";
+import { runExchangeKeysCherrypickTest } from "./test-exchange-keys-cherrypick.js";
import { runExchangeKycAuthTest } from "./test-exchange-kyc-auth.js";
import { runExchangeManagementFaultTest } from "./test-exchange-management-fault.js";
import { runExchangeManagementTest } from "./test-exchange-management.js";
@@ -287,6 +288,7 @@ const allTests: TestMainFunction[] = [
runDepositMergeTest,
runSimplePaymentTest,
runExchangeManagementFaultTest,
+ runExchangeKeysCherrypickTest,
runExchangeTimetravelTest,
runFeeRegressionTest,
runForcedSelectionTest,