diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken.tsx index 890f4225..03d40677 100644 --- a/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken.tsx +++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/NativeToken.tsx @@ -1,6 +1,7 @@ import { Dialog } from '@chakra-ui/react' import { type ReactElement, useState } from 'react' import { type Hash, parseEther, type TransactionReceipt } from 'viem' +import { sepolia } from 'viem/chains' import { useSendTransaction } from 'wagmi' import Wrapper from '@/src/components/pageComponents/home/Examples/demos/TransactionButton/Wrapper' import TransactionButton from '@/src/components/sharedComponents/TransactionButton' @@ -46,7 +47,10 @@ const NativeToken = () => { text="Demo transaction that sends 0.1 Sepolia ETH from / to your wallet." title="Native token demo" > + {/* chainId must be explicit: the parent WalletStatusVerifier already verified Sepolia, + but TransactionButton checks against appChainId without it. */} ({ const { useWalletStatus } = await import('@/src/hooks/useWalletStatus') const mockedUseWalletStatus = vi.mocked(useWalletStatus) +const mockWeb3Status = { + readOnlyClient: undefined, + appChainId: 1, + address: '0xdeadbeef', + balance: undefined, + connectingWallet: false, + switchingChain: false, + isWalletConnected: true, + walletClient: undefined, + isWalletSynced: true, + walletChainId: 1, + switchChain: vi.fn(), + disconnect: vi.fn(), +} as unknown as Web3Status + const system = createSystem(defaultConfig) const renderWithChakra = (ui: ReactNode) => @@ -63,6 +79,7 @@ describe('SignButton', () => { targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], targetChainId: 1, switchChain: mockSwitchChain, + web3Status: mockWeb3Status, }) renderWithChakra() @@ -79,6 +96,7 @@ describe('SignButton', () => { targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], targetChainId: 1, switchChain: mockSwitchChain, + web3Status: mockWeb3Status, }) renderWithChakra( @@ -102,6 +120,7 @@ describe('SignButton', () => { >['targetChain'], targetChainId: 10, switchChain: mockSwitchChain, + web3Status: mockWeb3Status, }) renderWithChakra() @@ -119,6 +138,7 @@ describe('SignButton', () => { targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], targetChainId: 1, switchChain: mockSwitchChain, + web3Status: mockWeb3Status, }) renderWithChakra() diff --git a/src/components/sharedComponents/TokenDropdown.tsx b/src/components/sharedComponents/TokenDropdown.tsx index 5e68f989..ba34631f 100644 --- a/src/components/sharedComponents/TokenDropdown.tsx +++ b/src/components/sharedComponents/TokenDropdown.tsx @@ -28,8 +28,9 @@ type Props = ComponentPropsWithoutRef<'span'> & TokenDropdownProps * @param {string} [props.placeholder] - Placeholder text for the search input. * @param {number} [props.containerHeight] - Height of the virtualized tokens list. * @param {number} [props.itemHeight] - Height of each item in the tokens list. - * @param {boolean} [props.showBalance] - Whether to show the token balance in the list. + * @param {boolean} [props.showBalance] - Whether to show the token balance column in each row. * @param {boolean} [props.showTopTokens] - Whether to show the top tokens section in the list. + * @param {boolean} [props.sortByBalance] - Sort tokens with a positive balance to the top, ordered by USD value descending. Defaults to true when a wallet is connected. * @param {ComponentPropsWithoutRef<'span'>} props.restProps - Additional props for the span element. * * @example diff --git a/src/components/sharedComponents/TokenInput/index.tsx b/src/components/sharedComponents/TokenInput/index.tsx index 7497c428..8fb86b61 100644 --- a/src/components/sharedComponents/TokenInput/index.tsx +++ b/src/components/sharedComponents/TokenInput/index.tsx @@ -58,8 +58,9 @@ type Props = FlexProps & TokenInputProps * @param {number} [props.iconSize=32] - Optional size of the token icon in the list. Default is 32. * @param {number} [props.itemHeight=64] - Optional height of each item in the list. Default is 64. * @param {boolean} [props.showAddTokenButton=false] - Optional flag to allow adding a token. Default is false. - * @param {boolean} [props.showBalance=false] - Optional flag to show the token balance in the list. Default is false. + * @param {boolean} [props.showBalance=false] - Optional flag to show the token balance column in each row. Default is false. * @param {boolean} [props.showTopTokens=false] - Optional flag to show the top tokens in the list. Default is false. + * @param {boolean} [props.sortByBalance] - Sort tokens with a positive balance to the top, ordered by USD value descending. Defaults to true when a wallet is connected. */ const TokenInput: FC = ({ containerHeight, @@ -73,6 +74,7 @@ const TokenInput: FC = ({ showBalance, showTopTokens, singleToken, + sortByBalance, thousandSeparator = true, title, tokenInput, @@ -230,6 +232,7 @@ const TokenInput: FC = ({ showAddTokenButton={showAddTokenButton} showBalance={showBalance} showTopTokens={showTopTokens} + sortByBalance={sortByBalance} > (({ isLoading, token if (hasExtensions) { const balance = formatUnits((token.extensions?.balance ?? 0n) as bigint, token.decimals) - const value = ( - Number.parseFloat((token.extensions?.priceUSD ?? '0') as string) * Number.parseFloat(balance) - ).toFixed(2) + const priceUSD = token.extensions?.priceUSD as string | undefined + const usdLabel = + priceUSD !== undefined + ? `$ ${(Number.parseFloat(priceUSD) * Number.parseFloat(balance)).toFixed(2)}` + : NO_PRICE_DATA_LABEL return ( {balance} - $ {value} + {usdLabel} ) } diff --git a/src/components/sharedComponents/TokenSelect/index.tsx b/src/components/sharedComponents/TokenSelect/index.tsx index b2baead7..28d0f51e 100644 --- a/src/components/sharedComponents/TokenSelect/index.tsx +++ b/src/components/sharedComponents/TokenSelect/index.tsx @@ -25,6 +25,7 @@ export interface TokenSelectProps { showAddTokenButton?: boolean showTopTokens?: boolean showBalance?: boolean + sortByBalance?: boolean } /** @ignore */ @@ -42,8 +43,9 @@ type Props = FlexProps & TokenSelectProps * @param {number} [props.iconSize=32] - Optional size of the token icon in the list. Default is 32. * @param {number} [props.itemHeight=64] - Optional height of each item in the list. Default is 64. * @param {boolean} [props.showAddTokenButton=false] - Optional flag to allow adding a token. Default is false. - * @param {boolean} [props.showBalance=false] - Optional flag to show the token balance in the list. Default is false. + * @param {boolean} [props.showBalance=false] - Optional flag to show the token balance column in each row. Default is false. * @param {boolean} [props.showTopTokens=false] - Optional flag to show the top tokens in the list. Default is false. + * @param {boolean} [props.sortByBalance] - Sort tokens with a positive balance to the top, ordered by USD value descending. Defaults to true when a wallet is connected. */ const TokenSelect = withSuspenseAndRetry( ({ @@ -59,9 +61,10 @@ const TokenSelect = withSuspenseAndRetry( showAddTokenButton = false, showBalance = false, showTopTokens = false, + sortByBalance, ...restProps }) => { - const { appChainId, walletChainId } = useWeb3Status() + const { appChainId, isWalletConnected, walletChainId } = useWeb3Status() const [chainId, setChainId] = useState(() => getValidChainId({ @@ -122,9 +125,12 @@ const TokenSelect = withSuspenseAndRetry( previousDepsRef.current = [appChainId, currentNetworkId, walletChainId] }, [appChainId, currentNetworkId, networks, walletChainId]) + const resolvedSortByBalance = sortByBalance ?? isWalletConnected + const { isLoadingBalances, tokensByChainId } = useTokens({ chainId, - withBalance: showBalance, + withBalance: showBalance || resolvedSortByBalance, + sortByBalance: resolvedSortByBalance, }) const { searchResult, searchTerm, setSearchTerm } = useTokenSearch( diff --git a/src/components/sharedComponents/TransactionButton.test.tsx b/src/components/sharedComponents/TransactionButton.test.tsx index 032dc575..c733d990 100644 --- a/src/components/sharedComponents/TransactionButton.test.tsx +++ b/src/components/sharedComponents/TransactionButton.test.tsx @@ -2,6 +2,7 @@ import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' import { render, screen } from '@testing-library/react' import { createElement, type ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Web3Status } from '@/src/hooks/useWeb3Status' import TransactionButton from './TransactionButton' const mockSwitchChain = vi.fn() @@ -43,6 +44,21 @@ vi.mock('wagmi', () => ({ const { useWalletStatus } = await import('@/src/hooks/useWalletStatus') const mockedUseWalletStatus = vi.mocked(useWalletStatus) +const mockWeb3Status = { + readOnlyClient: undefined, + appChainId: 1, + address: '0xdeadbeef', + balance: undefined, + connectingWallet: false, + switchingChain: false, + isWalletConnected: true, + walletClient: undefined, + isWalletSynced: true, + walletChainId: 1, + switchChain: vi.fn(), + disconnect: vi.fn(), +} as unknown as Web3Status + const system = createSystem(defaultConfig) const renderWithChakra = (ui: ReactNode) => @@ -61,6 +77,7 @@ describe('TransactionButton', () => { targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], targetChainId: 1, switchChain: mockSwitchChain, + web3Status: mockWeb3Status, }) renderWithChakra(Send) @@ -77,6 +94,7 @@ describe('TransactionButton', () => { targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], targetChainId: 1, switchChain: mockSwitchChain, + web3Status: mockWeb3Status, }) renderWithChakra( @@ -102,6 +120,7 @@ describe('TransactionButton', () => { >['targetChain'], targetChainId: 10, switchChain: mockSwitchChain, + web3Status: mockWeb3Status, }) renderWithChakra(Send) @@ -121,6 +140,7 @@ describe('TransactionButton', () => { >['targetChain'], targetChainId: 10, switchChain: mockSwitchChain, + web3Status: mockWeb3Status, }) renderWithChakra( @@ -144,6 +164,7 @@ describe('TransactionButton', () => { targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], targetChainId: 1, switchChain: mockSwitchChain, + web3Status: mockWeb3Status, }) renderWithChakra(Send ETH) diff --git a/src/components/sharedComponents/WalletStatusVerifier.test.tsx b/src/components/sharedComponents/WalletStatusVerifier.test.tsx index 8487e9b3..7f9fc23e 100644 --- a/src/components/sharedComponents/WalletStatusVerifier.test.tsx +++ b/src/components/sharedComponents/WalletStatusVerifier.test.tsx @@ -3,10 +3,26 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { createElement, type ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Web3Status } from '@/src/hooks/useWeb3Status' import { useWeb3StatusConnected, WalletStatusVerifier } from './WalletStatusVerifier' const mockSwitchChain = vi.fn() +const mockWeb3Status = { + readOnlyClient: undefined, + appChainId: 1, + address: '0xdeadbeef', + balance: undefined, + connectingWallet: false, + switchingChain: false, + isWalletConnected: true, + walletClient: undefined, + isWalletSynced: true, + walletChainId: 1, + switchChain: vi.fn(), + disconnect: vi.fn(), +} as unknown as Web3Status + vi.mock('@/src/hooks/useWalletStatus', () => ({ useWalletStatus: vi.fn(() => ({ isReady: false, @@ -15,23 +31,7 @@ vi.mock('@/src/hooks/useWalletStatus', () => ({ targetChain: { id: 1, name: 'Ethereum' }, targetChainId: 1, switchChain: mockSwitchChain, - })), -})) - -vi.mock('@/src/hooks/useWeb3Status', () => ({ - useWeb3Status: vi.fn(() => ({ - readOnlyClient: {}, - appChainId: 1, - address: '0xdeadbeef', - balance: undefined, - connectingWallet: false, - switchingChain: false, - isWalletConnected: true, - walletClient: undefined, - isWalletSynced: true, - walletChainId: 1, - switchChain: vi.fn(), - disconnect: vi.fn(), + web3Status: mockWeb3Status, })), })) @@ -65,6 +65,7 @@ describe('WalletStatusVerifier', () => { targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], targetChainId: 1, switchChain: mockSwitchChain, + web3Status: mockWeb3Status, }) renderWithChakra( @@ -87,6 +88,7 @@ describe('WalletStatusVerifier', () => { targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], targetChainId: 1, switchChain: mockSwitchChain, + web3Status: mockWeb3Status, }) renderWithChakra( @@ -111,6 +113,7 @@ describe('WalletStatusVerifier', () => { >['targetChain'], targetChainId: 10, switchChain: mockSwitchChain, + web3Status: mockWeb3Status, }) renderWithChakra( @@ -134,6 +137,7 @@ describe('WalletStatusVerifier', () => { targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], targetChainId: 1, switchChain: mockSwitchChain, + web3Status: mockWeb3Status, }) renderWithChakra( @@ -159,6 +163,7 @@ describe('WalletStatusVerifier', () => { >['targetChain'], targetChainId: 10, switchChain: mockSwitchChain, + web3Status: mockWeb3Status, }) renderWithChakra( @@ -179,6 +184,7 @@ describe('WalletStatusVerifier', () => { targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], targetChainId: 1, switchChain: mockSwitchChain, + web3Status: mockWeb3Status, }) const ChildComponent = () => { diff --git a/src/components/sharedComponents/WalletStatusVerifier.tsx b/src/components/sharedComponents/WalletStatusVerifier.tsx index 4c883563..5d30c336 100644 --- a/src/components/sharedComponents/WalletStatusVerifier.tsx +++ b/src/components/sharedComponents/WalletStatusVerifier.tsx @@ -1,7 +1,7 @@ import { createContext, type FC, type ReactElement, type ReactNode, useContext } from 'react' import SwitchChainButton from '@/src/components/sharedComponents/ui/SwitchChainButton' import { useWalletStatus } from '@/src/hooks/useWalletStatus' -import { useWeb3Status, type Web3Status } from '@/src/hooks/useWeb3Status' +import type { Web3Status } from '@/src/hooks/useWeb3Status' import type { ChainsIds } from '@/src/lib/networks.config' import { ConnectWalletButton } from '@/src/providers/Web3Provider' import type { RequiredNonNull } from '@/src/types/utils' @@ -51,9 +51,8 @@ const WalletStatusVerifier: FC = ({ fallback = , switchChainLabel = 'Switch to', }: WalletStatusVerifierProps) => { - const { needsConnect, needsChainSwitch, targetChain, targetChainId, switchChain } = + const { needsConnect, needsChainSwitch, targetChain, targetChainId, switchChain, web3Status } = useWalletStatus({ chainId }) - const web3Status = useWeb3Status() if (needsConnect) { return fallback diff --git a/src/hooks/useTokens.test.ts b/src/hooks/useTokens.test.ts index f3d3b62c..b2b17906 100644 --- a/src/hooks/useTokens.test.ts +++ b/src/hooks/useTokens.test.ts @@ -2,7 +2,7 @@ import type { TokenAmount, TokensResponse } from '@lifi/sdk' import { zeroAddress } from 'viem' import { describe, expect, it, vi } from 'vitest' import type { Token, Tokens } from '@/src/types/token' -import { updateTokensBalances } from './useTokens' +import { updateTokensBalances, updateTokensWithRawBalances } from './useTokens' // Mimic a setup that overrides PUBLIC_NATIVE_TOKEN_ADDRESS to the Aave-style sentinel // (0xEeee...), which env.ts lowercases. The merge must bridge this back to LI.FI's @@ -31,7 +31,14 @@ const makeLifiToken = (address: string, symbol: string, decimals: number, priceU priceUSD, }) +const daiAddress = '0x6B175474E89094C44Da98b954EedeAC495271d0F' + describe('updateTokensBalances', () => { + const threeTokens: Tokens = [ + { chainId: 1, address: LOCAL_NATIVE, name: 'Ether', symbol: 'ETH', decimals: 18 }, + { chainId: 1, address: usdcAddress, name: 'USD Coin', symbol: 'USDC', decimals: 6 }, + { chainId: 1, address: daiAddress, name: 'Dai', symbol: 'DAI', decimals: 18 }, + ] it('merges LI.FI native balance onto a local native token that uses a non-zero sentinel', () => { const prices: TokensResponse = { tokens: { @@ -68,4 +75,146 @@ describe('updateTokensBalances', () => { expect(token.extensions?.priceUSD).toBe('0') } }) + + it('places tokens with positive balance before zero-balance tokens', () => { + const prices: TokensResponse = { + tokens: { + 1: [ + makeLifiToken(LOCAL_NATIVE, 'ETH', 18, '2300'), + makeLifiToken(usdcAddress, 'USDC', 6, '1'), + makeLifiToken(daiAddress, 'DAI', 18, '1'), + ], + }, + } + const balances: TokenAmount[] = [ + { ...makeLifiToken(LOCAL_NATIVE, 'ETH', 18, '2300'), amount: 0n }, + { ...makeLifiToken(usdcAddress, 'USDC', 6, '1'), amount: 5_000_000n }, + { ...makeLifiToken(daiAddress, 'DAI', 18, '1'), amount: 0n }, + ] + + const { tokens } = updateTokensBalances(threeTokens, [balances, prices]) + + expect(tokens[0].symbol).toBe('USDC') + expect(tokens[1].symbol).toBe('ETH') + expect(tokens[2].symbol).toBe('DAI') + }) + + it('zero-balance tokens retain source order among themselves', () => { + const prices: TokensResponse = { + tokens: { + 1: [ + makeLifiToken(LOCAL_NATIVE, 'ETH', 18, '2300'), + makeLifiToken(usdcAddress, 'USDC', 6, '1'), + makeLifiToken(daiAddress, 'DAI', 18, '1'), + ], + }, + } + const balances: TokenAmount[] = [] + + const { tokens } = updateTokensBalances(threeTokens, [balances, prices]) + + expect(tokens[0].symbol).toBe('ETH') + expect(tokens[1].symbol).toBe('USDC') + expect(tokens[2].symbol).toBe('DAI') + }) + + it('preserves source order when sortByBalance is false', () => { + const prices: TokensResponse = { + tokens: { + 1: [ + makeLifiToken(LOCAL_NATIVE, 'ETH', 18, '2300'), + makeLifiToken(usdcAddress, 'USDC', 6, '1'), + makeLifiToken(daiAddress, 'DAI', 18, '1'), + ], + }, + } + const balances: TokenAmount[] = [ + { ...makeLifiToken(LOCAL_NATIVE, 'ETH', 18, '2300'), amount: 0n }, + { ...makeLifiToken(usdcAddress, 'USDC', 6, '1'), amount: 5_000_000n }, + { ...makeLifiToken(daiAddress, 'DAI', 18, '1'), amount: 0n }, + ] + + const { tokens } = updateTokensBalances(threeTokens, [balances, prices], { + sortByBalance: false, + }) + + expect(tokens[0].symbol).toBe('ETH') + expect(tokens[1].symbol).toBe('USDC') + expect(tokens[2].symbol).toBe('DAI') + }) +}) + +describe('updateTokensWithRawBalances', () => { + it('places tokens with positive balance before zero-balance tokens', () => { + const rawBalances: Record> = { + 11155111: { + [LOCAL_NATIVE]: 2_000_000_000_000_000_000n, + [usdcAddress]: 0n, + }, + } + const sepoliaTokens: Tokens = [ + { chainId: 11155111, address: LOCAL_NATIVE, name: 'Ether', symbol: 'ETH', decimals: 18 }, + { chainId: 11155111, address: usdcAddress, name: 'USD Coin', symbol: 'USDC', decimals: 6 }, + ] + + const { tokens } = updateTokensWithRawBalances(sepoliaTokens, rawBalances) + + expect(tokens[0].symbol).toBe('ETH') + expect(tokens[1].symbol).toBe('USDC') + }) + + it('zero-balance tokens retain source order', () => { + const rawBalances: Record> = { + 11155111: { + [LOCAL_NATIVE]: 0n, + [usdcAddress]: 0n, + [daiAddress]: 0n, + }, + } + const sepoliaTokens: Tokens = [ + { chainId: 11155111, address: LOCAL_NATIVE, name: 'Ether', symbol: 'ETH', decimals: 18 }, + { chainId: 11155111, address: usdcAddress, name: 'USD Coin', symbol: 'USDC', decimals: 6 }, + { chainId: 11155111, address: daiAddress, name: 'Dai', symbol: 'DAI', decimals: 18 }, + ] + + const { tokens } = updateTokensWithRawBalances(sepoliaTokens, rawBalances) + + expect(tokens[0].symbol).toBe('ETH') + expect(tokens[1].symbol).toBe('USDC') + expect(tokens[2].symbol).toBe('DAI') + }) + + it('sets balance extension but omits priceUSD so the balance column shows N/A', () => { + const rawBalances: Record> = { + 11155111: { [LOCAL_NATIVE]: 500n }, + } + const sepoliaTokens: Tokens = [ + { chainId: 11155111, address: LOCAL_NATIVE, name: 'Ether', symbol: 'ETH', decimals: 18 }, + ] + + const { tokens } = updateTokensWithRawBalances(sepoliaTokens, rawBalances) + + expect(tokens[0].extensions?.balance).toBe(500n) + expect(tokens[0].extensions?.priceUSD).toBeUndefined() + }) + + it('preserves source order when sortByBalance is false', () => { + const rawBalances: Record> = { + 11155111: { + [LOCAL_NATIVE]: 0n, + [usdcAddress]: 1_000_000n, + }, + } + const sepoliaTokens: Tokens = [ + { chainId: 11155111, address: LOCAL_NATIVE, name: 'Ether', symbol: 'ETH', decimals: 18 }, + { chainId: 11155111, address: usdcAddress, name: 'USD Coin', symbol: 'USDC', decimals: 6 }, + ] + + const { tokens } = updateTokensWithRawBalances(sepoliaTokens, rawBalances, { + sortByBalance: false, + }) + + expect(tokens[0].symbol).toBe('ETH') + expect(tokens[1].symbol).toBe('USDC') + }) }) diff --git a/src/hooks/useTokens.ts b/src/hooks/useTokens.ts index 10c9e87d..95338b9b 100644 --- a/src/hooks/useTokens.ts +++ b/src/hooks/useTokens.ts @@ -9,14 +9,15 @@ import { } from '@lifi/sdk' import { useQuery } from '@tanstack/react-query' import { useMemo } from 'react' -import { type Address, type Chain, formatUnits } from 'viem' +import { type Address, type Chain, erc20Abi, formatUnits, getAddress } from 'viem' +import { usePublicClient } from 'wagmi' import { env } from '@/src/env' import { useTokenLists } from '@/src/hooks/useTokenLists' import { useWeb3Status } from '@/src/hooks/useWeb3Status' import { lifiRpcUrls } from '@/src/lib/networks.config' import type { Token, Tokens } from '@/src/types/token' -import { toLocalNativeAddress } from '@/src/utils/address' +import { isNativeToken, toLocalNativeAddress } from '@/src/utils/address' import { logger } from '@/src/utils/logger' import type { TokensMap } from '@/src/utils/tokenListsCache' @@ -40,10 +41,15 @@ export const lifiConfig = createConfig({ * - Automatic sorting by token value (balance × price) * - Periodic refetching for up-to-date balances and prices * + * On chains not covered by LI.FI (e.g. Sepolia), balance fetching falls back to a + * direct on-chain multicall. In this mode `priceUSD` is absent from token extensions, + * so any UI that reads `extensions.priceUSD` should treat `undefined` as "N/A". + * * @param {Object} params - Parameters for tokens fetching * @param {Address} [params.account] - Account address for balance fetching (defaults to connected wallet) * @param {Chain['id']} [params.chainId] - Specific chain ID to filter tokens (defaults to all supported chains) * @param {boolean} [params.withBalance=true] - Whether to fetch token balances + * @param {boolean} [params.sortByBalance=true] - Whether to sort tokens by balance. When false, source order is preserved even if balances are fetched. * * @returns {Object} Token data and loading state * @returns {Token[]} returns.tokens - Array of tokens with price and balance information @@ -73,12 +79,15 @@ export const useTokens = ( account, chainId, withBalance, + sortByBalance = true, }: { account?: Address chainId?: Chain['id'] withBalance?: boolean + sortByBalance?: boolean } = { withBalance: true, + sortByBalance: true, }, ) => { const { address } = useWeb3Status() @@ -128,31 +137,94 @@ export const useTokens = ( enabled: canFetchBalance && !!tokensPricesByChain && chainsToFetch.length > 0, }) + // Multicall fallback: used when a specific chain is provided and LI.FI does not + // cover it (e.g. Sepolia). We wait for the LI.FI chains list to load so we can + // confirm the chain is absent before triggering on-chain calls. + const publicClient = usePublicClient({ chainId }) + const useOnchainFallback = + canFetchBalance && !!chainId && !isLoadingChains && !!chains && !lifiChainsId.includes(chainId) + + const { data: onchainBalances, isLoading: isLoadingOnchainBalances } = useQuery({ + queryKey: ['onchain', 'balances', account, chainId], + queryFn: async () => { + // biome-ignore lint/style/noNonNullAssertion: chainId guarded by enabled: useOnchainFallback + const tokensForChain = tokensData.tokensByChainId[chainId!] ?? [] + const nativeTokens = tokensForChain.filter((t) => isNativeToken(t.address)) + const erc20Tokens = tokensForChain.filter((t) => !isNativeToken(t.address)) + + const balances: Record = {} + + const nativeResults = await Promise.all( + nativeTokens.map((t) => + // biome-ignore lint/style/noNonNullAssertion: publicClient and account guarded by enabled: useOnchainFallback && !!publicClient + publicClient!.getBalance({ address: account! as Address }).then((b) => ({ t, b })), + ), + ) + for (const { t, b } of nativeResults) { + balances[t.address.toLowerCase()] = b + } + + if (erc20Tokens.length > 0) { + // biome-ignore lint/style/noNonNullAssertion: publicClient and account guarded by enabled: useOnchainFallback && !!publicClient + const results = await publicClient!.multicall({ + contracts: erc20Tokens.map((token) => ({ + address: getAddress(token.address), + abi: erc20Abi, + functionName: 'balanceOf' as const, + // biome-ignore lint/style/noNonNullAssertion: account guarded by enabled: useOnchainFallback + args: [account! as Address], + })), + }) + results.forEach((result, i) => { + balances[erc20Tokens[i].address.toLowerCase()] = + result.status === 'success' ? (result.result as bigint) : 0n + }) + } + + return balances + }, + staleTime: BALANCE_EXPIRATION_TIME, + refetchInterval: BALANCE_EXPIRATION_TIME, + gcTime: Number.POSITIVE_INFINITY, + enabled: useOnchainFallback && !!publicClient, + }) + const cache = useMemo(() => { - if ( - withBalance && - account && - !isLoadingPrices && - !isLoadingBalances && - tokensBalances && - tokensPricesByChain - ) { - return updateTokensBalances(tokensData.tokens, [tokensBalances, tokensPricesByChain]) + if (withBalance && account) { + if (!isLoadingPrices && !isLoadingBalances && tokensBalances && tokensPricesByChain) { + return updateTokensBalances(tokensData.tokens, [tokensBalances, tokensPricesByChain], { + sortByBalance, + }) + } + if (useOnchainFallback && !isLoadingOnchainBalances && onchainBalances && chainId) { + return updateTokensWithRawBalances( + tokensData.tokens, + { [chainId]: onchainBalances }, + { sortByBalance }, + ) + } } return tokensData }, [ account, + chainId, isLoadingBalances, + isLoadingOnchainBalances, isLoadingPrices, + onchainBalances, + sortByBalance, tokensBalances, tokensData, tokensPricesByChain, + useOnchainFallback, withBalance, ]) return { ...cache, - isLoadingBalances: Boolean(isLoadingChains || isLoadingBalances || isLoadingPrices), + isLoadingBalances: Boolean( + isLoadingChains || isLoadingBalances || isLoadingPrices || isLoadingOnchainBalances, + ), isLoadingPrices: Boolean(isLoadingChains || isLoadingPrices), } } @@ -167,6 +239,7 @@ export const useTokens = ( export function updateTokensBalances( tokens: Tokens, results: [Array, TokensResponse], + { sortByBalance = true }: { sortByBalance?: boolean } = {}, ) { const [balanceTokens, prices] = results @@ -212,9 +285,11 @@ export function updateTokensBalances( }) logger.timeEnd('extending tokens with balance info') - logger.time('sorting tokens by balance') - tokensWithBalances.sort(sortFn) - logger.timeEnd('sorting tokens by balance') + if (sortByBalance) { + logger.time('sorting tokens by balance') + tokensWithBalances.sort(sortFn) + logger.timeEnd('sorting tokens by balance') + } logger.time('updating tokens cache') const tokensByChain = tokensWithBalances.reduce( @@ -233,6 +308,48 @@ export function updateTokensBalances( return { tokens: tokensWithBalances, tokensByChainId: tokensByChain } } +/** + * Updates tokens with raw on-chain balances (no price data), sorting tokens with + * a positive balance before zero-balance tokens. Within each group, source order + * is preserved. Used as a fallback for chains not covered by LI.FI. + * + * @param tokens - The array of tokens to enrich. + * @param rawBalances - Map of `chainId → address → bigint balance`. + * @returns Updated tokens and tokens grouped by chain ID. + */ +export function updateTokensWithRawBalances( + tokens: Tokens, + rawBalances: Record>, + { sortByBalance = true }: { sortByBalance?: boolean } = {}, +) { + const tokensWithBalances = tokens.map( + (token): Token => ({ + ...token, + extensions: { + balance: rawBalances[token.chainId]?.[token.address.toLowerCase()] ?? 0n, + }, + }), + ) + + if (sortByBalance) { + tokensWithBalances.sort(sortByBalancePresenceFn) + } + + const tokensByChainId = tokensWithBalances.reduce( + (acc, token) => { + if (!acc[token.chainId]) { + acc[token.chainId] = [token] + } else { + acc[token.chainId].push(token) + } + return acc + }, + {} as Record, + ) + + return { tokens: tokensWithBalances, tokensByChainId } +} + /** * A sorting function used to sort tokens by balance. * @param a The first token. @@ -248,3 +365,13 @@ function sortFn(a: Token, b: Token) { Number.parseFloat((a.extensions?.priceUSD as string) ?? '0') ) } + +/** + * Sorts tokens so those with a positive balance come first, preserving source + * order within each group. Used when USD price data is unavailable. + */ +function sortByBalancePresenceFn(a: Token, b: Token) { + const aHas = ((a.extensions?.balance as bigint) ?? 0n) > 0n ? 1 : 0 + const bHas = ((b.extensions?.balance as bigint) ?? 0n) > 0n ? 1 : 0 + return bHas - aHas +} diff --git a/src/hooks/useWalletStatus.test.ts b/src/hooks/useWalletStatus.test.ts index ce5bd0fd..63ba8c2c 100644 --- a/src/hooks/useWalletStatus.test.ts +++ b/src/hooks/useWalletStatus.test.ts @@ -110,6 +110,9 @@ describe('useWalletStatus', () => { expect(result.current.needsConnect).toBe(false) expect(result.current.needsChainSwitch).toBe(false) expect(result.current.isReady).toBe(true) + expect(result.current.web3Status).toBeDefined() + expect(result.current.web3Status.appChainId).toBe(1) + expect(result.current.web3Status.isWalletConnected).toBe(true) }) it('uses provided chainId over appChainId', () => { diff --git a/src/hooks/useWalletStatus.ts b/src/hooks/useWalletStatus.ts index 845a3713..0a769bab 100644 --- a/src/hooks/useWalletStatus.ts +++ b/src/hooks/useWalletStatus.ts @@ -1,7 +1,7 @@ import type { Chain } from 'viem' import { extractChain } from 'viem' -import { useWeb3Status } from '@/src/hooks/useWeb3Status' +import { useWeb3Status, type Web3Status } from '@/src/hooks/useWeb3Status' import { type ChainsIds, chains } from '@/src/lib/networks.config' interface UseWalletStatusOptions { @@ -15,13 +15,14 @@ interface WalletStatus { targetChain: Chain targetChainId: ChainsIds switchChain: (chainId: ChainsIds) => void + web3Status: Web3Status } export const useWalletStatus = (options?: UseWalletStatusOptions): WalletStatus => { - const { appChainId, isWalletConnected, isWalletSynced, switchChain, walletChainId } = - useWeb3Status() + const web3Status = useWeb3Status() + const { appChainId, isWalletConnected, isWalletSynced, switchChain, walletChainId } = web3Status - const targetChainId = options?.chainId || appChainId || chains[0].id + const targetChainId = options?.chainId ?? appChainId ?? chains[0].id const targetChain = extractChain({ chains, id: targetChainId }) const needsConnect = !isWalletConnected @@ -35,5 +36,6 @@ export const useWalletStatus = (options?: UseWalletStatusOptions): WalletStatus targetChain, targetChainId, switchChain, + web3Status, } } diff --git a/src/hooks/useWeb3Status.test.ts b/src/hooks/useWeb3Status.test.ts index 3fe34ca3..02e71aec 100644 --- a/src/hooks/useWeb3Status.test.ts +++ b/src/hooks/useWeb3Status.test.ts @@ -141,15 +141,22 @@ describe('useWeb3StatusConnected', () => { targetChain: { id: 1, name: 'Ethereum' } as ReturnType['targetChain'], targetChainId: 1, switchChain: vi.fn(), + web3Status: { + address: '0xdeadbeef' as Address, + appChainId: 1, + balance: undefined, + connectingWallet: false, + disconnect: vi.fn(), + isWalletConnected: true, + isWalletSynced: true, + readOnlyClient: undefined, + switchChain: vi.fn(), + switchingChain: false, + walletChainId: 1, + walletClient: undefined, + } as unknown as ReturnType['web3Status'], }) - vi.mocked(wagmi.useAccount).mockReturnValueOnce({ - address: '0xdeadbeef' as Address, - chainId: 1, - isConnected: true, - isConnecting: false, - } as unknown as ReturnType) - const wrapper = ({ children }: { children: React.ReactNode }) => createElement(WalletStatusVerifier, null, children)