From d19c31d460d9f3affb074278d527c91f86d15502 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Tue, 9 Dec 2025 12:32:46 +0100 Subject: [PATCH 01/32] chore: integrate storageService with tokenListController --- packages/assets-controllers/package.json | 1 + .../src/TokenListController.test.ts | 1 - .../src/TokenListController.ts | 244 ++++++++++++++---- .../assets-controllers/tsconfig.build.json | 1 + packages/assets-controllers/tsconfig.json | 1 + yarn.lock | 3 +- 6 files changed, 201 insertions(+), 50 deletions(-) diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index 0f14de3a4a1..693b2f5133d 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -78,6 +78,7 @@ "@metamask/snaps-controllers": "^14.0.1", "@metamask/snaps-sdk": "^9.0.0", "@metamask/snaps-utils": "^11.0.0", + "@metamask/storage-service": "^0.0.0", "@metamask/transaction-controller": "^62.5.0", "@metamask/utils": "^11.8.1", "@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 b6a03aa7b52..01b5b556a49 100644 --- a/packages/assets-controllers/src/TokenListController.test.ts +++ b/packages/assets-controllers/src/TokenListController.test.ts @@ -1331,7 +1331,6 @@ describe('TokenListController', () => { ).toMatchInlineSnapshot(` Object { "preventPollingOnNetworkRestart": false, - "tokensChainsCache": Object {}, } `); }); diff --git a/packages/assets-controllers/src/TokenListController.ts b/packages/assets-controllers/src/TokenListController.ts index 6ee57c476bb..c4172b30cbb 100644 --- a/packages/assets-controllers/src/TokenListController.ts +++ b/packages/assets-controllers/src/TokenListController.ts @@ -11,6 +11,11 @@ import type { NetworkControllerGetNetworkClientByIdAction, } from '@metamask/network-controller'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import type { + StorageServiceSetItemAction, + StorageServiceGetItemAction, + StorageServiceRemoveItemAction, +} from '@metamask/storage-service'; import type { Hex } from '@metamask/utils'; import { Mutex } from 'async-mutex'; @@ -65,7 +70,11 @@ export type GetTokenListState = ControllerGetStateAction< export type TokenListControllerActions = GetTokenListState; -type AllowedActions = NetworkControllerGetNetworkClientByIdAction; +type AllowedActions = + | NetworkControllerGetNetworkClientByIdAction + | StorageServiceSetItemAction + | StorageServiceGetItemAction + | StorageServiceRemoveItemAction; type AllowedEvents = NetworkControllerStateChangeEvent; @@ -78,7 +87,7 @@ export type TokenListControllerMessenger = Messenger< const metadata: StateMetadata = { tokensChainsCache: { includeInStateLogs: false, - persist: true, + persist: false, // Persisted separately via StorageService includeInDebugSnapshot: true, usedInUi: true, }, @@ -110,17 +119,20 @@ export class TokenListController extends StaticIntervalPollingController { - private readonly mutex = new Mutex(); + readonly #mutex = new Mutex(); - private intervalId?: ReturnType; + // Storage key for StorageService + static readonly #storageKey = 'tokensChainsCache'; - 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. @@ -159,12 +171,27 @@ export class TokenListController extends StaticIntervalPollingController { + // Migrate existing cache from state to StorageService if needed + return this.#migrateStateToStorage(); + }) + .catch((error) => { + console.error( + 'TokenListController: Failed to load cache from storage:', + error, + ); + }); + 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 @@ -183,25 +210,124 @@ export class TokenListController extends StaticIntervalPollingController { + try { + const { result, error } = await this.messenger.call( + 'StorageService:getItem', + name, + TokenListController.#storageKey, + ); + + if (error) { + console.error( + 'TokenListController: Error loading cache from storage:', + error, + ); + return; + } + + if (result) { + // Load from StorageService into state + this.update((state) => { + state.tokensChainsCache = result as TokensChainsCache; + }); + } + } catch (error) { + console.error( + 'TokenListController: Failed to load cache from storage:', + error, + ); + } + } + + /** + * Save tokensChainsCache from state to StorageService. + * This persists large token data outside of the persisted state. + * + * @returns A promise that resolves when saving is complete. + */ + async #saveCacheToStorage(): Promise { + try { + await this.messenger.call( + 'StorageService:setItem', + name, + TokenListController.#storageKey, + this.state.tokensChainsCache, + ); + } catch (error) { + console.error( + 'TokenListController: Failed to save cache to storage:', + error, + ); + } + } + + /** + * Migrate tokensChainsCache from old persisted state to StorageService. + * This handles backward compatibility for users upgrading from versions + * where tokensChainsCache was persisted in state. + * + * @returns A promise that resolves when migration is complete. + */ + async #migrateStateToStorage(): Promise { + try { + // Check if state has data (from old persisted state) + const hasStateData = + this.state.tokensChainsCache && + Object.keys(this.state.tokensChainsCache).length > 0; + + if (!hasStateData) { + return; + } + + // Check if StorageService already has data + const { result } = await this.messenger.call( + 'StorageService:getItem', + name, + TokenListController.#storageKey, + ); + + // If StorageService is empty but state has data, migrate it + if (!result) { + await this.#saveCacheToStorage(); + } + } catch (error) { + console.error( + 'TokenListController: Failed to migrate cache to storage:', + error, + ); + } + } + /** * Updates state and restarts polling on changes to the network controller * state. * * @param networkControllerState - The updated network controller state. */ - async #onNetworkControllerStateChange(networkControllerState: NetworkState) { + async #onNetworkControllerStateChange( + networkControllerState: NetworkState, + ): Promise { const selectedNetworkClient = this.messenger.call( 'NetworkController:getNetworkClientById', networkControllerState.selectedNetworkClientId, ); const { chainId } = selectedNetworkClient.configuration; - if (this.chainId !== chainId) { - this.abortController.abort(); - this.abortController = new AbortController(); - this.chainId = chainId; + if (this.#chainId !== chainId) { + this.#abortController.abort(); + this.#abortController = new AbortController(); + this.#chainId = chainId; if (this.state.preventPollingOnNetworkRestart) { - this.clearingTokenListData(); + this.clearingTokenListData().catch((error) => { + console.error('Failed to clear token list data:', error); + }); } } } @@ -214,8 +340,8 @@ export class TokenListController extends StaticIntervalPollingController { + if (!isTokenListSupportedForNetwork(this.#chainId)) { return; } await this.#startDeprecatedPolling(); @@ -227,8 +353,8 @@ export class TokenListController extends StaticIntervalPollingController { + this.#stopPolling(); await this.#startDeprecatedPolling(); } @@ -238,8 +364,8 @@ 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); } /** @@ -293,12 +419,13 @@ export class TokenListController extends StaticIntervalPollingController { - const releaseLock = await this.mutex.acquire(); + const releaseLock = await this.#mutex.acquire(); try { if (this.isCacheValid(chainId)) { return; @@ -309,7 +436,7 @@ export class TokenListController extends StaticIntervalPollingController fetchTokenListByChainId( chainId, - this.abortController.signal, + this.#abortController.signal, ) as Promise, ); @@ -328,22 +455,30 @@ export class TokenListController extends StaticIntervalPollingController { - const newDataCache: DataCache = { data: {}, timestamp: Date.now() }; - state.tokensChainsCache[chainId] ??= newDataCache; - state.tokensChainsCache[chainId].data = tokenList; - state.tokensChainsCache[chainId].timestamp = Date.now(); + state.tokensChainsCache[chainId] = newDataCache; }); + + // Persist to StorageService (async, non-blocking) + await this.#saveCacheToStorage(); return; } // No response - fallback to previous state, or initialise empty if (!tokensFromAPI) { + 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(); }); + + // Persist to StorageService + await this.#saveCacheToStorage(); } } finally { releaseLock(); @@ -355,20 +490,33 @@ export class TokenListController extends StaticIntervalPollingController { - return { - ...this.state, - tokensChainsCache: {}, - }; + async clearingTokenListData(): Promise { + // Clear state + this.update((state) => { + state.tokensChainsCache = {}; }); + + // Clear from StorageService + try { + await this.messenger.call( + 'StorageService:removeItem', + name, + TokenListController.#storageKey, + ); + } catch (error) { + console.error( + 'TokenListController: Failed to clear cache from storage:', + error, + ); + } } /** 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 700332d93df..c7e7bbc187d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2663,6 +2663,7 @@ __metadata: "@metamask/snaps-controllers": "npm:^14.0.1" "@metamask/snaps-sdk": "npm:^9.0.0" "@metamask/snaps-utils": "npm:^11.0.0" + "@metamask/storage-service": "npm:^0.0.0" "@metamask/transaction-controller": "npm:^62.5.0" "@metamask/utils": "npm:^11.8.1" "@ts-bridge/cli": "npm:^0.6.4" @@ -4919,7 +4920,7 @@ __metadata: languageName: node linkType: hard -"@metamask/storage-service@workspace:packages/storage-service": +"@metamask/storage-service@npm:^0.0.0, @metamask/storage-service@workspace:packages/storage-service": version: 0.0.0-use.local resolution: "@metamask/storage-service@workspace:packages/storage-service" dependencies: From bffca81de8e058d993f8ccc58fdc7193b03f85d6 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Tue, 9 Dec 2025 12:57:03 +0100 Subject: [PATCH 02/32] chore: lint --- packages/assets-controllers/src/TokenListController.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/assets-controllers/src/TokenListController.test.ts b/packages/assets-controllers/src/TokenListController.test.ts index 01b5b556a49..84a3f4e11a5 100644 --- a/packages/assets-controllers/src/TokenListController.test.ts +++ b/packages/assets-controllers/src/TokenListController.test.ts @@ -1069,7 +1069,7 @@ describe('TokenListController', () => { state: existingState, }); expect(controller.state).toStrictEqual(existingState); - controller.clearingTokenListData(); + await controller.clearingTokenListData(); expect(controller.state.tokensChainsCache).toStrictEqual({}); From 339ad1138f4f65f313d190768966a02a0c87a2f7 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Tue, 9 Dec 2025 15:08:25 +0100 Subject: [PATCH 03/32] fix: ut --- .../src/TokenListController.test.ts | 280 +++++++++++++++++- 1 file changed, 277 insertions(+), 3 deletions(-) diff --git a/packages/assets-controllers/src/TokenListController.test.ts b/packages/assets-controllers/src/TokenListController.test.ts index 84a3f4e11a5..7064bb79975 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, + TokensChainsCache, } from './TokenListController'; import { TokenListController } from './TokenListController'; import { advanceTime } from '../../../tests/helpers'; @@ -478,8 +479,42 @@ 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); + }, + ); + + return messenger; }; const getRestrictedMessenger = ( @@ -496,13 +531,23 @@ const getRestrictedMessenger = ( }); messenger.delegate({ messenger: tokenListControllerMessenger, - actions: ['NetworkController:getNetworkClientById'], + actions: [ + 'NetworkController:getNetworkClientById', + 'StorageService:getItem', + 'StorageService:setItem', + 'StorageService:removeItem', + ], events: ['NetworkController:stateChange'], }); return tokenListControllerMessenger; }; describe('TokenListController', () => { + beforeEach(() => { + // Clear mock storage between tests + mockStorage.clear(); + }); + afterEach(() => { jest.clearAllTimers(); sinon.restore(); @@ -1354,6 +1399,235 @@ describe('TokenListController', () => { `); }); }); + + describe('StorageService migration', () => { + it('should migrate tokensChainsCache from state to StorageService on first launch', async () => { + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + + // Simulate old persisted state with tokensChainsCache + const oldPersistedState = { + tokensChainsCache: { + [ChainId.mainnet]: { + data: sampleMainnetTokensChainsCache, + timestamp: Date.now(), + }, + }, + preventPollingOnNetworkRestart: false, + }; + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + state: oldPersistedState, + }); + + // Fetch tokens to trigger save to storage (migration happens asynchronously in constructor) + nock(tokenService.TOKEN_END_POINT_API) + .get(getTokensPath(ChainId.mainnet)) + .reply(200, sampleMainnetTokenList); + + await controller.fetchTokenList(ChainId.mainnet); + + // Verify data was saved to StorageService + const { result } = await messenger.call( + 'StorageService:getItem', + 'TokenListController', + 'tokensChainsCache', + ); + + expect(result).toBeDefined(); + const resultCache = result as TokensChainsCache; + expect(resultCache[ChainId.mainnet]).toBeDefined(); + expect(resultCache[ChainId.mainnet].data).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 + const existingStorageData = { + [ChainId.mainnet]: { + data: { '0xExistingToken': { name: 'Existing', symbol: 'EXT' } }, + timestamp: Date.now(), + }, + }; + await messenger.call( + 'StorageService:setItem', + 'TokenListController', + 'tokensChainsCache', + existingStorageData, + ); + + // 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, + }); + + // Wait for migration logic to run + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify StorageService still has original data (not overwritten) + const { result } = await messenger.call( + 'StorageService:getItem', + 'TokenListController', + 'tokensChainsCache', + ); + + expect(result).toStrictEqual(existingStorageData); + const resultCache = result as TokensChainsCache; + expect(resultCache[ChainId.mainnet].data).toStrictEqual( + existingStorageData[ChainId.mainnet].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 }, + }); + + // Wait for migration logic to run + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify nothing was saved to StorageService + const { result } = await messenger.call( + 'StorageService:getItem', + 'TokenListController', + 'tokensChainsCache', + ); + + expect(result).toBeUndefined(); + + 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; + + controller1.destroy(); + + // Verify data is in StorageService + const { result } = await messenger.call( + 'StorageService:getItem', + 'TokenListController', + 'tokensChainsCache', + ); + + expect(result).toBeDefined(); + expect(result).toStrictEqual(savedCache); + }); + + 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); + + // Verify data was saved to StorageService (fetchTokenList awaits the save) + const { result } = await messenger.call( + 'StorageService:getItem', + 'TokenListController', + 'tokensChainsCache', + ); + + expect(result).toBeDefined(); + const resultCache = result as TokensChainsCache; + expect(resultCache[ChainId.mainnet]).toBeDefined(); + expect(resultCache[ChainId.mainnet].data).toBeDefined(); + + controller.destroy(); + }); + + it('should clear tokensChainsCache from StorageService when clearing data', async () => { + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + + // Pre-populate StorageService + const storageData = { + [ChainId.mainnet]: { + data: sampleMainnetTokensChainsCache, + timestamp: Date.now(), + }, + }; + await messenger.call( + 'StorageService:setItem', + 'TokenListController', + 'tokensChainsCache', + storageData, + ); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + state: { + tokensChainsCache: storageData, + 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 (clearingTokenListData awaits the removal) + const { result } = await messenger.call( + 'StorageService:getItem', + 'TokenListController', + 'tokensChainsCache', + ); + + expect(result).toBeUndefined(); + expect(controller.state.tokensChainsCache).toStrictEqual({}); + + controller.destroy(); + }); + }); }); /** @@ -1362,7 +1636,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`; From ec25b7283950259fbb8e46eb3c12d7860001d319 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Tue, 9 Dec 2025 18:05:03 +0100 Subject: [PATCH 04/32] fix: lint --- eslint-suppressions.json | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index ffe8e50d8bd..77824ab09e5 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -517,7 +517,7 @@ }, "packages/assets-controllers/src/TokenListController.test.ts": { "@typescript-eslint/explicit-function-return-type": { - "count": 2 + "count": 1 }, "id-denylist": { "count": 2 @@ -526,14 +526,6 @@ "count": 7 } }, - "packages/assets-controllers/src/TokenListController.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 6 - }, - "no-restricted-syntax": { - "count": 7 - } - }, "packages/assets-controllers/src/TokenRatesController.test.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 4 From 9c9078b3c3e9593806cdd4e8c82151f989d81772 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Wed, 10 Dec 2025 16:39:28 +0100 Subject: [PATCH 05/32] fix: changelog --- packages/assets-controllers/CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 9b2ce0d0f8f..927eb4528a8 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- `TokenListController` now persists `tokensChainsCache` via `StorageService` instead of controller state to reduce memory usage ([#7413](https://github.com/MetaMask/core/pull/7413)) + - Includes migration logic to automatically move existing cache data on first launch after upgrade + - `tokensChainsCache` state metadata now has `persist: false` to prevent duplicate persistence - Reduce severity of ERC721 metadata interface log from `console.error` to `console.warn` ([#7412](https://github.com/MetaMask/core/pull/7412)) - Fixes [#24988](https://github.com/MetaMask/metamask-extension/issues/24988) - Bump `@metamask/transaction-controller` from `^62.4.0` to `^62.6.0` ([#7325](https://github.com/MetaMask/core/pull/7325), [#7430](https://github.com/MetaMask/core/pull/7430)) From 5c9329a4ecf2696b0b58350c312967779a1cbc27 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Mon, 15 Dec 2025 15:18:44 +0100 Subject: [PATCH 06/32] fix: per chain file save --- packages/assets-controllers/CHANGELOG.md | 6 +- .../src/TokenListController.test.ts | 123 ++++++----- .../src/TokenListController.ts | 204 ++++++++++++++---- 3 files changed, 233 insertions(+), 100 deletions(-) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 927eb4528a8..4127f8824ac 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -13,8 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- `TokenListController` now persists `tokensChainsCache` via `StorageService` instead of controller state to reduce memory usage ([#7413](https://github.com/MetaMask/core/pull/7413)) - - Includes migration logic to automatically move existing cache data on first launch after upgrade +- `TokenListController` now persists `tokensChainsCache` via `StorageService` using per-chain files to reduce write amplification ([#7413](https://github.com/MetaMask/core/pull/7413)) + - Each chain's token cache (~100-500KB) is stored in a separate file, avoiding rewriting all chains (~5MB) on every update + - Includes migration logic to automatically split existing cache data into per-chain files on first launch after upgrade + - All chains are loaded in parallel at startup to maintain compatibility with TokenDetectionController - `tokensChainsCache` state metadata now has `persist: false` to prevent duplicate persistence - Reduce severity of ERC721 metadata interface log from `console.error` to `console.warn` ([#7412](https://github.com/MetaMask/core/pull/7412)) - Fixes [#24988](https://github.com/MetaMask/metamask-extension/issues/24988) diff --git a/packages/assets-controllers/src/TokenListController.test.ts b/packages/assets-controllers/src/TokenListController.test.ts index 7064bb79975..f5c38177b08 100644 --- a/packages/assets-controllers/src/TokenListController.test.ts +++ b/packages/assets-controllers/src/TokenListController.test.ts @@ -22,7 +22,7 @@ import type { TokenListMap, TokenListState, TokenListControllerMessenger, - TokensChainsCache, + DataCache, } from './TokenListController'; import { TokenListController } from './TokenListController'; import { advanceTime } from '../../../tests/helpers'; @@ -514,6 +514,20 @@ const getMessenger = (): RootMessenger => { }, ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messenger as any).registerActionHandler( + 'StorageService:getAllKeys', + (controllerNamespace: string) => { + const keys: string[] = []; + mockStorage.forEach((_value, key) => { + // Extract key without namespace prefix + const keyWithoutNamespace = key.replace(`${controllerNamespace}:`, ''); + keys.push(keyWithoutNamespace); + }); + return keys; + }, + ); + return messenger; }; @@ -536,6 +550,7 @@ const getRestrictedMessenger = ( 'StorageService:getItem', 'StorageService:setItem', 'StorageService:removeItem', + 'StorageService:getAllKeys', ], events: ['NetworkController:stateChange'], }); @@ -1422,24 +1437,22 @@ describe('TokenListController', () => { state: oldPersistedState, }); - // Fetch tokens to trigger save to storage (migration happens asynchronously in constructor) - nock(tokenService.TOKEN_END_POINT_API) - .get(getTokensPath(ChainId.mainnet)) - .reply(200, sampleMainnetTokenList); - - await controller.fetchTokenList(ChainId.mainnet); + // Wait for async migration to complete + await new Promise((resolve) => setTimeout(resolve, 100)); - // Verify data was saved to StorageService + // Verify data was migrated to StorageService (per-chain file) + const chainStorageKey = `tokensChainsCache:${ChainId.mainnet}`; const { result } = await messenger.call( 'StorageService:getItem', 'TokenListController', - 'tokensChainsCache', + chainStorageKey, ); expect(result).toBeDefined(); - const resultCache = result as TokensChainsCache; - expect(resultCache[ChainId.mainnet]).toBeDefined(); - expect(resultCache[ChainId.mainnet].data).toBeDefined(); + const resultCache = result as DataCache; + expect(resultCache.data).toBeDefined(); + expect(resultCache.timestamp).toBeDefined(); + expect(resultCache.data).toStrictEqual(sampleMainnetTokensChainsCache); controller.destroy(); }); @@ -1448,18 +1461,17 @@ describe('TokenListController', () => { const messenger = getMessenger(); const restrictedMessenger = getRestrictedMessenger(messenger); - // Pre-populate StorageService with existing data - const existingStorageData = { - [ChainId.mainnet]: { - data: { '0xExistingToken': { name: 'Existing', symbol: 'EXT' } }, - timestamp: Date.now(), - }, + // 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', - 'tokensChainsCache', - existingStorageData, + chainStorageKey, + existingChainData, ); // Initialize with different state data @@ -1486,14 +1498,12 @@ describe('TokenListController', () => { const { result } = await messenger.call( 'StorageService:getItem', 'TokenListController', - 'tokensChainsCache', + chainStorageKey, ); - expect(result).toStrictEqual(existingStorageData); - const resultCache = result as TokensChainsCache; - expect(resultCache[ChainId.mainnet].data).toStrictEqual( - existingStorageData[ChainId.mainnet].data, - ); + expect(result).toStrictEqual(existingChainData); + const resultCache = result as DataCache; + expect(resultCache.data).toStrictEqual(existingChainData.data); controller.destroy(); }); @@ -1511,14 +1521,16 @@ describe('TokenListController', () => { // Wait for migration logic to run await new Promise((resolve) => setTimeout(resolve, 100)); - // Verify nothing was saved to StorageService - const { result } = await messenger.call( - 'StorageService:getItem', + // Verify nothing was saved to StorageService (check no per-chain files) + const allKeys = await messenger.call( + 'StorageService:getAllKeys', 'TokenListController', - 'tokensChainsCache', + ); + const cacheKeys = allKeys.filter((key) => + key.startsWith('tokensChainsCache:'), ); - expect(result).toBeUndefined(); + expect(cacheKeys).toHaveLength(0); controller.destroy(); }); @@ -1542,15 +1554,16 @@ describe('TokenListController', () => { controller1.destroy(); - // Verify data is in StorageService + // Verify data is in StorageService (per-chain file) + const chainStorageKey = `tokensChainsCache:${ChainId.mainnet}`; const { result } = await messenger.call( 'StorageService:getItem', 'TokenListController', - 'tokensChainsCache', + chainStorageKey, ); expect(result).toBeDefined(); - expect(result).toStrictEqual(savedCache); + expect(result).toStrictEqual(savedCache[ChainId.mainnet]); }); it('should save tokensChainsCache to StorageService when fetching tokens', async () => { @@ -1568,17 +1581,18 @@ describe('TokenListController', () => { await controller.fetchTokenList(ChainId.mainnet); - // Verify data was saved to StorageService (fetchTokenList awaits the save) + // Verify data was saved to StorageService (per-chain file) + const chainStorageKey = `tokensChainsCache:${ChainId.mainnet}`; const { result } = await messenger.call( 'StorageService:getItem', 'TokenListController', - 'tokensChainsCache', + chainStorageKey, ); expect(result).toBeDefined(); - const resultCache = result as TokensChainsCache; - expect(resultCache[ChainId.mainnet]).toBeDefined(); - expect(resultCache[ChainId.mainnet].data).toBeDefined(); + const resultCache = result as DataCache; + expect(resultCache.data).toBeDefined(); + expect(resultCache.timestamp).toBeDefined(); controller.destroy(); }); @@ -1587,25 +1601,26 @@ describe('TokenListController', () => { const messenger = getMessenger(); const restrictedMessenger = getRestrictedMessenger(messenger); - // Pre-populate StorageService - const storageData = { - [ChainId.mainnet]: { - data: sampleMainnetTokensChainsCache, - timestamp: Date.now(), - }, + // 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', - 'tokensChainsCache', - storageData, + chainStorageKey, + chainData, ); const controller = new TokenListController({ chainId: ChainId.mainnet, messenger: restrictedMessenger, state: { - tokensChainsCache: storageData, + tokensChainsCache: { + [ChainId.mainnet]: chainData, + }, preventPollingOnNetworkRestart: false, }, }); @@ -1615,14 +1630,16 @@ describe('TokenListController', () => { await controller.clearingTokenListData(); - // Verify data was removed from StorageService (clearingTokenListData awaits the removal) - const { result } = await messenger.call( - 'StorageService:getItem', + // Verify data was removed from StorageService (per-chain file removed) + const allKeys = await messenger.call( + 'StorageService:getAllKeys', 'TokenListController', - 'tokensChainsCache', + ); + const cacheKeys = allKeys.filter((key) => + key.startsWith('tokensChainsCache:'), ); - expect(result).toBeUndefined(); + expect(cacheKeys).toHaveLength(0); expect(controller.state.tokensChainsCache).toStrictEqual({}); controller.destroy(); diff --git a/packages/assets-controllers/src/TokenListController.ts b/packages/assets-controllers/src/TokenListController.ts index c4172b30cbb..8d43ef3ebe3 100644 --- a/packages/assets-controllers/src/TokenListController.ts +++ b/packages/assets-controllers/src/TokenListController.ts @@ -15,6 +15,7 @@ import type { StorageServiceSetItemAction, StorageServiceGetItemAction, StorageServiceRemoveItemAction, + StorageServiceGetAllKeysAction, } from '@metamask/storage-service'; import type { Hex } from '@metamask/utils'; import { Mutex } from 'async-mutex'; @@ -43,7 +44,7 @@ export type TokenListToken = { export type TokenListMap = Record; -type DataCache = { +export type DataCache = { timestamp: number; data: TokenListMap; }; @@ -74,7 +75,8 @@ type AllowedActions = | NetworkControllerGetNetworkClientByIdAction | StorageServiceSetItemAction | StorageServiceGetItemAction - | StorageServiceRemoveItemAction; + | StorageServiceRemoveItemAction + | StorageServiceGetAllKeysAction; type AllowedEvents = NetworkControllerStateChangeEvent; @@ -121,8 +123,18 @@ export class TokenListController extends StaticIntervalPollingController { readonly #mutex = new Mutex(); - // Storage key for StorageService - static readonly #storageKey = 'tokensChainsCache'; + // Storage key prefix for per-chain files + static readonly #storageKeyPrefix = 'tokensChainsCache'; + + /** + * 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}`; + } #intervalId?: ReturnType; @@ -212,30 +224,64 @@ export class TokenListController extends StaticIntervalPollingController { try { - const { result, error } = await this.messenger.call( - 'StorageService:getItem', + // Get all keys for this controller + const allKeys = await this.messenger.call( + 'StorageService:getAllKeys', name, - TokenListController.#storageKey, ); - if (error) { - console.error( - 'TokenListController: Error loading cache from storage:', - error, - ); - return; + // Filter keys that belong to tokensChainsCache (per-chain files) + const cacheKeys = allKeys.filter((key) => + key.startsWith(`${TokenListController.#storageKeyPrefix}:`), + ); + + if (cacheKeys.length === 0) { + return; // No cached data } - if (result) { - // Load from StorageService into state + // 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; + } + }); + + // Load into state (all chains available for TokenDetectionController) + if (Object.keys(loadedCache).length > 0) { this.update((state) => { - state.tokensChainsCache = result as TokensChainsCache; + state.tokensChainsCache = loadedCache; }); } } catch (error) { @@ -247,56 +293,107 @@ export class TokenListController extends StaticIntervalPollingController { + 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, - TokenListController.#storageKey, - this.state.tokensChainsCache, + storageKey, + chainData, ); } catch (error) { console.error( - 'TokenListController: Failed to save cache to storage:', + `TokenListController: Failed to save cache for ${chainId}:`, error, ); } } /** - * Migrate tokensChainsCache from old persisted state to StorageService. - * This handles backward compatibility for users upgrading from versions - * where tokensChainsCache was persisted in state. + * Migrate tokensChainsCache from old storage formats to per-chain files. + * Handles backward compatibility for users upgrading from: + * 1. Old persisted state (tokensChainsCache was in state) + * 2. Old single-file StorageService (all chains in one file) * * @returns A promise that resolves when migration is complete. */ async #migrateStateToStorage(): Promise { try { - // Check if state has data (from old persisted state) - const hasStateData = + let dataToMigrate: TokensChainsCache | null = null; + + // Check for old single-file storage (previous StorageService version) + const { result: oldStorageData } = await this.messenger.call( + 'StorageService:getItem', + name, + TokenListController.#storageKeyPrefix, // Old key without chain suffix + ); + + if (oldStorageData) { + // Migrate from old single-file storage + dataToMigrate = oldStorageData as TokensChainsCache; + console.log( + 'TokenListController: Migrating from single-file to per-chain storage', + ); + } else if ( this.state.tokensChainsCache && - Object.keys(this.state.tokensChainsCache).length > 0; + Object.keys(this.state.tokensChainsCache).length > 0 + ) { + // Check if per-chain files already exist + const allKeys = await this.messenger.call( + 'StorageService:getAllKeys', + name, + ); + const hasPerChainFiles = allKeys.some((key) => + key.startsWith(`${TokenListController.#storageKeyPrefix}:`), + ); - if (!hasStateData) { - return; + if (!hasPerChainFiles) { + // Migrate from old persisted state + dataToMigrate = this.state.tokensChainsCache; + console.log( + 'TokenListController: Migrating from persisted state to per-chain storage', + ); + } } - // Check if StorageService already has data - const { result } = await this.messenger.call( - 'StorageService:getItem', - name, - TokenListController.#storageKey, + if (!dataToMigrate) { + return; // Nothing to migrate + } + + // Split into per-chain files + await Promise.all( + Object.keys(dataToMigrate).map((chainId) => + this.#saveChainCacheToStorage(chainId as Hex), + ), ); - // If StorageService is empty but state has data, migrate it - if (!result) { - await this.#saveCacheToStorage(); + // Remove old single-file storage if it existed + if (oldStorageData) { + await this.messenger.call( + 'StorageService:removeItem', + name, + TokenListController.#storageKeyPrefix, + ); } + + console.log( + 'TokenListController: Migration to per-chain storage complete', + ); } catch (error) { console.error( 'TokenListController: Failed to migrate cache to storage:', @@ -464,8 +561,8 @@ export class TokenListController extends StaticIntervalPollingController { // Clear state @@ -504,12 +601,29 @@ export class TokenListController extends StaticIntervalPollingController + key.startsWith(`${TokenListController.#storageKeyPrefix}:`), + ); + + await Promise.all( + cacheKeys.map((key) => + this.messenger.call('StorageService:removeItem', name, key), + ), + ); + + // Also remove old single-file storage if it exists (cleanup) await this.messenger.call( 'StorageService:removeItem', name, - TokenListController.#storageKey, + TokenListController.#storageKeyPrefix, ); } catch (error) { console.error( From 0c0e10167b788c0a75cb640635e40e24a83c94ff Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Mon, 15 Dec 2025 18:08:12 +0100 Subject: [PATCH 07/32] fix: update @metamask/storage-service version in package.json and yarn.lock --- packages/assets-controllers/package.json | 2 +- yarn.lock | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index 7a54579f9a4..edebf771eee 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -78,8 +78,8 @@ "@metamask/snaps-controllers": "^14.0.1", "@metamask/snaps-sdk": "^9.0.0", "@metamask/snaps-utils": "^11.0.0", + "@metamask/storage-service": "^0.0.1", "@metamask/transaction-controller": "^62.7.0", - "@metamask/storage-service": "^0.0.0", "@metamask/utils": "^11.8.1", "@types/bn.js": "^5.1.5", "@types/uuid": "^8.3.0", diff --git a/yarn.lock b/yarn.lock index 48b261fb7bb..9101faaadef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2662,6 +2662,7 @@ __metadata: "@metamask/snaps-controllers": "npm:^14.0.1" "@metamask/snaps-sdk": "npm:^9.0.0" "@metamask/snaps-utils": "npm:^11.0.0" + "@metamask/storage-service": "npm:^0.0.1" "@metamask/transaction-controller": "npm:^62.7.0" "@metamask/utils": "npm:^11.8.1" "@ts-bridge/cli": "npm:^0.6.4" @@ -4941,7 +4942,7 @@ __metadata: languageName: node linkType: hard -"@metamask/storage-service@npm:^0.0.0, @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: From c8b0472c50e0e7bec533bedb5cc30409276a4290 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Mon, 15 Dec 2025 19:21:28 +0100 Subject: [PATCH 08/32] test: enhance TokenListController tests for error handling in cache loading and saving --- .../src/TokenListController.test.ts | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) diff --git a/packages/assets-controllers/src/TokenListController.test.ts b/packages/assets-controllers/src/TokenListController.test.ts index f5c38177b08..fa499133d1b 100644 --- a/packages/assets-controllers/src/TokenListController.test.ts +++ b/packages/assets-controllers/src/TokenListController.test.ts @@ -1644,6 +1644,197 @@ describe('TokenListController', () => { 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[] = []; + mockStorage.forEach((_value, key) => { + const keyWithoutNamespace = key.replace( + `${controllerNamespace}:`, + '', + ); + 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, + }); + + // Wait for async loading to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + // 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[] = []; + mockStorage.forEach((_value, key) => { + const keyWithoutNamespace = key.replace( + `${controllerNamespace}:`, + '', + ); + 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, + }); + + // Wait for async initialization + await new Promise((resolve) => setTimeout(resolve, 100)); + + // 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); + + // 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(); + }); }); }); From 5490244cda65a19846347af37fc1f0c0d7638a23 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Mon, 15 Dec 2025 19:28:34 +0100 Subject: [PATCH 09/32] test: add test --- .../src/TokenListController.test.ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/packages/assets-controllers/src/TokenListController.test.ts b/packages/assets-controllers/src/TokenListController.test.ts index fa499133d1b..130defb2fe8 100644 --- a/packages/assets-controllers/src/TokenListController.test.ts +++ b/packages/assets-controllers/src/TokenListController.test.ts @@ -1835,6 +1835,85 @@ describe('TokenListController', () => { consoleErrorSpy.mockRestore(); controller.destroy(); }); + + it('should handle errors during migration to StorageService', async () => { + // Create messenger where getAllKeys throws to cause migration logic to fail + const messengerWithErrors = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + // Register getItem to return empty (no old storage data) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messengerWithErrors as any).registerActionHandler( + 'StorageService:getItem', + () => { + return {}; // No old single-file storage + }, + ); + + // Register setItem 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); + }, + ); + + // Register getAllKeys to throw error during migration check + // This will cause the migration logic itself to fail (not just the save) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messengerWithErrors as any).registerActionHandler( + 'StorageService:getAllKeys', + () => { + throw new Error('Failed to get keys during migration'); + }, + ); + + // 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 migration errors + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + // Initialize with state data that will trigger migration + const stateWithData = { + tokensChainsCache: { + [ChainId.mainnet]: { + data: sampleMainnetTokensChainsCache, + timestamp: Date.now(), + }, + }, + preventPollingOnNetworkRestart: false, + }; + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + state: stateWithData, + }); + + // Wait for async migration to attempt and fail + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify console.error was called with the migration error + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'TokenListController: Failed to migrate cache to storage:', + expect.any(Error), + ); + + consoleErrorSpy.mockRestore(); + controller.destroy(); + }); }); }); From eec88692cbf3407d657badc6020eb435c8dc16ab Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Mon, 15 Dec 2025 20:46:09 +0100 Subject: [PATCH 10/32] test: fix test --- .../src/TokenListController.test.ts | 199 +++++++++++++++++- .../src/TokenListController.ts | 71 ++----- 2 files changed, 219 insertions(+), 51 deletions(-) diff --git a/packages/assets-controllers/src/TokenListController.test.ts b/packages/assets-controllers/src/TokenListController.test.ts index 130defb2fe8..e187a1dcd92 100644 --- a/packages/assets-controllers/src/TokenListController.test.ts +++ b/packages/assets-controllers/src/TokenListController.test.ts @@ -519,10 +519,13 @@ const getMessenger = (): RootMessenger => { 'StorageService:getAllKeys', (controllerNamespace: string) => { const keys: string[] = []; + const prefix = `${controllerNamespace}:`; mockStorage.forEach((_value, key) => { - // Extract key without namespace prefix - const keyWithoutNamespace = key.replace(`${controllerNamespace}:`, ''); - keys.push(keyWithoutNamespace); + // Only include keys for this namespace + if (key.startsWith(prefix)) { + const keyWithoutNamespace = key.substring(prefix.length); + keys.push(keyWithoutNamespace); + } }); return keys; }, @@ -1195,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, + }); + + // Wait for initialization + await new Promise((resolve) => setTimeout(resolve, 100)); + + // 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; @@ -1914,6 +2007,106 @@ describe('TokenListController', () => { consoleErrorSpy.mockRestore(); controller.destroy(); }); + + it('should handle errors when clearing cache from StorageService', async () => { + // Create messenger where getAllKeys throws + const messengerWithErrors = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + // Register getAllKeys to throw error + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messengerWithErrors as any).registerActionHandler( + 'StorageService:getAllKeys', + () => { + throw new Error('Failed to get keys'); + }, + ); + + // 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); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + }); + + // Wait for initialization + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Mock console.error + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + // Try to clear - should catch error + await controller.clearingTokenListData(); + + // Verify error was logged + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'TokenListController: Failed to clear cache from storage:', + expect.any(Error), + ); + + consoleErrorSpy.mockRestore(); + 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(); + }); }); }); diff --git a/packages/assets-controllers/src/TokenListController.ts b/packages/assets-controllers/src/TokenListController.ts index 8d43ef3ebe3..1104ffde217 100644 --- a/packages/assets-controllers/src/TokenListController.ts +++ b/packages/assets-controllers/src/TokenListController.ts @@ -325,72 +325,47 @@ export class TokenListController extends StaticIntervalPollingController { try { - let dataToMigrate: TokensChainsCache | null = null; + // Check if we have data in state that needs migration + if ( + !this.state.tokensChainsCache || + Object.keys(this.state.tokensChainsCache).length === 0 + ) { + return; // No data to migrate + } - // Check for old single-file storage (previous StorageService version) - const { result: oldStorageData } = await this.messenger.call( - 'StorageService:getItem', + // Check if per-chain files already exist (migration already done) + const allKeys = await this.messenger.call( + 'StorageService:getAllKeys', name, - TokenListController.#storageKeyPrefix, // Old key without chain suffix + ); + const hasPerChainFiles = allKeys.some((key) => + key.startsWith(`${TokenListController.#storageKeyPrefix}:`), ); - if (oldStorageData) { - // Migrate from old single-file storage - dataToMigrate = oldStorageData as TokensChainsCache; - console.log( - 'TokenListController: Migrating from single-file to per-chain storage', - ); - } else if ( - this.state.tokensChainsCache && - Object.keys(this.state.tokensChainsCache).length > 0 - ) { - // Check if per-chain files already exist - const allKeys = await this.messenger.call( - 'StorageService:getAllKeys', - name, - ); - const hasPerChainFiles = allKeys.some((key) => - key.startsWith(`${TokenListController.#storageKeyPrefix}:`), - ); - - if (!hasPerChainFiles) { - // Migrate from old persisted state - dataToMigrate = this.state.tokensChainsCache; - console.log( - 'TokenListController: Migrating from persisted state to per-chain storage', - ); - } + if (hasPerChainFiles) { + return; // Already migrated } - if (!dataToMigrate) { - return; // Nothing to migrate - } + // Migrate from old persisted state to per-chain files + console.log( + 'TokenListController: Migrating from persisted state to per-chain storage', + ); // Split into per-chain files await Promise.all( - Object.keys(dataToMigrate).map((chainId) => + Object.keys(this.state.tokensChainsCache).map((chainId) => this.#saveChainCacheToStorage(chainId as Hex), ), ); - // Remove old single-file storage if it existed - if (oldStorageData) { - await this.messenger.call( - 'StorageService:removeItem', - name, - TokenListController.#storageKeyPrefix, - ); - } - console.log( 'TokenListController: Migration to per-chain storage complete', ); From 9332b6eff5be59516727377cf79a69b666c0c28f Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Thu, 18 Dec 2025 10:32:52 +0100 Subject: [PATCH 11/32] test: add test to ensure cache is loaded only once during multiple fetchTokenList calls --- .../src/TokenListController.test.ts | 101 ++++++++++++++++++ .../src/TokenListController.ts | 71 ++++++++---- 2 files changed, 151 insertions(+), 21 deletions(-) diff --git a/packages/assets-controllers/src/TokenListController.test.ts b/packages/assets-controllers/src/TokenListController.test.ts index e187a1dcd92..1a10930fd66 100644 --- a/packages/assets-controllers/src/TokenListController.test.ts +++ b/packages/assets-controllers/src/TokenListController.test.ts @@ -2069,6 +2069,107 @@ describe('TokenListController', () => { 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, + }); + + // Wait for initialization to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Record call counts after initialization + const getItemCallsAfterInit = getItemCallCount; + const getAllKeysCallsAfterInit = getAllKeysCallCount; + + // getAllKeys should be called twice during init (once for load, once for migration check) + expect(getAllKeysCallsAfterInit).toBe(2); + // 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(); + }); }); describe('deprecated methods', () => { diff --git a/packages/assets-controllers/src/TokenListController.ts b/packages/assets-controllers/src/TokenListController.ts index 1104ffde217..3ecd5112c0a 100644 --- a/packages/assets-controllers/src/TokenListController.ts +++ b/packages/assets-controllers/src/TokenListController.ts @@ -123,6 +123,12 @@ export class TokenListController extends StaticIntervalPollingController { readonly #mutex = new Mutex(); + /** + * Promise that resolves when initialization (loading cache from storage) is complete. + * Methods that access the cache should await this before proceeding. + */ + readonly #initializationPromise: Promise; + // Storage key prefix for per-chain files static readonly #storageKeyPrefix = 'tokensChainsCache'; @@ -191,18 +197,9 @@ export class TokenListController extends StaticIntervalPollingController { - // Migrate existing cache from state to StorageService if needed - return this.#migrateStateToStorage(); - }) - .catch((error) => { - console.error( - 'TokenListController: Failed to load cache from storage:', - error, - ); - }); + // Load cache from StorageService on initialization and handle migration. + // Store the promise so other methods can await it to avoid race conditions. + this.#initializationPromise = this.#initializeFromStorage(); if (onNetworkStateChange) { // TODO: Either fix this lint violation or explain why it's necessary to ignore. @@ -222,11 +219,36 @@ export class TokenListController extends StaticIntervalPollingController { + const releaseLock = await this.#mutex.acquire(); + try { + await this.#loadCacheFromStorage(); + await this.#migrateStateToStorage(); + } catch (error) { + console.error( + 'TokenListController: Failed to initialize from storage:', + error, + ); + } finally { + releaseLock(); + } + } + /** * 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 { @@ -278,10 +300,17 @@ export class TokenListController extends StaticIntervalPollingController 0) { this.update((state) => { - state.tokensChainsCache = loadedCache; + // 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; + } + } }); } } catch (error) { @@ -497,6 +526,10 @@ export class TokenListController extends StaticIntervalPollingController { + // Wait for initialization to complete before fetching + // This ensures we have loaded any cached data from storage first + await this.#initializationPromise; + const releaseLock = await this.#mutex.acquire(); try { if (this.isCacheValid(chainId)) { @@ -571,6 +604,9 @@ export class TokenListController extends StaticIntervalPollingController { + // Wait for initialization to complete before clearing + await this.#initializationPromise; + // Clear state this.update((state) => { state.tokensChainsCache = {}; @@ -593,13 +629,6 @@ export class TokenListController extends StaticIntervalPollingController Date: Thu, 18 Dec 2025 11:18:30 +0100 Subject: [PATCH 12/32] fix: fix race condition on clearingTokenListData --- .../src/TokenListController.ts | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/assets-controllers/src/TokenListController.ts b/packages/assets-controllers/src/TokenListController.ts index 3ecd5112c0a..f97da82b8b4 100644 --- a/packages/assets-controllers/src/TokenListController.ts +++ b/packages/assets-controllers/src/TokenListController.ts @@ -602,18 +602,27 @@ export class TokenListController extends StaticIntervalPollingController { // Wait for initialization to complete before clearing await this.#initializationPromise; - // Clear state - this.update((state) => { - state.tokensChainsCache = {}; - }); - - // Clear all per-chain files from StorageService + const releaseLock = await this.#mutex.acquire(); try { + // Clear state + this.update((state) => { + state.tokensChainsCache = {}; + }); + + // Clear all per-chain files from StorageService const allKeys = await this.messenger.call( 'StorageService:getAllKeys', name, @@ -634,6 +643,8 @@ export class TokenListController extends StaticIntervalPollingController Date: Thu, 18 Dec 2025 11:48:14 +0100 Subject: [PATCH 13/32] fix: fix state inconsistency on error --- .../src/TokenListController.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/assets-controllers/src/TokenListController.ts b/packages/assets-controllers/src/TokenListController.ts index f97da82b8b4..921570de946 100644 --- a/packages/assets-controllers/src/TokenListController.ts +++ b/packages/assets-controllers/src/TokenListController.ts @@ -603,13 +603,11 @@ export class TokenListController extends StaticIntervalPollingController { // Wait for initialization to complete before clearing @@ -617,12 +615,9 @@ export class TokenListController extends StaticIntervalPollingController { - state.tokensChainsCache = {}; - }); - - // Clear all per-chain files from StorageService + // Clear storage first to maintain consistency. + // If storage clearing fails, state remains unchanged and matches storage. + // This prevents stale data from reappearing after restart. const allKeys = await this.messenger.call( 'StorageService:getAllKeys', name, @@ -638,6 +633,11 @@ export class TokenListController extends StaticIntervalPollingController { + state.tokensChainsCache = {}; + }); } catch (error) { console.error( 'TokenListController: Failed to clear cache from storage:', From e74a52013924d74f74c3c5b890727f8a9e674f97 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Thu, 18 Dec 2025 13:31:13 +0100 Subject: [PATCH 14/32] fix: refactor and improve migration log --- .../src/TokenListController.ts | 40 ++++++++++++------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/packages/assets-controllers/src/TokenListController.ts b/packages/assets-controllers/src/TokenListController.ts index 921570de946..811ec204f6a 100644 --- a/packages/assets-controllers/src/TokenListController.ts +++ b/packages/assets-controllers/src/TokenListController.ts @@ -358,40 +358,52 @@ export class TokenListController extends StaticIntervalPollingController { try { // Check if we have data in state that needs migration - if ( - !this.state.tokensChainsCache || - Object.keys(this.state.tokensChainsCache).length === 0 - ) { + const chainsInState = Object.keys(this.state.tokensChainsCache) as Hex[]; + if (chainsInState.length === 0) { return; // No data to migrate } - // Check if per-chain files already exist (migration already done) + // Get existing per-chain files to determine which chains still need migration const allKeys = await this.messenger.call( 'StorageService:getAllKeys', name, ); - const hasPerChainFiles = allKeys.some((key) => - key.startsWith(`${TokenListController.#storageKeyPrefix}:`), + const existingChainKeys = new Set( + allKeys.filter((key) => + key.startsWith(`${TokenListController.#storageKeyPrefix}:`), + ), ); - if (hasPerChainFiles) { - return; // Already migrated + // Find chains that exist in state but not in storage (need migration) + const chainsMissingFromStorage = chainsInState.filter((chainId) => { + const storageKey = TokenListController.#getChainStorageKey(chainId); + return !existingChainKeys.has(storageKey); + }); + + if (chainsMissingFromStorage.length === 0) { + return; // All chains already migrated } - // Migrate from old persisted state to per-chain files + // Migrate only the chains that are missing from storage console.log( - 'TokenListController: Migrating from persisted state to per-chain storage', + `TokenListController: Migrating ${chainsMissingFromStorage.length} chain(s) from persisted state to per-chain storage`, ); - // Split into per-chain files + // Migrate chains in parallel. Individual failures are logged inside + // #saveChainCacheToStorage. If any fail, the cache will self-heal + // when fetchTokenList is called for that chain. await Promise.all( - Object.keys(this.state.tokensChainsCache).map((chainId) => - this.#saveChainCacheToStorage(chainId as Hex), + chainsMissingFromStorage.map((chainId) => + this.#saveChainCacheToStorage(chainId), ), ); From 6f637377d4ce90050f255f2c859e761353e8d7d9 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Thu, 18 Dec 2025 13:48:20 +0100 Subject: [PATCH 15/32] fix: fix timestamp save --- .../src/TokenListController.test.ts | 22 ++++++++++-------- .../src/TokenListController.ts | 23 +++++++++++-------- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/packages/assets-controllers/src/TokenListController.test.ts b/packages/assets-controllers/src/TokenListController.test.ts index 1a10930fd66..85ca14a8fca 100644 --- a/packages/assets-controllers/src/TokenListController.test.ts +++ b/packages/assets-controllers/src/TokenListController.test.ts @@ -1801,12 +1801,13 @@ describe('TokenListController', () => { 'StorageService:getAllKeys', (controllerNamespace: string) => { const keys: string[] = []; + const prefix = `${controllerNamespace}:`; mockStorage.forEach((_value, key) => { - const keyWithoutNamespace = key.replace( - `${controllerNamespace}:`, - '', - ); - keys.push(keyWithoutNamespace); + // Only include keys for this namespace + if (key.startsWith(prefix)) { + const keyWithoutNamespace = key.substring(prefix.length); + keys.push(keyWithoutNamespace); + } }); return keys; }, @@ -1885,12 +1886,13 @@ describe('TokenListController', () => { 'StorageService:getAllKeys', (controllerNamespace: string) => { const keys: string[] = []; + const prefix = `${controllerNamespace}:`; mockStorage.forEach((_value, key) => { - const keyWithoutNamespace = key.replace( - `${controllerNamespace}:`, - '', - ); - keys.push(keyWithoutNamespace); + // Only include keys for this namespace + if (key.startsWith(prefix)) { + const keyWithoutNamespace = key.substring(prefix.length); + keys.push(keyWithoutNamespace); + } }); return keys; }, diff --git a/packages/assets-controllers/src/TokenListController.ts b/packages/assets-controllers/src/TokenListController.ts index 811ec204f6a..b34c1747c06 100644 --- a/packages/assets-controllers/src/TokenListController.ts +++ b/packages/assets-controllers/src/TokenListController.ts @@ -586,16 +586,21 @@ export class TokenListController extends StaticIntervalPollingController { - state.tokensChainsCache[chainId] ??= newDataCache; - state.tokensChainsCache[chainId].timestamp = Date.now(); - }); - - // Persist only this chain to StorageService - await this.#saveChainCacheToStorage(chainId); + const existingCache = this.state.tokensChainsCache[chainId]; + if (!existingCache) { + // No existing cache - initialize empty and persist + const newDataCache: DataCache = { data: {}, timestamp: Date.now() }; + this.update((state) => { + state.tokensChainsCache[chainId] = newDataCache; + }); + await this.#saveChainCacheToStorage(chainId); + } + // If there's existing cache, keep it as-is (don't update timestamp or persist) } } finally { releaseLock(); From a2c42fbe7b2ec771096d2a5351ba2604fb3433cd Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Thu, 18 Dec 2025 14:52:13 +0100 Subject: [PATCH 16/32] fix: use promise.allSettled --- .../src/TokenListController.ts | 51 +++++++++++++++---- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/packages/assets-controllers/src/TokenListController.ts b/packages/assets-controllers/src/TokenListController.ts index b34c1747c06..0661d88f76b 100644 --- a/packages/assets-controllers/src/TokenListController.ts +++ b/packages/assets-controllers/src/TokenListController.ts @@ -620,9 +620,10 @@ export class TokenListController extends StaticIntervalPollingController { + 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), ), ); - // Only clear state after storage is successfully cleared + // 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) => { - state.tokensChainsCache = {}; + 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( From d1d8354f05cf58326dc46188749a3bc65044fe6c Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Thu, 18 Dec 2025 15:11:11 +0100 Subject: [PATCH 17/32] fix: clear state on storage access error --- packages/assets-controllers/CHANGELOG.md | 2 +- .../src/TokenListController.test.ts | 32 ++++++++++++++++--- .../src/TokenListController.ts | 6 ++++ 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 21338331969..36ffc0848f6 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- `TokenListController` now persists `tokensChainsCache` via `StorageService` using per-chain files to reduce write amplification ([#7413](https://github.com/MetaMask/core/pull/7413)) +- **BREAKING:** `TokenListController` now persists `tokensChainsCache` via `StorageService` using per-chain files to reduce write amplification ([#7413](https://github.com/MetaMask/core/pull/7413)) - Each chain's token cache (~100-500KB) is stored in a separate file, avoiding rewriting all chains (~5MB) on every update - Includes migration logic to automatically split existing cache data into per-chain files on first launch after upgrade - All chains are loaded in parallel at startup to maintain compatibility with TokenDetectionController diff --git a/packages/assets-controllers/src/TokenListController.test.ts b/packages/assets-controllers/src/TokenListController.test.ts index 85ca14a8fca..3cf80032978 100644 --- a/packages/assets-controllers/src/TokenListController.test.ts +++ b/packages/assets-controllers/src/TokenListController.test.ts @@ -2011,17 +2011,21 @@ describe('TokenListController', () => { }); it('should handle errors when clearing cache from StorageService', async () => { - // Create messenger where getAllKeys throws + // Create messenger where getAllKeys throws only during clear const messengerWithErrors = new Messenger({ namespace: MOCK_ANY_NAMESPACE, }); - // Register getAllKeys to throw error + 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', () => { - throw new Error('Failed to get keys'); + if (shouldThrow) { + throw new Error('Failed to get keys'); + } + return []; // Return empty array for initialization }, ); @@ -2048,18 +2052,34 @@ describe('TokenListController', () => { 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 }, }); // Wait for initialization await new Promise((resolve) => setTimeout(resolve, 100)); + // 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 + // Try to clear - should catch error but still clear state await controller.clearingTokenListData(); // Verify error was logged @@ -2068,6 +2088,10 @@ describe('TokenListController', () => { 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(); }); diff --git a/packages/assets-controllers/src/TokenListController.ts b/packages/assets-controllers/src/TokenListController.ts index 0661d88f76b..6b18a1f0449 100644 --- a/packages/assets-controllers/src/TokenListController.ts +++ b/packages/assets-controllers/src/TokenListController.ts @@ -693,6 +693,12 @@ export class TokenListController extends StaticIntervalPollingController { + state.tokensChainsCache = {}; + }); } finally { releaseLock(); } From eb78626d65495a65ff9675ac2e32f2a87c7afeac Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Thu, 18 Dec 2025 15:19:12 +0100 Subject: [PATCH 18/32] fix: changelog --- packages/assets-controllers/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 36ffc0848f6..0d64d6152e6 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **BREAKING:** `TokenListController` now persists `tokensChainsCache` via `StorageService` using per-chain files to reduce write amplification ([#7413](https://github.com/MetaMask/core/pull/7413)) +- **BREAKING:** `TokenListController` now persists `tokensChainsCache` via `StorageService` using per-chain files to reduce write amplification ([#7413](https://github.com/MetaMask/core/pull/7413)) - Each chain's token cache (~100-500KB) is stored in a separate file, avoiding rewriting all chains (~5MB) on every update - Includes migration logic to automatically split existing cache data into per-chain files on first launch after upgrade - All chains are loaded in parallel at startup to maintain compatibility with TokenDetectionController From e84ca0a0ca102dab17cecc33e6a2e18c230aed55 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Tue, 13 Jan 2026 13:16:02 +0100 Subject: [PATCH 19/32] fix: use subscribe to update state with debounce, rm migration and create new init --- .../src/TokenListController.test.ts | 126 ++++---- .../src/TokenListController.ts | 302 +++++++++--------- 2 files changed, 220 insertions(+), 208 deletions(-) diff --git a/packages/assets-controllers/src/TokenListController.test.ts b/packages/assets-controllers/src/TokenListController.test.ts index 3cf80032978..4eba18520e8 100644 --- a/packages/assets-controllers/src/TokenListController.test.ts +++ b/packages/assets-controllers/src/TokenListController.test.ts @@ -1256,8 +1256,8 @@ describe('TokenListController', () => { messenger: restrictedMessenger, }); - // Wait for initialization - await new Promise((resolve) => setTimeout(resolve, 100)); + // Initialize the controller + await controller.initialize(); // Mock console.error to verify error handling const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); @@ -1509,31 +1509,29 @@ describe('TokenListController', () => { }); describe('StorageService migration', () => { - it('should migrate tokensChainsCache from state to StorageService on first launch', async () => { + // 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); - // Simulate old persisted state with tokensChainsCache - const oldPersistedState = { - tokensChainsCache: { - [ChainId.mainnet]: { - data: sampleMainnetTokensChainsCache, - timestamp: Date.now(), - }, - }, - preventPollingOnNetworkRestart: false, - }; - const controller = new TokenListController({ chainId: ChainId.mainnet, messenger: restrictedMessenger, - state: oldPersistedState, }); - // Wait for async migration to complete - await new Promise((resolve) => setTimeout(resolve, 100)); + // 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)); - // Verify data was migrated to StorageService (per-chain file) const chainStorageKey = `tokensChainsCache:${ChainId.mainnet}`; const { result } = await messenger.call( 'StorageService:getItem', @@ -1545,7 +1543,6 @@ describe('TokenListController', () => { const resultCache = result as DataCache; expect(resultCache.data).toBeDefined(); expect(resultCache.timestamp).toBeDefined(); - expect(resultCache.data).toStrictEqual(sampleMainnetTokensChainsCache); controller.destroy(); }); @@ -1584,8 +1581,8 @@ describe('TokenListController', () => { state: stateWithDifferentData, }); - // Wait for migration logic to run - await new Promise((resolve) => setTimeout(resolve, 100)); + // Initialize the controller to trigger storage migration logic + await controller.initialize(); // Verify StorageService still has original data (not overwritten) const { result } = await messenger.call( @@ -1611,8 +1608,8 @@ describe('TokenListController', () => { state: { tokensChainsCache: {}, preventPollingOnNetworkRestart: false }, }); - // Wait for migration logic to run - await new Promise((resolve) => setTimeout(resolve, 100)); + // 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( @@ -1645,6 +1642,9 @@ describe('TokenListController', () => { 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) @@ -1674,6 +1674,9 @@ describe('TokenListController', () => { 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( @@ -1823,8 +1826,8 @@ describe('TokenListController', () => { messenger: restrictedMessenger, }); - // Wait for async loading to complete - await new Promise((resolve) => setTimeout(resolve, 100)); + // Initialize the controller to load from storage + await controller.initialize(); // Verify that mainnet chain loaded successfully expect(controller.state.tokensChainsCache[ChainId.mainnet]).toBeDefined(); @@ -1908,8 +1911,8 @@ describe('TokenListController', () => { messenger: restrictedMessenger, }); - // Wait for async initialization - await new Promise((resolve) => setTimeout(resolve, 100)); + // Initialize the controller + await controller.initialize(); // Try to fetch tokens - this should trigger save which will fail nock(tokenService.TOKEN_END_POINT_API) @@ -1918,6 +1921,9 @@ describe('TokenListController', () => { 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}:`, @@ -1931,39 +1937,35 @@ describe('TokenListController', () => { controller.destroy(); }); - it('should handle errors during migration to StorageService', async () => { - // Create messenger where getAllKeys throws to cause migration logic to fail + 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 (no old storage data) + // Register getItem to return empty // eslint-disable-next-line @typescript-eslint/no-explicit-any (messengerWithErrors as any).registerActionHandler( 'StorageService:getItem', () => { - return {}; // No old single-file storage + return {}; }, ); - // Register setItem normally + // Register setItem to throw error // 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); + () => { + throw new Error('Failed to save to storage'); }, ); - // Register getAllKeys to throw error during migration check - // This will cause the migration logic itself to fail (not just the save) + // Register getAllKeys normally // eslint-disable-next-line @typescript-eslint/no-explicit-any (messengerWithErrors as any).registerActionHandler( 'StorageService:getAllKeys', - () => { - throw new Error('Failed to get keys during migration'); - }, + () => [], ); // Register removeItem (not used in this test but required) @@ -1977,32 +1979,30 @@ describe('TokenListController', () => { const restrictedMessenger = getRestrictedMessenger(messengerWithErrors); - // Mock console.error to verify it's called for migration errors + // Mock console.error to verify it's called for persistence errors const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); - // Initialize with state data that will trigger migration - const stateWithData = { - tokensChainsCache: { - [ChainId.mainnet]: { - data: sampleMainnetTokensChainsCache, - timestamp: Date.now(), - }, - }, - preventPollingOnNetworkRestart: false, - }; - const controller = new TokenListController({ chainId: ChainId.mainnet, messenger: restrictedMessenger, - state: stateWithData, }); - // Wait for async migration to attempt and fail - await new Promise((resolve) => setTimeout(resolve, 100)); + // 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); - // Verify console.error was called with the migration error + // 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 migrate cache to storage:', + `TokenListController: Failed to save cache for ${ChainId.mainnet}:`, expect.any(Error), ); @@ -2065,8 +2065,8 @@ describe('TokenListController', () => { state: { tokensChainsCache: initialCache }, }); - // Wait for initialization - await new Promise((resolve) => setTimeout(resolve, 100)); + // Initialize the controller + await controller.initialize(); // Verify cache exists before clearing expect( @@ -2168,15 +2168,15 @@ describe('TokenListController', () => { messenger: restrictedMessenger, }); - // Wait for initialization to complete - await new Promise((resolve) => setTimeout(resolve, 100)); + // Initialize the controller + await controller.initialize(); // Record call counts after initialization const getItemCallsAfterInit = getItemCallCount; const getAllKeysCallsAfterInit = getAllKeysCallCount; - // getAllKeys should be called twice during init (once for load, once for migration check) - expect(getAllKeysCallsAfterInit).toBe(2); + // 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); diff --git a/packages/assets-controllers/src/TokenListController.ts b/packages/assets-controllers/src/TokenListController.ts index 6b18a1f0449..c9613666f92 100644 --- a/packages/assets-controllers/src/TokenListController.ts +++ b/packages/assets-controllers/src/TokenListController.ts @@ -18,7 +18,6 @@ import type { StorageServiceGetAllKeysAction, } from '@metamask/storage-service'; import type { Hex } from '@metamask/utils'; -import { Mutex } from 'async-mutex'; import { isTokenListSupportedForNetwork, @@ -121,13 +120,31 @@ export class TokenListController extends StaticIntervalPollingController { - readonly #mutex = new Mutex(); - /** * Promise that resolves when initialization (loading cache from storage) is complete. - * Methods that access the cache should await this before proceeding. */ - readonly #initializationPromise: Promise; + #initializationPromise: Promise = Promise.resolve(); + + /** + * Debounce timer for persisting state changes to storage. + */ + #persistDebounceTimer?: ReturnType; + + /** + * Tracks which chains have pending changes to persist. + * Only changed chains are persisted to reduce write amplification. + */ + readonly #changedChainsToPersist: 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'; @@ -197,9 +214,12 @@ 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. @@ -221,25 +241,94 @@ 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 { - const releaseLock = await this.#mutex.acquire(); try { await this.#loadCacheFromStorage(); - await this.#migrateStateToStorage(); + // Initialize previous cache to prevent re-persisting loaded data + this.#previousTokensChainsCache = { ...this.state.tokensChainsCache }; } catch (error) { console.error( 'TokenListController: Failed to initialize from storage:', error, ); - } finally { - releaseLock(); } } + /** + * 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) { + 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(() => { + this.#persistChangedChains().catch((error) => { + console.error('TokenListController: Failed to persist cache:', error); + }); + }, TokenListController.#persistDebounceMs); + } + + /** + * Persist only the chains that have changed to storage. + * Reduces write amplification by skipping unchanged chains. + * + * @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; + } + + await Promise.all( + chainsToPersist.map((chainId) => this.#saveChainCacheToStorage(chainId)), + ); + } + /** * Load tokensChainsCache from StorageService into state. * Loads all cached chains from separate per-chain files in parallel. @@ -353,71 +442,6 @@ export class TokenListController extends StaticIntervalPollingController { - try { - // Check if we have data in state that needs migration - const chainsInState = Object.keys(this.state.tokensChainsCache) as Hex[]; - if (chainsInState.length === 0) { - return; // No data to migrate - } - - // Get existing per-chain files to determine which chains still need migration - const allKeys = await this.messenger.call( - 'StorageService:getAllKeys', - name, - ); - const existingChainKeys = new Set( - allKeys.filter((key) => - key.startsWith(`${TokenListController.#storageKeyPrefix}:`), - ), - ); - - // Find chains that exist in state but not in storage (need migration) - const chainsMissingFromStorage = chainsInState.filter((chainId) => { - const storageKey = TokenListController.#getChainStorageKey(chainId); - return !existingChainKeys.has(storageKey); - }); - - if (chainsMissingFromStorage.length === 0) { - return; // All chains already migrated - } - - // Migrate only the chains that are missing from storage - console.log( - `TokenListController: Migrating ${chainsMissingFromStorage.length} chain(s) from persisted state to per-chain storage`, - ); - - // Migrate chains in parallel. Individual failures are logged inside - // #saveChainCacheToStorage. If any fail, the cache will self-heal - // when fetchTokenList is called for that chain. - await Promise.all( - chainsMissingFromStorage.map((chainId) => - this.#saveChainCacheToStorage(chainId), - ), - ); - - console.log( - 'TokenListController: Migration to per-chain storage complete', - ); - } catch (error) { - console.error( - 'TokenListController: Failed to migrate cache to storage:', - error, - ); - } - } - /** * Updates state and restarts polling on changes to the network controller * state. @@ -533,77 +557,64 @@ export class TokenListController extends StaticIntervalPollingController { - // Wait for initialization to complete before fetching - // This ensures we have loaded any cached data from storage first - await this.#initializationPromise; + if (this.isCacheValid(chainId)) { + return; + } - 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, + ); - // Fetch fresh token list from the API - const tokensFromAPI = await safelyExecute( - () => - fetchTokenListByChainId( + // 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, - this.#abortController.signal, - ) as Promise, - ); + tokenAddress: token.address, + }), + }; + } - // 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, - }), - }; - } + // Update state - persistence happens automatically via subscription + const newDataCache: DataCache = { + data: tokenList, + timestamp: Date.now(), + }; + this.update((state) => { + state.tokensChainsCache[chainId] = newDataCache; + }); + return; + } - // Update state - const newDataCache: DataCache = { - data: tokenList, - timestamp: Date.now(), - }; + // 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) => { state.tokensChainsCache[chainId] = newDataCache; }); - - // Persist only this chain to StorageService (reduces write amplification) - await this.#saveChainCacheToStorage(chainId); - 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 and persist - const newDataCache: DataCache = { data: {}, timestamp: Date.now() }; - this.update((state) => { - state.tokensChainsCache[chainId] = newDataCache; - }); - await this.#saveChainCacheToStorage(chainId); - } - // If there's existing cache, keep it as-is (don't update timestamp or persist) } - } finally { - releaseLock(); + // If there's existing cache, keep it as-is (don't update timestamp or persist) } } @@ -625,13 +636,18 @@ export class TokenListController extends StaticIntervalPollingController { - // Wait for initialization to complete before clearing - await this.#initializationPromise; + // Cancel any pending persist operations since we're clearing + if (this.#persistDebounceTimer) { + clearTimeout(this.#persistDebounceTimer); + this.#persistDebounceTimer = undefined; + } + this.#changedChainsToPersist.clear(); + this.#previousTokensChainsCache = {}; - const releaseLock = await this.#mutex.acquire(); try { const allKeys = await this.messenger.call( 'StorageService:getAllKeys', @@ -693,14 +709,10 @@ export class TokenListController extends StaticIntervalPollingController { state.tokensChainsCache = {}; }); - } finally { - releaseLock(); } } From f6cac32398eb12d9f164ea45507b7ac8dc801227 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Tue, 13 Jan 2026 14:21:29 +0100 Subject: [PATCH 20/32] fix: test cov --- .../src/TokenListController.test.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/assets-controllers/src/TokenListController.test.ts b/packages/assets-controllers/src/TokenListController.test.ts index 9c0b633550a..6e97515d2a9 100644 --- a/packages/assets-controllers/src/TokenListController.test.ts +++ b/packages/assets-controllers/src/TokenListController.test.ts @@ -2196,6 +2196,63 @@ describe('TokenListController', () => { controller.destroy(); }); + + it('should handle errors during initialization from storage', async () => { + // Create messenger where getAllKeys throws + const messengerWithErrors = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + // Register getAllKeys to throw + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messengerWithErrors as any).registerActionHandler( + 'StorageService:getAllKeys', + () => { + throw new Error('Failed to get keys'); + }, + ); + + // 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', + () => { + // Do nothing + }, + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messengerWithErrors as any).registerActionHandler( + 'StorageService:removeItem', + () => { + // Do nothing + }, + ); + + const restrictedMessenger = getRestrictedMessenger(messengerWithErrors); + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + }); + + // Initialize should catch error + await controller.initialize(); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'TokenListController: Failed to load cache from storage:', + expect.any(Error), + ); + + consoleErrorSpy.mockRestore(); + controller.destroy(); + }); }); describe('deprecated methods', () => { From abd120eb56be6dcb8a72602eb7d982f5568cdc6e Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Tue, 13 Jan 2026 14:25:31 +0100 Subject: [PATCH 21/32] fix: test cov --- .../src/TokenListController.test.ts | 245 ++++++++++++++++++ 1 file changed, 245 insertions(+) diff --git a/packages/assets-controllers/src/TokenListController.test.ts b/packages/assets-controllers/src/TokenListController.test.ts index 6e97515d2a9..7a1b1dc3ed4 100644 --- a/packages/assets-controllers/src/TokenListController.test.ts +++ b/packages/assets-controllers/src/TokenListController.test.ts @@ -2196,7 +2196,9 @@ describe('TokenListController', () => { controller.destroy(); }); + }); + describe('edge cases for coverage', () => { it('should handle errors during initialization from storage', async () => { // Create messenger where getAllKeys throws const messengerWithErrors = new Messenger({ @@ -2250,6 +2252,249 @@ describe('TokenListController', () => { expect.any(Error), ); + consoleErrorSpy.mockRestore(); + controller.destroy(); + }); + it('should handle partial failures when clearing cache from StorageService', async () => { + const messenger = getMessenger(); + const restrictedMessenger = getRestrictedMessenger(messenger); + + // Pre-populate storage with two chains + const chainStorageKey1 = `tokensChainsCache:${ChainId.mainnet}`; + const chainStorageKey2 = `tokensChainsCache:${ChainId.goerli}`; + const chainData: DataCache = { + data: sampleMainnetTokensChainsCache, + timestamp: Date.now(), + }; + + await messenger.call( + 'StorageService:setItem', + 'TokenListController', + chainStorageKey1, + chainData, + ); + await messenger.call( + 'StorageService:setItem', + 'TokenListController', + chainStorageKey2, + chainData, + ); + + // Create controller with both chains in state + const controller = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger, + state: { + tokensChainsCache: { + [ChainId.mainnet]: chainData, + [ChainId.goerli]: chainData, + }, + preventPollingOnNetworkRestart: false, + }, + }); + + await controller.initialize(); + + // Create a new messenger where removeItem fails for one chain + const messengerWithPartialFailure = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messengerWithPartialFailure as any).registerActionHandler( + 'StorageService:getAllKeys', + () => [chainStorageKey1, chainStorageKey2], + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messengerWithPartialFailure as any).registerActionHandler( + 'StorageService:removeItem', + async (_controllerNamespace: string, key: string) => { + if (key === chainStorageKey2) { + return Promise.reject(new Error('Failed to remove')); + } + }, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messengerWithPartialFailure as any).registerActionHandler( + 'StorageService:getItem', + () => ({}), + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messengerWithPartialFailure as any).registerActionHandler( + 'StorageService:setItem', + () => { + // Do nothing + }, + ); + + const restrictedMessenger2 = getRestrictedMessenger( + messengerWithPartialFailure, + ); + + const controller2 = new TokenListController({ + chainId: ChainId.mainnet, + messenger: restrictedMessenger2, + state: { + tokensChainsCache: { + [ChainId.mainnet]: chainData, + [ChainId.goerli]: chainData, + }, + preventPollingOnNetworkRestart: false, + }, + }); + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + await controller2.clearingTokenListData(); + + // Should have logged error for the failed chain + expect(consoleErrorSpy).toHaveBeenCalledWith( + `TokenListController: Failed to remove cache for chain ${ChainId.goerli}:`, + expect.any(Error), + ); + + // Failed chain should be preserved in state + expect(controller2.state.tokensChainsCache[ChainId.goerli]).toBeDefined(); + // Successful chain should be cleared + expect( + controller2.state.tokensChainsCache[ChainId.mainnet], + ).toBeUndefined(); + + consoleErrorSpy.mockRestore(); + controller.destroy(); + controller2.destroy(); + }); + }); + + describe('debounce timer cancellation', () => { + it('should cancel pending persist when clearing token list data', 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.initialize(); + + // Fetch tokens to trigger state change and start debounce timer + await controller.fetchTokenList(ChainId.mainnet); + + // Immediately clear (before debounce timer fires) - this should cancel the timer + await controller.clearingTokenListData(); + + // Wait past the debounce time + await new Promise((resolve) => setTimeout(resolve, 600)); + + // State should be cleared + expect(controller.state.tokensChainsCache).toStrictEqual({}); + + controller.destroy(); + }); + }); + + describe('network change error handling', () => { + it('should handle clearingTokenListData errors on preventPollingOnNetworkRestart', async () => { + const getNetworkClientById = buildMockGetNetworkClientById({ + [InfuraNetworkType.mainnet]: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.mainnet, + ), + [InfuraNetworkType.sepolia]: buildInfuraNetworkClientConfiguration( + InfuraNetworkType.sepolia, + ), + }); + + // Create messenger where getAllKeys throws during clear + const messengerWithErrors = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + let clearAttempted = false; + + // Register getAllKeys to throw after first successful call + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messengerWithErrors as any).registerActionHandler( + 'StorageService:getAllKeys', + () => { + if (clearAttempted) { + throw new Error('Failed to get keys during clear'); + } + return []; + }, + ); + + // 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', + () => { + // Do nothing + }, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messengerWithErrors as any).registerActionHandler( + 'StorageService:removeItem', + () => { + // Do nothing + }, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messengerWithErrors as any).registerActionHandler( + 'NetworkController:getNetworkClientById', + getNetworkClientById, + ); + + const restrictedMessenger = getRestrictedMessenger(messengerWithErrors); + + const controller = new TokenListController({ + chainId: ChainId.mainnet, + preventPollingOnNetworkRestart: true, + messenger: restrictedMessenger, + }); + + await controller.initialize(); + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + // Now make getAllKeys throw for subsequent calls (during clear on network change) + clearAttempted = true; + + // Trigger network change which should attempt to clear and catch error + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (messengerWithErrors as any).publish( + 'NetworkController:stateChange', + { + selectedNetworkClientId: InfuraNetworkType.sepolia, + networkConfigurationsByChainId: {}, + networksMetadata: {}, + }, + [], + ); + + // Wait for async error handling + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Error is caught inside clearingTokenListData, so this message is logged + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'TokenListController: Failed to clear cache from storage:', + expect.any(Error), + ); + consoleErrorSpy.mockRestore(); controller.destroy(); }); From b0d745f9f045a86eb36d305ea7b48d39e2d25cfc Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Tue, 13 Jan 2026 14:34:12 +0100 Subject: [PATCH 22/32] fix: changelog --- packages/assets-controllers/CHANGELOG.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 69fc136a53f..5fbfae656f2 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -9,6 +9,12 @@ 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)) ## [95.1.0] @@ -47,11 +53,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **BREAKING:** `TokenListController` now persists `tokensChainsCache` via `StorageService` using per-chain files to reduce write amplification ([#7413](https://github.com/MetaMask/core/pull/7413)) - - Each chain's token cache (~100-500KB) is stored in a separate file, avoiding rewriting all chains (~5MB) on every update - - Includes migration logic to automatically split existing cache data into per-chain files on first launch after upgrade - - All chains are loaded in parallel at startup to maintain compatibility with TokenDetectionController - - `tokensChainsCache` state metadata now has `persist: false` to prevent duplicate persistence - Bump `@metamask/snaps-controllers` from `^14.0.1` to `^17.2.0` ([#7550](https://github.com/MetaMask/core/pull/7550)) - Bump `@metamask/snaps-sdk` from `^9.0.0` to `^10.3.0` ([#7550](https://github.com/MetaMask/core/pull/7550)) - Bump `@metamask/snaps-utils` from `^11.0.0` to `^11.7.0` ([#7550](https://github.com/MetaMask/core/pull/7550)) From dd094a6095247a2de1a3590a737e0d0c0feb7771 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Tue, 13 Jan 2026 14:45:19 +0100 Subject: [PATCH 23/32] fix: cleanup debounce on destroy --- packages/assets-controllers/src/TokenListController.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/assets-controllers/src/TokenListController.ts b/packages/assets-controllers/src/TokenListController.ts index ead04ff4093..e91c7d7cf39 100644 --- a/packages/assets-controllers/src/TokenListController.ts +++ b/packages/assets-controllers/src/TokenListController.ts @@ -516,6 +516,13 @@ export class TokenListController extends StaticIntervalPollingController Date: Tue, 13 Jan 2026 15:07:51 +0100 Subject: [PATCH 24/32] fix: fix loading cache from storage --- .../src/TokenListController.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/assets-controllers/src/TokenListController.ts b/packages/assets-controllers/src/TokenListController.ts index e91c7d7cf39..3ab4d459572 100644 --- a/packages/assets-controllers/src/TokenListController.ts +++ b/packages/assets-controllers/src/TokenListController.ts @@ -260,8 +260,6 @@ export class TokenListController extends StaticIntervalPollingController { try { await this.#loadCacheFromStorage(); - // Initialize previous cache to prevent re-persisting loaded data - this.#previousTokensChainsCache = { ...this.state.tokensChainsCache }; } catch (error) { console.error( 'TokenListController: Failed to initialize from storage:', @@ -394,6 +392,21 @@ export class TokenListController extends StaticIntervalPollingController 0) { + // Compute the final cache state before calling update(). + // We must set #previousTokensChainsCache to match the final state BEFORE + // the update() call, because update() triggers the stateChange subscription + // synchronously. If #previousTokensChainsCache is still {} when the + // subscription fires, all loaded chains would be detected as "new" and + // scheduled for re-persistence (which defeats the purpose of loading from storage). + const existingCache = this.state.tokensChainsCache; + const finalCache: TokensChainsCache = { ...existingCache }; + for (const [chainId, cacheData] of Object.entries(loadedCache)) { + if (!finalCache[chainId as Hex]) { + finalCache[chainId as Hex] = cacheData; + } + } + this.#previousTokensChainsCache = finalCache; + this.update((state) => { // Only load chains that don't already exist in state // This prevents overwriting fresh API data with stale cached data From aaf4ec8a091dae5087321447f8fa7a04c6dd1956 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Tue, 13 Jan 2026 15:27:55 +0100 Subject: [PATCH 25/32] fix: fix race between in progress persistence and clear --- .../src/TokenListController.ts | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/assets-controllers/src/TokenListController.ts b/packages/assets-controllers/src/TokenListController.ts index 3ab4d459572..2f4a5f269e7 100644 --- a/packages/assets-controllers/src/TokenListController.ts +++ b/packages/assets-controllers/src/TokenListController.ts @@ -132,6 +132,12 @@ export class TokenListController extends StaticIntervalPollingController; + /** + * 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. @@ -314,6 +320,10 @@ export class TokenListController extends StaticIntervalPollingController { @@ -324,9 +334,15 @@ export class TokenListController extends StaticIntervalPollingController this.#saveChainCacheToStorage(chainId)), - ); + ).then(() => undefined); // Convert Promise to Promise + + try { + await this.#persistInFlightPromise; + } finally { + this.#persistInFlightPromise = undefined; + } } /** @@ -662,7 +678,6 @@ export class TokenListController extends StaticIntervalPollingController { - // Cancel any pending persist operations since we're clearing if (this.#persistDebounceTimer) { clearTimeout(this.#persistDebounceTimer); this.#persistDebounceTimer = undefined; @@ -670,6 +685,17 @@ export class TokenListController extends StaticIntervalPollingController Date: Tue, 13 Jan 2026 16:18:32 +0100 Subject: [PATCH 26/32] fix: fix persistence on initialization --- .../src/TokenListController.test.ts | 89 +++++++++++++++++++ .../src/TokenListController.ts | 27 +++--- 2 files changed, 101 insertions(+), 15 deletions(-) diff --git a/packages/assets-controllers/src/TokenListController.test.ts b/packages/assets-controllers/src/TokenListController.test.ts index 7a1b1dc3ed4..9338711c6f2 100644 --- a/packages/assets-controllers/src/TokenListController.test.ts +++ b/packages/assets-controllers/src/TokenListController.test.ts @@ -2196,6 +2196,95 @@ describe('TokenListController', () => { 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(); + }); }); describe('edge cases for coverage', () => { diff --git a/packages/assets-controllers/src/TokenListController.ts b/packages/assets-controllers/src/TokenListController.ts index 2f4a5f269e7..6327cdb42ab 100644 --- a/packages/assets-controllers/src/TokenListController.ts +++ b/packages/assets-controllers/src/TokenListController.ts @@ -408,21 +408,6 @@ export class TokenListController extends StaticIntervalPollingController 0) { - // Compute the final cache state before calling update(). - // We must set #previousTokensChainsCache to match the final state BEFORE - // the update() call, because update() triggers the stateChange subscription - // synchronously. If #previousTokensChainsCache is still {} when the - // subscription fires, all loaded chains would be detected as "new" and - // scheduled for re-persistence (which defeats the purpose of loading from storage). - const existingCache = this.state.tokensChainsCache; - const finalCache: TokensChainsCache = { ...existingCache }; - for (const [chainId, cacheData] of Object.entries(loadedCache)) { - if (!finalCache[chainId as Hex]) { - finalCache[chainId as Hex] = cacheData; - } - } - this.#previousTokensChainsCache = finalCache; - this.update((state) => { // Only load chains that don't already exist in state // This prevents overwriting fresh API data with stale cached data @@ -432,6 +417,18 @@ export class TokenListController extends StaticIntervalPollingController Date: Tue, 13 Jan 2026 18:26:39 +0100 Subject: [PATCH 27/32] fix: test cov --- .../ERC1155/ERC1155Standard.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.test.ts b/packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.test.ts index d35857522a2..5014754f652 100644 --- a/packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.test.ts +++ b/packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.test.ts @@ -1,4 +1,5 @@ import { Web3Provider } from '@ethersproject/providers'; +import * as controllerUtils from '@metamask/controller-utils'; import HttpProvider from '@metamask/ethjs-provider-http'; import nock from 'nock'; @@ -420,5 +421,44 @@ describe('ERC1155Standard', () => { // Restore original methods jest.restoreAllMocks(); }); + + it('should successfully fetch and parse metadata with image', async () => { + // Mock successful ERC1155 interface check + jest + .spyOn(erc1155Standard, 'contractSupportsBase1155Interface') + .mockResolvedValue(true); + jest.spyOn(erc1155Standard, 'getAssetSymbol').mockResolvedValue('TEST'); + jest + .spyOn(erc1155Standard, 'getAssetName') + .mockResolvedValue('Test Token'); + jest + .spyOn(erc1155Standard, 'getTokenURI') + .mockResolvedValue('https://example.com/metadata.json'); + + // Mock timeoutFetch to return successful response with image + jest.spyOn(controllerUtils, 'timeoutFetch').mockResolvedValue({ + json: async () => ({ + name: 'Test NFT', + description: 'A test NFT', + image: 'https://example.com/image.png', + }), + } as Response); + + const ipfsGateway = 'https://ipfs.gateway.com'; + const details = await erc1155Standard.getDetails( + ERC1155_ADDRESS, + ipfsGateway, + SAMPLE_TOKEN_ID, + ); + + expect(details.standard).toBe('ERC1155'); + expect(details.tokenURI).toBe('https://example.com/metadata.json'); + expect(details.image).toBe('https://example.com/image.png'); + expect(details.symbol).toBe('TEST'); + expect(details.name).toBe('Test Token'); + + // Restore original methods + jest.restoreAllMocks(); + }); }); }); From 1cd37df343ab90cd954f4bc6f9736ad2a0acb4ee Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Tue, 13 Jan 2026 18:51:35 +0100 Subject: [PATCH 28/32] fix: lint --- packages/assets-controllers/src/TokenListController.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/assets-controllers/src/TokenListController.test.ts b/packages/assets-controllers/src/TokenListController.test.ts index 9338711c6f2..65bf5d2022e 100644 --- a/packages/assets-controllers/src/TokenListController.test.ts +++ b/packages/assets-controllers/src/TokenListController.test.ts @@ -2400,7 +2400,7 @@ describe('TokenListController', () => { 'StorageService:removeItem', async (_controllerNamespace: string, key: string) => { if (key === chainStorageKey2) { - return Promise.reject(new Error('Failed to remove')); + throw new Error('Failed to remove'); } }, ); From e88e572f5c835b43d8bc3c8b711d657f09d18a13 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Wed, 14 Jan 2026 08:55:05 +0100 Subject: [PATCH 29/32] fix: fix coverage --- .../ERC1155/ERC1155Standard.test.ts | 39 --- .../src/TokenListController.test.ts | 318 ++++-------------- .../src/TokenListController.ts | 39 +-- 3 files changed, 80 insertions(+), 316 deletions(-) diff --git a/packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.test.ts b/packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.test.ts index 5014754f652..f4fba6de71f 100644 --- a/packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.test.ts +++ b/packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.test.ts @@ -421,44 +421,5 @@ describe('ERC1155Standard', () => { // Restore original methods jest.restoreAllMocks(); }); - - it('should successfully fetch and parse metadata with image', async () => { - // Mock successful ERC1155 interface check - jest - .spyOn(erc1155Standard, 'contractSupportsBase1155Interface') - .mockResolvedValue(true); - jest.spyOn(erc1155Standard, 'getAssetSymbol').mockResolvedValue('TEST'); - jest - .spyOn(erc1155Standard, 'getAssetName') - .mockResolvedValue('Test Token'); - jest - .spyOn(erc1155Standard, 'getTokenURI') - .mockResolvedValue('https://example.com/metadata.json'); - - // Mock timeoutFetch to return successful response with image - jest.spyOn(controllerUtils, 'timeoutFetch').mockResolvedValue({ - json: async () => ({ - name: 'Test NFT', - description: 'A test NFT', - image: 'https://example.com/image.png', - }), - } as Response); - - const ipfsGateway = 'https://ipfs.gateway.com'; - const details = await erc1155Standard.getDetails( - ERC1155_ADDRESS, - ipfsGateway, - SAMPLE_TOKEN_ID, - ); - - expect(details.standard).toBe('ERC1155'); - expect(details.tokenURI).toBe('https://example.com/metadata.json'); - expect(details.image).toBe('https://example.com/image.png'); - expect(details.symbol).toBe('TEST'); - expect(details.name).toBe('Test Token'); - - // Restore original methods - jest.restoreAllMocks(); - }); }); }); diff --git a/packages/assets-controllers/src/TokenListController.test.ts b/packages/assets-controllers/src/TokenListController.test.ts index 65bf5d2022e..44166b0c692 100644 --- a/packages/assets-controllers/src/TokenListController.test.ts +++ b/packages/assets-controllers/src/TokenListController.test.ts @@ -2285,310 +2285,112 @@ describe('TokenListController', () => { controller.destroy(); }); - }); - describe('edge cases for coverage', () => { - it('should handle errors during initialization from storage', async () => { - // Create messenger where getAllKeys throws - const messengerWithErrors = new Messenger({ + 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, }); - // Register getAllKeys to throw // eslint-disable-next-line @typescript-eslint/no-explicit-any - (messengerWithErrors as any).registerActionHandler( - 'StorageService:getAllKeys', - () => { - throw new Error('Failed to get keys'); + (trackingMessenger as any).registerActionHandler( + 'StorageService:getItem', + (controllerNamespace: string, key: string) => { + const storageKey = `${controllerNamespace}:${key}`; + const value = mockStorage.get(storageKey); + return value ? { result: value } : {}; }, ); - // 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( + (trackingMessenger as any).registerActionHandler( 'StorageService:setItem', - () => { - // Do nothing + (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 - (messengerWithErrors as any).registerActionHandler( + (trackingMessenger as any).registerActionHandler( 'StorageService:removeItem', - () => { - // Do nothing + (controllerNamespace: string, key: string) => { + const storageKey = `${controllerNamespace}:${key}`; + mockStorage.delete(storageKey); }, ); - const restrictedMessenger = getRestrictedMessenger(messengerWithErrors); - - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); - - const controller = new TokenListController({ - chainId: ChainId.mainnet, - messenger: restrictedMessenger, - }); - - // Initialize should catch error - await controller.initialize(); - - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'TokenListController: Failed to load cache from storage:', - expect.any(Error), + // 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; + }, ); - consoleErrorSpy.mockRestore(); - controller.destroy(); - }); - it('should handle partial failures when clearing cache from StorageService', async () => { - const messenger = getMessenger(); - const restrictedMessenger = getRestrictedMessenger(messenger); + const restrictedMessenger = getRestrictedMessenger(trackingMessenger); - // Pre-populate storage with two chains - const chainStorageKey1 = `tokensChainsCache:${ChainId.mainnet}`; - const chainStorageKey2 = `tokensChainsCache:${ChainId.goerli}`; - const chainData: DataCache = { + // Create initial state with chain A (mainnet) - NOT in storage + const chainAData: DataCache = { data: sampleMainnetTokensChainsCache, timestamp: Date.now(), }; - await messenger.call( - 'StorageService:setItem', - 'TokenListController', - chainStorageKey1, - chainData, - ); - await messenger.call( - 'StorageService:setItem', - 'TokenListController', - chainStorageKey2, - chainData, - ); - - // Create controller with both chains in state const controller = new TokenListController({ chainId: ChainId.mainnet, messenger: restrictedMessenger, state: { tokensChainsCache: { - [ChainId.mainnet]: chainData, - [ChainId.goerli]: chainData, + [ChainId.mainnet]: chainAData, }, preventPollingOnNetworkRestart: false, }, }); + // Initialize - this should load chain B from storage AND schedule chain A for persistence await controller.initialize(); - // Create a new messenger where removeItem fails for one chain - const messengerWithPartialFailure = new Messenger({ - namespace: MOCK_ANY_NAMESPACE, - }); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (messengerWithPartialFailure as any).registerActionHandler( - 'StorageService:getAllKeys', - () => [chainStorageKey1, chainStorageKey2], - ); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (messengerWithPartialFailure as any).registerActionHandler( - 'StorageService:removeItem', - async (_controllerNamespace: string, key: string) => { - if (key === chainStorageKey2) { - throw new Error('Failed to remove'); - } - }, - ); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (messengerWithPartialFailure as any).registerActionHandler( - 'StorageService:getItem', - () => ({}), - ); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (messengerWithPartialFailure as any).registerActionHandler( - 'StorageService:setItem', - () => { - // Do nothing - }, - ); - - const restrictedMessenger2 = getRestrictedMessenger( - messengerWithPartialFailure, - ); - - const controller2 = new TokenListController({ - chainId: ChainId.mainnet, - messenger: restrictedMessenger2, - state: { - tokensChainsCache: { - [ChainId.mainnet]: chainData, - [ChainId.goerli]: chainData, - }, - preventPollingOnNetworkRestart: false, - }, - }); - - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); - - await controller2.clearingTokenListData(); - - // Should have logged error for the failed chain - expect(consoleErrorSpy).toHaveBeenCalledWith( - `TokenListController: Failed to remove cache for chain ${ChainId.goerli}:`, - expect.any(Error), - ); - - // Failed chain should be preserved in state - expect(controller2.state.tokensChainsCache[ChainId.goerli]).toBeDefined(); - // Successful chain should be cleared + // Verify both chains are in state + expect(controller.state.tokensChainsCache[ChainId.mainnet]).toBeDefined(); expect( - controller2.state.tokensChainsCache[ChainId.mainnet], - ).toBeUndefined(); - - consoleErrorSpy.mockRestore(); - controller.destroy(); - controller2.destroy(); - }); - }); - - describe('debounce timer cancellation', () => { - it('should cancel pending persist when clearing token list data', async () => { - const messenger = getMessenger(); - const restrictedMessenger = getRestrictedMessenger(messenger); + controller.state.tokensChainsCache[ChainId['bsc-mainnet']], + ).toBeDefined(); - nock(tokenService.TOKEN_END_POINT_API) - .get(getTokensPath(ChainId.mainnet)) - .reply(200, sampleMainnetTokenList); - - const controller = new TokenListController({ - chainId: ChainId.mainnet, - messenger: restrictedMessenger, - }); - - await controller.initialize(); - - // Fetch tokens to trigger state change and start debounce timer - await controller.fetchTokenList(ChainId.mainnet); - - // Immediately clear (before debounce timer fires) - this should cancel the timer - await controller.clearingTokenListData(); - - // Wait past the debounce time + // Wait for debounced persistence to complete (500ms + buffer) await new Promise((resolve) => setTimeout(resolve, 600)); - // State should be cleared - expect(controller.state.tokensChainsCache).toStrictEqual({}); - - controller.destroy(); - }); - }); - - describe('network change error handling', () => { - it('should handle clearingTokenListData errors on preventPollingOnNetworkRestart', async () => { - const getNetworkClientById = buildMockGetNetworkClientById({ - [InfuraNetworkType.mainnet]: buildInfuraNetworkClientConfiguration( - InfuraNetworkType.mainnet, - ), - [InfuraNetworkType.sepolia]: buildInfuraNetworkClientConfiguration( - InfuraNetworkType.sepolia, - ), - }); - - // Create messenger where getAllKeys throws during clear - const messengerWithErrors = new Messenger({ - namespace: MOCK_ANY_NAMESPACE, - }); - - let clearAttempted = false; + // Verify chain A (mainnet) was persisted since it was in initial state but not in storage + expect(persistedChains).toContain(`tokensChainsCache:${ChainId.mainnet}`); - // Register getAllKeys to throw after first successful call - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (messengerWithErrors as any).registerActionHandler( - 'StorageService:getAllKeys', - () => { - if (clearAttempted) { - throw new Error('Failed to get keys during clear'); - } - return []; - }, - ); - - // 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', - () => { - // Do nothing - }, - ); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (messengerWithErrors as any).registerActionHandler( - 'StorageService:removeItem', - () => { - // Do nothing - }, - ); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (messengerWithErrors as any).registerActionHandler( - 'NetworkController:getNetworkClientById', - getNetworkClientById, - ); - - const restrictedMessenger = getRestrictedMessenger(messengerWithErrors); - - const controller = new TokenListController({ - chainId: ChainId.mainnet, - preventPollingOnNetworkRestart: true, - messenger: restrictedMessenger, - }); - - await controller.initialize(); - - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); - - // Now make getAllKeys throw for subsequent calls (during clear on network change) - clearAttempted = true; - - // Trigger network change which should attempt to clear and catch error - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (messengerWithErrors as any).publish( - 'NetworkController:stateChange', - { - selectedNetworkClientId: InfuraNetworkType.sepolia, - networkConfigurationsByChainId: {}, - networksMetadata: {}, - }, - [], - ); - - // Wait for async error handling - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Error is caught inside clearingTokenListData, so this message is logged - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'TokenListController: Failed to clear cache from storage:', - expect.any(Error), + // Verify chain B (bsc-mainnet) was NOT re-persisted since it was loaded from storage + expect(persistedChains).not.toContain( + `tokensChainsCache:${ChainId['bsc-mainnet']}`, ); - consoleErrorSpy.mockRestore(); controller.destroy(); }); }); - describe('deprecated methods', () => { it('should restart polling when restart() is called', async () => { const messenger = getMessenger(); diff --git a/packages/assets-controllers/src/TokenListController.ts b/packages/assets-controllers/src/TokenListController.ts index 6327cdb42ab..27332689ddf 100644 --- a/packages/assets-controllers/src/TokenListController.ts +++ b/packages/assets-controllers/src/TokenListController.ts @@ -264,14 +264,7 @@ export class TokenListController extends StaticIntervalPollingController { - try { - await this.#loadCacheFromStorage(); - } catch (error) { - console.error( - 'TokenListController: Failed to initialize from storage:', - error, - ); - } + await this.#loadCacheFromStorage(); } /** @@ -310,9 +303,10 @@ export class TokenListController extends StaticIntervalPollingController { - this.#persistChangedChains().catch((error) => { - console.error('TokenListController: Failed to persist cache:', error); - }); + // 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); } @@ -358,7 +352,6 @@ export class TokenListController extends StaticIntervalPollingController { try { - // Get all keys for this controller const allKeys = await this.messenger.call( 'StorageService:getAllKeys', name, @@ -370,7 +363,7 @@ export class TokenListController extends StaticIntervalPollingController 0) { + this.#debouncePersist(); + } } } catch (error) { console.error( @@ -490,9 +492,8 @@ export class TokenListController extends StaticIntervalPollingController { - console.error('Failed to clear token list data:', error); - }); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.clearingTokenListData(); } } } From d98f8ccb676736d5508cfb430dea1f209d2be9d4 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Wed, 14 Jan 2026 09:10:25 +0100 Subject: [PATCH 30/32] fix: rm not needed import --- .../src/Standards/NftStandards/ERC1155/ERC1155Standard.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.test.ts b/packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.test.ts index f4fba6de71f..d35857522a2 100644 --- a/packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.test.ts +++ b/packages/assets-controllers/src/Standards/NftStandards/ERC1155/ERC1155Standard.test.ts @@ -1,5 +1,4 @@ import { Web3Provider } from '@ethersproject/providers'; -import * as controllerUtils from '@metamask/controller-utils'; import HttpProvider from '@metamask/ethjs-provider-http'; import nock from 'nock'; From 821afcb8a4e8194cb7407ffda6b6e8b3ebcad74e Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Wed, 14 Jan 2026 09:24:53 +0100 Subject: [PATCH 31/32] fix: lint --- eslint-suppressions.json | 8 -------- 1 file changed, 8 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 746952f94dd..20d89d97038 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 From 009e026d597105e6864759e911218e43fc441ad5 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Wed, 14 Jan 2026 13:50:43 +0100 Subject: [PATCH 32/32] fix: fix repersistence of cached chains --- .../src/TokenListController.ts | 51 +++++++++++-------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/packages/assets-controllers/src/TokenListController.ts b/packages/assets-controllers/src/TokenListController.ts index 27332689ddf..28c3873035c 100644 --- a/packages/assets-controllers/src/TokenListController.ts +++ b/packages/assets-controllers/src/TokenListController.ts @@ -144,6 +144,14 @@ export class TokenListController extends StaticIntervalPollingController = 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. */ @@ -281,7 +289,13 @@ export class TokenListController extends StaticIntervalPollingController 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 @@ -411,26 +433,11 @@ export class TokenListController extends StaticIntervalPollingController 0) { - this.#debouncePersist(); - } + // 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( @@ -550,6 +557,7 @@ export class TokenListController extends StaticIntervalPollingController