taler-typescript-core

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

commit fc2b4c6b7baa2810571f6e308e5a97e65ab57a79
parent a5603fd32c3775b64979389c4d73bb65e17e5f5e
Author: Florian Dold <florian@dold.me>
Date:   Wed, 18 Mar 2026 17:38:40 +0100

wallet-core: refactor withdrawal transitions

Diffstat:
Mpackages/taler-wallet-core/src/common.ts | 37-------------------------------------
Mpackages/taler-wallet-core/src/withdraw.ts | 897++++++++++++++++++++++++++++++++++---------------------------------------------
2 files changed, 386 insertions(+), 548 deletions(-)

diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts @@ -824,43 +824,6 @@ export namespace TaskIdentifiers { } /** - * Result of a transaction transition. - */ -export enum TransitionResultType { - Transition = 1, - Stay = 2, - Delete = 3, -} - -export type TransitionResult<R> = - | { type: TransitionResultType.Stay } - | { - type: TransitionResultType.Transition; - rec: R; - balanceEffect: BalanceEffect; - } - | { type: TransitionResultType.Delete }; - -export const TransitionResult = { - stay<T>(): TransitionResult<T> { - return { type: TransitionResultType.Stay }; - }, - delete<T>(): TransitionResult<T> { - return { type: TransitionResultType.Delete }; - }, - transition<T>( - rec: T, - balanceEffect: BalanceEffect = BalanceEffect.Any, - ): TransitionResult<T> { - return { - type: TransitionResultType.Transition, - rec, - balanceEffect, - }; - }, -}; - -/** * Transaction context. * Uniform interface to all transactions. */ diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -116,17 +116,17 @@ import { } from "@gnu-taler/taler-util/http"; import { PendingTaskType, + RecordHandle, TaskIdStr, TaskRunResult, TaskRunResultType, TransactionContext, - TransitionResult, - TransitionResultType, cancelableFetch, cancelableLongPoll, constructTaskIdentifier, genericWaitForState, genericWaitForStateVal, + getGenericRecordHandle, makeCoinAvailable, makeCoinsVisible, requireExchangeTosAcceptedOrThrow, @@ -144,8 +144,6 @@ import { WalletDbAllStoresReadOnlyTransaction, WalletDbReadOnlyTransaction, WalletDbReadWriteTransaction, - WalletDbStoresArr, - WalletStoresV1, WgInfo, WithdrawalGroupRecord, WithdrawalGroupStatus, @@ -187,7 +185,6 @@ import { isKycOperationDue, runKycCheckAlgo, } from "./kyc.js"; -import { DbAccess } from "./query.js"; import { BalanceEffect, TransitionInfo, @@ -487,102 +484,27 @@ export class WithdrawTransactionContext implements TransactionContext { // FIXME: Handle orphaned withdrawals where the p2p or recoup tx was deleted? } - /** - * Transition a withdrawal transaction. - * Extra object stores may be accessed during the transition. - */ - async transition<StoreNameArray extends WalletDbStoresArr = []>( - opts: { extraStores?: StoreNameArray; transactionLabel?: string }, - f: ( - rec: WithdrawalGroupRecord | undefined, - tx: WalletDbReadWriteTransaction< - [ - "withdrawalGroups", - "transactionsMeta", - "operationRetries", - "exchanges", - "exchangeDetails", - ...StoreNameArray, - ] - >, - ) => Promise<TransitionResult<WithdrawalGroupRecord>>, - ): Promise<boolean> { - const baseStores = [ - "withdrawalGroups" as const, - "transactionsMeta" as const, - "operationRetries" as const, - "exchanges" as const, - "exchangeDetails" as const, - ]; - const stores = opts.extraStores - ? [...baseStores, ...opts.extraStores] - : baseStores; - - let errorThrown: Error | undefined; - const didTransition: boolean = await this.wex.runLegacyWalletDbTx( - async (tx) => { - const wgRec = await tx.withdrawalGroups.get(this.withdrawalGroupId); - let oldTxState: TransactionState; - let oldStId: number; - if (wgRec) { - oldTxState = computeWithdrawalTransactionStatus(wgRec); - oldStId = wgRec.status; - } else { - oldTxState = { - major: TransactionMajorState.None, - }; - oldStId = 0; - } - let res: TransitionResult<WithdrawalGroupRecord> | undefined; - try { - res = await f(wgRec, tx); - } catch (error) { - if (error instanceof Error) { - errorThrown = error; - } - return false; - } - - switch (res.type) { - case TransitionResultType.Transition: { - await tx.withdrawalGroups.put(res.rec); - await this.updateTransactionMeta(tx); - const newTxState = computeWithdrawalTransactionStatus(res.rec); - applyNotifyTransition(tx.notify, this.transactionId, { - oldTxState, - newTxState, - balanceEffect: res.balanceEffect, - oldStId, - newStId: res.rec.status, - }); - return true; - } - case TransitionResultType.Delete: - await tx.withdrawalGroups.delete(this.withdrawalGroupId); - await this.updateTransactionMeta(tx); - applyNotifyTransition(tx.notify, this.transactionId, { - oldTxState, - newTxState: { - major: TransactionMajorState.None, - }, - balanceEffect: BalanceEffect.Any, - oldStId, - newStId: -1, - }); - return true; - default: - return false; - } + async getRecordHandle( + tx: WalletDbReadWriteTransaction<["withdrawalGroups", "transactionsMeta"]>, + ): Promise< + [WithdrawalGroupRecord | undefined, RecordHandle<WithdrawalGroupRecord>] + > { + return getGenericRecordHandle<WithdrawalGroupRecord>( + this, + tx as any, + async () => tx.withdrawalGroups.get(this.withdrawalGroupId), + async (r) => { + await tx.withdrawalGroups.put(r); }, + async () => tx.withdrawalGroups.delete(this.withdrawalGroupId), + (r) => computeWithdrawalTransactionStatus(r), + (r) => r.status, + () => this.updateTransactionMeta(tx), ); - if (errorThrown) { - throw errorThrown; - } - return didTransition; } async deleteTransaction(): Promise<void> { - const res = await this.wex.runLegacyWalletDbTx(async (tx) => { + await this.wex.runLegacyWalletDbTx(async (tx) => { return this.deleteTransactionInTx(tx); }); } @@ -620,178 +542,160 @@ export class WithdrawTransactionContext implements TransactionContext { async suspendTransaction(): Promise<void> { const { withdrawalGroupId } = this; - await this.transition( - { - transactionLabel: "suspend-transaction-withdraw", - }, - async (wg, _tx) => { - if (!wg) { - logger.warn(`withdrawal group ${withdrawalGroupId} not found`); - return TransitionResult.stay(); - } - let newStatus: WithdrawalGroupStatus | undefined = undefined; - switch (wg.status) { - case WithdrawalGroupStatus.PendingReady: - newStatus = WithdrawalGroupStatus.SuspendedReady; - break; - case WithdrawalGroupStatus.AbortingBank: - newStatus = WithdrawalGroupStatus.SuspendedAbortingBank; - break; - case WithdrawalGroupStatus.PendingWaitConfirmBank: - newStatus = WithdrawalGroupStatus.SuspendedWaitConfirmBank; - break; - case WithdrawalGroupStatus.PendingRegisteringBank: - newStatus = WithdrawalGroupStatus.SuspendedRegisteringBank; - break; - case WithdrawalGroupStatus.PendingQueryingStatus: - newStatus = WithdrawalGroupStatus.SuspendedQueryingStatus; - break; - case WithdrawalGroupStatus.PendingKyc: - newStatus = WithdrawalGroupStatus.SuspendedKyc; - break; - default: - logger.warn( - `Unsupported 'suspend' on withdrawal transaction in status ${wg.status}`, - ); - return TransitionResult.stay(); - } - wg.status = newStatus; - return TransitionResult.transition(wg); - }, - ); + await this.wex.runLegacyWalletDbTx(async (tx) => { + const [wg, h] = await this.getRecordHandle(tx); + if (!wg) { + logger.warn(`withdrawal group ${withdrawalGroupId} not found`); + return; + } + let newStatus: WithdrawalGroupStatus | undefined = undefined; + switch (wg.status) { + case WithdrawalGroupStatus.PendingReady: + newStatus = WithdrawalGroupStatus.SuspendedReady; + break; + case WithdrawalGroupStatus.AbortingBank: + newStatus = WithdrawalGroupStatus.SuspendedAbortingBank; + break; + case WithdrawalGroupStatus.PendingWaitConfirmBank: + newStatus = WithdrawalGroupStatus.SuspendedWaitConfirmBank; + break; + case WithdrawalGroupStatus.PendingRegisteringBank: + newStatus = WithdrawalGroupStatus.SuspendedRegisteringBank; + break; + case WithdrawalGroupStatus.PendingQueryingStatus: + newStatus = WithdrawalGroupStatus.SuspendedQueryingStatus; + break; + case WithdrawalGroupStatus.PendingKyc: + newStatus = WithdrawalGroupStatus.SuspendedKyc; + break; + default: + logger.warn( + `Unsupported 'suspend' on withdrawal transaction in status ${wg.status}`, + ); + return; + } + wg.status = newStatus; + await h.update(wg); + }); } async abortTransaction(reason?: TalerErrorDetail): Promise<void> { const { withdrawalGroupId } = this; - await this.transition( - { - transactionLabel: "abort-transaction-withdraw", - }, - async (wg, _tx) => { - // FIXME: When aborting a partially succeeded withdrawal, - // we need to mark already withdrawn coins as visible. - if (!wg) { - logger.warn(`withdrawal group ${withdrawalGroupId} not found`); - return TransitionResult.stay(); - } - let newStatus: WithdrawalGroupStatus | undefined = undefined; - switch (wg.status) { - case WithdrawalGroupStatus.SuspendedRegisteringBank: - case WithdrawalGroupStatus.SuspendedWaitConfirmBank: - case WithdrawalGroupStatus.PendingWaitConfirmBank: - case WithdrawalGroupStatus.PendingRegisteringBank: - newStatus = WithdrawalGroupStatus.AbortingBank; - break; - case WithdrawalGroupStatus.SuspendedKyc: - case WithdrawalGroupStatus.SuspendedQueryingStatus: - case WithdrawalGroupStatus.SuspendedReady: - case WithdrawalGroupStatus.PendingKyc: - case WithdrawalGroupStatus.PendingQueryingStatus: - case WithdrawalGroupStatus.PendingBalanceKyc: - case WithdrawalGroupStatus.SuspendedBalanceKyc: - case WithdrawalGroupStatus.PendingBalanceKycInit: - case WithdrawalGroupStatus.SuspendedBalanceKycInit: - newStatus = WithdrawalGroupStatus.AbortedExchange; - break; - case WithdrawalGroupStatus.PendingReady: - case WithdrawalGroupStatus.SuspendedRedenominate: - case WithdrawalGroupStatus.PendingRedenominate: - newStatus = WithdrawalGroupStatus.SuspendedReady; - break; - case WithdrawalGroupStatus.SuspendedAbortingBank: - case WithdrawalGroupStatus.AbortingBank: - case WithdrawalGroupStatus.AbortedUserRefused: - // No transition needed, but not an error - return TransitionResult.stay(); - case WithdrawalGroupStatus.DialogProposed: - newStatus = WithdrawalGroupStatus.AbortedUserRefused; - break; - case WithdrawalGroupStatus.Done: - case WithdrawalGroupStatus.FailedBankAborted: - case WithdrawalGroupStatus.AbortedExchange: - case WithdrawalGroupStatus.AbortedBank: - case WithdrawalGroupStatus.FailedAbortingBank: - case WithdrawalGroupStatus.AbortedOtherWallet: - // Not allowed - throw Error("abort not allowed in current state"); - default: - assertUnreachable(wg.status); - } - wg.abortReason = reason; - wg.status = newStatus; - return TransitionResult.transition(wg); - }, - ); + await this.wex.runLegacyWalletDbTx(async (tx) => { + const [wg, h] = await this.getRecordHandle(tx); + if (!wg) { + logger.warn(`withdrawal group ${withdrawalGroupId} not found`); + return; + } + let newStatus: WithdrawalGroupStatus | undefined = undefined; + switch (wg.status) { + case WithdrawalGroupStatus.SuspendedRegisteringBank: + case WithdrawalGroupStatus.SuspendedWaitConfirmBank: + case WithdrawalGroupStatus.PendingWaitConfirmBank: + case WithdrawalGroupStatus.PendingRegisteringBank: + newStatus = WithdrawalGroupStatus.AbortingBank; + break; + case WithdrawalGroupStatus.SuspendedKyc: + case WithdrawalGroupStatus.SuspendedQueryingStatus: + case WithdrawalGroupStatus.SuspendedReady: + case WithdrawalGroupStatus.PendingKyc: + case WithdrawalGroupStatus.PendingQueryingStatus: + case WithdrawalGroupStatus.PendingBalanceKyc: + case WithdrawalGroupStatus.SuspendedBalanceKyc: + case WithdrawalGroupStatus.PendingBalanceKycInit: + case WithdrawalGroupStatus.SuspendedBalanceKycInit: + newStatus = WithdrawalGroupStatus.AbortedExchange; + break; + case WithdrawalGroupStatus.PendingReady: + case WithdrawalGroupStatus.SuspendedRedenominate: + case WithdrawalGroupStatus.PendingRedenominate: + newStatus = WithdrawalGroupStatus.SuspendedReady; + break; + case WithdrawalGroupStatus.SuspendedAbortingBank: + case WithdrawalGroupStatus.AbortingBank: + case WithdrawalGroupStatus.AbortedUserRefused: + // No transition needed, but not an error + return; + case WithdrawalGroupStatus.DialogProposed: + newStatus = WithdrawalGroupStatus.AbortedUserRefused; + break; + case WithdrawalGroupStatus.Done: + case WithdrawalGroupStatus.FailedBankAborted: + case WithdrawalGroupStatus.AbortedExchange: + case WithdrawalGroupStatus.AbortedBank: + case WithdrawalGroupStatus.FailedAbortingBank: + case WithdrawalGroupStatus.AbortedOtherWallet: + // Not allowed + throw Error("abort not allowed in current state"); + default: + assertUnreachable(wg.status); + } + wg.abortReason = reason; + wg.status = newStatus; + await h.update(wg); + }); } async resumeTransaction(): Promise<void> { const { withdrawalGroupId } = this; - await this.transition( - { - transactionLabel: "resume-transaction-withdraw", - }, - async (wg, _tx) => { - if (!wg) { - logger.warn(`withdrawal group ${withdrawalGroupId} not found`); - return TransitionResult.stay(); - } - let newStatus: WithdrawalGroupStatus | undefined = undefined; - switch (wg.status) { - case WithdrawalGroupStatus.SuspendedReady: - newStatus = WithdrawalGroupStatus.PendingReady; - break; - case WithdrawalGroupStatus.SuspendedAbortingBank: - newStatus = WithdrawalGroupStatus.AbortingBank; - break; - case WithdrawalGroupStatus.SuspendedWaitConfirmBank: - newStatus = WithdrawalGroupStatus.PendingWaitConfirmBank; - break; - case WithdrawalGroupStatus.SuspendedQueryingStatus: - newStatus = WithdrawalGroupStatus.PendingQueryingStatus; - break; - case WithdrawalGroupStatus.SuspendedRegisteringBank: - newStatus = WithdrawalGroupStatus.PendingRegisteringBank; - break; - case WithdrawalGroupStatus.SuspendedKyc: - newStatus = WithdrawalGroupStatus.PendingKyc; - break; - default: - logger.warn( - `Unsupported 'resume' on withdrawal transaction in status ${wg.status}`, - ); - return TransitionResult.stay(); - } - wg.status = newStatus; - return TransitionResult.transition(wg); - }, - ); + await this.wex.runLegacyWalletDbTx(async (tx) => { + const [wg, h] = await this.getRecordHandle(tx); + if (!wg) { + logger.warn(`withdrawal group ${withdrawalGroupId} not found`); + return; + } + let newStatus: WithdrawalGroupStatus | undefined = undefined; + switch (wg.status) { + case WithdrawalGroupStatus.SuspendedReady: + newStatus = WithdrawalGroupStatus.PendingReady; + break; + case WithdrawalGroupStatus.SuspendedAbortingBank: + newStatus = WithdrawalGroupStatus.AbortingBank; + break; + case WithdrawalGroupStatus.SuspendedWaitConfirmBank: + newStatus = WithdrawalGroupStatus.PendingWaitConfirmBank; + break; + case WithdrawalGroupStatus.SuspendedQueryingStatus: + newStatus = WithdrawalGroupStatus.PendingQueryingStatus; + break; + case WithdrawalGroupStatus.SuspendedRegisteringBank: + newStatus = WithdrawalGroupStatus.PendingRegisteringBank; + break; + case WithdrawalGroupStatus.SuspendedKyc: + newStatus = WithdrawalGroupStatus.PendingKyc; + break; + default: + logger.warn( + `Unsupported 'resume' on withdrawal transaction in status ${wg.status}`, + ); + return; + } + wg.status = newStatus; + await h.update(wg); + }); } async failTransaction(reason?: TalerErrorDetail): Promise<void> { const { withdrawalGroupId } = this; - await this.transition( - { - transactionLabel: "fail-transaction-withdraw", - }, - async (wg, _tx) => { - if (!wg) { - logger.warn(`withdrawal group ${withdrawalGroupId} not found`); - return TransitionResult.stay(); - } - let newStatus: WithdrawalGroupStatus | undefined = undefined; - switch (wg.status) { - case WithdrawalGroupStatus.SuspendedAbortingBank: - case WithdrawalGroupStatus.AbortingBank: - newStatus = WithdrawalGroupStatus.FailedAbortingBank; - break; - default: - return TransitionResult.stay(); - } - wg.status = newStatus; - wg.failReason = reason; - return TransitionResult.transition(wg); - }, - ); + await this.wex.runLegacyWalletDbTx(async (tx) => { + const [wg, h] = await this.getRecordHandle(tx); + if (!wg) { + logger.warn(`withdrawal group ${withdrawalGroupId} not found`); + return; + } + let newStatus: WithdrawalGroupStatus | undefined = undefined; + switch (wg.status) { + case WithdrawalGroupStatus.SuspendedAbortingBank: + case WithdrawalGroupStatus.AbortingBank: + newStatus = WithdrawalGroupStatus.FailedAbortingBank; + break; + default: + return; + } + wg.status = newStatus; + wg.failReason = reason; + await h.update(wg); + }); } } @@ -1065,20 +969,18 @@ async function processWithdrawalGroupRedenominate( exchangeBaseUrl, withdrawalGroup.withdrawalGroupId, ); - const didTransition = await ctx.transition({}, async (rec) => { + return await ctx.wex.runLegacyWalletDbTx(async (tx) => { + const [rec, h] = await ctx.getRecordHandle(tx); switch (rec?.status) { case WithdrawalGroupStatus.PendingRedenominate: break; default: - return TransitionResult.stay(); + return TaskRunResult.backoff(); } rec.status = WithdrawalGroupStatus.PendingReady; - return TransitionResult.transition(rec); + await h.update(rec); + return TaskRunResult.progress(); }); - if (!didTransition) { - return TaskRunResult.backoff(); - } - return TaskRunResult.progress(); } async function processWithdrawalGroupBalanceKyc( @@ -1144,43 +1046,41 @@ async function processWithdrawalGroupBalanceKyc( }); if (ret.result === "ok") { - const transitionInfo = await ctx.transition({}, async (wg) => { + return await ctx.wex.runLegacyWalletDbTx(async (tx) => { + const [wg, h] = await ctx.getRecordHandle(tx); if (!wg) { - return TransitionResult.stay(); + return TaskRunResult.finished(); } switch (wg.status) { case WithdrawalGroupStatus.PendingBalanceKyc: case WithdrawalGroupStatus.PendingBalanceKycInit: { wg.status = WithdrawalGroupStatus.PendingReady; - return TransitionResult.transition(wg); + await h.update(wg); + return TaskRunResult.progress(); } default: { - return TransitionResult.stay(); + return TaskRunResult.backoff(); } } }); - if (transitionInfo) { - return TaskRunResult.progress(); - } else { - return TaskRunResult.backoff(); - } } else if ( withdrawalGroup.status === WithdrawalGroupStatus.PendingBalanceKycInit && ret.walletKycStatus === ExchangeWalletKycStatus.Legi ) { - await ctx.transition({}, async (wg) => { + return await ctx.wex.runLegacyWalletDbTx(async (tx) => { + const [wg, h] = await ctx.getRecordHandle(tx); if (!wg) { - return TransitionResult.stay(); + return TaskRunResult.finished(); } if (wg.status !== WithdrawalGroupStatus.PendingBalanceKycInit) { - return TransitionResult.stay(); + return TaskRunResult.backoff(); } wg.status = WithdrawalGroupStatus.PendingBalanceKyc; wg.kycAccessToken = ret.walletKycAccessToken; delete wg.kycPaytoHash; - return TransitionResult.transition(wg); + await h.update(wg); + return TaskRunResult.progress(); }); - return TaskRunResult.progress(); } else { throw Error("not reached"); } @@ -1197,14 +1097,14 @@ async function transitionSimple( from: WithdrawalGroupStatus, to: WithdrawalGroupStatus, ): Promise<void> { - await ctx.transition({}, async (rec) => { + await ctx.wex.runLegacyWalletDbTx(async (tx) => { + const [rec, h] = await ctx.getRecordHandle(tx); switch (rec?.status) { case from: { rec.status = to; - return TransitionResult.transition(rec); + await h.update(rec); } } - return TransitionResult.stay(); }); } @@ -1598,38 +1498,34 @@ async function transitionKycRequired( const withdrawalGroupId = withdrawalGroup.withdrawalGroupId; const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); - await ctx.transition( - { - extraStores: ["planchets"], - }, - async (wg2, tx) => { - if (!wg2) { - return TransitionResult.stay(); - } - for (let i = startIdx; i < requestCoinIdxs.length; i++) { - const planchet = await tx.planchets.indexes.byGroupAndIndex.get([ - withdrawalGroup.withdrawalGroupId, - requestCoinIdxs[i], - ]); - if (!planchet) { - continue; - } - planchet.planchetStatus = PlanchetStatus.KycRequired; - await tx.planchets.put(planchet); - } - switch (wg2.status) { - case WithdrawalGroupStatus.PendingReady: - case WithdrawalGroupStatus.PendingKyc: - break; - default: - return TransitionResult.stay(); + await ctx.wex.runLegacyWalletDbTx(async (tx) => { + const [wg2, h] = await ctx.getRecordHandle(tx); + if (!wg2) { + return; + } + for (let i = startIdx; i < requestCoinIdxs.length; i++) { + const planchet = await tx.planchets.indexes.byGroupAndIndex.get([ + withdrawalGroup.withdrawalGroupId, + requestCoinIdxs[i], + ]); + if (!planchet) { + continue; } - wg2.kycPaytoHash = legiRequiredResp.h_payto; - wg2.kycLastDeny = timestampPreciseToDb(TalerPreciseTimestamp.now()); - wg2.status = WithdrawalGroupStatus.PendingKyc; - return TransitionResult.transition(wg2); - }, - ); + planchet.planchetStatus = PlanchetStatus.KycRequired; + await tx.planchets.put(planchet); + } + switch (wg2.status) { + case WithdrawalGroupStatus.PendingReady: + case WithdrawalGroupStatus.PendingKyc: + break; + default: + return; + } + wg2.kycPaytoHash = legiRequiredResp.h_payto; + wg2.kycLastDeny = timestampPreciseToDb(TalerPreciseTimestamp.now()); + wg2.status = WithdrawalGroupStatus.PendingKyc; + await h.update(wg2); + }); } /** @@ -2314,64 +2210,50 @@ async function processQueryReserve( ); } - const transitionResult = await ctx.transition( - { - extraStores: [ - "denominations", - "denominationFamilies", - "bankAccountsV2", - "planchets", - ], - }, - async (wg, tx) => { - if (!wg) { - logger.warn(`withdrawal group ${withdrawalGroupId} not found`); - return TransitionResult.stay(); - } - if (wg.status !== WithdrawalGroupStatus.PendingQueryingStatus) { - return TransitionResult.stay(); - } - const lastOrigin = result.response.last_origin; - // If the withdrawal had external confirmation, we don't store the - // bank account details learned via the reserve here. - const externalConfirmation = - wg.wgInfo.withdrawalType === WithdrawalRecordType.BankIntegrated && - wg.wgInfo.bankInfo.externalConfirmation; - if (lastOrigin != null && !externalConfirmation) { - await storeKnownBankAccount(tx, currency, lastOrigin); - } - if (amountChanged) { - const planchetKeys = await tx.planchets.indexes.byGroup.getAllKeys( - wg.withdrawalGroupId, - ); - for (const pk of planchetKeys) { - await tx.planchets.delete(pk); - } - const candidates = await getWithdrawableDenomsTx( - wex, - tx, - exchangeBaseUrl, - currency, - ); - const denomsSel = selectWithdrawalDenominations( - Amounts.parseOrThrow(result.response.balance), - candidates, - ); - wg.denomsSel = denomsSel; - wg.rawWithdrawalAmount = denomsSel.totalWithdrawCost; - wg.effectiveWithdrawalAmount = denomsSel.totalCoinValue; + return await ctx.wex.runLegacyWalletDbTx(async (tx) => { + const [wg, h] = await ctx.getRecordHandle(tx); + if (!wg) { + logger.warn(`withdrawal group ${withdrawalGroupId} not found`); + return TaskRunResult.finished(); + } + if (wg.status !== WithdrawalGroupStatus.PendingQueryingStatus) { + return TaskRunResult.backoff(); + } + const lastOrigin = result.response.last_origin; + // If the withdrawal had external confirmation, we don't store the + // bank account details learned via the reserve here. + const externalConfirmation = + wg.wgInfo.withdrawalType === WithdrawalRecordType.BankIntegrated && + wg.wgInfo.bankInfo.externalConfirmation; + if (lastOrigin != null && !externalConfirmation) { + await storeKnownBankAccount(tx, currency, lastOrigin); + } + if (amountChanged) { + const planchetKeys = await tx.planchets.indexes.byGroup.getAllKeys( + wg.withdrawalGroupId, + ); + for (const pk of planchetKeys) { + await tx.planchets.delete(pk); } - wg.status = WithdrawalGroupStatus.PendingReady; - wg.reserveBalanceAmount = Amounts.stringify(result.response.balance); - return TransitionResult.transition(wg); - }, - ); - - if (transitionResult) { + const candidates = await getWithdrawableDenomsTx( + wex, + tx, + exchangeBaseUrl, + currency, + ); + const denomsSel = selectWithdrawalDenominations( + Amounts.parseOrThrow(result.response.balance), + candidates, + ); + wg.denomsSel = denomsSel; + wg.rawWithdrawalAmount = denomsSel.totalWithdrawCost; + wg.effectiveWithdrawalAmount = denomsSel.totalCoinValue; + } + wg.status = WithdrawalGroupStatus.PendingReady; + wg.reserveBalanceAmount = Amounts.stringify(result.response.balance); + await h.update(wg); return TaskRunResult.progress(); - } else { - return TaskRunResult.backoff(); - } + }); } /** @@ -2407,14 +2289,16 @@ async function processWithdrawalGroupAbortingBank( }); logger.info(`abort response status: ${abortResp.status}`); - await ctx.transition({}, async (wg) => { + await ctx.wex.runLegacyWalletDbTx(async (tx) => { + const [wg, h] = await ctx.getRecordHandle(tx); if (!wg) { - return TransitionResult.stay(); + return; } wg.status = WithdrawalGroupStatus.AbortedBank; wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now()); - return TransitionResult.transition(wg); + await h.update(wg); }); + return TaskRunResult.finished(); } @@ -2475,9 +2359,10 @@ async function processWithdrawalGroupPendingKyc( checkProtocolInvariant(algoRes.requiresAuth != true); - await ctx.transition({}, async (rec) => { + await ctx.wex.runLegacyWalletDbTx(async (tx) => { + const [rec, h] = await ctx.getRecordHandle(tx); if (!rec) { - return TransitionResult.stay(); + return; } rec.kycLastAmlReview = updatedStatus.lastAmlReview; rec.kycLastCheckStatus = updatedStatus.lastCheckStatus; @@ -2485,7 +2370,7 @@ async function processWithdrawalGroupPendingKyc( rec.kycLastDeny = updatedStatus.lastDeny; rec.kycLastRuleGen = updatedStatus.lastRuleGen; rec.kycAccessToken = updatedStatus.accessToken; - return TransitionResult.transition(rec); + await h.update(rec); }); return algoRes.taskResult; @@ -2674,13 +2559,14 @@ async function processWithdrawalGroupPendingReady( if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) { logger.warn("Finishing empty withdrawal group (no denoms)"); - await ctx.transition({}, async (wg) => { - if (!wg) { - return TransitionResult.stay(); + await ctx.wex.runLegacyWalletDbTx(async (tx) => { + const [rec, h] = await ctx.getRecordHandle(tx); + if (!rec) { + return; } - wg.status = WithdrawalGroupStatus.Done; - wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now()); - return TransitionResult.transition(wg); + rec.status = WithdrawalGroupStatus.Done; + rec.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now()); + await h.update(rec); }); return TaskRunResult.finished(); } @@ -2708,12 +2594,13 @@ async function processWithdrawalGroupPendingReady( amount: kycCheckRes.nextThreshold, exchangeBaseUrl, }); - await ctx.transition({}, async (wg) => { + await ctx.wex.runLegacyWalletDbTx(async (tx) => { + const [wg, h] = await ctx.getRecordHandle(tx); if (!wg) { - return TransitionResult.stay(); + return; } wg.status = WithdrawalGroupStatus.PendingBalanceKycInit; - return TransitionResult.transition(wg); + await h.update(wg); }); return TaskRunResult.progress(); } @@ -2839,46 +2726,43 @@ async function processWithdrawalGroupPendingReady( let numActive = 0; const maxReportedErrors = 5; - const res = await ctx.transition( - { - extraStores: ["coins", "coinAvailability", "planchets"], - }, - async (wg, tx) => { - if (!wg) { - return TransitionResult.stay(); - } + const res = await ctx.wex.runLegacyWalletDbTx(async (tx) => { + const [wg, h] = await ctx.getRecordHandle(tx); + if (!wg) { + return; + } - const groupPlanchets = - await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId); - for (const x of groupPlanchets) { - switch (x.planchetStatus) { - case PlanchetStatus.KycRequired: - case PlanchetStatus.Pending: - numActive++; - break; - case PlanchetStatus.WithdrawalDone: - break; - } - if (x.lastError) { - numPlanchetErrors++; - if (numPlanchetErrors < maxReportedErrors) { - errorsPerCoin[x.coinIdx] = x.lastError; - } + const groupPlanchets = + await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId); + for (const x of groupPlanchets) { + switch (x.planchetStatus) { + case PlanchetStatus.KycRequired: + case PlanchetStatus.Pending: + numActive++; + break; + case PlanchetStatus.WithdrawalDone: + break; + } + if (x.lastError) { + numPlanchetErrors++; + if (numPlanchetErrors < maxReportedErrors) { + errorsPerCoin[x.coinIdx] = x.lastError; } } + } - if ( - (wg.timestampFinish === undefined || - wg.status !== WithdrawalGroupStatus.Done) && - numActive === 0 - ) { - wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now()); - wg.status = WithdrawalGroupStatus.Done; - await makeCoinsVisible(wex, tx, ctx.transactionId); - } - return TransitionResult.transition(wg); - }, - ); + if ( + (wg.timestampFinish === undefined || + wg.status !== WithdrawalGroupStatus.Done) && + numActive === 0 + ) { + wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now()); + wg.status = WithdrawalGroupStatus.Done; + await makeCoinsVisible(wex, tx, ctx.transactionId); + } + await h.update(wg); + return wg; + }); if (!res) { throw Error("withdrawal group does not exist anymore"); @@ -2909,20 +2793,18 @@ async function startRedenomination( await startUpdateExchangeEntry(ctx.wex, exchangeBaseUrl, { forceUnavailable: true, }); - const didTransition = await ctx.transition({}, async (rec) => { + return await ctx.wex.runLegacyWalletDbTx(async (tx) => { + const [rec, h] = await ctx.getRecordHandle(tx); switch (rec?.status) { case WithdrawalGroupStatus.PendingReady: break; default: - return TransitionResult.stay(); + return TaskRunResult.backoff(); } rec.status = WithdrawalGroupStatus.PendingRedenominate; - return TransitionResult.transition(rec); - }); - if (didTransition) { + await h.update(rec); return TaskRunResult.progress(); - } - return TaskRunResult.backoff(); + }); } export async function processWithdrawalGroup( @@ -3377,16 +3259,17 @@ async function registerReserveWithBank( codecForBankWithdrawalOperationPostResponse(), ); - await ctx.transition({}, async (r) => { + return await ctx.wex.runLegacyWalletDbTx(async (tx) => { + const [r, h] = await ctx.getRecordHandle(tx); if (!r) { - return TransitionResult.stay(); + return TaskRunResult.finished(); } switch (r.status) { case WithdrawalGroupStatus.PendingRegisteringBank: case WithdrawalGroupStatus.PendingWaitConfirmBank: break; default: - return TransitionResult.stay(); + return TaskRunResult.backoff(); } if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) { throw Error("invariant failed"); @@ -3396,26 +3279,26 @@ async function registerReserveWithBank( ); r.status = WithdrawalGroupStatus.PendingWaitConfirmBank; r.wgInfo.bankInfo.confirmUrl = status.confirm_transfer_url; - return TransitionResult.transition(r); + await h.update(r); + return TaskRunResult.progress(); }); - - return TaskRunResult.progress(); } async function transitionBankAborted( ctx: WithdrawTransactionContext, ): Promise<TaskRunResult> { logger.info("bank aborted the withdrawal"); - await ctx.transition({}, async (r) => { + return await ctx.wex.runLegacyWalletDbTx(async (tx) => { + const [r, h] = await ctx.getRecordHandle(tx); if (!r) { - return TransitionResult.stay(); + return TaskRunResult.finished(); } switch (r.status) { case WithdrawalGroupStatus.PendingRegisteringBank: case WithdrawalGroupStatus.PendingWaitConfirmBank: break; default: - return TransitionResult.stay(); + return TaskRunResult.backoff(); } if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) { throw Error("invariant failed"); @@ -3423,9 +3306,9 @@ async function transitionBankAborted( const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()); r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now); r.status = WithdrawalGroupStatus.FailedBankAborted; - return TransitionResult.transition(r); + await h.update(r); + return TaskRunResult.progress(); }); - return TaskRunResult.progress(); } async function processBankRegisterReserve( @@ -3583,16 +3466,17 @@ async function processReserveBankStatus( ); } - const transitionInfo = await ctx.transition({}, async (r) => { + return await ctx.wex.runLegacyWalletDbTx(async (tx) => { + const [r, h] = await ctx.getRecordHandle(tx); if (!r) { - return TransitionResult.stay(); + return TaskRunResult.finished(); } // Re-check reserve status within transaction switch (r.status) { case WithdrawalGroupStatus.PendingWaitConfirmBank: break; default: - return TransitionResult.stay(); + return TaskRunResult.backoff(); } if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) { throw Error("invariant failed"); @@ -3608,17 +3492,12 @@ async function processReserveBankStatus( r.effectiveWithdrawalAmount = denomSel.totalCoinValue; r.instructedAmount = denomSel.totalWithdrawCost; } - return TransitionResult.transition(r); + await h.update(r); + return TaskRunResult.progress(); } else { - return TransitionResult.stay(); + return TaskRunResult.backoff(); } }); - - if (transitionInfo) { - return TaskRunResult.progress(); - } else { - return TaskRunResult.backoff(); - } } export interface PrepareCreateWithdrawalGroupResult { @@ -4161,76 +4040,72 @@ export async function confirmWithdrawal( } } - await ctx.transition( - { - extraStores: ["exchanges"], - }, - async (rec, tx) => { - if (!rec) { - return TransitionResult.stay(); + await ctx.wex.runLegacyWalletDbTx(async (tx) => { + const [rec, h] = await ctx.getRecordHandle(tx); + if (!rec) { + return; + } + switch (rec.status) { + case WithdrawalGroupStatus.Done: + case WithdrawalGroupStatus.PendingReady: + case WithdrawalGroupStatus.PendingRegisteringBank: + case WithdrawalGroupStatus.PendingWaitConfirmBank: { + // Be idempotent. + return; } - switch (rec.status) { - case WithdrawalGroupStatus.Done: - case WithdrawalGroupStatus.PendingReady: - case WithdrawalGroupStatus.PendingRegisteringBank: - case WithdrawalGroupStatus.PendingWaitConfirmBank: { - // Be idempotent. - return TransitionResult.stay(); - } - case WithdrawalGroupStatus.AbortedOtherWallet: { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK, - {}, - ); - } - case WithdrawalGroupStatus.DialogProposed: { - rec.exchangeBaseUrl = exchange.exchangeBaseUrl; - rec.instructedAmount = req.amount; - rec.restrictAge = req.restrictAge; - if (initialDenoms != null) { - rec.denomsSel = initialDenoms; - rec.rawWithdrawalAmount = initialDenoms.totalWithdrawCost; - rec.effectiveWithdrawalAmount = initialDenoms.totalCoinValue; - } else { - rec.denomsSel = undefined; - rec.rawWithdrawalAmount = Amounts.stringify( - Amounts.zeroOfCurrency(instructedCurrency), - ); - rec.effectiveWithdrawalAmount = Amounts.stringify( - Amounts.zeroOfCurrency(instructedCurrency), - ); - } - checkDbInvariant( - rec.wgInfo.withdrawalType === WithdrawalRecordType.BankIntegrated, - "withdrawal type mismatch", + case WithdrawalGroupStatus.AbortedOtherWallet: { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK, + {}, + ); + } + case WithdrawalGroupStatus.DialogProposed: { + rec.exchangeBaseUrl = exchange.exchangeBaseUrl; + rec.instructedAmount = req.amount; + rec.restrictAge = req.restrictAge; + if (initialDenoms != null) { + rec.denomsSel = initialDenoms; + rec.rawWithdrawalAmount = initialDenoms.totalWithdrawCost; + rec.effectiveWithdrawalAmount = initialDenoms.totalCoinValue; + } else { + rec.denomsSel = undefined; + rec.rawWithdrawalAmount = Amounts.stringify( + Amounts.zeroOfCurrency(instructedCurrency), ); - rec.wgInfo.exchangeCreditAccounts = withdrawalAccountList; - rec.wgInfo.bankInfo.exchangePaytoUri = exchangePaytoUri; - rec.status = WithdrawalGroupStatus.PendingRegisteringBank; - - await internalPerformExchangeWasUsed( - wex, - tx, - exchange.exchangeBaseUrl, - withdrawalGroup, + rec.effectiveWithdrawalAmount = Amounts.stringify( + Amounts.zeroOfCurrency(instructedCurrency), ); - - return TransitionResult.transition(rec); } + checkDbInvariant( + rec.wgInfo.withdrawalType === WithdrawalRecordType.BankIntegrated, + "withdrawal type mismatch", + ); + rec.wgInfo.exchangeCreditAccounts = withdrawalAccountList; + rec.wgInfo.bankInfo.exchangePaytoUri = exchangePaytoUri; + rec.status = WithdrawalGroupStatus.PendingRegisteringBank; - default: { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED, - { - message: `unable to confirm withdrawal in current state`, - txState: computeWithdrawalTransactionStatus(rec), - debugStateNum: rec.status, - }, - ); - } + await internalPerformExchangeWasUsed( + wex, + tx, + exchange.exchangeBaseUrl, + withdrawalGroup, + ); + await h.update(rec); + return; } - }, - ); + + default: { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED, + { + message: `unable to confirm withdrawal in current state`, + txState: computeWithdrawalTransactionStatus(rec), + debugStateNum: rec.status, + }, + ); + } + } + }); await wex.taskScheduler.resetTaskRetries(ctx.taskId);