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.",
"transactionDetails": "Transaction Details",
"hideDetails": "Hide Details",
"slippageWarning": "Slippage Warning",
"youAreSelling": "You are selling",
"orTransFail": "or the transaction will fail.",
"youWillReceive": "You will receive at least",

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

@ -1,9 +1,30 @@
import { useMemo, useCallback, useEffect } from 'react'
import { useState, useMemo, useCallback, useEffect } from 'react'
import { useWeb3Context } from 'web3-react'
import ERC20_ABI from '../abi/erc20'
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/
export function useBodyKeyDown(targetKey, onKeyDown, suppressOnKeyDown = false) {
const downHandler = useCallback(

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

@ -18,7 +18,7 @@ function CreateExchange({ history, location }) {
const factory = useFactoryContract()
const [tokenAddress, setTokenAddress] = useState({
address: (location.state && location.state.tokenAddress) || '',
address: '',
name: ''
})
const [tokenAddressError, setTokenAddressError] = useState()
@ -74,7 +74,12 @@ function CreateExchange({ history, location }) {
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>
<div className="pool__summary-panel">
<div className="pool__exchange-rate-wrapper">

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

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

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

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

2062
yarn.lock

File diff suppressed because it is too large Load Diff