taler-typescript-core

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

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:
Apackages/taler-wallet-core/src/coinAvailability.test.ts | 276+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/db/handle.ts | 2+-
Mpackages/taler-wallet-core/src/db/indexeddb/fixups.ts | 5+++--
Mpackages/taler-wallet-core/src/db/records.ts | 2++
Mpackages/taler-wallet-core/src/wallet.ts | 168++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
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,