commit c55ade1e183080ec23c00bf351380f24540b0eb1
parent 4f243f609d883c38c7fade3486cad5543f5be904
Author: Florian Dold <dold@taler.net>
Date: Fri, 7 Aug 2026 02:53:47 +0200
harness: check that a changed exchange currency takes the same path
A currency change reaches the same conflict branch as a changed master public
key, so it gets the same test. Adds unit coverage for the superseded-key scope
encoding and for resolving a base URL from a master public key.
Issue: https://bugs.taler.net/n/8576
Diffstat:
4 files changed, 480 insertions(+), 0 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-exchange-currency-change.ts b/packages/taler-harness/src/integrationtests/test-exchange-currency-change.ts
@@ -0,0 +1,155 @@
+/*
+ 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 {
+ ExchangeUpdateStatus,
+ TalerErrorCode,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ withdrawViaBankV3,
+} from "../harness/environments.js";
+import {
+ ExchangeService,
+ GlobalTestState,
+ setupDb,
+} from "../harness/harness.js";
+
+/**
+ * An exchange that comes back under a different currency.
+ *
+ * The wallet detects this in the same place as a changed master public key --
+ * both make the stored details incompatible with the response -- so this
+ * pins that the second branch of that check is handled the same way and did
+ * not get left behind.
+ */
+export async function runExchangeCurrencyChangeTest(
+ t: GlobalTestState,
+): Promise<void> {
+ const { walletClient, exchange, bankClient, exchangeBankAccount } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ amount: "TESTKUDOS:10",
+ bankClient,
+ exchange,
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ t.logStep("withdrawal-done");
+
+ const balanceBefore = await walletClient.call(
+ WalletApiOperation.GetBalances,
+ {},
+ );
+
+ await exchange.stop();
+
+ // Same base URL, same port, different currency.
+ const db2 = await setupDb(t, {
+ nameSuffix: "ccy",
+ });
+ const exchange2 = ExchangeService.create(t, {
+ name: "testexchange-ccy",
+ currency: "KUDOSTWO",
+ httpPort: 8081,
+ database: db2.connStr,
+ });
+
+ await exchange2.addBankAccount("1", exchangeBankAccount);
+ exchange2.addCoinConfigList(defaultCoinConfig.map((x) => x("KUDOSTWO")));
+ await exchange2.start();
+
+ t.logStep("exchange-restarted");
+
+ await walletClient.call(WalletApiOperation.UpdateExchangeEntry, {
+ exchangeBaseUrl: exchange.baseUrl,
+ force: true,
+ });
+
+ // Must not wedge, exactly as for a changed master public key.
+ await walletClient.call(WalletApiOperation.TestingWaitExchangeReady, {
+ exchangeBaseUrl: exchange.baseUrl,
+ forceUpdate: true,
+ });
+
+ const exchangesList = await walletClient.call(
+ WalletApiOperation.ListExchanges,
+ {},
+ );
+
+ console.log(j2s(exchangesList));
+
+ const entry = exchangesList.exchanges.find(
+ (e) => e.exchangeBaseUrl === exchange.baseUrl,
+ );
+ t.assertTrue(entry != null);
+ t.assertDeepEqual(entry.exchangeUpdateStatus, ExchangeUpdateStatus.Ready);
+
+ const change = entry.unconfirmedKeyChange;
+ t.assertTrue(change != null);
+ t.assertDeepEqual(change.currentCurrency, "KUDOSTWO");
+ t.assertDeepEqual(change.supersededCurrency, "TESTKUDOS");
+
+ t.logStep("currency-change-reported");
+
+ // The TESTKUDOS the wallet holds are still recorded, and were not written
+ // off as a denomination loss just because the exchange now speaks another
+ // currency. Compared per currency rather than as a whole list: the entry
+ // legitimately gains a bucket for the currency it now uses.
+ const balanceAfter = await walletClient.call(
+ WalletApiOperation.GetBalances,
+ {},
+ );
+ const oldBefore = balanceBefore.balances.find(
+ (b) => b.scopeInfo.currency === "TESTKUDOS",
+ );
+ const oldAfter = balanceAfter.balances.find(
+ (b) => b.scopeInfo.currency === "TESTKUDOS",
+ );
+ t.assertTrue(oldBefore != null && oldAfter != null);
+ t.assertAmountEquals(oldAfter.available, oldBefore.available);
+
+ t.logStep("old-funds-intact");
+
+ // And withdrawing is refused until the change is confirmed.
+ const err = await t.assertThrowsTalerErrorAsync(async () => {
+ await walletClient.call(WalletApiOperation.AcceptManualWithdrawal, {
+ exchangeBaseUrl: exchange.baseUrl,
+ amount: "KUDOSTWO:5",
+ });
+ });
+ t.assertDeepEqual(
+ err.errorDetail.code,
+ TalerErrorCode.WALLET_EXCHANGE_KEYS_NOT_ACCEPTED,
+ );
+
+ t.logStep("withdrawal-refused");
+
+ // See test-exchange-master-pub-change: a second exchange left running on the
+ // shared port breaks the next test.
+ await exchange2.stop();
+}
+
+runExchangeCurrencyChangeTest.suites = ["wallet", "exchange"];
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -69,6 +69,7 @@ import { runExchangeKeysCherrypickTest } from "./test-exchange-keys-cherrypick.j
import { runExchangeKycAuthTest } from "./test-exchange-kyc-auth.js";
import { runExchangeManagementFaultTest } from "./test-exchange-management-fault.js";
import { runExchangeManagementTest } from "./test-exchange-management.js";
+import { runExchangeCurrencyChangeTest } from "./test-exchange-currency-change.js";
import { runExchangeMasterPubChangeTest } from "./test-exchange-master-pub-change.js";
import { runExchangeMerchantKycAuthTest } from "./test-exchange-merchant-kyc-auth.js";
import { runExchangePurseTest } from "./test-exchange-purse.js";
@@ -385,6 +386,7 @@ const allTests: TestMainFunction[] = [
runWithdrawalHandoverTest,
runWithdrawalAmountTest,
runWithdrawalFlexTest,
+ runExchangeCurrencyChangeTest,
runExchangeMasterPubChangeTest,
runMerchantCategoriesTest,
runMerchantSelfProvisionActivationTest,
diff --git a/packages/taler-util/src/scope-info.test.ts b/packages/taler-util/src/scope-info.test.ts
@@ -0,0 +1,111 @@
+/*
+ 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/>
+ */
+
+/**
+ * Encodings of a scope.
+ *
+ * stringifyScopeInfo is a database key, so its output for the scopes that
+ * already existed has to stay byte-for-byte what it was; the short form is
+ * what the UIs round-trip through.
+ */
+
+import assert from "node:assert";
+import { test } from "node:test";
+
+import {
+ ScopeInfo,
+ ScopeType,
+ codecForScopeInfo,
+ parseScopeInfoShort,
+ stringifyScopeInfo,
+ stringifyScopeInfoShort,
+} from "./types-taler-wallet.js";
+
+const GLOBAL: ScopeInfo = { type: ScopeType.Global, currency: "TESTKUDOS" };
+const EXCHANGE: ScopeInfo = {
+ type: ScopeType.Exchange,
+ currency: "TESTKUDOS",
+ url: "https://exchange.test/",
+};
+const AUDITOR: ScopeInfo = {
+ type: ScopeType.Auditor,
+ currency: "TESTKUDOS",
+ url: "https://auditor.test/",
+};
+const LEGACY: ScopeInfo = {
+ type: ScopeType.ExchangeLegacyKeys,
+ currency: "TESTKUDOS",
+ url: "https://exchange.test/",
+ masterPub: "MPKMPKMPK",
+};
+
+test("the stored form of the pre-existing scopes is unchanged", () => {
+ // These are primary keys of stored currency-info records. A change here
+ // orphans every row written by an older wallet.
+ assert.strictEqual(stringifyScopeInfo(GLOBAL), "taler-si:global/TESTKUDOS");
+ assert.strictEqual(
+ stringifyScopeInfo(EXCHANGE),
+ "taler-si:exchange/TESTKUDOS/https%3A%2F%2Fexchange.test%2F",
+ );
+ assert.strictEqual(
+ stringifyScopeInfo(AUDITOR),
+ "taler-si:auditor/TESTKUDOS/https%3A%2F%2Fauditor.test%2F",
+ );
+});
+
+test("a superseded key set stores under a prefix of its own", () => {
+ assert.strictEqual(
+ stringifyScopeInfo(LEGACY),
+ "taler-si:exchange-legacy/TESTKUDOS/https%3A%2F%2Fexchange.test%2F/MPKMPKMPK",
+ );
+ // Distinct from the current key set at the same exchange, which is the
+ // whole point of the separate bucket.
+ assert.notStrictEqual(
+ stringifyScopeInfo(LEGACY),
+ stringifyScopeInfo(EXCHANGE),
+ );
+});
+
+test("the short form round-trips every scope type", () => {
+ for (const si of [GLOBAL, EXCHANGE, AUDITOR, LEGACY]) {
+ const back = parseScopeInfoShort(stringifyScopeInfoShort(si));
+ assert.deepStrictEqual(back, si, `round trip failed for ${si.type}`);
+ }
+});
+
+test("the short form round-trips a URL with a port and a path", () => {
+ const si: ScopeInfo = {
+ type: ScopeType.ExchangeLegacyKeys,
+ currency: "EUR",
+ // A path component contains the separator the third field is split on,
+ // which only works because the URL is percent-encoded.
+ url: "https://exchange.test:8081/sub/path/",
+ masterPub: "ABCDEF",
+ };
+ assert.deepStrictEqual(parseScopeInfoShort(stringifyScopeInfoShort(si)), si);
+});
+
+test("an exchange scope is not mistaken for a superseded one", () => {
+ const parsed = parseScopeInfoShort(stringifyScopeInfoShort(EXCHANGE));
+ assert.strictEqual(parsed?.type, ScopeType.Exchange);
+});
+
+test("the codec accepts a superseded key set", () => {
+ const decoded = codecForScopeInfo().decode(
+ JSON.parse(JSON.stringify(LEGACY)),
+ );
+ assert.deepStrictEqual(decoded, LEGACY);
+});
diff --git a/packages/taler-wallet-core/src/exchange-master-pub.test.ts b/packages/taler-wallet-core/src/exchange-master-pub.test.ts
@@ -0,0 +1,212 @@
+/*
+ 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/>
+ */
+
+/**
+ * Resolving a master public key back to a base URL.
+ *
+ * A coin names the key that signed its denomination, so this lookup sits
+ * between every coin and the exchange it has to talk to. It runs against
+ * both backends because a wrong answer here is not an error, it is a request
+ * sent to the wrong exchange.
+ */
+
+import assert from "node:assert";
+import { test } from "node:test";
+
+import {
+ AbsoluteTime,
+ Duration,
+ TalerError,
+ TalerErrorCode,
+ TalerPreciseTimestamp,
+ encodeCrock,
+ stringToBytes,
+} from "@gnu-taler/taler-util";
+
+import {
+ ExchangeEntryDbRecordStatus,
+ ExchangeEntryDbUpdateStatus,
+ WalletExchangeDetails,
+ WalletExchangeEntry,
+ timestampPreciseToDb,
+} from "./db-common.js";
+import { DbTxRunner } from "./dbtx-conformance.js";
+import { runnerFactories } from "./dbtx-runners.js";
+import {
+ getExchangeBaseUrlForMasterPub,
+ getExchangeBaseUrlForMasterPubOrThrow,
+} from "./exchanges.js";
+
+/** A syntactically valid 32-byte key derived from a readable label. */
+function key(label: string): string {
+ const bytes = new Uint8Array(32);
+ bytes.set(stringToBytes(label).slice(0, 32));
+ return encodeCrock(bytes);
+}
+
+function makeDetails(
+ exchangeBaseUrl: string,
+ masterPublicKey: string,
+ currency = "TESTKUDOS",
+): WalletExchangeDetails {
+ return {
+ exchangeBaseUrl,
+ masterPublicKey,
+ currency,
+ auditors: [],
+ protocolVersionRange: "18:0:1",
+ tinyAmount: `${currency}:0.01`,
+ reserveClosingDelay: { d_us: 1000 },
+ globalFees: [],
+ wireInfo: { accounts: [], feesForType: {} },
+ bankComplianceLanguage: undefined,
+ defaultPeerPushExpiration: undefined,
+ };
+}
+
+function makeEntry(baseUrl: string): WalletExchangeEntry {
+ return {
+ baseUrl,
+ entryStatus: ExchangeEntryDbRecordStatus.Preset,
+ updateStatus: ExchangeEntryDbUpdateStatus.Initial,
+ detailsPointer: undefined,
+ tosAcceptedEtag: undefined,
+ tosAcceptedTimestamp: undefined,
+ tosCurrentEtag: undefined,
+ lastKeysEtag: undefined,
+ lastUpdate: undefined,
+ nextUpdateStamp: timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()),
+ ),
+ nextRefreshCheckStamp: timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()),
+ ),
+ };
+}
+
+for (const makeRunner of runnerFactories) {
+ const withRunner = async (
+ fn: (runner: DbTxRunner) => Promise<void>,
+ ): Promise<void> => {
+ const runner = await makeRunner();
+ try {
+ await fn(runner);
+ } finally {
+ await runner.close();
+ }
+ };
+
+ test("master pub resolves to the only base URL that has it", async () => {
+ await withRunner(async (runner) => {
+ await runner.runReadWriteTx(async (tx) => {
+ await tx.upsertExchangeDetails(makeDetails("https://one/", key("m1")));
+ });
+ const got = await runner.runReadWriteTx((tx) =>
+ getExchangeBaseUrlForMasterPub(tx, key("m1")),
+ );
+ assert.strictEqual(got, "https://one/");
+ });
+ });
+
+ test("an unknown master pub resolves to nothing", async () => {
+ await withRunner(async (runner) => {
+ const got = await runner.runReadWriteTx((tx) =>
+ getExchangeBaseUrlForMasterPub(tx, key("absent")),
+ );
+ assert.strictEqual(got, undefined);
+ });
+ });
+
+ test("the entry's own pointer wins over another base URL", async () => {
+ await withRunner(async (runner) => {
+ // Both URLs carry the same key, as they do mid-migration. Only the
+ // second is the one its entry actually points at.
+ await runner.runReadWriteTx(async (tx) => {
+ await tx.upsertExchangeDetails(makeDetails("https://aaa/", key("m2")));
+ await tx.upsertExchangeDetails(makeDetails("https://zzz/", key("m2")));
+ await tx.upsertExchange(makeEntry("https://aaa/"));
+ const pointed = makeEntry("https://zzz/");
+ pointed.detailsPointer = {
+ masterPublicKey: key("m2"),
+ currency: "TESTKUDOS",
+ updateClock: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ };
+ await tx.upsertExchange(pointed);
+ });
+ const got = await runner.runReadWriteTx((tx) =>
+ getExchangeBaseUrlForMasterPub(tx, key("m2")),
+ );
+ assert.strictEqual(
+ got,
+ "https://zzz/",
+ "the pointed-at row must win over the alphabetically lower one",
+ );
+ });
+ });
+
+ test("without a pointer the currency hint decides", async () => {
+ await withRunner(async (runner) => {
+ await runner.runReadWriteTx(async (tx) => {
+ await tx.upsertExchangeDetails(
+ makeDetails("https://kudos/", key("m3"), "TESTKUDOS"),
+ );
+ await tx.upsertExchangeDetails(
+ makeDetails("https://euro/", key("m3"), "EUR"),
+ );
+ });
+ const got = await runner.runReadWriteTx((tx) =>
+ getExchangeBaseUrlForMasterPub(tx, key("m3"), { currency: "EUR" }),
+ );
+ assert.strictEqual(got, "https://euro/");
+ });
+ });
+
+ test("an ambiguous lookup is at least stable", async () => {
+ await withRunner(async (runner) => {
+ await runner.runReadWriteTx(async (tx) => {
+ await tx.upsertExchangeDetails(makeDetails("https://bbb/", key("m4")));
+ await tx.upsertExchangeDetails(makeDetails("https://aaa/", key("m4")));
+ });
+ const first = await runner.runReadWriteTx((tx) =>
+ getExchangeBaseUrlForMasterPub(tx, key("m4")),
+ );
+ const second = await runner.runReadWriteTx((tx) =>
+ getExchangeBaseUrlForMasterPub(tx, key("m4")),
+ );
+ assert.strictEqual(first, "https://aaa/");
+ assert.strictEqual(second, first);
+ });
+ });
+
+ test("the throwing variant names the key it could not resolve", async () => {
+ await withRunner(async (runner) => {
+ await assert.rejects(
+ runner.runReadWriteTx((tx) =>
+ getExchangeBaseUrlForMasterPubOrThrow(tx, key("gone")),
+ ),
+ (e: unknown) => {
+ assert.ok(e instanceof TalerError);
+ assert.strictEqual(
+ e.errorDetail.code,
+ TalerErrorCode.WALLET_EXCHANGE_ENTRY_NOT_FOUND,
+ );
+ assert.strictEqual(e.errorDetail.masterPub, key("gone"));
+ return true;
+ },
+ );
+ });
+ });
+}