diff --git a/.gitignore b/.gitignore index f951efb0b..e868485d9 100644 --- a/.gitignore +++ b/.gitignore @@ -37,7 +37,6 @@ tsconfig.tsbuildinfo # Local Netlify folder .netlify -.vscode .ignore .yalc yalc.lock diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..7ed67eb07 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "prettier.requireConfig": true, + "workbench.colorCustomizations": { + "activityBar.background": "#02072d", + "titleBar.activeBackground": "#000285", + "titleBar.activeForeground": "#dae6f9" + } +} diff --git a/src/hooks/useConfirmTransaction.ts b/src/hooks/useConfirmTransaction.ts index 30032afa3..07c437edf 100644 --- a/src/hooks/useConfirmTransaction.ts +++ b/src/hooks/useConfirmTransaction.ts @@ -161,18 +161,17 @@ const useConfirmTransaction = (props: Props): ReturnProps => { ); } - const [sdkRoute, receipt] = await config.routes - .get(route) - .send( - sourceToken, - amount, - sourceChain, - signer, - destChain, - receivingWallet.address, - destToken, - { nativeGas: toNativeToken }, - ); + const [sdkRoute, receipt] = await config.routes.execute( + route, + sourceToken, + amount, + sourceChain, + signer, + destChain, + receivingWallet.address, + destToken, + { nativeGas: toNativeToken }, + ); // Clear cached balances on sending chain clearBalanceCache(sendingWallet, sourceChain); diff --git a/src/hooks/useFetchQuotes.ts b/src/hooks/useFetchQuotes.ts index dc3e527ad..14fb193fe 100644 --- a/src/hooks/useFetchQuotes.ts +++ b/src/hooks/useFetchQuotes.ts @@ -83,7 +83,10 @@ export default (routes: string[], params: Params): HookReturn => { return; } - const nextExpiry = config.routes.quoteCache.nextExpiry(routes, rParams); + const nextExpiry = config.routes.quoteMetadataCache.nextExpiry( + routes, + rParams, + ); if (!nextExpiry) { return; diff --git a/src/routes/mayan/index.ts b/src/routes/mayan/index.ts index fcbd99072..0f38d640e 100644 --- a/src/routes/mayan/index.ts +++ b/src/routes/mayan/index.ts @@ -611,9 +611,11 @@ class MayanRouteBase extends routes.AutomaticRoute< ), }; - const deadline64Seconds = parseInt(quote.deadline64, 10) * 1000; - const expires = deadline64Seconds - ? new Date(deadline64Seconds) + // deadline64 is in seconds + const deadline64MilliSeconds = parseInt(quote.deadline64, 10) * 1000; + + const expires = deadline64MilliSeconds + ? new Date(deadline64MilliSeconds) : undefined; const fullQuote: Q = { diff --git a/src/routes/operator.ts b/src/routes/operator.ts index d16296198..74fc7d0a5 100644 --- a/src/routes/operator.ts +++ b/src/routes/operator.ts @@ -4,10 +4,18 @@ import { parseTokenKey, tokenKey } from 'config/tokens'; import { maybeLogSdkError } from 'utils/errors'; import memoize from 'fast-memoize'; -import type { Chain, TransactionId, TokenId } from '@wormhole-foundation/sdk'; +import type { + Chain, + TransactionId, + TokenId, + Network, + Signer, +} from '@wormhole-foundation/sdk'; import { routes, amount as sdkAmount } from '@wormhole-foundation/sdk'; -import SDKv2Route from './sdkv2'; +import SDKv2Route from './sdkv2/route'; +import type { QuoteMetadata } from './types'; +import { getDefaultQuoteExpiry, getQuoteExpiry } from 'utils/routes'; export interface TxInfo { route: string; @@ -39,7 +47,7 @@ export interface QuoteParams { export default class RouteOperator { preference: string[]; routes: Record; - quoteCache: QuoteCache; + quoteMetadataCache: QuoteMetadataCache; constructor(routesConfig: routes.RouteConstructor[] = DEFAULT_ROUTES) { const routes = {}; @@ -56,7 +64,7 @@ export default class RouteOperator { } this.routes = routes; this.preference = preference; - this.quoteCache = new QuoteCache(); + this.quoteMetadataCache = new QuoteMetadataCache(); } get(name: string): SDKv2Route { @@ -168,24 +176,29 @@ export default class RouteOperator { async getQuotes( routes: string[], params: QuoteParams, - ): Promise>> { + ): Promise> { const results = 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)); + const quoteMetadata = this.quoteMetadataCache.get(route, params); + + if (quoteMetadata?.quote) { + return quoteMetadata.quote; } + + return this.quoteMetadataCache.fetchQuote( + route, + params, + this.get(route), + ); }), ); - // Convert the array of promise results to a quoteName=>quoteResult map - const quotes = {}; + const quotes: Record = {}; for (let i = 0; i < routes.length; i++) { const route = routes[i]; const result = results[i]; + if (result.status === 'rejected') { quotes[route] = { success: false, @@ -215,17 +228,57 @@ export default class RouteOperator { return isSupported; }); + + async execute( + routeName: string, + sourceToken: Token, + amount: sdkAmount.Amount, + sourceChain: Chain, + signer: Signer, + destChain: Chain, + recipientAddress: string, + destToken: Token, + options: routes.AutomaticTokenBridgeRoute.Options, + ) { + const route = this.get(routeName); + + const quoteParams: QuoteParams = { + amount, + sourceChain, + sourceToken, + destChain, + destToken, + nativeGas: options.nativeGas, + recipient: recipientAddress, + }; + + let quoteMetadata = this.quoteMetadataCache.get(routeName, quoteParams); + + if (!quoteMetadata) { + quoteMetadata = await route.getQuote( + amount, + sourceToken, + destToken, + sourceChain, + destChain, + options, + recipientAddress, + ); + } + + return route.send(quoteMetadata, signer, destChain, recipientAddress); + } } // 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 +// we queue up a Promise in `QuoteMetadataEntry.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 { - cache: Record; +class QuoteMetadataCache { + cache: Record; pending: Record; constructor() { @@ -243,96 +296,88 @@ class QuoteCache { )}:${params.nativeGas}:${params.recipient}`; } - get(routeName: string, params: QuoteParams): QuoteResult | null { + get(routeName: string, params: QuoteParams): QuoteMetadata | null { const key = this.quoteParamsKey(routeName, params); - const cachedVal = this.cache[key]; - if (cachedVal) { - if (cachedVal.ttl() > 5) { - return cachedVal.result; - } else { - delete this.cache[key]; - } + const quoteMetadata = this.cache[key]; + const hasQuoteExpired = quoteMetadata?.ttl() <= 5_000; + + if (!quoteMetadata || hasQuoteExpired) { + delete this.cache[key]; + return null; } - return null; + return { + quote: quoteMetadata.quote, + request: quoteMetadata.request, + routeInstance: quoteMetadata.routeInstance, + }; } - async fetch( + async fetchQuote( routeName: string, params: QuoteParams, route: SDKv2Route, ): Promise { - console.debug('Fetching quote', routeName, params); - 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 }, - params.recipient, - ) - .then((result: QuoteResult) => { - const pending = this.pending[key]; - for (const { resolve } of pending) { - resolve(result); - } - delete this.pending[key]; - - if (result.success) { - const now = Date.now(); - - // A valid expiry should be at least 5 seconds in the future - // and if not, we should default to 60 seconds. - const isValidExpiry = - result.expires instanceof Date && - result.expires.getTime() > now + 5_000; - - if (!isValidExpiry) { - result.expires = new Date(now + 60_000); - } - } - - console.debug(`Fetched quote`, routeName, result); - - this.cache[key] = new QuoteCacheEntry(result); - }) - .catch((err: any) => { - console.debug(`Error fetching quote`, routeName, err); - const pending = this.pending[key]; - for (const { reject } of pending) { - reject(err); - } - delete this.pending[key]; - - // Cache uncaught error - this.cache[key] = new QuoteCacheEntry({ + // We don't yet have a pending request for this key, so initiate one + route + .getQuote( + params.amount, + params.sourceToken, + params.destToken, + params.sourceChain, + params.destChain, + { nativeGas: params.nativeGas }, + params.recipient, + ) + .then(({ routeInstance, quote, request }: QuoteMetadata) => { + const pending = this.pending[key]; + + for (const { resolve } of pending) { + resolve(quote); + } + + delete this.pending[key]; + + if (quote.success) { + quote.expires = getQuoteExpiry(quote.expires); + } + + this.cache[key] = new QuoteMetadataEntry(quote, routeInstance, request); + }) + .catch((err: any) => { + const pending = this.pending[key]; + + for (const { reject } of pending) { + reject(err); + } + + delete this.pending[key]; + + // Cache uncaught error + this.cache[key] = new QuoteMetadataEntry( + { success: false, error: err, - }); - }); + }, + {} as routes.Route, + {} as routes.RouteTransferRequest, + ); + }); - return returnPromise; - } + return new Promise((resolve, reject) => { + this.pending[key] = [{ resolve, reject }]; + }); } nextExpiry(routes: string[], params: QuoteParams): Date | undefined { @@ -359,31 +404,41 @@ interface QuotePromiseHandlers { reject: (err: Error) => void; } -class QuoteCacheEntry { - // Last quote we received (the cached value) - result: QuoteResult; +class QuoteMetadataEntry { + // Last quote we received + quote: QuoteResult; // Last time we fetched a quote timestamp: Date; - - constructor(result: QuoteResult) { - this.result = result; + // Optional route used for the quote + routeInstance: routes.Route; + // Optional request used for the quote + request: routes.RouteTransferRequest; + + constructor( + quote: QuoteResult, + routeInstance: routes.Route, + request: routes.RouteTransferRequest, + ) { + this.quote = quote; + this.routeInstance = routeInstance; + this.request = request; this.timestamp = new Date(); } // Number of seconds this quote is still valid for before we should fetch a new one - expires(): Date { - if (this.result.success) { - // For a successful quote, if it specifies an expiry we return that - // otherwise we default to a TTL 1 minute - return this.result.expires ?? new Date(this.timestamp.valueOf() + 60_000); + expires() { + if (this.quote.success) { + return ( + this.quote.expires ?? getDefaultQuoteExpiry(this.timestamp.getTime()) + ); } else { - // We cache errors for 10 seconds - return new Date(this.timestamp.valueOf() + 120_000); + // We cache errors for 120 seconds + return new Date(this.timestamp.getTime() + 120_000); } } - // TTL in seconds before quote expires - ttl(): number { - return (this.expires().valueOf() - Date.now().valueOf()) / 1000; + // TTL in ms before quote expires + ttl() { + return this.expires().getTime() - Date.now(); } } diff --git a/src/routes/sdkv2/index.ts b/src/routes/sdkv2/index.ts deleted file mode 100644 index 178ddbeb6..000000000 --- a/src/routes/sdkv2/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { SDKv2Route } from './route'; - -export default SDKv2Route; diff --git a/src/routes/sdkv2/route.ts b/src/routes/sdkv2/route.ts index 25c465733..7fa9b7979 100644 --- a/src/routes/sdkv2/route.ts +++ b/src/routes/sdkv2/route.ts @@ -25,11 +25,12 @@ import { getWrappedNativeToken, shouldFilterSameChainToken, } from 'utils/wrappedNativeTokens'; +import type { QuoteMetadata } from '../types'; type Amount = sdkAmount.Amount; // =^o^= -export class SDKv2Route { +export default class SDKv2Route { // TODO: remove this IS_TOKEN_BRIDGE_ROUTE = false; @@ -178,14 +179,12 @@ export class SDKv2Route { destChain: Chain, options?: routes.AutomaticTokenBridgeRoute.Options, recipient?: string, - ): Promise< - [ - routes.Route, - routes.QuoteResult, - routes.RouteTransferRequest, - ] - > { - const req = await this.createRequest( + ): Promise { + if (!sourceChain || !destChain || !sourceToken || !destToken) { + throw new Error('Malformed quote request'); + } + + const request = await this.createRequest( sourceToken, destToken, sourceChain, @@ -193,9 +192,9 @@ export class SDKv2Route { recipient, ); - const wh = await getWormholeContextV2(); - const route = new this.rc(wh); - const validationResult = await route.validate(req, { + const routeInstance = await this.createRouteInstance(); + + const validationResult = await routeInstance.validate(request, { amount: sdkAmount.display(amount), options, }); @@ -204,9 +203,16 @@ export class SDKv2Route { throw validationResult.error; } - const quote = await route.quote(req, validationResult.params); + const quote = await routeInstance.quote(request, validationResult.params); - return [route, quote, req]; + return { routeInstance, quote, request }; + } + + async createRouteInstance() { + const wh = await getWormholeContextV2(); + const routeInstance = new this.rc(wh); + + return routeInstance; } async createRequest( @@ -218,8 +224,8 @@ export class SDKv2Route { ): Promise> { const sourceContext = (await this.getV2ChainContext(sourceChain)).context; const destContext = (await this.getV2ChainContext(destChain)).context; - const wh = await getWormholeContextV2(); + const req = await routes.RouteTransferRequest.create( wh, /* @ts-ignore */ @@ -233,65 +239,24 @@ export class SDKv2Route { sourceContext, destContext, ); - return req; - } - - async computeQuote( - amountIn: Amount, - sourceToken: Token, - destToken: Token, - fromChain: Chain, - toChain: Chain, - options?: routes.AutomaticTokenBridgeRoute.Options, - recipient?: string, - ): Promise> { - if (!fromChain || !toChain) { - throw new Error('Need both chains to get a quote from SDKv2'); - } - const [, quote] = await this.getQuote( - amountIn, - sourceToken, - destToken, - fromChain, - toChain, - options, - recipient, - ); - - if (!quote.success) { - throw quote.error; - } - - return quote; + return req; } async send( - sourceToken: Token, - amount: Amount, - fromChain: Chain, + quoteMetadata: QuoteMetadata, signer: Signer, toChain: Chain, recipientAddress: string, - destToken: Token, - options?: routes.AutomaticTokenBridgeRoute.Options, ): Promise<[routes.Route, routes.Receipt]> { - const [route, quote, req] = await this.getQuote( - amount, - sourceToken, - destToken, - fromChain, - toChain, - options, - recipientAddress, - ); + const { quote, routeInstance, request } = quoteMetadata; if (!quote.success) { throw quote.error; } - let receipt = await route.initiate( - req, + let receipt = await routeInstance.initiate( + request, signer, quote, Wormhole.chainAddress(toChain, recipientAddress), @@ -303,7 +268,7 @@ export class SDKv2Route { receipt.state === TransferState.SourceInitiated || receipt.state === TransferState.SourceFinalized ) { - return [route, receipt]; + return [routeInstance, receipt]; } // Otherwise track the transfer until it reaches a final state, @@ -314,9 +279,9 @@ export class SDKv2Route { while (retries < maxRetries) { try { - for await (receipt of route.track(receipt, 120 * 1000)) { + for await (receipt of routeInstance.track(receipt, 120 * 1000)) { if (receipt.state >= TransferState.SourceInitiated) { - return [route, receipt]; + return [routeInstance, receipt]; } } } catch (e) { diff --git a/src/routes/types.ts b/src/routes/types.ts new file mode 100644 index 000000000..4795e2fcb --- /dev/null +++ b/src/routes/types.ts @@ -0,0 +1,7 @@ +import type { routes, Network } from '@wormhole-foundation/sdk'; + +export type QuoteMetadata = { + quote: routes.QuoteResult; + request: routes.RouteTransferRequest; + routeInstance: routes.Route; +}; diff --git a/src/utils/routes.ts b/src/utils/routes.ts index 250a74079..e69c5647f 100644 --- a/src/utils/routes.ts +++ b/src/utils/routes.ts @@ -35,3 +35,22 @@ export const getBestRoutes = ( return { fastestRoute, cheapestRoute }; }; + +export function getDefaultQuoteExpiry(fromDate: number) { + return new Date(fromDate + 30_000); +} + +export function getQuoteExpiry(expiryFromQuote?: Date) { + const now = Date.now(); + + // A valid expiry should be at least 5 seconds in the future + // If not, we should default it to 30 seconds. + const isValidExpiry = + expiryFromQuote instanceof Date && expiryFromQuote.getTime() > now + 5_000; + + if (!isValidExpiry) { + return getDefaultQuoteExpiry(now); + } + + return expiryFromQuote; +} diff --git a/src/views/Terms.tsx b/src/views/Terms.tsx index fbd793582..44a3d63f4 100644 --- a/src/views/Terms.tsx +++ b/src/views/Terms.tsx @@ -30,13 +30,12 @@ function Terms() { -

Last Updated: April 18, 2025

+

Last Updated: September 15, 2025

These Terms of Service (the "Agreement") explain the terms and conditions by which you may access and use the Services provided by - W7, LLC (d.b.a. Wormhole Labs) (the "Company," "we," "us," or "our"). - The "Services" shall include, but not limited to, the website located - at{' '} + Maha Labs, LLC (the "Company," "we," "us," or "our"). The "Services" + shall include, but not limited to, the website located at{' '}