From 54378f1b6890d4b12c81ace78e45e797f56c3542 Mon Sep 17 00:00:00 2001 From: juanmigdr Date: Fri, 17 Apr 2026 12:24:45 +0200 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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. */