diff --git a/packages/assets-controllers/src/NftDetectionController.test.ts b/packages/assets-controllers/src/NftDetectionController.test.ts index 5f6ca9a8ff..11b90114dd 100644 --- a/packages/assets-controllers/src/NftDetectionController.test.ts +++ b/packages/assets-controllers/src/NftDetectionController.test.ts @@ -1,4 +1,5 @@ -import { NFT_API_BASE_URL, ChainId, toHex } from '@metamask/controller-utils'; +import { ControllerMessenger } from '@metamask/base-controller'; +import { NFT_API_BASE_URL, ChainId } from '@metamask/controller-utils'; import { NetworkClientType, defaultState as defaultNetworkState, @@ -24,15 +25,18 @@ import { buildMockGetNetworkClientById, } from '../../network-controller/tests/helpers'; import { Source } from './constants'; -import { getDefaultNftState, type NftState } from './NftController'; +import { getDefaultNftState } from './NftController'; import { - type NftDetectionConfig, NftDetectionController, BlockaidResultType, + type AllowedActions, + type AllowedEvents, } from './NftDetectionController'; const DEFAULT_INTERVAL = 180000; +const controllerName = 'NftDetectionController' as const; + describe('NftDetectionController', () => { let clock: sinon.SinonFakeTimers; @@ -283,25 +287,14 @@ describe('NftDetectionController', () => { sinon.restore(); }); - it('should set default config', async () => { - await withController(({ controller }) => { - expect(controller.config).toStrictEqual({ - interval: DEFAULT_INTERVAL, - chainId: toHex(1), - selectedAddress: '', - disabled: true, - }); - }); - }); - it('should poll and detect NFTs on interval while on mainnet', async () => { await withController( - { config: { interval: 10 } }, + { options: { interval: 10 } }, async ({ controller, controllerEvents }) => { const mockNfts = sinon .stub(controller, 'detectNfts') .returns(Promise.resolve()); - controllerEvents.preferencesStateChange({ + controllerEvents.triggerPreferencesStateChange({ ...getDefaultPreferencesState(), useNftDetection: true, }); @@ -425,7 +418,7 @@ describe('NftDetectionController', () => { ], ]); - controllerEvents.networkStateChange({ + controllerEvents.triggerNetworkStateChange({ ...defaultNetworkState, selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', }); @@ -460,13 +453,36 @@ describe('NftDetectionController', () => { ); }); - it('should detect mainnet correctly', async () => { - await withController(({ controller }) => { - controller.configure({ chainId: ChainId.mainnet }); - expect(controller.isMainnet()).toBe(true); - controller.configure({ chainId: ChainId.goerli }); - expect(controller.isMainnet()).toBe(false); - }); + it('should detect mainnet truthy', async () => { + await withController( + { + mockNetworkState: { + selectedNetworkClientId: 'mainnet', + }, + mockPreferencesState: { + selectedAddress: '', + }, + }, + ({ controller }) => { + expect(controller.isMainnet()).toBe(true); + }, + ); + }); + + it('should detect mainnet falsy', async () => { + await withController( + { + mockNetworkState: { + selectedNetworkClientId: 'goerli', + }, + mockPreferencesState: { + selectedAddress: '', + }, + }, + ({ controller }) => { + expect(controller.isMainnet()).toBe(false); + }, + ); }); it('should not autodetect while not on mainnet', async () => { @@ -486,20 +502,22 @@ describe('NftDetectionController', () => { await withController( { - config: { - interval: pollingInterval, - }, options: { + interval: pollingInterval, addNft: mockAddNft, - chainId: '0x1', disabled: false, - selectedAddress: '0x1', }, mockNetworkClientConfigurationsByNetworkClientId: { 'AAAA-AAAA-AAAA-AAAA': buildCustomNetworkClientConfiguration({ chainId: '0x123', }), }, + mockNetworkState: { + selectedNetworkClientId: 'mainnet', + }, + mockPreferencesState: { + selectedAddress: '0x1', + }, }, async ({ controller, controllerEvents }) => { await controller.start(); @@ -561,7 +579,7 @@ describe('NftDetectionController', () => { }, ); - controllerEvents.networkStateChange({ + controllerEvents.triggerNetworkStateChange({ ...defaultNetworkState, selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', }); @@ -576,11 +594,16 @@ describe('NftDetectionController', () => { it('should detect and add NFTs correctly when blockaid result is not included in response', async () => { const mockAddNft = jest.fn(); + const selectedAddress = '0x1'; await withController( - { options: { addNft: mockAddNft } }, + { + options: { addNft: mockAddNft }, + mockPreferencesState: { + selectedAddress, + }, + }, async ({ controller, controllerEvents }) => { - const selectedAddress = '0x1'; - controllerEvents.preferencesStateChange({ + controllerEvents.triggerPreferencesStateChange({ ...getDefaultPreferencesState(), selectedAddress, useNftDetection: true, @@ -616,11 +639,14 @@ describe('NftDetectionController', () => { it('should detect and add NFTs correctly when blockaid result is in response', async () => { const mockAddNft = jest.fn(); + const selectedAddress = '0x123'; await withController( - { options: { addNft: mockAddNft } }, + { + options: { addNft: mockAddNft }, + mockPreferencesState: { selectedAddress }, + }, async ({ controller, controllerEvents }) => { - const selectedAddress = '0x123'; - controllerEvents.preferencesStateChange({ + controllerEvents.triggerPreferencesStateChange({ ...getDefaultPreferencesState(), selectedAddress, useNftDetection: true, @@ -665,11 +691,14 @@ describe('NftDetectionController', () => { it('should detect and add NFTs and filter them correctly', async () => { const mockAddNft = jest.fn(); + const selectedAddress = '0x12345'; await withController( - { options: { addNft: mockAddNft } }, + { + options: { addNft: mockAddNft }, + mockPreferencesState: { selectedAddress }, + }, async ({ controller, controllerEvents }) => { - const selectedAddress = '0x12345'; - controllerEvents.preferencesStateChange({ + controllerEvents.triggerPreferencesStateChange({ ...getDefaultPreferencesState(), selectedAddress, useNftDetection: true, @@ -730,7 +759,7 @@ describe('NftDetectionController', () => { { options: { addNft: mockAddNft } }, async ({ controller, controllerEvents }) => { const selectedAddress = '0x1'; - controllerEvents.preferencesStateChange({ + controllerEvents.triggerPreferencesStateChange({ ...getDefaultPreferencesState(), selectedAddress, useNftDetection: true, @@ -783,11 +812,14 @@ describe('NftDetectionController', () => { ], }; }); + const selectedAddress = '0x9'; await withController( - { options: { addNft: mockAddNft, getNftState: mockGetNftState } }, + { + options: { addNft: mockAddNft, getNftState: mockGetNftState }, + mockPreferencesState: { selectedAddress }, + }, async ({ controller, controllerEvents }) => { - const selectedAddress = '0x9'; - controllerEvents.preferencesStateChange({ + controllerEvents.triggerPreferencesStateChange({ ...getDefaultPreferencesState(), selectedAddress, useNftDetection: true, @@ -808,19 +840,19 @@ describe('NftDetectionController', () => { it('should not detect and add NFTs if there is no selectedAddress', async () => { const mockAddNft = jest.fn(); + const selectedAddress = ''; // Emtpy selected address await withController( - { options: { addNft: mockAddNft } }, + { + options: { addNft: mockAddNft }, + mockPreferencesState: { selectedAddress }, + }, async ({ controller, controllerEvents }) => { - const selectedAddress = ''; // Emtpy selected address - controllerEvents.preferencesStateChange({ + controllerEvents.triggerPreferencesStateChange({ ...getDefaultPreferencesState(), selectedAddress, useNftDetection: true, // auto-detect is enabled so it proceeds to check userAddress }); - // confirm that default selected address is an empty string - expect(controller.config.selectedAddress).toBe(''); - await controller.detectNfts(); expect(mockAddNft).not.toHaveBeenCalled(); @@ -832,7 +864,7 @@ describe('NftDetectionController', () => { const mockAddNft = jest.fn(); const mockNetworkClient: NetworkClient = { configuration: { - chainId: toHex(1), + chainId: ChainId.mainnet, rpcUrl: 'https://test.network', ticker: 'TEST', type: NetworkClientType.Custom, @@ -854,10 +886,10 @@ describe('NftDetectionController', () => { it('should not detectNfts when disabled is false and useNftDetection is true', async () => { await withController( - { config: { interval: 10 }, options: { disabled: false } }, + { options: { disabled: false, interval: 10 } }, async ({ controller, controllerEvents }) => { const mockNfts = sinon.stub(controller, 'detectNfts'); - controllerEvents.preferencesStateChange({ + controllerEvents.triggerPreferencesStateChange({ ...getDefaultPreferencesState(), useNftDetection: true, }); @@ -881,11 +913,14 @@ describe('NftDetectionController', () => { it('should not detect and add NFTs if preferences controller useNftDetection is set to false', async () => { const mockAddNft = jest.fn(); + const selectedAddress = '0x9'; await withController( - { options: { addNft: mockAddNft } }, + { + options: { addNft: mockAddNft, disabled: false }, + mockPreferencesState: { selectedAddress }, + }, async ({ controller, controllerEvents }) => { - const selectedAddress = '0x9'; - controllerEvents.preferencesStateChange({ + controllerEvents.triggerPreferencesStateChange({ ...getDefaultPreferencesState(), selectedAddress, useNftDetection: false, @@ -920,7 +955,7 @@ describe('NftDetectionController', () => { await withController( { options: { addNft: mockAddNft } }, async ({ controller, controllerEvents }) => { - controllerEvents.preferencesStateChange({ + controllerEvents.triggerPreferencesStateChange({ ...getDefaultPreferencesState(), selectedAddress, useNftDetection: true, @@ -941,53 +976,59 @@ describe('NftDetectionController', () => { it('should rethrow error when Nft APi server fails with error other than fetch failure', async () => { const selectedAddress = '0x4'; - await withController(async ({ controller, controllerEvents }) => { - // This mock is for the initial detect call after preferences change - nock(NFT_API_BASE_URL) - .get(`/users/${selectedAddress}/tokens`) - .query({ - continuation: '', - limit: '50', - chainIds: '1', - includeTopBid: true, - }) - .reply(200, { - tokens: [], + await withController( + { mockPreferencesState: { selectedAddress } }, + async ({ controller, controllerEvents }) => { + // This mock is for the initial detect call after preferences change + nock(NFT_API_BASE_URL) + .get(`/users/${selectedAddress}/tokens`) + .query({ + continuation: '', + limit: '50', + chainIds: '1', + includeTopBid: true, + }) + .reply(200, { + tokens: [], + }); + controllerEvents.triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + selectedAddress, + useNftDetection: true, }); - controllerEvents.preferencesStateChange({ - ...getDefaultPreferencesState(), - selectedAddress, - useNftDetection: true, - }); - // Wait for detect call triggered by preferences state change to settle - await advanceTime({ - clock, - duration: 1, - }); - // This mock is for the call under test - nock(NFT_API_BASE_URL) - .get(`/users/${selectedAddress}/tokens`) - .query({ - continuation: '', - limit: '50', - chainIds: '1', - includeTopBid: true, - }) - .replyWithError(new Error('UNEXPECTED ERROR')); - - await expect(() => controller.detectNfts()).rejects.toThrow( - 'UNEXPECTED ERROR', - ); - }); + // Wait for detect call triggered by preferences state change to settle + await advanceTime({ + clock, + duration: 1, + }); + // This mock is for the call under test + nock(NFT_API_BASE_URL) + .get(`/users/${selectedAddress}/tokens`) + .query({ + continuation: '', + limit: '50', + chainIds: '1', + includeTopBid: true, + }) + .replyWithError(new Error('UNEXPECTED ERROR')); + + await expect(() => controller.detectNfts()).rejects.toThrow( + 'UNEXPECTED ERROR', + ); + }, + ); }); it('should rethrow error when attempt to add NFT fails', async () => { const mockAddNft = jest.fn(); + const selectedAddress = '0x1'; await withController( - { options: { addNft: mockAddNft } }, + { + options: { addNft: mockAddNft }, + mockPreferencesState: { selectedAddress }, + }, async ({ controller, controllerEvents }) => { - const selectedAddress = '0x1'; - controllerEvents.preferencesStateChange({ + controllerEvents.triggerPreferencesStateChange({ ...getDefaultPreferencesState(), selectedAddress, useNftDetection: true, @@ -1013,7 +1054,7 @@ describe('NftDetectionController', () => { // Repeated preference changes should only trigger 1 detection for (let i = 0; i < 5; i++) { - controllerEvents.preferencesStateChange({ + controllerEvents.triggerPreferencesStateChange({ ...getDefaultPreferencesState(), useNftDetection: true, }); @@ -1022,7 +1063,7 @@ describe('NftDetectionController', () => { expect(detectNfts.callCount).toBe(1); // Irrelevant preference changes shouldn't trigger a detection - controllerEvents.preferencesStateChange({ + controllerEvents.triggerPreferencesStateChange({ ...getDefaultPreferencesState(), useNftDetection: true, securityAlertsEnabled: true, @@ -1037,9 +1078,8 @@ describe('NftDetectionController', () => { * A collection of mock external controller events. */ type ControllerEvents = { - nftsStateChange: (state: NftState) => void; - preferencesStateChange: (state: PreferencesState) => void; - networkStateChange: (state: NetworkState) => void; + triggerPreferencesStateChange: (state: PreferencesState) => void; + triggerNetworkStateChange: (state: NetworkState) => void; }; type WithControllerCallback = ({ @@ -1051,11 +1091,12 @@ type WithControllerCallback = ({ type WithControllerOptions = { options?: Partial[0]>; - config?: Partial; mockNetworkClientConfigurationsByNetworkClientId?: Record< NetworkClientId, NetworkClientConfiguration >; + mockNetworkState?: Partial; + mockPreferencesState?: Partial; }; type WithControllerArgs = @@ -1077,43 +1118,69 @@ async function withController( const [ { options = {}, - config = {}, mockNetworkClientConfigurationsByNetworkClientId = {}, + mockNetworkState = {}, + mockPreferencesState = {}, }, testFunction, ] = args.length === 2 ? args : [{}, args[0]]; - // Explicit cast used here because we know the `on____` functions are always - // set in the constructor. - const controllerEvents = {} as ControllerEvents; + const messenger = new ControllerMessenger(); + + messenger.registerActionHandler( + 'NetworkController:getState', + jest.fn().mockReturnValue({ + ...defaultNetworkState, + ...mockNetworkState, + }), + ); const getNetworkClientById = buildMockGetNetworkClientById( mockNetworkClientConfigurationsByNetworkClientId, ); + messenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + getNetworkClientById, + ); - const controller = new NftDetectionController( - { - chainId: ChainId.mainnet, - onNftsStateChange: (listener) => { - controllerEvents.nftsStateChange = listener; - }, - onPreferencesStateChange: (listener) => { - controllerEvents.preferencesStateChange = listener; - }, - onNetworkStateChange: (listener) => { - controllerEvents.networkStateChange = listener; - }, - getOpenSeaApiKey: jest.fn(), - addNft: jest.fn(), - getNftApi: jest.fn(), - getNetworkClientById, - getNftState: getDefaultNftState, - disabled: true, - selectedAddress: '', - ...options, - }, - config, + messenger.registerActionHandler( + 'PreferencesController:getState', + jest.fn().mockReturnValue({ + ...getDefaultPreferencesState(), + ...mockPreferencesState, + }), ); + + const controllerMessenger = messenger.getRestricted({ + name: controllerName, + allowedActions: [ + 'NetworkController:getState', + 'NetworkController:getNetworkClientById', + 'PreferencesController:getState', + ], + allowedEvents: [ + 'NetworkController:stateChange', + 'PreferencesController:stateChange', + ], + }); + + const controller = new NftDetectionController({ + messenger: controllerMessenger, + disabled: true, + addNft: jest.fn(), + getNftState: getDefaultNftState, + ...options, + }); + + const controllerEvents = { + triggerPreferencesStateChange: (state: PreferencesState) => { + messenger.publish('PreferencesController:stateChange', state, []); + }, + triggerNetworkStateChange: (state: NetworkState) => { + messenger.publish('NetworkController:stateChange', state, []); + }, + }; + try { return await testFunction({ controller, diff --git a/packages/assets-controllers/src/NftDetectionController.ts b/packages/assets-controllers/src/NftDetectionController.ts index e47251fbd2..7b8d5171b0 100644 --- a/packages/assets-controllers/src/NftDetectionController.ts +++ b/packages/assets-controllers/src/NftDetectionController.ts @@ -1,19 +1,26 @@ -import type { BaseConfig, BaseState } from '@metamask/base-controller'; +import type { AddApprovalRequest } from '@metamask/approval-controller'; +import type { RestrictedControllerMessenger } from '@metamask/base-controller'; import { fetchWithErrorHandling, toChecksumHexAddress, ChainId, NFT_API_BASE_URL, + NFT_API_VERSION, + NFT_API_TIMEOUT, } from '@metamask/controller-utils'; import type { NetworkClientId, - NetworkController, - NetworkState, NetworkClient, + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerStateChangeEvent, + NetworkControllerGetStateAction, } from '@metamask/network-controller'; -import { StaticIntervalPollingControllerV1 } from '@metamask/polling-controller'; -import type { PreferencesState } from '@metamask/preferences-controller'; -import type { Hex } from '@metamask/utils'; +import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import type { + PreferencesControllerGetStateAction, + PreferencesControllerStateChangeEvent, + PreferencesState, +} from '@metamask/preferences-controller'; import { Source } from './constants'; import { @@ -24,6 +31,26 @@ import { const DEFAULT_INTERVAL = 180000; +const controllerName = 'NftDetectionController'; + +export type AllowedActions = + | AddApprovalRequest + | NetworkControllerGetStateAction + | NetworkControllerGetNetworkClientByIdAction + | PreferencesControllerGetStateAction; + +export type AllowedEvents = + | PreferencesControllerStateChangeEvent + | NetworkControllerStateChangeEvent; + +export type NftDetectionControllerMessenger = RestrictedControllerMessenger< + typeof controllerName, + AllowedActions, + AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; + /** * @type ApiNft * @@ -44,10 +71,7 @@ const DEFAULT_INTERVAL = 180000; * @property creator - The NFT owner information object * @property lastSale - When this item was last sold */ -// This interface was created before this ESLint rule was added. -// Convert to a `type` in a future major version. -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export interface ApiNft { +export type ApiNft = { token_id: string; num_sales: number | null; background_color: string | null; @@ -63,7 +87,7 @@ export interface ApiNft { asset_contract: ApiNftContract; creator: ApiNftCreator; last_sale: ApiNftLastSale | null; -} +}; /** * @type ApiNftContract @@ -79,10 +103,7 @@ export interface ApiNft { * @property description - The NFT contract description * @property external_link - External link containing additional information */ -// This interface was created before this ESLint rule was added. -// Convert to a `type` in a future major version. -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export interface ApiNftContract { +export type ApiNftContract = { address: string; asset_contract_type: string | null; created_date: string | null; @@ -96,7 +117,7 @@ export interface ApiNftContract { image_url?: string | null; tokenCount?: string | null; }; -} +}; /** * @type ApiNftLastSale @@ -106,14 +127,11 @@ export interface ApiNftContract { * @property total_price - URI of NFT image associated with this owner * @property transaction - Object containing transaction_hash and block_hash */ -// This interface was created before this ESLint rule was added. -// Convert to a `type` in a future major version. -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export interface ApiNftLastSale { +export type ApiNftLastSale = { event_timestamp: string; total_price: string; transaction: { transaction_hash: string; block_hash: string }; -} +}; /** * @type ApiNftCreator @@ -123,31 +141,11 @@ export interface ApiNftLastSale { * @property profile_img_url - URI of NFT image associated with this owner * @property address - The owner address */ -// This interface was created before this ESLint rule was added. -// Convert to a `type` in a future major version. -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export interface ApiNftCreator { +export type ApiNftCreator = { user: { username: string }; profile_img_url: string; address: string; -} - -/** - * @type NftDetectionConfig - * - * NftDetection configuration - * @property interval - Polling interval used to fetch new token rates - * @property chainId - Current chain ID - * @property selectedAddress - Vault selected address - */ -// This interface was created before this ESLint rule was added. -// Convert to a `type` in a future major version. -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export interface NftDetectionConfig extends BaseConfig { - interval: number; - chainId: Hex; - selectedAddress: string; -} +}; export type ReservoirResponse = { tokens: TokensResponse[]; @@ -350,162 +348,62 @@ export type Metadata = { /** * Controller that passively polls on a set interval for NFT auto detection */ -export class NftDetectionController extends StaticIntervalPollingControllerV1< - NftDetectionConfig, - BaseState +export class NftDetectionController extends StaticIntervalPollingController< + typeof controllerName, + Record, + NftDetectionControllerMessenger > { - private intervalId?: ReturnType; - - private getOwnerNftApi({ - address, - next, - }: { - address: string; - next?: string; - }) { - return `${NFT_API_BASE_URL}/users/${address}/tokens?chainIds=1&limit=50&includeTopBid=true&continuation=${ - next ?? '' - }`; - } - - private async getOwnerNfts(address: string) { - let nftApiResponse: ReservoirResponse; - let nfts: TokensResponse[] = []; - let next; - - do { - nftApiResponse = await fetchWithErrorHandling({ - url: this.getOwnerNftApi({ address, next }), - options: { - headers: { - Version: '1', - }, - }, - timeout: 15000, - }); - - if (!nftApiResponse) { - return nfts; - } - - const newNfts = nftApiResponse.tokens.filter( - (elm) => - elm.token.isSpam === false && - (elm.blockaidResult?.result_type - ? elm.blockaidResult?.result_type === BlockaidResultType.Benign - : true), - ); - - nfts = [...nfts, ...newNfts]; - } while ((next = nftApiResponse.continuation)); - - return nfts; - } - - /** - * Name of this controller used during composition - */ - override name = 'NftDetectionController'; + #intervalId?: ReturnType; - private readonly getOpenSeaApiKey: () => string | undefined; + #interval: number; - private readonly addNft: NftController['addNft']; + #disabled: boolean; - private readonly getNftApi: NftController['getNftApi']; + readonly #addNft: NftController['addNft']; - private readonly getNftState: () => NftState; - - private readonly getNetworkClientById: NetworkController['getNetworkClientById']; + readonly #getNftState: () => NftState; /** - * Creates an NftDetectionController instance. + * The controller options * * @param options - The controller options. - * @param options.chainId - The chain ID of the current network. - * @param options.onNftsStateChange - Allows subscribing to assets controller state changes. - * @param options.onPreferencesStateChange - Allows subscribing to preferences controller state changes. - * @param options.onNetworkStateChange - Allows subscribing to network controller state changes. - * @param options.getOpenSeaApiKey - Gets the OpenSea API key, if one is set. + * @param options.interval - The pooling interval. + * @param options.messenger - A reference to the messaging system. + * @param options.disabled - Represents previous value of useNftDetection. Used to detect changes of useNftDetection. Default value is true. * @param options.addNft - Add an NFT. - * @param options.getNftApi - Gets the URL to fetch an NFT from OpenSea. * @param options.getNftState - Gets the current state of the Assets controller. - * @param options.disabled - Represents previous value of useNftDetection. Used to detect changes of useNftDetection. Default value is true. - * @param options.selectedAddress - Represents current selected address. - * @param options.getNetworkClientById - Gets the network client by ID, from the NetworkController. - * @param config - Initial options used to configure this controller. - * @param state - Initial state to set on this controller. */ - constructor( - { - chainId: initialChainId, - getNetworkClientById, - onPreferencesStateChange, - onNetworkStateChange, - getOpenSeaApiKey, - addNft, - getNftApi, - getNftState, - disabled: initialDisabled, - selectedAddress: initialSelectedAddress, - }: { - chainId: Hex; - getNetworkClientById: NetworkController['getNetworkClientById']; - onNftsStateChange: (listener: (nftsState: NftState) => void) => void; - onPreferencesStateChange: ( - listener: (preferencesState: PreferencesState) => void, - ) => void; - onNetworkStateChange: ( - listener: (networkState: NetworkState) => void, - ) => void; - getOpenSeaApiKey: () => string | undefined; - addNft: NftController['addNft']; - getNftApi: NftController['getNftApi']; - getNftState: () => NftState; - disabled: boolean; - selectedAddress: string; - }, - config?: Partial, - state?: Partial, - ) { - super(config, state); - this.defaultConfig = { - interval: DEFAULT_INTERVAL, - chainId: initialChainId, - selectedAddress: initialSelectedAddress, - disabled: initialDisabled, - }; - this.initialize(); - this.getNftState = getNftState; - this.getNetworkClientById = getNetworkClientById; - onPreferencesStateChange(({ selectedAddress, useNftDetection }) => { - const { selectedAddress: previouslySelectedAddress, disabled } = - this.config; - - if ( - selectedAddress !== previouslySelectedAddress || - !useNftDetection !== disabled - ) { - this.configure({ selectedAddress, disabled: !useNftDetection }); - if (useNftDetection) { - this.start(); - } else { - this.stop(); - } - } + constructor({ + interval = DEFAULT_INTERVAL, + messenger, + disabled = false, + addNft, + getNftState, + }: { + interval?: number; + messenger: NftDetectionControllerMessenger; + disabled: boolean; + addNft: NftController['addNft']; + getNftState: () => NftState; + }) { + super({ + name: controllerName, + messenger, + metadata: {}, + state: {}, }); + this.#interval = interval; + this.#disabled = disabled; - onNetworkStateChange(({ selectedNetworkClientId }) => { - const selectedNetworkClient = getNetworkClientById( - selectedNetworkClientId, - ); - const { chainId } = selectedNetworkClient.configuration; + this.#getNftState = getNftState; + this.#addNft = addNft; - this.configure({ chainId }); - }); - this.getOpenSeaApiKey = getOpenSeaApiKey; - this.addNft = addNft; - this.getNftApi = getNftApi; - this.setIntervalLength(this.config.interval); + this.messagingSystem.subscribe( + 'PreferencesController:stateChange', + this.#onPreferencesControllerStateChange.bind(this), + ); + + this.setIntervalLength(this.#interval); } async _executePoll( @@ -519,38 +417,36 @@ export class NftDetectionController extends StaticIntervalPollingControllerV1< * Start polling for the currency rate. */ async start() { - if (!this.isMainnet() || this.disabled) { + if (!this.isMainnet() || this.#disabled) { return; } - await this.startPolling(); + await this.#startPolling(); } /** * Stop polling for the currency rate. */ stop() { - this.stopPolling(); + this.#stopPolling(); } - private stopPolling() { - if (this.intervalId) { - clearInterval(this.intervalId); + #stopPolling() { + if (this.#intervalId) { + clearInterval(this.#intervalId); } } /** * Starts a new polling interval. * - * @param interval - An interval on which to poll. */ - private async startPolling(interval?: number): Promise { - interval && this.configure({ interval }, false, false); - this.stopPolling(); + async #startPolling(): Promise { + this.#stopPolling(); await this.detectNfts(); - this.intervalId = setInterval(async () => { + this.#intervalId = setInterval(async () => { await this.detectNfts(); - }, this.config.interval); + }, this.#interval); } /** @@ -558,11 +454,79 @@ export class NftDetectionController extends StaticIntervalPollingControllerV1< * * @returns Whether current network is mainnet. */ - isMainnet = (): boolean => this.config.chainId === ChainId.mainnet; + isMainnet(): boolean { + const { selectedNetworkClientId } = this.messagingSystem.call( + 'NetworkController:getState', + ); + const { + configuration: { chainId }, + } = this.messagingSystem.call( + 'NetworkController:getNetworkClientById', + selectedNetworkClientId, + ); + return chainId === ChainId.mainnet; + } - isMainnetByNetworkClientId = (networkClient: NetworkClient): boolean => { + isMainnetByNetworkClientId(networkClient: NetworkClient): boolean { return networkClient.configuration.chainId === ChainId.mainnet; - }; + } + + /** + * Handles the state change of the preference controller. + * @param preferencesState - The new state of the preference controller. + * @param preferencesState.useNftDetection - Boolean indicating user preference on NFT detection. + */ + #onPreferencesControllerStateChange({ useNftDetection }: PreferencesState) { + if (!useNftDetection !== this.#disabled) { + this.#disabled = !useNftDetection; + if (useNftDetection) { + this.start(); + } else { + this.stop(); + } + } + } + + #getOwnerNftApi({ address, next }: { address: string; next?: string }) { + return `${NFT_API_BASE_URL}/users/${address}/tokens?chainIds=1&limit=50&includeTopBid=true&continuation=${ + next ?? '' + }`; + } + + async #getOwnerNfts(address: string) { + let nftApiResponse: ReservoirResponse; + let nfts: TokensResponse[] = []; + let next; + + do { + nftApiResponse = await fetchWithErrorHandling({ + url: this.#getOwnerNftApi({ address, next }), + options: { + headers: { + Version: NFT_API_VERSION, + }, + }, + timeout: NFT_API_TIMEOUT, + }); + + if (!nftApiResponse) { + return nfts; + } + + const newNfts = + nftApiResponse.tokens?.filter( + (elm) => + elm.token.isSpam === false && + (elm.blockaidResult?.result_type + ? elm.blockaidResult?.result_type === BlockaidResultType.Benign + : true), + ) ?? []; + + nfts = [...nfts, ...newNfts]; + } while ((next = nftApiResponse.continuation)); + + return nfts; + } /** * Triggers asset ERC721 token auto detection on mainnet. Any newly detected NFTs are @@ -572,17 +536,16 @@ export class NftDetectionController extends StaticIntervalPollingControllerV1< * @param options.networkClientId - The network client ID to detect NFTs on. * @param options.userAddress - The address to detect NFTs for. */ - async detectNfts( - { - networkClientId, - userAddress, - }: { - networkClientId?: NetworkClientId; - userAddress: string; - } = { userAddress: this.config.selectedAddress }, - ) { + async detectNfts(options?: { + networkClientId?: NetworkClientId; + userAddress?: string; + }) { + const userAddress = + options?.userAddress ?? + this.messagingSystem.call('PreferencesController:getState') + .selectedAddress; /* istanbul ignore if */ - if (!this.isMainnet() || this.disabled) { + if (!this.isMainnet() || this.#disabled) { return; } /* istanbul ignore else */ @@ -590,7 +553,7 @@ export class NftDetectionController extends StaticIntervalPollingControllerV1< return; } - const apiNfts = await this.getOwnerNfts(userAddress); + const apiNfts = await this.#getOwnerNfts(userAddress); const addNftPromises = apiNfts.map(async (nft) => { const { tokenId: token_id, @@ -611,8 +574,8 @@ export class NftDetectionController extends StaticIntervalPollingControllerV1< let ignored; /* istanbul ignore else */ - const { ignoredNfts } = this.getNftState(); - if (ignoredNfts.length) { + const { ignoredNfts } = this.#getNftState(); + if (ignoredNfts.length > 0) { ignored = ignoredNfts.find((c) => { /* istanbul ignore next */ return ( @@ -641,11 +604,11 @@ export class NftDetectionController extends StaticIntervalPollingControllerV1< collection && { collection }, ); - await this.addNft(contract, token_id, { + await this.#addNft(contract, token_id, { nftMetadata, userAddress, source: Source.Detected, - networkClientId, + networkClientId: options?.networkClientId, }); } }); diff --git a/packages/controller-utils/src/constants.ts b/packages/controller-utils/src/constants.ts index 915b6fad59..1c4ae8d499 100644 --- a/packages/controller-utils/src/constants.ts +++ b/packages/controller-utils/src/constants.ts @@ -110,6 +110,10 @@ export const OPENSEA_PROXY_URL = export const NFT_API_BASE_URL = 'https://nft.api.cx.metamask.io'; +export const NFT_API_VERSION = '1'; + +export const NFT_API_TIMEOUT = 15000; + // Default origin for controllers export const ORIGIN_METAMASK = 'metamask';