taler-typescript-core

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

commit dc9a66639823efc07df5577cf79afc6b9520fd31
parent 9ebb76056c33bdedfe879faf0cbe3cb198ef2f3c
Author: Florian Dold <dold@taler.net>
Date:   Thu, 27 Aug 2026 23:49:49 +0200

wallet-core: index visible coins and batch balance lookups

Diffstat:
Mpackages/taler-wallet-core/src/balance.test.ts | 200++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/balance.ts | 216++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mpackages/taler-wallet-core/src/db/indexeddb/schema.ts | 7++++++-
Mpackages/taler-wallet-core/src/db/indexeddb/transaction.ts | 6++++++
Mpackages/taler-wallet-core/src/db/sqlite/schema-migrations.test.ts | 32++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/db/sqlite/schema.ts | 9++++++++-
Mpackages/taler-wallet-core/src/db/sqlite/transaction.ts | 7+++++++
Mpackages/taler-wallet-core/src/db/testing/benchmark.ts | 23++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/db/testing/conformance-cases.ts | 28++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/db/transaction.ts | 8++++++++
10 files changed, 486 insertions(+), 50 deletions(-)

diff --git a/packages/taler-wallet-core/src/balance.test.ts b/packages/taler-wallet-core/src/balance.test.ts @@ -24,7 +24,9 @@ import { ExchangeEntryDbRecordStatus, ExchangeEntryDbUpdateStatus, DepositOperationStatus, + PurchaseStatus, RefreshOperationStatus, + WalletCoinAvailability, WalletExchangeDetails, WalletExchangeEntry, WalletRefreshGroup, @@ -59,7 +61,8 @@ function makeBalanceContext( exchangeBaseUrl: x.baseUrl, currency: "TESTKUDOS", masterPublicKey: `master-pub-${x.baseUrl}`, - } as WalletExchangeDetails, + auditors: [], + } as unknown as WalletExchangeDetails, ]), ); const tx = { @@ -69,13 +72,19 @@ function makeBalanceContext( async getExchanges() { return exchanges; }, + async listGlobalCurrencyExchanges() { + return []; + }, + async listGlobalCurrencyAuditors() { + return []; + }, async getExchangeDetails(baseUrl: string) { return detailsByUrl.get(baseUrl); }, async getExchangeScopeInfo(baseUrl: string, currency: string) { return { type: ScopeType.Exchange, currency, url: baseUrl }; }, - async getCoinAvailabilities() { + async getVisibleCoinAvailabilities() { return []; }, async getDenominationsByRefs() { @@ -112,6 +121,25 @@ function makeBalanceContext( return { wex, tx }; } +function makeAvailability( + exchangeBaseUrl: string, + exchangeMasterPub: string, + denomPubHash: string, + visibleCoinCount: number, +): WalletCoinAvailability { + return { + exchangeBaseUrl, + exchangeMasterPub, + denomPubHash, + maxAge: 0, + currency: "TESTKUDOS", + value: "TESTKUDOS:1", + freshCoinCount: visibleCoinCount, + hasFreshCoins: visibleCoinCount > 0 ? 1 : 0, + visibleCoinCount, + }; +} + test("haveProdBalance classifies demo, test, and production exchanges", async () => { const cases: Array<{ name: string; @@ -165,6 +193,123 @@ test("haveProdBalance classifies demo, test, and production exchanges", async () } }); +test("zero current global balance remains visible without availability rows", async () => { + const exchangeBaseUrl = "https://zero-global.example/"; + const exchange = makeExchange(exchangeBaseUrl); + const { wex, tx } = makeBalanceContext([exchange]); + tx.listGlobalCurrencyExchanges = async () => [ + { + currency: "TESTKUDOS", + exchangeBaseUrl, + exchangeMasterPub: `master-pub-${exchangeBaseUrl}`, + }, + ]; + + const result = await getBalancesInsideTransaction(wex, tx); + assert.strictEqual(result.balances.length, 1); + assert.deepStrictEqual(result.balances[0].scopeInfo, { + type: ScopeType.Global, + currency: "TESTKUDOS", + }); + assert.strictEqual(result.balances[0].available, "TESTKUDOS:0"); +}); + +test("balance scope inputs are loaded once per exchange", async () => { + const exchangeBaseUrl = "https://cached-scope.example/"; + const exchange = makeExchange(exchangeBaseUrl); + const { wex, tx } = makeBalanceContext([exchange]); + const masterPub = `master-pub-${exchangeBaseUrl}`; + let detailLoads = 0; + let globalExchangeLoads = 0; + let globalAuditorLoads = 0; + const getExchangeDetails = tx.getExchangeDetails.bind(tx); + tx.getExchangeDetails = async (baseUrl: string) => { + detailLoads++; + return await getExchangeDetails(baseUrl); + }; + tx.listGlobalCurrencyExchanges = async () => { + globalExchangeLoads++; + return []; + }; + tx.listGlobalCurrencyAuditors = async () => { + globalAuditorLoads++; + return []; + }; + tx.getVisibleCoinAvailabilities = async () => [ + makeAvailability(exchangeBaseUrl, masterPub, "denom-a", 1), + makeAvailability(exchangeBaseUrl, masterPub, "denom-b", 2), + ]; + tx.getExchangeScopeInfo = async () => { + throw Error("per-row scope lookup must not be used"); + }; + tx.getDenominationsByRefs = async () => { + throw Error("getBalances must not hydrate denominations"); + }; + + const result = await getBalancesInsideTransaction(wex, tx); + assert.strictEqual(result.balances[0].available, "TESTKUDOS:3"); + assert.strictEqual(detailLoads, 1); + assert.strictEqual(globalExchangeLoads, 1); + assert.strictEqual(globalAuditorLoads, 1); +}); + +test("visible availability retains auditor and legacy-key scopes", async () => { + const exchangeBaseUrl = "https://scoped.example/"; + const auditorBaseUrl = "https://auditor.example/"; + const currentMasterPub = "current-master"; + const auditorPub = "auditor-pub"; + const { wex, tx } = makeBalanceContext([makeExchange(exchangeBaseUrl)]); + tx.getExchangeDetails = async () => + ({ + exchangeBaseUrl, + currency: "TESTKUDOS", + masterPublicKey: currentMasterPub, + auditors: [ + { + auditor_url: auditorBaseUrl, + auditor_pub: auditorPub, + auditor_name: "Auditor", + denomination_keys: [ + { denom_pub_h: "audited-denom", auditor_sig: "auditor-sig" }, + ], + walletAuditorSignaturesVerified: true, + }, + ], + }) as WalletExchangeDetails; + tx.listGlobalCurrencyAuditors = async () => [ + { + currency: "TESTKUDOS", + auditorBaseUrl, + auditorPub, + }, + ]; + tx.getVisibleCoinAvailabilities = async () => [ + makeAvailability(exchangeBaseUrl, currentMasterPub, "audited-denom", 2), + makeAvailability(exchangeBaseUrl, currentMasterPub, "other-denom", 1), + makeAvailability(exchangeBaseUrl, "old-master", "legacy-denom", 4), + ]; + + const result = await getBalancesInsideTransaction(wex, tx); + const auditor = result.balances.find( + (balance) => balance.scopeInfo.type === ScopeType.Auditor, + ); + const exchange = result.balances.find( + (balance) => balance.scopeInfo.type === ScopeType.Exchange, + ); + const legacy = result.balances.find( + (balance) => balance.scopeInfo.type === ScopeType.ExchangeLegacyKeys, + ); + assert.strictEqual(auditor?.available, "TESTKUDOS:2"); + assert.strictEqual(exchange?.available, "TESTKUDOS:1"); + assert.strictEqual(legacy?.available, "TESTKUDOS:4"); + assert.strictEqual( + legacy?.scopeInfo.type === ScopeType.ExchangeLegacyKeys + ? legacy.scopeInfo.masterPub + : undefined, + "old-master", + ); +}); + test("pending refresh balance respects the requested sender scope", async () => { const tx = { async getCoinAvailabilities() { @@ -309,3 +454,54 @@ test("active deposit amounts are counted once per exchange", async () => { assert.strictEqual(byUrl.get(exchangeA)?.pendingOutgoing, "TESTKUDOS:2"); assert.strictEqual(byUrl.get(exchangeB)?.pendingOutgoing, "TESTKUDOS:3"); }); + +test("active purchases share one deduplicated coin lookup", async () => { + const exchangeA = "https://purchase-a.example/"; + const exchangeB = "https://purchase-b.example/"; + const { wex, tx } = makeBalanceContext([ + makeExchange(exchangeA), + makeExchange(exchangeB), + ]); + tx.getActivePurchases = async () => + [ + { + purchaseStatus: PurchaseStatus.PendingPaying, + payInfo: { + totalPayCost: "TESTKUDOS:3", + payCoinSelection: { + coinPubs: ["coin-a", "coin-b"], + coinContributions: ["TESTKUDOS:1", "TESTKUDOS:2"], + }, + }, + }, + { + purchaseStatus: PurchaseStatus.SuspendedPaying, + payInfo: { + totalPayCost: "TESTKUDOS:3", + payCoinSelection: { + coinPubs: ["coin-a"], + coinContributions: ["TESTKUDOS:3"], + }, + }, + }, + ] as any; + const coinLookupArgs: string[][] = []; + tx.getCoinsByPubs = async (coinPubs: string[]) => { + coinLookupArgs.push(coinPubs); + return [ + { coinPub: "coin-a", exchangeBaseUrl: exchangeA }, + { coinPub: "coin-b", exchangeBaseUrl: exchangeB }, + ] as any; + }; + + const result = await getBalancesInsideTransaction(wex, tx); + assert.deepStrictEqual(coinLookupArgs, [["coin-a", "coin-b"]]); + const byUrl = new Map<string, (typeof result.balances)[number]>(); + for (const balance of result.balances) { + if (balance.scopeInfo.type === ScopeType.Exchange) { + byUrl.set(balance.scopeInfo.url, balance); + } + } + assert.strictEqual(byUrl.get(exchangeA)?.pendingOutgoing, "TESTKUDOS:4"); + assert.strictEqual(byUrl.get(exchangeB)?.pendingOutgoing, "TESTKUDOS:2"); +}); diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts @@ -99,7 +99,10 @@ import { WalletExchangeDetails, } from "./db/records.js"; import { getEffectiveExchangeType } from "./builtin-exchanges.js"; -import { hasVerifiedAuditorTrust } from "./auditorTrust.js"; +import { + auditorProvidesVerifiedTrust, + hasVerifiedAuditorTrust, +} from "./auditorTrust.js"; import { WalletDbTransaction } from "./db/transaction.js"; import { parseExchangeWireAccountPayto } from "./exchange-payto.js"; import { @@ -126,6 +129,142 @@ interface WalletBalance { shoppingUrls: Set<string>; } +function globalExchangeKey( + currency: string, + exchangeBaseUrl: string, + exchangeMasterPub: string, +): string { + return `${currency}\0${exchangeBaseUrl}\0${exchangeMasterPub}`; +} + +function globalAuditorKey( + currency: string, + auditorBaseUrl: string, + auditorPub: string, +): string { + return `${currency}\0${auditorBaseUrl}\0${auditorPub}`; +} + +/** + * Database inputs needed to classify balance contributions by scope. + * + * Auditor membership is denomination-specific, but exchange details and the + * user's global memberships are not. Keeping those inputs here avoids + * resolving them through the DAL once per availability row. + */ +class BalanceScopeResolver { + private exchangeDetails = new Map< + string, + WalletExchangeDetails | undefined + >(); + + private constructor( + private tx: WalletDbTransaction, + private globalExchanges: Set<string>, + private globalAuditors: Set<string>, + ) {} + + static async load(tx: WalletDbTransaction): Promise<BalanceScopeResolver> { + const globalExchangeRecords = await tx.listGlobalCurrencyExchanges(); + const globalAuditorRecords = await tx.listGlobalCurrencyAuditors(); + return new BalanceScopeResolver( + tx, + new Set( + globalExchangeRecords.map((rec) => + globalExchangeKey( + rec.currency, + rec.exchangeBaseUrl, + rec.exchangeMasterPub, + ), + ), + ), + new Set( + globalAuditorRecords.map((rec) => + globalAuditorKey(rec.currency, rec.auditorBaseUrl, rec.auditorPub), + ), + ), + ); + } + + async getExchangeDetails( + exchangeBaseUrl: string, + ): Promise<WalletExchangeDetails | undefined> { + if (!this.exchangeDetails.has(exchangeBaseUrl)) { + this.exchangeDetails.set( + exchangeBaseUrl, + await this.tx.getExchangeDetails(exchangeBaseUrl), + ); + } + return this.exchangeDetails.get(exchangeBaseUrl); + } + + async resolveScope( + exchangeBaseUrl: string, + currency: string, + exchangeMasterPub?: string, + denomPubHash?: string, + ): Promise<ScopeInfo> { + const det = await this.getExchangeDetails(exchangeBaseUrl); + if ( + det && + (det.currency !== currency || + (exchangeMasterPub != null && + det.masterPublicKey !== exchangeMasterPub)) + ) { + return { + type: ScopeType.ExchangeLegacyKeys, + currency, + url: exchangeBaseUrl, + masterPub: exchangeMasterPub ?? det.masterPublicKey, + }; + } + if (!det) { + return { + type: ScopeType.Exchange, + currency, + url: exchangeBaseUrl, + }; + } + if ( + this.globalExchanges.has( + globalExchangeKey( + det.currency, + det.exchangeBaseUrl, + det.masterPublicKey, + ), + ) + ) { + return { + currency: det.currency, + type: ScopeType.Global, + }; + } + if (denomPubHash != null) { + for (const aud of det.auditors) { + if (!auditorProvidesVerifiedTrust(aud, { denomPubHash })) { + continue; + } + if ( + this.globalAuditors.has( + globalAuditorKey(det.currency, aud.auditor_url, aud.auditor_pub), + ) + ) { + return { + currency: det.currency, + type: ScopeType.Auditor, + url: aud.auditor_url, + }; + } + } + } + return { + type: ScopeType.Exchange, + currency: det.currency, + url: det.exchangeBaseUrl, + }; + } +} + async function computeRefreshGroupAvailableAmountForExchanges( tx: WalletDbTransaction, r: WalletRefreshGroup, @@ -198,7 +337,7 @@ class BalancesStore { constructor( private wex: WalletExecutionContext, - private tx: WalletDbTransaction, + private scopeResolver: BalanceScopeResolver, ) {} /** @@ -268,23 +407,10 @@ class BalancesStore { exchangeMasterPub?: string, denomPubHash?: string, ): Promise<ScopeInfo> { - const det = await this.tx.getExchangeDetails(exchangeBaseUrl); - if ( - det && - (det.currency !== currency || - (exchangeMasterPub != null && - det.masterPublicKey !== exchangeMasterPub)) - ) { - return { - type: ScopeType.ExchangeLegacyKeys, - currency, - url: exchangeBaseUrl, - masterPub: exchangeMasterPub ?? det.masterPublicKey, - }; - } - return await this.tx.getExchangeScopeInfo( + return await this.scopeResolver.resolveScope( exchangeBaseUrl, currency, + exchangeMasterPub, denomPubHash, ); } @@ -475,7 +601,8 @@ export async function getBalancesInsideTransaction( wex: WalletExecutionContext, tx: WalletDbTransaction, ): Promise<BalancesResponse> { - const balanceStore: BalancesStore = new BalancesStore(wex, tx); + const scopeResolver = await BalanceScopeResolver.load(tx); + const balanceStore: BalancesStore = new BalancesStore(wex, scopeResolver); let haveProdBalance = false; const donationSummaries = await tx.getDonationSummaries(); @@ -489,7 +616,7 @@ export async function getBalancesInsideTransaction( ex.entryStatus === ExchangeEntryDbRecordStatus.Used || ex.tosAcceptedTimestamp != null ) { - const det = await tx.getExchangeDetails(ex.baseUrl); + const det = await scopeResolver.getExchangeDetails(ex.baseUrl); if (det) { const exchangeType = getEffectiveExchangeType( ex.baseUrl, @@ -519,34 +646,18 @@ export async function getBalancesInsideTransaction( } } - const coinAvailability = await tx.getCoinAvailabilities(); - const denominations = await tx.getDenominationsByRefs(coinAvailability); - const masterPubByDenom = new Map( - denominations.map((denom) => [denomRefKey(denom), denom.exchangeMasterPub]), - ); + const coinAvailability = await tx.getVisibleCoinAvailabilities(); 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 = denomRefKey(ca); - const masterPub = masterPubByDenom.get(denomKey) ?? ca.exchangeMasterPub; - await balanceStore.addZero( + // The availability identity records issuance provenance. In particular, + // an exchange key change keeps the old coins under the key that signed + // them rather than re-attributing them to the current exchange details. + await balanceStore.addAvailable( ca.currency, ca.exchangeBaseUrl, - masterPub, + Amounts.mult(ca.value, ca.visibleCoinCount).amount, + ca.exchangeMasterPub, ca.denomPubHash, ); - if (count > 0) { - await balanceStore.addAvailable( - ca.currency, - ca.exchangeBaseUrl, - Amounts.mult(ca.value, count).amount, - masterPub, - ca.denomPubHash, - ); - } } const refreshGroups = await tx.getActiveRefreshGroups(); @@ -771,6 +882,23 @@ export async function getBalancesInsideTransaction( } const purchases = await tx.getActivePurchases(); + const payingPurchases = purchases.filter( + (rec) => + (rec.purchaseStatus === PurchaseStatus.SuspendedPaying || + rec.purchaseStatus === PurchaseStatus.PendingPaying) && + rec.payInfo?.payCoinSelection?.coinPubs != null, + ); + const selectedCoinPubs = [ + ...new Set( + payingPurchases.flatMap((rec) => rec.payInfo!.payCoinSelection!.coinPubs), + ), + ]; + const selectedCoins = selectedCoinPubs.length + ? await tx.getCoinsByPubs(selectedCoinPubs) + : []; + const selectedCoinsByPub = new Map( + selectedCoins.map((coin) => [coin.coinPub, coin]), + ); for (const rec of purchases) { switch (rec.purchaseStatus) { case PurchaseStatus.SuspendedPaying: @@ -780,11 +908,9 @@ export async function getBalancesInsideTransaction( } const currency = Amounts.currencyOf(rec.payInfo.totalPayCost); const sel = rec.payInfo.payCoinSelection; - const coins = await tx.getCoinsByPubs(sel.coinPubs); - const coinsByPub = new Map(coins.map((c) => [c.coinPub, c])); for (let i = 0; i < sel.coinPubs.length; i++) { const coinPub = sel.coinPubs[i]; - const coinRec = coinsByPub.get(coinPub); + const coinRec = selectedCoinsByPub.get(coinPub); if (!coinRec) { continue; } diff --git a/packages/taler-wallet-core/src/db/indexeddb/schema.ts b/packages/taler-wallet-core/src/db/indexeddb/schema.ts @@ -169,7 +169,7 @@ export const TALER_WALLET_DB_GENERATION_PREFIX = `${TALER_WALLET_MAIN_DB_NAME}-g * backwards-compatible way or object stores and indices * are added. */ -export const WALLET_DB_MINOR_VERSION = 32; +export const WALLET_DB_MINOR_VERSION = 33; // FIXME: Should these be numeric codes? export type KycUserType = "individual" | "business"; @@ -624,6 +624,11 @@ export const WalletIndexedDbStoresV1 = { ["exchangeBaseUrl", "hasFreshCoins", "maxAge"], { versionAdded: 32 }, ), + byVisibleCoinCount: describeIndex( + "byVisibleCoinCount", + "visibleCoinCount", + { versionAdded: 33 }, + ), }, ), // The pre-re-key store. Keeps its map key equal to its store name: the diff --git a/packages/taler-wallet-core/src/db/indexeddb/transaction.ts b/packages/taler-wallet-core/src/db/indexeddb/transaction.ts @@ -1982,6 +1982,12 @@ export class IdbWalletTransaction implements WalletDbTransaction { return await this.tx.coinAvailabilityV2.getAll(); } + async getVisibleCoinAvailabilities(): Promise<WalletCoinAvailability[]> { + return await this.tx.coinAvailabilityV2.indexes.byVisibleCoinCount.getAll( + GlobalIDB.KeyRange.lowerBound(1), + ); + } + async getActiveRefreshGroups(): Promise<WalletRefreshGroup[]> { return await this.tx.refreshGroups.indexes.byStatus.getAll( getActiveKeyRange(), diff --git a/packages/taler-wallet-core/src/db/sqlite/schema-migrations.test.ts b/packages/taler-wallet-core/src/db/sqlite/schema-migrations.test.ts @@ -193,6 +193,38 @@ test("wallet query migration backfills availability and creates indexes", async } }); +test("visible availability migration adds the partial balance index", async () => { + const { path, cleanup } = withTempDb(); + try { + let db = await openRaw(path); + await initSqliteWalletDb( + db, + schemaMigrations.filter((x) => x.version < 11), + ); + let indexes = await queryAll(db, "PRAGMA index_list(coin_availability)"); + assert.ok( + !indexes.some((x) => x.name === "coin_availability_by_visible_count"), + ); + await db.close(); + + db = await openRaw(path); + await initSqliteWalletDb(db); + indexes = await queryAll(db, "PRAGMA index_list(coin_availability)"); + assert.ok( + indexes.some((x) => x.name === "coin_availability_by_visible_count"), + ); + const definitions = await queryAll( + db, + "SELECT sql FROM sqlite_master" + + " WHERE type = 'index' AND name = 'coin_availability_by_visible_count'", + ); + assert.match(String(definitions[0]?.sql), /WHERE visible_coin_count > 0/); + await db.close(); + } finally { + cleanup(); + } +}); + test("peer capability migration deterministically removes legacy duplicates", async () => { const { path, cleanup } = withTempDb(); try { diff --git a/packages/taler-wallet-core/src/db/sqlite/schema.ts b/packages/taler-wallet-core/src/db/sqlite/schema.ts @@ -85,7 +85,7 @@ * * Bump this when adding a migration to {@link schemaMigrations}. */ -export const SQLITE_SCHEMA_VERSION = 10; +export const SQLITE_SCHEMA_VERSION = 11; /** * Tables of the IndexedDB emulation, children before parents. @@ -1396,6 +1396,13 @@ export const schemaMigrations: SchemaMigration[] = [ "CREATE UNIQUE INDEX peer_pull_debit_by_exchange_and_contract_priv ON peer_pull_debit (exchange_base_url, contract_priv)", ], }, + { + version: 11, + name: "visible-coin-availability", + statements: [ + "CREATE INDEX coin_availability_by_visible_count ON coin_availability (visible_coin_count) WHERE visible_coin_count > 0", + ], + }, ]; /** Native tables that contain wallet records (not schema bookkeeping). */ diff --git a/packages/taler-wallet-core/src/db/sqlite/transaction.ts b/packages/taler-wallet-core/src/db/sqlite/transaction.ts @@ -1582,6 +1582,13 @@ export class SqliteWalletTransaction implements WalletDbTransaction { return rows.map((r) => this.rowToCoinAvailability(r)); } + async getVisibleCoinAvailabilities(): Promise<WalletCoinAvailability[]> { + const rows = await this.all( + "SELECT * FROM coin_availability WHERE visible_coin_count > 0", + ); + return rows.map((r) => this.rowToCoinAvailability(r)); + } + async getCoinAvailabilityByExchange( exchangeBaseUrl: string, ): Promise<WalletCoinAvailability[]> { diff --git a/packages/taler-wallet-core/src/db/testing/benchmark.ts b/packages/taler-wallet-core/src/db/testing/benchmark.ts @@ -46,6 +46,8 @@ import { WalletCoinAvailability, WalletDenomination, } from "../records.js"; +import { getBalancesInsideTransaction } from "../../balance.js"; +import { WalletExecutionContext } from "../../wallet.js"; import type { DbTxRunner } from "./conformance.js"; const logger = new Logger("db/testing/benchmark.ts"); @@ -192,7 +194,10 @@ export async function populateDbBench( value: "TESTKUDOS:1" as AmountString, freshCoinCount: 10, hasFreshCoins: 1, - visibleCoinCount: 10, + // Most rows in a long-lived wallet describe denominations whose last + // visible coin has already been spent. Keep that shape in the + // benchmark instead of making every historical row contribute. + visibleCoinCount: d % 10 === 0 ? 10 : 0, }; await tx.upsertCoinAvailability(avail); } @@ -361,6 +366,22 @@ export async function measureDbBenchQueries( ), ); + await time("getVisibleCoinAvailabilities", async () => + runner.runReadWriteTx( + async (tx) => (await tx.getVisibleCoinAvailabilities()).length, + ), + ); + + const balanceWex = { + ws: { devExperimentState: {} }, + } as WalletExecutionContext; + await time("getBalances", async () => + runner.runReadWriteTx( + async (tx) => + (await getBalancesInsideTransaction(balanceWex, tx)).balances.length, + ), + ); + // A write-heavy transaction, to keep an eye on commit cost. await time("upsertCoin x100 (one tx)", async () => runner.runReadWriteTx(async (tx) => { diff --git a/packages/taler-wallet-core/src/db/testing/conformance-cases.ts b/packages/taler-wallet-core/src/db/testing/conformance-cases.ts @@ -2219,6 +2219,34 @@ export const conformanceCases: ConformanceCase[] = [ }, { + name: "coin availability: visible query excludes zero-visible rows", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + const visible = makeAvail("https://visible/", "visible", 0); + visible.freshCoinCount = 0; + visible.visibleCoinCount = 2; + const freshButHidden = makeAvail("https://visible/", "hidden", 0); + freshButHidden.freshCoinCount = 3; + freshButHidden.visibleCoinCount = 0; + const empty = makeAvail("https://visible/", "empty", 0); + empty.freshCoinCount = 0; + empty.visibleCoinCount = 0; + await tx.upsertCoinAvailability(visible); + await tx.upsertCoinAvailability(freshButHidden); + await tx.upsertCoinAvailability(empty); + }); + const got = await runner.runReadWriteTx((tx) => + tx.getVisibleCoinAvailabilities(), + ); + t.deepEqual( + got.map((rec) => rec.denomPubHash), + [ckh("visible")], + "only a positive visible count contributes to getBalances", + ); + }, + }, + + { name: "coin availability: delete targets one (exchange, denom, age)", async run(t, runner) { await runner.runReadWriteTx(async (tx) => { diff --git a/packages/taler-wallet-core/src/db/transaction.ts b/packages/taler-wallet-core/src/db/transaction.ts @@ -1196,6 +1196,14 @@ export interface WalletDbTransaction { /** List all coin availability records. */ getCoinAvailabilities(): Promise<WalletCoinAvailability[]>; + /** + * List coin availability records that contribute to the displayed balance. + * + * Records without visible coins are deliberately excluded at the storage + * layer, so callers do not have to materialize historical zero-count rows. + */ + getVisibleCoinAvailabilities(): Promise<WalletCoinAvailability[]>; + /** List every reserve. */ listAllReserves(): Promise<WalletReserve[]>;