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