diff --git a/src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx b/src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx
index eb6848cf..e7952897 100644
--- a/src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx
+++ b/src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx
@@ -1,5 +1,6 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { screen } from '@testing-library/react'
+import { screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createMockWeb3Status, renderWithProviders } from '@/src/test-utils'
import tokenInput from './index'
@@ -20,6 +21,8 @@ vi.mock('@/src/hooks/useTokenLists', () => ({
vi.mock('@/src/hooks/useTokenSearch', () => ({
useTokenSearch: vi.fn(() => ({
searchResult: [],
+ searchTerm: '',
+ setSearchTerm: vi.fn(),
})),
}))
@@ -37,13 +40,51 @@ vi.mock('@/src/components/sharedComponents/TokenInput/useTokenInput', () => ({
})),
}))
+// TokenSelect calls useTokens internally; return empty arrays per chain to avoid
+// TopTokens receiving undefined and crashing on .find()
+vi.mock('@/src/hooks/useTokens', () => ({
+ useTokens: vi.fn(() => ({
+ tokens: [],
+ tokensByChainId: { 1: [], 10: [], 42161: [], 137: [], 11155111: [] },
+ isLoadingBalances: false,
+ })),
+}))
+
+vi.mock('@/src/constants/common', () => ({
+ includeTestnets: true,
+ isDev: false,
+ NO_PRICE_DATA_LABEL: 'N/A',
+}))
+
describe('TokenInput demo', () => {
it('renders the token input container', () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
renderWithProviders(
{tokenInput.demo},
)
- // The mode dropdown should be visible
- expect(screen.getByText('Single token')).toBeDefined()
+ expect(screen.getByText('Multi token')).toBeDefined()
+ })
+
+ it('shows Sepolia in the network selector when includeTestnets is true', async () => {
+ const user = userEvent.setup()
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+
+ renderWithProviders(
+ {tokenInput.demo},
+ )
+
+ // Open the TokenSelect dialog
+ await user.click(screen.getByText('Select'))
+
+ // There are two "Chevron down" SVGs: one in the DropdownButton trigger (pointer-events:none)
+ // and one in the NetworkButton inside the dialog. Click the last one (the network switcher).
+ const chevrons = await screen.findAllByTitle('Chevron down')
+ const networkChevron = chevrons[chevrons.length - 1]
+ const networkButton = networkChevron.closest('button')
+ expect(networkButton).not.toBeNull()
+ await user.click(networkButton as HTMLButtonElement)
+
+ // Sepolia should be listed as a network option
+ await waitFor(() => expect(screen.getByText('Sepolia')).toBeDefined())
})
})
diff --git a/src/components/pageComponents/home/Examples/demos/TokenInput/index.tsx b/src/components/pageComponents/home/Examples/demos/TokenInput/index.tsx
index 38a3eba9..99d44408 100644
--- a/src/components/pageComponents/home/Examples/demos/TokenInput/index.tsx
+++ b/src/components/pageComponents/home/Examples/demos/TokenInput/index.tsx
@@ -4,14 +4,16 @@ import {
NetworkEthereum,
NetworkOptimism,
NetworkPolygon,
+ NetworkSepolia,
} from '@web3icons/react'
import { useState } from 'react'
-import { arbitrum, mainnet, optimism, polygon } from 'viem/chains'
+import { arbitrum, mainnet, optimism, polygon, sepolia } from 'viem/chains'
import OptionsDropdown from '@/src/components/pageComponents/home/Examples/demos/OptionsDropdown'
import Icon from '@/src/components/pageComponents/home/Examples/demos/TokenInput/Icon'
import BaseTokenInput from '@/src/components/sharedComponents/TokenInput'
import { useTokenInput } from '@/src/components/sharedComponents/TokenInput/useTokenInput'
import type { Networks } from '@/src/components/sharedComponents/TokenSelect/types'
+import { includeTestnets } from '@/src/constants/common'
import { useTokenLists } from '@/src/hooks/useTokenLists'
import { useTokenSearch } from '@/src/hooks/useTokenSearch'
import { useWeb3Status } from '@/src/hooks/useWeb3Status'
@@ -105,6 +107,21 @@ const TokenInputMode = withSuspenseAndRetry(
label: polygon.name,
onClick: () => setCurrentNetworkId(polygon.id),
},
+ ...(includeTestnets
+ ? [
+ {
+ icon: (
+
+ ),
+ id: sepolia.id,
+ label: sepolia.name,
+ onClick: () => setCurrentNetworkId(sepolia.id),
+ },
+ ]
+ : []),
]
return (
@@ -127,10 +144,10 @@ const TokenInputMode = withSuspenseAndRetry(
* token or multi token mode.
*/
const TokenInput = () => {
- const [currentTokenInput, setCurrentTokenInput] = useState('single')
+ const [currentTokenInput, setCurrentTokenInput] = useState('multi')
const dropdownItems = [
- { label: 'Single token', onClick: () => setCurrentTokenInput('single') },
{ label: 'Multi token', onClick: () => setCurrentTokenInput('multi') },
+ { label: 'Single token', onClick: () => setCurrentTokenInput('single') },
]
return (
diff --git a/src/components/sharedComponents/TokenInput/Components.tsx b/src/components/sharedComponents/TokenInput/Components.tsx
index 51be2218..a7e80371 100644
--- a/src/components/sharedComponents/TokenInput/Components.tsx
+++ b/src/components/sharedComponents/TokenInput/Components.tsx
@@ -277,9 +277,10 @@ export const CloseButton: FC = ({ children, ...restProps }) => (
border="none"
color="var(--title-color-default)"
cursor="pointer"
- position="absolute"
- right={0}
- top={10}
+ marginLeft="auto"
+ marginRight={4}
+ marginBottom={4}
+ marginTop={0}
_active={{
opacity: 0.7,
}}
diff --git a/src/components/sharedComponents/TokenInput/index.tsx b/src/components/sharedComponents/TokenInput/index.tsx
index a3e9bac6..964836b1 100644
--- a/src/components/sharedComponents/TokenInput/index.tsx
+++ b/src/components/sharedComponents/TokenInput/index.tsx
@@ -27,6 +27,9 @@ import type { UseTokenInputReturnType } from '@/src/components/sharedComponents/
import TokenLogo from '@/src/components/sharedComponents/TokenLogo'
import TokenSelect, { type TokenSelectProps } from '@/src/components/sharedComponents/TokenSelect'
import Spinner from '@/src/components/sharedComponents/ui/Spinner'
+import { NO_PRICE_DATA_LABEL } from '@/src/constants/common'
+import { useWeb3Status } from '@/src/hooks/useWeb3Status'
+import { chains } from '@/src/lib/networks.config'
import type { Token } from '@/src/types/token'
import styles from './styles'
@@ -92,6 +95,23 @@ const TokenInput: FC = ({
() => (balance && selectedToken ? balance : BigInt(0)),
[balance, selectedToken],
)
+
+ const { appChainId, walletChainId } = useWeb3Status()
+ const activeChainId = selectedToken?.chainId ?? currentNetworkId ?? walletChainId ?? appChainId
+ const isTestnetChain = useMemo(
+ () => chains.find((c) => c.id === activeChainId)?.testnet === true,
+ [activeChainId],
+ )
+
+ const estimatedUSDValue = useMemo(() => {
+ if (isTestnetChain) return null
+ if (!selectedToken) return 0
+ const priceUSD = selectedToken.extensions?.priceUSD
+ if (priceUSD === undefined || priceUSD === null) return 0
+ const tokenAmount = Number.parseFloat(formatUnits(amount, selectedToken.decimals ?? 0))
+ return Number.parseFloat(priceUSD as string) * tokenAmount
+ }, [isTestnetChain, selectedToken, amount])
+
const selectIconSize = 24
const decimals = selectedToken ? selectedToken.decimals : 2
@@ -167,7 +187,9 @@ const TokenInput: FC = ({
)}
- ~$0.00
+
+ {estimatedUSDValue === null ? NO_PRICE_DATA_LABEL : `~$${estimatedUSDValue.toFixed(2)}`}
+
{balanceError && 'Error...'}
diff --git a/src/components/sharedComponents/TokenInput/useTokenInput.test.ts b/src/components/sharedComponents/TokenInput/useTokenInput.test.ts
new file mode 100644
index 00000000..930d23d6
--- /dev/null
+++ b/src/components/sharedComponents/TokenInput/useTokenInput.test.ts
@@ -0,0 +1,101 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { act, renderHook, waitFor } from '@testing-library/react'
+import { createElement, type ReactNode } from 'react'
+import { zeroAddress } from 'viem'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { Token } from '@/src/types/token'
+import { useTokenInput } from './useTokenInput'
+
+const walletAddress = '0x71C7656EC7ab88b098defB751B7401B5f6d8976F' as const
+
+const mockUseAccount = vi.fn()
+const mockUsePublicClient = vi.fn()
+const mockGetBalance = vi.fn()
+
+vi.mock('wagmi', () => ({
+ useAccount: () => mockUseAccount(),
+ usePublicClient: (args: { chainId?: number } = {}) => {
+ mockUsePublicClient(args)
+ return { getBalance: mockGetBalance }
+ },
+}))
+
+vi.mock('@/src/hooks/useErc20Balance', () => ({
+ useErc20Balance: () => ({ balance: undefined, balanceError: null, isLoadingBalance: false }),
+}))
+
+vi.mock('@/src/env', () => ({
+ env: { PUBLIC_NATIVE_TOKEN_ADDRESS: zeroAddress.toLowerCase() },
+}))
+
+const mainnetUsdc: Token = {
+ address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
+ chainId: 1,
+ decimals: 6,
+ name: 'USD Coin',
+ symbol: 'USDC',
+}
+
+const sepoliaEth: Token = {
+ address: zeroAddress,
+ chainId: 11155111,
+ decimals: 18,
+ name: 'Sepolia Ether',
+ symbol: 'ETH',
+}
+
+const wrapper = ({ children }: { children: ReactNode }) =>
+ createElement(
+ QueryClientProvider,
+ { client: new QueryClient({ defaultOptions: { queries: { retry: false } } }) },
+ children,
+ )
+
+describe('useTokenInput', () => {
+ beforeEach(() => {
+ mockUseAccount.mockReturnValue({ address: walletAddress })
+ mockUsePublicClient.mockClear()
+ mockGetBalance.mockReset()
+ })
+
+ it('rebinds the native public client to the selected token chain when the user switches chains', async () => {
+ mockGetBalance.mockResolvedValue(42n)
+
+ const { result } = renderHook(() => useTokenInput(mainnetUsdc), { wrapper })
+
+ act(() => {
+ result.current.setTokenSelected(sepoliaEth)
+ })
+
+ await waitFor(() =>
+ expect(mockUsePublicClient).toHaveBeenLastCalledWith({ chainId: sepoliaEth.chainId }),
+ )
+ await waitFor(() => expect(result.current.balance).toBe(42n))
+ })
+
+ it('binds the native public client to the selected token chain when no initial token is given', async () => {
+ mockGetBalance.mockResolvedValue(7n)
+
+ const { result } = renderHook(() => useTokenInput(), { wrapper })
+
+ act(() => {
+ result.current.setTokenSelected(sepoliaEth)
+ })
+
+ await waitFor(() =>
+ expect(mockUsePublicClient).toHaveBeenLastCalledWith({ chainId: sepoliaEth.chainId }),
+ )
+ await waitFor(() => expect(result.current.balance).toBe(7n))
+ })
+
+ it('does not fetch native balance when wallet is disconnected', () => {
+ mockUseAccount.mockReturnValue({ address: undefined })
+
+ const { result } = renderHook(() => useTokenInput(sepoliaEth), { wrapper })
+
+ expect(result.current.balance).toBeUndefined()
+ expect(result.current.balanceError).toBeNull()
+ expect(result.current.isLoadingBalance).toBe(false)
+ expect(mockGetBalance).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/components/sharedComponents/TokenInput/useTokenInput.tsx b/src/components/sharedComponents/TokenInput/useTokenInput.tsx
index 7bc881a2..954d4862 100644
--- a/src/components/sharedComponents/TokenInput/useTokenInput.tsx
+++ b/src/components/sharedComponents/TokenInput/useTokenInput.tsx
@@ -54,7 +54,7 @@ export function useTokenInput(token?: Token) {
token: selectedToken,
})
- const publicClient = usePublicClient({ chainId: token?.chainId })
+ const publicClient = usePublicClient({ chainId: selectedToken?.chainId })
const isNative = selectedToken?.address ? isNativeToken(selectedToken.address) : false
const {
@@ -64,7 +64,7 @@ export function useTokenInput(token?: Token) {
} = useQuery({
queryKey: ['nativeBalance', selectedToken?.address, selectedToken?.chainId, userWallet],
queryFn: () => publicClient?.getBalance({ address: getAddress(userWallet ?? '') }),
- enabled: isNative,
+ enabled: isNative && !!userWallet,
})
return {
diff --git a/src/components/sharedComponents/TokenSelect/List/BalanceLoading.tsx b/src/components/sharedComponents/TokenSelect/List/BalanceLoading.tsx
new file mode 100644
index 00000000..86688e92
--- /dev/null
+++ b/src/components/sharedComponents/TokenSelect/List/BalanceLoading.tsx
@@ -0,0 +1,26 @@
+import { Flex, type FlexProps, Skeleton } from '@chakra-ui/react'
+import type { FC } from 'react'
+
+/**
+ * Skeleton placeholder for token balance and USD value, shown while data is loading.
+ */
+const BalanceLoading: FC = ({ ...restProps }) => (
+
+
+
+
+)
+
+export default BalanceLoading
diff --git a/src/components/sharedComponents/TokenSelect/List/Row.tsx b/src/components/sharedComponents/TokenSelect/List/Row.tsx
index 5e981137..ba4aeef4 100644
--- a/src/components/sharedComponents/TokenSelect/List/Row.tsx
+++ b/src/components/sharedComponents/TokenSelect/List/Row.tsx
@@ -1,7 +1,8 @@
-import { Box, Flex, type FlexProps, Skeleton } from '@chakra-ui/react'
+import { Box, Flex, type FlexProps } from '@chakra-ui/react'
import type { FC } from 'react'
import TokenLogo from '@/src/components/sharedComponents/TokenLogo'
import AddERC20TokenButton from '@/src/components/sharedComponents/TokenSelect/List/AddERC20TokenButton'
+import BalanceLoading from '@/src/components/sharedComponents/TokenSelect/List/BalanceLoading'
import TokenBalance from '@/src/components/sharedComponents/TokenSelect/List/TokenBalance'
import type { Token } from '@/src/types/token'
@@ -20,25 +21,6 @@ const Icon: FC<{ size: number } & FlexProps> = ({ size, children, ...restProps }
)
-const BalanceLoading: FC = ({ ...restProps }) => (
-
-
-
-
-)
-
interface TokenSelectRowProps extends Omit {
iconSize: number
isLoadingBalances?: boolean
diff --git a/src/components/sharedComponents/TokenSelect/List/TokenBalance.test.tsx b/src/components/sharedComponents/TokenSelect/List/TokenBalance.test.tsx
new file mode 100644
index 00000000..a8376299
--- /dev/null
+++ b/src/components/sharedComponents/TokenSelect/List/TokenBalance.test.tsx
@@ -0,0 +1,167 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { useBalance } from 'wagmi'
+import { useErc20Balance } from '@/src/hooks/useErc20Balance'
+import { useWeb3Status } from '@/src/hooks/useWeb3Status'
+import { createMockWeb3Status, renderWithProviders } from '@/src/test-utils'
+import TokenBalance from './TokenBalance'
+
+const mockAddress = '0x1234567890123456789012345678901234567890' as const
+const nativeAddress = '0x0000000000000000000000000000000000000000'
+
+const erc20Token = {
+ name: 'USD Coin',
+ symbol: 'USDC',
+ address: '0x94a9D9AC8a22534E3FaCa9F4e7F2E2cf85d5E4C8' as `0x${string}`,
+ decimals: 6,
+ chainId: 11155111,
+}
+
+const nativeToken = {
+ name: 'Sepolia Ether',
+ symbol: 'ETH',
+ address: nativeAddress as `0x${string}`,
+ decimals: 18,
+ chainId: 11155111,
+}
+
+const tokenWithExtensions = {
+ ...erc20Token,
+ extensions: {
+ balance: 5_000_000n,
+ priceUSD: '1.00',
+ },
+}
+
+vi.mock('@/src/hooks/useWeb3Status', () => ({
+ useWeb3Status: vi.fn(),
+}))
+
+vi.mock('@/src/hooks/useErc20Balance', () => ({
+ useErc20Balance: vi.fn(),
+}))
+
+vi.mock('wagmi', () => ({
+ useBalance: vi.fn(),
+}))
+
+vi.mock('@/src/env', () => ({
+ env: {
+ PUBLIC_NATIVE_TOKEN_ADDRESS: '0x0000000000000000000000000000000000000000',
+ PUBLIC_APP_NAME: 'test',
+ },
+}))
+
+function renderTokenBalance(props: {
+ isLoading?: boolean
+ token: typeof erc20Token | typeof nativeToken | typeof tokenWithExtensions
+}) {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ return renderWithProviders(
+
+
+ ,
+ )
+}
+
+describe('TokenBalance', () => {
+ beforeEach(() => {
+ vi.mocked(useWeb3Status).mockReturnValue(
+ createMockWeb3Status({ address: mockAddress, isWalletConnected: true }) as ReturnType<
+ typeof useWeb3Status
+ >,
+ )
+ vi.mocked(useErc20Balance).mockReturnValue({
+ balance: 0n,
+ balanceError: null,
+ isLoadingBalance: false,
+ })
+ vi.mocked(useBalance).mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ } as ReturnType)
+ })
+
+ it('suspends the component while isLoading is true, showing no balance or price text', () => {
+ renderTokenBalance({ isLoading: true, token: erc20Token })
+ // DefaultFallback spinner renders; no component output visible.
+ expect(screen.queryByText('N/A')).toBeNull()
+ expect(screen.queryByText('$')).toBeNull()
+ })
+
+ it('renders LI.FI balance and USD value when extensions are present', () => {
+ renderTokenBalance({ isLoading: false, token: tokenWithExtensions })
+ // balance: 5_000_000 / 10^6 = 5 (viem trims trailing zeros)
+ expect(screen.getByText('5')).toBeDefined()
+ expect(screen.getByText('$ 5.00')).toBeDefined()
+ })
+
+ it('shows two loading skeletons while on-chain ERC-20 fetch is in flight', () => {
+ vi.mocked(useErc20Balance).mockReturnValue({
+ balance: undefined,
+ balanceError: null,
+ isLoadingBalance: true,
+ })
+ renderTokenBalance({ isLoading: false, token: erc20Token })
+ // BalanceLoading renders two skeletons; no balance text or N/A visible yet.
+ expect(screen.queryByText('0')).toBeNull()
+ expect(screen.queryByText('N/A')).toBeNull()
+ })
+
+ it('renders on-chain ERC-20 balance and N/A when no extensions', () => {
+ vi.mocked(useErc20Balance).mockReturnValue({
+ balance: 2_500_000n,
+ balanceError: null,
+ isLoadingBalance: false,
+ })
+ renderTokenBalance({ isLoading: false, token: erc20Token })
+ // balance: 2_500_000 / 10^6 = 2.5
+ expect(screen.getByText('2.5')).toBeDefined()
+ expect(screen.getByText('N/A')).toBeDefined()
+ })
+
+ it('renders on-chain native balance and N/A when no extensions', () => {
+ vi.mocked(useBalance).mockReturnValue({
+ data: { value: 1_000_000_000_000_000_000n, decimals: 18, formatted: '1.0', symbol: 'ETH' },
+ isLoading: false,
+ } as ReturnType)
+ renderTokenBalance({ isLoading: false, token: nativeToken })
+ // balance: 1e18 / 10^18 = 1 (viem trims trailing zeros)
+ expect(screen.getByText('1')).toBeDefined()
+ expect(screen.getByText('N/A')).toBeDefined()
+ })
+
+ it('shows zero balance and N/A when ERC-20 fetch returns an error', () => {
+ vi.mocked(useErc20Balance).mockReturnValue({
+ balance: undefined,
+ balanceError: new Error('fetch failed'),
+ isLoadingBalance: false,
+ })
+ renderTokenBalance({ isLoading: false, token: erc20Token })
+ expect(screen.getByText('0')).toBeDefined()
+ expect(screen.getByText('N/A')).toBeDefined()
+ })
+
+ it('shows zero balance and N/A when native balance fetch returns an error', () => {
+ // useBalance returns undefined data on error; fallback resolves to 0n.
+ vi.mocked(useBalance).mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ } as ReturnType)
+ renderTokenBalance({ isLoading: false, token: nativeToken })
+ expect(screen.getByText('0')).toBeDefined()
+ expect(screen.getByText('N/A')).toBeDefined()
+ })
+
+ it('shows zero balance and N/A when no wallet is connected', () => {
+ vi.mocked(useWeb3Status).mockReturnValue(
+ createMockWeb3Status({ address: undefined, isWalletConnected: false }) as ReturnType<
+ typeof useWeb3Status
+ >,
+ )
+ renderTokenBalance({ isLoading: false, token: erc20Token })
+ expect(screen.getByText('0')).toBeDefined()
+ expect(screen.getByText('N/A')).toBeDefined()
+ })
+})
diff --git a/src/components/sharedComponents/TokenSelect/List/TokenBalance.tsx b/src/components/sharedComponents/TokenSelect/List/TokenBalance.tsx
index 4a0fec9e..56b702d6 100644
--- a/src/components/sharedComponents/TokenSelect/List/TokenBalance.tsx
+++ b/src/components/sharedComponents/TokenSelect/List/TokenBalance.tsx
@@ -1,69 +1,103 @@
import { Box, Flex } from '@chakra-ui/react'
import { formatUnits } from 'viem'
+import { useBalance } from 'wagmi'
+import { NO_PRICE_DATA_LABEL } from '@/src/constants/common'
+import { useErc20Balance } from '@/src/hooks/useErc20Balance'
+import { useWeb3Status } from '@/src/hooks/useWeb3Status'
import type { Token } from '@/src/types/token'
+import { isNativeToken } from '@/src/utils/address'
import { withSuspenseAndRetry } from '@/src/utils/suspenseWrapper'
+import BalanceLoading from './BalanceLoading'
interface TokenBalanceProps {
isLoading?: boolean
token: Token
}
+const balanceBoxProps = {
+ color: 'var(--row-token-balance-color)',
+ fontSize: '16px',
+ fontWeight: '400',
+ lineHeight: '1.2',
+ _groupHover: { color: 'var(--row-token-balance-color-hover, var(--row-token-balance-color))' },
+} as const
+
+const valueBoxProps = {
+ color: 'var(--row-token-value-color)',
+ fontSize: '12px',
+ fontWeight: '400',
+ lineHeight: '1.2',
+ _groupHover: { color: 'var(--row-token-value-color-hover, var(--row-token-value-color))' },
+} as const
+
+const flexProps = {
+ alignItems: 'flex-end',
+ display: 'flex',
+ flexDirection: 'column',
+ rowGap: 1,
+} as const
+
/**
- * Renders the token balance in the token list row.
+ * Renders the token balance and USD value in a token list row.
*
- * @param {object} props - The component props.
- * @param {boolean} props.isLoading - Indicates if the token balance is currently being loaded.
- * @param {Token} props.token - The token object containing the amount, decimals, and price in USD.
+ * When LI.FI price/balance data is available (`token.extensions`), it displays
+ * the enriched balance and computed USD value. On chains LI.FI does not support
+ * (e.g. Sepolia), it falls back to on-chain balance via wagmi and renders "N/A"
+ * for the USD value.
*
- * @throws {Promise} If the token balance is still loading or if the token does not have balance information.
- * @returns {JSX.Element} The rendered token balance component.
+ * @param {object} props
+ * @param {boolean} props.isLoading - True while the LI.FI price/balance fetch is in flight.
+ * @param {Token} props.token - The token to display.
*
- * @example
- * ```tsx
- *
- * ```
+ * @throws {Promise} While loading (triggers Suspense skeleton).
*/
const TokenBalance = withSuspenseAndRetry(({ isLoading, token }) => {
- const tokenHasBalanceInfo = !!token.extensions
+ const { address } = useWeb3Status()
+ const isNative = isNativeToken(token.address)
+ const hasExtensions = !!token.extensions
+
+ const { data: nativeBalanceData, isLoading: isLoadingNative } = useBalance({
+ address,
+ chainId: token.chainId,
+ query: { enabled: !!address && isNative && !hasExtensions },
+ })
+
+ const { balance: erc20Balance, isLoadingBalance: isLoadingErc20 } = useErc20Balance({
+ address: !isNative && !hasExtensions ? address : undefined,
+ token: !isNative && !hasExtensions ? token : undefined,
+ })
- if (isLoading || !tokenHasBalanceInfo) {
+ if (isLoading) {
throw Promise.reject()
}
- 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)
+ 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)
+
+ return (
+
+ {balance}
+ $ {value}
+
+ )
+ }
+
+ const isLoadingFallback = isNative ? isLoadingNative : isLoadingErc20
+ if (isLoadingFallback) {
+ return
+ }
+
+ const fallbackBalance = isNative
+ ? formatUnits(nativeBalanceData?.value ?? 0n, token.decimals)
+ : formatUnits(erc20Balance ?? 0n, token.decimals)
return (
-
-
- {balance}
-
-
- $ {value}
-
+
+ {fallbackBalance}
+ {NO_PRICE_DATA_LABEL}
)
})
diff --git a/src/components/sharedComponents/TokenSelect/index.tsx b/src/components/sharedComponents/TokenSelect/index.tsx
index 0ca21ff8..b2baead7 100644
--- a/src/components/sharedComponents/TokenSelect/index.tsx
+++ b/src/components/sharedComponents/TokenSelect/index.tsx
@@ -152,6 +152,7 @@ const TokenSelect = withSuspenseAndRetry(
overflow="hidden"
{...restProps}
>
+ {children}
(
showBalance={showBalance}
tokenList={searchResult}
/>
- {children}
)
},
diff --git a/src/constants/aaveSepoliaFaucet.ts b/src/constants/aaveSepoliaFaucet.ts
new file mode 100644
index 00000000..49e29bb9
--- /dev/null
+++ b/src/constants/aaveSepoliaFaucet.ts
@@ -0,0 +1,88 @@
+import { getAddress } from 'viem'
+import { sepolia } from 'viem/chains'
+import type { TokenList } from '@/src/types/token'
+
+// trustwallet/assets pinned 2026-04-18; bump SHA when icons need updating
+const TW_SHA = 'a2c7ba6ca5cc1f16f1be80f642618396ed6df6be'
+const TW = `https://raw.githubusercontent.com/trustwallet/assets/${TW_SHA}/blockchains/ethereum/assets`
+
+// Source: AAVE v3 Sepolia deployment - https://github.com/bgd-labs/aave-address-book/blob/main/src/AaveV3Sepolia.sol
+export const aaveSepoliaFaucetTokens: TokenList = {
+ name: 'AAVE Sepolia Faucet',
+ timestamp: '2026-04-20T00:00:00Z',
+ version: { major: 1, minor: 0, patch: 0 },
+ tokens: [
+ {
+ chainId: sepolia.id,
+ address: getAddress('0xFF34B3d4Aee8ddCd6F9AFFFB6Fe49bD371b8a357'),
+ name: 'Dai Stablecoin',
+ symbol: 'DAI',
+ decimals: 18,
+ logoURI: `${TW}/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png`,
+ },
+ {
+ chainId: sepolia.id,
+ address: getAddress('0xf8Fb3713D459D7C1018BD0A49D19b4C44290EBE5'),
+ name: 'Chainlink Token',
+ symbol: 'LINK',
+ decimals: 18,
+ logoURI: `${TW}/0x514910771AF9Ca656af840dff83E8264EcF986CA/logo.png`,
+ },
+ {
+ chainId: sepolia.id,
+ address: getAddress('0x94a9D9AC8a22534E3FaCa9F4e7F2E2cf85d5E4C8'),
+ name: 'USD Coin',
+ symbol: 'USDC',
+ decimals: 6,
+ logoURI: `${TW}/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png`,
+ },
+ {
+ chainId: sepolia.id,
+ address: getAddress('0x29f2D40B0605204364af54EC677bD022dA425d03'),
+ name: 'Wrapped Bitcoin',
+ symbol: 'WBTC',
+ decimals: 8,
+ logoURI: `${TW}/0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599/logo.png`,
+ },
+ {
+ chainId: sepolia.id,
+ address: getAddress('0xC558DBdd856501FCd9aaF1E62eae57A9F0629a3c'),
+ name: 'Wrapped Ether',
+ symbol: 'WETH',
+ decimals: 18,
+ logoURI: `${TW}/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png`,
+ },
+ {
+ chainId: sepolia.id,
+ address: getAddress('0xaA8E23Fb1079EA71e0a56F48a2aA51851D8433D0'),
+ name: 'Tether USD',
+ symbol: 'USDT',
+ decimals: 6,
+ logoURI: `${TW}/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png`,
+ },
+ {
+ chainId: sepolia.id,
+ address: getAddress('0x88541670E55cC00bEEFD87eB59EDd1b7C511AC9a'),
+ name: 'Aave Token',
+ symbol: 'AAVE',
+ decimals: 18,
+ logoURI: `${TW}/0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9/logo.png`,
+ },
+ {
+ chainId: sepolia.id,
+ address: getAddress('0x6d906e526a4e2Ca02097BA9d0caA3c382F52278E'),
+ name: 'Euro Stablecoin',
+ symbol: 'EURS',
+ decimals: 2,
+ logoURI: `${TW}/0xdB25f211AB05b1c97D595516F45794528a807ad8/logo.png`,
+ },
+ {
+ chainId: sepolia.id,
+ address: getAddress('0xc4bF5CbDaBE595361438F8c6a187bDc330539c60'),
+ name: 'Gho Token',
+ symbol: 'GHO',
+ decimals: 18,
+ logoURI: `${TW}/0x40D16FC0246aD3160Ccc09B8D0D3A2cD28aE6C2f/logo.png`,
+ },
+ ],
+}
diff --git a/src/constants/common.ts b/src/constants/common.ts
index a12b5a0e..991b8e66 100644
--- a/src/constants/common.ts
+++ b/src/constants/common.ts
@@ -9,3 +9,6 @@ export const isDev = import.meta.env.DEV
* @source
*/
export const includeTestnets = env.PUBLIC_INCLUDE_TESTNETS
+
+/** Displayed when no price data is available (e.g. chains unsupported by LI.FI). */
+export const NO_PRICE_DATA_LABEL = 'N/A'
diff --git a/src/constants/tokenLists.ts b/src/constants/tokenLists.ts
index 12475c5b..4f08f945 100644
--- a/src/constants/tokenLists.ts
+++ b/src/constants/tokenLists.ts
@@ -1,3 +1,7 @@
+import { aaveSepoliaFaucetTokens } from '@/src/constants/aaveSepoliaFaucet'
+import { includeTestnets } from '@/src/constants/common'
+import type { TokenList } from '@/src/types/token'
+
/**
* @dev Here you can add the list of tokens you want to use in the app
* The list follow the standard from: https://tokenlists.org/
@@ -7,3 +11,23 @@
export const tokenLists = {
COINGECKO: 'https://tokens.coingecko.com/uniswap/all.json',
} as const
+
+export type BundledTokenList = {
+ key: string
+ list: TokenList
+ enabled: boolean
+}
+
+/**
+ * @dev Bundled (offline) token lists included at build time.
+ * Add entries here to ship curated token sets without a network request.
+ * Each entry is gated by an `enabled` flag so testnet lists stay out of
+ * production builds when PUBLIC_INCLUDE_TESTNETS is false.
+ */
+export const bundledTokenLists: BundledTokenList[] = [
+ {
+ key: 'aave-sepolia-faucet',
+ list: aaveSepoliaFaucetTokens,
+ enabled: includeTestnets,
+ },
+]
diff --git a/src/hooks/useTokenLists.test.ts b/src/hooks/useTokenLists.test.ts
index d76e996e..6763fcaf 100644
--- a/src/hooks/useTokenLists.test.ts
+++ b/src/hooks/useTokenLists.test.ts
@@ -28,6 +28,7 @@ vi.mock('@/src/env', () => ({
vi.mock('@/src/constants/tokenLists', () => ({
tokenLists: {},
+ bundledTokenLists: [],
}))
vi.mock('@tanstack/react-query', async (importActual) => {
diff --git a/src/hooks/useTokenLists.ts b/src/hooks/useTokenLists.ts
index 57ddc697..8f53e944 100644
--- a/src/hooks/useTokenLists.ts
+++ b/src/hooks/useTokenLists.ts
@@ -7,7 +7,7 @@ import defaultTokens from '@uniswap/default-token-list'
import { useMemo } from 'react'
import * as chains from 'viem/chains'
-import { tokenLists } from '@/src/constants/tokenLists'
+import { bundledTokenLists, tokenLists } from '@/src/constants/tokenLists'
import { env } from '@/src/env'
import { type Token, type TokenList, tokenSchema } from '@/src/types/token'
import { logger } from '@/src/utils/logger'
@@ -58,13 +58,23 @@ export const useTokenLists = (): TokensMap => {
return env.PUBLIC_USE_DEFAULT_TOKENS ? ['default', ...urls] : urls
}, [])
+ const enabledBundledLists = useMemo(() => bundledTokenLists.filter((b) => b.enabled), [])
+
return useSuspenseQueries({
- queries: tokenListUrls.map>((url) => ({
- queryKey: ['tokens-list', url],
- queryFn: () => fetchTokenList(url),
- staleTime: 60 * 60 * 1000,
- gcTime: 60 * 60 * 1000,
- })),
+ queries: [
+ ...tokenListUrls.map>((url) => ({
+ queryKey: ['tokens-list', url],
+ queryFn: () => fetchTokenList(url),
+ staleTime: 60 * 60 * 1000,
+ gcTime: 60 * 60 * 1000,
+ })),
+ ...enabledBundledLists.map>((b) => ({
+ queryKey: ['tokens-list', b.key],
+ queryFn: () => Promise.resolve(b.list),
+ staleTime: Number.POSITIVE_INFINITY,
+ gcTime: Number.POSITIVE_INFINITY,
+ })),
+ ],
combine: combineTokenLists,
})
}
diff --git a/src/hooks/useTokens.test.ts b/src/hooks/useTokens.test.ts
new file mode 100644
index 00000000..f3d3b62c
--- /dev/null
+++ b/src/hooks/useTokens.test.ts
@@ -0,0 +1,71 @@
+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'
+
+// 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
+// zero-address convention.
+const { LOCAL_NATIVE } = vi.hoisted(() => ({
+ LOCAL_NATIVE: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
+}))
+
+vi.mock('@/src/env', () => ({
+ env: { PUBLIC_NATIVE_TOKEN_ADDRESS: LOCAL_NATIVE, PUBLIC_APP_NAME: 'test' },
+}))
+
+const usdcAddress = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'
+
+const localTokens: Tokens = [
+ { chainId: 1, address: LOCAL_NATIVE, name: 'Ether', symbol: 'ETH', decimals: 18 },
+ { chainId: 1, address: usdcAddress, name: 'USD Coin', symbol: 'USDC', decimals: 6 },
+]
+
+const makeLifiToken = (address: string, symbol: string, decimals: number, priceUSD: string) => ({
+ chainId: 1,
+ address,
+ symbol,
+ name: symbol,
+ decimals,
+ priceUSD,
+})
+
+describe('updateTokensBalances', () => {
+ it('merges LI.FI native balance onto a local native token that uses a non-zero sentinel', () => {
+ const prices: TokensResponse = {
+ tokens: {
+ 1: [
+ makeLifiToken(zeroAddress, 'ETH', 18, '2300'),
+ makeLifiToken(usdcAddress, 'USDC', 6, '1'),
+ ],
+ },
+ }
+ const balances: TokenAmount[] = [
+ { ...makeLifiToken(zeroAddress, 'ETH', 18, '2300'), amount: 1_758_640_884_554_030_066n },
+ { ...makeLifiToken(usdcAddress, 'USDC', 6, '1'), amount: 5_000_000n },
+ ]
+
+ const { tokens } = updateTokensBalances(localTokens, [balances, prices])
+
+ const eth = tokens.find((t: Token) => t.address === LOCAL_NATIVE)
+ const usdc = tokens.find((t: Token) => t.address === usdcAddress)
+
+ expect(eth?.extensions?.balance).toBe(1_758_640_884_554_030_066n)
+ expect(eth?.extensions?.priceUSD).toBe('2300')
+ expect(usdc?.extensions?.balance).toBe(5_000_000n)
+ expect(usdc?.extensions?.priceUSD).toBe('1')
+ })
+
+ it('falls back to zero balance when LI.FI has no data for a local token', () => {
+ const prices: TokensResponse = { tokens: { 1: [] } }
+ const balances: TokenAmount[] = []
+
+ const { tokens } = updateTokensBalances(localTokens, [balances, prices])
+
+ for (const token of tokens) {
+ expect(token.extensions?.balance).toBe(0n)
+ expect(token.extensions?.priceUSD).toBe('0')
+ }
+ })
+})
diff --git a/src/hooks/useTokens.ts b/src/hooks/useTokens.ts
index d829a12d..d25c24c1 100644
--- a/src/hooks/useTokens.ts
+++ b/src/hooks/useTokens.ts
@@ -15,6 +15,7 @@ import { env } from '@/src/env'
import { useTokenLists } from '@/src/hooks/useTokenLists'
import { useWeb3Status } from '@/src/hooks/useWeb3Status'
import type { Token, Tokens } from '@/src/types/token'
+import { toLocalNativeAddress } from '@/src/utils/address'
import { logger } from '@/src/utils/logger'
import type { TokensMap } from '@/src/utils/tokenListsCache'
@@ -104,7 +105,7 @@ export const useTokens = (
staleTime: BALANCE_EXPIRATION_TIME,
refetchInterval: BALANCE_EXPIRATION_TIME,
gcTime: Number.POSITIVE_INFINITY,
- enabled: canFetchBalance && !!chains,
+ enabled: canFetchBalance && chainsToFetch.length > 0,
})
const { data: tokensBalances, isLoading: isLoadingBalances } = useQuery({
@@ -121,7 +122,7 @@ export const useTokens = (
staleTime: BALANCE_EXPIRATION_TIME,
refetchInterval: BALANCE_EXPIRATION_TIME,
gcTime: Number.POSITIVE_INFINITY,
- enabled: canFetchBalance && !!tokensPricesByChain,
+ enabled: canFetchBalance && !!tokensPricesByChain && chainsToFetch.length > 0,
})
const cache = useMemo(() => {
@@ -133,7 +134,7 @@ export const useTokens = (
tokensBalances &&
tokensPricesByChain
) {
- return udpateTokensBalances(tokensData.tokens, [tokensBalances, tokensPricesByChain])
+ return updateTokensBalances(tokensData.tokens, [tokensBalances, tokensPricesByChain])
}
return tokensData
}, [
@@ -159,7 +160,10 @@ export const useTokens = (
* @param results - The results containing the balance tokens and prices.
* @returns An object containing the updated tokens and tokens grouped by chain ID.
*/
-function udpateTokensBalances(tokens: Tokens, results: [Array, TokensResponse]) {
+export function updateTokensBalances(
+ tokens: Tokens,
+ results: [Array, TokensResponse],
+) {
const [balanceTokens, prices] = results
logger.time('extending tokens with balance info')
@@ -168,7 +172,7 @@ function udpateTokensBalances(tokens: Tokens, results: [Array, Toke
acc[chainId] = {}
tokens.forEach((token) => {
- acc[chainId][token.address] = token.priceUSD ?? '0'
+ acc[chainId][toLocalNativeAddress(token.address)] = token.priceUSD ?? '0'
})
return acc
@@ -182,7 +186,8 @@ function udpateTokensBalances(tokens: Tokens, results: [Array, Toke
acc[balanceToken.chainId] = {}
}
- acc[balanceToken.chainId][balanceToken.address] = balanceToken.amount ?? 0n
+ acc[balanceToken.chainId][toLocalNativeAddress(balanceToken.address)] =
+ balanceToken.amount ?? 0n
return acc
},
diff --git a/src/utils/address.ts b/src/utils/address.ts
index d9d0a5e4..49244839 100644
--- a/src/utils/address.ts
+++ b/src/utils/address.ts
@@ -1,3 +1,4 @@
+import { zeroAddress } from 'viem'
import { env } from '@/src/env'
/**
@@ -19,3 +20,11 @@ import { env } from '@/src/env'
export const isNativeToken = (address: string) => {
return address.toLowerCase() === env.PUBLIC_NATIVE_TOKEN_ADDRESS
}
+
+/**
+ * LI.FI reports native tokens at the zero address. Rewrite it to the app's
+ * configured native sentinel (env.PUBLIC_NATIVE_TOKEN_ADDRESS) so lookups keyed
+ * on local token addresses match. No-op when the app uses the zero-address sentinel.
+ */
+export const toLocalNativeAddress = (address: string): string =>
+ address.toLowerCase() === zeroAddress ? env.PUBLIC_NATIVE_TOKEN_ADDRESS : address