diff --git a/.changeset/happy-pans-sneeze.md b/.changeset/happy-pans-sneeze.md new file mode 100644 index 00000000000..e9f3111334b --- /dev/null +++ b/.changeset/happy-pans-sneeze.md @@ -0,0 +1,5 @@ +--- +"thirdweb": patch +--- + +Optimize fetching payment tokens in payment widgets diff --git a/packages/thirdweb/src/bridge/Chains.ts b/packages/thirdweb/src/bridge/Chains.ts index 51a3142eef3..3789837e46a 100644 --- a/packages/thirdweb/src/bridge/Chains.ts +++ b/packages/thirdweb/src/bridge/Chains.ts @@ -1,6 +1,7 @@ import type { ThirdwebClient } from "../client/client.js"; import { getThirdwebBaseUrl } from "../utils/domains.js"; import { getClientFetch } from "../utils/fetch.js"; +import { withCache } from "../utils/promise/withCache.js"; import type { Chain } from "./types/Chain.js"; import { ApiError } from "./types/Errors.js"; @@ -54,22 +55,30 @@ import { ApiError } from "./types/Errors.js"; export async function chains(options: chains.Options): Promise { const { client } = options; - const clientFetch = getClientFetch(client); - const url = new URL(`${getThirdwebBaseUrl("bridge")}/v1/chains`); + return withCache( + async () => { + const clientFetch = getClientFetch(client); + const url = new URL(`${getThirdwebBaseUrl("bridge")}/v1/chains`); - const response = await clientFetch(url.toString()); - if (!response.ok) { - const errorJson = await response.json(); - throw new ApiError({ - code: errorJson.code || "UNKNOWN_ERROR", - correlationId: errorJson.correlationId || undefined, - message: errorJson.message || response.statusText, - statusCode: response.status, - }); - } + const response = await clientFetch(url.toString()); + if (!response.ok) { + const errorJson = await response.json(); + throw new ApiError({ + code: errorJson.code || "UNKNOWN_ERROR", + correlationId: errorJson.correlationId || undefined, + message: errorJson.message || response.statusText, + statusCode: response.status, + }); + } - const { data }: { data: Chain[] } = await response.json(); - return data; + const { data }: { data: Chain[] } = await response.json(); + return data; + }, + { + cacheKey: "bridge-chains", + cacheTime: 1000 * 60 * 60 * 1, // 1 hours + }, + ); } export declare namespace chains { diff --git a/packages/thirdweb/src/chains/types.ts b/packages/thirdweb/src/chains/types.ts index 90a07b26600..d4741fb47f9 100644 --- a/packages/thirdweb/src/chains/types.ts +++ b/packages/thirdweb/src/chains/types.ts @@ -88,14 +88,6 @@ export type ChainMetadata = { stackType: string; }; -/** - * @chain - */ -export type ChainService = { - service: string; - enabled: boolean; -}; - /** * @chain */ diff --git a/packages/thirdweb/src/chains/utils.ts b/packages/thirdweb/src/chains/utils.ts index d93cda57413..e8ce2a6a57a 100644 --- a/packages/thirdweb/src/chains/utils.ts +++ b/packages/thirdweb/src/chains/utils.ts @@ -7,7 +7,6 @@ import type { Chain, ChainMetadata, ChainOptions, - ChainService, LegacyChain, } from "./types.js"; @@ -323,62 +322,22 @@ export function getChainMetadata(chain: Chain): Promise { ); } -type FetchChainServiceResponse = - | { - data: { - services: ChainService[]; - }; - error?: never; - } - | { - data?: never; - error: unknown; - }; - -/** - * Retrieves a list of services available on a given chain - * @param chain - The chain object containing the chain ID. - * @returns A Promise that resolves to chain services. - * @throws If there is an error fetching the chain services. - * @example - * ```ts - * const chain = defineChain({ id: 1 }); - * const chainServices = await getChainServices(chain); - * console.log(chainServices); - * ``` - * @chain - */ -export function getChainServices(chain: Chain): Promise { - const chainId = chain.id; +export async function getInsightEnabledChainIds(): Promise { return withCache( async () => { - try { - const res = await fetch( - `https://api.thirdweb.com/v1/chains/${chainId}/services`, + const res = await fetch( + `https://api.thirdweb.com/v1/chains/services?service=insight`, + ); + if (!res.ok) { + throw new Error( + `Failed to fetch services. ${res.status} ${res.statusText}`, ); - if (!res.ok) { - throw new Error( - `Failed to fetch services for chainId ${chainId}. ${res.status} ${res.statusText}`, - ); - } - - const response = (await res.json()) as FetchChainServiceResponse; - if (response.error) { - throw new Error(`Failed to fetch services for chainId ${chainId}`); - } - if (!response.data) { - throw new Error(`Failed to fetch services for chainId ${chainId}`); - } - - const services = response.data.services; - - return services; - } catch { - throw new Error(`Failed to fetch services for chainId ${chainId}`); } + const response = (await res.json()) as { data: Record }; + return Object.keys(response.data).map((chainId) => Number(chainId)); }, { - cacheKey: `chain:${chainId}:services`, + cacheKey: `chain:insight-enabled`, cacheTime: 24 * 60 * 60 * 1000, // 1 day }, ); diff --git a/packages/thirdweb/src/insight/common.ts b/packages/thirdweb/src/insight/common.ts index 22afc54b7a4..aedbb62bb90 100644 --- a/packages/thirdweb/src/insight/common.ts +++ b/packages/thirdweb/src/insight/common.ts @@ -1,29 +1,21 @@ import type { Chain } from "../chains/types.js"; -import { getChainServices } from "../chains/utils.js"; +import { getInsightEnabledChainIds } from "../chains/utils.js"; export async function assertInsightEnabled(chains: Chain[]) { - const chainData = await Promise.all( - chains.map((chain) => - isInsightEnabled(chain).then((enabled) => ({ - chain, - enabled, - })), - ), - ); - - const insightEnabled = chainData.every((c) => c.enabled); + const chainIds = await getInsightEnabledChainIds(); + const insightEnabled = chains.every((c) => chainIds.includes(c.id)); if (!insightEnabled) { throw new Error( - `Insight is not available for chains ${chainData - .filter((c) => !c.enabled) - .map((c) => c.chain.id) + `Insight is not available for chains ${chains + .filter((c) => !chainIds.includes(c.id)) + .map((c) => c.id) .join(", ")}`, ); } } export async function isInsightEnabled(chain: Chain) { - const chainData = await getChainServices(chain); - return chainData.some((c) => c.service === "insight" && c.enabled); + const chainIds = await getInsightEnabledChainIds(); + return chainIds.includes(chain.id); } diff --git a/packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts b/packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts index 6b5a74b4865..cf07e6736eb 100644 --- a/packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts +++ b/packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts @@ -1,9 +1,12 @@ import { useQuery } from "@tanstack/react-query"; +import { chains } from "../../../bridge/Chains.js"; import { routes } from "../../../bridge/Routes.js"; import type { Token } from "../../../bridge/types/Token.js"; -import { getCachedChain } from "../../../chains/utils.js"; +import { + getCachedChain, + getInsightEnabledChainIds, +} from "../../../chains/utils.js"; import type { ThirdwebClient } from "../../../client/client.js"; -import { isInsightEnabled } from "../../../insight/common.js"; import { getOwnedTokens } from "../../../insight/get-tokens.js"; import { toTokens } from "../../../utils/units.js"; import type { Wallet } from "../../../wallets/interfaces/wallet.js"; @@ -56,42 +59,29 @@ export function usePaymentMethods(options: { if (!wallet) { throw new Error("No wallet connected"); } - const allRoutes = await routes({ - client, - destinationChainId: destinationToken.chainId, - destinationTokenAddress: destinationToken.address, - includePrices: true, - limit: 100, - maxSteps: 3, - sortBy: "popularity", // Get top 100 most popular routes - }); - - const allOriginTokens = includeDestinationToken - ? [destinationToken, ...allRoutes.map((route) => route.originToken)] - : allRoutes.map((route) => route.originToken); - // 1. Resolve all unique chains in the supported token map - const uniqueChains = Array.from( - new Set(allOriginTokens.map((t) => t.chainId)), - ); + // 1. Get all supported chains + const [allChains, insightEnabledChainIds] = await Promise.all([ + chains({ client }), + getInsightEnabledChainIds(), + ]); - // 2. Check insight availability once per chain - const insightSupport = await Promise.all( - uniqueChains.map(async (c) => ({ - chain: getCachedChain(c), - enabled: await isInsightEnabled(getCachedChain(c)), - })), + // 2. Check insight availability for all chains + const insightEnabledChains = allChains.filter((c) => + insightEnabledChainIds.includes(c.chainId), ); - const insightEnabledChains = insightSupport.filter((c) => c.enabled); - // 3. ERC-20 balances for insight-enabled chains (batched 5 chains / call) - let owned: OwnedTokenWithQuote[] = []; + // 3. Get all owned tokens for insight-enabled chains + let allOwnedTokens: Array<{ + balance: bigint; + originToken: Token; + }> = []; let page = 0; - const limit = 100; + const limit = 500; while (true) { const batch = await getOwnedTokens({ - chains: insightEnabledChains.map((c) => c.chain), + chains: insightEnabledChains.map((c) => getCachedChain(c.chainId)), client, ownerAddress: wallet.getAccount()?.address || "", queryOptions: { @@ -105,25 +95,85 @@ export function usePaymentMethods(options: { break; } - // find matching origin token in allRoutes + // Convert to our format and filter out zero balances const tokensWithBalance = batch + .filter((b) => b.value > 0n) .map((b) => ({ balance: b.value, - originAmount: 0n, - originToken: allOriginTokens.find( - (t) => - t.address.toLowerCase() === b.tokenAddress.toLowerCase() && - t.chainId === b.chainId, - ), - })) - .filter((t) => !!t.originToken) as OwnedTokenWithQuote[]; - - owned = [...owned, ...tokensWithBalance]; + originToken: { + address: b.tokenAddress, + chainId: b.chainId, + decimals: b.decimals, + iconUri: "", + name: b.name, + priceUsd: 0, + symbol: b.symbol, + } as Token, + })); + + allOwnedTokens = [...allOwnedTokens, ...tokensWithBalance]; page += 1; } - // sort by dollar balance descending - owned.sort((a, b) => { + // 4. For each chain where we have owned tokens, fetch possible routes + const chainsWithOwnedTokens = Array.from( + new Set(allOwnedTokens.map((t) => t.originToken.chainId)), + ); + + const allValidOriginTokens = new Map(); + + // Add destination token if included + if (includeDestinationToken) { + const tokenKey = `${destinationToken.chainId}-${destinationToken.address.toLowerCase()}`; + allValidOriginTokens.set(tokenKey, destinationToken); + } + + // Fetch routes for each chain with owned tokens + await Promise.all( + chainsWithOwnedTokens.map(async (chainId) => { + try { + // TODO (bridge): this is quite inefficient, need to fix the popularity sorting to really capture all users tokens + const routesForChain = await routes({ + client, + destinationChainId: destinationToken.chainId, + destinationTokenAddress: destinationToken.address, + includePrices: true, + limit: 100, + maxSteps: 3, + originChainId: chainId, + sortBy: "popularity", + }); + + // Add all origin tokens from this chain's routes + for (const route of routesForChain) { + const tokenKey = `${route.originToken.chainId}-${route.originToken.address.toLowerCase()}`; + allValidOriginTokens.set(tokenKey, route.originToken); + } + } catch (error) { + // Log error but don't fail the entire operation + console.warn(`Failed to fetch routes for chain ${chainId}:`, error); + } + }), + ); + + // 5. Filter owned tokens to only include valid origin tokens + const validOwnedTokens: OwnedTokenWithQuote[] = []; + + for (const ownedToken of allOwnedTokens) { + const tokenKey = `${ownedToken.originToken.chainId}-${ownedToken.originToken.address.toLowerCase()}`; + const validOriginToken = allValidOriginTokens.get(tokenKey); + + if (validOriginToken) { + validOwnedTokens.push({ + balance: ownedToken.balance, + originAmount: 0n, + originToken: validOriginToken, // Use the token with pricing info from routes + }); + } + } + + // Sort by dollar balance descending + validOwnedTokens.sort((a, b) => { const aDollarBalance = Number.parseFloat(toTokens(a.balance, a.originToken.decimals)) * a.originToken.priceUsd; @@ -135,29 +185,19 @@ export function usePaymentMethods(options: { const suitableOriginTokens: OwnedTokenWithQuote[] = []; - for (const b of owned) { - if (b.originToken && b.balance > 0n) { - if ( - includeDestinationToken && - b.originToken.address.toLowerCase() === - destinationToken.address.toLowerCase() && - b.originToken.chainId === destinationToken.chainId - ) { - // add same token to the front of the list - suitableOriginTokens.unshift({ - balance: b.balance, - originAmount: 0n, - originToken: b.originToken, - }); - continue; - } - - suitableOriginTokens.push({ - balance: b.balance, - originAmount: 0n, - originToken: b.originToken, - }); + for (const token of validOwnedTokens) { + if ( + includeDestinationToken && + token.originToken.address.toLowerCase() === + destinationToken.address.toLowerCase() && + token.originToken.chainId === destinationToken.chainId + ) { + // Add same token to the front of the list + suitableOriginTokens.unshift(token); + continue; } + + suitableOriginTokens.push(token); } const transformedRoutes = [