Commit 82c30681 authored by Zach Pomerantz's avatar Zach Pomerantz Committed by GitHub

fix: ignore stale SOR fetches (#3313)

* fix: propagate ROUTE_NOT_FOUND and fallback appropriately

* fix: display insufficient liquidities

* fix: ignore stale SOR results

* fix: retain trade state while loading

* fix: mv debouncing to SOR logic for sync state
parent 41ef9616
...@@ -55,12 +55,9 @@ export default function Input({ disabled, focused }: InputProps) { ...@@ -55,12 +55,9 @@ export default function Input({ disabled, focused }: InputProps) {
// extract eagerly in case of reversal // extract eagerly in case of reversal
usePrefetchCurrencyColor(swapInputCurrency) usePrefetchCurrencyColor(swapInputCurrency)
const isTradeLoading = useMemo( const isRouteLoading = tradeState === TradeState.SYNCING || tradeState === TradeState.LOADING
() => TradeState.LOADING === tradeState || TradeState.SYNCING === tradeState,
[tradeState]
)
const isDependentField = useAtomValue(independentFieldAtom) !== Field.INPUT const isDependentField = useAtomValue(independentFieldAtom) !== Field.INPUT
const isLoading = isDependentField && isTradeLoading const isLoading = isRouteLoading && isDependentField
//TODO(ianlapham): mimic logic from app swap page //TODO(ianlapham): mimic logic from app swap page
const mockApproved = true const mockApproved = true
......
...@@ -48,14 +48,9 @@ export default function Output({ disabled, focused, children }: PropsWithChildre ...@@ -48,14 +48,9 @@ export default function Output({ disabled, focused, children }: PropsWithChildre
const [swapOutputAmount, updateSwapOutputAmount] = useSwapAmount(Field.OUTPUT) const [swapOutputAmount, updateSwapOutputAmount] = useSwapAmount(Field.OUTPUT)
const [swapOutputCurrency, updateSwapOutputCurrency] = useSwapCurrency(Field.OUTPUT) const [swapOutputCurrency, updateSwapOutputCurrency] = useSwapCurrency(Field.OUTPUT)
//loading status of the trade const isRouteLoading = tradeState === TradeState.SYNCING || tradeState === TradeState.LOADING
const isTradeLoading = useMemo(
() => TradeState.LOADING === tradeState || TradeState.SYNCING === tradeState,
[tradeState]
)
const isDependentField = useAtomValue(independentFieldAtom) !== Field.OUTPUT const isDependentField = useAtomValue(independentFieldAtom) !== Field.OUTPUT
const isLoading = isDependentField && isTradeLoading const isLoading = isRouteLoading && isDependentField
const overrideColor = useAtomValue(colorAtom) const overrideColor = useAtomValue(colorAtom)
const dynamicColor = useCurrencyColor(swapOutputCurrency) const dynamicColor = useCurrencyColor(swapOutputCurrency)
......
...@@ -23,12 +23,7 @@ export default function Toolbar({ disabled }: { disabled?: boolean }) { ...@@ -23,12 +23,7 @@ export default function Toolbar({ disabled }: { disabled?: boolean }) {
currencies: { [Field.INPUT]: inputCurrency, [Field.OUTPUT]: outputCurrency }, currencies: { [Field.INPUT]: inputCurrency, [Field.OUTPUT]: outputCurrency },
currencyBalances: { [Field.INPUT]: balance }, currencyBalances: { [Field.INPUT]: balance },
} = useSwapInfo() } = useSwapInfo()
const isRouteLoading = state === TradeState.SYNCING || state === TradeState.LOADING
const [routeFound, routeIsLoading] = useMemo(
() => [Boolean(trade?.swaps), TradeState.LOADING === state || TradeState.SYNCING === state],
[state, trade?.swaps]
)
const isAmountPopulated = useIsAmountPopulated() const isAmountPopulated = useIsAmountPopulated()
const caption = useMemo(() => { const caption = useMemo(() => {
...@@ -41,10 +36,10 @@ export default function Toolbar({ disabled }: { disabled?: boolean }) { ...@@ -41,10 +36,10 @@ export default function Toolbar({ disabled }: { disabled?: boolean }) {
} }
if (inputCurrency && outputCurrency && isAmountPopulated) { if (inputCurrency && outputCurrency && isAmountPopulated) {
if (!trade || routeIsLoading) { if (isRouteLoading) {
return <Caption.LoadingTrade /> return <Caption.LoadingTrade />
} }
if (!routeFound) { if (!trade?.swaps) {
return <Caption.InsufficientLiquidity /> return <Caption.InsufficientLiquidity />
} }
if (balance && trade?.inputAmount.greaterThan(balance)) { if (balance && trade?.inputAmount.greaterThan(balance)) {
...@@ -56,7 +51,7 @@ export default function Toolbar({ disabled }: { disabled?: boolean }) { ...@@ -56,7 +51,7 @@ export default function Toolbar({ disabled }: { disabled?: boolean }) {
} }
return <Caption.Empty /> return <Caption.Empty />
}, [balance, chainId, disabled, inputCurrency, isAmountPopulated, outputCurrency, routeFound, routeIsLoading, trade]) }, [balance, chainId, disabled, inputCurrency, isAmountPopulated, isRouteLoading, outputCurrency, trade])
return ( return (
<> <>
......
import { Protocol } from '@uniswap/router-sdk' import { Protocol } from '@uniswap/router-sdk'
import { Currency, CurrencyAmount, TradeType } from '@uniswap/sdk-core' import { Currency, CurrencyAmount, TradeType } from '@uniswap/sdk-core'
import { ChainId } from '@uniswap/smart-order-router' import { ChainId } from '@uniswap/smart-order-router'
import useDebounce from 'hooks/useDebounce'
import { useStablecoinAmountFromFiatValue } from 'hooks/useUSDCPrice' import { useStablecoinAmountFromFiatValue } from 'hooks/useUSDCPrice'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { GetQuoteResult, InterfaceTrade, TradeState } from 'state/routing/types' import { GetQuoteResult, InterfaceTrade, TradeState } from 'state/routing/types'
...@@ -41,6 +42,12 @@ export default function useClientSideSmartOrderRouterTrade<TTradeType extends Tr ...@@ -41,6 +42,12 @@ export default function useClientSideSmartOrderRouterTrade<TTradeType extends Tr
state: TradeState state: TradeState
trade: InterfaceTrade<Currency, Currency, TTradeType> | undefined trade: InterfaceTrade<Currency, Currency, TTradeType> | undefined
} { } {
// Debounce is used to prevent excessive requests to SOR, as it is data intensive.
// This helps provide a "syncing" state the UI can reference for loading animations.
const inputs = useMemo(() => [tradeType, amountSpecified, otherCurrency], [tradeType, amountSpecified, otherCurrency])
const debouncedInputs = useDebounce(inputs, 200)
const isDebouncing = inputs !== debouncedInputs
const chainId = amountSpecified?.currency.chainId const chainId = amountSpecified?.currency.chainId
const { library } = useActiveWeb3React() const { library } = useActiveWeb3React()
...@@ -62,90 +69,87 @@ export default function useClientSideSmartOrderRouterTrade<TTradeType extends Tr ...@@ -62,90 +69,87 @@ export default function useClientSideSmartOrderRouterTrade<TTradeType extends Tr
const params = useMemo(() => chainId && library && { chainId, provider: library }, [chainId, library]) const params = useMemo(() => chainId && library && { chainId, provider: library }, [chainId, library])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [{ quoteResult, error }, setFetchedResult] = useState<{ const [{ data: quoteResult, error }, setResult] = useState<{
quoteResult: GetQuoteResult | undefined data?: GetQuoteResult
error: unknown error?: unknown
}>({ }>({ error: undefined })
quoteResult: undefined,
error: undefined,
})
const config = useMemo(() => getConfig(chainId), [chainId]) const config = useMemo(() => getConfig(chainId), [chainId])
// When arguments update, make a new call to SOR for updated quote // When arguments update, make a new call to SOR for updated quote
useEffect(() => { useEffect(() => {
setLoading(true) setLoading(true)
if (isDebouncing) return
let stale = false
fetchQuote() fetchQuote()
return () => {
stale = true
setLoading(false)
}
async function fetchQuote() { async function fetchQuote() {
try { if (queryArgs && params) {
if (queryArgs && params) { let result
const result = await getClientSideQuote(queryArgs, params, config) try {
setFetchedResult({ result = await getClientSideQuote(queryArgs, params, config)
quoteResult: result.data, } catch {
error: result.error, result = { error: true }
}) }
if (!stale) {
setResult(result)
setLoading(false)
} }
} catch (e) {
setFetchedResult({
quoteResult: undefined,
error: true,
})
} finally {
setLoading(false)
} }
} }
}, [queryArgs, params, config]) }, [queryArgs, params, config, isDebouncing])
const route = useMemo( const route = useMemo(
() => computeRoutes(currencyIn, currencyOut, tradeType, quoteResult), () => computeRoutes(currencyIn, currencyOut, tradeType, quoteResult),
[currencyIn, currencyOut, quoteResult, tradeType] [currencyIn, currencyOut, quoteResult, tradeType]
) )
// get USD gas cost of trade in active chains stablecoin amount
const gasUseEstimateUSD = useStablecoinAmountFromFiatValue(quoteResult?.gasUseEstimateUSD) ?? null const gasUseEstimateUSD = useStablecoinAmountFromFiatValue(quoteResult?.gasUseEstimateUSD) ?? null
const trade = useMemo(() => {
if (route) {
try {
return route && transformRoutesToTrade(route, tradeType, gasUseEstimateUSD)
} catch (e: unknown) {
console.debug('transformRoutesToTrade failed: ', e)
}
}
return
}, [gasUseEstimateUSD, route, tradeType])
return useMemo(() => { return useMemo(() => {
if (!currencyIn || !currencyOut) { if (!currencyIn || !currencyOut) {
return { return { state: TradeState.INVALID, trade: undefined }
state: TradeState.INVALID,
trade: undefined,
}
} }
if (loading && !quoteResult) { // Returns the last trade state while syncing/loading to avoid jank from clearing the last trade while loading.
// only on first hook render if (isDebouncing) {
return { return { state: TradeState.SYNCING, trade }
state: TradeState.LOADING, } else if (loading) {
trade: undefined, return { state: TradeState.LOADING, trade }
}
} }
let otherAmount = undefined let otherAmount = undefined
if (tradeType === TradeType.EXACT_INPUT && currencyOut && quoteResult) { if (quoteResult) {
otherAmount = CurrencyAmount.fromRawAmount(currencyOut, quoteResult.quote) switch (tradeType) {
} case TradeType.EXACT_INPUT:
if (tradeType === TradeType.EXACT_OUTPUT && currencyIn && quoteResult) { otherAmount = CurrencyAmount.fromRawAmount(currencyOut, quoteResult.quote)
otherAmount = CurrencyAmount.fromRawAmount(currencyIn, quoteResult.quote) break
case TradeType.EXACT_OUTPUT:
otherAmount = CurrencyAmount.fromRawAmount(currencyIn, quoteResult.quote)
break
}
} }
if (error || !otherAmount || !route || route.length === 0 || !queryArgs) { if (error || !otherAmount || !route || route.length === 0 || !queryArgs) {
return { return { state: TradeState.NO_ROUTE_FOUND, trade: undefined }
state: TradeState.NO_ROUTE_FOUND,
trade: undefined,
}
} }
try { if (trade) {
const trade = transformRoutesToTrade(route, tradeType, gasUseEstimateUSD) return { state: TradeState.VALID, trade }
return {
// always return VALID regardless of isFetching status
state: TradeState.VALID,
trade,
}
} catch (e) {
console.debug('transformRoutesToTrade failed: ', e)
return { state: TradeState.INVALID, trade: undefined }
} }
}, [currencyIn, currencyOut, loading, quoteResult, tradeType, error, route, queryArgs, gasUseEstimateUSD]) return { state: TradeState.INVALID, trade: undefined }
}, [currencyIn, currencyOut, isDebouncing, loading, quoteResult, error, route, queryArgs, trade, tradeType])
} }
import { Currency, CurrencyAmount, TradeType } from '@uniswap/sdk-core' import { Currency, CurrencyAmount, TradeType } from '@uniswap/sdk-core'
import { useClientSideV3Trade } from 'hooks/useClientSideV3Trade' import { useClientSideV3Trade } from 'hooks/useClientSideV3Trade'
import useDebounce from 'hooks/useDebounce'
import { useMemo } from 'react'
import { InterfaceTrade, TradeState } from 'state/routing/types' import { InterfaceTrade, TradeState } from 'state/routing/types'
import useClientSideSmartOrderRouterTrade from '../routing/useClientSideSmartOrderRouterTrade' import useClientSideSmartOrderRouterTrade from '../routing/useClientSideSmartOrderRouterTrade'
/**
* Returns the currency amount from independent field, currency from independent field,
* and currency from dependent field.
*/
function getTradeInputs(
trade: InterfaceTrade<Currency, Currency, TradeType> | undefined,
tradeType: TradeType
): [CurrencyAmount<Currency> | undefined, Currency | undefined, Currency | undefined] {
if (trade) {
if (tradeType === TradeType.EXACT_INPUT) {
return [trade.inputAmount, trade.inputAmount.currency, trade.outputAmount.currency]
}
if (tradeType === TradeType.EXACT_OUTPUT) {
return [trade.outputAmount, trade.outputAmount.currency, trade.inputAmount.currency]
}
}
return [undefined, undefined, undefined]
}
interface TradeDebouncingParams {
amounts: [CurrencyAmount<Currency> | undefined, CurrencyAmount<Currency> | undefined]
indepdenentCurrencies: [Currency | undefined, Currency | undefined]
dependentCurrencies: [Currency | undefined, Currency | undefined]
}
/**
* Returns wether debounced values are stale compared to latest values from trade.
*/
function isTradeDebouncing({ amounts, indepdenentCurrencies, dependentCurrencies }: TradeDebouncingParams): boolean {
// Ensure that amount from user input matches latest trade.
const amountsMatch = amounts[0] && amounts[1]?.equalTo(amounts[0])
// Ensure active swap currencies match latest trade.
const currenciesMatch =
indepdenentCurrencies[0] &&
indepdenentCurrencies[1]?.equals(indepdenentCurrencies[0]) &&
dependentCurrencies[0] &&
dependentCurrencies[1]?.equals(dependentCurrencies[0])
return !amountsMatch || !currenciesMatch
}
/** /**
* Returns the best v2+v3 trade for a desired swap. * Returns the best v2+v3 trade for a desired swap.
* @param tradeType whether the swap is an exact in/out * @param tradeType whether the swap is an exact in/out
...@@ -62,47 +18,15 @@ export function useBestTrade( ...@@ -62,47 +18,15 @@ export function useBestTrade(
state: TradeState state: TradeState
trade: InterfaceTrade<Currency, Currency, TradeType> | undefined trade: InterfaceTrade<Currency, Currency, TradeType> | undefined
} { } {
// Debounce is used to prevent excessive requests to SOR, as it is data intensive. const clientSORTrade = useClientSideSmartOrderRouterTrade(tradeType, amountSpecified, otherCurrency)
// This helps provide a "syncing" state the UI can reference for loading animations.
const [debouncedAmount, debouncedOtherCurrency] = useDebounce(
useMemo(() => [amountSpecified, otherCurrency], [amountSpecified, otherCurrency]),
200
)
const clientSORTrade = useClientSideSmartOrderRouterTrade(tradeType, debouncedAmount, debouncedOtherCurrency)
const [amountFromLatestTrade, currencyFromTrade, otherCurrencyFromTrade] = getTradeInputs(
clientSORTrade.trade,
tradeType
)
const debouncing =
(amountSpecified && debouncedAmount && amountSpecified !== debouncedAmount) ||
(amountSpecified && debouncedOtherCurrency && otherCurrency && debouncedOtherCurrency !== otherCurrency)
const syncing =
amountSpecified &&
isTradeDebouncing({
amounts: [amountFromLatestTrade, amountSpecified],
indepdenentCurrencies: [currencyFromTrade, amountSpecified?.currency],
dependentCurrencies: [otherCurrencyFromTrade, debouncedOtherCurrency],
})
const useFallback = !syncing && clientSORTrade.state === TradeState.NO_ROUTE_FOUND
// Use a simple client side logic as backup if SOR is not available. // Use a simple client side logic as backup if SOR is not available.
const useFallback = clientSORTrade.state === TradeState.NO_ROUTE_FOUND || clientSORTrade.state === TradeState.INVALID
const fallbackTrade = useClientSideV3Trade( const fallbackTrade = useClientSideV3Trade(
tradeType, tradeType,
useFallback ? debouncedAmount : undefined, useFallback ? amountSpecified : undefined,
useFallback ? debouncedOtherCurrency : undefined useFallback ? otherCurrency : undefined
) )
return useMemo( return useFallback ? fallbackTrade : clientSORTrade
() => ({
...(useFallback ? fallbackTrade : clientSORTrade),
...(syncing ? { state: TradeState.SYNCING } : {}),
...(debouncing ? { state: TradeState.LOADING } : {}),
}),
[debouncing, fallbackTrade, syncing, clientSORTrade, useFallback]
)
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment