taler-typescript-core

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

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

wallet-core: repair stale coin availability during operations

Diffstat:
Mpackages/taler-wallet-core/src/common.ts | 59+++++++++++++++++++++++++++++++++++++++++++++--------------
Mpackages/taler-wallet-core/src/refresh.ts | 57+++++++++++++++++++++++++++++++++++++++++++--------------
Mpackages/taler-wallet-core/src/requests.test.ts | 31+++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/requests.ts | 22+++++++++++++++++-----
Mpackages/taler-wallet-core/src/wallet.ts | 50++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 186 insertions(+), 33 deletions(-)

diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts @@ -66,6 +66,7 @@ import { ExchangeEntryDbRecordStatus, ExchangeEntryDbUpdateStatus, WalletCoin, + WalletCoinAvailability, WalletCoinHistory, WalletDepositGroup, WalletExchangeEntry, @@ -89,6 +90,7 @@ import { BalanceEffect, applyNotifyTransition } from "./transactions.js"; import { coinAvailabilityRefKey, denomRefKey, + ensureCoinAvailabilityCounters, getDenomInfos, WalletExecutionContext, } from "./wallet.js"; @@ -200,6 +202,44 @@ export async function spendCoins( availability, ]), ); + const requiredByAvailability = new Map< + string, + { + availability: WalletCoinAvailability; + fresh: number; + visible: number; + } + >(); + for (const coinPub of csi.coinPubs) { + const coin = coinsByPub.get(coinPub); + if (!coin) { + throw Error("coin allocated for payment doesn't exist anymore"); + } + const key = coinAvailabilityRefKey(coin); + const availability = availabilityByRef.get(key); + checkDbInvariant( + !!availability, + `age denom info is missing for ${coin.maxAge}`, + ); + const required = requiredByAvailability.get(key) ?? { + availability, + fresh: 0, + visible: 0, + }; + required.fresh++; + if (coin.visible) { + required.visible++; + } + requiredByAvailability.set(key, required); + } + for (const required of requiredByAvailability.values()) { + await ensureCoinAvailabilityCounters( + tx, + required.availability, + required.fresh, + required.visible, + ); + } const loadedHistories = await tx.getCoinHistoriesByPubs(csi.coinPubs); const historiesByPub = new Map( loadedHistories.map((history) => [history.coinPub, history]), @@ -239,20 +279,11 @@ export async function spendCoins( } coinAvailability.freshCoinCount--; if (coin.visible) { - // Clamped rather than thrown: the visible count is a display - // figure, and refusing to spend a coin over it would be worse - // than showing a stale number. The fresh count above is the - // one that guards spendability, and that does throw. - if (!coinAvailability.visibleCoinCount) { - logger.error( - `visible coin count underflow for denom ${coin.denomPubHash}` + - ` age ${coin.maxAge} at ${coinAvailability.exchangeBaseUrl}:` + - ` a visible coin was spent but the count is` + - ` ${coinAvailability.visibleCoinCount}`, - ); - } else { - coinAvailability.visibleCoinCount--; - } + checkDbInvariant( + coinAvailability.visibleCoinCount > 0, + `no visible coins for ${coin.denomPubHash}`, + ); + coinAvailability.visibleCoinCount--; } let histEntry: WalletCoinHistory | undefined = historiesByPub.get( coin.coinPub, diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts @@ -124,6 +124,7 @@ import { coinAvailabilityRefKey, denomRefKey, EXCHANGE_COINS_LOCK, + ensureCoinAvailabilityCounters, getDenomInfo, getDenomInfos, WalletExecutionContext, @@ -1885,6 +1886,43 @@ async function applyRefreshToOldCoins( availability, ]), ); + const requiredByAvailability = new Map< + string, + { + availability: WalletCoinAvailability; + fresh: number; + visible: number; + } + >(); + for (const { coin } of loadedCoins) { + if (coin.status !== CoinStatus.Fresh) { + continue; + } + const key = coinAvailabilityRefKey(coin); + const availability = availabilitiesByRef.get(key); + checkDbInvariant( + !!availability, + `no denom info for ${coin.denomPubHash} age ${coin.maxAge}`, + ); + const required = requiredByAvailability.get(key) ?? { + availability, + fresh: 0, + visible: 0, + }; + required.fresh++; + if (coin.visible) { + required.visible++; + } + requiredByAvailability.set(key, required); + } + for (const required of requiredByAvailability.values()) { + await ensureCoinAvailabilityCounters( + tx, + required.availability, + required.fresh, + required.visible, + ); + } const histories = await tx.getCoinHistoriesByPubs( loadedCoins.map((x) => x.coin.coinPub), ); @@ -1911,20 +1949,11 @@ async function applyRefreshToOldCoins( ); coinAv.freshCoinCount--; if (coin.visible) { - // Clamped rather than thrown: the visible count is a display - // figure, and refusing to spend a coin over it would be worse - // than showing a stale number. The fresh count above is the - // one that guards spendability, and that does throw. - if (!coinAv.visibleCoinCount) { - logger.error( - `visible coin count underflow for denom ${coin.denomPubHash}` + - ` age ${coin.maxAge} at ${coinAv.exchangeBaseUrl}:` + - ` a visible coin was spent but the count is` + - ` ${coinAv.visibleCoinCount}`, - ); - } else { - coinAv.visibleCoinCount--; - } + checkDbInvariant( + coinAv.visibleCoinCount > 0, + `no visible coins for ${coin.denomPubHash}`, + ); + coinAv.visibleCoinCount--; } changedAvailabilities.set(coinAvailabilityRefKey(coinAv), coinAv); break; diff --git a/packages/taler-wallet-core/src/requests.test.ts b/packages/taler-wallet-core/src/requests.test.ts @@ -298,6 +298,8 @@ test("removing one global exchange retains shared currency metadata", async () = function makeCoinSuspensionContext( status: CoinStatus, freshCoinCount: number, + visibleCoinCount = 0, + visible?: number, ): { wex: WalletExecutionContext; coin: WalletCoin; @@ -309,12 +311,14 @@ function makeCoinSuspensionContext( exchangeMasterPub: "exchange-master-pub", maxAge: 0, status, + visible, } as WalletCoin; const availability = { denomPubHash: coin.denomPubHash, exchangeMasterPub: coin.exchangeMasterPub, maxAge: coin.maxAge, freshCoinCount, + visibleCoinCount, } as WalletCoinAvailability; const tx = { async getCoin(): Promise<WalletCoin> { @@ -323,6 +327,9 @@ function makeCoinSuspensionContext( async getCoinAvailability(): Promise<WalletCoinAvailability> { return availability; }, + async getCoinsByDenomPubHashes(): Promise<WalletCoin[]> { + return [coin]; + }, async upsertCoin(updated: WalletCoin): Promise<void> { Object.assign(coin, updated); }, @@ -359,6 +366,30 @@ test("unsuspending a coin is idempotent and only revives suspended coins", async assert.strictEqual(suspended.availability.freshCoinCount, 1); }); +test("suspending a visible coin updates both availability counters", async () => { + const ctx = makeCoinSuspensionContext(CoinStatus.Fresh, 1, 1, 1); + + await setCoinSuspended(ctx.wex, ctx.coin.coinPub, true); + assert.strictEqual(ctx.coin.status, CoinStatus.FreshSuspended); + assert.strictEqual(ctx.availability.freshCoinCount, 0); + assert.strictEqual(ctx.availability.visibleCoinCount, 0); + + await setCoinSuspended(ctx.wex, ctx.coin.coinPub, false); + assert.strictEqual(ctx.coin.status, CoinStatus.Fresh); + assert.strictEqual(ctx.availability.freshCoinCount, 1); + assert.strictEqual(ctx.availability.visibleCoinCount, 1); +}); + +test("suspending repairs stale availability counters", async () => { + const ctx = makeCoinSuspensionContext(CoinStatus.Fresh, 0, 0, 1); + + await setCoinSuspended(ctx.wex, ctx.coin.coinPub, true); + + assert.strictEqual(ctx.coin.status, CoinStatus.FreshSuspended); + assert.strictEqual(ctx.availability.freshCoinCount, 0); + assert.strictEqual(ctx.availability.visibleCoinCount, 0); +}); + function makeExchangeEntry( baseUrl: string, entryStatus: ExchangeEntryDbRecordStatus, diff --git a/packages/taler-wallet-core/src/requests.ts b/packages/taler-wallet-core/src/requests.ts @@ -454,7 +454,10 @@ import { import { WalletExecutionContext, denomRefKey, + ensureCoinAvailabilityCounters, + migrateCoinAvailability, migrateMaterializedTransactions, + recomputeCoinAvailabilityAtCurrentVersion, rematerializeTransactionsAtCurrentVersion, walletExchangeClient, } from "./wallet.js"; @@ -555,18 +558,25 @@ export async function setCoinSuspended( if (c.status !== CoinStatus.Fresh) { return; } - if (coinAvailability.freshCoinCount === 0) { - throw Error( - `invalid coin count ${coinAvailability.freshCoinCount} in DB`, - ); - } + await ensureCoinAvailabilityCounters( + tx, + coinAvailability, + 1, + c.visible ? 1 : 0, + ); coinAvailability.freshCoinCount--; + if (c.visible) { + coinAvailability.visibleCoinCount--; + } c.status = CoinStatus.FreshSuspended; } else { if (c.status !== CoinStatus.FreshSuspended) { return; } coinAvailability.freshCoinCount++; + if (c.visible) { + coinAvailability.visibleCoinCount++; + } c.status = CoinStatus.Fresh; } await tx.upsertCoin(c); @@ -1004,6 +1014,7 @@ async function handleSetWalletRunConfig( } await migrateMaterializedTransactions(wex); + await migrateCoinAvailability(wex); if (!wex.ws.initCalled) { await collectLeftoverClaims(wex); @@ -1914,6 +1925,7 @@ async function handleImportDb( // finalizer throws. await wex.ws.db.importDatabase(req.dump, async (tx) => { await rematerializeTransactionsAtCurrentVersion(wex, tx); + await recomputeCoinAvailabilityAtCurrentVersion(tx); }); // The import replaced the database underneath the DAL, writing through the diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -58,6 +58,7 @@ import { WalletNotification, WalletRunConfig, assertUnreachable, + checkDbInvariant, getErrorDetailFromException, j2s, openPromise, @@ -563,6 +564,55 @@ export function coinAvailabilityRefKey(ref: WalletCoinAvailabilityRef): string { } /** + * Repair cached fresh/visible counters for one denomination and age group. + * + * This is intentionally targeted: callers use it only after detecting that a + * requested transition would underflow a derived counter. + */ +export async function ensureCoinAvailabilityCounters( + tx: WalletDbTransaction, + availability: WalletCoinAvailability, + requiredFresh: number, + requiredVisible: number, +): Promise<void> { + if ( + availability.freshCoinCount >= requiredFresh && + availability.visibleCoinCount >= requiredVisible + ) { + return; + } + const oldFresh = availability.freshCoinCount; + const oldVisible = availability.visibleCoinCount; + const coins = await tx.getCoinsByDenomPubHashes([ + availability.denomPubHash, + ]); + const freshCoins = coins.filter( + (coin) => + coin.status === CoinStatus.Fresh && + coin.exchangeMasterPub === availability.exchangeMasterPub && + coin.maxAge === availability.maxAge, + ); + availability.freshCoinCount = freshCoins.length; + availability.visibleCoinCount = freshCoins.filter( + (coin) => !!coin.visible, + ).length; + availability.hasFreshCoins = freshCoins.length > 0 ? 1 : 0; + checkDbInvariant( + availability.freshCoinCount >= requiredFresh && + availability.visibleCoinCount >= requiredVisible, + `coin availability cannot cover ${requiredFresh} fresh and` + + ` ${requiredVisible} visible coins for ${availability.denomPubHash}`, + ); + logger.warn( + `repaired coin availability for ${availability.denomPubHash}` + + ` age ${availability.maxAge}: fresh ${oldFresh} ->` + + ` ${availability.freshCoinCount}, visible ${oldVisible} ->` + + ` ${availability.visibleCoinCount}`, + ); + await tx.upsertCoinAvailability(availability); +} + +/** * Load denomination information for many references with one backend batch. * * Cached entries are reused, missing records are omitted, and each unique