taler-typescript-core

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

commit ec7078d61bbed62fc62acff155ad80c8e7483beb
parent 2bf716d4db6b5aa4f41e1f81fc34c1f30e654a28
Author: Florian Dold <florian@dold.me>
Date:   Thu, 19 Mar 2026 21:17:27 +0100

wallet-core: retry transactions if exchange entry is outdated

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-peer-pull.ts | 3++-
Mpackages/taler-harness/src/integrationtests/test-timetravel-autorefresh.ts | 35+++++++++++++++++++++--------------
Mpackages/taler-wallet-core/src/exchanges.ts | 43+++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-merchant.ts | 2+-
Mpackages/taler-wallet-core/src/pay-peer-common.ts | 4++--
Mpackages/taler-wallet-core/src/query.ts | 30------------------------------
Mpackages/taler-wallet-core/src/refresh.ts | 15++++++++-------
Mpackages/taler-wallet-core/src/wallet.ts | 77++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
8 files changed, 127 insertions(+), 82 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-peer-pull.ts b/packages/taler-harness/src/integrationtests/test-peer-pull.ts @@ -432,7 +432,8 @@ export async function runPeerPullTest(t: GlobalTestState) { await exchange.stop(); exchange.setTimetravel(timetravelOffsetMs); - await Promise.all([exchange.start(), exchange.runExpireOnce()]); + await exchange.start(); + await exchange.runExpireOnce(); await Promise.all( [wallet1, wallet2, wallet3].map((w) => diff --git a/packages/taler-harness/src/integrationtests/test-timetravel-autorefresh.ts b/packages/taler-harness/src/integrationtests/test-timetravel-autorefresh.ts @@ -24,7 +24,6 @@ import { TalerCorebankApiClient, TalerMerchantInstanceHttpClient, TransactionMajorState, - j2s, succeedOrThrow, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; @@ -119,19 +118,23 @@ export async function runTimetravelAutorefreshTest(t: GlobalTestState) { await merchant.start(); await merchant.pingUntilAvailable(); - const { accessToken: adminAccessToken } = await merchant.addInstanceWithWireAccount({ - id: "admin", - name: "Default Instance", - paytoUris: [getTestHarnessPaytoForLabel("merchant-default")], - }); + const { accessToken: adminAccessToken } = + await merchant.addInstanceWithWireAccount({ + id: "admin", + name: "Default Instance", + paytoUris: [getTestHarnessPaytoForLabel("merchant-default")], + }); - await merchant.addInstanceWithWireAccount({ - id: "minst1", - name: "minst1", - paytoUris: [getTestHarnessPaytoForLabel("minst1")], - }, {adminAccessToken}); + await merchant.addInstanceWithWireAccount( + { + id: "minst1", + name: "minst1", + paytoUris: [getTestHarnessPaytoForLabel("minst1")], + }, + { adminAccessToken }, + ); const merchantAdminAccessToken = adminAccessToken; - + console.log("setup done!"); const { walletClient } = await createWalletDaemonWithClient(t, { @@ -156,7 +159,6 @@ export async function runTimetravelAutorefreshTest(t: GlobalTestState) { t.logStep("wait"); await wres.withdrawalFinishedCond; const exchangeUpdated1Cond = walletClient.waitForNotificationCond((x) => { - t.logStep(`EXCHANGE UPDATE, ${j2s(x)}`); return ( x.type === NotificationType.ExchangeStateTransition && x.exchangeBaseUrl === exchange.baseUrl @@ -251,11 +253,16 @@ export async function runTimetravelAutorefreshTest(t: GlobalTestState) { ); const orderStatus = succeedOrThrow( - await merchantClient.getOrderDetails(merchantAdminAccessToken, orderResp.order_id), + await merchantClient.getOrderDetails( + merchantAdminAccessToken, + orderResp.order_id, + ), ); t.assertTrue(orderStatus.order_status === "unpaid"); + t.logStep("Preparing payment"); + const r = await walletClient.call(WalletApiOperation.PreparePayForUri, { talerPayUri: orderStatus.taler_pay_uri, }); diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -1194,6 +1194,49 @@ export interface ReadyExchangeSummary { } /** + * Exception to signal that an exchange that is intented + * to be used in a transaction is outdated. + * + * Thrown from transactions to trigger an exchange entry update + * and re-try of the transaction. + */ +export class OutdatedExchangeError extends Error { + constructor( + message: string, + public readonly exchangeBaseUrl: string, + ) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + this.name = this.constructor.name; + } +} + +/** + * Require that an exchange entry is reasonably up to date + * within a transaction. + * + * If the exchange entry is not up to date, an exception is thrown + * that will cause the transaction to be re-tried after updating + * the exchange. + */ +export async function requireExchangeReadyTx( + wex: WalletExecutionContext, + tx: WalletIndexedDbTransaction, + exchangeBaseUrl: string, +): Promise<void> { + const exchange = await tx.exchanges.get(exchangeBaseUrl); + if (!exchange) { + // This is fatal, outer transaction will not be retried. + throw Error("exchange does not exist in database"); + } + if ( + AbsoluteTime.isExpired(timestampAbsoluteFromDb(exchange.nextUpdateStamp)) + ) { + throw new OutdatedExchangeError("exchange entry outdated", exchangeBaseUrl); + } +} + +/** * Ensure that a fresh exchange entry exists for the given * exchange base URL. * diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -773,7 +773,7 @@ export async function getTotalPaymentCost( currency: string, pcs: SelectedProspectiveCoin[], ): Promise<AmountJson> { - return wex.runLegacyWalletDbTx(async (tx) => { + return await wex.runLegacyWalletDbTx(async (tx) => { return await getTotalPaymentCostInTx(wex, tx, currency, pcs); }); } diff --git a/packages/taler-wallet-core/src/pay-peer-common.ts b/packages/taler-wallet-core/src/pay-peer-common.ts @@ -114,8 +114,8 @@ export async function getTotalPeerPaymentCost( const exchangeBaseUrl = pcs[0].exchangeBaseUrl; await updateWithdrawalDenomsForExchange(wex, exchangeBaseUrl); } - return wex.runLegacyWalletDbTx(async (tx) => { - return getTotalPeerPaymentCostInTx(wex, tx, pcs); + return await wex.runLegacyWalletDbTx(async (tx) => { + return await getTotalPeerPaymentCostInTx(wex, tx, pcs); }); } diff --git a/packages/taler-wallet-core/src/query.ts b/packages/taler-wallet-core/src/query.ts @@ -538,36 +538,6 @@ export function describeStoreV2< }; } -type KeyPathComponents = string | number; - -/** - * Follow a key path (dot-separated) in an object. - */ -type DerefKeyPath<T, P> = P extends `${infer PX extends keyof T & - KeyPathComponents}` - ? T[PX] - : P extends `${infer P0 extends keyof T & KeyPathComponents}.${infer Rest}` - ? DerefKeyPath<T[P0], Rest> - : unknown; - -/** - * Return a path if it is a valid dot-separate path to an object. - * Otherwise, return "never". - */ -type ValidateKeyPath<T, P> = P extends `${infer PX extends keyof T & - KeyPathComponents}` - ? PX - : P extends `${infer P0 extends keyof T & KeyPathComponents}.${infer Rest}` - ? `${P0}.${ValidateKeyPath<T[P0], Rest>}` - : never; - -// function foo<T, P>( -// x: T, -// p: P extends ValidateKeyPath<T, P> ? P : never, -// ): void {} - -// foo({x: [0,1,2]}, "x.0"); - export interface TransactionUtil { notify: (w: WalletNotification) => void; scheduleOnCommit(f: () => void): void; diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts @@ -106,7 +106,11 @@ import { WalletIndexedDbTransaction, } from "./db.js"; import { selectWithdrawalDenominations } from "./denomSelection.js"; -import { fetchFreshExchange, getScopeForAllExchanges } from "./exchanges.js"; +import { + fetchFreshExchange, + getScopeForAllExchanges, + requireExchangeReadyTx, +} from "./exchanges.js"; import { constructTransactionIdentifier, isUnsuccessfulTransaction, @@ -144,9 +148,7 @@ export class RefreshTransactionContext implements TransactionContext { }); } - async updateTransactionMeta( - tx: WalletIndexedDbTransaction, - ): Promise<void> { + async updateTransactionMeta(tx: WalletIndexedDbTransaction): Promise<void> { const rgRec = await tx.refreshGroups.get(this.refreshGroupId); if (!rgRec) { await tx.transactionsMeta.delete(this.transactionId); @@ -240,9 +242,7 @@ export class RefreshTransactionContext implements TransactionContext { }); } - async deleteTransactionInTx( - tx: WalletIndexedDbTransaction, - ): Promise<void> { + async deleteTransactionInTx(tx: WalletIndexedDbTransaction): Promise<void> { const [rg, h] = await this.getRecordHandle(tx); if (!rg) { logger.warn( @@ -357,6 +357,7 @@ export async function getTotalRefreshCost( const key = `denom=${exchangeBaseUrl}/${denomPubHash};left=${Amounts.stringify( amountLeft, )}`; + await requireExchangeReadyTx(wex, tx, exchangeBaseUrl); // FIXME: What about expiration of this cache? return wex.ws.refreshCostCache.getOrPut(key, async () => { const allDenoms = await getWithdrawableDenomsTx( diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -335,6 +335,7 @@ import { handleSetDonau, } from "./donau.js"; import { + OutdatedExchangeError, ReadyExchangeSummary, acceptExchangeTermsOfService, addPresetExchangeEntry, @@ -2794,15 +2795,16 @@ export function getObservedWalletExecutionContext( return wex; } -function maybeExtractUnverifiedDenomError( +function maybeExtractTransactionError( e: unknown, -): UnverifiedDenomError | undefined { - if (e instanceof UnverifiedDenomError) { +): UnverifiedDenomError | OutdatedExchangeError | undefined { + if (e instanceof UnverifiedDenomError || e instanceof OutdatedExchangeError) { return e; } if ( e instanceof TransactionAbortedError && - e.exn instanceof UnverifiedDenomError + (e.exn instanceof UnverifiedDenomError || + e.exn instanceof OutdatedExchangeError) ) { return e.exn; } @@ -2815,35 +2817,56 @@ async function handleTxRetries<T>( ): Promise<T> { const coveredExchanges = new Set<string>(); while (1) { + wex.cancellationToken.throwIfCancelled(); try { return await f(); } catch (e) { - let exn = maybeExtractUnverifiedDenomError(e); + let exn = maybeExtractTransactionError(e); if (exn == null) { throw e; } - const url = exn.denomInfo.exchangeBaseUrl; - logger.info(`got unverified denominations, updating ${url}`); - if (coveredExchanges.has(url)) { - logger.error(`exchange was already covered, giving up`); - throw e; - } - // Make sure this doesn't recurse - if (wex.ws.disableTransactionRetries) { - throw e; - } - try { - wex.ws.disableTransactionRetries = true; - await fetchFreshExchange(wex, url); - await updateWithdrawalDenomsForExchange(wex, url); - coveredExchanges.add(url); - } catch (e) { - logger.error( - `Exception thrown while trying to heal UnverifiedDenomError`, - ); - throw e; - } finally { - wex.ws.disableTransactionRetries = true; + if (exn instanceof UnverifiedDenomError) { + const url = exn.denomInfo.exchangeBaseUrl; + logger.info(`got unverified denominations, updating ${url}`); + if (coveredExchanges.has(url)) { + logger.error(`exchange was already covered, giving up`); + throw e; + } + // Make sure this doesn't recurse + if (wex.ws.disableTransactionRetries) { + throw e; + } + try { + wex.ws.disableTransactionRetries = true; + await fetchFreshExchange(wex, url); + await updateWithdrawalDenomsForExchange(wex, url); + coveredExchanges.add(url); + } catch (e) { + logger.error( + `Exception thrown while trying to heal UnverifiedDenomError`, + ); + throw e; + } finally { + wex.ws.disableTransactionRetries = true; + } + } else if (exn instanceof OutdatedExchangeError) { + const url = exn.exchangeBaseUrl; + logger.info(`got outdated exchange entry, updating ${url}`); + // Make sure this doesn't recurse + if (wex.ws.disableTransactionRetries) { + throw e; + } + try { + wex.ws.disableTransactionRetries = true; + await fetchFreshExchange(wex, url); + } catch (e) { + logger.error( + `Exception thrown while trying to heal UnverifiedDenomError`, + ); + throw e; + } finally { + wex.ws.disableTransactionRetries = true; + } } } }