Commit 0f519911 authored by Zach Pomerantz's avatar Zach Pomerantz Committed by GitHub

feat: improved warning ux (#3310)

parent da8884d8
...@@ -16,7 +16,7 @@ import Row from '../Row' ...@@ -16,7 +16,7 @@ import Row from '../Row'
import TokenImg from '../TokenImg' import TokenImg from '../TokenImg'
import TokenInput from './TokenInput' import TokenInput from './TokenInput'
export const LoadingSpan = styled.span<{ $loading: boolean }>` export const LoadingRow = styled(Row)<{ $loading: boolean }>`
${loadingOpacityCss}; ${loadingOpacityCss};
` `
...@@ -85,7 +85,7 @@ export default function Input({ disabled, focused }: InputProps) { ...@@ -85,7 +85,7 @@ export default function Input({ disabled, focused }: InputProps) {
> >
<ThemedText.Body2 color="secondary"> <ThemedText.Body2 color="secondary">
<Row> <Row>
<LoadingSpan $loading={isLoading}>{inputUSDC ? `$${inputUSDC.toFixed(2)}` : '-'}</LoadingSpan> <LoadingRow $loading={isLoading}>{inputUSDC ? `$${inputUSDC.toFixed(2)}` : '-'}</LoadingRow>
{balance && ( {balance && (
<Balance color={inputCurrencyAmount?.greaterThan(balance) ? 'error' : undefined} focused={focused}> <Balance color={inputCurrencyAmount?.greaterThan(balance) ? 'error' : undefined} focused={focused}>
Balance: <span style={{ userSelect: 'text' }}>{formatCurrencyAmount(balance, 4, i18n.locale)}</span> Balance: <span style={{ userSelect: 'text' }}>{formatCurrencyAmount(balance, 4, i18n.locale)}</span>
......
...@@ -12,10 +12,11 @@ import { PropsWithChildren, useMemo } from 'react' ...@@ -12,10 +12,11 @@ import { PropsWithChildren, useMemo } from 'react'
import { TradeState } from 'state/routing/types' import { TradeState } from 'state/routing/types'
import { computeFiatValuePriceImpact } from 'utils/computeFiatValuePriceImpact' import { computeFiatValuePriceImpact } from 'utils/computeFiatValuePriceImpact'
import { formatCurrencyAmount } from 'utils/formatCurrencyAmount' import { formatCurrencyAmount } from 'utils/formatCurrencyAmount'
import { getPriceImpactWarning } from 'utils/prices'
import Column from '../Column' import Column from '../Column'
import Row from '../Row' import Row from '../Row'
import { Balance, InputProps, LoadingSpan } from './Input' import { Balance, InputProps, LoadingRow } from './Input'
import TokenInput from './TokenInput' import TokenInput from './TokenInput'
export const colorAtom = atom<string | undefined>(undefined) export const colorAtom = atom<string | undefined>(undefined)
...@@ -63,16 +64,19 @@ export default function Output({ disabled, focused, children }: PropsWithChildre ...@@ -63,16 +64,19 @@ export default function Output({ disabled, focused, children }: PropsWithChildre
const outputUSDC = useUSDCValue(outputCurrencyAmount) const outputUSDC = useUSDCValue(outputCurrencyAmount)
const priceImpact = useMemo(() => { const priceImpact = useMemo(() => {
const computedChange = computeFiatValuePriceImpact(inputUSDC, outputUSDC) const fiatValuePriceImpact = computeFiatValuePriceImpact(inputUSDC, outputUSDC)
return computedChange ? parseFloat(computedChange.multiply(-1)?.toSignificant(3)) : undefined if (!fiatValuePriceImpact) return null
}, [inputUSDC, outputUSDC])
const usdc = useMemo(() => { const color = getPriceImpactWarning(fiatValuePriceImpact)
if (outputUSDC) { const sign = fiatValuePriceImpact.lessThan(0) ? '+' : ''
return `$${outputUSDC.toFixed(2)} (${priceImpact && priceImpact > 0 ? '+' : ''}${priceImpact}%)` const displayedPriceImpact = parseFloat(fiatValuePriceImpact.multiply(-1)?.toSignificant(3))
} return (
return '' <ThemedText.Body2 color={color}>
}, [priceImpact, outputUSDC]) ({sign}
{displayedPriceImpact}%)
</ThemedText.Body2>
)
}, [inputUSDC, outputUSDC])
return ( return (
<DynamicThemeProvider color={color}> <DynamicThemeProvider color={color}>
...@@ -92,7 +96,9 @@ export default function Output({ disabled, focused, children }: PropsWithChildre ...@@ -92,7 +96,9 @@ export default function Output({ disabled, focused, children }: PropsWithChildre
> >
<ThemedText.Body2 color="secondary"> <ThemedText.Body2 color="secondary">
<Row> <Row>
<LoadingSpan $loading={isLoading}>{usdc}</LoadingSpan> <LoadingRow gap={0.5} $loading={isLoading}>
{outputUSDC?.toFixed(2)} {priceImpact}
</LoadingRow>
{balance && ( {balance && (
<Balance focused={focused}> <Balance focused={focused}>
Balance: <span style={{ userSelect: 'text' }}>{formatCurrencyAmount(balance, 4, i18n.locale)}</span> Balance: <span style={{ userSelect: 'text' }}>{formatCurrencyAmount(balance, 4, i18n.locale)}</span>
......
import { Trans } from '@lingui/macro' import { Trans } from '@lingui/macro'
import { Percent } from '@uniswap/sdk-core'
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
import Popover from 'lib/components/Popover' import Popover from 'lib/components/Popover'
import { useTooltip } from 'lib/components/Tooltip' import { useTooltip } from 'lib/components/Tooltip'
import { toPercent } from 'lib/hooks/useAllowedSlippage' import { getSlippageWarning, toPercent } from 'lib/hooks/useAllowedSlippage'
import { AlertTriangle, Check, Icon, LargeIcon, XOctagon } from 'lib/icons' import { AlertTriangle, Check, Icon, LargeIcon, XOctagon } from 'lib/icons'
import { autoSlippageAtom, MAX_VALID_SLIPPAGE, maxSlippageAtom, MIN_HIGH_SLIPPAGE } from 'lib/state/settings' import { autoSlippageAtom, maxSlippageAtom } from 'lib/state/settings'
import styled, { Color, ThemedText } from 'lib/theme' import styled, { ThemedText } from 'lib/theme'
import { forwardRef, memo, ReactNode, useCallback, useMemo, useRef, useState } from 'react' import { forwardRef, memo, ReactNode, useCallback, useMemo, useRef, useState } from 'react'
import { BaseButton, TextButton } from '../../Button' import { BaseButton, TextButton } from '../../Button'
...@@ -56,35 +55,18 @@ const Option = forwardRef<HTMLButtonElement, OptionProps>(function Option( ...@@ -56,35 +55,18 @@ const Option = forwardRef<HTMLButtonElement, OptionProps>(function Option(
) )
}) })
enum WarningState { const Warning = memo(function Warning({ state, showTooltip }: { state?: 'warning' | 'error'; showTooltip: boolean }) {
INVALID_SLIPPAGE = 1, let icon: Icon | undefined
HIGH_SLIPPAGE,
}
function toWarningState(percent: Percent | undefined): WarningState | undefined {
if (percent?.greaterThan(MAX_VALID_SLIPPAGE)) {
return WarningState.INVALID_SLIPPAGE
} else if (percent?.greaterThan(MIN_HIGH_SLIPPAGE)) {
return WarningState.HIGH_SLIPPAGE
}
return
}
const Warning = memo(function Warning({ state, showTooltip }: { state: WarningState; showTooltip: boolean }) {
let icon: Icon
let color: Color
let content: ReactNode let content: ReactNode
let show = showTooltip let show = showTooltip
switch (state) { switch (state) {
case WarningState.INVALID_SLIPPAGE: case 'error':
icon = XOctagon icon = XOctagon
color = 'error'
content = invalidSlippage content = invalidSlippage
show = true show = true
break break
case WarningState.HIGH_SLIPPAGE: case 'warning':
icon = AlertTriangle icon = AlertTriangle
color = 'warning'
content = highSlippage content = highSlippage
break break
} }
...@@ -97,7 +79,7 @@ const Warning = memo(function Warning({ state, showTooltip }: { state: WarningSt ...@@ -97,7 +79,7 @@ const Warning = memo(function Warning({ state, showTooltip }: { state: WarningSt
offset={16} offset={16}
contained contained
> >
<LargeIcon icon={icon} color={color} size={1.25} /> <LargeIcon icon={icon} color={state} size={1.25} />
</Popover> </Popover>
) )
}) })
...@@ -106,7 +88,7 @@ export default function MaxSlippageSelect() { ...@@ -106,7 +88,7 @@ export default function MaxSlippageSelect() {
const [autoSlippage, setAutoSlippage] = useAtom(autoSlippageAtom) const [autoSlippage, setAutoSlippage] = useAtom(autoSlippageAtom)
const [maxSlippage, setMaxSlippage] = useAtom(maxSlippageAtom) const [maxSlippage, setMaxSlippage] = useAtom(maxSlippageAtom)
const maxSlippageInput = useMemo(() => maxSlippage?.toString() || '', [maxSlippage]) const maxSlippageInput = useMemo(() => maxSlippage?.toString() || '', [maxSlippage])
const [warning, setWarning] = useState<WarningState | undefined>(toWarningState(toPercent(maxSlippage))) const [warning, setWarning] = useState<'warning' | 'error' | undefined>(getSlippageWarning(toPercent(maxSlippage)))
const option = useRef<HTMLButtonElement>(null) const option = useRef<HTMLButtonElement>(null)
const showTooltip = useTooltip(option.current) const showTooltip = useTooltip(option.current)
...@@ -117,10 +99,10 @@ export default function MaxSlippageSelect() { ...@@ -117,10 +99,10 @@ export default function MaxSlippageSelect() {
const processValue = useCallback( const processValue = useCallback(
(value: number | undefined) => { (value: number | undefined) => {
const percent = toPercent(value) const percent = toPercent(value)
const warning = toWarningState(percent) const warning = getSlippageWarning(percent)
setWarning(warning) setWarning(warning)
setMaxSlippage(value) setMaxSlippage(value)
setAutoSlippage(!percent || warning === WarningState.INVALID_SLIPPAGE) setAutoSlippage(!percent || warning === 'error')
}, },
[setAutoSlippage, setMaxSlippage] [setAutoSlippage, setMaxSlippage]
) )
...@@ -146,7 +128,7 @@ export default function MaxSlippageSelect() { ...@@ -146,7 +128,7 @@ export default function MaxSlippageSelect() {
ref={option} ref={option}
tabIndex={-1} tabIndex={-1}
> >
<Row color={warning === WarningState.INVALID_SLIPPAGE ? 'error' : undefined}> <Row color={warning === 'error' ? 'error' : undefined}>
<DecimalInput <DecimalInput
size={Math.max(maxSlippageInput.length, 3)} size={Math.max(maxSlippageInput.length, 3)}
value={maxSlippageInput} value={maxSlippageInput}
......
...@@ -2,15 +2,14 @@ import { t } from '@lingui/macro' ...@@ -2,15 +2,14 @@ import { t } from '@lingui/macro'
import { useLingui } from '@lingui/react' import { useLingui } from '@lingui/react'
import { Trade } from '@uniswap/router-sdk' import { Trade } from '@uniswap/router-sdk'
import { Currency, Percent, TradeType } from '@uniswap/sdk-core' import { Currency, Percent, TradeType } from '@uniswap/sdk-core'
import { ALLOWED_PRICE_IMPACT_HIGH, ALLOWED_PRICE_IMPACT_MEDIUM } from 'constants/misc'
import { useAtomValue } from 'jotai/utils' import { useAtomValue } from 'jotai/utils'
import { MIN_HIGH_SLIPPAGE } from 'lib/state/settings' import { getSlippageWarning } from 'lib/hooks/useAllowedSlippage'
import { feeOptionsAtom } from 'lib/state/swap' import { feeOptionsAtom } from 'lib/state/swap'
import styled, { Color, ThemedText } from 'lib/theme' import styled, { Color, ThemedText } from 'lib/theme'
import { useMemo } from 'react' import { useMemo } from 'react'
import { currencyId } from 'utils/currencyId' import { currencyId } from 'utils/currencyId'
import { formatCurrencyAmount } from 'utils/formatCurrencyAmount' import { formatCurrencyAmount } from 'utils/formatCurrencyAmount'
import { computeRealizedLPFeeAmount, computeRealizedPriceImpact } from 'utils/prices' import { computeRealizedLPFeeAmount, computeRealizedPriceImpact, getPriceImpactWarning } from 'utils/prices'
import Row from '../../Row' import Row from '../../Row'
...@@ -52,7 +51,7 @@ export default function Details({ trade, allowedSlippage }: DetailsProps) { ...@@ -52,7 +51,7 @@ export default function Details({ trade, allowedSlippage }: DetailsProps) {
const { i18n } = useLingui() const { i18n } = useLingui()
const details = useMemo(() => { const details = useMemo(() => {
const rows = [] const rows: Array<[string, string] | [string, string, Color | undefined]> = []
// @TODO(ianlapham): Check that provider fee is even a valid list item // @TODO(ianlapham): Check that provider fee is even a valid list item
if (feeOptions) { if (feeOptions) {
...@@ -63,13 +62,7 @@ export default function Details({ trade, allowedSlippage }: DetailsProps) { ...@@ -63,13 +62,7 @@ export default function Details({ trade, allowedSlippage }: DetailsProps) {
} }
} }
const priceImpactRow = [t`Price impact`, `${priceImpact.toFixed(2)}%`] rows.push([t`Price impact`, `${priceImpact.toFixed(2)}%`, getPriceImpactWarning(priceImpact)])
if (priceImpact.greaterThan(ALLOWED_PRICE_IMPACT_HIGH)) {
priceImpactRow.push('error')
} else if (priceImpact.greaterThan(ALLOWED_PRICE_IMPACT_MEDIUM)) {
priceImpactRow.push('warning')
}
rows.push(priceImpactRow)
if (lpFeeAmount) { if (lpFeeAmount) {
const parsedLpFee = formatCurrencyAmount(lpFeeAmount, 6, i18n.locale) const parsedLpFee = formatCurrencyAmount(lpFeeAmount, 6, i18n.locale)
...@@ -86,11 +79,7 @@ export default function Details({ trade, allowedSlippage }: DetailsProps) { ...@@ -86,11 +79,7 @@ export default function Details({ trade, allowedSlippage }: DetailsProps) {
rows.push([t`Minimum received`, `${localizedMaxSent} ${outputCurrency.symbol}`]) rows.push([t`Minimum received`, `${localizedMaxSent} ${outputCurrency.symbol}`])
} }
const slippageToleranceRow = [t`Slippage tolerance`, `${allowedSlippage.toFixed(2)}%`] rows.push([t`Slippage tolerance`, `${allowedSlippage.toFixed(2)}%`, getSlippageWarning(allowedSlippage)])
if (allowedSlippage.greaterThan(MIN_HIGH_SLIPPAGE)) {
slippageToleranceRow.push('warning')
}
rows.push(slippageToleranceRow)
return rows return rows
}, [ }, [
...@@ -109,7 +98,7 @@ export default function Details({ trade, allowedSlippage }: DetailsProps) { ...@@ -109,7 +98,7 @@ export default function Details({ trade, allowedSlippage }: DetailsProps) {
return ( return (
<> <>
{details.map(([label, detail, color]) => ( {details.map(([label, detail, color]) => (
<Detail key={label} label={label} value={detail} color={color as Color} /> <Detail key={label} label={label} value={detail} color={color} />
))} ))}
</> </>
) )
......
...@@ -2,18 +2,17 @@ import { Trans } from '@lingui/macro' ...@@ -2,18 +2,17 @@ import { Trans } from '@lingui/macro'
import { useLingui } from '@lingui/react' import { useLingui } from '@lingui/react'
import { Trade } from '@uniswap/router-sdk' import { Trade } from '@uniswap/router-sdk'
import { Currency, Percent, TradeType } from '@uniswap/sdk-core' import { Currency, Percent, TradeType } from '@uniswap/sdk-core'
import { ALLOWED_PRICE_IMPACT_HIGH, ALLOWED_PRICE_IMPACT_MEDIUM } from 'constants/misc'
import { useAtomValue } from 'jotai/utils' import { useAtomValue } from 'jotai/utils'
import { IconButton } from 'lib/components/Button' import { IconButton } from 'lib/components/Button'
import { getSlippageWarning } from 'lib/hooks/useAllowedSlippage'
import useScrollbar from 'lib/hooks/useScrollbar' import useScrollbar from 'lib/hooks/useScrollbar'
import { AlertTriangle, BarChart, Expando, Info } from 'lib/icons' import { AlertTriangle, BarChart, Expando, Info } from 'lib/icons'
import { MIN_HIGH_SLIPPAGE } from 'lib/state/settings'
import { Field, independentFieldAtom } from 'lib/state/swap' import { Field, independentFieldAtom } from 'lib/state/swap'
import styled, { ThemedText } from 'lib/theme' import styled, { ThemedText } from 'lib/theme'
import formatLocaleNumber from 'lib/utils/formatLocaleNumber' import formatLocaleNumber from 'lib/utils/formatLocaleNumber'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { formatCurrencyAmount, formatPrice } from 'utils/formatCurrencyAmount' import { formatCurrencyAmount, formatPrice } from 'utils/formatCurrencyAmount'
import { computeRealizedPriceImpact } from 'utils/prices' import { computeRealizedPriceImpact, getPriceImpactWarning } from 'utils/prices'
import { tradeMeaningfullyDiffers } from 'utils/tradeMeaningFullyDiffer' import { tradeMeaningfullyDiffers } from 'utils/tradeMeaningFullyDiffer'
import ActionButton, { Action } from '../../ActionButton' import ActionButton, { Action } from '../../ActionButton'
...@@ -98,10 +97,7 @@ export function SummaryDialog({ trade, allowedSlippage, onConfirm }: SummaryDial ...@@ -98,10 +97,7 @@ export function SummaryDialog({ trade, allowedSlippage, onConfirm }: SummaryDial
const scrollbar = useScrollbar(details) const scrollbar = useScrollbar(details)
const warning = useMemo(() => { const warning = useMemo(() => {
if (priceImpact.greaterThan(ALLOWED_PRICE_IMPACT_HIGH)) return 'error' return getPriceImpactWarning(priceImpact) || getSlippageWarning(allowedSlippage)
if (priceImpact.greaterThan(ALLOWED_PRICE_IMPACT_MEDIUM)) return 'warning'
if (allowedSlippage.greaterThan(MIN_HIGH_SLIPPAGE)) return 'warning'
return
}, [allowedSlippage, priceImpact]) }, [allowedSlippage, priceImpact])
const [ackPriceImpact, setAckPriceImpact] = useState(false) const [ackPriceImpact, setAckPriceImpact] = useState(false)
...@@ -120,7 +116,7 @@ export function SummaryDialog({ trade, allowedSlippage, onConfirm }: SummaryDial ...@@ -120,7 +116,7 @@ export function SummaryDialog({ trade, allowedSlippage, onConfirm }: SummaryDial
onClick: () => setConfirmedTrade(trade), onClick: () => setConfirmedTrade(trade),
children: <Trans>Accept</Trans>, children: <Trans>Accept</Trans>,
} }
} else if (priceImpact.greaterThan(ALLOWED_PRICE_IMPACT_HIGH) && !ackPriceImpact) { } else if (getPriceImpactWarning(priceImpact) === 'error' && !ackPriceImpact) {
return { return {
message: <Trans>High price impact</Trans>, message: <Trans>High price impact</Trans>,
onClick: () => setAckPriceImpact(true), onClick: () => setAckPriceImpact(true),
......
...@@ -11,8 +11,17 @@ export function toPercent(maxSlippage: number | undefined): Percent | undefined ...@@ -11,8 +11,17 @@ export function toPercent(maxSlippage: number | undefined): Percent | undefined
} }
/** Returns the user-inputted max slippage. */ /** Returns the user-inputted max slippage. */
export default function useMaxSlippage(trade: InterfaceTrade<Currency, Currency, TradeType> | undefined): Percent { export default function useAllowedSlippage(trade: InterfaceTrade<Currency, Currency, TradeType> | undefined): Percent {
const autoSlippage = useAutoSlippageTolerance(trade) const autoSlippage = useAutoSlippageTolerance(trade)
const maxSlippage = toPercent(useAtomValue(maxSlippageAtom)) const maxSlippage = toPercent(useAtomValue(maxSlippageAtom))
return useAtomValue(autoSlippageAtom) ? autoSlippage : maxSlippage ?? autoSlippage return useAtomValue(autoSlippageAtom) ? autoSlippage : maxSlippage ?? autoSlippage
} }
export const MAX_VALID_SLIPPAGE = new Percent(1, 2)
export const MIN_HIGH_SLIPPAGE = new Percent(1, 100)
export function getSlippageWarning(slippage?: Percent): 'warning' | 'error' | undefined {
if (slippage?.greaterThan(MAX_VALID_SLIPPAGE)) return 'error'
if (slippage?.greaterThan(MIN_HIGH_SLIPPAGE)) return 'warning'
return
}
import { Percent } from '@uniswap/sdk-core'
import { atomWithReset } from 'jotai/utils' import { atomWithReset } from 'jotai/utils'
import { pickAtom, setTogglable } from './atoms' import { pickAtom, setTogglable } from './atoms'
export const MAX_VALID_SLIPPAGE = new Percent(1, 2)
export const MIN_HIGH_SLIPPAGE = new Percent(1, 100)
interface Settings { interface Settings {
autoSlippage: boolean // if true, slippage will use the default calculation autoSlippage: boolean // if true, slippage will use the default calculation
maxSlippage: number | undefined // expressed as a percent maxSlippage: number | undefined // expressed as a percent
......
...@@ -21,6 +21,12 @@ export function computeRealizedPriceImpact(trade: Trade<Currency, Currency, Trad ...@@ -21,6 +21,12 @@ export function computeRealizedPriceImpact(trade: Trade<Currency, Currency, Trad
return trade.priceImpact.subtract(realizedLpFeePercent) return trade.priceImpact.subtract(realizedLpFeePercent)
} }
export function getPriceImpactWarning(priceImpact?: Percent): 'warning' | 'error' | undefined {
if (priceImpact?.greaterThan(ALLOWED_PRICE_IMPACT_HIGH)) return 'error'
if (priceImpact?.greaterThan(ALLOWED_PRICE_IMPACT_MEDIUM)) return 'warning'
return
}
// computes realized lp fee as a percent // computes realized lp fee as a percent
export function computeRealizedLPFeePercent(trade: Trade<Currency, Currency, TradeType>): Percent { export function computeRealizedLPFeePercent(trade: Trade<Currency, Currency, TradeType>): Percent {
let percent: Percent let percent: Percent
......
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