diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 452ee5ba9e2..2f879da7c79 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **BREAKING:** Adds `@metamask/accounts-controller` ^8.0.0 and `@metamask/keyring-controller` ^12.0.0 as dependencies and peer dependencies. ([#3775](https://github.com/MetaMask/core/pull/3775/)). +- **BREAKING:** `TokenDetectionController` newly subscribes to the `PreferencesController:stateChange`, `AccountsController:selectedAccountChange`, `KeyringController:lock`, `KeyringController:unlock` events, and allows the `PreferencesController:getState` messenger action. ([#3775](https://github.com/MetaMask/core/pull/3775/)) + +### Changed + +- **BREAKING:** `TokenDetectionController` is merged with `DetectTokensController` from the `metamask-extension` repo. ([#3775](https://github.com/MetaMask/core/pull/3775/)) + - **BREAKING:** `TokenDetectionController` now resets its polling interval to the default value of 3 minutes when token detection is triggered by external controller events `KeyringController:unlock`, `TokenListController:stateChange`, `PreferencesController:stateChange`, `AccountsController:selectedAccountChange`. + - **BREAKING:** `TokenDetectionController` now refetches tokens on `NetworkController:networkDidChange` if the `networkClientId` is changed instead of `chainId`. + - **BREAKING:** `TokenDetectionController` cannot initiate polling or token detection if `KeyringController` state is locked. + - **BREAKING:** The `detectTokens` method now excludes tokens that are already included in the `TokensController`'s `detectedTokens` list from the batch of incoming tokens it sends to the `TokensController` `addDetectedTokens` method. + - **BREAKING:** The constructor for `TokenDetectionController` expects a new required proprerty `trackMetaMetricsEvent`, which defines the callback that is called in the `detectTokens` method. + - **BREAKING:** In Mainnet, even if the `PreferenceController`'s `useTokenDetection` option is set to false, automatic token detection is performed on the legacy token list (token data from the contract-metadata repo). + +### Removed + +- **BREAKING:** `TokenDetectionController` constructor no longer accepts options `onPreferencesStateChange`, `getPreferencesState`. ([#3775](https://github.com/MetaMask/core/pull/3775/)) +- **BREAKING:** `TokenDetectionController` no longer allows the `NetworkController:stateChange` event. The `NetworkController:networkDidChange` event can be used instead. ([#3775](https://github.com/MetaMask/core/pull/3775/)) + ## [25.0.0] ### Added diff --git a/packages/assets-controllers/jest.config.js b/packages/assets-controllers/jest.config.js index 6d35a70f3ac..34e91df6918 100644 --- a/packages/assets-controllers/jest.config.js +++ b/packages/assets-controllers/jest.config.js @@ -17,10 +17,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 88.36, - functions: 97.08, - lines: 97.23, - statements: 97.28, + branches: 88.3, + functions: 95.32, + lines: 96.69, + statements: 96.7, }, }, diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index d2636efd0e3..8ee5303d60d 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -36,11 +36,13 @@ "@ethersproject/contracts": "^5.7.0", "@ethersproject/providers": "^5.7.0", "@metamask/abi-utils": "^2.0.2", + "@metamask/accounts-controller": "^10.0.0", "@metamask/approval-controller": "^5.1.2", "@metamask/base-controller": "^4.1.1", "@metamask/contract-metadata": "^2.4.0", "@metamask/controller-utils": "^8.0.2", "@metamask/eth-query": "^4.0.0", + "@metamask/keyring-controller": "^12.2.0", "@metamask/metamask-eth-abis": "3.0.0", "@metamask/network-controller": "^17.2.0", "@metamask/polling-controller": "^5.0.0", @@ -59,6 +61,7 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@metamask/ethjs-provider-http": "^0.3.0", + "@metamask/keyring-api": "^3.0.0", "@types/jest": "^27.4.1", "@types/lodash": "^4.14.191", "@types/node": "^16.18.54", @@ -73,7 +76,9 @@ "typescript": "~4.8.4" }, "peerDependencies": { + "@metamask/accounts-controller": "^10.0.0", "@metamask/approval-controller": "^5.1.2", + "@metamask/keyring-controller": "^12.2.0", "@metamask/network-controller": "^17.2.0", "@metamask/preferences-controller": "^7.0.0" }, diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index 2f68b71b903..be648263448 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -6,8 +6,11 @@ import { convertHexToDecimal, BUILT_IN_NETWORKS, } from '@metamask/controller-utils'; +import type { InternalAccount } from '@metamask/keyring-api'; +import type { KeyringControllerState } from '@metamask/keyring-controller'; import { defaultState as defaultNetworkState, + type NetworkState, type NetworkConfiguration, type NetworkController, } from '@metamask/network-controller'; @@ -133,13 +136,18 @@ function buildTokenDetectionControllerMessenger( return controllerMessenger.getRestricted({ name: controllerName, allowedActions: [ + 'KeyringController:getState', 'NetworkController:getNetworkConfigurationByNetworkClientId', 'TokenListController:getState', + 'PreferencesController:getState', ], allowedEvents: [ - 'NetworkController:stateChange', + 'AccountsController:selectedAccountChange', + 'KeyringController:lock', + 'KeyringController:unlock', 'NetworkController:networkDidChange', 'TokenListController:stateChange', + 'PreferencesController:stateChange', ], }); } @@ -226,7 +234,7 @@ describe('TokenDetectionController', () => { }, }, async ({ controller, mockTokenListGetState }) => { - mockTokenListGetState.mockReturnValue({ + mockTokenListGetState({ ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { @@ -267,7 +275,7 @@ describe('TokenDetectionController', () => { }, }, async ({ controller, mockTokenListGetState }) => { - mockTokenListGetState.mockReturnValue({ + mockTokenListGetState({ ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { @@ -325,7 +333,7 @@ describe('TokenDetectionController', () => { }, }, }; - mockTokenListGetState.mockReturnValue(tokenListState); + mockTokenListGetState(tokenListState); await controller.start(); mockAddDetectedTokens.mockReset(); @@ -338,7 +346,7 @@ describe('TokenDetectionController', () => { aggregators: sampleTokenB.aggregators, iconUrl: sampleTokenB.image, }; - mockTokenListGetState.mockReturnValue(tokenListState); + mockTokenListGetState(tokenListState); await advanceTime({ clock, duration: interval }); expect(mockAddDetectedTokens).toHaveBeenCalledWith( @@ -373,7 +381,7 @@ describe('TokenDetectionController', () => { }, }, async ({ controller, mockTokenListGetState }) => { - mockTokenListGetState.mockReturnValue({ + mockTokenListGetState({ ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { @@ -410,7 +418,7 @@ describe('TokenDetectionController', () => { }, }, async ({ controller, mockTokenListGetState }) => { - mockTokenListGetState.mockReturnValue({ + mockTokenListGetState({ ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { @@ -464,7 +472,7 @@ describe('TokenDetectionController', () => { }, }, async ({ mockTokenListGetState, triggerPreferencesStateChange }) => { - mockTokenListGetState.mockReturnValue({ + mockTokenListGetState({ ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { @@ -506,17 +514,12 @@ describe('TokenDetectionController', () => { addDetectedTokens: mockAddDetectedTokens, disabled: false, getBalancesInSingleCall: mockGetBalancesInSingleCall, - getPreferencesState: jest.fn().mockReturnValue({ - ...getDefaultPreferencesState(), - selectedAddress, - useTokenDetection: false, - }), networkClientId: NetworkType.mainnet, selectedAddress, }, }, async ({ mockTokenListGetState, triggerPreferencesStateChange }) => { - mockTokenListGetState.mockReturnValue({ + mockTokenListGetState({ ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { @@ -531,6 +534,13 @@ describe('TokenDetectionController', () => { }, }); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + selectedAddress, + useTokenDetection: false, + }); + await advanceTime({ clock, duration: 1 }); + triggerPreferencesStateChange({ ...getDefaultPreferencesState(), selectedAddress, @@ -566,7 +576,7 @@ describe('TokenDetectionController', () => { }, }, async ({ mockTokenListGetState, triggerPreferencesStateChange }) => { - mockTokenListGetState.mockReturnValue({ + mockTokenListGetState({ ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { @@ -610,7 +620,7 @@ describe('TokenDetectionController', () => { }, }, async ({ mockTokenListGetState, triggerPreferencesStateChange }) => { - mockTokenListGetState.mockReturnValue({ + mockTokenListGetState({ ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { @@ -659,7 +669,7 @@ describe('TokenDetectionController', () => { }, }, async ({ mockTokenListGetState, triggerPreferencesStateChange }) => { - mockTokenListGetState.mockReturnValue({ + mockTokenListGetState({ ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { @@ -698,17 +708,12 @@ describe('TokenDetectionController', () => { addDetectedTokens: mockAddDetectedTokens, disabled: true, getBalancesInSingleCall: mockGetBalancesInSingleCall, - getPreferencesState: jest.fn().mockReturnValue({ - ...getDefaultPreferencesState(), - selectedAddress, - useTokenDetection: false, - }), networkClientId: NetworkType.mainnet, selectedAddress, }, }, async ({ mockTokenListGetState, triggerPreferencesStateChange }) => { - mockTokenListGetState.mockReturnValue({ + mockTokenListGetState({ ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { @@ -723,6 +728,13 @@ describe('TokenDetectionController', () => { }, }); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + selectedAddress, + useTokenDetection: false, + }); + await advanceTime({ clock, duration: 1 }); + triggerPreferencesStateChange({ ...getDefaultPreferencesState(), selectedAddress, @@ -748,7 +760,7 @@ describe('TokenDetectionController', () => { }); describe('when "disabled" is "false"', () => { - it('should detect new tokens after switching chains', async () => { + it('should detect new tokens after switching network client id', async () => { const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ [sampleTokenA.address]: new BN(1), }); @@ -770,7 +782,7 @@ describe('TokenDetectionController', () => { messenger, }, async ({ mockTokenListGetState }) => { - mockTokenListGetState.mockReturnValue({ + mockTokenListGetState({ ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { @@ -821,7 +833,7 @@ describe('TokenDetectionController', () => { messenger, }, async ({ mockTokenListGetState }) => { - mockTokenListGetState.mockReturnValue({ + mockTokenListGetState({ ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { @@ -847,7 +859,7 @@ describe('TokenDetectionController', () => { ); }); - it('should not detect new tokens if the chain has not changed', async () => { + it('should not detect new tokens if the network client id has not changed', async () => { const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ [sampleTokenA.address]: new BN(1), }); @@ -869,7 +881,7 @@ describe('TokenDetectionController', () => { messenger, }, async ({ mockTokenListGetState }) => { - mockTokenListGetState.mockReturnValue({ + mockTokenListGetState({ ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { @@ -886,7 +898,7 @@ describe('TokenDetectionController', () => { messenger.publish('NetworkController:networkDidChange', { ...defaultNetworkState, - selectedNetworkClientId: 'mainnnet', + selectedNetworkClientId: 'mainnet', }); await advanceTime({ clock, duration: 1 }); @@ -897,7 +909,7 @@ describe('TokenDetectionController', () => { }); describe('when "disabled" is "true"', () => { - it('should not detect new tokens after switching chains', async () => { + it('should not detect new tokens after switching network client id', async () => { const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ [sampleTokenA.address]: new BN(1), }); @@ -919,7 +931,7 @@ describe('TokenDetectionController', () => { messenger, }, async ({ mockTokenListGetState }) => { - mockTokenListGetState.mockReturnValue({ + mockTokenListGetState({ ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { @@ -994,7 +1006,7 @@ describe('TokenDetectionController', () => { }, }, }; - mockTokenListGetState.mockReturnValue(tokenListState); + mockTokenListGetState(tokenListState); messenger.publish( 'TokenListController:stateChange', @@ -1037,7 +1049,7 @@ describe('TokenDetectionController', () => { ...getDefaultTokenListState(), tokenList: {}, }; - mockTokenListGetState.mockReturnValue(tokenListState); + mockTokenListGetState(tokenListState); messenger.publish( 'TokenListController:stateChange', @@ -1089,7 +1101,7 @@ describe('TokenDetectionController', () => { }, }, }; - mockTokenListGetState.mockReturnValue(tokenListState); + mockTokenListGetState(tokenListState); messenger.publish( 'TokenListController:stateChange', @@ -1137,7 +1149,7 @@ describe('TokenDetectionController', () => { messenger, }, async ({ controller, mockTokenListGetState }) => { - mockTokenListGetState.mockReturnValue({ + mockTokenListGetState({ ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { @@ -1211,7 +1223,7 @@ describe('TokenDetectionController', () => { messenger, }, async ({ controller, mockTokenListGetState }) => { - mockTokenListGetState.mockReturnValue({ + mockTokenListGetState({ ...getDefaultTokenListState(), tokenList: { [sampleTokenA.address]: { @@ -1255,12 +1267,29 @@ function getTokensPath(chainId: Hex) { type WithControllerCallback = ({ controller, + mockKeyringGetState, mockTokenListGetState, + mockPreferencesGetState, + triggerKeyringUnlock, + triggerKeyringLock, + triggerTokenListStateChange, triggerPreferencesStateChange, + triggerSelectedAccountChange, + triggerNetworkDidChange, }: { controller: TokenDetectionController; - mockTokenListGetState: jest.Mock; + mockKeyringGetState: (state: KeyringControllerState) => void; + mockTokenListGetState: (state: TokenListState) => void; + mockPreferencesGetState: (state: PreferencesState) => void; + mockGetNetworkConfigurationByNetworkClientId: ( + handler: (networkClientId: string) => NetworkConfiguration, + ) => void; + triggerKeyringUnlock: () => void; + triggerKeyringLock: () => void; + triggerTokenListStateChange: (state: TokenListState) => void; triggerPreferencesStateChange: (state: PreferencesState) => void; + triggerSelectedAccountChange: (account: InternalAccount) => void; + triggerNetworkDidChange: (state: NetworkState) => void; }) => Promise | ReturnValue; type WithControllerOptions = { @@ -1289,51 +1318,97 @@ async function withController( const controllerMessenger = messenger ?? new ControllerMessenger(); - const mockGetNetworkConfigurationByNetworkClientId = jest - .fn< - ReturnType, - Parameters - >() - .mockImplementation((networkClientId: string) => { - return mockNetworkConfigurations[networkClientId]; - }); + const mockKeyringState = jest.fn(); + controllerMessenger.registerActionHandler( + 'KeyringController:getState', + mockKeyringState.mockReturnValue({ + isUnlocked: true, + } as unknown as KeyringControllerState), + ); + const mockGetNetworkConfigurationByNetworkClientId = jest.fn< + ReturnType, + Parameters + >(); controllerMessenger.registerActionHandler( 'NetworkController:getNetworkConfigurationByNetworkClientId', - mockGetNetworkConfigurationByNetworkClientId, + mockGetNetworkConfigurationByNetworkClientId.mockImplementation( + (networkClientId: string) => { + return mockNetworkConfigurations[networkClientId]; + }, + ), ); - const mockTokenListGetState = jest - .fn() - .mockReturnValue({ ...getDefaultTokenListState() }); + const mockTokenListState = jest.fn(); controllerMessenger.registerActionHandler( 'TokenListController:getState', - mockTokenListGetState, + mockTokenListState.mockReturnValue({ ...getDefaultTokenListState() }), + ); + const mockPreferencesState = jest.fn(); + controllerMessenger.registerActionHandler( + 'PreferencesController:getState', + mockPreferencesState.mockReturnValue({ + ...getDefaultPreferencesState(), + }), ); - const preferencesStateChangeListeners: ((state: PreferencesState) => void)[] = - []; const controller = new TokenDetectionController({ networkClientId: NetworkType.mainnet, - onPreferencesStateChange: (listener) => { - preferencesStateChangeListeners.push(listener); - }, getBalancesInSingleCall: jest.fn(), addDetectedTokens: jest.fn(), getTokensState: jest.fn().mockReturnValue(getDefaultTokensState()), - getPreferencesState: jest.fn().mockReturnValue({ - ...getDefaultPreferencesState(), - useTokenDetection: true, - }), + trackMetaMetricsEvent: jest.fn(), messenger: buildTokenDetectionControllerMessenger(controllerMessenger), ...options, }); try { return await fn({ controller, - mockTokenListGetState, + mockKeyringGetState: (state: KeyringControllerState) => { + mockKeyringState.mockReturnValue(state); + }, + mockPreferencesGetState: (state: PreferencesState) => { + mockPreferencesState.mockReturnValue(state); + }, + mockTokenListGetState: (state: TokenListState) => { + mockTokenListState.mockReturnValue(state); + }, + mockGetNetworkConfigurationByNetworkClientId: ( + handler: (networkClientId: string) => NetworkConfiguration, + ) => { + mockGetNetworkConfigurationByNetworkClientId.mockImplementation( + handler, + ); + }, + triggerKeyringUnlock: () => { + controllerMessenger.publish('KeyringController:unlock'); + }, + triggerKeyringLock: () => { + controllerMessenger.publish('KeyringController:lock'); + }, + triggerTokenListStateChange: (state: TokenListState) => { + controllerMessenger.publish( + 'TokenListController:stateChange', + state, + [], + ); + }, triggerPreferencesStateChange: (state: PreferencesState) => { - for (const listener of preferencesStateChangeListeners) { - listener(state); - } + controllerMessenger.publish( + 'PreferencesController:stateChange', + state, + [], + ); + }, + triggerSelectedAccountChange: (account: InternalAccount) => { + controllerMessenger.publish( + 'AccountsController:selectedAccountChange', + account, + ); + }, + triggerNetworkDidChange: (state: NetworkState) => { + controllerMessenger.publish( + 'NetworkController:networkDidChange', + state, + ); }, }); } finally { diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index 19c4d90871d..13175385f28 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -1,20 +1,30 @@ +import type { AccountsControllerSelectedAccountChangeEvent } from '@metamask/accounts-controller'; import type { RestrictedControllerMessenger, ControllerGetStateAction, ControllerStateChangeEvent, } from '@metamask/base-controller'; +import contractMap from '@metamask/contract-metadata'; import { + ChainId, safelyExecute, toChecksumHexAddress, } from '@metamask/controller-utils'; +import type { + KeyringControllerGetStateAction, + KeyringControllerLockEvent, + KeyringControllerUnlockEvent, +} from '@metamask/keyring-controller'; import type { NetworkClientId, NetworkControllerNetworkDidChangeEvent, - NetworkControllerStateChangeEvent, NetworkControllerGetNetworkConfigurationByNetworkClientId, } from '@metamask/network-controller'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; -import type { PreferencesState } from '@metamask/preferences-controller'; +import type { + PreferencesControllerGetStateAction, + PreferencesControllerStateChangeEvent, +} from '@metamask/preferences-controller'; import type { Hex } from '@metamask/utils'; import type { AssetsContractController } from './AssetsContractController'; @@ -22,12 +32,48 @@ import { isTokenDetectionSupportedForNetwork } from './assetsUtil'; import type { GetTokenListState, TokenListStateChange, + TokenListToken, } from './TokenListController'; import type { Token } from './TokenRatesController'; import type { TokensController, TokensState } from './TokensController'; const DEFAULT_INTERVAL = 180000; +/** + * Finds a case insensitive match in an array of strings + * @param source - An array of strings to search. + * @param target - The target string to search for. + * @returns The first match that is found. + */ +function findCaseInsensitiveMatch(source: string[], target: string) { + return source.find((e: string) => e.toLowerCase() === target.toLowerCase()); +} + +type LegacyToken = Omit< + Token, + 'aggregators' | 'image' | 'balanceError' | 'isERC721' +> & { + name: string; + logo: string; + erc20?: boolean; + erc721?: boolean; +}; + +export const STATIC_MAINNET_TOKEN_LIST = Object.entries( + contractMap, +).reduce>>((acc, [base, contract]) => { + const { logo, ...tokenMetadata } = contract; + return { + ...acc, + [base.toLowerCase()]: { + ...tokenMetadata, + address: base.toLowerCase(), + iconUrl: `images/contract/${logo}`, + aggregators: [], + }, + }; +}, {}); + export const controllerName = 'TokenDetectionController'; export type TokenDetectionState = Record; @@ -42,7 +88,9 @@ export type TokenDetectionControllerActions = export type AllowedActions = | NetworkControllerGetNetworkConfigurationByNetworkClientId - | GetTokenListState; + | GetTokenListState + | KeyringControllerGetStateAction + | PreferencesControllerGetStateAction; export type TokenDetectionControllerStateChangeEvent = ControllerStateChangeEvent; @@ -51,9 +99,12 @@ export type TokenDetectionControllerEvents = TokenDetectionControllerStateChangeEvent; export type AllowedEvents = - | NetworkControllerStateChangeEvent + | AccountsControllerSelectedAccountChangeEvent | NetworkControllerNetworkDidChangeEvent - | TokenListStateChange; + | TokenListStateChange + | KeyringControllerLockEvent + | KeyringControllerUnlockEvent + | PreferencesControllerStateChangeEvent; export type TokenDetectionControllerMessenger = RestrictedControllerMessenger< typeof controllerName, @@ -70,6 +121,7 @@ export type TokenDetectionControllerMessenger = RestrictedControllerMessenger< * @property selectedAddress - Vault selected address * @property networkClientId - The network client ID of the current selected network * @property disabled - Boolean to track if network requests are blocked + * @property isUnlocked - Boolean to track if the keyring state is unlocked * @property isDetectionEnabledFromPreferences - Boolean to track if detection is enabled from PreferencesController * @property isDetectionEnabledForNetwork - Boolean to track if detected is enabled for current network */ @@ -88,6 +140,8 @@ export class TokenDetectionController extends StaticIntervalPollingController< #disabled: boolean; + #isUnlocked: boolean; + #isDetectionEnabledFromPreferences: boolean; #isDetectionEnabledForNetwork: boolean; @@ -98,6 +152,16 @@ export class TokenDetectionController extends StaticIntervalPollingController< readonly #getTokensState: () => TokensState; + readonly #trackMetaMetricsEvent: (options: { + event: string; + category: string; + properties: { + tokens: string[]; + token_standard: string; + asset_type: string; + }; + }) => void; + /** * Creates a TokenDetectionController instance. * @@ -107,40 +171,40 @@ export class TokenDetectionController extends StaticIntervalPollingController< * @param options.interval - Polling interval used to fetch new token rates * @param options.networkClientId - The selected network client ID of the current network * @param options.selectedAddress - Vault selected address - * @param options.onPreferencesStateChange - Allows subscribing to preferences controller state changes. * @param options.addDetectedTokens - Add a list of detected tokens. * @param options.getBalancesInSingleCall - Gets the balances of a list of tokens for the given address. * @param options.getTokensState - Gets the current state of the Tokens controller. - * @param options.getPreferencesState - Gets the state of the preferences controller. + * @param options.trackMetaMetricsEvent - Sets options for MetaMetrics event tracking. */ constructor({ networkClientId, selectedAddress = '', interval = DEFAULT_INTERVAL, disabled = true, - onPreferencesStateChange, getBalancesInSingleCall, addDetectedTokens, - getPreferencesState, getTokensState, + trackMetaMetricsEvent, messenger, }: { networkClientId: NetworkClientId; selectedAddress?: string; interval?: number; disabled?: boolean; - onPreferencesStateChange: ( - listener: (preferencesState: PreferencesState) => void, - ) => void; addDetectedTokens: TokensController['addDetectedTokens']; getBalancesInSingleCall: AssetsContractController['getBalancesInSingleCall']; getTokensState: () => TokensState; - getPreferencesState: () => PreferencesState; + trackMetaMetricsEvent: (options: { + event: string; + category: string; + properties: { + tokens: string[]; + token_standard: string; + asset_type: string; + }; + }) => void; messenger: TokenDetectionControllerMessenger; }) { - const { useTokenDetection: defaultUseTokenDetection } = - getPreferencesState(); - super({ name: controllerName, messenger, @@ -155,6 +219,8 @@ export class TokenDetectionController extends StaticIntervalPollingController< this.#selectedAddress = selectedAddress; this.#chainId = this.#getCorrectChainId(networkClientId); + const { useTokenDetection: defaultUseTokenDetection } = + this.messagingSystem.call('PreferencesController:getState'); this.#isDetectionEnabledFromPreferences = defaultUseTokenDetection; this.#isDetectionEnabledForNetwork = isTokenDetectionSupportedForNetwork( this.#chainId, @@ -164,18 +230,43 @@ export class TokenDetectionController extends StaticIntervalPollingController< this.#getBalancesInSingleCall = getBalancesInSingleCall; this.#getTokensState = getTokensState; + this.#trackMetaMetricsEvent = trackMetaMetricsEvent; + + const { isUnlocked } = this.messagingSystem.call( + 'KeyringController:getState', + ); + this.#isUnlocked = isUnlocked; + + this.#registerEventListeners(); + } + + /** + * Constructor helper for registering this controller's messaging system subscriptions to controller events. + */ + #registerEventListeners() { + this.messagingSystem.subscribe('KeyringController:unlock', async () => { + this.#isUnlocked = true; + await this.#restartTokenDetection(); + }); + + this.messagingSystem.subscribe('KeyringController:lock', () => { + this.#isUnlocked = false; + this.#stopPolling(); + }); + this.messagingSystem.subscribe( 'TokenListController:stateChange', async ({ tokenList }) => { const hasTokens = Object.keys(tokenList).length; if (hasTokens) { - await this.detectTokens(); + await this.#restartTokenDetection(); } }, ); - onPreferencesStateChange( + this.messagingSystem.subscribe( + 'PreferencesController:stateChange', async ({ selectedAddress: newSelectedAddress, useTokenDetection }) => { const isSelectedAddressChanged = this.#selectedAddress !== newSelectedAddress; @@ -189,7 +280,26 @@ export class TokenDetectionController extends StaticIntervalPollingController< useTokenDetection && (isSelectedAddressChanged || isDetectionChangedFromPreferences) ) { - await this.detectTokens(); + await this.#restartTokenDetection({ + selectedAddress: this.#selectedAddress, + }); + } + }, + ); + + this.messagingSystem.subscribe( + 'AccountsController:selectedAccountChange', + async ({ address: newSelectedAddress }) => { + const isSelectedAddressChanged = + this.#selectedAddress !== newSelectedAddress; + if ( + isSelectedAddressChanged && + this.#isDetectionEnabledFromPreferences + ) { + this.#selectedAddress = newSelectedAddress; + await this.#restartTokenDetection({ + selectedAddress: this.#selectedAddress, + }); } }, ); @@ -197,16 +307,18 @@ export class TokenDetectionController extends StaticIntervalPollingController< this.messagingSystem.subscribe( 'NetworkController:networkDidChange', async ({ selectedNetworkClientId }) => { - this.#networkClientId = selectedNetworkClientId; - const newChainId = this.#getCorrectChainId(selectedNetworkClientId); - const isChainIdChanged = this.#chainId !== newChainId; - this.#chainId = newChainId; + const isNetworkClientIdChanged = + this.#networkClientId !== selectedNetworkClientId; + const newChainId = this.#getCorrectChainId(selectedNetworkClientId); this.#isDetectionEnabledForNetwork = isTokenDetectionSupportedForNetwork(newChainId); - if (this.#isDetectionEnabledForNetwork && isChainIdChanged) { - await this.detectTokens(); + if (isNetworkClientIdChanged && this.#isDetectionEnabledForNetwork) { + this.#networkClientId = selectedNetworkClientId; + await this.#restartTokenDetection({ + networkClientId: this.#networkClientId, + }); } }, ); @@ -226,6 +338,15 @@ export class TokenDetectionController extends StaticIntervalPollingController< this.#disabled = true; } + /** + * Internal isActive state + * + * @type {object} + */ + get isActive() { + return !this.#disabled && this.#isUnlocked; + } + /** * Start polling for detected tokens. */ @@ -252,7 +373,7 @@ export class TokenDetectionController extends StaticIntervalPollingController< * Starts a new polling interval. */ async #startPolling(): Promise { - if (this.#disabled) { + if (!this.isActive) { return; } this.#stopPolling(); @@ -275,7 +396,7 @@ export class TokenDetectionController extends StaticIntervalPollingController< networkClientId: string, options: { address: string }, ): Promise { - if (this.#disabled) { + if (!this.isActive) { return; } await this.detectTokens({ @@ -285,11 +406,31 @@ export class TokenDetectionController extends StaticIntervalPollingController< } /** - * Triggers asset ERC20 token auto detection for each contract address in contract metadata on mainnet. + * Restart token detection polling period and call detectNewTokens + * in case of address change or user session initialization. * - * @param options - Options to detect tokens. + * @param options - Options for restart token detection. + * @param options.selectedAddress - the selectedAddress against which to detect for token balances * @param options.networkClientId - The ID of the network client to use. - * @param options.accountAddress - The account address to use. + */ + async #restartTokenDetection({ + selectedAddress, + networkClientId, + }: { selectedAddress?: string; networkClientId?: string } = {}) { + await this.detectTokens({ + networkClientId, + accountAddress: selectedAddress, + }); + this.setIntervalLength(DEFAULT_INTERVAL); + } + + /** + * For each token in the token list provided by the TokenListController, checks the token's balance for the selected account address on the active network. + * On mainnet, if token detection is disabled in preferences, ERC20 token auto detection will be triggered for each contract address in the legacy token list from the @metamask/contract-metadata repo. + * + * @param options - Options for token detection. + * @param options.networkClientId - The ID of the network client to use. + * @param options.accountAddress - the selectedAddress against which to detect for token balances. */ async detectTokens({ networkClientId, @@ -298,27 +439,42 @@ export class TokenDetectionController extends StaticIntervalPollingController< networkClientId?: NetworkClientId; accountAddress?: string; } = {}): Promise { - if ( - this.#disabled || - !this.#isDetectionEnabledForNetwork || - !this.#isDetectionEnabledFromPreferences - ) { + if (!this.isActive || !this.#isDetectionEnabledForNetwork) { return; } - const { tokens } = this.#getTokensState(); - const selectedAddress = accountAddress || this.#selectedAddress; + const selectedAddress = accountAddress ?? this.#selectedAddress; const chainId = this.#getCorrectChainId(networkClientId); - const tokensAddresses = tokens.map( - /* istanbul ignore next*/ (token) => token.address.toLowerCase(), - ); + if ( + !this.#isDetectionEnabledFromPreferences && + chainId !== ChainId.mainnet + ) { + return; + } + const isTokenDetectionInactiveInMainnet = + !this.#isDetectionEnabledFromPreferences && chainId === ChainId.mainnet; const { tokenList } = this.messagingSystem.call( 'TokenListController:getState', ); + const tokenListUsed = isTokenDetectionInactiveInMainnet + ? STATIC_MAINNET_TOKEN_LIST + : tokenList; + + const { tokens, detectedTokens } = this.#getTokensState(); const tokensToDetect: string[] = []; - for (const address of Object.keys(tokenList)) { - if (!tokensAddresses.includes(address)) { - tokensToDetect.push(address); + + for (const tokenAddress of Object.keys(tokenListUsed)) { + if ( + !findCaseInsensitiveMatch( + tokens.map(({ address }) => address), + tokenAddress, + ) && + !findCaseInsensitiveMatch( + detectedTokens.map(({ address }) => address), + tokenAddress, + ) + ) { + tokensToDetect.push(tokenAddress); } } const sliceOfTokensToDetect = []; @@ -344,6 +500,7 @@ export class TokenDetectionController extends StaticIntervalPollingController< tokensSlice, ); const tokensToAdd: Token[] = []; + const eventTokensDetails = []; for (const tokenAddress of Object.keys(balances)) { let ignored; /* istanbul ignore else */ @@ -355,13 +512,15 @@ export class TokenDetectionController extends StaticIntervalPollingController< ); } const caseInsensitiveTokenKey = - Object.keys(tokenList).find( - (i) => i.toLowerCase() === tokenAddress.toLowerCase(), + findCaseInsensitiveMatch( + Object.keys(tokenListUsed), + tokenAddress, ) ?? ''; if (ignored === undefined) { const { decimals, symbol, aggregators, iconUrl, name } = tokenList[caseInsensitiveTokenKey]; + eventTokensDetails.push(`${symbol} - ${tokenAddress}`); tokensToAdd.push({ address: tokenAddress, decimals, @@ -375,6 +534,15 @@ export class TokenDetectionController extends StaticIntervalPollingController< } if (tokensToAdd.length) { + this.#trackMetaMetricsEvent({ + event: 'Token Detected', + category: 'Wallet', + properties: { + tokens: eventTokensDetails, + token_standard: 'ERC20', + asset_type: 'TOKEN', + }, + }); await this.#addDetectedTokens(tokensToAdd, { selectedAddress, chainId, diff --git a/packages/assets-controllers/tsconfig.build.json b/packages/assets-controllers/tsconfig.build.json index 93737886c16..5d38b996867 100644 --- a/packages/assets-controllers/tsconfig.build.json +++ b/packages/assets-controllers/tsconfig.build.json @@ -6,9 +6,11 @@ "rootDir": "./src" }, "references": [ + { "path": "../accounts-controller/tsconfig.build.json" }, { "path": "../approval-controller/tsconfig.build.json" }, { "path": "../base-controller/tsconfig.build.json" }, { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../keyring-controller/tsconfig.build.json" }, { "path": "../network-controller/tsconfig.build.json" }, { "path": "../preferences-controller/tsconfig.build.json" }, { "path": "../polling-controller/tsconfig.build.json" } diff --git a/packages/assets-controllers/tsconfig.json b/packages/assets-controllers/tsconfig.json index 0dba0b3a1de..05bd347469b 100644 --- a/packages/assets-controllers/tsconfig.json +++ b/packages/assets-controllers/tsconfig.json @@ -5,9 +5,11 @@ "rootDir": "../.." }, "references": [ + { "path": "../accounts-controller" }, { "path": "../approval-controller" }, { "path": "../base-controller" }, { "path": "../controller-utils" }, + { "path": "../keyring-controller" }, { "path": "../network-controller" }, { "path": "../preferences-controller" }, { "path": "../polling-controller" } diff --git a/yarn.lock b/yarn.lock index 3be79ebcc4e..c2b4cab67da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1477,7 +1477,7 @@ __metadata: languageName: node linkType: hard -"@metamask/accounts-controller@workspace:packages/accounts-controller": +"@metamask/accounts-controller@^10.0.0, @metamask/accounts-controller@workspace:packages/accounts-controller": version: 0.0.0-use.local resolution: "@metamask/accounts-controller@workspace:packages/accounts-controller" dependencies: @@ -1581,6 +1581,7 @@ __metadata: "@ethersproject/contracts": ^5.7.0 "@ethersproject/providers": ^5.7.0 "@metamask/abi-utils": ^2.0.2 + "@metamask/accounts-controller": ^10.0.0 "@metamask/approval-controller": ^5.1.2 "@metamask/auto-changelog": ^3.4.4 "@metamask/base-controller": ^4.1.1 @@ -1588,6 +1589,8 @@ __metadata: "@metamask/controller-utils": ^8.0.2 "@metamask/eth-query": ^4.0.0 "@metamask/ethjs-provider-http": ^0.3.0 + "@metamask/keyring-api": ^3.0.0 + "@metamask/keyring-controller": ^12.2.0 "@metamask/metamask-eth-abis": 3.0.0 "@metamask/network-controller": ^17.2.0 "@metamask/polling-controller": ^5.0.0 @@ -1615,7 +1618,9 @@ __metadata: typescript: ~4.8.4 uuid: ^8.3.2 peerDependencies: + "@metamask/accounts-controller": ^10.0.0 "@metamask/approval-controller": ^5.1.2 + "@metamask/keyring-controller": ^12.2.0 "@metamask/network-controller": ^17.2.0 "@metamask/preferences-controller": ^7.0.0 languageName: unknown @@ -3819,6 +3824,15 @@ __metadata: languageName: node linkType: hard +"abort-controller@npm:^3.0.0": + version: 3.0.0 + resolution: "abort-controller@npm:3.0.0" + dependencies: + event-target-shim: ^5.0.0 + checksum: 170bdba9b47b7e65906a28c8ce4f38a7a369d78e2271706f020849c1bfe0ee2067d4261df8bbb66eb84f79208fd5b710df759d64191db58cfba7ce8ef9c54b75 + languageName: node + linkType: hard + "acorn-globals@npm:^6.0.0": version: 6.0.0 resolution: "acorn-globals@npm:6.0.0" @@ -6072,6 +6086,20 @@ __metadata: languageName: node linkType: hard +"event-target-shim@npm:^5.0.0": + version: 5.0.1 + resolution: "event-target-shim@npm:5.0.1" + checksum: 1ffe3bb22a6d51bdeb6bf6f7cf97d2ff4a74b017ad12284cc9e6a279e727dc30a5de6bb613e5596ff4dc3e517841339ad09a7eec44266eccb1aa201a30448166 + languageName: node + linkType: hard + +"events@npm:^3.3.0": + version: 3.3.0 + resolution: "events@npm:3.3.0" + checksum: f6f487ad2198aa41d878fa31452f1a3c00958f46e9019286ff4787c84aac329332ab45c9cdc8c445928fc6d7ded294b9e005a7fce9426488518017831b272780 + languageName: node + linkType: hard + "evp_bytestokey@npm:^1.0.3": version: 1.0.3 resolution: "evp_bytestokey@npm:1.0.3" @@ -9496,6 +9524,13 @@ __metadata: languageName: node linkType: hard +"process@npm:^0.11.10": + version: 0.11.10 + resolution: "process@npm:0.11.10" + checksum: bfcce49814f7d172a6e6a14d5fa3ac92cc3d0c3b9feb1279774708a719e19acd673995226351a082a9ae99978254e320ccda4240ddc474ba31a76c79491ca7c3 + languageName: node + linkType: hard + "promise-retry@npm:^2.0.1": version: 2.0.1 resolution: "promise-retry@npm:2.0.1" @@ -9615,7 +9650,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:3.6.2, readable-stream@npm:^3.0.2, readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.0, readable-stream@npm:^3.6.2, readable-stream@npm:^3.6.2 || ^4.4.2": +"readable-stream@npm:3.6.2, readable-stream@npm:^3.0.2, readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.0, readable-stream@npm:^3.6.2": version: 3.6.2 resolution: "readable-stream@npm:3.6.2" dependencies: @@ -9626,6 +9661,19 @@ __metadata: languageName: node linkType: hard +"readable-stream@npm:^3.6.2 || ^4.4.2": + version: 4.5.2 + resolution: "readable-stream@npm:4.5.2" + dependencies: + abort-controller: ^3.0.0 + buffer: ^6.0.3 + events: ^3.3.0 + process: ^0.11.10 + string_decoder: ^1.3.0 + checksum: c4030ccff010b83e4f33289c535f7830190773e274b3fcb6e2541475070bdfd69c98001c3b0cb78763fc00c8b62f514d96c2b10a8bd35d5ce45203a25fa1d33a + languageName: node + linkType: hard + "readable-stream@npm:~2.3.6": version: 2.3.8 resolution: "readable-stream@npm:2.3.8" @@ -10365,7 +10413,7 @@ __metadata: languageName: node linkType: hard -"string_decoder@npm:^1.1.1": +"string_decoder@npm:^1.1.1, string_decoder@npm:^1.3.0": version: 1.3.0 resolution: "string_decoder@npm:1.3.0" dependencies: