taler-typescript-core

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

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:
Mpackages/taler-wallet-core/src/denomSelection.ts | 31++++++++++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/pay-merchant.ts | 13+++++--------
Mpackages/taler-wallet-core/src/query.ts | 9++++++---
Mpackages/taler-wallet-core/src/wallet.ts | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
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> {