taler-typescript-core

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

commit 15cd09e5ce7293dc5ae370b67426a4b2cc5a219b
parent 92862389d2b189c910e2fc6489a540b8ec7be8b5
Author: Florian Dold <florian@dold.me>
Date:   Tue,  7 Jan 2025 19:30:53 +0100

wallet-core,harness: support cash acceptor withdrawals, test

Diffstat:
Mpackages/taler-harness/src/harness/environments.ts | 54+++++++++++++++++++++++++++++++++++++++++++++++++-----
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 2++
Mpackages/taler-util/src/http-client/bank-core.ts | 33+++++++++++++++++++++++++++++++--
Mpackages/taler-util/src/http-client/bank-integration.ts | 12+++++++++---
Mpackages/taler-util/src/types-taler-bank-integration.ts | 60++++++++++++++++++++++++++++++++++++++++++------------------
Mpackages/taler-util/src/types-taler-common.ts | 83-------------------------------------------------------------------------------
Mpackages/taler-util/src/types-taler-corebank.ts | 4++--
Mpackages/taler-util/src/types-taler-wallet.ts | 8++++----
Mpackages/taler-wallet-core/src/balance.ts | 26++++++++++++++++++++------
Mpackages/taler-wallet-core/src/withdraw.ts | 166++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
10 files changed, 265 insertions(+), 183 deletions(-)

diff --git a/packages/taler-harness/src/harness/environments.ts b/packages/taler-harness/src/harness/environments.ts @@ -24,6 +24,7 @@ * Imports */ import { + AccessToken, AccountProperties, AmlDecisionRequest, AmlDecisionRequestWithoutSignature, @@ -33,16 +34,19 @@ import { decodeCrock, Duration, encodeCrock, + getRandomBytes, HttpStatusCode, j2s, LegitimizationRuleSet, Logger, MerchantApiClient, + narrowOpSuccessOrThrow, NotificationType, PartialWalletRunConfig, PreparePayResultType, signAmlDecision, TalerCorebankApiClient, + TalerCoreBankHttpClient, TalerMerchantApi, TalerProtocolTimestamp, TransactionIdStr, @@ -142,6 +146,11 @@ export interface EnvOptions { accountRestrictions?: HarnessAccountRestriction[]; + /** + * Force usage of libeufin for this particular test. + */ + forceLibeufin?: boolean; + additionalExchangeConfig?(e: ExchangeService): void; additionalMerchantConfig?(m: MerchantService): void; additionalBankConfig?(b: BankService): void; @@ -463,9 +472,10 @@ export async function createSimpleTestkudosEnvironmentV3( httpPort: 8082, }; - const bank: BankService = useLibeufinBank - ? await LibeufinBankService.create(t, bc) - : await FakebankService.create(t, bc); + const bank: BankService = + useLibeufinBank || opts.forceLibeufin + ? await LibeufinBankService.create(t, bc) + : await FakebankService.create(t, bc); const exchange = ExchangeService.create(t, { name: "testexchange-1", @@ -1130,7 +1140,6 @@ export interface KycTestEnv { wireGatewayApiClient: WireGatewayApiClient; } - export async function createKycTestkudosEnvironment( t: GlobalTestState, opts: KycEnvOptions = {}, @@ -1219,7 +1228,6 @@ export async function createKycTestkudosEnvironment( await walletService.start(); await walletService.pingUntilAvailable(); - const walletClient = new WalletClient({ name: "wallet", unixPath: walletService.socketPath, @@ -1293,3 +1301,39 @@ export async function createKycTestkudosEnvironment( wireGatewayApiClient, }; } + +export interface TestUserResult { + username: string; + password: string; + token: AccessToken; +} + +/** + * Register a new bank user with a random name and obtain a + * login token. + */ +export async function registerHarnessBankTestUser( + bankClient: TalerCoreBankHttpClient, +): Promise<TestUserResult> { + const username = "user-" + encodeCrock(getRandomBytes(10)).toLowerCase(); + const password = "pw-" + encodeCrock(getRandomBytes(10)).toLowerCase(); + const createRes = await bankClient.createAccount(undefined, { + name: username, + username, + password, + }); + narrowOpSuccessOrThrow("createAccount", createRes); + // It's a test account, so it's safe to log credentials. + logger.info( + `Created test bank account ${username} with password ${password}`, + ); + const tokRes = await bankClient.createAccessTokenBasic(username, password, { + scope: "readwrite", + }); + narrowOpSuccessOrThrow("token", tokRes); + return { + password, + username, + token: tokRes.body.access_token, + }; +} diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -143,6 +143,7 @@ import { runWallettestingTest } from "./test-wallettesting.js"; import { runWithdrawalAbortBankTest } from "./test-withdrawal-abort-bank.js"; import { runWithdrawalAmountTest } from "./test-withdrawal-amount.js"; import { runWithdrawalBankIntegratedTest } from "./test-withdrawal-bank-integrated.js"; +import { runWithdrawalCashacceptorTest } from "./test-withdrawal-cashacceptor.js"; import { runWithdrawalConversionTest } from "./test-withdrawal-conversion.js"; import { runWithdrawalExternalTest } from "./test-withdrawal-external.js"; import { runWithdrawalFakebankTest } from "./test-withdrawal-fakebank.js"; @@ -296,6 +297,7 @@ const allTests: TestMainFunction[] = [ runKycAmpTimeoutTest, runKycAmpFailureTest, runPeerPushAbortTest, + runWithdrawalCashacceptorTest, ]; export interface TestRunSpec { diff --git a/packages/taler-util/src/http-client/bank-core.ts b/packages/taler-util/src/http-client/bank-core.ts @@ -26,8 +26,10 @@ import { PaginationParams, TalerError, TalerErrorCode, + TokenRequest, UserAndToken, codecForTalerCommonConfigResponse, + codecForTokenSuccessResponse, opKnownAlternativeFailure, opKnownHttpFailure, opKnownTalerFailure, @@ -46,7 +48,7 @@ import { opSuccessFromHttp, opUnknownFailure, } from "../operation.js"; -import { WithdrawalOperationStatus } from "../types-taler-bank-integration.js"; +import { WithdrawalOperationStatusFlag } from "../types-taler-bank-integration.js"; import { codecForAccountData, codecForBankAccountCreateWithdrawalResponse, @@ -70,6 +72,7 @@ import { CacheEvictor, addLongPollingParam, addPaginationParams, + makeBasicAuthHeader, makeBearerTokenAuthHeader, nullEvictor, } from "./utils.js"; @@ -123,6 +126,32 @@ export class TalerCoreBankHttpClient { return compare?.compatible ?? false; } + async createAccessTokenBasic( + username: string, + password: string, + body: TokenRequest, + ) { + const url = new URL(`accounts/${username}/token`, this.baseUrl); + const resp = await this.httpLib.fetch(url.href, { + method: "POST", + headers: { + Authorization: makeBasicAuthHeader(username, password), + }, + body, + }); + switch (resp.status) { + case HttpStatusCode.Ok: + return opSuccessFromHttp(resp, codecForTokenSuccessResponse()); + //FIXME: missing in docs + case HttpStatusCode.Unauthorized: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.NotFound: + return opKnownHttpFailure(resp.status, resp); + default: + return opUnknownFailure(resp, await readTalerErrorResponse(resp)); + } + } + /** * https://docs.taler.net/core/api-corebank.html#config * @@ -754,7 +783,7 @@ export class TalerCoreBankHttpClient { async getWithdrawalById( wid: string, params?: { - old_state?: WithdrawalOperationStatus; + old_state?: WithdrawalOperationStatusFlag; } & LongPollParams, ) { const url = new URL(`withdrawals/${wid}`, this.baseUrl); diff --git a/packages/taler-util/src/http-client/bank-integration.ts b/packages/taler-util/src/http-client/bank-integration.ts @@ -21,6 +21,8 @@ import { LibtoolVersion } from "../libtool-version.js"; import { Logger } from "../logging.js"; import { FailCasesByMethod, + OperationFail, + OperationOk, ResultByMethod, opEmptySuccess, opKnownHttpFailure, @@ -31,7 +33,8 @@ import { import { TalerErrorCode } from "../taler-error-codes.js"; import { BankWithdrawalOperationPostRequest, - WithdrawalOperationStatus, + BankWithdrawalOperationStatus, + WithdrawalOperationStatusFlag, codecForBankWithdrawalOperationPostResponse, codecForBankWithdrawalOperationStatus, } from "../types-taler-bank-integration.js"; @@ -96,9 +99,12 @@ export class TalerBankIntegrationHttpClient { async getWithdrawalOperationById( woid: string, params?: { - old_state?: WithdrawalOperationStatus; + old_state?: WithdrawalOperationStatusFlag; } & LongPollParams, - ) { + ): Promise< + | OperationOk<BankWithdrawalOperationStatus> + | OperationFail<HttpStatusCode.NotFound> + > { const url = new URL(`withdrawal-operation/${woid}`, this.baseUrl); addLongPollingParam(url, params); if (params) { diff --git a/packages/taler-util/src/types-taler-bank-integration.ts b/packages/taler-util/src/types-taler-bank-integration.ts @@ -16,12 +16,30 @@ SPDX-License-Identifier: AGPL-3.0-or-later */ -import { Codec, buildCodecForObject, codecForConstString, codecForEither, codecOptional } from "./codec.js"; -import { codecForAmountString, codecForList, codecForString } from "./index.js"; +import { + Codec, + buildCodecForObject, + codecForConstString, + codecForEither, + codecOptional, +} from "./codec.js"; +import { + codecForAmountString, + codecForBoolean, + codecForList, + codecForString, +} from "./index.js"; import { PaytoString, codecForPaytoString } from "./payto.js"; -import { AmountString, CurrencySpecification, codecForCurrencyName, codecForCurrencySpecificiation, codecForLibtoolVersion, codecForURLString } from "./types-taler-common.js"; - -export type WithdrawalOperationStatus = +import { + AmountString, + CurrencySpecification, + codecForCurrencyName, + codecForCurrencySpecificiation, + codecForLibtoolVersion, + codecForURLString, +} from "./types-taler-common.js"; + +export type WithdrawalOperationStatusFlag = | "pending" | "selected" | "aborted" @@ -49,7 +67,7 @@ export interface BankWithdrawalOperationStatus { // selected: the operations has been selected and is pending confirmation // aborted: the operation has been aborted // confirmed: the transfer has been confirmed and registered by the bank - status: WithdrawalOperationStatus; + status: WithdrawalOperationStatusFlag; // Currency used for the withdrawal. // MUST be present when amount is absent. @@ -116,6 +134,14 @@ export interface BankWithdrawalOperationStatus { // only non-null if status is selected or confirmed. // @since **v1** selected_exchange_account?: string; + + // If true, tells the wallet not to allow the user to + // specify an amount to withdraw and to not provide + // any amount when registering with the withdrawal + // operation. The amount to withdraw will be set + // by the final /withdrawals/$WITHDRAWAL_ID/confirm step. + // @since **v5** + no_amount_to_wallet?: boolean; } export interface BankWithdrawalOperationPostRequest { @@ -138,7 +164,7 @@ export interface BankWithdrawalOperationPostResponse { // selected: the operations has been selected and is pending confirmation // aborted: the operation has been aborted // confirmed: the transfer has been confirmed and registered by the bank - status: Omit<"pending", WithdrawalOperationStatus>; + status: Omit<"pending", WithdrawalOperationStatusFlag>; // URL that the user needs to navigate to in order to // complete some final confirmation (e.g. 2FA). @@ -148,15 +174,13 @@ export interface BankWithdrawalOperationPostResponse { confirm_transfer_url?: string; } - -export const codecForBankVersion = - (): Codec<BankVersion> => - buildCodecForObject<BankVersion>() - .property("currency", codecForCurrencyName()) - .property("currency_specification", codecForCurrencySpecificiation()) - .property("name", codecForConstString("taler-bank-integration")) - .property("version", codecForLibtoolVersion()) - .build("TalerBankIntegrationApi.BankVersion"); +export const codecForBankVersion = (): Codec<BankVersion> => + buildCodecForObject<BankVersion>() + .property("currency", codecForCurrencyName()) + .property("currency_specification", codecForCurrencySpecificiation()) + .property("name", codecForConstString("taler-bank-integration")) + .property("version", codecForLibtoolVersion()) + .build("TalerBankIntegrationApi.BankVersion"); export const codecForBankWithdrawalOperationStatus = (): Codec<BankWithdrawalOperationStatus> => @@ -183,6 +207,7 @@ export const codecForBankWithdrawalOperationStatus = .property("wire_types", codecForList(codecForString())) .property("selected_reserve_pub", codecOptional(codecForString())) .property("selected_exchange_account", codecOptional(codecForString())) + .property("no_amount_to_wallet", codecOptional(codecForBoolean())) .build("TalerBankIntegrationApi.BankWithdrawalOperationStatus"); export const codecForBankWithdrawalOperationPostResponse = @@ -197,4 +222,4 @@ export const codecForBankWithdrawalOperationPostResponse = ), ) .property("confirm_transfer_url", codecOptional(codecForURLString())) - .build("TalerBankIntegrationApi.BankWithdrawalOperationPostResponse"); -\ No newline at end of file + .build("TalerBankIntegrationApi.BankWithdrawalOperationPostResponse"); diff --git a/packages/taler-util/src/types-taler-common.ts b/packages/taler-util/src/types-taler-common.ts @@ -177,61 +177,6 @@ export type ImageDataUrl = string; */ export type Cs25519Point = string; -/** - * Response from the bank. - */ -export class WithdrawOperationStatusResponse { - status: "selected" | "aborted" | "confirmed" | "pending"; - - selection_done: boolean; - - transfer_done: boolean; - - aborted: boolean; - - amount: string | undefined; - - sender_wire?: string; - - suggested_exchange?: string; - - confirm_transfer_url?: string; - - wire_types: string[]; - - // Currency used for the withdrawal. - // MUST be present when amount is absent. - // @since **v2**, may become mandatory in the future. - currency?: string; - - // Minimum amount that the wallet can choose to withdraw. - // Only applicable when the amount is not fixed. - // @since **v4**. - min_amount?: AmountString; - - // Maximum amount that the wallet can choose to withdraw. - // Only applicable when the amount is not fixed. - // @since **v4**. - max_amount?: AmountString; - - // The non-Taler card fees the customer will have - // to pay to the bank / payment service provider - // they are using to make the withdrawal in addition - // to the amount. - // @since **v4** - card_fees?: AmountString; - - // Exchange account selected by the wallet; - // only non-null if status is selected or confirmed. - // @since **v1** - selected_exchange_account?: string; - - // Reserve public key selected by the exchange, - // only non-null if status is selected or confirmed. - // @since **v1** - selected_reserve_pub?: EddsaPublicKey; -} - export type LitAmountString = `${string}:${number}`; export type LibtoolVersionString = string; @@ -265,34 +210,6 @@ export const codecForEddsaSignature = codecForString; export const codecForInternationalizedString = (): Codec<InternationalizedString> => codecForMap(codecForString()); -export const codecForWithdrawOperationStatusResponse = - (): Codec<WithdrawOperationStatusResponse> => - buildCodecForObject<WithdrawOperationStatusResponse>() - .property( - "status", - codecForEither( - codecForConstString("selected"), - codecForConstString("confirmed"), - codecForConstString("aborted"), - codecForConstString("pending"), - ), - ) - .property("selection_done", codecForBoolean()) - .property("transfer_done", codecForBoolean()) - .property("aborted", codecForBoolean()) - .property("amount", codecOptional(codecForString())) - .property("sender_wire", codecOptional(codecForString())) - .property("suggested_exchange", codecOptional(codecForString())) - .property("confirm_transfer_url", codecOptional(codecForString())) - .property("wire_types", codecForList(codecForString())) - .property("currency", codecOptional(codecForString())) - .property("card_fees", codecOptional(codecForAmountString())) - .property("min_amount", codecOptional(codecForAmountString())) - .property("max_amount", codecOptional(codecForAmountString())) - .property("selected_exchange_account", codecOptional(codecForString())) - .property("selected_reserve_pub", codecOptional(codecForEddsaPublicKey())) - .build("WithdrawOperationStatusResponse"); - export const codecForCurrencySpecificiation = (): Codec<CurrencySpecification> => buildCodecForObject<CurrencySpecification>() diff --git a/packages/taler-util/src/types-taler-corebank.ts b/packages/taler-util/src/types-taler-corebank.ts @@ -36,7 +36,7 @@ import { } from "./index.js"; import { PaytoString, codecForPaytoString } from "./payto.js"; import { TalerUriString } from "./taleruri.js"; -import { WithdrawalOperationStatus } from "./types-taler-bank-integration.js"; +import { WithdrawalOperationStatusFlag } from "./types-taler-bank-integration.js"; import { AmountString, CurrencySpecification, @@ -174,7 +174,7 @@ export interface WithdrawalPublicInfo { // selected: the operations has been selected and is pending confirmation // aborted: the operation has been aborted // confirmed: the transfer has been confirmed and registered by the bank - status: WithdrawalOperationStatus; + status: WithdrawalOperationStatusFlag; // Amount that will be withdrawn with this operation // (raw amount without fee considerations). diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -54,7 +54,7 @@ import { InternationalizedString, TalerMerchantApi, TemplateParams, - WithdrawalOperationStatus, + WithdrawalOperationStatusFlag, canonicalizeBaseUrl, } from "./index.js"; import { PaytoString, codecForPaytoString } from "./payto.js"; @@ -953,7 +953,7 @@ export interface PreparePayResultAlreadyConfirmed { } export interface BankWithdrawDetails { - status: WithdrawalOperationStatus; + status: WithdrawalOperationStatusFlag; currency: string; amount: AmountJson | undefined; editableAmount: boolean; @@ -1926,7 +1926,7 @@ export interface PrepareBankIntegratedWithdrawalResponse { export interface ConfirmWithdrawalRequest { transactionId: string; exchangeBaseUrl: string; - amount: AmountString; + amount: AmountString | undefined; forcedDenomSel?: ForcedDenomSel; restrictAge?: number; } @@ -2503,7 +2503,7 @@ export interface TxIdResponse { export interface WithdrawUriInfoResponse { operationId: string; - status: WithdrawalOperationStatus; + status: WithdrawalOperationStatusFlag; confirmTransferUrl?: string; currency: string; amount: AmountString | undefined; diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts @@ -80,6 +80,7 @@ import { RefreshOperationStatus, WalletDbReadOnlyTransaction, WithdrawalGroupStatus, + WithdrawalRecordType, } from "./db.js"; import { getExchangeScopeInfo, @@ -401,14 +402,27 @@ export async function getBalancesInsideTransaction( } case WithdrawalGroupStatus.PendingWaitConfirmBank: { checkDbInvariant( - wg.denomsSel !== undefined, - "wg in confirmed state should have been initialized", - ); - checkDbInvariant( wg.exchangeBaseUrl !== undefined, - "wg in kyc state should have been initialized", + "withdrawal group in PendingWaitConfirmBank state should have been initialized", ); - const currency = Amounts.currencyOf(wg.denomsSel.totalCoinValue); + + // FIXME: Consider just having the currency as a fixed field in the DB + // instead of having it in many locations. + let currency: string; + + if (wg.denomsSel) { + currency = Amounts.currencyOf(wg.denomsSel.totalCoinValue); + } else if ( + wg.wgInfo.withdrawalType === WithdrawalRecordType.BankIntegrated && + wg.wgInfo.bankInfo.currency + ) { + currency = wg.wgInfo.bankInfo.currency; + } else { + logger.warn( + "could not determine currency for confirmed withdrawal group", + ); + break; + } await balanceStore.setFlagIncomingConfirmation( currency, wg.exchangeBaseUrl, diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -95,7 +95,6 @@ import { codecForExchangeWithdrawBatchResponse, codecForLegitimizationNeededResponse, codecForReserveStatus, - codecForWithdrawOperationStatusResponse, encodeCrock, getErrorDetailFromException, getRandomBytes, @@ -430,38 +429,26 @@ export class WithdrawTransactionContext implements TransactionContext { } if ( - !wgRecord.instructedAmount || - !wgRecord.denomsSel || + // !wgRecord.instructedAmount || + // !wgRecord.denomsSel || !wgRecord.exchangeBaseUrl ) { // withdrawal group is in preparation, nothing to update return; } - if ( - wgRecord.wgInfo.withdrawalType === WithdrawalRecordType.BankIntegrated - ) { - } else if ( - wgRecord.wgInfo.withdrawalType === WithdrawalRecordType.BankManual - ) { - checkDbInvariant( - wgRecord.instructedAmount !== undefined, - "manual withdrawal without amount can't be created", - ); - checkDbInvariant( - wgRecord.denomsSel !== undefined, - "manual withdrawal without denoms can't be created", - ); - } else { - // FIXME: If this is an orphaned withdrawal for a p2p transaction, we - // still might want to report the withdrawal. + let currency: string | undefined; + if (wgRecord.rawWithdrawalAmount) { + currency = Amounts.currencyOf(wgRecord.rawWithdrawalAmount); + } + if (!currency) { return; } await tx.transactionsMeta.put({ transactionId: ctx.transactionId, status: wgRecord.status, timestamp: wgRecord.timestampStart, - currency: Amounts.currencyOf(wgRecord.instructedAmount), + currency, exchanges: [wgRecord.exchangeBaseUrl], }); @@ -1177,7 +1164,7 @@ export async function getBankWithdrawalInfo( let editableAmount = false; if (status.amount !== undefined) { amount = Amounts.parseOrThrow(status.amount); - } else { + } else if (!status.no_amount_to_wallet) { amount = status.suggested_amount === undefined ? undefined @@ -2912,10 +2899,10 @@ async function processBankRegisterReserve( const status = await readSuccessResponseJsonOrThrow( statusResp, - codecForWithdrawOperationStatusResponse(), + codecForBankWithdrawalOperationStatus(), ); - if (status.aborted) { + if (status.status === "aborted") { return transitionBankAborted(ctx); } @@ -2973,21 +2960,40 @@ async function processReserveBankStatus( const status = await readSuccessResponseJsonOrThrow( statusResp, - codecForWithdrawOperationStatusResponse(), + codecForBankWithdrawalOperationStatus(), ); if (logger.shouldLogTrace()) { logger.trace(`response body: ${j2s(status)}`); } - if (status.aborted) { + if (status.status === "aborted") { return transitionBankAborted(ctx); } - if (!status.transfer_done) { + if (status.status != "confirmed") { return TaskRunResult.longpollReturnedPending(); } + 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, + ); + } + const transitionInfo = await ctx.transition({}, async (r) => { if (!r) { return TransitionResult.stay(); @@ -3002,11 +3008,17 @@ async function processReserveBankStatus( if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) { throw Error("invariant failed"); } - if (status.transfer_done) { + 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; + } return TransitionResult.transition(r); } else { return TransitionResult.stay(); @@ -3389,7 +3401,8 @@ export async function confirmWithdrawal( ): Promise<AcceptWithdrawalResponse> { const parsedTx = parseTransactionIdentifier(req.transactionId); const selectedExchange = req.exchangeBaseUrl; - const instructedAmount = Amounts.parseOrThrow(req.amount); + const instructedAmount = + req.amount == null ? undefined : Amounts.parseOrThrow(req.amount); if (parsedTx?.tag !== TransactionType.Withdrawal) { throw Error("invalid withdrawal transaction ID"); @@ -3412,10 +3425,20 @@ export async function confirmWithdrawal( throw Error("not a bank integrated withdrawal"); } + let instructedCurrency: string; + if (instructedAmount) { + instructedCurrency = instructedAmount.currency; + } else { + if (!withdrawalGroup.wgInfo.bankInfo.currency) { + throw Error("currency must be provided by bank"); + } + instructedCurrency = withdrawalGroup.wgInfo.bankInfo.currency; + } + const exchange = await fetchFreshExchange(wex, selectedExchange); requireExchangeTosAcceptedOrThrow(exchange); - if (checkWithdrawalHardLimitExceeded(exchange, req.amount)) { + if (req.amount && checkWithdrawalHardLimitExceeded(exchange, req.amount)) { throw Error("withdrawal would exceed hard KYC limit"); } @@ -3452,14 +3475,17 @@ export async function confirmWithdrawal( bankWireTypes, ); - const withdrawalAccountList = await fetchWithdrawalAccountInfo( - wex, - { - exchange, - instructedAmount, - }, - wex.cancellationToken, - ); + let withdrawalAccountList: WithdrawalExchangeAccountDetails[] = []; + if (instructedAmount) { + withdrawalAccountList = await fetchWithdrawalAccountInfo( + wex, + { + exchange, + instructedAmount, + }, + wex.cancellationToken, + ); + } const senderWire = withdrawalGroup.wgInfo.bankInfo.senderWire; @@ -3498,9 +3524,9 @@ export async function confirmWithdrawal( await tx.bankAccountsV2.indexes.byPaytoUri.get(senderWire); if (existingAccount) { // Add currency for existing known bank account if necessary - if (existingAccount.currencies?.includes(instructedAmount.currency)) { + if (existingAccount.currencies?.includes(instructedCurrency)) { existingAccount.currencies = [ - instructedAmount.currency, + instructedCurrency, ...(existingAccount.currencies ?? []), ]; existingAccount.currencies.sort(); @@ -3511,7 +3537,7 @@ export async function confirmWithdrawal( const myId = `acct:${encodeCrock(getRandomBytes(32))}`; await tx.bankAccountsV2.put({ - currencies: [instructedAmount.currency], + currencies: [instructedCurrency], kycCompleted: false, paytoUri: senderWire, bankAccountId: myId, @@ -3533,12 +3559,17 @@ export async function confirmWithdrawal( wex, withdrawalGroup.withdrawalGroupId, ); - const initalDenoms = await getInitialDenomsSelection( - wex, - exchange.exchangeBaseUrl, - instructedAmount, - req.forcedDenomSel, - ); + + let initialDenoms: DenomSelectionState | undefined; + + if (instructedAmount != null) { + initialDenoms = await getInitialDenomsSelection( + wex, + exchange.exchangeBaseUrl, + instructedAmount, + req.forcedDenomSel, + ); + } let pending = false; await ctx.transition({}, async (rec) => { @@ -3560,9 +3591,19 @@ export async function confirmWithdrawal( rec.exchangeBaseUrl = exchange.exchangeBaseUrl; rec.instructedAmount = req.amount; rec.restrictAge = req.restrictAge; - rec.denomsSel = initalDenoms; - rec.rawWithdrawalAmount = initalDenoms.totalWithdrawCost; - rec.effectiveWithdrawalAmount = initalDenoms.totalCoinValue; + 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", @@ -3583,6 +3624,7 @@ export async function confirmWithdrawal( await wex.taskScheduler.resetTaskRetries(ctx.taskId); + // FIXME: Merge with transaction above! const res = await wex.db.runReadWriteTx( { storeNames: ["exchanges"], @@ -3660,14 +3702,20 @@ export async function acceptBankIntegratedWithdrawal( contents: "prepared acceptBankIntegratedWithdrawal", }); - let amount: AmountString; + let amount: AmountString | undefined; if (p.info.amount == null) { if (req.amount == null) { - throw Error( - "amount required, as withdrawal operation has flexible amount", - ); + if (p.info.editableAmount) { + throw Error( + "amount required, as withdrawal operation has flexible amount", + ); + } + // Amount will be determined by the bank only after withdrawal has + // been confirmed by the wallet. + amount = undefined; + } else { + amount = Amounts.stringify(req.amount); } - amount = Amounts.stringify(req.amount); } else { if (req.amount == null) { amount = p.info.amount; @@ -3686,7 +3734,7 @@ export async function acceptBankIntegratedWithdrawal( logger.info(`confirming withdrawal with tx ${p.transactionId}`); await confirmWithdrawal(wex, { - amount: Amounts.stringify(amount), + amount: amount == null ? undefined : Amounts.stringify(amount), exchangeBaseUrl: selectedExchange, transactionId: p.transactionId, restrictAge: req.restrictAge, @@ -3783,7 +3831,7 @@ async function fetchAccount( cancellationToken: CancellationToken, ): Promise<WithdrawalExchangeAccountDetails> { let paytoUri: string; - let transferAmount: AmountString | undefined = undefined; + let transferAmount: AmountString | undefined; let currencySpecification: CurrencySpecification | undefined = undefined; if (acct.conversion_url != null) { const reqUrl = new URL("cashin-rate", acct.conversion_url); @@ -3860,9 +3908,7 @@ async function fetchAccount( currencySpecification, creditRestrictions: acct.credit_restrictions, }; - if (transferAmount != null) { - acctInfo.transferAmount = transferAmount; - } + acctInfo.transferAmount = transferAmount; return acctInfo; }