Commit 26d2ab53 authored by cartcrom's avatar cartcrom Committed by GitHub

feat: disable exact_output FOT swaps (#7237)

* feat: disable exact_output FOT swaps

* fix: pr comments

* test: update snapshots
parent e50ecc83
import React from 'react' import React, { forwardRef } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import { escapeRegExp } from 'utils' import { escapeRegExp } from 'utils'
const StyledInput = styled.input<{ error?: boolean; fontSize?: string; align?: string }>` const StyledInput = styled.input<{ error?: boolean; fontSize?: string; align?: string; disabled?: boolean }>`
color: ${({ error, theme }) => (error ? theme.critical : theme.neutral1)}; color: ${({ error, theme }) => (error ? theme.critical : theme.neutral1)};
pointer-events: ${({ disabled }) => (disabled ? 'none' : 'auto')};
width: 0; width: 0;
position: relative; position: relative;
font-weight: 485; font-weight: 485;
...@@ -40,60 +41,61 @@ const StyledInput = styled.input<{ error?: boolean; fontSize?: string; align?: s ...@@ -40,60 +41,61 @@ const StyledInput = styled.input<{ error?: boolean; fontSize?: string; align?: s
const inputRegex = RegExp(`^\\d*(?:\\\\[.])?\\d*$`) // match escaped "." characters via in a non-capturing group const inputRegex = RegExp(`^\\d*(?:\\\\[.])?\\d*$`) // match escaped "." characters via in a non-capturing group
export const Input = React.memo(function InnerInput({ interface InputProps extends Omit<React.HTMLProps<HTMLInputElement>, 'ref' | 'onChange' | 'as'> {
value,
onUserInput,
placeholder,
prependSymbol,
...rest
}: {
value: string | number value: string | number
onUserInput: (input: string) => void onUserInput: (input: string) => void
error?: boolean error?: boolean
fontSize?: string fontSize?: string
align?: 'right' | 'left' align?: 'right' | 'left'
prependSymbol?: string prependSymbol?: string
} & Omit<React.HTMLProps<HTMLInputElement>, 'ref' | 'onChange' | 'as'>) { }
const enforcer = (nextUserInput: string) => {
if (nextUserInput === '' || inputRegex.test(escapeRegExp(nextUserInput))) { const Input = forwardRef<HTMLInputElement, InputProps>(
onUserInput(nextUserInput) ({ value, onUserInput, placeholder, prependSymbol, ...rest }: InputProps, ref) => {
const enforcer = (nextUserInput: string) => {
if (nextUserInput === '' || inputRegex.test(escapeRegExp(nextUserInput))) {
onUserInput(nextUserInput)
}
} }
}
return ( return (
<StyledInput <StyledInput
{...rest} {...rest}
value={prependSymbol && value ? prependSymbol + value : value} ref={ref}
onChange={(event) => { value={prependSymbol && value ? prependSymbol + value : value}
if (prependSymbol) { onChange={(event) => {
const value = event.target.value if (prependSymbol) {
const value = event.target.value
// cut off prepended symbol // cut off prepended symbol
const formattedValue = value.toString().includes(prependSymbol) const formattedValue = value.toString().includes(prependSymbol)
? value.toString().slice(1, value.toString().length + 1) ? value.toString().slice(1, value.toString().length + 1)
: value : value
// replace commas with periods, because uniswap exclusively uses period as the decimal separator // replace commas with periods, because uniswap exclusively uses period as the decimal separator
enforcer(formattedValue.replace(/,/g, '.')) enforcer(formattedValue.replace(/,/g, '.'))
} else { } else {
enforcer(event.target.value.replace(/,/g, '.')) enforcer(event.target.value.replace(/,/g, '.'))
} }
}} }}
// universal input options // universal input options
inputMode="decimal" inputMode="decimal"
autoComplete="off" autoComplete="off"
autoCorrect="off" autoCorrect="off"
// text-specific options // text-specific options
type="text" type="text"
pattern="^[0-9]*[.,]?[0-9]*$" pattern="^[0-9]*[.,]?[0-9]*$"
placeholder={placeholder || '0'} placeholder={placeholder || '0'}
minLength={1} minLength={1}
maxLength={79} maxLength={79}
spellCheck="false" spellCheck="false"
/> />
) )
}) }
)
export default Input Input.displayName = 'Input'
const MemoizedInput = React.memo(Input)
export { MemoizedInput as Input }
// const inputRegex = RegExp(`^\\d*(?:\\\\[.])?\\d*$`) // match escaped "." characters via in a non-capturing group // const inputRegex = RegExp(`^\\d*(?:\\\\[.])?\\d*$`) // match escaped "." characters via in a non-capturing group
import { AutoColumn } from 'components/Column' import { AutoColumn } from 'components/Column'
import CurrencyInputPanel from 'components/CurrencyInputPanel' import CurrencyInputPanel from 'components/CurrencyInputPanel'
import Input from 'components/NumericalInput' import { Input } from 'components/NumericalInput'
import styled from 'styled-components' import styled from 'styled-components'
export const Wrapper = styled.div` export const Wrapper = styled.div`
......
import { Trans } from '@lingui/macro'
import styled from 'styled-components'
import { ThemedText } from 'theme'
const Divider = styled.div`
width: 100%;
height: 1px;
border-width: 0;
margin: 12px 0;
background-color: ${({ theme }) => theme.surface3};
`
export function OutputTaxTooltipBody({ currencySymbol }: { currencySymbol?: string }) {
return (
<>
<ThemedText.SubHeaderSmall color="textPrimary">
<Trans>Exact input only</Trans>
</ThemedText.SubHeaderSmall>
<Divider />
<ThemedText.LabelMicro color="textPrimary">
{currencySymbol ? (
<Trans>
{currencySymbol} fees don&apos;t allow for accurate exact outputs. Use the `You pay` field instead.
</Trans>
) : (
<Trans>
Fees on the selected output token don&apos;t allow for accurate exact outputs. Use the `You pay` field
instead.
</Trans>
)}
</ThemedText.LabelMicro>
</>
)
}
...@@ -31,6 +31,7 @@ import { SwitchLocaleLink } from 'components/SwitchLocaleLink' ...@@ -31,6 +31,7 @@ import { SwitchLocaleLink } from 'components/SwitchLocaleLink'
import TokenSafetyModal from 'components/TokenSafety/TokenSafetyModal' import TokenSafetyModal from 'components/TokenSafety/TokenSafetyModal'
import { getChainInfo } from 'constants/chainInfo' import { getChainInfo } from 'constants/chainInfo'
import { asSupportedChain, isSupportedChain } from 'constants/chains' import { asSupportedChain, isSupportedChain } from 'constants/chains'
import { getInputTax, getOutputTax } from 'constants/tax'
import { getSwapCurrencyId, TOKEN_SHORTHANDS } from 'constants/tokens' import { getSwapCurrencyId, TOKEN_SHORTHANDS } from 'constants/tokens'
import { useCurrency, useDefaultActiveTokens } from 'hooks/Tokens' import { useCurrency, useDefaultActiveTokens } from 'hooks/Tokens'
import { useIsSwapUnsupported } from 'hooks/useIsSwapUnsupported' import { useIsSwapUnsupported } from 'hooks/useIsSwapUnsupported'
...@@ -43,14 +44,14 @@ import { useUSDPrice } from 'hooks/useUSDPrice' ...@@ -43,14 +44,14 @@ import { useUSDPrice } from 'hooks/useUSDPrice'
import useWrapCallback, { WrapErrorText, WrapType } from 'hooks/useWrapCallback' import useWrapCallback, { WrapErrorText, WrapType } from 'hooks/useWrapCallback'
import JSBI from 'jsbi' import JSBI from 'jsbi'
import { formatSwapQuoteReceivedEventProperties } from 'lib/utils/analytics' import { formatSwapQuoteReceivedEventProperties } from 'lib/utils/analytics'
import { ReactNode, useCallback, useEffect, useMemo, useReducer, useState } from 'react' import { ReactNode, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import { ArrowDown } from 'react-feather' import { ArrowDown } from 'react-feather'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { Text } from 'rebass' import { Text } from 'rebass'
import { useAppSelector } from 'state/hooks' import { useAppSelector } from 'state/hooks'
import { InterfaceTrade, TradeState } from 'state/routing/types' import { InterfaceTrade, TradeState } from 'state/routing/types'
import { isClassicTrade, isUniswapXTrade } from 'state/routing/utils' import { isClassicTrade, isUniswapXTrade } from 'state/routing/utils'
import { Field, replaceSwapState } from 'state/swap/actions' import { Field, forceExactInput, replaceSwapState } from 'state/swap/actions'
import { useDefaultsFromURLSearch, useDerivedSwapInfo, useSwapActionHandlers } from 'state/swap/hooks' import { useDefaultsFromURLSearch, useDerivedSwapInfo, useSwapActionHandlers } from 'state/swap/hooks'
import swapReducer, { initialState as initialSwapState, SwapState } from 'state/swap/reducer' import swapReducer, { initialState as initialSwapState, SwapState } from 'state/swap/reducer'
import styled, { useTheme } from 'styled-components' import styled, { useTheme } from 'styled-components'
...@@ -64,6 +65,7 @@ import { didUserReject } from 'utils/swapErrorToUserReadableMessage' ...@@ -64,6 +65,7 @@ import { didUserReject } from 'utils/swapErrorToUserReadableMessage'
import { useScreenSize } from '../../hooks/useScreenSize' import { useScreenSize } from '../../hooks/useScreenSize'
import { useIsDarkMode } from '../../theme/components/ThemeToggle' import { useIsDarkMode } from '../../theme/components/ThemeToggle'
import { OutputTaxTooltipBody } from './TaxTooltipBody'
import { UniswapXOptIn } from './UniswapXOptIn' import { UniswapXOptIn } from './UniswapXOptIn'
export const ArrowContainer = styled.div` export const ArrowContainer = styled.div`
...@@ -279,6 +281,19 @@ export function Swap({ ...@@ -279,6 +281,19 @@ export function Swap({
inputError: swapInputError, inputError: swapInputError,
} = swapInfo } = swapInfo
const [inputTokenHasTax, outputTokenHasTax] = useMemo(
() => [
!!currencies[Field.INPUT] && !getInputTax(currencies[Field.INPUT]).equalTo(0),
!!currencies[Field.OUTPUT] && !getOutputTax(currencies[Field.OUTPUT]).equalTo(0),
],
[currencies]
)
useEffect(() => {
// Force exact input if the user switches to an output token with tax
if (outputTokenHasTax && independentField === Field.OUTPUT) dispatch(forceExactInput())
}, [independentField, outputTokenHasTax, trade?.outputAmount])
const { const {
wrapType, wrapType,
execute: onWrap, execute: onWrap,
...@@ -510,6 +525,7 @@ export function Swap({ ...@@ -510,6 +525,7 @@ export function Swap({
}, },
[onCurrencyChange, onCurrencySelection, state, trace] [onCurrencyChange, onCurrencySelection, state, trace]
) )
const inputCurrencyNumericalInputRef = useRef<HTMLInputElement>(null)
const handleMaxInput = useCallback(() => { const handleMaxInput = useCallback(() => {
maxInputAmount && onUserInput(Field.INPUT, maxInputAmount.toExact()) maxInputAmount && onUserInput(Field.INPUT, maxInputAmount.toExact())
...@@ -608,6 +624,7 @@ export function Swap({ ...@@ -608,6 +624,7 @@ export function Swap({
showCommonBases showCommonBases
id={InterfaceSectionName.CURRENCY_INPUT_PANEL} id={InterfaceSectionName.CURRENCY_INPUT_PANEL}
loading={independentField === Field.OUTPUT && routeIsSyncing} loading={independentField === Field.OUTPUT && routeIsSyncing}
ref={inputCurrencyNumericalInputRef}
/> />
</Trace> </Trace>
</SwapSection> </SwapSection>
...@@ -621,7 +638,7 @@ export function Swap({ ...@@ -621,7 +638,7 @@ export function Swap({
data-testid="swap-currency-button" data-testid="swap-currency-button"
onClick={() => { onClick={() => {
if (disableTokenInputs) return if (disableTokenInputs) return
onSwitchTokens() onSwitchTokens(inputTokenHasTax, formattedAmounts[dependentField])
maybeLogFirstSwapAction(trace) maybeLogFirstSwapAction(trace)
}} }}
color={theme.neutral1} color={theme.neutral1}
...@@ -650,6 +667,13 @@ export function Swap({ ...@@ -650,6 +667,13 @@ export function Swap({
showCommonBases showCommonBases
id={InterfaceSectionName.CURRENCY_OUTPUT_PANEL} id={InterfaceSectionName.CURRENCY_OUTPUT_PANEL}
loading={independentField === Field.INPUT && routeIsSyncing} loading={independentField === Field.INPUT && routeIsSyncing}
numericalInputSettings={{
// We disable numerical input here if the selected token has tax, since we cannot guarantee exact_outputs for FOT tokens
disabled: outputTokenHasTax,
// Focus the input currency panel if the user tries to type into the disabled output currency panel
onDisabledClick: () => inputCurrencyNumericalInputRef.current?.focus(),
disabledTooltipBody: <OutputTaxTooltipBody currencySymbol={currencies[Field.OUTPUT]?.symbol} />,
}}
/> />
</Trace> </Trace>
{recipient !== null && !showWrap ? ( {recipient !== null && !showWrap ? (
......
...@@ -6,7 +6,10 @@ export enum Field { ...@@ -6,7 +6,10 @@ export enum Field {
} }
export const selectCurrency = createAction<{ field: Field; currencyId: string }>('swap/selectCurrency') export const selectCurrency = createAction<{ field: Field; currencyId: string }>('swap/selectCurrency')
export const switchCurrencies = createAction<void>('swap/switchCurrencies') export const switchCurrencies = createAction<{ newOutputHasTax: boolean; previouslyEstimatedOutput: string }>(
'swap/switchCurrencies'
)
export const forceExactInput = createAction<void>('swap/forceExactInput')
export const typeInput = createAction<{ field: Field; typedValue: string }>('swap/typeInput') export const typeInput = createAction<{ field: Field; typedValue: string }>('swap/typeInput')
export const replaceSwapState = createAction<{ export const replaceSwapState = createAction<{
field: Field field: Field
......
...@@ -23,7 +23,7 @@ import { SwapState } from './reducer' ...@@ -23,7 +23,7 @@ import { SwapState } from './reducer'
export function useSwapActionHandlers(dispatch: React.Dispatch<AnyAction>): { export function useSwapActionHandlers(dispatch: React.Dispatch<AnyAction>): {
onCurrencySelection: (field: Field, currency: Currency) => void onCurrencySelection: (field: Field, currency: Currency) => void
onSwitchTokens: () => void onSwitchTokens: (newOutputHasTax: boolean, previouslyEstimatedOutput: string) => void
onUserInput: (field: Field, typedValue: string) => void onUserInput: (field: Field, typedValue: string) => void
onChangeRecipient: (recipient: string | null) => void onChangeRecipient: (recipient: string | null) => void
} { } {
...@@ -39,9 +39,12 @@ export function useSwapActionHandlers(dispatch: React.Dispatch<AnyAction>): { ...@@ -39,9 +39,12 @@ export function useSwapActionHandlers(dispatch: React.Dispatch<AnyAction>): {
[dispatch] [dispatch]
) )
const onSwitchTokens = useCallback(() => { const onSwitchTokens = useCallback(
dispatch(switchCurrencies()) (newOutputHasTax: boolean, previouslyEstimatedOutput: string) => {
}, [dispatch]) dispatch(switchCurrencies({ newOutputHasTax, previouslyEstimatedOutput }))
},
[dispatch]
)
const onUserInput = useCallback( const onUserInput = useCallback(
(field: Field, typedValue: string) => { (field: Field, typedValue: string) => {
......
import { createReducer } from '@reduxjs/toolkit' import { createReducer } from '@reduxjs/toolkit'
import { parsedQueryString } from 'hooks/useParsedQueryString' import { parsedQueryString } from 'hooks/useParsedQueryString'
import { Field, replaceSwapState, selectCurrency, setRecipient, switchCurrencies, typeInput } from './actions' import {
Field,
forceExactInput,
replaceSwapState,
selectCurrency,
setRecipient,
switchCurrencies,
typeInput,
} from './actions'
import { queryParametersToSwapState } from './hooks' import { queryParametersToSwapState } from './hooks'
export interface SwapState { export interface SwapState {
...@@ -55,7 +63,17 @@ export default createReducer<SwapState>(initialState, (builder) => ...@@ -55,7 +63,17 @@ export default createReducer<SwapState>(initialState, (builder) =>
} }
} }
}) })
.addCase(switchCurrencies, (state) => { .addCase(switchCurrencies, (state, { payload: { newOutputHasTax, previouslyEstimatedOutput } }) => {
if (newOutputHasTax && state.independentField === Field.INPUT) {
// To prevent swaps with FOT tokens as exact-outputs, we leave it as an exact-in swap and use the previously estimated output amount as the new exact-in amount.
return {
...state,
[Field.INPUT]: { currencyId: state[Field.OUTPUT].currencyId },
[Field.OUTPUT]: { currencyId: state[Field.INPUT].currencyId },
typedValue: previouslyEstimatedOutput,
}
}
return { return {
...state, ...state,
independentField: state.independentField === Field.INPUT ? Field.OUTPUT : Field.INPUT, independentField: state.independentField === Field.INPUT ? Field.OUTPUT : Field.INPUT,
...@@ -63,6 +81,13 @@ export default createReducer<SwapState>(initialState, (builder) => ...@@ -63,6 +81,13 @@ export default createReducer<SwapState>(initialState, (builder) =>
[Field.OUTPUT]: { currencyId: state[Field.INPUT].currencyId }, [Field.OUTPUT]: { currencyId: state[Field.INPUT].currencyId },
} }
}) })
.addCase(forceExactInput, (state) => {
return {
...state,
independentField: Field.INPUT,
typedValue: '',
}
})
.addCase(typeInput, (state, { payload: { field, typedValue } }) => { .addCase(typeInput, (state, { payload: { field, typedValue } }) => {
return { return {
...state, ...state,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment