commit 8c286a684659fb714b16b3c7eaef282a9a7eab7e
parent d40f79731bd2d3541d42a698225ae0da6981d7aa
Author: Florian Dold <dold@taler.net>
Date: Mon, 27 Jul 2026 19:12:00 +0200
wallet-core: remove ephemeral exchange entries on cold start
Issue: https://bugs.taler.net/n/11659
Diffstat:
7 files changed, 274 insertions(+), 10 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-exchange-ephemeral.ts b/packages/taler-harness/src/integrationtests/test-exchange-ephemeral.ts
@@ -0,0 +1,178 @@
+/*
+ This file is part of GNU Taler
+ (C) 2025 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 {
+ AbsoluteTime,
+ Duration,
+ ExchangeEntryStatus,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionType,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ createWalletDaemonWithClient,
+ withdrawViaBankV3,
+} from "../harness/environments.js";
+import {
+ GlobalTestState,
+ WalletClient,
+ WalletService,
+} from "../harness/harness.js";
+
+interface WalletDaemon {
+ walletClient: WalletClient;
+ walletService: WalletService;
+}
+
+/**
+ * An exchange entry that the wallet only contacted to show something about it
+ * is ephemeral and must not survive a restart of the wallet. As soon as
+ * anything in the wallet depends on the exchange, the entry stops being
+ * ephemeral and is kept.
+ */
+export async function runExchangeEphemeralTest(t: GlobalTestState) {
+ const { bankClient, exchange } = await createSimpleTestkudosEnvironmentV3(t);
+
+ let w1 = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ persistent: true,
+ });
+ let w2 = await createWalletDaemonWithClient(t, {
+ name: "w2",
+ persistent: true,
+ });
+
+ async function restartWallet(
+ w: WalletDaemon,
+ name: string,
+ ): Promise<WalletDaemon> {
+ w.walletClient.remoteWallet?.close();
+ await w.walletService.stop();
+ return createWalletDaemonWithClient(t, { name, persistent: true });
+ }
+
+ async function listExchangeUrls(
+ walletClient: WalletClient,
+ ): Promise<string[]> {
+ const res = await walletClient.call(WalletApiOperation.ListExchanges, {});
+ return res.exchanges.map((x) => x.exchangeBaseUrl);
+ }
+
+ await t.runSpanAsync("ephemeral entry removed on cold start", async () => {
+ await w1.walletClient.call(WalletApiOperation.AddExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ ephemeral: true,
+ });
+ const entry = await w1.walletClient.call(
+ WalletApiOperation.GetExchangeEntryByUrl,
+ { exchangeBaseUrl: exchange.baseUrl },
+ );
+ t.assertDeepEqual(entry.exchangeEntryStatus, ExchangeEntryStatus.Ephemeral);
+
+ w1 = await restartWallet(w1, "w1");
+
+ t.assertDeepEqual(await listExchangeUrls(w1.walletClient), []);
+ });
+
+ await t.runSpanAsync("used entry kept on cold start", async () => {
+ const withdrawRes = await withdrawViaBankV3(t, {
+ walletClient: w1.walletClient,
+ bankClient,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+ await withdrawRes.withdrawalFinishedCond;
+
+ w1 = await restartWallet(w1, "w1");
+
+ const entry = await w1.walletClient.call(
+ WalletApiOperation.GetExchangeEntryByUrl,
+ { exchangeBaseUrl: exchange.baseUrl },
+ );
+ t.assertDeepEqual(entry.exchangeEntryStatus, ExchangeEntryStatus.Used);
+ });
+
+ await t.runSpanAsync("incoming p2p payment keeps entry", async () => {
+ const purseExpiration = AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ days: 2 }),
+ ),
+ );
+ const initiate = await w1.walletClient.call(
+ WalletApiOperation.InitiatePeerPushDebit,
+ {
+ partialContractTerms: {
+ summary: "ephemeral exchange test",
+ amount: "TESTKUDOS:5",
+ purse_expiration: purseExpiration,
+ },
+ },
+ );
+ await w1.walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: initiate.transactionId,
+ txState: {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Ready,
+ },
+ });
+ const debitTx = await w1.walletClient.call(
+ WalletApiOperation.GetTransactionById,
+ { transactionId: initiate.transactionId },
+ );
+ t.assertTrue(debitTx.type === TransactionType.PeerPushDebit);
+
+ // The second wallet has never seen the exchange, so the entry for it is
+ // created while preparing the incoming payment.
+ t.assertDeepEqual(await listExchangeUrls(w2.walletClient), []);
+ const prepared = await w2.walletClient.call(
+ WalletApiOperation.PreparePeerPushCredit,
+ { talerUri: debitTx.talerUri! },
+ );
+
+ // The offer is a transaction the user can act on, so the exchange it
+ // refers to must not be garbage-collected.
+ const entry = await w2.walletClient.call(
+ WalletApiOperation.GetExchangeEntryByUrl,
+ { exchangeBaseUrl: exchange.baseUrl },
+ );
+ t.assertDeepEqual(entry.exchangeEntryStatus, ExchangeEntryStatus.Used);
+
+ w2 = await restartWallet(w2, "w2");
+
+ t.assertDeepEqual(await listExchangeUrls(w2.walletClient), [
+ exchange.baseUrl,
+ ]);
+
+ // The offer survived the restart and can still be accepted.
+ await w2.walletClient.call(WalletApiOperation.ConfirmPeerPushCredit, {
+ transactionId: prepared.transactionId,
+ });
+ await w2.walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: prepared.transactionId,
+ txState: {
+ major: TransactionMajorState.Done,
+ },
+ });
+ });
+}
+
+runExchangeEphemeralTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -60,6 +60,7 @@ import { runDonauMinusTTest } from "./test-donau-minus-t.js";
import { runDonauMultiTest } from "./test-donau-multi.js";
import { runDonauTest } from "./test-donau.js";
import { runExchangeDepositTest } from "./test-exchange-deposit.js";
+import { runExchangeEphemeralTest } from "./test-exchange-ephemeral.js";
import { runExchangeKycAuthTest } from "./test-exchange-kyc-auth.js";
import { runExchangeManagementFaultTest } from "./test-exchange-management-fault.js";
import { runExchangeManagementTest } from "./test-exchange-management.js";
@@ -277,6 +278,7 @@ const allTests: TestMainFunction[] = [
runKycChallengerTest,
runExchangePurseTest,
runExchangeDepositTest,
+ runExchangeEphemeralTest,
runMerchantExchangeConfusionTest,
runMerchantInstancesDeleteTest,
runMerchantInstancesTest,
diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts
@@ -83,7 +83,7 @@ import {
PeerPushDebitRecord,
} from "./db-indexeddb.js";
import { WalletDbTransaction } from "./dbtx.js";
-import { ReadyExchangeSummary } from "./exchanges.js";
+import { ReadyExchangeSummary, markExchangeUsed } from "./exchanges.js";
import { createRefreshGroup } from "./refresh.js";
import { BalanceEffect, applyNotifyTransition } from "./transactions.js";
import { WalletExecutionContext, getDenomInfo } from "./wallet.js";
@@ -1124,6 +1124,15 @@ export async function getGenericRecordHandle<T>(
return;
}
await updateMeta();
+ if (rec == null) {
+ // The transaction is new, so the exchanges it involves are now used by
+ // the wallet and their entries must not be garbage-collected as
+ // ephemeral ones anymore.
+ const meta = await tx.getTransactionMeta(ctx.transactionId);
+ for (const exchangeBaseUrl of meta?.exchanges ?? []) {
+ await markExchangeUsed(tx, exchangeBaseUrl);
+ }
+ }
applyNotifyTransition(tx.notify, ctx.transactionId, {
oldTxState,
newTxState,
diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts
@@ -118,6 +118,7 @@ import {
getExchangeDetailsInTx,
getExchangeWireFee,
getScopeForAllExchanges,
+ markExchangeUsed,
} from "./exchanges.js";
import {
EddsaKeyPairStrings,
@@ -1328,6 +1329,9 @@ async function provideDepositAccountKeypair(
exchange.currentAccountPriv = newPair.priv;
exchange.currentAccountPub = newPair.pub;
await tx.upsertExchange(exchange);
+ // The wallet keeps an account at the exchange now, so the entry must
+ // stay around even if no transaction ends up using it.
+ await markExchangeUsed(tx, exchangeBaseUrl);
});
return newPair;
}
diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts
@@ -3272,6 +3272,67 @@ async function purgeExchange(
await rematerializeTransactions(wex, tx);
}
+/**
+ * Remove all exchange entries that are still ephemeral, i.e. that the wallet
+ * only ever contacted to show something about them (fees, terms of service,
+ * ...) without anything in the wallet depending on them.
+ *
+ * Must be called before the task loop is started, while no operation of our
+ * own can be in flight: an ephemeral entry in the DB is then necessarily left
+ * over from a previous run of the wallet.
+ *
+ * An entry the wallet depends on is never ephemeral, since every resource
+ * created at an exchange (a transaction, an account, a reserve) marks the
+ * entry as used. An ephemeral entry that is in use anyway means that the
+ * marking was missed somewhere, so such an entry is repaired instead of
+ * being removed.
+ */
+export async function deleteEphemeralExchanges(
+ wex: WalletExecutionContext,
+): Promise<void> {
+ const numDeleted = await wex.runWalletDbTx(async (tx) => {
+ let n = 0;
+ const exchangeRecs = (await tx.getExchanges()).filter(
+ (x) => x.entryStatus === ExchangeEntryDbRecordStatus.Ephemeral,
+ );
+ if (exchangeRecs.length === 0) {
+ return 0;
+ }
+ const exchangesInTransactions = new Set<string>();
+ const metaRecs = await tx.listTransactionMetaByStatus({
+ onlyActive: false,
+ });
+ for (const metaRec of metaRecs) {
+ for (const exchangeBaseUrl of metaRec.exchanges) {
+ exchangesInTransactions.add(exchangeBaseUrl);
+ }
+ }
+ for (const exchangeRec of exchangeRecs) {
+ const res = await internalGetExchangeResources(tx, exchangeRec.baseUrl);
+ if (
+ res.hasResources ||
+ exchangesInTransactions.has(exchangeRec.baseUrl)
+ ) {
+ // Whatever depends on the exchange should have marked the entry as
+ // used when it was created. Removing the entry anyway would silently
+ // discard it, so repair the entry status instead.
+ logger.warn(
+ `ephemeral exchange entry ${exchangeRec.baseUrl} is in use, marking it as used instead of removing it`,
+ );
+ await markExchangeUsed(tx, exchangeRec.baseUrl);
+ continue;
+ }
+ logger.info(`removing ephemeral exchange entry ${exchangeRec.baseUrl}`);
+ await purgeExchange(wex, tx, exchangeRec, true);
+ n++;
+ }
+ return n;
+ });
+ if (numDeleted > 0) {
+ wex.ws.exchangeCache.clear();
+ }
+}
+
export async function deleteExchange(
wex: WalletExecutionContext,
req: DeleteExchangeRequest,
@@ -3635,6 +3696,9 @@ export async function handleStartExchangeWalletKyc(
oldExchangeState,
newExchangeState: getExchangeState(exchange),
});
+ // The wallet keeps an account at the exchange now, so the entry must
+ // stay around even if no transaction ends up using it.
+ await markExchangeUsed(tx, req.exchangeBaseUrl);
} else {
logger.info(
`another KYC process is already active for ${req.exchangeBaseUrl} over ${reserveRec.thresholdRequested}`,
diff --git a/packages/taler-wallet-core/src/pay-peer-common.ts b/packages/taler-wallet-core/src/pay-peer-common.ts
@@ -26,6 +26,7 @@ import {
import { WalletReserve } from "./db-common.js";
import { SpendCoinDetails } from "./crypto/cryptoImplementation.js";
import { DbPeerPushPaymentCoinSelection } from "./db-indexeddb.js";
+import { markExchangeUsed } from "./exchanges.js";
import { getTotalRefreshCost } from "./refresh.js";
import { WalletExecutionContext, getDenomInfo } from "./wallet.js";
import { updateWithdrawalDenomsForExchange } from "./withdraw.js";
@@ -134,21 +135,25 @@ export async function getMergeReserveInfo(
async (tx) => {
const ex = await tx.getExchange(req.exchangeBaseUrl);
checkDbInvariant(!!ex, `no exchange record for ${req.exchangeBaseUrl}`);
+ let reserve: WalletReserve | undefined;
if (ex.currentMergeReserveRowId != null) {
- const reserve = await tx.getReserve(ex.currentMergeReserveRowId);
+ reserve = await tx.getReserve(ex.currentMergeReserveRowId);
checkDbInvariant(
!!reserve,
`reserver ${ex.currentMergeReserveRowId} missing in db`,
);
- return reserve;
+ } else {
+ reserve = {
+ reservePriv: newReservePair.priv,
+ reservePub: newReservePair.pub,
+ };
+ reserve.rowId = await tx.upsertReserve(reserve);
+ ex.currentMergeReserveRowId = reserve.rowId;
+ await tx.upsertExchange(ex);
}
- const reserve: WalletReserve = {
- reservePriv: newReservePair.priv,
- reservePub: newReservePair.pub,
- };
- reserve.rowId = await tx.upsertReserve(reserve);
- ex.currentMergeReserveRowId = reserve.rowId;
- await tx.upsertExchange(ex);
+ // The wallet keeps an account at the exchange now, so the entry must
+ // stay around even if no transaction ends up using it.
+ await markExchangeUsed(tx, req.exchangeBaseUrl);
return reserve;
},
);
diff --git a/packages/taler-wallet-core/src/requests.ts b/packages/taler-wallet-core/src/requests.ts
@@ -293,6 +293,7 @@ import {
} from "./donau.js";
import {
acceptExchangeTermsOfService,
+ deleteEphemeralExchanges,
deleteExchange,
fetchFreshExchange,
fetchFreshExchangeWithRetryNow,
@@ -832,6 +833,7 @@ async function handleSetWalletRunConfig(
if (!wex.ws.initCalled) {
await collectLeftoverClaims(wex);
+ await deleteEphemeralExchanges(wex);
}
const resp: InitResponse = {