taler-typescript-core

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

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:
Apackages/taler-harness/src/integrationtests/test-exchange-currency-change.ts | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 2++
Apackages/taler-util/src/scope-info.test.ts | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/exchange-master-pub.test.ts | 212+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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; + }, + ); + }); + }); +}