From 4149d47439fb88ba0ececb965926cb902f4133dc Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Fri, 7 Jun 2024 22:44:23 +0800 Subject: [PATCH] refactor: update Nft Controllers to use selectedAccountId instead of selectedAddress (#4221) ## Explanation This PR updates removes `selectedAddress` and uses the controller messenger to get InternalAccounts in the Nft Controllers ## References Fixes https://github.com/MetaMask/accounts-planning/issues/381 ## Changelog ### `@metamask/assets-controllers` - **BREAKING**: `NftController` constructor argument `selectedAddress` has been removed. - **BREAKING**: `NftController` now requires `AccountsControlelr:get{Account,SelectedAccount}` messenger actions. - **BREAKING**: `NftController` now requires `AccountsController:selectedEvmAccountChange` event. - **BREAKING**: `NftDetectionController` now requires `AccountsControlelr:getSelectedAccount` messenger actions. ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've highlighted breaking changes using the "BREAKING" category above as appropriate --- .../src/NftController.test.ts | 1059 +++++++++++------ .../assets-controllers/src/NftController.ts | 228 ++-- .../src/NftDetectionController.test.ts | 270 +++-- .../src/NftDetectionController.ts | 8 +- 4 files changed, 1015 insertions(+), 550 deletions(-) diff --git a/packages/assets-controllers/src/NftController.test.ts b/packages/assets-controllers/src/NftController.test.ts index e2bba3891a..6e23d74f1a 100644 --- a/packages/assets-controllers/src/NftController.test.ts +++ b/packages/assets-controllers/src/NftController.test.ts @@ -1,5 +1,15 @@ import type { Network } from '@ethersproject/providers'; -import type { ApprovalControllerMessenger } from '@metamask/approval-controller'; +import type { + AccountsControllerGetAccountAction, + AccountsControllerGetSelectedAccountAction, + AccountsControllerSelectedAccountChangeEvent, + AccountsControllerSelectedEvmAccountChangeEvent, +} from '@metamask/accounts-controller'; +import type { + AddApprovalRequest, + ApprovalStateChange, + ApprovalControllerMessenger, +} from '@metamask/approval-controller'; import { ApprovalController } from '@metamask/approval-controller'; import { ControllerMessenger } from '@metamask/base-controller'; import { @@ -15,11 +25,15 @@ import { NFT_API_BASE_URL, InfuraNetworkType, } from '@metamask/controller-utils'; +import type { InternalAccount } from '@metamask/keyring-api'; import type { NetworkClientConfiguration, NetworkClientId, + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerNetworkDidChangeEvent, } from '@metamask/network-controller'; import { defaultState as defaultNetworkState } from '@metamask/network-controller'; +import type { PreferencesControllerStateChangeEvent } from '@metamask/preferences-controller'; import { getDefaultPreferencesState, type PreferencesState, @@ -29,6 +43,7 @@ import nock from 'nock'; import * as sinon from 'sinon'; import { v4 } from 'uuid'; +import { createMockInternalAccount } from '../../accounts-controller/src/tests/mocks'; import type { ExtractAvailableAction, ExtractAvailableEvent, @@ -62,6 +77,11 @@ const ERC721_DEPRESSIONIST_ADDRESS = '0x18E8E76aeB9E2d9FA2A2b88DD9CF3C8ED45c3660'; const ERC721_DEPRESSIONIST_ID = '36'; const OWNER_ADDRESS = '0x5a3CA5cD63807Ce5e4d7841AB32Ce6B6d9BbBa2D'; +const OWNER_ID = '54d1e7bc-1dce-4220-a15f-2f454bae7869'; +const OWNER_ACCOUNT = createMockInternalAccount({ + id: OWNER_ID, + address: OWNER_ADDRESS, +}); const SECOND_OWNER_ADDRESS = '0x500017171kasdfbou081'; const DEPRESSIONIST_CID_V1 = @@ -84,6 +104,17 @@ const GOERLI = { ticker: NetworksTicker.goerli, }; +type ApprovalActions = + | AddApprovalRequest + | AccountsControllerGetAccountAction + | AccountsControllerGetSelectedAccountAction + | NetworkControllerGetNetworkClientByIdAction; +type ApprovalEvents = + | ApprovalStateChange + | PreferencesControllerStateChangeEvent + | NetworkControllerNetworkDidChangeEvent + | AccountsControllerSelectedEvmAccountChangeEvent; + const controllerName = 'NftController' as const; // Mock out detectNetwork function for cleaner tests, Ethers calls this a bunch of times because the Web3Provider is paranoid. @@ -120,17 +151,20 @@ jest.mock('uuid', () => { * @param args.mockNetworkClientConfigurationsByNetworkClientId - Used to construct * mock versions of network clients and ultimately mock the * `NetworkController:getNetworkClientById` action. + * @param args.defaultSelectedAccount - The default selected account to use in * @returns A collection of test controllers and mocks. */ function setupController({ options = {}, mockNetworkClientConfigurationsByNetworkClientId = {}, + defaultSelectedAccount = OWNER_ACCOUNT, }: { options?: Partial[0]>; mockNetworkClientConfigurationsByNetworkClientId?: Record< NetworkClientId, NetworkClientConfiguration >; + defaultSelectedAccount?: InternalAccount; } = {}) { const messenger = new ControllerMessenger< | ExtractAvailableAction @@ -139,6 +173,7 @@ function setupController({ | ExtractAvailableEvent | AllowedEvents | ExtractAvailableEvent + | AccountsControllerSelectedAccountChangeEvent >(); const getNetworkClientById = buildMockGetNetworkClientById( @@ -149,6 +184,24 @@ function setupController({ getNetworkClientById, ); + const mockGetAccount = jest + .fn() + .mockReturnValue(defaultSelectedAccount ?? OWNER_ACCOUNT); + + messenger.registerActionHandler( + 'AccountsController:getAccount', + mockGetAccount, + ); + + const mockGetSelectedAccount = jest + .fn() + .mockReturnValue(defaultSelectedAccount ?? OWNER_ACCOUNT); + + messenger.registerActionHandler( + 'AccountsController:getSelectedAccount', + mockGetSelectedAccount, + ); + const approvalControllerMessenger = messenger.getRestricted({ name: 'ApprovalController', allowedActions: [], @@ -160,15 +213,29 @@ function setupController({ showApprovalRequest: jest.fn(), }); - const nftControllerMessenger = messenger.getRestricted({ + const nftControllerMessenger = messenger.getRestricted< + typeof controllerName, + ApprovalActions['type'], + Extract< + ApprovalEvents, + | PreferencesControllerStateChangeEvent + | AccountsControllerSelectedEvmAccountChangeEvent + | NetworkControllerNetworkDidChangeEvent + >['type'] + >({ name: controllerName, allowedActions: [ 'ApprovalController:addRequest', + 'AccountsController:getSelectedAccount', + 'AccountsController:getAccount', 'NetworkController:getNetworkClientById', ], allowedEvents: [ - 'NetworkController:networkDidChange', + // @ts-expect-error - Adding this for test + 'AccountsController:selectedAccountChange', + 'AccountsController:selectedEvmAccountChange', 'PreferencesController:stateChange', + 'NetworkController:networkDidChange', ], }); @@ -203,15 +270,28 @@ function setupController({ triggerPreferencesStateChange({ ...getDefaultPreferencesState(), openSeaEnabled: true, - selectedAddress: OWNER_ADDRESS, }); + const triggerSelectedAccountChange = ( + internalAccount: InternalAccount, + ): void => { + messenger.publish( + 'AccountsController:selectedEvmAccountChange', + internalAccount, + ); + }; + + triggerSelectedAccountChange(OWNER_ACCOUNT); + return { nftController, messenger, approvalController, changeNetwork, triggerPreferencesStateChange, + triggerSelectedAccountChange, + mockGetAccount, + mockGetSelectedAccount, }; } @@ -402,12 +482,17 @@ describe('NftController', () => { }, }); - const callActionSpy = jest.spyOn(messenger, 'call').mockResolvedValue({}); + const callActionSpy = jest.spyOn(messenger, 'call'); await expect(() => nftController.watchNft(ERC721_NFT, ERC721, 'https://test-dapp.com'), ).rejects.toThrow('Suggested NFT is not owned by the selected account'); - expect(callActionSpy).toHaveBeenCalledTimes(0); + // First call is getInternalAccount. Second call is the approval request. + expect(callActionSpy).not.toHaveBeenNthCalledWith( + 2, + 'ApprovalController:addRequest', + expect.any(Object), + ); }); it('should error if the call to isNftOwner fail', async function () { @@ -432,12 +517,13 @@ describe('NftController', () => { }, }); - const callActionSpy = jest.spyOn(messenger, 'call').mockResolvedValue({}); + const callActionSpy = jest.spyOn(messenger, 'call'); await expect(() => nftController.watchNft(ERC1155_NFT, ERC1155, 'https://test-dapp.com'), ).rejects.toThrow('Suggested NFT is not owned by the selected account'); - expect(callActionSpy).toHaveBeenCalledTimes(0); + // First call is to get InternalAccount + expect(callActionSpy).toHaveBeenCalledTimes(1); }); it('should handle ERC721 type and add pending request to ApprovalController with the OpenSea API disabled and IPFS gateway enabled', async function () { @@ -451,20 +537,24 @@ describe('NftController', () => { description: 'testERC721Description', }), ); - const { nftController, messenger, triggerPreferencesStateChange } = - setupController({ - options: { - getERC721TokenURI: jest - .fn() - .mockImplementation(() => 'https://testtokenuri.com'), - getERC721OwnerOf: jest.fn().mockImplementation(() => OWNER_ADDRESS), - }, - }); + const { + nftController, + messenger, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + } = setupController({ + options: { + getERC721TokenURI: jest + .fn() + .mockImplementation(() => 'https://testtokenuri.com'), + getERC721OwnerOf: jest.fn().mockImplementation(() => OWNER_ADDRESS), + }, + }); + triggerSelectedAccountChange(OWNER_ACCOUNT); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), isIpfsGatewayEnabled: true, openSeaEnabled: false, - selectedAddress: OWNER_ADDRESS, }); const requestId = 'approval-request-id-1'; @@ -473,11 +563,17 @@ describe('NftController', () => { (v4 as jest.Mock).mockImplementationOnce(() => requestId); - const callActionSpy = jest.spyOn(messenger, 'call').mockResolvedValue({}); + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockReturnValueOnce(OWNER_ACCOUNT) + .mockResolvedValueOnce({}) + .mockReturnValueOnce(OWNER_ACCOUNT); await nftController.watchNft(ERC721_NFT, ERC721, 'https://test-dapp.com'); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( + // First call is getInternalAccount. Second call is the approval request. + expect(callActionSpy).toHaveBeenCalledTimes(3); + expect(callActionSpy).toHaveBeenNthCalledWith( + 2, 'ApprovalController:addRequest', { id: requestId, @@ -512,20 +608,24 @@ describe('NftController', () => { description: 'testERC721Description', }), ); - const { nftController, messenger, triggerPreferencesStateChange } = - setupController({ - options: { - getERC721TokenURI: jest - .fn() - .mockImplementation(() => 'https://testtokenuri.com'), - getERC721OwnerOf: jest.fn().mockImplementation(() => OWNER_ADDRESS), - }, - }); + const { + nftController, + messenger, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + } = setupController({ + options: { + getERC721TokenURI: jest + .fn() + .mockImplementation(() => 'https://testtokenuri.com'), + getERC721OwnerOf: jest.fn().mockImplementation(() => OWNER_ADDRESS), + }, + }); + triggerSelectedAccountChange(OWNER_ACCOUNT); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), isIpfsGatewayEnabled: true, openSeaEnabled: true, - selectedAddress: OWNER_ADDRESS, }); const requestId = 'approval-request-id-1'; @@ -534,11 +634,17 @@ describe('NftController', () => { (v4 as jest.Mock).mockImplementationOnce(() => requestId); - const callActionSpy = jest.spyOn(messenger, 'call').mockResolvedValue({}); + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockReturnValueOnce(OWNER_ACCOUNT) + .mockResolvedValueOnce({}) + .mockReturnValueOnce(OWNER_ACCOUNT); await nftController.watchNft(ERC721_NFT, ERC721, 'https://test-dapp.com'); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( + // First call is getInternalAccount. Second call is the approval request. + expect(callActionSpy).toHaveBeenCalledTimes(3); + expect(callActionSpy).toHaveBeenNthCalledWith( + 2, 'ApprovalController:addRequest', { id: requestId, @@ -573,20 +679,24 @@ describe('NftController', () => { description: 'testERC721Description', }), ); - const { nftController, messenger, triggerPreferencesStateChange } = - setupController({ - options: { - getERC721TokenURI: jest - .fn() - .mockImplementation(() => 'ipfs://testtokenuri.com'), - getERC721OwnerOf: jest.fn().mockImplementation(() => OWNER_ADDRESS), - }, - }); + const { + nftController, + messenger, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + } = setupController({ + options: { + getERC721TokenURI: jest + .fn() + .mockImplementation(() => 'ipfs://testtokenuri.com'), + getERC721OwnerOf: jest.fn().mockImplementation(() => OWNER_ADDRESS), + }, + }); + triggerSelectedAccountChange(OWNER_ACCOUNT); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), isIpfsGatewayEnabled: false, openSeaEnabled: false, - selectedAddress: OWNER_ADDRESS, }); const requestId = 'approval-request-id-1'; @@ -595,11 +705,17 @@ describe('NftController', () => { (v4 as jest.Mock).mockImplementationOnce(() => requestId); - const callActionSpy = jest.spyOn(messenger, 'call').mockResolvedValue({}); + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockReturnValueOnce(OWNER_ACCOUNT) + .mockResolvedValueOnce({}) + .mockReturnValueOnce(OWNER_ACCOUNT); await nftController.watchNft(ERC721_NFT, ERC721, 'https://test-dapp.com'); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( + // First call is getInternalAccount. Second call is the approval request. + expect(callActionSpy).toHaveBeenCalledTimes(3); + expect(callActionSpy).toHaveBeenNthCalledWith( + 2, 'ApprovalController:addRequest', { id: requestId, @@ -634,20 +750,25 @@ describe('NftController', () => { description: 'testERC721Description', }), ); - const { nftController, messenger, triggerPreferencesStateChange } = - setupController({ - options: { - getERC721TokenURI: jest - .fn() - .mockImplementation(() => 'ipfs://testtokenuri.com'), - getERC721OwnerOf: jest.fn().mockImplementation(() => OWNER_ADDRESS), - }, - }); + const { + nftController, + messenger, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + } = setupController({ + options: { + getERC721TokenURI: jest + .fn() + .mockImplementation(() => 'ipfs://testtokenuri.com'), + getERC721OwnerOf: jest.fn().mockImplementation(() => OWNER_ADDRESS), + }, + }); + + triggerSelectedAccountChange(OWNER_ACCOUNT); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), isIpfsGatewayEnabled: false, openSeaEnabled: true, - selectedAddress: OWNER_ADDRESS, }); const requestId = 'approval-request-id-1'; @@ -656,11 +777,17 @@ describe('NftController', () => { (v4 as jest.Mock).mockImplementationOnce(() => requestId); - const callActionSpy = jest.spyOn(messenger, 'call').mockResolvedValue({}); + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockReturnValueOnce(OWNER_ACCOUNT) + .mockResolvedValueOnce({}) + .mockReturnValueOnce(OWNER_ACCOUNT); await nftController.watchNft(ERC721_NFT, ERC721, 'https://test-dapp.com'); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( + // First call is getInternalAccount. Second call is the approval request. + expect(callActionSpy).toHaveBeenCalledTimes(3); + expect(callActionSpy).toHaveBeenNthCalledWith( + 2, 'ApprovalController:addRequest', { id: requestId, @@ -696,23 +823,28 @@ describe('NftController', () => { }), ); - const { nftController, messenger, triggerPreferencesStateChange } = - setupController({ - options: { - getERC721TokenURI: jest - .fn() - .mockRejectedValue(new Error('Not an ERC721 contract')), - getERC1155TokenURI: jest - .fn() - .mockImplementation(() => 'https://testtokenuri.com'), - getERC1155BalanceOf: jest.fn().mockImplementation(() => new BN(1)), - }, - }); + const { + nftController, + messenger, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + } = setupController({ + options: { + getERC721TokenURI: jest + .fn() + .mockRejectedValue(new Error('Not an ERC721 contract')), + getERC1155TokenURI: jest + .fn() + .mockImplementation(() => 'https://testtokenuri.com'), + getERC1155BalanceOf: jest.fn().mockImplementation(() => new BN(1)), + }, + }); + + triggerSelectedAccountChange(OWNER_ACCOUNT); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), isIpfsGatewayEnabled: true, openSeaEnabled: false, - selectedAddress: OWNER_ADDRESS, }); const requestId = 'approval-request-id-1'; @@ -720,15 +852,21 @@ describe('NftController', () => { (v4 as jest.Mock).mockImplementationOnce(() => requestId); - const callActionSpy = jest.spyOn(messenger, 'call').mockResolvedValue({}); + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockReturnValueOnce(OWNER_ACCOUNT) + .mockResolvedValueOnce({}) + .mockReturnValueOnce(OWNER_ACCOUNT); await nftController.watchNft( ERC1155_NFT, ERC1155, 'https://etherscan.io', ); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( + // First call is getInternalAccount. Second call is the approval request. + expect(callActionSpy).toHaveBeenCalledTimes(3); + expect(callActionSpy).toHaveBeenNthCalledWith( + 2, 'ApprovalController:addRequest', { id: requestId, @@ -780,7 +918,6 @@ describe('NftController', () => { ...getDefaultPreferencesState(), isIpfsGatewayEnabled: true, openSeaEnabled: true, - selectedAddress: OWNER_ADDRESS, }); const requestId = 'approval-request-id-1'; @@ -788,15 +925,21 @@ describe('NftController', () => { (v4 as jest.Mock).mockImplementationOnce(() => requestId); - const callActionSpy = jest.spyOn(messenger, 'call').mockResolvedValue({}); + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockReturnValueOnce(OWNER_ACCOUNT) + .mockResolvedValueOnce({}) + .mockReturnValue(OWNER_ACCOUNT); await nftController.watchNft( ERC1155_NFT, ERC1155, 'https://etherscan.io', ); - expect(callActionSpy).toHaveBeenCalledTimes(1); - expect(callActionSpy).toHaveBeenCalledWith( + // First call is getInternalAccount. Second call is the approval request. + expect(callActionSpy).toHaveBeenCalledTimes(3); + expect(callActionSpy).toHaveBeenNthCalledWith( + 2, 'ApprovalController:addRequest', { id: requestId, @@ -838,6 +981,7 @@ describe('NftController', () => { approvalController, changeNetwork, triggerPreferencesStateChange, + triggerSelectedAccountChange, } = setupController({ options: { getERC721OwnerOf: jest @@ -882,10 +1026,10 @@ describe('NftController', () => { expect(nftController.state.allNfts).toStrictEqual({}); // this is our account and network status when the watchNFT request is made + triggerSelectedAccountChange(OWNER_ACCOUNT); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), openSeaEnabled: true, - selectedAddress: OWNER_ADDRESS, }); changeNetwork({ selectedNetworkClientId: InfuraNetworkType.goerli }); @@ -938,6 +1082,7 @@ describe('NftController', () => { messenger, approvalController, triggerPreferencesStateChange, + triggerSelectedAccountChange, changeNetwork, } = setupController({ options: { @@ -981,6 +1126,7 @@ describe('NftController', () => { expect(nftController.state.allNfts).toStrictEqual({}); // this is our account and network status when the watchNFT request is made + triggerSelectedAccountChange(OWNER_ACCOUNT); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), openSeaEnabled: true, @@ -994,10 +1140,13 @@ describe('NftController', () => { await pendingRequest; // change the network and selectedAddress before accepting the request + const differentAccount = createMockInternalAccount({ + address: '0xfa2d29eb2dbd1fc5ed7e781aa0549a7b3e032f1d', + }); + triggerSelectedAccountChange(differentAccount); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), openSeaEnabled: true, - selectedAddress: '0xDifferentAddress', }); changeNetwork({ selectedNetworkClientId: InfuraNetworkType.sepolia }); // now accept the request @@ -1052,11 +1201,9 @@ describe('NftController', () => { describe('addNft', () => { it('should add NFT and NFT contract', async () => { - const selectedAddress = OWNER_ADDRESS; const { nftController } = setupController({ options: { chainId: ChainId.mainnet, - selectedAddress, getERC721AssetName: jest.fn().mockResolvedValue('Name'), }, }); @@ -1076,7 +1223,7 @@ describe('NftController', () => { }); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], ).toStrictEqual({ address: '0x01', description: 'description', @@ -1093,7 +1240,7 @@ describe('NftController', () => { }); expect( - nftController.state.allNftContracts[selectedAddress][ + nftController.state.allNftContracts[OWNER_ACCOUNT.address][ ChainId.mainnet ][0], ).toStrictEqual({ @@ -1165,15 +1312,26 @@ describe('NftController', () => { const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); const mockGetERC1155TokenURI = jest.fn().mockRejectedValue(''); - const { nftController, triggerPreferencesStateChange } = setupController({ + const { + nftController, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + mockGetAccount, + } = setupController({ options: { getERC721TokenURI: mockGetERC721TokenURI, getERC1155TokenURI: mockGetERC1155TokenURI, }, }); const firstAddress = '0x123'; + const firstAccount = createMockInternalAccount({ address: firstAddress }); const secondAddress = '0x321'; + const secondAccount = createMockInternalAccount({ + address: secondAddress, + }); + mockGetAccount.mockReturnValue(firstAccount); + triggerSelectedAccountChange(firstAccount); nock('https://url').get('/').reply(200, { name: 'name', image: 'url', @@ -1182,19 +1340,20 @@ describe('NftController', () => { triggerPreferencesStateChange({ ...getDefaultPreferencesState(), openSeaEnabled: true, - selectedAddress: firstAddress, }); await nftController.addNft('0x01', '1234'); + mockGetAccount.mockReturnValue(secondAccount); + triggerSelectedAccountChange(secondAccount); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), openSeaEnabled: true, - selectedAddress: secondAddress, }); await nftController.addNft('0x02', '4321'); + mockGetAccount.mockReturnValue(firstAccount); + triggerSelectedAccountChange(firstAccount); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), openSeaEnabled: true, - selectedAddress: firstAddress, }); expect( nftController.state.allNfts[firstAddress][ChainId.mainnet][0], @@ -1212,11 +1371,9 @@ describe('NftController', () => { }); it('should update NFT if image is different', async () => { - const selectedAddress = OWNER_ADDRESS; const { nftController } = setupController({ - options: { - selectedAddress, - }, + options: {}, + defaultSelectedAccount: OWNER_ACCOUNT, }); await nftController.addNft('0x01', '1', { @@ -1230,7 +1387,7 @@ describe('NftController', () => { }); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], ).toStrictEqual({ address: '0x01', description: 'description', @@ -1253,7 +1410,7 @@ describe('NftController', () => { }); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], ).toStrictEqual({ address: '0x01', description: 'description', @@ -1267,11 +1424,9 @@ describe('NftController', () => { }); it('should not duplicate NFT nor NFT contract if already added', async () => { - const selectedAddress = OWNER_ADDRESS; const { nftController } = setupController({ - options: { - selectedAddress, - }, + options: {}, + defaultSelectedAccount: OWNER_ACCOUNT, }); await nftController.addNft('0x01', '1', { @@ -1295,19 +1450,19 @@ describe('NftController', () => { }); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], ).toHaveLength(1); expect( - nftController.state.allNftContracts[selectedAddress][ChainId.mainnet], + nftController.state.allNftContracts[OWNER_ACCOUNT.address][ + ChainId.mainnet + ], ).toHaveLength(1); }); it('should add NFT and get information from NFT-API', async () => { - const selectedAddress = OWNER_ADDRESS; const { nftController } = setupController({ options: { - selectedAddress, getERC721TokenURI: jest .fn() .mockRejectedValue(new Error('Not an ERC721 contract')), @@ -1315,11 +1470,12 @@ describe('NftController', () => { .fn() .mockRejectedValue(new Error('Not an ERC1155 contract')), }, + defaultSelectedAccount: OWNER_ACCOUNT, }); await nftController.addNft('0x01', '1'); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], ).toStrictEqual({ address: '0x01', description: 'Description', @@ -1336,10 +1492,8 @@ describe('NftController', () => { }); it('should add NFT erc721 and aggregate NFT data from both contract and NFT-API', async () => { - const selectedAddress = OWNER_ADDRESS; const { nftController } = setupController({ options: { - selectedAddress, getERC721AssetName: jest.fn().mockResolvedValue('KudosToken'), getERC721AssetSymbol: jest.fn().mockResolvedValue('KDO'), getERC721TokenURI: jest @@ -1348,6 +1502,7 @@ describe('NftController', () => { 'https://ipfs.gitcoin.co:443/api/v0/cat/QmPmt6EAaioN78ECnW5oCL8v2YvVSpoBjLCjrXhhsAvoov', ), }, + defaultSelectedAccount: OWNER_ACCOUNT, }); nock(NFT_API_BASE_URL) .get( @@ -1377,7 +1532,7 @@ describe('NftController', () => { await nftController.addNft(ERC721_KUDOSADDRESS, ERC721_KUDOS_TOKEN_ID); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], ).toStrictEqual({ address: ERC721_KUDOSADDRESS, image: 'url', @@ -1392,7 +1547,7 @@ describe('NftController', () => { }); expect( - nftController.state.allNftContracts[selectedAddress][ + nftController.state.allNftContracts[OWNER_ACCOUNT.address][ ChainId.mainnet ][0], ).toStrictEqual({ @@ -1404,10 +1559,8 @@ describe('NftController', () => { }); it('should add NFT erc1155 and get NFT information from contract when NFT API call fail', async () => { - const selectedAddress = OWNER_ADDRESS; const { nftController } = setupController({ options: { - selectedAddress, getERC721TokenURI: jest .fn() .mockRejectedValue(new Error('Not a 721 contract')), @@ -1417,6 +1570,7 @@ describe('NftController', () => { 'https://api.opensea.io/api/v1/metadata/0x495f947276749Ce646f68AC8c248420045cb7b5e/0x{id}', ), }, + defaultSelectedAccount: OWNER_ACCOUNT, }); nock('https://api.opensea.io') .get( @@ -1433,7 +1587,7 @@ describe('NftController', () => { await nftController.addNft(ERC1155_NFT_ADDRESS, ERC1155_NFT_ID); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], ).toStrictEqual({ address: ERC1155_NFT_ADDRESS, image: 'image (directly from tokenURI)', @@ -1449,10 +1603,8 @@ describe('NftController', () => { }); it('should add NFT erc721 and get NFT information only from contract', async () => { - const selectedAddress = OWNER_ADDRESS; const { nftController } = setupController({ options: { - selectedAddress, getERC721AssetName: jest.fn().mockResolvedValue('KudosToken'), getERC721AssetSymbol: jest.fn().mockResolvedValue('KDO'), getERC721TokenURI: jest.fn().mockImplementation((tokenAddress) => { @@ -1464,6 +1616,7 @@ describe('NftController', () => { } }), }, + defaultSelectedAccount: OWNER_ACCOUNT, }); nock('https://ipfs.gitcoin.co:443') .get('/api/v0/cat/QmPmt6EAaioN78ECnW5oCL8v2YvVSpoBjLCjrXhhsAvoov') @@ -1482,7 +1635,7 @@ describe('NftController', () => { await nftController.addNft(ERC721_KUDOSADDRESS, ERC721_KUDOS_TOKEN_ID); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], ).toStrictEqual({ address: ERC721_KUDOSADDRESS, image: 'Kudos Image (directly from tokenURI)', @@ -1497,7 +1650,7 @@ describe('NftController', () => { }); expect( - nftController.state.allNftContracts[selectedAddress][ + nftController.state.allNftContracts[OWNER_ACCOUNT.address][ ChainId.mainnet ][0], ).toStrictEqual({ @@ -1509,14 +1662,13 @@ describe('NftController', () => { }); it('should add NFT by provider type', async () => { - const selectedAddress = OWNER_ADDRESS; const tokenURI = 'https://url/'; const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); const { nftController, changeNetwork } = setupController({ options: { - selectedAddress, getERC721TokenURI: mockGetERC721TokenURI, }, + defaultSelectedAccount: OWNER_ACCOUNT, }); nock('https://url').get('/').reply(200, { name: 'name', @@ -1530,11 +1682,15 @@ describe('NftController', () => { changeNetwork({ selectedNetworkClientId: InfuraNetworkType.sepolia }); expect( - nftController.state.allNfts[selectedAddress]?.[ChainId[GOERLI.type]], + nftController.state.allNfts[OWNER_ACCOUNT.address]?.[ + ChainId[GOERLI.type] + ], ).toBeUndefined(); expect( - nftController.state.allNfts[selectedAddress][ChainId[SEPOLIA.type]][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ + ChainId[SEPOLIA.type] + ][0], ).toStrictEqual({ address: '0x01', description: 'description', @@ -1554,15 +1710,14 @@ describe('NftController', () => { const mockGetERC721AssetSymbol = jest.fn().mockResolvedValue(''); const mockGetERC721AssetName = jest.fn().mockResolvedValue(''); const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); - const selectedAddress = OWNER_ADDRESS; const { nftController } = setupController({ options: { - selectedAddress, onNftAdded: mockOnNftAdded, getERC721AssetSymbol: mockGetERC721AssetSymbol, getERC721AssetName: mockGetERC721AssetName, getERC721TokenURI: mockGetERC721TokenURI, }, + defaultSelectedAccount: OWNER_ACCOUNT, }); nock('https://url').get('/').reply(200, { @@ -1574,7 +1729,7 @@ describe('NftController', () => { await nftController.addNft('0x01234abcdefg', '1234'); expect(nftController.state.allNftContracts).toStrictEqual({ - [selectedAddress]: { + [OWNER_ACCOUNT.address]: { [ChainId.mainnet]: [ { address: '0x01234abcdefg', @@ -1585,7 +1740,7 @@ describe('NftController', () => { }); expect(nftController.state.allNfts).toStrictEqual({ - [selectedAddress]: { + [OWNER_ACCOUNT.address]: { [ChainId.mainnet]: [ { address: '0x01234abcdefg', @@ -1676,11 +1831,9 @@ describe('NftController', () => { }); it('should add an nft and nftContract when there is valid contract information and source is "detected"', async () => { - const selectedAddress = OWNER_ADDRESS; const mockOnNftAdded = jest.fn(); const { nftController } = setupController({ options: { - selectedAddress, onNftAdded: mockOnNftAdded, getERC721AssetName: jest .fn() @@ -1689,6 +1842,7 @@ describe('NftController', () => { .fn() .mockRejectedValue(new Error('Failed to fetch')), }, + defaultSelectedAccount: OWNER_ACCOUNT, }); nock(NFT_API_BASE_URL) .get( @@ -1716,26 +1870,28 @@ describe('NftController', () => { '0x6EbeAf8e8E946F0716E6533A6f2cefc83f60e8Ab', '123', { - userAddress: selectedAddress, + userAddress: OWNER_ACCOUNT.address, source: Source.Detected, }, ); expect( - nftController.state.allNfts[selectedAddress]?.[ChainId.mainnet], + nftController.state.allNfts[OWNER_ACCOUNT.address]?.[ChainId.mainnet], ).toBeUndefined(); expect( - nftController.state.allNftContracts[selectedAddress]?.[ChainId.mainnet], + nftController.state.allNftContracts[OWNER_ACCOUNT.address]?.[ + ChainId.mainnet + ], ).toBeUndefined(); await nftController.addNft(ERC721_KUDOSADDRESS, ERC721_KUDOS_TOKEN_ID, { - userAddress: selectedAddress, + userAddress: OWNER_ACCOUNT.address, source: Source.Detected, }); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], ).toStrictEqual([ { address: ERC721_KUDOSADDRESS, @@ -1756,7 +1912,9 @@ describe('NftController', () => { ]); expect( - nftController.state.allNftContracts[selectedAddress][ChainId.mainnet], + nftController.state.allNftContracts[OWNER_ACCOUNT.address][ + ChainId.mainnet + ], ).toStrictEqual([ { address: ERC721_KUDOSADDRESS, @@ -1776,11 +1934,9 @@ describe('NftController', () => { }); it('should not add an nft and nftContract when there is not valid contract information (or an issue fetching it) and source is "detected"', async () => { - const selectedAddress = OWNER_ADDRESS; const mockOnNftAdded = jest.fn(); const { nftController } = setupController({ options: { - selectedAddress, onNftAdded: mockOnNftAdded, getERC721AssetName: jest .fn() @@ -1789,6 +1945,7 @@ describe('NftController', () => { .fn() .mockRejectedValue(new Error('Failed to fetch')), }, + defaultSelectedAccount: OWNER_ACCOUNT, }); nock(NFT_API_BASE_URL) .get( @@ -1799,12 +1956,12 @@ describe('NftController', () => { '0x6EbeAf8e8E946F0716E6533A6f2cefc83f60e8Ab', '123', { - userAddress: selectedAddress, + userAddress: OWNER_ACCOUNT.address, source: Source.Detected, }, ); await nftController.addNft(ERC721_KUDOSADDRESS, ERC721_KUDOS_TOKEN_ID, { - userAddress: selectedAddress, + userAddress: OWNER_ACCOUNT.address, source: Source.Detected, }); @@ -1814,11 +1971,9 @@ describe('NftController', () => { }); it('should not add duplicate NFTs to the ignoredNfts list', async () => { - const selectedAddress = OWNER_ADDRESS; const { nftController } = setupController({ - options: { - selectedAddress, - }, + options: {}, + defaultSelectedAccount: OWNER_ACCOUNT, }); await nftController.addNft('0x01', '1', { @@ -1840,13 +1995,13 @@ describe('NftController', () => { }); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], ).toHaveLength(2); expect(nftController.state.ignoredNfts).toHaveLength(0); nftController.removeAndIgnoreNft('0x01', '1'); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], ).toHaveLength(1); expect(nftController.state.ignoredNfts).toHaveLength(1); @@ -1860,41 +2015,41 @@ describe('NftController', () => { }); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], ).toHaveLength(2); expect(nftController.state.ignoredNfts).toHaveLength(1); nftController.removeAndIgnoreNft('0x01', '1'); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], ).toHaveLength(1); expect(nftController.state.ignoredNfts).toHaveLength(1); }); it('should add NFT with metadata hosted in IPFS', async () => { - const selectedAddress = OWNER_ADDRESS; - const { nftController, triggerPreferencesStateChange } = setupController({ - options: { - getERC721AssetName: jest - .fn() - .mockResolvedValue("Maltjik.jpg's Depressionists"), - getERC721AssetSymbol: jest.fn().mockResolvedValue('DPNS'), - getERC721TokenURI: jest.fn().mockImplementation((tokenAddress) => { - switch (tokenAddress) { - case ERC721_DEPRESSIONIST_ADDRESS: - return `ipfs://${DEPRESSIONIST_CID_V1}`; - default: - throw new Error('Not an ERC721 token'); - } - }), - getERC1155TokenURI: jest - .fn() - .mockRejectedValue(new Error('Not an ERC1155 token')), - }, - }); + const { nftController, triggerPreferencesStateChange, mockGetAccount } = + setupController({ + options: { + getERC721AssetName: jest + .fn() + .mockResolvedValue("Maltjik.jpg's Depressionists"), + getERC721AssetSymbol: jest.fn().mockResolvedValue('DPNS'), + getERC721TokenURI: jest.fn().mockImplementation((tokenAddress) => { + switch (tokenAddress) { + case ERC721_DEPRESSIONIST_ADDRESS: + return `ipfs://${DEPRESSIONIST_CID_V1}`; + default: + throw new Error('Not an ERC721 token'); + } + }), + getERC1155TokenURI: jest + .fn() + .mockRejectedValue(new Error('Not an ERC1155 token')), + }, + }); + mockGetAccount.mockReturnValue(OWNER_ACCOUNT); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), - selectedAddress, ipfsGateway: IPFS_DEFAULT_GATEWAY_URL, }); @@ -1904,7 +2059,7 @@ describe('NftController', () => { ); expect( - nftController.state.allNftContracts[selectedAddress][ + nftController.state.allNftContracts[OWNER_ACCOUNT.address][ ChainId.mainnet ][0], ).toStrictEqual({ @@ -1914,7 +2069,7 @@ describe('NftController', () => { schemaName: ERC721, }); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], ).toStrictEqual({ address: ERC721_DEPRESSIONIST_ADDRESS, tokenId: '36', @@ -1930,7 +2085,6 @@ describe('NftController', () => { }); it('should add NFT erc721 when call to NFT API fail', async () => { - const selectedAddress = OWNER_ADDRESS; const { nftController } = setupController(); nock(NFT_API_BASE_URL) .get( @@ -1941,7 +2095,7 @@ describe('NftController', () => { await nftController.addNft(ERC721_NFT_ADDRESS, ERC721_NFT_ID); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], ).toStrictEqual({ address: ERC721_NFT_ADDRESS, image: null, @@ -2191,6 +2345,33 @@ describe('NftController', () => { }, ]); }); + + it('should handle unset selectedAccount', async () => { + const { nftController, mockGetAccount } = setupController({ + options: { + chainId: ChainId.mainnet, + getERC721AssetName: jest.fn().mockResolvedValue('Name'), + }, + }); + + mockGetAccount.mockReturnValue(null); + + await nftController.addNft('0x01', '1', { + nftMetadata: { + name: 'name', + image: 'image', + description: 'description', + standard: 'standard', + favorite: false, + collection: { + tokenCount: '0', + image: 'url', + }, + }, + }); + + expect(nftController.state.allNftContracts['']).toBeUndefined(); + }); }); describe('addNftVerifyOwnership', () => { @@ -2198,13 +2379,28 @@ describe('NftController', () => { const tokenURI = 'https://url/'; const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); - const { nftController, triggerPreferencesStateChange } = setupController({ + const { + nftController, + mockGetAccount, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + } = setupController({ options: { getERC721TokenURI: mockGetERC721TokenURI, }, }); const firstAddress = '0x123'; + const firstAccount = createMockInternalAccount({ + address: firstAddress, + id: '22c022b5-309c-45e4-a82d-64bb11fc0e74', + }); const secondAddress = '0x321'; + const secondAccount = createMockInternalAccount({ + address: secondAddress, + id: 'f9a42417-6071-4b51-8ecd-f7b14abd8851', + }); + mockGetAccount.mockReturnValue(firstAccount); + triggerSelectedAccountChange(firstAccount); jest.spyOn(nftController, 'isNftOwner').mockResolvedValue(true); nock('https://url').get('/').reply(200, { @@ -2215,22 +2411,23 @@ describe('NftController', () => { triggerPreferencesStateChange({ ...getDefaultPreferencesState(), openSeaEnabled: true, - selectedAddress: firstAddress, }); await nftController.addNftVerifyOwnership('0x01', '1234'); + mockGetAccount.mockReturnValue(secondAccount); + triggerSelectedAccountChange(secondAccount); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), openSeaEnabled: true, - selectedAddress: secondAddress, }); await nftController.addNftVerifyOwnership('0x02', '4321'); + mockGetAccount.mockReturnValue(firstAccount); + triggerSelectedAccountChange(firstAccount); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), openSeaEnabled: true, - selectedAddress: firstAddress, }); expect( - nftController.state.allNfts[firstAddress][ChainId.mainnet][0], + nftController.state.allNfts[firstAccount.address][ChainId.mainnet][0], ).toStrictEqual({ address: '0x01', description: 'description', @@ -2245,14 +2442,23 @@ describe('NftController', () => { }); it('should throw an error if selected address is not owner of input NFT', async () => { - const { nftController, triggerPreferencesStateChange } = - setupController(); + const { + nftController, + mockGetAccount, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + } = setupController(); jest.spyOn(nftController, 'isNftOwner').mockResolvedValue(false); const firstAddress = '0x123'; + const firstAccount = createMockInternalAccount({ + address: firstAddress, + id: '22c022b5-309c-45e4-a82d-64bb11fc0e74', + }); + mockGetAccount.mockReturnValue(firstAccount); + triggerSelectedAccountChange(firstAccount); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), openSeaEnabled: true, - selectedAddress: firstAddress, }); const result = async () => await nftController.addNftVerifyOwnership('0x01', '1234'); @@ -2263,14 +2469,27 @@ describe('NftController', () => { it('should verify ownership by selected address and add NFT by the correct chainId when passed networkClientId', async () => { const tokenURI = 'https://url/'; const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); - const { nftController, triggerPreferencesStateChange } = setupController({ + const { + nftController, + triggerPreferencesStateChange, + mockGetAccount, + triggerSelectedAccountChange, + } = setupController({ options: { getERC721TokenURI: mockGetERC721TokenURI, }, }); const firstAddress = '0x123'; + const firstAccount = createMockInternalAccount({ + address: firstAddress, + id: '22c022b5-309c-45e4-a82d-64bb11fc0e74', + }); const secondAddress = '0x321'; + const secondAccount = createMockInternalAccount({ + address: secondAddress, + id: 'f9a42417-6071-4b51-8ecd-f7b14abd8851', + }); jest.spyOn(nftController, 'isNftOwner').mockResolvedValue(true); @@ -2282,25 +2501,27 @@ describe('NftController', () => { description: 'description', }) .persist(); + mockGetAccount.mockReturnValue(firstAccount); + triggerSelectedAccountChange(firstAccount); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), openSeaEnabled: true, - selectedAddress: firstAddress, }); await nftController.addNftVerifyOwnership('0x01', '1234', { networkClientId: 'sepolia', }); + mockGetAccount.mockReturnValue(secondAccount); + triggerSelectedAccountChange(secondAccount); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), openSeaEnabled: true, - selectedAddress: secondAddress, }); await nftController.addNftVerifyOwnership('0x02', '4321', { networkClientId: 'goerli', }); expect( - nftController.state.allNfts[firstAddress][SEPOLIA.chainId][0], + nftController.state.allNfts[firstAccount.address][SEPOLIA.chainId][0], ).toStrictEqual({ address: '0x01', description: 'description', @@ -2313,7 +2534,7 @@ describe('NftController', () => { tokenURI, }); expect( - nftController.state.allNfts[secondAddress][GOERLI.chainId][0], + nftController.state.allNfts[secondAccount.address][GOERLI.chainId][0], ).toStrictEqual({ address: '0x02', description: 'description', @@ -2330,17 +2551,21 @@ describe('NftController', () => { it('should verify ownership by selected address and add NFT by the correct userAddress when passed userAddress', async () => { const tokenURI = 'https://url/'; const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); - const { nftController, changeNetwork, triggerPreferencesStateChange } = - setupController({ - options: { - getERC721TokenURI: mockGetERC721TokenURI, - }, - }); + const { + nftController, + changeNetwork, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + } = setupController({ + options: { + getERC721TokenURI: mockGetERC721TokenURI, + }, + }); // Ensure that the currently selected address is not the same as either of the userAddresses + triggerSelectedAccountChange(OWNER_ACCOUNT); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), openSeaEnabled: true, - selectedAddress: OWNER_ADDRESS, }); const firstAddress = '0x123'; @@ -2396,11 +2621,9 @@ describe('NftController', () => { describe('removeNft', () => { it('should remove NFT and NFT contract', async () => { - const selectedAddress = OWNER_ADDRESS; const { nftController } = setupController({ - options: { - selectedAddress, - }, + options: {}, + defaultSelectedAccount: OWNER_ACCOUNT, }); await nftController.addNft('0x01', '1', { @@ -2413,16 +2636,17 @@ describe('NftController', () => { }); nftController.removeNft('0x01', '1'); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], ).toHaveLength(0); expect( - nftController.state.allNftContracts[selectedAddress][ChainId.mainnet], + nftController.state.allNftContracts[OWNER_ACCOUNT.address][ + ChainId.mainnet + ], ).toHaveLength(0); }); it('should not remove NFT contract if NFT still exists', async () => { - const selectedAddress = OWNER_ADDRESS; const { nftController } = setupController(); await nftController.addNft('0x01', '1', { @@ -2444,18 +2668,25 @@ describe('NftController', () => { }); nftController.removeNft('0x01', '1'); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], ).toHaveLength(1); expect( - nftController.state.allNftContracts[selectedAddress][ChainId.mainnet], + nftController.state.allNftContracts[OWNER_ACCOUNT.address][ + ChainId.mainnet + ], ).toHaveLength(1); }); it('should remove NFT by selected address', async () => { const tokenURI = 'https://url/'; const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); - const { nftController, triggerPreferencesStateChange } = setupController({ + const { + nftController, + triggerPreferencesStateChange, + mockGetAccount, + triggerSelectedAccountChange, + } = setupController({ options: { getERC721TokenURI: mockGetERC721TokenURI, }, @@ -2466,30 +2697,39 @@ describe('NftController', () => { description: 'description', }); const firstAddress = '0x123'; + const firstAccount = createMockInternalAccount({ + address: firstAddress, + id: '22c022b5-309c-45e4-a82d-64bb11fc0e74', + }); const secondAddress = '0x321'; + const secondAccount = createMockInternalAccount({ + address: secondAddress, + id: 'f9a42417-6071-4b51-8ecd-f7b14abd8851', + }); + mockGetAccount.mockReturnValue(firstAccount); + triggerSelectedAccountChange(firstAccount); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), openSeaEnabled: true, - selectedAddress: firstAddress, }); await nftController.addNft('0x02', '4321'); + mockGetAccount.mockReturnValue(secondAccount); + triggerSelectedAccountChange(secondAccount); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), openSeaEnabled: true, - selectedAddress: secondAddress, }); await nftController.addNft('0x01', '1234'); nftController.removeNft('0x01', '1234'); expect( - nftController.state.allNfts[secondAddress][ChainId.mainnet], + nftController.state.allNfts[secondAccount.address][ChainId.mainnet], ).toHaveLength(0); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), openSeaEnabled: true, - selectedAddress: firstAddress, }); expect( - nftController.state.allNfts[firstAddress][ChainId.mainnet][0], + nftController.state.allNfts[firstAccount.address][ChainId.mainnet][0], ).toStrictEqual({ address: '0x02', description: 'description', @@ -2504,14 +2744,13 @@ describe('NftController', () => { }); it('should remove NFT by provider type', async () => { - const selectedAddress = OWNER_ADDRESS; const tokenURI = 'https://url/'; const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); const { nftController, changeNetwork } = setupController({ options: { - selectedAddress, getERC721TokenURI: mockGetERC721TokenURI, }, + defaultSelectedAccount: OWNER_ACCOUNT, }); nock('https://url').get('/').reply(200, { @@ -2525,13 +2764,13 @@ describe('NftController', () => { await nftController.addNft('0x01', '1234'); nftController.removeNft('0x01', '1234'); expect( - nftController.state.allNfts[selectedAddress][GOERLI.chainId], + nftController.state.allNfts[OWNER_ACCOUNT.address][GOERLI.chainId], ).toHaveLength(0); changeNetwork({ selectedNetworkClientId: InfuraNetworkType.sepolia }); expect( - nftController.state.allNfts[selectedAddress][SEPOLIA.chainId][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][SEPOLIA.chainId][0], ).toStrictEqual({ address: '0x02', description: 'description', @@ -2546,17 +2785,31 @@ describe('NftController', () => { }); it('should remove correct NFT and NFT contract when passed networkClientId and userAddress in options', async () => { - const { nftController, changeNetwork, triggerPreferencesStateChange } = - setupController(); + const { + nftController, + changeNetwork, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + mockGetAccount, + } = setupController(); const userAddress1 = '0x123'; + const userAccount1 = createMockInternalAccount({ + address: userAddress1, + id: '5fd59cae-95d3-4a1d-ba97-657c8f83c300', + }); const userAddress2 = '0x321'; + const userAccount2 = createMockInternalAccount({ + address: userAddress2, + id: '9ea40063-a95c-4f79-a4b6-0c065549245e', + }); changeNetwork({ selectedNetworkClientId: InfuraNetworkType.sepolia }); + mockGetAccount.mockReturnValue(userAccount1); + triggerSelectedAccountChange(userAccount1); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), openSeaEnabled: true, - selectedAddress: userAddress1, }); await nftController.addNft('0x01', '1', { @@ -2582,10 +2835,11 @@ describe('NftController', () => { }); changeNetwork({ selectedNetworkClientId: InfuraNetworkType.goerli }); + mockGetAccount.mockReturnValue(userAccount2); + triggerSelectedAccountChange(userAccount2); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), openSeaEnabled: true, - selectedAddress: userAddress2, }); // now remove the nft after changing to a different network and account from the one where it was added @@ -2605,11 +2859,9 @@ describe('NftController', () => { }); it('should be able to clear the ignoredNfts list', async () => { - const selectedAddress = OWNER_ADDRESS; const { nftController } = setupController({ - options: { - selectedAddress, - }, + options: {}, + defaultSelectedAccount: OWNER_ACCOUNT, }); await nftController.addNft('0x02', '1', { @@ -2623,13 +2875,13 @@ describe('NftController', () => { }); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], ).toHaveLength(1); expect(nftController.state.ignoredNfts).toHaveLength(0); nftController.removeAndIgnoreNft('0x02', '1'); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], ).toHaveLength(0); expect(nftController.state.ignoredNfts).toHaveLength(1); @@ -2766,17 +3018,20 @@ describe('NftController', () => { }); it('should add NFT with null metadata if the ipfs gateway is disabled and opensea is disabled', async () => { - const selectedAddress = OWNER_ADDRESS; - const { nftController, triggerPreferencesStateChange } = setupController({ + const { + nftController, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + } = setupController({ options: { getERC721TokenURI: jest.fn().mockRejectedValue(''), getERC1155TokenURI: jest.fn().mockResolvedValue('ipfs://*'), }, + defaultSelectedAccount: OWNER_ACCOUNT, }); - + triggerSelectedAccountChange(OWNER_ACCOUNT); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), - selectedAddress, isIpfsGatewayEnabled: false, openSeaEnabled: false, }); @@ -2784,7 +3039,7 @@ describe('NftController', () => { await nftController.addNft(ERC1155_NFT_ADDRESS, ERC1155_NFT_ID); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], ).toStrictEqual({ address: ERC1155_NFT_ADDRESS, name: null, @@ -2801,11 +3056,9 @@ describe('NftController', () => { describe('updateNftFavoriteStatus', () => { it('should not set NFT as favorite if nft not found', async () => { - const selectedAddress = OWNER_ADDRESS; const { nftController } = setupController({ - options: { - selectedAddress, - }, + options: {}, + defaultSelectedAccount: OWNER_ACCOUNT, }); await nftController.addNft( @@ -2821,7 +3074,7 @@ describe('NftController', () => { ); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], ).toStrictEqual( expect.objectContaining({ address: ERC721_DEPRESSIONIST_ADDRESS, @@ -2831,11 +3084,9 @@ describe('NftController', () => { ); }); it('should set NFT as favorite', async () => { - const selectedAddress = OWNER_ADDRESS; const { nftController } = setupController({ - options: { - selectedAddress, - }, + options: {}, + defaultSelectedAccount: OWNER_ACCOUNT, }); await nftController.addNft( @@ -2851,7 +3102,7 @@ describe('NftController', () => { ); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], ).toStrictEqual( expect.objectContaining({ address: ERC721_DEPRESSIONIST_ADDRESS, @@ -2862,11 +3113,9 @@ describe('NftController', () => { }); it('should set NFT as favorite and then unset it', async () => { - const selectedAddress = OWNER_ADDRESS; const { nftController } = setupController({ - options: { - selectedAddress, - }, + options: {}, + defaultSelectedAccount: OWNER_ACCOUNT, }); await nftController.addNft( @@ -2882,7 +3131,7 @@ describe('NftController', () => { ); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], ).toStrictEqual( expect.objectContaining({ address: ERC721_DEPRESSIONIST_ADDRESS, @@ -2898,7 +3147,7 @@ describe('NftController', () => { ); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], ).toStrictEqual( expect.objectContaining({ address: ERC721_DEPRESSIONIST_ADDRESS, @@ -2909,11 +3158,9 @@ describe('NftController', () => { }); it('should keep the favorite status as true after updating metadata', async () => { - const selectedAddress = OWNER_ADDRESS; const { nftController } = setupController({ - options: { - selectedAddress, - }, + options: {}, + defaultSelectedAccount: OWNER_ACCOUNT, }); await nftController.addNft( @@ -2929,7 +3176,7 @@ describe('NftController', () => { ); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], ).toStrictEqual( expect.objectContaining({ address: ERC721_DEPRESSIONIST_ADDRESS, @@ -2952,7 +3199,7 @@ describe('NftController', () => { ); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], ).toStrictEqual( expect.objectContaining({ image: 'new_image', @@ -2966,16 +3213,14 @@ describe('NftController', () => { ); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], ).toHaveLength(1); }); it('should keep the favorite status as false after updating metadata', async () => { - const selectedAddress = OWNER_ADDRESS; const { nftController } = setupController({ - options: { - selectedAddress, - }, + options: {}, + defaultSelectedAccount: OWNER_ACCOUNT, }); await nftController.addNft( @@ -2985,7 +3230,7 @@ describe('NftController', () => { ); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], ).toStrictEqual( expect.objectContaining({ address: ERC721_DEPRESSIONIST_ADDRESS, @@ -3008,7 +3253,7 @@ describe('NftController', () => { ); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], ).toStrictEqual( expect.objectContaining({ image: 'new_image', @@ -3022,22 +3267,36 @@ describe('NftController', () => { ); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet], ).toHaveLength(1); }); it('should set NFT as favorite when passed networkClientId and userAddress in options', async () => { - const { nftController, triggerPreferencesStateChange, changeNetwork } = - setupController(); + const { + nftController, + triggerPreferencesStateChange, + changeNetwork, + triggerSelectedAccountChange, + mockGetAccount, + } = setupController(); const userAddress1 = '0x123'; + const userAccount1 = createMockInternalAccount({ + address: userAddress1, + id: '0a2a9a41-2b35-4863-8f36-baceec4e9686', + }); const userAddress2 = '0x321'; + const userAccount2 = createMockInternalAccount({ + address: userAddress2, + id: '09b239a4-c229-4a2b-9739-1cb4b9dea7b9', + }); changeNetwork({ selectedNetworkClientId: InfuraNetworkType.sepolia }); + mockGetAccount.mockReturnValue(userAccount1); + triggerSelectedAccountChange(userAccount1); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), openSeaEnabled: true, - selectedAddress: userAddress1, }); await nftController.addNft( @@ -3047,7 +3306,7 @@ describe('NftController', () => { ); expect( - nftController.state.allNfts[userAddress1][SEPOLIA.chainId][0], + nftController.state.allNfts[userAccount1.address][SEPOLIA.chainId][0], ).toStrictEqual( expect.objectContaining({ address: ERC721_DEPRESSIONIST_ADDRESS, @@ -3057,10 +3316,11 @@ describe('NftController', () => { ); changeNetwork({ selectedNetworkClientId: InfuraNetworkType.goerli }); + mockGetAccount.mockReturnValue(userAccount2); + triggerSelectedAccountChange(userAccount2); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), openSeaEnabled: true, - selectedAddress: userAddress2, }); // now favorite the nft after changing to a different account from the one where it was added @@ -3070,12 +3330,12 @@ describe('NftController', () => { true, { networkClientId: SEPOLIA.type, - userAddress: userAddress1, + userAddress: userAccount1.address, }, ); expect( - nftController.state.allNfts[userAddress1][SEPOLIA.chainId][0], + nftController.state.allNfts[userAccount1.address][SEPOLIA.chainId][0], ).toStrictEqual( expect.objectContaining({ address: ERC721_DEPRESSIONIST_ADDRESS, @@ -3088,12 +3348,10 @@ describe('NftController', () => { describe('checkAndUpdateNftsOwnershipStatus', () => { describe('checkAndUpdateAllNftsOwnershipStatus', () => { - it('should check whether NFTs for the current selectedAddress/chainId combination are still owned by the selectedAddress and update the isCurrentlyOwned value to false when NFT is not still owned', async () => { - const selectedAddress = OWNER_ADDRESS; + it('should check whether NFTs for the current selectedAccount/chainId combination are still owned by the selectedAccount and update the isCurrentlyOwned value to false when NFT is not still owned', async () => { const { nftController } = setupController({ - options: { - selectedAddress, - }, + options: {}, + defaultSelectedAccount: OWNER_ACCOUNT, }); jest.spyOn(nftController, 'isNftOwner').mockResolvedValue(false); @@ -3107,24 +3365,22 @@ describe('NftController', () => { }, }); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0] + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0] .isCurrentlyOwned, ).toBe(true); await nftController.checkAndUpdateAllNftsOwnershipStatus(); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0] + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0] .isCurrentlyOwned, ).toBe(false); }); - it('should check whether NFTs for the current selectedAddress/chainId combination are still owned by the selectedAddress and leave/set the isCurrentlyOwned value to true when NFT is still owned', async () => { - const selectedAddress = OWNER_ADDRESS; + it('should check whether NFTs for the current selectedAccount/chainId combination are still owned by the selectedAccount and leave/set the isCurrentlyOwned value to true when NFT is still owned', async () => { const { nftController } = setupController({ - options: { - selectedAddress, - }, + options: {}, + defaultSelectedAccount: OWNER_ACCOUNT, }); jest.spyOn(nftController, 'isNftOwner').mockResolvedValue(true); @@ -3139,23 +3395,21 @@ describe('NftController', () => { }); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0] + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0] .isCurrentlyOwned, ).toBe(true); await nftController.checkAndUpdateAllNftsOwnershipStatus(); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0] + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0] .isCurrentlyOwned, ).toBe(true); }); - it('should check whether NFTs for the current selectedAddress/chainId combination are still owned by the selectedAddress and leave the isCurrentlyOwned value as is when NFT ownership check fails', async () => { - const selectedAddress = OWNER_ADDRESS; + it('should check whether NFTs for the current selectedAccount/chainId combination are still owned by the selectedAccount and leave the isCurrentlyOwned value as is when NFT ownership check fails', async () => { const { nftController } = setupController({ - options: { - selectedAddress, - }, + options: {}, + defaultSelectedAccount: OWNER_ACCOUNT, }); jest .spyOn(nftController, 'isNftOwner') @@ -3172,26 +3426,29 @@ describe('NftController', () => { }); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0] + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0] .isCurrentlyOwned, ).toBe(true); await nftController.checkAndUpdateAllNftsOwnershipStatus(); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0] + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0] .isCurrentlyOwned, ).toBe(true); }); - it('should check whether NFTs for the current selectedAddress/chainId combination are still owned by the selectedAddress and update the isCurrentlyOwned value to false when NFT is not still owned, when the currently configured selectedAddress/chainId are different from those passed', async () => { - const selectedAddress = OWNER_ADDRESS; - const { nftController, changeNetwork, triggerPreferencesStateChange } = - setupController(); + it('should check whether NFTs for the current selectedAccount/chainId combination are still owned by the selectedAccount and update the isCurrentlyOwned value to false when NFT is not still owned, when the currently configured selectedAccount/chainId are different from those passed', async () => { + const { + nftController, + changeNetwork, + triggerPreferencesStateChange, + mockGetAccount, + } = setupController(); + mockGetAccount.mockReturnValue(OWNER_ACCOUNT); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), openSeaEnabled: true, - selectedAddress, }); changeNetwork({ selectedNetworkClientId: InfuraNetworkType.sepolia }); @@ -3206,7 +3463,7 @@ describe('NftController', () => { }); expect( - nftController.state.allNfts[selectedAddress][ChainId.sepolia][0] + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.sepolia][0] .isCurrentlyOwned, ).toBe(true); @@ -3215,7 +3472,6 @@ describe('NftController', () => { triggerPreferencesStateChange({ ...getDefaultPreferencesState(), openSeaEnabled: true, - selectedAddress: SECOND_OWNER_ADDRESS, }); changeNetwork({ selectedNetworkClientId: InfuraNetworkType.goerli }); @@ -3229,15 +3485,36 @@ describe('NftController', () => { .isCurrentlyOwned, ).toBe(false); }); + + it('should handle default case where selectedAccount is not set', async () => { + const { nftController, mockGetAccount } = setupController({ + options: {}, + }); + mockGetAccount.mockReturnValue(null); + jest.spyOn(nftController, 'isNftOwner').mockResolvedValue(false); + + await nftController.addNft('0x02', '1', { + nftMetadata: { + name: 'name', + image: 'image', + description: 'description', + standard: 'standard', + favorite: false, + }, + }); + expect(nftController.state.allNfts['']).toBeUndefined(); + + await nftController.checkAndUpdateAllNftsOwnershipStatus(); + + expect(nftController.state.allNfts['']).toBeUndefined(); + }); }); describe('checkAndUpdateSingleNftOwnershipStatus', () => { - it('should check whether the passed NFT is still owned by the the current selectedAddress/chainId combination and update its isCurrentlyOwned property in state if batch is false and isNftOwner returns false', async () => { - const selectedAddress = OWNER_ADDRESS; + it('should check whether the passed NFT is still owned by the the current selectedAccount/chainId combination and update its isCurrentlyOwned property in state if batch is false and isNftOwner returns false', async () => { const { nftController } = setupController({ - options: { - selectedAddress, - }, + options: {}, + defaultSelectedAccount: OWNER_ACCOUNT, }); const nft = { @@ -3255,7 +3532,7 @@ describe('NftController', () => { }); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0] + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0] .isCurrentlyOwned, ).toBe(true); @@ -3264,17 +3541,15 @@ describe('NftController', () => { await nftController.checkAndUpdateSingleNftOwnershipStatus(nft, false); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0] + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0] .isCurrentlyOwned, ).toBe(false); }); it('should check whether the passed NFT is still owned by the the current selectedAddress/chainId combination and return the updated NFT object without updating state if batch is true', async () => { - const selectedAddress = OWNER_ADDRESS; const { nftController } = setupController({ - options: { - selectedAddress, - }, + options: {}, + defaultSelectedAccount: OWNER_ACCOUNT, }); const nft = { @@ -3292,7 +3567,7 @@ describe('NftController', () => { }); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0] + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0] .isCurrentlyOwned, ).toBe(true); @@ -3302,22 +3577,26 @@ describe('NftController', () => { await nftController.checkAndUpdateSingleNftOwnershipStatus(nft, true); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0] + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0] .isCurrentlyOwned, ).toBe(true); - expect(updatedNft.isCurrentlyOwned).toBe(false); + expect(updatedNft?.isCurrentlyOwned).toBe(false); }); it('should check whether the passed NFT is still owned by the the selectedAddress/chainId combination passed in the accountParams argument and update its isCurrentlyOwned property in state, when the currently configured selectedAddress/chainId are different from those passed', async () => { - const firstSelectedAddress = OWNER_ADDRESS; - const { nftController, changeNetwork, triggerPreferencesStateChange } = - setupController(); - + const firstSelectedAddress = OWNER_ACCOUNT.address; + const { + nftController, + changeNetwork, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + } = setupController(); + + triggerSelectedAccountChange(OWNER_ACCOUNT); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), openSeaEnabled: true, - selectedAddress: firstSelectedAddress, }); changeNetwork({ selectedNetworkClientId: InfuraNetworkType.sepolia }); @@ -3341,11 +3620,13 @@ describe('NftController', () => { ).toBe(true); jest.spyOn(nftController, 'isNftOwner').mockResolvedValue(false); - + const secondAccount = createMockInternalAccount({ + address: SECOND_OWNER_ADDRESS, + }); + triggerSelectedAccountChange(secondAccount); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), openSeaEnabled: true, - selectedAddress: SECOND_OWNER_ADDRESS, }); changeNetwork({ selectedNetworkClientId: InfuraNetworkType.goerli }); @@ -3361,14 +3642,18 @@ describe('NftController', () => { }); it('should check whether the passed NFT is still owned by the the selectedAddress/chainId combination passed in the accountParams argument and return the updated NFT object without updating state, when the currently configured selectedAddress/chainId are different from those passed and batch is true', async () => { - const firstSelectedAddress = OWNER_ADDRESS; - const { nftController, changeNetwork, triggerPreferencesStateChange } = - setupController(); - + const firstSelectedAddress = OWNER_ACCOUNT.address; + const { + nftController, + changeNetwork, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + } = setupController(); + + triggerSelectedAccountChange(OWNER_ACCOUNT); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), openSeaEnabled: true, - selectedAddress: OWNER_ADDRESS, }); changeNetwork({ selectedNetworkClientId: InfuraNetworkType.sepolia }); @@ -3392,11 +3677,13 @@ describe('NftController', () => { ).toBe(true); jest.spyOn(nftController, 'isNftOwner').mockResolvedValue(false); - + const secondAccount = createMockInternalAccount({ + address: SECOND_OWNER_ADDRESS, + }); + triggerSelectedAccountChange(secondAccount); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), openSeaEnabled: true, - selectedAddress: SECOND_OWNER_ADDRESS, }); changeNetwork({ selectedNetworkClientId: InfuraNetworkType.goerli }); @@ -3435,41 +3722,38 @@ describe('NftController', () => { }; it('should return null if the NFT does not exist in the state', async () => { - const selectedAddress = OWNER_ADDRESS; const { nftController } = setupController({ - options: { - selectedAddress, - }, + options: {}, + defaultSelectedAccount: OWNER_ACCOUNT, }); expect( nftController.findNftByAddressAndTokenId( mockNft.address, mockNft.tokenId, - selectedAddress, + OWNER_ACCOUNT.address, ChainId.mainnet, ), ).toBeNull(); }); it('should return the NFT by the address and tokenId', () => { - const selectedAddress = OWNER_ADDRESS; const { nftController } = setupController({ options: { - selectedAddress, state: { allNfts: { - [OWNER_ADDRESS]: { [ChainId.mainnet]: [mockNft] }, + [OWNER_ACCOUNT.address]: { [ChainId.mainnet]: [mockNft] }, }, }, }, + defaultSelectedAccount: OWNER_ACCOUNT, }); expect( nftController.findNftByAddressAndTokenId( mockNft.address, mockNft.tokenId, - selectedAddress, + OWNER_ACCOUNT.address, ChainId.mainnet, ), ).toStrictEqual({ nft: mockNft, index: 0 }); @@ -3477,7 +3761,6 @@ describe('NftController', () => { }); describe('updateNftByAddressAndTokenId', () => { - const selectedAddress = OWNER_ADDRESS; const mockTransactionId = '60d36710-b150-11ec-8a49-c377fbd05e27'; const mockNft = { address: '0x02', @@ -3503,13 +3786,13 @@ describe('NftController', () => { it('should update the NFT if the NFT exist', async () => { const { nftController } = setupController({ options: { - selectedAddress, state: { allNfts: { - [OWNER_ADDRESS]: { [ChainId.mainnet]: [mockNft] }, + [OWNER_ACCOUNT.address]: { [ChainId.mainnet]: [mockNft] }, }, }, }, + defaultSelectedAccount: OWNER_ACCOUNT, }); nftController.updateNft( @@ -3517,20 +3800,19 @@ describe('NftController', () => { { transactionId: mockTransactionId, }, - selectedAddress, + OWNER_ACCOUNT.address, ChainId.mainnet, ); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0], ).toStrictEqual(expectedMockNft); }); it('should return undefined if the NFT does not exist', () => { const { nftController } = setupController({ - options: { - selectedAddress, - }, + options: {}, + defaultSelectedAccount: OWNER_ACCOUNT, }); expect( @@ -3539,7 +3821,7 @@ describe('NftController', () => { { transactionId: mockTransactionId, }, - selectedAddress, + OWNER_ACCOUNT.address, ChainId.mainnet, ), ).toBeUndefined(); @@ -3562,27 +3844,23 @@ describe('NftController', () => { }; it('should not update any NFT state and should return false when passed a transaction id that does not match that of any NFT', async () => { - const selectedAddress = OWNER_ADDRESS; const { nftController } = setupController({ - options: { - selectedAddress, - }, + options: {}, + defaultSelectedAccount: OWNER_ACCOUNT, }); expect( nftController.resetNftTransactionStatusByTransactionId( nonExistTransactionId, - selectedAddress, + OWNER_ACCOUNT.address, ChainId.mainnet, ), ).toBe(false); }); it('should set the transaction id of an NFT in state to undefined, and return true when it has successfully updated this state', async () => { - const selectedAddress = OWNER_ADDRESS; const { nftController } = setupController({ options: { - selectedAddress, state: { allNfts: { [OWNER_ADDRESS]: { [ChainId.mainnet]: [mockNft] }, @@ -3592,20 +3870,20 @@ describe('NftController', () => { }); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0] + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0] .transactionId, ).toBe(mockTransactionId); expect( nftController.resetNftTransactionStatusByTransactionId( mockTransactionId, - selectedAddress, + OWNER_ACCOUNT.address, ChainId.mainnet, ), ).toBe(true); expect( - nftController.state.allNfts[selectedAddress][ChainId.mainnet][0] + nftController.state.allNfts[OWNER_ACCOUNT.address][ChainId.mainnet][0] .transactionId, ).toBeUndefined(); }); @@ -3613,17 +3891,17 @@ describe('NftController', () => { describe('updateNftMetadata', () => { it('should update Nft metadata successfully', async () => { - const selectedAddress = OWNER_ADDRESS; const tokenURI = 'https://api.pudgypenguins.io/lil/4'; const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); - const { nftController } = setupController({ + const { nftController, mockGetAccount } = setupController({ options: { - selectedAddress, getERC721TokenURI: mockGetERC721TokenURI, }, + defaultSelectedAccount: OWNER_ACCOUNT, }); const spy = jest.spyOn(nftController, 'updateNft'); const testNetworkClientId = 'sepolia'; + mockGetAccount.mockReturnValue(OWNER_ACCOUNT); await nftController.addNft('0xtest', '3', { nftMetadata: { name: '', description: '', image: '', standard: '' }, networkClientId: testNetworkClientId, @@ -3655,7 +3933,7 @@ describe('NftController', () => { expect(spy).toHaveBeenCalledTimes(1); expect( - nftController.state.allNfts[selectedAddress][SEPOLIA.chainId][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][SEPOLIA.chainId][0], ).toStrictEqual({ address: '0xtest', description: 'description pudgy', @@ -3670,17 +3948,17 @@ describe('NftController', () => { }); it('should not update metadata when state nft and fetched nft are the same', async () => { - const selectedAddress = OWNER_ADDRESS; const tokenURI = 'https://url/'; const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); - const { nftController } = setupController({ + const { nftController, mockGetAccount } = setupController({ options: { - selectedAddress, getERC721TokenURI: mockGetERC721TokenURI, }, + defaultSelectedAccount: OWNER_ACCOUNT, }); const updateNftSpy = jest.spyOn(nftController, 'updateNft'); const testNetworkClientId = 'sepolia'; + mockGetAccount.mockReturnValue(OWNER_ACCOUNT); await nftController.addNft('0xtest', '3', { nftMetadata: { name: 'toto', @@ -3713,6 +3991,7 @@ describe('NftController', () => { }, ]; + mockGetAccount.mockReturnValue(OWNER_ACCOUNT); await nftController.updateNftMetadata({ nfts: testInputNfts, networkClientId: testNetworkClientId, @@ -3720,7 +3999,7 @@ describe('NftController', () => { expect(updateNftSpy).toHaveBeenCalledTimes(0); expect( - nftController.state.allNfts[selectedAddress][SEPOLIA.chainId][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][SEPOLIA.chainId][0], ).toStrictEqual({ address: '0xtest', description: 'description', @@ -3735,17 +4014,17 @@ describe('NftController', () => { }); it('should trigger update metadata when state nft and fetched nft are not the same', async () => { - const selectedAddress = OWNER_ADDRESS; const tokenURI = 'https://url/'; const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); - const { nftController } = setupController({ + const { nftController, mockGetAccount } = setupController({ options: { - selectedAddress, getERC721TokenURI: mockGetERC721TokenURI, }, + defaultSelectedAccount: OWNER_ACCOUNT, }); const spy = jest.spyOn(nftController, 'updateNft'); const testNetworkClientId = 'sepolia'; + mockGetAccount.mockReturnValue(OWNER_ACCOUNT); await nftController.addNft('0xtest', '3', { nftMetadata: { name: 'toto', @@ -3781,7 +4060,7 @@ describe('NftController', () => { expect(spy).toHaveBeenCalledTimes(1); expect( - nftController.state.allNfts[selectedAddress][SEPOLIA.chainId][0], + nftController.state.allNfts[OWNER_ACCOUNT.address][SEPOLIA.chainId][0], ).toStrictEqual({ address: '0xtest', description: 'description', @@ -3796,8 +4075,11 @@ describe('NftController', () => { }); it('should not update metadata when nfts has image/name/description already', async () => { - const { nftController, triggerPreferencesStateChange } = - setupController(); + const { + nftController, + triggerPreferencesStateChange, + triggerSelectedAccountChange, + } = setupController(); const spy = jest.spyOn(nftController, 'updateNftMetadata'); const testNetworkClientId = 'sepolia'; @@ -3813,12 +4095,12 @@ describe('NftController', () => { networkClientId: testNetworkClientId, }); + triggerSelectedAccountChange(OWNER_ACCOUNT); // trigger preference change triggerPreferencesStateChange({ ...getDefaultPreferencesState(), isIpfsGatewayEnabled: false, openSeaEnabled: true, - selectedAddress: OWNER_ADDRESS, }); expect(spy).toHaveBeenCalledTimes(0); @@ -3827,12 +4109,16 @@ describe('NftController', () => { it('should trigger calling updateNftMetadata when preferences change - openseaEnabled', async () => { const tokenURI = 'https://url/'; const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); - const { nftController, triggerPreferencesStateChange, changeNetwork } = - setupController({ - options: { - getERC721TokenURI: mockGetERC721TokenURI, - }, - }); + const { + nftController, + triggerPreferencesStateChange, + changeNetwork, + triggerSelectedAccountChange, + } = setupController({ + options: { + getERC721TokenURI: mockGetERC721TokenURI, + }, + }); changeNetwork({ selectedNetworkClientId: InfuraNetworkType.sepolia }); const spy = jest.spyOn(nftController, 'updateNftMetadata'); @@ -3865,21 +4151,24 @@ describe('NftController', () => { ...getDefaultPreferencesState(), isIpfsGatewayEnabled: false, openSeaEnabled: true, - selectedAddress: OWNER_ADDRESS, }); - + triggerSelectedAccountChange(OWNER_ACCOUNT); expect(spy).toHaveBeenCalledTimes(1); }); it('should trigger calling updateNftMetadata when preferences change - ipfs enabled', async () => { const tokenURI = 'https://url/'; const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); - const { nftController, triggerPreferencesStateChange, changeNetwork } = - setupController({ - options: { - getERC721TokenURI: mockGetERC721TokenURI, - }, - }); + const { + nftController, + triggerPreferencesStateChange, + changeNetwork, + triggerSelectedAccountChange, + } = setupController({ + options: { + getERC721TokenURI: mockGetERC721TokenURI, + }, + }); changeNetwork({ selectedNetworkClientId: InfuraNetworkType.sepolia }); const spy = jest.spyOn(nftController, 'updateNftMetadata'); @@ -3912,8 +4201,8 @@ describe('NftController', () => { ...getDefaultPreferencesState(), isIpfsGatewayEnabled: true, openSeaEnabled: false, - selectedAddress: OWNER_ADDRESS, }); + triggerSelectedAccountChange(OWNER_ACCOUNT); expect(spy).toHaveBeenCalledTimes(1); }); @@ -4021,4 +4310,26 @@ describe('NftController', () => { expect(spy3).toHaveBeenCalledTimes(1); }); }); + + // Testing to make sure selectedAccountChange isn't used. This can return non-EVM accounts. + it('triggering selectedAccountChange would not trigger anything', async () => { + const tokenURI = 'https://url/'; + const mockGetERC721TokenURI = jest.fn().mockResolvedValue(tokenURI); + const { nftController, messenger } = setupController({ + options: { + openSeaEnabled: true, + getERC721TokenURI: mockGetERC721TokenURI, + }, + }); + const updateNftMetadataSpy = jest.spyOn(nftController, 'updateNftMetadata'); + messenger.publish( + 'AccountsController:selectedAccountChange', + createMockInternalAccount({ + id: 'new-id', + address: '0x5284deb594c4b593268d7c98e5ecd29dcafa5b49', + }), + ); + + expect(updateNftMetadataSpy).not.toHaveBeenCalled(); + }); }); diff --git a/packages/assets-controllers/src/NftController.ts b/packages/assets-controllers/src/NftController.ts index 998552b9d3..4f876412dc 100644 --- a/packages/assets-controllers/src/NftController.ts +++ b/packages/assets-controllers/src/NftController.ts @@ -1,4 +1,9 @@ import { isAddress } from '@ethersproject/address'; +import type { + AccountsControllerSelectedEvmAccountChangeEvent, + AccountsControllerGetAccountAction, + AccountsControllerGetSelectedAccountAction, +} from '@metamask/accounts-controller'; import type { AddApprovalRequest } from '@metamask/approval-controller'; import type { RestrictedControllerMessenger, @@ -20,6 +25,7 @@ import { ApprovalType, NFT_API_BASE_URL, } from '@metamask/controller-utils'; +import { type InternalAccount } from '@metamask/keyring-api'; import type { NetworkClientId, NetworkControllerGetNetworkClientByIdAction, @@ -214,11 +220,14 @@ export type NftControllerActions = NftControllerGetStateAction; */ export type AllowedActions = | AddApprovalRequest + | AccountsControllerGetAccountAction + | AccountsControllerGetSelectedAccountAction | NetworkControllerGetNetworkClientByIdAction; export type AllowedEvents = | PreferencesControllerStateChangeEvent - | NetworkControllerNetworkDidChangeEvent; + | NetworkControllerNetworkDidChangeEvent + | AccountsControllerSelectedEvmAccountChangeEvent; export type NftControllerStateChangeEvent = ControllerStateChangeEvent< typeof controllerName, @@ -259,7 +268,7 @@ export class NftController extends BaseController< */ openSeaApiKey?: string; - #selectedAddress: string; + #selectedAccountId: string; #chainId: Hex; @@ -296,7 +305,6 @@ export class NftController extends BaseController< * * @param options - The controller options. * @param options.chainId - The chain ID of the current network. - * @param options.selectedAddress - The currently selected address. * @param options.ipfsGateway - The configured IPFS gateway. * @param options.openSeaEnabled - Controls whether the OpenSea API is used. * @param options.useIpfsSubdomains - Controls whether IPFS subdomains are used. @@ -314,7 +322,6 @@ export class NftController extends BaseController< */ constructor({ chainId: initialChainId, - selectedAddress = '', ipfsGateway = IPFS_DEFAULT_GATEWAY_URL, openSeaEnabled = false, useIpfsSubdomains = true, @@ -330,7 +337,6 @@ export class NftController extends BaseController< state = {}, }: { chainId: Hex; - selectedAddress?: string; ipfsGateway?: string; openSeaEnabled?: boolean; useIpfsSubdomains?: boolean; @@ -361,7 +367,9 @@ export class NftController extends BaseController< }, }); - this.#selectedAddress = selectedAddress; + this.#selectedAccountId = this.messagingSystem.call( + 'AccountsController:getSelectedAccount', + ).id; this.#chainId = initialChainId; this.#ipfsGateway = ipfsGateway; this.#openSeaEnabled = openSeaEnabled; @@ -385,6 +393,11 @@ export class NftController extends BaseController< 'NetworkController:networkDidChange', this.#onNetworkControllerNetworkDidChange.bind(this), ); + + this.messagingSystem.subscribe( + 'AccountsController:selectedEvmAccountChange', + this.#onSelectedAccountChange.bind(this), + ); } /** @@ -407,18 +420,19 @@ export class NftController extends BaseController< /** * Handles the state change of the preference controller. * @param preferencesState - The new state of the preference controller. - * @param preferencesState.selectedAddress - The current selected address. * @param preferencesState.ipfsGateway - The configured IPFS gateway. * @param preferencesState.openSeaEnabled - Controls whether the OpenSea API is used. * @param preferencesState.isIpfsGatewayEnabled - Controls whether IPFS is enabled or not. */ async #onPreferencesControllerStateChange({ - selectedAddress, ipfsGateway, openSeaEnabled, isIpfsGatewayEnabled, }: PreferencesState) { - this.#selectedAddress = selectedAddress; + const selectedAccount = this.messagingSystem.call( + 'AccountsController:getSelectedAccount', + ); + this.#selectedAccountId = selectedAccount.id; this.#ipfsGateway = ipfsGateway; this.#openSeaEnabled = openSeaEnabled; this.#isIpfsGatewayEnabled = isIpfsGatewayEnabled; @@ -426,20 +440,26 @@ export class NftController extends BaseController< const needsUpdateNftMetadata = (isIpfsGatewayEnabled && ipfsGateway !== '') || openSeaEnabled; + if (needsUpdateNftMetadata && selectedAccount) { + await this.#updateNftUpdateForAccount(selectedAccount); + } + } + + /** + * Handles the selected account change on the accounts controller. + * @param internalAccount - The new selected account. + */ + async #onSelectedAccountChange(internalAccount: InternalAccount) { + const oldSelectedAccountId = this.#selectedAccountId; + this.#selectedAccountId = internalAccount.id; + + const needsUpdateNftMetadata = + ((this.#isIpfsGatewayEnabled && this.#ipfsGateway !== '') || + this.#openSeaEnabled) && + oldSelectedAccountId !== internalAccount.id; + if (needsUpdateNftMetadata) { - const nfts: Nft[] = - this.state.allNfts[selectedAddress]?.[this.#chainId] ?? []; - // filter only nfts - const nftsToUpdate = nfts.filter( - (singleNft) => - !singleNft.name && !singleNft.description && !singleNft.image, - ); - if (nftsToUpdate.length !== 0) { - await this.updateNftMetadata({ - nfts: nftsToUpdate, - userAddress: selectedAddress, - }); - } + await this.#updateNftUpdateForAccount(internalAccount); } } @@ -466,6 +486,12 @@ export class NftController extends BaseController< baseStateKey: Key, { userAddress, chainId }: { userAddress: string; chainId: Hex }, ) { + // userAddress can be an empty string if it is not set via an account change or in constructor + // while this doesn't cause any issues, we want to ensure that we don't store assets to an empty string address + if (!userAddress) { + return; + } + this.update((state) => { const oldState = state[baseStateKey]; const addressState = oldState[userAddress] || {}; @@ -1218,15 +1244,18 @@ export class NftController extends BaseController< origin: string, { networkClientId, - userAddress = this.#selectedAddress, + userAddress, }: { networkClientId?: NetworkClientId; userAddress?: string; - } = { - userAddress: this.#selectedAddress, - }, + } = {}, ) { - await this.#validateWatchNft(asset, type, userAddress); + const addressToSearch = this.#getAddressOrSelectedAddress(userAddress); + if (!addressToSearch) { + return; + } + + await this.#validateWatchNft(asset, type, addressToSearch); const nftMetadata = await this.#getNftInformation( asset.address, @@ -1245,7 +1274,7 @@ export class NftController extends BaseController< type, id: random(), time: Date.now(), - interactingAddress: userAddress, + interactingAddress: addressToSearch, origin, }; await this._requestApproval(suggestedNftMeta); @@ -1341,19 +1370,19 @@ export class NftController extends BaseController< address: string, tokenId: string, { - userAddress = this.#selectedAddress, + userAddress, networkClientId, source, }: { userAddress?: string; networkClientId?: NetworkClientId; source?: Source; - } = { - userAddress: this.#selectedAddress, - }, + } = {}, ) { + const addressToSearch = this.#getAddressOrSelectedAddress(userAddress); + if ( - !(await this.isNftOwner(userAddress, address, tokenId, { + !(await this.isNftOwner(addressToSearch, address, tokenId, { networkClientId, })) ) { @@ -1361,7 +1390,7 @@ export class NftController extends BaseController< } await this.addNft(address, tokenId, { networkClientId, - userAddress, + userAddress: addressToSearch, source, }); } @@ -1383,7 +1412,7 @@ export class NftController extends BaseController< tokenId: string, { nftMetadata, - userAddress = this.#selectedAddress, + userAddress, source = Source.Custom, networkClientId, }: { @@ -1391,8 +1420,13 @@ export class NftController extends BaseController< userAddress?: string; source?: Source; networkClientId?: NetworkClientId; - } = { userAddress: this.#selectedAddress }, + } = {}, ) { + const addressToSearch = this.#getAddressOrSelectedAddress(userAddress); + if (!addressToSearch) { + return; + } + const checksumHexAddress = toChecksumHexAddress(tokenAddress); const chainId = this.#getCorrectChainId({ networkClientId }); @@ -1407,7 +1441,7 @@ export class NftController extends BaseController< const newNftContracts = await this.#addNftContract({ tokenAddress: checksumHexAddress, - userAddress, + userAddress: addressToSearch, networkClientId, source, nftMetadata, @@ -1427,7 +1461,7 @@ export class NftController extends BaseController< nftMetadata, nftContract, chainId, - userAddress, + addressToSearch, source, ); } @@ -1443,13 +1477,15 @@ export class NftController extends BaseController< */ async updateNftMetadata({ nfts, - userAddress = this.#selectedAddress, + userAddress, networkClientId, }: { nfts: Nft[]; userAddress?: string; networkClientId?: NetworkClientId; }) { + const addressToSearch = this.#getAddressOrSelectedAddress(userAddress); + const releaseLock = await this.#mutex.acquire(); try { @@ -1478,7 +1514,7 @@ export class NftController extends BaseController< // We want to avoid updating the state if the state and fetched nft info are the same const nftsWithDifferentMetadata: NftUpdate[] = []; const { allNfts } = this.state; - const stateNfts = allNfts[userAddress]?.[chainId] || []; + const stateNfts = allNfts[addressToSearch]?.[chainId] || []; nftMetadataResults.forEach((singleNft) => { const existingEntry: Nft | undefined = stateNfts.find( @@ -1501,7 +1537,7 @@ export class NftController extends BaseController< if (nftsWithDifferentMetadata.length !== 0) { nftsWithDifferentMetadata.forEach((elm) => - this.updateNft(elm.nft, elm.newMetadata, userAddress, chainId), + this.updateNft(elm.nft, elm.newMetadata, addressToSearch, chainId), ); } } finally { @@ -1523,25 +1559,27 @@ export class NftController extends BaseController< tokenId: string, { networkClientId, - userAddress = this.#selectedAddress, - }: { networkClientId?: NetworkClientId; userAddress?: string } = { - userAddress: this.#selectedAddress, - }, + userAddress, + }: { networkClientId?: NetworkClientId; userAddress?: string } = {}, ) { + const addressToSearch = this.#getAddressOrSelectedAddress(userAddress); const chainId = this.#getCorrectChainId({ networkClientId }); const checksumHexAddress = toChecksumHexAddress(address); this.#removeIndividualNft(checksumHexAddress, tokenId, { chainId, - userAddress, + userAddress: addressToSearch, }); const { allNfts } = this.state; - const nfts = allNfts[userAddress]?.[chainId] || []; + const nfts = allNfts[addressToSearch]?.[chainId] || []; const remainingNft = nfts.find( (nft) => nft.address.toLowerCase() === checksumHexAddress.toLowerCase(), ); if (!remainingNft) { - this.#removeNftContract(checksumHexAddress, { chainId, userAddress }); + this.#removeNftContract(checksumHexAddress, { + chainId, + userAddress: addressToSearch, + }); } } @@ -1559,24 +1597,26 @@ export class NftController extends BaseController< tokenId: string, { networkClientId, - userAddress = this.#selectedAddress, - }: { networkClientId?: NetworkClientId; userAddress?: string } = { - userAddress: this.#selectedAddress, - }, + userAddress, + }: { networkClientId?: NetworkClientId; userAddress?: string } = {}, ) { + const addressToSearch = this.#getAddressOrSelectedAddress(userAddress); const chainId = this.#getCorrectChainId({ networkClientId }); const checksumHexAddress = toChecksumHexAddress(address); this.#removeAndIgnoreIndividualNft(checksumHexAddress, tokenId, { chainId, - userAddress, + userAddress: addressToSearch, }); const { allNfts } = this.state; - const nfts = allNfts[userAddress]?.[chainId] || []; + const nfts = allNfts[addressToSearch]?.[chainId] || []; const remainingNft = nfts.find( (nft) => nft.address.toLowerCase() === checksumHexAddress.toLowerCase(), ); if (!remainingNft) { - this.#removeNftContract(checksumHexAddress, { chainId, userAddress }); + this.#removeNftContract(checksumHexAddress, { + chainId, + userAddress: addressToSearch, + }); } } @@ -1604,17 +1644,16 @@ export class NftController extends BaseController< nft: Nft, batch: boolean, { - userAddress = this.#selectedAddress, + userAddress, networkClientId, - }: { networkClientId?: NetworkClientId; userAddress?: string } = { - userAddress: this.#selectedAddress, - }, + }: { networkClientId?: NetworkClientId; userAddress?: string } = {}, ) { + const addressToSearch = this.#getAddressOrSelectedAddress(userAddress); const chainId = this.#getCorrectChainId({ networkClientId }); const { address, tokenId } = nft; let isOwned = nft.isCurrentlyOwned; try { - isOwned = await this.isNftOwner(userAddress, address, tokenId, { + isOwned = await this.isNftOwner(addressToSearch, address, tokenId, { networkClientId, }); } catch { @@ -1634,7 +1673,7 @@ export class NftController extends BaseController< // if this is not part of a batched update we update this one NFT in state const { allNfts } = this.state; - const nfts = [...(allNfts[userAddress]?.[chainId] || [])]; + const nfts = [...(allNfts[addressToSearch]?.[chainId] || [])]; const indexToUpdate = nfts.findIndex( (item) => item.tokenId === tokenId && @@ -1644,16 +1683,16 @@ export class NftController extends BaseController< if (indexToUpdate !== -1) { nfts[indexToUpdate] = updatedNft; this.update((state) => { - state.allNfts[userAddress] = Object.assign( + state.allNfts[addressToSearch] = Object.assign( {}, - state.allNfts[userAddress], + state.allNfts[addressToSearch], { [chainId]: nfts, }, ); }); this.#updateNestedNftState(nfts, ALL_NFTS_STATE_KEY, { - userAddress, + userAddress: addressToSearch, chainId, }); } @@ -1668,17 +1707,17 @@ export class NftController extends BaseController< * @param options.networkClientId - The networkClientId that can be used to identify the network client to use for this request. * @param options.userAddress - The address of the account where the NFT ownership status is checked/updated. */ - async checkAndUpdateAllNftsOwnershipStatus( - { - networkClientId, - userAddress = this.#selectedAddress, - }: { networkClientId?: NetworkClientId; userAddress?: string } = { - userAddress: this.#selectedAddress, - }, - ) { + async checkAndUpdateAllNftsOwnershipStatus({ + networkClientId, + userAddress, + }: { + networkClientId?: NetworkClientId; + userAddress?: string; + } = {}) { + const addressToSearch = this.#getAddressOrSelectedAddress(userAddress); const chainId = this.#getCorrectChainId({ networkClientId }); const { allNfts } = this.state; - const nfts = allNfts[userAddress]?.[chainId] || []; + const nfts = allNfts[addressToSearch]?.[chainId] || []; const updatedNfts = await Promise.all( nfts.map(async (nft) => { return ( @@ -1691,7 +1730,7 @@ export class NftController extends BaseController< ); this.#updateNestedNftState(updatedNfts, ALL_NFTS_STATE_KEY, { - userAddress, + userAddress: addressToSearch, chainId, }); } @@ -1712,17 +1751,16 @@ export class NftController extends BaseController< favorite: boolean, { networkClientId, - userAddress = this.#selectedAddress, + userAddress, }: { networkClientId?: NetworkClientId; userAddress?: string; - } = { - userAddress: this.#selectedAddress, - }, + } = {}, ) { + const addressToSearch = this.#getAddressOrSelectedAddress(userAddress); const chainId = this.#getCorrectChainId({ networkClientId }); const { allNfts } = this.state; - const nfts = [...(allNfts[userAddress]?.[chainId] || [])]; + const nfts = [...(allNfts[addressToSearch]?.[chainId] || [])]; const index: number = nfts.findIndex( (nft) => nft.address === address && nft.tokenId === tokenId, ); @@ -1741,7 +1779,7 @@ export class NftController extends BaseController< this.#updateNestedNftState(nfts, ALL_NFTS_STATE_KEY, { chainId, - userAddress, + userAddress: addressToSearch, }); } @@ -1882,6 +1920,36 @@ export class NftController extends BaseController< true, ); } + + #getAddressOrSelectedAddress(address: string | undefined): string { + if (address) { + return address; + } + + // If the address is not defined (or empty), we fallback to the currently selected account's address + const selectedAccount = this.messagingSystem.call( + 'AccountsController:getAccount', + this.#selectedAccountId, + ); + return selectedAccount?.address || ''; + } + + async #updateNftUpdateForAccount(account: InternalAccount) { + const nfts: Nft[] = + this.state.allNfts[account.address]?.[this.#chainId] ?? []; + + // Filter only nfts + const nftsToUpdate = nfts.filter( + (singleNft) => + !singleNft.name && !singleNft.description && !singleNft.image, + ); + if (nftsToUpdate.length !== 0) { + await this.updateNftMetadata({ + nfts: nftsToUpdate, + userAddress: account.address, + }); + } + } } export default NftController; diff --git a/packages/assets-controllers/src/NftDetectionController.test.ts b/packages/assets-controllers/src/NftDetectionController.test.ts index 79e08cee93..47233e0a59 100644 --- a/packages/assets-controllers/src/NftDetectionController.test.ts +++ b/packages/assets-controllers/src/NftDetectionController.test.ts @@ -1,3 +1,4 @@ +import type { AccountsController } from '@metamask/accounts-controller'; import { ControllerMessenger } from '@metamask/base-controller'; import { NFT_API_BASE_URL, ChainId } from '@metamask/controller-utils'; import { @@ -20,6 +21,7 @@ import * as sinon from 'sinon'; import { FakeBlockTracker } from '../../../tests/fake-block-tracker'; import { FakeProvider } from '../../../tests/fake-provider'; import { advanceTime } from '../../../tests/helpers'; +import { createMockInternalAccount } from '../../accounts-controller/src/tests/mocks'; import { buildCustomNetworkClientConfiguration, buildMockGetNetworkClientById, @@ -37,6 +39,8 @@ const DEFAULT_INTERVAL = 180000; const controllerName = 'NftDetectionController' as const; +const defaultSelectedAccount = createMockInternalAccount(); + describe('NftDetectionController', () => { let clock: sinon.SinonFakeTimers; @@ -288,8 +292,16 @@ describe('NftDetectionController', () => { }); it('should poll and detect NFTs on interval while on mainnet', async () => { + const mockGetSelectedAccount = jest + .fn() + .mockReturnValue(defaultSelectedAccount); await withController( - { options: { interval: 10 } }, + { + options: { + interval: 10, + }, + mockGetSelectedAccount, + }, async ({ controller, controllerEvents }) => { const mockNfts = sinon .stub(controller, 'detectNfts') @@ -317,51 +329,56 @@ describe('NftDetectionController', () => { }); it('should poll and detect NFTs by networkClientId on interval while on mainnet', async () => { - await withController(async ({ controller }) => { - const spy = jest - .spyOn(controller, 'detectNfts') - .mockImplementation(() => { - return Promise.resolve(); - }); + await withController( + { + options: {}, + }, + async ({ controller }) => { + const spy = jest + .spyOn(controller, 'detectNfts') + .mockImplementation(() => { + return Promise.resolve(); + }); - controller.startPollingByNetworkClientId('mainnet', { - address: '0x1', - }); + controller.startPollingByNetworkClientId('mainnet', { + address: '0x1', + }); - await advanceTime({ clock, duration: 0 }); - expect(spy.mock.calls).toHaveLength(1); - await advanceTime({ - clock, - duration: DEFAULT_INTERVAL / 2, - }); - expect(spy.mock.calls).toHaveLength(1); - await advanceTime({ - clock, - duration: DEFAULT_INTERVAL / 2, - }); - expect(spy.mock.calls).toHaveLength(2); - await advanceTime({ clock, duration: DEFAULT_INTERVAL }); - expect(spy.mock.calls).toMatchObject([ - [ - { - networkClientId: 'mainnet', - userAddress: '0x1', - }, - ], - [ - { - networkClientId: 'mainnet', - userAddress: '0x1', - }, - ], - [ - { - networkClientId: 'mainnet', - userAddress: '0x1', - }, - ], - ]); - }); + await advanceTime({ clock, duration: 0 }); + expect(spy.mock.calls).toHaveLength(1); + await advanceTime({ + clock, + duration: DEFAULT_INTERVAL / 2, + }); + expect(spy.mock.calls).toHaveLength(1); + await advanceTime({ + clock, + duration: DEFAULT_INTERVAL / 2, + }); + expect(spy.mock.calls).toHaveLength(2); + await advanceTime({ clock, duration: DEFAULT_INTERVAL }); + expect(spy.mock.calls).toMatchObject([ + [ + { + networkClientId: 'mainnet', + userAddress: '0x1', + }, + ], + [ + { + networkClientId: 'mainnet', + userAddress: '0x1', + }, + ], + [ + { + networkClientId: 'mainnet', + userAddress: '0x1', + }, + ], + ]); + }, + ); }); it('should not rely on the currently selected chain to poll for NFTs when a specific chain is being targeted for polling', async () => { @@ -499,6 +516,8 @@ describe('NftDetectionController', () => { it('should respond to chain ID changing when using legacy polling', async () => { const mockAddNft = jest.fn(); const pollingInterval = 100; + const selectedAccount = createMockInternalAccount({ address: '0x1' }); + const mockGetSelectedAccount = jest.fn().mockReturnValue(selectedAccount); await withController( { @@ -515,9 +534,8 @@ describe('NftDetectionController', () => { mockNetworkState: { selectedNetworkClientId: 'mainnet', }, - mockPreferencesState: { - selectedAddress: '0x1', - }, + mockPreferencesState: {}, + mockGetSelectedAccount, }, async ({ controller, controllerEvents }) => { await controller.start(); @@ -595,17 +613,19 @@ 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'; + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, + }); + const mockGetSelectedAccount = jest.fn().mockReturnValue(selectedAccount); await withController( { options: { addNft: mockAddNft }, - mockPreferencesState: { - selectedAddress, - }, + mockPreferencesState: {}, + mockGetSelectedAccount, }, async ({ controller, controllerEvents }) => { controllerEvents.triggerPreferencesStateChange({ ...getDefaultPreferencesState(), - selectedAddress, useNftDetection: true, }); // Wait for detect call triggered by preferences state change to settle @@ -628,7 +648,7 @@ describe('NftDetectionController', () => { standard: 'ERC721', imageOriginal: 'imageOriginal/2574.png', }, - userAddress: selectedAddress, + userAddress: selectedAccount.address, source: Source.Detected, networkClientId: undefined, }, @@ -640,15 +660,19 @@ describe('NftDetectionController', () => { it('should detect and add NFTs correctly when blockaid result is in response', async () => { const mockAddNft = jest.fn(); const selectedAddress = '0x123'; + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, + }); + const mockGetSelectedAccount = jest.fn().mockReturnValue(selectedAccount); await withController( { options: { addNft: mockAddNft }, - mockPreferencesState: { selectedAddress }, + mockPreferencesState: {}, + mockGetSelectedAccount, }, async ({ controller, controllerEvents }) => { controllerEvents.triggerPreferencesStateChange({ ...getDefaultPreferencesState(), - selectedAddress, useNftDetection: true, }); // Wait for detect call triggered by preferences state change to settle @@ -669,7 +693,7 @@ describe('NftDetectionController', () => { standard: 'ERC721', imageOriginal: 'imageOriginal/2574.png', }, - userAddress: selectedAddress, + userAddress: selectedAccount.address, source: Source.Detected, networkClientId: undefined, }); @@ -681,7 +705,7 @@ describe('NftDetectionController', () => { standard: 'ERC721', imageOriginal: 'imageOriginal/2575.png', }, - userAddress: selectedAddress, + userAddress: selectedAccount.address, source: Source.Detected, networkClientId: undefined, }); @@ -692,15 +716,19 @@ describe('NftDetectionController', () => { it('should detect and add NFTs and filter them correctly', async () => { const mockAddNft = jest.fn(); const selectedAddress = '0x12345'; + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, + }); + const mockGetSelectedAccount = jest.fn().mockReturnValue(selectedAccount); await withController( { options: { addNft: mockAddNft }, - mockPreferencesState: { selectedAddress }, + mockPreferencesState: {}, + mockGetSelectedAccount, }, async ({ controller, controllerEvents }) => { controllerEvents.triggerPreferencesStateChange({ ...getDefaultPreferencesState(), - selectedAddress, useNftDetection: true, }); // Wait for detect call triggered by preferences state change to settle @@ -727,7 +755,7 @@ describe('NftDetectionController', () => { standard: 'ERC721', imageOriginal: 'imageOriginal/1.png', }, - userAddress: selectedAddress, + userAddress: selectedAccount.address, source: Source.Detected, networkClientId: undefined, }, @@ -744,7 +772,7 @@ describe('NftDetectionController', () => { standard: 'ERC721', imageOriginal: 'imageOriginal/2.png', }, - userAddress: selectedAddress, + userAddress: selectedAccount.address, source: Source.Detected, networkClientId: undefined, }, @@ -755,13 +783,22 @@ describe('NftDetectionController', () => { it('should detect and add NFTs by networkClientId correctly', async () => { const mockAddNft = jest.fn(); + const mockGetSelectedAccount = jest.fn(); await withController( - { options: { addNft: mockAddNft } }, + { + options: { + addNft: mockAddNft, + }, + mockGetSelectedAccount, + }, async ({ controller, controllerEvents }) => { const selectedAddress = '0x1'; + const updatedSelectedAccount = createMockInternalAccount({ + address: selectedAddress, + }); + mockGetSelectedAccount.mockReturnValue(updatedSelectedAccount); controllerEvents.triggerPreferencesStateChange({ ...getDefaultPreferencesState(), - selectedAddress, useNftDetection: true, }); // Wait for detect call triggered by preferences state change to settle @@ -798,6 +835,7 @@ describe('NftDetectionController', () => { it('should not autodetect NFTs that exist in the ignoreList', async () => { const mockAddNft = jest.fn(); + const mockGetSelectedAccount = jest.fn(); const mockGetNftState = jest.fn().mockImplementation(() => { return { ...getDefaultNftControllerState(), @@ -813,15 +851,19 @@ describe('NftDetectionController', () => { }; }); const selectedAddress = '0x9'; + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, + }); await withController( { options: { addNft: mockAddNft, getNftState: mockGetNftState }, mockPreferencesState: { selectedAddress }, + mockGetSelectedAccount, }, async ({ controller, controllerEvents }) => { + mockGetSelectedAccount.mockReturnValue(selectedAccount); controllerEvents.triggerPreferencesStateChange({ ...getDefaultPreferencesState(), - selectedAddress, useNftDetection: true, }); // Wait for detect call triggered by preferences state change to settle @@ -840,16 +882,17 @@ describe('NftDetectionController', () => { it('should not detect and add NFTs if there is no selectedAddress', async () => { const mockAddNft = jest.fn(); - const selectedAddress = ''; // Emtpy selected address + // mock uninitialised selectedAccount when it is '' + const mockGetSelectedAccount = jest.fn().mockReturnValue({ address: '' }); await withController( { options: { addNft: mockAddNft }, - mockPreferencesState: { selectedAddress }, + mockPreferencesState: {}, + mockGetSelectedAccount, }, async ({ controller, controllerEvents }) => { controllerEvents.triggerPreferencesStateChange({ ...getDefaultPreferencesState(), - selectedAddress, useNftDetection: true, // auto-detect is enabled so it proceeds to check userAddress }); @@ -914,16 +957,21 @@ describe('NftDetectionController', () => { it('should not detect and add NFTs if preferences controller useNftDetection is set to false', async () => { const mockAddNft = jest.fn(); + const mockGetSelectedAccount = jest.fn(); const selectedAddress = '0x9'; + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, + }); await withController( { options: { addNft: mockAddNft, disabled: false }, - mockPreferencesState: { selectedAddress }, + mockPreferencesState: {}, + mockGetSelectedAccount, }, async ({ controller, controllerEvents }) => { + mockGetSelectedAccount.mockReturnValue(selectedAccount); controllerEvents.triggerPreferencesStateChange({ ...getDefaultPreferencesState(), - selectedAddress, useNftDetection: false, }); // Wait for detect call triggered by preferences state change to settle @@ -941,9 +989,9 @@ describe('NftDetectionController', () => { }); it('should do nothing when the request to Nft API fails', async () => { - const selectedAddress = '0x3'; + const selectedAccount = createMockInternalAccount({ address: '0x3' }); nock(NFT_API_BASE_URL) - .get(`/users/${selectedAddress}/tokens`) + .get(`/users/${selectedAccount.address}/tokens`) .query({ continuation: '', limit: '50', @@ -953,12 +1001,17 @@ describe('NftDetectionController', () => { .replyWithError(new Error('Failed to fetch')) .persist(); const mockAddNft = jest.fn(); + const mockGetSelectedAccount = jest.fn().mockReturnValue(selectedAccount); await withController( - { options: { addNft: mockAddNft } }, + { + options: { + addNft: mockAddNft, + }, + mockGetSelectedAccount, + }, async ({ controller, controllerEvents }) => { controllerEvents.triggerPreferencesStateChange({ ...getDefaultPreferencesState(), - selectedAddress, useNftDetection: true, }); // Wait for detect call triggered by preferences state change to settle @@ -977,8 +1030,15 @@ describe('NftDetectionController', () => { it('should rethrow error when Nft APi server fails with error other than fetch failure', async () => { const selectedAddress = '0x4'; + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, + }); + const mockGetSelectedAccount = jest.fn().mockReturnValue(selectedAccount); await withController( - { mockPreferencesState: { selectedAddress } }, + { + mockPreferencesState: {}, + mockGetSelectedAccount, + }, async ({ controller, controllerEvents }) => { // This mock is for the initial detect call after preferences change nock(NFT_API_BASE_URL) @@ -994,7 +1054,6 @@ describe('NftDetectionController', () => { }); controllerEvents.triggerPreferencesStateChange({ ...getDefaultPreferencesState(), - selectedAddress, useNftDetection: true, }); // Wait for detect call triggered by preferences state change to settle @@ -1022,16 +1081,21 @@ describe('NftDetectionController', () => { it('should rethrow error when attempt to add NFT fails', async () => { const mockAddNft = jest.fn(); + const mockGetSelectedAccount = jest.fn(); const selectedAddress = '0x1'; + const selectedAccount = createMockInternalAccount({ + address: selectedAddress, + }); await withController( { options: { addNft: mockAddNft }, - mockPreferencesState: { selectedAddress }, + mockPreferencesState: {}, + mockGetSelectedAccount, }, async ({ controller, controllerEvents }) => { + mockGetSelectedAccount.mockReturnValue(selectedAccount); controllerEvents.triggerPreferencesStateChange({ ...getDefaultPreferencesState(), - selectedAddress, useNftDetection: true, }); // Wait for detect call triggered by preferences state change to settle @@ -1050,28 +1114,38 @@ describe('NftDetectionController', () => { }); it('should only re-detect when relevant settings change', async () => { - await withController({}, async ({ controller, controllerEvents }) => { - const detectNfts = sinon.stub(controller, 'detectNfts'); + const mockGetSelectedAccount = jest + .fn() + .mockReturnValue(defaultSelectedAccount); + await withController( + { + options: {}, + mockGetSelectedAccount, + }, + async ({ controller, controllerEvents }) => { + const detectNfts = sinon.stub(controller, 'detectNfts'); + + // Repeated preference changes should only trigger 1 detection + for (let i = 0; i < 5; i++) { + controllerEvents.triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + useNftDetection: true, + securityAlertsEnabled: true, + }); + } + await advanceTime({ clock, duration: 1 }); + expect(detectNfts.callCount).toBe(1); - // Repeated preference changes should only trigger 1 detection - for (let i = 0; i < 5; i++) { + // Irrelevant preference changes shouldn't trigger a detection controllerEvents.triggerPreferencesStateChange({ ...getDefaultPreferencesState(), useNftDetection: true, + securityAlertsEnabled: true, }); - } - await advanceTime({ clock, duration: 1 }); - expect(detectNfts.callCount).toBe(1); - - // Irrelevant preference changes shouldn't trigger a detection - controllerEvents.triggerPreferencesStateChange({ - ...getDefaultPreferencesState(), - useNftDetection: true, - securityAlertsEnabled: true, - }); - await advanceTime({ clock, duration: 1 }); - expect(detectNfts.callCount).toBe(1); - }); + await advanceTime({ clock, duration: 1 }); + expect(detectNfts.callCount).toBe(1); + }, + ); }); }); @@ -1098,6 +1172,7 @@ type WithControllerOptions = { >; mockNetworkState?: Partial; mockPreferencesState?: Partial; + mockGetSelectedAccount?: jest.Mock; }; type WithControllerArgs = @@ -1122,6 +1197,9 @@ async function withController( mockNetworkClientConfigurationsByNetworkClientId = {}, mockNetworkState = {}, mockPreferencesState = {}, + mockGetSelectedAccount = jest + .fn() + .mockReturnValue(defaultSelectedAccount), }, testFunction, ] = args.length === 2 ? args : [{}, args[0]]; @@ -1136,6 +1214,11 @@ async function withController( }), ); + messenger.registerActionHandler( + 'AccountsController:getSelectedAccount', + mockGetSelectedAccount, + ); + const getNetworkClientById = buildMockGetNetworkClientById( mockNetworkClientConfigurationsByNetworkClientId, ); @@ -1158,6 +1241,7 @@ async function withController( 'NetworkController:getState', 'NetworkController:getNetworkClientById', 'PreferencesController:getState', + 'AccountsController:getSelectedAccount', ], allowedEvents: [ 'NetworkController:stateChange', diff --git a/packages/assets-controllers/src/NftDetectionController.ts b/packages/assets-controllers/src/NftDetectionController.ts index 25f7fd6ef4..0754b890d7 100644 --- a/packages/assets-controllers/src/NftDetectionController.ts +++ b/packages/assets-controllers/src/NftDetectionController.ts @@ -1,3 +1,4 @@ +import type { AccountsControllerGetSelectedAccountAction } from '@metamask/accounts-controller'; import type { AddApprovalRequest } from '@metamask/approval-controller'; import type { RestrictedControllerMessenger } from '@metamask/base-controller'; import { @@ -37,7 +38,8 @@ export type AllowedActions = | AddApprovalRequest | NetworkControllerGetStateAction | NetworkControllerGetNetworkClientByIdAction - | PreferencesControllerGetStateAction; + | PreferencesControllerGetStateAction + | AccountsControllerGetSelectedAccountAction; export type AllowedEvents = | PreferencesControllerStateChangeEvent @@ -542,8 +544,8 @@ export class NftDetectionController extends StaticIntervalPollingController< }) { const userAddress = options?.userAddress ?? - this.messagingSystem.call('PreferencesController:getState') - .selectedAddress; + this.messagingSystem.call('AccountsController:getSelectedAccount') + .address; /* istanbul ignore if */ if (!this.isMainnet() || this.#disabled) { return;