diff --git a/apps/frontend/src/app/5_pages/ConvertPage/ConvertPage.slippage.test.ts b/apps/frontend/src/app/5_pages/ConvertPage/ConvertPage.slippage.test.ts new file mode 100644 index 000000000..131b6c28a --- /dev/null +++ b/apps/frontend/src/app/5_pages/ConvertPage/ConvertPage.slippage.test.ts @@ -0,0 +1,74 @@ +import { + MAXIMUM_SLIPPAGE_TOLERANCE, + SLIPPAGE_TOLERANCE_WARNING_THRESHOLD, + isHighSlippageTolerance, + isInvalidSlippageTolerance, +} from './ConvertPage.slippage'; + +describe('ConvertPage slippage tolerance policy', () => { + it('warns above 5% and hard-caps at 49%', () => { + expect(SLIPPAGE_TOLERANCE_WARNING_THRESHOLD).toBe(5); + expect(MAXIMUM_SLIPPAGE_TOLERANCE).toBe(49); + }); + + describe('isHighSlippageTolerance', () => { + it('does not warn for the default tolerance', () => { + expect(isHighSlippageTolerance('0.5')).toBe(false); + }); + + it('does not warn just below 5%', () => { + expect(isHighSlippageTolerance('4.99')).toBe(false); + }); + + it('warns at exactly 5% (catches 5 typed instead of 0.5)', () => { + expect(isHighSlippageTolerance('5')).toBe(true); + }); + + it('warns at 5.01%', () => { + expect(isHighSlippageTolerance('5.01')).toBe(true); + }); + + it('warns at 6%', () => { + expect(isHighSlippageTolerance('6')).toBe(true); + }); + + it('warns at 49%', () => { + expect(isHighSlippageTolerance('49')).toBe(true); + }); + + it('does not warn for an empty input', () => { + expect(isHighSlippageTolerance('')).toBe(false); + }); + }); + + describe('isInvalidSlippageTolerance', () => { + it('allows the default tolerance', () => { + expect(isInvalidSlippageTolerance('0.5')).toBe(false); + }); + + it('allows 5.01% and 6% (warning only, swap still allowed)', () => { + expect(isInvalidSlippageTolerance('5.01')).toBe(false); + expect(isInvalidSlippageTolerance('6')).toBe(false); + }); + + it('allows exactly 49%', () => { + expect(isInvalidSlippageTolerance('49')).toBe(false); + }); + + it('blocks 49.01%', () => { + expect(isInvalidSlippageTolerance('49.01')).toBe(true); + }); + + it('blocks 50%', () => { + expect(isInvalidSlippageTolerance('50')).toBe(true); + }); + + it('blocks 100%', () => { + expect(isInvalidSlippageTolerance('100')).toBe(true); + }); + + it('allows an empty input', () => { + expect(isInvalidSlippageTolerance('')).toBe(false); + }); + }); +}); diff --git a/apps/frontend/src/app/5_pages/ConvertPage/ConvertPage.slippage.ts b/apps/frontend/src/app/5_pages/ConvertPage/ConvertPage.slippage.ts new file mode 100644 index 000000000..fa566a97e --- /dev/null +++ b/apps/frontend/src/app/5_pages/ConvertPage/ConvertPage.slippage.ts @@ -0,0 +1,14 @@ +// Slippage tolerance input policy. Not to be confused with +// MAXIMUM_ALLOWED_SLIPPAGE in ConvertPage.constants.ts, which gates the +// quoted price impact of a swap, not the user-entered tolerance. +export const SLIPPAGE_TOLERANCE_WARNING_THRESHOLD = 5; +export const MAXIMUM_SLIPPAGE_TOLERANCE = 49; + +// Inclusive: exactly 5 also warns, to catch 5 typed instead of 0.5. +export const isHighSlippageTolerance = (value: string) => + Number(value) >= SLIPPAGE_TOLERANCE_WARNING_THRESHOLD; + +// The input's HTML max attribute does not block typed values, so values +// above the cap must also be rejected here and block submission. +export const isInvalidSlippageTolerance = (value: string) => + Number(value) > MAXIMUM_SLIPPAGE_TOLERANCE; diff --git a/apps/frontend/src/app/5_pages/ConvertPage/ConvertPage.tsx b/apps/frontend/src/app/5_pages/ConvertPage/ConvertPage.tsx index 2982456a3..1869d8f68 100644 --- a/apps/frontend/src/app/5_pages/ConvertPage/ConvertPage.tsx +++ b/apps/frontend/src/app/5_pages/ConvertPage/ConvertPage.tsx @@ -73,6 +73,11 @@ import { SMART_ROUTER_STABLECOINS, SWAP_ROUTES, } from './ConvertPage.constants'; +import { + MAXIMUM_SLIPPAGE_TOLERANCE, + isHighSlippageTolerance, + isInvalidSlippageTolerance, +} from './ConvertPage.slippage'; import { CategoryType } from './ConvertPage.types'; import { AssetDropdownWithFilters } from './components/AssetDropdownWithFilters/AssetDropdownWithFilters'; import { useConversionMaintenance } from './hooks/useConversionMaintenance'; @@ -183,6 +188,16 @@ const ConvertPage: FC = () => { DEFAULT_SLIPPAGE_TOLERANCE, ); + const isSlippageToleranceInvalid = useMemo( + () => isInvalidSlippageTolerance(slippageTolerance), + [slippageTolerance], + ); + + const isSlippageToleranceHigh = useMemo( + () => isHighSlippageTolerance(slippageTolerance), + [slippageTolerance], + ); + const [priceInQuote, setPriceQuote] = useState(false); const hasMyntBalance = useMemo(() => myntBalance.gt(0), [myntBalance]); const [amount, setAmount, weiAmount] = useWeiAmountInput(''); @@ -539,6 +554,7 @@ const ConvertPage: FC = () => { Number(amount) > Number(maximumAmountToConvert) || !destinationToken || !route || + isSlippageToleranceInvalid || (isSlippageHigh && !slippageWarningAccepted), [ isInMaintenance, @@ -547,6 +563,7 @@ const ConvertPage: FC = () => { maximumAmountToConvert, destinationToken, route, + isSlippageToleranceInvalid, isSlippageHigh, slippageWarningAccepted, ], @@ -867,8 +884,30 @@ const ConvertPage: FC = () => { step="0.01" decimalPrecision={2} placeholder="0" - max="100" + max={MAXIMUM_SLIPPAGE_TOLERANCE} + invalid={isSlippageToleranceInvalid} /> + + {isSlippageToleranceInvalid ? ( + + ) : ( + isSlippageToleranceHigh && ( + + ) + )} diff --git a/apps/frontend/src/locales/en/translations.json b/apps/frontend/src/locales/en/translations.json index 44343c9a0..642803008 100644 --- a/apps/frontend/src/locales/en/translations.json +++ b/apps/frontend/src/locales/en/translations.json @@ -432,7 +432,9 @@ "quoteError": "No route found, please try another asset or a lower amount", "noAvailableTokens": "No available tokens", "swapValueWarning": "The swap value is significantly lower than expected due to market conditions or slippage.", - "slippageWarning": "I acknowledge the risk and confirm to proceed" + "slippageWarning": "I acknowledge the risk and confirm to proceed", + "highSlippageToleranceWarning": "High slippage tolerance: your transaction may be frontrun and result in an unfavorable trade", + "invalidSlippageToleranceError": "Slippage tolerance must be {{max}}% or less" }, "txDialog": { "convertTitle": "Convert {{from}} to {{to}}",