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 --- .../src/integrationtests/test-currency-scope.ts | 3 +- packages/taler-util/src/notifications.ts | 3 + packages/taler-util/src/transactions-types.ts | 1 + packages/taler-util/src/wallet-types.ts | 30 +++ packages/taler-wallet-core/src/balance.ts | 3 + packages/taler-wallet-core/src/db.ts | 20 ++ packages/taler-wallet-core/src/wallet-api-types.ts | 27 ++ packages/taler-wallet-core/src/wallet.ts | 18 ++ packages/taler-wallet-core/src/withdraw.ts | 272 +++++++++++++++++---- 9 files changed, 332 insertions(+), 45 deletions(-) diff --git a/packages/taler-harness/src/integrationtests/test-currency-scope.ts b/packages/taler-harness/src/integrationtests/test-currency-scope.ts index e07a8f47b..058941e16 100644 --- a/packages/taler-harness/src/integrationtests/test-currency-scope.ts +++ b/packages/taler-harness/src/integrationtests/test-currency-scope.ts @@ -18,7 +18,7 @@ * Imports. */ import { Duration, j2s } from "@gnu-taler/taler-util"; -import { Wallet, WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { defaultCoinConfig } from "../harness/denomStructures.js"; import { BankService, @@ -30,7 +30,6 @@ import { } from "../harness/harness.js"; import { createWalletDaemonWithClient, - makeTestPaymentV2, withdrawViaBankV2, } from "../harness/helpers.js"; diff --git a/packages/taler-util/src/notifications.ts b/packages/taler-util/src/notifications.ts index b60fb267c..d4dfe7589 100644 --- a/packages/taler-util/src/notifications.ts +++ b/packages/taler-util/src/notifications.ts @@ -30,6 +30,9 @@ export enum NotificationType { BalanceChange = "balance-change", BackupOperationError = "backup-error", TransactionStateTransition = "transaction-state-transition", + /** + * @deprecated + */ WithdrawalOperationTransition = "withdrawal-operation-transition", ExchangeStateTransition = "exchange-state-transition", Idle = "idle", diff --git a/packages/taler-util/src/transactions-types.ts b/packages/taler-util/src/transactions-types.ts index ac4c3d717..cee3de9fa 100644 --- a/packages/taler-util/src/transactions-types.ts +++ b/packages/taler-util/src/transactions-types.ts @@ -151,6 +151,7 @@ export enum TransactionMinorState { RefundAvailable = "refund-available", AcceptRefund = "accept-refund", PaidByOther = "paid-by-other", + CompletedByOtherWallet = "completed-by-other-wallet", } export enum TransactionAction { diff --git a/packages/taler-util/src/wallet-types.ts b/packages/taler-util/src/wallet-types.ts index 9575e6d7d..ccf3c230a 100644 --- a/packages/taler-util/src/wallet-types.ts +++ b/packages/taler-util/src/wallet-types.ts @@ -1883,6 +1883,36 @@ export interface GetWithdrawalDetailsForAmountRequest { clientCancellationId?: string; } +export interface PrepareBankIntegratedWithdrawalRequest { + talerWithdrawUri: string; + exchangeBaseUrl: string; + forcedDenomSel?: ForcedDenomSel; + restrictAge?: number; +} + +export const codecForPrepareBankIntegratedWithdrawalRequest = + (): Codec => + buildCodecForObject() + .property("exchangeBaseUrl", codecForString()) + .property("talerWithdrawUri", codecForString()) + .property("forcedDenomSel", codecForAny()) + .property("restrictAge", codecOptional(codecForNumber())) + .build("PrepareBankIntegratedWithdrawalRequest"); + +export interface PrepareBankIntegratedWithdrawalResponse { + transactionId: string; +} + +export interface ConfirmWithdrawalRequest { + transactionId: string; +} + +export const codecForConfirmWithdrawalRequestRequest = + (): Codec => + buildCodecForObject() + .property("transactionId", codecForString()) + .build("ConfirmWithdrawalRequest"); + export interface AcceptBankIntegratedWithdrawalRequest { talerWithdrawUri: string; exchangeBaseUrl: string; diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts index 1fef9876e..5a805b477 100644 --- a/packages/taler-wallet-core/src/balance.ts +++ b/packages/taler-wallet-core/src/balance.ts @@ -357,6 +357,9 @@ export async function getBalancesInsideTransaction( case WithdrawalGroupStatus.AbortedExchange: case WithdrawalGroupStatus.FailedAbortingBank: case WithdrawalGroupStatus.FailedBankAborted: + case WithdrawalGroupStatus.AbortedOtherWallet: + case WithdrawalGroupStatus.AbortedUserRefused: + case WithdrawalGroupStatus.DialogProposed: case WithdrawalGroupStatus.Done: // Does not count as pendingIncoming return; diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index 085e909cf..1edafb315 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -297,6 +297,11 @@ export enum WithdrawalGroupStatus { PendingReady = 0x0100_0004, SuspendedReady = 0x0110_0004, + /** + * Proposed to the user, has can choose to accept/refuse. + */ + DialogProposed = 0x0101_0000, + /** * We are telling the bank that we don't want to complete * the withdrawal! @@ -338,6 +343,21 @@ export enum WithdrawalGroupStatus { AbortedExchange = 0x0503_0001, AbortedBank = 0x0503_0002, + + /** + * User didn't refused the withdrawal. + */ + AbortedUserRefused = 0x0503_0003, + + /** + * Another wallet confirmed the withdrawal + * (by POSTing the reseve pub to the bank) + * before we had the chance. + * + * In this situation, we'll let the other wallet continue + * and give up ourselves. + */ + AbortedOtherWallet = 0x0503_0004, } /** diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts index e876d8aea..f83db6039 100644 --- a/packages/taler-wallet-core/src/wallet-api-types.ts +++ b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -47,6 +47,7 @@ import { ConfirmPayResult, ConfirmPeerPullDebitRequest, ConfirmPeerPushCreditRequest, + ConfirmWithdrawalRequest, ConvertAmountRequest, CreateDepositGroupRequest, CreateDepositGroupResponse, @@ -91,6 +92,8 @@ import { ListGlobalCurrencyAuditorsResponse, ListGlobalCurrencyExchangesResponse, ListKnownBankAccountsRequest, + PrepareBankIntegratedWithdrawalRequest, + PrepareBankIntegratedWithdrawalResponse, PrepareDepositRequest, PrepareDepositResponse, PreparePayRequest, @@ -195,6 +198,8 @@ export enum WalletApiOperation { SetExchangeTosForgotten = "SetExchangeTosForgotten", StartRefundQueryForUri = "startRefundQueryForUri", StartRefundQuery = "startRefundQuery", + PrepareBankIntegratedWithdrawal = "prepareBankIntegratedWithdrawal", + ConfirmWithdrawal = "confirmWithdrawal", AcceptBankIntegratedWithdrawal = "acceptBankIntegratedWithdrawal", GetExchangeTos = "getExchangeTos", GetExchangeDetailedInfo = "getExchangeDetailedInfo", @@ -475,8 +480,28 @@ export type GetWithdrawalDetailsForUriOp = { response: WithdrawUriInfoResponse; }; +/** + * Prepare a bank-integrated withdrawal operation. + */ +export type PrepareBankIntegratedWithdrawalOp = { + op: WalletApiOperation.PrepareBankIntegratedWithdrawal; + request: PrepareBankIntegratedWithdrawalRequest; + response: PrepareBankIntegratedWithdrawalResponse; +}; + +/** + * Confirm a withdrawal transaction. + */ +export type ConfirmWithdrawalOp = { + op: WalletApiOperation.ConfirmWithdrawal; + request: ConfirmWithdrawalRequest; + response: EmptyObject; +}; + /** * Accept a bank-integrated withdrawal. + * + * @deprecated in favor of prepare/confirm withdrawal. */ export type AcceptBankIntegratedWithdrawalOp = { op: WalletApiOperation.AcceptBankIntegratedWithdrawal; @@ -1292,6 +1317,8 @@ export type WalletOperations = { [WalletApiOperation.TestingGetDenomStats]: TestingGetDenomStatsOp; [WalletApiOperation.TestingPing]: TestingPingOp; [WalletApiOperation.Shutdown]: ShutdownOp; + [WalletApiOperation.PrepareBankIntegratedWithdrawal]: PrepareBankIntegratedWithdrawalOp; + [WalletApiOperation.ConfirmWithdrawal]: ConfirmWithdrawalOp; }; export type WalletCoreRequestType< diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index dd6ce96f4..8bfb6772f 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -86,6 +86,7 @@ import { codecForCheckPeerPushDebitRequest, codecForConfirmPayRequest, codecForConfirmPeerPushPaymentRequest, + codecForConfirmWithdrawalRequestRequest, codecForConvertAmountRequest, codecForCreateDepositGroupRequest, codecForDeleteExchangeRequest, @@ -111,6 +112,7 @@ import { codecForIntegrationTestV2Args, codecForListExchangesForScopedCurrencyRequest, codecForListKnownBankAccounts, + codecForPrepareBankIntegratedWithdrawalRequest, codecForPrepareDepositRequest, codecForPreparePayRequest, codecForPreparePayTemplateRequest, @@ -295,9 +297,11 @@ import { } from "./wallet-api-types.js"; import { acceptWithdrawalFromUri, + confirmWithdrawal, createManualWithdrawal, getWithdrawalDetailsForAmount, getWithdrawalDetailsForUri, + prepareBankIntegratedWithdrawal, } from "./withdraw.js"; const logger = new Logger("wallet.ts"); @@ -965,6 +969,20 @@ async function dispatchRequestInternal( restrictAge: req.restrictAge, }); } + case WalletApiOperation.ConfirmWithdrawal: { + const req = codecForConfirmWithdrawalRequestRequest().decode(payload); + return confirmWithdrawal(wex, req.transactionId); + } + case WalletApiOperation.PrepareBankIntegratedWithdrawal: { + const req = + codecForPrepareBankIntegratedWithdrawalRequest().decode(payload); + return prepareBankIntegratedWithdrawal(wex, { + selectedExchange: req.exchangeBaseUrl, + talerWithdrawUri: req.talerWithdrawUri, + forcedDenomSel: req.forcedDenomSel, + restrictAge: req.restrictAge, + }); + } case WalletApiOperation.GetExchangeTos: { const req = codecForGetExchangeTosRequest().decode(payload); return getExchangeTos( 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