taler-typescript-core

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

commit 9191d998f8327bfa0a165e9a9a353ecd3c726df0
parent 0fdd0c48dc747d16a5a5fe4752873c91b353e614
Author: Florian Dold <florian@dold.me>
Date:   Sun,  6 Oct 2024 16:31:52 +0200

wallet-core: kyc hard/soft limits

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-kyc-threshold-withdrawal.ts | 233++++++++++++++-----------------------------------------------------------------
Mpackages/taler-wallet-core/src/deposits.ts | 17++++++++++++++++-
Mpackages/taler-wallet-core/src/exchanges.ts | 2++
Apackages/taler-wallet-core/src/kyc.ts | 252+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-peer-pull-credit.ts | 7+++++++
Mpackages/taler-wallet-core/src/pay-peer-push-credit.ts | 46++++++++++++++++++++++++++++++++++++++--------
Mpackages/taler-wallet-core/src/withdraw.ts | 15++++++++++++++-
7 files changed, 370 insertions(+), 202 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-kyc-threshold-withdrawal.ts b/packages/taler-harness/src/integrationtests/test-kyc-threshold-withdrawal.ts @@ -19,205 +19,55 @@ */ import { NotificationType, - TalerCorebankApiClient, TransactionMajorState, TransactionMinorState, TransactionType, } from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { - createSyncCryptoApi, - EddsaKeyPairStrings, - WalletApiOperation, -} from "@gnu-taler/taler-wallet-core"; -import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js"; -import { - BankService, - DbInfo, - ExchangeService, - getTestHarnessPaytoForLabel, - GlobalTestState, - HarnessExchangeBankAccount, - setupDb, - WalletClient, - WalletService, -} from "../harness/harness.js"; -import { EnvOptions, postAmlDecisionNoRules } from "../harness/environments.js"; - -interface KycTestEnv { - commonDb: DbInfo; - bankClient: TalerCorebankApiClient; - exchange: ExchangeService; - exchangeBankAccount: HarnessExchangeBankAccount; - walletClient: WalletClient; - walletService: WalletService; - amlKeypair: EddsaKeyPairStrings; -} - -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 = getTestHarnessPaytoForLabel(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, - }); - - const ageMaskSpec = opts.ageMaskSpec; - - if (ageMaskSpec) { - exchange.enableAgeRestrictions(ageMaskSpec); - // Enable age restriction for all coins. - exchange.addCoinConfigList( - coinConfig.map((x) => ({ - ...x, - name: `${x.name}-age`, - ageRestricted: true, - })), - ); - // For mixed age restrictions, we also offer coins without age restrictions - if (opts.mixedAgeRestriction) { - exchange.addCoinConfigList( - coinConfig.map((x) => ({ ...x, ageRestricted: false })), - ); - } - } else { - exchange.addCoinConfigList(coinConfig); - } - - await exchange.modifyConfig(async (config) => { - config.setString("exchange", "enable_kyc", "yes"); - - config.setString("KYC-RULE-R1", "operation_type", "withdraw"); - 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:5"); - config.setString("KYC-RULE-R1", "timeframe", "1d"); - config.setString("KYC-RULE-R1", "next_measures", "M1"); - - config.setString("KYC-RULE-R1", "operation_type", "withdraw"); - 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:300"); - config.setString("KYC-RULE-R1", "timeframe", "1d"); - config.setString("KYC-RULE-R1", "next_measures", "verboten"); - - 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: "", - }, - }; -} + createKycTestkudosEnvironment, + postAmlDecisionNoRules, +} from "../harness/environments.js"; +import { GlobalTestState } from "../harness/harness.js"; export async function runKycThresholdWithdrawalTest(t: GlobalTestState) { // Set up test environment const { walletClient, bankClient, exchange, amlKeypair } = - await createKycTestkudosEnvironment(t); + await createKycTestkudosEnvironment(t, { + adjustExchangeConfig(config) { + config.setString("exchange", "enable_kyc", "yes"); + + config.setString("KYC-RULE-R1", "operation_type", "withdraw"); + 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:5"); + config.setString("KYC-RULE-R1", "timeframe", "1d"); + config.setString("KYC-RULE-R1", "next_measures", "M1"); + + config.setString("KYC-RULE-R1", "operation_type", "withdraw"); + 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:300"); + config.setString("KYC-RULE-R1", "timeframe", "1d"); + config.setString("KYC-RULE-R1", "next_measures", "verboten"); + + 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"); + }, + }); // Withdraw digital cash into the wallet. @@ -247,8 +97,8 @@ export async function runKycThresholdWithdrawalTest(t: GlobalTestState) { }, ); - // t.assertTrue(!!withdrawalAmountInfo.kycHardLimit); - // t.assertAmountEquals(withdrawalAmountInfo.kycHardLimit, "TESTKUDOS:300"); + t.assertTrue(!!withdrawalAmountInfo.kycHardLimit); + t.assertAmountEquals(withdrawalAmountInfo.kycHardLimit, "TESTKUDOS:300"); // Withdraw @@ -268,7 +118,6 @@ export async function runKycThresholdWithdrawalTest(t: GlobalTestState) { withdrawalOperationId: wop.withdrawal_id, }); - t.logStep("waiting for pending(kyc-required)"); const kycNotificationCond = walletClient.waitForNotificationCond((x) => { diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts @@ -115,7 +115,7 @@ import { getScopeForAllExchanges, } from "./exchanges.js"; import { EddsaKeyPairStrings } from "./index.js"; -import { getDepositLimitInfo } from "./kyc.js"; +import { checkDepositHardLimitExceeded, getDepositLimitInfo } from "./kyc.js"; import { extractContractData, generateDepositPermissions, @@ -2035,6 +2035,21 @@ export async function createDepositGroup( assertUnreachable(payCoinSel); } + const usedExchangesSet = new Set<string>(); + for (const c of coins) { + usedExchangesSet.add(c.exchangeBaseUrl); + } + + const exchanges: ReadyExchangeSummary[] = []; + + for (const exchangeBaseUrl of usedExchangesSet) { + exchanges.push(await fetchFreshExchange(wex, exchangeBaseUrl)); + } + + if (checkDepositHardLimitExceeded(exchanges, req.amount)) { + throw Error("deposit would exceed hard KYC limit"); + } + // Heuristic for the merchant key pair: If there's an exchange where we made // a withdrawal from, use that key pair, so the user doesn't have to do // a KYC transfer to establish a kyc account key pair. diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -1761,6 +1761,8 @@ export async function updateExchangeFromUrlHandler( wireInfo, ageMask, walletBalanceLimits: keysInfo.walletBalanceLimits, + hardLimits: keysInfo.hardLimits, + zeroLimits: keysInfo.zeroLimits, }; r.noFees = noFees; r.peerPaymentsDisabled = peerPaymentsDisabled; diff --git a/packages/taler-wallet-core/src/kyc.ts b/packages/taler-wallet-core/src/kyc.ts @@ -0,0 +1,252 @@ +/* + This file is part of GNU Taler + (C) 2024 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/> + */ + +import { + AmountJson, + AmountLike, + Amounts, + AmountString, +} from "@gnu-taler/taler-util"; +import { ReadyExchangeSummary } from "./exchanges.js"; + +/** + * @fileoverview Helpers for KYC. + * @author Florian Dold <dold@taler.net> + */ + +export interface SimpleLimitInfo { + kycHardLimit: AmountString | undefined; + kycSoftLimit: AmountString | undefined; +} + +export interface MultiExchangeLimitInfo { + kycHardLimit: AmountString | undefined; + kycSoftLimit: AmountString | undefined; + + /** + * Exchanges that would require soft KYC. + */ + kycExchanges: string[]; +} + +/** + * Return the smallest given amount, where an undefined amount + * is interpreted the larger amount. + */ +function minDefAmount( + a: AmountLike | undefined, + b: AmountLike | undefined, +): AmountJson { + if (a == null) { + if (b == null) { + throw Error(); + } + return Amounts.jsonifyAmount(b); + } + if (b == null) { + if (a == null) { + throw Error(); + } + return Amounts.jsonifyAmount(a); + } + return Amounts.min(a, b); +} + +/** + * Add to an amount. + * Interprets the second argument as zero if not defined. + */ +function addDefAmount(a: AmountLike, b: AmountLike | undefined): AmountJson { + if (b == null) { + return Amounts.jsonifyAmount(a); + } + return Amounts.add(a, b).amount; +} + +export function getDepositLimitInfo( + exchanges: ReadyExchangeSummary[], + instructedAmount: AmountLike, +): MultiExchangeLimitInfo { + let kycHardLimit: AmountJson | undefined; + let kycSoftLimit: AmountJson | undefined; + let kycExchanges: string[] = []; + + // FIXME: Summing up the limits doesn't really make a lot of sense, + // as the funds at each exchange are limited (by the coins in the wallet), + // and thus an exchange where we don't have coins but that has a high + // KYC limit can't meaningfully contribute to the whole limit. + + for (const exchange of exchanges) { + const exchLim = getSingleExchangeDepositLimitInfo( + exchange, + instructedAmount, + ); + if (exchLim.kycSoftLimit) { + kycExchanges.push(exchange.exchangeBaseUrl); + kycSoftLimit = addDefAmount(exchLim.kycSoftLimit, kycSoftLimit); + } + if (exchLim.kycHardLimit) { + kycHardLimit = addDefAmount(exchLim.kycHardLimit, kycHardLimit); + } + } + + return { + kycHardLimit: kycHardLimit ? Amounts.stringify(kycHardLimit) : undefined, + kycSoftLimit: kycSoftLimit ? Amounts.stringify(kycSoftLimit) : undefined, + kycExchanges, + }; +} + +export function getSingleExchangeDepositLimitInfo( + exchange: ReadyExchangeSummary, + instructedAmount: AmountLike, +): SimpleLimitInfo { + let kycHardLimit: AmountJson | undefined; + let kycSoftLimit: AmountJson | undefined; + + for (let lim of exchange.hardLimits) { + switch (lim.operation_type) { + case "DEPOSIT": + case "AGGREGATE": + // FIXME: This should consider past deposits and KYC checks + kycHardLimit = minDefAmount(kycHardLimit, lim.threshold); + break; + } + } + + for (let limAmount of exchange.walletBalanceLimitWithoutKyc ?? []) { + kycSoftLimit = minDefAmount(kycSoftLimit, limAmount); + } + + for (let lim of exchange.zeroLimits) { + switch (lim.operation_type) { + case "DEPOSIT": + case "AGGREGATE": + kycSoftLimit = Amounts.zeroOfAmount(instructedAmount); + break; + } + } + + return { + kycHardLimit: kycHardLimit ? Amounts.stringify(kycHardLimit) : undefined, + kycSoftLimit: kycSoftLimit ? Amounts.stringify(kycSoftLimit) : undefined, + }; +} + +export function getPeerCreditLimitInfo( + exchange: ReadyExchangeSummary, + instructedAmount: AmountLike, +): SimpleLimitInfo { + let kycHardLimit: AmountJson | undefined; + let kycSoftLimit: AmountJson | undefined; + + for (let lim of exchange.hardLimits) { + switch (lim.operation_type) { + case "BALANCE": + case "MERGE": + // FIXME: This should consider past merges and KYC checks + kycHardLimit = minDefAmount(kycHardLimit, lim.threshold); + break; + } + } + + for (let limAmount of exchange.walletBalanceLimitWithoutKyc ?? []) { + kycSoftLimit = minDefAmount(kycSoftLimit, limAmount); + } + + for (let lim of exchange.zeroLimits) { + switch (lim.operation_type) { + case "BALANCE": + case "MERGE": + kycSoftLimit = Amounts.zeroOfAmount(instructedAmount); + break; + } + } + + return { + kycHardLimit: kycHardLimit ? Amounts.stringify(kycHardLimit) : undefined, + kycSoftLimit: kycSoftLimit ? Amounts.stringify(kycSoftLimit) : undefined, + }; +} + +export function checkWithdrawalHardLimitExceeded( + exchange: ReadyExchangeSummary, + instructedAmount: AmountLike, +): boolean { + const limitInfo = getWithdrawalLimitInfo(exchange, instructedAmount); + return ( + limitInfo.kycHardLimit != null && + Amounts.cmp(limitInfo.kycHardLimit, instructedAmount) <= 0 + ); +} + +export function checkPeerCreditHardLimitExceeded( + exchange: ReadyExchangeSummary, + instructedAmount: AmountLike, +): boolean { + const limitInfo = getPeerCreditLimitInfo(exchange, instructedAmount); + return ( + limitInfo.kycHardLimit != null && + Amounts.cmp(limitInfo.kycHardLimit, instructedAmount) <= 0 + ); +} + +export function checkDepositHardLimitExceeded( + exchanges: ReadyExchangeSummary[], + instructedAmount: AmountLike, +): boolean { + const limitInfo = getDepositLimitInfo(exchanges, instructedAmount); + return ( + limitInfo.kycHardLimit != null && + Amounts.cmp(limitInfo.kycHardLimit, instructedAmount) <= 0 + ); +} + +export function getWithdrawalLimitInfo( + exchange: ReadyExchangeSummary, + instructedAmount: AmountLike, +): SimpleLimitInfo { + let kycHardLimit: AmountJson | undefined; + let kycSoftLimit: AmountJson | undefined; + + for (let lim of exchange.hardLimits) { + switch (lim.operation_type) { + case "BALANCE": + case "WITHDRAW": + // FIXME: This should consider past withdrawals and KYC checks + kycHardLimit = minDefAmount(kycHardLimit, lim.threshold); + break; + } + } + + for (let limAmount of exchange.walletBalanceLimitWithoutKyc ?? []) { + kycSoftLimit = minDefAmount(kycSoftLimit, limAmount); + } + + for (let lim of exchange.zeroLimits) { + switch (lim.operation_type) { + case "BALANCE": + case "WITHDRAW": + kycSoftLimit = Amounts.zeroOfAmount(instructedAmount); + break; + } + } + + return { + kycHardLimit: kycHardLimit ? Amounts.stringify(kycHardLimit) : undefined, + kycSoftLimit: kycSoftLimit ? Amounts.stringify(kycSoftLimit) : undefined, + }; +} diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts @@ -95,6 +95,7 @@ import { getScopeForAllExchanges, handleStartExchangeWalletKyc, } from "./exchanges.js"; +import { checkPeerCreditHardLimitExceeded } from "./kyc.js"; import { codecForExchangePurseStatus, getMergeReserveInfo, @@ -1465,6 +1466,12 @@ export async function initiatePeerPullPayment( const exchange = await fetchFreshExchange(wex, exchangeBaseUrl); requireExchangeTosAcceptedOrThrow(exchange); + if ( + checkPeerCreditHardLimitExceeded(exchange, req.partialContractTerms.amount) + ) { + throw Error("peer credit would exceed hard KYC limit"); + } + const mergeReserveInfo = await getMergeReserveInfo(wex, { exchangeBaseUrl: exchangeBaseUrl, }); diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts @@ -91,7 +91,10 @@ import { getScopeForAllExchanges, handleStartExchangeWalletKyc, } from "./exchanges.js"; -import { getPeerCreditLimitInfo } from "./kyc.js"; +import { + checkPeerCreditHardLimitExceeded, + getPeerCreditLimitInfo, +} from "./kyc.js"; import { codecForExchangePurseStatus, getMergeReserveInfo, @@ -1298,31 +1301,55 @@ export async function confirmPeerPushCredit( logger.trace(`confirming peer-push-credit ${ctx.peerPushCreditId}`); - const peerInc = await wex.db.runReadWriteTx( + const res = await wex.db.runReadWriteTx( { storeNames: ["contractTerms", "peerPushCredit", "transactionsMeta"] }, async (tx) => { const rec = await tx.peerPushCredit.get(ctx.peerPushCreditId); if (!rec) { return; } - if (rec.status === PeerPushCreditStatus.DialogProposed) { - rec.status = PeerPushCreditStatus.PendingMerge; + const ct = await tx.contractTerms.get(rec.contractTermsHash); + if (!ct) { + return undefined; } - await tx.peerPushCredit.put(rec); - await ctx.updateTransactionMeta(tx); - return rec; + return { + peerInc: rec, + contractTerms: ct.contractTermsRaw as PeerContractTerms, + }; }, ); - if (!peerInc) { + if (!res) { throw Error( `can't accept unknown incoming p2p push payment (${req.transactionId})`, ); } + const peerInc = res.peerInc; + const exchange = await fetchFreshExchange(wex, peerInc.exchangeBaseUrl); requireExchangeTosAcceptedOrThrow(exchange); + if (checkPeerCreditHardLimitExceeded(exchange, res.contractTerms.amount)) { + throw Error("peer credit would exceed hard KYC limit"); + } + + await wex.db.runReadWriteTx( + { storeNames: ["contractTerms", "peerPushCredit", "transactionsMeta"] }, + async (tx) => { + const rec = await tx.peerPushCredit.get(ctx.peerPushCreditId); + if (!rec) { + return; + } + if (rec.status === PeerPushCreditStatus.DialogProposed) { + rec.status = PeerPushCreditStatus.PendingMerge; + } + await tx.peerPushCredit.put(rec); + await ctx.updateTransactionMeta(tx); + return rec; + }, + ); + wex.taskScheduler.startShepherdTask(ctx.taskId); return { @@ -1454,3 +1481,6 @@ export function computePeerPushCreditTransactionActions( assertUnreachable(pushCreditRecord.status); } } +function checkPeerHardLimitExceeded(exchanges: any, amount: any) { + throw new Error("Function not implemented."); +} diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -173,7 +173,10 @@ import { lookupExchangeByUri, markExchangeUsed, } from "./exchanges.js"; -import { getWithdrawalLimitInfo } from "./kyc.js"; +import { + checkWithdrawalHardLimitExceeded, + getWithdrawalLimitInfo, +} from "./kyc.js"; import { DbAccess } from "./query.js"; import { TransitionInfo, @@ -2568,6 +2571,8 @@ export async function getExchangeWithdrawalInfo( throw Error("exchange is in invalid state"); } + logger.info(`exchange ready summary: ${j2s(exchange)}`); + const ret: ExchangeWithdrawalDetails = { exchangePaytoUris: paytoUris, exchangeWireAccounts, @@ -3407,6 +3412,10 @@ export async function confirmWithdrawal( const exchange = await fetchFreshExchange(wex, selectedExchange); requireExchangeTosAcceptedOrThrow(exchange); + if (checkWithdrawalHardLimitExceeded(exchange, req.amount)) { + throw Error("withdrawal would exceed hard KYC limit"); + } + const talerWithdrawUri = withdrawalGroup.wgInfo.bankInfo.talerWithdrawUri; /** @@ -3877,6 +3886,10 @@ export async function createManualWithdrawal( ); } + if (checkWithdrawalHardLimitExceeded(exchange, req.amount)) { + throw Error("withdrawal would exceed hard KYC limit"); + } + let reserveKeyPair: EddsaKeyPairStrings; if (req.forceReservePriv) { const pubResp = await wex.cryptoApi.eddsaGetPublic({