diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index b55cb0fb20d..33f90623757 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -37,8 +37,33 @@ 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 { fetchVerifiedTokensByAddresses } from './tokens-api-v3'; +import type { TokenV3Asset } from './tokens-api-v3'; + +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 + >; import { TokenDetectionController, controllerName, @@ -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,8 +1258,16 @@ describe('TokenDetectionController', () => { timestamp: 0, }, }, - }); - + }, + }, + async ({ + mockGetAccount, + triggerSelectedAccountChange, + triggerPreferencesStateChange, + callActionSpy, + }) => { + mockGetAccount(firstSelectedAccount); + triggerPreferencesStateChange({ ...getDefaultPreferencesState(), useTokenDetection: false, @@ -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,8 +1617,9 @@ describe('TokenDetectionController', () => { }, }, }, - }); - + }, + }, + async ({ callActionSpy, triggerNetworkDidChange }) => { triggerNetworkDidChange({ ...getDefaultNetworkControllerState(), selectedNetworkClientId: NetworkType.sepolia, @@ -1710,14 +1650,7 @@ describe('TokenDetectionController', () => { getAccount: selectedAccount, getSelectedAccount: selectedAccount, }, - }, - async ({ - mockTokenListGetState, - callActionSpy, - triggerNetworkDidChange, - }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { [ChainId.sepolia]: { data: { @@ -1734,8 +1667,9 @@ describe('TokenDetectionController', () => { timestamp: 0, }, }, - }); - + }, + }, + async ({ callActionSpy, triggerNetworkDidChange }) => { triggerNetworkDidChange({ ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'avalanche', @@ -1768,14 +1702,7 @@ describe('TokenDetectionController', () => { getAccount: selectedAccount, getSelectedAccount: selectedAccount, }, - }, - async ({ - mockTokenListGetState, - callActionSpy, - triggerNetworkDidChange, - }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), + mockTokenListState: { tokensChainsCache: { [ChainId.sepolia]: { data: { @@ -1792,8 +1719,9 @@ describe('TokenDetectionController', () => { timestamp: 0, }, }, - }); - + }, + }, + async ({ callActionSpy, triggerNetworkDidChange }) => { triggerNetworkDidChange({ ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'avalanche', @@ -1826,466 +1754,10 @@ describe('TokenDetectionController', () => { mocks: { getAccount: selectedAccount, getSelectedAccount: selectedAccount, - }, - }, - async ({ - mockTokenListGetState, - callActionSpy, - triggerNetworkDidChange, - }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), - tokensChainsCache: { - [ChainId.sepolia]: { - data: { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }, - timestamp: 0, - }, - }, - }); - - triggerNetworkDidChange({ - ...getDefaultNetworkControllerState(), - selectedNetworkClientId: 'avalanche', - }); - await jestAdvanceTime({ duration: 1 }); - - expect(callActionSpy).not.toHaveBeenCalledWith( - 'TokensController:addDetectedTokens', - ); - }, - ); - }); - }); - }); - - describe('TokenListController:stateChange', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - describe('when "disabled" is false', () => { - it('should detect tokens if the token list is non-empty', async () => { - const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ - [sampleTokenA.address]: new BN(1), - }); - const selectedAccount = createMockInternalAccount({ - address: '0x0000000000000000000000000000000000000001', - }); - await withController( - { - options: { - disabled: false, - getBalancesInSingleCall: mockGetBalancesInSingleCall, - }, - mocks: { - getSelectedAccount: selectedAccount, - getAccount: selectedAccount, - }, - }, - async ({ - mockTokenListGetState, - callActionSpy, - triggerTokenListStateChange, - mockNetworkState, - }) => { - // Set selectedNetworkClientId to avalanche (not in SUPPORTED_NETWORKS_ACCOUNTS_API_V4) - mockNetworkState({ - ...getDefaultNetworkControllerState(), - selectedNetworkClientId: 'avalanche', - }); - const tokenList = { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }; - const tokenListState = { - ...getDefaultTokenListState(), - tokensChainsCache: { - '0xa86a': { - timestamp: 0, - data: tokenList, - }, - }, - }; - mockTokenListGetState(tokenListState); - - triggerTokenListStateChange(tokenListState); - await jestAdvanceTime({ duration: 1 }); - - expect(callActionSpy).toHaveBeenCalledWith( - 'TokensController:addTokens', - [sampleTokenA], - 'avalanche', - ); - }, - ); - }); - - it('should not detect tokens if the token list is empty', async () => { - const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ - [sampleTokenA.address]: new BN(1), - }); - const selectedAccount = createMockInternalAccount({ - address: '0x0000000000000000000000000000000000000001', - }); - await withController( - { - options: { - disabled: false, - getBalancesInSingleCall: mockGetBalancesInSingleCall, - }, - mocks: { - getSelectedAccount: selectedAccount, - getAccount: selectedAccount, - }, - }, - async ({ - mockTokenListGetState, - callActionSpy, - triggerTokenListStateChange, - }) => { - const tokenListState = { - ...getDefaultTokenListState(), - tokensChainsCache: {}, - }; - mockTokenListGetState(tokenListState); - - triggerTokenListStateChange(tokenListState); - await jestAdvanceTime({ duration: 1 }); - - expect(callActionSpy).not.toHaveBeenCalledWith( - 'TokensController:addDetectedTokens', - ); - }, - ); - }); - - describe('when keyring is locked', () => { - it('should not detect tokens', async () => { - const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ - [sampleTokenA.address]: new BN(1), - }); - const selectedAccount = createMockInternalAccount({ - address: '0x0000000000000000000000000000000000000001', - }); - await withController( - { - options: { - disabled: false, - getBalancesInSingleCall: mockGetBalancesInSingleCall, - }, - isKeyringUnlocked: false, - mocks: { - getSelectedAccount: selectedAccount, - getAccount: selectedAccount, - }, - }, - async ({ - mockTokenListGetState, - callActionSpy, - triggerTokenListStateChange, - }) => { - const tokenListState = { - ...getDefaultTokenListState(), - tokensChainsCache: { - [ChainId.sepolia]: { - data: { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }, - timestamp: 0, - }, - }, - }; - mockTokenListGetState(tokenListState); - - triggerTokenListStateChange(tokenListState); - await jestAdvanceTime({ duration: 1 }); - - expect(callActionSpy).not.toHaveBeenCalledWith( - 'TokensController:addDetectedTokens', - ); - }, - ); - }); - }); - }); - - describe('when "disabled" is true', () => { - it('should not detect tokens', async () => { - const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ - [sampleTokenA.address]: new BN(1), - }); - const selectedAccount = createMockInternalAccount({ - address: '0x0000000000000000000000000000000000000001', - }); - await withController( - { - options: { - disabled: true, - getBalancesInSingleCall: mockGetBalancesInSingleCall, - }, - mocks: { - getSelectedAccount: selectedAccount, - getAccount: selectedAccount, - }, - }, - async ({ - mockTokenListGetState, - callActionSpy, - triggerTokenListStateChange, - }) => { - const tokenListState = { - ...getDefaultTokenListState(), - tokensChainsCache: { - [ChainId.sepolia]: { - data: { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }, - timestamp: 0, - }, - }, - }; - mockTokenListGetState(tokenListState); - - triggerTokenListStateChange(tokenListState); - await jestAdvanceTime({ duration: 1 }); - - expect(callActionSpy).not.toHaveBeenCalledWith( - 'TokensController:addDetectedTokens', - ); - }, - ); - }); - }); - - describe('when previous and incoming tokensChainsCache are equal with the same timestamp', () => { - it('should not call detect tokens', async () => { - const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ - [sampleTokenA.address]: new BN(1), - }); - const selectedAccount = createMockInternalAccount({ - address: '0x0000000000000000000000000000000000000001', - }); - await withController( - { - options: { - disabled: false, - getBalancesInSingleCall: mockGetBalancesInSingleCall, - }, - mocks: { - getSelectedAccount: selectedAccount, - getAccount: selectedAccount, - }, - }, - async ({ - mockTokenListGetState, - triggerTokenListStateChange, - controller, - }) => { - const tokenListState = { - ...getDefaultTokenListState(), - tokensChainsCache: { - [ChainId.sepolia]: { - data: { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }, - timestamp: 0, - }, - }, - }; - mockTokenListGetState(tokenListState); - // This should set the tokensChainsCache value - triggerTokenListStateChange(tokenListState); - await jestAdvanceTime({ duration: 1 }); - - const mockTokens = jest.spyOn(controller, 'detectTokens'); - - // Re-trigger state change so that incoming list is equal the current list in state - triggerTokenListStateChange(tokenListState); - await jestAdvanceTime({ duration: 1 }); - expect(mockTokens).toHaveBeenCalledTimes(0); - }, - ); - }); - }); - - describe('when previous and incoming tokensChainsCache are equal with different timestamp', () => { - it('should not call detect tokens', async () => { - const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ - [sampleTokenA.address]: new BN(1), - }); - const selectedAccount = createMockInternalAccount({ - address: '0x0000000000000000000000000000000000000001', - }); - await withController( - { - options: { - disabled: false, - getBalancesInSingleCall: mockGetBalancesInSingleCall, - }, - mocks: { - getSelectedAccount: selectedAccount, - getAccount: selectedAccount, - }, - }, - async ({ - mockTokenListGetState, - triggerTokenListStateChange, - controller, - }) => { - const tokenListState = { - ...getDefaultTokenListState(), - tokensChainsCache: { - [ChainId.sepolia]: { - data: { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }, - timestamp: 0, - }, - }, - }; - mockTokenListGetState(tokenListState); - // This should set the tokensChainsCache value - triggerTokenListStateChange(tokenListState); - await jestAdvanceTime({ duration: 1 }); - - const mockTokens = jest.spyOn(controller, 'detectTokens'); - - // Re-trigger state change so that incoming list is equal the current list in state - triggerTokenListStateChange({ - ...tokenListState, - tokensChainsCache: { - [ChainId.sepolia]: { - data: { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }, - timestamp: 3424, // same list with different timestamp should not trigger detectTokens again - }, - }, - }); - await jestAdvanceTime({ duration: 1 }); - expect(mockTokens).toHaveBeenCalledTimes(0); - }, - ); - }); - }); - - describe('when previous and incoming tokensChainsCache are not equal', () => { - it('should call detect tokens', async () => { - const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ - [sampleTokenA.address]: new BN(1), - }); - const selectedAccount = createMockInternalAccount({ - address: '0x0000000000000000000000000000000000000001', - }); - await withController( - { - options: { - disabled: false, - getBalancesInSingleCall: mockGetBalancesInSingleCall, - }, - mocks: { - getSelectedAccount: selectedAccount, - getAccount: selectedAccount, - }, - }, - async ({ - mockTokenListGetState, - triggerTokenListStateChange, - controller, - }) => { - const tokenListState = { - ...getDefaultTokenListState(), - tokensChainsCache: { - [ChainId.sepolia]: { - data: { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }, - timestamp: 0, - }, - }, - }; - mockTokenListGetState(tokenListState); - // This should set the tokensChainsCache value - triggerTokenListStateChange(tokenListState); - await jestAdvanceTime({ duration: 1 }); - - const mockTokens = jest.spyOn(controller, 'detectTokens'); - - // Re-trigger state change so that incoming list is equal the current list in state - triggerTokenListStateChange({ - ...tokenListState, + }, + 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(); @@ -3384,6 +2847,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 +2968,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 +3059,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 +3128,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 +3200,19 @@ describe('TokenDetectionController', () => { }, }, async ({ controller, callActionSpy }) => { + const verifiedTokens = new Map(); + verifiedTokens.set('0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', { + assetId: + 'eip155:43114/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + decimals: 6, + iconUrl: 'https://example.com/usdc.png', + name: 'USD Coin', + symbol: 'USDC', + occurrences: 11, + aggregators: [], + }); + mockFetchVerifiedTokensByAddresses.mockResolvedValue(verifiedTokens); + await controller.addDetectedTokensViaPolling({ tokensSlice: [mockTokenAddress], chainId: chainId as Hex, @@ -3896,28 +3434,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 +3574,19 @@ describe('TokenDetectionController', () => { }, }, async ({ controller, callActionSpy }) => { + const verifiedTokens = new Map(); + verifiedTokens.set('0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c', { + assetId: + 'eip155:43114/erc20:0x1f573d6fb3f13d689ff844b4ce37794d79a7ff1c', + decimals: 18, + iconUrl: 'https://example.com/bnt.png', + name: 'Bancor', + symbol: 'BNT', + occurrences: 11, + aggregators: [], + }); + mockFetchVerifiedTokensByAddresses.mockResolvedValue(verifiedTokens); + await controller.addDetectedTokensViaPolling({ tokensSlice: [ trackedTokenAddress, @@ -4066,7 +3627,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 +3637,6 @@ type WithControllerCallback = ({ mockGetSelectedAccount, mockKeyringGetState, mockTokensGetState, - mockTokenListGetState, mockPreferencesGetState, mockGetNetworkClientById, mockGetNetworkConfigurationByNetworkClientId, @@ -4084,7 +3644,6 @@ type WithControllerCallback = ({ callActionSpy, triggerKeyringUnlock, triggerKeyringLock, - triggerTokenListStateChange, triggerPreferencesStateChange, triggerSelectedAccountChange, triggerNetworkDidChange, @@ -4095,7 +3654,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 +3670,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 +3783,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 +3861,6 @@ async function withController( mockPreferencesGetState: (state: PreferencesState) => { mockPreferencesState.mockReturnValue(state); }, - mockTokenListGetState: (state: TokenListState) => { - mockTokenListStateFunc.mockReturnValue(state); - }, mockGetNetworkClientById: ( handler: ( networkClientId: NetworkClientId, @@ -4333,9 +3890,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..5780ba3f0ff 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 }) => { @@ -448,30 +430,6 @@ export class TokenDetectionController extends StaticIntervalPollingController { const { allTokens, allDetectedTokens, allIgnoredTokens } = this.messenger.call('TokensController:getState'); const [tokensAddresses, detectedTokensAddresses, ignoredTokensAddresses] = [ @@ -661,10 +607,10 @@ export class TokenDetectionController extends StaticIntervalPollingController( (acc, [key, value]) => ({ ...acc, [key]: { @@ -698,18 +644,46 @@ export class TokenDetectionController extends StaticIntervalPollingController { + const cached = this.#tokenListCache.get(chainId); + const now = Date.now(); + + if ( + cached && + now - cached.timestamp < TokenDetectionController.#tokenListCacheMaxAge + ) { + return cached.data; + } + + const isMainnetDetectionInactive = + !this.#isDetectionEnabledFromPreferences && chainId === ChainId.mainnet; + + if (isMainnetDetectionInactive) { + const data = this.#getStaticMainnetTokenList(); + this.#tokenListCache.set(chainId, { data, timestamp: now }); + return data; + } + + const 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 +704,17 @@ export class TokenDetectionController extends StaticIntervalPollingController address.toLowerCase()); + const addressesToFetch = tokensSlice.filter((address) => { + const lower = address.toLowerCase(); + // Skip tokens already in allTokens + // Skip tokens in allIgnoredTokens + return ( + !existingTokenAddresses.includes(lower) && + !ignoredTokenAddresses.includes(lower) + ); + }); + + if (addressesToFetch.length === 0) { + return; + } + + const verifiedTokens = await fetchVerifiedTokensByAddresses( + chainId, + addressesToFetch, + ); + const tokensWithBalance: Token[] = []; const eventTokensDetails: string[] = []; - for (const tokenAddress of tokensSlice) { + for (const tokenAddress of addressesToFetch) { const lowercaseTokenAddress = tokenAddress.toLowerCase(); const checksummedTokenAddress = toChecksumHexAddress(tokenAddress); - // Skip tokens already in allTokens - if (existingTokenAddresses.includes(lowercaseTokenAddress)) { - continue; - } - - // Skip tokens in allIgnoredTokens - if (ignoredTokenAddresses.includes(lowercaseTokenAddress)) { - continue; - } - - // Check map of validated tokens (cache keys are lowercase) - const tokenData = - this.#tokensChainsCache[chainId]?.data?.[lowercaseTokenAddress]; - + const tokenData = verifiedTokens.get(lowercaseTokenAddress); if (!tokenData) { continue; } @@ -995,6 +971,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' }); }); }); }); @@ -3934,7 +3927,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.ts b/packages/assets-controllers/src/token-service.ts index 02464f15560..f3260ce3045 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.ts b/packages/assets-controllers/src/tokens-api-v3.ts new file mode 100644 index 00000000000..0a1a3d42c9a --- /dev/null +++ b/packages/assets-controllers/src/tokens-api-v3.ts @@ -0,0 +1,138 @@ +import { convertHexToDecimal, handleFetch } from '@metamask/controller-utils'; +import type { Hex } from '@metamask/utils'; + +import type { TokenRwaData } from './token-service'; + +export const TOKENS_API_V3_BASE_URL = 'https://tokens.api.cx.metamask.io/v3'; + +/** + * Maximum number of asset IDs per API request. Requests with more IDs are + * split into parallel batches of this size. + */ +export const MAX_BATCH_SIZE = 25; + +/** + * The minimum number of occurrences (aggregator listings) a token must have to + * be considered verified. Tokens below this threshold are treated as potential + * spam and excluded from detection results. + */ +export const MIN_OCCURRENCES = 3; + +export type TokenV3Asset = { + assetId: string; + decimals: number; + iconUrl: string; + name: string; + symbol: string; + occurrences: number; + aggregators?: string[]; + rwaData?: TokenRwaData; +}; + +// In-flight deduplication so parallel callers for the same batch share a +// single HTTP request. +const inFlight = new Map>(); + +/** + * Fetch a single batch of token metadata from the v3 API. + * + * @param assetIds - CAIP-19 asset IDs for this batch (max {@link MAX_BATCH_SIZE}). + * @returns Resolved token assets returned by the API. + */ +async function fetchTokenBatch(assetIds: string[]): Promise { + const key = assetIds.join(','); + + const existing = inFlight.get(key); + if (existing) { + return existing; + } + + const params = new URLSearchParams({ + assetIds: assetIds.join(','), + includeOccurrences: 'true', + includeIconUrl: 'true', + includeAggregators: 'true', + includeRwaData: 'true', + }); + + const promise = (async () => { + try { + const data = (await handleFetch( + `${TOKENS_API_V3_BASE_URL}/assets?${params}`, + )) as TokenV3Asset[]; + return Array.isArray(data) ? data : []; + } finally { + inFlight.delete(key); + } + })(); + + inFlight.set(key, promise); + return promise; +} + +/** + * Fetch token metadata from the v3 tokens API for the given asset IDs, + * splitting large inputs into parallel batches of at most {@link MAX_BATCH_SIZE}. + * + * @param assetIds - CAIP-19 asset IDs to fetch. + * @returns All resolved token assets across all batches. + */ +async function fetchTokenAssets(assetIds: string[]): Promise { + const batches: string[][] = []; + for (let i = 0; i < assetIds.length; i += MAX_BATCH_SIZE) { + batches.push(assetIds.slice(i, i + MAX_BATCH_SIZE)); + } + const results = await Promise.all(batches.map(fetchTokenBatch)); + return results.flat(); +} + +/** + * Build a CAIP-19 ERC-20 asset ID from a chain ID and a token address. + * + * @param chainId - Hex chain ID (e.g. `0x1`). + * @param tokenAddress - ERC-20 contract address (any casing). + * @returns CAIP-19 asset ID string, e.g. `eip155:1/erc20:0xabc...`. + */ +export function buildCaipAssetId(chainId: Hex, tokenAddress: string): string { + const decimalChainId = convertHexToDecimal(chainId); + return `eip155:${decimalChainId}/erc20:${tokenAddress.toLowerCase()}`; +} + +/** + * Fetch token metadata for the given ERC-20 addresses on a specific chain, + * filtering out tokens that do not meet the minimum occurrences threshold + * (spam filter). + * + * Results are keyed by lowercase token address for easy lookup. + * + * @param chainId - Hex chain ID. + * @param tokenAddresses - ERC-20 token addresses to look up. + * @returns Map from lowercase token address to verified token asset data. + */ +export async function fetchVerifiedTokensByAddresses( + chainId: Hex, + tokenAddresses: string[], +): Promise> { + if (tokenAddresses.length === 0) { + return new Map(); + } + + const assetIds = tokenAddresses.map((address) => + buildCaipAssetId(chainId, address), + ); + + const assets = await fetchTokenAssets(assetIds); + + const result = new Map(); + for (const asset of assets) { + if (asset.occurrences >= MIN_OCCURRENCES) { + // Extract the address part from the CAIP-19 ID: "eip155:1/erc20:0xabc" → "0xabc" + const address = asset.assetId.split('/erc20:')[1]?.toLowerCase(); + if (address) { + result.set(address, asset); + } + } + } + + return result; +}