taler-typescript-core

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

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:
Mpackages/taler-harness/src/integrationtests/test-multiexchange.ts | 236+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mpackages/taler-wallet-core/src/balance.test.ts | 66+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mpackages/taler-wallet-core/src/balance.ts | 7+++++++
Mpackages/taler-wallet-core/src/pay-merchant.ts | 106++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mpackages/taler-wallet-core/src/refresh.ts | 23+++++++++++++++++++----
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;