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(() =>