diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index e4444493ab..ec2f55b1da 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -244,6 +244,9 @@ const ButtonErrorStyle = styled(Base)` &:disabled { opacity: 50%; cursor: auto; + box-shadow: none; + background-color: ${({ theme }) => theme.red1}; + border: 1px solid ${({ theme }) => theme.red1}; } ` diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx index 3298d2866a..a61352e7ca 100644 --- a/src/components/Header/index.tsx +++ b/src/components/Header/index.tsx @@ -17,6 +17,7 @@ import { useTokenBalanceTreatingWETHasETH } from '../../state/wallet/hooks' import { ExternalLink, StyledInternalLink } from '../../theme' import { YellowCard } from '../Card' import { AutoColumn } from '../Column' +import Settings from '../Settings' import Menu from '../Menu' import Row, { RowBetween } from '../Row' @@ -31,15 +32,12 @@ const HeaderFrame = styled.div` width: 100%; top: 0; position: absolute; - - pointer-events: none; - + z-index: 2; ${({ theme }) => theme.mediaWidth.upToExtraSmall` padding: 12px 0 0 0; width: calc(100%); position: relative; `}; - z-index: 2; ` const HeaderElement = styled.div` @@ -130,6 +128,12 @@ const NETWORK_LABELS: { [chainId in ChainId]: string | null } = { [ChainId.KOVAN]: 'Kovan' } +const BalanceWrapper = styled.div` + ${({ theme }) => theme.mediaWidth.upToExtraSmall` + display: none; + `}; +` + export default function Header() { const { account, chainId } = useActiveWeb3React() @@ -174,16 +178,17 @@ export default function Header() { {!isMobile && NETWORK_LABELS[chainId] && {NETWORK_LABELS[chainId]}} - {account && userEthBalance ? ( - - {userEthBalance?.toSignificant(4)} ETH - - ) : null} + + {account && userEthBalance ? ( + + {userEthBalance?.toSignificant(4)} ETH + + ) : null} + -
- -
+ + diff --git a/src/components/Settings/index.tsx b/src/components/Settings/index.tsx new file mode 100644 index 0000000000..cd9b2f4880 --- /dev/null +++ b/src/components/Settings/index.tsx @@ -0,0 +1,253 @@ +import React, { useRef, useEffect, useContext, useState } from 'react' +import { Settings, X } from 'react-feather' +import styled from 'styled-components' + +import { + useUserSlippageTolerance, + useExpertModeManager, + useUserDeadline, + useDarkModeManager +} from '../../state/user/hooks' +import SlippageTabs from '../SlippageTabs' +import { RowFixed, RowBetween } from '../Row' +import { TYPE } from '../../theme' +import QuestionHelper from '../QuestionHelper' +import Toggle from '../Toggle' +import { ThemeContext } from 'styled-components' +import { AutoColumn } from '../Column' +import { ButtonError } from '../Button' +import { useSettingsMenuOpen, useToggleSettingsMenu } from '../../state/application/hooks' +import { Text } from 'rebass' +import Modal from '../Modal' + +const StyledMenuIcon = styled(Settings)` + height: 20px; + width: 20px; + + > * { + stroke: ${({ theme }) => theme.text1}; + } +` + +const StyledCloseIcon = styled(X)` + height: 20px; + width: 20px; + :hover { + cursor: pointer; + } + + > * { + stroke: ${({ theme }) => theme.text1}; + } +` + +const StyledMenuButton = styled.button` + position: relative; + width: 100%; + height: 100%; + border: none; + background-color: transparent; + margin: 0; + padding: 0; + height: 35px; + background-color: ${({ theme }) => theme.bg3}; + + padding: 0.15rem 0.5rem; + border-radius: 0.5rem; + + :hover, + :focus { + cursor: pointer; + outline: none; + background-color: ${({ theme }) => theme.bg4}; + } + + svg { + margin-top: 2px; + } +` +const EmojiWrapper = styled.div` + position: absolute; + bottom: -6px; + right: 0px; + font-size: 14px; +` + +const StyledMenu = styled.div` + margin-left: 0.5rem; + display: flex; + justify-content: center; + align-items: center; + position: relative; + border: none; + text-align: left; +` + +const MenuFlyout = styled.span` + min-width: 20.125rem; + background-color: ${({ theme }) => theme.bg1}; + box-shadow: 0px 0px 1px rgba(0, 0, 0, 0.01), 0px 4px 8px rgba(0, 0, 0, 0.04), 0px 16px 24px rgba(0, 0, 0, 0.04), + 0px 24px 32px rgba(0, 0, 0, 0.01); + border-radius: 0.5rem; + display: flex; + flex-direction: column; + font-size: 1rem; + position: absolute; + top: 3rem; + right: 0rem; + z-index: 100; + + ${({ theme }) => theme.mediaWidth.upToExtraSmall` + min-width: 18.125rem; + right: -46px; + `}; +` + +const Break = styled.div` + width: 100%; + height: 1px; + background-color: ${({ theme }) => theme.bg3}; +` + +const ModalContentWrapper = styled.div` + display: flex; + align-items: center; + justify-content: center; + padding: 2rem 0; + background-color: ${({ theme }) => theme.bg2}; + border-radius: 20px; +` + +export default function SettingsTab() { + const node = useRef() + const open = useSettingsMenuOpen() + const toggle = useToggleSettingsMenu() + + const theme = useContext(ThemeContext) + const [userSlippageTolerance, setUserslippageTolerance] = useUserSlippageTolerance() + + const [deadline, setDeadline] = useUserDeadline() + + const [expertMode, toggleExpertMode] = useExpertModeManager() + + const [darkMode, toggleDarkMode] = useDarkModeManager() + + // show confirmation view before turning on + const [showConfirmation, setShowConfirmation] = useState(false) + + useEffect(() => { + const handleClickOutside = e => { + if (node.current?.contains(e.target) ?? false) { + return + } + toggle() + } + + if (open) { + document.addEventListener('mousedown', handleClickOutside) + } else { + document.removeEventListener('mousedown', handleClickOutside) + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, [open, toggle]) + + return ( + + setShowConfirmation(false)}> + + + +
+ + Are you sure? + + setShowConfirmation(false)} /> + + + + + Expert mode turns off the confirm transaction prompt and allows high slippage trades that often result + in bad rates and lost funds. + + + ONLY USE THIS MODE IF YOU KNOW WHAT YOU ARE DOING. + + { + if (window.prompt(`Please type the word "confirm" to enable expert mode.`) === 'confirm') { + toggleExpertMode() + setShowConfirmation(false) + } + }} + > + + Turn On Expert Mode + + + + + + + + + {expertMode && ( + + + 🧙 + + + )} + + {open && ( + + + + Transaction Settings + + + + Interface Settings + + + + + Toggle Expert Mode + + + + { + toggleExpertMode() + setShowConfirmation(false) + } + : () => setShowConfirmation(true) + } + /> + + + + + Toggle Dark Mode + + + + + + + )} + + ) +} diff --git a/src/components/SlippageTabs/index.tsx b/src/components/SlippageTabs/index.tsx index e9c159bb91..554a40898d 100644 --- a/src/components/SlippageTabs/index.tsx +++ b/src/components/SlippageTabs/index.tsx @@ -78,10 +78,6 @@ const OptionCustom = styled(FancyButton)<{ active?: boolean; warning?: boolean } } ` -const SlippageSelector = styled.div` - padding: 0 20px; -` - export interface SlippageTabsProps { rawSlippage: number setRawSlippage: (rawSlippage: number) => void @@ -146,15 +142,14 @@ export default function SlippageTabs({ rawSlippage, setRawSlippage, deadline, se } return ( - <> - - - Set slippage tolerance - - - - - + + + + + Slippage tolerance + + + )} - + - + - Deadline + Transaction deadline - + - + ) } diff --git a/src/components/Toggle/index.tsx b/src/components/Toggle/index.tsx new file mode 100644 index 0000000000..992ffb6e86 --- /dev/null +++ b/src/components/Toggle/index.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import styled from 'styled-components' + +const ToggleElement = styled.span<{ isActive?: boolean; isOnSwitch?: boolean }>` + padding: 0.25rem 0.5rem; + border-radius: 14px; + background: ${({ theme, isActive, isOnSwitch }) => (isActive ? (isOnSwitch ? theme.primary1 : theme.text4) : 'none')}; + color: ${({ theme, isActive, isOnSwitch }) => (isActive ? (isOnSwitch ? theme.white : theme.text2) : theme.text3)}; + font-size: 0.825rem; + font-weight: 400; +` + +const StyledToggle = styled.a<{ isActive?: boolean; activeElement?: boolean }>` + border-radius: 16px; + border: 1px solid ${({ theme, isActive }) => (isActive ? theme.primary5 : theme.text4)}; + display: flex; + width: fit-content; + cursor: pointer; + text-decoration: none; + :hover { + text-decoration: none; + } +` + +export interface ToggleProps { + isActive: boolean + toggle: () => void +} + +export default function Toggle({ isActive, toggle }: ToggleProps) { + return ( + + + On + + + Off + + + ) +} diff --git a/src/components/swap/AdvancedSwapDetails.tsx b/src/components/swap/AdvancedSwapDetails.tsx index 81f74c304f..ef916bea49 100644 --- a/src/components/swap/AdvancedSwapDetails.tsx +++ b/src/components/swap/AdvancedSwapDetails.tsx @@ -10,10 +10,10 @@ import { AutoColumn } from '../Column' import { SectionBreak } from './styleds' import QuestionHelper from '../QuestionHelper' import { RowBetween, RowFixed } from '../Row' -import SlippageTabs, { SlippageTabsProps } from '../SlippageTabs' import FormattedPriceImpact from './FormattedPriceImpact' import TokenLogo from '../TokenLogo' import flatMap from 'lodash.flatmap' +import { useUserSlippageTolerance } from '../../state/user/hooks' function TradeSummary({ trade, allowedSlippage }: { trade: Trade; allowedSlippage: number }) { const theme = useContext(ThemeContext) @@ -61,20 +61,20 @@ function TradeSummary({ trade, allowedSlippage }: { trade: Trade; allowedSlippag - - ) } -export interface AdvancedSwapDetailsProps extends SlippageTabsProps { +export interface AdvancedSwapDetailsProps { trade?: Trade onDismiss: () => void } -export function AdvancedSwapDetails({ trade, onDismiss, ...slippageTabProps }: AdvancedSwapDetailsProps) { +export function AdvancedSwapDetails({ trade, onDismiss }: AdvancedSwapDetailsProps) { const theme = useContext(ThemeContext) + const [allowedSlippage] = useUserSlippageTolerance() + return ( @@ -85,13 +85,9 @@ export function AdvancedSwapDetails({ trade, onDismiss, ...slippageTabProps }: A - - - {trade && } - - - + {trade && } + {trade?.route?.path?.length > 2 && } {trade?.route?.path?.length > 2 && ( diff --git a/src/components/swap/PriceSlippageWarningCard.tsx b/src/components/swap/PriceSlippageWarningCard.tsx deleted file mode 100644 index 1ee5889aee..0000000000 --- a/src/components/swap/PriceSlippageWarningCard.tsx +++ /dev/null @@ -1,30 +0,0 @@ -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/styleds.ts b/src/components/swap/styleds.ts index 70d0288258..dee886278c 100644 --- a/src/components/swap/styleds.ts +++ b/src/components/swap/styleds.ts @@ -45,9 +45,15 @@ export const BottomGrouping = styled.div` position: relative; ` -export const ErrorText = styled(Text)<{ severity?: 0 | 1 | 2 | 3 }>` +export const ErrorText = styled(Text)<{ severity?: 0 | 1 | 2 | 3 | 4 }>` color: ${({ theme, severity }) => - severity === 3 ? theme.red1 : severity === 2 ? theme.yellow2 : severity === 1 ? theme.text1 : theme.green1}; + severity === 3 || severity === 4 + ? theme.red1 + : severity === 2 + ? theme.yellow2 + : severity === 1 + ? theme.text1 + : theme.green1}; ` export const InputGroup = styled(AutoColumn)` diff --git a/src/constants/index.ts b/src/constants/index.ts index 48b548b923..b2e47d7393 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -153,6 +153,8 @@ export const BIPS_BASE = JSBI.BigInt(10000) export const ALLOWED_PRICE_IMPACT_LOW: Percent = new Percent(JSBI.BigInt(100), BIPS_BASE) // 1% export const ALLOWED_PRICE_IMPACT_MEDIUM: Percent = new Percent(JSBI.BigInt(500), BIPS_BASE) // 5% export const ALLOWED_PRICE_IMPACT_HIGH: Percent = new Percent(JSBI.BigInt(1000), BIPS_BASE) // 10% +// for non expert mode disable swaps above this +export const BLOCKED_PRICE_IMPACT_NON_EXPERT: Percent = new Percent(JSBI.BigInt(5000), BIPS_BASE) // 50% // if the price slippage exceeds this number, force the user to type 'confirm' to execute export const PRICE_IMPACT_WITHOUT_FEE_CONFIRM_MIN: Percent = new Percent(JSBI.BigInt(2500), BIPS_BASE) // 25% diff --git a/src/hooks/useSwapCallback.ts b/src/hooks/useSwapCallback.ts index b68efccf04..e5bbc8b410 100644 --- a/src/hooks/useSwapCallback.ts +++ b/src/hooks/useSwapCallback.ts @@ -60,8 +60,8 @@ function getSwapType(trade: Trade | undefined): SwapType | undefined { // 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 + allowedSlippage: number = INITIAL_ALLOWED_SLIPPAGE, // in bips + deadline: number = DEFAULT_DEADLINE_FROM_NOW, // in seconds from now to?: string // recipient of output, optional ): null | (() => Promise) { const { account, chainId, library } = useActiveWeb3React() diff --git a/src/pages/AddLiquidity/index.tsx b/src/pages/AddLiquidity/index.tsx index 5cbb92021e..064628290e 100644 --- a/src/pages/AddLiquidity/index.tsx +++ b/src/pages/AddLiquidity/index.tsx @@ -17,7 +17,7 @@ import Row, { AutoRow, RowBetween, RowFixed, RowFlat } from '../../components/Ro import TokenLogo from '../../components/TokenLogo' -import { ROUTER_ADDRESS, MIN_ETH, ONE_BIPS, DEFAULT_DEADLINE_FROM_NOW, INITIAL_ALLOWED_SLIPPAGE } from '../../constants' +import { ROUTER_ADDRESS, MIN_ETH, ONE_BIPS } from '../../constants' import { useActiveWeb3React } from '../../hooks' import { useTransactionAdder } from '../../state/transactions/hooks' @@ -34,7 +34,7 @@ import { import { Field } from '../../state/mint/actions' import { useApproveCallback, ApprovalState } from '../../hooks/useApproveCallback' import { useWalletModalToggle } from '../../state/application/hooks' -import AdvancedSwapDetailsDropdown from '../../components/swap/AdvancedSwapDetailsDropdown' +import { useUserSlippageTolerance, useUserDeadline, useIsExpertMode } from '../../state/user/hooks' export default function AddLiquidity({ match: { params } }: RouteComponentProps<{ tokens: string }>) { useDefaultsFromURLMatchParams(params) @@ -45,6 +45,8 @@ export default function AddLiquidity({ match: { params } }: RouteComponentProps< // toggle wallet when disconnected const toggleWalletModal = useWalletModalToggle() + const expertMode = useIsExpertMode() + // mint state const { independentField, typedValue, otherTypedValue } = useMintState() const { @@ -64,14 +66,13 @@ export default function AddLiquidity({ match: { params } }: RouteComponentProps< const isValid = !error // modal and loading - const [showAdvanced, setShowAdvanced] = useState(false) // toggling slippage, deadline, etc. on and off - const [showConfirm, setShowConfirm] = useState(false) // show confirmation modal - const [attemptingTxn, setAttemptingTxn] = useState(false) // waiting for user confirmaion/rejection - const [txHash, setTxHash] = useState('') + const [showConfirm, setShowConfirm] = useState(false) + const [attemptingTxn, setAttemptingTxn] = useState(false) // clicked confirm - // tx parameters - const [deadline, setDeadline] = useState(DEFAULT_DEADLINE_FROM_NOW) - const [allowedSlippage, setAllowedSlippage] = useState(INITIAL_ALLOWED_SLIPPAGE) + // txn values + const [deadline] = useUserDeadline() // custom from users settings + const [allowedSlippage] = useUserSlippageTolerance() // custom from users + const [txHash, setTxHash] = useState('') // get formatted amounts const formattedAmounts = { @@ -427,7 +428,7 @@ export default function AddLiquidity({ match: { params } }: RouteComponentProps< )} { - setShowConfirm(true) + expertMode ? onAdd() : setShowConfirm(true) }} disabled={!isValid || approvalA !== ApprovalState.APPROVED || approvalB !== ApprovalState.APPROVED} error={!isValid && !!parsedAmounts[Field.TOKEN_A] && !!parsedAmounts[Field.TOKEN_B]} @@ -442,17 +443,6 @@ export default function AddLiquidity({ match: { params } }: RouteComponentProps< - {isValid && !!parsedAmounts[Field.TOKEN_A] && !!parsedAmounts[Field.TOKEN_B] ? ( - - ) : null} - {pair && !noLiquidity ? ( diff --git a/src/pages/RemoveLiquidity/index.tsx b/src/pages/RemoveLiquidity/index.tsx index 57da1ba2f7..8b0c458fa5 100644 --- a/src/pages/RemoveLiquidity/index.tsx +++ b/src/pages/RemoveLiquidity/index.tsx @@ -18,7 +18,7 @@ import Row, { RowBetween, RowFixed } from '../../components/Row' import Slider from '../../components/Slider' import TokenLogo from '../../components/TokenLogo' -import { ROUTER_ADDRESS, DEFAULT_DEADLINE_FROM_NOW, INITIAL_ALLOWED_SLIPPAGE } from '../../constants' +import { ROUTER_ADDRESS } from '../../constants' import { useActiveWeb3React } from '../../hooks' import { usePairContract } from '../../hooks/useContract' @@ -31,9 +31,9 @@ import { useApproveCallback, ApprovalState } from '../../hooks/useApproveCallbac import { Dots } from '../../components/swap/styleds' import { useDefaultsFromURLMatchParams, useBurnActionHandlers } from '../../state/burn/hooks' import { useDerivedBurnInfo, useBurnState } from '../../state/burn/hooks' -import AdvancedSwapDetailsDropdown from '../../components/swap/AdvancedSwapDetailsDropdown' import { Field } from '../../state/burn/actions' import { useWalletModalToggle } from '../../state/application/hooks' +import { useUserDeadline, useUserSlippageTolerance } from '../../state/user/hooks' import { BigNumber } from '@ethersproject/bignumber' export default function RemoveLiquidity({ match: { params } }: RouteComponentProps<{ tokens: string }>) { @@ -52,15 +52,14 @@ export default function RemoveLiquidity({ match: { params } }: RouteComponentPro const isValid = !error // modal and loading - const [showDetailed, setShowDetailed] = useState(false) // toggling detailed view - const [showAdvanced, setShowAdvanced] = useState(false) // toggling slippage, deadline, etc. on and off - const [showConfirm, setShowConfirm] = useState(false) // show confirmation modal - const [attemptingTxn, setAttemptingTxn] = useState(false) // waiting for user confirmaion/rejection - const [txHash, setTxHash] = useState('') + const [showConfirm, setShowConfirm] = useState(false) + const [showDetailed, setShowDetailed] = useState(false) + const [attemptingTxn, setAttemptingTxn] = useState(false) // clicked confirm - // tx parameters - const [deadline, setDeadline] = useState(DEFAULT_DEADLINE_FROM_NOW) - const [allowedSlippage, setAllowedSlippage] = useState(INITIAL_ALLOWED_SLIPPAGE) + // txn values + const [txHash, setTxHash] = useState('') + const [deadline] = useUserDeadline() + const [allowedSlippage] = useUserSlippageTolerance() const formattedAmounts = { [Field.LIQUIDITY_PERCENT]: parsedAmounts[Field.LIQUIDITY_PERCENT].equalTo('0') @@ -592,17 +591,6 @@ export default function RemoveLiquidity({ match: { params } }: RouteComponentPro - {isValid ? ( - - ) : null} - {pair ? ( diff --git a/src/pages/Send/index.tsx b/src/pages/Send/index.tsx index e73352e449..56aada6c53 100644 --- a/src/pages/Send/index.tsx +++ b/src/pages/Send/index.tsx @@ -22,19 +22,14 @@ import { TransferModalHeader } from '../../components/swap/TransferModalHeader' import BetterTradeLink from '../../components/swap/BetterTradeLink' import TokenLogo from '../../components/TokenLogo' import { TokenWarningCards } from '../../components/TokenWarningCard' -import { - DEFAULT_DEADLINE_FROM_NOW, - INITIAL_ALLOWED_SLIPPAGE, - MIN_ETH, - BETTER_TRADE_LINK_THRESHOLD -} from '../../constants' +import { INITIAL_ALLOWED_SLIPPAGE, MIN_ETH, BETTER_TRADE_LINK_THRESHOLD } from '../../constants' import { getTradeVersion, isTradeBetter } from '../../data/V1' import { useActiveWeb3React } from '../../hooks' import { useApproveCallbackFromTrade, ApprovalState } from '../../hooks/useApproveCallback' import { useSendCallback } from '../../hooks/useSendCallback' import { useSwapCallback } from '../../hooks/useSwapCallback' +import { useWalletModalToggle, useToggleSettingsMenu } from '../../state/application/hooks' import useToggledVersion, { Version } from '../../hooks/useToggledVersion' -import { useWalletModalToggle } from '../../state/application/hooks' import { Field } from '../../state/swap/actions' import { useDefaultsFromURLSearch, @@ -46,7 +41,8 @@ import { useAllTokenBalancesTreatingWETHasETH } from '../../state/wallet/hooks' import { CursorPointer, TYPE } from '../../theme' import { computeSlippageAdjustedAmounts, computeTradePriceBreakdown, warningSeverity } from '../../utils/prices' import AppBody from '../AppBody' -import { PriceSlippageWarningCard } from '../../components/swap/PriceSlippageWarningCard' +import { useUserSlippageTolerance, useUserDeadline, useExpertModeManager } from '../../state/user/hooks' +import { ClickableText } from '../Pool/styleds' export default function Send() { useDefaultsFromURLSearch() @@ -59,6 +55,10 @@ export default function Send() { // toggle wallet when disconnected const toggleWalletModal = useWalletModalToggle() + // for expert mode + const toggleSettings = useToggleSettingsMenu() + const [expertMode] = useExpertModeManager() + // sending state const [sendingWithSwap, setSendingWithSwap] = useState(false) const [recipient, setRecipient] = useState('') @@ -102,10 +102,8 @@ export default function Send() { const [showConfirm, setShowConfirm] = useState(false) // show confirmation modal const [attemptingTxn, setAttemptingTxn] = useState(false) // waiting for user confirmaion/rejection const [txHash, setTxHash] = useState('') - - // tx parameters - const [deadline, setDeadline] = useState(DEFAULT_DEADLINE_FROM_NOW) - const [allowedSlippage, setAllowedSlippage] = useState(INITIAL_ALLOWED_SLIPPAGE) + const [deadline] = useUserDeadline() // custom from user settings + const [allowedSlippage] = useUserSlippageTolerance() // custom from user settings const route = bestTrade?.route const userHasSpecifiedInputOutput = @@ -223,7 +221,8 @@ export default function Send() { ((sendingWithSwap && isSwapValid) || (!sendingWithSwap && isSendValid)) && (approval === ApprovalState.NOT_APPROVED || approval === ApprovalState.PENDING || - (approvalSubmitted && approval === ApprovalState.APPROVED)) + (approvalSubmitted && approval === ApprovalState.APPROVED)) && + !(severity > 3 && !expertMode) function modalHeader() { if (!sendingWithSwap) { @@ -487,6 +486,20 @@ export default function Send() { + {allowedSlippage !== INITIAL_ALLOWED_SLIPPAGE && ( + + + + Slippage Tolerance + + + + + {allowedSlippage ? allowedSlippage / 100 : '-'}% + + + + )} {bestTrade && severity > 1 && ( { - setShowConfirm(true) + expertMode ? (sendingWithSwap ? onSwap() : onSend()) : setShowConfirm(true) }} width="48%" id="send-button" @@ -544,23 +557,28 @@ export default function Send() { error={sendingWithSwap && isSwapValid && severity > 2} > - {`Send${severity > 2 ? ' Anyway' : ''}`} + {severity > 3 && !expertMode ? `Price Impact High` : `Send${severity > 2 ? ' Anyway' : ''}`} ) : ( { - setShowConfirm(true) + expertMode ? (sendingWithSwap ? onSwap() : onSend()) : setShowConfirm(true) }} id="send-button" - disabled={(sendingWithSwap && !isSwapValid) || (!sendingWithSwap && !isSendValid)} + disabled={ + (sendingWithSwap && !isSwapValid) || + (!sendingWithSwap && !isSendValid) || + (severity > 3 && !expertMode && sendingWithSwap) + } error={sendingWithSwap && isSwapValid && severity > 2} > {(sendingWithSwap ? swapError : null) || sendAmountError || recipientError || + (severity > 3 && !expertMode && `Price Impact Too High`) || `Send${severity > 2 ? ' Anyway' : ''}`} @@ -571,21 +589,7 @@ export default function Send() { {bestTrade && ( - - )} - - {priceImpactWithoutFee && severity > 2 && ( - - - + )} ) diff --git a/src/pages/Swap/index.tsx b/src/pages/Swap/index.tsx index 9f329f67b8..17f67efea2 100644 --- a/src/pages/Swap/index.tsx +++ b/src/pages/Swap/index.tsx @@ -20,18 +20,15 @@ import SwapModalHeader from '../../components/swap/SwapModalHeader' import TradePrice from '../../components/swap/TradePrice' import BetterTradeLink from '../../components/swap/BetterTradeLink' import { TokenWarningCards } from '../../components/TokenWarningCard' -import { - DEFAULT_DEADLINE_FROM_NOW, - INITIAL_ALLOWED_SLIPPAGE, - MIN_ETH, - BETTER_TRADE_LINK_THRESHOLD -} from '../../constants' -import { getTradeVersion, isTradeBetter } from '../../data/V1' import { useActiveWeb3React } from '../../hooks' import { useApproveCallbackFromTrade, ApprovalState } from '../../hooks/useApproveCallback' import { useSwapCallback } from '../../hooks/useSwapCallback' +import { useWalletModalToggle, useToggleSettingsMenu } from '../../state/application/hooks' +import { useExpertModeManager, useUserSlippageTolerance, useUserDeadline } from '../../state/user/hooks' + +import { INITIAL_ALLOWED_SLIPPAGE, MIN_ETH, BETTER_TRADE_LINK_THRESHOLD } from '../../constants' +import { getTradeVersion, isTradeBetter } from '../../data/V1' import useToggledVersion, { Version } from '../../hooks/useToggledVersion' -import { useWalletModalToggle } from '../../state/application/hooks' import { Field } from '../../state/swap/actions' import { useDefaultsFromURLSearch, @@ -42,7 +39,7 @@ import { import { CursorPointer, TYPE } from '../../theme' import { computeSlippageAdjustedAmounts, computeTradePriceBreakdown, warningSeverity } from '../../utils/prices' import AppBody from '../AppBody' -import { PriceSlippageWarningCard } from '../../components/swap/PriceSlippageWarningCard' +import { ClickableText } from '../Pool/styleds' export default function Swap() { useDefaultsFromURLSearch() @@ -53,6 +50,14 @@ export default function Swap() { // toggle wallet when disconnected const toggleWalletModal = useWalletModalToggle() + // for expert mode + const toggleSettings = useToggleSettingsMenu() + const [expertMode] = useExpertModeManager() + + // get custom setting values for user + const [deadline] = useUserDeadline() + const [allowedSlippage] = useUserSlippageTolerance() + // swap state const { independentField, typedValue } = useSwapState() const { bestTrade: bestTradeV2, tokenBalances, parsedAmount, tokens, error, v1Trade } = useDerivedSwapInfo() @@ -84,10 +89,6 @@ export default function Swap() { const [attemptingTxn, setAttemptingTxn] = useState(false) // waiting for user confirmaion/rejection const [txHash, setTxHash] = useState('') - // tx parameters - const [deadline, setDeadline] = useState(DEFAULT_DEADLINE_FROM_NOW) - const [allowedSlippage, setAllowedSlippage] = useState(INITIAL_ALLOWED_SLIPPAGE) - const formattedAmounts = { [independentField]: typedValue, [dependentField]: parsedAmounts[dependentField] ? parsedAmounts[dependentField].toSignificant(6) : '' @@ -107,13 +108,6 @@ export default function Swap() { // check if user has gone through approval process, used to show two step buttons, reset on token change const [approvalSubmitted, setApprovalSubmitted] = useState(false) - // show approve flow when: no error on inputs, not approved or pending, or approved in current session - const showApproveFlow = - !error && - (approval === ApprovalState.NOT_APPROVED || - approval === ApprovalState.PENDING || - (approvalSubmitted && approval === ApprovalState.APPROVED)) - // mark when a user has submitted an approval, reset onTokenSelection for input field useEffect(() => { if (approval === ApprovalState.PENDING) { @@ -146,7 +140,6 @@ export default function Swap() { if (priceImpactWithoutFee && !confirmPriceImpactWithoutFee(priceImpactWithoutFee)) { return } - setAttemptingTxn(true) swapCallback() .then(hash => { @@ -178,6 +171,15 @@ export default function Swap() { // warnings on slippage const priceImpactSeverity = warningSeverity(priceImpactWithoutFee) + // show approve flow when: no error on inputs, not approved or pending, or approved in current session + // never show if price impact is above threshold in non expert mode + const showApproveFlow = + !error && + (approval === ApprovalState.NOT_APPROVED || + approval === ApprovalState.PENDING || + (approvalSubmitted && approval === ApprovalState.APPROVED)) && + !(priceImpactSeverity > 3 && !expertMode) + function modalHeader() { return ( + {allowedSlippage !== INITIAL_ALLOWED_SLIPPAGE && ( + + + Slippage Tolerance + + + {allowedSlippage ? allowedSlippage / 100 : '-'}% + + + )} + {bestTrade && priceImpactSeverity > 1 && ( { - setShowConfirm(true) + expertMode ? onSwap() : setShowConfirm(true) }} width="48%" id="swap-button" - disabled={!isValid || approval !== ApprovalState.APPROVED} + disabled={!isValid || approval !== ApprovalState.APPROVED || (priceImpactSeverity > 3 && !expertMode)} error={isValid && priceImpactSeverity > 2} > - {`Swap${priceImpactSeverity > 2 ? ' Anyway' : ''}`} + {priceImpactSeverity > 3 && !expertMode + ? `Price Impact High` + : `Swap${priceImpactSeverity > 2 ? ' Anyway' : ''}`} ) : ( { - setShowConfirm(true) + expertMode ? onSwap() : setShowConfirm(true) }} id="swap-button" - disabled={!isValid} + disabled={!isValid || (priceImpactSeverity > 3 && !expertMode)} error={isValid && priceImpactSeverity > 2} > - {error ?? `Swap${priceImpactSeverity > 2 ? ' Anyway' : ''}`} + {error + ? error + : priceImpactSeverity > 3 && !expertMode + ? `Price Impact Too High` + : `Swap${priceImpactSeverity > 2 ? ' Anyway' : ''}`} )} @@ -367,21 +386,7 @@ export default function Swap() { {bestTrade && ( - - )} - - {priceImpactWithoutFee && priceImpactSeverity > 2 && ( - - - + )} ) diff --git a/src/state/application/actions.ts b/src/state/application/actions.ts index 140eb25279..c098b015c6 100644 --- a/src/state/application/actions.ts +++ b/src/state/application/actions.ts @@ -23,5 +23,6 @@ export type PopupContent = export const updateBlockNumber = createAction<{ chainId: number; blockNumber: number }>('updateBlockNumber') export const toggleWalletModal = createAction('toggleWalletModal') +export const toggleSettingsMenu = createAction('toggleSettingsMenu') export const addPopup = createAction<{ key?: string; 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 4caf1f90fd..b996d2c57a 100644 --- a/src/state/application/hooks.ts +++ b/src/state/application/hooks.ts @@ -1,6 +1,6 @@ import { useCallback, useMemo } from 'react' import { useActiveWeb3React } from '../../hooks' -import { addPopup, PopupContent, removePopup, toggleWalletModal } from './actions' +import { addPopup, PopupContent, removePopup, toggleWalletModal, toggleSettingsMenu } from './actions' import { useSelector, useDispatch } from 'react-redux' import { AppState } from '../index' @@ -19,6 +19,15 @@ export function useWalletModalToggle(): () => void { return useCallback(() => dispatch(toggleWalletModal()), [dispatch]) } +export function useSettingsMenuOpen(): boolean { + return useSelector((state: AppState) => state.application.settingsMenuOpen) +} + +export function useToggleSettingsMenu(): () => void { + const dispatch = useDispatch() + return useCallback(() => dispatch(toggleSettingsMenu()), [dispatch]) +} + // returns a function that allows adding a popup export function useAddPopup(): (content: PopupContent, key?: string) => void { const dispatch = useDispatch() diff --git a/src/state/application/reducer.ts b/src/state/application/reducer.ts index b15fde46d1..203243d908 100644 --- a/src/state/application/reducer.ts +++ b/src/state/application/reducer.ts @@ -1,5 +1,12 @@ import { createReducer, nanoid } from '@reduxjs/toolkit' -import { addPopup, PopupContent, removePopup, toggleWalletModal, updateBlockNumber } from './actions' +import { + addPopup, + PopupContent, + removePopup, + toggleWalletModal, + toggleSettingsMenu, + updateBlockNumber +} from './actions' type PopupList = Array<{ key: string; show: boolean; content: PopupContent }> @@ -7,12 +14,14 @@ interface ApplicationState { blockNumber: { [chainId: number]: number } popupList: PopupList walletModalOpen: boolean + settingsMenuOpen: boolean } const initialState: ApplicationState = { blockNumber: {}, popupList: [], - walletModalOpen: false + walletModalOpen: false, + settingsMenuOpen: false } export default createReducer(initialState, builder => @@ -28,6 +37,9 @@ export default createReducer(initialState, builder => .addCase(toggleWalletModal, state => { state.walletModalOpen = !state.walletModalOpen }) + .addCase(toggleSettingsMenu, state => { + state.settingsMenuOpen = !state.settingsMenuOpen + }) .addCase(addPopup, (state, { payload: { content, key } }) => { if (key && state.popupList.some(popup => popup.key === key)) return state.popupList.push({ diff --git a/src/state/swap/hooks.ts b/src/state/swap/hooks.ts index 86f5113943..57d7fe93a9 100644 --- a/src/state/swap/hooks.ts +++ b/src/state/swap/hooks.ts @@ -1,3 +1,4 @@ +import { Version } from './../../hooks/useToggledVersion' import { parseUnits } from '@ethersproject/units' import { ChainId, JSBI, Token, TokenAmount, Trade, WETH } from '@uniswap/sdk' import { ParsedQs } from 'qs' @@ -13,6 +14,9 @@ import { AppDispatch, AppState } from '../index' import { useTokenBalancesTreatWETHAsETH } from '../wallet/hooks' import { Field, replaceSwapState, selectToken, switchTokens, typeInput } from './actions' import { SwapState } from './reducer' +import useToggledVersion from '../../hooks/useToggledVersion' +import { useUserSlippageTolerance } from '../user/hooks' +import { computeSlippageAdjustedAmounts } from '../../utils/prices' export function useSwapState(): AppState['swap'] { return useSelector(state => state.swap) @@ -83,6 +87,8 @@ export function useDerivedSwapInfo(): { } { const { account } = useActiveWeb3React() + const toggledVersion = useToggledVersion() + const { independentField, typedValue, @@ -132,12 +138,29 @@ export function useDerivedSwapInfo(): { error = error ?? 'Select a token' } - // this check is incorrect, it should check against the maximum amount in - // rather than the estimated amount in - // const [balanceIn, amountIn] = [tokenBalances[Field.INPUT], parsedAmounts[Field.INPUT]] - // if (balanceIn && amountIn && balanceIn.lessThan(amountIn)) { - // error = 'Insufficient ' + amountIn.token.symbol + ' balance' - // } + const [allowedSlippage] = useUserSlippageTolerance() + + const slippageAdjustedAmounts = + bestTrade && allowedSlippage && computeSlippageAdjustedAmounts(bestTrade, allowedSlippage) + + const slippageAdjustedAmountsV1 = + v1Trade && allowedSlippage && computeSlippageAdjustedAmounts(v1Trade, allowedSlippage) + + // compare input balance to MAx input based on version + const [balanceIn, amountIn] = [ + tokenBalances[Field.INPUT], + toggledVersion === Version.v1 + ? slippageAdjustedAmountsV1 + ? slippageAdjustedAmountsV1[Field.INPUT] + : null + : slippageAdjustedAmounts + ? slippageAdjustedAmounts[Field.INPUT] + : null + ] + + if (balanceIn && amountIn && balanceIn.lessThan(amountIn)) { + error = 'Insufficient ' + amountIn.token.symbol + ' balance' + } return { tokens, diff --git a/src/state/user/actions.ts b/src/state/user/actions.ts index 30d87ef374..2765408d61 100644 --- a/src/state/user/actions.ts +++ b/src/state/user/actions.ts @@ -16,6 +16,11 @@ export interface SerializedPair { export const updateVersion = createAction('updateVersion') export const updateMatchesDarkMode = createAction<{ matchesDarkMode: boolean }>('updateMatchesDarkMode') export const updateUserDarkMode = createAction<{ userDarkMode: boolean }>('updateUserDarkMode') +export const updateUserExpertMode = createAction<{ userExpertMode: boolean }>('updateUserExpertMode') +export const updateUserSlippageTolerance = createAction<{ userSlippageTolerance: number }>( + 'updateUserSlippageTolerance' +) +export const updateUserDeadline = createAction<{ userDeadline: number }>('updateUserDeadline') export const addSerializedToken = createAction<{ serializedToken: SerializedToken }>('addSerializedToken') export const removeSerializedToken = createAction<{ chainId: number; address: string }>('removeSerializedToken') export const addSerializedPair = createAction<{ serializedPair: SerializedPair }>('addSerializedPair') diff --git a/src/state/user/hooks.tsx b/src/state/user/hooks.tsx index 08477662a3..82ae18d323 100644 --- a/src/state/user/hooks.tsx +++ b/src/state/user/hooks.tsx @@ -13,7 +13,10 @@ import { removeSerializedToken, SerializedPair, SerializedToken, - updateUserDarkMode + updateUserDarkMode, + updateUserExpertMode, + updateUserSlippageTolerance, + updateUserDeadline } from './actions' import { BASES_TO_TRACK_LIQUIDITY_FOR, DUMMY_PAIRS_TO_PIN } from '../../constants' @@ -63,6 +66,54 @@ export function useDarkModeManager(): [boolean, () => void] { return [darkMode, toggleSetDarkMode] } +export function useIsExpertMode(): boolean { + const userExpertMode = useSelector(state => state.user.userExpertMode) + return userExpertMode +} + +export function useExpertModeManager(): [boolean, () => void] { + const dispatch = useDispatch() + const expertMode = useIsExpertMode() + + const toggleSetExpertMode = useCallback(() => { + dispatch(updateUserExpertMode({ userExpertMode: !expertMode })) + }, [expertMode, dispatch]) + + return [expertMode, toggleSetExpertMode] +} + +export function useUserSlippageTolerance(): [number, (slippage: number) => void] { + const dispatch = useDispatch() + const userSlippageTolerance = useSelector(state => { + return state.user.userSlippageTolerance + }) + + const setUserSlippageTolerance = useCallback( + (userSlippageTolerance: number) => { + dispatch(updateUserSlippageTolerance({ userSlippageTolerance })) + }, + [dispatch] + ) + + return [userSlippageTolerance, setUserSlippageTolerance] +} + +export function useUserDeadline(): [number, (slippage: number) => void] { + const dispatch = useDispatch() + const userDeadline = useSelector(state => { + return state.user.userDeadline + }) + + const setUserDeadline = useCallback( + (userDeadline: number) => { + dispatch(updateUserDeadline({ userDeadline })) + }, + [dispatch] + ) + + return [userDeadline, setUserDeadline] +} + export function useAddUserToken(): (token: Token) => void { const dispatch = useDispatch() return useCallback( diff --git a/src/state/user/reducer.ts b/src/state/user/reducer.ts index 5d34848e2a..a1adef6c50 100644 --- a/src/state/user/reducer.ts +++ b/src/state/user/reducer.ts @@ -1,5 +1,5 @@ +import { INITIAL_ALLOWED_SLIPPAGE, DEFAULT_DEADLINE_FROM_NOW } from './../../constants/index' import { createReducer } from '@reduxjs/toolkit' -import { ChainId, WETH } from '@uniswap/sdk' import { addSerializedPair, addSerializedToken, @@ -10,7 +10,10 @@ import { SerializedToken, updateMatchesDarkMode, updateUserDarkMode, - updateVersion + updateVersion, + updateUserExpertMode, + updateUserSlippageTolerance, + updateUserDeadline } from './actions' const currentTimestamp = () => new Date().getTime() @@ -21,6 +24,14 @@ interface UserState { userDarkMode: boolean | null // the user's choice for dark mode or light mode matchesDarkMode: boolean // whether the dark mode media query matches + userExpertMode: boolean + + // user defined slippage tolerance in bips, used in all txns + userSlippageTolerance: number + + // deadline set by user in minutes, used in all txns + userDeadline: number + tokens: { [chainId: number]: { [address: string]: SerializedToken @@ -50,13 +61,13 @@ function pairKey(token0Address: string, token1Address: string) { const initialState: UserState = { lastVersion: '', - userDarkMode: null, matchesDarkMode: false, - + userExpertMode: false, + userSlippageTolerance: INITIAL_ALLOWED_SLIPPAGE, + userDeadline: DEFAULT_DEADLINE_FROM_NOW, tokens: {}, pairs: {}, - timestamp: currentTimestamp() } @@ -68,12 +79,14 @@ export default createReducer(initialState, builder => if (GIT_COMMIT_HASH && state.lastVersion !== GIT_COMMIT_HASH) { state.lastVersion = GIT_COMMIT_HASH - // Wed May 20, 2020 @ ~9pm central - if (state.timestamp < 1590027589111) { - // this should remove the user added token from 'eth' for mainnet - if (state.tokens[ChainId.MAINNET]) { - delete state.tokens[ChainId.MAINNET][WETH[ChainId.MAINNET].address] - } + // slippage isnt being tracked in local storage, reset to default + if (typeof state.userSlippageTolerance !== 'number') { + state.userSlippageTolerance = INITIAL_ALLOWED_SLIPPAGE + } + + // deadline isnt being tracked in local storage, reset to default + if (typeof state.userDeadline !== 'number') { + state.userDeadline = DEFAULT_DEADLINE_FROM_NOW } } state.timestamp = currentTimestamp() @@ -86,6 +99,18 @@ export default createReducer(initialState, builder => state.matchesDarkMode = action.payload.matchesDarkMode state.timestamp = currentTimestamp() }) + .addCase(updateUserExpertMode, (state, action) => { + state.userExpertMode = action.payload.userExpertMode + state.timestamp = currentTimestamp() + }) + .addCase(updateUserSlippageTolerance, (state, action) => { + state.userSlippageTolerance = action.payload.userSlippageTolerance + state.timestamp = currentTimestamp() + }) + .addCase(updateUserDeadline, (state, action) => { + state.userDeadline = action.payload.userDeadline + state.timestamp = currentTimestamp() + }) .addCase(addSerializedToken, (state, { payload: { serializedToken } }) => { state.tokens[serializedToken.chainId] = state.tokens[serializedToken.chainId] || {} state.tokens[serializedToken.chainId][serializedToken.address] = serializedToken diff --git a/src/utils/prices.ts b/src/utils/prices.ts index 524179caa5..0431d6dfd1 100644 --- a/src/utils/prices.ts +++ b/src/utils/prices.ts @@ -1,3 +1,4 @@ +import { BLOCKED_PRICE_IMPACT_NON_EXPERT } from './../constants/index' import { Fraction, JSBI, Percent, TokenAmount, Trade } from '@uniswap/sdk' import { ALLOWED_PRICE_IMPACT_HIGH, ALLOWED_PRICE_IMPACT_LOW, ALLOWED_PRICE_IMPACT_MEDIUM } from '../constants' import { Field } from '../state/swap/actions' @@ -51,7 +52,8 @@ export function computeSlippageAdjustedAmounts( } } -export function warningSeverity(priceImpact: Percent): 0 | 1 | 2 | 3 { +export function warningSeverity(priceImpact: Percent): 0 | 1 | 2 | 3 | 4 { + if (!priceImpact?.lessThan(BLOCKED_PRICE_IMPACT_NON_EXPERT)) return 4 if (!priceImpact?.lessThan(ALLOWED_PRICE_IMPACT_HIGH)) return 3 if (!priceImpact?.lessThan(ALLOWED_PRICE_IMPACT_MEDIUM)) return 2 if (!priceImpact?.lessThan(ALLOWED_PRICE_IMPACT_LOW)) return 1