taler-typescript-core

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

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:
Apackages/taler-harness/src/integrationtests/test-exchange-ephemeral.ts | 178+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 2++
Mpackages/taler-wallet-core/src/common.ts | 11++++++++++-
Mpackages/taler-wallet-core/src/deposits.ts | 4++++
Mpackages/taler-wallet-core/src/exchanges.ts | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-peer-common.ts | 23++++++++++++++---------
Mpackages/taler-wallet-core/src/requests.ts | 2++
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 = {