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 ...@@ -8,9 +8,11 @@ import PrefetchBalancesWrapper from 'components/AccountDrawer/PrefetchBalancesWr
import { AutoColumn } from 'components/Column' import { AutoColumn } from 'components/Column'
import { LoadingOpacityContainer, loadingOpacityMixin } from 'components/Loader/styled' import { LoadingOpacityContainer, loadingOpacityMixin } from 'components/Loader/styled'
import CurrencyLogo from 'components/Logo/CurrencyLogo' import CurrencyLogo from 'components/Logo/CurrencyLogo'
import Tooltip from 'components/Tooltip'
import { isSupportedChain } from 'constants/chains' import { isSupportedChain } from 'constants/chains'
import ms from 'ms'
import { darken } from 'polished' import { darken } from 'polished'
import { ReactNode, useCallback, useState } from 'react' import { forwardRef, ReactNode, useCallback, useEffect, useState } from 'react'
import { Lock } from 'react-feather' import { Lock } from 'react-feather'
import styled, { useTheme } from 'styled-components' import styled, { useTheme } from 'styled-components'
import { flexColumnNoWrap, flexRowNoWrap } from 'theme/styles' import { flexColumnNoWrap, flexRowNoWrap } from 'theme/styles'
...@@ -58,6 +60,7 @@ const CurrencySelect = styled(ButtonGray)<{ ...@@ -58,6 +60,7 @@ const CurrencySelect = styled(ButtonGray)<{
selected: boolean selected: boolean
hideInput?: boolean hideInput?: boolean
disabled?: boolean disabled?: boolean
animateShake?: boolean
}>` }>`
align-items: center; align-items: center;
background-color: ${({ selected, theme }) => (selected ? theme.surface1 : theme.accent1)}; background-color: ${({ selected, theme }) => (selected ? theme.surface1 : theme.accent1)};
...@@ -105,6 +108,34 @@ const CurrencySelect = styled(ButtonGray)<{ ...@@ -105,6 +108,34 @@ const CurrencySelect = styled(ButtonGray)<{
} }
visibility: ${({ visible }) => (visible ? 'visible' : 'hidden')}; 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` const InputRow = styled.div`
...@@ -206,162 +237,204 @@ interface SwapCurrencyInputPanelProps { ...@@ -206,162 +237,204 @@ interface SwapCurrencyInputPanelProps {
locked?: boolean locked?: boolean
loading?: boolean loading?: boolean
disabled?: boolean disabled?: boolean
numericalInputSettings?: {
disabled?: boolean
onDisabledClick?: () => void
disabledTooltipBody?: ReactNode
}
} }
export default function SwapCurrencyInputPanel({ const SwapCurrencyInputPanel = forwardRef<HTMLInputElement, SwapCurrencyInputPanelProps>(
value, (
onUserInput, {
onMax, value,
showMaxButton, onUserInput,
onCurrencySelect, onMax,
currency, showMaxButton,
otherCurrency, onCurrencySelect,
id, currency,
showCommonBases, otherCurrency,
showCurrencyAmount, id,
disableNonToken, showCommonBases,
renderBalance, showCurrencyAmount,
fiatValue, disableNonToken,
priceImpact, renderBalance,
hideBalance = false, fiatValue,
pair = null, // used for double token logo priceImpact,
hideInput = false, hideBalance = false,
locked = false, pair = null, // used for double token logo
loading = false, hideInput = false,
disabled = false, locked = false,
label, loading = false,
...rest disabled = false,
}: SwapCurrencyInputPanelProps) { numericalInputSettings,
const [modalOpen, setModalOpen] = useState(false) label,
const { account, chainId } = useWeb3React() ...rest
const selectedCurrencyBalance = useCurrencyBalance(account ?? undefined, currency ?? undefined) },
const theme = useTheme() ref
) => {
const handleDismissSearch = useCallback(() => { const [modalOpen, setModalOpen] = useState(false)
setModalOpen(false) const { account, chainId } = useWeb3React()
}, [setModalOpen]) const selectedCurrencyBalance = useCurrencyBalance(account ?? undefined, currency ?? undefined)
const theme = useTheme()
const chainAllowed = isSupportedChain(chainId)
const handleDismissSearch = useCallback(() => {
return ( setModalOpen(false)
<InputPanel id={id} hideInput={hideInput} {...rest}> }, [setModalOpen])
{locked && (
<FixedContainer> const [tooltipVisible, setTooltipVisible] = useState(false)
<AutoColumn gap="sm" justify="center"> const handleDisabledNumericalInputClick = useCallback(() => {
<Lock /> if (numericalInputSettings?.disabled && !tooltipVisible) {
<ThemedText.BodySecondary fontSize="12px" textAlign="center" padding="0 12px"> setTooltipVisible(true)
<Trans>The market price is outside your specified price range. Single-asset deposit only.</Trans> setTimeout(() => setTooltipVisible(false), ms('4s')) // reset shake animation state after 4s
</ThemedText.BodySecondary> numericalInputSettings.onDisabledClick?.()
</AutoColumn> }
</FixedContainer> }, [tooltipVisible, numericalInputSettings])
)}
<Container hideInput={hideInput}> const chainAllowed = isSupportedChain(chainId)
<ThemedText.SubHeaderSmall style={{ userSelect: 'none' }}>{label}</ThemedText.SubHeaderSmall>
<InputRow style={hideInput ? { padding: '0', borderRadius: '8px' } : {}}> // reset tooltip state when currency changes
{!hideInput && ( useEffect(() => setTooltipVisible(false), [currency])
<StyledNumericalInput
className="token-amount-input" return (
value={value} <InputPanel id={id} hideInput={hideInput} {...rest}>
onUserInput={onUserInput} {locked && (
disabled={!chainAllowed || disabled} <FixedContainer>
$loading={loading} <AutoColumn gap="sm" justify="center">
/> <Lock />
)} <ThemedText.BodySecondary fontSize="12px" textAlign="center" padding="0 12px">
<PrefetchBalancesWrapper shouldFetchOnAccountUpdate={modalOpen}> <Trans>The market price is outside your specified price range. Single-asset deposit only.</Trans>
<CurrencySelect </ThemedText.BodySecondary>
disabled={!chainAllowed || disabled} </AutoColumn>
visible={currency !== undefined} </FixedContainer>
selected={!!currency} )}
hideInput={hideInput}
className="open-currency-select-button" <Container hideInput={hideInput}>
onClick={() => { <ThemedText.SubHeaderSmall style={{ userSelect: 'none' }}>{label}</ThemedText.SubHeaderSmall>
if (onCurrencySelect) { <InputRow style={hideInput ? { padding: '0', borderRadius: '8px' } : {}}>
setModalOpen(true) {!hideInput && (
} <div style={{ display: 'flex', flexGrow: '1' }} onClick={handleDisabledNumericalInputClick}>
}} <StyledNumericalInput
> className="token-amount-input"
<Aligner> value={value}
<RowFixed> onUserInput={onUserInput}
{pair ? ( disabled={!chainAllowed || disabled || numericalInputSettings?.disabled}
<span style={{ marginRight: '0.5rem' }}> $loading={loading}
<DoubleCurrencyLogo currency0={pair.token0} currency1={pair.token1} size={24} margin={true} /> id={id}
</span> ref={ref}
) : currency ? ( />
<CurrencyLogo style={{ marginRight: '2px' }} currency={currency} size="24px" /> </div>
) : null} )}
{pair ? ( <PrefetchBalancesWrapper shouldFetchOnAccountUpdate={modalOpen}>
<StyledTokenName className="pair-name-container"> <Tooltip
{pair?.token0.symbol}:{pair?.token1.symbol} show={tooltipVisible && !modalOpen}
</StyledTokenName> placement="bottom"
) : ( offsetY={14}
<StyledTokenName className="token-symbol-container" active={Boolean(currency && currency.symbol)}> text={numericalInputSettings?.disabledTooltipBody}
{(currency && currency.symbol && currency.symbol.length > 20 >
? currency.symbol.slice(0, 4) + <CurrencySelect
'...' + disabled={!chainAllowed || disabled}
currency.symbol.slice(currency.symbol.length - 5, currency.symbol.length) visible={currency !== undefined}
: currency?.symbol) || <Trans>Select token</Trans>} selected={!!currency}
</StyledTokenName> hideInput={hideInput}
)} className="open-currency-select-button"
</RowFixed> onClick={() => {
{onCurrencySelect && <StyledDropDown selected={!!currency} />} if (onCurrencySelect) {
</Aligner> setModalOpen(true)
</CurrencySelect> }
</PrefetchBalancesWrapper> }}
</InputRow> animateShake={tooltipVisible}
{Boolean(!hideInput && !hideBalance) && ( >
<FiatRow> <Aligner>
<RowBetween> <RowFixed>
<LoadingOpacityContainer $loading={loading}> {pair ? (
{fiatValue && <FiatValue fiatValue={fiatValue} priceImpact={priceImpact} />} <span style={{ marginRight: '0.5rem' }}>
</LoadingOpacityContainer> <DoubleCurrencyLogo currency0={pair.token0} currency1={pair.token1} size={24} margin={true} />
{account ? ( </span>
<RowFixed style={{ height: '16px' }}> ) : currency ? (
<ThemedText.DeprecatedBody <CurrencyLogo style={{ marginRight: '2px' }} currency={currency} size="24px" />
data-testid="balance-text" ) : null}
color={theme.neutral2} {pair ? (
fontWeight={485} <StyledTokenName className="pair-name-container">
fontSize={14} {pair?.token0.symbol}:{pair?.token1.symbol}
style={{ display: 'inline' }} </StyledTokenName>
>
{!hideBalance && currency && selectedCurrencyBalance ? (
renderBalance ? (
renderBalance(selectedCurrencyBalance)
) : ( ) : (
<Trans>Balance: {formatCurrencyAmount(selectedCurrencyBalance, NumberType.TokenNonTx)}</Trans> <StyledTokenName
) className="token-symbol-container"
) : null} active={Boolean(currency && currency.symbol)}
</ThemedText.DeprecatedBody> >
{showMaxButton && selectedCurrencyBalance ? ( {(currency && currency.symbol && currency.symbol.length > 20
<TraceEvent ? currency.symbol.slice(0, 4) +
events={[BrowserEvent.onClick]} '...' +
name={SwapEventName.SWAP_MAX_TOKEN_AMOUNT_SELECTED} currency.symbol.slice(currency.symbol.length - 5, currency.symbol.length)
element={InterfaceElementName.MAX_TOKEN_AMOUNT_BUTTON} : currency?.symbol) || <Trans>Select token</Trans>}
</StyledTokenName>
)}
</RowFixed>
{onCurrencySelect && <StyledDropDown selected={!!currency} />}
</Aligner>
</CurrencySelect>
</Tooltip>
</PrefetchBalancesWrapper>
</InputRow>
{Boolean(!hideInput && !hideBalance) && (
<FiatRow>
<RowBetween>
<LoadingOpacityContainer $loading={loading}>
{fiatValue && <FiatValue fiatValue={fiatValue} priceImpact={priceImpact} />}
</LoadingOpacityContainer>
{account ? (
<RowFixed style={{ height: '16px' }}>
<ThemedText.DeprecatedBody
data-testid="balance-text"
color={theme.neutral2}
fontWeight={485}
fontSize={14}
style={{ display: 'inline' }}
> >
<StyledBalanceMax onClick={onMax}> {!hideBalance && currency && selectedCurrencyBalance ? (
<Trans>Max</Trans> renderBalance ? (
</StyledBalanceMax> renderBalance(selectedCurrencyBalance)
</TraceEvent> ) : (
) : null} <Trans>Balance: {formatCurrencyAmount(selectedCurrencyBalance, NumberType.TokenNonTx)}</Trans>
</RowFixed> )
) : ( ) : null}
<span /> </ThemedText.DeprecatedBody>
)} {showMaxButton && selectedCurrencyBalance ? (
</RowBetween> <TraceEvent
</FiatRow> events={[BrowserEvent.onClick]}
name={SwapEventName.SWAP_MAX_TOKEN_AMOUNT_SELECTED}
element={InterfaceElementName.MAX_TOKEN_AMOUNT_BUTTON}
>
<StyledBalanceMax onClick={onMax}>
<Trans>Max</Trans>
</StyledBalanceMax>
</TraceEvent>
) : null}
</RowFixed>
) : (
<span />
)}
</RowBetween>
</FiatRow>
)}
</Container>
{onCurrencySelect && (
<CurrencySearchModal
isOpen={modalOpen}
onDismiss={handleDismissSearch}
onCurrencySelect={onCurrencySelect}
selectedCurrency={currency}
otherSelectedCurrency={otherCurrency}
showCommonBases={showCommonBases}
showCurrencyAmount={showCurrencyAmount}
disableNonToken={disableNonToken}
/>
)} )}
</Container> </InputPanel>
{onCurrencySelect && ( )
<CurrencySearchModal }
isOpen={modalOpen} )
onDismiss={handleDismissSearch} SwapCurrencyInputPanel.displayName = 'SwapCurrencyInputPanel'
onCurrencySelect={onCurrencySelect}
selectedCurrency={currency} export default SwapCurrencyInputPanel
otherSelectedCurrency={otherCurrency}
showCommonBases={showCommonBases}
showCurrencyAmount={showCurrencyAmount}
disableNonToken={disableNonToken}
/>
)}
</InputPanel>
)
}
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`
......
...@@ -765,6 +765,7 @@ exports[`disable nft on landing page does not render nft information and card 1` ...@@ -765,6 +765,7 @@ exports[`disable nft on landing page does not render nft information and card 1`
.c27 { .c27 {
color: #222222; color: #222222;
pointer-events: auto;
width: 0; width: 0;
position: relative; position: relative;
font-weight: 485; font-weight: 485;
...@@ -864,6 +865,8 @@ exports[`disable nft on landing page does not render nft information and card 1` ...@@ -864,6 +865,8 @@ exports[`disable nft on landing page does not render nft information and card 1`
margin-left: 12px; margin-left: 12px;
box-shadow: 0px 0px 10px 0px rgba(34,34,34,0.04); box-shadow: 0px 0px 10px 0px rgba(34,34,34,0.04);
visibility: visible; visibility: visible;
-webkit-animation: none;
animation: none;
} }
.c30:hover, .c30:hover,
...@@ -919,6 +922,8 @@ exports[`disable nft on landing page does not render nft information and card 1` ...@@ -919,6 +922,8 @@ exports[`disable nft on landing page does not render nft information and card 1`
margin-left: 12px; margin-left: 12px;
box-shadow: 0px 0px 10px 0px rgba(34,34,34,0.04); box-shadow: 0px 0px 10px 0px rgba(34,34,34,0.04);
visibility: visible; visibility: visible;
-webkit-animation: none;
animation: none;
} }
.c44:hover, .c44:hover,
...@@ -1826,56 +1831,65 @@ exports[`disable nft on landing page does not render nft information and card 1` ...@@ -1826,56 +1831,65 @@ exports[`disable nft on landing page does not render nft information and card 1`
<div <div
class="c26" class="c26"
> >
<input <div
autocomplete="off" style="display: flex; flex-grow: 1;"
autocorrect="off" >
class="c27 c28 token-amount-input" <input
inputmode="decimal" autocomplete="off"
maxlength="79" autocorrect="off"
minlength="1" class="c27 c28 token-amount-input"
pattern="^[0-9]*[.,]?[0-9]*$" id="swap-currency-input"
placeholder="0" inputmode="decimal"
spellcheck="false" maxlength="79"
type="text" minlength="1"
value="" pattern="^[0-9]*[.,]?[0-9]*$"
/> placeholder="0"
spellcheck="false"
type="text"
value=""
/>
</div>
<div> <div>
<button <div
class="c14 c15 c29 c30 open-currency-select-button" class="c13"
> >
<span <button
class="c31" class="c14 c15 c29 c30 open-currency-select-button"
> >
<div <span
class="c6 c7 c10" class="c31"
> >
<div <div
class="c32" class="c6 c7 c10"
style="height: 24px; width: 24px; margin-right: 2px;"
> >
<div <div
class="c33" class="c32"
style="height: 24px; width: 24px; margin-right: 2px;"
> >
<img <div
alt="ETH logo" class="c33"
class="c34" >
src="ethereum-logo.png" <img
/> alt="ETH logo"
class="c34"
src="ethereum-logo.png"
/>
</div>
</div> </div>
<span
class="c35 token-symbol-container"
>
ETH
</span>
</div> </div>
<span <svg
class="c35 token-symbol-container" class="c36"
> >
ETH dropdown.svg
</span> </svg>
</div> </span>
<svg </button>
class="c36" </div>
>
dropdown.svg
</svg>
</span>
</button>
</div> </div>
</div> </div>
<div <div
...@@ -1948,42 +1962,51 @@ exports[`disable nft on landing page does not render nft information and card 1` ...@@ -1948,42 +1962,51 @@ exports[`disable nft on landing page does not render nft information and card 1`
<div <div
class="c26" class="c26"
> >
<input <div
autocomplete="off" style="display: flex; flex-grow: 1;"
autocorrect="off" >
class="c27 c28 token-amount-input" <input
inputmode="decimal" autocomplete="off"
maxlength="79" autocorrect="off"
minlength="1" class="c27 c28 token-amount-input"
pattern="^[0-9]*[.,]?[0-9]*$" id="swap-currency-output"
placeholder="0" inputmode="decimal"
spellcheck="false" maxlength="79"
type="text" minlength="1"
value="" pattern="^[0-9]*[.,]?[0-9]*$"
/> placeholder="0"
spellcheck="false"
type="text"
value=""
/>
</div>
<div> <div>
<button <div
class="c14 c15 c29 c44 open-currency-select-button" class="c13"
> >
<span <button
class="c31" class="c14 c15 c29 c44 open-currency-select-button"
> >
<div <span
class="c6 c7 c10" class="c31"
> >
<span <div
class="c35 token-symbol-container" class="c6 c7 c10"
> >
Select token <span
</span> class="c35 token-symbol-container"
</div> >
<svg Select token
class="c45" </span>
> </div>
dropdown.svg <svg
</svg> class="c45"
</span> >
</button> dropdown.svg
</svg>
</span>
</button>
</div>
</div> </div>
</div> </div>
<div <div
...@@ -3345,6 +3368,7 @@ exports[`disable nft on landing page renders nft information and card 1`] = ` ...@@ -3345,6 +3368,7 @@ exports[`disable nft on landing page renders nft information and card 1`] = `
.c27 { .c27 {
color: #222222; color: #222222;
pointer-events: auto;
width: 0; width: 0;
position: relative; position: relative;
font-weight: 485; font-weight: 485;
...@@ -3444,6 +3468,8 @@ exports[`disable nft on landing page renders nft information and card 1`] = ` ...@@ -3444,6 +3468,8 @@ exports[`disable nft on landing page renders nft information and card 1`] = `
margin-left: 12px; margin-left: 12px;
box-shadow: 0px 0px 10px 0px rgba(34,34,34,0.04); box-shadow: 0px 0px 10px 0px rgba(34,34,34,0.04);
visibility: visible; visibility: visible;
-webkit-animation: none;
animation: none;
} }
.c30:hover, .c30:hover,
...@@ -3499,6 +3525,8 @@ exports[`disable nft on landing page renders nft information and card 1`] = ` ...@@ -3499,6 +3525,8 @@ exports[`disable nft on landing page renders nft information and card 1`] = `
margin-left: 12px; margin-left: 12px;
box-shadow: 0px 0px 10px 0px rgba(34,34,34,0.04); box-shadow: 0px 0px 10px 0px rgba(34,34,34,0.04);
visibility: visible; visibility: visible;
-webkit-animation: none;
animation: none;
} }
.c44:hover, .c44:hover,
...@@ -4418,56 +4446,65 @@ exports[`disable nft on landing page renders nft information and card 1`] = ` ...@@ -4418,56 +4446,65 @@ exports[`disable nft on landing page renders nft information and card 1`] = `
<div <div
class="c26" class="c26"
> >
<input <div
autocomplete="off" style="display: flex; flex-grow: 1;"
autocorrect="off" >
class="c27 c28 token-amount-input" <input
inputmode="decimal" autocomplete="off"
maxlength="79" autocorrect="off"
minlength="1" class="c27 c28 token-amount-input"
pattern="^[0-9]*[.,]?[0-9]*$" id="swap-currency-input"
placeholder="0" inputmode="decimal"
spellcheck="false" maxlength="79"
type="text" minlength="1"
value="" pattern="^[0-9]*[.,]?[0-9]*$"
/> placeholder="0"
spellcheck="false"
type="text"
value=""
/>
</div>
<div> <div>
<button <div
class="c14 c15 c29 c30 open-currency-select-button" class="c13"
> >
<span <button
class="c31" class="c14 c15 c29 c30 open-currency-select-button"
> >
<div <span
class="c6 c7 c10" class="c31"
> >
<div <div
class="c32" class="c6 c7 c10"
style="height: 24px; width: 24px; margin-right: 2px;"
> >
<div <div
class="c33" class="c32"
style="height: 24px; width: 24px; margin-right: 2px;"
> >
<img <div
alt="ETH logo" class="c33"
class="c34" >
src="ethereum-logo.png" <img
/> alt="ETH logo"
class="c34"
src="ethereum-logo.png"
/>
</div>
</div> </div>
<span
class="c35 token-symbol-container"
>
ETH
</span>
</div> </div>
<span <svg
class="c35 token-symbol-container" class="c36"
> >
ETH dropdown.svg
</span> </svg>
</div> </span>
<svg </button>
class="c36" </div>
>
dropdown.svg
</svg>
</span>
</button>
</div> </div>
</div> </div>
<div <div
...@@ -4540,42 +4577,51 @@ exports[`disable nft on landing page renders nft information and card 1`] = ` ...@@ -4540,42 +4577,51 @@ exports[`disable nft on landing page renders nft information and card 1`] = `
<div <div
class="c26" class="c26"
> >
<input <div
autocomplete="off" style="display: flex; flex-grow: 1;"
autocorrect="off" >
class="c27 c28 token-amount-input" <input
inputmode="decimal" autocomplete="off"
maxlength="79" autocorrect="off"
minlength="1" class="c27 c28 token-amount-input"
pattern="^[0-9]*[.,]?[0-9]*$" id="swap-currency-output"
placeholder="0" inputmode="decimal"
spellcheck="false" maxlength="79"
type="text" minlength="1"
value="" pattern="^[0-9]*[.,]?[0-9]*$"
/> placeholder="0"
spellcheck="false"
type="text"
value=""
/>
</div>
<div> <div>
<button <div
class="c14 c15 c29 c44 open-currency-select-button" class="c13"
> >
<span <button
class="c31" class="c14 c15 c29 c44 open-currency-select-button"
> >
<div <span
class="c6 c7 c10" class="c31"
> >
<span <div
class="c35 token-symbol-container" class="c6 c7 c10"
> >
Select token <span
</span> class="c35 token-symbol-container"
</div> >
<svg Select token
class="c45" </span>
> </div>
dropdown.svg <svg
</svg> class="c45"
</span> >
</button> dropdown.svg
</svg>
</span>
</button>
</div>
</div> </div>
</div> </div>
<div <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