From ad39f1be422d8720a4d0e4ec48f0e01cd3eef2a2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 08:06:43 +0000 Subject: [PATCH 1/6] 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 c800175a48f..b293e53de7f 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 33b19d453ea..3a716cc7063 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 0baf8a33311..90c7787c927 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/6] 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 b293e53de7f..32092160ff9 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/6] 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 3a716cc7063..82704f4cdb6 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: { From e905f2de6c8d8f181c66ad8f00ad6020934186dd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 20 Jul 2026 20:59:28 +0000 Subject: [PATCH 4/6] docs(assets-controllers): shorten isDeprecated JSDoc per review Co-authored-by: Prithpal Sooriya --- .../src/TokenDetectionController.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index 90c7787c927..50b1af5b558 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -232,11 +232,7 @@ export class TokenDetectionController extends StaticIntervalPollingController Date: Mon, 20 Jul 2026 21:02:57 +0000 Subject: [PATCH 5/6] fix(assets-controllers): reset state in TokenDetectionController enforceDisabledState Follow the shared isDeprecated pattern: clear state via this.update and bail early at entry points instead of toggling the private disabled flag. Co-authored-by: Prithpal Sooriya --- .../src/TokenDetectionController.test.ts | 20 ++++--------------- .../src/TokenDetectionController.ts | 5 ++++- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index 25919ba177d..0416b5c9701 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -4069,20 +4069,11 @@ 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); + expect(controller.state).toStrictEqual({}); }, ); }); @@ -4134,6 +4125,7 @@ describe('TokenDetectionController', () => { await controller.detectTokens(); expect(mockGetBalancesInSingleCall).not.toHaveBeenCalled(); + expect(controller.state).toStrictEqual({}); }, ); }); @@ -4158,7 +4150,7 @@ describe('TokenDetectionController', () => { await controller.detectTokens(); expect(mockGetBalancesInSingleCall).not.toHaveBeenCalled(); - expect(controller.isActive).toBe(false); + expect(controller.state).toStrictEqual({}); }, ); }); @@ -4187,7 +4179,6 @@ describe('TokenDetectionController', () => { await controller.start(); expect(mockDetectTokens).not.toHaveBeenCalled(); - expect(controller.isActive).toBe(false); }, ); }); @@ -4212,7 +4203,7 @@ describe('TokenDetectionController', () => { }); expect(mockGetBalancesInSingleCall).not.toHaveBeenCalled(); - expect(controller.isActive).toBe(false); + expect(controller.state).toStrictEqual({}); }, ); }); @@ -4260,7 +4251,6 @@ describe('TokenDetectionController', () => { expect.anything(), expect.anything(), ); - expect(controller.isActive).toBe(false); }, ); }); @@ -4308,7 +4298,6 @@ describe('TokenDetectionController', () => { expect.anything(), expect.anything(), ); - expect(controller.isActive).toBe(false); }, ); }); @@ -4335,7 +4324,6 @@ describe('TokenDetectionController', () => { deprecated = true; await controller.detectTokens(); - expect(controller.isActive).toBe(false); detectTokensSpy.mockClear(); await jestAdvanceTime({ duration: 15 }); diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index 50b1af5b558..fc0be1fd122 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -306,7 +306,10 @@ export class TokenDetectionController extends StaticIntervalPollingController ({})); } /** From 20b56676932c02e5e8f0d5d00e8a0e7237ee3305 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 20 Jul 2026 22:48:50 +0000 Subject: [PATCH 6/6] fix(assets-controllers): keep polling when TokenDetectionController is deprecated enforceDisabledState only clears state; entry points bail early without stopping legacy or mixin polling, matching the shared isDeprecated pattern. Co-authored-by: Prithpal Sooriya --- .../src/TokenDetectionController.test.ts | 8 ++++++-- .../assets-controllers/src/TokenDetectionController.ts | 1 - 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index 0416b5c9701..d6947ccf626 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -4302,14 +4302,16 @@ describe('TokenDetectionController', () => { ); }); - it('stops polling when isDeprecated toggles to true at runtime while polling is active', async () => { + it('keeps polling but bails early when isDeprecated toggles to true at runtime', async () => { jest.useFakeTimers(); let deprecated = false; + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({}); await withController( { options: { isDeprecated: () => deprecated, disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, }, mocks: { getSelectedAccount: defaultSelectedAccount, @@ -4324,10 +4326,12 @@ describe('TokenDetectionController', () => { deprecated = true; await controller.detectTokens(); + mockGetBalancesInSingleCall.mockClear(); detectTokensSpy.mockClear(); await jestAdvanceTime({ duration: 15 }); - expect(detectTokensSpy).not.toHaveBeenCalled(); + expect(detectTokensSpy).toHaveBeenCalled(); + expect(mockGetBalancesInSingleCall).not.toHaveBeenCalled(); }, ); jest.useRealTimers(); diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index fc0be1fd122..a499e36a0ff 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -305,7 +305,6 @@ export class TokenDetectionController extends StaticIntervalPollingController