From ad39f1be422d8720a4d0e4ec48f0e01cd3eef2a2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 08:06:43 +0000 Subject: [PATCH 1/3] chore(assets-controllers): add isDeprecated to TokenDetectionController Add an optional isDeprecated constructor callback so hosts can disable token detection when AssetsController supersedes this controller via the assets-unify-state feature flag. When deprecated, polling is stopped and all detection entry points become no-ops without tearing down the controller. Co-authored-by: Prithpal Sooriya --- packages/assets-controllers/CHANGELOG.md | 6 + .../src/TokenDetectionController.test.ts | 274 ++++++++++++++++++ .../src/TokenDetectionController.ts | 46 +++ 3 files changed, 326 insertions(+) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index c800175a48..b293e53de7 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `isDeprecated` option to `TokenDetectionController` constructor ([#TBD](https://github.com/MetaMask/core/pull/TBD)) + - When `isDeprecated()` returns `true`, no network requests are sent and polling is stopped at construction and at every entry point (`start`, `detectTokens`, `_executePoll`, `addDetectedTokensViaWs`, and `addDetectedTokensViaPolling`), so no token detection runs while the controller is disabled. + - The function is re-evaluated on each entry point so it can be toggled at runtime without reconstructing the controller. + ## [109.3.0] ### Added diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index 33b19d453e..3a716cc706 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -4061,6 +4061,280 @@ describe('TokenDetectionController', () => { ); }); }); + + describe('isDeprecated', () => { + it('disables the controller at construction when isDeprecated() returns true', async () => { + await withController( + { options: { isDeprecated: () => true, disabled: false } }, + ({ controller }) => { + expect(controller.isActive).toBe(false); + }, + ); + }); + + it('does not throw at construction when isDeprecated() is true', async () => { + await withController( + { options: { isDeprecated: () => true } }, + ({ controller }) => { + expect(controller.isActive).toBe(false); + }, + ); + }); + + it('does not make any network calls when isDeprecated() returns true from construction', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({}); + await withController( + { + options: { + isDeprecated: () => true, + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: defaultSelectedAccount, + }, + }, + async ({ controller, mockTokenListGetState, mockGetNetworkClientById }) => { + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0xa86a': { + timestamp: 0, + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + aggregators: [], + iconUrl: '', + occurrences: 11, + }, + }, + }, + }, + }); + mockGetNetworkClientById( + () => + ({ + configuration: { chainId: '0xa86a' }, + }) as unknown as AutoManagedNetworkClient, + ); + + await controller.detectTokens(); + + expect(mockGetBalancesInSingleCall).not.toHaveBeenCalled(); + }, + ); + }); + + it('does not detect tokens when isDeprecated toggles to true at runtime via detectTokens', async () => { + let deprecated = false; + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({}); + await withController( + { + options: { + isDeprecated: () => deprecated, + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: defaultSelectedAccount, + }, + }, + async ({ controller }) => { + deprecated = true; + + await controller.detectTokens(); + + expect(mockGetBalancesInSingleCall).not.toHaveBeenCalled(); + expect(controller.isActive).toBe(false); + }, + ); + }); + + it('does not start polling when isDeprecated toggles to true at runtime via start', async () => { + let deprecated = false; + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({}); + await withController( + { + options: { + isDeprecated: () => deprecated, + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: defaultSelectedAccount, + }, + }, + async ({ controller }) => { + const mockDetectTokens = jest + .spyOn(controller, 'detectTokens') + .mockImplementation(); + + deprecated = true; + + await controller.start(); + + expect(mockDetectTokens).not.toHaveBeenCalled(); + expect(controller.isActive).toBe(false); + }, + ); + }); + + it('does not detect tokens when isDeprecated toggles to true at runtime via _executePoll', async () => { + let deprecated = false; + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({}); + await withController( + { + options: { + isDeprecated: () => deprecated, + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + }, + async ({ controller }) => { + deprecated = true; + + await controller._executePoll({ + chainIds: ['0xa86a'], + address: '0x1', + }); + + expect(mockGetBalancesInSingleCall).not.toHaveBeenCalled(); + expect(controller.isActive).toBe(false); + }, + ); + }); + + it('does not add tokens when isDeprecated toggles to true at runtime via addDetectedTokensViaWs', async () => { + let deprecated = false; + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const chainId = '0xa86a'; + + await withController( + { + options: { + isDeprecated: () => deprecated, + disabled: false, + }, + mockTokenListState: { + tokensChainsCache: { + [chainId]: { + timestamp: 0, + data: { + [mockTokenAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: mockTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ controller, callActionSpy }) => { + deprecated = true; + + await controller.addDetectedTokensViaWs({ + tokensSlice: [mockTokenAddress], + chainId: chainId as Hex, + }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.anything(), + expect.anything(), + ); + expect(controller.isActive).toBe(false); + }, + ); + }); + + it('does not add tokens when isDeprecated toggles to true at runtime via addDetectedTokensViaPolling', async () => { + let deprecated = false; + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const chainId = '0xa86a'; + + await withController( + { + options: { + isDeprecated: () => deprecated, + disabled: false, + }, + mockTokenListState: { + tokensChainsCache: { + [chainId]: { + timestamp: 0, + data: { + [mockTokenAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: mockTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ controller, callActionSpy }) => { + deprecated = true; + + await controller.addDetectedTokensViaPolling({ + tokensSlice: [mockTokenAddress], + chainId: chainId as Hex, + }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.anything(), + expect.anything(), + ); + expect(controller.isActive).toBe(false); + }, + ); + }); + + it('stops polling when isDeprecated toggles to true at runtime while polling is active', async () => { + jest.useFakeTimers(); + let deprecated = false; + await withController( + { + options: { + isDeprecated: () => deprecated, + disabled: false, + }, + mocks: { + getSelectedAccount: defaultSelectedAccount, + }, + }, + async ({ controller }) => { + const detectTokensSpy = jest.spyOn(controller, 'detectTokens'); + + controller.setIntervalLength(10); + await controller.start(); + expect(detectTokensSpy).toHaveBeenCalledTimes(1); + + deprecated = true; + await controller.detectTokens(); + expect(controller.isActive).toBe(false); + + detectTokensSpy.mockClear(); + await jestAdvanceTime({ duration: 15 }); + expect(detectTokensSpy).not.toHaveBeenCalled(); + }, + ); + jest.useRealTimers(); + }); + }); }); /** diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index 0baf8a3331..90c7787c92 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -204,6 +204,8 @@ export class TokenDetectionController extends StaticIntervalPollingController boolean; + readonly #isDeprecated: () => boolean; + readonly #getBalancesInSingleCall: AssetsContractController['getBalancesInSingleCall']; readonly #trackMetaMetricsEvent: (options: { @@ -230,6 +232,11 @@ export class TokenDetectionController extends StaticIntervalPollingController true, useExternalServices = (): boolean => true, + isDeprecated = (): boolean => false, }: { interval?: number; disabled?: boolean; @@ -259,6 +267,7 @@ export class TokenDetectionController extends StaticIntervalPollingController boolean; useExternalServices?: () => boolean; + isDeprecated?: () => boolean; }) { super({ name: controllerName, @@ -290,10 +299,27 @@ export class TokenDetectionController extends StaticIntervalPollingController { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } this.enable(); await this.#startPolling(); } @@ -459,6 +489,10 @@ export class TokenDetectionController extends StaticIntervalPollingController { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } if (!this.isActive) { return; } @@ -574,6 +608,10 @@ export class TokenDetectionController extends StaticIntervalPollingController { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } if (!this.isActive) { return; } @@ -886,6 +924,10 @@ export class TokenDetectionController extends StaticIntervalPollingController { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } // Check if token detection is enabled via preferences if (!this.#useTokenDetection()) { return; @@ -992,6 +1034,10 @@ export class TokenDetectionController extends StaticIntervalPollingController { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } // Check if token detection is enabled via preferences if (!this.#useTokenDetection()) { return; From 4eeb7359aebb356c5387a3bbbe6c22b1be6f1a89 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 08:07:41 +0000 Subject: [PATCH 2/3] docs(assets-controllers): link changelog entry to PR #9362 Co-authored-by: Prithpal Sooriya --- 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 b293e53de7..32092160ff 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `isDeprecated` option to `TokenDetectionController` constructor ([#TBD](https://github.com/MetaMask/core/pull/TBD)) +- Add `isDeprecated` option to `TokenDetectionController` constructor ([#9362](https://github.com/MetaMask/core/pull/9362)) - When `isDeprecated()` returns `true`, no network requests are sent and polling is stopped at construction and at every entry point (`start`, `detectTokens`, `_executePoll`, `addDetectedTokensViaWs`, and `addDetectedTokensViaPolling`), so no token detection runs while the controller is disabled. - The function is re-evaluated on each entry point so it can be toggled at runtime without reconstructing the controller. From 4d7b9b6ef8a11a613e842dfe2c430206223d3f40 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 08:16:10 +0000 Subject: [PATCH 3/3] style(assets-controllers): fix oxfmt formatting in TokenDetectionController tests Co-authored-by: Prithpal Sooriya --- .../assets-controllers/src/TokenDetectionController.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index 3a716cc706..82704f4cdb 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -4094,7 +4094,11 @@ describe('TokenDetectionController', () => { getSelectedAccount: defaultSelectedAccount, }, }, - async ({ controller, mockTokenListGetState, mockGetNetworkClientById }) => { + async ({ + controller, + mockTokenListGetState, + mockGetNetworkClientById, + }) => { mockTokenListGetState({ ...getDefaultTokenListState(), tokensChainsCache: {