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:
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
) {