taler-typescript-core

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

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:
Mpackages/taler-wallet-core/src/requests.test.ts | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/requests.ts | 4++--
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++;