From e8fcfcda3989b88686a05327319ac4dc707467b9 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:35:01 -0300 Subject: [PATCH 1/5] fix(token-input): display balance USD value and show spinner while loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compute the estimated USD field from price × balance instead of price × amount, so selecting a token immediately shows its USD worth without requiring user input or pressing MAX. Add useTokens call inside useTokenInput to fetch prices for the selected token chain, fixing single-token mode where the initial token carries no priceUSD. Expose priceUSD and isLoadingPrice from the hook. Show a spinner in the USD slot while price or balance is loading. Closes #393 --- .../sharedComponents/TokenInput/index.tsx | 20 ++++--- .../TokenInput/useTokenInput.test.ts | 54 +++++++++++++++++++ .../TokenInput/useTokenInput.tsx | 12 +++++ 3 files changed, 79 insertions(+), 7 deletions(-) diff --git a/src/components/sharedComponents/TokenInput/index.tsx b/src/components/sharedComponents/TokenInput/index.tsx index 964836b1..aa647821 100644 --- a/src/components/sharedComponents/TokenInput/index.tsx +++ b/src/components/sharedComponents/TokenInput/index.tsx @@ -85,6 +85,8 @@ const TokenInput: FC = ({ balance, balanceError, isLoadingBalance, + isLoadingPrice, + priceUSD, selectedToken, setAmount, setAmountError, @@ -105,12 +107,10 @@ const TokenInput: FC = ({ 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]) + if (!selectedToken || !priceUSD || !balance) return 0 + const tokenBalance = Number.parseFloat(formatUnits(balance, selectedToken.decimals ?? 0)) + return Number.parseFloat(priceUSD) * tokenBalance + }, [isTestnetChain, selectedToken, priceUSD, balance]) const selectIconSize = 24 const decimals = selectedToken ? selectedToken.decimals : 2 @@ -188,7 +188,13 @@ const TokenInput: FC = ({ - {estimatedUSDValue === null ? NO_PRICE_DATA_LABEL : `~$${estimatedUSDValue.toFixed(2)}`} + {estimatedUSDValue === null ? ( + NO_PRICE_DATA_LABEL + ) : selectedToken && !isTestnetChain && (isLoadingPrice || isLoadingBalance) ? ( + + ) : ( + `~$${(estimatedUSDValue as number).toFixed(2)}` + )} diff --git a/src/components/sharedComponents/TokenInput/useTokenInput.test.ts b/src/components/sharedComponents/TokenInput/useTokenInput.test.ts index 930d23d6..f926a78f 100644 --- a/src/components/sharedComponents/TokenInput/useTokenInput.test.ts +++ b/src/components/sharedComponents/TokenInput/useTokenInput.test.ts @@ -11,6 +11,7 @@ const walletAddress = '0x71C7656EC7ab88b098defB751B7401B5f6d8976F' as const const mockUseAccount = vi.fn() const mockUsePublicClient = vi.fn() const mockGetBalance = vi.fn() +const mockUseTokens = vi.fn() vi.mock('wagmi', () => ({ useAccount: () => mockUseAccount(), @@ -24,6 +25,10 @@ vi.mock('@/src/hooks/useErc20Balance', () => ({ useErc20Balance: () => ({ balance: undefined, balanceError: null, isLoadingBalance: false }), })) +vi.mock('@/src/hooks/useTokens', () => ({ + useTokens: (args: unknown) => mockUseTokens(args), +})) + vi.mock('@/src/env', () => ({ env: { PUBLIC_NATIVE_TOKEN_ADDRESS: zeroAddress.toLowerCase() }, })) @@ -56,6 +61,7 @@ describe('useTokenInput', () => { mockUseAccount.mockReturnValue({ address: walletAddress }) mockUsePublicClient.mockClear() mockGetBalance.mockReset() + mockUseTokens.mockReturnValue({ tokensByChainId: {}, isLoadingBalances: false, tokens: [] }) }) it('rebinds the native public client to the selected token chain when the user switches chains', async () => { @@ -98,4 +104,52 @@ describe('useTokenInput', () => { expect(result.current.isLoadingBalance).toBe(false) expect(mockGetBalance).not.toHaveBeenCalled() }) + + it('exposes priceUSD for the selected token from useTokens', async () => { + mockUseTokens.mockReturnValue({ + tokensByChainId: { + 1: [{ ...mainnetUsdc, extensions: { priceUSD: '1.00' } }], + }, + isLoadingBalances: false, + tokens: [], + }) + + const { result } = renderHook(() => useTokenInput(mainnetUsdc), { wrapper }) + + await waitFor(() => expect(result.current.priceUSD).toBe('1.00')) + }) + + it('exposes isLoadingPrice as true while useTokens is loading', () => { + mockUseTokens.mockReturnValue({ tokensByChainId: {}, isLoadingBalances: true, tokens: [] }) + + const { result } = renderHook(() => useTokenInput(mainnetUsdc), { wrapper }) + + expect(result.current.isLoadingPrice).toBe(true) + }) + + it('updates priceUSD when a different token is selected', async () => { + const mainnetEth: Token = { + address: zeroAddress, + chainId: 1, + decimals: 18, + name: 'Ether', + symbol: 'ETH', + } + mockUseTokens.mockReturnValue({ + tokensByChainId: { + 1: [{ ...mainnetEth, extensions: { priceUSD: '3000.00' } }], + }, + isLoadingBalances: false, + tokens: [], + }) + mockGetBalance.mockResolvedValue(1000000000000000000n) + + const { result } = renderHook(() => useTokenInput(mainnetUsdc), { wrapper }) + + act(() => { + result.current.setTokenSelected(mainnetEth) + }) + + await waitFor(() => expect(result.current.priceUSD).toBe('3000.00')) + }) }) diff --git a/src/components/sharedComponents/TokenInput/useTokenInput.tsx b/src/components/sharedComponents/TokenInput/useTokenInput.tsx index 954d4862..6ac6ac26 100644 --- a/src/components/sharedComponents/TokenInput/useTokenInput.tsx +++ b/src/components/sharedComponents/TokenInput/useTokenInput.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react' import { getAddress } from 'viem' import { useAccount, usePublicClient } from 'wagmi' import { useErc20Balance } from '@/src/hooks/useErc20Balance' +import { useTokens } from '@/src/hooks/useTokens' import type { Token } from '@/src/types/token' import { isNativeToken } from '@/src/utils/address' @@ -49,6 +50,15 @@ export function useTokenInput(token?: Token) { }, [token]) const { address: userWallet } = useAccount() + const { tokensByChainId, isLoadingBalances: isLoadingPrice } = useTokens({ + chainId: selectedToken?.chainId, + withBalance: true, + }) + const priceUSD = selectedToken + ? (tokensByChainId[selectedToken.chainId]?.find((t) => t.address === selectedToken.address) + ?.extensions?.priceUSD as string | undefined) + : undefined + const { balance, balanceError, isLoadingBalance } = useErc20Balance({ address: userWallet ? getAddress(userWallet) : undefined, token: selectedToken, @@ -75,6 +85,8 @@ export function useTokenInput(token?: Token) { balance: isNative ? nativeBalance : balance, balanceError: isNative ? nativeBalanceError : balanceError, isLoadingBalance: isNative ? isLoadingNativeBalance : isLoadingBalance, + isLoadingPrice, + priceUSD, selectedToken, setTokenSelected, } From 373f72e820fa761b77432405afa59bb0c1954430 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:40:47 -0300 Subject: [PATCH 2/5] refactor(token-input): remove redundant testnet guard and unnecessary cast in USD value render --- src/components/sharedComponents/TokenInput/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/sharedComponents/TokenInput/index.tsx b/src/components/sharedComponents/TokenInput/index.tsx index aa647821..7497c428 100644 --- a/src/components/sharedComponents/TokenInput/index.tsx +++ b/src/components/sharedComponents/TokenInput/index.tsx @@ -190,10 +190,10 @@ const TokenInput: FC = ({ {estimatedUSDValue === null ? ( NO_PRICE_DATA_LABEL - ) : selectedToken && !isTestnetChain && (isLoadingPrice || isLoadingBalance) ? ( + ) : selectedToken && (isLoadingPrice || isLoadingBalance) ? ( ) : ( - `~$${(estimatedUSDValue as number).toFixed(2)}` + `~$${estimatedUSDValue.toFixed(2)}` )} From fa4a12f3fe2e99db4dabe329a96a989d419e2a4d Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:10:14 -0300 Subject: [PATCH 3/5] fix(token-input): compare token addresses case-insensitively when resolving priceUSD --- .../home/Examples/demos/TokenInput/index.test.tsx | 2 ++ src/components/sharedComponents/TokenInput/useTokenInput.tsx | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) 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 e7952897..faa053d8 100644 --- a/src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx +++ b/src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx @@ -35,6 +35,8 @@ vi.mock('@/src/components/sharedComponents/TokenInput/useTokenInput', () => ({ balance: 0n, balanceError: null, isLoadingBalance: false, + isLoadingPrice: false, + priceUSD: undefined, selectedToken: undefined, setTokenSelected: vi.fn(), })), diff --git a/src/components/sharedComponents/TokenInput/useTokenInput.tsx b/src/components/sharedComponents/TokenInput/useTokenInput.tsx index 6ac6ac26..f3a6435b 100644 --- a/src/components/sharedComponents/TokenInput/useTokenInput.tsx +++ b/src/components/sharedComponents/TokenInput/useTokenInput.tsx @@ -55,8 +55,9 @@ export function useTokenInput(token?: Token) { withBalance: true, }) const priceUSD = selectedToken - ? (tokensByChainId[selectedToken.chainId]?.find((t) => t.address === selectedToken.address) - ?.extensions?.priceUSD as string | undefined) + ? (tokensByChainId[selectedToken.chainId]?.find( + (t) => t.address.toLowerCase() === selectedToken.address.toLowerCase(), + )?.extensions?.priceUSD as string | undefined) : undefined const { balance, balanceError, isLoadingBalance } = useErc20Balance({ From 24715bfe331c75c74c5dc506780a3332509e951b Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:32:33 -0300 Subject: [PATCH 4/5] refactor(use-tokens): expose dedicated isLoadingPrices signal Decouple price loading from combined isLoadingBalances so consumers that only need to know when price data is ready (TokenInput's USD display) are not kept in a loading state by unrelated balance queries. isLoadingBalances retains its current union semantics; TokenSelect consumers are unaffected. --- .../Examples/demos/TokenInput/index.test.tsx | 1 + .../TokenInput/useTokenInput.test.ts | 16 ++++++++++++++-- .../TokenInput/useTokenInput.tsx | 2 +- src/hooks/useTokens.ts | 2 ++ 4 files changed, 18 insertions(+), 3 deletions(-) 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 faa053d8..c582dd3d 100644 --- a/src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx +++ b/src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx @@ -49,6 +49,7 @@ vi.mock('@/src/hooks/useTokens', () => ({ tokens: [], tokensByChainId: { 1: [], 10: [], 42161: [], 137: [], 11155111: [] }, isLoadingBalances: false, + isLoadingPrices: false, })), })) diff --git a/src/components/sharedComponents/TokenInput/useTokenInput.test.ts b/src/components/sharedComponents/TokenInput/useTokenInput.test.ts index f926a78f..68631017 100644 --- a/src/components/sharedComponents/TokenInput/useTokenInput.test.ts +++ b/src/components/sharedComponents/TokenInput/useTokenInput.test.ts @@ -61,7 +61,12 @@ describe('useTokenInput', () => { mockUseAccount.mockReturnValue({ address: walletAddress }) mockUsePublicClient.mockClear() mockGetBalance.mockReset() - mockUseTokens.mockReturnValue({ tokensByChainId: {}, isLoadingBalances: false, tokens: [] }) + mockUseTokens.mockReturnValue({ + tokensByChainId: {}, + isLoadingBalances: false, + isLoadingPrices: false, + tokens: [], + }) }) it('rebinds the native public client to the selected token chain when the user switches chains', async () => { @@ -111,6 +116,7 @@ describe('useTokenInput', () => { 1: [{ ...mainnetUsdc, extensions: { priceUSD: '1.00' } }], }, isLoadingBalances: false, + isLoadingPrices: false, tokens: [], }) @@ -120,7 +126,12 @@ describe('useTokenInput', () => { }) it('exposes isLoadingPrice as true while useTokens is loading', () => { - mockUseTokens.mockReturnValue({ tokensByChainId: {}, isLoadingBalances: true, tokens: [] }) + mockUseTokens.mockReturnValue({ + tokensByChainId: {}, + isLoadingBalances: true, + isLoadingPrices: true, + tokens: [], + }) const { result } = renderHook(() => useTokenInput(mainnetUsdc), { wrapper }) @@ -140,6 +151,7 @@ describe('useTokenInput', () => { 1: [{ ...mainnetEth, extensions: { priceUSD: '3000.00' } }], }, isLoadingBalances: false, + isLoadingPrices: false, tokens: [], }) mockGetBalance.mockResolvedValue(1000000000000000000n) diff --git a/src/components/sharedComponents/TokenInput/useTokenInput.tsx b/src/components/sharedComponents/TokenInput/useTokenInput.tsx index f3a6435b..99417995 100644 --- a/src/components/sharedComponents/TokenInput/useTokenInput.tsx +++ b/src/components/sharedComponents/TokenInput/useTokenInput.tsx @@ -50,7 +50,7 @@ export function useTokenInput(token?: Token) { }, [token]) const { address: userWallet } = useAccount() - const { tokensByChainId, isLoadingBalances: isLoadingPrice } = useTokens({ + const { tokensByChainId, isLoadingPrices: isLoadingPrice } = useTokens({ chainId: selectedToken?.chainId, withBalance: true, }) diff --git a/src/hooks/useTokens.ts b/src/hooks/useTokens.ts index d25c24c1..776e1c1b 100644 --- a/src/hooks/useTokens.ts +++ b/src/hooks/useTokens.ts @@ -47,6 +47,7 @@ export const lifiConfig = createConfig({ * @returns {Token[]} returns.tokens - Array of tokens with price and balance information * @returns {Record} returns.tokensByChainId - Tokens organized by chain ID * @returns {boolean} returns.isLoadingBalances - Loading state for token balances and prices + * @returns {boolean} returns.isLoadingPrices - Loading state for token prices only * * @example * ```tsx @@ -150,6 +151,7 @@ export const useTokens = ( return { ...cache, isLoadingBalances: Boolean(isLoadingChains || isLoadingBalances || isLoadingPrices), + isLoadingPrices: Boolean(isLoadingChains || isLoadingPrices), } } From 3035958ac926d217a1a846dcdb1f7a981d3a2d38 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:33:32 -0300 Subject: [PATCH 5/5] docs(token-input): document priceUSD and isLoadingPrice return fields Add missing @returns entries to useTokenInput JSDoc so consumers can discover the USD price value and its loading state. --- src/components/sharedComponents/TokenInput/useTokenInput.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/sharedComponents/TokenInput/useTokenInput.tsx b/src/components/sharedComponents/TokenInput/useTokenInput.tsx index 99417995..1c273ebf 100644 --- a/src/components/sharedComponents/TokenInput/useTokenInput.tsx +++ b/src/components/sharedComponents/TokenInput/useTokenInput.tsx @@ -26,6 +26,8 @@ export type UseTokenInputReturnType = ReturnType * @returns {bigint} returns.balance - Current token balance (ERC20 or native) * @returns {Error|null} returns.balanceError - Error from balance fetching * @returns {boolean} returns.isLoadingBalance - Loading state for balance + * @returns {string|undefined} returns.priceUSD - USD price of the selected token (from useTokens) + * @returns {boolean} returns.isLoadingPrice - Loading state for the selected token's USD price * @returns {Token|undefined} returns.selectedToken - Currently selected token * @returns {function} returns.setTokenSelected - Function to update selected token *