taler-typescript-core

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

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:
Apackages/taler-harness/src/integrationtests/test-denom-revoked-balance.ts | 253+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 2++
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,