diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 793f8d99be9..8c78da1a99d 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -428,12 +428,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": 4 } }, "packages/assets-controllers/src/TokenListController.test.ts": { @@ -480,7 +480,7 @@ "count": 6 }, "no-restricted-syntax": { - "count": 4 + "count": 3 } }, "packages/assets-controllers/src/TokensController.ts": { @@ -491,7 +491,7 @@ "count": 1 }, "@typescript-eslint/prefer-optional-chain": { - "count": 4 + "count": 3 }, "id-length": { "count": 1 @@ -503,7 +503,7 @@ "count": 1 }, "no-restricted-syntax": { - "count": 2 + "count": 1 }, "require-atomic-updates": { "count": 1 diff --git a/packages/assets-controllers/src/TokenDetectionController-method-action-types.ts b/packages/assets-controllers/src/TokenDetectionController-method-action-types.ts index 1ec590fcea7..f6cc3afb617 100644 --- a/packages/assets-controllers/src/TokenDetectionController-method-action-types.ts +++ b/packages/assets-controllers/src/TokenDetectionController-method-action-types.ts @@ -38,7 +38,7 @@ export type TokenDetectionControllerStopAction = { }; /** - * For each token in the token list provided by the TokenListController, checks the token's balance for the selected account address on the active network. + * For each token in the token list (fetched directly from the tokens API), checks the token's balance for the selected account address on the active network. * On mainnet, if token detection is disabled in preferences, ERC20 token auto detection will be triggered for each contract address in the legacy token list from the @metamask/contract-metadata repo. * * @param options - Options for token detection. @@ -58,7 +58,8 @@ export type TokenDetectionControllerDetectTokensAction = { * This method: * - Checks if useTokenDetection preference is enabled (skips if disabled) * - Checks if external services are enabled (skips if disabled) - * - Tokens are expected to be in the tokensChainsCache with full metadata + * - Fetches token metadata from the v3 tokens API and filters out unverified + * tokens (occurrences < 3) as a spam prevention measure * - Balance fetching is skipped since balances are provided by the websocket * - Ignored tokens have been filtered out by the caller * @@ -78,7 +79,8 @@ export type TokenDetectionControllerAddDetectedTokensViaWsAction = { * - Checks if useTokenDetection preference is enabled (skips if disabled) * - Checks if external services are enabled (skips if disabled) * - Filters out tokens already in allTokens or allIgnoredTokens - * - Tokens are expected to be in the tokensChainsCache with full metadata + * - Fetches token metadata from the v3 tokens API and filters out unverified + * tokens (occurrences < 3) as a spam prevention measure * - Balance fetching is skipped since balances are provided by the caller * * @param options - The options object diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index b55cb0fb20d..e1bf24f0e9c 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -37,7 +37,10 @@ import { buildInfuraNetworkConfiguration, } from '../../network-controller/tests/helpers'; import { formatAggregatorNames } from './assetsUtil'; -import { TOKEN_END_POINT_API } from './token-service'; +import { + TOKEN_END_POINT_API, + fetchAndBuildTokenListMap, +} from './token-service'; import type { TokenDetectionControllerMessenger } from './TokenDetectionController'; import { TokenDetectionController, @@ -47,12 +50,34 @@ import { import { getDefaultTokenListState } from './TokenListController'; import type { TokenListState, TokenListToken } from './TokenListController'; import type { Token } from './TokenRatesController'; +import { fetchVerifiedTokensByAddresses } from './tokens-api-v3'; +import type { TokenV3Asset } from './tokens-api-v3'; import type { TokensController, TokensControllerState, } from './TokensController'; import { getDefaultTokensState } from './TokensController'; +jest.mock('./token-service', () => ({ + ...jest.requireActual('./token-service'), + fetchAndBuildTokenListMap: jest.fn(), +})); + +jest.mock('./tokens-api-v3', () => ({ + ...jest.requireActual('./tokens-api-v3'), + fetchVerifiedTokensByAddresses: jest.fn(), +})); + +const mockFetchAndBuildTokenListMap = + fetchAndBuildTokenListMap as jest.MockedFunction< + typeof fetchAndBuildTokenListMap + >; + +const mockFetchVerifiedTokensByAddresses = + fetchVerifiedTokensByAddresses as jest.MockedFunction< + typeof fetchVerifiedTokensByAddresses + >; + const DEFAULT_INTERVAL = 180000; const sampleAggregators = [ @@ -95,7 +120,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 +130,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 +230,6 @@ function buildTokenDetectionControllerMessenger( 'NetworkController:getState', 'TokensController:getState', 'TokensController:addDetectedTokens', - 'TokenListController:getState', 'PreferencesController:getState', 'TokensController:addTokens', 'NetworkController:findNetworkClientIdByChainId', @@ -215,7 +239,6 @@ function buildTokenDetectionControllerMessenger( 'KeyringController:lock', 'KeyringController:unlock', 'NetworkController:networkDidChange', - 'TokenListController:stateChange', 'PreferencesController:stateChange', 'TransactionController:transactionConfirmed', ], @@ -227,6 +250,8 @@ describe('TokenDetectionController', () => { const defaultSelectedAccount = createMockInternalAccount(); beforeEach(async () => { + mockFetchVerifiedTokensByAddresses.mockResolvedValue(new Map()); + mockFetchAndBuildTokenListMap.mockResolvedValue(undefined); nock(TOKEN_END_POINT_API) .get(getTokensPath(ChainId.mainnet)) .reply(200, sampleTokenList) @@ -410,11 +435,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 +474,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 +502,7 @@ describe('TokenDetectionController', () => { getAccount: selectedAccount, getSelectedAccount: selectedAccount, }, - }, - - async ({ controller, mockTokenListGetState, callActionSpy }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { '0xa86a': { timestamp: 0, @@ -501,8 +519,10 @@ describe('TokenDetectionController', () => { }, }, }, - }); + }, + }, + async ({ controller, callActionSpy }) => { await controller.start(); expect(callActionSpy).not.toHaveBeenCalledWith( @@ -533,10 +553,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 +591,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 +623,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); + mockFetchAndBuildTokenListMap.mockImplementation((fetchChainId) => { + if (fetchChainId === '0xa86a') { + return Promise.resolve({ + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + [sampleTokenB.address]: { + 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 +682,7 @@ describe('TokenDetectionController', () => { getAccount: selectedAccount, getSelectedAccount: selectedAccount, }, - }, - async ({ - controller, - mockTokensGetState, - mockTokenListGetState, - callActionSpy, - }) => { - mockTokensGetState({ - ...getDefaultTokensState(), - }); - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { '0xa86a': { timestamp: 0, @@ -704,6 +699,11 @@ describe('TokenDetectionController', () => { }, }, }, + }, + }, + async ({ controller, mockTokensGetState, callActionSpy }) => { + mockTokensGetState({ + ...getDefaultTokensState(), }); await controller.start(); @@ -727,10 +727,7 @@ describe('TokenDetectionController', () => { mocks: { getSelectedAccount: defaultSelectedAccount, }, - }, - async ({ controller, mockTokenListGetState, callActionSpy }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { '0xa86a': { timestamp: 0, @@ -747,8 +744,9 @@ describe('TokenDetectionController', () => { }, }, }, - }); - + }, + }, + async ({ controller, callActionSpy }) => { await controller.start(); expect(callActionSpy).not.toHaveBeenCalledWith( @@ -788,26 +786,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 +803,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 +851,7 @@ describe('TokenDetectionController', () => { mocks: { getSelectedAccount: selectedAccount, }, - }, - async ({ - mockTokenListGetState, - triggerSelectedAccountChange, - callActionSpy, - }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { '0xa86a': { timestamp: 0, @@ -879,8 +868,9 @@ describe('TokenDetectionController', () => { }, }, }, - }); - + }, + }, + async ({ triggerSelectedAccountChange, callActionSpy }) => { triggerSelectedAccountChange({ address: selectedAccount.address, } as InternalAccount); @@ -914,14 +904,7 @@ describe('TokenDetectionController', () => { getSelectedAccount: firstSelectedAccount, }, isKeyringUnlocked: false, - }, - async ({ - mockTokenListGetState, - triggerSelectedAccountChange, - callActionSpy, - }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { '0xa86a': { timestamp: 0, @@ -938,8 +921,9 @@ describe('TokenDetectionController', () => { }, }, }, - }); - + }, + }, + async ({ triggerSelectedAccountChange, callActionSpy }) => { triggerSelectedAccountChange({ address: secondSelectedAccount.address, } as InternalAccount); @@ -974,14 +958,7 @@ describe('TokenDetectionController', () => { mocks: { getSelectedAccount: firstSelectedAccount, }, - }, - async ({ - mockTokenListGetState, - triggerSelectedAccountChange, - callActionSpy, - }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { '0xa86a': { timestamp: 0, @@ -998,8 +975,9 @@ describe('TokenDetectionController', () => { }, }, }, - }); - + }, + }, + async ({ triggerSelectedAccountChange, callActionSpy }) => { triggerSelectedAccountChange({ address: secondSelectedAccount.address, } as InternalAccount); @@ -1043,17 +1021,7 @@ describe('TokenDetectionController', () => { mocks: { getSelectedAccount: firstSelectedAccount, }, - }, - async ({ - mockGetAccount, - mockTokenListGetState, - mockNetworkState, - triggerPreferencesStateChange, - triggerSelectedAccountChange, - callActionSpy, - }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { '0xa86a': { timestamp: 0, @@ -1070,7 +1038,15 @@ describe('TokenDetectionController', () => { }, }, }, - }); + }, + }, + async ({ + mockGetAccount, + mockNetworkState, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + callActionSpy, + }) => { mockNetworkState({ networkConfigurationsByChainId: { '0xa86a': { @@ -1128,18 +1104,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 +1121,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 +1169,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 +1186,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 +1241,7 @@ describe('TokenDetectionController', () => { mocks: { getSelectedAccount: firstSelectedAccount, }, - }, - async ({ - mockGetAccount, - mockTokenListGetState, - triggerSelectedAccountChange, - triggerPreferencesStateChange, - callActionSpy, - }) => { - mockGetAccount(firstSelectedAccount); - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { '0xa86a': { data: { @@ -1296,7 +1258,15 @@ describe('TokenDetectionController', () => { timestamp: 0, }, }, - }); + }, + }, + async ({ + mockGetAccount, + triggerSelectedAccountChange, + triggerPreferencesStateChange, + callActionSpy, + }) => { + mockGetAccount(firstSelectedAccount); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), @@ -1330,14 +1300,7 @@ describe('TokenDetectionController', () => { getAccount: selectedAccount, getSelectedAccount: selectedAccount, }, - }, - async ({ - mockTokenListGetState, - triggerPreferencesStateChange, - callActionSpy, - }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { [ChainId.sepolia]: { data: { @@ -1354,8 +1317,9 @@ describe('TokenDetectionController', () => { timestamp: 0, }, }, - }); - + }, + }, + async ({ triggerPreferencesStateChange, callActionSpy }) => { triggerPreferencesStateChange({ ...getDefaultPreferencesState(), useTokenDetection: true, @@ -1392,16 +1356,7 @@ describe('TokenDetectionController', () => { getAccount: firstSelectedAccount, }, isKeyringUnlocked: false, - }, - async ({ - mockGetAccount, - mockTokenListGetState, - triggerPreferencesStateChange, - triggerSelectedAccountChange, - callActionSpy, - }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { [ChainId.sepolia]: { data: { @@ -1418,8 +1373,14 @@ describe('TokenDetectionController', () => { timestamp: 0, }, }, - }); - + }, + }, + async ({ + mockGetAccount, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + callActionSpy, + }) => { triggerPreferencesStateChange({ ...getDefaultPreferencesState(), useTokenDetection: true, @@ -1453,14 +1414,7 @@ describe('TokenDetectionController', () => { getSelectedAccount: selectedAccount, getAccount: selectedAccount, }, - }, - async ({ - mockTokenListGetState, - triggerPreferencesStateChange, - callActionSpy, - }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { [ChainId.sepolia]: { data: { @@ -1477,8 +1431,9 @@ describe('TokenDetectionController', () => { timestamp: 0, }, }, - }); - + }, + }, + async ({ triggerPreferencesStateChange, callActionSpy }) => { triggerPreferencesStateChange({ ...getDefaultPreferencesState(), useTokenDetection: false, @@ -1520,16 +1475,7 @@ describe('TokenDetectionController', () => { getAccount: firstSelectedAccount, getSelectedAccount: firstSelectedAccount, }, - }, - async ({ - mockGetAccount, - mockTokenListGetState, - triggerPreferencesStateChange, - triggerSelectedAccountChange, - callActionSpy, - }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { [ChainId.sepolia]: { data: { @@ -1546,8 +1492,14 @@ describe('TokenDetectionController', () => { timestamp: 0, }, }, - }); - + }, + }, + async ({ + mockGetAccount, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + callActionSpy, + }) => { triggerPreferencesStateChange({ ...getDefaultPreferencesState(), useTokenDetection: true, @@ -1580,14 +1532,7 @@ describe('TokenDetectionController', () => { getAccount: selectedAccount, getSelectedAccount: selectedAccount, }, - }, - async ({ - mockTokenListGetState, - triggerPreferencesStateChange, - callActionSpy, - }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { [ChainId.sepolia]: { data: { @@ -1604,8 +1549,9 @@ describe('TokenDetectionController', () => { timestamp: 0, }, }, - }); - + }, + }, + async ({ triggerPreferencesStateChange, callActionSpy }) => { triggerPreferencesStateChange({ ...getDefaultPreferencesState(), useTokenDetection: false, @@ -1654,14 +1600,7 @@ describe('TokenDetectionController', () => { getAccount: selectedAccount, getSelectedAccount: selectedAccount, }, - }, - async ({ - mockTokenListGetState, - callActionSpy, - triggerNetworkDidChange, - }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { [ChainId.sepolia]: { timestamp: 0, @@ -1678,560 +1617,23 @@ describe('TokenDetectionController', () => { }, }, }, - }); - - triggerNetworkDidChange({ - ...getDefaultNetworkControllerState(), - selectedNetworkClientId: NetworkType.sepolia, - }); - await jestAdvanceTime({ duration: 1 }); - - expect(callActionSpy).not.toHaveBeenCalledWith( - 'TokensController:addDetectedTokens', - ); - }, - ); - }); - - it('should not detect new tokens if the network client id has not changed', async () => { - const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ - [sampleTokenA.address]: new BN(1), - }); - const selectedAccount = createMockInternalAccount({ - address: '0x0000000000000000000000000000000000000001', - }); - await withController( - { - options: { - disabled: false, - getBalancesInSingleCall: mockGetBalancesInSingleCall, - }, - 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('when keyring is locked', () => { - it('should not detect new tokens after switching network client id', 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: { - 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('when "disabled" is true', () => { - it('should not detect new tokens after switching network client id', async () => { - const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ - [sampleTokenA.address]: new BN(1), - }); - const selectedAccount = createMockInternalAccount({ - address: '0x0000000000000000000000000000000000000001', - }); - await withController( - { - options: { - disabled: true, - getBalancesInSingleCall: mockGetBalancesInSingleCall, - }, - 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, - }, - }, - }); - + async ({ callActionSpy, triggerNetworkDidChange }) => { 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 - }, - }, + selectedNetworkClientId: NetworkType.sepolia, }); await jestAdvanceTime({ duration: 1 }); - expect(mockTokens).toHaveBeenCalledTimes(0); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + ); }, ); }); - }); - describe('when previous and incoming tokensChainsCache are not equal', () => { - it('should call detect tokens', async () => { + it('should not detect new tokens if the network client id has not changed', async () => { const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ [sampleTokenA.address]: new BN(1), }); @@ -2245,17 +1647,10 @@ describe('TokenDetectionController', () => { getBalancesInSingleCall: mockGetBalancesInSingleCall, }, mocks: { - getSelectedAccount: selectedAccount, getAccount: selectedAccount, + getSelectedAccount: selectedAccount, }, - }, - async ({ - mockTokenListGetState, - triggerTokenListStateChange, - controller, - }) => { - const tokenListState = { - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { [ChainId.sepolia]: { data: { @@ -2272,20 +1667,97 @@ describe('TokenDetectionController', () => { timestamp: 0, }, }, - }; - mockTokenListGetState(tokenListState); - // This should set the tokensChainsCache value - triggerTokenListStateChange(tokenListState); + }, + }, + async ({ callActionSpy, triggerNetworkDidChange }) => { + triggerNetworkDidChange({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'avalanche', + }); await jestAdvanceTime({ duration: 1 }); - const mockTokens = jest.spyOn(controller, 'detectTokens'); + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + ); + }, + ); + }); + + describe('when keyring is locked', () => { + it('should not detect new tokens after switching network client id', 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: { + getAccount: selectedAccount, + getSelectedAccount: selectedAccount, + }, + mockTokenListState: { + 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, + }, + }, + }, + }, + async ({ callActionSpy, triggerNetworkDidChange }) => { + triggerNetworkDidChange({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'avalanche', + }); + await jestAdvanceTime({ duration: 1 }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + ); + }, + ); + }); + }); + }); - // Re-trigger state change so that incoming list is equal the current list in state - triggerTokenListStateChange({ - ...tokenListState, + describe('when "disabled" is true', () => { + it('should not detect new tokens after switching network client id', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + await withController( + { + options: { + disabled: true, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getAccount: selectedAccount, + getSelectedAccount: selectedAccount, + }, + mockTokenListState: { tokensChainsCache: { - ...tokenListState.tokensChainsCache, - [ChainId['linea-mainnet']]: { + [ChainId.sepolia]: { data: { [sampleTokenA.address]: { name: sampleTokenA.name, @@ -2297,12 +1769,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 +1816,7 @@ describe('TokenDetectionController', () => { getSelectedAccount: selectedAccount, getAccount: selectedAccount, }, - }, - async ({ controller, mockTokenListGetState }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { [ChainId.sepolia]: { data: { @@ -2355,7 +1833,9 @@ describe('TokenDetectionController', () => { timestamp: 0, }, }, - }); + }, + }, + async ({ controller }) => { const spy = jest .spyOn(controller, 'detectTokens') .mockImplementation(() => { @@ -2461,24 +1941,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 +1958,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 +2005,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 +2022,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 +2067,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 +2103,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 +2128,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 +2200,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 +2217,21 @@ describe('TokenDetectionController', () => { }, }, }, + }, + }, + async ({ + mockNetworkState, + callActionSpy, + triggerTransactionConfirmed, + }) => { + const defaultState = getDefaultNetworkControllerState(); + mockNetworkState({ + ...defaultState, + selectedNetworkClientId: 'avalanche', + networkConfigurationsByChainId: { + ...defaultState.networkConfigurationsByChainId, + ...mockNetworkConfigurationsByChainId, + }, }); triggerTransactionConfirmed({ chainId: '0xa86a' }); @@ -2869,25 +2338,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 +2355,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 +2544,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 +2596,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 +2706,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 +2723,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 +2779,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 +2796,13 @@ describe('TokenDetectionController', () => { }, }, }, + }, + }, + async ({ controller, mockNetworkState }) => { + // Set selectedNetworkClientId to avalanche (not in SUPPORTED_NETWORKS_ACCOUNTS_API_V4) + mockNetworkState({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'avalanche', }); await controller.start(); @@ -3353,6 +2816,58 @@ describe('TokenDetectionController', () => { }); describe('addDetectedTokensViaWs', () => { + it('should return early when useTokenDetection is disabled', async () => { + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const chainId = '0xa86a'; + + await withController( + { + options: { + disabled: false, + useTokenDetection: () => false, + }, + }, + async ({ controller, callActionSpy }) => { + await controller.addDetectedTokensViaWs({ + tokensSlice: [mockTokenAddress], + chainId: chainId as Hex, + }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.anything(), + expect.anything(), + ); + }, + ); + }); + + it('should return early when useExternalServices is disabled', async () => { + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const chainId = '0xa86a'; + + await withController( + { + options: { + disabled: false, + useExternalServices: () => false, + }, + }, + async ({ controller, callActionSpy }) => { + await controller.addDetectedTokensViaWs({ + tokensSlice: [mockTokenAddress], + chainId: chainId as Hex, + }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.anything(), + expect.anything(), + ); + }, + ); + }); + it('should add tokens detected from websocket with metadata from cache', async () => { const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; const checksummedTokenAddress = @@ -3384,6 +2899,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 +3020,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 +3111,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 +3180,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 +3252,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, @@ -3744,6 +3334,33 @@ describe('TokenDetectionController', () => { ); }); + it('should return early when useExternalServices is disabled', async () => { + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const chainId = '0xa86a'; + + await withController( + { + options: { + disabled: false, + useTokenDetection: () => true, + useExternalServices: () => false, + }, + }, + async ({ controller, callActionSpy }) => { + await controller.addDetectedTokensViaPolling({ + tokensSlice: [mockTokenAddress], + chainId: chainId as Hex, + }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.anything(), + expect.anything(), + ); + }, + ); + }); + it('should skip tokens already in allTokens', async () => { const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; const checksummedTokenAddress = @@ -3896,28 +3513,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, - }, + mockFetchAndBuildTokenListMap.mockImplementation((fetchChainId) => { + if (fetchChainId === chainId) { + return Promise.resolve({ + [mockTokenAddress]: { + 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 +3653,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, @@ -4055,6 +3695,126 @@ describe('TokenDetectionController', () => { ); }); }); + + describe('#getTokenListForChain cache behaviour', () => { + it('fetches the full API list after detection is re-enabled on mainnet, bypassing the stale static cache', async () => { + const mainnetChainId = ChainId.mainnet; + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({}); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, + }, + async ({ + controller, + mockNetworkState, + mockGetNetworkClientById, + mockFindNetworkClientIdByChainId, + triggerPreferencesStateChange, + }) => { + const defaultState = getDefaultNetworkControllerState(); + const mainnetNetworkConfig: NetworkConfiguration = { + chainId: ChainId.mainnet, + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + blockExplorerUrls: [], + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + networkClientId: 'mainnet', + type: RpcEndpointType.Custom, + url: 'https://mainnet.infura.io/v3/test', + failoverUrls: [], + }, + ], + }; + mockNetworkState({ + ...defaultState, + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + ...defaultState.networkConfigurationsByChainId, + [ChainId.mainnet]: mainnetNetworkConfig, + }, + }); + mockFindNetworkClientIdByChainId(() => 'mainnet'); + mockGetNetworkClientById( + () => + ({ + configuration: { chainId: ChainId.mainnet }, + provider: {}, + destroy: {}, + blockTracker: {}, + }) as unknown as AutoManagedNetworkClient, + ); + + // Disable detection so the static mainnet list gets cached on first run + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useTokenDetection: false, + }); + + // First run: detection disabled → static list gets cached + await controller.detectTokens({ + chainIds: [mainnetChainId], + forceRpc: true, + }); + + // API should NOT have been called while detection was disabled + expect(mockFetchAndBuildTokenListMap).not.toHaveBeenCalledWith( + mainnetChainId, + expect.anything(), + ); + + // User re-enables token detection + mockFetchAndBuildTokenListMap.mockClear(); + mockFetchAndBuildTokenListMap.mockResolvedValue({}); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useTokenDetection: true, + }); + + // Second run: detection now enabled → must bypass stale static cache + // and call the full API + await controller.detectTokens({ + chainIds: [mainnetChainId], + forceRpc: true, + }); + + expect(mockFetchAndBuildTokenListMap).toHaveBeenCalledWith( + mainnetChainId, + expect.anything(), + ); + }, + ); + }); + }); + + describe('destroy', () => { + it('should abort the internal abort controller and stop polling', async () => { + await withController( + { options: { disabled: false } }, + async ({ controller }) => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + + controller.destroy(); + + expect(abortSpy).toHaveBeenCalled(); + abortSpy.mockRestore(); + }, + ); + }); + }); }); /** @@ -4066,7 +3826,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 +3836,6 @@ type WithControllerCallback = ({ mockGetSelectedAccount, mockKeyringGetState, mockTokensGetState, - mockTokenListGetState, mockPreferencesGetState, mockGetNetworkClientById, mockGetNetworkConfigurationByNetworkClientId, @@ -4084,7 +3843,6 @@ type WithControllerCallback = ({ callActionSpy, triggerKeyringUnlock, triggerKeyringLock, - triggerTokenListStateChange, triggerPreferencesStateChange, triggerSelectedAccountChange, triggerNetworkDidChange, @@ -4095,7 +3853,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 +3869,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 +3982,17 @@ async function withController( ...mockTokensState, }), ); - const mockTokenListStateFunc = jest.fn(); - messenger.registerActionHandler( - 'TokenListController:getState', - mockTokenListStateFunc.mockReturnValue({ - ...getDefaultTokenListState(), - ...mockTokenListState, - }), - ); + const initialTokenListState = { + ...getDefaultTokenListState(), + ...mockTokenListState, + }; + mockFetchAndBuildTokenListMap.mockImplementation((chainId: Hex) => { + const cache = initialTokenListState.tokensChainsCache[chainId]; + if (cache) { + return Promise.resolve(cache.data); + } + return Promise.resolve(undefined); + }); const mockPreferencesState = jest.fn(); messenger.registerActionHandler( 'PreferencesController:getState', @@ -4301,9 +4060,6 @@ async function withController( mockPreferencesGetState: (state: PreferencesState) => { mockPreferencesState.mockReturnValue(state); }, - mockTokenListGetState: (state: TokenListState) => { - mockTokenListStateFunc.mockReturnValue(state); - }, mockGetNetworkClientById: ( handler: ( networkClientId: NetworkClientId, @@ -4333,9 +4089,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 89a724c8a58..27aecaa629e 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -39,19 +39,16 @@ 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 { isTokenDetectionSupportedForNetwork } from './assetsUtil'; import { SUPPORTED_NETWORKS_ACCOUNTS_API_V4 } from './constants'; +import { fetchAndBuildTokenListMap } from './token-service'; import type { TokenDetectionControllerMethodActions } from './TokenDetectionController-method-action-types'; -import type { - GetTokenListState, - TokenListMap, - TokenListStateChange, - TokensChainsCache, -} from './TokenListController'; +import type { TokenListMap, TokensChainsCache } from './TokenListController'; import type { Token } from './TokenRatesController'; +import { fetchVerifiedTokensByAddresses } from './tokens-api-v3'; import type { TokensControllerGetStateAction } from './TokensController'; import type { TokensControllerAddDetectedTokensAction, @@ -129,7 +126,6 @@ export type AllowedActions = | NetworkControllerGetNetworkClientByIdAction | NetworkControllerGetNetworkConfigurationByNetworkClientId | NetworkControllerGetStateAction - | GetTokenListState | KeyringControllerGetStateAction | PreferencesControllerGetStateAction | TokensControllerGetStateAction @@ -147,7 +143,6 @@ export type TokenDetectionControllerEvents = export type AllowedEvents = | AccountsControllerSelectedEvmAccountChangeEvent | NetworkControllerNetworkDidChangeEvent - | TokenListStateChange | KeyringControllerLockEvent | KeyringControllerUnlockEvent | PreferencesControllerStateChangeEvent @@ -200,7 +195,14 @@ export class TokenDetectionController extends StaticIntervalPollingController(); + + static readonly #tokenListCacheMaxAge = 4 * 60 * 60 * 1000; + + readonly #abortController: AbortController; #disabled: boolean; @@ -274,17 +276,12 @@ 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 }) => { @@ -344,6 +326,11 @@ export class TokenDetectionController extends StaticIntervalPollingController { @@ -448,30 +435,6 @@ export class TokenDetectionController extends StaticIntervalPollingController { const { allTokens, allDetectedTokens, allIgnoredTokens } = this.messenger.call('TokensController:getState'); const [tokensAddresses, detectedTokensAddresses, ignoredTokensAddresses] = [ @@ -661,10 +612,10 @@ export class TokenDetectionController extends StaticIntervalPollingController( (acc, [key, value]) => ({ ...acc, [key]: { @@ -698,18 +649,50 @@ export class TokenDetectionController extends StaticIntervalPollingController { + const now = Date.now(); + + // Check the preference-based path first so that changing the preference + // (e.g. re-enabling detection on mainnet) always bypasses a stale + // static-list cache entry. + const isMainnetDetectionInactive = + !this.#isDetectionEnabledFromPreferences && chainId === ChainId.mainnet; + + if (isMainnetDetectionInactive) { + const data = this.#getStaticMainnetTokenList(); + this.#tokenListCache.set(chainId, { data, timestamp: now }); + return data; + } + + const cached = this.#tokenListCache.get(chainId); + + if ( + cached && + now - cached.timestamp < TokenDetectionController.#tokenListCacheMaxAge + ) { + return cached.data; + } + + const tokenList = await fetchAndBuildTokenListMap( + chainId, + this.#abortController.signal, + ); + + if (!tokenList) { + return cached?.data ?? {}; + } + + this.#tokenListCache.set(chainId, { data: tokenList, timestamp: now }); + return tokenList; } async #addDetectedTokens({ @@ -730,11 +713,17 @@ export class TokenDetectionController extends StaticIntervalPollingController + fetchVerifiedTokensByAddresses(chainId, tokensSlice), + )) ?? new Map(); const tokensWithBalance: Token[] = []; const eventTokensDetails: string[] = []; @@ -814,10 +802,7 @@ 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 safelyExecute(() => + fetchVerifiedTokensByAddresses(chainId, addressesToFetch), + )) ?? new Map(); + 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; } @@ -995,6 +980,12 @@ export class TokenDetectionController extends StaticIntervalPollingController ({ })); jest.mock('./Standards/ERC20Standard'); jest.mock('./Standards/NftStandards/ERC1155/ERC1155Standard'); +jest.mock('./token-service', () => ({ + ...jest.requireActual('./token-service'), + fetchAndBuildTokenListMap: jest.fn(), +})); + +const mockFetchAndBuildTokenListMap = + fetchAndBuildTokenListMap as jest.MockedFunction< + typeof fetchAndBuildTokenListMap + >; type AllActions = | MessengerActions @@ -80,6 +92,7 @@ describe('TokensController', () => { ContractMock.mockReturnValue( buildMockEthersERC721Contract({ supportsInterface: false }), ); + mockFetchAndBuildTokenListMap.mockResolvedValue(undefined); }); it('should set default state', async () => { @@ -3261,123 +3274,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' }); }); }); }); @@ -3760,6 +3753,237 @@ describe('TokensController', () => { }); }); + describe('enrichTokenMetadata', () => { + it('enriches token names from the token list on construction', async () => { + const tokenAddress = '0x01'; + const initialState: TokensControllerState = { + allTokens: { + [ChainId.mainnet]: { + '0x1': [ + { + address: tokenAddress, + symbol: 'TKN', + decimals: 18, + aggregators: [], + image: undefined, + name: undefined, + }, + ], + }, + }, + allIgnoredTokens: {}, + allDetectedTokens: {}, + }; + + mockFetchAndBuildTokenListMap.mockResolvedValue({ + [tokenAddress]: { + address: tokenAddress, + symbol: 'TKN', + decimals: 18, + name: 'Token Name', + occurrences: 5, + aggregators: [], + iconUrl: '', + }, + }); + + await withController( + { options: { state: initialState } }, + async ({ controller }) => { + // Allow the async enrichment to complete + await new Promise((resolve) => { + process.nextTick(() => { + resolve(); + }); + }); + expect( + controller.state.allTokens[ChainId.mainnet]['0x1'][0].name, + ).toBe('Token Name'); + }, + ); + }); + + it('enriches rwaData from the token list on construction', async () => { + const tokenAddress = '0x01'; + const rwaData = { ticker: 'TICKER', instrumentType: 'bond' }; + const initialState: TokensControllerState = { + allTokens: { + [ChainId.mainnet]: { + '0x1': [ + { + address: tokenAddress, + symbol: 'TKN', + decimals: 18, + aggregators: [], + image: undefined, + name: 'Existing Name', + }, + ], + }, + }, + allIgnoredTokens: {}, + allDetectedTokens: {}, + }; + + mockFetchAndBuildTokenListMap.mockResolvedValue({ + [tokenAddress]: { + address: tokenAddress, + symbol: 'TKN', + decimals: 18, + name: 'Existing Name', + occurrences: 5, + aggregators: [], + iconUrl: '', + rwaData, + }, + }); + + await withController( + { options: { state: initialState } }, + async ({ controller }) => { + await new Promise((resolve) => { + process.nextTick(() => { + resolve(); + }); + }); + expect( + controller.state.allTokens[ChainId.mainnet]['0x1'][0].rwaData, + ).toStrictEqual(rwaData); + }, + ); + }); + + it('does not update state when no enrichment is needed', async () => { + const tokenAddress = '0x01'; + const initialState: TokensControllerState = { + allTokens: { + [ChainId.mainnet]: { + '0x1': [ + { + address: tokenAddress, + symbol: 'TKN', + decimals: 18, + aggregators: [], + image: undefined, + name: 'Already Named', + }, + ], + }, + }, + allIgnoredTokens: {}, + allDetectedTokens: {}, + }; + + mockFetchAndBuildTokenListMap.mockResolvedValue({ + [tokenAddress]: { + address: tokenAddress, + symbol: 'TKN', + decimals: 18, + name: 'Already Named', + occurrences: 5, + aggregators: [], + iconUrl: '', + }, + }); + + await withController( + { options: { state: initialState } }, + async ({ controller }) => { + const stateBefore = controller.state; + await new Promise((resolve) => { + process.nextTick(() => { + resolve(); + }); + }); + // State should be the same reference since no changes were made + expect(controller.state.allTokens).toStrictEqual( + stateBefore.allTokens, + ); + }, + ); + }); + + it('uses cached token list on repeated enrichment calls', async () => { + const tokenAddress = '0x01'; + const initialState: TokensControllerState = { + allTokens: { + [ChainId.mainnet]: { + '0x1': [ + { + address: tokenAddress, + symbol: 'TKN', + decimals: 18, + aggregators: [], + image: undefined, + name: undefined, + }, + ], + }, + }, + allIgnoredTokens: {}, + allDetectedTokens: {}, + }; + + mockFetchAndBuildTokenListMap.mockResolvedValue({ + [tokenAddress]: { + address: tokenAddress, + symbol: 'TKN', + decimals: 18, + name: 'Token Name', + occurrences: 5, + aggregators: [], + iconUrl: '', + }, + }); + + await withController( + { options: { state: initialState } }, + async ({ controller }) => { + await new Promise((resolve) => { + process.nextTick(() => { + resolve(); + }); + }); + // First call should have fetched + expect(mockFetchAndBuildTokenListMap).toHaveBeenCalledTimes(1); + // Clear mock call count, then trigger enrichment again + mockFetchAndBuildTokenListMap.mockClear(); + // Add a token to trigger enrichment + await controller.addToken({ + address: '0x02', + symbol: 'TKN2', + decimals: 18, + networkClientId: 'mainnet', + }); + await new Promise((resolve) => { + process.nextTick(() => { + resolve(); + }); + }); + // Second call should use cached list (not call API again) + expect(mockFetchAndBuildTokenListMap).not.toHaveBeenCalled(); + }, + ); + }); + }); + + describe('destroy', () => { + it('should clear the enrich interval and abort the abort controller', async () => { + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + + await withController({}, ({ controller }) => { + controller.destroy(); + + expect(clearIntervalSpy).toHaveBeenCalled(); + expect(abortSpy).toHaveBeenCalled(); + }); + + clearIntervalSpy.mockRestore(); + abortSpy.mockRestore(); + }); + }); + describe('metadata', () => { it('includes expected state in debug snapshots', async () => { await withController(({ controller }) => { @@ -3934,7 +4158,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 5b2ae1e351e..02bd741c297 100644 --- a/packages/assets-controllers/src/TokensController.ts +++ b/packages/assets-controllers/src/TokensController.ts @@ -50,13 +50,11 @@ import { ERC20Standard } from './Standards/ERC20Standard'; import { ERC1155Standard } from './Standards/NftStandards/ERC1155/ERC1155Standard'; import { fetchTokenMetadata, + fetchAndBuildTokenListMap, TOKEN_METADATA_NO_SUPPORT_ERROR, TokenRwaData, } from './token-service'; -import type { - TokenListStateChange, - TokenListToken, -} from './TokenListController'; +import type { TokenListMap, TokenListToken } from './TokenListController'; 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; @@ -209,6 +206,15 @@ export class TokensController extends BaseController< readonly #abortController: AbortController; + readonly #tokenListCache = new Map< + Hex, + { data: TokenListMap; timestamp: number } + >(); + + static readonly #tokenListCacheMaxAge = 4 * 60 * 60 * 1000; + + #enrichIntervalId: ReturnType | undefined; + /** * Tokens controller options * @@ -261,44 +267,14 @@ 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 - } - } - } - } - } - - // Update the state with the modified tokens - this.update(() => { - return { - ...this.state, - allTokens: updatedAllTokens, - }; - }); - }, - ); + this.#enrichTokenMetadata().catch(() => { + // Silently handle enrichment errors on startup + }); + this.#enrichIntervalId = setInterval(() => { + this.#enrichTokenMetadata().catch(() => { + // Silently handle enrichment errors + }); + }, TokensController.#tokenListCacheMaxAge); } #handleOnAccountRemoved(accountAddress: string) { @@ -402,6 +378,87 @@ 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 tokenList = await fetchAndBuildTokenListMap( + chainId, + this.#abortController.signal, + ); + + if (!tokenList) { + return cached?.data ?? {}; + } + + 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) { + token.rwaData = cachedToken.rwaData; + hasChanges = true; + } + } + } + } + + if (hasChanges) { + this.update(() => ({ + ...this.state, + allTokens: updatedAllTokens, + })); + } + } + /** * Adds a token to the stored token list. * @@ -472,8 +529,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(), @@ -509,6 +566,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(); @@ -587,6 +653,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(); } @@ -1152,6 +1226,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. */ diff --git a/packages/assets-controllers/src/token-service.test.ts b/packages/assets-controllers/src/token-service.test.ts index 7ae819b9a8e..5bf0ee52dad 100644 --- a/packages/assets-controllers/src/token-service.test.ts +++ b/packages/assets-controllers/src/token-service.test.ts @@ -5,6 +5,7 @@ import nock from 'nock'; import type { SortTrendingBy } from './token-service'; import { + fetchAndBuildTokenListMap, fetchTokenAssets, fetchTokenListByChainId, fetchTokenMetadata, @@ -1683,4 +1684,58 @@ describe('Token service', () => { expect(result).toStrictEqual([]); }); }); + + describe('fetchAndBuildTokenListMap', () => { + const abortController = new AbortController(); + const { signal } = abortController; + + it('returns a normalized TokenListMap with formatted aggregators and proxied icon URLs', async () => { + nock(TOKEN_END_POINT_API) + .get( + `/tokens/${sampleDecimalChainId}?occurrenceFloor=3&includeNativeAssets=false&includeTokenFees=false&includeAssetType=false&includeERC20Permit=false&includeStorage=false&includeRwaData=true`, + ) + .reply(200, sampleTokenList) + .persist(); + + const result = await fetchAndBuildTokenListMap(sampleChainId, signal); + + expect(result).toBeDefined(); + expect(typeof result).toBe('object'); + // Each returned address should map to a token object + for (const token of sampleTokenList) { + expect(result?.[token.address]).toBeDefined(); + expect(result?.[token.address].address).toBe(token.address); + expect(result?.[token.address].symbol).toBe(token.symbol); + } + }); + + it('returns undefined when fetchTokenListByChainId returns undefined (aborted)', async () => { + const ac = new AbortController(); + nock(TOKEN_END_POINT_API) + .get( + `/tokens/${sampleDecimalChainId}?occurrenceFloor=3&includeNativeAssets=false&includeTokenFees=false&includeAssetType=false&includeERC20Permit=false&includeStorage=false&includeRwaData=true`, + ) + .delay(ONE_SECOND_IN_MILLISECONDS) + .reply(200, sampleTokenList) + .persist(); + + const fetchPromise = fetchAndBuildTokenListMap(sampleChainId, ac.signal); + ac.abort(); + + expect(await fetchPromise).toBeUndefined(); + }); + + it('returns undefined when the fetch throws an error', async () => { + nock(TOKEN_END_POINT_API) + .get( + `/tokens/${sampleDecimalChainId}?occurrenceFloor=3&includeNativeAssets=false&includeTokenFees=false&includeAssetType=false&includeERC20Permit=false&includeStorage=false&includeRwaData=true`, + ) + .replyWithError('network failure') + .persist(); + + const result = await fetchAndBuildTokenListMap(sampleChainId, signal); + + expect(result).toBeUndefined(); + }); + }); }); diff --git a/packages/assets-controllers/src/token-service.ts b/packages/assets-controllers/src/token-service.ts index 02464f15560..37585ea3da3 100644 --- a/packages/assets-controllers/src/token-service.ts +++ b/packages/assets-controllers/src/token-service.ts @@ -6,7 +6,12 @@ import { } from '@metamask/controller-utils'; import type { CaipAssetType, CaipChainId, Hex } from '@metamask/utils'; -import { isTokenListSupportedForNetwork } from './assetsUtil'; +import { + isTokenListSupportedForNetwork, + formatAggregatorNames, + formatIconUrlWithProxy, +} from './assetsUtil'; +import type { TokenListMap, TokenListToken } from './TokenListController'; export const TOKEN_END_POINT_API = 'https://token.api.cx.metamask.io'; export const TOKEN_METADATA_NO_SUPPORT_ERROR = @@ -226,6 +231,48 @@ export async function fetchTokenListByChainId( return undefined; } +/** + * Fetch the token list for the given chain and transform each entry into the + * normalized {@link TokenListMap} shape (formatted aggregator names + proxied + * icon URL). Returns `undefined` when the request is aborted or fails so + * callers can fall back to a previously cached value. + * + * @param chainId - The hex chain ID to fetch tokens for. + * @param abortSignal - An abort signal used to cancel the request if necessary. + * @returns The normalized token list map, or `undefined` on failure. + */ +export async function fetchAndBuildTokenListMap( + chainId: Hex, + abortSignal: AbortSignal, +): Promise { + let tokensFromAPI: TokenListToken[] | undefined; + try { + tokensFromAPI = (await fetchTokenListByChainId(chainId, abortSignal)) as + | TokenListToken[] + | undefined; + } catch { + return undefined; + } + + if (!tokensFromAPI) { + return undefined; + } + + const tokenList: TokenListMap = {}; + for (const token of tokensFromAPI) { + tokenList[token.address] = { + ...token, + aggregators: formatAggregatorNames(token.aggregators ?? []), + iconUrl: formatIconUrlWithProxy({ + chainId, + tokenAddress: token.address, + }), + }; + } + + return tokenList; +} + export type TokenRwaData = { market?: { nextOpen?: string; diff --git a/packages/assets-controllers/src/tokens-api-v3.test.ts b/packages/assets-controllers/src/tokens-api-v3.test.ts new file mode 100644 index 00000000000..771ae21eada --- /dev/null +++ b/packages/assets-controllers/src/tokens-api-v3.test.ts @@ -0,0 +1,177 @@ +import { handleFetch } from '@metamask/controller-utils'; + +import { + buildCaipAssetId, + fetchVerifiedTokensByAddresses, + MAX_BATCH_SIZE, + MIN_OCCURRENCES, + TOKENS_API_V3_BASE_URL, +} from './tokens-api-v3'; +import type { TokenV3Asset } from './tokens-api-v3'; + +jest.mock('@metamask/controller-utils', () => ({ + ...jest.requireActual('@metamask/controller-utils'), + handleFetch: jest.fn(), +})); + +const mockHandleFetch = handleFetch as jest.MockedFunction; + +const MOCK_CHAIN_ID = '0x1' as const; +const MOCK_TOKEN_ADDRESS = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + +const makeAsset = ( + address: string, + occurrences: number, + chainId = MOCK_CHAIN_ID, +): TokenV3Asset => ({ + assetId: buildCaipAssetId(chainId, address), + decimals: 18, + iconUrl: `https://example.com/${address}.png`, + name: 'Token', + symbol: 'TKN', + occurrences, + aggregators: ['agg1', 'agg2'], +}); + +describe('tokens-api-v3', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('buildCaipAssetId', () => { + it('builds a CAIP-19 asset ID from chainId and address', () => { + expect(buildCaipAssetId('0x1', '0xAbCd')).toBe('eip155:1/erc20:0xabcd'); + }); + + it('lowercases the token address', () => { + const result = buildCaipAssetId( + '0x89', + '0xABCDEF1234567890ABCDEF1234567890ABCDEF12', + ); + expect(result).toBe( + 'eip155:137/erc20:0xabcdef1234567890abcdef1234567890abcdef12', + ); + }); + }); + + describe('fetchVerifiedTokensByAddresses', () => { + it('returns an empty map when given an empty address list', async () => { + const result = await fetchVerifiedTokensByAddresses(MOCK_CHAIN_ID, []); + expect(result.size).toBe(0); + expect(mockHandleFetch).not.toHaveBeenCalled(); + }); + + it('returns verified tokens that meet the minimum occurrences threshold', async () => { + const verifiedAsset = makeAsset(MOCK_TOKEN_ADDRESS, MIN_OCCURRENCES); + mockHandleFetch.mockResolvedValue([verifiedAsset]); + + const result = await fetchVerifiedTokensByAddresses(MOCK_CHAIN_ID, [ + MOCK_TOKEN_ADDRESS, + ]); + + expect(result.size).toBe(1); + expect(result.get(MOCK_TOKEN_ADDRESS.toLowerCase())).toStrictEqual( + verifiedAsset, + ); + }); + + it('filters out tokens below the minimum occurrences threshold', async () => { + const spamAsset = makeAsset(MOCK_TOKEN_ADDRESS, MIN_OCCURRENCES - 1); + mockHandleFetch.mockResolvedValue([spamAsset]); + + const result = await fetchVerifiedTokensByAddresses(MOCK_CHAIN_ID, [ + MOCK_TOKEN_ADDRESS, + ]); + + expect(result.size).toBe(0); + }); + + it('correctly constructs the API URL with asset IDs', async () => { + mockHandleFetch.mockResolvedValue([]); + + await fetchVerifiedTokensByAddresses(MOCK_CHAIN_ID, [MOCK_TOKEN_ADDRESS]); + + const expectedAssetId = buildCaipAssetId( + MOCK_CHAIN_ID, + MOCK_TOKEN_ADDRESS, + ); + const expectedParams = new URLSearchParams({ + assetIds: expectedAssetId, + includeOccurrences: 'true', + includeIconUrl: 'true', + includeAggregators: 'true', + includeRwaData: 'true', + }); + expect(mockHandleFetch).toHaveBeenCalledWith( + `${TOKENS_API_V3_BASE_URL}/assets?${expectedParams}`, + ); + }); + + it('handles a non-array API response gracefully', async () => { + mockHandleFetch.mockResolvedValue({ error: 'bad request' } as never); + + const result = await fetchVerifiedTokensByAddresses(MOCK_CHAIN_ID, [ + MOCK_TOKEN_ADDRESS, + ]); + + expect(result.size).toBe(0); + }); + + it('splits large address lists into batches of MAX_BATCH_SIZE', async () => { + const addresses = Array.from( + { length: MAX_BATCH_SIZE + 5 }, + (_, i) => `0x${String(i).padStart(40, '0')}`, + ); + + mockHandleFetch.mockResolvedValue([]); + + await fetchVerifiedTokensByAddresses(MOCK_CHAIN_ID, addresses); + + // Should call the API twice: one full batch + one partial batch + expect(mockHandleFetch).toHaveBeenCalledTimes(2); + }); + + it('deduplicates in-flight requests for identical batches', async () => { + const addresses = [MOCK_TOKEN_ADDRESS]; + + let resolveFirst!: (value: TokenV3Asset[]) => void; + const firstCallPromise = new Promise((resolve) => { + resolveFirst = resolve; + }); + mockHandleFetch.mockReturnValueOnce(firstCallPromise as never); + + const [result1, result2] = await Promise.all([ + fetchVerifiedTokensByAddresses(MOCK_CHAIN_ID, addresses), + fetchVerifiedTokensByAddresses(MOCK_CHAIN_ID, addresses), + (async (): Promise => { + resolveFirst([makeAsset(MOCK_TOKEN_ADDRESS, MIN_OCCURRENCES)]); + })(), + ]); + + // Both callers should see the same result + expect(result1?.size).toBe(result2?.size); + // Only one HTTP request was made despite two callers + expect(mockHandleFetch).toHaveBeenCalledTimes(1); + }); + + it('returns an empty map when the API throws', async () => { + mockHandleFetch.mockRejectedValue(new Error('network error')); + + await expect( + fetchVerifiedTokensByAddresses(MOCK_CHAIN_ID, [MOCK_TOKEN_ADDRESS]), + ).rejects.toThrow('network error'); + }); + + it('lowercases asset addresses in the result map', async () => { + const mixedCaseAddress = '0xABCDEFabcdefABCDEFabcdefABCDEFabcdefABCD'; + const asset = makeAsset(mixedCaseAddress, MIN_OCCURRENCES); + mockHandleFetch.mockResolvedValue([asset]); + + const result = await fetchVerifiedTokensByAddresses(MOCK_CHAIN_ID, [ + mixedCaseAddress, + ]); + + expect(result.has(mixedCaseAddress.toLowerCase())).toBe(true); + }); + }); +}); 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..09cdcb9be52 --- /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 (): Promise => { + 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; +}