From 095beae0c2219b2ddc20ffbaad395539e58b5691 Mon Sep 17 00:00:00 2001 From: Moody Salem Date: Sat, 16 May 2020 17:55:22 -0400 Subject: [PATCH] Refactor ExchangePage into Swap and Send pages (#774) * Part 1, separate swap and send, move duplicate code into separate files * Move some more constants out of the swap/send * Support defaults from URL parameters * Implement query string parsing via a redux action * Finish merging the changes * Fix the slippage warnings * Clean up some other files * More refactoring * Move the price bar out of the component * Move advanced details and some computations into utilities * Make same improvements to send * Approval hook * Swap page functional with swap callback hook * Swap/send page functional with swap callback hook * Fix lint * Move styleds.ts * Fix integration test * Fix error state in swap and move some things around * Move send callback out of send page * Make send behave more like current behavior * Differentiate swap w/o send on send page from swap w/o send on swap page * Remove userAdvanced which was always false * Remove the price bar which is not used in the new UI * Delete the file * Fix null in the send dialog and move another component out of send * Move the swap modal header out to another file * Include change after merge * Add recipient to swap message * Keep input token selected if user has no balance and clicks send with swap * Move the modal footer out of send component * Remove the hard coded estimated time * Fix the label/action for swap/sends * Share the swap modal footer between swap and send * Fix integration test * remove margin from popper to suppress warnings fix missing ENS name recipient link default deadline to 15 minutes * ensure useApproveCallback returns accurate data * clean up callbacks * extra space * Re-apply ignored changes from v2 branch ExchangePage file Co-authored-by: Noah Zinsmeister --- cypress/integration/landing.test.ts | 4 +- cypress/integration/swap.test.ts | 6 +- package.json | 2 + src/components/AdvancedSettings/index.tsx | 181 --- src/components/BalanceCard/index.tsx | 220 --- src/components/ConfirmationModal/index.tsx | 11 +- src/components/CreatePool/index.tsx | 2 +- src/components/CurrencyInputPanel/index.tsx | 23 +- src/components/ExchangePage/index.tsx | 1316 ----------------- src/components/ExchangePage/swap-store.ts | 136 -- src/components/NavigationTabs/index.tsx | 6 +- src/components/Question/index.tsx | 1 - src/components/SearchModal/index.tsx | 6 +- src/components/SlippageTabs/index.tsx | 273 ++-- src/components/WarningCard/index.tsx | 4 +- src/components/swap/AdvancedSwapDetails.tsx | 91 ++ .../swap/AdvancedSwapDetailsDropdown.tsx | 47 + src/components/swap/FormattedPriceImpact.tsx | 13 + .../swap/PriceSlippageWarningCard.tsx | 30 + src/components/swap/SwapModalFooter.tsx | 119 ++ src/components/swap/SwapModalHeader.tsx | 75 + src/components/swap/TransferModalHeader.tsx | 55 + src/components/swap/V1TradeLink.tsx | 26 + .../{ExchangePage => swap}/styleds.ts | 29 +- src/constants/index.ts | 19 +- src/hooks/index.ts | 3 +- src/hooks/useApproveCallback.ts | 61 + src/hooks/useSendCallback.ts | 64 + src/hooks/useSwapCallback.ts | 192 +++ src/pages/App.tsx | 18 +- src/pages/Pool/AddLiquidity.tsx | 14 +- src/pages/Pool/RemoveLiquidity.tsx | 20 +- src/pages/Send/index.tsx | 528 ++++++- src/pages/Swap/index.tsx | 330 ++++- src/state/application/actions.ts | 1 - src/state/application/hooks.ts | 4 - src/state/application/reducer.ts | 16 +- src/state/index.ts | 4 +- src/state/swap/actions.ts | 11 + src/state/swap/hooks.ts | 147 ++ src/state/swap/reducer.ts | 96 ++ src/theme/components.tsx | 2 +- src/utils/index.ts | 49 - src/utils/prices.ts | 67 + yarn.lock | 10 + 45 files changed, 2168 insertions(+), 2164 deletions(-) delete mode 100644 src/components/AdvancedSettings/index.tsx delete mode 100644 src/components/BalanceCard/index.tsx delete mode 100644 src/components/ExchangePage/index.tsx delete mode 100644 src/components/ExchangePage/swap-store.ts create mode 100644 src/components/swap/AdvancedSwapDetails.tsx create mode 100644 src/components/swap/AdvancedSwapDetailsDropdown.tsx create mode 100644 src/components/swap/FormattedPriceImpact.tsx create mode 100644 src/components/swap/PriceSlippageWarningCard.tsx create mode 100644 src/components/swap/SwapModalFooter.tsx create mode 100644 src/components/swap/SwapModalHeader.tsx create mode 100644 src/components/swap/TransferModalHeader.tsx create mode 100644 src/components/swap/V1TradeLink.tsx rename src/components/{ExchangePage => swap}/styleds.ts (74%) create mode 100644 src/hooks/useApproveCallback.ts create mode 100644 src/hooks/useSendCallback.ts create mode 100644 src/hooks/useSwapCallback.ts create mode 100644 src/state/swap/actions.ts create mode 100644 src/state/swap/hooks.ts create mode 100644 src/state/swap/reducer.ts create mode 100644 src/utils/prices.ts diff --git a/cypress/integration/landing.test.ts b/cypress/integration/landing.test.ts index 970817309b..376247f2c2 100644 --- a/cypress/integration/landing.test.ts +++ b/cypress/integration/landing.test.ts @@ -2,8 +2,8 @@ import { TEST_ADDRESS_NEVER_USE } from '../support/commands' describe('Landing Page', () => { beforeEach(() => cy.visit('/')) - it('loads exchange page', () => { - cy.get('#exchange-page') + it('loads swap page', () => { + cy.get('#swap-page') }) it('redirects to url /swap', () => { diff --git a/cypress/integration/swap.test.ts b/cypress/integration/swap.test.ts index 4d64cf9236..30a3bb4dbe 100644 --- a/cypress/integration/swap.test.ts +++ b/cypress/integration/swap.test.ts @@ -35,8 +35,8 @@ describe('Swap', () => { cy.get('.token-item-0xc7AD46e0b8a400Bb3C915120d284AafbA8fc4735').click() cy.get('#swap-currency-input .token-amount-input').type('0.001') cy.get('#swap-currency-output .token-amount-input').should('not.equal', '') - cy.get('#exchange-show-advanced').click() - cy.get('#exchange-swap-button').click() - cy.get('#exchange-page-confirm-swap-or-send').should('contain', 'Confirm Swap') + cy.get('#show-advanced').click() + cy.get('#swap-button').click() + cy.get('#confirm-swap-or-send').should('contain', 'Confirm Swap') }) }) diff --git a/package.json b/package.json index d80e72d34d..5320d90de9 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "jazzicon": "^1.5.0", "polished": "^3.3.2", "qrcode.react": "^0.9.3", + "qs": "^6.9.4", "react": "^16.13.1", "react-device-detect": "^1.6.2", "react-dom": "^16.13.1", @@ -60,6 +61,7 @@ "@ethersproject/wallet": "^5.0.0-beta.141", "@types/jest": "^25.2.1", "@types/node": "^13.13.5", + "@types/qs": "^6.9.2", "@types/react": "^16.9.34", "@types/react-dom": "^16.9.7", "@types/react-redux": "^7.1.8", diff --git a/src/components/AdvancedSettings/index.tsx b/src/components/AdvancedSettings/index.tsx deleted file mode 100644 index 9f3a8a7e3c..0000000000 --- a/src/components/AdvancedSettings/index.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import React, { useState, useEffect, useCallback } from 'react' -import styled from 'styled-components' - -import QuestionHelper from '../Question' -import NumericalInput from '../NumericalInput' -import { Link } from '../../theme/components' -import { TYPE } from '../../theme' -import { AutoColumn } from '../Column' -import { ButtonRadio } from '../Button' -import { useTranslation } from 'react-i18next' -import Row, { RowBetween, RowFixed } from '../Row' - -const InputWrapper = styled(RowBetween)<{ active?: boolean; error?: boolean }>` - width: 200px; - background-color: ${({ theme }) => theme.bg1}; - border-radius: 8px; - padding: 4px 8px; - border: 1px solid transparent; - border: ${({ active, error, theme }) => - error ? '1px solid ' + theme.red1 : active ? '1px solid ' + theme.primary1 : ''}; -` - -const SLIPPAGE_INDEX = { - 1: 1, - 2: 2, - 3: 3, - 4: 4 -} - -interface AdvancedSettingsProps { - setIsOpen: (boolean) => void - setDeadline: (number) => void - allowedSlippage: number - setAllowedSlippage: (number) => void -} - -export default function AdvancedSettings({ - setIsOpen, - setDeadline, - allowedSlippage, - setAllowedSlippage -}: AdvancedSettingsProps) { - // text translation - const { t } = useTranslation() - - const [deadlineInput, setDeadlineInput] = useState(20) - const [slippageInput, setSlippageInput] = useState('') - const [activeIndex, setActiveIndex] = useState(SLIPPAGE_INDEX[2]) - - const [slippageInputError, setSlippageInputError] = useState(null) // error - - const parseCustomInput = useCallback( - val => { - const acceptableValues = [/^$/, /^\d{1,2}$/, /^\d{0,2}\.\d{0,2}$/] - if (val > 5) { - setSlippageInputError('Your transaction may be front-run.') - } else { - setSlippageInputError(null) - } - if (acceptableValues.some(a => a.test(val))) { - setSlippageInput(val) - setAllowedSlippage(val * 100) - } - }, - [setAllowedSlippage] - ) - - function parseCustomDeadline(val) { - const acceptableValues = [/^$/, /^\d+$/] - if (acceptableValues.some(re => re.test(val))) { - setDeadlineInput(val) - setDeadline(val * 60) - } - } - - // update settings based on current slippage selected - useEffect(() => { - if (allowedSlippage === 10) { - setActiveIndex(1) - } else if (allowedSlippage === 50) { - setActiveIndex(2) - } else if (allowedSlippage === 100) { - setActiveIndex(3) - } else { - setActiveIndex(4) - setSlippageInput('' + allowedSlippage / 100) - parseCustomInput(allowedSlippage) - } - }, [allowedSlippage, parseCustomInput]) - - return ( - - { - setIsOpen(false) - }} - > - back - - - Front-running tolerance - - - - { - setActiveIndex(SLIPPAGE_INDEX[1]) - setAllowedSlippage(10) - }} - > - 0.1% - - { - setActiveIndex(SLIPPAGE_INDEX[2]) - setAllowedSlippage(50) - }} - > - 0.5% (suggested) - - { - setActiveIndex(SLIPPAGE_INDEX[3]) - setAllowedSlippage(100) - }} - > - 1% - - - - - { - parseCustomInput(val) - setActiveIndex(SLIPPAGE_INDEX[4]) - }} - placeholder="Custom" - onClick={() => { - setActiveIndex(SLIPPAGE_INDEX[4]) - if (slippageInput) { - parseCustomInput(slippageInput) - } - }} - /> - % - - {slippageInputError && ( - - Your transaction may be front-run - - )} - - - Adjust deadline (minutes from now) - - - - { - parseCustomDeadline(val) - }} - /> - - - - ) -} diff --git a/src/components/BalanceCard/index.tsx b/src/components/BalanceCard/index.tsx deleted file mode 100644 index f2d6202ef4..0000000000 --- a/src/components/BalanceCard/index.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import React, { useState } from 'react' - -import TokenLogo from '../TokenLogo' -import { TYPE } from '../../theme' -import { Text } from 'rebass' - -import { Hover } from '../../theme' -import { GreyCard } from '../Card' -import { AutoColumn } from '../Column' -import { RowBetween, AutoRow } from '../Row' -import { Copy as CopyIcon, BarChart2, Info, Share, ChevronDown, ChevronUp, Plus } from 'react-feather' -import DoubleLogo from '../DoubleLogo' -import { ButtonSecondary, ButtonGray } from '../Button' -import { Token } from '@uniswap/sdk' - -interface BalanceCardProps { - token0: Token - balance0: boolean - import0: boolean - token1: Token - balance1: boolean - import1: boolean -} - -export default function BalanceCard({ token0, balance0, import0, token1, balance1, import1 }: BalanceCardProps) { - const [showInfo, setshowInfo] = useState(false) - - return ( - - - {!showInfo ? ( - - - setshowInfo(true)} padding={' 0'}> - - Show selection details - - - - - - ) : ( - - - setshowInfo(false)} padding={'0px'}> - - Hide selection details - - - - - - )} - {showInfo && ( - - {token0 && balance0 && ( - // setDetails0(!details0)}> - <> - - - - {token0?.name} ({token0?.symbol}) - - - - {import0 && Token imported by user} - - - - - - - Info - - - - - - - - Charts - - - - - - - - Copy Address - - - - - - - )} - - {token1 && balance1 && ( - // setDetails1(!details1)}> - <> - - - - {token1?.name} ({token1?.symbol}) - - - - {import1 && Token imported by user} - - - - - - - Info - - - - - - - - Charts - - - - - - - - Copy Address - - - - - - - )} - - - - - {token0?.symbol}:{token1?.symbol} - - - - {import1 && Token imported by user} - - - - - - - Info - - - - - - - - Charts - - - - - - - - Copy Address - - - - - - - - Add Liquidity - - - - - - - )} - - - {token1 && ( - - - Share - - )} - - - ) -} diff --git a/src/components/ConfirmationModal/index.tsx b/src/components/ConfirmationModal/index.tsx index bfd4069fe1..316299455e 100644 --- a/src/components/ConfirmationModal/index.tsx +++ b/src/components/ConfirmationModal/index.tsx @@ -118,11 +118,12 @@ function ConfirmationModal({ )} - - {pendingConfirmation - ? 'Confirm this transaction in your wallet' - : `Estimated time until confirmation: 3 min`} - + + {pendingConfirmation && ( + + Confirm this transaction in your wallet + + )} diff --git a/src/components/CreatePool/index.tsx b/src/components/CreatePool/index.tsx index 5e6d73d40e..c10af95e47 100644 --- a/src/components/CreatePool/index.tsx +++ b/src/components/CreatePool/index.tsx @@ -27,7 +27,7 @@ const STEP = { SHOW_CREATE_PAGE: 'SHOW_CREATE_PAGE' // show create page } -function CreatePool({ history }: RouteComponentProps<{}>) { +function CreatePool({ history }: RouteComponentProps) { const { chainId } = useWeb3React() const [showSearch, setShowSearch] = useState(false) const [activeField, setActiveField] = useState(Fields.TOKEN0) diff --git a/src/components/CurrencyInputPanel/index.tsx b/src/components/CurrencyInputPanel/index.tsx index 33760568c9..94ce997c12 100644 --- a/src/components/CurrencyInputPanel/index.tsx +++ b/src/components/CurrencyInputPanel/index.tsx @@ -3,13 +3,14 @@ import React, { useState, useContext } from 'react' import styled, { ThemeContext } from 'styled-components' import '@reach/tooltip/styles.css' import { darken } from 'polished' +import { Field } from '../../state/swap/actions' import { useTokenBalanceTreatingWETHasETH } from '../../state/wallet/hooks' import TokenLogo from '../TokenLogo' import DoubleLogo from '../DoubleLogo' import SearchModal from '../SearchModal' import { RowBetween } from '../Row' -import { TYPE, Hover } from '../../theme' +import { TYPE, CursorPointer } from '../../theme' import { Input as NumericalInput } from '../NumericalInput' import { ReactComponent as DropDown } from '../../assets/images/dropdown.svg' @@ -120,10 +121,10 @@ const StyledBalanceMax = styled.button` interface CurrencyInputPanelProps { value: string - field: number - onUserInput: (field: number, val: string) => void - onMax: () => void - atMax: boolean + field: string + onUserInput: (field: string, val: string) => void + onMax?: () => void + showMaxButton: boolean label?: string urlAddedTokens?: Token[] onTokenSelection?: (tokenAddress: string) => void @@ -135,7 +136,6 @@ interface CurrencyInputPanelProps { hideInput?: boolean showSendWithSwap?: boolean otherSelectedTokenAddress?: string | null - advanced?: boolean id: string } @@ -144,7 +144,7 @@ export default function CurrencyInputPanel({ field, onUserInput, onMax, - atMax, + showMaxButton, label = 'Input', urlAddedTokens = [], // used onTokenSelection = null, @@ -156,7 +156,6 @@ export default function CurrencyInputPanel({ hideInput = false, showSendWithSwap = false, otherSelectedTokenAddress = null, - advanced = false, id }: CurrencyInputPanelProps) { const { t } = useTranslation() @@ -176,7 +175,7 @@ export default function CurrencyInputPanel({ {label} {account && ( - + - + )} @@ -203,7 +202,7 @@ export default function CurrencyInputPanel({ onUserInput(field, val) }} /> - {account && !advanced && !!token?.address && !atMax && label !== 'To' && ( + {account && !!token?.address && showMaxButton && label !== 'To' && ( MAX )} @@ -249,7 +248,7 @@ export default function CurrencyInputPanel({ showSendWithSwap={showSendWithSwap} hiddenToken={token?.address} otherSelectedTokenAddress={otherSelectedTokenAddress} - otherSelectedText={field === 0 ? 'Selected as output' : 'Selected as input'} + otherSelectedText={field === Field.INPUT ? 'Selected as output' : 'Selected as input'} /> )} diff --git a/src/components/ExchangePage/index.tsx b/src/components/ExchangePage/index.tsx deleted file mode 100644 index e6d855bb65..0000000000 --- a/src/components/ExchangePage/index.tsx +++ /dev/null @@ -1,1316 +0,0 @@ -import { BigNumber } from '@ethersproject/bignumber' -import { MaxUint256 } from '@ethersproject/constants' -import { Contract } from '@ethersproject/contracts' -import { parseUnits } from '@ethersproject/units' -import { Fraction, JSBI, Percent, TokenAmount, TradeType, WETH } from '@uniswap/sdk' -import React, { useCallback, useContext, useEffect, useState } from 'react' -import { ArrowDown, ChevronDown, ChevronUp, Repeat } from 'react-feather' -import ReactGA from 'react-ga' -import { RouteComponentProps, withRouter } from 'react-router-dom' -import { Text } from 'rebass' -import { ThemeContext } from 'styled-components' -import Card, { BlueCard, GreyCard, YellowCard } from '../../components/Card' -import { AutoColumn, ColumnCenter } from '../../components/Column' -import { ROUTER_ADDRESS } from '../../constants' -import { useTokenAllowance } from '../../data/Allowances' -import { useV1TradeLinkIfBetter } from '../../data/V1' -import { useTokenContract, useWeb3React } from '../../hooks' -import { useTokenByAddressAndAutomaticallyAdd } from '../../hooks/Tokens' -import { useTradeExactIn, useTradeExactOut } from '../../hooks/Trades' -import { useUserAdvanced, useWalletModalToggle } from '../../state/application/hooks' -import { useHasPendingApproval, useTransactionAdder } from '../../state/transactions/hooks' -import { useAllTokenBalancesTreatingWETHasETH } from '../../state/wallet/hooks' -import { Hover, TYPE } from '../../theme' -import { Link } from '../../theme/components' -import { - basisPointsToPercent, - calculateGasMargin, - getEtherscanLink, - getRouterContract, - getSigner, - QueryParams -} from '../../utils' -import Copy from '../AccountDetails/Copy' -import AddressInputPanel from '../AddressInputPanel' -import { ButtonError, ButtonLight, ButtonPrimary, ButtonSecondary } from '../Button' -import ConfirmationModal from '../ConfirmationModal' -import CurrencyInputPanel from '../CurrencyInputPanel' -import QuestionHelper from '../Question' -import { AutoRow, RowBetween, RowFixed } from '../Row' -import SlippageTabs from '../SlippageTabs' -import TokenLogo from '../TokenLogo' -import { - AdvancedDropwdown, - ArrowWrapper, - BottomGrouping, - Dots, - ErrorText, - FixedBottom, - InputGroup, - SectionBreak, - StyledBalanceMaxMini, - StyledNumerical, - TruncatedText, - Wrapper -} from './styleds' -import { Field, SwapAction, useSwapStateReducer } from './swap-store' - -enum SwapType { - EXACT_TOKENS_FOR_TOKENS, - EXACT_TOKENS_FOR_ETH, - EXACT_ETH_FOR_TOKENS, - TOKENS_FOR_EXACT_TOKENS, - TOKENS_FOR_EXACT_ETH, - ETH_FOR_EXACT_TOKENS -} - -// default allowed slippage, in bips -const INITIAL_ALLOWED_SLIPPAGE = 50 - -// 15 minutes, denominated in seconds -const DEFAULT_DEADLINE_FROM_NOW = 60 * 20 - -// used for warning states based on slippage in bips -const ALLOWED_SLIPPAGE_MEDIUM = 100 -const ALLOWED_SLIPPAGE_HIGH = 500 - -// used to ensure the user doesn't send so much ETH so they end up with <.01 -const MIN_ETH: JSBI = JSBI.BigInt(`1${'0'.repeat(16)}`) // .01 ETH - -interface ExchangePageProps extends RouteComponentProps { - sendingInput: boolean - params: QueryParams -} - -function ExchangePage({ sendingInput = false, history, params }: ExchangePageProps) { - // text translation - // const { t } = useTranslation() - const { chainId, account, library } = useWeb3React() - const theme = useContext(ThemeContext) - - // adding notifications on txns - const addTransaction = useTransactionAdder() - - // toggle wallet when disconnected - const toggleWalletModal = useWalletModalToggle() - - // sending state - const [sending] = useState(sendingInput) - const [sendingWithSwap, setSendingWithSwap] = useState(false) - const [recipient, setRecipient] = useState('') - const [ENS, setENS] = useState('') - - // trade details, check query params for initial state - const [state, dispatch] = useSwapStateReducer(params) - - const { independentField, typedValue, ...fieldData } = state - const dependentField: Field = independentField === Field.INPUT ? Field.OUTPUT : Field.INPUT - const tradeType: TradeType = independentField === Field.INPUT ? TradeType.EXACT_INPUT : TradeType.EXACT_OUTPUT - const [tradeError, setTradeError] = useState('') // error for things like reserve size or route - - const tokens = { - [Field.INPUT]: useTokenByAddressAndAutomaticallyAdd(fieldData[Field.INPUT].address), - [Field.OUTPUT]: useTokenByAddressAndAutomaticallyAdd(fieldData[Field.OUTPUT].address) - } - - // token contracts for approvals and direct sends - const tokenContractInput: Contract = useTokenContract(tokens[Field.INPUT]?.address) - const tokenContractOutput: Contract = useTokenContract(tokens[Field.OUTPUT]?.address) - - // modal and loading - const [showConfirm, setShowConfirm] = useState(false) - const [showAdvanced, setShowAdvanced] = useState(false) - const [attemptingTxn, setAttemptingTxn] = useState(false) // clicked confirmed - const [pendingConfirmation, setPendingConfirmation] = useState(true) // waiting for user confirmation - - // txn values - const [txHash, setTxHash] = useState('') - const [deadline, setDeadline] = useState(DEFAULT_DEADLINE_FROM_NOW) - const [allowedSlippage, setAllowedSlippage] = useState(INITIAL_ALLOWED_SLIPPAGE) - - // all balances for detecting a swap with send - const allBalances = useAllTokenBalancesTreatingWETHasETH() - - // get user- and token-specific lookup data - const userBalances = { - [Field.INPUT]: allBalances?.[account]?.[tokens[Field.INPUT]?.address], - [Field.OUTPUT]: allBalances?.[account]?.[tokens[Field.OUTPUT]?.address] - } - - // parse the amount that the user typed - const parsedAmounts: { [field: number]: TokenAmount } = {} - if (typedValue !== '' && typedValue !== '.' && tokens[independentField]) { - try { - const typedValueParsed = parseUnits(typedValue, tokens[independentField].decimals).toString() - if (typedValueParsed !== '0') - parsedAmounts[independentField] = new TokenAmount(tokens[independentField], typedValueParsed) - } catch (error) { - // should only fail if the user specifies too many decimal places of precision (or maybe exceed max uint?) - console.error(error) - } - } - - const bestTradeExactIn = useTradeExactIn( - tradeType === TradeType.EXACT_INPUT ? parsedAmounts[independentField] : null, - tokens[Field.OUTPUT] - ) - const bestTradeExactOut = useTradeExactOut( - tokens[Field.INPUT], - tradeType === TradeType.EXACT_OUTPUT ? parsedAmounts[independentField] : null - ) - - const trade = tradeType === TradeType.EXACT_INPUT ? bestTradeExactIn : bestTradeExactOut - - // return link to the appropriate v1 pair if the slippage on v1 is lower - const v1TradeLinkIfBetter = useV1TradeLinkIfBetter(trade, new Percent('50', '10000')) - - const route = trade?.route - const userHasSpecifiedInputOutput = - !!tokens[Field.INPUT] && - !!tokens[Field.OUTPUT] && - !!parsedAmounts[independentField] && - parsedAmounts[independentField].greaterThan(JSBI.BigInt(0)) - const noRoute = !route - - const slippageFromTrade: Percent = trade && trade.slippage - - if (trade) - parsedAmounts[dependentField] = tradeType === TradeType.EXACT_INPUT ? trade.outputAmount : trade.inputAmount - - // check whether the user has approved the router on the input token - const inputApproval: TokenAmount = useTokenAllowance(tokens[Field.INPUT], account, ROUTER_ADDRESS) - const userHasApprovedRouter = - tokens[Field.INPUT]?.equals(WETH[chainId]) || - (!!inputApproval && - !!parsedAmounts[Field.INPUT] && - JSBI.greaterThanOrEqual(inputApproval.raw, parsedAmounts[Field.INPUT].raw)) - const pendingApprovalInput = useHasPendingApproval(tokens[Field.INPUT]?.address) - - const formattedAmounts = { - [independentField]: typedValue, - [dependentField]: parsedAmounts[dependentField] ? parsedAmounts[dependentField].toSignificant(6) : '' - } - - // for each hop in our trade, take away the x*y=k price impact from 0.3% fees - // e.g. for 3 tokens/2 hops: 1 - ((1 - .03) * (1-.03)) - const baseFee = basisPointsToPercent(10000 - 30) - const realizedLPFee = !trade - ? undefined - : basisPointsToPercent(10000).subtract( - trade.route.path.length === 2 - ? baseFee - : new Array(trade.route.path.length - 2) - .fill(0) - .reduce((currentFee: Percent | Fraction): Fraction => currentFee.multiply(baseFee), baseFee) - ) - // the x*y=k impact - const priceSlippage = - slippageFromTrade && realizedLPFee - ? new Percent( - slippageFromTrade.subtract(realizedLPFee).numerator, - slippageFromTrade.subtract(realizedLPFee).denominator - ) - : undefined - - // the amount of the input that accrues to LPs - const realizedLPFeeAmount = - realizedLPFee && - new TokenAmount(tokens[Field.INPUT], realizedLPFee.multiply(parsedAmounts[Field.INPUT].raw).quotient) - - const onTokenSelection = useCallback( - (field: Field, address: string) => { - dispatch({ - type: SwapAction.SELECT_TOKEN, - payload: { field, address } - }) - }, - [dispatch] - ) - - const onSwapTokens = useCallback(() => { - dispatch({ - type: SwapAction.SWITCH_TOKENS, - payload: undefined - }) - }, [dispatch]) - - const onUserInput = useCallback( - (field: Field, typedValue: string) => { - dispatch({ type: SwapAction.TYPE, payload: { field, typedValue } }) - }, - [dispatch] - ) - - const onMaxInput = useCallback( - (typedValue: string) => { - dispatch({ - type: SwapAction.TYPE, - payload: { - field: Field.INPUT, - typedValue - } - }) - }, - [dispatch] - ) - - // reset field if sending with with swap is cancled - useEffect(() => { - if (sending && !sendingWithSwap) { - onTokenSelection(Field.OUTPUT, null) - } - }, [onTokenSelection, sending, sendingWithSwap]) - - const maxAmountInput: TokenAmount = - !!userBalances[Field.INPUT] && - !!tokens[Field.INPUT] && - !!WETH[chainId] && - userBalances[Field.INPUT].greaterThan( - new TokenAmount(tokens[Field.INPUT], tokens[Field.INPUT].equals(WETH[chainId]) ? MIN_ETH : '0') - ) - ? tokens[Field.INPUT].equals(WETH[chainId]) - ? userBalances[Field.INPUT].subtract(new TokenAmount(WETH[chainId], MIN_ETH)) - : userBalances[Field.INPUT] - : undefined - const atMaxAmountInput: boolean = - !!maxAmountInput && !!parsedAmounts[Field.INPUT] ? maxAmountInput.equalTo(parsedAmounts[Field.INPUT]) : undefined - - function getSwapType(): SwapType { - if (tradeType === TradeType.EXACT_INPUT) { - if (tokens[Field.INPUT].equals(WETH[chainId])) { - return SwapType.EXACT_ETH_FOR_TOKENS - } else if (tokens[Field.OUTPUT].equals(WETH[chainId])) { - return SwapType.EXACT_TOKENS_FOR_ETH - } else { - return SwapType.EXACT_TOKENS_FOR_TOKENS - } - } else if (tradeType === TradeType.EXACT_OUTPUT) { - if (tokens[Field.INPUT].equals(WETH[chainId])) { - return SwapType.ETH_FOR_EXACT_TOKENS - } else if (tokens[Field.OUTPUT].equals(WETH[chainId])) { - return SwapType.TOKENS_FOR_EXACT_ETH - } else { - return SwapType.TOKENS_FOR_EXACT_TOKENS - } - } - } - - const slippageAdjustedAmounts: { [field: number]: TokenAmount } = { - [independentField]: parsedAmounts[independentField], - [dependentField]: - parsedAmounts[dependentField] && trade - ? tradeType === TradeType.EXACT_INPUT - ? trade.minimumAmountOut(basisPointsToPercent(allowedSlippage)) - : trade.maximumAmountIn(basisPointsToPercent(allowedSlippage)) - : undefined - } - - // reset modal state when closed - function resetModal() { - // clear input if txn submitted - if (!pendingConfirmation) { - onUserInput(Field.INPUT, '') - } - setPendingConfirmation(true) - setAttemptingTxn(false) - setShowAdvanced(false) - } - - // function for a pure send - async function onSend() { - setAttemptingTxn(true) - - const signer = getSigner(library, account) - // get token contract if needed - let estimate: Function, method: Function, args - if (tokens[Field.INPUT].equals(WETH[chainId])) { - signer - .sendTransaction({ to: recipient.toString(), value: BigNumber.from(parsedAmounts[Field.INPUT].raw.toString()) }) - .then(response => { - setTxHash(response.hash) - ReactGA.event({ category: 'ExchangePage', action: 'Send', label: tokens[Field.INPUT]?.symbol }) - addTransaction(response, { - summary: - 'Send ' + - parsedAmounts[Field.INPUT]?.toSignificant(3) + - ' ' + - tokens[Field.INPUT]?.symbol + - ' to ' + - recipient - }) - setPendingConfirmation(false) - }) - .catch(() => { - resetModal() - setShowConfirm(false) - }) - } else { - estimate = tokenContractInput.estimateGas.transfer - method = tokenContractInput.transfer - args = [recipient, parsedAmounts[Field.INPUT].raw.toString()] - await estimate(...args) - .then(estimatedGasLimit => - method(...args, { - gasLimit: calculateGasMargin(estimatedGasLimit) - }).then(response => { - setTxHash(response.hash) - addTransaction(response, { - summary: - 'Send ' + - parsedAmounts[Field.INPUT]?.toSignificant(3) + - ' ' + - tokens[Field.INPUT]?.symbol + - ' to ' + - recipient - }) - setPendingConfirmation(false) - }) - ) - .catch(() => { - resetModal() - setShowConfirm(false) - }) - } - } - - // covers swap or swap with send - async function onSwap() { - const routerContract: Contract = getRouterContract(chainId, library, account) - - setAttemptingTxn(true) // mark that user is attempting transaction - - const path = Object.keys(route.path).map(key => { - return route.path[key].address - }) - let estimate: Function, method: Function, args: any[], value: BigNumber - const deadlineFromNow: number = Math.ceil(Date.now() / 1000) + deadline - - switch (getSwapType()) { - case SwapType.EXACT_TOKENS_FOR_TOKENS: - estimate = routerContract.estimateGas.swapExactTokensForTokens - method = routerContract.swapExactTokensForTokens - args = [ - slippageAdjustedAmounts[Field.INPUT].raw.toString(), - slippageAdjustedAmounts[Field.OUTPUT].raw.toString(), - path, - sending ? recipient : account, - deadlineFromNow - ] - value = null - break - case SwapType.TOKENS_FOR_EXACT_TOKENS: - estimate = routerContract.estimateGas.swapTokensForExactTokens - method = routerContract.swapTokensForExactTokens - args = [ - slippageAdjustedAmounts[Field.OUTPUT].raw.toString(), - slippageAdjustedAmounts[Field.INPUT].raw.toString(), - path, - sending ? recipient : account, - deadlineFromNow - ] - value = null - break - case SwapType.EXACT_ETH_FOR_TOKENS: - estimate = routerContract.estimateGas.swapExactETHForTokens - method = routerContract.swapExactETHForTokens - args = [ - slippageAdjustedAmounts[Field.OUTPUT].raw.toString(), - path, - sending ? recipient : account, - deadlineFromNow - ] - value = BigNumber.from(slippageAdjustedAmounts[Field.INPUT].raw.toString()) - break - case SwapType.TOKENS_FOR_EXACT_ETH: - estimate = routerContract.estimateGas.swapTokensForExactETH - method = routerContract.swapTokensForExactETH - args = [ - slippageAdjustedAmounts[Field.OUTPUT].raw.toString(), - slippageAdjustedAmounts[Field.INPUT].raw.toString(), - path, - sending ? recipient : account, - deadlineFromNow - ] - value = null - break - case SwapType.EXACT_TOKENS_FOR_ETH: - estimate = routerContract.estimateGas.swapExactTokensForETH - method = routerContract.swapExactTokensForETH - args = [ - slippageAdjustedAmounts[Field.INPUT].raw.toString(), - slippageAdjustedAmounts[Field.OUTPUT].raw.toString(), - path, - sending ? recipient : account, - deadlineFromNow - ] - value = null - break - case SwapType.ETH_FOR_EXACT_TOKENS: - estimate = routerContract.estimateGas.swapETHForExactTokens - method = routerContract.swapETHForExactTokens - args = [ - slippageAdjustedAmounts[Field.OUTPUT].raw.toString(), - path, - sending ? recipient : account, - deadlineFromNow - ] - value = BigNumber.from(slippageAdjustedAmounts[Field.INPUT].raw.toString()) - break - } - - await estimate(...args, value ? { value } : {}) - .then(estimatedGasLimit => - method(...args, { - ...(value ? { value } : {}), - gasLimit: calculateGasMargin(estimatedGasLimit) - }).then(response => { - setTxHash(response.hash) - ReactGA.event({ - category: 'ExchangePage', - label: sending && recipient !== account ? 'Swap w/ Send' : 'Swap', - action: [tokens[Field.INPUT]?.symbol, tokens[Field.OUTPUT]?.symbol].join(';') - }) - addTransaction(response, { - summary: - 'Swap ' + - slippageAdjustedAmounts?.[Field.INPUT]?.toSignificant(3) + - ' ' + - tokens[Field.INPUT]?.symbol + - ' for ' + - slippageAdjustedAmounts?.[Field.OUTPUT]?.toSignificant(3) + - ' ' + - tokens[Field.OUTPUT]?.symbol - }) - setPendingConfirmation(false) - }) - ) - .catch(e => { - console.error(e) - resetModal() - setShowConfirm(false) - }) - } - - async function approveAmount(field: Field) { - let useUserBalance = false - const tokenContract = field === Field.INPUT ? tokenContractInput : tokenContractOutput - - const estimatedGas = await tokenContract.estimateGas.approve(ROUTER_ADDRESS, MaxUint256).catch(() => { - // general fallback for tokens who restrict approval amounts - useUserBalance = true - return tokenContract.estimateGas.approve(ROUTER_ADDRESS, userBalances[field].raw.toString()) - }) - - tokenContract - .approve(ROUTER_ADDRESS, useUserBalance ? userBalances[field].raw.toString() : MaxUint256, { - gasLimit: calculateGasMargin(estimatedGas) - }) - .then(response => { - addTransaction(response, { - summary: 'Approve ' + tokens[field]?.symbol, - approvalOfToken: tokens[field].address - }) - }) - } - - // errors - const [generalError, setGeneralError] = useState('') - const [inputError, setInputError] = useState('') - const [outputError, setOutputError] = useState('') - const [recipientError, setRecipientError] = useState('') - const [isValid, setIsValid] = useState(false) - - const ignoreOutput: boolean = sending ? !sendingWithSwap : false - const [showInverted, setShowInverted] = useState(false) - - const advanced = useUserAdvanced() - - useEffect(() => { - // reset errors - setGeneralError(null) - setInputError(null) - setOutputError(null) - setTradeError(null) - setIsValid(true) - - if (recipientError) { - setIsValid(false) - } - - if (!account) { - setGeneralError('Connect Wallet') - setIsValid(false) - } - - if (!parsedAmounts[Field.INPUT]) { - setInputError('Enter an amount') - setIsValid(false) - } - - if (!parsedAmounts[Field.OUTPUT] && !ignoreOutput) { - setOutputError('Enter an amount') - setIsValid(false) - } - - if ( - userBalances[Field.INPUT] && - parsedAmounts[Field.INPUT] && - userBalances[Field.INPUT].lessThan(parsedAmounts[Field.INPUT]) - ) { - setInputError('Insufficient ' + tokens[Field.INPUT]?.symbol + ' balance') - setIsValid(false) - } - - // check for null trade entitiy if not enough balance for trade - if ( - (!sending || sendingWithSwap) && - userBalances[Field.INPUT] && - !trade && - parsedAmounts[independentField] && - !parsedAmounts[dependentField] && - tokens[dependentField] - ) { - setInputError('Insufficient ' + tokens[Field.INPUT]?.symbol + ' balance') - setIsValid(false) - } - }, [ - sending, - sendingWithSwap, - dependentField, - ignoreOutput, - independentField, - parsedAmounts, - recipientError, - tokens, - route, - trade, - userBalances, - account - ]) - - // warnings on slippage - const warningLow: boolean = slippageFromTrade?.lessThan(new Percent(ALLOWED_SLIPPAGE_MEDIUM.toString(), '10000')) - // TODO greaterThanOrEqualTo in SDK - const warningMedium: boolean = - slippageFromTrade?.equalTo(new Percent(ALLOWED_SLIPPAGE_MEDIUM.toString(), '10000')) || - slippageFromTrade?.greaterThan(new Percent(ALLOWED_SLIPPAGE_MEDIUM.toString(), '10000')) - // TODO greaterThanOrEqualTo in SDK - const warningHigh: boolean = - slippageFromTrade?.equalTo(new Percent(ALLOWED_SLIPPAGE_HIGH.toString(), '10000')) || - slippageFromTrade?.greaterThan(new Percent(ALLOWED_SLIPPAGE_HIGH.toString(), '10000')) - - function modalHeader() { - if (sending && !sendingWithSwap) { - return ( - - - - {parsedAmounts[Field.INPUT]?.toSignificant(6)} {tokens[Field.INPUT]?.symbol} - - - - To - {ENS ? ( - - {ENS} - - - - {recipient?.slice(0, 8)}...{recipient?.slice(34, 42)}↗ - - - - - - ) : ( - - - - {recipient?.slice(0, 6)}...{recipient?.slice(36, 42)}↗ - - - - - )} - - ) - } - - if (sending && 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)} - - - - ) - } - - if (!sending) { - return ( - - - - {!!formattedAmounts[Field.INPUT] && formattedAmounts[Field.INPUT]} - - - - - {tokens[Field.INPUT]?.symbol || ''} - - - - - - - - - {!!formattedAmounts[Field.OUTPUT] && formattedAmounts[Field.OUTPUT]} - - - - - {tokens[Field.OUTPUT]?.symbol || ''} - - - - - {independentField === Field.INPUT ? ( - - {`Output is estimated. You will receive at least `} - - {slippageAdjustedAmounts[Field.OUTPUT]?.toSignificant(6)} {tokens[Field.OUTPUT]?.symbol}{' '} - - {' or the transaction will revert.'} - - ) : ( - - {`Input is estimated. You will sell at most `}{' '} - - {slippageAdjustedAmounts[Field.INPUT]?.toSignificant(6)} {tokens[Field.INPUT]?.symbol} - - {' or the transaction will revert.'} - - )} - - - ) - } - } - - function modalBottom() { - if (sending && !sendingWithSwap) { - return ( - - - - Confirm send - - - - ) - } - - if (!sending || (sending && sendingWithSwap)) { - return ( - <> - - {!noRoute && tokens[Field.OUTPUT] && tokens[Field.INPUT] && ( - - - Price - - - {trade && showInverted - ? (trade?.executionPrice?.invert()?.toSignificant(6) ?? '') + - ' ' + - tokens[Field.INPUT]?.symbol + - ' / ' + - tokens[Field.OUTPUT]?.symbol - : (trade?.executionPrice?.toSignificant(6) ?? '') + - ' ' + - tokens[Field.OUTPUT]?.symbol + - ' / ' + - tokens[Field.INPUT]?.symbol} - setShowInverted(!showInverted)}> - - - - - )} - - - - - {independentField === Field.INPUT ? (sending ? 'Min sent' : 'Minimum received') : 'Maximum sold'} - - - - - - {independentField === Field.INPUT - ? slippageAdjustedAmounts[Field.OUTPUT]?.toSignificant(4) ?? '-' - : slippageAdjustedAmounts[Field.INPUT]?.toSignificant(4) ?? '-'} - - {parsedAmounts[Field.OUTPUT] && parsedAmounts[Field.INPUT] && ( - - {independentField === Field.INPUT - ? parsedAmounts[Field.OUTPUT] && tokens[Field.OUTPUT]?.symbol - : parsedAmounts[Field.INPUT] && tokens[Field.INPUT]?.symbol} - - )} - - - - - - Price Impact - - - - - {priceSlippage?.lessThan(new Percent('1', '10000')) ? '<0.01%' : `${priceSlippage?.toFixed(2)}%` ?? '-'} - - - - - - Liquidity Provider Fee - - - - - {realizedLPFeeAmount ? realizedLPFeeAmount?.toSignificant(6) + ' ' + tokens[Field.INPUT]?.symbol : '-'} - - - - - - - - {warningHigh ? (sending ? 'Send Anyway' : 'Swap Anyway') : sending ? 'Confirm Send' : 'Confirm Swap'} - - - - - ) - } - } - - const PriceBar = function() { - return ( - - Rate info - - - {trade ? `${trade.executionPrice.toSignificant(6)} ` : '-'} - - - {tokens[Field.OUTPUT]?.symbol} / {tokens[Field.INPUT]?.symbol} - - - - - {trade ? `${trade.executionPrice.invert().toSignificant(6)} ` : '-'} - - - {tokens[Field.INPUT]?.symbol} / {tokens[Field.OUTPUT]?.symbol} - - - - - {priceSlippage?.lessThan(new Percent('1', '10000')) ? '<0.01%' : `${priceSlippage?.toFixed(2)}%` ?? '-'} - - - Price Impact - - - - ) - } - - // text to show while loading - const pendingText: string = sending - ? 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}` - : ` Swapping ${parsedAmounts[Field.INPUT]?.toSignificant(6)} ${tokens[Field.INPUT]?.symbol} for ${parsedAmounts[ - Field.OUTPUT - ]?.toSignificant(6)} ${tokens[Field.OUTPUT]?.symbol}` - - function _onTokenSelect(address: string) { - // if no user balance - switch view to a send with swap - const hasBalance = allBalances?.[account]?.[address]?.greaterThan('0') - if (!hasBalance && sending) { - onTokenSelection(Field.INPUT, null) - 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) - } - } - - return ( - - { - resetModal() - setShowConfirm(false) - }} - attemptingTxn={attemptingTxn} - pendingConfirmation={pendingConfirmation} - hash={txHash} - topContent={modalHeader} - bottomContent={modalBottom} - pendingText={pendingText} - /> - {sending && !sendingWithSwap && ( - - - onUserInput(Field.INPUT, val)} - /> - onUserInput(Field.INPUT, val)} - onMax={() => { - maxAmountInput && onMaxInput(maxAmountInput.toExact()) - }} - atMax={atMaxAmountInput} - token={tokens[Field.INPUT]} - onTokenSelection={address => _onTokenSelect(address)} - hideBalance={true} - hideInput={true} - showSendWithSwap={true} - advanced={advanced} - label={''} - id="swap-currency-input" - otherSelectedTokenAddress={tokens[Field.OUTPUT]?.address} - /> - - - setSendingWithSwap(true)} - > - + Add a swap - - {account && ( - { - maxAmountInput && onMaxInput(maxAmountInput.toExact()) - }} - > - Input Max - - )} - - - )} - - {(!sending || sendingWithSwap) && ( - <> - { - maxAmountInput && onMaxInput(maxAmountInput.toExact()) - }} - onTokenSelection={address => onTokenSelection(Field.INPUT, address)} - otherSelectedTokenAddress={tokens[Field.OUTPUT]?.address} - id="swap-currency-input" - /> - - {sendingWithSwap ? ( - - - - - - setSendingWithSwap(false)} - style={{ marginRight: '0px', width: 'fit-content', fontSize: '14px' }} - padding={'4px 6px'} - > - Remove Swap - - - - ) : ( - - - - - - - - )} - {}} - atMax={true} - label={independentField === Field.INPUT && parsedAmounts[Field.OUTPUT] ? 'To (estimated)' : 'To'} - token={tokens[Field.OUTPUT]} - onTokenSelection={address => onTokenSelection(Field.OUTPUT, address)} - advanced={advanced} - otherSelectedTokenAddress={tokens[Field.INPUT]?.address} - id="swap-currency-output" - /> - {sendingWithSwap && ( - - - - )} - - )} - - {sending && ( - - { - if (error && input !== '') { - setRecipientError('Invalid Recipient') - } else if (error && input === '') { - setRecipientError('Enter a Recipient') - } else { - setRecipientError(null) - } - }} - /> - - )} - {!noRoute && tokens[Field.OUTPUT] && tokens[Field.INPUT] && ( - - {advanced ? ( - - ) : ( - - - - Price - - - {trade && showInverted - ? (trade?.executionPrice?.invert()?.toSignificant(6) ?? '') + - ' ' + - tokens[Field.INPUT]?.symbol + - ' per ' + - tokens[Field.OUTPUT]?.symbol - : (trade?.executionPrice?.toSignificant(6) ?? '') + - ' ' + - tokens[Field.OUTPUT]?.symbol + - ' per ' + - tokens[Field.INPUT]?.symbol} - setShowInverted(!showInverted)}> - - - - - - {trade && (warningHigh || warningMedium) && ( - - - Price Impact - - - - {priceSlippage?.lessThan(new Percent('1', '10000')) - ? '<0.01%' - : `${priceSlippage?.toFixed(2)}%` ?? '-'} - - - - - )} - - )} - - )} - - - {!account ? ( - { - toggleWalletModal() - }} - > - Connect Wallet - - ) : noRoute && userHasSpecifiedInputOutput ? ( - - Insufficient liquidity for this trade. - { - history.push('/add/' + tokens[Field.INPUT]?.address + '-' + tokens[Field.OUTPUT]?.address) - }} - > - {' '} - Add liquidity now. - - - ) : !userHasApprovedRouter && !inputError ? ( - { - approveAmount(Field.INPUT) - }} - disabled={pendingApprovalInput} - > - {pendingApprovalInput ? ( - Approving {tokens[Field.INPUT]?.symbol} - ) : ( - 'Approve ' + tokens[Field.INPUT]?.symbol - )} - - ) : ( - { - setShowConfirm(true) - }} - id="exchange-swap-button" - disabled={!isValid} - error={!!warningHigh} - > - - {generalError || - inputError || - outputError || - recipientError || - tradeError || - `${sending ? 'Send' : 'Swap'}${warningHigh ? ' Anyway' : ''}`} - - - )} - {v1TradeLinkIfBetter && ( - - - - There is a better price for this trade on - - Uniswap V1 ↗ - - - - - )} - - {tokens[Field.INPUT] && tokens[Field.OUTPUT] && !noRoute && ( - - {!showAdvanced && ( - - setShowAdvanced(true)} padding={'8px 20px'} id="exchange-show-advanced"> - - Show Advanced - - - - - )} - {showAdvanced && ( - - - setShowAdvanced(false)} padding={'8px 20px'}> - - Hide Advanced - - - - - - - - - - {independentField === Field.INPUT - ? sending - ? 'Minimum sent' - : 'Minimum received' - : 'Maximum sold'} - - - - - - {independentField === Field.INPUT - ? slippageAdjustedAmounts[Field.OUTPUT]?.toSignificant(4) ?? '-' - : slippageAdjustedAmounts[Field.INPUT]?.toSignificant(4) ?? '-'} - - {parsedAmounts[Field.OUTPUT] && parsedAmounts[Field.INPUT] && ( - - {independentField === Field.INPUT - ? parsedAmounts[Field.OUTPUT] && tokens[Field.OUTPUT]?.symbol - : parsedAmounts[Field.INPUT] && tokens[Field.INPUT]?.symbol} - - )} - - - - - - Price Impact - - - - - {priceSlippage?.lessThan(new Percent('1', '10000')) - ? '<0.01%' - : `${priceSlippage?.toFixed(2)}%` ?? '-'} - - - - - - Liquidity Provider Fee - - - - - {realizedLPFeeAmount - ? realizedLPFeeAmount?.toSignificant(4) + ' ' + tokens[Field.INPUT]?.symbol - : '-'} - - - - - - - Set slippage tolerance - - - - - - )} - - - {warningHigh && ( - - - - - - ⚠️ - {' '} - - Price Warning - - - - - This trade will move the price by ~{priceSlippage.toFixed(2)}%. - - - - )} - - - - )} - - ) -} - -export default withRouter(ExchangePage) diff --git a/src/components/ExchangePage/swap-store.ts b/src/components/ExchangePage/swap-store.ts deleted file mode 100644 index 0e7735421f..0000000000 --- a/src/components/ExchangePage/swap-store.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { WETH } from '@uniswap/sdk' -import { useReducer } from 'react' -import { useWeb3React } from '../../hooks' -import { QueryParams } from '../../utils' - -export enum Field { - INPUT, - OUTPUT -} - -export interface SwapState { - independentField: Field - typedValue: string - [Field.INPUT]: { - address: string | undefined - } - [Field.OUTPUT]: { - address: string | undefined - } -} - -export function initializeSwapState({ - inputTokenAddress, - outputTokenAddress, - typedValue, - independentField -}): SwapState { - return { - independentField: independentField, - typedValue: typedValue, - [Field.INPUT]: { - address: inputTokenAddress - }, - [Field.OUTPUT]: { - address: outputTokenAddress - } - } -} - -export enum SwapAction { - SELECT_TOKEN, - SWITCH_TOKENS, - TYPE -} - -export interface Payload { - [SwapAction.SELECT_TOKEN]: { - field: Field - address: string - } - [SwapAction.SWITCH_TOKENS]: undefined - [SwapAction.TYPE]: { - field: Field - typedValue: string - } -} - -export function reducer( - state: SwapState, - action: { - type: SwapAction - payload: Payload[SwapAction] - } -): SwapState { - switch (action.type) { - case SwapAction.SELECT_TOKEN: { - const { field, address } = action.payload as Payload[SwapAction.SELECT_TOKEN] - const otherField = field === Field.INPUT ? Field.OUTPUT : Field.INPUT - if (address === state[otherField].address) { - // the case where we have to swap the order - return { - ...state, - independentField: state.independentField === Field.INPUT ? Field.OUTPUT : Field.INPUT, - [field]: { address }, - [otherField]: { address: state[field].address } - } - } else { - // the normal case - return { - ...state, - [field]: { address } - } - } - } - case SwapAction.SWITCH_TOKENS: { - return { - ...state, - independentField: state.independentField === Field.INPUT ? Field.OUTPUT : Field.INPUT, - [Field.INPUT]: { address: state[Field.OUTPUT].address }, - [Field.OUTPUT]: { address: state[Field.INPUT].address } - } - } - case SwapAction.TYPE: { - const { field, typedValue } = action.payload as Payload[SwapAction.TYPE] - return { - ...state, - independentField: field, - typedValue - } - } - default: { - throw Error - } - } -} - -export function useSwapStateReducer(params: QueryParams) { - const { chainId } = useWeb3React() - return useReducer( - reducer, - { - independentField: params.outputTokenAddress && !params.inputTokenAddress ? Field.OUTPUT : Field.INPUT, - inputTokenAddress: params.inputTokenAddress ? params.inputTokenAddress : WETH[chainId].address, - outputTokenAddress: params.outputTokenAddress ? params.outputTokenAddress : '', - typedValue: - params.inputTokenAddress && !params.outputTokenAddress - ? params.inputTokenAmount - ? params.inputTokenAmount - : '' - : !params.inputTokenAddress && params.outputTokenAddress - ? params.outputTokenAmount - ? params.outputTokenAmount - : '' - : params.inputTokenAddress && params.outputTokenAddress - ? params.inputTokenAmount - ? params.inputTokenAmount - : '' - : '' - ? '' - : '' - ? '' - : '' - }, - initializeSwapState - ) -} diff --git a/src/components/NavigationTabs/index.tsx b/src/components/NavigationTabs/index.tsx index 0dd217dd31..8d5890e6b1 100644 --- a/src/components/NavigationTabs/index.tsx +++ b/src/components/NavigationTabs/index.tsx @@ -4,7 +4,7 @@ import { darken } from 'polished' import { useTranslation } from 'react-i18next' import { withRouter, NavLink, Link as HistoryLink, RouteComponentProps } from 'react-router-dom' -import { Hover } from '../../theme' +import { CursorPointer } from '../../theme' import { ArrowLeft } from 'react-feather' import { RowBetween } from '../Row' import QuestionHelper from '../Question' @@ -105,9 +105,9 @@ function NavigationTabs({ location: { pathname }, history }: RouteComponentProps {adding || removing ? ( - history.push('/pool')}> + history.push('/pool')}> - + {adding ? 'Add' : 'Remove'} Liquidity theme.bg1}; diff --git a/src/components/SearchModal/index.tsx b/src/components/SearchModal/index.tsx index ea84a0238a..7542ceb4d7 100644 --- a/src/components/SearchModal/index.tsx +++ b/src/components/SearchModal/index.tsx @@ -15,7 +15,7 @@ import TokenLogo from '../TokenLogo' import DoubleTokenLogo from '../DoubleLogo' import Column, { AutoColumn } from '../Column' import { Text } from 'rebass' -import { Hover } from '../../theme' +import { CursorPointer } from '../../theme' import { ArrowLeft } from 'react-feather' import { CloseIcon } from '../../theme/components' import { ButtonPrimary, ButtonSecondary } from '../../components/Button' @@ -557,13 +557,13 @@ function SearchModal({ - + { setShowTokenImport(false) }} /> - + Import A Token diff --git a/src/components/SlippageTabs/index.tsx b/src/components/SlippageTabs/index.tsx index 11ed00d35d..56d354a7e8 100644 --- a/src/components/SlippageTabs/index.tsx +++ b/src/components/SlippageTabs/index.tsx @@ -123,19 +123,14 @@ const Percent = styled.div` `)}; ` -interface TransactionDetailsProps { +export interface SlippageTabsProps { rawSlippage: number setRawSlippage: (rawSlippage: number) => void deadline: number setDeadline: (deadline: number) => void } -export default function TransactionDetails({ - setRawSlippage, - rawSlippage, - deadline, - setDeadline -}: TransactionDetailsProps) { +export default function SlippageTabs({ setRawSlippage, rawSlippage, deadline, setDeadline }: SlippageTabsProps) { const [activeIndex, setActiveIndex] = useState(2) const [warningType, setWarningType] = useState(WARNING_TYPE.none) @@ -250,142 +245,138 @@ export default function TransactionDetails({ } }) - const dropDownContent = () => { - return ( - <> - - - - - - { - setFromCustom() - }} - > - - {!(warningType === WARNING_TYPE.none || warningType === WARNING_TYPE.emptyInput) && ( - - ⚠️ - - )} - - + + + + + + { + setFromCustom() + }} + > + + {!(warningType === WARNING_TYPE.none || warningType === WARNING_TYPE.emptyInput) && ( + - % - - - - - - - {warningType === WARNING_TYPE.emptyInput && 'Enter a slippage percentage'} - {warningType === WARNING_TYPE.invalidEntryBound && 'Please select a value no greater than 50%'} - {warningType === WARNING_TYPE.riskyEntryHigh && 'Your transaction may be frontrun'} - {warningType === WARNING_TYPE.riskyEntryLow && 'Your transaction may fail'} - - - - - - Deadline - - - - + ⚠️ + + )} - - - minutes - - - - - ) - } - - return dropDownContent() + + % + + + + + + + {warningType === WARNING_TYPE.emptyInput && 'Enter a slippage percentage'} + {warningType === WARNING_TYPE.invalidEntryBound && 'Please select a value no greater than 50%'} + {warningType === WARNING_TYPE.riskyEntryHigh && 'Your transaction may be frontrun'} + {warningType === WARNING_TYPE.riskyEntryLow && 'Your transaction may fail'} + + + + + + Deadline + + + + + + + + minutes + + + + + ) } diff --git a/src/components/WarningCard/index.tsx b/src/components/WarningCard/index.tsx index e7c90252c1..2000e1a4c7 100644 --- a/src/components/WarningCard/index.tsx +++ b/src/components/WarningCard/index.tsx @@ -132,7 +132,7 @@ interface WarningCardProps { currency: string } -function WarningCard({ onDismiss, urlAddedTokens, currency }: WarningCardProps) { +export default function WarningCard({ onDismiss, urlAddedTokens, currency }: WarningCardProps) { const [showPopup, setPopup] = useState(false) const { chainId } = useWeb3React() const { symbol: inputSymbol, name: inputName } = useToken(currency) @@ -183,5 +183,3 @@ function WarningCard({ onDismiss, urlAddedTokens, currency }: WarningCardProps) ) } - -export default WarningCard diff --git a/src/components/swap/AdvancedSwapDetails.tsx b/src/components/swap/AdvancedSwapDetails.tsx new file mode 100644 index 0000000000..fdf1f1e234 --- /dev/null +++ b/src/components/swap/AdvancedSwapDetails.tsx @@ -0,0 +1,91 @@ +import { Trade, TradeType } from '@uniswap/sdk' +import React, { useContext } from 'react' +import { ChevronUp } from 'react-feather' +import { Text } from 'rebass' +import { ThemeContext } from 'styled-components' +import { Field } from '../../state/swap/actions' +import { CursorPointer, TYPE } from '../../theme' +import { computeSlippageAdjustedAmounts, computeTradePriceBreakdown } from '../../utils/prices' +import { AutoColumn } from '../Column' +import { SectionBreak } from './styleds' +import QuestionHelper from '../Question' +import { RowBetween, RowFixed } from '../Row' +import SlippageTabs, { SlippageTabsProps } from '../SlippageTabs' +import FormattedPriceImpact from './FormattedPriceImpact' + +export interface AdvancedSwapDetailsProps extends SlippageTabsProps { + trade: Trade + onDismiss: () => void +} + +export function AdvancedSwapDetails({ trade, onDismiss, ...slippageTabProps }: AdvancedSwapDetailsProps) { + const { priceImpactWithoutFee, realizedLPFee } = computeTradePriceBreakdown(trade) + const theme = useContext(ThemeContext) + const isExactIn = trade.tradeType === TradeType.EXACT_INPUT + const slippageAdjustedAmounts = computeSlippageAdjustedAmounts(trade, slippageTabProps.rawSlippage) + + return ( + + + + + Hide Advanced + + + + + + + + + + {isExactIn ? 'Minimum received' : 'Maximum sold'} + + + + + + {isExactIn + ? `${slippageAdjustedAmounts[Field.OUTPUT]?.toSignificant(4)} ${trade.outputAmount.token.symbol}` ?? '-' + : `${slippageAdjustedAmounts[Field.INPUT]?.toSignificant(4)} ${trade.inputAmount.token.symbol}` ?? '-'} + + + + + + + Price Impact + + + + + + + + + Liquidity Provider Fee + + + + + {realizedLPFee ? `${realizedLPFee.toSignificant(4)} ${trade.inputAmount.token.symbol}` : '-'} + + + + + + + Set slippage tolerance + + + + + + ) +} diff --git a/src/components/swap/AdvancedSwapDetailsDropdown.tsx b/src/components/swap/AdvancedSwapDetailsDropdown.tsx new file mode 100644 index 0000000000..3a9814f086 --- /dev/null +++ b/src/components/swap/AdvancedSwapDetailsDropdown.tsx @@ -0,0 +1,47 @@ +import { Percent } from '@uniswap/sdk' +import React, { useContext } from 'react' +import { ChevronDown } from 'react-feather' +import { Text } from 'rebass' +import { ThemeContext } from 'styled-components' +import { CursorPointer } from '../../theme' +import { warningServerity } from '../../utils/prices' +import { AutoColumn } from '../Column' +import { RowBetween } from '../Row' +import { AdvancedSwapDetails, AdvancedSwapDetailsProps } from './AdvancedSwapDetails' +import { PriceSlippageWarningCard } from './PriceSlippageWarningCard' +import { AdvancedDropwdown, FixedBottom } from './styleds' + +export default function AdvancedSwapDetailsDropdown({ + priceImpactWithoutFee, + showAdvanced, + setShowAdvanced, + ...rest +}: Omit & { + showAdvanced: boolean + setShowAdvanced: (showAdvanced: boolean) => void + priceImpactWithoutFee: Percent +}) { + const theme = useContext(ThemeContext) + const severity = warningServerity(priceImpactWithoutFee) + return ( + + {showAdvanced ? ( + setShowAdvanced(false)} /> + ) : ( + + setShowAdvanced(true)} padding={'8px 20px'} id="show-advanced"> + + Show Advanced + + + + + )} + + + {severity > 2 && } + + + + ) +} diff --git a/src/components/swap/FormattedPriceImpact.tsx b/src/components/swap/FormattedPriceImpact.tsx new file mode 100644 index 0000000000..c2b22e5c7b --- /dev/null +++ b/src/components/swap/FormattedPriceImpact.tsx @@ -0,0 +1,13 @@ +import { Percent } from '@uniswap/sdk' +import React from 'react' +import { ONE_BIPS } from '../../constants' +import { warningServerity } from '../../utils/prices' +import { ErrorText } from './styleds' + +export default function FormattedPriceImpact({ priceImpact }: { priceImpact?: Percent }) { + return ( + + {priceImpact?.lessThan(ONE_BIPS) ? '<0.01%' : `${priceImpact?.toFixed(2)}%` ?? '-'} + + ) +} diff --git a/src/components/swap/PriceSlippageWarningCard.tsx b/src/components/swap/PriceSlippageWarningCard.tsx new file mode 100644 index 0000000000..1ee5889aee --- /dev/null +++ b/src/components/swap/PriceSlippageWarningCard.tsx @@ -0,0 +1,30 @@ +import { Percent } from '@uniswap/sdk' +import React, { useContext } from 'react' +import { Text } from 'rebass' +import { ThemeContext } from 'styled-components' +import { YellowCard } from '../Card' +import { AutoColumn } from '../Column' +import { RowBetween, RowFixed } from '../Row' + +export function PriceSlippageWarningCard({ priceSlippage }: { priceSlippage: Percent }) { + const theme = useContext(ThemeContext) + return ( + + + + + + ⚠️ + {' '} + + Price Warning + + + + + This trade will move the price by ~{priceSlippage.toFixed(2)}%. + + + + ) +} diff --git a/src/components/swap/SwapModalFooter.tsx b/src/components/swap/SwapModalFooter.tsx new file mode 100644 index 0000000000..3db007ba54 --- /dev/null +++ b/src/components/swap/SwapModalFooter.tsx @@ -0,0 +1,119 @@ +import { Percent, TokenAmount, Trade, TradeType } from '@uniswap/sdk' +import React, { useContext } from 'react' +import { Repeat } from 'react-feather' +import { Text } from 'rebass' +import { ThemeContext } from 'styled-components' +import { Field } from '../../state/swap/actions' +import { TYPE } from '../../theme' +import { formatExecutionPrice } from '../../utils/prices' +import { ButtonError } from '../Button' +import { AutoColumn } from '../Column' +import QuestionHelper from '../Question' +import { AutoRow, RowBetween, RowFixed } from '../Row' +import FormattedPriceImpact from './FormattedPriceImpact' +import { StyledBalanceMaxMini } from './styleds' + +export default function SwapModalFooter({ + trade, + showInverted, + setShowInverted, + severity, + slippageAdjustedAmounts, + onSwap, + parsedAmounts, + realizedLPFee, + priceImpactWithoutFee, + confirmText +}: { + trade?: Trade + showInverted: boolean + setShowInverted: (inverted: boolean) => void + severity: number + slippageAdjustedAmounts?: { [field in Field]?: TokenAmount } + onSwap: () => any + parsedAmounts?: { [field in Field]?: TokenAmount } + realizedLPFee?: TokenAmount + priceImpactWithoutFee?: Percent + confirmText: string +}) { + const theme = useContext(ThemeContext) + return ( + <> + + + + Price + + + {formatExecutionPrice(trade, showInverted)} + setShowInverted(!showInverted)}> + + + + + + + + + {trade?.tradeType === TradeType.EXACT_INPUT ? 'Min sent' : 'Maximum sold'} + + + + + + {trade?.tradeType === TradeType.EXACT_INPUT + ? slippageAdjustedAmounts[Field.OUTPUT]?.toSignificant(4) ?? '-' + : slippageAdjustedAmounts[Field.INPUT]?.toSignificant(4) ?? '-'} + + {parsedAmounts[Field.OUTPUT] && parsedAmounts[Field.INPUT] && ( + + {trade?.tradeType === TradeType.EXACT_INPUT + ? parsedAmounts[Field.OUTPUT]?.token?.symbol + : parsedAmounts[Field.INPUT]?.token?.symbol} + + )} + + + + + + Price Impact + + + + + + + + + Liquidity Provider Fee + + + + + {realizedLPFee ? realizedLPFee?.toSignificant(6) + ' ' + trade?.inputAmount?.token?.symbol : '-'} + + + + + + 2} style={{ margin: '10px 0 0 0' }} id="confirm-swap-or-send"> + + {confirmText} + + + + + ) +} diff --git a/src/components/swap/SwapModalHeader.tsx b/src/components/swap/SwapModalHeader.tsx new file mode 100644 index 0000000000..5c2547c106 --- /dev/null +++ b/src/components/swap/SwapModalHeader.tsx @@ -0,0 +1,75 @@ +import { Token, TokenAmount } from '@uniswap/sdk' +import React, { useContext } from 'react' +import { ArrowDown } from 'react-feather' +import { Text } from 'rebass' +import { ThemeContext } from 'styled-components' +import { Field } from '../../state/swap/actions' +import { TYPE } from '../../theme' +import { AutoColumn } from '../Column' +import { RowBetween, RowFixed } from '../Row' +import TokenLogo from '../TokenLogo' +import { TruncatedText } from './styleds' + +export default function SwapModalHeader({ + formattedAmounts, + tokens, + slippageAdjustedAmounts, + priceImpactSeverity, + independentField +}: { + formattedAmounts?: { [field in Field]?: string } + tokens?: { [field in Field]?: Token } + slippageAdjustedAmounts?: { [field in Field]?: TokenAmount } + priceImpactSeverity: number + independentField: Field +}) { + const theme = useContext(ThemeContext) + return ( + + + + {!!formattedAmounts[Field.INPUT] && formattedAmounts[Field.INPUT]} + + + + + {tokens[Field.INPUT]?.symbol || ''} + + + + + + + + 2 ? theme.red1 : ''}> + {!!formattedAmounts[Field.OUTPUT] && formattedAmounts[Field.OUTPUT]} + + + + + {tokens[Field.OUTPUT]?.symbol || ''} + + + + + {independentField === Field.INPUT ? ( + + {`Output is estimated. You will receive at least `} + + {slippageAdjustedAmounts[Field.OUTPUT]?.toSignificant(6)} {tokens[Field.OUTPUT]?.symbol}{' '} + + {' or the transaction will revert.'} + + ) : ( + + {`Input is estimated. You will sell at most `} + + {slippageAdjustedAmounts[Field.INPUT]?.toSignificant(6)} {tokens[Field.INPUT]?.symbol} + + {' or the transaction will revert.'} + + )} + + + ) +} diff --git a/src/components/swap/TransferModalHeader.tsx b/src/components/swap/TransferModalHeader.tsx new file mode 100644 index 0000000000..a309bc8a02 --- /dev/null +++ b/src/components/swap/TransferModalHeader.tsx @@ -0,0 +1,55 @@ +import { TokenAmount } from '@uniswap/sdk' +import React from 'react' +import { Text } from 'rebass' +import { useWeb3React } from '../../hooks' +import { Link, 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 } = useWeb3React() + 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/V1TradeLink.tsx b/src/components/swap/V1TradeLink.tsx new file mode 100644 index 0000000000..f77882d30b --- /dev/null +++ b/src/components/swap/V1TradeLink.tsx @@ -0,0 +1,26 @@ +import { Trade } from '@uniswap/sdk' +import React, { useContext } from 'react' +import { Text } from 'rebass' +import { ThemeContext } from 'styled-components' +import { V1_TRADE_LINK_THRESHOLD } from '../../constants' +import { useV1TradeLinkIfBetter } from '../../data/V1' +import { Link } from '../../theme' +import { YellowCard } from '../Card' +import { AutoColumn } from '../Column' + +export default function V1TradeLink({ bestV2Trade }: { bestV2Trade?: Trade }) { + const v1TradeLinkIfBetter = useV1TradeLinkIfBetter(bestV2Trade, V1_TRADE_LINK_THRESHOLD) + const theme = useContext(ThemeContext) + return v1TradeLinkIfBetter ? ( + + + + There is a better price for this trade on + + Uniswap V1 ↗ + + + + + ) : null +} diff --git a/src/components/ExchangePage/styleds.ts b/src/components/swap/styleds.ts similarity index 74% rename from src/components/ExchangePage/styleds.ts rename to src/components/swap/styleds.ts index 8045e71279..5691fb3ebd 100644 --- a/src/components/ExchangePage/styleds.ts +++ b/src/components/swap/styleds.ts @@ -55,9 +55,9 @@ export const BottomGrouping = styled.div` position: relative; ` -export const ErrorText = styled(Text)<{ warningLow?: boolean; warningMedium?: boolean; warningHigh?: boolean }>` - color: ${({ theme, warningLow, warningMedium, warningHigh }) => - warningHigh ? theme.red1 : warningMedium ? theme.yellow2 : warningLow ? theme.green1 : theme.text1}; +export const ErrorText = styled(Text)<{ severity?: 0 | 1 | 2 | 3 }>` + color: ${({ theme, severity }) => + severity === 3 ? theme.red1 : severity === 2 ? theme.yellow2 : severity === 1 ? theme.green1 : theme.text1}; ` export const InputGroup = styled(AutoColumn)` @@ -75,29 +75,6 @@ export const StyledNumerical = styled(NumericalInput)` color: ${({ theme }) => theme.text4}; } ` - -export const MaxButton = styled.button` - position: absolute; - right: 70px; - padding: 0.25rem 0.35rem; - background-color: ${({ theme }) => theme.primary5}; - border: 1px solid ${({ theme }) => theme.primary5}; - border-radius: 0.5rem; - font-size: 0.875rem; - font-weight: 500; - text-transform: uppercase; - cursor: pointer; - margin-right: 0.5rem; - color: ${({ theme }) => theme.primaryText1}; - :hover { - border: 1px solid ${({ theme }) => theme.primary1}; - } - :focus { - border: 1px solid ${({ theme }) => theme.primary1}; - outline: none; - } -` - export const StyledBalanceMaxMini = styled.button<{ active?: boolean }>` height: 22px; width: 22px; diff --git a/src/constants/index.ts b/src/constants/index.ts index d3eab50bbf..7f23fbc246 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,5 +1,5 @@ -import { injected, walletconnect, walletlink, fortmatic, portis } from '../connectors' -import { ChainId, WETH, Token } from '@uniswap/sdk' +import { ChainId, Token, WETH, JSBI, Percent } from '@uniswap/sdk' +import { fortmatic, injected, portis, walletconnect, walletlink } from '../connectors' export const V1_FACTORY_ADDRESS = '0xc0a47dFe034B400B47bDaD5FecDa2621de6c4d95' @@ -103,3 +103,18 @@ export const SUPPORTED_WALLETS = } export const NetworkContextName = 'NETWORK' + +// default allowed slippage, in bips +export const INITIAL_ALLOWED_SLIPPAGE = 50 +// 15 minutes, denominated in seconds +export const DEFAULT_DEADLINE_FROM_NOW = 60 * 15 + +// one basis point +export const ONE_BIPS = new Percent(JSBI.BigInt(1), JSBI.BigInt(10000)) +// used for warning states +export const ALLOWED_SLIPPAGE_LOW: Percent = new Percent(JSBI.BigInt(100), JSBI.BigInt(10000)) +export const ALLOWED_SLIPPAGE_MEDIUM: Percent = new Percent(JSBI.BigInt(500), JSBI.BigInt(10000)) +export const ALLOWED_SLIPPAGE_HIGH: Percent = new Percent(JSBI.BigInt(1000), JSBI.BigInt(10000)) +// 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(50), JSBI.BigInt(10000)) diff --git a/src/hooks/index.ts b/src/hooks/index.ts index ba313f83e0..4240745bcf 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,3 +1,4 @@ +import { Contract } from '@ethersproject/contracts' import { Web3Provider } from '@ethersproject/providers' import { useState, useMemo, useCallback, useEffect, useRef } from 'react' import { useWeb3React as useWeb3ReactCore } from '@web3-react/core' @@ -190,7 +191,7 @@ export function useContract(address, ABI, withSignerIfPossible = true) { } // returns null on errors -export function useTokenContract(tokenAddress, withSignerIfPossible = true) { +export function useTokenContract(tokenAddress: string, withSignerIfPossible = true): Contract { const { library, account } = useWeb3React() return useMemo(() => { diff --git a/src/hooks/useApproveCallback.ts b/src/hooks/useApproveCallback.ts new file mode 100644 index 0000000000..4adaedaa8c --- /dev/null +++ b/src/hooks/useApproveCallback.ts @@ -0,0 +1,61 @@ +import { MaxUint256 } from '@ethersproject/constants' +import { Trade, WETH } from '@uniswap/sdk' +import { useCallback, useMemo } from 'react' +import { ROUTER_ADDRESS } from '../constants' +import { useTokenAllowance } from '../data/Allowances' +import { Field } from '../state/swap/actions' +import { useTransactionAdder } from '../state/transactions/hooks' +import { computeSlippageAdjustedAmounts } from '../utils/prices' +import { calculateGasMargin } from '../utils' +import { useTokenContract, useWeb3React } from './index' + +// returns a function to approve the amount required to execute a trade if necessary, otherwise null +export function useApproveCallback(trade?: Trade, allowedSlippage = 0): [undefined | boolean, () => Promise] { + const { account, chainId } = useWeb3React() + const currentAllowance = useTokenAllowance(trade?.inputAmount?.token, account, ROUTER_ADDRESS) + + const slippageAdjustedAmountIn = useMemo(() => computeSlippageAdjustedAmounts(trade, allowedSlippage)[Field.INPUT], [ + trade, + allowedSlippage + ]) + + const mustApprove = useMemo(() => { + // we treat WETH as ETH which requires no approvals + if (trade?.inputAmount?.token?.equals(WETH[chainId])) return false + // return undefined if we don't have enough data to know whether or not we need to approve + if (!currentAllowance) return undefined + // slippageAdjustedAmountIn will be defined if currentAllowance is + return currentAllowance.lessThan(slippageAdjustedAmountIn) + }, [trade, chainId, currentAllowance, slippageAdjustedAmountIn]) + + const tokenContract = useTokenContract(trade?.inputAmount?.token?.address) + const addTransaction = useTransactionAdder() + const approve = useCallback(async (): Promise => { + if (!mustApprove) return + + let useUserBalance = false + + const estimatedGas = await tokenContract.estimateGas.approve(ROUTER_ADDRESS, MaxUint256).catch(() => { + // general fallback for tokens who restrict approval amounts + useUserBalance = true + return tokenContract.estimateGas.approve(ROUTER_ADDRESS, slippageAdjustedAmountIn.raw.toString()) + }) + + return tokenContract + .approve(ROUTER_ADDRESS, useUserBalance ? slippageAdjustedAmountIn.raw.toString() : MaxUint256, { + gasLimit: calculateGasMargin(estimatedGas) + }) + .then(response => { + addTransaction(response, { + summary: 'Approve ' + trade?.inputAmount?.token?.symbol, + approvalOfToken: trade?.inputAmount?.token?.symbol + }) + }) + .catch(error => { + console.debug('Failed to approve token', error) + throw error + }) + }, [mustApprove, tokenContract, slippageAdjustedAmountIn, trade, addTransaction]) + + return [mustApprove, approve] +} diff --git a/src/hooks/useSendCallback.ts b/src/hooks/useSendCallback.ts new file mode 100644 index 0000000000..c645a981b8 --- /dev/null +++ b/src/hooks/useSendCallback.ts @@ -0,0 +1,64 @@ +import { BigNumber } from '@ethersproject/bignumber' +import { WETH, TokenAmount, JSBI } 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 { useENSName, useTokenContract, useWeb3React } from './index' + +// 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 } = useWeb3React() + const addTransaction = useTransactionAdder() + const ensName = useENSName(recipient) + const tokenContract = useTokenContract(amount?.token?.address) + const balance = useTokenBalanceTreatingWETHasETH(account, 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 (token.equals(WETH[chainId])) { + return getSigner(library, account) + .sendTransaction({ to: recipient, value: BigNumber.from(amount.raw.toString()) }) + .then(response => { + addTransaction(response, { + summary: 'Send ' + amount.toSignificant(3) + ' ' + token?.symbol + ' to ' + (ensName ?? recipient) + }) + return response.hash + }) + .catch(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 => { + 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 new file mode 100644 index 0000000000..ff16a05658 --- /dev/null +++ b/src/hooks/useSwapCallback.ts @@ -0,0 +1,192 @@ +import { BigNumber } from '@ethersproject/bignumber' +import { Contract } from '@ethersproject/contracts' +import { Token, 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 { Field } from '../state/swap/actions' +import { useTransactionAdder } from '../state/transactions/hooks' +import { computeSlippageAdjustedAmounts } from '../utils/prices' +import { calculateGasMargin, getRouterContract, isAddress } from '../utils' +import { useENSName, useWeb3React } from './index' + +enum SwapType { + EXACT_TOKENS_FOR_TOKENS, + EXACT_TOKENS_FOR_ETH, + EXACT_ETH_FOR_TOKENS, + TOKENS_FOR_EXACT_TOKENS, + TOKENS_FOR_EXACT_ETH, + ETH_FOR_EXACT_TOKENS +} + +function getSwapType(tokens: { [field in Field]?: Token }, isExactIn: boolean, chainId: number): SwapType { + if (isExactIn) { + if (tokens[Field.INPUT]?.equals(WETH[chainId])) { + return SwapType.EXACT_ETH_FOR_TOKENS + } else if (tokens[Field.OUTPUT]?.equals(WETH[chainId])) { + return SwapType.EXACT_TOKENS_FOR_ETH + } else { + return SwapType.EXACT_TOKENS_FOR_TOKENS + } + } else { + if (tokens[Field.INPUT]?.equals(WETH[chainId])) { + return SwapType.ETH_FOR_EXACT_TOKENS + } else if (tokens[Field.OUTPUT]?.equals(WETH[chainId])) { + return SwapType.TOKENS_FOR_EXACT_ETH + } else { + return SwapType.TOKENS_FOR_EXACT_TOKENS + } + } +} + +// 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 + allowedSlippage: number = INITIAL_ALLOWED_SLIPPAGE, // in bips, optional + deadline: number = DEFAULT_DEADLINE_FROM_NOW, // in seconds from now, optional + to?: string // recipient of output, optional +): null | (() => Promise) { + const { account, chainId, library } = useWeb3React() + const inputAllowance = useTokenAllowance(trade?.inputAmount?.token, account, ROUTER_ADDRESS) + const addTransaction = useTransactionAdder() + const recipient = to ? isAddress(to) : account + const ensName = useENSName(to) + + return useMemo(() => { + if (!trade) return null + if (!recipient) return null + + // will always be defined + const slippageAdjustedAmounts = computeSlippageAdjustedAmounts(trade, allowedSlippage) + + // no allowance + if ( + !trade.inputAmount.token.equals(WETH[chainId]) && + (!inputAllowance || slippageAdjustedAmounts[Field.INPUT].greaterThan(inputAllowance)) + ) { + return null + } + + return async function onSwap() { + const routerContract: Contract = getRouterContract(chainId, library, account) + + 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 + ) + + let estimate, method, args, value + switch (swapType) { + case SwapType.EXACT_TOKENS_FOR_TOKENS: + estimate = routerContract.estimateGas.swapExactTokensForTokens + method = routerContract.swapExactTokensForTokens + args = [ + slippageAdjustedAmounts[Field.INPUT].raw.toString(), + slippageAdjustedAmounts[Field.OUTPUT].raw.toString(), + path, + account, + deadlineFromNow + ] + value = null + break + case SwapType.TOKENS_FOR_EXACT_TOKENS: + estimate = routerContract.estimateGas.swapTokensForExactTokens + method = routerContract.swapTokensForExactTokens + args = [ + slippageAdjustedAmounts[Field.OUTPUT].raw.toString(), + slippageAdjustedAmounts[Field.INPUT].raw.toString(), + path, + account, + deadlineFromNow + ] + value = null + break + case SwapType.EXACT_ETH_FOR_TOKENS: + estimate = routerContract.estimateGas.swapExactETHForTokens + method = routerContract.swapExactETHForTokens + args = [slippageAdjustedAmounts[Field.OUTPUT].raw.toString(), path, account, deadlineFromNow] + value = BigNumber.from(slippageAdjustedAmounts[Field.INPUT].raw.toString()) + break + case SwapType.TOKENS_FOR_EXACT_ETH: + estimate = routerContract.estimateGas.swapTokensForExactETH + method = routerContract.swapTokensForExactETH + args = [ + slippageAdjustedAmounts[Field.OUTPUT].raw.toString(), + slippageAdjustedAmounts[Field.INPUT].raw.toString(), + path, + account, + deadlineFromNow + ] + value = null + break + case SwapType.EXACT_TOKENS_FOR_ETH: + estimate = routerContract.estimateGas.swapExactTokensForETH + method = routerContract.swapExactTokensForETH + args = [ + slippageAdjustedAmounts[Field.INPUT].raw.toString(), + slippageAdjustedAmounts[Field.OUTPUT].raw.toString(), + path, + account, + deadlineFromNow + ] + value = null + break + case SwapType.ETH_FOR_EXACT_TOKENS: + estimate = routerContract.estimateGas.swapETHForExactTokens + method = routerContract.swapETHForExactTokens + args = [slippageAdjustedAmounts[Field.OUTPUT].raw.toString(), path, account, deadlineFromNow] + value = BigNumber.from(slippageAdjustedAmounts[Field.INPUT].raw.toString()) + break + } + + return estimate(...args, value ? { value } : {}) + .then(estimatedGasLimit => + method(...args, { + ...(value ? { value } : {}), + gasLimit: calculateGasMargin(estimatedGasLimit) + }) + ) + .then(response => { + if (recipient === account) { + addTransaction(response, { + summary: + 'Swap ' + + slippageAdjustedAmounts[Field.INPUT].toSignificant(3) + + ' ' + + trade.inputAmount.token.symbol + + ' for ' + + slippageAdjustedAmounts[Field.OUTPUT].toSignificant(3) + + ' ' + + trade.outputAmount.token.symbol + }) + } else { + addTransaction(response, { + summary: + 'Swap ' + + slippageAdjustedAmounts[Field.INPUT].toSignificant(3) + + ' ' + + trade.inputAmount.token.symbol + + ' for ' + + slippageAdjustedAmounts[Field.OUTPUT].toSignificant(3) + + ' ' + + trade.outputAmount.token.symbol + + ' to ' + + (ensName ?? recipient) + }) + } + + return response.hash + }) + .catch(error => { + console.error(`Swap or gas estimate failed`, error) + throw error + }) + } + }, [account, allowedSlippage, addTransaction, chainId, deadline, inputAllowance, library, trade, ensName, recipient]) +} diff --git a/src/pages/App.tsx b/src/pages/App.tsx index 8ddfce56d0..6a43eaaecf 100644 --- a/src/pages/App.tsx +++ b/src/pages/App.tsx @@ -9,7 +9,7 @@ import NavigationTabs from '../components/NavigationTabs' import Web3ReactManager from '../components/Web3ReactManager' import Popups from '../components/Popups' -import { isAddress, getAllQueryParams } from '../utils' +import { isAddress } from '../utils' import Swap from './Swap' import Send from './Send' @@ -99,8 +99,6 @@ function GoogleAnalyticsReporter({ location: { pathname, search } }: RouteCompon } export default function App() { - const params = getAllQueryParams() - return ( <> @@ -115,14 +113,12 @@ export default function App() { - {/* this Suspense is for route code-splitting */} - } /> - } /> - } /> - } /> - } /> - } /> + + + + + - + diff --git a/src/pages/Pool/AddLiquidity.tsx b/src/pages/Pool/AddLiquidity.tsx index d1beb72eac..ad2e7845fa 100644 --- a/src/pages/Pool/AddLiquidity.tsx +++ b/src/pages/Pool/AddLiquidity.tsx @@ -47,8 +47,8 @@ const FixedBottom = styled.div` ` enum Field { - INPUT, - OUTPUT + INPUT = 'INPUT', + OUTPUT = 'OUTPUT' } interface AddState { @@ -155,7 +155,7 @@ function AddLiquidity({ token0, token1 }: AddLiquidityProps) { const dependentField: Field = independentField === Field.INPUT ? Field.OUTPUT : Field.INPUT // get basic SDK entities - const tokens: { [field: number]: Token } = { + const tokens: { [field in Field]: Token } = { [Field.INPUT]: useTokenByAddressAndAutomaticallyAdd(fieldData[Field.INPUT].address), [Field.OUTPUT]: useTokenByAddressAndAutomaticallyAdd(fieldData[Field.OUTPUT].address) } @@ -173,7 +173,7 @@ function AddLiquidity({ token0, token1 }: AddLiquidityProps) { (!!pair && JSBI.equal(pair.reserve0.raw, JSBI.BigInt(0)) && JSBI.equal(pair.reserve1.raw, JSBI.BigInt(0))) // get user-pecific and token-specific lookup data - const userBalances: { [field: number]: TokenAmount } = { + const userBalances: { [field in Field]: TokenAmount } = { [Field.INPUT]: useTokenBalanceTreatingWETHasETH(account, tokens[Field.INPUT]), [Field.OUTPUT]: useTokenBalanceTreatingWETHasETH(account, tokens[Field.OUTPUT]) } @@ -443,7 +443,7 @@ function AddLiquidity({ token0, token1 }: AddLiquidityProps) { ReactGA.event({ category: 'Liquidity', action: 'Add', - label: [tokens[Field.INPUT]?.symbol, tokens[Field.OUTPUT]?.symbol].join(';') + label: [tokens[Field.INPUT]?.symbol, tokens[Field.OUTPUT]?.symbol].join('/') }) setTxHash(response.hash) addTransaction(response, { @@ -697,7 +697,7 @@ function AddLiquidity({ token0, token1 }: AddLiquidityProps) { onMax={() => { maxAmountInput && onMax(maxAmountInput.toExact(), Field.INPUT) }} - atMax={atMaxAmountInput} + showMaxButton={!atMaxAmountInput} token={tokens[Field.INPUT]} onTokenSelection={address => onTokenSelection(Field.INPUT, address)} pair={pair} @@ -714,7 +714,7 @@ function AddLiquidity({ token0, token1 }: AddLiquidityProps) { onMax={() => { maxAmountOutput && onMax(maxAmountOutput?.toExact(), Field.OUTPUT) }} - atMax={atMaxAmountOutput} + showMaxButton={!atMaxAmountOutput} token={tokens[Field.OUTPUT]} onTokenSelection={address => onTokenSelection(Field.OUTPUT, address)} pair={pair} diff --git a/src/pages/Pool/RemoveLiquidity.tsx b/src/pages/Pool/RemoveLiquidity.tsx index b92f676d2b..587f3f3050 100644 --- a/src/pages/Pool/RemoveLiquidity.tsx +++ b/src/pages/Pool/RemoveLiquidity.tsx @@ -37,9 +37,9 @@ const ALLOWED_SLIPPAGE = 50 const DEADLINE_FROM_NOW = 60 * 20 enum Field { - LIQUIDITY, - TOKEN0, - TOKEN1 + LIQUIDITY = 'LIQUIDITY', + TOKEN0 = 'TOKEN0', + TOKEN1 = 'TOKEN1' } interface RemoveState { @@ -120,7 +120,7 @@ export default function RemoveLiquidity({ token0, token1 }: { token0: string; to const outputToken: Token = useToken(token1) // get basic SDK entities - const tokens: { [field: number]: Token } = { + const tokens: { [field in Field]?: Token } = { [Field.TOKEN0]: inputToken, [Field.TOKEN1]: outputToken } @@ -136,7 +136,7 @@ export default function RemoveLiquidity({ token0, token1 }: { token0: string; to const [state, dispatch] = useReducer(reducer, initializeRemoveState(userLiquidity?.toExact(), token0, token1)) const { independentField, typedValue } = state - const tokensDeposited: { [field: number]: TokenAmount } = { + const tokensDeposited: { [field in Field]?: TokenAmount } = { [Field.TOKEN0]: pair && totalPoolTokens && @@ -164,7 +164,7 @@ export default function RemoveLiquidity({ token0, token1 }: { token0: string; to dispatch({ type: RemoveAction.TYPE, payload: { field, typedValue } }) }, []) - const parsedAmounts: { [field: number]: TokenAmount } = {} + const parsedAmounts: { [field in Field]?: TokenAmount } = {} let poolTokenAmount try { if (typedValue !== '' && typedValue !== '.' && tokens[Field.TOKEN0] && tokens[Field.TOKEN1] && userLiquidity) { @@ -461,7 +461,7 @@ export default function RemoveLiquidity({ token0, token1 }: { token0: string; to ReactGA.event({ category: 'Liquidity', action: 'Remove', - label: [tokens[Field.TOKEN0]?.symbol, tokens[Field.TOKEN1]?.symbol].join(';') + label: [tokens[Field.TOKEN0]?.symbol, tokens[Field.TOKEN1]?.symbol].join('/') }) setPendingConfirmation(false) setTxHash(response.hash) @@ -680,7 +680,7 @@ export default function RemoveLiquidity({ token0, token1 }: { token0: string; to value={formattedAmounts[Field.LIQUIDITY]} onUserInput={onUserInput} onMax={onMax} - atMax={atMaxAmount} + showMaxButton={!atMaxAmount} disableTokenSelect token={pair?.liquidityToken} isExchange={true} @@ -695,7 +695,7 @@ export default function RemoveLiquidity({ token0, token1 }: { token0: string; to value={formattedAmounts[Field.TOKEN0]} onUserInput={onUserInput} onMax={onMax} - atMax={atMaxAmount} + showMaxButton={!atMaxAmount} token={tokens[Field.TOKEN0]} label={'Output'} disableTokenSelect @@ -709,7 +709,7 @@ export default function RemoveLiquidity({ token0, token1 }: { token0: string; to value={formattedAmounts[Field.TOKEN1]} onUserInput={onUserInput} onMax={onMax} - atMax={atMaxAmount} + showMaxButton={!atMaxAmount} token={tokens[Field.TOKEN1]} label={'Output'} disableTokenSelect diff --git a/src/pages/Send/index.tsx b/src/pages/Send/index.tsx index b2b2ee4961..683d322c4e 100644 --- a/src/pages/Send/index.tsx +++ b/src/pages/Send/index.tsx @@ -1,8 +1,526 @@ -import React from 'react' +import { JSBI, TokenAmount, WETH } from '@uniswap/sdk' +import React, { useContext, useEffect, useState } from 'react' +import { ArrowDown, Repeat } 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' +import { ButtonError, ButtonLight, ButtonPrimary, ButtonSecondary } from '../../components/Button' +import Card, { BlueCard, GreyCard } from '../../components/Card' +import { AutoColumn, ColumnCenter } from '../../components/Column' +import ConfirmationModal from '../../components/ConfirmationModal' +import CurrencyInputPanel from '../../components/CurrencyInputPanel' +import QuestionHelper from '../../components/Question' +import { AutoRow, RowBetween, RowFixed } from '../../components/Row' +import AdvancedSwapDetailsDropdown from '../../components/swap/AdvancedSwapDetailsDropdown' +import FormattedPriceImpact from '../../components/swap/FormattedPriceImpact' +import SwapModalFooter from '../../components/swap/SwapModalFooter' +import { + ArrowWrapper, + BottomGrouping, + Dots, + InputGroup, + StyledBalanceMaxMini, + StyledNumerical, + Wrapper +} from '../../components/swap/styleds' +import { TransferModalHeader } from '../../components/swap/TransferModalHeader' +import V1TradeLink from '../../components/swap/V1TradeLink' +import TokenLogo from '../../components/TokenLogo' +import { DEFAULT_DEADLINE_FROM_NOW, INITIAL_ALLOWED_SLIPPAGE, MIN_ETH } from '../../constants' +import { useWeb3React } from '../../hooks' +import { useApproveCallback } from '../../hooks/useApproveCallback' +import { useSendCallback } from '../../hooks/useSendCallback' +import { useSwapCallback } from '../../hooks/useSwapCallback' +import { useWalletModalToggle } from '../../state/application/hooks' +import { Field } from '../../state/swap/actions' +import { useDefaultsFromURL, useDerivedSwapInfo, useSwapActionHandlers, useSwapState } from '../../state/swap/hooks' +import { useHasPendingApproval } from '../../state/transactions/hooks' +import { useAllTokenBalancesTreatingWETHasETH } from '../../state/wallet/hooks' +import { CursorPointer, TYPE } from '../../theme' +import { Link } from '../../theme/components' +import { computeSlippageAdjustedAmounts, computeTradePriceBreakdown, warningServerity } from '../../utils/prices' -import ExchangePage from '../../components/ExchangePage' -import { QueryParams } from '../../utils' +export default function Send({ history, location: { search } }: RouteComponentProps) { + useDefaultsFromURL(search) -export default function Send({ params }: { params: QueryParams }) { - return + // text translation + // const { t } = useTranslation() + const { chainId, account } = useWeb3React() + const theme = useContext(ThemeContext) + + // toggle wallet when disconnected + const toggleWalletModal = useWalletModalToggle() + + // sending state + const [sendingWithSwap, setSendingWithSwap] = useState(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 } = useSwapState() + const { parsedAmounts, bestTrade, tokenBalances, tokens, error: swapError } = useDerivedSwapInfo() + const isSwapValid = !swapError && !recipientError && bestTrade + + const dependentField: Field = independentField === Field.INPUT ? Field.OUTPUT : Field.INPUT + + // modal and loading + const [showConfirm, setShowConfirm] = useState(false) + const [showAdvanced, setShowAdvanced] = useState(false) + const [attemptingTxn, setAttemptingTxn] = useState(false) // clicked confirmed + const [pendingConfirmation, setPendingConfirmation] = useState(true) // waiting for user confirmation + + // txn values + const [txHash, setTxHash] = useState('') + const [deadline, setDeadline] = useState(DEFAULT_DEADLINE_FROM_NOW) + const [allowedSlippage, setAllowedSlippage] = useState(INITIAL_ALLOWED_SLIPPAGE) + + 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 [mustApprove, approveCallback] = useApproveCallback(bestTrade, allowedSlippage) + const pendingApprovalInput = useHasPendingApproval(tokens[Field.INPUT]?.address) + + 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() + + // reset field if sending with with swap is cancled + useEffect(() => { + if (!sendingWithSwap) { + onTokenSelection(Field.OUTPUT, null) + } + }, [onTokenSelection, sendingWithSwap]) + + 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 + + // reset modal state when closed + function resetModal() { + // clear input if txn submitted + if (!pendingConfirmation) { + onUserInput(Field.INPUT, '') + } + setPendingConfirmation(true) + setAttemptingTxn(false) + setShowAdvanced(false) + } + + const swapCallback = useSwapCallback(bestTrade, allowedSlippage, deadline, recipient) + + function onSwap() { + setAttemptingTxn(true) + swapCallback().then(hash => { + setTxHash(hash) + setPendingConfirmation(false) + + ReactGA.event({ + category: 'Send', + action: recipient === account ? 'Swap w/o Send' : 'Swap w/ Send', + label: [bestTrade.inputAmount.token.symbol, bestTrade.outputAmount.token.symbol].join(';') + }) + }) + } + + const sendCallback = useSendCallback(parsedAmounts?.[Field.INPUT], recipient) + const isSendValid = sendCallback !== null && (sendingWithSwap === false || mustApprove === false) + + async function onSend() { + setAttemptingTxn(true) + + sendCallback() + .then(hash => { + setTxHash(hash) + ReactGA.event({ category: 'Swap', action: 'Send', label: tokens[Field.INPUT]?.symbol }) + setPendingConfirmation(false) + }) + .catch(() => { + resetModal() + setShowConfirm(false) + }) + } + + const [showInverted, setShowInverted] = useState(false) + + // warnings on slippage + const severity = !sendingWithSwap ? 0 : warningServerity(priceImpactWithoutFee) + + 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?.[account]?.[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 ( + + { + resetModal() + setShowConfirm(false) + }} + attemptingTxn={attemptingTxn} + pendingConfirmation={pendingConfirmation} + 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 => onTokenSelection(Field.INPUT, address)} + otherSelectedTokenAddress={tokens[Field.OUTPUT]?.address} + id="swap-currency-input" + /> + + {sendingWithSwap ? ( + + + + + + setSendingWithSwap(false)} + style={{ marginRight: '0px', width: 'fit-content', 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) + } + }} + /> + + {!noRoute && tokens[Field.OUTPUT] && tokens[Field.INPUT] && ( + + + + + Price + + + {bestTrade && showInverted + ? (bestTrade?.executionPrice?.invert()?.toSignificant(6) ?? '') + + ' ' + + tokens[Field.INPUT]?.symbol + + ' per ' + + tokens[Field.OUTPUT]?.symbol + : (bestTrade?.executionPrice?.toSignificant(6) ?? '') + + ' ' + + tokens[Field.OUTPUT]?.symbol + + ' per ' + + tokens[Field.INPUT]?.symbol} + setShowInverted(!showInverted)}> + + + + + + {bestTrade && severity > 1 && ( + + + Price Impact + + + + + + + )} + + + )} + + + {!account ? ( + { + toggleWalletModal() + }} + > + Connect Wallet + + ) : noRoute && userHasSpecifiedInputOutput ? ( + + Insufficient liquidity for this trade. + { + history.push('/add/' + tokens[Field.INPUT]?.address + '-' + tokens[Field.OUTPUT]?.address) + }} + > + Add liquidity now. + + + ) : mustApprove === true ? ( + + {pendingApprovalInput ? ( + Approving {tokens[Field.INPUT]?.symbol} + ) : ( + 'Approve ' + tokens[Field.INPUT]?.symbol + )} + + ) : ( + { + setShowConfirm(true) + }} + id="send-button" + disabled={(sendingWithSwap && !isSwapValid) || (!sendingWithSwap && !isSendValid)} + error={sendingWithSwap && isSwapValid && severity > 2} + > + + {(sendingWithSwap ? swapError : null) || + sendAmountError || + recipientError || + `Send${severity > 2 ? ' Anyway' : ''}`} + + + )} + + + {bestTrade && ( + + )} + + ) } diff --git a/src/pages/Swap/index.tsx b/src/pages/Swap/index.tsx index e8a655d4dc..589da2d15c 100644 --- a/src/pages/Swap/index.tsx +++ b/src/pages/Swap/index.tsx @@ -1,7 +1,327 @@ -import React from 'react' -import ExchangePage from '../../components/ExchangePage' -import { QueryParams } from '../../utils' +import { JSBI, TokenAmount, WETH } from '@uniswap/sdk' +import React, { useContext, useState } from 'react' +import { ArrowDown, Repeat } 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 } 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 QuestionHelper from '../../components/Question' +import { RowBetween, RowFixed } from '../../components/Row' +import AdvancedSwapDetailsDropdown from '../../components/swap/AdvancedSwapDetailsDropdown' +import FormattedPriceImpact from '../../components/swap/FormattedPriceImpact' +import { ArrowWrapper, BottomGrouping, Dots, StyledBalanceMaxMini, Wrapper } from '../../components/swap/styleds' +import SwapModalFooter from '../../components/swap/SwapModalFooter' +import V1TradeLink from '../../components/swap/V1TradeLink' +import { DEFAULT_DEADLINE_FROM_NOW, INITIAL_ALLOWED_SLIPPAGE, MIN_ETH } from '../../constants' +import { useWeb3React } from '../../hooks' +import { useApproveCallback } from '../../hooks/useApproveCallback' +import { useSwapCallback } from '../../hooks/useSwapCallback' +import { useWalletModalToggle } from '../../state/application/hooks' +import { Field } from '../../state/swap/actions' +import { useDefaultsFromURL, useDerivedSwapInfo, useSwapActionHandlers, useSwapState } from '../../state/swap/hooks' +import { useHasPendingApproval } from '../../state/transactions/hooks' +import { CursorPointer, Link, TYPE } from '../../theme' +import { computeSlippageAdjustedAmounts, computeTradePriceBreakdown, warningServerity } from '../../utils/prices' +import SwapModalHeader from '../../components/swap/SwapModalHeader' -export default function Swap({ params }: { params: QueryParams }) { - return +export default function Swap({ history, location: { search } }: RouteComponentProps) { + useDefaultsFromURL(search) + // text translation + // const { t } = useTranslation() + const { chainId, account } = useWeb3React() + const theme = useContext(ThemeContext) + + // toggle wallet when disconnected + const toggleWalletModal = useWalletModalToggle() + + const { independentField, typedValue } = useSwapState() + const { bestTrade, tokenBalances, parsedAmounts, tokens, error } = useDerivedSwapInfo() + const isValid = !error + const dependentField: Field = independentField === Field.INPUT ? Field.OUTPUT : Field.INPUT + + // modal and loading + const [showConfirm, setShowConfirm] = useState(false) + const [showAdvanced, setShowAdvanced] = useState(false) + const [attemptingTxn, setAttemptingTxn] = useState(false) // clicked confirmed + const [pendingConfirmation, setPendingConfirmation] = useState(true) // waiting for user confirmation + + // txn values + const [txHash, setTxHash] = useState('') + const [deadline, setDeadline] = useState(DEFAULT_DEADLINE_FROM_NOW) + const [allowedSlippage, setAllowedSlippage] = useState(INITIAL_ALLOWED_SLIPPAGE) + + 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 [mustApprove, approveCallback] = useApproveCallback(bestTrade, allowedSlippage) + const pendingApprovalInput = useHasPendingApproval(tokens[Field.INPUT]?.address) + + const formattedAmounts = { + [independentField]: typedValue, + [dependentField]: parsedAmounts[dependentField] ? parsedAmounts[dependentField].toSignificant(6) : '' + } + + 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 slippageAdjustedAmounts = computeSlippageAdjustedAmounts(bestTrade, allowedSlippage) + + // reset modal state when closed + function resetModal() { + // clear input if txn submitted + if (!pendingConfirmation) { + onUserInput(Field.INPUT, '') + } + setPendingConfirmation(true) + setAttemptingTxn(false) + setShowAdvanced(false) + } + + // the callback to execute the swap + const swapCallback = useSwapCallback(bestTrade, allowedSlippage, deadline) + + function onSwap() { + setAttemptingTxn(true) + swapCallback().then(hash => { + setTxHash(hash) + setPendingConfirmation(false) + + ReactGA.event({ + category: 'Swap', + action: 'Swap w/o Send', + label: [bestTrade.inputAmount.token.symbol, bestTrade.outputAmount.token.symbol].join('/') + }) + }) + } + + // errors + const [showInverted, setShowInverted] = useState(false) + + const { priceImpactWithoutFee, realizedLPFee } = computeTradePriceBreakdown(bestTrade) + + // warnings on slippage + const priceImpactSeverity = warningServerity(priceImpactWithoutFee) + + function modalHeader() { + return ( + + ) + } + + function modalBottom() { + return ( + 2 ? 'Swap Anyway' : 'Confirm Swap'} + showInverted={showInverted} + severity={priceImpactSeverity} + setShowInverted={setShowInverted} + onSwap={onSwap} + realizedLPFee={realizedLPFee} + parsedAmounts={parsedAmounts} + priceImpactWithoutFee={priceImpactWithoutFee} + slippageAdjustedAmounts={slippageAdjustedAmounts} + trade={bestTrade} + /> + ) + } + + // text to show while loading + const pendingText = `Swapping ${parsedAmounts[Field.INPUT]?.toSignificant(6)} ${ + tokens[Field.INPUT]?.symbol + } for ${parsedAmounts[Field.OUTPUT]?.toSignificant(6)} ${tokens[Field.OUTPUT]?.symbol}` + + return ( + + { + resetModal() + setShowConfirm(false) + }} + attemptingTxn={attemptingTxn} + pendingConfirmation={pendingConfirmation} + hash={txHash} + topContent={modalHeader} + bottomContent={modalBottom} + pendingText={pendingText} + /> + + + <> + { + maxAmountInput && onUserInput(Field.INPUT, maxAmountInput.toExact()) + }} + onTokenSelection={address => onTokenSelection(Field.INPUT, address)} + otherSelectedTokenAddress={tokens[Field.OUTPUT]?.address} + id="swap-currency-input" + /> + + + + + + + + + + onTokenSelection(Field.OUTPUT, address)} + otherSelectedTokenAddress={tokens[Field.INPUT]?.address} + id="swap-currency-output" + /> + + + {!noRoute && tokens[Field.OUTPUT] && tokens[Field.INPUT] && ( + + + + + Price + + + {bestTrade && showInverted + ? (bestTrade?.executionPrice?.invert()?.toSignificant(6) ?? '') + + ' ' + + tokens[Field.INPUT]?.symbol + + ' per ' + + tokens[Field.OUTPUT]?.symbol + : (bestTrade?.executionPrice?.toSignificant(6) ?? '') + + ' ' + + tokens[Field.OUTPUT]?.symbol + + ' per ' + + tokens[Field.INPUT]?.symbol} + setShowInverted(!showInverted)}> + + + + + + {bestTrade && priceImpactSeverity > 1 && ( + + + Price Impact + + + + + + + )} + + + )} + + + {!account ? ( + { + toggleWalletModal() + }} + > + Connect Wallet + + ) : noRoute && userHasSpecifiedInputOutput ? ( + + Insufficient liquidity for this trade. + { + history.push('/add/' + tokens[Field.INPUT]?.address + '-' + tokens[Field.OUTPUT]?.address) + }} + > + {' '} + Add liquidity now. + + + ) : mustApprove === true ? ( + + {pendingApprovalInput ? ( + Approving {tokens[Field.INPUT]?.symbol} + ) : ( + 'Approve ' + tokens[Field.INPUT]?.symbol + )} + + ) : ( + { + setShowConfirm(true) + }} + id="swap-button" + disabled={!isValid} + error={isValid && priceImpactSeverity > 2} + > + + {error ?? `Swap${priceImpactSeverity > 2 ? ' Anyway' : ''}`} + + + )} + + + {bestTrade && ( + + )} + + ) } diff --git a/src/state/application/actions.ts b/src/state/application/actions.ts index c5281be313..205fb57926 100644 --- a/src/state/application/actions.ts +++ b/src/state/application/actions.ts @@ -23,6 +23,5 @@ export type PopupContent = export const updateBlockNumber = createAction<{ networkId: number; blockNumber: number | null }>('updateBlockNumber') export const toggleWalletModal = createAction('toggleWalletModal') -export const toggleUserAdvanced = createAction('toggleUserAdvanced') export const addPopup = createAction<{ content: PopupContent }>('addPopup') export const removePopup = createAction<{ key: string }>('removePopup') diff --git a/src/state/application/hooks.ts b/src/state/application/hooks.ts index da843976ce..5964c81f10 100644 --- a/src/state/application/hooks.ts +++ b/src/state/application/hooks.ts @@ -19,10 +19,6 @@ export function useWalletModalToggle() { return useCallback(() => dispatch(toggleWalletModal()), [dispatch]) } -export function useUserAdvanced() { - return useSelector((state: AppState) => state.application.userAdvanced) -} - // returns a function that allows adding a popup export function useAddPopup(): (content: PopupContent) => void { const dispatch = useDispatch() diff --git a/src/state/application/reducer.ts b/src/state/application/reducer.ts index 9f55519351..644d9c1cff 100644 --- a/src/state/application/reducer.ts +++ b/src/state/application/reducer.ts @@ -1,12 +1,5 @@ import { createReducer, nanoid } from '@reduxjs/toolkit' -import { - addPopup, - PopupContent, - removePopup, - toggleUserAdvanced, - toggleWalletModal, - updateBlockNumber -} from './actions' +import { addPopup, PopupContent, removePopup, toggleWalletModal, updateBlockNumber } from './actions' type PopupList = Array<{ key: string; show: boolean; content: PopupContent }> @@ -14,14 +7,12 @@ interface ApplicationState { blockNumber: { [chainId: number]: number } popupList: PopupList walletModalOpen: boolean - userAdvanced: boolean } const initialState: ApplicationState = { blockNumber: {}, popupList: [], - walletModalOpen: false, - userAdvanced: false + walletModalOpen: false } export default createReducer(initialState, builder => @@ -30,9 +21,6 @@ export default createReducer(initialState, builder => const { networkId, blockNumber } = action.payload state.blockNumber[networkId] = blockNumber }) - .addCase(toggleUserAdvanced, state => { - state.userAdvanced = !state.userAdvanced - }) .addCase(toggleWalletModal, state => { state.walletModalOpen = !state.walletModalOpen }) diff --git a/src/state/index.ts b/src/state/index.ts index 2644557587..3ffaa06e99 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -3,6 +3,7 @@ import application from './application/reducer' import { updateVersion } from './user/actions' import user from './user/reducer' import wallet from './wallet/reducer' +import swap from './swap/reducer' import transactions from './transactions/reducer' import { save, load } from 'redux-localstorage-simple' @@ -13,7 +14,8 @@ const store = configureStore({ application, user, transactions, - wallet + wallet, + swap }, middleware: [...getDefaultMiddleware(), save({ states: PERSISTED_KEYS })], preloadedState: load({ states: PERSISTED_KEYS }) diff --git a/src/state/swap/actions.ts b/src/state/swap/actions.ts new file mode 100644 index 0000000000..2281b649b4 --- /dev/null +++ b/src/state/swap/actions.ts @@ -0,0 +1,11 @@ +import { createAction } from '@reduxjs/toolkit' + +export enum Field { + INPUT = 'INPUT', + OUTPUT = 'OUTPUT' +} + +export const setDefaultsFromURL = 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') diff --git a/src/state/swap/hooks.ts b/src/state/swap/hooks.ts new file mode 100644 index 0000000000..c5d555bf91 --- /dev/null +++ b/src/state/swap/hooks.ts @@ -0,0 +1,147 @@ +import { parseUnits } from '@ethersproject/units' +import { JSBI, Token, TokenAmount, Trade } from '@uniswap/sdk' +import { useCallback, useEffect } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { useWeb3React } from '../../hooks' +import { useTokenByAddressAndAutomaticallyAdd } from '../../hooks/Tokens' +import { useTradeExactIn, useTradeExactOut } from '../../hooks/Trades' +import { AppDispatch, AppState } from '../index' +import { useTokenBalancesTreatWETHAsETH } from '../wallet/hooks' +import { Field, selectToken, setDefaultsFromURL, switchTokens, typeInput } from './actions' + +export function useSwapState(): AppState['swap'] { + return useSelector(state => state.swap) +} + +export function useSwapActionHandlers(): { + onTokenSelection: (field: Field, address: string) => void + onSwitchTokens: () => void + onUserInput: (field: Field, typedValue: string) => void +} { + const dispatch = useDispatch() + const onTokenSelection = useCallback( + (field: Field, address: string) => { + dispatch( + selectToken({ + field, + address + }) + ) + }, + [dispatch] + ) + + const onSwapTokens = useCallback(() => { + dispatch(switchTokens()) + }, [dispatch]) + + const onUserInput = useCallback( + (field: Field, typedValue: string) => { + dispatch(typeInput({ field, typedValue })) + }, + [dispatch] + ) + + return { + onSwitchTokens: onSwapTokens, + onTokenSelection, + onUserInput + } +} + +// try to parse a user entered amount for a given token +function tryParseAmount(value?: string, token?: Token): TokenAmount | undefined { + if (!value || !token) return + try { + const typedValueParsed = parseUnits(value, token.decimals).toString() + if (typedValueParsed !== '0') return new TokenAmount(token, JSBI.BigInt(typedValueParsed)) + } catch (error) { + // should fail if the user specifies too many decimal places of precision (or maybe exceed max uint?) + console.debug(`Failed to parse input amount: "${value}"`, error) + } +} + +// from the current swap inputs, compute the best trade and return it. +export function useDerivedSwapInfo(): { + tokens: { [field in Field]?: Token } + tokenBalances: { [field in Field]?: TokenAmount } + parsedAmounts: { [field in Field]?: TokenAmount } + bestTrade?: Trade + error?: string +} { + const { account } = useWeb3React() + + const { + independentField, + typedValue, + [Field.INPUT]: { address: tokenInAddress }, + [Field.OUTPUT]: { address: tokenOutAddress } + } = useSwapState() + + const tokenIn = useTokenByAddressAndAutomaticallyAdd(tokenInAddress) + const tokenOut = useTokenByAddressAndAutomaticallyAdd(tokenOutAddress) + + const relevantTokenBalances = useTokenBalancesTreatWETHAsETH(account, [tokenIn, tokenOut]) + + const isExactIn: boolean = independentField === Field.INPUT + const amount = tryParseAmount(typedValue, isExactIn ? tokenIn : tokenOut) + + const bestTradeExactIn = useTradeExactIn(isExactIn ? amount : null, tokenOut) + const bestTradeExactOut = useTradeExactOut(tokenIn, !isExactIn ? amount : null) + + 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.INPUT]: tokenIn, + [Field.OUTPUT]: tokenOut + } + + let error: string | undefined + if (!account) { + error = 'Connect Wallet' + } + + if (!parsedAmounts[Field.INPUT]) { + error = error ?? 'Enter an amount' + } + + if (!parsedAmounts[Field.OUTPUT]) { + error = error ?? 'Enter an amount' + } + + if ( + tokenBalances[Field.INPUT] && + parsedAmounts[Field.INPUT] && + tokenBalances[Field.INPUT].lessThan(parsedAmounts[Field.INPUT]) + ) { + error = 'Insufficient ' + tokens[Field.INPUT]?.symbol + ' balance' + } + + return { + tokens, + tokenBalances, + parsedAmounts, + bestTrade, + error + } +} + +// updates the swap state to use the defaults for a given network whenever the query +// string updates +export function useDefaultsFromURL(search?: string) { + const { chainId } = useWeb3React() + const dispatch = useDispatch() + useEffect(() => { + dispatch(setDefaultsFromURL({ chainId, queryString: search })) + }, [dispatch, search, chainId]) +} diff --git a/src/state/swap/reducer.ts b/src/state/swap/reducer.ts new file mode 100644 index 0000000000..695b2635b1 --- /dev/null +++ b/src/state/swap/reducer.ts @@ -0,0 +1,96 @@ +import { parse } from 'qs' +import { createReducer } from '@reduxjs/toolkit' +import { WETH } from '@uniswap/sdk' +import { isAddress } from '../../utils' +import { Field, selectToken, setDefaultsFromURL, switchTokens, typeInput } from './actions' + +export interface SwapState { + readonly independentField: Field + readonly typedValue: string + readonly [Field.INPUT]: { + readonly address: string | undefined + } + readonly [Field.OUTPUT]: { + readonly address: string | undefined + } +} + +const initialState: SwapState = { + independentField: Field.INPUT, + typedValue: '', + [Field.INPUT]: { + address: '' + }, + [Field.OUTPUT]: { + address: '' + } +} + +function parseTokenURL(input: any, chainId: number): string { + if (typeof input !== 'string') return '' + const valid = isAddress(input) + if (valid) return valid + if (input.toLowerCase() === 'eth') return WETH[chainId]?.address ?? '' + return '' +} + +export default createReducer(initialState, builder => + builder + .addCase(setDefaultsFromURL, (state, { payload: { queryString, chainId } }) => { + if (queryString && queryString.length > 1) { + const result = parse(queryString.substr(1), { parseArrays: false }) + const inToken = parseTokenURL(result.inputToken, chainId) + const outToken = parseTokenURL(result.outputToken, chainId) + return { + [Field.INPUT]: { + address: inToken + }, + [Field.OUTPUT]: { + address: inToken === outToken ? '' : outToken + }, + typedValue: typeof result.amount === 'string' ? result.amount : '', + independentField: result.exact === 'out' ? Field.OUTPUT : Field.INPUT + } + } + + return { + ...initialState, + [Field.INPUT]: { + address: WETH[chainId]?.address + } + } + }) + .addCase(selectToken, (state, { payload: { address, field } }) => { + const otherField = field === Field.INPUT ? Field.OUTPUT : Field.INPUT + if (address === state[otherField].address) { + // the case where we have to swap the order + return { + ...state, + independentField: state.independentField === Field.INPUT ? Field.OUTPUT : Field.INPUT, + [field]: { address }, + [otherField]: { address: state[field].address } + } + } else { + // the normal case + return { + ...state, + [field]: { address } + } + } + }) + .addCase(switchTokens, state => { + return { + ...state, + independentField: state.independentField === Field.INPUT ? Field.OUTPUT : Field.INPUT, + [Field.INPUT]: { address: state[Field.OUTPUT].address }, + [Field.OUTPUT]: { address: state[Field.INPUT].address } + } + }) + .addCase(typeInput, (state, { payload: { field, typedValue } }) => { + return { + ...state, + independentField: field, + typedValue + } + }) +) diff --git a/src/theme/components.tsx b/src/theme/components.tsx index 239b520177..0ea80b87ae 100644 --- a/src/theme/components.tsx +++ b/src/theme/components.tsx @@ -107,7 +107,7 @@ export const Spinner = styled.img` height: 16px; ` -export const Hover = styled.div` +export const CursorPointer = styled.div` :hover { cursor: pointer; } diff --git a/src/utils/index.ts b/src/utils/index.ts index 74030cfcd6..afa39e966e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -54,38 +54,6 @@ export function getQueryParam(windowLocation: Location, name: string): string | return q && q[1] } -function parseUrlAddress(param: string): string { - const addr = isAddress(getQueryParam(window.location, param)) - if (addr === false) { - return '' - } - return addr -} - -function parseUrlTokenAmount(paramName: string): string { - const value = getQueryParam(window.location, paramName) - if (!isNaN(Number(value))) { - return '' - } - return value -} - -export interface QueryParams { - readonly inputTokenAddress: string - readonly outputTokenAddress: string - readonly inputTokenAmount: string - readonly outputTokenAmount: string -} - -export function getAllQueryParams(): QueryParams { - return { - inputTokenAddress: parseUrlAddress('inputTokenAddress'), - outputTokenAddress: parseUrlAddress('outputTokenAddress'), - inputTokenAmount: parseUrlTokenAmount('inputTokenAmount'), - outputTokenAmount: parseUrlTokenAmount('outputTokenAmount') - } -} - // shorten the checksummed version of the input address to have 0x + 4 characters at start and end export function shortenAddress(address: string, chars = 4): string { const parsed = isAddress(address) @@ -195,23 +163,6 @@ export async function getTokenDecimals(tokenAddress, library) { }) } -// get the ether balance of an address -export async function getEtherBalance(address, library) { - if (!isAddress(address)) { - throw Error(`Invalid 'address' parameter '${address}'`) - } - return library.getBalance(address) -} - -// get the token balance of an address -export async function getTokenBalance(tokenAddress, address, library) { - if (!isAddress(tokenAddress) || !isAddress(address)) { - throw Error(`Invalid 'tokenAddress' or 'address' parameter '${tokenAddress}' or '${address}'.`) - } - - return getContract(tokenAddress, ERC20_ABI, library).balanceOf(address) -} - export function escapeRegExp(string: string): string { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string } diff --git a/src/utils/prices.ts b/src/utils/prices.ts new file mode 100644 index 0000000000..d5718834db --- /dev/null +++ b/src/utils/prices.ts @@ -0,0 +1,67 @@ +import { Fraction, JSBI, Percent, TokenAmount, Trade } from '@uniswap/sdk' +import { ALLOWED_SLIPPAGE_HIGH, ALLOWED_SLIPPAGE_LOW, ALLOWED_SLIPPAGE_MEDIUM } from '../constants' +import { Field } from '../state/swap/actions' +import { basisPointsToPercent } from './index' + +const BASE_FEE = new Percent(JSBI.BigInt(30), JSBI.BigInt(10000)) +const ONE_HUNDRED_PERCENT = new Percent(JSBI.BigInt(10000), JSBI.BigInt(10000)) +const INPUT_FRACTION_AFTER_FEE = ONE_HUNDRED_PERCENT.subtract(BASE_FEE) +// computes price breakdown for the trade +export function computeTradePriceBreakdown( + trade?: Trade +): { priceImpactWithoutFee?: Percent; realizedLPFee?: TokenAmount } { + // for each hop in our trade, take away the x*y=k price impact from 0.3% fees + // e.g. for 3 tokens/2 hops: 1 - ((1 - .03) * (1-.03)) + const realizedLPFee = !trade + ? undefined + : ONE_HUNDRED_PERCENT.subtract( + trade.route.pairs.reduce( + (currentFee: Fraction): Fraction => currentFee.multiply(INPUT_FRACTION_AFTER_FEE), + INPUT_FRACTION_AFTER_FEE + ) + ) + + // remove lp fees from price impact + const priceImpactWithoutFeeFraction = trade?.slippage?.subtract(realizedLPFee) + + // the x*y=k impact + const priceImpactWithoutFeePercent = priceImpactWithoutFeeFraction + ? new Percent(priceImpactWithoutFeeFraction?.numerator, priceImpactWithoutFeeFraction?.denominator) + : undefined + + // the amount of the input that accrues to LPs + const realizedLPFeeAmount = + realizedLPFee && new TokenAmount(trade.inputAmount.token, realizedLPFee.multiply(trade.inputAmount.raw).quotient) + + return { priceImpactWithoutFee: priceImpactWithoutFeePercent, realizedLPFee: realizedLPFeeAmount } +} + +// computes the minimum amount out and maximum amount in for a trade given a user specified allowed slippage in bips +export function computeSlippageAdjustedAmounts( + trade: Trade, + allowedSlippage: number +): { [field in Field]?: TokenAmount } { + const pct = basisPointsToPercent(allowedSlippage) + return { + [Field.INPUT]: trade?.maximumAmountIn(pct), + [Field.OUTPUT]: trade?.minimumAmountOut(pct) + } +} + +export function warningServerity(priceImpact: Percent): 0 | 1 | 2 | 3 { + if (!priceImpact?.lessThan(ALLOWED_SLIPPAGE_HIGH)) return 3 + if (!priceImpact?.lessThan(ALLOWED_SLIPPAGE_MEDIUM)) return 2 + if (!priceImpact?.lessThan(ALLOWED_SLIPPAGE_LOW)) return 1 + return 0 +} + +export function formatExecutionPrice(trade?: Trade, inverted?: boolean): string { + if (!trade) { + return '' + } + return inverted + ? `${trade.executionPrice.invert().toSignificant(6)} ${trade.inputAmount.token.symbol} / ${ + trade.outputAmount.token.symbol + }` + : `${trade.executionPrice.toSignificant(6)} ${trade.outputAmount.token.symbol} / ${trade.inputAmount.token.symbol}` +} diff --git a/yarn.lock b/yarn.lock index 5d820f454a..f84684bdc6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2897,6 +2897,11 @@ resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8" integrity sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw== +"@types/qs@^6.9.2": + version "6.9.2" + resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.2.tgz#faab98ec4f96ee72c829b7ec0983af4f4d343113" + integrity sha512-a9bDi4Z3zCZf4Lv1X/vwnvbbDYSNz59h3i3KdyuYYN+YrLjSeJD0dnphdULDfySvUv6Exy/O0K6wX/kQpnPQ+A== + "@types/react-dom@^16.9.7": version "16.9.7" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.7.tgz#60844d48ce252d7b2dccf0c7bb937130e27c0cd2" @@ -14307,6 +14312,11 @@ qs@6.7.0: resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ== +qs@^6.9.4: + version "6.9.4" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.4.tgz#9090b290d1f91728d3c22e54843ca44aea5ab687" + integrity sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ== + qs@~6.5.2: version "6.5.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"