From 54378f1b6890d4b12c81ace78e45e797f56c3542 Mon Sep 17 00:00:00 2001 From: juanmigdr Date: Fri, 17 Apr 2026 12:24:45 +0200 Subject: [PATCH 01/15] chore: remove tokensChainsCache usage when filtering verified tokens only based on aggregators --- .../src/TokenDetectionController.ts | 63 ++++---- .../assets-controllers/src/tokens-api-v3.ts | 138 ++++++++++++++++++ 2 files changed, 168 insertions(+), 33 deletions(-) create mode 100644 packages/assets-controllers/src/tokens-api-v3.ts diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index 89a724c8a58..38d717a723a 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -42,6 +42,7 @@ import type { Hex } from '@metamask/utils'; import { isEqual, mapValues, isObject, get } from 'lodash'; import type { AssetsContractController } from './AssetsContractController'; +import { fetchVerifiedTokensByAddresses } from './tokens-api-v3'; import { isTokenDetectionSupportedForNetwork } from './assetsUtil'; import { SUPPORTED_NETWORKS_ACCOUNTS_API_V4 } from './constants'; import type { TokenDetectionControllerMethodActions } from './TokenDetectionController-method-action-types'; @@ -773,7 +774,8 @@ export class TokenDetectionController extends StaticIntervalPollingController address.toLowerCase()); + const addressesToFetch = tokensSlice.filter((address) => { + const lower = address.toLowerCase(); + // Skip tokens already in allTokens + // Skip tokens in allIgnoredTokens + return ( + !existingTokenAddresses.includes(lower) && + !ignoredTokenAddresses.includes(lower) + ); + }); + + if (addressesToFetch.length === 0) { + return; + } + + const verifiedTokens = await fetchVerifiedTokensByAddresses( + chainId, + addressesToFetch, + ); + const tokensWithBalance: Token[] = []; const eventTokensDetails: string[] = []; - for (const tokenAddress of tokensSlice) { + for (const tokenAddress of addressesToFetch) { const lowercaseTokenAddress = tokenAddress.toLowerCase(); const checksummedTokenAddress = toChecksumHexAddress(tokenAddress); - // Skip tokens already in allTokens - if (existingTokenAddresses.includes(lowercaseTokenAddress)) { - continue; - } - - // Skip tokens in allIgnoredTokens - if (ignoredTokenAddresses.includes(lowercaseTokenAddress)) { - continue; - } - - // Check map of validated tokens (cache keys are lowercase) - const tokenData = - this.#tokensChainsCache[chainId]?.data?.[lowercaseTokenAddress]; - + const tokenData = verifiedTokens.get(lowercaseTokenAddress); if (!tokenData) { continue; } diff --git a/packages/assets-controllers/src/tokens-api-v3.ts b/packages/assets-controllers/src/tokens-api-v3.ts new file mode 100644 index 00000000000..0a1a3d42c9a --- /dev/null +++ b/packages/assets-controllers/src/tokens-api-v3.ts @@ -0,0 +1,138 @@ +import { convertHexToDecimal, handleFetch } from '@metamask/controller-utils'; +import type { Hex } from '@metamask/utils'; + +import type { TokenRwaData } from './token-service'; + +export const TOKENS_API_V3_BASE_URL = 'https://tokens.api.cx.metamask.io/v3'; + +/** + * Maximum number of asset IDs per API request. Requests with more IDs are + * split into parallel batches of this size. + */ +export const MAX_BATCH_SIZE = 25; + +/** + * The minimum number of occurrences (aggregator listings) a token must have to + * be considered verified. Tokens below this threshold are treated as potential + * spam and excluded from detection results. + */ +export const MIN_OCCURRENCES = 3; + +export type TokenV3Asset = { + assetId: string; + decimals: number; + iconUrl: string; + name: string; + symbol: string; + occurrences: number; + aggregators?: string[]; + rwaData?: TokenRwaData; +}; + +// In-flight deduplication so parallel callers for the same batch share a +// single HTTP request. +const inFlight = new Map>(); + +/** + * Fetch a single batch of token metadata from the v3 API. + * + * @param assetIds - CAIP-19 asset IDs for this batch (max {@link MAX_BATCH_SIZE}). + * @returns Resolved token assets returned by the API. + */ +async function fetchTokenBatch(assetIds: string[]): Promise { + const key = assetIds.join(','); + + const existing = inFlight.get(key); + if (existing) { + return existing; + } + + const params = new URLSearchParams({ + assetIds: assetIds.join(','), + includeOccurrences: 'true', + includeIconUrl: 'true', + includeAggregators: 'true', + includeRwaData: 'true', + }); + + const promise = (async () => { + try { + const data = (await handleFetch( + `${TOKENS_API_V3_BASE_URL}/assets?${params}`, + )) as TokenV3Asset[]; + return Array.isArray(data) ? data : []; + } finally { + inFlight.delete(key); + } + })(); + + inFlight.set(key, promise); + return promise; +} + +/** + * Fetch token metadata from the v3 tokens API for the given asset IDs, + * splitting large inputs into parallel batches of at most {@link MAX_BATCH_SIZE}. + * + * @param assetIds - CAIP-19 asset IDs to fetch. + * @returns All resolved token assets across all batches. + */ +async function fetchTokenAssets(assetIds: string[]): Promise { + const batches: string[][] = []; + for (let i = 0; i < assetIds.length; i += MAX_BATCH_SIZE) { + batches.push(assetIds.slice(i, i + MAX_BATCH_SIZE)); + } + const results = await Promise.all(batches.map(fetchTokenBatch)); + return results.flat(); +} + +/** + * Build a CAIP-19 ERC-20 asset ID from a chain ID and a token address. + * + * @param chainId - Hex chain ID (e.g. `0x1`). + * @param tokenAddress - ERC-20 contract address (any casing). + * @returns CAIP-19 asset ID string, e.g. `eip155:1/erc20:0xabc...`. + */ +export function buildCaipAssetId(chainId: Hex, tokenAddress: string): string { + const decimalChainId = convertHexToDecimal(chainId); + return `eip155:${decimalChainId}/erc20:${tokenAddress.toLowerCase()}`; +} + +/** + * Fetch token metadata for the given ERC-20 addresses on a specific chain, + * filtering out tokens that do not meet the minimum occurrences threshold + * (spam filter). + * + * Results are keyed by lowercase token address for easy lookup. + * + * @param chainId - Hex chain ID. + * @param tokenAddresses - ERC-20 token addresses to look up. + * @returns Map from lowercase token address to verified token asset data. + */ +export async function fetchVerifiedTokensByAddresses( + chainId: Hex, + tokenAddresses: string[], +): Promise> { + if (tokenAddresses.length === 0) { + return new Map(); + } + + const assetIds = tokenAddresses.map((address) => + buildCaipAssetId(chainId, address), + ); + + const assets = await fetchTokenAssets(assetIds); + + const result = new Map(); + for (const asset of assets) { + if (asset.occurrences >= MIN_OCCURRENCES) { + // Extract the address part from the CAIP-19 ID: "eip155:1/erc20:0xabc" → "0xabc" + const address = asset.assetId.split('/erc20:')[1]?.toLowerCase(); + if (address) { + result.set(address, asset); + } + } + } + + return result; +} From 41acf1ef69e6232b922d407985df8c497849ac46 Mon Sep 17 00:00:00 2001 From: juanmigdr Date: Fri, 17 Apr 2026 16:32:42 +0200 Subject: [PATCH 02/15] chore: updated mechanism of rwaData enrochment --- .../src/TokenListController.ts | 85 +++++++++++++++---- .../src/TokensController.ts | 78 +++++++++-------- 2 files changed, 113 insertions(+), 50 deletions(-) diff --git a/packages/assets-controllers/src/TokenListController.ts b/packages/assets-controllers/src/TokenListController.ts index d0a18ca4a17..111dc96b8fc 100644 --- a/packages/assets-controllers/src/TokenListController.ts +++ b/packages/assets-controllers/src/TokenListController.ts @@ -23,6 +23,7 @@ import { formatAggregatorNames, formatIconUrlWithProxy, } from './assetsUtil'; +import { SUPPORTED_NETWORKS_ACCOUNTS_API_V4 } from './constants'; import { TokenRwaData, fetchTokenListByChainId } from './token-service'; // 4 Hour Interval Cache Refresh Threshold @@ -61,7 +62,19 @@ export type TokenListStateChange = ControllerStateChangeEvent< TokenListState >; -export type TokenListControllerEvents = TokenListStateChange; +/** + * Event emitted after a token list is successfully fetched from the API for a + * given chain. Carries the processed token list directly so consumers can use + * it without going through tokensChainsCache. + */ +export type TokenListTokenListFetchedEvent = { + type: `${typeof name}:tokenListFetched`; + payload: [{ chainId: Hex; tokenList: TokenListMap }]; +}; + +export type TokenListControllerEvents = + | TokenListStateChange + | TokenListTokenListFetchedEvent; export type GetTokenListState = ControllerGetStateAction< typeof name, @@ -371,10 +384,15 @@ export class TokenListController extends StaticIntervalPollingController - key.startsWith(`${TokenListController.#storageKeyPrefix}:`), - ); + // Filter keys that belong to tokensChainsCache (per-chain files), + // excluding V4-supported chains which no longer use this cache. + const cacheKeys = allKeys.filter((key) => { + if (!key.startsWith(`${TokenListController.#storageKeyPrefix}:`)) { + return false; + } + const chainId = key.split(':')[1] as Hex; + return !SUPPORTED_NETWORKS_ACCOUNTS_API_V4.includes(chainId); + }); // Load all chains in parallel const chainCaches = await Promise.all( @@ -408,11 +426,29 @@ export class TokenListController extends StaticIntervalPollingController !SUPPORTED_NETWORKS_ACCOUNTS_API_V4.includes(chainId), + ), ); + // Purge any V4 chain entries that survived in state (e.g. from stale + // Redux persistence before the client migration ran). + const v4ChainsInState = ( + Object.keys(this.state.tokensChainsCache) as Hex[] + ).filter((chainId) => SUPPORTED_NETWORKS_ACCOUNTS_API_V4.includes(chainId)); + + if (v4ChainsInState.length > 0) { + this.update((state) => { + for (const chainId of v4ChainsInState) { + delete state.tokensChainsCache[chainId]; + } + }); + } + // Merge loaded cache with existing state, preferring existing data // (which may be fresher if fetched during initialization) if (Object.keys(loadedCache).length > 0) { @@ -632,14 +668,27 @@ export class TokenListController extends StaticIntervalPollingController { - state.tokensChainsCache[chainId] = newDataCache; + // Publish the processed token list so subscribers (e.g. TokensController) + // can enrich their state directly from the API response without going + // through tokensChainsCache. + this.messenger.publish(`${name}:tokenListFetched`, { + chainId, + tokenList, }); + + // For chains supported by the Accounts API, token detection is handled + // via the WS/polling paths which call the v3 API directly. Writing the + // full token list to tokensChainsCache is unnecessary and wastes storage. + if (!SUPPORTED_NETWORKS_ACCOUNTS_API_V4.includes(chainId)) { + const newDataCache: DataCache = { + data: tokenList, + timestamp: Date.now(), + }; + this.update((state) => { + state.tokensChainsCache[chainId] = newDataCache; + }); + } + return; } @@ -647,7 +696,8 @@ export class TokenListController extends StaticIntervalPollingController { - const { allTokens } = this.state; - const selectedAddress = this.#getSelectedAddress(); - - // Deep clone the `allTokens` object to ensure mutability - const updatedAllTokens = cloneDeep(allTokens); - - for (const [chainId, chainCache] of Object.entries(tokensChainsCache)) { - const chainData = chainCache?.data ?? {}; - - if (updatedAllTokens[chainId as Hex]) { - if (updatedAllTokens[chainId as Hex][selectedAddress]) { - const tokens = updatedAllTokens[chainId as Hex][selectedAddress]; - - for (const [, token] of Object.entries(tokens)) { - const cachedToken = chainData[token.address.toLowerCase()]; - if (cachedToken && cachedToken.name && !token.name) { - token.name = cachedToken.name; // Update the token name - } - if (cachedToken?.rwaData) { - token.rwaData = cachedToken.rwaData; // Update the token RWA data - } - } - } - } - } - - // Update the state with the modified tokens - this.update(() => { - return { - ...this.state, - allTokens: updatedAllTokens, - }; - }); + 'TokenListController:tokenListFetched', + ({ chainId, tokenList }) => { + this.#enrichTokensFromTokenList(chainId, tokenList); }, ); } @@ -340,6 +311,43 @@ export class TokensController extends BaseController< }); } + /** + * Enriches tokens in `allTokens` for a given chain using data from a freshly + * fetched token list. Updates `name` (when missing) and `rwaData` for the + * currently selected address. + * + * @param chainId - The chain whose token list was just fetched. + * @param tokenList - The processed token list from the API response. + */ + #enrichTokensFromTokenList(chainId: Hex, tokenList: TokenListMap): void { + const { allTokens } = this.state; + const selectedAddress = this.#getSelectedAddress(); + + const tokensForChainAndAddress = allTokens[chainId]?.[selectedAddress]; + + if (!tokensForChainAndAddress?.length) { + return; + } + + // Deep clone the `allTokens` object to ensure mutability + const updatedAllTokens = cloneDeep(allTokens); + const tokens = updatedAllTokens[chainId][selectedAddress]; + + for (const token of tokens) { + const cachedToken = tokenList[token.address.toLowerCase()]; + if (cachedToken && cachedToken.name && !token.name) { + token.name = cachedToken.name; // Update the token name + } + if (cachedToken?.rwaData) { + token.rwaData = cachedToken.rwaData; // Update the token RWA data + } + } + + this.update((state) => { + state.allTokens = updatedAllTokens; + }); + } + /** * Handles the event when the network state changes. * From c534a2e4cdb623f3bc4c18d11353cdf432010bba Mon Sep 17 00:00:00 2001 From: juanmigdr Date: Tue, 21 Apr 2026 13:29:38 +0200 Subject: [PATCH 03/15] chore: made some changes --- .../src/TokenDetectionController.test.ts | 1407 ++++++----------- .../src/TokenDetectionController.ts | 160 +- .../src/TokenListController.ts | 85 +- .../src/TokensController.test.ts | 177 +-- .../src/TokensController.ts | 165 +- 5 files changed, 767 insertions(+), 1227 deletions(-) diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index b55cb0fb20d..9a124e55d93 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -37,8 +37,30 @@ import { buildInfuraNetworkConfiguration, } from '../../network-controller/tests/helpers'; import { formatAggregatorNames } from './assetsUtil'; -import { TOKEN_END_POINT_API } from './token-service'; +import { TOKEN_END_POINT_API, fetchTokenListByChainId } from './token-service'; +import { fetchVerifiedTokensByAddresses } from './tokens-api-v3'; +import type { TokenV3Asset } from './tokens-api-v3'; import type { TokenDetectionControllerMessenger } from './TokenDetectionController'; + +jest.mock('./token-service', () => ({ + ...jest.requireActual('./token-service'), + fetchTokenListByChainId: jest.fn(), +})); + +jest.mock('./tokens-api-v3', () => ({ + ...jest.requireActual('./tokens-api-v3'), + fetchVerifiedTokensByAddresses: jest.fn(), +})); + +const mockFetchTokenListByChainId = + fetchTokenListByChainId as jest.MockedFunction< + typeof fetchTokenListByChainId + >; + +const mockFetchVerifiedTokensByAddresses = + fetchVerifiedTokensByAddresses as jest.MockedFunction< + typeof fetchVerifiedTokensByAddresses + >; import { TokenDetectionController, controllerName, @@ -95,7 +117,7 @@ const sampleTokenA = { symbol: tokenAFromList.symbol, decimals: tokenAFromList.decimals, image: - 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x514910771af9ca656af840dff83e8264ecf986ca.png', + 'https://static.cx.metamask.io/api/v1/tokenIcons/43114/0x514910771af9ca656af840dff83e8264ecf986ca.png', isERC721: false, aggregators: formattedSampleAggregators, name: 'Chainlink', @@ -105,7 +127,7 @@ const sampleTokenB = { symbol: tokenBFromList.symbol, decimals: tokenBFromList.decimals, image: - 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c.png', + 'https://static.cx.metamask.io/api/v1/tokenIcons/43114/0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c.png', isERC721: false, aggregators: formattedSampleAggregators, name: 'Bancor', @@ -205,7 +227,6 @@ function buildTokenDetectionControllerMessenger( 'NetworkController:getState', 'TokensController:getState', 'TokensController:addDetectedTokens', - 'TokenListController:getState', 'PreferencesController:getState', 'TokensController:addTokens', 'NetworkController:findNetworkClientIdByChainId', @@ -215,7 +236,6 @@ function buildTokenDetectionControllerMessenger( 'KeyringController:lock', 'KeyringController:unlock', 'NetworkController:networkDidChange', - 'TokenListController:stateChange', 'PreferencesController:stateChange', 'TransactionController:transactionConfirmed', ], @@ -227,6 +247,8 @@ describe('TokenDetectionController', () => { const defaultSelectedAccount = createMockInternalAccount(); beforeEach(async () => { + mockFetchVerifiedTokensByAddresses.mockResolvedValue(new Map()); + mockFetchTokenListByChainId.mockResolvedValue(undefined); nock(TOKEN_END_POINT_API) .get(getTokensPath(ChainId.mainnet)) .reply(200, sampleTokenList) @@ -410,11 +432,28 @@ describe('TokenDetectionController', () => { getAccount: selectedAccount, getSelectedAccount: selectedAccount, }, + mockTokenListState: { + tokensChainsCache: { + '0xa86a': { + timestamp: 0, + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }, + }, + }, }, async ({ controller, - mockTokenListGetState, callActionSpy, mockGetNetworkClientById, mockNetworkState, @@ -432,26 +471,6 @@ describe('TokenDetectionController', () => { }) as unknown as AutoManagedNetworkClient, ); - mockTokenListGetState({ - ...getDefaultTokenListState(), - tokensChainsCache: { - '0xa86a': { - timestamp: 0, - data: { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }, - }, - }, - }); - await controller.start(); expect(callActionSpy).toHaveBeenCalledWith( @@ -480,11 +499,7 @@ describe('TokenDetectionController', () => { getAccount: selectedAccount, getSelectedAccount: selectedAccount, }, - }, - - async ({ controller, mockTokenListGetState, callActionSpy }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { '0xa86a': { timestamp: 0, @@ -501,8 +516,10 @@ describe('TokenDetectionController', () => { }, }, }, - }); + }, + }, + async ({ controller, callActionSpy }) => { await controller.start(); expect(callActionSpy).not.toHaveBeenCalledWith( @@ -533,10 +550,27 @@ describe('TokenDetectionController', () => { getAccount: selectedAccount, getSelectedAccount: selectedAccount, }, + mockTokenListState: { + tokensChainsCache: { + '0xa86a': { + timestamp: 0, + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }, + }, + }, }, async ({ controller, - mockTokenListGetState, mockNetworkState, mockGetNetworkClientById, mockFindNetworkClientIdByChainId, @@ -554,25 +588,6 @@ describe('TokenDetectionController', () => { }) as unknown as AutoManagedNetworkClient, ); mockFindNetworkClientIdByChainId(() => 'avalanche'); - mockTokenListGetState({ - ...getDefaultTokenListState(), - tokensChainsCache: { - '0xa86a': { - timestamp: 0, - data: { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }, - }, - }, - }); await controller.start(); @@ -605,50 +620,38 @@ describe('TokenDetectionController', () => { getSelectedAccount: selectedAccount, }, }, - async ({ - controller, - mockTokenListGetState, - callActionSpy, - mockNetworkState, - }) => { + async ({ controller, callActionSpy, mockNetworkState }) => { mockNetworkState({ ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'avalanche', }); - const tokenListState = { - ...getDefaultTokenListState(), - tokensChainsCache: { - '0xa86a': { - timestamp: 0, - data: { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }, - }, - }, - }; - mockTokenListGetState(tokenListState); await controller.start(); - tokenListState.tokensChainsCache['0xa86a'].data[ - sampleTokenB.address - ] = { - name: sampleTokenB.name, - symbol: sampleTokenB.symbol, - decimals: sampleTokenB.decimals, - address: sampleTokenB.address, - occurrences: 1, - aggregators: sampleTokenB.aggregators, - iconUrl: sampleTokenB.image, - }; - mockTokenListGetState(tokenListState); + mockFetchTokenListByChainId.mockImplementation((fetchChainId) => { + if (fetchChainId === '0xa86a') { + return Promise.resolve([ + { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + { + name: sampleTokenB.name, + symbol: sampleTokenB.symbol, + decimals: sampleTokenB.decimals, + address: sampleTokenB.address, + occurrences: 1, + aggregators: sampleTokenB.aggregators, + iconUrl: sampleTokenB.image, + }, + ]); + } + return Promise.resolve(undefined); + }); await jestAdvanceTime({ duration: interval }); expect(callActionSpy).toHaveBeenCalledWith( @@ -676,18 +679,7 @@ describe('TokenDetectionController', () => { getAccount: selectedAccount, getSelectedAccount: selectedAccount, }, - }, - async ({ - controller, - mockTokensGetState, - mockTokenListGetState, - callActionSpy, - }) => { - mockTokensGetState({ - ...getDefaultTokensState(), - }); - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { '0xa86a': { timestamp: 0, @@ -704,6 +696,11 @@ describe('TokenDetectionController', () => { }, }, }, + }, + }, + async ({ controller, mockTokensGetState, callActionSpy }) => { + mockTokensGetState({ + ...getDefaultTokensState(), }); await controller.start(); @@ -727,10 +724,7 @@ describe('TokenDetectionController', () => { mocks: { getSelectedAccount: defaultSelectedAccount, }, - }, - async ({ controller, mockTokenListGetState, callActionSpy }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { '0xa86a': { timestamp: 0, @@ -747,8 +741,9 @@ describe('TokenDetectionController', () => { }, }, }, - }); - + }, + }, + async ({ controller, callActionSpy }) => { await controller.start(); expect(callActionSpy).not.toHaveBeenCalledWith( @@ -788,26 +783,7 @@ describe('TokenDetectionController', () => { mocks: { getSelectedAccount: firstSelectedAccount, }, - }, - async ({ - mockGetAccount, - mockTokenListGetState, - triggerSelectedAccountChange, - callActionSpy, - mockNetworkState, - }) => { - // Set selectedNetworkClientId to avalanche and include it in networkConfigurationsByChainId - const defaultState = getDefaultNetworkControllerState(); - mockNetworkState({ - ...defaultState, - selectedNetworkClientId: 'avalanche', - networkConfigurationsByChainId: { - ...defaultState.networkConfigurationsByChainId, - ...mockNetworkConfigurationsByChainId, - }, - }); - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { '0xa86a': { timestamp: 0, @@ -824,6 +800,23 @@ describe('TokenDetectionController', () => { }, }, }, + }, + }, + async ({ + mockGetAccount, + triggerSelectedAccountChange, + callActionSpy, + mockNetworkState, + }) => { + // Set selectedNetworkClientId to avalanche and include it in networkConfigurationsByChainId + const defaultState = getDefaultNetworkControllerState(); + mockNetworkState({ + ...defaultState, + selectedNetworkClientId: 'avalanche', + networkConfigurationsByChainId: { + ...defaultState.networkConfigurationsByChainId, + ...mockNetworkConfigurationsByChainId, + }, }); mockGetAccount(secondSelectedAccount); @@ -855,14 +848,7 @@ describe('TokenDetectionController', () => { mocks: { getSelectedAccount: selectedAccount, }, - }, - async ({ - mockTokenListGetState, - triggerSelectedAccountChange, - callActionSpy, - }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { '0xa86a': { timestamp: 0, @@ -879,8 +865,9 @@ describe('TokenDetectionController', () => { }, }, }, - }); - + }, + }, + async ({ triggerSelectedAccountChange, callActionSpy }) => { triggerSelectedAccountChange({ address: selectedAccount.address, } as InternalAccount); @@ -914,14 +901,7 @@ describe('TokenDetectionController', () => { getSelectedAccount: firstSelectedAccount, }, isKeyringUnlocked: false, - }, - async ({ - mockTokenListGetState, - triggerSelectedAccountChange, - callActionSpy, - }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { '0xa86a': { timestamp: 0, @@ -938,8 +918,9 @@ describe('TokenDetectionController', () => { }, }, }, - }); - + }, + }, + async ({ triggerSelectedAccountChange, callActionSpy }) => { triggerSelectedAccountChange({ address: secondSelectedAccount.address, } as InternalAccount); @@ -974,14 +955,7 @@ describe('TokenDetectionController', () => { mocks: { getSelectedAccount: firstSelectedAccount, }, - }, - async ({ - mockTokenListGetState, - triggerSelectedAccountChange, - callActionSpy, - }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { '0xa86a': { timestamp: 0, @@ -998,8 +972,9 @@ describe('TokenDetectionController', () => { }, }, }, - }); - + }, + }, + async ({ triggerSelectedAccountChange, callActionSpy }) => { triggerSelectedAccountChange({ address: secondSelectedAccount.address, } as InternalAccount); @@ -1043,17 +1018,7 @@ describe('TokenDetectionController', () => { mocks: { getSelectedAccount: firstSelectedAccount, }, - }, - async ({ - mockGetAccount, - mockTokenListGetState, - mockNetworkState, - triggerPreferencesStateChange, - triggerSelectedAccountChange, - callActionSpy, - }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { '0xa86a': { timestamp: 0, @@ -1070,7 +1035,15 @@ describe('TokenDetectionController', () => { }, }, }, - }); + }, + }, + async ({ + mockGetAccount, + mockNetworkState, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + callActionSpy, + }) => { mockNetworkState({ networkConfigurationsByChainId: { '0xa86a': { @@ -1128,18 +1101,7 @@ describe('TokenDetectionController', () => { mocks: { getSelectedAccount: firstSelectedAccount, }, - }, - async ({ - mockGetAccount, - mockTokenListGetState, - mockNetworkState, - triggerPreferencesStateChange, - triggerSelectedAccountChange, - controller, - }) => { - const mockTokens = jest.spyOn(controller, 'detectTokens'); - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { '0xa86a': { timestamp: 0, @@ -1156,7 +1118,16 @@ describe('TokenDetectionController', () => { }, }, }, - }); + }, + }, + async ({ + mockGetAccount, + mockNetworkState, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + controller, + }) => { + const mockTokens = jest.spyOn(controller, 'detectTokens'); // Set to avalanche which is not in SUPPORTED_NETWORKS_ACCOUNTS_API_V4 mockNetworkState({ ...getDefaultNetworkControllerState(), @@ -1195,22 +1166,7 @@ describe('TokenDetectionController', () => { mocks: { getSelectedAccount: selectedAccount, }, - }, - async ({ - mockGetAccount, - mockTokenListGetState, - triggerPreferencesStateChange, - callActionSpy, - mockNetworkState, - }) => { - // Set selectedNetworkClientId to avalanche (not in SUPPORTED_NETWORKS_ACCOUNTS_API_V4) - mockNetworkState({ - ...getDefaultNetworkControllerState(), - selectedNetworkClientId: 'avalanche', - }); - mockGetAccount(selectedAccount); - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { '0xa86a': { timestamp: 0, @@ -1227,7 +1183,20 @@ describe('TokenDetectionController', () => { }, }, }, + }, + }, + async ({ + mockGetAccount, + triggerPreferencesStateChange, + callActionSpy, + mockNetworkState, + }) => { + // Set selectedNetworkClientId to avalanche (not in SUPPORTED_NETWORKS_ACCOUNTS_API_V4) + mockNetworkState({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'avalanche', }); + mockGetAccount(selectedAccount); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), @@ -1269,17 +1238,7 @@ describe('TokenDetectionController', () => { mocks: { getSelectedAccount: firstSelectedAccount, }, - }, - async ({ - mockGetAccount, - mockTokenListGetState, - triggerSelectedAccountChange, - triggerPreferencesStateChange, - callActionSpy, - }) => { - mockGetAccount(firstSelectedAccount); - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { '0xa86a': { data: { @@ -1296,9 +1255,17 @@ describe('TokenDetectionController', () => { timestamp: 0, }, }, - }); - - triggerPreferencesStateChange({ + }, + }, + async ({ + mockGetAccount, + triggerSelectedAccountChange, + triggerPreferencesStateChange, + callActionSpy, + }) => { + mockGetAccount(firstSelectedAccount); + + triggerPreferencesStateChange({ ...getDefaultPreferencesState(), useTokenDetection: false, }); @@ -1330,14 +1297,7 @@ describe('TokenDetectionController', () => { getAccount: selectedAccount, getSelectedAccount: selectedAccount, }, - }, - async ({ - mockTokenListGetState, - triggerPreferencesStateChange, - callActionSpy, - }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { [ChainId.sepolia]: { data: { @@ -1354,8 +1314,9 @@ describe('TokenDetectionController', () => { timestamp: 0, }, }, - }); - + }, + }, + async ({ triggerPreferencesStateChange, callActionSpy }) => { triggerPreferencesStateChange({ ...getDefaultPreferencesState(), useTokenDetection: true, @@ -1392,16 +1353,7 @@ describe('TokenDetectionController', () => { getAccount: firstSelectedAccount, }, isKeyringUnlocked: false, - }, - async ({ - mockGetAccount, - mockTokenListGetState, - triggerPreferencesStateChange, - triggerSelectedAccountChange, - callActionSpy, - }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { [ChainId.sepolia]: { data: { @@ -1418,8 +1370,14 @@ describe('TokenDetectionController', () => { timestamp: 0, }, }, - }); - + }, + }, + async ({ + mockGetAccount, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + callActionSpy, + }) => { triggerPreferencesStateChange({ ...getDefaultPreferencesState(), useTokenDetection: true, @@ -1453,14 +1411,7 @@ describe('TokenDetectionController', () => { getSelectedAccount: selectedAccount, getAccount: selectedAccount, }, - }, - async ({ - mockTokenListGetState, - triggerPreferencesStateChange, - callActionSpy, - }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { [ChainId.sepolia]: { data: { @@ -1477,8 +1428,9 @@ describe('TokenDetectionController', () => { timestamp: 0, }, }, - }); - + }, + }, + async ({ triggerPreferencesStateChange, callActionSpy }) => { triggerPreferencesStateChange({ ...getDefaultPreferencesState(), useTokenDetection: false, @@ -1520,16 +1472,7 @@ describe('TokenDetectionController', () => { getAccount: firstSelectedAccount, getSelectedAccount: firstSelectedAccount, }, - }, - async ({ - mockGetAccount, - mockTokenListGetState, - triggerPreferencesStateChange, - triggerSelectedAccountChange, - callActionSpy, - }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { [ChainId.sepolia]: { data: { @@ -1546,8 +1489,14 @@ describe('TokenDetectionController', () => { timestamp: 0, }, }, - }); - + }, + }, + async ({ + mockGetAccount, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + callActionSpy, + }) => { triggerPreferencesStateChange({ ...getDefaultPreferencesState(), useTokenDetection: true, @@ -1580,14 +1529,7 @@ describe('TokenDetectionController', () => { getAccount: selectedAccount, getSelectedAccount: selectedAccount, }, - }, - async ({ - mockTokenListGetState, - triggerPreferencesStateChange, - callActionSpy, - }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { [ChainId.sepolia]: { data: { @@ -1604,8 +1546,9 @@ describe('TokenDetectionController', () => { timestamp: 0, }, }, - }); - + }, + }, + async ({ triggerPreferencesStateChange, callActionSpy }) => { triggerPreferencesStateChange({ ...getDefaultPreferencesState(), useTokenDetection: false, @@ -1654,14 +1597,7 @@ describe('TokenDetectionController', () => { getAccount: selectedAccount, getSelectedAccount: selectedAccount, }, - }, - async ({ - mockTokenListGetState, - callActionSpy, - triggerNetworkDidChange, - }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { [ChainId.sepolia]: { timestamp: 0, @@ -1678,8 +1614,9 @@ describe('TokenDetectionController', () => { }, }, }, - }); - + }, + }, + async ({ callActionSpy, triggerNetworkDidChange }) => { triggerNetworkDidChange({ ...getDefaultNetworkControllerState(), selectedNetworkClientId: NetworkType.sepolia, @@ -1710,14 +1647,7 @@ describe('TokenDetectionController', () => { getAccount: selectedAccount, getSelectedAccount: selectedAccount, }, - }, - async ({ - mockTokenListGetState, - callActionSpy, - triggerNetworkDidChange, - }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { [ChainId.sepolia]: { data: { @@ -1734,8 +1664,9 @@ describe('TokenDetectionController', () => { timestamp: 0, }, }, - }); - + }, + }, + async ({ callActionSpy, triggerNetworkDidChange }) => { triggerNetworkDidChange({ ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'avalanche', @@ -1768,14 +1699,7 @@ describe('TokenDetectionController', () => { getAccount: selectedAccount, getSelectedAccount: selectedAccount, }, - }, - async ({ - mockTokenListGetState, - callActionSpy, - triggerNetworkDidChange, - }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { [ChainId.sepolia]: { data: { @@ -1792,8 +1716,9 @@ describe('TokenDetectionController', () => { timestamp: 0, }, }, - }); - + }, + }, + async ({ callActionSpy, triggerNetworkDidChange }) => { triggerNetworkDidChange({ ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'avalanche', @@ -1825,467 +1750,11 @@ describe('TokenDetectionController', () => { }, mocks: { getAccount: selectedAccount, - getSelectedAccount: selectedAccount, - }, - }, - async ({ - mockTokenListGetState, - callActionSpy, - triggerNetworkDidChange, - }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), - tokensChainsCache: { - [ChainId.sepolia]: { - data: { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }, - timestamp: 0, - }, - }, - }); - - triggerNetworkDidChange({ - ...getDefaultNetworkControllerState(), - selectedNetworkClientId: 'avalanche', - }); - await jestAdvanceTime({ duration: 1 }); - - expect(callActionSpy).not.toHaveBeenCalledWith( - 'TokensController:addDetectedTokens', - ); - }, - ); - }); - }); - }); - - describe('TokenListController:stateChange', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - describe('when "disabled" is false', () => { - it('should detect tokens if the token list is non-empty', async () => { - const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ - [sampleTokenA.address]: new BN(1), - }); - const selectedAccount = createMockInternalAccount({ - address: '0x0000000000000000000000000000000000000001', - }); - await withController( - { - options: { - disabled: false, - getBalancesInSingleCall: mockGetBalancesInSingleCall, - }, - mocks: { - getSelectedAccount: selectedAccount, - getAccount: selectedAccount, - }, - }, - async ({ - mockTokenListGetState, - callActionSpy, - triggerTokenListStateChange, - mockNetworkState, - }) => { - // Set selectedNetworkClientId to avalanche (not in SUPPORTED_NETWORKS_ACCOUNTS_API_V4) - mockNetworkState({ - ...getDefaultNetworkControllerState(), - selectedNetworkClientId: 'avalanche', - }); - const tokenList = { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }; - const tokenListState = { - ...getDefaultTokenListState(), - tokensChainsCache: { - '0xa86a': { - timestamp: 0, - data: tokenList, - }, - }, - }; - mockTokenListGetState(tokenListState); - - triggerTokenListStateChange(tokenListState); - await jestAdvanceTime({ duration: 1 }); - - expect(callActionSpy).toHaveBeenCalledWith( - 'TokensController:addTokens', - [sampleTokenA], - 'avalanche', - ); - }, - ); - }); - - it('should not detect tokens if the token list is empty', async () => { - const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ - [sampleTokenA.address]: new BN(1), - }); - const selectedAccount = createMockInternalAccount({ - address: '0x0000000000000000000000000000000000000001', - }); - await withController( - { - options: { - disabled: false, - getBalancesInSingleCall: mockGetBalancesInSingleCall, - }, - mocks: { - getSelectedAccount: selectedAccount, - getAccount: selectedAccount, - }, - }, - async ({ - mockTokenListGetState, - callActionSpy, - triggerTokenListStateChange, - }) => { - const tokenListState = { - ...getDefaultTokenListState(), - tokensChainsCache: {}, - }; - mockTokenListGetState(tokenListState); - - triggerTokenListStateChange(tokenListState); - await jestAdvanceTime({ duration: 1 }); - - expect(callActionSpy).not.toHaveBeenCalledWith( - 'TokensController:addDetectedTokens', - ); - }, - ); - }); - - describe('when keyring is locked', () => { - it('should not detect tokens', async () => { - const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ - [sampleTokenA.address]: new BN(1), - }); - const selectedAccount = createMockInternalAccount({ - address: '0x0000000000000000000000000000000000000001', - }); - await withController( - { - options: { - disabled: false, - getBalancesInSingleCall: mockGetBalancesInSingleCall, - }, - isKeyringUnlocked: false, - mocks: { - getSelectedAccount: selectedAccount, - getAccount: selectedAccount, - }, - }, - async ({ - mockTokenListGetState, - callActionSpy, - triggerTokenListStateChange, - }) => { - const tokenListState = { - ...getDefaultTokenListState(), - tokensChainsCache: { - [ChainId.sepolia]: { - data: { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }, - timestamp: 0, - }, - }, - }; - mockTokenListGetState(tokenListState); - - triggerTokenListStateChange(tokenListState); - await jestAdvanceTime({ duration: 1 }); - - expect(callActionSpy).not.toHaveBeenCalledWith( - 'TokensController:addDetectedTokens', - ); - }, - ); - }); - }); - }); - - describe('when "disabled" is true', () => { - it('should not detect tokens', async () => { - const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ - [sampleTokenA.address]: new BN(1), - }); - const selectedAccount = createMockInternalAccount({ - address: '0x0000000000000000000000000000000000000001', - }); - await withController( - { - options: { - disabled: true, - getBalancesInSingleCall: mockGetBalancesInSingleCall, - }, - mocks: { - getSelectedAccount: selectedAccount, - getAccount: selectedAccount, - }, - }, - async ({ - mockTokenListGetState, - callActionSpy, - triggerTokenListStateChange, - }) => { - const tokenListState = { - ...getDefaultTokenListState(), - tokensChainsCache: { - [ChainId.sepolia]: { - data: { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }, - timestamp: 0, - }, - }, - }; - mockTokenListGetState(tokenListState); - - triggerTokenListStateChange(tokenListState); - await jestAdvanceTime({ duration: 1 }); - - expect(callActionSpy).not.toHaveBeenCalledWith( - 'TokensController:addDetectedTokens', - ); - }, - ); - }); - }); - - describe('when previous and incoming tokensChainsCache are equal with the same timestamp', () => { - it('should not call detect tokens', async () => { - const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ - [sampleTokenA.address]: new BN(1), - }); - const selectedAccount = createMockInternalAccount({ - address: '0x0000000000000000000000000000000000000001', - }); - await withController( - { - options: { - disabled: false, - getBalancesInSingleCall: mockGetBalancesInSingleCall, - }, - mocks: { - getSelectedAccount: selectedAccount, - getAccount: selectedAccount, - }, - }, - async ({ - mockTokenListGetState, - triggerTokenListStateChange, - controller, - }) => { - const tokenListState = { - ...getDefaultTokenListState(), - tokensChainsCache: { - [ChainId.sepolia]: { - data: { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }, - timestamp: 0, - }, - }, - }; - mockTokenListGetState(tokenListState); - // This should set the tokensChainsCache value - triggerTokenListStateChange(tokenListState); - await jestAdvanceTime({ duration: 1 }); - - const mockTokens = jest.spyOn(controller, 'detectTokens'); - - // Re-trigger state change so that incoming list is equal the current list in state - triggerTokenListStateChange(tokenListState); - await jestAdvanceTime({ duration: 1 }); - expect(mockTokens).toHaveBeenCalledTimes(0); - }, - ); - }); - }); - - describe('when previous and incoming tokensChainsCache are equal with different timestamp', () => { - it('should not call detect tokens', async () => { - const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ - [sampleTokenA.address]: new BN(1), - }); - const selectedAccount = createMockInternalAccount({ - address: '0x0000000000000000000000000000000000000001', - }); - await withController( - { - options: { - disabled: false, - getBalancesInSingleCall: mockGetBalancesInSingleCall, - }, - mocks: { - getSelectedAccount: selectedAccount, - getAccount: selectedAccount, - }, - }, - async ({ - mockTokenListGetState, - triggerTokenListStateChange, - controller, - }) => { - const tokenListState = { - ...getDefaultTokenListState(), - tokensChainsCache: { - [ChainId.sepolia]: { - data: { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }, - timestamp: 0, - }, - }, - }; - mockTokenListGetState(tokenListState); - // This should set the tokensChainsCache value - triggerTokenListStateChange(tokenListState); - await jestAdvanceTime({ duration: 1 }); - - const mockTokens = jest.spyOn(controller, 'detectTokens'); - - // Re-trigger state change so that incoming list is equal the current list in state - triggerTokenListStateChange({ - ...tokenListState, - tokensChainsCache: { - [ChainId.sepolia]: { - data: { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }, - timestamp: 3424, // same list with different timestamp should not trigger detectTokens again - }, - }, - }); - await jestAdvanceTime({ duration: 1 }); - expect(mockTokens).toHaveBeenCalledTimes(0); - }, - ); - }); - }); - - describe('when previous and incoming tokensChainsCache are not equal', () => { - it('should call detect tokens', async () => { - const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ - [sampleTokenA.address]: new BN(1), - }); - const selectedAccount = createMockInternalAccount({ - address: '0x0000000000000000000000000000000000000001', - }); - await withController( - { - options: { - disabled: false, - getBalancesInSingleCall: mockGetBalancesInSingleCall, - }, - mocks: { - getSelectedAccount: selectedAccount, - getAccount: selectedAccount, - }, - }, - async ({ - mockTokenListGetState, - triggerTokenListStateChange, - controller, - }) => { - const tokenListState = { - ...getDefaultTokenListState(), - tokensChainsCache: { - [ChainId.sepolia]: { - data: { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }, - timestamp: 0, - }, - }, - }; - mockTokenListGetState(tokenListState); - // This should set the tokensChainsCache value - triggerTokenListStateChange(tokenListState); - await jestAdvanceTime({ duration: 1 }); - - const mockTokens = jest.spyOn(controller, 'detectTokens'); - - // Re-trigger state change so that incoming list is equal the current list in state - triggerTokenListStateChange({ - ...tokenListState, + getSelectedAccount: selectedAccount, + }, + mockTokenListState: { tokensChainsCache: { - ...tokenListState.tokensChainsCache, - [ChainId['linea-mainnet']]: { + [ChainId.sepolia]: { data: { [sampleTokenA.address]: { name: sampleTokenA.name, @@ -2297,12 +1766,21 @@ describe('TokenDetectionController', () => { iconUrl: sampleTokenA.image, }, }, - timestamp: 5546454, + timestamp: 0, }, }, + }, + }, + async ({ callActionSpy, triggerNetworkDidChange }) => { + triggerNetworkDidChange({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'avalanche', }); await jestAdvanceTime({ duration: 1 }); - expect(mockTokens).toHaveBeenCalledTimes(1); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + ); }, ); }); @@ -2335,10 +1813,7 @@ describe('TokenDetectionController', () => { getSelectedAccount: selectedAccount, getAccount: selectedAccount, }, - }, - async ({ controller, mockTokenListGetState }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { [ChainId.sepolia]: { data: { @@ -2355,7 +1830,9 @@ describe('TokenDetectionController', () => { timestamp: 0, }, }, - }); + }, + }, + async ({ controller }) => { const spy = jest .spyOn(controller, 'detectTokens') .mockImplementation(() => { @@ -2461,24 +1938,7 @@ describe('TokenDetectionController', () => { getSelectedAccount: selectedAccount, getAccount: selectedAccount, }, - }, - async ({ - controller, - mockTokenListGetState, - callActionSpy, - mockNetworkState, - }) => { - // Include Avalanche in networkConfigurationsByChainId for explicit chainId lookup - const defaultState = getDefaultNetworkControllerState(); - mockNetworkState({ - ...defaultState, - networkConfigurationsByChainId: { - ...defaultState.networkConfigurationsByChainId, - ...mockNetworkConfigurationsByChainId, - }, - }); - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { '0xa86a': { timestamp: 0, @@ -2495,6 +1955,17 @@ describe('TokenDetectionController', () => { }, }, }, + }, + }, + async ({ controller, callActionSpy, mockNetworkState }) => { + // Include Avalanche in networkConfigurationsByChainId for explicit chainId lookup + const defaultState = getDefaultNetworkControllerState(); + mockNetworkState({ + ...defaultState, + networkConfigurationsByChainId: { + ...defaultState.networkConfigurationsByChainId, + ...mockNetworkConfigurationsByChainId, + }, }); await controller.detectTokens({ @@ -2531,19 +2002,7 @@ describe('TokenDetectionController', () => { getSelectedAccount: selectedAccount, getAccount: selectedAccount, }, - }, - async ({ controller, mockTokenListGetState, mockNetworkState }) => { - // Include Avalanche in networkConfigurationsByChainId for explicit chainId lookup - const defaultState = getDefaultNetworkControllerState(); - mockNetworkState({ - ...defaultState, - networkConfigurationsByChainId: { - ...defaultState.networkConfigurationsByChainId, - ...mockNetworkConfigurationsByChainId, - }, - }); - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { '0xa86a': { timestamp: 0, @@ -2560,6 +2019,17 @@ describe('TokenDetectionController', () => { }, }, }, + }, + }, + async ({ controller, mockNetworkState }) => { + // Include Avalanche in networkConfigurationsByChainId for explicit chainId lookup + const defaultState = getDefaultNetworkControllerState(); + mockNetworkState({ + ...defaultState, + networkConfigurationsByChainId: { + ...defaultState.networkConfigurationsByChainId, + ...mockNetworkConfigurationsByChainId, + }, }); await controller.detectTokens({ @@ -2594,11 +2064,28 @@ describe('TokenDetectionController', () => { getBalancesInSingleCall: mockGetBalancesInSingleCall, trackMetaMetricsEvent: mockTrackMetaMetricsEvent, }, + mockTokenListState: { + tokensChainsCache: { + '0xa86a': { + timestamp: 0, + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }, + }, + }, }, async ({ controller, mockGetAccount, - mockTokenListGetState, callActionSpy, mockNetworkState, }) => { @@ -2613,25 +2100,6 @@ describe('TokenDetectionController', () => { }); // @ts-expect-error forcing an undefined value mockGetAccount(undefined); - mockTokenListGetState({ - ...getDefaultTokenListState(), - tokensChainsCache: { - '0xa86a': { - timestamp: 0, - data: { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }, - }, - }, - }); await controller.detectTokens({ chainIds: ['0xa86a'], @@ -2657,7 +2125,7 @@ describe('TokenDetectionController', () => { ], decimals: 18, image: - 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x514910771af9ca656af840dff83e8264ecf986ca.png', + 'https://static.cx.metamask.io/api/v1/tokenIcons/43114/0x514910771af9ca656af840dff83e8264ecf986ca.png', isERC721: false, name: 'Chainlink', symbol: 'LINK', @@ -2729,24 +2197,7 @@ describe('TokenDetectionController', () => { getSelectedAccount: selectedAccount, getAccount: selectedAccount, }, - }, - async ({ - mockTokenListGetState, - mockNetworkState, - callActionSpy, - triggerTransactionConfirmed, - }) => { - const defaultState = getDefaultNetworkControllerState(); - mockNetworkState({ - ...defaultState, - selectedNetworkClientId: 'avalanche', - networkConfigurationsByChainId: { - ...defaultState.networkConfigurationsByChainId, - ...mockNetworkConfigurationsByChainId, - }, - }); - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { '0xa86a': { timestamp: 0, @@ -2763,6 +2214,21 @@ describe('TokenDetectionController', () => { }, }, }, + }, + }, + async ({ + mockNetworkState, + callActionSpy, + triggerTransactionConfirmed, + }) => { + const defaultState = getDefaultNetworkControllerState(); + mockNetworkState({ + ...defaultState, + selectedNetworkClientId: 'avalanche', + networkConfigurationsByChainId: { + ...defaultState.networkConfigurationsByChainId, + ...mockNetworkConfigurationsByChainId, + }, }); triggerTransactionConfirmed({ chainId: '0xa86a' }); @@ -2869,25 +2335,7 @@ describe('TokenDetectionController', () => { getSelectedAccount: selectedAccount, getAccount: selectedAccount, }, - }, - async ({ - controller, - mockNetworkState, - mockTokenListGetState, - mockTokensGetState, - callActionSpy, - }) => { - const defaultState = getDefaultNetworkControllerState(); - mockNetworkState({ - ...defaultState, - selectedNetworkClientId: 'avalanche', - networkConfigurationsByChainId: { - ...defaultState.networkConfigurationsByChainId, - ...mockNetworkConfigurationsByChainId, - }, - }); - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { '0xa86a': { timestamp: 0, @@ -2904,6 +2352,22 @@ describe('TokenDetectionController', () => { }, }, }, + }, + }, + async ({ + controller, + mockNetworkState, + mockTokensGetState, + callActionSpy, + }) => { + const defaultState = getDefaultNetworkControllerState(); + mockNetworkState({ + ...defaultState, + selectedNetworkClientId: 'avalanche', + networkConfigurationsByChainId: { + ...defaultState.networkConfigurationsByChainId, + ...mockNetworkConfigurationsByChainId, + }, }); // Mock that the user already owns this token mockTokensGetState({ @@ -3077,12 +2541,29 @@ describe('TokenDetectionController', () => { getSelectedAccount: selectedAccount, getAccount: selectedAccount, }, + mockTokenListState: { + tokensChainsCache: { + '0x1': { + timestamp: 0, + data: { + [mainnetUSDC]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: mainnetUSDC, + occurrences: 1, + aggregators: [], + iconUrl: '', + }, + }, + }, + }, + }, }, async ({ controller, mockNetworkState, mockFindNetworkClientIdByChainId, - mockTokenListGetState, triggerPreferencesStateChange, }) => { const defaultState = getDefaultNetworkControllerState(); @@ -3112,25 +2593,6 @@ describe('TokenDetectionController', () => { mockFindNetworkClientIdByChainId(() => 'mainnet'); // Provide token list data for mainnet - mockTokenListGetState({ - ...getDefaultTokenListState(), - tokensChainsCache: { - '0x1': { - timestamp: 0, - data: { - [mainnetUSDC]: { - name: 'USD Coin', - symbol: 'USDC', - decimals: 6, - address: mainnetUSDC, - occurrences: 1, - aggregators: [], - iconUrl: '', - }, - }, - }, - }, - }); // Enable token detection for mainnet triggerPreferencesStateChange({ @@ -3241,15 +2703,7 @@ describe('TokenDetectionController', () => { mocks: { getSelectedAccount: defaultSelectedAccount, }, - }, - async ({ controller, mockTokenListGetState, mockNetworkState }) => { - // Set selectedNetworkClientId to avalanche (not in SUPPORTED_NETWORKS_ACCOUNTS_API_V4) - mockNetworkState({ - ...getDefaultNetworkControllerState(), - selectedNetworkClientId: 'avalanche', - }); - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { '0xa86a': { timestamp: 0, @@ -3266,6 +2720,13 @@ describe('TokenDetectionController', () => { }, }, }, + }, + }, + async ({ controller, mockNetworkState }) => { + // Set selectedNetworkClientId to avalanche (not in SUPPORTED_NETWORKS_ACCOUNTS_API_V4) + mockNetworkState({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'avalanche', }); // Start the controller to make it active @@ -3315,15 +2776,7 @@ describe('TokenDetectionController', () => { mocks: { getSelectedAccount: defaultSelectedAccount, }, - }, - async ({ controller, mockTokenListGetState, mockNetworkState }) => { - // Set selectedNetworkClientId to avalanche (not in SUPPORTED_NETWORKS_ACCOUNTS_API_V4) - mockNetworkState({ - ...getDefaultNetworkControllerState(), - selectedNetworkClientId: 'avalanche', - }); - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { '0xa86a': { timestamp: 0, @@ -3340,6 +2793,13 @@ describe('TokenDetectionController', () => { }, }, }, + }, + }, + async ({ controller, mockNetworkState }) => { + // Set selectedNetworkClientId to avalanche (not in SUPPORTED_NETWORKS_ACCOUNTS_API_V4) + mockNetworkState({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'avalanche', }); await controller.start(); @@ -3384,6 +2844,19 @@ describe('TokenDetectionController', () => { }, }, async ({ controller, callActionSpy }) => { + const verifiedTokens = new Map(); + verifiedTokens.set('0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', { + assetId: + 'eip155:43114/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + decimals: 6, + iconUrl: 'https://example.com/usdc.png', + name: 'USD Coin', + symbol: 'USDC', + occurrences: 11, + aggregators: [], + }); + mockFetchVerifiedTokensByAddresses.mockResolvedValue(verifiedTokens); + await controller.addDetectedTokensViaWs({ tokensSlice: [mockTokenAddress], chainId: chainId as Hex, @@ -3492,6 +2965,29 @@ describe('TokenDetectionController', () => { }, }, async ({ controller, callActionSpy }) => { + const verifiedTokens = new Map(); + verifiedTokens.set('0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', { + assetId: + 'eip155:43114/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + decimals: 6, + iconUrl: 'https://example.com/usdc.png', + name: 'USD Coin', + symbol: 'USDC', + occurrences: 11, + aggregators: [], + }); + verifiedTokens.set('0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c', { + assetId: + 'eip155:43114/erc20:0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c', + decimals: 18, + iconUrl: 'https://example.com/bnt.png', + name: 'Bancor', + symbol: 'BNT', + occurrences: 11, + aggregators: [], + }); + mockFetchVerifiedTokensByAddresses.mockResolvedValue(verifiedTokens); + // Add both tokens via websocket await controller.addDetectedTokensViaWs({ tokensSlice: [mockTokenAddress, secondTokenAddress], @@ -3560,6 +3056,19 @@ describe('TokenDetectionController', () => { }, }, async ({ controller, callActionSpy }) => { + const verifiedTokens = new Map(); + verifiedTokens.set('0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', { + assetId: + 'eip155:43114/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + decimals: 6, + iconUrl: 'https://example.com/usdc.png', + name: 'USD Coin', + symbol: 'USDC', + occurrences: 11, + aggregators: [], + }); + mockFetchVerifiedTokensByAddresses.mockResolvedValue(verifiedTokens); + await controller.addDetectedTokensViaWs({ tokensSlice: [mockTokenAddress], chainId: chainId as Hex, @@ -3616,6 +3125,19 @@ describe('TokenDetectionController', () => { }, }, async ({ controller, callActionSpy }) => { + const verifiedTokens = new Map(); + verifiedTokens.set('0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', { + assetId: + 'eip155:43114/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + decimals: 6, + iconUrl: 'https://example.com/usdc.png', + name: 'USD Coin', + symbol: 'USDC', + occurrences: 11, + aggregators: [], + }); + mockFetchVerifiedTokensByAddresses.mockResolvedValue(verifiedTokens); + // Call the public method directly on the controller instance await controller.addDetectedTokensViaWs({ tokensSlice: [mockTokenAddress], @@ -3675,6 +3197,19 @@ describe('TokenDetectionController', () => { }, }, async ({ controller, callActionSpy }) => { + const verifiedTokens = new Map(); + verifiedTokens.set('0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', { + assetId: + 'eip155:43114/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + decimals: 6, + iconUrl: 'https://example.com/usdc.png', + name: 'USD Coin', + symbol: 'USDC', + occurrences: 11, + aggregators: [], + }); + mockFetchVerifiedTokensByAddresses.mockResolvedValue(verifiedTokens); + await controller.addDetectedTokensViaPolling({ tokensSlice: [mockTokenAddress], chainId: chainId as Hex, @@ -3896,28 +3431,38 @@ describe('TokenDetectionController', () => { tokensChainsCache: {}, }, }, - async ({ controller, callActionSpy, mockTokenListGetState }) => { + async ({ controller, callActionSpy }) => { // Update the mock to return populated cache data // This simulates TokenListController having fetched token list data after construction - mockTokenListGetState({ - ...getDefaultTokenListState(), - tokensChainsCache: { - [chainId]: { - timestamp: 0, - data: { - [mockTokenAddress]: { - name: 'USD Coin', - symbol: 'USDC', - decimals: 6, - address: mockTokenAddress, - aggregators: [], - iconUrl: 'https://example.com/usdc.png', - occurrences: 11, - }, + mockFetchTokenListByChainId.mockImplementation((fetchChainId) => { + if (fetchChainId === chainId) { + return Promise.resolve([ + { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: mockTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, }, - }, - }, + ]); + } + return Promise.resolve(undefined); + }); + + const verifiedTokens = new Map(); + verifiedTokens.set('0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', { + assetId: + 'eip155:43114/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + decimals: 6, + iconUrl: 'https://example.com/usdc.png', + name: 'USD Coin', + symbol: 'USDC', + occurrences: 11, + aggregators: [], }); + mockFetchVerifiedTokensByAddresses.mockResolvedValue(verifiedTokens); // Call addDetectedTokensViaPolling - with the fix, it should fetch fresh cache await controller.addDetectedTokensViaPolling({ @@ -4026,6 +3571,19 @@ describe('TokenDetectionController', () => { }, }, async ({ controller, callActionSpy }) => { + const verifiedTokens = new Map(); + verifiedTokens.set('0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c', { + assetId: + 'eip155:43114/erc20:0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c', + decimals: 18, + iconUrl: 'https://example.com/bnt.png', + name: 'Bancor', + symbol: 'BNT', + occurrences: 11, + aggregators: [], + }); + mockFetchVerifiedTokensByAddresses.mockResolvedValue(verifiedTokens); + await controller.addDetectedTokensViaPolling({ tokensSlice: [ trackedTokenAddress, @@ -4066,7 +3624,7 @@ describe('TokenDetectionController', () => { function getTokensPath(chainId: Hex): string { return `/tokens/${convertHexToDecimal( chainId, - )}?occurrenceFloor=3&includeNativeAssets=false&includeTokenFees=false&includeAssetType=false`; + )}?occurrenceFloor=3&includeNativeAssets=false&includeTokenFees=false&includeAssetType=false&includeERC20Permit=false&includeStorage=false&includeRwaData=true`; } type WithControllerCallback = ({ @@ -4076,7 +3634,6 @@ type WithControllerCallback = ({ mockGetSelectedAccount, mockKeyringGetState, mockTokensGetState, - mockTokenListGetState, mockPreferencesGetState, mockGetNetworkClientById, mockGetNetworkConfigurationByNetworkClientId, @@ -4084,7 +3641,6 @@ type WithControllerCallback = ({ callActionSpy, triggerKeyringUnlock, triggerKeyringLock, - triggerTokenListStateChange, triggerPreferencesStateChange, triggerSelectedAccountChange, triggerNetworkDidChange, @@ -4095,7 +3651,6 @@ type WithControllerCallback = ({ mockGetSelectedAccount: (address: string) => void; mockKeyringGetState: (state: KeyringControllerState) => void; mockTokensGetState: (state: TokensControllerState) => void; - mockTokenListGetState: (state: TokenListState) => void; mockPreferencesGetState: (state: PreferencesState) => void; mockGetNetworkClientById: ( handler: ( @@ -4112,7 +3667,6 @@ type WithControllerCallback = ({ callActionSpy: jest.SpyInstance; triggerKeyringUnlock: () => void; triggerKeyringLock: () => void; - triggerTokenListStateChange: (state: TokenListState) => void; triggerPreferencesStateChange: (state: PreferencesState) => void; triggerSelectedAccountChange: (account: InternalAccount) => void; triggerNetworkDidChange: (state: NetworkState) => void; @@ -4226,14 +3780,17 @@ async function withController( ...mockTokensState, }), ); - const mockTokenListStateFunc = jest.fn(); - messenger.registerActionHandler( - 'TokenListController:getState', - mockTokenListStateFunc.mockReturnValue({ - ...getDefaultTokenListState(), - ...mockTokenListState, - }), - ); + const initialTokenListState = { + ...getDefaultTokenListState(), + ...mockTokenListState, + }; + mockFetchTokenListByChainId.mockImplementation((chainId: Hex) => { + const cache = initialTokenListState.tokensChainsCache[chainId]; + if (cache) { + return Promise.resolve(Object.values(cache.data)); + } + return Promise.resolve(undefined); + }); const mockPreferencesState = jest.fn(); messenger.registerActionHandler( 'PreferencesController:getState', @@ -4301,9 +3858,6 @@ async function withController( mockPreferencesGetState: (state: PreferencesState) => { mockPreferencesState.mockReturnValue(state); }, - mockTokenListGetState: (state: TokenListState) => { - mockTokenListStateFunc.mockReturnValue(state); - }, mockGetNetworkClientById: ( handler: ( networkClientId: NetworkClientId, @@ -4333,9 +3887,6 @@ async function withController( triggerKeyringLock: () => { messenger.publish('KeyringController:lock'); }, - triggerTokenListStateChange: (state: TokenListState) => { - messenger.publish('TokenListController:stateChange', state, []); - }, triggerPreferencesStateChange: (state: PreferencesState) => { messenger.publish('PreferencesController:stateChange', state, []); }, diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index 38d717a723a..0f2c4912f78 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -39,19 +39,23 @@ import type { import type { AuthenticationController } from '@metamask/profile-sync-controller'; import type { TransactionControllerTransactionConfirmedEvent } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; -import { isEqual, mapValues, isObject, get } from 'lodash'; +import { mapValues, isObject, get } from 'lodash'; import type { AssetsContractController } from './AssetsContractController'; -import { fetchVerifiedTokensByAddresses } from './tokens-api-v3'; -import { isTokenDetectionSupportedForNetwork } from './assetsUtil'; +import { + isTokenDetectionSupportedForNetwork, + formatAggregatorNames, + formatIconUrlWithProxy, +} from './assetsUtil'; import { SUPPORTED_NETWORKS_ACCOUNTS_API_V4 } from './constants'; import type { TokenDetectionControllerMethodActions } from './TokenDetectionController-method-action-types'; import type { - GetTokenListState, TokenListMap, - TokenListStateChange, + TokenListToken, TokensChainsCache, } from './TokenListController'; +import { fetchVerifiedTokensByAddresses } from './tokens-api-v3'; +import { fetchTokenListByChainId } from './token-service'; import type { Token } from './TokenRatesController'; import type { TokensControllerGetStateAction } from './TokensController'; import type { @@ -130,7 +134,6 @@ export type AllowedActions = | NetworkControllerGetNetworkClientByIdAction | NetworkControllerGetNetworkConfigurationByNetworkClientId | NetworkControllerGetStateAction - | GetTokenListState | KeyringControllerGetStateAction | PreferencesControllerGetStateAction | TokensControllerGetStateAction @@ -148,7 +151,6 @@ export type TokenDetectionControllerEvents = export type AllowedEvents = | AccountsControllerSelectedEvmAccountChangeEvent | NetworkControllerNetworkDidChangeEvent - | TokenListStateChange | KeyringControllerLockEvent | KeyringControllerUnlockEvent | PreferencesControllerStateChangeEvent @@ -201,7 +203,9 @@ export class TokenDetectionController extends StaticIntervalPollingController(); + + static readonly #tokenListCacheMaxAge = 4 * 60 * 60 * 1000; #disabled: boolean; @@ -280,12 +284,6 @@ export class TokenDetectionController extends StaticIntervalPollingController { - const isEqualValues = this.#compareTokensChainsCache( - tokensChainsCache, - this.#tokensChainsCache, - ); - if (!isEqualValues) { - this.#restartTokenDetection().catch(() => { - // Silently handle token detection errors - }); - } - }, - ); - this.messenger.subscribe( 'PreferencesController:stateChange', ({ useTokenDetection }) => { @@ -449,30 +432,6 @@ export class TokenDetectionController extends StaticIntervalPollingController { const { allTokens, allDetectedTokens, allIgnoredTokens } = this.messenger.call('TokensController:getState'); const [tokensAddresses, detectedTokensAddresses, ignoredTokensAddresses] = [ @@ -662,10 +609,10 @@ export class TokenDetectionController extends StaticIntervalPollingController( (acc, [key, value]) => ({ ...acc, [key]: { @@ -699,18 +646,61 @@ export class TokenDetectionController extends StaticIntervalPollingController { + const cached = this.#tokenListCache.get(chainId); + const now = Date.now(); + + if ( + cached && + now - cached.timestamp < TokenDetectionController.#tokenListCacheMaxAge + ) { + return cached.data; + } + + const isMainnetDetectionInactive = + !this.#isDetectionEnabledFromPreferences && chainId === ChainId.mainnet; + + if (isMainnetDetectionInactive) { + const data = this.#getStaticMainnetTokenList(); + this.#tokenListCache.set(chainId, { data, timestamp: now }); + return data; + } + + const tokensFromAPI = await safelyExecute( + () => + fetchTokenListByChainId( + chainId, + new AbortController().signal, + ) as Promise, + ); + + if (!tokensFromAPI) { + return cached?.data ?? {}; + } + + const tokenList: TokenListMap = {}; + for (const token of tokensFromAPI) { + tokenList[token.address] = { + ...token, + aggregators: formatAggregatorNames(token.aggregators), + iconUrl: formatIconUrlWithProxy({ + chainId, + tokenAddress: token.address, + }), + }; + } + + this.#tokenListCache.set(chainId, { data: tokenList, timestamp: now }); + return tokenList; } async #addDetectedTokens({ @@ -731,11 +721,17 @@ export class TokenDetectionController extends StaticIntervalPollingController; -/** - * Event emitted after a token list is successfully fetched from the API for a - * given chain. Carries the processed token list directly so consumers can use - * it without going through tokensChainsCache. - */ -export type TokenListTokenListFetchedEvent = { - type: `${typeof name}:tokenListFetched`; - payload: [{ chainId: Hex; tokenList: TokenListMap }]; -}; - -export type TokenListControllerEvents = - | TokenListStateChange - | TokenListTokenListFetchedEvent; +export type TokenListControllerEvents = TokenListStateChange; export type GetTokenListState = ControllerGetStateAction< typeof name, @@ -384,15 +371,10 @@ export class TokenListController extends StaticIntervalPollingController { - if (!key.startsWith(`${TokenListController.#storageKeyPrefix}:`)) { - return false; - } - const chainId = key.split(':')[1] as Hex; - return !SUPPORTED_NETWORKS_ACCOUNTS_API_V4.includes(chainId); - }); + // Filter keys that belong to tokensChainsCache (per-chain files) + const cacheKeys = allKeys.filter((key) => + key.startsWith(`${TokenListController.#storageKeyPrefix}:`), + ); // Load all chains in parallel const chainCaches = await Promise.all( @@ -426,29 +408,11 @@ export class TokenListController extends StaticIntervalPollingController !SUPPORTED_NETWORKS_ACCOUNTS_API_V4.includes(chainId), - ), + Object.keys(this.state.tokensChainsCache) as Hex[], ); - // Purge any V4 chain entries that survived in state (e.g. from stale - // Redux persistence before the client migration ran). - const v4ChainsInState = ( - Object.keys(this.state.tokensChainsCache) as Hex[] - ).filter((chainId) => SUPPORTED_NETWORKS_ACCOUNTS_API_V4.includes(chainId)); - - if (v4ChainsInState.length > 0) { - this.update((state) => { - for (const chainId of v4ChainsInState) { - delete state.tokensChainsCache[chainId]; - } - }); - } - // Merge loaded cache with existing state, preferring existing data // (which may be fresher if fetched during initialization) if (Object.keys(loadedCache).length > 0) { @@ -668,27 +632,14 @@ export class TokenListController extends StaticIntervalPollingController { + state.tokensChainsCache[chainId] = newDataCache; }); - - // For chains supported by the Accounts API, token detection is handled - // via the WS/polling paths which call the v3 API directly. Writing the - // full token list to tokensChainsCache is unnecessary and wastes storage. - if (!SUPPORTED_NETWORKS_ACCOUNTS_API_V4.includes(chainId)) { - const newDataCache: DataCache = { - data: tokenList, - timestamp: Date.now(), - }; - this.update((state) => { - state.tokensChainsCache[chainId] = newDataCache; - }); - } - return; } @@ -696,8 +647,7 @@ export class TokenListController extends StaticIntervalPollingController ({ })); jest.mock('./Standards/ERC20Standard'); jest.mock('./Standards/NftStandards/ERC1155/ERC1155Standard'); +jest.mock('./token-service', () => ({ + ...jest.requireActual('./token-service'), + fetchTokenListByChainId: jest.fn(), +})); + +const mockFetchTokenListByChainId = + fetchTokenListByChainId as jest.MockedFunction< + typeof fetchTokenListByChainId + >; type AllActions = | MessengerActions @@ -80,6 +89,7 @@ describe('TokensController', () => { ContractMock.mockReturnValue( buildMockEthersERC721Contract({ supportsInterface: false }), ); + mockFetchTokenListByChainId.mockResolvedValue(undefined); }); it('should set default state', async () => { @@ -3261,123 +3271,103 @@ describe('TokensController', () => { }); }); - describe('when TokenListController:stateChange is published', () => { - it('updates the name of each token to match its counterpart in the token list', async () => { - await withController(async ({ controller, messenger }) => { - ContractMock.mockReturnValue( - buildMockEthersERC721Contract({ supportsInterface: false }), - ); + describe('addToken metadata fallbacks', () => { + it('uses name from API metadata when caller does not provide one', async () => { + await withController(async ({ controller }) => { + nock(TOKEN_END_POINT_API) + .get( + `/token/${convertHexToDecimal( + ChainId.mainnet, + )}?address=0x01&includeRwaData=true`, + ) + .reply(200, { + address: '0x01', + symbol: 'bar', + decimals: 2, + occurrences: 1, + name: 'BarFromAPI', + aggregators: [], + }); + await controller.addToken({ address: '0x01', symbol: 'bar', decimals: 2, networkClientId: 'mainnet', }); + expect( controller.state.allTokens[ChainId.mainnet][ defaultMockInternalAccount.address - ][0], - ).toStrictEqual({ + ][0].name, + ).toBe('BarFromAPI'); + }); + }); + + it('uses rwaData from API metadata when caller does not provide one', async () => { + await withController(async ({ controller }) => { + nock(TOKEN_END_POINT_API) + .get( + `/token/${convertHexToDecimal( + ChainId.mainnet, + )}?address=0x01&includeRwaData=true`, + ) + .reply(200, { + address: '0x01', + symbol: 'bar', + decimals: 2, + occurrences: 1, + name: 'Bar', + aggregators: [], + rwaData: { ticker: 'BAR' }, + }); + + await controller.addToken({ address: '0x01', - decimals: 2, - image: 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x01.png', symbol: 'bar', - isERC721: false, - aggregators: [], - name: undefined, + decimals: 2, + networkClientId: 'mainnet', }); - messenger.publish( - 'TokenListController:stateChange', - { - tokensChainsCache: { - [ChainId.mainnet]: { - timestamp: 1, - data: { - '0x01': { - address: '0x01', - symbol: 'bar', - decimals: 2, - occurrences: 1, - name: 'BarName', - iconUrl: - 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x01.png', - aggregators: ['Aave'], - }, - }, - }, - }, - }, - [], - ); - expect( controller.state.allTokens[ChainId.mainnet][ defaultMockInternalAccount.address - ][0], - ).toStrictEqual({ - address: '0x01', - decimals: 2, - image: 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x01.png', - symbol: 'bar', - isERC721: false, - aggregators: [], - name: 'BarName', - }); + ][0].rwaData, + ).toStrictEqual({ ticker: 'BAR' }); }); }); - it('overwrites rwaData for tokens with cached rwaData', async () => { - await withController(async ({ controller, messenger }) => { - ContractMock.mockReturnValue( - buildMockEthersERC721Contract({ supportsInterface: false }), - ); - - await controller.addTokens( - [ - { - address: '0x01', - symbol: 'bar', - decimals: 2, - aggregators: [], - image: undefined, - name: undefined, - rwaData: { ticker: 'OLD' }, - }, - ], - 'mainnet', - ); + it('prefers caller-provided rwaData over API metadata', async () => { + await withController(async ({ controller }) => { + nock(TOKEN_END_POINT_API) + .get( + `/token/${convertHexToDecimal( + ChainId.mainnet, + )}?address=0x01&includeRwaData=true`, + ) + .reply(200, { + address: '0x01', + symbol: 'bar', + decimals: 2, + occurrences: 1, + name: 'Bar', + aggregators: [], + rwaData: { ticker: 'FROM_API' }, + }); - messenger.publish( - 'TokenListController:stateChange', - { - tokensChainsCache: { - [ChainId.mainnet]: { - timestamp: 1, - data: { - '0x01': { - address: '0x01', - symbol: 'bar', - decimals: 2, - occurrences: 1, - name: 'BarName', - iconUrl: - 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x01.png', - aggregators: ['Aave'], - rwaData: { ticker: 'NEW' }, - }, - }, - }, - }, - }, - [], - ); + await controller.addToken({ + address: '0x01', + symbol: 'bar', + decimals: 2, + networkClientId: 'mainnet', + rwaData: { ticker: 'FROM_CALLER' }, + }); expect( controller.state.allTokens[ChainId.mainnet][ defaultMockInternalAccount.address ][0].rwaData, - ).toStrictEqual({ ticker: 'NEW' }); + ).toStrictEqual({ ticker: 'FROM_CALLER' }); }); }); }); @@ -3934,7 +3924,6 @@ async function withController( 'NetworkController:networkDidChange', 'NetworkController:stateChange', 'AccountsController:selectedEvmAccountChange', - 'TokenListController:stateChange', 'KeyringController:accountRemoved', ], }); diff --git a/packages/assets-controllers/src/TokensController.ts b/packages/assets-controllers/src/TokensController.ts index a18429a7133..7c15890f9c6 100644 --- a/packages/assets-controllers/src/TokensController.ts +++ b/packages/assets-controllers/src/TokensController.ts @@ -50,15 +50,11 @@ import { ERC20Standard } from './Standards/ERC20Standard'; import { ERC1155Standard } from './Standards/NftStandards/ERC1155/ERC1155Standard'; import { fetchTokenMetadata, + fetchTokenListByChainId, TOKEN_METADATA_NO_SUPPORT_ERROR, TokenRwaData, } from './token-service'; -import type { - TokenListMap, - TokenListStateChange, - TokenListToken, - TokenListTokenListFetchedEvent, -} from './TokenListController'; +import type { TokenListMap, TokenListToken } from './TokenListController'; import type { Token } from './TokenRatesController'; import type { TokensControllerMethodActions } from './TokensController-method-action-types'; @@ -163,8 +159,6 @@ export type TokensControllerEvents = TokensControllerStateChangeEvent; export type AllowedEvents = | NetworkControllerStateChangeEvent | NetworkControllerNetworkDidChangeEvent - | TokenListStateChange - | TokenListTokenListFetchedEvent | AccountsControllerSelectedEvmAccountChangeEvent | KeyringControllerAccountRemovedEvent; @@ -212,6 +206,10 @@ export class TokensController extends BaseController< readonly #abortController: AbortController; + readonly #tokenListCache = new Map(); + + static readonly #tokenListCacheMaxAge = 4 * 60 * 60 * 1000; + /** * Tokens controller options * @@ -264,12 +262,14 @@ export class TokensController extends BaseController< (accountAddress: string) => this.#handleOnAccountRemoved(accountAddress), ); - this.messenger.subscribe( - 'TokenListController:tokenListFetched', - ({ chainId, tokenList }) => { - this.#enrichTokensFromTokenList(chainId, tokenList); - }, - ); + this.#enrichTokenMetadata().catch(() => { + // Silently handle enrichment errors on startup + }); + setInterval(() => { + this.#enrichTokenMetadata().catch(() => { + // Silently handle enrichment errors + }); + }, TokensController.#tokenListCacheMaxAge); } #handleOnAccountRemoved(accountAddress: string) { @@ -311,43 +311,6 @@ export class TokensController extends BaseController< }); } - /** - * Enriches tokens in `allTokens` for a given chain using data from a freshly - * fetched token list. Updates `name` (when missing) and `rwaData` for the - * currently selected address. - * - * @param chainId - The chain whose token list was just fetched. - * @param tokenList - The processed token list from the API response. - */ - #enrichTokensFromTokenList(chainId: Hex, tokenList: TokenListMap): void { - const { allTokens } = this.state; - const selectedAddress = this.#getSelectedAddress(); - - const tokensForChainAndAddress = allTokens[chainId]?.[selectedAddress]; - - if (!tokensForChainAndAddress?.length) { - return; - } - - // Deep clone the `allTokens` object to ensure mutability - const updatedAllTokens = cloneDeep(allTokens); - const tokens = updatedAllTokens[chainId][selectedAddress]; - - for (const token of tokens) { - const cachedToken = tokenList[token.address.toLowerCase()]; - if (cachedToken && cachedToken.name && !token.name) { - token.name = cachedToken.name; // Update the token name - } - if (cachedToken?.rwaData) { - token.rwaData = cachedToken.rwaData; // Update the token RWA data - } - } - - this.update((state) => { - state.allTokens = updatedAllTokens; - }); - } - /** * Handles the event when the network state changes. * @@ -410,6 +373,102 @@ export class TokensController extends BaseController< } } + #resolveRwaData( + callerRwaData: TokenRwaData | undefined, + tokenMetadata: TokenListToken | undefined, + ): { rwaData: TokenRwaData } | Record { + if (callerRwaData !== undefined) { + return { rwaData: callerRwaData }; + } + if (tokenMetadata?.rwaData) { + return { rwaData: tokenMetadata.rwaData }; + } + return {}; + } + + async #getTokenListForChain(chainId: Hex): Promise { + const cached = this.#tokenListCache.get(chainId); + const now = Date.now(); + + if ( + cached && + now - cached.timestamp < TokensController.#tokenListCacheMaxAge + ) { + return cached.data; + } + + const tokensFromAPI = await safelyExecute( + () => + fetchTokenListByChainId( + chainId, + this.#abortController.signal, + ) as Promise, + ); + + if (!tokensFromAPI) { + return cached?.data ?? {}; + } + + const tokenList: TokenListMap = {}; + for (const token of tokensFromAPI) { + tokenList[token.address] = { + ...token, + aggregators: formatAggregatorNames(token.aggregators), + iconUrl: formatIconUrlWithProxy({ + chainId, + tokenAddress: token.address, + }), + }; + } + + this.#tokenListCache.set(chainId, { data: tokenList, timestamp: now }); + return tokenList; + } + + async #enrichTokenMetadata(): Promise { + const { allTokens } = this.state; + const chainIds = Object.keys(allTokens) as Hex[]; + + if (chainIds.length === 0) { + return; + } + + const updatedAllTokens = cloneDeep(allTokens); + let hasChanges = false; + + for (const chainId of chainIds) { + const tokenList = await this.#getTokenListForChain(chainId); + if (Object.keys(tokenList).length === 0) { + continue; + } + + const accountsForChain = updatedAllTokens[chainId]; + for (const [, tokens] of Object.entries(accountsForChain)) { + for (const token of tokens) { + const cachedToken = tokenList[token.address.toLowerCase()]; + if (!cachedToken) { + continue; + } + if (cachedToken.name && !token.name) { + token.name = cachedToken.name; + hasChanges = true; + } + if (cachedToken.rwaData) { + token.rwaData = cachedToken.rwaData; + hasChanges = true; + } + } + } + } + + if (hasChanges) { + this.update(() => ({ + ...this.state, + allTokens: updatedAllTokens, + })); + } + } + /** * Adds a token to the stored token list. * @@ -480,8 +539,8 @@ export class TokensController extends BaseController< }), isERC721, aggregators: formatAggregatorNames(tokenMetadata?.aggregators ?? []), - name, - ...(rwaData !== undefined && { rwaData }), + name: name ?? tokenMetadata?.name, + ...this.#resolveRwaData(rwaData, tokenMetadata), }; const previousIndex = newTokens.findIndex( (token) => token.address.toLowerCase() === address.toLowerCase(), From a796bd119aaa9736e5eb3b95bd104e1af02afda2 Mon Sep 17 00:00:00 2001 From: juanmigdr Date: Tue, 21 Apr 2026 15:28:54 +0200 Subject: [PATCH 04/15] fix: formatting --- packages/assets-controllers/src/TokenDetectionController.ts | 5 ++++- packages/assets-controllers/src/TokensController.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index 0f2c4912f78..a524e54df7a 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -203,7 +203,10 @@ export class TokenDetectionController extends StaticIntervalPollingController(); + readonly #tokenListCache = new Map< + Hex, + { data: TokenListMap; timestamp: number } + >(); static readonly #tokenListCacheMaxAge = 4 * 60 * 60 * 1000; diff --git a/packages/assets-controllers/src/TokensController.ts b/packages/assets-controllers/src/TokensController.ts index 7c15890f9c6..e930017dbf7 100644 --- a/packages/assets-controllers/src/TokensController.ts +++ b/packages/assets-controllers/src/TokensController.ts @@ -206,7 +206,10 @@ export class TokensController extends BaseController< readonly #abortController: AbortController; - readonly #tokenListCache = new Map(); + readonly #tokenListCache = new Map< + Hex, + { data: TokenListMap; timestamp: number } + >(); static readonly #tokenListCacheMaxAge = 4 * 60 * 60 * 1000; From 0fcfc517543e80b6aa668044abe9959ea8335f83 Mon Sep 17 00:00:00 2001 From: juanmigdr Date: Tue, 21 Apr 2026 16:21:07 +0200 Subject: [PATCH 05/15] chore: minor changes --- .../src/TokensController.ts | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/assets-controllers/src/TokensController.ts b/packages/assets-controllers/src/TokensController.ts index e930017dbf7..58079197e59 100644 --- a/packages/assets-controllers/src/TokensController.ts +++ b/packages/assets-controllers/src/TokensController.ts @@ -213,6 +213,8 @@ export class TokensController extends BaseController< static readonly #tokenListCacheMaxAge = 4 * 60 * 60 * 1000; + #enrichIntervalId: ReturnType | undefined; + /** * Tokens controller options * @@ -268,7 +270,7 @@ export class TokensController extends BaseController< this.#enrichTokenMetadata().catch(() => { // Silently handle enrichment errors on startup }); - setInterval(() => { + this.#enrichIntervalId = setInterval(() => { this.#enrichTokenMetadata().catch(() => { // Silently handle enrichment errors }); @@ -456,7 +458,7 @@ export class TokensController extends BaseController< token.name = cachedToken.name; hasChanges = true; } - if (cachedToken.rwaData) { + if (cachedToken.rwaData && !token.rwaData) { token.rwaData = cachedToken.rwaData; hasChanges = true; } @@ -579,6 +581,15 @@ export class TokensController extends BaseController< this.update((state) => { Object.assign(state, newState); }); + + // Only enrich if the token ended up without rwaData (i.e. caller didn't + // provide it and the single-token metadata endpoint didn't return it). + if (!newEntry.rwaData) { + this.#enrichTokenMetadata().catch(() => { + // Silently handle enrichment errors + }); + } + return newTokens; } finally { releaseLock(); @@ -657,6 +668,14 @@ export class TokensController extends BaseController< state.allDetectedTokens = newAllDetectedTokens; state.allIgnoredTokens = newAllIgnoredTokens; }); + + // Only enrich if any token in the batch is missing rwaData. + const needsEnrichment = tokensToImport.some((token) => !token.rwaData); + if (needsEnrichment) { + this.#enrichTokenMetadata().catch(() => { + // Silently handle enrichment errors + }); + } } finally { releaseLock(); } @@ -1222,6 +1241,15 @@ export class TokensController extends BaseController< return account?.address ?? ''; } + override destroy(): void { + super.destroy(); + if (this.#enrichIntervalId !== undefined) { + clearInterval(this.#enrichIntervalId); + this.#enrichIntervalId = undefined; + } + this.#abortController.abort(); + } + /** * Reset the controller state to the default state. */ From c5a06b5a45c09e2d5758f375216ad9462febfbc3 Mon Sep 17 00:00:00 2001 From: juanmigdr Date: Wed, 22 Apr 2026 10:54:21 +0200 Subject: [PATCH 06/15] fix: bugs --- .../src/TokenDetectionController.test.ts | 37 +++++++------- .../src/TokenDetectionController.ts | 41 ++++++--------- .../src/TokensController.test.ts | 15 +++--- .../src/TokensController.ts | 25 ++-------- .../assets-controllers/src/token-service.ts | 50 ++++++++++++++++++- 5 files changed, 98 insertions(+), 70 deletions(-) diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index 9a124e55d93..00a3835383c 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -37,14 +37,17 @@ import { buildInfuraNetworkConfiguration, } from '../../network-controller/tests/helpers'; import { formatAggregatorNames } from './assetsUtil'; -import { TOKEN_END_POINT_API, fetchTokenListByChainId } from './token-service'; +import { + TOKEN_END_POINT_API, + fetchAndBuildTokenListMap, +} from './token-service'; import { fetchVerifiedTokensByAddresses } from './tokens-api-v3'; import type { TokenV3Asset } from './tokens-api-v3'; import type { TokenDetectionControllerMessenger } from './TokenDetectionController'; jest.mock('./token-service', () => ({ ...jest.requireActual('./token-service'), - fetchTokenListByChainId: jest.fn(), + fetchAndBuildTokenListMap: jest.fn(), })); jest.mock('./tokens-api-v3', () => ({ @@ -52,9 +55,9 @@ jest.mock('./tokens-api-v3', () => ({ fetchVerifiedTokensByAddresses: jest.fn(), })); -const mockFetchTokenListByChainId = - fetchTokenListByChainId as jest.MockedFunction< - typeof fetchTokenListByChainId +const mockFetchAndBuildTokenListMap = + fetchAndBuildTokenListMap as jest.MockedFunction< + typeof fetchAndBuildTokenListMap >; const mockFetchVerifiedTokensByAddresses = @@ -248,7 +251,7 @@ describe('TokenDetectionController', () => { beforeEach(async () => { mockFetchVerifiedTokensByAddresses.mockResolvedValue(new Map()); - mockFetchTokenListByChainId.mockResolvedValue(undefined); + mockFetchAndBuildTokenListMap.mockResolvedValue(undefined); nock(TOKEN_END_POINT_API) .get(getTokensPath(ChainId.mainnet)) .reply(200, sampleTokenList) @@ -627,10 +630,10 @@ describe('TokenDetectionController', () => { }); await controller.start(); - mockFetchTokenListByChainId.mockImplementation((fetchChainId) => { + mockFetchAndBuildTokenListMap.mockImplementation((fetchChainId) => { if (fetchChainId === '0xa86a') { - return Promise.resolve([ - { + return Promise.resolve({ + [sampleTokenA.address]: { name: sampleTokenA.name, symbol: sampleTokenA.symbol, decimals: sampleTokenA.decimals, @@ -639,7 +642,7 @@ describe('TokenDetectionController', () => { aggregators: sampleTokenA.aggregators, iconUrl: sampleTokenA.image, }, - { + [sampleTokenB.address]: { name: sampleTokenB.name, symbol: sampleTokenB.symbol, decimals: sampleTokenB.decimals, @@ -648,7 +651,7 @@ describe('TokenDetectionController', () => { aggregators: sampleTokenB.aggregators, iconUrl: sampleTokenB.image, }, - ]); + }); } return Promise.resolve(undefined); }); @@ -3434,10 +3437,10 @@ describe('TokenDetectionController', () => { async ({ controller, callActionSpy }) => { // Update the mock to return populated cache data // This simulates TokenListController having fetched token list data after construction - mockFetchTokenListByChainId.mockImplementation((fetchChainId) => { + mockFetchAndBuildTokenListMap.mockImplementation((fetchChainId) => { if (fetchChainId === chainId) { - return Promise.resolve([ - { + return Promise.resolve({ + [mockTokenAddress]: { name: 'USD Coin', symbol: 'USDC', decimals: 6, @@ -3446,7 +3449,7 @@ describe('TokenDetectionController', () => { iconUrl: 'https://example.com/usdc.png', occurrences: 11, }, - ]); + }); } return Promise.resolve(undefined); }); @@ -3784,10 +3787,10 @@ async function withController( ...getDefaultTokenListState(), ...mockTokenListState, }; - mockFetchTokenListByChainId.mockImplementation((chainId: Hex) => { + mockFetchAndBuildTokenListMap.mockImplementation((chainId: Hex) => { const cache = initialTokenListState.tokensChainsCache[chainId]; if (cache) { - return Promise.resolve(Object.values(cache.data)); + return Promise.resolve(cache.data); } return Promise.resolve(undefined); }); diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index a524e54df7a..16443e4597c 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -42,20 +42,15 @@ import type { Hex } from '@metamask/utils'; import { mapValues, isObject, get } from 'lodash'; import type { AssetsContractController } from './AssetsContractController'; -import { - isTokenDetectionSupportedForNetwork, - formatAggregatorNames, - formatIconUrlWithProxy, -} from './assetsUtil'; +import { isTokenDetectionSupportedForNetwork } from './assetsUtil'; import { SUPPORTED_NETWORKS_ACCOUNTS_API_V4 } from './constants'; import type { TokenDetectionControllerMethodActions } from './TokenDetectionController-method-action-types'; import type { TokenListMap, - TokenListToken, TokensChainsCache, } from './TokenListController'; import { fetchVerifiedTokensByAddresses } from './tokens-api-v3'; -import { fetchTokenListByChainId } from './token-service'; +import { fetchAndBuildTokenListMap } from './token-service'; import type { Token } from './TokenRatesController'; import type { TokensControllerGetStateAction } from './TokensController'; import type { @@ -210,6 +205,8 @@ export class TokenDetectionController extends StaticIntervalPollingController - fetchTokenListByChainId( - chainId, - new AbortController().signal, - ) as Promise, + const tokenList = await fetchAndBuildTokenListMap( + chainId, + this.#abortController.signal, ); - if (!tokensFromAPI) { + if (!tokenList) { return cached?.data ?? {}; } - const tokenList: TokenListMap = {}; - for (const token of tokensFromAPI) { - tokenList[token.address] = { - ...token, - aggregators: formatAggregatorNames(token.aggregators), - iconUrl: formatIconUrlWithProxy({ - chainId, - tokenAddress: token.address, - }), - }; - } - this.#tokenListCache.set(chainId, { data: tokenList, timestamp: now }); return tokenList; } @@ -991,6 +974,12 @@ export class TokenDetectionController extends StaticIntervalPollingController ({ ...jest.requireActual('./token-service'), - fetchTokenListByChainId: jest.fn(), + fetchAndBuildTokenListMap: jest.fn(), })); -const mockFetchTokenListByChainId = - fetchTokenListByChainId as jest.MockedFunction< - typeof fetchTokenListByChainId +const mockFetchAndBuildTokenListMap = + fetchAndBuildTokenListMap as jest.MockedFunction< + typeof fetchAndBuildTokenListMap >; type AllActions = @@ -89,7 +92,7 @@ describe('TokensController', () => { ContractMock.mockReturnValue( buildMockEthersERC721Contract({ supportsInterface: false }), ); - mockFetchTokenListByChainId.mockResolvedValue(undefined); + mockFetchAndBuildTokenListMap.mockResolvedValue(undefined); }); it('should set default state', async () => { diff --git a/packages/assets-controllers/src/TokensController.ts b/packages/assets-controllers/src/TokensController.ts index 58079197e59..02bd741c297 100644 --- a/packages/assets-controllers/src/TokensController.ts +++ b/packages/assets-controllers/src/TokensController.ts @@ -50,7 +50,7 @@ import { ERC20Standard } from './Standards/ERC20Standard'; import { ERC1155Standard } from './Standards/NftStandards/ERC1155/ERC1155Standard'; import { fetchTokenMetadata, - fetchTokenListByChainId, + fetchAndBuildTokenListMap, TOKEN_METADATA_NO_SUPPORT_ERROR, TokenRwaData, } from './token-service'; @@ -402,30 +402,15 @@ export class TokensController extends BaseController< return cached.data; } - const tokensFromAPI = await safelyExecute( - () => - fetchTokenListByChainId( - chainId, - this.#abortController.signal, - ) as Promise, + const tokenList = await fetchAndBuildTokenListMap( + chainId, + this.#abortController.signal, ); - if (!tokensFromAPI) { + if (!tokenList) { return cached?.data ?? {}; } - const tokenList: TokenListMap = {}; - for (const token of tokensFromAPI) { - tokenList[token.address] = { - ...token, - aggregators: formatAggregatorNames(token.aggregators), - iconUrl: formatIconUrlWithProxy({ - chainId, - tokenAddress: token.address, - }), - }; - } - this.#tokenListCache.set(chainId, { data: tokenList, timestamp: now }); return tokenList; } diff --git a/packages/assets-controllers/src/token-service.ts b/packages/assets-controllers/src/token-service.ts index 02464f15560..969df972bf5 100644 --- a/packages/assets-controllers/src/token-service.ts +++ b/packages/assets-controllers/src/token-service.ts @@ -6,7 +6,12 @@ import { } from '@metamask/controller-utils'; import type { CaipAssetType, CaipChainId, Hex } from '@metamask/utils'; -import { isTokenListSupportedForNetwork } from './assetsUtil'; +import { + isTokenListSupportedForNetwork, + formatAggregatorNames, + formatIconUrlWithProxy, +} from './assetsUtil'; +import type { TokenListMap, TokenListToken } from './TokenListController'; export const TOKEN_END_POINT_API = 'https://token.api.cx.metamask.io'; export const TOKEN_METADATA_NO_SUPPORT_ERROR = @@ -226,6 +231,49 @@ export async function fetchTokenListByChainId( return undefined; } +/** + * Fetch the token list for the given chain and transform each entry into the + * normalized {@link TokenListMap} shape (formatted aggregator names + proxied + * icon URL). Returns `undefined` when the request is aborted or fails so + * callers can fall back to a previously cached value. + * + * @param chainId - The hex chain ID to fetch tokens for. + * @param abortSignal - An abort signal used to cancel the request if necessary. + * @returns The normalized token list map, or `undefined` on failure. + */ +export async function fetchAndBuildTokenListMap( + chainId: Hex, + abortSignal: AbortSignal, +): Promise { + let tokensFromAPI: TokenListToken[] | undefined; + try { + tokensFromAPI = (await fetchTokenListByChainId( + chainId, + abortSignal, + )) as TokenListToken[] | undefined; + } catch { + return undefined; + } + + if (!tokensFromAPI) { + return undefined; + } + + const tokenList: TokenListMap = {}; + for (const token of tokensFromAPI) { + tokenList[token.address] = { + ...token, + aggregators: formatAggregatorNames(token.aggregators), + iconUrl: formatIconUrlWithProxy({ + chainId, + tokenAddress: token.address, + }), + }; + } + + return tokenList; +} + export type TokenRwaData = { market?: { nextOpen?: string; From bc23f1cc4327678d2c473125cf459c88470d5d62 Mon Sep 17 00:00:00 2001 From: juanmigdr Date: Wed, 22 Apr 2026 10:54:34 +0200 Subject: [PATCH 07/15] fix: format --- .../src/TokenDetectionController.test.ts | 2 +- .../assets-controllers/src/TokenDetectionController.ts | 9 +++------ packages/assets-controllers/src/token-service.ts | 7 +++---- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index 00a3835383c..33f90623757 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -41,9 +41,9 @@ import { TOKEN_END_POINT_API, fetchAndBuildTokenListMap, } from './token-service'; +import type { TokenDetectionControllerMessenger } from './TokenDetectionController'; import { fetchVerifiedTokensByAddresses } from './tokens-api-v3'; import type { TokenV3Asset } from './tokens-api-v3'; -import type { TokenDetectionControllerMessenger } from './TokenDetectionController'; jest.mock('./token-service', () => ({ ...jest.requireActual('./token-service'), diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index 16443e4597c..5780ba3f0ff 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -44,14 +44,11 @@ import { mapValues, isObject, get } from 'lodash'; import type { AssetsContractController } from './AssetsContractController'; import { isTokenDetectionSupportedForNetwork } from './assetsUtil'; import { SUPPORTED_NETWORKS_ACCOUNTS_API_V4 } from './constants'; -import type { TokenDetectionControllerMethodActions } from './TokenDetectionController-method-action-types'; -import type { - TokenListMap, - TokensChainsCache, -} from './TokenListController'; -import { fetchVerifiedTokensByAddresses } from './tokens-api-v3'; import { fetchAndBuildTokenListMap } from './token-service'; +import type { TokenDetectionControllerMethodActions } from './TokenDetectionController-method-action-types'; +import type { TokenListMap, TokensChainsCache } from './TokenListController'; import type { Token } from './TokenRatesController'; +import { fetchVerifiedTokensByAddresses } from './tokens-api-v3'; import type { TokensControllerGetStateAction } from './TokensController'; import type { TokensControllerAddDetectedTokensAction, diff --git a/packages/assets-controllers/src/token-service.ts b/packages/assets-controllers/src/token-service.ts index 969df972bf5..f3260ce3045 100644 --- a/packages/assets-controllers/src/token-service.ts +++ b/packages/assets-controllers/src/token-service.ts @@ -247,10 +247,9 @@ export async function fetchAndBuildTokenListMap( ): Promise { let tokensFromAPI: TokenListToken[] | undefined; try { - tokensFromAPI = (await fetchTokenListByChainId( - chainId, - abortSignal, - )) as TokenListToken[] | undefined; + tokensFromAPI = (await fetchTokenListByChainId(chainId, abortSignal)) as + | TokenListToken[] + | undefined; } catch { return undefined; } From 9cde2de54ce7b14fa5746f1da356a8d6cedf8e1e Mon Sep 17 00:00:00 2001 From: juanmigdr Date: Wed, 22 Apr 2026 11:16:36 +0200 Subject: [PATCH 08/15] chore: minimal --- .../src/TokenDetectionController-method-action-types.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/assets-controllers/src/TokenDetectionController-method-action-types.ts b/packages/assets-controllers/src/TokenDetectionController-method-action-types.ts index 1ec590fcea7..f6cc3afb617 100644 --- a/packages/assets-controllers/src/TokenDetectionController-method-action-types.ts +++ b/packages/assets-controllers/src/TokenDetectionController-method-action-types.ts @@ -38,7 +38,7 @@ export type TokenDetectionControllerStopAction = { }; /** - * For each token in the token list provided by the TokenListController, checks the token's balance for the selected account address on the active network. + * For each token in the token list (fetched directly from the tokens API), checks the token's balance for the selected account address on the active network. * On mainnet, if token detection is disabled in preferences, ERC20 token auto detection will be triggered for each contract address in the legacy token list from the @metamask/contract-metadata repo. * * @param options - Options for token detection. @@ -58,7 +58,8 @@ export type TokenDetectionControllerDetectTokensAction = { * This method: * - Checks if useTokenDetection preference is enabled (skips if disabled) * - Checks if external services are enabled (skips if disabled) - * - Tokens are expected to be in the tokensChainsCache with full metadata + * - Fetches token metadata from the v3 tokens API and filters out unverified + * tokens (occurrences < 3) as a spam prevention measure * - Balance fetching is skipped since balances are provided by the websocket * - Ignored tokens have been filtered out by the caller * @@ -78,7 +79,8 @@ export type TokenDetectionControllerAddDetectedTokensViaWsAction = { * - Checks if useTokenDetection preference is enabled (skips if disabled) * - Checks if external services are enabled (skips if disabled) * - Filters out tokens already in allTokens or allIgnoredTokens - * - Tokens are expected to be in the tokensChainsCache with full metadata + * - Fetches token metadata from the v3 tokens API and filters out unverified + * tokens (occurrences < 3) as a spam prevention measure * - Balance fetching is skipped since balances are provided by the caller * * @param options - The options object From 6265422cad9cea08b33e5c9812f48e7cff6fbe54 Mon Sep 17 00:00:00 2001 From: juanmigdr Date: Wed, 22 Apr 2026 12:46:16 +0200 Subject: [PATCH 09/15] chore: fix some issues --- .../src/TokenDetectionController.test.ts | 199 +++++++++++++++++ .../src/TokenDetectionController.ts | 41 ++-- .../src/TokensController.test.ts | 211 ++++++++++++++++++ .../src/token-service.test.ts | 55 +++++ .../assets-controllers/src/token-service.ts | 2 +- .../src/tokens-api-v3.test.ts | 175 +++++++++++++++ 6 files changed, 666 insertions(+), 17 deletions(-) create mode 100644 packages/assets-controllers/src/tokens-api-v3.test.ts diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index 33f90623757..2c92b4d8f5f 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -2816,6 +2816,58 @@ describe('TokenDetectionController', () => { }); describe('addDetectedTokensViaWs', () => { + it('should return early when useTokenDetection is disabled', async () => { + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const chainId = '0xa86a'; + + await withController( + { + options: { + disabled: false, + useTokenDetection: () => false, + }, + }, + async ({ controller, callActionSpy }) => { + await controller.addDetectedTokensViaWs({ + tokensSlice: [mockTokenAddress], + chainId: chainId as Hex, + }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.anything(), + expect.anything(), + ); + }, + ); + }); + + it('should return early when useExternalServices is disabled', async () => { + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const chainId = '0xa86a'; + + await withController( + { + options: { + disabled: false, + useExternalServices: () => false, + }, + }, + async ({ controller, callActionSpy }) => { + await controller.addDetectedTokensViaWs({ + tokensSlice: [mockTokenAddress], + chainId: chainId as Hex, + }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.anything(), + expect.anything(), + ); + }, + ); + }); + it('should add tokens detected from websocket with metadata from cache', async () => { const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; const checksummedTokenAddress = @@ -3282,6 +3334,33 @@ describe('TokenDetectionController', () => { ); }); + it('should return early when useExternalServices is disabled', async () => { + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const chainId = '0xa86a'; + + await withController( + { + options: { + disabled: false, + useTokenDetection: () => true, + useExternalServices: () => false, + }, + }, + async ({ controller, callActionSpy }) => { + await controller.addDetectedTokensViaPolling({ + tokensSlice: [mockTokenAddress], + chainId: chainId as Hex, + }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.anything(), + expect.anything(), + ); + }, + ); + }); + it('should skip tokens already in allTokens', async () => { const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; const checksummedTokenAddress = @@ -3616,6 +3695,126 @@ describe('TokenDetectionController', () => { ); }); }); + + describe('#getTokenListForChain cache behaviour', () => { + it('fetches the full API list after detection is re-enabled on mainnet, bypassing the stale static cache', async () => { + const mainnetChainId = ChainId.mainnet; + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({}); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, + }, + async ({ + controller, + mockNetworkState, + mockGetNetworkClientById, + mockFindNetworkClientIdByChainId, + triggerPreferencesStateChange, + }) => { + const defaultState = getDefaultNetworkControllerState(); + const mainnetNetworkConfig = { + chainId: ChainId.mainnet, + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + blockExplorerUrls: [] as string[], + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + networkClientId: 'mainnet' as NetworkClientId, + type: RpcEndpointType.Custom, + url: 'https://mainnet.infura.io/v3/test', + failoverUrls: [] as string[], + }, + ], + }; + mockNetworkState({ + ...defaultState, + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + ...defaultState.networkConfigurationsByChainId, + [ChainId.mainnet]: mainnetNetworkConfig, + }, + }); + mockFindNetworkClientIdByChainId(() => 'mainnet'); + mockGetNetworkClientById( + () => + ({ + configuration: { chainId: ChainId.mainnet }, + provider: {}, + destroy: {}, + blockTracker: {}, + }) as unknown as AutoManagedNetworkClient, + ); + + // Disable detection so the static mainnet list gets cached on first run + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useTokenDetection: false, + }); + + // First run: detection disabled → static list gets cached + await controller.detectTokens({ + chainIds: [mainnetChainId], + forceRpc: true, + }); + + // API should NOT have been called while detection was disabled + expect(mockFetchAndBuildTokenListMap).not.toHaveBeenCalledWith( + mainnetChainId, + expect.anything(), + ); + + // User re-enables token detection + mockFetchAndBuildTokenListMap.mockClear(); + mockFetchAndBuildTokenListMap.mockResolvedValue({}); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useTokenDetection: true, + }); + + // Second run: detection now enabled → must bypass stale static cache + // and call the full API + await controller.detectTokens({ + chainIds: [mainnetChainId], + forceRpc: true, + }); + + expect(mockFetchAndBuildTokenListMap).toHaveBeenCalledWith( + mainnetChainId, + expect.anything(), + ); + }, + ); + }); + }); + + describe('destroy', () => { + it('should abort the internal abort controller and stop polling', async () => { + await withController( + { options: { disabled: false } }, + async ({ controller }) => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + + controller.destroy(); + + expect(abortSpy).toHaveBeenCalled(); + abortSpy.mockRestore(); + }, + ); + }); + }); }); /** diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index 5780ba3f0ff..27aecaa629e 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -326,6 +326,11 @@ export class TokenDetectionController extends StaticIntervalPollingController { @@ -654,16 +659,11 @@ export class TokenDetectionController extends StaticIntervalPollingController { - const cached = this.#tokenListCache.get(chainId); const now = Date.now(); - if ( - cached && - now - cached.timestamp < TokenDetectionController.#tokenListCacheMaxAge - ) { - return cached.data; - } - + // Check the preference-based path first so that changing the preference + // (e.g. re-enabling detection on mainnet) always bypasses a stale + // static-list cache entry. const isMainnetDetectionInactive = !this.#isDetectionEnabledFromPreferences && chainId === ChainId.mainnet; @@ -673,6 +673,15 @@ export class TokenDetectionController extends StaticIntervalPollingController + fetchVerifiedTokensByAddresses(chainId, tokensSlice), + )) ?? new Map(); const tokensWithBalance: Token[] = []; const eventTokensDetails: string[] = []; @@ -901,10 +910,10 @@ export class TokenDetectionController extends StaticIntervalPollingController + fetchVerifiedTokensByAddresses(chainId, addressesToFetch), + )) ?? new Map(); const tokensWithBalance: Token[] = []; const eventTokensDetails: string[] = []; diff --git a/packages/assets-controllers/src/TokensController.test.ts b/packages/assets-controllers/src/TokensController.test.ts index 1a62c406f6c..b71d70e5da4 100644 --- a/packages/assets-controllers/src/TokensController.test.ts +++ b/packages/assets-controllers/src/TokensController.test.ts @@ -3753,6 +3753,217 @@ describe('TokensController', () => { }); }); + describe('enrichTokenMetadata', () => { + it('enriches token names from the token list on construction', async () => { + const tokenAddress = '0x01'; + const initialState: TokensControllerState = { + allTokens: { + [ChainId.mainnet]: { + '0x1': [ + { + address: tokenAddress, + symbol: 'TKN', + decimals: 18, + aggregators: [], + image: undefined, + name: undefined, + }, + ], + }, + }, + allIgnoredTokens: {}, + allDetectedTokens: {}, + }; + + mockFetchAndBuildTokenListMap.mockResolvedValue({ + [tokenAddress]: { + address: tokenAddress, + symbol: 'TKN', + decimals: 18, + name: 'Token Name', + occurrences: 5, + aggregators: [], + iconUrl: '', + }, + }); + + await withController( + { options: { state: initialState } }, + async ({ controller }) => { + // Allow the async enrichment to complete + await new Promise(process.nextTick); + expect( + controller.state.allTokens[ChainId.mainnet]['0x1'][0].name, + ).toBe('Token Name'); + }, + ); + }); + + it('enriches rwaData from the token list on construction', async () => { + const tokenAddress = '0x01'; + const rwaData = { ticker: 'TICKER', instrumentType: 'bond' }; + const initialState: TokensControllerState = { + allTokens: { + [ChainId.mainnet]: { + '0x1': [ + { + address: tokenAddress, + symbol: 'TKN', + decimals: 18, + aggregators: [], + image: undefined, + name: 'Existing Name', + }, + ], + }, + }, + allIgnoredTokens: {}, + allDetectedTokens: {}, + }; + + mockFetchAndBuildTokenListMap.mockResolvedValue({ + [tokenAddress]: { + address: tokenAddress, + symbol: 'TKN', + decimals: 18, + name: 'Existing Name', + occurrences: 5, + aggregators: [], + iconUrl: '', + rwaData, + }, + }); + + await withController( + { options: { state: initialState } }, + async ({ controller }) => { + await new Promise(process.nextTick); + expect( + controller.state.allTokens[ChainId.mainnet]['0x1'][0].rwaData, + ).toStrictEqual(rwaData); + }, + ); + }); + + it('does not update state when no enrichment is needed', async () => { + const tokenAddress = '0x01'; + const initialState: TokensControllerState = { + allTokens: { + [ChainId.mainnet]: { + '0x1': [ + { + address: tokenAddress, + symbol: 'TKN', + decimals: 18, + aggregators: [], + image: undefined, + name: 'Already Named', + }, + ], + }, + }, + allIgnoredTokens: {}, + allDetectedTokens: {}, + }; + + mockFetchAndBuildTokenListMap.mockResolvedValue({ + [tokenAddress]: { + address: tokenAddress, + symbol: 'TKN', + decimals: 18, + name: 'Already Named', + occurrences: 5, + aggregators: [], + iconUrl: '', + }, + }); + + await withController( + { options: { state: initialState } }, + async ({ controller }) => { + const stateBefore = controller.state; + await new Promise(process.nextTick); + // State should be the same reference since no changes were made + expect(controller.state.allTokens).toStrictEqual( + stateBefore.allTokens, + ); + }, + ); + }); + + it('uses cached token list on repeated enrichment calls', async () => { + const tokenAddress = '0x01'; + const initialState: TokensControllerState = { + allTokens: { + [ChainId.mainnet]: { + '0x1': [ + { + address: tokenAddress, + symbol: 'TKN', + decimals: 18, + aggregators: [], + image: undefined, + name: undefined, + }, + ], + }, + }, + allIgnoredTokens: {}, + allDetectedTokens: {}, + }; + + mockFetchAndBuildTokenListMap.mockResolvedValue({ + [tokenAddress]: { + address: tokenAddress, + symbol: 'TKN', + decimals: 18, + name: 'Token Name', + occurrences: 5, + aggregators: [], + iconUrl: '', + }, + }); + + await withController( + { options: { state: initialState } }, + async ({ controller }) => { + await new Promise(process.nextTick); + // First call should have fetched + expect(mockFetchAndBuildTokenListMap).toHaveBeenCalledTimes(1); + // Clear mock call count, then trigger enrichment again + mockFetchAndBuildTokenListMap.mockClear(); + // Add a token to trigger enrichment + await controller.addToken({ + address: '0x02', + symbol: 'TKN2', + decimals: 18, + networkClientId: 'mainnet', + }); + await new Promise(process.nextTick); + // Second call should use cached list (not call API again) + expect(mockFetchAndBuildTokenListMap).not.toHaveBeenCalled(); + }, + ); + }); + }); + + describe('destroy', () => { + it('should clear the enrich interval and abort the abort controller', async () => { + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + + await withController({}, ({ controller }) => { + controller.destroy(); + + expect(clearIntervalSpy).toHaveBeenCalled(); + expect(abortSpy).toHaveBeenCalled(); + }); + + clearIntervalSpy.mockRestore(); + abortSpy.mockRestore(); + }); + }); + describe('metadata', () => { it('includes expected state in debug snapshots', async () => { await withController(({ controller }) => { diff --git a/packages/assets-controllers/src/token-service.test.ts b/packages/assets-controllers/src/token-service.test.ts index 7ae819b9a8e..5bf0ee52dad 100644 --- a/packages/assets-controllers/src/token-service.test.ts +++ b/packages/assets-controllers/src/token-service.test.ts @@ -5,6 +5,7 @@ import nock from 'nock'; import type { SortTrendingBy } from './token-service'; import { + fetchAndBuildTokenListMap, fetchTokenAssets, fetchTokenListByChainId, fetchTokenMetadata, @@ -1683,4 +1684,58 @@ describe('Token service', () => { expect(result).toStrictEqual([]); }); }); + + describe('fetchAndBuildTokenListMap', () => { + const abortController = new AbortController(); + const { signal } = abortController; + + it('returns a normalized TokenListMap with formatted aggregators and proxied icon URLs', async () => { + nock(TOKEN_END_POINT_API) + .get( + `/tokens/${sampleDecimalChainId}?occurrenceFloor=3&includeNativeAssets=false&includeTokenFees=false&includeAssetType=false&includeERC20Permit=false&includeStorage=false&includeRwaData=true`, + ) + .reply(200, sampleTokenList) + .persist(); + + const result = await fetchAndBuildTokenListMap(sampleChainId, signal); + + expect(result).toBeDefined(); + expect(typeof result).toBe('object'); + // Each returned address should map to a token object + for (const token of sampleTokenList) { + expect(result?.[token.address]).toBeDefined(); + expect(result?.[token.address].address).toBe(token.address); + expect(result?.[token.address].symbol).toBe(token.symbol); + } + }); + + it('returns undefined when fetchTokenListByChainId returns undefined (aborted)', async () => { + const ac = new AbortController(); + nock(TOKEN_END_POINT_API) + .get( + `/tokens/${sampleDecimalChainId}?occurrenceFloor=3&includeNativeAssets=false&includeTokenFees=false&includeAssetType=false&includeERC20Permit=false&includeStorage=false&includeRwaData=true`, + ) + .delay(ONE_SECOND_IN_MILLISECONDS) + .reply(200, sampleTokenList) + .persist(); + + const fetchPromise = fetchAndBuildTokenListMap(sampleChainId, ac.signal); + ac.abort(); + + expect(await fetchPromise).toBeUndefined(); + }); + + it('returns undefined when the fetch throws an error', async () => { + nock(TOKEN_END_POINT_API) + .get( + `/tokens/${sampleDecimalChainId}?occurrenceFloor=3&includeNativeAssets=false&includeTokenFees=false&includeAssetType=false&includeERC20Permit=false&includeStorage=false&includeRwaData=true`, + ) + .replyWithError('network failure') + .persist(); + + const result = await fetchAndBuildTokenListMap(sampleChainId, signal); + + expect(result).toBeUndefined(); + }); + }); }); diff --git a/packages/assets-controllers/src/token-service.ts b/packages/assets-controllers/src/token-service.ts index f3260ce3045..37585ea3da3 100644 --- a/packages/assets-controllers/src/token-service.ts +++ b/packages/assets-controllers/src/token-service.ts @@ -262,7 +262,7 @@ export async function fetchAndBuildTokenListMap( for (const token of tokensFromAPI) { tokenList[token.address] = { ...token, - aggregators: formatAggregatorNames(token.aggregators), + aggregators: formatAggregatorNames(token.aggregators ?? []), iconUrl: formatIconUrlWithProxy({ chainId, tokenAddress: token.address, diff --git a/packages/assets-controllers/src/tokens-api-v3.test.ts b/packages/assets-controllers/src/tokens-api-v3.test.ts new file mode 100644 index 00000000000..21c5cfd3a28 --- /dev/null +++ b/packages/assets-controllers/src/tokens-api-v3.test.ts @@ -0,0 +1,175 @@ +import { handleFetch } from '@metamask/controller-utils'; + +import { + buildCaipAssetId, + fetchVerifiedTokensByAddresses, + MAX_BATCH_SIZE, + MIN_OCCURRENCES, + TOKENS_API_V3_BASE_URL, +} from './tokens-api-v3'; + +jest.mock('@metamask/controller-utils', () => ({ + ...jest.requireActual('@metamask/controller-utils'), + handleFetch: jest.fn(), +})); + +const mockHandleFetch = handleFetch as jest.MockedFunction; + +const MOCK_CHAIN_ID = '0x1' as const; +const MOCK_TOKEN_ADDRESS = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + +const makeAsset = ( + address: string, + occurrences: number, + chainId = MOCK_CHAIN_ID, +) => ({ + assetId: buildCaipAssetId(chainId, address), + decimals: 18, + iconUrl: `https://example.com/${address}.png`, + name: 'Token', + symbol: 'TKN', + occurrences, + aggregators: ['agg1', 'agg2'], +}); + +describe('tokens-api-v3', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('buildCaipAssetId', () => { + it('builds a CAIP-19 asset ID from chainId and address', () => { + expect(buildCaipAssetId('0x1', '0xAbCd')).toBe( + 'eip155:1/erc20:0xabcd', + ); + }); + + it('lowercases the token address', () => { + const result = buildCaipAssetId( + '0x89', + '0xABCDEF1234567890ABCDEF1234567890ABCDEF12', + ); + expect(result).toBe( + 'eip155:137/erc20:0xabcdef1234567890abcdef1234567890abcdef12', + ); + }); + }); + + describe('fetchVerifiedTokensByAddresses', () => { + it('returns an empty map when given an empty address list', async () => { + const result = await fetchVerifiedTokensByAddresses(MOCK_CHAIN_ID, []); + expect(result.size).toBe(0); + expect(mockHandleFetch).not.toHaveBeenCalled(); + }); + + it('returns verified tokens that meet the minimum occurrences threshold', async () => { + const verifiedAsset = makeAsset(MOCK_TOKEN_ADDRESS, MIN_OCCURRENCES); + mockHandleFetch.mockResolvedValue([verifiedAsset]); + + const result = await fetchVerifiedTokensByAddresses(MOCK_CHAIN_ID, [ + MOCK_TOKEN_ADDRESS, + ]); + + expect(result.size).toBe(1); + expect(result.get(MOCK_TOKEN_ADDRESS.toLowerCase())).toStrictEqual( + verifiedAsset, + ); + }); + + it('filters out tokens below the minimum occurrences threshold', async () => { + const spamAsset = makeAsset(MOCK_TOKEN_ADDRESS, MIN_OCCURRENCES - 1); + mockHandleFetch.mockResolvedValue([spamAsset]); + + const result = await fetchVerifiedTokensByAddresses(MOCK_CHAIN_ID, [ + MOCK_TOKEN_ADDRESS, + ]); + + expect(result.size).toBe(0); + }); + + it('correctly constructs the API URL with asset IDs', async () => { + mockHandleFetch.mockResolvedValue([]); + + await fetchVerifiedTokensByAddresses(MOCK_CHAIN_ID, [MOCK_TOKEN_ADDRESS]); + + const expectedAssetId = buildCaipAssetId(MOCK_CHAIN_ID, MOCK_TOKEN_ADDRESS); + const expectedParams = new URLSearchParams({ + assetIds: expectedAssetId, + includeOccurrences: 'true', + includeIconUrl: 'true', + includeAggregators: 'true', + includeRwaData: 'true', + }); + expect(mockHandleFetch).toHaveBeenCalledWith( + `${TOKENS_API_V3_BASE_URL}/assets?${expectedParams}`, + ); + }); + + it('handles a non-array API response gracefully', async () => { + mockHandleFetch.mockResolvedValue({ error: 'bad request' } as never); + + const result = await fetchVerifiedTokensByAddresses(MOCK_CHAIN_ID, [ + MOCK_TOKEN_ADDRESS, + ]); + + expect(result.size).toBe(0); + }); + + it('splits large address lists into batches of MAX_BATCH_SIZE', async () => { + const addresses = Array.from( + { length: MAX_BATCH_SIZE + 5 }, + (_, i) => `0x${String(i).padStart(40, '0')}`, + ); + + mockHandleFetch.mockResolvedValue([]); + + await fetchVerifiedTokensByAddresses(MOCK_CHAIN_ID, addresses); + + // Should call the API twice: one full batch + one partial batch + expect(mockHandleFetch).toHaveBeenCalledTimes(2); + }); + + it('deduplicates in-flight requests for identical batches', async () => { + const addresses = [MOCK_TOKEN_ADDRESS]; + + let resolveFirst!: (v: unknown) => void; + const firstCallPromise = new Promise((res) => { + resolveFirst = res; + }); + mockHandleFetch.mockReturnValueOnce(firstCallPromise as never); + + const [result1, result2] = await Promise.all([ + fetchVerifiedTokensByAddresses(MOCK_CHAIN_ID, addresses), + fetchVerifiedTokensByAddresses(MOCK_CHAIN_ID, addresses), + (async () => { + resolveFirst([makeAsset(MOCK_TOKEN_ADDRESS, MIN_OCCURRENCES)]); + })(), + ]); + + // Both callers should see the same result + expect(result1?.size).toBe(result2?.size); + // Only one HTTP request was made despite two callers + expect(mockHandleFetch).toHaveBeenCalledTimes(1); + }); + + it('returns an empty map when the API throws', async () => { + mockHandleFetch.mockRejectedValue(new Error('network error')); + + await expect( + fetchVerifiedTokensByAddresses(MOCK_CHAIN_ID, [MOCK_TOKEN_ADDRESS]), + ).rejects.toThrow('network error'); + }); + + it('lowercases asset addresses in the result map', async () => { + const mixedCaseAddress = '0xABCDEFabcdefABCDEFabcdefABCDEFabcdefABCD'; + const asset = makeAsset(mixedCaseAddress, MIN_OCCURRENCES); + mockHandleFetch.mockResolvedValue([asset]); + + const result = await fetchVerifiedTokensByAddresses(MOCK_CHAIN_ID, [ + mixedCaseAddress, + ]); + + expect(result.has(mixedCaseAddress.toLowerCase())).toBe(true); + }); + }); +}); From 5ebba430732a00ea7a93ee158b81d1e9362dfde5 Mon Sep 17 00:00:00 2001 From: juanmigdr Date: Wed, 22 Apr 2026 12:50:22 +0200 Subject: [PATCH 10/15] fix: formatting --- packages/assets-controllers/src/tokens-api-v3.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/assets-controllers/src/tokens-api-v3.test.ts b/packages/assets-controllers/src/tokens-api-v3.test.ts index 21c5cfd3a28..dfe96f801af 100644 --- a/packages/assets-controllers/src/tokens-api-v3.test.ts +++ b/packages/assets-controllers/src/tokens-api-v3.test.ts @@ -39,9 +39,7 @@ describe('tokens-api-v3', () => { describe('buildCaipAssetId', () => { it('builds a CAIP-19 asset ID from chainId and address', () => { - expect(buildCaipAssetId('0x1', '0xAbCd')).toBe( - 'eip155:1/erc20:0xabcd', - ); + expect(buildCaipAssetId('0x1', '0xAbCd')).toBe('eip155:1/erc20:0xabcd'); }); it('lowercases the token address', () => { @@ -92,7 +90,10 @@ describe('tokens-api-v3', () => { await fetchVerifiedTokensByAddresses(MOCK_CHAIN_ID, [MOCK_TOKEN_ADDRESS]); - const expectedAssetId = buildCaipAssetId(MOCK_CHAIN_ID, MOCK_TOKEN_ADDRESS); + const expectedAssetId = buildCaipAssetId( + MOCK_CHAIN_ID, + MOCK_TOKEN_ADDRESS, + ); const expectedParams = new URLSearchParams({ assetIds: expectedAssetId, includeOccurrences: 'true', From da09f0c2669b29a51b555d86979ea89db73365e8 Mon Sep 17 00:00:00 2001 From: juanmigdr Date: Wed, 22 Apr 2026 13:23:21 +0200 Subject: [PATCH 11/15] fix: issues --- .../src/TokenDetectionController.test.ts | 37 ++++++++++--------- .../src/TokensController.test.ts | 30 ++++++++++++--- .../src/tokens-api-v3.test.ts | 11 +++--- .../assets-controllers/src/tokens-api-v3.ts | 2 +- 4 files changed, 51 insertions(+), 29 deletions(-) diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index 2c92b4d8f5f..a135d5c9517 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -30,6 +30,21 @@ import type { Hex } from '@metamask/utils'; import BN from 'bn.js'; import nock from 'nock'; +import type { TokenDetectionControllerMessenger } from './TokenDetectionController'; +import { + TokenDetectionController, + controllerName, + mapChainIdWithTokenListMap, +} from './TokenDetectionController'; +import { getDefaultTokenListState } from './TokenListController'; +import type { TokenListState, TokenListToken } from './TokenListController'; +import type { Token } from './TokenRatesController'; +import type { + TokensController, + TokensControllerState, +} from './TokensController'; +import { getDefaultTokensState } from './TokensController'; + import { jestAdvanceTime } from '../../../tests/helpers'; import { createMockInternalAccount } from '../../accounts-controller/tests/mocks'; import { @@ -41,7 +56,6 @@ import { TOKEN_END_POINT_API, fetchAndBuildTokenListMap, } from './token-service'; -import type { TokenDetectionControllerMessenger } from './TokenDetectionController'; import { fetchVerifiedTokensByAddresses } from './tokens-api-v3'; import type { TokenV3Asset } from './tokens-api-v3'; @@ -64,19 +78,6 @@ const mockFetchVerifiedTokensByAddresses = fetchVerifiedTokensByAddresses as jest.MockedFunction< typeof fetchVerifiedTokensByAddresses >; -import { - TokenDetectionController, - controllerName, - mapChainIdWithTokenListMap, -} from './TokenDetectionController'; -import { getDefaultTokenListState } from './TokenListController'; -import type { TokenListState, TokenListToken } from './TokenListController'; -import type { Token } from './TokenRatesController'; -import type { - TokensController, - TokensControllerState, -} from './TokensController'; -import { getDefaultTokensState } from './TokensController'; const DEFAULT_INTERVAL = 180000; @@ -3723,19 +3724,19 @@ describe('TokenDetectionController', () => { triggerPreferencesStateChange, }) => { const defaultState = getDefaultNetworkControllerState(); - const mainnetNetworkConfig = { + const mainnetNetworkConfig: NetworkConfiguration = { chainId: ChainId.mainnet, name: 'Ethereum Mainnet', nativeCurrency: 'ETH', - blockExplorerUrls: [] as string[], + blockExplorerUrls: [], defaultBlockExplorerUrlIndex: 0, defaultRpcEndpointIndex: 0, rpcEndpoints: [ { - networkClientId: 'mainnet' as NetworkClientId, + networkClientId: 'mainnet', type: RpcEndpointType.Custom, url: 'https://mainnet.infura.io/v3/test', - failoverUrls: [] as string[], + failoverUrls: [], }, ], }; diff --git a/packages/assets-controllers/src/TokensController.test.ts b/packages/assets-controllers/src/TokensController.test.ts index b71d70e5da4..7b2251e24ee 100644 --- a/packages/assets-controllers/src/TokensController.test.ts +++ b/packages/assets-controllers/src/TokensController.test.ts @@ -3791,7 +3791,11 @@ describe('TokensController', () => { { options: { state: initialState } }, async ({ controller }) => { // Allow the async enrichment to complete - await new Promise(process.nextTick); + await new Promise((resolve) => { + process.nextTick(() => { + resolve(); + }); + }); expect( controller.state.allTokens[ChainId.mainnet]['0x1'][0].name, ).toBe('Token Name'); @@ -3837,7 +3841,11 @@ describe('TokensController', () => { await withController( { options: { state: initialState } }, async ({ controller }) => { - await new Promise(process.nextTick); + await new Promise((resolve) => { + process.nextTick(() => { + resolve(); + }); + }); expect( controller.state.allTokens[ChainId.mainnet]['0x1'][0].rwaData, ).toStrictEqual(rwaData); @@ -3882,7 +3890,11 @@ describe('TokensController', () => { { options: { state: initialState } }, async ({ controller }) => { const stateBefore = controller.state; - await new Promise(process.nextTick); + await new Promise((resolve) => { + process.nextTick(() => { + resolve(); + }); + }); // State should be the same reference since no changes were made expect(controller.state.allTokens).toStrictEqual( stateBefore.allTokens, @@ -3927,7 +3939,11 @@ describe('TokensController', () => { await withController( { options: { state: initialState } }, async ({ controller }) => { - await new Promise(process.nextTick); + await new Promise((resolve) => { + process.nextTick(() => { + resolve(); + }); + }); // First call should have fetched expect(mockFetchAndBuildTokenListMap).toHaveBeenCalledTimes(1); // Clear mock call count, then trigger enrichment again @@ -3939,7 +3955,11 @@ describe('TokensController', () => { decimals: 18, networkClientId: 'mainnet', }); - await new Promise(process.nextTick); + await new Promise((resolve) => { + process.nextTick(() => { + resolve(); + }); + }); // Second call should use cached list (not call API again) expect(mockFetchAndBuildTokenListMap).not.toHaveBeenCalled(); }, diff --git a/packages/assets-controllers/src/tokens-api-v3.test.ts b/packages/assets-controllers/src/tokens-api-v3.test.ts index dfe96f801af..771ae21eada 100644 --- a/packages/assets-controllers/src/tokens-api-v3.test.ts +++ b/packages/assets-controllers/src/tokens-api-v3.test.ts @@ -7,6 +7,7 @@ import { MIN_OCCURRENCES, TOKENS_API_V3_BASE_URL, } from './tokens-api-v3'; +import type { TokenV3Asset } from './tokens-api-v3'; jest.mock('@metamask/controller-utils', () => ({ ...jest.requireActual('@metamask/controller-utils'), @@ -22,7 +23,7 @@ const makeAsset = ( address: string, occurrences: number, chainId = MOCK_CHAIN_ID, -) => ({ +): TokenV3Asset => ({ assetId: buildCaipAssetId(chainId, address), decimals: 18, iconUrl: `https://example.com/${address}.png`, @@ -133,16 +134,16 @@ describe('tokens-api-v3', () => { it('deduplicates in-flight requests for identical batches', async () => { const addresses = [MOCK_TOKEN_ADDRESS]; - let resolveFirst!: (v: unknown) => void; - const firstCallPromise = new Promise((res) => { - resolveFirst = res; + let resolveFirst!: (value: TokenV3Asset[]) => void; + const firstCallPromise = new Promise((resolve) => { + resolveFirst = resolve; }); mockHandleFetch.mockReturnValueOnce(firstCallPromise as never); const [result1, result2] = await Promise.all([ fetchVerifiedTokensByAddresses(MOCK_CHAIN_ID, addresses), fetchVerifiedTokensByAddresses(MOCK_CHAIN_ID, addresses), - (async () => { + (async (): Promise => { resolveFirst([makeAsset(MOCK_TOKEN_ADDRESS, MIN_OCCURRENCES)]); })(), ]); diff --git a/packages/assets-controllers/src/tokens-api-v3.ts b/packages/assets-controllers/src/tokens-api-v3.ts index 0a1a3d42c9a..09cdcb9be52 100644 --- a/packages/assets-controllers/src/tokens-api-v3.ts +++ b/packages/assets-controllers/src/tokens-api-v3.ts @@ -55,7 +55,7 @@ async function fetchTokenBatch(assetIds: string[]): Promise { includeRwaData: 'true', }); - const promise = (async () => { + const promise = (async (): Promise => { try { const data = (await handleFetch( `${TOKENS_API_V3_BASE_URL}/assets?${params}`, From 2c0fce8cdaf84a7b1de25d9ad6f5576608574fc6 Mon Sep 17 00:00:00 2001 From: juanmigdr Date: Wed, 22 Apr 2026 13:24:56 +0200 Subject: [PATCH 12/15] chore: minor --- .../src/TokenDetectionController.test.ts | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index a135d5c9517..e1bf24f0e9c 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -30,6 +30,17 @@ import type { Hex } from '@metamask/utils'; import BN from 'bn.js'; import nock from 'nock'; +import { jestAdvanceTime } from '../../../tests/helpers'; +import { createMockInternalAccount } from '../../accounts-controller/tests/mocks'; +import { + buildCustomRpcEndpoint, + buildInfuraNetworkConfiguration, +} from '../../network-controller/tests/helpers'; +import { formatAggregatorNames } from './assetsUtil'; +import { + TOKEN_END_POINT_API, + fetchAndBuildTokenListMap, +} from './token-service'; import type { TokenDetectionControllerMessenger } from './TokenDetectionController'; import { TokenDetectionController, @@ -39,26 +50,14 @@ import { import { getDefaultTokenListState } from './TokenListController'; import type { TokenListState, TokenListToken } from './TokenListController'; import type { Token } from './TokenRatesController'; +import { fetchVerifiedTokensByAddresses } from './tokens-api-v3'; +import type { TokenV3Asset } from './tokens-api-v3'; import type { TokensController, TokensControllerState, } from './TokensController'; import { getDefaultTokensState } from './TokensController'; -import { jestAdvanceTime } from '../../../tests/helpers'; -import { createMockInternalAccount } from '../../accounts-controller/tests/mocks'; -import { - buildCustomRpcEndpoint, - buildInfuraNetworkConfiguration, -} from '../../network-controller/tests/helpers'; -import { formatAggregatorNames } from './assetsUtil'; -import { - TOKEN_END_POINT_API, - fetchAndBuildTokenListMap, -} from './token-service'; -import { fetchVerifiedTokensByAddresses } from './tokens-api-v3'; -import type { TokenV3Asset } from './tokens-api-v3'; - jest.mock('./token-service', () => ({ ...jest.requireActual('./token-service'), fetchAndBuildTokenListMap: jest.fn(), From e92d9f66e6222b2106ab07da8566a46980d86cb9 Mon Sep 17 00:00:00 2001 From: juanmigdr Date: Wed, 22 Apr 2026 14:18:34 +0200 Subject: [PATCH 13/15] chore: minor changes --- eslint-suppressions.json | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 97a623f347f..22f24480c65 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -429,14 +429,9 @@ "count": 4 } }, - "packages/assets-controllers/src/TokenDetectionController.test.ts": { - "no-restricted-syntax": { - "count": 2 - } - }, "packages/assets-controllers/src/TokenDetectionController.ts": { "no-restricted-syntax": { - "count": 6 + "count": 3 } }, "packages/assets-controllers/src/TokenListController.test.ts": { @@ -483,7 +478,7 @@ "count": 6 }, "no-restricted-syntax": { - "count": 4 + "count": 3 } }, "packages/assets-controllers/src/TokensController.ts": { @@ -494,7 +489,7 @@ "count": 1 }, "@typescript-eslint/prefer-optional-chain": { - "count": 4 + "count": 3 }, "id-length": { "count": 1 @@ -506,7 +501,7 @@ "count": 1 }, "no-restricted-syntax": { - "count": 2 + "count": 1 }, "require-atomic-updates": { "count": 1 @@ -2377,4 +2372,4 @@ "count": 10 } } -} +} \ No newline at end of file From 993973fb2a64af545c03516bfe86fe070bd640f6 Mon Sep 17 00:00:00 2001 From: juanmigdr Date: Wed, 22 Apr 2026 15:25:47 +0200 Subject: [PATCH 14/15] chore: minor --- eslint-suppressions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 22f24480c65..4235264f644 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2372,4 +2372,4 @@ "count": 10 } } -} \ No newline at end of file +} From a96ca4e568f61be2c79077ed3fbf8e38158b6357 Mon Sep 17 00:00:00 2001 From: juanmigdr Date: Wed, 22 Apr 2026 16:00:10 +0200 Subject: [PATCH 15/15] chore: brought back suppresions --- eslint-suppressions.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 4235264f644..9b4406358cd 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -429,9 +429,14 @@ "count": 4 } }, + "packages/assets-controllers/src/TokenDetectionController.test.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, "packages/assets-controllers/src/TokenDetectionController.ts": { "no-restricted-syntax": { - "count": 3 + "count": 4 } }, "packages/assets-controllers/src/TokenListController.test.ts": {