diff --git a/src/components/Header/VersionSwitch.tsx b/src/components/Header/VersionSwitch.tsx new file mode 100644 index 00000000000..3a23449a8ac --- /dev/null +++ b/src/components/Header/VersionSwitch.tsx @@ -0,0 +1,64 @@ +import { stringify } from 'qs' +import React, { useCallback, useMemo } from 'react' +import { Link, useLocation } from 'react-router-dom' +import styled from 'styled-components' +import useParsedQueryString from '../../hooks/useParsedQueryString' +import useToggledVersion, { Version } from '../../hooks/useToggledVersion' + +const VersionLabel = styled.span<{ enabled: boolean }>` + padding: ${({ enabled }) => (enabled ? '0.15rem 0.5rem 0.16rem 0.45rem' : '0.15rem 0.5rem 0.16rem 0.35rem')}; + border-radius: 14px; + background: ${({ theme, enabled }) => (enabled ? theme.primary1 : 'none')}; + color: ${({ theme, enabled }) => (enabled ? theme.white : theme.primary1)}; + font-size: 0.825rem; + font-weight: 400; + :hover { + user-select: ${({ enabled }) => (enabled ? 'none' : 'initial')}; + background: ${({ theme, enabled }) => (enabled ? theme.primary1 : 'none')}; + color: ${({ theme, enabled }) => (enabled ? theme.white : theme.primary3)}; + } +` +const VersionToggle = styled(Link)<{ enabled: boolean }>` + border-radius: 16px; + opacity: ${({ enabled }) => (enabled ? 1 : 0.5)}; + cursor: ${({ enabled }) => (enabled ? 'pointer' : 'default')}; + background: ${({ theme }) => theme.primary5}; + border: 1px solid ${({ theme }) => theme.primary4}; + color: ${({ theme }) => theme.primary1}; + display: flex; + width: fit-content; + text-decoration: none; + :hover { + text-decoration: none; + } +` + +export function VersionSwitch() { + const version = useToggledVersion() + const location = useLocation() + const query = useParsedQueryString() + const versionSwitchAvailable = location.pathname === '/swap' || location.pathname === '/send' + + const toggleDest = useMemo(() => { + return versionSwitchAvailable + ? { + ...location, + search: `?${stringify({ ...query, use: version === Version.v1 ? undefined : Version.v1 })}` + } + : location + }, [location, query, version, versionSwitchAvailable]) + + const handleClick = useCallback( + e => { + if (!versionSwitchAvailable) e.preventDefault() + }, + [versionSwitchAvailable] + ) + + return ( + + V2 + V1 + + ) +} diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx index 11314957a87..3298d2866ac 100644 --- a/src/components/Header/index.tsx +++ b/src/components/Header/index.tsx @@ -1,27 +1,27 @@ +import { ChainId, WETH } from '@uniswap/sdk' import React from 'react' +import { isMobile } from 'react-device-detect' import { Link as HistoryLink } from 'react-router-dom' +import { Text } from 'rebass' import styled from 'styled-components' -import { useTokenBalanceTreatingWETHasETH } from '../../state/wallet/hooks' - -import Row from '../Row' -import Menu from '../Menu' -import Web3Status from '../Web3Status' - -import { ExternalLink, StyledInternalLink } from '../../theme' -import { Text } from 'rebass' -import { WETH, ChainId } from '@uniswap/sdk' -import { isMobile } from 'react-device-detect' -import { YellowCard } from '../Card' -import { useActiveWeb3React } from '../../hooks' -import { useDarkModeManager } from '../../state/user/hooks' import Logo from '../../assets/svg/logo.svg' -import Wordmark from '../../assets/svg/wordmark.svg' import LogoDark from '../../assets/svg/logo_white.svg' +import Wordmark from '../../assets/svg/wordmark.svg' import WordmarkDark from '../../assets/svg/wordmark_white.svg' +import { useActiveWeb3React } from '../../hooks' +import { useDarkModeManager } from '../../state/user/hooks' +import { useTokenBalanceTreatingWETHasETH } from '../../state/wallet/hooks' + +import { ExternalLink, StyledInternalLink } from '../../theme' +import { YellowCard } from '../Card' import { AutoColumn } from '../Column' -import { RowBetween } from '../Row' +import Menu from '../Menu' + +import Row, { RowBetween } from '../Row' +import Web3Status from '../Web3Status' +import { VersionSwitch } from './VersionSwitch' const HeaderFrame = styled.div` display: flex; @@ -122,33 +122,13 @@ const MigrateBanner = styled(AutoColumn)` `}; ` -const VersionLabel = styled.span<{ isV2?: boolean }>` - padding: ${({ isV2 }) => (isV2 ? '0.15rem 0.5rem 0.16rem 0.45rem' : '0.15rem 0.5rem 0.16rem 0.35rem')}; - border-radius: 14px; - background: ${({ theme, isV2 }) => (isV2 ? theme.primary1 : 'none')}; - color: ${({ theme, isV2 }) => (isV2 ? theme.white : theme.primary1)}; - font-size: 0.825rem; - font-weight: 400; - :hover { - user-select: ${({ isV2 }) => (isV2 ? 'none' : 'initial')}; - background: ${({ theme, isV2 }) => (isV2 ? theme.primary1 : 'none')}; - color: ${({ theme, isV2 }) => (isV2 ? theme.white : theme.primary3)}; - } -` - -const VersionToggle = styled.a` - border-radius: 16px; - background: ${({ theme }) => theme.primary5}; - border: 1px solid ${({ theme }) => theme.primary4}; - color: ${({ theme }) => theme.primary1}; - display: flex; - width: fit-content; - cursor: pointer; - text-decoration: none; - :hover { - text-decoration: none; - } -` +const NETWORK_LABELS: { [chainId in ChainId]: string | null } = { + [ChainId.MAINNET]: null, + [ChainId.RINKEBY]: 'Rinkeby', + [ChainId.ROPSTEN]: 'Ropsten', + [ChainId.GÖRLI]: 'Görli', + [ChainId.KOVAN]: 'Kovan' +} export default function Header() { const { account, chainId } = useActiveWeb3React() @@ -187,21 +167,11 @@ export default function Header() { )} - - {!isMobile && ( - - V2 - V1 - - )} - + {!isMobile && } - {!isMobile && chainId === ChainId.ROPSTEN && Ropsten} - {!isMobile && chainId === ChainId.RINKEBY && Rinkeby} - {!isMobile && chainId === ChainId.GÖRLI && Görli} - {!isMobile && chainId === ChainId.KOVAN && Kovan} + {!isMobile && NETWORK_LABELS[chainId] && {NETWORK_LABELS[chainId]}} {account && userEthBalance ? ( diff --git a/src/components/TxnPopup/index.tsx b/src/components/TxnPopup/index.tsx index 0656ee33fd8..4c67e76ec49 100644 --- a/src/components/TxnPopup/index.tsx +++ b/src/components/TxnPopup/index.tsx @@ -1,7 +1,7 @@ -import React, { useCallback, useState } from 'react' +import React, { useCallback, useContext, useState } from 'react' import { AlertCircle, CheckCircle } from 'react-feather' -import styled from 'styled-components' +import styled, { ThemeContext } from 'styled-components' import { useActiveWeb3React } from '../../hooks' import useInterval from '../../hooks/useInterval' @@ -51,13 +51,16 @@ export default function TxnPopup({ isRunning ? delay : null ) + const handleMouseEnter = useCallback(() => setIsRunning(false), []) + const handleMouseLeave = useCallback(() => setIsRunning(true), []) + + const theme = useContext(ThemeContext) + return ( - setIsRunning(false)} onMouseLeave={() => setIsRunning(true)}> - {success ? ( - - ) : ( - - )} + +
+ {success ? : } +
{summary ? summary : 'Hash: ' + hash.slice(0, 8) + '...' + hash.slice(58, 65)} diff --git a/src/components/swap/BetterTradeLink.tsx b/src/components/swap/BetterTradeLink.tsx new file mode 100644 index 00000000000..dcd3c07ad39 --- /dev/null +++ b/src/components/swap/BetterTradeLink.tsx @@ -0,0 +1,40 @@ +import { stringify } from 'qs' +import React, { useContext, useMemo } from 'react' +import { useLocation } from 'react-router' +import { Text } from 'rebass' +import { ThemeContext } from 'styled-components' +import useParsedQueryString from '../../hooks/useParsedQueryString' +import { Version } from '../../hooks/useToggledVersion' + +import { StyledInternalLink } from '../../theme' +import { YellowCard } from '../Card' +import { AutoColumn } from '../Column' + +export default function BetterTradeLink({ version }: { version: Version }) { + const theme = useContext(ThemeContext) + const location = useLocation() + const search = useParsedQueryString() + + const linkDestination = useMemo(() => { + return { + ...location, + search: `?${stringify({ + ...search, + use: version + })}` + } + }, [location, search, version]) + + return ( + + + + There is a better price for this trade on{' '} + + Uniswap {version.toUpperCase()} ↗ + + + + + ) +} diff --git a/src/components/swap/V1TradeLink.tsx b/src/components/swap/V1TradeLink.tsx deleted file mode 100644 index 4a65d584e41..00000000000 --- a/src/components/swap/V1TradeLink.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React, { useContext } from 'react' -import { Text } from 'rebass' -import { ThemeContext } from 'styled-components' - -import { ExternalLink } from '../../theme' -import { YellowCard } from '../Card' -import { AutoColumn } from '../Column' - -export default function V1TradeLink({ v1TradeLinkIfBetter }: { v1TradeLinkIfBetter: string }) { - const theme = useContext(ThemeContext) - return v1TradeLinkIfBetter ? ( - - - - There is a better price for this trade on{' '} - - Uniswap V1 ↗ - - - - - ) : null -} diff --git a/src/constants/index.ts b/src/constants/index.ts index 21dd2d952fd..48b548b9232 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -159,4 +159,4 @@ export const PRICE_IMPACT_WITHOUT_FEE_CONFIRM_MIN: Percent = new Percent(JSBI.Bi // used to ensure the user doesn't send so much ETH so they end up with <.01 export const MIN_ETH: JSBI = JSBI.exponentiate(JSBI.BigInt(10), JSBI.BigInt(16)) // .01 ETH -export const V1_TRADE_LINK_THRESHOLD = new Percent(JSBI.BigInt(75), JSBI.BigInt(10000)) +export const BETTER_TRADE_LINK_THRESHOLD = new Percent(JSBI.BigInt(75), JSBI.BigInt(10000)) diff --git a/src/data/V1.ts b/src/data/V1.ts index 7f83fc2374d..e86603db43d 100644 --- a/src/data/V1.ts +++ b/src/data/V1.ts @@ -1,27 +1,25 @@ -import { ChainId, JSBI, Pair, Percent, Route, Token, TokenAmount, Trade, TradeType, WETH } from '@uniswap/sdk' +import { JSBI, Pair, Percent, Route, Token, TokenAmount, Trade, TradeType, WETH } from '@uniswap/sdk' import { useMemo } from 'react' import { useActiveWeb3React } from '../hooks' import { useAllTokens } from '../hooks/Tokens' import { useV1FactoryContract } from '../hooks/useContract' +import { Version } from '../hooks/useToggledVersion' import { NEVER_RELOAD, useSingleCallResult, useSingleContractMultipleData } from '../state/multicall/hooks' import { useETHBalances, useTokenBalance, useTokenBalances } from '../state/wallet/hooks' -function useV1PairAddress(tokenAddress?: string): string | undefined { +export function useV1ExchangeAddress(tokenAddress?: string): string | undefined { const contract = useV1FactoryContract() const inputs = useMemo(() => [tokenAddress], [tokenAddress]) return useSingleCallResult(contract, 'getExchange', inputs)?.result?.[0] } -class MockV1Pair extends Pair { - readonly isV1: true = true -} +class MockV1Pair extends Pair {} function useMockV1Pair(token?: Token): MockV1Pair | undefined { - const isWETH = token?.equals(WETH[token?.chainId]) + const isWETH: boolean = token && WETH[token.chainId] ? token.equals(WETH[token.chainId]) : false - // will only return an address on mainnet, and not for WETH - const v1PairAddress = useV1PairAddress(isWETH ? undefined : token?.address) + const v1PairAddress = useV1ExchangeAddress(isWETH ? undefined : token?.address) const tokenBalance = useTokenBalance(v1PairAddress, token) const ETHBalance = useETHBalances([v1PairAddress])[v1PairAddress ?? ''] @@ -43,7 +41,7 @@ export function useAllTokenV1Exchanges(): { [exchangeAddress: string]: Token } { data?.reduce<{ [exchangeAddress: string]: Token }>((memo, { result }, ix) => { const token = allTokens[args[ix][0]] if (result?.[0]) { - memo[result?.[0]] = token + memo[result[0]] = token } return memo }, {}) ?? {}, @@ -74,24 +72,23 @@ export function useUserHasLiquidityInAllTokens(): boolean | undefined { ) } -export function useV1TradeLinkIfBetter( +/** + * Returns the trade to execute on V1 to go between input and output token + */ +export function useV1Trade( isExactIn?: boolean, - input?: Token, - output?: Token, - exactAmount?: TokenAmount, - v2Trade?: Trade, - minimumDelta: Percent = new Percent('0') -): string | undefined { + inputToken?: Token, + outputToken?: Token, + exactAmount?: TokenAmount +): Trade | undefined { const { chainId } = useActiveWeb3React() - const isMainnet: boolean = chainId === ChainId.MAINNET - // get the mock v1 pairs - const inputPair = useMockV1Pair(input) - const outputPair = useMockV1Pair(output) + const inputPair = useMockV1Pair(inputToken) + const outputPair = useMockV1Pair(outputToken) - const inputIsWETH = isMainnet && input?.equals(WETH[ChainId.MAINNET]) - const outputIsWETH = isMainnet && output?.equals(WETH[ChainId.MAINNET]) + const inputIsWETH = (inputToken && chainId && WETH[chainId] && inputToken.equals(WETH[chainId])) ?? false + const outputIsWETH = (outputToken && chainId && WETH[chainId] && outputToken.equals(WETH[chainId])) ?? false // construct a direct or through ETH v1 route let pairs: Pair[] = [] @@ -105,7 +102,7 @@ export function useV1TradeLinkIfBetter( pairs = [inputPair, outputPair] } - const route = input && pairs && pairs.length > 0 && new Route(pairs, input) + const route = inputToken && pairs && pairs.length > 0 && new Route(pairs, inputToken) let v1Trade: Trade | undefined try { v1Trade = @@ -113,25 +110,53 @@ export function useV1TradeLinkIfBetter( ? new Trade(route, exactAmount, isExactIn ? TradeType.EXACT_INPUT : TradeType.EXACT_OUTPUT) : undefined } catch {} + return v1Trade +} + +export function getTradeVersion(trade?: Trade): Version | undefined { + const isV1 = trade?.route?.pairs?.some(pair => pair instanceof MockV1Pair) + if (isV1) return Version.v1 + if (isV1 === false) return Version.v2 + return undefined +} - let v1HasBetterTrade = false - if (v1Trade) { - if (isExactIn) { - // discount the v1 output amount by minimumDelta - const discountedV1Output = v1Trade?.outputAmount.multiply(new Percent('1').subtract(minimumDelta)) - // check if the discounted v1 amount is still greater than v2, short-circuiting if no v2 trade exists - v1HasBetterTrade = !v2Trade || discountedV1Output.greaterThan(v2Trade.outputAmount) - } else { - // inflate the v1 amount by minimumDelta - const inflatedV1Input = v1Trade?.inputAmount.multiply(new Percent('1').add(minimumDelta)) - // check if the inflated v1 amount is still less than v2, short-circuiting if no v2 trade exists - v1HasBetterTrade = !v2Trade || inflatedV1Input.lessThan(v2Trade.inputAmount) - } +// returns the v1 exchange against which a trade should be executed +export function useV1TradeExchangeAddress(trade: Trade | undefined): string | undefined { + const tokenAddress: string | undefined = useMemo(() => { + const tradeVersion = getTradeVersion(trade) + const isV1 = tradeVersion === Version.v1 + return isV1 + ? trade && + WETH[trade.inputAmount.token.chainId] && + trade.inputAmount.token.equals(WETH[trade.inputAmount.token.chainId]) + ? trade.outputAmount.token.address + : trade?.inputAmount?.token?.address + : undefined + }, [trade]) + return useV1ExchangeAddress(tokenAddress) +} + +const ZERO_PERCENT = new Percent('0') +const ONE_HUNDRED_PERCENT = new Percent('1') +// returns whether tradeB is better than tradeA by at least a threshold +export function isTradeBetter( + tradeA: Trade | undefined, + tradeB: Trade | undefined, + minimumDelta: Percent = ZERO_PERCENT +): boolean | undefined { + if (!tradeA || !tradeB) return undefined + + if ( + tradeA.tradeType !== tradeB.tradeType || + !tradeA.inputAmount.token.equals(tradeB.inputAmount.token) || + !tradeB.outputAmount.token.equals(tradeB.outputAmount.token) + ) { + throw new Error('Trades are not comparable') } - return v1HasBetterTrade && input && output - ? `https://v1.uniswap.exchange/swap?inputCurrency=${inputIsWETH ? 'ETH' : input.address}&outputCurrency=${ - outputIsWETH ? 'ETH' : output.address - }` - : undefined + if (minimumDelta.equalTo(ZERO_PERCENT)) { + return tradeA.executionPrice.lessThan(tradeB.executionPrice) + } else { + return tradeA.executionPrice.raw.multiply(minimumDelta.add(ONE_HUNDRED_PERCENT)).lessThan(tradeB.executionPrice) + } } diff --git a/src/hooks/useApproveCallback.ts b/src/hooks/useApproveCallback.ts index bdc4816581a..5ab1b141489 100644 --- a/src/hooks/useApproveCallback.ts +++ b/src/hooks/useApproveCallback.ts @@ -4,12 +4,14 @@ import { Trade, WETH, TokenAmount } from '@uniswap/sdk' import { useCallback, useMemo } from 'react' import { ROUTER_ADDRESS } from '../constants' import { useTokenAllowance } from '../data/Allowances' +import { getTradeVersion, useV1TradeExchangeAddress } from '../data/V1' import { Field } from '../state/swap/actions' import { useTransactionAdder, useHasPendingApproval } from '../state/transactions/hooks' import { computeSlippageAdjustedAmounts } from '../utils/prices' import { calculateGasMargin } from '../utils' import { useTokenContract } from './useContract' import { useActiveWeb3React } from './index' +import { Version } from './useToggledVersion' export enum ApprovalState { UNKNOWN, @@ -21,16 +23,16 @@ export enum ApprovalState { // returns a variable indicating the state of the approval and a function which approves if necessary or early returns export function useApproveCallback( amountToApprove?: TokenAmount, - addressToApprove?: string + spender?: string ): [ApprovalState, () => Promise] { const { account } = useActiveWeb3React() - const currentAllowance = useTokenAllowance(amountToApprove?.token, account ?? undefined, addressToApprove) - const pendingApproval = useHasPendingApproval(amountToApprove?.token?.address) + const currentAllowance = useTokenAllowance(amountToApprove?.token, account ?? undefined, spender) + const pendingApproval = useHasPendingApproval(amountToApprove?.token?.address, spender) // check the current approval status - const approval = useMemo(() => { - if (!amountToApprove) return ApprovalState.UNKNOWN + const approvalState: ApprovalState = useMemo(() => { + if (!amountToApprove || !spender) return ApprovalState.UNKNOWN // we treat WETH as ETH which requires no approvals if (amountToApprove.token.equals(WETH[amountToApprove.token.chainId])) return ApprovalState.APPROVED // we might not have enough data to know whether or not we need to approve @@ -38,13 +40,13 @@ export function useApproveCallback( if (pendingApproval) return ApprovalState.PENDING // amountToApprove will be defined if currentAllowance is return currentAllowance.lessThan(amountToApprove) ? ApprovalState.NOT_APPROVED : ApprovalState.APPROVED - }, [amountToApprove, currentAllowance, pendingApproval]) + }, [amountToApprove, currentAllowance, pendingApproval, spender]) const tokenContract = useTokenContract(amountToApprove?.token?.address) const addTransaction = useTransactionAdder() const approve = useCallback(async (): Promise => { - if (approval !== ApprovalState.NOT_APPROVED) { + if (approvalState !== ApprovalState.NOT_APPROVED) { console.error('approve was called unnecessarily') return } @@ -59,30 +61,35 @@ export function useApproveCallback( return } + if (!spender) { + console.error('no spender') + return + } + let useExact = false - const estimatedGas = await tokenContract.estimateGas.approve(addressToApprove, MaxUint256).catch(() => { + const estimatedGas = await tokenContract.estimateGas.approve(spender, MaxUint256).catch(() => { // general fallback for tokens who restrict approval amounts useExact = true - return tokenContract.estimateGas.approve(addressToApprove, amountToApprove.raw.toString()) + return tokenContract.estimateGas.approve(spender, amountToApprove.raw.toString()) }) return tokenContract - .approve(addressToApprove, useExact ? amountToApprove.raw.toString() : MaxUint256, { + .approve(spender, useExact ? amountToApprove.raw.toString() : MaxUint256, { gasLimit: calculateGasMargin(estimatedGas) }) .then((response: TransactionResponse) => { addTransaction(response, { - summary: 'Approve ' + amountToApprove?.token?.symbol, - approvalOfToken: amountToApprove?.token?.address + summary: 'Approve ' + amountToApprove.token.symbol, + approval: { tokenAddress: amountToApprove.token.address, spender: spender } }) }) .catch((error: Error) => { console.debug('Failed to approve token', error) throw error }) - }, [approval, tokenContract, addressToApprove, amountToApprove, addTransaction]) + }, [approvalState, tokenContract, spender, amountToApprove, addTransaction]) - return [approval, approve] + return [approvalState, approve] } // wraps useApproveCallback in the context of a swap @@ -91,5 +98,7 @@ export function useApproveCallbackFromTrade(trade?: Trade, allowedSlippage = 0) () => (trade ? computeSlippageAdjustedAmounts(trade, allowedSlippage)[Field.INPUT] : undefined), [trade, allowedSlippage] ) - return useApproveCallback(amountToApprove, ROUTER_ADDRESS) + const tradeIsV1 = getTradeVersion(trade) === Version.v1 + const v1ExchangeAddress = useV1TradeExchangeAddress(trade) + return useApproveCallback(amountToApprove, tradeIsV1 ? v1ExchangeAddress : ROUTER_ADDRESS) } diff --git a/src/hooks/useContract.ts b/src/hooks/useContract.ts index 6069874ed05..654c261e70f 100644 --- a/src/hooks/useContract.ts +++ b/src/hooks/useContract.ts @@ -30,23 +30,23 @@ export function useV1FactoryContract(): Contract | null { return useContract(V1_FACTORY_ADDRESSES[chainId as ChainId], V1_FACTORY_ABI, false) } -export function useV1ExchangeContract(address: string): Contract | null { - return useContract(address, V1_EXCHANGE_ABI, false) -} - export function useV2MigratorContract(): Contract | null { return useContract(MIGRATOR_ADDRESS, MIGRATOR_ABI, true) } -export function useTokenContract(tokenAddress?: string, withSignerIfPossible = true): Contract | null { +export function useV1ExchangeContract(address?: string, withSignerIfPossible?: boolean): Contract | null { + return useContract(address, V1_EXCHANGE_ABI, withSignerIfPossible) +} + +export function useTokenContract(tokenAddress?: string, withSignerIfPossible?: boolean): Contract | null { return useContract(tokenAddress, ERC20_ABI, withSignerIfPossible) } -export function useBytes32TokenContract(tokenAddress?: string, withSignerIfPossible = true): Contract | null { +export function useBytes32TokenContract(tokenAddress?: string, withSignerIfPossible?: boolean): Contract | null { return useContract(tokenAddress, ERC20_BYTES32_ABI, withSignerIfPossible) } -export function usePairContract(pairAddress?: string, withSignerIfPossible = true): Contract | null { +export function usePairContract(pairAddress?: string, withSignerIfPossible?: boolean): Contract | null { return useContract(pairAddress, IUniswapV2PairABI, withSignerIfPossible) } diff --git a/src/hooks/useParsedQueryString.ts b/src/hooks/useParsedQueryString.ts new file mode 100644 index 00000000000..f0a3002f6d6 --- /dev/null +++ b/src/hooks/useParsedQueryString.ts @@ -0,0 +1,11 @@ +import { parse, ParsedQs } from 'qs' +import { useMemo } from 'react' +import { useLocation } from 'react-router-dom' + +export default function useParsedQueryString(): ParsedQs { + const { search } = useLocation() + return useMemo( + () => (search && search.length > 1 ? parse(search, { parseArrays: false, ignoreQueryPrefix: true }) : {}), + [search] + ) +} diff --git a/src/hooks/useSwapCallback.ts b/src/hooks/useSwapCallback.ts index 130f843381a..b68efccf04d 100644 --- a/src/hooks/useSwapCallback.ts +++ b/src/hooks/useSwapCallback.ts @@ -1,15 +1,19 @@ import { BigNumber } from '@ethersproject/bignumber' +import { MaxUint256 } from '@ethersproject/constants' import { Contract } from '@ethersproject/contracts' -import { ChainId, Token, Trade, TradeType, WETH } from '@uniswap/sdk' +import { ChainId, 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 { computeSlippageAdjustedAmounts } from '../utils/prices' import { calculateGasMargin, getRouterContract, isAddress } from '../utils' +import { computeSlippageAdjustedAmounts } from '../utils/prices' import { useActiveWeb3React } from './index' +import { useV1ExchangeContract } from './useContract' import useENSName from './useENSName' +import { Version } from './useToggledVersion' enum SwapType { EXACT_TOKENS_FOR_TOKENS, @@ -17,25 +21,37 @@ enum SwapType { EXACT_ETH_FOR_TOKENS, TOKENS_FOR_EXACT_TOKENS, TOKENS_FOR_EXACT_ETH, - ETH_FOR_EXACT_TOKENS + ETH_FOR_EXACT_TOKENS, + V1_EXACT_ETH_FOR_TOKENS, + V1_EXACT_TOKENS_FOR_ETH, + V1_EXACT_TOKENS_FOR_TOKENS, + V1_ETH_FOR_EXACT_TOKENS, + V1_TOKENS_FOR_EXACT_ETH, + V1_TOKENS_FOR_EXACT_TOKENS } -function getSwapType(tokens: { [field in Field]?: Token }, isExactIn: boolean, chainId: number): SwapType { +function getSwapType(trade: Trade | undefined): SwapType | undefined { + if (!trade) return undefined + const chainId = trade.inputAmount.token.chainId + const inputWETH = trade.inputAmount.token.equals(WETH[chainId]) + const outputWETH = trade.outputAmount.token.equals(WETH[chainId]) + const isExactIn = trade.tradeType === TradeType.EXACT_INPUT + const isV1 = getTradeVersion(trade) === Version.v1 if (isExactIn) { - if (tokens[Field.INPUT]?.equals(WETH[chainId as ChainId])) { - return SwapType.EXACT_ETH_FOR_TOKENS - } else if (tokens[Field.OUTPUT]?.equals(WETH[chainId as ChainId])) { - return SwapType.EXACT_TOKENS_FOR_ETH + if (inputWETH) { + return isV1 ? SwapType.V1_EXACT_ETH_FOR_TOKENS : SwapType.EXACT_ETH_FOR_TOKENS + } else if (outputWETH) { + return isV1 ? SwapType.V1_EXACT_TOKENS_FOR_ETH : SwapType.EXACT_TOKENS_FOR_ETH } else { - return SwapType.EXACT_TOKENS_FOR_TOKENS + return isV1 ? SwapType.V1_EXACT_TOKENS_FOR_TOKENS : SwapType.EXACT_TOKENS_FOR_TOKENS } } else { - if (tokens[Field.INPUT]?.equals(WETH[chainId as ChainId])) { - return SwapType.ETH_FOR_EXACT_TOKENS - } else if (tokens[Field.OUTPUT]?.equals(WETH[chainId as ChainId])) { - return SwapType.TOKENS_FOR_EXACT_ETH + if (inputWETH) { + return isV1 ? SwapType.V1_ETH_FOR_EXACT_TOKENS : SwapType.ETH_FOR_EXACT_TOKENS + } else if (outputWETH) { + return isV1 ? SwapType.V1_TOKENS_FOR_EXACT_ETH : SwapType.TOKENS_FOR_EXACT_ETH } else { - return SwapType.TOKENS_FOR_EXACT_TOKENS + return isV1 ? SwapType.V1_TOKENS_FOR_EXACT_TOKENS : SwapType.TOKENS_FOR_EXACT_TOKENS } } } @@ -49,13 +65,19 @@ export function useSwapCallback( to?: string // recipient of output, optional ): null | (() => Promise) { const { account, chainId, library } = useActiveWeb3React() - const inputAllowance = useTokenAllowance(trade?.inputAmount?.token, account ?? undefined, ROUTER_ADDRESS) const addTransaction = useTransactionAdder() const recipient = to ? isAddress(to) : account const ensName = useENSName(to) + const tradeVersion = getTradeVersion(trade) + const v1Exchange = useV1ExchangeContract(useV1TradeExchangeAddress(trade), true) + const inputAllowance = useTokenAllowance( + trade?.inputAmount?.token, + account ?? undefined, + tradeVersion === Version.v1 ? v1Exchange?.address : ROUTER_ADDRESS + ) return useMemo(() => { - if (!trade || !recipient) return null + if (!trade || !recipient || !tradeVersion) return null // will always be defined const { @@ -78,17 +100,17 @@ export function useSwapCallback( throw new Error('missing dependencies in onSwap callback') } - const routerContract: Contract = getRouterContract(chainId, library, account) + const contract: Contract | null = + tradeVersion === Version.v2 ? getRouterContract(chainId, library, account) : v1Exchange + if (!contract) { + throw new Error('Failed to get a swap contract') + } const path = trade.route.path.map(t => t.address) const deadlineFromNow: number = Math.ceil(Date.now() / 1000) + deadline - const swapType = getSwapType( - { [Field.INPUT]: trade.inputAmount.token, [Field.OUTPUT]: trade.outputAmount.token }, - trade.tradeType === TradeType.EXACT_INPUT, - chainId as ChainId - ) + const swapType = getSwapType(trade) // let estimate: Function, method: Function, let methodNames: string[], @@ -145,11 +167,63 @@ export function useSwapCallback( args = [slippageAdjustedOutput.raw.toString(), path, recipient, deadlineFromNow] value = BigNumber.from(slippageAdjustedInput.raw.toString()) break + case SwapType.V1_EXACT_ETH_FOR_TOKENS: + methodNames = ['ethToTokenTransferInput'] + args = [slippageAdjustedOutput.raw.toString(), deadlineFromNow, recipient] + value = BigNumber.from(slippageAdjustedInput.raw.toString()) + break + case SwapType.V1_EXACT_TOKENS_FOR_TOKENS: + methodNames = ['tokenToTokenTransferInput'] + args = [ + slippageAdjustedInput.raw.toString(), + slippageAdjustedOutput.raw.toString(), + 1, + deadlineFromNow, + recipient, + trade.outputAmount.token.address + ] + break + case SwapType.V1_EXACT_TOKENS_FOR_ETH: + methodNames = ['tokenToEthTransferOutput'] + args = [ + slippageAdjustedOutput.raw.toString(), + slippageAdjustedInput.raw.toString(), + deadlineFromNow, + recipient + ] + break + case SwapType.V1_ETH_FOR_EXACT_TOKENS: + methodNames = ['ethToTokenTransferOutput'] + args = [slippageAdjustedOutput.raw.toString(), deadlineFromNow, recipient] + value = BigNumber.from(slippageAdjustedInput.raw.toString()) + break + case SwapType.V1_TOKENS_FOR_EXACT_ETH: + methodNames = ['tokenToEthTransferOutput'] + args = [ + slippageAdjustedOutput.raw.toString(), + slippageAdjustedInput.raw.toString(), + deadlineFromNow, + recipient + ] + break + case SwapType.V1_TOKENS_FOR_EXACT_TOKENS: + methodNames = ['tokenToTokenTransferOutput'] + args = [ + slippageAdjustedOutput.raw.toString(), + slippageAdjustedInput.raw.toString(), + MaxUint256.toString(), + deadlineFromNow, + recipient, + trade.outputAmount.token.address + ] + break + default: + throw new Error(`Unhandled swap type: ${swapType}`) } const safeGasEstimates: (BigNumber | undefined)[] = await Promise.all( methodNames.map(methodName => - routerContract.estimateGas[methodName](...args, value ? { value } : {}) + contract.estimateGas[methodName](...args, value ? { value } : {}) .then(calculateGasMargin) .catch(error => { console.error(`estimateGas failed for ${methodName}`, error) @@ -198,38 +272,25 @@ export function useSwapCallback( const methodName = methodNames[indexOfSuccessfulEstimation] const safeGasEstimate = safeGasEstimates[indexOfSuccessfulEstimation] - return routerContract[methodName](...args, { + return contract[methodName](...args, { gasLimit: safeGasEstimate, ...(value ? { value } : {}) }) .then((response: any) => { - if (recipient === account) { - addTransaction(response, { - summary: - 'Swap ' + - slippageAdjustedInput.toSignificant(3) + - ' ' + - trade.inputAmount.token.symbol + - ' for ' + - slippageAdjustedOutput.toSignificant(3) + - ' ' + - trade.outputAmount.token.symbol - }) - } else { - addTransaction(response, { - summary: - 'Swap ' + - slippageAdjustedInput.toSignificant(3) + - ' ' + - trade.inputAmount.token.symbol + - ' for ' + - slippageAdjustedOutput.toSignificant(3) + - ' ' + - trade.outputAmount.token.symbol + - ' to ' + - (ensName ?? recipient) - }) - } + const inputSymbol = trade.inputAmount.token.symbol + const outputSymbol = trade.outputAmount.token.symbol + const inputAmount = slippageAdjustedInput.toSignificant(3) + const outputAmount = slippageAdjustedOutput.toSignificant(3) + + const base = `Swap ${inputAmount} ${inputSymbol} for ${outputAmount} ${outputSymbol}` + const withRecipient = recipient === account ? base : `${base} to ${ensName ?? recipient}` + + const withVersion = + tradeVersion === Version.v2 ? withRecipient : `${withRecipient} on ${tradeVersion.toUpperCase()}` + + addTransaction(response, { + summary: withVersion + }) return response.hash }) @@ -240,11 +301,24 @@ export function useSwapCallback( } // otherwise, the error was unexpected and we need to convey that else { - console.error(`swap failed for ${methodName}`, error) + console.error(`Swap failed`, error, methodName, args, value) throw Error('An error occurred while swapping. Please contact support.') } }) } } - }, [account, allowedSlippage, addTransaction, chainId, deadline, inputAllowance, library, trade, ensName, recipient]) + }, [ + trade, + recipient, + tradeVersion, + allowedSlippage, + chainId, + inputAllowance, + library, + account, + v1Exchange, + deadline, + addTransaction, + ensName + ]) } diff --git a/src/hooks/useToggledVersion.ts b/src/hooks/useToggledVersion.ts new file mode 100644 index 00000000000..4876918d590 --- /dev/null +++ b/src/hooks/useToggledVersion.ts @@ -0,0 +1,13 @@ +import useParsedQueryString from './useParsedQueryString' + +export enum Version { + v1 = 'v1', + v2 = 'v2' +} + +export default function useToggledVersion(): Version { + const { use } = useParsedQueryString() + if (!use || typeof use !== 'string') return Version.v2 + if (use.toLowerCase() === 'v1') return Version.v1 + return Version.v2 +} diff --git a/src/pages/MigrateV1/MigrateV1Exchange.tsx b/src/pages/MigrateV1/MigrateV1Exchange.tsx index f85f19a63dd..ffc0fc79921 100644 --- a/src/pages/MigrateV1/MigrateV1Exchange.tsx +++ b/src/pages/MigrateV1/MigrateV1Exchange.tsx @@ -9,6 +9,7 @@ import { PinkCard, YellowCard, LightCard } from '../../components/Card' import { AutoColumn } from '../../components/Column' import QuestionHelper from '../../components/QuestionHelper' import { AutoRow, RowBetween } from '../../components/Row' +import { Dots } from '../../components/swap/styleds' import { DEFAULT_DEADLINE_FROM_NOW, INITIAL_ALLOWED_SLIPPAGE } from '../../constants' import { MIGRATOR_ADDRESS } from '../../constants/abis/migrator' import { usePair } from '../../data/Reserves' @@ -195,11 +196,13 @@ function V1PairMigration({ liquidityTokenAmount, token }: { liquidityTokenAmount disabled={approval !== ApprovalState.NOT_APPROVED} onClick={approve} > - {approval === ApprovalState.PENDING - ? 'Approving...' - : approval === ApprovalState.APPROVED - ? 'Approved' - : 'Approve'} + {approval === ApprovalState.PENDING ? ( + Approving + ) : approval === ApprovalState.APPROVED ? ( + 'Approved' + ) : ( + 'Approve' + )} diff --git a/src/pages/Send/index.tsx b/src/pages/Send/index.tsx index 484d1514645..e73352e449e 100644 --- a/src/pages/Send/index.tsx +++ b/src/pages/Send/index.tsx @@ -2,7 +2,6 @@ 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 { RouteComponentProps } from 'react-router-dom' import { Text } from 'rebass' import { ThemeContext } from 'styled-components' import AddressInputPanel from '../../components/AddressInputPanel' @@ -20,14 +19,21 @@ 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 V1TradeLink from '../../components/swap/V1TradeLink' +import BetterTradeLink from '../../components/swap/BetterTradeLink' import TokenLogo from '../../components/TokenLogo' import { TokenWarningCards } from '../../components/TokenWarningCard' -import { DEFAULT_DEADLINE_FROM_NOW, INITIAL_ALLOWED_SLIPPAGE, MIN_ETH } from '../../constants' +import { + DEFAULT_DEADLINE_FROM_NOW, + 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 useToggledVersion, { Version } from '../../hooks/useToggledVersion' import { useWalletModalToggle } from '../../state/application/hooks' import { Field } from '../../state/swap/actions' import { @@ -42,8 +48,8 @@ import { computeSlippageAdjustedAmounts, computeTradePriceBreakdown, warningSeve import AppBody from '../AppBody' import { PriceSlippageWarningCard } from '../../components/swap/PriceSlippageWarningCard' -export default function Send({ location: { search } }: RouteComponentProps) { - useDefaultsFromURLSearch(search) +export default function Send() { + useDefaultsFromURLSearch() // text translation // const { t } = useTranslation() @@ -62,15 +68,33 @@ export default function Send({ location: { search } }: RouteComponentProps) { // trade details, check query params for initial state const { independentField, typedValue } = useSwapState() const { - parsedAmounts, - bestTrade, + parsedAmount, + bestTrade: bestTradeV2, tokenBalances, tokens, error: swapError, - v1TradeLinkIfBetter + v1Trade } = useDerivedSwapInfo() - const isSwapValid = !swapError && !recipientError && bestTrade + 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 @@ -152,7 +176,11 @@ export default function Send({ location: { search } }: RouteComponentProps) { ReactGA.event({ category: 'Send', action: recipient === account ? 'Swap w/o Send' : 'Swap w/ Send', - label: [bestTrade.inputAmount.token.symbol, bestTrade.outputAmount.token.symbol].join(';') + label: [ + bestTrade.inputAmount.token.symbol, + bestTrade.outputAmount.token.symbol, + getTradeVersion(bestTrade) + ].join('/') }) }) .catch(error => { @@ -420,7 +448,6 @@ export default function Send({ location: { search } }: RouteComponentProps) { field={Field.OUTPUT} value={formattedAmounts[Field.OUTPUT]} onUserInput={onUserInput} - // eslint-disable-next-line @typescript-eslint/no-empty-function label={independentField === Field.INPUT && parsedAmounts[Field.OUTPUT] ? 'To (estimated)' : 'To'} showMaxButton={false} token={tokens[Field.OUTPUT]} @@ -538,7 +565,7 @@ export default function Send({ location: { search } }: RouteComponentProps) { )} - + {betterTradeLinkVersion && } diff --git a/src/pages/Swap/index.tsx b/src/pages/Swap/index.tsx index 5406eb2688e..9f329f67b8b 100644 --- a/src/pages/Swap/index.tsx +++ b/src/pages/Swap/index.tsx @@ -2,7 +2,6 @@ import { JSBI, TokenAmount, WETH } from '@uniswap/sdk' import React, { useContext, useState, useEffect } from 'react' import { ArrowDown } from 'react-feather' import ReactGA from 'react-ga' -import { RouteComponentProps } from 'react-router-dom' import { Text } from 'rebass' import { ThemeContext } from 'styled-components' import { ButtonError, ButtonLight, ButtonPrimary } from '../../components/Button' @@ -19,12 +18,19 @@ import { ArrowWrapper, BottomGrouping, Dots, Wrapper } from '../../components/sw import SwapModalFooter from '../../components/swap/SwapModalFooter' import SwapModalHeader from '../../components/swap/SwapModalHeader' import TradePrice from '../../components/swap/TradePrice' -import V1TradeLink from '../../components/swap/V1TradeLink' +import BetterTradeLink from '../../components/swap/BetterTradeLink' import { TokenWarningCards } from '../../components/TokenWarningCard' -import { DEFAULT_DEADLINE_FROM_NOW, INITIAL_ALLOWED_SLIPPAGE, MIN_ETH } from '../../constants' +import { + DEFAULT_DEADLINE_FROM_NOW, + 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 { useSwapCallback } from '../../hooks/useSwapCallback' +import useToggledVersion, { Version } from '../../hooks/useToggledVersion' import { useWalletModalToggle } from '../../state/application/hooks' import { Field } from '../../state/swap/actions' import { @@ -38,8 +44,8 @@ import { computeSlippageAdjustedAmounts, computeTradePriceBreakdown, warningSeve import AppBody from '../AppBody' import { PriceSlippageWarningCard } from '../../components/swap/PriceSlippageWarningCard' -export default function Swap({ location: { search } }: RouteComponentProps) { - useDefaultsFromURLSearch(search) +export default function Swap() { + useDefaultsFromURLSearch() const { chainId, account } = useActiveWeb3React() const theme = useContext(ThemeContext) @@ -49,7 +55,25 @@ export default function Swap({ location: { search } }: RouteComponentProps) { // swap state const { independentField, typedValue } = useSwapState() - const { bestTrade, tokenBalances, parsedAmounts, tokens, error, v1TradeLinkIfBetter } = useDerivedSwapInfo() + const { bestTrade: bestTradeV2, tokenBalances, parsedAmount, tokens, error, 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 { onSwitchTokens, onTokenSelection, onUserInput } = useSwapActionHandlers() const isValid = !error const dependentField: Field = independentField === Field.INPUT ? Field.OUTPUT : Field.INPUT @@ -132,7 +156,11 @@ export default function Swap({ location: { search } }: RouteComponentProps) { ReactGA.event({ category: 'Swap', action: 'Swap w/o Send', - label: [bestTrade.inputAmount.token.symbol, bestTrade.outputAmount.token.symbol].join('/') + label: [ + bestTrade.inputAmount.token.symbol, + bestTrade.outputAmount.token.symbol, + getTradeVersion(bestTrade) + ].join('/') }) }) .catch(error => { @@ -245,7 +273,6 @@ export default function Swap({ location: { search } }: RouteComponentProps) { field={Field.OUTPUT} value={formattedAmounts[Field.OUTPUT]} onUserInput={onUserInput} - // eslint-disable-next-line @typescript-eslint/no-empty-function label={independentField === Field.INPUT ? 'To (estimated)' : 'To'} showMaxButton={false} token={tokens[Field.OUTPUT]} @@ -334,7 +361,7 @@ export default function Swap({ location: { search } }: RouteComponentProps) { )} - + {betterTradeLinkVersion && } diff --git a/src/state/swap/actions.ts b/src/state/swap/actions.ts index f618612c606..91cb3ebddc0 100644 --- a/src/state/swap/actions.ts +++ b/src/state/swap/actions.ts @@ -5,7 +5,12 @@ export enum Field { OUTPUT = 'OUTPUT' } -export const setDefaultsFromURLSearch = createAction<{ chainId: number; queryString?: string }>('setDefaultsFromURL') export const selectToken = createAction<{ field: Field; address: string }>('selectToken') export const switchTokens = createAction('switchTokens') export const typeInput = createAction<{ field: Field; typedValue: string }>('typeInput') +export const replaceSwapState = createAction<{ + field: Field + typedValue: string + inputTokenAddress?: string + outputTokenAddress?: string +}>('replaceSwapState') diff --git a/src/state/swap/hooks.test.ts b/src/state/swap/hooks.test.ts new file mode 100644 index 00000000000..f62e97581fd --- /dev/null +++ b/src/state/swap/hooks.test.ts @@ -0,0 +1,53 @@ +import { ChainId, WETH } from '@uniswap/sdk' +import { parse } from 'qs' +import { Field } from './actions' +import { queryParametersToSwapState } from './hooks' + +describe('hooks', () => { + describe('#queryParametersToSwapState', () => { + test('ETH to DAI', () => { + expect( + queryParametersToSwapState( + parse( + '?inputCurrency=ETH&outputCurrency=0x6b175474e89094c44da98b954eedeac495271d0f&exactAmount=20.5&exactField=outPUT', + { parseArrays: false, ignoreQueryPrefix: true } + ), + ChainId.MAINNET + ) + ).toEqual({ + [Field.OUTPUT]: { address: '0x6B175474E89094C44Da98b954EedeAC495271d0F' }, + [Field.INPUT]: { address: WETH[ChainId.MAINNET].address }, + typedValue: '20.5', + independentField: Field.OUTPUT + }) + }) + + test('does not duplicate eth for invalid output token', () => { + expect( + queryParametersToSwapState( + parse('?outputCurrency=invalid', { parseArrays: false, ignoreQueryPrefix: true }), + ChainId.MAINNET + ) + ).toEqual({ + [Field.INPUT]: { address: '' }, + [Field.OUTPUT]: { address: WETH[ChainId.MAINNET].address }, + typedValue: '', + independentField: Field.INPUT + }) + }) + + test('output ETH only', () => { + expect( + queryParametersToSwapState( + parse('?outputCurrency=eth&exactAmount=20.5', { parseArrays: false, ignoreQueryPrefix: true }), + ChainId.MAINNET + ) + ).toEqual({ + [Field.OUTPUT]: { address: WETH[ChainId.MAINNET].address }, + [Field.INPUT]: { address: '' }, + typedValue: '20.5', + independentField: Field.INPUT + }) + }) + }) +}) diff --git a/src/state/swap/hooks.ts b/src/state/swap/hooks.ts index 3d23425eacb..c1f3b92affc 100644 --- a/src/state/swap/hooks.ts +++ b/src/state/swap/hooks.ts @@ -1,15 +1,18 @@ import { parseUnits } from '@ethersproject/units' -import { JSBI, Token, TokenAmount, Trade } from '@uniswap/sdk' +import { ChainId, JSBI, Token, TokenAmount, Trade, WETH } from '@uniswap/sdk' +import { ParsedQs } from 'qs' import { useCallback, useEffect } from 'react' import { useDispatch, useSelector } from 'react-redux' +import { useV1Trade } from '../../data/V1' import { useActiveWeb3React } from '../../hooks' import { useTokenByAddressAndAutomaticallyAdd } from '../../hooks/Tokens' import { useTradeExactIn, useTradeExactOut } from '../../hooks/Trades' +import useParsedQueryString from '../../hooks/useParsedQueryString' +import { isAddress } from '../../utils' import { AppDispatch, AppState } from '../index' import { useTokenBalancesTreatWETHAsETH } from '../wallet/hooks' -import { Field, selectToken, setDefaultsFromURLSearch, switchTokens, typeInput } from './actions' -import { useV1TradeLinkIfBetter } from '../../data/V1' -import { V1_TRADE_LINK_THRESHOLD } from '../../constants' +import { Field, replaceSwapState, selectToken, switchTokens, typeInput } from './actions' +import { SwapState } from './reducer' export function useSwapState(): AppState['swap'] { return useSelector(state => state.swap) @@ -33,7 +36,7 @@ export function useSwapActionHandlers(): { [dispatch] ) - const onSwapTokens = useCallback(() => { + const onSwitchTokens = useCallback(() => { dispatch(switchTokens()) }, [dispatch]) @@ -45,7 +48,7 @@ export function useSwapActionHandlers(): { ) return { - onSwitchTokens: onSwapTokens, + onSwitchTokens, onTokenSelection, onUserInput } @@ -73,10 +76,10 @@ export function tryParseAmount(value?: string, token?: Token): TokenAmount | und export function useDerivedSwapInfo(): { tokens: { [field in Field]?: Token } tokenBalances: { [field in Field]?: TokenAmount } - parsedAmounts: { [field in Field]?: TokenAmount } + parsedAmount: TokenAmount | undefined bestTrade: Trade | null error?: string - v1TradeLinkIfBetter?: string + v1Trade: Trade | undefined } { const { account } = useActiveWeb3React() @@ -90,76 +93,121 @@ export function useDerivedSwapInfo(): { const tokenIn = useTokenByAddressAndAutomaticallyAdd(tokenInAddress) const tokenOut = useTokenByAddressAndAutomaticallyAdd(tokenOutAddress) - const relevantTokenBalances = useTokenBalancesTreatWETHAsETH(account ?? undefined, [tokenIn, tokenOut]) + const relevantTokenBalances = useTokenBalancesTreatWETHAsETH(account ?? undefined, [ + tokenIn ?? undefined, + tokenOut ?? undefined + ]) const isExactIn: boolean = independentField === Field.INPUT - const amount = tryParseAmount(typedValue, isExactIn ? tokenIn : tokenOut) + const parsedAmount = tryParseAmount(typedValue, (isExactIn ? tokenIn : tokenOut) ?? undefined) - const bestTradeExactIn = useTradeExactIn(isExactIn ? amount : undefined, tokenOut) - const bestTradeExactOut = useTradeExactOut(tokenIn, !isExactIn ? amount : undefined) + const bestTradeExactIn = useTradeExactIn(isExactIn ? parsedAmount : undefined, tokenOut ?? undefined) + const bestTradeExactOut = useTradeExactOut(tokenIn ?? undefined, !isExactIn ? parsedAmount : undefined) const bestTrade = isExactIn ? bestTradeExactIn : bestTradeExactOut - const parsedAmounts = { - [Field.INPUT]: isExactIn ? amount : bestTrade?.inputAmount, - [Field.OUTPUT]: isExactIn ? bestTrade?.outputAmount : amount - } - const tokenBalances = { [Field.INPUT]: relevantTokenBalances?.[tokenIn?.address ?? ''], [Field.OUTPUT]: relevantTokenBalances?.[tokenOut?.address ?? ''] } const tokens: { [field in Field]?: Token } = { - [Field.INPUT]: tokenIn, - [Field.OUTPUT]: tokenOut + [Field.INPUT]: tokenIn ?? undefined, + [Field.OUTPUT]: tokenOut ?? undefined } // get link to trade on v1, if a better rate exists - const v1TradeLinkIfBetter = useV1TradeLinkIfBetter( - isExactIn, - tokens[Field.INPUT], - tokens[Field.OUTPUT], - isExactIn ? parsedAmounts[Field.INPUT] : parsedAmounts[Field.OUTPUT], - bestTrade ?? undefined, - V1_TRADE_LINK_THRESHOLD - ) + const v1Trade = useV1Trade(isExactIn, tokens[Field.INPUT], tokens[Field.OUTPUT], parsedAmount) let error: string | undefined if (!account) { error = 'Connect Wallet' } - if (!parsedAmounts[Field.INPUT]) { + if (!parsedAmount) { error = error ?? 'Enter an amount' } - if (!parsedAmounts[Field.OUTPUT]) { - error = error ?? 'Enter an amount' + if (!tokens[Field.INPUT] || !tokens[Field.OUTPUT]) { + error = error ?? 'Select a token' } - const [balanceIn, amountIn] = [tokenBalances[Field.INPUT], parsedAmounts[Field.INPUT]] - if (balanceIn && amountIn && balanceIn.lessThan(amountIn)) { - error = 'Insufficient ' + amountIn.token.symbol + ' balance' - } + // this check is incorrect, it should check against the maximum amount in + // rather than the estimated amount in + // const [balanceIn, amountIn] = [tokenBalances[Field.INPUT], parsedAmounts[Field.INPUT]] + // if (balanceIn && amountIn && balanceIn.lessThan(amountIn)) { + // error = 'Insufficient ' + amountIn.token.symbol + ' balance' + // } return { tokens, tokenBalances, - parsedAmounts, + parsedAmount, bestTrade, error, - v1TradeLinkIfBetter + v1Trade + } +} + +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 WETH[chainId as ChainId]?.address } -// updates the swap state to use the defaults for a given network whenever the query -// string updates -export function useDefaultsFromURLSearch(search?: string) { +function parseTokenAmountURLParameter(urlParam: any): string { + return typeof urlParam === 'string' && !isNaN(parseFloat(urlParam)) ? urlParam : '' +} + +function parseIndependentFieldURLParameter(urlParam: any): Field { + return typeof urlParam === 'string' && urlParam.toLowerCase() === 'output' ? Field.OUTPUT : Field.INPUT +} + +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 = '' + } else { + outputCurrency = '' + } + } + + return { + [Field.INPUT]: { + address: inputCurrency + }, + [Field.OUTPUT]: { + address: outputCurrency + }, + typedValue: parseTokenAmountURLParameter(parsedQs.exactAmount), + independentField: parseIndependentFieldURLParameter(parsedQs.exactField) + } +} + +// updates the swap state to use the defaults for a given network +export function useDefaultsFromURLSearch() { const { chainId } = useActiveWeb3React() const dispatch = useDispatch() + const parsedQs = useParsedQueryString() + useEffect(() => { if (!chainId) return - dispatch(setDefaultsFromURLSearch({ chainId, queryString: search })) - }, [dispatch, search, chainId]) + const parsed = queryParametersToSwapState(parsedQs, chainId) + dispatch( + replaceSwapState({ + typedValue: parsed.typedValue, + field: parsed.independentField, + inputTokenAddress: parsed[Field.INPUT].address, + outputTokenAddress: parsed[Field.OUTPUT].address + }) + ) + // eslint-disable-next-line + }, [dispatch, chainId]) } diff --git a/src/state/swap/reducer.test.ts b/src/state/swap/reducer.test.ts index 0308caab0eb..d1f555dd430 100644 --- a/src/state/swap/reducer.test.ts +++ b/src/state/swap/reducer.test.ts @@ -1,6 +1,5 @@ -import { ChainId, WETH } from '@uniswap/sdk' import { createStore, Store } from 'redux' -import { Field, setDefaultsFromURLSearch } from './actions' +import { Field, selectToken } from './actions' import reducer, { SwapState } from './reducer' describe('swap reducer', () => { @@ -15,54 +14,21 @@ describe('swap reducer', () => { }) }) - describe('setDefaultsFromURL', () => { - test('ETH to DAI', () => { + describe('selectToken', () => { + it('changes token', () => { store.dispatch( - setDefaultsFromURLSearch({ - chainId: ChainId.MAINNET, - queryString: - '?inputCurrency=ETH&outputCurrency=0x6b175474e89094c44da98b954eedeac495271d0f&exactAmount=20.5&exactField=outPUT' - }) - ) - - expect(store.getState()).toEqual({ - [Field.OUTPUT]: { address: '0x6B175474E89094C44Da98b954EedeAC495271d0F' }, - [Field.INPUT]: { address: WETH[ChainId.MAINNET].address }, - typedValue: '20.5', - independentField: Field.OUTPUT - }) - }) - - test('does not duplicate eth for invalid output token', () => { - store.dispatch( - setDefaultsFromURLSearch({ - chainId: ChainId.MAINNET, - queryString: '?outputCurrency=invalid' + selectToken({ + field: Field.OUTPUT, + address: '0x0000' }) ) expect(store.getState()).toEqual({ + [Field.OUTPUT]: { address: '0x0000' }, [Field.INPUT]: { address: '' }, - [Field.OUTPUT]: { address: WETH[ChainId.MAINNET].address }, typedValue: '', independentField: Field.INPUT }) }) - - test('output ETH only', () => { - store.dispatch( - setDefaultsFromURLSearch({ - chainId: ChainId.MAINNET, - queryString: '?outputCurrency=eth&exactAmount=20.5' - }) - ) - - expect(store.getState()).toEqual({ - [Field.OUTPUT]: { address: WETH[ChainId.MAINNET].address }, - [Field.INPUT]: { address: '' }, - typedValue: '20.5', - independentField: Field.INPUT - }) - }) }) }) diff --git a/src/state/swap/reducer.ts b/src/state/swap/reducer.ts index 90dab767b5b..cc48efbfa32 100644 --- a/src/state/swap/reducer.ts +++ b/src/state/swap/reducer.ts @@ -1,8 +1,5 @@ -import { parse } from 'qs' import { createReducer } from '@reduxjs/toolkit' -import { ChainId, WETH } from '@uniswap/sdk' -import { isAddress } from '../../utils' -import { Field, selectToken, setDefaultsFromURLSearch, switchTokens, typeInput } from './actions' +import { Field, replaceSwapState, selectToken, switchTokens, typeInput } from './actions' export interface SwapState { readonly independentField: Field @@ -26,58 +23,18 @@ const initialState: SwapState = { } } -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 WETH[chainId as ChainId]?.address -} - -function parseTokenAmountURLParameter(urlParam: any): string { - return typeof urlParam === 'string' && !isNaN(parseFloat(urlParam)) ? urlParam : '' -} - -function parseIndependentFieldURLParameter(urlParam: any): Field { - return typeof urlParam === 'string' && urlParam.toLowerCase() === 'output' ? Field.OUTPUT : Field.INPUT -} - export default createReducer(initialState, builder => builder - .addCase(setDefaultsFromURLSearch, (_, { payload: { queryString, chainId } }) => { - if (queryString && queryString.length > 1) { - const parsedQs = parse(queryString, { parseArrays: false, ignoreQueryPrefix: true }) - - let inputCurrency = parseCurrencyFromURLParameter(parsedQs.inputCurrency, chainId) - let outputCurrency = parseCurrencyFromURLParameter(parsedQs.outputCurrency, chainId) - if (inputCurrency === outputCurrency) { - if (typeof parsedQs.outputCurrency === 'string') { - inputCurrency = '' - } else { - outputCurrency = '' - } - } - - return { - [Field.INPUT]: { - address: inputCurrency - }, - [Field.OUTPUT]: { - address: outputCurrency - }, - typedValue: parseTokenAmountURLParameter(parsedQs.exactAmount), - independentField: parseIndependentFieldURLParameter(parsedQs.exactField) - } - } - + .addCase(replaceSwapState, (state, { payload: { typedValue, field, inputTokenAddress, outputTokenAddress } }) => { return { - ...initialState, [Field.INPUT]: { - address: WETH[chainId as ChainId]?.address ?? '' - } + address: inputTokenAddress + }, + [Field.OUTPUT]: { + address: outputTokenAddress + }, + independentField: field, + typedValue: typedValue } }) .addCase(selectToken, (state, { payload: { address, field } }) => { diff --git a/src/state/transactions/actions.ts b/src/state/transactions/actions.ts index 7549064f372..af5792a405e 100644 --- a/src/state/transactions/actions.ts +++ b/src/state/transactions/actions.ts @@ -15,7 +15,7 @@ export const addTransaction = createAction<{ chainId: number hash: string from: string - approvalOfToken?: string + approval?: { tokenAddress: string; spender: string } summary?: string }>('addTransaction') export const clearAllTransactions = createAction<{ chainId: number }>('clearAllTransactions') diff --git a/src/state/transactions/hooks.tsx b/src/state/transactions/hooks.tsx index 0559111c986..a2180487cd8 100644 --- a/src/state/transactions/hooks.tsx +++ b/src/state/transactions/hooks.tsx @@ -1,5 +1,5 @@ import { TransactionResponse } from '@ethersproject/providers' -import { useCallback } from 'react' +import { useCallback, useMemo } from 'react' import { useDispatch, useSelector } from 'react-redux' import { useActiveWeb3React } from '../../hooks' @@ -10,7 +10,7 @@ import { TransactionDetails, TransactionState } from './reducer' // helper that can take a ethers library transaction response and add it to the list of transactions export function useTransactionAdder(): ( response: TransactionResponse, - customData?: { summary?: string; approvalOfToken?: string } + customData?: { summary?: string; approval?: { tokenAddress: string; spender: string } } ) => void { const { chainId, account } = useActiveWeb3React() const dispatch = useDispatch() @@ -18,7 +18,7 @@ export function useTransactionAdder(): ( return useCallback( ( response: TransactionResponse, - { summary, approvalOfToken }: { summary?: string; approvalOfToken?: string } = {} + { summary, approval }: { summary?: string; approval?: { tokenAddress: string; spender: string } } = {} ) => { if (!account) return if (!chainId) return @@ -27,7 +27,7 @@ export function useTransactionAdder(): ( if (!hash) { throw Error('No transaction hash found.') } - dispatch(addTransaction({ hash, from: account, chainId, approvalOfToken, summary })) + dispatch(addTransaction({ hash, from: account, chainId, approval, summary })) }, [dispatch, chainId, account] ) @@ -51,15 +51,22 @@ export function useIsTransactionPending(transactionHash?: string): boolean { } // returns whether a token has a pending approval transaction -export function useHasPendingApproval(tokenAddress?: string): boolean { +export function useHasPendingApproval(tokenAddress: string | undefined, spender: string | undefined): boolean { const allTransactions = useAllTransactions() - return typeof tokenAddress !== 'string' - ? false - : Object.keys(allTransactions).some(hash => { + return useMemo( + () => + typeof tokenAddress === 'string' && + typeof spender === 'string' && + Object.keys(allTransactions).some(hash => { if (allTransactions[hash]?.receipt) { return false } else { - return allTransactions[hash]?.approvalOfToken === tokenAddress + return ( + allTransactions[hash]?.approval?.tokenAddress === tokenAddress && + allTransactions[hash]?.approval?.spender === spender + ) } - }) + }), + [allTransactions, spender, tokenAddress] + ) } diff --git a/src/state/transactions/reducer.ts b/src/state/transactions/reducer.ts index bada0346bd9..5315b11529f 100644 --- a/src/state/transactions/reducer.ts +++ b/src/state/transactions/reducer.ts @@ -5,7 +5,7 @@ const now = () => new Date().getTime() export interface TransactionDetails { hash: string - approvalOfToken?: string + approval?: { tokenAddress: string; spender: string } summary?: string receipt?: SerializableTransactionReceipt addedTime: number @@ -26,12 +26,12 @@ const initialState: TransactionState = {} export default createReducer(initialState, builder => builder - .addCase(addTransaction, (state, { payload: { chainId, from, hash, approvalOfToken, summary } }) => { + .addCase(addTransaction, (state, { payload: { chainId, from, hash, approval, summary } }) => { if (state[chainId]?.[hash]) { throw Error('Attempted to add existing transaction.') } state[chainId] = state[chainId] ?? {} - state[chainId][hash] = { hash, approvalOfToken, summary, from, addedTime: now() } + state[chainId][hash] = { hash, approval, summary, from, addedTime: now() } }) .addCase(clearAllTransactions, (state, { payload: { chainId } }) => { if (!state[chainId]) return diff --git a/src/utils/index.ts b/src/utils/index.ts index 7fbe0496758..c8efae8c9b8 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,7 +3,6 @@ import { getAddress } from '@ethersproject/address' import { AddressZero } from '@ethersproject/constants' import { JsonRpcSigner, Web3Provider } from '@ethersproject/providers' import { BigNumber } from '@ethersproject/bignumber' -import { abi as IUniswapV2PairABI } from '@uniswap/v2-core/build/IUniswapV2Pair.json' import { abi as IUniswapV2Router02ABI } from '@uniswap/v2-periphery/build/IUniswapV2Router02.json' import { ROUTER_ADDRESS } from '../constants' import { ChainId, JSBI, Percent, TokenAmount } from '@uniswap/sdk' @@ -92,11 +91,6 @@ export function getRouterContract(_: number, library: Web3Provider, account?: st return getContract(ROUTER_ADDRESS, IUniswapV2Router02ABI, library, account) } -// account is optional -export function getExchangeContract(pairAddress: string, library: Web3Provider, account?: string) { - return getContract(pairAddress, IUniswapV2PairABI, library, account) -} - export function escapeRegExp(string: string): string { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string }