commit 2730c3ab5bb7db22c7493f98d07a56672e762d84
parent e5eb252f1f81efa5bf2dd411a4fbcb63cd3da4ca
Author: Florian Dold <dold@taler.net>
Date: Sat, 5 Sep 2026 23:13:40 +0200
wallet-core: report balance KYC requirements in withdrawal previews
Use the enforcement balance check to report whether a withdrawal needs
KYC, including existing coins, pending refresh outputs, and the known
personal threshold. Expose current and projected balances together with
the applicable threshold and remaining allowance.
Retain the existing soft and hard limit fields for older clients.
Issue: https://bugs.taler.net/n/10410
Diffstat:
4 files changed, 274 insertions(+), 75 deletions(-)
diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts
@@ -2225,7 +2225,36 @@ export interface AcceptManualWithdrawalResult {
withdrawalAccountsList: WithdrawalExchangeAccountDetails[];
}
-export interface WithdrawalDetailsForAmount {
+/**
+ * This wallet's balance at the issuing exchange at preview time, using the
+ * same accounting as balance-KYC enforcement (including pending refresh
+ * outputs). Does not reserve capacity for concurrent withdrawals or report
+ * account-wide transaction-volume/hard-limit usage.
+ */
+export interface BalanceKycUsage {
+ currentBalance: AmountString;
+ /** Current balance plus the selected coins' value after withdrawal fees. */
+ projectedBalance: AmountString;
+ /** Applicable balance threshold; omitted when no finite limit is known. */
+ threshold?: AmountString;
+ /** Additional balance permitted, clamped to zero; omitted with threshold. */
+ remaining?: AmountString;
+}
+
+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
+ * 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;
+}
+
+export interface WithdrawalDetailsForAmount extends WithdrawalKycPreview {
/**
* Exchange base URL for the withdrawal.
*/
@@ -2322,7 +2351,7 @@ export interface DenomSelectionState {
*
* Sent to the wallet frontend to be rendered and shown to the user.
*/
-export interface ExchangeWithdrawalDetails {
+export interface ExchangeWithdrawalDetails extends WithdrawalKycPreview {
exchangePaytoUris: string[];
/**
diff --git a/packages/taler-wallet-core/src/exchanges.test.ts b/packages/taler-wallet-core/src/exchanges.test.ts
@@ -16,9 +16,11 @@
import assert from "node:assert";
import { test } from "node:test";
-import { ScopeType, TalerErrorCode } from "@gnu-taler/taler-util";
+import { AmountString, ScopeType, TalerErrorCode } from "@gnu-taler/taler-util";
import {
CoinSourceType,
+ ReserveRecordStatus,
+ WalletReserve,
WalletCoin,
WalletExchangeDetails,
WalletWithdrawalGroup,
@@ -26,6 +28,7 @@ import {
import { WalletDbTransaction } from "./db/transaction.js";
import { WalletExecutionContext } from "./wallet.js";
import {
+ checkIncomingAmountLegalUnderKycBalanceThreshold,
filterCoinsByExchangeMasterPub,
getLegacyScopesForTransaction,
getLegacyMasterPubs,
@@ -435,3 +438,180 @@ test("legacy purge rejects a stale current master key before deleting anything",
error?.errorDetail?.code === TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
);
});
+
+/** Only read operations are provided, so a preview cannot start KYC. */
+function balanceKycFixture(
+ options: {
+ limits?: AmountString[];
+ reserve?: Partial<WalletReserve>;
+ pendingRefreshOutputCount?: number;
+ } = {},
+) {
+ const exchangeBaseUrl = "https://exchange.example/";
+ const otherExchangeBaseUrl = "https://other-exchange.example/";
+ const assertExchange = (url: string) =>
+ assert.ok([exchangeBaseUrl, otherExchangeBaseUrl].includes(url));
+ const limits = options.limits;
+ let transactions = 0;
+ const tx = {
+ async getExchange(url: string) {
+ assertExchange(url);
+ return {
+ baseUrl: url,
+ detailsPointer: { currency: "KUDOS", masterPublicKey: "master" },
+ currentMergeReserveRowId: options.reserve ? 1 : undefined,
+ };
+ },
+ async getExchangeDetailsByPointer(url: string) {
+ assertExchange(url);
+ return {
+ currency: "KUDOS",
+ walletBalanceLimits: limits,
+ wireInfo: { accounts: [] },
+ };
+ },
+ async getCoinAvailabilityByExchange(url: string) {
+ assertExchange(url);
+ return [
+ {
+ value: "KUDOS:2",
+ freshCoinCount: url === exchangeBaseUrl ? 2 : 50,
+ pendingRefreshOutputCount: options.pendingRefreshOutputCount,
+ },
+ ];
+ },
+ async getReserve(rowId: number) {
+ assert.equal(rowId, 1);
+ return options.reserve;
+ },
+ } as unknown as WalletDbTransaction;
+ const wex = {
+ async runWalletDbTx<T>(f: (tx: WalletDbTransaction) => Promise<T>) {
+ transactions++;
+ return f(tx);
+ },
+ } as WalletExecutionContext;
+ return {
+ async check(incoming: AmountString, url = exchangeBaseUrl) {
+ const before = transactions;
+ const result = await checkIncomingAmountLegalUnderKycBalanceThreshold(
+ wex,
+ url,
+ incoming,
+ );
+ assert.equal(
+ transactions,
+ before + 1,
+ "usage and decision share one snapshot",
+ );
+ return result;
+ },
+ };
+}
+
+test("balance KYC preview includes existing coins and pending refresh outputs", async () => {
+ const fixture = balanceKycFixture({
+ limits: ["KUDOS:10"],
+ pendingRefreshOutputCount: 1,
+ });
+ for (const [incoming, projected, outcome] of [
+ ["KUDOS:3", "KUDOS:9", "ok"],
+ ["KUDOS:4", "KUDOS:10", "ok"],
+ ["KUDOS:4.01", "KUDOS:10.01", "violation"],
+ ] as const) {
+ const result = await fixture.check(incoming);
+ assert.equal(result.result, outcome);
+ assert.deepStrictEqual(result.balanceKyc, {
+ currentBalance: "KUDOS:6",
+ projectedBalance: projected,
+ threshold: "KUDOS:10",
+ remaining: "KUDOS:4",
+ });
+ if (result.result === "violation")
+ assert.equal(result.requiredBalance, projected);
+ }
+});
+
+test("balance KYC usage uses the first limit while authorizing the full balance", async () => {
+ const limits: AmountString[] = ["KUDOS:30", "KUDOS:10"];
+ const result = await balanceKycFixture({ limits }).check("KUDOS:40");
+ assert.equal(result.result, "violation");
+ assert.equal(result.balanceKyc.threshold, "KUDOS:10");
+ assert.equal(result.balanceKyc.projectedBalance, "KUDOS:44");
+ if (result.result === "violation")
+ assert.equal(result.nextThreshold, "KUDOS:30");
+ assert.deepStrictEqual(
+ limits,
+ ["KUDOS:30", "KUDOS:10"],
+ "do not mutate stored limits",
+ );
+});
+
+test("completed balance KYC uses the personal next threshold", async () => {
+ const fixture = balanceKycFixture({
+ limits: ["KUDOS:10"],
+ reserve: {
+ status: ReserveRecordStatus.Done,
+ thresholdNext: "KUDOS:30",
+ },
+ });
+ for (const [incoming, outcome] of [
+ ["KUDOS:10", "ok"],
+ ["KUDOS:26", "ok"],
+ ["KUDOS:26.01", "violation"],
+ ] as const) {
+ const result = await fixture.check(incoming);
+ assert.equal(result.result, outcome);
+ assert.equal(result.balanceKyc.threshold, "KUDOS:30");
+ assert.equal(result.balanceKyc.remaining, "KUDOS:26");
+ }
+ const result = await balanceKycFixture({
+ reserve: {
+ status: ReserveRecordStatus.Done,
+ thresholdNext: "KUDOS:30",
+ },
+ }).check("KUDOS:27");
+ assert.equal(
+ result.result,
+ "violation",
+ "known personal limits also apply without public limits",
+ );
+});
+
+test("balance preview omits allowance when no finite limit is known", async () => {
+ for (const options of [
+ {},
+ { limits: [] },
+ {
+ limits: ["KUDOS:10" as AmountString],
+ reserve: { status: ReserveRecordStatus.Done },
+ },
+ ]) {
+ const result = await balanceKycFixture(options).check("KUDOS:40");
+ assert.deepStrictEqual(result, {
+ result: "ok",
+ balanceKyc: { currentBalance: "KUDOS:4", projectedBalance: "KUDOS:44" },
+ });
+ }
+});
+
+test("balance allowance never becomes negative", async () => {
+ const result = await balanceKycFixture({ limits: ["KUDOS:3"] }).check(
+ "KUDOS:1",
+ );
+ assert.equal(result.result, "violation");
+ assert.equal(result.balanceKyc.remaining, "KUDOS:0");
+});
+
+test("balance KYC counts only holdings at the issuing exchange", async () => {
+ const fixture = balanceKycFixture({ limits: ["KUDOS:10"] });
+ const first = await fixture.check("KUDOS:2");
+ assert.equal(first.result, "ok");
+ assert.equal(first.balanceKyc.currentBalance, "KUDOS:4");
+ const other = await fixture.check(
+ "KUDOS:2",
+ "https://other-exchange.example/",
+ );
+ assert.equal(other.result, "violation");
+ assert.equal(other.balanceKyc.currentBalance, "KUDOS:100");
+});
diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts
@@ -36,6 +36,7 @@ import {
AmountLike,
AmountString,
Amounts,
+ BalanceKycUsage,
CancellationToken,
CoinRefreshRequest,
CoinStatus,
@@ -4909,7 +4910,9 @@ export function findExchangeWireFee(
return fee;
}
-export type BalanceThresholdCheckResult =
+export type BalanceThresholdCheckResult = {
+ balanceKyc: BalanceKycUsage;
+} & (
| {
result: "ok";
}
@@ -4921,7 +4924,8 @@ export type BalanceThresholdCheckResult =
requiredBalance: AmountString;
walletKycStatus: ExchangeWalletKycStatus | undefined;
walletKycAccessToken: string | undefined;
- };
+ }
+);
export async function checkIncomingAmountLegalUnderKycBalanceThreshold(
wex: WalletExecutionContext,
@@ -4951,84 +4955,57 @@ export async function checkIncomingAmountLegalUnderKycBalanceThreshold(
}
const balExpected = Amounts.add(balAmount, amountIncoming).amount;
- // Check if we already have KYC for a sufficient threshold.
-
const reserveId = exchangeRec.currentMergeReserveRowId;
let reserveRec: WalletReserve | undefined;
if (reserveId) {
reserveRec = await tx.getReserve(reserveId);
checkDbInvariant(!!reserveRec, "reserve");
- // FIXME: also consider KYC expiration!
- if (reserveRec.thresholdNext) {
- logger.trace(
- `Checking expected balance ${Amounts.stringify(
- balExpected,
- )} against threshold ${Amounts.stringify(
- reserveRec.thresholdNext,
- )}`,
- );
- if (Amounts.cmp(balExpected, reserveRec.thresholdNext) <= 0) {
- logger.trace(
- `Next threshold ${Amounts.stringify(
- reserveRec.thresholdNext,
- )} not yet breached`,
- );
- return {
- result: "ok",
- };
- }
- } 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.info(
- `No next balance threshold, assuming balance KYC is okay`,
- );
- return {
- result: "ok",
- };
- }
}
- // No luck, check the next limit we should request, if any.
-
- const limits = det.walletBalanceLimits;
- if (!limits) {
- logger.info("no balance limits defined");
- return {
- result: "ok",
- };
- }
- limits.sort((a, b) => Amounts.cmp(a, b));
- logger.trace(`applicable limits: ${j2s(limits)}`);
- let limViolated: AmountString | undefined = undefined;
- // A threshold is only crossed once the balance exceeds it, reaching it
- // exactly is still allowed.
- for (let i = 0; i < limits.length; i++) {
- if (Amounts.cmp(limits[i], balExpected) < 0) {
- limViolated = limits[i];
- const limNext = limits[i + 1];
- if (limNext == null || Amounts.cmp(limNext, balExpected) >= 0) {
- break;
- }
- }
- }
- if (!limViolated) {
- logger.trace("balance limit okay");
- return {
- result: "ok",
- };
- } else {
- logger.info(`balance limit ${limViolated} would be violated`);
- return {
- result: "violation",
- nextThreshold: limViolated,
- requiredBalance: Amounts.stringify(balExpected),
- walletKycStatus: reserveRec?.status
- ? getKycStatusFromReserveStatus(reserveRec.status)
- : undefined,
- walletKycAccessToken: reserveRec?.kycAccessToken,
- };
+ // A known personal threshold takes precedence over the public limits.
+ // Without a next threshold, completed KYC is treated as unlimited, as in
+ // the enforcement check before previews exposed this information.
+ // FIXME: also consider KYC expiration!
+ const limits = [...(det.walletBalanceLimits ?? [])].sort(Amounts.cmp);
+ const threshold =
+ reserveRec?.thresholdNext ??
+ (reserveRec?.status === ReserveRecordStatus.Done
+ ? undefined
+ : limits[0]);
+ const balanceKyc: BalanceKycUsage = {
+ currentBalance: Amounts.stringify(balAmount),
+ projectedBalance: Amounts.stringify(balExpected),
+ ...(threshold == null
+ ? {}
+ : {
+ threshold,
+ remaining: Amounts.stringify(
+ Amounts.sub(threshold, balAmount).amount,
+ ),
+ }),
+ };
+ // Reaching a threshold exactly is allowed. Fees do not increase the
+ // wallet balance, so callers pass the selected coins' value.
+ if (threshold == null || Amounts.cmp(balExpected, threshold) <= 0) {
+ return { result: "ok", balanceKyc };
}
+
+ // Preserve the crossed public limit in nextThreshold for callers that
+ // use it; authorization itself uses the entire projected balance.
+ const crossed =
+ reserveRec?.thresholdNext ??
+ limits.filter((limit) => Amounts.cmp(limit, balExpected) < 0).at(-1)!;
+ logger.info(`balance limit ${threshold} would be violated`);
+ return {
+ result: "violation",
+ balanceKyc,
+ nextThreshold: crossed,
+ requiredBalance: balanceKyc.projectedBalance,
+ walletKycStatus: reserveRec?.status
+ ? getKycStatusFromReserveStatus(reserveRec.status)
+ : undefined,
+ walletKycAccessToken: reserveRec?.kycAccessToken,
+ };
},
);
}
diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts
@@ -3515,6 +3515,15 @@ export async function getExchangeWithdrawalInfo(
throw Error("exchange is in invalid state");
}
+ const balanceCheck = await checkIncomingAmountLegalUnderKycBalanceThreshold(
+ wex,
+ exchangeBaseUrl,
+ selectedDenoms.totalCoinValue,
+ );
+ const zeroLimitWarning = exchange.zeroLimits.some((limit) =>
+ ["BALANCE", "WITHDRAW", "TRANSACTION"].includes(limit.operation_type),
+ );
+
const ret: ExchangeWithdrawalDetails = {
exchangePaytoUris: paytoUris,
exchangeWireAccounts,
@@ -3532,6 +3541,8 @@ export async function getExchangeWithdrawalInfo(
? { unconfirmedKeyChange: exchange.unconfirmedKeyChange }
: undefined),
...getWithdrawalLimitInfo(exchange, instructedAmount),
+ kycRequired: balanceCheck.result === "violation" || zeroLimitWarning,
+ balanceKyc: balanceCheck.balanceKyc,
};
return ret;
}
@@ -5192,6 +5203,8 @@ export async function internalGetWithdrawalDetailsForAmount(
: undefined),
kycHardLimit: wi.kycHardLimit,
kycSoftLimit: wi.kycSoftLimit,
+ kycRequired: wi.kycRequired,
+ balanceKyc: wi.balanceKyc,
};
return resp;
}