taler-typescript-core

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

commit 3666f260e5b1dfbd272d0f8042326eadb97e1706
parent 3db1be2a73f94e27d4085ac751457546624204ff
Author: Florian Dold <dold@taler.net>
Date:   Tue,  1 Sep 2026 19:53:06 +0200

wallet-core: stop waiting once bank confirms withdrawal

Diffstat:
Mpackages/taler-wallet-core/src/wallet.ts | 3++-
Mpackages/taler-wallet-core/src/withdraw.ts | 251++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
2 files changed, 156 insertions(+), 98 deletions(-)

diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -343,10 +343,11 @@ export function walletExchangeClient( baseUrl: string, wex: WalletExecutionContext, timeout?: Duration, + cancellationToken: CancellationToken = wex.cancellationToken, ): TalerExchangeHttpClient { return new TalerExchangeHttpClient(baseUrl, { httpClient: wex.http, - cancelationToken: wex.cancellationToken, + cancelationToken: cancellationToken, longPollQueue: wex.ws.longpollQueue, timeout, }); diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -29,12 +29,14 @@ import { AmountLike, AmountString, Amounts, + BankWithdrawalOperationStatus, BankWithdrawDetails, BlindedDenominationSignature, CoinEnvelope, CoinStatus, ConfirmWithdrawalRequest, CurrencySpecification, + CancellationToken, DenomKeyType, DenomSelItem, DenomSelectionState, @@ -2243,6 +2245,7 @@ async function processQueryReserve( async function queryReserveStatus( wex: WalletExecutionContext, withdrawalGroup: WalletWithdrawalGroup, + cancellationToken: CancellationToken = wex.cancellationToken, ) { checkDbInvariant( withdrawalGroup.exchangeBaseUrl !== undefined, @@ -2253,6 +2256,7 @@ async function queryReserveStatus( withdrawalGroup.exchangeBaseUrl, wex, getReserveRequestTimeout(withdrawalGroup), + cancellationToken, ); logger.trace(`querying reserve status for ${reservePub}`); @@ -3643,6 +3647,61 @@ async function processBankRegisterReserve( ); } +async function processConfirmedBankWithdrawal( + wex: WalletExecutionContext, + ctx: WithdrawTransactionContext, + withdrawalGroup: WalletWithdrawalGroup, + status: BankWithdrawalOperationStatus, +): Promise<TaskRunResult> { + let denomSel: undefined | DenomSelectionState = undefined; + + if (withdrawalGroup.denomsSel == null) { + const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; + if (!exchangeBaseUrl) { + throw Error("invalid state"); + } + if (!status.amount) { + throw Error("bank did not provide amount"); + } + const instructedAmount = Amounts.parseOrThrow(status.amount); + denomSel = await getInitialDenomsSelection( + wex, + exchangeBaseUrl, + instructedAmount, + undefined, + ); + } + + return await ctx.wex.runWalletDbTx(async (tx) => { + const [r, h] = await ctx.getRecordHandle(tx); + if (!r) { + return TaskRunResult.finished(); + } + // Re-check reserve status within transaction + switch (r.status) { + case WithdrawalGroupStatus.PendingWaitConfirmBank: + break; + default: + return TaskRunResult.backoff(); + } + if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) { + throw Error("invariant failed"); + } + logger.info("withdrawal: transfer confirmed by bank."); + const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()); + r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now); + r.status = WithdrawalGroupStatus.PendingQueryingStatus; + if (denomSel != null) { + r.denomsSel = denomSel; + r.rawWithdrawalAmount = denomSel.totalWithdrawCost; + r.effectiveWithdrawalAmount = denomSel.totalCoinValue; + r.instructedAmount = denomSel.totalWithdrawCost; + } + await h.update(r, "reserve-bank-status"); + return TaskRunResult.progress(); + }); +} + async function processReserveBankStatus( wex: WalletExecutionContext, withdrawalGroupId: string, @@ -3676,124 +3735,122 @@ async function processReserveBankStatus( if (!uriResult) { throw Error(`can't parse withdrawal URL ${bankInfo.talerWithdrawUri}`); } + const pollCts = CancellationToken.create(); + const pollCancellation = CancellationToken.race( + wex.cancellationToken, + pollCts.token, + ); const bankClient = new TalerBankIntegrationHttpClient( uriResult.bankIntegrationApiBaseUrl, { httpClient: wex.http, - cancellationToken: wex.cancellationToken, + cancellationToken: pollCancellation, timeout: getReserveRequestTimeout(withdrawalGroup), }, ); - // 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, { + // Both endpoints describe the same transfer from opposite sides. A + // confirmed bank transfer is enough to make progress without waiting for + // the exchange. For every other bank outcome we still wait for the reserve, + // since arrived funds are authoritative even if the bank reports an abort + // or an outage. + const bankPromise = 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 reservePromise = queryReserveStatus( + wex, + withdrawalGroup, + pollCancellation, + ); + try { + const firstPoll = await Promise.race([ + bankPromise.then( + (value) => ({ source: "bank" as const, value }), + (reason) => ({ source: "bank-error" as const, reason }), + ), + reservePromise.then( + (value) => ({ source: "reserve" as const, value }), + (reason) => ({ source: "reserve-error" as const, reason }), + ), + ]); - const status = succeedOrThrow(bankPoll.value); + if ( + firstPoll.source === "bank" && + firstPoll.value.case === "ok" && + firstPoll.value.body.status === "confirmed" + ) { + return await processConfirmedBankWithdrawal( + wex, + ctx, + withdrawalGroup, + firstPoll.value.body, + ); + } + const [bankPoll, reservePoll] = await Promise.allSettled([ + bankPromise, + reservePromise, + ]); + + 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 (logger.shouldLogTrace()) { - logger.trace(`response body: ${j2s(status)}`); - } + 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 (status.status === "aborted") { - return transitionBankAborted(ctx); - } + if (bankPoll.status === "rejected") { + throw bankPoll.reason; + } - if (status.status != "confirmed") { - return TaskRunResult.longpollReturnedPending(); - } + const status = succeedOrThrow(bankPoll.value); - let denomSel: undefined | DenomSelectionState = undefined; + if (logger.shouldLogTrace()) { + logger.trace(`response body: ${j2s(status)}`); + } - if (withdrawalGroup.denomsSel == null) { - const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; - if (!exchangeBaseUrl) { - throw Error("invalid state"); + if (status.status === "aborted") { + return transitionBankAborted(ctx); } - if (!status.amount) { - throw Error("bank did not provide amount"); + + if (status.status != "confirmed") { + return TaskRunResult.longpollReturnedPending(); } - const instructedAmount = Amounts.parseOrThrow(status.amount); - denomSel = await getInitialDenomsSelection( + + return await processConfirmedBankWithdrawal( wex, - exchangeBaseUrl, - instructedAmount, - undefined, + ctx, + withdrawalGroup, + status, ); + } finally { + pollCts.cancel("reserve/bank status polling finished"); } - - return await ctx.wex.runWalletDbTx(async (tx) => { - const [r, h] = await ctx.getRecordHandle(tx); - if (!r) { - return TaskRunResult.finished(); - } - // Re-check reserve status within transaction - switch (r.status) { - case WithdrawalGroupStatus.PendingWaitConfirmBank: - break; - default: - return TaskRunResult.backoff(); - } - if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) { - throw Error("invariant failed"); - } - if (status.status == "confirmed") { - logger.info("withdrawal: transfer confirmed by bank."); - const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()); - r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now); - r.status = WithdrawalGroupStatus.PendingQueryingStatus; - if (denomSel != null) { - r.denomsSel = denomSel; - r.rawWithdrawalAmount = denomSel.totalWithdrawCost; - r.effectiveWithdrawalAmount = denomSel.totalCoinValue; - r.instructedAmount = denomSel.totalWithdrawCost; - } - await h.update(r, "reserve-bank-status"); - return TaskRunResult.progress(); - } else { - return TaskRunResult.backoff(); - } - }); } export interface PrepareCreateWithdrawalGroupResult {