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