diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 72db8441213..20d89d97038 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -348,7 +348,7 @@ }, "packages/assets-controllers/src/TokenListController.test.ts": { "@typescript-eslint/explicit-function-return-type": { - "count": 2 + "count": 1 }, "id-denylist": { "count": 2 @@ -357,14 +357,6 @@ "count": 7 } }, - "packages/assets-controllers/src/TokenListController.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 1 - }, - "no-restricted-syntax": { - "count": 7 - } - }, "packages/assets-controllers/src/TokenRatesController.test.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 4 diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index e36f0303c5a..613ddbd3b20 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -14,6 +14,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** `TokenListController` now persists `tokensChainsCache` via `StorageService` and requires clients to call `initialize()` after construction ([#7413](https://github.com/MetaMask/core/pull/7413)) + - Each chain's token cache is stored in a separate file, reducing write amplification + - All chains are loaded in parallel at startup to maintain compatibility with TokenDetectionController + - `tokensChainsCache` state metadata now has `persist: false` to prevent duplicate persistence + - Clients must call `await controller.initialize()` before using the controller + - State changes are automatically persisted via debounced subscription +- Bump `@metamask/transaction-controller` from `^62.8.0` to `^62.9.0` ([#7602](https://github.com/MetaMask/core/pull/7602)) - Bump `@metamask/transaction-controller` from `^62.8.0` to `^62.9.1` ([#7602](https://github.com/MetaMask/core/pull/7602), [#7604](https://github.com/MetaMask/core/pull/7604)) - Bump `@metamask/network-controller` from `^27.2.0` to `^28.0.0` ([#7604](https://github.com/MetaMask/core/pull/7604)) - Bump `@metamask/accounts-controller` from `^35.0.0` to `^35.0.1` ([#7604](https://github.com/MetaMask/core/pull/7604)) diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index f6a7dd4c82a..c325b3ebba7 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -78,6 +78,7 @@ "@metamask/snaps-controllers": "^17.2.0", "@metamask/snaps-sdk": "^10.3.0", "@metamask/snaps-utils": "^11.7.0", + "@metamask/storage-service": "^0.0.1", "@metamask/transaction-controller": "^62.9.1", "@metamask/utils": "^11.9.0", "@types/bn.js": "^5.1.5", diff --git a/packages/assets-controllers/src/TokenListController.test.ts b/packages/assets-controllers/src/TokenListController.test.ts index 1db82bd2ed4..44166b0c692 100644 --- a/packages/assets-controllers/src/TokenListController.test.ts +++ b/packages/assets-controllers/src/TokenListController.test.ts @@ -22,6 +22,7 @@ import type { TokenListMap, TokenListState, TokenListControllerMessenger, + DataCache, } from './TokenListController'; import { TokenListController } from './TokenListController'; import { advanceTime } from '../../../tests/helpers'; @@ -478,8 +479,59 @@ type RootMessenger = Messenger< AllTokenListControllerEvents >; +// Mock storage for StorageService +const mockStorage = new Map(); + const getMessenger = (): RootMessenger => { - return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); + const messenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE }); + + // Register StorageService mock handlers + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messenger as any).registerActionHandler( + 'StorageService:getItem', + (controllerNamespace: string, key: string) => { + const storageKey = `${controllerNamespace}:${key}`; + const value = mockStorage.get(storageKey); + return value ? { result: value } : {}; + }, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messenger as any).registerActionHandler( + 'StorageService:setItem', + (controllerNamespace: string, key: string, value: unknown) => { + const storageKey = `${controllerNamespace}:${key}`; + mockStorage.set(storageKey, value); + }, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messenger as any).registerActionHandler( + 'StorageService:removeItem', + (controllerNamespace: string, key: string) => { + const storageKey = `${controllerNamespace}:${key}`; + mockStorage.delete(storageKey); + }, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messenger as any).registerActionHandler( + 'StorageService:getAllKeys', + (controllerNamespace: string) => { + const keys: string[] = []; + const prefix = `${controllerNamespace}:`; + mockStorage.forEach((_value, key) => { + // Only include keys for this namespace + if (key.startsWith(prefix)) { + const keyWithoutNamespace = key.substring(prefix.length); + keys.push(keyWithoutNamespace); + } + }); + return keys; + }, + ); + + return messenger; }; const getRestrictedMessenger = ( @@ -496,13 +548,24 @@ const getRestrictedMessenger = ( }); messenger.delegate({ messenger: tokenListControllerMessenger, - actions: ['NetworkController:getNetworkClientById'], + actions: [ + 'NetworkController:getNetworkClientById', + 'StorageService:getItem', + 'StorageService:setItem', + 'StorageService:removeItem', + 'StorageService:getAllKeys', + ], events: ['NetworkController:stateChange'], }); return tokenListControllerMessenger; }; describe('TokenListController', () => { + beforeEach(() => { + // Clear mock storage between tests + mockStorage.clear(); + }); + afterEach(() => { jest.clearAllTimers(); sinon.restore(); @@ -1069,7 +1132,7 @@ describe('TokenListController', () => { state: existingState, }); expect(controller.state).toStrictEqual(existingState); - controller.clearingTokenListData(); + await controller.clearingTokenListData(); expect(controller.state.tokensChainsCache).toStrictEqual({}); @@ -1135,6 +1198,96 @@ describe('TokenListController', () => { }); }); + it('should handle errors when clearing data on network change', async () => { + // Create messenger where getAllKeys throws during network change + const messenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + // Register getAllKeys to throw error + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messenger as any).registerActionHandler( + 'StorageService:getAllKeys', + () => { + throw new Error('Failed to get keys during network change'); + }, + ); + + // Register other handlers + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messenger as any).registerActionHandler( + 'StorageService:getItem', + () => ({}), + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messenger as any).registerActionHandler( + 'StorageService:setItem', + (_controllerNamespace: string, _key: string, _value: unknown) => { + // Do nothing - testing error path + }, + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messenger as any).registerActionHandler( + 'StorageService:removeItem', + (_controllerNamespace: string, _key: string) => { + // Do nothing - testing error path + }, + ); + + const getNetworkClientById = buildMockGetNetworkClientById({ + [InfuraNetworkType.mainnet]: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.mainnet, + ), + [InfuraNetworkType.sepolia]: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.sepolia, + ), + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messenger as any).registerActionHandler( + 'NetworkController:getNetworkClientById', + getNetworkClientById, + ); + + const restrictedMessenger = getRestrictedMessenger(messenger); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + preventPollingOnNetworkRestart: true, + messenger: restrictedMessenger, + }); + + // Initialize the controller + await controller.initialize(); + + // Mock console.error to verify error handling + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + // Trigger network change (should try to clear data and catch error) + // Using type assertion since we're testing with a minimal messenger + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messenger as any).publish( + 'NetworkController:stateChange', + { + selectedNetworkClientId: InfuraNetworkType.sepolia, + networkConfigurationsByChainId: {}, + networksMetadata: {}, + }, + [], + ); + + // Wait for async error handling + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify error was logged from clearingTokenListData's internal error handling + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'TokenListController: Failed to clear cache from storage:', + expect.any(Error), + ); + + consoleErrorSpy.mockRestore(); + controller.destroy(); + }); + describe('startPolling', () => { let clock: sinon.SinonFakeTimers; const pollingIntervalTime = 1000; @@ -1331,7 +1484,6 @@ describe('TokenListController', () => { ).toMatchInlineSnapshot(` Object { "preventPollingOnNetworkRestart": false, - "tokensChainsCache": Object {}, } `); }); @@ -1355,6 +1507,927 @@ describe('TokenListController', () => { `); }); }); + + describe('StorageService migration', () => { + // State changes after construction trigger debounced persistence + it('should persist state changes to StorageService via debounced subscription', async () => { + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + }); + + // Initialize the controller + await controller.initialize(); + + // Fetch tokens to trigger state change (which triggers persistence) + nock(tokenService.TOKEN_END_POINT_API) + .get(getTokensPath(ChainId.mainnet)) + .reply(200, sampleMainnetTokenList); + + await controller.fetchTokenList(ChainId.mainnet); + + // Wait for debounced persistence to complete (500ms + buffer) + await new Promise((resolve) => setTimeout(resolve, 600)); + + const chainStorageKey = `tokensChainsCache:${ChainId.mainnet}`; + const { result } = await messenger.call( + 'StorageService:getItem', + 'TokenListController', + chainStorageKey, + ); + + expect(result).toBeDefined(); + const resultCache = result as DataCache; + expect(resultCache.data).toBeDefined(); + expect(resultCache.timestamp).toBeDefined(); + + controller.destroy(); + }); + + it('should not overwrite StorageService if it already has data', async () => { + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + + // Pre-populate StorageService with existing data (per-chain file) + const existingChainData: DataCache = { + data: sampleMainnetTokensChainsCache, + timestamp: Date.now(), + }; + const chainStorageKey = `tokensChainsCache:${ChainId.mainnet}`; + await messenger.call( + 'StorageService:setItem', + 'TokenListController', + chainStorageKey, + existingChainData, + ); + + // Initialize with different state data + const stateWithDifferentData = { + tokensChainsCache: { + [ChainId.mainnet]: { + data: sampleMainnetTokensChainsCache, + timestamp: Date.now(), + }, + }, + preventPollingOnNetworkRestart: false, + }; + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + state: stateWithDifferentData, + }); + + // Initialize the controller to trigger storage migration logic + await controller.initialize(); + + // Verify StorageService still has original data (not overwritten) + const { result } = await messenger.call( + 'StorageService:getItem', + 'TokenListController', + chainStorageKey, + ); + + expect(result).toStrictEqual(existingChainData); + const resultCache = result as DataCache; + expect(resultCache.data).toStrictEqual(existingChainData.data); + + controller.destroy(); + }); + + it('should not migrate when state has empty tokensChainsCache', async () => { + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + state: { tokensChainsCache: {}, preventPollingOnNetworkRestart: false }, + }); + + // Initialize the controller to trigger migration logic + await controller.initialize(); + + // Verify nothing was saved to StorageService (check no per-chain files) + const allKeys = await messenger.call( + 'StorageService:getAllKeys', + 'TokenListController', + ); + const cacheKeys = allKeys.filter((key) => + key.startsWith('tokensChainsCache:'), + ); + + expect(cacheKeys).toHaveLength(0); + + controller.destroy(); + }); + + it('should save and load tokensChainsCache from StorageService', async () => { + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + + // Create controller and fetch tokens (which saves to storage) + const controller1 = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + }); + + nock(tokenService.TOKEN_END_POINT_API) + .get(getTokensPath(ChainId.mainnet)) + .reply(200, sampleMainnetTokenList); + + await controller1.fetchTokenList(ChainId.mainnet); + const savedCache = controller1.state.tokensChainsCache; + + // Wait for debounced persistence to complete (500ms + buffer) + await new Promise((resolve) => setTimeout(resolve, 600)); + + controller1.destroy(); + + // Verify data is in StorageService (per-chain file) + const chainStorageKey = `tokensChainsCache:${ChainId.mainnet}`; + const { result } = await messenger.call( + 'StorageService:getItem', + 'TokenListController', + chainStorageKey, + ); + + expect(result).toBeDefined(); + expect(result).toStrictEqual(savedCache[ChainId.mainnet]); + }); + + it('should save tokensChainsCache to StorageService when fetching tokens', async () => { + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + + nock(tokenService.TOKEN_END_POINT_API) + .get(getTokensPath(ChainId.mainnet)) + .reply(200, sampleMainnetTokenList); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + }); + + await controller.fetchTokenList(ChainId.mainnet); + + // Wait for debounced persistence to complete (500ms + buffer) + await new Promise((resolve) => setTimeout(resolve, 600)); + + // Verify data was saved to StorageService (per-chain file) + const chainStorageKey = `tokensChainsCache:${ChainId.mainnet}`; + const { result } = await messenger.call( + 'StorageService:getItem', + 'TokenListController', + chainStorageKey, + ); + + expect(result).toBeDefined(); + const resultCache = result as DataCache; + expect(resultCache.data).toBeDefined(); + expect(resultCache.timestamp).toBeDefined(); + + controller.destroy(); + }); + + it('should clear tokensChainsCache from StorageService when clearing data', async () => { + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + + // Pre-populate StorageService (per-chain file) + const chainData: DataCache = { + data: sampleMainnetTokensChainsCache, + timestamp: Date.now(), + }; + const chainStorageKey = `tokensChainsCache:${ChainId.mainnet}`; + await messenger.call( + 'StorageService:setItem', + 'TokenListController', + chainStorageKey, + chainData, + ); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + state: { + tokensChainsCache: { + [ChainId.mainnet]: chainData, + }, + preventPollingOnNetworkRestart: false, + }, + }); + + // Wait a bit for async initialization to complete + await new Promise((resolve) => setTimeout(resolve, 50)); + + await controller.clearingTokenListData(); + + // Verify data was removed from StorageService (per-chain file removed) + const allKeys = await messenger.call( + 'StorageService:getAllKeys', + 'TokenListController', + ); + const cacheKeys = allKeys.filter((key) => + key.startsWith('tokensChainsCache:'), + ); + + expect(cacheKeys).toHaveLength(0); + expect(controller.state.tokensChainsCache).toStrictEqual({}); + + controller.destroy(); + }); + + it('should handle errors when loading individual chain cache files', async () => { + // Pre-populate storage with two chains + const validChainData: DataCache = { + data: sampleMainnetTokensChainsCache, + timestamp: Date.now(), + }; + const binanceChainData: DataCache = { + data: sampleBinanceTokensChainsCache, + timestamp: Date.now(), + }; + + mockStorage.set( + `TokenListController:tokensChainsCache:${ChainId.mainnet}`, + validChainData, + ); + mockStorage.set( + `TokenListController:tokensChainsCache:${ChainId.goerli}`, + binanceChainData, + ); + + // Create messenger with getItem that returns error for goerli + const messengerWithErrors = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + // Register getItem handler that returns error for goerli + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messengerWithErrors as any).registerActionHandler( + 'StorageService:getItem', + (controllerNamespace: string, key: string) => { + if (key === `tokensChainsCache:${ChainId.goerli}`) { + return { error: 'Failed to load chain data' }; + } + const storageKey = `${controllerNamespace}:${key}`; + const value = mockStorage.get(storageKey); + return value ? { result: value } : {}; + }, + ); + + // Register other handlers normally + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messengerWithErrors as any).registerActionHandler( + 'StorageService:setItem', + (controllerNamespace: string, key: string, value: unknown) => { + const storageKey = `${controllerNamespace}:${key}`; + mockStorage.set(storageKey, value); + }, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messengerWithErrors as any).registerActionHandler( + 'StorageService:removeItem', + (controllerNamespace: string, key: string) => { + const storageKey = `${controllerNamespace}:${key}`; + mockStorage.delete(storageKey); + }, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messengerWithErrors as any).registerActionHandler( + 'StorageService:getAllKeys', + (controllerNamespace: string) => { + const keys: string[] = []; + const prefix = `${controllerNamespace}:`; + mockStorage.forEach((_value, key) => { + // Only include keys for this namespace + if (key.startsWith(prefix)) { + const keyWithoutNamespace = key.substring(prefix.length); + keys.push(keyWithoutNamespace); + } + }); + return keys; + }, + ); + + const restrictedMessenger = getRestrictedMessenger(messengerWithErrors); + + // Mock console.error to verify it's called for the error case + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + }); + + // Initialize the controller to load from storage + await controller.initialize(); + + // Verify that mainnet chain loaded successfully + expect(controller.state.tokensChainsCache[ChainId.mainnet]).toBeDefined(); + expect( + controller.state.tokensChainsCache[ChainId.mainnet].data, + ).toStrictEqual(sampleMainnetTokensChainsCache); + + // Verify that goerli chain is not in the cache (due to error) + expect( + controller.state.tokensChainsCache[ChainId.goerli], + ).toBeUndefined(); + + // Verify console.error was called with the error + expect(consoleErrorSpy).toHaveBeenCalledWith( + `TokenListController: Error loading cache for ${ChainId.goerli}:`, + 'Failed to load chain data', + ); + + consoleErrorSpy.mockRestore(); + controller.destroy(); + }); + + it('should handle StorageService errors when saving cache', async () => { + // Create a messenger with setItem that throws errors + const messengerWithErrors = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + // Register all handlers, but make setItem throw + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messengerWithErrors as any).registerActionHandler( + 'StorageService:getItem', + (controllerNamespace: string, key: string) => { + const storageKey = `${controllerNamespace}:${key}`; + const value = mockStorage.get(storageKey); + return value ? { result: value } : {}; + }, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messengerWithErrors as any).registerActionHandler( + 'StorageService:setItem', + () => { + throw new Error('Storage write failed'); + }, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messengerWithErrors as any).registerActionHandler( + 'StorageService:removeItem', + (controllerNamespace: string, key: string) => { + const storageKey = `${controllerNamespace}:${key}`; + mockStorage.delete(storageKey); + }, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messengerWithErrors as any).registerActionHandler( + 'StorageService:getAllKeys', + (controllerNamespace: string) => { + const keys: string[] = []; + const prefix = `${controllerNamespace}:`; + mockStorage.forEach((_value, key) => { + // Only include keys for this namespace + if (key.startsWith(prefix)) { + const keyWithoutNamespace = key.substring(prefix.length); + keys.push(keyWithoutNamespace); + } + }); + return keys; + }, + ); + + const restrictedMessenger = getRestrictedMessenger(messengerWithErrors); + + // Mock console.error to verify it's called for save errors + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + }); + + // Initialize the controller + await controller.initialize(); + + // Try to fetch tokens - this should trigger save which will fail + nock(tokenService.TOKEN_END_POINT_API) + .get(getTokensPath(ChainId.mainnet)) + .reply(200, sampleMainnetTokenList); + + await controller.fetchTokenList(ChainId.mainnet); + + // Wait for debounced persistence to attempt (and fail) + await new Promise((resolve) => setTimeout(resolve, 600)); + + // Verify console.error was called with the save error + expect(consoleErrorSpy).toHaveBeenCalledWith( + `TokenListController: Failed to save cache for ${ChainId.mainnet}:`, + expect.any(Error), + ); + + // Verify state was still updated even though save failed + expect(controller.state.tokensChainsCache[ChainId.mainnet]).toBeDefined(); + + consoleErrorSpy.mockRestore(); + controller.destroy(); + }); + + it('should handle errors during debounced persistence', async () => { + // Create messenger where setItem throws to cause persistence to fail + const messengerWithErrors = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + // Register getItem to return empty + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messengerWithErrors as any).registerActionHandler( + 'StorageService:getItem', + () => { + return {}; + }, + ); + + // Register setItem to throw error + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messengerWithErrors as any).registerActionHandler( + 'StorageService:setItem', + () => { + throw new Error('Failed to save to storage'); + }, + ); + + // Register getAllKeys normally + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messengerWithErrors as any).registerActionHandler( + 'StorageService:getAllKeys', + () => [], + ); + + // Register removeItem (not used in this test but required) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messengerWithErrors as any).registerActionHandler( + 'StorageService:removeItem', + () => { + // Do nothing + }, + ); + + const restrictedMessenger = getRestrictedMessenger(messengerWithErrors); + + // Mock console.error to verify it's called for persistence errors + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + }); + + // Initialize the controller + await controller.initialize(); + + // Fetch tokens to trigger state change (which triggers persistence) + nock(tokenService.TOKEN_END_POINT_API) + .get(getTokensPath(ChainId.mainnet)) + .reply(200, sampleMainnetTokenList); + + await controller.fetchTokenList(ChainId.mainnet); + + // Wait for debounced persistence to attempt (and fail) + await new Promise((resolve) => setTimeout(resolve, 600)); + + // Verify console.error was called with the save error (from #saveChainCacheToStorage) + expect(consoleErrorSpy).toHaveBeenCalledWith( + `TokenListController: Failed to save cache for ${ChainId.mainnet}:`, + expect.any(Error), + ); + + consoleErrorSpy.mockRestore(); + controller.destroy(); + }); + + it('should handle errors when clearing cache from StorageService', async () => { + // Create messenger where getAllKeys throws only during clear + const messengerWithErrors = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + let shouldThrow = false; + // Register getAllKeys to throw error only when flag is set + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messengerWithErrors as any).registerActionHandler( + 'StorageService:getAllKeys', + () => { + if (shouldThrow) { + throw new Error('Failed to get keys'); + } + return []; // Return empty array for initialization + }, + ); + + // Register other handlers + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messengerWithErrors as any).registerActionHandler( + 'StorageService:getItem', + () => ({}), + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messengerWithErrors as any).registerActionHandler( + 'StorageService:setItem', + (_controllerNamespace: string, _key: string, _value: unknown) => { + // Do nothing - testing error path in getAllKeys + }, + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messengerWithErrors as any).registerActionHandler( + 'StorageService:removeItem', + (_controllerNamespace: string, _key: string) => { + // Do nothing - testing error path in getAllKeys + }, + ); + + const restrictedMessenger = getRestrictedMessenger(messengerWithErrors); + + // Initialize controller with pre-populated state + const initialCache = { + [ChainId.mainnet]: { + timestamp: Date.now(), + data: sampleMainnetTokensChainsCache, + }, + }; + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + state: { tokensChainsCache: initialCache }, + }); + + // Initialize the controller + await controller.initialize(); + + // Verify cache exists before clearing + expect( + Object.keys(controller.state.tokensChainsCache).length, + ).toBeGreaterThan(0); + + // Now enable throwing to test error handling during clear + shouldThrow = true; + + // Mock console.error + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + // Try to clear - should catch error but still clear state + await controller.clearingTokenListData(); + + // Verify error was logged + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'TokenListController: Failed to clear cache from storage:', + expect.any(Error), + ); + + // Verify state was still cleared despite the error + // This ensures consistent behavior with the no-keys case + expect(controller.state.tokensChainsCache).toStrictEqual({}); + + consoleErrorSpy.mockRestore(); + controller.destroy(); + }); + + it('should only load cache from storage once even when fetchTokenList is called multiple times', async () => { + // Pre-populate storage with cached data + const chainData: DataCache = { + data: sampleMainnetTokensChainsCache, + timestamp: Date.now(), + }; + mockStorage.set( + `TokenListController:tokensChainsCache:${ChainId.mainnet}`, + chainData, + ); + + // Track how many times getItem is called + let getItemCallCount = 0; + let getAllKeysCallCount = 0; + + const trackingMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (trackingMessenger as any).registerActionHandler( + 'StorageService:getItem', + (controllerNamespace: string, key: string) => { + getItemCallCount += 1; + const storageKey = `${controllerNamespace}:${key}`; + const value = mockStorage.get(storageKey); + return value ? { result: value } : {}; + }, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (trackingMessenger as any).registerActionHandler( + 'StorageService:setItem', + (controllerNamespace: string, key: string, value: unknown) => { + const storageKey = `${controllerNamespace}:${key}`; + mockStorage.set(storageKey, value); + }, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (trackingMessenger as any).registerActionHandler( + 'StorageService:removeItem', + (controllerNamespace: string, key: string) => { + const storageKey = `${controllerNamespace}:${key}`; + mockStorage.delete(storageKey); + }, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (trackingMessenger as any).registerActionHandler( + 'StorageService:getAllKeys', + (controllerNamespace: string) => { + getAllKeysCallCount += 1; + const keys: string[] = []; + const prefix = `${controllerNamespace}:`; + mockStorage.forEach((_value, key) => { + if (key.startsWith(prefix)) { + const keyWithoutNamespace = key.substring(prefix.length); + keys.push(keyWithoutNamespace); + } + }); + return keys; + }, + ); + + const restrictedMessenger = getRestrictedMessenger(trackingMessenger); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + }); + + // Initialize the controller + await controller.initialize(); + + // Record call counts after initialization + const getItemCallsAfterInit = getItemCallCount; + const getAllKeysCallsAfterInit = getAllKeysCallCount; + + // getAllKeys should be called once during init (for loading cache) + expect(getAllKeysCallsAfterInit).toBe(1); + // getItem should be called once for the cached chain during load + expect(getItemCallsAfterInit).toBe(1); + + // Now call fetchTokenList multiple times + nock(tokenService.TOKEN_END_POINT_API) + .get(getTokensPath(ChainId.mainnet)) + .reply(200, sampleMainnetTokenList) + .persist(); + + await controller.fetchTokenList(ChainId.mainnet); + await controller.fetchTokenList(ChainId.mainnet); + await controller.fetchTokenList(ChainId.mainnet); + + // Verify getAllKeys was NOT called again after initialization + // (getItem may be called for other reasons, but getAllKeys is only used in load/migrate) + expect(getAllKeysCallCount).toBe(getAllKeysCallsAfterInit); + + controller.destroy(); + }); + + it('should NOT re-persist data loaded from storage during initialization', async () => { + // Pre-populate storage with cached data + const chainData: DataCache = { + data: sampleMainnetTokensChainsCache, + timestamp: Date.now(), + }; + mockStorage.set( + `TokenListController:tokensChainsCache:${ChainId.mainnet}`, + chainData, + ); + + // Track how many times setItem is called + let setItemCallCount = 0; + + const trackingMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (trackingMessenger as any).registerActionHandler( + 'StorageService:getItem', + (controllerNamespace: string, key: string) => { + const storageKey = `${controllerNamespace}:${key}`; + const value = mockStorage.get(storageKey); + return value ? { result: value } : {}; + }, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (trackingMessenger as any).registerActionHandler( + 'StorageService:setItem', + (controllerNamespace: string, key: string, value: unknown) => { + setItemCallCount += 1; + const storageKey = `${controllerNamespace}:${key}`; + mockStorage.set(storageKey, value); + }, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (trackingMessenger as any).registerActionHandler( + 'StorageService:removeItem', + (controllerNamespace: string, key: string) => { + const storageKey = `${controllerNamespace}:${key}`; + mockStorage.delete(storageKey); + }, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (trackingMessenger as any).registerActionHandler( + 'StorageService:getAllKeys', + (controllerNamespace: string) => { + const keys: string[] = []; + const prefix = `${controllerNamespace}:`; + mockStorage.forEach((_value, key) => { + if (key.startsWith(prefix)) { + const keyWithoutNamespace = key.substring(prefix.length); + keys.push(keyWithoutNamespace); + } + }); + return keys; + }, + ); + + const restrictedMessenger = getRestrictedMessenger(trackingMessenger); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + }); + + // Initialize the controller - this should load from storage + await controller.initialize(); + + // Verify data was loaded correctly + expect(controller.state.tokensChainsCache[ChainId.mainnet]).toBeDefined(); + expect( + controller.state.tokensChainsCache[ChainId.mainnet].data, + ).toStrictEqual(sampleMainnetTokensChainsCache); + + // Wait longer than the debounce delay (500ms) to ensure any scheduled + // persistence would have executed + await new Promise((resolve) => setTimeout(resolve, 600)); + + // Verify setItem was NOT called - loaded data should not be re-persisted + expect(setItemCallCount).toBe(0); + + controller.destroy(); + }); + + it('should persist initial state chains when storage has different chains', async () => { + // Pre-populate storage with data for chain B (different from initial state) + const chainBData: DataCache = { + data: sampleBinanceTokensChainsCache, + timestamp: Date.now() - 1000, // Older timestamp + }; + mockStorage.set( + `TokenListController:tokensChainsCache:${ChainId['bsc-mainnet']}`, + chainBData, + ); + + // Track setItem calls and which chains are persisted + const persistedChains: string[] = []; + + const trackingMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (trackingMessenger as any).registerActionHandler( + 'StorageService:getItem', + (controllerNamespace: string, key: string) => { + const storageKey = `${controllerNamespace}:${key}`; + const value = mockStorage.get(storageKey); + return value ? { result: value } : {}; + }, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (trackingMessenger as any).registerActionHandler( + 'StorageService:setItem', + (controllerNamespace: string, key: string, value: unknown) => { + persistedChains.push(key); + const storageKey = `${controllerNamespace}:${key}`; + mockStorage.set(storageKey, value); + }, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (trackingMessenger as any).registerActionHandler( + 'StorageService:removeItem', + (controllerNamespace: string, key: string) => { + const storageKey = `${controllerNamespace}:${key}`; + mockStorage.delete(storageKey); + }, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (trackingMessenger as any).registerActionHandler( + 'StorageService:getAllKeys', + (controllerNamespace: string) => { + const keys: string[] = []; + const prefix = `${controllerNamespace}:`; + mockStorage.forEach((_value, key) => { + if (key.startsWith(prefix)) { + const keyWithoutNamespace = key.substring(prefix.length); + keys.push(keyWithoutNamespace); + } + }); + return keys; + }, + ); + + const restrictedMessenger = getRestrictedMessenger(trackingMessenger); + + // Create initial state with chain A (mainnet) - NOT in storage + const chainAData: DataCache = { + data: sampleMainnetTokensChainsCache, + timestamp: Date.now(), + }; + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + state: { + tokensChainsCache: { + [ChainId.mainnet]: chainAData, + }, + preventPollingOnNetworkRestart: false, + }, + }); + + // Initialize - this should load chain B from storage AND schedule chain A for persistence + await controller.initialize(); + + // Verify both chains are in state + expect(controller.state.tokensChainsCache[ChainId.mainnet]).toBeDefined(); + expect( + controller.state.tokensChainsCache[ChainId['bsc-mainnet']], + ).toBeDefined(); + + // Wait for debounced persistence to complete (500ms + buffer) + await new Promise((resolve) => setTimeout(resolve, 600)); + + // Verify chain A (mainnet) was persisted since it was in initial state but not in storage + expect(persistedChains).toContain(`tokensChainsCache:${ChainId.mainnet}`); + + // Verify chain B (bsc-mainnet) was NOT re-persisted since it was loaded from storage + expect(persistedChains).not.toContain( + `tokensChainsCache:${ChainId['bsc-mainnet']}`, + ); + + controller.destroy(); + }); + }); + describe('deprecated methods', () => { + it('should restart polling when restart() is called', async () => { + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + interval: 100, + }); + + nock(tokenService.TOKEN_END_POINT_API) + .get(getTokensPath(ChainId.mainnet)) + .reply(200, sampleMainnetTokenList) + .persist(); + + // Start initial polling + await controller.start(); + + // Wait for first fetch + await new Promise((resolve) => setTimeout(resolve, 150)); + + const initialCache = { ...controller.state.tokensChainsCache }; + expect(initialCache[ChainId.mainnet]).toBeDefined(); + + // Restart polling + await controller.restart(); + + // Wait for another fetch + await new Promise((resolve) => setTimeout(resolve, 150)); + + // Verify polling continued + expect(controller.state.tokensChainsCache[ChainId.mainnet]).toBeDefined(); + + controller.destroy(); + }); + }); }); /** @@ -1363,7 +2436,7 @@ describe('TokenListController', () => { * @param chainId - The chain ID. * @returns The constructed path. */ -function getTokensPath(chainId: Hex) { +function getTokensPath(chainId: Hex): string { return `/tokens/${convertHexToDecimal( chainId, )}?occurrenceFloor=3&includeNativeAssets=false&includeTokenFees=false&includeAssetType=false&includeERC20Permit=false&includeStorage=false&includeRwaData=true`; diff --git a/packages/assets-controllers/src/TokenListController.ts b/packages/assets-controllers/src/TokenListController.ts index f186f93823c..28c3873035c 100644 --- a/packages/assets-controllers/src/TokenListController.ts +++ b/packages/assets-controllers/src/TokenListController.ts @@ -11,8 +11,13 @@ import type { NetworkControllerGetNetworkClientByIdAction, } from '@metamask/network-controller'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import type { + StorageServiceSetItemAction, + StorageServiceGetItemAction, + StorageServiceRemoveItemAction, + StorageServiceGetAllKeysAction, +} from '@metamask/storage-service'; import type { Hex } from '@metamask/utils'; -import { Mutex } from 'async-mutex'; import { isTokenListSupportedForNetwork, @@ -40,7 +45,7 @@ export type TokenListToken = { export type TokenListMap = Record; -type DataCache = { +export type DataCache = { timestamp: number; data: TokenListMap; }; @@ -67,7 +72,12 @@ export type GetTokenListState = ControllerGetStateAction< export type TokenListControllerActions = GetTokenListState; -type AllowedActions = NetworkControllerGetNetworkClientByIdAction; +type AllowedActions = + | NetworkControllerGetNetworkClientByIdAction + | StorageServiceSetItemAction + | StorageServiceGetItemAction + | StorageServiceRemoveItemAction + | StorageServiceGetAllKeysAction; type AllowedEvents = NetworkControllerStateChangeEvent; @@ -80,7 +90,7 @@ export type TokenListControllerMessenger = Messenger< const metadata: StateMetadata = { tokensChainsCache: { includeInStateLogs: false, - persist: true, + persist: false, // Persisted separately via StorageService includeInDebugSnapshot: true, usedInUi: true, }, @@ -112,17 +122,68 @@ export class TokenListController extends StaticIntervalPollingController { - private readonly mutex = new Mutex(); + /** + * Promise that resolves when initialization (loading cache from storage) is complete. + */ + #initializationPromise: Promise = Promise.resolve(); + + /** + * Debounce timer for persisting state changes to storage. + */ + #persistDebounceTimer?: ReturnType; + + /** + * Promise that resolves when the current persist operation completes. + * Used to prevent race conditions between persist and clear operations. + */ + #persistInFlightPromise?: Promise; + + /** + * Tracks which chains have pending changes to persist. + * Only changed chains are persisted to reduce write amplification. + */ + readonly #changedChainsToPersist: Set = new Set(); + + /** + * Tracks chains that were just loaded from storage and should skip + * the next persistence cycle. This prevents redundant writes where + * data loaded from storage would be immediately written back. + * Chains are removed from this set after being skipped once. + */ + readonly #chainsLoadedFromStorage: Set = new Set(); + + /** + * Previous tokensChainsCache for detecting which chains changed. + */ + #previousTokensChainsCache: TokensChainsCache = {}; + + /** + * Debounce delay for persisting state changes (in milliseconds). + */ + static readonly #persistDebounceMs = 500; + + // Storage key prefix for per-chain files + static readonly #storageKeyPrefix = 'tokensChainsCache'; - private intervalId?: ReturnType; + /** + * Get storage key for a specific chain. + * + * @param chainId - The chain ID. + * @returns Storage key for the chain. + */ + static #getChainStorageKey(chainId: Hex): string { + return `${TokenListController.#storageKeyPrefix}:${chainId}`; + } - private readonly intervalDelay: number; + #intervalId?: ReturnType; - private readonly cacheRefreshThreshold: number; + readonly #intervalDelay: number; - private chainId: Hex; + readonly #cacheRefreshThreshold: number; - private abortController: AbortController; + #chainId: Hex; + + #abortController: AbortController; /** * Creates a TokenListController instance. @@ -161,12 +222,21 @@ export class TokenListController extends StaticIntervalPollingController this.#onCacheChanged(newCache), + (controllerState) => controllerState.tokensChainsCache, + ); + if (onNetworkStateChange) { // TODO: Either fix this lint violation or explain why it's necessary to ignore. // eslint-disable-next-line @typescript-eslint/no-misused-promises @@ -185,6 +255,230 @@ export class TokenListController extends StaticIntervalPollingController { + this.#initializationPromise = this.#initializeFromStorage(); + await this.#initializationPromise; + } + + /** + * Internal method to load cache from storage and run migration. + * + * @returns A promise that resolves when initialization is complete. + */ + async #initializeFromStorage(): Promise { + await this.#loadCacheFromStorage(); + } + + /** + * Handle tokensChainsCache changes by detecting which chains changed + * and scheduling debounced persistence. + * + * @param newCache - The new tokensChainsCache state. + */ + #onCacheChanged(newCache: TokensChainsCache): void { + // Detect which chains changed by comparing with previous cache + for (const chainId of Object.keys(newCache) as Hex[]) { + const newData = newCache[chainId]; + const prevData = this.#previousTokensChainsCache[chainId]; + + // Chain is new or timestamp changed (indicating data update) + if (!prevData || prevData.timestamp !== newData.timestamp) { + // Skip persistence for chains that were just loaded from storage + // (they don't need to be written back immediately) + if (this.#chainsLoadedFromStorage.has(chainId)) { + this.#chainsLoadedFromStorage.delete(chainId); // Clean up - future updates should persist + } else { + this.#changedChainsToPersist.add(chainId); + } + } + } + + // Update previous cache reference + this.#previousTokensChainsCache = { ...newCache }; + + // Schedule persistence if there are changes + if (this.#changedChainsToPersist.size > 0) { + this.#debouncePersist(); + } + } + + /** + * Debounce persistence of changed chains to storage. + */ + #debouncePersist(): void { + if (this.#persistDebounceTimer) { + clearTimeout(this.#persistDebounceTimer); + } + + this.#persistDebounceTimer = setTimeout(() => { + // Note: #persistChangedChains handles errors internally via #saveChainCacheToStorage, + // so this promise will not reject. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.#persistChangedChains(); + }, TokenListController.#persistDebounceMs); + } + + /** + * Persist only the chains that have changed to storage. + * Reduces write amplification by skipping unchanged chains. + * + * Tracks the in-flight operation via #persistInFlightPromise so that + * clearingTokenListData() can wait for it to complete before removing + * items from storage, preventing race conditions. + * + * @returns A promise that resolves when changed chains are persisted. + */ + async #persistChangedChains(): Promise { + const chainsToPersist = [...this.#changedChainsToPersist]; + this.#changedChainsToPersist.clear(); + + if (chainsToPersist.length === 0) { + return; + } + + this.#persistInFlightPromise = Promise.all( + chainsToPersist.map((chainId) => this.#saveChainCacheToStorage(chainId)), + ).then(() => undefined); // Convert Promise to Promise + + try { + await this.#persistInFlightPromise; + } finally { + this.#persistInFlightPromise = undefined; + } + } + + /** + * Load tokensChainsCache from StorageService into state. + * Loads all cached chains from separate per-chain files in parallel. + * Called during initialization to restore cached data. + * + * Note: This method merges loaded data with existing state to avoid + * overwriting any fresh data that may have been fetched concurrently. + * Caller must hold the mutex. + * + * @returns A promise that resolves when loading is complete. + */ + async #loadCacheFromStorage(): Promise { + try { + const allKeys = await this.messenger.call( + 'StorageService:getAllKeys', + name, + ); + + // Filter keys that belong to tokensChainsCache (per-chain files) + const cacheKeys = allKeys.filter((key) => + key.startsWith(`${TokenListController.#storageKeyPrefix}:`), + ); + + if (cacheKeys.length === 0) { + return; + } + + // Load all chains in parallel + const chainCaches = await Promise.all( + cacheKeys.map(async (key) => { + // Extract chainId from key: 'tokensChainsCache:0x1' → '0x1' + const chainId = key.split(':')[1] as Hex; + + const { result, error } = await this.messenger.call( + 'StorageService:getItem', + name, + key, + ); + + if (error) { + console.error( + `TokenListController: Error loading cache for ${chainId}:`, + error, + ); + return null; + } + + return result ? { chainId, data: result as DataCache } : null; + }), + ); + + // Build complete cache from loaded chains + const loadedCache: TokensChainsCache = {}; + chainCaches.forEach((chainCache) => { + if (chainCache) { + loadedCache[chainCache.chainId] = chainCache.data; + } + }); + + // Merge loaded cache with existing state, preferring existing data + // (which may be fresher if fetched during initialization) + if (Object.keys(loadedCache).length > 0) { + // Track which chains we're actually loading from storage + // These will be skipped in the next #onCacheChanged to avoid redundant writes + for (const chainId of Object.keys(loadedCache) as Hex[]) { + if (!this.state.tokensChainsCache[chainId]) { + this.#chainsLoadedFromStorage.add(chainId); + } + } + + this.update((state) => { + // Only load chains that don't already exist in state + // This prevents overwriting fresh API data with stale cached data + for (const [chainId, cacheData] of Object.entries(loadedCache)) { + if (!state.tokensChainsCache[chainId as Hex]) { + state.tokensChainsCache[chainId as Hex] = cacheData; + } + } + }); + + // Note: The update() call above triggers #onCacheChanged. Chains that were + // just loaded from storage are tracked in #chainsLoadedFromStorage and will + // be skipped from persistence (since they're already in storage). + // Chains from initial state that were NOT overwritten will still be persisted + // correctly, as they're not in #chainsLoadedFromStorage. + } + } catch (error) { + console.error( + 'TokenListController: Failed to load cache from storage:', + error, + ); + } + } + + /** + * Save a specific chain's cache to StorageService. + * This persists only the updated chain's data, reducing write amplification. + * + * @param chainId - The chain ID to save. + * @returns A promise that resolves when saving is complete. + */ + async #saveChainCacheToStorage(chainId: Hex): Promise { + try { + const chainData = this.state.tokensChainsCache[chainId]; + + if (!chainData) { + console.warn(`TokenListController: No cache data for chain ${chainId}`); + return; + } + + const storageKey = TokenListController.#getChainStorageKey(chainId); + + await this.messenger.call( + 'StorageService:setItem', + name, + storageKey, + chainData, + ); + } catch (error) { + console.error( + `TokenListController: Failed to save cache for ${chainId}:`, + error, + ); + } + } + /** * Updates state and restarts polling on changes to the network controller * state. @@ -200,11 +494,12 @@ export class TokenListController extends StaticIntervalPollingController { - if (!isTokenListSupportedForNetwork(this.chainId)) { + if (!isTokenListSupportedForNetwork(this.#chainId)) { return; } await this.#startDeprecatedPolling(); @@ -232,7 +527,7 @@ export class TokenListController extends StaticIntervalPollingController { - this.stopPolling(); + this.#stopPolling(); await this.#startDeprecatedPolling(); } @@ -243,7 +538,7 @@ export class TokenListController extends StaticIntervalPollingController { // renaming this to avoid collision with base class - await safelyExecute(() => this.fetchTokenList(this.chainId)); + await safelyExecute(() => this.fetchTokenList(this.#chainId)); // TODO: Either fix this lint violation or explain why it's necessary to ignore. // eslint-disable-next-line @typescript-eslint/no-misused-promises - this.intervalId = setInterval(async () => { - await safelyExecute(() => this.fetchTokenList(this.chainId)); - }, this.intervalDelay); + this.#intervalId = setInterval(async () => { + await safelyExecute(() => this.fetchTokenList(this.#chainId)); + }, this.#intervalDelay); } /** @@ -297,60 +600,65 @@ export class TokenListController extends StaticIntervalPollingController { - const releaseLock = await this.mutex.acquire(); - try { - if (this.isCacheValid(chainId)) { - return; - } - - // Fetch fresh token list from the API - const tokensFromAPI = await safelyExecute( - () => - fetchTokenListByChainId( - chainId, - this.abortController.signal, - ) as Promise, - ); + if (this.isCacheValid(chainId)) { + return; + } - // Have response - process and update list - if (tokensFromAPI) { - // Format tokens from API (HTTP) and update tokenList - const tokenList: TokenListMap = {}; - for (const token of tokensFromAPI) { - tokenList[token.address] = { - ...token, - aggregators: formatAggregatorNames(token.aggregators), - iconUrl: formatIconUrlWithProxy({ - chainId, - tokenAddress: token.address, - }), - }; - } + // Fetch fresh token list from the API + const tokensFromAPI = await safelyExecute( + () => + fetchTokenListByChainId( + chainId, + this.#abortController.signal, + ) as Promise, + ); - this.update((state) => { - const newDataCache: DataCache = { data: {}, timestamp: Date.now() }; - state.tokensChainsCache[chainId] ??= newDataCache; - state.tokensChainsCache[chainId].data = tokenList; - state.tokensChainsCache[chainId].timestamp = Date.now(); - }); - return; + // Have response - process and update list + if (tokensFromAPI) { + // Format tokens from API (HTTP) and update tokenList + const tokenList: TokenListMap = {}; + for (const token of tokensFromAPI) { + tokenList[token.address] = { + ...token, + aggregators: formatAggregatorNames(token.aggregators), + iconUrl: formatIconUrlWithProxy({ + chainId, + tokenAddress: token.address, + }), + }; } - // No response - fallback to previous state, or initialise empty - if (!tokensFromAPI) { + // Update state - persistence happens automatically via subscription + const newDataCache: DataCache = { + data: tokenList, + timestamp: Date.now(), + }; + this.update((state) => { + state.tokensChainsCache[chainId] = newDataCache; + }); + return; + } + + // No response - fallback to previous state, or initialise empty. + // Only initialize with a new timestamp if there's no existing cache. + // If there's existing cache, keep it as-is without updating the timestamp + // to avoid making stale data appear "fresh" and preventing retry attempts. + if (!tokensFromAPI) { + 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) => { - const newDataCache: DataCache = { data: {}, timestamp: Date.now() }; - state.tokensChainsCache[chainId] ??= newDataCache; - state.tokensChainsCache[chainId].timestamp = Date.now(); + state.tokensChainsCache[chainId] = newDataCache; }); } - } finally { - releaseLock(); + // If there's existing cache, keep it as-is (don't update timestamp or persist) } } @@ -359,20 +667,108 @@ export class TokenListController extends StaticIntervalPollingController { - return { - ...this.state, - tokensChainsCache: {}, - }; - }); + async clearingTokenListData(): Promise { + if (this.#persistDebounceTimer) { + clearTimeout(this.#persistDebounceTimer); + this.#persistDebounceTimer = undefined; + } + this.#changedChainsToPersist.clear(); + this.#chainsLoadedFromStorage.clear(); + this.#previousTokensChainsCache = {}; + + // Wait for any in-flight persist operation to complete before clearing storage. + // This prevents race conditions where persist setItem calls interleave with + // our removeItem calls, potentially re-saving data after we remove it. + if (this.#persistInFlightPromise) { + try { + await this.#persistInFlightPromise; + } catch { + // Ignore + } + } + + try { + const allKeys = await this.messenger.call( + 'StorageService:getAllKeys', + name, + ); + + // Filter and remove all tokensChainsCache keys + const cacheKeys = allKeys.filter((key) => + key.startsWith(`${TokenListController.#storageKeyPrefix}:`), + ); + + if (cacheKeys.length === 0) { + // No storage keys to remove, just clear state + this.update((state) => { + state.tokensChainsCache = {}; + }); + return; + } + + // Use Promise.allSettled to handle partial failures gracefully. + // This ensures all removals are attempted and we can track which succeeded. + const results = await Promise.allSettled( + cacheKeys.map((key) => + this.messenger.call('StorageService:removeItem', name, key), + ), + ); + + // Identify which chains failed to be removed from storage + const failedChainIds = new Set(); + results.forEach((result, index) => { + if (result.status === 'rejected') { + const key = cacheKeys[index]; + const chainId = key.split(':')[1] as Hex; + failedChainIds.add(chainId); + console.error( + `TokenListController: Failed to remove cache for chain ${chainId}:`, + result.reason, + ); + } + }); + + // Update state to match storage: keep only chains that failed to be removed + this.update((state) => { + if (failedChainIds.size === 0) { + state.tokensChainsCache = {}; + } else { + // Keep only chains that failed to be removed from storage + const preservedCache: TokensChainsCache = {}; + for (const chainId of failedChainIds) { + if (state.tokensChainsCache[chainId]) { + preservedCache[chainId] = state.tokensChainsCache[chainId]; + } + } + state.tokensChainsCache = preservedCache; + } + }); + } catch (error) { + console.error( + 'TokenListController: Failed to clear cache from storage:', + error, + ); + // Still clear state even if storage access fails + this.update((state) => { + state.tokensChainsCache = {}; + }); + } } /** diff --git a/packages/assets-controllers/tsconfig.build.json b/packages/assets-controllers/tsconfig.build.json index 5ef0e52c3b8..d24f83bf4e8 100644 --- a/packages/assets-controllers/tsconfig.build.json +++ b/packages/assets-controllers/tsconfig.build.json @@ -18,6 +18,7 @@ { "path": "../preferences-controller/tsconfig.build.json" }, { "path": "../polling-controller/tsconfig.build.json" }, { "path": "../permission-controller/tsconfig.build.json" }, + { "path": "../storage-service/tsconfig.build.json" }, { "path": "../transaction-controller/tsconfig.build.json" }, { "path": "../phishing-controller/tsconfig.build.json" } ], diff --git a/packages/assets-controllers/tsconfig.json b/packages/assets-controllers/tsconfig.json index a537b98ca39..5a4630d0a47 100644 --- a/packages/assets-controllers/tsconfig.json +++ b/packages/assets-controllers/tsconfig.json @@ -18,6 +18,7 @@ { "path": "../phishing-controller" }, { "path": "../polling-controller" }, { "path": "../permission-controller" }, + { "path": "../storage-service" }, { "path": "../transaction-controller" } ], "include": ["../../types", "./src", "../../tests"] diff --git a/yarn.lock b/yarn.lock index 5da153fd94f..2ffa3ad7f2d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2675,6 +2675,7 @@ __metadata: "@metamask/snaps-controllers": "npm:^17.2.0" "@metamask/snaps-sdk": "npm:^10.3.0" "@metamask/snaps-utils": "npm:^11.7.0" + "@metamask/storage-service": "npm:^0.0.1" "@metamask/transaction-controller": "npm:^62.9.1" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" @@ -4941,7 +4942,7 @@ __metadata: languageName: node linkType: hard -"@metamask/storage-service@workspace:packages/storage-service": +"@metamask/storage-service@npm:^0.0.1, @metamask/storage-service@workspace:packages/storage-service": version: 0.0.0-use.local resolution: "@metamask/storage-service@workspace:packages/storage-service" dependencies: