Skip to content

Commit

Permalink
fix: ignore stale SOR fetches (#3313)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
zzmp authored Feb 15, 2022
1 parent 41ef961 commit 82c3068
Show file tree
Hide file tree
Showing 5 changed files with 72 additions and 157 deletions.
7 changes: 2 additions & 5 deletions src/lib/components/Swap/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,9 @@ export default function Input({ disabled, focused }: InputProps) {
// extract eagerly in case of reversal
usePrefetchCurrencyColor(swapInputCurrency)

const isTradeLoading = useMemo(
() => TradeState.LOADING === tradeState || TradeState.SYNCING === tradeState,
[tradeState]
)
const isRouteLoading = tradeState === TradeState.SYNCING || tradeState === TradeState.LOADING
const isDependentField = useAtomValue(independentFieldAtom) !== Field.INPUT
const isLoading = isDependentField && isTradeLoading
const isLoading = isRouteLoading && isDependentField

//TODO(ianlapham): mimic logic from app swap page
const mockApproved = true
Expand Down
9 changes: 2 additions & 7 deletions src/lib/components/Swap/Output.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,9 @@ export default function Output({ disabled, focused, children }: PropsWithChildre
const [swapOutputAmount, updateSwapOutputAmount] = useSwapAmount(Field.OUTPUT)
const [swapOutputCurrency, updateSwapOutputCurrency] = useSwapCurrency(Field.OUTPUT)

//loading status of the trade
const isTradeLoading = useMemo(
() => TradeState.LOADING === tradeState || TradeState.SYNCING === tradeState,
[tradeState]
)

const isRouteLoading = tradeState === TradeState.SYNCING || tradeState === TradeState.LOADING
const isDependentField = useAtomValue(independentFieldAtom) !== Field.OUTPUT
const isLoading = isDependentField && isTradeLoading
const isLoading = isRouteLoading && isDependentField

const overrideColor = useAtomValue(colorAtom)
const dynamicColor = useCurrencyColor(swapOutputCurrency)
Expand Down
13 changes: 4 additions & 9 deletions src/lib/components/Swap/Toolbar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,7 @@ export default function Toolbar({ disabled }: { disabled?: boolean }) {
currencies: { [Field.INPUT]: inputCurrency, [Field.OUTPUT]: outputCurrency },
currencyBalances: { [Field.INPUT]: balance },
} = useSwapInfo()

const [routeFound, routeIsLoading] = useMemo(
() => [Boolean(trade?.swaps), TradeState.LOADING === state || TradeState.SYNCING === state],
[state, trade?.swaps]
)

const isRouteLoading = state === TradeState.SYNCING || state === TradeState.LOADING
const isAmountPopulated = useIsAmountPopulated()

const caption = useMemo(() => {
Expand All @@ -41,10 +36,10 @@ export default function Toolbar({ disabled }: { disabled?: boolean }) {
}

if (inputCurrency && outputCurrency && isAmountPopulated) {
if (!trade || routeIsLoading) {
if (isRouteLoading) {
return <Caption.LoadingTrade />
}
if (!routeFound) {
if (!trade?.swaps) {
return <Caption.InsufficientLiquidity />
}
if (balance && trade?.inputAmount.greaterThan(balance)) {
Expand All @@ -56,7 +51,7 @@ export default function Toolbar({ disabled }: { disabled?: boolean }) {
}

return <Caption.Empty />
}, [balance, chainId, disabled, inputCurrency, isAmountPopulated, outputCurrency, routeFound, routeIsLoading, trade])
}, [balance, chainId, disabled, inputCurrency, isAmountPopulated, isRouteLoading, outputCurrency, trade])

return (
<>
Expand Down
114 changes: 59 additions & 55 deletions src/lib/hooks/routing/useClientSideSmartOrderRouterTrade.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Protocol } from '@uniswap/router-sdk'
import { Currency, CurrencyAmount, TradeType } from '@uniswap/sdk-core'
import { ChainId } from '@uniswap/smart-order-router'
import useDebounce from 'hooks/useDebounce'
import { useStablecoinAmountFromFiatValue } from 'hooks/useUSDCPrice'
import { useEffect, useMemo, useState } from 'react'
import { GetQuoteResult, InterfaceTrade, TradeState } from 'state/routing/types'
Expand Down Expand Up @@ -41,6 +42,12 @@ export default function useClientSideSmartOrderRouterTrade<TTradeType extends Tr
state: TradeState
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 { library } = useActiveWeb3React()

Expand All @@ -62,90 +69,87 @@ export default function useClientSideSmartOrderRouterTrade<TTradeType extends Tr
const params = useMemo(() => chainId && library && { chainId, provider: library }, [chainId, library])

const [loading, setLoading] = useState(false)
const [{ quoteResult, error }, setFetchedResult] = useState<{
quoteResult: GetQuoteResult | undefined
error: unknown
}>({
quoteResult: undefined,
error: undefined,
})

const [{ data: quoteResult, error }, setResult] = useState<{
data?: GetQuoteResult
error?: unknown
}>({ error: undefined })
const config = useMemo(() => getConfig(chainId), [chainId])

// When arguments update, make a new call to SOR for updated quote
useEffect(() => {
setLoading(true)
if (isDebouncing) return

let stale = false
fetchQuote()
return () => {
stale = true
setLoading(false)
}

async function fetchQuote() {
try {
if (queryArgs && params) {
const result = await getClientSideQuote(queryArgs, params, config)
setFetchedResult({
quoteResult: result.data,
error: result.error,
})
if (queryArgs && params) {
let result
try {
result = await getClientSideQuote(queryArgs, params, config)
} catch {
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(
() => computeRoutes(currencyIn, currencyOut, tradeType, quoteResult),
[currencyIn, currencyOut, quoteResult, tradeType]
)

// get USD gas cost of trade in active chains stablecoin amount
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(() => {
if (!currencyIn || !currencyOut) {
return {
state: TradeState.INVALID,
trade: undefined,
}
return { state: TradeState.INVALID, trade: undefined }
}

if (loading && !quoteResult) {
// only on first hook render
return {
state: TradeState.LOADING,
trade: undefined,
}
// Returns the last trade state while syncing/loading to avoid jank from clearing the last trade while loading.
if (isDebouncing) {
return { state: TradeState.SYNCING, trade }
} else if (loading) {
return { state: TradeState.LOADING, trade }
}

let otherAmount = undefined
if (tradeType === TradeType.EXACT_INPUT && currencyOut && quoteResult) {
otherAmount = CurrencyAmount.fromRawAmount(currencyOut, quoteResult.quote)
}
if (tradeType === TradeType.EXACT_OUTPUT && currencyIn && quoteResult) {
otherAmount = CurrencyAmount.fromRawAmount(currencyIn, quoteResult.quote)
if (quoteResult) {
switch (tradeType) {
case TradeType.EXACT_INPUT:
otherAmount = CurrencyAmount.fromRawAmount(currencyOut, quoteResult.quote)
break
case TradeType.EXACT_OUTPUT:
otherAmount = CurrencyAmount.fromRawAmount(currencyIn, quoteResult.quote)
break
}
}

if (error || !otherAmount || !route || route.length === 0 || !queryArgs) {
return {
state: TradeState.NO_ROUTE_FOUND,
trade: undefined,
}
return { state: TradeState.NO_ROUTE_FOUND, trade: undefined }
}

try {
const trade = transformRoutesToTrade(route, tradeType, gasUseEstimateUSD)
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 }
if (trade) {
return { state: TradeState.VALID, trade }
}
}, [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])
}
86 changes: 5 additions & 81 deletions src/lib/hooks/swap/useBestTrade.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,9 @@
import { Currency, CurrencyAmount, TradeType } from '@uniswap/sdk-core'
import { useClientSideV3Trade } from 'hooks/useClientSideV3Trade'
import useDebounce from 'hooks/useDebounce'
import { useMemo } from 'react'
import { InterfaceTrade, TradeState } from 'state/routing/types'

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.
* @param tradeType whether the swap is an exact in/out
Expand All @@ -62,47 +18,15 @@ export function useBestTrade(
state: TradeState
trade: InterfaceTrade<Currency, Currency, TradeType> | 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 [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
const clientSORTrade = useClientSideSmartOrderRouterTrade(tradeType, amountSpecified, otherCurrency)

// 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(
tradeType,
useFallback ? debouncedAmount : undefined,
useFallback ? debouncedOtherCurrency : undefined
useFallback ? amountSpecified : undefined,
useFallback ? otherCurrency : undefined
)

return useMemo(
() => ({
...(useFallback ? fallbackTrade : clientSORTrade),
...(syncing ? { state: TradeState.SYNCING } : {}),
...(debouncing ? { state: TradeState.LOADING } : {}),
}),
[debouncing, fallbackTrade, syncing, clientSORTrade, useFallback]
)
return useFallback ? fallbackTrade : clientSORTrade
}

0 comments on commit 82c3068

Please sign in to comment.