Slippage warning (#292)

* add rudimentary slippage warning

fix address checksum bug

debounce address input

alway show tx details if valid trade

* fix estimated copy
This commit is contained in:
Noah Zinsmeister 2019-05-16 13:21:14 -04:00 committed by GitHub
parent c6e165eaf7
commit e27cd92cd2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1144 additions and 1155 deletions

@ -26,6 +26,7 @@
"unlockTokenCont": "Please unlock token to continue.", "unlockTokenCont": "Please unlock token to continue.",
"transactionDetails": "Transaction Details", "transactionDetails": "Transaction Details",
"hideDetails": "Hide Details", "hideDetails": "Hide Details",
"slippageWarning": "Slippage Warning",
"youAreSelling": "You are selling", "youAreSelling": "You are selling",
"orTransFail": "or the transaction will fail.", "orTransFail": "or the transaction will fail.",
"youWillReceive": "You will receive at least", "youWillReceive": "You will receive at least",

@ -4,20 +4,23 @@ import { useTranslation } from 'react-i18next'
import { useWeb3Context } from 'web3-react' import { useWeb3Context } from 'web3-react'
import { isAddress } from '../../utils' import { isAddress } from '../../utils'
import { useDebounce } from '../../hooks'
// import QrCode from '../QrCode' // commented out pending further review // import QrCode from '../QrCode' // commented out pending further review
import './address-input-panel.scss' import './address-input-panel.scss'
export default function AddressInputPanel({ title, onChange = () => {}, onError = () => {} }) { export default function AddressInputPanel({ title, initialInput = '', onChange = () => {}, onError = () => {} }) {
const { t } = useTranslation() const { t } = useTranslation()
const { library } = useWeb3Context() const { library } = useWeb3Context()
const [input, setInput] = useState('') const [input, setInput] = useState(initialInput)
const debouncedInput = useDebounce(input, 150)
const [data, setData] = useState({ address: undefined, name: undefined }) const [data, setData] = useState({ address: undefined, name: undefined })
const [error, setError] = useState(false) const [error, setError] = useState(false)
// keep stuff in sync // keep data and errors in sync
useEffect(() => { useEffect(() => {
onChange({ address: data.address, name: data.name }) onChange({ address: data.address, name: data.name })
}, [onChange, data.address, data.name]) }, [onChange, data.address, data.name])
@ -25,29 +28,30 @@ export default function AddressInputPanel({ title, onChange = () => {}, onError
onError(error) onError(error)
}, [onError, error]) }, [onError, error])
// run parser on debounced input
useEffect(() => { useEffect(() => {
let stale = false let stale = false
if (isAddress(input)) { if (isAddress(debouncedInput)) {
library.lookupAddress(input).then(name => { library.lookupAddress(debouncedInput).then(name => {
if (!stale) { if (!stale) {
// if an ENS name exists, set it as the destination // if an ENS name exists, set it as the destination
if (name) { if (name) {
setInput(name) setInput(name)
} else { } else {
setData({ address: input, name: '' }) setData({ address: debouncedInput, name: '' })
setError(null) setError(null)
} }
} }
}) })
} else { } else {
if (input !== '') { if (debouncedInput !== '') {
try { try {
library.resolveName(input).then(address => { library.resolveName(debouncedInput).then(address => {
if (!stale) { if (!stale) {
// if the input name resolves to an address // if the debounced input name resolves to an address
if (address) { if (address) {
setData({ address: address, name: input }) setData({ address: address, name: debouncedInput })
setError(null) setError(null)
} else { } else {
setError(true) setError(true)
@ -62,10 +66,20 @@ export default function AddressInputPanel({ title, onChange = () => {}, onError
return () => { return () => {
stale = true stale = true
}
}, [debouncedInput, library, onChange, onError])
function onInput(event) {
if (data.address !== undefined || data.name !== undefined) {
setData({ address: undefined, name: undefined }) setData({ address: undefined, name: undefined })
}
if (error !== undefined) {
setError() setError()
} }
}, [input, library, onChange, onError]) const input = event.target.value
const checksummedInput = isAddress(input)
setInput(checksummedInput || input)
}
return ( return (
<div className="currency-input-panel"> <div className="currency-input-panel">
@ -83,11 +97,15 @@ export default function AddressInputPanel({ title, onChange = () => {}, onError
<div className="currency-input-panel__input-row"> <div className="currency-input-panel__input-row">
<input <input
type="text" type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className={classnames('address-input-panel__input', { className={classnames('address-input-panel__input', {
'address-input-panel__input--error': input !== '' && error 'address-input-panel__input--error': input !== '' && error
})} })}
placeholder="0x1234..." placeholder="0x1234..."
onChange={e => setInput(e.target.value)} onChange={onInput}
value={input} value={input}
/> />
</div> </div>

@ -0,0 +1,42 @@
@import '../../variables.scss';
.contextual-info {
&__summary-wrapper {
color: $dove-gray;
font-size: 0.75rem;
text-align: center;
margin-top: 1rem;
padding-top: 1rem;
}
&--error {
color: $salmon-red;
}
&__details {
background-color: $concrete-gray;
padding: 1.5rem;
border-radius: 1rem;
font-size: 0.75rem;
margin-top: 1rem;
}
&__open-details-container {
cursor: pointer;
@extend %row-nowrap;
align-items: center;
justify-content: center;
font-size: 0.75rem;
color: $royal-blue;
span {
margin-right: 12px;
}
img {
height: 0.75rem;
width: 0.75rem;
}
}
}

@ -0,0 +1,55 @@
import React, { useState } from 'react'
import styled from 'styled-components'
import c from 'classnames'
import { ReactComponent as Dropup } from '../../assets/images/dropup-blue.svg'
import { ReactComponent as Dropdown } from '../../assets/images/dropdown-blue.svg'
import './contextual-info.scss'
const WrappedDropup = ({ isError, ...rest }) => <Dropup {...rest} />
const ColoredDropup = styled(WrappedDropup)`
path {
stroke: ${props => props.isError && props.theme.salmonRed};
}
`
const WrappedDropdown = ({ isError, ...rest }) => <Dropdown {...rest} />
const ColoredDropdown = styled(WrappedDropdown)`
path {
stroke: ${props => props.isError && props.theme.salmonRed};
}
`
export default function ContextualInfo({
openDetailsText = 'Transaction Details',
closeDetailsText = 'Hide Details',
contextualInfo = '',
allowExpand = false,
renderTransactionDetails = () => {},
isError = false
}) {
const [showDetails, setShowDetails] = useState(false)
return !allowExpand ? (
<div className={c({ 'contextual-info--error': isError }, 'contextual-info__summary-wrapper')}>
<div>{contextualInfo}</div>
</div>
) : (
<>
<div
key="open-details"
className="contextual-info__summary-wrapper contextual-info__open-details-container"
onClick={() => setShowDetails(s => !s)}
>
<>
<span className={c({ 'contextual-info--error': isError })}>
{contextualInfo ? contextualInfo : showDetails ? closeDetailsText : openDetailsText}
</span>
{showDetails ? <ColoredDropup isError={isError} /> : <ColoredDropdown isError={isError} />}
</>
</div>
{showDetails && <div className="contextual-info__details">{renderTransactionDetails()}</div>}
</>
)
}

@ -281,6 +281,12 @@ function CurrencySelectModal({ onClose, onTokenSelect }) {
} }
}, []) }, [])
function onInput(event) {
const input = event.target.value
const checksummedInput = isAddress(input)
setSearchQuery(checksummedInput || input)
}
return ( return (
<Modal onClose={_onClose}> <Modal onClose={_onClose}>
<CSSTransitionGroup <CSSTransitionGroup
@ -298,9 +304,7 @@ function CurrencySelectModal({ onClose, onTokenSelect }) {
type="text" type="text"
placeholder={t('searchOrPaste')} placeholder={t('searchOrPaste')}
className="token-modal__search-input" className="token-modal__search-input"
onChange={e => { onChange={onInput}
setSearchQuery(e.target.value)
}}
/> />
<img src={SearchIcon} className="token-modal__search-icon" alt="search" /> <img src={SearchIcon} className="token-modal__search-icon" alt="search" />
</div> </div>

@ -1,9 +1,30 @@
import { useMemo, useCallback, useEffect } from 'react' import { useState, useMemo, useCallback, useEffect } from 'react'
import { useWeb3Context } from 'web3-react' import { useWeb3Context } from 'web3-react'
import ERC20_ABI from '../abi/erc20' import ERC20_ABI from '../abi/erc20'
import { getContract, getFactoryContract, getExchangeContract } from '../utils' import { getContract, getFactoryContract, getExchangeContract } from '../utils'
// modified from https://usehooks.com/useDebounce/
export function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(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/ // modified from https://usehooks.com/useKeyPress/
export function useBodyKeyDown(targetKey, onKeyDown, suppressOnKeyDown = false) { export function useBodyKeyDown(targetKey, onKeyDown, suppressOnKeyDown = false) {
const downHandler = useCallback( const downHandler = useCallback(

@ -297,7 +297,7 @@ export default function AddLiquidity() {
const deadline = Math.ceil(Date.now() / 1000) + DEADLINE_FROM_NOW const deadline = Math.ceil(Date.now() / 1000) + DEADLINE_FROM_NOW
const estimatedGasLimit = await exchangeContract.estimate.addLiquidity( const estimatedGasLimit = await exchangeContract.estimate.addLiquidity(
isNewExchange ? inputValueParsed : liquidityTokensMin, isNewExchange ? ethers.constants.Zero : liquidityTokensMin,
isNewExchange ? outputValueParsed : outputValueMax, isNewExchange ? outputValueParsed : outputValueMax,
deadline, deadline,
{ {
@ -307,7 +307,7 @@ export default function AddLiquidity() {
exchangeContract exchangeContract
.addLiquidity( .addLiquidity(
isNewExchange ? inputValueParsed : liquidityTokensMin, isNewExchange ? ethers.constants.Zero : liquidityTokensMin,
isNewExchange ? outputValueParsed : outputValueMax, isNewExchange ? outputValueParsed : outputValueMax,
deadline, deadline,
{ {
@ -476,7 +476,7 @@ export default function AddLiquidity() {
</OversizedPanel> </OversizedPanel>
<CurrencyInputPanel <CurrencyInputPanel
title={t('deposit')} title={t('deposit')}
description={isNewExchange ? `(${t('estimated')})` : ''} description={isNewExchange ? '' : outputValue ? `(${t('estimated')})` : ''}
extraText={outputBalance && formatBalance(amountFormatter(outputBalance, decimals, Math.min(decimals, 4)))} extraText={outputBalance && formatBalance(amountFormatter(outputBalance, decimals, Math.min(decimals, 4)))}
selectedTokenAddress={outputCurrency} selectedTokenAddress={outputCurrency}
onCurrencySelected={outputCurrency => { onCurrencySelected={outputCurrency => {

@ -18,7 +18,7 @@ function CreateExchange({ history, location }) {
const factory = useFactoryContract() const factory = useFactoryContract()
const [tokenAddress, setTokenAddress] = useState({ const [tokenAddress, setTokenAddress] = useState({
address: (location.state && location.state.tokenAddress) || '', address: '',
name: '' name: ''
}) })
const [tokenAddressError, setTokenAddressError] = useState() const [tokenAddressError, setTokenAddressError] = useState()
@ -74,7 +74,12 @@ function CreateExchange({ history, location }) {
return ( return (
<> <>
<AddressInputPanel title={t('tokenAddress')} onChange={setTokenAddress} onError={setTokenAddressError} /> <AddressInputPanel
title={t('tokenAddress')}
initialInput={(location.state && location.state.tokenAddress) || ''}
onChange={setTokenAddress}
onError={setTokenAddressError}
/>
<OversizedPanel hideBottom> <OversizedPanel hideBottom>
<div className="pool__summary-panel"> <div className="pool__summary-panel">
<div className="pool__exchange-rate-wrapper"> <div className="pool__exchange-rate-wrapper">

@ -284,7 +284,7 @@ export default function RemoveLiquidity() {
</OversizedPanel> </OversizedPanel>
<CurrencyInputPanel <CurrencyInputPanel
title={t('output')} title={t('output')}
description={`(${t('estimated')})`} description={ethWithdrawn && tokenWithdrawn ? `(${t('estimated')})` : ''}
key="remove-liquidity-input" key="remove-liquidity-input"
renderInput={() => renderInput={() =>
ethWithdrawn && tokenWithdrawn ? ( ethWithdrawn && tokenWithdrawn ? (

@ -5,7 +5,7 @@ import { useWeb3Context } from 'web3-react'
import { ethers } from 'ethers' import { ethers } from 'ethers'
import CurrencyInputPanel from '../../components/CurrencyInputPanel' import CurrencyInputPanel from '../../components/CurrencyInputPanel'
import ContextualInfo from '../../components/ContextualInfo' import NewContextualInfo from '../../components/ContextualInfoNew'
import OversizedPanel from '../../components/OversizedPanel' import OversizedPanel from '../../components/OversizedPanel'
import ArrowDownBlue from '../../assets/images/arrow-down-blue.svg' import ArrowDownBlue from '../../assets/images/arrow-down-blue.svg'
import ArrowDownGrey from '../../assets/images/arrow-down-grey.svg' import ArrowDownGrey from '../../assets/images/arrow-down-grey.svg'
@ -425,18 +425,16 @@ export default function Swap() {
) )
const percentSlippage = const percentSlippage =
exchangeRate && exchangeRate && marketRate
marketRate && ? exchangeRate
amountFormatter(
exchangeRate
.sub(marketRate) .sub(marketRate)
.abs() .abs()
.mul(ethers.utils.bigNumberify(10).pow(ethers.utils.bigNumberify(18))) .mul(ethers.utils.bigNumberify(10).pow(ethers.utils.bigNumberify(18)))
.div(marketRate) .div(marketRate)
.sub(ethers.utils.bigNumberify(3).mul(ethers.utils.bigNumberify(10).pow(ethers.utils.bigNumberify(15)))), .sub(ethers.utils.bigNumberify(3).mul(ethers.utils.bigNumberify(10).pow(ethers.utils.bigNumberify(15))))
16, : undefined
2 const percentSlippageFormatted = percentSlippage && amountFormatter(percentSlippage, 16, 2)
) const slippageWarning = percentSlippage && percentSlippage.gte(ethers.utils.parseEther('.1')) // 10%
const isValid = exchangeRate && inputError === null && independentError === null const isValid = exchangeRate && inputError === null && independentError === null
@ -479,7 +477,7 @@ export default function Swap() {
{t('orTransFail')} {t('orTransFail')}
</div> </div>
<div className="send__last-summary-text"> <div className="send__last-summary-text">
{t('priceChange')} {b(`${percentSlippage}%`)}. {t('priceChange')} {b(`${percentSlippageFormatted}%`)}.
</div> </div>
</div> </div>
) )
@ -509,7 +507,7 @@ export default function Swap() {
{t('orTransFail')} {t('orTransFail')}
</div> </div>
<div className="send__last-summary-text"> <div className="send__last-summary-text">
{t('priceChange')} {b(`${percentSlippage}%`)}. {t('priceChange')} {b(`${percentSlippageFormatted}%`)}.
</div> </div>
</div> </div>
) )
@ -533,10 +531,11 @@ export default function Swap() {
} }
return ( return (
<ContextualInfo <NewContextualInfo
openDetailsText={t('transactionDetails')} openDetailsText={t('transactionDetails')}
closeDetailsText={t('hideDetails')} closeDetailsText={t('hideDetails')}
contextualInfo={contextualInfo} contextualInfo={contextualInfo ? contextualInfo : slippageWarning ? t('slippageWarning') : ''}
allowExpand={!!(inputCurrency && outputCurrency && inputValueParsed && outputValueParsed)}
isError={isError} isError={isError}
renderTransactionDetails={renderTransactionDetails} renderTransactionDetails={renderTransactionDetails}
/> />

@ -4,6 +4,7 @@ import { ThemeProvider as StyledComponentsThemeProvider, createGlobalStyle } fro
const theme = { const theme = {
uniswapPink: '#DC6BE5', uniswapPink: '#DC6BE5',
royalBlue: '#2f80ed', royalBlue: '#2f80ed',
salmonRed: '#ff6871',
white: '#FFF', white: '#FFF',
black: '#000' black: '#000'
} }

@ -15,16 +15,17 @@ export const ERROR_CODES = ['TOKEN_NAME', 'TOKEN_SYMBOL', 'TOKEN_DECIMALS'].redu
) )
export function safeAccess(object, path) { export function safeAccess(object, path) {
return path.reduce( return object
? path.reduce(
(accumulator, currentValue) => (accumulator && accumulator[currentValue] ? accumulator[currentValue] : null), (accumulator, currentValue) => (accumulator && accumulator[currentValue] ? accumulator[currentValue] : null),
object object
) )
: null
} }
export function isAddress(value) { export function isAddress(value) {
try { try {
ethers.utils.getAddress(value) return ethers.utils.getAddress(value.toLowerCase())
return true
} catch { } catch {
return false return false
} }

2062
yarn.lock

File diff suppressed because it is too large Load Diff