From 1ba5d8bb40a6a7b1f681e704eef31d88ed21f985 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 08:07:27 +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 assets-unify-state supersedes this controller. When true, polling stops and all detection entry points become no-ops. Co-authored-by: Prithpal Sooriya --- packages/assets-controllers/CHANGELOG.md | 6 + .../src/TokenDetectionController.test.ts | 270 ++++++++++++++++++ .../src/TokenDetectionController.ts | 57 ++++ 3 files changed, 333 insertions(+) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index c800175a48..8ece8d5a63 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 + - When `isDeprecated()` returns `true`, no token detection requests are sent and polling is stopped at construction and at every entry point (`detectTokens`, `addDetectedTokensViaWs`, `addDetectedTokensViaPolling`, `start`, `_executePoll`, and event-driven restarts), so the controller is fully inactive. + - 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..fc3f426310 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -4061,6 +4061,276 @@ describe('TokenDetectionController', () => { ); }); }); + + describe('isDeprecated', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('stops polling at construction when isDeprecated() returns true', async () => { + await withController( + { + isKeyringUnlocked: true, + options: { disabled: false, isDeprecated: () => true }, + }, + async ({ controller }) => { + const mockDetectTokens = jest + .spyOn(controller, 'detectTokens') + .mockImplementation(); + + await controller.start(); + await jestAdvanceTime({ duration: DEFAULT_INTERVAL * 2 }); + + expect(mockDetectTokens).not.toHaveBeenCalled(); + expect(controller.isActive).toBe(false); + }, + ); + }); + + it('does not throw at construction when isDeprecated() returns true', async () => { + await withController( + { + options: { isDeprecated: () => true }, + }, + ({ controller }) => { + expect(controller.state).toStrictEqual({}); + }, + ); + }); + + it('does not make any detection requests when isDeprecated() returns true from construction', async () => { + const mockGetBalancesInSingleCall = jest.fn(); + await withController( + { + isKeyringUnlocked: true, + options: { + disabled: false, + isDeprecated: () => true, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + }, + async ({ controller }) => { + await controller.detectTokens({ chainIds: ['0xa86a'] }); + + expect(mockGetBalancesInSingleCall).not.toHaveBeenCalled(); + }, + ); + }); + + it('does not detect tokens and stops polling when isDeprecated toggles to true at runtime via detectTokens', async () => { + let deprecated = false; + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({}); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + await withController( + { + isKeyringUnlocked: true, + options: { + disabled: false, + isDeprecated: () => deprecated, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, + }, + async ({ controller, mockTokenListGetState, mockNetworkState }) => { + const defaultState = getDefaultNetworkControllerState(); + mockNetworkState({ + ...defaultState, + networkConfigurationsByChainId: { + ...defaultState.networkConfigurationsByChainId, + ...mockNetworkConfigurationsByChainId, + }, + }); + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0xa86a': { + timestamp: 0, + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, + }, + }, + }, + }); + + await controller.detectTokens({ + chainIds: ['0xa86a'], + selectedAddress: selectedAccount.address, + }); + expect(mockGetBalancesInSingleCall).toHaveBeenCalled(); + + mockGetBalancesInSingleCall.mockClear(); + deprecated = true; + + await controller.detectTokens({ + chainIds: ['0xa86a'], + selectedAddress: selectedAccount.address, + }); + + expect(mockGetBalancesInSingleCall).not.toHaveBeenCalled(); + expect(controller.isActive).toBe(false); + }, + ); + }); + + it('does not poll when isDeprecated toggles to true at runtime via start', async () => { + let deprecated = false; + await withController( + { + isKeyringUnlocked: true, + options: { disabled: false, isDeprecated: () => deprecated }, + }, + async ({ controller }) => { + const mockDetectTokens = jest + .spyOn(controller, 'detectTokens') + .mockImplementation(); + + await controller.start(); + expect(mockDetectTokens).toHaveBeenCalledTimes(1); + + deprecated = true; + mockDetectTokens.mockClear(); + + await controller.start(); + await jestAdvanceTime({ duration: DEFAULT_INTERVAL * 2 }); + + expect(mockDetectTokens).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; + await withController( + { + options: { isDeprecated: () => deprecated }, + mockTokenListState: { + tokensChainsCache: { + [ChainId.mainnet]: { + timestamp: Date.now(), + data: { + [tokenAFromList.address.toLowerCase()]: { + ...tokenAFromList, + aggregators: formattedSampleAggregators, + iconUrl: '', + }, + }, + }, + }, + }, + }, + async ({ controller, callActionSpy, mockFindNetworkClientIdByChainId }) => { + mockFindNetworkClientIdByChainId(() => 'mainnet'); + + deprecated = true; + + await controller.addDetectedTokensViaWs({ + tokensSlice: [tokenAFromList.address], + chainId: ChainId.mainnet, + }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.anything(), + expect.anything(), + ); + }, + ); + }); + + it('does not add tokens when isDeprecated toggles to true at runtime via addDetectedTokensViaPolling', async () => { + let deprecated = false; + await withController( + { + options: { isDeprecated: () => deprecated }, + mockTokenListState: { + tokensChainsCache: { + [ChainId.mainnet]: { + timestamp: Date.now(), + data: { + [tokenAFromList.address.toLowerCase()]: { + ...tokenAFromList, + aggregators: formattedSampleAggregators, + iconUrl: '', + }, + }, + }, + }, + }, + }, + async ({ controller, callActionSpy, mockFindNetworkClientIdByChainId }) => { + mockFindNetworkClientIdByChainId(() => 'mainnet'); + + deprecated = true; + + await controller.addDetectedTokensViaPolling({ + tokensSlice: [tokenAFromList.address], + chainId: ChainId.mainnet, + }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.anything(), + expect.anything(), + ); + }, + ); + }); + + it('does not detect tokens on KeyringController:unlock when deprecated', async () => { + await withController( + { + isKeyringUnlocked: false, + options: { disabled: false, isDeprecated: () => true }, + }, + async ({ controller, triggerKeyringUnlock }) => { + const mockDetectTokens = jest + .spyOn(controller, 'detectTokens') + .mockImplementation(); + + triggerKeyringUnlock(); + + expect(mockDetectTokens).not.toHaveBeenCalled(); + }, + ); + }); + + it('does not detect tokens on TransactionController:transactionConfirmed when deprecated', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({}); + await withController( + { + isKeyringUnlocked: true, + options: { + disabled: false, + isDeprecated: () => true, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + }, + }, + async ({ triggerTransactionConfirmed }) => { + triggerTransactionConfirmed({ chainId: ChainId.mainnet }); + + expect(mockGetBalancesInSingleCall).not.toHaveBeenCalled(); + }, + ); + }); + }); }); /** diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index 0baf8a3331..67784c5706 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,12 @@ export class TokenDetectionController extends StaticIntervalPollingController true, useExternalServices = (): boolean => true, + isDeprecated = (): boolean => false, }: { interval?: number; disabled?: boolean; @@ -259,6 +268,7 @@ export class TokenDetectionController extends StaticIntervalPollingController boolean; useExternalServices?: () => boolean; + isDeprecated?: () => boolean; }) { super({ name: controllerName, @@ -290,10 +300,27 @@ export class TokenDetectionController extends StaticIntervalPollingController { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } this.enable(); await this.#startPolling(); } @@ -412,6 +443,10 @@ export class TokenDetectionController extends StaticIntervalPollingController { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } if (!this.isActive) { return; } @@ -459,6 +494,10 @@ export class TokenDetectionController extends StaticIntervalPollingController { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } if (!this.isActive) { return; } @@ -483,6 +522,10 @@ export class TokenDetectionController extends StaticIntervalPollingController { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } await this.detectTokens({ chainIds, selectedAddress, @@ -574,6 +617,10 @@ export class TokenDetectionController extends StaticIntervalPollingController { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } if (!this.isActive) { return; } @@ -886,6 +933,11 @@ 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 +1044,11 @@ export class TokenDetectionController extends StaticIntervalPollingController { + if (this.#isDeprecated()) { + this.#enforceDisabledState(); + return; + } + // Check if token detection is enabled via preferences if (!this.#useTokenDetection()) { return; From 28eb0659db9cba6af2db7445d5e7785c577d740f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 08:09:58 +0000 Subject: [PATCH 2/3] docs(assets-controllers): link changelog entry to PR #9363 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 8ece8d5a63..8ec4a9cfb0 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 +- Add `isDeprecated` option to `TokenDetectionController` constructor ([#9363](https://github.com/MetaMask/core/pull/9363)) - When `isDeprecated()` returns `true`, no token detection requests are sent and polling is stopped at construction and at every entry point (`detectTokens`, `addDetectedTokensViaWs`, `addDetectedTokensViaPolling`, `start`, `_executePoll`, and event-driven restarts), so the controller is fully inactive. - The function is re-evaluated on each entry point so it can be toggled at runtime without reconstructing the controller. From 4ff1ce8382a7d1988268fd400da49f6d05b5e653 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 08:17:44 +0000 Subject: [PATCH 3/3] style(assets-controllers): fix oxfmt formatting in TokenDetectionController tests Co-authored-by: Prithpal Sooriya --- .../src/TokenDetectionController.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index fc3f426310..dd0a1d561a 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -4236,7 +4236,11 @@ describe('TokenDetectionController', () => { }, }, }, - async ({ controller, callActionSpy, mockFindNetworkClientIdByChainId }) => { + async ({ + controller, + callActionSpy, + mockFindNetworkClientIdByChainId, + }) => { mockFindNetworkClientIdByChainId(() => 'mainnet'); deprecated = true; @@ -4275,7 +4279,11 @@ describe('TokenDetectionController', () => { }, }, }, - async ({ controller, callActionSpy, mockFindNetworkClientIdByChainId }) => { + async ({ + controller, + callActionSpy, + mockFindNetworkClientIdByChainId, + }) => { mockFindNetworkClientIdByChainId(() => 'mainnet'); deprecated = true;