From e7c844fa4fc8a823191d7ac2de0ae5c0079e14a8 Mon Sep 17 00:00:00 2001 From: juanmigdr Date: Tue, 5 May 2026 16:16:50 +0200 Subject: [PATCH 01/14] chore: decouple controllers from tokens list --- packages/assets-controllers/package.json | 1 + .../src/TokenDetectionController.test.ts | 491 ++---------------- .../src/TokenDetectionController.ts | 88 +--- .../src/TokenListService.ts | 97 ++++ .../src/TokensController.test.ts | 196 ++++--- .../src/TokensController.ts | 76 ++- packages/assets-controllers/src/index.ts | 1 + yarn.lock | 8 + 8 files changed, 283 insertions(+), 675 deletions(-) create mode 100644 packages/assets-controllers/src/TokenListService.ts diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index ffd286abbe..aaefa85a52 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -87,6 +87,7 @@ "@metamask/storage-service": "^1.0.1", "@metamask/transaction-controller": "^65.1.0", "@metamask/utils": "^11.9.0", + "@tanstack/query-core": "^5.100.8", "@types/bn.js": "^5.1.5", "@types/uuid": "^8.3.0", "async-mutex": "^0.5.0", diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index d4b746114c..ebe65502aa 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -47,7 +47,8 @@ import { mapChainIdWithTokenListMap, } from './TokenDetectionController'; import { getDefaultTokenListState } from './TokenListController'; -import type { TokenListState, TokenListToken } from './TokenListController'; +import type { TokenListMap, TokenListState, TokenListToken } from './TokenListController'; +import type { TokenListService } from './TokenListService'; import type { Token } from './TokenRatesController'; import type { TokensController, @@ -207,7 +208,6 @@ function buildTokenDetectionControllerMessenger( 'NetworkController:getState', 'TokensController:getState', 'TokensController:addDetectedTokens', - 'TokenListController:getState', 'PreferencesController:getState', 'TokensController:addTokens', 'NetworkController:findNetworkClientIdByChainId', @@ -217,7 +217,6 @@ function buildTokenDetectionControllerMessenger( 'KeyringController:lock', 'KeyringController:unlock', 'NetworkController:networkDidChange', - 'TokenListController:stateChange', 'PreferencesController:stateChange', 'TransactionController:transactionConfirmed', ], @@ -1870,447 +1869,6 @@ describe('TokenDetectionController', () => { }); }); - 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, - tokensChainsCache: { - ...tokenListState.tokensChainsCache, - [ChainId['linea-mainnet']]: { - data: { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }, - timestamp: 5546454, - }, - }, - }); - await jestAdvanceTime({ duration: 1 }); - expect(mockTokens).toHaveBeenCalledTimes(1); - }, - ); - }); - }); - }); - describe('startPolling', () => { beforeEach(() => { jest.useFakeTimers(); @@ -4044,10 +3602,10 @@ describe('TokenDetectionController', () => { ); }); - it('should fetch fresh token metadata cache from TokenListController at call time', async () => { - // This test verifies the fix for the bug where addDetectedTokensViaPolling used - // a stale/empty tokensChainsCache from construction time instead of fetching - // fresh data from TokenListController:getState at call time. + it('should fetch fresh token metadata cache from TokenListService at call time', async () => { + // This test verifies that addDetectedTokensViaPolling fetches the token list + // from the TokenListService at call time (not at construction time), so that + // tokens added to the service after construction are still detected. const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; const checksummedTokenAddress = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; @@ -4252,7 +3810,6 @@ type WithControllerCallback = ({ callActionSpy, triggerKeyringUnlock, triggerKeyringLock, - triggerTokenListStateChange, triggerPreferencesStateChange, triggerSelectedAccountChange, triggerNetworkDidChange, @@ -4263,6 +3820,7 @@ type WithControllerCallback = ({ mockGetSelectedAccount: (address: string) => void; mockKeyringGetState: (state: KeyringControllerState) => void; mockTokensGetState: (state: TokensControllerState) => void; + /** Updates the mock TokenListService to return a specific token list state. */ mockTokenListGetState: (state: TokenListState) => void; mockPreferencesGetState: (state: PreferencesState) => void; mockGetNetworkClientById: ( @@ -4280,7 +3838,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; @@ -4394,14 +3951,23 @@ async function withController( ...mockTokensState, }), ); - const mockTokenListStateFunc = jest.fn(); - messenger.registerActionHandler( - 'TokenListController:getState', - mockTokenListStateFunc.mockReturnValue({ - ...getDefaultTokenListState(), - ...mockTokenListState, - }), - ); + + // Build the initial TokenListState and a mutable reference so tests can update it. + let currentTokenListState: TokenListState = { + ...getDefaultTokenListState(), + ...mockTokenListState, + }; + const mockFetchTokensByChainId = jest + .fn, [Hex]>() + .mockImplementation((chainId: Hex) => { + return Promise.resolve( + currentTokenListState.tokensChainsCache[chainId]?.data ?? {}, + ); + }); + const tokenListService = { + fetchTokensByChainId: mockFetchTokensByChainId, + } as unknown as TokenListService; + const mockPreferencesState = jest.fn(); messenger.registerActionHandler( 'PreferencesController:getState', @@ -4448,6 +4014,7 @@ async function withController( getBalancesInSingleCall: jest.fn(), trackMetaMetricsEvent: jest.fn(), messenger: tokenDetectionControllerMessenger, + tokenListService, ...options, }); try { @@ -4470,7 +4037,10 @@ async function withController( mockPreferencesState.mockReturnValue(state); }, mockTokenListGetState: (state: TokenListState) => { - mockTokenListStateFunc.mockReturnValue(state); + currentTokenListState = state; + mockFetchTokensByChainId.mockImplementation((chainId: Hex) => + Promise.resolve(state.tokensChainsCache[chainId]?.data ?? {}), + ); }, mockGetNetworkClientById: ( handler: ( @@ -4501,9 +4071,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 6ba8fb4d91..d2479bf097 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -39,7 +39,7 @@ 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 { @@ -54,12 +54,11 @@ import { } from './constants'; import type { TokenDetectionControllerMethodActions } from './TokenDetectionController-method-action-types'; import type { - GetTokenListState, TokenListMap, - TokenListStateChange, TokenListToken, TokensChainsCache, } from './TokenListController'; +import type { TokenListService } from './TokenListService'; import type { Token } from './TokenRatesController'; import type { TokensControllerGetStateAction } from './TokensController'; import type { @@ -142,7 +141,6 @@ export type AllowedActions = | NetworkControllerGetNetworkClientByIdAction | NetworkControllerGetNetworkConfigurationByNetworkClientId | NetworkControllerGetStateAction - | GetTokenListState | KeyringControllerGetStateAction | PreferencesControllerGetStateAction | TokensControllerGetStateAction @@ -160,7 +158,6 @@ export type TokenDetectionControllerEvents = export type AllowedEvents = | AccountsControllerSelectedEvmAccountChangeEvent | NetworkControllerNetworkDidChangeEvent - | TokenListStateChange | KeyringControllerLockEvent | KeyringControllerUnlockEvent | PreferencesControllerStateChangeEvent @@ -215,6 +212,8 @@ export class TokenDetectionController extends StaticIntervalPollingController true, useExternalServices = (): boolean => true, }: { @@ -275,6 +276,7 @@ export class TokenDetectionController extends StaticIntervalPollingController void; messenger: TokenDetectionControllerMessenger; + tokenListService: TokenListService; useTokenDetection?: () => boolean; useExternalServices?: () => boolean; }) { @@ -292,11 +294,7 @@ 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 }) => { @@ -461,30 +444,6 @@ export class TokenDetectionController extends StaticIntervalPollingController { if (!isTokenDetectionSupportedForNetwork(chainId)) { return false; } @@ -567,12 +526,11 @@ export class TokenDetectionController extends StaticIntervalPollingController { for (const { chainId, networkClientId } of chainsToDetectUsingRpc) { - if (!this.#shouldDetectTokens(chainId)) { + if (!(await this.#shouldDetectTokens(chainId))) { continue; } @@ -606,7 +564,7 @@ export class TokenDetectionController extends StaticIntervalPollingController { + const raw = await this.#queryClient.fetchQuery({ + queryKey: ['TokenListService:fetchTokensByChainId', chainId], + queryFn: () => + fetchTokenListByChainId( + chainId, + this.#abortController.signal, + ) as Promise, + staleTime: FOUR_HOURS_MS, + }); + + return buildTokenListMap(raw ?? [], chainId); + } + + /** + * Abort any in-flight requests and clear the query cache. + */ + destroy(): void { + this.#abortController.abort(); + this.#queryClient.clear(); + } +} + +/** + * Normalise a raw token list array (from the token API) into a `TokenListMap`. + * + * @param tokens - Raw array of token objects returned by the API. + * @param chainId - The chain the tokens belong to (used for icon URL proxy). + * @returns A record keyed by lowercased token address. + */ +export function buildTokenListMap( + tokens: TokenListToken[], + chainId: Hex, +): TokenListMap { + const tokenListMap: TokenListMap = {}; + for (const token of tokens) { + tokenListMap[token.address] = { + ...token, + aggregators: formatAggregatorNames(token.aggregators), + iconUrl: formatIconUrlWithProxy({ + chainId, + tokenAddress: token.address, + }), + }; + } + return tokenListMap; +} diff --git a/packages/assets-controllers/src/TokensController.test.ts b/packages/assets-controllers/src/TokensController.test.ts index d374840e5a..db3bf37c7a 100644 --- a/packages/assets-controllers/src/TokensController.test.ts +++ b/packages/assets-controllers/src/TokensController.test.ts @@ -3261,124 +3261,104 @@ describe('TokensController', () => { }); }); - describe('when TokenListController:stateChange is published', () => { + describe('on initialization, token list enrichment', () => { 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 }), - ); - await controller.addToken({ - address: '0x01', - symbol: 'bar', - decimals: 2, - networkClientId: 'mainnet', - }); - 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: undefined, - }); - - 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'], - }, + await withController( + { + options: { + state: { + allTokens: { + [ChainId.mainnet]: { + [defaultMockInternalAccount.address]: [ + { + address: '0x01', + decimals: 2, + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x01.png', + symbol: 'bar', + isERC721: false, + aggregators: [], + name: undefined, + }, + ], }, }, }, }, - [], - ); - - 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', - }); - }); + }, + async ({ controller }) => { + // The enrichment is async (fires in constructor); wait for it. + await new Promise((resolve) => setTimeout(resolve, 0)); + + // TokenListService returns the token list for mainnet with a name. + // withController stubs fetchTokensByChainId to return {} by default; + // for this test we rely on the fact that the name stays undefined + // because the service returned nothing — verifying the plumbing at + // a unit level would require a more detailed setup tested below. + expect( + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ][0].name, + ).toBeUndefined(); + }, + ); }); - 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('enriches name and rwaData from the token list service at init time', async () => { + const tokenAddress = '0x01'; - 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 withController( + { + options: { + state: { + allTokens: { + [ChainId.mainnet]: { + [defaultMockInternalAccount.address]: [ + { + address: tokenAddress, + decimals: 2, + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x01.png', + symbol: 'bar', + isERC721: false, + aggregators: [], + name: undefined, + rwaData: { ticker: 'OLD' } as TokenRwaData, + }, + ], }, }, }, + tokenListService: { + fetchTokensByChainId: jest.fn().mockResolvedValue({ + [tokenAddress]: { + address: tokenAddress, + 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' }, + }, + }), + } as unknown as import('./TokenListService').TokenListService, }, - [], - ); + }, + async ({ controller }) => { + // Enrichment is a fire-and-forget async call in the constructor. + await new Promise((resolve) => setTimeout(resolve, 0)); - expect( - controller.state.allTokens[ChainId.mainnet][ - defaultMockInternalAccount.address - ][0].rwaData, - ).toStrictEqual({ ticker: 'NEW' }); - }); + const token = + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ][0]; + + expect(token.name).toBe('BarName'); + expect(token.rwaData).toStrictEqual({ ticker: 'NEW' }); + }, + ); }); }); @@ -3934,7 +3914,6 @@ async function withController( 'NetworkController:networkDidChange', 'NetworkController:stateChange', 'AccountsController:selectedEvmAccountChange', - 'TokenListController:stateChange', 'KeyringController:accountRemoved', ], }); @@ -3961,6 +3940,10 @@ async function withController( mockListAccounts, ); + const tokenListService = { + fetchTokensByChainId: jest.fn().mockResolvedValue({}), + } as unknown as import('./TokenListService').TokenListService; + const controller = new TokensController({ chainId: ChainId.mainnet, // The tests assume that this is set, but they shouldn't make that @@ -3969,6 +3952,7 @@ async function withController( // not specified. provider: new FakeProvider(), messenger: tokensControllerMessenger, + tokenListService, ...options, }); diff --git a/packages/assets-controllers/src/TokensController.ts b/packages/assets-controllers/src/TokensController.ts index 5b2ae1e351..d8f4614113 100644 --- a/packages/assets-controllers/src/TokensController.ts +++ b/packages/assets-controllers/src/TokensController.ts @@ -53,10 +53,8 @@ import { TOKEN_METADATA_NO_SUPPORT_ERROR, TokenRwaData, } from './token-service'; -import type { - TokenListStateChange, - TokenListToken, -} from './TokenListController'; +import type { TokenListToken } from './TokenListController'; +import type { TokenListService } from './TokenListService'; import type { Token } from './TokenRatesController'; import type { TokensControllerMethodActions } from './TokensController-method-action-types'; @@ -161,7 +159,6 @@ export type TokensControllerEvents = TokensControllerStateChangeEvent; export type AllowedEvents = | NetworkControllerStateChangeEvent | NetworkControllerNetworkDidChangeEvent - | TokenListStateChange | AccountsControllerSelectedEvmAccountChangeEvent | KeyringControllerAccountRemovedEvent; @@ -217,16 +214,19 @@ export class TokensController extends BaseController< * @param options.provider - Network provider. * @param options.state - Initial state to set on this controller. * @param options.messenger - The messenger. + * @param options.tokenListService - Shared service for fetching token metadata per chain. */ constructor({ provider, state, messenger, + tokenListService, }: { chainId: Hex; provider: Provider; state?: Partial; messenger: TokensControllerMessenger; + tokenListService: TokenListService; }) { super({ name: controllerName, @@ -261,44 +261,38 @@ export class TokensController extends BaseController< (accountAddress: string) => this.#handleOnAccountRemoved(accountAddress), ); - this.messenger.subscribe( - 'TokenListController:stateChange', - ({ tokensChainsCache }) => { - 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 - } - } - } + // Enrich persisted tokens with name/rwaData from the token list once at init. + this.#enrichTokensFromTokenList(tokenListService).catch(() => { + // Tokens remain usable without metadata enrichment + }); + } + + async #enrichTokensFromTokenList( + tokenListService: TokenListService, + ): Promise { + const { allTokens } = this.state; + const selectedAddress = this.#getSelectedAddress(); + const chainIds = Object.keys(allTokens) as Hex[]; + if (chainIds.length === 0) { + return; + } + const updatedAllTokens = cloneDeep(allTokens); + for (const chainId of chainIds) { + const chainData = await tokenListService.fetchTokensByChainId(chainId); + const tokens = updatedAllTokens[chainId]?.[selectedAddress]; + if (tokens) { + for (const token of tokens) { + const cachedToken = chainData[token.address.toLowerCase()]; + if (cachedToken?.name && !token.name) { + token.name = cachedToken.name; + } + if (cachedToken?.rwaData) { + token.rwaData = cachedToken.rwaData; } } - - // Update the state with the modified tokens - this.update(() => { - return { - ...this.state, - allTokens: updatedAllTokens, - }; - }); - }, - ); + } + } + this.update(() => ({ ...this.state, allTokens: updatedAllTokens })); } #handleOnAccountRemoved(accountAddress: string) { diff --git a/packages/assets-controllers/src/index.ts b/packages/assets-controllers/src/index.ts index 8b51e77e4a..8bc2c17247 100644 --- a/packages/assets-controllers/src/index.ts +++ b/packages/assets-controllers/src/index.ts @@ -128,6 +128,7 @@ export type { TokenListControllerMessenger, } from './TokenListController'; export { TokenListController } from './TokenListController'; +export { TokenListService, buildTokenListMap } from './TokenListService'; export type { ContractExchangeRates, ContractMarketData, diff --git a/yarn.lock b/yarn.lock index 728f009f08..d514e99f7e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2859,6 +2859,7 @@ __metadata: "@metamask/storage-service": "npm:^1.0.1" "@metamask/transaction-controller": "npm:^65.1.0" "@metamask/utils": "npm:^11.9.0" + "@tanstack/query-core": "npm:^5.100.8" "@ts-bridge/cli": "npm:^0.6.4" "@types/bn.js": "npm:^5.1.5" "@types/jest": "npm:^29.5.14" @@ -6667,6 +6668,13 @@ __metadata: languageName: node linkType: hard +"@tanstack/query-core@npm:^5.100.8": + version: 5.100.8 + resolution: "@tanstack/query-core@npm:5.100.8" + checksum: 10/9df53836eda4269d2d4f99b18faba3f97dc2ae67a85b4a83a86c80e8de184e58bbbc19c3489d70f9ed58295457f316dec4ac1e5b03bcdba0f195410880683782 + languageName: node + linkType: hard + "@tanstack/query-core@npm:^5.62.16": version: 5.90.20 resolution: "@tanstack/query-core@npm:5.90.20" From 158939096c0e716559413cc72237f8427c7c0ad7 Mon Sep 17 00:00:00 2001 From: juanmigdr Date: Tue, 5 May 2026 16:34:19 +0200 Subject: [PATCH 02/14] chore: updated changelog --- packages/assets-controllers/CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 3d775a23ab..2349f1ca9e 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `TokenListService`, a shared service for fetching and caching the token list per chain ([#8700](https://github.com/MetaMask/core/pull/8700)) + - Wraps `@tanstack/query-core` to cache results in-memory for 4 hours per chain ID, matching `TokenListController`'s existing threshold. + - Multiple controllers sharing the same `TokenListService` instance share the same cache: only one HTTP request is made per chain per 4-hour window regardless of how many callers invoke `fetchTokensByChainId`. + - Exported from the package as `TokenListService` and `buildTokenListMap`. +- Add `@tanstack/query-core` as a direct dependency ([#8700](https://github.com/MetaMask/core/pull/8700)) - Expose missing public `AccountTrackerController` methods through its messenger ([#8693](https://github.com/MetaMask/core/pull/8693)) - The following actions are now available: - `AccountTrackerController:refresh` @@ -22,6 +27,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** `TokenDetectionController` now requires a `tokenListService: TokenListService` constructor option ([#8700](https://github.com/MetaMask/core/pull/8700)) + - Token list data is fetched directly from `TokenListService` instead of reading `TokenListController` state on each detection pass. + - `GetTokenListState` has been removed from `AllowedActions` and `TokenListStateChange` has been removed from `AllowedEvents` on `TokenDetectionControllerMessenger`. +- **BREAKING:** `TokensController` now requires a `tokenListService: TokenListService` constructor option ([#8700](https://github.com/MetaMask/core/pull/8700)) + - `TokenListStateChange` has been removed from `AllowedEvents` on `TokensControllerMessenger`. + - Token `name` and `rwaData` enrichment now happens once at controller initialization instead of reactively on every `TokenListController:stateChange` event. - Bump `@metamask/transaction-controller` from `^65.0.0` to `^65.1.0` ([#8691](https://github.com/MetaMask/core/pull/8691)) - Switch the default mUSD asset from upfront `allTokens` state seeding to detection-based discovery on Ethereum mainnet (`0x1`), Linea (`0xe708`), and Monad mainnet (`0x8f`) ([#8688](https://github.com/MetaMask/core/pull/8688)) - `TokenDetectionController` now merges mUSD into the in-memory token list cache for these chains so it is treated as a regular detection candidate, replacing the previous `start()`-time `TokensController:addTokens` call and the per-event re-seed runs. @@ -34,6 +45,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed +- `TokenDetectionController` no longer restarts token detection when `TokenListController` publishes a `stateChange` event ([#8700](https://github.com/MetaMask/core/pull/8700)) + - Detection is still triggered on wallet unlock, account change, network change, and preference changes; the extra restart that occurred whenever `TokenListController` refreshed its cache is gone. - Stop seeding mUSD directly into `TokensController` state and remove the related event subscriptions ([#8688](https://github.com/MetaMask/core/pull/8688)) - `TokensController` no longer subscribes to `KeyringController:unlock`, `AccountsController:accountAdded`, `AccountsController:selectedEvmAccountChange`, `NetworkController:networkAdded`, or `NetworkController:stateChange` for mUSD seeding purposes. - `TokensControllerMessenger` no longer requires `NetworkControllerNetworkAddedEvent`, `AccountsControllerAccountAddedEvent`, or `KeyringControllerUnlockEvent` as allowed events. From 4217d5e47d20aed7e400666f1a1e81e4e75f6031 Mon Sep 17 00:00:00 2001 From: juanmigdr Date: Tue, 5 May 2026 17:06:56 +0200 Subject: [PATCH 03/14] chore: fixed some issues --- .../src/TokenDetectionController.test.ts | 52 ---------- .../src/TokenDetectionController.ts | 95 +++++++++---------- .../src/TokenListService.ts | 5 + .../src/TokensController.ts | 34 +++++-- 4 files changed, 73 insertions(+), 113 deletions(-) diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index ebe65502aa..bcd6da4518 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -44,7 +44,6 @@ import type { TokenDetectionControllerMessenger } from './TokenDetectionControll import { TokenDetectionController, controllerName, - mapChainIdWithTokenListMap, } from './TokenDetectionController'; import { getDefaultTokenListState } from './TokenListController'; import type { TokenListMap, TokenListState, TokenListToken } from './TokenListController'; @@ -2799,57 +2798,6 @@ describe('TokenDetectionController', () => { }); }); - describe('mapChainIdWithTokenListMap', () => { - it('should return an empty object when given an empty input', () => { - const tokensChainsCache = {}; - const result = mapChainIdWithTokenListMap(tokensChainsCache); - expect(result).toStrictEqual({}); - }); - - it('should return the same structure when there is no "data" property in the object', () => { - const tokensChainsCache = { - chain1: { info: 'no data property' }, - }; - const result = mapChainIdWithTokenListMap(tokensChainsCache); - expect(result).toStrictEqual(tokensChainsCache); // Expect unchanged structure - }); - - it('should map "data" property if present in the object', () => { - const tokensChainsCache = { - chain1: { data: 'someData' }, - }; - const result = mapChainIdWithTokenListMap(tokensChainsCache); - expect(result).toStrictEqual({ chain1: 'someData' }); - }); - - it('should handle multiple chains with mixed "data" properties', () => { - const tokensChainsCache = { - chain1: { data: 'someData1' }, - chain2: { info: 'no data property' }, - chain3: { data: 'someData3' }, - }; - const result = mapChainIdWithTokenListMap(tokensChainsCache); - - expect(result).toStrictEqual({ - chain1: 'someData1', - chain2: { info: 'no data property' }, - chain3: 'someData3', - }); - }); - - it('should handle nested object with "data" property correctly', () => { - const tokensChainsCache = { - chain1: { - data: { - nested: 'nestedData', - }, - }, - }; - const result = mapChainIdWithTokenListMap(tokensChainsCache); - expect(result).toStrictEqual({ chain1: { nested: 'nestedData' } }); - }); - }); - describe('constructor options', () => { describe('useTokenDetection', () => { it('should disable token detection when useTokenDetection is false', async () => { diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index d2479bf097..ef4db0d5c5 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -39,7 +39,6 @@ import type { import type { AuthenticationController } from '@metamask/profile-sync-controller'; import type { TransactionControllerTransactionConfirmedEvent } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; -import { mapValues, isObject, get } from 'lodash'; import type { AssetsContractController } from './AssetsContractController'; import { @@ -105,23 +104,6 @@ const MUSD_TOKEN_DETECTION_CHAIN_ID_SET = new Set( MUSD_TOKEN_DETECTION_CHAIN_IDS, ); -/** - * Function that takes a TokensChainsCache object and maps chainId with TokenListMap. - * - * @param tokensChainsCache - TokensChainsCache input object - * @returns returns the map of chainId with TokenListMap - */ -export function mapChainIdWithTokenListMap( - tokensChainsCache: TokensChainsCache, -): Record { - return mapValues(tokensChainsCache, (value) => { - if (isObject(value) && 'data' in value) { - return get(value, ['data']); - } - return value; - }); -} - export const controllerName = 'TokenDetectionController'; export type TokenDetectionState = Record; @@ -210,8 +192,6 @@ export class TokenDetectionController extends StaticIntervalPollingController { + /** + * Returns the token cache for `chainId` if detection should proceed, or `null` if it + * should be skipped. Each call fetches a fresh snapshot from `TokenListService` (which + * may serve from its in-memory cache) so concurrent calls for different chains never + * overwrite each other's data. + * + * @param chainId - The chain ID to build a detection cache for. + * @returns A `TokensChainsCache` scoped to `chainId`, or `null` when detection should be skipped. + */ + async #getChainCacheForDetection( + chainId: Hex, + ): Promise { if (!isTokenDetectionSupportedForNetwork(chainId)) { - return false; + return null; } if ( !this.#isDetectionEnabledFromPreferences && chainId !== ChainId.mainnet ) { - return false; + return null; } const isMainnetDetectionInactive = !this.#isDetectionEnabledFromPreferences && chainId === ChainId.mainnet; if (isMainnetDetectionInactive) { - this.#tokensChainsCache = this.#getConvertedStaticMainnetTokenList(); - } else { - const tokenListMap = - await this.#tokenListService.fetchTokensByChainId(chainId); - this.#tokensChainsCache = this.#applyMusdDefaultToTokensChainsCache( - chainId, - { [chainId]: { data: tokenListMap, timestamp: Date.now() } }, - ); + return this.#getConvertedStaticMainnetTokenList(); } - return true; + const tokenListMap = + await this.#tokenListService.fetchTokensByChainId(chainId); + return this.#applyMusdDefaultToTokensChainsCache(chainId, { + [chainId]: { data: tokenListMap, timestamp: Date.now() }, + }); } async #detectTokensUsingRpc( @@ -542,12 +530,14 @@ export class TokenDetectionController extends StaticIntervalPollingController { for (const { chainId, networkClientId } of chainsToDetectUsingRpc) { - if (!(await this.#shouldDetectTokens(chainId))) { + const chainCache = await this.#getChainCacheForDetection(chainId); + if (!chainCache) { continue; } const tokenCandidateSlices = this.#getSlicesOfTokensToDetect({ chainId, + chainCache, selectedAddress: addressToDetect, }); const tokenDetectionPromises = tokenCandidateSlices.map((tokensSlice) => @@ -556,6 +546,7 @@ export class TokenDetectionController extends StaticIntervalPollingController { await safelyExecute(async () => { const balances = await this.#getBalancesInSingleCall( @@ -795,11 +790,16 @@ export class TokenDetectionController extends StaticIntervalPollingController + const musdListToken = Object.entries(chainData).find(([key]) => isEqualCaseInsensitive(key, MUSD_ERC20_ADDRESS_LOWER), )?.[1]; if (musdListToken) { @@ -897,14 +896,11 @@ export class TokenDetectionController extends StaticIntervalPollingController, staleTime: FOUR_HOURS_MS, + gcTime: FOUR_HOURS_MS, }); return buildTokenListMap(raw ?? [], chainId); diff --git a/packages/assets-controllers/src/TokensController.ts b/packages/assets-controllers/src/TokensController.ts index d8f4614113..b45dbfc595 100644 --- a/packages/assets-controllers/src/TokensController.ts +++ b/packages/assets-controllers/src/TokensController.ts @@ -270,17 +270,32 @@ export class TokensController extends BaseController< async #enrichTokensFromTokenList( tokenListService: TokenListService, ): Promise { - const { allTokens } = this.state; - const selectedAddress = this.#getSelectedAddress(); - const chainIds = Object.keys(allTokens) as Hex[]; + const chainIds = Object.keys(this.state.allTokens) as Hex[]; if (chainIds.length === 0) { return; } - const updatedAllTokens = cloneDeep(allTokens); - for (const chainId of chainIds) { - const chainData = await tokenListService.fetchTokensByChainId(chainId); - const tokens = updatedAllTokens[chainId]?.[selectedAddress]; - if (tokens) { + + // Fetch all chain data concurrently before touching state so the async gap + // is as short as possible and we never hold a stale T0 snapshot while + // awaiting individual chain requests. + const chainDataEntries = await Promise.all( + chainIds.map(async (chainId) => { + const data = await tokenListService.fetchTokensByChainId(chainId); + return [chainId, data] as const; + }), + ); + const chainDataMap = Object.fromEntries(chainDataEntries); + + // Read the live state inside the updater so we only patch metadata fields + // and never discard tokens added or modified during the async gap above. + const selectedAddress = this.#getSelectedAddress(); + this.update((state) => { + for (const chainId of chainIds) { + const chainData = chainDataMap[chainId]; + const tokens = state.allTokens[chainId]?.[selectedAddress]; + if (!tokens || !chainData) { + continue; + } for (const token of tokens) { const cachedToken = chainData[token.address.toLowerCase()]; if (cachedToken?.name && !token.name) { @@ -291,8 +306,7 @@ export class TokensController extends BaseController< } } } - } - this.update(() => ({ ...this.state, allTokens: updatedAllTokens })); + }); } #handleOnAccountRemoved(accountAddress: string) { From a7138c92df976147b8dd8873d31b2ac7fb6aeaa5 Mon Sep 17 00:00:00 2001 From: juanmigdr Date: Tue, 5 May 2026 17:12:01 +0200 Subject: [PATCH 04/14] chore: fix linting --- .../src/TokenDetectionController.test.ts | 6 +++++- .../src/TokenDetectionController.ts | 10 +++------- packages/assets-controllers/src/TokenListService.ts | 12 +++--------- 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index bcd6da4518..9478382da3 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -46,7 +46,11 @@ import { controllerName, } from './TokenDetectionController'; import { getDefaultTokenListState } from './TokenListController'; -import type { TokenListMap, TokenListState, TokenListToken } from './TokenListController'; +import type { + TokenListMap, + TokenListState, + TokenListToken, +} from './TokenListController'; import type { TokenListService } from './TokenListService'; import type { Token } from './TokenRatesController'; import type { diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index ef4db0d5c5..2c1b337f30 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -629,9 +629,7 @@ export class TokenDetectionController extends StaticIntervalPollingController Date: Tue, 5 May 2026 17:34:16 +0200 Subject: [PATCH 05/14] chore: updated tanstack --- packages/assets-controllers/CHANGELOG.md | 2 +- packages/assets-controllers/package.json | 2 +- yarn.lock | 9 +-------- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 2349f1ca9e..2ee6e1e503 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Wraps `@tanstack/query-core` to cache results in-memory for 4 hours per chain ID, matching `TokenListController`'s existing threshold. - Multiple controllers sharing the same `TokenListService` instance share the same cache: only one HTTP request is made per chain per 4-hour window regardless of how many callers invoke `fetchTokensByChainId`. - Exported from the package as `TokenListService` and `buildTokenListMap`. -- Add `@tanstack/query-core` as a direct dependency ([#8700](https://github.com/MetaMask/core/pull/8700)) +- Add `@tanstack/query-core` `^5.62.16` as a direct dependency ([#8700](https://github.com/MetaMask/core/pull/8700)) - Expose missing public `AccountTrackerController` methods through its messenger ([#8693](https://github.com/MetaMask/core/pull/8693)) - The following actions are now available: - `AccountTrackerController:refresh` diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index aaefa85a52..591ebf5056 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -87,7 +87,7 @@ "@metamask/storage-service": "^1.0.1", "@metamask/transaction-controller": "^65.1.0", "@metamask/utils": "^11.9.0", - "@tanstack/query-core": "^5.100.8", + "@tanstack/query-core": "^5.62.16", "@types/bn.js": "^5.1.5", "@types/uuid": "^8.3.0", "async-mutex": "^0.5.0", diff --git a/yarn.lock b/yarn.lock index d514e99f7e..ffa945037f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2859,7 +2859,7 @@ __metadata: "@metamask/storage-service": "npm:^1.0.1" "@metamask/transaction-controller": "npm:^65.1.0" "@metamask/utils": "npm:^11.9.0" - "@tanstack/query-core": "npm:^5.100.8" + "@tanstack/query-core": "npm:^5.62.16" "@ts-bridge/cli": "npm:^0.6.4" "@types/bn.js": "npm:^5.1.5" "@types/jest": "npm:^29.5.14" @@ -6668,13 +6668,6 @@ __metadata: languageName: node linkType: hard -"@tanstack/query-core@npm:^5.100.8": - version: 5.100.8 - resolution: "@tanstack/query-core@npm:5.100.8" - checksum: 10/9df53836eda4269d2d4f99b18faba3f97dc2ae67a85b4a83a86c80e8de184e58bbbc19c3489d70f9ed58295457f316dec4ac1e5b03bcdba0f195410880683782 - languageName: node - linkType: hard - "@tanstack/query-core@npm:^5.62.16": version: 5.90.20 resolution: "@tanstack/query-core@npm:5.90.20" From f7eae558c40678d8bc0b5aec2d924e31a7d00001 Mon Sep 17 00:00:00 2001 From: juanmigdr Date: Tue, 5 May 2026 17:47:54 +0200 Subject: [PATCH 06/14] chore: fix some issues --- .../src/TokenDetectionController-method-action-types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/assets-controllers/src/TokenDetectionController-method-action-types.ts b/packages/assets-controllers/src/TokenDetectionController-method-action-types.ts index 1ec590fcea..80ac096584 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 provided by the TokenListService, 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. From 2f8219b6e9e782d5e7eefa010ce28835d6e320ae Mon Sep 17 00:00:00 2001 From: juanmigdr Date: Tue, 5 May 2026 18:07:40 +0200 Subject: [PATCH 07/14] chore: added more tests --- .../src/TokenDetectionController.test.ts | 328 ++++++++++++++++++ .../src/TokenListService.test.ts | 107 ++++++ .../src/TokenListService.ts | 12 +- 3 files changed, 442 insertions(+), 5 deletions(-) create mode 100644 packages/assets-controllers/src/TokenListService.test.ts diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index 9478382da3..8ae1461f0a 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -3279,6 +3279,157 @@ describe('TokenDetectionController', () => { }, ); }); + + it('should not add tokens when useTokenDetection is false', async () => { + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const chainId = '0xa86a'; + + await withController( + { + options: { + disabled: false, + useTokenDetection: () => false, + }, + mockTokenListState: { + tokensChainsCache: { + [chainId]: { + timestamp: 0, + data: { + [mockTokenAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: mockTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ controller, callActionSpy }) => { + await controller.addDetectedTokensViaWs({ + tokensSlice: [mockTokenAddress], + chainId: chainId as Hex, + }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.anything(), + expect.anything(), + ); + }, + ); + }); + + it('should not add tokens when useExternalServices is false', async () => { + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const chainId = '0xa86a'; + + await withController( + { + options: { + disabled: false, + useExternalServices: () => false, + }, + mockTokenListState: { + tokensChainsCache: { + [chainId]: { + timestamp: 0, + data: { + [mockTokenAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: mockTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ controller, callActionSpy }) => { + await controller.addDetectedTokensViaWs({ + tokensSlice: [mockTokenAddress], + chainId: chainId as Hex, + }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.anything(), + expect.anything(), + ); + }, + ); + }); + + it('does not append a duplicate mUSD entry when the slice already includes mUSD', async () => { + const usdcAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const usdcChecksummed = + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + + await withController( + { + options: { disabled: false }, + mockTokenListState: { + tokensChainsCache: { + [ChainId.mainnet]: { + timestamp: 0, + data: { + [usdcAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: usdcAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ + controller, + callActionSpy, + mockFindNetworkClientIdByChainId, + }) => { + mockFindNetworkClientIdByChainId(() => 'mainnet'); + await controller.addDetectedTokensViaWs({ + tokensSlice: [ + toChecksumHexAddress(MUSD_ERC20_ADDRESS_LOWER), + usdcAddress, + ], + chainId: ChainId.mainnet, + }); + + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.arrayContaining([ + expect.objectContaining({ + address: toChecksumHexAddress(MUSD_ERC20_ADDRESS_LOWER), + }), + expect.objectContaining({ address: usdcChecksummed }), + ]), + 'mainnet', + ); + const addTokensCall = callActionSpy.mock.calls.find( + (call) => call[0] === 'TokensController:addTokens', + ); + const payload = addTokensCall?.[1] as { address: string }[]; + const musdRows = payload.filter((t) => + t.address.toLowerCase() === MUSD_ERC20_ADDRESS_LOWER, + ); + expect(musdRows).toHaveLength(1); + }, + ); + }); }); describe('addDetectedTokensViaPolling', () => { @@ -3732,6 +3883,183 @@ describe('TokenDetectionController', () => { }, ); }); + + it('should skip if useExternalServices is disabled', async () => { + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const chainId = '0xa86a'; + + await withController( + { + options: { + disabled: false, + useTokenDetection: () => true, + useExternalServices: () => false, + }, + mockTokenListState: { + tokensChainsCache: { + [chainId]: { + timestamp: 0, + data: { + [mockTokenAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: mockTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ controller, callActionSpy }) => { + await controller.addDetectedTokensViaPolling({ + tokensSlice: [mockTokenAddress], + chainId: chainId as Hex, + }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.anything(), + expect.anything(), + ); + }, + ); + }); + + it('should ignore slice addresses that are not in the token list map', async () => { + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const checksummedTokenAddress = + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const unknownAddress = '0x0000000000000000000000000000000000000002'; + const chainId = '0xa86a'; + + await withController( + { + options: { + disabled: false, + useTokenDetection: () => true, + }, + mockTokenListState: { + tokensChainsCache: { + [chainId]: { + timestamp: 0, + data: { + [mockTokenAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: mockTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ controller, callActionSpy }) => { + await controller.addDetectedTokensViaPolling({ + tokensSlice: [mockTokenAddress, unknownAddress], + chainId: chainId as Hex, + }); + + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + [ + { + address: checksummedTokenAddress, + decimals: 6, + symbol: 'USDC', + aggregators: [], + image: 'https://example.com/usdc.png', + isERC721: false, + name: 'USD Coin', + }, + ], + 'avalanche', + ); + }, + ); + }); + + it('does not append a duplicate mUSD entry when the slice already includes mUSD', async () => { + const usdcAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const usdcChecksummed = + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + + await withController( + { + options: { disabled: false, useTokenDetection: () => true }, + mocks: { + getAccount: selectedAccount, + getSelectedAccount: selectedAccount, + }, + mockTokensState: { + allTokens: {}, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }, + mockTokenListState: { + tokensChainsCache: { + [ChainId.mainnet]: { + timestamp: 0, + data: { + [usdcAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: usdcAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ + controller, + callActionSpy, + mockFindNetworkClientIdByChainId, + }) => { + mockFindNetworkClientIdByChainId(() => 'mainnet'); + await controller.addDetectedTokensViaPolling({ + tokensSlice: [ + toChecksumHexAddress(MUSD_ERC20_ADDRESS_LOWER), + usdcAddress, + ], + chainId: ChainId.mainnet, + }); + + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.arrayContaining([ + expect.objectContaining({ + address: toChecksumHexAddress(MUSD_ERC20_ADDRESS_LOWER), + }), + expect.objectContaining({ address: usdcChecksummed }), + ]), + 'mainnet', + ); + const addTokensCall = callActionSpy.mock.calls.find( + (call) => call[0] === 'TokensController:addTokens', + ); + const payload = addTokensCall?.[1] as { address: string }[]; + const musdRows = payload.filter((t) => + t.address.toLowerCase() === MUSD_ERC20_ADDRESS_LOWER, + ); + expect(musdRows).toHaveLength(1); + }, + ); + }); }); }); diff --git a/packages/assets-controllers/src/TokenListService.test.ts b/packages/assets-controllers/src/TokenListService.test.ts new file mode 100644 index 0000000000..927811a01f --- /dev/null +++ b/packages/assets-controllers/src/TokenListService.test.ts @@ -0,0 +1,107 @@ +import type { Hex } from '@metamask/utils'; + +import type { TokenListToken } from './TokenListController'; +import { buildTokenListMap, TokenListService } from './TokenListService'; +import { fetchTokenListByChainId } from './token-service'; + +jest.mock('./token-service', () => ({ + fetchTokenListByChainId: jest.fn(), +})); + +const mockedFetchTokenListByChainId = jest.mocked(fetchTokenListByChainId); + +describe('buildTokenListMap', () => { + it('maps tokens by address and applies aggregator and icon formatting', () => { + const chainId = '0x1' as Hex; + const tokens: TokenListToken[] = [ + { + name: 'Sample', + symbol: 'SMP', + decimals: 18, + address: '0xabc0000000000000000000000000000000000001', + occurrences: 3, + aggregators: ['bancor', 'cmc'], + iconUrl: 'https://example.com/icon.png', + }, + ]; + + const map = buildTokenListMap(tokens, chainId); + + expect(Object.keys(map)).toStrictEqual([ + '0xabc0000000000000000000000000000000000001', + ]); + expect(map['0xabc0000000000000000000000000000000000001']).toMatchObject({ + name: 'Sample', + symbol: 'SMP', + decimals: 18, + address: '0xabc0000000000000000000000000000000000001', + aggregators: ['Bancor', 'CMC'], + }); + expect( + map['0xabc0000000000000000000000000000000000001'].iconUrl, + ).toContain('https://static.cx.metamask.io'); + }); + + it('returns an empty map when the token array is empty', () => { + expect(buildTokenListMap([], '0x1' as Hex)).toStrictEqual({}); + }); +}); + +describe('TokenListService', () => { + beforeEach(() => { + mockedFetchTokenListByChainId.mockReset(); + }); + + it('fetches via token-service and caches results for the same chain', async () => { + const chainId = '0xa86a' as Hex; + const apiToken = { + name: 'Avalanche Token', + symbol: 'AVT', + decimals: 18, + address: '0x1000000000000000000000000000000000000001', + occurrences: 5, + aggregators: [] as string[], + iconUrl: '', + }; + mockedFetchTokenListByChainId.mockResolvedValue([apiToken]); + + const service = new TokenListService(); + const first = await service.fetchTokensByChainId(chainId); + const second = await service.fetchTokensByChainId(chainId); + + expect(mockedFetchTokenListByChainId).toHaveBeenCalledTimes(1); + expect(first).toStrictEqual(second); + expect(first[apiToken.address]).toMatchObject({ + symbol: 'AVT', + name: 'Avalanche Token', + }); + + service.destroy(); + }); + + it('treats an undefined API response as an empty list', async () => { + mockedFetchTokenListByChainId.mockResolvedValue(undefined); + + const service = new TokenListService(); + await expect(service.fetchTokensByChainId('0x1' as Hex)).resolves.toStrictEqual( + {}, + ); + + service.destroy(); + }); + + it('clearing the cache via destroy causes the next fetch to hit the network again', async () => { + const chainId = '0x1' as Hex; + mockedFetchTokenListByChainId.mockResolvedValue([]); + + const service = new TokenListService(); + await service.fetchTokensByChainId(chainId); + await service.fetchTokensByChainId(chainId); + expect(mockedFetchTokenListByChainId).toHaveBeenCalledTimes(1); + + service.destroy(); + + await service.fetchTokensByChainId(chainId); + expect(mockedFetchTokenListByChainId).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/assets-controllers/src/TokenListService.ts b/packages/assets-controllers/src/TokenListService.ts index 197a2813a0..660cf4aa63 100644 --- a/packages/assets-controllers/src/TokenListService.ts +++ b/packages/assets-controllers/src/TokenListService.ts @@ -47,18 +47,20 @@ export class TokenListService { * @returns A map of lowercase token address → token metadata. */ async fetchTokensByChainId(chainId: Hex): Promise { - const raw = await this.#queryClient.fetchQuery({ + const tokens = await this.#queryClient.fetchQuery({ queryKey: ['TokenListService:fetchTokensByChainId', chainId], - queryFn: () => - fetchTokenListByChainId( + queryFn: async () => { + const list = (await fetchTokenListByChainId( chainId, this.#abortController.signal, - ) as Promise, + )) as TokenListToken[] | undefined; + return list ?? []; + }, staleTime: FOUR_HOURS_MS, gcTime: FOUR_HOURS_MS, }); - return buildTokenListMap(raw ?? [], chainId); + return buildTokenListMap(tokens, chainId); } /** From 53b30041641c167e6f44c8545d7682dd036242be Mon Sep 17 00:00:00 2001 From: juanmigdr Date: Tue, 5 May 2026 18:13:14 +0200 Subject: [PATCH 08/14] chore: misc --- .../src/TokenDetectionController.test.ts | 14 ++++++-------- .../src/TokenListService.test.ts | 14 +++++++------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index 8ae1461f0a..1fc5f7256e 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -3370,8 +3370,7 @@ describe('TokenDetectionController', () => { it('does not append a duplicate mUSD entry when the slice already includes mUSD', async () => { const usdcAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; - const usdcChecksummed = - '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const usdcChecksummed = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; await withController( { @@ -3423,8 +3422,8 @@ describe('TokenDetectionController', () => { (call) => call[0] === 'TokensController:addTokens', ); const payload = addTokensCall?.[1] as { address: string }[]; - const musdRows = payload.filter((t) => - t.address.toLowerCase() === MUSD_ERC20_ADDRESS_LOWER, + const musdRows = payload.filter( + (t) => t.address.toLowerCase() === MUSD_ERC20_ADDRESS_LOWER, ); expect(musdRows).toHaveLength(1); }, @@ -3988,8 +3987,7 @@ describe('TokenDetectionController', () => { it('does not append a duplicate mUSD entry when the slice already includes mUSD', async () => { const usdcAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; - const usdcChecksummed = - '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const usdcChecksummed = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; const selectedAccount = createMockInternalAccount({ address: '0x0000000000000000000000000000000000000001', }); @@ -4053,8 +4051,8 @@ describe('TokenDetectionController', () => { (call) => call[0] === 'TokensController:addTokens', ); const payload = addTokensCall?.[1] as { address: string }[]; - const musdRows = payload.filter((t) => - t.address.toLowerCase() === MUSD_ERC20_ADDRESS_LOWER, + const musdRows = payload.filter( + (t) => t.address.toLowerCase() === MUSD_ERC20_ADDRESS_LOWER, ); expect(musdRows).toHaveLength(1); }, diff --git a/packages/assets-controllers/src/TokenListService.test.ts b/packages/assets-controllers/src/TokenListService.test.ts index 927811a01f..d80284ff9a 100644 --- a/packages/assets-controllers/src/TokenListService.test.ts +++ b/packages/assets-controllers/src/TokenListService.test.ts @@ -1,8 +1,8 @@ import type { Hex } from '@metamask/utils'; +import { fetchTokenListByChainId } from './token-service'; import type { TokenListToken } from './TokenListController'; import { buildTokenListMap, TokenListService } from './TokenListService'; -import { fetchTokenListByChainId } from './token-service'; jest.mock('./token-service', () => ({ fetchTokenListByChainId: jest.fn(), @@ -37,9 +37,9 @@ describe('buildTokenListMap', () => { address: '0xabc0000000000000000000000000000000000001', aggregators: ['Bancor', 'CMC'], }); - expect( - map['0xabc0000000000000000000000000000000000001'].iconUrl, - ).toContain('https://static.cx.metamask.io'); + expect(map['0xabc0000000000000000000000000000000000001'].iconUrl).toContain( + 'https://static.cx.metamask.io', + ); }); it('returns an empty map when the token array is empty', () => { @@ -83,9 +83,9 @@ describe('TokenListService', () => { mockedFetchTokenListByChainId.mockResolvedValue(undefined); const service = new TokenListService(); - await expect(service.fetchTokensByChainId('0x1' as Hex)).resolves.toStrictEqual( - {}, - ); + await expect( + service.fetchTokensByChainId('0x1' as Hex), + ).resolves.toStrictEqual({}); service.destroy(); }); From d32024a5a3fd16933556b7753e1670828a801d41 Mon Sep 17 00:00:00 2001 From: juanmigdr Date: Tue, 5 May 2026 18:22:22 +0200 Subject: [PATCH 09/14] chore: minor adjustments --- .../assets-controllers/src/TokenDetectionController.test.ts | 6 ++++-- packages/assets-controllers/src/TokenListService.test.ts | 4 +--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index 1fc5f7256e..be773674f9 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -3423,7 +3423,8 @@ describe('TokenDetectionController', () => { ); const payload = addTokensCall?.[1] as { address: string }[]; const musdRows = payload.filter( - (t) => t.address.toLowerCase() === MUSD_ERC20_ADDRESS_LOWER, + (tokenRow) => + tokenRow.address.toLowerCase() === MUSD_ERC20_ADDRESS_LOWER, ); expect(musdRows).toHaveLength(1); }, @@ -4052,7 +4053,8 @@ describe('TokenDetectionController', () => { ); const payload = addTokensCall?.[1] as { address: string }[]; const musdRows = payload.filter( - (t) => t.address.toLowerCase() === MUSD_ERC20_ADDRESS_LOWER, + (tokenRow) => + tokenRow.address.toLowerCase() === MUSD_ERC20_ADDRESS_LOWER, ); expect(musdRows).toHaveLength(1); }, diff --git a/packages/assets-controllers/src/TokenListService.test.ts b/packages/assets-controllers/src/TokenListService.test.ts index d80284ff9a..fb3e9a58e4 100644 --- a/packages/assets-controllers/src/TokenListService.test.ts +++ b/packages/assets-controllers/src/TokenListService.test.ts @@ -83,9 +83,7 @@ describe('TokenListService', () => { mockedFetchTokenListByChainId.mockResolvedValue(undefined); const service = new TokenListService(); - await expect( - service.fetchTokensByChainId('0x1' as Hex), - ).resolves.toStrictEqual({}); + expect(await service.fetchTokensByChainId('0x1' as Hex)).toStrictEqual({}); service.destroy(); }); From f411013d59273d9468ec1c0a298ab02efbd10d26 Mon Sep 17 00:00:00 2001 From: juanmigdr Date: Tue, 5 May 2026 18:32:33 +0200 Subject: [PATCH 10/14] chore: updated suppressions --- eslint-suppressions.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 4201574ddf..424a178f32 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -423,12 +423,12 @@ }, "packages/assets-controllers/src/TokenDetectionController.test.ts": { "no-restricted-syntax": { - "count": 2 + "count": 1 } }, "packages/assets-controllers/src/TokenDetectionController.ts": { "no-restricted-syntax": { - "count": 6 + "count": 3 } }, "packages/assets-controllers/src/TokenListController.test.ts": { @@ -475,7 +475,7 @@ "count": 6 }, "no-restricted-syntax": { - "count": 4 + "count": 3 } }, "packages/assets-controllers/src/TokensController.ts": { @@ -486,7 +486,7 @@ "count": 1 }, "@typescript-eslint/prefer-optional-chain": { - "count": 4 + "count": 3 }, "id-length": { "count": 1 @@ -498,7 +498,7 @@ "count": 1 }, "no-restricted-syntax": { - "count": 2 + "count": 1 }, "require-atomic-updates": { "count": 1 @@ -2351,4 +2351,4 @@ "count": 10 } } -} +} \ No newline at end of file From c2e84b121bc8e0946260337842cfb4370d32f7df Mon Sep 17 00:00:00 2001 From: juanmigdr Date: Tue, 5 May 2026 18:46:52 +0200 Subject: [PATCH 11/14] chore: minor --- eslint-suppressions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 424a178f32..1e3f44bab3 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2351,4 +2351,4 @@ "count": 10 } } -} \ No newline at end of file +} From d483965502b5c4a6913d814206682c3c8908c7ad Mon Sep 17 00:00:00 2001 From: juanmigdr Date: Tue, 5 May 2026 19:38:38 +0200 Subject: [PATCH 12/14] chore: solved issues --- .../src/TokenDetectionController.test.ts | 25 ++++++- .../src/TokenDetectionController.ts | 26 +++++-- .../src/TokenListService.test.ts | 74 ++++++++++++++++++- .../src/TokenListService.ts | 20 ++--- .../src/TokensController.ts | 7 +- 5 files changed, 127 insertions(+), 25 deletions(-) diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index be773674f9..33b19d453e 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -4240,8 +4240,16 @@ async function withController( const mockFetchTokensByChainId = jest .fn, [Hex]>() .mockImplementation((chainId: Hex) => { + const data = currentTokenListState.tokensChainsCache[chainId]?.data ?? {}; + // Normalise keys to lowercase to match buildTokenListMap's output so that + // lookups using lowercased addresses (as done in production code) work correctly. return Promise.resolve( - currentTokenListState.tokensChainsCache[chainId]?.data ?? {}, + Object.fromEntries( + Object.entries(data).map(([addr, token]) => [ + addr.toLowerCase(), + token, + ]), + ), ); }); const tokenListService = { @@ -4318,9 +4326,18 @@ async function withController( }, mockTokenListGetState: (state: TokenListState) => { currentTokenListState = state; - mockFetchTokensByChainId.mockImplementation((chainId: Hex) => - Promise.resolve(state.tokensChainsCache[chainId]?.data ?? {}), - ); + mockFetchTokensByChainId.mockImplementation((chainId: Hex) => { + const data = state.tokensChainsCache[chainId]?.data ?? {}; + // Normalise keys to lowercase to match buildTokenListMap's output. + return Promise.resolve( + Object.fromEntries( + Object.entries(data).map(([addr, token]) => [ + addr.toLowerCase(), + token, + ]), + ), + ); + }); }, mockGetNetworkClientById: ( handler: ( diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index 2c1b337f30..2371b18f1e 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -792,7 +792,9 @@ export class TokenDetectionController extends StaticIntervalPollingController { service.destroy(); }); + it('does not re-run map formatting on cache hits for the same chain', async () => { + const chainId = '0xa86a' as Hex; + const apiTokens = [ + { + name: 'Token A', + symbol: 'TKA', + decimals: 18, + address: '0x1000000000000000000000000000000000000001', + occurrences: 1, + aggregators: ['bancor'] as string[], + iconUrl: '', + }, + { + name: 'Token B', + symbol: 'TKB', + decimals: 6, + address: '0x2000000000000000000000000000000000000002', + occurrences: 1, + aggregators: ['cmc'] as string[], + iconUrl: '', + }, + ]; + mockedFetchTokenListByChainId.mockResolvedValue(apiTokens); + + const formatAggregatorsSpy = jest.spyOn( + assetsUtil, + 'formatAggregatorNames', + ); + const formatIconSpy = jest.spyOn(assetsUtil, 'formatIconUrlWithProxy'); + + const service = new TokenListService(); + await service.fetchTokensByChainId(chainId); + await service.fetchTokensByChainId(chainId); + + expect(mockedFetchTokenListByChainId).toHaveBeenCalledTimes(1); + expect(formatAggregatorsSpy).toHaveBeenCalledTimes(2); + expect(formatIconSpy).toHaveBeenCalledTimes(2); + + formatAggregatorsSpy.mockRestore(); + formatIconSpy.mockRestore(); + service.destroy(); + }); + it('treats an undefined API response as an empty list', async () => { mockedFetchTokenListByChainId.mockResolvedValue(undefined); @@ -90,16 +134,38 @@ describe('TokenListService', () => { it('clearing the cache via destroy causes the next fetch to hit the network again', async () => { const chainId = '0x1' as Hex; - mockedFetchTokenListByChainId.mockResolvedValue([]); + const apiToken = { + name: 'Restored After Destroy', + symbol: 'RAD', + decimals: 18, + address: '0x1000000000000000000000000000000000000001', + occurrences: 1, + aggregators: [] as string[], + iconUrl: '', + }; + mockedFetchTokenListByChainId.mockImplementation( + async (_chainId, abortSignal) => { + // Mirror token-service: aborted fetches resolve to undefined, not a list. + if (abortSignal.aborted) { + return undefined; + } + return [apiToken]; + }, + ); const service = new TokenListService(); - await service.fetchTokensByChainId(chainId); - await service.fetchTokensByChainId(chainId); + const first = await service.fetchTokensByChainId(chainId); + const cached = await service.fetchTokensByChainId(chainId); expect(mockedFetchTokenListByChainId).toHaveBeenCalledTimes(1); + expect(cached).toStrictEqual(first); + expect(first[apiToken.address]).toMatchObject({ symbol: 'RAD' }); service.destroy(); - await service.fetchTokensByChainId(chainId); + const afterDestroy = await service.fetchTokensByChainId(chainId); expect(mockedFetchTokenListByChainId).toHaveBeenCalledTimes(2); + expect(afterDestroy[apiToken.address]).toMatchObject({ symbol: 'RAD' }); + + service.destroy(); }); }); diff --git a/packages/assets-controllers/src/TokenListService.ts b/packages/assets-controllers/src/TokenListService.ts index 660cf4aa63..ebeba9a6ef 100644 --- a/packages/assets-controllers/src/TokenListService.ts +++ b/packages/assets-controllers/src/TokenListService.ts @@ -12,13 +12,14 @@ const FOUR_HOURS_MS = 4 * 60 * 60 * 1000; * Shared service for fetching and caching the token list per chain. * * Callers invoke `fetchTokensByChainId` directly. TanStack Query caches the - * result for 4 hours so that multiple controllers share the same in-memory - * cache without redundant network requests. + * normalised `TokenListMap` for 4 hours so that multiple controllers share the + * same in-memory cache without redundant network requests or per-token + * formatting work on cache hits. */ export class TokenListService { readonly #queryClient: QueryClient; - readonly #abortController: AbortController; + #abortController: AbortController; constructor() { this.#abortController = new AbortController(); @@ -47,28 +48,29 @@ export class TokenListService { * @returns A map of lowercase token address → token metadata. */ async fetchTokensByChainId(chainId: Hex): Promise { - const tokens = await this.#queryClient.fetchQuery({ + return await this.#queryClient.fetchQuery({ queryKey: ['TokenListService:fetchTokensByChainId', chainId], queryFn: async () => { const list = (await fetchTokenListByChainId( chainId, this.#abortController.signal, )) as TokenListToken[] | undefined; - return list ?? []; + return buildTokenListMap(list ?? [], chainId); }, staleTime: FOUR_HOURS_MS, gcTime: FOUR_HOURS_MS, }); - - return buildTokenListMap(tokens, chainId); } /** - * Abort any in-flight requests and clear the query cache. + * Abort in-flight requests, clear the query cache, and reset the abort + * controller so subsequent `fetchTokensByChainId` calls are not stuck with an + * already-aborted signal (which would cache empty results). */ destroy(): void { this.#abortController.abort(); this.#queryClient.clear(); + this.#abortController = new AbortController(); } } @@ -85,7 +87,7 @@ export function buildTokenListMap( ): TokenListMap { const tokenListMap: TokenListMap = {}; for (const token of tokens) { - tokenListMap[token.address] = { + tokenListMap[token.address.toLowerCase()] = { ...token, aggregators: formatAggregatorNames(token.aggregators), iconUrl: formatIconUrlWithProxy({ diff --git a/packages/assets-controllers/src/TokensController.ts b/packages/assets-controllers/src/TokensController.ts index b45dbfc595..4867b5fc14 100644 --- a/packages/assets-controllers/src/TokensController.ts +++ b/packages/assets-controllers/src/TokensController.ts @@ -286,10 +286,11 @@ export class TokensController extends BaseController< ); const chainDataMap = Object.fromEntries(chainDataEntries); - // Read the live state inside the updater so we only patch metadata fields - // and never discard tokens added or modified during the async gap above. - const selectedAddress = this.#getSelectedAddress(); + // Read selectedAddress inside the updater so it reflects the live account + // at the moment the state write happens, not a snapshot taken before the + // async fetch gap above. this.update((state) => { + const selectedAddress = this.#getSelectedAddress(); for (const chainId of chainIds) { const chainData = chainDataMap[chainId]; const tokens = state.allTokens[chainId]?.[selectedAddress]; From a42657f856a2c9defe85a75a84cb70ada88aa015 Mon Sep 17 00:00:00 2001 From: juanmigdr Date: Tue, 5 May 2026 20:17:18 +0200 Subject: [PATCH 13/14] chore: make sure we dont cache errors --- .../assets-controllers/src/TokenListService.ts | 8 ++++++-- .../assets-controllers/src/TokensController.ts | 17 ++++++++++++++--- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/assets-controllers/src/TokenListService.ts b/packages/assets-controllers/src/TokenListService.ts index ebeba9a6ef..4f6db1a015 100644 --- a/packages/assets-controllers/src/TokenListService.ts +++ b/packages/assets-controllers/src/TokenListService.ts @@ -48,8 +48,12 @@ export class TokenListService { * @returns A map of lowercase token address → token metadata. */ async fetchTokensByChainId(chainId: Hex): Promise { - return await this.#queryClient.fetchQuery({ - queryKey: ['TokenListService:fetchTokensByChainId', chainId], + const queryKey = ['TokenListService:fetchTokensByChainId', chainId]; + // On failure, TanStack Query v5 sets isInvalidated=true and leaves state.data + // undefined, so the next fetchQuery call always triggers a fresh network request + // rather than serving the cached error. No manual cache eviction is needed. + return this.#queryClient.fetchQuery({ + queryKey, queryFn: async () => { const list = (await fetchTokenListByChainId( chainId, diff --git a/packages/assets-controllers/src/TokensController.ts b/packages/assets-controllers/src/TokensController.ts index 4867b5fc14..c386f4e4f1 100644 --- a/packages/assets-controllers/src/TokensController.ts +++ b/packages/assets-controllers/src/TokensController.ts @@ -53,7 +53,7 @@ import { TOKEN_METADATA_NO_SUPPORT_ERROR, TokenRwaData, } from './token-service'; -import type { TokenListToken } from './TokenListController'; +import type { TokenListMap, TokenListToken } from './TokenListController'; import type { TokenListService } from './TokenListService'; import type { Token } from './TokenRatesController'; import type { TokensControllerMethodActions } from './TokensController-method-action-types'; @@ -278,13 +278,24 @@ export class TokensController extends BaseController< // Fetch all chain data concurrently before touching state so the async gap // is as short as possible and we never hold a stale T0 snapshot while // awaiting individual chain requests. - const chainDataEntries = await Promise.all( + // Promise.allSettled ensures a transient error on one chain does not + // prevent other chains from being enriched. + const results = await Promise.allSettled( chainIds.map(async (chainId) => { const data = await tokenListService.fetchTokensByChainId(chainId); return [chainId, data] as const; }), ); - const chainDataMap = Object.fromEntries(chainDataEntries); + const chainDataMap = Object.fromEntries( + results + .filter( + ( + result, + ): result is PromiseFulfilledResult => + result.status === 'fulfilled', + ) + .map((result) => result.value), + ); // Read selectedAddress inside the updater so it reflects the live account // at the moment the state write happens, not a snapshot taken before the From 009026d370a8b0e8894189e713abbc20f57b06a0 Mon Sep 17 00:00:00 2001 From: juanmigdr Date: Wed, 6 May 2026 11:14:38 +0200 Subject: [PATCH 14/14] chore: updated the comment --- .../src/TokenDetectionController.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index 2371b18f1e..9ac70d8645 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -900,9 +900,9 @@ export class TokenDetectionController extends StaticIntervalPollingController