commit b95d4ca3e584dabd627a63fcff847d2111fae890
parent a4e35b9fab0cdd7f1f6b8a348c24229fb210843c
Author: Florian Dold <dold@taler.net>
Date: Wed, 19 Aug 2026 18:51:12 +0200
wallet-core: fix abort recovery for partial payments
Issue: https://bugs.taler.net/n/11243
Diffstat:
5 files changed, 406 insertions(+), 32 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-multiexchange.ts b/packages/taler-harness/src/integrationtests/test-multiexchange.ts
@@ -18,9 +18,19 @@
* Imports.
*/
import {
+ ConfirmPayResultType,
Duration,
+ NotificationType,
+ openPromise,
+ RefreshReason,
+ succeedOrThrow,
TalerCorebankApiClient,
+ TalerErrorCode,
+ TalerErrorDetail,
TalerMerchantApi,
+ TalerMerchantInstanceHttpClient,
+ TransactionMajorState,
+ TransactionMinorState,
TransactionType,
} from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
@@ -31,6 +41,12 @@ import {
withdrawViaBankV3,
} from "../harness/environments.js";
import {
+ FaultInjectedExchangeService,
+ FaultInjectedMerchantService,
+ FaultInjectionRequestContext,
+ FaultInjectionResponseContext,
+} from "../harness/faultInjection.js";
+import {
BankService,
ExchangeService,
GlobalTestState,
@@ -78,6 +94,25 @@ export async function runMultiExchangeTest(t: GlobalTestState) {
database: dbDefault.connStr,
});
+ const faultyMerchant = new FaultInjectedMerchantService(t, merchant, 9083);
+ const faultyExchangeOne = new FaultInjectedExchangeService(
+ t,
+ exchangeOne,
+ 9181,
+ );
+ const faultyExchangeTwo = new FaultInjectedExchangeService(
+ t,
+ exchangeTwo,
+ 9281,
+ );
+
+ await exchangeOne.modifyConfig(async (config) => {
+ config.setString("exchange", "base_url", faultyExchangeOne.baseUrl);
+ });
+ await exchangeTwo.modifyConfig(async (config) => {
+ config.setString("exchange", "base_url", faultyExchangeTwo.baseUrl);
+ });
+
let exchangeOneBankAccount: HarnessExchangeBankAccount = {
wireGatewayApiBaseUrl: new URL(
"accounts/myexchange/taler-wire-gateway/",
@@ -105,7 +140,7 @@ export async function runMultiExchangeTest(t: GlobalTestState) {
};
bank.setSuggestedExchange(
- exchangeOne,
+ faultyExchangeOne,
exchangeOneBankAccount.accountPaytoUri,
);
@@ -152,8 +187,8 @@ export async function runMultiExchangeTest(t: GlobalTestState) {
// Start and configure merchant
- merchant.addExchange(exchangeOne);
- merchant.addExchange(exchangeTwo);
+ merchant.addExchange(faultyExchangeOne);
+ merchant.addExchange(faultyExchangeTwo);
await merchant.start();
await merchant.pingUntilAvailable();
@@ -192,14 +227,14 @@ export async function runMultiExchangeTest(t: GlobalTestState) {
await withdrawViaBankV3(t, {
walletClient,
bankClient,
- exchange: exchangeOne,
+ exchange: faultyExchangeOne,
amount: "TESTKUDOS:6",
});
await withdrawViaBankV3(t, {
walletClient,
bankClient,
- exchange: exchangeTwo,
+ exchange: faultyExchangeTwo,
amount: "TESTKUDOS:6",
});
@@ -216,7 +251,7 @@ export async function runMultiExchangeTest(t: GlobalTestState) {
"url" in balance.scopeInfo ? balance.scopeInfo.url : "",
)
.sort(),
- [exchangeOne.baseUrl, exchangeTwo.baseUrl].sort(),
+ [faultyExchangeOne.baseUrl, faultyExchangeTwo.baseUrl].sort(),
);
for (const balance of fundedBalances.balances) {
t.assertAmountEquals(balance.available, "TESTKUDOS:5.85");
@@ -232,7 +267,7 @@ export async function runMultiExchangeTest(t: GlobalTestState) {
const payment = await makeTestPaymentV2(t, {
walletClient,
- merchant,
+ merchant: faultyMerchant,
order,
merchantAdminAccessToken: adminAccessToken,
});
@@ -244,11 +279,190 @@ export async function runMultiExchangeTest(t: GlobalTestState) {
);
t.assertTrue(paymentTx.type === TransactionType.Payment);
t.assertDeepEqual(
- paymentTx.scopes
- .map((scope) => ("url" in scope ? scope.url : ""))
- .sort(),
- [exchangeOne.baseUrl, exchangeTwo.baseUrl].sort(),
+ paymentTx.scopes.map((scope) => ("url" in scope ? scope.url : "")).sort(),
+ [faultyExchangeOne.baseUrl, faultyExchangeTwo.baseUrl].sort(),
+ );
+
+ // Fund both exchanges again. Neither balance is enough for the next
+ // payment by itself, which forces the merchant to submit one deposit batch
+ // to each exchange.
+ await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange: faultyExchangeOne,
+ amount: "TESTKUDOS:6",
+ });
+ await withdrawViaBankV3(t, {
+ walletClient,
+ bankClient,
+ exchange: faultyExchangeTwo,
+ amount: "TESTKUDOS:6",
+ });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ let failSecondExchangeDeposit = true;
+ let holdSecondExchangeMelt = false;
+ const releaseSecondExchangeMelt = openPromise<void>();
+ faultyExchangeTwo.faultProxy.addFault({
+ async modifyRequest(ctx: FaultInjectionRequestContext) {
+ const pathname = new URL(ctx.requestUrl).pathname;
+ if (failSecondExchangeDeposit && pathname.endsWith("/batch-deposit")) {
+ const err: TalerErrorDetail = {
+ code: TalerErrorCode.GENERIC_INTERNAL_INVARIANT_FAILURE,
+ hint: "injected exchange deposit failure",
+ };
+ ctx.dropRequest = true;
+ ctx.substituteResponseStatusCode = 500;
+ ctx.substituteResponseBody = Buffer.from(JSON.stringify(err));
+ }
+ if (holdSecondExchangeMelt && pathname.endsWith("/melt")) {
+ await releaseSecondExchangeMelt.promise;
+ }
+ },
+ });
+
+ let observeDepositedCoinConflict = false;
+ const depositedCoinConflictObserved = openPromise<void>();
+ faultyExchangeOne.faultProxy.addFault({
+ async modifyRequest(ctx: FaultInjectionRequestContext) {
+ const pathname = new URL(ctx.requestUrl).pathname;
+ if (
+ observeDepositedCoinConflict &&
+ pathname.includes("/coins/") &&
+ pathname.endsWith("/history")
+ ) {
+ depositedCoinConflictObserved.resolve();
+ }
+ },
+ });
+
+ const abortRequested = openPromise<void>();
+ let depositedCoinIndices: number[] = [];
+ let holdAbort = true;
+ faultyMerchant.faultProxy.addFault({
+ async modifyRequest(ctx: FaultInjectionRequestContext) {
+ if (holdAbort && new URL(ctx.requestUrl).pathname.endsWith("/abort")) {
+ const requestBody = JSON.parse(ctx.requestBody!.toString()) as {
+ coins: Array<{ exchange_url: string }>;
+ };
+ depositedCoinIndices = requestBody.coins.flatMap((coin, index) =>
+ coin.exchange_url === faultyExchangeOne.baseUrl ? [index] : [],
+ );
+ abortRequested.resolve();
+ // Let the deposited coin's precautionary refresh reach the expected
+ // zero-balance conflict before the merchant submits its refund.
+ await depositedCoinConflictObserved.promise;
+ }
+ },
+ async modifyResponse(ctx: FaultInjectionResponseContext) {
+ if (
+ new URL(ctx.request.requestUrl).pathname.endsWith("/abort") &&
+ ctx.responseBody
+ ) {
+ const responseBody = JSON.parse(ctx.responseBody.toString()) as {
+ refunds: unknown[];
+ };
+ for (const index of depositedCoinIndices) {
+ // The current merchant rolls its partial deposit bookkeeping back
+ // when the other exchange fails. Supply the abort-refund response
+ // that this regression needs to exercise.
+ responseBody.refunds[index] = {
+ type: "success",
+ exchange_status: 200,
+ exchange_sig: "injected-exchange-signature",
+ exchange_pub: "injected-exchange-public-key",
+ };
+ }
+ ctx.responseBody = Buffer.from(JSON.stringify(responseBody));
+ }
+ },
+ });
+
+ const merchantClient = new TalerMerchantInstanceHttpClient(
+ faultyMerchant.makeInstanceBaseUrl(),
+ );
+ const abortOrder = succeedOrThrow(
+ await merchantClient.createOrder(adminAccessToken, {
+ order: {
+ summary: "Partially deposited payment",
+ amount: "TESTKUDOS:10",
+ },
+ }),
);
+ const abortOrderStatus = succeedOrThrow(
+ await merchantClient.getOrderDetails(adminAccessToken, abortOrder.order_id),
+ );
+ t.assertDeepEqual(abortOrderStatus.order_status, "unpaid");
+
+ const prepareAbortPay = await walletClient.call(
+ WalletApiOperation.PreparePayForUriV2,
+ { talerPayUri: abortOrderStatus.taler_pay_uri },
+ );
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: prepareAbortPay.transactionId,
+ txState: {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.Proposed,
+ },
+ });
+ const confirmAbortPay = await walletClient.call(
+ WalletApiOperation.ConfirmPay,
+ {
+ transactionId: prepareAbortPay.transactionId,
+ choiceIndex: 0,
+ },
+ );
+ t.assertDeepEqual(confirmAbortPay.type, ConfirmPayResultType.Pending);
+
+ const refundStored = walletClient.waitForNotificationCond(
+ (notification) =>
+ notification.type === NotificationType.TransactionStateTransition &&
+ notification.causeHint === "refund-group-create",
+ );
+ observeDepositedCoinConflict = true;
+ holdSecondExchangeMelt = true;
+ await walletClient.call(WalletApiOperation.AbortTransaction, {
+ transactionId: prepareAbortPay.transactionId,
+ });
+ await abortRequested.promise;
+ await depositedCoinConflictObserved.promise;
+ await refundStored;
+ // The other exchange's melt is still held, so the payment must remain
+ // aborting after the merchant refund has been stored.
+
+ const withRefreshes = await walletClient.call(
+ WalletApiOperation.GetTransactions,
+ { includeRefreshes: true },
+ );
+ const abortRefresh = withRefreshes.transactions.find(
+ (tx) =>
+ tx.type === TransactionType.Refresh &&
+ tx.originatingTransactionId === prepareAbortPay.transactionId &&
+ tx.refreshReason === RefreshReason.AbortPay,
+ );
+ t.assertTrue(abortRefresh?.type === TransactionType.Refresh);
+
+ const paymentWhileAbortHeld = await walletClient.call(
+ WalletApiOperation.GetTransactionById,
+ { transactionId: prepareAbortPay.transactionId },
+ );
+ t.assertDeepEqual(
+ paymentWhileAbortHeld.txState.major,
+ TransactionMajorState.Aborting,
+ );
+
+ failSecondExchangeDeposit = false;
+ holdAbort = false;
+ holdSecondExchangeMelt = false;
+ releaseSecondExchangeMelt.resolve();
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: abortRefresh.transactionId,
+ txState: { major: TransactionMajorState.Done },
+ });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: prepareAbortPay.transactionId,
+ txState: { major: TransactionMajorState.Aborted },
+ });
}
runMultiExchangeTest.suites = ["wallet"];
diff --git a/packages/taler-wallet-core/src/balance.test.ts b/packages/taler-wallet-core/src/balance.test.ts
@@ -15,7 +15,7 @@
import assert from "node:assert";
import { test } from "node:test";
-import { Amounts, ScopeType } from "@gnu-taler/taler-util";
+import { Amounts, RefreshReason, ScopeType } from "@gnu-taler/taler-util";
import {
getBalancesInsideTransaction,
getPaymentBalanceDetailsInTx,
@@ -23,8 +23,10 @@ import {
import {
ExchangeEntryDbRecordStatus,
ExchangeEntryDbUpdateStatus,
+ RefreshOperationStatus,
WalletExchangeDetails,
WalletExchangeEntry,
+ WalletRefreshGroup,
} from "./db-common.js";
import { WalletDbTransaction } from "./dbtx.js";
import { WalletExecutionContext } from "./wallet.js";
@@ -41,7 +43,10 @@ function makeExchange(
} as WalletExchangeEntry;
}
-function makeBalanceContext(exchanges: WalletExchangeEntry[]): {
+function makeBalanceContext(
+ exchanges: WalletExchangeEntry[],
+ refreshGroups: WalletRefreshGroup[] = [],
+): {
wex: WalletExecutionContext;
tx: WalletDbTransaction;
} {
@@ -72,7 +77,7 @@ function makeBalanceContext(exchanges: WalletExchangeEntry[]): {
return [];
},
async getActiveRefreshGroups() {
- return [];
+ return refreshGroups;
},
async getActiveWithdrawalGroups() {
return [];
@@ -164,6 +169,7 @@ test("pending refresh balance respects the requested sender scope", async () =>
return [
{
currency: "TESTKUDOS",
+ reason: RefreshReason.Refund,
infoPerExchange: {
"https://exchange-b.example/": {
outputEffective: "TESTKUDOS:5",
@@ -216,3 +222,57 @@ test("pending refresh balance respects the requested sender scope", async () =>
"TESTKUDOS:5",
);
});
+
+test("payment abort refresh output is not available", async () => {
+ const exchangeBaseUrl = "https://exchange.example/";
+ const makeRefreshGroup = (
+ reason: RefreshReason,
+ outputEffective: string,
+ ): WalletRefreshGroup =>
+ ({
+ currency: "TESTKUDOS",
+ infoPerExchange: {
+ [exchangeBaseUrl]: { outputEffective },
+ },
+ operationStatus: RefreshOperationStatus.Pending,
+ reason,
+ }) as unknown as WalletRefreshGroup;
+
+ const ordinaryRefresh = makeRefreshGroup(RefreshReason.Refund, "TESTKUDOS:5");
+ const abortRefresh = makeRefreshGroup(RefreshReason.AbortPay, "TESTKUDOS:7");
+ const { wex, tx } = makeBalanceContext(
+ [makeExchange(exchangeBaseUrl)],
+ [ordinaryRefresh, abortRefresh],
+ );
+
+ const balances = await getBalancesInsideTransaction(wex, tx);
+ const exchangeBalance = balances.balances.find(
+ (x) =>
+ x.scopeInfo.type === ScopeType.Exchange &&
+ x.scopeInfo.url === exchangeBaseUrl,
+ );
+ assert.ok(exchangeBalance);
+ assert.strictEqual(exchangeBalance.available, "TESTKUDOS:5");
+
+ const detailTx = {
+ async getCoinAvailabilities() {
+ return [];
+ },
+ async getActiveRefreshGroups() {
+ return [ordinaryRefresh, abortRefresh];
+ },
+ } as unknown as WalletDbTransaction;
+ const details = await getPaymentBalanceDetailsInTx(wex, detailTx, {
+ currency: "TESTKUDOS",
+ minAge: 0,
+ restrictSenderScope: undefined,
+ restrictReceiverExchanges: undefined,
+ restrictWireMethods: undefined,
+ depositPaytoUri: undefined,
+ });
+ assert.strictEqual(
+ Amounts.stringify(details.balanceAvailable),
+ "TESTKUDOS:5",
+ );
+ assert.strictEqual(Amounts.stringify(details.balanceMaterial), "TESTKUDOS:0");
+});
diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts
@@ -73,6 +73,7 @@ import {
j2s,
Logger,
Paytos,
+ RefreshReason,
Result,
ScopeInfo,
ScopeType,
@@ -130,6 +131,9 @@ async function computeRefreshGroupAvailableAmountForExchanges(
if (r.timestampFinished) {
return available;
}
+ if (r.reason === RefreshReason.AbortPay) {
+ return available;
+ }
if (!r.infoPerExchange) {
return available;
}
@@ -513,6 +517,9 @@ export async function getBalancesInsideTransaction(
const refreshGroups = await tx.getActiveRefreshGroups();
for (const r of refreshGroups) {
+ if (r.reason === RefreshReason.AbortPay) {
+ continue;
+ }
switch (r.operationStatus) {
case RefreshOperationStatus.Pending:
case RefreshOperationStatus.Suspended:
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -114,6 +114,7 @@ import {
} from "./coinSelection.js";
import {
constructTaskIdentifier,
+ genericWaitForState,
genericWaitForStateVal,
getGenericRecordHandle,
LookupFullTransactionOpts,
@@ -131,6 +132,7 @@ import { EddsaKeyPairStrings } from "./crypto/cryptoImplementation.js";
import {
ConfigRecordKey,
PurchaseStatus,
+ RefreshOperationStatus,
RefundGroupStatus,
RefundItemStatus,
timestampPreciseFromDb,
@@ -161,6 +163,7 @@ import {
calculateRefreshOutput,
createRefreshGroup,
getTotalRefreshCost,
+ RefreshTransactionContext,
} from "./refresh.js";
import {
selectPayTokensInTx,
@@ -4333,15 +4336,11 @@ async function processPurchaseAbortingRefund(
TalerErrorCode.MERCHANT_POST_ORDERS_ID_ABORT_CONTRACT_NOT_FOUND) ||
isOrderUnknown(abortHttpResp)
) {
- await wex.runWalletDbTx(async (tx) => {
- const [rec, h] = await ctx.getRecordHandle(tx);
- if (rec?.purchaseStatus !== PurchaseStatus.AbortingWithRefund) {
- return;
- }
- rec.purchaseStatus = PurchaseStatus.AbortedOrderDeleted;
- await h.update(rec, "abort-order-gone");
- });
- return TaskRunResult.progress();
+ return await waitForRefreshOnAbortedPayment(
+ wex,
+ purchase,
+ PurchaseStatus.AbortedOrderDeleted,
+ );
}
if (abortHttpResp.case !== "ok") {
@@ -4385,7 +4384,88 @@ async function processPurchaseAbortingRefund(
),
});
}
- return await storeRefunds(wex, purchase, refunds, RefundReason.AbortRefund);
+ const storeResult = await storeRefunds(
+ wex,
+ purchase,
+ refunds,
+ RefundReason.AbortRefund,
+ );
+ if (storeResult.type !== TaskRunResultType.Progress) {
+ return storeResult;
+ }
+ return await waitForRefreshOnAbortedPayment(
+ wex,
+ purchase,
+ PurchaseStatus.AbortedRefunded,
+ );
+}
+
+/**
+ * Wait for the precautionary refresh created while aborting a payment.
+ *
+ * The merchant's abort response only tells us what happened to the deposits.
+ * The payment is not fully aborted until the wallet has also recovered (or
+ * conclusively failed to recover) the selected coins.
+ */
+async function waitForRefreshOnAbortedPayment(
+ wex: WalletExecutionContext,
+ purchase: WalletPurchase,
+ finalStatus:
+ | PurchaseStatus.AbortedOrderDeleted
+ | PurchaseStatus.AbortedRefunded,
+): Promise<TaskRunResult> {
+ const abortRefreshGroupId = await wex.runWalletDbTx(async (tx) => {
+ return (await tx.getPurchase(purchase.proposalId))?.abortRefreshGroupId;
+ });
+ checkLogicInvariant(!!abortRefreshGroupId);
+
+ const ctx = new PayMerchantTransactionContext(wex, purchase.proposalId);
+ const refreshCtx = new RefreshTransactionContext(wex, abortRefreshGroupId);
+
+ await genericWaitForState(wex, {
+ async checkState() {
+ return await wex.runWalletDbTx(async (tx) => {
+ const refreshGroup = await tx.getRefreshGroup(abortRefreshGroupId);
+ switch (refreshGroup?.operationStatus) {
+ case undefined:
+ case RefreshOperationStatus.Failed:
+ case RefreshOperationStatus.Finished:
+ return true;
+ default:
+ return false;
+ }
+ });
+ },
+ filterNotification(notif) {
+ return (
+ notif.type === NotificationType.TransactionStateTransition &&
+ notif.transactionId === refreshCtx.transactionId
+ );
+ },
+ });
+
+ const didTransition = await wex.runWalletDbTx(async (tx) => {
+ const refreshGroup = await tx.getRefreshGroup(abortRefreshGroupId);
+ switch (refreshGroup?.operationStatus) {
+ case undefined:
+ logger.warn("no aborting refresh group found for payment");
+ break;
+ case RefreshOperationStatus.Failed:
+ case RefreshOperationStatus.Finished:
+ break;
+ default:
+ return false;
+ }
+ const [rec, h] = await ctx.getRecordHandle(tx);
+ if (rec?.purchaseStatus !== PurchaseStatus.AbortingWithRefund) {
+ return false;
+ }
+ rec.purchaseStatus = finalStatus;
+ await h.update(rec, "abort-refresh-done");
+ return true;
+ });
+
+ return didTransition ? TaskRunResult.progress() : TaskRunResult.backoff();
}
async function processPurchaseQueryRefund(
@@ -4835,11 +4915,9 @@ async function storeRefunds(
);
if (numPendingItemsTotal === 0) {
- if (isAborting) {
- myPurchase.purchaseStatus = PurchaseStatus.AbortedRefunded;
- } else if (shouldCheckAutoRefund) {
+ if (!isAborting && shouldCheckAutoRefund) {
myPurchase.purchaseStatus = PurchaseStatus.FinalizingQueryingAutoRefund;
- } else {
+ } else if (!isAborting) {
myPurchase.purchaseStatus = PurchaseStatus.Done;
}
myPurchase.refundAmountAwaiting = undefined;
diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts
@@ -1092,7 +1092,6 @@ async function handleRefreshMeltConflict(
return;
}
if (Amounts.isZero(historyJson.balance)) {
- rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed;
const refreshSession = await tx.getRefreshSession(
ctx.refreshGroupId,
coinIndex,
@@ -1100,9 +1099,25 @@ async function handleRefreshMeltConflict(
if (!refreshSession) {
throw Error("db invariant failed: missing refresh session in database");
}
- refreshSession.lastError = errDetails;
- await tx.upsertRefreshSession(refreshSession);
- await h.update(rg, "melt-conflict");
+ if (rg.reason === RefreshReason.AbortPay) {
+ // A coin selected for a partially completed payment can legitimately
+ // have no balance left: the merchant deposited it before the wallet
+ // started the precautionary abort refresh. The merchant's abort
+ // refund is responsible for recovering that value, so this refresh
+ // coin has no output and is complete rather than failed.
+ await destroyRefreshSession(ctx.wex, tx, rg, refreshSession);
+ await tx.deleteRefreshSession(ctx.refreshGroupId, coinIndex);
+ rg.expectedOutputPerCoin[coinIndex] = Amounts.stringify(
+ Amounts.zeroOfCurrency(rg.currency),
+ );
+ rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;
+ await h.update(rg, "melt-conflict-abort-pay-spent");
+ } else {
+ rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed;
+ refreshSession.lastError = errDetails;
+ await tx.upsertRefreshSession(refreshSession);
+ await h.update(rg, "melt-conflict");
+ }
} else {
// Try again with new denoms!
rg.inputPerCoin[coinIndex] = historyJson.balance;