taler-typescript-core

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

commit c77b2b130866d395b05bd428e09a6316f7d6cf79
parent 6a358f5b9238fe1cf7ef513922a6b81b2db26901
Author: Florian Dold <dold@taler.net>
Date:   Mon,  7 Sep 2026 12:37:13 +0200

taler-harness: cover TOPS transaction risk monitoring

Install TOPS, establish a deposit baseline and open an account through
KYC. Advance the exchange clock by two months and exercise deposits
below and above the volume threshold, including repeated deposits after
an investigation starts.

Issue: https://bugs.taler.net/n/9639

Diffstat:
Apackages/taler-harness/src/integrationtests/test-tops-transaction-risk-monitoring.ts | 329+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 2++
2 files changed, 331 insertions(+), 0 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-tops-transaction-risk-monitoring.ts b/packages/taler-harness/src/integrationtests/test-tops-transaction-risk-monitoring.ts @@ -0,0 +1,329 @@ +/* + 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/> + */ + +/** + * TOPS monitoring after KYC and two months of time travel. + * https://bugs.gnunet.org/view.php?id=9639#c24332 + */ +import { + AmountString, + Configuration, + decodeCrock, + OfficerId, + OfficerSession, + succeedOrThrow, + TalerProtocolTimestamp, + TransactionIdStr, + TransactionMajorState, + TransactionMinorState, + TransactionType, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { + configureCommonKyc, + createKycTestkudosEnvironmentFull, + postAmlDecision, + withdrawViaBankV3, +} from "../harness/environments.js"; +import { defaultCoinConfig } from "../harness/denomStructures.js"; +import { GlobalTestState, runCommand } from "../harness/harness.js"; + +const DAY_MS = 24 * 60 * 60 * 1000; +const FUTURE_MS = 60 * DAY_MS; +const WIRE_DELAY_US = 3 * 60 * 60 * 1000000; +const MONTH_SECONDS = 4 * 7 * 24 * 60 * 60; +const YEAR_SECONDS = 52 * 7 * 24 * 60 * 60; + +function adjustExchangeConfig(config: Configuration): void { + configureCommonKyc(config); + config.setString("KYC-RULE-R1", "operation_type", "aggregate"); + 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-MEASURE-M1", "check_name", "C1"); + config.setString("KYC-MEASURE-M1", "context", "{}"); + config.setString("KYC-CHECK-C1", "type", "INFO"); + config.setString("KYC-CHECK-C1", "description", "Open a monitored account"); + config.setString("KYC-CHECK-C1", "fallback", "FREEZE"); +} + +export async function runTopsTransactionRiskMonitoringTest(t: GlobalTestState) { + // Keep denominations withdrawable as well as spendable across the jump. + const { + walletClient, + bankClient, + exchange, + bank, + wireGatewayApi, + amlKeypair, + exchangeApi, + commonDb, + merchant, + } = await createKycTestkudosEnvironmentFull(t, { + adjustExchangeConfig, + coinConfig: [ + ...defaultCoinConfig.map((makeCoin) => makeCoin("TESTKUDOS")), + ...[100, 1000, 10000, 100000].map((value) => ({ + ...defaultCoinConfig[defaultCoinConfig.length - 1]("TESTKUDOS"), + name: `TESTKUDOS_large_${value}`, + value: `TESTKUDOS:${value}`, + })), + ].map((coin) => ({ ...coin, durationWithdraw: "90 days" })), + }); + // This scenario deposits directly from the wallet; no merchant is needed. + await merchant.stop(); + await exchange.dbinit({ customization: "tops" }); + const officer: OfficerSession = { + id: amlKeypair.pub as OfficerId, + __signingKey: decodeCrock(amlKeypair.priv), + }; + const withdrawal = await withdrawViaBankV3(t, { + bankClient, + exchange, + walletClient, + amount: "TESTKUDOS:500000", + }); + await withdrawal.withdrawalFinishedCond; + const deposit = async (amount: AmountString) => { + const result = await walletClient.call( + WalletApiOperation.CreateDepositGroup, + { + amount, + depositPaytoUri: withdrawal.accountPaytoUri, + }, + ); + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: result.transactionId, + txState: { + major: TransactionMajorState.Finalizing, + minor: TransactionMinorState.Track, + }, + }); + return result; + }; + const finishDeposit = async ( + transactionId: TransactionIdStr, + offsetMs: number, + ) => { + const timetravelMicroseconds = offsetMs * 1000 + WIRE_DELAY_US; + await exchange.runAggregatorOnceWithTimetravel({ timetravelMicroseconds }); + await exchange.runTransferOnceWithTimetravel({ timetravelMicroseconds }); + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId, + txState: { major: TransactionMajorState.Done }, + }); + }; + + t.logStep("deposit-triggers-kyc"); + const baseline = await deposit("TESTKUDOS:100000"); + const depositTx = await walletClient.call( + WalletApiOperation.GetTransactionById, + { + transactionId: baseline.transactionId, + }, + ); + t.assertDeepEqual(depositTx.type, TransactionType.Deposit); + succeedOrThrow( + await wireGatewayApi.addKycAuth({ + auth: bank.getAdminAuth(), + body: { + amount: "TESTKUDOS:0.1", + debit_account: withdrawal.accountPaytoUri, + account_pub: depositTx.accountPub, + }, + }), + ); + await exchange.runWirewatchOnce(); + await exchange.runAggregatorOnceWithTimetravel({ + timetravelMicroseconds: WIRE_DELAY_US, + }); + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: baseline.transactionId, + txState: { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.KycRequired, + }, + }); + const pending = await walletClient.call( + WalletApiOperation.GetTransactionById, + { + transactionId: baseline.transactionId, + }, + ); + const account = pending.kycPaytoHash; + t.assertTrue(!!account); + t.assertDeepEqual( + succeedOrThrow( + await exchangeApi.getAmlAccounts(officer, { + account, + investigation: true, + }), + ).accounts.length, + 0, + ); + const newRules = { + custom_measures: {}, + expiration_time: TalerProtocolTimestamp.never(), + rules: [], + successor_measure: undefined, + }; + const properties = { CUSTOMER_LABEL: "Monitored test account" }; + await postAmlDecision(t, { + exchangeBaseUrl: exchange.baseUrl, + paytoHash: account, + amlPriv: amlKeypair.priv, + amlPub: amlKeypair.pub, + newRules, + properties, + events: ["ACCOUNT_OPEN"], + }); + await finishDeposit(baseline.transactionId, 0); + const assertInvestigation = async (expected: boolean) => { + const accounts = succeedOrThrow( + await exchangeApi.getAmlAccounts(officer, { + account, + open: true, + }), + ).accounts; + t.assertDeepEqual(accounts.length, 1); + t.assertDeepEqual(accounts[0].to_investigate, expected); + const flagged = succeedOrThrow( + await exchangeApi.getAmlAccounts(officer, { + account, + investigation: true, + }), + ).accounts; + t.assertDeepEqual(flagged.length, expected ? 1 : 0); + const decisions = succeedOrThrow( + await exchangeApi.getAmlDecisions(officer, { + account, + active: true, + }), + ).records; + t.assertDeepEqual(decisions.length, 1); + const decision = decisions[0]; + t.assertDeepEqual(decision.to_investigate, expected); + t.assertDeepEqual(decision.limits, newRules); + t.assertDeepEqual( + decision.properties?.CUSTOMER_LABEL, + properties.CUSTOMER_LABEL, + ); + return decision; + }; + await assertInvestigation(false); + + // Observe real deposit totals, including fees, and production statistics. + // No deposits, statistics, or AML outcomes are seeded through SQL. + const hPayto = `decode('${Buffer.from(decodeCrock(account)).toString("hex")}', 'hex')`; + let offsetMs = 0; + const readSql = async (query: string): Promise<string> => + runCommand(t, "tops-statistics", "psql", [ + commonDb.connStr, + "-X", + "-qAt", + "-v", + "ON_ERROR_STOP=1", + "-c", + `SET search_path TO exchange; + SET taler.timetravel_us = '${offsetMs * 1000}'; + ${query}`, + ]); + const readVolumes = async () => { + const amounts: { range: number; val: number; frac: number }[] = JSON.parse( + await readSql(` + SELECT json_agg(s) FROM ( + SELECT range, (rvalue).val AS val, (rvalue).frac AS frac + FROM exchange_statistic_interval_amount_get('deposit-volume', ${hPayto}) + ) s`), + ); + const units = (range: number) => { + const row = amounts.find((x) => Number(x.range) === range); + t.assertTrue(!!row, `missing interval ${range}`); + return BigInt(row.val) * 100000000n + BigInt(row.frac); + }; + const total = await readSql(` + SELECT COALESCE(SUM((bd.total_amount).val::NUMERIC * 100000000 + + (bd.total_amount).frac), 0)::TEXT AS units + FROM batch_deposits bd JOIN wire_targets wt USING (wire_target_h_payto) + WHERE wt.h_normalized_payto = ${hPayto}`); + const month = units(MONTH_SECONDS); + const year = units(YEAR_SECONDS); + t.assertDeepEqual(year, BigInt(total.trim())); + return { month, year }; + }; + const initial = await readVolumes(); + t.assertTrue(initial.year > 0n); + t.assertDeepEqual(initial.month, initial.year); + + t.logStep("advance-sixty-days"); + await exchange.stop(); + exchange.setTimetravel(FUTURE_MS); + await exchange.start(); + await exchange.pingUntilAvailable(); + await walletClient.call(WalletApiOperation.TestingSetTimetravel, { + offsetMs: FUTURE_MS, + }); + offsetMs = FUTURE_MS; + const aged = await readVolumes(); + t.assertDeepEqual(aged.month, 0n); + t.assertDeepEqual(aged.year, initial.year); + + t.logStep("normal-deposit-does-not-trigger-investigation"); + const small = await deposit("TESTKUDOS:50000"); + const normal = await readVolumes(); + t.assertDeepEqual(normal.year, initial.year + normal.month); + t.assertTrue(normal.year / 100000000n >= 2n * (normal.month / 100000000n)); + await assertInvestigation(false); + await finishDeposit(small.transactionId, FUTURE_MS); + + t.logStep("increased-volume-triggers-investigation"); + const large = await deposit("TESTKUDOS:150000"); + const suspicious = await readVolumes(); + t.assertDeepEqual(suspicious.year, initial.year + suspicious.month); + t.assertTrue( + suspicious.year / 100000000n < 2n * (suspicious.month / 100000000n), + ); + const investigation = await assertInvestigation(true); + t.assertDeepEqual( + investigation.properties?.INVESTIGATION_TRIGGER, + "DEPOSIT_ANOMALY", + ); + t.assertDeepEqual( + investigation.properties?.INVESTIGATION_STATE, + "INVESTIGATION_PENDING", + ); + t.assertTrue(typeof investigation.decision_time.t_s === "number"); + t.assertTrue( + investigation.decision_time.t_s > Date.now() / 1000 + (59 * DAY_MS) / 1000, + ); + await finishDeposit(large.transactionId, FUTURE_MS); + + const before = await readSql( + `SELECT COUNT(*) FROM legitimization_outcomes WHERE h_payto = ${hPayto}`, + ); + const repeat = await deposit("TESTKUDOS:1"); + await assertInvestigation(true); + const after = await readSql( + `SELECT COUNT(*) FROM legitimization_outcomes WHERE h_payto = ${hPayto}`, + ); + t.assertDeepEqual(after, before); + await finishDeposit(repeat.transactionId, FUTURE_MS); +} + +runTopsTransactionRiskMonitoringTest.suites = ["tops", "libeufin"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -179,6 +179,7 @@ import { runRevocationTest } from "./test-revocation.js"; import { runSimplePaymentTest } from "./test-simple-payment.js"; import { runTimetravelAutorefreshTest } from "./test-timetravel-autorefresh.js"; import { runTimetravelWithdrawTest } from "./test-timetravel-withdraw.js"; +import { runTopsTransactionRiskMonitoringTest } from "./test-tops-transaction-risk-monitoring.js"; import { runTopsAmlBasicTest } from "./test-tops-aml-basic.js"; import { runTopsAmlCustomAddrPostalTest } from "./test-tops-aml-custom-addr-postal.js"; import { runTopsAmlCustomAddrSmsTest } from "./test-tops-aml-custom-addr-sms.js"; @@ -474,6 +475,7 @@ const allTests: TestMainFunction[] = [ runKycDecisionEventsTest, runWalletDevexpFakeprotoverTest, runTopsAmlBasicTest, + runTopsTransactionRiskMonitoringTest, runTopsAmlCustomAddrPostalTest, runTopsAmlCustomAddrSmsTest, runTopsAmlKyxNaturalTest,