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:
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)}`);