commit c098e7187a7e3cfe8ae3641744a5638a00b59b0f
parent 46c97c2b361c58ccf4d5ab3b915c20dc12df8a92
Author: Florian Dold <dold@taler.net>
Date: Thu, 20 Aug 2026 19:06:41 +0200
wallet-core: prevent coin resurrection when unsuspending
Diffstat:
2 files changed, 74 insertions(+), 2 deletions(-)
diff --git a/packages/taler-wallet-core/src/requests.test.ts b/packages/taler-wallet-core/src/requests.test.ts
@@ -18,6 +18,7 @@ import assert from "node:assert";
import { test } from "node:test";
import {
+ CoinStatus,
ExchangeEntryStatus,
ExchangeEntrySource,
ExchangeRecommendationReason,
@@ -34,6 +35,8 @@ import {
ExchangeEntryDbRecordStatus,
ExchangeEntryDbUpdateStatus,
WalletExchangeEntry,
+ WalletCoin,
+ WalletCoinAvailability,
timestampPreciseToDb,
} from "./db-common.js";
import { WalletDbTransaction } from "./dbtx.js";
@@ -44,6 +47,7 @@ import {
handleGetDefaultExchanges,
handleHintApplicationResumed,
handleListWithdrawalExchangeCandidates,
+ setCoinSuspended,
} from "./requests.js";
import { WalletApiOperation } from "./wallet-api-types.js";
import { Wallet, WalletExecutionContext } from "./wallet.js";
@@ -167,6 +171,74 @@ test("application-resumed hint reports DB failures independently", async () => {
);
});
+function makeCoinSuspensionContext(
+ status: CoinStatus,
+ freshCoinCount: number,
+): {
+ wex: WalletExecutionContext;
+ coin: WalletCoin;
+ availability: WalletCoinAvailability;
+} {
+ const coin = {
+ coinPub: "coin-pub",
+ denomPubHash: "denom-pub-hash",
+ exchangeMasterPub: "exchange-master-pub",
+ maxAge: 0,
+ status,
+ } as WalletCoin;
+ const availability = {
+ denomPubHash: coin.denomPubHash,
+ exchangeMasterPub: coin.exchangeMasterPub,
+ maxAge: coin.maxAge,
+ freshCoinCount,
+ } as WalletCoinAvailability;
+ const tx = {
+ async getCoin(): Promise<WalletCoin> {
+ return coin;
+ },
+ async getCoinAvailability(): Promise<WalletCoinAvailability> {
+ return availability;
+ },
+ async upsertCoin(updated: WalletCoin): Promise<void> {
+ Object.assign(coin, updated);
+ },
+ async upsertCoinAvailability(
+ updated: WalletCoinAvailability,
+ ): Promise<void> {
+ Object.assign(availability, updated);
+ },
+ } as unknown as WalletDbTransaction;
+ return {
+ coin,
+ availability,
+ wex: {
+ async runWalletDbTx<T>(
+ f: (tx: WalletDbTransaction) => Promise<T>,
+ ): Promise<T> {
+ return await f(tx);
+ },
+ } as WalletExecutionContext,
+ };
+}
+
+test("unsuspending a coin is idempotent and only revives suspended coins", async () => {
+ for (const status of [CoinStatus.Fresh, CoinStatus.DenomLoss]) {
+ const ctx = makeCoinSuspensionContext(status, 1);
+ await setCoinSuspended(ctx.wex, ctx.coin.coinPub, false);
+ assert.strictEqual(ctx.coin.status, status);
+ assert.strictEqual(ctx.availability.freshCoinCount, 1);
+ }
+
+ const suspended = makeCoinSuspensionContext(CoinStatus.FreshSuspended, 0);
+ await setCoinSuspended(
+ suspended.wex,
+ suspended.coin.coinPub,
+ false,
+ );
+ assert.strictEqual(suspended.coin.status, CoinStatus.Fresh);
+ assert.strictEqual(suspended.availability.freshCoinCount, 1);
+});
+
function makeExchangeEntry(
baseUrl: string,
entryStatus: ExchangeEntryDbRecordStatus,
diff --git a/packages/taler-wallet-core/src/requests.ts b/packages/taler-wallet-core/src/requests.ts
@@ -525,7 +525,7 @@ async function forgetBankAccount(
return;
}
-async function setCoinSuspended(
+export async function setCoinSuspended(
wex: WalletExecutionContext,
coinPub: string,
suspended: boolean,
@@ -553,7 +553,7 @@ async function setCoinSuspended(
coinAvailability.freshCoinCount--;
c.status = CoinStatus.FreshSuspended;
} else {
- if (c.status == CoinStatus.Dormant) {
+ if (c.status !== CoinStatus.FreshSuspended) {
return;
}
coinAvailability.freshCoinCount++;