taler-typescript-core

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

commit 5c0b8673e85c3136a994f1645b28a825e63783d0
parent 321d21b3f6dd20db153568bce545eb00d8189935
Author: Florian Dold <dold@taler.net>
Date:   Tue,  3 Nov 2026 09:35:29 +0100

wallet-core: count withdrawal KYC volume across reserves

Diffstat:
Apackages/taler-harness/src/integrationtests/test-kyc-withdrawal-recovery.ts | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 3+++
Mpackages/taler-wallet-core/src/withdraw.ts | 24+++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/withdrawal-kyc.ts | 81++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
4 files changed, 195 insertions(+), 33 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-kyc-withdrawal-recovery.ts b/packages/taler-harness/src/integrationtests/test-kyc-withdrawal-recovery.ts @@ -0,0 +1,120 @@ +/* + This file is part of GNU Taler + (C) 2026 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 assert from "node:assert/strict"; +import { + WithdrawalKycStatus, + TalerErrorCode, + AmountString, + TransactionState, + TransactionMajorState as Major, + TransactionMinorState as Minor, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation as Op } from "@gnu-taler/taler-wallet-core"; +import { GlobalTestState } from "../harness/harness.js"; +import { + KycTestEnv, + configureSimpleKycInfoRule, + createKycTestkudosEnvironmentFull, +} from "../harness/environments.js"; +async function withdraw( + env: KycTestEnv, + user: { username: string }, + amount: AmountString, + target: TransactionState, + expectedPreview?: WithdrawalKycStatus, +) { + const wop = await env.bankClient.createWithdrawalOperation( + user.username, + amount, + ); + const prepared = await env.walletClient.call( + Op.PrepareBankIntegratedWithdrawal, + { talerWithdrawUri: wop.taler_withdraw_uri }, + ); + const preview = await env.walletClient.call( + Op.GetWithdrawalDetailsForAmount, + { + exchangeBaseUrl: env.exchange.baseUrl, + transactionId: prepared.transactionId, + amount, + }, + ); + + if (expectedPreview) + assert.equal(preview.withdrawalKycStatus, expectedPreview); + await env.walletClient.call(Op.ConfirmWithdrawal, { + transactionId: prepared.transactionId, + exchangeBaseUrl: env.exchange.baseUrl, + amount, + }); + await env.walletClient.call(Op.TestingWaitTransactionState, { + transactionId: prepared.transactionId, + txState: { major: Major.Pending, minor: Minor.BankConfirmTransfer }, + }); + await env.bankClient.confirmWithdrawalOperation(user.username, { + withdrawalOperationId: wop.withdrawal_id, + }); + await env.walletClient.call(Op.TestingWaitTransactionState, { + transactionId: prepared.transactionId, + txState: target, + }); + return prepared.transactionId; +} + +export async function runKycWithdrawalCumulativeHardTest(t: GlobalTestState) { + const env = await createKycTestkudosEnvironmentFull(t, { + walletPersistent: true, + adjustExchangeConfig(config) { + configureSimpleKycInfoRule(config, "withdraw"); + config.setString("KYC-RULE-R1", "threshold", "TESTKUDOS:150"); + config.setString("KYC-RULE-R1", "next_measures", "verboten"); + }, + }); + const user = await env.bankClient.createRandomBankUser(); + env.bankClient.setAuth({ username: user.username, password: user.password }); + await withdraw(env, user, "TESTKUDOS:100", { major: Major.Done }); + await env.walletService.stop(); + await env.walletService.start(); + await env.walletService.pingUntilAvailable(); + await env.walletClient.connect(); + const init = await env.walletClient.call(Op.InitWallet, { + config: { testing: { skipDefaults: true } }, + }); + assert.equal(init.databaseBackend, t.expectedWalletDbBackend); + const id = await withdraw( + env, + user, + "TESTKUDOS:100", + { + major: Major.Failed, + minor: Minor.KycHardLimit, + }, + "hard-limit", + ); + const tx = await env.walletClient.call(Op.GetTransactionById, { + transactionId: id, + }); + + assert.equal(tx.failReason?.code, TalerErrorCode.WALLET_KYC_LIMIT_EXCEEDED); + assert.equal( + tx.txState.major, + Major.Failed, + "Cumulative hard limit should trigger reserve recovery and fail", + ); +} + +runKycWithdrawalCumulativeHardTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -115,6 +115,7 @@ import { runKycSkipExpirationTest } from "./test-kyc-skip-expiration.js"; import { runKycTwoFormsTest } from "./test-kyc-two-forms.js"; import { runKycWalletDepositAbortTest } from "./test-kyc-wallet-deposit-abort.js"; import { runKycWithdrawalCumulativeTest } from "./test-kyc-withdrawal-cumulative.js"; +import { runKycWithdrawalCumulativeHardTest } from "./test-kyc-withdrawal-recovery.js"; import { runKycWithdrawalVerbotenTest } from "./test-kyc-withdrawal-verboten.js"; import { runLibeufinBankTest } from "./test-libeufin-bank.js"; import { runLibeufinConversionTest } from "./test-libeufin-conversion.js"; @@ -472,6 +473,8 @@ const allTests: TestMainFunction[] = [ runKycBalanceWithdrawalTest, runKycBalanceWithdrawalCumulativeTest, runKycBalanceWithdrawalZeroLimitTest, + withWalletDbBackend(runKycWithdrawalCumulativeHardTest, "default"), + withWalletDbBackend(runKycWithdrawalCumulativeHardTest, "indexeddb"), withWalletDbBackend(runKycWithdrawalCumulativeTest, "default"), withWalletDbBackend(runKycWithdrawalCumulativeTest, "indexeddb"), runKycNewMeasureTest, diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -206,7 +206,10 @@ import { parseTransactionIdentifier, } from "./transactions.js"; import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions.js"; -import { getWithdrawalVolumeKycStatus } from "./withdrawal-kyc.js"; +import { + getCompletedWithdrawalVolume, + getWithdrawalVolumeKycStatus, +} from "./withdrawal-kyc.js"; import { denomRefKey, WalletExecutionContext, @@ -2762,7 +2765,26 @@ async function processWithdrawalGroupPendingKyc( checkDbInvariant(!!amount, "amount must be known for KYC"); if (withdrawalGroup.kycPaytoHash) { + const isBankWithdrawal = + withdrawalGroup.wgInfo.withdrawalType === + WithdrawalRecordType.BankIntegrated || + withdrawalGroup.wgInfo.withdrawalType === WithdrawalRecordType.BankManual; myKycState = { + getTimeframeVolume: isBankWithdrawal + ? async (rule) => { + const groups = await wex.runWalletDbTx((tx) => + tx.getWithdrawalGroupsByExchange(exchangeBaseUrl), + ); + return getCompletedWithdrawalVolume({ + rule, + groups, + exchangeBaseUrl, + paytoHash: kycPaytoHash, + withdrawalGroupId: withdrawalGroup.withdrawalGroupId, + amount, + }).volume; + } + : undefined, accountPriv, accountPub, amount, diff --git a/packages/taler-wallet-core/src/withdrawal-kyc.ts b/packages/taler-wallet-core/src/withdrawal-kyc.ts @@ -55,6 +55,52 @@ function senderHash(group: WalletWithdrawalGroup): string | undefined { return group.kycPaytoHash; } +/** Known completed volume; missing history never supplies evidence of a violation. */ +export function getCompletedWithdrawalVolume(args: { + rule: AccountLimit; + groups: WalletWithdrawalGroup[]; + exchangeBaseUrl: string; + paytoHash: string; + withdrawalGroupId: string; + amount: AmountLike; + now?: AbsoluteTime; +}) { + const cutoff = AbsoluteTime.subtractDuraction( + args.now ?? AbsoluteTime.now(), + Duration.fromTalerProtocolDuration(args.rule.timeframe), + ); + let incomplete = false; + let volume = Amounts.zeroOfAmount(args.amount); + for (const group of args.groups) { + if ( + group.withdrawalGroupId === args.withdrawalGroupId || + group.exchangeBaseUrl !== args.exchangeBaseUrl || + group.status !== WithdrawalGroupStatus.Done || + !bankWithdrawal(group) + ) + continue; + const hash = senderHash(group); + if (hash != null && hash !== args.paytoHash) continue; + if (group.timestampFinish == null) { + incomplete = true; + continue; + } + const finished = timestampAbsoluteFromDb(group.timestampFinish); + if (AbsoluteTime.cmp(finished, cutoff) < 0) continue; + if ( + hash == null || + group.rawWithdrawalAmount == null || + Amounts.currencyOf(group.rawWithdrawalAmount) !== + Amounts.currencyOf(args.amount) + ) { + incomplete = true; + continue; + } + volume = Amounts.add(volume, group.rawWithdrawalAmount).amount; + } + return { volume, incomplete }; +} + /** Evaluate completed withdrawals, independently of coins still held by the wallet. */ export function evaluateWithdrawalVolumeLimits(args: { rules: AccountLimit[]; @@ -76,38 +122,9 @@ export function evaluateWithdrawalVolumeLimits(args: { incomplete = true; continue; } - const cutoff = AbsoluteTime.subtractDuraction( - now, - Duration.fromTalerProtocolDuration(rule.timeframe), - ); - let volume = Amounts.zeroOfAmount(args.amount); - for (const group of args.groups) { - if ( - group.withdrawalGroupId === args.withdrawalGroupId || - group.exchangeBaseUrl !== args.exchangeBaseUrl || - group.status !== WithdrawalGroupStatus.Done || - !bankWithdrawal(group) - ) - continue; - const hash = senderHash(group); - if (hash != null && hash !== args.paytoHash) continue; - if (group.timestampFinish == null) { - incomplete = true; - continue; - } - const finished = timestampAbsoluteFromDb(group.timestampFinish); - if (AbsoluteTime.cmp(finished, cutoff) < 0) continue; - if ( - hash == null || - group.rawWithdrawalAmount == null || - Amounts.currencyOf(group.rawWithdrawalAmount) !== - Amounts.currencyOf(args.amount) - ) { - incomplete = true; - continue; - } - volume = Amounts.add(volume, group.rawWithdrawalAmount).amount; - } + const history = getCompletedWithdrawalVolume({ ...args, rule, now }); + const { volume } = history; + incomplete ||= history.incomplete; if ( Amounts.cmp(Amounts.add(volume, args.amount).amount, rule.threshold) > 0 ) {