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:
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> {