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:
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 {