taler-typescript-core

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

commit 2571dbcedd4841da0184c9b69acf584a03490fdd
parent 5360d30889418e17035a51a69e46d5c0936ccf96
Author: Florian Dold <florian@dold.me>
Date:   Mon, 23 Jun 2025 19:15:45 +0200

wallet-core: implement exchange base URL migration

Diffstat:
Mpackages/taler-harness/src/harness/harness.ts | 53+++++++++++++++++++++++++++++++++++++----------------
Apackages/taler-harness/src/integrationtests/test-wallet-exchange-migration.ts | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 2++
Mpackages/taler-util/src/types-taler-wallet.ts | 12++++++++++++
Mpackages/taler-wallet-core/src/db.ts | 24+++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/exchanges.ts | 343+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 16++++++++++++++++
Mpackages/taler-wallet-core/src/wallet.ts | 21+++++++++++++++++++++
8 files changed, 525 insertions(+), 51 deletions(-)

diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts @@ -67,6 +67,7 @@ import { createPlatformHttpLib, expectSuccessResponseOrThrow, readSuccessResponseJsonOrThrow, + readTalerErrorResponse, } from "@gnu-taler/taler-util/http"; import { WalletApiOperation, @@ -663,6 +664,8 @@ export async function pingProc( logger.trace(`pinging ${serviceName} at ${url}`); const resp = await harnessHttpLib.fetch(url); if (resp.status !== 200) { + const err = await readTalerErrorResponse(resp); + logger.info(`error: ${j2s(err)}`); throw Error("non-200 status code"); } logger.trace(`service ${serviceName} available`); @@ -1081,9 +1084,11 @@ export const BankService = useLibeufinBank export interface ExchangeConfig { name: string; currency: string; + hostname?: string; roundUnit?: string; httpPort: number; database: string; + allowExistingMasterPriv?: boolean; overrideTestDir?: string; overrideWireFee?: string; /** @@ -1268,7 +1273,12 @@ export class ExchangeService implements ExchangeServiceInterface { "master_priv_file", "${TALER_DATA_HOME}/exchange/offline-keys/master.priv", ); - config.setString("exchange", "base_url", `http://localhost:${e.httpPort}/`); + const hostname = e.hostname ?? "localhost"; + config.setString( + "exchange", + "base_url", + `http://${hostname}:${e.httpPort}/`, + ); config.setString("exchange", "serve", "tcp"); config.setString("exchange", "port", `${e.httpPort}`); @@ -1280,27 +1290,37 @@ export class ExchangeService implements ExchangeServiceInterface { // FIXME: Remove once the exchange default config properly ships this. config.setString("exchange", "EXPIRE_IDLE_SLEEP_INTERVAL", "1 s"); - const exchangeMasterKey = createEddsaKeyPair(); - - config.setString( - "exchange", - "master_public_key", - encodeCrock(exchangeMasterKey.eddsaPub), - ); - const masterPrivFile = config .getPath("exchange-offline", "master_priv_file") .required(); - fs.mkdirSync(path.dirname(masterPrivFile), { recursive: true }); - + let exchangeMasterKey: EddsaKeyPair; if (fs.existsSync(masterPrivFile)) { - throw new Error( - "master priv file already exists, can't create new exchange config", + if (!e.allowExistingMasterPriv) { + throw new Error( + "master priv file already exists, can't create new exchange config", + ); + } + const masterPriv = fs.readFileSync(masterPrivFile); + const masterPub = eddsaGetPublic(masterPriv); + exchangeMasterKey = { + eddsaPriv: masterPriv, + eddsaPub: masterPub, + }; + } else { + exchangeMasterKey = createEddsaKeyPair(); + fs.mkdirSync(path.dirname(masterPrivFile), { recursive: true }); + fs.writeFileSync( + masterPrivFile, + Buffer.from(exchangeMasterKey.eddsaPriv), ); } - fs.writeFileSync(masterPrivFile, Buffer.from(exchangeMasterKey.eddsaPriv)); + config.setString( + "exchange", + "master_public_key", + encodeCrock(exchangeMasterKey.eddsaPub), + ); const cfgFilename = testDir + `/exchange-${e.name}.conf`; config.writeTo(cfgFilename, { excludeDefaults: true }); @@ -1431,7 +1451,8 @@ export class ExchangeService implements ExchangeServiceInterface { } get baseUrl() { - return `http://localhost:${this.exchangeConfig.httpPort}/`; + const host = this.exchangeConfig.hostname ?? "localhost"; + return `http://${host}:${this.exchangeConfig.httpPort}/`; } isRunning(): boolean { @@ -1769,7 +1790,7 @@ export class ExchangeService implements ExchangeServiceInterface { this.exchangeHttpProc = this.globalState.spawnService( "taler-exchange-httpd", - ["-LINFO", "-c", this.configFilename, ...this.timetravelArgArr], + ["-LDEBUG", "-c", this.configFilename, ...this.timetravelArgArr], `exchange-httpd-${this.name}`, { ...process.env, ...(this.exchangeConfig.extraProcEnv ?? {}) }, ); diff --git a/packages/taler-harness/src/integrationtests/test-wallet-exchange-migration.ts b/packages/taler-harness/src/integrationtests/test-wallet-exchange-migration.ts @@ -0,0 +1,105 @@ +/* + This file is part of GNU Taler + (C) 2023 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 { + j2s, + ScopeType, + TalerCorebankApiClient, + TransactionType, +} 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 } from "../harness/harness.js"; + +/** + * Run test for basic, bank-integrated withdrawal and payment. + */ +export async function runWalletExchangeMigrationTest(t: GlobalTestState) { + // Set up test environment + + const { walletClient, bank, exchange, merchant, commonDb } = + await createSimpleTestkudosEnvironmentV3(t); + + // Withdraw digital cash into the wallet. + const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl); + + await withdrawViaBankV3(t, { + walletClient, + bankClient, + exchange, + amount: "TESTKUDOS:20", + }); + + await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + + await exchange.stop(); + + // Exchange running on a different port. + const exchange2 = ExchangeService.create(t, { + name: "testexchange-1", + currency: "TESTKUDOS", + httpPort: 8181, + hostname: "myexchange.localhost", + database: commonDb.connStr, + allowExistingMasterPriv: true, + }); + + await walletClient.call( + WalletApiOperation.TestingPlanMigrateExchangeBaseUrl, + { + oldExchangeBaseUrl: exchange.baseUrl, + newExchangeBaseUrl: exchange2.baseUrl, + }, + ); + + exchange2.addCoinConfigList(defaultCoinConfig.map((x) => x("TESTKUDOS"))); + + await exchange2.start(); + + try { + await walletClient.call(WalletApiOperation.UpdateExchangeEntry, { + exchangeBaseUrl: exchange.baseUrl, + force: true, + }); + } catch (e) {} + + const balances = await walletClient.call(WalletApiOperation.GetBalances, {}); + console.log(j2s(balances)); + + t.assertDeepEqual(balances.balances.length, 1); + const si = balances.balances[0].scopeInfo; + t.assertDeepEqual(si.type, ScopeType.Exchange); + t.assertDeepEqual(si.url, "http://myexchange.localhost:8181/"); + + const transactions = await walletClient.call( + WalletApiOperation.GetTransactionsV2, + {}, + ); + console.log(j2s(transactions)); + t.assertDeepEqual(transactions.transactions.length, 1); + const tx0 = transactions.transactions[0]; + t.assertDeepEqual(tx0.type, TransactionType.Withdrawal); + t.assertDeepEqual(tx0.exchangeBaseUrl, "http://myexchange.localhost:8181/"); +} + +runWalletExchangeMigrationTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -156,6 +156,7 @@ import { runWalletDd48Test } from "./test-wallet-dd48.js"; import { runWalletDenomExpireTest } from "./test-wallet-denom-expire.js"; import { runWalletDevExperimentsTest } from "./test-wallet-dev-experiments.js"; import { runWalletDevexpFakeprotoverTest } from "./test-wallet-devexp-fakeprotover.js"; +import { runWalletExchangeMigrationTest } from "./test-wallet-exchange-migration.js"; import { runWalletExchangeUpdateTest } from "./test-wallet-exchange-update.js"; import { runWalletGenDbTest } from "./test-wallet-gendb.js"; import { runWalletInsufficientBalanceTest } from "./test-wallet-insufficient-balance.js"; @@ -351,6 +352,7 @@ const allTests: TestMainFunction[] = [ runKycMerchantDepositFormTest, runExchangeKycAuthTest, runTopsPeerTest, + runWalletExchangeMigrationTest, ]; export interface TestRunSpec { diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -4087,6 +4087,18 @@ export const codecForTestingWaitWalletKycRequest = .property("passed", codecForBoolean()) .build("TestingWaitWalletKycRequest"); +export interface TestingPlanMigrateExchangeBaseUrlRequest { + oldExchangeBaseUrl: string; + newExchangeBaseUrl: string; +} + +export const codecForTestingPlanMigrateExchangeBaseUrlRequest = + (): Codec<TestingPlanMigrateExchangeBaseUrlRequest> => + buildCodecForObject<TestingPlanMigrateExchangeBaseUrlRequest>() + .property("oldExchangeBaseUrl", codecForString()) + .property("newExchangeBaseUrl", codecForString()) + .build("TestingMigrateExchangeBaseUrlRequest"); + export interface StartExchangeWalletKycRequest { exchangeBaseUrl: string; amount: AmountString; diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts @@ -162,7 +162,7 @@ export const CURRENT_DB_CONFIG_KEY = "currentMainDbName"; * backwards-compatible way or object stores and indices * are added. */ -export const WALLET_DB_MINOR_VERSION = 17; +export const WALLET_DB_MINOR_VERSION = 18; declare const symDbProtocolTimestamp: unique symbol; @@ -2746,11 +2746,33 @@ export interface CurrencyInfoRecord { source: "exchange" | "user" | "preset"; } +export enum ExchangeMigrationReason { + MismatchedBaseUrl = "mismatched-base-url", + UnavailableOldUrl = "unavailable-old-url", +} + +export interface ExchangeMigrationLogRecord { + oldExchangeBaseUrl: string; + newExchangeBaseUrl: string; + timestamp: DbPreciseTimestamp; + /** + * Reason that triggered the exchange base URL migration. + */ + reason: ExchangeMigrationReason; +} + /** * Schema definition for the IndexedDB * wallet database. */ export const WalletStoresV1 = { + exchangeBaseUrlMigrationLog: describeStoreV2({ + recordCodec: passthroughCodec<ExchangeMigrationLogRecord>(), + storeName: "exchangeBaseUrlMigrationLog", + keyPath: ["oldExchangeBaseUrl", "newExchangeBaseUrl"], + versionAdded: 18, + indexes: {}, + }), denomLossEvents: describeStoreV2({ recordCodec: passthroughCodec<DenomLossEventRecord>(), storeName: "denomLossEvents", diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -77,6 +77,7 @@ import { TalerPreciseTimestamp, TalerProtocolDuration, TalerProtocolTimestamp, + TestingPlanMigrateExchangeBaseUrlRequest, TestingWaitExchangeStateRequest, TestingWaitWalletKycRequest, Transaction, @@ -126,7 +127,6 @@ import { TaskIdStr, TaskIdentifiers, TaskRunResult, - TaskRunResultType, TransactionContext, cancelableFetch, cancelableLongPoll, @@ -148,6 +148,7 @@ import { ExchangeEntryDbRecordStatus, ExchangeEntryDbUpdateStatus, ExchangeEntryRecord, + ExchangeMigrationReason, ReserveRecord, ReserveRecordStatus, WalletDbAllStoresReadOnlyTransaction, @@ -186,7 +187,11 @@ import { rematerializeTransactions, } from "./transactions.js"; import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions.js"; -import { InternalWalletState, WalletExecutionContext } from "./wallet.js"; +import { + InternalWalletState, + WalletExecutionContext, + walletExchangeClient, +} from "./wallet.js"; import { WithdrawTransactionContext, updateWithdrawalDenomsForExchange, @@ -688,7 +693,7 @@ export async function forgetExchangeTermsOfService( async function validateWireInfo( wex: WalletExecutionContext, versionCurrent: number, - wireInfo: ExchangeKeysDownloadResult, + wireInfo: ExchangeKeysDownloadSuccessResult, masterPublicKey: string, ): Promise<WireInfo> { for (const a of wireInfo.accounts) { @@ -886,7 +891,7 @@ async function provideExchangeRecordInTx( return { exchange, exchangeDetails, notification }; } -export interface ExchangeKeysDownloadResult { +export interface ExchangeKeysDownloadSuccessResult { baseUrl: string; masterPublicKey: string; currency: string; @@ -908,6 +913,10 @@ export interface ExchangeKeysDownloadResult { bankComplianceLanguage: string | undefined; } +export type ExchangeKeysDownloadResult = + | { type: "ok"; res: ExchangeKeysDownloadSuccessResult } + | { type: "version-incompatible"; exchangeProtocolVersion: string }; + /** * Download and validate an exchange's /keys data. */ @@ -917,10 +926,7 @@ async function downloadExchangeKeysInfo( timeout: Duration, cancellationToken: CancellationToken, noCache: boolean, -): Promise< - | { type: "ok"; res: ExchangeKeysDownloadResult } - | { type: "version-incompatible"; exchangeProtocolVersion: string } -> { +): Promise<ExchangeKeysDownloadResult> { const keysUrl = new URL("keys", baseUrl); const headers: Record<string, string> = {}; @@ -1048,7 +1054,7 @@ async function downloadExchangeKeysInfo( } } - const res: ExchangeKeysDownloadResult = { + const res: ExchangeKeysDownloadSuccessResult = { masterPublicKey: exchangeKeysResponseUnchecked.master_public_key, currency, baseUrl: exchangeKeysResponseUnchecked.base_url, @@ -1502,7 +1508,7 @@ async function waitReadyExchange( } function checkPeerPaymentsDisabled( - keysInfo: ExchangeKeysDownloadResult, + keysInfo: ExchangeKeysDownloadSuccessResult, ): boolean { const now = AbsoluteTime.now(); for (let gf of keysInfo.globalFees) { @@ -1520,7 +1526,7 @@ function checkPeerPaymentsDisabled( return true; } -function checkNoFees(keysInfo: ExchangeKeysDownloadResult): boolean { +function checkNoFees(keysInfo: ExchangeKeysDownloadSuccessResult): boolean { for (const gf of keysInfo.globalFees) { if (!Amounts.isZero(gf.account_fee)) { return false; @@ -1695,13 +1701,48 @@ export async function updateExchangeFromUrlHandler( const timeout = getExchangeRequestTimeout(); - const keysInfoRes = await downloadExchangeKeysInfo( - exchangeBaseUrl, - wex.http, - timeout, - wex.cancellationToken, - oldExchangeRec.cachebreakNextUpdate ?? false, - ); + let keysInfoRes: ExchangeKeysDownloadResult; + + try { + keysInfoRes = await downloadExchangeKeysInfo( + exchangeBaseUrl, + wex.http, + timeout, + wex.cancellationToken, + oldExchangeRec.cachebreakNextUpdate ?? false, + ); + } catch (e) { + logger.warn(`unable to download exchange keys for ${exchangeBaseUrl}`); + // If keys download fails, check if there's a migration. + // Only if the migration target is reachable, migrate there! + const plan = wex.ws.exchangeMigrationPlan.get(exchangeBaseUrl); + if (plan) { + logger.warn( + `trying migration from ${exchangeBaseUrl} to ${plan.newExchangeBaseUrl}`, + ); + const newExchangeClient = walletExchangeClient( + plan.newExchangeBaseUrl, + wex, + ); + const newExchangeKeys = await newExchangeClient.getKeys(); + logger.info(`new exchange status ${newExchangeKeys.case}`); + if ( + newExchangeKeys.case !== "ok" || + newExchangeKeys.body.base_url !== plan.newExchangeBaseUrl + ) { + logger.info(`not migrating`); + throw e; + } + logger.info(`migrating`); + await migrateExchange(wex, { + oldExchangeBaseUrl: exchangeBaseUrl, + newExchangeBaseUrl: plan.newExchangeBaseUrl, + trigger: ExchangeMigrationReason.UnavailableOldUrl, + }); + return TaskRunResult.finished(); + } + throw e; + } logger.trace("validating exchange wire info"); @@ -1716,6 +1757,46 @@ export async function updateExchangeFromUrlHandler( const keysInfo = keysInfoRes.res; + if (keysInfo.baseUrl != exchangeBaseUrl) { + const plan = wex.ws.exchangeMigrationPlan.get(exchangeBaseUrl); + if (plan?.newExchangeBaseUrl === keysInfo.baseUrl) { + const newExchangeClient = walletExchangeClient( + plan.newExchangeBaseUrl, + wex, + ); + const newExchangeKeys = await newExchangeClient.getKeys(); + if ( + newExchangeKeys.case !== "ok" || + newExchangeKeys.body.base_url !== plan.newExchangeBaseUrl + ) { + logger.warn("ignoring migration record, new URL not reachable"); + const errorDetail: TalerErrorDetail = makeErrorDetail( + TalerErrorCode.WALLET_EXCHANGE_BASE_URL_MISMATCH, + { + urlWallet: exchangeBaseUrl, + urlExchange: keysInfo.baseUrl, + }, + ); + return TaskRunResult.error(errorDetail); + } + await migrateExchange(wex, { + oldExchangeBaseUrl: exchangeBaseUrl, + newExchangeBaseUrl: plan.newExchangeBaseUrl, + trigger: ExchangeMigrationReason.MismatchedBaseUrl, + }); + return TaskRunResult.backoff(); + } + logger.warn("exchange base URL mismatch"); + const errorDetail: TalerErrorDetail = makeErrorDetail( + TalerErrorCode.WALLET_EXCHANGE_BASE_URL_MISMATCH, + { + urlWallet: exchangeBaseUrl, + urlExchange: keysInfo.baseUrl, + }, + ); + return TaskRunResult.error(errorDetail); + } + const version = LibtoolVersion.parseVersion(keysInfo.protocolVersion); if (!version) { // Should have been validated earlier. @@ -1735,21 +1816,6 @@ export async function updateExchangeFromUrlHandler( keysInfo.masterPublicKey, ); - if (keysInfo.baseUrl != exchangeBaseUrl) { - logger.warn("exchange base URL mismatch"); - const errorDetail: TalerErrorDetail = makeErrorDetail( - TalerErrorCode.WALLET_EXCHANGE_BASE_URL_MISMATCH, - { - urlWallet: exchangeBaseUrl, - urlExchange: keysInfo.baseUrl, - }, - ); - return { - type: TaskRunResultType.Error, - errorDetail, - }; - } - logger.trace("finished validating exchange /wire info"); const tosMeta = await downloadTosMeta(wex, exchangeBaseUrl); @@ -2627,7 +2693,7 @@ export async function getExchangeTos( * obtained by requesting /keys. */ export interface ExchangeInfo { - keys: ExchangeKeysDownloadResult; + keys: ExchangeKeysDownloadSuccessResult; } /** @@ -3457,6 +3523,16 @@ export async function handleTestingWaitExchangeWalletKyc( return {}; } +export async function handleTestingPlanMigrateExchangeBaseUrl( + wex: WalletExecutionContext, + req: TestingPlanMigrateExchangeBaseUrlRequest, +): Promise<EmptyObject> { + wex.ws.exchangeMigrationPlan.set(req.oldExchangeBaseUrl, { + newExchangeBaseUrl: req.newExchangeBaseUrl, + }); + return {}; +} + /** * Start a wallet KYC process. * @@ -4001,3 +4077,202 @@ export async function getPreferredExchangeForCurrency( ); return url; } + +interface MigrateExchangeRequest { + oldExchangeBaseUrl: string; + newExchangeBaseUrl: string; + trigger: ExchangeMigrationReason; +} + +export async function migrateExchange( + wex: WalletExecutionContext, + req: MigrateExchangeRequest, +): Promise<void> { + await wex.db.runAllStoresReadWriteTx({}, async (tx) => { + const migrationRec = await tx.exchangeBaseUrlMigrationLog.get([ + req.oldExchangeBaseUrl, + req.newExchangeBaseUrl, + ]); + if (migrationRec) { + logger.warn( + `exchange ${migrationRec.oldExchangeBaseUrl} already migrated`, + ); + return; + } + + const exch = await tx.exchanges.get(req.oldExchangeBaseUrl); + if (!exch) { + logger.warn(`exchange ${req.oldExchangeBaseUrl} does not exist anymore`); + return; + } + + await tx.exchangeBaseUrlMigrationLog.put({ + oldExchangeBaseUrl: req.oldExchangeBaseUrl, + newExchangeBaseUrl: req.newExchangeBaseUrl, + timestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()), + reason: req.trigger, + }); + + { + const denomKeys = + await tx.denominations.indexes.byExchangeBaseUrl.getAllKeys( + req.oldExchangeBaseUrl, + ); + + for (const dk of denomKeys) { + const rec = await tx.denominations.get(dk); + if (!rec) { + continue; + } + rec.exchangeBaseUrl = req.newExchangeBaseUrl; + await tx.denominations.put(rec); + } + } + + { + await tx.denomLossEvents.iter().forEachAsync(async (rec) => { + if (rec.exchangeBaseUrl === req.oldExchangeBaseUrl) { + rec.exchangeBaseUrl = req.newExchangeBaseUrl; + await tx.denomLossEvents.put(rec); + } + }); + } + + { + await tx.recoupGroups.indexes.byExchangeBaseUrl + .iter(req.oldExchangeBaseUrl) + .forEachAsync(async (rec) => { + rec.exchangeBaseUrl = req.newExchangeBaseUrl; + await tx.recoupGroups.put(rec); + }); + } + + { + await tx.exchangeDetails.indexes.byExchangeBaseUrl + .iter(req.oldExchangeBaseUrl) + .forEachAsync(async (rec) => { + rec.exchangeBaseUrl = req.newExchangeBaseUrl; + await tx.exchangeDetails.put(rec); + }); + } + + { + const rec = await tx.exchanges.get(req.oldExchangeBaseUrl); + if (rec) { + rec.baseUrl = req.newExchangeBaseUrl; + await tx.exchanges.delete(req.oldExchangeBaseUrl); + await tx.exchanges.put(rec); + } + } + + { + await tx.coins.indexes.byBaseUrl + .iter(req.oldExchangeBaseUrl) + .forEachAsync(async (rec) => { + rec.exchangeBaseUrl = req.newExchangeBaseUrl; + await tx.coins.put(rec); + }); + } + + { + await tx.coinAvailability.indexes.byExchangeBaseUrl + .iter(req.oldExchangeBaseUrl) + .forEachAsync(async (rec) => { + await tx.coinAvailability.delete([ + rec.exchangeBaseUrl, + rec.denomPubHash, + rec.maxAge, + ]); + rec.exchangeBaseUrl = req.newExchangeBaseUrl; + await tx.coinAvailability.put(rec); + }); + } + + { + await tx.peerPullCredit.iter().forEachAsync(async (rec) => { + if (rec.exchangeBaseUrl === req.oldExchangeBaseUrl) { + rec.exchangeBaseUrl = req.newExchangeBaseUrl; + await tx.peerPullCredit.put(rec); + } + }); + } + + { + await tx.peerPullDebit.iter().forEachAsync(async (rec) => { + if (rec.exchangeBaseUrl === req.oldExchangeBaseUrl) { + rec.exchangeBaseUrl = req.newExchangeBaseUrl; + await tx.peerPullDebit.put(rec); + } + }); + } + + { + await tx.peerPushCredit.iter().forEachAsync(async (rec) => { + if (rec.exchangeBaseUrl === req.oldExchangeBaseUrl) { + rec.exchangeBaseUrl = req.newExchangeBaseUrl; + await tx.peerPushCredit.put(rec); + } + }); + } + + { + await tx.peerPushDebit.iter().forEachAsync(async (rec) => { + if (rec.exchangeBaseUrl === req.oldExchangeBaseUrl) { + rec.exchangeBaseUrl = req.newExchangeBaseUrl; + await tx.peerPushDebit.put(rec); + } + }); + } + + { + await tx.withdrawalGroups.indexes.byExchangeBaseUrl + .iter(req.oldExchangeBaseUrl) + .forEachAsync(async (rec) => { + rec.exchangeBaseUrl = req.newExchangeBaseUrl; + await tx.withdrawalGroups.put(rec); + }); + } + + { + await tx.refreshGroups.iter().forEachAsync(async (rec) => { + if ( + rec.infoPerExchange && + rec.infoPerExchange[req.oldExchangeBaseUrl] != null + ) { + rec.infoPerExchange[req.newExchangeBaseUrl] = + rec.infoPerExchange[req.oldExchangeBaseUrl]; + delete rec.infoPerExchange[req.oldExchangeBaseUrl]; + await tx.refreshGroups.put(rec); + } + }); + } + + { + await tx.depositGroups.iter().forEachAsync(async (rec) => { + if ( + rec.infoPerExchange && + rec.infoPerExchange[req.oldExchangeBaseUrl] != null + ) { + rec.infoPerExchange[req.newExchangeBaseUrl] = + rec.infoPerExchange[req.oldExchangeBaseUrl]; + delete rec.infoPerExchange[req.oldExchangeBaseUrl]; + await tx.depositGroups.put(rec); + } + }); + } + + tx.notify({ + type: NotificationType.ExchangeStateTransition, + exchangeBaseUrl: req.oldExchangeBaseUrl, + oldExchangeState: getExchangeState(exch), + newExchangeState: undefined, + }); + + tx.notify({ + type: NotificationType.ExchangeStateTransition, + exchangeBaseUrl: req.newExchangeBaseUrl, + oldExchangeState: undefined, + newExchangeState: getExchangeState(exch), + }); + }); +} diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -153,6 +153,7 @@ import { TestingGetDenomStatsRequest, TestingGetDenomStatsResponse, TestingGetReserveHistoryRequest, + TestingPlanMigrateExchangeBaseUrlRequest, TestingSetTimetravelRequest, TestingWaitExchangeStateRequest, TestingWaitTransactionRequest, @@ -306,6 +307,7 @@ export enum WalletApiOperation { TestingResetAllRetries = "testingResetAllRetries", StartExchangeWalletKyc = "startExchangeWalletKyc", TestingWaitExchangeWalletKyc = "testingWaitWalletKyc", + TestingPlanMigrateExchangeBaseUrl = "testingPlanMigrateExchangeBaseUrl", HintApplicationResumed = "hintApplicationResumed", /** @@ -722,6 +724,19 @@ export type TestingWaitExchangeWalletKycOp = { }; /** + * Enable migration from an old exchange base URL to a new + * exchange base URL. + * + * The actual migration is only applied once the exchange + * returns the new base URL. + */ +export type TestingPlanMigrateExchangeBaseUrlOp = { + op: WalletApiOperation.TestingPlanMigrateExchangeBaseUrl; + request: TestingPlanMigrateExchangeBaseUrlRequest; + response: EmptyObject; +}; + +/** * Prepare for withdrawing via a taler://withdraw-exchange URI. */ export type PrepareWithdrawExchangeOp = { @@ -1515,6 +1530,7 @@ export type WalletOperations = { [WalletApiOperation.GetBankingChoicesForPayto]: GetBankingChoicesForPaytoOp; [WalletApiOperation.StartExchangeWalletKyc]: StartExchangeWalletKycOp; [WalletApiOperation.TestingWaitExchangeWalletKyc]: TestingWaitExchangeWalletKycOp; + [WalletApiOperation.TestingPlanMigrateExchangeBaseUrl]: TestingPlanMigrateExchangeBaseUrlOp; [WalletApiOperation.HintApplicationResumed]: HintApplicationResumedOp; }; diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -218,6 +218,7 @@ import { codecForTestPayArgs, codecForTestingGetDenomStatsRequest, codecForTestingGetReserveHistoryRequest, + codecForTestingPlanMigrateExchangeBaseUrlRequest, codecForTestingSetTimetravelRequest, codecForTestingWaitWalletKycRequest, codecForTransactionByIdRequest, @@ -308,6 +309,7 @@ import { getExchangeTos, getExchangeWireDetailsInTx, handleStartExchangeWalletKyc, + handleTestingPlanMigrateExchangeBaseUrl, handleTestingWaitExchangeState, handleTestingWaitExchangeWalletKyc, listExchanges, @@ -2394,6 +2396,10 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { codec: codecForTestingWaitWalletKycRequest(), handler: handleTestingWaitExchangeWalletKyc, }, + [WalletApiOperation.TestingPlanMigrateExchangeBaseUrl]: { + codec: codecForTestingPlanMigrateExchangeBaseUrlRequest(), + handler: handleTestingPlanMigrateExchangeBaseUrl, + }, }; /** @@ -2818,6 +2824,17 @@ export class InternalWalletState { return this.dbImplementation.idbFactory; } + /** + * Planned exchange migrations. + * Maps the old exchange base URL to a new one. + */ + exchangeMigrationPlan: Map< + string, + { + newExchangeBaseUrl: string; + } + > = new Map(); + get db(): DbAccess<typeof WalletStoresV1> { if (!this._dbAccessHandle) { this._dbAccessHandle = this.createDbAccessHandle( @@ -2903,6 +2920,10 @@ export class InternalWalletState { this.cryptoDispatcher = new CryptoDispatcher(cryptoWorkerFactory); this.cryptoApi = this.cryptoDispatcher.cryptoApi; this.timerGroup = new TimerGroup(timer); + // Migration record used for testing. + this.exchangeMigrationPlan.set("http://exchange.taler.localhost:4321/", { + newExchangeBaseUrl: "http://exchange.taler2.localhost:4321/", + }); } async ensureWalletDbOpen(): Promise<void> {