taler-typescript-core

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

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:
Mpackages/taler-wallet-core/src/balance.test.ts | 30++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/balance.ts | 110++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mpackages/taler-wallet-core/src/coinSelection.ts | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mpackages/taler-wallet-core/src/exchanges.ts | 17+++++++++++------
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;