import type { ExtendedChain, Route, Token } from '@lifi/sdk' import { ChainType, convertQuoteToRoute, getContractCallsQuote, getRelayerQuote, getRoutes, LiFiErrorCode, parseUnits, } from '@lifi/sdk' import { useAccount } from '@lifi/wallet-management' import { useChainTypeFromAddress, useEthereumContext, } from '@lifi/widget-provider' import { keepPreviousData, useQuery, useQueryClient, } from '@tanstack/react-query' import { useCallback, useMemo } from 'react' import { useSDKClient } from '../providers/SDKClientProvider.js' import { useWidgetConfig } from '../providers/WidgetProvider/WidgetProvider.js' import { useFieldValues } from '../stores/form/useFieldValues.js' import { useNavigationTabsStore } from '../stores/navigationTabs/useNavigationTabsStore.js' import { useIntermediateRoutesStore } from '../stores/routes/useIntermediateRoutesStore.js' import { useSetExecutableRoute } from '../stores/routes/useSetExecutableRoute.js' import { defaultSlippage } from '../stores/settings/createSettingsStore.js' import { useSettings } from '../stores/settings/useSettings.js' import { WidgetEvent } from '../types/events.js' import type { TokensByChain } from '../types/token.js' import { getQueryKey } from '../utils/queries.js' import { updateTokenInCache } from '../utils/token.js' import { useChain } from './useChain.js' import { useDebouncedWatch } from './useDebouncedWatch.js' import { useGasRefuel } from './useGasRefuel.js' import { useIsBatchingSupported } from './useIsBatchingSupported.js' import { useSwapOnly } from './useSwapOnly.js' import { useToken } from './useToken.js' import { useWidgetEvents } from './useWidgetEvents.js' const refetchTime = 60_000 interface RoutesProps { observableRoute?: Route /** * Address to quote from when no wallet is connected. Treated as a non-signing * placeholder, so signer-dependent (relayer/permit2) quotes are skipped. */ quoteFromAddress?: string /** * Keep showing the previous result while a new query (e.g. a token/amount * change) is fetching, instead of clearing to a loading state. */ keepPreviousData?: boolean } export const useRoutes = ({ observableRoute, quoteFromAddress, keepPreviousData: keepPreviousDataEnabled, }: RoutesProps = {}): { routes: Route[] | undefined isLoading: boolean isFetching: boolean isFetched: boolean isError: boolean dataUpdatedAt: number refetchTime: number refetch: () => void fromChain: ExtendedChain | undefined toChain: ExtendedChain | undefined queryKey: readonly unknown[] setReviewableRoute: (route: Route) => void } => { const { mode, modeOptions, contractTool, bridges, exchanges, feeConfig, useRelayerRoutes, keyPrefix, } = useWidgetConfig() const sdkClient = useSDKClient() const setExecutableRoute = useSetExecutableRoute() const queryClient = useQueryClient() const emitter = useWidgetEvents() const swapOnly = useSwapOnly() const isPrivate = useNavigationTabsStore( (state) => state.activeTab === 'private' ) const { disabledBridges, disabledExchanges, enabledBridges, enabledExchanges, enabledAutoRefuel, routePriority, slippage, } = useSettings([ 'disabledBridges', 'disabledExchanges', 'enabledBridges', 'enabledExchanges', 'enabledAutoRefuel', 'routePriority', 'slippage', ]) const [fromTokenAmount] = useDebouncedWatch(500, 'fromAmount') // Debounce toAmount like fromAmount to avoid a request per keystroke const [toTokenAmount] = useDebouncedWatch(500, 'toAmount') const [ fromChainId, fromTokenAddress, toAddress, toChainId, toTokenAddress, contractCalls, ] = useFieldValues( 'fromChain', 'fromToken', 'toAddress', 'toChain', 'toToken', 'contractCalls' ) const [validUntilDuration, partiallyFillable] = useFieldValues( 'validUntil', 'partiallyFillable' ) const { token: fromToken } = useToken(fromChainId, fromTokenAddress) const { token: toToken } = useToken(toChainId, toTokenAddress) const { chain: fromChain } = useChain(fromChainId) const { chain: toChain } = useChain(toChainId) const { enabled: enabledRefuel, fromAmount: gasRecommendationFromAmount } = useGasRefuel() const { getChainTypeFromAddress } = useChainTypeFromAddress() const { isGaslessStep, disableMessageSigning } = useEthereumContext() const { account } = useAccount({ chainType: fromChain?.chainType }) const { isBatchingSupported, isBatchingSupportedLoading } = useIsBatchingSupported(fromChain, account.address) // In limit mode toAmount is the user's limit target, derived from the sell // amount and limit price. It's keyed and sent to the backend so changing the // limit fetches a fresh quote. The receive card is read-only (it displays the // quote and never writes toAmount back), so keying on it can't loop. Both a // sell amount and a limit price (→ toAmount) are required before fetching. const hasAmount = mode === 'limit' ? Number(fromTokenAmount) > 0 && Number(toTokenAmount) > 0 : Number(fromTokenAmount) > 0 || Number(toTokenAmount) > 0 const customType = mode === 'custom' ? modeOptions?.custom?.type : undefined const isContractCallQuote = mode === 'custom' && Boolean(contractCalls?.length) // A contract-call quote needs a connected wallet to resolve fromAddress. // Deposit funding quotes a plain route with no contract calls and no wallet // (a placeholder fromAddress arrives via quoteFromAddress); every other custom // flow stays gated on a real wallet, as before. const contractCallQuoteEnabled: boolean = isContractCallQuote ? Boolean(account.address) : mode !== 'custom' || customType === 'deposit' const effectiveFromAddress = account.address ?? quoteFromAddress // When we bridge between ecosystems we need to be sure toAddress is set and has the same chainType as toChain // If toAddress is set, it must have the same chainType as toChain const hasToAddressAndChainTypeSatisfied: boolean = !!toChain && !!toAddress && getChainTypeFromAddress(toAddress) === toChain.chainType // We need to check for toAddress only if it is set const isToAddressSatisfied = toAddress ? hasToAddressAndChainTypeSatisfied : true // toAddress might be an empty string, but we need to pass undefined if there is no value const toWalletAddress = toAddress || undefined // We need to send the full allowed tools array if custom tool settings are applied const allowedBridges = bridges?.allow?.length || bridges?.deny?.length ? enabledBridges : undefined const allowedExchanges = exchanges?.allow?.length || exchanges?.deny?.length ? enabledExchanges : undefined const allowSwitchChain = sdkClient.config?.routeOptions?.allowSwitchChain const isEnabled = Boolean(Number(fromChain?.id)) && Boolean(Number(toChain?.id)) && Boolean(fromToken?.address) && Boolean(toToken?.address) && !Number.isNaN(slippage) && hasAmount && isToAddressSatisfied && contractCallQuoteEnabled && !isBatchingSupportedLoading // Some values should be strictly typed and isEnabled ensures that const queryKey = useMemo( () => [ getQueryKey('routes', keyPrefix), effectiveFromAddress, fromChain?.id as number, fromToken?.address as string, fromTokenAmount, toWalletAddress, toChain?.id as number, toToken?.address as string, toTokenAmount, validUntilDuration, partiallyFillable, contractCalls, slippage, swapOnly, disabledBridges, disabledExchanges, allowedBridges, allowedExchanges, routePriority, mode, allowSwitchChain, enabledRefuel && enabledAutoRefuel, gasRecommendationFromAmount, feeConfig?.fee, disableMessageSigning, !!isBatchingSupported, isPrivate, observableRoute?.id, ] as const, [ keyPrefix, effectiveFromAddress, fromChain?.id, fromToken?.address, fromTokenAmount, toWalletAddress, toChain?.id, toToken?.address, toTokenAmount, validUntilDuration, partiallyFillable, contractCalls, slippage, swapOnly, disabledBridges, disabledExchanges, allowedBridges, allowedExchanges, routePriority, mode, allowSwitchChain, enabledRefuel, enabledAutoRefuel, gasRecommendationFromAmount, feeConfig?.fee, disableMessageSigning, isBatchingSupported, isPrivate, observableRoute?.id, ] ) const { getIntermediateRoutes, setIntermediateRoutes } = useIntermediateRoutesStore() const { data, isLoading, isFetching, isFetched, isError, dataUpdatedAt, refetch, } = useQuery({ queryKey, queryFn: async ({ queryKey: [ _, fromAddress, fromChainId, fromTokenAddress, fromTokenAmount, toAddress, toChainId, toTokenAddress, toTokenAmount, validUntilDuration, partiallyFillable, contractCalls, slippage = defaultSlippage, swapOnly, disabledBridges, disabledExchanges, allowedBridges, allowedExchanges, routePriority, mode, allowSwitchChain, enabledRefuel, gasRecommendationFromAmount, configuredFee, disableMessageSigning, isBatchingSupported, isPrivate, // _observableRouteId must be the last element in the query key _observableRouteId, ], signal, }) => { const fromAmount = parseUnits(fromTokenAmount, fromToken!.decimals) const toAmount = toTokenAmount ? parseUnits(toTokenAmount, toToken!.decimals) : undefined const formattedSlippage = slippage ? Number.parseFloat(slippage) / 100 : defaultSlippage const allowBridges = swapOnly ? [] : observableRoute ? observableRoute.steps.flatMap((step) => step.includedSteps.reduce((toolKeys, includedStep) => { if (includedStep.type === 'cross') { toolKeys.push(includedStep.toolDetails.key) } return toolKeys }, [] as string[]) ) : allowedBridges const allowExchanges = observableRoute ? observableRoute.steps.flatMap((step) => step.includedSteps.reduce((toolKeys, includedStep) => { if (includedStep.type === 'swap') { toolKeys.push(includedStep.toolDetails.key) } return toolKeys }, [] as string[]) ) : allowedExchanges const calculatedFee = await feeConfig?.calculateFee?.({ fromChain: fromChain!, toChain: toChain!, fromToken: fromToken!, toToken: toToken!, fromAddress, toAddress, fromAmount, toAmount, slippage: formattedSlippage, }) if (mode === 'custom' && contractCalls?.length && toAmount) { const contractCallQuote = await getContractCallsQuote( sdkClient, { // Contract calls are enabled only when fromAddress is set fromAddress: fromAddress as string, fromChain: fromChainId, fromToken: fromTokenAddress, toAmount: toAmount.toString(), toChain: toChainId, toToken: toTokenAddress, contractCalls, denyBridges: disabledBridges.length ? disabledBridges : undefined, denyExchanges: disabledExchanges.length ? disabledExchanges : undefined, allowBridges, allowExchanges, toFallbackAddress: toAddress, slippage: formattedSlippage, fee: calculatedFee || configuredFee, }, { signal } ) contractCallQuote.action.toToken = toToken! const customStep = mode === 'custom' ? contractCallQuote.includedSteps?.find( (step) => step.type === 'custom' ) : undefined if (customStep && contractTool) { const toolDetails = { key: contractTool.name, name: contractTool.name, logoURI: contractTool.logoURI, } customStep.toolDetails = toolDetails contractCallQuote.toolDetails = toolDetails } const route: Route = convertQuoteToRoute(contractCallQuote) return [route] } // Prevent sending a request for the same chain token combinations. // Exception: proceed anyway if mode is custom and modeOptions custom type is deposit if ( fromChainId === toChainId && fromTokenAddress === toTokenAddress && !(mode === 'custom' && modeOptions?.custom?.type === 'deposit') ) { return } const isObservableRelayerRoute = observableRoute?.steps?.some( (step) => !!isGaslessStep?.(step, fromChain) ) const shouldUseMainRoutes = !observableRoute || !isObservableRelayerRoute const shouldUseRelayerQuote = // Relayer quotes don't support limit-order params mode !== 'limit' && account.address && fromAddress && fromChain?.chainType === ChainType.EVM && fromChain.permit2 && fromChain.permit2Proxy && fromChain.relayerSupported && fromChain.nativeToken.address !== fromTokenAddress && useRelayerRoutes && !isBatchingSupported && (!observableRoute || isObservableRelayerRoute) const limitOrderRouteParams = mode === 'limit' ? { toAmount: toAmount?.toString(), validUntil: Math.floor(Date.now() / 1000) + validUntilDuration, partiallyFillable: partiallyFillable, } : undefined const mainRoutesPromise = shouldUseMainRoutes ? getRoutes( sdkClient, { fromAddress, fromAmount: fromAmount.toString(), fromChainId, fromTokenAddress, toAddress, toChainId, toTokenAddress, fromAmountForGas: enabledRefuel && gasRecommendationFromAmount ? gasRecommendationFromAmount : undefined, ...limitOrderRouteParams, options: { allowSwitchChain: mode === 'refuel' ? false : allowSwitchChain, bridges: allowBridges?.length || disabledBridges.length ? { allow: allowBridges, deny: disabledBridges.length ? disabledBridges : undefined, } : undefined, exchanges: allowExchanges?.length || disabledExchanges.length ? { allow: allowExchanges, deny: disabledExchanges.length ? disabledExchanges : undefined, } : undefined, order: routePriority, slippage: formattedSlippage, fee: calculatedFee || configuredFee, executionType: disableMessageSigning ? 'transaction' : 'all', ...(isPrivate && { private: true }), }, }, { signal } ) : Promise.resolve(null) const relayerQuotePromise = shouldUseRelayerQuote ? getRelayerQuote( sdkClient, { fromAddress, fromAmount: fromAmount.toString(), fromChain: fromChainId, fromToken: fromTokenAddress, toAddress, toChain: toChainId, toToken: toTokenAddress, fromAmountForGas: enabledRefuel && gasRecommendationFromAmount ? gasRecommendationFromAmount : undefined, order: routePriority, slippage: formattedSlippage, fee: calculatedFee || configuredFee, ...(allowBridges?.length || disabledBridges.length ? { allowBridges: allowBridges, denyBridges: disabledBridges.length ? disabledBridges : undefined, } : undefined), ...(allowExchanges?.length || disabledExchanges.length ? { allowExchanges: allowExchanges, denyExchanges: disabledExchanges.length ? disabledExchanges : undefined, } : undefined), }, { signal } ) .then(convertQuoteToRoute) .catch(() => null) : Promise.resolve(null) // Wait for the main routes to complete first const routesResult = await mainRoutesPromise if (routesResult?.routes[0] && fromAddress) { // Update local tokens cache to keep priceUSD in sync const { fromToken, toToken } = routesResult.routes[0] ;[fromToken, toToken].forEach((token) => { // Update main tokens cache (verified) queryClient.setQueriesData( { queryKey: [getQueryKey('tokens', keyPrefix)] }, (data) => updateTokenInCache(data, token) ) // Update search tokens cache (unverified) - matches any search query queryClient.setQueriesData( { queryKey: [getQueryKey('tokens-search', keyPrefix)], exact: false, }, (data) => updateTokenInCache(data, token) ) queryClient.setQueriesData( { queryKey: [ getQueryKey('token-balances', keyPrefix), fromAddress, token.chainId, ], }, (data) => { if (data) { const clonedData = [...data] const index = clonedData.findIndex( (dataToken) => dataToken.address === token.address ) if (index >= 0) { clonedData[index] = { ...clonedData[index], ...token, } } return clonedData } } ) }) } const initialRoutes = routesResult?.routes ?? [] if (shouldUseRelayerQuote && initialRoutes.length) { setIntermediateRoutes(queryKey, initialRoutes) emitter.emit(WidgetEvent.AvailableRoutes, initialRoutes) // Return early if we're only using main routes } else if (shouldUseMainRoutes) { // If we don't need relayer quote, return the initial routes emitter.emit(WidgetEvent.AvailableRoutes, initialRoutes) return initialRoutes } const relayerRouteResult = await relayerQuotePromise // If we have a relayer route, add it to the routes array if (relayerRouteResult) { // Insert the relayer route at position 1 (after the first route) initialRoutes.splice(1, 0, relayerRouteResult) // Emit the updated routes emitter.emit(WidgetEvent.AvailableRoutes, initialRoutes) } return initialRoutes }, enabled: isEnabled, staleTime: refetchTime, placeholderData: keepPreviousDataEnabled ? keepPreviousData : undefined, refetchInterval(query) { return Math.min( Math.abs(refetchTime - (Date.now() - query.state.dataUpdatedAt)), refetchTime ) }, retry(failureCount, error: any) { if (process.env.NODE_ENV === 'development') { console.warn('Route query failed:', { failureCount, error }) } if (failureCount >= 3) { return false } if (error?.code === LiFiErrorCode.NotFound) { return false } return true }, }) const setReviewableRoute = useCallback( (route: Route) => { const queryDataKey = queryKey.toSpliced(queryKey.length - 1, 1, route.id) queryClient.setQueryData(queryDataKey, [route], { updatedAt: dataUpdatedAt || Date.now(), }) setExecutableRoute(route) }, [queryClient, dataUpdatedAt, setExecutableRoute, queryKey] ) return { routes: data || getIntermediateRoutes(queryKey), isLoading: isEnabled && isLoading, isFetching, isFetched, isError, dataUpdatedAt, refetchTime, refetch, fromChain, toChain, queryKey, setReviewableRoute, } }