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 => {
const input = event.target.value
const withoutSpaces = input.replace(/\s+/g, '')
onChange(withoutSpaces)
},
[onChange]
)
// keep data and errors in sync const error = Boolean(value.length > 0 && !loading && !address)
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 checksummedInput = isAddress(input.replace(/\s/g, '')) // delete whitespace
setInput(checksummedInput || input)
}
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,89 +48,68 @@ 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'}>
<Tabs> {t('swap')}
<RowBetween style={{ padding: '1rem' }}> </StyledNavLink>
<CursorPointer onClick={() => history.push('/pool')}> <StyledNavLink id={`pool-nav-link`} to={'/pool'} isActive={() => active === 'pool'}>
<ArrowLink /> {t('pool')}
</CursorPointer> </StyledNavLink>
<ActiveText>{adding ? 'Add' : 'Remove'} Liquidity</ActiveText> </Tabs>
<QuestionHelper
text={
adding
? 'When you add liquidity, you are given pool tokens representing your position. These tokens automatically earn fees proportional to your share of the pool, and can be redeemed at any time.'
: 'Removing pool tokens converts your position back into underlying tokens at the current rate, proportional to your share of the pool. Accrued fees are included in the amounts you receive.'
}
/>
</RowBetween>
</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) export function CreatePoolTabs() {
return (
<Tabs>
<RowBetween style={{ padding: '1rem' }}>
<HistoryLink to="/pool">
<StyledArrowLeft />
</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>
<QuestionHelper
text={
adding
? 'When you add liquidity, you are given pool tokens representing your position. These tokens automatically earn fees proportional to your share of the pool, and can be redeemed at any time.'
: 'Removing pool tokens converts your position back into underlying tokens at the current rate, proportional to your share of the pool. Accrued fees are included in the amounts you receive.'
}
/>
</RowBetween>
</Tabs>
)
}

@ -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,17 +8,18 @@ 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;
:hover { ${({ clickable }) =>
cursor: pointer; clickable
opacity: 0.8; ? css`
} :hover {
cursor: pointer;
opacity: 0.8;
}
`
: null}
` `
export const SectionBreak = styled.div` export const SectionBreak = styled.div`

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,28 +242,28 @@ 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'} value={formattedAmounts[Field.INPUT]}
value={formattedAmounts[Field.INPUT]} showMaxButton={!atMaxAmountInput}
showMaxButton={!atMaxAmountInput} token={tokens[Field.INPUT]}
token={tokens[Field.INPUT]} onUserInput={onUserInput}
onUserInput={onUserInput} onMax={() => {
onMax={() => { maxAmountInput && onUserInput(Field.INPUT, maxAmountInput.toExact())
maxAmountInput && onUserInput(Field.INPUT, maxAmountInput.toExact()) }}
}} onTokenSelection={address => {
onTokenSelection={address => { setApprovalSubmitted(false) // reset 2 step UI for approvals
setApprovalSubmitted(false) // reset 2 step UI for approvals onTokenSelection(Field.INPUT, address)
onTokenSelection(Field.INPUT, address) }}
}} otherSelectedTokenAddress={tokens[Field.OUTPUT]?.address}
otherSelectedTokenAddress={tokens[Field.OUTPUT]?.address} id="swap-currency-input"
id="swap-currency-input" />
/>
<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,20 +273,39 @@ 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>
</AutoColumn> {recipient === null ? (
</CursorPointer> <LinkStyledButton id="add-recipient-button" onClick={() => onChangeRecipient('')}>
<CurrencyInputPanel + add recipient
field={Field.OUTPUT} </LinkStyledButton>
value={formattedAmounts[Field.OUTPUT]} ) : null}
onUserInput={onUserInput} </AutoRow>
label={independentField === Field.INPUT ? 'To (estimated)' : 'To'} </AutoColumn>
showMaxButton={false} </CursorPointer>
token={tokens[Field.OUTPUT]} <CurrencyInputPanel
onTokenSelection={address => onTokenSelection(Field.OUTPUT, address)} field={Field.OUTPUT}
otherSelectedTokenAddress={tokens[Field.INPUT]?.address} value={formattedAmounts[Field.OUTPUT]}
id="swap-currency-output" onUserInput={onUserInput}
/> label={independentField === Field.INPUT ? 'To (estimated)' : 'To'}
</> showMaxButton={false}
token={tokens[Field.OUTPUT]}
onTokenSelection={address => onTokenSelection(Field.OUTPUT, address)}
otherSelectedTokenAddress={tokens[Field.INPUT]?.address}
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,23 +22,28 @@ 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(
return { replaceSwapState,
[Field.INPUT]: { (state, { payload: { typedValue, recipient, field, inputTokenAddress, outputTokenAddress } }) => {
address: inputTokenAddress return {
}, [Field.INPUT]: {
[Field.OUTPUT]: { address: inputTokenAddress
address: outputTokenAddress },
}, [Field.OUTPUT]: {
independentField: field, address: outputTokenAddress
typedValue: typedValue },
independentField: field,
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;