taler-typescript-core

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

commit d179e4c3c8f3a415c87048a3153ba90f3d5f0819
parent 36dee74243fad2fef47bda1c2ea121f763710514
Author: Florian Dold <dold@taler.net>
Date:   Thu, 20 Aug 2026 00:03:40 +0200

wallet-core: batch database hot paths

Diffstat:
Mpackages/taler-wallet-core/src/balance.test.ts | 3+++
Mpackages/taler-wallet-core/src/balance.ts | 46++++++++++++++++++++++------------------------
Mpackages/taler-wallet-core/src/coinSelection.test.ts | 3+++
Mpackages/taler-wallet-core/src/coinSelection.ts | 32+++++++++++++++++++++++---------
Mpackages/taler-wallet-core/src/common.ts | 42+++++++++++++++++++++++++-----------------
Apackages/taler-wallet-core/src/dbtx-bench.test.ts | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/dbtx-bench.ts | 190++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mpackages/taler-wallet-core/src/dbtx-conformance-cases.ts | 118++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mpackages/taler-wallet-core/src/dbtx-indexeddb.ts | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/dbtx-runners.ts | 1+
Mpackages/taler-wallet-core/src/dbtx-sqlite.ts | 204++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mpackages/taler-wallet-core/src/dbtx.ts | 28++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/deposits-performance.test.ts | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/deposits.ts | 384++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mpackages/taler-wallet-core/src/exchanges.ts | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mpackages/taler-wallet-core/src/instructedAmountConversion.ts | 8++++++--
Mpackages/taler-wallet-core/src/pay-merchant.ts | 64++++++++++++++++++++++++++++++++++++----------------------------
Mpackages/taler-wallet-core/src/pay-peer-common.ts | 40++++++++++++++++++++++++++--------------
Mpackages/taler-wallet-core/src/recoup.ts | 4+++-
Mpackages/taler-wallet-core/src/refresh.ts | 266+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mpackages/taler-wallet-core/src/requests.ts | 21+++++++++++++--------
Mpackages/taler-wallet-core/src/wallet.ts | 53+++++++++++++++++++++++++++++++++++++++++++++++++++--
Mpackages/taler-wallet-core/src/withdraw.ts | 27++++++++++++++++++++++-----
23 files changed, 1370 insertions(+), 417 deletions(-)

diff --git a/packages/taler-wallet-core/src/balance.test.ts b/packages/taler-wallet-core/src/balance.test.ts @@ -78,6 +78,9 @@ function makeBalanceContext( async getCoinAvailabilities() { return []; }, + async getDenominationsByRefs() { + return []; + }, async getActiveRefreshGroups() { return refreshGroups; }, diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts @@ -103,7 +103,11 @@ import { import { getEffectiveExchangeType } from "./builtin-exchanges.js"; import {} from "./db-indexeddb.js"; import { WalletDbTransaction } from "./dbtx.js"; -import { getDenomInfo, WalletExecutionContext } from "./wallet.js"; +import { + denomRefKey, + getDenomInfos, + WalletExecutionContext, +} from "./wallet.js"; /** * Logger. @@ -502,20 +506,17 @@ export async function getBalancesInsideTransaction( } const coinAvailability = await tx.getCoinAvailabilities(); - const masterPubByDenom = new Map<string, string | undefined>(); + const denominations = await tx.getDenominationsByRefs(coinAvailability); + const masterPubByDenom = new Map( + denominations.map((denom) => [denomRefKey(denom), denom.exchangeMasterPub]), + ); 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 denomKey = `${ca.exchangeMasterPub}/${ca.denomPubHash}`; - if (!masterPubByDenom.has(denomKey)) { - masterPubByDenom.set( - denomKey, - (await tx.getDenomination(ca))?.exchangeMasterPub, - ); - } + const denomKey = denomRefKey(ca); const masterPub = masterPubByDenom.get(denomKey) ?? ca.exchangeMasterPub; await balanceStore.addZero(ca.currency, ca.exchangeBaseUrl, masterPub); if (count > 0) { @@ -924,7 +925,6 @@ export interface PaymentBalanceDetails { /** 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 @@ -934,26 +934,23 @@ export class PaymentBalanceSnapshot { private constructor( readonly availabilities: WalletCoinAvailability[], readonly refreshGroups: WalletRefreshGroup[], + readonly denoms: Map<string, DenominationInfo>, ) {} - static async load(tx: WalletDbTransaction): Promise<PaymentBalanceSnapshot> { + static async load( + wex: WalletExecutionContext, + tx: WalletDbTransaction, + ): Promise<PaymentBalanceSnapshot> { const [availabilities, refreshGroups] = await Promise.all([ tx.getCoinAvailabilities(), tx.getActiveRefreshGroups(), ]); - return new PaymentBalanceSnapshot(availabilities, refreshGroups); + const denoms = await getDenomInfos(wex, tx, availabilities); + return new PaymentBalanceSnapshot(availabilities, refreshGroups, denoms); } - 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); + getDenom(availability: WalletCoinAvailability): DenominationInfo | undefined { + return this.denoms.get(denomRefKey(availability)); } async getExchangeDetails( @@ -1000,7 +997,8 @@ export async function getPaymentBalanceDetailsInTx( req: PaymentRestrictionsForBalance, existingSnapshot?: PaymentBalanceSnapshot, ): Promise<PaymentBalanceDetails> { - const snapshot = existingSnapshot ?? (await PaymentBalanceSnapshot.load(tx)); + const snapshot = + existingSnapshot ?? (await PaymentBalanceSnapshot.load(wex, tx)); const d: PaymentBalanceDetails = { balanceAvailable: Amounts.zeroOfCurrency(req.currency), balanceMaterial: Amounts.zeroOfCurrency(req.currency), @@ -1023,7 +1021,7 @@ export async function getPaymentBalanceDetailsInTx( continue; } - const denom = await snapshot.getDenom(wex, tx, ca); + const denom = snapshot.getDenom(ca); if (!denom) { continue; } diff --git a/packages/taler-wallet-core/src/coinSelection.test.ts b/packages/taler-wallet-core/src/coinSelection.test.ts @@ -651,6 +651,9 @@ test("deposit available max includes pending-only refresh outputs", async () => async getDenomination() { return denomination; }, + async getDenominationsByRefs() { + return [denomination]; + }, } as unknown as WalletDbTransaction; const wex = { async runWalletDbTx<T>( diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts @@ -79,7 +79,11 @@ import { ExchangeDetails, getExchangeDetailsInTx, } from "./exchanges.js"; -import { getDenomInfo, WalletExecutionContext } from "./wallet.js"; +import { + denomRefKey, + getDenomInfos, + WalletExecutionContext, +} from "./wallet.js"; import { WalletDbTransaction } from "./dbtx.js"; const logger = new Logger("coinSelection.ts"); @@ -401,13 +405,18 @@ async function maybeRepairCoinSelection( wireFeesPerExchange: Record<string, AmountJson>; }, ): Promise<void> { + const coins = await tx.getCoinsByPubs( + prevPayCoins.map((prev) => prev.coinPub), + ); + const coinsByPub = new Map(coins.map((coin) => [coin.coinPub, coin])); + const denoms = await getDenomInfos(wex, tx, coins); // Look at existing pay coin selection and tally up for (const prev of prevPayCoins) { - const coin = await tx.getCoin(prev.coinPub); + const coin = coinsByPub.get(prev.coinPub); if (!coin) { continue; } - const denom = await getDenomInfo(wex, tx, coin); + const denom = denoms.get(denomRefKey(coin)); if (!denom) { continue; } @@ -635,7 +644,7 @@ async function getSupersededCoinBalances( )?.masterPublicKey; exchangeMasterPubs.set(availability.exchangeBaseUrl, exchangeMasterPub); } - const denom = await snapshot.getDenom(wex, tx, availability); + const denom = snapshot.getDenom(availability); if ( !denom || !exchangeMasterPub || @@ -673,7 +682,7 @@ export async function reportInsufficientBalanceDetails( url: req.exchangeBaseUrl, } : undefined); - const balanceSnapshot = await PaymentBalanceSnapshot.load(tx); + const balanceSnapshot = await PaymentBalanceSnapshot.load(wex, tx); const details = await getPaymentBalanceDetailsInTx( wex, tx, @@ -2012,11 +2021,15 @@ async function selectPayCandidates( } let numUsable = 0; + const candidateDenoms = await tx.getDenominationsByRefs(myExchangeCoins); + const candidateDenomsByRef = new Map( + candidateDenoms.map((denom) => [denomRefKey(denom), denom]), + ); // Save denoms with how many coins are available // FIXME: Check that the individual denomination is audited! for (const coinAvail of myExchangeCoins) { - const denom = await tx.getDenomination(coinAvail); + const denom = candidateDenomsByRef.get(denomRefKey(coinAvail)); checkDbInvariant( !!denom, `denomination of a coin is missing hash: ${coinAvail.denomPubHash}`, @@ -2132,9 +2145,10 @@ export async function computeCoinSelMaxExpirationDate( selectedDenom: SelResult, ): Promise<TalerProtocolTimestamp> { let minAutorefreshExecuteThreshold = TalerProtocolTimestamp.never(); - for (const dph of Object.keys(selectedDenom)) { - const selInfo = selectedDenom[dph]; - const denom = await getDenomInfo(wex, tx, selInfo); + const selections = Object.values(selectedDenom); + const denoms = await getDenomInfos(wex, tx, selections); + for (const selInfo of selections) { + const denom = denoms.get(denomRefKey(selInfo)); if (!denom) { continue; } diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts @@ -23,7 +23,6 @@ import { AsyncFlag, CoinRefreshRequest, CoinStatus, - DenominationInfo, Duration, DurationUnitSpec, ErrorInfoSummary, @@ -67,7 +66,6 @@ import { ExchangeEntryDbRecordStatus, ExchangeEntryDbUpdateStatus, WalletCoin, - WalletCoinAvailability, WalletCoinHistory, WalletDepositGroup, WalletExchangeEntry, @@ -88,7 +86,12 @@ import { WalletDbTransaction } from "./dbtx.js"; import { ReadyExchangeSummary, markExchangeUsed } from "./exchanges.js"; import { createRefreshGroup } from "./refresh.js"; import { BalanceEffect, applyNotifyTransition } from "./transactions.js"; -import { WalletExecutionContext, getDenomInfo } from "./wallet.js"; +import { + coinAvailabilityRefKey, + denomRefKey, + getDenomInfos, + WalletExecutionContext, +} from "./wallet.js"; const logger = new Logger("operations/common.ts"); @@ -182,28 +185,32 @@ export async function spendCoins( 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>(); + const denomByRef = await getDenomInfos(wex, tx, loadedCoins); + const loadedAvailabilities = + await tx.getCoinAvailabilitiesByRefs(loadedCoins); + const availabilityByRef = new Map( + loadedAvailabilities.map((availability) => [ + coinAvailabilityRefKey(availability), + availability, + ]), + ); + const loadedHistories = await tx.getCoinHistoriesByPubs(csi.coinPubs); + const historiesByPub = new Map( + loadedHistories.map((history) => [history.coinPub, history]), + ); for (let i = 0; i < csi.coinPubs.length; i++) { const coin = coinsByPub.get(csi.coinPubs[i]); if (!coin) { throw Error("coin allocated for payment doesn't exist anymore"); } - const denomKey = `${coin.exchangeMasterPub}/${coin.denomPubHash}`; - let denom = denomByRef.get(denomKey); - if (!denom) { - denom = await getDenomInfo(wex, tx, coin); - } + const denomKey = denomRefKey(coin); + const denom = denomByRef.get(denomKey); checkDbInvariant( !!denom, `denomination of a coin is missing hash: ${coin.denomPubHash}`, ); - denomByRef.set(denomKey, denom); - const availabilityKey = `${denomKey}/${coin.maxAge}`; - let coinAvailability = availabilityByRef.get(availabilityKey); - if (!coinAvailability) { - coinAvailability = await tx.getCoinAvailability(coin); - } + const availabilityKey = coinAvailabilityRefKey(coin); + const coinAvailability = availabilityByRef.get(availabilityKey); checkDbInvariant( !!coinAvailability, `age denom info is missing for ${coin.maxAge}`, @@ -241,7 +248,7 @@ export async function spendCoins( coinAvailability.visibleCoinCount--; } } - let histEntry: WalletCoinHistory | undefined = await tx.getCoinHistory( + let histEntry: WalletCoinHistory | undefined = historiesByPub.get( coin.coinPub, ); if (!histEntry) { @@ -255,6 +262,7 @@ export async function spendCoins( transactionId: csi.transactionId, amount: Amounts.stringify(contrib), }); + historiesByPub.set(coin.coinPub, histEntry); await tx.upsertCoinHistory(histEntry); await tx.upsertCoin(coin); } diff --git a/packages/taler-wallet-core/src/dbtx-bench.test.ts b/packages/taler-wallet-core/src/dbtx-bench.test.ts @@ -0,0 +1,49 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import assert from "node:assert"; +import { test } from "node:test"; + +import { benchmarkOneBackend, DbBenchOptions } from "./dbtx-bench.js"; +import { runnerFactories } from "./dbtx-runners.js"; + +test("database benchmark smoke test returns real, comparable rows", async () => { + const options: DbBenchOptions = { + numCoins: 120, + numDenominations: 12, + numExchanges: 2, + repeats: 1, + }; + const results = []; + for (const makeRunner of runnerFactories) { + const runner = await makeRunner(); + try { + results.push(await benchmarkOneBackend(runner, options)); + } finally { + await runner.close(); + } + } + + assert.strictEqual(results.length, 2); + assert.deepStrictEqual( + results[0].queries.map((query) => [query.name, query.rows]), + results[1].queries.map((query) => [query.name, query.rows]), + ); + const freshQuery = results[0].queries.find((query) => + query.name.startsWith("getFreshCoinsByDenomAndAge"), + ); + assert.ok(freshQuery && freshQuery.rows > 0); +}); diff --git a/packages/taler-wallet-core/src/dbtx-bench.ts b/packages/taler-wallet-core/src/dbtx-bench.ts @@ -45,6 +45,7 @@ import { WalletCoin, WalletCoinAvailability, WalletDenomination, + WalletDenominationFamily, } from "./db-common.js"; import { DbTxRunner } from "./dbtx-conformance.js"; @@ -132,13 +133,23 @@ async function populate( runner: DbTxRunner, opts: DbBenchOptions, ): Promise<void> { - const denomsPerExchange = Math.max( - 1, - Math.floor(opts.numDenominations / opts.numExchanges), - ); - // Denominations and their availability rows. await runner.runReadWriteTx(async (tx) => { + for (let e = 0; e < opts.numExchanges; e++) { + const family: WalletDenominationFamily = { + denominationFamilySerial: e + 1, + familyParams: { + exchangeBaseUrl: exchangeUrl(e), + exchangeMasterPub: key(`master-${e}`), + value: "TESTKUDOS:1" as AmountString, + feeDeposit: "TESTKUDOS:0.01" as AmountString, + feeRefresh: "TESTKUDOS:0.01" as AmountString, + feeRefund: "TESTKUDOS:0.01" as AmountString, + feeWithdraw: "TESTKUDOS:0.01" as AmountString, + }, + }; + await tx.upsertDenominationFamily(family); + } for (let d = 0; d < opts.numDenominations; d++) { const ex = exchangeUrl(d % opts.numExchanges); const dph = hash(`denom-${d}`); @@ -153,7 +164,7 @@ async function populate( exchangeMasterPub: key(`master-${d % opts.numExchanges}`), currency: "TESTKUDOS", value: "TESTKUDOS:1" as AmountString, - denominationFamilySerial: (d % denomsPerExchange) + 1, + denominationFamilySerial: (d % opts.numExchanges) + 1, stampStart: (1000 + d) as DbProtocolTimestamp, stampExpireWithdraw: (2000 + d) as DbProtocolTimestamp, stampExpireDeposit: (3000 + d) as DbProtocolTimestamp, @@ -195,7 +206,7 @@ async function populate( coinPub: key(`coin-${i}`), coinPriv: key(`coinpriv-${i}`), exchangeBaseUrl: exchangeUrl(d % opts.numExchanges), - exchangeMasterPub: key(`mpk-${d % opts.numExchanges}`), + exchangeMasterPub: key(`master-${d % opts.numExchanges}`), denomPubHash: hash(`denom-${d}`), denomSig: { cipher: DenomKeyType.Rsa, rsa_signature: `sig-${i}` }, blindingKey: key(`bk-${i}`), @@ -235,6 +246,7 @@ async function measure( const time = async ( name: string, + expectedRows: number, f: () => Promise<number>, ): Promise<void> => { const samples: number[] = []; @@ -243,6 +255,11 @@ async function measure( const t0 = performance.now(); rows = await f(); samples.push(performance.now() - t0); + if (rows !== expectedRows) { + throw Error( + `benchmark query ${name} returned ${rows} rows, expected ${expectedRows}`, + ); + } } results.push({ name, @@ -255,7 +272,7 @@ async function measure( // A point lookup on the primary key, the single most common operation. const someCoin = key(`coin-${Math.floor(opts.numCoins / 2)}`); - await time("getCoin (point lookup)", async () => + await time("getCoin (point lookup)", 1, async () => runner.runReadWriteTx(async (tx) => ((await tx.getCoin(someCoin)) ? 1 : 0)), ); @@ -265,61 +282,124 @@ async function measure( for (let i = 0; i < Math.min(200, opts.numCoins); i++) { pubs.push(key(`coin-${i}`)); } - await time("getCoinsByPubs (200)", async () => + await time("getCoinsByPubs (200)", pubs.length, async () => runner.runReadWriteTx(async (tx) => (await tx.getCoinsByPubs(pubs)).length), ); - await time("getCoinsByExchange", async () => + const denomRefs = Array.from( + { length: Math.min(200, opts.numDenominations) }, + (_, d) => ({ + exchangeMasterPub: key(`master-${d % opts.numExchanges}`), + denomPubHash: hash(`denom-${d}`), + }), + ); + await time("getDenominationsByRefs (200)", denomRefs.length, async () => + runner.runReadWriteTx( + async (tx) => (await tx.getDenominationsByRefs(denomRefs)).length, + ), + ); + + const availabilityRefs = denomRefs.map((ref, d) => ({ + ...ref, + maxAge: d % 2 === 0 ? 0 : 21, + })); + await time( + "getCoinAvailabilitiesByRefs (200)", + availabilityRefs.length, + async () => + runner.runReadWriteTx( + async (tx) => + (await tx.getCoinAvailabilitiesByRefs(availabilityRefs)).length, + ), + ); + + const countCoinsForExchange = (exchangeIndex: number): number => { + let count = 0; + for (let i = 0; i < opts.numCoins; i++) { + if ((i % opts.numDenominations) % opts.numExchanges === exchangeIndex) { + count++; + } + } + return count; + }; + const coinsAtExchangeZero = countCoinsForExchange(0); + + await time("getCoinsByExchange", coinsAtExchangeZero, async () => runner.runReadWriteTx( async (tx) => (await tx.getCoinsByExchange(exchangeUrl(0))).length, ), ); - await time("countCoinsByExchange", async () => + await time("countCoinsByExchange", coinsAtExchangeZero, async () => runner.runReadWriteTx(async (tx) => tx.countCoinsByExchange(exchangeUrl(0)), ), ); - await time("getCoinsByDenomPubHash", async () => + const coinsForDenomZero = + Math.floor((opts.numCoins - 1) / opts.numDenominations) + 1; + await time("getCoinsByDenomPubHash", coinsForDenomZero, async () => runner.runReadWriteTx( async (tx) => (await tx.getCoinsByDenomPubHash(hash("denom-0"))).length, ), ); - // Indexed multi-column lookup with a limit -- coin selection's hot path. - await time("getFreshCoinsByDenomAndAge (limit 10)", async () => + const denomHashes = Array.from({ length: opts.numDenominations }, (_, d) => + hash(`denom-${d}`), + ); + await time("getCoinsByDenomPubHashes", opts.numCoins, async () => runner.runReadWriteTx( - async (tx) => - ( - await tx.getFreshCoinsByDenomAndAge( - { - exchangeMasterPub: key("master-0"), - denomPubHash: hash("denom-0"), - maxAge: 0, - }, - 10, - ) - ).length, + async (tx) => (await tx.getCoinsByDenomPubHashes(denomHashes)).length, ), ); - await time("getCoinAvailabilityByExchangeAndAgeRange", async () => - runner.runReadWriteTx( - async (tx) => - ( - await tx.getCoinAvailabilityByExchangeAndAgeRange( - exchangeUrl(0), - 0, - 21, - ) - ).length, - ), + // Indexed multi-column lookup with a limit -- coin selection's hot path. + let freshCoinsForDenomZero = 0; + for (let i = 0; i < opts.numCoins; i += opts.numDenominations) { + if (Math.floor(i / opts.numDenominations) % 4 !== 0) { + freshCoinsForDenomZero++; + } + } + await time( + "getFreshCoinsByDenomAndAge (limit 10)", + Math.min(10, freshCoinsForDenomZero), + async () => + runner.runReadWriteTx( + async (tx) => + ( + await tx.getFreshCoinsByDenomAndAge( + { + exchangeMasterPub: key("master-0"), + denomPubHash: hash("denom-0"), + maxAge: 0, + }, + 10, + ) + ).length, + ), + ); + + const denomsAtExchangeZero = + Math.floor((opts.numDenominations - 1) / opts.numExchanges) + 1; + await time( + "getCoinAvailabilityByExchangeAndAgeRange", + denomsAtExchangeZero, + async () => + runner.runReadWriteTx( + async (tx) => + ( + await tx.getCoinAvailabilityByExchangeAndAgeRange( + exchangeUrl(0), + 0, + 21, + ) + ).length, + ), ); // The early-terminating keyset scan. Deliberately matches nothing until // late, so a backend that materialises the whole family shows up here. - await time("findDenominationByFamilyFromExpiry", async () => + await time("findDenominationByFamilyFromExpiry", 1, async () => runner.runReadWriteTx(async (tx) => { const found = await tx.findDenominationByFamilyFromExpiry( 1, @@ -330,7 +410,7 @@ async function measure( }), ); - await time("getDenominationsByMasterPub", async () => + await time("getDenominationsByMasterPub", denomsAtExchangeZero, async () => runner.runReadWriteTx( async (tx) => (await tx.getDenominationsByMasterPub(key("master-0"))).length, @@ -338,20 +418,25 @@ async function measure( ); // Full scans: the wallet does these on balance computation and purge. - await time("listAllCoins (full scan)", async () => + await time("listAllCoins (full scan)", opts.numCoins, async () => runner.runReadWriteTx(async (tx) => (await tx.listAllCoins()).length), ); - await time("getCoinAvailabilities (full scan)", async () => - runner.runReadWriteTx( - async (tx) => (await tx.getCoinAvailabilities()).length, - ), + await time( + "getCoinAvailabilities (full scan)", + opts.numDenominations, + async () => + runner.runReadWriteTx( + async (tx) => (await tx.getCoinAvailabilities()).length, + ), ); // A write-heavy transaction, to keep an eye on commit cost. - await time("upsertCoin x100 (one tx)", async () => + const numUpserts = Math.min(100, opts.numCoins); + await time("upsertCoin x100 (one tx)", numUpserts, async () => runner.runReadWriteTx(async (tx) => { - for (let i = 0; i < 100; i++) { + let updated = 0; + for (let i = 0; i < numUpserts; i++) { const coin = await tx.getCoin(key(`coin-${i}`)); if (coin) { coin.status = @@ -359,9 +444,10 @@ async function measure( ? CoinStatus.Dormant : CoinStatus.Fresh; await tx.upsertCoin(coin); + updated++; } } - return 100; + return updated; }), ); @@ -376,6 +462,18 @@ export async function benchmarkOneBackend( opts: DbBenchOptions, dbSizeBytes?: () => number | undefined, ): Promise<DbBenchResult> { + if ( + !Number.isSafeInteger(opts.numCoins) || + opts.numCoins <= 0 || + !Number.isSafeInteger(opts.numDenominations) || + opts.numDenominations <= 0 || + !Number.isSafeInteger(opts.numExchanges) || + opts.numExchanges <= 0 || + !Number.isSafeInteger(opts.repeats) || + opts.repeats <= 0 + ) { + throw Error("benchmark options must be positive safe integers"); + } logger.info(`populating ${runner.name}: ${opts.numCoins} coins`); const t0 = performance.now(); await populate(runner, opts); diff --git a/packages/taler-wallet-core/src/dbtx-conformance-cases.ts b/packages/taler-wallet-core/src/dbtx-conformance-cases.ts @@ -811,6 +811,41 @@ export const conformanceCases: ConformanceCase[] = [ }, { + name: "denomination: batch lookup preserves order across chunks", + async run(t, runner) { + const primary = makeDenomination("https://batch-denom/", "bd-primary"); + const other = makeDenomination("https://batch-denom/", "bd-other"); + other.exchangeMasterPub = ck("master-other"); + await runner.runReadWriteTx(async (tx) => { + await seedDenomFamily(tx, "https://batch-denom/", 1); + await tx.upsertDenomination(primary); + await tx.upsertDenomination(other); + }); + const missing = { + exchangeMasterPub: ck("master-pub"), + denomPubHash: ckh("bd-missing"), + }; + const refs = Array.from({ length: 403 }, (_, i) => + i % 17 === 0 ? missing : i % 2 === 0 ? primary : other, + ); + const got = await runner.runReadWriteTx((tx) => + tx.getDenominationsByRefs(refs), + ); + t.deepEqual( + got.map((d) => [d.exchangeMasterPub, d.denomPubHash]), + refs + .filter((ref) => ref !== missing) + .map((ref) => [ref.exchangeMasterPub, ref.denomPubHash]), + "missing references are skipped and duplicates retain input order", + ); + t.deepEqual( + await runner.runReadWriteTx((tx) => tx.getDenominationsByRefs([])), + [], + ); + }, + }, + + { name: "refund items by group are found (regression: array keyPath)", // The IndexedDB index byRefundGroupId is declared with an ARRAY keyPath, // so its keys are single-element arrays. Passing a bare string matched @@ -1882,6 +1917,20 @@ export const conformanceCases: ConformanceCase[] = [ tx.getCoinsByDenomPubHash(ckh("dq-1")), ); t.equal(byDenom.length, 2, "denom hash spans exchanges"); + const denomHashes = [ + ckh("dq-1"), + ...Array.from({ length: 501 }, (_, i) => ckh(`dq-missing-${i}`)), + ckh("dq-2"), + ckh("dq-1"), + ]; + const byDenoms = await runner.runReadWriteTx((tx) => + tx.getCoinsByDenomPubHashes(denomHashes), + ); + t.deepEqual( + byDenoms.map((coin) => coin.coinPub).sort(), + [ck("cq-1"), ck("cq-2"), ck("cq-3")].sort(), + "batch lookup spans exchanges, chunks safely and de-duplicates hashes", + ); const bySrc = await runner.runReadWriteTx((tx) => tx.getCoinsBySourceTransaction("txn-1"), ); @@ -1897,13 +1946,15 @@ export const conformanceCases: ConformanceCase[] = [ await tx.upsertCoin(makeCoin("cb-1")); await tx.upsertCoin(makeCoin("cb-2")); }); - const got = await runner.runReadWriteTx((tx) => - tx.getCoinsByPubs([ck("cb-2"), ck("cb-missing"), ck("cb-1")]), + const missing = ck("cb-missing"); + const pubs = Array.from({ length: 503 }, (_, i) => + i % 19 === 0 ? missing : i % 2 === 0 ? ck("cb-2") : ck("cb-1"), ); + const got = await runner.runReadWriteTx((tx) => tx.getCoinsByPubs(pubs)); t.deepEqual( got.map((c) => c.coinPub), - [ck("cb-2"), ck("cb-1")], - "missing pubs are dropped, not returned as holes", + pubs.filter((pub) => pub !== missing), + "missing pubs are dropped while duplicates retain input order", ); }, }, @@ -2020,6 +2071,35 @@ export const conformanceCases: ConformanceCase[] = [ }, }, + { + name: "coin history: batch lookup preserves order across chunks", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + for (const label of ["chb-1", "chb-2"]) { + await tx.upsertCoin(makeCoin(label)); + await tx.upsertCoinHistory({ + coinPub: ck(label), + history: [ + { type: "withdraw", transactionId: txnId(`txn:${label}`) }, + ], + }); + } + }); + const missing = ck("chb-missing"); + const pubs = Array.from({ length: 503 }, (_, i) => + i % 23 === 0 ? missing : i % 2 === 0 ? ck("chb-2") : ck("chb-1"), + ); + const got = await runner.runReadWriteTx((tx) => + tx.getCoinHistoriesByPubs(pubs), + ); + t.deepEqual( + got.map((h) => h.coinPub), + pubs.filter((pub) => pub !== missing), + "missing histories are skipped while duplicates retain input order", + ); + }, + }, + // ------------------------------------------------------ coin availability { @@ -2050,6 +2130,36 @@ export const conformanceCases: ConformanceCase[] = [ }, { + name: "coin availability: batch lookup preserves order across chunks", + async run(t, runner) { + const zero = makeAvail("https://batch-avail/", "ba", 0); + const adult = makeAvail("https://batch-avail/", "ba", 21); + await runner.runReadWriteTx(async (tx) => { + await tx.upsertCoinAvailability(zero); + await tx.upsertCoinAvailability(adult); + }); + const missing = { + exchangeMasterPub: ck("master-pub"), + denomPubHash: ckh("ba-missing"), + maxAge: 0, + }; + const refs = Array.from({ length: 303 }, (_, i) => + i % 13 === 0 ? missing : i % 2 === 0 ? zero : adult, + ); + const got = await runner.runReadWriteTx((tx) => + tx.getCoinAvailabilitiesByRefs(refs), + ); + t.deepEqual( + got.map((a) => [a.exchangeMasterPub, a.denomPubHash, a.maxAge]), + refs + .filter((ref) => ref !== missing) + .map((ref) => [ref.exchangeMasterPub, ref.denomPubHash, ref.maxAge]), + "missing references are skipped and duplicates retain input order", + ); + }, + }, + + { name: "coin availability: upsert updates counts in place", async run(t, runner) { const rec = makeAvail("https://eu/", "du", 0); diff --git a/packages/taler-wallet-core/src/dbtx-indexeddb.ts b/packages/taler-wallet-core/src/dbtx-indexeddb.ts @@ -674,6 +674,18 @@ export class IdbWalletTransaction implements WalletDbTransaction { return await tx.coins.indexes.byDenomPubHash.getAll(denomPubHash); } + async getCoinsByDenomPubHashes( + denomPubHashes: string[], + ): Promise<WalletCoin[]> { + const uniqueHashes = [...new Set(denomPubHashes)]; + const groups = await Promise.all( + uniqueHashes.map((hash) => + this.tx.coins.indexes.byDenomPubHash.getAll(hash), + ), + ); + return groups.flat(); + } + async deleteCoin(coinPub: string): Promise<void> { const tx = this.tx; // Cascade to the history, which describes this coin and nothing else. @@ -1498,6 +1510,23 @@ export class IdbWalletTransaction implements WalletDbTransaction { ]); } + async getCoinAvailabilitiesByRefs( + refs: WalletCoinAvailabilityRef[], + ): Promise<WalletCoinAvailability[]> { + const records = await Promise.all( + refs.map((ref) => + this.tx.coinAvailabilityV2.get([ + ref.exchangeMasterPub, + ref.denomPubHash, + ref.maxAge, + ]), + ), + ); + return records.filter( + (record): record is WalletCoinAvailability => record !== undefined, + ); + } + async upsertCoinAvailability(rec: WalletCoinAvailability): Promise<void> { const tx = this.tx; await tx.coinAvailabilityV2.put({ @@ -1513,6 +1542,17 @@ export class IdbWalletTransaction implements WalletDbTransaction { return await tx.coinHistory.get(coinPub); } + async getCoinHistoriesByPubs( + coinPubs: string[], + ): Promise<WalletCoinHistory[]> { + const records = await Promise.all( + coinPubs.map((coinPub) => this.tx.coinHistory.get(coinPub)), + ); + return records.filter( + (record): record is WalletCoinHistory => record !== undefined, + ); + } + async listAllCoinHistories(): Promise<WalletCoinHistory[]> { return await this.tx.coinHistory.getAll(); } @@ -1846,6 +1886,19 @@ export class IdbWalletTransaction implements WalletDbTransaction { ]); } + async getDenominationsByRefs( + refs: WalletDenomRef[], + ): Promise<WalletDenomination[]> { + const records = await Promise.all( + refs.map((ref) => + this.tx.denominationsV2.get([ref.exchangeMasterPub, ref.denomPubHash]), + ), + ); + return records.filter( + (record): record is WalletDenomination => record !== undefined, + ); + } + async findDenominationByFamilyFromExpiry( denominationFamilySerial: number, minStampExpireWithdraw: DbProtocolTimestamp, diff --git a/packages/taler-wallet-core/src/dbtx-runners.ts b/packages/taler-wallet-core/src/dbtx-runners.ts @@ -43,6 +43,7 @@ export async function makeIdbRunner( const backend = await createSqliteBackend(sqlite3Impl, { filename, }); + backend.enableTracing = false; backend.trackStats = true; BridgeIDBFactory.enableTracing = false; const idbFactory = new BridgeIDBFactory(backend); diff --git a/packages/taler-wallet-core/src/dbtx-sqlite.ts b/packages/taler-wallet-core/src/dbtx-sqlite.ts @@ -953,6 +953,58 @@ export class SqliteWalletTransaction implements WalletDbTransaction { return row ? this.rowToDenomination(row) : undefined; } + async getDenominationsByRefs( + refs: WalletDenomRef[], + ): Promise<WalletDenomination[]> { + if (refs.length === 0) { + return []; + } + const encoded = refs.map((ref) => { + const masterPub = crockToDb(ref.exchangeMasterPub); + const denomPubHash = crockToDb(ref.denomPubHash); + return { + masterPub, + denomPubHash, + key: `${blobKey(masterPub)}/${blobKey(denomPubHash)}`, + }; + }); + const byKey = new Map<string, WalletDenomination>(); + // Two parameters per reference. Staying below 999 keeps this compatible + // with sqlite builds that use the traditional bind-parameter limit. + for (let offset = 0; offset < encoded.length; offset += 400) { + const chunk = encoded.slice(offset, offset + 400); + const params: Record<string, Sqlite3Value> = {}; + const values = chunk.map((ref, i) => { + params[`mpk${i}`] = ref.masterPub; + params[`dph${i}`] = ref.denomPubHash; + return `($mpk${i}, $dph${i})`; + }); + const rows = await this.all( + "SELECT * FROM denominations" + + ` WHERE (exchange_master_pub, denom_pub_hash) IN (${values.join(", ")})`, + params, + ); + for (const row of rows) { + const masterPub = row.exchange_master_pub; + const denomPubHash = row.denom_pub_hash; + if ( + !(masterPub instanceof Uint8Array) || + !(denomPubHash instanceof Uint8Array) + ) { + throw Error("denomination identity columns must be BLOBs"); + } + byKey.set( + `${blobKey(masterPub)}/${blobKey(denomPubHash)}`, + this.rowToDenomination(row), + ); + } + } + return encoded.flatMap((ref) => { + const record = byKey.get(ref.key); + return record ? [record] : []; + }); + } + async getDenominationsByMasterPub( exchangeMasterPub: string, ): Promise<WalletDenomination[]> { @@ -1384,6 +1436,32 @@ export class SqliteWalletTransaction implements WalletDbTransaction { return rows.map((r) => this.rowToCoin(r)); } + async getCoinsByDenomPubHashes( + denomPubHashes: string[], + ): Promise<WalletCoin[]> { + const unique = new Map<string, Uint8Array>(); + for (const hash of denomPubHashes) { + const blob = crockToDb(hash); + unique.set(blobKey(blob), blob); + } + const blobs = [...unique.values()]; + const coins: WalletCoin[] = []; + for (let offset = 0; offset < blobs.length; offset += 500) { + const chunk = blobs.slice(offset, offset + 500); + const params: Record<string, Uint8Array> = {}; + const placeholders = chunk.map((blob, i) => { + params[`p${i}`] = blob; + return `$p${i}`; + }); + const rows = await this.all( + `SELECT * FROM coins WHERE denom_pub_hash IN (${placeholders.join(", ")})`, + params, + ); + coins.push(...rows.map((row) => this.rowToCoin(row))); + } + return coins; + } + async getCoinsBySourceTransaction( transactionId: string, ): Promise<WalletCoin[]> { @@ -1403,16 +1481,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { if (coinPubs.length === 0) { return []; } - const params: Record<string, Uint8Array> = {}; const blobs = coinPubs.map((pub) => crockToDb(pub)); - const placeholders = blobs.map((blob, i) => { - params[`p${i}`] = blob; - return `$p${i}`; - }); - const rows = await this.all( - `SELECT * FROM coins WHERE coin_pub IN (${placeholders.join(", ")})`, - params, - ); // Keyed on the stored bytes, not on the caller's string. Several // distinct strings can decode to the same key -- 52 Crockford characters // carry 260 bits and a key is 256 -- so re-encoding a row yields the @@ -1420,12 +1489,24 @@ export class SqliteWalletTransaction implements WalletDbTransaction { // Matching on the string dropped every coin whose argument was not // canonical, silently and without error. const byKey = new Map<string, WalletCoin>(); - for (const row of rows) { - const raw = row.coin_pub; - if (!(raw instanceof Uint8Array)) { - throw Error("coins.coin_pub must be a BLOB column"); + for (let offset = 0; offset < blobs.length; offset += 500) { + const chunk = blobs.slice(offset, offset + 500); + const params: Record<string, Uint8Array> = {}; + const placeholders = chunk.map((blob, i) => { + params[`p${i}`] = blob; + return `$p${i}`; + }); + const rows = await this.all( + `SELECT * FROM coins WHERE coin_pub IN (${placeholders.join(", ")})`, + params, + ); + for (const row of rows) { + const raw = row.coin_pub; + if (!(raw instanceof Uint8Array)) { + throw Error("coins.coin_pub must be a BLOB column"); + } + byKey.set(blobKey(raw), this.rowToCoin(row)); } - byKey.set(blobKey(raw), this.rowToCoin(row)); } const coins: WalletCoin[] = []; for (const blob of blobs) { @@ -1476,18 +1557,52 @@ export class SqliteWalletTransaction implements WalletDbTransaction { if (!row) { return undefined; } + return this.rowToCoinHistory(row); + } + + private rowToCoinHistory(row: ResultRow): WalletCoinHistory { return { coinPub: dbToCrock(row.coin_pub), history: dbToJson(row.history), }; } + async getCoinHistoriesByPubs( + coinPubs: string[], + ): Promise<WalletCoinHistory[]> { + if (coinPubs.length === 0) { + return []; + } + const blobs = coinPubs.map((coinPub) => crockToDb(coinPub)); + const byKey = new Map<string, WalletCoinHistory>(); + for (let offset = 0; offset < blobs.length; offset += 500) { + const chunk = blobs.slice(offset, offset + 500); + const params: Record<string, Uint8Array> = {}; + const placeholders = chunk.map((blob, i) => { + params[`p${i}`] = blob; + return `$p${i}`; + }); + const rows = await this.all( + `SELECT * FROM coin_history WHERE coin_pub IN (${placeholders.join(", ")})`, + params, + ); + for (const row of rows) { + const raw = row.coin_pub; + if (!(raw instanceof Uint8Array)) { + throw Error("coin_history.coin_pub must be a BLOB column"); + } + byKey.set(blobKey(raw), this.rowToCoinHistory(row)); + } + } + return blobs.flatMap((blob) => { + const record = byKey.get(blobKey(blob)); + return record ? [record] : []; + }); + } + async listAllCoinHistories(): Promise<WalletCoinHistory[]> { const rows = await this.all("SELECT * FROM coin_history"); - return rows.map((row) => ({ - coinPub: dbToCrock(row.coin_pub), - history: dbToJson(row.history), - })); + return rows.map((row) => this.rowToCoinHistory(row)); } async upsertCoinHistory(rec: WalletCoinHistory): Promise<void> { @@ -1539,6 +1654,59 @@ export class SqliteWalletTransaction implements WalletDbTransaction { return row ? this.rowToCoinAvailability(row) : undefined; } + async getCoinAvailabilitiesByRefs( + refs: WalletCoinAvailabilityRef[], + ): Promise<WalletCoinAvailability[]> { + if (refs.length === 0) { + return []; + } + const encoded = refs.map((ref) => { + const masterPub = crockToDb(ref.exchangeMasterPub); + const denomPubHash = crockToDb(ref.denomPubHash); + return { + masterPub, + denomPubHash, + maxAge: ref.maxAge, + key: `${blobKey(masterPub)}/${blobKey(denomPubHash)}/${ref.maxAge}`, + }; + }); + const byKey = new Map<string, WalletCoinAvailability>(); + // Three parameters per reference, again below sqlite's traditional limit. + for (let offset = 0; offset < encoded.length; offset += 300) { + const chunk = encoded.slice(offset, offset + 300); + const params: Record<string, Sqlite3Value> = {}; + const values = chunk.map((ref, i) => { + params[`mpk${i}`] = ref.masterPub; + params[`dph${i}`] = ref.denomPubHash; + params[`age${i}`] = ref.maxAge; + return `($mpk${i}, $dph${i}, $age${i})`; + }); + const rows = await this.all( + "SELECT * FROM coin_availability" + + ` WHERE (exchange_master_pub, denom_pub_hash, max_age) IN (${values.join(", ")})`, + params, + ); + for (const row of rows) { + const masterPub = row.exchange_master_pub; + const denomPubHash = row.denom_pub_hash; + if ( + !(masterPub instanceof Uint8Array) || + !(denomPubHash instanceof Uint8Array) + ) { + throw Error("coin availability identity columns must be BLOBs"); + } + byKey.set( + `${blobKey(masterPub)}/${blobKey(denomPubHash)}/${num(row.max_age)}`, + this.rowToCoinAvailability(row), + ); + } + } + return encoded.flatMap((ref) => { + const record = byKey.get(ref.key); + return record ? [record] : []; + }); + } + async upsertCoinAvailability(rec: WalletCoinAvailability): Promise<void> { await this.run( `INSERT INTO coin_availability ( diff --git a/packages/taler-wallet-core/src/dbtx.ts b/packages/taler-wallet-core/src/dbtx.ts @@ -530,6 +530,12 @@ export interface WalletDbTransaction { /** Get the coins of a given denomination. */ getCoinsByDenomPubHash(denomPubHash: string): Promise<WalletCoin[]>; + /** + * Get coins issued with any of the denomination hashes. Each matching coin + * is returned once even when a hash is repeated in the input. + */ + getCoinsByDenomPubHashes(denomPubHashes: string[]): Promise<WalletCoin[]>; + /** Delete a coin by its public key. */ deleteCoin(coinPub: string): Promise<void>; @@ -998,12 +1004,28 @@ export interface WalletDbTransaction { ref: WalletCoinAvailabilityRef, ): Promise<WalletCoinAvailability | undefined>; + /** + * Get availability records for a list of denomination/age references. + * + * Found records follow input order, duplicates are preserved and missing + * references are skipped. + */ + getCoinAvailabilitiesByRefs( + refs: WalletCoinAvailabilityRef[], + ): Promise<WalletCoinAvailability[]>; + /** Create or update a coin availability record. */ upsertCoinAvailability(rec: WalletCoinAvailability): Promise<void>; /** Get the recorded history of a coin. */ getCoinHistory(coinPub: string): Promise<WalletCoinHistory | undefined>; + /** + * Get histories for a list of coin public keys, in input order, skipping + * missing records and preserving duplicates. + */ + getCoinHistoriesByPubs(coinPubs: string[]): Promise<WalletCoinHistory[]>; + /** List every coin history, including orphaned legacy rows. */ listAllCoinHistories(): Promise<WalletCoinHistory[]>; @@ -1130,6 +1152,12 @@ export interface WalletDbTransaction { getDenomination(ref: WalletDenomRef): Promise<WalletDenomination | undefined>; /** + * Get denominations for a list of references. Found records follow input + * order, duplicates are preserved and missing references are skipped. + */ + getDenominationsByRefs(refs: WalletDenomRef[]): Promise<WalletDenomination[]>; + + /** * Find the first denomination of a family, scanning in withdraw-expiry order * from the given timestamp, that satisfies the caller's predicate. * diff --git a/packages/taler-wallet-core/src/deposits-performance.test.ts b/packages/taler-wallet-core/src/deposits-performance.test.ts @@ -0,0 +1,85 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import assert from "node:assert"; +import { test } from "node:test"; + +import { + DepositElementStatus, + DepositOperationStatus, + WalletDepositGroup, +} from "./db-common.js"; +import { + testing_applyDepositTrackingProgress, + testing_applyKycRequiredTransition, +} from "./deposits.js"; + +test("batched deposit tracking preserves wired status and merges evidence", () => { + const dg = { + statusPerCoin: [ + DepositElementStatus.Wired, + DepositElementStatus.DepositPending, + ], + trackingState: { existing: {} }, + } as unknown as WalletDepositGroup; + const wiredEvidence = { + amountRaw: "TESTKUDOS:1", + wireFee: "TESTKUDOS:0.01", + exchangePub: "exchange-pub", + timestampExecuted: 1, + wireTransferId: "wtid", + } as any; + + testing_applyDepositTrackingProgress(dg, [ + { index: 0, status: DepositElementStatus.Tracking }, + { + index: 1, + status: DepositElementStatus.Wired, + wiredCoin: { id: "new", value: wiredEvidence }, + }, + ]); + + assert.deepStrictEqual(dg.statusPerCoin, [ + DepositElementStatus.Wired, + DepositElementStatus.Wired, + ]); + assert.strictEqual(dg.trackingState?.existing != null, true); + assert.strictEqual(dg.trackingState?.new, wiredEvidence); +}); + +test("KYC transition can be folded into the tracking progress flush", () => { + const dg = { + operationStatus: DepositOperationStatus.FinalizingTrack, + } as WalletDepositGroup; + const changed = testing_applyKycRequiredTransition( + dg, + { + exchangeUrl: "https://exchange.example/", + kycPaytoHash: "payto-hash", + badKycAuth: false, + }, + [], + undefined, + ); + + assert.strictEqual(changed, true); + assert.strictEqual( + dg.operationStatus, + DepositOperationStatus.PendingAggregateKyc, + ); + assert.strictEqual(dg.kycInfo?.exchangeBaseUrl, "https://exchange.example/"); + assert.strictEqual(dg.kycInfo?.paytoHash, "payto-hash"); +}); diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts @@ -117,8 +117,8 @@ import { ReadyExchangeSummary, fetchFreshExchange, fetchFreshExchangeWithRetryNow, + findExchangeWireFee, getExchangeDetailsInTx, - getExchangeWireFee, getScopeForAllExchanges, markExchangeUsed, } from "./exchanges.js"; @@ -140,7 +140,7 @@ import { runWithMaybeProgressContext } from "./progress.js"; import { RefreshTransactionContext, createRefreshGroup, - getTotalRefreshCost, + getTotalRefreshCosts, } from "./refresh.js"; import { BalanceEffect, @@ -151,8 +151,9 @@ import { parseTransactionIdentifier, } from "./transactions.js"; import { + denomRefKey, + getDenomInfos, WalletExecutionContext, - getDenomInfo, walletExchangeClient, } from "./wallet.js"; import { augmentPaytoUrisForKycTransfer } from "./withdraw.js"; @@ -1446,64 +1447,109 @@ async function transitionToKycRequired( if (!dg) { return undefined; } - switch (dg.operationStatus) { - case DepositOperationStatus.LegacyPendingTrack: - case DepositOperationStatus.FinalizingTrack: - if (args.badKycAuth) { - throw Error("not yet supported"); - } else { - dg.operationStatus = DepositOperationStatus.PendingAggregateKyc; - } - break; - case DepositOperationStatus.PendingDeposit: - if (args.badKycAuth) { - dg.operationStatus = DepositOperationStatus.PendingDepositKycAuth; - dg.kycAuthTransferExpiry = transferExpiry; - dg.kycAuthTransferOptions = options; - } else { - dg.operationStatus = DepositOperationStatus.PendingDepositKyc; - } - break; - case DepositOperationStatus.PendingDepositKycAuth: - if (!args.badKycAuth) { - dg.operationStatus = DepositOperationStatus.PendingDepositKyc; - } else { - dg.kycAuthTransferExpiry = transferExpiry; - dg.kycAuthTransferOptions = options; - } - break; - case DepositOperationStatus.PendingDepositKyc: - if (args.badKycAuth) { - dg.operationStatus = DepositOperationStatus.PendingDepositKycAuth; - dg.kycAuthTransferExpiry = transferExpiry; - dg.kycAuthTransferOptions = options; - } - break; - default: - logger.warn( - `transitionToKycRequired: state ${dg.operationStatus} / ${ - DepositOperationStatus[dg.operationStatus] - } not handled`, - ); - return; - } - if (dg.kycInfo && dg.kycInfo.exchangeBaseUrl === args.exchangeUrl) { - dg.kycInfo.lastDeny = timestampPreciseToDb(TalerPreciseTimestamp.now()); - dg.kycInfo.lastBadKycAuth = args.badKycAuth; - } else { - // Reset other info when new exchange is involved. - dg.kycInfo = { - exchangeBaseUrl: args.exchangeUrl, - paytoHash: args.kycPaytoHash, - lastDeny: timestampPreciseToDb(TalerPreciseTimestamp.now()), - lastBadKycAuth: args.badKycAuth, - }; + if (!applyKycRequiredTransition(dg, args, options, transferExpiry)) { + return; } await h.update(dg, "kyc-required"); }); return TaskRunResult.progress(); } +function applyKycRequiredTransition( + dg: WalletDepositGroup, + args: { + kycPaytoHash: string; + exchangeUrl: string; + badKycAuth: boolean; + }, + options: KycAuthTransferOptionRaw[], + transferExpiry: TalerProtocolTimestamp | undefined, +): boolean { + switch (dg.operationStatus) { + case DepositOperationStatus.LegacyPendingTrack: + case DepositOperationStatus.FinalizingTrack: + if (args.badKycAuth) { + throw Error("not yet supported"); + } + dg.operationStatus = DepositOperationStatus.PendingAggregateKyc; + break; + case DepositOperationStatus.PendingDeposit: + if (args.badKycAuth) { + dg.operationStatus = DepositOperationStatus.PendingDepositKycAuth; + dg.kycAuthTransferExpiry = transferExpiry; + dg.kycAuthTransferOptions = options; + } else { + dg.operationStatus = DepositOperationStatus.PendingDepositKyc; + } + break; + case DepositOperationStatus.PendingDepositKycAuth: + if (!args.badKycAuth) { + dg.operationStatus = DepositOperationStatus.PendingDepositKyc; + } else { + dg.kycAuthTransferExpiry = transferExpiry; + dg.kycAuthTransferOptions = options; + } + break; + case DepositOperationStatus.PendingDepositKyc: + if (args.badKycAuth) { + dg.operationStatus = DepositOperationStatus.PendingDepositKycAuth; + dg.kycAuthTransferExpiry = transferExpiry; + dg.kycAuthTransferOptions = options; + } + break; + default: + logger.warn( + `transitionToKycRequired: state ${dg.operationStatus} / ${ + DepositOperationStatus[dg.operationStatus] + } not handled`, + ); + return false; + } + if (dg.kycInfo && dg.kycInfo.exchangeBaseUrl === args.exchangeUrl) { + dg.kycInfo.lastDeny = timestampPreciseToDb(TalerPreciseTimestamp.now()); + dg.kycInfo.lastBadKycAuth = args.badKycAuth; + } else { + dg.kycInfo = { + exchangeBaseUrl: args.exchangeUrl, + paytoHash: args.kycPaytoHash, + lastDeny: timestampPreciseToDb(TalerPreciseTimestamp.now()), + lastBadKycAuth: args.badKycAuth, + }; + } + return true; +} + +interface DepositTrackingProgressUpdate { + index: number; + status: DepositElementStatus; + wiredCoin?: { id: string; value: WalletDepositTrackingInfo }; +} + +function applyDepositTrackingProgress( + dg: WalletDepositGroup, + progress: DepositTrackingProgressUpdate[], +): void { + if (!dg.statusPerCoin) { + return; + } + for (const update of progress) { + if ( + dg.statusPerCoin[update.index] !== DepositElementStatus.Wired || + update.status === DepositElementStatus.Wired + ) { + dg.statusPerCoin[update.index] = update.status; + } + if (update.wiredCoin) { + dg.trackingState ??= {}; + dg.trackingState[update.wiredCoin.id] = update.wiredCoin.value; + } + } +} + +export const testing_applyDepositTrackingProgress = + applyDepositTrackingProgress; +export const testing_applyKycRequiredTransition = applyKycRequiredTransition; + async function processDepositGroupTrack( wex: WalletExecutionContext, depositGroup: WalletDepositGroup, @@ -1523,137 +1569,150 @@ async function processDepositGroupTrack( logger.trace(`tracking deposit group, status ${j2s(statusPerCoin)}`); const { depositGroupId } = depositGroup; const ctx = new DepositTransactionContext(wex, depositGroupId); - for (let i = 0; i < statusPerCoin.length; i++) { - const coinPub = payCoinSelection.coinPubs[i]; - // FIXME: Make the URL part of the coin selection? - const exchangeBaseUrl = await wex.runWalletDbTx(async (tx) => { - const coinRecord = await tx.getCoin(coinPub); - checkDbInvariant(!!coinRecord, `coin ${coinPub} not found in DB`); - return coinRecord.exchangeBaseUrl; - }); + const trackingInputs = await wex.runWalletDbTx(async (tx) => { + const coins = await tx.getCoinsByPubs(payCoinSelection.coinPubs); + const coinsByPub = new Map(coins.map((coin) => [coin.coinPub, coin])); + for (const coinPub of payCoinSelection.coinPubs) { + checkDbInvariant( + !!coinsByPub.get(coinPub), + `coin ${coinPub} not found in DB`, + ); + } + const exchangeBaseUrls = [ + ...new Set(coins.map((coin) => coin.exchangeBaseUrl)), + ]; + const details = await Promise.all( + exchangeBaseUrls.map((baseUrl) => tx.getExchangeDetails(baseUrl)), + ); + return { + coinsByPub, + exchangeDetailsByUrl: new Map( + exchangeBaseUrls.map((baseUrl, i) => [baseUrl, details[i]]), + ), + }; + }); + let wireType: string | undefined; + const getWireType = (): string => { + if (wireType !== undefined) { + return wireType; + } + const payto = Result.orUndefined( + Paytos.fromString(depositGroup.wire.payto_uri), + ); + if (!payto) { + throw Error(`unparsable payto: ${depositGroup.wire.payto_uri}`); + } + return (wireType = payto.targetType!); + }; + const progress: DepositTrackingProgressUpdate[] = []; - let updatedTxStatus: DepositElementStatus | undefined = undefined; - let newWiredCoin: - | { - id: string; - value: WalletDepositTrackingInfo; - } - | undefined; + const flushProgress = async (kycArgs?: { + kycPaytoHash: string; + exchangeUrl: string; + badKycAuth: boolean; + }): Promise<boolean> => { + let allWired = true; + await wex.runWalletDbTx(async (tx) => { + const [dg, h] = await ctx.getRecordHandle(tx); + if (!dg?.statusPerCoin) { + return; + } + applyDepositTrackingProgress(dg, progress); + if (kycArgs) { + applyKycRequiredTransition(dg, kycArgs, [], undefined); + await h.update(dg, "kyc-required"); + return; + } + allWired = dg.statusPerCoin.every( + (status) => status === DepositElementStatus.Wired, + ); + if (allWired) { + dg.timestampFinished = timestampPreciseToDb( + TalerPreciseTimestamp.now(), + ); + dg.operationStatus = DepositOperationStatus.Finished; + } + await h.update(dg, "track"); + }); + return allWired; + }; - if (statusPerCoin[i] !== DepositElementStatus.Wired) { + try { + for (let i = 0; i < statusPerCoin.length; i++) { + if (statusPerCoin[i] === DepositElementStatus.Wired) { + continue; + } + const coinPub = payCoinSelection.coinPubs[i]; + const coinRecord = trackingInputs.coinsByPub.get(coinPub)!; + const exchangeBaseUrl = coinRecord.exchangeBaseUrl; const track = await trackDeposit( wex, depositGroup, coinPub, exchangeBaseUrl, ); - logger.trace(`track response: ${j2s(track)}`); if (track.type === "accepted") { if (!track.kyc_ok && track.requirement_row !== undefined) { - // FIXME: Take this from the response. const paytoHash = encodeCrock( Paytos.hashNormalized(depositGroup.wire.payto_uri), ); - return transitionToKycRequired(wex, depositGroup, { + await flushProgress({ exchangeUrl: exchangeBaseUrl, kycPaytoHash: paytoHash, - badKycAuth: false, // ?? + badKycAuth: false, }); - } else { - updatedTxStatus = DepositElementStatus.Tracking; + return TaskRunResult.progress(); } + progress.push({ index: i, status: DepositElementStatus.Tracking }); } else if (track.type === "wired") { - updatedTxStatus = DepositElementStatus.Wired; - - const payto = Result.orUndefined( - Paytos.fromString(depositGroup.wire.payto_uri), - ); - if (!payto) { - throw Error(`unparsable payto: ${depositGroup.wire.payto_uri}`); + const exchangeDetails = + trackingInputs.exchangeDetailsByUrl.get(exchangeBaseUrl); + if (!exchangeDetails) { + throw Error(`exchange missing: ${exchangeBaseUrl}`); } - - const fee = await getExchangeWireFee( - wex, - payto.targetType!, - exchangeBaseUrl, + const fee = findExchangeWireFee( + exchangeDetails, + getWireType(), track.execution_time, ); - const raw = Amounts.parseOrThrow(track.coin_contribution); - const wireFee = Amounts.parseOrThrow(fee.wireFee); - - newWiredCoin = { - value: { - amountRaw: Amounts.stringify(raw), - wireFee: Amounts.stringify(wireFee), - exchangePub: track.exchange_pub, - timestampExecuted: timestampProtocolToDb(track.execution_time), - wireTransferId: track.wtid, + progress.push({ + index: i, + status: DepositElementStatus.Wired, + wiredCoin: { + id: track.exchange_sig, + value: { + amountRaw: Amounts.stringify( + Amounts.parseOrThrow(track.coin_contribution), + ), + wireFee: Amounts.stringify(Amounts.parseOrThrow(fee.wireFee)), + exchangePub: track.exchange_pub, + timestampExecuted: timestampProtocolToDb(track.execution_time), + wireTransferId: track.wtid, + }, }, - id: track.exchange_sig, - }; + }); } else { - updatedTxStatus = DepositElementStatus.DepositPending; + progress.push({ + index: i, + status: DepositElementStatus.DepositPending, + }); } } - - if (updatedTxStatus !== undefined) { - await wex.runWalletDbTx(async (tx) => { - const dg = await tx.getDepositGroup(depositGroupId); - if (!dg) { - return; - } - if (!dg.statusPerCoin) { - return; - } - if (updatedTxStatus !== undefined) { - dg.statusPerCoin[i] = updatedTxStatus; - } - if (newWiredCoin) { - /** - * FIXME: if there is a new wire information from the exchange - * it should add up to the previous tracking states. - * - * This may loose information by overriding prev state. - * - * And: add checks to integration tests - */ - if (!dg.trackingState) { - dg.trackingState = {}; - } - - dg.trackingState[newWiredCoin.id] = newWiredCoin.value; - } - await tx.upsertDepositGroup(dg); - await ctx.updateTransactionMeta(tx); - }); + } catch (originalError) { + if (progress.length > 0) { + try { + await flushProgress(); + } catch (flushError) { + throw new Error("failed to persist deposit tracking progress", { + cause: { originalError, flushError }, + }); + } } + throw originalError; } - let allWired = true; - - await wex.runWalletDbTx(async (tx) => { - const [dg, h] = await ctx.getRecordHandle(tx); - if (!dg) { - return undefined; - } - if (!dg.statusPerCoin) { - return undefined; - } - for (let i = 0; i < dg.statusPerCoin.length; i++) { - if (dg.statusPerCoin[i] !== DepositElementStatus.Wired) { - allWired = false; - break; - } - } - if (allWired) { - dg.timestampFinished = timestampPreciseToDb(TalerPreciseTimestamp.now()); - dg.operationStatus = DepositOperationStatus.Finished; - await tx.upsertDepositGroup(dg); - await ctx.updateTransactionMeta(tx); - } - await h.update(dg, "track"); - }); + const allWired = await flushProgress(); if (allWired) { return TaskRunResult.finished(); } else { @@ -2457,8 +2516,9 @@ async function getCounterpartyEffectiveDepositAmount( const exchangeSet: Set<string> = new Set(); await wex.runWalletDbTx(async (tx) => { + const denoms = await getDenomInfos(wex, tx, pcs); for (let i = 0; i < pcs.length; i++) { - const denom = await getDenomInfo(wex, tx, pcs[i]); + const denom = denoms.get(denomRefKey(pcs[i])); if (!denom) { throw Error("can't find denomination to calculate deposit amount"); } @@ -2513,17 +2573,19 @@ async function getTotalFeesForDepositAmount( const exchangeSet: Set<string> = new Set(); await wex.runWalletDbTx(async (tx) => { + const denoms = await getDenomInfos(wex, tx, pcs); + const refreshRequests = []; for (let i = 0; i < pcs.length; i++) { - const denom = await getDenomInfo(wex, tx, pcs[i]); + const denom = denoms.get(denomRefKey(pcs[i])); if (!denom) { throw Error("can't find denomination to calculate deposit amount"); } coinFee.push(Amounts.parseOrThrow(denom.feeDeposit)); exchangeSet.add(pcs[i].exchangeBaseUrl); const amountLeft = Amounts.sub(denom.value, pcs[i].contribution).amount; - const refreshCost = await getTotalRefreshCost(wex, tx, denom, amountLeft); - refreshFee.push(refreshCost); + refreshRequests.push({ refreshedDenom: denom, amountLeft }); } + refreshFee.push(...(await getTotalRefreshCosts(wex, tx, refreshRequests))); for (const exchangeUrl of exchangeSet.values()) { const exchangeDetails = await getExchangeDetailsInTx(tx, exchangeUrl); diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -154,6 +154,7 @@ import { WalletDenomLossEvent, WalletDenomination, WalletDenominationFamily, + WalletCoin, WalletExchangeDetails, WalletExchangeDetailsPointer, WalletExchangeEntry, @@ -194,6 +195,7 @@ import { } from "./transactions.js"; import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions.js"; import { + denomRefKey, InternalWalletState, WalletExecutionContext, walletExchangeClient, @@ -362,8 +364,10 @@ export async function getScopeForAllCoins( coinPubs: string[], ): Promise<ScopeInfo[]> { let exchangeSet = new Set<string>(); + const coins = await tx.getCoinsByPubs(coinPubs); + const coinsByPub = new Map(coins.map((coin) => [coin.coinPub, coin])); for (const pub of coinPubs) { - const coin = await tx.getCoin(pub); + const coin = coinsByPub.get(pub); if (!coin) { logger.warn(`coin ${coinPubs} not found, unable to compute full scope`); continue; @@ -2761,12 +2765,16 @@ async function doExchangeAutoRefresh( return; } const coins = await tx.getCoinsByExchange(exchangeBaseUrl); + const denominations = await tx.getDenominationsByRefs(coins); + const denominationsByRef = new Map( + denominations.map((denom) => [denomRefKey(denom), denom]), + ); const refreshCoins: CoinRefreshRequest[] = []; for (const coin of coins) { if (coin.status !== CoinStatus.Fresh) { continue; } - const denom = await tx.getDenomination(coin); + const denom = denominationsByRef.get(denomRefKey(coin)); if (!denom) { logger.warn("denomination not in database"); continue; @@ -2895,13 +2903,26 @@ async function handleDenomLoss( let amountExpired = Amount.zeroOfCurrency(currency); let amountRevoked = Amount.zeroOfCurrency(currency); let amountUnoffered = Amount.zeroOfCurrency(currency); + const denominations = await tx.getDenominationsByRefs(coinAvailabilityRecs); + const denominationsByRef = new Map( + denominations.map((denom) => [denomRefKey(denom), denom]), + ); + const coinsByDenomHash = new Map<string, WalletCoin[]>(); + const affectedCoins = await tx.getCoinsByDenomPubHashes( + coinAvailabilityRecs.map((availability) => availability.denomPubHash), + ); + for (const coin of affectedCoins) { + const group = coinsByDenomHash.get(coin.denomPubHash) ?? []; + group.push(coin); + coinsByDenomHash.set(coin.denomPubHash, group); + } for (const coinAv of coinAvailabilityRecs) { if (coinAv.freshCoinCount <= 0) { continue; } const n = coinAv.freshCoinCount; - const denom = await tx.getDenomination(coinAv); + const denom = denominationsByRef.get(denomRefKey(coinAv)); const timestampExpireDeposit = !denom ? undefined : timestampAbsoluteFromDb(denom.stampExpireDeposit); @@ -2949,7 +2970,7 @@ async function handleDenomLoss( logger.warn(`denomination ${coinAv.denomPubHash} is a loss`); - const coins = await tx.getCoinsByDenomPubHash(coinAv.denomPubHash); + const coins = coinsByDenomHash.get(coinAv.denomPubHash) ?? []; for (const coin of coins) { switch (coin.status) { case CoinStatus.Fresh: @@ -3219,13 +3240,31 @@ async function handleRecoup( const recoupDenomList = recoup; const newlyRevokedCoinPubs: string[] = []; logger.trace("recoup list from exchange", recoupDenomList); + const refs = recoupDenomList.map((recoupInfo) => ({ + exchangeMasterPub, + denomPubHash: recoupInfo.h_denom_pub, + })); + const denominations = await tx.getDenominationsByRefs(refs); + const denominationsByRef = new Map( + denominations.map((denom) => [denomRefKey(denom), denom]), + ); + const coinsByDenomHash = new Map<string, WalletCoin[]>(); + const affectedCoins = await tx.getCoinsByDenomPubHashes( + recoupDenomList.map((recoupInfo) => recoupInfo.h_denom_pub), + ); + for (const coin of affectedCoins) { + const group = coinsByDenomHash.get(coin.denomPubHash) ?? []; + group.push(coin); + coinsByDenomHash.set(coin.denomPubHash, group); + } for (const recoupInfo of recoupDenomList) { // A revocation names a denomination of the key set currently in force: // it arrives in that key set's own /keys response. - const oldDenom = await tx.getDenomination({ + const ref = { exchangeMasterPub: exchangeMasterPub, denomPubHash: recoupInfo.h_denom_pub, - }); + }; + const oldDenom = denominationsByRef.get(denomRefKey(ref)); if (!oldDenom) { // We never even knew about the revoked denomination, all good. continue; @@ -3239,9 +3278,7 @@ async function handleRecoup( logger.info("revoking denom", recoupInfo.h_denom_pub); oldDenom.isRevoked = true; await tx.upsertDenomination(oldDenom); - const affectedCoins = await tx.getCoinsByDenomPubHash( - recoupInfo.h_denom_pub, - ); + const affectedCoins = coinsByDenomHash.get(recoupInfo.h_denom_pub) ?? []; for (const ac of affectedCoins) { newlyRevokedCoinPubs.push(ac.coinPub); } @@ -4219,10 +4256,19 @@ export async function getExchangeWireFee( throw Error(`exchange missing: ${baseUrl}`); } + return findExchangeWireFee(exchangeDetails, wireType, time); +} + +/** Find the applicable wire fee in already-loaded exchange details. */ +export function findExchangeWireFee( + exchangeDetails: WalletExchangeDetails, + wireType: string, + time: TalerProtocolTimestamp, +): WireFee { const fees = exchangeDetails.wireInfo.feesForType[wireType]; if (!fees || fees.length === 0) { throw Error( - `exchange ${baseUrl} doesn't have fees for wire type ${wireType}`, + `exchange ${exchangeDetails.exchangeBaseUrl} doesn't have fees for wire type ${wireType}`, ); } const fee = fees.find((x) => { diff --git a/packages/taler-wallet-core/src/instructedAmountConversion.ts b/packages/taler-wallet-core/src/instructedAmountConversion.ts @@ -35,7 +35,7 @@ import { getAllDenominationsForExchange, getExchangeDetailsInTx, } from "./exchanges.js"; -import { WalletExecutionContext } from "./wallet.js"; +import { denomRefKey, WalletExecutionContext } from "./wallet.js"; export interface CoinInfo { id: string; @@ -246,8 +246,12 @@ async function getAvailableCoins( // FIXME: Check that the individual denomination is audited! // FIXME: Should we exclude denominations that are // not spendable anymore? + const denominations = await tx.getDenominationsByRefs(myExchangeCoins); + const denominationsByRef = new Map( + denominations.map((denom) => [denomRefKey(denom), denom]), + ); for (const coinAvail of myExchangeCoins) { - const denom = await tx.getDenomination(coinAvail); + const denom = denominationsByRef.get(denomRefKey(coinAvail)); checkDbInvariant( !!denom, `denomination of a coin is missing hash: ${coinAvail.denomPubHash}`, diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -164,7 +164,7 @@ import { runWithMaybeProgressContext } from "./progress.js"; import { calculateRefreshOutput, createRefreshGroup, - getTotalRefreshCost, + getTotalRefreshCosts, RefreshTransactionContext, } from "./refresh.js"; import { @@ -182,8 +182,9 @@ import { parseTransactionIdentifier, } from "./transactions.js"; import { + denomRefKey, EXCHANGE_COINS_LOCK, - getDenomInfo, + getDenomInfos, WalletExecutionContext, walletMerchantClient, } from "./wallet.js"; @@ -849,28 +850,30 @@ 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 denomKey = `${pcs[i].exchangeMasterPub}/${pcs[i].denomPubHash}`; - let denom = denoms.get(denomKey); - if (!denom) { - denom = await tx.getDenomination(pcs[i]); - } + const loadedDenoms = await tx.getDenominationsByRefs(pcs); + const denoms = new Map( + loadedDenoms.map((denom) => [denomRefKey(denom), denom]), + ); + const paymentDenoms = pcs.map((coin) => { + const denom = denoms.get(denomRefKey(coin)); 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, - tx, - WalletDenomination.toDenomInfo(denom), - amountLeft, - ); + return denom; + }); + const refreshCosts = await getTotalRefreshCosts( + wex, + tx, + paymentDenoms.map((denom, i) => ({ + refreshedDenom: WalletDenomination.toDenomInfo(denom), + amountLeft: Amounts.sub(denom.value, pcs[i].contribution).amount, + })), + ); + for (let i = 0; i < pcs.length; i++) { costs.push(Amounts.parseOrThrow(pcs[i].contribution)); - costs.push(refreshCost); + costs.push(refreshCosts[i]); } const zero = Amounts.zeroOfCurrency(currency); return Amounts.sum([zero, ...costs]).amount; @@ -2097,23 +2100,21 @@ export async function generateDepositPermissions( await wex.runWalletDbTx(async (tx) => { const coins = await tx.getCoinsByPubs(payCoinSel.coinPubs); const coinsByPub = new Map(coins.map((coin) => [coin.coinPub, coin])); - const denoms = new Map<string, WalletDenomination>(); + const loadedDenoms = await tx.getDenominationsByRefs(coins); + const denoms = new Map( + loadedDenoms.map((denom) => [denomRefKey(denom), denom]), + ); for (const coinPub of payCoinSel.coinPubs) { const coin = coinsByPub.get(coinPub); if (!coin) { throw Error("can't pay, allocated coin not found anymore"); } - const denomKey = `${coin.exchangeMasterPub}/${coin.denomPubHash}`; - let denom = denoms.get(denomKey); - if (!denom) { - denom = await tx.getDenomination(coin); - } + const denom = denoms.get(denomRefKey(coin)); if (!denom) { throw Error( "can't pay, denomination of allocated coin not found anymore", ); } - denoms.set(denomKey, denom); coinWithDenom.push({ coin, denom }); } }); @@ -4335,9 +4336,13 @@ async function processPurchaseAbortingRefund( await h.update(rec, "abort-refresh"); } + const abortCoins = await tx.getCoinsByPubs(payCoinSelection.coinPubs); + const abortCoinsByPub = new Map( + abortCoins.map((coin) => [coin.coinPub, coin]), + ); for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { const coinPub = payCoinSelection.coinPubs[i]; - const coin = await tx.getCoin(coinPub); + const coin = abortCoinsByPub.get(coinPub); checkDbInvariant(!!coin, `coin not found for ${coinPub}`); abortingCoins.push({ coin_pub: coinPub, @@ -4693,12 +4698,15 @@ async function computeRefreshRequest( items: WalletRefundItem[], ): Promise<CoinRefreshRequest[]> { const refreshCoins: CoinRefreshRequest[] = []; + const coins = await tx.getCoinsByPubs(items.map((item) => item.coinPub)); + const coinsByPub = new Map(coins.map((coin) => [coin.coinPub, coin])); + const denoms = await getDenomInfos(wex, tx, coins); for (const item of items) { - const coin = await tx.getCoin(item.coinPub); + const coin = coinsByPub.get(item.coinPub); if (!coin) { throw Error("coin not found"); } - const denomInfo = await getDenomInfo(wex, tx, coin); + const denomInfo = denoms.get(denomRefKey(coin)); if (!denomInfo) { throw Error("denom not found"); } diff --git a/packages/taler-wallet-core/src/pay-peer-common.ts b/packages/taler-wallet-core/src/pay-peer-common.ts @@ -28,8 +28,12 @@ import { WalletReserve } from "./db-common.js"; import { SpendCoinDetails } from "./crypto/cryptoImplementation.js"; import { DbPeerPushPaymentCoinSelection } from "./db-indexeddb.js"; import { markExchangeUsed } from "./exchanges.js"; -import { getTotalRefreshCost } from "./refresh.js"; -import { WalletExecutionContext, getDenomInfo } from "./wallet.js"; +import { getTotalRefreshCosts } from "./refresh.js"; +import { + denomRefKey, + getDenomInfos, + WalletExecutionContext, +} from "./wallet.js"; import { updateWithdrawalDenomsForExchange } from "./withdraw.js"; import { WalletDbTransaction } from "./dbtx.js"; @@ -42,12 +46,15 @@ export async function queryCoinInfosForSelection( ): Promise<SpendCoinDetails[]> { let infos: SpendCoinDetails[] = []; await wex.runWalletDbTx(async (tx) => { + const coins = await tx.getCoinsByPubs(csel.coinPubs); + const coinsByPub = new Map(coins.map((coin) => [coin.coinPub, coin])); + const denoms = await getDenomInfos(wex, tx, coins); for (let i = 0; i < csel.coinPubs.length; i++) { - const coin = await tx.getCoin(csel.coinPubs[i]); + const coin = coinsByPub.get(csel.coinPubs[i]); if (!coin) { throw Error("coin not found anymore"); } - const denom = await getDenomInfo(wex, tx, coin); + const denom = denoms.get(denomRefKey(coin)); if (!denom) { throw Error("denom for coin not found anymore"); } @@ -75,22 +82,27 @@ export async function getTotalPeerPaymentCostInTx( throw Error("can't calculate payment cost, no coin selected"); } const costs: AmountJson[] = []; - for (let i = 0; i < pcs.length; i++) { - const denomInfo = await getDenomInfo(wex, tx, pcs[i]); + const denoms = await getDenomInfos(wex, tx, pcs); + const paymentDenoms = pcs.map((coin) => { + const denomInfo = denoms.get(denomRefKey(coin)); if (!denomInfo) { throw Error( "can't calculate payment cost, denomination for coin not found", ); } - const amountLeft = Amounts.sub(denomInfo.value, pcs[i].contribution).amount; - const refreshCost = await getTotalRefreshCost( - wex, - tx, - denomInfo, - amountLeft, - ); + return denomInfo; + }); + const refreshCosts = await getTotalRefreshCosts( + wex, + tx, + paymentDenoms.map((denomInfo, i) => ({ + refreshedDenom: denomInfo, + amountLeft: Amounts.sub(denomInfo.value, pcs[i].contribution).amount, + })), + ); + for (let i = 0; i < pcs.length; i++) { costs.push(Amounts.parseOrThrow(pcs[i].contribution)); - costs.push(refreshCost); + costs.push(refreshCosts[i]); } return Amounts.sum(costs).amount; } diff --git a/packages/taler-wallet-core/src/recoup.ts b/packages/taler-wallet-core/src/recoup.ts @@ -483,9 +483,11 @@ export async function createRecoupGroup( operationStatus: RecoupOperationStatus.Pending, }; + const coins = await tx.getCoinsByPubs(coinPubs); + const coinsByPub = new Map(coins.map((coin) => [coin.coinPub, coin])); for (let coinIdx = 0; coinIdx < coinPubs.length; coinIdx++) { const coinPub = coinPubs[coinIdx]; - const coin = await tx.getCoin(coinPub); + const coin = coinsByPub.get(coinPub); if (!coin) { await putGroupAsFinished(wex, tx, recoupGroup, coinIdx); continue; diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts @@ -116,8 +116,11 @@ import { makeTransactionActionUnsupportedError, } from "./transactions.js"; import { + coinAvailabilityRefKey, + denomRefKey, EXCHANGE_COINS_LOCK, getDenomInfo, + getDenomInfos, WalletExecutionContext, walletExchangeClient, } from "./wallet.js"; @@ -357,24 +360,73 @@ export async function getTotalRefreshCost( refreshedDenom: DenominationInfo, amountLeft: AmountJson, ): Promise<AmountJson> { - const { exchangeBaseUrl, denomPubHash } = refreshedDenom; - const key = `denom=${exchangeBaseUrl}/${denomPubHash};left=${Amounts.stringify( - amountLeft, - )}`; - const cached = wex.ws.refreshCostCache.get(key); - if (cached) { - return cached; + return ( + await getTotalRefreshCosts(wex, tx, [{ refreshedDenom, amountLeft }]) + )[0]; +} + +function refreshCostCacheKey( + refreshedDenom: DenominationInfo, + amountLeft: AmountJson, +): string { + return `denom=${refreshedDenom.exchangeBaseUrl}/${ + refreshedDenom.denomPubHash + };left=${Amounts.stringify(amountLeft)}`; +} + +/** Compute many refresh costs while loading withdrawable denoms once per exchange. */ +export async function getTotalRefreshCosts( + wex: WalletExecutionContext, + tx: WalletDbTransaction, + requests: Array<{ + refreshedDenom: DenominationInfo; + amountLeft: AmountJson; + }>, +): Promise<AmountJson[]> { + const results: Array<AmountJson | undefined> = new Array(requests.length); + const groups = new Map<string, number[]>(); + for (let i = 0; i < requests.length; i++) { + const request = requests[i]; + const cached = wex.ws.refreshCostCache.get( + refreshCostCacheKey(request.refreshedDenom, request.amountLeft), + ); + if (cached) { + results[i] = cached; + continue; + } + const groupKey = `${request.refreshedDenom.exchangeBaseUrl}\0${Amounts.currencyOf( + request.amountLeft, + )}`; + const indices = groups.get(groupKey) ?? []; + indices.push(i); + groups.set(groupKey, indices); } - await requireExchangeReadyTx(wex, tx, exchangeBaseUrl); - return wex.ws.refreshCostCache.getOrPut(key, async () => { + for (const indices of groups.values()) { + const first = requests[indices[0]]; + const exchangeBaseUrl = first.refreshedDenom.exchangeBaseUrl; + const currency = Amounts.currencyOf(first.amountLeft); + await requireExchangeReadyTx(wex, tx, exchangeBaseUrl); const allDenoms = await getWithdrawableDenomsTx( wex, tx, exchangeBaseUrl, - Amounts.currencyOf(amountLeft), + currency, ); - return getTotalRefreshCostInternal(allDenoms, refreshedDenom, amountLeft); - }); + for (const index of indices) { + const request = requests[index]; + const cost = getTotalRefreshCostInternal( + allDenoms, + request.refreshedDenom, + request.amountLeft, + ); + wex.ws.refreshCostCache.put( + refreshCostCacheKey(request.refreshedDenom, request.amountLeft), + cost, + ); + results[index] = cost; + } + } + return results as AmountJson[]; } /** @@ -1382,20 +1434,41 @@ async function refreshReveal( return; } rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished; - for (const coin of coins) { - const existingCoin = await tx.getCoin(coin.coinPub); - if (existingCoin) { - continue; - } + const existingCoins = await tx.getCoinsByPubs( + coins.map((coin) => coin.coinPub), + ); + const existingCoinPubs = new Set(existingCoins.map((coin) => coin.coinPub)); + const newCoins = coins.filter( + (coin) => !existingCoinPubs.has(coin.coinPub), + ); + const denoms = await getDenomInfos(wex, tx, newCoins); + const loadedAvailabilities = await tx.getCoinAvailabilitiesByRefs(newCoins); + const availabilities = new Map( + loadedAvailabilities.map((availability) => [ + coinAvailabilityRefKey(availability), + availability, + ]), + ); + for (const coin of newCoins) { await tx.upsertCoin(coin); - const denomInfo = await getDenomInfo(wex, tx, coin); + const denomInfo = denoms.get(denomRefKey(coin)); checkDbInvariant(!!denomInfo, `no denom with hash ${coin.denomPubHash}`); - const car = await getCoinAvailabilityForDenom( - wex, - tx, - denomInfo, - coin.maxAge, - ); + const availabilityKey = coinAvailabilityRefKey(coin); + let car = availabilities.get(availabilityKey); + if (!car) { + car = { + maxAge: coin.maxAge, + value: denomInfo.value, + currency: Amounts.currencyOf(denomInfo.value), + denomPubHash: denomInfo.denomPubHash, + exchangeBaseUrl: denomInfo.exchangeBaseUrl, + exchangeMasterPub: denomInfo.exchangeMasterPub, + freshCoinCount: 0, + hasFreshCoins: 0, + visibleCoinCount: 0, + }; + availabilities.set(availabilityKey, car); + } checkDbInvariant( car.pendingRefreshOutputCount != null && car.pendingRefreshOutputCount > 0, @@ -1403,7 +1476,9 @@ async function refreshReveal( ); car.pendingRefreshOutputCount--; car.freshCoinCount++; - await tx.upsertCoinAvailability(car); + } + for (const availability of availabilities.values()) { + await tx.upsertCoinAvailability(availability); } await h.update(rg, "reveal"); }); @@ -1657,31 +1732,55 @@ export interface RefreshOutputInfo { perExchangeInfo: Record<string, WalletRefreshGroupPerExchangeInfo>; } -export async function calculateRefreshOutput( +interface LoadedRefreshCoin { + request: CoinRefreshRequest; + coin: WalletCoin; + denom: DenominationInfo; +} + +async function loadRefreshCoins( wex: WalletExecutionContext, tx: WalletDbTransaction, - currency: string, - oldCoinPubs: CoinRefreshRequest[], -): Promise<RefreshOutputInfo> { - const estimatedOutputPerCoin: AmountJson[] = []; - - const infoPerExchange: Record<string, WalletRefreshGroupPerExchangeInfo> = {}; - - for (const ocp of oldCoinPubs) { - const coin = await tx.getCoin(ocp.coinPub); + requests: CoinRefreshRequest[], +): Promise<LoadedRefreshCoin[]> { + const coins = await tx.getCoinsByPubs(requests.map((x) => x.coinPub)); + const coinsByPub = new Map(coins.map((coin) => [coin.coinPub, coin])); + const denoms = await getDenomInfos(wex, tx, coins); + return requests.map((request) => { + const coin = coinsByPub.get(request.coinPub); checkDbInvariant(!!coin, "coin must be in database"); - const denom = await getDenomInfo(wex, tx, coin); + const denom = denoms.get(denomRefKey(coin)); checkDbInvariant( !!denom, "denomination for existing coin must be in database", ); - const refreshAmount = ocp.amount; - const cost = await getTotalRefreshCost( - wex, - tx, - denom, - Amounts.parseOrThrow(refreshAmount), - ); + return { request, coin, denom }; + }); +} + +async function calculateRefreshOutputFromLoaded( + wex: WalletExecutionContext, + tx: WalletDbTransaction, + loadedCoins: LoadedRefreshCoin[], +): Promise<RefreshOutputInfo> { + const estimatedOutputPerCoin: AmountJson[] = []; + const infoPerExchange: Record<string, WalletRefreshGroupPerExchangeInfo> = {}; + const refreshAmounts = loadedCoins.map(({ request }) => + Amounts.parseOrThrow(request.amount), + ); + const costs = await getTotalRefreshCosts( + wex, + tx, + loadedCoins.map(({ denom }, i) => ({ + refreshedDenom: denom, + amountLeft: refreshAmounts[i], + })), + ); + + for (let i = 0; i < loadedCoins.length; i++) { + const { request, coin } = loadedCoins[i]; + const refreshAmount = request.amount; + const cost = costs[i]; const output = Amounts.sub(refreshAmount, cost).amount; let exchInfo = infoPerExchange[coin.exchangeBaseUrl]; if (!exchInfo) { @@ -1701,26 +1800,46 @@ export async function calculateRefreshOutput( }; } -async function applyRefreshToOldCoins( +export async function calculateRefreshOutput( wex: WalletExecutionContext, tx: WalletDbTransaction, + _currency: string, oldCoinPubs: CoinRefreshRequest[], +): Promise<RefreshOutputInfo> { + const loadedCoins = await loadRefreshCoins(wex, tx, oldCoinPubs); + return calculateRefreshOutputFromLoaded(wex, tx, loadedCoins); +} + +async function applyRefreshToOldCoins( + tx: WalletDbTransaction, + loadedCoins: LoadedRefreshCoin[], refreshGroupId: string, ): Promise<void> { - for (const ocp of oldCoinPubs) { - const coin = await tx.getCoin(ocp.coinPub); - checkDbInvariant(!!coin, "coin must be in database"); - const denom = await getDenomInfo(wex, tx, coin); - checkDbInvariant( - !!denom, - "denomination for existing coin must be in database", - ); + const availabilities = await tx.getCoinAvailabilitiesByRefs( + loadedCoins.map((x) => x.coin), + ); + const availabilitiesByRef = new Map( + availabilities.map((availability) => [ + coinAvailabilityRefKey(availability), + availability, + ]), + ); + const histories = await tx.getCoinHistoriesByPubs( + loadedCoins.map((x) => x.coin.coinPub), + ); + const historiesByPub = new Map( + histories.map((history) => [history.coinPub, history]), + ); + const changedAvailabilities = new Map<string, WalletCoinAvailability>(); + const changedHistories = new Map<string, WalletCoinHistory>(); + const changedCoins = new Map<string, WalletCoin>(); + for (const { request, coin } of loadedCoins) { switch (coin.status) { case CoinStatus.Dormant: break; case CoinStatus.Fresh: { coin.status = CoinStatus.Dormant; - const coinAv = await tx.getCoinAvailability(coin); + const coinAv = availabilitiesByRef.get(coinAvailabilityRefKey(coin)); checkDbInvariant( !!coinAv, `no denom info for ${coin.denomPubHash} age ${coin.maxAge}`, @@ -1746,7 +1865,7 @@ async function applyRefreshToOldCoins( coinAv.visibleCoinCount--; } } - await tx.upsertCoinAvailability(coinAv); + changedAvailabilities.set(coinAvailabilityRefKey(coinAv), coinAv); break; } case CoinStatus.FreshSuspended: { @@ -1760,7 +1879,7 @@ async function applyRefreshToOldCoins( default: assertUnreachable(coin.status); } - let histEntry: WalletCoinHistory | undefined = await tx.getCoinHistory( + let histEntry: WalletCoinHistory | undefined = historiesByPub.get( coin.coinPub, ); if (!histEntry) { @@ -1775,9 +1894,19 @@ async function applyRefreshToOldCoins( tag: TransactionType.Refresh, refreshGroupId, }), - amount: Amounts.stringify(ocp.amount), + amount: Amounts.stringify(request.amount), }); - await tx.upsertCoinHistory(histEntry); + historiesByPub.set(coin.coinPub, histEntry); + changedHistories.set(coin.coinPub, histEntry); + changedCoins.set(coin.coinPub, coin); + } + for (const availability of changedAvailabilities.values()) { + await tx.upsertCoinAvailability(availability); + } + for (const history of changedHistories.values()) { + await tx.upsertCoinHistory(history); + } + for (const coin of changedCoins.values()) { await tx.upsertCoin(coin); } } @@ -1804,13 +1933,9 @@ export async function createRefreshGroup( originatingTransactionId: string | undefined, ): Promise<CreateRefreshGroupResult> { const exchanges: Set<string> = new Set(); - - for (const x of oldCoinPubs) { - const rec = await tx.getCoin(x.coinPub); - if (!rec) { - continue; - } - exchanges.add(rec.exchangeBaseUrl); + const loadedCoins = await loadRefreshCoins(wex, tx, oldCoinPubs); + for (const { coin } of loadedCoins) { + exchanges.add(coin.exchangeBaseUrl); } for (const exch of exchanges) { @@ -1819,7 +1944,7 @@ export async function createRefreshGroup( const refreshGroupId = encodeCrock(getRandomBytes(32)); - const outInfo = await calculateRefreshOutput(wex, tx, currency, oldCoinPubs); + const outInfo = await calculateRefreshOutputFromLoaded(wex, tx, loadedCoins); const estimatedOutputPerCoin = outInfo.outputPerCoin; @@ -1829,7 +1954,7 @@ export async function createRefreshGroup( ); } - await applyRefreshToOldCoins(wex, tx, oldCoinPubs, refreshGroupId); + await applyRefreshToOldCoins(tx, loadedCoins, refreshGroupId); const refundRequests: { [n: number]: ExchangeRefundRequest } = {}; @@ -2051,12 +2176,17 @@ export async function forceRefresh( } const res = await wex.runWalletDbTx(async (tx) => { const coinPubs: CoinRefreshRequest[] = []; + const coins = await tx.getCoinsByPubs( + req.refreshCoinSpecs.map((spec) => spec.coinPub), + ); + const coinsByPub = new Map(coins.map((coin) => [coin.coinPub, coin])); + const denoms = await getDenomInfos(wex, tx, coins); for (const c of req.refreshCoinSpecs) { - const coin = await tx.getCoin(c.coinPub); + const coin = coinsByPub.get(c.coinPub); if (!coin) { throw Error(`coin (pubkey ${c}) not found`); } - const denom = await getDenomInfo(wex, tx, coin); + const denom = denoms.get(denomRefKey(coin)); checkDbInvariant(!!denom, `no denom hash: ${coin.denomPubHash}`); coinPubs.push({ coinPub: c.coinPub, diff --git a/packages/taler-wallet-core/src/requests.ts b/packages/taler-wallet-core/src/requests.ts @@ -445,7 +445,7 @@ import { import { WalletExecutionContext, applyRunConfigDefaults, - getDenomInfo, + denomRefKey, migrateMaterializedTransactions, walletExchangeClient, } from "./wallet.js"; @@ -572,8 +572,18 @@ async function dumpCoins(wex: WalletExecutionContext): Promise<CoinDumpJson> { logger.info("dumping coins"); await wex.runWalletDbTx(async (tx) => { const coins = await tx.listAllCoins(); + const denominations = await tx.getDenominationsByRefs(coins); + const denominationsByRef = new Map( + denominations.map((denom) => [denomRefKey(denom), denom]), + ); + const histories = await tx.getCoinHistoriesByPubs( + coins.map((coin) => coin.coinPub), + ); + const historiesByPub = new Map( + histories.map((history) => [history.coinPub, history]), + ); for (const c of coins) { - const denom = await tx.getDenomination(c); + const denom = denominationsByRef.get(denomRefKey(c)); if (!denom) { logger.warn("no denom found for coin"); continue; @@ -587,12 +597,7 @@ async function dumpCoins(wex: WalletExecutionContext): Promise<CoinDumpJson> { if (cs.type == CoinSourceType.Withdraw) { withdrawalReservePub = cs.reservePub; } - const denomInfo = await getDenomInfo(wex, tx, c); - if (!denomInfo) { - logger.warn("no denomination found for coin"); - continue; - } - const historyRec = await tx.getCoinHistory(c.coinPub); + const historyRec = historiesByPub.get(c.coinPub); coinsJson.coins.push({ coinPub: c.coinPub, denomPub: denom.denomPub, diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -74,7 +74,11 @@ import { ConfigRecordKey, WalletDenomination } from "./db-common.js"; import { IdbWalletDbHandle } from "./dbtx-handle-impl.js"; import { WalletDbHandle } from "./dbtx-handle.js"; import { watchForCacheInvalidation } from "./dbtx-shared.js"; -import { WalletDbTransaction, WalletDenomRef } from "./dbtx.js"; +import { + WalletCoinAvailabilityRef, + WalletDbTransaction, + WalletDenomRef, +} from "./dbtx.js"; import { UnverifiedDenomError } from "./denomSelection.js"; import { DevExperimentHttpLib, DevExperimentState } from "./dev-experiments.js"; import { @@ -337,7 +341,7 @@ export async function getDenomInfo( tx: WalletDbTransaction, ref: WalletDenomRef, ): Promise<DenominationInfo | undefined> { - const key = `${ref.exchangeMasterPub}:${ref.denomPubHash}`; + const key = denomRefKey(ref); return wex.ws.denomInfoCache.getOrPut(key, async () => { const d = await tx.getDenomination(ref); if (d != null) { @@ -348,6 +352,51 @@ export async function getDenomInfo( }); } +/** Stable in-memory key for denomination references. */ +export function denomRefKey(ref: WalletDenomRef): string { + return `${ref.exchangeMasterPub}:${ref.denomPubHash}`; +} + +/** Stable in-memory key for coin-availability references. */ +export function coinAvailabilityRefKey(ref: WalletCoinAvailabilityRef): string { + return `${denomRefKey(ref)}:${ref.maxAge}`; +} + +/** + * Load denomination information for many references with one backend batch. + * + * Cached entries are reused, missing records are omitted, and each unique + * reference appears at most once in the returned map. + */ +export async function getDenomInfos( + wex: WalletExecutionContext, + tx: WalletDbTransaction, + refs: WalletDenomRef[], +): Promise<Map<string, DenominationInfo>> { + const result = new Map<string, DenominationInfo>(); + const missing = new Map<string, WalletDenomRef>(); + for (const ref of refs) { + const key = denomRefKey(ref); + const cached = wex.ws.denomInfoCache.get(key); + if (cached) { + result.set(key, cached); + } else if (!missing.has(key)) { + missing.set(key, ref); + } + } + if (missing.size === 0) { + return result; + } + const records = await tx.getDenominationsByRefs([...missing.values()]); + for (const record of records) { + const key = denomRefKey(record); + const info = WalletDenomination.toDenomInfo(record); + wex.ws.denomInfoCache.put(key, info); + result.set(key, info); + } + return result; +} + /** * Get an API client from an internal wallet state object. */ diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -198,6 +198,7 @@ import { } from "./transactions.js"; import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions.js"; import { + denomRefKey, WalletExecutionContext, getDenomInfo, walletBankIntegrationClient, @@ -2474,12 +2475,28 @@ async function redenominateWithdrawal( let prevEarliestDepositExpiration = AbsoluteTime.never(); const prevDenoms: DenomSelItem[] = []; let coinIndex = 0; + const exchangeMasterPub = await getWithdrawalMasterPub(tx, exchangeBaseUrl); + const denominations = await tx.getDenominationsByRefs( + oldSel.selectedDenoms.map((selection) => ({ + exchangeMasterPub, + denomPubHash: selection.denomPubHash, + })), + ); + const denominationsByRef = new Map( + denominations.map((denom) => [denomRefKey(denom), denom]), + ); + const planchets = await tx.getPlanchetsByGroup(withdrawalGroupId); + const planchetsByIndex = new Map( + planchets.map((planchet) => [planchet.coinIdx, planchet]), + ); for (let i = 0; i < oldSel.selectedDenoms.length; i++) { const sel = wg.denomsSel.selectedDenoms[i]; - const denom = await tx.getDenomination({ - exchangeMasterPub: await getWithdrawalMasterPub(tx, exchangeBaseUrl), - denomPubHash: sel.denomPubHash, - }); + const denom = denominationsByRef.get( + denomRefKey({ + exchangeMasterPub, + denomPubHash: sel.denomPubHash, + }), + ); let denomOkay: boolean = false; @@ -2522,7 +2539,7 @@ async function redenominateWithdrawal( for (let j = 0; j < sel.count; j++) { const ci = coinIndex + j; - const p = await tx.getPlanchetByGroupAndIndex(withdrawalGroupId, ci); + const p = planchetsByIndex.get(ci); if (!p) { // Maybe planchet wasn't yet generated. // No problem!