commit 46fbf8c1fb2943f48c0737f461812bd12f370486
parent e7c440ed738d426fda6d0c090b71652b2f2a62df
Author: Florian Dold <dold@taler.net>
Date: Tue, 28 Jul 2026 16:11:56 +0200
harness: check that revoked coins are dropped from the balance and from p2p
Diffstat:
2 files changed, 255 insertions(+), 0 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-denom-revoked-balance.ts b/packages/taler-harness/src/integrationtests/test-denom-revoked-balance.ts
@@ -0,0 +1,253 @@
+/*
+ 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.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AmountString,
+ Amounts,
+ CoinStatus,
+ DenomLossEventType,
+ Duration,
+ j2s,
+ Logger,
+ TalerErrorCode,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionType,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { makeNoFeeCoinConfig } from "../harness/denomStructures.js";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ withdrawViaBankV3,
+} from "../harness/environments.js";
+import { GlobalTestState } from "../harness/harness.js";
+
+const logger = new Logger("test-denom-revoked-balance");
+
+const purseExpiration = AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(AbsoluteTime.now(), Duration.fromSpec({ days: 2 })),
+);
+
+const initialAmount = "TESTKUDOS:20" as AmountString;
+
+/**
+ * Check that coins of a revoked denomination are neither counted towards the
+ * balance nor selected for a peer-to-peer payment.
+ *
+ * The exchange is configured without fees, so every amount below is exact.
+ */
+export async function runDenomRevokedBalanceTest(t: GlobalTestState) {
+ const { walletClient, bankClient, exchange } =
+ await createSimpleTestkudosEnvironmentV3(
+ t,
+ makeNoFeeCoinConfig("TESTKUDOS"),
+ );
+
+ const wres = await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange,
+ amount: initialAmount,
+ });
+ await wres.withdrawalFinishedCond;
+
+ /**
+ * Sum up the value of all coins the wallet considers spendable, grouped
+ * by denomination.
+ */
+ async function getFreshCoinsByDenom(): Promise<
+ Map<string, { count: number; total: AmountString }>
+ > {
+ const coinDump = await walletClient.call(WalletApiOperation.DumpCoins, {});
+ const byDenom = new Map<string, { count: number; total: AmountString }>();
+ for (const coin of coinDump.coins) {
+ if (coin.coinStatus !== CoinStatus.Fresh) {
+ continue;
+ }
+ const prev = byDenom.get(coin.denomPubHash) ?? {
+ count: 0,
+ total: Amounts.stringify(Amounts.zeroOfCurrency("TESTKUDOS")),
+ };
+ byDenom.set(coin.denomPubHash, {
+ count: prev.count + 1,
+ total: Amounts.stringify(
+ Amounts.add(prev.total, coin.denomValue as AmountString).amount,
+ ),
+ });
+ }
+ return byDenom;
+ }
+
+ async function getAvailableBalance(): Promise<AmountString> {
+ const bal = await walletClient.call(WalletApiOperation.GetBalances, {});
+ t.assertTrue(bal.balances.length === 1);
+ return bal.balances[0].available;
+ }
+
+ const coinsBefore = await getFreshCoinsByDenom();
+ logger.info(`fresh coins before revocation: ${j2s([...coinsBefore])}`);
+
+ const balanceBefore = await getAvailableBalance();
+ t.assertAmountEquals(balanceBefore, initialAmount);
+
+ // Revoke the denomination that holds the most money, so that both the
+ // revoked and the remaining part of the balance are non-zero.
+ let revokedDenomPubHash: string | undefined;
+ let revokedAmount = Amounts.stringify(Amounts.zeroOfCurrency("TESTKUDOS"));
+ for (const [denomPubHash, info] of coinsBefore) {
+ if (Amounts.cmp(info.total, revokedAmount) > 0) {
+ revokedDenomPubHash = denomPubHash;
+ revokedAmount = info.total;
+ }
+ }
+ t.assertTrue(revokedDenomPubHash != null);
+ const remainingAmount = Amounts.stringify(
+ Amounts.sub(initialAmount, revokedAmount).amount,
+ );
+ t.assertTrue(!Amounts.isZero(remainingAmount));
+
+ t.logStep(
+ `revoking denomination ${revokedDenomPubHash} worth ${revokedAmount}` +
+ `, leaving ${remainingAmount}`,
+ );
+
+ await exchange.revokeDenomination(revokedDenomPubHash);
+ await exchange.keyup();
+
+ // Make the wallet notice the revocation. UpdateExchangeEntry only kicks
+ // off the update task, so wait for it to actually run.
+ await walletClient.call(WalletApiOperation.UpdateExchangeEntry, {
+ exchangeBaseUrl: exchange.baseUrl,
+ force: true,
+ });
+ await walletClient.call(WalletApiOperation.TestingWaitExchangeReady, {
+ exchangeBaseUrl: exchange.baseUrl,
+ forceUpdate: true,
+ });
+
+ const coinsAfter = await getFreshCoinsByDenom();
+ logger.info(`fresh coins after revocation: ${j2s([...coinsAfter])}`);
+
+ const balanceAfter = await getAvailableBalance();
+ t.logStep(`balance after revocation: ${balanceAfter}`);
+
+ // (1) The revoked coins must not count towards the balance anymore.
+ t.assertTrue(coinsAfter.get(revokedDenomPubHash) == null);
+ t.assertAmountEquals(balanceAfter, remainingAmount);
+
+ // The coins are booked as a denomination loss, reported as a revocation and
+ // not as the plain retirement that a revoked denomination also looks like.
+ // Listing transactions must keep working, even though the revocation put a
+ // recoup group in the database.
+ const txs = await walletClient.call(WalletApiOperation.GetTransactions, {});
+ const denomLossTxs = txs.transactions.filter(
+ (x) => x.type === TransactionType.DenomLoss,
+ );
+ t.assertTrue(denomLossTxs.length === 1);
+ t.assertDeepEqual(
+ denomLossTxs[0].lossEventType,
+ DenomLossEventType.DenomRevoked,
+ );
+ t.assertAmountEquals(denomLossTxs[0].amountEffective, revokedAmount);
+
+ const coinsDuring = await walletClient.call(WalletApiOperation.DumpCoins, {});
+ for (const coin of coinsDuring.coins) {
+ if (coin.denomPubHash !== revokedDenomPubHash) {
+ continue;
+ }
+ t.assertDeepEqual(coin.coinStatus, CoinStatus.DenomLoss);
+ }
+
+ // (2) The revoked coins must not be offered for a peer-to-peer payment.
+ const maxPush = await walletClient.call(
+ WalletApiOperation.GetMaxPeerPushDebitAmount,
+ {
+ currency: "TESTKUDOS",
+ },
+ );
+ t.logStep(`max peer-push-debit amount: ${j2s(maxPush)}`);
+ t.assertAmountEquals(maxPush.effectiveAmount, remainingAmount);
+
+ // (3) Coin selection must not fall back to the revoked coins when the
+ // remaining balance does not suffice.
+ const checkTooMuch = await walletClient.call(
+ WalletApiOperation.CheckPeerPushDebitV2,
+ {
+ amount: initialAmount,
+ },
+ );
+ t.assertTrue(checkTooMuch.type === "insufficient-balance");
+
+ const ex = await t.assertThrowsTalerErrorAsync(async () => {
+ await walletClient.call(WalletApiOperation.InitiatePeerPushDebit, {
+ partialContractTerms: {
+ summary: "must not use revoked coins",
+ amount: initialAmount,
+ purse_expiration: purseExpiration,
+ },
+ });
+ });
+ t.assertTrue(
+ ex.errorDetail.code ===
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ );
+
+ // (4) Spending exactly what is left must still work, i.e. the coins of the
+ // revoked denomination are the only ones that became unusable.
+ const checkRest = await walletClient.call(
+ WalletApiOperation.CheckPeerPushDebitV2,
+ {
+ amount: remainingAmount as AmountString,
+ },
+ );
+ t.assertTrue(checkRest.type === "ok");
+ t.assertAmountEquals(checkRest.amountEffective, remainingAmount);
+
+ const initiate = await walletClient.call(
+ WalletApiOperation.InitiatePeerPushDebit,
+ {
+ partialContractTerms: {
+ summary: "spend everything that is left",
+ amount: remainingAmount as AmountString,
+ purse_expiration: purseExpiration,
+ },
+ },
+ );
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: initiate.transactionId,
+ txState: {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Ready,
+ },
+ });
+
+ // The revoked coin was not touched by the payment: it is still recorded as
+ // a denomination loss, not as spent.
+ const coinDump = await walletClient.call(WalletApiOperation.DumpCoins, {});
+ for (const coin of coinDump.coins) {
+ if (coin.denomPubHash !== revokedDenomPubHash) {
+ continue;
+ }
+ t.assertDeepEqual(coin.coinStatus, CoinStatus.DenomLoss);
+ }
+}
+
+runDenomRevokedBalanceTest.suites = ["wallet"];
+runDenomRevokedBalanceTest.timeoutMs = 120000;
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -48,6 +48,7 @@ import { runCurrencyScopeSeparationTest } from "./test-currency-scope-separation
import { runCurrencyScopeTest } from "./test-currency-scope.js";
import { runDenomLostComplexTest } from "./test-denom-lost-complex.js";
import { runDenomLostTest } from "./test-denom-lost.js";
+import { runDenomRevokedBalanceTest } from "./test-denom-revoked-balance.js";
import { runDenomUnofferedTest } from "./test-denom-unoffered.js";
import { runDepositFaultTest } from "./test-deposit-fault.js";
import { runDepositMergeTest } from "./test-deposit-merge.js";
@@ -273,6 +274,7 @@ const allTests: TestMainFunction[] = [
runBankApiTest,
runClaimLoopTest,
runClauseSchnorrTest,
+ runDenomRevokedBalanceTest,
runDenomUnofferedTest,
runDepositTest,
runDepositMergeTest,