taler-typescript-core

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

commit 2015d76af1721e81cc00fd6640ec07b94fc65cb6
parent c6dd41b63c9e5c88d5e916bacb258c37b1efc11b
Author: Florian Dold <florian@dold.me>
Date:   Sun,  4 May 2025 00:26:35 +0200

wallet-core: improve handling of balance thresholds, extend test

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-kyc-balance-withdrawal.ts | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mpackages/taler-util/src/time.ts | 6++++++
Mpackages/taler-util/src/types-taler-exchange.ts | 3+++
Mpackages/taler-wallet-core/src/exchanges.ts | 64++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mpackages/taler-wallet-core/src/withdraw.ts | 54++++++++++++++++++++++++++----------------------------
5 files changed, 160 insertions(+), 50 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 @@ -23,6 +23,10 @@ import { ExchangeWalletKycStatus, hashFullPaytoUri, j2s, + LimitOperationType, + TalerProtocolDuration, + TalerProtocolTimestamp, + TalerWireGatewayHttpClient, TransactionIdStr, TransactionMajorState, TransactionMinorState, @@ -31,7 +35,7 @@ import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { configureCommonKyc, createKycTestkudosEnvironment, - postAmlDecisionNoRules, + postAmlDecision, withdrawViaBankV3, } from "../harness/environments.js"; import { GlobalTestState } from "../harness/harness.js"; @@ -67,10 +71,15 @@ function adjustExchangeConfig(config: Configuration): void { export async function runKycBalanceWithdrawalTest(t: GlobalTestState) { // Set up test environment - const { walletClient, bankClient, exchange, amlKeypair } = - await createKycTestkudosEnvironment(t, { - adjustExchangeConfig, - }); + const { + walletClient, + bankClient, + exchange, + amlKeypair, + exchangeBankAccount, + } = await createKycTestkudosEnvironment(t, { + adjustExchangeConfig, + }); // Withdraw digital cash into the wallet. @@ -130,16 +139,78 @@ export async function runKycBalanceWithdrawalTest(t: GlobalTestState) { `payto://taler-reserve-http/localhost:${exchange.port}/${kycReservePub}`, ); - await postAmlDecisionNoRules(t, { + await postAmlDecision(t, { amlPriv: amlKeypair.priv, amlPub: amlKeypair.pub, exchangeBaseUrl: exchange.baseUrl, paytoHash: encodeCrock(hPayto), + newRules: { + expiration_time: TalerProtocolTimestamp.never(), + rules: [ + { + measures: ["verboten"], + display_priority: 1, + operation_type: LimitOperationType.balance, + threshold: "TESTKUDOS:30", + timeframe: TalerProtocolDuration.forever(), + exposed: true, + }, + { + measures: ["verboten"], + display_priority: 1, + operation_type: LimitOperationType.deposit, + threshold: "TESTKUDOS:5", + timeframe: TalerProtocolDuration.fromSpec({ days: 1 }), + exposed: true, + }, + ], + custom_measures: {}, + }, }); } // Now after KYC is done for the balance, the withdrawal should finish await wres.withdrawalFinishedCond; + + // Now test the *next* threshold, but this time using manual withdrawal + + t.logStep("testing manual withdrawal"); + + const mwResp = await walletClient.call( + WalletApiOperation.AcceptManualWithdrawal, + { + // Specify larger amount than what will be in the reserve. + amount: "TESTKUDOS:100", + exchangeBaseUrl: exchange.baseUrl, + }, + ); + + const user = await bankClient.createRandomBankUser(); + + // Next, do a manual withdrawal. + const wireGatewayApiClient = new TalerWireGatewayHttpClient( + exchangeBankAccount.wireGatewayApiBaseUrl, + ); + + const reservePub = mwResp.reservePub; + + await wireGatewayApiClient.addIncoming({ + auth: exchangeBankAccount.wireGatewayAuth, + body: { + amount: "TESTKUDOS:5", + debit_account: user.accountPaytoUri, + reserve_pub: reservePub, + }, + }); + + // Wallet should not require KYC, since the actual amount withdrawn + // does not pass the threshold. + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: mwResp.transactionId as TransactionIdStr, + txState: { + major: TransactionMajorState.Done, + }, + }); } runKycBalanceWithdrawalTest.suites = ["wallet"]; diff --git a/packages/taler-util/src/time.ts b/packages/taler-util/src/time.ts @@ -101,6 +101,12 @@ export namespace TalerProtocolDuration { export function fromSpec(d: DurationUnitSpec) { return Duration.toTalerProtocolDuration(Duration.fromSpec(d)); } + + export function forever(): TalerProtocolDuration { + return { + d_us: "forever", + }; + } } export namespace TalerProtocolTimestamp { diff --git a/packages/taler-util/src/types-taler-exchange.ts b/packages/taler-util/src/types-taler-exchange.ts @@ -1850,6 +1850,9 @@ export interface WalletKycRequest { reserve_pub: EddsaPublicKeyString; } +/** + * Doc name: api-exchange/WalletKycCheckResponse + */ export interface WalletKycCheckResponse { // Next balance limit above which a KYC check // may be required. Optional, not given if no diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -61,6 +61,7 @@ import { HttpStatusCode, LegitimizationNeededResponse, LibtoolVersion, + LimitOperationType, ListExchangesRequest, Logger, NotificationType, @@ -96,6 +97,7 @@ import { checkDbInvariant, checkLogicInvariant, codecForAccountKycStatus, + codecForAmlWalletKycCheckResponse, codecForExchangeKeysResponse, codecForLegitimizationNeededResponse, durationMul, @@ -3288,7 +3290,12 @@ export async function checkIncomingAmountLegalUnderKycBalanceThreshold( checkDbInvariant(!!reserveRec, "reserve"); // FIXME: also consider KYC expiration! if (reserveRec.thresholdNext) { - if (Amounts.cmp(reserveRec.thresholdNext, balExpected) >= 0) { + logger.trace( + `Next threshold ${Amounts.stringify( + reserveRec.thresholdNext, + )} not yet breached`, + ); + if (Amounts.cmp(reserveRec.thresholdNext, balExpected) <= 0) { return { result: "ok", }; @@ -3296,6 +3303,9 @@ export async function checkIncomingAmountLegalUnderKycBalanceThreshold( } 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. + logger.warn( + `No next balance threshold, assuming balance KYC is okay`, + ); return { result: "ok", }; @@ -3549,11 +3559,15 @@ async function handleExchangeKycPendingWallet( case HttpStatusCode.Ok: { // KYC somehow already passed // FIXME: Store next threshold and timestamp! - const accountKycStatus = await readSuccessResponseJsonOrThrow( + const walletKycResp = await readSuccessResponseJsonOrThrow( res, - codecForAccountKycStatus(), + codecForAmlWalletKycCheckResponse(), + ); + return handleExchangeKycSuccess( + wex, + exchange.baseUrl, + walletKycResp.next_threshold, ); - return handleExchangeKycSuccess(wex, exchange.baseUrl, accountKycStatus); } case HttpStatusCode.NoContent: { // KYC disabled at exchange. @@ -3581,7 +3595,7 @@ async function handleExchangeKycPendingWallet( async function handleExchangeKycSuccess( wex: WalletExecutionContext, exchangeBaseUrl: string, - accountKycStatus: AccountKycStatus | undefined, + nextThreshold: AmountLike | undefined, ): Promise<TaskRunResult> { logger.info(`kyc check for ${exchangeBaseUrl} satisfied`); const dbRes = await wex.db.runReadWriteTx( @@ -3610,16 +3624,9 @@ async function handleExchangeKycSuccess( 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; - } - } + if (nextThreshold) { + reserve.thresholdNext = Amounts.stringify(nextThreshold); } - reserve.thresholdNext = nextLimit; await tx.reserves.put(reserve); logger.info(`newly granted threshold: ${reserve.thresholdGranted}`); @@ -3639,6 +3646,21 @@ async function handleExchangeKycSuccess( return TaskRunResult.progress(); } +function findNextBalanceLimit( + accountKycStatus: AccountKycStatus, + currentGranted: AmountLike | undefined, +): AmountString | undefined { + for (const lim of accountKycStatus.limits ?? []) { + if ( + lim.operation_type === LimitOperationType.balance && + (currentGranted == null || Amounts.cmp(lim.threshold, currentGranted) > 0) + ) { + return lim.threshold; + } + } + return undefined; +} + /** * The exchange has just told us that we need some legitimization * from the user. Request more details and store the result in the database. @@ -3670,7 +3692,12 @@ async function handleExchangeKycRespLegi( resp, codecForAccountKycStatus(), ); - return handleExchangeKycSuccess(wex, exchangeBaseUrl, accountKycStatus); + logger.trace(`balance KYC account status: ${j2s(accountKycStatus)}`); + const nextLimit = findNextBalanceLimit( + accountKycStatus, + reserve.thresholdGranted, + ); + return handleExchangeKycSuccess(wex, exchangeBaseUrl, nextLimit); } case HttpStatusCode.Accepted: { // Store the result in the DB! @@ -3776,7 +3803,12 @@ async function handleExchangeKycPendingLegitimization( resp, codecForAccountKycStatus(), ); - return handleExchangeKycSuccess(wex, exchange.baseUrl, accountKycStatus); + logger.trace(`balance KYC account status: ${j2s(accountKycStatus)}`); + const nextLimit = findNextBalanceLimit( + accountKycStatus, + reserve.thresholdGranted, + ); + return handleExchangeKycSuccess(wex, exchange.baseUrl, nextLimit); } case HttpStatusCode.Accepted: // FIXME: Do we ever need to update the access token? diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -1038,7 +1038,7 @@ async function processWithdrawalGroupBalanceKyc( } if ( withdrawalGroup.status === - WithdrawalGroupStatus.PendingBalanceKycInit && + WithdrawalGroupStatus.PendingBalanceKycInit && checkRes.walletKycStatus === ExchangeWalletKycStatus.Legi ) { return checkRes; @@ -2061,7 +2061,8 @@ export async function updateWithdrawalDenomsForExchange( denom.verificationStatus === DenominationVerificationStatus.Unverified ) { logger.trace( - `Validating denomination (${current + 1}/${denominations.length + `Validating denomination (${current + 1}/${ + denominations.length }) signature of ${denom.denomPubHash}`, ); let valid = false; @@ -2563,6 +2564,8 @@ async function processWithdrawalGroupPendingReady( withdrawalGroup.effectiveWithdrawalAmount, ); + logger.info(`balance-kyc check result: ${j2s(kycCheckRes)}`); + if (kycCheckRes.result === "violation") { // Do this before we transition so that the exchange is already in the right state. await handleStartExchangeWalletKyc(wex, { @@ -2621,7 +2624,6 @@ async function processWithdrawalGroupPendingReady( for (let i = 0; i < numTotalCoins; i += maxBatchSize) { let resp: WithdrawalBatchResult; if (exchangeVer.current >= 26) { - logger.warn("new exchange version, but still using old batch request"); resp = await processPlanchetExchangeBatchRequest(wex, wgContext, { batchSize: maxBatchSize, coinStartIndex: i, @@ -2752,7 +2754,7 @@ export async function processWithdrawalGroup( logger.trace("processing withdrawal group", withdrawalGroupId); const withdrawalGroup = await wex.db.runReadOnlyTx( { storeNames: ["withdrawalGroups"] }, - (tx) => tx.withdrawalGroups.get(withdrawalGroupId) + (tx) => tx.withdrawalGroups.get(withdrawalGroupId), ); if (!withdrawalGroup) { @@ -2873,7 +2875,7 @@ export async function getExchangeWithdrawalInfo( ) { logger.warn( `wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` + - `(exchange has ${exchange.protocolVersionRange}), checking for updates`, + `(exchange has ${exchange.protocolVersionRange}), checking for updates`, ); } } @@ -3059,9 +3061,8 @@ async function getWithdrawalGroupRecordTx( withdrawalGroupId: string; }, ): Promise<WithdrawalGroupRecord | undefined> { - return await db.runReadOnlyTx( - { storeNames: ["withdrawalGroups"] }, - (tx) => tx.withdrawalGroups.get(req.withdrawalGroupId) + return await db.runReadOnlyTx({ storeNames: ["withdrawalGroups"] }, (tx) => + tx.withdrawalGroups.get(req.withdrawalGroupId), ); } @@ -3100,7 +3101,7 @@ async function registerReserveWithBank( ): Promise<TaskRunResult> { const withdrawalGroup = await wex.db.runReadOnlyTx( { storeNames: ["withdrawalGroups"] }, - (tx) => tx.withdrawalGroups.get(withdrawalGroupId) + (tx) => tx.withdrawalGroups.get(withdrawalGroupId), ); const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); switch (withdrawalGroup?.status) { @@ -3426,11 +3427,7 @@ async function getInitialDenomsSelection( if (forcedDenoms) { logger.warn("using forced denom selection"); - return selectForcedWithdrawalDenominations( - amount, - denoms, - forcedDenoms, - ); + return selectForcedWithdrawalDenominations(amount, denoms, forcedDenoms); } else { return selectWithdrawalDenominations(amount, denoms); } @@ -3528,9 +3525,9 @@ export async function internalPrepareCreateWithdrawalGroup( !amount || !exchangeBaseUrl ? undefined : { - amount, - canonExchange: exchangeBaseUrl, - }, + amount, + canonExchange: exchangeBaseUrl, + }, }; } @@ -3688,7 +3685,8 @@ export async function prepareBankIntegratedWithdrawal( ): Promise<PrepareBankIntegratedWithdrawalResponse> { const existingWithdrawalGroup = await wex.db.runReadOnlyTx( { storeNames: ["withdrawalGroups"] }, - (tx) => tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(req.talerWithdrawUri) + (tx) => + tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(req.talerWithdrawUri), ); const parsedUri = parseTalerUri(req.talerWithdrawUri); @@ -3807,7 +3805,7 @@ export async function confirmWithdrawal( } const withdrawalGroup = await wex.db.runReadOnlyTx( { storeNames: ["withdrawalGroups"] }, - (tx) => tx.withdrawalGroups.get(parsedTx.withdrawalGroupId) + (tx) => tx.withdrawalGroups.get(parsedTx.withdrawalGroupId), ); if (!withdrawalGroup) { @@ -3916,7 +3914,7 @@ export async function confirmWithdrawal( const bankAccountId = await wex.db.runReadWriteTx( { storeNames: ["bankAccountsV2"] }, - (tx) => storeKnownBankAccount(tx, instructedCurrency, senderWire) + (tx) => storeKnownBankAccount(tx, instructedCurrency, senderWire), ); if (bankAccountId) { @@ -4002,14 +4000,13 @@ export async function confirmWithdrawal( await wex.taskScheduler.resetTaskRetries(ctx.taskId); // FIXME: Merge with transaction above! - const res = await wex.db.runReadWriteTx( - { storeNames: ["exchanges"] }, - (tx) => internalPerformExchangeWasUsed( + const res = await wex.db.runReadWriteTx({ storeNames: ["exchanges"] }, (tx) => + internalPerformExchangeWasUsed( wex, tx, exchange.exchangeBaseUrl, withdrawalGroup, - ) + ), ); if (res.exchangeNotif) { wex.ws.notify(res.exchangeNotif); @@ -4106,7 +4103,8 @@ export async function acceptBankIntegratedWithdrawal( const newWithdrawralGroup = await wex.db.runReadOnlyTx( { storeNames: ["withdrawalGroups"] }, - (tx) => tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(req.talerWithdrawUri) + (tx) => + tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(req.talerWithdrawUri), ); checkDbInvariant( @@ -4173,7 +4171,7 @@ async function fetchAccount( // fetch currency specification from DB const resp = await wex.db.runReadOnlyTx( { storeNames: ["currencyInfo"] }, - (tx) => WalletDbHelpers.getCurrencyInfo(tx, scopeInfo) + (tx) => WalletDbHelpers.getCurrencyInfo(tx, scopeInfo), ); if (resp) { @@ -4307,7 +4305,7 @@ export async function createManualWithdrawal( const exchangePaytoUris = await wex.db.runReadOnlyTx( { storeNames: ["withdrawalGroups", "exchanges", "exchangeDetails"] }, - (tx) => getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId) + (tx) => getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId), ); wex.ws.notify({ @@ -4346,7 +4344,7 @@ export async function waitWithdrawalFinal( // Check if withdrawal is final const wg = await ctx.wex.db.runReadOnlyTx( { storeNames: ["withdrawalGroups"] }, - (tx) => tx.withdrawalGroups.get(ctx.withdrawalGroupId) + (tx) => tx.withdrawalGroups.get(ctx.withdrawalGroupId), ); if (!wg) { // Must've been deleted, we consider that final.