From a04c9debc2e1a8afa4abf24e803d0638e55de6cf Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Thu, 18 Dec 2025 17:18:29 +0100 Subject: [PATCH 01/14] feat(perps): add precision warning for TP/SL price inputs exceeding 5 significant figures --- .../Views/PerpsTPSLView/PerpsTPSLView.tsx | 16 +++++ .../UI/Perps/constants/perpsConfig.ts | 3 + .../UI/Perps/hooks/usePerpsTPSLForm.ts | 16 +++++ .../UI/Perps/utils/tpslValidation.ts | 58 +++++++++++++++++++ locales/languages/en.json | 3 +- 5 files changed, 95 insertions(+), 1 deletion(-) diff --git a/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.tsx b/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.tsx index e45ca6a9950c..082328b367e5 100644 --- a/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.tsx +++ b/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.tsx @@ -201,6 +201,8 @@ const PerpsTPSLView: React.FC = () => { takeProfitError, stopLossError, stopLossLiquidationError, + takeProfitPrecisionWarning, + stopLossPrecisionWarning, } = tpslForm.validation; const { formattedTakeProfitPercentage, @@ -650,6 +652,13 @@ const PerpsTPSLView: React.FC = () => { {takeProfitError} )} + + {/* Precision warning (non-blocking) */} + {Boolean(takeProfitPrecisionWarning) && ( + + {takeProfitPrecisionWarning} + + )} {/* Stop Loss Section */} @@ -812,6 +821,13 @@ const PerpsTPSLView: React.FC = () => { {stopLossError || stopLossLiquidationError} )} + + {/* Precision warning (non-blocking) */} + {Boolean(stopLossPrecisionWarning) && ( + + {stopLossPrecisionWarning} + + )} diff --git a/app/components/UI/Perps/constants/perpsConfig.ts b/app/components/UI/Perps/constants/perpsConfig.ts index fd85daed253f..f94921576da0 100644 --- a/app/components/UI/Perps/constants/perpsConfig.ts +++ b/app/components/UI/Perps/constants/perpsConfig.ts @@ -331,6 +331,9 @@ export const DECIMAL_PRECISION_CONFIG = { // Maximum decimal places for price input (matches Hyperliquid limit) // Used in TP/SL forms, limit price inputs, and price validation MAX_PRICE_DECIMALS: 6, + // Maximum significant figures allowed by HyperLiquid API + // Orders with more than 5 significant figures will be rejected + MAX_SIGNIFICANT_FIGURES: 5, // Defensive fallback for size decimals when market data fails to load // Real szDecimals should always come from market data API (varies by asset) // Using 6 as safe maximum to prevent crashes (covers most assets) diff --git a/app/components/UI/Perps/hooks/usePerpsTPSLForm.ts b/app/components/UI/Perps/hooks/usePerpsTPSLForm.ts index b7e64ab00c22..080126e1d2c1 100644 --- a/app/components/UI/Perps/hooks/usePerpsTPSLForm.ts +++ b/app/components/UI/Perps/hooks/usePerpsTPSLForm.ts @@ -15,6 +15,7 @@ import { getStopLossErrorDirection, getStopLossLiquidationErrorDirection, getTakeProfitErrorDirection, + hasExceededSignificantFigures, hasTPSLValuesChanged, isStopLossSafeFromLiquidation, isValidStopLossPrice, @@ -84,6 +85,8 @@ interface TPSLFormValidation { takeProfitError: string; stopLossError: string; stopLossLiquidationError: string; + takeProfitPrecisionWarning: string; + stopLossPrecisionWarning: string; } interface TPSLFormDisplay { @@ -872,6 +875,17 @@ export function usePerpsTPSLForm( }) : ''; + // Precision warning - HyperLiquid rounds to max 5 significant figures + const takeProfitPrecisionWarning = + takeProfitPrice && hasExceededSignificantFigures(takeProfitPrice) + ? strings('perps.tpsl.price_precision_warning') + : ''; + + const stopLossPrecisionWarning = + stopLossPrice && hasExceededSignificantFigures(stopLossPrice) + ? strings('perps.tpsl.price_precision_warning') + : ''; + // Display helpers const formattedTakeProfitPercentage = formatRoEPercentageDisplay( takeProfitPercentage, @@ -1000,6 +1014,8 @@ export function usePerpsTPSLForm( takeProfitError, stopLossError, stopLossLiquidationError, + takeProfitPrecisionWarning, + stopLossPrecisionWarning, }, display: { formattedTakeProfitPercentage, diff --git a/app/components/UI/Perps/utils/tpslValidation.ts b/app/components/UI/Perps/utils/tpslValidation.ts index 0ecd414274a3..b41cac6f9778 100644 --- a/app/components/UI/Perps/utils/tpslValidation.ts +++ b/app/components/UI/Perps/utils/tpslValidation.ts @@ -13,6 +13,64 @@ import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; import { DECIMAL_PRECISION_CONFIG } from '../constants/perpsConfig'; +/** + * Counts the number of significant figures in a numeric string + * This matches HyperLiquid's validation rules: + * - Count non-zero integer digits (without leading zeros) + * - Count ALL decimal digits (including leading zeros after decimal point) + * + * @param priceString - The price value as a string (may include $ or ,) + * @returns The count of significant figures + * + * @example + * countSignificantFigures('123.45') // 5 (3 integer + 2 decimal) + * countSignificantFigures('0.000123') // 6 (0 integer + 6 decimal) + * countSignificantFigures('12.345') // 5 (2 integer + 3 decimal) + * countSignificantFigures('12000') // 2 (trailing zeros in integer not counted) + */ +export const countSignificantFigures = (priceString: string): number => { + if (!priceString) return 0; + + // Clean the string - remove currency symbols and commas + const cleaned = priceString.replace(/[$,]/g, '').trim(); + + // Parse to ensure it's a valid number + const num = parseFloat(cleaned); + if (isNaN(num) || num === 0) return 0; + + // Split into integer and decimal parts + const [integerPart, decimalPart = ''] = cleaned.split('.'); + + // Remove leading zeros and negative sign from integer part + const trimmedInteger = integerPart.replace(/^-?0+/, '') || ''; + + // For integers without decimal, trailing zeros are ambiguous + // We treat them as not significant (matching HyperLiquid behavior) + const effectiveIntegerLength = decimalPart + ? trimmedInteger.length + : trimmedInteger.replace(/0+$/, '').length || + (trimmedInteger.length > 0 ? 1 : 0); + + // Count ALL decimal digits (including leading zeros like 0.000123) + // This matches HyperLiquid's validation behavior + return effectiveIntegerLength + decimalPart.length; +}; + +/** + * Checks if a price exceeds the maximum allowed significant figures + * + * @param priceString - The price value as a string + * @param maxSigFigs - Maximum allowed significant figures (default: MAX_SIGNIFICANT_FIGURES from config) + * @returns true if the price exceeds the limit, false otherwise + */ +export const hasExceededSignificantFigures = ( + priceString: string, + maxSigFigs: number = DECIMAL_PRECISION_CONFIG.MAX_SIGNIFICANT_FIGURES, +): boolean => { + if (!priceString || priceString.trim() === '') return false; + return countSignificantFigures(priceString) > maxSigFigs; +}; + interface ValidationParams { currentPrice: number; direction?: 'long' | 'short'; diff --git a/locales/languages/en.json b/locales/languages/en.json index 8c2598e25e51..19b64b9553ae 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1391,7 +1391,8 @@ "set": "Set", "clear": "Clear", "expected_profit": "Expected profit: {{amount}}", - "expected_loss": "Expected loss: {{amount}}" + "expected_loss": "Expected loss: {{amount}}", + "price_precision_warning": "Price may be rounded to 5 significant digits." }, "token_selector": { "no_tokens": "No tokens available" From 8fe017bf92558d40cf021ba388e6f9471f3e1a34 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Thu, 18 Dec 2025 17:29:55 +0100 Subject: [PATCH 02/14] fix(perps): improve TP/SL precision warning to match HyperLiquid rounding behavior --- .../UI/Perps/utils/tpslValidation.ts | 28 ++++++++++++++++--- locales/languages/en.json | 2 +- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/app/components/UI/Perps/utils/tpslValidation.ts b/app/components/UI/Perps/utils/tpslValidation.ts index b41cac6f9778..f12520808376 100644 --- a/app/components/UI/Perps/utils/tpslValidation.ts +++ b/app/components/UI/Perps/utils/tpslValidation.ts @@ -16,6 +16,7 @@ import { DECIMAL_PRECISION_CONFIG } from '../constants/perpsConfig'; /** * Counts the number of significant figures in a numeric string * This matches HyperLiquid's validation rules: + * - Trailing decimal zeros are trimmed first (via parseFloat().toString()) * - Count non-zero integer digits (without leading zeros) * - Count ALL decimal digits (including leading zeros after decimal point) * @@ -24,6 +25,7 @@ import { DECIMAL_PRECISION_CONFIG } from '../constants/perpsConfig'; * * @example * countSignificantFigures('123.45') // 5 (3 integer + 2 decimal) + * countSignificantFigures('123.4500') // 5 (trailing zeros trimmed first) * countSignificantFigures('0.000123') // 6 (0 integer + 6 decimal) * countSignificantFigures('12.345') // 5 (2 integer + 3 decimal) * countSignificantFigures('12000') // 2 (trailing zeros in integer not counted) @@ -34,12 +36,16 @@ export const countSignificantFigures = (priceString: string): number => { // Clean the string - remove currency symbols and commas const cleaned = priceString.replace(/[$,]/g, '').trim(); - // Parse to ensure it's a valid number + // Parse and convert back to string to trim trailing decimal zeros + // This matches formatHyperLiquidPrice: parseFloat(formattedPrice).toString() const num = parseFloat(cleaned); if (isNaN(num) || num === 0) return 0; + // Normalize to remove trailing zeros (e.g., "123.4500" -> "123.45") + const normalized = num.toString(); + // Split into integer and decimal parts - const [integerPart, decimalPart = ''] = cleaned.split('.'); + const [integerPart, decimalPart = ''] = normalized.split('.'); // Remove leading zeros and negative sign from integer part const trimmedInteger = integerPart.replace(/^-?0+/, '') || ''; @@ -57,17 +63,31 @@ export const countSignificantFigures = (priceString: string): number => { }; /** - * Checks if a price exceeds the maximum allowed significant figures + * Checks if a price will be rounded due to exceeding significant figures + * Only applies when the price has decimals - integers are never rounded + * This matches the behavior in formatHyperLiquidPrice * * @param priceString - The price value as a string * @param maxSigFigs - Maximum allowed significant figures (default: MAX_SIGNIFICANT_FIGURES from config) - * @returns true if the price exceeds the limit, false otherwise + * @returns true if the price will be rounded, false otherwise */ export const hasExceededSignificantFigures = ( priceString: string, maxSigFigs: number = DECIMAL_PRECISION_CONFIG.MAX_SIGNIFICANT_FIGURES, ): boolean => { if (!priceString || priceString.trim() === '') return false; + + // Clean the string and normalize (trim trailing zeros) + const cleaned = priceString.replace(/[$,]/g, '').trim(); + const num = parseFloat(cleaned); + if (isNaN(num)) return false; + + // Normalize to check for decimal presence after trimming trailing zeros + const normalized = num.toString(); + + // If there's no decimal part after normalization, the price won't be rounded + if (!normalized.includes('.')) return false; + return countSignificantFigures(priceString) > maxSigFigs; }; diff --git a/locales/languages/en.json b/locales/languages/en.json index 19b64b9553ae..01c897705e95 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1392,7 +1392,7 @@ "clear": "Clear", "expected_profit": "Expected profit: {{amount}}", "expected_loss": "Expected loss: {{amount}}", - "price_precision_warning": "Price may be rounded to 5 significant digits." + "price_precision_warning": "Decimal precision may be reduced after submitting." }, "token_selector": { "no_tokens": "No tokens available" From 34fbd35d7803dc2893cc9b0f6c481bab20d88898 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Thu, 18 Dec 2025 17:30:42 +0100 Subject: [PATCH 03/14] change range to universal --- .../Perps/components/PerpsPositionCard/PerpsPositionCard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx index ad8455d62838..c69f75fa5a5e 100644 --- a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx +++ b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx @@ -338,14 +338,14 @@ const PerpsPositionCard: React.FC = ({ if (hasTakeProfit && takeProfitPrice) { const tpPrice = formatPerpsFiat(parseFloat(takeProfitPrice), { - ranges: PRICE_RANGES_MINIMAL_VIEW, + ranges: PRICE_RANGES_UNIVERSAL, }); parts.push(`${strings('perps.order.tp')} ${tpPrice}`); } if (hasStopLoss && stopLossPrice) { const slPrice = formatPerpsFiat(parseFloat(stopLossPrice), { - ranges: PRICE_RANGES_MINIMAL_VIEW, + ranges: PRICE_RANGES_UNIVERSAL, }); parts.push(`${strings('perps.order.sl')} ${slPrice}`); } From a62132381760726ae8e6500f3fc258f551fc5108 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Thu, 18 Dec 2025 17:42:35 +0100 Subject: [PATCH 04/14] test(perps): add unit tests for significant figures validation utilities --- .../UI/Perps/utils/tpslValidation.test.ts | 232 ++++++++++++++++++ 1 file changed, 232 insertions(+) diff --git a/app/components/UI/Perps/utils/tpslValidation.test.ts b/app/components/UI/Perps/utils/tpslValidation.test.ts index 9045fc1e0d45..27675dfc7feb 100644 --- a/app/components/UI/Perps/utils/tpslValidation.test.ts +++ b/app/components/UI/Perps/utils/tpslValidation.test.ts @@ -16,9 +16,241 @@ import { getMaxStopLossPercentage, isValidStopLossPercentage, sanitizePercentageInput, + countSignificantFigures, + hasExceededSignificantFigures, } from './tpslValidation'; describe('TPSL Validation Utilities', () => { + describe('countSignificantFigures', () => { + describe('basic counting', () => { + it('returns 5 for price with 3 integer and 2 decimal digits', () => { + const result = countSignificantFigures('123.45'); + + expect(result).toBe(5); + }); + + it('returns 5 for price with 2 integer and 3 decimal digits', () => { + const result = countSignificantFigures('12.345'); + + expect(result).toBe(5); + }); + + it('returns 3 for integer without trailing zeros', () => { + const result = countSignificantFigures('123'); + + expect(result).toBe(3); + }); + + it('returns 2 for integer with trailing zeros (12000)', () => { + const result = countSignificantFigures('12000'); + + expect(result).toBe(2); + }); + }); + + describe('leading zeros after decimal', () => { + it('counts all 6 decimal digits for 0.000123', () => { + const result = countSignificantFigures('0.000123'); + + expect(result).toBe(6); + }); + + it('counts all 8 decimal digits for 0.00001234', () => { + const result = countSignificantFigures('0.00001234'); + + expect(result).toBe(8); + }); + + it('counts 4 decimal digits for 0.0001', () => { + const result = countSignificantFigures('0.0001'); + + expect(result).toBe(4); + }); + }); + + describe('trailing zeros trimming', () => { + it('returns 5 for price with trailing zeros after decimal', () => { + const result = countSignificantFigures('123.4500'); + + expect(result).toBe(5); + }); + + it('returns 3 for price with all trailing zeros in decimal', () => { + const result = countSignificantFigures('123.00'); + + expect(result).toBe(3); + }); + + it('returns 3 for 0.001000 after trimming trailing zeros', () => { + const result = countSignificantFigures('0.001000'); + + expect(result).toBe(3); + }); + }); + + describe('formatted inputs', () => { + it('handles dollar sign prefix', () => { + const result = countSignificantFigures('$123.45'); + + expect(result).toBe(5); + }); + + it('handles comma thousand separators', () => { + const result = countSignificantFigures('1,234.56'); + + expect(result).toBe(6); + }); + + it('handles dollar sign with comma separators', () => { + const result = countSignificantFigures('$1,234.56'); + + expect(result).toBe(6); + }); + }); + + describe('edge cases', () => { + it('returns 0 for empty string', () => { + const result = countSignificantFigures(''); + + expect(result).toBe(0); + }); + + it('returns 0 for zero', () => { + const result = countSignificantFigures('0'); + + expect(result).toBe(0); + }); + + it('returns 0 for invalid input', () => { + const result = countSignificantFigures('invalid'); + + expect(result).toBe(0); + }); + }); + }); + + describe('hasExceededSignificantFigures', () => { + describe('decimal prices exceeding limit', () => { + it('returns true for 6 significant figures when max is 5', () => { + const result = hasExceededSignificantFigures('123.456'); + + expect(result).toBe(true); + }); + + it('returns true for decimal with 6 digits including leading zeros', () => { + const result = hasExceededSignificantFigures('0.000123'); + + expect(result).toBe(true); + }); + + it('returns true for 8 significant figures', () => { + const result = hasExceededSignificantFigures('0.00001234'); + + expect(result).toBe(true); + }); + }); + + describe('prices within limit', () => { + it('returns false for 5 significant figures', () => { + const result = hasExceededSignificantFigures('123.45'); + + expect(result).toBe(false); + }); + + it('returns false for 4 significant figures', () => { + const result = hasExceededSignificantFigures('12.34'); + + expect(result).toBe(false); + }); + + it('returns false for 3 decimal digits with leading zeros', () => { + const result = hasExceededSignificantFigures('0.001'); + + expect(result).toBe(false); + }); + }); + + describe('integer prices', () => { + it('returns false for large integer', () => { + const result = hasExceededSignificantFigures('123456'); + + expect(result).toBe(false); + }); + + it('returns false for integer with many digits', () => { + const result = hasExceededSignificantFigures('1234567890'); + + expect(result).toBe(false); + }); + + it('returns false for integer with trailing zeros', () => { + const result = hasExceededSignificantFigures('12000'); + + expect(result).toBe(false); + }); + }); + + describe('trailing zeros normalization', () => { + it('returns false when trailing zeros normalize to 5 sig figs', () => { + const result = hasExceededSignificantFigures('123.4500'); + + expect(result).toBe(false); + }); + + it('returns false when decimal normalizes to integer', () => { + const result = hasExceededSignificantFigures('123.00'); + + expect(result).toBe(false); + }); + }); + + describe('edge cases', () => { + it('returns false for empty string', () => { + const result = hasExceededSignificantFigures(''); + + expect(result).toBe(false); + }); + + it('returns false for whitespace only', () => { + const result = hasExceededSignificantFigures(' '); + + expect(result).toBe(false); + }); + + it('returns false for invalid input', () => { + const result = hasExceededSignificantFigures('invalid'); + + expect(result).toBe(false); + }); + + it('handles formatted prices with dollar sign', () => { + const result = hasExceededSignificantFigures('$123.456'); + + expect(result).toBe(true); + }); + + it('handles formatted prices with commas', () => { + const result = hasExceededSignificantFigures('1,234.56'); + + expect(result).toBe(true); + }); + }); + + describe('custom max significant figures', () => { + it('returns true when exceeding custom max of 3', () => { + const result = hasExceededSignificantFigures('12.34', 3); + + expect(result).toBe(true); + }); + + it('returns false when within custom max of 8', () => { + const result = hasExceededSignificantFigures('0.00001234', 8); + + expect(result).toBe(false); + }); + }); + }); + describe('isValidTakeProfitPrice', () => { describe('Long positions', () => { const params = { currentPrice: 100, direction: 'long' as const }; From 2b34cfe0ca809aba950d7f4b9bdd083e7c7b445b Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Mon, 5 Jan 2026 11:55:29 +0100 Subject: [PATCH 05/14] chore: do not allow to enter numbers exceeding significant figures --- .../Views/PerpsTPSLView/PerpsTPSLView.tsx | 16 ----------- .../UI/Perps/hooks/usePerpsTPSLForm.ts | 27 +++++++++---------- 2 files changed, 12 insertions(+), 31 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.tsx b/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.tsx index 082328b367e5..e45ca6a9950c 100644 --- a/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.tsx +++ b/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.tsx @@ -201,8 +201,6 @@ const PerpsTPSLView: React.FC = () => { takeProfitError, stopLossError, stopLossLiquidationError, - takeProfitPrecisionWarning, - stopLossPrecisionWarning, } = tpslForm.validation; const { formattedTakeProfitPercentage, @@ -652,13 +650,6 @@ const PerpsTPSLView: React.FC = () => { {takeProfitError} )} - - {/* Precision warning (non-blocking) */} - {Boolean(takeProfitPrecisionWarning) && ( - - {takeProfitPrecisionWarning} - - )} {/* Stop Loss Section */} @@ -821,13 +812,6 @@ const PerpsTPSLView: React.FC = () => { {stopLossError || stopLossLiquidationError} )} - - {/* Precision warning (non-blocking) */} - {Boolean(stopLossPrecisionWarning) && ( - - {stopLossPrecisionWarning} - - )} diff --git a/app/components/UI/Perps/hooks/usePerpsTPSLForm.ts b/app/components/UI/Perps/hooks/usePerpsTPSLForm.ts index 080126e1d2c1..1313ab431316 100644 --- a/app/components/UI/Perps/hooks/usePerpsTPSLForm.ts +++ b/app/components/UI/Perps/hooks/usePerpsTPSLForm.ts @@ -85,8 +85,6 @@ interface TPSLFormValidation { takeProfitError: string; stopLossError: string; stopLossLiquidationError: string; - takeProfitPrecisionWarning: string; - stopLossPrecisionWarning: string; } interface TPSLFormDisplay { @@ -333,6 +331,12 @@ export function usePerpsTPSLForm( ) return; + if ( + hasExceededSignificantFigures(sanitized) && + sanitized.length > takeProfitPrice.length + ) + return; + setTakeProfitPrice(sanitized); // Set price as source of truth when user is actively typing @@ -432,6 +436,12 @@ export function usePerpsTPSLForm( ) return; + if ( + hasExceededSignificantFigures(sanitized) && + sanitized.length > stopLossPrice.length + ) + return; + setStopLossPrice(sanitized); // Set price as source of truth when user is actively typing @@ -875,17 +885,6 @@ export function usePerpsTPSLForm( }) : ''; - // Precision warning - HyperLiquid rounds to max 5 significant figures - const takeProfitPrecisionWarning = - takeProfitPrice && hasExceededSignificantFigures(takeProfitPrice) - ? strings('perps.tpsl.price_precision_warning') - : ''; - - const stopLossPrecisionWarning = - stopLossPrice && hasExceededSignificantFigures(stopLossPrice) - ? strings('perps.tpsl.price_precision_warning') - : ''; - // Display helpers const formattedTakeProfitPercentage = formatRoEPercentageDisplay( takeProfitPercentage, @@ -1014,8 +1013,6 @@ export function usePerpsTPSLForm( takeProfitError, stopLossError, stopLossLiquidationError, - takeProfitPrecisionWarning, - stopLossPrecisionWarning, }, display: { formattedTakeProfitPercentage, From 67d52d38c4c090f65a24a7172927d3de5db21183 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Mon, 5 Jan 2026 12:01:43 +0100 Subject: [PATCH 06/14] chore: add rounding --- .../UI/Perps/hooks/usePerpsTPSLForm.ts | 11 ++++--- .../UI/Perps/utils/tpslValidation.ts | 30 +++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/app/components/UI/Perps/hooks/usePerpsTPSLForm.ts b/app/components/UI/Perps/hooks/usePerpsTPSLForm.ts index 1313ab431316..54d022120dad 100644 --- a/app/components/UI/Perps/hooks/usePerpsTPSLForm.ts +++ b/app/components/UI/Perps/hooks/usePerpsTPSLForm.ts @@ -20,6 +20,7 @@ import { isStopLossSafeFromLiquidation, isValidStopLossPrice, isValidTakeProfitPrice, + roundToSignificantFigures, safeParseRoEPercentage, sanitizePercentageInput, validateTPSLPrices, @@ -721,8 +722,9 @@ export function usePerpsTPSLForm( // Only set values if we got a valid price if (price && price !== '' && Number.parseFloat(price) > 0) { - const priceString = price.toString(); - const formattedPriceString = formatPerpsFiat(priceString, { + // Round to 5 significant figures to match input validation + const roundedPrice = roundToSignificantFigures(price.toString()); + const formattedPriceString = formatPerpsFiat(roundedPrice, { ranges: PRICE_RANGES_UNIVERSAL, }); const sanitizedPriceString = formattedPriceString.replace( @@ -774,8 +776,9 @@ export function usePerpsTPSLForm( // Only set values if we got a valid price if (price && price !== '' && Number.parseFloat(price) > 0) { - const priceString = price.toString(); - const formattedPriceString = formatPerpsFiat(priceString, { + // Round to 5 significant figures to match input validation + const roundedPrice = roundToSignificantFigures(price.toString()); + const formattedPriceString = formatPerpsFiat(roundedPrice, { ranges: PRICE_RANGES_UNIVERSAL, }); const sanitizedPriceString = formattedPriceString.replace( diff --git a/app/components/UI/Perps/utils/tpslValidation.ts b/app/components/UI/Perps/utils/tpslValidation.ts index f12520808376..bee8074dcebd 100644 --- a/app/components/UI/Perps/utils/tpslValidation.ts +++ b/app/components/UI/Perps/utils/tpslValidation.ts @@ -91,6 +91,36 @@ export const hasExceededSignificantFigures = ( return countSignificantFigures(priceString) > maxSigFigs; }; +/** + * Rounds a price string to the maximum allowed significant figures + * This ensures preset calculations don't exceed the 5 significant figures limit + * + * @param priceString - The price value as a string + * @param maxSigFigs - Maximum allowed significant figures (default: MAX_SIGNIFICANT_FIGURES from config) + * @returns Price string rounded to max significant figures + * + * @example + * roundToSignificantFigures('123.456') // '123.46' (5 sig figs) + * roundToSignificantFigures('12345.67') // '12346' (5 sig figs) + * roundToSignificantFigures('0.000123456') // '0.00012346' (5 sig figs in decimal) + */ +export const roundToSignificantFigures = ( + priceString: string, + maxSigFigs: number = DECIMAL_PRECISION_CONFIG.MAX_SIGNIFICANT_FIGURES, +): string => { + if (!priceString || priceString.trim() === '') return priceString; + + const cleaned = priceString.replaceAll(/[$,]/g, '').trim(); + const num = Number.parseFloat(cleaned); + if (Number.isNaN(num) || num === 0) return priceString; + + // Use toPrecision for rounding to significant figures + const rounded = Number.parseFloat(num.toPrecision(maxSigFigs)); + + // Return as string, removing unnecessary trailing zeros + return rounded.toString(); +}; + interface ValidationParams { currentPrice: number; direction?: 'long' | 'short'; From 157a784774db45c969857008e1f1d9bba2632ecb Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Mon, 5 Jan 2026 12:04:12 +0100 Subject: [PATCH 07/14] chore: improve roundToSignificantFigures --- .../UI/Perps/utils/tpslValidation.ts | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/app/components/UI/Perps/utils/tpslValidation.ts b/app/components/UI/Perps/utils/tpslValidation.ts index bee8074dcebd..404d5e368e10 100644 --- a/app/components/UI/Perps/utils/tpslValidation.ts +++ b/app/components/UI/Perps/utils/tpslValidation.ts @@ -93,16 +93,17 @@ export const hasExceededSignificantFigures = ( /** * Rounds a price string to the maximum allowed significant figures - * This ensures preset calculations don't exceed the 5 significant figures limit + * Uses the same counting logic as countSignificantFigures: + * - Count non-zero integer digits + ALL decimal digits (including leading zeros) * * @param priceString - The price value as a string * @param maxSigFigs - Maximum allowed significant figures (default: MAX_SIGNIFICANT_FIGURES from config) * @returns Price string rounded to max significant figures * * @example - * roundToSignificantFigures('123.456') // '123.46' (5 sig figs) - * roundToSignificantFigures('12345.67') // '12346' (5 sig figs) - * roundToSignificantFigures('0.000123456') // '0.00012346' (5 sig figs in decimal) + * roundToSignificantFigures('123.456') // '123.46' (3 int + 2 dec = 5) + * roundToSignificantFigures('0.065242') // '0.06524' (0 int + 5 dec = 5) + * roundToSignificantFigures('12345.67') // '12346' (5 int + 0 dec = 5) */ export const roundToSignificantFigures = ( priceString: string, @@ -110,15 +111,38 @@ export const roundToSignificantFigures = ( ): string => { if (!priceString || priceString.trim() === '') return priceString; - const cleaned = priceString.replaceAll(/[$,]/g, '').trim(); + const cleaned = priceString.replace(/[$,]/g, '').trim(); const num = Number.parseFloat(cleaned); if (Number.isNaN(num) || num === 0) return priceString; - // Use toPrecision for rounding to significant figures - const rounded = Number.parseFloat(num.toPrecision(maxSigFigs)); + // Normalize to remove trailing zeros + const normalized = num.toString(); + const [integerPart, decimalPart = ''] = normalized.split('.'); + + // Count integer significant digits (without leading zeros) + const trimmedInteger = integerPart.replace(/^-?0+/, '') || ''; + const integerSigFigs = trimmedInteger.length; + + // If no decimal, return as is (integers are fine) + if (!decimalPart) return normalized; + + // Calculate how many decimal digits we can keep + const allowedDecimalDigits = maxSigFigs - integerSigFigs; + + if (allowedDecimalDigits <= 0) { + // Round to integer + return Math.round(num).toString(); + } + + if (decimalPart.length <= allowedDecimalDigits) { + // Already within limit + return normalized; + } - // Return as string, removing unnecessary trailing zeros - return rounded.toString(); + // Round to the allowed number of decimal places + const rounded = num.toFixed(allowedDecimalDigits); + // Remove trailing zeros + return Number.parseFloat(rounded).toString(); }; interface ValidationParams { From fcb61f2b22943e9a222ec5cb08438e405188fcd3 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Mon, 5 Jan 2026 12:08:12 +0100 Subject: [PATCH 08/14] chore: fix negative numbers --- app/components/UI/Perps/utils/tpslValidation.test.ts | 6 ++++++ app/components/UI/Perps/utils/tpslValidation.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/components/UI/Perps/utils/tpslValidation.test.ts b/app/components/UI/Perps/utils/tpslValidation.test.ts index 27675dfc7feb..77b2489f57ed 100644 --- a/app/components/UI/Perps/utils/tpslValidation.test.ts +++ b/app/components/UI/Perps/utils/tpslValidation.test.ts @@ -126,6 +126,12 @@ describe('TPSL Validation Utilities', () => { expect(result).toBe(0); }); + + it('returns 5 for negative number with 2 integer and 3 decimal digits', () => { + const result = countSignificantFigures('-12.345'); + + expect(result).toBe(5); + }); }); }); diff --git a/app/components/UI/Perps/utils/tpslValidation.ts b/app/components/UI/Perps/utils/tpslValidation.ts index 404d5e368e10..eda84b31bff4 100644 --- a/app/components/UI/Perps/utils/tpslValidation.ts +++ b/app/components/UI/Perps/utils/tpslValidation.ts @@ -48,7 +48,7 @@ export const countSignificantFigures = (priceString: string): number => { const [integerPart, decimalPart = ''] = normalized.split('.'); // Remove leading zeros and negative sign from integer part - const trimmedInteger = integerPart.replace(/^-?0+/, '') || ''; + const trimmedInteger = integerPart.replace(/^-?0*/, '') || ''; // For integers without decimal, trailing zeros are ambiguous // We treat them as not significant (matching HyperLiquid behavior) From 8113a691ee1291f46a27c9ee02906183c64768e9 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Mon, 5 Jan 2026 12:09:04 +0100 Subject: [PATCH 09/14] chore: remove unused translation --- locales/languages/en.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/locales/languages/en.json b/locales/languages/en.json index de066c34f345..578239ee393b 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1391,8 +1391,7 @@ "set": "Set", "clear": "Clear", "expected_profit": "Expected profit: {{amount}}", - "expected_loss": "Expected loss: {{amount}}", - "price_precision_warning": "Decimal precision may be reduced after submitting." + "expected_loss": "Expected loss: {{amount}}" }, "token_selector": { "no_tokens": "No tokens available" From f28bf77e4cf0869f7601c2afbee75370912f66e3 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Tue, 6 Jan 2026 09:35:38 +0100 Subject: [PATCH 10/14] fix: update tests --- app/components/UI/Perps/hooks/usePerpsTPSLForm.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/components/UI/Perps/hooks/usePerpsTPSLForm.test.ts b/app/components/UI/Perps/hooks/usePerpsTPSLForm.test.ts index f9cac791383c..2f6c593849aa 100644 --- a/app/components/UI/Perps/hooks/usePerpsTPSLForm.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsTPSLForm.test.ts @@ -219,10 +219,11 @@ describe('usePerpsTPSLForm', () => { }); act(() => { - result.current.handlers.handleTakeProfitPriceChange('55000.50abc'); + // Use 5000.50 (5 sig figs) instead of 55000.50 (6 sig figs) to stay within limit + result.current.handlers.handleTakeProfitPriceChange('5000.50abc'); }); - expect(result.current.formState.takeProfitPrice).toBe('55000.50'); + expect(result.current.formState.takeProfitPrice).toBe('5000.50'); }); it('prevent multiple decimal points in price input', () => { From 0d1866dc403fc4fc70d04802ef8388224988ffb1 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Tue, 6 Jan 2026 09:43:58 +0100 Subject: [PATCH 11/14] test(perps): add unit tests for roundToSignificantFigures function --- .../UI/Perps/utils/tpslValidation.test.ts | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) diff --git a/app/components/UI/Perps/utils/tpslValidation.test.ts b/app/components/UI/Perps/utils/tpslValidation.test.ts index 77b2489f57ed..a5b2503d6379 100644 --- a/app/components/UI/Perps/utils/tpslValidation.test.ts +++ b/app/components/UI/Perps/utils/tpslValidation.test.ts @@ -18,6 +18,7 @@ import { sanitizePercentageInput, countSignificantFigures, hasExceededSignificantFigures, + roundToSignificantFigures, } from './tpslValidation'; describe('TPSL Validation Utilities', () => { @@ -257,6 +258,201 @@ describe('TPSL Validation Utilities', () => { }); }); + describe('roundToSignificantFigures', () => { + describe('empty and invalid inputs', () => { + it('returns empty string for empty input', () => { + const result = roundToSignificantFigures(''); + + expect(result).toBe(''); + }); + + it('returns whitespace string for whitespace input', () => { + const result = roundToSignificantFigures(' '); + + expect(result).toBe(' '); + }); + + it('returns original string for invalid input', () => { + const result = roundToSignificantFigures('invalid'); + + expect(result).toBe('invalid'); + }); + + it('returns original string for zero', () => { + const result = roundToSignificantFigures('0'); + + expect(result).toBe('0'); + }); + + it('returns original string for 0.00', () => { + const result = roundToSignificantFigures('0.00'); + + expect(result).toBe('0.00'); + }); + }); + + describe('integers without decimal', () => { + it('returns normalized integer for simple integer', () => { + const result = roundToSignificantFigures('123'); + + expect(result).toBe('123'); + }); + + it('returns normalized integer for large integer', () => { + const result = roundToSignificantFigures('123456789'); + + expect(result).toBe('123456789'); + }); + + it('returns normalized integer for integer with trailing zeros', () => { + const result = roundToSignificantFigures('12000'); + + expect(result).toBe('12000'); + }); + }); + + describe('prices within limit', () => { + it('returns normalized price for 5 significant figures', () => { + const result = roundToSignificantFigures('123.45'); + + expect(result).toBe('123.45'); + }); + + it('returns normalized price for 4 significant figures', () => { + const result = roundToSignificantFigures('12.34'); + + expect(result).toBe('12.34'); + }); + + it('returns normalized price for 3 significant figures', () => { + const result = roundToSignificantFigures('1.23'); + + expect(result).toBe('1.23'); + }); + + it('returns normalized price for decimal with leading zeros within limit', () => { + const result = roundToSignificantFigures('0.001'); + + expect(result).toBe('0.001'); + }); + + it('returns normalized price trimming trailing zeros', () => { + const result = roundToSignificantFigures('123.4500'); + + expect(result).toBe('123.45'); + }); + }); + + describe('prices exceeding limit needing rounding', () => { + it('rounds to 5 significant figures for 3 integer + 3 decimal digits', () => { + const result = roundToSignificantFigures('123.456'); + + expect(result).toBe('123.46'); + }); + + it('rounds to 5 significant figures for 2 integer + 4 decimal digits', () => { + const result = roundToSignificantFigures('12.3456'); + + expect(result).toBe('12.346'); + }); + + it('rounds to 5 significant figures for 1 integer + 5 decimal digits', () => { + const result = roundToSignificantFigures('1.23456'); + + expect(result).toBe('1.2346'); + }); + + it('rounds decimal with leading zeros to 5 significant figures', () => { + const result = roundToSignificantFigures('0.065242'); + + expect(result).toBe('0.06524'); + }); + + it('rounds correctly with many leading zeros', () => { + const result = roundToSignificantFigures('0.00123456'); + + expect(result).toBe('0.00123'); + }); + }); + + describe('rounding to integer when integer part exceeds limit', () => { + it('rounds to integer when 5 integer digits + decimal', () => { + const result = roundToSignificantFigures('12345.67'); + + expect(result).toBe('12346'); + }); + + it('rounds to integer when 6 integer digits + decimal', () => { + const result = roundToSignificantFigures('123456.78'); + + expect(result).toBe('123457'); + }); + + it('rounds to integer when 7 integer digits + decimal', () => { + const result = roundToSignificantFigures('1234567.89'); + + expect(result).toBe('1234568'); + }); + }); + + describe('formatted inputs', () => { + it('handles dollar sign prefix', () => { + const result = roundToSignificantFigures('$123.456'); + + expect(result).toBe('123.46'); + }); + + it('handles comma thousand separators', () => { + const result = roundToSignificantFigures('1,234.567'); + + expect(result).toBe('1234.6'); + }); + + it('handles dollar sign with comma separators', () => { + const result = roundToSignificantFigures('$1,234.567'); + + expect(result).toBe('1234.6'); + }); + }); + + describe('negative numbers', () => { + // Note: Negative numbers have minor counting difference due to minus sign handling + // In practice, prices are always positive, so this is acceptable behavior + it('rounds negative decimal (counts minus sign in integer part)', () => { + const result = roundToSignificantFigures('-123.456'); + + // Minus sign causes integer to count as 4 chars, leaving 1 decimal + expect(result).toBe('-123.5'); + }); + + it('returns normalized negative integer', () => { + const result = roundToSignificantFigures('-12345'); + + expect(result).toBe('-12345'); + }); + }); + + describe('custom max significant figures', () => { + it('rounds to custom max of 3 significant figures', () => { + const result = roundToSignificantFigures('12.345', 3); + + expect(result).toBe('12.3'); + }); + + it('rounds to custom max of 8 significant figures', () => { + const result = roundToSignificantFigures('12.345678901', 8); + + expect(result).toBe('12.345679'); + }); + + it('returns as-is when within custom max', () => { + const result = roundToSignificantFigures('12.34', 8); + + expect(result).toBe('12.34'); + }); + }); + }); + describe('isValidTakeProfitPrice', () => { describe('Long positions', () => { const params = { currentPrice: 100, direction: 'long' as const }; From 92be3c30ecfd8ffd97f4caba792eadb09ae98a46 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Tue, 6 Jan 2026 09:48:46 +0100 Subject: [PATCH 12/14] fix(perps): align regex in roundToSignificantFigures with countSignificantFigures Change regex from /^-?0+/ to /^-?0*/ to properly strip minus sign when counting significant figures for negative numbers. The 0+ quantifier required at least one zero to match, causing the minus sign to remain in trimmedInteger for numbers like -12.345. --- app/components/UI/Perps/utils/tpslValidation.test.ts | 8 +++----- app/components/UI/Perps/utils/tpslValidation.ts | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/app/components/UI/Perps/utils/tpslValidation.test.ts b/app/components/UI/Perps/utils/tpslValidation.test.ts index a5b2503d6379..7846e90d2d9e 100644 --- a/app/components/UI/Perps/utils/tpslValidation.test.ts +++ b/app/components/UI/Perps/utils/tpslValidation.test.ts @@ -416,13 +416,11 @@ describe('TPSL Validation Utilities', () => { }); describe('negative numbers', () => { - // Note: Negative numbers have minor counting difference due to minus sign handling - // In practice, prices are always positive, so this is acceptable behavior - it('rounds negative decimal (counts minus sign in integer part)', () => { + it('rounds negative decimal to 5 significant figures', () => { const result = roundToSignificantFigures('-123.456'); - // Minus sign causes integer to count as 4 chars, leaving 1 decimal - expect(result).toBe('-123.5'); + // 3 integer digits + 2 decimal = 5 sig figs (minus sign is stripped for counting) + expect(result).toBe('-123.46'); }); it('returns normalized negative integer', () => { diff --git a/app/components/UI/Perps/utils/tpslValidation.ts b/app/components/UI/Perps/utils/tpslValidation.ts index eda84b31bff4..2617336a5c74 100644 --- a/app/components/UI/Perps/utils/tpslValidation.ts +++ b/app/components/UI/Perps/utils/tpslValidation.ts @@ -120,7 +120,7 @@ export const roundToSignificantFigures = ( const [integerPart, decimalPart = ''] = normalized.split('.'); // Count integer significant digits (without leading zeros) - const trimmedInteger = integerPart.replace(/^-?0+/, '') || ''; + const trimmedInteger = integerPart.replace(/^-?0*/, '') || ''; const integerSigFigs = trimmedInteger.length; // If no decimal, return as is (integers are fine) From a613997ed17fcf528b255fd4895fd655eef4d338 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Tue, 6 Jan 2026 10:24:15 +0100 Subject: [PATCH 13/14] fix(perps): round prices in percentage text input handlers to 5 sig figs --- app/components/UI/Perps/hooks/usePerpsTPSLForm.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/components/UI/Perps/hooks/usePerpsTPSLForm.ts b/app/components/UI/Perps/hooks/usePerpsTPSLForm.ts index 54d022120dad..627dbd1168a4 100644 --- a/app/components/UI/Perps/hooks/usePerpsTPSLForm.ts +++ b/app/components/UI/Perps/hooks/usePerpsTPSLForm.ts @@ -405,7 +405,9 @@ export function usePerpsTPSLForm( leverage, entryPrice, }); - setTakeProfitPrice(price.toString()); + // Round to 5 significant figures to match input validation + const roundedPrice = roundToSignificantFigures(price.toString()); + setTakeProfitPrice(roundedPrice); setSelectedTpPercentage(roeValue); } else if (!finalValue) { setTakeProfitPrice(''); @@ -511,7 +513,9 @@ export function usePerpsTPSLForm( leverage, entryPrice, }); - setStopLossPrice(price.toString()); + // Round to 5 significant figures to match input validation + const roundedPrice = roundToSignificantFigures(price.toString()); + setStopLossPrice(roundedPrice); setSelectedSlPercentage(roeValue); // Store absolute value for button comparison } else if (!finalValue) { setStopLossPrice(''); From b5af764e09b6f344250a8e08e73e126b09a369a8 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Tue, 6 Jan 2026 11:10:55 +0100 Subject: [PATCH 14/14] fix(perps): apply significant figures rounding in blur handlers Add roundToSignificantFigures to handleTakeProfitPercentageBlur, handleStopLossPercentageBlur, and handleStopLossPriceBlur (zero ROE case) to ensure consistency with other handlers and prevent HyperLiquid API rejection due to exceeding 5 significant figures. --- app/components/UI/Perps/hooks/usePerpsTPSLForm.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/app/components/UI/Perps/hooks/usePerpsTPSLForm.ts b/app/components/UI/Perps/hooks/usePerpsTPSLForm.ts index 627dbd1168a4..e7b4073c89c5 100644 --- a/app/components/UI/Perps/hooks/usePerpsTPSLForm.ts +++ b/app/components/UI/Perps/hooks/usePerpsTPSLForm.ts @@ -609,7 +609,9 @@ export function usePerpsTPSLForm( leverage, entryPrice, }); - setTakeProfitPrice(price.toString()); + // Round to 5 significant figures to match input validation + const roundedPrice = roundToSignificantFigures(price.toString()); + setTakeProfitPrice(roundedPrice); } }, [ takeProfitPercentage, @@ -659,7 +661,11 @@ export function usePerpsTPSLForm( entryPrice, }); if (zeroRoePrice && zeroRoePrice !== stopLossPrice) { - setStopLossPrice(zeroRoePrice.toString()); + // Round to 5 significant figures to match input validation + const roundedPrice = roundToSignificantFigures( + zeroRoePrice.toString(), + ); + setStopLossPrice(roundedPrice); } } } @@ -694,7 +700,9 @@ export function usePerpsTPSLForm( leverage, entryPrice, }); - setStopLossPrice(price.toString()); + // Round to 5 significant figures to match input validation + const roundedPrice = roundToSignificantFigures(price.toString()); + setStopLossPrice(roundedPrice); } }, [stopLossPercentage, leverage, currentPrice, actualDirection, entryPrice]);