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:
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