Commit 21504507 authored by Ian Lapham's avatar Ian Lapham Committed by GitHub

improvement(token warnings): show better warnings for imported tokens (#1005)

* add updated ui warnings for imported tokens

* remove useless styling

* update to surpress on default tokens

* add integration tests for warning cards on token import

* remove callbacks as props in token warning card
parent 1b07e958
describe('Warning', () => {
beforeEach(() => {
cy.clearLocalStorage()
cy.visit('/swap?outputCurrency=0x0a40f26d74274b7f22b28556a27b35d97ce08e0a')
})
it('Check that warning is displayed', () => {
cy.get('.token-warning-container').should('be.visible')
})
it('Check that warning hides after button dismissal.', () => {
cy.get('.token-dismiss-button').click()
cy.get('.token-warning-container').should('not.be.visible')
})
it('Check supression persists across sessions.', () => {
cy.get('.token-warning-container').should('be.visible')
cy.get('.token-dismiss-button').click()
cy.reload()
cy.get('.token-warning-container').should('not.be.visible')
})
})
...@@ -2,66 +2,41 @@ import { Currency, Token } from '@uniswap/sdk' ...@@ -2,66 +2,41 @@ import { Currency, Token } from '@uniswap/sdk'
import { transparentize } from 'polished' import { transparentize } from 'polished'
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import { ReactComponent as Close } from '../../assets/images/x.svg'
import { useActiveWeb3React } from '../../hooks' import { useActiveWeb3React } from '../../hooks'
import { useAllTokens } from '../../hooks/Tokens' import { useAllTokens } from '../../hooks/Tokens'
import { useDefaultTokenList } from '../../state/lists/hooks' import { useDefaultTokenList } from '../../state/lists/hooks'
import { Field } from '../../state/swap/actions' import { Field } from '../../state/swap/actions'
import { useTokenWarningDismissal } from '../../state/user/hooks'
import { ExternalLink, TYPE } from '../../theme' import { ExternalLink, TYPE } from '../../theme'
import { getEtherscanLink, isDefaultToken } from '../../utils' import { getEtherscanLink, isDefaultToken } from '../../utils'
import PropsOfExcluding from '../../utils/props-of-excluding' import PropsOfExcluding from '../../utils/props-of-excluding'
import QuestionHelper from '../QuestionHelper'
import CurrencyLogo from '../CurrencyLogo' import CurrencyLogo from '../CurrencyLogo'
import { AutoRow, RowBetween } from '../Row'
import { AutoColumn } from '../Column'
import { AlertTriangle } from 'react-feather'
import { ButtonError } from '../Button'
import { useTokenWarningDismissal } from '../../state/user/hooks'
const Wrapper = styled.div<{ error: boolean }>` const Wrapper = styled.div<{ error: boolean }>`
background: ${({ theme, error }) => transparentize(0.9, error ? theme.red1 : theme.yellow1)}; background: ${({ theme }) => transparentize(0.6, theme.white)};
position: relative; padding: 0.75rem;
padding: 1rem; border-radius: 20px;
/* border: 0.5px solid ${({ theme, error }) => transparentize(0.4, error ? theme.red1 : theme.yellow1)}; */
border-radius: 10px;
margin-bottom: 20px;
display: grid;
grid-template-rows: 14px auto auto;
grid-row-gap: 14px;
` `
const Row = styled.div` const WarningContainer = styled.div`
display: flex; max-width: 420px;
align-items: center; width: 100%;
justify-items: flex-start; padding: 1rem;
& > * { background: rgba(242, 150, 2, 0.05);
margin-right: 6px; border: 1px solid #f3841e;
} box-sizing: border-box;
` border-radius: 20px;
margin-bottom: 2rem;
const CloseColor = styled(Close)`
color: #aeaeae;
`
const CloseIcon = styled.div`
position: absolute;
right: 1rem;
top: 12px;
&:hover {
cursor: pointer;
opacity: 0.6;
}
& > * {
height: 16px;
width: 16px;
}
` `
const HELP_TEXT = ` const StyledWarningIcon = styled(AlertTriangle)`
The Uniswap V2 smart contracts are designed to support any ERC20 token on Ethereum. Any token can be stroke: ${({ theme }) => theme.red2};
loaded into the interface by entering its Ethereum address into the search field or passing it as a URL
parameter.
` `
const DUPLICATE_NAME_HELP_TEXT = `${HELP_TEXT} This token has the same name or symbol as another token in your list.`
interface TokenWarningCardProps extends PropsOfExcluding<typeof Wrapper, 'error'> { interface TokenWarningCardProps extends PropsOfExcluding<typeof Wrapper, 'error'> {
token?: Token token?: Token
} }
...@@ -74,8 +49,6 @@ export default function TokenWarningCard({ token, ...rest }: TokenWarningCardPro ...@@ -74,8 +49,6 @@ export default function TokenWarningCard({ token, ...rest }: TokenWarningCardPro
const tokenSymbol = token?.symbol?.toLowerCase() ?? '' const tokenSymbol = token?.symbol?.toLowerCase() ?? ''
const tokenName = token?.name?.toLowerCase() ?? '' const tokenName = token?.name?.toLowerCase() ?? ''
const [dismissed, dismissTokenWarning] = useTokenWarningDismissal(chainId, token)
const allTokens = useAllTokens() const allTokens = useAllTokens()
const duplicateNameOrSymbol = useMemo(() => { const duplicateNameOrSymbol = useMemo(() => {
...@@ -90,52 +63,77 @@ export default function TokenWarningCard({ token, ...rest }: TokenWarningCardPro ...@@ -90,52 +63,77 @@ export default function TokenWarningCard({ token, ...rest }: TokenWarningCardPro
}) })
}, [isDefault, token, chainId, allTokens, tokenSymbol, tokenName]) }, [isDefault, token, chainId, allTokens, tokenSymbol, tokenName])
if (isDefault || !token || dismissed) return null if (isDefault || !token) return null
return ( return (
<Wrapper error={duplicateNameOrSymbol} {...rest}> <Wrapper error={duplicateNameOrSymbol} {...rest}>
{duplicateNameOrSymbol ? null : ( <AutoRow gap="6px">
<CloseIcon onClick={dismissTokenWarning}> <AutoColumn gap="24px">
<CloseColor /> <CurrencyLogo currency={token} size={'16px'} />
</CloseIcon> <div> </div>
)} </AutoColumn>
<Row> <AutoColumn gap="10px" justify="flex-start">
<TYPE.subHeader>{duplicateNameOrSymbol ? 'Duplicate token name or symbol' : 'Imported token'}</TYPE.subHeader> <TYPE.main>
<QuestionHelper text={duplicateNameOrSymbol ? DUPLICATE_NAME_HELP_TEXT : HELP_TEXT} /> {token && token.name && token.symbol && token.name !== token.symbol
</Row> ? `${token.name} (${token.symbol})`
<Row> : token.name || token.symbol}
<CurrencyLogo currency={token} /> </TYPE.main>
<div style={{ fontWeight: 500 }}> <ExternalLink style={{ fontWeight: 400 }} href={getEtherscanLink(chainId, token.address, 'token')}>
{token && token.name && token.symbol && token.name !== token.symbol <TYPE.blue> (View on Etherscan)</TYPE.blue>
? `${token.name} (${token.symbol})` </ExternalLink>
: token.name || token.symbol} </AutoColumn>
</div> </AutoRow>
<ExternalLink style={{ fontWeight: 400 }} href={getEtherscanLink(chainId, token.address, 'token')}>
(View on Etherscan)
</ExternalLink>
</Row>
<Row>
<TYPE.italic>Verify this is the correct token before making any transactions.</TYPE.italic>
</Row>
</Wrapper> </Wrapper>
) )
} }
const WarningContainer = styled.div`
max-width: 420px;
width: 100%;
padding-left: 1rem;
padding-right: 1rem;
`
export function TokenWarningCards({ currencies }: { currencies: { [field in Field]?: Currency } }) { export function TokenWarningCards({ currencies }: { currencies: { [field in Field]?: Currency } }) {
const { chainId } = useActiveWeb3React()
const [dismissedToken0, dismissToken0] = useTokenWarningDismissal(chainId, currencies[Field.INPUT])
const [dismissedToken1, dismissToken1] = useTokenWarningDismissal(chainId, currencies[Field.OUTPUT])
return ( return (
<WarningContainer> <WarningContainer className="token-warning-container">
{Object.keys(currencies).map(field => <AutoColumn gap="lg">
currencies[field] instanceof Token ? ( <AutoRow gap="6px">
<TokenWarningCard style={{ marginBottom: 14 }} key={field} token={currencies[field]} /> <StyledWarningIcon />
) : null <TYPE.main color={'red2'}>Token imported</TYPE.main>
)} </AutoRow>
<TYPE.body color={'red2'}>
Anyone can create and name any ERC20 token on Ethereum, including creating fake versions of existing tokens
and tokens that claim to represent projects that do not have a token.
</TYPE.body>
<TYPE.body color={'red2'}>
Similar to Etherscan, this site can load arbitrary tokens via token addresses. Please do your own research
before interacting with any ERC20 token.
</TYPE.body>
{Object.keys(currencies).map(field => {
const dismissed = field === Field.INPUT ? dismissedToken0 : dismissedToken1
return currencies[field] instanceof Token && !dismissed ? (
<TokenWarningCard key={field} token={currencies[field]} />
) : null
})}
<RowBetween>
<div />
<ButtonError
error={true}
width={'140px'}
padding="0.5rem 1rem"
style={{
borderRadius: '10px'
}}
onClick={() => {
dismissToken0 && dismissToken0()
dismissToken1 && dismissToken1()
}}
>
<TYPE.body color="white" className="token-dismiss-button">
I understand
</TYPE.body>
</ButtonError>
<div />
</RowBetween>
</AutoColumn>
</WarningContainer> </WarningContainer>
) )
} }
import React from 'react' import React from 'react'
import styled from 'styled-components' import styled from 'styled-components'
export const BodyWrapper = styled.div` export const BodyWrapper = styled.div<{ disabled?: boolean }>`
position: relative; position: relative;
max-width: 420px; max-width: 420px;
width: 100%; width: 100%;
...@@ -10,11 +10,13 @@ export const BodyWrapper = styled.div` ...@@ -10,11 +10,13 @@ export const BodyWrapper = styled.div`
0px 24px 32px rgba(0, 0, 0, 0.01); 0px 24px 32px rgba(0, 0, 0, 0.01);
border-radius: 30px; border-radius: 30px;
padding: 1rem; padding: 1rem;
opacity: ${({ disabled }) => (disabled ? '0.4' : '1')};
pointer-events: ${({ disabled }) => disabled && 'none'};
` `
/** /**
* The styled container element that wraps the content of most pages and the tabs. * The styled container element that wraps the content of most pages and the tabs.
*/ */
export default function AppBody({ children }: { children: React.ReactNode }) { export default function AppBody({ children, disabled }: { children: React.ReactNode; disabled?: boolean }) {
return <BodyWrapper>{children}</BodyWrapper> return <BodyWrapper disabled={disabled}>{children}</BodyWrapper>
} }
...@@ -37,7 +37,12 @@ import { ...@@ -37,7 +37,12 @@ import {
useSwapActionHandlers, useSwapActionHandlers,
useSwapState useSwapState
} from '../../state/swap/hooks' } from '../../state/swap/hooks'
import { useExpertModeManager, useUserDeadline, useUserSlippageTolerance } from '../../state/user/hooks' import {
useExpertModeManager,
useUserDeadline,
useUserSlippageTolerance,
useTokenWarningDismissal
} from '../../state/user/hooks'
import { CursorPointer, LinkStyledButton, TYPE } from '../../theme' import { CursorPointer, LinkStyledButton, TYPE } from '../../theme'
import { maxAmountSpend } from '../../utils/maxAmountSpend' import { maxAmountSpend } from '../../utils/maxAmountSpend'
import { computeSlippageAdjustedAmounts, computeTradePriceBreakdown, warningSeverity } from '../../utils/prices' import { computeSlippageAdjustedAmounts, computeTradePriceBreakdown, warningSeverity } from '../../utils/prices'
...@@ -47,7 +52,7 @@ import { ClickableText } from '../Pool/styleds' ...@@ -47,7 +52,7 @@ import { ClickableText } from '../Pool/styleds'
export default function Swap() { export default function Swap() {
useDefaultsFromURLSearch() useDefaultsFromURLSearch()
const { account } = useActiveWeb3React() const { account, chainId } = useActiveWeb3React()
const theme = useContext(ThemeContext) const theme = useContext(ThemeContext)
// toggle wallet when disconnected // toggle wallet when disconnected
...@@ -241,10 +246,15 @@ export default function Swap() { ...@@ -241,10 +246,15 @@ export default function Swap() {
currencies[Field.INPUT]?.symbol currencies[Field.INPUT]?.symbol
} for ${parsedAmounts[Field.OUTPUT]?.toSignificant(6)} ${currencies[Field.OUTPUT]?.symbol}` } for ${parsedAmounts[Field.OUTPUT]?.toSignificant(6)} ${currencies[Field.OUTPUT]?.symbol}`
const [dismissedToken0] = useTokenWarningDismissal(chainId, currencies[Field.INPUT])
const [dismissedToken1] = useTokenWarningDismissal(chainId, currencies[Field.OUTPUT])
const showWarning =
(!dismissedToken0 && !!currencies[Field.INPUT]) || (!dismissedToken1 && !!currencies[Field.OUTPUT])
return ( return (
<> <>
<TokenWarningCards currencies={currencies} /> {showWarning && <TokenWarningCards currencies={currencies} />}
<AppBody> <AppBody disabled={!!showWarning}>
<SwapPoolTabs active={'swap'} /> <SwapPoolTabs active={'swap'} />
<Wrapper id="swap-page"> <Wrapper id="swap-page">
<ConfirmationModal <ConfirmationModal
...@@ -424,7 +434,6 @@ export default function Swap() { ...@@ -424,7 +434,6 @@ export default function Swap() {
</BottomGrouping> </BottomGrouping>
</Wrapper> </Wrapper>
</AppBody> </AppBody>
<AdvancedSwapDetailsDropdown trade={trade} /> <AdvancedSwapDetailsDropdown trade={trade} />
</> </>
) )
......
import { ChainId, Pair, Token } from '@uniswap/sdk' import { ChainId, Pair, Token, Currency } from '@uniswap/sdk'
import flatMap from 'lodash.flatmap' import flatMap from 'lodash.flatmap'
import { useCallback, useMemo } from 'react' import { useCallback, useMemo } from 'react'
import { shallowEqual, useDispatch, useSelector } from 'react-redux' import { shallowEqual, useDispatch, useSelector } from 'react-redux'
...@@ -19,6 +19,8 @@ import { ...@@ -19,6 +19,8 @@ import {
updateUserExpertMode, updateUserExpertMode,
updateUserSlippageTolerance updateUserSlippageTolerance
} from './actions' } from './actions'
import { useDefaultTokenList } from '../lists/hooks'
import { isDefaultToken } from '../../utils'
function serializeToken(token: Token): SerializedToken { function serializeToken(token: Token): SerializedToken {
return { return {
...@@ -165,22 +167,30 @@ export function usePairAdder(): (pair: Pair) => void { ...@@ -165,22 +167,30 @@ export function usePairAdder(): (pair: Pair) => void {
* Returns whether a token warning has been dismissed and a callback to dismiss it, * Returns whether a token warning has been dismissed and a callback to dismiss it,
* iff it has not already been dismissed and is a valid token. * iff it has not already been dismissed and is a valid token.
*/ */
export function useTokenWarningDismissal(chainId?: number, token?: Token): [boolean, null | (() => void)] { export function useTokenWarningDismissal(chainId?: number, token?: Currency): [boolean, null | (() => void)] {
const dismissalState = useSelector<AppState, AppState['user']['dismissedTokenWarnings']>( const dismissalState = useSelector<AppState, AppState['user']['dismissedTokenWarnings']>(
state => state.user.dismissedTokenWarnings state => state.user.dismissedTokenWarnings
) )
const dispatch = useDispatch<AppDispatch>() const dispatch = useDispatch<AppDispatch>()
// get default list, mark as dismissed if on list
const defaultList = useDefaultTokenList()
const isDefault = isDefaultToken(defaultList, token)
return useMemo(() => { return useMemo(() => {
if (!chainId || !token) return [false, null] if (!chainId || !token) return [false, null]
const dismissed: boolean = dismissalState?.[chainId]?.[token.address] === true const dismissed: boolean =
token instanceof Token ? dismissalState?.[chainId]?.[token.address] === true || isDefault : true
const callback = dismissed ? null : () => dispatch(dismissTokenWarning({ chainId, tokenAddress: token.address })) const callback =
dismissed || !(token instanceof Token)
? null
: () => dispatch(dismissTokenWarning({ chainId, tokenAddress: token.address }))
return [dismissed, callback] return [dismissed, callback]
}, [chainId, token, dismissalState, dispatch]) }, [chainId, token, dismissalState, isDefault, dispatch])
} }
/** /**
......
...@@ -75,6 +75,7 @@ export function colors(darkMode: boolean): Colors { ...@@ -75,6 +75,7 @@ export function colors(darkMode: boolean): Colors {
// other // other
red1: '#FF6871', red1: '#FF6871',
red2: '#F82D3A',
green1: '#27AE60', green1: '#27AE60',
yellow1: '#FFE270', yellow1: '#FFE270',
yellow2: '#F3841E' yellow2: '#F3841E'
......
...@@ -39,6 +39,7 @@ export interface Colors { ...@@ -39,6 +39,7 @@ export interface Colors {
// other // other
red1: Color red1: Color
red2: Color
green1: Color green1: Color
yellow1: Color yellow1: Color
yellow2: Color yellow2: Color
......
...@@ -5690,7 +5690,7 @@ cyclist@^1.0.1: ...@@ -5690,7 +5690,7 @@ cyclist@^1.0.1:
resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9"
integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk= integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk=
cypress@*, cypress@^4.5.0: cypress@*, cypress@^4.11.0:
version "4.11.0" version "4.11.0"
resolved "https://registry.yarnpkg.com/cypress/-/cypress-4.11.0.tgz#054b0b85fd3aea793f186249ee1216126d5f0a7e" resolved "https://registry.yarnpkg.com/cypress/-/cypress-4.11.0.tgz#054b0b85fd3aea793f186249ee1216126d5f0a7e"
integrity sha512-6Yd598+KPATM+dU1Ig0g2hbA+R/o1MAKt0xIejw4nZBVLSplCouBzqeKve6XsxGU6n4HMSt/+QYsWfFcoQeSEw== integrity sha512-6Yd598+KPATM+dU1Ig0g2hbA+R/o1MAKt0xIejw4nZBVLSplCouBzqeKve6XsxGU6n4HMSt/+QYsWfFcoQeSEw==
......
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