From 3843cef545da9cbd8c3bce451d934255627f9e6a Mon Sep 17 00:00:00 2001 From: CV Date: Mon, 16 Mar 2026 21:30:49 +0800 Subject: [PATCH 1/2] fix: clamp decimals in formatAmount/formatCurrency/formatPercent to prevent RangeError When formatAmount was called with decimals=0 (e.g., in LoanSimulator simulate mode), minimumFractionDigits (hardcoded at 2) exceeded maximumFractionDigits (0), causing toLocaleString to throw RangeError that crashed the entire React tree. Also added clamping to formatCurrency and formatPercent for negative decimals values. Co-Authored-By: Claude Opus 4.6 --- src/lib/utils.ts | 14 ++++++++------ test/lib/utils.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index faf252e..5296904 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -28,10 +28,10 @@ export function formatAmount( const abs = Math.abs(num); // Tiny positive numbers: show '< 0.0001' for values > 0 and < 0.0001 if (abs > 0 && abs < 0.0001) return `< 0.0001`; - // Cap maximum decimals to 4 to avoid very long fractional displays + // Cap decimals to valid range (0-4) and ensure min <= max const maxDecimals = Math.min(4, Math.max(0, decimals)); return num.toLocaleString("en-US", { - minimumFractionDigits: 2, + minimumFractionDigits: Math.min(2, maxDecimals), maximumFractionDigits: maxDecimals, }); } @@ -51,9 +51,10 @@ export function formatCurrency( return `${currencySymbol}${abbreviateNumber(num, 1)}`; } // For smaller numbers, show with requested decimals (no grouping abbreviation) + const safeDecimals = Math.max(0, decimals); return `${currencySymbol}${num.toLocaleString("en-US", { - minimumFractionDigits: decimals, - maximumFractionDigits: decimals, + minimumFractionDigits: safeDecimals, + maximumFractionDigits: safeDecimals, })}`; } @@ -64,9 +65,10 @@ export function formatPercent( if (value === null || value === undefined) return `0%`; const num = Number(value); if (!Number.isFinite(num)) return `0%`; + const safeDecimals = Math.max(0, decimals); return `${Number(value).toLocaleString("en-US", { - minimumFractionDigits: decimals, - maximumFractionDigits: decimals, + minimumFractionDigits: safeDecimals, + maximumFractionDigits: safeDecimals, })}%`; } diff --git a/test/lib/utils.test.ts b/test/lib/utils.test.ts index f17fbb5..e8ddee2 100644 --- a/test/lib/utils.test.ts +++ b/test/lib/utils.test.ts @@ -80,6 +80,21 @@ describe("formatAmount", () => { it("should respect custom decimals", () => { expect(formatAmount(1.123456, 2)).toBe("1.12"); }); + + it("should not throw RangeError when decimals is 0", () => { + expect(() => formatAmount(100000, 0)).not.toThrow(); + expect(formatAmount(100000, 0)).toBe("100,000"); + }); + + it("should not throw RangeError when decimals is 1", () => { + expect(() => formatAmount(1234.5, 1)).not.toThrow(); + expect(formatAmount(1234.5, 1)).toBe("1,234.5"); + }); + + it("should not throw RangeError when decimals is negative", () => { + expect(() => formatAmount(1234, -1)).not.toThrow(); + expect(formatAmount(1234, -1)).toBe("1,234"); + }); }); describe("formatCurrency", () => { @@ -110,6 +125,16 @@ describe("formatCurrency", () => { it("should not abbreviate when disabled", () => { expect(formatCurrency(5000, 2, "$", false)).toBe("$5,000.00"); }); + + it("should not throw RangeError when decimals is 0", () => { + expect(() => formatCurrency(100, 0)).not.toThrow(); + expect(formatCurrency(100, 0, "$", false)).toBe("$100"); + }); + + it("should not throw RangeError when decimals is negative", () => { + expect(() => formatCurrency(100, -1)).not.toThrow(); + expect(formatCurrency(100, -1, "$", false)).toBe("$100"); + }); }); describe("formatPercent", () => { @@ -132,6 +157,11 @@ describe("formatPercent", () => { it("should respect custom decimals", () => { expect(formatPercent(33.3333, 1)).toBe("33.3%"); }); + + it("should not throw RangeError when decimals is negative", () => { + expect(() => formatPercent(50, -1)).not.toThrow(); + expect(formatPercent(50, -1)).toBe("50%"); + }); }); describe("maskString", () => { From 97437b0bfd105c9acbc02636d76d5dc54ecf3dee Mon Sep 17 00:00:00 2001 From: CV Date: Mon, 16 Mar 2026 21:55:40 +0800 Subject: [PATCH 2/2] feat(simulator): move Borrow Amount field above collateral inputs in simulate mode Reorders the simulate mode layout so Borrow Amount (USDT) field and slider appear directly under the LTV gauge, before the collateral type selector and collateral input fields. Co-Authored-By: Claude Opus 4.6 --- src/components/wallet/LoanSimulator.tsx | 131 ++++++++++++------------ 1 file changed, 66 insertions(+), 65 deletions(-) diff --git a/src/components/wallet/LoanSimulator.tsx b/src/components/wallet/LoanSimulator.tsx index 839b187..3300e22 100644 --- a/src/components/wallet/LoanSimulator.tsx +++ b/src/components/wallet/LoanSimulator.tsx @@ -641,8 +641,73 @@ export default function LoanSimulator({ mode = "borrow", collateralType = "WBTC" {/* Input Field */}
{mode === "simulate" ? ( - /* Sandbox Mode - Collateral Type Selector + Collateral Input + Borrow Slider */ + /* Sandbox Mode - Borrow Slider + Collateral Type Selector + Collateral Input */
+ {/* Borrow Amount Slider */} +
+
+
+ + Borrow Amount (USDT) +
+ +
+
+ + {formatCurrency(parsedInput, 0, "$", false)} + +
+
+ setInputValue(e.target.value)} + disabled={maxBorrow <= 0} + className={cn( + "w-full h-2 rounded-full appearance-none cursor-pointer bg-white/10", + "[&::-webkit-slider-thumb]:appearance-none", + "[&::-webkit-slider-thumb]:w-5", + "[&::-webkit-slider-thumb]:h-5", + "[&::-webkit-slider-thumb]:rounded-full", + "[&::-webkit-slider-thumb]:bg-amber-500", + "[&::-webkit-slider-thumb]:shadow-lg", + "[&::-webkit-slider-thumb]:border-2", + "[&::-webkit-slider-thumb]:border-white/20", + "[&::-moz-range-thumb]:w-5", + "[&::-moz-range-thumb]:h-5", + "[&::-moz-range-thumb]:rounded-full", + "[&::-moz-range-thumb]:bg-amber-500", + "[&::-moz-range-thumb]:border-2", + "[&::-moz-range-thumb]:border-white/20", + "disabled:opacity-50" + )} + style={{ + background: maxBorrow > 0 + ? `linear-gradient(to right, rgb(245 158 11) 0%, rgb(245 158 11) ${(parsedInput / maxBorrow) * 100}%, rgba(255,255,255,0.1) ${(parsedInput / maxBorrow) * 100}%, rgba(255,255,255,0.1) 100%)` + : "rgba(255,255,255,0.1)" + }} + /> +
+ $0 + {formatCurrency(maxBorrow, 0, "$", false)} +
+
+ {maxBorrow <= 0 && parsedSandboxCollateral === 0 && ( +

+ Enter collateral amount to enable borrowing +

+ )} +
+ {/* Collateral Type Selector */}
- {/* Borrow Amount Slider */} -
-
-
- - Borrow Amount (USDT) -
- -
-
- - {formatCurrency(parsedInput, 0, "$", false)} - -
-
- setInputValue(e.target.value)} - disabled={maxBorrow <= 0} - className={cn( - "w-full h-2 rounded-full appearance-none cursor-pointer bg-white/10", - "[&::-webkit-slider-thumb]:appearance-none", - "[&::-webkit-slider-thumb]:w-5", - "[&::-webkit-slider-thumb]:h-5", - "[&::-webkit-slider-thumb]:rounded-full", - "[&::-webkit-slider-thumb]:bg-amber-500", - "[&::-webkit-slider-thumb]:shadow-lg", - "[&::-webkit-slider-thumb]:border-2", - "[&::-webkit-slider-thumb]:border-white/20", - "[&::-moz-range-thumb]:w-5", - "[&::-moz-range-thumb]:h-5", - "[&::-moz-range-thumb]:rounded-full", - "[&::-moz-range-thumb]:bg-amber-500", - "[&::-moz-range-thumb]:border-2", - "[&::-moz-range-thumb]:border-white/20", - "disabled:opacity-50" - )} - style={{ - background: maxBorrow > 0 - ? `linear-gradient(to right, rgb(245 158 11) 0%, rgb(245 158 11) ${(parsedInput / maxBorrow) * 100}%, rgba(255,255,255,0.1) ${(parsedInput / maxBorrow) * 100}%, rgba(255,255,255,0.1) 100%)` - : "rgba(255,255,255,0.1)" - }} - /> -
- $0 - {formatCurrency(maxBorrow, 0, "$", false)} -
-
- {maxBorrow <= 0 && parsedSandboxCollateral === 0 && ( -

- Enter collateral amount to enable borrowing -

- )} -
) : ( <>