Skip to content

Commit

Permalink
Async quote cache (wormhole-foundation#2535)
Browse files Browse the repository at this point in the history
* first pass at an async quote cache

* get rid of concept of a route being "unavailable"

* rename hook to useSupportedRoutes

* clean up and documentation

* remove temp var

* add Promise to this.pending before fetching quote

* remove consolespam

* add nativeGas to cache key
  • Loading branch information
artursapek authored Sep 9, 2024
1 parent 8085c82 commit 7d4b7c5
Show file tree
Hide file tree
Showing 11 changed files with 181 additions and 153 deletions.
19 changes: 10 additions & 9 deletions wormhole-connect/src/hooks/useComputeQuote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
24 changes: 10 additions & 14 deletions wormhole-connect/src/hooks/useRoutesQuotesBulk.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<QuoteResult[]>([]);

Expand All @@ -39,17 +37,15 @@ const useRoutesQuotesBulk = (
}

// Forcing TS to infer that fields are non-optional
const rParams = params as Required<RoutesQuotesBulkParams>;
const rParams = params as Required<QuoteParams>;

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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -99,7 +79,7 @@ const useAvailableRoutes = (): void => {
}
};

getAvailable();
getSupportedRoutes();

return () => {
isActive = false;
Expand Down
161 changes: 138 additions & 23 deletions wormhole-connect/src/routes/operator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface TxInfo {
receipt: routes.Receipt;
}

export type QuoteResult = routes.QuoteResult<routes.Options>;

type forEachCallback<T> = (name: string, route: SDKv2Route) => T;

export const DEFAULT_ROUTES = [
Expand All @@ -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<string, SDKv2Route>;
quoteCache: QuoteCache;

constructor(routesConfig: routes.RouteConstructor<any>[] = DEFAULT_ROUTES) {
const routes = {};
Expand All @@ -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 {
Expand Down Expand Up @@ -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<routes.QuoteResult<routes.Options>[]> {
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,
Expand All @@ -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<string, QuoteCacheEntry>;
pending: Record<string, QuotePromiseHandlers[]>;

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<QuoteResult> {
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<QuoteResult> = 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:
Expand Down
49 changes: 0 additions & 49 deletions wormhole-connect/src/routes/sdkv2/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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,
Expand Down
2 changes: 0 additions & 2 deletions wormhole-connect/src/store/transferInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,6 @@ export type TransferValidations = {
export type RouteState = {
name: string;
supported: boolean;
available: boolean;
availabilityError?: string;
};

export interface TransferInputState {
Expand Down
Loading

0 comments on commit 7d4b7c5

Please sign in to comment.