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:
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 {