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:
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;
+ }
}
}
}