Commit 365b429c authored by Moody Salem's avatar Moody Salem Committed by GitHub

feat(token lists): implement the uniswap default list as a token list (#983)

* load tokens from url `useTokenList`

* improve performance of the loading

* move the loading to redux and save loaded lists

* lint error

* move the list fetching code to a separate component

* change how token lists are fetched to use the updater and add unit tests

* fix a crash with currencyEquals

* bump sdk version

* token lists should automatically update for minor/patch changes

* nit

* show popups for list updates

* support pointing at localhost

* spuport ipfs/ipns logos

* use the updater to bump list versions

* save the old/new list in the popup for viewing diffs

* improve the list popup

* fix linter error, make sure visibility checking is working

* show list update notifications

* address a couple metamask warnings, linter error

* fix the custom added/default tokens

* refactor some popup stuff to reuse the fader

* linter error

* Revert: refactor some popup stuff to reuse the fader (a7b0f752)

* style improvements, linter

* add to the readme, drop the token-request template

* back to the beta that works with wallet connect

* get the dependencies to a state that works with wallet connect and passes integration tests
parent 32d30000
---
name: Token Request
about: Request a token addition
title: ''
labels: token request
assignees: ''
---
**Please provide the following information for your token.**
Token Address:
Token Name (from contract):
Token Decimals (from contract):
Token Symbol (from contract):
Uniswap Exchange Address of Token:
Link to the official homepage of token:
Link to CoinMarketCap or CoinGecko page of token:
Some tokens (e.g. BNB) do not work with Uniswap v1. In order to assess if your token works correctly, please complete small-value transactions of each of the types below, and submit the Etherscan transaction links for our review.
Test `addLiquidity` transaction:
Test `swap` transaction:
Test `removeLiquidity` transaction:
Are you willing to add liquidity to the liquidity pool for this token? (Y/N):
If so, how much liquidity are you willing to add?:
...@@ -20,6 +20,12 @@ To access the Uniswap Interface, use an IPFS gateway link from the ...@@ -20,6 +20,12 @@ To access the Uniswap Interface, use an IPFS gateway link from the
[latest release](https://github.com/Uniswap/uniswap-interface/releases/latest), [latest release](https://github.com/Uniswap/uniswap-interface/releases/latest),
or visit [app.uniswap.org](https://app.uniswap.org). or visit [app.uniswap.org](https://app.uniswap.org).
## Listing a token
Please see the
[@uniswap/default-token-list](https://github.com/uniswap/default-token-list)
repository.
## Development ## Development
### Install Dependencies ### Install Dependencies
......
...@@ -4,15 +4,17 @@ ...@@ -4,15 +4,17 @@
"homepage": ".", "homepage": ".",
"private": true, "private": true,
"devDependencies": { "devDependencies": {
"@ethersproject/address": "^5.0.1", "@ethersproject/address": "5.0.0-beta.134",
"@ethersproject/bignumber": "^5.0.3", "@ethersproject/bignumber": "5.0.0-beta.138",
"@ethersproject/constants": "^5.0.1", "@ethersproject/constants": "5.0.0-beta.133",
"@ethersproject/contracts": "^5.0.1", "@ethersproject/contracts": "5.0.0-beta.151",
"@ethersproject/experimental": "^5.0.0", "@ethersproject/experimental": "5.0.0-beta.141",
"@ethersproject/providers": "^5.0.4", "@ethersproject/networks": "5.0.0-beta.136",
"@ethersproject/strings": "^5.0.1", "@ethersproject/providers": "5.0.0-beta.162",
"@ethersproject/units": "^5.0.1", "@ethersproject/solidity": "5.0.2",
"@ethersproject/wallet": "^5.0.1", "@ethersproject/strings": "5.0.0-beta.136",
"@ethersproject/units": "5.0.0-beta.132",
"@ethersproject/wallet": "5.0.0-beta.141",
"@popperjs/core": "^2.4.4", "@popperjs/core": "^2.4.4",
"@reach/dialog": "^0.10.3", "@reach/dialog": "^0.10.3",
"@reach/portal": "^0.10.3", "@reach/portal": "^0.10.3",
...@@ -31,7 +33,8 @@ ...@@ -31,7 +33,8 @@
"@types/testing-library__cypress": "^5.0.5", "@types/testing-library__cypress": "^5.0.5",
"@typescript-eslint/eslint-plugin": "^2.31.0", "@typescript-eslint/eslint-plugin": "^2.31.0",
"@typescript-eslint/parser": "^2.31.0", "@typescript-eslint/parser": "^2.31.0",
"@uniswap/sdk": "3.0.1", "@uniswap/sdk": "3.0.3-beta.1",
"@uniswap/token-lists": "^1.0.0-beta.9",
"@uniswap/v2-core": "1.0.0", "@uniswap/v2-core": "1.0.0",
"@uniswap/v2-periphery": "^1.1.0-beta.0", "@uniswap/v2-periphery": "^1.1.0-beta.0",
"@web3-react/core": "^6.0.9", "@web3-react/core": "^6.0.9",
...@@ -40,6 +43,7 @@ ...@@ -40,6 +43,7 @@
"@web3-react/portis-connector": "^6.0.9", "@web3-react/portis-connector": "^6.0.9",
"@web3-react/walletconnect-connector": "^6.1.1", "@web3-react/walletconnect-connector": "^6.1.1",
"@web3-react/walletlink-connector": "^6.0.9", "@web3-react/walletlink-connector": "^6.0.9",
"ajv": "^6.12.3",
"copy-to-clipboard": "^3.2.0", "copy-to-clipboard": "^3.2.0",
"cross-env": "^7.0.2", "cross-env": "^7.0.2",
"cypress": "^4.5.0", "cypress": "^4.5.0",
......
import { Currency, ETHER, Token } from '@uniswap/sdk'
import React, { useState } from 'react' import React, { useState } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import { Currency, Token } from '@uniswap/sdk'
import EthereumLogo from '../../assets/images/ethereum-logo.png' import EthereumLogo from '../../assets/images/ethereum-logo.png'
import { WrappedTokenInfo } from '../../state/lists/hooks'
import uriToHttp from '../../utils/uriToHttp'
const getTokenLogoURL = address => const getTokenLogoURL = address =>
`https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/${address}/logo.png` `https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/${address}/logo.png`
const NO_LOGO_ADDRESSES: { [tokenAddress: string]: true } = {} const BAD_URIS: { [tokenAddress: string]: true } = {}
const Image = styled.img<{ size: string }>` const Image = styled.img<{ size: string }>`
width: ${({ size }) => size}; width: ${({ size }) => size};
...@@ -44,35 +46,49 @@ export default function CurrencyLogo({ ...@@ -44,35 +46,49 @@ export default function CurrencyLogo({
}) { }) {
const [, refresh] = useState<number>(0) const [, refresh] = useState<number>(0)
if (currency === ETHER) {
return <StyledEthereumLogo src={EthereumLogo} size={size} {...rest} />
}
if (currency instanceof Token) { if (currency instanceof Token) {
let path = '' let uri: string | undefined
if (!NO_LOGO_ADDRESSES[currency.address]) {
path = getTokenLogoURL(currency.address) if (currency instanceof WrappedTokenInfo) {
} else { if (currency.logoURI && !BAD_URIS[currency.logoURI]) {
uri = uriToHttp(currency.logoURI).filter(s => !BAD_URIS[s])[0]
}
}
if (!uri) {
const defaultUri = getTokenLogoURL(currency.address)
if (!BAD_URIS[defaultUri]) {
uri = defaultUri
}
}
if (uri) {
return ( return (
<Emoji {...rest} size={size}> <Image
<span role="img" aria-label="Thinking"> {...rest}
🤔 alt={`${currency.name} Logo`}
</span> src={uri}
</Emoji> size={size}
onError={() => {
if (currency instanceof Token) {
BAD_URIS[uri] = true
}
refresh(i => i + 1)
}}
/>
) )
} }
return (
<Image
{...rest}
alt={`${currency.name} Logo`}
src={path}
size={size}
onError={() => {
if (currency instanceof Token) {
NO_LOGO_ADDRESSES[currency.address] = true
}
refresh(i => i + 1)
}}
/>
)
} else {
return <StyledEthereumLogo src={EthereumLogo} size={size} {...rest} />
} }
return (
<Emoji {...rest} size={size}>
<span role="img" aria-label="Thinking">
🤔
</span>
</Emoji>
)
} }
import { TokenList, Version } from '@uniswap/token-lists'
import React, { useCallback, useContext } from 'react'
import { AlertCircle, Info } from 'react-feather'
import { useDispatch } from 'react-redux'
import { ThemeContext } from 'styled-components'
import { AppDispatch } from '../../state'
import { useRemovePopup } from '../../state/application/hooks'
import { acceptListUpdate } from '../../state/lists/actions'
import { TYPE } from '../../theme'
import { ButtonPrimary, ButtonSecondary } from '../Button'
import { AutoColumn } from '../Column'
import { AutoRow } from '../Row'
function versionLabel(version: Version): string {
return `v${version.major}.${version.minor}.${version.patch}`
}
export default function ListUpdatePopup({
popKey,
listUrl,
oldList,
newList,
auto
}: {
popKey: string
listUrl: string
oldList: TokenList
newList: TokenList
auto: boolean
}) {
const removePopup = useRemovePopup()
const removeThisPopup = useCallback(() => removePopup(popKey), [popKey, removePopup])
const dispatch = useDispatch<AppDispatch>()
const theme = useContext(ThemeContext)
const updateList = useCallback(() => {
if (auto) return
dispatch(acceptListUpdate(listUrl))
removeThisPopup()
}, [auto, dispatch, listUrl, removeThisPopup])
return (
<AutoRow>
<div style={{ paddingRight: 16 }}>
{auto ? <Info color={theme.text2} size={24} /> : <AlertCircle color={theme.red1} size={24} />}{' '}
</div>
<AutoColumn style={{ flex: '1' }} gap="8px">
{auto ? (
<TYPE.body fontWeight={500}>
The token list &quot;{oldList.name}&quot; has been updated to{' '}
<strong>{versionLabel(newList.version)}</strong>.
</TYPE.body>
) : (
<>
<div>
A token list update is available for the list &quot;{oldList.name}&quot; ({versionLabel(oldList.version)}{' '}
to {versionLabel(newList.version)}).
</div>
<AutoRow>
<div style={{ flexGrow: 1, marginRight: 6 }}>
<ButtonPrimary onClick={updateList}>Update list</ButtonPrimary>
</div>
<div style={{ flexGrow: 1 }}>
<ButtonSecondary onClick={removeThisPopup}>Dismiss</ButtonSecondary>
</div>
</AutoRow>
</>
)}
</AutoColumn>
</AutoRow>
)
}
import React, { useCallback, useContext, useState } from 'react' import React, { useCallback, useContext, useState } from 'react'
import { AlertCircle, CheckCircle } from 'react-feather' import { X } from 'react-feather'
import styled, { ThemeContext } from 'styled-components' import styled, { ThemeContext } from 'styled-components'
import { useActiveWeb3React } from '../../hooks'
import useInterval from '../../hooks/useInterval' import useInterval from '../../hooks/useInterval'
import { PopupContent } from '../../state/application/actions'
import { useRemovePopup } from '../../state/application/hooks' import { useRemovePopup } from '../../state/application/hooks'
import { TYPE } from '../../theme' import ListUpdatePopup from './ListUpdatePopup'
import TxnPopup from './TxnPopup'
import { ExternalLink } from '../../theme/components' export const StyledClose = styled(X)`
import { getEtherscanLink } from '../../utils' position: absolute;
import { AutoColumn } from '../Column' right: 10px;
import { AutoRow } from '../Row' top: 10px;
:hover {
cursor: pointer;
}
`
export const Popup = styled.div`
display: inline-block;
width: 100%;
padding: 1em;
background-color: ${({ theme }) => theme.bg1};
position: relative;
border-radius: 10px;
padding: 20px;
padding-right: 35px;
z-index: 2;
overflow: hidden;
${({ theme }) => theme.mediaWidth.upToSmall`
min-width: 290px;
`}
`
const DELAY = 100
const Fader = styled.div<{ count: number }>` const Fader = styled.div<{ count: number }>`
position: absolute; position: absolute;
bottom: 0px; bottom: 0px;
...@@ -23,20 +43,7 @@ const Fader = styled.div<{ count: number }>` ...@@ -23,20 +43,7 @@ const Fader = styled.div<{ count: number }>`
transition: width 100ms linear; transition: width 100ms linear;
` `
const delay = 100 export default function PopupItem({ content, popKey }: { content: PopupContent; popKey: string }) {
export default function TxnPopup({
hash,
success,
summary,
popKey
}: {
hash: string
success?: boolean
summary?: string
popKey?: string
}) {
const { chainId } = useActiveWeb3React()
const [count, setCount] = useState(1) const [count, setCount] = useState(1)
const [isRunning, setIsRunning] = useState(true) const [isRunning, setIsRunning] = useState(true)
...@@ -48,24 +55,32 @@ export default function TxnPopup({ ...@@ -48,24 +55,32 @@ export default function TxnPopup({
() => { () => {
count > 150 ? removeThisPopup() : setCount(count + 1) count > 150 ? removeThisPopup() : setCount(count + 1)
}, },
isRunning ? delay : null isRunning ? DELAY : null
) )
const theme = useContext(ThemeContext)
const handleMouseEnter = useCallback(() => setIsRunning(false), []) const handleMouseEnter = useCallback(() => setIsRunning(false), [])
const handleMouseLeave = useCallback(() => setIsRunning(true), []) const handleMouseLeave = useCallback(() => setIsRunning(true), [])
const theme = useContext(ThemeContext) let popupContent
if ('txn' in content) {
const {
txn: { hash, success, summary }
} = content
popupContent = <TxnPopup hash={hash} success={success} summary={summary} />
} else if ('listUpdate' in content) {
const {
listUpdate: { listUrl, oldList, newList, auto }
} = content
popupContent = <ListUpdatePopup popKey={popKey} listUrl={listUrl} oldList={oldList} newList={newList} auto={auto} />
}
return ( return (
<AutoRow onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}> <Popup onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<div style={{ paddingRight: 16 }}> <StyledClose color={theme.text2} onClick={() => removePopup(popKey)} />
{success ? <CheckCircle color={theme.green1} size={24} /> : <AlertCircle color={theme.red1} size={24} />} {popupContent}
</div>
<AutoColumn gap="8px">
<TYPE.body fontWeight={500}>{summary ?? 'Hash: ' + hash.slice(0, 8) + '...' + hash.slice(58, 65)}</TYPE.body>
<ExternalLink href={getEtherscanLink(chainId, hash, 'transaction')}>View on Etherscan</ExternalLink>
</AutoColumn>
<Fader count={count} /> <Fader count={count} />
</AutoRow> </Popup>
) )
} }
import React, { useContext } from 'react'
import { AlertCircle, CheckCircle } from 'react-feather'
import { ThemeContext } from 'styled-components'
import { useActiveWeb3React } from '../../hooks'
import { TYPE } from '../../theme'
import { ExternalLink } from '../../theme/components'
import { getEtherscanLink } from '../../utils'
import { AutoColumn } from '../Column'
import { AutoRow } from '../Row'
export default function TxnPopup({ hash, success, summary }: { hash: string; success?: boolean; summary?: string }) {
const { chainId } = useActiveWeb3React()
const theme = useContext(ThemeContext)
return (
<AutoRow>
<div style={{ paddingRight: 16 }}>
{success ? <CheckCircle color={theme.green1} size={24} /> : <AlertCircle color={theme.red1} size={24} />}
</div>
<AutoColumn gap="8px">
<TYPE.body fontWeight={500}>{summary ?? 'Hash: ' + hash.slice(0, 8) + '...' + hash.slice(58, 65)}</TYPE.body>
<ExternalLink href={getEtherscanLink(chainId, hash, 'transaction')}>View on Etherscan</ExternalLink>
</AutoColumn>
</AutoRow>
)
}
import React, { useContext } from 'react' import React from 'react'
import styled, { ThemeContext } from 'styled-components' import styled from 'styled-components'
import { useMediaLayout } from 'use-media' import { useMediaLayout } from 'use-media'
import { useActivePopups } from '../../state/application/hooks'
import { X } from 'react-feather'
import { PopupContent } from '../../state/application/actions'
import { useActivePopups, useRemovePopup } from '../../state/application/hooks'
import { AutoColumn } from '../Column' import { AutoColumn } from '../Column'
import TxnPopup from '../TxnPopup' import PopupItem from './PopupItem'
const StyledClose = styled(X)`
position: absolute;
right: 10px;
top: 10px;
:hover {
cursor: pointer;
}
`
const MobilePopupWrapper = styled.div<{ height: string | number }>` const MobilePopupWrapper = styled.div<{ height: string | number }>`
position: relative; position: relative;
...@@ -50,37 +37,9 @@ const FixedPopupColumn = styled(AutoColumn)` ...@@ -50,37 +37,9 @@ const FixedPopupColumn = styled(AutoColumn)`
`}; `};
` `
const Popup = styled.div`
display: inline-block;
width: 100%;
padding: 1em;
background-color: ${({ theme }) => theme.bg1};
position: relative;
border-radius: 10px;
padding: 20px;
padding-right: 35px;
z-index: 2;
overflow: hidden;
${({ theme }) => theme.mediaWidth.upToSmall`
min-width: 290px;
`}
`
function PopupItem({ content, popKey }: { content: PopupContent; popKey: string }) {
if ('txn' in content) {
const {
txn: { hash, success, summary }
} = content
return <TxnPopup popKey={popKey} hash={hash} success={success} summary={summary} />
}
}
export default function Popups() { export default function Popups() {
const theme = useContext(ThemeContext)
// get all popups // get all popups
const activePopups = useActivePopups() const activePopups = useActivePopups()
const removePopup = useRemovePopup()
// switch view settings on mobile // switch view settings on mobile
const isMobile = useMediaLayout({ maxWidth: '600px' }) const isMobile = useMediaLayout({ maxWidth: '600px' })
...@@ -88,14 +47,9 @@ export default function Popups() { ...@@ -88,14 +47,9 @@ export default function Popups() {
if (!isMobile) { if (!isMobile) {
return ( return (
<FixedPopupColumn gap="20px"> <FixedPopupColumn gap="20px">
{activePopups.map(item => { {activePopups.map(item => (
return ( <PopupItem key={item.key} content={item.content} popKey={item.key} />
<Popup key={item.key}> ))}
<StyledClose color={theme.text2} onClick={() => removePopup(item.key)} />
<PopupItem content={item.content} popKey={item.key} />
</Popup>
)
})}
</FixedPopupColumn> </FixedPopupColumn>
) )
} }
...@@ -107,14 +61,9 @@ export default function Popups() { ...@@ -107,14 +61,9 @@ export default function Popups() {
{activePopups // reverse so new items up front {activePopups // reverse so new items up front
.slice(0) .slice(0)
.reverse() .reverse()
.map(item => { .map(item => (
return ( <PopupItem key={item.key} content={item.content} popKey={item.key} />
<Popup key={item.key}> ))}
<StyledClose color={theme.text2} onClick={() => removePopup(item.key)} />
<PopupItem content={item.content} popKey={item.key} />
</Popup>
)
})}
</MobilePopupInner> </MobilePopupInner>
</MobilePopupWrapper> </MobilePopupWrapper>
) )
......
...@@ -30,7 +30,7 @@ export default function CommonBases({ ...@@ -30,7 +30,7 @@ export default function CommonBases({
onSelect, onSelect,
selectedCurrency selectedCurrency
}: { }: {
chainId: ChainId chainId?: ChainId
selectedCurrency?: Currency selectedCurrency?: Currency
onSelect: (currency: Currency) => void onSelect: (currency: Currency) => void
}) { }) {
...@@ -52,8 +52,8 @@ export default function CommonBases({ ...@@ -52,8 +52,8 @@ export default function CommonBases({
ETH ETH
</Text> </Text>
</BaseWrapper> </BaseWrapper>
{(SUGGESTED_BASES[chainId as ChainId] ?? []).map((token: Token) => { {(chainId ? SUGGESTED_BASES[chainId] : []).map((token: Token) => {
const selected = currencyEquals(selectedCurrency, token) const selected = selectedCurrency instanceof Token && selectedCurrency.address === token.address
return ( return (
<BaseWrapper onClick={() => !selected && onSelect(token)} disable={selected} key={token.address}> <BaseWrapper onClick={() => !selected && onSelect(token)} disable={selected} key={token.address}>
<CurrencyLogo currency={token} style={{ marginRight: 8 }} /> <CurrencyLogo currency={token} style={{ marginRight: 8 }} />
......
...@@ -5,6 +5,7 @@ import { Text } from 'rebass' ...@@ -5,6 +5,7 @@ import { Text } from 'rebass'
import { ThemeContext } from 'styled-components' import { ThemeContext } from 'styled-components'
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 { useAddUserToken, useRemoveUserAddedToken } from '../../state/user/hooks' import { useAddUserToken, useRemoveUserAddedToken } from '../../state/user/hooks'
import { useETHBalances } from '../../state/wallet/hooks' import { useETHBalances } from '../../state/wallet/hooks'
import { LinkStyledButton, TYPE } from '../../theme' import { LinkStyledButton, TYPE } from '../../theme'
...@@ -14,7 +15,7 @@ import { RowFixed } from '../Row' ...@@ -14,7 +15,7 @@ import { RowFixed } from '../Row'
import CurrencyLogo from '../CurrencyLogo' import CurrencyLogo from '../CurrencyLogo'
import { FadedSpan, MenuItem } from './styleds' import { FadedSpan, MenuItem } from './styleds'
import Loader from '../Loader' import Loader from '../Loader'
import { isDefaultToken, isCustomAddedToken } from '../../utils' import { isDefaultToken } from '../../utils'
function currencyKey(currency: Currency): string { function currencyKey(currency: Currency): string {
return currency instanceof Token ? currency.address : currency === ETHER ? 'ETHER' : '' return currency instanceof Token ? currency.address : currency === ETHER ? 'ETHER' : ''
...@@ -38,6 +39,7 @@ export default function CurrencyList({ ...@@ -38,6 +39,7 @@ export default function CurrencyList({
const { account, chainId } = useActiveWeb3React() const { account, chainId } = useActiveWeb3React()
const theme = useContext(ThemeContext) const theme = useContext(ThemeContext)
const allTokens = useAllTokens() const allTokens = useAllTokens()
const defaultTokens = useDefaultTokenList()
const addToken = useAddUserToken() const addToken = useAddUserToken()
const removeToken = useRemoveUserAddedToken() const removeToken = useRemoveUserAddedToken()
const ETHBalance = useETHBalances([account])[account] const ETHBalance = useETHBalances([account])[account]
...@@ -46,8 +48,8 @@ export default function CurrencyList({ ...@@ -46,8 +48,8 @@ export default function CurrencyList({
return memo(function CurrencyRow({ index, style }: { index: number; style: CSSProperties }) { return memo(function CurrencyRow({ index, style }: { index: number; style: CSSProperties }) {
const currency = index === 0 ? Currency.ETHER : currencies[index - 1] const currency = index === 0 ? Currency.ETHER : currencies[index - 1]
const key = currencyKey(currency) const key = currencyKey(currency)
const isDefault = isDefaultToken(currency) const isDefault = isDefaultToken(defaultTokens, currency)
const customAdded = isCustomAddedToken(allTokens, currency) const customAdded = Boolean(!isDefault && currency instanceof Token && allTokens[currency.address])
const balance = currency === ETHER ? ETHBalance : allBalances[key] const balance = currency === ETHER ? ETHBalance : allBalances[key]
const zeroBalance = balance && JSBI.equal(JSBI.BigInt(0), balance.raw) const zeroBalance = balance && JSBI.equal(JSBI.BigInt(0), balance.raw)
...@@ -129,6 +131,7 @@ export default function CurrencyList({ ...@@ -129,6 +131,7 @@ export default function CurrencyList({
allTokens, allTokens,
chainId, chainId,
currencies, currencies,
defaultTokens,
onCurrencySelect, onCurrencySelect,
otherCurrency, otherCurrency,
removeToken, removeToken,
......
...@@ -88,6 +88,9 @@ const MenuFlyout = styled.span` ...@@ -88,6 +88,9 @@ const MenuFlyout = styled.span`
background-color: ${({ theme }) => theme.bg1}; background-color: ${({ theme }) => theme.bg1};
box-shadow: 0px 0px 1px rgba(0, 0, 0, 0.01), 0px 4px 8px rgba(0, 0, 0, 0.04), 0px 16px 24px rgba(0, 0, 0, 0.04), box-shadow: 0px 0px 1px rgba(0, 0, 0, 0.01), 0px 4px 8px rgba(0, 0, 0, 0.04), 0px 16px 24px rgba(0, 0, 0, 0.04),
0px 24px 32px rgba(0, 0, 0, 0.01); 0px 24px 32px rgba(0, 0, 0, 0.01);
border: 1px solid ${({ theme }) => theme.bg3};
border-radius: 0.5rem; border-radius: 0.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
......
...@@ -5,6 +5,7 @@ import styled from 'styled-components' ...@@ -5,6 +5,7 @@ import styled from 'styled-components'
import { ReactComponent as Close } from '../../assets/images/x.svg' 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 { Field } from '../../state/swap/actions' import { Field } from '../../state/swap/actions'
import { useTokenWarningDismissal } from '../../state/user/hooks' import { useTokenWarningDismissal } from '../../state/user/hooks'
import { ExternalLink, TYPE } from '../../theme' import { ExternalLink, TYPE } from '../../theme'
...@@ -67,8 +68,8 @@ interface TokenWarningCardProps extends PropsOfExcluding<typeof Wrapper, 'error' ...@@ -67,8 +68,8 @@ interface TokenWarningCardProps extends PropsOfExcluding<typeof Wrapper, 'error'
export default function TokenWarningCard({ token, ...rest }: TokenWarningCardProps) { export default function TokenWarningCard({ token, ...rest }: TokenWarningCardProps) {
const { chainId } = useActiveWeb3React() const { chainId } = useActiveWeb3React()
const defaultTokens = useDefaultTokenList()
const isDefault = isDefaultToken(token) const isDefault = isDefaultToken(defaultTokens, token)
const tokenSymbol = token?.symbol?.toLowerCase() ?? '' const tokenSymbol = token?.symbol?.toLowerCase() ?? ''
const tokenName = token?.name?.toLowerCase() ?? '' const tokenName = token?.name?.toLowerCase() ?? ''
......
import { ChainId, JSBI, Percent, Token, WETH } from '@uniswap/sdk' import { ChainId, JSBI, Percent, Token, WETH } from '@uniswap/sdk'
import { fortmatic, injected, portis, walletconnect, walletlink } from '../connectors' import { fortmatic, injected, portis, walletconnect, walletlink } from '../connectors'
import { COMP, DAI, MKR, USDC, USDT } from './tokens/mainnet'
export const ROUTER_ADDRESS = '0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D' export const ROUTER_ADDRESS = '0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D'
...@@ -10,6 +9,12 @@ type ChainTokenList = { ...@@ -10,6 +9,12 @@ type ChainTokenList = {
readonly [chainId in ChainId]: Token[] readonly [chainId in ChainId]: Token[]
} }
export const DAI = new Token(ChainId.MAINNET, '0x6B175474E89094C44Da98b954EedeAC495271d0F', 18, 'DAI', 'Dai Stablecoin')
export const USDC = new Token(ChainId.MAINNET, '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', 6, 'USDC', 'USD//C')
export const USDT = new Token(ChainId.MAINNET, '0xdAC17F958D2ee523a2206206994597C13D831ec7', 6, 'USDT', 'Tether USD')
export const COMP = new Token(ChainId.MAINNET, '0xc00e94Cb662C3520282E6f5717214004A7f26888', 18, 'COMP', 'Compound')
export const MKR = new Token(ChainId.MAINNET, '0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2', 18, 'MKR', 'Maker')
const WETH_ONLY: ChainTokenList = { const WETH_ONLY: ChainTokenList = {
[ChainId.MAINNET]: [WETH[ChainId.MAINNET]], [ChainId.MAINNET]: [WETH[ChainId.MAINNET]],
[ChainId.ROPSTEN]: [WETH[ChainId.ROPSTEN]], [ChainId.ROPSTEN]: [WETH[ChainId.ROPSTEN]],
...@@ -142,3 +147,7 @@ export const BLOCKED_PRICE_IMPACT_NON_EXPERT: Percent = new Percent(JSBI.BigInt( ...@@ -142,3 +147,7 @@ export const BLOCKED_PRICE_IMPACT_NON_EXPERT: Percent = new Percent(JSBI.BigInt(
// used to ensure the user doesn't send so much ETH so they end up with <.01 // used to ensure the user doesn't send so much ETH so they end up with <.01
export const MIN_ETH: JSBI = JSBI.exponentiate(JSBI.BigInt(10), JSBI.BigInt(16)) // .01 ETH export const MIN_ETH: JSBI = JSBI.exponentiate(JSBI.BigInt(10), JSBI.BigInt(16)) // .01 ETH
export const BETTER_TRADE_LINK_THRESHOLD = new Percent(JSBI.BigInt(75), JSBI.BigInt(10000)) export const BETTER_TRADE_LINK_THRESHOLD = new Percent(JSBI.BigInt(75), JSBI.BigInt(10000))
// the Uniswap Default token list lives here
export const DEFAULT_TOKEN_LIST_URL =
'https://unpkg.com/@uniswap/default-token-list@latest/uniswap-default.tokenlist.json'
import { ChainId, Token, WETH } from '@uniswap/sdk'
import KOVAN_TOKENS from './kovan'
import MAINNET_TOKENS from './mainnet'
import RINKEBY_TOKENS from './rinkeby'
import ROPSTEN_TOKENS from './ropsten'
type AllTokens = Readonly<{ [chainId in ChainId]: Readonly<{ [tokenAddress: string]: Token }> }>
export const ALL_TOKENS: AllTokens = [
// WETH on all chains
...Object.values(WETH),
// chain-specific tokens
...MAINNET_TOKENS,
...RINKEBY_TOKENS,
...KOVAN_TOKENS,
...ROPSTEN_TOKENS
]
// put into an object
.reduce<AllTokens>(
(tokenMap, token) => {
if (tokenMap[token.chainId][token.address] !== undefined) throw Error('Duplicate tokens.')
return {
...tokenMap,
[token.chainId]: {
...tokenMap[token.chainId],
[token.address]: token
}
}
},
{
[ChainId.MAINNET]: {},
[ChainId.RINKEBY]: {},
[ChainId.GÖRLI]: {},
[ChainId.ROPSTEN]: {},
[ChainId.KOVAN]: {}
}
)
import { Token, ChainId } from '@uniswap/sdk'
export default [
new Token(ChainId.KOVAN, '0x4F96Fe3b7A6Cf9725f59d353F723c1bDb64CA6Aa', 18, 'DAI', 'Dai Stablecoin'),
new Token(ChainId.KOVAN, '0xAaF64BFCC32d0F15873a02163e7E500671a4ffcD', 18, 'MKR', 'Maker')
]
This diff is collapsed.
import { Token, ChainId } from '@uniswap/sdk'
export default [
new Token(ChainId.RINKEBY, '0xc7AD46e0b8a400Bb3C915120d284AafbA8fc4735', 18, 'DAI', 'Dai Stablecoin'),
new Token(ChainId.RINKEBY, '0xF9bA5210F91D0474bd1e1DcDAeC4C58E359AaD85', 18, 'MKR', 'Maker')
]
import { Token, ChainId } from '@uniswap/sdk'
export default [new Token(ChainId.ROPSTEN, '0xaD6D458402F60fD3Bd25163575031ACDce07538D', 18, 'DAI', 'Dai Stablecoin')]
import { parseBytes32String } from '@ethersproject/strings' import { parseBytes32String } from '@ethersproject/strings'
import { ChainId, Currency, ETHER, Token } from '@uniswap/sdk' import { Currency, ETHER, Token } from '@uniswap/sdk'
import { useMemo } from 'react' import { useMemo } from 'react'
import { ALL_TOKENS } from '../constants/tokens' import { useDefaultTokenList } from '../state/lists/hooks'
import { NEVER_RELOAD, useSingleCallResult } from '../state/multicall/hooks' import { NEVER_RELOAD, useSingleCallResult } from '../state/multicall/hooks'
import { useUserAddedTokens } from '../state/user/hooks' import { useUserAddedTokens } from '../state/user/hooks'
import { isAddress } from '../utils' import { isAddress } from '../utils'
...@@ -12,6 +12,7 @@ import { useBytes32TokenContract, useTokenContract } from './useContract' ...@@ -12,6 +12,7 @@ import { useBytes32TokenContract, useTokenContract } from './useContract'
export function useAllTokens(): { [address: string]: Token } { export function useAllTokens(): { [address: string]: Token } {
const { chainId } = useActiveWeb3React() const { chainId } = useActiveWeb3React()
const userAddedTokens = useUserAddedTokens() const userAddedTokens = useUserAddedTokens()
const allTokens = useDefaultTokenList()
return useMemo(() => { return useMemo(() => {
if (!chainId) return {} if (!chainId) return {}
...@@ -25,10 +26,10 @@ export function useAllTokens(): { [address: string]: Token } { ...@@ -25,10 +26,10 @@ export function useAllTokens(): { [address: string]: Token } {
}, },
// must make a copy because reduce modifies the map, and we do not // must make a copy because reduce modifies the map, and we do not
// want to make a copy in every iteration // want to make a copy in every iteration
{ ...ALL_TOKENS[chainId as ChainId] } { ...allTokens[chainId] }
) )
) )
}, [userAddedTokens, chainId]) }, [chainId, userAddedTokens, allTokens])
} }
// parse a name or symbol from a token response // parse a name or symbol from a token response
......
...@@ -72,21 +72,12 @@ export function useInactiveListener(suppress = false) { ...@@ -72,21 +72,12 @@ export function useInactiveListener(suppress = false) {
} }
} }
const handleNetworkChanged = () => {
// eat errors
activate(injected, undefined, true).catch(error => {
console.error('Failed to activate after networks changed', error)
})
}
ethereum.on('chainChanged', handleChainChanged) ethereum.on('chainChanged', handleChainChanged)
ethereum.on('networkChanged', handleNetworkChanged)
ethereum.on('accountsChanged', handleAccountsChanged) ethereum.on('accountsChanged', handleAccountsChanged)
return () => { return () => {
if (ethereum.removeListener) { if (ethereum.removeListener) {
ethereum.removeListener('chainChanged', handleChainChanged) ethereum.removeListener('chainChanged', handleChainChanged)
ethereum.removeListener('networkChanged', handleNetworkChanged)
ethereum.removeListener('accountsChanged', handleAccountsChanged) ethereum.removeListener('accountsChanged', handleAccountsChanged)
} }
} }
......
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
const VISIBILITY_STATE_SUPPORTED = 'visibilityState' in document
function isWindowVisible() {
return !VISIBILITY_STATE_SUPPORTED || document.visibilityState !== 'hidden'
}
/** /**
* Returns whether the window is currently visible to the user. * Returns whether the window is currently visible to the user.
*/ */
export default function useIsWindowVisible(): boolean { export default function useIsWindowVisible(): boolean {
const [focused, setFocused] = useState<boolean>(true) const [focused, setFocused] = useState<boolean>(isWindowVisible())
const listener = useCallback(() => { const listener = useCallback(() => {
setFocused(document.visibilityState !== 'hidden') setFocused(isWindowVisible())
}, [setFocused]) }, [setFocused])
useEffect(() => { useEffect(() => {
if (!VISIBILITY_STATE_SUPPORTED) return
document.addEventListener('visibilitychange', listener) document.addEventListener('visibilitychange', listener)
return () => { return () => {
document.removeEventListener('visibilitychange', listener) document.removeEventListener('visibilitychange', listener)
......
...@@ -42,8 +42,12 @@ export default function useWrapCallback( ...@@ -42,8 +42,12 @@ export default function useWrapCallback(
execute: execute:
sufficientBalance && inputAmount sufficientBalance && inputAmount
? async () => { ? async () => {
const txReceipt = await wethContract.deposit({ value: `0x${inputAmount.raw.toString(16)}` }) try {
addTransaction(txReceipt, { summary: `Wrap ${inputAmount.toSignificant(6)} ETH to WETH` }) const txReceipt = await wethContract.deposit({ value: `0x${inputAmount.raw.toString(16)}` })
addTransaction(txReceipt, { summary: `Wrap ${inputAmount.toSignificant(6)} ETH to WETH` })
} catch (error) {
console.error('Could not deposit', error)
}
} }
: undefined, : undefined,
error: sufficientBalance ? undefined : 'Insufficient ETH balance' error: sufficientBalance ? undefined : 'Insufficient ETH balance'
...@@ -54,8 +58,12 @@ export default function useWrapCallback( ...@@ -54,8 +58,12 @@ export default function useWrapCallback(
execute: execute:
sufficientBalance && inputAmount sufficientBalance && inputAmount
? async () => { ? async () => {
const txReceipt = await wethContract.withdraw(`0x${inputAmount.raw.toString(16)}`) try {
addTransaction(txReceipt, { summary: `Unwrap ${inputAmount.toSignificant(6)} WETH to ETH` }) const txReceipt = await wethContract.withdraw(`0x${inputAmount.raw.toString(16)}`)
addTransaction(txReceipt, { summary: `Unwrap ${inputAmount.toSignificant(6)} WETH to ETH` })
} catch (error) {
console.error('Could not withdraw', error)
}
} }
: undefined, : undefined,
error: sufficientBalance ? undefined : 'Insufficient WETH balance' error: sufficientBalance ? undefined : 'Insufficient WETH balance'
......
...@@ -12,12 +12,17 @@ import App from './pages/App' ...@@ -12,12 +12,17 @@ import App from './pages/App'
import store from './state' import store from './state'
import ApplicationUpdater from './state/application/updater' import ApplicationUpdater from './state/application/updater'
import TransactionUpdater from './state/transactions/updater' import TransactionUpdater from './state/transactions/updater'
import ListsUpdater from './state/lists/updater'
import UserUpdater from './state/user/updater' import UserUpdater from './state/user/updater'
import MulticallUpdater from './state/multicall/updater' import MulticallUpdater from './state/multicall/updater'
import ThemeProvider, { FixedGlobalStyle, ThemedGlobalStyle } from './theme' import ThemeProvider, { FixedGlobalStyle, ThemedGlobalStyle } from './theme'
const Web3ProviderNetwork = createWeb3ReactRoot(NetworkContextName) const Web3ProviderNetwork = createWeb3ReactRoot(NetworkContextName)
if ('ethereum' in window) {
;(window.ethereum as any).autoRefreshOnNetworkChange = false
}
function getLibrary(provider: any): Web3Provider { function getLibrary(provider: any): Web3Provider {
const library = new Web3Provider(provider) const library = new Web3Provider(provider)
library.pollingInterval = 15000 library.pollingInterval = 15000
...@@ -44,6 +49,7 @@ window.addEventListener('error', error => { ...@@ -44,6 +49,7 @@ window.addEventListener('error', error => {
function Updaters() { function Updaters() {
return ( return (
<> <>
<ListsUpdater />
<UserUpdater /> <UserUpdater />
<ApplicationUpdater /> <ApplicationUpdater />
<TransactionUpdater /> <TransactionUpdater />
......
...@@ -6,7 +6,8 @@ import { AutoRow } from '../../components/Row' ...@@ -6,7 +6,8 @@ import { AutoRow } from '../../components/Row'
import { SearchInput } from '../../components/SearchModal/styleds' import { SearchInput } from '../../components/SearchModal/styleds'
import { useAllTokenV1Exchanges } from '../../data/V1' import { useAllTokenV1Exchanges } from '../../data/V1'
import { useActiveWeb3React } from '../../hooks' import { useActiveWeb3React } from '../../hooks'
import { useToken, useAllTokens } from '../../hooks/Tokens' import { useAllTokens, useToken } from '../../hooks/Tokens'
import { useDefaultTokenList } from '../../state/lists/hooks'
import { useTokenBalancesWithLoadingIndicator } from '../../state/wallet/hooks' import { useTokenBalancesWithLoadingIndicator } from '../../state/wallet/hooks'
import { BackArrow, TYPE } from '../../theme' import { BackArrow, TYPE } from '../../theme'
import { LightCard } from '../../components/Card' import { LightCard } from '../../components/Card'
...@@ -16,7 +17,7 @@ import V1PositionCard from '../../components/PositionCard/V1' ...@@ -16,7 +17,7 @@ import V1PositionCard from '../../components/PositionCard/V1'
import QuestionHelper from '../../components/QuestionHelper' import QuestionHelper from '../../components/QuestionHelper'
import { Dots } from '../../components/swap/styleds' import { Dots } from '../../components/swap/styleds'
import { useAddUserToken } from '../../state/user/hooks' import { useAddUserToken } from '../../state/user/hooks'
import { isDefaultToken, isCustomAddedToken } from '../../utils' import { isDefaultToken } from '../../utils'
export default function MigrateV1() { export default function MigrateV1() {
const theme = useContext(ThemeContext) const theme = useContext(ThemeContext)
...@@ -27,15 +28,15 @@ export default function MigrateV1() { ...@@ -27,15 +28,15 @@ export default function MigrateV1() {
// automatically add the search token // automatically add the search token
const token = useToken(tokenSearch) const token = useToken(tokenSearch)
const isDefault = isDefaultToken(token) const defaultTokens = useDefaultTokenList()
const isDefault = isDefaultToken(defaultTokens, token)
const allTokens = useAllTokens() const allTokens = useAllTokens()
const isCustomAdded = isCustomAddedToken(allTokens, token)
const addToken = useAddUserToken() const addToken = useAddUserToken()
useEffect(() => { useEffect(() => {
if (token && !isDefault && !isCustomAdded) { if (token && !isDefault && !allTokens[token.address]) {
addToken(token) addToken(token)
} }
}, [token, isDefault, isCustomAdded, addToken]) }, [token, isDefault, addToken, allTokens])
// get V1 LP balances // get V1 LP balances
const V1Exchanges = useAllTokenV1Exchanges() const V1Exchanges = useAllTokenV1Exchanges()
......
import { createAction } from '@reduxjs/toolkit' import { createAction } from '@reduxjs/toolkit'
import { TokenList } from '@uniswap/token-lists'
export type PopupContent = { export type PopupContent =
txn: { | {
hash: string txn: {
success: boolean hash: string
summary?: string success: boolean
} summary?: string
} }
}
| {
listUpdate: {
listUrl: string
oldList: TokenList
newList: TokenList
auto: boolean
}
}
export const updateBlockNumber = createAction<{ chainId: number; blockNumber: number }>('updateBlockNumber') export const updateBlockNumber = createAction<{ chainId: number; blockNumber: number }>('updateBlockNumber')
export const toggleWalletModal = createAction<void>('toggleWalletModal') export const toggleWalletModal = createAction<void>('toggleWalletModal')
......
import { ChainId } from '@uniswap/sdk'
import { createStore, Store } from 'redux'
import { addPopup, removePopup, toggleSettingsMenu, toggleWalletModal, updateBlockNumber } from './actions'
import reducer, { ApplicationState } from './reducer'
describe('application reducer', () => {
let store: Store<ApplicationState>
beforeEach(() => {
store = createStore(reducer, {
popupList: [],
walletModalOpen: false,
settingsMenuOpen: false,
blockNumber: {
[ChainId.MAINNET]: 3
}
})
})
describe('addPopup', () => {
it('adds the popup to list with a generated id', () => {
store.dispatch(addPopup({ content: { txn: { hash: 'abc', summary: 'test', success: true } } }))
const list = store.getState().popupList
expect(list).toHaveLength(1)
expect(typeof list[0].key).toEqual('string')
expect(list[0].show).toEqual(true)
expect(list[0].content).toEqual({ txn: { hash: 'abc', summary: 'test', success: true } })
})
it('replaces any existing popups with the same key', () => {
store.dispatch(addPopup({ key: 'abc', content: { txn: { hash: 'abc', summary: 'test', success: true } } }))
store.dispatch(addPopup({ key: 'abc', content: { txn: { hash: 'def', summary: 'test2', success: false } } }))
const list = store.getState().popupList
expect(list).toHaveLength(1)
expect(list[0].key).toEqual('abc')
expect(list[0].show).toEqual(true)
expect(list[0].content).toEqual({ txn: { hash: 'def', summary: 'test2', success: false } })
})
})
describe('toggleWalletModal', () => {
it('toggles wallet modal', () => {
store.dispatch(toggleWalletModal())
expect(store.getState().walletModalOpen).toEqual(true)
store.dispatch(toggleWalletModal())
expect(store.getState().walletModalOpen).toEqual(false)
store.dispatch(toggleWalletModal())
expect(store.getState().walletModalOpen).toEqual(true)
})
})
describe('settingsMenuOpen', () => {
it('toggles settings menu', () => {
store.dispatch(toggleSettingsMenu())
expect(store.getState().settingsMenuOpen).toEqual(true)
store.dispatch(toggleSettingsMenu())
expect(store.getState().settingsMenuOpen).toEqual(false)
store.dispatch(toggleSettingsMenu())
expect(store.getState().settingsMenuOpen).toEqual(true)
})
})
describe('updateBlockNumber', () => {
it('updates block number', () => {
store.dispatch(updateBlockNumber({ chainId: ChainId.MAINNET, blockNumber: 4 }))
expect(store.getState().blockNumber[ChainId.MAINNET]).toEqual(4)
})
it('no op if late', () => {
store.dispatch(updateBlockNumber({ chainId: ChainId.MAINNET, blockNumber: 2 }))
expect(store.getState().blockNumber[ChainId.MAINNET]).toEqual(3)
})
it('works with non-set chains', () => {
store.dispatch(updateBlockNumber({ chainId: ChainId.ROPSTEN, blockNumber: 2 }))
expect(store.getState().blockNumber).toEqual({
[ChainId.MAINNET]: 3,
[ChainId.ROPSTEN]: 2
})
})
})
describe('removePopup', () => {
beforeEach(() => {
store.dispatch(addPopup({ key: 'abc', content: { txn: { hash: 'abc', summary: 'test', success: true } } }))
})
it('hides the popup', () => {
expect(store.getState().popupList[0].show).toBe(true)
store.dispatch(removePopup({ key: 'abc' }))
expect(store.getState().popupList).toHaveLength(1)
expect(store.getState().popupList[0].show).toBe(false)
})
})
})
...@@ -10,7 +10,7 @@ import { ...@@ -10,7 +10,7 @@ import {
type PopupList = Array<{ key: string; show: boolean; content: PopupContent }> type PopupList = Array<{ key: string; show: boolean; content: PopupContent }>
interface ApplicationState { export interface ApplicationState {
blockNumber: { [chainId: number]: number } blockNumber: { [chainId: number]: number }
popupList: PopupList popupList: PopupList
walletModalOpen: boolean walletModalOpen: boolean
...@@ -41,12 +41,13 @@ export default createReducer(initialState, builder => ...@@ -41,12 +41,13 @@ export default createReducer(initialState, builder =>
state.settingsMenuOpen = !state.settingsMenuOpen state.settingsMenuOpen = !state.settingsMenuOpen
}) })
.addCase(addPopup, (state, { payload: { content, key } }) => { .addCase(addPopup, (state, { payload: { content, key } }) => {
if (key && state.popupList.some(popup => popup.key === key)) return state.popupList = (key ? state.popupList.filter(popup => popup.key !== key) : state.popupList).concat([
state.popupList.push({ {
key: key || nanoid(), key: key || nanoid(),
show: true, show: true,
content content
}) }
])
}) })
.addCase(removePopup, (state, { payload: { key } }) => { .addCase(removePopup, (state, { payload: { key } }) => {
state.popupList.forEach(p => { state.popupList.forEach(p => {
......
...@@ -6,12 +6,13 @@ import user from './user/reducer' ...@@ -6,12 +6,13 @@ import user from './user/reducer'
import transactions from './transactions/reducer' import transactions from './transactions/reducer'
import swap from './swap/reducer' import swap from './swap/reducer'
import mint from './mint/reducer' import mint from './mint/reducer'
import lists from './lists/reducer'
import burn from './burn/reducer' import burn from './burn/reducer'
import multicall from './multicall/reducer' import multicall from './multicall/reducer'
import { updateVersion } from './user/actions' import { updateVersion } from './user/actions'
const PERSISTED_KEYS: string[] = ['user', 'transactions'] const PERSISTED_KEYS: string[] = ['user', 'transactions', 'lists']
const store = configureStore({ const store = configureStore({
reducer: { reducer: {
...@@ -21,7 +22,8 @@ const store = configureStore({ ...@@ -21,7 +22,8 @@ const store = configureStore({
swap, swap,
mint, mint,
burn, burn,
multicall multicall,
lists
}, },
middleware: [...getDefaultMiddleware(), save({ states: PERSISTED_KEYS })], middleware: [...getDefaultMiddleware(), save({ states: PERSISTED_KEYS })],
preloadedState: load({ states: PERSISTED_KEYS }) preloadedState: load({ states: PERSISTED_KEYS })
......
import { createAction, createAsyncThunk } from '@reduxjs/toolkit'
import { TokenList, Version } from '@uniswap/token-lists'
import schema from '@uniswap/token-lists/src/tokenlist.schema.json'
import Ajv from 'ajv'
import uriToHttp from '../../utils/uriToHttp'
const tokenListValidator = new Ajv({ allErrors: true }).compile(schema)
/**
* Contains the logic for resolving a URL to a valid token list
* @param listUrl list url
*/
async function getTokenList(listUrl: string): Promise<TokenList> {
const urls = uriToHttp(listUrl)
for (const url of urls) {
let response
try {
response = await fetch(url)
if (!response.ok) continue
} catch (error) {
console.error(`failed to fetch list ${listUrl} at uri ${url}`)
continue
}
const json = await response.json()
if (!tokenListValidator(json)) {
throw new Error(
tokenListValidator.errors?.reduce<string>((memo, error) => {
const add = `${error.dataPath} ${error.message ?? ''}`
return memo.length > 0 ? `${memo}; ${add}` : `${add}`
}, '') ?? 'Token list failed validation'
)
}
return json
}
throw new Error('Unrecognized list URL protocol.')
}
const fetchCache: { [url: string]: Promise<TokenList> } = {}
export const fetchTokenList = createAsyncThunk<TokenList, string>(
'lists/fetchTokenList',
(url: string) =>
// this makes it so we only ever fetch a list a single time concurrently
(fetchCache[url] =
fetchCache[url] ??
getTokenList(url).catch(error => {
delete fetchCache[url]
throw error
}))
)
export const acceptListUpdate = createAction<string>('lists/acceptListUpdate')
export const addList = createAction<string>('lists/addList')
export const rejectVersionUpdate = createAction<Version>('lists/rejectVersionUpdate')
import { ChainId, Token } from '@uniswap/sdk'
import { TokenInfo, TokenList } from '@uniswap/token-lists'
import { useMemo } from 'react'
import { useSelector } from 'react-redux'
import { DEFAULT_TOKEN_LIST_URL } from '../../constants'
import { AppState } from '../index'
/**
* Token instances created from token info.
*/
export class WrappedTokenInfo extends Token {
public readonly tokenInfo: TokenInfo
constructor(tokenInfo: TokenInfo) {
super(tokenInfo.chainId, tokenInfo.address, tokenInfo.decimals, tokenInfo.symbol, tokenInfo.name)
this.tokenInfo = tokenInfo
}
public get logoURI(): string | undefined {
return this.tokenInfo.logoURI
}
}
export type TokenAddressMap = Readonly<{ [chainId in ChainId]: Readonly<{ [tokenAddress: string]: WrappedTokenInfo }> }>
/**
* An empty result, useful as a default.
*/
const EMPTY_LIST: TokenAddressMap = {
[ChainId.KOVAN]: {},
[ChainId.RINKEBY]: {},
[ChainId.ROPSTEN]: {},
[ChainId.GÖRLI]: {},
[ChainId.MAINNET]: {}
}
const listCache: WeakMap<TokenList, TokenAddressMap> | null =
'WeakMap' in window ? new WeakMap<TokenList, TokenAddressMap>() : null
export function listToTokenMap(list: TokenList): TokenAddressMap {
const result = listCache?.get(list)
if (result) return result
const map = list.tokens.reduce<TokenAddressMap>(
(tokenMap, tokenInfo) => {
const token = new WrappedTokenInfo(tokenInfo)
if (tokenMap[token.chainId][token.address] !== undefined) throw Error('Duplicate tokens.')
return {
...tokenMap,
[token.chainId]: {
...tokenMap[token.chainId],
[token.address]: token
}
}
},
{ ...EMPTY_LIST }
)
listCache?.set(list, map)
return map
}
export function useTokenList(url: string): TokenAddressMap {
const lists = useSelector<AppState, AppState['lists']['byUrl']>(state => state.lists.byUrl)
return useMemo(() => {
const current = lists[url]?.current
if (!current) return EMPTY_LIST
return listToTokenMap(current)
}, [lists, url])
}
export function useDefaultTokenList(): TokenAddressMap {
return useTokenList(DEFAULT_TOKEN_LIST_URL)
}
// returns all downloaded current lists
export function useAllLists(): TokenList[] {
const lists = useSelector<AppState, AppState['lists']['byUrl']>(state => state.lists.byUrl)
return useMemo(
() =>
Object.keys(lists)
.map(url => lists[url].current)
.filter((l): l is TokenList => Boolean(l)),
[lists]
)
}
import { createStore, Store } from 'redux'
import { fetchTokenList, acceptListUpdate, addList } from './actions'
import reducer, { ListsState } from './reducer'
const STUB_TOKEN_LIST = {
name: '',
timestamp: '',
version: { major: 1, minor: 1, patch: 1 },
tokens: []
}
const PATCHED_STUB_LIST = {
...STUB_TOKEN_LIST,
version: { ...STUB_TOKEN_LIST.version, patch: STUB_TOKEN_LIST.version.patch + 1 }
}
const MINOR_UPDATED_STUB_LIST = {
...STUB_TOKEN_LIST,
version: { ...STUB_TOKEN_LIST.version, minor: STUB_TOKEN_LIST.version.minor + 1 }
}
const MAJOR_UPDATED_STUB_LIST = {
...STUB_TOKEN_LIST,
version: { ...STUB_TOKEN_LIST.version, major: STUB_TOKEN_LIST.version.major + 1 }
}
describe('list reducer', () => {
let store: Store<ListsState>
beforeEach(() => {
store = createStore(reducer, {
byUrl: {}
})
})
describe('fetchTokenList', () => {
describe('pending', () => {
it('sets pending', () => {
store.dispatch(fetchTokenList.pending('request-id', 'fake-url'))
expect(store.getState()).toEqual({
byUrl: {
'fake-url': {
error: null,
loadingRequestId: 'request-id',
current: null,
pendingUpdate: null
}
}
})
})
it('does not clear current list', () => {
store = createStore(reducer, {
byUrl: {
'fake-url': {
error: null,
current: STUB_TOKEN_LIST,
pendingUpdate: null,
loadingRequestId: null
}
}
})
store.dispatch(fetchTokenList.pending('request-id', 'fake-url'))
expect(store.getState()).toEqual({
byUrl: {
'fake-url': {
error: null,
current: STUB_TOKEN_LIST,
loadingRequestId: 'request-id',
pendingUpdate: null
}
}
})
})
})
describe('fulfilled', () => {
it('saves the list', () => {
store.dispatch(fetchTokenList.fulfilled(STUB_TOKEN_LIST, 'request-id', 'fake-url'))
expect(store.getState()).toEqual({
byUrl: {
'fake-url': {
error: null,
current: STUB_TOKEN_LIST,
loadingRequestId: null,
pendingUpdate: null
}
}
})
})
it('does not save the list in pending if current is same', () => {
store.dispatch(fetchTokenList.fulfilled(STUB_TOKEN_LIST, 'request-id', 'fake-url'))
store.dispatch(fetchTokenList.fulfilled(STUB_TOKEN_LIST, 'request-id', 'fake-url'))
expect(store.getState()).toEqual({
byUrl: {
'fake-url': {
error: null,
current: STUB_TOKEN_LIST,
loadingRequestId: null,
pendingUpdate: null
}
}
})
})
it('does not save to current if list is newer patch version', () => {
store.dispatch(fetchTokenList.fulfilled(STUB_TOKEN_LIST, 'request-id', 'fake-url'))
store.dispatch(fetchTokenList.fulfilled(PATCHED_STUB_LIST, 'request-id', 'fake-url'))
expect(store.getState()).toEqual({
byUrl: {
'fake-url': {
error: null,
current: STUB_TOKEN_LIST,
loadingRequestId: null,
pendingUpdate: PATCHED_STUB_LIST
}
}
})
})
it('does not save to current if list is newer minor version', () => {
store.dispatch(fetchTokenList.fulfilled(STUB_TOKEN_LIST, 'request-id', 'fake-url'))
store.dispatch(fetchTokenList.fulfilled(MINOR_UPDATED_STUB_LIST, 'request-id', 'fake-url'))
expect(store.getState()).toEqual({
byUrl: {
'fake-url': {
error: null,
current: STUB_TOKEN_LIST,
loadingRequestId: null,
pendingUpdate: MINOR_UPDATED_STUB_LIST
}
}
})
})
it('does not save to pending if list is newer major version', () => {
store.dispatch(fetchTokenList.fulfilled(STUB_TOKEN_LIST, 'request-id', 'fake-url'))
store.dispatch(fetchTokenList.fulfilled(MAJOR_UPDATED_STUB_LIST, 'request-id', 'fake-url'))
expect(store.getState()).toEqual({
byUrl: {
'fake-url': {
error: null,
current: STUB_TOKEN_LIST,
loadingRequestId: null,
pendingUpdate: MAJOR_UPDATED_STUB_LIST
}
}
})
})
})
describe('rejected', () => {
it('no-op if not loading', () => {
store.dispatch(fetchTokenList.rejected(new Error('abcd'), 'request-id', 'fake-url'))
expect(store.getState()).toEqual({
byUrl: {}
})
})
it('sets the error if loading', () => {
store = createStore(reducer, {
byUrl: {
'fake-url': {
error: null,
current: null,
loadingRequestId: 'request-id',
pendingUpdate: null
}
}
})
store.dispatch(fetchTokenList.rejected(new Error('abcd'), 'request-id', 'fake-url'))
expect(store.getState()).toEqual({
byUrl: {
'fake-url': {
error: 'abcd',
current: null,
loadingRequestId: null,
pendingUpdate: null
}
}
})
})
})
})
describe('addList', () => {
it('adds the list key to byUrl', () => {
store.dispatch(addList('list-id'))
expect(store.getState()).toEqual({
byUrl: {
'list-id': {
error: null,
current: null,
loadingRequestId: null,
pendingUpdate: null
}
}
})
})
it('no op for existing list', () => {
store = createStore(reducer, {
byUrl: {
'fake-url': {
error: null,
current: STUB_TOKEN_LIST,
loadingRequestId: null,
pendingUpdate: null
}
}
})
store.dispatch(addList('fake-url'))
expect(store.getState()).toEqual({
byUrl: {
'fake-url': {
error: null,
current: STUB_TOKEN_LIST,
loadingRequestId: null,
pendingUpdate: null
}
}
})
})
})
describe('acceptListUpdate', () => {
it('swaps pending update into current', () => {
store = createStore(reducer, {
byUrl: {
'fake-url': {
error: null,
current: STUB_TOKEN_LIST,
loadingRequestId: null,
pendingUpdate: PATCHED_STUB_LIST
}
}
})
store.dispatch(acceptListUpdate('fake-url'))
expect(store.getState()).toEqual({
byUrl: {
'fake-url': {
error: null,
current: PATCHED_STUB_LIST,
loadingRequestId: null,
pendingUpdate: null
}
}
})
})
})
})
import { createReducer } from '@reduxjs/toolkit'
import { getVersionUpgrade, VersionUpgrade } from '@uniswap/token-lists'
import { TokenList } from '@uniswap/token-lists/dist/types'
import { acceptListUpdate, addList, fetchTokenList } from './actions'
export interface ListsState {
readonly byUrl: {
readonly [url: string]: {
readonly current: TokenList | null
readonly pendingUpdate: TokenList | null
readonly loadingRequestId: string | null
readonly error: string | null
}
}
}
const initialState: ListsState = {
byUrl: {}
}
export default createReducer(initialState, builder =>
builder
.addCase(fetchTokenList.pending, (state, { meta: { arg: url, requestId } }) => {
state.byUrl[url] = {
current: null,
pendingUpdate: null,
...state.byUrl[url],
loadingRequestId: requestId,
error: null
}
})
.addCase(fetchTokenList.fulfilled, (state, { payload: tokenList, meta: { arg: url } }) => {
const current = state.byUrl[url]?.current
// no-op if update does nothing
if (current) {
const type = getVersionUpgrade(current.version, tokenList.version)
if (type === VersionUpgrade.NONE) return
state.byUrl[url] = {
...state.byUrl[url],
loadingRequestId: null,
error: null,
current: current,
pendingUpdate: tokenList
}
} else {
state.byUrl[url] = {
...state.byUrl[url],
loadingRequestId: null,
error: null,
current: tokenList,
pendingUpdate: null
}
}
})
.addCase(fetchTokenList.rejected, (state, { error, meta: { requestId, arg: url } }) => {
if (state.byUrl[url]?.loadingRequestId !== requestId) {
// no-op since it's not the latest request
return
}
state.byUrl[url] = {
...state.byUrl[url],
loadingRequestId: null,
error: error.message ?? 'Unknown error',
current: null,
pendingUpdate: null
}
})
.addCase(addList, (state, { payload: url }) => {
if (!state.byUrl[url]) {
state.byUrl[url] = {
loadingRequestId: null,
pendingUpdate: null,
current: null,
error: null
}
}
})
.addCase(acceptListUpdate, (state, { payload: url }) => {
if (!state.byUrl[url]?.pendingUpdate) {
throw new Error('accept list update called without pending update')
}
state.byUrl[url] = {
...state.byUrl[url],
pendingUpdate: null,
current: state.byUrl[url].pendingUpdate
}
})
)
import { getVersionUpgrade, minVersionBump, VersionUpgrade } from '@uniswap/token-lists'
import { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { DEFAULT_TOKEN_LIST_URL } from '../../constants'
import { addPopup } from '../application/actions'
import { AppDispatch, AppState } from '../index'
import { acceptListUpdate, addList, fetchTokenList } from './actions'
export default function Updater(): null {
const dispatch = useDispatch<AppDispatch>()
const lists = useSelector<AppState, AppState['lists']['byUrl']>(state => state.lists.byUrl)
// we should always fetch the default token list, so add it
useEffect(() => {
if (!lists[DEFAULT_TOKEN_LIST_URL]) dispatch(addList(DEFAULT_TOKEN_LIST_URL))
}, [dispatch, lists])
// on initial mount, refetch all the lists in storage
useEffect(() => {
Object.keys(lists).forEach(listUrl => dispatch(fetchTokenList(listUrl) as any))
// we only do this once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch])
// whenever a list is not loaded and not loading, try again to load it
useEffect(() => {
Object.keys(lists).forEach(listUrl => {
const list = lists[listUrl]
if (!list.current && !list.loadingRequestId && !list.error) {
dispatch(fetchTokenList(listUrl) as any)
}
})
}, [dispatch, lists])
// automatically update lists if versions are minor/patch
useEffect(() => {
Object.keys(lists).forEach(listUrl => {
const list = lists[listUrl]
if (list.current && list.pendingUpdate) {
const bump = getVersionUpgrade(list.current.version, list.pendingUpdate.version)
switch (bump) {
case VersionUpgrade.NONE:
throw new Error('unexpected no version bump')
case VersionUpgrade.PATCH:
case VersionUpgrade.MINOR:
case VersionUpgrade.MAJOR:
const min = minVersionBump(list.current.tokens, list.pendingUpdate.tokens)
// automatically update minor/patch as long as bump matches the min update
if (bump >= min) {
dispatch(acceptListUpdate(listUrl))
dispatch(
addPopup({
key: listUrl,
content: {
listUpdate: {
listUrl,
oldList: list.current,
newList: list.pendingUpdate,
auto: true
}
}
})
)
} else {
console.error(
`List at url ${listUrl} could not automatically update because the version bump was only PATCH/MINOR while the update had breaking changes and should have been MAJOR`
)
}
break
// this will be turned on later
// case VersionUpgrade.MAJOR:
// dispatch(
// addPopup({
// key: listUrl,
// content: {
// listUpdate: {
// listUrl,
// auto: false,
// oldList: list.current,
// newList: list.pendingUpdate
// }
// }
// })
// )
}
}
})
}, [dispatch, lists])
return null
}
...@@ -13,7 +13,7 @@ export default function Updater() { ...@@ -13,7 +13,7 @@ export default function Updater() {
const dispatch = useDispatch<AppDispatch>() const dispatch = useDispatch<AppDispatch>()
const transactions = useSelector<AppState, AppState['transactions']>(state => state.transactions) const transactions = useSelector<AppState, AppState['transactions']>(state => state.transactions)
const allTransactions = transactions[chainId ?? -1] ?? {} const allTransactions = chainId ? transactions[chainId] ?? {} : {}
// show popup on confirm // show popup on confirm
const addPopup = useAddPopup() const addPopup = useAddPopup()
......
...@@ -214,7 +214,7 @@ export function useTrackedTokenPairs(): [Token, Token][] { ...@@ -214,7 +214,7 @@ export function useTrackedTokenPairs(): [Token, Token][] {
(BASES_TO_TRACK_LIQUIDITY_FOR[chainId] ?? []) (BASES_TO_TRACK_LIQUIDITY_FOR[chainId] ?? [])
// to construct pairs of the given token with each base // to construct pairs of the given token with each base
.map(base => { .map(base => {
if (base.equals(token)) { if (base.address === token.address) {
return null return null
} else { } else {
return [base, token] return [base, token]
......
...@@ -20,7 +20,11 @@ describe('swap reducer', () => { ...@@ -20,7 +20,11 @@ describe('swap reducer', () => {
expect(store.getState().lastUpdateVersionTimestamp).toBeGreaterThanOrEqual(time) expect(store.getState().lastUpdateVersionTimestamp).toBeGreaterThanOrEqual(time)
}) })
it('sets allowed slippage and deadline', () => { it('sets allowed slippage and deadline', () => {
store = createStore(reducer, { ...initialState, userDeadline: undefined, userSlippageTolerance: undefined }) store = createStore(reducer, {
...initialState,
userDeadline: undefined,
userSlippageTolerance: undefined
} as any)
store.dispatch(updateVersion()) store.dispatch(updateVersion())
expect(store.getState().userDeadline).toEqual(DEFAULT_DEADLINE_FROM_NOW) expect(store.getState().userDeadline).toEqual(DEFAULT_DEADLINE_FROM_NOW)
expect(store.getState().userSlippageTolerance).toEqual(INITIAL_ALLOWED_SLIPPAGE) expect(store.getState().userSlippageTolerance).toEqual(INITIAL_ALLOWED_SLIPPAGE)
......
...@@ -5,8 +5,8 @@ import { JsonRpcSigner, Web3Provider } from '@ethersproject/providers' ...@@ -5,8 +5,8 @@ import { JsonRpcSigner, Web3Provider } from '@ethersproject/providers'
import { BigNumber } from '@ethersproject/bignumber' import { BigNumber } from '@ethersproject/bignumber'
import { abi as IUniswapV2Router02ABI } from '@uniswap/v2-periphery/build/IUniswapV2Router02.json' import { abi as IUniswapV2Router02ABI } from '@uniswap/v2-periphery/build/IUniswapV2Router02.json'
import { ROUTER_ADDRESS } from '../constants' import { ROUTER_ADDRESS } from '../constants'
import { ALL_TOKENS } from '../constants/tokens'
import { ChainId, JSBI, Percent, Token, CurrencyAmount, Currency, ETHER } from '@uniswap/sdk' import { ChainId, JSBI, Percent, Token, CurrencyAmount, Currency, ETHER } from '@uniswap/sdk'
import { TokenAddressMap } from '../state/lists/hooks'
// returns the checksummed address if the address is valid, otherwise returns false // returns the checksummed address if the address is valid, otherwise returns false
export function isAddress(value: any): string | false { export function isAddress(value: any): string | false {
...@@ -87,7 +87,7 @@ export function getContract(address: string, ABI: any, library: Web3Provider, ac ...@@ -87,7 +87,7 @@ export function getContract(address: string, ABI: any, library: Web3Provider, ac
throw Error(`Invalid 'address' parameter '${address}'.`) throw Error(`Invalid 'address' parameter '${address}'.`)
} }
return new Contract(address, ABI, getProviderOrSigner(library, account)) return new Contract(address, ABI, getProviderOrSigner(library, account) as any)
} }
// account is optional // account is optional
...@@ -99,12 +99,7 @@ export function escapeRegExp(string: string): string { ...@@ -99,12 +99,7 @@ export function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string
} }
export function isDefaultToken(currency?: Currency): boolean { export function isDefaultToken(defaultTokens: TokenAddressMap, currency?: Currency): boolean {
if (currency === ETHER) return true if (currency === ETHER) return true
return Boolean(currency instanceof Token && ALL_TOKENS[currency.chainId]?.[currency.address]) return Boolean(currency instanceof Token && defaultTokens[currency.chainId]?.[currency.address])
}
export function isCustomAddedToken(allTokens: { [address: string]: Token }, currency?: Currency): boolean {
const isDefault = isDefaultToken(currency)
return Boolean(!isDefault && currency instanceof Token && allTokens[currency.address])
} }
/**
* Given a URI that may be ipfs, or http, or an ENS name, return the fetchable http(s) URLs for the same content
* @param uri to convert to http url
*/
export default function uriToHttp(uri: string): string[] {
try {
const parsed = new URL(uri)
if (parsed.protocol === 'http:') {
return ['https' + uri.substr(4), uri]
} else if (parsed.protocol === 'https:') {
return [uri]
} else if (parsed.protocol === 'ipfs:') {
const hash = parsed.pathname.substring(2)
return [`https://cloudflare-ipfs.com/ipfs/${hash}/`, `https://ipfs.infura.io/ipfs/${hash}/`]
} else if (parsed.protocol === 'ipns:') {
const name = parsed.pathname.substring(2)
return [`https://cloudflare-ipfs.com/ipns/${name}/`, `https://ipfs.infura.io/ipns/${name}/`]
} else {
console.error('Unrecognized protocol', parsed)
return []
}
} catch (error) {
if (uri.toLowerCase().endsWith('.eth')) {
return [`https://${uri.toLowerCase()}.link`]
}
console.error('Failed to parse URI', error)
return []
}
}
import { CurrencyAmount, ETHER, Percent, Route, TokenAmount, Trade } from '@uniswap/sdk' import { CurrencyAmount, ETHER, Percent, Route, TokenAmount, Trade } from '@uniswap/sdk'
import { DAI, USDC } from '../constants/tokens/mainnet' import { DAI, USDC } from '../constants'
import { MockV1Pair } from '../data/V1' import { MockV1Pair } from '../data/V1'
import v1SwapArguments from './v1SwapArguments' import v1SwapArguments from './v1SwapArguments'
......
This source diff could not be displayed because it is too large. You can view the blob instead.
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