diff --git a/wormhole-connect/src/hooks/useComputeQuote.ts b/wormhole-connect/src/hooks/useComputeQuote.ts index fd03987e9..ca3f48cdf 100644 --- a/wormhole-connect/src/hooks/useComputeQuote.ts +++ b/wormhole-connect/src/hooks/useComputeQuote.ts @@ -61,15 +61,16 @@ const useComputeQuote = (props: Props): returnProps => { return; } - const r = config.routes.get(route); - const quote = await r.computeQuote( - amount, - sourceToken, - destToken, - sourceChain, - destChain, - { nativeGas: toNativeToken }, - ); + const quote = ( + await config.routes.getQuotes([route], { + amount, + sourceToken, + destToken, + sourceChain, + destChain, + nativeGas: toNativeToken, + }) + )[0]; if (!quote.success) { if (isActive) { diff --git a/wormhole-connect/src/hooks/useRoutesQuotesBulk.ts b/wormhole-connect/src/hooks/useRoutesQuotesBulk.ts index 72e128a9e..ed41da896 100644 --- a/wormhole-connect/src/hooks/useRoutesQuotesBulk.ts +++ b/wormhole-connect/src/hooks/useRoutesQuotesBulk.ts @@ -1,9 +1,10 @@ import { useState, useEffect, useMemo } from 'react'; import { Chain, routes } from '@wormhole-foundation/sdk'; +import { QuoteParams } from 'routes/operator'; import config from 'config'; -type RoutesQuotesBulkParams = { +type Params = { sourceChain?: Chain; sourceToken: string; destChain?: Chain; @@ -19,10 +20,7 @@ type HookReturn = { isFetching: boolean; }; -const useRoutesQuotesBulk = ( - routes: string[], - params: RoutesQuotesBulkParams, -): HookReturn => { +const useRoutesQuotesBulk = (routes: string[], params: Params): HookReturn => { const [isFetching, setIsFetching] = useState(false); const [quotes, setQuotes] = useState([]); @@ -39,17 +37,15 @@ const useRoutesQuotesBulk = ( } // Forcing TS to infer that fields are non-optional - const rParams = params as Required; + const rParams = params as Required; setIsFetching(true); - config.routes - .computeMultipleQuotes(routes, rParams) - .then((quoteResults) => { - if (!unmounted) { - setQuotes(quoteResults); - setIsFetching(false); - } - }); + config.routes.getQuotes(routes, rParams).then((quoteResults) => { + if (!unmounted) { + setQuotes(quoteResults); + setIsFetching(false); + } + }); return () => { unmounted = true; diff --git a/wormhole-connect/src/hooks/useAvailableRoutes.ts b/wormhole-connect/src/hooks/useSupportedRoutes.ts similarity index 76% rename from wormhole-connect/src/hooks/useAvailableRoutes.ts rename to wormhole-connect/src/hooks/useSupportedRoutes.ts index 9636247c1..712b94f31 100644 --- a/wormhole-connect/src/hooks/useAvailableRoutes.ts +++ b/wormhole-connect/src/hooks/useSupportedRoutes.ts @@ -26,12 +26,10 @@ const useAvailableRoutes = (): void => { let isActive = true; - const getAvailable = async () => { + const getSupportedRoutes = async () => { let routes: RouteState[] = []; await config.routes.forEach(async (name, route) => { let supported = false; - let available = false; - let availabilityError = ''; try { supported = await route.isRouteSupported( @@ -54,25 +52,7 @@ const useAvailableRoutes = (): void => { console.error('Error when checking route is supported:', e, name); } - // Check availability of a route only when it is supported - // Primary goal here is to prevent any unnecessary RPC calls - if (supported) { - try { - available = await route.isRouteAvailable( - token, - destToken, - debouncedAmount, - fromChain, - toChain, - { nativeGas: toNativeToken }, - ); - } catch (e) { - availabilityError = 'Route is unavailable.'; - console.error('Error when checking route is available:', e, name); - } - } - - routes.push({ name, supported, available, availabilityError }); + routes.push({ name, supported }); }); // If NTT or CCTP routes are available, then prioritize them over other routes @@ -99,7 +79,7 @@ const useAvailableRoutes = (): void => { } }; - getAvailable(); + getSupportedRoutes(); return () => { isActive = false; diff --git a/wormhole-connect/src/routes/operator.ts b/wormhole-connect/src/routes/operator.ts index 812ca27fc..36759b3eb 100644 --- a/wormhole-connect/src/routes/operator.ts +++ b/wormhole-connect/src/routes/operator.ts @@ -20,6 +20,8 @@ export interface TxInfo { receipt: routes.Receipt; } +export type QuoteResult = routes.QuoteResult; + type forEachCallback = (name: string, route: SDKv2Route) => T; export const DEFAULT_ROUTES = [ @@ -29,9 +31,19 @@ export const DEFAULT_ROUTES = [ routes.TokenBridgeRoute, ]; +export interface QuoteParams { + sourceChain: Chain; + sourceToken: string; + destChain: Chain; + destToken: string; + amount: string; + nativeGas: number; +} + export default class RouteOperator { preference: string[]; routes: Record; + quoteCache: QuoteCache; constructor(routesConfig: routes.RouteConstructor[] = DEFAULT_ROUTES) { const routes = {}; @@ -48,6 +60,7 @@ export default class RouteOperator { } this.routes = routes; this.preference = preference; + this.quoteCache = new QuoteCache(15_000 /* 15 seconds */); } get(name: string): SDKv2Route { @@ -184,31 +197,22 @@ export default class RouteOperator { return Object.values(supported); } - async computeMultipleQuotes( + async getQuotes( routes: string[], - params: { - sourceChain: Chain; - sourceToken: string; - destChain: Chain; - destToken: string; - amount: string; - nativeGas: number; - }, + params: QuoteParams, ): Promise[]> { - const quoteResults = await Promise.allSettled( - routes.map((route) => - this.get(route).computeQuote( - params.amount, - params.sourceToken, - params.destToken, - params.sourceChain, - params.destChain, - { nativeGas: params.nativeGas }, - ), - ), - ); - - return quoteResults.map((quoteResult) => { + return ( + await Promise.allSettled( + routes.map((route) => { + const cachedResult = this.quoteCache.get(route, params); + if (cachedResult) { + return cachedResult; + } else { + return this.quoteCache.fetch(route, params, this.get(route)); + } + }), + ) + ).map((quoteResult) => { if (quoteResult.status === 'rejected') { return { success: false, @@ -221,6 +225,117 @@ export default class RouteOperator { } } +// This caches successful quote results from SDK routes and handles multiple concurrent +// async functions asking for the same quote gracefully. +// +// If we are already fetching a quote and a second hook requests the same quote elsewhere, +// we queue up a Promise in `QuoteCacheEntry.pending` that we resolve when the original +// quote request is resolved. This just prevents us from making redundant API calls when +// multiple components or hooks are interested in a quote. +class QuoteCache { + ttl: number; + cache: Record; + pending: Record; + + constructor(ttl: number) { + this.ttl = ttl; + this.cache = {}; + this.pending = {}; + } + + quoteParamsKey(routeName: string, params: QuoteParams): string { + return `${routeName}:${params.sourceChain}:${params.sourceToken}:${params.destChain}:${params.destToken}:${params.amount}:${params.nativeGas}`; + } + + get(routeName: string, params: QuoteParams): QuoteResult | null { + const key = this.quoteParamsKey(routeName, params); + const cachedVal = this.cache[key]; + if (cachedVal) { + if (cachedVal.age() < this.ttl) { + return cachedVal.result; + } else { + delete this.cache[key]; + } + } + + return null; + } + + async fetch( + routeName: string, + params: QuoteParams, + route: SDKv2Route, + ): Promise { + const key = this.quoteParamsKey(routeName, params); + const pending = this.pending[key]; + if (pending) { + // We already have a pending request for this key, so don't create a new one. + // Instead, subscribe to its result when it resolves + return new Promise((resolve, reject) => { + pending.push({ resolve, reject }); + }); + } else { + // Initialize list of promises awaiting this result + const returnPromise: Promise = new Promise( + (resolve, reject) => { + this.pending[key] = [{ resolve, reject }]; + }, + ); + + // We don't yet have a pending request for this key, so initiate one + route + .computeQuote( + params.amount, + params.sourceToken, + params.destToken, + params.sourceChain, + params.destChain, + { nativeGas: params.nativeGas }, + ) + .then((result: QuoteResult) => { + const pending = this.pending[key]; + for (const { resolve } of pending) { + resolve(result); + } + delete this.pending[key]; + + // Cache result + this.cache[key] = new QuoteCacheEntry(result); + }) + .catch((err: any) => { + const pending = this.pending[key]; + for (const { reject } of pending) { + reject(err); + } + delete this.pending[key]; + }); + + return returnPromise; + } + } +} + +interface QuotePromiseHandlers { + resolve: (quote: QuoteResult) => void; + reject: (err: Error) => void; +} + +class QuoteCacheEntry { + // Last quote we received (the cached value) + result: QuoteResult; + // Last time we fetched a quote + timestamp: Date; + + constructor(result: QuoteResult) { + this.result = result; + this.timestamp = new Date(); + } + + age(): number { + return new Date().valueOf() - this.timestamp.valueOf(); + } +} + // Convenience function for integrators when adding NTT routes to their config // // Example: diff --git a/wormhole-connect/src/routes/sdkv2/route.ts b/wormhole-connect/src/routes/sdkv2/route.ts index 848db384e..7464cb55d 100644 --- a/wormhole-connect/src/routes/sdkv2/route.ts +++ b/wormhole-connect/src/routes/sdkv2/route.ts @@ -136,55 +136,6 @@ export class SDKv2Route { return this.rc.supportedChains(config.v2Network).includes(chain); } - async isRouteAvailable( - sourceToken: string, - destToken: string, - amount: string, - sourceChain: Chain, - destChain: Chain, - options?: routes.AutomaticTokenBridgeRoute.Options, - ): Promise { - try { - // The route should be available when no amount is set - if (!amount) return true; - const wh = await getWormholeContextV2(); - const route = new this.rc(wh); - if (routes.isAutomatic(route)) { - const req = await this.createRequest( - amount, - sourceToken, - destToken, - sourceChain, - destChain, - ); - const available = await route.isAvailable(req); - if (!available) { - return false; - } - } - const [, quote] = await this.getQuote( - amount, - sourceToken, - destToken, - sourceChain, - destChain, - options, - ); - if (!quote.success) { - return false; - } - } catch (e) { - console.error(`Error thrown in isRouteAvailable`, e); - // TODO is this the right place to try/catch these? - // or deeper inside SDKv2Route? - - // Re-throw for the caller to handle and surface the error message - throw e; - } - - return true; - } - async supportedSourceTokens( tokens: TokenConfig[], _destToken?: TokenConfig | undefined, diff --git a/wormhole-connect/src/store/transferInput.ts b/wormhole-connect/src/store/transferInput.ts index eac908135..df076167e 100644 --- a/wormhole-connect/src/store/transferInput.ts +++ b/wormhole-connect/src/store/transferInput.ts @@ -105,8 +105,6 @@ export type TransferValidations = { export type RouteState = { name: string; supported: boolean; - available: boolean; - availabilityError?: string; }; export interface TransferInputState { diff --git a/wormhole-connect/src/views/Bridge/RouteOptions.tsx b/wormhole-connect/src/views/Bridge/RouteOptions.tsx index de071c300..fc47bac5a 100644 --- a/wormhole-connect/src/views/Bridge/RouteOptions.tsx +++ b/wormhole-connect/src/views/Bridge/RouteOptions.tsx @@ -17,7 +17,7 @@ import ArrowRightIcon from 'icons/ArrowRight'; import Options from 'components/Options'; import Price from 'components/Price'; import { finality, Chain } from '@wormhole-foundation/sdk'; -import useAvailableRoutes from 'hooks/useAvailableRoutes'; +import useSupportedRoutes from 'hooks/useSupportedRoutes'; const useStyles = makeStyles()((theme: any) => ({ link: { @@ -353,13 +353,12 @@ function RouteOptions() { (state: RootState) => state.transferInput, ); - useAvailableRoutes(); + useSupportedRoutes(); const onSelect = useCallback( (value: string) => { if (routeStates && routeStates.some((rs) => rs.name === value)) { - const route = routeStates.find((rs) => rs.name === value); - if (route?.available) dispatch(setTransferRoute(value)); + dispatch(setTransferRoute(value)); } }, [routeStates, dispatch], @@ -384,13 +383,11 @@ function RouteOptions() { controlStyle={CollapseControlStyle.None} > - {allRoutes.map(({ name, available }) => { + {allRoutes.map(({ name }) => { return { key: name, - disabled: !available, - child: ( - - ), + disabled: false, + child: , }; })} diff --git a/wormhole-connect/src/views/v2/Bridge/ReviewTransaction/index.tsx b/wormhole-connect/src/views/v2/Bridge/ReviewTransaction/index.tsx index 6d5c9c2aa..bfcaba043 100644 --- a/wormhole-connect/src/views/v2/Bridge/ReviewTransaction/index.tsx +++ b/wormhole-connect/src/views/v2/Bridge/ReviewTransaction/index.tsx @@ -329,7 +329,6 @@ const ReviewTransaction = (props: Props) => { ({ type Props = { route: RouteData; - available: boolean; isSelected: boolean; error?: string; destinationGasDrop?: number; @@ -329,18 +328,15 @@ const SingleRoute = (props: Props) => { }, [destTokenConfig, providerText, receiveAmount, tokenPrices]); // There are three states for the Card area cursor: - // 1- If not available in the first place, "not-allowed" - // 2- If available but no action handler provided, fall back to default - // 3- Both available and there is an action handler, "pointer" + // 1- If no action handler provided, fall back to default + // 2- Otherwise there is an action handler, "pointer" const cursor = useMemo(() => { - if (!props.available) { - return 'not-allowed'; - } else if (typeof props.onSelect !== 'function') { + if (typeof props.onSelect !== 'function') { return 'auto'; } return 'pointer'; - }, [props.available, props.onSelect]); + }, [props.onSelect]); if (isEmptyObject(props.route)) { return <>; @@ -364,11 +360,11 @@ const SingleRoute = (props: Props) => { ? '1px solid #C1BBF6' : '1px solid transparent', cursor, - opacity: props.available ? 1 : 0.6, + opacity: 1, }} > { props.onSelect?.(props.route.name); diff --git a/wormhole-connect/src/views/v2/Bridge/Routes/index.tsx b/wormhole-connect/src/views/v2/Bridge/Routes/index.tsx index 854c01d14..bbc2f0fba 100644 --- a/wormhole-connect/src/views/v2/Bridge/Routes/index.tsx +++ b/wormhole-connect/src/views/v2/Bridge/Routes/index.tsx @@ -122,7 +122,7 @@ const Routes = ({ sortedSupportedRoutes, ...props }: Props) => { return ( <> - {renderRoutes.map(({ name, available, availabilityError }) => { + {renderRoutes.map(({ name }) => { const routeConfig = RoutesConfig[name]; const isSelected = routeConfig.name === props.selectedRoute; const quoteResult = quotesMap[name]; @@ -137,8 +137,7 @@ const Routes = ({ sortedSupportedRoutes, ...props }: Props) => { { // Set selectedRoute if the route is auto-selected // After the auto-selection, we set selectedRoute when user clicks on a route in the list useEffect(() => { - const validRoutes = sortedSupportedRoutes.filter( - (rs) => rs.supported && rs.available, - ); + const validRoutes = sortedSupportedRoutes.filter((rs) => rs.supported); const autoselectedRoute = route || validRoutes[0]?.name; // avoids overwriting selected route @@ -177,7 +175,7 @@ const Bridge = () => { }); // Pre-fetch available routes - useAvailableRoutes(); + useSupportedRoutes(); // Connect to any previously used wallets for the selected networks useConnectToLastUsedWallet(); @@ -357,11 +355,9 @@ const Bridge = () => { selectedRoute && Number(amount) > 0; - const availableRouteSelected = useMemo( + const supportedRouteSelected = useMemo( () => - routeStates?.find?.( - (rs) => rs.name === selectedRoute && !!rs.available && !!rs.supported, - ), + routeStates?.find?.((rs) => rs.name === selectedRoute && !!rs.supported), [routeStates, selectedRoute], ); @@ -370,7 +366,7 @@ const Bridge = () => {