commit c4e3867e3a89c537eac2fdafa3d51ed0013eab2b
parent 35d065bd14a2e4692ccf3d17fcaa769a1e116211
Author: Florian Dold <dold@taler.net>
Date: Thu, 27 Aug 2026 23:22:02 +0200
wallet-core: add versioned coin availability rematerialization
Diffstat:
5 files changed, 449 insertions(+), 4 deletions(-)
diff --git a/packages/taler-wallet-core/src/coinAvailability.test.ts b/packages/taler-wallet-core/src/coinAvailability.test.ts
@@ -0,0 +1,276 @@
+/*
+ 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.
+ */
+
+import assert from "node:assert";
+import { test } from "node:test";
+
+import { CoinStatus } from "@gnu-taler/taler-util";
+import { makeCoinsVisible } from "./common.js";
+import {
+ ConfigRecord,
+ ConfigRecordKey,
+ RefreshCoinStatus,
+ WalletCoin,
+ WalletCoinAvailability,
+ WalletDenomination,
+} from "./db/records.js";
+import { WalletDbTransaction } from "./db/transaction.js";
+import {
+ WalletExecutionContext,
+ migrateCoinAvailability,
+ recomputeCoinAvailability,
+} from "./wallet.js";
+
+function makeCoin(
+ coinPub: string,
+ status: CoinStatus,
+ visible?: number,
+): WalletCoin {
+ return {
+ coinPub,
+ status,
+ visible,
+ denomPubHash: "denom-pub-hash",
+ exchangeMasterPub: "exchange-master-pub",
+ maxAge: 0,
+ sourceTransactionId: "withdrawal:source",
+ } as WalletCoin;
+}
+
+function makeAvailability(): WalletCoinAvailability {
+ return {
+ denomPubHash: "denom-pub-hash",
+ exchangeMasterPub: "exchange-master-pub",
+ maxAge: 0,
+ freshCoinCount: 99,
+ visibleCoinCount: 99,
+ hasFreshCoins: 1,
+ pendingRefreshOutputCount: 99,
+ } as WalletCoinAvailability;
+}
+
+test("coin availability counters are rebuilt from coins and refreshes", async () => {
+ const availability = makeAvailability();
+ const oldCoin = makeCoin("refresh-input", CoinStatus.Dormant, 1);
+ const coins = [
+ makeCoin("fresh-visible", CoinStatus.Fresh, 1),
+ makeCoin("fresh-hidden", CoinStatus.Fresh),
+ makeCoin("dormant-visible", CoinStatus.Dormant, 1),
+ makeCoin("suspended-visible", CoinStatus.FreshSuspended, 1),
+ oldCoin,
+ ];
+ let writes = 0;
+ const tx = {
+ async getCoinAvailabilities() {
+ return [availability];
+ },
+ async listAllCoins() {
+ return coins;
+ },
+ async listAllRefreshGroups() {
+ return [
+ {
+ refreshGroupId: "refresh-group",
+ oldCoinPubs: [oldCoin.coinPub],
+ statusPerCoin: [RefreshCoinStatus.Pending],
+ },
+ {
+ refreshGroupId: "finished-refresh-group",
+ oldCoinPubs: [oldCoin.coinPub],
+ statusPerCoin: [RefreshCoinStatus.Finished],
+ },
+ {
+ refreshGroupId: "failed-refresh-group",
+ oldCoinPubs: [oldCoin.coinPub],
+ statusPerCoin: [RefreshCoinStatus.Failed],
+ },
+ {
+ refreshGroupId: "redenominate-refresh-group",
+ oldCoinPubs: [oldCoin.coinPub],
+ statusPerCoin: [RefreshCoinStatus.PendingRedenominate],
+ },
+ ];
+ },
+ async listAllRefreshSessions() {
+ return [
+ {
+ refreshGroupId: "refresh-group",
+ coinIndex: 0,
+ newDenoms: [{ denomPubHash: availability.denomPubHash, count: 3 }],
+ },
+ {
+ refreshGroupId: "finished-refresh-group",
+ coinIndex: 0,
+ newDenoms: [{ denomPubHash: availability.denomPubHash, count: 7 }],
+ },
+ {
+ refreshGroupId: "failed-refresh-group",
+ coinIndex: 0,
+ newDenoms: [{ denomPubHash: availability.denomPubHash, count: 11 }],
+ },
+ {
+ refreshGroupId: "redenominate-refresh-group",
+ coinIndex: 0,
+ newDenoms: [{ denomPubHash: availability.denomPubHash, count: 2 }],
+ },
+ ];
+ },
+ async upsertCoinAvailability() {
+ writes++;
+ },
+ } as unknown as WalletDbTransaction;
+
+ await recomputeCoinAvailability(tx);
+
+ assert.strictEqual(availability.freshCoinCount, 2);
+ assert.strictEqual(availability.visibleCoinCount, 1);
+ assert.strictEqual(availability.hasFreshCoins, 1);
+ assert.strictEqual(availability.pendingRefreshOutputCount, 5);
+ assert.strictEqual(writes, 1);
+});
+
+test("missing coin availability rows are recreated from denominations", async () => {
+ const freshCoin = makeCoin("fresh-visible", CoinStatus.Fresh, 1);
+ const oldCoin = {
+ ...makeCoin("refresh-input", CoinStatus.Dormant),
+ denomPubHash: "old-denom",
+ };
+ const written: WalletCoinAvailability[] = [];
+ const tx = {
+ async getCoinAvailabilities() {
+ return [];
+ },
+ async listAllCoins() {
+ return [freshCoin, oldCoin];
+ },
+ async getDenomination(ref: { denomPubHash: string }) {
+ return {
+ denomPubHash: ref.denomPubHash,
+ exchangeMasterPub: freshCoin.exchangeMasterPub,
+ exchangeBaseUrl: "https://exchange.example/",
+ currency: "TESTKUDOS",
+ value: "TESTKUDOS:1",
+ } as WalletDenomination;
+ },
+ async listAllRefreshGroups() {
+ return [
+ {
+ refreshGroupId: "refresh-group",
+ oldCoinPubs: [oldCoin.coinPub],
+ statusPerCoin: [RefreshCoinStatus.Pending],
+ },
+ ];
+ },
+ async listAllRefreshSessions() {
+ return [
+ {
+ refreshGroupId: "refresh-group",
+ coinIndex: 0,
+ newDenoms: [{ denomPubHash: "refresh-denom", count: 2 }],
+ },
+ ];
+ },
+ async upsertCoinAvailability(availability: WalletCoinAvailability) {
+ written.push(availability);
+ },
+ } as unknown as WalletDbTransaction;
+
+ await recomputeCoinAvailability(tx);
+
+ assert.deepStrictEqual(
+ written.map((availability) => ({
+ denomPubHash: availability.denomPubHash,
+ freshCoinCount: availability.freshCoinCount,
+ visibleCoinCount: availability.visibleCoinCount,
+ pendingRefreshOutputCount: availability.pendingRefreshOutputCount,
+ })),
+ [
+ {
+ denomPubHash: freshCoin.denomPubHash,
+ freshCoinCount: 1,
+ visibleCoinCount: 1,
+ pendingRefreshOutputCount: 0,
+ },
+ {
+ denomPubHash: "refresh-denom",
+ freshCoinCount: 0,
+ visibleCoinCount: 0,
+ pendingRefreshOutputCount: 2,
+ },
+ ],
+ );
+});
+
+test("coin availability is rebuilt once for each materialization version", async () => {
+ let version: ConfigRecord | undefined;
+ let rebuilds = 0;
+ const tx = {
+ async getConfig() {
+ return version;
+ },
+ async upsertConfig(record: ConfigRecord) {
+ version = record;
+ },
+ async getCoinAvailabilities() {
+ rebuilds++;
+ return [];
+ },
+ async listAllCoins() {
+ return [];
+ },
+ async listAllRefreshGroups() {
+ return [];
+ },
+ async listAllRefreshSessions() {
+ return [];
+ },
+ } as unknown as WalletDbTransaction;
+ const wex = {
+ async runWalletDbTx<T>(f: (tx: WalletDbTransaction) => Promise<T>) {
+ return f(tx);
+ },
+ } as WalletExecutionContext;
+
+ await migrateCoinAvailability(wex);
+ await migrateCoinAvailability(wex);
+
+ assert.strictEqual(rebuilds, 1);
+ assert.strictEqual(version?.key, ConfigRecordKey.CoinAvailabilityVersion);
+});
+
+test("source finalization does not make spent coins available again", async () => {
+ const availability = makeAvailability();
+ availability.freshCoinCount = 1;
+ availability.visibleCoinCount = 0;
+ const fresh = makeCoin("fresh", CoinStatus.Fresh);
+ const spent = makeCoin("spent", CoinStatus.Dormant);
+ const coins = [fresh, spent];
+ let availabilityWrites = 0;
+ const tx = {
+ async getCoinsBySourceTransaction() {
+ return coins;
+ },
+ async upsertCoin() {},
+ async getCoinAvailability() {
+ return availability;
+ },
+ async upsertCoinAvailability() {
+ availabilityWrites++;
+ },
+ } as unknown as WalletDbTransaction;
+ const wex = {} as WalletExecutionContext;
+
+ await makeCoinsVisible(wex, tx, "withdrawal:source");
+
+ assert.strictEqual(fresh.visible, 1);
+ assert.strictEqual(spent.visible, 1);
+ assert.strictEqual(availability.freshCoinCount, 1);
+ assert.strictEqual(availability.visibleCoinCount, 1);
+ assert.strictEqual(availabilityWrites, 1);
+});
diff --git a/packages/taler-wallet-core/src/db/handle.ts b/packages/taler-wallet-core/src/db/handle.ts
@@ -48,7 +48,7 @@ export interface WalletDbMigrationOptions {
/**
* Wallet-level work that must become visible in the same atomic import as the
- * restored records (currently rebuilding the materialized transaction view).
+ * restored records, such as rebuilding materialized views and counters.
*/
export type WalletDbImportFinalizer = (
tx: WalletDbTransaction,
diff --git a/packages/taler-wallet-core/src/db/indexeddb/fixups.ts b/packages/taler-wallet-core/src/db/indexeddb/fixups.ts
@@ -877,11 +877,12 @@ export async function applyFixups(
totalSteps: WALLET_DB_MAINTENANCE_TOTAL_STEPS,
});
await fixupInstruction.fn(tx);
- // A fixup may change any operation record from which transactionsMeta
- // is derived. Invalidate the durable view version in the same commit
+ // A fixup may change records behind materialized transactions or coin
+ // availability. Invalidate both durable versions in the same commit
// as the repair, so a crash or failed rematerialization is retried on
// the next initialization instead of leaving a stale "current" flag.
await tx.config.delete(ConfigRecordKey.MaterializedTransactionsVersion);
+ await tx.config.delete(ConfigRecordKey.CoinAvailabilityVersion);
await tx.fixups.put({
fixupName: fixupInstruction.name,
});
diff --git a/packages/taler-wallet-core/src/db/records.ts b/packages/taler-wallet-core/src/db/records.ts
@@ -2044,6 +2044,7 @@ export enum ConfigRecordKey {
LastInitInfo = "lastInitInfo",
LastResumed = "lastResumed",
MaterializedTransactionsVersion = "materializedTransactionsVersion",
+ CoinAvailabilityVersion = "coinAvailabilityVersion",
DonauConfig = "donauConfig",
}
@@ -2092,6 +2093,7 @@ export type ConfigRecord =
| { key: ConfigRecordKey.LastInitInfo; value: DbProtocolTimestamp }
| { key: ConfigRecordKey.LastResumed; value: DbProtocolTimestamp }
| { key: ConfigRecordKey.MaterializedTransactionsVersion; value: number }
+ | { key: ConfigRecordKey.CoinAvailabilityVersion; value: number }
| { key: ConfigRecordKey.DonauConfig; value: DonauConfig };
export enum RecoupOperationStatus {
diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts
@@ -28,6 +28,7 @@ import {
AsyncCondition,
Cache,
CancellationToken,
+ CoinStatus,
CoinSelectionAlgorithm,
CoinDepositPermission,
CoreApiResponse,
@@ -72,7 +73,12 @@ import {
CryptoDispatcher,
CryptoWorkerFactory,
} from "./crypto/workers/crypto-dispatcher.js";
-import { ConfigRecordKey, WalletDenomination } from "./db/records.js";
+import {
+ ConfigRecordKey,
+ RefreshCoinStatus,
+ WalletCoinAvailability,
+ WalletDenomination,
+} from "./db/records.js";
import { IdbWalletDbHandle } from "./db/indexeddb/handle.js";
import {
WALLET_DB_MAINTENANCE_TOTAL_STEPS,
@@ -335,6 +341,166 @@ type CancelFn = () => void;
*/
const MATERIALIZED_TRANSACTIONS_VERSION = 4;
+/**
+ * Incremented each time coin-availability counters need to be rebuilt from
+ * their authoritative records.
+ */
+const COIN_AVAILABILITY_VERSION = 1;
+
+async function getOrCreateCoinAvailabilityForRematerialization(
+ tx: WalletDbTransaction,
+ availabilities: WalletCoinAvailability[],
+ byRef: Map<string, WalletCoinAvailability>,
+ ref: WalletCoinAvailabilityRef,
+): Promise<WalletCoinAvailability> {
+ const key = coinAvailabilityRefKey(ref);
+ const existing = byRef.get(key);
+ if (existing) {
+ return existing;
+ }
+ const denom = await tx.getDenomination(ref);
+ if (!denom) {
+ throw Error(
+ `denomination ${ref.denomPubHash} is missing while rebuilding` +
+ ` coin availability for age ${ref.maxAge}`,
+ );
+ }
+ const availability: WalletCoinAvailability = {
+ maxAge: ref.maxAge,
+ value: denom.value,
+ currency: denom.currency,
+ denomPubHash: denom.denomPubHash,
+ exchangeBaseUrl: denom.exchangeBaseUrl,
+ exchangeMasterPub: denom.exchangeMasterPub,
+ freshCoinCount: 0,
+ hasFreshCoins: 0,
+ visibleCoinCount: 0,
+ pendingRefreshOutputCount: 0,
+ };
+ availabilities.push(availability);
+ byRef.set(key, availability);
+ return availability;
+}
+
+/**
+ * Recompute the derived coin-availability counters from coins and unfinished
+ * refresh sessions.
+ */
+export async function recomputeCoinAvailability(
+ tx: WalletDbTransaction,
+): Promise<void> {
+ const availabilities = await tx.getCoinAvailabilities();
+ const byRef = new Map(
+ availabilities.map((availability) => [
+ coinAvailabilityRefKey(availability),
+ availability,
+ ]),
+ );
+
+ for (const availability of availabilities) {
+ availability.freshCoinCount = 0;
+ availability.visibleCoinCount = 0;
+ availability.hasFreshCoins = 0;
+ availability.pendingRefreshOutputCount = 0;
+ }
+
+ const coins = await tx.listAllCoins();
+ const coinsByPub = new Map(coins.map((coin) => [coin.coinPub, coin]));
+ for (const coin of coins) {
+ if (coin.status !== CoinStatus.Fresh) {
+ continue;
+ }
+ const availability =
+ await getOrCreateCoinAvailabilityForRematerialization(
+ tx,
+ availabilities,
+ byRef,
+ coin,
+ );
+ availability.freshCoinCount++;
+ if (coin.visible) {
+ availability.visibleCoinCount++;
+ }
+ }
+
+ const refreshGroups = new Map(
+ (await tx.listAllRefreshGroups()).map((group) => [
+ group.refreshGroupId,
+ group,
+ ]),
+ );
+ for (const session of await tx.listAllRefreshSessions()) {
+ const group = refreshGroups.get(session.refreshGroupId);
+ if (!group) {
+ continue;
+ }
+ const coinStatus = group.statusPerCoin[session.coinIndex];
+ if (
+ coinStatus !== RefreshCoinStatus.Pending &&
+ coinStatus !== RefreshCoinStatus.PendingRedenominate
+ ) {
+ continue;
+ }
+ const oldCoinPub = group.oldCoinPubs[session.coinIndex];
+ const oldCoin = coinsByPub.get(oldCoinPub);
+ if (!oldCoin) {
+ throw Error(
+ `old coin ${oldCoinPub} missing for refresh ${session.refreshGroupId}`,
+ );
+ }
+ for (const output of session.newDenoms) {
+ const availability =
+ await getOrCreateCoinAvailabilityForRematerialization(
+ tx,
+ availabilities,
+ byRef,
+ {
+ exchangeMasterPub: oldCoin.exchangeMasterPub,
+ denomPubHash: output.denomPubHash,
+ maxAge: oldCoin.maxAge,
+ },
+ );
+ availability.pendingRefreshOutputCount =
+ (availability.pendingRefreshOutputCount ?? 0) + output.count;
+ }
+ }
+
+ for (const availability of availabilities) {
+ availability.hasFreshCoins = availability.freshCoinCount > 0 ? 1 : 0;
+ await tx.upsertCoinAvailability(availability);
+ }
+}
+
+/** Rebuild coin availability and durably mark this exact derivation version. */
+export async function recomputeCoinAvailabilityAtCurrentVersion(
+ tx: WalletDbTransaction,
+): Promise<void> {
+ await recomputeCoinAvailability(tx);
+ await tx.upsertConfig({
+ key: ConfigRecordKey.CoinAvailabilityVersion,
+ value: COIN_AVAILABILITY_VERSION,
+ });
+}
+
+export async function migrateCoinAvailability(
+ wex: WalletExecutionContext,
+): Promise<void> {
+ await wex.runWalletDbTx(async (tx) => {
+ const ver = await tx.getConfig(ConfigRecordKey.CoinAvailabilityVersion);
+ if (ver) {
+ if (ver.value === COIN_AVAILABILITY_VERSION) {
+ return;
+ }
+ if (ver.value > COIN_AVAILABILITY_VERSION) {
+ logger.error("database is newer than code (coinAvailabilityVersion)");
+ return;
+ }
+ }
+
+ await recomputeCoinAvailabilityAtCurrentVersion(tx);
+ });
+}
+
/** Rebuild the transaction view and durably mark this exact view version. */
export async function rematerializeTransactionsAtCurrentVersion(
wex: WalletExecutionContext,