diff --git a/shared/lib/asset-utils.test.ts b/shared/lib/asset-utils.test.ts new file mode 100644 index 000000000000..24f7fcaf3b6c --- /dev/null +++ b/shared/lib/asset-utils.test.ts @@ -0,0 +1,297 @@ +import { + CaipAssetType, + CaipAssetTypeStruct, + CaipChainId, + Hex, +} from '@metamask/utils'; +import { toEvmCaipChainId } from '@metamask/multichain-network-controller'; +import { MultichainNetwork } from '@metamask/multichain-transactions-controller'; +import { toHex } from '@metamask/controller-utils'; +import { MINUTE } from '../constants/time'; +import { MultichainNetworks } from '../constants/multichain/networks'; +import fetchWithCache from './fetch-with-cache'; +import { getAssetImageUrl, fetchAssetMetadata, toAssetId } from './asset-utils'; + +jest.mock('./fetch-with-cache'); +jest.mock('@metamask/multichain-network-controller'); +jest.mock('@metamask/controller-utils'); + +describe('asset-utils', () => { + const STATIC_METAMASK_BASE_URL = 'https://static.cx.metamask.io'; + const TOKEN_API_V3_BASE_URL = 'https://tokens.api.cx.metamask.io/v3'; + + describe('toAssetId', () => { + it('should return the same asset ID if input is already a CAIP asset type', () => { + const caipAssetId = CaipAssetTypeStruct.create('eip155:1/erc20:0x123'); + const chainId = 'eip155:1' as CaipChainId; + + const result = toAssetId(caipAssetId, chainId); + expect(result).toBe(caipAssetId); + }); + + it('should create Solana token asset ID correctly', () => { + const address = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + const chainId = MultichainNetwork.Solana as CaipChainId; + + const result = toAssetId(address, chainId); + expect(result).toBe(`${MultichainNetwork.Solana}/token:${address}`); + }); + + it('should create EVM token asset ID correctly', () => { + const address = '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984'; + const chainId = 'eip155:1' as CaipChainId; + + const result = toAssetId(address, chainId); + expect(result).toBe(`eip155:1/erc20:${address}`); + }); + + it('should return undefined for non-hex address on EVM chains', () => { + const address = 'not-a-hex-address'; + const chainId = 'eip155:1' as CaipChainId; + + const result = toAssetId(address, chainId); + expect(result).toBeUndefined(); + }); + + it('should handle different EVM chain IDs', () => { + const address = '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984'; + const chainId = 'eip155:137' as CaipChainId; + + const result = toAssetId(address, chainId); + expect(result).toBe(`eip155:137/erc20:${address}`); + }); + + it('should handle checksummed addresses', () => { + const address = '0x1F9840a85d5aF5bf1D1762F925BDADdC4201F984'; + const chainId = 'eip155:1' as CaipChainId; + + const result = toAssetId(address, chainId); + expect(result).toBe(`eip155:1/erc20:${address}`); + }); + }); + + describe('getAssetImageUrl', () => { + it('should return correct image URL for a CAIP asset ID', () => { + const assetId = 'eip155:1/erc20:0x123' as CaipAssetType; + const expectedUrl = `${STATIC_METAMASK_BASE_URL}/api/v2/tokenIcons/assets/eip155/1/erc20/0x123.png`; + + expect(getAssetImageUrl(assetId, 'eip155:1')).toBe(expectedUrl); + }); + + it('should return correct image URL for non-hex CAIP asset ID', () => { + const assetId = + `${MultichainNetworks.SOLANA}/token:aBCD` as CaipAssetType; + const expectedUrl = `${STATIC_METAMASK_BASE_URL}/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token/aBCD.png`; + + expect(getAssetImageUrl(assetId, 'eip155:1')).toBe(expectedUrl); + }); + + it('should handle asset IDs with multiple colons', () => { + const assetId = 'test:chain:1/token:0x123' as CaipAssetType; + + expect(getAssetImageUrl(assetId, 'eip155:1')).toBe(undefined); + }); + }); + + describe('fetchAssetMetadata', () => { + const mockAddress = '0x123' as Hex; + const mockChainId = 'eip155:1' as CaipChainId; + const mockHexChainId = '0x1' as Hex; + const mockAssetId = 'eip155:1/erc20:0x123' as CaipAssetType; + + beforeEach(() => { + jest.clearAllMocks(); + (toEvmCaipChainId as jest.Mock).mockReturnValue(mockChainId); + (toHex as jest.Mock).mockImplementation((val) => val as Hex); + }); + + it('should fetch EVM token metadata successfully', async () => { + const mockMetadata = { + assetId: mockAssetId, + symbol: 'TEST', + name: 'Test Token', + decimals: 18, + }; + + (fetchWithCache as jest.Mock).mockResolvedValueOnce([mockMetadata]); + + const result = await fetchAssetMetadata(mockAddress, mockHexChainId); + + expect(fetchWithCache).toHaveBeenCalledWith({ + url: `${TOKEN_API_V3_BASE_URL}/assets?assetIds=${mockAssetId}`, + fetchOptions: { + method: 'GET', + headers: { 'X-Client-Id': 'extension' }, + }, + cacheOptions: { + cacheRefreshTime: MINUTE, + }, + functionName: 'fetchAssetMetadata', + }); + + expect(result).toStrictEqual({ + symbol: 'TEST', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0x123.png', + assetId: mockAssetId, + address: mockAddress, + chainId: mockHexChainId, + }); + }); + + it('should fetch Solana token metadata successfully', async () => { + const solanaChainId = MultichainNetwork.Solana; + const solanaAddress = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + const solanaAssetId = `${solanaChainId}/token:${solanaAddress}`; + + const mockMetadata = { + assetId: solanaAssetId, + symbol: 'SOL', + name: 'Solana Token', + decimals: 9, + }; + + (fetchWithCache as jest.Mock).mockResolvedValueOnce([mockMetadata]); + + const result = await fetchAssetMetadata(solanaAddress, solanaChainId); + + expect(result).toStrictEqual({ + symbol: 'SOL', + decimals: 9, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v.png', + assetId: solanaAssetId, + address: solanaAddress, + chainId: solanaChainId, + }); + }); + + it('should handle CAIP chain IDs', async () => { + const mockMetadata = { + assetId: mockAssetId, + symbol: 'TEST', + name: 'Test Token', + decimals: 18, + }; + + (fetchWithCache as jest.Mock).mockResolvedValueOnce([mockMetadata]); + + const result = await fetchAssetMetadata(mockAddress, mockChainId); + + expect(toEvmCaipChainId).not.toHaveBeenCalled(); + + expect(fetchWithCache).toHaveBeenCalledWith({ + url: `${TOKEN_API_V3_BASE_URL}/assets?assetIds=${mockAssetId}`, + fetchOptions: { + method: 'GET', + headers: { 'X-Client-Id': 'extension' }, + }, + cacheOptions: { + cacheRefreshTime: MINUTE, + }, + functionName: 'fetchAssetMetadata', + }); + + expect(result).toStrictEqual({ + symbol: 'TEST', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0x123.png', + assetId: mockAssetId, + address: mockAddress, + chainId: mockHexChainId, + }); + }); + + it('should handle hex chain IDs', async () => { + const mockMetadata = { + assetId: mockAssetId, + symbol: 'TEST', + name: 'Test Token', + decimals: 18, + }; + + (fetchWithCache as jest.Mock).mockResolvedValueOnce([mockMetadata]); + + const result = await fetchAssetMetadata(mockAddress, mockHexChainId); + + expect(toEvmCaipChainId).toHaveBeenCalledWith(mockHexChainId); + + expect(fetchWithCache).toHaveBeenCalledWith({ + url: `${TOKEN_API_V3_BASE_URL}/assets?assetIds=${mockAssetId}`, + fetchOptions: { + method: 'GET', + headers: { 'X-Client-Id': 'extension' }, + }, + cacheOptions: { + cacheRefreshTime: MINUTE, + }, + functionName: 'fetchAssetMetadata', + }); + + expect(result).toStrictEqual({ + symbol: 'TEST', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0x123.png', + assetId: mockAssetId, + address: mockAddress, + chainId: mockHexChainId, + }); + }); + + it('should return undefined when API call fails', async () => { + (fetchWithCache as jest.Mock).mockRejectedValueOnce( + new Error('API Error'), + ); + + const result = await fetchAssetMetadata(mockAddress, mockHexChainId); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when metadata processing fails', async () => { + (fetchWithCache as jest.Mock).mockResolvedValueOnce([null]); + + const result = await fetchAssetMetadata(mockAddress, mockHexChainId); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when EVM address is not valid', async () => { + const mockMetadata = { + assetId: 'hjk', + symbol: 'TEST', + name: 'Test Token', + decimals: 18, + }; + + (fetchWithCache as jest.Mock).mockResolvedValueOnce([mockMetadata]); + + const result = await fetchAssetMetadata(mockAddress, mockHexChainId); + + expect(fetchWithCache).toHaveBeenCalledWith({ + url: `${TOKEN_API_V3_BASE_URL}/assets?assetIds=${mockAssetId}`, + fetchOptions: { + method: 'GET', + headers: { 'X-Client-Id': 'extension' }, + }, + cacheOptions: { + cacheRefreshTime: MINUTE, + }, + functionName: 'fetchAssetMetadata', + }); + + expect(result).toStrictEqual({ + symbol: 'TEST', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0x123.png', + assetId: mockAssetId, + address: mockAddress, + chainId: mockHexChainId, + }); + }); + }); +}); diff --git a/shared/lib/asset-utils.ts b/shared/lib/asset-utils.ts new file mode 100644 index 000000000000..1623e497f617 --- /dev/null +++ b/shared/lib/asset-utils.ts @@ -0,0 +1,135 @@ +import { + CaipAssetType, + parseCaipChainId, + CaipAssetTypeStruct, + CaipChainId, + Hex, + isCaipAssetType, + isCaipChainId, + isStrictHexString, + parseCaipAssetType, +} from '@metamask/utils'; + +import { toEvmCaipChainId } from '@metamask/multichain-network-controller'; +import { MultichainNetwork } from '@metamask/multichain-transactions-controller'; +import { toHex } from '@metamask/controller-utils'; +import { MINUTE } from '../constants/time'; +import { decimalToPrefixedHex } from '../modules/conversion.utils'; +import fetchWithCache from './fetch-with-cache'; + +const TOKEN_API_V3_BASE_URL = 'https://tokens.api.cx.metamask.io/v3'; +const STATIC_METAMASK_BASE_URL = 'https://static.cx.metamask.io'; + +export const toAssetId = ( + address: Hex | CaipAssetType | string, + chainId: CaipChainId, +): CaipAssetType | undefined => { + if (isCaipAssetType(address)) { + return address; + } else if (chainId === MultichainNetwork.Solana) { + return CaipAssetTypeStruct.create(`${chainId}/token:${address}`); + } + // EVM assets + if (!isStrictHexString(address)) { + return undefined; + } + return CaipAssetTypeStruct.create(`${chainId}/erc20:${address}`); +}; + +/** + * Returns the image url for a caip-formatted asset + * + * @param assetId - The hex address or caip-formatted asset id + * @param chainId - The chainId in caip or hex format + * @returns The image url for the asset + */ +export const getAssetImageUrl = ( + assetId: CaipAssetType | string, + chainId: CaipChainId | Hex, +) => { + const chainIdInCaip = isCaipChainId(chainId) + ? chainId + : toEvmCaipChainId(chainId); + + const assetIdInCaip = toAssetId(assetId, chainIdInCaip); + if (!assetIdInCaip) { + return undefined; + } + + return `${STATIC_METAMASK_BASE_URL}/api/v2/tokenIcons/assets/${assetIdInCaip.replaceAll( + ':', + '/', + )}.png`; +}; + +type AssetMetadata = { + assetId: string; + symbol: string; + name: string; + decimals: number; +}; + +/** + * Fetches the metadata for a token + * + * @param address - The address of the token + * @param chainId - The chainId of the token + * @param abortSignal - The abort signal for the fetch request + * @returns The metadata for the token + */ +export const fetchAssetMetadata = async ( + address: string | CaipAssetType | Hex, + chainId: Hex | CaipChainId, + abortSignal?: AbortSignal, +) => { + const chainIdInCaip = isCaipChainId(chainId) + ? chainId + : toEvmCaipChainId(chainId); + + const assetId = toAssetId(address, chainIdInCaip); + + if (!assetId) { + return undefined; + } + + try { + const [assetMetadata]: AssetMetadata[] = await fetchWithCache({ + url: `${TOKEN_API_V3_BASE_URL}/assets?assetIds=${assetId}`, + fetchOptions: { + method: 'GET', + headers: { 'X-Client-Id': 'extension' }, + signal: abortSignal, + }, + cacheOptions: { + cacheRefreshTime: MINUTE, + }, + functionName: 'fetchAssetMetadata', + }); + + const commonFields = { + symbol: assetMetadata.symbol, + decimals: assetMetadata.decimals, + image: getAssetImageUrl(assetId, chainIdInCaip), + assetId, + }; + + if (chainId === MultichainNetwork.Solana && assetId) { + const { assetReference } = parseCaipAssetType(assetId); + return { + ...commonFields, + address: assetReference, + assetId, + chainId, + }; + } + + const { reference } = parseCaipChainId(chainIdInCaip); + return { + ...commonFields, + address: toHex(address), + chainId: decimalToPrefixedHex(reference), + }; + } catch (error) { + return undefined; + } +}; diff --git a/test/e2e/tests/multichain/asset-picker-send.spec.ts b/test/e2e/tests/multichain/asset-picker-send.spec.ts index 66f651588e44..e274b6192cc0 100644 --- a/test/e2e/tests/multichain/asset-picker-send.spec.ts +++ b/test/e2e/tests/multichain/asset-picker-send.spec.ts @@ -85,6 +85,10 @@ describe('AssetPickerSendFlow', function () { ); await searchInputField.sendKeys('CHZ'); + await driver.elementCountBecomesN( + '[data-testid="multichain-token-list-button"]', + 1, + ); // check that CHZ is disabled const [tkn] = await driver.findElements( '[data-testid="multichain-token-list-button"]', diff --git a/test/e2e/tests/swap-send/swap-send-test-utils.ts b/test/e2e/tests/swap-send/swap-send-test-utils.ts index 09efdf26951a..4c10a444afba 100644 --- a/test/e2e/tests/swap-send/swap-send-test-utils.ts +++ b/test/e2e/tests/swap-send/swap-send-test-utils.ts @@ -56,6 +56,11 @@ export class SwapSendPage { ); await f.press(i); } + // Search input is debounced, so we need to wait for the token list to update + await this.driver.elementCountBecomesN( + '[data-testid="multichain-token-list-item"]', + 1, + ); // Verify that only matching tokens are listed assert.equal( ( diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/AssetList.tsx b/ui/components/multichain/asset-picker-amount/asset-picker-modal/AssetList.tsx index 6515bbfe71a6..4f78bb055c91 100644 --- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/AssetList.tsx +++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/AssetList.tsx @@ -5,6 +5,7 @@ import { NetworkConfiguration, } from '@metamask/network-controller'; import type { CaipChainId } from '@metamask/utils'; +import { useSelector } from 'react-redux'; import { useCurrencyDisplay } from '../../../../hooks/useCurrencyDisplay'; import { AssetType } from '../../../../../shared/constants/transaction'; import { Box } from '../../../component-library'; @@ -80,7 +81,7 @@ export default function AssetList({ const balanceValue = useMultichainSelector( getMultichainSelectedAccountCachedBalance, ); - const currentCurrency = useMultichainSelector(getMultichainCurrentCurrency); + const currentCurrency = useSelector(getMultichainCurrentCurrency); const [primaryCurrencyValue] = useCurrencyDisplay(balanceValue, { currency: currentCurrency, diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/__snapshots__/asset-picker-modal.test.tsx.snap b/ui/components/multichain/asset-picker-amount/asset-picker-modal/__snapshots__/asset-picker-modal.test.tsx.snap new file mode 100644 index 000000000000..750411d99c50 --- /dev/null +++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/__snapshots__/asset-picker-modal.test.tsx.snap @@ -0,0 +1,701 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AssetPickerModal token filtering should fetch metadata for unlisted tokens 1`] = ` +[ + "0x1f9840a85d5af5bf1d1762f925bdaddc4201f123", + true, + { + "current": null, + }, + "0xa", +] +`; + +exports[`AssetPickerModal token filtering should fetch metadata for unlisted tokens 2`] = ` +[ + { + "asset": undefined, + "assetItemProps": { + "isTitleHidden": false, + "isTitleNetworkName": true, + }, + "handleAssetChange": [MockFunction], + "isTokenDisabled": [Function], + "isTokenListLoading": false, + "network": { + "chainId": "0xa", + "name": "Optimism", + }, + "tokenList": [ + { + "address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f123", + "chainId": "0x1", + "decimals": 18, + "image": "https://example.com/image.png", + "symbol": "UNI", + }, + ], + }, +] +`; + +exports[`AssetPickerModal token filtering should filter tokens by address 1`] = ` +[ + { + "asset": undefined, + "assetItemProps": { + "isTitleHidden": false, + "isTitleNetworkName": true, + }, + "handleAssetChange": [MockFunction], + "isTokenDisabled": [Function], + "isTokenListLoading": false, + "network": { + "chainId": "0xa", + "name": "Optimism", + }, + "tokenList": [ + { + "address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", + "balance": "0x56bc75e2d63100000", + "chainId": "0x1", + "decimals": 18, + "isNative": false, + "symbol": "UNI", + "type": "TOKEN", + }, + ], + }, +] +`; + +exports[`AssetPickerModal token filtering should filter tokens by address 2`] = ` +[ + { + "asset": undefined, + "assetItemProps": { + "isTitleHidden": false, + "isTitleNetworkName": true, + }, + "handleAssetChange": [MockFunction], + "isTokenDisabled": [Function], + "isTokenListLoading": false, + "network": { + "chainId": "0xa", + "name": "Optimism", + }, + "tokenList": [ + { + "address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", + "balance": "0x56bc75e2d63100000", + "chainId": "0x1", + "decimals": 18, + "isNative": false, + "symbol": "UNI", + "type": "TOKEN", + }, + ], + }, +] +`; + +exports[`AssetPickerModal token filtering should filter tokens by chain when multichain network is selected 1`] = ` +[ + { + "asset": undefined, + "assetItemProps": { + "isTitleHidden": true, + "isTitleNetworkName": true, + }, + "handleAssetChange": [MockFunction], + "isTokenDisabled": [Function], + "isTokenListLoading": false, + "network": { + "chainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "name": "Solana", + }, + "tokenList": [ + { + "address": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "balance": "0x2faf080", + "chainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "decimals": 6, + "isNative": false, + "symbol": "USDC", + "type": "TOKEN", + }, + { + "address": "", + "balance": "1000", + "chainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "decimals": 18, + "image": {}, + "string": undefined, + "symbol": "ETH", + "type": "NATIVE", + }, + ], + }, +] +`; + +exports[`AssetPickerModal token filtering should filter tokens by symbol 1`] = ` +[ + { + "asset": undefined, + "assetItemProps": { + "isTitleHidden": false, + "isTitleNetworkName": true, + }, + "handleAssetChange": [MockFunction], + "isTokenDisabled": [Function], + "isTokenListLoading": false, + "network": { + "chainId": "0xa", + "name": "Optimism", + }, + "tokenList": [ + { + "address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", + "balance": "0x56bc75e2d63100000", + "chainId": "0x1", + "decimals": 18, + "isNative": false, + "symbol": "UNI", + "type": "TOKEN", + }, + ], + }, +] +`; + +exports[`AssetPickerModal token filtering should handle case-insensitive search 1`] = ` +[ + { + "asset": undefined, + "assetItemProps": { + "isTitleHidden": false, + "isTitleNetworkName": true, + }, + "handleAssetChange": [MockFunction], + "isTokenDisabled": [Function], + "isTokenListLoading": false, + "network": { + "chainId": "0xa", + "name": "Optimism", + }, + "tokenList": [ + { + "address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", + "balance": "0x56bc75e2d63100000", + "chainId": "0x1", + "decimals": 18, + "isNative": false, + "symbol": "UNI", + "type": "TOKEN", + }, + ], + }, +] +`; + +exports[`AssetPickerModal token filtering should handle case-insensitive search 2`] = ` +[ + { + "asset": undefined, + "assetItemProps": { + "isTitleHidden": false, + "isTitleNetworkName": true, + }, + "handleAssetChange": [MockFunction], + "isTokenDisabled": [Function], + "isTokenListLoading": false, + "network": { + "chainId": "0xa", + "name": "Optimism", + }, + "tokenList": [ + { + "address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", + "balance": "0x56bc75e2d63100000", + "chainId": "0x1", + "decimals": 18, + "isNative": false, + "symbol": "UNI", + "type": "TOKEN", + }, + ], + }, +] +`; + +exports[`AssetPickerModal token filtering should only show tokens with balances in send mode 1`] = ` +[ + { + "asset": undefined, + "assetItemProps": { + "isTitleHidden": false, + "isTitleNetworkName": false, + }, + "handleAssetChange": [MockFunction], + "isTokenDisabled": [Function], + "isTokenListLoading": false, + "network": { + "chainId": "0xa", + "name": "Optimism", + }, + "tokenList": [ + { + "address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f985", + "balance": "0x989680", + "chainId": "0xa", + "decimals": 6, + "isNative": false, + "symbol": "USDC", + "type": "TOKEN", + }, + { + "address": "", + "balance": "1000", + "chainId": "0xa", + "decimals": 18, + "image": {}, + "string": undefined, + "symbol": "ETH", + "type": "NATIVE", + }, + ], + }, +] +`; + +exports[`AssetPickerModal token filtering should only show tokens with balances in send mode 2`] = ` +[ + { + "asset": undefined, + "assetItemProps": { + "isTitleHidden": false, + "isTitleNetworkName": false, + }, + "handleAssetChange": [MockFunction], + "isTokenDisabled": [Function], + "isTokenListLoading": false, + "network": { + "chainId": "0xa", + "name": "Optimism", + }, + "tokenList": [ + { + "address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f985", + "balance": "0x989680", + "chainId": "0xa", + "decimals": 6, + "isNative": false, + "symbol": "USDC", + "type": "TOKEN", + }, + { + "address": "", + "balance": "1000", + "chainId": "0xa", + "decimals": 18, + "image": {}, + "string": undefined, + "symbol": "ETH", + "type": "NATIVE", + }, + ], + }, +] +`; + +exports[`AssetPickerModal token filtering should render all tokens from multiple chains 1`] = ` +[ + { + "asset": undefined, + "assetItemProps": { + "isTitleHidden": false, + "isTitleNetworkName": true, + }, + "handleAssetChange": [MockFunction], + "isTokenDisabled": [Function], + "isTokenListLoading": false, + "network": { + "chainId": "0xa", + "name": "Optimism", + }, + "tokenList": [ + { + "address": "", + "balance": "1.5", + "chainId": "0x1", + "decimals": 18, + "image": "./images/eth_logo.svg", + "isNative": true, + "symbol": "ETH", + "type": "NATIVE", + }, + { + "address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", + "balance": "0x56bc75e2d63100000", + "chainId": "0x1", + "decimals": 18, + "isNative": false, + "symbol": "UNI", + "type": "TOKEN", + }, + { + "address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f985", + "balance": "0x989680", + "chainId": "0xa", + "decimals": 6, + "isNative": false, + "symbol": "USDC", + "type": "TOKEN", + }, + { + "address": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "balance": "0x2faf080", + "chainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "decimals": 6, + "isNative": false, + "symbol": "USDC", + "type": "TOKEN", + }, + { + "address": "", + "balance": "1000", + "chainId": "0xa", + "decimals": 18, + "image": {}, + "string": undefined, + "symbol": "ETH", + "type": "NATIVE", + }, + ], + }, +] +`; + +exports[`AssetPickerModal token filtering should render all tokens from single chain 1`] = ` +[ + { + "asset": undefined, + "assetItemProps": { + "isTitleHidden": true, + "isTitleNetworkName": false, + }, + "handleAssetChange": [MockFunction], + "isTokenDisabled": [Function], + "isTokenListLoading": false, + "network": { + "chainId": "0x1", + "name": "Ethereum Mainnet", + }, + "tokenList": [ + { + "address": "", + "balance": "1.5", + "chainId": "0x1", + "decimals": 18, + "image": "./images/eth_logo.svg", + "isNative": true, + "symbol": "ETH", + "type": "NATIVE", + }, + { + "address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", + "balance": "0x56bc75e2d63100000", + "chainId": "0x1", + "decimals": 18, + "isNative": false, + "symbol": "UNI", + "type": "TOKEN", + }, + ], + }, +] +`; + +exports[`AssetPickerModal token filtering should respect MAX_UNOWNED_TOKENS_RENDERED limit 1`] = ` +[ + { + "asset": undefined, + "assetItemProps": { + "isTitleHidden": false, + "isTitleNetworkName": true, + }, + "handleAssetChange": [MockFunction], + "isTokenDisabled": [Function], + "isTokenListLoading": false, + "network": { + "chainId": "0xa", + "name": "Optimism", + }, + "tokenList": [ + { + "address": "", + "balance": "1.5", + "chainId": "0x1", + "decimals": 18, + "image": "./images/eth_logo.svg", + "isNative": true, + "symbol": "ETH", + "type": "NATIVE", + }, + { + "address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", + "balance": "0x56bc75e2d63100000", + "chainId": "0x1", + "decimals": 18, + "isNative": false, + "symbol": "UNI", + "type": "TOKEN", + }, + { + "address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f985", + "balance": "0x989680", + "chainId": "0xa", + "decimals": 6, + "isNative": false, + "symbol": "USDC", + "type": "TOKEN", + }, + { + "address": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "balance": "0x2faf080", + "chainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "decimals": 6, + "isNative": false, + "symbol": "USDC", + "type": "TOKEN", + }, + { + "address": "", + "balance": "1000", + "chainId": "0xa", + "decimals": 18, + "image": {}, + "string": undefined, + "symbol": "ETH", + "type": "NATIVE", + }, + ], + }, +] +`; + +exports[`AssetPickerModal token filtering should show all tokens when search query is cleared 1`] = ` +[ + { + "asset": undefined, + "assetItemProps": { + "isTitleHidden": false, + "isTitleNetworkName": true, + }, + "handleAssetChange": [MockFunction], + "isTokenDisabled": [Function], + "isTokenListLoading": false, + "network": { + "chainId": "0xa", + "name": "Optimism", + }, + "tokenList": [ + { + "address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", + "balance": "0x56bc75e2d63100000", + "chainId": "0x1", + "decimals": 18, + "isNative": false, + "symbol": "UNI", + "type": "TOKEN", + }, + ], + }, +] +`; + +exports[`AssetPickerModal token filtering should show all tokens when search query is cleared 2`] = ` +[ + { + "asset": undefined, + "assetItemProps": { + "isTitleHidden": false, + "isTitleNetworkName": true, + }, + "handleAssetChange": [MockFunction], + "isTokenDisabled": [Function], + "isTokenListLoading": false, + "network": { + "chainId": "0xa", + "name": "Optimism", + }, + "tokenList": [ + { + "address": "", + "balance": "1.5", + "chainId": "0x1", + "decimals": 18, + "image": "./images/eth_logo.svg", + "isNative": true, + "symbol": "ETH", + "type": "NATIVE", + }, + { + "address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", + "balance": "0x56bc75e2d63100000", + "chainId": "0x1", + "decimals": 18, + "isNative": false, + "symbol": "UNI", + "type": "TOKEN", + }, + { + "address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f985", + "balance": "0x989680", + "chainId": "0xa", + "decimals": 6, + "isNative": false, + "symbol": "USDC", + "type": "TOKEN", + }, + { + "address": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "balance": "0x2faf080", + "chainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "decimals": 6, + "isNative": false, + "symbol": "USDC", + "type": "TOKEN", + }, + { + "address": "", + "balance": "1000", + "chainId": "0xa", + "decimals": 18, + "image": {}, + "string": undefined, + "symbol": "ETH", + "type": "NATIVE", + }, + ], + }, +] +`; + +exports[`AssetPickerModal token filtering should show selected token first when selected network is active 1`] = ` +[ + { + "asset": { + "address": "NEWTOKEN", + "chainId": "0xa", + "image": "image.png", + "symbol": "USDT", + "type": "TOKEN", + }, + "assetItemProps": { + "isTitleHidden": false, + "isTitleNetworkName": true, + }, + "handleAssetChange": [MockFunction], + "isTokenDisabled": [Function], + "isTokenListLoading": false, + "network": { + "chainId": "0xa", + "name": "Optimism", + }, + "tokenList": [ + { + "address": "", + "balance": "1.5", + "chainId": "0x1", + "decimals": 18, + "image": "./images/eth_logo.svg", + "isNative": true, + "symbol": "ETH", + "type": "NATIVE", + }, + { + "address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", + "balance": "0x56bc75e2d63100000", + "chainId": "0x1", + "decimals": 18, + "isNative": false, + "symbol": "UNI", + "type": "TOKEN", + }, + { + "address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f985", + "balance": "0x989680", + "chainId": "0xa", + "decimals": 6, + "isNative": false, + "symbol": "USDC", + "type": "TOKEN", + }, + { + "address": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "balance": "0x2faf080", + "chainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "decimals": 6, + "isNative": false, + "symbol": "USDC", + "type": "TOKEN", + }, + { + "address": "", + "balance": "1000", + "chainId": "0xa", + "decimals": 18, + "image": {}, + "string": undefined, + "symbol": "ETH", + "type": "NATIVE", + }, + ], + }, +] +`; + +exports[`AssetPickerModal token filtering should show selected token first when selected network is not active 1`] = ` +[ + { + "asset": { + "address": "NEWTOKEN", + "chainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "image": "image.png", + "symbol": "USDT", + "type": "TOKEN", + }, + "assetItemProps": { + "isTitleHidden": true, + "isTitleNetworkName": false, + }, + "handleAssetChange": [MockFunction], + "isTokenDisabled": [Function], + "isTokenListLoading": false, + "network": { + "chainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "name": "Solana", + }, + "tokenList": [ + { + "address": "NEWTOKEN", + "chainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "image": "image.png", + "symbol": "USDT", + "type": "TOKEN", + }, + { + "address": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "balance": "50", + "chainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "decimals": 6, + "isNative": false, + "symbol": "USDC", + "type": "TOKEN", + }, + ], + }, +] +`; diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.test.tsx b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.test.tsx index 7eb2ad5518df..5f975fa94f56 100644 --- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.test.tsx +++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.test.tsx @@ -4,7 +4,10 @@ import configureStore from 'redux-mock-store'; import { useSelector } from 'react-redux'; import thunk from 'redux-thunk'; import sinon from 'sinon'; -import { RpcEndpointType } from '@metamask/network-controller'; +import { + NetworkConfiguration, + RpcEndpointType, +} from '@metamask/network-controller'; import { useI18nContext } from '../../../../hooks/useI18nContext'; import { useNftsCollections } from '../../../../hooks/useNftsCollections'; import { useTokenTracker } from '../../../../hooks/useTokenTracker'; @@ -25,25 +28,44 @@ import { getTokens, } from '../../../../ducks/metamask/metamask'; import { getTopAssets } from '../../../../ducks/swaps/swaps'; -import { getRenderableTokenData } from '../../../../hooks/useTokensToSearch'; import * as actions from '../../../../store/actions'; import { getSwapsBlockedTokens } from '../../../../ducks/send'; import { getMultichainNetworkConfigurationsByChainId, getMultichainCurrentChainId, getMultichainCurrentCurrency, + getMultichainIsEvm, + getMultichainNativeCurrency, + getMultichainCurrentNetwork, + getMultichainSelectedAccountCachedBalance, } from '../../../../selectors/multichain'; +import { MultichainNetworks } from '../../../../../shared/constants/multichain/networks'; +import { useMultichainBalances } from '../../../../hooks/useMultichainBalances'; import { AssetPickerModal } from './asset-picker-modal'; -import AssetList from './AssetList'; import { ERC20Asset } from './types'; -jest.mock('./AssetList', () => jest.fn(() =>
AssetList
)); +const mockAssetList = jest.fn(); +jest.mock('./AssetList', () => (props: unknown) => { + mockAssetList(props); + return <>AssetList; +}); + +const mockUseAssetMetadata = jest.fn(); +jest.mock('./hooks/useAssetMetadata', () => ({ + useAssetMetadata: (...args: unknown[]) => mockUseAssetMetadata(...args), +})); jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), useSelector: jest.fn(), })); +const mockUseMultichainSelector = jest.fn(); +jest.mock('../../../../hooks/useMultichainSelector', () => ({ + useMultichainSelector: (selector: unknown) => + mockUseMultichainSelector(selector), +})); + jest.mock('../../../../hooks/useI18nContext', () => ({ useI18nContext: jest.fn(), })); @@ -56,8 +78,9 @@ jest.mock('../../../../hooks/useTokenTracker', () => ({ useTokenTracker: jest.fn(), })); +const mockGetRenderableTokenData = jest.fn(); jest.mock('../../../../hooks/useTokensToSearch', () => ({ - getRenderableTokenData: jest.fn(), + getRenderableTokenData: (data: unknown) => mockGetRenderableTokenData(data), })); const mockUseMultichainBalances = jest.fn(); @@ -71,6 +94,11 @@ jest.mock('../../../../hooks/useNfts', () => ({ }), })); +jest.mock('lodash', () => ({ + ...jest.requireActual('lodash'), + debounce: jest.fn().mockImplementation((fn) => fn), +})); + describe('AssetPickerModal', () => { const useSelectorMock = useSelector as jest.Mock; const useI18nContextMock = useI18nContext as jest.Mock; @@ -81,6 +109,7 @@ describe('AssetPickerModal', () => { const onAssetChangeMock = jest.fn(); const onCloseMock = jest.fn(); + mockAssetList.mockReturnValue(() =>
AssetList
); const defaultProps = { header: 'sendSelectReceiveAsset', @@ -168,7 +197,7 @@ describe('AssetPickerModal', () => { useTokenTrackerMock.mockReturnValue({ tokensWithBalances: [], }); - (getRenderableTokenData as jest.Mock).mockReturnValue({}); + mockGetRenderableTokenData.mockReturnValue({}); mockUseMultichainBalances.mockReturnValue({ assetsWithBalance: [] }); }); @@ -224,9 +253,7 @@ describe('AssetPickerModal', () => { }, ); - expect( - (AssetList as jest.Mock).mock.calls.slice(-1)[0][0].tokenList.length, - ).toBe(2); + expect(mockAssetList.mock.calls.slice(-1)[0][0].tokenList.length).toBe(2); fireEvent.change( screen.getByPlaceholderText('searchTokensByNameOrAddress'), @@ -235,7 +262,7 @@ describe('AssetPickerModal', () => { }, ); - expect((AssetList as jest.Mock).mock.calls[1][0]).not.toEqual( + expect(mockAssetList.mock.calls[1][0]).not.toEqual( expect.objectContaining({ asset: { balance: '0x0', @@ -297,9 +324,7 @@ describe('AssetPickerModal', () => { }, ); - expect( - (AssetList as jest.Mock).mock.calls.slice(-1)[0][0].tokenList.length, - ).toBe(2); + expect(mockAssetList.mock.calls.slice(-1)[0][0].tokenList.length).toBe(2); fireEvent.change( screen.getByPlaceholderText('searchTokensByNameOrAddress'), @@ -308,7 +333,7 @@ describe('AssetPickerModal', () => { }, ); - expect((AssetList as jest.Mock).mock.calls[1][0]).not.toEqual( + expect(mockAssetList.mock.calls[1][0]).not.toEqual( expect.objectContaining({ asset: { balance: '0x0', @@ -319,14 +344,10 @@ describe('AssetPickerModal', () => { }), ); - expect( - (AssetList as jest.Mock).mock.calls.slice(-1)[0][0].tokenList.length, - ).toBe(1); + expect(mockAssetList.mock.calls.slice(-1)[0][0].tokenList.length).toBe(1); expect( - (AssetList as jest.Mock).mock.calls[2][0].isTokenDisabled({ - address: '0xtoken1', - }), + mockAssetList.mock.calls[2][0].isTokenDisabled({ address: '0xtoken1' }), ).toBe(true); }); @@ -393,3 +414,359 @@ describe('AssetPickerModal', () => { expect(getAllByRole('img')).toHaveLength(1); }); }); + +describe('AssetPickerModal token filtering', () => { + const onAssetChangeMock = jest.fn(); + const useI18nContextMock = useI18nContext as jest.Mock; + + const defaultProps = { + header: 'Select Token', + isOpen: true, + onClose: jest.fn(), + onAssetChange: onAssetChangeMock, + autoFocus: true, + network: { + chainId: '0xa', + name: 'Optimism', + } as unknown as NetworkConfiguration, + selectedChainIds: ['0xa', '0x1', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + isMultiselectEnabled: true, + networks: [ + { + chainId: '0x1', + name: 'Ethereum Mainnet', + }, + { + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + name: 'Solana', + }, + { + chainId: '0xa', + name: 'Optimism', + }, + ] as unknown as NetworkConfiguration[], + }; + + beforeEach(() => { + jest.clearAllMocks(); + + useI18nContextMock.mockReturnValue((key: string) => key); + mockGetRenderableTokenData.mockImplementation((data) => data); + mockUseMultichainBalances.mockReturnValue({ + assetsWithBalance: [ + { + address: '', + balance: '1.5', + chainId: '0x1', + decimals: 18, + image: './images/eth_logo.svg', + isNative: true, + symbol: 'ETH', + type: 'NATIVE', + }, + { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + balance: '100', + chainId: '0x1', + decimals: 18, + isNative: false, + symbol: 'UNI', + type: 'TOKEN', + }, + { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f985', + balance: '10', + chainId: '0xa', + decimals: 6, + isNative: false, + symbol: 'USDC', + type: 'TOKEN', + }, + { + address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + balance: '50', + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + decimals: 6, + isNative: false, + symbol: 'USDC', + type: 'TOKEN', + }, + ], + }); + + const useSelectorMock = useSelector as jest.Mock; + useSelectorMock.mockImplementation((selector) => { + switch (selector) { + case getMultichainCurrentChainId: + return '0xa'; + case getMultichainIsEvm: + return true; + case getSwapsBlockedTokens: + return []; + case getMultichainCurrentCurrency: + return 'USD'; + default: + return {}; + } + }); + + mockUseMultichainSelector.mockImplementation((selector) => { + if (selector === getMultichainCurrentNetwork) { + return 'ETH'; + } + switch (selector) { + case getMultichainCurrentNetwork: + return { + chainId: '0xa', + name: 'Optimism', + }; + case getMultichainNativeCurrency: + return 'ETH'; + case getMultichainSelectedAccountCachedBalance: + return '1000'; + default: + return {}; + } + }); + }); + + it('should render all tokens from multiple chains', () => { + renderWithProvider(); + + expect(mockAssetList.mock.calls.at(-1)).toMatchSnapshot(); + }); + + it('should render all tokens from single chain', () => { + renderWithProvider( + , + ); + + expect(mockAssetList.mock.calls.at(-1)).toMatchSnapshot(); + }); + + it('should filter tokens by symbol', async () => { + renderWithProvider(); + + const searchInput = screen.getByPlaceholderText( + 'searchTokensByNameOrAddress', + ); + fireEvent.change(searchInput, { target: { value: 'UNI' } }); + + expect(mockAssetList.mock.calls.at(-1)).toMatchSnapshot(); + }); + + it('should filter tokens by address', async () => { + renderWithProvider(); + + const searchInput = screen.getByPlaceholderText( + 'searchTokensByNameOrAddress', + ); + fireEvent.change(searchInput, { + target: { value: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984' }, + }); + + expect(mockAssetList.mock.calls.at(-1)).toMatchSnapshot(); + + // Test case-insensitive search + fireEvent.change(searchInput, { + target: { value: '0x1f9840a85d5af5bf1d1762f925bdaddc4201F984' }, + }); + + expect(mockAssetList.mock.calls.at(-1)).toMatchSnapshot(); + }); + + it('should show selected token first when selected network is not active', () => { + renderWithProvider( + + [ + { + address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + balance: '50', + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + decimals: 6, + isNative: false, + symbol: 'USDC', + type: 'TOKEN', + }, + { + address: 'NEWTOKEN', + chainId: MultichainNetworks.SOLANA, + symbol: 'USDT', + image: 'image.png', + type: AssetType.token, + }, + ] as unknown as (keyof typeof AssetPickerModal)['customTokenListGenerator'] + } + />, + ); + + expect(mockAssetList.mock.calls.at(-1)).toMatchSnapshot(); + }); + + it('should show selected token first when selected network is active', () => { + renderWithProvider( + , + ); + + expect(mockAssetList.mock.calls.at(-1)).toMatchSnapshot(); + }); + + it('should filter tokens by chain when multichain network is selected', async () => { + renderWithProvider( + , + ); + + expect(mockAssetList.mock.calls.at(-1)).toMatchSnapshot(); + }); + + it('should show all tokens when search query is cleared', async () => { + renderWithProvider(); + + const searchInput = screen.getByPlaceholderText( + 'searchTokensByNameOrAddress', + ); + fireEvent.change(searchInput, { target: { value: 'UNI' } }); + expect(mockAssetList.mock.calls.at(-1)).toMatchSnapshot(); + + fireEvent.change(searchInput, { target: { value: '' } }); + expect(mockAssetList.mock.calls.at(-1)).toMatchSnapshot(); + }); + + it('should only show tokens with balances in send mode', async () => { + renderWithProvider( + , + ); + + expect(mockAssetList.mock.calls.at(-1)).toMatchSnapshot(); + + // Add a token without balance to the list + mockUseMultichainBalances.mockImplementationOnce(() => ({ + assetsWithBalance: [ + ...useMultichainBalances().assetsWithBalance, + { + address: '0xnewtoken', + balance: '0', + chainId: '0x1', + decimals: 18, + isNative: false, + symbol: 'ZERO', + type: AssetType.token, + }, + ], + })); + + renderWithProvider( + , + ); + expect(mockAssetList.mock.calls.at(-1)).toMatchSnapshot(); + }); + + it('should handle case-insensitive search', async () => { + renderWithProvider(); + + const searchInput = screen.getByPlaceholderText( + 'searchTokensByNameOrAddress', + ); + + fireEvent.change(searchInput, { target: { value: 'uni' } }); + expect(mockAssetList.mock.calls.at(-1)).toMatchSnapshot(); + + fireEvent.change(searchInput, { target: { value: 'UNI' } }); + expect(mockAssetList.mock.calls.at(-1)).toMatchSnapshot(); + }); + + it('should respect MAX_UNOWNED_TOKENS_RENDERED limit', async () => { + // Create an array of 31 tokens (MAX_UNOWNED_TOKENS_RENDERED + 1) + const manyTokens = Array.from({ length: 31 }, (_, i) => ({ + address: `0xtoken${i}`, + balance: '0', + chainId: '0x1', + decimals: 18, + isNative: false, + symbol: `TOKEN${i}`, + type: AssetType.token, + })); + + mockUseMultichainBalances.mockImplementationOnce(() => ({ + assetsWithBalance: manyTokens, + })); + + renderWithProvider(); + + expect(mockAssetList.mock.calls.at(-1)).toMatchSnapshot(); + }); + + it('should fetch metadata for unlisted tokens', async () => { + mockUseAssetMetadata.mockReturnValue({ + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f123', + chainId: '0x1', + decimals: 18, + image: 'https://example.com/image.png', + symbol: 'UNI', + }); + + renderWithProvider(); + + const searchInput = screen.getByPlaceholderText( + 'searchTokensByNameOrAddress', + ); + fireEvent.change(searchInput, { + target: { value: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f123' }, + }); + + expect(mockUseAssetMetadata.mock.calls.at(-1)).toMatchSnapshot(); + expect(mockAssetList.mock.calls.at(-1)).toMatchSnapshot(); + }); +}); diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.tsx b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.tsx index f40cc4292eec..0275af4585f2 100644 --- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.tsx +++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.tsx @@ -1,4 +1,10 @@ -import React, { useState, useCallback, useMemo } from 'react'; +import React, { + useState, + useCallback, + useMemo, + useEffect, + useRef, +} from 'react'; import { useSelector } from 'react-redux'; import type { Token, @@ -7,6 +13,7 @@ import type { } from '@metamask/assets-controllers'; import { isCaipChainId, isStrictHexString, type Hex } from '@metamask/utils'; import { zeroAddress } from 'ethereumjs-util'; +import { debounce } from 'lodash'; import { Modal, ModalContent, @@ -63,8 +70,8 @@ import { getMultichainIsEvm, } from '../../../../selectors/multichain'; import { MultichainNetworks } from '../../../../../shared/constants/multichain/networks'; -import { getAssetsMetadata } from '../../../../selectors/assets'; import { Numeric } from '../../../../../shared/modules/Numeric'; +import { useAssetMetadata } from './hooks/useAssetMetadata'; import type { ERC20Asset, NativeAsset, @@ -144,6 +151,12 @@ export function AssetPickerModal({ const t = useI18nContext(); const [searchQuery, setSearchQuery] = useState(''); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery); + const debouncedSetSearchQuery = debounce(setDebouncedSearchQuery, 200); + useEffect(() => { + debouncedSetSearchQuery(searchQuery); + }, [searchQuery, debouncedSetSearchQuery]); + const abortControllerRef = useRef(null); const swapsBlockedTokens = useSelector(getSwapsBlockedTokens); const memoizedSwapsBlockedTokens = useMemo(() => { @@ -162,6 +175,10 @@ export function AssetPickerModal({ const isSelectedNetworkActive = selectedNetwork.chainId === currentChainId; const isEvm = useMultichainSelector(getMultichainIsEvm); + useEffect(() => { + setSearchQuery(''); + }, [selectedNetwork?.chainId]); + const nativeCurrencyImage = useMultichainSelector(getMultichainCurrencyImage); const nativeCurrency = useMultichainSelector(getMultichainNativeCurrency); const balanceValue = useMultichainSelector( @@ -202,7 +219,6 @@ export function AssetPickerModal({ useMultichainBalances(); const evmTokenMetadataByAddress = useSelector(getTokenList) as TokenListMap; - const nonEvmTokenMetadataByAddress = useSelector(getAssetsMetadata); const allowExternalServices = useSelector(getUseExternalServices); // Swaps top tokens @@ -314,21 +330,8 @@ export function AssetPickerModal({ } // Return early when SOLANA is selected since blocked and top tokens are not available + // All available solana tokens are in the multichainTokensWithBalance results if (selectedNetwork?.chainId === MultichainNetworks.SOLANA) { - for (const [address, token] of Object.entries( - nonEvmTokenMetadataByAddress, - )) { - const [caipChainId] = address.split('/'); - - if (shouldAddToken(token.symbol, address, caipChainId)) { - yield { - ...token, - address, - chainId: caipChainId, - decimals: token.units[0].decimals, - }; - } - } return; } @@ -369,7 +372,6 @@ export function AssetPickerModal({ selectedNetwork?.chainId, multichainTokensWithBalance, allDetectedTokens, - nonEvmTokenMetadataByAddress, topTokens, evmTokenMetadataByAddress, getIsDisabled, @@ -394,7 +396,7 @@ export function AssetPickerModal({ address?: string | null, tokenChainId?: string, ) => { - const trimmedSearchQuery = searchQuery.trim().toLowerCase(); + const trimmedSearchQuery = debouncedSearchQuery.trim().toLowerCase(); const isMatchedBySearchQuery = Boolean( !trimmedSearchQuery || symbol?.toLowerCase().includes(trimmedSearchQuery) || @@ -413,35 +415,43 @@ export function AssetPickerModal({ // If filteredTokensGenerator is passed in, use it to generate the filtered tokens // Otherwise use the default tokenGenerator - for (const token of (customTokenListGenerator ?? tokenListGenerator)( + const tokenGenerator = (customTokenListGenerator ?? tokenListGenerator)( shouldAddToken, - )) { + ); + + for (const token of tokenGenerator) { if (action === 'send' && token.balance === undefined) { continue; } filteredTokensAddresses.add(getTokenKey(token.address, token.chainId)); - if (!customTokenListGenerator && isStrictHexString(token.address)) { - filteredTokens.push( - getRenderableTokenData( - token.address - ? ({ - ...token, - ...evmTokenMetadataByAddress[token.address.toLowerCase()], - type: AssetType.token, - } as AssetWithDisplayData) - : token, - tokenConversionRates, - conversionRate, - currentCurrency, - token.chainId, - evmTokenMetadataByAddress, - ), - ); + + const tokenWithBalanceData = + !customTokenListGenerator && isStrictHexString(token.address) + ? getRenderableTokenData( + token.address + ? ({ + ...token, + ...evmTokenMetadataByAddress[token.address.toLowerCase()], + type: AssetType.token, + } as AssetWithDisplayData) + : token, + tokenConversionRates, + conversionRate, + currentCurrency, + token.chainId, + evmTokenMetadataByAddress, + ) + : (token as unknown as AssetWithDisplayData); + + // Add selected asset to the top of the list if it is the selected asset + if ( + asset?.address === tokenWithBalanceData.address && + selectedNetwork?.chainId === tokenWithBalanceData.chainId + ) { + filteredTokens.unshift(tokenWithBalanceData); } else { - filteredTokens.push( - token as unknown as AssetWithDisplayData, - ); + filteredTokens.push(tokenWithBalanceData); } if (filteredTokens.length > MAX_UNOWNED_TOKENS_RENDERED) { @@ -452,7 +462,7 @@ export function AssetPickerModal({ return filteredTokens; }, [ currentChainId, - searchQuery, + debouncedSearchQuery, isMultiselectEnabled, selectedChainIds, selectedNetwork?.chainId, @@ -463,8 +473,21 @@ export function AssetPickerModal({ tokenConversionRates, conversionRate, currentCurrency, + asset, ]); + // This fetches the metadata for the asset if it is not already in the filteredTokenList + const unlistedAssetMetadata = useAssetMetadata( + searchQuery, + filteredTokenList.length === 0, + abortControllerRef, + selectedNetwork?.chainId, + ); + + const displayedTokens = useMemo(() => { + return unlistedAssetMetadata ? [unlistedAssetMetadata] : filteredTokenList; + }, [unlistedAssetMetadata, filteredTokenList]); + const getNetworkPickerLabel = () => { if (!isMultiselectEnabled) { return ( @@ -562,14 +585,18 @@ export function AssetPickerModal({ setSearchQuery(value)} + onChange={(value) => { + // Cancel previous asset metadata fetch + abortControllerRef.current?.abort(); + setSearchQuery(value); + }} autoFocus={autoFocus} /> ({ + useSelector: jest.fn(), +})); + +const mockFetchAssetMetadata = jest.fn(); +const mockGetAssetImageUrl = jest.fn(); + +jest.mock('../../../../../../shared/lib/asset-utils', () => ({ + fetchAssetMetadata: (...args: unknown[]) => mockFetchAssetMetadata(...args), + getAssetImageUrl: (...args: unknown[]) => mockGetAssetImageUrl(...args), +})); + +const mockChainId = '0x1'; +const mockSearchQuery = '0x123asdfasdfasdfasdfasdfasadssdas'; +const mockAssetId = 'eip155:1/erc20:0x123'; +const mockAbortController = { current: new AbortController() }; +describe('useAssetMetadata', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useSelector as jest.Mock).mockReturnValue(true); // allowExternalServices = true + mockGetAssetImageUrl.mockReturnValue('mock-image-url'); + }); + + it('should return undefined when external services are disabled', async () => { + (useSelector as jest.Mock).mockReturnValue(false); // allowExternalServices = false + + const { result, waitForNextUpdate } = renderHook(() => + useAssetMetadata(mockSearchQuery, true, mockAbortController, mockChainId), + ); + + await waitForNextUpdate(); + expect(result.current).toBeUndefined(); + expect(mockFetchAssetMetadata).not.toHaveBeenCalled(); + }); + + it('should return undefined when chainId is not provided', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useAssetMetadata(mockSearchQuery, true, mockAbortController), + ); + + await waitForNextUpdate(); + expect(result.current).toBeUndefined(); + expect(mockFetchAssetMetadata).not.toHaveBeenCalled(); + }); + + it('should return undefined when search query is empty', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useAssetMetadata('', true, mockAbortController, mockChainId), + ); + + await waitForNextUpdate(); + expect(result.current).toBeUndefined(); + expect(mockFetchAssetMetadata).not.toHaveBeenCalled(); + }); + + it('should fetch and return asset metadata when conditions are met', async () => { + const mockMetadata = { + address: '0x123asdfasdfasdfasdfasdfasadssdas', + symbol: 'TEST', + decimals: 18, + assetId: mockAssetId, + chainId: mockChainId, + }; + + mockFetchAssetMetadata.mockResolvedValueOnce(mockMetadata); + + const { result, waitForNextUpdate } = renderHook(() => + useAssetMetadata(mockSearchQuery, true, mockAbortController, mockChainId), + ); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + ...mockMetadata, + chainId: mockChainId, + isNative: false, + type: AssetType.token, + image: 'mock-image-url', + balance: '', + string: '', + }); + + expect(mockFetchAssetMetadata).toHaveBeenCalledWith( + mockSearchQuery.trim(), + mockChainId, + mockAbortController.current.signal, + ); + expect(mockGetAssetImageUrl).toHaveBeenCalledWith(mockAssetId, mockChainId); + }); + + it('should return undefined when fetchAssetMetadata returns undefined', async () => { + mockFetchAssetMetadata.mockResolvedValueOnce(undefined); + + const { result, waitForNextUpdate } = renderHook(() => + useAssetMetadata(mockSearchQuery, true, mockAbortController, mockChainId), + ); + + await waitForNextUpdate(); + expect(result.current).toBeUndefined(); + + expect(mockFetchAssetMetadata).toHaveBeenCalledWith( + mockSearchQuery.trim(), + mockChainId, + mockAbortController.current.signal, + ); + }); + + it('should handle errors gracefully', async () => { + mockFetchAssetMetadata.mockRejectedValueOnce(new Error('API Error')); + + const { result, waitForNextUpdate } = renderHook(() => + useAssetMetadata(mockSearchQuery, true, mockAbortController, mockChainId), + ); + + await waitForNextUpdate(); + expect(result.current).toBeUndefined(); + + expect(mockFetchAssetMetadata).toHaveBeenCalledWith( + mockSearchQuery.trim(), + mockChainId, + mockAbortController.current.signal, + ); + }); +}); diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/hooks/useAssetMetadata.ts b/ui/components/multichain/asset-picker-amount/asset-picker-modal/hooks/useAssetMetadata.ts new file mode 100644 index 000000000000..3a435ad67779 --- /dev/null +++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/hooks/useAssetMetadata.ts @@ -0,0 +1,76 @@ +import { CaipAssetType, CaipChainId, Hex } from '@metamask/utils'; +import { useSelector } from 'react-redux'; +import { getUseExternalServices } from '../../../../../selectors'; +import { + fetchAssetMetadata, + getAssetImageUrl, +} from '../../../../../../shared/lib/asset-utils'; +import { AssetType } from '../../../../../../shared/constants/transaction'; +import { useAsyncResult } from '../../../../../hooks/useAsync'; + +/** + * Fetches token metadata for a single token if searchQuery is defined but filteredTokenList is empty + * + * @param searchQuery - The search query to fetch metadata for + * @param shouldFetchMetadata - Whether to fetch metadata + * @param abortControllerRef - The abort controller ref to use for the fetch request + * @param chainId - The chain id to fetch metadata for + * @returns The asset metadata + */ +export const useAssetMetadata = ( + searchQuery: string, + shouldFetchMetadata: boolean, + abortControllerRef: React.MutableRefObject, + chainId?: Hex | CaipChainId, +) => { + const allowExternalServices = useSelector(getUseExternalServices); + + const { value: assetMetadata } = useAsyncResult< + | { + address: Hex | CaipAssetType | string; + symbol: string; + decimals: number; + image: string; + chainId: Hex | CaipChainId; + isNative: boolean; + type: AssetType.token; + balance: string; + string: string; + } + | undefined + >(async () => { + if (!chainId || !searchQuery) { + return undefined; + } + + const trimmedSearchQuery = searchQuery.trim(); + if ( + allowExternalServices && + shouldFetchMetadata && + trimmedSearchQuery.length > 30 + ) { + abortControllerRef.current = new AbortController(); + const metadata = await fetchAssetMetadata( + trimmedSearchQuery, + chainId, + abortControllerRef.current.signal, + ); + + if (metadata) { + return { + ...metadata, + chainId, + isNative: false, + type: AssetType.token, + image: getAssetImageUrl(metadata.assetId, chainId) ?? '', + balance: '', + string: '', + } as const; + } + return undefined; + } + return undefined; + }, [shouldFetchMetadata, searchQuery]); + + return assetMetadata; +}; diff --git a/ui/hooks/bridge/useTokensWithFiltering.ts b/ui/hooks/bridge/useTokensWithFiltering.ts index d212673f13e2..1d0d4d12e085 100644 --- a/ui/hooks/bridge/useTokensWithFiltering.ts +++ b/ui/hooks/bridge/useTokensWithFiltering.ts @@ -11,6 +11,7 @@ import { fetchBridgeTokens, BridgeClientId, type BridgeAsset, + getNativeAssetForChainId, } from '@metamask/bridge-controller'; import { selectERC20TokensByChain } from '../../selectors'; import { AssetType } from '../../../shared/constants/transaction'; @@ -24,15 +25,14 @@ import { getTopAssetsFromFeatureFlags, } from '../../ducks/bridge/selectors'; import fetchWithCache from '../../../shared/lib/fetch-with-cache'; -import { - BRIDGE_API_BASE_URL, - STATIC_METAMASK_BASE_URL, -} from '../../../shared/constants/bridge'; +import { BRIDGE_API_BASE_URL } from '../../../shared/constants/bridge'; import type { AssetWithDisplayData, ERC20Asset, NativeAsset, } from '../../components/multichain/asset-picker-amount/asset-picker-modal/types'; +import { getAssetImageUrl } from '../../../shared/lib/asset-utils'; +import { MULTICHAIN_TOKEN_IMAGE_MAP } from '../../../shared/constants/multichain/networks'; type FilterPredicate = ( symbol: string, @@ -40,13 +40,6 @@ type FilterPredicate = ( tokenChainId?: string, ) => boolean; -// Returns the image url for a caip-formatted asset -const getAssetImageUrl = (assetId: string) => - `${STATIC_METAMASK_BASE_URL}/api/v2/tokenIcons/assets/${assetId?.replaceAll( - ':', - '/', - )}.png`; - /** * Returns a token list generator that filters and sorts tokens in this order * - matches search query @@ -149,7 +142,8 @@ export const useTokensWithFiltering = ( image: CHAIN_ID_TOKEN_IMAGE_MAP[ sharedFields.chainId as keyof typeof CHAIN_ID_TOKEN_IMAGE_MAP - ] ?? token.iconUrl, + ] ?? + (token.iconUrl || token.icon || ''), // Only unimported native assets are processed here so hardcode balance to 0 balance: '0', string: '0', @@ -159,11 +153,10 @@ export const useTokensWithFiltering = ( return { ...sharedFields, type: AssetType.token, - image: token.iconUrl ?? '', + image: token.iconUrl ?? token.icon ?? '', // Only tokens with 0 balance are processed here so hardcode empty string balance: '', string: undefined, - address: isSolanaChainId(chainId) ? token.assetId : token.address, }; }; @@ -207,7 +200,7 @@ export const useTokensWithFiltering = ( token.chainId, ) ) { - if (isNativeAddress(token.address)) { + if (isNativeAddress(token.address) || token.isNative) { yield { symbol: token.symbol, chainId: token.chainId, @@ -220,7 +213,16 @@ export const useTokensWithFiltering = ( image: CHAIN_ID_TOKEN_IMAGE_MAP[ token.chainId as keyof typeof CHAIN_ID_TOKEN_IMAGE_MAP - ] ?? getAssetImageUrl(token.address), + ] ?? + MULTICHAIN_TOKEN_IMAGE_MAP[ + token.chainId as keyof typeof MULTICHAIN_TOKEN_IMAGE_MAP + ] ?? + (getNativeAssetForChainId(token.chainId)?.icon || + getNativeAssetForChainId(token.chainId)?.iconUrl || + getAssetImageUrl( + token.address, + formatChainIdToCaip(token.chainId), + )), }; } else { yield { @@ -235,7 +237,11 @@ export const useTokensWithFiltering = ( image: (token.image || tokenList?.[token.address.toLowerCase()]?.iconUrl) ?? - getAssetImageUrl(token.address), + getAssetImageUrl( + token.address, + formatChainIdToCaip(token.chainId), + ) ?? + '', }; } } diff --git a/ui/hooks/useMultichainBalances.ts b/ui/hooks/useMultichainBalances.ts index 59c643fefb78..49dd78f368a3 100644 --- a/ui/hooks/useMultichainBalances.ts +++ b/ui/hooks/useMultichainBalances.ts @@ -1,6 +1,10 @@ import { useMemo } from 'react'; import { useSelector } from 'react-redux'; -import type { CaipChainId, Hex } from '@metamask/utils'; +import { + parseCaipAssetType, + type CaipChainId, + type Hex, +} from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import type { TokenWithBalance } from '../components/app/assets/types'; import { @@ -23,7 +27,8 @@ const useNonEvmAssetsWithBalances = (): ( | Omit & { chainId: `${string}:${string}`; decimals: number; - address: `${string}:${string}`; + address: string; + assetId: `${string}:${string}`; string: string; balance: string; tokenFiatAmount: number; @@ -58,17 +63,18 @@ const useNonEvmAssetsWithBalances = (): ( return assetIds .filter((caipAssetId) => assetMetadataById[caipAssetId]) .map((caipAssetId) => { - const [caipChainId, address] = caipAssetId.split('/'); - const [type] = address.split(':'); + const { chainId, assetReference, assetNamespace } = + parseCaipAssetType(caipAssetId); return { - chainId: caipChainId as `${string}:${string}`, + chainId, symbol: assetMetadataById[caipAssetId]?.symbol ?? '', - address: caipAssetId, + assetId: caipAssetId, + address: assetReference, string: balancesByAssetId[caipAssetId]?.amount ?? '0', balance: balancesByAssetId[caipAssetId]?.amount ?? '0', decimals: assetMetadataById[caipAssetId]?.units[0]?.decimals, image: assetMetadataById[caipAssetId]?.iconUrl ?? '', - type: type === 'token' ? AssetType.token : AssetType.native, + type: assetNamespace === 'token' ? AssetType.token : AssetType.native, tokenFiatAmount: new BigNumber( balancesByAssetId[caipAssetId]?.amount ?? '1', )