Compare commits

...

36 Commits

Author SHA1 Message Date
Moody Salem
9c1fe53e4b perf(ethereum): reduce number of calls by batching all polling node calls (#840)
* initial refactoring

* rebase lint error

* start implementing reducer

* multicall reducer

* working multicall!

* clean up performance, re-fix annoying error

* use multicall everywhere

* use multicall for balances

* fix lint warning

* Use checksummed address

* Fix strict warning

* get it to a working state with the more generic form

* convert useETHBalances

* Remove the eth-scan contract completely

* Remove the eth-scan contract completely more

* Default export

* Put the encoding/decoding in the methods that can do it most efficiently

* Avoid duplicate fetches via debounce

* Reduce delay to something less noticeable

* Return null if pair reserves are undefined to indicate it does not exist
2020-05-28 21:17:45 -04:00
Noah Zinsmeister
28c916ff45 Remove liquidity callback (#837)
* give add liquidity the reducer treatment

rename setDefaultsFromURL to setDefaultsFromURLSearch

* fix tests and crash

* rework DOM structure to make flow more natural

* allow slippage + deadline setting in add liquidity

* migrate burn

* disable token selection in mint

clear input between pairs

* reset fields between pairs

* tweak helper text

* address review comments
2020-05-27 13:13:31 -04:00
Moody Salem
7adb4b6bd6 chore(release): fix changelog commit hashes 2020-05-27 12:29:49 -04:00
Noah Zinsmeister
b2f0236ee8 Add liquidity callback (#830)
* give add liquidity the reducer treatment

rename setDefaultsFromURL to setDefaultsFromURLSearch

* fix tests and crash

* rework DOM structure to make flow more natural

* allow slippage + deadline setting in add liquidity

* disable token selection in mint

clear input between pairs
2020-05-27 11:42:25 -04:00
Moody Salem
4b57059353 fix(release): fix the dns record update 2020-05-27 09:58:28 -04:00
Moody Salem
6926f9a4ae fix(flatMap): don't use the native flatMap function, bump release plugin version 2020-05-27 09:29:53 -04:00
Moody Salem
7dec580944 chore(readme): prepare for more ipfs, use the latest version of github tag action 2020-05-27 09:14:12 -04:00
Moody Salem
5cf95680ef chore(release): fix cf-ipfs url 2020-05-27 09:01:20 -04:00
Moody Salem
f8d6bab4ae chore(release): limit release to release.yaml changes on v2 branch 2020-05-27 09:00:57 -04:00
Moody Salem
c9721c42bf perf(reduce call volume): save a bunch of calls to infura when the tab is not focused, change infura IDs for v2 2020-05-26 13:36:09 -04:00
Moody Salem
4414134bb2 chore(release): Fix release links 2020-05-26 13:17:53 -04:00
Moody Salem
44ba54e44a chore(release): add cf-ipfs.com 2020-05-26 13:03:50 -04:00
Moody Salem
9ec3109f72 chore(release): release text, convert cidv0 2020-05-26 13:01:38 -04:00
Moody Salem
e75793676a fix(release): include release changelog 2020-05-26 12:16:00 -04:00
Moody Salem
32006ded21 chore(release): trigger release on changes to release.yaml 2020-05-26 12:06:40 -04:00
Moody Salem
d4f1c579d8 chore(release): remove cancel action because it's confusing 2020-05-26 11:03:52 -04:00
Moody Salem
95f3541807 fix release.yaml script again 2020-05-26 10:30:12 -04:00
Moody Salem
da4ca73a1d post install scripts are needed for cypress 2020-05-26 10:07:32 -04:00
Moody Salem
e75bf8d003 typo in action name 2020-05-26 10:05:45 -04:00
Moody Salem
236f68a459 Missed a spot in the yarn tests 2020-05-26 09:59:59 -04:00
Moody Salem
9f07baaad2 Move the github action to its own repo 2020-05-26 09:54:33 -04:00
Moody Salem
c75464e1aa chore(release): only trigger release on tag 2020-05-26 09:43:48 -04:00
Moody Salem
bc80585bb4 chore(release): frozen lockfiles 2020-05-26 09:41:54 -04:00
Moody Salem
ad45b2b7bb chore(release): speed up install significantly 2020-05-26 09:38:43 -04:00
Moody Salem
63ac89e9f3 chore(release): clean up release.yaml 2020-05-26 09:28:47 -04:00
Moody Salem
1b6ae0d3db chore(release): trigger a release on tagged commits 2020-05-26 09:16:22 -04:00
Moody Salem
7d67819604 chore(release): Add an action to replace Vercel DNS records 2020-05-26 09:07:09 -04:00
Moody Salem
7b9b332c42 fix(popover): ios safari not showing the popover 2020-05-25 01:39:50 -04:00
Moody Salem
01feae978a add a discord link (#833) 2020-05-24 04:47:41 -04:00
Moody Salem
2452d51e14 fix(remove swap button): weirdness on firefox 2020-05-23 22:21:20 -04:00
Moody Salem
bbdc258083 fix(search modal): prevent overlapping tooltips, always render lists 2020-05-23 20:14:41 -04:00
Moody Salem
27b103e3f7 fix(search modal): restore style on sort button 2020-05-23 20:10:16 -04:00
Moody Salem
2a751b9892 Workaround for popover placement on elements that move, styling for small amounts of search results 2020-05-23 20:08:12 -04:00
Moody Salem
175e93fbba chore(release): Update the release text 2020-05-23 16:07:23 -04:00
Moody Salem
0b5fc07ee5 fix(popover): use the same offset 2020-05-23 12:17:39 -04:00
Moody Salem
a0d4710a11 perf(Search modal): performance improvements (#829)
* Search modal performance improvements

* Move more code out of the search modal

* Add the question helper to the search

* Fix a couple lint errors

* Fix the tests, duplicate import text

* Hide the token info on small screens, flex the list

* Fix token sorting, have a link that focuses and shows a tooltip for the search input

* Remove reach tooltip css

* Fix pair balances in the search modal

* Get the arrow working

* Only clear the input when they re-open

* Better way to exclude props

* More small performance tweaks
2020-05-23 12:06:16 -04:00
100 changed files with 3341 additions and 3093 deletions

2
.env
View File

@@ -1,2 +1,2 @@
REACT_APP_CHAIN_ID="1"
REACT_APP_NETWORK_URL="https://mainnet.infura.io/v3/b8800ce81b8c451698081d269b86692b"
REACT_APP_NETWORK_URL="https://mainnet.infura.io/v3/acb7e55995d04c49bfb52b7141599467"

View File

@@ -1,5 +1,5 @@
REACT_APP_CHAIN_ID="1"
REACT_APP_NETWORK_URL="https://mainnet.infura.io/v3/2acb2baa4c06402792e0c701a3697d10"
REACT_APP_NETWORK_URL="https://mainnet.infura.io/v3/febcb10ca2754433a61e0805bc6c047d"
REACT_APP_PORTIS_ID="c0e2bf01-4b08-4fd5-ac7b-8e26b58cd236"
REACT_APP_FORTMATIC_KEY="pk_live_F937DF033A1666BF"
REACT_APP_GOOGLE_ANALYTICS_ID="UA-128182339-4"

View File

@@ -1,36 +1,49 @@
name: Release
# every morning
on:
# every morning
schedule:
- cron: '0 12 * * *'
# releases are triggered on changes to this file
push:
branches:
- v2
paths:
- '.github/workflows/release.yaml'
jobs:
create-release:
name: Create Release
bump_version:
name: Bump Version
runs-on: ubuntu-latest
outputs:
new_tag: ${{ steps.github_tag_action.outputs.new_tag }}
changelog: ${{ steps.github_tag_action.outputs.changelog }}
steps:
- name: Checkout
uses: actions/checkout@v1
- name: Bump version and push tag
id: bump_version
uses: mathieudutour/github-tag-action@v4
id: github_tag_action
uses: mathieudutour/github-tag-action@v4.5
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
release_branches: .*
- name: Cancel this build if no new commits
if: ${{ steps.bump_version.outputs.new_tag == null }}
uses: andymckay/cancel-action@0.2
create_release:
name: Create Release
runs-on: ubuntu-latest
needs: bump_version
if: ${{ needs.bump_version.outputs.new_tag != null }}
steps:
- name: Checkout
uses: actions/checkout@v1
- uses: actions/setup-node@v1
with:
node-version: '12'
- name: Install yarn
run: npm install -g yarn
- name: Install dependencies
run: yarn
run: yarn install --ignore-scripts --frozen-lockfile
- name: Build the IPFS bundle
run: yarn ipfs-build
@@ -39,14 +52,26 @@ jobs:
id: upload
uses: anantaramdas/ipfs-pinata-deploy-action@v1.5.2
with:
pin-name: Uniswap ${{ steps.bump_version.outputs.new_tag }}
pin-name: Uniswap ${{ needs.bump_version.outputs.new_tag }}
path: './build'
pinata-api-key: ${{ secrets.PINATA_API_KEY }}
pinata-secret-api-key: ${{ secrets.PINATA_API_SECRET_KEY }}
- name: Convert CIDv0 to CIDv1
id: convert_cidv0
uses: uniswap/convert-cidv0-cidv1@v1.0.0
with:
cidv0: ${{ steps.upload.outputs.hash }}
- name: Update DNS with new IPFS hash
id: update_dns
run: npx vercel --token ${{ secrets.VERCEL_TOKEN }} --scope uniswap dns add uniswap.org _dnslink.app TXT "dnslink=/ipfs/${{ steps.upload.outputs.hash }}"
uses: uniswap/replace-vercel-dns-records@v1.0.0
with:
domain: 'uniswap.org'
subdomain: '_dnslink.app'
record-type: 'TXT'
value: dnslink=/ipfs/${{ steps.upload.outputs.hash }}
token: ${{ secrets.VERCEL_TOKEN }}
team-name: 'uniswap'
- name: Create GitHub Release
id: create_release
@@ -54,17 +79,27 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ steps.bump_version.outputs.new_tag }}
release_name: Release ${{ steps.bump_version.outputs.new_tag }}
tag_name: ${{ needs.bump_version.outputs.new_tag }}
release_name: Release ${{ needs.bump_version.outputs.new_tag }}
body: |
Release built from commit [`${{ github.sha }}`](https://github.com/Uniswap/uniswap-frontend/tree/${{ github.sha }})
The IPFS hash of the bundle is `${{ steps.upload.outputs.hash }}`
The IPFS hash of the bundle is:
- CIDv0: `${{ steps.upload.outputs.hash }}`
- CIDv1: `${{ steps.convert_cidv0.outputs.cidv1 }}`
The following IPFS Gateways can be used to access the release:
Uniswap uses [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) to store your settings.
**Beware** that other sites you access via the _same_ IPFS gateway can read and modify your settings on Uniswap without your permission.
You can avoid this issue by using a subdomain IPFS gateway. The preferred gateway URLs below utilize the CIDv1 of the release in the subdomain, and are relatively safer.
Preferred URLs:
- https://${{ steps.convert_cidv0.outputs.cidv1 }}.ipfs.dweb.link/
- https://${{ steps.convert_cidv0.outputs.cidv1 }}.cf-ipfs.com/
- [ipfs://${{ steps.upload.outputs.hash }}/](ipfs://${{ steps.upload.outputs.hash }}/)
Other IPFS gateways:
- https://cloudflare-ipfs.com/ipfs/${{ steps.upload.outputs.hash }}/
- https://ipfs.infura.io/ipfs/${{ steps.upload.outputs.hash }}/
- https://ipfs.io/ipfs/${{ steps.upload.outputs.hash }}/
- https://dweb.link/ipfs/${{ steps.upload.outputs.hash }}/
${{ steps.bump_version.outputs.changelog }}
${{ needs.bump_version.outputs.changelog }}

View File

@@ -26,7 +26,7 @@ jobs:
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- run: yarn
- run: yarn install
- run: yarn integration-test
unit-tests:
@@ -48,7 +48,7 @@ jobs:
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- run: yarn
- run: yarn install --ignore-scripts --frozen-lockfile
- run: yarn test
lint:
@@ -70,6 +70,6 @@ jobs:
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- run: yarn
- run: yarn install --ignore-scripts --frozen-lockfile
- run: yarn lint

View File

@@ -1,9 +1,7 @@
# Uniswap Frontend
[![Netlify Status](https://api.netlify.com/api/v1/badges/fa110555-b3c7-4eeb-b840-88a835009c62/deploy-status)](https://app.netlify.com/sites/uniswap/deploys)
[![Tests](https://github.com/Uniswap/uniswap-frontend/workflows/Tests/badge.svg?branch=v2)](https://github.com/Uniswap/uniswap-frontend/actions?query=workflow%3ATests)
[![Tests](https://github.com/Uniswap/uniswap-frontend/workflows/Tests/badge.svg)](https://github.com/Uniswap/uniswap-frontend/actions?query=workflow%3ATests)
[![Styled With Prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://prettier.io/)
[![Release](https://github.com/Uniswap/uniswap-frontend/workflows/Release/badge.svg?branch=v2)](https://github.com/Uniswap/uniswap-frontend/actions?query=workflow%3ARelease)
An open source interface for Uniswap -- a protocol for decentralized exchange of Ethereum tokens.
@@ -17,10 +15,9 @@ An open source interface for Uniswap -- a protocol for decentralized exchange of
## Accessing the frontend
The front end is deployed to IPFS as well as to [uniswap.exchange](https://uniswap.exchange).
To access the front end via IPFS, use a link from the
[latest release](https://github.com/Uniswap/uniswap-frontend/releases/latest).
To access the front end, use an IPFS gateway link from the
[latest release](https://github.com/Uniswap/uniswap-frontend/releases/latest)
or visit [uniswap.exchange](https://uniswap.exchange).
## Development
@@ -46,7 +43,7 @@ change `REACT_APP_NETWORK_ID` to `"{yourNetworkId}"`, and change `REACT_APP_NETW
Note that the front end only works properly on testnets where both
[Uniswap V2](https://uniswap.org/docs/v2/smart-contracts/factory/) and
[eth-scan](https://github.com/MyCryptoHQ/eth-scan) are deployed.
[multicall](https://github.com/makerdao/multicall) are deployed.
The frontend will not work on other networks.
## Contributions

View File

@@ -1,19 +1,19 @@
describe('Add Liquidity', () => {
it('loads the two correct tokens', () => {
cy.visit('/add/0xF9bA5210F91D0474bd1e1DcDAeC4C58E359AaD85-0xc778417E063141139Fce010982780140Aa0cD5Ab')
cy.get('#add-liquidity-input-token0 .token-symbol-container').should('contain.text', 'MKR')
cy.get('#add-liquidity-input-token1 .token-symbol-container').should('contain.text', 'ETH')
cy.get('#add-liquidity-input-tokena .token-symbol-container').should('contain.text', 'MKR')
cy.get('#add-liquidity-input-tokenb .token-symbol-container').should('contain.text', 'ETH')
})
it('does not crash if ETH is duplicated', () => {
cy.visit('/add/0xc778417E063141139Fce010982780140Aa0cD5Ab-0xc778417E063141139Fce010982780140Aa0cD5Ab')
cy.get('#add-liquidity-input-token0 .token-symbol-container').should('contain.text', 'ETH')
cy.get('#add-liquidity-input-token1 .token-symbol-container').should('not.contain.text', 'ETH')
cy.get('#add-liquidity-input-tokena .token-symbol-container').should('contain.text', 'ETH')
cy.get('#add-liquidity-input-tokenb .token-symbol-container').should('not.contain.text', 'ETH')
})
it('token not in storage is loaded', () => {
cy.visit('/add/0xb290b2f9f8f108d03ff2af3ac5c8de6de31cdf6d-0xF9bA5210F91D0474bd1e1DcDAeC4C58E359AaD85')
cy.get('#add-liquidity-input-token0 .token-symbol-container').should('contain.text', 'SKL')
cy.get('#add-liquidity-input-token1 .token-symbol-container').should('contain.text', 'MKR')
cy.get('#add-liquidity-input-tokena .token-symbol-container').should('contain.text', 'SKL')
cy.get('#add-liquidity-input-tokenb .token-symbol-container').should('contain.text', 'MKR')
})
})

View File

@@ -5,9 +5,9 @@ describe('Pool', () => {
cy.get('#token-search-input').type('DAI')
})
it.skip('can import a pool', () => {
it('can import a pool', () => {
cy.get('#join-pool-button').click()
cy.get('#import-pool-link').click() // blocked by the grid element in the search box
cy.get('#import-pool-link').click({ force: true }) // blocked by the grid element in the search box
cy.url().should('include', '/find')
})
})

View File

@@ -1,19 +1,19 @@
describe('Remove Liquidity', () => {
it('loads the two correct tokens', () => {
cy.visit('/remove/0xc778417E063141139Fce010982780140Aa0cD5Ab-0xF9bA5210F91D0474bd1e1DcDAeC4C58E359AaD85')
cy.get('#remove-liquidity-token0-symbol').should('contain.text', 'ETH')
cy.get('#remove-liquidity-token1-symbol').should('contain.text', 'MKR')
cy.get('#remove-liquidity-tokena-symbol').should('contain.text', 'ETH')
cy.get('#remove-liquidity-tokenb-symbol').should('contain.text', 'MKR')
})
it('does not crash if ETH is duplicated', () => {
cy.visit('/remove/0xc778417E063141139Fce010982780140Aa0cD5Ab-0xc778417E063141139Fce010982780140Aa0cD5Ab')
cy.get('#remove-liquidity-token0-symbol').should('contain.text', 'ETH')
cy.get('#remove-liquidity-token1-symbol').should('not.contain.text', 'ETH')
cy.get('#remove-liquidity-tokena-symbol').should('contain.text', 'ETH')
cy.get('#remove-liquidity-tokenb-symbol').should('not.contain.text', 'ETH')
})
it('token not in storage is loaded', () => {
cy.visit('/remove/0xb290b2f9f8f108d03ff2af3ac5c8de6de31cdf6d-0xF9bA5210F91D0474bd1e1DcDAeC4C58E359AaD85')
cy.get('#remove-liquidity-token0-symbol').should('contain.text', 'SKL')
cy.get('#remove-liquidity-token1-symbol').should('contain.text', 'MKR')
cy.get('#remove-liquidity-tokena-symbol').should('contain.text', 'SKL')
cy.get('#remove-liquidity-tokenb-symbol').should('contain.text', 'MKR')
})
})

View File

@@ -32,8 +32,10 @@ describe('Swap', () => {
it('can swap ETH for DAI', () => {
cy.get('#swap-currency-output .open-currency-select-button').click()
cy.get('.token-item-0xc7AD46e0b8a400Bb3C915120d284AafbA8fc4735').click()
cy.get('#swap-currency-input .token-amount-input').type('0.001')
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')
cy.get('#swap-currency-input .token-amount-input').type('0.001', { force: true })
cy.get('#swap-currency-output .token-amount-input').should('not.equal', '')
cy.get('#show-advanced').click()
cy.get('#swap-button').click()

View File

@@ -71,10 +71,9 @@ Cypress.Commands.overwrite('visit', (original, url, options) => {
...options,
onBeforeLoad(win) {
options && options.onBeforeLoad && options.onBeforeLoad(win)
const provider = new JsonRpcProvider('https://rinkeby.infura.io/v3/b8800ce81b8c451698081d269b86692b', 4)
const provider = new JsonRpcProvider('https://rinkeby.infura.io/v3/acb7e55995d04c49bfb52b7141599467', 4)
const signer = new Wallet(PRIVATE_KEY_TEST_NEVER_USE, provider)
const bridge = new CustomizedBridge(signer, provider)
win.ethereum = bridge
win.ethereum = new CustomizedBridge(signer, provider)
}
})
})

View File

@@ -14,18 +14,19 @@
"@ethersproject/units": "^5.0.0-beta.132",
"@ethersproject/wallet": "^5.0.0-beta.141",
"@material-ui/core": "^4.9.5",
"@mycrypto/eth-scan": "^2.1.0",
"@popperjs/core": "^2.4.0",
"@reach/dialog": "^0.2.8",
"@reach/tooltip": "^0.2.0",
"@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/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-window": "^1.8.2",
"@types/rebass": "^4.0.5",
"@types/styled-components": "^4.2.0",
"@types/testing-library__cypress": "^5.0.5",
@@ -54,6 +55,7 @@
"i18next-browser-languagedetector": "^3.0.1",
"i18next-xhr-backend": "^2.0.1",
"jazzicon": "^1.5.0",
"lodash.flatmap": "^4.5.0",
"polished": "^3.3.2",
"prettier": "^1.17.0",
"qrcode.react": "^0.9.3",
@@ -70,12 +72,12 @@
"react-scripts": "^3.4.1",
"react-spring": "^8.0.27",
"react-use-gesture": "^6.0.14",
"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",
"swr": "0.1.18",
"typescript": "^3.8.3",
"use-media": "^1.4.0"
},
@@ -107,4 +109,4 @@
]
},
"license": "GPL-3.0-or-later"
}
}

View File

@@ -1,6 +1,6 @@
import React from 'react'
import styled from 'styled-components'
import { useCopyClipboard } from '../../hooks'
import useCopyClipboard from '../../hooks/useCopyClipboard'
import { Link } from '../../theme'
import { CheckCircle, Copy } from 'react-feather'

View File

@@ -1,8 +1,9 @@
import React, { useState, useEffect, useContext } from 'react'
import styled, { ThemeContext } from 'styled-components'
import useDebounce from '../../hooks/useDebounce'
import { isAddress } from '../../utils'
import { useActiveWeb3React, useDebounce } from '../../hooks'
import { useActiveWeb3React } from '../../hooks'
import { Link, TYPE } from '../../theme'
import { AutoColumn } from '../Column'
import { RowBetween } from '../Row'

View File

@@ -1,11 +1,9 @@
import { Pair, Token } from '@uniswap/sdk'
import React, { useState, useContext } from 'react'
import styled, { ThemeContext } from 'styled-components'
import '@reach/tooltip/styles.css'
import { darken } from 'polished'
import { Field } from '../../state/swap/actions'
import { useTokenBalanceTreatingWETHasETH } from '../../state/wallet/hooks'
import TokenLogo from '../TokenLogo'
import DoubleLogo from '../DoubleLogo'
import SearchModal from '../SearchModal'

View File

@@ -1,10 +1,10 @@
import React, { useRef, useEffect } 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 useToggle from '../../hooks/useToggle'
import { Link } from '../../theme'
import { useToggle } from '../../hooks'
const StyledMenuIcon = styled(MenuIcon)`
path {
@@ -70,6 +70,10 @@ const MenuItem = styled(Link)`
:hover {
color: ${({ theme }) => theme.text1};
cursor: pointer;
text-decoration: none;
}
> svg {
margin-right: 8px;
}
`
@@ -102,26 +106,32 @@ export default function Menu() {
return (
<StyledMenu ref={node}>
<StyledMenuButton onClick={() => toggle()}>
<StyledMenuButton onClick={toggle}>
<StyledMenuIcon />
</StyledMenuButton>
{open ? (
{open && (
<MenuFlyout>
<MenuItem id="link" href="https://uniswap.org/">
<Info size={14} />
About
</MenuItem>
<MenuItem id="link" href="https://uniswap.org/docs/v2">
<BookOpen size={14} />
Docs
</MenuItem>
<MenuItem id="link" href={CODE_LINK}>
<Code size={14} />
Code
</MenuItem>
<MenuItem id="link" href="https://discord.gg/vXCdddD">
<MessageCircle size={14} />
Discord
</MenuItem>
<MenuItem id="link" href="https://uniswap.info/">
<PieChart size={14} />
Analytics
</MenuItem>
</MenuFlyout>
) : (
''
)}
</StyledMenu>
)

View File

@@ -9,9 +9,9 @@ import '@reach/dialog/styles.css'
import { transparentize } from 'polished'
import { useGesture } from 'react-use-gesture'
// errors emitted, fix with https://github.com/styled-components/styled-components/pull/3006
const AnimatedDialogOverlay = animated(DialogOverlay)
const StyledDialogOverlay = styled(AnimatedDialogOverlay)`
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const StyledDialogOverlay = styled(({ mobile, ...rest }) => <AnimatedDialogOverlay {...rest} />)<{ mobile: boolean }>`
&[data-reach-dialog-overlay] {
z-index: 2;
display: flex;
@@ -41,7 +41,9 @@ const StyledDialogOverlay = styled(AnimatedDialogOverlay)`
// 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} />)`
const StyledDialogContent = styled(({ minHeight, maxHeight, mobile, isOpen, ...rest }) => (
<DialogContent aria-label="content" {...rest} />
))`
&[data-reach-dialog-content] {
margin: 0 0 2rem 0;
border: 1px solid ${({ theme }) => theme.bg1};
@@ -95,7 +97,7 @@ interface ModalProps {
onDismiss: () => void
minHeight?: number | false
maxHeight?: number
initialFocusRef?: React.Ref<any>
initialFocusRef?: React.RefObject<any>
children?: React.ReactNode
}
@@ -145,7 +147,7 @@ export default function Modal({
style={props}
onDismiss={onDismiss}
initialFocusRef={initialFocusRef}
mobile={isMobile}
mobile={true}
>
<Spring // animation for entrance and exit
from={{
@@ -163,6 +165,7 @@ export default function Modal({
}}
>
<StyledDialogContent
ariaLabel="test"
style={props}
hidden={true}
minHeight={minHeight}
@@ -191,15 +194,9 @@ export default function Modal({
style={props}
onDismiss={onDismiss}
initialFocusRef={initialFocusRef}
mobile={isMobile ? isMobile : undefined}
mobile={false}
>
<StyledDialogContent
hidden={true}
minHeight={minHeight}
maxHeight={maxHeight}
isOpen={isOpen}
mobile={isMobile ? isMobile : undefined}
>
<StyledDialogContent hidden={true} minHeight={minHeight} maxHeight={maxHeight} isOpen={isOpen}>
<HiddenCloseButton onClick={onDismiss} />
{children}
</StyledDialogContent>

View File

@@ -3,13 +3,12 @@ import styled from 'styled-components'
import { darken } from 'polished'
import { useTranslation } from 'react-i18next'
import { withRouter, NavLink, Link as HistoryLink, RouteComponentProps } from 'react-router-dom'
import useBodyKeyDown from '../../hooks/useBodyKeyDown'
import { CursorPointer } from '../../theme'
import { ArrowLeft } from 'react-feather'
import { RowBetween } from '../Row'
import QuestionHelper from '../Question'
import { useBodyKeyDown } from '../../hooks'
import QuestionHelper from '../QuestionHelper'
const tabOrder = [
{
@@ -110,8 +109,8 @@ function NavigationTabs({ location: { pathname }, history }: RouteComponentProps
<QuestionHelper
text={
adding
? 'When you add liquidity, you are given pool tokens that represent your position in this pool. These tokens automatically earn fees proportional to your pool share and can be redeemed at any time.'
: 'Your liquidity is represented by a pool token (ERC20). Removing will convert your position back into tokens at the current rate and proportional to the amount of each token in the pool. Any fees you accrued are included in the token amounts you receive.'
? 'When you add liquidity, you are given pool tokens representing your position. These tokens automatically earn fees proportional to your share of the pool, and can be redeemed at any time.'
: 'Removing pool tokens converts your position back into underlying tokens at the current rate, proportional to your share of the pool. Accrued fees are included in the amounts you receive.'
}
/>
</RowBetween>

View File

@@ -0,0 +1,135 @@
import { Placement } from '@popperjs/core'
import { transparentize } from 'polished'
import React, { useState } from 'react'
import { usePopper } from 'react-popper'
import styled, { keyframes } from 'styled-components'
import useInterval from '../../hooks/useInterval'
import Portal from '@reach/portal'
const fadeIn = keyframes`
from {
opacity : 0;
}
to {
opacity : 1;
}
`
const fadeOut = keyframes`
from {
opacity : 1;
}
to {
opacity : 0;
}
`
const PopoverContainer = styled.div<{ show: boolean }>`
z-index: 9999;
visibility: ${props => (!props.show ? 'hidden' : 'visible')};
animation: ${props => (!props.show ? fadeOut : fadeIn)} 150ms linear;
transition: visibility 150ms linear;
background: ${({ theme }) => theme.bg2};
border: 1px solid ${({ theme }) => theme.bg3};
box-shadow: 0 4px 8px 0 ${({ theme }) => transparentize(0.9, theme.shadow1)};
color: ${({ theme }) => theme.text2};
border-radius: 8px;
`
const ReferenceElement = styled.div`
display: inline-block;
`
const Arrow = styled.div`
width: 8px;
height: 8px;
z-index: 9998;
::before {
position: absolute;
width: 8px;
height: 8px;
z-index: 9998;
content: '';
border: 1px solid ${({ theme }) => theme.bg3};
transform: rotate(45deg);
background: ${({ theme }) => theme.bg2};
}
&.arrow-top {
bottom: -5px;
::before {
border-top: none;
border-left: none;
}
}
&.arrow-bottom {
top: -5px;
::before {
border-bottom: none;
border-right: none;
}
}
&.arrow-left {
right: -5px;
::before {
border-bottom: none;
border-left: none;
}
}
&.arrow-right {
left: -5px;
::before {
border-right: none;
border-top: none;
}
}
`
export interface PopoverProps {
content: React.ReactNode
show: boolean
children: React.ReactNode
placement?: Placement
}
export default function Popover({ content, show, children, placement = 'auto' }: PopoverProps) {
const [referenceElement, setReferenceElement] = useState<HTMLDivElement>(null)
const [popperElement, setPopperElement] = useState<HTMLDivElement>(null)
const [arrowElement, setArrowElement] = useState<HTMLDivElement>(null)
const { styles, update, attributes } = usePopper(referenceElement, popperElement, {
placement,
strategy: 'fixed',
modifiers: [
{ name: 'offset', options: { offset: [8, 8] } },
{ name: 'arrow', options: { element: arrowElement } }
]
})
useInterval(update, show ? 100 : null)
return (
<>
<ReferenceElement ref={setReferenceElement}>{children}</ReferenceElement>
<Portal>
<PopoverContainer show={show} ref={setPopperElement} style={styles.popper} {...attributes.popper}>
{content}
<Arrow
className={`arrow-${attributes.popper?.['data-popper-placement'] ?? ''}`}
ref={setArrowElement}
style={styles.arrow}
{...attributes.arrow}
/>
</PopoverContainer>
</Portal>
</>
)
}

View File

@@ -1,101 +0,0 @@
import React, { useState } from 'react'
import { createPortal } from 'react-dom'
import styled, { keyframes } from 'styled-components'
import { HelpCircle as Question } from 'react-feather'
import { usePopper } from 'react-popper'
const Wrapper = styled.div`
position: relative;
`
const QuestionWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
margin-left: 0.4rem;
padding: 0.2rem;
border: none;
background: none;
outline: none;
cursor: default;
border-radius: 36px;
background-color: ${({ theme }) => theme.bg2};
color: ${({ theme }) => theme.text2};
:hover,
:focus {
opacity: 0.7;
}
`
const fadeIn = keyframes`
from {
opacity : 0;
}
to {
opacity : 1;
}
`
const Popup = styled.div`
width: 228px;
z-index: 9999;
padding: 0.6rem 1rem;
line-height: 150%;
background: ${({ theme }) => theme.bg1};
border: 1px solid ${({ theme }) => theme.bg3};
border-radius: 8px;
animation: ${fadeIn} 0.15s linear;
color: ${({ theme }) => theme.text2};
font-weight: 400;
`
export default function QuestionHelper({ text }: { text: string }) {
const [showPopup, setShowPopup] = useState<boolean>(false)
const [referenceElement, setReferenceElement] = useState(null)
const [popperElement, setPopperElement] = useState(null)
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: 'auto',
strategy: 'fixed',
modifiers: [
{
name: 'offset',
options: {
offset: [6, 6]
}
}
]
})
const portal = createPortal(
showPopup && (
<Popup ref={setPopperElement} style={styles.popper} {...attributes.popper}>
{text}
</Popup>
),
document.getElementById('popover-container')
)
return (
<Wrapper>
<QuestionWrapper
onClick={() => {
setShowPopup(true)
}}
onMouseEnter={() => {
setShowPopup(true)
}}
onMouseLeave={() => {
setShowPopup(false)
}}
ref={setReferenceElement}
>
<Question size={16} />
</QuestionWrapper>
{portal}
</Wrapper>
)
}

View File

@@ -0,0 +1,40 @@
import React, { useCallback, useState } from 'react'
import { HelpCircle as Question } from 'react-feather'
import styled from 'styled-components'
import Tooltip from '../Tooltip'
const QuestionWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
padding: 0.2rem;
border: none;
background: none;
outline: none;
cursor: default;
border-radius: 36px;
background-color: ${({ theme }) => theme.bg2};
color: ${({ theme }) => theme.text2};
:hover,
:focus {
opacity: 0.7;
}
`
export default function QuestionHelper({ text, disabled }: { text: string; disabled?: boolean }) {
const [show, setShow] = useState<boolean>(false)
const open = useCallback(() => setShow(true), [setShow])
const close = useCallback(() => setShow(false), [setShow])
return (
<span style={{ marginLeft: 4 }}>
<Tooltip text={text} show={show && !disabled}>
<QuestionWrapper onClick={open} onMouseEnter={open} onMouseLeave={close}>
<Question size={16} />
</QuestionWrapper>
</Tooltip>
</span>
)
}

View File

@@ -0,0 +1,46 @@
import React from 'react'
import { Text } from 'rebass'
import { COMMON_BASES } from '../../constants'
import { AutoColumn } from '../Column'
import QuestionHelper from '../QuestionHelper'
import { AutoRow } from '../Row'
import TokenLogo from '../TokenLogo'
import { BaseWrapper } from './styleds'
export default function CommonBases({
chainId,
onSelect,
selectedTokenAddress
}: {
chainId: number
selectedTokenAddress: string
onSelect: (tokenAddress: string) => void
}) {
return (
<AutoColumn gap="md">
<AutoRow>
<Text fontWeight={500} fontSize={16}>
Common Bases
</Text>
<QuestionHelper text="These tokens are commonly used in pairs." />
</AutoRow>
<AutoRow gap="10px">
{COMMON_BASES[chainId]?.map(token => {
return (
<BaseWrapper
gap="6px"
onClick={() => selectedTokenAddress !== token.address && onSelect(token.address)}
disable={selectedTokenAddress === token.address}
key={token.address}
>
<TokenLogo address={token.address} />
<Text fontWeight={500} fontSize={16}>
{token.symbol}
</Text>
</BaseWrapper>
)
})}
</AutoRow>
</AutoColumn>
)
}

View File

@@ -0,0 +1,64 @@
import { JSBI, Pair, TokenAmount } from '@uniswap/sdk'
import React from 'react'
import { FixedSizeList } from 'react-window'
import { Text } from 'rebass'
import { ButtonPrimary } from '../Button'
import DoubleTokenLogo from '../DoubleLogo'
import { RowFixed } from '../Row'
import { MenuItem, ModalInfo } from './styleds'
export default function PairList({
pairs,
focusTokenAddress,
pairBalances,
onSelectPair,
onAddLiquidity = onSelectPair
}: {
pairs: Pair[]
focusTokenAddress?: string
pairBalances: { [pairAddress: string]: TokenAmount }
onSelectPair: (pair: Pair) => void
onAddLiquidity: (pair: Pair) => void
}) {
if (pairs.length === 0) {
return <ModalInfo>No Pools Found</ModalInfo>
}
return (
<FixedSizeList
itemSize={54}
height={500}
itemCount={pairs.length}
width="100%"
style={{ flex: '1', minHeight: 200 }}
>
{({ index, style }) => {
const pair = pairs[index]
// the focused token is shown first
const tokenA = focusTokenAddress === pair.token1.address ? pair.token1 : pair.token0
const tokenB = tokenA === pair.token0 ? pair.token1 : pair.token0
const pairAddress = pair.liquidityToken.address
const balance = pairBalances[pairAddress]?.toSignificant(6)
const zeroBalance = pairBalances[pairAddress]?.raw && JSBI.equal(pairBalances[pairAddress].raw, JSBI.BigInt(0))
const selectPair = () => onSelectPair(pair)
const addLiquidity = () => onAddLiquidity(pair)
return (
<MenuItem style={style} onClick={selectPair}>
<RowFixed>
<DoubleTokenLogo a0={tokenA.address} a1={tokenB.address} size={24} margin={true} />
<Text fontWeight={500} fontSize={16}>{`${tokenA.symbol}/${tokenB.symbol}`}</Text>
</RowFixed>
<ButtonPrimary padding={'6px 8px'} width={'fit-content'} borderRadius={'12px'} onClick={addLiquidity}>
{balance ? (zeroBalance ? 'Join' : 'Add Liquidity') : 'Join'}
</ButtonPrimary>
</MenuItem>
)
}}
</FixedSizeList>
)
}

View File

@@ -0,0 +1,34 @@
import React from 'react'
import { Text } from 'rebass'
import styled from 'styled-components'
import { RowFixed } from '../Row'
export const FilterWrapper = styled(RowFixed)`
padding: 8px;
background-color: ${({ theme }) => theme.bg2};
color: ${({ theme }) => theme.text1};
border-radius: 8px;
user-select: none;
& > * {
user-select: none;
}
:hover {
cursor: pointer;
}
`
export default function SortButton({
toggleSortOrder,
ascending
}: {
toggleSortOrder: () => void
ascending: boolean
}) {
return (
<FilterWrapper onClick={toggleSortOrder}>
<Text fontSize={14} fontWeight={500}>
{ascending ? '↑' : '↓'}
</Text>
</FilterWrapper>
)
}

View File

@@ -0,0 +1,122 @@
import { ChainId, JSBI, Token, TokenAmount } from '@uniswap/sdk'
import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next'
import { FixedSizeList } from 'react-window'
import { Text } from 'rebass'
import { ThemeContext } from 'styled-components'
import Circle from '../../assets/images/circle.svg'
import { ALL_TOKENS } from '../../constants/tokens'
import { useActiveWeb3React } from '../../hooks'
import { Link as StyledLink, TYPE } from '../../theme'
import { isAddress } from '../../utils'
import { ButtonSecondary } from '../Button'
import Column, { AutoColumn } from '../Column'
import { RowFixed } from '../Row'
import TokenLogo from '../TokenLogo'
import { FadedSpan, GreySpan, MenuItem, SpinnerWrapper, ModalInfo } from './styleds'
function isDefaultToken(tokenAddress: string, chainId?: number): boolean {
const address = isAddress(tokenAddress)
return Boolean(chainId && address && ALL_TOKENS[chainId as ChainId]?.[tokenAddress])
}
export default function TokenList({
tokens,
allTokenBalances,
selectedToken,
onTokenSelect,
otherToken,
showSendWithSwap,
onRemoveAddedToken,
otherSelectedText
}: {
tokens: Token[]
selectedToken: string
allTokenBalances: { [tokenAddress: string]: TokenAmount }
onTokenSelect: (tokenAddress: string) => void
onRemoveAddedToken: (chainId: number, tokenAddress: string) => void
otherToken: string
showSendWithSwap?: boolean
otherSelectedText: string
}) {
const { t } = useTranslation()
const { account, chainId } = useActiveWeb3React()
const theme = useContext(ThemeContext)
if (tokens.length === 0) {
return <ModalInfo>{t('noToken')}</ModalInfo>
}
return (
<FixedSizeList
width="100%"
height={500}
itemCount={tokens.length}
itemSize={50}
style={{ flex: '1', minHeight: 200 }}
>
{({ index, style }) => {
const { address, symbol } = tokens[index]
const customAdded = !isDefaultToken(address, chainId)
const balance = allTokenBalances[address]
const zeroBalance = balance && JSBI.equal(JSBI.BigInt(0), balance.raw)
return (
<MenuItem
style={style}
key={address}
className={`token-item-${address}`}
onClick={() => (selectedToken && selectedToken === address ? null : onTokenSelect(address))}
disabled={selectedToken && selectedToken === address}
selected={otherToken === address}
>
<RowFixed>
<TokenLogo address={address} size={'24px'} style={{ marginRight: '14px' }} />
<Column>
<Text fontWeight={500}>
{symbol}
{otherToken === address && <GreySpan> ({otherSelectedText})</GreySpan>}
</Text>
<FadedSpan>
<TYPE.main fontWeight={500}>{customAdded && 'Added by user'}</TYPE.main>
{customAdded && (
<div
onClick={event => {
event.stopPropagation()
onRemoveAddedToken(chainId, address)
}}
>
<StyledLink style={{ marginLeft: '4px', fontWeight: 400 }}>(Remove)</StyledLink>
</div>
)}
</FadedSpan>
</Column>
</RowFixed>
<AutoColumn gap="4px" justify="end">
{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 ? (
<SpinnerWrapper src={Circle} alt="loader" />
) : (
'-'
)}
</AutoColumn>
</MenuItem>
)
}}
</FixedSizeList>
)
}

View File

@@ -1,24 +0,0 @@
import React from 'react'
import { Text } from 'rebass'
import { FilterWrapper } from './styleds'
export function TokenSortButton({
title,
toggleSortOrder,
invertSearchOrder
}: {
title: string
toggleSortOrder: () => void
invertSearchOrder: boolean
}) {
return (
<FilterWrapper onClick={toggleSortOrder}>
<Text fontSize={14} fontWeight={500}>
{title}
</Text>
<Text fontSize={14} fontWeight={500}>
{!invertSearchOrder ? '↓' : '↑'}
</Text>
</FilterWrapper>
)
}

View File

@@ -0,0 +1,50 @@
import { isAddress } from '../../utils'
import { Pair, Token } from '@uniswap/sdk'
export function filterTokens(tokens: Token[], search: string): Token[] {
if (search.length === 0) return tokens
const searchingAddress = isAddress(search)
if (searchingAddress) {
return tokens.filter(token => token.address === searchingAddress)
}
const lowerSearchParts = searchingAddress ? [] : search.toLowerCase().split(/\s+/)
const matchesSearch = (s: string): boolean => {
const sParts = s.toLowerCase().split(/\s+/)
return lowerSearchParts.every(p => p.length === 0 || sParts.some(sp => sp.startsWith(p)))
}
return tokens.filter(token => {
const { symbol, name } = token
return matchesSearch(symbol) || matchesSearch(name)
})
}
export function filterPairs(pairs: Pair[], search: string): Pair[] {
if (search.trim().length === 0) return pairs
const addressSearch = isAddress(search)
if (addressSearch) {
return pairs.filter(p => {
return (
p.token0.address === addressSearch ||
p.token1.address === addressSearch ||
p.liquidityToken.address === addressSearch
)
})
}
const lowerSearch = search.toLowerCase()
return pairs.filter(pair => {
const pairExpressionA = `${pair.token0.symbol}/${pair.token1.symbol}`.toLowerCase()
if (pairExpressionA.startsWith(lowerSearch)) return true
const pairExpressionB = `${pair.token1.symbol}/${pair.token0.symbol}`.toLowerCase()
if (pairExpressionB.startsWith(lowerSearch)) return true
return filterTokens([pair.token0, pair.token1], search).length > 0
})
}

View File

@@ -1,43 +1,29 @@
import '@reach/tooltip/styles.css'
import { ChainId, JSBI, Token, WETH } from '@uniswap/sdk'
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'
import { Pair, Token } from '@uniswap/sdk'
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { isMobile } from 'react-device-detect'
import { ArrowLeft } from 'react-feather'
import { useTranslation } from 'react-i18next'
import { RouteComponentProps, withRouter } from 'react-router-dom'
import { Text } from 'rebass'
import { ThemeContext } from 'styled-components'
import Circle from '../../assets/images/circle.svg'
import Card from '../../components/Card'
import { COMMON_BASES } from '../../constants'
import { ALL_TOKENS } from '../../constants/tokens'
import { useActiveWeb3React } from '../../hooks'
import { useAllTokens, useTokenByAddressAndAutomaticallyAdd } from '../../hooks/Tokens'
import { useAllDummyPairs, useRemoveUserAddedToken } from '../../state/user/hooks'
import { useAllTokenBalancesTreatingWETHasETH } from '../../state/wallet/hooks'
import { CursorPointer, TYPE } from '../../theme'
import { useAllTokenBalancesTreatingWETHasETH, useTokenBalances } from '../../state/wallet/hooks'
import { CloseIcon, Link as StyledLink } from '../../theme/components'
import { escapeRegExp, isAddress } from '../../utils'
import { ButtonPrimary, ButtonSecondary } from '../Button'
import Column, { AutoColumn } from '../Column'
import DoubleTokenLogo from '../DoubleLogo'
import { isAddress } from '../../utils'
import Column from '../Column'
import Modal from '../Modal'
import QuestionHelper from '../Question'
import { AutoRow, RowBetween, RowFixed } from '../Row'
import TokenLogo from '../TokenLogo'
import { useTokenComparator } from './sorting'
import {
BaseWrapper,
FadedSpan,
GreySpan,
Input,
ItemList,
MenuItem,
PaddedColumn,
SpinnerWrapper,
TokenModalInfo
} from './styleds'
import { TokenSortButton } from './TokenSortButton'
import QuestionHelper from '../QuestionHelper'
import { AutoRow, RowBetween } from '../Row'
import Tooltip from '../Tooltip'
import CommonBases from './CommonBases'
import { filterPairs, filterTokens } from './filtering'
import PairList from './PairList'
import { balanceComparator, useTokenComparator } from './sorting'
import { PaddedColumn, SearchInput } from './styleds'
import TokenList from './TokenList'
import SortButton from './SortButton'
interface SearchModalProps extends RouteComponentProps {
isOpen?: boolean
@@ -51,11 +37,6 @@ interface SearchModalProps extends RouteComponentProps {
showCommonBases?: boolean
}
function isDefaultToken(tokenAddress: string, chainId?: number): boolean {
const address = isAddress(tokenAddress)
return Boolean(chainId && address && ALL_TOKENS[chainId as ChainId]?.[tokenAddress])
}
function SearchModal({
history,
isOpen,
@@ -72,421 +53,177 @@ function SearchModal({
const { account, chainId } = useActiveWeb3React()
const theme = useContext(ThemeContext)
const isTokenView = filterType === 'tokens'
const allTokens = useAllTokens()
const allPairs = useAllDummyPairs()
const allBalances = useAllTokenBalancesTreatingWETHasETH()
const allTokenBalances = useAllTokenBalancesTreatingWETHasETH() ?? {}
const allPairBalances = useTokenBalances(
account,
allPairs.map(p => p.liquidityToken)
)
const [searchQuery, setSearchQuery] = useState('')
const [invertSearchOrder, setInvertSearchOrder] = useState(false)
const [searchQuery, setSearchQuery] = useState<string>('')
const [tooltipOpen, setTooltipOpen] = useState<boolean>(false)
const [invertSearchOrder, setInvertSearchOrder] = useState<boolean>(false)
const removeTokenByAddress = useRemoveUserAddedToken()
// if the current input is an address, and we don't have the token in context, try to fetch it
const searchQueryToken = useTokenByAddressAndAutomaticallyAdd(searchQuery)
// toggle specific token import view
const [showTokenImport, setShowTokenImport] = useState(false)
// used to help scanning on results, put token found from input on left
const [identifiedToken, setIdentifiedToken] = useState<Token>()
// reset view on close
useEffect(() => {
if (!isOpen) {
setShowTokenImport(false)
}
}, [isOpen])
// if the current input is an address, and we don't have the token in context, try to fetch it and import
useTokenByAddressAndAutomaticallyAdd(searchQuery)
const tokenComparator = useTokenComparator(invertSearchOrder)
const sortedTokenList = useMemo(() => {
return Object.values(allTokens)
.sort(tokenComparator)
.map(token => {
return {
name: token.name,
symbol: token.symbol,
address: token.address,
balance: allBalances[account]?.[token.address]
}
})
}, [allTokens, tokenComparator, allBalances, account])
const sortedTokens: Token[] = useMemo(() => {
if (!isTokenView) return []
return Object.values(allTokens).sort(tokenComparator)
}, [allTokens, isTokenView, tokenComparator])
const filteredTokenList = useMemo(() => {
return sortedTokenList.filter(tokenEntry => {
const customAdded = !isDefaultToken(tokenEntry.address, chainId)
const filteredTokens: Token[] = useMemo(() => {
if (!isTokenView) return []
return filterTokens(sortedTokens, searchQuery)
}, [isTokenView, sortedTokens, searchQuery])
// if token import page dont show preset list, else show all
const include = !showTokenImport || (showTokenImport && customAdded && searchQuery !== '')
const inputIsAddress = searchQuery.slice(0, 2) === '0x'
const regexMatches = Object.keys(tokenEntry).map(tokenEntryKey => {
if (tokenEntryKey === 'address') {
return (
include &&
inputIsAddress &&
typeof tokenEntry[tokenEntryKey] === 'string' &&
!!tokenEntry[tokenEntryKey].match(new RegExp(escapeRegExp(searchQuery), 'i'))
)
}
return (
include &&
typeof tokenEntry[tokenEntryKey] === 'string' &&
!!tokenEntry[tokenEntryKey].match(new RegExp(escapeRegExp(searchQuery), 'i'))
)
})
return regexMatches.some(m => m)
})
}, [sortedTokenList, chainId, showTokenImport, searchQuery])
function _onTokenSelect(address) {
setSearchQuery('')
function _onTokenSelect(address: string) {
onTokenSelect(address)
onDismiss()
}
// clear the input on open
useEffect(() => {
if (isOpen) setSearchQuery('')
}, [isOpen, setSearchQuery])
// manage focus on modal show
const inputRef = useRef()
const inputRef = useRef<HTMLInputElement>()
function onInput(event) {
const input = event.target.value
const checksummedInput = isAddress(input)
setSearchQuery(checksummedInput || input)
}
function clearInputAndDismiss() {
setSearchQuery('')
onDismiss()
}
// make an effort to identify the specific token a user is searching for
useEffect(() => {
const searchQueryIsAddress = !!isAddress(searchQuery)
// try to find an exact match by address
if (searchQueryIsAddress) {
const identifiedTokenByAddress = Object.values(allTokens).filter(token => {
return searchQueryIsAddress && token.address === isAddress(searchQuery)
})
if (identifiedTokenByAddress.length > 0) setIdentifiedToken(identifiedTokenByAddress[0])
}
// try to find an exact match by symbol
else {
const identifiedTokenBySymbol = Object.values(allTokens).filter(token => {
return token.symbol.slice(0, searchQuery.length).toLowerCase() === searchQuery.toLowerCase()
})
if (identifiedTokenBySymbol.length > 0) setIdentifiedToken(identifiedTokenBySymbol[0])
}
return () => {
setIdentifiedToken(undefined)
}
}, [allTokens, searchQuery])
const sortedPairList = useMemo(() => {
if (isTokenView) return []
return allPairs.sort((a, b): number => {
// sort by balance
const balanceA = allBalances[account]?.[a.liquidityToken.address]
const balanceB = allBalances[account]?.[b.liquidityToken.address]
if (balanceA?.greaterThan('0') && !balanceB?.greaterThan('0')) return !invertSearchOrder ? -1 : 1
if (!balanceA?.greaterThan('0') && balanceB?.greaterThan('0')) return !invertSearchOrder ? 1 : -1
if (balanceA?.greaterThan('0') && balanceB?.greaterThan('0')) {
return balanceA.greaterThan(balanceB) ? (!invertSearchOrder ? -1 : 1) : !invertSearchOrder ? 1 : -1
}
return 0
const balanceA = allPairBalances[a.liquidityToken.address]
const balanceB = allPairBalances[b.liquidityToken.address]
return balanceComparator(balanceA, balanceB)
})
}, [allPairs, allBalances, account, invertSearchOrder])
}, [isTokenView, allPairs, allPairBalances])
const filteredPairList = useMemo(() => {
const searchQueryIsAddress = !!isAddress(searchQuery)
return sortedPairList.filter(pair => {
// if there's no search query, hide non-ETH pairs
if (searchQuery === '') return pair.token0.equals(WETH[chainId]) || pair.token1.equals(WETH[chainId])
const filteredPairs = useMemo(() => {
if (isTokenView) return []
return filterPairs(sortedPairList, searchQuery)
}, [isTokenView, searchQuery, sortedPairList])
const token0 = pair.token0
const token1 = pair.token1
const selectPair = useCallback(
(pair: Pair) => {
history.push(`/add/${pair.token0.address}-${pair.token1.address}`)
},
[history]
)
if (searchQueryIsAddress) {
if (token0.address === isAddress(searchQuery)) return true
if (token1.address === isAddress(searchQuery)) return true
} else {
const identifier0 = `${token0.symbol}/${token1.symbol}`
const identifier1 = `${token1.symbol}/${token0.symbol}`
if (identifier0.slice(0, searchQuery.length).toLowerCase() === searchQuery.toLowerCase()) return true
if (identifier1.slice(0, searchQuery.length).toLowerCase() === searchQuery.toLowerCase()) return true
}
return false
})
}, [searchQuery, sortedPairList, chainId])
const focusedToken = Object.values(allTokens ?? {}).filter(token => {
return token.symbol.toLowerCase() === searchQuery || searchQuery === token.address
})[0]
function renderPairsList() {
if (filteredPairList?.length === 0) {
return (
<PaddedColumn justify="center">
<Text>No Pools Found</Text>
</PaddedColumn>
)
}
return (
filteredPairList &&
filteredPairList.map((pair, i) => {
// reset ordering to help scan search results
const token0 = identifiedToken ? (identifiedToken.equals(pair.token0) ? pair.token0 : pair.token1) : pair.token0
const token1 = identifiedToken ? (identifiedToken.equals(pair.token0) ? pair.token1 : pair.token0) : pair.token1
const pairAddress = pair.liquidityToken.address
const balance = allBalances?.[account]?.[pairAddress]?.toSignificant(6)
const zeroBalance =
allBalances?.[account]?.[pairAddress]?.raw &&
JSBI.equal(allBalances?.[account]?.[pairAddress].raw, JSBI.BigInt(0))
return (
<MenuItem
key={i}
onClick={() => {
history.push('/add/' + token0.address + '-' + token1.address)
onDismiss()
}}
>
<RowFixed>
<DoubleTokenLogo a0={token0?.address || ''} a1={token1?.address || ''} size={24} margin={true} />
<Text fontWeight={500} fontSize={16}>{`${token0?.symbol}/${token1?.symbol}`}</Text>
</RowFixed>
<ButtonPrimary
padding={'6px 8px'}
width={'fit-content'}
borderRadius={'12px'}
onClick={() => {
history.push('/add/' + token0.address + '-' + token1.address)
onDismiss()
}}
>
{balance ? (zeroBalance ? 'Join' : 'Add Liquidity') : 'Join'}
</ButtonPrimary>
</MenuItem>
)
})
)
}
function renderTokenList() {
if (filteredTokenList.length === 0) {
if (isAddress(searchQuery)) {
if (!searchQueryToken) {
return <TokenModalInfo>Searching...</TokenModalInfo>
} else {
// a user found a token by search that isn't yet added to localstorage
return (
<MenuItem
key={searchQueryToken.address}
className={`temporary-token-${searchQueryToken.address}`}
onClick={() => {
_onTokenSelect(searchQueryToken.address)
}}
>
<RowFixed>
<TokenLogo address={searchQueryToken.address} size={'24px'} style={{ marginRight: '14px' }} />
<Column>
<Text fontWeight={500}>{searchQueryToken.symbol}</Text>
<FadedSpan>(Found by search)</FadedSpan>
</Column>
</RowFixed>
</MenuItem>
)
}
} else {
return <TokenModalInfo>{t('noToken')}</TokenModalInfo>
}
} else {
return filteredTokenList.map(({ address, symbol, balance }) => {
const customAdded = !isDefaultToken(address, chainId)
const zeroBalance = balance && JSBI.equal(JSBI.BigInt(0), balance.raw)
// if token import page dont show preset list, else show all
return (
<MenuItem
key={address}
className={`token-item-${address}`}
onClick={() => (hiddenToken && hiddenToken === address ? null : _onTokenSelect(address))}
disabled={hiddenToken && hiddenToken === address}
selected={otherSelectedTokenAddress === address}
>
<RowFixed>
<TokenLogo address={address} size={'24px'} style={{ marginRight: '14px' }} />
<Column>
<Text fontWeight={500}>
{symbol}
{otherSelectedTokenAddress === address && <GreySpan> ({otherSelectedText})</GreySpan>}
</Text>
<FadedSpan>
<TYPE.main fontWeight={500}>{customAdded && 'Added by user'}</TYPE.main>
{customAdded && (
<div
onClick={event => {
event.stopPropagation()
if (searchQuery === address) {
setSearchQuery('')
}
removeTokenByAddress(chainId, address)
}}
>
<StyledLink style={{ marginLeft: '4px', fontWeight: 400 }}>(Remove)</StyledLink>
</div>
)}
</FadedSpan>
</Column>
</RowFixed>
<AutoColumn gap="4px" justify="end">
{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 ? (
<SpinnerWrapper src={Circle} alt="loader" />
) : (
'-'
)}
</AutoColumn>
</MenuItem>
)
})
}
}
const openTooltip = useCallback(() => {
setTooltipOpen(true)
inputRef.current?.focus()
}, [setTooltipOpen])
const closeTooltip = useCallback(() => setTooltipOpen(false), [setTooltipOpen])
return (
<Modal
isOpen={isOpen}
onDismiss={clearInputAndDismiss}
maxHeight={70}
initialFocusRef={isMobile ? undefined : inputRef}
>
<Modal isOpen={isOpen} onDismiss={onDismiss} maxHeight={70} initialFocusRef={isMobile ? undefined : inputRef}>
<Column style={{ width: '100%' }}>
{showTokenImport ? (
<PaddedColumn gap="lg">
<RowBetween>
<RowFixed>
<CursorPointer>
<ArrowLeft
onClick={() => {
setShowTokenImport(false)
}}
/>
</CursorPointer>
<Text fontWeight={500} fontSize={16} marginLeft={'10px'}>
Import A Token
</Text>
</RowFixed>
<CloseIcon onClick={onDismiss} />
</RowBetween>
<TYPE.body style={{ marginTop: '10px' }}>
To import a custom token, paste token address in the search bar.
</TYPE.body>
<Input type={'text'} placeholder={'0x000000...'} value={searchQuery} ref={inputRef} onChange={onInput} />
{renderTokenList()}
</PaddedColumn>
) : (
<PaddedColumn gap="20px">
<RowBetween>
<Text fontWeight={500} fontSize={16}>
{filterType === 'tokens' ? 'Select a token' : 'Select a pool'}
</Text>
<CloseIcon onClick={onDismiss} />
</RowBetween>
<Input
<PaddedColumn gap="20px">
<RowBetween>
<Text fontWeight={500} fontSize={16}>
{isTokenView ? 'Select a token' : 'Select a pool'}
<QuestionHelper
disabled={tooltipOpen}
text={
isTokenView
? 'Find a token by searching for its name or symbol or by pasting its address below.'
: 'Find a pair by searching for its name 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={onInput}
onBlur={closeTooltip}
/>
{showCommonBases && (
<AutoColumn gap="md">
<AutoRow>
<Text fontWeight={500} fontSize={16}>
Common Bases
</Text>
<QuestionHelper text="These tokens are commonly used in pairs." />
</AutoRow>
<AutoRow gap="10px">
{COMMON_BASES[chainId]?.map(token => {
return (
<BaseWrapper
gap="6px"
onClick={() => hiddenToken !== token.address && _onTokenSelect(token.address)}
disable={hiddenToken === token.address}
key={token.address}
>
<TokenLogo address={token.address} />
<Text fontWeight={500} fontSize={16}>
{token.symbol}
</Text>
</BaseWrapper>
)
})}
</AutoRow>
</AutoColumn>
</Tooltip>
{showCommonBases && (
<CommonBases chainId={chainId} onSelect={_onTokenSelect} selectedTokenAddress={hiddenToken} />
)}
<RowBetween>
<Text fontSize={14} fontWeight={500}>
{isTokenView ? 'Token Name' : 'Pool Name'}
</Text>
{isTokenView && (
<SortButton ascending={invertSearchOrder} toggleSortOrder={() => setInvertSearchOrder(iso => !iso)} />
)}
<RowBetween>
<Text fontSize={14} fontWeight={500}>
{filterType === 'tokens' ? 'Token Name' : 'Pool Name'}
</Text>
{filterType === 'tokens' && (
<TokenSortButton
invertSearchOrder={invertSearchOrder}
toggleSortOrder={() => setInvertSearchOrder(iso => !iso)}
title={filterType === 'tokens' ? 'Your Balances' : ' '}
/>
</RowBetween>
</PaddedColumn>
<div style={{ width: '100%', height: '1px', backgroundColor: theme.bg2 }} />
{isTokenView ? (
<TokenList
tokens={filteredTokens}
allTokenBalances={allTokenBalances}
onRemoveAddedToken={removeTokenByAddress}
onTokenSelect={_onTokenSelect}
otherSelectedText={otherSelectedText}
otherToken={otherSelectedTokenAddress}
selectedToken={hiddenToken}
showSendWithSwap={showSendWithSwap}
/>
) : (
<PairList
pairs={filteredPairs}
focusTokenAddress={focusedToken?.address}
onAddLiquidity={selectPair}
onSelectPair={selectPair}
pairBalances={allPairBalances}
/>
)}
<div style={{ width: '100%', height: '1px', backgroundColor: theme.bg2 }} />
<Card>
<AutoRow justify={'center'}>
<div>
{isTokenView ? (
<Text fontWeight={500} color={theme.text2} fontSize={14}>
<StyledLink onClick={openTooltip}>Having trouble finding a token?</StyledLink>
</Text>
) : (
<Text fontWeight={500}>
{!isMobile && "Don't see a pool? "}
<StyledLink
onClick={() => {
history.push('/find')
}}
>
{!isMobile ? 'Import it.' : 'Import pool.'}
</StyledLink>
</Text>
)}
</RowBetween>
</PaddedColumn>
)}
{!showTokenImport && <div style={{ width: '100%', height: '1px', backgroundColor: theme.bg2 }} />}
{!showTokenImport && <ItemList>{filterType === 'tokens' ? renderTokenList() : renderPairsList()}</ItemList>}
{!showTokenImport && <div style={{ width: '100%', height: '1px', backgroundColor: theme.bg2 }} />}
{!showTokenImport && (
<Card>
<AutoRow justify={'center'}>
<div>
{filterType !== 'tokens' && (
<Text fontWeight={500}>
{!isMobile && "Don't see a pool? "}
<StyledLink
onClick={() => {
history.push('/find')
}}
>
{!isMobile ? 'Import it.' : 'Import pool.'}
</StyledLink>
</Text>
)}
{filterType === 'tokens' && (
<Text fontWeight={500} color={theme.text2} fontSize={14}>
{!isMobile && "Don't see a token? "}
<StyledLink
onClick={() => {
setShowTokenImport(true)
}}
>
{!isMobile ? 'Import it.' : 'Import custom token.'}
</StyledLink>
</Text>
)}
</div>
</AutoRow>
</Card>
)}
</div>
</AutoRow>
</Card>
</Column>
</Modal>
)

View File

@@ -3,10 +3,21 @@ import { useMemo } from 'react'
import { useActiveWeb3React } from '../../hooks'
import { useAllTokenBalancesTreatingWETHasETH } from '../../state/wallet/hooks'
// compare two token amounts with highest one coming first
export function balanceComparator(balanceA?: TokenAmount, balanceB?: TokenAmount) {
if (balanceA && balanceB) {
return balanceA.greaterThan(balanceB) ? -1 : balanceA.equalTo(balanceB) ? 0 : 1
} else if (balanceA && balanceA.greaterThan('0')) {
return -1
} else if (balanceB && balanceB.greaterThan('0')) {
return 1
}
return 0
}
function getTokenComparator(
weth: Token | undefined,
balances: { [tokenAddress: string]: TokenAmount },
invertSearchOrder: boolean
balances: { [tokenAddress: string]: TokenAmount }
): (tokenA: Token, tokenB: Token) => number {
return function sortTokens(tokenA: Token, tokenB: Token): number {
// -1 = a is first
@@ -22,11 +33,8 @@ function getTokenComparator(
const balanceA = balances[tokenA.address]
const balanceB = balances[tokenB.address]
if (balanceA?.greaterThan('0') && !balanceB?.greaterThan('0')) return !invertSearchOrder ? -1 : 1
if (!balanceA?.greaterThan('0') && balanceB?.greaterThan('0')) return !invertSearchOrder ? 1 : -1
if (balanceA?.greaterThan('0') && balanceB?.greaterThan('0')) {
return balanceA.greaterThan(balanceB) ? (!invertSearchOrder ? -1 : 1) : !invertSearchOrder ? 1 : -1
}
const balanceComp = balanceComparator(balanceA, balanceB)
if (balanceComp !== 0) return balanceComp
// sort by symbol
return tokenA.symbol.toLowerCase() < tokenB.symbol.toLowerCase() ? -1 : 1
@@ -34,8 +42,15 @@ function getTokenComparator(
}
export function useTokenComparator(inverted: boolean): (tokenA: Token, tokenB: Token) => number {
const { account, chainId } = useActiveWeb3React()
const { chainId } = useActiveWeb3React()
const weth = WETH[chainId]
const balances = useAllTokenBalancesTreatingWETHasETH()
return useMemo(() => getTokenComparator(weth, balances[account] ?? {}, inverted), [account, balances, inverted, weth])
const comparator = useMemo(() => getTokenComparator(weth, balances ?? {}), [balances, weth])
return useMemo(() => {
if (inverted) {
return (tokenA: Token, tokenB: Token) => comparator(tokenA, tokenB) * -1
} else {
return comparator
}
}, [inverted, comparator])
}

View File

@@ -3,7 +3,7 @@ import { Spinner } from '../../theme'
import { AutoColumn } from '../Column'
import { AutoRow, RowBetween, RowFixed } from '../Row'
export const TokenModalInfo = styled.div`
export const ModalInfo = styled.div`
${({ theme }) => theme.flexRowNoWrap}
align-items: center;
padding: 1rem 1rem;
@@ -13,13 +13,6 @@ export const TokenModalInfo = styled.div`
min-height: 200px;
`
export const ItemList = styled.div`
flex-grow: 1;
height: 254px;
overflow-y: scroll;
-webkit-overflow-scrolling: touch;
`
export const FadedSpan = styled(RowFixed)`
color: ${({ theme }) => theme.primary1};
font-size: 14px;
@@ -59,20 +52,6 @@ export const Input = styled.input`
}
`
export const FilterWrapper = styled(RowFixed)`
padding: 8px;
background-color: ${({ selected, theme }) => selected && theme.bg2};
color: ${({ selected, theme }) => (selected ? theme.text1 : theme.text2)};
border-radius: 8px;
user-select: none;
& > * {
user-select: none;
}
:hover {
cursor: pointer;
}
`
export const PaddedColumn = styled(AutoColumn)`
padding: 20px;
padding-bottom: 12px;
@@ -106,3 +85,11 @@ export const BaseWrapper = styled(AutoRow)<{ disable?: boolean }>`
background-color: ${({ theme, disable }) => disable && theme.bg3};
opacity: ${({ disable }) => disable && '0.4'};
`
export const SearchInput = styled(Input)`
transition: border 100ms;
:focus {
border: 1px solid ${({ theme }) => theme.primary1};
outline: none;
}
`

View File

@@ -1,7 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react'
import React from 'react'
import Slider from '@material-ui/core/Slider'
import { withStyles } from '@material-ui/core/styles'
import { useDebounce } from '../../hooks'
const StyledSlider = withStyles({
root: {
@@ -51,36 +50,12 @@ const StyledSlider = withStyles({
interface InputSliderProps {
value: number
onChange: (val: number) => void
override?: boolean
onChange: (value: number) => void
}
export default function InputSlider({ value, onChange, override }: InputSliderProps) {
const [internalVal, setInternalVal] = useState<number>(value)
const debouncedInternalValue = useDebounce(internalVal, 100)
const handleChange = useCallback(
(e, val) => {
setInternalVal(val)
if (val !== debouncedInternalValue) {
onChange(val)
}
},
[setInternalVal, onChange, debouncedInternalValue]
)
useEffect(() => {
if (override) {
setInternalVal(value)
}
}, [override, value])
return (
<StyledSlider
value={typeof internalVal === 'number' ? internalVal : 0}
onChange={handleChange}
aria-labelledby="input-slider"
step={1}
/>
)
export default function InputSlider({ value, onChange }: InputSliderProps) {
function wrappedOnChange(_, value) {
onChange(value)
}
return <StyledSlider value={value} onChange={wrappedOnChange} aria-labelledby="input-slider" step={1} />
}

View File

@@ -1,22 +1,22 @@
import React, { useState, useEffect, useRef, useCallback, useContext } from 'react'
import React, { useState, useRef, useContext } from 'react'
import styled, { ThemeContext } from 'styled-components'
import QuestionHelper from '../Question'
import { Text } from 'rebass'
import QuestionHelper from '../QuestionHelper'
import { TYPE } from '../../theme'
import { AutoColumn } from '../Column'
import { RowBetween, RowFixed } from '../Row'
import { darken } from 'polished'
import { useDebounce } from '../../hooks'
const WARNING_TYPE = Object.freeze({
none: 'none',
emptyInput: 'emptyInput',
invalidEntryBound: 'invalidEntryBound',
riskyEntryHigh: 'riskyEntryHigh',
riskyEntryLow: 'riskyEntryLow'
})
enum SlippageError {
InvalidInput = 'InvalidInput',
RiskyLow = 'RiskyLow',
RiskyHigh = 'RiskyHigh'
}
enum DeadlineError {
InvalidInput = 'InvalidInput'
}
const FancyButton = styled.button`
color: ${({ theme }) => theme.text1};
@@ -46,7 +46,7 @@ const Option = styled(FancyButton)<{ active: boolean }>`
color: ${({ active, theme }) => (active ? theme.white : theme.text1)};
`
const Input = styled.input<{ active?: boolean }>`
const Input = styled.input`
background: ${({ theme }) => theme.bg1};
flex-grow: 1;
font-size: 12px;
@@ -56,15 +56,8 @@ const Input = styled.input<{ active?: boolean }>`
&::-webkit-inner-spin-button {
-webkit-appearance: none;
}
color: ${({ active, theme, color }) => (color === 'red' ? theme.red1 : active ? 'initial' : theme.text1)};
cursor: ${({ active }) => (active ? 'initial' : 'inherit')};
text-align: ${({ active }) => (active ? 'right' : 'left')};
`
const BottomError = styled(Text)<{ show?: boolean }>`
font-size: 14px;
font-weight: 400;
padding-top: ${({ show }) => (show ? '12px' : '')};
color: ${({ theme, color }) => (color === 'red' ? theme.red1 : theme.text1)};
text-align: right;
`
const OptionCustom = styled(FancyButton)<{ active?: boolean; warning?: boolean }>`
@@ -89,12 +82,6 @@ const SlippageSelector = styled.div`
padding: 0 20px;
`
const Percent = styled.div`
color: ${({ color, theme }) => (color === 'faded' ? theme.bg1 : color === 'red' ? theme.red1 : 'inherit')};
font-size: 0, 8rem;
flex-grow: 0;
`
export interface SlippageTabsProps {
rawSlippage: number
setRawSlippage: (rawSlippage: number) => void
@@ -102,247 +89,154 @@ export interface SlippageTabsProps {
setDeadline: (deadline: number) => void
}
export default function SlippageTabs({ setRawSlippage, rawSlippage, deadline, setDeadline }: SlippageTabsProps) {
export default function SlippageTabs({ rawSlippage, setRawSlippage, deadline, setDeadline }: SlippageTabsProps) {
const theme = useContext(ThemeContext)
const [activeIndex, setActiveIndex] = useState(2)
const [warningType, setWarningType] = useState(WARNING_TYPE.none)
const inputRef = useRef<HTMLInputElement>()
const [userInput, setUserInput] = useState('')
const debouncedInput = useDebounce(userInput, 150)
const [slippageInput, setSlippageInput] = useState('')
const [deadlineInput, setDeadlineInput] = useState('')
const [initialSlippage] = useState(rawSlippage)
const slippageInputIsValid =
slippageInput === '' || (rawSlippage / 100).toFixed(2) === Number.parseFloat(slippageInput).toFixed(2)
const deadlineInputIsValid = deadlineInput === '' || (deadline / 60).toString() === deadlineInput
const [deadlineInput, setDeadlineInput] = useState(deadline / 60)
const updateSlippage = useCallback(
newSlippage => {
// round to 2 decimals to prevent ethers error
const numParsed = newSlippage * 100
// set both slippage values in parents
setRawSlippage(numParsed)
},
[setRawSlippage]
)
const checkBounds = useCallback(
slippageValue => {
setWarningType(WARNING_TYPE.none)
if (slippageValue === '' || slippageValue === '.') {
return setWarningType(WARNING_TYPE.emptyInput)
}
// check bounds and set errors
if (Number(slippageValue) < 0 || Number(slippageValue) > 50) {
return setWarningType(WARNING_TYPE.invalidEntryBound)
}
if (Number(slippageValue) >= 0 && Number(slippageValue) < 0.1) {
setWarningType(WARNING_TYPE.riskyEntryLow)
}
if (Number(slippageValue) > 5) {
setWarningType(WARNING_TYPE.riskyEntryHigh)
}
//update the actual slippage value in parent
updateSlippage(Number(slippageValue))
},
[updateSlippage]
)
function parseCustomDeadline(e) {
const val = e.target.value
const acceptableValues = [/^$/, /^\d+$/]
if (acceptableValues.some(re => re.test(val))) {
setDeadlineInput(val)
setDeadline(val * 60)
}
}
const setFromCustom = () => {
setActiveIndex(4)
inputRef.current.focus()
// if there's a value, evaluate the bounds
checkBounds(debouncedInput)
let slippageError: SlippageError
if (slippageInput !== '' && !slippageInputIsValid) {
slippageError = SlippageError.InvalidInput
} else if (slippageInputIsValid && rawSlippage < 50) {
slippageError = SlippageError.RiskyLow
} else if (slippageInputIsValid && rawSlippage > 500) {
slippageError = SlippageError.RiskyHigh
}
// used for slippage presets
const setFromFixed = useCallback(
(index, slippage) => {
// update slippage in parent, reset errors and input state
updateSlippage(slippage)
setWarningType(WARNING_TYPE.none)
setActiveIndex(index)
},
[updateSlippage]
)
let deadlineError: DeadlineError
if (deadlineInput !== '' && !deadlineInputIsValid) {
deadlineError = DeadlineError.InvalidInput
}
useEffect(() => {
switch (initialSlippage) {
case 10:
setFromFixed(1, 0.1)
break
case 50:
setFromFixed(2, 0.5)
break
case 100:
setFromFixed(3, 1)
break
default:
// restrict to 2 decimal places
const acceptableValues = [/^$/, /^\d{1,2}$/, /^\d{0,2}\.\d{0,2}$/]
// if its within accepted decimal limit, update the input state
if (acceptableValues.some(val => val.test('' + initialSlippage / 100))) {
setUserInput('' + initialSlippage / 100)
setActiveIndex(4)
}
}
}, [initialSlippage, setFromFixed])
function parseCustomSlippage(event) {
setSlippageInput(event.target.value)
// check that the theyve entered number and correct decimal
const parseInput = e => {
const input = e.target.value
let valueAsIntFromRoundedFloat: number
try {
valueAsIntFromRoundedFloat = Number.parseInt((Number.parseFloat(event.target.value) * 100).toString())
} catch {}
// restrict to 2 decimal places
const acceptableValues = [/^$/, /^\d{1,2}$/, /^\d{0,2}\.\d{0,2}$/]
// if its within accepted decimal limit, update the input state
if (acceptableValues.some(a => a.test(input))) {
setUserInput(input)
if (
typeof valueAsIntFromRoundedFloat === 'number' &&
!Number.isNaN(valueAsIntFromRoundedFloat) &&
valueAsIntFromRoundedFloat < 5000
) {
setRawSlippage(valueAsIntFromRoundedFloat)
}
}
useEffect(() => {
if (activeIndex === 4) {
checkBounds(debouncedInput)
function parseCustomDeadline(event) {
setDeadlineInput(event.target.value)
let valueAsInt: number
try {
valueAsInt = Number.parseInt(event.target.value) * 60
} catch {}
if (typeof valueAsInt === 'number' && !Number.isNaN(valueAsInt) && valueAsInt > 0) {
setDeadline(valueAsInt)
}
})
}
return (
<>
<RowFixed padding={'0 20px'}>
<TYPE.black fontWeight={400} fontSize={14} color={theme.text2}>
Set slippage tolerance
</TYPE.black>
<QuestionHelper text="Your transaction will revert if the price changes unfavorably by more than this percentage." />
</RowFixed>
<SlippageSelector>
<RowBetween>
<Option
onClick={() => {
setFromFixed(1, 0.1)
setSlippageInput('')
setRawSlippage(10)
}}
active={activeIndex === 1}
active={rawSlippage === 10}
>
0.1%
</Option>
<Option
onClick={() => {
setFromFixed(2, 0.5)
setSlippageInput('')
setRawSlippage(50)
}}
active={activeIndex === 2}
active={rawSlippage === 50}
>
0.5%
</Option>
<Option
onClick={() => {
setFromFixed(3, 1)
setSlippageInput('')
setRawSlippage(100)
}}
active={activeIndex === 3}
active={rawSlippage === 100}
>
1%
</Option>
<OptionCustom
active={activeIndex === 4}
warning={
warningType !== WARNING_TYPE.none &&
warningType !== WARNING_TYPE.emptyInput &&
warningType !== WARNING_TYPE.riskyEntryLow
}
onClick={() => {
setFromCustom()
}}
>
<OptionCustom active={![10, 50, 100].includes(rawSlippage)} warning={!slippageInputIsValid} tabIndex={-1}>
<RowBetween>
{!(warningType === WARNING_TYPE.none || warningType === WARNING_TYPE.emptyInput) && (
<span
role="img"
aria-label="warning"
style={{
color:
warningType !== WARNING_TYPE.none && warningType !== WARNING_TYPE.riskyEntryLow
? 'red'
: warningType === WARNING_TYPE.riskyEntryLow
? '#F3841E'
: ''
}}
>
{!!slippageInput &&
(slippageError === SlippageError.RiskyLow || slippageError === SlippageError.RiskyHigh) ? (
<span role="img" aria-label="warning" style={{ color: '#F3841E' }}>
</span>
)}
) : null}
<Input
tabIndex={-1}
ref={inputRef}
active={activeIndex === 4}
placeholder={
activeIndex === 4
? !!userInput
? ''
: '0'
: activeIndex !== 4 && userInput !== ''
? userInput
: 'Custom'
}
value={activeIndex === 4 ? userInput : ''}
onChange={parseInput}
color={
warningType === WARNING_TYPE.emptyInput
? ''
: warningType !== WARNING_TYPE.none && warningType !== WARNING_TYPE.riskyEntryLow
? 'red'
: ''
}
placeholder={(rawSlippage / 100).toFixed(2)}
value={slippageInput}
onBlur={() => {
parseCustomSlippage({ target: { value: (rawSlippage / 100).toFixed(2) } })
}}
onChange={parseCustomSlippage}
color={!slippageInputIsValid ? 'red' : ''}
/>
<Percent
color={
activeIndex !== 4
? 'faded'
: warningType === WARNING_TYPE.riskyEntryHigh || warningType === WARNING_TYPE.invalidEntryBound
? 'red'
: ''
}
>
%
</Percent>
%
</RowBetween>
</OptionCustom>
</RowBetween>
<RowBetween>
<BottomError
show={activeIndex === 4}
color={
warningType === WARNING_TYPE.emptyInput
? '#565A69'
: warningType !== WARNING_TYPE.none && warningType !== WARNING_TYPE.riskyEntryLow
? 'red'
: warningType === WARNING_TYPE.riskyEntryLow
? '#F3841E'
: ''
}
{!!slippageError && (
<RowBetween
style={{
fontSize: '14px',
paddingTop: '7px',
color: slippageError === SlippageError.InvalidInput ? 'red' : '#F3841E'
}}
>
{warningType === WARNING_TYPE.emptyInput && 'Enter a slippage percentage'}
{warningType === WARNING_TYPE.invalidEntryBound && 'Please select a value no greater than 50%'}
{warningType === WARNING_TYPE.riskyEntryHigh && 'Your transaction may be frontrun'}
{warningType === WARNING_TYPE.riskyEntryLow && 'Your transaction may fail'}
</BottomError>
</RowBetween>
{slippageError === SlippageError.InvalidInput
? 'Enter a valid slippage percentage'
: slippageError === SlippageError.RiskyLow
? 'Your transaction may fail'
: 'Your transaction may be frontrun'}
</RowBetween>
)}
</SlippageSelector>
<AutoColumn gap="sm">
<RowFixed padding={'0 20px'}>
<TYPE.black fontSize={14} color={theme.text2}>
Deadline
</TYPE.black>
<QuestionHelper text="Deadline in minutes. If your transaction takes longer than this it will revert." />
<QuestionHelper text="Your transaction will revert if it is pending for more than this long." />
</RowFixed>
<RowFixed padding={'0 20px'}>
<OptionCustom style={{ width: '80px' }}>
<OptionCustom style={{ width: '80px' }} tabIndex={-1}>
<Input
tabIndex={-1}
placeholder={'' + deadlineInput}
color={!!deadlineError ? 'red' : undefined}
onBlur={() => {
parseCustomDeadline({ target: { value: (deadline / 60).toString() } })
}}
placeholder={(deadline / 60).toString()}
value={deadlineInput}
onChange={parseCustomDeadline}
/>

View File

@@ -11,7 +11,7 @@ import { useTokenWarningDismissal } from '../../state/user/hooks'
import { Link, TYPE } from '../../theme'
import { getEtherscanLink } from '../../utils'
import PropsOfExcluding from '../../utils/props-of-excluding'
import QuestionHelper from '../Question'
import QuestionHelper from '../QuestionHelper'
import TokenLogo from '../TokenLogo'
const Wrapper = styled.div<{ error: boolean }>`

View File

@@ -0,0 +1,18 @@
import React from 'react'
import styled from 'styled-components'
import Popover, { PopoverProps } from '../Popover'
const TooltipContainer = styled.div`
width: 228px;
padding: 0.6rem 1rem;
line-height: 150%;
font-weight: 400;
`
interface TooltipProps extends Omit<PopoverProps, 'content'> {
text: string
}
export default function Tooltip({ text, ...rest }: TooltipProps) {
return <Popover content={<TooltipContainer>{text}</TooltipContainer>} {...rest} />
}

View File

@@ -4,6 +4,7 @@ import styled from 'styled-components'
import { isMobile } from 'react-device-detect'
import { UnsupportedChainIdError, useWeb3React } from '@web3-react/core'
import { URI_AVAILABLE } from '@web3-react/walletconnect-connector'
import usePrevious from '../../hooks/usePrevious'
import { useWalletModalOpen, useWalletModalToggle } from '../../state/application/hooks'
import Modal from '../Modal'
@@ -11,7 +12,6 @@ import AccountDetails from '../AccountDetails'
import PendingView from './PendingView'
import Option from './Option'
import { SUPPORTED_WALLETS } from '../../constants'
import { usePrevious } from '../../hooks'
import { Link } from '../../theme'
import MetamaskIcon from '../../assets/images/metamask.png'
import { ReactComponent as Close } from '../../assets/images/x.svg'

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import { useWeb3React, UnsupportedChainIdError } from '@web3-react/core'
import { darken, lighten } from 'polished'
import { Activity } from 'react-feather'
import useENSName from '../../hooks/useENSName'
import { useWalletModalToggle } from '../../state/application/hooks'
import { TransactionDetails } from '../../state/transactions/reducer'
@@ -19,7 +20,6 @@ import { Spinner } from '../../theme'
import LightCircle from '../../assets/svg/lightcircle.svg'
import { RowBetween } from '../Row'
import { useENSName } from '../../hooks'
import { shortenAddress } from '../../utils'
import { useAllTransactions } from '../../state/transactions/hooks'
import { NetworkContextName } from '../../constants'

View File

@@ -8,47 +8,28 @@ import { CursorPointer, TYPE } from '../../theme'
import { computeSlippageAdjustedAmounts, computeTradePriceBreakdown } from '../../utils/prices'
import { AutoColumn } from '../Column'
import { SectionBreak } from './styleds'
import QuestionHelper from '../Question'
import QuestionHelper from '../QuestionHelper'
import { RowBetween, RowFixed } from '../Row'
import SlippageTabs, { SlippageTabsProps } from '../SlippageTabs'
import FormattedPriceImpact from './FormattedPriceImpact'
import TokenLogo from '../TokenLogo'
import flatMap from 'lodash.flatmap'
export interface AdvancedSwapDetailsProps extends SlippageTabsProps {
trade: Trade
onDismiss: () => void
}
export function AdvancedSwapDetails({ trade, onDismiss, ...slippageTabProps }: AdvancedSwapDetailsProps) {
const { priceImpactWithoutFee, realizedLPFee } = computeTradePriceBreakdown(trade)
function TradeSummary({ trade, allowedSlippage }: { trade: Trade; allowedSlippage: number }) {
const theme = useContext(ThemeContext)
const { priceImpactWithoutFee, realizedLPFee } = computeTradePriceBreakdown(trade)
const isExactIn = trade.tradeType === TradeType.EXACT_INPUT
const slippageAdjustedAmounts = computeSlippageAdjustedAmounts(trade, slippageTabProps.rawSlippage)
const slippageAdjustedAmounts = computeSlippageAdjustedAmounts(trade, allowedSlippage)
return (
<AutoColumn gap="md">
<CursorPointer>
<RowBetween onClick={onDismiss} padding={'8px 20px'}>
<Text fontSize={16} color={theme.text2} fontWeight={500} style={{ userSelect: 'none' }}>
Hide Advanced
</Text>
<ChevronUp color={theme.text2} />
</RowBetween>
</CursorPointer>
<SectionBreak />
<>
<AutoColumn style={{ padding: '0 20px' }}>
<RowBetween>
<RowFixed>
<TYPE.black fontSize={14} fontWeight={400} color={theme.text2}>
{isExactIn ? 'Minimum received' : 'Maximum sold'}
</TYPE.black>
<QuestionHelper
text={
isExactIn
? 'Price can change between when a transaction is submitted and when it is executed. This is the minimum amount you will receive. A worse rate will cause your transaction to revert.'
: 'Price can change between when a transaction is submitted and when it is executed. This is the maximum amount you will pay. A worse rate will cause your transaction to revert.'
}
/>
<QuestionHelper text="Your transaction will revert if there is a large, unfavorable price movement before it is confirmed." />
</RowFixed>
<RowFixed>
<TYPE.black color={theme.text1} fontSize={14}>
@@ -82,16 +63,36 @@ export function AdvancedSwapDetails({ trade, onDismiss, ...slippageTabProps }: A
</AutoColumn>
<SectionBreak />
</>
)
}
export interface AdvancedSwapDetailsProps extends SlippageTabsProps {
trade?: Trade
onDismiss: () => void
}
export function AdvancedSwapDetails({ trade, onDismiss, ...slippageTabProps }: AdvancedSwapDetailsProps) {
const theme = useContext(ThemeContext)
return (
<AutoColumn gap="md">
<CursorPointer>
<RowBetween onClick={onDismiss} padding={'8px 20px'}>
<Text fontSize={16} color={theme.text2} fontWeight={500} style={{ userSelect: 'none' }}>
Hide Advanced
</Text>
<ChevronUp color={theme.text2} />
</RowBetween>
</CursorPointer>
<SectionBreak />
{trade && <TradeSummary trade={trade} allowedSlippage={slippageTabProps.rawSlippage} />}
<RowFixed padding={'0 20px'}>
<TYPE.black fontWeight={400} fontSize={14} color={theme.text2}>
Set slippage tolerance
</TYPE.black>
<QuestionHelper text="Your transaction will revert if the execution price changes by more than this amount after you submit your trade." />
</RowFixed>
<SlippageTabs {...slippageTabProps} />
{trade.route.path.length > 2 && (
{trade?.route?.path?.length > 2 && (
<AutoColumn style={{ padding: '0 20px' }}>
<RowFixed>
<TYPE.black fontSize={14} fontWeight={400} color={theme.text2}>
@@ -109,27 +110,28 @@ export function AdvancedSwapDetails({ trade, onDismiss, ...slippageTabProps }: A
justifyContent="space-evenly"
alignItems="center"
>
{trade.route.path
{flatMap(
trade.route.path,
// add a null in-between each item
.flatMap((token, i, array) => {
(token, i, array) => {
const lastItem = i === array.length - 1
return lastItem ? [token] : [token, null]
})
.map((token, i) => {
// use null as an indicator to insert chevrons
if (token === null) {
return <ChevronRight key={i} color={theme.text2} />
} else {
return (
<Flex my="0.5rem" alignItems="center" key={token.address} style={{ flexShrink: 0 }}>
<TokenLogo address={token.address} size="1.5rem" />
<TYPE.black fontSize={14} color={theme.text1} ml="0.5rem">
{token.symbol}
</TYPE.black>
</Flex>
)
}
})}
}
).map((token, i) => {
// use null as an indicator to insert chevrons
if (token === null) {
return <ChevronRight key={i} color={theme.text2} />
} else {
return (
<Flex my="0.5rem" alignItems="center" key={token.address} style={{ flexShrink: 0 }}>
<TokenLogo address={token.address} size="1.5rem" />
<TYPE.black fontSize={14} color={theme.text1} ml="0.5rem">
{token.symbol}
</TYPE.black>
</Flex>
)
}
})}
</Flex>
</AutoColumn>
)}

View File

@@ -1,30 +1,23 @@
import { Percent } from '@uniswap/sdk'
import React, { useContext } from 'react'
import { ChevronDown } from 'react-feather'
import { Text } from 'rebass'
import { ThemeContext } from 'styled-components'
import { CursorPointer } from '../../theme'
import { warningServerity } from '../../utils/prices'
import { AutoColumn } from '../Column'
import { RowBetween } from '../Row'
import { AdvancedSwapDetails, AdvancedSwapDetailsProps } from './AdvancedSwapDetails'
import { PriceSlippageWarningCard } from './PriceSlippageWarningCard'
import { AdvancedDropwdown, FixedBottom } from './styleds'
import { AdvancedDropdown } from './styleds'
export default function AdvancedSwapDetailsDropdown({
priceImpactWithoutFee,
showAdvanced,
setShowAdvanced,
...rest
}: Omit<AdvancedSwapDetailsProps, 'onDismiss'> & {
showAdvanced: boolean
setShowAdvanced: (showAdvanced: boolean) => void
priceImpactWithoutFee: Percent
}) {
const theme = useContext(ThemeContext)
const severity = warningServerity(priceImpactWithoutFee)
return (
<AdvancedDropwdown>
<AdvancedDropdown>
{showAdvanced ? (
<AdvancedSwapDetails {...rest} onDismiss={() => setShowAdvanced(false)} />
) : (
@@ -37,11 +30,6 @@ export default function AdvancedSwapDetailsDropdown({
</RowBetween>
</CursorPointer>
)}
<FixedBottom>
<AutoColumn gap="lg">
{severity > 2 && <PriceSlippageWarningCard priceSlippage={priceImpactWithoutFee} />}
</AutoColumn>
</FixedBottom>
</AdvancedDropwdown>
</AdvancedDropdown>
)
}

View File

@@ -1,12 +1,12 @@
import { Percent } from '@uniswap/sdk'
import React from 'react'
import { ONE_BIPS } from '../../constants'
import { warningServerity } from '../../utils/prices'
import { warningSeverity } from '../../utils/prices'
import { ErrorText } from './styleds'
export default function FormattedPriceImpact({ priceImpact }: { priceImpact?: Percent }) {
return (
<ErrorText fontWeight={500} fontSize={14} severity={warningServerity(priceImpact)}>
<ErrorText fontWeight={500} fontSize={14} severity={warningSeverity(priceImpact)}>
{priceImpact?.lessThan(ONE_BIPS) ? '<0.01%' : `${priceImpact?.toFixed(2)}%` ?? '-'}
</ErrorText>
)

View File

@@ -8,7 +8,7 @@ import { TYPE } from '../../theme'
import { formatExecutionPrice } from '../../utils/prices'
import { ButtonError } from '../Button'
import { AutoColumn } from '../Column'
import QuestionHelper from '../Question'
import QuestionHelper from '../QuestionHelper'
import { AutoRow, RowBetween, RowFixed } from '../Row'
import FormattedPriceImpact from './FormattedPriceImpact'
import { StyledBalanceMaxMini } from './styleds'
@@ -66,9 +66,9 @@ export default function SwapModalFooter({
<RowBetween>
<RowFixed>
<TYPE.black fontSize={14} fontWeight={400} color={theme.text2}>
{trade?.tradeType === TradeType.EXACT_INPUT ? 'Min sent' : 'Maximum sold'}
{trade?.tradeType === TradeType.EXACT_INPUT ? 'Minimum sent' : 'Maximum sold'}
</TYPE.black>
<QuestionHelper text="A boundary is set so you are protected from large price movements after you submit your trade." />
<QuestionHelper text="Your transaction will revert if there is a large, unfavorable price movement before it is confirmed." />
</RowFixed>
<RowFixed>
<TYPE.black fontSize={14}>

View File

@@ -21,26 +21,16 @@ export const ArrowWrapper = styled.div`
}
`
export const FixedBottom = styled.div`
position: absolute;
margin-top: 1.5rem;
export const AdvancedDropdown = styled.div`
padding-top: calc(10px + 2rem);
padding-bottom: 10px;
margin-top: -2rem;
width: 100%;
margin-bottom: 40px;
`
export const AdvancedDropwdown = styled.div`
position: absolute;
margin-top: -12px;
max-width: 455px;
width: 100%;
margin-bottom: 100px;
padding: 10px 0;
padding-top: 36px;
max-width: 400px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
color: ${({ theme }) => theme.text2};
background-color: ${({ theme }) => theme.advancedBG};
color: ${({ theme }) => theme.text2};
z-index: -1;
`
@@ -57,7 +47,7 @@ export const BottomGrouping = styled.div`
export const ErrorText = styled(Text)<{ severity?: 0 | 1 | 2 | 3 }>`
color: ${({ theme, severity }) =>
severity === 3 ? theme.red1 : severity === 2 ? theme.yellow2 : severity === 1 ? theme.green1 : theme.text1};
severity === 3 ? theme.red1 : severity === 2 ? theme.yellow2 : severity === 1 ? theme.text1 : theme.green1};
`
export const InputGroup = styled(AutoColumn)`

View File

@@ -0,0 +1,6 @@
import { Interface } from '@ethersproject/abi'
import ERC20_ABI from './erc20.json'
const ERC20_INTERFACE = new Interface(ERC20_ABI)
export default ERC20_INTERFACE

View File

@@ -0,0 +1,143 @@
[
{
"constant": true,
"inputs": [],
"name": "getCurrentBlockTimestamp",
"outputs": [
{
"name": "timestamp",
"type": "uint256"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [
{
"components": [
{
"name": "target",
"type": "address"
},
{
"name": "callData",
"type": "bytes"
}
],
"name": "calls",
"type": "tuple[]"
}
],
"name": "aggregate",
"outputs": [
{
"name": "blockNumber",
"type": "uint256"
},
{
"name": "returnData",
"type": "bytes[]"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [],
"name": "getLastBlockHash",
"outputs": [
{
"name": "blockHash",
"type": "bytes32"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [
{
"name": "addr",
"type": "address"
}
],
"name": "getEthBalance",
"outputs": [
{
"name": "balance",
"type": "uint256"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [],
"name": "getCurrentBlockDifficulty",
"outputs": [
{
"name": "difficulty",
"type": "uint256"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [],
"name": "getCurrentBlockGasLimit",
"outputs": [
{
"name": "gaslimit",
"type": "uint256"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [],
"name": "getCurrentBlockCoinbase",
"outputs": [
{
"name": "coinbase",
"type": "address"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [
{
"name": "blockNumber",
"type": "uint256"
}
],
"name": "getBlockHash",
"outputs": [
{
"name": "blockHash",
"type": "bytes32"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
}
]

View File

@@ -0,0 +1,12 @@
import { ChainId } from '@uniswap/sdk'
import MULTICALL_ABI from './abi.json'
const MULTICALL_NETWORKS: { [chainId in ChainId]: string } = {
[ChainId.MAINNET]: '0xeefBa1e63905eF1D7ACbA5a8513c70307C1cE441',
[ChainId.ROPSTEN]: '0x53C43764255c17BD724F74c4eF150724AC50a3ed',
[ChainId.KOVAN]: '0x2cc8688C5f75E365aaEEb4ea8D6a480405A48D2A',
[ChainId.RINKEBY]: '0x42Ad527de7d4e9d9d011aC45B31D8551f8Fe9821',
[ChainId.GÖRLI]: '0x77dCa2C955b15e9dE4dbBCf1246B4B85b651e50e'
}
export { MULTICALL_ABI, MULTICALL_NETWORKS }

View File

@@ -1,26 +1,17 @@
import { Contract } from '@ethersproject/contracts'
import { Token, TokenAmount } from '@uniswap/sdk'
import useSWR from 'swr'
import { useMemo } from 'react'
import { SWRKeys, useKeepSWRDataLiveAsBlocksArrive } from '.'
import { useTokenContract } from '../hooks'
import { useTokenContract } from '../hooks/useContract'
import { useSingleCallResult } from '../state/multicall/hooks'
function getTokenAllowance(contract: Contract, token: Token): (owner: string, spender: string) => Promise<TokenAmount> {
return async (owner: string, spender: string): Promise<TokenAmount> =>
contract
.allowance(owner, spender)
.then((balance: { toString: () => string }) => new TokenAmount(token, balance.toString()))
}
export function useTokenAllowance(token?: Token, owner?: string, spender?: string): TokenAmount {
export function useTokenAllowance(token?: Token, owner?: string, spender?: string): TokenAmount | undefined {
const contract = useTokenContract(token?.address, false)
const shouldFetch = !!contract && typeof owner === 'string' && typeof spender === 'string'
const { data, mutate } = useSWR(
shouldFetch ? [owner, spender, token.address, token.chainId, SWRKeys.Allowances] : null,
getTokenAllowance(contract, token)
)
useKeepSWRDataLiveAsBlocksArrive(mutate)
const inputs = useMemo(() => [owner, spender], [owner, spender])
const allowance = useSingleCallResult(contract, 'allowance', inputs)
return data
return useMemo(() => (token && allowance ? new TokenAmount(token, allowance.toString()) : undefined), [
token,
allowance
])
}

View File

@@ -1,24 +1,8 @@
import { Contract } from '@ethersproject/contracts'
import { Token, TokenAmount, Pair } from '@uniswap/sdk'
import useSWR from 'swr'
import { useMemo } from 'react'
import { SWRKeys, useKeepSWRDataLiveAsBlocksArrive } from '.'
import { usePairContract } from '../hooks'
function getReserves(contract: Contract, tokenA: Token, tokenB: Token): () => Promise<Pair | null> {
return async (): Promise<Pair | null> =>
contract
.getReserves()
.then(
({ reserve0, reserve1 }: { reserve0: { toString: () => string }; reserve1: { toString: () => string } }) => {
const [token0, token1] = tokenA.sortsBefore(tokenB) ? [tokenA, tokenB] : [tokenB, tokenA]
return new Pair(new TokenAmount(token0, reserve0.toString()), new TokenAmount(token1, reserve1.toString()))
}
)
.catch(() => {
return null
})
}
import { usePairContract } from '../hooks/useContract'
import { useSingleCallResult } from '../state/multicall/hooks'
/*
* if loading, return undefined
@@ -26,13 +10,15 @@ function getReserves(contract: Contract, tokenA: Token, tokenB: Token): () => Pr
* if pair already created (even if 0 reserves), return pair
*/
export function usePair(tokenA?: Token, tokenB?: Token): undefined | Pair | null {
const pairAddress = !!tokenA && !!tokenB && !tokenA.equals(tokenB) ? Pair.getAddress(tokenA, tokenB) : undefined
const pairAddress = tokenA && tokenB && !tokenA.equals(tokenB) ? Pair.getAddress(tokenA, tokenB) : undefined
const contract = usePairContract(pairAddress, false)
const reserves = useSingleCallResult(contract, 'getReserves')
const shouldFetch = !!contract
const key = shouldFetch ? [pairAddress, tokenA.chainId, SWRKeys.Reserves] : null
const { data, mutate } = useSWR(key, getReserves(contract, tokenA, tokenB))
useKeepSWRDataLiveAsBlocksArrive(mutate)
return data
return useMemo(() => {
if (!pairAddress || !contract || !tokenA || !tokenB) return undefined
if (!reserves) return null
const { reserve0, reserve1 } = reserves
const [token0, token1] = tokenA.sortsBefore(tokenB) ? [tokenA, tokenB] : [tokenB, tokenA]
return new Pair(new TokenAmount(token0, reserve0.toString()), new TokenAmount(token1, reserve1.toString()))
}, [contract, pairAddress, reserves, tokenA, tokenB])
}

View File

@@ -1,26 +1,14 @@
import { Contract } from '@ethersproject/contracts'
import { BigNumber } from '@ethersproject/bignumber'
import { Token, TokenAmount } from '@uniswap/sdk'
import useSWR from 'swr'
import { useTokenContract } from '../hooks/useContract'
import { useSingleCallResult } from '../state/multicall/hooks'
import { SWRKeys, useKeepSWRDataLiveAsBlocksArrive } from '.'
import { useTokenContract } from '../hooks'
function getTotalSupply(contract: Contract, token: Token): () => Promise<TokenAmount> {
return async (): Promise<TokenAmount> =>
contract
.totalSupply()
.then((totalSupply: { toString: () => string }) => new TokenAmount(token, totalSupply.toString()))
}
export function useTotalSupply(token?: Token): TokenAmount {
// returns undefined if input token is undefined, or fails to get token contract,
// or contract total supply cannot be fetched
export function useTotalSupply(token?: Token): TokenAmount | undefined {
const contract = useTokenContract(token?.address, false)
const shouldFetch = !!contract
const { data, mutate } = useSWR(
shouldFetch ? [token.address, token.chainId, SWRKeys.TotalSupply] : null,
getTotalSupply(contract, token)
)
useKeepSWRDataLiveAsBlocksArrive(mutate)
const totalSupply: BigNumber = useSingleCallResult(contract, 'totalSupply')?.[0]
return data
return token && totalSupply ? new TokenAmount(token, totalSupply.toString()) : undefined
}

View File

@@ -1,28 +1,15 @@
import { Contract } from '@ethersproject/contracts'
import { Token, TokenAmount, Pair, Trade, ChainId, WETH, Route, TradeType, Percent } from '@uniswap/sdk'
import useSWR from 'swr'
import { ChainId, Pair, Percent, Route, Token, TokenAmount, Trade, TradeType, WETH } from '@uniswap/sdk'
import { useMemo } from 'react'
import { useActiveWeb3React } from '../hooks'
import { useV1FactoryContract } from '../hooks'
import { SWRKeys } from '.'
import { useETHBalances, useTokenBalances } from '../state/wallet/hooks'
function getV1PairAddress(contract: Contract): (tokenAddress: string) => Promise<string> {
return async (tokenAddress: string): Promise<string> => contract.getExchange(tokenAddress)
}
function useV1PairAddress(tokenAddress: string) {
const { chainId } = useActiveWeb3React()
import { useV1FactoryContract } from '../hooks/useContract'
import { useSingleCallResult } from '../state/multicall/hooks'
import { useETHBalances, useTokenBalance } from '../state/wallet/hooks'
function useV1PairAddress(tokenAddress?: string): string | undefined {
const contract = useV1FactoryContract()
const shouldFetch = chainId === ChainId.MAINNET && typeof tokenAddress === 'string' && !!contract
const { data } = useSWR(shouldFetch ? [tokenAddress, SWRKeys.V1PairAddress] : null, getV1PairAddress(contract), {
// don't need to update this data
revalidateOnFocus: false,
revalidateOnReconnect: false
})
return data
const inputs = useMemo(() => [tokenAddress], [tokenAddress])
return useSingleCallResult(contract, 'getExchange', inputs)?.[0]
}
function useMockV1Pair(token?: Token) {
@@ -30,37 +17,35 @@ function useMockV1Pair(token?: Token) {
// will only return an address on mainnet, and not for WETH
const v1PairAddress = useV1PairAddress(isWETH ? undefined : token?.address)
const tokenBalance = useTokenBalances(v1PairAddress, [token])[token?.address]
const ETHBalance = useETHBalances([v1PairAddress])[v1PairAddress]
const tokenBalance = useTokenBalance(v1PairAddress, token)
const ETHBalance = useETHBalances([v1PairAddress])[v1PairAddress ?? '']
return tokenBalance && ETHBalance
? new Pair(tokenBalance, new TokenAmount(WETH[token?.chainId], ETHBalance.toString()))
return tokenBalance && ETHBalance && token
? new Pair(tokenBalance, new TokenAmount(WETH[token.chainId], ETHBalance.toString()))
: undefined
}
export function useV1TradeLinkIfBetter(
isExactIn?: boolean,
inputToken?: Token,
outputToken?: Token,
input?: Token,
output?: Token,
exactAmount?: TokenAmount,
v2Trade?: Trade,
minimumDelta: Percent = new Percent('0')
): string {
): string | undefined {
const { chainId } = useActiveWeb3React()
const input = inputToken
const output = outputToken
const mainnet = chainId === ChainId.MAINNET
const isMainnet: boolean = chainId === ChainId.MAINNET
// get the mock v1 pairs
const inputPair = useMockV1Pair(input)
const outputPair = useMockV1Pair(output)
const inputIsWETH = mainnet && input?.equals(WETH[ChainId.MAINNET])
const outputIsWETH = mainnet && output?.equals(WETH[ChainId.MAINNET])
const inputIsWETH = isMainnet && input?.equals(WETH[ChainId.MAINNET])
const outputIsWETH = isMainnet && output?.equals(WETH[ChainId.MAINNET])
// construct a direct or through ETH v1 route
let pairs: Pair[]
let pairs: Pair[] = []
if (inputIsWETH && outputPair) {
pairs = [outputPair]
} else if (outputIsWETH && inputPair) {
@@ -71,8 +56,8 @@ export function useV1TradeLinkIfBetter(
pairs = [inputPair, outputPair]
}
const route = pairs && new Route(pairs, input)
let v1Trade: Trade
const route = input && pairs && pairs.length > 0 && new Route(pairs, input)
let v1Trade: Trade | undefined
try {
v1Trade =
route && exactAmount
@@ -86,16 +71,16 @@ export function useV1TradeLinkIfBetter(
// discount the v1 output amount by minimumDelta
const discountedV1Output = v1Trade?.outputAmount.multiply(new Percent('1').subtract(minimumDelta))
// check if the discounted v1 amount is still greater than v2, short-circuiting if no v2 trade exists
v1HasBetterTrade = !!!v2Trade || discountedV1Output.greaterThan(v2Trade.outputAmount)
v1HasBetterTrade = !v2Trade || discountedV1Output.greaterThan(v2Trade.outputAmount)
} else {
// inflate the v1 amount by minimumDelta
const inflatedV1Input = v1Trade?.inputAmount.multiply(new Percent('1').add(minimumDelta))
// check if the inflated v1 amount is still less than v2, short-circuiting if no v2 trade exists
v1HasBetterTrade = !!!v2Trade || inflatedV1Input.lessThan(v2Trade.inputAmount)
v1HasBetterTrade = !v2Trade || inflatedV1Input.lessThan(v2Trade.inputAmount)
}
}
return v1HasBetterTrade
return v1HasBetterTrade && input && output
? `https://v1.uniswap.exchange/swap?inputCurrency=${inputIsWETH ? 'ETH' : input.address}&outputCurrency=${
outputIsWETH ? 'ETH' : output.address
}`

View File

@@ -1,24 +0,0 @@
import { useEffect, useRef } from 'react'
import { responseInterface } from 'swr'
import { useBlockNumber } from '../state/application/hooks'
export enum SWRKeys {
Allowances,
Reserves,
TotalSupply,
V1PairAddress
}
export function useKeepSWRDataLiveAsBlocksArrive(mutate: responseInterface<any, any>['mutate']) {
// because we don't care about the referential identity of mutate, just bind it to a ref
const mutateRef = useRef(mutate)
useEffect(() => {
mutateRef.current = mutate
})
// then, whenever a new block arrives, trigger a mutation
const blockNumber = useBlockNumber()
useEffect(() => {
mutateRef.current()
}, [blockNumber])
}

4
src/data/tsconfig.json Normal file
View File

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

View File

@@ -1,16 +1,9 @@
import { Contract } from '@ethersproject/contracts'
import { Web3Provider } from '@ethersproject/providers'
import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
import { useWeb3React as useWeb3ReactCore } from '@web3-react/core'
import { useEffect, useState } from 'react'
import { isMobile } from 'react-device-detect'
import copy from 'copy-to-clipboard'
import IUniswapV1Factory from '../constants/abis/v1_factory.json'
import ERC20_ABI from '../constants/abis/erc20.json'
import { abi as IUniswapV2PairABI } from '@uniswap/v2-core/build/IUniswapV2Pair.json'
import { injected } from '../connectors'
import { NetworkContextName, V1_FACTORY_ADDRESS } from '../constants'
import { getContract, isAddress } from '../utils'
import { NetworkContextName } from '../constants'
export function useActiveWeb3React() {
const context = useWeb3ReactCore<Web3Provider>()
@@ -64,7 +57,7 @@ export function useInactiveListener(suppress = false) {
const handleChainChanged = () => {
// eat errors
activate(injected, undefined, true).catch(error => {
console.log(error)
console.error('Failed to activate after chain changed', error)
})
}
@@ -72,7 +65,7 @@ export function useInactiveListener(suppress = false) {
if (accounts.length > 0) {
// eat errors
activate(injected, undefined, true).catch(error => {
console.log(error)
console.error('Failed to activate after accounts changed', error)
})
}
}
@@ -80,7 +73,7 @@ export function useInactiveListener(suppress = false) {
const handleNetworkChanged = () => {
// eat errors
activate(injected, undefined, true).catch(error => {
console.log(error)
console.error('Failed to activate after networks changed', error)
})
}
@@ -99,157 +92,3 @@ export function useInactiveListener(suppress = false) {
return
}, [active, error, suppress, activate])
}
// modified from https://usehooks.com/useDebounce/
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
// Update debounced value after delay
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
// Cancel the timeout if value changes (also on delay change or unmount)
// This is how we prevent debounced value from updating if value is changed ...
// .. within the delay period. Timeout gets cleared and restarted.
return () => {
clearTimeout(handler)
}
}, [value, delay])
return debouncedValue
}
// modified from https://usehooks.com/useKeyPress/
export function useBodyKeyDown(targetKey: string, onKeyDown: () => void, suppressOnKeyDown = false) {
const downHandler = useCallback(
event => {
const {
target: { tagName },
key
} = event
if (key === targetKey && tagName === 'BODY' && !suppressOnKeyDown) {
event.preventDefault()
onKeyDown()
}
},
[targetKey, onKeyDown, suppressOnKeyDown]
)
useEffect(() => {
window.addEventListener('keydown', downHandler)
return () => {
window.removeEventListener('keydown', downHandler)
}
}, [downHandler])
}
export function useENSName(address?: string): string | null {
const { library } = useActiveWeb3React()
const [ENSName, setENSName] = useState<string | null>(null)
useEffect(() => {
if (!library || !address) return
if (isAddress(address)) {
let stale = false
library
.lookupAddress(address)
.then(name => {
if (!stale) {
if (name) {
setENSName(name)
} else {
setENSName(null)
}
}
})
.catch(() => {
if (!stale) {
setENSName(null)
}
})
return () => {
stale = true
setENSName(null)
}
}
return
}, [library, address])
return ENSName
}
// returns null on errors
function useContract(address?: string, ABI?: any, withSignerIfPossible = true): Contract | null {
const { library, account } = useActiveWeb3React()
return useMemo(() => {
if (!address || !ABI || !library) return null
try {
return getContract(address, ABI, library, withSignerIfPossible && account ? account : undefined)
} catch {
return null
}
}, [address, ABI, library, withSignerIfPossible, account])
}
export function useV1FactoryContract(): Contract | null {
return useContract(V1_FACTORY_ADDRESS, IUniswapV1Factory, false)
}
// returns null on errors
export function useTokenContract(tokenAddress?: string, withSignerIfPossible = true): Contract | null {
return useContract(tokenAddress, ERC20_ABI, withSignerIfPossible)
}
export function usePairContract(pairAddress?: string, withSignerIfPossible = true): Contract | null {
return useContract(pairAddress, IUniswapV2PairABI, withSignerIfPossible)
}
export function useCopyClipboard(timeout = 500): [boolean, (toCopy: string) => void] {
const [isCopied, setIsCopied] = useState(false)
const staticCopy = useCallback(text => {
const didCopy = copy(text)
setIsCopied(didCopy)
}, [])
useEffect(() => {
if (isCopied) {
const hide = setTimeout(() => {
setIsCopied(false)
}, timeout)
return () => {
clearTimeout(hide)
}
}
return
}, [isCopied, setIsCopied, timeout])
return [isCopied, staticCopy]
}
// modified from https://usehooks.com/usePrevious/
export function usePrevious<T>(value: T) {
// The ref object is a generic container whose current property is mutable ...
// ... and can hold any value, similar to an instance property on a class
const ref = useRef<T>()
// Store current value in ref
useEffect(() => {
ref.current = value
}, [value]) // Only re-run if value changes
// Return previous value (happens before update in useEffect above)
return ref.current
}
export function useToggle(initialState = false): [boolean, () => void] {
const [state, setState] = useState(initialState)
const toggle = useCallback(() => setState(state => !state), [])
return [state, toggle]
}

View File

@@ -8,7 +8,8 @@ import { Field } from '../state/swap/actions'
import { useTransactionAdder, useHasPendingApproval } from '../state/transactions/hooks'
import { computeSlippageAdjustedAmounts } from '../utils/prices'
import { calculateGasMargin } from '../utils'
import { useTokenContract, useActiveWeb3React } from './index'
import { useTokenContract } from './useContract'
import { useActiveWeb3React } from './index'
export enum ApprovalState {
UNKNOWN,

View File

@@ -0,0 +1,25 @@
import { useCallback, useEffect } from 'react'
// modified from https://usehooks.com/useKeyPress/
export default function useBodyKeyDown(targetKey: string, onKeyDown: () => void, suppressOnKeyDown = false) {
const downHandler = useCallback(
event => {
const {
target: { tagName },
key
} = event
if (key === targetKey && tagName === 'BODY' && !suppressOnKeyDown) {
event.preventDefault()
onKeyDown()
}
},
[targetKey, onKeyDown, suppressOnKeyDown]
)
useEffect(() => {
window.addEventListener('keydown', downHandler)
return () => {
window.removeEventListener('keydown', downHandler)
}
}, [downHandler])
}

42
src/hooks/useContract.ts Normal file
View File

@@ -0,0 +1,42 @@
import { Contract } from '@ethersproject/contracts'
import { ChainId } from '@uniswap/sdk'
import { abi as IUniswapV2PairABI } from '@uniswap/v2-core/build/IUniswapV2Pair.json'
import { useMemo } from 'react'
import { V1_FACTORY_ADDRESS } from '../constants'
import ERC20_ABI from '../constants/abis/erc20.json'
import IUniswapV1Factory from '../constants/abis/v1_factory.json'
import { MULTICALL_ABI, MULTICALL_NETWORKS } from '../constants/multicall'
import { getContract } from '../utils'
import { useActiveWeb3React } from './index'
// returns null on errors
function useContract(address?: string, ABI?: any, withSignerIfPossible = true): Contract | null {
const { library, account } = useActiveWeb3React()
return useMemo(() => {
if (!address || !ABI || !library) return null
try {
return getContract(address, ABI, library, withSignerIfPossible && account ? account : undefined)
} catch (error) {
console.error('Failed to get contract', error)
return null
}
}, [address, ABI, library, withSignerIfPossible, account])
}
export function useV1FactoryContract(): Contract | null {
return useContract(V1_FACTORY_ADDRESS, IUniswapV1Factory, false)
}
export function useTokenContract(tokenAddress?: string, withSignerIfPossible = true): Contract | null {
return useContract(tokenAddress, ERC20_ABI, withSignerIfPossible)
}
export function usePairContract(pairAddress?: string, withSignerIfPossible = true): Contract | null {
return useContract(pairAddress, IUniswapV2PairABI, withSignerIfPossible)
}
export function useMulticallContract(): Contract | null {
const { chainId } = useActiveWeb3React()
return useContract(MULTICALL_NETWORKS[chainId as ChainId], MULTICALL_ABI, false)
}

View File

@@ -0,0 +1,26 @@
import copy from 'copy-to-clipboard'
import { useCallback, useEffect, useState } from 'react'
export default function useCopyClipboard(timeout = 500): [boolean, (toCopy: string) => void] {
const [isCopied, setIsCopied] = useState(false)
const staticCopy = useCallback(text => {
const didCopy = copy(text)
setIsCopied(didCopy)
}, [])
useEffect(() => {
if (isCopied) {
const hide = setTimeout(() => {
setIsCopied(false)
}, timeout)
return () => {
clearTimeout(hide)
}
}
return
}, [isCopied, setIsCopied, timeout])
return [isCopied, staticCopy]
}

22
src/hooks/useDebounce.ts Normal file
View File

@@ -0,0 +1,22 @@
import { useEffect, useState } from 'react'
// modified from https://usehooks.com/useDebounce/
export default function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
// Update debounced value after delay
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
// Cancel the timeout if value changes (also on delay change or unmount)
// This is how we prevent debounced value from updating if value is changed ...
// .. within the delay period. Timeout gets cleared and restarted.
return () => {
clearTimeout(handler)
}
}, [value, delay])
return debouncedValue
}

45
src/hooks/useENSName.ts Normal file
View File

@@ -0,0 +1,45 @@
import { useEffect, useState } from 'react'
import { isAddress } from '../utils'
import { useActiveWeb3React } from './index'
/**
* 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): string | null {
const { library } = useActiveWeb3React()
const [ENSName, setENSName] = useState<string | null>(null)
useEffect(() => {
if (!library || !address) return
const validated = isAddress(address)
if (validated) {
let stale = false
library
.lookupAddress(validated)
.then(name => {
if (!stale) {
if (name) {
setENSName(name)
} else {
setENSName(null)
}
}
})
.catch(() => {
if (!stale) {
setENSName(null)
}
})
return () => {
stale = true
setENSName(null)
}
}
return
}, [library, address])
return ENSName
}

View File

@@ -16,6 +16,7 @@ export default function useInterval(callback: () => void, delay: null | number)
}
if (delay !== null) {
tick()
const id = setInterval(tick, delay)
return () => clearInterval(id)
}

View File

@@ -0,0 +1,20 @@
import { useCallback, useEffect, useState } from 'react'
/**
* Returns whether the window is currently visible to the user.
*/
export default function useIsWindowVisible(): boolean {
const [focused, setFocused] = useState<boolean>(true)
const listener = useCallback(() => {
setFocused(document.visibilityState !== 'hidden')
}, [setFocused])
useEffect(() => {
document.addEventListener('visibilitychange', listener)
return () => {
document.removeEventListener('visibilitychange', listener)
}
}, [listener])
return focused
}

16
src/hooks/usePrevious.ts Normal file
View File

@@ -0,0 +1,16 @@
import { useEffect, useRef } from 'react'
// modified from https://usehooks.com/usePrevious/
export default function usePrevious<T>(value: T) {
// The ref object is a generic container whose current property is mutable ...
// ... and can hold any value, similar to an instance property on a class
const ref = useRef<T>()
// Store current value in ref
useEffect(() => {
ref.current = value
}, [value]) // Only re-run if value changes
// Return previous value (happens before update in useEffect above)
return ref.current
}

View File

@@ -6,7 +6,9 @@ import { useTransactionAdder } from '../state/transactions/hooks'
import { useTokenBalanceTreatingWETHasETH } from '../state/wallet/hooks'
import { calculateGasMargin, getSigner, isAddress } from '../utils'
import { useENSName, useTokenContract, useActiveWeb3React } from './index'
import { useTokenContract } from './useContract'
import { useActiveWeb3React } from './index'
import useENSName from './useENSName'
// returns a callback for sending a token amount, treating WETH as ETH
// returns null with invalid arguments

View File

@@ -8,7 +8,8 @@ import { Field } from '../state/swap/actions'
import { useTransactionAdder } from '../state/transactions/hooks'
import { computeSlippageAdjustedAmounts } from '../utils/prices'
import { calculateGasMargin, getRouterContract, isAddress } from '../utils'
import { useENSName, useActiveWeb3React } from './index'
import { useActiveWeb3React } from './index'
import useENSName from './useENSName'
enum SwapType {
EXACT_TOKENS_FOR_TOKENS,

7
src/hooks/useToggle.ts Normal file
View File

@@ -0,0 +1,7 @@
import { useCallback, useState } from 'react'
export default function useToggle(initialState = false): [boolean, () => void] {
const [state, setState] = useState(initialState)
const toggle = useCallback(() => setState(state => !state), [])
return [state, toggle]
}

View File

@@ -12,7 +12,7 @@ import store from './state'
import ApplicationUpdater from './state/application/updater'
import TransactionUpdater from './state/transactions/updater'
import UserUpdater from './state/user/updater'
import WalletUpdater from './state/wallet/updater'
import MulticallUpdater from './state/multicall/updater'
import ThemeProvider, { FixedGlobalStyle, ThemedGlobalStyle } from './theme'
const Web3ProviderNetwork = createWeb3ReactRoot(NetworkContextName)
@@ -37,7 +37,7 @@ function Updaters() {
<UserUpdater />
<ApplicationUpdater />
<TransactionUpdater />
<WalletUpdater />
<MulticallUpdater />
</>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -67,6 +67,10 @@ const BackgroundGradient = styled.div`
}
`
const Marginer = styled.div`
margin-top: 5rem;
`
let Router: React.ComponentType
if (process.env.PUBLIC_URL === '.') {
Router = HashRouter
@@ -99,6 +103,7 @@ export default function App() {
<Route component={RedirectPathToSwapOnly} />
</Switch>
</Web3ReactManager>
<Marginer />
<Footer />
</BodyWrapper>
<BackgroundGradient />

View File

@@ -2,17 +2,15 @@ import React from 'react'
import styled from 'styled-components'
import NavigationTabs from '../components/NavigationTabs'
export const Body = styled.div`
const Body = styled.div`
position: relative;
max-width: 420px;
width: 100%;
/* min-height: 340px; */
background: ${({ theme }) => theme.bg1};
box-shadow: 0px 0px 1px rgba(0, 0, 0, 0.01), 0px 4px 8px rgba(0, 0, 0, 0.04), 0px 16px 24px rgba(0, 0, 0, 0.04),
0px 24px 32px rgba(0, 0, 0, 0.01);
border-radius: 30px;
padding: 1rem;
position: relative;
margin-bottom: 10rem;
`
/**

View File

@@ -3,7 +3,7 @@ import styled, { ThemeContext } from 'styled-components'
import { JSBI, Pair } from '@uniswap/sdk'
import { RouteComponentProps } from 'react-router-dom'
import Question from '../../components/Question'
import Question from '../../components/QuestionHelper'
import SearchModal from '../../components/SearchModal'
import PositionCard from '../../components/PositionCard'
import { useTokenBalances } from '../../state/wallet/hooks'

View File

@@ -4,12 +4,7 @@ import styled from 'styled-components'
export const Wrapper = styled.div`
position: relative;
`
export const FixedBottom = styled.div`
position: absolute;
top: 100px;
width: 100%;
margin-bottom: 80px;
`
export const ClickableText = styled(Text)`
:hover {
cursor: pointer;

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@ import Card, { BlueCard, GreyCard } from '../../components/Card'
import { AutoColumn, ColumnCenter } from '../../components/Column'
import ConfirmationModal from '../../components/ConfirmationModal'
import CurrencyInputPanel from '../../components/CurrencyInputPanel'
import QuestionHelper from '../../components/Question'
import QuestionHelper from '../../components/QuestionHelper'
import { AutoRow, RowBetween, RowFixed } from '../../components/Row'
import AdvancedSwapDetailsDropdown from '../../components/swap/AdvancedSwapDetailsDropdown'
import confirmPriceImpactWithoutFee from '../../components/swap/confirmPriceImpactWithoutFee'
@@ -30,14 +30,20 @@ import { useSendCallback } from '../../hooks/useSendCallback'
import { useSwapCallback } from '../../hooks/useSwapCallback'
import { useWalletModalToggle } from '../../state/application/hooks'
import { Field } from '../../state/swap/actions'
import { useDefaultsFromURL, useDerivedSwapInfo, useSwapActionHandlers, useSwapState } from '../../state/swap/hooks'
import {
useDefaultsFromURLSearch,
useDerivedSwapInfo,
useSwapActionHandlers,
useSwapState
} from '../../state/swap/hooks'
import { useAllTokenBalancesTreatingWETHasETH } from '../../state/wallet/hooks'
import { CursorPointer, TYPE } from '../../theme'
import { computeSlippageAdjustedAmounts, computeTradePriceBreakdown, warningServerity } from '../../utils/prices'
import { computeSlippageAdjustedAmounts, computeTradePriceBreakdown, warningSeverity } from '../../utils/prices'
import AppBody from '../AppBody'
import { PriceSlippageWarningCard } from '../../components/swap/PriceSlippageWarningCard'
export default function Send({ location: { search } }: RouteComponentProps) {
useDefaultsFromURL(search)
useDefaultsFromURLSearch(search)
// text translation
// const { t } = useTranslation()
@@ -173,7 +179,7 @@ export default function Send({ location: { search } }: RouteComponentProps) {
const [showInverted, setShowInverted] = useState<boolean>(false)
// warnings on slippage
const severity = !sendingWithSwap ? 0 : warningServerity(priceImpactWithoutFee)
const severity = !sendingWithSwap ? 0 : warningSeverity(priceImpactWithoutFee)
function modalHeader() {
if (!sendingWithSwap) {
@@ -245,7 +251,7 @@ export default function Send({ location: { search } }: RouteComponentProps) {
const swapState = useSwapState()
function _onTokenSelect(address: string) {
// if no user balance - switch view to a send with swap
const hasBalance = allBalances?.[account]?.[address]?.greaterThan('0') ?? false
const hasBalance = allBalances?.[address]?.greaterThan('0') ?? false
if (!hasBalance) {
onTokenSelection(
Field.INPUT,
@@ -370,7 +376,7 @@ export default function Send({ location: { search } }: RouteComponentProps) {
</ArrowWrapper>
<ButtonSecondary
onClick={() => setSendingWithSwap(false)}
style={{ marginRight: '0px', width: 'fit-content', fontSize: '14px' }}
style={{ marginRight: '0px', width: 'auto', fontSize: '14px' }}
padding={'4px 6px'}
>
Remove Swap
@@ -492,20 +498,26 @@ export default function Send({ location: { search } }: RouteComponentProps) {
)}
<V1TradeLink v1TradeLinkIfBetter={v1TradeLinkIfBetter} />
</BottomGrouping>
{bestTrade && (
<AdvancedSwapDetailsDropdown
trade={bestTrade}
rawSlippage={allowedSlippage}
deadline={deadline}
showAdvanced={showAdvanced}
setShowAdvanced={setShowAdvanced}
priceImpactWithoutFee={priceImpactWithoutFee}
setDeadline={setDeadline}
setRawSlippage={setAllowedSlippage}
/>
)}
</Wrapper>
</AppBody>
{bestTrade && (
<AdvancedSwapDetailsDropdown
trade={bestTrade}
rawSlippage={allowedSlippage}
deadline={deadline}
showAdvanced={showAdvanced}
setShowAdvanced={setShowAdvanced}
setDeadline={setDeadline}
setRawSlippage={setAllowedSlippage}
/>
)}
{priceImpactWithoutFee && severity > 2 && (
<AutoColumn gap="lg" style={{ marginTop: '1rem' }}>
<PriceSlippageWarningCard priceSlippage={priceImpactWithoutFee} />
</AutoColumn>
)}
</>
)
}

View File

@@ -10,7 +10,7 @@ import Card, { GreyCard } from '../../components/Card'
import { AutoColumn } from '../../components/Column'
import ConfirmationModal from '../../components/ConfirmationModal'
import CurrencyInputPanel from '../../components/CurrencyInputPanel'
import QuestionHelper from '../../components/Question'
import QuestionHelper from '../../components/QuestionHelper'
import { RowBetween, RowFixed } from '../../components/Row'
import AdvancedSwapDetailsDropdown from '../../components/swap/AdvancedSwapDetailsDropdown'
import confirmPriceImpactWithoutFee from '../../components/swap/confirmPriceImpactWithoutFee'
@@ -27,23 +27,30 @@ import { useApproveCallbackFromTrade, ApprovalState } from '../../hooks/useAppro
import { useSwapCallback } from '../../hooks/useSwapCallback'
import { useWalletModalToggle } from '../../state/application/hooks'
import { Field } from '../../state/swap/actions'
import { useDefaultsFromURL, useDerivedSwapInfo, useSwapActionHandlers, useSwapState } from '../../state/swap/hooks'
import {
useDefaultsFromURLSearch,
useDerivedSwapInfo,
useSwapActionHandlers,
useSwapState
} from '../../state/swap/hooks'
import { CursorPointer, TYPE } from '../../theme'
import { computeSlippageAdjustedAmounts, computeTradePriceBreakdown, warningServerity } from '../../utils/prices'
import { computeSlippageAdjustedAmounts, computeTradePriceBreakdown, warningSeverity } from '../../utils/prices'
import AppBody from '../AppBody'
import { PriceSlippageWarningCard } from '../../components/swap/PriceSlippageWarningCard'
export default function Swap({ location: { search } }: RouteComponentProps) {
useDefaultsFromURL(search)
// text translation
// const { t } = useTranslation()
useDefaultsFromURLSearch(search)
const { chainId, account } = useActiveWeb3React()
const theme = useContext(ThemeContext)
// toggle wallet when disconnected
const toggleWalletModal = useWalletModalToggle()
// swap state
const { independentField, typedValue } = useSwapState()
const { bestTrade, tokenBalances, parsedAmounts, tokens, error, v1TradeLinkIfBetter } = useDerivedSwapInfo()
const { onSwitchTokens, onTokenSelection, onUserInput } = useSwapActionHandlers()
const isValid = !error
const dependentField: Field = independentField === Field.INPUT ? Field.OUTPUT : Field.INPUT
@@ -58,6 +65,11 @@ export default function Swap({ location: { search } }: RouteComponentProps) {
const [deadline, setDeadline] = useState<number>(DEFAULT_DEADLINE_FROM_NOW)
const [allowedSlippage, setAllowedSlippage] = useState<number>(INITIAL_ALLOWED_SLIPPAGE)
const formattedAmounts = {
[independentField]: typedValue,
[dependentField]: parsedAmounts[dependentField] ? parsedAmounts[dependentField].toSignificant(6) : ''
}
const route = bestTrade?.route
const userHasSpecifiedInputOutput =
!!tokens[Field.INPUT] &&
@@ -69,13 +81,6 @@ export default function Swap({ location: { search } }: RouteComponentProps) {
// check whether the user has approved the router on the input token
const [approval, approveCallback] = useApproveCallbackFromTrade(bestTrade, allowedSlippage)
const formattedAmounts = {
[independentField]: typedValue,
[dependentField]: parsedAmounts[dependentField] ? parsedAmounts[dependentField].toSignificant(6) : ''
}
const { onSwitchTokens, onTokenSelection, onUserInput } = useSwapActionHandlers()
const maxAmountInput: TokenAmount =
!!tokenBalances[Field.INPUT] &&
!!tokens[Field.INPUT] &&
@@ -88,7 +93,7 @@ export default function Swap({ location: { search } }: RouteComponentProps) {
: tokenBalances[Field.INPUT]
: undefined
const atMaxAmountInput: boolean =
!!maxAmountInput && !!parsedAmounts[Field.INPUT] ? maxAmountInput.equalTo(parsedAmounts[Field.INPUT]) : undefined
maxAmountInput && parsedAmounts[Field.INPUT] ? maxAmountInput.equalTo(parsedAmounts[Field.INPUT]) : undefined
const slippageAdjustedAmounts = computeSlippageAdjustedAmounts(bestTrade, allowedSlippage)
@@ -130,7 +135,7 @@ export default function Swap({ location: { search } }: RouteComponentProps) {
const [showInverted, setShowInverted] = useState<boolean>(false)
// warnings on slippage
const priceImpactSeverity = warningServerity(priceImpactWithoutFee)
const priceImpactSeverity = warningSeverity(priceImpactWithoutFee)
function modalHeader() {
return (
@@ -259,13 +264,7 @@ export default function Swap({ location: { search } }: RouteComponentProps) {
</AutoColumn>
<BottomGrouping>
{!account ? (
<ButtonLight
onClick={() => {
toggleWalletModal()
}}
>
Connect Wallet
</ButtonLight>
<ButtonLight onClick={toggleWalletModal}>Connect Wallet</ButtonLight>
) : noRoute && userHasSpecifiedInputOutput ? (
<GreyCard style={{ textAlign: 'center' }}>
<TYPE.main mb="4px">Insufficient liquidity for this trade.</TYPE.main>
@@ -294,20 +293,26 @@ export default function Swap({ location: { search } }: RouteComponentProps) {
)}
<V1TradeLink v1TradeLinkIfBetter={v1TradeLinkIfBetter} />
</BottomGrouping>
{bestTrade && (
<AdvancedSwapDetailsDropdown
trade={bestTrade}
rawSlippage={allowedSlippage}
deadline={deadline}
showAdvanced={showAdvanced}
setShowAdvanced={setShowAdvanced}
priceImpactWithoutFee={priceImpactWithoutFee}
setDeadline={setDeadline}
setRawSlippage={setAllowedSlippage}
/>
)}
</Wrapper>
</AppBody>
{bestTrade && (
<AdvancedSwapDetailsDropdown
trade={bestTrade}
rawSlippage={allowedSlippage}
deadline={deadline}
showAdvanced={showAdvanced}
setShowAdvanced={setShowAdvanced}
setDeadline={setDeadline}
setRawSlippage={setAllowedSlippage}
/>
)}
{priceImpactWithoutFee && priceImpactSeverity > 2 && (
<AutoColumn gap="lg" style={{ marginTop: '1rem' }}>
<PriceSlippageWarningCard priceSlippage={priceImpactWithoutFee} />
</AutoColumn>
)}
</>
)
}

View File

@@ -1,5 +1,7 @@
import { useEffect, useState } from 'react'
import { useDebounce, useActiveWeb3React } from '../../hooks'
import { useActiveWeb3React } from '../../hooks'
import useDebounce from '../../hooks/useDebounce'
import useIsWindowVisible from '../../hooks/useIsWindowVisible'
import { updateBlockNumber } from './actions'
import { useDispatch } from 'react-redux'
@@ -7,6 +9,7 @@ export default function Updater() {
const { library, chainId } = useActiveWeb3React()
const dispatch = useDispatch()
const windowVisible = useIsWindowVisible()
const [maxBlockNumber, setMaxBlockNumber] = useState<number | null>(null)
// because blocks arrive in bunches with longer polling periods, we just want
// to process the latest one.
@@ -38,8 +41,10 @@ export default function Updater() {
useEffect(() => {
if (!chainId || !debouncedMaxBlockNumber) return
dispatch(updateBlockNumber({ chainId, blockNumber: debouncedMaxBlockNumber }))
}, [chainId, debouncedMaxBlockNumber, dispatch])
if (windowVisible) {
dispatch(updateBlockNumber({ chainId, blockNumber: debouncedMaxBlockNumber }))
}
}, [chainId, debouncedMaxBlockNumber, windowVisible, dispatch])
return null
}

10
src/state/burn/actions.ts Normal file
View File

@@ -0,0 +1,10 @@
import { createAction } from '@reduxjs/toolkit'
export enum Field {
LIQUIDITY_PERCENT = 'LIQUIDITY_PERCENT',
LIQUIDITY = 'LIQUIDITY',
TOKEN_A = 'TOKEN_A',
TOKEN_B = 'TOKEN_B'
}
export const typeInput = createAction<{ field: Field; typedValue: string }>('typeInputBurn')

187
src/state/burn/hooks.ts Normal file
View File

@@ -0,0 +1,187 @@
import { useEffect, useCallback, useMemo } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useActiveWeb3React } from '../../hooks'
import { AppDispatch, AppState } from '../index'
import { Field, typeInput } from './actions'
import { setDefaultsFromURLMatchParams } from '../mint/actions'
import { useTokenByAddressAndAutomaticallyAdd } from '../../hooks/Tokens'
import { Token, Pair, TokenAmount, Percent, JSBI, Route } from '@uniswap/sdk'
import { usePair } from '../../data/Reserves'
import { useTokenBalances } from '../wallet/hooks'
import { tryParseAmount } from '../swap/hooks'
import { useTotalSupply } from '../../data/TotalSupply'
const ZERO = JSBI.BigInt(0)
export function useBurnState(): AppState['burn'] {
return useSelector<AppState, AppState['burn']>(state => state.burn)
}
export function useDerivedBurnInfo(): {
tokens: { [field in Extract<Field, Field.TOKEN_A | Field.TOKEN_B>]?: Token }
pair?: Pair | null
route?: Route
parsedAmounts: {
[Field.LIQUIDITY_PERCENT]: Percent
[Field.LIQUIDITY]?: TokenAmount
[Field.TOKEN_A]?: TokenAmount
[Field.TOKEN_B]?: TokenAmount
}
error?: string
} {
const { account } = useActiveWeb3React()
const {
independentField,
typedValue,
[Field.TOKEN_A]: { address: tokenAAddress },
[Field.TOKEN_B]: { address: tokenBAddress }
} = useBurnState()
// tokens
const tokenA = useTokenByAddressAndAutomaticallyAdd(tokenAAddress)
const tokenB = useTokenByAddressAndAutomaticallyAdd(tokenBAddress)
const tokens: { [field in Extract<Field, Field.TOKEN_A | Field.TOKEN_B>]?: Token } = useMemo(
() => ({
[Field.TOKEN_A]: tokenA,
[Field.TOKEN_B]: tokenB
}),
[tokenA, tokenB]
)
// pair + totalsupply
const pair = usePair(tokens[Field.TOKEN_A], tokens[Field.TOKEN_B])
const noLiquidity =
pair === null || (!!pair && JSBI.equal(pair.reserve0.raw, ZERO) && JSBI.equal(pair.reserve1.raw, ZERO))
// route
const route =
!noLiquidity && pair && tokens[Field.TOKEN_A] ? new Route([pair], tokens[Field.TOKEN_A] as Token) : undefined
// balances
const relevantTokenBalances = useTokenBalances(account ?? undefined, [pair?.liquidityToken])
const userLiquidity: undefined | TokenAmount = relevantTokenBalances?.[pair?.liquidityToken?.address ?? '']
// liquidity values
const totalSupply = useTotalSupply(pair?.liquidityToken)
const liquidityValues: { [field in Extract<Field, Field.TOKEN_A | Field.TOKEN_B>]?: TokenAmount } = {
[Field.TOKEN_A]:
pair &&
tokens[Field.TOKEN_A] &&
totalSupply &&
userLiquidity &&
// this condition is a short-circuit in the case where useTokenBalance updates sooner than useTotalSupply
JSBI.greaterThanOrEqual(totalSupply.raw, userLiquidity.raw)
? new TokenAmount(
tokens[Field.TOKEN_A] as Token,
pair.getLiquidityValue(tokens[Field.TOKEN_A] as Token, totalSupply, userLiquidity, false).raw
)
: undefined,
[Field.TOKEN_B]:
pair &&
tokens[Field.TOKEN_B] &&
totalSupply &&
userLiquidity &&
// this condition is a short-circuit in the case where useTokenBalance updates sooner than useTotalSupply
JSBI.greaterThanOrEqual(totalSupply.raw, userLiquidity.raw)
? new TokenAmount(
tokens[Field.TOKEN_B] as Token,
pair.getLiquidityValue(tokens[Field.TOKEN_B] as Token, totalSupply, userLiquidity, false).raw
)
: undefined
}
let percentToRemove: Percent = new Percent('0', '100')
// user specified a %
if (independentField === Field.LIQUIDITY_PERCENT) {
percentToRemove = new Percent(typedValue, '100')
}
// user specified a specific amount of liquidity tokens
else if (independentField === Field.LIQUIDITY) {
if (pair?.liquidityToken) {
const independentAmount = tryParseAmount(typedValue, pair.liquidityToken)
if (independentAmount && userLiquidity && !independentAmount.greaterThan(userLiquidity)) {
percentToRemove = new Percent(independentAmount.raw, userLiquidity.raw)
}
}
}
// user specified a specific amount of token a or b
else {
if (tokens[independentField]) {
const independentAmount = tryParseAmount(typedValue, tokens[independentField])
if (
independentAmount &&
liquidityValues[independentField] &&
!independentAmount.greaterThan(liquidityValues[independentField] as TokenAmount)
) {
percentToRemove = new Percent(independentAmount.raw, (liquidityValues[independentField] as TokenAmount).raw)
}
}
}
const parsedAmounts: {
[Field.LIQUIDITY_PERCENT]: Percent
[Field.LIQUIDITY]?: TokenAmount
[Field.TOKEN_A]?: TokenAmount
[Field.TOKEN_B]?: TokenAmount
} = {
[Field.LIQUIDITY_PERCENT]: percentToRemove,
[Field.LIQUIDITY]:
userLiquidity && percentToRemove && percentToRemove.greaterThan('0')
? new TokenAmount(userLiquidity.token, percentToRemove.multiply(userLiquidity.raw).quotient)
: undefined,
[Field.TOKEN_A]:
tokens[Field.TOKEN_A] && percentToRemove && percentToRemove.greaterThan('0') && liquidityValues[Field.TOKEN_A]
? new TokenAmount(
tokens[Field.TOKEN_A] as Token,
percentToRemove.multiply((liquidityValues[Field.TOKEN_A] as TokenAmount).raw).quotient
)
: undefined,
[Field.TOKEN_B]:
tokens[Field.TOKEN_B] && percentToRemove && percentToRemove.greaterThan('0') && liquidityValues[Field.TOKEN_B]
? new TokenAmount(
tokens[Field.TOKEN_B] as Token,
percentToRemove.multiply((liquidityValues[Field.TOKEN_B] as TokenAmount).raw).quotient
)
: undefined
}
let error: string | undefined
if (!account) {
error = 'Connect Wallet'
}
if (!parsedAmounts[Field.LIQUIDITY] || !parsedAmounts[Field.TOKEN_A] || !parsedAmounts[Field.TOKEN_B]) {
error = error ?? 'Enter an amount'
}
return { tokens, pair, route, parsedAmounts, error }
}
export function useBurnActionHandlers(): {
onUserInput: (field: Field, typedValue: string) => void
} {
const dispatch = useDispatch<AppDispatch>()
const onUserInput = useCallback(
(field: Field, typedValue: string) => {
dispatch(typeInput({ field, typedValue }))
},
[dispatch]
)
return {
onUserInput
}
}
// updates the burn state to use the appropriate tokens, given the route
export function useDefaultsFromURLMatchParams(params: { [k: string]: string }) {
const { chainId } = useActiveWeb3React()
const dispatch = useDispatch<AppDispatch>()
useEffect(() => {
if (!chainId) return
dispatch(setDefaultsFromURLMatchParams({ chainId, params }))
}, [dispatch, chainId, params])
}

51
src/state/burn/reducer.ts Normal file
View File

@@ -0,0 +1,51 @@
import { createReducer } from '@reduxjs/toolkit'
import { Field, typeInput } from './actions'
import { setDefaultsFromURLMatchParams } from '../mint/actions'
import { parseTokens } from '../mint/reducer'
export interface MintState {
readonly independentField: Field
readonly typedValue: string
readonly [Field.TOKEN_A]: {
readonly address: string
}
readonly [Field.TOKEN_B]: {
readonly address: string
}
}
const initialState: MintState = {
independentField: Field.LIQUIDITY_PERCENT,
typedValue: '0',
[Field.TOKEN_A]: {
address: ''
},
[Field.TOKEN_B]: {
address: ''
}
}
export default createReducer<MintState>(initialState, builder =>
builder
.addCase(setDefaultsFromURLMatchParams, (state, { payload: { chainId, params } }) => {
const tokens = parseTokens(chainId, params?.tokens ?? '')
return {
independentField: Field.LIQUIDITY_PERCENT,
typedValue: '0',
[Field.TOKEN_A]: {
address: tokens[0]
},
[Field.TOKEN_B]: {
address: tokens[1]
}
}
})
.addCase(typeInput, (state, { payload: { field, typedValue } }) => {
return {
...state,
independentField: field,
typedValue
}
})
)

View File

@@ -1,12 +1,16 @@
import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit'
import application from './application/reducer'
import { updateVersion } from './user/actions'
import user from './user/reducer'
import wallet from './wallet/reducer'
import swap from './swap/reducer'
import transactions from './transactions/reducer'
import { save, load } from 'redux-localstorage-simple'
import application from './application/reducer'
import user from './user/reducer'
import transactions from './transactions/reducer'
import swap from './swap/reducer'
import mint from './mint/reducer'
import burn from './burn/reducer'
import multicall from './multicall/reducer'
import { updateVersion } from './user/actions'
const PERSISTED_KEYS: string[] = ['user', 'transactions']
const store = configureStore({
@@ -14,8 +18,10 @@ const store = configureStore({
application,
user,
transactions,
wallet,
swap
swap,
mint,
burn,
multicall
},
middleware: [...getDefaultMiddleware(), save({ states: PERSISTED_KEYS })],
preloadedState: load({ states: PERSISTED_KEYS })

13
src/state/mint/actions.ts Normal file
View File

@@ -0,0 +1,13 @@
import { createAction } from '@reduxjs/toolkit'
import { RouteComponentProps } from 'react-router-dom'
export enum Field {
TOKEN_A = 'TOKEN_A',
TOKEN_B = 'TOKEN_B'
}
export const setDefaultsFromURLMatchParams = createAction<{
chainId: number
params: RouteComponentProps<{ [k: string]: string }>['match']['params']
}>('setDefaultsFromMatch')
export const typeInput = createAction<{ field: Field; typedValue: string; noLiquidity: boolean }>('typeInputMint')

202
src/state/mint/hooks.ts Normal file
View File

@@ -0,0 +1,202 @@
import { useEffect, useCallback, useMemo } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { Token, TokenAmount, Route, JSBI, Price, Percent, Pair } from '@uniswap/sdk'
import { useActiveWeb3React } from '../../hooks'
import { AppDispatch, AppState } from '../index'
import { setDefaultsFromURLMatchParams, Field, typeInput } from './actions'
import { useTokenByAddressAndAutomaticallyAdd } from '../../hooks/Tokens'
import { useTokenBalancesTreatWETHAsETH } from '../wallet/hooks'
import { usePair } from '../../data/Reserves'
import { useTotalSupply } from '../../data/TotalSupply'
import { tryParseAmount } from '../swap/hooks'
const ZERO = JSBI.BigInt(0)
export function useMintState(): AppState['mint'] {
return useSelector<AppState, AppState['mint']>(state => state.mint)
}
export function useDerivedMintInfo(): {
dependentField: Field
tokens: { [field in Field]?: Token }
pair?: Pair | null
tokenBalances: { [field in Field]?: TokenAmount }
parsedAmounts: { [field in Field]?: TokenAmount }
price?: Price
noLiquidity?: boolean
liquidityMinted?: TokenAmount
poolTokenPercentage?: Percent
error?: string
} {
const { account } = useActiveWeb3React()
const {
independentField,
typedValue,
otherTypedValue,
[Field.TOKEN_A]: { address: tokenAAddress },
[Field.TOKEN_B]: { address: tokenBAddress }
} = useMintState()
const dependentField = independentField === Field.TOKEN_A ? Field.TOKEN_B : Field.TOKEN_A
// tokens
const tokenA = useTokenByAddressAndAutomaticallyAdd(tokenAAddress)
const tokenB = useTokenByAddressAndAutomaticallyAdd(tokenBAddress)
const tokens: { [field in Field]?: Token } = useMemo(
() => ({
[Field.TOKEN_A]: tokenA,
[Field.TOKEN_B]: tokenB
}),
[tokenA, tokenB]
)
// pair
const pair = usePair(tokens[Field.TOKEN_A], tokens[Field.TOKEN_B])
const noLiquidity =
pair === null || (!!pair && JSBI.equal(pair.reserve0.raw, ZERO) && JSBI.equal(pair.reserve1.raw, ZERO))
// route
const route = useMemo(
() =>
!noLiquidity && pair && tokens[independentField] ? new Route([pair], tokens[Field.TOKEN_A] as Token) : undefined,
[noLiquidity, pair, tokens, independentField]
)
// balances
const relevantTokenBalances = useTokenBalancesTreatWETHAsETH(account ?? undefined, [
tokens[Field.TOKEN_A],
tokens[Field.TOKEN_B]
])
const tokenBalances: { [field in Field]?: TokenAmount } = {
[Field.TOKEN_A]: relevantTokenBalances?.[tokens[Field.TOKEN_A]?.address ?? ''],
[Field.TOKEN_B]: relevantTokenBalances?.[tokens[Field.TOKEN_B]?.address ?? '']
}
// amounts
const independentAmount = tryParseAmount(typedValue, tokens[independentField])
const dependentAmount = useMemo(() => {
if (noLiquidity && otherTypedValue && tokens[dependentField]) {
return tryParseAmount(otherTypedValue, tokens[dependentField])
} else if (route && independentAmount) {
return dependentField === Field.TOKEN_B
? route.midPrice.quote(independentAmount)
: route.midPrice.invert().quote(independentAmount)
} else {
return
}
}, [noLiquidity, otherTypedValue, tokens, dependentField, independentAmount, route])
const parsedAmounts = {
[Field.TOKEN_A]: independentField === Field.TOKEN_A ? independentAmount : dependentAmount,
[Field.TOKEN_B]: independentField === Field.TOKEN_A ? dependentAmount : independentAmount
}
const price = useMemo(() => {
if (
noLiquidity &&
tokens[Field.TOKEN_A] &&
tokens[Field.TOKEN_B] &&
parsedAmounts[Field.TOKEN_A] &&
parsedAmounts[Field.TOKEN_B]
) {
return new Price(
tokens[Field.TOKEN_A] as Token,
tokens[Field.TOKEN_B] as Token,
(parsedAmounts[Field.TOKEN_A] as TokenAmount).raw,
(parsedAmounts[Field.TOKEN_B] as TokenAmount).raw
)
} else if (route) {
return route.midPrice
} else {
return
}
}, [noLiquidity, tokens, parsedAmounts, route])
// liquidity minted
const totalSupply = useTotalSupply(pair?.liquidityToken)
const liquidityMinted = useMemo(() => {
if (pair && totalSupply && parsedAmounts[Field.TOKEN_A] && parsedAmounts[Field.TOKEN_B]) {
return pair.getLiquidityMinted(
totalSupply,
parsedAmounts[Field.TOKEN_A] as TokenAmount,
parsedAmounts[Field.TOKEN_B] as TokenAmount
)
} else {
return
}
}, [pair, totalSupply, parsedAmounts])
const poolTokenPercentage = useMemo(() => {
if (liquidityMinted && totalSupply) {
return new Percent(liquidityMinted.raw, totalSupply.add(liquidityMinted).raw)
} else {
return
}
}, [liquidityMinted, totalSupply])
let error: string | undefined
if (!account) {
error = 'Connect Wallet'
}
if (!parsedAmounts[Field.TOKEN_A] || !parsedAmounts[Field.TOKEN_B]) {
error = error ?? 'Enter an amount'
}
if (
parsedAmounts[Field.TOKEN_A] &&
tokenBalances?.[Field.TOKEN_A]?.lessThan(parsedAmounts[Field.TOKEN_A] as TokenAmount)
) {
error = 'Insufficient ' + tokens[Field.TOKEN_A]?.symbol + ' balance'
}
if (
parsedAmounts[Field.TOKEN_B] &&
tokenBalances?.[Field.TOKEN_B]?.lessThan(parsedAmounts[Field.TOKEN_B] as TokenAmount)
) {
error = 'Insufficient ' + tokens[Field.TOKEN_B]?.symbol + ' balance'
}
return {
dependentField,
tokens,
pair,
tokenBalances,
parsedAmounts,
price,
noLiquidity,
liquidityMinted,
poolTokenPercentage,
error
}
}
export function useMintActionHandlers(): {
onUserInput: (field: Field, typedValue: string) => void
} {
const dispatch = useDispatch<AppDispatch>()
const { noLiquidity } = useDerivedMintInfo()
const onUserInput = useCallback(
(field: Field, typedValue: string) => {
dispatch(typeInput({ field, typedValue, noLiquidity: noLiquidity === true ? true : false }))
},
[dispatch, noLiquidity]
)
return {
onUserInput
}
}
// updates the mint state to use the appropriate tokens, given the route
export function useDefaultsFromURLMatchParams(params: { [k: string]: string }) {
const { chainId } = useActiveWeb3React()
const dispatch = useDispatch<AppDispatch>()
useEffect(() => {
if (!chainId) return
dispatch(setDefaultsFromURLMatchParams({ chainId, params }))
}, [dispatch, chainId, params])
}

View File

@@ -0,0 +1,40 @@
import { ChainId, WETH } from '@uniswap/sdk'
import { createStore, Store } from 'redux'
import { Field, setDefaultsFromURLMatchParams } from './actions'
import reducer, { MintState } from './reducer'
describe('mint reducer', () => {
let store: Store<MintState>
beforeEach(() => {
store = createStore(reducer, {
independentField: Field.TOKEN_A,
typedValue: '',
otherTypedValue: '',
[Field.TOKEN_A]: { address: '' },
[Field.TOKEN_B]: { address: '' }
})
})
describe('setDefaultsFromURLMatchParams', () => {
test('ETH to DAI', () => {
store.dispatch(
setDefaultsFromURLMatchParams({
chainId: ChainId.MAINNET,
params: {
tokens: 'ETH-0x6b175474e89094c44da98b954eedeac495271d0f'
}
})
)
expect(store.getState()).toEqual({
independentField: Field.TOKEN_A,
typedValue: '',
otherTypedValue: '',
[Field.TOKEN_A]: { address: WETH[ChainId.MAINNET].address },
[Field.TOKEN_B]: { address: '0x6b175474e89094c44da98b954eedeac495271d0f' }
})
})
})
})

97
src/state/mint/reducer.ts Normal file
View File

@@ -0,0 +1,97 @@
import { createReducer } from '@reduxjs/toolkit'
import { ChainId, WETH } from '@uniswap/sdk'
import { isAddress } from '../../utils'
import { Field, setDefaultsFromURLMatchParams, typeInput } from './actions'
export interface MintState {
readonly independentField: Field
readonly typedValue: string
readonly otherTypedValue: string // for the case when there's no liquidity
readonly [Field.TOKEN_A]: {
readonly address: string
}
readonly [Field.TOKEN_B]: {
readonly address: string
}
}
const initialState: MintState = {
independentField: Field.TOKEN_A,
typedValue: '',
otherTypedValue: '',
[Field.TOKEN_A]: {
address: ''
},
[Field.TOKEN_B]: {
address: ''
}
}
export function parseTokens(chainId: number, tokens: string): string[] {
return (
tokens
// split by '-'
.split('-')
// map to addresses
.map((token): string =>
isAddress(token)
? token
: token.toLowerCase() === 'ETH'.toLowerCase()
? WETH[chainId as ChainId]?.address ?? ''
: ''
)
//remove duplicates
.filter((token, i, array) => array.indexOf(token) === i)
// add two empty elements for cases where the array is length 0
.concat(['', ''])
// only consider the first 2 elements
.slice(0, 2)
)
}
export default createReducer<MintState>(initialState, builder =>
builder
.addCase(setDefaultsFromURLMatchParams, (state, { payload: { chainId, params } }) => {
const tokens = parseTokens(chainId, params?.tokens ?? '')
return {
independentField: Field.TOKEN_A,
typedValue: '',
otherTypedValue: '',
[Field.TOKEN_A]: {
address: tokens[0]
},
[Field.TOKEN_B]: {
address: tokens[1]
}
}
})
.addCase(typeInput, (state, { payload: { field, typedValue, noLiquidity } }) => {
if (noLiquidity) {
// they're typing into the field they've last typed in
if (field === state.independentField) {
return {
...state,
independentField: field,
typedValue
}
}
// they're typing into a new field, store the other value
else {
return {
...state,
independentField: field,
typedValue,
otherTypedValue: state.typedValue
}
}
} else {
return {
...state,
independentField: field,
typedValue,
otherTypedValue: ''
}
}
})
)

View File

@@ -0,0 +1,41 @@
import { createAction } from '@reduxjs/toolkit'
import { isAddress } from '../../utils'
export interface Call {
address: string
callData: string
}
export function toCallKey(call: Call): string {
return `${call.address}-${call.callData}`
}
export function parseCallKey(callKey: string): Call {
const pcs = callKey.split('-')
if (pcs.length !== 2) {
throw new Error(`Invalid call key: ${callKey}`)
}
const addr = isAddress(pcs[0])
if (!addr) {
throw new Error(`Invalid address: ${pcs[0]}`)
}
if (!pcs[1].match(/^0x[a-fA-F0-9]*$/)) {
throw new Error(`Invalid hex: ${pcs[1]}`)
}
return {
address: pcs[0],
callData: pcs[1]
}
}
export const addMulticallListeners = createAction<{ chainId: number; calls: Call[] }>('addMulticallListeners')
export const removeMulticallListeners = createAction<{ chainId: number; calls: Call[] }>('removeMulticallListeners')
export const updateMulticallResults = createAction<{
chainId: number
blockNumber: number
results: {
[callKey: string]: string | null
}
}>('updateMulticallResults')

View File

@@ -0,0 +1,174 @@
import { Interface } from '@ethersproject/abi'
import { BigNumber } from '@ethersproject/bignumber'
import { Contract } from '@ethersproject/contracts'
import { useEffect, useMemo } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useActiveWeb3React } from '../../hooks'
import useDebounce from '../../hooks/useDebounce'
import { AppDispatch, AppState } from '../index'
import { addMulticallListeners, Call, removeMulticallListeners, parseCallKey, toCallKey } from './actions'
export interface Result extends ReadonlyArray<any> {
readonly [key: string]: any
}
type MethodArg = string | number | BigNumber
type MethodArgs = Array<MethodArg | MethodArg[]>
type OptionalMethodInputs = Array<MethodArg | MethodArg[] | undefined> | undefined
function isMethodArg(x: unknown): x is MethodArg {
return ['string', 'number'].indexOf(typeof x) !== -1
}
function isValidMethodArgs(x: unknown): x is MethodArgs | undefined {
return (
x === undefined || (Array.isArray(x) && x.every(y => isMethodArg(y) || (Array.isArray(y) && y.every(isMethodArg))))
)
}
// the lowest level call for subscribing to contract data
function useCallsData(calls: (Call | undefined)[]): (string | undefined)[] {
const { chainId } = useActiveWeb3React()
const callResults = useSelector<AppState, AppState['multicall']['callResults']>(state => state.multicall.callResults)
const dispatch = useDispatch<AppDispatch>()
const serializedCallKeys: string = useMemo(
() =>
JSON.stringify(
calls
?.filter((c): c is Call => Boolean(c))
?.map(toCallKey)
?.sort() ?? []
),
[calls]
)
const debouncedSerializedCallKeys = useDebounce(serializedCallKeys, 20)
// update listeners when there is an actual change that persists for at least 100ms
useEffect(() => {
const callKeys: string[] = JSON.parse(debouncedSerializedCallKeys)
if (!chainId || callKeys.length === 0) return
const calls = callKeys.map(key => parseCallKey(key))
dispatch(
addMulticallListeners({
chainId,
calls
})
)
return () => {
dispatch(
removeMulticallListeners({
chainId,
calls
})
)
}
}, [chainId, dispatch, debouncedSerializedCallKeys])
return useMemo(() => {
return calls.map<string | undefined>(call => {
if (!chainId || !call) return undefined
const result = callResults[chainId]?.[toCallKey(call)]
if (!result || !result.data || result.data === '0x') {
return undefined
}
return result.data
})
}, [callResults, calls, chainId])
}
export function useSingleContractMultipleData(
contract: Contract | null | undefined,
methodName: string,
callInputs: OptionalMethodInputs[]
): (Result | undefined)[] {
const fragment = useMemo(() => contract?.interface?.getFunction(methodName), [contract, methodName])
const calls = useMemo(
() =>
contract && fragment && callInputs && callInputs.length > 0
? callInputs.map<Call>(inputs => {
return {
address: contract.address,
callData: contract.interface.encodeFunctionData(fragment, inputs)
}
})
: [],
[callInputs, contract, fragment]
)
const data = useCallsData(calls)
return useMemo(() => {
if (!fragment || !contract) return []
return data.map(data => (data ? contract.interface.decodeFunctionResult(fragment, data) : undefined))
}, [contract, data, fragment])
}
export function useMultipleContractSingleData(
addresses: (string | undefined)[],
contractInterface: Interface,
methodName: string,
callInputs?: OptionalMethodInputs
): (Result | undefined)[] {
const fragment = useMemo(() => contractInterface.getFunction(methodName), [contractInterface, methodName])
const callData: string | undefined = useMemo(
() =>
fragment && isValidMethodArgs(callInputs)
? contractInterface.encodeFunctionData(fragment, callInputs)
: undefined,
[callInputs, contractInterface, fragment]
)
const calls = useMemo(
() =>
fragment && addresses && addresses.length > 0 && callData
? addresses.map<Call | undefined>(address => {
return address && callData
? {
address,
callData
}
: undefined
})
: [],
[addresses, callData, fragment]
)
const data = useCallsData(calls)
return useMemo(() => {
if (!fragment) return []
return data.map(data => (data ? contractInterface.decodeFunctionResult(fragment, data) : undefined))
}, [contractInterface, data, fragment])
}
export function useSingleCallResult(
contract: Contract | null | undefined,
methodName: string,
inputs?: OptionalMethodInputs
): Result | undefined {
const fragment = useMemo(() => contract?.interface?.getFunction(methodName), [contract, methodName])
const calls = useMemo<Call[]>(() => {
return contract && fragment && isValidMethodArgs(inputs)
? [
{
address: contract.address,
callData: contract.interface.encodeFunctionData(fragment, inputs)
}
]
: []
}, [contract, fragment, inputs])
const data = useCallsData(calls)[0]
return useMemo(() => {
if (!contract || !fragment || !data) return undefined
return contract.interface.decodeFunctionResult(fragment, data)
}, [data, fragment, contract])
}

View File

@@ -0,0 +1,57 @@
import { createReducer } from '@reduxjs/toolkit'
import { addMulticallListeners, removeMulticallListeners, toCallKey, updateMulticallResults } from './actions'
interface MulticallState {
callListeners: {
[chainId: number]: {
[callKey: string]: number
}
}
callResults: {
[chainId: number]: {
[callKey: string]: {
data: string | null
blockNumber: number
}
}
}
}
const initialState: MulticallState = {
callListeners: {},
callResults: {}
}
export default createReducer(initialState, builder =>
builder
.addCase(addMulticallListeners, (state, { payload: { calls, chainId } }) => {
state.callListeners[chainId] = state.callListeners[chainId] ?? {}
calls.forEach(call => {
const callKey = toCallKey(call)
state.callListeners[chainId][callKey] = (state.callListeners[chainId][callKey] ?? 0) + 1
})
})
.addCase(removeMulticallListeners, (state, { payload: { chainId, calls } }) => {
if (!state.callListeners[chainId]) return
calls.forEach(call => {
const callKey = toCallKey(call)
if (state.callListeners[chainId][callKey] === 1) {
delete state.callListeners[chainId][callKey]
} else {
state.callListeners[chainId][callKey]--
}
})
})
.addCase(updateMulticallResults, (state, { payload: { chainId, results, blockNumber } }) => {
state.callResults[chainId] = state.callResults[chainId] ?? {}
Object.keys(results).forEach(callKey => {
const current = state.callResults[chainId][callKey]
if (current && current.blockNumber > blockNumber) return
state.callResults[chainId][callKey] = {
data: results[callKey],
blockNumber
}
})
})
)

View File

@@ -0,0 +1,81 @@
import { BigNumber } from '@ethersproject/bignumber'
import { useEffect, useMemo } 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 { useBlockNumber } from '../application/hooks'
import { AppDispatch, AppState } from '../index'
import { parseCallKey, updateMulticallResults } from './actions'
// chunk calls so we do not exceed the gas limit
const CALL_CHUNK_SIZE = 250
export default function Updater() {
const dispatch = useDispatch<AppDispatch>()
const state = useSelector<AppState, AppState['multicall']>(state => state.multicall)
const latestBlockNumber = useBlockNumber()
const { chainId } = useActiveWeb3React()
const multicallContract = useMulticallContract()
const listeningKeys = useMemo(() => {
if (!chainId || !state.callListeners[chainId]) return []
return Object.keys(state.callListeners[chainId]).filter(callKey => state.callListeners[chainId][callKey] > 0)
}, [state.callListeners, chainId])
const debouncedResults = useDebounce(state.callResults, 20)
const debouncedListeningKeys = useDebounce(listeningKeys, 20)
const unserializedOutdatedCallKeys = useMemo(() => {
if (!chainId || !debouncedResults[chainId]) return debouncedListeningKeys
if (!latestBlockNumber) return []
return debouncedListeningKeys.filter(key => {
const data = debouncedResults[chainId][key]
return !data || data.blockNumber < latestBlockNumber
})
}, [chainId, debouncedResults, debouncedListeningKeys, latestBlockNumber])
const serializedOutdatedCallKeys = useMemo(() => JSON.stringify(unserializedOutdatedCallKeys.sort()), [
unserializedOutdatedCallKeys
])
useEffect(() => {
const outdatedCallKeys: string[] = JSON.parse(serializedOutdatedCallKeys)
if (!multicallContract || !chainId || outdatedCallKeys.length === 0) return
const calls = outdatedCallKeys.map(key => parseCallKey(key))
const chunkedCalls = chunkArray(calls, CALL_CHUNK_SIZE)
console.debug('Firing off chunked calls', chunkedCalls)
chunkedCalls.forEach((chunk, index) =>
multicallContract
.aggregate(chunk.map(obj => [obj.address, obj.callData]))
.then(([resultsBlockNumber, returnData]: [BigNumber, string[]]) => {
// 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: resultsBlockNumber.toNumber()
})
)
})
.catch((error: any) => {
console.error('Failed to fetch multicall chunk', chunk, chainId, error)
})
)
}, [chainId, multicallContract, dispatch, serializedOutdatedCallKeys])
return null
}

View File

@@ -5,7 +5,7 @@ export enum Field {
OUTPUT = 'OUTPUT'
}
export const setDefaultsFromURL = createAction<{ chainId: number; queryString?: string }>('setDefaultsFromURL')
export const setDefaultsFromURLSearch = createAction<{ chainId: number; queryString?: string }>('setDefaultsFromURL')
export const selectToken = createAction<{ field: Field; address: string }>('selectToken')
export const switchTokens = createAction<void>('switchTokens')
export const typeInput = createAction<{ field: Field; typedValue: string }>('typeInput')

View File

@@ -7,7 +7,7 @@ import { useTokenByAddressAndAutomaticallyAdd } from '../../hooks/Tokens'
import { useTradeExactIn, useTradeExactOut } from '../../hooks/Trades'
import { AppDispatch, AppState } from '../index'
import { useTokenBalancesTreatWETHAsETH } from '../wallet/hooks'
import { Field, selectToken, setDefaultsFromURL, switchTokens, typeInput } from './actions'
import { Field, selectToken, setDefaultsFromURLSearch, switchTokens, typeInput } from './actions'
import { useV1TradeLinkIfBetter } from '../../data/V1'
import { V1_TRADE_LINK_THRESHOLD } from '../../constants'
@@ -52,7 +52,7 @@ export function useSwapActionHandlers(): {
}
// try to parse a user entered amount for a given token
function tryParseAmount(value?: string, token?: Token): TokenAmount | undefined {
export function tryParseAmount(value?: string, token?: Token): TokenAmount | undefined {
if (!value || !token) {
return
}
@@ -155,11 +155,11 @@ export function useDerivedSwapInfo(): {
// updates the swap state to use the defaults for a given network whenever the query
// string updates
export function useDefaultsFromURL(search?: string) {
export function useDefaultsFromURLSearch(search?: string) {
const { chainId } = useActiveWeb3React()
const dispatch = useDispatch<AppDispatch>()
useEffect(() => {
if (!chainId) return
dispatch(setDefaultsFromURL({ chainId, queryString: search }))
dispatch(setDefaultsFromURLSearch({ chainId, queryString: search }))
}, [dispatch, search, chainId])
}

View File

@@ -1,6 +1,6 @@
import { ChainId, WETH } from '@uniswap/sdk'
import { createStore, Store } from 'redux'
import { Field, setDefaultsFromURL } from './actions'
import { Field, setDefaultsFromURLSearch } from './actions'
import reducer, { SwapState } from './reducer'
describe('swap reducer', () => {
@@ -18,7 +18,7 @@ describe('swap reducer', () => {
describe('setDefaultsFromURL', () => {
test('ETH to DAI', () => {
store.dispatch(
setDefaultsFromURL({
setDefaultsFromURLSearch({
chainId: ChainId.MAINNET,
queryString:
'?inputCurrency=ETH&outputCurrency=0x6b175474e89094c44da98b954eedeac495271d0f&exactAmount=20.5&exactField=outPUT'
@@ -35,7 +35,7 @@ describe('swap reducer', () => {
test('does not duplicate eth for invalid output token', () => {
store.dispatch(
setDefaultsFromURL({
setDefaultsFromURLSearch({
chainId: ChainId.MAINNET,
queryString: '?outputCurrency=invalid'
})
@@ -51,7 +51,7 @@ describe('swap reducer', () => {
test('output ETH only', () => {
store.dispatch(
setDefaultsFromURL({
setDefaultsFromURLSearch({
chainId: ChainId.MAINNET,
queryString: '?outputCurrency=eth&exactAmount=20.5'
})

View File

@@ -2,7 +2,7 @@ import { parse } from 'qs'
import { createReducer } from '@reduxjs/toolkit'
import { ChainId, WETH } from '@uniswap/sdk'
import { isAddress } from '../../utils'
import { Field, selectToken, setDefaultsFromURL, switchTokens, typeInput } from './actions'
import { Field, selectToken, setDefaultsFromURLSearch, switchTokens, typeInput } from './actions'
export interface SwapState {
readonly independentField: Field
@@ -47,7 +47,7 @@ function parseIndependentFieldURLParameter(urlParam: any): Field {
export default createReducer<SwapState>(initialState, builder =>
builder
.addCase(setDefaultsFromURL, (state, { payload: { queryString, chainId } }) => {
.addCase(setDefaultsFromURLSearch, (_, { payload: { queryString, chainId } }) => {
if (queryString && queryString.length > 1) {
const parsedQs = parse(queryString, { parseArrays: false, ignoreQueryPrefix: true })

View File

@@ -3,7 +3,7 @@ import { useActiveWeb3React } from '../../hooks'
import { useCallback, useMemo } from 'react'
import { shallowEqual, useDispatch, useSelector } from 'react-redux'
import { useAllTokens } from '../../hooks/Tokens'
import { getTokenDecimals, getTokenName, getTokenSymbol, isAddress } from '../../utils'
import { getTokenInfoWithFallback, isAddress } from '../../utils'
import { AppDispatch, AppState } from '../index'
import {
addSerializedPair,
@@ -14,6 +14,7 @@ import {
SerializedToken,
updateUserDarkMode
} from './actions'
import flatMap from 'lodash.flatmap'
function serializeToken(token: Token): SerializedToken {
return {
@@ -69,11 +70,7 @@ export function useFetchTokenByAddress(): (address: string) => Promise<Token | n
if (!library || !chainId) return null
const validatedAddress = isAddress(address)
if (!validatedAddress) return null
const [decimals, symbol, name] = await Promise.all([
getTokenDecimals(address, library).catch(() => null),
getTokenSymbol(address, library).catch(() => 'UNKNOWN'),
getTokenName(address, library).catch(() => 'Unknown')
])
const { name, symbol, decimals } = await getTokenInfoWithFallback(validatedAddress, library)
if (decimals === null) {
return null
@@ -169,10 +166,11 @@ export function useAllDummyPairs(): Pair[] {
const generatedPairs: Pair[] = useMemo(
() =>
Object.values(tokens)
// select only tokens on the current chain
.filter(token => token.chainId === chainId)
.flatMap(token => {
flatMap(
Object.values(tokens)
// select only tokens on the current chain
.filter(token => token.chainId === chainId),
token => {
// for each token on the current chain,
return (
bases
@@ -188,7 +186,8 @@ export function useAllDummyPairs(): Pair[] {
})
.filter(pair => !!pair) as Pair[]
)
}),
}
),
[tokens, chainId]
)

View File

@@ -1,33 +0,0 @@
import { createAction } from '@reduxjs/toolkit'
export interface TokenBalanceListenerKey {
address: string
tokenAddress: string
}
// used by components that care about balances of given tokens and accounts
// being kept up to date
export const startListeningForTokenBalances = createAction<TokenBalanceListenerKey[]>('startListeningForTokenBalances')
export const stopListeningForTokenBalances = createAction<TokenBalanceListenerKey[]>('stopListeningForTokenBalances')
export const startListeningForBalance = createAction<{ addresses: string[] }>('startListeningForBalance')
export const stopListeningForBalance = createAction<{ addresses: string[] }>('stopListeningForBalance')
// these are used by the updater to update balances, and can also be used
// for optimistic updates, e.g. when a transaction is confirmed that changes the
// user's balances or allowances
export const updateTokenBalances = createAction<{
chainId: number
blockNumber: number
address: string
tokenBalances: {
[address: string]: string
}
}>('updateTokenBalances')
export const updateEtherBalances = createAction<{
chainId: number
blockNumber: number
etherBalances: {
[address: string]: string
}
}>('updateEtherBalances')

View File

@@ -1,65 +1,44 @@
import { getAddress } from '@ethersproject/address'
import { ChainId, JSBI, Token, TokenAmount, WETH } from '@uniswap/sdk'
import { useEffect, useMemo } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useMemo } from 'react'
import ERC20_INTERFACE from '../../constants/abis/erc20'
import { useAllTokens } from '../../hooks/Tokens'
import { useActiveWeb3React } from '../../hooks'
import { useMulticallContract } from '../../hooks/useContract'
import { isAddress } from '../../utils'
import { AppDispatch, AppState } from '../index'
import {
startListeningForBalance,
startListeningForTokenBalances,
stopListeningForBalance,
stopListeningForTokenBalances,
TokenBalanceListenerKey
} from './actions'
import { balanceKey } from './reducer'
import { useSingleContractMultipleData, useMultipleContractSingleData } from '../multicall/hooks'
/**
* Returns a map of the given addresses to their eventually consistent ETH balances.
*/
export function useETHBalances(uncheckedAddresses?: (string | undefined)[]): { [address: string]: JSBI | undefined } {
const dispatch = useDispatch<AppDispatch>()
const { chainId } = useActiveWeb3React()
const multicallContract = useMulticallContract()
const addresses: string[] = useMemo(
() =>
uncheckedAddresses
? uncheckedAddresses
.filter((a): a is string => isAddress(a) !== false)
.map(getAddress)
.map(isAddress)
.filter((a): a is string => a !== false)
.sort()
: [],
[uncheckedAddresses]
)
// used so that we do a deep comparison in `useEffect`
const serializedAddresses = JSON.stringify(addresses)
const results = useSingleContractMultipleData(
multicallContract,
'getEthBalance',
addresses.map(address => [address])
)
// add the listeners on mount, remove them on dismount
useEffect(() => {
const addresses = JSON.parse(serializedAddresses)
if (addresses.length === 0) return
dispatch(startListeningForBalance({ addresses }))
return () => {
dispatch(stopListeningForBalance({ addresses }))
}
}, [serializedAddresses, dispatch])
const rawBalanceMap = useSelector<AppState, AppState['wallet']['balances']>(({ wallet: { balances } }) => balances)
return useMemo(() => {
if (!chainId) return {}
return addresses.reduce<{ [address: string]: JSBI }>((map, address) => {
const key = balanceKey({ address, chainId })
const { value } = rawBalanceMap[key] ?? {}
if (value) {
map[address] = JSBI.BigInt(value)
}
return map
}, {})
}, [chainId, addresses, rawBalanceMap])
return useMemo(
() =>
addresses.reduce<{ [address: string]: JSBI | undefined }>((memo, address, i) => {
const value = results?.[i]?.[0]
if (value) memo[address] = JSBI.BigInt(value.toString())
return memo
}, {}),
[addresses, results]
)
}
/**
@@ -69,54 +48,29 @@ export function useTokenBalances(
address?: string,
tokens?: (Token | undefined)[]
): { [tokenAddress: string]: TokenAmount | undefined } {
const dispatch = useDispatch<AppDispatch>()
const { chainId } = useActiveWeb3React()
const validTokens: Token[] = useMemo(
const validatedTokens: Token[] = useMemo(
() => tokens?.filter((t?: Token): t is Token => isAddress(t?.address) !== false) ?? [],
[tokens]
)
// used so that we do a deep comparison in `useEffect`
const serializedCombos: string = useMemo(() => {
return JSON.stringify(
!address || validTokens.length === 0
? []
: validTokens
.map(t => t.address)
.sort()
.map(tokenAddress => ({ address, tokenAddress }))
)
}, [address, validTokens])
const validatedTokenAddresses = useMemo(() => validatedTokens.map(vt => vt.address), [validatedTokens])
// keep the listeners up to date
useEffect(() => {
const combos: TokenBalanceListenerKey[] = JSON.parse(serializedCombos)
if (combos.length === 0) return
const balances = useMultipleContractSingleData(validatedTokenAddresses, ERC20_INTERFACE, 'balanceOf', [address])
dispatch(startListeningForTokenBalances(combos))
return () => {
dispatch(stopListeningForTokenBalances(combos))
}
}, [address, serializedCombos, dispatch])
const rawBalanceMap = useSelector<AppState, AppState['wallet']['balances']>(({ wallet: { balances } }) => balances)
return useMemo(() => {
if (!address || validTokens.length === 0 || !chainId) {
return {}
}
return (
validTokens.reduce<{ [address: string]: TokenAmount }>((map, token) => {
const key = balanceKey({ address, chainId, tokenAddress: token.address })
const { value } = rawBalanceMap[key] ?? {}
if (value) {
map[token.address] = new TokenAmount(token, JSBI.BigInt(value))
}
return map
}, {}) ?? {}
)
}, [address, validTokens, chainId, rawBalanceMap])
return useMemo(
() =>
address && validatedTokens.length > 0
? validatedTokens.reduce<{ [tokenAddress: string]: TokenAmount | undefined }>((memo, token, i) => {
const value = balances?.[i]?.[0]
const amount = value ? JSBI.BigInt(value.toString()) : undefined
if (amount) {
memo[token.address] = new TokenAmount(token, amount)
}
return memo
}, {})
: {},
[address, validatedTokens, balances]
)
}
// contains the hacky logic to treat the WETH token input as if it's ETH to
@@ -173,12 +127,10 @@ export function useTokenBalanceTreatingWETHasETH(account?: string, token?: Token
}
// mimics useAllBalances
export function useAllTokenBalancesTreatingWETHasETH(): {
[account: string]: { [tokenAddress: string]: TokenAmount | undefined }
} {
export function useAllTokenBalancesTreatingWETHasETH(): { [tokenAddress: string]: TokenAmount | undefined } {
const { account } = useActiveWeb3React()
const allTokens = useAllTokens()
const allTokensArray = useMemo(() => Object.values(allTokens ?? {}), [allTokens])
const balances = useTokenBalancesTreatWETHAsETH(account ?? undefined, allTokensArray)
return account ? { [account]: balances } : {}
return balances ?? {}
}

View File

@@ -1,134 +0,0 @@
import { createReducer } from '@reduxjs/toolkit'
import { isAddress } from '../../utils'
import {
startListeningForBalance,
startListeningForTokenBalances,
stopListeningForBalance,
stopListeningForTokenBalances,
updateEtherBalances,
updateTokenBalances
} from './actions'
// all address keys are checksummed and valid addresses starting with 0x
interface WalletState {
readonly tokenBalanceListeners: {
readonly [address: string]: {
// the number of listeners for each address/token combo
readonly [tokenAddress: string]: number
}
}
readonly balanceListeners: {
// the number of ether balance listeners for each address
readonly [address: string]: number
}
readonly balances: {
readonly [balanceKey: string]: {
readonly value: string
readonly blockNumber: number | undefined
}
}
}
export function balanceKey({
chainId,
address,
tokenAddress
}: {
chainId: number
address: string
tokenAddress?: string // undefined for ETH
}): string {
return `${chainId}-${address}-${tokenAddress ?? 'ETH'}`
}
const initialState: WalletState = {
balanceListeners: {},
tokenBalanceListeners: {},
balances: {}
}
export default createReducer(initialState, builder =>
builder
.addCase(startListeningForTokenBalances, (state, { payload: combos }) => {
combos.forEach(combo => {
const [checksummedTokenAddress, checksummedAddress] = [isAddress(combo.tokenAddress), isAddress(combo.address)]
if (!checksummedAddress || !checksummedTokenAddress) {
console.error('invalid combo', combo)
return
}
state.tokenBalanceListeners[checksummedAddress] = state.tokenBalanceListeners[checksummedAddress] ?? {}
state.tokenBalanceListeners[checksummedAddress][checksummedTokenAddress] =
(state.tokenBalanceListeners[checksummedAddress][checksummedTokenAddress] ?? 0) + 1
})
})
.addCase(stopListeningForTokenBalances, (state, { payload: combos }) => {
combos.forEach(combo => {
const [checksummedTokenAddress, checksummedAddress] = [isAddress(combo.tokenAddress), isAddress(combo.address)]
if (!checksummedAddress || !checksummedTokenAddress) {
console.error('invalid combo', combo)
return
}
if (!state.tokenBalanceListeners[checksummedAddress]) return
if (!state.tokenBalanceListeners[checksummedAddress][checksummedTokenAddress]) return
if (state.tokenBalanceListeners[checksummedAddress][checksummedTokenAddress] === 1) {
delete state.tokenBalanceListeners[checksummedAddress][checksummedTokenAddress]
} else {
state.tokenBalanceListeners[checksummedAddress][checksummedTokenAddress]--
}
})
})
.addCase(startListeningForBalance, (state, { payload: { addresses } }) => {
addresses.forEach(address => {
const checksummedAddress = isAddress(address)
if (!checksummedAddress) {
console.error('invalid address', address)
return
}
state.balanceListeners[checksummedAddress] = (state.balanceListeners[checksummedAddress] ?? 0) + 1
})
})
.addCase(stopListeningForBalance, (state, { payload: { addresses } }) => {
addresses.forEach(address => {
const checksummedAddress = isAddress(address)
if (!checksummedAddress) {
console.error('invalid address', address)
return
}
if (!state.balanceListeners[checksummedAddress]) return
if (state.balanceListeners[checksummedAddress] === 1) {
delete state.balanceListeners[checksummedAddress]
} else {
state.balanceListeners[checksummedAddress]--
}
})
})
.addCase(updateTokenBalances, (state, { payload: { chainId, address, blockNumber, tokenBalances } }) => {
const checksummedAddress = isAddress(address)
if (!checksummedAddress) return
Object.keys(tokenBalances).forEach(tokenAddress => {
const checksummedTokenAddress = isAddress(tokenAddress)
if (!checksummedTokenAddress) return
const balance = tokenBalances[checksummedTokenAddress]
const key = balanceKey({ chainId, address: checksummedAddress, tokenAddress: checksummedTokenAddress })
const data = state.balances[key]
if (!data || data.blockNumber === undefined || data.blockNumber <= blockNumber) {
state.balances[key] = { value: balance, blockNumber }
}
})
})
.addCase(updateEtherBalances, (state, { payload: { etherBalances, chainId, blockNumber } }) => {
Object.keys(etherBalances).forEach(address => {
const checksummedAddress = isAddress(address)
if (!checksummedAddress) return
const balance = etherBalances[checksummedAddress]
const key = balanceKey({ chainId, address: checksummedAddress })
const data = state.balances[key]
if (!data || data.blockNumber === undefined || data.blockNumber <= blockNumber) {
state.balances[key] = { value: balance, blockNumber }
}
})
})
)

View File

@@ -1,107 +0,0 @@
import { BalanceMap, getEtherBalances, getTokensBalance } from '@mycrypto/eth-scan'
import { useEffect, useMemo } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useActiveWeb3React } from '../../hooks'
import { useBlockNumber } from '../application/hooks'
import { AppDispatch, AppState } from '../index'
import { updateEtherBalances, updateTokenBalances } from './actions'
import { balanceKey } from './reducer'
function convertBalanceMapValuesToString(balanceMap: BalanceMap): { [key: string]: string } {
return Object.keys(balanceMap).reduce<{ [key: string]: string }>((map, key) => {
map[key] = balanceMap[key].toString()
return map
}, {})
}
export default function Updater() {
const { chainId, library } = useActiveWeb3React()
const lastBlockNumber = useBlockNumber()
const dispatch = useDispatch<AppDispatch>()
const ethBalanceListeners = useSelector<AppState, AppState['wallet']['balanceListeners']>(state => {
return state.wallet.balanceListeners
})
const tokenBalanceListeners = useSelector<AppState, AppState['wallet']['tokenBalanceListeners']>(state => {
return state.wallet.tokenBalanceListeners
})
const allBalances = useSelector<AppState, AppState['wallet']['balances']>(state => state.wallet.balances)
const activeETHListeners: string[] = useMemo(() => {
return Object.keys(ethBalanceListeners).filter(address => ethBalanceListeners[address] > 0) // redundant check
}, [ethBalanceListeners])
const activeTokenBalanceListeners: { [address: string]: string[] } = useMemo(() => {
return Object.keys(tokenBalanceListeners).reduce<{ [address: string]: string[] }>((map, address) => {
const tokenAddresses = Object.keys(tokenBalanceListeners[address]).filter(
tokenAddress => tokenBalanceListeners[address][tokenAddress] > 0 // redundant check
)
map[address] = tokenAddresses
return map
}, {})
}, [tokenBalanceListeners])
const ethBalancesNeedUpdate: string[] = useMemo(() => {
if (!chainId || !lastBlockNumber) return []
return activeETHListeners.filter(address => {
const data = allBalances[balanceKey({ chainId, address })]
if (!data || !data.blockNumber) return true
return data.blockNumber < lastBlockNumber
})
}, [activeETHListeners, allBalances, chainId, lastBlockNumber])
const tokenBalancesNeedUpdate: { [address: string]: string[] } = useMemo(() => {
if (!chainId || !lastBlockNumber) return {}
return Object.keys(activeTokenBalanceListeners).reduce<{ [address: string]: string[] }>((map, address) => {
const needsUpdate =
activeTokenBalanceListeners[address]?.filter(tokenAddress => {
const data = allBalances[balanceKey({ chainId, tokenAddress, address })]
if (!data || !data.blockNumber) return true
return data.blockNumber < lastBlockNumber
}) ?? []
if (needsUpdate.length > 0) {
map[address] = needsUpdate
}
return map
}, {})
}, [activeTokenBalanceListeners, allBalances, chainId, lastBlockNumber])
useEffect(() => {
if (!library || !chainId || !lastBlockNumber || ethBalancesNeedUpdate.length === 0) return
getEtherBalances(library, ethBalancesNeedUpdate)
.then(balanceMap => {
dispatch(
updateEtherBalances({
blockNumber: lastBlockNumber,
chainId,
etherBalances: convertBalanceMapValuesToString(balanceMap)
})
)
})
.catch(error => {
console.error('balance fetch failed', ethBalancesNeedUpdate, error)
})
}, [library, ethBalancesNeedUpdate, dispatch, lastBlockNumber, chainId])
useEffect(() => {
if (!library || !chainId || !lastBlockNumber) return
Object.keys(tokenBalancesNeedUpdate).forEach(address => {
if (tokenBalancesNeedUpdate[address].length === 0) return
getTokensBalance(library, address, tokenBalancesNeedUpdate[address])
.then(tokenBalanceMap => {
dispatch(
updateTokenBalances({
address,
chainId,
blockNumber: lastBlockNumber,
tokenBalances: convertBalanceMapValuesToString(tokenBalanceMap)
})
)
})
.catch(error => {
console.error(`failed to get token balances`, address, tokenBalancesNeedUpdate[address], error)
})
})
}, [library, tokenBalancesNeedUpdate, dispatch, lastBlockNumber, chainId])
return null
}

View File

@@ -60,15 +60,11 @@ const StyledLink = styled.a`
export function Link({
onClick,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
as,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
ref,
target = '_blank',
href,
rel = 'noopener noreferrer',
...rest
}: HTMLProps<HTMLAnchorElement>) {
}: Omit<HTMLProps<HTMLAnchorElement>, 'as' | 'ref'>) {
const handleClick = useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => {
onClick && onClick(event) // first call back into the original onClick

View File

@@ -0,0 +1,32 @@
import chunkArray from './chunkArray'
describe('#chunkArray', () => {
it('size 1', () => {
expect(chunkArray([1, 2, 3], 1)).toEqual([[1], [2], [3]])
})
it('size 0 throws', () => {
expect(() => chunkArray([1, 2, 3], 0)).toThrow('maxChunkSize must be gte 1')
})
it('size gte items', () => {
expect(chunkArray([1, 2, 3], 3)).toEqual([[1, 2, 3]])
expect(chunkArray([1, 2, 3], 4)).toEqual([[1, 2, 3]])
})
it('size exact half', () => {
expect(chunkArray([1, 2, 3, 4], 2)).toEqual([
[1, 2],
[3, 4]
])
})
it('evenly distributes', () => {
const chunked = chunkArray([...Array(100).keys()], 40)
expect(chunked).toEqual([
[...Array(34).keys()],
[...Array(34).keys()].map(i => i + 34),
[...Array(32).keys()].map(i => i + 68)
])
expect(chunked[0][0]).toEqual(0)
expect(chunked[2][31]).toEqual(99)
})
})

11
src/utils/chunkArray.ts Normal file
View File

@@ -0,0 +1,11 @@
// chunks array into chunks of maximum size
// evenly distributes items among the chunks
export default function chunkArray<T>(items: T[], maxChunkSize: number): T[][] {
if (maxChunkSize < 1) throw new Error('maxChunkSize must be gte 1')
if (items.length <= maxChunkSize) return [items]
const numChunks: number = Math.ceil(items.length / maxChunkSize)
const chunkSize = Math.ceil(items.length / numChunks)
return [...Array(numChunks).keys()].map(ix => items.slice(ix * chunkSize, ix * chunkSize + chunkSize))
}

View File

@@ -102,42 +102,49 @@ export function getExchangeContract(pairAddress: string, library: Web3Provider,
return getContract(pairAddress, IUniswapV2PairABI, library, account)
}
// get token name
export async function getTokenName(tokenAddress: string, library: Web3Provider) {
// get token info and fall back to unknown if not available, except for the
// decimals which falls back to null
export async function getTokenInfoWithFallback(
tokenAddress: string,
library: Web3Provider
): Promise<{ name: string; symbol: string; decimals: null | number }> {
if (!isAddress(tokenAddress)) {
throw Error(`Invalid 'tokenAddress' parameter '${tokenAddress}'.`)
}
return getContract(tokenAddress, ERC20_ABI, library)
.name()
.catch(() =>
getContract(tokenAddress, ERC20_BYTES32_ABI, library)
.name()
.then(parseBytes32String)
)
}
const token = getContract(tokenAddress, ERC20_ABI, library)
// get token symbol
export async function getTokenSymbol(tokenAddress: string, library: Web3Provider) {
if (!isAddress(tokenAddress)) {
throw Error(`Invalid 'tokenAddress' parameter '${tokenAddress}'.`)
}
const namePromise: Promise<string> = token.name().catch(() =>
getContract(tokenAddress, ERC20_BYTES32_ABI, library)
.name()
.then(parseBytes32String)
.catch((e: Error) => {
console.debug('Failed to get name for token address', e, tokenAddress)
return 'Unknown'
})
)
return getContract(tokenAddress, ERC20_ABI, library)
.symbol()
.catch(() => {
const contractBytes32 = getContract(tokenAddress, ERC20_BYTES32_ABI, library)
return contractBytes32.symbol().then(parseBytes32String)
})
}
const symbolPromise: Promise<string> = token.symbol().catch(() => {
const contractBytes32 = getContract(tokenAddress, ERC20_BYTES32_ABI, library)
return contractBytes32
.symbol()
.then(parseBytes32String)
.catch((e: Error) => {
console.debug('Failed to get symbol for token address', e, tokenAddress)
return 'UNKNOWN'
})
})
const decimalsPromise: Promise<number | null> = token.decimals().catch((e: Error) => {
console.debug('Failed to get decimals for token address', e, tokenAddress)
return null
})
// get token decimals
export async function getTokenDecimals(tokenAddress: string, library: Web3Provider) {
if (!isAddress(tokenAddress)) {
throw Error(`Invalid 'tokenAddress' parameter '${tokenAddress}'.`)
}
return getContract(tokenAddress, ERC20_ABI, library).decimals()
const [name, symbol, decimals]: [string, string, number | null] = (await Promise.all([
namePromise,
symbolPromise,
decimalsPromise
])) as [string, string, number | null]
return { name, symbol, decimals }
}
export function escapeRegExp(string: string): string {

View File

@@ -51,7 +51,7 @@ export function computeSlippageAdjustedAmounts(
}
}
export function warningServerity(priceImpact: Percent): 0 | 1 | 2 | 3 {
export function warningSeverity(priceImpact: Percent): 0 | 1 | 2 | 3 {
if (!priceImpact?.lessThan(ALLOWED_PRICE_IMPACT_HIGH)) return 3
if (!priceImpact?.lessThan(ALLOWED_PRICE_IMPACT_MEDIUM)) return 2
if (!priceImpact?.lessThan(ALLOWED_PRICE_IMPACT_LOW)) return 1

View File

@@ -7,6 +7,7 @@
"strictNullChecks": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noImplicitUseStrict": true
"noUnusedLocals": true,
"noFallthroughCasesInSwitch": true
}
}

244
yarn.lock
View File

@@ -1349,21 +1349,6 @@
"@ethersproject/properties" ">=5.0.0-beta.140"
"@ethersproject/strings" ">=5.0.0-beta.136"
"@ethersproject/abi@^5.0.0-beta.146":
version "5.0.0-beta.155"
resolved "https://registry.yarnpkg.com/@ethersproject/abi/-/abi-5.0.0-beta.155.tgz#b02cc0d54a44fd499be9778be53ed112220e3ecd"
integrity sha512-Oy00vZtb/Yr6gL9SJdKj7lmcL3e/04K5Dpd20ej52rXuRDYddCn9yHSkYWRM8/ZFFepFqeXmZ3XVN0ixLOJwcA==
dependencies:
"@ethersproject/address" ">=5.0.0-beta.134"
"@ethersproject/bignumber" ">=5.0.0-beta.138"
"@ethersproject/bytes" ">=5.0.0-beta.137"
"@ethersproject/constants" ">=5.0.0-beta.133"
"@ethersproject/hash" ">=5.0.0-beta.133"
"@ethersproject/keccak256" ">=5.0.0-beta.131"
"@ethersproject/logger" ">=5.0.0-beta.137"
"@ethersproject/properties" ">=5.0.0-beta.140"
"@ethersproject/strings" ">=5.0.0-beta.136"
"@ethersproject/abstract-provider@>=5.0.0-beta.131":
version "5.0.0-beta.139"
resolved "https://registry.yarnpkg.com/@ethersproject/abstract-provider/-/abstract-provider-5.0.0-beta.139.tgz#a3b52c5494dcf67d277e2c0443813d9de746f8b4"
@@ -1468,7 +1453,7 @@
"@ethersproject/properties" ">=5.0.0-beta.131"
bn.js "^4.4.0"
"@ethersproject/bignumber@>=5.0.0-beta.138", "@ethersproject/bignumber@^5.0.0-beta.135":
"@ethersproject/bignumber@>=5.0.0-beta.138":
version "5.0.0-beta.139"
resolved "https://registry.yarnpkg.com/@ethersproject/bignumber/-/bignumber-5.0.0-beta.139.tgz#12a4fa5a76ee90f77932326311caf04e1de1cae0"
integrity sha512-h1C1okCmPK3UVWwMGUbuCZykplJmD/TdknPQQHJWL/chK5MqBhyQ5o1Cay7mHXKCBnjWrR9BtwjfkAh76pYtFA==
@@ -2308,15 +2293,6 @@
call-me-maybe "^1.0.1"
glob-to-regexp "^0.3.0"
"@mycrypto/eth-scan@^2.1.0":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@mycrypto/eth-scan/-/eth-scan-2.1.0.tgz#9248b00000bff0b4ac9acda093d98eae0c84b93c"
integrity sha512-ncbWZDz6lL/8iFklGYt5MG2iTtTzJ6V6P10ht6PoOMHFG/2HiAm9AbKzwP0twYRh/oa/p/nSg3SrZer0Zuk7ZA==
dependencies:
"@ethersproject/abi" "^5.0.0-beta.146"
"@ethersproject/bignumber" "^5.0.0-beta.135"
isomorphic-unfetch "^3.0.0"
"@nodelib/fs.stat@^1.1.2":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz#2b5a3ab3f918cca48a8c754c08168e3f03eba61b"
@@ -2385,68 +2361,34 @@
penpal "3.0.7"
pocket-js-core "0.0.3"
"@reach/auto-id@0.2.0":
version "0.2.0"
resolved "https://registry.yarnpkg.com/@reach/auto-id/-/auto-id-0.2.0.tgz#97f9e48fe736aa5c6f4f32cf73c1f19d005f8550"
integrity sha512-lVK/svL2HuQdp7jgvlrLkFsUx50Az9chAhxpiPwBqcS83I2pVWvXp98FOcSCCJCV++l115QmzHhFd+ycw1zLBg==
"@reach/component-component@^0.1.3":
version "0.1.3"
resolved "https://registry.yarnpkg.com/@reach/component-component/-/component-component-0.1.3.tgz#5d156319572dc38995b246f81878bc2577c517e5"
integrity sha512-a1USH7L3bEfDdPN4iNZGvMEFuBfkdG+QNybeyDv8RloVFgZYRoM+KGXyy2KOfEnTUM8QWDRSROwaL3+ts5Angg==
"@reach/dialog@^0.2.8":
version "0.2.9"
resolved "https://registry.yarnpkg.com/@reach/dialog/-/dialog-0.2.9.tgz#6375ec4adc1e22838aeede15f57d5eb5ac0e571c"
integrity sha512-4plyTRt2X4bB9A5fDFXH0bxb4aipLyAuRVMpOuA1RekFgvkxFn65em2CWYSzRsVf3aTQ2cEnYET4a5H2Qu8a5Q==
"@reach/dialog@^0.10.3":
version "0.10.3"
resolved "https://registry.yarnpkg.com/@reach/dialog/-/dialog-0.10.3.tgz#ba789809c3b194fff79d3bcb4a583c58e03edb83"
integrity sha512-RMpUHNjRQhkjGzKt9/oLmDhwUBikW3JbEzgzZngq5MGY5kWRPwYInLDkEA8We4E43AbBsl5J/PRzQha9V+EEXw==
dependencies:
"@reach/component-component" "^0.1.3"
"@reach/portal" "^0.2.1"
"@reach/utils" "^0.2.3"
react-focus-lock "^1.17.7"
react-remove-scroll "^1.0.2"
"@reach/observe-rect@^1.0.3":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@reach/observe-rect/-/observe-rect-1.1.0.tgz#4e967a93852b6004c3895d9ed8d4e5b41895afde"
integrity sha512-kE+jvoj/OyJV24C03VvLt5zclb9ArJi04wWXMMFwQvdZjdHoBlN4g0ZQFjyy/ejPF1Z/dpUD5dhRdBiUmIGZTA==
"@reach/portal@^0.2.1":
version "0.2.1"
resolved "https://registry.yarnpkg.com/@reach/portal/-/portal-0.2.1.tgz#07720b999e0063a9e179c14dbdc60fd991cfc9fa"
integrity sha512-pUQ0EtCcYm4ormEjJmdk4uzZCxOpaRHB8FDKJXy6q6GqRqQwZ4lAT1f2Tvw0DAmULmyZTpe1/heXY27Tdnct+Q==
dependencies:
"@reach/component-component" "^0.1.3"
"@reach/rect@^0.2.1":
version "0.2.1"
resolved "https://registry.yarnpkg.com/@reach/rect/-/rect-0.2.1.tgz#7343020174c90e2290b844d17c03fd9c78e6b601"
integrity sha512-aZ9RsNHDMQ3zETonikqu9/85iXxj+LPqZ9Gr9UAncj3AufYmGeWG3XG6b37B+7ORH+mkhVpLU2ZlIWxmOe9Cqg==
dependencies:
"@reach/component-component" "^0.1.3"
"@reach/observe-rect" "^1.0.3"
"@reach/tooltip@^0.2.0":
version "0.2.2"
resolved "https://registry.yarnpkg.com/@reach/tooltip/-/tooltip-0.2.2.tgz#a861ce38269b586597ab40417323b33d3d6dc927"
integrity sha512-afcfqH6EzDHmwTB6g1k0dSbkyT0s9KPIi5bX56nNuldsCIasImFFYDjRZLhFcuxjskwIsHAi06yC3GV6mtcRxw==
dependencies:
"@reach/auto-id" "0.2.0"
"@reach/portal" "^0.2.1"
"@reach/rect" "^0.2.1"
"@reach/utils" "^0.2.3"
"@reach/visually-hidden" "^0.1.4"
"@reach/portal" "^0.10.3"
"@reach/utils" "^0.10.3"
prop-types "^15.7.2"
react-focus-lock "^2.3.1"
react-remove-scroll "^2.3.0"
tslib "^1.11.2"
"@reach/utils@^0.2.3":
version "0.2.3"
resolved "https://registry.yarnpkg.com/@reach/utils/-/utils-0.2.3.tgz#820f6a6af4301b4c5065cfc04bb89e6a3d1d723f"
integrity sha512-zM9rA8jDchr05giMhL95dPeYkK67cBQnIhCVrOKKqgWGsv+2GE/HZqeptvU4zqs0BvIqsThwov+YxVNVh5csTQ==
"@reach/portal@^0.10.3":
version "0.10.3"
resolved "https://registry.yarnpkg.com/@reach/portal/-/portal-0.10.3.tgz#2eb408cc246d3eabbbf3b47ca4dc9c381cdb1d88"
integrity sha512-t8c+jtDxMLSPRGg93sQd2s6dDNilh5/qdrwmx88ki7l9h8oIXqMxPP3kSkOqZ9cbVR0b2A68PfMhCDOwMGvkoQ==
dependencies:
"@reach/utils" "^0.10.3"
tslib "^1.11.2"
"@reach/visually-hidden@^0.1.4":
version "0.1.4"
resolved "https://registry.yarnpkg.com/@reach/visually-hidden/-/visually-hidden-0.1.4.tgz#0dc4ecedf523004337214187db70a46183bd945b"
integrity sha512-QHbzXjflSlCvDd6vJwdwx16mSB+vUCCQMiU/wK/CgVNPibtpEiIbisyxkpZc55DyDFNUIqP91rSUsNae+ogGDQ==
"@reach/utils@^0.10.3":
version "0.10.3"
resolved "https://registry.yarnpkg.com/@reach/utils/-/utils-0.10.3.tgz#e30f9b172d131161953df7dd01553c57ca4e78f8"
integrity sha512-LoIZSfVAJMA+DnzAMCMfc/wAM39iKT8BQQ9gI1FODpxd8nPFP4cKisMuRXImh2/iVtG2Z6NzzCNgceJSrywqFQ==
dependencies:
"@types/warning" "^3.0.0"
tslib "^1.11.2"
warning "^4.0.3"
"@reduxjs/toolkit@^1.3.5":
version "1.3.5"
@@ -2852,6 +2794,18 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339"
integrity sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA==
"@types/lodash.flatmap@^4.5.6":
version "4.5.6"
resolved "https://registry.yarnpkg.com/@types/lodash.flatmap/-/lodash.flatmap-4.5.6.tgz#5f1ea80cebe403f0fbfcc1b5ad75cd09dd8b5785"
integrity sha512-ELNrUL9q+MB7AACaHivWIsKDFDgYhHE3/svXhqvDJgONtn2c467Cy87nEb7CEDvfaGCPv91lPaW596I8s5oiNQ==
dependencies:
"@types/lodash" "*"
"@types/lodash@*":
version "4.14.153"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.153.tgz#5cb7dded0649f1df97938ac5ffc4f134e9e9df98"
integrity sha512-lYniGRiRfZf2gGAR9cfRC3Pi5+Q1ziJCKqPmjZocigrSJUVPWf7st1BtSJ8JOeK0FLXVndQ1IjUjTco9CXGo/Q==
"@types/lodash@4.14.149":
version "4.14.149"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.149.tgz#1342d63d948c6062838fbf961012f74d4e638440"
@@ -2950,6 +2904,13 @@
dependencies:
"@types/react" "*"
"@types/react-window@^1.8.2":
version "1.8.2"
resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.2.tgz#a5a6b2762ce73ffaab7911ee1397cf645f2459fe"
integrity sha512-gP1xam68Wc4ZTAee++zx6pTdDAH08rAkQrWm4B4F/y6hhmlT9Mgx2q8lTCXnrPHXsr15XjRN9+K2DLKcz44qEQ==
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@^16.9.34":
version "16.9.34"
resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.34.tgz#f7d5e331c468f53affed17a8a4d488cd44ea9349"
@@ -3052,6 +3013,11 @@
dependencies:
pretty-format "^25.1.0"
"@types/warning@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@types/warning/-/warning-3.0.0.tgz#0d2501268ad8f9962b740d387c4654f5f8e23e52"
integrity sha1-DSUBJorY+ZYrdA04fEZU9fjiPlI=
"@types/web3-provider-engine@^14.0.0":
version "14.0.0"
resolved "https://registry.yarnpkg.com/@types/web3-provider-engine/-/web3-provider-engine-14.0.0.tgz#43adc3b39dc9812b82aef8cd2d66577665ad59b0"
@@ -8333,7 +8299,7 @@ fancy-log@^1.3.2:
parse-node-version "^1.0.0"
time-stamp "^1.0.0"
fast-deep-equal@2.0.1, fast-deep-equal@^2.0.1:
fast-deep-equal@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49"
integrity sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=
@@ -8644,7 +8610,7 @@ flush-write-stream@^1.0.0, flush-write-stream@^1.0.2:
inherits "^2.0.3"
readable-stream "^2.3.6"
focus-lock@^0.6.3:
focus-lock@^0.6.7:
version "0.6.8"
resolved "https://registry.yarnpkg.com/focus-lock/-/focus-lock-0.6.8.tgz#61985fadfa92f02f2ee1d90bc738efaf7f3c9f46"
integrity sha512-vkHTluRCoq9FcsrldC0ulQHiyBYgVJB2CX53I8r0nTC6KnEij7Of0jpBspjt3/CuNb6fyoj3aOh9J2HgQUM0og==
@@ -8919,6 +8885,11 @@ get-caller-file@^2.0.1:
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
get-nonce@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3"
integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==
get-own-enumerable-property-symbols@^3.0.0:
version "3.0.2"
resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz#b5fde77f22cbe35f390b4e089922c50bce6ef664"
@@ -10356,14 +10327,6 @@ isomorphic-fetch@2.2.1:
node-fetch "^1.0.1"
whatwg-fetch ">=0.10.0"
isomorphic-unfetch@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/isomorphic-unfetch/-/isomorphic-unfetch-3.0.0.tgz#de6d80abde487b17de2c400a7ef9e5ecc2efb362"
integrity sha512-V0tmJSYfkKokZ5mgl0cmfQMTb7MLHsBMngTkbLY0eXvKqiVRRoZP04Ly+KhKrJfKtzC9E6Pp15Jo+bwh7Vi2XQ==
dependencies:
node-fetch "^2.2.0"
unfetch "^4.0.0"
isstream@~0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
@@ -11887,7 +11850,7 @@ memdown@~3.0.0:
ltgt "~2.2.0"
safe-buffer "~5.1.1"
memoize-one@^5.0.0:
"memoize-one@>=3.1.1 <6", memoize-one@^5.0.0:
version "5.1.1"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0"
integrity sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA==
@@ -12346,11 +12309,6 @@ node-fetch@^1.0.1, node-fetch@~1.7.1:
encoding "^0.1.11"
is-stream "^1.0.1"
node-fetch@^2.2.0:
version "2.6.0"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd"
integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==
node-forge@0.9.0:
version "0.9.0"
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.9.0.tgz#d624050edbb44874adca12bb9a52ec63cb782579"
@@ -14430,7 +14388,7 @@ react-app-polyfill@^1.0.6:
regenerator-runtime "^0.13.3"
whatwg-fetch "^3.0.0"
react-clientside-effect@^1.2.0:
react-clientside-effect@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/react-clientside-effect/-/react-clientside-effect-1.2.2.tgz#6212fb0e07b204e714581dd51992603d1accc837"
integrity sha512-nRmoyxeok5PBO6ytPvSjKp9xwXg9xagoTK1mMjwnQxqM9Hd7MNPl+LS1bOSOe+CV2+4fnEquc7H/S8QD3q697A==
@@ -14501,15 +14459,17 @@ react-feather@^2.0.8:
dependencies:
prop-types "^15.7.2"
react-focus-lock@^1.17.7:
version "1.19.1"
resolved "https://registry.yarnpkg.com/react-focus-lock/-/react-focus-lock-1.19.1.tgz#2f3429793edaefe2d077121f973ce5a3c7a0651a"
integrity sha512-TPpfiack1/nF4uttySfpxPk4rGZTLXlaZl7ncZg/ELAk24Iq2B1UUaUioID8H8dneUXqznT83JTNDHDj+kwryw==
react-focus-lock@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/react-focus-lock/-/react-focus-lock-2.3.1.tgz#9d5d85899773609c7eefa4fc54fff6a0f5f2fc47"
integrity sha512-j15cWLPzH0gOmRrUg01C09Peu8qbcdVqr6Bjyfxj80cNZmH+idk/bNBYEDSmkAtwkXI+xEYWSmHYqtaQhZ8iUQ==
dependencies:
"@babel/runtime" "^7.0.0"
focus-lock "^0.6.3"
focus-lock "^0.6.7"
prop-types "^15.6.2"
react-clientside-effect "^1.2.0"
react-clientside-effect "^1.2.2"
use-callback-ref "^1.2.1"
use-sidecar "^1.0.1"
react-ga@^2.5.7:
version "2.7.0"
@@ -14548,21 +14508,24 @@ react-redux@^7.2.0:
prop-types "^15.7.2"
react-is "^16.9.0"
react-remove-scroll-bar@^1.1.5:
version "1.2.0"
resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-1.2.0.tgz#07250b2bc581f56315759c454c9b159dd04ba49d"
integrity sha512-8xSYR6xgW8QW65k38qB1Sh6ouTRjZ7BEteepR9tACd1rSaRyVYWabFxYLNOr4l1blZlqb81GEmDpUoPm7LsUTA==
react-remove-scroll-bar@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.1.0.tgz#edafe9b42a42c0dad9bdd10712772a1f9a39d7b9"
integrity sha512-5X5Y5YIPjIPrAoMJxf6Pfa7RLNGCgwZ95TdnVPgPuMftRfO8DaC7F4KP1b5eiO8hHbe7u+wZNDbYN5WUTpv7+g==
dependencies:
react-style-singleton "^1.1.0"
react-style-singleton "^2.1.0"
tslib "^1.0.0"
react-remove-scroll@^1.0.2:
version "1.0.8"
resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-1.0.8.tgz#a5aadc56368345a51ba524582c842a773849f609"
integrity sha512-AS6gFBO6T2CP0TgmDjq3Ip0Fz1HKyv+lzNrQAkJBSWGyOYaMWLMDy77mQJ7qEyy6fK0pI+Cz5x3X81/ux6SBew==
react-remove-scroll@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.3.0.tgz#3af06fe2f7130500704b676cdef94452c08fe593"
integrity sha512-UqVimLeAe+5EHXKfsca081hAkzg3WuDmoT9cayjBegd6UZVhlTEchleNp9J4TMGkb/ftLve7ARB5Wph+HJ7A5g==
dependencies:
react-remove-scroll-bar "^1.1.5"
react-remove-scroll-bar "^2.1.0"
react-style-singleton "^2.1.0"
tslib "^1.0.0"
use-callback-ref "^1.2.3"
use-sidecar "^1.0.1"
react-router-dom@^5.0.0:
version "5.1.2"
@@ -14661,11 +14624,12 @@ react-spring@^8.0.27:
"@babel/runtime" "^7.3.1"
prop-types "^15.5.8"
react-style-singleton@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-1.1.1.tgz#b2b698765519da812b80f55ab3c5fc5d849a2e63"
integrity sha512-0JD+XC5veR3oxf7GzIXipr89sM8R3rWnOR/gpzIV0DnoRBrcTvvkqyMu9icDYqM/6CWJhYcH5Jdy6Nim7PmoTQ==
react-style-singleton@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.1.0.tgz#7396885332e9729957f9df51f08cadbfc164e1c4"
integrity sha512-DH4ED+YABC1dhvSDYGGreAHmfuTXj6+ezT3CmHoqIEfxNgEYfIMoOtmbRp42JsUst3IPqBTDL+8r4TF7EWhIHw==
dependencies:
get-nonce "^1.0.0"
invariant "^2.2.4"
tslib "^1.0.0"
@@ -14684,6 +14648,14 @@ react-use-gesture@^6.0.14:
resolved "https://registry.yarnpkg.com/react-use-gesture/-/react-use-gesture-6.0.14.tgz#ab2d35ef72a5fb6060a6160eb12568c276f8a4b1"
integrity sha512-d9cnZJ0DOFd3FIO76J776DyhtbODgbxGKu19lvc1aSNTnRV5EKr9V4Uda188l2Qh0Va3pqWGxEQlw72r2cmnFQ==
react-window@^1.8.5:
version "1.8.5"
resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.5.tgz#a56b39307e79979721021f5d06a67742ecca52d1"
integrity sha512-HeTwlNa37AFa8MDZFZOKcNEkuF2YflA0hpGPiTT9vR7OawEt+GZbfM6wqkBahD3D3pUjIabQYzsnY/BSJbgq6Q==
dependencies:
"@babel/runtime" "^7.0.0"
memoize-one ">=3.1.1 <6"
react@^16.13.1:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e"
@@ -16523,13 +16495,6 @@ swarm-js@0.1.39:
tar "^4.0.2"
xhr-request-promise "^0.1.2"
swr@0.1.18:
version "0.1.18"
resolved "https://registry.yarnpkg.com/swr/-/swr-0.1.18.tgz#be62df4cb8d188dc092305b35ecda1f3be8e61c1"
integrity sha512-lD31JxsD0bXdT7dyGVIB7MHcwgFp+HbBBOLt075hJT0sEgW01E3+EuCeB6fsavxZ2UjUZ3f+SbNMo9c8pv9uiA==
dependencies:
fast-deep-equal "2.0.1"
symbol-observable@^1.1.0, symbol-observable@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"
@@ -16893,6 +16858,11 @@ tslib@^1.0.0, tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.11.2.tgz#9c79d83272c9a7aaf166f73915c9667ecdde3cc9"
integrity sha512-tTSkux6IGPnUGUd1XAZHcpu85MOkIl5zX49pO+jfsie3eP0B6pyhOlLXm3cAC6T7s+euSDDUUV+Acop5WmtkVg==
tslib@^1.11.2, tslib@^1.9.3:
version "1.13.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==
tsutils@^3.17.1:
version "3.17.1"
resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.17.1.tgz#ed719917f11ca0dee586272b2ac49e015a2dd759"
@@ -17049,11 +17019,6 @@ undertaker@^1.2.1:
object.reduce "^1.0.0"
undertaker-registry "^1.0.0"
unfetch@^4.0.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/unfetch/-/unfetch-4.1.0.tgz#6ec2dd0de887e58a4dee83a050ded80ffc4137db"
integrity sha512-crP/n3eAPUJxZXM9T80/yv0YhkTEx2K1D3h7D1AJM6fzsWZrxdyRuLN0JH/dkZh1LNH8LxCnBzoPFCPbb2iGpg==
unicode-canonical-property-names-ecmascript@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818"
@@ -17235,11 +17200,24 @@ usb@^1.6.0:
nan "2.13.2"
prebuild-install "^5.3.3"
use-callback-ref@^1.2.1, use-callback-ref@^1.2.3:
version "1.2.3"
resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.2.3.tgz#9f939dfb5740807bbf9dd79cdd4e99d27e827756"
integrity sha512-DPBPh1i2adCZoIArRlTuKRy7yue7QogtEnfv0AKrWsY+GA+4EKe37zhRDouNnyWMoNQFYZZRF+2dLHsWE4YvJA==
use-media@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/use-media/-/use-media-1.4.0.tgz#e777bf1f382a7aacabbd1f9ce3da2b62e58b2a98"
integrity sha512-XsgyUAf3nhzZmEfhc5MqLHwyaPjs78bgytpVJ/xDl0TF4Bptf3vEpBNBBT/EIKOmsOc8UbuECq3mrP3mt1QANA==
use-sidecar@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.0.2.tgz#e72f582a75842f7de4ef8becd6235a4720ad8af6"
integrity sha512-287RZny6m5KNMTb/Kq9gmjafi7lQL0YHO1lYolU6+tY1h9+Z3uCtkJJ3OSOq3INwYf2hBryCcDh4520AhJibMA==
dependencies:
detect-node "^2.0.4"
tslib "^1.9.3"
use@^3.1.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
@@ -17470,7 +17448,7 @@ walletlink@^2.0.2:
preact "^10.3.3"
rxjs "^6.5.4"
warning@^4.0.2:
warning@^4.0.2, warning@^4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3"
integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==