diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index c800175a48..8ec4a9cfb0 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 ([#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. + ## [109.3.0] ### Added diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index 33b19d453e..dd0a1d561a 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -4061,6 +4061,284 @@ 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;