Compare commits

...

23 Commits

Author SHA1 Message Date
Callil Capuozzo
ce12635332 Merge branch 'master' of https://github.com/Uniswap/uniswap-interface 2020-08-26 15:15:06 -04:00
Callil Capuozzo
2182e18f85 Add coingecko and tweak list introduction screen 2020-08-26 15:14:48 -04:00
Moody Salem
ad2c7dfdff add 3 more lists 2020-08-26 13:36:55 -05:00
Moody Salem
cb36c9103e fix integration tests, update default list 2020-08-26 11:43:10 -05:00
Moody Salem
0a1459ee83 remove any bias from the list selection 2020-08-26 10:47:55 -05:00
Moody Salem
8896a042f0 title for list URL only on list origin 2020-08-26 10:14:38 -05:00
Moody Salem
61ad07c3f2 add zerion list 2020-08-26 10:10:57 -05:00
Moody Salem
81a5164d99 fix the browse lists link 2020-08-26 09:34:46 -05:00
Moody Salem
467e80a42f improvement(#1043): do not allow swapping to bad addresses 2020-08-26 09:19:59 -05:00
Moody Salem
58f25aa439 another list 2020-08-26 08:54:33 -05:00
Moody Salem
377c71f2e5 bump to latest token lists version 2020-08-26 08:48:59 -05:00
Moody Salem
7cf25ac7c8 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>
2020-08-26 08:46:21 -05:00
Moody Salem
09b54570e1 fix integration test for recipient 2020-08-24 13:52:41 -05:00
Moody Salem
73580de922 improvement(swap): show add a send only for expert mode 2020-08-24 13:33:46 -05:00
Moody Salem
e32fd3a8fc disable blank issues 2020-08-18 10:26:33 -05:00
Moody Salem
057417c666 add a snippet to the issue templates 2020-08-18 10:25:02 -05:00
Moody Salem
f1b300af70 add a hook for getting the USDC price of any currency 2020-08-17 08:36:38 -05:00
Moody Salem
600049bc6e fix(list): change the url of the default token list so we can move the file in the npm package 2020-08-11 14:44:25 -05:00
Moody Salem
6e91311489 remove console.log statement 2020-08-10 15:11:05 -05:00
Moody Salem
f6a464cb3b fix(ampl): do not swap ampl via pairs other than DAI/ETH 2020-08-10 15:05:41 -05:00
Moody Salem
e589c751d7 improvement(swap errors): show more information about the swap error 2020-08-10 12:05:35 -05:00
Moody Salem
0f91af1df2 improvement(swap): Better swap errors for FoT (#1015)
* move the gas estimation stuff into its own hook and report errors from the gas estimation

* fix linter errors

* show the swap callback error separately

* rename some variables

* use a manually specified key for gas estimates

* flip price... thought i did this already

* only show swap callback error if approval state is approved

* some clean up to the swap components

* stop proactively looking for gas estimates

* improve some retry stuff, show errors inline

* add another retry test

* latest ethers

* fix integration tests

* simplify modal and fix jitter on open in mobile

* refactor confirmation modal into pieces before creating the error content

* finish refactoring of transaction confirmation modal

* show error state in the transaction confirmation modal

* fix lint errors

* error not always relevant

* fix lint errors, remove action item

* move a lot of code into ConfirmSwapModal.tsx

* show accept changes flow, not styled

* Adjust styles for slippage error states

* Add styles for updated price prompt

* Add input/output highlighting

* lint errors

* fix link to wallets in modal

* use total supply instead of reserves for `noLiquidity` (fixes #701)

* bump the walletconnect version to the fixed alpha

Co-authored-by: Callil Capuozzo <callil.capuozzo@gmail.com>
2020-08-06 18:18:43 -05:00
Moody Salem
10ef04510a fix(fonts): font-display in non-font-variation-settings conditioned css 2020-07-31 12:12:42 -05:00
119 changed files with 5359 additions and 2159 deletions

View File

@@ -4,11 +4,18 @@ about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
<!--
DO NOT CREATE A TOKEN LISTING REQUEST IN THIS REPOSITORY.
YOUR ISSUE WILL BE DELETED.
SEE https://github.com/Uniswap/default-token-list#adding-a-token
IF YOU NEED SUPPORT, JOIN THE DISCORD: https://discord.com/invite/EwFs3Pp
-->
**Bug Description**
A clear and concise description of what the bug is.
A clear and concise description of the bug.
**Steps to Reproduce**
1. Go to ...

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -4,9 +4,16 @@ about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
<!--
DO NOT CREATE A TOKEN LISTING REQUEST IN THIS REPOSITORY.
YOUR ISSUE WILL BE DELETED.
SEE https://github.com/Uniswap/default-token-list#adding-a-token
IF YOU NEED SUPPORT, JOIN THE DISCORD: https://discord.com/invite/EwFs3Pp
-->
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]

View File

@@ -4,7 +4,15 @@ about: Tell us something else
title: ''
labels: ''
assignees: ''
---
<!--
DO NOT CREATE A TOKEN LISTING REQUEST IN THIS REPOSITORY.
YOUR ISSUE WILL BE DELETED.
SEE https://github.com/Uniswap/default-token-list#adding-a-token
IF YOU NEED SUPPORT, JOIN THE DISCORD: https://discord.com/invite/EwFs3Pp
-->

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
describe('Swap', () => {
beforeEach(() => {
cy.clearLocalStorage()
cy.visit('/swap')
})
it('list selection persists', () => {
cy.get('#swap-currency-output .open-currency-select-button').click()
cy.get('#list-introduction-choose-a-list').click()
cy.get('#list-row-tokens-uniswap-eth .select-button').click()
cy.reload()
cy.get('#swap-currency-output .open-currency-select-button').click()
cy.get('#list-introduction-choose-a-list').should('not.exist')
})
// for some reason local storage is not being properly cleared
it.skip('change list', () => {
cy.get('#swap-currency-output .open-currency-select-button').click()
cy.get('#list-introduction-choose-a-list').click()
cy.get('#list-row-tokens-uniswap-eth .select-button').click()
cy.get('#currency-search-selected-list-name').should('contain', 'Uniswap')
cy.get('#currency-search-change-list-button').click()
cy.get('#list-row-tokens-1inch-eth .select-button').click()
cy.get('#currency-search-selected-list-name').should('contain', '1inch')
})
})

View File

@@ -1,5 +1,8 @@
describe('Swap', () => {
beforeEach(() => cy.visit('/swap'))
beforeEach(() => {
cy.clearLocalStorage()
cy.visit('/swap')
})
it('can enter an amount into input', () => {
cy.get('#swap-currency-input .token-amount-input')
.type('0.001', { delay: 200 })
@@ -32,6 +35,8 @@ describe('Swap', () => {
it('can swap ETH for DAI', () => {
cy.get('#swap-currency-output .open-currency-select-button').click()
cy.get('#list-introduction-choose-a-list').click()
cy.get('#list-row-tokens-uniswap-eth .select-button').click()
cy.get('.token-item-0xc7AD46e0b8a400Bb3C915120d284AafbA8fc4735').should('be.visible')
cy.get('.token-item-0xc7AD46e0b8a400Bb3C915120d284AafbA8fc4735').click({ force: true })
cy.get('#swap-currency-input .token-amount-input').should('be.visible')
@@ -41,14 +46,32 @@ describe('Swap', () => {
cy.get('#confirm-swap-or-send').should('contain', 'Confirm Swap')
})
it('add a recipient', () => {
cy.get('#add-recipient-button').click()
cy.get('#recipient').should('exist')
it('add a recipient does not exist unless in expert mode', () => {
cy.get('#add-recipient-button').should('not.exist')
})
it('remove recipient', () => {
cy.get('#add-recipient-button').click()
cy.get('#remove-recipient-button').click()
cy.get('#recipient').should('not.exist')
describe('expert mode', () => {
beforeEach(() => {
cy.window().then(win => {
cy.stub(win, 'prompt').returns('confirm')
})
cy.get('#open-settings-dialog-button').click()
cy.get('#toggle-expert-mode-button').click()
cy.get('#confirm-expert-mode').click()
})
it('add a recipient is visible', () => {
cy.get('#add-recipient-button').should('be.visible')
})
it('add a recipient', () => {
cy.get('#add-recipient-button').click()
cy.get('#recipient').should('exist')
})
it('remove recipient', () => {
cy.get('#add-recipient-button').click()
cy.get('#remove-recipient-button').click()
cy.get('#recipient').should('not.exist')
})
})
})

View File

@@ -6,14 +6,11 @@ describe('Warning', () => {
it('Check that warning is displayed', () => {
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-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')
})
})

View File

@@ -4,37 +4,30 @@
"homepage": ".",
"private": true,
"devDependencies": {
"@ethersproject/address": "5.0.0-beta.134",
"@ethersproject/bignumber": "5.0.0-beta.138",
"@ethersproject/constants": "5.0.0-beta.133",
"@ethersproject/contracts": "5.0.0-beta.151",
"@ethersproject/experimental": "5.0.0-beta.141",
"@ethersproject/networks": "5.0.0-beta.136",
"@ethersproject/providers": "5.0.0-beta.162",
"@ethersproject/solidity": "5.0.2",
"@ethersproject/strings": "5.0.0-beta.136",
"@ethersproject/units": "5.0.0-beta.132",
"@ethersproject/wallet": "5.0.0-beta.141",
"@ethersproject/experimental": "^5.0.1",
"@popperjs/core": "^2.4.4",
"@reach/dialog": "^0.10.3",
"@reach/portal": "^0.10.3",
"@reduxjs/toolkit": "^1.3.5",
"@types/jest": "^25.2.1",
"@types/lodash.flatmap": "^4.5.6",
"@types/multicodec": "^1.0.0",
"@types/node": "^13.13.5",
"@types/qs": "^6.9.2",
"@types/react": "^16.9.34",
"@types/react-dom": "^16.9.7",
"@types/react-redux": "^7.1.8",
"@types/react-router-dom": "^5.0.0",
"@types/react-virtualized-auto-sizer": "^1.0.0",
"@types/react-window": "^1.8.2",
"@types/rebass": "^4.0.5",
"@types/styled-components": "^5.1.0",
"@types/testing-library__cypress": "^5.0.5",
"@typescript-eslint/eslint-plugin": "^2.31.0",
"@typescript-eslint/parser": "^2.31.0",
"@uniswap/default-token-list": "^1.3.1",
"@uniswap/sdk": "3.0.3-beta.1",
"@uniswap/token-lists": "^1.0.0-beta.11",
"@uniswap/token-lists": "^1.0.0-beta.15",
"@uniswap/v2-core": "1.0.0",
"@uniswap/v2-periphery": "^1.1.0-beta.0",
"@web3-react/core": "^6.0.9",
@@ -44,6 +37,7 @@
"@web3-react/walletconnect-connector": "^6.1.1",
"@web3-react/walletlink-connector": "^6.0.9",
"ajv": "^6.12.3",
"cids": "^1.0.0",
"copy-to-clipboard": "^3.2.0",
"cross-env": "^7.0.2",
"cypress": "^4.11.0",
@@ -52,15 +46,17 @@
"eslint-plugin-prettier": "^3.1.3",
"eslint-plugin-react": "^7.19.0",
"eslint-plugin-react-hooks": "^4.0.0",
"ethers": "^5.0.7",
"i18next": "^15.0.9",
"i18next-browser-languagedetector": "^3.0.1",
"i18next-xhr-backend": "^2.0.1",
"inter-ui": "^3.13.1",
"jazzicon": "^1.5.0",
"lodash.flatmap": "^4.5.0",
"multicodec": "^2.0.0",
"multihashes": "^3.0.1",
"polished": "^3.3.2",
"prettier": "^1.17.0",
"qrcode.react": "^0.9.3",
"qs": "^6.9.4",
"react": "^16.13.1",
"react-device-detect": "^1.6.2",
@@ -74,14 +70,17 @@
"react-scripts": "^3.4.1",
"react-spring": "^8.0.27",
"react-use-gesture": "^6.0.14",
"react-virtualized-auto-sizer": "^1.0.2",
"react-window": "^1.8.5",
"rebass": "^4.0.7",
"redux-localstorage-simple": "^2.2.0",
"serve": "^11.3.0",
"start-server-and-test": "^1.11.0",
"styled-components": "^4.2.0",
"typescript": "^3.8.3",
"use-media": "^1.4.0"
"typescript": "^3.8.3"
},
"resolutions": {
"@walletconnect/web3-provider": "1.1.1-alpha.0"
},
"scripts": {
"start": "react-scripts start",

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

View File

@@ -251,26 +251,26 @@ export default function AccountDetails({
} else if (connector === walletconnect) {
return (
<IconWrapper size={16}>
<img src={WalletConnectIcon} alt={''} />
<img src={WalletConnectIcon} alt={'wallet connect logo'} />
</IconWrapper>
)
} else if (connector === walletlink) {
return (
<IconWrapper size={16}>
<img src={CoinbaseWalletIcon} alt={''} />
<img src={CoinbaseWalletIcon} alt={'coinbase wallet logo'} />
</IconWrapper>
)
} else if (connector === fortmatic) {
return (
<IconWrapper size={16}>
<img src={FortmaticIcon} alt={''} />
<img src={FortmaticIcon} alt={'fortmatic logo'} />
</IconWrapper>
)
} else if (connector === portis) {
return (
<>
<IconWrapper size={16}>
<img src={PortisIcon} alt={''} />
<img src={PortisIcon} alt={'portis logo'} />
<MainWalletAction
onClick={() => {
portis.portis.showPortis()
@@ -382,7 +382,6 @@ export default function AccountDetails({
</AccountControl>
</>
)}
{/* {formatConnectorName()} */}
</AccountGroupingRow>
</InfoCard>
</YourAccount>

View File

@@ -27,6 +27,8 @@ const Base = styled(RebassButton)<{
flex-wrap: nowrap;
align-items: center;
cursor: pointer;
position: relative;
z-index: 1;
&:disabled {
cursor: auto;
}

View File

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

View File

@@ -1,133 +0,0 @@
import React, { useContext } from 'react'
import styled, { ThemeContext } from 'styled-components'
import Modal from '../Modal'
import { ExternalLink } from '../../theme'
import { Text } from 'rebass'
import { CloseIcon, Spinner } from '../../theme/components'
import { RowBetween } from '../Row'
import { ArrowUpCircle } from 'react-feather'
import { ButtonPrimary } from '../Button'
import { AutoColumn, ColumnCenter } from '../Column'
import Circle from '../../assets/images/blue-loader.svg'
import { getEtherscanLink } from '../../utils'
import { useActiveWeb3React } from '../../hooks'
const Wrapper = styled.div`
width: 100%;
`
const Section = styled(AutoColumn)`
padding: 24px;
`
const BottomSection = styled(Section)`
background-color: ${({ theme }) => theme.bg2};
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
`
const ConfirmedIcon = styled(ColumnCenter)`
padding: 60px 0;
`
const CustomLightSpinner = styled(Spinner)<{ size: string }>`
height: ${({ size }) => size};
width: ${({ size }) => size};
`
interface ConfirmationModalProps {
isOpen: boolean
onDismiss: () => void
hash: string
topContent: () => React.ReactChild
bottomContent: () => React.ReactChild
attemptingTxn: boolean
pendingText: string
title?: string
}
export default function ConfirmationModal({
isOpen,
onDismiss,
topContent,
bottomContent,
attemptingTxn,
hash,
pendingText,
title = ''
}: ConfirmationModalProps) {
const { chainId } = useActiveWeb3React()
const theme = useContext(ThemeContext)
const transactionBroadcast = !!hash
// waiting for user to confirm/reject tx _or_ showing info on a tx that has been broadcast
if (attemptingTxn || transactionBroadcast) {
return (
<Modal isOpen={isOpen} onDismiss={onDismiss} maxHeight={90}>
<Wrapper>
<Section>
<RowBetween>
<div />
<CloseIcon onClick={onDismiss} />
</RowBetween>
<ConfirmedIcon>
{transactionBroadcast ? (
<ArrowUpCircle strokeWidth={0.5} size={90} color={theme.primary1} />
) : (
<CustomLightSpinner src={Circle} alt="loader" size={'90px'} />
)}
</ConfirmedIcon>
<AutoColumn gap="12px" justify={'center'}>
<Text fontWeight={500} fontSize={20}>
{transactionBroadcast ? 'Transaction Submitted' : 'Waiting For Confirmation'}
</Text>
<AutoColumn gap="12px" justify={'center'}>
<Text fontWeight={600} fontSize={14} color="" textAlign="center">
{pendingText}
</Text>
</AutoColumn>
{transactionBroadcast ? (
<>
<ExternalLink href={getEtherscanLink(chainId, hash, 'transaction')}>
<Text fontWeight={500} fontSize={14} color={theme.primary1}>
View on Etherscan
</Text>
</ExternalLink>
<ButtonPrimary onClick={onDismiss} style={{ margin: '20px 0 0 0' }}>
<Text fontWeight={500} fontSize={20}>
Close
</Text>
</ButtonPrimary>
</>
) : (
<Text fontSize={12} color="#565A69" textAlign="center">
Confirm this transaction in your wallet
</Text>
)}
</AutoColumn>
</Section>
</Wrapper>
</Modal>
)
}
// confirmation screen
return (
<Modal isOpen={isOpen} onDismiss={onDismiss} maxHeight={90}>
<Wrapper>
<Section>
<RowBetween>
<Text fontWeight={500} fontSize={20}>
{title}
</Text>
<CloseIcon onClick={onDismiss} />
</RowBetween>
{topContent()}
</Section>
<BottomSection gap="12px">{bottomContent()}</BottomSection>
</Wrapper>
</Modal>
)
}

View File

@@ -7,7 +7,7 @@ import CurrencySearchModal from '../SearchModal/CurrencySearchModal'
import CurrencyLogo from '../CurrencyLogo'
import DoubleCurrencyLogo from '../DoubleLogo'
import { RowBetween } from '../Row'
import { TYPE, CursorPointer } from '../../theme'
import { TYPE } from '../../theme'
import { Input as NumericalInput } from '../NumericalInput'
import { ReactComponent as DropDown } from '../../assets/images/dropdown.svg'
@@ -126,7 +126,6 @@ interface CurrencyInputPanelProps {
hideBalance?: boolean
pair?: Pair | null
hideInput?: boolean
showSendWithSwap?: boolean
otherCurrency?: Currency | null
id: string
showCommonBases?: boolean
@@ -144,7 +143,6 @@ export default function CurrencyInputPanel({
hideBalance = false,
pair = null, // used for double token logo
hideInput = false,
showSendWithSwap = false,
otherCurrency = null,
id,
showCommonBases
@@ -170,19 +168,17 @@ export default function CurrencyInputPanel({
{label}
</TYPE.body>
{account && (
<CursorPointer>
<TYPE.body
onClick={onMax}
color={theme.text2}
fontWeight={500}
fontSize={14}
style={{ display: 'inline' }}
>
{!hideBalance && !!currency && selectedCurrencyBalance
? 'Balance: ' + selectedCurrencyBalance?.toSignificant(6)
: ' -'}
</TYPE.body>
</CursorPointer>
<TYPE.body
onClick={onMax}
color={theme.text2}
fontWeight={500}
fontSize={14}
style={{ display: 'inline', cursor: 'pointer' }}
>
{!hideBalance && !!currency && selectedCurrencyBalance
? 'Balance: ' + selectedCurrencyBalance?.toSignificant(6)
: ' -'}
</TYPE.body>
)}
</RowBetween>
</LabelRow>
@@ -240,8 +236,7 @@ export default function CurrencyInputPanel({
isOpen={modalOpen}
onDismiss={handleDismissSearch}
onCurrencySelect={onCurrencySelect}
showSendWithSwap={showSendWithSwap}
hiddenCurrency={currency}
selectedCurrency={currency}
otherSelectedCurrency={otherCurrency}
showCommonBases={showCommonBases}
/>

View File

@@ -1,32 +1,14 @@
import { Currency, ETHER, Token } from '@uniswap/sdk'
import React, { useState } from 'react'
import React, { useMemo } from 'react'
import styled from 'styled-components'
import EthereumLogo from '../../assets/images/ethereum-logo.png'
import useHttpLocations from '../../hooks/useHttpLocations'
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`
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 }>`
width: ${({ size }) => size};
@@ -35,60 +17,38 @@ const StyledEthereumLogo = styled.img<{ size: string }>`
border-radius: 24px;
`
const StyledLogo = styled(Logo)<{ size: string }>`
width: ${({ size }) => size};
height: ${({ size }) => size};
`
export default function CurrencyLogo({
currency,
size = '24px',
...rest
style
}: {
currency?: Currency
size?: string
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) {
return <StyledEthereumLogo src={EthereumLogo} size={size} {...rest} />
return <StyledEthereumLogo src={EthereumLogo} size={size} style={style} />
}
if (currency instanceof Token) {
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>
)
return <StyledLogo size={size} srcs={srcs} alt={`${currency?.symbol ?? 'token'} logo`} style={style} />
}

View File

@@ -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} />
}

View File

@@ -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} />
}

View File

@@ -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 styled from 'styled-components'
import { ReactComponent as MenuIcon } from '../../assets/images/menu.svg'
import { useOnClickOutside } from '../../hooks/useOnClickOutside'
import useToggle from '../../hooks/useToggle'
import { ExternalLink } from '../../theme'
@@ -83,24 +84,7 @@ export default function Menu() {
const node = useRef<HTMLDivElement>()
const [open, toggle] = useToggle(false)
useEffect(() => {
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])
useOnClickOutside(node, open ? toggle : undefined)
return (
<StyledMenu ref={node}>

View File

@@ -1,8 +1,6 @@
import React from 'react'
import styled, { css } from 'styled-components'
import { animated, useTransition, useSpring } from 'react-spring'
import { Spring } from 'react-spring/renderprops'
import { DialogOverlay, DialogContent } from '@reach/dialog'
import { isMobile } from 'react-device-detect'
import '@reach/dialog/styles.css'
@@ -11,39 +9,25 @@ import { useGesture } from 'react-use-gesture'
const AnimatedDialogOverlay = animated(DialogOverlay)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const StyledDialogOverlay = styled(({ mobile, ...rest }) => <AnimatedDialogOverlay {...rest} />)<{ mobile: boolean }>`
const StyledDialogOverlay = styled(AnimatedDialogOverlay)`
&[data-reach-dialog-overlay] {
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
background-color: transparent;
overflow: hidden;
${({ mobile }) =>
mobile &&
css`
align-items: flex-end;
`}
display: flex;
align-items: center;
justify-content: center;
&::after {
content: '';
background-color: ${({ theme }) => theme.modalBG};
opacity: 0.5;
top: 0;
left: 0;
bottom: 0;
right: 0;
position: fixed;
z-index: -1;
}
background-color: ${({ theme }) => theme.modalBG};
}
`
const AnimatedDialogContent = animated(DialogContent)
// destructure to not pass custom props to Dialog DOM element
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const StyledDialogContent = styled(({ minHeight, maxHeight, mobile, isOpen, ...rest }) => (
<DialogContent {...rest} />
<AnimatedDialogContent {...rest} />
)).attrs({
'aria-label': 'dialog'
})`
@@ -54,6 +38,9 @@ const StyledDialogContent = styled(({ minHeight, maxHeight, mobile, isOpen, ...r
box-shadow: 0 4px 8px 0 ${({ theme }) => transparentize(0.95, theme.shadow1)};
padding: 0px;
width: 50vw;
overflow: hidden;
align-self: ${({ mobile }) => (mobile ? 'flex-end' : 'center')};
max-width: 420px;
${({ maxHeight }) =>
@@ -102,7 +89,7 @@ export default function Modal({
initialFocusRef = null,
children
}: ModalProps) {
const transitions = useTransition(isOpen, null, {
const fadeTransition = useTransition(isOpen, null, {
config: { duration: 200 },
from: { opacity: 0 },
enter: { opacity: 1 },
@@ -115,80 +102,37 @@ export default function Modal({
set({
y: state.down ? state.movement[1] : 0
})
if (state.velocity > 3 && state.direction[1] > 0) {
if (state.movement[1] > 300 || (state.velocity > 3 && state.direction[1] > 0)) {
onDismiss()
}
}
})
if (isMobile) {
return (
<>
{transitions.map(
({ item, key, props }) =>
item && (
<StyledDialogOverlay
key={key}
style={props}
onDismiss={onDismiss}
initialFocusRef={initialFocusRef}
mobile={true}
return (
<>
{fadeTransition.map(
({ item, key, props }) =>
item && (
<StyledDialogOverlay key={key} style={props} onDismiss={onDismiss} initialFocusRef={initialFocusRef}>
<StyledDialogContent
{...(isMobile
? {
...bind(),
style: { transform: y.interpolate(y => `translateY(${y > 0 ? y : 0}px)`) }
}
: {})}
aria-label="dialog content"
minHeight={minHeight}
maxHeight={maxHeight}
mobile={isMobile}
>
{/* prevents the automatic focusing of inputs on mobile by the reach dialog */}
{initialFocusRef ? null : <div tabIndex={1} />}
<Spring // animation for entrance and exit
from={{
transform: isOpen ? 'translateY(200px)' : 'translateY(100px)'
}}
to={{
transform: isOpen ? 'translateY(0px)' : 'translateY(200px)'
}}
>
{props => (
<animated.div
{...bind()}
style={{
transform: y.interpolate(y => `translateY(${y > 0 ? y : 0}px)`)
}}
>
<StyledDialogContent
aria-label="dialog content"
style={props}
hidden={true}
minHeight={minHeight}
maxHeight={maxHeight}
mobile={isMobile}
>
{children}
</StyledDialogContent>
</animated.div>
)}
</Spring>
</StyledDialogOverlay>
)
)}
</>
)
} else {
return (
<>
{transitions.map(
({ item, key, props }) =>
item && (
<StyledDialogOverlay key={key} style={props} onDismiss={onDismiss} initialFocusRef={initialFocusRef}>
<StyledDialogContent
aria-label="dialog content"
hidden={true}
minHeight={minHeight}
maxHeight={maxHeight}
isOpen={isOpen}
>
{children}
</StyledDialogContent>
</StyledDialogOverlay>
)
)}
</>
)
}
{!initialFocusRef && isMobile ? <div tabIndex={1} /> : null}
{children}
</StyledDialogContent>
</StyledDialogOverlay>
)
)}
</>
)
}

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import React, { useContext } from 'react'
import { AlertCircle, CheckCircle } from 'react-feather'
import { ThemeContext } from 'styled-components'
import styled, { ThemeContext } from 'styled-components'
import { useActiveWeb3React } from '../../hooks'
import { TYPE } from '../../theme'
import { ExternalLink } from '../../theme/components'
@@ -8,13 +8,25 @@ import { getEtherscanLink } from '../../utils'
import { AutoColumn } from '../Column'
import { AutoRow } from '../Row'
export default function TxnPopup({ hash, success, summary }: { hash: string; success?: boolean; summary?: string }) {
const RowNoFlex = styled(AutoRow)`
flex-wrap: nowrap;
`
export default function TransactionPopup({
hash,
success,
summary
}: {
hash: string
success?: boolean
summary?: string
}) {
const { chainId } = useActiveWeb3React()
const theme = useContext(ThemeContext)
return (
<AutoRow>
<RowNoFlex>
<div style={{ paddingRight: 16 }}>
{success ? <CheckCircle color={theme.green1} size={24} /> : <AlertCircle color={theme.red1} size={24} />}
</div>
@@ -22,6 +34,6 @@ export default function TxnPopup({ hash, success, summary }: { hash: string; suc
<TYPE.body fontWeight={500}>{summary ?? 'Hash: ' + hash.slice(0, 8) + '...' + hash.slice(58, 65)}</TYPE.body>
<ExternalLink href={getEtherscanLink(chainId, hash, 'transaction')}>View on Etherscan</ExternalLink>
</AutoColumn>
</AutoRow>
</RowNoFlex>
)
}

View File

@@ -1,6 +1,5 @@
import React from 'react'
import styled from 'styled-components'
import { useMediaLayout } from 'use-media'
import { useActivePopups } from '../../state/application/hooks'
import { AutoColumn } from '../Column'
import PopupItem from './PopupItem'
@@ -11,6 +10,11 @@ const MobilePopupWrapper = styled.div<{ height: string | number }>`
height: ${({ height }) => height};
margin: ${({ height }) => (height ? '0 auto;' : 0)};
margin-bottom: ${({ height }) => (height ? '20px' : 0)}};
display: none;
${({ theme }) => theme.mediaWidth.upToSmall`
display: block;
`};
`
const MobilePopupInner = styled.div`
@@ -26,11 +30,12 @@ const MobilePopupInner = styled.div`
`
const FixedPopupColumn = styled(AutoColumn)`
position: absolute;
top: 112px;
position: fixed;
top: 64px;
right: 1rem;
max-width: 355px !important;
width: 100%;
z-index: 2;
${({ theme }) => theme.mediaWidth.upToSmall`
display: none;
@@ -41,30 +46,23 @@ export default function Popups() {
// get all popups
const activePopups = useActivePopups()
// switch view settings on mobile
const isMobile = useMediaLayout({ maxWidth: '600px' })
if (!isMobile) {
return (
return (
<>
<FixedPopupColumn gap="20px">
{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>
)
}
//mobile
else
return (
<MobilePopupWrapper height={activePopups?.length > 0 ? 'fit-content' : 0}>
<MobilePopupInner>
{activePopups // reverse so new items up front
.slice(0)
.reverse()
.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>
</MobilePopupWrapper>
)
</>
)
}

View File

@@ -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 open = useCallback(() => setShow(true), [setShow])
@@ -30,7 +30,7 @@ export default function QuestionHelper({ text, disabled }: { text: string; disab
return (
<span style={{ marginLeft: 4 }}>
<Tooltip text={text} show={show && !disabled}>
<Tooltip text={text} show={show}>
<QuestionWrapper onClick={open} onMouseEnter={open} onMouseLeave={close}>
<Question size={16} />
</QuestionWrapper>

View File

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

View File

@@ -1,155 +1,208 @@
import { Currency, CurrencyAmount, currencyEquals, ETHER, JSBI, Token } from '@uniswap/sdk'
import React, { CSSProperties, memo, useContext, useMemo } from 'react'
import { Currency, CurrencyAmount, currencyEquals, ETHER, Token } from '@uniswap/sdk'
import React, { CSSProperties, MutableRefObject, useCallback, useMemo } from 'react'
import { FixedSizeList } from 'react-window'
import { Text } from 'rebass'
import { ThemeContext } from 'styled-components'
import styled from 'styled-components'
import { useActiveWeb3React } from '../../hooks'
import { useAllTokens } from '../../hooks/Tokens'
import { useDefaultTokenList } from '../../state/lists/hooks'
import { useSelectedTokenList, WrappedTokenInfo } from '../../state/lists/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 { ButtonSecondary } from '../Button'
import Column, { AutoColumn } from '../Column'
import Column from '../Column'
import { RowFixed } from '../Row'
import CurrencyLogo from '../CurrencyLogo'
import { MouseoverTooltip } from '../Tooltip'
import { FadedSpan, MenuItem } from './styleds'
import Loader from '../Loader'
import { isDefaultToken } from '../../utils'
import { isTokenOnList } from '../../utils'
function currencyKey(currency: Currency): string {
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({
height,
currencies,
allBalances,
selectedCurrency,
onCurrencySelect,
otherCurrency,
showSendWithSwap
fixedListRef,
showETH
}: {
height: number
currencies: Currency[]
selectedCurrency: Currency
allBalances: { [tokenAddress: string]: CurrencyAmount }
selectedCurrency: Currency | undefined
onCurrencySelect: (currency: Currency) => void
otherCurrency: Currency
showSendWithSwap?: boolean
otherCurrency: Currency | undefined
fixedListRef?: MutableRefObject<FixedSizeList | undefined>
showETH: boolean
}) {
const { account, chainId } = useActiveWeb3React()
const theme = useContext(ThemeContext)
const allTokens = useAllTokens()
const defaultTokens = useDefaultTokenList()
const addToken = useAddUserToken()
const removeToken = useRemoveUserAddedToken()
const ETHBalance = useETHBalances([account])[account]
const itemData = useMemo(() => (showETH ? [Currency.ETHER, ...currencies] : currencies), [currencies, showETH])
const CurrencyRow = useMemo(() => {
return memo(function CurrencyRow({ index, style }: { index: number; style: CSSProperties }) {
const currency = index === 0 ? Currency.ETHER : currencies[index - 1]
const key = currencyKey(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 Row = useCallback(
({ data, index, style }) => {
const currency: Currency = data[index]
const isSelected = Boolean(selectedCurrency && currencyEquals(selectedCurrency, currency))
const otherSelected = Boolean(otherCurrency && currencyEquals(otherCurrency, currency))
const handleSelect = () => onCurrencySelect(currency)
return (
<MenuItem
<CurrencyRow
style={style}
className={`token-item-${key}`}
onClick={() => (isSelected ? null : onCurrencySelect(currency))}
disabled={isSelected}
selected={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>
currency={currency}
isSelected={isSelected}
onSelect={handleSelect}
otherSelected={otherSelected}
/>
)
})
}, [
ETHBalance,
account,
addToken,
allBalances,
allTokens,
chainId,
currencies,
defaultTokens,
onCurrencySelect,
otherCurrency,
removeToken,
selectedCurrency,
showSendWithSwap,
theme.primary1
])
},
[onCurrencySelect, otherCurrency, selectedCurrency]
)
const itemKey = useCallback((index: number, data: any) => currencyKey(data[index]), [])
return (
<FixedSizeList
height={height}
ref={fixedListRef as any}
width="100%"
height={500}
itemCount={currencies.length + 1}
itemData={itemData}
itemCount={itemData.length}
itemSize={56}
style={{ flex: '1' }}
itemKey={index => currencyKey(currencies[index])}
itemKey={itemKey}
>
{CurrencyRow}
{Row}
</FixedSizeList>
)
}

View File

@@ -0,0 +1,214 @@
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 id="currency-search-selected-list-name">{selectedListInfo.current.name}</TYPE.main>
</Row>
) : null}
<LinkStyledButton
style={{ fontWeight: 500, color: theme.text2, fontSize: 16 }}
onClick={onChangeList}
id="currency-search-change-list-button"
>
{selectedListInfo.current ? 'Change' : 'Select a list'}
</LinkStyledButton>
</RowBetween>
</Card>
</Column>
)
}

View File

@@ -1,34 +1,18 @@
import { Currency, Token } from '@uniswap/sdk'
import React, { KeyboardEvent, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { isMobile } from 'react-device-detect'
import { useTranslation } from 'react-i18next'
import { Text } from 'rebass'
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 { Currency } from '@uniswap/sdk'
import React, { useCallback, useEffect, useState } from 'react'
import ReactGA from 'react-ga'
import useLast from '../../hooks/useLast'
import { useSelectedListUrl } from '../../state/lists/hooks'
import Modal from '../Modal'
import QuestionHelper from '../QuestionHelper'
import { AutoRow, RowBetween } from '../Row'
import Tooltip from '../Tooltip'
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'
import { CurrencySearch } from './CurrencySearch'
import ListIntroduction from './ListIntroduction'
import { ListSelect } from './ListSelect'
interface CurrencySearchModalProps {
isOpen?: boolean
onDismiss?: () => void
hiddenCurrency?: Currency
showSendWithSwap?: boolean
onCurrencySelect?: (currency: Currency) => void
isOpen: boolean
onDismiss: () => void
selectedCurrency?: Currency
onCurrencySelect: (currency: Currency) => void
otherSelectedCurrency?: Currency
showCommonBases?: boolean
}
@@ -37,53 +21,18 @@ export default function CurrencySearchModal({
isOpen,
onDismiss,
onCurrencySelect,
hiddenCurrency,
showSendWithSwap,
selectedCurrency,
otherSelectedCurrency,
showCommonBases = false
}: CurrencySearchModalProps) {
const { t } = useTranslation()
const { account, chainId } = useActiveWeb3React()
const theme = useContext(ThemeContext)
const [listView, setListView] = useState<boolean>(false)
const lastOpen = useLast(isOpen)
const [searchQuery, setSearchQuery] = useState<string>('')
const [tooltipOpen, setTooltipOpen] = useState<boolean>(false)
const [invertSearchOrder, setInvertSearchOrder] = useState<boolean>(false)
const allTokens = useAllTokens()
// 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])
useEffect(() => {
if (isOpen && !lastOpen) {
setListView(false)
}
}, [isOpen, lastOpen])
const handleCurrencySelect = useCallback(
(currency: Currency) => {
@@ -93,114 +42,44 @@ export default function CurrencySearchModal({
[onDismiss, onCurrencySelect]
)
// clear the input on open
useEffect(() => {
if (isOpen) setSearchQuery('')
}, [isOpen, setSearchQuery])
// 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)
setTooltipOpen(false)
const handleClickChangeList = useCallback(() => {
ReactGA.event({
category: 'Lists',
action: 'Change Lists'
})
setListView(true)
}, [])
const handleClickBack = useCallback(() => {
ReactGA.event({
category: 'Lists',
action: 'Back'
})
setListView(false)
}, [])
const handleSelectListIntroduction = useCallback(() => {
setListView(true)
}, [])
const openTooltip = useCallback(() => {
setTooltipOpen(true)
}, [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]
)
const selectedListUrl = useSelectedListUrl()
const noListSelected = !selectedListUrl
return (
<Modal
isOpen={isOpen}
onDismiss={onDismiss}
maxHeight={70}
initialFocusRef={isMobile ? undefined : inputRef}
minHeight={70}
>
<Column style={{ width: '100%' }}>
<PaddedColumn gap="14px">
<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}
<Modal isOpen={isOpen} onDismiss={onDismiss} maxHeight={90} minHeight={listView ? 40 : noListSelected ? 0 : 80}>
{listView ? (
<ListSelect onDismiss={onDismiss} onBack={handleClickBack} />
) : noListSelected ? (
<ListIntroduction onSelectList={handleSelectListIntroduction} />
) : (
<CurrencySearch
isOpen={isOpen}
onDismiss={onDismiss}
onCurrencySelect={handleCurrencySelect}
otherCurrency={otherSelectedCurrency}
selectedCurrency={hiddenCurrency}
showSendWithSwap={showSendWithSwap}
onChangeList={handleClickChangeList}
selectedCurrency={selectedCurrency}
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>
)
}

View File

@@ -0,0 +1,47 @@
import React from 'react'
import { Text } from 'rebass'
import { ExternalLink } from '../../theme'
import { ButtonPrimary } from '../Button'
import { OutlineCard } from '../Card'
import Column, { AutoColumn } from '../Column'
import { PaddedColumn } from './styleds'
import { useDarkModeManager } from '../../state/user/hooks'
import listLight from '../../assets/images/token-list/lists-light.png'
import listDark from '../../assets/images/token-list/lists-dark.png'
export default function ListIntroduction({ onSelectList }: { onSelectList: () => void }) {
const [isDark] = useDarkModeManager()
return (
<Column style={{ width: '100%', flex: '1 1' }}>
<PaddedColumn>
<AutoColumn gap="14px">
<img
style={{ width: '120px', margin: '0 auto' }}
src={isDark ? listDark : listLight}
alt="token-list-preview"
/>
<img
style={{ width: '100%', borderRadius: '12px' }}
src="https://cloudflare-ipfs.com/ipfs/QmRf1rAJcZjV3pwKTHfPdJh4RxR8yvRHkdLjZCsmp7T6hA"
alt="token-list-preview"
/>
<Text style={{ marginBottom: '8px', textAlign: 'center' }}>
Uniswap now supports token lists. You can add your own custom lists via IPFS, HTTPS and ENS.{' '}
</Text>
<ButtonPrimary onClick={onSelectList} id="list-introduction-choose-a-list">
Choose a list
</ButtonPrimary>
<OutlineCard 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 learn more.
</Text>
</OutlineCard>
</AutoColumn>
</PaddedColumn>
</Column>
)
}

View File

@@ -0,0 +1,379 @@
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
const lowerListUrl = listUrl.toLowerCase()
if (lowerListUrl.startsWith('ipfs://') || lowerListUrl.startsWith('ipns://')) {
return listUrl
}
try {
const url = new URL(listUrl)
return url.host
} catch (error) {
return undefined
}
}, [listUrl, ensName])
return <>{ensName ?? host}</>
}
function listUrlRowHTMLId(listUrl: string) {
return `list-row-${listUrl.replace(/\./g, '-')}`
}
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" id={listUrlRowHTMLId(listUrl)}>
{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' }}
>
{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 />
<div style={{ padding: '16px', textAlign: 'center' }}>
<ExternalLink href="https://tokenlists.org">Browse lists</ExternalLink>
</div>
</Column>
)
}

View File

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

View File

@@ -17,12 +17,26 @@ export const FadedSpan = styled(RowFixed)`
font-size: 14px;
`
export const GreySpan = styled.span`
color: ${({ theme }) => theme.text3};
font-weight: 400;
export const PaddedColumn = styled(AutoColumn)`
padding: 20px;
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;
display: flex;
padding: 16px;
@@ -43,28 +57,20 @@ export const Input = styled.input`
::placeholder {
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;
:focus {
border: 1px solid ${({ theme }) => theme.primary1};
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};
`

View File

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

View File

@@ -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 styled from 'styled-components'
import { useOnClickOutside } from '../../hooks/useOnClickOutside'
import {
useUserSlippageTolerance,
useExpertModeManager,
useUserDeadline,
useDarkModeManager
} from '../../state/user/hooks'
import SlippageTabs from '../SlippageTabs'
import TransactionSettings from '../TransactionSettings'
import { RowFixed, RowBetween } from '../Row'
import { TYPE } from '../../theme'
import QuestionHelper from '../QuestionHelper'
@@ -138,24 +138,7 @@ export default function SettingsTab() {
// show confirmation view before turning on
const [showConfirmation, setShowConfirmation] = useState(false)
useEffect(() => {
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])
useOnClickOutside(node, open ? toggle : undefined)
return (
<StyledMenu ref={node}>
@@ -188,7 +171,7 @@ export default function SettingsTab() {
}
}}
>
<Text fontSize={20} fontWeight={500}>
<Text fontSize={20} fontWeight={500} id="confirm-expert-mode">
Turn On Expert Mode
</Text>
</ButtonError>
@@ -196,7 +179,7 @@ export default function SettingsTab() {
</AutoColumn>
</ModalContentWrapper>
</Modal>
<StyledMenuButton onClick={toggle}>
<StyledMenuButton onClick={toggle} id="open-settings-dialog-button">
<StyledMenuIcon />
{expertMode && (
<EmojiWrapper>
@@ -212,7 +195,7 @@ export default function SettingsTab() {
<Text fontWeight={600} fontSize={14}>
Transaction Settings
</Text>
<SlippageTabs
<TransactionSettings
rawSlippage={userSlippageTolerance}
setRawSlippage={setUserslippageTolerance}
deadline={deadline}
@@ -229,6 +212,7 @@ export default function SettingsTab() {
<QuestionHelper text="Bypasses confirmation modals and allows high slippage trades. Use at your own risk." />
</RowFixed>
<Toggle
id="toggle-expert-mode-button"
isActive={expertMode}
toggle={
expertMode

View File

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

View File

@@ -10,26 +10,26 @@ const ToggleElement = styled.span<{ isActive?: boolean; isOnSwitch?: boolean }>`
font-weight: 400;
`
const StyledToggle = styled.a<{ isActive?: boolean; activeElement?: boolean }>`
const StyledToggle = styled.button<{ isActive?: boolean; activeElement?: boolean }>`
border-radius: 16px;
border: 1px solid ${({ theme, isActive }) => (isActive ? theme.primary5 : theme.text4)};
display: flex;
width: fit-content;
cursor: pointer;
text-decoration: none;
:hover {
text-decoration: none;
}
outline: none;
padding: 0;
background-color: transparent;
`
export interface ToggleProps {
id?: string
isActive: boolean
toggle: () => void
}
export default function Toggle({ isActive, toggle }: ToggleProps) {
export default function Toggle({ id, isActive, toggle }: ToggleProps) {
return (
<StyledToggle isActive={isActive} target="_self" onClick={toggle}>
<StyledToggle id={id} isActive={isActive} onClick={toggle}>
<ToggleElement isActive={isActive} isOnSwitch={true}>
On
</ToggleElement>

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,195 @@
import { ChainId } from '@uniswap/sdk'
import React, { useContext } from 'react'
import styled, { ThemeContext } from 'styled-components'
import Modal from '../Modal'
import { ExternalLink } from '../../theme'
import { Text } from 'rebass'
import { CloseIcon, Spinner } from '../../theme/components'
import { RowBetween } from '../Row'
import { AlertTriangle, ArrowUpCircle } from 'react-feather'
import { ButtonPrimary } from '../Button'
import { AutoColumn, ColumnCenter } from '../Column'
import Circle from '../../assets/images/blue-loader.svg'
import { getEtherscanLink } from '../../utils'
import { useActiveWeb3React } from '../../hooks'
const Wrapper = styled.div`
width: 100%;
`
const Section = styled(AutoColumn)`
padding: 24px;
`
const BottomSection = styled(Section)`
background-color: ${({ theme }) => theme.bg2};
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
`
const ConfirmedIcon = styled(ColumnCenter)`
padding: 60px 0;
`
const CustomLightSpinner = styled(Spinner)<{ size: string }>`
height: ${({ size }) => size};
width: ${({ size }) => size};
`
function ConfirmationPendingContent({ onDismiss, pendingText }: { onDismiss: () => void; pendingText: string }) {
return (
<Wrapper>
<Section>
<RowBetween>
<div />
<CloseIcon onClick={onDismiss} />
</RowBetween>
<ConfirmedIcon>
<CustomLightSpinner src={Circle} alt="loader" size={'90px'} />
</ConfirmedIcon>
<AutoColumn gap="12px" justify={'center'}>
<Text fontWeight={500} fontSize={20}>
Waiting For Confirmation
</Text>
<AutoColumn gap="12px" justify={'center'}>
<Text fontWeight={600} fontSize={14} color="" textAlign="center">
{pendingText}
</Text>
</AutoColumn>
<Text fontSize={12} color="#565A69" textAlign="center">
Confirm this transaction in your wallet
</Text>
</AutoColumn>
</Section>
</Wrapper>
)
}
function TransactionSubmittedContent({
onDismiss,
chainId,
hash
}: {
onDismiss: () => void
hash: string | undefined
chainId: ChainId
}) {
const theme = useContext(ThemeContext)
return (
<Wrapper>
<Section>
<RowBetween>
<div />
<CloseIcon onClick={onDismiss} />
</RowBetween>
<ConfirmedIcon>
<ArrowUpCircle strokeWidth={0.5} size={90} color={theme.primary1} />
</ConfirmedIcon>
<AutoColumn gap="12px" justify={'center'}>
<Text fontWeight={500} fontSize={20}>
Transaction Submitted
</Text>
<ExternalLink href={getEtherscanLink(chainId, hash, 'transaction')}>
<Text fontWeight={500} fontSize={14} color={theme.primary1}>
View on Etherscan
</Text>
</ExternalLink>
<ButtonPrimary onClick={onDismiss} style={{ margin: '20px 0 0 0' }}>
<Text fontWeight={500} fontSize={20}>
Close
</Text>
</ButtonPrimary>
</AutoColumn>
</Section>
</Wrapper>
)
}
export function ConfirmationModalContent({
title,
bottomContent,
onDismiss,
topContent
}: {
title: string
onDismiss: () => void
topContent: () => React.ReactNode
bottomContent: () => React.ReactNode
}) {
return (
<Wrapper>
<Section>
<RowBetween>
<Text fontWeight={500} fontSize={20}>
{title}
</Text>
<CloseIcon onClick={onDismiss} />
</RowBetween>
{topContent()}
</Section>
<BottomSection gap="12px">{bottomContent()}</BottomSection>
</Wrapper>
)
}
export function TransactionErrorContent({ message, onDismiss }: { message: string; onDismiss: () => void }) {
const theme = useContext(ThemeContext)
return (
<Wrapper>
<Section>
<RowBetween>
<Text fontWeight={500} fontSize={20}>
Error
</Text>
<CloseIcon onClick={onDismiss} />
</RowBetween>
<AutoColumn style={{ marginTop: 20, padding: '2rem 0' }} gap="24px" justify="center">
<AlertTriangle color={theme.red1} style={{ strokeWidth: 1.5 }} size={64} />
<Text fontWeight={500} fontSize={16} color={theme.red1} style={{ textAlign: 'center', width: '85%' }}>
{message}
</Text>
</AutoColumn>
</Section>
<BottomSection gap="12px">
<ButtonPrimary onClick={onDismiss}>Dismiss</ButtonPrimary>
</BottomSection>
</Wrapper>
)
}
interface ConfirmationModalProps {
isOpen: boolean
onDismiss: () => void
hash: string | undefined
content: () => React.ReactNode
attemptingTxn: boolean
pendingText: string
}
export default function TransactionConfirmationModal({
isOpen,
onDismiss,
attemptingTxn,
hash,
pendingText,
content
}: ConfirmationModalProps) {
const { chainId } = useActiveWeb3React()
if (!chainId) return null
// confirmation screen
return (
<Modal isOpen={isOpen} onDismiss={onDismiss} maxHeight={90}>
{attemptingTxn ? (
<ConfirmationPendingContent onDismiss={onDismiss} pendingText={pendingText} />
) : hash ? (
<TransactionSubmittedContent chainId={chainId} hash={hash} onDismiss={onDismiss} />
) : (
content()
)}
</Modal>
)
}

View File

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

View File

@@ -349,9 +349,7 @@ export default function WalletModal({
{walletView !== WALLET_VIEWS.PENDING && (
<Blurb>
<span>New to Ethereum? &nbsp;</span>{' '}
<ExternalLink href="https://ethereum.org/use/#3-what-is-a-wallet-and-which-one-should-i-use">
Learn more about wallets
</ExternalLink>
<ExternalLink href="https://ethereum.org/wallets/">Learn more about wallets</ExternalLink>
</Blurb>
)}
</ContentWrapper>

View File

@@ -73,23 +73,27 @@ export function AdvancedSwapDetails({ trade }: AdvancedSwapDetailsProps) {
const [allowedSlippage] = useUserSlippageTolerance()
const showRoute = trade?.route?.path?.length > 2
const showRoute = Boolean(trade && trade.route.path.length > 2)
return (
<AutoColumn gap="md">
{trade && <TradeSummary trade={trade} allowedSlippage={allowedSlippage} />}
{showRoute && (
{trade && (
<>
<SectionBreak />
<AutoColumn style={{ padding: '0 24px' }}>
<RowFixed>
<TYPE.black fontSize={14} fontWeight={400} color={theme.text2}>
Route
</TYPE.black>
<QuestionHelper text="Routing through these tokens resulted in the best price for your trade." />
</RowFixed>
<SwapRoute trade={trade} />
</AutoColumn>
<TradeSummary trade={trade} allowedSlippage={allowedSlippage} />
{showRoute && (
<>
<SectionBreak />
<AutoColumn style={{ padding: '0 24px' }}>
<RowFixed>
<TYPE.black fontSize={14} fontWeight={400} color={theme.text2}>
Route
</TYPE.black>
<QuestionHelper text="Routing through these tokens resulted in the best price for your trade." />
</RowFixed>
<SwapRoute trade={trade} />
</AutoColumn>
</>
)}
</>
)}
</AutoColumn>

View File

@@ -1,6 +1,6 @@
import React from 'react'
import styled from 'styled-components'
import useLast from '../../hooks/useLast'
import { useLastTruthy } from '../../hooks/useLast'
import { AdvancedSwapDetails, AdvancedSwapDetailsProps } from './AdvancedSwapDetails'
const AdvancedDetailsFooter = styled.div<{ show: boolean }>`
@@ -20,11 +20,11 @@ const AdvancedDetailsFooter = styled.div<{ show: boolean }>`
`
export default function AdvancedSwapDetailsDropdown({ trade, ...rest }: AdvancedSwapDetailsProps) {
const lastTrade = useLast(trade)
const lastTrade = useLastTruthy(trade)
return (
<AdvancedDetailsFooter show={Boolean(trade)}>
<AdvancedSwapDetails {...rest} trade={trade ?? lastTrade} />
<AdvancedSwapDetails {...rest} trade={trade ?? lastTrade ?? undefined} />
</AdvancedDetailsFooter>
)
}

View File

@@ -0,0 +1,109 @@
import { currencyEquals, Trade } from '@uniswap/sdk'
import React, { useCallback, useMemo } from 'react'
import TransactionConfirmationModal, {
ConfirmationModalContent,
TransactionErrorContent
} from '../TransactionConfirmationModal'
import SwapModalFooter from './SwapModalFooter'
import SwapModalHeader from './SwapModalHeader'
/**
* Returns true if the trade requires a confirmation of details before we can submit it
* @param tradeA trade A
* @param tradeB trade B
*/
function tradeMeaningfullyDiffers(tradeA: Trade, tradeB: Trade): boolean {
return (
tradeA.tradeType !== tradeB.tradeType ||
!currencyEquals(tradeA.inputAmount.currency, tradeB.inputAmount.currency) ||
!tradeA.inputAmount.equalTo(tradeB.inputAmount) ||
!currencyEquals(tradeA.outputAmount.currency, tradeB.outputAmount.currency) ||
!tradeA.outputAmount.equalTo(tradeB.outputAmount)
)
}
export default function ConfirmSwapModal({
trade,
originalTrade,
onAcceptChanges,
allowedSlippage,
onConfirm,
onDismiss,
recipient,
swapErrorMessage,
isOpen,
attemptingTxn,
txHash
}: {
isOpen: boolean
trade: Trade | undefined
originalTrade: Trade | undefined
attemptingTxn: boolean
txHash: string | undefined
recipient: string | null
allowedSlippage: number
onAcceptChanges: () => void
onConfirm: () => void
swapErrorMessage: string | undefined
onDismiss: () => void
}) {
const showAcceptChanges = useMemo(
() => Boolean(trade && originalTrade && tradeMeaningfullyDiffers(trade, originalTrade)),
[originalTrade, trade]
)
const modalHeader = useCallback(() => {
return trade ? (
<SwapModalHeader
trade={trade}
allowedSlippage={allowedSlippage}
recipient={recipient}
showAcceptChanges={showAcceptChanges}
onAcceptChanges={onAcceptChanges}
/>
) : null
}, [allowedSlippage, onAcceptChanges, recipient, showAcceptChanges, trade])
const modalBottom = useCallback(() => {
return trade ? (
<SwapModalFooter
onConfirm={onConfirm}
trade={trade}
disabledConfirm={showAcceptChanges}
swapErrorMessage={swapErrorMessage}
allowedSlippage={allowedSlippage}
/>
) : null
}, [allowedSlippage, onConfirm, showAcceptChanges, swapErrorMessage, trade])
// text to show while loading
const pendingText = `Swapping ${trade?.inputAmount?.toSignificant(6)} ${
trade?.inputAmount?.currency?.symbol
} for ${trade?.outputAmount?.toSignificant(6)} ${trade?.outputAmount?.currency?.symbol}`
const confirmationContent = useCallback(
() =>
swapErrorMessage ? (
<TransactionErrorContent onDismiss={onDismiss} message={swapErrorMessage} />
) : (
<ConfirmationModalContent
title="Confirm Swap"
onDismiss={onDismiss}
topContent={modalHeader}
bottomContent={modalBottom}
/>
),
[onDismiss, modalBottom, modalHeader, swapErrorMessage]
)
return (
<TransactionConfirmationModal
isOpen={isOpen}
onDismiss={onDismiss}
attemptingTxn={attemptingTxn}
hash={txHash}
content={confirmationContent}
pendingText={pendingText}
/>
)
}

View File

@@ -4,10 +4,13 @@ import { ONE_BIPS } from '../../constants'
import { warningSeverity } from '../../utils/prices'
import { ErrorText } from './styleds'
/**
* Formatted version of price impact text with warning colors
*/
export default function FormattedPriceImpact({ priceImpact }: { priceImpact?: Percent }) {
return (
<ErrorText fontWeight={500} fontSize={14} severity={warningSeverity(priceImpact)}>
{priceImpact?.lessThan(ONE_BIPS) ? '<0.01%' : `${priceImpact?.toFixed(2)}%` ?? '-'}
{priceImpact ? (priceImpact.lessThan(ONE_BIPS) ? '<0.01%' : `${priceImpact.toFixed(2)}%`) : '-'}
</ErrorText>
)
}

View File

@@ -1,46 +1,44 @@
import { CurrencyAmount, Percent, Trade, TradeType } from '@uniswap/sdk'
import React, { useContext } from 'react'
import { Trade, TradeType } from '@uniswap/sdk'
import React, { useContext, useMemo, useState } from 'react'
import { Repeat } from 'react-feather'
import { Text } from 'rebass'
import { ThemeContext } from 'styled-components'
import { Field } from '../../state/swap/actions'
import { TYPE } from '../../theme'
import { formatExecutionPrice } from '../../utils/prices'
import {
computeSlippageAdjustedAmounts,
computeTradePriceBreakdown,
formatExecutionPrice,
warningSeverity
} from '../../utils/prices'
import { ButtonError } from '../Button'
import { AutoColumn } from '../Column'
import QuestionHelper from '../QuestionHelper'
import { AutoRow, RowBetween, RowFixed } from '../Row'
import FormattedPriceImpact from './FormattedPriceImpact'
import { StyledBalanceMaxMini } from './styleds'
import { StyledBalanceMaxMini, SwapCallbackError } from './styleds'
export default function SwapModalFooter({
trade,
showInverted,
setShowInverted,
severity,
slippageAdjustedAmounts,
onSwap,
parsedAmounts,
realizedLPFee,
priceImpactWithoutFee,
confirmText
onConfirm,
allowedSlippage,
swapErrorMessage,
disabledConfirm
}: {
trade?: Trade
showInverted: boolean
setShowInverted: (inverted: boolean) => void
severity: number
slippageAdjustedAmounts?: { [field in Field]?: CurrencyAmount }
onSwap: () => any
parsedAmounts?: { [field in Field]?: CurrencyAmount }
realizedLPFee?: CurrencyAmount
priceImpactWithoutFee?: Percent
confirmText: string
trade: Trade
allowedSlippage: number
onConfirm: () => void
swapErrorMessage: string | undefined
disabledConfirm: boolean
}) {
const [showInverted, setShowInverted] = useState<boolean>(false)
const theme = useContext(ThemeContext)
if (!trade) {
return null
}
const slippageAdjustedAmounts = useMemo(() => computeSlippageAdjustedAmounts(trade, allowedSlippage), [
allowedSlippage,
trade
])
const { priceImpactWithoutFee, realizedLPFee } = useMemo(() => computeTradePriceBreakdown(trade), [trade])
const severity = warningSeverity(priceImpactWithoutFee)
return (
<>
@@ -71,23 +69,21 @@ export default function SwapModalFooter({
<RowBetween>
<RowFixed>
<TYPE.black fontSize={14} fontWeight={400} color={theme.text2}>
{trade?.tradeType === TradeType.EXACT_INPUT ? 'Minimum received' : 'Maximum sold'}
{trade.tradeType === TradeType.EXACT_INPUT ? 'Minimum received' : 'Maximum sold'}
</TYPE.black>
<QuestionHelper text="Your transaction will revert if there is a large, unfavorable price movement before it is confirmed." />
</RowFixed>
<RowFixed>
<TYPE.black fontSize={14}>
{trade?.tradeType === TradeType.EXACT_INPUT
{trade.tradeType === TradeType.EXACT_INPUT
? slippageAdjustedAmounts[Field.OUTPUT]?.toSignificant(4) ?? '-'
: slippageAdjustedAmounts[Field.INPUT]?.toSignificant(4) ?? '-'}
</TYPE.black>
{parsedAmounts[Field.OUTPUT] && parsedAmounts[Field.INPUT] && (
<TYPE.black fontSize={14} marginLeft={'4px'}>
{trade?.tradeType === TradeType.EXACT_INPUT
? parsedAmounts[Field.OUTPUT]?.currency?.symbol
: parsedAmounts[Field.INPUT]?.currency?.symbol}
</TYPE.black>
)}
<TYPE.black fontSize={14} marginLeft={'4px'}>
{trade.tradeType === TradeType.EXACT_INPUT
? trade.outputAmount.currency.symbol
: trade.inputAmount.currency.symbol}
</TYPE.black>
</RowFixed>
</RowBetween>
<RowBetween>
@@ -107,17 +103,25 @@ export default function SwapModalFooter({
<QuestionHelper text="A portion of each trade (0.30%) goes to liquidity providers as a protocol incentive." />
</RowFixed>
<TYPE.black fontSize={14}>
{realizedLPFee ? realizedLPFee?.toSignificant(6) + ' ' + trade?.inputAmount?.currency?.symbol : '-'}
{realizedLPFee ? realizedLPFee?.toSignificant(6) + ' ' + trade.inputAmount.currency.symbol : '-'}
</TYPE.black>
</RowBetween>
</AutoColumn>
<AutoRow>
<ButtonError onClick={onSwap} error={severity > 2} style={{ margin: '10px 0 0 0' }} id="confirm-swap-or-send">
<ButtonError
onClick={onConfirm}
disabled={disabledConfirm}
error={severity > 2}
style={{ margin: '10px 0 0 0' }}
id="confirm-swap-or-send"
>
<Text fontSize={20} fontWeight={500}>
{confirmText}
{severity > 2 ? 'Swap Anyway' : 'Confirm Swap'}
</Text>
</ButtonError>
{swapErrorMessage ? <SwapCallbackError error={swapErrorMessage} /> : null}
</AutoRow>
</>
)

View File

@@ -1,66 +1,107 @@
import { Currency, CurrencyAmount } from '@uniswap/sdk'
import React, { useContext } from 'react'
import { ArrowDown } from 'react-feather'
import { Trade, TradeType } from '@uniswap/sdk'
import React, { useContext, useMemo } from 'react'
import { ArrowDown, AlertTriangle } from 'react-feather'
import { Text } from 'rebass'
import { ThemeContext } from 'styled-components'
import { Field } from '../../state/swap/actions'
import { TYPE } from '../../theme'
import { ButtonPrimary } from '../Button'
import { isAddress, shortenAddress } from '../../utils'
import { computeSlippageAdjustedAmounts, computeTradePriceBreakdown, warningSeverity } from '../../utils/prices'
import { AutoColumn } from '../Column'
import { RowBetween, RowFixed } from '../Row'
import CurrencyLogo from '../CurrencyLogo'
import { TruncatedText } from './styleds'
import { RowBetween, RowFixed } from '../Row'
import { TruncatedText, SwapShowAcceptChanges } from './styleds'
export default function SwapModalHeader({
currencies,
formattedAmounts,
slippageAdjustedAmounts,
priceImpactSeverity,
independentField,
recipient
trade,
allowedSlippage,
recipient,
showAcceptChanges,
onAcceptChanges
}: {
currencies: { [field in Field]?: Currency }
formattedAmounts: { [field in Field]?: string }
slippageAdjustedAmounts: { [field in Field]?: CurrencyAmount }
priceImpactSeverity: number
independentField: Field
trade: Trade
allowedSlippage: number
recipient: string | null
showAcceptChanges: boolean
onAcceptChanges: () => void
}) {
const slippageAdjustedAmounts = useMemo(() => computeSlippageAdjustedAmounts(trade, allowedSlippage), [
trade,
allowedSlippage
])
const { priceImpactWithoutFee } = useMemo(() => computeTradePriceBreakdown(trade), [trade])
const priceImpactSeverity = warningSeverity(priceImpactWithoutFee)
const theme = useContext(ThemeContext)
return (
<AutoColumn gap={'md'} style={{ marginTop: '20px' }}>
<RowBetween align="flex-end">
<TruncatedText fontSize={24} fontWeight={500}>
{formattedAmounts[Field.INPUT]}
</TruncatedText>
<RowFixed gap="4px">
<CurrencyLogo currency={currencies[Field.INPUT]} size={'24px'} />
<RowFixed gap={'0px'}>
<CurrencyLogo currency={trade.inputAmount.currency} size={'24px'} style={{ marginRight: '12px' }} />
<TruncatedText
fontSize={24}
fontWeight={500}
color={showAcceptChanges && trade.tradeType === TradeType.EXACT_OUTPUT ? theme.primary1 : ''}
>
{trade.inputAmount.toSignificant(6)}
</TruncatedText>
</RowFixed>
<RowFixed gap={'0px'}>
<Text fontSize={24} fontWeight={500} style={{ marginLeft: '10px' }}>
{currencies[Field.INPUT]?.symbol}
{trade.inputAmount.currency.symbol}
</Text>
</RowFixed>
</RowBetween>
<RowFixed>
<ArrowDown size="16" color={theme.text2} />
<ArrowDown size="16" color={theme.text2} style={{ marginLeft: '4px', minWidth: '16px' }} />
</RowFixed>
<RowBetween align="flex-end">
<TruncatedText fontSize={24} fontWeight={500} color={priceImpactSeverity > 2 ? theme.red1 : ''}>
{formattedAmounts[Field.OUTPUT]}
</TruncatedText>
<RowFixed gap="4px">
<CurrencyLogo currency={currencies[Field.OUTPUT]} size={'24px'} />
<RowFixed gap={'0px'}>
<CurrencyLogo currency={trade.outputAmount.currency} size={'24px'} style={{ marginRight: '12px' }} />
<TruncatedText
fontSize={24}
fontWeight={500}
color={
priceImpactSeverity > 2
? theme.red1
: showAcceptChanges && trade.tradeType === TradeType.EXACT_INPUT
? theme.primary1
: ''
}
>
{trade.outputAmount.toSignificant(6)}
</TruncatedText>
</RowFixed>
<RowFixed gap={'0px'}>
<Text fontSize={24} fontWeight={500} style={{ marginLeft: '10px' }}>
{currencies[Field.OUTPUT]?.symbol}
{trade.outputAmount.currency.symbol}
</Text>
</RowFixed>
</RowBetween>
{showAcceptChanges ? (
<SwapShowAcceptChanges justify="flex-start" gap={'0px'}>
<RowBetween>
<RowFixed>
<AlertTriangle size={20} style={{ marginRight: '8px', minWidth: 24 }} />
<TYPE.main color={theme.primary1}> Price Updated</TYPE.main>
</RowFixed>
<ButtonPrimary
style={{ padding: '.5rem', width: 'fit-content', fontSize: '0.825rem', borderRadius: '12px' }}
onClick={onAcceptChanges}
>
Accept
</ButtonPrimary>
</RowBetween>
</SwapShowAcceptChanges>
) : null}
<AutoColumn justify="flex-start" gap="sm" style={{ padding: '12px 0 0 0px' }}>
{independentField === Field.INPUT ? (
{trade.tradeType === TradeType.EXACT_INPUT ? (
<TYPE.italic textAlign="left" style={{ width: '100%' }}>
{`Output is estimated. You will receive at least `}
<b>
{slippageAdjustedAmounts[Field.OUTPUT]?.toSignificant(6)} {currencies[Field.OUTPUT]?.symbol}
{slippageAdjustedAmounts[Field.OUTPUT]?.toSignificant(6)} {trade.outputAmount.currency.symbol}
</b>
{' or the transaction will revert.'}
</TYPE.italic>
@@ -68,7 +109,7 @@ export default function SwapModalHeader({
<TYPE.italic textAlign="left" style={{ width: '100%' }}>
{`Input is estimated. You will sell at most `}
<b>
{slippageAdjustedAmounts[Field.INPUT]?.toSignificant(6)} {currencies[Field.INPUT]?.symbol}
{slippageAdjustedAmounts[Field.INPUT]?.toSignificant(6)} {trade.inputAmount.currency.symbol}
</b>
{' or the transaction will revert.'}
</TYPE.italic>

View File

@@ -1,7 +1,11 @@
// gathers additional user consent for a high price impact
import { Percent } from '@uniswap/sdk'
import { ALLOWED_PRICE_IMPACT_HIGH, PRICE_IMPACT_WITHOUT_FEE_CONFIRM_MIN } from '../../constants'
/**
* Given the price impact, get user confirmation.
*
* @param priceImpactWithoutFee price impact of the trade without the fee.
*/
export default function confirmPriceImpactWithoutFee(priceImpactWithoutFee: Percent): boolean {
if (!priceImpactWithoutFee.lessThan(PRICE_IMPACT_WITHOUT_FEE_CONFIRM_MIN)) {
return (

View File

@@ -1,8 +1,9 @@
import { transparentize } from 'polished'
import React from 'react'
import { AlertTriangle } from 'react-feather'
import styled, { css } from 'styled-components'
import { AutoColumn } from '../Column'
import { Text } from 'rebass'
import NumericalInput from '../NumericalInput'
import { AutoColumn } from '../Column'
export const Wrapper = styled.div`
position: relative;
@@ -29,8 +30,7 @@ export const SectionBreak = styled.div`
`
export const BottomGrouping = styled.div`
margin-top: 12px;
position: relative;
margin-top: 1rem;
`
export const ErrorText = styled(Text)<{ severity?: 0 | 1 | 2 | 3 | 4 }>`
@@ -44,21 +44,6 @@ export const ErrorText = styled(Text)<{ severity?: 0 | 1 | 2 | 3 | 4 }>`
: theme.green1};
`
export const InputGroup = styled(AutoColumn)`
position: relative;
padding: 40px 0 20px 0;
`
export const StyledNumerical = styled(NumericalInput)`
text-align: center;
font-size: 48px;
font-weight: 500px;
width: 100%;
::placeholder {
color: ${({ theme }) => theme.text4};
}
`
export const StyledBalanceMaxMini = styled.button`
height: 22px;
width: 22px;
@@ -112,3 +97,51 @@ export const Dots = styled.span`
}
}
`
const SwapCallbackErrorInner = styled.div`
background-color: ${({ theme }) => transparentize(0.9, theme.red1)};
border-radius: 1rem;
display: flex;
align-items: center;
font-size: 0.825rem;
width: 100%;
padding: 3rem 1.25rem 1rem 1rem;
margin-top: -2rem;
color: ${({ theme }) => theme.red1};
z-index: -1;
p {
padding: 0;
margin: 0;
font-weight: 500;
}
`
const SwapCallbackErrorInnerAlertTriangle = styled.div`
background-color: ${({ theme }) => transparentize(0.9, theme.red1)};
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
border-radius: 12px;
min-width: 48px;
height: 48px;
`
export function SwapCallbackError({ error }: { error: string }) {
return (
<SwapCallbackErrorInner>
<SwapCallbackErrorInnerAlertTriangle>
<AlertTriangle size={24} />
</SwapCallbackErrorInnerAlertTriangle>
<p>{error}</p>
</SwapCallbackErrorInner>
)
}
export const SwapShowAcceptChanges = styled(AutoColumn)`
background-color: ${({ theme }) => transparentize(0.9, theme.primary1)};
color: ${({ theme }) => theme.primary1};
padding: 0.5rem;
border-radius: 12px;
margin-top: 8px;
`

View File

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

View File

@@ -22,19 +22,83 @@ class RequestError extends Error {
}
}
interface BatchItem {
request: { jsonrpc: '2.0'; id: number; method: string; params: unknown }
resolve: (result: any) => void
reject: (error: Error) => void
}
class MiniRpcProvider implements AsyncSendable {
public readonly isMetaMask: false = false
public readonly chainId: number
public readonly url: string
public readonly host: string
public readonly path: string
public readonly batchWaitTimeMs: number
constructor(chainId: number, url: string) {
private nextId = 1
private batchTimeoutId: ReturnType<typeof setTimeout> | null = null
private batch: BatchItem[] = []
constructor(chainId: number, url: string, batchWaitTimeMs?: number) {
this.chainId = chainId
this.url = url
const parsed = new URL(url)
this.host = parsed.host
this.path = parsed.pathname
// how long to wait to batch calls
this.batchWaitTimeMs = batchWaitTimeMs ?? 50
}
public readonly clearBatch = async () => {
console.debug('Clearing batch', this.batch)
const batch = this.batch
this.batch = []
this.batchTimeoutId = null
let response: Response
try {
response = await fetch(this.url, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify(batch.map(item => item.request))
})
} catch (error) {
batch.forEach(({ reject }) => reject(new Error('Failed to send batch call')))
return
}
if (!response.ok) {
batch.forEach(({ reject }) => reject(new RequestError(`${response.status}: ${response.statusText}`, -32000)))
return
}
let json
try {
json = await response.json()
} catch (error) {
batch.forEach(({ reject }) => reject(new Error('Failed to parse JSON response')))
return
}
const byKey = batch.reduce<{ [id: number]: BatchItem }>((memo, current) => {
memo[current.request.id] = current
return memo
}, {})
for (const result of json) {
const {
resolve,
reject,
request: { method }
} = byKey[result.id]
if (resolve && reject) {
if ('error' in result) {
reject(new RequestError(result?.error?.message, result?.error?.code, result?.error?.data))
} else if ('result' in result) {
resolve(result.result)
} else {
reject(new RequestError(`Received unexpected JSON-RPC response to ${method} request.`, -32000, result))
}
}
}
}
public readonly sendAsync = (
@@ -56,24 +120,20 @@ class MiniRpcProvider implements AsyncSendable {
if (method === 'eth_chainId') {
return `0x${this.chainId.toString(16)}`
}
const response = await fetch(this.url, {
method: 'POST',
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method,
params
const promise = new Promise((resolve, reject) => {
this.batch.push({
request: {
jsonrpc: '2.0',
id: this.nextId++,
method,
params
},
resolve,
reject
})
})
if (!response.ok) throw new RequestError(`${response.status}: ${response.statusText}`, -32000)
const body = await response.json()
if ('error' in body) {
throw new RequestError(body?.error?.message, body?.error?.code, body?.error?.data)
} else if ('result' in body) {
return body.result
} else {
throw new RequestError(`Received unexpected JSON-RPC response to ${method} request.`, -32000, body)
}
this.batchTimeoutId = this.batchTimeoutId ?? setTimeout(this.clearBatch, this.batchWaitTimeMs)
return promise
}
}
@@ -92,6 +152,10 @@ export class NetworkConnector extends AbstractConnector {
}, {})
}
public get provider(): MiniRpcProvider {
return this.providers[this.currentChainId]
}
public async activate(): Promise<ConnectorUpdate> {
return { provider: this.providers[this.currentChainId], chainId: this.currentChainId, account: null }
}

View File

@@ -1,3 +1,4 @@
import { Web3Provider } from '@ethersproject/providers'
import { InjectedConnector } from '@web3-react/injected-connector'
import { WalletConnectConnector } from '@web3-react/walletconnect-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 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') {
throw new Error(`REACT_APP_NETWORK_URL must be a defined environment variable`)
}
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({
supportedChainIds: [1, 3, 4, 5, 42]
})

View File

@@ -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"
}
]

View File

@@ -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"
}
]

View File

@@ -1,4 +1,5 @@
import { ChainId, JSBI, Percent, Token, WETH } from '@uniswap/sdk'
import { AbstractConnector } from '@web3-react/abstract-connector'
import { fortmatic, injected, portis, walletconnect, walletlink } from '../connectors'
@@ -14,6 +15,7 @@ export const USDC = new Token(ChainId.MAINNET, '0xA0b86991c6218b36c1d19D4a2e9Eb0
export const USDT = new Token(ChainId.MAINNET, '0xdAC17F958D2ee523a2206206994597C13D831ec7', 6, 'USDT', 'Tether USD')
export const COMP = new Token(ChainId.MAINNET, '0xc00e94Cb662C3520282E6f5717214004A7f26888', 18, 'COMP', 'Compound')
export const MKR = new Token(ChainId.MAINNET, '0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2', 18, 'MKR', 'Maker')
export const AMPL = new Token(ChainId.MAINNET, '0xD46bA6D942050d489DBd938a2C909A5d5039A161', 9, 'AMPL', 'Ampleforth')
const WETH_ONLY: ChainTokenList = {
[ChainId.MAINNET]: [WETH[ChainId.MAINNET]],
@@ -29,6 +31,16 @@ export const BASES_TO_CHECK_TRADES_AGAINST: ChainTokenList = {
[ChainId.MAINNET]: [...WETH_ONLY[ChainId.MAINNET], DAI, USDC, USDT, COMP, MKR]
}
/**
* Some tokens can only be swapped via certain pairs, so we override the list of bases that are considered for these
* tokens.
*/
export const CUSTOM_BASES: { [chainId in ChainId]?: { [tokenAddress: string]: Token[] } } = {
[ChainId.MAINNET]: {
[AMPL.address]: [DAI, WETH[ChainId.MAINNET]]
}
}
// used for display in the default list when adding liquidity
export const SUGGESTED_BASES: ChainTokenList = {
...WETH_ONLY,
@@ -52,7 +64,19 @@ export const PINNED_PAIRS: { readonly [chainId in ChainId]?: [Token, Token][] }
]
}
const TESTNET_CAPABLE_WALLETS = {
export interface WalletInfo {
connector?: AbstractConnector
name: string
iconName: string
description: string
href: string | null
color: string
primary?: true
mobile?: true
mobileOnly?: true
}
export const SUPPORTED_WALLETS: { [key: string]: WalletInfo } = {
INJECTED: {
connector: injected,
name: 'Injected',
@@ -69,62 +93,53 @@ const TESTNET_CAPABLE_WALLETS = {
description: 'Easy-to-use browser extension.',
href: null,
color: '#E8831D'
},
WALLET_CONNECT: {
connector: walletconnect,
name: 'WalletConnect',
iconName: 'walletConnectIcon.svg',
description: 'Connect to Trust Wallet, Rainbow Wallet and more...',
href: null,
color: '#4196FC',
mobile: true
},
WALLET_LINK: {
connector: walletlink,
name: 'Coinbase Wallet',
iconName: 'coinbaseWalletIcon.svg',
description: 'Use Coinbase Wallet app on mobile device',
href: null,
color: '#315CF5'
},
COINBASE_LINK: {
name: 'Open in Coinbase Wallet',
iconName: 'coinbaseWalletIcon.svg',
description: 'Open in Coinbase Wallet app.',
href: 'https://go.cb-w.com/mtUDhEZPy1',
color: '#315CF5',
mobile: true,
mobileOnly: true
},
FORTMATIC: {
connector: fortmatic,
name: 'Fortmatic',
iconName: 'fortmaticIcon.png',
description: 'Login using Fortmatic hosted wallet',
href: null,
color: '#6748FF',
mobile: true
},
Portis: {
connector: portis,
name: 'Portis',
iconName: 'portisIcon.png',
description: 'Login using Portis hosted wallet',
href: null,
color: '#4A6C9B',
mobile: true
}
}
export const SUPPORTED_WALLETS =
process.env.REACT_APP_CHAIN_ID !== '1'
? TESTNET_CAPABLE_WALLETS
: {
...TESTNET_CAPABLE_WALLETS,
...{
WALLET_CONNECT: {
connector: walletconnect,
name: 'WalletConnect',
iconName: 'walletConnectIcon.svg',
description: 'Connect to Trust Wallet, Rainbow Wallet and more...',
href: null,
color: '#4196FC',
mobile: true
},
WALLET_LINK: {
connector: walletlink,
name: 'Coinbase Wallet',
iconName: 'coinbaseWalletIcon.svg',
description: 'Use Coinbase Wallet app on mobile device',
href: null,
color: '#315CF5'
},
COINBASE_LINK: {
name: 'Open in Coinbase Wallet',
iconName: 'coinbaseWalletIcon.svg',
description: 'Open in Coinbase Wallet app.',
href: 'https://go.cb-w.com/mtUDhEZPy1',
color: '#315CF5',
mobile: true,
mobileOnly: true
},
FORTMATIC: {
connector: fortmatic,
name: 'Fortmatic',
iconName: 'fortmaticIcon.png',
description: 'Login using Fortmatic hosted wallet',
href: null,
color: '#6748FF',
mobile: true
},
Portis: {
connector: portis,
name: 'Portis',
iconName: 'portisIcon.png',
description: 'Login using Portis hosted wallet',
href: null,
color: '#4A6C9B',
mobile: true
}
}
}
export const NetworkContextName = 'NETWORK'
// default allowed slippage, in bips
@@ -147,7 +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
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))
// the Uniswap Default token list lives here
export const DEFAULT_TOKEN_LIST_URL =
'https://unpkg.com/@uniswap/default-token-list@latest/uniswap-default.tokenlist.json'

20
src/constants/lists.ts Normal file
View File

@@ -0,0 +1,20 @@
// 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',
'stablecoin.cmc.eth',
'tokenlist.zerion.eth',
'https://www.coingecko.com/tokens_list/uniswap/defi_100/v_0_0_0.json',
'https://app.tryroll.com/tokens.json',
'https://raw.githubusercontent.com/compound-finance/token-list/master/compound.tokenlist.json',
'ipfs://QmVNCFc3y1DMt8n4K42d8BYubUhQ7FgcNxzEHxSEHszUhL', // aave token list
'https://defiprime.com/defiprime.tokenlist.json',
'https://umaproject.org/uma.tokenlist.json'
]

View File

@@ -131,7 +131,7 @@ export function useV1Trade(
? new Trade(route, exactAmount, isExactIn ? TradeType.EXACT_INPUT : TradeType.EXACT_OUTPUT)
: undefined
} catch (error) {
console.error('Failed to create V1 trade', error)
console.debug('Failed to create V1 trade', error)
}
return v1Trade
}

View File

@@ -1,7 +1,7 @@
import { parseBytes32String } from '@ethersproject/strings'
import { Currency, ETHER, Token } from '@uniswap/sdk'
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 { useUserAddedTokens } from '../state/user/hooks'
import { isAddress } from '../utils'
@@ -12,7 +12,7 @@ import { useBytes32TokenContract, useTokenContract } from './useContract'
export function useAllTokens(): { [address: string]: Token } {
const { chainId } = useActiveWeb3React()
const userAddedTokens = useUserAddedTokens()
const allTokens = useDefaultTokenList()
const allTokens = useSelectedTokenList()
return useMemo(() => {
if (!chainId) return {}

View File

@@ -2,9 +2,8 @@ import { Currency, CurrencyAmount, Pair, Token, Trade } from '@uniswap/sdk'
import flatMap from 'lodash.flatmap'
import { useMemo } from 'react'
import { BASES_TO_CHECK_TRADES_AGAINST } from '../constants'
import { BASES_TO_CHECK_TRADES_AGAINST, CUSTOM_BASES } from '../constants'
import { PairState, usePairs } from '../data/Reserves'
import { maxHopsFor } from '../utils/maxHopsFor'
import { wrappedCurrency } from '../utils/wrappedCurrency'
import { useActiveWeb3React } from './index'
@@ -18,18 +17,46 @@ function useAllCommonPairs(currencyA?: Currency, currencyB?: Currency): Pair[] {
? [wrappedCurrency(currencyA, chainId), wrappedCurrency(currencyB, chainId)]
: [undefined, undefined]
const allPairCombinations: [Token | undefined, Token | undefined][] = useMemo(
() => [
// the direct pair
[tokenA, tokenB],
// token A against all bases
...bases.map((base): [Token | undefined, Token | undefined] => [tokenA, base]),
// token B against all bases
...bases.map((base): [Token | undefined, Token | undefined] => [tokenB, base]),
// each base against all bases
...flatMap(bases, (base): [Token, Token][] => bases.map(otherBase => [base, otherBase]))
],
[tokenA, tokenB, bases]
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(
() =>
tokenA && tokenB
? [
// the direct pair
[tokenA, tokenB],
// token A against all bases
...bases.map((base): [Token, Token] => [tokenA, base]),
// token B against all bases
...bases.map((base): [Token, Token] => [tokenB, base]),
// each base against all bases
...basePairs
]
.filter((tokens): tokens is [Token, Token] => Boolean(tokens[0] && tokens[1]))
.filter(([t0, t1]) => t0.address !== t1.address)
.filter(([tokenA, tokenB]) => {
if (!chainId) return true
const customBases = CUSTOM_BASES[chainId]
if (!customBases) return true
const customBasesA: Token[] | undefined = customBases[tokenA.address]
const customBasesB: Token[] | undefined = customBases[tokenB.address]
if (!customBasesA && !customBasesB) return true
if (customBasesA && !customBasesA.find(base => tokenB.equals(base))) return false
if (customBasesB && !customBasesB.find(base => tokenA.equals(base))) return false
return true
})
: [],
[tokenA, tokenB, bases, basePairs, chainId]
)
const allPairs = usePairs(allPairCombinations)
@@ -56,12 +83,10 @@ function useAllCommonPairs(currencyA?: Currency, currencyB?: Currency): Pair[] {
*/
export function useTradeExactIn(currencyAmountIn?: CurrencyAmount, currencyOut?: Currency): Trade | null {
const allowedPairs = useAllCommonPairs(currencyAmountIn?.currency, currencyOut)
return useMemo(() => {
if (currencyAmountIn && currencyOut && allowedPairs.length > 0) {
const maxHops = maxHopsFor(currencyAmountIn.currency, currencyOut)
return (
Trade.bestTradeExactIn(allowedPairs, currencyAmountIn, currencyOut, { maxHops, maxNumResults: 1 })[0] ?? null
Trade.bestTradeExactIn(allowedPairs, currencyAmountIn, currencyOut, { maxHops: 3, maxNumResults: 1 })[0] ?? null
)
}
return null
@@ -76,9 +101,9 @@ export function useTradeExactOut(currencyIn?: Currency, currencyAmountOut?: Curr
return useMemo(() => {
if (currencyIn && currencyAmountOut && allowedPairs.length > 0) {
const maxHops = maxHopsFor(currencyIn, currencyAmountOut.currency)
return (
Trade.bestTradeExactOut(allowedPairs, currencyIn, currencyAmountOut, { maxHops, maxNumResults: 1 })[0] ?? null
Trade.bestTradeExactOut(allowedPairs, currencyIn, currencyAmountOut, { maxHops: 3, maxNumResults: 1 })[0] ??
null
)
}
return null

View File

@@ -2,18 +2,20 @@ import { Contract } from '@ethersproject/contracts'
import { ChainId, WETH } from '@uniswap/sdk'
import { abi as IUniswapV2PairABI } from '@uniswap/v2-core/build/IUniswapV2Pair.json'
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 UNISOCKS_ABI from '../constants/abis/unisocks.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 UNISOCKS_ABI from '../constants/abis/unisocks.json'
import WETH_ABI from '../constants/abis/weth.json'
import { MULTICALL_ABI, MULTICALL_NETWORKS } from '../constants/multicall'
import { V1_EXCHANGE_ABI, V1_FACTORY_ABI, V1_FACTORY_ADDRESSES } from '../constants/v1'
import { getContract } from '../utils'
import { useActiveWeb3React } from './index'
// 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()
return useMemo(() => {
@@ -49,6 +51,26 @@ export function useWETHContract(withSignerIfPossible?: boolean): Contract | null
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 {
return useContract(tokenAddress, ERC20_BYTES32_ABI, withSignerIfPossible)
}

View File

@@ -1,46 +1,35 @@
import { useEffect, useState } from 'react'
import { useActiveWeb3React } from './index'
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'
import useDebounce from './useDebounce'
/**
* Does a lookup for an ENS name to find its address.
*/
export default function useENSAddress(ensName?: string | null): { loading: boolean; address: string | null } {
const { library } = useActiveWeb3React()
const [address, setAddress] = useState<{ loading: boolean; address: string | null }>({
loading: false,
address: null
})
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
}
const debouncedName = useDebounce(ensName, 200)
const ensNodeArgument = useMemo(() => {
if (!debouncedName) return [undefined]
try {
return debouncedName ? [namehash(debouncedName)] : [undefined]
} catch (error) {
return [undefined]
}
}, [library, ensName])
}, [debouncedName])
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 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
}
}

View File

@@ -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
}
}

View File

@@ -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 { 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.
* 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 } {
const { library } = useActiveWeb3React()
const [ENSName, setENSName] = useState<{ ENSName: string | null; loading: boolean }>({
loading: false,
ENSName: null
})
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
}
const debouncedAddress = useDebounce(address, 200)
const ensNodeArgument = useMemo(() => {
if (!debouncedAddress || !isAddress(debouncedAddress)) return [undefined]
try {
return debouncedAddress ? [namehash(`${debouncedAddress.toLowerCase().substr(2)}.addr.reverse`)] : [undefined]
} catch (error) {
return [undefined]
}
}, [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
}
}

View File

@@ -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]
)
}

View File

@@ -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])
}

View File

@@ -1,13 +1,33 @@
import { useEffect, useState } from 'react'
/**
* Returns the last value of type T that passes a filter function
* @param value changing value
* @param filterFn function that determines whether a given value should be considered for the last value
*/
export default function useLast<T>(
value: T | undefined | null,
filterFn?: (value: T | null | undefined) => boolean
): T | null | undefined {
const [last, setLast] = useState<T | null | undefined>(filterFn && filterFn(value) ? value : undefined)
useEffect(() => {
setLast(last => {
const shouldUse: boolean = filterFn ? filterFn(value) : true
if (shouldUse) return value
return last
})
}, [filterFn, value])
return last
}
function isDefined<T>(x: T | null | undefined): x is T {
return x !== null && x !== undefined
}
/**
* Returns the last truthy value of type T
* @param value changing value
*/
export default function useLast<T>(value: T | undefined | null): T | null | undefined {
const [last, setLast] = useState<T | null | undefined>(value)
useEffect(() => {
setLast(last => value ?? last)
}, [value])
return last
export function useLastTruthy<T>(value: T | undefined | null): T | null | undefined {
return useLast(value, isDefined)
}

View File

@@ -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])
}

View File

@@ -1,19 +1,107 @@
import { BigNumber } from '@ethersproject/bignumber'
import { Contract } from '@ethersproject/contracts'
import { JSBI, Percent, Router, Trade, TradeType } from '@uniswap/sdk'
import { JSBI, Percent, Router, SwapParameters, Trade, TradeType } from '@uniswap/sdk'
import { useMemo } from 'react'
import { BIPS_BASE, DEFAULT_DEADLINE_FROM_NOW, INITIAL_ALLOWED_SLIPPAGE } from '../constants'
import { getTradeVersion, useV1TradeExchangeAddress } from '../data/V1'
import { useTransactionAdder } from '../state/transactions/hooks'
import { calculateGasMargin, getRouterContract, isAddress, shortenAddress } from '../utils'
import isZero from '../utils/isZero'
import v1SwapArguments from '../utils/v1SwapArguments'
import { useActiveWeb3React } from './index'
import { useV1ExchangeContract } from './useContract'
import useENS from './useENS'
import { Version } from './useToggledVersion'
function isZero(hexNumber: string) {
return /^0x0*$/.test(hexNumber)
export enum SwapCallbackState {
INVALID,
LOADING,
VALID
}
interface SwapCall {
contract: Contract
parameters: SwapParameters
}
interface SuccessfulCall {
call: SwapCall
gasEstimate: BigNumber
}
interface FailedCall {
call: SwapCall
error: Error
}
type EstimatedSwapCall = SuccessfulCall | FailedCall
/**
* Returns the swap calls that can be used to make the trade
* @param trade trade to execute
* @param allowedSlippage user allowed slippage
* @param deadline the deadline for the trade
* @param recipientAddressOrName
*/
function useSwapCallArguments(
trade: Trade | undefined, // trade to execute, required
allowedSlippage: number = INITIAL_ALLOWED_SLIPPAGE, // in bips
deadline: number = DEFAULT_DEADLINE_FROM_NOW, // in seconds from now
recipientAddressOrName: string | null // the ENS name or address of the recipient of the trade, or null if swap should be returned to sender
): SwapCall[] {
const { account, chainId, library } = useActiveWeb3React()
const { address: recipientAddress } = useENS(recipientAddressOrName)
const recipient = recipientAddressOrName === null ? account : recipientAddress
const v1Exchange = useV1ExchangeContract(useV1TradeExchangeAddress(trade), true)
return useMemo(() => {
const tradeVersion = getTradeVersion(trade)
if (!trade || !recipient || !library || !account || !tradeVersion || !chainId) return []
const contract: Contract | null =
tradeVersion === Version.v2 ? getRouterContract(chainId, library, account) : v1Exchange
if (!contract) {
return []
}
const swapMethods = []
switch (tradeVersion) {
case Version.v2:
swapMethods.push(
Router.swapCallParameters(trade, {
feeOnTransfer: false,
allowedSlippage: new Percent(JSBI.BigInt(allowedSlippage), BIPS_BASE),
recipient,
ttl: deadline
})
)
if (trade.tradeType === TradeType.EXACT_INPUT) {
swapMethods.push(
Router.swapCallParameters(trade, {
feeOnTransfer: true,
allowedSlippage: new Percent(JSBI.BigInt(allowedSlippage), BIPS_BASE),
recipient,
ttl: deadline
})
)
}
break
case Version.v1:
swapMethods.push(
v1SwapArguments(trade, {
allowedSlippage: new Percent(JSBI.BigInt(allowedSlippage), BIPS_BASE),
recipient,
ttl: deadline
})
)
break
}
return swapMethods.map(parameters => ({ parameters, contract }))
}, [account, allowedSlippage, chainId, deadline, library, recipient, trade, v1Exchange])
}
// returns a function that will execute a swap, if the parameters are all valid
@@ -23,115 +111,97 @@ export function useSwapCallback(
allowedSlippage: number = INITIAL_ALLOWED_SLIPPAGE, // in bips
deadline: number = DEFAULT_DEADLINE_FROM_NOW, // in seconds from now
recipientAddressOrName: string | null // the ENS name or address of the recipient of the trade, or null if swap should be returned to sender
): null | (() => Promise<string>) {
): { state: SwapCallbackState; callback: null | (() => Promise<string>); error: string | null } {
const { account, chainId, library } = useActiveWeb3React()
const swapCalls = useSwapCallArguments(trade, allowedSlippage, deadline, recipientAddressOrName)
const addTransaction = useTransactionAdder()
const { address: recipientAddress } = useENS(recipientAddressOrName)
const recipient = recipientAddressOrName === null ? account : recipientAddress
const tradeVersion = getTradeVersion(trade)
const v1Exchange = useV1ExchangeContract(useV1TradeExchangeAddress(trade), true)
return useMemo(() => {
if (!trade || !recipient || !library || !account || !tradeVersion || !chainId) return null
return async function onSwap() {
const contract: Contract | null =
tradeVersion === Version.v2 ? getRouterContract(chainId, library, account) : v1Exchange
if (!contract) {
throw new Error('Failed to get a swap contract')
}
const swapMethods = []
switch (tradeVersion) {
case Version.v2:
swapMethods.push(
Router.swapCallParameters(trade, {
feeOnTransfer: false,
allowedSlippage: new Percent(JSBI.BigInt(allowedSlippage), BIPS_BASE),
recipient,
ttl: deadline
})
)
if (trade.tradeType === TradeType.EXACT_INPUT) {
swapMethods.push(
Router.swapCallParameters(trade, {
feeOnTransfer: true,
allowedSlippage: new Percent(JSBI.BigInt(allowedSlippage), BIPS_BASE),
recipient,
ttl: deadline
})
)
}
break
case Version.v1:
swapMethods.push(
v1SwapArguments(trade, {
allowedSlippage: new Percent(JSBI.BigInt(allowedSlippage), BIPS_BASE),
recipient,
ttl: deadline
})
)
break
}
const safeGasEstimates: (BigNumber | undefined)[] = await Promise.all(
swapMethods.map(({ args, methodName, value }) =>
contract.estimateGas[methodName](...args, value && !isZero(value) ? { value } : {})
.then(calculateGasMargin)
.catch(error => {
console.error(`estimateGas failed for ${methodName}`, error)
return undefined
})
)
)
// we expect failures from left to right, so throw if we see failures
// from right to left
for (let i = 0; i < safeGasEstimates.length - 1; i++) {
// if the FoT method fails, but the regular method does not, we should not
// use the regular method. this probably means something is wrong with the fot token.
if (BigNumber.isBigNumber(safeGasEstimates[i]) && !BigNumber.isBigNumber(safeGasEstimates[i + 1])) {
throw new Error(
'An error occurred. Please try raising your slippage. If that does not work, contact support.'
)
}
}
const indexOfSuccessfulEstimation = safeGasEstimates.findIndex(safeGasEstimate =>
BigNumber.isBigNumber(safeGasEstimate)
)
// all estimations failed...
if (indexOfSuccessfulEstimation === -1) {
// if only 1 method exists, either:
// a) the token is doing something weird not related to FoT (e.g. enforcing a whitelist)
// b) the token is FoT and the user specified an exact output, which is not allowed
if (swapMethods.length === 1) {
throw Error(
`An error occurred. If either of the tokens you're swapping take a fee on transfer, you must specify an exact input amount.`
)
}
// if 2 methods exists, either:
// a) the token is doing something weird not related to FoT (e.g. enforcing a whitelist)
// b) the token is FoT and is taking more than the specified slippage
else if (swapMethods.length === 2) {
throw Error(
`An error occurred. If either of the tokens you're swapping take a fee on transfer, you must specify a slippage tolerance higher than the fee.`
)
} else {
throw Error('This transaction would fail. Please contact support.')
}
if (!trade || !library || !account || !chainId) {
return { state: SwapCallbackState.INVALID, callback: null, error: 'Missing dependencies' }
}
if (!recipient) {
if (recipientAddressOrName !== null) {
return { state: SwapCallbackState.INVALID, callback: null, error: 'Invalid recipient' }
} else {
const { methodName, args, value } = swapMethods[indexOfSuccessfulEstimation]
const safeGasEstimate = safeGasEstimates[indexOfSuccessfulEstimation]
return { state: SwapCallbackState.LOADING, callback: null, error: null }
}
}
const tradeVersion = getTradeVersion(trade)
return {
state: SwapCallbackState.VALID,
callback: async function onSwap(): Promise<string> {
const estimatedCalls: EstimatedSwapCall[] = await Promise.all(
swapCalls.map(call => {
const {
parameters: { methodName, args, value },
contract
} = call
const options = !value || isZero(value) ? {} : { value }
return contract.estimateGas[methodName](...args, options)
.then(gasEstimate => {
return {
call,
gasEstimate
}
})
.catch(gasError => {
console.debug('Gas estimate failed, trying eth_call to extract error', call)
return contract.callStatic[methodName](...args, options)
.then(result => {
console.debug('Unexpected successful call after failed estimate gas', call, gasError, result)
return { call, error: new Error('Unexpected issue with estimating the gas. Please try again.') }
})
.catch(callError => {
console.debug('Call threw error', call, callError)
let errorMessage: string
switch (callError.reason) {
case 'UniswapV2Router: INSUFFICIENT_OUTPUT_AMOUNT':
case 'UniswapV2Router: EXCESSIVE_INPUT_AMOUNT':
errorMessage =
'This transaction will not succeed either due to price movement or fee on transfer. Try increasing your slippage tolerance.'
break
default:
errorMessage = `The transaction cannot succeed due to error: ${callError.reason}. This is probably an issue with one of the tokens you are swapping.`
}
return { call, error: new Error(errorMessage) }
})
})
})
)
// a successful estimation is a bignumber gas estimate and the next call is also a bignumber gas estimate
const successfulEstimation = estimatedCalls.find(
(el, ix, list): el is SuccessfulCall =>
'gasEstimate' in el && (ix === list.length - 1 || 'gasEstimate' in list[ix + 1])
)
if (!successfulEstimation) {
const errorCalls = estimatedCalls.filter((call): call is FailedCall => 'error' in call)
if (errorCalls.length > 0) throw errorCalls[errorCalls.length - 1].error
throw new Error('Unexpected error. Please contact support: none of the calls threw an error')
}
const {
call: {
contract,
parameters: { methodName, args, value }
},
gasEstimate
} = successfulEstimation
return contract[methodName](...args, {
gasLimit: safeGasEstimate,
...(value && !isZero(value) ? { value } : {})
gasLimit: calculateGasMargin(gasEstimate),
...(value && !isZero(value) ? { value, from: account } : { from: account })
})
.then((response: any) => {
const inputSymbol = trade.inputAmount.currency.symbol
@@ -161,27 +231,15 @@ export function useSwapCallback(
.catch((error: any) => {
// if the user rejected the tx, pass this along
if (error?.code === 4001) {
throw error
}
// otherwise, the error was unexpected and we need to convey that
else {
throw new Error('Transaction rejected.')
} else {
// otherwise, the error was unexpected and we need to convey that
console.error(`Swap failed`, error, methodName, args, value)
throw Error('An error occurred while swapping. Please contact support.')
throw new Error(`Swap failed: ${error.message}`)
}
})
}
},
error: null
}
}, [
trade,
recipient,
library,
account,
tradeVersion,
chainId,
allowedSlippage,
v1Exchange,
deadline,
recipientAddressOrName,
addTransaction
])
}, [trade, library, account, chainId, recipient, recipientAddressOrName, swapCalls, addTransaction])
}

View File

@@ -23,7 +23,7 @@ export default function useWrapCallback(
inputCurrency: Currency | undefined,
outputCurrency: Currency | undefined,
typedValue: string | undefined
): { wrapType: WrapType; execute?: undefined | (() => Promise<void>); error?: string } {
): { wrapType: WrapType; execute?: undefined | (() => Promise<void>); inputError?: string } {
const { chainId, account } = useActiveWeb3React()
const wethContract = useWETHContract()
const balance = useCurrencyBalance(account ?? undefined, inputCurrency)
@@ -50,7 +50,7 @@ export default function useWrapCallback(
}
}
: undefined,
error: sufficientBalance ? undefined : 'Insufficient ETH balance'
inputError: sufficientBalance ? undefined : 'Insufficient ETH balance'
}
} else if (currencyEquals(WETH[chainId], inputCurrency) && outputCurrency === ETHER) {
return {
@@ -66,7 +66,7 @@ export default function useWrapCallback(
}
}
: undefined,
error: sufficientBalance ? undefined : 'Insufficient WETH balance'
inputError: sufficientBalance ? undefined : 'Insufficient WETH balance'
}
} else {
return NOT_APPLICABLE

View File

@@ -1,21 +1,21 @@
import { Web3Provider } from '@ethersproject/providers'
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 ReactDOM from 'react-dom'
import ReactGA from 'react-ga'
import { Provider } from 'react-redux'
import { NetworkContextName } from './constants'
import 'inter-ui'
import './i18n'
import App from './pages/App'
import store from './state'
import ApplicationUpdater from './state/application/updater'
import TransactionUpdater from './state/transactions/updater'
import ListsUpdater from './state/lists/updater'
import UserUpdater from './state/user/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 getLibrary from './utils/getLibrary'
const Web3ProviderNetwork = createWeb3ReactRoot(NetworkContextName)
@@ -23,12 +23,6 @@ if ('ethereum' in window) {
;(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
if (typeof GOOGLE_ANALYTICS_ID === 'string') {
ReactGA.initialize(GOOGLE_ANALYTICS_ID)
@@ -59,21 +53,19 @@ function Updaters() {
}
ReactDOM.render(
<>
<StrictMode>
<FixedGlobalStyle />
<Web3ReactProvider getLibrary={getLibrary}>
<Web3ProviderNetwork getLibrary={getLibrary}>
<Provider store={store}>
<Updaters />
<ThemeProvider>
<>
<ThemedGlobalStyle />
<App />
</>
<ThemedGlobalStyle />
<App />
</ThemeProvider>
</Provider>
</Web3ProviderNetwork>
</Web3ReactProvider>
</>,
</StrictMode>,
document.getElementById('root')
)

View File

@@ -1,4 +1,4 @@
import { Currency, Fraction, Percent } from '@uniswap/sdk'
import { Currency, Percent, Price } from '@uniswap/sdk'
import React, { useContext } from 'react'
import { Text } from 'rebass'
import { ThemeContext } from 'styled-components'
@@ -8,7 +8,7 @@ import { ONE_BIPS } from '../../constants'
import { Field } from '../../state/mint/actions'
import { TYPE } from '../../theme'
export const PoolPriceBar = ({
export function PoolPriceBar({
currencies,
noLiquidity,
poolTokenPercentage,
@@ -17,20 +17,20 @@ export const PoolPriceBar = ({
currencies: { [field in Field]?: Currency }
noLiquidity?: boolean
poolTokenPercentage?: Percent
price?: Fraction
}) => {
price?: Price
}) {
const theme = useContext(ThemeContext)
return (
<AutoColumn gap="md">
<AutoRow justify="space-around" gap="4px">
<AutoColumn justify="center">
<TYPE.black>{price?.toSignificant(6) ?? '0'}</TYPE.black>
<TYPE.black>{price?.toSignificant(6) ?? '-'}</TYPE.black>
<Text fontWeight={500} fontSize={14} color={theme.text2} pt={1}>
{currencies[Field.CURRENCY_B]?.symbol} per {currencies[Field.CURRENCY_A]?.symbol}
</Text>
</AutoColumn>
<AutoColumn justify="center">
<TYPE.black>{price?.invert().toSignificant(6) ?? '0'}</TYPE.black>
<TYPE.black>{price?.invert()?.toSignificant(6) ?? '-'}</TYPE.black>
<Text fontWeight={500} fontSize={14} color={theme.text2} pt={1}>
{currencies[Field.CURRENCY_A]?.symbol} per {currencies[Field.CURRENCY_B]?.symbol}
</Text>

View File

@@ -10,7 +10,7 @@ import { ThemeContext } from 'styled-components'
import { ButtonError, ButtonLight, ButtonPrimary } from '../../components/Button'
import { BlueCard, GreyCard, LightCard } from '../../components/Card'
import { AutoColumn, ColumnCenter } from '../../components/Column'
import ConfirmationModal from '../../components/ConfirmationModal'
import TransactionConfirmationModal, { ConfirmationModalContent } from '../../components/TransactionConfirmationModal'
import CurrencyInputPanel from '../../components/CurrencyInputPanel'
import DoubleCurrencyLogo from '../../components/DoubleLogo'
import { AddRemoveTabs } from '../../components/NavigationTabs'
@@ -294,27 +294,34 @@ export default function AddLiquidity({
[currencyIdA, history, currencyIdB]
)
const handleDismissConfirmation = useCallback(() => {
setShowConfirm(false)
// if there was a tx hash, we want to clear the input
if (txHash) {
onFieldAInput('')
}
setTxHash('')
}, [onFieldAInput, txHash])
return (
<>
<AppBody>
<AddRemoveTabs adding={true} />
<Wrapper>
<ConfirmationModal
<TransactionConfirmationModal
isOpen={showConfirm}
onDismiss={() => {
setShowConfirm(false)
// if there was a tx hash, we want to clear the input
if (txHash) {
onFieldAInput('')
}
setTxHash('')
}}
onDismiss={handleDismissConfirmation}
attemptingTxn={attemptingTxn}
hash={txHash}
topContent={modalHeader}
bottomContent={modalBottom}
content={() => (
<ConfirmationModalContent
title={noLiquidity ? 'You are creating a pool' : 'You will receive'}
onDismiss={handleDismissConfirmation}
topContent={modalHeader}
bottomContent={modalBottom}
/>
)}
pendingText={pendingText}
title={noLiquidity ? 'You are creating a pool' : 'You will receive'}
/>
<AutoColumn gap="20px">
{noLiquidity && (

View File

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

View File

@@ -7,7 +7,7 @@ import { SearchInput } from '../../components/SearchModal/styleds'
import { useAllTokenV1Exchanges } from '../../data/V1'
import { useActiveWeb3React } from '../../hooks'
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 { BackArrow, TYPE } from '../../theme'
import { LightCard } from '../../components/Card'
@@ -17,7 +17,7 @@ import V1PositionCard from '../../components/PositionCard/V1'
import QuestionHelper from '../../components/QuestionHelper'
import { Dots } from '../../components/swap/styleds'
import { useAddUserToken } from '../../state/user/hooks'
import { isDefaultToken } from '../../utils'
import { isTokenOnList } from '../../utils'
export default function MigrateV1() {
const theme = useContext(ThemeContext)
@@ -28,15 +28,15 @@ export default function MigrateV1() {
// automatically add the search token
const token = useToken(tokenSearch)
const defaultTokens = useDefaultTokenList()
const isDefault = isDefaultToken(defaultTokens, token)
const selectedTokenListTokens = useSelectedTokenList()
const isOnSelectedList = isTokenOnList(selectedTokenListTokens, token)
const allTokens = useAllTokens()
const addToken = useAddUserToken()
useEffect(() => {
if (token && !isDefault && !allTokens[token.address]) {
if (token && !isOnSelectedList && !allTokens[token.address]) {
addToken(token)
}
}, [token, isDefault, addToken, allTokens])
}, [token, isOnSelectedList, addToken, allTokens])
// get V1 LP balances
const V1Exchanges = useAllTokenV1Exchanges()

View File

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

View File

@@ -11,7 +11,7 @@ import { ThemeContext } from 'styled-components'
import { ButtonPrimary, ButtonLight, ButtonError, ButtonConfirmed } from '../../components/Button'
import { LightCard } from '../../components/Card'
import { AutoColumn, ColumnCenter } from '../../components/Column'
import ConfirmationModal from '../../components/ConfirmationModal'
import TransactionConfirmationModal, { ConfirmationModalContent } from '../../components/TransactionConfirmationModal'
import CurrencyInputPanel from '../../components/CurrencyInputPanel'
import DoubleCurrencyLogo from '../../components/DoubleLogo'
import { AddRemoveTabs } from '../../components/NavigationTabs'
@@ -29,6 +29,7 @@ import { useTransactionAdder } from '../../state/transactions/hooks'
import { StyledInternalLink, TYPE } from '../../theme'
import { calculateGasMargin, calculateSlippageAmount, getRouterContract } from '../../utils'
import { currencyId } from '../../utils/currencyId'
import useDebouncedChangeHandler from '../../utils/useDebouncedChangeHandler'
import { wrappedCurrency } from '../../utils/wrappedCurrency'
import AppBody from '../AppBody'
import { ClickableText, MaxButton, Wrapper } from '../Pool/styleds'
@@ -274,12 +275,13 @@ export default function RemoveLiquidity({
throw new Error('Attempting to confirm without approval or a signature. Please contact support.')
}
const safeGasEstimates = await Promise.all(
const safeGasEstimates: (BigNumber | undefined)[] = await Promise.all(
methodNames.map(methodName =>
router.estimateGas[methodName](...args)
.then(calculateGasMargin)
.catch(error => {
console.error(`estimateGas failed for ${methodName}`, error)
console.error(`estimateGas failed`, methodName, args, error)
return undefined
})
)
)
@@ -447,28 +449,40 @@ export default function RemoveLiquidity({
[currencyIdA, currencyIdB, history]
)
const handleDismissConfirmation = useCallback(() => {
setShowConfirm(false)
setSignatureData(null) // important that we clear signature data to avoid bad sigs
// if there was a tx hash, we want to clear the input
if (txHash) {
onUserInput(Field.LIQUIDITY_PERCENT, '0')
}
setTxHash('')
}, [onUserInput, txHash])
const [innerLiquidityPercentage, setInnerLiquidityPercentage] = useDebouncedChangeHandler(
Number.parseInt(parsedAmounts[Field.LIQUIDITY_PERCENT].toFixed(0)),
liquidityPercentChangeCallback
)
return (
<>
<AppBody>
<AddRemoveTabs adding={false} />
<Wrapper>
<ConfirmationModal
<TransactionConfirmationModal
isOpen={showConfirm}
onDismiss={() => {
setShowConfirm(false)
setSignatureData(null) // important that we clear signature data to avoid bad sigs
// if there was a tx hash, we want to clear the input
if (txHash) {
onUserInput(Field.LIQUIDITY_PERCENT, '0')
}
setTxHash('')
}}
onDismiss={handleDismissConfirmation}
attemptingTxn={attemptingTxn}
hash={txHash ? txHash : ''}
topContent={modalHeader}
bottomContent={modalBottom}
content={() => (
<ConfirmationModalContent
title={'You will receive'}
onDismiss={handleDismissConfirmation}
topContent={modalHeader}
bottomContent={modalBottom}
/>
)}
pendingText={pendingText}
title="You will receive"
/>
<AutoColumn gap="md">
<LightCard>
@@ -491,10 +505,7 @@ export default function RemoveLiquidity({
</Row>
{!showDetailed && (
<>
<Slider
value={Number.parseInt(parsedAmounts[Field.LIQUIDITY_PERCENT].toFixed(0))}
onChange={liquidityPercentChangeCallback}
/>
<Slider value={innerLiquidityPercentage} onChange={setInnerLiquidityPercentage} />
<RowBetween>
<MaxButton onClick={() => onUserInput(Field.LIQUIDITY_PERCENT, '25')} width="20%">
25%

View File

@@ -1,5 +1,5 @@
import { CurrencyAmount, JSBI } from '@uniswap/sdk'
import React, { useCallback, useContext, useEffect, useState } from 'react'
import { CurrencyAmount, JSBI, Token, Trade } from '@uniswap/sdk'
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { ArrowDown } from 'react-feather'
import ReactGA from 'react-ga'
import { Text } from 'rebass'
@@ -8,22 +8,21 @@ import AddressInputPanel from '../../components/AddressInputPanel'
import { ButtonError, ButtonLight, ButtonPrimary } from '../../components/Button'
import Card, { GreyCard } from '../../components/Card'
import { AutoColumn } from '../../components/Column'
import ConfirmationModal from '../../components/ConfirmationModal'
import ConfirmSwapModal from '../../components/swap/ConfirmSwapModal'
import CurrencyInputPanel from '../../components/CurrencyInputPanel'
import { SwapPoolTabs } from '../../components/NavigationTabs'
import { AutoRow, RowBetween } from '../../components/Row'
import AdvancedSwapDetailsDropdown from '../../components/swap/AdvancedSwapDetailsDropdown'
import BetterTradeLink from '../../components/swap/BetterTradeLink'
import confirmPriceImpactWithoutFee from '../../components/swap/confirmPriceImpactWithoutFee'
import { ArrowWrapper, BottomGrouping, Dots, Wrapper } from '../../components/swap/styleds'
import SwapModalFooter from '../../components/swap/SwapModalFooter'
import SwapModalHeader from '../../components/swap/SwapModalHeader'
import { ArrowWrapper, BottomGrouping, Dots, SwapCallbackError, Wrapper } from '../../components/swap/styleds'
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 { getTradeVersion, isTradeBetter } from '../../data/V1'
import { useActiveWeb3React } from '../../hooks'
import { useCurrency } from '../../hooks/Tokens'
import { ApprovalState, useApproveCallbackFromTrade } from '../../hooks/useApproveCallback'
import useENSAddress from '../../hooks/useENSAddress'
import { useSwapCallback } from '../../hooks/useSwapCallback'
@@ -37,22 +36,31 @@ import {
useSwapActionHandlers,
useSwapState
} from '../../state/swap/hooks'
import {
useExpertModeManager,
useUserDeadline,
useUserSlippageTolerance,
useTokenWarningDismissal
} from '../../state/user/hooks'
import { CursorPointer, LinkStyledButton, TYPE } from '../../theme'
import { useExpertModeManager, useUserDeadline, useUserSlippageTolerance } from '../../state/user/hooks'
import { LinkStyledButton, TYPE } from '../../theme'
import { maxAmountSpend } from '../../utils/maxAmountSpend'
import { computeSlippageAdjustedAmounts, computeTradePriceBreakdown, warningSeverity } from '../../utils/prices'
import { computeTradePriceBreakdown, warningSeverity } from '../../utils/prices'
import AppBody from '../AppBody'
import { ClickableText } from '../Pool/styleds'
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)
// toggle wallet when disconnected
@@ -60,7 +68,7 @@ export default function Swap() {
// for expert mode
const toggleSettings = useToggleSettingsMenu()
const [expertMode] = useExpertModeManager()
const [isExpertMode] = useExpertModeManager()
// get custom setting values for user
const [deadline] = useUserDeadline()
@@ -68,8 +76,15 @@ export default function Swap() {
// swap state
const { independentField, typedValue, recipient } = useSwapState()
const { v1Trade, v2Trade, currencyBalances, parsedAmount, currencies, error } = useDerivedSwapInfo()
const { wrapType, execute: onWrap, error: wrapError } = useWrapCallback(
const {
v1Trade,
v2Trade,
currencyBalances,
parsedAmount,
currencies,
inputError: swapInputError
} = useDerivedSwapInfo()
const { wrapType, execute: onWrap, inputError: wrapInputError } = useWrapCallback(
currencies[Field.INPUT],
currencies[Field.OUTPUT],
typedValue
@@ -102,7 +117,7 @@ export default function Swap() {
}
const { onSwitchTokens, onCurrencySelection, onUserInput, onChangeRecipient } = useSwapActionHandlers()
const isValid = !error
const isValid = !swapInputError
const dependentField: Field = independentField === Field.INPUT ? Field.OUTPUT : Field.INPUT
const handleTypeInput = useCallback(
@@ -119,9 +134,19 @@ export default function Swap() {
)
// modal and loading
const [showConfirm, setShowConfirm] = useState<boolean>(false) // show confirmation modal
const [attemptingTxn, setAttemptingTxn] = useState<boolean>(false) // waiting for user confirmaion/rejection
const [txHash, setTxHash] = useState<string>('')
const [{ showConfirm, tradeToConfirm, swapErrorMessage, attemptingTxn, txHash }, setSwapState] = useState<{
showConfirm: boolean
tradeToConfirm: Trade | undefined
attemptingTxn: boolean
swapErrorMessage: string | undefined
txHash: string | undefined
}>({
showConfirm: false,
tradeToConfirm: undefined,
attemptingTxn: false,
swapErrorMessage: undefined,
txHash: undefined
})
const formattedAmounts = {
[independentField]: typedValue,
@@ -152,25 +177,27 @@ export default function Swap() {
const maxAmountInput: CurrencyAmount | undefined = maxAmountSpend(currencyBalances[Field.INPUT])
const atMaxAmountInput = Boolean(maxAmountInput && parsedAmounts[Field.INPUT]?.equalTo(maxAmountInput))
const slippageAdjustedAmounts = computeSlippageAdjustedAmounts(trade, allowedSlippage)
// the callback to execute the swap
const swapCallback = useSwapCallback(trade, allowedSlippage, deadline, recipient)
const { callback: swapCallback, error: swapCallbackError } = useSwapCallback(
trade,
allowedSlippage,
deadline,
recipient
)
const { priceImpactWithoutFee, realizedLPFee } = computeTradePriceBreakdown(trade)
const { priceImpactWithoutFee } = computeTradePriceBreakdown(trade)
function onSwap() {
const handleSwap = useCallback(() => {
if (priceImpactWithoutFee && !confirmPriceImpactWithoutFee(priceImpactWithoutFee)) {
return
}
if (!swapCallback) {
return
}
setAttemptingTxn(true)
setSwapState({ attemptingTxn: true, tradeToConfirm, showConfirm, swapErrorMessage: undefined, txHash: undefined })
swapCallback()
.then(hash => {
setAttemptingTxn(false)
setTxHash(hash)
setSwapState({ attemptingTxn: false, tradeToConfirm, showConfirm, swapErrorMessage: undefined, txHash: hash })
ReactGA.event({
category: 'Swap',
@@ -188,13 +215,15 @@ export default function Swap() {
})
})
.catch(error => {
setAttemptingTxn(false)
// we only care if the error is something _other_ than the user rejected the tx
if (error?.code !== 4001) {
console.error(error)
}
setSwapState({
attemptingTxn: false,
tradeToConfirm,
showConfirm,
swapErrorMessage: error.message,
txHash: undefined
})
})
}
}, [tradeToConfirm, account, priceImpactWithoutFee, recipient, recipientAddress, showConfirm, swapCallback, trade])
// errors
const [showInverted, setShowInverted] = useState<boolean>(false)
@@ -205,74 +234,62 @@ export default function Swap() {
// show approve flow when: no error on inputs, not approved or pending, or approved in current session
// never show if price impact is above threshold in non expert mode
const showApproveFlow =
!error &&
!swapInputError &&
(approval === ApprovalState.NOT_APPROVED ||
approval === ApprovalState.PENDING ||
(approvalSubmitted && approval === ApprovalState.APPROVED)) &&
!(priceImpactSeverity > 3 && !expertMode)
!(priceImpactSeverity > 3 && !isExpertMode)
function modalHeader() {
return (
<SwapModalHeader
currencies={currencies}
formattedAmounts={formattedAmounts}
slippageAdjustedAmounts={slippageAdjustedAmounts}
priceImpactSeverity={priceImpactSeverity}
independentField={independentField}
recipient={recipient}
/>
)
}
const handleConfirmDismiss = useCallback(() => {
setSwapState({ showConfirm: false, tradeToConfirm, attemptingTxn, swapErrorMessage, txHash })
// if there was a tx hash, we want to clear the input
if (txHash) {
onUserInput(Field.INPUT, '')
}
}, [attemptingTxn, onUserInput, swapErrorMessage, tradeToConfirm, txHash])
function modalBottom() {
return (
<SwapModalFooter
confirmText={priceImpactSeverity > 2 ? 'Swap Anyway' : 'Confirm Swap'}
showInverted={showInverted}
severity={priceImpactSeverity}
setShowInverted={setShowInverted}
onSwap={onSwap}
realizedLPFee={realizedLPFee}
parsedAmounts={parsedAmounts}
priceImpactWithoutFee={priceImpactWithoutFee}
slippageAdjustedAmounts={slippageAdjustedAmounts}
trade={trade}
/>
)
}
const handleAcceptChanges = useCallback(() => {
setSwapState({ tradeToConfirm: trade, swapErrorMessage, txHash, attemptingTxn, showConfirm })
}, [attemptingTxn, showConfirm, swapErrorMessage, trade, txHash])
// text to show while loading
const pendingText = `Swapping ${parsedAmounts[Field.INPUT]?.toSignificant(6)} ${
currencies[Field.INPUT]?.symbol
} for ${parsedAmounts[Field.OUTPUT]?.toSignificant(6)} ${currencies[Field.OUTPUT]?.symbol}`
const handleInputSelect = useCallback(
inputCurrency => {
setApprovalSubmitted(false) // reset 2 step UI for approvals
onCurrencySelection(Field.INPUT, inputCurrency)
},
[onCurrencySelection]
)
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 handleMaxInput = useCallback(() => {
maxAmountInput && onUserInput(Field.INPUT, maxAmountInput.toExact())
}, [maxAmountInput, onUserInput])
const handleOutputSelect = useCallback(outputCurrency => onCurrencySelection(Field.OUTPUT, outputCurrency), [
onCurrencySelection
])
return (
<>
{showWarning && <TokenWarningCards currencies={currencies} />}
<AppBody disabled={!!showWarning}>
<TokenWarningModal
isOpen={urlLoadedTokens.length > 0 && !dismissTokenWarning}
tokens={urlLoadedTokens}
onConfirm={handleConfirmTokenWarning}
/>
<AppBody>
<SwapPoolTabs active={'swap'} />
<Wrapper id="swap-page">
<ConfirmationModal
<ConfirmSwapModal
isOpen={showConfirm}
title="Confirm Swap"
onDismiss={() => {
setShowConfirm(false)
// if there was a tx hash, we want to clear the input
if (txHash) {
onUserInput(Field.INPUT, '')
}
setTxHash('')
}}
trade={trade}
originalTrade={tradeToConfirm}
onAcceptChanges={handleAcceptChanges}
attemptingTxn={attemptingTxn}
hash={txHash}
topContent={modalHeader}
bottomContent={modalBottom}
pendingText={pendingText}
txHash={txHash}
recipient={recipient}
allowedSlippage={allowedSlippage}
onConfirm={handleSwap}
swapErrorMessage={swapErrorMessage}
onDismiss={handleConfirmDismiss}
/>
<AutoColumn gap={'md'}>
@@ -282,45 +299,38 @@ export default function Swap() {
showMaxButton={!atMaxAmountInput}
currency={currencies[Field.INPUT]}
onUserInput={handleTypeInput}
onMax={() => {
maxAmountInput && onUserInput(Field.INPUT, maxAmountInput.toExact())
}}
onCurrencySelect={currency => {
setApprovalSubmitted(false) // reset 2 step UI for approvals
onCurrencySelection(Field.INPUT, currency)
}}
onMax={handleMaxInput}
onCurrencySelect={handleInputSelect}
otherCurrency={currencies[Field.OUTPUT]}
id="swap-currency-input"
/>
<CursorPointer>
<AutoColumn justify="space-between">
<AutoRow justify="space-between" style={{ padding: '0 1rem' }}>
<ArrowWrapper clickable>
<ArrowDown
size="16"
onClick={() => {
setApprovalSubmitted(false) // reset 2 step UI for approvals
onSwitchTokens()
}}
color={currencies[Field.INPUT] && currencies[Field.OUTPUT] ? theme.primary1 : theme.text2}
/>
</ArrowWrapper>
{recipient === null && !showWrap ? (
<LinkStyledButton id="add-recipient-button" onClick={() => onChangeRecipient('')}>
+ Add a send (optional)
</LinkStyledButton>
) : null}
</AutoRow>
</AutoColumn>
</CursorPointer>
<AutoColumn justify="space-between">
<AutoRow justify="space-between" style={{ padding: '0 1rem' }}>
<ArrowWrapper clickable>
<ArrowDown
size="16"
onClick={() => {
setApprovalSubmitted(false) // reset 2 step UI for approvals
onSwitchTokens()
}}
color={currencies[Field.INPUT] && currencies[Field.OUTPUT] ? theme.primary1 : theme.text2}
/>
</ArrowWrapper>
{recipient === null && !showWrap && isExpertMode ? (
<LinkStyledButton id="add-recipient-button" onClick={() => onChangeRecipient('')}>
+ Add a send (optional)
</LinkStyledButton>
) : null}
</AutoRow>
</AutoColumn>
<CurrencyInputPanel
value={formattedAmounts[Field.OUTPUT]}
onUserInput={handleTypeOutput}
label={independentField === Field.INPUT && !showWrap ? 'To (estimated)' : 'To'}
showMaxButton={false}
currency={currencies[Field.OUTPUT]}
onCurrencySelect={address => onCurrencySelection(Field.OUTPUT, address)}
onCurrencySelect={handleOutputSelect}
otherCurrency={currencies[Field.INPUT]}
id="swap-currency-output"
/>
@@ -361,7 +371,7 @@ export default function Swap() {
Slippage Tolerance
</ClickableText>
<ClickableText fontWeight={500} fontSize={14} color={theme.text2} onClick={toggleSettings}>
{allowedSlippage ? allowedSlippage / 100 : '-'}%
{allowedSlippage / 100}%
</ClickableText>
</RowBetween>
)}
@@ -373,8 +383,9 @@ export default function Swap() {
{!account ? (
<ButtonLight onClick={toggleWalletModal}>Connect Wallet</ButtonLight>
) : showWrap ? (
<ButtonPrimary disabled={Boolean(wrapError)} onClick={onWrap}>
{wrapError ?? (wrapType === WrapType.WRAP ? 'Wrap' : wrapType === WrapType.UNWRAP ? 'Unwrap' : null)}
<ButtonPrimary disabled={Boolean(wrapInputError)} onClick={onWrap}>
{wrapInputError ??
(wrapType === WrapType.WRAP ? 'Wrap' : wrapType === WrapType.UNWRAP ? 'Unwrap' : null)}
</ButtonPrimary>
) : noRoute && userHasSpecifiedInputOutput ? (
<GreyCard style={{ textAlign: 'center' }}>
@@ -398,15 +409,27 @@ export default function Swap() {
</ButtonPrimary>
<ButtonError
onClick={() => {
expertMode ? onSwap() : setShowConfirm(true)
if (isExpertMode) {
handleSwap()
} else {
setSwapState({
tradeToConfirm: trade,
attemptingTxn: false,
swapErrorMessage: undefined,
showConfirm: true,
txHash: undefined
})
}
}}
width="48%"
id="swap-button"
disabled={!isValid || approval !== ApprovalState.APPROVED || (priceImpactSeverity > 3 && !expertMode)}
disabled={
!isValid || approval !== ApprovalState.APPROVED || (priceImpactSeverity > 3 && !isExpertMode)
}
error={isValid && priceImpactSeverity > 2}
>
<Text fontSize={16} fontWeight={500}>
{priceImpactSeverity > 3 && !expertMode
{priceImpactSeverity > 3 && !isExpertMode
? `Price Impact High`
: `Swap${priceImpactSeverity > 2 ? ' Anyway' : ''}`}
</Text>
@@ -415,21 +438,32 @@ export default function Swap() {
) : (
<ButtonError
onClick={() => {
expertMode ? onSwap() : setShowConfirm(true)
if (isExpertMode) {
handleSwap()
} else {
setSwapState({
tradeToConfirm: trade,
attemptingTxn: false,
swapErrorMessage: undefined,
showConfirm: true,
txHash: undefined
})
}
}}
id="swap-button"
disabled={!isValid || (priceImpactSeverity > 3 && !expertMode)}
error={isValid && priceImpactSeverity > 2}
disabled={!isValid || (priceImpactSeverity > 3 && !isExpertMode) || !!swapCallbackError}
error={isValid && priceImpactSeverity > 2 && !swapCallbackError}
>
<Text fontSize={20} fontWeight={500}>
{error
? error
: priceImpactSeverity > 3 && !expertMode
{swapInputError
? swapInputError
: priceImpactSeverity > 3 && !isExpertMode
? `Price Impact Too High`
: `Swap${priceImpactSeverity > 2 ? ' Anyway' : ''}`}
</Text>
</ButtonError>
)}
{isExpertMode && swapErrorMessage ? <SwapCallbackError error={swapErrorMessage} /> : null}
{betterTradeLinkVersion && <BetterTradeLink version={betterTradeLinkVersion} />}
</BottomGrouping>
</Wrapper>

View File

@@ -21,5 +21,5 @@ export type PopupContent =
export const updateBlockNumber = createAction<{ chainId: number; blockNumber: number }>('updateBlockNumber')
export const toggleWalletModal = createAction<void>('toggleWalletModal')
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')

View File

@@ -25,6 +25,7 @@ describe('application reducer', () => {
expect(typeof list[0].key).toEqual('string')
expect(list[0].show).toEqual(true)
expect(list[0].content).toEqual({ txn: { hash: 'abc', summary: 'test', success: true } })
expect(list[0].removeAfterMs).toEqual(15000)
})
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].show).toEqual(true)
expect(list[0].content).toEqual({ txn: { hash: 'def', summary: 'test2', success: false } })
expect(list[0].removeAfterMs).toEqual(15000)
})
})

View File

@@ -8,7 +8,7 @@ import {
updateBlockNumber
} 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 {
blockNumber: { [chainId: number]: number }
@@ -40,12 +40,13 @@ export default createReducer(initialState, builder =>
.addCase(toggleSettingsMenu, state => {
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([
{
key: key || nanoid(),
show: true,
content
content,
removeAfterMs
}
])
})

View File

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

View File

@@ -1,54 +1,18 @@
import { createAction, createAsyncThunk } from '@reduxjs/toolkit'
import { ActionCreatorWithPayload, createAction } from '@reduxjs/toolkit'
import { TokenList, Version } from '@uniswap/token-lists'
import schema from '@uniswap/token-lists/src/tokenlist.schema.json'
import Ajv from 'ajv'
import uriToHttp from '../../utils/uriToHttp'
const tokenListValidator = new Ajv({ allErrors: true }).compile(schema)
/**
* Contains the logic for resolving a URL to a valid token list
* @param listUrl list url
*/
async function getTokenList(listUrl: string): Promise<TokenList> {
const urls = uriToHttp(listUrl)
for (const url of urls) {
let response
try {
response = await fetch(url)
if (!response.ok) continue
} catch (error) {
console.error(`failed to fetch list ${listUrl} at uri ${url}`)
continue
}
const json = await response.json()
if (!tokenListValidator(json)) {
throw new Error(
tokenListValidator.errors?.reduce<string>((memo, error) => {
const add = `${error.dataPath} ${error.message ?? ''}`
return memo.length > 0 ? `${memo}; ${add}` : `${add}`
}, '') ?? 'Token list failed validation'
)
}
return json
}
throw new Error('Unrecognized list URL protocol.')
export const fetchTokenList: Readonly<{
pending: ActionCreatorWithPayload<{ url: string; requestId: string }>
fulfilled: ActionCreatorWithPayload<{ url: string; tokenList: TokenList; requestId: string }>
rejected: ActionCreatorWithPayload<{ url: string; errorMessage: string; requestId: string }>
}> = {
pending: createAction('lists/fetchTokenList/pending'),
fulfilled: createAction('lists/fetchTokenList/fulfilled'),
rejected: createAction('lists/fetchTokenList/rejected')
}
const fetchCache: { [url: string]: Promise<TokenList> } = {}
export const fetchTokenList = createAsyncThunk<TokenList, string>(
'lists/fetchTokenList',
(url: string) =>
// this makes it so we only ever fetch a list a single time concurrently
(fetchCache[url] =
fetchCache[url] ??
getTokenList(url).catch(error => {
delete fetchCache[url]
throw error
}))
)
export const acceptListUpdate = createAction<string>('lists/acceptListUpdate')
export const addList = createAction<string>('lists/addList')
export const removeList = createAction<string>('lists/removeList')
export const selectList = createAction<string>('lists/selectList')
export const rejectVersionUpdate = createAction<Version>('lists/rejectVersionUpdate')

View File

@@ -1,18 +1,24 @@
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 { useSelector } from 'react-redux'
import { DEFAULT_TOKEN_LIST_URL } from '../../constants'
import { AppState } from '../index'
type TagDetails = Tags[keyof Tags]
export interface TagInfo extends TagDetails {
id: string
}
/**
* Token instances created from token info.
*/
export class WrappedTokenInfo extends Token {
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)
this.tokenInfo = tokenInfo
this.tags = tags
}
public get logoURI(): string | undefined {
return this.tokenInfo.logoURI
@@ -33,7 +39,7 @@ const EMPTY_LIST: TokenAddressMap = {
}
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 {
const result = listCache?.get(list)
@@ -41,7 +47,14 @@ export function listToTokenMap(list: TokenList): TokenAddressMap {
const map = list.tokens.reduce<TokenAddressMap>(
(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.')
return {
...tokenMap,
@@ -57,17 +70,38 @@ export function listToTokenMap(list: TokenList): TokenAddressMap {
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)
return useMemo(() => {
if (!url) return EMPTY_LIST
const current = lists[url]?.current
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])
}
export function useDefaultTokenList(): TokenAddressMap {
return useTokenList(DEFAULT_TOKEN_LIST_URL)
export function useSelectedListUrl(): string | undefined {
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

View File

@@ -1,6 +1,9 @@
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 UNISWAP_DEFAULT_TOKEN_LIST from '@uniswap/default-token-list'
const STUB_TOKEN_LIST = {
name: '',
@@ -27,14 +30,15 @@ describe('list reducer', () => {
beforeEach(() => {
store = createStore(reducer, {
byUrl: {}
byUrl: {},
selectedListUrl: undefined
})
})
describe('fetchTokenList', () => {
describe('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({
byUrl: {
'fake-url': {
@@ -43,7 +47,8 @@ describe('list reducer', () => {
current: null,
pendingUpdate: null
}
}
},
selectedListUrl: undefined
})
})
@@ -56,10 +61,11 @@ describe('list reducer', () => {
pendingUpdate: 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({
byUrl: {
'fake-url': {
@@ -68,14 +74,17 @@ describe('list reducer', () => {
loadingRequestId: 'request-id',
pendingUpdate: null
}
}
},
selectedListUrl: undefined
})
})
})
describe('fulfilled', () => {
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({
byUrl: {
'fake-url': {
@@ -84,13 +93,18 @@ describe('list reducer', () => {
loadingRequestId: null,
pendingUpdate: null
}
}
},
selectedListUrl: undefined
})
})
it('does not save the list in pending if current is same', () => {
store.dispatch(fetchTokenList.fulfilled(STUB_TOKEN_LIST, 'request-id', 'fake-url'))
store.dispatch(fetchTokenList.fulfilled(STUB_TOKEN_LIST, 'request-id', 'fake-url'))
store.dispatch(
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({
byUrl: {
'fake-url': {
@@ -99,14 +113,19 @@ describe('list reducer', () => {
loadingRequestId: null,
pendingUpdate: null
}
}
},
selectedListUrl: undefined
})
})
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({
byUrl: {
'fake-url': {
@@ -115,13 +134,18 @@ describe('list reducer', () => {
loadingRequestId: null,
pendingUpdate: PATCHED_STUB_LIST
}
}
},
selectedListUrl: undefined
})
})
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({
byUrl: {
'fake-url': {
@@ -130,13 +154,18 @@ describe('list reducer', () => {
loadingRequestId: null,
pendingUpdate: MINOR_UPDATED_STUB_LIST
}
}
},
selectedListUrl: undefined
})
})
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({
byUrl: {
'fake-url': {
@@ -145,16 +174,18 @@ describe('list reducer', () => {
loadingRequestId: null,
pendingUpdate: MAJOR_UPDATED_STUB_LIST
}
}
},
selectedListUrl: undefined
})
})
})
describe('rejected', () => {
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({
byUrl: {}
byUrl: {},
selectedListUrl: undefined
})
})
@@ -167,9 +198,10 @@ describe('list reducer', () => {
loadingRequestId: 'request-id',
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({
byUrl: {
'fake-url': {
@@ -178,7 +210,8 @@ describe('list reducer', () => {
loadingRequestId: null,
pendingUpdate: null
}
}
},
selectedListUrl: undefined
})
})
})
@@ -195,7 +228,8 @@ describe('list reducer', () => {
loadingRequestId: null,
pendingUpdate: null
}
}
},
selectedListUrl: undefined
})
})
it('no op for existing list', () => {
@@ -207,7 +241,8 @@ describe('list reducer', () => {
loadingRequestId: null,
pendingUpdate: null
}
}
},
selectedListUrl: undefined
})
store.dispatch(addList('fake-url'))
expect(store.getState()).toEqual({
@@ -218,7 +253,8 @@ describe('list reducer', () => {
loadingRequestId: null,
pendingUpdate: null
}
}
},
selectedListUrl: undefined
})
})
})
@@ -233,7 +269,8 @@ describe('list reducer', () => {
loadingRequestId: null,
pendingUpdate: PATCHED_STUB_LIST
}
}
},
selectedListUrl: undefined
})
store.dispatch(acceptListUpdate('fake-url'))
expect(store.getState()).toEqual({
@@ -244,7 +281,251 @@ describe('list reducer', () => {
loadingRequestId: 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)
})
})
})

View File

@@ -1,7 +1,10 @@
import { createReducer } from '@reduxjs/toolkit'
import { getVersionUpgrade, VersionUpgrade } from '@uniswap/token-lists'
import { TokenList } from '@uniswap/token-lists/dist/types'
import { acceptListUpdate, addList, fetchTokenList } from './actions'
import { DEFAULT_LIST_OF_LISTS, DEFAULT_TOKEN_LIST_URL } from '../../constants/lists'
import { updateVersion } from '../user/actions'
import { acceptListUpdate, addList, fetchTokenList, removeList, selectList } from './actions'
import UNISWAP_DEFAULT_LIST from '@uniswap/default-token-list'
export interface ListsState {
readonly byUrl: {
@@ -12,15 +15,40 @@ export interface ListsState {
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 = {
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 =>
builder
.addCase(fetchTokenList.pending, (state, { meta: { arg: url, requestId } }) => {
.addCase(fetchTokenList.pending, (state, { payload: { requestId, url } }) => {
state.byUrl[url] = {
current: null,
pendingUpdate: null,
@@ -29,19 +57,22 @@ export default createReducer(initialState, builder =>
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 loadingRequestId = state.byUrl[url]?.loadingRequestId
// no-op if update does nothing
if (current) {
const type = getVersionUpgrade(current.version, tokenList.version)
if (type === VersionUpgrade.NONE) return
state.byUrl[url] = {
...state.byUrl[url],
loadingRequestId: null,
error: null,
current: current,
pendingUpdate: tokenList
const upgradeType = getVersionUpgrade(current.version, tokenList.version)
if (upgradeType === VersionUpgrade.NONE) return
if (loadingRequestId === null || loadingRequestId === requestId) {
state.byUrl[url] = {
...state.byUrl[url],
loadingRequestId: null,
error: null,
current: current,
pendingUpdate: tokenList
}
}
} else {
state.byUrl[url] = {
@@ -53,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) {
// no-op since it's not the latest request
return
@@ -62,19 +93,29 @@ export default createReducer(initialState, builder =>
state.byUrl[url] = {
...state.byUrl[url],
loadingRequestId: null,
error: error.message ?? 'Unknown error',
error: errorMessage,
current: 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 }) => {
if (!state.byUrl[url]) {
state.byUrl[url] = {
loadingRequestId: null,
pendingUpdate: null,
current: null,
error: null
}
state.byUrl[url] = NEW_LIST_STATE
}
})
.addCase(removeList, (state, { payload: url }) => {
if (state.byUrl[url]) {
delete state.byUrl[url]
}
if (state.selectedListUrl === url) {
state.selectedListUrl = Object.keys(state.byUrl)[0]
}
})
.addCase(acceptListUpdate, (state, { payload: url }) => {
@@ -87,4 +128,31 @@ export default createReducer(initialState, builder =>
current: state.byUrl[url].pendingUpdate
}
})
.addCase(updateVersion, state => {
// 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
})
)

View File

@@ -1,36 +1,43 @@
import { getVersionUpgrade, minVersionBump, VersionUpgrade } from '@uniswap/token-lists'
import { useEffect } from 'react'
import { useCallback, useEffect } from 'react'
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 { AppDispatch, AppState } from '../index'
import { acceptListUpdate, addList, fetchTokenList } from './actions'
import { acceptListUpdate } from './actions'
export default function Updater(): null {
const { library } = useActiveWeb3React()
const dispatch = useDispatch<AppDispatch>()
const lists = useSelector<AppState, AppState['lists']['byUrl']>(state => state.lists.byUrl)
// we should always fetch the default token list, so add it
useEffect(() => {
if (!lists[DEFAULT_TOKEN_LIST_URL]) dispatch(addList(DEFAULT_TOKEN_LIST_URL))
}, [dispatch, lists])
const isWindowVisible = useIsWindowVisible()
// on initial mount, refetch all the lists in storage
useEffect(() => {
Object.keys(lists).forEach(listUrl => dispatch(fetchTokenList(listUrl) as any))
// we only do this once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch])
const fetchList = useFetchListCallback()
const fetchAllListsCallback = useCallback(() => {
if (!isWindowVisible) return
Object.keys(lists).forEach(url =>
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
useEffect(() => {
Object.keys(lists).forEach(listUrl => {
const list = lists[listUrl]
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
useEffect(() => {
@@ -43,7 +50,6 @@ export default function Updater(): null {
throw new Error('unexpected no version bump')
case VersionUpgrade.PATCH:
case VersionUpgrade.MINOR:
case VersionUpgrade.MAJOR:
const min = minVersionBump(list.current.tokens, list.pendingUpdate.tokens)
// automatically update minor/patch as long as bump matches the min update
if (bump >= min) {
@@ -68,21 +74,21 @@ export default function Updater(): null {
}
break
// this will be turned on later
// case VersionUpgrade.MAJOR:
// dispatch(
// addPopup({
// key: listUrl,
// content: {
// listUpdate: {
// listUrl,
// auto: false,
// oldList: list.current,
// newList: list.pendingUpdate
// }
// }
// })
// )
case VersionUpgrade.MAJOR:
dispatch(
addPopup({
key: listUrl,
content: {
listUpdate: {
listUrl,
auto: false,
oldList: list.current,
newList: list.pendingUpdate
}
},
removeAfterMs: null
})
)
}
}
})

View File

@@ -50,9 +50,10 @@ export function useDerivedMintInfo(
// pair
const [pairState, pair] = usePair(currencies[Field.CURRENCY_A], currencies[Field.CURRENCY_B])
const totalSupply = useTotalSupply(pair?.liquidityToken)
const noLiquidity: boolean =
pairState === PairState.NOT_EXISTS ||
Boolean(pair && JSBI.equal(pair.reserve0.raw, ZERO) && JSBI.equal(pair.reserve1.raw, ZERO))
pairState === PairState.NOT_EXISTS || Boolean(totalSupply && JSBI.equal(totalSupply.raw, ZERO))
// balances
const balances = useCurrencyBalances(account ?? undefined, [
@@ -94,16 +95,20 @@ export function useDerivedMintInfo(
[Field.CURRENCY_B]: independentField === Field.CURRENCY_A ? dependentAmount : independentAmount
}
const token0Price = pair?.token0Price
const price = useMemo(() => {
const { [Field.CURRENCY_A]: currencyAAmount, [Field.CURRENCY_B]: currencyBAmount } = parsedAmounts
if (currencyAAmount && currencyBAmount) {
return new Price(currencyAAmount.currency, currencyBAmount.currency, currencyAAmount.raw, currencyBAmount.raw)
if (noLiquidity) {
const { [Field.CURRENCY_A]: currencyAAmount, [Field.CURRENCY_B]: currencyBAmount } = parsedAmounts
if (currencyAAmount && currencyBAmount) {
return new Price(currencyAAmount.currency, currencyBAmount.currency, currencyAAmount.raw, currencyBAmount.raw)
}
return
} else {
return token0Price
}
return
}, [parsedAmounts])
}, [noLiquidity, token0Price, parsedAmounts])
// liquidity minted
const totalSupply = useTotalSupply(pair?.liquidityToken)
const liquidityMinted = useMemo(() => {
const { [Field.CURRENCY_A]: currencyAAmount, [Field.CURRENCY_B]: currencyBAmount } = parsedAmounts
const [tokenAmountA, tokenAmountB] = [

View File

@@ -1,11 +1,11 @@
import { Contract } from '@ethersproject/contracts'
import { useEffect, useMemo } from 'react'
import { useEffect, useMemo, useRef } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useActiveWeb3React } from '../../hooks'
import { useMulticallContract } from '../../hooks/useContract'
import useDebounce from '../../hooks/useDebounce'
import chunkArray from '../../utils/chunkArray'
import { retry } from '../../utils/retry'
import { CancelledError, retry, RetryableError } from '../../utils/retry'
import { useBlockNumber } from '../application/hooks'
import { AppDispatch, AppState } from '../index'
import {
@@ -30,11 +30,17 @@ async function fetchChunk(
chunk: Call[],
minBlockNumber: number
): Promise<{ results: string[]; blockNumber: number }> {
const [resultsBlockNumber, returnData] = await multicallContract.aggregate(
chunk.map(obj => [obj.address, obj.callData])
)
console.debug('Fetching chunk', multicallContract, chunk, minBlockNumber)
let resultsBlockNumber, returnData
try {
;[resultsBlockNumber, returnData] = await multicallContract.aggregate(chunk.map(obj => [obj.address, obj.callData]))
} catch (error) {
console.debug('Failed to fetch chunk inside retry', error)
throw error
}
if (resultsBlockNumber.toNumber() < minBlockNumber) {
throw new Error('Fetched for old block number')
console.debug(`Fetched results for old block number: ${resultsBlockNumber.toString()} vs. ${minBlockNumber}`)
throw new RetryableError('Fetched for old block number')
}
return { results: returnData, blockNumber: resultsBlockNumber.toNumber() }
}
@@ -112,6 +118,7 @@ export default function Updater() {
const latestBlockNumber = useBlockNumber()
const { chainId } = useActiveWeb3React()
const multicallContract = useMulticallContract()
const cancellations = useRef<{ blockNumber: number; cancellations: (() => void)[] }>()
const listeningKeys: { [callKey: string]: number } = useMemo(() => {
return activeListeningKeys(debouncedListeners, chainId)
@@ -134,6 +141,10 @@ export default function Updater() {
const chunkedCalls = chunkArray(calls, CALL_CHUNK_SIZE)
if (cancellations.current?.blockNumber !== latestBlockNumber) {
cancellations.current?.cancellations?.forEach(c => c())
}
dispatch(
fetchingMulticallResults({
calls,
@@ -142,38 +153,52 @@ export default function Updater() {
})
)
chunkedCalls.forEach((chunk, index) =>
// todo: cancel retries when the block number updates
retry(() => fetchChunk(multicallContract, chunk, latestBlockNumber), { n: 10, minWait: 2500, maxWait: 5000 })
.then(({ results: returnData, blockNumber: fetchBlockNumber }) => {
// accumulates the length of all previous indices
const firstCallKeyIndex = chunkedCalls.slice(0, index).reduce<number>((memo, curr) => memo + curr.length, 0)
const lastCallKeyIndex = firstCallKeyIndex + returnData.length
cancellations.current = {
blockNumber: latestBlockNumber,
cancellations: chunkedCalls.map((chunk, index) => {
const { cancel, promise } = retry(() => fetchChunk(multicallContract, chunk, latestBlockNumber), {
n: Infinity,
minWait: 2500,
maxWait: 3500
})
promise
.then(({ results: returnData, blockNumber: fetchBlockNumber }) => {
cancellations.current = { cancellations: [], blockNumber: latestBlockNumber }
dispatch(
updateMulticallResults({
chainId,
results: outdatedCallKeys
.slice(firstCallKeyIndex, lastCallKeyIndex)
.reduce<{ [callKey: string]: string | null }>((memo, callKey, i) => {
memo[callKey] = returnData[i] ?? null
return memo
}, {}),
blockNumber: fetchBlockNumber
})
)
})
.catch((error: any) => {
console.error('Failed to fetch multicall chunk', chunk, chainId, error)
dispatch(
errorFetchingMulticallResults({
calls: chunk,
chainId,
fetchingBlockNumber: latestBlockNumber
})
)
})
)
// accumulates the length of all previous indices
const firstCallKeyIndex = chunkedCalls.slice(0, index).reduce<number>((memo, curr) => memo + curr.length, 0)
const lastCallKeyIndex = firstCallKeyIndex + returnData.length
dispatch(
updateMulticallResults({
chainId,
results: outdatedCallKeys
.slice(firstCallKeyIndex, lastCallKeyIndex)
.reduce<{ [callKey: string]: string | null }>((memo, callKey, i) => {
memo[callKey] = returnData[i] ?? null
return memo
}, {}),
blockNumber: fetchBlockNumber
})
)
})
.catch((error: any) => {
if (error instanceof CancelledError) {
console.debug('Cancelled fetch for blockNumber', latestBlockNumber)
return
}
console.error('Failed to fetch multicall chunk', chunk, chainId, error)
dispatch(
errorFetchingMulticallResults({
calls: chunk,
chainId,
fetchingBlockNumber: latestBlockNumber
})
)
})
return cancel
})
}
}, [chainId, multicallContract, dispatch, serializedOutdatedCallKeys, latestBlockNumber])
return null

View File

@@ -3,7 +3,7 @@ import { Version } from '../../hooks/useToggledVersion'
import { parseUnits } from '@ethersproject/units'
import { Currency, CurrencyAmount, ETHER, JSBI, Token, TokenAmount, Trade } from '@uniswap/sdk'
import { ParsedQs } from 'qs'
import { useCallback, useEffect } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useV1Trade } from '../../data/V1'
import { useActiveWeb3React } from '../../hooks'
@@ -88,13 +88,31 @@ export function tryParseAmount(value?: string, currency?: Currency): CurrencyAmo
return
}
const BAD_RECIPIENT_ADDRESSES: string[] = [
'0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f', // v2 factory
'0xf164fC0Ec4E93095b804a4795bBe1e041497b92a', // v2 router 01
'0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D' // v2 router 02
]
/**
* Returns true if any of the pairs or tokens in a trade have the given checksummed address
* @param trade to check for the given address
* @param checksummedAddress address to check in the pairs and tokens
*/
function involvesAddress(trade: Trade, checksummedAddress: string): boolean {
return (
trade.route.path.some(token => token.address === checksummedAddress) ||
trade.route.pairs.some(pair => pair.liquidityToken.address === checksummedAddress)
)
}
// from the current swap inputs, compute the best trade and return it.
export function useDerivedSwapInfo(): {
currencies: { [field in Field]?: Currency }
currencyBalances: { [field in Field]?: CurrencyAmount }
parsedAmount: CurrencyAmount | undefined
v2Trade: Trade | undefined
error?: string
inputError?: string
v1Trade: Trade | undefined
} {
const { account } = useActiveWeb3React()
@@ -140,21 +158,30 @@ export function useDerivedSwapInfo(): {
// get link to trade on v1, if a better rate exists
const v1Trade = useV1Trade(isExactIn, currencies[Field.INPUT], currencies[Field.OUTPUT], parsedAmount)
let error: string | undefined
let inputError: string | undefined
if (!account) {
error = 'Connect Wallet'
inputError = 'Connect Wallet'
}
if (!parsedAmount) {
error = error ?? 'Enter an amount'
inputError = inputError ?? 'Enter an amount'
}
if (!currencies[Field.INPUT] || !currencies[Field.OUTPUT]) {
error = error ?? 'Select a token'
inputError = inputError ?? 'Select a token'
}
if (!to) {
error = error ?? 'Enter a recipient'
const formattedTo = isAddress(to)
if (!to || !formattedTo) {
inputError = inputError ?? 'Enter a recipient'
} else {
if (
BAD_RECIPIENT_ADDRESSES.indexOf(formattedTo) !== -1 ||
(bestTradeExactIn && involvesAddress(bestTradeExactIn, formattedTo)) ||
(bestTradeExactOut && involvesAddress(bestTradeExactOut, formattedTo))
) {
inputError = inputError ?? 'Invalid recipient'
}
}
const [allowedSlippage] = useUserSlippageTolerance()
@@ -177,7 +204,7 @@ export function useDerivedSwapInfo(): {
]
if (balanceIn && amountIn && balanceIn.lessThan(amountIn)) {
error = 'Insufficient ' + amountIn.currency.symbol + ' balance'
inputError = 'Insufficient ' + amountIn.currency.symbol + ' balance'
}
return {
@@ -185,7 +212,7 @@ export function useDerivedSwapInfo(): {
currencyBalances,
parsedAmount,
v2Trade: v2Trade ?? undefined,
error,
inputError,
v1Trade
}
}
@@ -246,10 +273,15 @@ export function queryParametersToSwapState(parsedQs: ParsedQs): SwapState {
}
// 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 dispatch = useDispatch<AppDispatch>()
const parsedQs = useParsedQueryString()
const [result, setResult] = useState<
{ inputCurrencyId: string | undefined; outputCurrencyId: string | undefined } | undefined
>()
useEffect(() => {
if (!chainId) return
@@ -264,6 +296,10 @@ export function useDefaultsFromURLSearch() {
recipient: parsed.recipient
})
)
setResult({ inputCurrencyId: parsed[Field.INPUT].currencyId, outputCurrencyId: parsed[Field.OUTPUT].currencyId })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch, chainId])
return result
}

View File

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

View File

@@ -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 { useCallback, useMemo } from 'react'
import { shallowEqual, useDispatch, useSelector } from 'react-redux'
@@ -10,7 +10,6 @@ import { AppDispatch, AppState } from '../index'
import {
addSerializedPair,
addSerializedToken,
dismissTokenWarning,
removeSerializedToken,
SerializedPair,
SerializedToken,
@@ -19,8 +18,6 @@ import {
updateUserExpertMode,
updateUserSlippageTolerance
} from './actions'
import { useDefaultTokenList } from '../lists/hooks'
import { isDefaultToken } from '../../utils'
function serializeToken(token: Token): SerializedToken {
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
* @param tokenA one of the two tokens

View File

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

View File

@@ -40,22 +40,22 @@ export const CloseIcon = styled(X)<{ onClick: () => void }>`
`
// 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;
text-decoration: none;
background: none;
cursor: pointer;
color: ${({ theme }) => theme.primary1};
cursor: ${({ disabled }) => (disabled ? 'default' : 'pointer')};
color: ${({ theme, disabled }) => (disabled ? theme.text2 : theme.primary1)};
font-weight: 500;
:hover {
text-decoration: underline;
text-decoration: ${({ disabled }) => (disabled ? null : 'underline')};
}
:focus {
outline: none;
text-decoration: underline;
text-decoration: ${({ disabled }) => (disabled ? null : 'underline')};
}
:active {
@@ -148,12 +148,6 @@ export const Spinner = styled.img`
height: 16px;
`
export const CursorPointer = styled.div`
:hover {
cursor: pointer;
}
`
const BackArrowLink = styled(StyledInternalLink)`
color: ${({ theme }) => theme.text1};
`

View File

@@ -52,10 +52,10 @@ export function colors(darkMode: boolean): Colors {
bg2: darkMode ? '#2C2F36' : '#F7F8FA',
bg3: darkMode ? '#40444F' : '#EDEEF2',
bg4: darkMode ? '#565A69' : '#CED0D9',
bg5: darkMode ? '#565A69' : '#888D9B',
bg5: darkMode ? '#6C7284' : '#888D9B',
//specialty colors
modalBG: darkMode ? 'rgba(0,0,0,0.85)' : 'rgba(0,0,0,0.6)',
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)',
//primary colors
@@ -172,11 +172,11 @@ export const FixedGlobalStyle = createGlobalStyle`
html, input, textarea, button {
font-family: 'Inter', sans-serif;
letter-spacing: -0.018em;
font-display: fallback;
}
@supports (font-variation-settings: normal) {
html, input, textarea, button {
font-family: 'Inter var', sans-serif;
font-display: fallback;
}
}

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

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

View File

@@ -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]))
})
})

Some files were not shown because too many files have changed in this diff Show More