Repository: rango-exchange/rango-client
Pull request #1118: fix: normalize inputAmount by stripping leading zeros after first non…
Author: login: nikaaru
Created: 2025-04-30T17:39:55Z | Merged: 2025-05-19T17:49:23Z

## 📝 Summary

- Allows users to type raw-zero values like `0000`, `0.`, or `0.0000` without immediate formatting
- As soon as a non-zero digit is entered, strips leading zeros and normalizes input (e.g. `00001` → `1`, `0000.01` → `0.01`)
- Enforces a minimum acceptable value of `0.00001` — input below this (e.g. `0.00000`) is blocked

### 🔍 Changes

- Added four regex constants in **UPPER_SNAKE_CASE**:
  - `ALL_ZERO_REGEX` – detects pure-zero strings (e.g., `0000`, `0.000`)
  - `LEADING_ZEROS_REGEX` – strips leading zeros only when followed by a digit
  - `LEADING_DOT_REGEX` – normalizes inputs like `.5` → `0.5`
  - `ALLOWABLE_ZERO_INPUT_REGEX` – allows partial raw-zero input like `0.`, `0.000`

- Updated `setInputAmount`:
  - Allows raw-zero phase inputs (via `ALL_ZERO_REGEX` + `ALLOWABLE_ZERO_INPUT_REGEX`)
  - Sanitizes only after the first meaningful digit using `LEADING_ZEROS_REGEX` and `LEADING_DOT_REGEX`
  - Blocks values below `0.00001` by checking parsed numeric value

### 🛠️ How to Test
1. Run the app and enter each scenario from the table below.  
2. Ensure the input behaves as expected and normalization occurs only when appropriate.

### ✅ Some Test Scenarios
| Input        | Expected Output | Notes                                  |
|--------------|-----------------|----------------------------------------|
| `00000`      | `00000`         | raw-zero, allowed                      |
| `0.`         | `0.`            | raw-zero + dot, allowed                |
| `0.0000`     | `0.0000`        | up to 4 decimal zeros allowed          |
| `00001`      | `1`             | sanitized                              |
| `0000.01`    | `0.01`          | sanitized + normalized                 |
| `.5`         | `0.5`           | normalized                             |
| `0.00001`    | `0.00001`       | meets minimum, allowed                 |



## 🔄 Updates (since last review)
- Extracted `sanitize/validate` logic into `sanitizer.ts` & `validation.ts`
- Added `sanitizer.test.ts` & `validation.test.ts` with edge‑case coverage
- Removed constants from `constants.ts` (`ALL_ZERO_REGEX`, `LEADING_ZEROS_REGEX`, `LEADING_DOT_REGEX`, `ALLOWABLE_ZERO_INPUT_REGEX`) — now internalized in `sanitizer.ts` and `validation.ts.`
- Removed "minimum value" validation (no longer blocks `0.00000`)
- Refactored and extracted shared logic into new utility modules:
   `sanitizer.ts`: includes `formatThousandsWithCommas`, `replaceSpacesWithDash`
   `validation.ts`: includes `isValidCurrencyFormat`, `isNumeric`, `isColorKeyOverridden`
- Added unit tests for all extracted utility functions
- Added sanitizeInputAmount method to normalize zero values (e.g., `0000` → `0`) on input **blur**
- Sync **URL** search params with `quoteStore`.

#### 🧪 Verification Steps

- Review new files `sanitizer.ts` and `validation.ts` for correct function extraction.
- Run a `yarn test` and confirm that all tests in `sanitizer.test.ts` and `validation.test.ts` pass.





Diff:
diff --git a/widget/embedded/src/components/Slippage/Slippage.tsx b/widget/embedded/src/components/Slippage/Slippage.tsx
index aa5611dd0b..79cc5bcdd9 100644
--- a/widget/embedded/src/components/Slippage/Slippage.tsx
+++ b/widget/embedded/src/components/Slippage/Slippage.tsx
@@ -14,6 +14,7 @@ import { MAX_SLIPPAGE, SLIPPAGES } from '../../constants/swapSettings';
 import { useAppStore } from '../../store/AppStore';
 import { getContainer } from '../../utils/common';
 import { getSlippageValidation } from '../../utils/settings';
+import { isValidCurrencyFormat } from '../../utils/validation';
 
 import {
   BaseContainer,
@@ -54,9 +55,8 @@ export function Slippage() {
 
   const onInput = (event: React.FormEvent<HTMLInputElement>) => {
     const input = event.target as HTMLInputElement;
-    const regex = /^(0|[1-9]\d*)(\.\d{1,2})?$/;
     const value = input.value;
-    if (!regex.test(value)) {
+    if (!isValidCurrencyFormat(value)) {
       input.value = value.slice(0, -1);
     }
   };
diff --git a/widget/embedded/src/containers/Inputs/Inputs.tsx b/widget/embedded/src/containers/Inputs/Inputs.tsx
index 55c7fd6c51..477c9838ba 100644
--- a/widget/embedded/src/containers/Inputs/Inputs.tsx
+++ b/widget/embedded/src/containers/Inputs/Inputs.tsx
@@ -34,6 +34,7 @@ export function Inputs(props: PropTypes) {
     toToken,
     toBlockchain,
     setInputAmount,
+    sanitizeInputAmount,
     inputAmount,
     inputUsdValue,
     outputAmount,
@@ -79,6 +80,7 @@ export function Inputs(props: PropTypes) {
           label={i18n.t('From')}
           mode="From"
           onInputChange={setInputAmount}
+          onInputBlur={sanitizeInputAmount}
           balance={fromTokenFormattedBalance}
           chain={{
             displayName: fromBlockchain?.displayName || '',
diff --git a/widget/embedded/src/hooks/useSyncUrlAndStore/useSyncUrlAndStore.ts b/widget/embedded/src/hooks/useSyncUrlAndStore/useSyncUrlAndStore.ts
index 7473cd290e..8d6dfdc4d2 100644
--- a/widget/embedded/src/hooks/useSyncUrlAndStore/useSyncUrlAndStore.ts
+++ b/widget/embedded/src/hooks/useSyncUrlAndStore/useSyncUrlAndStore.ts
@@ -83,6 +83,7 @@ export function useSyncUrlAndStore() {
   useEffect(() => {
     const { autoConnect, clientUrl, utmQueryParams, blockchain } =
       getUrlSearchParams();
+
     if (isInRouterContext && fetchMetaStatus === 'success') {
       updateUrlSearchParams({
         [SearchParams.FROM_BLOCKCHAIN]: fromBlockchain?.name,
@@ -107,6 +108,7 @@ export function useSyncUrlAndStore() {
     toBlockchain,
     toToken,
     campaignMode,
+    fetchMetaStatus,
   ]);
 
   useEffect(() => {
diff --git a/widget/embedded/src/pages/LiquiditySourcePage.tsx b/widget/embedded/src/pages/LiquiditySourcePage.tsx
index f9146db25f..f56d1da6fb 100644
--- a/widget/embedded/src/pages/LiquiditySourcePage.tsx
+++ b/widget/embedded/src/pages/LiquiditySourcePage.tsx
@@ -22,6 +22,7 @@ import {
 } from '../components/SettingsContainer';
 import { useAppStore } from '../store/AppStore';
 import { containsText } from '../utils/numbers';
+import { replaceSpacesWithDash } from '../utils/sanitizers';
 import { getUniqueSwappersGroups } from '../utils/settings';
 
 interface PropTypes {
@@ -70,9 +71,10 @@ export function LiquiditySourcePage({ sourceType }: PropTypes) {
   const list = liquiditySources.map((sourceItem) => {
     const { selected, groupTitle, logo, id, ...restSourceItem } = sourceItem;
     return {
-      id: `widget-setting-liquidity-source-${id
-        .toLowerCase()
-        .replace(/\s+/g, '-')}-item-btn`,
+      id: `widget-setting-liquidity-source-${replaceSpacesWithDash(
+        id.toLowerCase()
+      )}-item-btn`,
+
       start: <Image src={logo} size={22} type="circular" />,
       onClick: () => {
         if (!campaignMode) {
diff --git a/widget/embedded/src/store/quote.ts b/widget/embedded/src/store/quote.ts
index 49fdd6ffd5..d62c8e3726 100644
--- a/widget/embedded/src/store/quote.ts
+++ b/widget/embedded/src/store/quote.ts
@@ -21,8 +21,13 @@ import {
   type Wallet,
   WidgetEvents,
 } from '../types';
-import { isPositiveNumber } from '../utils/numbers';
+import { isPositiveNumber, sanitizeInputAmount } from '../utils/numbers';
+import {
+  ensureLeadingZeroForDecimal,
+  removeLeadingZeros,
+} from '../utils/sanitizers';
 import { getUsdInputFrom, getUsdOutputFrom } from '../utils/swap';
+import { isZeroValue } from '../utils/validation';
 
 import createSelectors from './selectors';
 
@@ -87,6 +92,7 @@ export interface QuoteState {
     value: SomeQuoteState[K]
   ) => void;
   setInputAmount: (amount: string) => void;
+  sanitizeInputAmount: (amount: string) => void;
   setSelectedQuote: (quote: SelectedQuote | null) => void;
   retry: (retryQuote: RetryQuote) => void;
   switchFromAndTo: () => void;
@@ -223,16 +229,29 @@ export const useQuoteStore = createSelectors(
           }),
         }));
       },
+      sanitizeInputAmount: (amount) => {
+        const sanitized = sanitizeInputAmount(amount);
+
+        set(() => ({
+          inputAmount: sanitized,
+        }));
+      },
       setInputAmount: (amount) => {
+        let sanitized = amount;
+        if (!isZeroValue(amount)) {
+          // sanitize once a meaningful digit is entered (e.g. "00001" → "1")
+          sanitized = removeLeadingZeros(sanitized);
+          sanitized = ensureLeadingZeroForDecimal(sanitized);
+        }
         set((state) => ({
-          inputAmount: amount,
-          ...(!amount && {
+          inputAmount: sanitized,
+          ...(!sanitized && {
             outputAmount: null,
             outputUsdValue: new BigNumber(0),
             selectedQuote: null,
           }),
           ...(!!state.fromToken && {
-            inputUsdValue: getUsdValue(state.fromToken, amount),
+            inputUsdValue: getUsdValue(state.fromToken, sanitized),
           }),
         }));
       },
diff --git a/widget/embedded/src/utils/colors.ts b/widget/embedded/src/utils/colors.ts
index b58c4620bc..50a7b6b956 100644
--- a/widget/embedded/src/utils/colors.ts
+++ b/widget/embedded/src/utils/colors.ts
@@ -2,6 +2,8 @@
 // Types
 import type { ThemeColors, WidgetColors, WidgetColorsKeys } from '../types';
 
+import { isColorKeyOverridden } from './validation';
+
 type RGB = {
   red: number;
   green: number;
@@ -40,15 +42,6 @@ function expandShortHexColor(hexColor: string) {
   return `#${hexColor}`;
 }
 
-/*
- * We letting users to override some specific colors (e.g. `primary550`, `secondary100`).
- * So we are generating a range of colors if `primary` (or other keys) has passed but if user is passing a specific color,
- * we will override the user color to generated range.
- */
-function isOverridingColor(colorKey: string): boolean {
-  return /[0-9]+$/.test(colorKey);
-}
-
 // pad a hexadecimal string with zeros if it needs it
 function pad(number: string, length: number) {
   return number.padStart(length, '0');
@@ -171,7 +164,7 @@ export function expandToGenerateThemeColors(
      */
     const isSingleColor = ['background', 'foreground'].includes(colorKey);
 
-    if (!isSingleColor && !isOverridingColor(colorKey)) {
+    if (!isSingleColor && !isColorKeyOverridden(colorKey)) {
       const expandedHexColor = expandShortHexColor(expandColor);
       Object.assign(
         output,
diff --git a/widget/embedded/src/utils/numbers.ts b/widget/embedded/src/utils/numbers.ts
index 27d3dbdeb2..5852f0e5b2 100644
--- a/widget/embedded/src/utils/numbers.ts
+++ b/widget/embedded/src/utils/numbers.ts
@@ -4,6 +4,9 @@ import type { BestRouteResponse } from 'rango-sdk';
 
 import { BigNumber } from 'bignumber.js';
 
+import { stripTrailingZeros } from './sanitizers';
+import { isZeroValue } from './validation';
+
 /*
  * if time > 1h -> rounded with 5 minutes precision
  * if time < 1h -> rounded with 15 seconds precision
@@ -125,3 +128,11 @@ export const containsText = (text: string, searchText: string) =>
 
 export const isPositiveNumber = (text?: string) =>
   !!text && parseFloat(text) > 0;
+
+export function sanitizeInputAmount(amount: string): string {
+  if (isZeroValue(amount)) {
+    return '0';
+  }
+
+  return stripTrailingZeros(amount);
+}
diff --git a/widget/embedded/src/utils/sanitizers.test.ts b/widget/embedded/src/utils/sanitizers.test.ts
new file mode 100644
index 0000000000..4f24560a89
--- /dev/null
+++ b/widget/embedded/src/utils/sanitizers.test.ts
@@ -0,0 +1,122 @@
+import { faker } from '@faker-js/faker';
+import { describe, expect, test } from 'vitest';
+
+import {
+  ensureLeadingZeroForDecimal,
+  formatThousandsWithCommas,
+  removeLeadingZeros,
+  replaceSpacesWithDash,
+  stripTrailingZeros,
+} from './sanitizers';
+
+const WORD_COUNT = 5;
+const FAKER_SEED = 12;
+const LONG_NUMBER_LENGTH = 50;
+faker.seed(FAKER_SEED);
+
+describe('check sanitization behaviors', () => {
+  describe('check leading zero removal', () => {
+    test('should remove zeros at start before digits', () => {
+      expect(removeLeadingZeros('000123')).toBe('123');
+      expect(removeLeadingZeros('00123')).toBe('123');
+    });
+
+    test('should preserve lone zero or non-digit prefixes', () => {
+      expect(removeLeadingZeros('0')).toBe('0');
+      expect(removeLeadingZeros('0000')).toBe('0');
+      expect(removeLeadingZeros('00.1')).toBe('0.1');
+      expect(removeLeadingZeros('00a')).toBe('0a');
+    });
+
+    test('should leave strings with no leading zeros unchanged', () => {
+      const val = faker.number.int({ min: 1, max: 999 }).toString();
+      expect(removeLeadingZeros(val)).toBe(val);
+    });
+  });
+
+  describe('check decimal leading zero insertion', () => {
+    test('should add a zero before lone decimal points', () => {
+      expect(ensureLeadingZeroForDecimal('.5')).toBe('0.5');
+      expect(ensureLeadingZeroForDecimal('.000')).toBe('0.000');
+      expect(ensureLeadingZeroForDecimal('0.001')).toBe('0.001');
+      expect(ensureLeadingZeroForDecimal('000.000')).toBe('000.000');
+    });
+
+    test('should not alter other inputs', () => {
+      expect(ensureLeadingZeroForDecimal('0.5')).toBe('0.5');
+      expect(ensureLeadingZeroForDecimal('2.')).toBe('2.');
+      expect(ensureLeadingZeroForDecimal('')).toBe('');
+    });
+  });
+
+  describe('check thousand separator formatting', () => {
+    test('should insert commas every three digits for 4–6 digit numbers', () => {
+      expect(formatThousandsWithCommas('1000')).toBe('1,000');
+      expect(formatThousandsWithCommas('12345')).toBe('12,345');
+      expect(formatThousandsWithCommas('123456')).toBe('123,456');
+    });
+
+    test('should handle large numbers correctly', () => {
+      expect(formatThousandsWithCommas('1234567')).toBe('1,234,567');
+      expect(formatThousandsWithCommas('1234567890')).toBe('1,234,567,890');
+    });
+
+    test('should not add commas for numbers below 1000', () => {
+      expect(formatThousandsWithCommas('0')).toBe('0');
+      expect(formatThousandsWithCommas('999')).toBe('999');
+    });
+  });
+
+  describe('check space-to-dash replacement', () => {
+    test('should convert any spaces to a single dash', () => {
+      const words = faker.lorem.words(WORD_COUNT).split(' ');
+      const spaced = words.join('   ');
+      expect(replaceSpacesWithDash(spaced)).toBe(words.join('-'));
+    });
+
+    test('should convert tabs and newlines into dashes', () => {
+      const words = faker.lorem.words(WORD_COUNT).split(' ');
+      expect(replaceSpacesWithDash(words.join('\t'))).toBe(words.join('-'));
+      expect(replaceSpacesWithDash(words.join('\n'))).toBe(words.join('-'));
+    });
+
+    test('should handle empty or single-word strings', () => {
+      expect(replaceSpacesWithDash('')).toBe('');
+      expect(replaceSpacesWithDash('nospace')).toBe('nospace');
+    });
+  });
+
+  describe('check trailing zero stripping', () => {
+    test('should trim only zeros after last non-zero digit', () => {
+      expect(stripTrailingZeros('0.0010000')).toBe('0.001');
+      expect(stripTrailingZeros('123.45000')).toBe('123.45');
+      expect(stripTrailingZeros('5.1000')).toBe('5.1');
+    });
+
+    test('should remove entire fractional part if all zeros', () => {
+      expect(stripTrailingZeros('10.000')).toBe('10');
+      expect(stripTrailingZeros('0.000')).toBe('0');
+    });
+
+    test('should leave inputs without trailing zeros untouched', () => {
+      expect(stripTrailingZeros('42')).toBe('42');
+      expect(stripTrailingZeros('7.89')).toBe('7.89');
+      expect(stripTrailingZeros('0.123')).toBe('0.123');
+    });
+
+    test('edge: very long fractional zeros', () => {
+      const long =
+        '0.' +
+        '0'.repeat(LONG_NUMBER_LENGTH) +
+        '1' +
+        '0'.repeat(LONG_NUMBER_LENGTH);
+      expect(stripTrailingZeros(long)).toBe(
+        '0.' + '0'.repeat(LONG_NUMBER_LENGTH) + '1'
+      );
+    });
+
+    test('edge: no decimal point', () => {
+      expect(stripTrailingZeros('1000')).toBe('1000');
+    });
+  });
+});
diff --git a/widget/embedded/src/utils/sanitizers.ts b/widget/embedded/src/utils/sanitizers.ts
new file mode 100644
index 0000000000..797d2605fc
--- /dev/null
+++ b/widget/embedded/src/utils/sanitizers.ts
@@ -0,0 +1,41 @@
+/**
+ * Remove leading zeros when followed by another digit.
+ * @example "000123" → "123"
+ */
+export function removeLeadingZeros(input: string): string {
+  return input.replace(/^0+(?=\d)/g, '');
+}
+
+/**
+ * Ensure a leading zero before a decimal point.
+ * @example ".45" → "0.45"
+ */
+export function ensureLeadingZeroForDecimal(input: string): string {
+  return input.replace(/^\.(\d+)/, '0.$1');
+}
+
+/**
+ * Insert commas as thousand separators.
+ * @example "1234567" → "1,234,567"
+ */
+export function formatThousandsWithCommas(input: string): string {
+  return input.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
+}
+
+/**
+ * Replace spaces (one or more) with a single dash.
+ * @example "a b  c" → "a-b-c"
+ */
+export function replaceSpacesWithDash(input: string): string {
+  return input.replace(/\s+/g, '-');
+}
+
+/**
+ * Strip any trailing zeros in the fractional part, and remove a dangling decimal point.
+ * @example "0.0010000" → "0.001"
+ * @example "10.000"    → "10"
+ */
+export function stripTrailingZeros(input: string): string {
+  const s = input.replace(/(\.\d*?[1-9])0+$/, '$1');
+  return s.replace(/\.0+$/, '');
+}
diff --git a/widget/embedded/src/utils/validation.test.ts b/widget/embedded/src/utils/validation.test.ts
new file mode 100644
index 0000000000..2ac42b93de
--- /dev/null
+++ b/widget/embedded/src/utils/validation.test.ts
@@ -0,0 +1,121 @@
+import { faker } from '@faker-js/faker';
+import { describe, expect, test } from 'vitest';
+
+import {
+  isColorKeyOverridden,
+  isNumeric,
+  isValidCurrencyFormat,
+  isZeroValue,
+} from './validation';
+
+const FAKER_SEED = 14;
+const THREE_MAX_DECIMAL = 3;
+const FOUR_MAX_DECIMAL = 4;
+const SMALL_INT = { min: 1, max: 9 };
+const FLOAT_OPTS = { min: 0, max: 100, precision: 0.01 };
+faker.seed(FAKER_SEED);
+
+describe('check validation behaviors', () => {
+  describe('check zero-value detection', () => {
+    test('should detect strings of zeros', () => {
+      expect(isZeroValue('0')).toBe(true);
+      expect(isZeroValue('000')).toBe(true);
+      expect(isZeroValue('000.00')).toBe(true);
+    });
+    test('should reject non-zero or fractional values', () => {
+      expect(isZeroValue('0.0001')).toBe(false);
+      expect(isZeroValue('000.0001')).toBe(false);
+      expect(isZeroValue(faker.number.int(SMALL_INT).toString())).toBe(false);
+    });
+    test('should return false for empty or non-numeric', () => {
+      expect(isZeroValue('')).toBe(false);
+      expect(isZeroValue('abc')).toBe(false);
+    });
+  });
+
+  describe('check currency format validation', () => {
+    // Default behavior (maxDecimals = 2)
+    test('should accepts integers and up to 2 decimals', () => {
+      expect(isValidCurrencyFormat('0')).toBe(true);
+      expect(isValidCurrencyFormat('10.2')).toBe(true);
+      expect(isValidCurrencyFormat('10.25')).toBe(true);
+    });
+
+    test('should rejects more than 2 decimals', () => {
+      expect(isValidCurrencyFormat('1.234')).toBe(false);
+      expect(isValidCurrencyFormat('0.001')).toBe(false);
+    });
+
+    test('should rejects leading zero on non-zero integer', () => {
+      expect(isValidCurrencyFormat('01')).toBe(false);
+      expect(isValidCurrencyFormat('00.5')).toBe(false);
+    });
+
+    test('should rejects non-numeric and malformed strings', () => {
+      expect(isValidCurrencyFormat('')).toBe(false);
+      expect(isValidCurrencyFormat('.5')).toBe(false);
+      expect(isValidCurrencyFormat('1.2.3')).toBe(false);
+      expect(isValidCurrencyFormat('abc')).toBe(false);
+      expect(isValidCurrencyFormat('0.1a')).toBe(false);
+    });
+
+    test('should allows up to specified decimals', () => {
+      expect(isValidCurrencyFormat('1.234', THREE_MAX_DECIMAL)).toBe(true);
+      expect(isValidCurrencyFormat('0.1234', FOUR_MAX_DECIMAL)).toBe(true);
+    });
+
+    test('should rejects if decimals exceed custom limit', () => {
+      expect(isValidCurrencyFormat('2.1234', THREE_MAX_DECIMAL)).toBe(false);
+      expect(isValidCurrencyFormat('0.12345', FOUR_MAX_DECIMAL)).toBe(false);
+    });
+
+    test('should rejects trailing dot', () => {
+      expect(isValidCurrencyFormat('10.')).toBe(false);
+    });
+
+    test('should handles zero with custom decimals edge', () => {
+      expect(isValidCurrencyFormat('0.0', 1)).toBe(true);
+      expect(isValidCurrencyFormat('0.00', 1)).toBe(false);
+    });
+
+    test('check large values within limits', () => {
+      expect(isValidCurrencyFormat('1234567890.12')).toBe(true);
+      expect(isValidCurrencyFormat('1234567890.123')).toBe(false);
+    });
+  });
+
+  describe('check numeric string validation', () => {
+    test('should accept integers and decimals', () => {
+      expect(isNumeric('0')).toBe(true);
+      expect(isNumeric('00.1')).toBe(true);
+      expect(isNumeric('123.456')).toBe(true);
+      expect(isNumeric(faker.number.float(FLOAT_OPTS).toString())).toBe(true);
+    });
+    test('should reject leading dot or letters', () => {
+      expect(isNumeric('.5')).toBe(false);
+      expect(isNumeric('abc')).toBe(false);
+      expect(isNumeric('1.2.3')).toBe(false);
+    });
+    test('should reject empty string', () => {
+      expect(isNumeric('')).toBe(false);
+    });
+  });
+
+  describe('check color key override detection', () => {
+    test('should detect numeric suffix keys', () => {
+      expect(isColorKeyOverridden('primary550')).toBe(true);
+      expect(isColorKeyOverridden('secondary100')).toBe(true);
+      expect(
+        isColorKeyOverridden(
+          faker.word.sample() +
+            faker.number.int({ min: 1, max: 999 }).toString()
+        )
+      ).toBe(true);
+    });
+    test('should reject keys without digits', () => {
+      expect(isColorKeyOverridden('primary')).toBe(false);
+      expect(isColorKeyOverridden('colorKey')).toBe(false);
+      expect(isColorKeyOverridden('')).toBe(false);
+    });
+  });
+});
diff --git a/widget/embedded/src/utils/validation.ts b/widget/embedded/src/utils/validation.ts
new file mode 100644
index 0000000000..4b18eff926
--- /dev/null
+++ b/widget/embedded/src/utils/validation.ts
@@ -0,0 +1,45 @@
+/**
+ * Check if a string is composed only of zeros, optionally with decimal zeros.
+ * @param input - string to test, e.g. "0", "000.00"
+ * @returns true when input represents zero, otherwise false
+ */
+export function isZeroValue(input: string) {
+  const zeroPattern = /^0+(?:\.0+)?$/;
+  return zeroPattern.test(input);
+}
+
+/**
+ * Validate currency-style input: up to `maxDecimals` places after the decimal point.
+ * @param input - string to test, e.g. "0", "10.25"
+ * @param maxDecimals - maximum digits allowed after the decimal (default: 2)
+ * @returns true for valid money formats, false otherwise
+ */
+export function isValidCurrencyFormat(
+  input: string,
+  maxDecimals: number = 2
+): boolean {
+  // construct pattern like ^(?:0|[1-9]\d*)(?:\.\d{1,2})?$
+  const pattern = `^(?:0|[1-9]\\d*)(?:\\.\\d{1,${maxDecimals}})?$`;
+  const regex = new RegExp(pattern);
+  return regex.test(input);
+}
+
+/**
+ * Test if a string is a numeric literal (integers or decimals), allows leading zeros.
+ * @param input - string to test, e.g. "00.5", "123"
+ * @returns true when string is numeric, false otherwise
+ */
+export function isNumeric(input: string): boolean {
+  const numericPattern = /^\d+(?:\.\d+)?$/;
+  return numericPattern.test(input);
+}
+
+/**
+ * Detect if a color key name ends with digits (overriding default shades).
+ * @param key - color key, e.g. "primary", "secondary100"
+ * @returns true when key has numeric suffix, false otherwise
+ */
+export function isColorKeyOverridden(key: string): boolean {
+  const overridePattern = /\d+$/;
+  return overridePattern.test(key);
+}
diff --git a/widget/embedded/src/utils/wallets.ts b/widget/embedded/src/utils/wallets.ts
index 5480567c98..9bb4272570 100644
--- a/widget/embedded/src/utils/wallets.ts
+++ b/widget/embedded/src/utils/wallets.ts
@@ -40,6 +40,7 @@ import { EXCLUDED_WALLETS } from '../constants/wallets';
 
 import { isBlockchainTypeInCategory, removeDuplicateFrom } from './common';
 import { numberToString } from './numbers';
+import { formatThousandsWithCommas } from './sanitizers';
 
 export type ExtendedModalWalletInfo = WalletInfoWithExtra &
   Pick<ExtendedWalletInfo, 'properties' | 'isHub'>;
@@ -294,7 +295,7 @@ export const calculateWalletUsdValue = (balances: BalanceState) => {
 
 function numberWithThousandSeparator(number: string | number): string {
   const parts = number.toString().split('.');
-  parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
+  parts[0] = formatThousandsWithCommas(parts[0]);
   return parts.join('.');
 }
 
diff --git a/widget/ui/src/containers/SwapInput/SwapInput.tsx b/widget/ui/src/containers/SwapInput/SwapInput.tsx
index 43dc4745fd..fd15d49b53 100644
--- a/widget/ui/src/containers/SwapInput/SwapInput.tsx
+++ b/widget/ui/src/containers/SwapInput/SwapInput.tsx
@@ -129,6 +129,10 @@ export function SwapInput(props: SwapInputPropTypes) {
                   size="large"
                   placeholder="0"
                   variant="ghost"
+                  {...('onInputBlur' in props && {
+                    onBlur: (event: React.ChangeEvent<HTMLInputElement>) =>
+                      props.onInputBlur?.(event.target.value),
+                  })}
                   min={0}
                   {...('onInputChange' in props && {
                     onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
diff --git a/widget/ui/src/containers/SwapInput/SwapInput.types.ts b/widget/ui/src/containers/SwapInput/SwapInput.types.ts
index 885b87c836..7db06304f1 100644
--- a/widget/ui/src/containers/SwapInput/SwapInput.types.ts
+++ b/widget/ui/src/containers/SwapInput/SwapInput.types.ts
@@ -33,6 +33,7 @@ type FromProps = {
   onSelectMaxBalance: () => void;
   onInputChange: (inputAmount: string) => void;
   anyWalletConnected: boolean;
+  onInputBlur?: (inputAmount: string) => void;
 };
 
 type ToProps = {


Reviews:
user: login: yeager-eren
body: Why you didn't use [zod](https://zod.dev/?id=numbers)‍‍?
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-04T07:13:58Z
user: login: yeager-eren
body: neat implementation. thanks.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T14:30:20Z
user: login: nikaaru
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-12T16:27:55Z
user: login: nikaaru
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-12T16:33:06Z
user: login: nikaaru
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T09:01:32Z
user: login: yeager-eren
body: I resolved my comments. you can move it to test.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T09:05:36Z

Review comments:
diff_hunk: @@ -0,0 +1,87 @@
+import { faker } from '@faker-js/faker';
+import { describe, expect, test } from 'vitest';
+
+import {
+  ensureLeadingZeroForDecimal,
+  formatThousandsWithCommas,
+  removeLeadingZeros,
+  replaceSpacesWithDash,
+} from './sanitizers';
+
+const WORD_COUNT = 5;
+const FAKER_SEED = 12;
+faker.seed(FAKER_SEED);
+
+describe('check sanitization behaviors', () => {
+  describe('check leading zero removal', () => {
+    test('should remove zeros at start before digits', () => {
+      expect(removeLeadingZeros('000123')).toBe('123');
+      expect(removeLeadingZeros('00123')).toBe('123');
+    });
+
+    test('should preserve lone zero or non-digit prefixes', () => {
+      expect(removeLeadingZeros('0')).toBe('0');
+      expect(removeLeadingZeros('0000')).toBe('0');
+      expect(removeLeadingZeros('00.1')).toBe('0.1');
+      expect(removeLeadingZeros('00a')).toBe('0a');
+    });
+
+    test('should leave strings with no leading zeros unchanged', () => {
+      const val = faker.number.int({ min: 1, max: 999 }).toString();
+      expect(removeLeadingZeros(val)).toBe(val);
+    });
+  });
+
+  describe('check decimal leading zero insertion', () => {
+    test('should add a zero before lone decimal points', () => {
+      expect(ensureLeadingZeroForDecimal('.5')).toBe('0.5');
+      expect(ensureLeadingZeroForDecimal('.000')).toBe('0.000');
+      expect(ensureLeadingZeroForDecimal('0.001')).toBe('0.001');
+      expect(ensureLeadingZeroForDecimal('000.000')).toBe('000.000');
+    });
+
+    test('should not alter other inputs', () => {
+      expect(ensureLeadingZeroForDecimal('0.5')).toBe('0.5');
+      expect(ensureLeadingZeroForDecimal('2.')).toBe('2.');
+      expect(ensureLeadingZeroForDecimal('')).toBe('');
+    });
+  });
+
+  describe('check thousand separator formatting', () => {
+    test('should insert commas every three digits for 4–6 digit numbers', () => {
+      expect(formatThousandsWithCommas('1000')).toBe('1,000');
+      expect(formatThousandsWithCommas('12345')).toBe('12,345');
+      expect(formatThousandsWithCommas('123456')).toBe('123,456');
+    });
+
+    test('should handle large numbers correctly', () => {
+      expect(formatThousandsWithCommas('1234567')).toBe('1,234,567');
+      expect(formatThousandsWithCommas('1234567890')).toBe('1,234,567,890');
+    });
+
+    test('should not add commas for numbers below 1000', () => {
+      expect(formatThousandsWithCommas('0')).toBe('0');
+      expect(formatThousandsWithCommas('999')).toBe('999');
+    });
+  });
+
+  describe('check space-to-dash replacement', () => {
+    test('should convert any spaces to a single dash', () => {
+      const words = faker.lorem.words(WORD_COUNT).split(' ');
+      const spaced = words.join('   ');
+      expect(replaceSpacesWithDash(spaced)).toBe(words.join('-'));
+    });
+
+    test('should convert tabs and newlines into dashes', () => {
+      const words = faker.lorem.words(WORD_COUNT).split(' ');
+      console.log(replaceSpacesWithDash(words.join('\t')));
path: widget/embedded/src/utils/sanitizers.test.ts
user: login: yeager-eren
body: 😗
created_at: 2025-05-09T13:13:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,39 @@
+/**
+ * Check if a string is composed only of zeros, optionally with decimal zeros.
+ * @param input - string to test, e.g. "0", "000.00"
+ * @returns true when input represents zero, otherwise false
+ */
+export function isZeroValue(input: string) {
+  const zeroPattern = /^0+(?:\.0+)?$/;
+  return zeroPattern.test(input);
+}
+
+/**
+ * Validate currency-style input: no leading zeros (except zero itself), up to two decimals.
+ * @param input - string to test, e.g. "0", "10.25"
+ * @returns true for valid money formats, false otherwise
+ */
+export function isValidCurrencyFormat(input: string) {
path: widget/embedded/src/utils/validation.ts
user: login: yeager-eren
body: nit: i don't know change the name or not, currency format is a good name, but not all the currencies have two minor unit.
Take a look at [ISO 4217](https://www.iso.org/iso-4217-currency-codes.html) and [this list](https://www.six-group.com/dam/download/financial-information/data-center/iso-currrency/lists/list-one.xls).
created_at: 2025-05-09T14:06:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,87 @@
+import { faker } from '@faker-js/faker';
+import { describe, expect, test } from 'vitest';
+
+import {
+  ensureLeadingZeroForDecimal,
+  formatThousandsWithCommas,
+  removeLeadingZeros,
+  replaceSpacesWithDash,
+} from './sanitizers';
+
+const WORD_COUNT = 5;
+const FAKER_SEED = 12;
+faker.seed(FAKER_SEED);
+
+describe('check sanitization behaviors', () => {
+  describe('check leading zero removal', () => {
+    test('should remove zeros at start before digits', () => {
+      expect(removeLeadingZeros('000123')).toBe('123');
+      expect(removeLeadingZeros('00123')).toBe('123');
+    });
+
+    test('should preserve lone zero or non-digit prefixes', () => {
+      expect(removeLeadingZeros('0')).toBe('0');
+      expect(removeLeadingZeros('0000')).toBe('0');
+      expect(removeLeadingZeros('00.1')).toBe('0.1');
+      expect(removeLeadingZeros('00a')).toBe('0a');
+    });
+
+    test('should leave strings with no leading zeros unchanged', () => {
+      const val = faker.number.int({ min: 1, max: 999 }).toString();
+      expect(removeLeadingZeros(val)).toBe(val);
+    });
+  });
+
+  describe('check decimal leading zero insertion', () => {
+    test('should add a zero before lone decimal points', () => {
+      expect(ensureLeadingZeroForDecimal('.5')).toBe('0.5');
+      expect(ensureLeadingZeroForDecimal('.000')).toBe('0.000');
+      expect(ensureLeadingZeroForDecimal('0.001')).toBe('0.001');
+      expect(ensureLeadingZeroForDecimal('000.000')).toBe('000.000');
+    });
+
+    test('should not alter other inputs', () => {
path: widget/embedded/src/utils/sanitizers.test.ts
user: login: yeager-eren
body: how about `0.1a`?
created_at: 2025-05-09T14:26:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,39 @@
+/**
+ * Check if a string is composed only of zeros, optionally with decimal zeros.
+ * @param input - string to test, e.g. "0", "000.00"
+ * @returns true when input represents zero, otherwise false
+ */
+export function isZeroValue(input: string) {
+  const zeroPattern = /^0+(?:\.0+)?$/;
+  return zeroPattern.test(input);
+}
+
+/**
+ * Validate currency-style input: no leading zeros (except zero itself), up to two decimals.
+ * @param input - string to test, e.g. "0", "10.25"
+ * @returns true for valid money formats, false otherwise
+ */
+export function isValidCurrencyFormat(input: string) {
path: widget/embedded/src/utils/validation.ts
user: login: nikaaru
body: I added dynamic decimal support to the `isValidCurrencyFormat` function.
Also, I updated a unit test.

created_at: 2025-05-12T16:27:55Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,87 @@
+import { faker } from '@faker-js/faker';
+import { describe, expect, test } from 'vitest';
+
+import {
+  ensureLeadingZeroForDecimal,
+  formatThousandsWithCommas,
+  removeLeadingZeros,
+  replaceSpacesWithDash,
+} from './sanitizers';
+
+const WORD_COUNT = 5;
+const FAKER_SEED = 12;
+faker.seed(FAKER_SEED);
+
+describe('check sanitization behaviors', () => {
+  describe('check leading zero removal', () => {
+    test('should remove zeros at start before digits', () => {
+      expect(removeLeadingZeros('000123')).toBe('123');
+      expect(removeLeadingZeros('00123')).toBe('123');
+    });
+
+    test('should preserve lone zero or non-digit prefixes', () => {
+      expect(removeLeadingZeros('0')).toBe('0');
+      expect(removeLeadingZeros('0000')).toBe('0');
+      expect(removeLeadingZeros('00.1')).toBe('0.1');
+      expect(removeLeadingZeros('00a')).toBe('0a');
+    });
+
+    test('should leave strings with no leading zeros unchanged', () => {
+      const val = faker.number.int({ min: 1, max: 999 }).toString();
+      expect(removeLeadingZeros(val)).toBe(val);
+    });
+  });
+
+  describe('check decimal leading zero insertion', () => {
+    test('should add a zero before lone decimal points', () => {
+      expect(ensureLeadingZeroForDecimal('.5')).toBe('0.5');
+      expect(ensureLeadingZeroForDecimal('.000')).toBe('0.000');
+      expect(ensureLeadingZeroForDecimal('0.001')).toBe('0.001');
+      expect(ensureLeadingZeroForDecimal('000.000')).toBe('000.000');
+    });
+
+    test('should not alter other inputs', () => {
path: widget/embedded/src/utils/sanitizers.test.ts
user: login: nikaaru
body: Should reject the non-numeric input.

I added some rejected tests for this function:

```ts
    test('should rejects non-numeric and malformed strings', () => {
      expect(isValidCurrencyFormat('')).toBe(false);
      expect(isValidCurrencyFormat('.5')).toBe(false);
      expect(isValidCurrencyFormat('1.2.3')).toBe(false);
      expect(isValidCurrencyFormat('abc')).toBe(false);
      expect(isValidCurrencyFormat('0.1a')).toBe(false);
    });
    ```
created_at: 2025-05-12T16:33:06Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,87 @@
+import { faker } from '@faker-js/faker';
+import { describe, expect, test } from 'vitest';
+
+import {
+  ensureLeadingZeroForDecimal,
+  formatThousandsWithCommas,
+  removeLeadingZeros,
+  replaceSpacesWithDash,
+} from './sanitizers';
+
+const WORD_COUNT = 5;
+const FAKER_SEED = 12;
+faker.seed(FAKER_SEED);
+
+describe('check sanitization behaviors', () => {
+  describe('check leading zero removal', () => {
+    test('should remove zeros at start before digits', () => {
+      expect(removeLeadingZeros('000123')).toBe('123');
+      expect(removeLeadingZeros('00123')).toBe('123');
+    });
+
+    test('should preserve lone zero or non-digit prefixes', () => {
+      expect(removeLeadingZeros('0')).toBe('0');
+      expect(removeLeadingZeros('0000')).toBe('0');
+      expect(removeLeadingZeros('00.1')).toBe('0.1');
+      expect(removeLeadingZeros('00a')).toBe('0a');
+    });
+
+    test('should leave strings with no leading zeros unchanged', () => {
+      const val = faker.number.int({ min: 1, max: 999 }).toString();
+      expect(removeLeadingZeros(val)).toBe(val);
+    });
+  });
+
+  describe('check decimal leading zero insertion', () => {
+    test('should add a zero before lone decimal points', () => {
+      expect(ensureLeadingZeroForDecimal('.5')).toBe('0.5');
+      expect(ensureLeadingZeroForDecimal('.000')).toBe('0.000');
+      expect(ensureLeadingZeroForDecimal('0.001')).toBe('0.001');
+      expect(ensureLeadingZeroForDecimal('000.000')).toBe('000.000');
+    });
+
+    test('should not alter other inputs', () => {
+      expect(ensureLeadingZeroForDecimal('0.5')).toBe('0.5');
+      expect(ensureLeadingZeroForDecimal('2.')).toBe('2.');
+      expect(ensureLeadingZeroForDecimal('')).toBe('');
+    });
+  });
+
+  describe('check thousand separator formatting', () => {
+    test('should insert commas every three digits for 4–6 digit numbers', () => {
+      expect(formatThousandsWithCommas('1000')).toBe('1,000');
+      expect(formatThousandsWithCommas('12345')).toBe('12,345');
+      expect(formatThousandsWithCommas('123456')).toBe('123,456');
+    });
+
+    test('should handle large numbers correctly', () => {
+      expect(formatThousandsWithCommas('1234567')).toBe('1,234,567');
+      expect(formatThousandsWithCommas('1234567890')).toBe('1,234,567,890');
+    });
+
+    test('should not add commas for numbers below 1000', () => {
+      expect(formatThousandsWithCommas('0')).toBe('0');
+      expect(formatThousandsWithCommas('999')).toBe('999');
+    });
+  });
+
+  describe('check space-to-dash replacement', () => {
+    test('should convert any spaces to a single dash', () => {
+      const words = faker.lorem.words(WORD_COUNT).split(' ');
+      const spaced = words.join('   ');
+      expect(replaceSpacesWithDash(spaced)).toBe(words.join('-'));
+    });
+
+    test('should convert tabs and newlines into dashes', () => {
+      const words = faker.lorem.words(WORD_COUNT).split(' ');
+      console.log(replaceSpacesWithDash(words.join('\t')));
path: widget/embedded/src/utils/sanitizers.test.ts
user: login: nikaaru
body: Removed
created_at: 2025-05-13T09:01:32Z
author_association: MEMBER

PR comments:
user: login: yeager-eren
created_at: 2025-05-04T07:30:23Z
body: 1. Query string should be validated and fixed on first page load. [e.g. ](http://rango-widget-iframe-3m27y1ljv-rango-exchange.vercel.app/?fromBlockchain=ETH&fromToken=ETH&toBlockchain=ETH&toToken=USDT--0xdac17f958d2ee523a2206206994597c13d831ec7&fromAmount=000.000)
2. Blur event should be used also to update and fix user's input. e.g. type 0000 and click outside the input (blur).
author_association: CONTRIBUTOR
user: login: nikaaru
created_at: 2025-05-05T08:45:12Z
body: > Why you didn't use [zod](https://zod.dev/?id=numbers)‍‍?

I'm not using `Zod` here:
- Because it hasn't been added to `rango-client` before.
- Adding `Zod` now would bloat our bundle
- We don't show the user a proper message either.
- And also, it wasn't mentioned in the task description either.
author_association: MEMBER
user: login: nikaaru
created_at: 2025-05-05T11:09:38Z
body: > 1. Query string should be validated and fixed on first page load. [e.g. ](http://rango-widget-iframe-3m27y1ljv-rango-exchange.vercel.app/?fromBlockchain=ETH&fromToken=ETH&toBlockchain=ETH&toToken=USDT--0xdac17f958d2ee523a2206206994597c13d831ec7&fromAmount=000.000)
> 2. Blur event should be used also to update and fix user's input. e.g. type 0000 and click outside the input (blur).

1. Good catch, the query string was also validated, but it wasn't truncated properly, so I fixed it.
2. Blur's behavior is 1inch different, and also this was not mentioned in the task.
author_association: MEMBER
user: login: nikaaru
created_at: 2025-05-19T13:20:55Z
body: ✅ Added new `stripTrailingZeros` function in `sanitizers.ts`  ( ```@example "0.0010000" → "0.001" ```)
✅ Covered edge cases in `sanitizer.test.ts` (pure‑zero, long fractions, no‑dot inputs)  

author_association: MEMBER