feat(send page): remove send page, implement recipient feature in swap page (#934)

* quick poc for removing swap page

* accidental import

* error for recipient field

* query parameter working

* undo id change

* tweaks to match mocks better

* tweaks to match mocks better

* some extra integration tests

* clean up nav tabs a bit

* clean up nav tabs a bit

* space swap/pool better

* stop selecting button text when double clicking

* remove unused transfer modal header

* add info to swap confirm modal

* shorten address

* improve summary message, remove unused send callback, fix react ga event

* fix lint errors

* arrow color
This commit is contained in:
Moody Salem 2020-07-08 23:06:29 -04:00 committed by GitHub
parent 8a845ee0e9
commit 21c1484c0e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 466 additions and 1059 deletions

@ -1,9 +1,7 @@
describe('Send', () => { describe('Send', () => {
beforeEach(() => cy.visit('/send')) beforeEach(() => cy.visit('/send'))
it('can enter an amount into input', () => { it('should redirect', () => {
cy.get('#sending-no-swap-input') cy.url().should('include', '/swap')
.type('0.001', { delay: 200 })
.should('have.value', '0.001')
}) })
}) })

@ -40,4 +40,15 @@ describe('Swap', () => {
cy.get('#swap-button').click() cy.get('#swap-button').click()
cy.get('#confirm-swap-or-send').should('contain', 'Confirm Swap') cy.get('#confirm-swap-or-send').should('contain', 'Confirm Swap')
}) })
it('add a recipient', () => {
cy.get('#add-recipient-button').click()
cy.get('#recipient').should('exist')
})
it('remove recipient', () => {
cy.get('#add-recipient-button').click()
cy.get('#remove-recipient-button').click()
cy.get('#recipient').should('not.exist')
})
}) })

@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import { CheckCircle, Triangle, ExternalLink as LinkIcon } from 'react-feather' import { CheckCircle, Triangle } from 'react-feather'
import { useActiveWeb3React } from '../../hooks' import { useActiveWeb3React } from '../../hooks'
import { getEtherscanLink } from '../../utils' import { getEtherscanLink } from '../../utils'
@ -50,8 +50,7 @@ export default function Transaction({ hash }: { hash: string }) {
<TransactionWrapper> <TransactionWrapper>
<TransactionState href={getEtherscanLink(chainId, hash, 'transaction')} pending={pending} success={success}> <TransactionState href={getEtherscanLink(chainId, hash, 'transaction')} pending={pending} success={success}>
<RowFixed> <RowFixed>
<TransactionStatusText>{summary ? summary : hash}</TransactionStatusText> <TransactionStatusText>{summary ?? hash} </TransactionStatusText>
<LinkIcon size={16} />
</RowFixed> </RowFixed>
<IconWrapper pending={pending} success={success}> <IconWrapper pending={pending} success={success}>
{pending ? <Loader /> : success ? <CheckCircle size="16" /> : <Triangle size="16" />} {pending ? <Loader /> : success ? <CheckCircle size="16" /> : <Triangle size="16" />}

@ -1,8 +1,6 @@
import React, { useState, useEffect, useContext } from 'react' import React, { useContext, useCallback } from 'react'
import styled, { ThemeContext } from 'styled-components' import styled, { ThemeContext } from 'styled-components'
import useDebounce from '../../hooks/useDebounce' import useENS from '../../hooks/useENS'
import { isAddress } from '../../utils'
import { useActiveWeb3React } from '../../hooks' import { useActiveWeb3React } from '../../hooks'
import { ExternalLink, TYPE } from '../../theme' import { ExternalLink, TYPE } from '../../theme'
import { AutoColumn } from '../Column' import { AutoColumn } from '../Column'
@ -24,6 +22,8 @@ const ContainerRow = styled.div<{ error: boolean }>`
align-items: center; align-items: center;
border-radius: 1.25rem; border-radius: 1.25rem;
border: 1px solid ${({ error, theme }) => (error ? theme.red1 : theme.bg2)}; border: 1px solid ${({ error, theme }) => (error ? theme.red1 : theme.bg2)};
transition: border-color 300ms ${({ error }) => (error ? 'step-end' : 'step-start')},
color 500ms ${({ error }) => (error ? 'step-end' : 'step-start')};
background-color: ${({ theme }) => theme.bg1}; background-color: ${({ theme }) => theme.bg1};
` `
@ -39,6 +39,7 @@ const Input = styled.input<{ error?: boolean }>`
flex: 1 1 auto; flex: 1 1 auto;
width: 0; width: 0;
background-color: ${({ theme }) => theme.bg1}; background-color: ${({ theme }) => theme.bg1};
transition: color 300ms ${({ error }) => (error ? 'step-end' : 'step-start')};
color: ${({ error, theme }) => (error ? theme.red1 : theme.primary1)}; color: ${({ error, theme }) => (error ? theme.red1 : theme.primary1)};
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -64,120 +65,65 @@ const Input = styled.input<{ error?: boolean }>`
} }
` `
interface Value {
address: string
name?: string
}
export default function AddressInputPanel({ export default function AddressInputPanel({
initialInput = '', id,
onChange, value,
onError onChange
}: { }: {
initialInput?: string id?: string
onChange: (val: { address: string; name?: string }) => void // the typed string value
onError: (error: boolean, input: string) => void value: string
// triggers whenever the typed value changes
onChange: (value: string) => void
}) { }) {
const { chainId, library } = useActiveWeb3React() const { chainId } = useActiveWeb3React()
const theme = useContext(ThemeContext) const theme = useContext(ThemeContext)
const [input, setInput] = useState(initialInput ? initialInput : '') const { address, loading, name } = useENS(value)
const debouncedInput = useDebounce(input, 200)
const [data, setData] = useState<{ address: string; name: string }>({ address: undefined, name: undefined }) const handleInput = useCallback(
const [error, setError] = useState<boolean>(false) event => {
// keep data and errors in sync
useEffect(() => {
onChange({ address: data.address, name: data.name })
}, [onChange, data.address, data.name])
useEffect(() => {
onError(error, input)
}, [onError, error, input])
// run parser on debounced input
useEffect(() => {
let stale = false
// if the input is an address, try to look up its name
if (isAddress(debouncedInput)) {
library
.lookupAddress(debouncedInput)
.then(name => {
if (stale) return
// if an ENS name exists, set it as the destination
if (name) {
setInput(name)
} else {
setData({ address: debouncedInput, name: '' })
setError(null)
}
})
.catch(() => {
if (stale) return
setData({ address: debouncedInput, name: '' })
setError(null)
})
}
// otherwise try to look up the address of the input, treated as an ENS name
else {
if (debouncedInput !== '') {
library
.resolveName(debouncedInput)
.then(address => {
if (stale) return
// if the debounced input name resolves to an address
if (address) {
setData({ address: address, name: debouncedInput })
setError(null)
} else {
setError(true)
}
})
.catch(() => {
if (stale) return
setError(true)
})
} else if (debouncedInput === '') {
setError(true)
}
}
return () => {
stale = true
}
}, [debouncedInput, library])
function onInput(event) {
setData({ address: undefined, name: undefined })
setError(false)
const input = event.target.value const input = event.target.value
const checksummedInput = isAddress(input.replace(/\s/g, '')) // delete whitespace const withoutSpaces = input.replace(/\s+/g, '')
setInput(checksummedInput || input) onChange(withoutSpaces)
} },
[onChange]
)
const error = Boolean(value.length > 0 && !loading && !address)
return ( return (
<InputPanel> <InputPanel id={id}>
<ContainerRow error={input !== '' && error}> <ContainerRow error={error}>
<InputContainer> <InputContainer>
<AutoColumn gap="md"> <AutoColumn gap="md">
<RowBetween> <RowBetween>
<TYPE.black color={theme.text2} fontWeight={500} fontSize={14}> <TYPE.black color={theme.text2} fontWeight={500} fontSize={14}>
Recipient Recipient
</TYPE.black> </TYPE.black>
{data.address && ( {address && (
<ExternalLink <ExternalLink href={getEtherscanLink(chainId, name ?? address, 'address')} style={{ fontSize: '14px' }}>
href={getEtherscanLink(chainId, data.name || data.address, 'address')}
style={{ fontSize: '14px' }}
>
(View on Etherscan) (View on Etherscan)
</ExternalLink> </ExternalLink>
)} )}
</RowBetween> </RowBetween>
<Input <Input
className="recipient-address-input"
type="text" type="text"
autoComplete="off" autoComplete="off"
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"
spellCheck="false" spellCheck="false"
placeholder="Wallet Address or ENS name" placeholder="Wallet Address or ENS name"
error={input !== '' && error} error={error}
onChange={onInput} pattern="^(0x[a-fA-F0-9]{40})$"
value={input} onChange={handleInput}
value={value}
/> />
</AutoColumn> </AutoColumn>
</InputContainer> </InputContainer>

@ -1,37 +1,18 @@
import React, { useCallback } from 'react' import React from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import { darken } from 'polished' import { darken } from 'polished'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { withRouter, NavLink, Link as HistoryLink, RouteComponentProps } from 'react-router-dom' import { NavLink, Link as HistoryLink } from 'react-router-dom'
import useBodyKeyDown from '../../hooks/useBodyKeyDown'
import { CursorPointer } from '../../theme'
import { ArrowLeft } from 'react-feather' import { ArrowLeft } from 'react-feather'
import { RowBetween } from '../Row' import { RowBetween } from '../Row'
import QuestionHelper from '../QuestionHelper' import QuestionHelper from '../QuestionHelper'
const tabOrder = [
{
path: '/swap',
textKey: 'swap',
regex: /\/swap/
},
{
path: '/send',
textKey: 'send',
regex: /\/send/
},
{
path: '/pool',
textKey: 'pool',
regex: /\/pool/
}
]
const Tabs = styled.div` const Tabs = styled.div`
${({ theme }) => theme.flexRowNoWrap} ${({ theme }) => theme.flexRowNoWrap}
align-items: center; align-items: center;
border-radius: 3rem; border-radius: 3rem;
justify-content: space-evenly;
` `
const activeClassName = 'ACTIVE' const activeClassName = 'ACTIVE'
@ -43,7 +24,6 @@ const StyledNavLink = styled(NavLink).attrs({
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 3rem; height: 3rem;
flex: 1 0 auto;
border-radius: 3rem; border-radius: 3rem;
outline: none; outline: none;
cursor: pointer; cursor: pointer;
@ -68,43 +48,59 @@ const ActiveText = styled.div`
font-size: 20px; font-size: 20px;
` `
const ArrowLink = styled(ArrowLeft)` const StyledArrowLeft = styled(ArrowLeft)`
color: ${({ theme }) => theme.text1}; color: ${({ theme }) => theme.text1};
` `
function NavigationTabs({ location: { pathname }, history }: RouteComponentProps<{}>) { export function SwapPoolTabs({ active }: { active: 'swap' | 'pool' }) {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useCallback(
direction => {
const tabIndex = tabOrder.findIndex(({ regex }) => pathname.match(regex))
history.push(tabOrder[(tabIndex + tabOrder.length + direction) % tabOrder.length].path)
},
[pathname, history]
)
const navigateRight = useCallback(() => {
navigate(1)
}, [navigate])
const navigateLeft = useCallback(() => {
navigate(-1)
}, [navigate])
useBodyKeyDown('ArrowRight', navigateRight)
useBodyKeyDown('ArrowLeft', navigateLeft)
const adding = pathname.match('/add')
const removing = pathname.match('/remove')
const finding = pathname.match('/find')
const creating = pathname.match('/create')
return ( return (
<> <Tabs style={{ marginBottom: '20px' }}>
{adding || removing ? ( <StyledNavLink id={`swap-nav-link`} to={'/swap'} isActive={() => active === 'swap'}>
{t('swap')}
</StyledNavLink>
<StyledNavLink id={`pool-nav-link`} to={'/pool'} isActive={() => active === 'pool'}>
{t('pool')}
</StyledNavLink>
</Tabs>
)
}
export function CreatePoolTabs() {
return (
<Tabs> <Tabs>
<RowBetween style={{ padding: '1rem' }}> <RowBetween style={{ padding: '1rem' }}>
<CursorPointer onClick={() => history.push('/pool')}> <HistoryLink to="/pool">
<ArrowLink /> <StyledArrowLeft />
</CursorPointer> </HistoryLink>
<ActiveText>Create Pool</ActiveText>
<QuestionHelper text={'Use this interface to create a new pool.'} />
</RowBetween>
</Tabs>
)
}
export function FindPoolTabs() {
return (
<Tabs>
<RowBetween style={{ padding: '1rem' }}>
<HistoryLink to="/pool">
<StyledArrowLeft />
</HistoryLink>
<ActiveText>Import Pool</ActiveText>
<QuestionHelper text={"Use this tool to find pairs that don't automatically appear in the interface."} />
</RowBetween>
</Tabs>
)
}
export function AddRemoveTabs({ adding }: { adding: boolean }) {
return (
<Tabs>
<RowBetween style={{ padding: '1rem' }}>
<HistoryLink to="/pool">
<StyledArrowLeft />
</HistoryLink>
<ActiveText>{adding ? 'Add' : 'Remove'} Liquidity</ActiveText> <ActiveText>{adding ? 'Add' : 'Remove'} Liquidity</ActiveText>
<QuestionHelper <QuestionHelper
text={ text={
@ -115,42 +111,5 @@ function NavigationTabs({ location: { pathname }, history }: RouteComponentProps
/> />
</RowBetween> </RowBetween>
</Tabs> </Tabs>
) : finding ? (
<Tabs>
<RowBetween style={{ padding: '1rem' }}>
<HistoryLink to="/pool">
<ArrowLink />
</HistoryLink>
<ActiveText>Import Pool</ActiveText>
<QuestionHelper text={"Use this tool to find pairs that don't automatically appear in the interface."} />
</RowBetween>
</Tabs>
) : creating ? (
<Tabs>
<RowBetween style={{ padding: '1rem' }}>
<HistoryLink to="/pool">
<ArrowLink />
</HistoryLink>
<ActiveText>Create Pool</ActiveText>
<QuestionHelper text={'Use this interface to create a new pool.'} />
</RowBetween>
</Tabs>
) : (
<Tabs style={{ marginBottom: '20px' }}>
{tabOrder.map(({ path, textKey, regex }) => (
<StyledNavLink
id={`${textKey}-nav-link`}
key={path}
to={path}
isActive={(_, { pathname }) => !!pathname.match(regex)}
>
{t(textKey)}
</StyledNavLink>
))}
</Tabs>
)}
</>
) )
} }
export default withRouter(NavigationTabs)

@ -62,9 +62,7 @@ export default function TxnPopup({
{success ? <CheckCircle color={theme.green1} size={24} /> : <AlertCircle color={theme.red1} size={24} />} {success ? <CheckCircle color={theme.green1} size={24} /> : <AlertCircle color={theme.red1} size={24} />}
</div> </div>
<AutoColumn gap="8px"> <AutoColumn gap="8px">
<TYPE.body fontWeight={500}> <TYPE.body fontWeight={500}>{summary ?? 'Hash: ' + hash.slice(0, 8) + '...' + hash.slice(58, 65)}</TYPE.body>
{summary ? summary : 'Hash: ' + hash.slice(0, 8) + '...' + hash.slice(58, 65)}
</TYPE.body>
<ExternalLink href={getEtherscanLink(chainId, hash, 'transaction')}>View on Etherscan</ExternalLink> <ExternalLink href={getEtherscanLink(chainId, hash, 'transaction')}>View on Etherscan</ExternalLink>
</AutoColumn> </AutoColumn>
<Fader count={count} /> <Fader count={count} />

@ -130,7 +130,7 @@ export default function Web3Status() {
const { active, account, connector, error } = useWeb3React() const { active, account, connector, error } = useWeb3React()
const contextNetwork = useWeb3React(NetworkContextName) const contextNetwork = useWeb3React(NetworkContextName)
const ENSName = useENSName(account) const { ENSName } = useENSName(account)
const allTransactions = useAllTransactions() const allTransactions = useAllTransactions()

@ -5,6 +5,7 @@ import { Text } from 'rebass'
import { ThemeContext } from 'styled-components' import { ThemeContext } from 'styled-components'
import { Field } from '../../state/swap/actions' import { Field } from '../../state/swap/actions'
import { TYPE } from '../../theme' import { TYPE } from '../../theme'
import { isAddress, shortenAddress } from '../../utils'
import { AutoColumn } from '../Column' import { AutoColumn } from '../Column'
import { RowBetween, RowFixed } from '../Row' import { RowBetween, RowFixed } from '../Row'
import TokenLogo from '../TokenLogo' import TokenLogo from '../TokenLogo'
@ -15,13 +16,15 @@ export default function SwapModalHeader({
formattedAmounts, formattedAmounts,
slippageAdjustedAmounts, slippageAdjustedAmounts,
priceImpactSeverity, priceImpactSeverity,
independentField independentField,
recipient
}: { }: {
tokens: { [field in Field]?: Token } tokens: { [field in Field]?: Token }
formattedAmounts: { [field in Field]?: string } formattedAmounts: { [field in Field]?: string }
slippageAdjustedAmounts: { [field in Field]?: TokenAmount } slippageAdjustedAmounts: { [field in Field]?: TokenAmount }
priceImpactSeverity: number priceImpactSeverity: number
independentField: Field independentField: Field
recipient: string | null
}) { }) {
const theme = useContext(ThemeContext) const theme = useContext(ThemeContext)
@ -71,6 +74,14 @@ export default function SwapModalHeader({
</TYPE.italic> </TYPE.italic>
)} )}
</AutoColumn> </AutoColumn>
{recipient !== null ? (
<AutoColumn justify="flex-start" gap="sm" style={{ padding: '12px 0 0 0px' }}>
<TYPE.main>
Output will be sent to{' '}
<b title={recipient}>{isAddress(recipient) ? shortenAddress(recipient) : recipient}</b>
</TYPE.main>
</AutoColumn>
) : null}
</AutoColumn> </AutoColumn>
) )
} }

@ -1,55 +0,0 @@
import { TokenAmount } from '@uniswap/sdk'
import React from 'react'
import { Text } from 'rebass'
import { useActiveWeb3React } from '../../hooks'
import { ExternalLink, TYPE } from '../../theme'
import { getEtherscanLink } from '../../utils'
import Copy from '../AccountDetails/Copy'
import { AutoColumn } from '../Column'
import { AutoRow, RowBetween } from '../Row'
import TokenLogo from '../TokenLogo'
export function TransferModalHeader({
recipient,
ENSName,
amount
}: {
recipient: string
ENSName: string
amount: TokenAmount
}) {
const { chainId } = useActiveWeb3React()
return (
<AutoColumn gap="lg" style={{ marginTop: '40px' }}>
<RowBetween>
<Text fontSize={36} fontWeight={500}>
{amount?.toSignificant(6)} {amount?.token?.symbol}
</Text>
<TokenLogo address={amount?.token?.address} size={'30px'} />
</RowBetween>
<TYPE.darkGray fontSize={20}>To</TYPE.darkGray>
{ENSName ? (
<AutoColumn gap="lg">
<TYPE.blue fontSize={36}>{ENSName}</TYPE.blue>
<AutoRow gap="10px">
<ExternalLink href={getEtherscanLink(chainId, ENSName, 'address')}>
<TYPE.blue fontSize={18}>
{recipient?.slice(0, 8)}...{recipient?.slice(34, 42)}
</TYPE.blue>
</ExternalLink>
<Copy toCopy={recipient} />
</AutoRow>
</AutoColumn>
) : (
<AutoRow gap="10px">
<ExternalLink href={getEtherscanLink(chainId, recipient, 'address')}>
<TYPE.blue fontSize={36}>
{recipient?.slice(0, 6)}...{recipient?.slice(36, 42)}
</TYPE.blue>
</ExternalLink>
<Copy toCopy={recipient} />
</AutoRow>
)}
</AutoColumn>
)
}

@ -1,4 +1,4 @@
import styled from 'styled-components' import styled, { css } from 'styled-components'
import { AutoColumn } from '../Column' import { AutoColumn } from '../Column'
import { Text } from 'rebass' import { Text } from 'rebass'
@ -8,18 +8,19 @@ export const Wrapper = styled.div`
position: relative; position: relative;
` `
export const ArrowWrapper = styled.div` export const ArrowWrapper = styled.div<{ clickable: boolean }>`
padding: 2px; padding: 2px;
border-radius: 12px;
display: flex;
justify-content: center;
align-items: center;
${({ clickable }) =>
clickable
? css`
:hover { :hover {
cursor: pointer; cursor: pointer;
opacity: 0.8; opacity: 0.8;
} }
` `
: null}
`
export const SectionBreak = styled.div` export const SectionBreak = styled.div`
height: 1px; height: 1px;

21
src/hooks/useENS.ts Normal file

@ -0,0 +1,21 @@
import { isAddress } from '../utils'
import useENSAddress from './useENSAddress'
import useENSName from './useENSName'
/**
* Given a name or address, does a lookup to resolve to an address and name
* @param nameOrAddress ENS name or address
*/
export default function useENS(
nameOrAddress?: string | null
): { loading: boolean; address: string | null; name: string | null } {
const validated = isAddress(nameOrAddress)
const reverseLookup = useENSName(validated ? validated : undefined)
const lookup = useENSAddress(nameOrAddress)
return {
loading: reverseLookup.loading || lookup.loading,
address: validated ? validated : lookup.address,
name: reverseLookup.ENSName ? reverseLookup.ENSName : !validated && lookup.address ? nameOrAddress || null : null
}
}

@ -0,0 +1,46 @@
import { useEffect, useState } from 'react'
import { useActiveWeb3React } from './index'
/**
* Does a lookup for an ENS name to find its address.
*/
export default function useENSAddress(ensName?: string | null): { loading: boolean; address: string | null } {
const { library } = useActiveWeb3React()
const [address, setAddress] = useState<{ loading: boolean; address: string | null }>({
loading: false,
address: null
})
useEffect(() => {
if (!library || typeof ensName !== 'string') {
setAddress({ loading: false, address: null })
return
} else {
let stale = false
setAddress({ loading: true, address: null })
library
.resolveName(ensName)
.then(address => {
if (!stale) {
if (address) {
setAddress({ loading: false, address })
} else {
setAddress({ loading: false, address: null })
}
}
})
.catch(() => {
if (!stale) {
setAddress({ loading: false, address: null })
}
})
return () => {
stale = true
}
}
}, [library, ensName])
return address
}

@ -6,39 +6,43 @@ import { useActiveWeb3React } from './index'
* Does a reverse lookup for an address to find its ENS name. * 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. * Note this is not the same as looking up an ENS name to find an address.
*/ */
export default function useENSName(address?: string): string | null { export default function useENSName(address?: string): { ENSName: string | null; loading: boolean } {
const { library } = useActiveWeb3React() const { library } = useActiveWeb3React()
const [ENSName, setENSName] = useState<string | null>(null) const [ENSName, setENSName] = useState<{ ENSName: string | null; loading: boolean }>({
loading: false,
ENSName: null
})
useEffect(() => { useEffect(() => {
if (!library || !address) return
const validated = isAddress(address) const validated = isAddress(address)
if (validated) { if (!library || !validated) {
setENSName({ loading: false, ENSName: null })
return
} else {
let stale = false let stale = false
setENSName({ loading: true, ENSName: null })
library library
.lookupAddress(validated) .lookupAddress(validated)
.then(name => { .then(name => {
if (!stale) { if (!stale) {
if (name) { if (name) {
setENSName(name) setENSName({ loading: false, ENSName: name })
} else { } else {
setENSName(null) setENSName({ loading: false, ENSName: null })
} }
} }
}) })
.catch(() => { .catch(() => {
if (!stale) { if (!stale) {
setENSName(null) setENSName({ loading: false, ENSName: null })
} }
}) })
return () => { return () => {
stale = true stale = true
setENSName(null)
} }
} }
return
}, [library, address]) }, [library, address])
return ENSName return ENSName

@ -1,70 +0,0 @@
import { BigNumber } from '@ethersproject/bignumber'
import { TransactionResponse } from '@ethersproject/providers'
import { WETH, TokenAmount, JSBI, ChainId } from '@uniswap/sdk'
import { useMemo } from 'react'
import { useTransactionAdder } from '../state/transactions/hooks'
import { useTokenBalanceTreatingWETHasETH } from '../state/wallet/hooks'
import { calculateGasMargin, getSigner, isAddress } from '../utils'
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
export function useSendCallback(amount?: TokenAmount, recipient?: string): null | (() => Promise<string>) {
const { library, account, chainId } = useActiveWeb3React()
const addTransaction = useTransactionAdder()
const ensName = useENSName(recipient)
const tokenContract = useTokenContract(amount?.token?.address)
const balance = useTokenBalanceTreatingWETHasETH(account ?? undefined, amount?.token)
return useMemo(() => {
if (!amount) return null
if (!amount.greaterThan(JSBI.BigInt(0))) return null
if (!isAddress(recipient)) return null
if (!balance) return null
if (balance.lessThan(amount)) return null
const token = amount?.token
return async function onSend(): Promise<string> {
if (!chainId || !library || !account || !tokenContract) {
throw new Error('missing dependencies in onSend callback')
}
if (token.equals(WETH[chainId as ChainId])) {
return getSigner(library, account)
.sendTransaction({ to: recipient, value: BigNumber.from(amount.raw.toString()) })
.then((response: TransactionResponse) => {
addTransaction(response, {
summary: 'Send ' + amount.toSignificant(3) + ' ' + token?.symbol + ' to ' + (ensName ?? recipient)
})
return response.hash
})
.catch((error: Error) => {
console.error('Failed to transfer ETH', error)
throw error
})
} else {
return tokenContract.estimateGas
.transfer(recipient, amount.raw.toString())
.then(estimatedGasLimit =>
tokenContract
.transfer(recipient, amount.raw.toString(), {
gasLimit: calculateGasMargin(estimatedGasLimit)
})
.then((response: TransactionResponse) => {
addTransaction(response, {
summary: 'Send ' + amount.toSignificant(3) + ' ' + token.symbol + ' to ' + (ensName ?? recipient)
})
return response.hash
})
)
.catch(error => {
console.error('Failed token transfer', error)
throw error
})
}
}
}, [addTransaction, library, account, chainId, amount, ensName, recipient, tokenContract, balance])
}

@ -1,18 +1,18 @@
import { BigNumber } from '@ethersproject/bignumber' import { BigNumber } from '@ethersproject/bignumber'
import { MaxUint256 } from '@ethersproject/constants' import { MaxUint256 } from '@ethersproject/constants'
import { Contract } from '@ethersproject/contracts' import { Contract } from '@ethersproject/contracts'
import { ChainId, Trade, TradeType, WETH } from '@uniswap/sdk' import { Trade, TradeType, WETH } from '@uniswap/sdk'
import { useMemo } from 'react' import { useMemo } from 'react'
import { DEFAULT_DEADLINE_FROM_NOW, INITIAL_ALLOWED_SLIPPAGE, ROUTER_ADDRESS } from '../constants' import { DEFAULT_DEADLINE_FROM_NOW, INITIAL_ALLOWED_SLIPPAGE, ROUTER_ADDRESS } from '../constants'
import { useTokenAllowance } from '../data/Allowances' import { useTokenAllowance } from '../data/Allowances'
import { getTradeVersion, useV1TradeExchangeAddress } from '../data/V1' import { getTradeVersion, useV1TradeExchangeAddress } from '../data/V1'
import { Field } from '../state/swap/actions' import { Field } from '../state/swap/actions'
import { useTransactionAdder } from '../state/transactions/hooks' import { useTransactionAdder } from '../state/transactions/hooks'
import { calculateGasMargin, getRouterContract, isAddress } from '../utils' import { calculateGasMargin, getRouterContract, shortenAddress, isAddress } from '../utils'
import { computeSlippageAdjustedAmounts } from '../utils/prices' import { computeSlippageAdjustedAmounts } from '../utils/prices'
import { useActiveWeb3React } from './index' import { useActiveWeb3React } from './index'
import { useV1ExchangeContract } from './useContract' import { useV1ExchangeContract } from './useContract'
import useENSName from './useENSName' import useENS from './useENS'
import { Version } from './useToggledVersion' import { Version } from './useToggledVersion'
enum SwapType { enum SwapType {
@ -59,15 +59,16 @@ function getSwapType(trade: Trade | undefined): SwapType | undefined {
// returns a function that will execute a swap, if the parameters are all valid // returns a function that will execute a swap, if the parameters are all valid
// and the user has approved the slippage adjusted input amount for the trade // and the user has approved the slippage adjusted input amount for the trade
export function useSwapCallback( export function useSwapCallback(
trade?: Trade, // trade to execute, required trade: Trade | undefined, // trade to execute, required
allowedSlippage: number = INITIAL_ALLOWED_SLIPPAGE, // in bips allowedSlippage: number = INITIAL_ALLOWED_SLIPPAGE, // in bips
deadline: number = DEFAULT_DEADLINE_FROM_NOW, // in seconds from now deadline: number = DEFAULT_DEADLINE_FROM_NOW, // in seconds from now
to?: string // recipient of output, optional recipientAddressOrName: string // the ENS name or address of the recipient of the trade
): null | (() => Promise<string>) { ): null | (() => Promise<string>) {
const { account, chainId, library } = useActiveWeb3React() const { account, chainId, library } = useActiveWeb3React()
const addTransaction = useTransactionAdder() const addTransaction = useTransactionAdder()
const recipient = to ? isAddress(to) : account
const ensName = useENSName(to) const { address: recipient } = useENS(recipientAddressOrName)
const tradeVersion = getTradeVersion(trade) const tradeVersion = getTradeVersion(trade)
const v1Exchange = useV1ExchangeContract(useV1TradeExchangeAddress(trade), true) const v1Exchange = useV1ExchangeContract(useV1TradeExchangeAddress(trade), true)
const inputAllowance = useTokenAllowance( const inputAllowance = useTokenAllowance(
@ -77,7 +78,7 @@ export function useSwapCallback(
) )
return useMemo(() => { return useMemo(() => {
if (!trade || !recipient || !tradeVersion) return null if (!trade || !recipient || !library || !account || !tradeVersion || !chainId) return null
// will always be defined // will always be defined
const { const {
@ -89,17 +90,13 @@ export function useSwapCallback(
// no allowance // no allowance
if ( if (
!trade.inputAmount.token.equals(WETH[chainId as ChainId]) && !trade.inputAmount.token.equals(WETH[chainId]) &&
(!inputAllowance || slippageAdjustedInput.greaterThan(inputAllowance)) (!inputAllowance || slippageAdjustedInput.greaterThan(inputAllowance))
) { ) {
return null return null
} }
return async function onSwap() { return async function onSwap() {
if (!chainId || !library || !account) {
throw new Error('missing dependencies in onSwap callback')
}
const contract: Contract | null = const contract: Contract | null =
tradeVersion === Version.v2 ? getRouterContract(chainId, library, account) : v1Exchange tradeVersion === Version.v2 ? getRouterContract(chainId, library, account) : v1Exchange
if (!contract) { if (!contract) {
@ -283,7 +280,12 @@ export function useSwapCallback(
const outputAmount = slippageAdjustedOutput.toSignificant(3) const outputAmount = slippageAdjustedOutput.toSignificant(3)
const base = `Swap ${inputAmount} ${inputSymbol} for ${outputAmount} ${outputSymbol}` const base = `Swap ${inputAmount} ${inputSymbol} for ${outputAmount} ${outputSymbol}`
const withRecipient = recipient === account ? base : `${base} to ${ensName ?? recipient}` const withRecipient =
recipient === account
? base
: `${base} to ${
isAddress(recipientAddressOrName) ? shortenAddress(recipientAddressOrName) : recipientAddressOrName
}`
const withVersion = const withVersion =
tradeVersion === Version.v2 ? withRecipient : `${withRecipient} on ${tradeVersion.toUpperCase()}` tradeVersion === Version.v2 ? withRecipient : `${withRecipient} on ${tradeVersion.toUpperCase()}`
@ -310,15 +312,15 @@ export function useSwapCallback(
}, [ }, [
trade, trade,
recipient, recipient,
tradeVersion,
allowedSlippage,
chainId,
inputAllowance,
library, library,
account, account,
tradeVersion,
chainId,
allowedSlippage,
inputAllowance,
v1Exchange, v1Exchange,
deadline, deadline,
addTransaction, recipientAddressOrName,
ensName addTransaction
]) ])
} }

@ -35,6 +35,7 @@ import { Field } from '../../state/mint/actions'
import { useApproveCallback, ApprovalState } from '../../hooks/useApproveCallback' import { useApproveCallback, ApprovalState } from '../../hooks/useApproveCallback'
import { useWalletModalToggle } from '../../state/application/hooks' import { useWalletModalToggle } from '../../state/application/hooks'
import { useUserSlippageTolerance, useUserDeadline, useIsExpertMode } from '../../state/user/hooks' import { useUserSlippageTolerance, useUserDeadline, useIsExpertMode } from '../../state/user/hooks'
import { AddRemoveTabs } from '../../components/NavigationTabs'
export default function AddLiquidity({ match: { params } }: RouteComponentProps<{ tokens: string }>) { export default function AddLiquidity({ match: { params } }: RouteComponentProps<{ tokens: string }>) {
useDefaultsFromURLMatchParams(params) useDefaultsFromURLMatchParams(params)
@ -307,6 +308,7 @@ export default function AddLiquidity({ match: { params } }: RouteComponentProps<
return ( return (
<> <>
<AppBody> <AppBody>
<AddRemoveTabs adding={true} />
<Wrapper> <Wrapper>
<ConfirmationModal <ConfirmationModal
isOpen={showConfirm} isOpen={showConfirm}

@ -14,7 +14,6 @@ import RemoveV1Exchange from './MigrateV1/RemoveV1Exchange'
import Pool from './Pool' import Pool from './Pool'
import PoolFinder from './PoolFinder' import PoolFinder from './PoolFinder'
import RemoveLiquidity from './RemoveLiquidity' import RemoveLiquidity from './RemoveLiquidity'
import Send from './Send'
import Swap from './Swap' import Swap from './Swap'
import { RedirectPathToSwapOnly, RedirectToSwap } from './Swap/redirects' import { RedirectPathToSwapOnly, RedirectToSwap } from './Swap/redirects'
@ -69,7 +68,7 @@ export default function App() {
<Switch> <Switch>
<Route exact strict path="/swap" component={Swap} /> <Route exact strict path="/swap" component={Swap} />
<Route exact strict path="/swap/:outputCurrency" component={RedirectToSwap} /> <Route exact strict path="/swap/:outputCurrency" component={RedirectToSwap} />
<Route exact strict path="/send" component={Send} /> <Route exact strict path="/send" component={RedirectPathToSwapOnly} />
<Route exact strict path="/find" component={PoolFinder} /> <Route exact strict path="/find" component={PoolFinder} />
<Route exact strict path="/pool" component={Pool} /> <Route exact strict path="/pool" component={Pool} />
<Route exact strict path="/create" component={CreatePool} /> <Route exact strict path="/create" component={CreatePool} />

@ -1,6 +1,5 @@
import React from 'react' import React from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import NavigationTabs from '../components/NavigationTabs'
export const BodyWrapper = styled.div` export const BodyWrapper = styled.div`
position: relative; position: relative;
@ -17,10 +16,5 @@ export const BodyWrapper = styled.div`
* The styled container element that wraps the content of most pages and the tabs. * The styled container element that wraps the content of most pages and the tabs.
*/ */
export default function AppBody({ children }: { children: React.ReactNode }) { export default function AppBody({ children }: { children: React.ReactNode }) {
return ( return <BodyWrapper>{children}</BodyWrapper>
<BodyWrapper>
<NavigationTabs />
<>{children}</>
</BodyWrapper>
)
} }

@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { RouteComponentProps, Redirect } from 'react-router-dom' import { RouteComponentProps, Redirect } from 'react-router-dom'
import { Token, WETH } from '@uniswap/sdk' import { Token, WETH } from '@uniswap/sdk'
import { CreatePoolTabs } from '../../components/NavigationTabs'
import AppBody from '../AppBody' import AppBody from '../AppBody'
import Row, { AutoRow } from '../../components/Row' import Row, { AutoRow } from '../../components/Row'
@ -56,6 +57,7 @@ export default function CreatePool({ location }: RouteComponentProps) {
return ( return (
<AppBody> <AppBody>
<CreatePoolTabs />
<AutoColumn gap="20px"> <AutoColumn gap="20px">
<AutoColumn gap="24px"> <AutoColumn gap="24px">
{!token0Address ? ( {!token0Address ? (

@ -2,6 +2,7 @@ import React, { useState, useContext, useCallback } from 'react'
import styled, { ThemeContext } from 'styled-components' import styled, { ThemeContext } from 'styled-components'
import { JSBI } from '@uniswap/sdk' import { JSBI } from '@uniswap/sdk'
import { RouteComponentProps } from 'react-router-dom' import { RouteComponentProps } from 'react-router-dom'
import { SwapPoolTabs } from '../../components/NavigationTabs'
import Question from '../../components/QuestionHelper' import Question from '../../components/QuestionHelper'
import PairSearchModal from '../../components/SearchModal/PairSearchModal' import PairSearchModal from '../../components/SearchModal/PairSearchModal'
@ -70,6 +71,7 @@ export default function Pool({ history }: RouteComponentProps) {
return ( return (
<AppBody> <AppBody>
<SwapPoolTabs active={'pool'} />
<AutoColumn gap="lg" justify="center"> <AutoColumn gap="lg" justify="center">
<ButtonPrimary <ButtonPrimary
id="join-pool-button" id="join-pool-button"

@ -5,6 +5,7 @@ import { Text } from 'rebass'
import { ButtonDropdownLight } from '../../components/Button' import { ButtonDropdownLight } from '../../components/Button'
import { LightCard } from '../../components/Card' import { LightCard } from '../../components/Card'
import { AutoColumn, ColumnCenter } from '../../components/Column' import { AutoColumn, ColumnCenter } from '../../components/Column'
import { FindPoolTabs } from '../../components/NavigationTabs'
import PositionCard from '../../components/PositionCard' import PositionCard from '../../components/PositionCard'
import Row from '../../components/Row' import Row from '../../components/Row'
import TokenSearchModal from '../../components/SearchModal/TokenSearchModal' import TokenSearchModal from '../../components/SearchModal/TokenSearchModal'
@ -61,6 +62,7 @@ export default function PoolFinder() {
return ( return (
<AppBody> <AppBody>
<FindPoolTabs />
<AutoColumn gap="md"> <AutoColumn gap="md">
<ButtonDropdownLight <ButtonDropdownLight
onClick={() => { onClick={() => {

@ -13,6 +13,7 @@ import { AutoColumn, ColumnCenter } from '../../components/Column'
import ConfirmationModal from '../../components/ConfirmationModal' import ConfirmationModal from '../../components/ConfirmationModal'
import CurrencyInputPanel from '../../components/CurrencyInputPanel' import CurrencyInputPanel from '../../components/CurrencyInputPanel'
import DoubleLogo from '../../components/DoubleLogo' import DoubleLogo from '../../components/DoubleLogo'
import { AddRemoveTabs } from '../../components/NavigationTabs'
import PositionCard from '../../components/PositionCard' import PositionCard from '../../components/PositionCard'
import Row, { RowBetween, RowFixed } from '../../components/Row' import Row, { RowBetween, RowFixed } from '../../components/Row'
@ -390,6 +391,7 @@ export default function RemoveLiquidity({ match: { params } }: RouteComponentPro
return ( return (
<> <>
<AppBody> <AppBody>
<AddRemoveTabs adding={false} />
<Wrapper> <Wrapper>
<ConfirmationModal <ConfirmationModal
isOpen={showConfirm} isOpen={showConfirm}

@ -1,592 +0,0 @@
import { JSBI, TokenAmount, WETH } from '@uniswap/sdk'
import React, { useContext, useEffect, useState } from 'react'
import { ArrowDown } from 'react-feather'
import ReactGA from 'react-ga'
import { Text } from 'rebass'
import { ThemeContext } from 'styled-components'
import AddressInputPanel from '../../components/AddressInputPanel'
import { ButtonError, ButtonLight, ButtonPrimary, ButtonSecondary } from '../../components/Button'
import Card, { BlueCard, GreyCard } from '../../components/Card'
import { AutoColumn, ColumnCenter } from '../../components/Column'
import ConfirmationModal from '../../components/ConfirmationModal'
import CurrencyInputPanel from '../../components/CurrencyInputPanel'
import { AutoRow, RowBetween } from '../../components/Row'
import AdvancedSwapDetailsDropdown from '../../components/swap/AdvancedSwapDetailsDropdown'
import confirmPriceImpactWithoutFee from '../../components/swap/confirmPriceImpactWithoutFee'
import SwapModalFooter from '../../components/swap/SwapModalFooter'
import { ArrowWrapper, BottomGrouping, Dots, InputGroup, StyledNumerical, Wrapper } from '../../components/swap/styleds'
import TradePrice from '../../components/swap/TradePrice'
import { TransferModalHeader } from '../../components/swap/TransferModalHeader'
import BetterTradeLink from '../../components/swap/BetterTradeLink'
import TokenLogo from '../../components/TokenLogo'
import { TokenWarningCards } from '../../components/TokenWarningCard'
import { INITIAL_ALLOWED_SLIPPAGE, MIN_ETH, BETTER_TRADE_LINK_THRESHOLD } from '../../constants'
import { getTradeVersion, isTradeBetter } from '../../data/V1'
import { useActiveWeb3React } from '../../hooks'
import { useApproveCallbackFromTrade, ApprovalState } from '../../hooks/useApproveCallback'
import { useSendCallback } from '../../hooks/useSendCallback'
import { useSwapCallback } from '../../hooks/useSwapCallback'
import { useWalletModalToggle, useToggleSettingsMenu } from '../../state/application/hooks'
import useToggledVersion, { Version } from '../../hooks/useToggledVersion'
import { Field } from '../../state/swap/actions'
import {
useDefaultsFromURLSearch,
useDerivedSwapInfo,
useSwapActionHandlers,
useSwapState
} from '../../state/swap/hooks'
import { useAllTokenBalancesTreatingWETHasETH } from '../../state/wallet/hooks'
import { CursorPointer, TYPE } from '../../theme'
import { computeSlippageAdjustedAmounts, computeTradePriceBreakdown, warningSeverity } from '../../utils/prices'
import AppBody from '../AppBody'
import { useUserSlippageTolerance, useUserDeadline, useExpertModeManager } from '../../state/user/hooks'
import { ClickableText } from '../Pool/styleds'
export default function Send() {
// override auto ETH populate to allow for single inputs or swap and send
useDefaultsFromURLSearch(true)
// text translation
// const { t } = useTranslation()
const { chainId, account } = useActiveWeb3React()
const theme = useContext(ThemeContext)
// toggle wallet when disconnected
const toggleWalletModal = useWalletModalToggle()
// for expert mode
const toggleSettings = useToggleSettingsMenu()
const [expertMode] = useExpertModeManager()
// sending state
const [sendingWithSwap, setSendingWithSwap] = useState<boolean>(false)
const [recipient, setRecipient] = useState<string>('')
const [ENS, setENS] = useState<string>('')
const [recipientError, setRecipientError] = useState<string | null>('Enter a Recipient')
// trade details, check query params for initial state
const {
independentField,
typedValue,
[Field.OUTPUT]: { address: output }
} = useSwapState()
// if output is valid set to sending view (will reset to undefined on remove swap)
useEffect(() => {
if (output) {
setSendingWithSwap(true)
}
}, [output])
const {
parsedAmount,
bestTrade: bestTradeV2,
tokenBalances,
tokens,
error: swapError,
v1Trade
} = useDerivedSwapInfo()
const toggledVersion = useToggledVersion()
const bestTrade = {
[Version.v1]: v1Trade,
[Version.v2]: bestTradeV2
}[toggledVersion]
const betterTradeLinkVersion: Version | undefined =
toggledVersion === Version.v2 && isTradeBetter(bestTradeV2, v1Trade, BETTER_TRADE_LINK_THRESHOLD)
? Version.v1
: toggledVersion === Version.v1 && isTradeBetter(v1Trade, bestTradeV2)
? Version.v2
: undefined
const parsedAmounts = {
[Field.INPUT]: independentField === Field.INPUT ? parsedAmount : bestTrade?.inputAmount,
[Field.OUTPUT]: independentField === Field.OUTPUT ? parsedAmount : bestTrade?.outputAmount
}
const isSwapValid = !swapError && !recipientError && bestTrade
const dependentField: Field = independentField === Field.INPUT ? Field.OUTPUT : Field.INPUT
// modal and loading
const [showConfirm, setShowConfirm] = useState<boolean>(false) // show confirmation modal
const [attemptingTxn, setAttemptingTxn] = useState<boolean>(false) // waiting for user confirmaion/rejection
const [txHash, setTxHash] = useState<string>('')
const [deadline] = useUserDeadline() // custom from user settings
const [allowedSlippage] = useUserSlippageTolerance() // custom from user settings
const route = bestTrade?.route
const userHasSpecifiedInputOutput =
!!tokens[Field.INPUT] &&
!!tokens[Field.OUTPUT] &&
!!parsedAmounts[independentField] &&
parsedAmounts[independentField].greaterThan(JSBI.BigInt(0))
const noRoute = !route
// check whether the user has approved the router on the input token
const [approval, approveCallback] = useApproveCallbackFromTrade(bestTrade, allowedSlippage)
// check if user has gone through approval process, used to show two step buttons, reset on token change
const [approvalSubmitted, setApprovalSubmitted] = useState<boolean>(false)
// mark when a user has submitted an approval, reset onTokenSelection for input field
useEffect(() => {
if (approval === ApprovalState.PENDING) {
setApprovalSubmitted(true)
}
}, [approval, approvalSubmitted])
const formattedAmounts = {
[independentField]: typedValue,
[dependentField]: parsedAmounts[dependentField] ? parsedAmounts[dependentField].toSignificant(6) : ''
}
const slippageAdjustedAmounts = computeSlippageAdjustedAmounts(bestTrade, allowedSlippage)
const { priceImpactWithoutFee, realizedLPFee } = computeTradePriceBreakdown(bestTrade)
const { onSwitchTokens, onTokenSelection, onUserInput } = useSwapActionHandlers()
const maxAmountInput: TokenAmount =
!!tokenBalances[Field.INPUT] &&
!!tokens[Field.INPUT] &&
!!WETH[chainId] &&
tokenBalances[Field.INPUT].greaterThan(
new TokenAmount(tokens[Field.INPUT], tokens[Field.INPUT].equals(WETH[chainId]) ? MIN_ETH : '0')
)
? tokens[Field.INPUT].equals(WETH[chainId])
? tokenBalances[Field.INPUT].subtract(new TokenAmount(WETH[chainId], MIN_ETH))
: tokenBalances[Field.INPUT]
: undefined
const atMaxAmountInput: boolean =
!!maxAmountInput && !!parsedAmounts[Field.INPUT] ? maxAmountInput.equalTo(parsedAmounts[Field.INPUT]) : undefined
const swapCallback = useSwapCallback(bestTrade, allowedSlippage, deadline, recipient)
function onSwap() {
if (priceImpactWithoutFee && !confirmPriceImpactWithoutFee(priceImpactWithoutFee)) {
return
}
setAttemptingTxn(true)
swapCallback()
.then(hash => {
setAttemptingTxn(false)
setTxHash(hash)
ReactGA.event({
category: 'Send',
action: recipient === account ? 'Swap w/o Send' : 'Swap w/ Send',
label: [
bestTrade.inputAmount.token.symbol,
bestTrade.outputAmount.token.symbol,
getTradeVersion(bestTrade)
].join('/')
})
})
.catch(error => {
setAttemptingTxn(false)
// we only care if the error is something _other_ than the user rejected the tx
if (error?.code !== 4001) {
console.error(error)
}
})
}
const sendCallback = useSendCallback(parsedAmounts?.[Field.INPUT], recipient)
const isSendValid = sendCallback !== null && (sendingWithSwap === false || approval === ApprovalState.APPROVED)
async function onSend() {
setAttemptingTxn(true)
sendCallback()
.then(hash => {
setAttemptingTxn(false)
setTxHash(hash)
ReactGA.event({ category: 'Send', action: 'Send', label: tokens[Field.INPUT]?.symbol })
})
.catch(error => {
setAttemptingTxn(false)
// we only care if the error is something _other_ than the user rejected the tx
if (error?.code !== 4001) {
console.error(error)
}
})
}
const [showInverted, setShowInverted] = useState<boolean>(false)
// warnings on slippage
const severity = !sendingWithSwap ? 0 : warningSeverity(priceImpactWithoutFee)
// show approval buttons when: no errors on input, not approved or pending, or has been approved in this session
const showApproveFlow =
((sendingWithSwap && isSwapValid) || (!sendingWithSwap && isSendValid)) &&
(approval === ApprovalState.NOT_APPROVED ||
approval === ApprovalState.PENDING ||
(approvalSubmitted && approval === ApprovalState.APPROVED)) &&
!(severity > 3 && !expertMode)
function modalHeader() {
if (!sendingWithSwap) {
return <TransferModalHeader amount={parsedAmounts?.[Field.INPUT]} ENSName={ENS} recipient={recipient} />
}
if (sendingWithSwap) {
return (
<AutoColumn gap="lg" style={{ marginTop: '40px' }}>
<AutoColumn gap="sm">
<AutoRow gap="10px">
<TokenLogo address={tokens[Field.OUTPUT]?.address} size={'30px'} />
<Text fontSize={36} fontWeight={500}>
{slippageAdjustedAmounts[Field.OUTPUT]?.toSignificant(4)} {tokens[Field.OUTPUT]?.symbol}
</Text>
</AutoRow>
<BlueCard>
Via {parsedAmounts[Field.INPUT]?.toSignificant(4)} {tokens[Field.INPUT]?.symbol} swap
</BlueCard>
</AutoColumn>
<AutoColumn gap="sm">
<TYPE.darkGray fontSize={20}>To</TYPE.darkGray>
<TYPE.blue fontSize={36}>
{recipient?.slice(0, 6)}...{recipient?.slice(36, 42)}
</TYPE.blue>
</AutoColumn>
</AutoColumn>
)
}
}
function modalBottom() {
if (!sendingWithSwap) {
return (
<AutoColumn>
<ButtonPrimary onClick={onSend} id="confirm-send">
<Text color="white" fontSize={20}>
Confirm send
</Text>
</ButtonPrimary>
</AutoColumn>
)
}
if (sendingWithSwap) {
return (
<SwapModalFooter
trade={bestTrade}
onSwap={onSwap}
setShowInverted={setShowInverted}
severity={severity}
showInverted={showInverted}
slippageAdjustedAmounts={slippageAdjustedAmounts}
priceImpactWithoutFee={priceImpactWithoutFee}
parsedAmounts={parsedAmounts}
realizedLPFee={realizedLPFee}
confirmText={severity > 2 ? 'Send Anyway' : 'Confirm Send'}
/>
)
}
}
// text to show while loading
const pendingText: string = sendingWithSwap
? `Sending ${parsedAmounts[Field.OUTPUT]?.toSignificant(6)} ${tokens[Field.OUTPUT]?.symbol} to ${recipient}`
: `Sending ${parsedAmounts[Field.INPUT]?.toSignificant(6)} ${tokens[Field.INPUT]?.symbol} to ${recipient}`
const allBalances = useAllTokenBalancesTreatingWETHasETH() // only for 0 balance token selection behavior
const swapState = useSwapState()
function _onTokenSelect(address: string) {
// if no user balance - switch view to a send with swap
const hasBalance = allBalances?.[address]?.greaterThan('0') ?? false
if (!hasBalance) {
onTokenSelection(
Field.INPUT,
swapState[Field.INPUT]?.address === address ? null : swapState[Field.INPUT]?.address
)
onTokenSelection(Field.OUTPUT, address)
setSendingWithSwap(true)
} else {
onTokenSelection(Field.INPUT, address)
}
}
function _onRecipient(result) {
if (result.address) {
setRecipient(result.address)
} else {
setRecipient('')
}
if (result.name) {
setENS(result.name)
}
}
const sendAmountError =
!sendingWithSwap && JSBI.equal(parsedAmounts?.[Field.INPUT]?.raw ?? JSBI.BigInt(0), JSBI.BigInt(0))
? 'Enter an amount'
: null
return (
<>
{sendingWithSwap ? <TokenWarningCards tokens={tokens} /> : null}
<AppBody>
<Wrapper id="send-page">
<ConfirmationModal
isOpen={showConfirm}
title={sendingWithSwap ? 'Confirm swap and send' : 'Confirm Send'}
onDismiss={() => {
setShowConfirm(false)
if (txHash) {
onUserInput(Field.INPUT, '')
}
setTxHash('')
}}
attemptingTxn={attemptingTxn}
hash={txHash}
topContent={modalHeader}
bottomContent={modalBottom}
pendingText={pendingText}
/>
{!sendingWithSwap && (
<AutoColumn justify="center" style={{ marginBottom: '1rem' }}>
<InputGroup gap="lg" justify="center">
<StyledNumerical
id="sending-no-swap-input"
value={formattedAmounts[Field.INPUT]}
onUserInput={val => onUserInput(Field.INPUT, val)}
/>
<CurrencyInputPanel
field={Field.INPUT}
value={formattedAmounts[Field.INPUT]}
onUserInput={(field, val) => onUserInput(Field.INPUT, val)}
onMax={() => {
maxAmountInput && onUserInput(Field.INPUT, maxAmountInput.toExact())
}}
showMaxButton={!atMaxAmountInput}
token={tokens[Field.INPUT]}
onTokenSelection={address => _onTokenSelect(address)}
hideBalance={true}
hideInput={true}
showSendWithSwap={true}
label={''}
id="swap-currency-input"
otherSelectedTokenAddress={tokens[Field.OUTPUT]?.address}
/>
</InputGroup>
<RowBetween style={{ width: 'fit-content' }}>
<ButtonSecondary
width="fit-content"
style={{ fontSize: '14px' }}
padding={'4px 8px'}
onClick={() => setSendingWithSwap(true)}
>
+ Add a swap
</ButtonSecondary>
{account && (
<ButtonSecondary
style={{ fontSize: '14px', marginLeft: '8px' }}
padding={'4px 8px'}
width="fit-content"
disabled={atMaxAmountInput}
onClick={() => {
maxAmountInput && onUserInput(Field.INPUT, maxAmountInput.toExact())
}}
>
Input Max
</ButtonSecondary>
)}
</RowBetween>
</AutoColumn>
)}
<AutoColumn gap={'md'}>
{sendingWithSwap && (
<>
<CurrencyInputPanel
field={Field.INPUT}
label={independentField === Field.OUTPUT && parsedAmounts[Field.INPUT] ? 'From (estimated)' : 'From'}
value={formattedAmounts[Field.INPUT]}
showMaxButton={!atMaxAmountInput}
token={tokens[Field.INPUT]}
onUserInput={onUserInput}
onMax={() => {
maxAmountInput && onUserInput(Field.INPUT, maxAmountInput.toExact())
}}
onTokenSelection={address => {
setApprovalSubmitted(false)
onTokenSelection(Field.INPUT, address)
}}
otherSelectedTokenAddress={tokens[Field.OUTPUT]?.address}
id="swap-currency-input"
/>
{sendingWithSwap ? (
<ColumnCenter>
<RowBetween padding="0 1rem 0 12px">
<ArrowWrapper onClick={onSwitchTokens}>
<ArrowDown size="16" color={theme.text2} onClick={onSwitchTokens} />
</ArrowWrapper>
<ButtonSecondary
onClick={() => {
setSendingWithSwap(false)
onTokenSelection(Field.OUTPUT, null)
}}
style={{ marginRight: '0px', width: 'auto', fontSize: '14px' }}
padding={'4px 6px'}
>
Remove Swap
</ButtonSecondary>
</RowBetween>
</ColumnCenter>
) : (
<CursorPointer>
<AutoColumn style={{ padding: '0 1rem' }}>
<ArrowWrapper>
<ArrowDown
size="16"
onClick={onSwitchTokens}
color={tokens[Field.INPUT] && tokens[Field.OUTPUT] ? theme.primary1 : theme.text2}
/>
</ArrowWrapper>
</AutoColumn>
</CursorPointer>
)}
<CurrencyInputPanel
field={Field.OUTPUT}
value={formattedAmounts[Field.OUTPUT]}
onUserInput={onUserInput}
label={independentField === Field.INPUT && parsedAmounts[Field.OUTPUT] ? 'To (estimated)' : 'To'}
showMaxButton={false}
token={tokens[Field.OUTPUT]}
onTokenSelection={address => onTokenSelection(Field.OUTPUT, address)}
otherSelectedTokenAddress={tokens[Field.INPUT]?.address}
id="swap-currency-output"
/>
{sendingWithSwap && (
<RowBetween padding="0 1rem 0 12px">
<ArrowDown size="16" color={theme.text2} />
</RowBetween>
)}
</>
)}
<AutoColumn gap="lg" justify="center">
<AddressInputPanel
onChange={_onRecipient}
onError={(error: boolean, input) => {
if (error && input !== '') {
setRecipientError('Invalid Recipient')
} else if (error && input === '') {
setRecipientError('Enter a Recipient')
} else {
setRecipientError(null)
}
}}
/>
</AutoColumn>
{sendingWithSwap && (
<Card padding={'.25rem .75rem 0 .75rem'} borderRadius={'20px'}>
<AutoColumn gap="4px">
<RowBetween align="center">
<Text fontWeight={500} fontSize={14} color={theme.text2}>
Price
</Text>
<TradePrice
inputToken={tokens[Field.INPUT]}
outputToken={tokens[Field.OUTPUT]}
price={bestTrade?.executionPrice}
showInverted={showInverted}
setShowInverted={setShowInverted}
/>
</RowBetween>
{allowedSlippage !== INITIAL_ALLOWED_SLIPPAGE && (
<RowBetween align="center">
<ClickableText>
<Text fontWeight={500} fontSize={14} color={theme.text2} onClick={toggleSettings}>
Slippage Tolerance
</Text>
</ClickableText>
<ClickableText>
<Text fontWeight={500} fontSize={14} color={theme.text2} onClick={toggleSettings}>
{allowedSlippage ? allowedSlippage / 100 : '-'}%
</Text>
</ClickableText>
</RowBetween>
)}
</AutoColumn>
</Card>
)}
</AutoColumn>
<BottomGrouping>
{!account ? (
<ButtonLight
onClick={() => {
toggleWalletModal()
}}
>
Connect Wallet
</ButtonLight>
) : noRoute && userHasSpecifiedInputOutput ? (
<GreyCard style={{ textAlign: 'center' }}>
<TYPE.main mb="4px">Insufficient liquidity for this trade.</TYPE.main>
</GreyCard>
) : showApproveFlow ? (
<RowBetween>
<ButtonPrimary
onClick={approveCallback}
disabled={approval !== ApprovalState.NOT_APPROVED || approvalSubmitted}
width="48%"
altDisbaledStyle={approval === ApprovalState.PENDING} // show solid button while waiting
>
{approval === ApprovalState.PENDING ? (
<Dots>Approving</Dots>
) : approvalSubmitted && approval === ApprovalState.APPROVED ? (
'Approved'
) : (
'Approve ' + tokens[Field.INPUT]?.symbol
)}
</ButtonPrimary>
<ButtonError
onClick={() => {
expertMode ? (sendingWithSwap ? onSwap() : onSend()) : setShowConfirm(true)
}}
width="48%"
id="send-button"
disabled={approval !== ApprovalState.APPROVED}
error={sendingWithSwap && isSwapValid && severity > 2}
>
<Text fontSize={16} fontWeight={500}>
{severity > 3 && !expertMode ? `Price Impact High` : `Send${severity > 2 ? ' Anyway' : ''}`}
</Text>
</ButtonError>
</RowBetween>
) : (
<ButtonError
onClick={() => {
expertMode ? (sendingWithSwap ? onSwap() : onSend()) : setShowConfirm(true)
}}
id="send-button"
disabled={
(sendingWithSwap && !isSwapValid) ||
(!sendingWithSwap && !isSendValid) ||
(severity > 3 && !expertMode && sendingWithSwap)
}
error={sendingWithSwap && isSwapValid && severity > 2}
>
<Text fontSize={20} fontWeight={500}>
{(sendingWithSwap ? swapError : null) ||
sendAmountError ||
recipientError ||
(severity > 3 && !expertMode && `Price Impact Too High`) ||
`Send${severity > 2 ? ' Anyway' : ''}`}
</Text>
</ButtonError>
)}
{betterTradeLinkVersion && <BetterTradeLink version={betterTradeLinkVersion} />}
</BottomGrouping>
</Wrapper>
</AppBody>
<AdvancedSwapDetailsDropdown trade={bestTrade} />
</>
)
}

@ -4,12 +4,14 @@ import { ArrowDown } from 'react-feather'
import ReactGA from 'react-ga' import ReactGA from 'react-ga'
import { Text } from 'rebass' import { Text } from 'rebass'
import { ThemeContext } from 'styled-components' import { ThemeContext } from 'styled-components'
import AddressInputPanel from '../../components/AddressInputPanel'
import { ButtonError, ButtonLight, ButtonPrimary } from '../../components/Button' import { ButtonError, ButtonLight, ButtonPrimary } from '../../components/Button'
import Card, { GreyCard } from '../../components/Card' import Card, { GreyCard } from '../../components/Card'
import { AutoColumn } from '../../components/Column' import { AutoColumn } from '../../components/Column'
import ConfirmationModal from '../../components/ConfirmationModal' import ConfirmationModal from '../../components/ConfirmationModal'
import CurrencyInputPanel from '../../components/CurrencyInputPanel' import CurrencyInputPanel from '../../components/CurrencyInputPanel'
import { RowBetween } from '../../components/Row' import { SwapPoolTabs } from '../../components/NavigationTabs'
import { AutoRow, RowBetween } from '../../components/Row'
import AdvancedSwapDetailsDropdown from '../../components/swap/AdvancedSwapDetailsDropdown' import AdvancedSwapDetailsDropdown from '../../components/swap/AdvancedSwapDetailsDropdown'
import confirmPriceImpactWithoutFee from '../../components/swap/confirmPriceImpactWithoutFee' import confirmPriceImpactWithoutFee from '../../components/swap/confirmPriceImpactWithoutFee'
import { ArrowWrapper, BottomGrouping, Dots, Wrapper } from '../../components/swap/styleds' import { ArrowWrapper, BottomGrouping, Dots, Wrapper } from '../../components/swap/styleds'
@ -20,6 +22,7 @@ import BetterTradeLink from '../../components/swap/BetterTradeLink'
import { TokenWarningCards } from '../../components/TokenWarningCard' import { TokenWarningCards } from '../../components/TokenWarningCard'
import { useActiveWeb3React } from '../../hooks' import { useActiveWeb3React } from '../../hooks'
import { useApproveCallbackFromTrade, ApprovalState } from '../../hooks/useApproveCallback' import { useApproveCallbackFromTrade, ApprovalState } from '../../hooks/useApproveCallback'
import useENSAddress from '../../hooks/useENSAddress'
import { useSwapCallback } from '../../hooks/useSwapCallback' import { useSwapCallback } from '../../hooks/useSwapCallback'
import { useWalletModalToggle, useToggleSettingsMenu } from '../../state/application/hooks' import { useWalletModalToggle, useToggleSettingsMenu } from '../../state/application/hooks'
import { useExpertModeManager, useUserSlippageTolerance, useUserDeadline } from '../../state/user/hooks' import { useExpertModeManager, useUserSlippageTolerance, useUserDeadline } from '../../state/user/hooks'
@ -34,7 +37,7 @@ import {
useSwapActionHandlers, useSwapActionHandlers,
useSwapState useSwapState
} from '../../state/swap/hooks' } from '../../state/swap/hooks'
import { CursorPointer, TYPE } from '../../theme' import { CursorPointer, LinkStyledButton, TYPE } from '../../theme'
import { computeSlippageAdjustedAmounts, computeTradePriceBreakdown, warningSeverity } from '../../utils/prices' import { computeSlippageAdjustedAmounts, computeTradePriceBreakdown, warningSeverity } from '../../utils/prices'
import AppBody from '../AppBody' import AppBody from '../AppBody'
import { ClickableText } from '../Pool/styleds' import { ClickableText } from '../Pool/styleds'
@ -57,10 +60,11 @@ export default function Swap() {
const [allowedSlippage] = useUserSlippageTolerance() const [allowedSlippage] = useUserSlippageTolerance()
// swap state // swap state
const { independentField, typedValue } = useSwapState() const { independentField, typedValue, recipient } = useSwapState()
const { bestTrade: bestTradeV2, tokenBalances, parsedAmount, tokens, error, v1Trade } = useDerivedSwapInfo() const { bestTrade: bestTradeV2, tokenBalances, parsedAmount, tokens, error, v1Trade } = useDerivedSwapInfo()
const { address: recipientAddress } = useENSAddress(recipient)
const toggledVersion = useToggledVersion() const toggledVersion = useToggledVersion()
const bestTrade = { const trade = {
[Version.v1]: v1Trade, [Version.v1]: v1Trade,
[Version.v2]: bestTradeV2 [Version.v2]: bestTradeV2
}[toggledVersion] }[toggledVersion]
@ -73,11 +77,11 @@ export default function Swap() {
: undefined : undefined
const parsedAmounts = { const parsedAmounts = {
[Field.INPUT]: independentField === Field.INPUT ? parsedAmount : bestTrade?.inputAmount, [Field.INPUT]: independentField === Field.INPUT ? parsedAmount : trade?.inputAmount,
[Field.OUTPUT]: independentField === Field.OUTPUT ? parsedAmount : bestTrade?.outputAmount [Field.OUTPUT]: independentField === Field.OUTPUT ? parsedAmount : trade?.outputAmount
} }
const { onSwitchTokens, onTokenSelection, onUserInput } = useSwapActionHandlers() const { onSwitchTokens, onTokenSelection, onUserInput, onChangeRecipient } = useSwapActionHandlers()
const isValid = !error const isValid = !error
const dependentField: Field = independentField === Field.INPUT ? Field.OUTPUT : Field.INPUT const dependentField: Field = independentField === Field.INPUT ? Field.OUTPUT : Field.INPUT
@ -91,7 +95,7 @@ export default function Swap() {
[dependentField]: parsedAmounts[dependentField] ? parsedAmounts[dependentField].toSignificant(6) : '' [dependentField]: parsedAmounts[dependentField] ? parsedAmounts[dependentField].toSignificant(6) : ''
} }
const route = bestTrade?.route const route = trade?.route
const userHasSpecifiedInputOutput = const userHasSpecifiedInputOutput =
!!tokens[Field.INPUT] && !!tokens[Field.INPUT] &&
!!tokens[Field.OUTPUT] && !!tokens[Field.OUTPUT] &&
@ -100,7 +104,7 @@ export default function Swap() {
const noRoute = !route const noRoute = !route
// check whether the user has approved the router on the input token // check whether the user has approved the router on the input token
const [approval, approveCallback] = useApproveCallbackFromTrade(bestTrade, allowedSlippage) const [approval, approveCallback] = useApproveCallbackFromTrade(trade, allowedSlippage)
// check if user has gone through approval process, used to show two step buttons, reset on token change // check if user has gone through approval process, used to show two step buttons, reset on token change
const [approvalSubmitted, setApprovalSubmitted] = useState<boolean>(false) const [approvalSubmitted, setApprovalSubmitted] = useState<boolean>(false)
@ -126,12 +130,12 @@ export default function Swap() {
const atMaxAmountInput: boolean = 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) const slippageAdjustedAmounts = computeSlippageAdjustedAmounts(trade, allowedSlippage)
// the callback to execute the swap // the callback to execute the swap
const swapCallback = useSwapCallback(bestTrade, allowedSlippage, deadline) const swapCallback = useSwapCallback(trade, allowedSlippage, deadline, recipient)
const { priceImpactWithoutFee, realizedLPFee } = computeTradePriceBreakdown(bestTrade) const { priceImpactWithoutFee, realizedLPFee } = computeTradePriceBreakdown(trade)
function onSwap() { function onSwap() {
if (priceImpactWithoutFee && !confirmPriceImpactWithoutFee(priceImpactWithoutFee)) { if (priceImpactWithoutFee && !confirmPriceImpactWithoutFee(priceImpactWithoutFee)) {
@ -145,12 +149,13 @@ export default function Swap() {
ReactGA.event({ ReactGA.event({
category: 'Swap', category: 'Swap',
action: 'Swap w/o Send', action:
label: [ recipient === null
bestTrade.inputAmount.token.symbol, ? 'Swap w/o Send'
bestTrade.outputAmount.token.symbol, : (recipientAddress ?? recipient) === account
getTradeVersion(bestTrade) ? 'Swap w/o Send + recipient'
].join('/') : 'Swap w/ Send',
label: [trade.inputAmount.token.symbol, trade.outputAmount.token.symbol, getTradeVersion(trade)].join('/')
}) })
}) })
.catch(error => { .catch(error => {
@ -185,6 +190,7 @@ export default function Swap() {
slippageAdjustedAmounts={slippageAdjustedAmounts} slippageAdjustedAmounts={slippageAdjustedAmounts}
priceImpactSeverity={priceImpactSeverity} priceImpactSeverity={priceImpactSeverity}
independentField={independentField} independentField={independentField}
recipient={recipient}
/> />
) )
} }
@ -201,7 +207,7 @@ export default function Swap() {
parsedAmounts={parsedAmounts} parsedAmounts={parsedAmounts}
priceImpactWithoutFee={priceImpactWithoutFee} priceImpactWithoutFee={priceImpactWithoutFee}
slippageAdjustedAmounts={slippageAdjustedAmounts} slippageAdjustedAmounts={slippageAdjustedAmounts}
trade={bestTrade} trade={trade}
/> />
) )
} }
@ -215,6 +221,7 @@ export default function Swap() {
<> <>
<TokenWarningCards tokens={tokens} /> <TokenWarningCards tokens={tokens} />
<AppBody> <AppBody>
<SwapPoolTabs active={'swap'} />
<Wrapper id="swap-page"> <Wrapper id="swap-page">
<ConfirmationModal <ConfirmationModal
isOpen={showConfirm} isOpen={showConfirm}
@ -235,7 +242,6 @@ export default function Swap() {
/> />
<AutoColumn gap={'md'}> <AutoColumn gap={'md'}>
<>
<CurrencyInputPanel <CurrencyInputPanel
field={Field.INPUT} field={Field.INPUT}
label={independentField === Field.OUTPUT ? 'From (estimated)' : 'From'} label={independentField === Field.OUTPUT ? 'From (estimated)' : 'From'}
@ -255,8 +261,9 @@ export default function Swap() {
/> />
<CursorPointer> <CursorPointer>
<AutoColumn style={{ padding: '0 1rem' }}> <AutoColumn justify="space-between">
<ArrowWrapper> <AutoRow justify="space-between" style={{ padding: '0 1rem' }}>
<ArrowWrapper clickable>
<ArrowDown <ArrowDown
size="16" size="16"
onClick={() => { onClick={() => {
@ -266,6 +273,12 @@ export default function Swap() {
color={tokens[Field.INPUT] && tokens[Field.OUTPUT] ? theme.primary1 : theme.text2} color={tokens[Field.INPUT] && tokens[Field.OUTPUT] ? theme.primary1 : theme.text2}
/> />
</ArrowWrapper> </ArrowWrapper>
{recipient === null ? (
<LinkStyledButton id="add-recipient-button" onClick={() => onChangeRecipient('')}>
+ add recipient
</LinkStyledButton>
) : null}
</AutoRow>
</AutoColumn> </AutoColumn>
</CursorPointer> </CursorPointer>
<CurrencyInputPanel <CurrencyInputPanel
@ -279,7 +292,20 @@ export default function Swap() {
otherSelectedTokenAddress={tokens[Field.INPUT]?.address} otherSelectedTokenAddress={tokens[Field.INPUT]?.address}
id="swap-currency-output" id="swap-currency-output"
/> />
{recipient !== null ? (
<>
<AutoRow justify="space-between" style={{ padding: '0 1rem' }}>
<ArrowWrapper clickable={false}>
<ArrowDown size="16" color={theme.text2} />
</ArrowWrapper>
<LinkStyledButton id="remove-recipient-button" onClick={() => onChangeRecipient(null)}>
- remove recipient
</LinkStyledButton>
</AutoRow>
<AddressInputPanel id="recipient" value={recipient} onChange={onChangeRecipient} />
</> </>
) : null}
<Card padding={'.25rem .75rem 0 .75rem'} borderRadius={'20px'}> <Card padding={'.25rem .75rem 0 .75rem'} borderRadius={'20px'}>
<AutoColumn gap="4px"> <AutoColumn gap="4px">
@ -290,7 +316,7 @@ export default function Swap() {
<TradePrice <TradePrice
inputToken={tokens[Field.INPUT]} inputToken={tokens[Field.INPUT]}
outputToken={tokens[Field.OUTPUT]} outputToken={tokens[Field.OUTPUT]}
price={bestTrade?.executionPrice} price={trade?.executionPrice}
showInverted={showInverted} showInverted={showInverted}
setShowInverted={setShowInverted} setShowInverted={setShowInverted}
/> />
@ -371,7 +397,7 @@ export default function Swap() {
</Wrapper> </Wrapper>
</AppBody> </AppBody>
<AdvancedSwapDetailsDropdown trade={bestTrade} /> <AdvancedSwapDetailsDropdown trade={trade} />
</> </>
) )
} }

@ -13,4 +13,6 @@ export const replaceSwapState = createAction<{
typedValue: string typedValue: string
inputTokenAddress?: string inputTokenAddress?: string
outputTokenAddress?: string outputTokenAddress?: string
recipient: string | null
}>('replaceSwapState') }>('replaceSwapState')
export const setRecipient = createAction<{ recipient: string | null }>('setRecipient')

@ -18,7 +18,8 @@ describe('hooks', () => {
[Field.OUTPUT]: { address: '0x6B175474E89094C44Da98b954EedeAC495271d0F' }, [Field.OUTPUT]: { address: '0x6B175474E89094C44Da98b954EedeAC495271d0F' },
[Field.INPUT]: { address: WETH[ChainId.MAINNET].address }, [Field.INPUT]: { address: WETH[ChainId.MAINNET].address },
typedValue: '20.5', typedValue: '20.5',
independentField: Field.OUTPUT independentField: Field.OUTPUT,
recipient: null
}) })
}) })
@ -32,7 +33,8 @@ describe('hooks', () => {
[Field.INPUT]: { address: '' }, [Field.INPUT]: { address: '' },
[Field.OUTPUT]: { address: WETH[ChainId.MAINNET].address }, [Field.OUTPUT]: { address: WETH[ChainId.MAINNET].address },
typedValue: '', typedValue: '',
independentField: Field.INPUT independentField: Field.INPUT,
recipient: null
}) })
}) })
@ -46,7 +48,58 @@ describe('hooks', () => {
[Field.OUTPUT]: { address: WETH[ChainId.MAINNET].address }, [Field.OUTPUT]: { address: WETH[ChainId.MAINNET].address },
[Field.INPUT]: { address: '' }, [Field.INPUT]: { address: '' },
typedValue: '20.5', typedValue: '20.5',
independentField: Field.INPUT independentField: Field.INPUT,
recipient: null
})
})
test('invalid recipient', () => {
expect(
queryParametersToSwapState(
parse('?outputCurrency=eth&exactAmount=20.5&recipient=abc', { parseArrays: false, ignoreQueryPrefix: true }),
ChainId.MAINNET
)
).toEqual({
[Field.OUTPUT]: { address: WETH[ChainId.MAINNET].address },
[Field.INPUT]: { address: '' },
typedValue: '20.5',
independentField: Field.INPUT,
recipient: null
})
})
test('valid recipient', () => {
expect(
queryParametersToSwapState(
parse('?outputCurrency=eth&exactAmount=20.5&recipient=0x0fF2D1eFd7A57B7562b2bf27F3f37899dB27F4a5', {
parseArrays: false,
ignoreQueryPrefix: true
}),
ChainId.MAINNET
)
).toEqual({
[Field.OUTPUT]: { address: WETH[ChainId.MAINNET].address },
[Field.INPUT]: { address: '' },
typedValue: '20.5',
independentField: Field.INPUT,
recipient: '0x0fF2D1eFd7A57B7562b2bf27F3f37899dB27F4a5'
})
})
test('accepts any recipient', () => {
expect(
queryParametersToSwapState(
parse('?outputCurrency=eth&exactAmount=20.5&recipient=bob.argent.xyz', {
parseArrays: false,
ignoreQueryPrefix: true
}),
ChainId.MAINNET
)
).toEqual({
[Field.OUTPUT]: { address: WETH[ChainId.MAINNET].address },
[Field.INPUT]: { address: '' },
typedValue: '20.5',
independentField: Field.INPUT,
recipient: 'bob.argent.xyz'
}) })
}) })
}) })

@ -1,4 +1,5 @@
import { Version } from './../../hooks/useToggledVersion' import useENS from '../../hooks/useENS'
import { Version } from '../../hooks/useToggledVersion'
import { parseUnits } from '@ethersproject/units' import { parseUnits } from '@ethersproject/units'
import { ChainId, JSBI, Token, TokenAmount, Trade, WETH } from '@uniswap/sdk' import { ChainId, JSBI, Token, TokenAmount, Trade, WETH } from '@uniswap/sdk'
import { ParsedQs } from 'qs' import { ParsedQs } from 'qs'
@ -12,7 +13,7 @@ import useParsedQueryString from '../../hooks/useParsedQueryString'
import { isAddress } from '../../utils' import { isAddress } from '../../utils'
import { AppDispatch, AppState } from '../index' import { AppDispatch, AppState } from '../index'
import { useTokenBalancesTreatWETHAsETH } from '../wallet/hooks' import { useTokenBalancesTreatWETHAsETH } from '../wallet/hooks'
import { Field, replaceSwapState, selectToken, switchTokens, typeInput } from './actions' import { Field, replaceSwapState, selectToken, setRecipient, switchTokens, typeInput } from './actions'
import { SwapState } from './reducer' import { SwapState } from './reducer'
import useToggledVersion from '../../hooks/useToggledVersion' import useToggledVersion from '../../hooks/useToggledVersion'
import { useUserSlippageTolerance } from '../user/hooks' import { useUserSlippageTolerance } from '../user/hooks'
@ -26,6 +27,7 @@ export function useSwapActionHandlers(): {
onTokenSelection: (field: Field, address: string) => void onTokenSelection: (field: Field, address: string) => void
onSwitchTokens: () => void onSwitchTokens: () => void
onUserInput: (field: Field, typedValue: string) => void onUserInput: (field: Field, typedValue: string) => void
onChangeRecipient: (recipient: string | null) => void
} { } {
const dispatch = useDispatch<AppDispatch>() const dispatch = useDispatch<AppDispatch>()
const onTokenSelection = useCallback( const onTokenSelection = useCallback(
@ -51,10 +53,18 @@ export function useSwapActionHandlers(): {
[dispatch] [dispatch]
) )
const onChangeRecipient = useCallback(
(recipient: string | null) => {
dispatch(setRecipient({ recipient }))
},
[dispatch]
)
return { return {
onSwitchTokens, onSwitchTokens,
onTokenSelection, onTokenSelection,
onUserInput onUserInput,
onChangeRecipient
} }
} }
@ -93,11 +103,14 @@ export function useDerivedSwapInfo(): {
independentField, independentField,
typedValue, typedValue,
[Field.INPUT]: { address: tokenInAddress }, [Field.INPUT]: { address: tokenInAddress },
[Field.OUTPUT]: { address: tokenOutAddress } [Field.OUTPUT]: { address: tokenOutAddress },
recipient
} = useSwapState() } = useSwapState()
const tokenIn = useToken(tokenInAddress) const tokenIn = useToken(tokenInAddress)
const tokenOut = useToken(tokenOutAddress) const tokenOut = useToken(tokenOutAddress)
const recipientLookup = useENS(recipient ?? undefined)
const to: string | null = (recipient === null ? account : recipientLookup.address) ?? null
const relevantTokenBalances = useTokenBalancesTreatWETHAsETH(account ?? undefined, [ const relevantTokenBalances = useTokenBalancesTreatWETHAsETH(account ?? undefined, [
tokenIn ?? undefined, tokenIn ?? undefined,
@ -138,6 +151,10 @@ export function useDerivedSwapInfo(): {
error = error ?? 'Select a token' error = error ?? 'Select a token'
} }
if (!to) {
error = error ?? 'Enter a recipient'
}
const [allowedSlippage] = useUserSlippageTolerance() const [allowedSlippage] = useUserSlippageTolerance()
const slippageAdjustedAmounts = const slippageAdjustedAmounts =
@ -172,14 +189,14 @@ export function useDerivedSwapInfo(): {
} }
} }
function parseCurrencyFromURLParameter(urlParam: any, chainId: number, overrideWETH: boolean): string { function parseCurrencyFromURLParameter(urlParam: any, chainId: number): string {
if (typeof urlParam === 'string') { if (typeof urlParam === 'string') {
const valid = isAddress(urlParam) const valid = isAddress(urlParam)
if (valid) return valid if (valid) return valid
if (urlParam.toLowerCase() === 'eth') return WETH[chainId as ChainId]?.address ?? '' if (urlParam.toLowerCase() === 'eth') return WETH[chainId as ChainId]?.address ?? ''
if (valid === false) return WETH[chainId as ChainId]?.address ?? '' if (valid === false) return WETH[chainId as ChainId]?.address ?? ''
} }
return overrideWETH ? '' : WETH[chainId as ChainId]?.address ?? '' return WETH[chainId as ChainId]?.address ?? ''
} }
function parseTokenAmountURLParameter(urlParam: any): string { function parseTokenAmountURLParameter(urlParam: any): string {
@ -190,9 +207,20 @@ function parseIndependentFieldURLParameter(urlParam: any): Field {
return typeof urlParam === 'string' && urlParam.toLowerCase() === 'output' ? Field.OUTPUT : Field.INPUT return typeof urlParam === 'string' && urlParam.toLowerCase() === 'output' ? Field.OUTPUT : Field.INPUT
} }
export function queryParametersToSwapState(parsedQs: ParsedQs, chainId: ChainId, overrideETH: boolean): SwapState { const ENS_NAME_REGEX = /^[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)?$/
let inputCurrency = parseCurrencyFromURLParameter(parsedQs.inputCurrency, chainId, overrideETH) const ADDRESS_REGEX = /^0x[a-fA-F0-9]{40}$/
let outputCurrency = parseCurrencyFromURLParameter(parsedQs.outputCurrency, chainId, overrideETH) function validatedRecipient(recipient: any): string | null {
if (typeof recipient !== 'string') return null
const address = isAddress(recipient)
if (address) return address
if (ENS_NAME_REGEX.test(recipient)) return recipient
if (ADDRESS_REGEX.test(recipient)) return recipient
return null
}
export function queryParametersToSwapState(parsedQs: ParsedQs, chainId: ChainId): SwapState {
let inputCurrency = parseCurrencyFromURLParameter(parsedQs.inputCurrency, chainId)
let outputCurrency = parseCurrencyFromURLParameter(parsedQs.outputCurrency, chainId)
if (inputCurrency === outputCurrency) { if (inputCurrency === outputCurrency) {
if (typeof parsedQs.outputCurrency === 'string') { if (typeof parsedQs.outputCurrency === 'string') {
inputCurrency = '' inputCurrency = ''
@ -201,6 +229,8 @@ export function queryParametersToSwapState(parsedQs: ParsedQs, chainId: ChainId,
} }
} }
const recipient = validatedRecipient(parsedQs.recipient)
return { return {
[Field.INPUT]: { [Field.INPUT]: {
address: inputCurrency address: inputCurrency
@ -209,29 +239,29 @@ export function queryParametersToSwapState(parsedQs: ParsedQs, chainId: ChainId,
address: outputCurrency address: outputCurrency
}, },
typedValue: parseTokenAmountURLParameter(parsedQs.exactAmount), typedValue: parseTokenAmountURLParameter(parsedQs.exactAmount),
independentField: parseIndependentFieldURLParameter(parsedQs.exactField) independentField: parseIndependentFieldURLParameter(parsedQs.exactField),
recipient
} }
} }
// updates the swap state to use the defaults for a given network // updates the swap state to use the defaults for a given network
// set overrideETH to true if dont want to autopopulate ETH export function useDefaultsFromURLSearch() {
export function useDefaultsFromURLSearch(overrideWETH = false) {
const { chainId } = useActiveWeb3React() const { chainId } = useActiveWeb3React()
const dispatch = useDispatch<AppDispatch>() const dispatch = useDispatch<AppDispatch>()
const parsedQs = useParsedQueryString() const parsedQs = useParsedQueryString()
useEffect(() => { useEffect(() => {
if (!chainId) return if (!chainId) return
const parsed = queryParametersToSwapState(parsedQs, chainId, overrideWETH) const parsed = queryParametersToSwapState(parsedQs, chainId)
dispatch( dispatch(
replaceSwapState({ replaceSwapState({
typedValue: parsed.typedValue, typedValue: parsed.typedValue,
field: parsed.independentField, field: parsed.independentField,
inputTokenAddress: parsed[Field.INPUT].address, inputTokenAddress: parsed[Field.INPUT].address,
outputTokenAddress: parsed[Field.OUTPUT].address outputTokenAddress: parsed[Field.OUTPUT].address,
recipient: parsed.recipient
}) })
) )
// eslint-disable-next-line }, [dispatch, chainId, parsedQs])
}, [dispatch, chainId])
} }

@ -1,5 +1,5 @@
import { createReducer } from '@reduxjs/toolkit' import { createReducer } from '@reduxjs/toolkit'
import { Field, replaceSwapState, selectToken, switchTokens, typeInput } from './actions' import { Field, replaceSwapState, selectToken, setRecipient, switchTokens, typeInput } from './actions'
export interface SwapState { export interface SwapState {
readonly independentField: Field readonly independentField: Field
@ -10,6 +10,8 @@ export interface SwapState {
readonly [Field.OUTPUT]: { readonly [Field.OUTPUT]: {
readonly address: string | undefined readonly address: string | undefined
} }
// the typed recipient address, or null if swap should go to sender
readonly recipient: string | null
} }
const initialState: SwapState = { const initialState: SwapState = {
@ -20,12 +22,15 @@ const initialState: SwapState = {
}, },
[Field.OUTPUT]: { [Field.OUTPUT]: {
address: '' address: ''
} },
recipient: null
} }
export default createReducer<SwapState>(initialState, builder => export default createReducer<SwapState>(initialState, builder =>
builder builder
.addCase(replaceSwapState, (state, { payload: { typedValue, field, inputTokenAddress, outputTokenAddress } }) => { .addCase(
replaceSwapState,
(state, { payload: { typedValue, recipient, field, inputTokenAddress, outputTokenAddress } }) => {
return { return {
[Field.INPUT]: { [Field.INPUT]: {
address: inputTokenAddress address: inputTokenAddress
@ -34,9 +39,11 @@ export default createReducer<SwapState>(initialState, builder =>
address: outputTokenAddress address: outputTokenAddress
}, },
independentField: field, independentField: field,
typedValue: typedValue typedValue: typedValue,
recipient
} }
}) }
)
.addCase(selectToken, (state, { payload: { address, field } }) => { .addCase(selectToken, (state, { payload: { address, field } }) => {
const otherField = field === Field.INPUT ? Field.OUTPUT : Field.INPUT const otherField = field === Field.INPUT ? Field.OUTPUT : Field.INPUT
if (address === state[otherField].address) { if (address === state[otherField].address) {
@ -70,4 +77,7 @@ export default createReducer<SwapState>(initialState, builder =>
typedValue typedValue
} }
}) })
.addCase(setRecipient, (state, { payload: { recipient } }) => {
state.recipient = recipient
})
) )

@ -39,7 +39,7 @@ export function useAllTransactions(): { [txHash: string]: TransactionDetails } {
const state = useSelector<AppState, TransactionState>(state => state.transactions) const state = useSelector<AppState, TransactionState>(state => state.transactions)
return state[chainId ?? -1] ?? {} return chainId ? state[chainId] ?? {} : {}
} }
export function useIsTransactionPending(transactionHash?: string): boolean { export function useIsTransactionPending(transactionHash?: string): boolean {

@ -188,6 +188,10 @@ body {
box-sizing: border-box; box-sizing: border-box;
} }
button {
user-select: none;
}
html { html {
font-size: 16px; font-size: 16px;
font-variant: none; font-variant: none;