commit 6cf375d47e91c94d985d4e626fde6fea10147067
parent fdbf4a2feaf90f59caf339743d4fdd1fa88f6b46
Author: Florian Dold <florian@dold.me>
Date: Fri, 13 Mar 2026 13:43:24 +0100
wallet-core: retry transactions, validate denoms if necessary
Diffstat:
4 files changed, 108 insertions(+), 22 deletions(-)
diff --git a/packages/taler-wallet-core/src/denomSelection.ts b/packages/taler-wallet-core/src/denomSelection.ts
@@ -27,16 +27,39 @@ import {
AbsoluteTime,
AmountJson,
Amounts,
+ DenominationInfo,
DenomSelectionState,
ForcedDenomSel,
Logger,
} from "@gnu-taler/taler-util";
-import { DenominationRecord, timestampAbsoluteFromDb } from "./db.js";
+import {
+ DenominationRecord,
+ DenominationVerificationStatus,
+ timestampAbsoluteFromDb,
+} from "./db.js";
import { isWithdrawableDenom } from "./denominations.js";
const logger = new Logger("denomSelection.ts");
/**
+ * Exception to signal that a denomination that is intended
+ * to be used has not been verified yet.
+ *
+ * Thrown from transactions to trigger a verification
+ * and re-try of the transaction.
+ */
+export class UnverifiedDenomError extends Error {
+ constructor(
+ message: string,
+ public readonly denomInfo: DenominationInfo,
+ ) {
+ super(message);
+ Object.setPrototypeOf(this, new.target.prototype);
+ this.name = this.constructor.name;
+ }
+}
+
+/**
* Get a list of denominations (with repetitions possible)
* whose total value is as close as possible to the available
* amount, but never larger.
@@ -69,6 +92,12 @@ export function selectWithdrawalDenominations(
// Precondition check
for (let i = 0; i < denoms.length; i++) {
const d = denoms[i];
+ if (d.verificationStatus === DenominationVerificationStatus.Unverified) {
+ throw new UnverifiedDenomError(
+ "unverified denomination",
+ DenominationRecord.toDenomInfo(d),
+ );
+ }
if (!isWithdrawableDenom(d)) {
throw Error(
"non-withdrawable denom passed to selectWithdrawalDenominations",
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -834,12 +834,9 @@ export async function getTotalPaymentCost(
currency: string,
pcs: SelectedProspectiveCoin[],
): Promise<AmountJson> {
- return wex.db.runReadOnlyTx(
- { storeNames: ["coins", "denominations", "denominationFamilies"] },
- async (tx) => {
- return getTotalPaymentCostInTx(wex, tx, currency, pcs);
- },
- );
+ return wex.runLegacyWalletDbTx(async (tx) => {
+ return await getTotalPaymentCostInTx(wex, tx, currency, pcs);
+ });
}
export async function getTotalPaymentCostInTx(
@@ -2874,7 +2871,7 @@ export async function confirmPay(
`recording payment on ${proposal.orderId} with session ID ${sessionId}`,
);
- await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
+ await wex.runLegacyWalletDbTx(async (tx) => {
const p = await tx.purchases.get(proposal.proposalId);
if (!p) {
return;
@@ -2955,7 +2952,7 @@ export async function confirmPay(
const confRes = await wtx.getConfig(ConfigRecordKey.DonauConfig);
logger.info(
- `dona conf: ${j2s(confRes)}, useDonau: ${
+ `donau conf: ${j2s(confRes)}, useDonau: ${
args.useDonau
}, choiceIndex: ${choiceIndex}, oidx=${p.donauOutputIndex}, ctVersion=${
contractTerms.version
diff --git a/packages/taler-wallet-core/src/query.ts b/packages/taler-wallet-core/src/query.ts
@@ -144,8 +144,11 @@ interface CursorValueResult<T> {
value: T;
}
-class TransactionAbortedError extends Error {
- constructor(m: string) {
+export class TransactionAbortedError extends Error {
+ constructor(
+ m: string,
+ public readonly exn?: Error,
+ ) {
super(m);
// Set the prototype explicitly.
@@ -713,7 +716,7 @@ function runTx<Arg, Res>(
} else {
msg = "Transaction aborted (no DB error)";
}
- const abortExn = new TransactionAbortedError(msg);
+ const abortExn = new TransactionAbortedError(msg, transactionException);
internalContext.isAborted = true;
internalContext.abortExn = abortExn;
unregisterOnCancelled();
diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts
@@ -325,6 +325,7 @@ import {
CoinSourceType,
ConfigRecordKey,
DenominationRecord,
+ WalletDbAllStoresReadWriteTransaction,
WalletDbReadOnlyTransaction,
WalletStoresV1,
applyFixups,
@@ -342,6 +343,7 @@ import {
isCandidateWithdrawableDenomRec,
isWithdrawableDenom,
} from "./denominations.js";
+import { UnverifiedDenomError } from "./denomSelection.js";
import {
checkDepositGroup,
createDepositGroup,
@@ -418,6 +420,7 @@ import {
AfterCommitInfo,
DbAccess,
DbAccessImpl,
+ TransactionAbortedError,
TriggerSpec,
} from "./query.js";
import { forceRefresh } from "./refresh.js";
@@ -479,6 +482,7 @@ import {
getWithdrawalDetailsForAmount,
getWithdrawalDetailsForUri,
prepareBankIntegratedWithdrawal,
+ updateWithdrawalDenomsForExchange,
} from "./withdraw.js";
const logger = new Logger("wallet.ts");
@@ -490,6 +494,17 @@ const logger = new Logger("wallet.ts");
* request handler or for a shepherded task.
*/
export interface WalletExecutionContext {
+ /**
+ * Start a database transaction.
+ * Uses the legacy transaction that only works with IndexedDB.
+ */
+ runLegacyWalletDbTx<T>(
+ f: (tx: WalletDbAllStoresReadWriteTransaction) => Promise<T>,
+ ): Promise<T>;
+ /**
+ * Start a database transaction.
+ * Uses the database abstraction layer.
+ */
runWalletDbTx<T>(f: (tx: WalletDbTransaction) => Promise<T>): Promise<T>;
readonly ws: InternalWalletState;
readonly cryptoApi: TalerCryptoInterface;
@@ -2905,7 +2920,10 @@ export function getObservedWalletExecutionContext(
const db = ws.createDbAccessHandle(cancellationToken);
const wex: WalletExecutionContext = {
async runWalletDbTx(f) {
- return await ws.runWalletDbTx(f);
+ return await runWalletDbTx(wex, f);
+ },
+ async runLegacyWalletDbTx(f) {
+ return await runLegacyWalletDbTx(wex, f);
},
ws,
cancellationToken,
@@ -2919,6 +2937,50 @@ export function getObservedWalletExecutionContext(
return wex;
}
+async function runWalletDbTx<T>(
+ wex: WalletExecutionContext,
+ f: (tx: WalletDbTransaction) => Promise<T>,
+): Promise<T> {
+ // FIXME: Here's where we should add some retry logic.
+ return wex.db.runAllStoresReadWriteTx({}, async (mytx) => {
+ const tx = new IdbWalletTransaction(mytx);
+ return await f(tx);
+ });
+}
+
+async function runLegacyWalletDbTx<T>(
+ wex: WalletExecutionContext,
+ f: (tx: WalletDbAllStoresReadWriteTransaction) => Promise<T>,
+): Promise<T> {
+ // FIXME: Make sure this doesn't recurse
+ const coveredExchanges = new Set<string>();
+ while (1) {
+ try {
+ return await wex.db.runAllStoresReadWriteTx({}, async (mytx) => {
+ return await f(mytx);
+ });
+ } catch (e) {
+ if (
+ e instanceof TransactionAbortedError &&
+ e.exn instanceof UnverifiedDenomError
+ ) {
+ const url = e.exn.denomInfo.exchangeBaseUrl;
+ logger.info(`got unverified denominations, updating ${url}`);
+ if (coveredExchanges.has(url)) {
+ logger.error(`exchange was already covered, giving up`);
+ throw e;
+ }
+ await fetchFreshExchange(wex, url);
+ await updateWithdrawalDenomsForExchange(wex, url);
+ coveredExchanges.add(url);
+ } else {
+ throw e;
+ }
+ }
+ }
+ throw Error("not reached");
+}
+
export function getNormalWalletExecutionContext(
ws: InternalWalletState,
cancellationToken: CancellationToken,
@@ -2928,7 +2990,10 @@ export function getNormalWalletExecutionContext(
const db = ws.createDbAccessHandle(cancellationToken);
const wex: WalletExecutionContext = {
async runWalletDbTx(f) {
- return await ws.runWalletDbTx(f);
+ return await runWalletDbTx(wex, f);
+ },
+ async runLegacyWalletDbTx(f) {
+ return await runLegacyWalletDbTx(wex, f);
},
ws,
cancellationToken,
@@ -3406,14 +3471,6 @@ export class InternalWalletState {
}
}
- runWalletDbTx<T>(f: (tx: WalletDbTransaction) => Promise<T>): Promise<T> {
- // FIXME: Here's where we should add some retry logic.
- return this.db.runAllStoresReadWriteTx({}, async (mytx) => {
- const tx = new IdbWalletTransaction(mytx);
- return await f(tx);
- });
- }
-
createDbAccessHandle(
cancellationToken: CancellationToken,
): DbAccess<typeof WalletStoresV1> {