taler-typescript-core

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

commit 95e70cad7a1d56ee001064fad3168a78495aef62
parent 52ba306d750311cc1b46c97c3135bcc9b66d0fff
Author: Florian Dold <dold@taler.net>
Date:   Tue, 28 Jul 2026 12:00:05 +0200

harness: check wallet balances around aborted p2p payments

The exchange runs without fees, so an abort that gives the money back has to
restore the balance exactly.

Diffstat:
Apackages/taler-harness/src/integrationtests/test-peer-abort-balance.ts | 378+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/src/integrationtests/test-peer-pull-debit-purse-gone.ts | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 4++++
3 files changed, 521 insertions(+), 0 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-peer-abort-balance.ts b/packages/taler-harness/src/integrationtests/test-peer-abort-balance.ts @@ -0,0 +1,378 @@ +/* + 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, + Duration, + j2s, + TransactionMajorState, + TransactionMinorState, + TransactionPeerPullCredit, + TransactionPeerPushDebit, + TransactionType, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { makeNoFeeCoinConfig } from "../harness/denomStructures.js"; +import { + createSimpleTestkudosEnvironmentV3, + createWalletDaemonWithClient, + withdrawViaBankV3, +} from "../harness/environments.js"; +import { GlobalTestState, WalletClient } from "../harness/harness.js"; + +const purseExpiration = AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.addDuration(AbsoluteTime.now(), Duration.fromSpec({ days: 2 })), +); + +const initialAmount = "TESTKUDOS:20" as AmountString; +const paymentAmount = "TESTKUDOS:5" as AmountString; +const remainingAmount = "TESTKUDOS:15" as AmountString; +const zero = "TESTKUDOS:0" as AmountString; + +interface ExpectedBalance { + available: AmountString; + pendingIncoming?: AmountString; + pendingOutgoing?: AmountString; +} + +/** + * Check the wallet balance before, during and after aborted peer-to-peer + * payments. + * + * The exchange is configured without any fees, so aborting a payment must + * restore the balance exactly. Where the abort comes too late to take the + * money back, exactly the payment amount must end up with the recipient. + * + * Related bugs: + * https://bugs.taler.net/n/11657 + */ +export async function runPeerAbortBalanceTest(t: GlobalTestState) { + const [ + { walletClient: wallet1, bankClient, exchange }, + { walletClient: wallet2 }, + ] = await Promise.all([ + createSimpleTestkudosEnvironmentV3(t, makeNoFeeCoinConfig("TESTKUDOS")), + createWalletDaemonWithClient(t, { + name: "w2", + }), + ]); + + /** + * Check the balance of a wallet. Fields that are not given are expected + * to be zero, so that money can neither vanish nor stay stuck in a pending + * transaction unnoticed. + */ + async function checkBalance( + walletClient: WalletClient, + label: string, + expected: ExpectedBalance, + ): Promise<void> { + const bal = await walletClient.call(WalletApiOperation.GetBalances, {}); + if (bal.balances.length !== 1) { + t.fail(`${label}: expected a single balance, got ${j2s(bal)}`); + } + const actual = bal.balances[0]; + const checks: [string, AmountString, AmountString][] = [ + ["available", actual.available, expected.available], + [ + "pendingIncoming", + actual.pendingIncoming, + expected.pendingIncoming ?? zero, + ], + [ + "pendingOutgoing", + actual.pendingOutgoing, + expected.pendingOutgoing ?? zero, + ], + ]; + for (const [field, actualAmount, expectedAmount] of checks) { + if (Amounts.cmp(actualAmount, expectedAmount) !== 0) { + t.fail( + `${label}: expected ${field} to be ${expectedAmount}, got ${actualAmount}`, + ); + } + } + } + + async function initPushDebit( + summary: string, + ): Promise<TransactionPeerPushDebit> { + const initiate = await wallet1.call( + WalletApiOperation.InitiatePeerPushDebit, + { + partialContractTerms: { + summary, + amount: paymentAmount, + purse_expiration: purseExpiration, + }, + }, + ); + await wallet1.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: initiate.transactionId, + txState: { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.Ready, + }, + }); + const tx = await wallet1.call(WalletApiOperation.GetTransactionById, { + transactionId: initiate.transactionId, + }); + t.assertDeepEqual(tx.type, TransactionType.PeerPushDebit); + return tx; + } + + async function initPullCredit( + summary: string, + ): Promise<TransactionPeerPullCredit> { + const initiate = await wallet1.call( + WalletApiOperation.InitiatePeerPullCredit, + { + exchangeBaseUrl: exchange.baseUrl, + partialContractTerms: { + summary, + amount: paymentAmount, + purse_expiration: purseExpiration, + }, + }, + ); + await wallet1.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: initiate.transactionId, + txState: { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.Ready, + }, + }); + const tx = await wallet1.call(WalletApiOperation.GetTransactionById, { + transactionId: initiate.transactionId, + }); + t.assertDeepEqual(tx.type, TransactionType.PeerPullCredit); + return tx; + } + + await Promise.all( + [wallet1, wallet2].map(async (walletClient) => { + const withdrawRes = await withdrawViaBankV3(t, { + walletClient, + bankClient, + exchange, + amount: initialAmount, + }); + await withdrawRes.withdrawalFinishedCond; + }), + ); + + await checkBalance(wallet1, "sender after the withdrawal", { + available: initialAmount, + }); + await checkBalance(wallet2, "recipient after the withdrawal", { + available: initialAmount, + }); + + await t.runSpanAsync("push debit abort", async () => { + const tx = await initPushDebit("aborted push"); + + await checkBalance(wallet1, "sender while the push debit is ready", { + available: remainingAmount, + pendingOutgoing: paymentAmount, + }); + + await wallet1.call(WalletApiOperation.AbortTransaction, { + transactionId: tx.transactionId, + }); + await wallet1.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: tx.transactionId, + txState: { + major: TransactionMajorState.Aborted, + }, + }); + // The coins that went into the purse are only available again once the + // refresh that the abort started has finished. + await wallet1.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + + await checkBalance(wallet1, "sender after the push debit was aborted", { + available: initialAmount, + }); + }); + + await t.runSpanAsync("push debit abort before purse creation", async () => { + // With the exchange down the coins are selected but the purse is never + // created, so the abort has to give the coins back without the exchange + // ever having seen them. + await exchange.stop(); + const initiate = await wallet1.call( + WalletApiOperation.InitiatePeerPushDebit, + { + partialContractTerms: { + summary: "aborted push before purse creation", + amount: paymentAmount, + purse_expiration: purseExpiration, + }, + }, + ); + await wallet1.call(WalletApiOperation.AbortTransaction, { + transactionId: initiate.transactionId, + }); + await wallet1.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: initiate.transactionId, + txState: { + major: TransactionMajorState.Aborting, + minor: TransactionMinorState.DeletePurse, + }, + }); + await exchange.start(); + await wallet1.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: initiate.transactionId, + txState: { + major: TransactionMajorState.Aborted, + }, + }); + await wallet1.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + + await checkBalance(wallet1, "sender after the early abort", { + available: initialAmount, + }); + }); + + await t.runSpanAsync("pull credit abort", async () => { + const tx = await initPullCredit("aborted invoice"); + + await checkBalance(wallet1, "invoicer while the pull credit is ready", { + available: initialAmount, + pendingIncoming: paymentAmount, + }); + + await wallet1.call(WalletApiOperation.AbortTransaction, { + transactionId: tx.transactionId, + }); + await wallet1.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: tx.transactionId, + txState: { + major: TransactionMajorState.Aborted, + }, + }); + await wallet1.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + + await checkBalance(wallet1, "invoicer after the pull credit was aborted", { + available: initialAmount, + }); + }); + + await t.runSpanAsync("pull debit abort", async () => { + const tx = await initPullCredit("invoice paid and then aborted"); + + const prepare = await wallet2.call( + WalletApiOperation.PreparePeerPullDebit, + { talerUri: tx.talerUri! }, + ); + + // Stop the exchange so that the coins stay selected but undeposited, + // which is the state the payer aborts from. + await exchange.stop(); + await wallet2.call(WalletApiOperation.ConfirmPeerPullDebit, { + transactionId: prepare.transactionId, + }); + + await checkBalance(wallet2, "payer while the pull debit is pending", { + available: remainingAmount, + pendingOutgoing: paymentAmount, + }); + + await wallet2.call(WalletApiOperation.AbortTransaction, { + transactionId: prepare.transactionId, + }); + await exchange.start(); + await wallet2.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: prepare.transactionId, + txState: { + major: TransactionMajorState.Aborted, + }, + }); + await wallet2.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + + await checkBalance(wallet2, "payer after the pull debit was aborted", { + available: initialAmount, + }); + + // Clean up the invoice, it can never be paid anymore. + await wallet1.call(WalletApiOperation.AbortTransaction, { + transactionId: tx.transactionId, + }); + await wallet1.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + await checkBalance(wallet1, "invoicer after the payer aborted", { + available: initialAmount, + }); + }); + + await t.runSpanAsync("push debit abort after the merge", async () => { + const tx = await initPushDebit("push aborted too late"); + + const prepare = await wallet2.call( + WalletApiOperation.PreparePeerPushCredit, + { talerUri: tx.talerUri! }, + ); + + // Suspend the sender so that it does not observe the merge on its own. + // The abort below is then the first request that learns about it. + await wallet1.call(WalletApiOperation.SuspendTransaction, { + transactionId: tx.transactionId, + }); + await wallet1.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: tx.transactionId, + txState: { + major: TransactionMajorState.Suspended, + minor: TransactionMinorState.Ready, + }, + }); + + await wallet2.call(WalletApiOperation.ConfirmPeerPushCredit, { + transactionId: prepare.transactionId, + }); + await wallet2.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: prepare.transactionId, + txState: { + major: TransactionMajorState.Done, + }, + }); + + // The recipient already has the money, so the payment can not be taken + // back and the coins must not be refreshed. + await wallet1.call(WalletApiOperation.AbortTransaction, { + transactionId: tx.transactionId, + }); + await wallet1.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: tx.transactionId, + txState: { + major: TransactionMajorState.Done, + }, + }); + await wallet1.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + await wallet2.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + + await checkBalance(wallet1, "sender of the merged push debit", { + available: remainingAmount, + }); + await checkBalance(wallet2, "recipient of the merged push debit", { + available: "TESTKUDOS:25", + }); + }); +} + +runPeerAbortBalanceTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/test-peer-pull-debit-purse-gone.ts b/packages/taler-harness/src/integrationtests/test-peer-pull-debit-purse-gone.ts @@ -0,0 +1,139 @@ +/* + 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, + Duration, + j2s, + TransactionMajorState, + TransactionMinorState, + TransactionType, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { makeNoFeeCoinConfig } from "../harness/denomStructures.js"; +import { + createSimpleTestkudosEnvironmentV3, + createWalletDaemonWithClient, + withdrawViaBankV3, +} from "../harness/environments.js"; +import { GlobalTestState } from "../harness/harness.js"; + +const purseExpiration = AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.addDuration(AbsoluteTime.now(), Duration.fromSpec({ days: 2 })), +); + +const initialAmount = "TESTKUDOS:20" as AmountString; +const paymentAmount = "TESTKUDOS:5" as AmountString; + +/** + * Pay an invoice that is aborted by the invoicer in the meantime, and check + * that the payer keeps its money. + * + * The payer allocates its coins when it accepts the invoice, but by then the + * purse is already deleted and the deposit is answered with "gone". Since + * not a single coin made it into the purse, all of them must become spendable + * again. + * + * Related bugs: + * https://bugs.taler.net/n/11657 + */ +export async function runPeerPullDebitPurseGoneTest(t: GlobalTestState) { + const [ + { walletClient: wallet1, bankClient, exchange }, + { walletClient: wallet2 }, + ] = await Promise.all([ + createSimpleTestkudosEnvironmentV3(t, makeNoFeeCoinConfig("TESTKUDOS")), + createWalletDaemonWithClient(t, { + name: "w2", + }), + ]); + + const withdrawRes = await withdrawViaBankV3(t, { + walletClient: wallet2, + bankClient, + exchange, + amount: initialAmount, + }); + await withdrawRes.withdrawalFinishedCond; + + const initiate = await wallet1.call( + WalletApiOperation.InitiatePeerPullCredit, + { + exchangeBaseUrl: exchange.baseUrl, + partialContractTerms: { + summary: "invoice aborted while being paid", + amount: paymentAmount, + purse_expiration: purseExpiration, + }, + }, + ); + await wallet1.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: initiate.transactionId, + txState: { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.Ready, + }, + }); + const tx = await wallet1.call(WalletApiOperation.GetTransactionById, { + transactionId: initiate.transactionId, + }); + t.assertDeepEqual(tx.type, TransactionType.PeerPullCredit); + + const prepare = await wallet2.call(WalletApiOperation.PreparePeerPullDebit, { + talerUri: tx.talerUri!, + }); + + await wallet1.call(WalletApiOperation.AbortTransaction, { + transactionId: tx.transactionId, + }); + await wallet1.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: tx.transactionId, + txState: { + major: TransactionMajorState.Aborted, + }, + }); + + await wallet2.call(WalletApiOperation.ConfirmPeerPullDebit, { + transactionId: prepare.transactionId, + }); + await wallet2.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: prepare.transactionId, + txState: [ + { major: TransactionMajorState.Aborted }, + { major: TransactionMajorState.Expired }, + { major: TransactionMajorState.Failed }, + ], + }); + await wallet2.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + + const bal = await wallet2.call(WalletApiOperation.GetBalances, {}); + t.assertDeepEqual(bal.balances.length, 1); + if ( + Amounts.cmp(bal.balances[0].available, initialAmount) !== 0 || + Amounts.isNonZero(bal.balances[0].pendingOutgoing) + ) { + t.fail( + `payer lost money on the unpaid invoice, balance is ${j2s(bal.balances[0])}`, + ); + } +} + +runPeerPullDebitPurseGoneTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -155,6 +155,8 @@ import { runPaymentTransientTest } from "./test-payment-transient.js"; import { runPaymentZeroTest } from "./test-payment-zero.js"; import { runPaymentTest } from "./test-payment.js"; import { runPaywallFlowTest } from "./test-paywall-flow.js"; +import { runPeerAbortBalanceTest } from "./test-peer-abort-balance.js"; +import { runPeerPullDebitPurseGoneTest } from "./test-peer-pull-debit-purse-gone.js"; import { runPeerPullLargeTest } from "./test-peer-pull-large.js"; import { runPeerPullTest } from "./test-peer-pull.js"; import { runPeerPushLargeTest } from "./test-peer-push-large.js"; @@ -311,6 +313,8 @@ const allTests: TestMainFunction[] = [ runMultiExchangeTest, runWalletBalanceTest, runPaywallFlowTest, + runPeerAbortBalanceTest, + runPeerPullDebitPurseGoneTest, runPeerPullTest, runPeerPushTest, runRefundAutoTest,