taler-typescript-core

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

commit 7dc29f9bc4cb3a8a1943ede93e5b0834f67d9788
parent 2643f33dbdfb6bb75d42ddce7c0c65c7adfa0842
Author: Florian Dold <florian@dold.me>
Date:   Wed, 21 Aug 2024 15:24:40 +0200

wallet-core: balance limits for withdrawal

Diffstat:
Apackages/taler-harness/src/integrationtests/test-kyc-balance-withdrawal.ts | 266+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 2++
Mpackages/taler-util/src/types-taler-exchange.ts | 38+++++++++++++++++++++++++++++---------
Mpackages/taler-util/src/types-taler-wallet-transactions.ts | 1+
Mpackages/taler-util/src/types-taler-wallet.ts | 7+++++++
Mpackages/taler-wallet-core/src/balance.ts | 2++
Mpackages/taler-wallet-core/src/db.ts | 25+++++++++++++++++++------
Mpackages/taler-wallet-core/src/exchanges.ts | 228++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 12++++++++++++
Mpackages/taler-wallet-core/src/wallet.ts | 5+++++
Mpackages/taler-wallet-core/src/withdraw.ts | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
11 files changed, 697 insertions(+), 32 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-kyc-balance-withdrawal.ts b/packages/taler-harness/src/integrationtests/test-kyc-balance-withdrawal.ts @@ -0,0 +1,266 @@ +/* + This file is part of GNU Taler + (C) 2020 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Imports. + */ +import { + encodeCrock, + ExchangeWalletKycStatus, + hashPaytoUri, + j2s, + TalerCorebankApiClient, + TransactionIdStr, + TransactionMajorState, + TransactionMinorState, +} from "@gnu-taler/taler-util"; +import { + createSyncCryptoApi, + EddsaKeypair, + WalletApiOperation, +} from "@gnu-taler/taler-wallet-core"; +import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js"; +import { + BankService, + DbInfo, + ExchangeService, + generateRandomPayto, + GlobalTestState, + HarnessExchangeBankAccount, + setupDb, + WalletClient, + WalletService, +} from "../harness/harness.js"; +import { + EnvOptions, + postAmlDecisionNoRules, + withdrawViaBankV3, +} from "../harness/helpers.js"; + +interface KycTestEnv { + commonDb: DbInfo; + bankClient: TalerCorebankApiClient; + exchange: ExchangeService; + exchangeBankAccount: HarnessExchangeBankAccount; + walletClient: WalletClient; + walletService: WalletService; + amlKeypair: EddsaKeypair; +} + +async function createKycTestkudosEnvironment( + t: GlobalTestState, + coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS")), + opts: EnvOptions = {}, +): Promise<KycTestEnv> { + const db = await setupDb(t); + + const bank = await BankService.create(t, { + allowRegistrations: true, + currency: "TESTKUDOS", + database: db.connStr, + httpPort: 8082, + }); + + const exchange = ExchangeService.create(t, { + name: "testexchange-1", + currency: "TESTKUDOS", + httpPort: 8081, + database: db.connStr, + }); + + let receiverName = "Exchange"; + let exchangeBankUsername = "exchange"; + let exchangeBankPassword = "mypw"; + let exchangePaytoUri = generateRandomPayto(exchangeBankUsername); + + await exchange.addBankAccount("1", { + accountName: exchangeBankUsername, + accountPassword: exchangeBankPassword, + wireGatewayApiBaseUrl: new URL( + "accounts/exchange/taler-wire-gateway/", + bank.baseUrl, + ).href, + accountPaytoUri: exchangePaytoUri, + }); + + bank.setSuggestedExchange(exchange, exchangePaytoUri); + + await bank.start(); + + await bank.pingUntilAvailable(); + + const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, { + auth: { + username: "admin", + password: "adminpw", + }, + }); + + await bankClient.registerAccountExtended({ + name: receiverName, + password: exchangeBankPassword, + username: exchangeBankUsername, + is_taler_exchange: true, + payto_uri: exchangePaytoUri, + }); + + exchange.addCoinConfigList(coinConfig); + + await exchange.modifyConfig(async (config) => { + config.setString("exchange", "enable_kyc", "yes"); + + config.setString("KYC-RULE-R1", "operation_type", "balance"); + config.setString("KYC-RULE-R1", "enabled", "yes"); + config.setString("KYC-RULE-R1", "exposed", "yes"); + config.setString("KYC-RULE-R1", "is_and_combinator", "yes"); + config.setString("KYC-RULE-R1", "threshold", "TESTKUDOS:10"); + config.setString("KYC-RULE-R1", "timeframe", "forever"); + config.setString("KYC-RULE-R1", "next_measures", "M1"); + + config.setString("KYC-MEASURE-M1", "check_name", "C1"); + config.setString("KYC-MEASURE-M1", "context", "{}"); + config.setString("KYC-MEASURE-M1", "program", "P1"); + + config.setString("AML-PROGRAM-P1", "command", "/bin/true"); + config.setString("AML-PROGRAM-P1", "enabled", "true"); + config.setString("AML-PROGRAM-P1", "description", "this does nothing"); + config.setString("AML-PROGRAM-P1", "fallback", "M1"); + + config.setString("KYC-CHECK-C1", "type", "INFO"); + config.setString("KYC-CHECK-C1", "description", "my check!"); + config.setString("KYC-CHECK-C1", "fallback", "M1"); + }); + + await exchange.start(); + + const cryptoApi = createSyncCryptoApi(); + const amlKeypair = await cryptoApi.createEddsaKeypair({}); + + await exchange.enableAmlAccount(amlKeypair.pub, "Alice"); + + const walletService = new WalletService(t, { + name: "wallet", + useInMemoryDb: true, + }); + await walletService.start(); + await walletService.pingUntilAvailable(); + + const walletClient = new WalletClient({ + name: "wallet", + unixPath: walletService.socketPath, + onNotification(n) { + console.log("got notification", n); + }, + }); + await walletClient.connect(); + await walletClient.client.call(WalletApiOperation.InitWallet, { + config: { + testing: { + skipDefaults: true, + }, + }, + }); + + console.log("setup done!"); + + return { + commonDb: db, + exchange, + amlKeypair, + walletClient, + walletService, + bankClient, + exchangeBankAccount: { + accountName: "", + accountPassword: "", + accountPaytoUri: "", + wireGatewayApiBaseUrl: "", + }, + }; +} + +export async function runKycBalanceWithdrawalTest(t: GlobalTestState) { + // Set up test environment + + const { walletClient, bankClient, exchange, amlKeypair } = + await createKycTestkudosEnvironment(t); + + // Withdraw digital cash into the wallet. + + const wres = await withdrawViaBankV3(t, { + amount: "TESTKUDOS:20", + bankClient, + exchange, + walletClient, + }); + + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: wres.transactionId as TransactionIdStr, + txState: { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.BalanceKycRequired, + }, + }); + + { + const exchangeEntry = await walletClient.call( + WalletApiOperation.GetExchangeEntryByUrl, + { + exchangeBaseUrl: exchange.baseUrl, + }, + ); + console.log(j2s(exchangeEntry)); + } + + await walletClient.call(WalletApiOperation.TestingWaitExchangeState, { + exchangeBaseUrl: exchange.baseUrl, + walletKycStatus: ExchangeWalletKycStatus.Legi, + }); + + { + const exchangeEntry = await walletClient.call( + WalletApiOperation.GetExchangeEntryByUrl, + { + exchangeBaseUrl: exchange.baseUrl, + }, + ); + console.log(j2s(exchangeEntry)); + t.assertDeepEqual( + exchangeEntry.walletKycStatus, + ExchangeWalletKycStatus.Legi, + ); + + const kycReservePub = exchangeEntry.walletKycReservePub; + t.assertTrue(!!kycReservePub); + + // FIXME: Create/user helper function for this! + const hPayto = hashPaytoUri( + `payto://taler-reserve-http/localhost:${exchange.port}/${kycReservePub}`, + ); + + await postAmlDecisionNoRules(t, { + amlPriv: amlKeypair.priv, + amlPub: amlKeypair.pub, + exchangeBaseUrl: exchange.baseUrl, + paytoHash: encodeCrock(hPayto), + }); + } + + // Now after KYC is done for the balance, the withdrawal should finish + await wres.withdrawalFinishedCond; +} + +runKycBalanceWithdrawalTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -47,6 +47,7 @@ import { runExchangePurseTest } from "./test-exchange-purse.js"; import { runExchangeTimetravelTest } from "./test-exchange-timetravel.js"; import { runFeeRegressionTest } from "./test-fee-regression.js"; import { runForcedSelectionTest } from "./test-forced-selection.js"; +import { runKycBalanceWithdrawalTest } from "./test-kyc-balance-withdrawal.js"; import { runKycDepositAggregateTest } from "./test-kyc-deposit-aggregate.js"; import { runKycExchangeWalletTest } from "./test-kyc-exchange-wallet.js"; import { runKycFormWithdrawalTest } from "./test-kyc-form-withdrawal.js"; @@ -256,6 +257,7 @@ const allTests: TestMainFunction[] = [ runKycPeerPullTest, runKycDepositAggregateTest, runKycFormWithdrawalTest, + runKycBalanceWithdrawalTest, ]; export interface TestRunSpec { diff --git a/packages/taler-util/src/types-taler-exchange.ts b/packages/taler-util/src/types-taler-exchange.ts @@ -448,6 +448,12 @@ export interface ExchangeKeysJson { wire_fees: { [methodName: string]: WireFeesJson[] }; denominations: DenomGroup[]; + + // Threshold amounts beyond which wallet should + // trigger the KYC process of the issuing exchange. + // Optional option, if not given there is no limit. + // Currency must match currency. + wallet_balance_limit_without_kyc?: AmountString[]; } export interface ExchangeMeltRequest { @@ -877,6 +883,10 @@ export const codecForExchangeKeysJson = (): Codec<ExchangeKeysJson> => .property("accounts", codecForList(codecForExchangeWireAccount())) .property("wire_fees", codecForMap(codecForList(codecForWireFeesJson()))) .property("denominations", codecForList(codecForNgDenominations)) + .property( + "wallet_balance_limit_without_kyc", + codecOptional(codecForList(codecForAmountString())), + ) .build("ExchangeKeysJson"); export const codecForWireFeesJson = (): Codec<WireFeesJson> => @@ -1692,8 +1702,7 @@ export interface KycProcessClientInformation { declare const opaque_kycReq: unique symbol; export type KycRequirementInformationId = string & { [opaque_kycReq]: true }; declare const opaque_formId: unique symbol; -export type KycBuiltInFromId = string & { [opaque_formId]: true } - +export type KycBuiltInFromId = string & { [opaque_formId]: true }; export interface KycRequirementInformation { // Which form should be used? Common values include "INFO" @@ -2435,17 +2444,25 @@ export const codecForKycCheckPublicInformation = export const codecForKycRequirementInformationId = (): Codec<KycRequirementInformationId> => codecForString() as Codec<KycRequirementInformationId>; -export const codecForKycFormId = - (): Codec<KycBuiltInFromId> => - codecForString() as Codec<KycBuiltInFromId>; - +export const codecForKycFormId = (): Codec<KycBuiltInFromId> => + codecForString() as Codec<KycBuiltInFromId>; export const codecForKycRequirementInformation = (): Codec<KycRequirementInformation> => buildCodecForObject<KycRequirementInformation>() - .property("form", codecForEither(codecForConstString("LINK"), codecForConstString("INFO"), codecForKycFormId())) + .property( + "form", + codecForEither( + codecForConstString("LINK"), + codecForConstString("INFO"), + codecForKycFormId(), + ), + ) .property("description", codecForString()) - .property("description_i18n", codecOptional(codecForInternationalizedString())) + .property( + "description_i18n", + codecOptional(codecForInternationalizedString()), + ) .property("id", codecOptional(codecForKycRequirementInformationId())) .build("TalerExchangeApi.KycRequirementInformation"); @@ -2454,7 +2471,10 @@ export const codecForKycProcessClientInformation = buildCodecForObject<KycProcessClientInformation>() .property( "requirements", - codecOptionalDefault(codecForList(codecForKycRequirementInformation()), []), + codecOptionalDefault( + codecForList(codecForKycRequirementInformation()), + [], + ), ) .property("is_and_combinator", codecOptional(codecForBoolean())) .property( diff --git a/packages/taler-util/src/types-taler-wallet-transactions.ts b/packages/taler-util/src/types-taler-wallet-transactions.ts @@ -128,6 +128,7 @@ export enum TransactionMinorState { Deposit = "deposit", KycRequired = "kyc", MergeKycRequired = "merge-kyc", + BalanceKycRequired = "balance-kyc", Track = "track", SubmitPayment = "submit-payment", RebindSession = "rebind-session", diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -1480,6 +1480,8 @@ export interface ExchangeListItem { walletKycStatus?: ExchangeWalletKycStatus; walletKycReservePub?: string; + walletKycAccessToken?: string; + walletKycUrl?: string; /** * P2P payments are disabled with this exchange @@ -3222,6 +3224,11 @@ export interface WalletContractData { minimumAge?: number; } +export interface TestingWaitExchangeStateRequest { + exchangeBaseUrl: string; + walletKycStatus?: ExchangeWalletKycStatus; +} + export interface TestingWaitTransactionRequest { transactionId: TransactionIdStr; txState: TransactionState; diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts @@ -360,6 +360,8 @@ export async function getBalancesInsideTransaction( case WithdrawalGroupStatus.SuspendedQueryingStatus: // Pending, but no special flag. break; + case WithdrawalGroupStatus.PendingBalanceKyc: + case WithdrawalGroupStatus.SuspendedBalanceKyc: case WithdrawalGroupStatus.SuspendedKyc: case WithdrawalGroupStatus.PendingKyc: { checkDbInvariant( diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts @@ -304,6 +304,18 @@ export enum WithdrawalGroupStatus { SuspendedReady = 0x0110_0004, /** + * Exchange wants KYC info from the user. + */ + PendingKyc = 0x0100_0005, + SuspendedKyc = 0x0110_005, + + /** + * Exchange wants KYC info from the user. + */ + PendingBalanceKyc = 0x0100_0006, + SuspendedBalanceKyc = 0x0110_006, + + /** * Proposed to the user, has can choose to accept/refuse. */ DialogProposed = 0x0101_0000, @@ -316,12 +328,6 @@ export enum WithdrawalGroupStatus { SuspendedAbortingBank = 0x0113_0001, /** - * Exchange wants KYC info from the user. - */ - PendingKyc = 0x0100_0005, - SuspendedKyc = 0x0110_005, - - /** * The corresponding withdraw record has been created. * No further processing is done, unless explicitly requested * by the user. @@ -607,6 +613,8 @@ export interface ExchangeDetailsRecord { * Age restrictions supported by the exchange (bitmask). */ ageMask?: number; + + walletBalanceLimits?: AmountString[]; } export interface ExchangeDetailsPointer { @@ -2178,6 +2186,11 @@ export interface ReserveRecord { */ thresholdGranted?: AmountString; + /** + * Threshold that will trigger the next KYC. + */ + thresholdNext?: AmountString; + kycAccessToken?: string; amlReview?: boolean; diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -25,9 +25,11 @@ */ import { AbsoluteTime, + AccountKycStatus, AgeRestriction, Amount, AmountLike, + AmountString, Amounts, CancellationToken, CoinRefreshRequest, @@ -74,6 +76,7 @@ import { TalerPreciseTimestamp, TalerProtocolDuration, TalerProtocolTimestamp, + TestingWaitExchangeStateRequest, TestingWaitWalletKycRequest, Transaction, TransactionAction, @@ -451,6 +454,10 @@ async function makeExchangeListItem( exchangeEntryStatus: getExchangeEntryStatusFromRecord(r), walletKycStatus, walletKycReservePub: reserveRec?.reservePub, + walletKycUrl: reserveRec?.kycAccessToken + ? new URL(`kyc-spa/${reserveRec.kycAccessToken}`, r.baseUrl).href + : undefined, + walletKycAccessToken: reserveRec?.kycAccessToken, tosStatus: getExchangeTosStatusFromRecord(r), ageRestrictionOptions: exchangeDetails?.ageMask ? AgeRestriction.getAgeGroupsFromMask(exchangeDetails.ageMask) @@ -838,6 +845,7 @@ export interface ExchangeKeysDownloadResult { accounts: ExchangeWireAccount[]; wireFees: { [methodName: string]: WireFeesJson[] }; currencySpecification?: CurrencySpecification; + walletBalanceLimits: AmountString[] | undefined; } /** @@ -1001,6 +1009,8 @@ async function downloadExchangeKeysInfo( accounts: exchangeKeysJsonUnchecked.accounts, wireFees: exchangeKeysJsonUnchecked.wire_fees, currencySpecification: exchangeKeysJsonUnchecked.currency_specification, + walletBalanceLimits: + exchangeKeysJsonUnchecked.wallet_balance_limit_without_kyc, }; } @@ -1730,6 +1740,7 @@ export async function updateExchangeFromUrlHandler( exchangeBaseUrl: r.baseUrl, wireInfo, ageMask, + walletBalanceLimits: keysInfo.walletBalanceLimits, }; r.noFees = noFees; r.peerPaymentsDisabled = peerPaymentsDisabled; @@ -2952,6 +2963,156 @@ export async function getExchangeWireFee( return fee; } +export type BalanceThresholdCheckResult = + | { + result: "ok"; + } + | { + result: "violation"; + nextThreshold: AmountString; + }; + +export async function checkIncomingAmountLegalUnderKycBalanceThreshold( + wex: WalletExecutionContext, + exchangeBaseUrl: string, + amountIncoming: AmountLike, +): Promise<BalanceThresholdCheckResult> { + logger.info(`checking ${exchangeBaseUrl} +${amountIncoming} for KYC`); + return await wex.db.runReadOnlyTx( + { + storeNames: [ + "exchanges", + "exchangeDetails", + "reserves", + "coinAvailability", + ], + }, + async (tx): Promise<BalanceThresholdCheckResult> => { + const exchangeRec = await tx.exchanges.get(exchangeBaseUrl); + if (!exchangeRec) { + throw Error("exchange not found"); + } + const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl); + if (!det) { + throw Error("exchange not found"); + } + const coinAvRecs = + await tx.coinAvailability.indexes.byExchangeBaseUrl.getAll( + exchangeBaseUrl, + ); + let balAmount = Amounts.zeroOfCurrency(det.currency); + for (const av of coinAvRecs) { + const n = av.freshCoinCount + (av.pendingRefreshOutputCount ?? 0); + balAmount = Amounts.add( + balAmount, + Amounts.mult(av.value, n).amount, + ).amount; + } + const balExpected = Amounts.add(balAmount, amountIncoming).amount; + + // Check if we already have KYC for a sufficient threshold. + + const reserveId = exchangeRec.currentMergeReserveRowId; + if (reserveId) { + const reserveRec = await tx.reserves.get(reserveId); + checkDbInvariant(!!reserveRec, "reserve"); + // FIXME: also consider KYC expiration! + if (reserveRec.thresholdNext) { + if (Amounts.cmp(reserveRec.thresholdNext, balExpected) >= 0) { + return { + result: "ok", + }; + } + } else if (reserveRec.status === ReserveRecordStatus.Done) { + // We don't know what the next threshold is, but we've passed *some* KYC + // check. We don't have enough information, so we allow the balance increase. + return { + result: "ok", + }; + } + } + + // No luck, check the next limit we should request, if any. + + const limits = det.walletBalanceLimits; + if (!limits) { + logger.info("no balance limits defined"); + return { + result: "ok", + }; + } + limits.sort((a, b) => Amounts.cmp(a, b)); + logger.info(`applicable limits: ${j2s(limits)}`); + let limViolated: AmountString | undefined = undefined; + let limNext: AmountString | undefined = undefined; + for (let i = 0; i < limits.length; i++) { + if (Amounts.cmp(limits[i], balExpected) <= 0) { + limViolated = limits[i]; + limNext = limits[i + 1]; + if (limNext == null || Amounts.cmp(limNext, balExpected) > 0) { + break; + } + } + } + if (!limViolated) { + logger.info("balance limit okay"); + return { + result: "ok", + }; + } else { + logger.info( + `balance limit ${limViolated} would be violated, next is ${limNext}`, + ); + return { + result: "violation", + nextThreshold: limNext ?? limViolated, + }; + } + }, + ); +} + +/** + * Wait until it is allowed for the user to add the given amount + * to the wallet balance, either because the balance is low enough + * or KYC was completed. + */ +export async function waitIncomingAmountLegalUnderKycBalanceThreshold( + wex: WalletExecutionContext, + exchangeBaseUrl: string, + amountExpected: AmountLike, +): Promise<void> { + await genericWaitForState(wex, { + async checkState(): Promise<boolean> { + const checkRes = await checkIncomingAmountLegalUnderKycBalanceThreshold( + wex, + exchangeBaseUrl, + amountExpected, + ); + logger.info( + `balance check result for ${exchangeBaseUrl} +${amountExpected}: ${j2s( + checkRes, + )}`, + ); + if (checkRes.result === "ok") { + return true; + } + await handleStartExchangeWalletKyc(wex, { + amount: checkRes.nextThreshold, + exchangeBaseUrl, + }); + return false; + }, + filterNotification(notif) { + return ( + (notif.type === NotificationType.ExchangeStateTransition && + notif.exchangeBaseUrl === exchangeBaseUrl) || + notif.type === NotificationType.BalanceChange + ); + }, + }); +} + /** * Wait until kyc has passed for the wallet. * @@ -3016,6 +3177,32 @@ export async function waitExchangeWalletKyc( }); } +export async function handleTestingWaitExchangeState( + wex: WalletExecutionContext, + req: TestingWaitExchangeStateRequest, +): Promise<EmptyObject> { + await genericWaitForState(wex, { + async checkState(): Promise<boolean> { + const exchangeEntry = await lookupExchangeByUri(wex, { + exchangeBaseUrl: req.exchangeBaseUrl, + }); + if (req.walletKycStatus) { + if (req.walletKycStatus !== exchangeEntry.walletKycStatus) { + return false; + } + } + return true; + }, + filterNotification(notif) { + return ( + notif.type === NotificationType.ExchangeStateTransition && + notif.exchangeBaseUrl === req.exchangeBaseUrl + ); + }, + }); + return {}; +} + export async function handleTestingWaitExchangeWalletKyc( wex: WalletExecutionContext, req: TestingWaitWalletKycRequest, @@ -3080,7 +3267,7 @@ export async function handleStartExchangeWalletKyc( } else { // FIXME: Check expiration once exchange tells us! logger.info( - `KYC already granted for ${req.exchangeBaseUrl} over ${reserveRec.thresholdGranted}`, + `KYC already granted for ${req.exchangeBaseUrl} over ${req.amount}, granted ${reserveRec.thresholdGranted}`, ); return undefined; } @@ -3127,11 +3314,15 @@ async function handleExchangeKycPendingWallet( case HttpStatusCode.Ok: { // KYC somehow already passed // FIXME: Store next threshold and timestamp! - return handleExchangeKycSuccess(wex, exchange.baseUrl); + const accountKycStatus = await readSuccessResponseJsonOrThrow( + res, + codecForAccountKycStatus(), + ); + return handleExchangeKycSuccess(wex, exchange.baseUrl, accountKycStatus); } case HttpStatusCode.NoContent: { // KYC disabled at exchange. - return handleExchangeKycSuccess(wex, exchange.baseUrl); + return handleExchangeKycSuccess(wex, exchange.baseUrl, undefined); } case HttpStatusCode.Forbidden: { // Did not work! @@ -3155,7 +3346,9 @@ async function handleExchangeKycPendingWallet( async function handleExchangeKycSuccess( wex: WalletExecutionContext, exchangeBaseUrl: string, + accountKycStatus: AccountKycStatus | undefined, ): Promise<TaskRunResult> { + logger.info(`kyc check for ${exchangeBaseUrl} satisfied`); const dbRes = await wex.db.runReadWriteTx( { storeNames: ["exchanges", "reserves"] }, async (tx) => { @@ -3181,7 +3374,20 @@ async function handleExchangeKycSuccess( reserve.thresholdGranted = reserve.thresholdRequested; delete reserve.thresholdRequested; delete reserve.requirementRow; + + // Try to figure out the next balance limit + let nextLimit: AmountString | undefined = undefined; + if (accountKycStatus?.limits) { + for (const lim of accountKycStatus.limits) { + if (lim.operation_type.toLowerCase() === "balance") { + nextLimit = lim.threshold; + } + } + } + reserve.thresholdNext = nextLimit; + await tx.reserves.put(reserve); + logger.info(`newly granted threshold: ${reserve.thresholdGranted}`); return { notification: { type: NotificationType.ExchangeStateTransition, @@ -3226,7 +3432,11 @@ async function handleExchangeKycRespLegi( switch (resp.status) { case HttpStatusCode.Ok: { // FIXME: Store information about next limit! - return handleExchangeKycSuccess(wex, exchangeBaseUrl); + const accountKycStatus = await readSuccessResponseJsonOrThrow( + resp, + codecForAccountKycStatus(), + ); + return handleExchangeKycSuccess(wex, exchangeBaseUrl, accountKycStatus); } case HttpStatusCode.Accepted: { // Store the result in the DB! @@ -3234,7 +3444,7 @@ async function handleExchangeKycRespLegi( } case HttpStatusCode.NoContent: { // KYC not configured, so already satisfied - return handleExchangeKycSuccess(wex, exchangeBaseUrl); + return handleExchangeKycSuccess(wex, exchangeBaseUrl, undefined); } default: { const err = await readTalerErrorResponse(resp); @@ -3327,14 +3537,18 @@ async function handleExchangeKycPendingLegitimization( switch (resp.status) { case HttpStatusCode.Ok: { // FIXME: Store information about next limit! - return handleExchangeKycSuccess(wex, exchange.baseUrl); + const accountKycStatus = await readSuccessResponseJsonOrThrow( + resp, + codecForAccountKycStatus(), + ); + return handleExchangeKycSuccess(wex, exchange.baseUrl, accountKycStatus); } case HttpStatusCode.Accepted: // FIXME: Do we ever need to update the access token? return TaskRunResult.longpollReturnedPending(); case HttpStatusCode.NoContent: { // KYC not configured, so already satisfied - return handleExchangeKycSuccess(wex, exchange.baseUrl); + return handleExchangeKycSuccess(wex, exchange.baseUrl, undefined); } default: { const err = await readTalerErrorResponse(resp); diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -140,6 +140,7 @@ import { TestingGetDenomStatsResponse, TestingGetReserveHistoryRequest, TestingSetTimetravelRequest, + TestingWaitExchangeStateRequest, TestingWaitTransactionRequest, TestingWaitWalletKycRequest, Transaction, @@ -279,6 +280,7 @@ export enum WalletApiOperation { TestingWaitTransactionsFinal = "testingWaitTransactionsFinal", TestingWaitRefreshesFinal = "testingWaitRefreshesFinal", TestingWaitTransactionState = "testingWaitTransactionState", + TestingWaitExchangeState = "testingWaitExchangeState", TestingWaitTasksDone = "testingWaitTasksDone", TestingSetTimetravel = "testingSetTimetravel", TestingGetDenomStats = "testingGetDenomStats", @@ -1217,6 +1219,15 @@ export type TestingWaitTransactionStateOp = { response: EmptyObject; }; +/** + * Wait until an exchange entry is in a particular state. + */ +export type TestingWaitExchangeStateOp = { + op: WalletApiOperation.TestingWaitTransactionState; + request: TestingWaitExchangeStateRequest; + response: EmptyObject; +}; + export type TestingPingOp = { op: WalletApiOperation.TestingPing; request: EmptyObject; @@ -1355,6 +1366,7 @@ export type WalletOperations = { [WalletApiOperation.TestingWaitRefreshesFinal]: TestingWaitRefreshesFinalOp; [WalletApiOperation.TestingSetTimetravel]: TestingSetTimetravelOp; [WalletApiOperation.TestingWaitTransactionState]: TestingWaitTransactionStateOp; + [WalletApiOperation.TestingWaitExchangeState]: TestingWaitExchangeStateOp; [WalletApiOperation.TestingWaitTasksDone]: TestingWaitTasksDoneOp; [WalletApiOperation.GetCurrencySpecification]: GetCurrencySpecificationOp; [WalletApiOperation.CreateStoredBackup]: CreateStoredBackupsOp; diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -270,6 +270,7 @@ import { getExchangeTos, getExchangeWireDetailsInTx, handleStartExchangeWalletKyc, + handleTestingWaitExchangeState, handleTestingWaitExchangeWalletKyc, listExchanges, lookupExchangeByUri, @@ -1557,6 +1558,10 @@ interface HandlerWithValidator<Tag extends WalletApiOperation> { } const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { + [WalletApiOperation.TestingWaitExchangeState]: { + codec: codecForAny(), + handler: handleTestingWaitExchangeState, + }, [WalletApiOperation.AbortTransaction]: { codec: codecForAbortTransaction(), handler: handleAbortTransaction, diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -159,13 +159,16 @@ import { isWithdrawableDenom } from "./denominations.js"; import { ExchangeWireDetails, ReadyExchangeSummary, + checkIncomingAmountLegalUnderKycBalanceThreshold, fetchFreshExchange, getExchangePaytoUri, getExchangeWireDetailsInTx, getScopeForAllExchanges, + handleStartExchangeWalletKyc, listExchanges, lookupExchangeByUri, markExchangeUsed, + waitIncomingAmountLegalUnderKycBalanceThreshold, } from "./exchanges.js"; import { DbAccess } from "./query.js"; import { @@ -183,10 +186,17 @@ import { WalletExecutionContext, getDenomInfo } from "./wallet.js"; */ const logger = new Logger("withdraw.ts"); +interface TxKycDetails { + kycAccessToken?: string; + kycUrl?: string; + kycPaytoHash?: string; +} + function buildTransactionForBankIntegratedWithdraw( wg: WithdrawalGroupRecord, scopes: ScopeInfo[], - ort?: OperationRetryRecord, + ort: OperationRetryRecord | undefined, + kycDetails: TxKycDetails | undefined, ): TransactionWithdrawal { if (wg.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) { throw Error(""); @@ -203,7 +213,7 @@ function buildTransactionForBankIntegratedWithdraw( const txState = computeWithdrawalTransactionStatus(wg); const zero = Amounts.stringify(Amounts.zeroOfCurrency(currency)); - return { + let txDetails: TransactionWithdrawal = { type: TransactionType.Withdrawal, txState, scopes, @@ -237,15 +247,22 @@ function buildTransactionForBankIntegratedWithdraw( tag: TransactionType.Withdrawal, withdrawalGroupId: wg.withdrawalGroupId, }), - ...(ort?.lastError ? { error: ort.lastError } : {}), }; + if (ort?.lastError) { + txDetails.error = ort.lastError; + } + if (kycDetails) { + txDetails = { ...txDetails, ...kycDetails }; + } + return txDetails; } function buildTransactionForManualWithdraw( wg: WithdrawalGroupRecord, exchangeDetails: ExchangeWireDetails | undefined, scopes: ScopeInfo[], - ort?: OperationRetryRecord, + ort: OperationRetryRecord | undefined, + kycDetails: TxKycDetails | undefined, ): TransactionWithdrawal { if (wg.wgInfo.withdrawalType !== WithdrawalRecordType.BankManual) throw Error(""); @@ -264,7 +281,7 @@ function buildTransactionForManualWithdraw( const txState = computeWithdrawalTransactionStatus(wg); - return { + let txDetails: TransactionWithdrawal = { type: TransactionType.Withdrawal, txState, scopes, @@ -292,6 +309,13 @@ function buildTransactionForManualWithdraw( }), ...(ort?.lastError ? { error: ort.lastError } : {}), }; + if (ort?.lastError) { + txDetails.error = ort.lastError; + } + if (kycDetails) { + txDetails = { ...txDetails, ...kycDetails }; + } + return txDetails; } export class WithdrawTransactionContext implements TransactionContext { @@ -327,18 +351,31 @@ export class WithdrawTransactionContext implements TransactionContext { if (!withdrawalGroupRecord) { return undefined; } + const exchangeBaseUrl = withdrawalGroupRecord.exchangeBaseUrl; const exchangeDetails = - withdrawalGroupRecord.exchangeBaseUrl === undefined + exchangeBaseUrl == null ? undefined - : await getExchangeWireDetailsInTx( - tx, - withdrawalGroupRecord.exchangeBaseUrl, - ); + : await getExchangeWireDetailsInTx(tx, exchangeBaseUrl); const scopes = await getScopeForAllExchanges( tx, !exchangeDetails ? [] : [exchangeDetails.exchangeBaseUrl], ); + let kycDetails: TxKycDetails | undefined = undefined; + + switch (withdrawalGroupRecord.status) { + case WithdrawalGroupStatus.PendingKyc: + case WithdrawalGroupStatus.SuspendedKyc: { + kycDetails = { + kycAccessToken: withdrawalGroupRecord.kycAccessToken, + kycPaytoHash: withdrawalGroupRecord.kycPending?.paytoHash, + kycUrl: withdrawalGroupRecord.kycUrl, + }; + break; + } + // For the balance KYC, the client should get the kycUrl etc. from the exchange entry! + } + const ort = await tx.operationRetries.get(this.taskId); if ( withdrawalGroupRecord.wgInfo.withdrawalType === @@ -348,6 +385,7 @@ export class WithdrawTransactionContext implements TransactionContext { withdrawalGroupRecord, scopes, ort, + kycDetails, ); } if (!exchangeDetails) { @@ -360,6 +398,7 @@ export class WithdrawTransactionContext implements TransactionContext { exchangeDetails, scopes, ort, + kycDetails, ); } @@ -588,6 +627,8 @@ export class WithdrawTransactionContext implements TransactionContext { case WithdrawalGroupStatus.SuspendedReady: case WithdrawalGroupStatus.PendingKyc: case WithdrawalGroupStatus.PendingQueryingStatus: + case WithdrawalGroupStatus.PendingBalanceKyc: + case WithdrawalGroupStatus.SuspendedBalanceKyc: newStatus = WithdrawalGroupStatus.AbortedExchange; break; case WithdrawalGroupStatus.PendingReady: @@ -794,6 +835,16 @@ export function computeWithdrawalTransactionStatus( major: TransactionMajorState.Aborted, minor: TransactionMinorState.CompletedByOtherWallet, }; + case WithdrawalGroupStatus.PendingBalanceKyc: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.BalanceKycRequired, + }; + case WithdrawalGroupStatus.SuspendedBalanceKyc: + return { + major: TransactionMajorState.Suspended, + minor: TransactionMinorState.BalanceKycRequired, + }; } } @@ -865,9 +916,51 @@ export function computeWithdrawalTransactionActions( return [TransactionAction.Delete]; case WithdrawalGroupStatus.DialogProposed: return [TransactionAction.Abort]; + case WithdrawalGroupStatus.PendingBalanceKyc: + return [ + TransactionAction.Suspend, + TransactionAction.Retry, + TransactionAction.Abort, + ]; + case WithdrawalGroupStatus.SuspendedBalanceKyc: + return [TransactionAction.Resume, TransactionAction.Abort]; } } +async function processWithdrawalGroupBalanceKyc( + ctx: WithdrawTransactionContext, + withdrawalGroup: WithdrawalGroupRecord, +): Promise<TaskRunResult> { + const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; + const amount = withdrawalGroup.effectiveWithdrawalAmount; + if (!exchangeBaseUrl) { + throw Error( + "invalid state (expected withdrawal group to have exchange base URL)", + ); + } + if (!amount) { + throw Error( + "invalid state (expected withdrawal group to have effective withdrawal amount)", + ); + } + await waitIncomingAmountLegalUnderKycBalanceThreshold( + ctx.wex, + exchangeBaseUrl, + amount, + ); + await ctx.transition({}, async (wg) => { + if (!wg) { + return TransitionResult.stay(); + } + if (wg.status !== WithdrawalGroupStatus.PendingBalanceKyc) { + return TransitionResult.stay(); + } + wg.status = WithdrawalGroupStatus.PendingReady; + return TransitionResult.transition(wg); + }); + return TaskRunResult.progress(); +} + async function processWithdrawalGroupDialogProposed( ctx: WithdrawTransactionContext, withdrawalGroup: WithdrawalGroupRecord, @@ -2080,6 +2173,33 @@ async function processWithdrawalGroupPendingReady( return TaskRunResult.finished(); } + checkDbInvariant( + withdrawalGroup.effectiveWithdrawalAmount != null, + "expected withdrawal group to have effective amount", + ); + + const kycCheckRes = await checkIncomingAmountLegalUnderKycBalanceThreshold( + wex, + withdrawalGroup.exchangeBaseUrl, + withdrawalGroup.effectiveWithdrawalAmount, + ); + + if (kycCheckRes.result === "violation") { + // Do this before we transition so that the exchange is already in the right state. + await handleStartExchangeWalletKyc(wex, { + amount: kycCheckRes.nextThreshold, + exchangeBaseUrl, + }); + await ctx.transition({}, async (wg) => { + if (!wg) { + return TransitionResult.stay(); + } + wg.status = WithdrawalGroupStatus.PendingBalanceKyc; + return TransitionResult.transition(wg); + }); + return TaskRunResult.progress(); + } + const numTotalCoins = withdrawalGroup.denomsSel.selectedDenoms .map((x) => x.count) .reduce((a, b) => a + b); @@ -2268,6 +2388,8 @@ export async function processWithdrawalGroup( return await processWithdrawalGroupAbortingBank(wex, withdrawalGroup); case WithdrawalGroupStatus.DialogProposed: return await processWithdrawalGroupDialogProposed(ctx, withdrawalGroup); + case WithdrawalGroupStatus.PendingBalanceKyc: + return await processWithdrawalGroupBalanceKyc(ctx, withdrawalGroup); case WithdrawalGroupStatus.AbortedBank: case WithdrawalGroupStatus.AbortedExchange: case WithdrawalGroupStatus.FailedAbortingBank: @@ -2277,6 +2399,7 @@ export async function processWithdrawalGroup( case WithdrawalGroupStatus.SuspendedReady: case WithdrawalGroupStatus.SuspendedRegisteringBank: case WithdrawalGroupStatus.SuspendedWaitConfirmBank: + case WithdrawalGroupStatus.SuspendedBalanceKyc: case WithdrawalGroupStatus.Done: case WithdrawalGroupStatus.FailedBankAborted: case WithdrawalGroupStatus.AbortedUserRefused: