From 8b5d1276b9d9043e85cba91704c908ff544916e0 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Tue, 30 Apr 2024 11:50:59 +0200 Subject: wallet-core: new states for withdrawal, prepare/confirm requests --- packages/taler-wallet-core/src/withdraw.ts | 272 ++++++++++++++++++++++++----- 1 file changed, 229 insertions(+), 43 deletions(-) (limited to 'packages/taler-wallet-core/src/withdraw.ts') diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts index a55ada796..0597c051f 100644 --- a/packages/taler-wallet-core/src/withdraw.ts +++ b/packages/taler-wallet-core/src/withdraw.ts @@ -56,6 +56,7 @@ import { Logger, NotificationType, ObservabilityEventType, + PrepareBankIntegratedWithdrawalResponse, TalerBankIntegrationHttpClient, TalerError, TalerErrorCode, @@ -79,7 +80,9 @@ import { assertUnreachable, canonicalizeBaseUrl, checkDbInvariant, + checkLogicInvariant, codeForBankWithdrawalOperationPostResponse, + codecForBankWithdrawalOperationStatus, codecForCashinConversionResponse, codecForConversionBankConfig, codecForExchangeWithdrawBatchResponse, @@ -154,6 +157,7 @@ import { constructTransactionIdentifier, isUnsuccessfulTransaction, notifyTransition, + parseTransactionIdentifier, } from "./transactions.js"; import { WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, @@ -164,7 +168,7 @@ import { WalletExecutionContext, getDenomInfo } from "./wallet.js"; /** * Logger for this file. */ -const logger = new Logger("operations/withdraw.ts"); +const logger = new Logger("withdraw.ts"); /** * Update the materialized withdrawal transaction based @@ -466,13 +470,18 @@ export class WithdrawTransactionContext implements TransactionContext { 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: @@ -658,6 +667,21 @@ export function computeWithdrawalTransactionStatus( major: TransactionMajorState.Aborted, minor: TransactionMinorState.Bank, }; + case WithdrawalGroupStatus.AbortedUserRefused: + return { + major: TransactionMajorState.Aborted, + minor: TransactionMinorState.Refused, + }; + case WithdrawalGroupStatus.DialogProposed: + return { + major: TransactionMajorState.Dialog, + minor: TransactionMinorState.Proposed, + }; + case WithdrawalGroupStatus.AbortedOtherWallet: + return { + major: TransactionMajorState.Aborted, + minor: TransactionMinorState.CompletedByOtherWallet, + }; } } @@ -702,14 +726,78 @@ export function computeWithdrawalTransactionActions( case WithdrawalGroupStatus.SuspendedKyc: return [TransactionAction.Resume, TransactionAction.Abort]; case WithdrawalGroupStatus.FailedAbortingBank: - return [TransactionAction.Delete]; case WithdrawalGroupStatus.AbortedExchange: - return [TransactionAction.Delete]; case WithdrawalGroupStatus.AbortedBank: + case WithdrawalGroupStatus.AbortedOtherWallet: + case WithdrawalGroupStatus.AbortedUserRefused: return [TransactionAction.Delete]; + case WithdrawalGroupStatus.DialogProposed: + return [TransactionAction.Abort]; } } +async function processWithdrawalGroupDialogProposed( + ctx: WithdrawTransactionContext, + withdrawalGroup: WithdrawalGroupRecord, +): Promise { + if ( + withdrawalGroup.wgInfo.withdrawalType !== + WithdrawalRecordType.BankIntegrated + ) { + throw new Error( + "processWithdrawalGroupDialogProposed called in unexpected state", + ); + } + + const talerWithdrawUri = withdrawalGroup.wgInfo.bankInfo.talerWithdrawUri; + + const parsedUri = parseWithdrawUri(talerWithdrawUri); + + checkLogicInvariant(!!parsedUri); + + const wopid = parsedUri.withdrawalOperationId; + + const url = new URL( + `withdrawal-operation/${wopid}`, + parsedUri.bankIntegrationApiBaseUrl, + ); + + url.searchParams.set("old_state", "pending"); + url.searchParams.set("long_poll_ms", "30000"); + + const resp = await ctx.wex.http.fetch(url.href, { + method: "GET", + cancellationToken: ctx.wex.cancellationToken, + }); + + // If the bank claims that the withdrawal operation is already + // pending, but we're still in DialogProposed, some other wallet + // must've completed the withdrawal, we're giving up. + + switch (resp.status) { + case HttpStatusCode.Ok: { + const body = await readSuccessResponseJsonOrThrow( + resp, + codecForBankWithdrawalOperationStatus(), + ); + if (body.status !== "pending") { + await ctx.transition({}, async (rec) => { + switch (rec?.status) { + case WithdrawalGroupStatus.DialogProposed: { + rec.status = WithdrawalGroupStatus.AbortedOtherWallet; + return TransitionResult.transition(rec); + } + } + return TransitionResult.stay(); + }); + } + break; + } + } + + return TaskRunResult.longpollReturnedPending(); +} + /** * Get information about a withdrawal from * a taler://withdraw URI by asking the bank. @@ -1907,6 +1995,8 @@ export async function processWithdrawalGroup( throw Error(`withdrawal group ${withdrawalGroupId} not found`); } + const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); + switch (withdrawalGroup.status) { case WithdrawalGroupStatus.PendingRegisteringBank: return await processBankRegisterReserve(wex, withdrawalGroupId); @@ -1924,6 +2014,8 @@ export async function processWithdrawalGroup( return await processWithdrawalGroupPendingReady(wex, withdrawalGroup); case WithdrawalGroupStatus.AbortingBank: return await processWithdrawalGroupAbortingBank(wex, withdrawalGroup); + case WithdrawalGroupStatus.DialogProposed: + return await processWithdrawalGroupDialogProposed(ctx, withdrawalGroup); case WithdrawalGroupStatus.AbortedBank: case WithdrawalGroupStatus.AbortedExchange: case WithdrawalGroupStatus.FailedAbortingBank: @@ -1936,6 +2028,8 @@ export async function processWithdrawalGroup( case WithdrawalGroupStatus.SuspendedWaitConfirmBank: case WithdrawalGroupStatus.Done: case WithdrawalGroupStatus.FailedBankAborted: + case WithdrawalGroupStatus.AbortedUserRefused: + case WithdrawalGroupStatus.AbortedOtherWallet: // Nothing to do. return TaskRunResult.finished(); default: @@ -2073,12 +2167,6 @@ export interface GetWithdrawalDetailsForUriOpts { notifyChangeFromPendingTimeoutMs?: number; } -type WithdrawalOperationMemoryMap = { - [uri: string]: boolean | undefined; -}; - -const ongoingChecks: WithdrawalOperationMemoryMap = {}; - /** * Get more information about a taler://withdraw URI. * @@ -2119,37 +2207,6 @@ export async function getWithdrawalDetailsForUri( ); }); - // FIXME: this should be removed after the extended version of - // withdrawal state machine. issue #8099 - if ( - info.status === "pending" && - opts.notifyChangeFromPendingTimeoutMs !== undefined && - !ongoingChecks[talerWithdrawUri] - ) { - ongoingChecks[talerWithdrawUri] = true; - const bankApi = new TalerBankIntegrationHttpClient( - info.apiBaseUrl, - wex.http, - ); - - bankApi - .getWithdrawalOperationById(info.operationId, { - old_state: "pending", - timeoutMs: opts.notifyChangeFromPendingTimeoutMs, - }) - .then((resp) => { - if (resp.type === "ok" && resp.body.status !== "pending") { - wex.ws.notify({ - type: NotificationType.WithdrawalOperationTransition, - uri: talerWithdrawUri, - }); - } - }) - .finally(() => { - ongoingChecks[talerWithdrawUri] = false; - }); - } - return { operationId: info.operationId, confirmTransferUrl: info.confirmTransferUrl, @@ -2731,6 +2788,135 @@ export async function internalCreateWithdrawalGroup( return res.withdrawalGroup; } +export async function prepareBankIntegratedWithdrawal( + wex: WalletExecutionContext, + req: { + talerWithdrawUri: string; + selectedExchange: string; + forcedDenomSel?: ForcedDenomSel; + restrictAge?: number; + }, +): Promise { + const existingWithdrawalGroup = await wex.db.runReadOnlyTx( + { storeNames: ["withdrawalGroups"] }, + async (tx) => { + return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get( + req.talerWithdrawUri, + ); + }, + ); + + if (existingWithdrawalGroup) { + let url: string | undefined; + if ( + existingWithdrawalGroup.wgInfo.withdrawalType === + WithdrawalRecordType.BankIntegrated + ) { + url = existingWithdrawalGroup.wgInfo.bankInfo.confirmUrl; + } + return { + transactionId: constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId: existingWithdrawalGroup.withdrawalGroupId, + }), + }; + } + + const selectedExchange = canonicalizeBaseUrl(req.selectedExchange); + const exchange = await fetchFreshExchange(wex, selectedExchange); + + const withdrawInfo = await getBankWithdrawalInfo( + wex.http, + req.talerWithdrawUri, + ); + const exchangePaytoUri = await getExchangePaytoUri( + wex, + selectedExchange, + withdrawInfo.wireTypes, + ); + + const withdrawalAccountList = await fetchWithdrawalAccountInfo( + wex, + { + exchange, + instructedAmount: withdrawInfo.amount, + }, + wex.cancellationToken, + ); + + const withdrawalGroup = await internalCreateWithdrawalGroup(wex, { + amount: withdrawInfo.amount, + exchangeBaseUrl: req.selectedExchange, + wgInfo: { + withdrawalType: WithdrawalRecordType.BankIntegrated, + exchangeCreditAccounts: withdrawalAccountList, + bankInfo: { + exchangePaytoUri, + talerWithdrawUri: req.talerWithdrawUri, + confirmUrl: withdrawInfo.confirmTransferUrl, + timestampBankConfirmed: undefined, + timestampReserveInfoPosted: undefined, + }, + }, + restrictAge: req.restrictAge, + forcedDenomSel: req.forcedDenomSel, + reserveStatus: WithdrawalGroupStatus.DialogProposed, + }); + + const withdrawalGroupId = withdrawalGroup.withdrawalGroupId; + + const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); + + wex.taskScheduler.startShepherdTask(ctx.taskId); + + return { + transactionId: ctx.transactionId, + }; +} + +export async function confirmWithdrawal( + wex: WalletExecutionContext, + transactionId: string, +): Promise { + const parsedTx = parseTransactionIdentifier(transactionId); + if (parsedTx?.tag !== TransactionType.Withdrawal) { + throw Error("invalid withdrawal transaction ID"); + } + const withdrawalGroup = await wex.db.runReadOnlyTx( + { storeNames: ["withdrawalGroups"] }, + async (tx) => { + return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get( + parsedTx.withdrawalGroupId, + ); + }, + ); + + if (!withdrawalGroup) { + throw Error("withdrawal group not found"); + } + + const ctx = new WithdrawTransactionContext( + wex, + withdrawalGroup.withdrawalGroupId, + ); + ctx.transition({}, async (rec) => { + if (!rec) { + return TransitionResult.stay(); + } + switch (rec.status) { + case WithdrawalGroupStatus.DialogProposed: { + rec.status = WithdrawalGroupStatus.PendingRegisteringBank; + return TransitionResult.transition(rec); + } + default: + throw Error("unable to confirm withdrawal in current state"); + } + }); + + await wex.taskScheduler.resetTaskRetries(ctx.taskId); + wex.taskScheduler.startShepherdTask(ctx.taskId); +} + /** * Accept a bank-integrated withdrawal. * @@ -2738,6 +2924,8 @@ export async function internalCreateWithdrawalGroup( * * Thus after this call returns, the withdrawal operation can be confirmed * with the bank. + * + * @deprecated in favor of prepare/accept */ export async function acceptWithdrawalFromUri( wex: WalletExecutionContext, @@ -2779,7 +2967,7 @@ export async function acceptWithdrawalFromUri( }; } - await fetchFreshExchange(wex, selectedExchange); + const exchange = await fetchFreshExchange(wex, selectedExchange); const withdrawInfo = await getBankWithdrawalInfo( wex.http, req.talerWithdrawUri, @@ -2790,8 +2978,6 @@ export async function acceptWithdrawalFromUri( withdrawInfo.wireTypes, ); - const exchange = await fetchFreshExchange(wex, selectedExchange); - const withdrawalAccountList = await fetchWithdrawalAccountInfo( wex, { -- cgit v1.2.3