diff --git a/src/components/Settings/index.tsx b/src/components/Settings/index.tsx index a58054eae0..75615c9c48 100644 --- a/src/components/Settings/index.tsx +++ b/src/components/Settings/index.tsx @@ -9,7 +9,8 @@ import { useDarkModeManager, useExpertModeManager, useUserTransactionTTL, - useUserSlippageTolerance + useUserSlippageTolerance, + useUserSingleHopOnly } from '../../state/user/hooks' import { TYPE } from '../../theme' import { ButtonError } from '../Button' @@ -135,6 +136,8 @@ export default function SettingsTab() { const [expertMode, toggleExpertMode] = useExpertModeManager() + const [singleHopOnly, setSingleHopOnly] = useUserSingleHopOnly() + const [darkMode, toggleDarkMode] = useDarkModeManager() // show confirmation view before turning on @@ -230,6 +233,19 @@ export default function SettingsTab() { } /> + + + + Disable Multihops + + + + (singleHopOnly ? setSingleHopOnly(false) : setSingleHopOnly(true))} + /> + diff --git a/src/constants/index.ts b/src/constants/index.ts index 70d304cbb7..3496527231 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -201,6 +201,7 @@ export const BLOCKED_PRICE_IMPACT_NON_EXPERT: Percent = new Percent(JSBI.BigInt( // 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 BETTER_TRADE_LINK_THRESHOLD = new Percent(JSBI.BigInt(75), JSBI.BigInt(10000)) +export const BETTER_TRADE_LESS_HOPS_THRESHOLD = new Percent(JSBI.BigInt(50), JSBI.BigInt(10000)) // SDN OFAC addresses export const BLOCKED_ADDRESSES: string[] = [ diff --git a/src/data/V1.ts b/src/data/V1.ts index 25120543ab..7829c7c0fa 100644 --- a/src/data/V1.ts +++ b/src/data/V1.ts @@ -3,11 +3,9 @@ import { BigintIsh, Currency, CurrencyAmount, - currencyEquals, ETHER, JSBI, Pair, - Percent, Route, Token, TokenAmount, @@ -157,31 +155,3 @@ export function useV1TradeExchangeAddress(trade: Trade | undefined): string | un }, [trade]) return useV1ExchangeAddress(tokenAddress) } - -const ZERO_PERCENT = new Percent('0') -const ONE_HUNDRED_PERCENT = new Percent('1') - -// returns whether tradeB is better than tradeA by at least a threshold percentage amount -export function isTradeBetter( - tradeA: Trade | undefined, - tradeB: Trade | undefined, - minimumDelta: Percent = ZERO_PERCENT -): boolean | undefined { - if (tradeA && !tradeB) return false - if (tradeB && !tradeA) return true - if (!tradeA || !tradeB) return undefined - - if ( - tradeA.tradeType !== tradeB.tradeType || - !currencyEquals(tradeA.inputAmount.currency, tradeB.inputAmount.currency) || - !currencyEquals(tradeB.outputAmount.currency, tradeB.outputAmount.currency) - ) { - throw new Error('Trades are not comparable') - } - - if (minimumDelta.equalTo(ZERO_PERCENT)) { - return tradeA.executionPrice.lessThan(tradeB.executionPrice) - } else { - return tradeA.executionPrice.raw.multiply(minimumDelta.add(ONE_HUNDRED_PERCENT)).lessThan(tradeB.executionPrice) - } -} diff --git a/src/hooks/Trades.ts b/src/hooks/Trades.ts index 0b2c55c00a..867ddadcd9 100644 --- a/src/hooks/Trades.ts +++ b/src/hooks/Trades.ts @@ -1,12 +1,14 @@ +import { isTradeBetter } from 'utils/trades' import { Currency, CurrencyAmount, Pair, Token, Trade } from '@uniswap/sdk' import flatMap from 'lodash.flatmap' import { useMemo } from 'react' -import { BASES_TO_CHECK_TRADES_AGAINST, CUSTOM_BASES } from '../constants' +import { BASES_TO_CHECK_TRADES_AGAINST, CUSTOM_BASES, BETTER_TRADE_LESS_HOPS_THRESHOLD } from '../constants' import { PairState, usePairs } from '../data/Reserves' import { wrappedCurrency } from '../utils/wrappedCurrency' import { useActiveWeb3React } from './index' +import { useUserSingleHopOnly } from 'state/user/hooks' function useAllCommonPairs(currencyA?: Currency, currencyB?: Currency): Pair[] { const { chainId } = useActiveWeb3React() @@ -78,19 +80,40 @@ function useAllCommonPairs(currencyA?: Currency, currencyB?: Currency): Pair[] { ) } +const MAX_HOPS = 3 + /** * Returns the best trade for the exact amount of tokens in to the given token out */ export function useTradeExactIn(currencyAmountIn?: CurrencyAmount, currencyOut?: Currency): Trade | null { const allowedPairs = useAllCommonPairs(currencyAmountIn?.currency, currencyOut) + + const [singleHopOnly] = useUserSingleHopOnly() + return useMemo(() => { if (currencyAmountIn && currencyOut && allowedPairs.length > 0) { - return ( - Trade.bestTradeExactIn(allowedPairs, currencyAmountIn, currencyOut, { maxHops: 3, maxNumResults: 1 })[0] ?? null - ) + if (singleHopOnly) { + return ( + Trade.bestTradeExactIn(allowedPairs, currencyAmountIn, currencyOut, { maxHops: 1, maxNumResults: 1 })[0] ?? + null + ) + } + // search through trades with varying hops, find best trade out of them + let bestTradeSoFar: Trade | null = null + for (let i = 1; i <= MAX_HOPS; i++) { + const currentTrade: Trade | null = + Trade.bestTradeExactIn(allowedPairs, currencyAmountIn, currencyOut, { maxHops: i, maxNumResults: 1 })[0] ?? + null + // if current trade is best yet, save it + if (isTradeBetter(bestTradeSoFar, currentTrade, BETTER_TRADE_LESS_HOPS_THRESHOLD)) { + bestTradeSoFar = currentTrade + } + } + return bestTradeSoFar } + return null - }, [allowedPairs, currencyAmountIn, currencyOut]) + }, [allowedPairs, currencyAmountIn, currencyOut, singleHopOnly]) } /** @@ -99,13 +122,28 @@ export function useTradeExactIn(currencyAmountIn?: CurrencyAmount, currencyOut?: export function useTradeExactOut(currencyIn?: Currency, currencyAmountOut?: CurrencyAmount): Trade | null { const allowedPairs = useAllCommonPairs(currencyIn, currencyAmountOut?.currency) + const [singleHopOnly] = useUserSingleHopOnly() + return useMemo(() => { if (currencyIn && currencyAmountOut && allowedPairs.length > 0) { - return ( - Trade.bestTradeExactOut(allowedPairs, currencyIn, currencyAmountOut, { maxHops: 3, maxNumResults: 1 })[0] ?? - null - ) + if (singleHopOnly) { + return ( + Trade.bestTradeExactOut(allowedPairs, currencyIn, currencyAmountOut, { maxHops: 1, maxNumResults: 1 })[0] ?? + null + ) + } + // search through trades with varying hops, find best trade out of them + let bestTradeSoFar: Trade | null = null + for (let i = 1; i <= MAX_HOPS; i++) { + const currentTrade = + Trade.bestTradeExactOut(allowedPairs, currencyIn, currencyAmountOut, { maxHops: i, maxNumResults: 1 })[0] ?? + null + if (isTradeBetter(bestTradeSoFar, currentTrade, BETTER_TRADE_LESS_HOPS_THRESHOLD)) { + bestTradeSoFar = currentTrade + } + } + return bestTradeSoFar } return null - }, [allowedPairs, currencyIn, currencyAmountOut]) + }, [currencyIn, currencyAmountOut, allowedPairs, singleHopOnly]) } diff --git a/src/pages/Swap/index.tsx b/src/pages/Swap/index.tsx index 696f1cc4cb..707ddea6e6 100644 --- a/src/pages/Swap/index.tsx +++ b/src/pages/Swap/index.tsx @@ -21,7 +21,7 @@ import TokenWarningModal from '../../components/TokenWarningModal' import ProgressSteps from '../../components/ProgressSteps' import { BETTER_TRADE_LINK_THRESHOLD, INITIAL_ALLOWED_SLIPPAGE } from '../../constants' -import { getTradeVersion, isTradeBetter } from '../../data/V1' +import { getTradeVersion } from '../../data/V1' import { useActiveWeb3React } from '../../hooks' import { useCurrency } from '../../hooks/Tokens' import { ApprovalState, useApproveCallbackFromTrade } from '../../hooks/useApproveCallback' @@ -37,13 +37,14 @@ import { useSwapActionHandlers, useSwapState } from '../../state/swap/hooks' -import { useExpertModeManager, useUserSlippageTolerance } from '../../state/user/hooks' +import { useExpertModeManager, useUserSlippageTolerance, useUserSingleHopOnly } from '../../state/user/hooks' import { LinkStyledButton, TYPE } from '../../theme' import { maxAmountSpend } from '../../utils/maxAmountSpend' import { computeTradePriceBreakdown, warningSeverity } from '../../utils/prices' import AppBody from '../AppBody' import { ClickableText } from '../Pool/styleds' import Loader from '../../components/Loader' +import { isTradeBetter } from 'utils/trades' export default function Swap() { const loadedUrlParams = useDefaultsFromURLSearch() @@ -183,6 +184,8 @@ export default function Swap() { const { priceImpactWithoutFee } = computeTradePriceBreakdown(trade) + const [singleHopOnly] = useUserSingleHopOnly() + const handleSwap = useCallback(() => { if (priceImpactWithoutFee && !confirmPriceImpactWithoutFee(priceImpactWithoutFee)) { return @@ -209,6 +212,11 @@ export default function Swap() { getTradeVersion(trade) ].join('/') }) + + ReactGA.event({ + category: 'Routing', + action: singleHopOnly ? 'Swap with multihop disabled' : 'Swap with multihop enabled' + }) }) .catch(error => { setSwapState({ @@ -219,7 +227,17 @@ export default function Swap() { txHash: undefined }) }) - }, [tradeToConfirm, account, priceImpactWithoutFee, recipient, recipientAddress, showConfirm, swapCallback, trade]) + }, [ + priceImpactWithoutFee, + swapCallback, + tradeToConfirm, + showConfirm, + recipient, + recipientAddress, + account, + trade, + singleHopOnly + ]) // errors const [showInverted, setShowInverted] = useState(false) @@ -384,6 +402,7 @@ export default function Swap() { ) : noRoute && userHasSpecifiedInputOutput ? ( Insufficient liquidity for this trade. + {singleHopOnly && Try enabling multi-hop trades.} ) : showApproveFlow ? ( diff --git a/src/state/user/actions.ts b/src/state/user/actions.ts index 8433f43b07..f6701c3b94 100644 --- a/src/state/user/actions.ts +++ b/src/state/user/actions.ts @@ -16,6 +16,7 @@ export interface SerializedPair { export const updateMatchesDarkMode = createAction<{ matchesDarkMode: boolean }>('user/updateMatchesDarkMode') export const updateUserDarkMode = createAction<{ userDarkMode: boolean }>('user/updateUserDarkMode') export const updateUserExpertMode = createAction<{ userExpertMode: boolean }>('user/updateUserExpertMode') +export const updateUserSingleHopOnly = createAction<{ userSingleHopOnly: boolean }>('user/updateUserSingleHopOnly') export const updateUserSlippageTolerance = createAction<{ userSlippageTolerance: number }>( 'user/updateUserSlippageTolerance' ) diff --git a/src/state/user/hooks.tsx b/src/state/user/hooks.tsx index 5c4bd61878..178cfbd665 100644 --- a/src/state/user/hooks.tsx +++ b/src/state/user/hooks.tsx @@ -1,5 +1,6 @@ import { ChainId, Pair, Token } from '@uniswap/sdk' import flatMap from 'lodash.flatmap' +import ReactGA from 'react-ga' import { useCallback, useMemo } from 'react' import { shallowEqual, useDispatch, useSelector } from 'react-redux' import { BASES_TO_TRACK_LIQUIDITY_FOR, PINNED_PAIRS } from '../../constants' @@ -17,7 +18,8 @@ import { updateUserDeadline, updateUserExpertMode, updateUserSlippageTolerance, - toggleURLWarning + toggleURLWarning, + updateUserSingleHopOnly } from './actions' function serializeToken(token: Token): SerializedToken { @@ -81,6 +83,27 @@ export function useExpertModeManager(): [boolean, () => void] { return [expertMode, toggleSetExpertMode] } +export function useUserSingleHopOnly(): [boolean, (newSingleHopOnly: boolean) => void] { + const dispatch = useDispatch() + + const singleHopOnly = useSelector( + state => state.user.userSingleHopOnly + ) + + const setSingleHopOnly = useCallback( + (newSingleHopOnly: boolean) => { + ReactGA.event({ + category: 'Routing', + action: newSingleHopOnly ? 'enable single hop' : 'disable single hop' + }) + dispatch(updateUserSingleHopOnly({ userSingleHopOnly: newSingleHopOnly })) + }, + [dispatch] + ) + + return [singleHopOnly, setSingleHopOnly] +} + export function useUserSlippageTolerance(): [number, (slippage: number) => void] { const dispatch = useDispatch() const userSlippageTolerance = useSelector(state => { diff --git a/src/state/user/reducer.ts b/src/state/user/reducer.ts index 8dd046a6af..e1a8cf11b9 100644 --- a/src/state/user/reducer.ts +++ b/src/state/user/reducer.ts @@ -13,7 +13,8 @@ import { updateUserExpertMode, updateUserSlippageTolerance, updateUserDeadline, - toggleURLWarning + toggleURLWarning, + updateUserSingleHopOnly } from './actions' const currentTimestamp = () => new Date().getTime() @@ -27,6 +28,8 @@ export interface UserState { userExpertMode: boolean + userSingleHopOnly: boolean // only allow swaps on direct pairs + // user defined slippage tolerance in bips, used in all txns userSlippageTolerance: number @@ -58,6 +61,7 @@ export const initialState: UserState = { userDarkMode: null, matchesDarkMode: false, userExpertMode: false, + userSingleHopOnly: false, userSlippageTolerance: INITIAL_ALLOWED_SLIPPAGE, userDeadline: DEFAULT_DEADLINE_FROM_NOW, tokens: {}, @@ -103,6 +107,9 @@ export default createReducer(initialState, builder => state.userDeadline = action.payload.userDeadline state.timestamp = currentTimestamp() }) + .addCase(updateUserSingleHopOnly, (state, action) => { + state.userSingleHopOnly = action.payload.userSingleHopOnly + }) .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/trades.ts b/src/utils/trades.ts new file mode 100644 index 0000000000..154d7294ac --- /dev/null +++ b/src/utils/trades.ts @@ -0,0 +1,29 @@ +import { Trade, currencyEquals, Percent } from '@uniswap/sdk' + +const ZERO_PERCENT = new Percent('0') +const ONE_HUNDRED_PERCENT = new Percent('1') + +// returns whether tradeB is better than tradeA by at least a threshold percentage amount +export function isTradeBetter( + tradeA: Trade | undefined | null, + tradeB: Trade | undefined | null, + minimumDelta: Percent = ZERO_PERCENT +): boolean | undefined { + if (tradeA && !tradeB) return false + if (tradeB && !tradeA) return true + if (!tradeA || !tradeB) return undefined + + if ( + tradeA.tradeType !== tradeB.tradeType || + !currencyEquals(tradeA.inputAmount.currency, tradeB.inputAmount.currency) || + !currencyEquals(tradeB.outputAmount.currency, tradeB.outputAmount.currency) + ) { + throw new Error('Trades are not comparable') + } + + if (minimumDelta.equalTo(ZERO_PERCENT)) { + return tradeA.executionPrice.lessThan(tradeB.executionPrice) + } else { + return tradeA.executionPrice.raw.multiply(minimumDelta.add(ONE_HUNDRED_PERCENT)).lessThan(tradeB.executionPrice) + } +}