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:
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,