diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 84b213e3b65..cfcc1f3bae9 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -174,9 +174,6 @@ } }, "packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts": { - "import-x/no-relative-packages": { - "count": 1 - }, "no-restricted-syntax": { "count": 4 } @@ -188,7 +185,7 @@ }, "packages/assets-controller/src/data-sources/RpcDataSource.ts": { "no-restricted-syntax": { - "count": 3 + "count": 2 } }, "packages/assets-controller/src/data-sources/SnapDataSource.ts": { diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index bd4ae4433f0..479698c06a0 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- `TokenDetector` now fetches the token list directly from the Tokens API (`/v3/chains/{chain}/assets`) via a new `TokensApiClient` instead of reading from `TokenListController:getState` ([#8385](https://github.com/MetaMask/core/pull/8385)) + - `TokenDetectorMessenger` type has been removed; `TokenDetector` constructor now takes a `TokensApiClient` instance as its second argument + - `RpcDataSource` no longer requires `TokenListController:getState` — `GetTokenListState` has been removed from `RpcDataSourceAllowedActions` and `AssetsControllerAllowedActions` + - Unknown ERC-20 metadata is no longer looked up from the token list as a fallback in `RpcDataSource`; `TokenDataSource` handles enrichment downstream - Split `getAssets` fetch pipeline into a fast awaited path and a parallel fire-and-forget background path to reduce perceived latency on unlock and onboarding ([#8383](https://github.com/MetaMask/core/pull/8383)) - Fast pipeline: AccountsApi + StakedBalance → Detection → Token + Price (awaited, committed to state immediately) - Background pipeline: Snap + RPC run in parallel → Detection → Token + Price when basic functionality is enabled; when disabled (RPC-only mode), Token + Price are omitted (fire-and-forget merge) diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 3d0fa4a2765..2443c852557 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -178,13 +178,6 @@ async function withController( ).registerActionHandler('NetworkController:getNetworkClientById', () => ({ provider: {}, })); - ( - messenger as { - registerActionHandler: (a: string, h: () => unknown) => void; - } - ).registerActionHandler('TokenListController:getState', () => ({ - tokensChainsCache: {}, - })); if (clientControllerState !== undefined) { ( @@ -292,13 +285,6 @@ describe('AssetsController', () => { ).registerActionHandler('NetworkController:getNetworkClientById', () => ({ provider: {}, })); - ( - messenger as { - registerActionHandler: (a: string, h: () => unknown) => void; - } - ).registerActionHandler('TokenListController:getState', () => ({ - tokensChainsCache: {}, - })); const controller = new AssetsController({ messenger: messenger as unknown as AssetsControllerMessenger, @@ -393,13 +379,6 @@ describe('AssetsController', () => { ).registerActionHandler('NetworkController:getNetworkClientById', () => ({ provider: {}, })); - ( - messenger as { - registerActionHandler: (a: string, h: () => unknown) => void; - } - ).registerActionHandler('TokenListController:getState', () => ({ - tokensChainsCache: {}, - })); expect( () => diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index c45b8e51947..fa4793cbdc0 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -2,7 +2,6 @@ import type { AccountTreeControllerGetAccountsFromSelectedAccountGroupAction, AccountTreeControllerSelectedAccountGroupChangeEvent, } from '@metamask/account-tree-controller'; -import type { GetTokenListState } from '@metamask/assets-controllers'; import { BaseController } from '@metamask/base-controller'; import type { ControllerGetStateAction, @@ -267,7 +266,6 @@ type AllowedActions = // AssetsController | AccountTreeControllerGetAccountsFromSelectedAccountGroupAction // RpcDataSource - | GetTokenListState | NetworkControllerGetStateAction | NetworkControllerGetNetworkClientByIdAction // RpcDataSource, StakedBalanceDataSource diff --git a/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts b/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts index 51ffe4a0a2a..70cdf829075 100644 --- a/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts +++ b/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts @@ -8,12 +8,12 @@ import { MockAnyNamespace, } from '@metamask/messenger'; import { NetworkStatus } from '@metamask/network-controller'; - import { NetworkState, RpcEndpoint, RpcEndpointType, -} from '../../../network-controller/src/NetworkController'; +} from '@metamask/network-controller/src/NetworkController'; + import { AssetsControllerMessenger, getDefaultAssetsControllerState, @@ -53,7 +53,6 @@ export function createMockAssetControllerMessenger(): { 'AccountTreeController:getAccountsFromSelectedAccountGroup', 'AssetsController:getState', // RpcDataSource - 'TokenListController:getState', 'NetworkController:getState', 'NetworkController:getNetworkClientById', // RpcDataSource, StakedBalanceDataSource @@ -173,10 +172,6 @@ export function registerRpcDataSourceActions( getDefaultAssetsControllerState(), ); - rootMessenger.registerActionHandler('TokenListController:getState', () => ({ - tokensChainsCache: {}, - })); - rootMessenger.registerActionHandler( 'NetworkEnablementController:getState', () => ({ diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts index 1355065a592..5481daca581 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts @@ -122,7 +122,6 @@ type ActionHandlerOverrides = { configuration: { chainId: string }; }; 'AssetsController:getState'?: () => unknown; - 'TokenListController:getState'?: () => unknown; 'NetworkEnablementController:getState'?: () => unknown; }; @@ -198,15 +197,6 @@ async function withController( getDefaultAssetsControllerState(), ); } - if (!actionHandlerOverrides['TokenListController:getState']) { - ( - rootMessenger as { - registerActionHandler: (a: string, h: () => unknown) => void; - } - ).registerActionHandler('TokenListController:getState', () => ({ - tokensChainsCache: {}, - })); - } if (!actionHandlerOverrides['NetworkEnablementController:getState']) { ( rootMessenger as { @@ -699,7 +689,7 @@ describe('RpcDataSource', () => { ); }); - it('merges metadata from chain status, existing state, and token list', async () => { + it('merges metadata from chain status and existing state', async () => { const getState = jest.fn().mockReturnValue({ assetsInfo: { 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': { @@ -715,25 +705,10 @@ describe('RpcDataSource', () => { assetPreferences: {}, selectedCurrency: 'usd', }); - const tokenListState = { - tokensChainsCache: { - '0x1': { - data: { - '0xabcdef00000000000000000000000000000000': { - symbol: 'TKN', - name: 'Token', - decimals: 18, - iconUrl: 'https://example.com/icon.png', - }, - }, - }, - }, - }; await withController( { actionHandlerOverrides: { 'AssetsController:getState': getState, - 'TokenListController:getState': () => tokenListState, }, }, async ({ controller }) => { @@ -1406,81 +1381,7 @@ describe('RpcDataSource', () => { expect(response.assetsInfo[normalizedId]).toStrictEqual(existingMetadata); }); - it('uses token list metadata for ERC20 not in AssetsController state (#getTokenMetadataFromTokenList)', async () => { - const tokenAddress = '0xDef4567890123456789012345678901234567890'; - const erc20AssetId = `eip155:1/erc20:${tokenAddress}` as Caip19AssetId; - const normalizedId = normalizeAssetId(erc20AssetId); - let balanceUpdateCallback: - | ((result: BalanceFetchResult) => void | Promise) - | null = null; - jest - .spyOn(BalanceFetcher.prototype, 'setOnBalanceUpdate') - .mockImplementation(function (this: BalanceFetcher, callback) { - balanceUpdateCallback = callback; - }); - - const tokenListState = { - tokensChainsCache: { - [MOCK_CHAIN_ID_HEX]: { - timestamp: 0, - data: { - [tokenAddress.toLowerCase()]: { - address: tokenAddress, - symbol: 'TKN', - name: 'Test Token', - decimals: 18, - iconUrl: 'https://example.com/icon.png', - }, - }, - }, - }, - }; - - const onAssetsUpdate = jest.fn(); - await withController( - { - actionHandlerOverrides: { - 'AssetsController:getState': () => ({ - ...getDefaultAssetsControllerState(), - assetsInfo: {}, - }), - 'TokenListController:getState': () => tokenListState, - }, - }, - async ({ controller }) => { - await controller.subscribe({ - request: createDataRequest(), - subscriptionId: 'test-sub', - isUpdate: false, - onAssetsUpdate, - }); - - expect(balanceUpdateCallback).not.toBeNull(); - await balanceUpdateCallback?.( - createBalanceFetchResult({ - balances: [ - { - assetId: erc20AssetId, - balance: '0', - } as BalanceFetchResult['balances'][0], - ], - }), - ); - }, - ); - - expect(onAssetsUpdate).toHaveBeenCalled(); - const [response] = onAssetsUpdate.mock.calls[0]; - expect(response.assetsInfo[normalizedId]).toStrictEqual({ - type: 'erc20', - symbol: 'TKN', - name: 'Test Token', - decimals: 18, - image: 'https://example.com/icon.png', - }); - }); - - it('omits unknown ERC-20 from assetsInfo when not in token list (#getTokenMetadataFromTokenList no match)', async () => { + it('omits unknown ERC-20 from assetsInfo when not in existing state', async () => { const tokenAddress = '0xAbc0000000000000000000000000000000000001'; const erc20AssetId = `eip155:1/erc20:${tokenAddress}` as Caip19AssetId; const normalizedId = normalizeAssetId(erc20AssetId); @@ -1493,22 +1394,6 @@ describe('RpcDataSource', () => { balanceUpdateCallback = callback; }); - const tokenListState = { - tokensChainsCache: { - [MOCK_CHAIN_ID_HEX]: { - timestamp: 0, - data: { - '0xOtherAddress': { - address: '0xOtherAddress', - symbol: 'OTH', - name: 'Other', - decimals: 18, - }, - }, - }, - }, - }; - const onAssetsUpdate = jest.fn(); await withController( { @@ -1517,7 +1402,6 @@ describe('RpcDataSource', () => { ...getDefaultAssetsControllerState(), assetsInfo: {}, }), - 'TokenListController:getState': () => tokenListState, }, }, async ({ controller }) => { @@ -1572,7 +1456,6 @@ describe('RpcDataSource', () => { }, }, }), - 'TokenListController:getState': () => ({ tokensChainsCache: {} }), }, }, async ({ controller }) => { @@ -1601,220 +1484,6 @@ describe('RpcDataSource', () => { response.assetsBalance?.[MOCK_ACCOUNT_ID]?.[normalizedId], ).toBeUndefined(); }); - - it('omits unknown ERC-20 from assetsInfo when token list has no chain cache (#getTokenMetadataFromTokenList)', async () => { - const erc20AssetId = - 'eip155:1/erc20:0xAbc0000000000000000000000000000000000002' as Caip19AssetId; - const normalizedId = normalizeAssetId(erc20AssetId); - let balanceUpdateCallback: - | ((result: BalanceFetchResult) => void | Promise) - | null = null; - jest - .spyOn(BalanceFetcher.prototype, 'setOnBalanceUpdate') - .mockImplementation(function (this: BalanceFetcher, callback) { - balanceUpdateCallback = callback; - }); - - const onAssetsUpdate = jest.fn(); - await withController( - { - actionHandlerOverrides: { - 'AssetsController:getState': () => ({ - ...getDefaultAssetsControllerState(), - assetsInfo: {}, - }), - 'TokenListController:getState': () => ({ tokensChainsCache: {} }), - }, - }, - async ({ controller }) => { - await controller.subscribe({ - request: createDataRequest(), - subscriptionId: 'test-sub', - isUpdate: false, - onAssetsUpdate, - }); - await balanceUpdateCallback?.( - createBalanceFetchResult({ - balances: [ - { - assetId: erc20AssetId, - balance: '0', - } as BalanceFetchResult['balances'][0], - ], - }), - ); - }, - ); - - const [response] = onAssetsUpdate.mock.calls[0]; - expect(response.assetsInfo?.[normalizedId]).toBeUndefined(); - }); - - it('omits unknown ERC-20 from assetsInfo when token list entry lacks symbol/decimals (#getTokenMetadataFromTokenList)', async () => { - const tokenAddress = '0xAbc0000000000000000000000000000000000003'; - const erc20AssetId = `eip155:1/erc20:${tokenAddress}` as Caip19AssetId; - const normalizedId = normalizeAssetId(erc20AssetId); - let balanceUpdateCallback: - | ((result: BalanceFetchResult) => void | Promise) - | null = null; - jest - .spyOn(BalanceFetcher.prototype, 'setOnBalanceUpdate') - .mockImplementation(function (this: BalanceFetcher, callback) { - balanceUpdateCallback = callback; - }); - - const tokenListState = { - tokensChainsCache: { - [MOCK_CHAIN_ID_HEX]: { - timestamp: 0, - data: { - [tokenAddress.toLowerCase()]: { - address: tokenAddress, - symbol: '', - name: 'Incomplete', - decimals: undefined as unknown as number, - }, - }, - }, - }, - }; - - const onAssetsUpdate = jest.fn(); - await withController( - { - actionHandlerOverrides: { - 'AssetsController:getState': () => ({ - ...getDefaultAssetsControllerState(), - assetsInfo: {}, - }), - 'TokenListController:getState': () => tokenListState, - }, - }, - async ({ controller }) => { - await controller.subscribe({ - request: createDataRequest(), - subscriptionId: 'test-sub', - isUpdate: false, - onAssetsUpdate, - }); - await balanceUpdateCallback?.( - createBalanceFetchResult({ - balances: [ - { - assetId: erc20AssetId, - balance: '0', - } as BalanceFetchResult['balances'][0], - ], - }), - ); - }, - ); - - const [response] = onAssetsUpdate.mock.calls[0]; - expect(response.assetsInfo?.[normalizedId]).toBeUndefined(); - }); - - it('omits non-ERC20 asset from assetsInfo when not found in token list (#getTokenMetadataFromTokenList)', async () => { - const nonErc20AssetId = - 'eip155:1/erc721:0xAbc0000000000000000000000000000000000004' as Caip19AssetId; - const normalizedId = normalizeAssetId(nonErc20AssetId); - let balanceUpdateCallback: - | ((result: BalanceFetchResult) => void | Promise) - | null = null; - jest - .spyOn(BalanceFetcher.prototype, 'setOnBalanceUpdate') - .mockImplementation(function (this: BalanceFetcher, callback) { - balanceUpdateCallback = callback; - }); - - const onAssetsUpdate = jest.fn(); - await withController( - { - actionHandlerOverrides: { - 'AssetsController:getState': () => ({ - ...getDefaultAssetsControllerState(), - assetsInfo: {}, - }), - 'TokenListController:getState': () => ({ - tokensChainsCache: { - [MOCK_CHAIN_ID_HEX]: { timestamp: 0, data: {} }, - }, - }), - }, - }, - async ({ controller }) => { - await controller.subscribe({ - request: createDataRequest(), - subscriptionId: 'test-sub', - isUpdate: false, - onAssetsUpdate, - }); - await balanceUpdateCallback?.( - createBalanceFetchResult({ - balances: [ - { - assetId: nonErc20AssetId, - balance: '0', - } as BalanceFetchResult['balances'][0], - ], - }), - ); - }, - ); - - const [response] = onAssetsUpdate.mock.calls[0]; - expect(response.assetsInfo?.[normalizedId]).toBeUndefined(); - }); - - it('omits unknown ERC-20 from assetsInfo when TokenListController:getState throws (#getTokenMetadataFromTokenList catch)', async () => { - const erc20AssetId = - 'eip155:1/erc20:0xAbc0000000000000000000000000000000000005' as Caip19AssetId; - const normalizedId = normalizeAssetId(erc20AssetId); - let balanceUpdateCallback: - | ((result: BalanceFetchResult) => void | Promise) - | null = null; - jest - .spyOn(BalanceFetcher.prototype, 'setOnBalanceUpdate') - .mockImplementation(function (this: BalanceFetcher, callback) { - balanceUpdateCallback = callback; - }); - - const onAssetsUpdate = jest.fn(); - await withController( - { - actionHandlerOverrides: { - 'AssetsController:getState': () => ({ - ...getDefaultAssetsControllerState(), - assetsInfo: {}, - }), - 'TokenListController:getState': () => { - throw new Error('Token list unavailable'); - }, - }, - }, - async ({ controller }) => { - await controller.subscribe({ - request: createDataRequest(), - subscriptionId: 'test-sub', - isUpdate: false, - onAssetsUpdate, - }); - await balanceUpdateCallback?.( - createBalanceFetchResult({ - balances: [ - { - assetId: erc20AssetId, - balance: '0', - } as BalanceFetchResult['balances'][0], - ], - }), - ); - }, - ); - - const [response] = onAssetsUpdate.mock.calls[0]; - expect(response.assetsInfo?.[normalizedId]).toBeUndefined(); - }); }); describe('handleDetectionUpdate (via callback)', () => { diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.ts b/packages/assets-controller/src/data-sources/RpcDataSource.ts index f5f00cde2c1..5b2aa2c1d34 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.ts @@ -1,5 +1,4 @@ import { Web3Provider } from '@ethersproject/providers'; -import type { GetTokenListState } from '@metamask/assets-controllers'; import { toHex } from '@metamask/controller-utils'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { @@ -18,7 +17,6 @@ import type { import { isStrictHexString, isCaipChainId, - numberToHex, parseCaipAssetType, parseCaipChainId, } from '@metamask/utils'; @@ -34,6 +32,7 @@ import { BalanceFetcher, MulticallClient, TokenDetector, + TokensApiClient, } from './evm-rpc-services'; import type { BalancePollingInput, @@ -43,7 +42,6 @@ import type { Address, AssetFetchEntry, Provider as RpcProvider, - TokenListState, BalanceFetchResult, TokenDetectionResult, } from './evm-rpc-services/types'; @@ -75,7 +73,6 @@ export type RpcDataSourceAllowedActions = | NetworkControllerGetStateAction | NetworkControllerGetNetworkClientByIdAction | AssetsControllerGetStateAction - | GetTokenListState | NetworkEnablementControllerGetStateAction; // Allowed events that RpcDataSource can subscribe to @@ -269,12 +266,6 @@ export class RpcDataSource extends AbstractDataSource< }, }; - const tokenDetectorMessenger = { - call: (_action: 'TokenListController:getState'): TokenListState => { - return this.#messenger.call('TokenListController:getState'); - }, - }; - // Initialize BalanceFetcher with polling interval this.#balanceFetcher = new BalanceFetcher( this.#multicallClient, @@ -286,9 +277,10 @@ export class RpcDataSource extends AbstractDataSource< ); // Initialize TokenDetector with polling interval + const tokensApiClient = new TokensApiClient(); this.#tokenDetector = new TokenDetector( this.#multicallClient, - tokenDetectorMessenger, + tokensApiClient, { pollingInterval: detectionInterval, tokenDetectionEnabled: this.#tokenDetectionEnabled, @@ -347,21 +339,11 @@ export class RpcDataSource extends AbstractDataSource< }; } } else { - // For ERC20 tokens, try existing metadata from state first + // For ERC20 tokens, use existing metadata from state if available. + // Unknown ERC-20s are omitted until TokenDataSource enriches them. const existingMeta = existingMetadata[balance.assetId]; - if (existingMeta) { assetsInfo[balance.assetId] = existingMeta; - } else { - // Fallback to token list if not in state - const tokenListMeta = this.#getTokenMetadataFromTokenList( - balance.assetId, - ); - if (tokenListMeta) { - assetsInfo[balance.assetId] = tokenListMeta; - } - // Unknown ERC-20: omit from assetsInfo until decimals are known. - // #handleBalanceUpdate resolves decimals via RPC or omits the balance. } } } @@ -921,9 +903,7 @@ export class RpcDataSource extends AbstractDataSource< const tokenAddress = parsed.assetReference.toLowerCase() as Address; const normalizedId = normalizeAssetId(assetId); - const decimals = - existingMetadata[normalizedId]?.decimals ?? - this.#getTokenMetadataFromTokenList(normalizedId)?.decimals; + const decimals = existingMetadata[normalizedId]?.decimals; assetsToFetch.push({ assetId, @@ -1373,64 +1353,12 @@ export class RpcDataSource extends AbstractDataSource< try { const state = this.#messenger.call('AssetsController:getState'); return state.assetsInfo ?? {}; - } catch { - // If AssetsController:getState fails, return empty metadata + } catch (error) { + log('Failed to get existing assets metadata', { error }); return {}; } } - /** - * Get token metadata from TokenListController for an ERC20 token. - * Used as a fallback when metadata is not in AssetsController state. - * - * @param assetId - The CAIP-19 asset ID (e.g., "eip155:1/erc20:0x...") - * @returns Token metadata if found in token list, undefined otherwise. - */ - #getTokenMetadataFromTokenList( - assetId: Caip19AssetId, - ): AssetMetadata | undefined { - try { - const parsed = parseCaipAssetType(assetId); - if (parsed.assetNamespace !== 'erc20') { - return undefined; - } - const tokenAddress = parsed.assetReference; - const { reference } = parseCaipChainId(parsed.chainId); - const hexChainId = numberToHex(parseInt(reference, 10)); - - const tokenListState = this.#messenger.call( - 'TokenListController:getState', - ); - const chainCacheEntry = tokenListState?.tokensChainsCache?.[hexChainId]; - const chainTokenList = chainCacheEntry?.data; - - if (!chainTokenList) { - return undefined; - } - - // Look up token by address (case-insensitive) - const lowerAddress = tokenAddress.toLowerCase(); - for (const [address, tokenData] of Object.entries(chainTokenList)) { - if (address.toLowerCase() === lowerAddress) { - const token = tokenData; - if (token.symbol && token.decimals !== undefined) { - return { - type: 'erc20', - symbol: token.symbol, - name: token.name ?? token.symbol, - decimals: token.decimals, - image: token.iconUrl, - }; - } - } - } - - return undefined; - } catch { - return undefined; - } - } - /** * Destroy the data source and clean up resources. */ diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/clients/TokensApiClient.test.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/clients/TokensApiClient.test.ts new file mode 100644 index 00000000000..330515f1479 --- /dev/null +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/clients/TokensApiClient.test.ts @@ -0,0 +1,389 @@ +import { TokensApiClient } from './TokensApiClient'; +import type { TokensApiClientConfig } from './TokensApiClient'; +import type { ChainId } from '../types'; + +// ============================================================================= +// CONSTANTS +// ============================================================================= + +const MAINNET_CHAIN_ID = '0x1' as ChainId; +const POLYGON_CHAIN_ID = '0x89' as ChainId; + +const EXPECTED_BASE_URL = 'https://tokens.api.cx.metamask.io/v3/chains'; + +// ============================================================================= +// HELPERS +// ============================================================================= + +type MockApiToken = { + assetId: string; + symbol?: string; + name?: string; + decimals?: number; + occurrences?: number; +}; + +function createMockResponse( + data: MockApiToken[], + status = 200, +): jest.Mocked { + return { + ok: status >= 200 && status < 300, + status, + json: jest.fn().mockResolvedValue({ data }), + } as unknown as jest.Mocked; +} + +function createMockFetch( + response: jest.Mocked, +): jest.MockedFunction { + return jest.fn().mockResolvedValue(response); +} + +function buildClient(config?: TokensApiClientConfig): TokensApiClient { + return new TokensApiClient(config); +} + +// ============================================================================= +// TESTS +// ============================================================================= + +describe('TokensApiClient', () => { + describe('constructor', () => { + it('uses globalThis.fetch by default', async () => { + const globalFetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(createMockResponse([])); + + const client = new TokensApiClient(); + await client.fetchTokenList(MAINNET_CHAIN_ID); + + expect(globalFetchSpy).toHaveBeenCalledTimes(1); + globalFetchSpy.mockRestore(); + }); + + it('uses the provided fetch function instead of globalThis.fetch', async () => { + const mockFetch = createMockFetch(createMockResponse([])); + const client = buildClient({ fetch: mockFetch }); + + await client.fetchTokenList(MAINNET_CHAIN_ID); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); + + describe('fetchTokenList', () => { + describe('URL construction', () => { + it('converts hex chain ID to CAIP chain ID in the URL', async () => { + const mockFetch = createMockFetch(createMockResponse([])); + const client = buildClient({ fetch: mockFetch }); + + await client.fetchTokenList(MAINNET_CHAIN_ID); + + const [url] = mockFetch.mock.calls[0] as [string]; + expect(url).toContain(`${EXPECTED_BASE_URL}/eip155:1/assets`); + }); + + it('correctly converts a non-mainnet hex chain ID', async () => { + const mockFetch = createMockFetch(createMockResponse([])); + const client = buildClient({ fetch: mockFetch }); + + await client.fetchTokenList(POLYGON_CHAIN_ID); + + const [url] = mockFetch.mock.calls[0] as [string]; + expect(url).toContain(`${EXPECTED_BASE_URL}/eip155:137/assets`); + }); + + it('includes all required query parameters', async () => { + const mockFetch = createMockFetch(createMockResponse([])); + const client = buildClient({ fetch: mockFetch }); + + await client.fetchTokenList(MAINNET_CHAIN_ID); + + const [url] = mockFetch.mock.calls[0] as [string]; + expect(url).toContain('first=25'); + expect(url).toContain('includeOccurrences=true'); + expect(url).toContain('includeMetadata=true'); + expect(url).toContain('occurrenceFloor=3'); + expect(url).toContain('includeRwaData=true'); + expect(url).toContain('excludeDescription=true'); + }); + }); + + describe('successful responses', () => { + it('returns an empty array when the API returns no data', async () => { + const mockFetch = createMockFetch(createMockResponse([])); + const client = buildClient({ fetch: mockFetch }); + + const result = await client.fetchTokenList(MAINNET_CHAIN_ID); + + expect(result).toStrictEqual([]); + }); + + it('maps a valid erc20 token entry to a TokenListEntry', async () => { + const mockFetch = createMockFetch( + createMockResponse([ + { + assetId: + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + occurrences: 10, + }, + ]), + ); + const client = buildClient({ fetch: mockFetch }); + + const result = await client.fetchTokenList(MAINNET_CHAIN_ID); + + expect(result).toStrictEqual([ + { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + occurrences: 10, + }, + ]); + }); + + it('returns multiple entries when the API returns multiple erc20 tokens', async () => { + const mockFetch = createMockFetch( + createMockResponse([ + { + assetId: + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + occurrences: 10, + }, + { + assetId: + 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7', + symbol: 'USDT', + name: 'Tether USD', + decimals: 6, + occurrences: 8, + }, + ]), + ); + const client = buildClient({ fetch: mockFetch }); + + const result = await client.fetchTokenList(MAINNET_CHAIN_ID); + + expect(result).toHaveLength(2); + expect(result[0].symbol).toBe('USDC'); + expect(result[1].symbol).toBe('USDT'); + }); + + it('extracts the contract address from the assetId', async () => { + const tokenAddress = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const mockFetch = createMockFetch( + createMockResponse([ + { + assetId: `eip155:1/erc20:${tokenAddress}`, + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }, + ]), + ); + const client = buildClient({ fetch: mockFetch }); + + const [entry] = await client.fetchTokenList(MAINNET_CHAIN_ID); + + expect(entry.address).toBe(tokenAddress); + }); + }); + + describe('optional field defaults', () => { + it('defaults symbol to empty string when missing', async () => { + const mockFetch = createMockFetch( + createMockResponse([ + { + assetId: + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + name: 'USD Coin', + decimals: 6, + }, + ]), + ); + const client = buildClient({ fetch: mockFetch }); + + const [entry] = await client.fetchTokenList(MAINNET_CHAIN_ID); + + expect(entry.symbol).toBe(''); + }); + + it('defaults name to empty string when missing', async () => { + const mockFetch = createMockFetch( + createMockResponse([ + { + assetId: + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + symbol: 'USDC', + decimals: 6, + }, + ]), + ); + const client = buildClient({ fetch: mockFetch }); + + const [entry] = await client.fetchTokenList(MAINNET_CHAIN_ID); + + expect(entry.name).toBe(''); + }); + + it('defaults decimals to 18 when missing', async () => { + const mockFetch = createMockFetch( + createMockResponse([ + { + assetId: + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + symbol: 'USDC', + name: 'USD Coin', + }, + ]), + ); + const client = buildClient({ fetch: mockFetch }); + + const [entry] = await client.fetchTokenList(MAINNET_CHAIN_ID); + + expect(entry.decimals).toBe(18); + }); + + it('includes occurrences as undefined when not present in the response', async () => { + const mockFetch = createMockFetch( + createMockResponse([ + { + assetId: + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }, + ]), + ); + const client = buildClient({ fetch: mockFetch }); + + const [entry] = await client.fetchTokenList(MAINNET_CHAIN_ID); + + expect(entry.occurrences).toBeUndefined(); + }); + }); + + describe('filtering non-erc20 assets', () => { + it('filters out native slip44 assets', async () => { + const mockFetch = createMockFetch( + createMockResponse([ + { + assetId: 'eip155:1/slip44:60', + symbol: 'ETH', + name: 'Ether', + decimals: 18, + }, + { + assetId: + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }, + ]), + ); + const client = buildClient({ fetch: mockFetch }); + + const result = await client.fetchTokenList(MAINNET_CHAIN_ID); + + expect(result).toHaveLength(1); + expect(result[0].symbol).toBe('USDC'); + }); + + it('filters out erc721 assets', async () => { + const mockFetch = createMockFetch( + createMockResponse([ + { + assetId: + 'eip155:1/erc721:0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D', + symbol: 'BAYC', + name: 'Bored Ape Yacht Club', + decimals: 0, + }, + ]), + ); + const client = buildClient({ fetch: mockFetch }); + + const result = await client.fetchTokenList(MAINNET_CHAIN_ID); + + expect(result).toStrictEqual([]); + }); + + it('returns an empty array when all items are non-erc20', async () => { + const mockFetch = createMockFetch( + createMockResponse([ + { + assetId: 'eip155:1/slip44:60', + symbol: 'ETH', + name: 'Ether', + decimals: 18, + }, + { + assetId: + 'eip155:1/erc721:0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D', + symbol: 'BAYC', + name: 'BAYC', + decimals: 0, + }, + ]), + ); + const client = buildClient({ fetch: mockFetch }); + + const result = await client.fetchTokenList(MAINNET_CHAIN_ID); + + expect(result).toStrictEqual([]); + }); + }); + + describe('error handling', () => { + it('throws when the API returns a 404', async () => { + const mockFetch = createMockFetch(createMockResponse([], 404)); + const client = buildClient({ fetch: mockFetch }); + + await expect(client.fetchTokenList(MAINNET_CHAIN_ID)).rejects.toThrow( + 'Tokens API responded with 404 for eip155:1', + ); + }); + + it('throws when the API returns a 500', async () => { + const mockFetch = createMockFetch(createMockResponse([], 500)); + const client = buildClient({ fetch: mockFetch }); + + await expect(client.fetchTokenList(MAINNET_CHAIN_ID)).rejects.toThrow( + 'Tokens API responded with 500 for eip155:1', + ); + }); + + it('includes the CAIP chain ID in the error message', async () => { + const mockFetch = createMockFetch(createMockResponse([], 503)); + const client = buildClient({ fetch: mockFetch }); + + await expect(client.fetchTokenList(POLYGON_CHAIN_ID)).rejects.toThrow( + 'eip155:137', + ); + }); + + it('propagates network errors thrown by fetch', async () => { + const networkError = new Error('Network failure'); + const mockFetch = jest.fn().mockRejectedValue(networkError); + const client = buildClient({ + fetch: mockFetch as unknown as typeof globalThis.fetch, + }); + + await expect(client.fetchTokenList(MAINNET_CHAIN_ID)).rejects.toThrow( + 'Network failure', + ); + }); + }); + }); +}); diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/clients/TokensApiClient.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/clients/TokensApiClient.ts new file mode 100644 index 00000000000..77879ad1eed --- /dev/null +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/clients/TokensApiClient.ts @@ -0,0 +1,76 @@ +import type { ChainId, TokenListEntry } from '../types'; + +const TOKENS_API_BASE_URL = 'https://tokens.api.cx.metamask.io/v3/chains'; + +/** How many tokens to request from the API per chain. */ +const TOKENS_API_FIRST = 25; + +/** Shape of a single item in the Tokens API response `data` array. */ +type ApiTokenData = { + assetId: string; + symbol?: string; + name?: string; + decimals?: number; + occurrences?: number; +}; + +export type TokensApiClientConfig = { + /** Fetch function (defaults to globalThis.fetch). */ + fetch?: typeof globalThis.fetch; +}; + +/** + * Client for the MetaMask Tokens API. + * Fetches the top ERC-20 tokens for a given chain (occurrenceFloor=3, first=25). + */ +export class TokensApiClient { + readonly #fetch: typeof globalThis.fetch; + + constructor(config?: TokensApiClientConfig) { + this.#fetch = config?.fetch ?? globalThis.fetch.bind(globalThis); + } + + /** + * Fetch the list of top ERC-20 tokens for a chain from the Tokens API. + * Only `erc20` assets are returned; native (`slip44`) entries are skipped. + * + * @param hexChainId - Chain ID in hex format (e.g. `'0x1'` for Ethereum mainnet). + * @returns Array of token list entries with address and metadata. + * @throws If the API responds with a non-2xx status. + */ + async fetchTokenList(hexChainId: ChainId): Promise { + const chainIdDecimal = parseInt(hexChainId, 16); + const caipChainId = `eip155:${chainIdDecimal}`; + + const url = + `${TOKENS_API_BASE_URL}/${caipChainId}/assets` + + `?first=${TOKENS_API_FIRST}` + + `&includeOccurrences=true` + + `&includeMetadata=true` + + `&occurrenceFloor=3` + + `&includeRwaData=true` + + `&excludeDescription=true`; + + const response = await this.#fetch(url); + if (!response.ok) { + throw new Error( + `Tokens API responded with ${response.status} for ${caipChainId}`, + ); + } + + const { data } = (await response.json()) as { data: ApiTokenData[] }; + + return data + .filter((item) => item.assetId.includes('/erc20:')) + .map((item) => { + const address = item.assetId.split('/erc20:')[1]; + return { + address, + symbol: item.symbol ?? '', + name: item.name ?? '', + decimals: item.decimals ?? 18, + occurrences: item.occurrences, + }; + }); + } +} diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/clients/index.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/clients/index.ts index 2b422f91c2e..53d8d1f66ee 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/clients/index.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/clients/index.ts @@ -5,5 +5,7 @@ export { type MulticallClientConfig, } from './MulticallClient'; +export { TokensApiClient, type TokensApiClientConfig } from './TokensApiClient'; + // Re-export provider types from types module export type { GetProviderFunction, Provider } from '../types'; diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/index.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/index.ts index 5b30f3b7d36..2e3f571136f 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/index.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/index.ts @@ -11,7 +11,12 @@ export type { BalanceFetchResult, TokenDetectionResult, } from './types'; -export { MulticallClient, type MulticallClientConfig } from './clients'; +export { + MulticallClient, + type MulticallClientConfig, + TokensApiClient, + type TokensApiClientConfig, +} from './clients'; export { BalanceFetcher, TokenDetector, diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/TokenDetector.test.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/TokenDetector.test.ts index 6c8375c3c6f..45c0b1065ba 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/services/TokenDetector.test.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/TokenDetector.test.ts @@ -1,17 +1,15 @@ -import type { Hex } from '@metamask/utils'; - import { TokenDetector } from './TokenDetector'; import type { TokenDetectorConfig, - TokenDetectorMessenger, DetectionPollingInput, } from './TokenDetector'; import type { MulticallClient } from '../clients'; +import type { TokensApiClient } from '../clients/TokensApiClient'; import type { Address, BalanceOfResponse, ChainId, - TokenListState, + TokenListEntry, } from '../types'; // ============================================================================= @@ -40,8 +38,7 @@ const createMockMulticallClient = (): jest.Mocked => batchBalanceOf: jest.fn(), }) as unknown as jest.Mocked; -function createMockTokenListState( - chainId: ChainId, +function createMockTokenList( tokens: { address: Address; symbol: string; @@ -50,20 +47,18 @@ function createMockTokenListState( iconUrl?: string; aggregators?: string[]; }[], -): TokenListState { - const data: Record = {}; - for (const token of tokens) { - data[token.address] = token; - } +): TokenListEntry[] { + return tokens.map((token) => ({ ...token })); +} +function createMockTokensApiClient( + tokenListByChain: Record = {}, +): jest.Mocked { return { - tokensChainsCache: { - [chainId]: { - timestamp: Date.now(), - data, - }, - }, - }; + fetchTokenList: jest.fn((chainId: ChainId) => + Promise.resolve(tokenListByChain[chainId] ?? []), + ), + } as unknown as jest.Mocked; } function createMockBalanceResponse( @@ -75,28 +70,19 @@ function createMockBalanceResponse( return { tokenAddress, accountAddress, success, balance }; } -function createMockMessenger( - tokenListState?: TokenListState, -): TokenDetectorMessenger { - return { - call: (_action: 'TokenListController:getState'): TokenListState => { - return tokenListState ?? { tokensChainsCache: {} }; - }, - }; -} - // ============================================================================= // WITH CONTROLLER PATTERN // ============================================================================= type WithControllerOptions = { config?: TokenDetectorConfig; - tokenListState?: TokenListState; + tokenListByChain?: Record; }; type WithControllerCallback = (params: { controller: TokenDetector; mockMulticallClient: jest.Mocked; + mockTokensApiClient: jest.Mocked; }) => Promise | ReturnValue; async function withController( @@ -112,18 +98,18 @@ async function withController( | [WithControllerCallback] ): Promise { const [options, fn] = args.length === 2 ? args : [{}, args[0]]; - const { config, tokenListState } = options; + const { config, tokenListByChain = {} } = options; const mockMulticallClient = createMockMulticallClient(); - const mockMessenger = createMockMessenger(tokenListState); + const mockTokensApiClient = createMockTokensApiClient(tokenListByChain); const controller = new TokenDetector( mockMulticallClient, - mockMessenger, + mockTokensApiClient, config, ); try { - return await fn({ controller, mockMulticallClient }); + return await fn({ controller, mockMulticallClient, mockTokensApiClient }); } finally { controller.stopAllPolling(); } @@ -188,7 +174,7 @@ describe('TokenDetector', () => { describe('setOnDetectionUpdate', () => { it('sets the detection update callback', async () => { - const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + const tokenList = createMockTokenList([ { address: TEST_TOKEN_1, symbol: 'USDC', @@ -200,7 +186,7 @@ describe('TokenDetector', () => { await withController( { config: { tokenDetectionEnabled: () => true }, - tokenListState: mockState, + tokenListByChain: { [MAINNET_CHAIN_ID]: tokenList }, }, async ({ controller, mockMulticallClient }) => { const mockCallback = jest.fn(); @@ -236,7 +222,7 @@ describe('TokenDetector', () => { }); it('does not call callback when no tokens detected', async () => { - const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + const tokenList = createMockTokenList([ { address: TEST_TOKEN_1, symbol: 'USDC', @@ -248,7 +234,7 @@ describe('TokenDetector', () => { await withController( { config: { tokenDetectionEnabled: () => true }, - tokenListState: mockState, + tokenListByChain: { [MAINNET_CHAIN_ID]: tokenList }, }, async ({ controller, mockMulticallClient }) => { const mockCallback = jest.fn(); @@ -325,15 +311,15 @@ describe('TokenDetector', () => { }); describe('getTokensToCheck', () => { - it('returns empty array when no token list state getter is set', async () => { + it('returns empty array when API returns empty list', async () => { await withController(async ({ controller }) => { - const tokens = controller.getTokensToCheck(MAINNET_CHAIN_ID); + const tokens = await controller.getTokensToCheck(MAINNET_CHAIN_ID); expect(tokens).toStrictEqual([]); }); }); - it('returns empty array when chain is not in cache', async () => { - const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + it('returns empty array when chain has no tokens in API', async () => { + const tokenList = createMockTokenList([ { address: TEST_TOKEN_1, symbol: 'USDC', @@ -343,35 +329,16 @@ describe('TokenDetector', () => { ]); await withController( - { tokenListState: mockState }, + { tokenListByChain: { [MAINNET_CHAIN_ID]: tokenList } }, async ({ controller }) => { - const tokens = controller.getTokensToCheck(POLYGON_CHAIN_ID); - expect(tokens).toStrictEqual([]); - }, - ); - }); - - it('returns empty array when chain cache data is undefined', async () => { - const mockState: TokenListState = { - tokensChainsCache: { - [MAINNET_CHAIN_ID]: { - timestamp: Date.now(), - data: undefined as unknown as Record, - }, - }, - }; - - await withController( - { tokenListState: mockState }, - async ({ controller }) => { - const tokens = controller.getTokensToCheck(MAINNET_CHAIN_ID); + const tokens = await controller.getTokensToCheck(POLYGON_CHAIN_ID); expect(tokens).toStrictEqual([]); }, ); }); it('returns all token addresses for the chain', async () => { - const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + const tokenList = createMockTokenList([ { address: TEST_TOKEN_1, symbol: 'USDC', @@ -393,9 +360,9 @@ describe('TokenDetector', () => { ]); await withController( - { tokenListState: mockState }, + { tokenListByChain: { [MAINNET_CHAIN_ID]: tokenList } }, async ({ controller }) => { - const tokens = controller.getTokensToCheck(MAINNET_CHAIN_ID); + const tokens = await controller.getTokensToCheck(MAINNET_CHAIN_ID); expect(tokens).toHaveLength(3); expect(tokens).toContain(TEST_TOKEN_1); expect(tokens).toContain(TEST_TOKEN_2); @@ -403,12 +370,24 @@ describe('TokenDetector', () => { }, ); }); + + it('calls the Tokens API with the correct chain ID', async () => { + await withController( + { tokenListByChain: {} }, + async ({ controller, mockTokensApiClient }) => { + await controller.getTokensToCheck(POLYGON_CHAIN_ID); + expect(mockTokensApiClient.fetchTokenList).toHaveBeenCalledWith( + POLYGON_CHAIN_ID, + ); + }, + ); + }); }); describe('detectTokens', () => { it('returns empty result when no tokens to check', async () => { await withController( - { tokenListState: { tokensChainsCache: {} } }, + { tokenListByChain: {} }, async ({ controller, mockMulticallClient }) => { const result = await controller.detectTokens( MAINNET_CHAIN_ID, @@ -433,7 +412,7 @@ describe('TokenDetector', () => { }); it('detects tokens with non-zero balances', async () => { - const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + const tokenList = createMockTokenList([ { address: TEST_TOKEN_1, symbol: 'USDC', @@ -447,7 +426,7 @@ describe('TokenDetector', () => { await withController( { config: { tokenDetectionEnabled: () => true }, - tokenListState: mockState, + tokenListByChain: { [MAINNET_CHAIN_ID]: tokenList }, }, async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ @@ -488,26 +467,19 @@ describe('TokenDetector', () => { }); it('includes detected asset but omits detectedBalances when token list entry has no decimals', async () => { - const mockState: TokenListState = { - tokensChainsCache: { - [MAINNET_CHAIN_ID]: { - timestamp: Date.now(), - data: { - [TEST_TOKEN_1]: { - address: TEST_TOKEN_1, - symbol: 'USDC', - name: 'USD Coin', - decimals: undefined as unknown as number, - }, - }, - }, + const tokenList: TokenListEntry[] = [ + { + address: TEST_TOKEN_1, + symbol: 'USDC', + name: 'USD Coin', + decimals: undefined as unknown as number, }, - }; + ]; await withController( { config: { tokenDetectionEnabled: () => true }, - tokenListState: mockState, + tokenListByChain: { [MAINNET_CHAIN_ID]: tokenList }, }, async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ @@ -532,7 +504,7 @@ describe('TokenDetector', () => { }); it('includes detectedBalances when token list entry has zero decimals', async () => { - const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + const tokenList = createMockTokenList([ { address: TEST_TOKEN_1, symbol: 'ZERO', @@ -544,7 +516,7 @@ describe('TokenDetector', () => { await withController( { config: { tokenDetectionEnabled: () => true }, - tokenListState: mockState, + tokenListByChain: { [MAINNET_CHAIN_ID]: tokenList }, }, async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ @@ -565,7 +537,7 @@ describe('TokenDetector', () => { }); it('categorizes zero balance tokens correctly', async () => { - const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + const tokenList = createMockTokenList([ { address: TEST_TOKEN_1, symbol: 'USDC', @@ -577,7 +549,7 @@ describe('TokenDetector', () => { await withController( { config: { tokenDetectionEnabled: () => true }, - tokenListState: mockState, + tokenListByChain: { [MAINNET_CHAIN_ID]: tokenList }, }, async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ @@ -599,7 +571,7 @@ describe('TokenDetector', () => { }); it('categorizes failed calls correctly', async () => { - const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + const tokenList = createMockTokenList([ { address: TEST_TOKEN_1, symbol: 'USDC', @@ -611,7 +583,7 @@ describe('TokenDetector', () => { await withController( { config: { tokenDetectionEnabled: () => true }, - tokenListState: mockState, + tokenListByChain: { [MAINNET_CHAIN_ID]: tokenList }, }, async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ @@ -631,7 +603,7 @@ describe('TokenDetector', () => { }); it('handles mixed results correctly', async () => { - const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + const tokenList = createMockTokenList([ { address: TEST_TOKEN_1, symbol: 'USDC', @@ -655,7 +627,7 @@ describe('TokenDetector', () => { await withController( { config: { tokenDetectionEnabled: () => true }, - tokenListState: mockState, + tokenListByChain: { [MAINNET_CHAIN_ID]: tokenList }, }, async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ @@ -685,7 +657,7 @@ describe('TokenDetector', () => { describe('tokenDetectionEnabled', () => { it('returns empty result and does not call batchBalanceOf when tokenDetectionEnabled is false in config', async () => { - const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + const tokenList = createMockTokenList([ { address: TEST_TOKEN_1, symbol: 'USDC', @@ -697,7 +669,7 @@ describe('TokenDetector', () => { await withController( { config: { tokenDetectionEnabled: () => false }, - tokenListState: mockState, + tokenListByChain: { [MAINNET_CHAIN_ID]: tokenList }, }, async ({ controller, mockMulticallClient }) => { const result = await controller.detectTokens( @@ -722,7 +694,7 @@ describe('TokenDetector', () => { }); it('runs detection when tokenDetectionEnabled is true in config', async () => { - const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + const tokenList = createMockTokenList([ { address: TEST_TOKEN_1, symbol: 'USDC', @@ -734,7 +706,7 @@ describe('TokenDetector', () => { await withController( { config: { tokenDetectionEnabled: () => true }, - tokenListState: mockState, + tokenListByChain: { [MAINNET_CHAIN_ID]: tokenList }, }, async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ @@ -759,7 +731,7 @@ describe('TokenDetector', () => { }); it('options.tokenDetectionEnabled overrides config when true', async () => { - const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + const tokenList = createMockTokenList([ { address: TEST_TOKEN_1, symbol: 'USDC', @@ -771,7 +743,7 @@ describe('TokenDetector', () => { await withController( { config: { tokenDetectionEnabled: () => false }, - tokenListState: mockState, + tokenListByChain: { [MAINNET_CHAIN_ID]: tokenList }, }, async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ @@ -797,7 +769,7 @@ describe('TokenDetector', () => { }); it('options.tokenDetectionEnabled overrides config when false', async () => { - const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + const tokenList = createMockTokenList([ { address: TEST_TOKEN_1, symbol: 'USDC', @@ -809,7 +781,7 @@ describe('TokenDetector', () => { await withController( { config: { tokenDetectionEnabled: () => true }, - tokenListState: mockState, + tokenListByChain: { [MAINNET_CHAIN_ID]: tokenList }, }, async ({ controller, mockMulticallClient }) => { const result = await controller.detectTokens( @@ -827,7 +799,7 @@ describe('TokenDetector', () => { }); it('_executePoll does not call onDetectionUpdate when tokenDetectionEnabled is false in config', async () => { - const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + const tokenList = createMockTokenList([ { address: TEST_TOKEN_1, symbol: 'USDC', @@ -839,7 +811,7 @@ describe('TokenDetector', () => { await withController( { config: { tokenDetectionEnabled: () => false }, - tokenListState: mockState, + tokenListByChain: { [MAINNET_CHAIN_ID]: tokenList }, }, async ({ controller, mockMulticallClient }) => { const mockCallback = jest.fn(); @@ -869,7 +841,7 @@ describe('TokenDetector', () => { }); it('returns empty result and does not call batchBalanceOf when useExternalService is false in config', async () => { - const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + const tokenList = createMockTokenList([ { address: TEST_TOKEN_1, symbol: 'USDC', @@ -884,7 +856,7 @@ describe('TokenDetector', () => { tokenDetectionEnabled: () => true, useExternalService: () => false, }, - tokenListState: mockState, + tokenListByChain: { [MAINNET_CHAIN_ID]: tokenList }, }, async ({ controller, mockMulticallClient }) => { const result = await controller.detectTokens( @@ -901,7 +873,7 @@ describe('TokenDetector', () => { }); it('runs detection when both tokenDetectionEnabled and useExternalService are true', async () => { - const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + const tokenList = createMockTokenList([ { address: TEST_TOKEN_1, symbol: 'USDC', @@ -916,7 +888,7 @@ describe('TokenDetector', () => { tokenDetectionEnabled: () => true, useExternalService: () => true, }, - tokenListState: mockState, + tokenListByChain: { [MAINNET_CHAIN_ID]: tokenList }, }, async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ @@ -941,7 +913,7 @@ describe('TokenDetector', () => { }); it('options.useExternalService overrides config when false', async () => { - const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + const tokenList = createMockTokenList([ { address: TEST_TOKEN_1, symbol: 'USDC', @@ -956,7 +928,7 @@ describe('TokenDetector', () => { tokenDetectionEnabled: () => true, useExternalService: () => true, }, - tokenListState: mockState, + tokenListByChain: { [MAINNET_CHAIN_ID]: tokenList }, }, async ({ controller, mockMulticallClient }) => { const result = await controller.detectTokens( @@ -973,7 +945,7 @@ describe('TokenDetector', () => { }); it('_executePoll does not call onDetectionUpdate when useExternalService is false in config', async () => { - const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + const tokenList = createMockTokenList([ { address: TEST_TOKEN_1, symbol: 'USDC', @@ -988,7 +960,7 @@ describe('TokenDetector', () => { tokenDetectionEnabled: () => true, useExternalService: () => false, }, - tokenListState: mockState, + tokenListByChain: { [MAINNET_CHAIN_ID]: tokenList }, }, async ({ controller, mockMulticallClient }) => { const mockCallback = jest.fn(); @@ -1020,7 +992,7 @@ describe('TokenDetector', () => { describe('asset creation', () => { it('creates correct CAIP-19 asset ID for mainnet', async () => { - const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + const tokenList = createMockTokenList([ { address: TEST_TOKEN_1, symbol: 'USDC', @@ -1032,7 +1004,7 @@ describe('TokenDetector', () => { await withController( { config: { tokenDetectionEnabled: () => true }, - tokenListState: mockState, + tokenListByChain: { [MAINNET_CHAIN_ID]: tokenList }, }, async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ @@ -1058,7 +1030,7 @@ describe('TokenDetector', () => { }); it('creates correct CAIP-19 asset ID for polygon', async () => { - const mockState = createMockTokenListState(POLYGON_CHAIN_ID, [ + const tokenList = createMockTokenList([ { address: TEST_TOKEN_1, symbol: 'USDC', @@ -1070,7 +1042,7 @@ describe('TokenDetector', () => { await withController( { config: { tokenDetectionEnabled: () => true }, - tokenListState: mockState, + tokenListByChain: { [POLYGON_CHAIN_ID]: tokenList }, }, async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ @@ -1098,7 +1070,7 @@ describe('TokenDetector', () => { describe('balance formatting', () => { it('formats balance with 6 decimals correctly', async () => { - const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + const tokenList = createMockTokenList([ { address: TEST_TOKEN_1, symbol: 'USDC', @@ -1110,7 +1082,7 @@ describe('TokenDetector', () => { await withController( { config: { tokenDetectionEnabled: () => true }, - tokenListState: mockState, + tokenListByChain: { [MAINNET_CHAIN_ID]: tokenList }, }, async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ @@ -1136,7 +1108,7 @@ describe('TokenDetector', () => { }); it('returns raw balance for invalid balance strings', async () => { - const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + const tokenList = createMockTokenList([ { address: TEST_TOKEN_1, symbol: 'USDC', @@ -1148,7 +1120,7 @@ describe('TokenDetector', () => { await withController( { config: { tokenDetectionEnabled: () => true }, - tokenListState: mockState, + tokenListByChain: { [MAINNET_CHAIN_ID]: tokenList }, }, async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ @@ -1176,7 +1148,7 @@ describe('TokenDetector', () => { describe('batching behavior', () => { it('uses custom batch size from options', async () => { - const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + const tokenList = createMockTokenList([ { address: TEST_TOKEN_1, symbol: 'USDC', @@ -1194,7 +1166,7 @@ describe('TokenDetector', () => { await withController( { config: { tokenDetectionEnabled: () => true }, - tokenListState: mockState, + tokenListByChain: { [MAINNET_CHAIN_ID]: tokenList }, }, async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ @@ -1214,7 +1186,7 @@ describe('TokenDetector', () => { }); it('accumulates results across multiple batches', async () => { - const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + const tokenList = createMockTokenList([ { address: TEST_TOKEN_1, symbol: 'USDC', @@ -1232,7 +1204,7 @@ describe('TokenDetector', () => { await withController( { config: { tokenDetectionEnabled: () => true }, - tokenListState: mockState, + tokenListByChain: { [MAINNET_CHAIN_ID]: tokenList }, }, async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf @@ -1275,26 +1247,19 @@ describe('TokenDetector', () => { const uppercaseAddress = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Address; - const mockState: TokenListState = { - tokensChainsCache: { - [MAINNET_CHAIN_ID]: { - timestamp: Date.now(), - data: { - [lowercaseAddress]: { - address: lowercaseAddress, - symbol: 'USDC', - name: 'USD Coin', - decimals: 6, - }, - }, - }, + const tokenList: TokenListEntry[] = [ + { + address: lowercaseAddress, + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, }, - }; + ]; await withController( { config: { tokenDetectionEnabled: () => true }, - tokenListState: mockState, + tokenListByChain: { [MAINNET_CHAIN_ID]: tokenList }, }, async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ @@ -1321,7 +1286,7 @@ describe('TokenDetector', () => { it('omits detectedBalances when token metadata is missing (no decimals fallback)', async () => { const unknownToken = '0x9999999999999999999999999999999999999999' as Address; - const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + const tokenList = createMockTokenList([ { address: TEST_TOKEN_1, symbol: 'USDC', @@ -1333,7 +1298,7 @@ describe('TokenDetector', () => { await withController( { config: { tokenDetectionEnabled: () => true }, - tokenListState: mockState, + tokenListByChain: { [MAINNET_CHAIN_ID]: tokenList }, }, async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/TokenDetector.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/TokenDetector.ts index d1f8be3a3d4..558f16e96e8 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/services/TokenDetector.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/TokenDetector.ts @@ -1,7 +1,9 @@ import { StaticIntervalPollingControllerOnly } from '@metamask/polling-controller'; import type { CaipAssetType } from '@metamask/utils'; +import { projectLogger, createModuleLogger } from '../../../logger'; import type { MulticallClient } from '../clients'; +import type { TokensApiClient } from '../clients/TokensApiClient'; import type { AccountId, Address, @@ -13,18 +15,12 @@ import type { TokenDetectionOptions, TokenDetectionResult, TokenListEntry, - TokenListState, } from '../types'; import { reduceInBatchesSerially } from '../utils'; -const DEFAULT_DETECTION_INTERVAL = 180_000; // 3 minutes +const log = createModuleLogger(projectLogger, 'TokenDetector'); -/** - * Minimal messenger interface for TokenDetector. - */ -export type TokenDetectorMessenger = { - call: (action: 'TokenListController:getState') => TokenListState; -}; +const DEFAULT_DETECTION_INTERVAL = 180_000; // 3 minutes export type TokenDetectorConfig = { /** Function returning whether token detection is enabled (avoids stale value) */ @@ -56,25 +52,28 @@ export type OnDetectionUpdateCallback = (result: TokenDetectionResult) => void; /** * TokenDetector - Detects tokens with non-zero balances via multicall. + * Fetches the token list from the Tokens API and uses multicall to check balances. * Extends StaticIntervalPollingControllerOnly for built-in polling support. */ export class TokenDetector extends StaticIntervalPollingControllerOnly() { readonly #multicallClient: MulticallClient; - readonly #messenger: TokenDetectorMessenger; + readonly #tokensApiClient: TokensApiClient; readonly #config: Required>; + readonly #tokenListCache: Map = new Map(); + #onDetectionUpdate: OnDetectionUpdateCallback | undefined; constructor( multicallClient: MulticallClient, - messenger: TokenDetectorMessenger, + tokensApiClient: TokensApiClient, config?: TokenDetectorConfig, ) { super(); this.#multicallClient = multicallClient; - this.#messenger = messenger; + this.#tokensApiClient = tokensApiClient; this.#config = { tokenDetectionEnabled: config?.tokenDetectionEnabled ?? ((): boolean => true), @@ -83,7 +82,6 @@ export class TokenDetector extends StaticIntervalPollingControllerOnly { - // Check if token list is available for this chain - const tokensToCheck = this.getTokensToCheck(input.chainId); - - if (tokensToCheck.length === 0) { - // No tokens in list for chain, will retry on next poll - return; - } - - const result = await this.detectTokens( - input.chainId, - input.accountId, - input.accountAddress, - ); + try { + const result = await this.detectTokens( + input.chainId, + input.accountId, + input.accountAddress, + ); - if (this.#onDetectionUpdate && result.detectedAssets.length > 0) { - this.#onDetectionUpdate(result); + if (this.#onDetectionUpdate && result.detectedAssets.length > 0) { + this.#onDetectionUpdate(result); + } + } catch (error) { + log('Token detection poll failed', { chainId: input.chainId, error }); } } - getTokensToCheck(chainId: ChainId): Address[] { - const tokenListState = this.#messenger.call('TokenListController:getState'); - - // Defensive check for tokensChainsCache - if (!tokenListState?.tokensChainsCache) { - return []; - } - - // Try direct lookup first - let chainCacheEntry = tokenListState.tokensChainsCache[chainId]; - - // If not found, try normalizing the chain ID (e.g., 0x0a -> 0xa) - if (!chainCacheEntry) { - const normalizedChainId: ChainId = `0x${parseInt(chainId, 16).toString( - 16, - )}`; - chainCacheEntry = tokenListState.tokensChainsCache[normalizedChainId]; - } - - const chainTokenList = chainCacheEntry?.data; - - if (!chainTokenList) { - return []; - } - - return Object.keys(chainTokenList) as Address[]; + /** + * Fetch the list of token addresses to check for the given chain. + * Calls the Tokens API and caches the result for metadata lookups. + * + * @param chainId - Chain ID in hex format. + * @returns Array of token contract addresses. + */ + async getTokensToCheck(chainId: ChainId): Promise { + const tokenList = await this.#fetchAndCacheTokenList(chainId); + return tokenList.map((entry) => entry.address as Address); } async detectTokens( @@ -177,7 +155,7 @@ export class TokenDetector extends StaticIntervalPollingControllerOnly { + try { + const list = await this.#tokensApiClient.fetchTokenList(chainId); + this.#tokenListCache.set(chainId, list); + return list; + } catch (error) { + const cached = this.#tokenListCache.get(chainId); + log('Failed to fetch token list; using stale cache', { + chainId, + cachedCount: cached?.length ?? 0, + error, + }); + return cached ?? []; + } + } + #processBalanceResponses( responses: BalanceOfResponse[], accumulator: { @@ -342,29 +336,15 @@ export class TokenDetector extends StaticIntervalPollingControllerOnly entry.address === tokenAddress); + if (exact) { + return exact; } - return undefined; + return list.find((entry) => entry.address.toLowerCase() === lowerAddress); } #createAsset( diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/index.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/index.ts index 4c8d7f95a0f..1eded823b02 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/services/index.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/index.ts @@ -1,7 +1,6 @@ export { TokenDetector, type TokenDetectorConfig, - type TokenDetectorMessenger, type DetectionPollingInput, type OnDetectionUpdateCallback, } from './TokenDetector';