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:
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[]>;