diff --git a/.changeset/nervous-clouds-teach.md b/.changeset/nervous-clouds-teach.md new file mode 100644 index 000000000..c3db586e1 --- /dev/null +++ b/.changeset/nervous-clouds-teach.md @@ -0,0 +1,19 @@ +--- +'@relayprotocol/relay-kit-ui': major +--- + +Migrate wallet balance fetching from Dune (sunset) to Codex + +Breaking changes: + +- `duneConfig` provider option removed — use `codexConfig` (`{ apiBaseUrl?, apiKey? }`). The default api base url is `https://graph.codex.io`; override it to proxy requests and protect your api key. +- `useDuneBalances` hook removed — use `useCodexBalances(address, queryOptions)`. The mainnet/testnet parameter is gone; balances are fetched from Codex-supported networks and filtered by your configured chains. +- `isDuneBalance` removed from `useCurrencyBalance`'s return value. +- `useMultiWalletBalances` no longer takes the mainnet/testnet parameter. + +Other changes: + +- New `useSolanaBalance` hook: selected SVM tokens (Solana and Eclipse) now fetch balances directly from the chain's RPC (native via `getBalance`, SPL via `getTokenAccountsByOwner`) instead of an indexer, so post-swap balances are fresh and the aggressive periodic refetch workaround is gone. The internal eclipse-only balance hook is gone; Eclipse flows through the same path. +- Native SVM balances (SOL, Eclipse ETH) in the consolidated token selector list are fetched from their RPCs and merged with Codex results, since Codex does not index native SVM balances (nor Eclipse at all). Their USD values come from Codex prices for wrapped SOL / mainnet WETH. +- Spam filtering: Codex's `removeScams` plus an `isScam` drop, and a liquidity heuristic replacing Dune's spam scoring — tokens whose pool liquidity is under $1k or below the balance's usd value have their usd value stripped so they can't crowd out real holdings in the token selector (balances still display). +- Known loss: Soon balances are gone from the token selector list; Soon is no longer a supported chain. diff --git a/demo/components/providers/RelayKitProviderWrapper.tsx b/demo/components/providers/RelayKitProviderWrapper.tsx index 831c4ed29..9d1827581 100644 --- a/demo/components/providers/RelayKitProviderWrapper.tsx +++ b/demo/components/providers/RelayKitProviderWrapper.tsx @@ -60,9 +60,9 @@ export const RelayKitProviderWrapper: FC<{ baseApiUrl: relayApi, source: 'relay-demo', logLevel: LogLevel.Verbose, - duneConfig: { - apiBaseUrl: process.env.NEXT_PUBLIC_DUNE_API_URL, - apiKey: process.env.NEXT_PUBLIC_DUNE_API_KEY + codexConfig: { + apiBaseUrl: process.env.NEXT_PUBLIC_CODEX_API_URL, + apiKey: process.env.NEXT_PUBLIC_CODEX_API_KEY }, chains: dynamicChains, privateChainIds: process.env.NEXT_PUBLIC_INCLUDE_CHAINS?.split(','), diff --git a/demo/pages/api/codex/graphql.ts b/demo/pages/api/codex/graphql.ts new file mode 100644 index 000000000..1041d35c6 --- /dev/null +++ b/demo/pages/api/codex/graphql.ts @@ -0,0 +1,63 @@ +import type { NextApiRequest, NextApiResponse } from 'next' + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +): Promise { + const CODEX_API_KEY = process.env.CODEX_API_KEY + const allowedDomains = process.env.ALLOWED_API_DOMAINS + ? process.env.ALLOWED_API_DOMAINS.split(',') + : [] + let origin = req.headers.origin || req.headers.referer || '' + + try { + origin = new URL(origin).origin + } catch (e) {} + + if (allowedDomains.includes(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin) + res.setHeader('Vary', 'Origin') + res.setHeader('Access-Control-Allow-Methods', 'POST,OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization') + } + + if (req.method === 'OPTIONS') { + res.status(200).end() + return + } + + if (req.method !== 'POST') { + res.status(405).json({ message: 'Method not allowed' }) + return + } + + if (!CODEX_API_KEY) { + res.status(500).json({ + error: 'Server configuration error' + }) + return + } + + if (allowedDomains.length > 0 && !allowedDomains.includes(origin)) { + res.status(403).json({ message: 'Forbidden: Origin not allowed' }) + return + } + + const codexResponse = await fetch('https://graph.codex.io/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: CODEX_API_KEY + }, + body: JSON.stringify(req.body) + }) + + const response = await codexResponse.json() + + if (!codexResponse.ok) { + res.status(codexResponse.status).json(response) + return + } + + res.json(response) +} diff --git a/demo/pages/api/dune/[...path].ts b/demo/pages/api/dune/[...path].ts deleted file mode 100644 index 4add51d7a..000000000 --- a/demo/pages/api/dune/[...path].ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from 'next' - -const API_MAPPINGS = { - 'api/echo/v1/balances/evm': 'v1/evm/balances', - 'api/echo/beta2/balances/svm': 'beta/svm/balances' -} - -export default async function handler( - req: NextApiRequest, - res: NextApiResponse -): Promise { - const DUNE_API_KEY = process.env.DUNE_API_KEY - const allowedDomains = process.env.ALLOWED_API_DOMAINS - ? process.env.ALLOWED_API_DOMAINS.split(',') - : [] - const cache = 60 - let origin = req.headers.origin || req.headers.referer || '' - - try { - origin = new URL(origin).origin - } catch (e) {} - - if (allowedDomains.includes(origin)) { - res.setHeader('Access-Control-Allow-Origin', origin) - res.setHeader('Vary', 'Origin') - res.setHeader('Access-Control-Allow-Methods', 'GET,OPTIONS') - res.setHeader( - 'Access-Control-Allow-Headers', - 'Content-Type,Authorization,x-sim-api-key' - ) - } - - if (req.method === 'OPTIONS') { - res.status(200).end() - return - } - - const path = req.url?.replace('/api/dune', '') || '' - const newPath = Object.entries(API_MAPPINGS).reduce( - (acc, [old, new_]) => acc.replace(old, new_), - path - ) - - let modifiedPath = newPath - const chainIdsMatch = modifiedPath.match(/chain_ids=([^&]*)/) - if (chainIdsMatch && chainIdsMatch[1].includes('mainnet')) { - if (!chainIdsMatch[1].includes('747474')) { - const newChainIds = chainIdsMatch[1] + ',747474' - modifiedPath = modifiedPath.replace( - /chain_ids=([^&]*)/, - `chain_ids=${newChainIds}` - ) - } - } - - const url = `https://api.sim.dune.com${modifiedPath}` - - if (!DUNE_API_KEY) { - res.status(500).json({ - error: 'Server configuration error' - }) - return - } - - if (allowedDomains.length > 0 && !allowedDomains.includes(origin)) { - res.status(403).json({ message: 'Forbidden: Origin not allowed' }) - return - } - - const duneResponse = await fetch(url, { - headers: { - 'X-Sim-Api-Key': DUNE_API_KEY - } as HeadersInit - }) - - const response = await duneResponse.json() - - if (!duneResponse.ok) { - res.status(duneResponse.status).json(response) - return - } - - // Set response cache - res.setHeader('Cache-Control', `public, s-maxage=${cache}`) - res.setHeader('CDN-Cache-Control', `public, s-maxage=${cache}`) - res.setHeader('Vercel-CDN-Cache-Control', `public, s-maxage=${cache}`) - - res.json(response) -} diff --git a/packages/ui/src/components/common/TokenSelector/PaymentMethod.tsx b/packages/ui/src/components/common/TokenSelector/PaymentMethod.tsx index 2fee90add..0f6a85236 100644 --- a/packages/ui/src/components/common/TokenSelector/PaymentMethod.tsx +++ b/packages/ui/src/components/common/TokenSelector/PaymentMethod.tsx @@ -189,21 +189,17 @@ const PaymentMethod: FC = ({ const { balanceMap: tokenBalances, - data: duneTokens, + data: walletTokens, isLoading: isLoadingBalances - } = useMultiWalletBalances( - linkedWallets, - address, - relayClient?.baseApiUrl?.includes('testnet') ? 'testnet' : 'mainnet' - ) + } = useMultiWalletBalances(linkedWallets, address) - const filteredDuneTokenBalances = useMemo(() => { - return duneTokens?.balances - }, [duneTokens?.balances]) + const filteredTokenBalances = useMemo(() => { + return walletTokens?.balances + }, [walletTokens?.balances]) const userTokensQuery = useMemo(() => { - if (filteredDuneTokenBalances && filteredDuneTokenBalances.length > 0) { - const sortedBalances = [...filteredDuneTokenBalances] + if (filteredTokenBalances && filteredTokenBalances.length > 0) { + const sortedBalances = [...filteredTokenBalances] .sort((a, b) => { const aValue = a.value_usd || 0 const bValue = b.value_usd || 0 @@ -216,7 +212,7 @@ const PaymentMethod: FC = ({ ) } return undefined - }, [filteredDuneTokenBalances]) + }, [filteredTokenBalances]) const { data: userTokens, isLoading: isLoadingUserTokens } = useTokenList( relayClient?.baseApiUrl, @@ -229,7 +225,7 @@ const PaymentMethod: FC = ({ } : undefined, { - enabled: !!filteredDuneTokenBalances + enabled: !!filteredTokenBalances } ) @@ -516,9 +512,7 @@ const PaymentMethod: FC = ({ - + {/* Main Token Content */} { @@ -648,7 +642,7 @@ const PaymentMethod: FC = ({ ) : ( {(() => { - const hasLoadedBalanceData = Boolean(duneTokens) + const hasLoadedBalanceData = Boolean(walletTokens) const userTokensReady = !isLoadingUserTokens const isWaitingForBalanceData = address && @@ -761,9 +755,7 @@ const PaymentMethod: FC = ({ <>
onOpenChange(true)}>{trigger}
{open && ( - + {paymentMethodContent} )} diff --git a/packages/ui/src/components/common/TokenSelector/TokenSelector.tsx b/packages/ui/src/components/common/TokenSelector/TokenSelector.tsx index 37fc09ff0..91614ef6b 100644 --- a/packages/ui/src/components/common/TokenSelector/TokenSelector.tsx +++ b/packages/ui/src/components/common/TokenSelector/TokenSelector.tsx @@ -16,7 +16,7 @@ import type { Token } from '../../../types/index.js' import { type ChainFilterValue } from './ChainFilter.js' import useRelayClient from '../../../hooks/useRelayClient.js' import { type Address } from 'viem' -import { useDebounceState, useDuneBalances } from '../../../hooks/index.js' +import { useDebounceState, useCodexBalances } from '../../../hooks/index.js' import { useMediaQuery } from 'usehooks-ts' import { type Currency, @@ -211,10 +211,10 @@ const TokenSelector: FC = ({ // Get user's token balances const { - data: duneTokens, + data: walletTokens, balanceMap: tokenBalances, isLoading: isLoadingBalances - } = useDuneBalances( + } = useCodexBalances( address && address !== evmDeadAddress && address !== solDeadAddress && @@ -222,27 +222,26 @@ const TokenSelector: FC = ({ isValidAddress ? address : undefined, - relayClient?.baseApiUrl?.includes('testnet') ? 'testnet' : 'mainnet', { staleTime: 60000, gcTime: 60000 } ) - // Filter dune token balances based on configured chains - const filteredDuneTokenBalances = useMemo(() => { - return duneTokens?.balances?.filter((balance) => + // Filter wallet token balances based on configured chains + const filteredTokenBalances = useMemo(() => { + return walletTokens?.balances?.filter((balance) => configuredChainIds.includes(balance.chain_id) ) - }, [duneTokens?.balances, configuredChainIds]) + }, [walletTokens?.balances, configuredChainIds]) const userTokensQuery = useMemo(() => { if (depositAddressOnly) { return undefined } - if (filteredDuneTokenBalances && filteredDuneTokenBalances.length > 0) { - const sortedBalances = [...filteredDuneTokenBalances] + if (filteredTokenBalances && filteredTokenBalances.length > 0) { + const sortedBalances = [...filteredTokenBalances] .sort((a, b) => (b.value_usd ?? 0) - (a.value_usd ?? 0)) .slice(0, 100) @@ -251,7 +250,7 @@ const TokenSelector: FC = ({ ) } return undefined - }, [filteredDuneTokenBalances, depositAddressOnly]) + }, [filteredTokenBalances, depositAddressOnly]) const solverUserTokens = useMemo(() => { if (!depositAddressOnly) { diff --git a/packages/ui/src/components/widgets/SwapWidgetRenderer.tsx b/packages/ui/src/components/widgets/SwapWidgetRenderer.tsx index f9623d654..ac057c148 100644 --- a/packages/ui/src/components/widgets/SwapWidgetRenderer.tsx +++ b/packages/ui/src/components/widgets/SwapWidgetRenderer.tsx @@ -259,8 +259,10 @@ const SwapWidgetRenderer: FC = ({ const fromChainWalletVMSupported = isChainVmTypeSupported(fromChain?.vmType, supportedWalletVMs) || fromChain?.id === 1337 - const toChainWalletVMSupported = - isChainVmTypeSupported(toChain?.vmType, supportedWalletVMs) + const toChainWalletVMSupported = isChainVmTypeSupported( + toChain?.vmType, + supportedWalletVMs + ) const defaultRecipient = useMemo(() => { const _linkedWallet = linkedWallets?.find( @@ -310,7 +312,6 @@ const SwapWidgetRenderer: FC = ({ queryKey: fromBalanceQueryKey, isLoading: isLoadingFromBalance, isError: fromBalanceErrorFetching, - isDuneBalance: fromBalanceIsDune, hasPendingBalance: fromBalancePending } = useCurrencyBalance({ chain: fromChain, @@ -325,7 +326,6 @@ const SwapWidgetRenderer: FC = ({ value: toBalance, queryKey: toBalanceQueryKey, isLoading: isLoadingToBalance, - isDuneBalance: toBalanceIsDune, hasPendingBalance: toBalancePending } = useCurrencyBalance({ chain: toChain, @@ -337,45 +337,10 @@ const SwapWidgetRenderer: FC = ({ }) const invalidateBalanceQueries = useCallback(() => { - const invalidatePeriodically = (invalidateFn: () => void) => { - let maxRefreshes = 4 - let refreshCount = 0 - const timer = setInterval(() => { - if (maxRefreshes === refreshCount) { - clearInterval(timer) - return - } - refreshCount++ - invalidateFn() - }, 3000) - } - - queryClient.invalidateQueries({ queryKey: ['useDuneBalances'] }) - - // Dune balances are sometimes stale, because of this we need to aggressively fetch them - // for a predetermined period to make sure we get back a fresh response - if (fromBalanceIsDune) { - invalidatePeriodically(() => { - queryClient.invalidateQueries({ queryKey: fromBalanceQueryKey }) - }) - } else { - queryClient.invalidateQueries({ queryKey: fromBalanceQueryKey }) - } - if (toBalanceIsDune) { - invalidatePeriodically(() => { - queryClient.invalidateQueries({ queryKey: toBalanceQueryKey }) - }) - } else { - queryClient.invalidateQueries({ queryKey: toBalanceQueryKey }) - } - }, [ - queryClient, - fromBalanceQueryKey, - toBalanceQueryKey, - toBalanceIsDune, - fromBalanceIsDune, - address - ]) + queryClient.invalidateQueries({ queryKey: ['useCodexBalances'] }) + queryClient.invalidateQueries({ queryKey: fromBalanceQueryKey }) + queryClient.invalidateQueries({ queryKey: toBalanceQueryKey }) + }, [queryClient, fromBalanceQueryKey, toBalanceQueryKey, address]) const { data: capabilities } = useCapabilities({ query: { enabled: diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts index c0b1ddde7..c50ed2e3a 100644 --- a/packages/ui/src/hooks/index.ts +++ b/packages/ui/src/hooks/index.ts @@ -3,7 +3,8 @@ import useENSResolver from './useENSResolver.js' import useCurrencyBalance from './useCurrencyBalance.js' import useRelayClient from './useRelayClient.js' import useDebounceState from './useDebounceState.js' -import useDuneBalances from './useDuneBalances.js' +import useCodexBalances from './useCodexBalances.js' +import useSolanaBalance from './useSolanaBalance.js' import { useMultiWalletBalances } from './useMultiWalletBalances.js' import useWalletAddress from './useWalletAddress.js' import useDisconnected from './useDisconnected.js' @@ -35,7 +36,8 @@ export { useCurrencyBalance, useRelayClient, useDebounceState, - useDuneBalances, + useCodexBalances, + useSolanaBalance, useMultiWalletBalances, useWalletAddress, useDisconnected, diff --git a/packages/ui/src/hooks/useCodexBalances.ts b/packages/ui/src/hooks/useCodexBalances.ts new file mode 100644 index 000000000..f40d3012c --- /dev/null +++ b/packages/ui/src/hooks/useCodexBalances.ts @@ -0,0 +1,440 @@ +import { formatUnits, isAddress, zeroAddress } from 'viem' +import { ProviderOptionsContext } from '../providers/RelayKitProvider.js' +import { useContext } from 'react' +import { + useQuery, + type DefaultError, + type QueryKey +} from '@tanstack/react-query' +import { eclipse, isSolanaAddress, solana } from '../utils/solana.js' +import type { RelayChain } from '@relayprotocol/relay-sdk' +import useRelayClient from './useRelayClient.js' + +export type WalletBalance = { + chain_id: number + address: string + amount: string + symbol: string + decimals: number + price_usd?: number + value_usd?: number +} + +export type WalletBalanceResponse = { + balances: WalletBalance[] +} | null + +export type BalanceMap = Record + +export type CodexConfig = { + apiBaseUrl?: string + apiKey?: string +} + +export const CODEX_SVM_NETWORK_ID = 1399811149 + +const SOLANA_NATIVE_ADDRESS = '11111111111111111111111111111111' +// zero address + EIP-7528 placeholder — indexers use either for native entries +const EVM_NATIVE_ALIASES = [ + zeroAddress, + '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + 'native' +] + +const MAX_BALANCES = 200 +const PAGE_LIMIT = 100 +// Codex prices thinly-traded junk that removeScams misses — below this pool +// liquidity a token's usd value is treated as unrealizable and stripped +const MIN_LIQUIDITY_USD = 1000 + +const BALANCES_QUERY = `query WalletBalances($input: BalancesInput!) { + balances(input: $input) { + cursor + items { + balance + balanceUsd + tokenPriceUsd + liquidityUsd + tokenAddress + networkId + token { + symbol + decimals + isScam + } + } + } +}` + +type CodexBalanceItem = { + balance: string + balanceUsd?: string | null + tokenPriceUsd?: string | null + liquidityUsd?: string | null + tokenAddress: string + networkId: number + token?: { + symbol?: string | null + decimals?: number | null + isScam?: boolean | null + } | null +} + +export type SvmNativeChain = { + chainId: number + rpcUrl: string + symbol: string + decimals: number + priceToken?: { + address: string + networkId: number + } +} + +// Codex doesn't index native SVM assets — price them via equivalent indexed tokens +const SVM_NATIVE_PRICE_TOKENS: Record< + number, + { address: string; networkId: number } +> = { + // SOL priced via wrapped SOL + [solana.id]: { + address: 'So11111111111111111111111111111111111111112', + networkId: CODEX_SVM_NETWORK_ID + }, + // eclipse gas token is ETH, priced via mainnet WETH + [eclipse.id]: { + address: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + networkId: 1 + } +} + +// Codex requires networks when includeNative is true; unsupported ids are ignored +export const getEvmNetworkIds = (chains?: RelayChain[]): number[] => { + return (chains ?? []) + .filter((chain) => chain.vmType === 'evm') + .map((chain) => chain.id) +} + +/** + * SVM chains whose native balances come from their RPC — Codex only covers + * Solana SPL tokens, so gas balances (SOL, eclipse ETH) are merged in manually. + */ +export const getSvmNativeChains = (chains?: RelayChain[]): SvmNativeChain[] => { + return (chains ?? []) + .filter((chain) => chain.vmType === 'svm') + .flatMap((chain) => + chain.httpRpcUrl && + chain.currency?.symbol && + chain.currency?.decimals !== undefined + ? [ + { + chainId: chain.id, + rpcUrl: chain.httpRpcUrl, + symbol: chain.currency.symbol, + decimals: chain.currency.decimals, + priceToken: SVM_NATIVE_PRICE_TOKENS[chain.id] + } + ] + : [] + ) +} + +const fetchSvmNativeBalances = async ( + address: string, + chains: SvmNativeChain[] +): Promise> => { + return Promise.all( + chains.map(async (chain) => { + try { + const response = await fetch(chain.rpcUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'getBalance', + params: [address] + }) + }) + const data = await response.json() + if (data.error) { + return null + } + return { chain, amount: BigInt(data.result?.value ?? 0) } + } catch (e) { + return null + } + }) + ) +} + +const NATIVE_PRICES_QUERY = `query NativePrices($inputs: [GetPriceInput!]!) { + getTokenPrices(inputs: $inputs) { + address + networkId + priceUsd + } +}` + +const fetchSvmNativePrices = async ( + apiUrl: string, + config: CodexConfig, + chains: SvmNativeChain[] +): Promise> => { + const inputs = chains.flatMap((chain) => + chain.priceToken ? [chain.priceToken] : [] + ) + if (!inputs.length) { + return {} + } + try { + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(config.apiKey ? { Authorization: config.apiKey } : {}) + }, + body: JSON.stringify({ + query: NATIVE_PRICES_QUERY, + variables: { inputs } + }) + }) + const json = await response.json() + const prices: Record = {} + json.data?.getTokenPrices?.forEach( + ( + price: { + address: string + networkId: number + priceUsd?: number | null + } | null + ) => { + if (price?.priceUsd !== undefined && price?.priceUsd !== null) { + prices[`${price.networkId}:${price.address}`] = price.priceUsd + } + } + ) + return prices + } catch (e) { + return {} + } +} + +const normalizeAddress = (networkId: number, tokenAddress: string): string => { + if (networkId === CODEX_SVM_NETWORK_ID) { + return tokenAddress === 'native' ? SOLANA_NATIVE_ADDRESS : tokenAddress + } + return EVM_NATIVE_ALIASES.includes(tokenAddress.toLowerCase()) + ? zeroAddress + : tokenAddress.toLowerCase() +} + +const toNumber = (value?: string | null): number | undefined => { + if (value === undefined || value === null) { + return undefined + } + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : undefined +} + +export const fetchCodexBalances = async ( + address: string, + config: CodexConfig, + networks: number[], + svmNativeChains?: SvmNativeChain[] +): Promise => { + const apiUrl = `${config.apiBaseUrl ?? 'https://graph.codex.io'}/graphql` + const items: CodexBalanceItem[] = [] + let cursor: string | null = null + + while (items.length < MAX_BALANCES) { + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(config.apiKey ? { Authorization: config.apiKey } : {}) + }, + body: JSON.stringify({ + query: BALANCES_QUERY, + variables: { + input: { + walletAddress: address, + includeNative: true, + removeScams: true, + sortBy: 'USD_VALUE', + sortDirection: 'DESC', + limit: PAGE_LIMIT, + networks, + ...(cursor ? { cursor } : {}) + } + } + }) + }) + + if (!response.ok) { + throw new Error(`Failed to fetch balances for ${address}`) + } + + const json = await response.json() + if (json.errors?.length) { + throw new Error( + json.errors[0]?.message ?? `Failed to fetch balances for ${address}` + ) + } + + const page = json.data?.balances + if (!page?.items?.length) { + break + } + items.push(...page.items) + cursor = page.cursor + if (!cursor) { + break + } + } + + const balances = items.reduce((balances, item) => { + const decimals = item.token?.decimals + const symbol = item.token?.symbol + if (typeof decimals !== 'number' || !symbol || item.token?.isScam) { + return balances + } + try { + BigInt(item.balance) + } catch (e) { + return balances + } + + const address = normalizeAddress(item.networkId, item.tokenAddress) + const isNative = + address === zeroAddress || address === SOLANA_NATIVE_ADDRESS + let price_usd = toNumber(item.tokenPriceUsd) + let value_usd = toNumber(item.balanceUsd) + const liquidityUsd = toNumber(item.liquidityUsd) + if ( + !isNative && + liquidityUsd !== undefined && + (liquidityUsd < MIN_LIQUIDITY_USD || + (value_usd !== undefined && value_usd > liquidityUsd)) + ) { + price_usd = undefined + value_usd = undefined + } + + balances.push({ + chain_id: + item.networkId === CODEX_SVM_NETWORK_ID ? solana.id : item.networkId, + address, + amount: item.balance, + symbol, + decimals, + price_usd, + value_usd + }) + return balances + }, []) + + if (svmNativeChains?.length) { + const [nativeBalances, nativePrices] = await Promise.all([ + fetchSvmNativeBalances(address, svmNativeChains), + fetchSvmNativePrices(apiUrl, config, svmNativeChains) + ]) + nativeBalances.forEach((native) => { + if (!native) { + return + } + const priceToken = native.chain.priceToken + const priceUsd = priceToken + ? nativePrices[`${priceToken.networkId}:${priceToken.address}`] + : undefined + const existing = balances.find( + (balance) => + balance.chain_id === native.chain.chainId && + balance.address === SOLANA_NATIVE_ADDRESS + ) + if (existing) { + const price = existing.price_usd ?? priceUsd + existing.amount = native.amount.toString() + if (price !== undefined) { + existing.price_usd = price + existing.value_usd = + price * Number(formatUnits(native.amount, existing.decimals)) + } + } else if (native.amount > BigInt(0)) { + balances.push({ + chain_id: native.chain.chainId, + address: SOLANA_NATIVE_ADDRESS, + amount: native.amount.toString(), + symbol: native.chain.symbol, + decimals: native.chain.decimals, + price_usd: priceUsd, + value_usd: + priceUsd !== undefined + ? priceUsd * + Number(formatUnits(native.amount, native.chain.decimals)) + : undefined + }) + } + }) + } + + return { balances } +} + +type QueryType = typeof useQuery< + WalletBalanceResponse, + DefaultError, + WalletBalanceResponse, + QueryKey +> +type QueryOptions = Parameters['0'] + +export default (address?: string, queryOptions?: Partial) => { + const providerOptions = useContext(ProviderOptionsContext) + const relayClient = useRelayClient() + const codexConfig = providerOptions.codexConfig + const queryKey = ['useCodexBalances', address] + const isEvmAddress = isAddress(address ?? '') + const isSvmAddress = isSolanaAddress(address ?? '') + const networks = isSvmAddress + ? [CODEX_SVM_NETWORK_ID] + : getEvmNetworkIds(relayClient?.chains) + + const response = (useQuery as QueryType)({ + queryKey, + queryFn: () => { + if (!address || (!isSvmAddress && !isEvmAddress)) { + return null + } + + return fetchCodexBalances( + address, + codexConfig ?? {}, + networks, + isSvmAddress ? getSvmNativeChains(relayClient?.chains) : undefined + ) + }, + ...queryOptions, + enabled: + address !== undefined && + networks.length > 0 && + (codexConfig?.apiKey !== undefined || + codexConfig?.apiBaseUrl !== undefined) && + queryOptions?.enabled && + (isSvmAddress || isEvmAddress) + }) + + // Keys are lowercased to match consumer lookups; balance.address keeps the + // mint's original case for the tokens api + const balanceMap = response?.data?.balances?.reduce((balanceMap, balance) => { + balanceMap[`${balance.chain_id}:${balance.address.toLowerCase()}`] = balance + return balanceMap + }, {} as BalanceMap) + + return { ...response, balanceMap, queryKey } as ReturnType & { + balanceMap: typeof balanceMap + queryKey: (string | undefined)[] + } +} diff --git a/packages/ui/src/hooks/useCurrencyBalance.ts b/packages/ui/src/hooks/useCurrencyBalance.ts index 7a9a168aa..f8cd16ed1 100644 --- a/packages/ui/src/hooks/useCurrencyBalance.ts +++ b/packages/ui/src/hooks/useCurrencyBalance.ts @@ -9,13 +9,10 @@ import { useBalance, useReadContract } from 'wagmi' import { erc20Abi } from 'viem' import type { QueryKey } from '@tanstack/react-query' import type { AdaptedWallet, RelayChain } from '@relayprotocol/relay-sdk' -import useDuneBalances from './useDuneBalances.js' +import useSolanaBalance from './useSolanaBalance.js' import useBitcoinBalance from './useBitcoinBalance.js' import useAdaptedWalletBalance from './useAdaptedWalletBalance.js' import { isValidAddress } from '../utils/address.js' -import useRelayClient from './useRelayClient.js' -import useEclipseBalance from '../hooks/useEclipseBalance.js' -import { eclipse } from '../utils/solana.js' import useHyperliquidBalance from './useHyperliquidBalance.js' import useHyperliquidAccountMode from './useHyperliquidAccountMode.js' import useTronBalance from '../hooks/useTronBalance.js' @@ -36,7 +33,6 @@ type UseCurrencyBalanceData = { isLoading: boolean isError: boolean | GetBalanceErrorType | null error: boolean | ReadContractErrorType | Error | null - isDuneBalance: boolean hasPendingBalance?: boolean } @@ -51,7 +47,6 @@ const useCurrencyBalance = ({ }: UseBalanceProps): UseCurrencyBalanceData => { const isErc20Currency = currency && currency !== zeroAddress const isValidEvmAddress = address && isAddress(address) - const relayClient = useRelayClient() const adaptedWalletBalanceIsEnabled = wallet?.getBalance !== undefined && wallet.vmType === chain?.vmType @@ -113,15 +108,15 @@ const useCurrencyBalance = ({ const _isValidAddress = isValidAddress(chain?.vmType, address, chain?.id) - const duneBalances = useDuneBalances( + const solanaBalance = useSolanaBalance( address, - relayClient?.baseApiUrl?.includes('testnet') ? 'testnet' : 'mainnet', + currency ? (currency as string) : undefined, + chain?.httpRpcUrl, { enabled: Boolean( !adaptedWalletBalanceIsEnabled && chain && chain.vmType === 'svm' && - chain.id !== eclipse.id && address && _isValidAddress && enabled @@ -144,18 +139,6 @@ const useCurrencyBalance = ({ staleTime: refreshInterval }) - const eclipseBalances = useEclipseBalance(address, { - enabled: Boolean( - !adaptedWalletBalanceIsEnabled && - chain && - chain.vmType === 'svm' && - chain.id === eclipse.id && - address && - _isValidAddress && - enabled - ) - }) - const isHypevm = chain?.vmType === 'hypevm' const { data: hyperliquidAccountMode } = useHyperliquidAccountMode( @@ -219,8 +202,7 @@ const useCurrencyBalance = ({ queryKey: adaptedWalletBalance.queryKey, isLoading: adaptedWalletBalance.isLoading, isError: adaptedWalletBalance.isError, - error: adaptedWalletBalance.error, - isDuneBalance: false + error: adaptedWalletBalance.error } } else if (chain?.vmType === 'evm') { const value = isErc20Currency ? erc20Balance : ethBalance?.value @@ -230,51 +212,14 @@ const useCurrencyBalance = ({ const isLoading = isErc20Currency ? erc20BalanceIsLoading : ethBalanceIsLoading - return { value, queryKey, isLoading, isError, error, isDuneBalance: false } + return { value, queryKey, isLoading, isError, error } } else if (chain?.vmType === 'svm') { - let value: undefined | bigint = undefined - let isDuneBalance = true - let isError = false - let error: Error | null = null - - if (chain.id === eclipse.id) { - value = eclipseBalances.data?.balance - isDuneBalance = false - isError = eclipseBalances.isError - error = eclipseBalances.error - } else { - value = - currency && - duneBalances.balanceMap && - duneBalances.balanceMap[`${chain.id}:${currency}`] && - duneBalances.balanceMap[`${chain.id}:${currency}`].amount - ? BigInt( - duneBalances.balanceMap[`${chain.id}:${currency}`].amount ?? 0 - ) - : undefined - isDuneBalance = true - isError = duneBalances.isError - error = duneBalances.error - } - - if (_isValidAddress) { - return { - value, - queryKey: duneBalances.queryKey, - isLoading: duneBalances.isLoading, - isError, - error, - isDuneBalance - } - } else { - return { - value: undefined, - queryKey: duneBalances.queryKey, - isLoading: duneBalances.isLoading, - isError, - error, - isDuneBalance - } + return { + value: _isValidAddress ? solanaBalance.balance : undefined, + queryKey: solanaBalance.queryKey, + isLoading: solanaBalance.isLoading, + isError: solanaBalance.isError, + error: solanaBalance.error } } else if (chain?.vmType === 'bvm') { if (_isValidAddress) { @@ -287,7 +232,7 @@ const useCurrencyBalance = ({ isLoading: bitcoinBalances.isLoading, isError: bitcoinBalances.isError, error: bitcoinBalances.error, - isDuneBalance: false, + hasPendingBalance: bitcoinBalances.data?.pendingBalance && bitcoinBalances.data?.pendingBalance > 0n @@ -301,7 +246,7 @@ const useCurrencyBalance = ({ isLoading: bitcoinBalances.isLoading, isError: bitcoinBalances.isError, error: bitcoinBalances.error, - isDuneBalance: false, + hasPendingBalance: false } } @@ -311,8 +256,7 @@ const useCurrencyBalance = ({ queryKey: hyperliquidBalance.queryKey, isLoading: hyperliquidBalance.isLoading, isError: hyperliquidBalance.isError, - error: hyperliquidBalance.error, - isDuneBalance: false + error: hyperliquidBalance.error } } else if (chain?.vmType === 'tvm') { return { @@ -320,8 +264,7 @@ const useCurrencyBalance = ({ queryKey: tronBalance.queryKey, isLoading: tronBalance.isLoading, isError: tronBalance.isError, - error: tronBalance.error, - isDuneBalance: false + error: tronBalance.error } } else if (chain?.vmType === 'tonvm') { return { @@ -329,17 +272,15 @@ const useCurrencyBalance = ({ queryKey: tonBalance.queryKey, isLoading: tonBalance.isLoading, isError: tonBalance.isError, - error: tonBalance.error, - isDuneBalance: false + error: tonBalance.error } } else { return { value: undefined, - queryKey: duneBalances.queryKey, - isLoading: duneBalances.isLoading, - isError: duneBalances.isError, - error: duneBalances.error, - isDuneBalance: false + queryKey: [], + isLoading: false, + isError: false, + error: null } } } diff --git a/packages/ui/src/hooks/useDuneBalances.ts b/packages/ui/src/hooks/useDuneBalances.ts deleted file mode 100644 index abb4f684b..000000000 --- a/packages/ui/src/hooks/useDuneBalances.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { formatUnits, isAddress, zeroAddress } from 'viem' -import { ProviderOptionsContext } from '../providers/RelayKitProvider.js' -import { useContext } from 'react' -import { - useQuery, - type DefaultError, - type QueryKey -} from '@tanstack/react-query' -import { eclipse, isSolanaAddress, solana } from '../utils/solana.js' - -export type DuneBalanceResponse = { - request_time: string - response_time: string - wallet_address: string - balances: Array<{ - chain: string - chain_id: number - address: string - amount: string - symbol: string - decimals: number - price_usd?: number - value_usd?: number - }> -} | null - -export type BalanceMap = Record< - string, - NonNullable['balances'][0] -> - -type QueryType = typeof useQuery< - DuneBalanceResponse, - DefaultError, - DuneBalanceResponse, - QueryKey -> -type QueryOptions = Parameters['0'] - -export default ( - address?: string, - evmChainIds: 'mainnet' | 'testnet' = 'mainnet', - queryOptions?: Partial -) => { - const providerOptions = useContext(ProviderOptionsContext) - const queryKey = ['useDuneBalances', address] - const isEvmAddress = isAddress(address ?? '') - const isSvmAddress = isSolanaAddress(address ?? '') - - const response = (useQuery as QueryType)({ - queryKey: ['useDuneBalances', address], - queryFn: () => { - let url = `${ - providerOptions.duneConfig?.apiBaseUrl ?? 'https://api.sim.dune.com' - }/v1/evm/balances/${address?.toLowerCase()}?chain_ids=${evmChainIds}&exclude_spam_tokens=true` - if (isSvmAddress) { - url = `${ - providerOptions.duneConfig?.apiBaseUrl ?? 'https://api.sim.dune.com' - }/beta/svm/balances/${address}?chain_ids=all&exclude_spam_tokens=true` - } - - if (!isSvmAddress && !isEvmAddress) { - return null - } - - return fetch(url, { - headers: providerOptions.duneConfig?.apiKey - ? { - 'X-Sim-Api-Key': providerOptions.duneConfig?.apiKey - } - : ({} as HeadersInit) - }) - .then((response) => response.json()) - .then((response) => { - if (response.balances) { - const balances = - response.balances as NonNullable['balances'] - if (balances) { - balances - .filter((balance) => { - try { - BigInt(balance.amount) - return true - } catch (e) { - return false - } - }) - .sort((a, b) => { - // Check if value_usd exists and use it for comparison if available - if (a.value_usd !== undefined && b.value_usd !== undefined) { - return a.value_usd - b.value_usd - } else if (a.value_usd !== undefined) { - return -1 // a should come before b as it has a value_usd - } else if (b.value_usd !== undefined) { - return 1 // b should come before a as it has a value_usd - } else { - const amountA = parseFloat( - formatUnits(BigInt(a.amount), a.decimals) - ) - const amountB = parseFloat( - formatUnits(BigInt(b.amount), b.decimals) - ) - return amountA - amountB - } - }) - } - } - return response - }) - }, - ...queryOptions, - enabled: - address !== undefined && - (providerOptions.duneConfig?.apiKey !== undefined || - providerOptions.duneConfig?.apiBaseUrl !== undefined) && - queryOptions?.enabled && - (isSvmAddress || isEvmAddress) - }) - - const balanceMap = response?.data?.balances?.reduce((balanceMap, balance) => { - // Create a new object instead of mutating the cached one - const normalizedBalance = { ...balance } - - // Normalize chain_id - if (!normalizedBalance.chain_id && normalizedBalance.chain === 'solana') { - normalizedBalance.chain_id = solana.id - } - if (!normalizedBalance.chain_id && normalizedBalance.chain === 'eclipse') { - normalizedBalance.chain_id = eclipse.id - } - - // Normalize native address - if (normalizedBalance.address === 'native') { - normalizedBalance.address = - normalizedBalance.chain === 'solana' || - normalizedBalance.chain === 'eclipse' - ? '11111111111111111111111111111111' - : zeroAddress - } - - let chainId = normalizedBalance.chain_id - if (!chainId && normalizedBalance.chain === 'solana') { - chainId = solana.id - } - if (!chainId && normalizedBalance.chain === 'eclipse') { - chainId = eclipse.id - } - - balanceMap[`${chainId}:${normalizedBalance.address}`] = normalizedBalance - return balanceMap - }, {} as BalanceMap) - - return { ...response, balanceMap, queryKey } as ReturnType & { - balanceMap: typeof balanceMap - queryKey: (string | undefined)[] - } -} diff --git a/packages/ui/src/hooks/useEclipseBalance.ts b/packages/ui/src/hooks/useEclipseBalance.ts deleted file mode 100644 index 3de26eb2e..000000000 --- a/packages/ui/src/hooks/useEclipseBalance.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { - useQuery, - type DefaultError, - type QueryKey -} from '@tanstack/react-query' -import useRelayClient from './useRelayClient.js' - -type EclipseBalanceResponse = { - value: number -} - -type BalanceResponse = { - balance: bigint -} - -type QueryType = typeof useQuery< - BalanceResponse | undefined, - DefaultError, - BalanceResponse | undefined, - QueryKey -> -type QueryOptions = Parameters['0'] - -export default (address?: string, queryOptions?: Partial) => { - const client = useRelayClient() - const eclipseChain = client?.chains?.find((chain) => chain.id === 9286185) - const rpcUrl = - eclipseChain && eclipseChain.httpRpcUrl - ? eclipseChain.httpRpcUrl - : 'https://mainnetbeta-rpc.eclipse.xyz' - const queryKey = ['useEclipseBalance', address, rpcUrl] - - const response = (useQuery as QueryType)({ - queryKey, - queryFn: async () => { - const payload = { - jsonrpc: '2.0', - id: 1, - method: 'getBalance', - params: [address] - } - - const response = await fetch(rpcUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(payload) - }) - - const data = await response.json() - - if (data.error) { - throw new Error(data.error.message) - } - - const result = data.result as EclipseBalanceResponse - - return { - balance: BigInt(result.value) - } - }, - enabled: address !== undefined, - ...queryOptions - }) - - return { - ...response, - balance: response.data?.balance, - queryKey - } as ReturnType & { - balance: bigint | undefined - queryKey: (string | undefined)[] - } -} diff --git a/packages/ui/src/hooks/useEnhancedTokensList.ts b/packages/ui/src/hooks/useEnhancedTokensList.ts index ad69a217e..50a688dcb 100644 --- a/packages/ui/src/hooks/useEnhancedTokensList.ts +++ b/packages/ui/src/hooks/useEnhancedTokensList.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import type { BalanceMap } from './useDuneBalances' +import type { BalanceMap } from './useCodexBalances.js' import { type Currency } from '@relayprotocol/relay-kit-hooks' import { useInternalRelayChains } from '../hooks/index.js' import type { ChainVM, RelayChain } from '@relayprotocol/relay-sdk' diff --git a/packages/ui/src/hooks/useMultiWalletBalances.ts b/packages/ui/src/hooks/useMultiWalletBalances.ts index dc5445abe..d5e736d6f 100644 --- a/packages/ui/src/hooks/useMultiWalletBalances.ts +++ b/packages/ui/src/hooks/useMultiWalletBalances.ts @@ -1,6 +1,14 @@ import { useMemo, useContext } from 'react' import { useQueries } from '@tanstack/react-query' -import { type BalanceMap, type DuneBalanceResponse } from './useDuneBalances.js' +import { + CODEX_SVM_NETWORK_ID, + fetchCodexBalances, + getEvmNetworkIds, + getSvmNativeChains, + type BalanceMap, + type WalletBalanceResponse +} from './useCodexBalances.js' +import useRelayClient from './useRelayClient.js' import { isDeadAddress } from '@relayprotocol/relay-sdk' import { ProviderOptionsContext } from '../providers/RelayKitProvider.js' import { isAddress } from 'viem' @@ -13,8 +21,7 @@ import type { LinkedWallet } from '../types/index.js' */ export const useMultiWalletBalances = ( linkedWallets?: LinkedWallet[], - primaryAddress?: string, - evmChainIds: 'mainnet' | 'testnet' = 'mainnet' + primaryAddress?: string ) => { const walletAddresses = useMemo(() => { const addresses = new Set() @@ -45,64 +52,37 @@ export const useMultiWalletBalances = ( }, [primaryAddress, linkedWallets]) const providerOptions = useContext(ProviderOptionsContext) + const relayClient = useRelayClient() const balanceQueries = useQueries({ queries: walletAddresses.map((address) => { const isEvmAddress = isAddress(address) const isSvmAddress = isSolanaAddress(address) - - let url = `${ - providerOptions?.duneConfig?.apiBaseUrl ?? 'https://api.sim.dune.com' - }/v1/evm/balances/${address.toLowerCase()}?chain_ids=${evmChainIds}&exclude_spam_tokens=true` - - if (isSvmAddress) { - url = `${ - providerOptions?.duneConfig?.apiBaseUrl ?? 'https://api.sim.dune.com' - }/beta/svm/balances/${address}?chain_ids=all&exclude_spam_tokens=true` - } + const networks = isSvmAddress + ? [CODEX_SVM_NETWORK_ID] + : getEvmNetworkIds(relayClient?.chains) return { - queryKey: ['useDuneBalances', address], - queryFn: async (): Promise => { + queryKey: ['useCodexBalances', address], + queryFn: async (): Promise => { if (!isSvmAddress && !isEvmAddress) { return null } - const response = await fetch(url, { - headers: providerOptions?.duneConfig?.apiKey - ? { - 'X-Sim-Api-Key': providerOptions.duneConfig.apiKey - } - : {} - }) - - if (!response.ok) { - throw new Error(`Failed to fetch balance for ${address}`) - } - - const data = await response.json() - - if (data?.balances) { - // Filter out invalid amounts like useDuneBalances does - const validBalances = data.balances.filter((balance: any) => { - try { - BigInt(balance.amount) - return true - } catch (e) { - return false - } - }) - - return { - ...data, - balances: validBalances - } as DuneBalanceResponse - } - - return data as DuneBalanceResponse + return fetchCodexBalances( + address, + providerOptions?.codexConfig ?? {}, + networks, + isSvmAddress ? getSvmNativeChains(relayClient?.chains) : undefined + ) }, enabled: Boolean( - address && address.trim() !== '' && (isEvmAddress || isSvmAddress) + address && + address.trim() !== '' && + (isEvmAddress || isSvmAddress) && + networks.length > 0 && + (providerOptions?.codexConfig?.apiKey !== undefined || + providerOptions?.codexConfig?.apiBaseUrl !== undefined) ), staleTime: 60000, gcTime: 60000, @@ -112,55 +92,54 @@ export const useMultiWalletBalances = ( }) // Merge all balance data with proper parallelization - no more 5-wallet limit! - const { mergedBalanceMap, mergedDuneTokens, isLoadingBalances } = - useMemo(() => { - const mergedMap: BalanceMap = {} - const allBalances: NonNullable['balances'] = [] - let anyLoading = false - - balanceQueries.forEach((query) => { - if (query.isLoading) anyLoading = true - - if (query.data?.balances) { - const balanceMap: BalanceMap = {} - query.data.balances.forEach((balance) => { - const key = `${balance.chain_id}:${balance.address.toLowerCase()}` - balanceMap[key] = balance - }) - - Object.entries(balanceMap).forEach(([key, balance]) => { - if (mergedMap[key]) { - // Token exists in multiple wallets - sum the amounts - const existingAmount = BigInt(mergedMap[key].amount) - const newAmount = BigInt(balance.amount) - const totalAmount = existingAmount + newAmount - - mergedMap[key] = { - ...balance, - amount: totalAmount.toString(), - value_usd: - (mergedMap[key].value_usd || 0) + (balance.value_usd || 0) - } - } else { - mergedMap[key] = balance + const { mergedBalanceMap, mergedTokens, isLoadingBalances } = useMemo(() => { + const mergedMap: BalanceMap = {} + const allBalances: NonNullable['balances'] = [] + let anyLoading = false + + balanceQueries.forEach((query) => { + if (query.isLoading) anyLoading = true + + if (query.data?.balances) { + const balanceMap: BalanceMap = {} + query.data.balances.forEach((balance) => { + const key = `${balance.chain_id}:${balance.address.toLowerCase()}` + balanceMap[key] = balance + }) + + Object.entries(balanceMap).forEach(([key, balance]) => { + if (mergedMap[key]) { + // Token exists in multiple wallets - sum the amounts + const existingAmount = BigInt(mergedMap[key].amount) + const newAmount = BigInt(balance.amount) + const totalAmount = existingAmount + newAmount + + mergedMap[key] = { + ...balance, + amount: totalAmount.toString(), + value_usd: + (mergedMap[key].value_usd || 0) + (balance.value_usd || 0) } - }) - - allBalances.push(...query.data.balances) - } - }) + } else { + mergedMap[key] = balance + } + }) - return { - mergedBalanceMap: mergedMap, - mergedDuneTokens: - allBalances.length > 0 ? { balances: allBalances } : undefined, - isLoadingBalances: anyLoading + allBalances.push(...query.data.balances) } - }, [balanceQueries]) + }) + + return { + mergedBalanceMap: mergedMap, + mergedTokens: + allBalances.length > 0 ? { balances: allBalances } : undefined, + isLoadingBalances: anyLoading + } + }, [balanceQueries]) return { balanceMap: mergedBalanceMap, - data: mergedDuneTokens, + data: mergedTokens, isLoading: isLoadingBalances } } diff --git a/packages/ui/src/hooks/useSolanaBalance.ts b/packages/ui/src/hooks/useSolanaBalance.ts new file mode 100644 index 000000000..517de3686 --- /dev/null +++ b/packages/ui/src/hooks/useSolanaBalance.ts @@ -0,0 +1,113 @@ +import { + useQuery, + type DefaultError, + type QueryKey +} from '@tanstack/react-query' + +const SOLANA_NATIVE_ADDRESS = '11111111111111111111111111111111' + +type BalanceResponse = { + balance: bigint +} + +type TokenAccount = { + account?: { + data?: { + parsed?: { + info?: { + tokenAmount?: { + amount?: string + } + } + } + } + } +} + +type QueryType = typeof useQuery< + BalanceResponse | undefined, + DefaultError, + BalanceResponse | undefined, + QueryKey +> +type QueryOptions = Parameters['0'] + +export default ( + address?: string, + currency?: string, + rpcUrl?: string, + queryOptions?: Partial +) => { + const queryKey = ['useSolanaBalance', address, currency, rpcUrl] + + const response = (useQuery as QueryType)({ + queryKey, + queryFn: async () => { + if (!rpcUrl || !address || !currency) { + throw new Error('Missing address, currency or rpcUrl') + } + + const isNative = currency === SOLANA_NATIVE_ADDRESS + const payload = isNative + ? { + jsonrpc: '2.0', + id: 1, + method: 'getBalance', + params: [address] + } + : { + jsonrpc: '2.0', + id: 1, + method: 'getTokenAccountsByOwner', + params: [address, { mint: currency }, { encoding: 'jsonParsed' }] + } + + const response = await fetch(rpcUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + }) + + const data = await response.json() + + if (data.error) { + throw new Error(data.error.message) + } + + if (isNative) { + return { + balance: BigInt(data.result?.value ?? 0) + } + } + + const accounts = (data.result?.value ?? []) as TokenAccount[] + const balance = accounts.reduce( + (total, account) => + total + + BigInt( + account?.account?.data?.parsed?.info?.tokenAmount?.amount ?? 0 + ), + BigInt(0) + ) + + return { balance } + }, + ...queryOptions, + enabled: + address !== undefined && + currency !== undefined && + rpcUrl !== undefined && + (queryOptions?.enabled ?? true) + }) + + return { + ...response, + balance: response.data?.balance, + queryKey + } as ReturnType & { + balance: bigint | undefined + queryKey: (string | undefined)[] + } +} diff --git a/packages/ui/src/providers/RelayKitProvider.tsx b/packages/ui/src/providers/RelayKitProvider.tsx index 3dc1b4ed8..b9c16b502 100644 --- a/packages/ui/src/providers/RelayKitProvider.tsx +++ b/packages/ui/src/providers/RelayKitProvider.tsx @@ -37,14 +37,14 @@ type RelayKitProviderOptions = { * An array of fee objects composing of a recipient address and the fee in BPS */ appFees?: AppFees - duneConfig?: { + codexConfig?: { /** - * The base url for the dune api, if omitted the default will be used. Override this config to protect your api key via a proxy. + * The base url for the codex graphql api, if omitted the default (https://graph.codex.io) will be used. Override this config to protect your api key via a proxy. */ apiBaseUrl?: string /** * This key is used to fetch token balances, to improve the general UX and suggest relevant tokens - * Can be omitted and the ui will continue to function. Refer to the dune docs on how to get an api key + * Can be omitted and the ui will continue to function. Refer to the codex docs (https://docs.codex.io) on how to get an api key */ apiKey?: string } @@ -226,7 +226,7 @@ export const RelayKitProvider: FC = function ({ () => ({ appName: options.appName, appFees: options.appFees, - duneConfig: options.duneConfig, + codexConfig: options.codexConfig, vmConnectorKeyOverrides: options.vmConnectorKeyOverrides, privateChainIds: options.privateChainIds, themeScheme: options.themeScheme,