taler-typescript-core

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

commit f50000ab21a6cfbab86b797e19afc376c77e7ae3
parent 1f8e44cdc6543faad6af7415546d4be7c50b1235
Author: Florian Dold <dold@taler.net>
Date:   Sat, 22 Aug 2026 17:59:06 +0200

wallet-core: poll bank and reserve concurrently

Issue: https://bugs.taler.net/n/8441

Diffstat:
Apackages/taler-harness/src/integrationtests/test-withdrawal-bank-outage.ts | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 2++
Mpackages/taler-wallet-core/src/withdraw.ts | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
3 files changed, 273 insertions(+), 48 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-bank-outage.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-bank-outage.ts @@ -0,0 +1,124 @@ +/* + 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/> + */ + +import { + TalerExchangeHttpClient, + TransactionMajorState, + TransactionMinorState, + TransactionType, + WithdrawalType, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { createSimpleTestkudosEnvironmentV3 } from "../harness/environments.js"; +import { GlobalTestState } from "../harness/harness.js"; + +/** + * The exchange's funded reserve must let a withdrawal finish even when the + * bank becomes unavailable before wallet-core observes bank confirmation. + */ +export async function runWithdrawalBankOutageTest(t: GlobalTestState) { + const { bank, bankClient, exchange, walletClient, walletService } = + await createSimpleTestkudosEnvironmentV3(t); + + // Keep the transfer out of the reserve until the wallet daemon has stopped, + // so it cannot observe either successful long-poll prematurely. + await exchange.stopWirewatch(); + + const user = await bankClient.createRandomBankUser(); + bankClient.setAuth(user); + const withdrawal = await bankClient.createWithdrawalOperation( + user.username, + "TESTKUDOS:10", + ); + + await walletClient.call(WalletApiOperation.GetWithdrawalDetailsForUri, { + talerWithdrawUri: withdrawal.taler_withdraw_uri, + }); + const { transactionId } = await walletClient.call( + WalletApiOperation.AcceptBankIntegratedWithdrawal, + { + exchangeBaseUrl: exchange.baseUrl, + talerWithdrawUri: withdrawal.taler_withdraw_uri, + }, + ); + + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId, + txState: { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.BankConfirmTransfer, + }, + }); + + const pendingTx = await walletClient.call( + WalletApiOperation.GetTransactionById, + { transactionId }, + ); + t.assertTrue(pendingTx.type === TransactionType.Withdrawal); + t.assertTrue( + pendingTx.withdrawalDetails.type === WithdrawalType.TalerBankIntegrationApi, + ); + const reservePub = pendingTx.withdrawalDetails.reservePub; + + await walletService.stop(); + + // Complete the transfer while the wallet cannot observe the bank's + // confirmation, then make the bank unavailable before restarting it. + await bankClient.confirmWithdrawalOperation(user.username, { + withdrawalOperationId: withdrawal.withdrawal_id, + }); + await exchange.runWirewatchOnce(); + + const exchangeClient = new TalerExchangeHttpClient(exchange.baseUrl); + const reserveStatus = await exchangeClient.getReserveStatus(reservePub); + t.assertDeepEqual(reserveStatus.case, "ok"); + + await bank.stop(); + await walletService.start(); + await walletService.pingUntilAvailable(); + await walletClient.connect(); + await walletClient.call(WalletApiOperation.InitWallet, { + config: { + testing: { + skipDefaults: true, + emitObservabilityEvents: false, + }, + }, + }); + + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId, + txState: { major: TransactionMajorState.Done }, + timeout: { seconds: 30 }, + }); + + const completedTx = await walletClient.call( + WalletApiOperation.GetTransactionById, + { transactionId }, + ); + t.assertTrue(completedTx.type === TransactionType.Withdrawal); + t.assertTrue( + completedTx.withdrawalDetails.type === + WithdrawalType.TalerBankIntegrationApi, + ); + t.assertTrue(completedTx.withdrawalDetails.confirmed === false); + t.assertTrue(completedTx.withdrawalDetails.reserveIsReady === true); + + const balances = await walletClient.call(WalletApiOperation.GetBalances, {}); + t.assertAmountEquals(balances.balances[0].available, "TESTKUDOS:9.85"); +} + +runWithdrawalBankOutageTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -229,6 +229,7 @@ import { runWithdrawalAbortBankTest } from "./test-withdrawal-abort-bank.js"; import { runWithdrawalAmountTest } from "./test-withdrawal-amount.js"; import { runWithdrawalBadSuggestedExchangeTest } from "./test-withdrawal-bad-suggested-exchange.js"; import { runWithdrawalBankIntegratedTest } from "./test-withdrawal-bank-integrated.js"; +import { runWithdrawalBankOutageTest } from "./test-withdrawal-bank-outage.js"; import { runWithdrawalCashacceptorTest } from "./test-withdrawal-cashacceptor.js"; import { runWithdrawalConflictTest } from "./test-withdrawal-conflict.js"; import { runWithdrawalConversionTest } from "./test-withdrawal-conversion.js"; @@ -333,6 +334,7 @@ const allTests: TestMainFunction[] = [ runWithdrawalAbortBankTest, runWithdrawalBadSuggestedExchangeTest, runWithdrawalBankIntegratedTest, + runWithdrawalBankOutageTest, runWithdrawalFakebankTest, runWithdrawalFeesTest, runWithdrawalConversionTest, diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -61,6 +61,7 @@ import { ObservabilityEventType, Paytos, PrepareBankIntegratedWithdrawalResponse, + ReserveStatus, Result, ScopeInfo, TalerBankConversionHttpClient, @@ -2210,7 +2211,6 @@ async function processQueryReserve( wex: WalletExecutionContext, withdrawalGroupId: string, ): Promise<TaskRunResult> { - const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); const withdrawalGroup = await getWithdrawalGroupRecordTx(wex, { withdrawalGroupId, }); @@ -2224,27 +2224,32 @@ async function processQueryReserve( withdrawalGroup.exchangeBaseUrl !== undefined, "can't get funding uri from uninitialized wg", ); - checkDbInvariant( - withdrawalGroup.denomsSel !== undefined, - "can't process uninitialized exchange", - ); - checkDbInvariant( - withdrawalGroup.instructedAmount !== undefined, - "can't process uninitialized exchange", - ); + const resp = await queryReserveStatus(wex, withdrawalGroup); - const instructedAmount = withdrawalGroup.instructedAmount; - const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; - const currency = Amounts.currencyOf(withdrawalGroup.instructedAmount); + if (resp.case === HttpStatusCode.NotFound) { + logger.trace(`got reserve status error (not found)`); + return TaskRunResult.longpollReturnedPending(); + } - const relevantDenoms = await getWithdrawalCandidateDenoms( - wex, - exchangeBaseUrl, - instructedAmount, - ); + return await processFundedReserve(wex, withdrawalGroup, resp.body, undefined); +} +/** + * Long-poll the exchange for a withdrawal group's reserve. + * + * This deliberately performs no database updates. Bank-integrated + * withdrawals can therefore run it concurrently with the bank status poll + * and decide which result is authoritative after both requests have settled. + */ +async function queryReserveStatus( + wex: WalletExecutionContext, + withdrawalGroup: WalletWithdrawalGroup, +) { + checkDbInvariant( + withdrawalGroup.exchangeBaseUrl !== undefined, + "can't get funding uri from uninitialized wg", + ); const reservePub = withdrawalGroup.reservePub; - const exchangeClient = walletExchangeClient( withdrawalGroup.exchangeBaseUrl, wex, @@ -2253,30 +2258,70 @@ async function processQueryReserve( logger.trace(`querying reserve status for ${reservePub}`); const resp = await exchangeClient.getReserveStatus(reservePub, true); - logger.trace(`reserve status code: HTTP ${resp.response.status}`); + return resp; +} - if (resp.case === HttpStatusCode.NotFound) { - logger.trace(`got reserve status error (not found)`); - return TaskRunResult.longpollReturnedPending(); - } +/** + * Record a funded reserve and move the withdrawal on to coin withdrawal. + * + * A successful reserve response is authoritative: the wallet controls the + * reserve key and can recover the value even when the bank no longer answers. + * The optional bank confirmation preserves the bank's amount and confirmation + * timestamp when both long-polls succeeded together. + */ +async function processFundedReserve( + wex: WalletExecutionContext, + withdrawalGroup: WalletWithdrawalGroup, + reserveStatus: ReserveStatus, + bankConfirmation: { amount: AmountString | undefined } | undefined, +): Promise<TaskRunResult> { + const ctx = new WithdrawTransactionContext( + wex, + withdrawalGroup.withdrawalGroupId, + ); + checkDbInvariant( + withdrawalGroup.exchangeBaseUrl !== undefined, + "can't get funding uri from uninitialized wg", + ); + const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; + const reserveBalance = Amounts.stringify(reserveStatus.balance); + const currency = Amounts.currencyOf(reserveBalance); - const reserveStatus = resp.body; logger.trace(`got reserve status ${j2s(reserveStatus)}`); + let denomsSel = withdrawalGroup.denomsSel; + let instructedAmount = withdrawalGroup.instructedAmount; + if (denomsSel === undefined) { + const selectionAmount = bankConfirmation?.amount ?? reserveBalance; + denomsSel = await getInitialDenomsSelection( + wex, + exchangeBaseUrl, + Amounts.parseOrThrow(selectionAmount), + undefined, + ); + instructedAmount = denomsSel.totalWithdrawCost; + } + + checkDbInvariant( + instructedAmount !== undefined, + "can't process uninitialized withdrawal amount", + ); + + // Ensure that candidate denominations have been validated before using the + // selection. The returned list is intentionally not needed here. + await getWithdrawalCandidateDenoms(wex, exchangeBaseUrl, instructedAmount); + // We only allow changing the amount *down*, so that user error // in the wire transfer won't result in a giant withdrawal. // See https://bugs.taler.net/n/9732 // We also re-select when the initial selection had zero coins // (skipped denoms are not counted). - let amountChanged = - Amounts.cmp( - reserveStatus.balance, - withdrawalGroup.denomsSel.totalWithdrawCost, - ) === -1; + const amountChanged = + Amounts.cmp(reserveStatus.balance, denomsSel.totalWithdrawCost) === -1; let numActiveDenoms = 0; - for (const sd of withdrawalGroup.denomsSel.selectedDenoms) { + for (const sd of denomsSel.selectedDenoms) { numActiveDenoms += sd.count - (sd.skip ?? 0); } const redoSelection = amountChanged || numActiveDenoms === 0; @@ -2284,23 +2329,26 @@ async function processQueryReserve( if (redoSelection) { // If we change the denom selection, make sure we have // fresh info about the exchange. - await fetchFreshExchange(wex, withdrawalGroup.exchangeBaseUrl, { + await fetchFreshExchange(wex, exchangeBaseUrl, { forceUpdate: redoSelection, }); - await updateWithdrawalDenomsForExchange( - wex, - withdrawalGroup.exchangeBaseUrl, - ); + await updateWithdrawalDenomsForExchange(wex, exchangeBaseUrl); } return await ctx.wex.runWalletDbTx(async (tx) => { const [wg, h] = await ctx.getRecordHandle(tx); if (!wg) { - logger.warn(`withdrawal group ${withdrawalGroupId} not found`); + logger.warn( + `withdrawal group ${withdrawalGroup.withdrawalGroupId} not found`, + ); return TaskRunResult.finished(); } - if (wg.status !== WithdrawalGroupStatus.PendingQueryingStatus) { - return TaskRunResult.backoff(); + switch (wg.status) { + case WithdrawalGroupStatus.PendingQueryingStatus: + case WithdrawalGroupStatus.PendingWaitConfirmBank: + break; + default: + return TaskRunResult.backoff(); } const lastOrigin = reserveStatus.last_origin; // If the withdrawal had external confirmation, we don't store the @@ -2326,9 +2374,24 @@ async function processQueryReserve( wg.denomsSel = denomsSel; wg.rawWithdrawalAmount = denomsSel.totalWithdrawCost; wg.effectiveWithdrawalAmount = denomsSel.totalCoinValue; + } else if (wg.denomsSel === undefined) { + wg.denomsSel = denomsSel; + wg.rawWithdrawalAmount = denomsSel.totalWithdrawCost; + wg.effectiveWithdrawalAmount = denomsSel.totalCoinValue; + } + if (wg.instructedAmount === undefined) { + wg.instructedAmount = instructedAmount; + } + if ( + bankConfirmation !== undefined && + wg.wgInfo.withdrawalType === WithdrawalRecordType.BankIntegrated && + wg.wgInfo.bankInfo.timestampBankConfirmed === undefined + ) { + const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()); + wg.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now); } wg.status = WithdrawalGroupStatus.PendingReady; - wg.reserveBalanceAmount = Amounts.stringify(reserveStatus.balance); + wg.reserveBalanceAmount = reserveBalance; await h.update(wg, "query-reserve"); return TaskRunResult.progress(); }); @@ -3651,15 +3714,51 @@ async function processReserveBankStatus( }, ); - const status = succeedOrThrow( - await bankClient.getWithdrawalOperationById( - uriResult.withdrawalOperationId, - { - old_state: "selected", - timeoutMs: 30000, - }, - ), - ); + // Both endpoints describe the same transfer from opposite sides. Poll + // them in one task invocation so that a bank outage after the transfer was + // submitted cannot keep already-arrived reserve funds stranded. + const [bankPoll, reservePoll] = await Promise.allSettled([ + bankClient.getWithdrawalOperationById(uriResult.withdrawalOperationId, { + old_state: "selected", + timeoutMs: 30000, + }), + queryReserveStatus(wex, withdrawalGroup), + ]); + + const bankStatus = + bankPoll.status === "fulfilled" && bankPoll.value.case === "ok" + ? bankPoll.value.body + : undefined; + + // A funded reserve is definitive. Prefer it even if the bank request + // failed or reports an abort: the wallet controls the reserve key and must + // recover the value that reached the exchange. + if (reservePoll.status === "fulfilled" && reservePoll.value.case === "ok") { + return await processFundedReserve( + wex, + withdrawalGroup, + reservePoll.value.body, + bankStatus?.status === "confirmed" + ? { amount: bankStatus.amount } + : undefined, + ); + } + + if (reservePoll.status === "rejected") { + // Until the bank confirms the transfer, exchange failures are secondary: + // keep them out of the transaction's single user-visible retry record. + logger.warn( + `reserve long-poll failed while waiting for bank confirmation: ${j2s( + getErrorDetailFromException(reservePoll.reason), + )}`, + ); + } + + if (bankPoll.status === "rejected") { + throw bankPoll.reason; + } + + const status = succeedOrThrow(bankPoll.value); if (logger.shouldLogTrace()) { logger.trace(`response body: ${j2s(status)}`);