taler-typescript-core

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

commit 74490cf8943d716ccffd2a9db0d204ab0aa4b5aa
parent 22c1e668054402cdf126b21b2d1981fa91ac9c4f
Author: Florian Dold <dold@taler.net>
Date:   Fri, 11 Sep 2026 20:27:21 +0200

wallet-core: preview cumulative withdrawal KYC limits

Accept the prepared bank withdrawal ID to identify the sender account.
Fetch current account rules with a previously funded reserve and compare
each withdrawal timeframe against completed withdrawals in this wallet.

Expose soft, hard, and unknown outcomes without changing withdrawal state,
and retain the existing balance and zero-limit warnings.

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

Diffstat:
Mpackages/taler-util/src/types-taler-wallet.ts | 23+++++++++++++++++++++--
Mpackages/taler-wallet-core/src/withdraw.ts | 9++++++++-
Apackages/taler-wallet-core/src/withdrawal-kyc.test.ts | 382+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/withdrawal-kyc.ts | 208+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 619 insertions(+), 3 deletions(-)

diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -2243,16 +2243,31 @@ export interface BalanceKycUsage { export interface WithdrawalKycPreview { /** * Whether the proposed withdrawal needs a KYC warning based on this wallet's - * balance, known KYC allowance, and advertised zero-limit rules. Prefer this - * result over comparing kycSoftLimit with the withdrawal amount. Optional + * balance, known KYC allowance, advertised zero-limit rules, and known + * withdrawal volume. Prefer this result over comparing kycSoftLimit with + * the withdrawal amount. Optional * for compatibility with older wallet-core versions, which omit it. * This is a preview, not a guarantee that the exchange will not require KYC. */ kycRequired?: boolean; /** Balance usage from the same evaluation as kycRequired. */ balanceKyc?: BalanceKycUsage; + /** + * Account-specific withdrawal-rule preview using this wallet's history. + * "ok" only covers exposed rules and available local history, not other + * wallets, deleted history, or concurrent withdrawals. "unknown" means + * account limits could not be evaluated; it must not be shown as clearance. + * Older wallet-core versions omit this field. + */ + withdrawalKycStatus?: WithdrawalKycStatus; } +export type WithdrawalKycStatus = + | "unknown" + | "ok" + | "kyc-required" + | "hard-limit"; + export interface WithdrawalDetailsForAmount extends WithdrawalKycPreview { /** * Exchange base URL for the withdrawal. @@ -2661,6 +2676,9 @@ export const codecForAcceptManualWithdrawalRequest = export interface GetWithdrawalDetailsForAmountRequest { exchangeBaseUrl?: string; + /** Prepared bank-integrated withdrawal whose sender account should be checked. */ + transactionId?: TransactionIdStr; + /** * Specify currency scope for the withdrawal. * @@ -2745,6 +2763,7 @@ export const codecForGetWithdrawalDetailsForAmountRequest = (): Codec<GetWithdrawalDetailsForAmountRequest> => buildCodecForObject<GetWithdrawalDetailsForAmountRequest>() .property("exchangeBaseUrl", codecOptional(codecForCanonBaseUrl())) + .property("transactionId", codecOptional(codecForTransactionIdStr())) .property("restrictScope", codecOptional(codecForScopeInfo())) .property("amount", codecForAmountString()) .property("restrictAge", codecOptional(codecForNumber())) diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -205,6 +205,7 @@ import { parseTransactionIdentifier, } from "./transactions.js"; import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions.js"; +import { getWithdrawalVolumeKycStatus } from "./withdrawal-kyc.js"; import { denomRefKey, WalletExecutionContext, @@ -5189,6 +5190,11 @@ export async function internalGetWithdrawalDetailsForAmount( for (const x of wi.selectedDenoms.selectedDenoms) { numCoins += x.count; } + const withdrawalKycStatus = await getWithdrawalVolumeKycStatus(wex, { + exchangeBaseUrl, + transactionId: req.transactionId, + amount: wi.selectedDenoms.totalWithdrawCost, + }); const resp: WithdrawalDetailsForAmount = { exchangeBaseUrl, amountRaw: req.amount, @@ -5203,7 +5209,8 @@ export async function internalGetWithdrawalDetailsForAmount( : undefined), kycHardLimit: wi.kycHardLimit, kycSoftLimit: wi.kycSoftLimit, - kycRequired: wi.kycRequired, + kycRequired: wi.kycRequired || withdrawalKycStatus === "kyc-required", + withdrawalKycStatus, balanceKyc: wi.balanceKyc, }; return resp; diff --git a/packages/taler-wallet-core/src/withdrawal-kyc.test.ts b/packages/taler-wallet-core/src/withdrawal-kyc.test.ts @@ -0,0 +1,382 @@ +/* + 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 { + AbsoluteTime, + AccountLimit, + AmountString, + CancellationToken, + codecForGetWithdrawalDetailsForAmountRequest, + Duration, + encodeCrock, + LimitOperationType, + Paytos, + TalerErrorCode, + TalerPreciseTimestamp, + TransactionIdStr, +} from "@gnu-taler/taler-util"; +import { HeadersImpl, HttpRequestLibrary } from "@gnu-taler/taler-util/http"; +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + timestampPreciseToDb, + WalletWithdrawalGroup, + WithdrawalGroupStatus, + WithdrawalRecordType, +} from "./db/records.js"; +import { WalletDbTransaction } from "./db/transaction.js"; +import { WalletExecutionContext } from "./wallet.js"; +import { + evaluateWithdrawalVolumeLimits, + getWithdrawalVolumeKycStatus, +} from "./withdrawal-kyc.js"; + +const exchangeBaseUrl = "https://exchange.example/"; +const sender = "payto://x-taler-bank/localhost/alice?receiver-name=Alice"; +const paytoHash = encodeCrock(Paytos.hashNormalized(sender)); +const nowMs = 1_000_000_000; +const day = 86400000; +const stamp = (ms: number) => + timestampPreciseToDb(TalerPreciseTimestamp.fromMilliseconds(ms)); +function group( + overrides: Partial<WalletWithdrawalGroup> = {}, +): WalletWithdrawalGroup { + return { + withdrawalGroupId: "first", + exchangeBaseUrl, + wgInfo: { + withdrawalType: WithdrawalRecordType.BankIntegrated, + bankInfo: { senderWire: sender }, + }, + reservePub: "prior-public-key", + reservePriv: "prior-private-key", + status: WithdrawalGroupStatus.Done, + rawWithdrawalAmount: "KUDOS:100", + effectiveWithdrawalAmount: "KUDOS:99.8", + timestampStart: stamp(nowMs - 2 * day), + timestampFinish: stamp(nowMs - 1000), + ...overrides, + } as WalletWithdrawalGroup; +} +function rule(overrides: Partial<AccountLimit> = {}): AccountLimit { + return { + operation_type: LimitOperationType.withdraw, + threshold: "KUDOS:150" as AmountString, + timeframe: Duration.toTalerProtocolDuration(Duration.fromSpec({ days: 1 })), + soft_limit: true, + ...overrides, + }; +} +function evaluate(groups = [group()], rules = [rule()], amount = "KUDOS:100") { + return evaluateWithdrawalVolumeLimits({ + groups, + rules, + exchangeBaseUrl, + paytoHash, + amount, + withdrawalGroupId: "proposed", + now: AbsoluteTime.fromMilliseconds(nowMs), + }); +} + +test("withdrawal volume includes fees and uses completion time, with equality allowed", () => { + assert.equal(evaluate(), "kyc-required"); + assert.equal(evaluate(undefined, undefined, "KUDOS:50"), "ok"); + assert.equal(evaluate(undefined, undefined, "KUDOS:50.01"), "kyc-required"); + // Using coin value instead of withdrawal cost would incorrectly allow this. + assert.equal( + evaluate(undefined, [rule({ threshold: "KUDOS:199.9" as AmountString })]), + "kyc-required", + ); +}); + +test("each rule uses its own rolling timeframe", () => { + const old = group({ timestampFinish: stamp(nowMs - day - 1) }); + assert.equal(evaluate([old]), "ok"); + assert.equal( + evaluate([group({ timestampFinish: stamp(nowMs - day) })]), + "kyc-required", + ); + assert.equal( + evaluate([old], [rule(), rule({ timeframe: { d_us: "forever" } })]), + "kyc-required", + ); +}); + +test("withdrawal volume isolates accounts and exchanges and excludes pending/internal operations", () => { + const otherSender = group({ + wgInfo: { + withdrawalType: WithdrawalRecordType.BankIntegrated, + bankInfo: { senderWire: "payto://x-taler-bank/localhost/bob" }, + } as never, + }); + for (const record of [ + otherSender, + group({ exchangeBaseUrl: "https://other.example/" }), + group({ withdrawalGroupId: "proposed" }), + group({ status: WithdrawalGroupStatus.PendingKyc }), + group({ status: WithdrawalGroupStatus.AbortedBank }), + group({ + wgInfo: { withdrawalType: WithdrawalRecordType.PeerPushCredit } as never, + }), + ]) + assert.equal(evaluate([record]), "ok"); + const normalized = group({ + wgInfo: { + withdrawalType: WithdrawalRecordType.BankIntegrated, + bankInfo: { + senderWire: + "payto://x-taler-bank/localhost/alice?receiver-name=Renamed", + }, + } as never, + }); + assert.equal(evaluate([normalized]), "kyc-required"); + assert.equal( + evaluate([ + group({ + wgInfo: { withdrawalType: WithdrawalRecordType.BankManual }, + kycPaytoHash: paytoHash, + }), + ]), + "kyc-required", + ); +}); + +test("hard limits take precedence and unrelated operation types are ignored", () => { + const hard = rule({ + threshold: "KUDOS:180" as AmountString, + soft_limit: false, + }); + assert.equal(evaluate(undefined, [rule(), hard]), "hard-limit"); + assert.equal(evaluate(undefined, [hard, rule()]), "hard-limit"); + assert.equal( + evaluate(undefined, [rule({ soft_limit: undefined })]), + "hard-limit", + ); + assert.equal( + evaluate(undefined, [rule({ operation_type: LimitOperationType.balance })]), + "ok", + ); +}); + +test("incomplete relevant history is unknown, without hiding a proven violation", () => { + for (const record of [ + group({ rawWithdrawalAmount: undefined }), + group({ timestampFinish: undefined }), + group({ wgInfo: { withdrawalType: WithdrawalRecordType.BankManual } }), + ]) { + assert.equal(evaluate([record]), "unknown"); + assert.equal( + evaluate([record, group({ withdrawalGroupId: "known" })]), + "kyc-required", + ); + } + assert.equal( + evaluate([ + group({ + rawWithdrawalAmount: undefined, + timestampFinish: stamp(nowMs - day - 1), + }), + ]), + "ok", + ); +}); + +function fixture() { + const proposed = group({ + withdrawalGroupId: "proposed", + status: WithdrawalGroupStatus.DialogProposed, + }); + const previous = group({ timestampFinish: stamp(Date.now() - 1000) }); + const source = CancellationToken.create(); + const state = { + proposed: proposed as WalletWithdrawalGroup | undefined, + groups: [previous], + status: 200, + body: { + aml_review: false, + rule_gen: 0, + access_token: "access-token", + limits: [rule()], + } as Record<string, unknown>, + fail: false, + cancel: false, + requests: 0, + inTransaction: false, + }; + const http: HttpRequestLibrary = { + async fetch(url, opt) { + assert.equal(state.inTransaction, false); + assert.equal(url, exchangeBaseUrl + "kyc-check/" + paytoHash); + assert.equal(opt?.timeout?.d_ms, 5000); + assert.equal(opt?.readOnly, true); + state.requests++; + if (state.cancel) source.cancel(); + if (state.fail) throw Error("lookup unavailable"); + return { + requestUrl: url, + requestMethod: "GET", + status: state.status, + headers: new HeadersImpl(), + async json() { + return state.body; + }, + async text() { + return JSON.stringify(state.body); + }, + async bytes() { + return new TextEncoder().encode(JSON.stringify(state.body)); + }, + }; + }, + }; + const tx = { + async getWithdrawalGroup(id: string) { + assert.equal(id, "proposed"); + return state.proposed; + }, + async getWithdrawalGroupsByExchange(url: string) { + assert.equal(url, exchangeBaseUrl); + return state.groups; + }, + } as WalletDbTransaction; + const wex = { + http, + cancellationToken: source.token, + ws: { longpollQueue: undefined }, + cryptoApi: { + async signWalletKycAuth(args: unknown) { + assert.deepEqual(args, { + accountPub: previous.reservePub, + accountPriv: previous.reservePriv, + }); + assert.equal(state.inTransaction, false); + return { sig: "account-signature" }; + }, + }, + async runWalletDbTx<T>(fn: (tx: WalletDbTransaction) => Promise<T>) { + state.inTransaction = true; + try { + return await fn(tx); + } finally { + state.inTransaction = false; + } + }, + } as unknown as WalletExecutionContext; + return { + state, + source, + check: ( + transactionId: + | TransactionIdStr + | undefined = "txn:withdrawal:proposed" as TransactionIdStr, + ) => + getWithdrawalVolumeKycStatus(wex, { + exchangeBaseUrl, + transactionId, + amount: "KUDOS:100", + }), + }; +} + +test("preview fetches current rules with a funded reserve and never writes wallet state", async () => { + const f = fixture(); + const before = structuredClone(f.state.groups); + assert.equal(await f.check(), "kyc-required"); + f.state.body.limits = []; + assert.equal(await f.check(), "ok"); + f.state.body.limits = [rule({ soft_limit: false })]; + f.state.status = 202; + assert.equal(await f.check(), "hard-limit"); + assert.equal(f.state.requests, 3); + assert.deepEqual(f.state.groups, before); +}); + +test("missing context, unavailable limits, and failed lookups are unknown", async () => { + // Legacy callers omit the field entirely. + assert.equal( + await getWithdrawalVolumeKycStatus( + { + cancellationToken: CancellationToken.CONTINUE, + } as WalletExecutionContext, + { exchangeBaseUrl, amount: "KUDOS:100" }, + ), + "unknown", + ); + const noKey = fixture(); + noKey.state.groups = []; + assert.equal(await noKey.check(), "unknown"); + assert.equal(noKey.state.requests, 0); + const noSender = fixture(); + noSender.state.proposed!.wgInfo = { + withdrawalType: WithdrawalRecordType.BankIntegrated, + bankInfo: {}, + } as never; + assert.equal(await noSender.check(), "unknown"); + assert.equal(noSender.state.requests, 0); + for (const status of [403, 404, 409, 500]) { + const f = fixture(); + f.state.status = status; + assert.equal(await f.check(), "unknown"); + } + const f = fixture(); + f.state.body.limits = undefined; + assert.equal(await f.check(), "unknown"); + f.state.fail = true; + assert.equal(await f.check(), "unknown"); + f.state.fail = false; + f.state.status = 204; + assert.equal(await f.check(), "ok"); +}); + +test("invalid transaction references fail and cancellation is not reported as unknown", async () => { + const f = fixture(); + await assert.rejects( + f.check("txn:payment:wrong" as TransactionIdStr), + (e: any) => + e.errorDetail.code === TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + ); + f.state.proposed = undefined; + await assert.rejects( + f.check(), + (e: any) => + e.errorDetail.code === TalerErrorCode.WALLET_TRANSACTION_NOT_FOUND, + ); + const wrongType = fixture(); + wrongType.state.proposed!.wgInfo = { + withdrawalType: WithdrawalRecordType.BankManual, + }; + await assert.rejects(wrongType.check()); + const cancelled = fixture(); + cancelled.state.cancel = true; + cancelled.state.fail = true; + await assert.rejects(cancelled.check(), CancellationToken.CancellationError); + assert.equal(f.state.requests, 0); +}); + +test("withdrawal preview request codec accepts old callers and transaction context", () => { + const old = { exchangeBaseUrl, amount: "KUDOS:100" }; + assert.equal( + codecForGetWithdrawalDetailsForAmountRequest().decode(old).transactionId, + undefined, + ); + assert.equal( + codecForGetWithdrawalDetailsForAmountRequest().decode({ + ...old, + transactionId: "txn:withdrawal:proposed", + }).transactionId, + "txn:withdrawal:proposed", + ); +}); diff --git a/packages/taler-wallet-core/src/withdrawal-kyc.ts b/packages/taler-wallet-core/src/withdrawal-kyc.ts @@ -0,0 +1,208 @@ +/* + 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 { + AbsoluteTime, + AccountLimit, + AmountLike, + Amounts, + Duration, + encodeCrock, + HttpStatusCode, + Paytos, + TransactionIdStr, + TransactionType, + WithdrawalKycStatus, +} from "@gnu-taler/taler-util"; +import { + timestampAbsoluteFromDb, + WalletWithdrawalGroup, + WithdrawalGroupStatus, + WithdrawalRecordType, +} from "./db/records.js"; +import { + makeInvalidTransactionIdError, + makeTransactionNotFoundError, + parseTransactionIdentifier, +} from "./transactions.js"; +import { WalletExecutionContext, walletExchangeClient } from "./wallet.js"; + +function bankWithdrawal(group: WalletWithdrawalGroup): boolean { + return ( + group.wgInfo.withdrawalType === WithdrawalRecordType.BankIntegrated || + group.wgInfo.withdrawalType === WithdrawalRecordType.BankManual + ); +} + +function senderHash(group: WalletWithdrawalGroup): string | undefined { + if (group.wgInfo.withdrawalType === WithdrawalRecordType.BankIntegrated) { + const sender = group.wgInfo.bankInfo.senderWire; + if (sender) return encodeCrock(Paytos.hashNormalized(sender)); + } + return group.kycPaytoHash; +} + +/** Evaluate completed withdrawals, independently of coins still held by the wallet. */ +export function evaluateWithdrawalVolumeLimits(args: { + rules: AccountLimit[]; + groups: WalletWithdrawalGroup[]; + exchangeBaseUrl: string; + paytoHash: string; + withdrawalGroupId: string; + amount: AmountLike; + now?: AbsoluteTime; +}): WithdrawalKycStatus { + const now = args.now ?? AbsoluteTime.now(); + let result: WithdrawalKycStatus = "ok"; + let incomplete = false; + for (const rule of args.rules) { + if (rule.operation_type !== "WITHDRAW") continue; + if ( + Amounts.currencyOf(rule.threshold) !== Amounts.currencyOf(args.amount) + ) { + 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; + } + if ( + Amounts.cmp(Amounts.add(volume, args.amount).amount, rule.threshold) > 0 + ) { + if (!rule.soft_limit) return "hard-limit"; + result = "kyc-required"; + } + } + return result === "ok" && incomplete ? "unknown" : result; +} + +/** + * Read-only preflight: authenticate with a previously funded reserve, without + * starting KYC or changing the proposed withdrawal. Network work happens after + * the database snapshot has been released. + */ +export async function getWithdrawalVolumeKycStatus( + wex: WalletExecutionContext, + args: { + exchangeBaseUrl: string; + transactionId?: TransactionIdStr; + amount: AmountLike; + }, +): Promise<WithdrawalKycStatus> { + wex.cancellationToken.throwIfCancelled(); + if (args.transactionId == null) return "unknown"; + const parsed = parseTransactionIdentifier(args.transactionId); + if (parsed?.tag !== TransactionType.Withdrawal) { + throw makeInvalidTransactionIdError( + args.transactionId, + TransactionType.Withdrawal, + ); + } + const snapshot = await wex.runWalletDbTx(async (tx) => { + const proposed = await tx.getWithdrawalGroup(parsed.withdrawalGroupId); + if (!proposed) throw makeTransactionNotFoundError(args.transactionId!); + if ( + proposed.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated + ) { + throw makeInvalidTransactionIdError( + args.transactionId!, + TransactionType.Withdrawal, + ); + } + const sender = proposed.wgInfo.bankInfo.senderWire; + if (!sender) return undefined; + const paytoHash = encodeCrock(Paytos.hashNormalized(sender)); + const groups = await tx.getWithdrawalGroupsByExchange(args.exchangeBaseUrl); + const credential = groups.find( + (group) => + group.withdrawalGroupId !== proposed.withdrawalGroupId && + group.status === WithdrawalGroupStatus.Done && + bankWithdrawal(group) && + senderHash(group) === paytoHash, + ); + if (!credential) return undefined; + return { paytoHash, groups, credential }; + }); + wex.cancellationToken.throwIfCancelled(); + if (!snapshot) return "unknown"; + + const { credential, paytoHash, groups } = snapshot; + const signature = await wex.cryptoApi.signWalletKycAuth({ + accountPub: credential.reservePub, + accountPriv: credential.reservePriv, + }); + wex.cancellationToken.throwIfCancelled(); + const client = walletExchangeClient( + args.exchangeBaseUrl, + wex, + Duration.fromSpec({ seconds: 5 }), + ); + let response: Awaited<ReturnType<typeof client.checkKycStatus>>; + try { + response = await client.checkKycStatus({ + paytoHash, + accountPub: credential.reservePub, + accountSig: signature.sig, + longpoll: false, + }); + } catch { + wex.cancellationToken.throwIfCancelled(); + return "unknown"; + } + wex.cancellationToken.throwIfCancelled(); + if (response.case === HttpStatusCode.NoContent) return "ok"; + if (response.case !== "ok" && response.case !== HttpStatusCode.Accepted) + return "unknown"; + const rules = response.body.limits; + if (rules == null) return "unknown"; + return evaluateWithdrawalVolumeLimits({ + rules, + groups, + paytoHash, + exchangeBaseUrl: args.exchangeBaseUrl, + withdrawalGroupId: parsed.withdrawalGroupId, + amount: args.amount, + }); +}