Updates to Token Modal (#399)

This commit is contained in:
Ian Lapham 2019-08-12 20:37:32 -04:00 committed by Noah Zinsmeister
parent be2012cdf5
commit 677537ca31
25 changed files with 4207 additions and 981 deletions

4
.gitignore vendored

@ -24,4 +24,6 @@ yarn-debug.log*
yarn-error.log*
notes.txt
.idea/
.idea/
.vscode/

@ -7,9 +7,10 @@
"dependencies": {
"@reach/dialog": "^0.2.8",
"@reach/tooltip": "^0.2.0",
"@uniswap/sdk": "^1.0.0-beta.4",
"copy-to-clipboard": "^3.2.0",
"escape-string-regexp": "^2.0.0",
"ethers": "^4.0.28",
"ethers": "^4.0.33",
"i18next": "^15.0.9",
"i18next-browser-languagedetector": "^3.0.1",
"i18next-xhr-backend": "^2.0.1",

@ -18,7 +18,8 @@
"unlock": "Unlock",
"pending": "Pending",
"selectToken": "Select a token",
"searchOrPaste": "Search Token or Paste Address",
"searchOrPaste": "Search Token Name, Symbol, or Address",
"searchOrPasteMobile": "Name, Symbol, or Address",
"noExchange": "No Exchange Found",
"exchangeRate": "Exchange Rate",
"unknownError": "Oops! An unknown error occurred. Please refresh the page, or visit from another browser or device.",
@ -36,6 +37,7 @@
"youWillReceive": "You will receive at least",
"youAreBuying": "You are buying",
"itWillCost": "It will cost at most",
"forAtMost": "for at most",
"insufficientBalance": "Insufficient Balance",
"inputNotValid": "Not a valid input value",
"differentToken": "Must be different token.",

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 9.27455 20.9097 6.80375 19.1414 5" stroke="#AEAEAE" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 321 B

1
src/assets/images/x.svg Normal file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-x"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>

After

Width:  |  Height:  |  Size: 299 B

@ -2,6 +2,7 @@ import React, { useState, useRef, useMemo } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { ethers } from 'ethers'
import { BigNumber } from '@uniswap/sdk'
import styled from 'styled-components'
import escapeStringRegex from 'escape-string-regexp'
import { darken } from 'polished'
@ -11,14 +12,18 @@ import { isMobile } from 'react-device-detect'
import { BorderlessInput } from '../../theme'
import { useTokenContract } from '../../hooks'
import { isAddress, calculateGasMargin } from '../../utils'
import { isAddress, calculateGasMargin, formatToUsd, formatTokenBalance, formatEthBalance } from '../../utils'
import { ReactComponent as DropDown } from '../../assets/images/dropdown.svg'
import Modal from '../Modal'
import TokenLogo from '../TokenLogo'
import SearchIcon from '../../assets/images/magnifying-glass.svg'
import { useTransactionAdder, usePendingApproval } from '../../contexts/Transactions'
import { useTokenDetails, useAllTokenDetails } from '../../contexts/Tokens'
import close from '../../assets/images/x.svg'
import { transparentize } from 'polished'
import { Spinner } from '../../theme'
import Circle from '../../assets/images/circle-grey.svg'
import { useUSDPrice } from '../../contexts/Application'
const GAS_MARGIN = ethers.utils.bigNumberify(1000)
@ -35,7 +40,6 @@ const SubCurrencySelect = styled.button`
outline: none;
cursor: pointer;
user-select: none;
`
const InputRow = styled.div`
@ -52,8 +56,11 @@ const Input = styled(BorderlessInput)`
`
const StyledBorderlessInput = styled(BorderlessInput)`
min-height: 1.75rem;
min-height: 2.5rem;
flex-shrink: 0;
text-align: left;
padding-left: 1.6rem;
background-color: ${({ theme }) => theme.concreteGray};
`
const CurrencySelect = styled.button`
@ -152,10 +159,27 @@ const TokenModal = styled.div`
width: 100%;
`
const ModalHeader = styled.div`
position: relative;
display: flex;
flex-direction: row;
align-items: center;
padding: 0 2rem;
height: 60px;
`
const CloseIcon = styled.div`
position: absolute;
right: 1.4rem;
&:hover {
cursor: pointer;
}
`
const SearchContainer = styled.div`
${({ theme }) => theme.flexRowNoWrap}
padding: 1rem;
border-bottom: 1px solid ${({ theme }) => theme.mercuryGray};
padding: 0.5rem 2rem;
background-color: ${({ theme }) => theme.concreteGray};
`
const TokenModalInfo = styled.div`
@ -177,9 +201,8 @@ const TokenList = styled.div`
const TokenModalRow = styled.div`
${({ theme }) => theme.flexRowNoWrap}
align-items: center;
padding: 1rem 1.5rem;
margin: 0.25rem 0.5rem;
justify-content: space-between;
padding: 0.8rem 2rem;
cursor: pointer;
user-select: none;
@ -188,16 +211,55 @@ const TokenModalRow = styled.div`
}
:hover {
background-color: ${({ theme }) => theme.concreteGray};
background-color: ${({ theme }) => theme.tokenRowHover};
}
${({ theme }) => theme.mediaWidth.upToMedium`padding: 0.8rem 1rem;`}
`
const TokenRowLeft = styled.div`
${({ theme }) => theme.flexRowNoWrap}
align-items : center;
`
const TokenSymbolGroup = styled.div`
${({ theme }) => theme.flexColumnNoWrap};
margin-left: 1rem;
`
const TokenFullName = styled.div`
color: ${({ theme }) => theme.chaliceGray};
`
const TokenRowBalance = styled.div`
font-size: 1rem;
line-height: 20px;
`
const TokenRowUsd = styled.div`
font-size: 1rem;
line-height: 1.5rem;
color: ${({ theme }) => theme.chaliceGray};
`
const TokenRowRight = styled.div`
${({ theme }) => theme.flexColumnNoWrap};
align-items: flex-end;
`
const StyledTokenName = styled.span`
margin: 0 0.25rem 0 0.25rem;
`
const SpinnerWrapper = styled(Spinner)`
margin: 0 0.25rem 0 0.25rem;
color: ${({ theme }) => theme.chaliceGray};
opacity: 0.6;
`
export default function CurrencyInputPanel({
onValueChange = () => {},
allBalances,
renderInput,
onCurrencySelected = () => {},
title,
@ -236,7 +298,6 @@ export default function CurrencyInputPanel({
selectedTokenExchangeAddress,
ethers.constants.MaxUint256
)
tokenContract
.approve(selectedTokenExchangeAddress, ethers.constants.MaxUint256, {
gasLimit: calculateGasMargin(estimatedGas, GAS_MARGIN)
@ -337,48 +398,106 @@ export default function CurrencyInputPanel({
{!disableTokenSelect && (
<CurrencySelectModal
isOpen={modalIsOpen}
// isOpen={true}
onDismiss={() => {
setModalIsOpen(false)
}}
onTokenSelect={onCurrencySelected}
allBalances={allBalances}
/>
)}
</InputPanel>
)
}
function CurrencySelectModal({ isOpen, onDismiss, onTokenSelect }) {
function CurrencySelectModal({ isOpen, onDismiss, onTokenSelect, allBalances }) {
const { t } = useTranslation()
const [searchQuery, setSearchQuery] = useState('')
const { exchangeAddress } = useTokenDetails(searchQuery)
const allTokens = useAllTokenDetails()
// BigNumber.js instance
const ethPrice = useUSDPrice()
const _usdAmounts = Object.keys(allTokens).map(k => {
if (
ethPrice &&
allBalances &&
allBalances[k] &&
allBalances[k].ethRate &&
!allBalances[k].ethRate.isNaN() &&
allBalances[k].balance
) {
const USDRate = ethPrice.times(allBalances[k].ethRate)
const balanceBigNumber = new BigNumber(allBalances[k].balance.toString())
const usdBalance = balanceBigNumber.times(USDRate).div(new BigNumber(10).pow(allTokens[k].decimals))
return usdBalance
} else {
return null
}
})
const usdAmounts =
_usdAmounts &&
Object.keys(allTokens).reduce(
(accumulator, currentValue, i) => Object.assign({ [currentValue]: _usdAmounts[i] }, accumulator),
{}
)
const tokenList = useMemo(() => {
return Object.keys(allTokens)
.sort((a, b) => {
const aSymbol = allTokens[a].symbol.toLowerCase()
const bSymbol = allTokens[b].symbol.toLowerCase()
if (aSymbol === 'ETH'.toLowerCase() || bSymbol === 'ETH'.toLowerCase()) {
return aSymbol === bSymbol ? 0 : aSymbol === 'ETH'.toLowerCase() ? -1 : 1
} else {
return aSymbol < bSymbol ? -1 : aSymbol > bSymbol ? 1 : 0
}
if (usdAmounts[a] && !usdAmounts[b]) {
return -1
} else if (usdAmounts[b] && !usdAmounts[a]) {
return 1
}
// check for balance - sort by value
if (usdAmounts[a] && usdAmounts[b]) {
const aUSD = usdAmounts[a]
const bUSD = usdAmounts[b]
return aUSD.gt(bUSD) ? -1 : aUSD.lt(bUSD) ? 1 : 0
}
return aSymbol < bSymbol ? -1 : aSymbol > bSymbol ? 1 : 0
})
.map(k => {
let balance
let usdBalance
// only update if we have data
if (k === 'ETH' && allBalances && allBalances[k]) {
balance = formatEthBalance(allBalances[k].balance)
usdBalance = usdAmounts[k]
} else if (allBalances && allBalances[k]) {
balance = formatTokenBalance(allBalances[k].balance, allTokens[k].decimals)
usdBalance = usdAmounts[k]
}
return {
name: allTokens[k].name,
symbol: allTokens[k].symbol,
address: k
address: k,
balance: balance,
usdBalance: usdBalance
}
})
}, [allTokens])
}, [allBalances, allTokens, usdAmounts])
const filteredTokenList = useMemo(() => {
return tokenList.filter(tokenEntry => {
// check the regex for each field
const regexMatches = Object.keys(tokenEntry).map(tokenEntryKey => {
return (
tokenEntry[tokenEntryKey] &&
typeof tokenEntry[tokenEntryKey] === 'string' &&
!!tokenEntry[tokenEntryKey].match(new RegExp(escapeStringRegex(searchQuery), 'i'))
)
})
@ -397,7 +516,6 @@ function CurrencySelectModal({ isOpen, onDismiss, onTokenSelect }) {
if (isAddress(searchQuery) && exchangeAddress === undefined) {
return <TokenModalInfo>Searching for Exchange...</TokenModalInfo>
}
if (isAddress(searchQuery) && exchangeAddress === ethers.constants.AddressZero) {
return (
<>
@ -408,16 +526,30 @@ function CurrencySelectModal({ isOpen, onDismiss, onTokenSelect }) {
</>
)
}
if (!filteredTokenList.length) {
return <TokenModalInfo>{t('noExchange')}</TokenModalInfo>
}
return filteredTokenList.map(({ address, symbol }) => {
return filteredTokenList.map(({ address, symbol, name, balance, usdBalance }) => {
return (
<TokenModalRow key={address} onClick={() => _onTokenSelect(address)}>
<TokenLogo address={address} />
<span id="symbol">{symbol}</span>
<TokenRowLeft>
<TokenLogo address={address} size={'2rem'} />
<TokenSymbolGroup>
<span id="symbol">{symbol}</span>
<TokenFullName>{name}</TokenFullName>
</TokenSymbolGroup>
</TokenRowLeft>
<TokenRowRight>
{balance ? (
<TokenRowBalance>{balance && (balance > 0 || balance === '<0.0001') ? balance : '-'}</TokenRowBalance>
) : (
<SpinnerWrapper src={Circle} alt="loader" />
)}
<TokenRowUsd>
{usdBalance ? (usdBalance.lt(0.01) ? '<$0.01' : '$' + formatToUsd(usdBalance)) : ''}
</TokenRowUsd>
</TokenRowRight>
</TokenModalRow>
)
})
@ -432,12 +564,33 @@ function CurrencySelectModal({ isOpen, onDismiss, onTokenSelect }) {
setSearchQuery(checksummedInput || input)
}
function clearInputAndDismiss() {
setSearchQuery('')
onDismiss()
}
return (
<Modal isOpen={isOpen} onDismiss={onDismiss} minHeight={50} initialFocusRef={isMobile ? undefined : inputRef}>
<Modal
isOpen={isOpen}
onDismiss={clearInputAndDismiss}
minHeight={60}
initialFocusRef={isMobile ? undefined : inputRef}
>
<TokenModal>
<ModalHeader>
<p>Select Token</p>
<CloseIcon onClick={clearInputAndDismiss}>
<img src={close} alt={'close icon'} />
</CloseIcon>
</ModalHeader>
<SearchContainer>
<StyledBorderlessInput ref={inputRef} type="text" placeholder={t('searchOrPaste')} onChange={onInput} />
<img src={SearchIcon} alt="search" />
<StyledBorderlessInput
ref={inputRef}
type="text"
placeholder={isMobile ? t('searchOrPasteMobile') : t('searchOrPaste')}
onChange={onInput}
/>
</SearchContainer>
<TokenList>{renderTokenList()}</TokenList>
</TokenModal>

@ -1,7 +1,9 @@
import React, { useState, useReducer, useEffect } from 'react'
import ReactGA from 'react-ga'
import { useTranslation } from 'react-i18next'
import { useWeb3Context } from 'web3-react'
import { ethers } from 'ethers'
import styled from 'styled-components'
@ -16,6 +18,7 @@ import { useExchangeContract } from '../../hooks'
import { useTokenDetails } from '../../contexts/Tokens'
import { useTransactionAdder } from '../../contexts/Transactions'
import { useAddressBalance, useExchangeReserves } from '../../contexts/Balances'
import { useFetchAllBalances } from '../../contexts/AllBalances'
import { useAddressAllowance } from '../../contexts/Allowances'
const INPUT = 0
@ -251,6 +254,7 @@ export default function ExchangePage({ initialCurrency, sending }) {
// core swap state
const [swapState, dispatchSwapState] = useReducer(swapStateReducer, initialCurrency, getInitialSwapState)
const { independentValue, dependentValue, independentField, inputCurrency, outputCurrency } = swapState
const [recipient, setRecipient] = useState({ address: '', name: '' })
@ -583,10 +587,13 @@ export default function ExchangePage({ initialCurrency, sending }) {
const [customSlippageError, setcustomSlippageError] = useState('')
const allBalances = useFetchAllBalances()
return (
<>
<CurrencyInputPanel
title={t('input')}
allBalances={allBalances}
description={inputValueFormatted && independentField === OUTPUT ? estimatedText : ''}
extraText={inputBalanceFormatted && formatBalance(inputBalanceFormatted)}
extraTextClickHander={() => {
@ -626,6 +633,7 @@ export default function ExchangePage({ initialCurrency, sending }) {
</OversizedPanel>
<CurrencyInputPanel
title={t('output')}
allBalances={allBalances}
description={outputValueFormatted && independentField === INPUT ? estimatedText : ''}
extraText={outputBalanceFormatted && formatBalance(outputBalanceFormatted)}
onCurrencySelected={outputCurrency => {

@ -28,7 +28,7 @@ const StyledDialogContent = styled(FilteredDialogContent)`
width: 50vw;
max-width: 650px;
${({ theme }) => theme.mediaWidth.upToMedium`width: 65vw;`}
${({ theme }) => theme.mediaWidth.upToSmall`width: 80vw;`}
${({ theme }) => theme.mediaWidth.upToSmall`width: 85vw;`}
max-height: 50vh;
${({ minHeight }) =>
minHeight &&
@ -39,7 +39,7 @@ const StyledDialogContent = styled(FilteredDialogContent)`
${({ theme }) => theme.mediaWidth.upToSmall`max-height: 80vh;`}
display: flex;
overflow: hidden;
border-radius: 1.5rem;
border-radius: 10px;
}
`

@ -10,12 +10,13 @@ const BAD_IMAGES = {}
const Image = styled.img`
width: ${({ size }) => size};
height: ${({ size }) => size};
background-color: white;
border-radius: 1rem;
`
const Emoji = styled.span`
width: ${({ size }) => size};
font-size: ${({ size }) => size};
height: ${({ size }) => size};
`
const StyledEthereumLogo = styled(EthereumLogo)`
@ -33,7 +34,7 @@ export default function TokenLogo({ address, size = '1rem', ...rest }) {
path = TOKEN_ICON_API(address.toLowerCase())
} else {
return (
<Emoji {...rest}>
<Emoji {...rest} size={size}>
<span role="img" aria-label="Thinking">
🤔
</span>

@ -580,7 +580,6 @@ export default function TransactionDetails(props) {
)} ${props.inputSymbol}`
)}
</ValueWrapper>
.
</div>
<LastSummaryText>
{b(props.recipientAddress)} {t('willReceive')}{' '}
@ -595,7 +594,7 @@ export default function TransactionDetails(props) {
</ValueWrapper>{' '}
</LastSummaryText>
<LastSummaryText>
{t('priceChange')} <ValueWrapper>{b(`${props.percentSlippageFormatted}%`)}</ValueWrapper>.
{t('priceChange')} <ValueWrapper>{b(`${props.percentSlippageFormatted}%`)}</ValueWrapper>
</LastSummaryText>
</TransactionInfo>
) : (
@ -623,7 +622,7 @@ export default function TransactionDetails(props) {
</ValueWrapper>
</div>
<LastSummaryText>
{t('priceChange')} <ValueWrapper>{b(`${props.percentSlippageFormatted}%`)}</ValueWrapper>.
{t('priceChange')} <ValueWrapper>{b(`${props.percentSlippageFormatted}%`)}</ValueWrapper>
</LastSummaryText>
</TransactionInfo>
)
@ -641,10 +640,7 @@ export default function TransactionDetails(props) {
)} ${props.outputSymbol}`
)}
</ValueWrapper>{' '}
{t('to')} {b(props.recipientAddress)}
</div>
<LastSummaryText>
{t('itWillCost')}{' '}
{t('to')} {b(props.recipientAddress)} {t('forAtMost')}{' '}
<ValueWrapper>
{b(
`${amountFormatter(
@ -654,39 +650,35 @@ export default function TransactionDetails(props) {
)} ${props.inputSymbol}`
)}
</ValueWrapper>{' '}
</LastSummaryText>
</div>
<LastSummaryText>
{t('priceChange')} <ValueWrapper>{b(`${props.percentSlippageFormatted}%`)}</ValueWrapper>.
{t('priceChange')} <ValueWrapper>{b(`${props.percentSlippageFormatted}%`)}</ValueWrapper>
</LastSummaryText>
</TransactionInfo>
) : (
<TransactionInfo>
<div>
{t('youAreBuying')}{' '}
<ValueWrapper>
{b(
`${amountFormatter(
props.independentValueParsed,
props.independentDecimals,
Math.min(4, props.independentDecimals)
)} ${props.outputSymbol}`
)}
</ValueWrapper>
</div>
{t('youAreBuying')}{' '}
<ValueWrapper>
{b(
`${amountFormatter(
props.independentValueParsed,
props.independentDecimals,
Math.min(4, props.independentDecimals)
)} ${props.outputSymbol}`
)}
</ValueWrapper>{' '}
{t('forAtMost')}{' '}
<ValueWrapper>
{b(
`${amountFormatter(
props.dependentValueMaximum,
props.dependentDecimals,
Math.min(4, props.dependentDecimals)
)} ${props.inputSymbol}`
)}
</ValueWrapper>{' '}
<LastSummaryText>
{t('itWillCost')}{' '}
<ValueWrapper>
{b(
`${amountFormatter(
props.dependentValueMaximum,
props.dependentDecimals,
Math.min(4, props.dependentDecimals)
)} ${props.inputSymbol}`
)}
</ValueWrapper>{' '}
</LastSummaryText>
<LastSummaryText>
{t('priceChange')} <ValueWrapper>{b(`${props.percentSlippageFormatted}%`)}</ValueWrapper>.
{t('priceChange')} <ValueWrapper>{b(`${props.percentSlippageFormatted}%`)}</ValueWrapper>
</LastSummaryText>
</TransactionInfo>
)

@ -59,9 +59,13 @@ const Web3StatusConnected = styled(Web3StatusGeneric)`
color: ${({ pending, theme }) => (pending ? theme.royalBlue : theme.doveGray)};
font-weight: 400;
:hover {
> P {
color: ${({ theme }) => theme.uniswapPink};
}
background-color: ${({ pending, theme }) =>
pending ? transparentize(0.9, theme.royalBlue) : transparentize(0.9, theme.mercuryGray)};
}
:focus {
border: 1px solid
${({ pending, theme }) => (pending ? darken(0.1, theme.royalBlue) : darken(0.1, theme.mercuryGray))};

102
src/contexts/AllBalances.js Normal file

@ -0,0 +1,102 @@
import React, { createContext, useContext, useReducer, useMemo, useCallback } from 'react'
import { ethers } from 'ethers'
import { getTokenReserves, getMarketDetails, BigNumber } from '@uniswap/sdk'
import { useWeb3Context } from 'web3-react'
import { safeAccess, isAddress, getEtherBalance, getTokenBalance } from '../utils'
import { useAllTokenDetails } from './Tokens'
const ZERO = ethers.utils.bigNumberify(0)
const ONE = new BigNumber(1)
const UPDATE = 'UPDATE'
const AllBalancesContext = createContext()
function useAllBalancesContext() {
return useContext(AllBalancesContext)
}
function reducer(state, { type, payload }) {
switch (type) {
case UPDATE: {
const { allBalanceData, networkId, address } = payload
return {
...state,
[networkId]: {
...(safeAccess(state, [networkId]) || {}),
[address]: {
...(safeAccess(state, [networkId, address]) || {}),
allBalanceData
}
}
}
}
default: {
throw Error(`Unexpected action type in AllBalancesContext reducer: '${type}'.`)
}
}
}
export default function Provider({ children }) {
const [state, dispatch] = useReducer(reducer, {})
const update = useCallback((allBalanceData, networkId, address) => {
dispatch({ type: UPDATE, payload: { allBalanceData, networkId, address } })
}, [])
return (
<AllBalancesContext.Provider value={useMemo(() => [state, { update }], [state, update])}>
{children}
</AllBalancesContext.Provider>
)
}
export function useFetchAllBalances() {
const { account, networkId, library } = useWeb3Context()
const allTokens = useAllTokenDetails()
const [state, { update }] = useAllBalancesContext()
const { allBalanceData } = safeAccess(state, [networkId, account]) || {}
const getData = async () => {
if (!!library && !!account) {
const newBalances = {}
await Promise.all(
Object.keys(allTokens).map(async k => {
let balance = null
let ethRate = null
if (isAddress(k) || k === 'ETH') {
if (k === 'ETH') {
balance = await getEtherBalance(account, library).catch(() => null)
ethRate = ONE
} else {
balance = await getTokenBalance(k, account, library).catch(() => null)
// only get values for tokens with positive balances
if (!!balance && balance.gt(ZERO)) {
const tokenReserves = await getTokenReserves(k, library).catch(() => null)
if (!!tokenReserves) {
const marketDetails = getMarketDetails(tokenReserves)
if (marketDetails.marketRate && marketDetails.marketRate.rate) {
ethRate = marketDetails.marketRate.rate
}
}
}
}
return (newBalances[k] = { balance, ethRate })
}
})
)
update(newBalances, networkId, account)
}
}
useMemo(getData, [account, state])
return allBalanceData
}

@ -1,10 +1,14 @@
import React, { createContext, useContext, useReducer, useMemo, useCallback, useEffect } from 'react'
import { useWeb3Context } from 'web3-react'
import { safeAccess } from '../utils'
const BLOCK_NUMBERS = 'BLOCK_NUMBERS'
import { safeAccess } from '../utils'
import { getUSDPrice } from '../utils/price'
const BLOCK_NUMBER = 'BLOCK_NUMBER'
const USD_PRICE = 'USD_PRICE'
const UPDATE_BLOCK_NUMBER = 'UPDATE_BLOCK_NUMBER'
const UPDATE_USD_PRICE = 'UPDATE_USD_PRICE'
const ApplicationContext = createContext()
@ -18,12 +22,22 @@ function reducer(state, { type, payload }) {
const { networkId, blockNumber } = payload
return {
...state,
[BLOCK_NUMBERS]: {
...(safeAccess(state, [BLOCK_NUMBERS]) || {}),
[BLOCK_NUMBER]: {
...(safeAccess(state, [BLOCK_NUMBER]) || {}),
[networkId]: blockNumber
}
}
}
case UPDATE_USD_PRICE: {
const { networkId, USDPrice } = payload
return {
...state,
[USD_PRICE]: {
...(safeAccess(state, [USD_PRICE]) || {}),
[networkId]: USDPrice
}
}
}
default: {
throw Error(`Unexpected action type in ApplicationContext reducer: '${type}'.`)
}
@ -32,15 +46,22 @@ function reducer(state, { type, payload }) {
export default function Provider({ children }) {
const [state, dispatch] = useReducer(reducer, {
[BLOCK_NUMBERS]: {}
[BLOCK_NUMBER]: {},
[USD_PRICE]: {}
})
const updateBlockNumber = useCallback((networkId, blockNumber) => {
dispatch({ type: UPDATE_BLOCK_NUMBER, payload: { networkId, blockNumber } })
}, [])
const updateUSDPrice = useCallback((networkId, USDPrice) => {
dispatch({ type: UPDATE_USD_PRICE, payload: { networkId, USDPrice } })
}, [])
return (
<ApplicationContext.Provider value={useMemo(() => [state, { updateBlockNumber }], [state, updateBlockNumber])}>
<ApplicationContext.Provider
value={useMemo(() => [state, { updateBlockNumber, updateUSDPrice }], [state, updateBlockNumber, updateUSDPrice])}
>
{children}
</ApplicationContext.Provider>
)
@ -49,7 +70,24 @@ export default function Provider({ children }) {
export function Updater() {
const { networkId, library } = useWeb3Context()
const [, { updateBlockNumber }] = useApplicationContext()
const globalBlockNumber = useBlockNumber()
const [, { updateBlockNumber, updateUSDPrice }] = useApplicationContext()
useEffect(() => {
let stale = false
getUSDPrice(library)
.then(([price]) => {
if (!stale) {
updateUSDPrice(networkId, price)
}
})
.catch(() => {
if (!stale) {
updateUSDPrice(networkId, null)
}
})
}, [globalBlockNumber, library, networkId, updateUSDPrice])
useEffect(() => {
if ((networkId || networkId === 0) && library) {
@ -88,5 +126,13 @@ export function useBlockNumber() {
const [state] = useApplicationContext()
return safeAccess(state, [BLOCK_NUMBERS, networkId])
return safeAccess(state, [BLOCK_NUMBER, networkId])
}
export function useUSDPrice() {
const { networkId } = useWeb3Context()
const [state] = useApplicationContext()
return safeAccess(state, [USD_PRICE, networkId])
}

@ -79,7 +79,6 @@ export function useAddressBalance(address, tokenAddress) {
update(networkId, address, tokenAddress, null, globalBlockNumber)
}
})
return () => {
stale = true
}

@ -8,6 +8,7 @@ const LAST_SAVED = 'LAST_SAVED'
const BETA_MESSAGE_DISMISSED = 'BETA_MESSAGE_DISMISSED'
const DARK_MODE = 'DARK_MODE'
const UPDATABLE_KEYS = [BETA_MESSAGE_DISMISSED, DARK_MODE]
const UPDATE_KEY = 'UPDATE_KEY'

@ -466,7 +466,6 @@ export function useTokenDetails(tokenAddress) {
}
}
)
return () => {
stale = true
}

@ -10,6 +10,7 @@ import TransactionContextProvider, { Updater as TransactionContextUpdater } from
import TokensContextProvider from './contexts/Tokens'
import BalancesContextProvider from './contexts/Balances'
import AllowancesContextProvider from './contexts/Allowances'
import AllBalancesContextProvider from './contexts/AllBalances'
import App from './pages/App'
import InjectedConnector from './InjectedConnector'
@ -35,7 +36,9 @@ function ContextProviders({ children }) {
<TransactionContextProvider>
<TokensContextProvider>
<BalancesContextProvider>
<AllowancesContextProvider>{children}</AllowancesContextProvider>
<AllBalancesContextProvider>
<AllowancesContextProvider>{children}</AllowancesContextProvider>
</AllBalancesContextProvider>
</BalancesContextProvider>
</TokensContextProvider>
</TransactionContextProvider>

@ -15,6 +15,7 @@ import { useExchangeContract } from '../../hooks'
import { amountFormatter, calculateGasMargin } from '../../utils'
import { useTransactionAdder } from '../../contexts/Transactions'
import { useTokenDetails } from '../../contexts/Tokens'
import { useFetchAllBalances } from '../../contexts/AllBalances'
import { useAddressBalance, useExchangeReserves } from '../../contexts/Balances'
import { useAddressAllowance } from '../../contexts/Allowances'
@ -534,6 +535,8 @@ export default function AddLiquidity() {
const isActive = active && account
const isValid = (inputError === null || outputError === null) && !showUnlock
const allBalances = useFetchAllBalances()
return (
<>
{isNewExchange ? (
@ -550,6 +553,7 @@ export default function AddLiquidity() {
<CurrencyInputPanel
title={t('deposit')}
allBalances={allBalances}
extraText={inputBalance && formatBalance(amountFormatter(inputBalance, 18, 4))}
onValueChange={inputValue => {
dispatchAddLiquidityState({ type: 'UPDATE_VALUE', payload: { value: inputValue, field: INPUT } })
@ -566,6 +570,7 @@ export default function AddLiquidity() {
</OversizedPanel>
<CurrencyInputPanel
title={t('deposit')}
allBalances={allBalances}
description={isNewExchange ? '' : outputValue ? `(${t('estimated')})` : ''}
extraText={outputBalance && formatBalance(amountFormatter(outputBalance, decimals, Math.min(decimals, 4)))}
selectedTokenAddress={outputCurrency}

@ -15,6 +15,7 @@ import { useExchangeContract } from '../../hooks'
import { useTransactionAdder } from '../../contexts/Transactions'
import { useTokenDetails } from '../../contexts/Tokens'
import { useAddressBalance } from '../../contexts/Balances'
import { useFetchAllBalances } from '../../contexts/AllBalances'
import { calculateGasMargin, amountFormatter } from '../../utils'
// denominated in bips
@ -328,10 +329,13 @@ export default function RemoveLiquidity() {
const marketRate = getMarketRate(exchangeETHBalance, exchangeTokenBalance, decimals)
const allBalances = useFetchAllBalances()
return (
<>
<CurrencyInputPanel
title={t('poolTokens')}
allBalances={allBalances}
extraText={poolTokenBalance && formatBalance(amountFormatter(poolTokenBalance, 18, 4))}
extraTextClickHander={() => {
if (poolTokenBalance) {
@ -354,6 +358,7 @@ export default function RemoveLiquidity() {
</OversizedPanel>
<CurrencyInputPanel
title={t('output')}
allBalances={allBalances}
description={!!(ethWithdrawn && tokenWithdrawn) ? `(${t('estimated')})` : ''}
key="remove-liquidity-input"
renderInput={() =>

@ -69,7 +69,7 @@ export const BorderlessInput = styled.input`
}
::placeholder {
color: ${({ theme }) => theme.placeholderGray};
color: ${({ theme }) => theme.chaliceGray};
}
`

@ -51,12 +51,14 @@ const theme = darkMode => ({
doveGray: darkMode ? '#C4C4C4' : '#737373',
mineshaftGray: darkMode ? '#E1E1E1' : '#2B2B2B',
buttonOutlineGrey: darkMode ? '#FAFAFA' : '#F2F2F2',
tokenRowHover: darkMode ? '#404040' : '#F2F2F2',
//blacks
charcoalBlack: darkMode ? '#F2F2F2' : '#404040',
// blues
zumthorBlue: darkMode ? '#212529' : '#EBF4FF',
malibuBlue: darkMode ? '#E67AEF' : '#5CA2FF',
royalBlue: darkMode ? '#DC6BE5' : '#2F80ED',
loadingBlue: darkMode ? '#e4f0ff' : '#e4f0ff',
// purples
wisteriaPurple: '#DC6BE5',
// reds
@ -69,6 +71,9 @@ const theme = darkMode => ({
uniswapPink: '#DC6BE5',
connectedGreen: '#27AE60',
//specific
textHover: darkMode ? theme.uniswapPink : theme.doveGray,
// media queries
mediaWidth: mediaWidthTemplates,
// css snippets

@ -5,6 +5,7 @@ import EXCHANGE_ABI from '../constants/abis/exchange'
import ERC20_ABI from '../constants/abis/erc20'
import ERC20_BYTES32_ABI from '../constants/abis/erc20_bytes32'
import { FACTORY_ADDRESSES } from '../constants'
import { formatFixed } from '@uniswap/sdk'
import UncheckedJsonRpcSigner from './signer'
@ -178,10 +179,27 @@ export async function getEtherBalance(address, library) {
if (!isAddress(address)) {
throw Error(`Invalid 'address' parameter '${address}'`)
}
return library.getBalance(address)
}
export function formatEthBalance(balance) {
return amountFormatter(balance, 18, 6)
}
export function formatTokenBalance(balance, decimal) {
return !!(balance && Number.isInteger(decimal)) ? amountFormatter(balance, decimal, Math.min(4, decimal)) : 0
}
export function formatToUsd(price) {
const format = { decimalSeparator: '.', groupSeparator: ',', groupSize: 3 }
const usdPrice = formatFixed(price, {
decimalPlaces: 2,
dropTrailingZeros: false,
format
})
return usdPrice
}
// get the token balance of an address
export async function getTokenBalance(tokenAddress, address, library) {
if (!isAddress(tokenAddress) || !isAddress(address)) {

49
src/utils/math.js Normal file

@ -0,0 +1,49 @@
import { BigNumber } from '@uniswap/sdk'
// returns a deep copied + sorted list of values, as well as a sortmap
export function sortBigNumbers(values) {
const valueMap = values.map((value, i) => ({ value, i }))
valueMap.sort((a, b) => {
if (a.value.isGreaterThan(b.value)) {
return 1
} else if (a.value.isLessThan(b.value)) {
return -1
} else {
return 0
}
})
return [
valueMap.map(element => values[element.i]),
values.map((_, i) => valueMap.findIndex(element => element.i === i))
]
}
export function getMedian(values) {
const [sortedValues, sortMap] = sortBigNumbers(values)
if (values.length % 2 === 0) {
const middle = values.length / 2
const indices = [middle - 1, middle]
return [
sortedValues[middle - 1].plus(sortedValues[middle]).dividedBy(2),
sortMap.map(element => (indices.includes(element) ? new BigNumber(0.5) : new BigNumber(0)))
]
} else {
const middle = Math.floor(values.length / 2)
return [sortedValues[middle], sortMap.map(element => (element === middle ? new BigNumber(1) : new BigNumber(0)))]
}
}
export function getMean(values, _weights) {
const weights = _weights ? _weights : values.map(() => new BigNumber(1))
const weightedValues = values.map((value, i) => value.multipliedBy(weights[i]))
const numerator = weightedValues.reduce(
(accumulator, currentValue) => accumulator.plus(currentValue),
new BigNumber(0)
)
const denominator = weights.reduce((accumulator, currentValue) => accumulator.plus(currentValue), new BigNumber(0))
return [numerator.dividedBy(denominator), weights.map(weight => weight.dividedBy(denominator))]
}

43
src/utils/price.js Normal file

@ -0,0 +1,43 @@
import { getTokenReserves, getMarketDetails } from '@uniswap/sdk'
import { getMedian, getMean } from './math'
const DAI = 'DAI'
const USDC = 'USDC'
const TUSD = 'TUSD'
const USD_STABLECOINS = [DAI, USDC, TUSD]
const USD_STABLECOIN_ADDRESSES = [
'0x89d24A6b4CcB1B6fAA2625fE562bDD9a23260359',
'0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
'0x8dd5fbCe2F6a956C3022bA3663759011Dd51e73E'
]
function forEachStablecoin(runner) {
return USD_STABLECOINS.map((stablecoin, index) => runner(index, stablecoin))
}
export async function getUSDPrice(library) {
return Promise.all(forEachStablecoin(i => getTokenReserves(USD_STABLECOIN_ADDRESSES[i], library))).then(reserves => {
const ethReserves = forEachStablecoin(i => reserves[i].ethReserve.amount)
const marketDetails = forEachStablecoin(i => getMarketDetails(reserves[i], undefined))
const ethPrices = forEachStablecoin(i => marketDetails[i].marketRate.rateInverted)
const [median, medianWeights] = getMedian(ethPrices)
const [mean, meanWeights] = getMean(ethPrices)
const [weightedMean, weightedMeanWeights] = getMean(ethPrices, ethReserves)
const ethPrice = getMean([median, mean, weightedMean])[0]
const _stablecoinWeights = [
getMean([medianWeights[0], meanWeights[0], weightedMeanWeights[0]])[0],
getMean([medianWeights[1], meanWeights[1], weightedMeanWeights[1]])[0],
getMean([medianWeights[2], meanWeights[2], weightedMeanWeights[2]])[0]
]
const stablecoinWeights = forEachStablecoin((i, stablecoin) => ({
[stablecoin]: _stablecoinWeights[i]
})).reduce((accumulator, currentValue) => ({ ...accumulator, ...currentValue }), {})
return [ethPrice, stablecoinWeights]
})
}

4592
yarn.lock

File diff suppressed because it is too large Load Diff