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
......@@ -8,9 +8,11 @@ import PrefetchBalancesWrapper from 'components/AccountDrawer/PrefetchBalancesWr
import { AutoColumn } from 'components/Column'
import { LoadingOpacityContainer, loadingOpacityMixin } from 'components/Loader/styled'
import CurrencyLogo from 'components/Logo/CurrencyLogo'
import Tooltip from 'components/Tooltip'
import { isSupportedChain } from 'constants/chains'
import ms from 'ms'
import { darken } from 'polished'
import { ReactNode, useCallback, useState } from 'react'
import { forwardRef, ReactNode, useCallback, useEffect, useState } from 'react'
import { Lock } from 'react-feather'
import styled, { useTheme } from 'styled-components'
import { flexColumnNoWrap, flexRowNoWrap } from 'theme/styles'
......@@ -58,6 +60,7 @@ const CurrencySelect = styled(ButtonGray)<{
selected: boolean
hideInput?: boolean
disabled?: boolean
animateShake?: boolean
}>`
align-items: center;
background-color: ${({ selected, theme }) => (selected ? theme.surface1 : theme.accent1)};
......@@ -105,6 +108,34 @@ const CurrencySelect = styled(ButtonGray)<{
}
visibility: ${({ visible }) => (visible ? 'visible' : 'hidden')};
@keyframes horizontal-shaking {
0% {
transform: translateX(0);
animation-timing-function: ease-in-out;
}
20% {
transform: translateX(10px);
animation-timing-function: ease-in-out;
}
40% {
transform: translateX(-10px);
animation-timing-function: ease-in-out;
}
60% {
transform: translateX(10px);
animation-timing-function: ease-in-out;
}
80% {
transform: translateX(-10px);
animation-timing-function: ease-in-out;
}
100% {
transform: translateX(0);
animation-timing-function: ease-in-out;
}
}
animation: ${({ animateShake }) => (animateShake ? 'horizontal-shaking 300ms' : 'none')};
`
const InputRow = styled.div`
......@@ -206,9 +237,16 @@ interface SwapCurrencyInputPanelProps {
locked?: boolean
loading?: boolean
disabled?: boolean
numericalInputSettings?: {
disabled?: boolean
onDisabledClick?: () => void
disabledTooltipBody?: ReactNode
}
}
export default function SwapCurrencyInputPanel({
const SwapCurrencyInputPanel = forwardRef<HTMLInputElement, SwapCurrencyInputPanelProps>(
(
{
value,
onUserInput,
onMax,
......@@ -229,9 +267,12 @@ export default function SwapCurrencyInputPanel({
locked = false,
loading = false,
disabled = false,
numericalInputSettings,
label,
...rest
}: SwapCurrencyInputPanelProps) {
},
ref
) => {
const [modalOpen, setModalOpen] = useState(false)
const { account, chainId } = useWeb3React()
const selectedCurrencyBalance = useCurrencyBalance(account ?? undefined, currency ?? undefined)
......@@ -241,8 +282,20 @@ export default function SwapCurrencyInputPanel({
setModalOpen(false)
}, [setModalOpen])
const [tooltipVisible, setTooltipVisible] = useState(false)
const handleDisabledNumericalInputClick = useCallback(() => {
if (numericalInputSettings?.disabled && !tooltipVisible) {
setTooltipVisible(true)
setTimeout(() => setTooltipVisible(false), ms('4s')) // reset shake animation state after 4s
numericalInputSettings.onDisabledClick?.()
}
}, [tooltipVisible, numericalInputSettings])
const chainAllowed = isSupportedChain(chainId)
// reset tooltip state when currency changes
useEffect(() => setTooltipVisible(false), [currency])
return (
<InputPanel id={id} hideInput={hideInput} {...rest}>
{locked && (
......@@ -255,19 +308,30 @@ export default function SwapCurrencyInputPanel({
</AutoColumn>
</FixedContainer>
)}
<Container hideInput={hideInput}>
<ThemedText.SubHeaderSmall style={{ userSelect: 'none' }}>{label}</ThemedText.SubHeaderSmall>
<InputRow style={hideInput ? { padding: '0', borderRadius: '8px' } : {}}>
{!hideInput && (
<div style={{ display: 'flex', flexGrow: '1' }} onClick={handleDisabledNumericalInputClick}>
<StyledNumericalInput
className="token-amount-input"
value={value}
onUserInput={onUserInput}
disabled={!chainAllowed || disabled}
disabled={!chainAllowed || disabled || numericalInputSettings?.disabled}
$loading={loading}
id={id}
ref={ref}
/>
</div>
)}
<PrefetchBalancesWrapper shouldFetchOnAccountUpdate={modalOpen}>
<Tooltip
show={tooltipVisible && !modalOpen}
placement="bottom"
offsetY={14}
text={numericalInputSettings?.disabledTooltipBody}
>
<CurrencySelect
disabled={!chainAllowed || disabled}
visible={currency !== undefined}
......@@ -279,6 +343,7 @@ export default function SwapCurrencyInputPanel({
setModalOpen(true)
}
}}
animateShake={tooltipVisible}
>
<Aligner>
<RowFixed>
......@@ -294,7 +359,10 @@ export default function SwapCurrencyInputPanel({
{pair?.token0.symbol}:{pair?.token1.symbol}
</StyledTokenName>
) : (
<StyledTokenName className="token-symbol-container" active={Boolean(currency && currency.symbol)}>
<StyledTokenName
className="token-symbol-container"
active={Boolean(currency && currency.symbol)}
>
{(currency && currency.symbol && currency.symbol.length > 20
? currency.symbol.slice(0, 4) +
'...' +
......@@ -306,6 +374,7 @@ export default function SwapCurrencyInputPanel({
{onCurrencySelect && <StyledDropDown selected={!!currency} />}
</Aligner>
</CurrencySelect>
</Tooltip>
</PrefetchBalancesWrapper>
</InputRow>
{Boolean(!hideInput && !hideBalance) && (
......@@ -364,4 +433,8 @@ export default function SwapCurrencyInputPanel({
)}
</InputPanel>
)
}
}
)
SwapCurrencyInputPanel.displayName = 'SwapCurrencyInputPanel'
export default SwapCurrencyInputPanel
import React from 'react'
import React, { forwardRef } from 'react'
import styled from 'styled-components'
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)};
pointer-events: ${({ disabled }) => (disabled ? 'none' : 'auto')};
width: 0;
position: relative;
font-weight: 485;
......@@ -40,20 +41,17 @@ const StyledInput = styled.input<{ error?: boolean; fontSize?: string; align?: s
const inputRegex = RegExp(`^\\d*(?:\\\\[.])?\\d*$`) // match escaped "." characters via in a non-capturing group
export const Input = React.memo(function InnerInput({
value,
onUserInput,
placeholder,
prependSymbol,
...rest
}: {
interface InputProps extends Omit<React.HTMLProps<HTMLInputElement>, 'ref' | 'onChange' | 'as'> {
value: string | number
onUserInput: (input: string) => void
error?: boolean
fontSize?: string
align?: 'right' | 'left'
prependSymbol?: string
} & Omit<React.HTMLProps<HTMLInputElement>, 'ref' | 'onChange' | 'as'>) {
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ value, onUserInput, placeholder, prependSymbol, ...rest }: InputProps, ref) => {
const enforcer = (nextUserInput: string) => {
if (nextUserInput === '' || inputRegex.test(escapeRegExp(nextUserInput))) {
onUserInput(nextUserInput)
......@@ -63,6 +61,7 @@ export const Input = React.memo(function InnerInput({
return (
<StyledInput
{...rest}
ref={ref}
value={prependSymbol && value ? prependSymbol + value : value}
onChange={(event) => {
if (prependSymbol) {
......@@ -92,8 +91,11 @@ export const Input = React.memo(function InnerInput({
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
import { AutoColumn } from 'components/Column'
import CurrencyInputPanel from 'components/CurrencyInputPanel'
import Input from 'components/NumericalInput'
import { Input } from 'components/NumericalInput'
import styled from 'styled-components'
export const Wrapper = styled.div`
......
......@@ -765,6 +765,7 @@ exports[`disable nft on landing page does not render nft information and card 1`
.c27 {
color: #222222;
pointer-events: auto;
width: 0;
position: relative;
font-weight: 485;
......@@ -864,6 +865,8 @@ exports[`disable nft on landing page does not render nft information and card 1`
margin-left: 12px;
box-shadow: 0px 0px 10px 0px rgba(34,34,34,0.04);
visibility: visible;
-webkit-animation: none;
animation: none;
}
.c30:hover,
......@@ -919,6 +922,8 @@ exports[`disable nft on landing page does not render nft information and card 1`
margin-left: 12px;
box-shadow: 0px 0px 10px 0px rgba(34,34,34,0.04);
visibility: visible;
-webkit-animation: none;
animation: none;
}
.c44:hover,
......@@ -1825,11 +1830,15 @@ exports[`disable nft on landing page does not render nft information and card 1`
</div>
<div
class="c26"
>
<div
style="display: flex; flex-grow: 1;"
>
<input
autocomplete="off"
autocorrect="off"
class="c27 c28 token-amount-input"
id="swap-currency-input"
inputmode="decimal"
maxlength="79"
minlength="1"
......@@ -1839,7 +1848,11 @@ exports[`disable nft on landing page does not render nft information and card 1`
type="text"
value=""
/>
</div>
<div>
<div
class="c13"
>
<button
class="c14 c15 c29 c30 open-currency-select-button"
>
......@@ -1878,6 +1891,7 @@ exports[`disable nft on landing page does not render nft information and card 1`
</button>
</div>
</div>
</div>
<div
class="c37 c38"
>
......@@ -1947,11 +1961,15 @@ exports[`disable nft on landing page does not render nft information and card 1`
</div>
<div
class="c26"
>
<div
style="display: flex; flex-grow: 1;"
>
<input
autocomplete="off"
autocorrect="off"
class="c27 c28 token-amount-input"
id="swap-currency-output"
inputmode="decimal"
maxlength="79"
minlength="1"
......@@ -1961,7 +1979,11 @@ exports[`disable nft on landing page does not render nft information and card 1`
type="text"
value=""
/>
</div>
<div>
<div
class="c13"
>
<button
class="c14 c15 c29 c44 open-currency-select-button"
>
......@@ -1986,6 +2008,7 @@ exports[`disable nft on landing page does not render nft information and card 1`
</button>
</div>
</div>
</div>
<div
class="c37 c38"
>
......@@ -3345,6 +3368,7 @@ exports[`disable nft on landing page renders nft information and card 1`] = `
.c27 {
color: #222222;
pointer-events: auto;
width: 0;
position: relative;
font-weight: 485;
......@@ -3444,6 +3468,8 @@ exports[`disable nft on landing page renders nft information and card 1`] = `
margin-left: 12px;
box-shadow: 0px 0px 10px 0px rgba(34,34,34,0.04);
visibility: visible;
-webkit-animation: none;
animation: none;
}
.c30:hover,
......@@ -3499,6 +3525,8 @@ exports[`disable nft on landing page renders nft information and card 1`] = `
margin-left: 12px;
box-shadow: 0px 0px 10px 0px rgba(34,34,34,0.04);
visibility: visible;
-webkit-animation: none;
animation: none;
}
.c44:hover,
......@@ -4417,11 +4445,15 @@ exports[`disable nft on landing page renders nft information and card 1`] = `
</div>
<div
class="c26"
>
<div
style="display: flex; flex-grow: 1;"
>
<input
autocomplete="off"
autocorrect="off"
class="c27 c28 token-amount-input"
id="swap-currency-input"
inputmode="decimal"
maxlength="79"
minlength="1"
......@@ -4431,7 +4463,11 @@ exports[`disable nft on landing page renders nft information and card 1`] = `
type="text"
value=""
/>
</div>
<div>
<div
class="c13"
>
<button
class="c14 c15 c29 c30 open-currency-select-button"
>
......@@ -4470,6 +4506,7 @@ exports[`disable nft on landing page renders nft information and card 1`] = `
</button>
</div>
</div>
</div>
<div
class="c37 c38"
>
......@@ -4539,11 +4576,15 @@ exports[`disable nft on landing page renders nft information and card 1`] = `
</div>
<div
class="c26"
>
<div
style="display: flex; flex-grow: 1;"
>
<input
autocomplete="off"
autocorrect="off"
class="c27 c28 token-amount-input"
id="swap-currency-output"
inputmode="decimal"
maxlength="79"
minlength="1"
......@@ -4553,7 +4594,11 @@ exports[`disable nft on landing page renders nft information and card 1`] = `
type="text"
value=""
/>
</div>
<div>
<div
class="c13"
>
<button
class="c14 c15 c29 c44 open-currency-select-button"
>
......@@ -4578,6 +4623,7 @@ exports[`disable nft on landing page renders nft information and card 1`] = `
</button>
</div>
</div>
</div>
<div
class="c37 c38"
>
......
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'
import TokenSafetyModal from 'components/TokenSafety/TokenSafetyModal'
import { getChainInfo } from 'constants/chainInfo'
import { asSupportedChain, isSupportedChain } from 'constants/chains'
import { getInputTax, getOutputTax } from 'constants/tax'
import { getSwapCurrencyId, TOKEN_SHORTHANDS } from 'constants/tokens'
import { useCurrency, useDefaultActiveTokens } from 'hooks/Tokens'
import { useIsSwapUnsupported } from 'hooks/useIsSwapUnsupported'
......@@ -43,14 +44,14 @@ import { useUSDPrice } from 'hooks/useUSDPrice'
import useWrapCallback, { WrapErrorText, WrapType } from 'hooks/useWrapCallback'
import JSBI from 'jsbi'
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 { useLocation, useNavigate } from 'react-router-dom'
import { Text } from 'rebass'
import { useAppSelector } from 'state/hooks'
import { InterfaceTrade, TradeState } from 'state/routing/types'
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 swapReducer, { initialState as initialSwapState, SwapState } from 'state/swap/reducer'
import styled, { useTheme } from 'styled-components'
......@@ -64,6 +65,7 @@ import { didUserReject } from 'utils/swapErrorToUserReadableMessage'
import { useScreenSize } from '../../hooks/useScreenSize'
import { useIsDarkMode } from '../../theme/components/ThemeToggle'
import { OutputTaxTooltipBody } from './TaxTooltipBody'
import { UniswapXOptIn } from './UniswapXOptIn'
export const ArrowContainer = styled.div`
......@@ -279,6 +281,19 @@ export function Swap({
inputError: swapInputError,
} = 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 {
wrapType,
execute: onWrap,
......@@ -510,6 +525,7 @@ export function Swap({
},
[onCurrencyChange, onCurrencySelection, state, trace]
)
const inputCurrencyNumericalInputRef = useRef<HTMLInputElement>(null)
const handleMaxInput = useCallback(() => {
maxInputAmount && onUserInput(Field.INPUT, maxInputAmount.toExact())
......@@ -608,6 +624,7 @@ export function Swap({
showCommonBases
id={InterfaceSectionName.CURRENCY_INPUT_PANEL}
loading={independentField === Field.OUTPUT && routeIsSyncing}
ref={inputCurrencyNumericalInputRef}
/>
</Trace>
</SwapSection>
......@@ -621,7 +638,7 @@ export function Swap({
data-testid="swap-currency-button"
onClick={() => {
if (disableTokenInputs) return
onSwitchTokens()
onSwitchTokens(inputTokenHasTax, formattedAmounts[dependentField])
maybeLogFirstSwapAction(trace)
}}
color={theme.neutral1}
......@@ -650,6 +667,13 @@ export function Swap({
showCommonBases
id={InterfaceSectionName.CURRENCY_OUTPUT_PANEL}
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>
{recipient !== null && !showWrap ? (
......
......@@ -6,7 +6,10 @@ export enum Field {
}
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 replaceSwapState = createAction<{
field: Field
......
......@@ -23,7 +23,7 @@ import { SwapState } from './reducer'
export function useSwapActionHandlers(dispatch: React.Dispatch<AnyAction>): {
onCurrencySelection: (field: Field, currency: Currency) => void
onSwitchTokens: () => void
onSwitchTokens: (newOutputHasTax: boolean, previouslyEstimatedOutput: string) => void
onUserInput: (field: Field, typedValue: string) => void
onChangeRecipient: (recipient: string | null) => void
} {
......@@ -39,9 +39,12 @@ export function useSwapActionHandlers(dispatch: React.Dispatch<AnyAction>): {
[dispatch]
)
const onSwitchTokens = useCallback(() => {
dispatch(switchCurrencies())
}, [dispatch])
const onSwitchTokens = useCallback(
(newOutputHasTax: boolean, previouslyEstimatedOutput: string) => {
dispatch(switchCurrencies({ newOutputHasTax, previouslyEstimatedOutput }))
},
[dispatch]
)
const onUserInput = useCallback(
(field: Field, typedValue: string) => {
......
import { createReducer } from '@reduxjs/toolkit'
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'
export interface SwapState {
......@@ -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 {
...state,
independentField: state.independentField === Field.INPUT ? Field.OUTPUT : Field.INPUT,
......@@ -63,6 +81,13 @@ export default createReducer<SwapState>(initialState, (builder) =>
[Field.OUTPUT]: { currencyId: state[Field.INPUT].currencyId },
}
})
.addCase(forceExactInput, (state) => {
return {
...state,
independentField: Field.INPUT,
typedValue: '',
}
})
.addCase(typeInput, (state, { payload: { field, typedValue } }) => {
return {
...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