UNPKG

0xtrails

Version:

SDK for Trails

850 lines (770 loc) 24.3 kB
import type { GatewayNativeTokenBalances, GatewayTokenBalance, GetTokenBalancesSummaryReturn, NativeTokenBalance, SequenceIndexerGateway, TokenBalance, } from "@0xsequence/indexer" import { ContractVerificationStatus } from "@0xsequence/indexer" import type { Page, Price, SequenceAPIClient } from "@0xsequence/trails-api" import { QueryClient, useQuery } from "@tanstack/react-query" import type { Address } from "ox" import { useEffect, useState } from "react" import { formatUnits, parseUnits, zeroAddress } from "viem" import { useAPIClient } from "./apiClient.js" import { useIndexerGatewayClient } from "./indexerClient.js" import { getTokenPrices, useTokenPrices } from "./prices.js" import { logger } from "./logger.js" export type { NativeTokenBalance, TokenBalance } const REFRESH_INTERVAL = 10000 // 10 seconds // Initialize query client for token balances const tokenBalancesQueryClient = new QueryClient({ defaultOptions: { queries: { staleTime: REFRESH_INTERVAL, // 10 seconds - faster updates for balance changes gcTime: REFRESH_INTERVAL, // 10 seconds retry: 2, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), refetchOnWindowFocus: false, refetchOnReconnect: true, }, }, }) // Default empty page info for query fallback const defaultPage = { page: 1, pageSize: 10, more: false } // Type guard for native token balance export function isNativeToken( token: TokenBalance | NativeTokenBalance, ): token is NativeTokenBalance { if ("contractAddress" in token) { return false } return true } export interface TokenBalanceWithPrice extends TokenBalance { price?: Price balanceUsd?: number balanceUsdFormatted?: string } export interface NativeTokenBalanceWithPrice extends NativeTokenBalance { price?: Price balanceUsd?: number balanceUsdFormatted?: string symbol?: string } export type TokenBalanceExtended = | TokenBalanceWithPrice | NativeTokenBalanceWithPrice export function sortTokensByPriority( a: TokenBalanceExtended, b: TokenBalanceExtended, ): number { // First sort by USD balance if available const aUsdBalance = a.balanceUsd ?? 0 const bUsdBalance = b.balanceUsd ?? 0 // If both have non-zero balances, sort by balance if (aUsdBalance > 0 && bUsdBalance > 0) { return bUsdBalance - aUsdBalance // Higher USD balance first } // If one has balance and other doesn't, prioritize the one with balance if (aUsdBalance > 0 && bUsdBalance === 0) return -1 if (aUsdBalance === 0 && bUsdBalance > 0) return 1 // If both have zero balance, sort by volume if (aUsdBalance === 0 && bUsdBalance === 0) { const aVolume = getTokenVolume24h(a.price) const bVolume = getTokenVolume24h(b.price) if (aVolume !== bVolume) { return bVolume - aVolume // Higher volume first for zero balance tokens } } // Then sort by native token status if (isNativeToken(a) && !isNativeToken(b)) return -1 if (!isNativeToken(a) && isNativeToken(b)) return 1 // Then sort by token balance try { const balanceA = BigInt(a.balance) const balanceB = BigInt(b.balance) if (balanceA > balanceB) return -1 if (balanceA < balanceB) return 1 } catch { // If balance comparison fails, continue to volume sorting } return 0 } // Helper function to extract 24h volume from price data function getTokenVolume24h(price?: any): number { // Try different possible volume field names from the API if (!price) return 0 // Primary: use price24hVol if available, otherwise try other common field names return Number( price.price24hVol || price.volume24h || price.volume_24h || price.vol24h || price.dailyVolume || 0, ) } export interface GetTokenBalancesWithPrice { page: Page nativeBalances: Array< NativeTokenBalance & { price?: Price symbol?: string balanceUsd?: number balanceUsdFormatted?: string } > balances: Array< TokenBalance & { price?: Price balanceUsd?: number balanceUsdFormatted?: string } > } export function useTokenBalances( address: Address.Address, indexerGatewayClient?: SequenceIndexerGateway, sequenceApiClient?: SequenceAPIClient, ): { tokenBalancesData: GetTokenBalancesSummaryReturn | undefined isLoadingBalances: boolean isLoadingPrices: boolean isLoadingSortedTokens: boolean balanceError: Error | null sortedTokens: TokenBalanceExtended[] } { // Always call hooks unconditionally to fix React rules violation const hookIndexerClient = useIndexerGatewayClient() const hookApiClient = useAPIClient() // Use passed parameters if available, otherwise use hook results const indexerClient = indexerGatewayClient ?? hookIndexerClient const apiClient = sequenceApiClient ?? hookApiClient // Fetch token balances with improved query key structure const { data: tokenBalancesData, isLoading: isLoadingBalances, error: balanceError, } = useQuery<GetTokenBalancesWithPrice>({ queryKey: ["tokenBalances", "summary", address], queryFn: async (): Promise<GetTokenBalancesWithPrice> => { if (!address) { logger.console.warn("[trails-sdk] No account address or indexer client") return { balances: [], nativeBalances: [], page: defaultPage, } as GetTokenBalancesWithPrice } try { const summaryFromGateway = await getTokenBalances({ account: address, indexerGatewayClient: indexerClient, }) return { page: summaryFromGateway.page, balances: ( summaryFromGateway.balances as unknown as GatewayTokenBalance[] ).flatMap((b) => b.results), nativeBalances: ( summaryFromGateway.nativeBalances as unknown as GatewayNativeTokenBalances[] ).flatMap((b) => b.results), } } catch (error) { logger.console.error( "[trails-sdk] Failed to fetch token balances:", error, ) throw error } }, enabled: !!address && !!indexerClient, staleTime: 10000, // 10 seconds - faster updates for balance changes gcTime: 10000, // 10 seconds cache time retry: (failureCount, error) => { // Don't retry 404s or network errors after 3 attempts if (error && "status" in error && error.status === 404) return false if (failureCount < 3) return true return false }, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), // Exponential backoff refetchOnWindowFocus: true, // Prevent refetch on window focus refetchOnReconnect: true, // Refetch on reconnect refetchInterval: REFRESH_INTERVAL, // Background refetch every 10 seconds refetchIntervalInBackground: true, refetchOnMount: true, }) const { tokenPrices, isLoadingTokenPrices } = useTokenPrices( (tokenBalancesData?.balances ?? []) .map((b: any) => { return { tokenId: b.contractInfo?.symbol, contractAddress: b.contractAddress, chainId: b.contractInfo?.chainId!, } }) .concat( (tokenBalancesData?.nativeBalances ?? []).map((b) => { return { tokenId: b.symbol, contractAddress: zeroAddress, chainId: b.chainId, } }), ) ?? [], apiClient, ) const { data: sortedTokens = [], isLoading: isLoadingSortedTokens } = useQuery<TokenBalanceExtended[]>({ queryKey: [ "tokenBalances", "sorted", address, tokenBalancesData?.page?.page, tokenPrices?.length, ], queryFn: () => { if (!tokenBalancesData || !tokenPrices) { return [] } const balances = [ ...tokenBalancesData.nativeBalances, ...tokenBalancesData.balances, ].filter((token) => { try { return BigInt(token.balance) > 0n } catch { return false } }) // First pass: add prices to all tokens const tokensWithPrices = balances.map((token) => { const isNative = isNativeToken(token) const priceData = tokenPrices.find( (p: { token: { contractAddress: string; chainId: number } }) => p.token.contractAddress === (isNative ? zeroAddress : token.contractAddress) && p.token.chainId === (isNative ? token.chainId : token.contractInfo?.chainId), ) if (priceData?.price) { const tokenWithPrice = { ...token, price: priceData.price } tokenWithPrice.balanceUsd = getTokenBalanceUsd( token, priceData.price, ) tokenWithPrice.balanceUsdFormatted = getTokenBalanceUsdFormatted( token, priceData.price, ) return tokenWithPrice } return token }) return tokensWithPrices.sort(sortTokensByPriority) }, enabled: !isLoadingBalances && !isLoadingTokenPrices && !!tokenBalancesData && !!tokenPrices, staleTime: REFRESH_INTERVAL, // 10 seconds for sorted tokens gcTime: REFRESH_INTERVAL, // 10 seconds cache time refetchOnWindowFocus: true, }) return { tokenBalancesData, isLoadingBalances, isLoadingPrices: isLoadingTokenPrices, isLoadingSortedTokens: isLoadingSortedTokens || isLoadingBalances || isLoadingTokenPrices, balanceError, sortedTokens, } } // Helper to format balance export function formatRawAmount( balance: string | bigint, decimals: number = 18, ): string { if (!balance) { return "0" } try { const formatted = formatUnits(BigInt(balance), decimals) return formatAmount(formatted) } catch (e) { logger.console.error("[trails-sdk] Error formatting balance:", e) return balance.toString() } } export function getTokenBalanceUsd( token: TokenBalance | NativeTokenBalance, tokenPrice: Price, ): number { const isNative = isNativeToken(token) const formattedBalance = formatRawAmount( token.balance, isNative ? 18 : token.contractInfo?.decimals, ) const priceUsd = Number(tokenPrice.value) ?? 0 return Number(formattedBalance) * priceUsd } export function formatAmount( value: string | number, { maxFractionDigits = 8, minFractionDigits = 2, }: { maxFractionDigits?: number; minFractionDigits?: number } = {}, ): string { if (!value) { value = 0 } try { return Number(value).toLocaleString(undefined, { maximumFractionDigits: maxFractionDigits, minimumFractionDigits: minFractionDigits, useGrouping: false, }) } catch (err) { logger.console.error("[trails-sdk] Error formatting value:", err) } return value.toString() } export function formatAmountDisplay( value: string | number, { maxFractionDigits = 8, minFractionDigits = 2, }: { maxFractionDigits?: number; minFractionDigits?: number } = {}, ): string { if (!value) { value = 0 } try { return Number(value).toLocaleString(undefined, { maximumFractionDigits: maxFractionDigits, minimumFractionDigits: minFractionDigits, useGrouping: true, }) } catch (err) { logger.console.error("[trails-sdk] Error formatting value:", err) } return value.toString() } export function formatUsdAmountDisplay(value: number | string = 0): string { if (!value) { value = 0 } const displayValue = Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 2, minimumFractionDigits: 2, }).format(Number(value)) if (displayValue === "$0.00" && Number(value) > 0 && Number(value) < 0.01) { return `<$0.01` } return displayValue } export function getTokenBalanceUsdFormatted( token: TokenBalance | NativeTokenBalance, tokenPrice: Price, ): string { const balanceUsd = getTokenBalanceUsd(token, tokenPrice) return formatUsdAmountDisplay(balanceUsd) } export function useTokenBalanceUsdFormat( token: TokenBalance | NativeTokenBalance, tokenPrice: Price, ): string { const [format, setFormat] = useState<string>("") useEffect(() => { const formattedBalance = getTokenBalanceUsdFormatted(token, tokenPrice) setFormat(formattedBalance) }, [token, tokenPrice]) return format } export type GetTokenBalancesParams = { account: string indexerGatewayClient: SequenceIndexerGateway } // Separate fetch function for token balances summary export async function fetchGetTokenBalancesSummary({ account, indexerGatewayClient, }: GetTokenBalancesParams): Promise<GetTokenBalancesSummaryReturn> { if (!account || !indexerGatewayClient) { throw new Error("Account address and indexer client are required") } try { const summaryFromGateway = await indexerGatewayClient.getTokenBalancesSummary({ filter: { accountAddresses: [account], contractStatus: ContractVerificationStatus.VERIFIED, contractTypes: ["ERC20"], omitNativeBalances: false, }, }) return summaryFromGateway as unknown as GetTokenBalancesSummaryReturn } catch (error) { logger.console.error( "[trails-sdk] Failed to fetch token balances summary:", error, ) throw error } } export async function getTokenBalances({ account, indexerGatewayClient, }: GetTokenBalancesParams): Promise<GetTokenBalancesSummaryReturn> { return tokenBalancesQueryClient.fetchQuery({ queryKey: ["tokenBalances", "summary", account], queryFn: () => fetchGetTokenBalancesSummary({ account, indexerGatewayClient }), staleTime: REFRESH_INTERVAL, // 10 seconds - faster updates for balance changes gcTime: REFRESH_INTERVAL, // 10 seconds }) } // Cache invalidation utility function export function invalidateTokenBalancesCache(account?: string) { if (account) { // Invalidate specific account's token balances tokenBalancesQueryClient.invalidateQueries({ queryKey: ["tokenBalances", account], }) } else { // Invalidate all token balance queries tokenBalancesQueryClient.invalidateQueries({ queryKey: ["tokenBalances"], }) } } export type GetTokenBalancesFlatArrayParams = { account: string indexerGatewayClient: SequenceIndexerGateway } export type GetTokenBalancesFlatArrayReturn = { balances: TokenBalance[] } export async function getTokenBalancesFlatArray({ account, indexerGatewayClient, }: GetTokenBalancesFlatArrayParams): Promise<TokenBalance[]> { const summaryFromGateway = await getTokenBalances({ account, indexerGatewayClient, }) const tokenMap = new Map<string, TokenBalance>() for (const balance of summaryFromGateway.balances) { ;(balance as any).results.forEach((b: any) => { tokenMap.set( `${b.contractAddress}-${b.contractInfo?.chainId}-${b.contractInfo?.symbol}`, { ...b, contractAddress: b.contractAddress ?? zeroAddress, tokenId: b.contractInfo?.symbol, }, ) }) } for (const balance of summaryFromGateway.nativeBalances) { ;(balance as any).results.forEach((b: any) => { tokenMap.set(`${b.contractAddress}-${b.chainId}-${b.symbol}`, { ...b, contractAddress: b.contractAddress ?? zeroAddress, tokenId: b.symbol, }) }) } const tokens = Array.from(tokenMap.values()) return tokens } export type GetTokenBalancesWithPricesParams = { account: string indexerGatewayClient: SequenceIndexerGateway apiClient: SequenceAPIClient } export type GetTokenBalancesWithPriceReturn = { balances: TokenBalanceWithPrice[] } export async function getTokenBalancesWithPrices({ account, indexerGatewayClient, apiClient, }: GetTokenBalancesWithPricesParams): Promise<GetTokenBalancesWithPriceReturn> { const tokens = await getTokenBalancesFlatArray({ account, indexerGatewayClient, }) const tokenPrices = await getTokenPrices(apiClient, tokens) const balancesWithPrices = tokens.map((b) => { const price = tokenPrices.find((p) => { const isSameChain = p.token.chainId === b.chainId let isSameToken = p.token.contractAddress === b.contractAddress if (!b.contractAddress) { isSameToken = p.token.contractAddress === zeroAddress || !p.token.contractAddress } return isSameChain && isSameToken }) return { ...b, price: price?.price, balanceUsd: price?.price ? getTokenBalanceUsd(b, price?.price) : undefined, balanceUsdFormatted: price?.price ? getTokenBalanceUsdFormatted(b, price?.price) : undefined, } }) return { balances: balancesWithPrices, } } export type UseAccountTokenBalanceParams = { account?: string | null token?: string | null chainId?: number | null indexerGatewayClient?: SequenceIndexerGateway | null apiClient?: SequenceAPIClient | null } export function useAccountTokenBalance({ account, token, chainId, indexerGatewayClient, apiClient, }: UseAccountTokenBalanceParams) { const { data: tokenBalance, isLoading: isLoadingTokenBalance } = useQuery({ queryKey: ["tokenBalances", "balances", account], queryFn: async () => { if ( !account || !indexerGatewayClient || !apiClient || !token || !chainId ) { return null } const { balances } = await getTokenBalancesWithPrices({ account, indexerGatewayClient, apiClient, }) const tokenBalance = balances.find( (b) => b.chainId === chainId && (b.contractAddress?.toLowerCase() === token.toLowerCase() || (!b.contractAddress && token === zeroAddress)), ) return tokenBalance }, }) return { tokenBalance, isLoadingTokenBalance, } } export type HasSufficientBalanceParams = { account: string token: string amount: string chainId: number indexerGatewayClient: SequenceIndexerGateway apiClient: SequenceAPIClient } export async function getHasSufficientBalanceToken({ account, token, amount, chainId, indexerGatewayClient, apiClient, }: HasSufficientBalanceParams): Promise<boolean> { const { balances } = await getTokenBalancesWithPrices({ account, indexerGatewayClient, apiClient, }) const tokenBalance = balances.find( (b) => b.chainId === chainId && (b.contractAddress?.toLowerCase() === token.toLowerCase() || (!b.contractAddress && token === zeroAddress)), ) if (!tokenBalance) { return false } const decimals = tokenBalance?.contractInfo?.decimals ?? 18 return tokenBalance?.balance ? BigInt(tokenBalance.balance) >= parseUnits(amount, decimals) : false } export function useHasSufficientBalanceToken( account: string, token: string, amount: string, chainId: number, ): { hasSufficientBalanceToken: boolean isLoadingHasSufficientBalanceToken: boolean } { const indexerGatewayClient = useIndexerGatewayClient() const apiClient = useAPIClient() const { data: hasSufficientBalanceToken, isLoading: isLoadingHasSufficientBalanceToken, } = useQuery({ queryKey: ["tokenBalances", "sufficient", account, token, amount, chainId], queryFn: () => account ? getHasSufficientBalanceToken({ account: account, token: token, amount: amount, chainId: chainId, indexerGatewayClient: indexerGatewayClient, apiClient: apiClient, }) : null, enabled: !!account && !!token && !!amount && !!chainId, staleTime: 45000, // 45 seconds gcTime: 180000, // 3 minutes cache time retry: (failureCount, error) => { if (error && "status" in error && error.status === 404) return false if (failureCount < 2) return true return false }, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), refetchOnWindowFocus: false, }) return { hasSufficientBalanceToken: hasSufficientBalanceToken || false, isLoadingHasSufficientBalanceToken, } } export type GetHasSufficientBalanceUsdParams = { account: string targetAmountUsd: number | string indexerGatewayClient: SequenceIndexerGateway apiClient: SequenceAPIClient } export async function getHasSufficientBalanceUsd({ account, targetAmountUsd, indexerGatewayClient, apiClient, }: GetHasSufficientBalanceUsdParams): Promise<boolean> { const totalBalanceUsd = await getAccountTotalBalanceUsd({ account, indexerGatewayClient, apiClient, }) return totalBalanceUsd >= Number(targetAmountUsd) } export function useHasSufficientBalanceUsd( account: string, targetAmountUsd?: number | string | null, ): { hasSufficientBalanceUsd: boolean isLoadingHasSufficientBalanceUsd: boolean hasSufficientBalanceUsdError: Error | null } { const indexerGatewayClient = useIndexerGatewayClient() const apiClient = useAPIClient() const { data: hasSufficientBalanceUsd, isLoading: isLoadingHasSufficientBalanceUsd, error: hasSufficientBalanceUsdError, } = useQuery({ queryKey: ["tokenBalances", "sufficientUsd", account, targetAmountUsd], queryFn: () => account && targetAmountUsd ? getHasSufficientBalanceUsd({ account: account, targetAmountUsd: targetAmountUsd, indexerGatewayClient: indexerGatewayClient, apiClient: apiClient, }) : false, enabled: !!account && !!targetAmountUsd, staleTime: REFRESH_INTERVAL, // 10 seconds gcTime: REFRESH_INTERVAL, // 10 seconds cache time retry: (failureCount, error) => { if (error && "status" in error && error.status === 404) return false if (failureCount < 2) return true return false }, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), refetchOnWindowFocus: false, }) return { hasSufficientBalanceUsd: hasSufficientBalanceUsd || false, isLoadingHasSufficientBalanceUsd: isLoadingHasSufficientBalanceUsd || !targetAmountUsd || !account, hasSufficientBalanceUsdError, } } export type GetAccountTotalBalanceUsdParams = { account: string indexerGatewayClient: SequenceIndexerGateway apiClient: SequenceAPIClient } export async function getAccountTotalBalanceUsd({ account, indexerGatewayClient, apiClient, }: GetAccountTotalBalanceUsdParams): Promise<number> { const { balances } = await getTokenBalancesWithPrices({ account, indexerGatewayClient, apiClient, }) return balances.reduce((acc, b) => acc + (b.balanceUsd ?? 0), 0) } export function useAccountTotalBalanceUsd(account: string): { totalBalanceUsd: number isLoadingTotalBalanceUsd: boolean totalBalanceUsdFormatted: string } { const indexerGatewayClient = useIndexerGatewayClient() const apiClient = useAPIClient() const { data: totalBalanceUsd, isLoading: isLoadingTotalBalanceUsd } = useQuery({ queryKey: ["tokenBalances", "totalUsd", account], queryFn: () => account ? getAccountTotalBalanceUsd({ account: account, indexerGatewayClient: indexerGatewayClient, apiClient: apiClient, }) : null, enabled: !!account, staleTime: REFRESH_INTERVAL, // 10 seconds - faster updates for balance changes gcTime: REFRESH_INTERVAL, // 10 seconds cache time retry: (failureCount, error) => { if (error && "status" in error && error.status === 404) return false if (failureCount < 2) return true return false }, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), refetchOnWindowFocus: false, refetchOnReconnect: true, refetchInterval: REFRESH_INTERVAL, // Background refetch every 10 seconds refetchIntervalInBackground: true, refetchOnMount: true, }) return { totalBalanceUsd: totalBalanceUsd || 0, isLoadingTotalBalanceUsd, totalBalanceUsdFormatted: formatUsdAmountDisplay(totalBalanceUsd || 0), } }