diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index f4b0f3c715b..936b0ce48cd 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `forceRefreshTokenList()` method to `TokenListController` to allow manual refresh of token cache ([#39628](https://github.com/MetaMask/metamask-extension/issues/39628)) + - Add `TokenListController:forceRefreshTokenList` messenger action + - Method bypasses cache validation to fetch fresh token data from API + - Useful for refreshing stale token data on Base chain and other supported networks - Add `HYPEREVM` support ([#7790](https://github.com/MetaMask/core/pull/7790)) - Add `HYPEREVM` in `SupportedTokenDetectionNetworks` - Add `HYPEREVM` in `SUPPORTED_NETWORKS_ACCOUNTS_API_V4` diff --git a/packages/assets-controllers/src/TokenListController.test.ts b/packages/assets-controllers/src/TokenListController.test.ts index 3f557dfe36c..cc65cd80f8d 100644 --- a/packages/assets-controllers/src/TokenListController.test.ts +++ b/packages/assets-controllers/src/TokenListController.test.ts @@ -2117,6 +2117,184 @@ describe('TokenListController', () => { controller.destroy(); }); }); + + describe('forceRefreshTokenList', () => { + it('should force refresh token list even when cache is valid', async () => { + nock(tokenService.TOKEN_END_POINT_API) + .get(getTokensPath(ChainId.mainnet)) + .reply(200, sampleMainnetTokenList) + .persist(); + + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + cacheRefreshThreshold: 4 * 60 * 60 * 1000, // 4 hours + }); + await controller.initialize(); + + // First fetch to populate cache + await controller.fetchTokenList(ChainId.mainnet); + const initialTimestamp = + controller.state.tokensChainsCache[ChainId.mainnet]?.timestamp; + expect(initialTimestamp).toBeDefined(); + + // Wait a bit to ensure timestamp would be different + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Force refresh - should bypass cache validation + await controller.forceRefreshTokenList(ChainId.mainnet); + + const refreshedTimestamp = + controller.state.tokensChainsCache[ChainId.mainnet]?.timestamp; + expect(refreshedTimestamp).toBeDefined(); + expect(refreshedTimestamp).toBeGreaterThan(initialTimestamp as number); + + // Verify data is still correct + expect( + controller.state.tokensChainsCache[ChainId.mainnet].data, + ).toStrictEqual( + sampleSingleChainState.tokensChainsCache[ChainId.mainnet].data, + ); + + controller.destroy(); + }); + + it('should work via messenger action', async () => { + nock(tokenService.TOKEN_END_POINT_API) + .get(getTokensPath(ChainId.mainnet)) + .reply(200, sampleMainnetTokenList) + .persist(); + + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + }); + await controller.initialize(); + + // First fetch to populate cache + await controller.fetchTokenList(ChainId.mainnet); + const initialTimestamp = + controller.state.tokensChainsCache[ChainId.mainnet]?.timestamp; + expect(initialTimestamp).toBeDefined(); + + // Wait a bit to ensure timestamp would be different + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Force refresh via messenger action + await messenger.call('TokenListController:forceRefreshTokenList', ChainId.mainnet); + + const refreshedTimestamp = + controller.state.tokensChainsCache[ChainId.mainnet]?.timestamp; + expect(refreshedTimestamp).toBeDefined(); + expect(refreshedTimestamp).toBeGreaterThan(initialTimestamp as number); + + controller.destroy(); + }); + + it('should not refresh for unsupported networks', async () => { + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + }); + await controller.initialize(); + + const unsupportedChainId = '0x1337' as Hex; + const initialState = { ...controller.state.tokensChainsCache }; + + // Should return early without error + await controller.forceRefreshTokenList(unsupportedChainId); + + // State should remain unchanged + expect(controller.state.tokensChainsCache).toStrictEqual(initialState); + + controller.destroy(); + }); + + it('should handle API errors gracefully', async () => { + nock(tokenService.TOKEN_END_POINT_API) + .get(getTokensPath(ChainId.mainnet)) + .reply(500, { error: 'Internal Server Error' }) + .persist(); + + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + state: { + tokensChainsCache: { + [ChainId.mainnet]: { + data: sampleMainnetTokensChainsCache, + timestamp: Date.now(), + }, + }, + }, + }); + await controller.initialize(); + + const existingCache = controller.state.tokensChainsCache[ChainId.mainnet]; + expect(existingCache).toBeDefined(); + + // Force refresh should handle error gracefully + await controller.forceRefreshTokenList(ChainId.mainnet); + + // Cache should remain unchanged on error + expect(controller.state.tokensChainsCache[ChainId.mainnet]).toStrictEqual( + existingCache, + ); + + controller.destroy(); + }); + + it('should refresh Base chain (0x2105) token list', async () => { + const baseChainId = '0x2105' as Hex; + const baseTokenList = [ + { + address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + symbol: 'USDC', + decimals: 6, + occurrences: 5, + name: 'USD Coin', + aggregators: ['CoinGecko', '1inch'], + iconUrl: `https://static.cx.metamask.io/api/v1/tokenIcons/${convertHexToDecimal(baseChainId)}/0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913.png`, + }, + ]; + + nock(tokenService.TOKEN_END_POINT_API) + .get(getTokensPath(baseChainId)) + .reply(200, baseTokenList) + .persist(); + + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + const controller = new TokenListController({ + chainId: baseChainId, + messenger: restrictedMessenger, + cacheRefreshThreshold: 4 * 60 * 60 * 1000, // 4 hours + }); + await controller.initialize(); + + // Force refresh Base chain + await controller.forceRefreshTokenList(baseChainId); + + const cache = controller.state.tokensChainsCache[baseChainId]; + expect(cache).toBeDefined(); + expect(cache?.data).toBeDefined(); + expect(cache?.timestamp).toBeDefined(); + expect(cache?.data['0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913']).toBeDefined(); + expect( + cache?.data['0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'].symbol, + ).toBe('USDC'); + + controller.destroy(); + }); + }); }); /** diff --git a/packages/assets-controllers/src/TokenListController.ts b/packages/assets-controllers/src/TokenListController.ts index 78431a25a81..04310826bfa 100644 --- a/packages/assets-controllers/src/TokenListController.ts +++ b/packages/assets-controllers/src/TokenListController.ts @@ -68,7 +68,12 @@ export type GetTokenListState = ControllerGetStateAction< TokenListState >; -export type TokenListControllerActions = GetTokenListState; +export type ForceRefreshTokenList = { + type: `${typeof name}:forceRefreshTokenList`; + handler: (chainId: Hex) => Promise; +}; + +export type TokenListControllerActions = GetTokenListState | ForceRefreshTokenList; type AllowedActions = | NetworkControllerGetNetworkClientByIdAction @@ -134,6 +139,12 @@ export class TokenListController extends StaticIntervalPollingController this.#onCacheChanged(newCache), (controllerState) => controllerState.tokensChainsCache, ); + + // Register action handlers + this.messenger.registerActionHandler( + `${name}:forceRefreshTokenList`, + (chainId: Hex) => this.forceRefreshTokenList(chainId), + ); + + this.#isInitialized = true; } /** @@ -521,6 +540,11 @@ export class TokenListController extends StaticIntervalPollingController { - if (this.isCacheValid(chainId)) { - return; - } - + async #fetchAndUpdateTokenList(chainId: Hex): Promise { // Fetch fresh token list from the API const tokensFromAPI = await safelyExecute( () => @@ -612,17 +633,29 @@ export class TokenListController extends StaticIntervalPollingController { - state.tokensChainsCache[chainId] = newDataCache; - }); - } - // If there's existing cache, keep it as-is (don't update timestamp or persist) + const existingCache = this.state.tokensChainsCache[chainId]; + if (!existingCache) { + // No existing cache - initialize empty (persistence happens automatically) + const newDataCache: DataCache = { data: {}, timestamp: Date.now() }; + this.update((state) => { + state.tokensChainsCache[chainId] = newDataCache; + }); + } + // If there's existing cache, keep it as-is (don't update timestamp or persist) + } + + /** + * Fetching token list from the Token Service API. This will fetch tokens across chains. + * State changes are automatically persisted via the stateChange subscription. + * + * @param chainId - The chainId of the current chain triggering the fetch. + */ + async fetchTokenList(chainId: Hex): Promise { + if (this.isCacheValid(chainId)) { + return; } + + await this.#fetchAndUpdateTokenList(chainId); } isCacheValid(chainId: Hex): boolean { @@ -633,6 +666,21 @@ export class TokenListController extends StaticIntervalPollingController { + if (!isTokenListSupportedForNetwork(chainId)) { + return; + } + + await this.#fetchAndUpdateTokenList(chainId); + } } export default TokenListController;