diff --git a/cypress/integration/send.test.ts b/cypress/integration/send.test.ts index 38d435123f..5eb639a76b 100644 --- a/cypress/integration/send.test.ts +++ b/cypress/integration/send.test.ts @@ -1,9 +1,7 @@ describe('Send', () => { beforeEach(() => cy.visit('/send')) - it('can enter an amount into input', () => { - cy.get('#sending-no-swap-input') - .type('0.001', { delay: 200 }) - .should('have.value', '0.001') + it('should redirect', () => { + cy.url().should('include', '/swap') }) }) diff --git a/cypress/integration/swap.test.ts b/cypress/integration/swap.test.ts index eb3b2f74a2..662f4b0153 100644 --- a/cypress/integration/swap.test.ts +++ b/cypress/integration/swap.test.ts @@ -40,4 +40,15 @@ describe('Swap', () => { cy.get('#swap-button').click() 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') + }) }) diff --git a/src/components/AccountDetails/Transaction.tsx b/src/components/AccountDetails/Transaction.tsx index 389be13931..cbe59a3950 100644 --- a/src/components/AccountDetails/Transaction.tsx +++ b/src/components/AccountDetails/Transaction.tsx @@ -1,6 +1,6 @@ import React from 'react' 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 { getEtherscanLink } from '../../utils' @@ -50,8 +50,7 @@ export default function Transaction({ hash }: { hash: string }) { - {summary ? summary : hash} - + {summary ?? hash} ↗ {pending ? : success ? : } diff --git a/src/components/AddressInputPanel/index.tsx b/src/components/AddressInputPanel/index.tsx index f5132d637e..de5e6cf75c 100644 --- a/src/components/AddressInputPanel/index.tsx +++ b/src/components/AddressInputPanel/index.tsx @@ -1,8 +1,6 @@ -import React, { useState, useEffect, useContext } from 'react' +import React, { useContext, useCallback } from 'react' import styled, { ThemeContext } from 'styled-components' -import useDebounce from '../../hooks/useDebounce' - -import { isAddress } from '../../utils' +import useENS from '../../hooks/useENS' import { useActiveWeb3React } from '../../hooks' import { ExternalLink, TYPE } from '../../theme' import { AutoColumn } from '../Column' @@ -24,6 +22,8 @@ const ContainerRow = styled.div<{ error: boolean }>` align-items: center; border-radius: 1.25rem; 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}; ` @@ -39,6 +39,7 @@ const Input = styled.input<{ error?: boolean }>` flex: 1 1 auto; width: 0; background-color: ${({ theme }) => theme.bg1}; + transition: color 300ms ${({ error }) => (error ? 'step-end' : 'step-start')}; color: ${({ error, theme }) => (error ? theme.red1 : theme.primary1)}; overflow: hidden; text-overflow: ellipsis; @@ -64,120 +65,65 @@ const Input = styled.input<{ error?: boolean }>` } ` +interface Value { + address: string + name?: string +} + export default function AddressInputPanel({ - initialInput = '', - onChange, - onError + id, + value, + onChange }: { - initialInput?: string - onChange: (val: { address: string; name?: string }) => void - onError: (error: boolean, input: string) => void + id?: string + // the typed string value + value: string + // triggers whenever the typed value changes + onChange: (value: string) => void }) { - const { chainId, library } = useActiveWeb3React() + const { chainId } = useActiveWeb3React() const theme = useContext(ThemeContext) - const [input, setInput] = useState(initialInput ? initialInput : '') - const debouncedInput = useDebounce(input, 200) + const { address, loading, name } = useENS(value) - const [data, setData] = useState<{ address: string; name: string }>({ address: undefined, name: undefined }) - const [error, setError] = useState(false) + const handleInput = useCallback( + event => { + const input = event.target.value + const withoutSpaces = input.replace(/\s+/g, '') + onChange(withoutSpaces) + }, + [onChange] + ) - // 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 checksummedInput = isAddress(input.replace(/\s/g, '')) // delete whitespace - setInput(checksummedInput || input) - } + const error = Boolean(value.length > 0 && !loading && !address) return ( - - + + Recipient - {data.address && ( - + {address && ( + (View on Etherscan) )} diff --git a/src/components/NavigationTabs/index.tsx b/src/components/NavigationTabs/index.tsx index 6c90c002b4..7806402a54 100644 --- a/src/components/NavigationTabs/index.tsx +++ b/src/components/NavigationTabs/index.tsx @@ -1,37 +1,18 @@ -import React, { useCallback } from 'react' +import React from 'react' import styled from 'styled-components' import { darken } from 'polished' import { useTranslation } from 'react-i18next' -import { withRouter, NavLink, Link as HistoryLink, RouteComponentProps } from 'react-router-dom' -import useBodyKeyDown from '../../hooks/useBodyKeyDown' +import { NavLink, Link as HistoryLink } from 'react-router-dom' -import { CursorPointer } from '../../theme' import { ArrowLeft } from 'react-feather' import { RowBetween } from '../Row' 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` ${({ theme }) => theme.flexRowNoWrap} align-items: center; border-radius: 3rem; + justify-content: space-evenly; ` const activeClassName = 'ACTIVE' @@ -43,7 +24,6 @@ const StyledNavLink = styled(NavLink).attrs({ align-items: center; justify-content: center; height: 3rem; - flex: 1 0 auto; border-radius: 3rem; outline: none; cursor: pointer; @@ -68,89 +48,68 @@ const ActiveText = styled.div` font-size: 20px; ` -const ArrowLink = styled(ArrowLeft)` +const StyledArrowLeft = styled(ArrowLeft)` color: ${({ theme }) => theme.text1}; ` -function NavigationTabs({ location: { pathname }, history }: RouteComponentProps<{}>) { +export function SwapPoolTabs({ active }: { active: 'swap' | 'pool' }) { 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 ( - <> - {adding || removing ? ( - - - history.push('/pool')}> - - - {adding ? 'Add' : 'Remove'} Liquidity - - - - ) : finding ? ( - - - - - - Import Pool - - - - ) : creating ? ( - - - - - - Create Pool - - - - ) : ( - - {tabOrder.map(({ path, textKey, regex }) => ( - !!pathname.match(regex)} - > - {t(textKey)} - - ))} - - )} - + + active === 'swap'}> + {t('swap')} + + active === 'pool'}> + {t('pool')} + + ) } -export default withRouter(NavigationTabs) +export function CreatePoolTabs() { + return ( + + + + + + Create Pool + + + + ) +} + +export function FindPoolTabs() { + return ( + + + + + + Import Pool + + + + ) +} + +export function AddRemoveTabs({ adding }: { adding: boolean }) { + return ( + + + + + + {adding ? 'Add' : 'Remove'} Liquidity + + + + ) +} diff --git a/src/components/TxnPopup/index.tsx b/src/components/TxnPopup/index.tsx index 4c67e76ec4..af8d78f3d8 100644 --- a/src/components/TxnPopup/index.tsx +++ b/src/components/TxnPopup/index.tsx @@ -62,9 +62,7 @@ export default function TxnPopup({ {success ? : } - - {summary ? summary : 'Hash: ' + hash.slice(0, 8) + '...' + hash.slice(58, 65)} - + {summary ?? 'Hash: ' + hash.slice(0, 8) + '...' + hash.slice(58, 65)} View on Etherscan diff --git a/src/components/Web3Status/index.tsx b/src/components/Web3Status/index.tsx index c72e2ac58f..9322d1ee4c 100644 --- a/src/components/Web3Status/index.tsx +++ b/src/components/Web3Status/index.tsx @@ -130,7 +130,7 @@ export default function Web3Status() { const { active, account, connector, error } = useWeb3React() const contextNetwork = useWeb3React(NetworkContextName) - const ENSName = useENSName(account) + const { ENSName } = useENSName(account) const allTransactions = useAllTransactions() diff --git a/src/components/swap/SwapModalHeader.tsx b/src/components/swap/SwapModalHeader.tsx index d6dd469f07..433acb90f7 100644 --- a/src/components/swap/SwapModalHeader.tsx +++ b/src/components/swap/SwapModalHeader.tsx @@ -5,6 +5,7 @@ import { Text } from 'rebass' import { ThemeContext } from 'styled-components' import { Field } from '../../state/swap/actions' import { TYPE } from '../../theme' +import { isAddress, shortenAddress } from '../../utils' import { AutoColumn } from '../Column' import { RowBetween, RowFixed } from '../Row' import TokenLogo from '../TokenLogo' @@ -15,13 +16,15 @@ export default function SwapModalHeader({ formattedAmounts, slippageAdjustedAmounts, priceImpactSeverity, - independentField + independentField, + recipient }: { tokens: { [field in Field]?: Token } formattedAmounts: { [field in Field]?: string } slippageAdjustedAmounts: { [field in Field]?: TokenAmount } priceImpactSeverity: number independentField: Field + recipient: string | null }) { const theme = useContext(ThemeContext) @@ -71,6 +74,14 @@ export default function SwapModalHeader({ )} + {recipient !== null ? ( + + + Output will be sent to{' '} + {isAddress(recipient) ? shortenAddress(recipient) : recipient} + + + ) : null} ) } diff --git a/src/components/swap/TransferModalHeader.tsx b/src/components/swap/TransferModalHeader.tsx deleted file mode 100644 index 7d701531d3..0000000000 --- a/src/components/swap/TransferModalHeader.tsx +++ /dev/null @@ -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 ( - - - - {amount?.toSignificant(6)} {amount?.token?.symbol} - - - - To - {ENSName ? ( - - {ENSName} - - - - {recipient?.slice(0, 8)}...{recipient?.slice(34, 42)}↗ - - - - - - ) : ( - - - - {recipient?.slice(0, 6)}...{recipient?.slice(36, 42)}↗ - - - - - )} - - ) -} diff --git a/src/components/swap/styleds.ts b/src/components/swap/styleds.ts index 5c910ab008..612dd02f5f 100644 --- a/src/components/swap/styleds.ts +++ b/src/components/swap/styleds.ts @@ -1,4 +1,4 @@ -import styled from 'styled-components' +import styled, { css } from 'styled-components' import { AutoColumn } from '../Column' import { Text } from 'rebass' @@ -8,17 +8,18 @@ export const Wrapper = styled.div` position: relative; ` -export const ArrowWrapper = styled.div` +export const ArrowWrapper = styled.div<{ clickable: boolean }>` padding: 2px; - border-radius: 12px; - display: flex; - justify-content: center; - align-items: center; - :hover { - cursor: pointer; - opacity: 0.8; - } + ${({ clickable }) => + clickable + ? css` + :hover { + cursor: pointer; + opacity: 0.8; + } + ` + : null} ` export const SectionBreak = styled.div` diff --git a/src/hooks/useENS.ts b/src/hooks/useENS.ts new file mode 100644 index 0000000000..b556546e8a --- /dev/null +++ b/src/hooks/useENS.ts @@ -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 + } +} diff --git a/src/hooks/useENSAddress.ts b/src/hooks/useENSAddress.ts new file mode 100644 index 0000000000..05d6a13df4 --- /dev/null +++ b/src/hooks/useENSAddress.ts @@ -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 +} diff --git a/src/hooks/useENSName.ts b/src/hooks/useENSName.ts index 54ed7807c9..249c520d1a 100644 --- a/src/hooks/useENSName.ts +++ b/src/hooks/useENSName.ts @@ -6,39 +6,43 @@ import { useActiveWeb3React } from './index' * 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. */ -export default function useENSName(address?: string): string | null { +export default function useENSName(address?: string): { ENSName: string | null; loading: boolean } { const { library } = useActiveWeb3React() - const [ENSName, setENSName] = useState(null) + const [ENSName, setENSName] = useState<{ ENSName: string | null; loading: boolean }>({ + loading: false, + ENSName: null + }) useEffect(() => { - if (!library || !address) return const validated = isAddress(address) - if (validated) { + if (!library || !validated) { + setENSName({ loading: false, ENSName: null }) + return + } else { let stale = false + setENSName({ loading: true, ENSName: null }) library .lookupAddress(validated) .then(name => { if (!stale) { if (name) { - setENSName(name) + setENSName({ loading: false, ENSName: name }) } else { - setENSName(null) + setENSName({ loading: false, ENSName: null }) } } }) .catch(() => { if (!stale) { - setENSName(null) + setENSName({ loading: false, ENSName: null }) } }) return () => { stale = true - setENSName(null) } } - return }, [library, address]) return ENSName diff --git a/src/hooks/useSendCallback.ts b/src/hooks/useSendCallback.ts deleted file mode 100644 index 09f978a239..0000000000 --- a/src/hooks/useSendCallback.ts +++ /dev/null @@ -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) { - 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 { - 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]) -} diff --git a/src/hooks/useSwapCallback.ts b/src/hooks/useSwapCallback.ts index e5bbc8b410..d7421cb7ed 100644 --- a/src/hooks/useSwapCallback.ts +++ b/src/hooks/useSwapCallback.ts @@ -1,18 +1,18 @@ import { BigNumber } from '@ethersproject/bignumber' import { MaxUint256 } from '@ethersproject/constants' 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 { DEFAULT_DEADLINE_FROM_NOW, INITIAL_ALLOWED_SLIPPAGE, ROUTER_ADDRESS } from '../constants' import { useTokenAllowance } from '../data/Allowances' import { getTradeVersion, useV1TradeExchangeAddress } from '../data/V1' import { Field } from '../state/swap/actions' 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 { useActiveWeb3React } from './index' import { useV1ExchangeContract } from './useContract' -import useENSName from './useENSName' +import useENS from './useENS' import { Version } from './useToggledVersion' 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 // and the user has approved the slippage adjusted input amount for the trade export function useSwapCallback( - trade?: Trade, // trade to execute, required + trade: Trade | undefined, // trade to execute, required allowedSlippage: number = INITIAL_ALLOWED_SLIPPAGE, // in bips 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) { const { account, chainId, library } = useActiveWeb3React() const addTransaction = useTransactionAdder() - const recipient = to ? isAddress(to) : account - const ensName = useENSName(to) + + const { address: recipient } = useENS(recipientAddressOrName) + const tradeVersion = getTradeVersion(trade) const v1Exchange = useV1ExchangeContract(useV1TradeExchangeAddress(trade), true) const inputAllowance = useTokenAllowance( @@ -77,7 +78,7 @@ export function useSwapCallback( ) return useMemo(() => { - if (!trade || !recipient || !tradeVersion) return null + if (!trade || !recipient || !library || !account || !tradeVersion || !chainId) return null // will always be defined const { @@ -89,17 +90,13 @@ export function useSwapCallback( // no allowance if ( - !trade.inputAmount.token.equals(WETH[chainId as ChainId]) && + !trade.inputAmount.token.equals(WETH[chainId]) && (!inputAllowance || slippageAdjustedInput.greaterThan(inputAllowance)) ) { return null } return async function onSwap() { - if (!chainId || !library || !account) { - throw new Error('missing dependencies in onSwap callback') - } - const contract: Contract | null = tradeVersion === Version.v2 ? getRouterContract(chainId, library, account) : v1Exchange if (!contract) { @@ -283,7 +280,12 @@ export function useSwapCallback( const outputAmount = slippageAdjustedOutput.toSignificant(3) 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 = tradeVersion === Version.v2 ? withRecipient : `${withRecipient} on ${tradeVersion.toUpperCase()}` @@ -310,15 +312,15 @@ export function useSwapCallback( }, [ trade, recipient, - tradeVersion, - allowedSlippage, - chainId, - inputAllowance, library, account, + tradeVersion, + chainId, + allowedSlippage, + inputAllowance, v1Exchange, deadline, - addTransaction, - ensName + recipientAddressOrName, + addTransaction ]) } diff --git a/src/pages/AddLiquidity/index.tsx b/src/pages/AddLiquidity/index.tsx index b7263a6faf..1a6fe851f2 100644 --- a/src/pages/AddLiquidity/index.tsx +++ b/src/pages/AddLiquidity/index.tsx @@ -35,6 +35,7 @@ import { Field } from '../../state/mint/actions' import { useApproveCallback, ApprovalState } from '../../hooks/useApproveCallback' import { useWalletModalToggle } from '../../state/application/hooks' import { useUserSlippageTolerance, useUserDeadline, useIsExpertMode } from '../../state/user/hooks' +import { AddRemoveTabs } from '../../components/NavigationTabs' export default function AddLiquidity({ match: { params } }: RouteComponentProps<{ tokens: string }>) { useDefaultsFromURLMatchParams(params) @@ -307,6 +308,7 @@ export default function AddLiquidity({ match: { params } }: RouteComponentProps< return ( <> + - + diff --git a/src/pages/AppBody.tsx b/src/pages/AppBody.tsx index 47ec025ba1..2e9c4a368e 100644 --- a/src/pages/AppBody.tsx +++ b/src/pages/AppBody.tsx @@ -1,6 +1,5 @@ import React from 'react' import styled from 'styled-components' -import NavigationTabs from '../components/NavigationTabs' export const BodyWrapper = styled.div` 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. */ export default function AppBody({ children }: { children: React.ReactNode }) { - return ( - - - <>{children} - - ) + return {children} } diff --git a/src/pages/CreatePool/index.tsx b/src/pages/CreatePool/index.tsx index 80824f24bb..d4a0745550 100644 --- a/src/pages/CreatePool/index.tsx +++ b/src/pages/CreatePool/index.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react' import { RouteComponentProps, Redirect } from 'react-router-dom' import { Token, WETH } from '@uniswap/sdk' +import { CreatePoolTabs } from '../../components/NavigationTabs' import AppBody from '../AppBody' import Row, { AutoRow } from '../../components/Row' @@ -56,6 +57,7 @@ export default function CreatePool({ location }: RouteComponentProps) { return ( + {!token0Address ? ( diff --git a/src/pages/Pool/index.tsx b/src/pages/Pool/index.tsx index da242269a8..ef64744d04 100644 --- a/src/pages/Pool/index.tsx +++ b/src/pages/Pool/index.tsx @@ -2,6 +2,7 @@ import React, { useState, useContext, useCallback } from 'react' import styled, { ThemeContext } from 'styled-components' import { JSBI } from '@uniswap/sdk' import { RouteComponentProps } from 'react-router-dom' +import { SwapPoolTabs } from '../../components/NavigationTabs' import Question from '../../components/QuestionHelper' import PairSearchModal from '../../components/SearchModal/PairSearchModal' @@ -70,6 +71,7 @@ export default function Pool({ history }: RouteComponentProps) { return ( + + { diff --git a/src/pages/RemoveLiquidity/index.tsx b/src/pages/RemoveLiquidity/index.tsx index 8b0c458fa5..59467aa914 100644 --- a/src/pages/RemoveLiquidity/index.tsx +++ b/src/pages/RemoveLiquidity/index.tsx @@ -13,6 +13,7 @@ import { AutoColumn, ColumnCenter } from '../../components/Column' import ConfirmationModal from '../../components/ConfirmationModal' import CurrencyInputPanel from '../../components/CurrencyInputPanel' import DoubleLogo from '../../components/DoubleLogo' +import { AddRemoveTabs } from '../../components/NavigationTabs' import PositionCard from '../../components/PositionCard' import Row, { RowBetween, RowFixed } from '../../components/Row' @@ -390,6 +391,7 @@ export default function RemoveLiquidity({ match: { params } }: RouteComponentPro return ( <> + (false) - const [recipient, setRecipient] = useState('') - const [ENS, setENS] = useState('') - const [recipientError, setRecipientError] = useState('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(false) // show confirmation modal - const [attemptingTxn, setAttemptingTxn] = useState(false) // waiting for user confirmaion/rejection - const [txHash, setTxHash] = useState('') - 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(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(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 - } - - if (sendingWithSwap) { - return ( - - - - - - {slippageAdjustedAmounts[Field.OUTPUT]?.toSignificant(4)} {tokens[Field.OUTPUT]?.symbol} - - - - Via {parsedAmounts[Field.INPUT]?.toSignificant(4)} {tokens[Field.INPUT]?.symbol} swap - - - - To - - {recipient?.slice(0, 6)}...{recipient?.slice(36, 42)} - - - - ) - } - } - - function modalBottom() { - if (!sendingWithSwap) { - return ( - - - - Confirm send - - - - ) - } - - if (sendingWithSwap) { - return ( - 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 ? : null} - - - { - setShowConfirm(false) - if (txHash) { - onUserInput(Field.INPUT, '') - } - setTxHash('') - }} - attemptingTxn={attemptingTxn} - hash={txHash} - topContent={modalHeader} - bottomContent={modalBottom} - pendingText={pendingText} - /> - {!sendingWithSwap && ( - - - onUserInput(Field.INPUT, 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} - /> - - - setSendingWithSwap(true)} - > - + Add a swap - - {account && ( - { - maxAmountInput && onUserInput(Field.INPUT, maxAmountInput.toExact()) - }} - > - Input Max - - )} - - - )} - - {sendingWithSwap && ( - <> - { - maxAmountInput && onUserInput(Field.INPUT, maxAmountInput.toExact()) - }} - onTokenSelection={address => { - setApprovalSubmitted(false) - onTokenSelection(Field.INPUT, address) - }} - otherSelectedTokenAddress={tokens[Field.OUTPUT]?.address} - id="swap-currency-input" - /> - {sendingWithSwap ? ( - - - - - - { - setSendingWithSwap(false) - onTokenSelection(Field.OUTPUT, null) - }} - style={{ marginRight: '0px', width: 'auto', fontSize: '14px' }} - padding={'4px 6px'} - > - Remove Swap - - - - ) : ( - - - - - - - - )} - onTokenSelection(Field.OUTPUT, address)} - otherSelectedTokenAddress={tokens[Field.INPUT]?.address} - id="swap-currency-output" - /> - {sendingWithSwap && ( - - - - )} - - )} - - - { - if (error && input !== '') { - setRecipientError('Invalid Recipient') - } else if (error && input === '') { - setRecipientError('Enter a Recipient') - } else { - setRecipientError(null) - } - }} - /> - - {sendingWithSwap && ( - - - - - Price - - - - - {allowedSlippage !== INITIAL_ALLOWED_SLIPPAGE && ( - - - - Slippage Tolerance - - - - - {allowedSlippage ? allowedSlippage / 100 : '-'}% - - - - )} - - - )} - - - {!account ? ( - { - toggleWalletModal() - }} - > - Connect Wallet - - ) : noRoute && userHasSpecifiedInputOutput ? ( - - Insufficient liquidity for this trade. - - ) : showApproveFlow ? ( - - - {approval === ApprovalState.PENDING ? ( - Approving - ) : approvalSubmitted && approval === ApprovalState.APPROVED ? ( - 'Approved' - ) : ( - 'Approve ' + tokens[Field.INPUT]?.symbol - )} - - { - expertMode ? (sendingWithSwap ? onSwap() : onSend()) : setShowConfirm(true) - }} - width="48%" - id="send-button" - disabled={approval !== ApprovalState.APPROVED} - error={sendingWithSwap && isSwapValid && severity > 2} - > - - {severity > 3 && !expertMode ? `Price Impact High` : `Send${severity > 2 ? ' Anyway' : ''}`} - - - - ) : ( - { - expertMode ? (sendingWithSwap ? onSwap() : onSend()) : setShowConfirm(true) - }} - id="send-button" - disabled={ - (sendingWithSwap && !isSwapValid) || - (!sendingWithSwap && !isSendValid) || - (severity > 3 && !expertMode && sendingWithSwap) - } - error={sendingWithSwap && isSwapValid && severity > 2} - > - - {(sendingWithSwap ? swapError : null) || - sendAmountError || - recipientError || - (severity > 3 && !expertMode && `Price Impact Too High`) || - `Send${severity > 2 ? ' Anyway' : ''}`} - - - )} - {betterTradeLinkVersion && } - - - - - - - ) -} diff --git a/src/pages/Swap/index.tsx b/src/pages/Swap/index.tsx index 51ef6fdaf1..6ade0fac73 100644 --- a/src/pages/Swap/index.tsx +++ b/src/pages/Swap/index.tsx @@ -4,12 +4,14 @@ 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 } from '../../components/Button' import Card, { GreyCard } from '../../components/Card' import { AutoColumn } from '../../components/Column' import ConfirmationModal from '../../components/ConfirmationModal' 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 confirmPriceImpactWithoutFee from '../../components/swap/confirmPriceImpactWithoutFee' 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 { useActiveWeb3React } from '../../hooks' import { useApproveCallbackFromTrade, ApprovalState } from '../../hooks/useApproveCallback' +import useENSAddress from '../../hooks/useENSAddress' import { useSwapCallback } from '../../hooks/useSwapCallback' import { useWalletModalToggle, useToggleSettingsMenu } from '../../state/application/hooks' import { useExpertModeManager, useUserSlippageTolerance, useUserDeadline } from '../../state/user/hooks' @@ -34,7 +37,7 @@ import { useSwapActionHandlers, useSwapState } from '../../state/swap/hooks' -import { CursorPointer, TYPE } from '../../theme' +import { CursorPointer, LinkStyledButton, TYPE } from '../../theme' import { computeSlippageAdjustedAmounts, computeTradePriceBreakdown, warningSeverity } from '../../utils/prices' import AppBody from '../AppBody' import { ClickableText } from '../Pool/styleds' @@ -57,10 +60,11 @@ export default function Swap() { const [allowedSlippage] = useUserSlippageTolerance() // swap state - const { independentField, typedValue } = useSwapState() + const { independentField, typedValue, recipient } = useSwapState() const { bestTrade: bestTradeV2, tokenBalances, parsedAmount, tokens, error, v1Trade } = useDerivedSwapInfo() + const { address: recipientAddress } = useENSAddress(recipient) const toggledVersion = useToggledVersion() - const bestTrade = { + const trade = { [Version.v1]: v1Trade, [Version.v2]: bestTradeV2 }[toggledVersion] @@ -73,11 +77,11 @@ export default function Swap() { : undefined const parsedAmounts = { - [Field.INPUT]: independentField === Field.INPUT ? parsedAmount : bestTrade?.inputAmount, - [Field.OUTPUT]: independentField === Field.OUTPUT ? parsedAmount : bestTrade?.outputAmount + [Field.INPUT]: independentField === Field.INPUT ? parsedAmount : trade?.inputAmount, + [Field.OUTPUT]: independentField === Field.OUTPUT ? parsedAmount : trade?.outputAmount } - const { onSwitchTokens, onTokenSelection, onUserInput } = useSwapActionHandlers() + const { onSwitchTokens, onTokenSelection, onUserInput, onChangeRecipient } = useSwapActionHandlers() const isValid = !error 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) : '' } - const route = bestTrade?.route + const route = trade?.route const userHasSpecifiedInputOutput = !!tokens[Field.INPUT] && !!tokens[Field.OUTPUT] && @@ -100,7 +104,7 @@ export default function Swap() { const noRoute = !route // 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 const [approvalSubmitted, setApprovalSubmitted] = useState(false) @@ -126,12 +130,12 @@ export default function Swap() { const atMaxAmountInput: boolean = 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 - 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() { if (priceImpactWithoutFee && !confirmPriceImpactWithoutFee(priceImpactWithoutFee)) { @@ -145,12 +149,13 @@ export default function Swap() { ReactGA.event({ category: 'Swap', - action: 'Swap w/o Send', - label: [ - bestTrade.inputAmount.token.symbol, - bestTrade.outputAmount.token.symbol, - getTradeVersion(bestTrade) - ].join('/') + action: + recipient === null + ? 'Swap w/o Send' + : (recipientAddress ?? recipient) === account + ? 'Swap w/o Send + recipient' + : 'Swap w/ Send', + label: [trade.inputAmount.token.symbol, trade.outputAmount.token.symbol, getTradeVersion(trade)].join('/') }) }) .catch(error => { @@ -185,6 +190,7 @@ export default function Swap() { slippageAdjustedAmounts={slippageAdjustedAmounts} priceImpactSeverity={priceImpactSeverity} independentField={independentField} + recipient={recipient} /> ) } @@ -201,7 +207,7 @@ export default function Swap() { parsedAmounts={parsedAmounts} priceImpactWithoutFee={priceImpactWithoutFee} slippageAdjustedAmounts={slippageAdjustedAmounts} - trade={bestTrade} + trade={trade} /> ) } @@ -215,6 +221,7 @@ export default function Swap() { <> + - <> - { - maxAmountInput && onUserInput(Field.INPUT, maxAmountInput.toExact()) - }} - onTokenSelection={address => { - setApprovalSubmitted(false) // reset 2 step UI for approvals - onTokenSelection(Field.INPUT, address) - }} - otherSelectedTokenAddress={tokens[Field.OUTPUT]?.address} - id="swap-currency-input" - /> + { + maxAmountInput && onUserInput(Field.INPUT, maxAmountInput.toExact()) + }} + onTokenSelection={address => { + setApprovalSubmitted(false) // reset 2 step UI for approvals + onTokenSelection(Field.INPUT, address) + }} + otherSelectedTokenAddress={tokens[Field.OUTPUT]?.address} + id="swap-currency-input" + /> - - - + + + + { @@ -266,20 +273,39 @@ export default function Swap() { color={tokens[Field.INPUT] && tokens[Field.OUTPUT] ? theme.primary1 : theme.text2} /> - - - onTokenSelection(Field.OUTPUT, address)} - otherSelectedTokenAddress={tokens[Field.INPUT]?.address} - id="swap-currency-output" - /> - + {recipient === null ? ( + onChangeRecipient('')}> + + add recipient + + ) : null} + + + + onTokenSelection(Field.OUTPUT, address)} + otherSelectedTokenAddress={tokens[Field.INPUT]?.address} + id="swap-currency-output" + /> + + {recipient !== null ? ( + <> + + + + + onChangeRecipient(null)}> + - remove recipient + + + + + ) : null} @@ -290,7 +316,7 @@ export default function Swap() { @@ -371,7 +397,7 @@ export default function Swap() { - + ) } diff --git a/src/state/swap/actions.ts b/src/state/swap/actions.ts index 91cb3ebddc..9cc3ee982b 100644 --- a/src/state/swap/actions.ts +++ b/src/state/swap/actions.ts @@ -13,4 +13,6 @@ export const replaceSwapState = createAction<{ typedValue: string inputTokenAddress?: string outputTokenAddress?: string + recipient: string | null }>('replaceSwapState') +export const setRecipient = createAction<{ recipient: string | null }>('setRecipient') diff --git a/src/state/swap/hooks.test.ts b/src/state/swap/hooks.test.ts index f62e97581f..dcf756f06b 100644 --- a/src/state/swap/hooks.test.ts +++ b/src/state/swap/hooks.test.ts @@ -18,7 +18,8 @@ describe('hooks', () => { [Field.OUTPUT]: { address: '0x6B175474E89094C44Da98b954EedeAC495271d0F' }, [Field.INPUT]: { address: WETH[ChainId.MAINNET].address }, typedValue: '20.5', - independentField: Field.OUTPUT + independentField: Field.OUTPUT, + recipient: null }) }) @@ -32,7 +33,8 @@ describe('hooks', () => { [Field.INPUT]: { address: '' }, [Field.OUTPUT]: { address: WETH[ChainId.MAINNET].address }, typedValue: '', - independentField: Field.INPUT + independentField: Field.INPUT, + recipient: null }) }) @@ -46,7 +48,58 @@ describe('hooks', () => { [Field.OUTPUT]: { address: WETH[ChainId.MAINNET].address }, [Field.INPUT]: { address: '' }, 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' }) }) }) diff --git a/src/state/swap/hooks.ts b/src/state/swap/hooks.ts index fa71033979..9d43ce76be 100644 --- a/src/state/swap/hooks.ts +++ b/src/state/swap/hooks.ts @@ -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 { ChainId, JSBI, Token, TokenAmount, Trade, WETH } from '@uniswap/sdk' import { ParsedQs } from 'qs' @@ -12,7 +13,7 @@ import useParsedQueryString from '../../hooks/useParsedQueryString' import { isAddress } from '../../utils' import { AppDispatch, AppState } from '../index' 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 useToggledVersion from '../../hooks/useToggledVersion' import { useUserSlippageTolerance } from '../user/hooks' @@ -26,6 +27,7 @@ export function useSwapActionHandlers(): { onTokenSelection: (field: Field, address: string) => void onSwitchTokens: () => void onUserInput: (field: Field, typedValue: string) => void + onChangeRecipient: (recipient: string | null) => void } { const dispatch = useDispatch() const onTokenSelection = useCallback( @@ -51,10 +53,18 @@ export function useSwapActionHandlers(): { [dispatch] ) + const onChangeRecipient = useCallback( + (recipient: string | null) => { + dispatch(setRecipient({ recipient })) + }, + [dispatch] + ) + return { onSwitchTokens, onTokenSelection, - onUserInput + onUserInput, + onChangeRecipient } } @@ -93,11 +103,14 @@ export function useDerivedSwapInfo(): { independentField, typedValue, [Field.INPUT]: { address: tokenInAddress }, - [Field.OUTPUT]: { address: tokenOutAddress } + [Field.OUTPUT]: { address: tokenOutAddress }, + recipient } = useSwapState() const tokenIn = useToken(tokenInAddress) const tokenOut = useToken(tokenOutAddress) + const recipientLookup = useENS(recipient ?? undefined) + const to: string | null = (recipient === null ? account : recipientLookup.address) ?? null const relevantTokenBalances = useTokenBalancesTreatWETHAsETH(account ?? undefined, [ tokenIn ?? undefined, @@ -138,6 +151,10 @@ export function useDerivedSwapInfo(): { error = error ?? 'Select a token' } + if (!to) { + error = error ?? 'Enter a recipient' + } + const [allowedSlippage] = useUserSlippageTolerance() 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') { const valid = isAddress(urlParam) if (valid) return valid if (urlParam.toLowerCase() === 'eth') 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 { @@ -190,9 +207,20 @@ function parseIndependentFieldURLParameter(urlParam: any): Field { return typeof urlParam === 'string' && urlParam.toLowerCase() === 'output' ? Field.OUTPUT : Field.INPUT } -export function queryParametersToSwapState(parsedQs: ParsedQs, chainId: ChainId, overrideETH: boolean): SwapState { - let inputCurrency = parseCurrencyFromURLParameter(parsedQs.inputCurrency, chainId, overrideETH) - let outputCurrency = parseCurrencyFromURLParameter(parsedQs.outputCurrency, chainId, overrideETH) +const ENS_NAME_REGEX = /^[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)?$/ +const ADDRESS_REGEX = /^0x[a-fA-F0-9]{40}$/ +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 (typeof parsedQs.outputCurrency === 'string') { inputCurrency = '' @@ -201,6 +229,8 @@ export function queryParametersToSwapState(parsedQs: ParsedQs, chainId: ChainId, } } + const recipient = validatedRecipient(parsedQs.recipient) + return { [Field.INPUT]: { address: inputCurrency @@ -209,29 +239,29 @@ export function queryParametersToSwapState(parsedQs: ParsedQs, chainId: ChainId, address: outputCurrency }, 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 -// set overrideETH to true if dont want to autopopulate ETH -export function useDefaultsFromURLSearch(overrideWETH = false) { +export function useDefaultsFromURLSearch() { const { chainId } = useActiveWeb3React() const dispatch = useDispatch() const parsedQs = useParsedQueryString() useEffect(() => { if (!chainId) return - const parsed = queryParametersToSwapState(parsedQs, chainId, overrideWETH) + const parsed = queryParametersToSwapState(parsedQs, chainId) dispatch( replaceSwapState({ typedValue: parsed.typedValue, field: parsed.independentField, inputTokenAddress: parsed[Field.INPUT].address, - outputTokenAddress: parsed[Field.OUTPUT].address + outputTokenAddress: parsed[Field.OUTPUT].address, + recipient: parsed.recipient }) ) - // eslint-disable-next-line - }, [dispatch, chainId]) + }, [dispatch, chainId, parsedQs]) } diff --git a/src/state/swap/reducer.ts b/src/state/swap/reducer.ts index cc48efbfa3..af552c86b1 100644 --- a/src/state/swap/reducer.ts +++ b/src/state/swap/reducer.ts @@ -1,5 +1,5 @@ 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 { readonly independentField: Field @@ -10,6 +10,8 @@ export interface SwapState { readonly [Field.OUTPUT]: { readonly address: string | undefined } + // the typed recipient address, or null if swap should go to sender + readonly recipient: string | null } const initialState: SwapState = { @@ -20,23 +22,28 @@ const initialState: SwapState = { }, [Field.OUTPUT]: { address: '' - } + }, + recipient: null } export default createReducer(initialState, builder => builder - .addCase(replaceSwapState, (state, { payload: { typedValue, field, inputTokenAddress, outputTokenAddress } }) => { - return { - [Field.INPUT]: { - address: inputTokenAddress - }, - [Field.OUTPUT]: { - address: outputTokenAddress - }, - independentField: field, - typedValue: typedValue + .addCase( + replaceSwapState, + (state, { payload: { typedValue, recipient, field, inputTokenAddress, outputTokenAddress } }) => { + return { + [Field.INPUT]: { + address: inputTokenAddress + }, + [Field.OUTPUT]: { + address: outputTokenAddress + }, + independentField: field, + typedValue: typedValue, + recipient + } } - }) + ) .addCase(selectToken, (state, { payload: { address, field } }) => { const otherField = field === Field.INPUT ? Field.OUTPUT : Field.INPUT if (address === state[otherField].address) { @@ -70,4 +77,7 @@ export default createReducer(initialState, builder => typedValue } }) + .addCase(setRecipient, (state, { payload: { recipient } }) => { + state.recipient = recipient + }) ) diff --git a/src/state/transactions/hooks.tsx b/src/state/transactions/hooks.tsx index a2180487cd..db392e81fc 100644 --- a/src/state/transactions/hooks.tsx +++ b/src/state/transactions/hooks.tsx @@ -39,7 +39,7 @@ export function useAllTransactions(): { [txHash: string]: TransactionDetails } { const state = useSelector(state => state.transactions) - return state[chainId ?? -1] ?? {} + return chainId ? state[chainId] ?? {} : {} } export function useIsTransactionPending(transactionHash?: string): boolean { diff --git a/src/theme/index.tsx b/src/theme/index.tsx index 058f1b8594..66a494c117 100644 --- a/src/theme/index.tsx +++ b/src/theme/index.tsx @@ -188,6 +188,10 @@ body { box-sizing: border-box; } +button { + user-select: none; +} + html { font-size: 16px; font-variant: none;