taler-typescript-core

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

commit 168ba0117bbdefdc61fcaa51115897cd9ff37c4b
parent dd0b0b6eff68f50316f915387e8791e1cda974fe
Author: Florian Dold <dold@taler.net>
Date:   Wed, 19 Aug 2026 21:41:54 +0200

wallet-core: cache and batch payment preparation

Diffstat:
Mpackages/taler-util/src/cache.ts | 4++++
Mpackages/taler-wallet-core/src/common.ts | 24+++++++++++++++++++++---
Mpackages/taler-wallet-core/src/dbtx-shared.ts | 18+++++++++++++++++-
Mpackages/taler-wallet-core/src/pay-merchant.ts | 70+++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mpackages/taler-wallet-core/src/refresh.ts | 5++++-
Mpackages/taler-wallet-core/src/wallet.ts | 55++++++++++++++++++++++++++++++++++++++++++++++++++++++-
6 files changed, 153 insertions(+), 23 deletions(-)

diff --git a/packages/taler-util/src/cache.ts b/packages/taler-util/src/cache.ts @@ -67,6 +67,10 @@ export class Cache<T> { this.map.clear(); } + delete(key: string): void { + this.map.delete(key); + } + put(key: string, value: T): void { if (this.map.size > this.maxCapacity) { this.map.clear(); diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts @@ -23,6 +23,7 @@ import { AsyncFlag, CoinRefreshRequest, CoinStatus, + DenominationInfo, Duration, DurationUnitSpec, ErrorInfoSummary, @@ -66,6 +67,7 @@ import { ExchangeEntryDbRecordStatus, ExchangeEntryDbUpdateStatus, WalletCoin, + WalletCoinAvailability, WalletCoinHistory, WalletDepositGroup, WalletExchangeEntry, @@ -178,21 +180,35 @@ export async function spendCoins( return; } let refreshCoinPubs: CoinRefreshRequest[] = []; + const loadedCoins = await tx.getCoinsByPubs(csi.coinPubs); + const coinsByPub = new Map(loadedCoins.map((coin) => [coin.coinPub, coin])); + const denomByRef = new Map<string, DenominationInfo>(); + const availabilityByRef = new Map<string, WalletCoinAvailability>(); for (let i = 0; i < csi.coinPubs.length; i++) { - const coin = await tx.getCoin(csi.coinPubs[i]); + const coin = coinsByPub.get(csi.coinPubs[i]); if (!coin) { throw Error("coin allocated for payment doesn't exist anymore"); } - const denom = await getDenomInfo(wex, tx, coin); + const denomKey = `${coin.exchangeMasterPub}/${coin.denomPubHash}`; + let denom = denomByRef.get(denomKey); + if (!denom) { + denom = await getDenomInfo(wex, tx, coin); + } checkDbInvariant( !!denom, `denomination of a coin is missing hash: ${coin.denomPubHash}`, ); - const coinAvailability = await tx.getCoinAvailability(coin); + denomByRef.set(denomKey, denom); + const availabilityKey = `${denomKey}/${coin.maxAge}`; + let coinAvailability = availabilityByRef.get(availabilityKey); + if (!coinAvailability) { + coinAvailability = await tx.getCoinAvailability(coin); + } checkDbInvariant( !!coinAvailability, `age denom info is missing for ${coin.maxAge}`, ); + availabilityByRef.set(availabilityKey, coinAvailability); const contrib = csi.contributions[i]; coin.status = CoinStatus.Dormant; const remaining = Amounts.sub(denom.value, contrib); @@ -241,6 +257,8 @@ export async function spendCoins( }); await tx.upsertCoinHistory(histEntry); await tx.upsertCoin(coin); + } + for (const coinAvailability of availabilityByRef.values()) { await tx.upsertCoinAvailability(coinAvailability); } diff --git a/packages/taler-wallet-core/src/dbtx-shared.ts b/packages/taler-wallet-core/src/dbtx-shared.ts @@ -25,6 +25,7 @@ import { assertUnreachable, ScopeInfo, ScopeType } from "@gnu-taler/taler-util"; import { WalletDbTransaction } from "./dbtx.js"; +import { PurchaseStatus, WalletPurchase } from "./db-common.js"; /** * Does the exchange fall within the given scope? @@ -145,11 +146,26 @@ export const CACHE_INVALIDATING_METHODS: ReadonlySet<string> = new Set([ */ export function watchForCacheInvalidation<T extends WalletDbTransaction>( tx: T, - flag: { dirty: boolean }, + flag: { dirty: boolean; terminalPaymentIds?: Set<string> }, ): T { return new Proxy(tx, { get(target, prop, receiver) { const value = Reflect.get(target, prop, receiver); + if (prop === "upsertPurchase" && typeof value === "function") { + return (...args: unknown[]) => { + const purchase = args[0] as WalletPurchase; + if (purchase.purchaseStatus >= PurchaseStatus.Done) { + flag.terminalPaymentIds?.add(purchase.proposalId); + } + return value.apply(target, args); + }; + } + if (prop === "deletePurchase" && typeof value === "function") { + return (...args: unknown[]) => { + flag.terminalPaymentIds?.add(args[0] as string); + return value.apply(target, args); + }; + } if (typeof prop === "string" && CACHE_INVALIDATING_METHODS.has(prop)) { if (typeof value === "function") { return (...args: unknown[]) => { diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -53,6 +53,7 @@ import { GetChoicesForPaymentResult, getRandomBytes, HashCodeString, + hash, hashPayWalletData, HostPortPath, HttpStatusCode, @@ -86,6 +87,7 @@ import { SharePaymentResult, SignedTokenEnvelope, StartRefundQueryForUriResponse, + stringToBytes, TalerError, TalerErrorCode, TalerErrorDetail, @@ -847,13 +849,19 @@ export async function getTotalPaymentCostInTx( pcs: SelectedProspectiveCoin[], ): Promise<AmountJson> { const costs: AmountJson[] = []; + const denoms = new Map<string, WalletDenomination>(); for (let i = 0; i < pcs.length; i++) { - const denom = await tx.getDenomination(pcs[i]); + const denomKey = `${pcs[i].exchangeMasterPub}/${pcs[i].denomPubHash}`; + let denom = denoms.get(denomKey); + if (!denom) { + denom = await tx.getDenomination(pcs[i]); + } if (!denom) { throw Error( "can't calculate payment cost, denomination for coin not found", ); } + denoms.set(denomKey, denom); const amountLeft = Amounts.sub(denom.value, pcs[i].contribution).amount; const refreshCost = await getTotalRefreshCost( wex, @@ -2080,33 +2088,41 @@ export async function generateDepositPermissions( contractData: MerchantContractTerms, contractTermsHash: HashCodeString, walletData?: PayWalletData, + cacheScope?: string, ): Promise<CoinDepositPermission[]> { - const depositPermissions: CoinDepositPermission[] = []; const coinWithDenom: Array<{ coin: WalletCoin; denom: WalletDenomination; }> = []; await wex.runWalletDbTx(async (tx) => { - for (let i = 0; i < payCoinSel.coinContributions.length; i++) { - const coin = await tx.getCoin(payCoinSel.coinPubs[i]); + const coins = await tx.getCoinsByPubs(payCoinSel.coinPubs); + const coinsByPub = new Map(coins.map((coin) => [coin.coinPub, coin])); + const denoms = new Map<string, WalletDenomination>(); + for (const coinPub of payCoinSel.coinPubs) { + const coin = coinsByPub.get(coinPub); if (!coin) { throw Error("can't pay, allocated coin not found anymore"); } - const denom = await tx.getDenomination(coin); + const denomKey = `${coin.exchangeMasterPub}/${coin.denomPubHash}`; + let denom = denoms.get(denomKey); + if (!denom) { + denom = await tx.getDenomination(coin); + } if (!denom) { throw Error( "can't pay, denomination of allocated coin not found anymore", ); } + denoms.set(denomKey, denom); coinWithDenom.push({ coin, denom }); } }); - for (let i = 0; i < payCoinSel.coinContributions.length; i++) { - const { coin, denom } = coinWithDenom[i]; - let wireInfoHash: string; - wireInfoHash = contractData.h_wire; - const dp = await wex.cryptoApi.signDepositPermission({ + const walletDataHash = walletData + ? encodeCrock(hashPayWalletData(walletData)) + : undefined; + const signRequests = coinWithDenom.map(({ coin, denom }, i) => { + return { coinPriv: coin.coinPriv, coinPub: coin.coinPub, contractTermsHash, @@ -2119,16 +2135,35 @@ export async function generateDepositPermissions( refundDeadline: contractData.refund_deadline, spendAmount: Amounts.parseOrThrow(payCoinSel.coinContributions[i]), timestamp: contractData.timestamp, - wireInfoHash, + wireInfoHash: contractData.h_wire, ageCommitmentProof: coin.ageCommitmentProof, requiredMinimumAge: contractData.minimum_age, - walletDataHash: walletData - ? encodeCrock(hashPayWalletData(walletData)) - : undefined, - }); - depositPermissions.push(dp); + walletDataHash, + }; + }); + + if (!cacheScope) { + return await Promise.all( + signRequests.map((req) => wex.cryptoApi.signDepositPermission(req)), + ); + } + const cacheKey = encodeCrock( + hash(stringToBytes(JSON.stringify([cacheScope, signRequests]))), + ); + const cached = wex.ws.depositPermissionCache.get(cacheKey); + if (cached) { + return await cached; + } + const generated = Promise.all( + signRequests.map((req) => wex.cryptoApi.signDepositPermission(req)), + ); + wex.ws.cacheDepositPermissions(cacheScope, cacheKey, generated); + try { + return await generated; + } catch (e) { + wex.ws.forgetDepositPermission(cacheScope, cacheKey); + throw e; } - return depositPermissions; } /** @@ -3157,6 +3192,7 @@ async function processPurchasePay( download.contractTerms, download.contractTermsHash, wallet_data, + proposalId, ); const reqBody: any = { diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts @@ -361,8 +361,11 @@ export async function getTotalRefreshCost( const key = `denom=${exchangeBaseUrl}/${denomPubHash};left=${Amounts.stringify( amountLeft, )}`; + const cached = wex.ws.refreshCostCache.get(key); + if (cached) { + return cached; + } await requireExchangeReadyTx(wex, tx, exchangeBaseUrl); - // FIXME: What about expiration of this cache? return wex.ws.refreshCostCache.getOrPut(key, async () => { const allDenoms = await getWithdrawableDenomsTx( wex, diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -29,6 +29,7 @@ import { Cache, CancellationToken, CoinSelectionAlgorithm, + CoinDepositPermission, CoreApiResponse, DenominationInfo, Duration, @@ -518,12 +519,13 @@ async function runWalletDbTx<T>( // the wallet's caches stale. Acted on only after a successful commit: a // transaction that rolls back changed nothing, and an attempt that is about // to be retried resets the flag so a discarded write cannot carry over. - const dirty = { dirty: false }; + const dirty = { dirty: false, terminalPaymentIds: new Set<string>() }; // Retries wrap the transaction for both backends: the failure modes they // guard against (transaction aborted, retryable conflict) are not specific // to the storage layer. return await handleTxRetries(wex, async () => { dirty.dirty = false; + dirty.terminalPaymentIds.clear(); const location = getCallerInfo(); wex.oc.observe({ type: ObservabilityEventType.DbQueryStart, @@ -544,6 +546,9 @@ async function runWalletDbTx<T>( if (dirty.dirty) { wex.ws.clearAllCaches(); } + for (const proposalId of dirty.terminalPaymentIds) { + wex.ws.clearDepositPermissionCache(proposalId); + } return ret; } catch (e) { wex.oc.observe({ @@ -876,6 +881,52 @@ export class InternalWalletState { Duration.fromSpec({ minutes: 1 }), ); + depositPermissionCache = new Cache<Promise<CoinDepositPermission[]>>( + 100, + Duration.fromSpec({ minutes: 5 }), + ); + + private depositPermissionKeysByScope = new Map<string, Set<string>>(); + + cacheDepositPermissions( + scope: string, + key: string, + value: Promise<CoinDepositPermission[]>, + ): void { + let trackedKeys = 0; + for (const keys of this.depositPermissionKeysByScope.values()) { + trackedKeys += keys.size; + } + if (trackedKeys >= 100) { + this.depositPermissionCache.clear(); + this.depositPermissionKeysByScope.clear(); + } + this.depositPermissionCache.put(key, value); + const keys = this.depositPermissionKeysByScope.get(scope) ?? new Set(); + keys.add(key); + this.depositPermissionKeysByScope.set(scope, keys); + } + + forgetDepositPermission(scope: string, key: string): void { + this.depositPermissionCache.delete(key); + const keys = this.depositPermissionKeysByScope.get(scope); + keys?.delete(key); + if (keys?.size === 0) { + this.depositPermissionKeysByScope.delete(scope); + } + } + + clearDepositPermissionCache(scope: string): void { + const keys = this.depositPermissionKeysByScope.get(scope); + if (!keys) { + return; + } + for (const key of keys) { + this.depositPermissionCache.delete(key); + } + this.depositPermissionKeysByScope.delete(scope); + } + /** * Promises that are waiting for a particular resource. */ @@ -1000,6 +1051,8 @@ export class InternalWalletState { this.denomInfoCache.clear(); this.refreshCostCache.clear(); this.exchangeBaseUrlCache.clear(); + this.depositPermissionCache.clear(); + this.depositPermissionKeysByScope.clear(); } initWithConfig(newConfig: WalletRunConfig): void {