commit dd0b0b6eff68f50316f915387e8791e1cda974fe
parent df14901ddb7fa7c7bcd1c5526addf2b478e406c8
Author: Florian Dold <dold@taler.net>
Date: Wed, 19 Aug 2026 21:41:45 +0200
wallet-core: reuse balance snapshots and exchange lookups
Diffstat:
4 files changed, 187 insertions(+), 50 deletions(-)
diff --git a/packages/taler-wallet-core/src/balance.test.ts b/packages/taler-wallet-core/src/balance.test.ts
@@ -23,10 +23,12 @@ import {
import {
ExchangeEntryDbRecordStatus,
ExchangeEntryDbUpdateStatus,
+ DepositOperationStatus,
RefreshOperationStatus,
WalletExchangeDetails,
WalletExchangeEntry,
WalletRefreshGroup,
+ WalletDepositGroup,
} from "./db-common.js";
import { WalletDbTransaction } from "./dbtx.js";
import { WalletExecutionContext } from "./wallet.js";
@@ -276,3 +278,31 @@ test("payment abort refresh output is not available", async () => {
);
assert.strictEqual(Amounts.stringify(details.balanceMaterial), "TESTKUDOS:0");
});
+
+test("active deposit amounts are counted once per exchange", async () => {
+ const exchangeA = "https://deposit-a.example/";
+ const exchangeB = "https://deposit-b.example/";
+ const { wex, tx } = makeBalanceContext([
+ makeExchange(exchangeA),
+ makeExchange(exchangeB),
+ ]);
+ const depositGroup = {
+ amount: "TESTKUDOS:5",
+ operationStatus: DepositOperationStatus.PendingDeposit,
+ infoPerExchange: {
+ [exchangeA]: { amountEffective: "TESTKUDOS:2" },
+ [exchangeB]: { amountEffective: "TESTKUDOS:3" },
+ },
+ } as unknown as WalletDepositGroup;
+ tx.getActiveDepositGroups = async () => [depositGroup];
+
+ const balances = await getBalancesInsideTransaction(wex, tx);
+ const byUrl = new Map<string, (typeof balances.balances)[number]>();
+ for (const balance of balances.balances) {
+ if (balance.scopeInfo.type === ScopeType.Exchange) {
+ byUrl.set(balance.scopeInfo.url, balance);
+ }
+ }
+ assert.strictEqual(byUrl.get(exchangeA)?.pendingOutgoing, "TESTKUDOS:2");
+ assert.strictEqual(byUrl.get(exchangeB)?.pendingOutgoing, "TESTKUDOS:3");
+});
diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts
@@ -69,6 +69,7 @@ import {
BalancesResponse,
checkDbInvariant,
DonauSummaryItem,
+ DenominationInfo,
GetBalanceDetailRequest,
j2s,
Logger,
@@ -96,6 +97,8 @@ import {
WalletRefreshGroup,
WithdrawalRecordType,
WalletDonationSummary,
+ WalletCoinAvailability,
+ WalletExchangeDetails,
} from "./db-common.js";
import { getEffectiveExchangeType } from "./builtin-exchanges.js";
import {} from "./db-indexeddb.js";
@@ -124,6 +127,7 @@ async function computeRefreshGroupAvailableAmountForExchanges(
tx: WalletDbTransaction,
r: WalletRefreshGroup,
restrictSenderScope: ScopeInfo | undefined,
+ snapshot?: PaymentBalanceSnapshot,
): Promise<AmountJson> {
// Don't count finished refreshes, since the refresh already resulted
// in coins being added to the wallet.
@@ -141,7 +145,9 @@ async function computeRefreshGroupAvailableAmountForExchanges(
const pe = r.infoPerExchange[exch];
if (
restrictSenderScope &&
- !(await tx.checkExchangeInScope(exch, restrictSenderScope))
+ !(snapshot
+ ? await snapshot.checkExchangeInScope(tx, exch, restrictSenderScope)
+ : await tx.checkExchangeInScope(exch, restrictSenderScope))
) {
continue;
}
@@ -496,14 +502,21 @@ export async function getBalancesInsideTransaction(
}
const coinAvailability = await tx.getCoinAvailabilities();
+ const masterPubByDenom = new Map<string, string | undefined>();
for (const ca of coinAvailability) {
const count = ca.visibleCoinCount ?? 0;
// The denomination is authoritative for which key set the coins belong
// to: an exchange update re-attributes the denominations it still
// offers, while the availability row keeps the key recorded when the
// coin was made available.
- const denom = await tx.getDenomination(ca);
- const masterPub = denom?.exchangeMasterPub ?? ca.exchangeMasterPub;
+ const denomKey = `${ca.exchangeMasterPub}/${ca.denomPubHash}`;
+ if (!masterPubByDenom.has(denomKey)) {
+ masterPubByDenom.set(
+ denomKey,
+ (await tx.getDenomination(ca))?.exchangeMasterPub,
+ );
+ }
+ const masterPub = masterPubByDenom.get(denomKey) ?? ca.exchangeMasterPub;
await balanceStore.addZero(ca.currency, ca.exchangeBaseUrl, masterPub);
if (count > 0) {
await balanceStore.addAvailable(
@@ -790,16 +803,7 @@ export async function getBalancesInsideTransaction(
case DepositOperationStatus.SuspendedDepositKyc:
case DepositOperationStatus.SuspendedDepositKycAuth:
case DepositOperationStatus.PendingDeposit: {
- const perExchange = dgRecord.infoPerExchange;
- if (perExchange) {
- for (const [e, v] of Object.entries(perExchange)) {
- await balanceStore.addPendingOutgoing(
- currency,
- e,
- v.amountEffective,
- );
- }
- }
+ await balanceStore.addPendingOutgoing(currency, e, x.amountEffective);
}
}
}
@@ -918,6 +922,69 @@ export interface PaymentBalanceDetails {
maxMerchantEffectiveDepositAmount: AmountJson;
}
+/** Reusable database inputs for global and per-exchange payment diagnostics. */
+export class PaymentBalanceSnapshot {
+ readonly denoms = new Map<string, DenominationInfo | undefined>();
+ readonly exchangeDetails = new Map<
+ string,
+ WalletExchangeDetails | undefined
+ >();
+ readonly scopes = new Map<string, boolean>();
+
+ private constructor(
+ readonly availabilities: WalletCoinAvailability[],
+ readonly refreshGroups: WalletRefreshGroup[],
+ ) {}
+
+ static async load(tx: WalletDbTransaction): Promise<PaymentBalanceSnapshot> {
+ const [availabilities, refreshGroups] = await Promise.all([
+ tx.getCoinAvailabilities(),
+ tx.getActiveRefreshGroups(),
+ ]);
+ return new PaymentBalanceSnapshot(availabilities, refreshGroups);
+ }
+
+ async getDenom(
+ wex: WalletExecutionContext,
+ tx: WalletDbTransaction,
+ availability: WalletCoinAvailability,
+ ): Promise<DenominationInfo | undefined> {
+ const key = `${availability.exchangeMasterPub}/${availability.denomPubHash}`;
+ if (!this.denoms.has(key)) {
+ this.denoms.set(key, await getDenomInfo(wex, tx, availability));
+ }
+ return this.denoms.get(key);
+ }
+
+ async getExchangeDetails(
+ tx: WalletDbTransaction,
+ exchangeBaseUrl: string,
+ ): Promise<WalletExchangeDetails | undefined> {
+ if (!this.exchangeDetails.has(exchangeBaseUrl)) {
+ this.exchangeDetails.set(
+ exchangeBaseUrl,
+ await tx.getExchangeDetails(exchangeBaseUrl),
+ );
+ }
+ return this.exchangeDetails.get(exchangeBaseUrl);
+ }
+
+ async checkExchangeInScope(
+ tx: WalletDbTransaction,
+ exchangeBaseUrl: string,
+ scope: ScopeInfo,
+ ): Promise<boolean> {
+ const key = `${exchangeBaseUrl}\0${j2s(scope)}`;
+ if (!this.scopes.has(key)) {
+ this.scopes.set(
+ key,
+ await tx.checkExchangeInScope(exchangeBaseUrl, scope),
+ );
+ }
+ return this.scopes.get(key)!;
+ }
+}
+
export async function getPaymentBalanceDetails(
wex: WalletExecutionContext,
req: PaymentRestrictionsForBalance,
@@ -931,7 +998,9 @@ export async function getPaymentBalanceDetailsInTx(
wex: WalletExecutionContext,
tx: WalletDbTransaction,
req: PaymentRestrictionsForBalance,
+ existingSnapshot?: PaymentBalanceSnapshot,
): Promise<PaymentBalanceDetails> {
+ const snapshot = existingSnapshot ?? (await PaymentBalanceSnapshot.load(tx));
const d: PaymentBalanceDetails = {
balanceAvailable: Amounts.zeroOfCurrency(req.currency),
balanceMaterial: Amounts.zeroOfCurrency(req.currency),
@@ -947,14 +1016,14 @@ export async function getPaymentBalanceDetailsInTx(
logger.trace(`computing balance details for ${j2s(req)}`);
- const availableCoins = await tx.getCoinAvailabilities();
+ const availableCoins = snapshot.availabilities;
for (const ca of availableCoins) {
if (ca.currency != req.currency) {
continue;
}
- const denom = await getDenomInfo(wex, tx, ca);
+ const denom = await snapshot.getDenom(wex, tx, ca);
if (!denom) {
continue;
}
@@ -962,7 +1031,8 @@ export async function getPaymentBalanceDetailsInTx(
// Skip exchanges if excluded by the receiver.
if (
req.restrictSenderScope &&
- !(await tx.checkExchangeInScope(
+ !(await snapshot.checkExchangeInScope(
+ tx,
ca.exchangeBaseUrl,
req.restrictSenderScope,
))
@@ -970,7 +1040,10 @@ export async function getPaymentBalanceDetailsInTx(
continue;
}
- const wireDetails = await tx.getExchangeDetails(ca.exchangeBaseUrl);
+ const wireDetails = await snapshot.getExchangeDetails(
+ tx,
+ ca.exchangeBaseUrl,
+ );
if (!wireDetails) {
continue;
}
@@ -1117,7 +1190,7 @@ export async function getPaymentBalanceDetailsInTx(
}
}
- const refreshGroups = await tx.getActiveRefreshGroups();
+ const refreshGroups = snapshot.refreshGroups;
for (const r of refreshGroups) {
if (r.currency != req.currency) {
continue;
@@ -1126,6 +1199,7 @@ export async function getPaymentBalanceDetailsInTx(
tx,
r,
req.restrictSenderScope,
+ snapshot,
);
d.balanceAvailable = Amounts.add(d.balanceAvailable, balRefresh).amount;
}
diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts
@@ -69,6 +69,7 @@ import {
} from "@gnu-taler/taler-util";
import {
getPaymentBalanceDetailsInTx,
+ PaymentBalanceSnapshot,
PaymentBalanceDetails,
} from "./balance.js";
import { getAutoRefreshExecuteThreshold } from "./common.js";
@@ -613,10 +614,11 @@ async function getSupersededCoinBalances(
wex: WalletExecutionContext,
tx: WalletDbTransaction,
currency: string,
+ snapshot: PaymentBalanceSnapshot,
): Promise<Map<string, AmountJson>> {
const amounts = new Map<string, AmountJson>();
const exchangeMasterPubs = new Map<string, string | undefined>();
- const availabilities = await tx.getCoinAvailabilities();
+ const availabilities = snapshot.availabilities;
for (const availability of availabilities) {
if (
availability.currency !== currency ||
@@ -629,11 +631,11 @@ async function getSupersededCoinBalances(
);
if (!exchangeMasterPubs.has(availability.exchangeBaseUrl)) {
exchangeMasterPub = (
- await tx.getExchangeDetails(availability.exchangeBaseUrl)
+ await snapshot.getExchangeDetails(tx, availability.exchangeBaseUrl)
)?.masterPublicKey;
exchangeMasterPubs.set(availability.exchangeBaseUrl, exchangeMasterPub);
}
- const denom = await getDenomInfo(wex, tx, availability);
+ const denom = await snapshot.getDenom(wex, tx, availability);
if (
!denom ||
!exchangeMasterPub ||
@@ -671,14 +673,20 @@ export async function reportInsufficientBalanceDetails(
url: req.exchangeBaseUrl,
}
: undefined);
- const details = await getPaymentBalanceDetailsInTx(wex, tx, {
- restrictSenderScope: effectiveScope,
- restrictReceiverExchanges: req.restrictExchanges,
- restrictWireMethods: req.wireMethod ? [req.wireMethod] : undefined,
- currency,
- minAge: req.requiredMinimumAge ?? 0,
- depositPaytoUri: req.depositPaytoUri,
- });
+ const balanceSnapshot = await PaymentBalanceSnapshot.load(tx);
+ const details = await getPaymentBalanceDetailsInTx(
+ wex,
+ tx,
+ {
+ restrictSenderScope: effectiveScope,
+ restrictReceiverExchanges: req.restrictExchanges,
+ restrictWireMethods: req.wireMethod ? [req.wireMethod] : undefined,
+ currency,
+ minAge: req.requiredMinimumAge ?? 0,
+ depositPaytoUri: req.depositPaytoUri,
+ },
+ balanceSnapshot,
+ );
const perExchange: PaymentInsufficientBalanceDetails["perExchange"] = {};
const diagnosticExchanges: PaymentInsufficientBalanceStructuredDetails["exchanges"] =
{};
@@ -687,6 +695,7 @@ export async function reportInsufficientBalanceDetails(
wex,
tx,
currency,
+ balanceSnapshot,
);
let combinedBaseMaximum = zero;
let combinedExchangeMaximum = zero;
@@ -702,7 +711,7 @@ export async function reportInsufficientBalanceDetails(
continue;
}
let missingGlobalFees = false;
- const exchWire = await getExchangeDetailsInTx(tx, exch.baseUrl);
+ const exchWire = await balanceSnapshot.getExchangeDetails(tx, exch.baseUrl);
if (!exchWire) {
// No wire details about the exchange known, skip!
continue;
@@ -716,24 +725,34 @@ export async function reportInsufficientBalanceDetails(
continue;
}
anySameCurrencyExchange = true;
- const exchDet = await getPaymentBalanceDetailsInTx(wex, tx, {
- restrictSenderScope: {
- type: ScopeType.Exchange,
- currency,
- url: exch.baseUrl,
+ const exchDet = await getPaymentBalanceDetailsInTx(
+ wex,
+ tx,
+ {
+ restrictSenderScope: {
+ type: ScopeType.Exchange,
+ currency,
+ url: exch.baseUrl,
+ },
+ restrictReceiverExchanges: req.restrictExchanges,
+ restrictWireMethods: req.wireMethod ? [req.wireMethod] : undefined,
+ currency: Amounts.currencyOf(req.instructedAmount),
+ minAge: req.requiredMinimumAge ?? 0,
+ depositPaytoUri: req.depositPaytoUri,
},
- restrictReceiverExchanges: req.restrictExchanges,
- restrictWireMethods: req.wireMethod ? [req.wireMethod] : undefined,
- currency: Amounts.currencyOf(req.instructedAmount),
- minAge: req.requiredMinimumAge ?? 0,
- depositPaytoUri: req.depositPaytoUri,
- });
+ balanceSnapshot,
+ );
const reasons: CoinSelectionFailureReason[] = [];
const inRequestedScope =
(!req.exchangeBaseUrl || req.exchangeBaseUrl === exch.baseUrl) &&
(!req.restrictScope ||
- (await checkExchangeInScopeTx(tx, exch.baseUrl, req.restrictScope)));
+ (await checkExchangeInScopeTx(
+ tx,
+ exch.baseUrl,
+ req.restrictScope,
+ exchWire,
+ )));
if (!inRequestedScope && effectiveScope) {
appendReason(reasons, {
type: CoinSelectionFailureReasonType.ScopeRestricted,
@@ -1900,7 +1919,11 @@ async function selectPayCandidates(
const exchanges = await tx.getExchanges();
const wfPerExchange: Record<string, AmountJson> = {};
for (const exchange of exchanges) {
- const exchangeDetails = await getExchangeDetailsInTx(tx, exchange.baseUrl);
+ const exchangeDetails = await getExchangeDetailsInTx(
+ tx,
+ exchange.baseUrl,
+ exchange,
+ );
// Exchange has same currency
if (exchangeDetails?.currency !== req.currency) {
logger.shouldLogTrace() &&
@@ -1946,7 +1969,12 @@ async function selectPayCandidates(
}
const isInScope = req.restrictScope
- ? await checkExchangeInScopeTx(tx, exchange.baseUrl, req.restrictScope)
+ ? await checkExchangeInScopeTx(
+ tx,
+ exchange.baseUrl,
+ req.restrictScope,
+ exchangeDetails,
+ )
: true;
if (!isInScope) {
diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts
@@ -225,8 +225,9 @@ interface ExchangeTosDownloadResult {
async function getExchangeRecordsInternal(
tx: WalletDbTransaction,
exchangeBaseUrl: string,
+ knownExchange?: WalletExchangeEntry,
): Promise<WalletExchangeDetails | undefined> {
- const r = await tx.getExchange(exchangeBaseUrl);
+ const r = knownExchange ?? (await tx.getExchange(exchangeBaseUrl));
if (!r) {
logger.warn(`no exchange found for ${exchangeBaseUrl}`);
return;
@@ -619,8 +620,13 @@ export interface ExchangeDetails {
export async function getExchangeDetailsInTx(
tx: WalletDbTransaction,
exchangeBaseUrl: string,
+ knownExchange?: WalletExchangeEntry,
): Promise<ExchangeDetails | undefined> {
- const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl);
+ const det = await getExchangeRecordsInternal(
+ tx,
+ exchangeBaseUrl,
+ knownExchange,
+ );
if (!det) {
return undefined;
}
@@ -4826,6 +4832,7 @@ export async function checkExchangeInScopeTx(
tx: WalletDbTransaction,
exchangeBaseUrl: string,
scope: ScopeInfo,
+ knownDetails?: Pick<ExchangeDetails, "currency" | "masterPublicKey">,
): Promise<boolean> {
logger.trace(
`checking if exchange ${exchangeBaseUrl} is in scope ${j2s(scope)}`,
@@ -4835,10 +4842,8 @@ export async function checkExchangeInScopeTx(
return scope.url === exchangeBaseUrl;
}
case ScopeType.Global: {
- const exchangeDetails = await getExchangeRecordsInternal(
- tx,
- exchangeBaseUrl,
- );
+ const exchangeDetails =
+ knownDetails ?? (await getExchangeRecordsInternal(tx, exchangeBaseUrl));
if (!exchangeDetails) {
logger.trace(`no details for ${exchangeBaseUrl}`);
return false;