commit 8bcb68fd7ade1bb17a4b46b06ed9f7846512ff4b
parent 37019e79814239d3468bf6c928a6a25fd4d831ff
Author: Florian Dold <dold@taler.net>
Date: Fri, 11 Sep 2026 20:27:59 +0200
wallet-webui: show cumulative withdrawal limit warnings
Keep the prepared bank transaction ID when recalculating an amount or age
restriction so wallet-core can check the sender account.
Show cumulative KYC warnings, disable confirmation for known hard limits,
and keep unavailable checks informational. Preserve existing balance
warnings and compatibility with older wallet-core responses.
Issue: https://bugs.taler.net/n/10489
Diffstat:
5 files changed, 176 insertions(+), 13 deletions(-)
diff --git a/packages/wallet-webui/src/routes/App.tsx b/packages/wallet-webui/src/routes/App.tsx
@@ -169,6 +169,7 @@ import {
} from "./exchange-key-recovery-model.js";
import {
withdrawalReviewModel,
+ withdrawalPreviewRequest,
type WithdrawalPreparation,
} from "./withdrawal-review-model.js";
import {
@@ -2714,11 +2715,7 @@ function WithdrawalRoute(
const amount = `${provider.currency}:${value}` as AmountString;
const result = await progressRequest.callAndInvalidate(
WalletApiOperation.GetWithdrawalDetailsForAmount,
- {
- exchangeBaseUrl: provider.exchangeBaseUrl,
- amount,
- ...(restrictAge === undefined ? {} : { restrictAge }),
- },
+ withdrawalPreviewRequest(provider.exchangeBaseUrl, amount, restrictAge),
["exchanges"],
);
if (result.tag === "error") {
@@ -2870,6 +2867,7 @@ function WithdrawalRoute(
blockedReason={keyRecovery?.message ?? review.blockedReason}
blockedActionLabel={keyRecovery?.actionLabel ?? review.gate?.label}
warning={review.warning}
+ limitInfo={review.limitInfo}
balanceKyc={review.balanceKyc}
coinCountWarning={review.coinCountWarning}
working={accepting || preparing || review.trustLoading}
@@ -3602,11 +3600,12 @@ function IntegratedWithdrawalRoute() {
const amount = `${provider.currency}:${value}` as AmountString;
const result = await progressRequest.callAndInvalidate(
WalletApiOperation.GetWithdrawalDetailsForAmount,
- {
- exchangeBaseUrl: provider.exchangeBaseUrl,
+ withdrawalPreviewRequest(
+ provider.exchangeBaseUrl,
amount,
- ...(restrictAge === undefined ? {} : { restrictAge }),
- },
+ restrictAge,
+ preparation?.transactionId,
+ ),
["exchanges"],
);
if (Result.isError(result))
@@ -3791,6 +3790,7 @@ function IntegratedWithdrawalRoute() {
blockedReason={keyRecovery?.message ?? review.blockedReason}
blockedActionLabel={keyRecovery?.actionLabel ?? review.gate?.label}
warning={review.warning}
+ limitInfo={review.limitInfo}
balanceKyc={review.balanceKyc}
coinCountWarning={review.coinCountWarning}
working={accepting || preparing || review.trustLoading}
diff --git a/packages/wallet-webui/src/routes/withdrawal-review-model.ts b/packages/wallet-webui/src/routes/withdrawal-review-model.ts
@@ -2,6 +2,8 @@ import {
Amounts,
type AmountString,
type ExchangeListItem,
+ type GetWithdrawalDetailsForAmountRequest,
+ type TransactionIdStr,
type WithdrawalDetailsForAmount,
} from "@gnu-taler/taler-util";
import { i18n } from "../i18n/runtime.js";
@@ -16,6 +18,21 @@ export interface WithdrawalPreparation {
ageRestrictionOptions: number[];
}
+/** Keep the bank transaction context when amount or age choices are recalculated. */
+export function withdrawalPreviewRequest(
+ exchangeBaseUrl: string,
+ amount: AmountString,
+ restrictAge?: number,
+ transactionId?: TransactionIdStr,
+): GetWithdrawalDetailsForAmountRequest {
+ return {
+ exchangeBaseUrl,
+ amount,
+ ...(restrictAge === undefined ? {} : { restrictAge }),
+ ...(transactionId === undefined ? {} : { transactionId }),
+ };
+}
+
export function withdrawalReviewModel(
prepared: WithdrawalPreparation,
exchanges: ExchangeListItem[] | undefined,
@@ -51,11 +68,15 @@ export function withdrawalReviewModel(
(!trustLoading && !managedExchange
? i18n.str`The wallet could not verify the selected exchange. Go back and select it again.`
: undefined);
+ const volumeStatus = prepared.details.withdrawalKycStatus;
const blockedReason =
(trustLoading
? i18n.str`Checking the exchange's keys and terms…`
: trustError) ??
gate?.reason ??
+ (volumeStatus === "hard-limit"
+ ? i18n.str`This withdrawal would exceed the exchange’s withdrawal limit. Choose a smaller amount or try again later.`
+ : undefined) ??
(accountCount === 0
? i18n.str`The exchange did not provide a usable bank account for this withdrawal.`
: undefined);
@@ -66,9 +87,18 @@ export function withdrawalReviewModel(
Amounts.cmp(prepared.details.amountRaw, prepared.details.kycSoftLimit) >=
0,
);
- const warning = overSoftKyc
- ? i18n.str`This amount may require identity verification at the exchange.`
- : undefined;
+ const warning =
+ volumeStatus === "hard-limit"
+ ? undefined
+ : volumeStatus === "kyc-required"
+ ? i18n.str`This withdrawal, together with your recent withdrawals, may require identity verification at the exchange.`
+ : overSoftKyc
+ ? i18n.str`This amount may require identity verification at the exchange.`
+ : undefined;
+ const limitInfo =
+ !warning && volumeStatus === "unknown"
+ ? i18n.str`Withdrawal limits could not be checked. Identity verification may be required.`
+ : undefined;
const usage = prepared.details.balanceKyc;
const balanceKyc =
overSoftKyc &&
@@ -85,6 +115,7 @@ export function withdrawalReviewModel(
gate,
blockedReason,
warning,
+ limitInfo,
balanceKyc,
coinCountWarning,
trustLoading,
diff --git a/packages/wallet-webui/src/screens/WithdrawalReviewScreen.tsx b/packages/wallet-webui/src/screens/WithdrawalReviewScreen.tsx
@@ -14,6 +14,7 @@ export function WithdrawalReviewScreen(props: {
blockedReason?: string;
blockedActionLabel?: string;
warning?: string;
+ limitInfo?: string;
balanceKyc?: BalanceKycUsage;
coinCountWarning?: string;
ageRestrictionOptions?: number[];
@@ -108,6 +109,11 @@ export function WithdrawalReviewScreen(props: {
)}
</Card>
)}
+ {props.limitInfo && (
+ <Card>
+ <p class="text-secondary">{props.limitInfo}</p>
+ </Card>
+ )}
{props.coinCountWarning && (
<Card class="border-warning bg-warningContainer">
<p class="text-onWarningContainer">{props.coinCountWarning}</p>
diff --git a/packages/wallet-webui/test/routing.test.ts b/packages/wallet-webui/test/routing.test.ts
@@ -7,7 +7,10 @@ import {
peerRequestExchangeGate,
withdrawalExchangeGate,
} from "../src/routes/exchange-gate-model.js";
-import { withdrawalReviewModel } from "../src/routes/withdrawal-review-model.js";
+import {
+ withdrawalReviewModel,
+ withdrawalPreviewRequest,
+} from "../src/routes/withdrawal-review-model.js";
import { safeWalletReturnTo } from "../src/routes/ManagementRoutes.js";
test("hash route matching ignores flow query parameters", () => {
@@ -245,3 +248,84 @@ test("withdrawal review uses the explicit KYC decision and balance figures", ()
);
assert.equal(exact.warning, undefined);
});
+
+test("bank withdrawal preview recalculation retains account context without leaking it to manual flows", () => {
+ for (const age of [undefined, 18]) {
+ for (const amount of ["KUDOS:40", "KUDOS:100"] as const) {
+ const bank = withdrawalPreviewRequest(
+ "https://exchange.example/",
+ amount,
+ age,
+ "txn:withdrawal:prepared" as never,
+ );
+ assert.equal(bank.transactionId, "txn:withdrawal:prepared");
+ assert.equal(bank.amount, amount);
+ assert.equal(bank.restrictAge, age);
+ }
+ }
+ const manual = withdrawalPreviewRequest(
+ "https://exchange.example/",
+ "KUDOS:100" as never,
+ );
+ assert.equal(Object.hasOwn(manual, "transactionId"), false);
+});
+
+test("withdrawal review distinguishes volume warnings, hard limits, and unavailable checks", () => {
+ const prepared = {
+ provider: {
+ exchangeBaseUrl: "https://exchange.example/",
+ currency: "KUDOS",
+ commonAmounts: [],
+ },
+ amount: "KUDOS:100",
+ ageRestrictionOptions: [],
+ details: {
+ amountRaw: "KUDOS:100",
+ amountEffective: "KUDOS:99.8",
+ numCoins: 19,
+ withdrawalAccountsList: [{ status: "ok" }],
+ kycRequired: false,
+ },
+ };
+ const exchanges = [
+ {
+ exchangeBaseUrl: prepared.provider.exchangeBaseUrl,
+ tosStatus: ExchangeTosStatus.Accepted,
+ },
+ ] as never;
+ const model = (status: string | undefined, kycRequired = false) =>
+ withdrawalReviewModel(
+ {
+ ...prepared,
+ details: {
+ ...prepared.details,
+ withdrawalKycStatus: status,
+ kycRequired,
+ },
+ } as never,
+ exchanges,
+ false,
+ );
+ const volume = model("kyc-required", true);
+ assert.match(volume.warning ?? "", /together with your recent withdrawals/);
+ assert.equal(volume.blockedReason, undefined);
+ const hard = model("hard-limit", true);
+ assert.match(
+ hard.blockedReason ?? "",
+ /exceed the exchange’s withdrawal limit/,
+ );
+ assert.equal(hard.warning, undefined);
+ const unknown = model("unknown");
+ assert.match(unknown.limitInfo ?? "", /could not be checked/);
+ assert.equal(unknown.warning, undefined);
+ assert.equal(unknown.blockedReason, undefined);
+ const balance = model("unknown", true);
+ assert.match(balance.warning ?? "", /identity verification/);
+ assert.equal(balance.limitInfo, undefined);
+ for (const status of ["ok", undefined]) {
+ const clear = model(status);
+ assert.equal(clear.warning, undefined);
+ assert.equal(clear.limitInfo, undefined);
+ assert.equal(clear.blockedReason, undefined);
+ }
+});
diff --git a/packages/wallet-webui/test/screens.test.tsx b/packages/wallet-webui/test/screens.test.tsx
@@ -4483,3 +4483,45 @@ test("withdrawal review shows balance usage alongside both warnings", async () =
cleanup();
await window.happyDOM.abort();
});
+
+test("withdrawal review keeps unknown limits informational and blocks known hard limits", async () => {
+ const window = installDom();
+ const { render, cleanup, act } = await import("@testing-library/preact");
+ let confirmed = 0;
+ const props = {
+ exchange: "https://exchange.example/",
+ amountRaw: "KUDOS:100",
+ amountEffective: "KUDOS:99.8",
+ working: false,
+ onConfirm: () => {
+ confirmed++;
+ },
+ onBack: () => {},
+ onCancel: () => {},
+ };
+ const view = render(
+ <WithdrawalReviewScreen
+ {...props}
+ limitInfo="Withdrawal limits could not be checked. Identity verification may be required."
+ />,
+ );
+ assert(view.getByText(/Withdrawal limits could not be checked/));
+ const button = view.getByRole("button", {
+ name: "Withdraw",
+ }) as HTMLButtonElement;
+ assert.equal(button.disabled, false);
+ await act(() => button.click());
+ assert.equal(confirmed, 1);
+ view.rerender(
+ <WithdrawalReviewScreen
+ {...props}
+ blockedReason="This withdrawal would exceed the exchange’s withdrawal limit. Choose a smaller amount or try again later."
+ />,
+ );
+ assert.match(view.getByRole("alert").textContent!, /exceed the exchange/);
+ assert.equal(button.disabled, true);
+ await act(() => button.click());
+ assert.equal(confirmed, 1);
+ cleanup();
+ await window.happyDOM.abort();
+});