feat(lists): allow selecting and adding token lists (#1023)

* more list stuff

Use the selected list instead of the default list, but also use the default list

start list selection code

* move token warning to a modal, fix the install issue

* add/remove/enter key

* handle enter on currency select for ETHER

* change slippage tolerance to be a slider

* make ui closer to the mocks

* commit slider changes

* back to tabs

* copy changes

* bump list version

* some styling for the list select

* bump uniswap default list version

* use contract calls to get ens names and addresses

* show list logo

* fix failing integration test

* .eth.link

* list introduction screen

* remove showSendWithSwap

* fix integration and unit tests

* resolve ENS names

* logos from ens

* fix the lint errors

* some refactoring to better support using a the library provider from the user for resolving ENS names

* load list info from the list url for the introduction page

* make it slightly harder to remove a list

* minor clean up, some help text and links

* remove icon from list update popup

* show added/removed tokens

* add GA everywhere, don't debounce contenthash lookups

* show tags

* fix tag key

* tag display, list rendering, needs optimization

* fix list fetching in firefox, style issue in safari

* sort the lists, clean up styling

* use client provider when possible

* show token warning for url loaded tokens

* improve the warning modal

* some refactoring to fix the list fetching on networks other than mainnet

* fix tests

* some minor improvements

* increase timeout to maybe fix integration tests which pass locally

* build for tests using the dev network url

* reset the lists if we deleted the other two copies

* improve how we handle updating the default list of lists

* fix integration test

* Update token list selection styles

* fix external links, reuse the on click outside code, show add errors

* show the list origin instead of the full url

* fix update list link

* show host instead of hostname
do not automatically dismiss major version upgrades for lists

* fix link to tokenlists.org

* add uma

* clean up styling in list rows

* bump token list version

* bump token list version again

* hover symbol to see currency name

* bump version

* add cmc lists, dharma list

Co-authored-by: Callil Capuozzo <callil.capuozzo@gmail.com>
This commit is contained in:
Moody Salem 2020-08-26 08:46:21 -05:00 committed by GitHub
parent 09b54570e1
commit 7cf25ac7c8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
84 changed files with 3799 additions and 1077 deletions

@ -29,6 +29,8 @@ jobs:
- run: yarn install --frozen-lockfile - run: yarn install --frozen-lockfile
- run: yarn cypress install - run: yarn cypress install
- run: yarn build - run: yarn build
env:
REACT_APP_NETWORK_URL: "https://mainnet.infura.io/v3/4bf032f2d38a4ed6bb975b80d6340847"
- run: yarn integration-test - run: yarn integration-test
unit-tests: unit-tests:

@ -3,5 +3,6 @@
"pluginsFile": false, "pluginsFile": false,
"fixturesFolder": false, "fixturesFolder": false,
"supportFile": "cypress/support/index.js", "supportFile": "cypress/support/index.js",
"video": false "video": false,
"defaultCommandTimeout": 10000
} }

@ -0,0 +1,11 @@
describe('Swap', () => {
beforeEach(() => cy.visit('/swap'))
it('list selection persists', () => {
cy.get('#swap-currency-output .open-currency-select-button').click()
cy.get('#select-default-uniswap-list .select-button').click()
cy.reload()
cy.get('#swap-currency-output .open-currency-select-button').click()
cy.get('#select-default-uniswap-list').should('not.exist')
})
})

@ -1,5 +1,8 @@
describe('Swap', () => { describe('Swap', () => {
beforeEach(() => cy.visit('/swap')) beforeEach(() => {
cy.clearLocalStorage()
cy.visit('/swap')
})
it('can enter an amount into input', () => { it('can enter an amount into input', () => {
cy.get('#swap-currency-input .token-amount-input') cy.get('#swap-currency-input .token-amount-input')
.type('0.001', { delay: 200 }) .type('0.001', { delay: 200 })
@ -32,6 +35,7 @@ describe('Swap', () => {
it('can swap ETH for DAI', () => { it('can swap ETH for DAI', () => {
cy.get('#swap-currency-output .open-currency-select-button').click() cy.get('#swap-currency-output .open-currency-select-button').click()
cy.get('#select-default-uniswap-list .select-button').click()
cy.get('.token-item-0xc7AD46e0b8a400Bb3C915120d284AafbA8fc4735').should('be.visible') cy.get('.token-item-0xc7AD46e0b8a400Bb3C915120d284AafbA8fc4735').should('be.visible')
cy.get('.token-item-0xc7AD46e0b8a400Bb3C915120d284AafbA8fc4735').click({ force: true }) cy.get('.token-item-0xc7AD46e0b8a400Bb3C915120d284AafbA8fc4735').click({ force: true })
cy.get('#swap-currency-input .token-amount-input').should('be.visible') cy.get('#swap-currency-input .token-amount-input').should('be.visible')

@ -6,14 +6,11 @@ describe('Warning', () => {
it('Check that warning is displayed', () => { it('Check that warning is displayed', () => {
cy.get('.token-warning-container').should('be.visible') cy.get('.token-warning-container').should('be.visible')
}) })
it('Check that warning hides after button dismissal.', () => { it('Check that warning hides after button dismissal', () => {
cy.get('.token-dismiss-button').should('be.disabled')
cy.get('.understand-checkbox').click()
cy.get('.token-dismiss-button').should('not.be.disabled')
cy.get('.token-dismiss-button').click() cy.get('.token-dismiss-button').click()
cy.get('.token-warning-container').should('not.be.visible') 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')
})
}) })

@ -11,20 +11,23 @@
"@reduxjs/toolkit": "^1.3.5", "@reduxjs/toolkit": "^1.3.5",
"@types/jest": "^25.2.1", "@types/jest": "^25.2.1",
"@types/lodash.flatmap": "^4.5.6", "@types/lodash.flatmap": "^4.5.6",
"@types/multicodec": "^1.0.0",
"@types/node": "^13.13.5", "@types/node": "^13.13.5",
"@types/qs": "^6.9.2", "@types/qs": "^6.9.2",
"@types/react": "^16.9.34", "@types/react": "^16.9.34",
"@types/react-dom": "^16.9.7", "@types/react-dom": "^16.9.7",
"@types/react-redux": "^7.1.8", "@types/react-redux": "^7.1.8",
"@types/react-router-dom": "^5.0.0", "@types/react-router-dom": "^5.0.0",
"@types/react-virtualized-auto-sizer": "^1.0.0",
"@types/react-window": "^1.8.2", "@types/react-window": "^1.8.2",
"@types/rebass": "^4.0.5", "@types/rebass": "^4.0.5",
"@types/styled-components": "^5.1.0", "@types/styled-components": "^5.1.0",
"@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/default-token-list": "^1.3.0",
"@uniswap/sdk": "3.0.3-beta.1", "@uniswap/sdk": "3.0.3-beta.1",
"@uniswap/token-lists": "^1.0.0-beta.11", "@uniswap/token-lists": "^1.0.0-beta.14",
"@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",
@ -34,6 +37,7 @@
"@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", "ajv": "^6.12.3",
"cids": "^1.0.0",
"copy-to-clipboard": "^3.2.0", "copy-to-clipboard": "^3.2.0",
"cross-env": "^7.0.2", "cross-env": "^7.0.2",
"cypress": "^4.11.0", "cypress": "^4.11.0",
@ -49,6 +53,8 @@
"inter-ui": "^3.13.1", "inter-ui": "^3.13.1",
"jazzicon": "^1.5.0", "jazzicon": "^1.5.0",
"lodash.flatmap": "^4.5.0", "lodash.flatmap": "^4.5.0",
"multicodec": "^2.0.0",
"multihashes": "^3.0.1",
"polished": "^3.3.2", "polished": "^3.3.2",
"prettier": "^1.17.0", "prettier": "^1.17.0",
"qs": "^6.9.4", "qs": "^6.9.4",
@ -64,6 +70,7 @@
"react-scripts": "^3.4.1", "react-scripts": "^3.4.1",
"react-spring": "^8.0.27", "react-spring": "^8.0.27",
"react-use-gesture": "^6.0.14", "react-use-gesture": "^6.0.14",
"react-virtualized-auto-sizer": "^1.0.2",
"react-window": "^1.8.5", "react-window": "^1.8.5",
"rebass": "^4.0.7", "rebass": "^4.0.7",
"redux-localstorage-simple": "^2.2.0", "redux-localstorage-simple": "^2.2.0",

@ -19,11 +19,11 @@ export const LightCard = styled(Card)`
` `
export const GreyCard = styled(Card)` export const GreyCard = styled(Card)`
background-color: ${({ theme }) => theme.advancedBG}; background-color: ${({ theme }) => theme.bg3};
` `
export const OutlineCard = styled(Card)` export const OutlineCard = styled(Card)`
border: 1px solid ${({ theme }) => theme.advancedBG}; border: 1px solid ${({ theme }) => theme.bg3};
` `
export const YellowCard = styled(Card)` export const YellowCard = styled(Card)`

@ -126,7 +126,6 @@ interface CurrencyInputPanelProps {
hideBalance?: boolean hideBalance?: boolean
pair?: Pair | null pair?: Pair | null
hideInput?: boolean hideInput?: boolean
showSendWithSwap?: boolean
otherCurrency?: Currency | null otherCurrency?: Currency | null
id: string id: string
showCommonBases?: boolean showCommonBases?: boolean
@ -144,7 +143,6 @@ export default function CurrencyInputPanel({
hideBalance = false, hideBalance = false,
pair = null, // used for double token logo pair = null, // used for double token logo
hideInput = false, hideInput = false,
showSendWithSwap = false,
otherCurrency = null, otherCurrency = null,
id, id,
showCommonBases showCommonBases
@ -238,8 +236,7 @@ export default function CurrencyInputPanel({
isOpen={modalOpen} isOpen={modalOpen}
onDismiss={handleDismissSearch} onDismiss={handleDismissSearch}
onCurrencySelect={onCurrencySelect} onCurrencySelect={onCurrencySelect}
showSendWithSwap={showSendWithSwap} selectedCurrency={currency}
hiddenCurrency={currency}
otherSelectedCurrency={otherCurrency} otherSelectedCurrency={otherCurrency}
showCommonBases={showCommonBases} showCommonBases={showCommonBases}
/> />

@ -1,32 +1,14 @@
import { Currency, ETHER, Token } from '@uniswap/sdk' import { Currency, ETHER, Token } from '@uniswap/sdk'
import React, { useState } from 'react' import React, { useMemo } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import EthereumLogo from '../../assets/images/ethereum-logo.png' import EthereumLogo from '../../assets/images/ethereum-logo.png'
import useHttpLocations from '../../hooks/useHttpLocations'
import { WrappedTokenInfo } from '../../state/lists/hooks' import { WrappedTokenInfo } from '../../state/lists/hooks'
import uriToHttp from '../../utils/uriToHttp' import Logo from '../Logo'
const getTokenLogoURL = address => const getTokenLogoURL = (address: string) =>
`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 BAD_URIS: { [tokenAddress: string]: true } = {}
const Image = styled.img<{ size: string }>`
width: ${({ size }) => size};
height: ${({ size }) => size};
background-color: white;
border-radius: 1rem;
box-shadow: 0px 6px 10px rgba(0, 0, 0, 0.075);
`
const Emoji = styled.span<{ size?: string }>`
display: flex;
align-items: center;
justify-content: center;
font-size: ${({ size }) => size};
width: ${({ size }) => size};
height: ${({ size }) => size};
margin-bottom: -4px;
`
const StyledEthereumLogo = styled.img<{ size: string }>` const StyledEthereumLogo = styled.img<{ size: string }>`
width: ${({ size }) => size}; width: ${({ size }) => size};
@ -35,60 +17,38 @@ const StyledEthereumLogo = styled.img<{ size: string }>`
border-radius: 24px; border-radius: 24px;
` `
const StyledLogo = styled(Logo)<{ size: string }>`
width: ${({ size }) => size};
height: ${({ size }) => size};
`
export default function CurrencyLogo({ export default function CurrencyLogo({
currency, currency,
size = '24px', size = '24px',
...rest style
}: { }: {
currency?: Currency currency?: Currency
size?: string size?: string
style?: React.CSSProperties style?: React.CSSProperties
}) { }) {
const [, refresh] = useState<number>(0) const uriLocations = useHttpLocations(currency instanceof WrappedTokenInfo ? currency.logoURI : undefined)
const srcs: string[] = useMemo(() => {
if (currency === ETHER) return []
if (currency instanceof Token) {
if (currency instanceof WrappedTokenInfo) {
return [...uriLocations, getTokenLogoURL(currency.address)]
}
return [getTokenLogoURL(currency.address)]
}
return []
}, [currency, uriLocations])
if (currency === ETHER) { if (currency === ETHER) {
return <StyledEthereumLogo src={EthereumLogo} size={size} {...rest} /> return <StyledEthereumLogo src={EthereumLogo} size={size} style={style} />
} }
if (currency instanceof Token) { return <StyledLogo size={size} srcs={srcs} alt={`${currency?.symbol ?? 'token'} logo`} style={style} />
let uri: string | undefined
if (currency instanceof WrappedTokenInfo) {
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 (
<Image
{...rest}
alt={`${currency.name} Logo`}
src={uri}
size={size}
onError={() => {
if (currency instanceof Token) {
BAD_URIS[uri] = true
}
refresh(i => i + 1)
}}
/>
)
}
}
return (
<Emoji {...rest} size={size}>
<span role="img" aria-label="Thinking">
🤔
</span>
</Emoji>
)
} }

@ -0,0 +1,26 @@
import React from 'react'
import styled from 'styled-components'
import useHttpLocations from '../../hooks/useHttpLocations'
import Logo from '../Logo'
const StyledListLogo = styled(Logo)<{ size: string }>`
width: ${({ size }) => size};
height: ${({ size }) => size};
`
export default function ListLogo({
logoURI,
style,
size = '24px',
alt
}: {
logoURI: string
size?: string
style?: React.CSSProperties
alt?: string
}) {
const srcs: string[] = useHttpLocations(logoURI)
return <StyledListLogo alt={alt} size={size} srcs={srcs} style={style} />
}

@ -0,0 +1,34 @@
import React, { useState } from 'react'
import { AlertTriangle } from 'react-feather'
import { ImageProps } from 'rebass'
const BAD_SRCS: { [tokenAddress: string]: true } = {}
export interface LogoProps extends Pick<ImageProps, 'style' | 'alt' | 'className'> {
srcs: string[]
}
/**
* Renders an image by sequentially trying a list of URIs, and then eventually a fallback triangle alert
*/
export default function Logo({ srcs, alt, ...rest }: LogoProps) {
const [, refresh] = useState<number>(0)
const src: string | undefined = srcs.find(src => !BAD_SRCS[src])
if (src) {
return (
<img
{...rest}
alt={alt}
src={src}
onError={() => {
if (src) BAD_SRCS[src] = true
refresh(i => i + 1)
}}
/>
)
}
return <AlertTriangle {...rest} />
}

@ -1,7 +1,8 @@
import React, { useRef, useEffect } from 'react' import React, { useRef } from 'react'
import { Info, BookOpen, Code, PieChart, MessageCircle } from 'react-feather' import { Info, BookOpen, Code, PieChart, MessageCircle } from 'react-feather'
import styled from 'styled-components' import styled from 'styled-components'
import { ReactComponent as MenuIcon } from '../../assets/images/menu.svg' import { ReactComponent as MenuIcon } from '../../assets/images/menu.svg'
import { useOnClickOutside } from '../../hooks/useOnClickOutside'
import useToggle from '../../hooks/useToggle' import useToggle from '../../hooks/useToggle'
import { ExternalLink } from '../../theme' import { ExternalLink } from '../../theme'
@ -83,24 +84,7 @@ export default function Menu() {
const node = useRef<HTMLDivElement>() const node = useRef<HTMLDivElement>()
const [open, toggle] = useToggle(false) const [open, toggle] = useToggle(false)
useEffect(() => { useOnClickOutside(node, open ? toggle : undefined)
const handleClickOutside = e => {
if (node.current?.contains(e.target) ?? false) {
return
}
toggle()
}
if (open) {
document.addEventListener('mousedown', handleClickOutside)
} else {
document.removeEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [open, toggle])
return ( return (
<StyledMenu ref={node}> <StyledMenu ref={node}>

@ -38,6 +38,7 @@ const StyledDialogContent = styled(({ minHeight, maxHeight, mobile, isOpen, ...r
box-shadow: 0 4px 8px 0 ${({ theme }) => transparentize(0.95, theme.shadow1)}; box-shadow: 0 4px 8px 0 ${({ theme }) => transparentize(0.95, theme.shadow1)};
padding: 0px; padding: 0px;
width: 50vw; width: 50vw;
overflow: hidden;
align-self: ${({ mobile }) => (mobile ? 'flex-end' : 'center')}; align-self: ${({ mobile }) => (mobile ? 'flex-end' : 'center')};

@ -1,20 +1,17 @@
import { TokenList, Version } from '@uniswap/token-lists' import { diffTokenLists, TokenList } from '@uniswap/token-lists'
import React, { useCallback, useContext } from 'react' import React, { useCallback, useMemo } from 'react'
import { AlertCircle, Info } from 'react-feather' import ReactGA from 'react-ga'
import { useDispatch } from 'react-redux' import { useDispatch } from 'react-redux'
import { ThemeContext } from 'styled-components' import { Text } from 'rebass'
import { AppDispatch } from '../../state' import { AppDispatch } from '../../state'
import { useRemovePopup } from '../../state/application/hooks' import { useRemovePopup } from '../../state/application/hooks'
import { acceptListUpdate } from '../../state/lists/actions' import { acceptListUpdate } from '../../state/lists/actions'
import { TYPE } from '../../theme' import { TYPE } from '../../theme'
import { ButtonPrimary, ButtonSecondary } from '../Button' import listVersionLabel from '../../utils/listVersionLabel'
import { ButtonSecondary } from '../Button'
import { AutoColumn } from '../Column' import { AutoColumn } from '../Column'
import { AutoRow } from '../Row' import { AutoRow } from '../Row'
function versionLabel(version: Version): string {
return `v${version.major}.${version.minor}.${version.patch}`
}
export default function ListUpdatePopup({ export default function ListUpdatePopup({
popKey, popKey,
listUrl, listUrl,
@ -31,34 +28,68 @@ export default function ListUpdatePopup({
const removePopup = useRemovePopup() const removePopup = useRemovePopup()
const removeThisPopup = useCallback(() => removePopup(popKey), [popKey, removePopup]) const removeThisPopup = useCallback(() => removePopup(popKey), [popKey, removePopup])
const dispatch = useDispatch<AppDispatch>() const dispatch = useDispatch<AppDispatch>()
const theme = useContext(ThemeContext)
const updateList = useCallback(() => { const handleAcceptUpdate = useCallback(() => {
if (auto) return if (auto) return
ReactGA.event({
category: 'Lists',
action: 'Update List from Popup',
label: listUrl
})
dispatch(acceptListUpdate(listUrl)) dispatch(acceptListUpdate(listUrl))
removeThisPopup() removeThisPopup()
}, [auto, dispatch, listUrl, removeThisPopup]) }, [auto, dispatch, listUrl, removeThisPopup])
const { added: tokensAdded, changed: tokensChanged, removed: tokensRemoved } = useMemo(() => {
return diffTokenLists(oldList.tokens, newList.tokens)
}, [newList.tokens, oldList.tokens])
const numTokensChanged = useMemo(
() => Object.keys(tokensChanged).reduce((memo, chainId) => memo + Object.keys(tokensChanged[chainId]).length, 0),
[tokensChanged]
)
return ( return (
<AutoRow> <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"> <AutoColumn style={{ flex: '1' }} gap="8px">
{auto ? ( {auto ? (
<TYPE.body fontWeight={500}> <TYPE.body fontWeight={500}>
The token list &quot;{oldList.name}&quot; has been updated to{' '} The token list &quot;{oldList.name}&quot; has been updated to{' '}
<strong>{versionLabel(newList.version)}</strong>. <strong>{listVersionLabel(newList.version)}</strong>.
</TYPE.body> </TYPE.body>
) : ( ) : (
<> <>
<div> <div>
A token list update is available for the list &quot;{oldList.name}&quot; ({versionLabel(oldList.version)}{' '} <Text>
to {versionLabel(newList.version)}). An update is available for the token list &quot;{oldList.name}&quot; (
{listVersionLabel(oldList.version)} to {listVersionLabel(newList.version)}).
</Text>
<ul>
{tokensAdded.length > 0 ? (
<li>
{tokensAdded.map(token => (
<strong key={`${token.chainId}-${token.address}`} title={token.address}>
{token.symbol}
</strong>
))}{' '}
added
</li>
) : null}
{tokensRemoved.length > 0 ? (
<li>
{tokensRemoved.map(token => (
<strong key={`${token.chainId}-${token.address}`} title={token.address}>
{token.symbol}
</strong>
))}{' '}
removed
</li>
) : null}
{numTokensChanged > 0 ? <li>{numTokensChanged} tokens updated</li> : null}
</ul>
</div> </div>
<AutoRow> <AutoRow>
<div style={{ flexGrow: 1, marginRight: 6 }}> <div style={{ flexGrow: 1, marginRight: 12 }}>
<ButtonPrimary onClick={updateList}>Update list</ButtonPrimary> <ButtonSecondary onClick={handleAcceptUpdate}>Accept update</ButtonSecondary>
</div> </div>
<div style={{ flexGrow: 1 }}> <div style={{ flexGrow: 1 }}>
<ButtonSecondary onClick={removeThisPopup}>Dismiss</ButtonSecondary> <ButtonSecondary onClick={removeThisPopup}>Dismiss</ButtonSecondary>

@ -1,7 +1,8 @@
import React, { useCallback, useContext, useState } from 'react' import React, { useCallback, useContext, useEffect } from 'react'
import { X } from 'react-feather' import { X } from 'react-feather'
import { useSpring } from 'react-spring/web'
import styled, { ThemeContext } from 'styled-components' import styled, { ThemeContext } from 'styled-components'
import useInterval from '../../hooks/useInterval' import { animated } from 'react-spring'
import { PopupContent } from '../../state/application/actions' import { PopupContent } from '../../state/application/actions'
import { useRemovePopup } from '../../state/application/hooks' import { useRemovePopup } from '../../state/application/hooks'
import ListUpdatePopup from './ListUpdatePopup' import ListUpdatePopup from './ListUpdatePopup'
@ -25,44 +26,48 @@ export const Popup = styled.div`
border-radius: 10px; border-radius: 10px;
padding: 20px; padding: 20px;
padding-right: 35px; padding-right: 35px;
z-index: 2;
overflow: hidden; overflow: hidden;
${({ theme }) => theme.mediaWidth.upToSmall` ${({ theme }) => theme.mediaWidth.upToSmall`
min-width: 290px; min-width: 290px;
`} `}
` `
const DELAY = 100 const Fader = styled.div`
const Fader = styled.div<{ count: number }>`
position: absolute; position: absolute;
bottom: 0px; bottom: 0px;
left: 0px; left: 0px;
width: ${({ count }) => `calc(100% - (100% / ${150 / count}))`}; width: 100%;
height: 2px; height: 2px;
background-color: ${({ theme }) => theme.bg3}; background-color: ${({ theme }) => theme.bg3};
transition: width 100ms linear;
` `
export default function PopupItem({ content, popKey }: { content: PopupContent; popKey: string }) { const AnimatedFader = animated(Fader)
const [count, setCount] = useState(1)
const [isRunning, setIsRunning] = useState(true) export default function PopupItem({
removeAfterMs,
content,
popKey
}: {
removeAfterMs: number | null
content: PopupContent
popKey: string
}) {
const removePopup = useRemovePopup() const removePopup = useRemovePopup()
const removeThisPopup = useCallback(() => removePopup(popKey), [popKey, removePopup]) const removeThisPopup = useCallback(() => removePopup(popKey), [popKey, removePopup])
useEffect(() => {
if (removeAfterMs === null) return
useInterval( const timeout = setTimeout(() => {
() => { removeThisPopup()
count > 150 ? removeThisPopup() : setCount(count + 1) }, removeAfterMs)
},
isRunning ? DELAY : null return () => {
) clearTimeout(timeout)
}
}, [removeAfterMs, removeThisPopup])
const theme = useContext(ThemeContext) const theme = useContext(ThemeContext)
const handleMouseEnter = useCallback(() => setIsRunning(false), [])
const handleMouseLeave = useCallback(() => setIsRunning(true), [])
let popupContent let popupContent
if ('txn' in content) { if ('txn' in content) {
const { const {
@ -76,11 +81,13 @@ export default function PopupItem({ content, popKey }: { content: PopupContent;
popupContent = <ListUpdatePopup popKey={popKey} listUrl={listUrl} oldList={oldList} newList={newList} auto={auto} /> popupContent = <ListUpdatePopup popKey={popKey} listUrl={listUrl} oldList={oldList} newList={newList} auto={auto} />
} }
const faderStyle = useSpring({ from: { width: '100%' }, to: { width: '0%' }, config: { duration: removeAfterMs } })
return ( return (
<Popup onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}> <Popup>
<StyledClose color={theme.text2} onClick={() => removePopup(popKey)} /> <StyledClose color={theme.text2} onClick={removeThisPopup} />
{popupContent} {popupContent}
<Fader count={count} /> {removeAfterMs !== null ? <AnimatedFader style={faderStyle} /> : null}
</Popup> </Popup>
) )
} }

@ -35,6 +35,7 @@ const FixedPopupColumn = styled(AutoColumn)`
right: 1rem; right: 1rem;
max-width: 355px !important; max-width: 355px !important;
width: 100%; width: 100%;
z-index: 2;
${({ theme }) => theme.mediaWidth.upToSmall` ${({ theme }) => theme.mediaWidth.upToSmall`
display: none; display: none;
@ -49,7 +50,7 @@ export default function Popups() {
<> <>
<FixedPopupColumn gap="20px"> <FixedPopupColumn gap="20px">
{activePopups.map(item => ( {activePopups.map(item => (
<PopupItem key={item.key} content={item.content} popKey={item.key} /> <PopupItem key={item.key} content={item.content} popKey={item.key} removeAfterMs={item.removeAfterMs} />
))} ))}
</FixedPopupColumn> </FixedPopupColumn>
<MobilePopupWrapper height={activePopups?.length > 0 ? 'fit-content' : 0}> <MobilePopupWrapper height={activePopups?.length > 0 ? 'fit-content' : 0}>
@ -58,7 +59,7 @@ export default function Popups() {
.slice(0) .slice(0)
.reverse() .reverse()
.map(item => ( .map(item => (
<PopupItem key={item.key} content={item.content} popKey={item.key} /> <PopupItem key={item.key} content={item.content} popKey={item.key} removeAfterMs={item.removeAfterMs} />
))} ))}
</MobilePopupInner> </MobilePopupInner>
</MobilePopupWrapper> </MobilePopupWrapper>

@ -22,7 +22,7 @@ const QuestionWrapper = styled.div`
} }
` `
export default function QuestionHelper({ text, disabled }: { text: string; disabled?: boolean }) { export default function QuestionHelper({ text }: { text: string }) {
const [show, setShow] = useState<boolean>(false) const [show, setShow] = useState<boolean>(false)
const open = useCallback(() => setShow(true), [setShow]) const open = useCallback(() => setShow(true), [setShow])
@ -30,7 +30,7 @@ export default function QuestionHelper({ text, disabled }: { text: string; disab
return ( return (
<span style={{ marginLeft: 4 }}> <span style={{ marginLeft: 4 }}>
<Tooltip text={text} show={show && !disabled}> <Tooltip text={text} show={show}>
<QuestionWrapper onClick={open} onMouseEnter={open} onMouseLeave={close}> <QuestionWrapper onClick={open} onMouseEnter={open} onMouseLeave={close}>
<Question size={16} /> <Question size={16} />
</QuestionWrapper> </QuestionWrapper>

@ -44,7 +44,11 @@ export default function CommonBases({
</AutoRow> </AutoRow>
<AutoRow gap="4px"> <AutoRow gap="4px">
<BaseWrapper <BaseWrapper
onClick={() => !currencyEquals(selectedCurrency, ETHER) && onSelect(ETHER)} onClick={() => {
if (!selectedCurrency || !currencyEquals(selectedCurrency, ETHER)) {
onSelect(ETHER)
}
}}
disable={selectedCurrency === ETHER} disable={selectedCurrency === ETHER}
> >
<CurrencyLogo currency={ETHER} style={{ marginRight: 8 }} /> <CurrencyLogo currency={ETHER} style={{ marginRight: 8 }} />

@ -1,155 +1,208 @@
import { Currency, CurrencyAmount, currencyEquals, ETHER, JSBI, Token } from '@uniswap/sdk' import { Currency, CurrencyAmount, currencyEquals, ETHER, Token } from '@uniswap/sdk'
import React, { CSSProperties, memo, useContext, useMemo } from 'react' import React, { CSSProperties, MutableRefObject, useCallback, useMemo } from 'react'
import { FixedSizeList } from 'react-window' import { FixedSizeList } from 'react-window'
import { Text } from 'rebass' import { Text } from 'rebass'
import { ThemeContext } from 'styled-components' import styled from 'styled-components'
import { useActiveWeb3React } from '../../hooks' import { useActiveWeb3React } from '../../hooks'
import { useAllTokens } from '../../hooks/Tokens' import { useSelectedTokenList, WrappedTokenInfo } from '../../state/lists/hooks'
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 { useCurrencyBalance } from '../../state/wallet/hooks'
import { LinkStyledButton, TYPE } from '../../theme' import { LinkStyledButton, TYPE } from '../../theme'
import { ButtonSecondary } from '../Button' import Column from '../Column'
import Column, { AutoColumn } from '../Column'
import { RowFixed } from '../Row' import { RowFixed } from '../Row'
import CurrencyLogo from '../CurrencyLogo' import CurrencyLogo from '../CurrencyLogo'
import { MouseoverTooltip } from '../Tooltip'
import { FadedSpan, MenuItem } from './styleds' import { FadedSpan, MenuItem } from './styleds'
import Loader from '../Loader' import Loader from '../Loader'
import { isDefaultToken } from '../../utils' import { isTokenOnList } 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' : ''
} }
const StyledBalanceText = styled(Text)`
white-space: nowrap;
overflow: hidden;
max-width: 5rem;
text-overflow: ellipsis;
`
const Tag = styled.div`
background-color: ${({ theme }) => theme.bg3};
color: ${({ theme }) => theme.text2};
font-size: 14px;
border-radius: 4px;
padding: 0.25rem 0.3rem 0.25rem 0.3rem;
max-width: 6rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
justify-self: flex-end;
margin-right: 4px;
`
function Balance({ balance }: { balance: CurrencyAmount }) {
return <StyledBalanceText title={balance.toExact()}>{balance.toSignificant(4)}</StyledBalanceText>
}
const TagContainer = styled.div`
display: flex;
justify-content: flex-end;
`
function TokenTags({ currency }: { currency: Currency }) {
if (!(currency instanceof WrappedTokenInfo)) {
return <span />
}
const tags = currency.tags
if (!tags || tags.length === 0) return <span />
const tag = tags[0]
return (
<TagContainer>
<MouseoverTooltip text={tag.description}>
<Tag key={tag.id}>{tag.name}</Tag>
</MouseoverTooltip>
{tags.length > 1 ? (
<MouseoverTooltip
text={tags
.slice(1)
.map(({ name, description }) => `${name}: ${description}`)
.join('; \n')}
>
<Tag>...</Tag>
</MouseoverTooltip>
) : null}
</TagContainer>
)
}
function CurrencyRow({
currency,
onSelect,
isSelected,
otherSelected,
style
}: {
currency: Currency
onSelect: () => void
isSelected: boolean
otherSelected: boolean
style: CSSProperties
}) {
const { account, chainId } = useActiveWeb3React()
const key = currencyKey(currency)
const selectedTokenList = useSelectedTokenList()
const isOnSelectedList = isTokenOnList(selectedTokenList, currency)
const customAdded = Boolean(!isOnSelectedList && currency instanceof Token)
const balance = useCurrencyBalance(account ?? undefined, currency)
const removeToken = useRemoveUserAddedToken()
const addToken = useAddUserToken()
return (
<MenuItem
style={style}
className={`token-item-${key}`}
onClick={() => (isSelected ? null : onSelect())}
disabled={isSelected}
selected={otherSelected}
>
<CurrencyLogo currency={currency} size={'24px'} />
<Column>
<Text title={currency.name} fontWeight={500}>
{currency.symbol}
</Text>
<FadedSpan>
{customAdded ? (
<TYPE.main fontWeight={500}>
Added by user
<LinkStyledButton
onClick={event => {
event.stopPropagation()
if (chainId && currency instanceof Token) removeToken(chainId, currency.address)
}}
>
(Remove)
</LinkStyledButton>
</TYPE.main>
) : null}
{!isOnSelectedList && !customAdded ? (
<TYPE.main fontWeight={500}>
Found by address
<LinkStyledButton
onClick={event => {
event.stopPropagation()
if (currency instanceof Token) addToken(currency)
}}
>
(Add)
</LinkStyledButton>
</TYPE.main>
) : null}
</FadedSpan>
</Column>
<TokenTags currency={currency} />
<RowFixed style={{ justifySelf: 'flex-end' }}>
{balance ? <Balance balance={balance} /> : account ? <Loader /> : null}
</RowFixed>
</MenuItem>
)
}
export default function CurrencyList({ export default function CurrencyList({
height,
currencies, currencies,
allBalances,
selectedCurrency, selectedCurrency,
onCurrencySelect, onCurrencySelect,
otherCurrency, otherCurrency,
showSendWithSwap fixedListRef,
showETH
}: { }: {
height: number
currencies: Currency[] currencies: Currency[]
selectedCurrency: Currency selectedCurrency: Currency | undefined
allBalances: { [tokenAddress: string]: CurrencyAmount }
onCurrencySelect: (currency: Currency) => void onCurrencySelect: (currency: Currency) => void
otherCurrency: Currency otherCurrency: Currency | undefined
showSendWithSwap?: boolean fixedListRef?: MutableRefObject<FixedSizeList | undefined>
showETH: boolean
}) { }) {
const { account, chainId } = useActiveWeb3React() const itemData = useMemo(() => (showETH ? [Currency.ETHER, ...currencies] : currencies), [currencies, showETH])
const theme = useContext(ThemeContext)
const allTokens = useAllTokens()
const defaultTokens = useDefaultTokenList()
const addToken = useAddUserToken()
const removeToken = useRemoveUserAddedToken()
const ETHBalance = useETHBalances([account])[account]
const CurrencyRow = useMemo(() => { const Row = useCallback(
return memo(function CurrencyRow({ index, style }: { index: number; style: CSSProperties }) { ({ data, index, style }) => {
const currency = index === 0 ? Currency.ETHER : currencies[index - 1] const currency: Currency = data[index]
const key = currencyKey(currency) const isSelected = Boolean(selectedCurrency && currencyEquals(selectedCurrency, currency))
const isDefault = isDefaultToken(defaultTokens, currency)
const customAdded = Boolean(!isDefault && currency instanceof Token && allTokens[currency.address])
const balance = currency === ETHER ? ETHBalance : allBalances[key]
const zeroBalance = balance && JSBI.equal(JSBI.BigInt(0), balance.raw)
const isSelected = Boolean(selectedCurrency && currencyEquals(currency, selectedCurrency))
const otherSelected = Boolean(otherCurrency && currencyEquals(otherCurrency, currency)) const otherSelected = Boolean(otherCurrency && currencyEquals(otherCurrency, currency))
const handleSelect = () => onCurrencySelect(currency)
return ( return (
<MenuItem <CurrencyRow
style={style} style={style}
className={`token-item-${key}`} currency={currency}
onClick={() => (isSelected ? null : onCurrencySelect(currency))} isSelected={isSelected}
disabled={isSelected} onSelect={handleSelect}
selected={otherSelected} otherSelected={otherSelected}
> />
<RowFixed>
<CurrencyLogo currency={currency} size={'24px'} style={{ marginRight: '14px' }} />
<Column>
<Text fontWeight={500}>{currency.symbol}</Text>
<FadedSpan>
{customAdded ? (
<TYPE.main fontWeight={500}>
Added by user
<LinkStyledButton
onClick={event => {
event.stopPropagation()
if (currency instanceof Token) removeToken(chainId, currency.address)
}}
>
(Remove)
</LinkStyledButton>
</TYPE.main>
) : null}
{!isDefault && !customAdded ? (
<TYPE.main fontWeight={500}>
Found by address
<LinkStyledButton
onClick={event => {
event.stopPropagation()
if (currency instanceof Token) addToken(currency)
}}
>
(Add)
</LinkStyledButton>
</TYPE.main>
) : null}
</FadedSpan>
</Column>
</RowFixed>
<AutoColumn>
{balance ? (
<Text>
{zeroBalance && showSendWithSwap ? (
<ButtonSecondary padding={'4px 8px'}>
<Text textAlign="center" fontWeight={500} fontSize={14} color={theme.primary1}>
Send With Swap
</Text>
</ButtonSecondary>
) : balance ? (
balance.toSignificant(6)
) : (
'-'
)}
</Text>
) : account ? (
<Loader />
) : (
'-'
)}
</AutoColumn>
</MenuItem>
) )
}) },
}, [ [onCurrencySelect, otherCurrency, selectedCurrency]
ETHBalance, )
account,
addToken, const itemKey = useCallback((index: number, data: any) => currencyKey(data[index]), [])
allBalances,
allTokens,
chainId,
currencies,
defaultTokens,
onCurrencySelect,
otherCurrency,
removeToken,
selectedCurrency,
showSendWithSwap,
theme.primary1
])
return ( return (
<FixedSizeList <FixedSizeList
height={height}
ref={fixedListRef as any}
width="100%" width="100%"
height={500} itemData={itemData}
itemCount={currencies.length + 1} itemCount={itemData.length}
itemSize={56} itemSize={56}
style={{ flex: '1' }} itemKey={itemKey}
itemKey={index => currencyKey(currencies[index])}
> >
{CurrencyRow} {Row}
</FixedSizeList> </FixedSizeList>
) )
} }

@ -0,0 +1,210 @@
import { Currency, ETHER, Token } from '@uniswap/sdk'
import React, { KeyboardEvent, RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import ReactGA from 'react-ga'
import { useTranslation } from 'react-i18next'
import { FixedSizeList } from 'react-window'
import { Text } from 'rebass'
import { ThemeContext } from 'styled-components'
import { useActiveWeb3React } from '../../hooks'
import { useAllTokens, useToken } from '../../hooks/Tokens'
import { useSelectedListInfo } from '../../state/lists/hooks'
import { CloseIcon, LinkStyledButton, TYPE } from '../../theme'
import { isAddress } from '../../utils'
import Card from '../Card'
import Column from '../Column'
import ListLogo from '../ListLogo'
import QuestionHelper from '../QuestionHelper'
import Row, { RowBetween } from '../Row'
import CommonBases from './CommonBases'
import CurrencyList from './CurrencyList'
import { filterTokens } from './filtering'
import SortButton from './SortButton'
import { useTokenComparator } from './sorting'
import { PaddedColumn, SearchInput, Separator } from './styleds'
import AutoSizer from 'react-virtualized-auto-sizer'
interface CurrencySearchProps {
isOpen: boolean
onDismiss: () => void
selectedCurrency?: Currency
onCurrencySelect: (currency: Currency) => void
otherSelectedCurrency?: Currency
showCommonBases?: boolean
onChangeList: () => void
}
export function CurrencySearch({
selectedCurrency,
onCurrencySelect,
otherSelectedCurrency,
showCommonBases,
onDismiss,
isOpen,
onChangeList
}: CurrencySearchProps) {
const { t } = useTranslation()
const { chainId } = useActiveWeb3React()
const theme = useContext(ThemeContext)
const fixedList = useRef<FixedSizeList>()
const [searchQuery, setSearchQuery] = useState<string>('')
const [invertSearchOrder, setInvertSearchOrder] = useState<boolean>(false)
const allTokens = useAllTokens()
// if they input an address, use it
const isAddressSearch = isAddress(searchQuery)
const searchToken = useToken(searchQuery)
useEffect(() => {
if (isAddressSearch) {
ReactGA.event({
category: 'Currency Select',
action: 'Search by address',
label: isAddressSearch
})
}
}, [isAddressSearch])
const showETH: boolean = useMemo(() => {
const s = searchQuery.toLowerCase().trim()
return s === '' || s === 'e' || s === 'et' || s === 'eth'
}, [searchQuery])
const tokenComparator = useTokenComparator(invertSearchOrder)
const filteredTokens: Token[] = useMemo(() => {
if (isAddressSearch) return searchToken ? [searchToken] : []
return filterTokens(Object.values(allTokens), searchQuery)
}, [isAddressSearch, searchToken, allTokens, searchQuery])
const filteredSortedTokens: Token[] = useMemo(() => {
if (searchToken) return [searchToken]
const sorted = filteredTokens.sort(tokenComparator)
const symbolMatch = searchQuery
.toLowerCase()
.split(/\s+/)
.filter(s => s.length > 0)
if (symbolMatch.length > 1) return sorted
return [
...(searchToken ? [searchToken] : []),
// sort any exact symbol matches first
...sorted.filter(token => token.symbol?.toLowerCase() === symbolMatch[0]),
...sorted.filter(token => token.symbol?.toLowerCase() !== symbolMatch[0])
]
}, [filteredTokens, searchQuery, searchToken, tokenComparator])
const handleCurrencySelect = useCallback(
(currency: Currency) => {
onCurrencySelect(currency)
onDismiss()
},
[onDismiss, onCurrencySelect]
)
// clear the input on open
useEffect(() => {
if (isOpen) setSearchQuery('')
}, [isOpen])
// manage focus on modal show
const inputRef = useRef<HTMLInputElement>()
const handleInput = useCallback(event => {
const input = event.target.value
const checksummedInput = isAddress(input)
setSearchQuery(checksummedInput || input)
fixedList.current?.scrollTo(0)
}, [])
const handleEnter = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
const s = searchQuery.toLowerCase().trim()
if (s === 'eth') {
handleCurrencySelect(ETHER)
} else if (filteredSortedTokens.length > 0) {
if (
filteredSortedTokens[0].symbol?.toLowerCase() === searchQuery.trim().toLowerCase() ||
filteredSortedTokens.length === 1
) {
handleCurrencySelect(filteredSortedTokens[0])
}
}
}
},
[filteredSortedTokens, handleCurrencySelect, searchQuery]
)
const selectedListInfo = useSelectedListInfo()
return (
<Column style={{ width: '100%', flex: '1 1' }}>
<PaddedColumn gap="14px">
<RowBetween>
<Text fontWeight={500} fontSize={16}>
Select a token
<QuestionHelper text="Find a token by searching for its name or symbol or by pasting its address below." />
</Text>
<CloseIcon onClick={onDismiss} />
</RowBetween>
<SearchInput
type="text"
id="token-search-input"
placeholder={t('tokenSearchPlaceholder')}
value={searchQuery}
ref={inputRef as RefObject<HTMLInputElement>}
onChange={handleInput}
onKeyDown={handleEnter}
/>
{showCommonBases && (
<CommonBases chainId={chainId} onSelect={handleCurrencySelect} selectedCurrency={selectedCurrency} />
)}
<RowBetween>
<Text fontSize={14} fontWeight={500}>
Token Name
</Text>
<SortButton ascending={invertSearchOrder} toggleSortOrder={() => setInvertSearchOrder(iso => !iso)} />
</RowBetween>
</PaddedColumn>
<Separator />
<div style={{ flex: '1' }}>
<AutoSizer disableWidth>
{({ height }) => (
<CurrencyList
height={height}
showETH={showETH}
currencies={filteredSortedTokens}
onCurrencySelect={handleCurrencySelect}
otherCurrency={otherSelectedCurrency}
selectedCurrency={selectedCurrency}
fixedListRef={fixedList}
/>
)}
</AutoSizer>
</div>
<Separator />
<Card>
<RowBetween>
{selectedListInfo.current ? (
<Row>
{selectedListInfo.current.logoURI ? (
<ListLogo
style={{ marginRight: 12 }}
logoURI={selectedListInfo.current.logoURI}
alt={`${selectedListInfo.current.name} list logo`}
/>
) : null}
<TYPE.main>{selectedListInfo.current.name}</TYPE.main>
</Row>
) : null}
<LinkStyledButton style={{ fontWeight: 500, color: theme.text2, fontSize: 16 }} onClick={onChangeList}>
{selectedListInfo.current ? 'Change' : 'Select a list'}
</LinkStyledButton>
</RowBetween>
</Card>
</Column>
)
}

@ -1,34 +1,18 @@
import { Currency, Token } from '@uniswap/sdk' import { Currency } from '@uniswap/sdk'
import React, { KeyboardEvent, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import { isMobile } from 'react-device-detect' import ReactGA from 'react-ga'
import { useTranslation } from 'react-i18next' import useLast from '../../hooks/useLast'
import { Text } from 'rebass' import { useSelectedListUrl } from '../../state/lists/hooks'
import { ThemeContext } from 'styled-components'
import Card from '../../components/Card'
import { useActiveWeb3React } from '../../hooks'
import { useAllTokens, useToken } from '../../hooks/Tokens'
import useInterval from '../../hooks/useInterval'
import { useAllTokenBalances, useTokenBalance } from '../../state/wallet/hooks'
import { CloseIcon, LinkStyledButton } from '../../theme'
import { isAddress } from '../../utils'
import Column from '../Column'
import Modal from '../Modal' import Modal from '../Modal'
import QuestionHelper from '../QuestionHelper' import { CurrencySearch } from './CurrencySearch'
import { AutoRow, RowBetween } from '../Row' import ListIntroduction from './ListIntroduction'
import Tooltip from '../Tooltip' import { ListSelect } from './ListSelect'
import CommonBases from './CommonBases'
import { filterTokens } from './filtering'
import { useTokenComparator } from './sorting'
import { PaddedColumn, SearchInput } from './styleds'
import CurrencyList from './CurrencyList'
import SortButton from './SortButton'
interface CurrencySearchModalProps { interface CurrencySearchModalProps {
isOpen?: boolean isOpen: boolean
onDismiss?: () => void onDismiss: () => void
hiddenCurrency?: Currency selectedCurrency?: Currency
showSendWithSwap?: boolean onCurrencySelect: (currency: Currency) => void
onCurrencySelect?: (currency: Currency) => void
otherSelectedCurrency?: Currency otherSelectedCurrency?: Currency
showCommonBases?: boolean showCommonBases?: boolean
} }
@ -37,53 +21,18 @@ export default function CurrencySearchModal({
isOpen, isOpen,
onDismiss, onDismiss,
onCurrencySelect, onCurrencySelect,
hiddenCurrency, selectedCurrency,
showSendWithSwap,
otherSelectedCurrency, otherSelectedCurrency,
showCommonBases = false showCommonBases = false
}: CurrencySearchModalProps) { }: CurrencySearchModalProps) {
const { t } = useTranslation() const [listView, setListView] = useState<boolean>(false)
const { account, chainId } = useActiveWeb3React() const lastOpen = useLast(isOpen)
const theme = useContext(ThemeContext)
const [searchQuery, setSearchQuery] = useState<string>('') useEffect(() => {
const [tooltipOpen, setTooltipOpen] = useState<boolean>(false) if (isOpen && !lastOpen) {
const [invertSearchOrder, setInvertSearchOrder] = useState<boolean>(false) setListView(false)
const allTokens = useAllTokens() }
}, [isOpen, lastOpen])
// if the current input is an address, and we don't have the token in context, try to fetch it and import
const searchToken = useToken(searchQuery)
const searchTokenBalance = useTokenBalance(account, searchToken)
const allTokenBalances_ = useAllTokenBalances()
const allTokenBalances = searchToken
? {
[searchToken.address]: searchTokenBalance
}
: allTokenBalances_ ?? {}
const tokenComparator = useTokenComparator(invertSearchOrder)
const filteredTokens: Token[] = useMemo(() => {
if (searchToken) return [searchToken]
return filterTokens(Object.values(allTokens), searchQuery)
}, [searchToken, allTokens, searchQuery])
const filteredSortedTokens: Token[] = useMemo(() => {
if (searchToken) return [searchToken]
const sorted = filteredTokens.sort(tokenComparator)
const symbolMatch = searchQuery
.toLowerCase()
.split(/\s+/)
.filter(s => s.length > 0)
if (symbolMatch.length > 1) return sorted
return [
...(searchToken ? [searchToken] : []),
// sort any exact symbol matches first
...sorted.filter(token => token.symbol.toLowerCase() === symbolMatch[0]),
...sorted.filter(token => token.symbol.toLowerCase() !== symbolMatch[0])
]
}, [filteredTokens, searchQuery, searchToken, tokenComparator])
const handleCurrencySelect = useCallback( const handleCurrencySelect = useCallback(
(currency: Currency) => { (currency: Currency) => {
@ -93,114 +42,41 @@ export default function CurrencySearchModal({
[onDismiss, onCurrencySelect] [onDismiss, onCurrencySelect]
) )
// clear the input on open const handleClickChangeList = useCallback(() => {
useEffect(() => { ReactGA.event({
if (isOpen) setSearchQuery('') category: 'Lists',
}, [isOpen, setSearchQuery]) action: 'Change Lists'
})
// manage focus on modal show setListView(true)
const inputRef = useRef<HTMLInputElement>() }, [])
const handleInput = useCallback(event => { const handleClickBack = useCallback(() => {
const input = event.target.value ReactGA.event({
const checksummedInput = isAddress(input) category: 'Lists',
setSearchQuery(checksummedInput || input) action: 'Back'
setTooltipOpen(false) })
setListView(false)
}, []) }, [])
const openTooltip = useCallback(() => { const selectedListUrl = useSelectedListUrl()
setTooltipOpen(true) const noListSelected = !selectedListUrl
}, [setTooltipOpen])
const closeTooltip = useCallback(() => setTooltipOpen(false), [setTooltipOpen])
useInterval(
() => {
setTooltipOpen(false)
},
tooltipOpen ? 4000 : null,
false
)
const handleEnter = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && filteredSortedTokens.length > 0) {
if (
filteredSortedTokens[0].symbol.toLowerCase() === searchQuery.trim().toLowerCase() ||
filteredSortedTokens.length === 1
) {
handleCurrencySelect(filteredSortedTokens[0])
}
}
},
[filteredSortedTokens, handleCurrencySelect, searchQuery]
)
return ( return (
<Modal <Modal isOpen={isOpen} onDismiss={onDismiss} maxHeight={70} minHeight={noListSelected || listView ? 40 : 70}>
isOpen={isOpen} {noListSelected ? (
onDismiss={onDismiss} <ListIntroduction />
maxHeight={70} ) : listView ? (
initialFocusRef={isMobile ? undefined : inputRef} <ListSelect onDismiss={onDismiss} onBack={handleClickBack} />
minHeight={70} ) : (
> <CurrencySearch
<Column style={{ width: '100%' }}> isOpen={isOpen}
<PaddedColumn gap="14px"> onDismiss={onDismiss}
<RowBetween>
<Text fontWeight={500} fontSize={16}>
Select a token
<QuestionHelper
disabled={tooltipOpen}
text="Find a token by searching for its name or symbol or by pasting its address below."
/>
</Text>
<CloseIcon onClick={onDismiss} />
</RowBetween>
<Tooltip
text="Import any token into your list by pasting the token address into the search field."
show={tooltipOpen}
placement="bottom"
>
<SearchInput
type="text"
id="token-search-input"
placeholder={t('tokenSearchPlaceholder')}
value={searchQuery}
ref={inputRef}
onChange={handleInput}
onFocus={closeTooltip}
onBlur={closeTooltip}
onKeyDown={handleEnter}
/>
</Tooltip>
{showCommonBases && (
<CommonBases chainId={chainId} onSelect={handleCurrencySelect} selectedCurrency={hiddenCurrency} />
)}
<RowBetween>
<Text fontSize={14} fontWeight={500}>
Token Name
</Text>
<SortButton ascending={invertSearchOrder} toggleSortOrder={() => setInvertSearchOrder(iso => !iso)} />
</RowBetween>
</PaddedColumn>
<div style={{ width: '100%', height: '1px', backgroundColor: theme.bg2 }} />
<CurrencyList
currencies={filteredSortedTokens}
allBalances={allTokenBalances}
onCurrencySelect={handleCurrencySelect} onCurrencySelect={handleCurrencySelect}
otherCurrency={otherSelectedCurrency} onChangeList={handleClickChangeList}
selectedCurrency={hiddenCurrency} selectedCurrency={selectedCurrency}
showSendWithSwap={showSendWithSwap} otherSelectedCurrency={otherSelectedCurrency}
showCommonBases={showCommonBases}
/> />
<div style={{ width: '100%', height: '1px', backgroundColor: theme.bg2 }} /> )}
<Card>
<AutoRow justify={'center'}>
<div>
<LinkStyledButton style={{ fontWeight: 500, color: theme.text2, fontSize: 16 }} onClick={openTooltip}>
Having trouble finding a token?
</LinkStyledButton>
</div>
</AutoRow>
</Card>
</Column>
</Modal> </Modal>
) )
} }

@ -0,0 +1,80 @@
import React, { memo, useCallback, useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { Text } from 'rebass'
import { AppDispatch, AppState } from '../../state'
import { addList, selectList } from '../../state/lists/actions'
import { ExternalLink } from '../../theme'
import { ButtonPrimary } from '../Button'
import { OutlineCard, GreyCard } from '../Card'
import Column, { AutoColumn } from '../Column'
import ListLogo from '../ListLogo'
import Row from '../Row'
import { PaddedColumn } from './styleds'
const ListCard = memo(function ListCard({ id, listUrl }: { id: string; listUrl: string }) {
const dispatch = useDispatch<AppDispatch>()
const listsByUrl = useSelector<AppState, AppState['lists']['byUrl']>(state => state.lists.byUrl)
const list = listsByUrl[listUrl]?.current
useEffect(() => {
if (!listsByUrl[listUrl]) dispatch(addList(listUrl))
}, [dispatch, listUrl, listsByUrl])
const handleSelect = useCallback(() => {
dispatch(selectList(listUrl))
}, [dispatch, listUrl])
if (!list) return null
return (
<OutlineCard style={{ padding: '0.5rem .75rem' }} id={id}>
<Row align="center">
{list.logoURI ? (
<ListLogo style={{ marginRight: '0.5rem' }} logoURI={list.logoURI} alt={`${list.name} list logo`} />
) : null}
<Text fontWeight={500} style={{ flex: '1' }}>
{list.name}
</Text>
<ButtonPrimary
className="select-button"
style={{ width: '6rem', padding: '0.5rem .35rem', borderRadius: '12px' }}
onClick={handleSelect}
>
Select
</ButtonPrimary>
</Row>
</OutlineCard>
)
})
export default function ListIntroduction() {
return (
<Column style={{ width: '100%', flex: '1 1' }}>
<PaddedColumn>
<AutoColumn gap="14px">
<Text fontWeight={600} fontSize={20}>
Select a list
</Text>
<Text style={{ marginBottom: '8px' }}>
Get started by selecting a token list below. You can switch between token lists and add your own custom
lists via IPFS, HTTPS and ENS.
</Text>
<ListCard id="select-kleros-list" listUrl={'t2crtokens.eth'} />
<ListCard
id="select-1inch-list"
listUrl={'https://www.coingecko.com/tokens_list/uniswap/defi_100/v_0_0_0.json'}
/>
<ListCard id="select-default-uniswap-list" listUrl={'tokens.uniswap.eth'} />
<GreyCard style={{ marginBottom: '8px', padding: '1rem' }}>
<Text fontWeight={400} fontSize={14} style={{ textAlign: 'center' }}>
Token lists are an{' '}
<ExternalLink href="https://github.com/uniswap/token-lists">open specification</ExternalLink>. Check out{' '}
<ExternalLink href="https://tokenlists.org">tokenlists.org</ExternalLink> to find more lists.
</Text>
</GreyCard>
</AutoColumn>
</PaddedColumn>
</Column>
)
}

@ -0,0 +1,372 @@
import React, { memo, useCallback, useMemo, useRef, useState } from 'react'
import { ArrowLeft } from 'react-feather'
import ReactGA from 'react-ga'
import { usePopper } from 'react-popper'
import { useDispatch, useSelector } from 'react-redux'
import { Text } from 'rebass'
import styled from 'styled-components'
import { ReactComponent as DropDown } from '../../assets/images/dropdown.svg'
import { useFetchListCallback } from '../../hooks/useFetchListCallback'
import { useOnClickOutside } from '../../hooks/useOnClickOutside'
import useToggle from '../../hooks/useToggle'
import { AppDispatch, AppState } from '../../state'
import { acceptListUpdate, removeList, selectList } from '../../state/lists/actions'
import { useSelectedListUrl } from '../../state/lists/hooks'
import { CloseIcon, ExternalLink, LinkStyledButton, TYPE } from '../../theme'
import listVersionLabel from '../../utils/listVersionLabel'
import { parseENSAddress } from '../../utils/parseENSAddress'
import uriToHttp from '../../utils/uriToHttp'
import { ButtonOutlined, ButtonPrimary, ButtonSecondary } from '../Button'
import Column from '../Column'
import ListLogo from '../ListLogo'
import QuestionHelper from '../QuestionHelper'
import Row, { RowBetween } from '../Row'
import { PaddedColumn, SearchInput, Separator, SeparatorDark } from './styleds'
const UnpaddedLinkStyledButton = styled(LinkStyledButton)`
padding: 0;
font-size: 1rem;
opacity: ${({ disabled }) => (disabled ? '0.4' : '1')};
`
const PopoverContainer = styled.div<{ show: boolean }>`
z-index: 100;
visibility: ${props => (props.show ? 'visible' : 'hidden')};
opacity: ${props => (props.show ? 1 : 0)};
transition: visibility 150ms linear, opacity 150ms linear;
background: ${({ theme }) => theme.bg2};
border: 1px solid ${({ theme }) => theme.bg3};
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);
color: ${({ theme }) => theme.text2};
border-radius: 0.5rem;
padding: 1rem;
display: grid;
grid-template-rows: 1fr;
grid-gap: 8px;
font-size: 1rem;
text-align: left;
`
const StyledMenu = styled.div`
display: flex;
justify-content: center;
align-items: center;
position: relative;
border: none;
`
const StyledListUrlText = styled.div`
max-width: 160px;
opacity: 0.6;
margin-right: 0.5rem;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
`
function ListOrigin({ listUrl }: { listUrl: string }) {
const ensName = useMemo(() => parseENSAddress(listUrl)?.ensName, [listUrl])
const host = useMemo(() => {
if (ensName) return undefined
try {
const url = new URL(listUrl)
return url.host
} catch (error) {
return undefined
}
}, [listUrl, ensName])
return <>{ensName ?? host}</>
}
const ListRow = memo(function ListRow({ listUrl, onBack }: { listUrl: string; onBack: () => void }) {
const listsByUrl = useSelector<AppState, AppState['lists']['byUrl']>(state => state.lists.byUrl)
const selectedListUrl = useSelectedListUrl()
const dispatch = useDispatch<AppDispatch>()
const { current: list, pendingUpdate: pending } = listsByUrl[listUrl]
const isSelected = listUrl === selectedListUrl
const [open, toggle] = useToggle(false)
const node = useRef<HTMLDivElement>()
const [referenceElement, setReferenceElement] = useState<HTMLDivElement>()
const [popperElement, setPopperElement] = useState<HTMLDivElement>()
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: 'auto',
strategy: 'fixed',
modifiers: [{ name: 'offset', options: { offset: [8, 8] } }]
})
useOnClickOutside(node, open ? toggle : undefined)
const selectThisList = useCallback(() => {
if (isSelected) return
ReactGA.event({
category: 'Lists',
action: 'Select List',
label: listUrl
})
dispatch(selectList(listUrl))
onBack()
}, [dispatch, isSelected, listUrl, onBack])
const handleAcceptListUpdate = useCallback(() => {
if (!pending) return
ReactGA.event({
category: 'Lists',
action: 'Update List from List Select',
label: listUrl
})
dispatch(acceptListUpdate(listUrl))
}, [dispatch, listUrl, pending])
const handleRemoveList = useCallback(() => {
ReactGA.event({
category: 'Lists',
action: 'Start Remove List',
label: listUrl
})
if (window.prompt(`Please confirm you would like to remove this list by typing REMOVE`) === `REMOVE`) {
ReactGA.event({
category: 'Lists',
action: 'Confirm Remove List',
label: listUrl
})
dispatch(removeList(listUrl))
}
}, [dispatch, listUrl])
if (!list) return null
return (
<Row key={listUrl} align="center" padding="16px">
{list.logoURI ? (
<ListLogo style={{ marginRight: '1rem' }} logoURI={list.logoURI} alt={`${list.name} list logo`} />
) : (
<div style={{ width: '24px', height: '24px', marginRight: '1rem' }} />
)}
<Column style={{ flex: '1' }}>
<Row>
<Text
fontWeight={isSelected ? 500 : 400}
fontSize={16}
style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}
title={listUrl}
>
{list.name}
</Text>
</Row>
<Row
style={{
marginTop: '4px'
}}
>
<StyledListUrlText title={listUrl}>
<ListOrigin listUrl={listUrl} />
</StyledListUrlText>
</Row>
</Column>
<StyledMenu ref={node as any}>
<ButtonOutlined
style={{
width: '2rem',
padding: '.8rem .35rem',
borderRadius: '12px',
fontSize: '14px',
marginRight: '0.5rem'
}}
onClick={toggle}
ref={setReferenceElement}
>
<DropDown />
</ButtonOutlined>
{open && (
<PopoverContainer show={true} ref={setPopperElement as any} style={styles.popper} {...attributes.popper}>
<div>{list && listVersionLabel(list.version)}</div>
<SeparatorDark />
<ExternalLink href={`https://tokenlists.org/token-list?url=${listUrl}`}>View list</ExternalLink>
<UnpaddedLinkStyledButton onClick={handleRemoveList} disabled={Object.keys(listsByUrl).length === 1}>
Remove list
</UnpaddedLinkStyledButton>
{pending && (
<UnpaddedLinkStyledButton onClick={handleAcceptListUpdate}>Update list</UnpaddedLinkStyledButton>
)}
</PopoverContainer>
)}
</StyledMenu>
{isSelected ? (
<ButtonPrimary
disabled={true}
className="select-button"
style={{ width: '5rem', minWidth: '5rem', padding: '0.5rem .35rem', borderRadius: '12px', fontSize: '14px' }}
>
Selected
</ButtonPrimary>
) : (
<>
<ButtonPrimary
className="select-button"
style={{
width: '5rem',
minWidth: '4.5rem',
padding: '0.5rem .35rem',
borderRadius: '12px',
fontSize: '14px'
}}
onClick={selectThisList}
>
Select
</ButtonPrimary>
</>
)}
</Row>
)
})
const AddListButton = styled(ButtonSecondary)`
/* height: 1.8rem; */
max-width: 4rem;
margin-left: 1rem;
border-radius: 12px;
padding: 10px 18px;
`
const ListContainer = styled.div`
flex: 1;
overflow: auto;
`
export function ListSelect({ onDismiss, onBack }: { onDismiss: () => void; onBack: () => void }) {
const [listUrlInput, setListUrlInput] = useState<string>('')
const dispatch = useDispatch<AppDispatch>()
const lists = useSelector<AppState, AppState['lists']['byUrl']>(state => state.lists.byUrl)
const adding = Boolean(lists[listUrlInput]?.loadingRequestId)
const [addError, setAddError] = useState<string | null>(null)
const handleInput = useCallback(e => {
setListUrlInput(e.target.value)
setAddError(null)
}, [])
const fetchList = useFetchListCallback()
const handleAddList = useCallback(() => {
if (adding) return
setAddError(null)
fetchList(listUrlInput)
.then(() => {
setListUrlInput('')
ReactGA.event({
category: 'Lists',
action: 'Add List',
label: listUrlInput
})
})
.catch(error => {
ReactGA.event({
category: 'Lists',
action: 'Add List Failed',
label: listUrlInput
})
setAddError(error.message)
dispatch(removeList(listUrlInput))
})
}, [adding, dispatch, fetchList, listUrlInput])
const validUrl: boolean = useMemo(() => {
return uriToHttp(listUrlInput).length > 0 || Boolean(parseENSAddress(listUrlInput))
}, [listUrlInput])
const handleEnterKey = useCallback(
e => {
if (validUrl && e.key === 'Enter') {
handleAddList()
}
},
[handleAddList, validUrl]
)
const sortedLists = useMemo(() => {
const listUrls = Object.keys(lists)
return listUrls
.filter(listUrl => {
return Boolean(lists[listUrl].current)
})
.sort((u1, u2) => {
const { current: l1 } = lists[u1]
const { current: l2 } = lists[u2]
if (l1 && l2) {
return l1.name.toLowerCase() < l2.name.toLowerCase()
? -1
: l1.name.toLowerCase() === l2.name.toLowerCase()
? 0
: 1
}
if (l1) return -1
if (l2) return 1
return 0
})
}, [lists])
return (
<Column style={{ width: '100%', flex: '1 1' }}>
<PaddedColumn>
<RowBetween>
<div>
<ArrowLeft style={{ cursor: 'pointer' }} onClick={onBack} />
</div>
<Text fontWeight={500} fontSize={20}>
Manage Lists
</Text>
<CloseIcon onClick={onDismiss} />
</RowBetween>
</PaddedColumn>
<Separator />
<PaddedColumn gap="14px">
<Text fontWeight={600}>
Add a list{' '}
<QuestionHelper text="Token lists are an open specification for lists of ERC20 tokens. You can use any token list by entering its URL below. Beware that third party token lists can contain fake or malicious ERC20 tokens." />
</Text>
<Row>
<SearchInput
type="text"
id="list-add-input"
placeholder="https:// or ipfs:// or ENS name"
value={listUrlInput}
onChange={handleInput}
onKeyDown={handleEnterKey}
style={{ height: '2.75rem', borderRadius: 12, padding: '12px' }}
/>
<AddListButton onClick={handleAddList} disabled={!validUrl}>
Add
</AddListButton>
</Row>
{addError ? (
<TYPE.error title={addError} style={{ textOverflow: 'ellipsis', overflow: 'hidden' }} error>
{addError}
</TYPE.error>
) : null}
</PaddedColumn>
<Separator />
<ListContainer>
{sortedLists.map(listUrl => (
<ListRow key={listUrl} listUrl={listUrl} onBack={onBack} />
))}
</ListContainer>
<Separator />
<ExternalLink style={{ margin: '16px', textAlign: 'center' }} href="https://tokenlists.org">
Browse lists
</ExternalLink>
</Column>
)
}

@ -1,6 +1,5 @@
import { Token, TokenAmount, WETH } from '@uniswap/sdk' import { Token, TokenAmount } from '@uniswap/sdk'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useActiveWeb3React } from '../../hooks'
import { useAllTokenBalances } from '../../state/wallet/hooks' import { useAllTokenBalances } from '../../state/wallet/hooks'
// compare two token amounts with highest one coming first // compare two token amounts with highest one coming first
@ -15,20 +14,13 @@ function balanceComparator(balanceA?: TokenAmount, balanceB?: TokenAmount) {
return 0 return 0
} }
function getTokenComparator( function getTokenComparator(balances: {
weth: Token | undefined, [tokenAddress: string]: TokenAmount | undefined
balances: { [tokenAddress: string]: TokenAmount } }): (tokenA: Token, tokenB: Token) => number {
): (tokenA: Token, tokenB: Token) => number {
return function sortTokens(tokenA: Token, tokenB: Token): number { return function sortTokens(tokenA: Token, tokenB: Token): number {
// -1 = a is first // -1 = a is first
// 1 = b is first // 1 = b is first
// sort ETH first
if (weth) {
if (tokenA.equals(weth)) return -1
if (tokenB.equals(weth)) return 1
}
// sort by balances // sort by balances
const balanceA = balances[tokenA.address] const balanceA = balances[tokenA.address]
const balanceB = balances[tokenB.address] const balanceB = balances[tokenB.address]
@ -36,16 +28,18 @@ function getTokenComparator(
const balanceComp = balanceComparator(balanceA, balanceB) const balanceComp = balanceComparator(balanceA, balanceB)
if (balanceComp !== 0) return balanceComp if (balanceComp !== 0) return balanceComp
// sort by symbol if (tokenA.symbol && tokenB.symbol) {
return tokenA.symbol.toLowerCase() < tokenB.symbol.toLowerCase() ? -1 : 1 // sort by symbol
return tokenA.symbol.toLowerCase() < tokenB.symbol.toLowerCase() ? -1 : 1
} else {
return tokenA.symbol ? -1 : tokenB.symbol ? -1 : 0
}
} }
} }
export function useTokenComparator(inverted: boolean): (tokenA: Token, tokenB: Token) => number { export function useTokenComparator(inverted: boolean): (tokenA: Token, tokenB: Token) => number {
const { chainId } = useActiveWeb3React()
const weth = WETH[chainId]
const balances = useAllTokenBalances() const balances = useAllTokenBalances()
const comparator = useMemo(() => getTokenComparator(weth, balances ?? {}), [balances, weth]) const comparator = useMemo(() => getTokenComparator(balances ?? {}), [balances])
return useMemo(() => { return useMemo(() => {
if (inverted) { if (inverted) {
return (tokenA: Token, tokenB: Token) => comparator(tokenA, tokenB) * -1 return (tokenA: Token, tokenB: Token) => comparator(tokenA, tokenB) * -1

@ -17,12 +17,26 @@ export const FadedSpan = styled(RowFixed)`
font-size: 14px; font-size: 14px;
` `
export const GreySpan = styled.span` export const PaddedColumn = styled(AutoColumn)`
color: ${({ theme }) => theme.text3}; padding: 20px;
font-weight: 400; padding-bottom: 12px;
` `
export const Input = styled.input` export const MenuItem = styled(RowBetween)`
padding: 4px 20px;
height: 56px;
display: grid;
grid-template-columns: auto minmax(auto, 1fr) auto minmax(0, 72px);
grid-gap: 16px;
cursor: ${({ disabled }) => !disabled && 'pointer'};
pointer-events: ${({ disabled }) => disabled && 'none'};
:hover {
background-color: ${({ theme, disabled }) => !disabled && theme.bg2};
}
opacity: ${({ disabled, selected }) => (disabled || selected ? 0.5 : 1)};
`
export const SearchInput = styled.input`
position: relative; position: relative;
display: flex; display: flex;
padding: 16px; padding: 16px;
@ -43,28 +57,20 @@ export const Input = styled.input`
::placeholder { ::placeholder {
color: ${({ theme }) => theme.text3}; color: ${({ theme }) => theme.text3};
} }
`
export const PaddedColumn = styled(AutoColumn)`
padding: 20px;
padding-bottom: 12px;
`
export const MenuItem = styled(RowBetween)`
padding: 4px 20px;
height: 56px;
cursor: ${({ disabled }) => !disabled && 'pointer'};
pointer-events: ${({ disabled }) => disabled && 'none'};
:hover {
background-color: ${({ theme, disabled }) => !disabled && theme.bg2};
}
opacity: ${({ disabled, selected }) => (disabled || selected ? 0.5 : 1)};
`
export const SearchInput = styled(Input)`
transition: border 100ms; transition: border 100ms;
:focus { :focus {
border: 1px solid ${({ theme }) => theme.primary1}; border: 1px solid ${({ theme }) => theme.primary1};
outline: none; outline: none;
} }
` `
export const Separator = styled.div`
width: 100%;
height: 1px;
background-color: ${({ theme }) => theme.bg2};
`
export const SeparatorDark = styled.div`
width: 100%;
height: 1px;
background-color: ${({ theme }) => theme.bg3};
`

@ -0,0 +1,4 @@
{
"extends": "../../../tsconfig.strict.json",
"include": ["**/*"]
}

@ -1,14 +1,14 @@
import React, { useRef, useEffect, useContext, useState } from 'react' import React, { useRef, useContext, useState } from 'react'
import { Settings, X } from 'react-feather' import { Settings, X } from 'react-feather'
import styled from 'styled-components' import styled from 'styled-components'
import { useOnClickOutside } from '../../hooks/useOnClickOutside'
import { import {
useUserSlippageTolerance, useUserSlippageTolerance,
useExpertModeManager, useExpertModeManager,
useUserDeadline, useUserDeadline,
useDarkModeManager useDarkModeManager
} from '../../state/user/hooks' } from '../../state/user/hooks'
import SlippageTabs from '../SlippageTabs' import TransactionSettings from '../TransactionSettings'
import { RowFixed, RowBetween } from '../Row' import { RowFixed, RowBetween } from '../Row'
import { TYPE } from '../../theme' import { TYPE } from '../../theme'
import QuestionHelper from '../QuestionHelper' import QuestionHelper from '../QuestionHelper'
@ -138,24 +138,7 @@ export default function SettingsTab() {
// show confirmation view before turning on // show confirmation view before turning on
const [showConfirmation, setShowConfirmation] = useState(false) const [showConfirmation, setShowConfirmation] = useState(false)
useEffect(() => { useOnClickOutside(node, open ? toggle : undefined)
const handleClickOutside = e => {
if (node.current?.contains(e.target) ?? false) {
return
}
toggle()
}
if (open) {
document.addEventListener('mousedown', handleClickOutside)
} else {
document.removeEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [open, toggle])
return ( return (
<StyledMenu ref={node}> <StyledMenu ref={node}>
@ -212,7 +195,7 @@ export default function SettingsTab() {
<Text fontWeight={600} fontSize={14}> <Text fontWeight={600} fontSize={14}>
Transaction Settings Transaction Settings
</Text> </Text>
<SlippageTabs <TransactionSettings
rawSlippage={userSlippageTolerance} rawSlippage={userSlippageTolerance}
setRawSlippage={setUserslippageTolerance} setRawSlippage={setUserslippageTolerance}
deadline={deadline} deadline={deadline}

@ -1,7 +1,7 @@
import React, { useCallback } from 'react' import React, { useCallback } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
const StyledRangeInput = styled.input<{ value: number }>` const StyledRangeInput = styled.input<{ size: number }>`
-webkit-appearance: none; /* Hides the slider so that custom slider can be made */ -webkit-appearance: none; /* Hides the slider so that custom slider can be made */
width: 100%; /* Specific width is required for Firefox. */ width: 100%; /* Specific width is required for Firefox. */
background: transparent; /* Otherwise white in Chrome */ background: transparent; /* Otherwise white in Chrome */
@ -17,8 +17,8 @@ const StyledRangeInput = styled.input<{ value: number }>`
&::-webkit-slider-thumb { &::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
height: 28px; height: ${({ size }) => size}px;
width: 28px; width: ${({ size }) => size}px;
background-color: #565a69; background-color: #565a69;
border-radius: 100%; border-radius: 100%;
border: none; border: none;
@ -33,8 +33,8 @@ const StyledRangeInput = styled.input<{ value: number }>`
} }
&::-moz-range-thumb { &::-moz-range-thumb {
height: 28px; height: ${({ size }) => size}px;
width: 28px; width: ${({ size }) => size}px;
background-color: #565a69; background-color: #565a69;
border-radius: 100%; border-radius: 100%;
border: none; border: none;
@ -48,8 +48,8 @@ const StyledRangeInput = styled.input<{ value: number }>`
} }
&::-ms-thumb { &::-ms-thumb {
height: 28px; height: ${({ size }) => size}px;
width: 28px; width: ${({ size }) => size}px;
background-color: #565a69; background-color: #565a69;
border-radius: 100%; border-radius: 100%;
color: ${({ theme }) => theme.bg1}; color: ${({ theme }) => theme.bg1};
@ -62,24 +62,12 @@ const StyledRangeInput = styled.input<{ value: number }>`
} }
&::-webkit-slider-runnable-track { &::-webkit-slider-runnable-track {
background: linear-gradient( background: linear-gradient(90deg, ${({ theme }) => theme.bg5}, ${({ theme }) => theme.bg3});
90deg,
${({ theme }) => theme.bg5},
${({ theme }) => theme.bg5} ${({ value }) => value}%,
${({ theme }) => theme.bg3} ${({ value }) => value}%,
${({ theme }) => theme.bg3}
);
height: 2px; height: 2px;
} }
&::-moz-range-track { &::-moz-range-track {
background: linear-gradient( background: linear-gradient(90deg, ${({ theme }) => theme.bg5}, ${({ theme }) => theme.bg3});
90deg,
${({ theme }) => theme.bg5},
${({ theme }) => theme.bg5} ${({ value }) => value}%,
${({ theme }) => theme.bg3} ${({ value }) => value}%,
${({ theme }) => theme.bg3}
);
height: 2px; height: 2px;
} }
@ -102,26 +90,31 @@ const StyledRangeInput = styled.input<{ value: number }>`
interface InputSliderProps { interface InputSliderProps {
value: number value: number
onChange: (value: number) => void onChange: (value: number) => void
step?: number
min?: number
max?: number
size?: number
} }
export default function InputSlider({ value, onChange }: InputSliderProps) { export default function Slider({ value, onChange, min = 0, step = 1, max = 100, size = 28 }: InputSliderProps) {
const changeCallback = useCallback( const changeCallback = useCallback(
e => { e => {
onChange(e.target.value) onChange(parseInt(e.target.value))
}, },
[onChange] [onChange]
) )
return ( return (
<StyledRangeInput <StyledRangeInput
size={size}
type="range" type="range"
value={value} value={value}
style={{ width: '90%', marginLeft: 15, marginRight: 15, padding: '15px 0' }} style={{ width: '90%', marginLeft: 15, marginRight: 15, padding: '15px 0' }}
onChange={changeCallback} onChange={changeCallback}
aria-labelledby="input-slider" aria-labelledby="input slider"
step={1} step={step}
min={0} min={min}
max={100} max={max}
/> />
) )
} }

@ -1,139 +0,0 @@
import { Currency, Token } from '@uniswap/sdk'
import { transparentize } from 'polished'
import React, { useMemo } from 'react'
import styled from 'styled-components'
import { useActiveWeb3React } from '../../hooks'
import { useAllTokens } from '../../hooks/Tokens'
import { useDefaultTokenList } from '../../state/lists/hooks'
import { Field } from '../../state/swap/actions'
import { ExternalLink, TYPE } from '../../theme'
import { getEtherscanLink, isDefaultToken } from '../../utils'
import PropsOfExcluding from '../../utils/props-of-excluding'
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 }>`
background: ${({ theme }) => transparentize(0.6, theme.white)};
padding: 0.75rem;
border-radius: 20px;
`
const WarningContainer = styled.div`
max-width: 420px;
width: 100%;
padding: 1rem;
background: rgba(242, 150, 2, 0.05);
border: 1px solid #f3841e;
box-sizing: border-box;
border-radius: 20px;
margin-bottom: 2rem;
`
const StyledWarningIcon = styled(AlertTriangle)`
stroke: ${({ theme }) => theme.red2};
`
interface TokenWarningCardProps extends PropsOfExcluding<typeof Wrapper, 'error'> {
token?: Token
}
export default function TokenWarningCard({ token, ...rest }: TokenWarningCardProps) {
const { chainId } = useActiveWeb3React()
const defaultTokens = useDefaultTokenList()
const isDefault = isDefaultToken(defaultTokens, token)
const tokenSymbol = token?.symbol?.toLowerCase() ?? ''
const tokenName = token?.name?.toLowerCase() ?? ''
const allTokens = useAllTokens()
const duplicateNameOrSymbol = useMemo(() => {
if (isDefault || !token || !chainId) return false
return Object.keys(allTokens).some(tokenAddress => {
const userToken = allTokens[tokenAddress]
if (userToken.equals(token)) {
return false
}
return userToken.symbol.toLowerCase() === tokenSymbol || userToken.name.toLowerCase() === tokenName
})
}, [isDefault, token, chainId, allTokens, tokenSymbol, tokenName])
if (isDefault || !token) return null
return (
<Wrapper error={duplicateNameOrSymbol} {...rest}>
<AutoRow gap="6px">
<AutoColumn gap="24px">
<CurrencyLogo currency={token} size={'16px'} />
<div> </div>
</AutoColumn>
<AutoColumn gap="10px" justify="flex-start">
<TYPE.main>
{token && token.name && token.symbol && token.name !== token.symbol
? `${token.name} (${token.symbol})`
: token.name || token.symbol}
</TYPE.main>
<ExternalLink style={{ fontWeight: 400 }} href={getEtherscanLink(chainId, token.address, 'token')}>
<TYPE.blue> (View on Etherscan)</TYPE.blue>
</ExternalLink>
</AutoColumn>
</AutoRow>
</Wrapper>
)
}
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 (
<WarningContainer className="token-warning-container">
<AutoColumn gap="lg">
<AutoRow gap="6px">
<StyledWarningIcon />
<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>
)
}

@ -0,0 +1,151 @@
import { Token } from '@uniswap/sdk'
import { transparentize } from 'polished'
import React, { useCallback, useMemo, useState } from 'react'
import styled from 'styled-components'
import { useActiveWeb3React } from '../../hooks'
import { useAllTokens } from '../../hooks/Tokens'
import { ExternalLink, TYPE } from '../../theme'
import { getEtherscanLink, shortenAddress } from '../../utils'
import CurrencyLogo from '../CurrencyLogo'
import Modal from '../Modal'
import { AutoRow, RowBetween } from '../Row'
import { AutoColumn } from '../Column'
import { AlertTriangle } from 'react-feather'
import { ButtonError } from '../Button'
const Wrapper = styled.div<{ error: boolean }>`
background: ${({ theme }) => transparentize(0.6, theme.bg3)};
padding: 0.75rem;
border-radius: 20px;
`
const WarningContainer = styled.div`
max-width: 420px;
width: 100%;
padding: 1rem;
background: rgba(242, 150, 2, 0.05);
border: 1px solid #f3841e;
border-radius: 20px;
overflow: auto;
`
const StyledWarningIcon = styled(AlertTriangle)`
stroke: ${({ theme }) => theme.red2};
`
interface TokenWarningCardProps {
token?: Token
}
function TokenWarningCard({ token }: TokenWarningCardProps) {
const { chainId } = useActiveWeb3React()
const tokenSymbol = token?.symbol?.toLowerCase() ?? ''
const tokenName = token?.name?.toLowerCase() ?? ''
const allTokens = useAllTokens()
const duplicateNameOrSymbol = useMemo(() => {
if (!token || !chainId) return false
return Object.keys(allTokens).some(tokenAddress => {
const userToken = allTokens[tokenAddress]
if (userToken.equals(token)) {
return false
}
return userToken.symbol.toLowerCase() === tokenSymbol || userToken.name.toLowerCase() === tokenName
})
}, [token, chainId, allTokens, tokenSymbol, tokenName])
if (!token) return null
return (
<Wrapper error={duplicateNameOrSymbol}>
<AutoRow gap="6px">
<AutoColumn gap="24px">
<CurrencyLogo currency={token} size={'16px'} />
<div> </div>
</AutoColumn>
<AutoColumn gap="10px" justify="flex-start">
<TYPE.main>
{token && token.name && token.symbol && token.name !== token.symbol
? `${token.name} (${token.symbol})`
: token.name || token.symbol}{' '}
</TYPE.main>
<ExternalLink style={{ fontWeight: 400 }} href={getEtherscanLink(chainId, token.address, 'token')}>
<TYPE.blue title={token.address}>{shortenAddress(token.address)} (View on Etherscan)</TYPE.blue>
</ExternalLink>
</AutoColumn>
</AutoRow>
</Wrapper>
)
}
export default function TokenWarningModal({
isOpen,
tokens,
onConfirm
}: {
isOpen: boolean
tokens: Token[]
onConfirm: () => void
}) {
const [understandChecked, setUnderstandChecked] = useState(false)
const toggleUnderstand = useCallback(() => setUnderstandChecked(uc => !uc), [])
const handleDismiss = useCallback(() => null, [])
return (
<Modal isOpen={isOpen} onDismiss={handleDismiss} maxHeight={90}>
<WarningContainer className="token-warning-container">
<AutoColumn gap="lg">
<AutoRow gap="6px">
<StyledWarningIcon />
<TYPE.main color={'red2'}>Token imported</TYPE.main>
</AutoRow>
<TYPE.body color={'red2'}>
Anyone can create an ERC20 token on Ethereum with <em>any</em> name, 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'}>
This interface can load arbitrary tokens by token addresses. Please take extra caution and do your research
when interacting with arbitrary ERC20 tokens.
</TYPE.body>
<TYPE.body color={'red2'}>
If you purchase an arbitrary token, <strong>you may be unable to sell it back.</strong>
</TYPE.body>
{tokens.map(token => {
return <TokenWarningCard key={token.address} token={token} />
})}
<RowBetween>
<div>
<label style={{ cursor: 'pointer', userSelect: 'none' }}>
<input
type="checkbox"
className="understand-checkbox"
checked={understandChecked}
onChange={toggleUnderstand}
/>{' '}
I understand
</label>
</div>
<ButtonError
disabled={!understandChecked}
error={true}
width={'140px'}
padding="0.5rem 1rem"
className="token-dismiss-button"
style={{
borderRadius: '10px'
}}
onClick={() => {
onConfirm()
}}
>
<TYPE.body color="white">Continue</TYPE.body>
</ButtonError>
</RowBetween>
</AutoColumn>
</WarningContainer>
</Modal>
)
}

@ -30,7 +30,7 @@ export const SectionBreak = styled.div`
` `
export const BottomGrouping = styled.div` export const BottomGrouping = styled.div`
margin-top: 12px; margin-top: 1rem;
` `
export const ErrorText = styled(Text)<{ severity?: 0 | 1 | 2 | 3 | 4 }>` export const ErrorText = styled(Text)<{ severity?: 0 | 1 | 2 | 3 | 4 }>`

@ -152,6 +152,10 @@ export class NetworkConnector extends AbstractConnector {
}, {}) }, {})
} }
public get provider(): MiniRpcProvider {
return this.providers[this.currentChainId]
}
public async activate(): Promise<ConnectorUpdate> { public async activate(): Promise<ConnectorUpdate> {
return { provider: this.providers[this.currentChainId], chainId: this.currentChainId, account: null } return { provider: this.providers[this.currentChainId], chainId: this.currentChainId, account: null }
} }

@ -1,3 +1,4 @@
import { Web3Provider } from '@ethersproject/providers'
import { InjectedConnector } from '@web3-react/injected-connector' import { InjectedConnector } from '@web3-react/injected-connector'
import { WalletConnectConnector } from '@web3-react/walletconnect-connector' import { WalletConnectConnector } from '@web3-react/walletconnect-connector'
import { WalletLinkConnector } from '@web3-react/walletlink-connector' import { WalletLinkConnector } from '@web3-react/walletlink-connector'
@ -10,14 +11,21 @@ const NETWORK_URL = process.env.REACT_APP_NETWORK_URL
const FORMATIC_KEY = process.env.REACT_APP_FORTMATIC_KEY const FORMATIC_KEY = process.env.REACT_APP_FORTMATIC_KEY
const PORTIS_ID = process.env.REACT_APP_PORTIS_ID const PORTIS_ID = process.env.REACT_APP_PORTIS_ID
export const NETWORK_CHAIN_ID: number = parseInt(process.env.REACT_APP_CHAIN_ID ?? '1')
if (typeof NETWORK_URL === 'undefined') { if (typeof NETWORK_URL === 'undefined') {
throw new Error(`REACT_APP_NETWORK_URL must be a defined environment variable`) throw new Error(`REACT_APP_NETWORK_URL must be a defined environment variable`)
} }
export const network = new NetworkConnector({ export const network = new NetworkConnector({
urls: { [Number(process.env.REACT_APP_CHAIN_ID)]: NETWORK_URL } urls: { [NETWORK_CHAIN_ID]: NETWORK_URL }
}) })
let networkLibrary: Web3Provider | undefined
export function getNetworkLibrary(): Web3Provider {
return (networkLibrary = networkLibrary ?? new Web3Provider(network.provider as any))
}
export const injected = new InjectedConnector({ export const injected = new InjectedConnector({
supportedChainIds: [1, 3, 4, 5, 42] supportedChainIds: [1, 3, 4, 5, 42]
}) })

@ -0,0 +1,816 @@
[
{
"inputs": [
{
"internalType": "contract ENS",
"name": "_ens",
"type": "address"
}
],
"payable": false,
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"indexed": true,
"internalType": "uint256",
"name": "contentType",
"type": "uint256"
}
],
"name": "ABIChanged",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"indexed": false,
"internalType": "address",
"name": "a",
"type": "address"
}
],
"name": "AddrChanged",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"indexed": false,
"internalType": "uint256",
"name": "coinType",
"type": "uint256"
},
{
"indexed": false,
"internalType": "bytes",
"name": "newAddress",
"type": "bytes"
}
],
"name": "AddressChanged",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"indexed": true,
"internalType": "address",
"name": "owner",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "target",
"type": "address"
},
{
"indexed": false,
"internalType": "bool",
"name": "isAuthorised",
"type": "bool"
}
],
"name": "AuthorisationChanged",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"indexed": false,
"internalType": "bytes",
"name": "hash",
"type": "bytes"
}
],
"name": "ContenthashChanged",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"indexed": false,
"internalType": "bytes",
"name": "name",
"type": "bytes"
},
{
"indexed": false,
"internalType": "uint16",
"name": "resource",
"type": "uint16"
},
{
"indexed": false,
"internalType": "bytes",
"name": "record",
"type": "bytes"
}
],
"name": "DNSRecordChanged",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"indexed": false,
"internalType": "bytes",
"name": "name",
"type": "bytes"
},
{
"indexed": false,
"internalType": "uint16",
"name": "resource",
"type": "uint16"
}
],
"name": "DNSRecordDeleted",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
}
],
"name": "DNSZoneCleared",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"indexed": true,
"internalType": "bytes4",
"name": "interfaceID",
"type": "bytes4"
},
{
"indexed": false,
"internalType": "address",
"name": "implementer",
"type": "address"
}
],
"name": "InterfaceChanged",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"indexed": false,
"internalType": "string",
"name": "name",
"type": "string"
}
],
"name": "NameChanged",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"indexed": false,
"internalType": "bytes32",
"name": "x",
"type": "bytes32"
},
{
"indexed": false,
"internalType": "bytes32",
"name": "y",
"type": "bytes32"
}
],
"name": "PubkeyChanged",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"indexed": true,
"internalType": "string",
"name": "indexedKey",
"type": "string"
},
{
"indexed": false,
"internalType": "string",
"name": "key",
"type": "string"
}
],
"name": "TextChanged",
"type": "event"
},
{
"constant": true,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"internalType": "uint256",
"name": "contentTypes",
"type": "uint256"
}
],
"name": "ABI",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
},
{
"internalType": "bytes",
"name": "",
"type": "bytes"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
}
],
"name": "addr",
"outputs": [
{
"internalType": "address payable",
"name": "",
"type": "address"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [
{
"internalType": "bytes32",
"name": "",
"type": "bytes32"
},
{
"internalType": "address",
"name": "",
"type": "address"
},
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"name": "authorisations",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": false,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
}
],
"name": "clearDNSZone",
"outputs": [],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": true,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
}
],
"name": "contenthash",
"outputs": [
{
"internalType": "bytes",
"name": "",
"type": "bytes"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"internalType": "bytes32",
"name": "name",
"type": "bytes32"
},
{
"internalType": "uint16",
"name": "resource",
"type": "uint16"
}
],
"name": "dnsRecord",
"outputs": [
{
"internalType": "bytes",
"name": "",
"type": "bytes"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"internalType": "bytes32",
"name": "name",
"type": "bytes32"
}
],
"name": "hasDNSRecords",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"internalType": "bytes4",
"name": "interfaceID",
"type": "bytes4"
}
],
"name": "interfaceImplementer",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
}
],
"name": "name",
"outputs": [
{
"internalType": "string",
"name": "",
"type": "string"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
}
],
"name": "pubkey",
"outputs": [
{
"internalType": "bytes32",
"name": "x",
"type": "bytes32"
},
{
"internalType": "bytes32",
"name": "y",
"type": "bytes32"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": false,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"internalType": "uint256",
"name": "contentType",
"type": "uint256"
},
{
"internalType": "bytes",
"name": "data",
"type": "bytes"
}
],
"name": "setABI",
"outputs": [],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": false,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"internalType": "uint256",
"name": "coinType",
"type": "uint256"
},
{
"internalType": "bytes",
"name": "a",
"type": "bytes"
}
],
"name": "setAddr",
"outputs": [],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": false,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"internalType": "address",
"name": "a",
"type": "address"
}
],
"name": "setAddr",
"outputs": [],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": false,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"internalType": "address",
"name": "target",
"type": "address"
},
{
"internalType": "bool",
"name": "isAuthorised",
"type": "bool"
}
],
"name": "setAuthorisation",
"outputs": [],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": false,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"internalType": "bytes",
"name": "hash",
"type": "bytes"
}
],
"name": "setContenthash",
"outputs": [],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": false,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"internalType": "bytes",
"name": "data",
"type": "bytes"
}
],
"name": "setDNSRecords",
"outputs": [],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": false,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"internalType": "bytes4",
"name": "interfaceID",
"type": "bytes4"
},
{
"internalType": "address",
"name": "implementer",
"type": "address"
}
],
"name": "setInterface",
"outputs": [],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": false,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"internalType": "string",
"name": "name",
"type": "string"
}
],
"name": "setName",
"outputs": [],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": false,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"internalType": "bytes32",
"name": "x",
"type": "bytes32"
},
{
"internalType": "bytes32",
"name": "y",
"type": "bytes32"
}
],
"name": "setPubkey",
"outputs": [],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": false,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"internalType": "string",
"name": "key",
"type": "string"
},
{
"internalType": "string",
"name": "value",
"type": "string"
}
],
"name": "setText",
"outputs": [],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": true,
"inputs": [
{
"internalType": "bytes4",
"name": "interfaceID",
"type": "bytes4"
}
],
"name": "supportsInterface",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"payable": false,
"stateMutability": "pure",
"type": "function"
},
{
"constant": true,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"internalType": "string",
"name": "key",
"type": "string"
}
],
"name": "text",
"outputs": [
{
"internalType": "string",
"name": "",
"type": "string"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
}
]

@ -0,0 +1,422 @@
[
{
"inputs": [
{
"internalType": "contract ENS",
"name": "_old",
"type": "address"
}
],
"payable": false,
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "owner",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "operator",
"type": "address"
},
{
"indexed": false,
"internalType": "bool",
"name": "approved",
"type": "bool"
}
],
"name": "ApprovalForAll",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"indexed": true,
"internalType": "bytes32",
"name": "label",
"type": "bytes32"
},
{
"indexed": false,
"internalType": "address",
"name": "owner",
"type": "address"
}
],
"name": "NewOwner",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"indexed": false,
"internalType": "address",
"name": "resolver",
"type": "address"
}
],
"name": "NewResolver",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"indexed": false,
"internalType": "uint64",
"name": "ttl",
"type": "uint64"
}
],
"name": "NewTTL",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"indexed": false,
"internalType": "address",
"name": "owner",
"type": "address"
}
],
"name": "Transfer",
"type": "event"
},
{
"constant": true,
"inputs": [
{
"internalType": "address",
"name": "owner",
"type": "address"
},
{
"internalType": "address",
"name": "operator",
"type": "address"
}
],
"name": "isApprovedForAll",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [],
"name": "old",
"outputs": [
{
"internalType": "contract ENS",
"name": "",
"type": "address"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
}
],
"name": "owner",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
}
],
"name": "recordExists",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
}
],
"name": "resolver",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": false,
"inputs": [
{
"internalType": "address",
"name": "operator",
"type": "address"
},
{
"internalType": "bool",
"name": "approved",
"type": "bool"
}
],
"name": "setApprovalForAll",
"outputs": [],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": false,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"internalType": "address",
"name": "owner",
"type": "address"
}
],
"name": "setOwner",
"outputs": [],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": false,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"internalType": "address",
"name": "owner",
"type": "address"
},
{
"internalType": "address",
"name": "resolver",
"type": "address"
},
{
"internalType": "uint64",
"name": "ttl",
"type": "uint64"
}
],
"name": "setRecord",
"outputs": [],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": false,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"internalType": "address",
"name": "resolver",
"type": "address"
}
],
"name": "setResolver",
"outputs": [],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": false,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"internalType": "bytes32",
"name": "label",
"type": "bytes32"
},
{
"internalType": "address",
"name": "owner",
"type": "address"
}
],
"name": "setSubnodeOwner",
"outputs": [
{
"internalType": "bytes32",
"name": "",
"type": "bytes32"
}
],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": false,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"internalType": "bytes32",
"name": "label",
"type": "bytes32"
},
{
"internalType": "address",
"name": "owner",
"type": "address"
},
{
"internalType": "address",
"name": "resolver",
"type": "address"
},
{
"internalType": "uint64",
"name": "ttl",
"type": "uint64"
}
],
"name": "setSubnodeRecord",
"outputs": [],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": false,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
},
{
"internalType": "uint64",
"name": "ttl",
"type": "uint64"
}
],
"name": "setTTL",
"outputs": [],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": true,
"inputs": [
{
"internalType": "bytes32",
"name": "node",
"type": "bytes32"
}
],
"name": "ttl",
"outputs": [
{
"internalType": "uint64",
"name": "",
"type": "uint64"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
}
]

@ -1,5 +1,5 @@
import { AbstractConnector } from '@web3-react/abstract-connector'
import { ChainId, JSBI, Percent, Token, WETH } from '@uniswap/sdk' import { ChainId, JSBI, Percent, Token, WETH } from '@uniswap/sdk'
import { AbstractConnector } from '@web3-react/abstract-connector'
import { fortmatic, injected, portis, walletconnect, walletlink } from '../connectors' import { fortmatic, injected, portis, walletconnect, walletlink } from '../connectors'
@ -162,6 +162,3 @@ 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'

14
src/constants/lists.ts Normal file

@ -0,0 +1,14 @@
// the Uniswap Default token list lives here
export const DEFAULT_TOKEN_LIST_URL = 'tokens.uniswap.eth'
export const DEFAULT_LIST_OF_LISTS: string[] = [
DEFAULT_TOKEN_LIST_URL,
't2crtokens.eth', // kleros
'tokens.1inch.eth', // 1inch
'synths.snx.eth',
'tokenlist.dharma.eth',
'defi.cmc.eth',
'erc20.cmc.eth',
'https://defiprime.com/defiprime.tokenlist.json',
'https://umaproject.org/uma.tokenlist.json'
]

@ -1,7 +1,7 @@
import { parseBytes32String } from '@ethersproject/strings' import { parseBytes32String } from '@ethersproject/strings'
import { Currency, ETHER, Token } from '@uniswap/sdk' import { Currency, ETHER, Token } from '@uniswap/sdk'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useDefaultTokenList } from '../state/lists/hooks' import { useSelectedTokenList } 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,7 +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() const allTokens = useSelectedTokenList()
return useMemo(() => { return useMemo(() => {
if (!chainId) return {} if (!chainId) return {}

@ -17,35 +17,46 @@ function useAllCommonPairs(currencyA?: Currency, currencyB?: Currency): Pair[] {
? [wrappedCurrency(currencyA, chainId), wrappedCurrency(currencyB, chainId)] ? [wrappedCurrency(currencyA, chainId), wrappedCurrency(currencyB, chainId)]
: [undefined, undefined] : [undefined, undefined]
const basePairs: [Token, Token][] = useMemo(
() =>
flatMap(bases, (base): [Token, Token][] => bases.map(otherBase => [base, otherBase])).filter(
([t0, t1]) => t0.address !== t1.address
),
[bases]
)
const allPairCombinations: [Token, Token][] = useMemo( const allPairCombinations: [Token, Token][] = useMemo(
() => () =>
[ tokenA && tokenB
// the direct pair ? [
[tokenA, tokenB], // the direct pair
// token A against all bases [tokenA, tokenB],
...bases.map((base): [Token | undefined, Token | undefined] => [tokenA, base]), // token A against all bases
// token B against all bases ...bases.map((base): [Token, Token] => [tokenA, base]),
...bases.map((base): [Token | undefined, Token | undefined] => [tokenB, base]), // token B against all bases
// each base against all bases ...bases.map((base): [Token, Token] => [tokenB, base]),
...flatMap(bases, (base): [Token, Token][] => bases.map(otherBase => [base, otherBase])) // each base against all bases
] ...basePairs
.filter((tokens): tokens is [Token, Token] => Boolean(tokens[0] && tokens[1])) ]
.filter(([tokenA, tokenB]) => { .filter((tokens): tokens is [Token, Token] => Boolean(tokens[0] && tokens[1]))
if (!chainId) return true .filter(([t0, t1]) => t0.address !== t1.address)
const customBases = CUSTOM_BASES[chainId] .filter(([tokenA, tokenB]) => {
if (!customBases) return true if (!chainId) return true
const customBases = CUSTOM_BASES[chainId]
if (!customBases) return true
const customBasesA: Token[] | undefined = customBases[tokenA.address] const customBasesA: Token[] | undefined = customBases[tokenA.address]
const customBasesB: Token[] | undefined = customBases[tokenB.address] const customBasesB: Token[] | undefined = customBases[tokenB.address]
if (!customBasesA && !customBasesB) return true if (!customBasesA && !customBasesB) return true
if (customBasesA && customBasesA.findIndex(base => tokenB.equals(base)) === -1) return false if (customBasesA && !customBasesA.find(base => tokenB.equals(base))) return false
if (customBasesB && customBasesB.findIndex(base => tokenA.equals(base)) === -1) return false if (customBasesB && !customBasesB.find(base => tokenA.equals(base))) return false
return true return true
}), })
[tokenA, tokenB, bases, chainId] : [],
[tokenA, tokenB, bases, basePairs, chainId]
) )
const allPairs = usePairs(allPairCombinations) const allPairs = usePairs(allPairCombinations)
@ -72,7 +83,6 @@ function useAllCommonPairs(currencyA?: Currency, currencyB?: Currency): Pair[] {
*/ */
export function useTradeExactIn(currencyAmountIn?: CurrencyAmount, currencyOut?: Currency): Trade | null { export function useTradeExactIn(currencyAmountIn?: CurrencyAmount, currencyOut?: Currency): Trade | null {
const allowedPairs = useAllCommonPairs(currencyAmountIn?.currency, currencyOut) const allowedPairs = useAllCommonPairs(currencyAmountIn?.currency, currencyOut)
return useMemo(() => { return useMemo(() => {
if (currencyAmountIn && currencyOut && allowedPairs.length > 0) { if (currencyAmountIn && currencyOut && allowedPairs.length > 0) {
return ( return (

@ -2,18 +2,20 @@ import { Contract } from '@ethersproject/contracts'
import { ChainId, WETH } from '@uniswap/sdk' import { ChainId, WETH } from '@uniswap/sdk'
import { abi as IUniswapV2PairABI } from '@uniswap/v2-core/build/IUniswapV2Pair.json' import { abi as IUniswapV2PairABI } from '@uniswap/v2-core/build/IUniswapV2Pair.json'
import { useMemo } from 'react' import { useMemo } from 'react'
import ENS_ABI from '../constants/abis/ens-registrar.json'
import ENS_PUBLIC_RESOLVER_ABI from '../constants/abis/ens-public-resolver.json'
import { ERC20_BYTES32_ABI } from '../constants/abis/erc20' import { ERC20_BYTES32_ABI } from '../constants/abis/erc20'
import UNISOCKS_ABI from '../constants/abis/unisocks.json'
import ERC20_ABI from '../constants/abis/erc20.json' import ERC20_ABI from '../constants/abis/erc20.json'
import WETH_ABI from '../constants/abis/weth.json'
import { MIGRATOR_ABI, MIGRATOR_ADDRESS } from '../constants/abis/migrator' import { MIGRATOR_ABI, MIGRATOR_ADDRESS } from '../constants/abis/migrator'
import UNISOCKS_ABI from '../constants/abis/unisocks.json'
import WETH_ABI from '../constants/abis/weth.json'
import { MULTICALL_ABI, MULTICALL_NETWORKS } from '../constants/multicall' import { MULTICALL_ABI, MULTICALL_NETWORKS } from '../constants/multicall'
import { V1_EXCHANGE_ABI, V1_FACTORY_ABI, V1_FACTORY_ADDRESSES } from '../constants/v1' import { V1_EXCHANGE_ABI, V1_FACTORY_ABI, V1_FACTORY_ADDRESSES } from '../constants/v1'
import { getContract } from '../utils' import { getContract } from '../utils'
import { useActiveWeb3React } from './index' import { useActiveWeb3React } from './index'
// returns null on errors // returns null on errors
function useContract(address?: string, ABI?: any, withSignerIfPossible = true): Contract | null { function useContract(address: string | undefined, ABI: any, withSignerIfPossible = true): Contract | null {
const { library, account } = useActiveWeb3React() const { library, account } = useActiveWeb3React()
return useMemo(() => { return useMemo(() => {
@ -49,6 +51,26 @@ export function useWETHContract(withSignerIfPossible?: boolean): Contract | null
return useContract(chainId ? WETH[chainId].address : undefined, WETH_ABI, withSignerIfPossible) return useContract(chainId ? WETH[chainId].address : undefined, WETH_ABI, withSignerIfPossible)
} }
export function useENSRegistrarContract(withSignerIfPossible?: boolean): Contract | null {
const { chainId } = useActiveWeb3React()
let address: string | undefined
if (chainId) {
switch (chainId) {
case ChainId.MAINNET:
case ChainId.GÖRLI:
case ChainId.ROPSTEN:
case ChainId.RINKEBY:
address = '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e'
break
}
}
return useContract(address, ENS_ABI, withSignerIfPossible)
}
export function useENSResolverContract(address: string | undefined, withSignerIfPossible?: boolean): Contract | null {
return useContract(address, ENS_PUBLIC_RESOLVER_ABI, withSignerIfPossible)
}
export function useBytes32TokenContract(tokenAddress?: string, withSignerIfPossible?: boolean): Contract | null { export function useBytes32TokenContract(tokenAddress?: string, withSignerIfPossible?: boolean): Contract | null {
return useContract(tokenAddress, ERC20_BYTES32_ABI, withSignerIfPossible) return useContract(tokenAddress, ERC20_BYTES32_ABI, withSignerIfPossible)
} }

@ -1,46 +1,30 @@
import { useEffect, useState } from 'react' import { namehash } from 'ethers/lib/utils'
import { useActiveWeb3React } from './index' import { useMemo } from 'react'
import { useSingleCallResult } from '../state/multicall/hooks'
import { useENSRegistrarContract, useENSResolverContract } from './useContract'
import useDebounce from './useDebounce'
/** /**
* Does a lookup for an ENS name to find its address. * Does a lookup for an ENS name to find its address.
*/ */
export default function useENSAddress(ensName?: string | null): { loading: boolean; address: string | null } { export default function useENSAddress(ensName?: string | null): { loading: boolean; address: string | null } {
const { library } = useActiveWeb3React() const debouncedName = useDebounce(ensName, 200)
const ensNodeArgument = useMemo(() => {
const [address, setAddress] = useState<{ loading: boolean; address: string | null }>({ if (!debouncedName) return [undefined]
loading: false, try {
address: null return debouncedName ? [namehash(debouncedName)] : [undefined]
}) } catch (error) {
return [undefined]
useEffect(() => {
if (!library || typeof ensName !== 'string') {
setAddress({ loading: false, address: null })
return
} else {
let stale = false
setAddress({ loading: true, address: null })
library
.resolveName(ensName)
.then(address => {
if (!stale) {
if (address) {
setAddress({ loading: false, address })
} else {
setAddress({ loading: false, address: null })
}
}
})
.catch(() => {
if (!stale) {
setAddress({ loading: false, address: null })
}
})
return () => {
stale = true
}
} }
}, [library, ensName]) }, [debouncedName])
const registrarContract = useENSRegistrarContract(false)
const resolverAddress = useSingleCallResult(registrarContract, 'resolver', ensNodeArgument)
const resolverContract = useENSResolverContract(resolverAddress.result?.[0], false)
const addr = useSingleCallResult(resolverContract, 'addr', ensNodeArgument)
return address const changed = debouncedName !== ensName
return {
address: changed ? null : addr.result?.[0] ?? null,
loading: changed || resolverAddress.loading || addr.loading
}
} }

@ -0,0 +1,32 @@
import { namehash } from 'ethers/lib/utils'
import { useMemo } from 'react'
import { useSingleCallResult } from '../state/multicall/hooks'
import isZero from '../utils/isZero'
import { useENSRegistrarContract, useENSResolverContract } from './useContract'
/**
* Does a lookup for an ENS name to find its contenthash.
*/
export default function useENSContentHash(ensName?: string | null): { loading: boolean; contenthash: string | null } {
const ensNodeArgument = useMemo(() => {
if (!ensName) return [undefined]
try {
return ensName ? [namehash(ensName)] : [undefined]
} catch (error) {
return [undefined]
}
}, [ensName])
const registrarContract = useENSRegistrarContract(false)
const resolverAddressResult = useSingleCallResult(registrarContract, 'resolver', ensNodeArgument)
const resolverAddress = resolverAddressResult.result?.[0]
const resolverContract = useENSResolverContract(
resolverAddress && isZero(resolverAddress) ? undefined : resolverAddress,
false
)
const contenthash = useSingleCallResult(resolverContract, 'contenthash', ensNodeArgument)
return {
contenthash: contenthash.result?.[0] ?? null,
loading: resolverAddressResult.loading || contenthash.loading
}
}

@ -1,49 +1,37 @@
import { useEffect, useState } from 'react' import { namehash } from 'ethers/lib/utils'
import { useMemo } from 'react'
import { useSingleCallResult } from '../state/multicall/hooks'
import { isAddress } from '../utils' import { isAddress } from '../utils'
import { useActiveWeb3React } from './index' import isZero from '../utils/isZero'
import { useENSRegistrarContract, useENSResolverContract } from './useContract'
import useDebounce from './useDebounce'
/** /**
* Does a reverse lookup for an address to find its ENS name. * Does a reverse lookup for an address to find its ENS name.
* Note this is not the same as looking up an ENS name to find an address. * Note this is not the same as looking up an ENS name to find an address.
*/ */
export default function useENSName(address?: string): { ENSName: string | null; loading: boolean } { export default function useENSName(address?: string): { ENSName: string | null; loading: boolean } {
const { library } = useActiveWeb3React() const debouncedAddress = useDebounce(address, 200)
const ensNodeArgument = useMemo(() => {
const [ENSName, setENSName] = useState<{ ENSName: string | null; loading: boolean }>({ if (!debouncedAddress || !isAddress(debouncedAddress)) return [undefined]
loading: false, try {
ENSName: null return debouncedAddress ? [namehash(`${debouncedAddress.toLowerCase().substr(2)}.addr.reverse`)] : [undefined]
}) } catch (error) {
return [undefined]
useEffect(() => {
const validated = isAddress(address)
if (!library || !validated) {
setENSName({ loading: false, ENSName: null })
return
} else {
let stale = false
setENSName({ loading: true, ENSName: null })
library
.lookupAddress(validated)
.then(name => {
if (!stale) {
if (name) {
setENSName({ loading: false, ENSName: name })
} else {
setENSName({ loading: false, ENSName: null })
}
}
})
.catch(() => {
if (!stale) {
setENSName({ loading: false, ENSName: null })
}
})
return () => {
stale = true
}
} }
}, [library, address]) }, [debouncedAddress])
const registrarContract = useENSRegistrarContract(false)
const resolverAddress = useSingleCallResult(registrarContract, 'resolver', ensNodeArgument)
const resolverAddressResult = resolverAddress.result?.[0]
const resolverContract = useENSResolverContract(
resolverAddressResult && !isZero(resolverAddressResult) ? resolverAddressResult : undefined,
false
)
const name = useSingleCallResult(resolverContract, 'name', ensNodeArgument)
return ENSName const changed = debouncedAddress !== address
return {
ENSName: changed ? null : name.result?.[0] ?? null,
loading: changed || resolverAddress.loading || name.loading
}
} }

@ -0,0 +1,50 @@
import { nanoid } from '@reduxjs/toolkit'
import { ChainId } from '@uniswap/sdk'
import { TokenList } from '@uniswap/token-lists'
import { useCallback } from 'react'
import { useDispatch } from 'react-redux'
import { getNetworkLibrary, NETWORK_CHAIN_ID } from '../connectors'
import { AppDispatch } from '../state'
import { fetchTokenList } from '../state/lists/actions'
import getTokenList from '../utils/getTokenList'
import resolveENSContentHash from '../utils/resolveENSContentHash'
import { useActiveWeb3React } from './index'
export function useFetchListCallback(): (listUrl: string) => Promise<TokenList> {
const { chainId, library } = useActiveWeb3React()
const dispatch = useDispatch<AppDispatch>()
const ensResolver = useCallback(
(ensName: string) => {
if (!library || chainId !== ChainId.MAINNET) {
if (NETWORK_CHAIN_ID === ChainId.MAINNET) {
const networkLibrary = getNetworkLibrary()
if (networkLibrary) {
return resolveENSContentHash(ensName, networkLibrary)
}
}
throw new Error('Could not construct mainnet ENS resolver')
}
return resolveENSContentHash(ensName, library)
},
[chainId, library]
)
return useCallback(
async (listUrl: string) => {
const requestId = nanoid()
dispatch(fetchTokenList.pending({ requestId, url: listUrl }))
return getTokenList(listUrl, ensResolver)
.then(tokenList => {
dispatch(fetchTokenList.fulfilled({ url: listUrl, tokenList, requestId }))
return tokenList
})
.catch(error => {
console.debug(`Failed to get list at url ${listUrl}`, error)
dispatch(fetchTokenList.rejected({ url: listUrl, requestId, errorMessage: error.message }))
throw error
})
},
[dispatch, ensResolver]
)
}

@ -0,0 +1,17 @@
import { useMemo } from 'react'
import contenthashToUri from '../utils/contenthashToUri'
import { parseENSAddress } from '../utils/parseENSAddress'
import uriToHttp from '../utils/uriToHttp'
import useENSContentHash from './useENSContentHash'
export default function useHttpLocations(uri: string | undefined): string[] {
const ens = useMemo(() => (uri ? parseENSAddress(uri) : undefined), [uri])
const resolvedContentHash = useENSContentHash(ens?.ensName)
return useMemo(() => {
if (ens) {
return resolvedContentHash.contenthash ? uriToHttp(contenthashToUri(resolvedContentHash.contenthash)) : []
} else {
return uri ? uriToHttp(uri) : []
}
}, [ens, resolvedContentHash.contenthash, uri])
}

@ -0,0 +1,26 @@
import { RefObject, useEffect, useRef } from 'react'
export function useOnClickOutside<T extends HTMLElement>(
node: RefObject<T | undefined>,
handler: undefined | (() => void)
) {
const handlerRef = useRef<undefined | (() => void)>(handler)
useEffect(() => {
handlerRef.current = handler
}, [handler])
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (node.current?.contains(e.target as Node) ?? false) {
return
}
if (handlerRef.current) handlerRef.current()
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [node])
}

@ -1,21 +1,21 @@
import { Web3Provider } from '@ethersproject/providers'
import { createWeb3ReactRoot, Web3ReactProvider } from '@web3-react/core' import { createWeb3ReactRoot, Web3ReactProvider } from '@web3-react/core'
import React from 'react' import 'inter-ui'
import React, { StrictMode } from 'react'
import { isMobile } from 'react-device-detect' import { isMobile } from 'react-device-detect'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import ReactGA from 'react-ga' import ReactGA from 'react-ga'
import { Provider } from 'react-redux' import { Provider } from 'react-redux'
import { NetworkContextName } from './constants' import { NetworkContextName } from './constants'
import 'inter-ui'
import './i18n' import './i18n'
import App from './pages/App' 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 ListsUpdater from './state/lists/updater' import ListsUpdater from './state/lists/updater'
import UserUpdater from './state/user/updater'
import MulticallUpdater from './state/multicall/updater' import MulticallUpdater from './state/multicall/updater'
import TransactionUpdater from './state/transactions/updater'
import UserUpdater from './state/user/updater'
import ThemeProvider, { FixedGlobalStyle, ThemedGlobalStyle } from './theme' import ThemeProvider, { FixedGlobalStyle, ThemedGlobalStyle } from './theme'
import getLibrary from './utils/getLibrary'
const Web3ProviderNetwork = createWeb3ReactRoot(NetworkContextName) const Web3ProviderNetwork = createWeb3ReactRoot(NetworkContextName)
@ -23,12 +23,6 @@ if ('ethereum' in window) {
;(window.ethereum as any).autoRefreshOnNetworkChange = false ;(window.ethereum as any).autoRefreshOnNetworkChange = false
} }
function getLibrary(provider: any): Web3Provider {
const library = new Web3Provider(provider)
library.pollingInterval = 15000
return library
}
const GOOGLE_ANALYTICS_ID: string | undefined = process.env.REACT_APP_GOOGLE_ANALYTICS_ID const GOOGLE_ANALYTICS_ID: string | undefined = process.env.REACT_APP_GOOGLE_ANALYTICS_ID
if (typeof GOOGLE_ANALYTICS_ID === 'string') { if (typeof GOOGLE_ANALYTICS_ID === 'string') {
ReactGA.initialize(GOOGLE_ANALYTICS_ID) ReactGA.initialize(GOOGLE_ANALYTICS_ID)
@ -59,21 +53,19 @@ function Updaters() {
} }
ReactDOM.render( ReactDOM.render(
<> <StrictMode>
<FixedGlobalStyle /> <FixedGlobalStyle />
<Web3ReactProvider getLibrary={getLibrary}> <Web3ReactProvider getLibrary={getLibrary}>
<Web3ProviderNetwork getLibrary={getLibrary}> <Web3ProviderNetwork getLibrary={getLibrary}>
<Provider store={store}> <Provider store={store}>
<Updaters /> <Updaters />
<ThemeProvider> <ThemeProvider>
<> <ThemedGlobalStyle />
<ThemedGlobalStyle /> <App />
<App />
</>
</ThemeProvider> </ThemeProvider>
</Provider> </Provider>
</Web3ProviderNetwork> </Web3ProviderNetwork>
</Web3ReactProvider> </Web3ReactProvider>
</>, </StrictMode>,
document.getElementById('root') document.getElementById('root')
) )

@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import styled from 'styled-components' import styled from 'styled-components'
export const BodyWrapper = styled.div<{ disabled?: boolean }>` export const BodyWrapper = styled.div`
position: relative; position: relative;
max-width: 420px; max-width: 420px;
width: 100%; width: 100%;
@ -10,13 +10,11 @@ export const BodyWrapper = styled.div<{ disabled?: boolean }>`
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, disabled }: { children: React.ReactNode; disabled?: boolean }) { export default function AppBody({ children }: { children: React.ReactNode }) {
return <BodyWrapper disabled={disabled}>{children}</BodyWrapper> return <BodyWrapper>{children}</BodyWrapper>
} }

@ -7,7 +7,7 @@ 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 { useAllTokens, useToken } from '../../hooks/Tokens' import { useAllTokens, useToken } from '../../hooks/Tokens'
import { useDefaultTokenList } from '../../state/lists/hooks' import { useSelectedTokenList } 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'
@ -17,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 } from '../../utils' import { isTokenOnList } from '../../utils'
export default function MigrateV1() { export default function MigrateV1() {
const theme = useContext(ThemeContext) const theme = useContext(ThemeContext)
@ -28,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 defaultTokens = useDefaultTokenList() const selectedTokenListTokens = useSelectedTokenList()
const isDefault = isDefaultToken(defaultTokens, token) const isOnSelectedList = isTokenOnList(selectedTokenListTokens, token)
const allTokens = useAllTokens() const allTokens = useAllTokens()
const addToken = useAddUserToken() const addToken = useAddUserToken()
useEffect(() => { useEffect(() => {
if (token && !isDefault && !allTokens[token.address]) { if (token && !isOnSelectedList && !allTokens[token.address]) {
addToken(token) addToken(token)
} }
}, [token, isDefault, addToken, allTokens]) }, [token, isOnSelectedList, addToken, allTokens])
// get V1 LP balances // get V1 LP balances
const V1Exchanges = useAllTokenV1Exchanges() const V1Exchanges = useAllTokenV1Exchanges()

@ -185,7 +185,7 @@ export default function PoolFinder() {
onCurrencySelect={handleCurrencySelect} onCurrencySelect={handleCurrencySelect}
onDismiss={handleSearchDismiss} onDismiss={handleSearchDismiss}
showCommonBases showCommonBases
hiddenCurrency={(activeField === Fields.TOKEN0 ? currency1 : currency0) ?? undefined} selectedCurrency={(activeField === Fields.TOKEN0 ? currency1 : currency0) ?? undefined}
/> />
</AppBody> </AppBody>
) )

@ -29,6 +29,7 @@ import { useTransactionAdder } from '../../state/transactions/hooks'
import { StyledInternalLink, TYPE } from '../../theme' import { StyledInternalLink, TYPE } from '../../theme'
import { calculateGasMargin, calculateSlippageAmount, getRouterContract } from '../../utils' import { calculateGasMargin, calculateSlippageAmount, getRouterContract } from '../../utils'
import { currencyId } from '../../utils/currencyId' import { currencyId } from '../../utils/currencyId'
import useDebouncedChangeHandler from '../../utils/useDebouncedChangeHandler'
import { wrappedCurrency } from '../../utils/wrappedCurrency' import { wrappedCurrency } from '../../utils/wrappedCurrency'
import AppBody from '../AppBody' import AppBody from '../AppBody'
import { ClickableText, MaxButton, Wrapper } from '../Pool/styleds' import { ClickableText, MaxButton, Wrapper } from '../Pool/styleds'
@ -458,6 +459,11 @@ export default function RemoveLiquidity({
setTxHash('') setTxHash('')
}, [onUserInput, txHash]) }, [onUserInput, txHash])
const [innerLiquidityPercentage, setInnerLiquidityPercentage] = useDebouncedChangeHandler(
Number.parseInt(parsedAmounts[Field.LIQUIDITY_PERCENT].toFixed(0)),
liquidityPercentChangeCallback
)
return ( return (
<> <>
<AppBody> <AppBody>
@ -499,10 +505,7 @@ export default function RemoveLiquidity({
</Row> </Row>
{!showDetailed && ( {!showDetailed && (
<> <>
<Slider <Slider value={innerLiquidityPercentage} onChange={setInnerLiquidityPercentage} />
value={Number.parseInt(parsedAmounts[Field.LIQUIDITY_PERCENT].toFixed(0))}
onChange={liquidityPercentChangeCallback}
/>
<RowBetween> <RowBetween>
<MaxButton onClick={() => onUserInput(Field.LIQUIDITY_PERCENT, '25')} width="20%"> <MaxButton onClick={() => onUserInput(Field.LIQUIDITY_PERCENT, '25')} width="20%">
25% 25%

@ -1,5 +1,5 @@
import { CurrencyAmount, JSBI, Trade } from '@uniswap/sdk' import { CurrencyAmount, JSBI, Token, Trade } from '@uniswap/sdk'
import React, { useCallback, useContext, useEffect, useState } from 'react' import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { ArrowDown } from 'react-feather' import { ArrowDown } from 'react-feather'
import ReactGA from 'react-ga' import ReactGA from 'react-ga'
import { Text } from 'rebass' import { Text } from 'rebass'
@ -17,11 +17,12 @@ import BetterTradeLink from '../../components/swap/BetterTradeLink'
import confirmPriceImpactWithoutFee from '../../components/swap/confirmPriceImpactWithoutFee' import confirmPriceImpactWithoutFee from '../../components/swap/confirmPriceImpactWithoutFee'
import { ArrowWrapper, BottomGrouping, Dots, SwapCallbackError, Wrapper } from '../../components/swap/styleds' import { ArrowWrapper, BottomGrouping, Dots, SwapCallbackError, Wrapper } from '../../components/swap/styleds'
import TradePrice from '../../components/swap/TradePrice' import TradePrice from '../../components/swap/TradePrice'
import { TokenWarningCards } from '../../components/TokenWarningCard' import TokenWarningModal from '../../components/TokenWarningModal'
import { BETTER_TRADE_LINK_THRESHOLD, INITIAL_ALLOWED_SLIPPAGE } from '../../constants' import { BETTER_TRADE_LINK_THRESHOLD, INITIAL_ALLOWED_SLIPPAGE } from '../../constants'
import { getTradeVersion, isTradeBetter } from '../../data/V1' import { getTradeVersion, isTradeBetter } from '../../data/V1'
import { useActiveWeb3React } from '../../hooks' import { useActiveWeb3React } from '../../hooks'
import { useCurrency } from '../../hooks/Tokens'
import { ApprovalState, useApproveCallbackFromTrade } from '../../hooks/useApproveCallback' import { ApprovalState, useApproveCallbackFromTrade } from '../../hooks/useApproveCallback'
import useENSAddress from '../../hooks/useENSAddress' import useENSAddress from '../../hooks/useENSAddress'
import { useSwapCallback } from '../../hooks/useSwapCallback' import { useSwapCallback } from '../../hooks/useSwapCallback'
@ -35,12 +36,7 @@ import {
useSwapActionHandlers, useSwapActionHandlers,
useSwapState useSwapState
} from '../../state/swap/hooks' } from '../../state/swap/hooks'
import { import { useExpertModeManager, useUserDeadline, useUserSlippageTolerance } from '../../state/user/hooks'
useExpertModeManager,
useTokenWarningDismissal,
useUserDeadline,
useUserSlippageTolerance
} from '../../state/user/hooks'
import { LinkStyledButton, TYPE } from '../../theme' import { LinkStyledButton, TYPE } from '../../theme'
import { maxAmountSpend } from '../../utils/maxAmountSpend' import { maxAmountSpend } from '../../utils/maxAmountSpend'
import { computeTradePriceBreakdown, warningSeverity } from '../../utils/prices' import { computeTradePriceBreakdown, warningSeverity } from '../../utils/prices'
@ -48,9 +44,23 @@ import AppBody from '../AppBody'
import { ClickableText } from '../Pool/styleds' import { ClickableText } from '../Pool/styleds'
export default function Swap() { export default function Swap() {
useDefaultsFromURLSearch() const loadedUrlParams = useDefaultsFromURLSearch()
const { account, chainId } = useActiveWeb3React() // token warning stuff
const [loadedInputCurrency, loadedOutputCurrency] = [
useCurrency(loadedUrlParams?.inputCurrencyId),
useCurrency(loadedUrlParams?.outputCurrencyId)
]
const [dismissTokenWarning, setDismissTokenWarning] = useState<boolean>(false)
const urlLoadedTokens: Token[] = useMemo(
() => [loadedInputCurrency, loadedOutputCurrency]?.filter((c): c is Token => c instanceof Token) ?? [],
[loadedInputCurrency, loadedOutputCurrency]
)
const handleConfirmTokenWarning = useCallback(() => {
setDismissTokenWarning(true)
}, [])
const { account } = useActiveWeb3React()
const theme = useContext(ThemeContext) const theme = useContext(ThemeContext)
// toggle wallet when disconnected // toggle wallet when disconnected
@ -230,11 +240,6 @@ export default function Swap() {
(approvalSubmitted && approval === ApprovalState.APPROVED)) && (approvalSubmitted && approval === ApprovalState.APPROVED)) &&
!(priceImpactSeverity > 3 && !isExpertMode) !(priceImpactSeverity > 3 && !isExpertMode)
const [dismissedToken0] = useTokenWarningDismissal(chainId, currencies[Field.INPUT])
const [dismissedToken1] = useTokenWarningDismissal(chainId, currencies[Field.OUTPUT])
const showWarning =
(!dismissedToken0 && !!currencies[Field.INPUT]) || (!dismissedToken1 && !!currencies[Field.OUTPUT])
const handleConfirmDismiss = useCallback(() => { const handleConfirmDismiss = useCallback(() => {
setSwapState({ showConfirm: false, tradeToConfirm, attemptingTxn, swapErrorMessage, txHash }) setSwapState({ showConfirm: false, tradeToConfirm, attemptingTxn, swapErrorMessage, txHash })
// if there was a tx hash, we want to clear the input // if there was a tx hash, we want to clear the input
@ -247,10 +252,30 @@ export default function Swap() {
setSwapState({ tradeToConfirm: trade, swapErrorMessage, txHash, attemptingTxn, showConfirm }) setSwapState({ tradeToConfirm: trade, swapErrorMessage, txHash, attemptingTxn, showConfirm })
}, [attemptingTxn, showConfirm, swapErrorMessage, trade, txHash]) }, [attemptingTxn, showConfirm, swapErrorMessage, trade, txHash])
const handleInputSelect = useCallback(
inputCurrency => {
setApprovalSubmitted(false) // reset 2 step UI for approvals
onCurrencySelection(Field.INPUT, inputCurrency)
},
[onCurrencySelection]
)
const handleMaxInput = useCallback(() => {
maxAmountInput && onUserInput(Field.INPUT, maxAmountInput.toExact())
}, [maxAmountInput, onUserInput])
const handleOutputSelect = useCallback(outputCurrency => onCurrencySelection(Field.OUTPUT, outputCurrency), [
onCurrencySelection
])
return ( return (
<> <>
{showWarning && <TokenWarningCards currencies={currencies} />} <TokenWarningModal
<AppBody disabled={showWarning}> isOpen={urlLoadedTokens.length > 0 && !dismissTokenWarning}
tokens={urlLoadedTokens}
onConfirm={handleConfirmTokenWarning}
/>
<AppBody>
<SwapPoolTabs active={'swap'} /> <SwapPoolTabs active={'swap'} />
<Wrapper id="swap-page"> <Wrapper id="swap-page">
<ConfirmSwapModal <ConfirmSwapModal
@ -274,13 +299,8 @@ export default function Swap() {
showMaxButton={!atMaxAmountInput} showMaxButton={!atMaxAmountInput}
currency={currencies[Field.INPUT]} currency={currencies[Field.INPUT]}
onUserInput={handleTypeInput} onUserInput={handleTypeInput}
onMax={() => { onMax={handleMaxInput}
maxAmountInput && onUserInput(Field.INPUT, maxAmountInput.toExact()) onCurrencySelect={handleInputSelect}
}}
onCurrencySelect={currency => {
setApprovalSubmitted(false) // reset 2 step UI for approvals
onCurrencySelection(Field.INPUT, currency)
}}
otherCurrency={currencies[Field.OUTPUT]} otherCurrency={currencies[Field.OUTPUT]}
id="swap-currency-input" id="swap-currency-input"
/> />
@ -310,7 +330,7 @@ export default function Swap() {
label={independentField === Field.INPUT && !showWrap ? 'To (estimated)' : 'To'} label={independentField === Field.INPUT && !showWrap ? 'To (estimated)' : 'To'}
showMaxButton={false} showMaxButton={false}
currency={currencies[Field.OUTPUT]} currency={currencies[Field.OUTPUT]}
onCurrencySelect={address => onCurrencySelection(Field.OUTPUT, address)} onCurrencySelect={handleOutputSelect}
otherCurrency={currencies[Field.INPUT]} otherCurrency={currencies[Field.INPUT]}
id="swap-currency-output" id="swap-currency-output"
/> />
@ -351,7 +371,7 @@ export default function Swap() {
Slippage Tolerance Slippage Tolerance
</ClickableText> </ClickableText>
<ClickableText fontWeight={500} fontSize={14} color={theme.text2} onClick={toggleSettings}> <ClickableText fontWeight={500} fontSize={14} color={theme.text2} onClick={toggleSettings}>
{allowedSlippage ? allowedSlippage / 100 : '-'}% {allowedSlippage / 100}%
</ClickableText> </ClickableText>
</RowBetween> </RowBetween>
)} )}

@ -21,5 +21,5 @@ export type PopupContent =
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')
export const toggleSettingsMenu = createAction<void>('toggleSettingsMenu') export const toggleSettingsMenu = createAction<void>('toggleSettingsMenu')
export const addPopup = createAction<{ key?: string; content: PopupContent }>('addPopup') export const addPopup = createAction<{ key?: string; removeAfterMs?: number | null; content: PopupContent }>('addPopup')
export const removePopup = createAction<{ key: string }>('removePopup') export const removePopup = createAction<{ key: string }>('removePopup')

@ -25,6 +25,7 @@ describe('application reducer', () => {
expect(typeof list[0].key).toEqual('string') expect(typeof list[0].key).toEqual('string')
expect(list[0].show).toEqual(true) expect(list[0].show).toEqual(true)
expect(list[0].content).toEqual({ txn: { hash: 'abc', summary: 'test', success: true } }) expect(list[0].content).toEqual({ txn: { hash: 'abc', summary: 'test', success: true } })
expect(list[0].removeAfterMs).toEqual(15000)
}) })
it('replaces any existing popups with the same key', () => { it('replaces any existing popups with the same key', () => {
@ -35,6 +36,7 @@ describe('application reducer', () => {
expect(list[0].key).toEqual('abc') expect(list[0].key).toEqual('abc')
expect(list[0].show).toEqual(true) expect(list[0].show).toEqual(true)
expect(list[0].content).toEqual({ txn: { hash: 'def', summary: 'test2', success: false } }) expect(list[0].content).toEqual({ txn: { hash: 'def', summary: 'test2', success: false } })
expect(list[0].removeAfterMs).toEqual(15000)
}) })
}) })

@ -8,7 +8,7 @@ import {
updateBlockNumber updateBlockNumber
} from './actions' } from './actions'
type PopupList = Array<{ key: string; show: boolean; content: PopupContent }> type PopupList = Array<{ key: string; show: boolean; content: PopupContent; removeAfterMs: number | null }>
export interface ApplicationState { export interface ApplicationState {
blockNumber: { [chainId: number]: number } blockNumber: { [chainId: number]: number }
@ -40,12 +40,13 @@ export default createReducer(initialState, builder =>
.addCase(toggleSettingsMenu, state => { .addCase(toggleSettingsMenu, state => {
state.settingsMenuOpen = !state.settingsMenuOpen state.settingsMenuOpen = !state.settingsMenuOpen
}) })
.addCase(addPopup, (state, { payload: { content, key } }) => { .addCase(addPopup, (state, { payload: { content, key, removeAfterMs = 15000 } }) => {
state.popupList = (key ? state.popupList.filter(popup => popup.key !== key) : state.popupList).concat([ state.popupList = (key ? state.popupList.filter(popup => popup.key !== key) : state.popupList).concat([
{ {
key: key || nanoid(), key: key || nanoid(),
show: true, show: true,
content content,
removeAfterMs
} }
]) ])
}) })

@ -25,7 +25,7 @@ const store = configureStore({
multicall, multicall,
lists lists
}, },
middleware: [...getDefaultMiddleware(), save({ states: PERSISTED_KEYS })], middleware: [...getDefaultMiddleware({ thunk: false }), save({ states: PERSISTED_KEYS })],
preloadedState: load({ states: PERSISTED_KEYS }) preloadedState: load({ states: PERSISTED_KEYS })
}) })

@ -1,54 +1,18 @@
import { createAction, createAsyncThunk } from '@reduxjs/toolkit' import { ActionCreatorWithPayload, createAction } from '@reduxjs/toolkit'
import { TokenList, Version } from '@uniswap/token-lists' 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) export const fetchTokenList: Readonly<{
pending: ActionCreatorWithPayload<{ url: string; requestId: string }>
/** fulfilled: ActionCreatorWithPayload<{ url: string; tokenList: TokenList; requestId: string }>
* Contains the logic for resolving a URL to a valid token list rejected: ActionCreatorWithPayload<{ url: string; errorMessage: string; requestId: string }>
* @param listUrl list url }> = {
*/ pending: createAction('lists/fetchTokenList/pending'),
async function getTokenList(listUrl: string): Promise<TokenList> { fulfilled: createAction('lists/fetchTokenList/fulfilled'),
const urls = uriToHttp(listUrl) rejected: createAction('lists/fetchTokenList/rejected')
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 acceptListUpdate = createAction<string>('lists/acceptListUpdate')
export const addList = createAction<string>('lists/addList') export const addList = createAction<string>('lists/addList')
export const removeList = createAction<string>('lists/removeList')
export const selectList = createAction<string>('lists/selectList')
export const rejectVersionUpdate = createAction<Version>('lists/rejectVersionUpdate') export const rejectVersionUpdate = createAction<Version>('lists/rejectVersionUpdate')

@ -1,18 +1,24 @@
import { ChainId, Token } from '@uniswap/sdk' import { ChainId, Token } from '@uniswap/sdk'
import { TokenInfo, TokenList } from '@uniswap/token-lists' import { Tags, TokenInfo, TokenList } from '@uniswap/token-lists'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { DEFAULT_TOKEN_LIST_URL } from '../../constants'
import { AppState } from '../index' import { AppState } from '../index'
type TagDetails = Tags[keyof Tags]
export interface TagInfo extends TagDetails {
id: string
}
/** /**
* Token instances created from token info. * Token instances created from token info.
*/ */
export class WrappedTokenInfo extends Token { export class WrappedTokenInfo extends Token {
public readonly tokenInfo: TokenInfo public readonly tokenInfo: TokenInfo
constructor(tokenInfo: TokenInfo) { public readonly tags: TagInfo[]
constructor(tokenInfo: TokenInfo, tags: TagInfo[]) {
super(tokenInfo.chainId, tokenInfo.address, tokenInfo.decimals, tokenInfo.symbol, tokenInfo.name) super(tokenInfo.chainId, tokenInfo.address, tokenInfo.decimals, tokenInfo.symbol, tokenInfo.name)
this.tokenInfo = tokenInfo this.tokenInfo = tokenInfo
this.tags = tags
} }
public get logoURI(): string | undefined { public get logoURI(): string | undefined {
return this.tokenInfo.logoURI return this.tokenInfo.logoURI
@ -33,7 +39,7 @@ const EMPTY_LIST: TokenAddressMap = {
} }
const listCache: WeakMap<TokenList, TokenAddressMap> | null = const listCache: WeakMap<TokenList, TokenAddressMap> | null =
'WeakMap' in window ? new WeakMap<TokenList, TokenAddressMap>() : null typeof WeakMap !== 'undefined' ? new WeakMap<TokenList, TokenAddressMap>() : null
export function listToTokenMap(list: TokenList): TokenAddressMap { export function listToTokenMap(list: TokenList): TokenAddressMap {
const result = listCache?.get(list) const result = listCache?.get(list)
@ -41,7 +47,14 @@ export function listToTokenMap(list: TokenList): TokenAddressMap {
const map = list.tokens.reduce<TokenAddressMap>( const map = list.tokens.reduce<TokenAddressMap>(
(tokenMap, tokenInfo) => { (tokenMap, tokenInfo) => {
const token = new WrappedTokenInfo(tokenInfo) const tags: TagInfo[] =
tokenInfo.tags
?.map(tagId => {
if (!list.tags?.[tagId]) return undefined
return { ...list.tags[tagId], id: tagId }
})
?.filter((x): x is TagInfo => Boolean(x)) ?? []
const token = new WrappedTokenInfo(tokenInfo, tags)
if (tokenMap[token.chainId][token.address] !== undefined) throw Error('Duplicate tokens.') if (tokenMap[token.chainId][token.address] !== undefined) throw Error('Duplicate tokens.')
return { return {
...tokenMap, ...tokenMap,
@ -57,17 +70,38 @@ export function listToTokenMap(list: TokenList): TokenAddressMap {
return map return map
} }
export function useTokenList(url: string): TokenAddressMap { export function useTokenList(url: string | undefined): TokenAddressMap {
const lists = useSelector<AppState, AppState['lists']['byUrl']>(state => state.lists.byUrl) const lists = useSelector<AppState, AppState['lists']['byUrl']>(state => state.lists.byUrl)
return useMemo(() => { return useMemo(() => {
if (!url) return EMPTY_LIST
const current = lists[url]?.current const current = lists[url]?.current
if (!current) return EMPTY_LIST if (!current) return EMPTY_LIST
return listToTokenMap(current) try {
return listToTokenMap(current)
} catch (error) {
console.error('Could not show token list due to error', error)
return EMPTY_LIST
}
}, [lists, url]) }, [lists, url])
} }
export function useDefaultTokenList(): TokenAddressMap { export function useSelectedListUrl(): string | undefined {
return useTokenList(DEFAULT_TOKEN_LIST_URL) return useSelector<AppState, AppState['lists']['selectedListUrl']>(state => state.lists.selectedListUrl)
}
export function useSelectedTokenList(): TokenAddressMap {
return useTokenList(useSelectedListUrl())
}
export function useSelectedListInfo(): { current: TokenList | null; pending: TokenList | null; loading: boolean } {
const selectedUrl = useSelectedListUrl()
const listsByUrl = useSelector<AppState, AppState['lists']['byUrl']>(state => state.lists.byUrl)
const list = selectedUrl ? listsByUrl[selectedUrl] : undefined
return {
current: list?.current ?? null,
pending: list?.pendingUpdate ?? null,
loading: list?.loadingRequestId !== null
}
} }
// returns all downloaded current lists // returns all downloaded current lists

@ -1,6 +1,9 @@
import { createStore, Store } from 'redux' import { createStore, Store } from 'redux'
import { fetchTokenList, acceptListUpdate, addList } from './actions' import { DEFAULT_LIST_OF_LISTS, DEFAULT_TOKEN_LIST_URL } from '../../constants/lists'
import { updateVersion } from '../user/actions'
import { fetchTokenList, acceptListUpdate, addList, removeList, selectList } from './actions'
import reducer, { ListsState } from './reducer' import reducer, { ListsState } from './reducer'
import UNISWAP_DEFAULT_TOKEN_LIST from '@uniswap/default-token-list'
const STUB_TOKEN_LIST = { const STUB_TOKEN_LIST = {
name: '', name: '',
@ -27,14 +30,15 @@ describe('list reducer', () => {
beforeEach(() => { beforeEach(() => {
store = createStore(reducer, { store = createStore(reducer, {
byUrl: {} byUrl: {},
selectedListUrl: undefined
}) })
}) })
describe('fetchTokenList', () => { describe('fetchTokenList', () => {
describe('pending', () => { describe('pending', () => {
it('sets pending', () => { it('sets pending', () => {
store.dispatch(fetchTokenList.pending('request-id', 'fake-url')) store.dispatch(fetchTokenList.pending({ requestId: 'request-id', url: 'fake-url' }))
expect(store.getState()).toEqual({ expect(store.getState()).toEqual({
byUrl: { byUrl: {
'fake-url': { 'fake-url': {
@ -43,7 +47,8 @@ describe('list reducer', () => {
current: null, current: null,
pendingUpdate: null pendingUpdate: null
} }
} },
selectedListUrl: undefined
}) })
}) })
@ -56,10 +61,11 @@ describe('list reducer', () => {
pendingUpdate: null, pendingUpdate: null,
loadingRequestId: null loadingRequestId: null
} }
} },
selectedListUrl: undefined
}) })
store.dispatch(fetchTokenList.pending('request-id', 'fake-url')) store.dispatch(fetchTokenList.pending({ requestId: 'request-id', url: 'fake-url' }))
expect(store.getState()).toEqual({ expect(store.getState()).toEqual({
byUrl: { byUrl: {
'fake-url': { 'fake-url': {
@ -68,14 +74,17 @@ describe('list reducer', () => {
loadingRequestId: 'request-id', loadingRequestId: 'request-id',
pendingUpdate: null pendingUpdate: null
} }
} },
selectedListUrl: undefined
}) })
}) })
}) })
describe('fulfilled', () => { describe('fulfilled', () => {
it('saves the list', () => { it('saves the list', () => {
store.dispatch(fetchTokenList.fulfilled(STUB_TOKEN_LIST, 'request-id', 'fake-url')) store.dispatch(
fetchTokenList.fulfilled({ tokenList: STUB_TOKEN_LIST, requestId: 'request-id', url: 'fake-url' })
)
expect(store.getState()).toEqual({ expect(store.getState()).toEqual({
byUrl: { byUrl: {
'fake-url': { 'fake-url': {
@ -84,13 +93,18 @@ describe('list reducer', () => {
loadingRequestId: null, loadingRequestId: null,
pendingUpdate: null pendingUpdate: null
} }
} },
selectedListUrl: undefined
}) })
}) })
it('does not save the list in pending if current is same', () => { 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(
store.dispatch(fetchTokenList.fulfilled(STUB_TOKEN_LIST, 'request-id', 'fake-url')) fetchTokenList.fulfilled({ tokenList: STUB_TOKEN_LIST, requestId: 'request-id', url: 'fake-url' })
)
store.dispatch(
fetchTokenList.fulfilled({ tokenList: STUB_TOKEN_LIST, requestId: 'request-id', url: 'fake-url' })
)
expect(store.getState()).toEqual({ expect(store.getState()).toEqual({
byUrl: { byUrl: {
'fake-url': { 'fake-url': {
@ -99,14 +113,19 @@ describe('list reducer', () => {
loadingRequestId: null, loadingRequestId: null,
pendingUpdate: null pendingUpdate: null
} }
} },
selectedListUrl: undefined
}) })
}) })
it('does not save to current if list is newer patch version', () => { 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({ tokenList: STUB_TOKEN_LIST, requestId: 'request-id', url: 'fake-url' })
)
store.dispatch(fetchTokenList.fulfilled(PATCHED_STUB_LIST, 'request-id', 'fake-url')) store.dispatch(
fetchTokenList.fulfilled({ tokenList: PATCHED_STUB_LIST, requestId: 'request-id', url: 'fake-url' })
)
expect(store.getState()).toEqual({ expect(store.getState()).toEqual({
byUrl: { byUrl: {
'fake-url': { 'fake-url': {
@ -115,13 +134,18 @@ describe('list reducer', () => {
loadingRequestId: null, loadingRequestId: null,
pendingUpdate: PATCHED_STUB_LIST pendingUpdate: PATCHED_STUB_LIST
} }
} },
selectedListUrl: undefined
}) })
}) })
it('does not save to current if list is newer minor version', () => { 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({ tokenList: STUB_TOKEN_LIST, requestId: 'request-id', url: 'fake-url' })
)
store.dispatch(fetchTokenList.fulfilled(MINOR_UPDATED_STUB_LIST, 'request-id', 'fake-url')) store.dispatch(
fetchTokenList.fulfilled({ tokenList: MINOR_UPDATED_STUB_LIST, requestId: 'request-id', url: 'fake-url' })
)
expect(store.getState()).toEqual({ expect(store.getState()).toEqual({
byUrl: { byUrl: {
'fake-url': { 'fake-url': {
@ -130,13 +154,18 @@ describe('list reducer', () => {
loadingRequestId: null, loadingRequestId: null,
pendingUpdate: MINOR_UPDATED_STUB_LIST pendingUpdate: MINOR_UPDATED_STUB_LIST
} }
} },
selectedListUrl: undefined
}) })
}) })
it('does not save to pending if list is newer major version', () => { 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({ tokenList: STUB_TOKEN_LIST, requestId: 'request-id', url: 'fake-url' })
)
store.dispatch(fetchTokenList.fulfilled(MAJOR_UPDATED_STUB_LIST, 'request-id', 'fake-url')) store.dispatch(
fetchTokenList.fulfilled({ tokenList: MAJOR_UPDATED_STUB_LIST, requestId: 'request-id', url: 'fake-url' })
)
expect(store.getState()).toEqual({ expect(store.getState()).toEqual({
byUrl: { byUrl: {
'fake-url': { 'fake-url': {
@ -145,16 +174,18 @@ describe('list reducer', () => {
loadingRequestId: null, loadingRequestId: null,
pendingUpdate: MAJOR_UPDATED_STUB_LIST pendingUpdate: MAJOR_UPDATED_STUB_LIST
} }
} },
selectedListUrl: undefined
}) })
}) })
}) })
describe('rejected', () => { describe('rejected', () => {
it('no-op if not loading', () => { it('no-op if not loading', () => {
store.dispatch(fetchTokenList.rejected(new Error('abcd'), 'request-id', 'fake-url')) store.dispatch(fetchTokenList.rejected({ requestId: 'request-id', errorMessage: 'abcd', url: 'fake-url' }))
expect(store.getState()).toEqual({ expect(store.getState()).toEqual({
byUrl: {} byUrl: {},
selectedListUrl: undefined
}) })
}) })
@ -167,9 +198,10 @@ describe('list reducer', () => {
loadingRequestId: 'request-id', loadingRequestId: 'request-id',
pendingUpdate: null pendingUpdate: null
} }
} },
selectedListUrl: undefined
}) })
store.dispatch(fetchTokenList.rejected(new Error('abcd'), 'request-id', 'fake-url')) store.dispatch(fetchTokenList.rejected({ requestId: 'request-id', errorMessage: 'abcd', url: 'fake-url' }))
expect(store.getState()).toEqual({ expect(store.getState()).toEqual({
byUrl: { byUrl: {
'fake-url': { 'fake-url': {
@ -178,7 +210,8 @@ describe('list reducer', () => {
loadingRequestId: null, loadingRequestId: null,
pendingUpdate: null pendingUpdate: null
} }
} },
selectedListUrl: undefined
}) })
}) })
}) })
@ -195,7 +228,8 @@ describe('list reducer', () => {
loadingRequestId: null, loadingRequestId: null,
pendingUpdate: null pendingUpdate: null
} }
} },
selectedListUrl: undefined
}) })
}) })
it('no op for existing list', () => { it('no op for existing list', () => {
@ -207,7 +241,8 @@ describe('list reducer', () => {
loadingRequestId: null, loadingRequestId: null,
pendingUpdate: null pendingUpdate: null
} }
} },
selectedListUrl: undefined
}) })
store.dispatch(addList('fake-url')) store.dispatch(addList('fake-url'))
expect(store.getState()).toEqual({ expect(store.getState()).toEqual({
@ -218,7 +253,8 @@ describe('list reducer', () => {
loadingRequestId: null, loadingRequestId: null,
pendingUpdate: null pendingUpdate: null
} }
} },
selectedListUrl: undefined
}) })
}) })
}) })
@ -233,7 +269,8 @@ describe('list reducer', () => {
loadingRequestId: null, loadingRequestId: null,
pendingUpdate: PATCHED_STUB_LIST pendingUpdate: PATCHED_STUB_LIST
} }
} },
selectedListUrl: undefined
}) })
store.dispatch(acceptListUpdate('fake-url')) store.dispatch(acceptListUpdate('fake-url'))
expect(store.getState()).toEqual({ expect(store.getState()).toEqual({
@ -244,7 +281,251 @@ describe('list reducer', () => {
loadingRequestId: null, loadingRequestId: null,
pendingUpdate: null pendingUpdate: null
} }
} },
selectedListUrl: undefined
})
})
})
describe('removeList', () => {
it('deletes the list key', () => {
store = createStore(reducer, {
byUrl: {
'fake-url': {
error: null,
current: STUB_TOKEN_LIST,
loadingRequestId: null,
pendingUpdate: PATCHED_STUB_LIST
}
},
selectedListUrl: undefined
})
store.dispatch(removeList('fake-url'))
expect(store.getState()).toEqual({
byUrl: {},
selectedListUrl: undefined
})
})
it('unselects the list if selected', () => {
store = createStore(reducer, {
byUrl: {
'fake-url': {
error: null,
current: STUB_TOKEN_LIST,
loadingRequestId: null,
pendingUpdate: PATCHED_STUB_LIST
}
},
selectedListUrl: 'fake-url'
})
store.dispatch(removeList('fake-url'))
expect(store.getState()).toEqual({
byUrl: {},
selectedListUrl: undefined
})
})
})
describe('selectList', () => {
it('sets the selected list url', () => {
store = createStore(reducer, {
byUrl: {
'fake-url': {
error: null,
current: STUB_TOKEN_LIST,
loadingRequestId: null,
pendingUpdate: PATCHED_STUB_LIST
}
},
selectedListUrl: undefined
})
store.dispatch(selectList('fake-url'))
expect(store.getState()).toEqual({
byUrl: {
'fake-url': {
error: null,
current: STUB_TOKEN_LIST,
loadingRequestId: null,
pendingUpdate: PATCHED_STUB_LIST
}
},
selectedListUrl: 'fake-url'
})
})
it('selects if not present already', () => {
store = createStore(reducer, {
byUrl: {
'fake-url': {
error: null,
current: STUB_TOKEN_LIST,
loadingRequestId: null,
pendingUpdate: PATCHED_STUB_LIST
}
},
selectedListUrl: undefined
})
store.dispatch(selectList('fake-url-invalid'))
expect(store.getState()).toEqual({
byUrl: {
'fake-url': {
error: null,
current: STUB_TOKEN_LIST,
loadingRequestId: null,
pendingUpdate: PATCHED_STUB_LIST
},
'fake-url-invalid': {
error: null,
current: null,
loadingRequestId: null,
pendingUpdate: null
}
},
selectedListUrl: 'fake-url-invalid'
})
})
it('works if list already added', () => {
store = createStore(reducer, {
byUrl: {
'fake-url': {
error: null,
current: null,
loadingRequestId: null,
pendingUpdate: null
}
},
selectedListUrl: undefined
})
store.dispatch(selectList('fake-url'))
expect(store.getState()).toEqual({
byUrl: {
'fake-url': {
error: null,
current: null,
loadingRequestId: null,
pendingUpdate: null
}
},
selectedListUrl: 'fake-url'
})
})
})
describe('updateVersion', () => {
describe('never initialized', () => {
beforeEach(() => {
store = createStore(reducer, {
byUrl: {
'https://unpkg.com/@uniswap/default-token-list@latest/uniswap-default.tokenlist.json': {
error: null,
current: STUB_TOKEN_LIST,
loadingRequestId: null,
pendingUpdate: null
},
'https://unpkg.com/@uniswap/default-token-list@latest': {
error: null,
current: STUB_TOKEN_LIST,
loadingRequestId: null,
pendingUpdate: null
}
},
selectedListUrl: undefined
})
store.dispatch(updateVersion())
})
it('clears the current lists', () => {
expect(
store.getState().byUrl['https://unpkg.com/@uniswap/default-token-list@latest/uniswap-default.tokenlist.json']
).toBeUndefined()
expect(store.getState().byUrl['https://unpkg.com/@uniswap/default-token-list@latest']).toBeUndefined()
})
it('puts in all the new lists', () => {
expect(Object.keys(store.getState().byUrl)).toEqual(DEFAULT_LIST_OF_LISTS)
})
it('all lists are empty', () => {
const s = store.getState()
Object.keys(s.byUrl).forEach(url => {
if (url === DEFAULT_TOKEN_LIST_URL) {
expect(s.byUrl[url]).toEqual({
error: null,
current: UNISWAP_DEFAULT_TOKEN_LIST,
loadingRequestId: null,
pendingUpdate: null
})
} else {
expect(s.byUrl[url]).toEqual({
error: null,
current: null,
loadingRequestId: null,
pendingUpdate: null
})
}
})
})
it('sets initialized lists', () => {
expect(store.getState().lastInitializedDefaultListOfLists).toEqual(DEFAULT_LIST_OF_LISTS)
})
})
describe('initialized with a different set of lists', () => {
beforeEach(() => {
store = createStore(reducer, {
byUrl: {
'https://unpkg.com/@uniswap/default-token-list@latest/uniswap-default.tokenlist.json': {
error: null,
current: STUB_TOKEN_LIST,
loadingRequestId: null,
pendingUpdate: null
},
'https://unpkg.com/@uniswap/default-token-list@latest': {
error: null,
current: STUB_TOKEN_LIST,
loadingRequestId: null,
pendingUpdate: null
}
},
selectedListUrl: undefined,
lastInitializedDefaultListOfLists: ['https://unpkg.com/@uniswap/default-token-list@latest']
})
store.dispatch(updateVersion())
})
it('does not remove lists not in last initialized list of lists', () => {
expect(
store.getState().byUrl['https://unpkg.com/@uniswap/default-token-list@latest/uniswap-default.tokenlist.json']
).toEqual({
error: null,
current: STUB_TOKEN_LIST,
loadingRequestId: null,
pendingUpdate: null
})
})
it('removes lists in the last initialized list of lists', () => {
expect(store.getState().byUrl['https://unpkg.com/@uniswap/default-token-list@latest']).toBeUndefined()
})
it('adds all the lists in the default list of lists', () => {
expect(Object.keys(store.getState().byUrl)).toContain(DEFAULT_TOKEN_LIST_URL)
})
it('each of those initialized lists is empty', () => {
const byUrl = store.getState().byUrl
// note we don't expect the uniswap default list to be prepopulated
// this is ok.
Object.keys(byUrl).forEach(url => {
if (url !== 'https://unpkg.com/@uniswap/default-token-list@latest/uniswap-default.tokenlist.json') {
expect(byUrl[url]).toEqual({
error: null,
current: null,
loadingRequestId: null,
pendingUpdate: null
})
}
})
})
it('sets initialized lists', () => {
expect(store.getState().lastInitializedDefaultListOfLists).toEqual(DEFAULT_LIST_OF_LISTS)
}) })
}) })
}) })

@ -1,8 +1,10 @@
import { createReducer } from '@reduxjs/toolkit' import { createReducer } from '@reduxjs/toolkit'
import { getVersionUpgrade, VersionUpgrade } from '@uniswap/token-lists' import { getVersionUpgrade, VersionUpgrade } from '@uniswap/token-lists'
import { TokenList } from '@uniswap/token-lists/dist/types' import { TokenList } from '@uniswap/token-lists/dist/types'
import { DEFAULT_LIST_OF_LISTS, DEFAULT_TOKEN_LIST_URL } from '../../constants/lists'
import { updateVersion } from '../user/actions' import { updateVersion } from '../user/actions'
import { acceptListUpdate, addList, fetchTokenList } from './actions' import { acceptListUpdate, addList, fetchTokenList, removeList, selectList } from './actions'
import UNISWAP_DEFAULT_LIST from '@uniswap/default-token-list'
export interface ListsState { export interface ListsState {
readonly byUrl: { readonly byUrl: {
@ -13,15 +15,40 @@ export interface ListsState {
readonly error: string | null readonly error: string | null
} }
} }
// this contains the default list of lists from the last time the updateVersion was called, i.e. the app was reloaded
readonly lastInitializedDefaultListOfLists?: string[]
readonly selectedListUrl: string | undefined
} }
const NEW_LIST_STATE: ListsState['byUrl'][string] = {
error: null,
current: null,
loadingRequestId: null,
pendingUpdate: null
}
type Mutable<T> = { -readonly [P in keyof T]: T[P] extends ReadonlyArray<infer U> ? U[] : T[P] }
const initialState: ListsState = { const initialState: ListsState = {
byUrl: {} lastInitializedDefaultListOfLists: DEFAULT_LIST_OF_LISTS,
byUrl: {
...DEFAULT_LIST_OF_LISTS.reduce<Mutable<ListsState['byUrl']>>((memo, listUrl) => {
memo[listUrl] = NEW_LIST_STATE
return memo
}, {}),
[DEFAULT_TOKEN_LIST_URL]: {
error: null,
current: UNISWAP_DEFAULT_LIST,
loadingRequestId: null,
pendingUpdate: null
}
},
selectedListUrl: undefined
} }
export default createReducer(initialState, builder => export default createReducer(initialState, builder =>
builder builder
.addCase(fetchTokenList.pending, (state, { meta: { arg: url, requestId } }) => { .addCase(fetchTokenList.pending, (state, { payload: { requestId, url } }) => {
state.byUrl[url] = { state.byUrl[url] = {
current: null, current: null,
pendingUpdate: null, pendingUpdate: null,
@ -30,19 +57,22 @@ export default createReducer(initialState, builder =>
error: null error: null
} }
}) })
.addCase(fetchTokenList.fulfilled, (state, { payload: tokenList, meta: { arg: url } }) => { .addCase(fetchTokenList.fulfilled, (state, { payload: { requestId, tokenList, url } }) => {
const current = state.byUrl[url]?.current const current = state.byUrl[url]?.current
const loadingRequestId = state.byUrl[url]?.loadingRequestId
// no-op if update does nothing // no-op if update does nothing
if (current) { if (current) {
const type = getVersionUpgrade(current.version, tokenList.version) const upgradeType = getVersionUpgrade(current.version, tokenList.version)
if (type === VersionUpgrade.NONE) return if (upgradeType === VersionUpgrade.NONE) return
state.byUrl[url] = { if (loadingRequestId === null || loadingRequestId === requestId) {
...state.byUrl[url], state.byUrl[url] = {
loadingRequestId: null, ...state.byUrl[url],
error: null, loadingRequestId: null,
current: current, error: null,
pendingUpdate: tokenList current: current,
pendingUpdate: tokenList
}
} }
} else { } else {
state.byUrl[url] = { state.byUrl[url] = {
@ -54,7 +84,7 @@ export default createReducer(initialState, builder =>
} }
} }
}) })
.addCase(fetchTokenList.rejected, (state, { error, meta: { requestId, arg: url } }) => { .addCase(fetchTokenList.rejected, (state, { payload: { url, requestId, errorMessage } }) => {
if (state.byUrl[url]?.loadingRequestId !== requestId) { if (state.byUrl[url]?.loadingRequestId !== requestId) {
// no-op since it's not the latest request // no-op since it's not the latest request
return return
@ -63,19 +93,29 @@ export default createReducer(initialState, builder =>
state.byUrl[url] = { state.byUrl[url] = {
...state.byUrl[url], ...state.byUrl[url],
loadingRequestId: null, loadingRequestId: null,
error: error.message ?? 'Unknown error', error: errorMessage,
current: null, current: null,
pendingUpdate: null pendingUpdate: null
} }
}) })
.addCase(selectList, (state, { payload: url }) => {
state.selectedListUrl = url
// automatically adds list
if (!state.byUrl[url]) {
state.byUrl[url] = NEW_LIST_STATE
}
})
.addCase(addList, (state, { payload: url }) => { .addCase(addList, (state, { payload: url }) => {
if (!state.byUrl[url]) { if (!state.byUrl[url]) {
state.byUrl[url] = { state.byUrl[url] = NEW_LIST_STATE
loadingRequestId: null, }
pendingUpdate: null, })
current: null, .addCase(removeList, (state, { payload: url }) => {
error: null if (state.byUrl[url]) {
} delete state.byUrl[url]
}
if (state.selectedListUrl === url) {
state.selectedListUrl = Object.keys(state.byUrl)[0]
} }
}) })
.addCase(acceptListUpdate, (state, { payload: url }) => { .addCase(acceptListUpdate, (state, { payload: url }) => {
@ -89,6 +129,30 @@ export default createReducer(initialState, builder =>
} }
}) })
.addCase(updateVersion, state => { .addCase(updateVersion, state => {
delete state.byUrl['https://unpkg.com/@uniswap/default-token-list@latest/uniswap-default.tokenlist.json'] // state loaded from localStorage, but new lists have never been initialized
if (!state.lastInitializedDefaultListOfLists) {
state.byUrl = initialState.byUrl
state.selectedListUrl = undefined
} else if (state.lastInitializedDefaultListOfLists) {
const lastInitializedSet = state.lastInitializedDefaultListOfLists.reduce<Set<string>>(
(s, l) => s.add(l),
new Set()
)
const newListOfListsSet = DEFAULT_LIST_OF_LISTS.reduce<Set<string>>((s, l) => s.add(l), new Set())
DEFAULT_LIST_OF_LISTS.forEach(listUrl => {
if (!lastInitializedSet.has(listUrl)) {
state.byUrl[listUrl] = NEW_LIST_STATE
}
})
state.lastInitializedDefaultListOfLists.forEach(listUrl => {
if (!newListOfListsSet.has(listUrl)) {
delete state.byUrl[listUrl]
}
})
}
state.lastInitializedDefaultListOfLists = DEFAULT_LIST_OF_LISTS
}) })
) )

@ -1,36 +1,43 @@
import { getVersionUpgrade, minVersionBump, VersionUpgrade } from '@uniswap/token-lists' import { getVersionUpgrade, minVersionBump, VersionUpgrade } from '@uniswap/token-lists'
import { useEffect } from 'react' import { useCallback, useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
import { DEFAULT_TOKEN_LIST_URL } from '../../constants' import { useActiveWeb3React } from '../../hooks'
import { useFetchListCallback } from '../../hooks/useFetchListCallback'
import useInterval from '../../hooks/useInterval'
import useIsWindowVisible from '../../hooks/useIsWindowVisible'
import { addPopup } from '../application/actions' import { addPopup } from '../application/actions'
import { AppDispatch, AppState } from '../index' import { AppDispatch, AppState } from '../index'
import { acceptListUpdate, addList, fetchTokenList } from './actions' import { acceptListUpdate } from './actions'
export default function Updater(): null { export default function Updater(): null {
const { library } = useActiveWeb3React()
const dispatch = useDispatch<AppDispatch>() const dispatch = useDispatch<AppDispatch>()
const lists = useSelector<AppState, AppState['lists']['byUrl']>(state => state.lists.byUrl) const lists = useSelector<AppState, AppState['lists']['byUrl']>(state => state.lists.byUrl)
// we should always fetch the default token list, so add it const isWindowVisible = useIsWindowVisible()
useEffect(() => {
if (!lists[DEFAULT_TOKEN_LIST_URL]) dispatch(addList(DEFAULT_TOKEN_LIST_URL))
}, [dispatch, lists])
// on initial mount, refetch all the lists in storage const fetchList = useFetchListCallback()
useEffect(() => {
Object.keys(lists).forEach(listUrl => dispatch(fetchTokenList(listUrl) as any)) const fetchAllListsCallback = useCallback(() => {
// we only do this once if (!isWindowVisible) return
// eslint-disable-next-line react-hooks/exhaustive-deps Object.keys(lists).forEach(url =>
}, [dispatch]) fetchList(url).catch(error => console.debug('interval list fetching error', error))
)
}, [fetchList, isWindowVisible, lists])
// fetch all lists every 10 minutes, but only after we initialize library
useInterval(fetchAllListsCallback, library ? 1000 * 60 * 10 : null)
// whenever a list is not loaded and not loading, try again to load it // whenever a list is not loaded and not loading, try again to load it
useEffect(() => { useEffect(() => {
Object.keys(lists).forEach(listUrl => { Object.keys(lists).forEach(listUrl => {
const list = lists[listUrl] const list = lists[listUrl]
if (!list.current && !list.loadingRequestId && !list.error) { if (!list.current && !list.loadingRequestId && !list.error) {
dispatch(fetchTokenList(listUrl) as any) fetchList(listUrl).catch(error => console.debug('list added fetching error', error))
} }
}) })
}, [dispatch, lists]) }, [dispatch, fetchList, library, lists])
// automatically update lists if versions are minor/patch // automatically update lists if versions are minor/patch
useEffect(() => { useEffect(() => {
@ -43,7 +50,6 @@ export default function Updater(): null {
throw new Error('unexpected no version bump') throw new Error('unexpected no version bump')
case VersionUpgrade.PATCH: case VersionUpgrade.PATCH:
case VersionUpgrade.MINOR: case VersionUpgrade.MINOR:
case VersionUpgrade.MAJOR:
const min = minVersionBump(list.current.tokens, list.pendingUpdate.tokens) const min = minVersionBump(list.current.tokens, list.pendingUpdate.tokens)
// automatically update minor/patch as long as bump matches the min update // automatically update minor/patch as long as bump matches the min update
if (bump >= min) { if (bump >= min) {
@ -68,21 +74,21 @@ export default function Updater(): null {
} }
break break
// this will be turned on later case VersionUpgrade.MAJOR:
// case VersionUpgrade.MAJOR: dispatch(
// dispatch( addPopup({
// addPopup({ key: listUrl,
// key: listUrl, content: {
// content: { listUpdate: {
// listUpdate: { listUrl,
// listUrl, auto: false,
// auto: false, oldList: list.current,
// oldList: list.current, newList: list.pendingUpdate
// newList: list.pendingUpdate }
// } },
// } removeAfterMs: null
// }) })
// ) )
} }
} }
}) })

@ -3,7 +3,7 @@ import { Version } from '../../hooks/useToggledVersion'
import { parseUnits } from '@ethersproject/units' import { parseUnits } from '@ethersproject/units'
import { Currency, CurrencyAmount, ETHER, JSBI, Token, TokenAmount, Trade } from '@uniswap/sdk' import { Currency, CurrencyAmount, ETHER, JSBI, Token, TokenAmount, Trade } from '@uniswap/sdk'
import { ParsedQs } from 'qs' import { ParsedQs } from 'qs'
import { useCallback, useEffect } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
import { useV1Trade } from '../../data/V1' import { useV1Trade } from '../../data/V1'
import { useActiveWeb3React } from '../../hooks' import { useActiveWeb3React } from '../../hooks'
@ -246,10 +246,15 @@ export function queryParametersToSwapState(parsedQs: ParsedQs): SwapState {
} }
// updates the swap state to use the defaults for a given network // updates the swap state to use the defaults for a given network
export function useDefaultsFromURLSearch() { export function useDefaultsFromURLSearch():
| { inputCurrencyId: string | undefined; outputCurrencyId: string | undefined }
| undefined {
const { chainId } = useActiveWeb3React() const { chainId } = useActiveWeb3React()
const dispatch = useDispatch<AppDispatch>() const dispatch = useDispatch<AppDispatch>()
const parsedQs = useParsedQueryString() const parsedQs = useParsedQueryString()
const [result, setResult] = useState<
{ inputCurrencyId: string | undefined; outputCurrencyId: string | undefined } | undefined
>()
useEffect(() => { useEffect(() => {
if (!chainId) return if (!chainId) return
@ -264,6 +269,10 @@ export function useDefaultsFromURLSearch() {
recipient: parsed.recipient recipient: parsed.recipient
}) })
) )
setResult({ inputCurrencyId: parsed[Field.INPUT].currencyId, outputCurrencyId: parsed[Field.OUTPUT].currencyId })
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch, chainId]) }, [dispatch, chainId])
return result
} }

@ -27,4 +27,3 @@ export const addSerializedPair = createAction<{ serializedPair: SerializedPair }
export const removeSerializedPair = createAction<{ chainId: number; tokenAAddress: string; tokenBAddress: string }>( export const removeSerializedPair = createAction<{ chainId: number; tokenAAddress: string; tokenBAddress: string }>(
'removeSerializedPair' 'removeSerializedPair'
) )
export const dismissTokenWarning = createAction<{ chainId: number; tokenAddress: string }>('dismissTokenWarning')

@ -1,4 +1,4 @@
import { ChainId, Pair, Token, Currency } from '@uniswap/sdk' import { ChainId, Pair, Token } 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'
@ -10,7 +10,6 @@ import { AppDispatch, AppState } from '../index'
import { import {
addSerializedPair, addSerializedPair,
addSerializedToken, addSerializedToken,
dismissTokenWarning,
removeSerializedToken, removeSerializedToken,
SerializedPair, SerializedPair,
SerializedToken, SerializedToken,
@ -19,8 +18,6 @@ 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 {
@ -163,36 +160,6 @@ export function usePairAdder(): (pair: Pair) => void {
) )
} }
/**
* 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.
*/
export function useTokenWarningDismissal(chainId?: number, token?: Currency): [boolean, null | (() => void)] {
const dismissalState = useSelector<AppState, AppState['user']['dismissedTokenWarnings']>(
state => state.user.dismissedTokenWarnings
)
const dispatch = useDispatch<AppDispatch>()
// get default list, mark as dismissed if on list
const defaultList = useDefaultTokenList()
const isDefault = isDefaultToken(defaultList, token)
return useMemo(() => {
if (!chainId || !token) return [false, null]
const dismissed: boolean =
token instanceof Token ? dismissalState?.[chainId]?.[token.address] === true || isDefault : true
const callback =
dismissed || !(token instanceof Token)
? null
: () => dispatch(dismissTokenWarning({ chainId, tokenAddress: token.address }))
return [dismissed, callback]
}, [chainId, token, dismissalState, isDefault, dispatch])
}
/** /**
* Given two tokens return the liquidity token that represents its liquidity shares * Given two tokens return the liquidity token that represents its liquidity shares
* @param tokenA one of the two tokens * @param tokenA one of the two tokens

@ -3,7 +3,6 @@ import { createReducer } from '@reduxjs/toolkit'
import { import {
addSerializedPair, addSerializedPair,
addSerializedToken, addSerializedToken,
dismissTokenWarning,
removeSerializedPair, removeSerializedPair,
removeSerializedToken, removeSerializedToken,
SerializedPair, SerializedPair,
@ -39,13 +38,6 @@ export interface UserState {
} }
} }
// the token warnings that the user has dismissed
dismissedTokenWarnings?: {
[chainId: number]: {
[tokenAddress: string]: true
}
}
pairs: { pairs: {
[chainId: number]: { [chainId: number]: {
// keyed by token0Address:token1Address // keyed by token0Address:token1Address
@ -75,11 +67,13 @@ export default createReducer(initialState, builder =>
builder builder
.addCase(updateVersion, state => { .addCase(updateVersion, state => {
// slippage isnt being tracked in local storage, reset to default // slippage isnt being tracked in local storage, reset to default
// noinspection SuspiciousTypeOfGuard
if (typeof state.userSlippageTolerance !== 'number') { if (typeof state.userSlippageTolerance !== 'number') {
state.userSlippageTolerance = INITIAL_ALLOWED_SLIPPAGE state.userSlippageTolerance = INITIAL_ALLOWED_SLIPPAGE
} }
// deadline isnt being tracked in local storage, reset to default // deadline isnt being tracked in local storage, reset to default
// noinspection SuspiciousTypeOfGuard
if (typeof state.userDeadline !== 'number') { if (typeof state.userDeadline !== 'number') {
state.userDeadline = DEFAULT_DEADLINE_FROM_NOW state.userDeadline = DEFAULT_DEADLINE_FROM_NOW
} }
@ -116,11 +110,6 @@ export default createReducer(initialState, builder =>
delete state.tokens[chainId][address] delete state.tokens[chainId][address]
state.timestamp = currentTimestamp() state.timestamp = currentTimestamp()
}) })
.addCase(dismissTokenWarning, (state, { payload: { chainId, tokenAddress } }) => {
state.dismissedTokenWarnings = state.dismissedTokenWarnings ?? {}
state.dismissedTokenWarnings[chainId] = state.dismissedTokenWarnings[chainId] ?? {}
state.dismissedTokenWarnings[chainId][tokenAddress] = true
})
.addCase(addSerializedPair, (state, { payload: { serializedPair } }) => { .addCase(addSerializedPair, (state, { payload: { serializedPair } }) => {
if ( if (
serializedPair.token0.chainId === serializedPair.token1.chainId && serializedPair.token0.chainId === serializedPair.token1.chainId &&

@ -40,22 +40,22 @@ export const CloseIcon = styled(X)<{ onClick: () => void }>`
` `
// A button that triggers some onClick result, but looks like a link. // A button that triggers some onClick result, but looks like a link.
export const LinkStyledButton = styled.button` export const LinkStyledButton = styled.button<{ disabled?: boolean }>`
border: none; border: none;
text-decoration: none; text-decoration: none;
background: none; background: none;
cursor: pointer; cursor: ${({ disabled }) => (disabled ? 'default' : 'pointer')};
color: ${({ theme }) => theme.primary1}; color: ${({ theme, disabled }) => (disabled ? theme.text2 : theme.primary1)};
font-weight: 500; font-weight: 500;
:hover { :hover {
text-decoration: underline; text-decoration: ${({ disabled }) => (disabled ? null : 'underline')};
} }
:focus { :focus {
outline: none; outline: none;
text-decoration: underline; text-decoration: ${({ disabled }) => (disabled ? null : 'underline')};
} }
:active { :active {

@ -52,10 +52,10 @@ export function colors(darkMode: boolean): Colors {
bg2: darkMode ? '#2C2F36' : '#F7F8FA', bg2: darkMode ? '#2C2F36' : '#F7F8FA',
bg3: darkMode ? '#40444F' : '#EDEEF2', bg3: darkMode ? '#40444F' : '#EDEEF2',
bg4: darkMode ? '#565A69' : '#CED0D9', bg4: darkMode ? '#565A69' : '#CED0D9',
bg5: darkMode ? '#565A69' : '#888D9B', bg5: darkMode ? '#6C7284' : '#888D9B',
//specialty colors //specialty colors
modalBG: darkMode ? 'rgba(0,0,0,42.5)' : 'rgba(0,0,0,0.3)', modalBG: darkMode ? 'rgba(0,0,0,.425)' : 'rgba(0,0,0,0.3)',
advancedBG: darkMode ? 'rgba(0,0,0,0.1)' : 'rgba(255,255,255,0.6)', advancedBG: darkMode ? 'rgba(0,0,0,0.1)' : 'rgba(255,255,255,0.6)',
//primary colors //primary colors

4
src/utils/content-hash.d.ts vendored Normal file

@ -0,0 +1,4 @@
declare module 'content-hash' {
declare function decode(x: string): string
declare function getCodec(x: string): string
}

@ -0,0 +1,21 @@
import contenthashToUri, { hexToUint8Array } from './contenthashToUri'
// this test is skipped for now because importing CID results in
// TypeError: TextDecoder is not a constructor
describe('#contenthashToUri', () => {
it('1inch.tokens.eth contenthash', () => {
expect(contenthashToUri('0xe3010170122013e051d1cfff20606de36845d4fe28deb9861a319a5bc8596fa4e610e8803918')).toEqual(
'ipfs://QmPgEqyV3m8SB52BS2j2mJpu9zGprhj2BGCHtRiiw2fdM1'
)
})
it('uniswap.eth contenthash', () => {
expect(contenthashToUri('0xe5010170000f6170702e756e69737761702e6f7267')).toEqual('ipns://app.uniswap.org')
})
})
describe('#hexToUint8Array', () => {
it('common case', () => {
expect(hexToUint8Array('0x010203fdfeff')).toEqual(new Uint8Array([1, 2, 3, 253, 254, 255]))
})
})

@ -0,0 +1,43 @@
import CID from 'cids'
import { getCodec, rmPrefix } from 'multicodec'
import { decode, toB58String } from 'multihashes'
export function hexToUint8Array(hex: string): Uint8Array {
hex = hex.startsWith('0x') ? hex.substr(2) : hex
if (hex.length % 2 !== 0) throw new Error('hex must have length that is multiple of 2')
const arr = new Uint8Array(hex.length / 2)
for (let i = 0; i < arr.length; i++) {
arr[i] = parseInt(hex.substr(i * 2, 2), 16)
}
return arr
}
const UTF_8_DECODER = new TextDecoder()
/**
* Returns the URI representation of the content hash for supported codecs
* @param contenthash to decode
*/
export default function contenthashToUri(contenthash: string): string {
const buff = hexToUint8Array(contenthash)
const codec = getCodec(buff as Buffer) // the typing is wrong for @types/multicodec
switch (codec) {
case 'ipfs-ns': {
const data = rmPrefix(buff as Buffer)
const cid = new CID(data)
return `ipfs://${toB58String(cid.multihash)}`
}
case 'ipns-ns': {
const data = rmPrefix(buff as Buffer)
const cid = new CID(data)
const multihash = decode(cid.multihash)
if (multihash.name === 'identity') {
return `ipns://${UTF_8_DECODER.decode(multihash.digest).trim()}`
} else {
return `ipns://${toB58String(cid.multihash)}`
}
}
default:
throw new Error(`Unrecognized codec: ${codec}`)
}
}

7
src/utils/getLibrary.ts Normal file

@ -0,0 +1,7 @@
import { Web3Provider } from '@ethersproject/providers'
export default function getLibrary(provider: any): Web3Provider {
const library = new Web3Provider(provider)
library.pollingInterval = 15000
return library
}

69
src/utils/getTokenList.ts Normal file

@ -0,0 +1,69 @@
import { TokenList } from '@uniswap/token-lists'
import schema from '@uniswap/token-lists/src/tokenlist.schema.json'
import Ajv from 'ajv'
import contenthashToUri from './contenthashToUri'
import { parseENSAddress } from './parseENSAddress'
import uriToHttp from './uriToHttp'
const tokenListValidator = new Ajv({ allErrors: true }).compile(schema)
/**
* Contains the logic for resolving a list URL to a validated token list
* @param listUrl list url
* @param resolveENSContentHash resolves an ens name to a contenthash
*/
export default async function getTokenList(
listUrl: string,
resolveENSContentHash: (ensName: string) => Promise<string>
): Promise<TokenList> {
const parsedENS = parseENSAddress(listUrl)
let urls: string[]
if (parsedENS) {
let contentHashUri
try {
contentHashUri = await resolveENSContentHash(parsedENS.ensName)
} catch (error) {
console.debug(`Failed to resolve ENS name: ${parsedENS.ensName}`, error)
throw new Error(`Failed to resolve ENS name: ${parsedENS.ensName}`)
}
let translatedUri
try {
translatedUri = contenthashToUri(contentHashUri)
} catch (error) {
console.debug('Failed to translate contenthash to URI', contentHashUri)
throw new Error(`Failed to translate contenthash to URI: ${contentHashUri}`)
}
urls = uriToHttp(`${translatedUri}${parsedENS.ensPath ?? ''}`)
} else {
urls = uriToHttp(listUrl)
}
for (let i = 0; i < urls.length; i++) {
const url = urls[i]
const isLast = i === urls.length - 1
let response
try {
response = await fetch(url)
} catch (error) {
console.debug('Failed to fetch list', listUrl, error)
if (isLast) throw new Error(`Failed to download list ${listUrl}`)
continue
}
if (!response.ok) {
if (isLast) throw new Error(`Failed to download list ${listUrl}`)
continue
}
const json = await response.json()
if (!tokenListValidator(json)) {
const validationErrors: string =
tokenListValidator.errors?.reduce<string>((memo, error) => {
const add = `${error.dataPath} ${error.message ?? ''}`
return memo.length > 0 ? `${memo}; ${add}` : `${add}`
}, '') ?? 'unknown error'
throw new Error(`Token list failed validation: ${validationErrors}`)
}
return json
}
throw new Error('Unrecognized list URL protocol.')
}

@ -99,7 +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(defaultTokens: TokenAddressMap, currency?: Currency): boolean { export function isTokenOnList(defaultTokens: TokenAddressMap, currency?: Currency): boolean {
if (currency === ETHER) return true if (currency === ETHER) return true
return Boolean(currency instanceof Token && defaultTokens[currency.chainId]?.[currency.address]) return Boolean(currency instanceof Token && defaultTokens[currency.chainId]?.[currency.address])
} }

@ -0,0 +1,5 @@
import { Version } from '@uniswap/token-lists'
export default function listVersionLabel(version: Version): string {
return `v${version.major}.${version.minor}.${version.patch}`
}

4
src/utils/multihashes.d.ts vendored Normal file

@ -0,0 +1,4 @@
declare module 'multihashes' {
declare function decode(buff: Uint8Array): { code: number; name: string; length: number; digest: Uint8Array }
declare function toB58String(hash: Uint8Array): string
}

@ -0,0 +1,14 @@
import { parseENSAddress } from './parseENSAddress'
describe('parseENSAddress', () => {
it('test cases', () => {
expect(parseENSAddress('hello.eth')).toEqual({ ensName: 'hello.eth', ensPath: undefined })
expect(parseENSAddress('hello.eth/')).toEqual({ ensName: 'hello.eth', ensPath: '/' })
expect(parseENSAddress('hello.world.eth/')).toEqual({ ensName: 'hello.world.eth', ensPath: '/' })
expect(parseENSAddress('hello.world.eth/abcdef')).toEqual({ ensName: 'hello.world.eth', ensPath: '/abcdef' })
expect(parseENSAddress('abso.lutely')).toEqual(undefined)
expect(parseENSAddress('abso.lutely.eth')).toEqual({ ensName: 'abso.lutely.eth', ensPath: undefined })
expect(parseENSAddress('eth')).toEqual(undefined)
expect(parseENSAddress('eth/hello-world')).toEqual(undefined)
})
})

@ -0,0 +1,7 @@
const ENS_NAME_REGEX = /^(([a-zA-Z0-9]+\.)+)eth(\/.*)?$/
export function parseENSAddress(ensAddress: string): { ensName: string; ensPath: string | undefined } | undefined {
const match = ensAddress.match(ENS_NAME_REGEX)
if (!match) return
return { ensName: `${match[1].toLowerCase()}eth`, ensPath: match[3] }
}

@ -1,13 +0,0 @@
import React from 'react'
/**
* Helper type that returns the props type of another component, excluding
* any of the keys passed as the optional second argument.
*/
type PropsOfExcluding<TComponent, TExcludingProps = void> = TComponent extends React.ComponentType<infer P>
? TExcludingProps extends string | number | symbol
? Omit<P, TExcludingProps>
: P
: unknown
export default PropsOfExcluding

@ -0,0 +1,67 @@
import { Contract } from '@ethersproject/contracts'
import { Provider } from '@ethersproject/abstract-provider'
import { namehash } from 'ethers/lib/utils'
const REGISTRAR_ABI = [
{
constant: true,
inputs: [
{
name: 'node',
type: 'bytes32'
}
],
name: 'resolver',
outputs: [
{
name: 'resolverAddress',
type: 'address'
}
],
payable: false,
stateMutability: 'view',
type: 'function'
}
]
const REGISTRAR_ADDRESS = '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e'
const RESOLVER_ABI = [
{
constant: true,
inputs: [
{
internalType: 'bytes32',
name: 'node',
type: 'bytes32'
}
],
name: 'contenthash',
outputs: [
{
internalType: 'bytes',
name: '',
type: 'bytes'
}
],
payable: false,
stateMutability: 'view',
type: 'function'
}
]
// cache the resolver contracts since most of them are the public resolver
function resolverContract(resolverAddress: string, provider: Provider): Contract {
return new Contract(resolverAddress, RESOLVER_ABI, provider)
}
/**
* Fetches and decodes the result of an ENS contenthash lookup on mainnet to a URI
* @param ensName to resolve
* @param provider provider to use to fetch the data
*/
export default async function resolveENSContentHash(ensName: string, provider: Provider): Promise<string> {
const ensRegistrarContract = new Contract(REGISTRAR_ADDRESS, REGISTRAR_ABI, provider)
const hash = namehash(ensName)
const resolverAddress = await ensRegistrarContract.resolver(hash)
return resolverContract(resolverAddress, provider).contenthash(hash)
}

@ -2,7 +2,7 @@ import uriToHttp from './uriToHttp'
describe('uriToHttp', () => { describe('uriToHttp', () => {
it('returns .eth.link for ens names', () => { it('returns .eth.link for ens names', () => {
expect(uriToHttp('t2crtokens.eth')).toEqual(['https://t2crtokens.eth.link']) expect(uriToHttp('t2crtokens.eth')).toEqual([])
}) })
it('returns https first for http', () => { it('returns https first for http', () => {
expect(uriToHttp('http://test.com')).toEqual(['https://test.com', 'http://test.com']) expect(uriToHttp('http://test.com')).toEqual(['https://test.com', 'http://test.com'])

@ -1,27 +1,21 @@
/** /**
* Given a URI that may be ipfs, or http, or an ENS name, return the fetchable http(s) URLs for the same content * Given a URI that may be ipfs, ipns, http, or https protocol, return the fetch-able http(s) URLs for the same content
* @param uri to convert to http url * @param uri to convert to fetch-able http url
*/ */
export default function uriToHttp(uri: string): string[] { export default function uriToHttp(uri: string): string[] {
try { const protocol = uri.split(':')[0].toLowerCase()
const parsed = new URL(uri) switch (protocol) {
if (parsed.protocol === 'http:') { case 'https':
return ['https' + uri.substr(4), uri]
} else if (parsed.protocol === 'https:') {
return [uri] return [uri]
} else if (parsed.protocol === 'ipfs:') { case 'http':
const hash = parsed.href.match(/^ipfs:(\/\/)?(.*)$/)?.[2] return ['https' + uri.substr(4), uri]
case 'ipfs':
const hash = uri.match(/^ipfs:(\/\/)?(.*)$/i)?.[2]
return [`https://cloudflare-ipfs.com/ipfs/${hash}/`, `https://ipfs.io/ipfs/${hash}/`] return [`https://cloudflare-ipfs.com/ipfs/${hash}/`, `https://ipfs.io/ipfs/${hash}/`]
} else if (parsed.protocol === 'ipns:') { case 'ipns':
const name = parsed.href.match(/^ipns:(\/\/)?(.*)$/)?.[2] const name = uri.match(/^ipns:(\/\/)?(.*)$/i)?.[2]
return [`https://cloudflare-ipfs.com/ipns/${name}/`, `https://ipfs.io/ipns/${name}/`] return [`https://cloudflare-ipfs.com/ipns/${name}/`, `https://ipfs.io/ipns/${name}/`]
} else { default:
return [] return []
}
} catch (error) {
if (uri.toLowerCase().endsWith('.eth')) {
return [`https://${uri.toLowerCase()}.link`]
}
return []
} }
} }

@ -0,0 +1,40 @@
import { useCallback, useEffect, useRef, useState } from 'react'
/**
* Easy way to debounce the handling of a rapidly changing value, e.g. a changing slider input
* @param value value that is rapidly changing
* @param onChange change handler that should receive the debounced updates to the value
* @param debouncedMs how long we should wait for changes to be applied
*/
export default function useDebouncedChangeHandler<T>(
value: T,
onChange: (newValue: T) => void,
debouncedMs = 100
): [T, (value: T) => void] {
const [inner, setInner] = useState<T>(() => value)
const timer = useRef<ReturnType<typeof setTimeout>>()
const onChangeInner = useCallback(
(newValue: T) => {
setInner(newValue)
if (timer.current) {
clearTimeout(timer.current)
}
timer.current = setTimeout(() => {
onChange(newValue)
timer.current = undefined
}, debouncedMs)
},
[debouncedMs, onChange]
)
useEffect(() => {
if (timer.current) {
clearTimeout(timer.current)
timer.current = undefined
}
setInner(value)
}, [value])
return [inner, onChangeInner]
}

@ -2326,6 +2326,13 @@
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA== integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==
"@types/multicodec@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@types/multicodec/-/multicodec-1.0.0.tgz#9c9c2df84ea5006c65a048873600f71c4565a397"
integrity sha512-UZkJT3rb8AfT2S1bTk7Gj+1wP9GJQ4zSnHDycRxEiI4yPOn47s5rSK86w/EFHvnNBhsu3zl+XNbTnBcxBd9dAQ==
dependencies:
"@types/node" "*"
"@types/node@*": "@types/node@*":
version "14.0.26" version "14.0.26"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.0.26.tgz#22a3b8a46510da8944b67bfc27df02c34a35331c" resolved "https://registry.yarnpkg.com/@types/node/-/node-14.0.26.tgz#22a3b8a46510da8944b67bfc27df02c34a35331c"
@ -2404,6 +2411,13 @@
"@types/history" "*" "@types/history" "*"
"@types/react" "*" "@types/react" "*"
"@types/react-virtualized-auto-sizer@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.0.tgz#fc32f30a8dab527b5816f3a757e1e1d040c8f272"
integrity sha512-NMErdIdSnm2j/7IqMteRiRvRulpjoELnXWUwdbucYCz84xG9PHcoOrr7QfXwB/ku7wd6egiKFrzt/+QK4Imeeg==
dependencies:
"@types/react" "*"
"@types/react-window@^1.8.2": "@types/react-window@^1.8.2":
version "1.8.2" version "1.8.2"
resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.2.tgz#a5a6b2762ce73ffaab7911ee1397cf645f2459fe" resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.2.tgz#a5a6b2762ce73ffaab7911ee1397cf645f2459fe"
@ -2550,6 +2564,11 @@
semver "^7.3.2" semver "^7.3.2"
tsutils "^3.17.1" tsutils "^3.17.1"
"@uniswap/default-token-list@^1.3.0":
version "1.3.0"
resolved "https://registry.yarnpkg.com/@uniswap/default-token-list/-/default-token-list-1.3.0.tgz#fd40e14165b31ff098b031b58ce8ca5ab81b3247"
integrity sha512-EsHVHVn7UgtxrhiM6tcBCXic56dTpl1WiKcIZhFyTFkA0vja7iZIJ3FYiYxT56g4hT0B9Lm7ZdBaPvEY3d1/eQ==
"@uniswap/lib@1.1.1": "@uniswap/lib@1.1.1":
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/@uniswap/lib/-/lib-1.1.1.tgz#0afd29601846c16e5d082866cbb24a9e0758e6bc" resolved "https://registry.yarnpkg.com/@uniswap/lib/-/lib-1.1.1.tgz#0afd29601846c16e5d082866cbb24a9e0758e6bc"
@ -2568,10 +2587,10 @@
tiny-warning "^1.0.3" tiny-warning "^1.0.3"
toformat "^2.0.0" toformat "^2.0.0"
"@uniswap/token-lists@^1.0.0-beta.11": "@uniswap/token-lists@^1.0.0-beta.14":
version "1.0.0-beta.11" version "1.0.0-beta.14"
resolved "https://registry.yarnpkg.com/@uniswap/token-lists/-/token-lists-1.0.0-beta.11.tgz#365dd55d536c67fa550554c0658391bfbc2930b7" resolved "https://registry.yarnpkg.com/@uniswap/token-lists/-/token-lists-1.0.0-beta.14.tgz#599ae9eb04121736fd2e309a31227b0343d77511"
integrity sha512-dGHdb58d+rN7G164ziPP6omb1R0hwBVgs95er83OzXKkVRlLKE/FLSdgpDaTxLj1war+P/hZXw2/ToYcKFsobQ== integrity sha512-9+qzJqlQ6U4CE4RrMl1Gkh+ISWfnY/5bO7zFi+UGiJ2IEcZUYJm3bE2AcUc5g6WNAm7CL0uf1FU+fz3JlamOIg==
"@uniswap/v2-core@1.0.0": "@uniswap/v2-core@1.0.0":
version "1.0.0" version "1.0.0"
@ -4751,6 +4770,17 @@ ci-info@^2.0.0:
resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46"
integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==
cids@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/cids/-/cids-1.0.0.tgz#5a148c4adbf3c56c45bbcf9000451907dfe8b5b2"
integrity sha512-HEBCIElSiXlkgZq3dgHJc3eDcnFteFp96N8/1/oqX5lkxBtB66sZ12jqEP3g7Ut++jEk6kIUGifQ1Qrya1jcNQ==
dependencies:
class-is "^1.1.0"
multibase "^3.0.0"
multicodec "^2.0.0"
multihashes "^3.0.1"
uint8arrays "^1.0.0"
cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3:
version "1.0.4" version "1.0.4"
resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de" resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de"
@ -4759,6 +4789,11 @@ cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3:
inherits "^2.0.1" inherits "^2.0.1"
safe-buffer "^5.0.1" safe-buffer "^5.0.1"
class-is@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/class-is/-/class-is-1.1.0.tgz#9d3c0fba0440d211d843cec3dedfa48055005825"
integrity sha512-rhjH9AG1fvabIDoGRVH587413LPjTZgmDF9fOFCbFJQV4yuocX1mHxxvXI4g3cGwbVY9wAYIoKlg1N79frJKQw==
class-utils@^0.3.5: class-utils@^0.3.5:
version "0.3.6" version "0.3.6"
resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463" resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463"
@ -10247,6 +10282,14 @@ ms@^2.1.1:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
multibase@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/multibase/-/multibase-3.0.0.tgz#f56eb828ee5c00241fe860ed5e2d144c8b9821f4"
integrity sha512-fuB+zfRbF5zWV4L+CPM0dgA0gX7DHG/IMyzwhVi2RxbRVWn41Wk7SkKW8cxYDGOg6TVh7XgyoesjOAYrB1HBAA==
dependencies:
base-x "^3.0.8"
web-encoding "^1.0.2"
multicast-dns-service-types@^1.1.0: multicast-dns-service-types@^1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz#899f11d9686e5e05cb91b35d5f0e63b773cfc901" resolved "https://registry.yarnpkg.com/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz#899f11d9686e5e05cb91b35d5f0e63b773cfc901"
@ -10260,6 +10303,23 @@ multicast-dns@^6.0.1:
dns-packet "^1.3.1" dns-packet "^1.3.1"
thunky "^1.0.2" thunky "^1.0.2"
multicodec@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/multicodec/-/multicodec-2.0.0.tgz#e0c41d99ce29d5f92a9406a6a31169a3802a1ca3"
integrity sha512-2SLsdTCXqOpUfoSHkTaVzxnjjl5fsSO283Idb9rAYgKGVu188NFP5KncuZ8Ifg8H2gc5GOi2rkuhLumqv9nweQ==
dependencies:
uint8arrays "1.0.0"
varint "^5.0.0"
multihashes@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/multihashes/-/multihashes-3.0.1.tgz#607c243d5e04ec022ac76c9c114e08416216f019"
integrity sha512-fFY67WOtb0359IjDZxaCU3gJILlkwkFbxbwrK9Bej5+NqNaYztzLOj8/NgMNMg/InxmhK+Uu8S/U4EcqsHzB7Q==
dependencies:
multibase "^3.0.0"
uint8arrays "^1.0.0"
varint "^5.0.0"
mute-stream@0.0.8: mute-stream@0.0.8:
version "0.0.8" version "0.0.8"
resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d"
@ -12423,6 +12483,11 @@ react-use-gesture@^6.0.14:
resolved "https://registry.yarnpkg.com/react-use-gesture/-/react-use-gesture-6.0.14.tgz#ab2d35ef72a5fb6060a6160eb12568c276f8a4b1" resolved "https://registry.yarnpkg.com/react-use-gesture/-/react-use-gesture-6.0.14.tgz#ab2d35ef72a5fb6060a6160eb12568c276f8a4b1"
integrity sha512-d9cnZJ0DOFd3FIO76J776DyhtbODgbxGKu19lvc1aSNTnRV5EKr9V4Uda188l2Qh0Va3pqWGxEQlw72r2cmnFQ== integrity sha512-d9cnZJ0DOFd3FIO76J776DyhtbODgbxGKu19lvc1aSNTnRV5EKr9V4Uda188l2Qh0Va3pqWGxEQlw72r2cmnFQ==
react-virtualized-auto-sizer@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.2.tgz#a61dd4f756458bbf63bd895a92379f9b70f803bd"
integrity sha512-MYXhTY1BZpdJFjUovvYHVBmkq79szK/k7V3MO+36gJkWGkrXKtyr4vCPtpphaTLRAdDNoYEYFZWE8LjN+PIHNg==
react-window@^1.8.5: react-window@^1.8.5:
version "1.8.5" version "1.8.5"
resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.5.tgz#a56b39307e79979721021f5d06a67742ecca52d1" resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.5.tgz#a56b39307e79979721021f5d06a67742ecca52d1"
@ -14376,6 +14441,22 @@ ua-parser-js@^0.7.21:
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.21.tgz#853cf9ce93f642f67174273cc34565ae6f308777" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.21.tgz#853cf9ce93f642f67174273cc34565ae6f308777"
integrity sha512-+O8/qh/Qj8CgC6eYBVBykMrNtp5Gebn4dlGD/kKXVkJNDwyrAwSIqwz8CDf+tsAIWVycKcku6gIXJ0qwx/ZXaQ== integrity sha512-+O8/qh/Qj8CgC6eYBVBykMrNtp5Gebn4dlGD/kKXVkJNDwyrAwSIqwz8CDf+tsAIWVycKcku6gIXJ0qwx/ZXaQ==
uint8arrays@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/uint8arrays/-/uint8arrays-1.0.0.tgz#9cf979517f85c32d6ef54adf824e3499bb715331"
integrity sha512-14tqEVujDREW7YwonSZZwLvo7aFDfX7b6ubvM/U7XvZol+CC/LbhaX/550VlWmhddAL9Wou1sxp0Of3tGqXigg==
dependencies:
multibase "^3.0.0"
web-encoding "^1.0.2"
uint8arrays@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/uint8arrays/-/uint8arrays-1.1.0.tgz#d034aa65399a9fd213a1579e323f0b29f67d0ed2"
integrity sha512-cLdlZ6jnFczsKf5IH1gPHTtcHtPGho5r4CvctohmQjw8K7Q3gFdfIGHxSTdTaCKrL4w09SsPRJTqRS0drYeszA==
dependencies:
multibase "^3.0.0"
web-encoding "^1.0.2"
unicode-canonical-property-names-ecmascript@^1.0.4: unicode-canonical-property-names-ecmascript@^1.0.4:
version "1.0.4" version "1.0.4"
resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818"
@ -14599,6 +14680,11 @@ value-equal@^1.0.1:
resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c" resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c"
integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw== integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==
varint@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/varint/-/varint-5.0.0.tgz#d826b89f7490732fabc0c0ed693ed475dcb29ebf"
integrity sha1-2Ca4n3SQcy+rwMDtaT7Uddyynr8=
vary@~1.1.2: vary@~1.1.2:
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
@ -14705,6 +14791,11 @@ wbuf@^1.1.0, wbuf@^1.7.3:
dependencies: dependencies:
minimalistic-assert "^1.0.0" minimalistic-assert "^1.0.0"
web-encoding@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/web-encoding/-/web-encoding-1.0.2.tgz#e5050d4826597f242eb6e0e5fede05e3b9512ca5"
integrity sha512-fe9pqxglgy25Z4Ds+2GwZIrOnLxeozydMj0iV8zx0ZNxE3MPTseF4oXGrrBuH4vSkoDYDXoPRRFY/FEYitEUTA==
web3-provider-engine@15.0.12: web3-provider-engine@15.0.12:
version "15.0.12" version "15.0.12"
resolved "https://registry.yarnpkg.com/web3-provider-engine/-/web3-provider-engine-15.0.12.tgz#24d7f2f6fb6de856824c7306291018c4fc543ac3" resolved "https://registry.yarnpkg.com/web3-provider-engine/-/web3-provider-engine-15.0.12.tgz#24d7f2f6fb6de856824c7306291018c4fc543ac3"