diff --git a/app/components/Views/confirmations/hooks/pay/useAssetFiatFormatter.test.ts b/app/components/Views/confirmations/hooks/pay/useAssetFiatFormatter.test.ts new file mode 100644 index 000000000000..60cef1a573c2 --- /dev/null +++ b/app/components/Views/confirmations/hooks/pay/useAssetFiatFormatter.test.ts @@ -0,0 +1,145 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; + +import { useAssetFiatFormatter } from './useAssetFiatFormatter'; +import { useTransactionPayCurrency } from './useTransactionPayCurrency'; +import { selectCurrentCurrency } from '../../../../../selectors/currencyRateController'; +import { getIntlNumberFormatter } from '../../../../../util/intl'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('./useTransactionPayCurrency', () => ({ + useTransactionPayCurrency: jest.fn(), +})); + +jest.mock('../../../../../selectors/currencyRateController', () => ({ + selectCurrentCurrency: jest.fn(), +})); + +jest.mock('../../../../../util/intl', () => ({ + getIntlNumberFormatter: jest.fn(), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + locale: 'en-US', + strings: jest.fn((key: string) => key), +})); + +const mockUseSelector = jest.mocked(useSelector); +const mockUseTransactionPayCurrency = jest.mocked(useTransactionPayCurrency); +const mockGetIntlNumberFormatter = jest.mocked(getIntlNumberFormatter); + +const mockFormatter = { format: jest.fn() }; + +function buildState({ preferredCurrency = 'usd' } = {}) { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectCurrentCurrency) return preferredCurrency; + return undefined; + }); +} + +describe('useAssetFiatFormatter', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseTransactionPayCurrency.mockReturnValue(undefined); + mockGetIntlNumberFormatter.mockReturnValue( + mockFormatter as unknown as ReturnType, + ); + mockFormatter.format.mockImplementation((n) => `formatted:${String(n)}`); + }); + + describe('fiatCurrency selection', () => { + it('uses the preferred currency outside pay flow', () => { + buildState({ preferredCurrency: 'eur' }); + + const { result } = renderHook(() => useAssetFiatFormatter()); + + expect(result.current.fiatCurrency).toBe('eur'); + }); + + it('uses the pay currency when useTransactionPayCurrency returns a value', () => { + buildState({ preferredCurrency: 'eur' }); + mockUseTransactionPayCurrency.mockReturnValue('USD'); + + const { result } = renderHook(() => useAssetFiatFormatter()); + + expect(result.current.fiatCurrency).toBe('USD'); + }); + }); + + describe('format', () => { + it('formats a numeric amount using Intl currency', () => { + buildState({ preferredCurrency: 'eur' }); + + const { result } = renderHook(() => useAssetFiatFormatter()); + const output = result.current.format('100'); + + expect(mockGetIntlNumberFormatter).toHaveBeenCalledWith( + 'en-US', + expect.objectContaining({ style: 'currency', currency: 'eur' }), + ); + expect(mockFormatter.format).toHaveBeenCalledWith('100'); + expect(output).toBe('formatted:100'); + }); + + it('formats with the pay currency when in pay flow', () => { + buildState({ preferredCurrency: 'eur' }); + mockUseTransactionPayCurrency.mockReturnValue('USD'); + + const { result } = renderHook(() => useAssetFiatFormatter()); + result.current.format('110'); + + expect(mockGetIntlNumberFormatter).toHaveBeenCalledWith( + 'en-US', + expect.objectContaining({ currency: 'USD' }), + ); + expect(mockFormatter.format).toHaveBeenCalledWith('110'); + }); + + it('uses minimumFractionDigits=0 for integer amounts', () => { + buildState({ preferredCurrency: 'eur' }); + + renderHook(() => useAssetFiatFormatter()).result.current.format('100'); + + expect(mockGetIntlNumberFormatter).toHaveBeenCalledWith( + 'en-US', + expect.objectContaining({ minimumFractionDigits: 0 }), + ); + }); + + it('uses minimumFractionDigits=2 for non-integer amounts', () => { + buildState({ preferredCurrency: 'eur' }); + + renderHook(() => useAssetFiatFormatter()).result.current.format('100.5'); + + expect(mockGetIntlNumberFormatter).toHaveBeenCalledWith( + 'en-US', + expect.objectContaining({ minimumFractionDigits: 2 }), + ); + }); + + it('returns undefined when input is undefined', () => { + buildState({ preferredCurrency: 'eur' }); + + const { result } = renderHook(() => useAssetFiatFormatter()); + const output = result.current.format(undefined); + + expect(output).toBeUndefined(); + expect(mockFormatter.format).not.toHaveBeenCalled(); + }); + + it('falls back to `${value} ${currency}` when Intl throws', () => { + buildState({ preferredCurrency: 'eur' }); + mockGetIntlNumberFormatter.mockImplementation(() => { + throw new Error('boom'); + }); + + const { result } = renderHook(() => useAssetFiatFormatter()); + const output = result.current.format('42'); + + expect(output).toBe('42 eur'); + }); + }); +}); diff --git a/app/components/Views/confirmations/hooks/pay/useAssetFiatFormatter.ts b/app/components/Views/confirmations/hooks/pay/useAssetFiatFormatter.ts new file mode 100644 index 000000000000..e4a4ae99cdfb --- /dev/null +++ b/app/components/Views/confirmations/hooks/pay/useAssetFiatFormatter.ts @@ -0,0 +1,60 @@ +import { useSelector } from 'react-redux'; +import { useCallback } from 'react'; +import { BigNumber } from 'bignumber.js'; + +import I18n from '../../../../../../locales/i18n'; +import Logger from '../../../../../util/Logger'; +import { getIntlNumberFormatter } from '../../../../../util/intl'; +import { selectCurrentCurrency } from '../../../../../selectors/currencyRateController'; +import { useTransactionPayCurrency } from './useTransactionPayCurrency'; + +export type AssetFiatFormatter = ( + amount: BigNumber.Value | undefined, +) => string | undefined; + +/** + * Returns a currency formatter whose target currency follows the pay-flow + * override: USD inside `PAY_TRANSACTION_TYPES` confirmations, otherwise the + * user's preferred currency. + * + * This hook only formats — it does not derive rates or convert values. + * Callers must pass an `amount` already denominated in `fiatCurrency`. Rate + * derivation should go through `useTokenFiatRates`, which owns the + * stablecoin exception and the USD/preferred-currency rate selection. + * + * Returns `undefined` when the input amount is `undefined`, so callers can + * pass through a missing rate as "hide fiat". + */ +export function useAssetFiatFormatter(): { + format: AssetFiatFormatter; + fiatCurrency: string; +} { + const preferredCurrency = useSelector(selectCurrentCurrency); + const payCurrency = useTransactionPayCurrency(); + const fiatCurrency = payCurrency ?? preferredCurrency; + + const format = useCallback( + (amount) => { + if (amount === undefined) { + return undefined; + } + + const value = new BigNumber(amount); + const hasDecimals = !value.isInteger(); + + try { + return getIntlNumberFormatter(I18n.locale, { + style: 'currency', + currency: fiatCurrency, + minimumFractionDigits: hasDecimals ? 2 : 0, + }).format(value.toFixed() as unknown as number); + } catch (error) { + Logger.error(error as Error); + return `${value.toFixed()} ${fiatCurrency}`; + } + }, + [fiatCurrency], + ); + + return { format, fiatCurrency }; +} diff --git a/app/components/Views/confirmations/hooks/send/useAccountTokens.test.ts b/app/components/Views/confirmations/hooks/send/useAccountTokens.test.ts index 7b6eedae48a8..9434143646d0 100644 --- a/app/components/Views/confirmations/hooks/send/useAccountTokens.test.ts +++ b/app/components/Views/confirmations/hooks/send/useAccountTokens.test.ts @@ -3,7 +3,6 @@ import { useSelector } from 'react-redux'; import { useAccountTokens } from './useAccountTokens'; import { getNetworkBadgeSource } from '../../utils/network'; -import { getIntlNumberFormatter } from '../../../../../util/intl'; import { TokenStandard } from '../../types/token'; import { selectAssetsBySelectedAccountGroup, @@ -16,7 +15,8 @@ import { useTokensData } from '../../../../hooks/useTokensData/useTokensData'; import { buildEvmCaip19AssetId } from '../../../../../util/multichain/buildEvmCaip19AssetId'; import { Hex } from '@metamask/utils'; import { useTransactionAccountOverride } from '../transactions/useTransactionAccountOverride'; -import { useTransactionPayCurrency } from '../pay/useTransactionPayCurrency'; +import { useAssetFiatFormatter } from '../pay/useAssetFiatFormatter'; +import { useTokenFiatRates } from '../tokens/useTokenFiatRates'; import { selectInternalAccountsById } from '../../../../../selectors/accountsController'; import { selectAccountToGroupMap } from '../../../../../selectors/multichainAccounts/accountTreeController'; @@ -28,19 +28,10 @@ jest.mock('../../utils/network', () => ({ getNetworkBadgeSource: jest.fn(), })); -jest.mock('../../../../../util/intl', () => ({ - getIntlNumberFormatter: jest.fn(), -})); - jest.mock('../../../../../util/networks', () => ({ isTestNet: jest.fn(), })); -jest.mock('../../../../../../locales/i18n', () => ({ - locale: 'en-US', - strings: jest.fn((key: string) => key), -})); - jest.mock('../transactions/useTransactionAccountOverride', () => ({ useTransactionAccountOverride: jest.fn(), })); @@ -48,10 +39,19 @@ const useTransactionAccountOverrideMock = jest.mocked( useTransactionAccountOverride, ); -jest.mock('../pay/useTransactionPayCurrency', () => ({ - useTransactionPayCurrency: jest.fn(), +jest.mock('../pay/useAssetFiatFormatter', () => ({ + useAssetFiatFormatter: jest.fn(), +})); +const useAssetFiatFormatterMock = jest.mocked(useAssetFiatFormatter); + +jest.mock('../tokens/useTokenFiatRates', () => ({ + useTokenFiatRates: jest.fn(), +})); +const useTokenFiatRatesMock = jest.mocked(useTokenFiatRates); + +jest.mock('../../../../../core/Multichain/utils', () => ({ + isNonEvmChainId: jest.fn((chainId: string) => !chainId?.startsWith('0x')), })); -const useTransactionPayCurrencyMock = jest.mocked(useTransactionPayCurrency); jest.mock('../../../../../selectors/accountsController', () => ({ selectInternalAccountsById: jest.fn(), @@ -82,7 +82,6 @@ jest.mock('../../../../../util/multichain/buildEvmCaip19AssetId'); const mockUseSelector = jest.mocked(useSelector); const mockGetNetworkBadgeSource = jest.mocked(getNetworkBadgeSource); -const mockGetIntlNumberFormatter = jest.mocked(getIntlNumberFormatter); const mockSelectAssetsBySelectedAccountGroup = jest.mocked( selectAssetsBySelectedAccountGroup, ); @@ -91,18 +90,24 @@ const mockIsTestNet = jest.mocked(isTestNet); const mockUseTokensData = jest.mocked(useTokensData); const mockBuildEvmCaip19AssetId = jest.mocked(buildEvmCaip19AssetId); +const mockFormatFiat = jest.fn(); + const mockAssets = { '0x1': [ { chainId: '0x1', + address: '0xtoken1', accountType: 'eip155:1/erc20:0xtoken1', + balance: '100.50', fiat: { balance: '100.50' }, rawBalance: '0x1234', symbol: 'TOKEN1', }, { chainId: '0x1', + address: '0xtoken2', accountType: 'eip155:1/erc20:0xtoken2', + balance: '0', fiat: { balance: '0' }, rawBalance: '0x0', symbol: 'TOKEN2', @@ -111,7 +116,9 @@ const mockAssets = { 'solana:mainnet': [ { chainId: 'solana:mainnet', + address: 'SolTokenPubkey1', accountType: 'solana:mainnet/spl:0xsoltoken1', + balance: '50.25', fiat: { balance: '50.25' }, rawBalance: '0x5678', symbol: 'SOLTOKEN1', @@ -119,16 +126,11 @@ const mockAssets = { ], }; -const mockFormatter = { - format: jest.fn(), -}; - describe('useAccountTokens', () => { beforeEach(() => { jest.clearAllMocks(); useTransactionAccountOverrideMock.mockReturnValue(undefined); - useTransactionPayCurrencyMock.mockReturnValue(undefined); // eslint-disable-next-line @typescript-eslint/no-explicit-any mockSelectAssetsBySelectedAccountGroup.mockReturnValue(mockAssets as any); @@ -150,10 +152,17 @@ describe('useAccountTokens', () => { return undefined; }); + mockFormatFiat.mockReturnValue('$100.50'); + useAssetFiatFormatterMock.mockReturnValue({ + format: mockFormatFiat, + fiatCurrency: 'USD', + }); + + useTokenFiatRatesMock.mockImplementation((requests) => + requests.map(() => 1), + ); + mockGetNetworkBadgeSource.mockReturnValue('network-badge-source'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - mockGetIntlNumberFormatter.mockReturnValue(mockFormatter as any); - mockFormatter.format.mockReturnValue('$100.50'); mockIsTestNet.mockReturnValue(false); mockUseTokensData.mockReturnValue({}); mockBuildEvmCaip19AssetId.mockImplementation( @@ -203,84 +212,61 @@ describe('useAccountTokens', () => { }); }); - it('formats balance in selected currency', () => { + it('formats balance in selected currency for EVM assets', () => { const { result } = renderHook(() => useAccountTokens()); - result.current.forEach((asset) => { + const evmRows = result.current.filter((a) => + (a.chainId as string).startsWith('0x'), + ); + expect(evmRows.length).toBeGreaterThan(0); + evmRows.forEach((asset) => { expect(asset.balanceInSelectedCurrency).toBe('$100.50'); }); }); - it('handles integer amounts without decimals', () => { - const integerAssets = { + it('passes each asset balance * fiat rate to the formatter', () => { + const balanceAssets = { '0x1': [ { chainId: '0x1', + address: '0xtoken1', accountType: 'eip155:1/erc20:0xtoken1', + balance: '100', fiat: { balance: '100' }, rawBalance: '0x1234', symbol: 'TOKEN1', }, - ], - }; - - mockSelectAssetsBySelectedAccountGroup.mockReturnValue( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - integerAssets as any, - ); - mockUseSelector.mockImplementation((selector) => { - if (selector === selectAssetsBySelectedAccountGroup) { - return integerAssets; - } - if (selector === selectCurrentCurrency) { - return 'USD'; - } - return undefined; - }); - - renderHook(() => useAccountTokens()); - - expect(mockGetIntlNumberFormatter).toHaveBeenCalledWith('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 0, - }); - }); - - it('handles decimal amounts with fraction digits', () => { - const decimalAssets = { - '0x1': [ { chainId: '0x1', - accountType: 'eip155:1/erc20:0xtoken1', + address: '0xtoken2', + accountType: 'eip155:1/erc20:0xtoken2', + balance: '50', fiat: { balance: '100.50' }, - rawBalance: '0x1234', - symbol: 'TOKEN1', + rawBalance: '0x5678', + symbol: 'TOKEN2', }, ], }; mockSelectAssetsBySelectedAccountGroup.mockReturnValue( // eslint-disable-next-line @typescript-eslint/no-explicit-any - decimalAssets as any, + balanceAssets as any, ); mockUseSelector.mockImplementation((selector) => { if (selector === selectAssetsBySelectedAccountGroup) { - return decimalAssets; - } - if (selector === selectCurrentCurrency) { - return 'USD'; + return balanceAssets; } return undefined; }); + useTokenFiatRatesMock.mockReturnValue([2, 3]); renderHook(() => useAccountTokens()); - expect(mockGetIntlNumberFormatter).toHaveBeenCalledWith('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 2, - }); + const calls = mockFormatFiat.mock.calls.map((args) => + args[0]?.toString(), + ); + expect(calls).toContain('200'); + expect(calls).toContain('150'); }); }); @@ -339,7 +325,9 @@ describe('useAccountTokens', () => { '0x1': [ { chainId: '0x1', + address: '0xtoken1', accountType: 'eip155:1/erc20:0xtoken1', + balance: '10', fiat: { balance: '10' }, rawBalance: '0x1234', symbol: 'MAINNET_TOKEN', @@ -348,7 +336,9 @@ describe('useAccountTokens', () => { '0xaa36a7': [ { chainId: '0xaa36a7', + address: '0xnative', accountType: 'eip155:11155111/slip44:60', + balance: '1000', fiat: { balance: '1000' }, rawBalance: '0x5678', symbol: 'SepoliaETH', @@ -987,47 +977,207 @@ describe('useAccountTokens', () => { }); }); - describe('pay currency', () => { - it('uses preferred currency when useTransactionPayCurrency returns undefined', () => { - useTransactionPayCurrencyMock.mockReturnValue(undefined); + describe('fiat formatter delegation', () => { + it('sets balanceInSelectedCurrency to the formatter output for EVM assets', () => { + mockFormatFiat.mockReturnValue('$110.00'); + + const { result } = renderHook(() => useAccountTokens()); + + const evmRows = result.current.filter((a) => + (a.chainId as string).startsWith('0x'), + ); + expect(evmRows.length).toBeGreaterThan(0); + evmRows.forEach((asset) => { + expect(asset.balanceInSelectedCurrency).toBe('$110.00'); + }); + }); + + it('formats non-EVM assets using their preferred-currency fiat balance', () => { + mockFormatFiat.mockImplementation((v) => + v === undefined ? undefined : `formatted:${String(v)}`, + ); + + const { result } = renderHook(() => useAccountTokens()); + + const nonEvm = result.current.find((a) => a.chainId === 'solana:mainnet'); + expect(nonEvm?.balanceInSelectedCurrency).toBe('formatted:50.25'); + }); + + it('does not use useTokenFiatRates output for non-EVM assets', () => { + useTokenFiatRatesMock.mockReturnValue([999]); + mockFormatFiat.mockImplementation((v) => + v === undefined ? undefined : `formatted:${String(v)}`, + ); + + const { result } = renderHook(() => useAccountTokens()); + + const nonEvm = result.current.find((a) => a.chainId === 'solana:mainnet'); + expect(nonEvm?.balanceInSelectedCurrency).toBe('formatted:50.25'); + }); + + it('propagates undefined from the formatter as a hidden fiat value', () => { + mockFormatFiat.mockReturnValue(undefined); + + const { result } = renderHook(() => useAccountTokens()); + + result.current.forEach((asset) => { + expect(asset.balanceInSelectedCurrency).toBeUndefined(); + }); + }); + + it('does not call the formatter for testnet assets when fiat is hidden', () => { + const testnetOnlyAssets = { + '0x5': [ + { + chainId: '0x5', + address: '0xtoken1', + accountType: 'eip155:5/erc20:0xtoken1', + balance: '100.50', + fiat: { balance: '100.50' }, + rawBalance: '0x1234', + symbol: 'TOKEN1', + }, + ], + }; + + mockIsTestNet.mockReturnValue(true); mockUseSelector.mockImplementation((selector) => { if (selector === selectAssetsBySelectedAccountGroup) { - return mockAssets; + return testnetOnlyAssets; } - if (selector === selectCurrentCurrency) { - return 'eur'; + if (selector === selectShowFiatInTestnets) { + return false; } return undefined; }); renderHook(() => useAccountTokens()); - expect(mockGetIntlNumberFormatter).toHaveBeenCalledWith('en-US', { - style: 'currency', - currency: 'eur', - minimumFractionDigits: 2, - }); + expect(mockFormatFiat).not.toHaveBeenCalled(); }); + }); + + describe('sort key follows displayed fiat', () => { + it('sorts by derived fiat (balance * rate), not asset.fiat.balance', () => { + const stablecoinLikeAssets = { + '0x1': [ + { + chainId: '0x1', + address: '0xstable', + accountType: 'eip155:1/erc20:0xstable', + balance: '1000', + fiat: { balance: '998' }, + rawBalance: '0x1234', + symbol: 'USDC', + }, + { + chainId: '0x1', + address: '0xvolatile', + accountType: 'eip155:1/erc20:0xvolatile', + balance: '10', + fiat: { balance: '999' }, + rawBalance: '0x5678', + symbol: 'VOL', + }, + ], + }; - it('uses USD from useTransactionPayCurrency over preferred currency', () => { - useTransactionPayCurrencyMock.mockReturnValue('USD'); + mockSelectAssetsBySelectedAccountGroup.mockReturnValue( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + stablecoinLikeAssets as any, + ); mockUseSelector.mockImplementation((selector) => { if (selector === selectAssetsBySelectedAccountGroup) { - return mockAssets; + return stablecoinLikeAssets; } - if (selector === selectCurrentCurrency) { - return 'eur'; + return undefined; + }); + // USDC rate = 1 (stablecoin bypass shape), VOL rate = 99.9. + // Derived fiat: USDC=1000*1=1000, VOL=10*99.9=999. + // asset.fiat.balance would sort VOL(999) above USDC(998). + // Correct sort by derived fiat puts USDC(1000) first. + useTokenFiatRatesMock.mockReturnValue([1, 99.9]); + + const { result } = renderHook(() => useAccountTokens()); + + expect(result.current[0].symbol).toBe('USDC'); + expect(result.current[1].symbol).toBe('VOL'); + }); + }); + + describe('zero balance with missing rate', () => { + it('renders $0 for an EVM zero-balance token even when useTokenFiatRates returns undefined', () => { + const zeroBalanceAssets = { + '0x1': [ + { + chainId: '0x1', + address: '0xtoken1', + accountType: 'eip155:1/erc20:0xtoken1', + balance: '0', + fiat: { balance: '0' }, + rawBalance: '0x0', + symbol: 'TOKEN1', + }, + ], + }; + + mockSelectAssetsBySelectedAccountGroup.mockReturnValue( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + zeroBalanceAssets as any, + ); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectAssetsBySelectedAccountGroup) { + return zeroBalanceAssets; } return undefined; }); + useTokenFiatRatesMock.mockReturnValue([undefined]); + mockFormatFiat.mockImplementation((v) => + v === undefined ? undefined : `formatted:${String(v)}`, + ); - renderHook(() => useAccountTokens()); + const { result } = renderHook(() => + useAccountTokens({ includeNoBalance: true }), + ); + + const token = result.current.find((a) => a.symbol === 'TOKEN1'); + expect(token?.balanceInSelectedCurrency).toBe('formatted:0'); + }); + + it('still hides fiat for a non-zero EVM balance when rate is missing', () => { + const nonZeroAssets = { + '0x1': [ + { + chainId: '0x1', + address: '0xtoken1', + accountType: 'eip155:1/erc20:0xtoken1', + balance: '10', + fiat: { balance: '10' }, + rawBalance: '0x1234', + symbol: 'TOKEN1', + }, + ], + }; - expect(mockGetIntlNumberFormatter).toHaveBeenCalledWith('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 2, + mockSelectAssetsBySelectedAccountGroup.mockReturnValue( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + nonZeroAssets as any, + ); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectAssetsBySelectedAccountGroup) { + return nonZeroAssets; + } + return undefined; }); + useTokenFiatRatesMock.mockReturnValue([undefined]); + mockFormatFiat.mockImplementation((v) => + v === undefined ? undefined : `formatted:${String(v)}`, + ); + + const { result } = renderHook(() => useAccountTokens()); + + const token = result.current.find((a) => a.symbol === 'TOKEN1'); + expect(token?.balanceInSelectedCurrency).toBeUndefined(); }); }); }); diff --git a/app/components/Views/confirmations/hooks/send/useAccountTokens.ts b/app/components/Views/confirmations/hooks/send/useAccountTokens.ts index 285b50d78284..9d3e9a33cc74 100644 --- a/app/components/Views/confirmations/hooks/send/useAccountTokens.ts +++ b/app/components/Views/confirmations/hooks/send/useAccountTokens.ts @@ -1,3 +1,4 @@ +import type { Asset } from '@metamask/assets-controllers'; import { useSelector } from 'react-redux'; import { useCallback, useMemo } from 'react'; import { BigNumber } from 'bignumber.js'; @@ -10,18 +11,19 @@ import { import { selectInternalAccountsById } from '../../../../../selectors/accountsController'; import { selectAccountToGroupMap } from '../../../../../selectors/multichainAccounts/accountTreeController'; import { isTestNet } from '../../../../../util/networks'; -import Logger from '../../../../../util/Logger'; -import { selectCurrentCurrency } from '../../../../../selectors/currencyRateController'; import { selectShowFiatInTestnets } from '../../../../../selectors/settings'; -import I18n from '../../../../../../locales/i18n'; -import { getIntlNumberFormatter } from '../../../../../util/intl'; import { getNetworkBadgeSource } from '../../utils/network'; import { AssetType, TokenStandard } from '../../types/token'; import { useTokensData } from '../../../../hooks/useTokensData/useTokensData'; import { buildEvmCaip19AssetId } from '../../../../../util/multichain/buildEvmCaip19AssetId'; import type { RootState } from '../../../../../reducers'; import { useTransactionAccountOverride } from '../transactions/useTransactionAccountOverride'; -import { useTransactionPayCurrency } from '../pay/useTransactionPayCurrency'; +import { useAssetFiatFormatter } from '../pay/useAssetFiatFormatter'; +import { + TokenFiatRateRequest, + useTokenFiatRates, +} from '../tokens/useTokenFiatRates'; +import { isNonEvmChainId } from '../../../../../core/Multichain/utils'; import { isNetworkTestnet } from './useNetworkFilter'; export interface EnrichTokenRequest { @@ -31,30 +33,6 @@ export interface EnrichTokenRequest { const EMPTY_REQUESTS: EnrichTokenRequest[] = []; -function useAccountGroupAssets(accountAddress?: string | null) { - const internalAccountsById = useSelector(selectInternalAccountsById); - const accountToGroupMap = useSelector(selectAccountToGroupMap); - - const accountGroupId = useMemo(() => { - if (!accountAddress) return undefined; - const internalAccountId = Object.keys(internalAccountsById).find( - (id) => - internalAccountsById[id].address.toLowerCase() === - accountAddress.toLowerCase(), - ); - if (!internalAccountId) return undefined; - return accountToGroupMap[internalAccountId]?.id; - }, [accountAddress, internalAccountsById, accountToGroupMap]); - - const selectOverrideAssets = useCallback( - (state: RootState) => selectAssetsByAccountGroupId(state, accountGroupId), - [accountGroupId], - ); - - const overrideAssets = useSelector(selectOverrideAssets); - return accountGroupId ? overrideAssets : undefined; -} - export function useAccountTokens({ includeNoBalance = false, tokenFilter, @@ -75,25 +53,12 @@ export function useAccountTokens({ [accountOverride, accountAssets, globalAssets], ); - const preferredCurrency = useSelector(selectCurrentCurrency); - const payCurrency = useTransactionPayCurrency(); - const fiatCurrency = payCurrency ?? preferredCurrency; const showFiatOnTestnets = useSelector(selectShowFiatInTestnets); + const { format: formatFiat, fiatCurrency } = useAssetFiatFormatter(); - const assetIds = useMemo( - () => - enrichTokenRequests.map((req) => - buildEvmCaip19AssetId(req.address, req.chainId), - ), - [enrichTokenRequests], - ); - - const tokensByAssetId = useTokensData(assetIds); - - return useMemo(() => { + const assetsWithBalance = useMemo(() => { const flatAssets = Object.values(assets).flat(); - - const assetsWithBalance = flatAssets.filter((asset) => { + return flatAssets.filter((asset) => { if (tokenFilter) { const address = asset.assetId; if ( @@ -121,7 +86,29 @@ export function useAccountTokens({ return haveBalance || isTestNetAsset; }); + }, [assets, includeNoBalance, tokenFilter]); + + const fiatRateRequests = useMemo( + () => + assetsWithBalance + .filter(isEvmRateEligible) + .map((asset) => buildFiatRateRequest(asset, fiatCurrency)), + [assetsWithBalance, fiatCurrency], + ); + + const fiatRates = useTokenFiatRates(fiatRateRequests); + + const assetIds = useMemo( + () => + enrichTokenRequests.map((req) => + buildEvmCaip19AssetId(req.address, req.chainId), + ), + [enrichTokenRequests], + ); + const tokensByAssetId = useTokensData(assetIds); + + return useMemo(() => { // "Show conversion on test networks" setting: when disabled, testnet // assets must not display fiat values nor be ranked by them. const isFiatHidden = (chainId?: string) => @@ -129,45 +116,34 @@ export function useAccountTokens({ Boolean(chainId) && isNetworkTestnet(chainId as string); + const sortableFiatByAsset = new WeakMap(); + let evmIndex = 0; const processedAssets = assetsWithBalance.map((asset) => { - const fiatAmount = new BigNumber(asset.fiat?.balance || 0); - const hasDecimals = !fiatAmount.isInteger(); - - let balanceInSelectedCurrency: string | undefined; - if (isFiatHidden(asset.chainId)) { - balanceInSelectedCurrency = undefined; - } else { - try { - balanceInSelectedCurrency = getIntlNumberFormatter(I18n.locale, { - style: 'currency', - currency: fiatCurrency, - minimumFractionDigits: hasDecimals ? 2 : 0, - }).format(fiatAmount.toFixed() as unknown as number); - } catch (error) { - Logger.error(error as Error); - balanceInSelectedCurrency = `${fiatAmount.toFixed()} ${fiatCurrency}`; - } - } + const rate = isEvmRateEligible(asset) ? fiatRates[evmIndex++] : undefined; + const fiatAmount = deriveAssetFiat( + asset, + rate, + isFiatHidden(asset.chainId), + ); - return { + const balanceInSelectedCurrency = + fiatAmount !== undefined ? formatFiat(fiatAmount) : undefined; + + const processed = { ...asset, networkBadgeSource: getNetworkBadgeSource(asset.chainId as Hex), balanceInSelectedCurrency, standard: TokenStandard.ERC20 as const, } as AssetType; + + if (fiatAmount !== undefined) { + sortableFiatByAsset.set(processed, fiatAmount); + } + return processed; }); if (enrichTokenRequests.length > 0) { - let zeroFiat: string; - try { - zeroFiat = getIntlNumberFormatter(I18n.locale, { - style: 'currency', - currency: fiatCurrency, - minimumFractionDigits: 0, - }).format(0); - } catch { - zeroFiat = `0 ${fiatCurrency}`; - } + const zeroFiat = formatFiat(0) ?? ''; const existingKeys = new Set( processedAssets.map( @@ -204,22 +180,107 @@ export function useAccountTokens({ } } - const sortableFiatBalance = (asset: AssetType) => - isFiatHidden(asset.chainId) - ? new BigNumber(0) - : new BigNumber(asset.fiat?.balance || 0); + // Sort by the same fiat we display. Falls back to the assets-controller + // preferred-currency balance for tokens that have no derived fiat (e.g. + // enrichment placeholders added below with `zeroFiat`), so unknown + // tokens sink to the bottom. + const sortableFiatBalance = (asset: AssetType) => { + if (isFiatHidden(asset.chainId)) return new BigNumber(0); + const derived = sortableFiatByAsset.get(asset); + if (derived !== undefined) return derived; + return new BigNumber(asset.fiat?.balance || 0); + }; return processedAssets.sort( (a, b) => sortableFiatBalance(b).comparedTo(sortableFiatBalance(a)) || 0, ); }, [ - assets, - includeNoBalance, - fiatCurrency, + assetsWithBalance, showFiatOnTestnets, - tokenFilter, enrichTokenRequests, assetIds, tokensByAssetId, + formatFiat, + fiatRates, ]) as unknown as AssetType[]; } + +function useAccountGroupAssets(accountAddress?: string | null) { + const internalAccountsById = useSelector(selectInternalAccountsById); + const accountToGroupMap = useSelector(selectAccountToGroupMap); + + const accountGroupId = useMemo(() => { + if (!accountAddress) return undefined; + const internalAccountId = Object.keys(internalAccountsById).find( + (id) => + internalAccountsById[id].address.toLowerCase() === + accountAddress.toLowerCase(), + ); + if (!internalAccountId) return undefined; + return accountToGroupMap[internalAccountId]?.id; + }, [accountAddress, internalAccountsById, accountToGroupMap]); + + const selectOverrideAssets = useCallback( + (state: RootState) => selectAssetsByAccountGroupId(state, accountGroupId), + [accountGroupId], + ); + + const overrideAssets = useSelector(selectOverrideAssets); + return accountGroupId ? overrideAssets : undefined; +} + +/** + * Whether the asset is EVM-scoped with a hex address, i.e. eligible for + * `useTokenFiatRates` (which crashes on non-hex addresses like Solana). + * Non-EVM assets fall back to `asset.fiat.balance` in the render loop. + * + * This predicate is the single source of truth for the paired walks over + * `assetsWithBalance` — request build and rate consumption — so they cannot + * drift out of lockstep. + */ +function isEvmRateEligible(asset: Asset): boolean { + return ( + Boolean(asset.chainId) && + !isNonEvmChainId(asset.chainId) && + 'address' in asset && + Boolean(asset.address) + ); +} + +function buildFiatRateRequest( + asset: Asset, + currency: string, +): TokenFiatRateRequest { + return { + address: (asset as { address: Hex }).address, + chainId: asset.chainId as Hex, + currency: currency.toLowerCase(), + }; +} + +/** + * Fiat amount to display for a single asset, or `undefined` to hide. + * + * - Testnet-hidden → undefined. + * - EVM with rate → `balance * rate` (picks up stablecoin bypass). + * - EVM zero balance without rate → `0` (currency-invariant, avoids + * hiding `$0` rows when market data hasn't loaded). + * - EVM non-zero without rate → undefined (can't safely render). + * - Non-EVM → the assets-controller's preferred-currency `fiat.balance`. + */ +function deriveAssetFiat( + asset: Asset, + rate: number | undefined, + isFiatHidden: boolean, +): BigNumber | undefined { + if (isFiatHidden) return undefined; + + if (isEvmRateEligible(asset)) { + const balance = asset.balance ? new BigNumber(asset.balance) : undefined; + if (rate !== undefined && balance) return balance.multipliedBy(rate); + if (balance?.isZero()) return new BigNumber(0); + return undefined; + } + + return asset.fiat?.balance ? new BigNumber(asset.fiat.balance) : undefined; +}