From e27cbf0d98fd2a074c5c99f8656fd28fe6fc273e Mon Sep 17 00:00:00 2001 From: gregfromstl Date: Mon, 28 Jul 2025 17:40:40 -0700 Subject: [PATCH 1/2] feat: faster quote generation --- .../src/react/core/hooks/usePaymentMethods.ts | 307 +++++------------- .../src/react/core/machines/paymentMachine.ts | 2 + .../payment-selection/TokenSelection.tsx | 85 ++--- 3 files changed, 98 insertions(+), 296 deletions(-) diff --git a/packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts b/packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts index 323adf45f6b..d633d0ab336 100644 --- a/packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts +++ b/packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts @@ -1,28 +1,15 @@ import { useQuery } from "@tanstack/react-query"; -import { chains } from "../../../bridge/Chains.js"; -import { routes } from "../../../bridge/Routes.js"; +import type { Quote } from "../../../bridge/index.js"; +import { ApiError } from "../../../bridge/types/Errors.js"; import type { Token } from "../../../bridge/types/Token.js"; -import { - getCachedChain, - getInsightEnabledChainIds, -} from "../../../chains/utils.js"; import type { ThirdwebClient } from "../../../client/client.js"; -import { getOwnedTokens } from "../../../insight/get-tokens.js"; -import { toTokens } from "../../../utils/units.js"; +import { getThirdwebBaseUrl } from "../../../utils/domains.js"; +import { getClientFetch } from "../../../utils/fetch.js"; +import { toTokens, toUnits } from "../../../utils/units.js"; import type { Wallet } from "../../../wallets/interfaces/wallet.js"; -import { - type GetWalletBalanceResult, - getWalletBalance, -} from "../../../wallets/utils/getWalletBalance.js"; import type { PaymentMethod } from "../machines/paymentMachine.js"; import { useActiveWallet } from "./wallets/useActiveWallet.js"; -type OwnedTokenWithQuote = { - originToken: Token; - balance: bigint; - originAmount: bigint; -}; - /** * Hook that returns available payment methods for BridgeEmbed * Fetches real routes data based on the destination token @@ -57,225 +44,85 @@ export function usePaymentMethods(options: { const localWallet = useActiveWallet(); // TODO (bridge): get all connected wallets const wallet = payerWallet || localWallet; - const routesQuery = useQuery({ + const query = useQuery({ enabled: !!wallet, queryFn: async (): Promise => { - if (!wallet) { + const account = wallet?.getAccount(); + if (!wallet || !account) { throw new Error("No wallet connected"); } - // 1. Get all supported chains - const [allChains, insightEnabledChainIds] = await Promise.all([ - chains({ client }), - getInsightEnabledChainIds(), - ]); - - // 2. Check insight availability for all chains - const insightEnabledChains = allChains.filter((c) => - insightEnabledChainIds.includes(c.chainId), + const url = new URL( + `${getThirdwebBaseUrl("bridge")}/v1/buy/quote/${account.address}`, ); - - // 3. Get all owned tokens for insight-enabled chains - let allOwnedTokens: Array<{ - balance: bigint; - originToken: Token; - }> = []; - let page = 0; - const limit = 500; - - while (true) { - let batch: GetWalletBalanceResult[]; - try { - batch = await getOwnedTokens({ - chains: insightEnabledChains.map((c) => getCachedChain(c.chainId)), - client, - ownerAddress: wallet.getAccount()?.address || "", - queryOptions: { - limit, - metadata: "false", - page, - }, - }); - } catch (error) { - // If the batch fails, fall back to getting native balance for each chain - console.warn(`Failed to get owned tokens for batch ${page}:`, error); - - const chainsInBatch = insightEnabledChains.map((c) => - getCachedChain(c.chainId), - ); - const nativeBalances = await Promise.allSettled( - chainsInBatch.map(async (chain) => { - const balance = await getWalletBalance({ - address: wallet.getAccount()?.address || "", - chain, - client, - }); - return balance; - }), - ); - - // Transform successful native balances into the same format as getOwnedTokens results - batch = nativeBalances - .filter((result) => result.status === "fulfilled") - .map((result) => result.value) - .filter((balance) => balance.value > 0n); - - // Convert to our format - const tokensWithBalance = batch.map((b) => ({ - balance: b.value, - originToken: { - address: b.tokenAddress, - chainId: b.chainId, - decimals: b.decimals, - iconUri: "", - name: b.name, - prices: { - USD: 0, - }, - symbol: b.symbol, - } as Token, - })); - - allOwnedTokens = [...allOwnedTokens, ...tokensWithBalance]; - break; - } - - if (batch.length === 0) { - break; - } - - // Convert to our format and filter out zero balances - const tokensWithBalance = batch - .filter((b) => b.value > 0n) - .map((b) => ({ - balance: b.value, - originToken: { - address: b.tokenAddress, - chainId: b.chainId, - decimals: b.decimals, - iconUri: "", - name: b.name, - prices: { - USD: 0, - }, - symbol: b.symbol, - } as Token, - })); - - allOwnedTokens = [...allOwnedTokens, ...tokensWithBalance]; - page += 1; - } - - // 4. For each chain where we have owned tokens, fetch possible routes - const chainsWithOwnedTokens = Array.from( - new Set(allOwnedTokens.map((t) => t.originToken.chainId)), + url.searchParams.set( + "destinationChainId", + destinationToken.chainId.toString(), ); - - 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, - }); - - // Add all origin tokens from this chain's routes - for (const route of routesForChain) { - // Skip if the origin token is the same as the destination token, will be added later only if includeDestinationToken is true - if ( - route.originToken.chainId === destinationToken.chainId && - route.originToken.address.toLowerCase() === - destinationToken.address.toLowerCase() - ) { - continue; - } - 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); - } - }), + url.searchParams.set("destinationTokenAddress", destinationToken.address); + url.searchParams.set( + "amount", + toUnits(destinationAmount, destinationToken.decimals).toString(), ); - // 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 - }); - } + const clientFetch = getClientFetch(client); + 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, + }); } - // Sort by dollar balance descending - validOwnedTokens.sort((a, b) => { - const aDollarBalance = - Number.parseFloat(toTokens(a.balance, a.originToken.decimals)) * - (a.originToken.prices.USD || 0); - const bDollarBalance = - Number.parseFloat(toTokens(b.balance, b.originToken.decimals)) * - (b.originToken.prices.USD || 0); - return bDollarBalance - aDollarBalance; - }); - - const suitableOriginTokens: OwnedTokenWithQuote[] = []; - - 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 = [ - ...suitableOriginTokens.map((s) => ({ - balance: s.balance, - originToken: s.originToken, - payerWallet: wallet, - type: "wallet" as const, - })), - ]; - return transformedRoutes; + const { + data: allValidOriginTokens, + }: { data: { quote: Quote; balance: string; token: Token }[] } = + await response.json(); + + // Sort by enough balance to pay THEN gross balance + const validTokenQuotes = allValidOriginTokens.map((s) => ({ + balance: BigInt(s.balance), + originToken: s.token, + payerWallet: wallet, + type: "wallet" as const, + quote: s.quote, + })); + const insufficientBalanceQuotes = validTokenQuotes + .filter((s) => s.balance < s.quote.originAmount) + .sort((a, b) => { + return ( + Number.parseFloat( + toTokens(a.quote.originAmount, a.originToken.decimals), + ) * + (a.originToken.prices.USD || 1) - + Number.parseFloat( + toTokens(b.quote.originAmount, b.originToken.decimals), + ) * + (b.originToken.prices.USD || 1) + ); + }); + const sufficientBalanceQuotes = validTokenQuotes + .filter((s) => s.balance >= s.quote.originAmount) + .sort((a, b) => { + return ( + Number.parseFloat( + toTokens(b.quote.originAmount, b.originToken.decimals), + ) * + (b.originToken.prices.USD || 1) - + Number.parseFloat( + toTokens(a.quote.originAmount, a.originToken.decimals), + ) * + (a.originToken.prices.USD || 1) + ); + }); + // Move all sufficient balance quotes to the top + return [...sufficientBalanceQuotes, ...insufficientBalanceQuotes]; }, queryKey: [ - "bridge-routes", + "payment-methods", destinationToken.chainId, destinationToken.address, destinationAmount, @@ -287,11 +134,11 @@ export function usePaymentMethods(options: { }); return { - data: routesQuery.data || [], - error: routesQuery.error, - isError: routesQuery.isError, - isLoading: routesQuery.isLoading, - isSuccess: routesQuery.isSuccess, - refetch: routesQuery.refetch, + data: query.data || [], + error: query.error, + isError: query.isError, + isLoading: query.isLoading, + isSuccess: query.isSuccess, + refetch: query.refetch, }; } diff --git a/packages/thirdweb/src/react/core/machines/paymentMachine.ts b/packages/thirdweb/src/react/core/machines/paymentMachine.ts index e98484ad2dc..337db24c424 100644 --- a/packages/thirdweb/src/react/core/machines/paymentMachine.ts +++ b/packages/thirdweb/src/react/core/machines/paymentMachine.ts @@ -1,4 +1,5 @@ import { useCallback, useState } from "react"; +import type { Quote } from "../../../bridge/index.js"; import type { Token } from "../../../bridge/types/Token.js"; import type { Address } from "../../../utils/address.js"; import type { AsyncStorage } from "../../../utils/storage/AsyncStorage.js"; @@ -24,6 +25,7 @@ export type PaymentMethod = payerWallet: Wallet; originToken: Token; balance: bigint; + quote: Quote; } | { type: "fiat"; diff --git a/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/TokenSelection.tsx b/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/TokenSelection.tsx index 4d02c9cc2ae..e889fed9cc5 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/TokenSelection.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/payment-selection/TokenSelection.tsx @@ -3,7 +3,6 @@ import type { Token } from "../../../../../bridge/types/Token.js"; import type { ThirdwebClient } from "../../../../../client/client.js"; import { useCustomTheme } from "../../../../core/design-system/CustomThemeProvider.js"; import { radius, spacing } from "../../../../core/design-system/index.js"; -import { useBridgeQuote } from "../../../../core/hooks/useBridgeQuote.js"; import type { PaymentMethod } from "../../../../core/machines/paymentMachine.js"; import { formatTokenAmount } from "../../ConnectWallet/screens/formatTokenBalance.js"; import { Container } from "../../components/basic.js"; @@ -36,29 +35,12 @@ interface PaymentMethodTokenRowProps { function PaymentMethodTokenRow({ paymentMethod, - destinationToken, - destinationAmount, client, onPaymentMethodSelected, - feePayer, }: PaymentMethodTokenRowProps) { const theme = useCustomTheme(); - // Fetch individual quote for this specific token pair - const { - data: quote, - isLoading: quoteLoading, - error: quoteError, - } = useBridgeQuote({ - client, - destinationAmount, - destinationToken, - feePayer, - originToken: paymentMethod.originToken, - }); - - // Use the fetched originAmount if available, otherwise fall back to the one from paymentMethod - const displayOriginAmount = quote?.originAmount; + const displayOriginAmount = paymentMethod.quote.originAmount; const hasEnoughBalance = displayOriginAmount ? paymentMethod.balance >= displayOriginAmount : false; @@ -97,57 +79,28 @@ function PaymentMethodTokenRow({ gap="3xs" style={{ alignItems: "flex-end", flex: 1 }} > - {quoteLoading ? ( - <> - {/* Price amount skeleton */} - - {/* Balance skeleton */} - - - - - - ) : quoteError ? ( - - Quote failed + + {formatTokenAmount( + displayOriginAmount, + paymentMethod.originToken.decimals, + )}{" "} + {paymentMethod.originToken.symbol} + + + + Balance:{" "} - ) : displayOriginAmount ? ( - + {formatTokenAmount( - displayOriginAmount, + paymentMethod.balance, paymentMethod.originToken.decimals, - )}{" "} - {paymentMethod.originToken.symbol} + )} - ) : ( - "--.--" - )} - {!quoteLoading && ( - - - Balance:{" "} - - - {formatTokenAmount( - paymentMethod.balance, - paymentMethod.originToken.decimals, - )} - - - )} + From c53f063f18a747bc2d30f004043c44777278737c Mon Sep 17 00:00:00 2001 From: gregfromstl Date: Tue, 29 Jul 2025 09:53:38 -0700 Subject: [PATCH 2/2] changeset --- .changeset/cute-actors-beam.md | 5 ++ .../src/react/core/hooks/useBridgeQuote.ts | 71 ------------------- 2 files changed, 5 insertions(+), 71 deletions(-) create mode 100644 .changeset/cute-actors-beam.md delete mode 100644 packages/thirdweb/src/react/core/hooks/useBridgeQuote.ts diff --git a/.changeset/cute-actors-beam.md b/.changeset/cute-actors-beam.md new file mode 100644 index 00000000000..a4cb7aa1404 --- /dev/null +++ b/.changeset/cute-actors-beam.md @@ -0,0 +1,5 @@ +--- +"thirdweb": patch +--- + +Faster payment widget quote discovery diff --git a/packages/thirdweb/src/react/core/hooks/useBridgeQuote.ts b/packages/thirdweb/src/react/core/hooks/useBridgeQuote.ts deleted file mode 100644 index 7565c720ab2..00000000000 --- a/packages/thirdweb/src/react/core/hooks/useBridgeQuote.ts +++ /dev/null @@ -1,71 +0,0 @@ -"use client"; -import { useQuery } from "@tanstack/react-query"; -import * as Buy from "../../../bridge/Buy.js"; -import * as Transfer from "../../../bridge/Transfer.js"; -import type { Token } from "../../../bridge/types/Token.js"; -import type { ThirdwebClient } from "../../../client/client.js"; -import { checksumAddress } from "../../../utils/address.js"; - -interface UseBridgeQuoteParams { - originToken: Token; - destinationToken: Token; - destinationAmount: bigint; - client: ThirdwebClient; - enabled?: boolean; - feePayer?: "sender" | "receiver"; -} - -export function useBridgeQuote({ - originToken, - destinationToken, - destinationAmount, - feePayer, - client, - enabled = true, -}: UseBridgeQuoteParams) { - return useQuery({ - enabled: - enabled && !!originToken && !!destinationToken && !!destinationAmount, - queryFn: async () => { - // if ssame token and chain, use transfer - if ( - checksumAddress(originToken.address) === - checksumAddress(destinationToken.address) && - originToken.chainId === destinationToken.chainId - ) { - const transfer = await Transfer.prepare({ - amount: destinationAmount, - chainId: originToken.chainId, - client, - feePayer, - receiver: destinationToken.address, - sender: originToken.address, - tokenAddress: originToken.address, - }); - return transfer; - } - const quote = await Buy.quote({ - amount: destinationAmount, - client, - destinationChainId: destinationToken.chainId, - destinationTokenAddress: destinationToken.address, - originChainId: originToken.chainId, - originTokenAddress: originToken.address, - }); - - return quote; - }, - queryKey: [ - "bridge-quote", - originToken.chainId, - originToken.address, - destinationToken.chainId, - destinationToken.address, - destinationAmount.toString(), - feePayer, - ], - refetchInterval: 60000, // 30 seconds - retry: 3, // 1 minute - staleTime: 30000, - }); -}