diff --git a/packages/assets-controllers/src/NftDetectionController.test.ts b/packages/assets-controllers/src/NftDetectionController.test.ts index 85c6588b8aa..62e3e9a0d46 100644 --- a/packages/assets-controllers/src/NftDetectionController.test.ts +++ b/packages/assets-controllers/src/NftDetectionController.test.ts @@ -51,7 +51,7 @@ describe('NftDetectionController', () => { nock(NFT_API_BASE_URL) .persist() .get( - `/users/0x1/tokens?chainIds=1&limit=50&includeTopBid=true&continuation=`, + `/users/0x1/tokens?chainIds=1&limit=50&includeTopBid=true&collection=0xCE7ec4B2DfB30eB6c0BB5656D33aAd6BFb4001Fc&collection=0x0B0fa4fF58D28A88d63235bd0756EDca69e49e6d&collection=0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD&continuation=`, ) .reply(200, { tokens: [ @@ -118,7 +118,7 @@ describe('NftDetectionController', () => { ], }) .get( - `/users/0x9/tokens?chainIds=1&limit=50&includeTopBid=true&continuation=`, + `/users/0x9/tokens?chainIds=1&limit=50&includeTopBid=true&collection=0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD&continuation=`, ) .reply(200, { tokens: [ @@ -142,7 +142,7 @@ describe('NftDetectionController', () => { ], }) .get( - `/users/0x123/tokens?chainIds=1&limit=50&includeTopBid=true&continuation=`, + `/users/0x123/tokens?chainIds=1&limit=50&includeTopBid=true&collection=0xtest1&collection=0xtest2&continuation=`, ) .reply(200, { tokens: [ @@ -161,6 +161,7 @@ describe('NftDetectionController', () => { }, isSpam: false, collection: { + openseaVerificationStatus: 'verified', id: '0xtest1', }, }, @@ -185,6 +186,7 @@ describe('NftDetectionController', () => { }, isSpam: false, collection: { + openseaVerificationStatus: 'verified', id: '0xtest2', }, }, @@ -308,6 +310,99 @@ describe('NftDetectionController', () => { }, }, ], + }) + .get( + `/users/Oxuser/collections?chainId=1&limit=20&includeTopBid=true&offset=0`, + ) + .reply(200, { + collections: [ + { + collection: { + id: '0xtest1', + slug: '', + name: '', + image: null, + isSpam: false, + tokenCount: '2', + primaryContract: '0xtest1', + rank: { + '1day': null, + '7day': null, + '30day': null, + allTime: null, + }, + volume: { + '1day': 0, + '7day': 0, + '30day': 0, + allTime: 0, + }, + volumeChange: { + '1day': null, + '7day': null, + '30day': null, + }, + floorSale: { + '1day': null, + '7day': null, + '30day': null, + }, + contractKind: 'erc721', + }, + ownership: { + tokenCount: '1', + totalValue: 0, + }, + }, + { + collection: { + id: '0xtest2', + slug: '', + name: '', + image: null, + isSpam: false, + tokenCount: '2', + primaryContract: '0xtest2', + rank: { + '1day': null, + '7day': null, + '30day': null, + allTime: null, + }, + volume: { + '1day': 0, + '7day': 0, + '30day': 0, + allTime: 0, + }, + volumeChange: { + '1day': null, + '7day': null, + '30day': null, + }, + floorSale: { + '1day': null, + '7day': null, + '30day': null, + }, + contractKind: 'erc721', + }, + ownership: { + tokenCount: '1', + totalValue: 0, + }, + }, + ], + }) + .get( + `/users/Oxuser/collections?chainId=1&limit=20&includeTopBid=true&offset=20`, + ) + .reply(200, { + collections: [], + }) + .get(`/collections?contract=0xtest1&contract=0xtest2&chainId=1`) + .reply(200, { + collections: [], }); }); @@ -410,6 +505,121 @@ describe('NftDetectionController', () => { selectedAddress, }); // nock + const mockApiCallUserCollections = nock(NFT_API_BASE_URL) + .get(`/users/${selectedAddress}/collections`) + .query({ + chainId: '59144', + limit: '20', + includeTopBid: true, + offset: '0', + }) + .reply(200, { + collections: [ + { + collection: { + id: '0x8bec24c57d944779417ab93c6e745ccf56e47225', + slug: '', + name: '', + image: null, + isSpam: false, + tokenCount: '2', + primaryContract: '0x8bec24c57d944779417ab93c6e745ccf56e47225', + tokenSetId: + 'contract:0x8bec24c57d944779417ab93c6e745ccf56e47225', + rank: { + '1day': null, + '7day': null, + '30day': null, + allTime: null, + }, + volume: { + '1day': 0, + '7day': 0, + '30day': 0, + allTime: 0, + }, + volumeChange: { + '1day': null, + '7day': null, + '30day': null, + }, + floorSale: { + '1day': null, + '7day': null, + '30day': null, + }, + contractKind: 'erc721', + }, + ownership: { + tokenCount: '1', + totalValue: 0, + }, + }, + ], + }); + const mockApiCallUserCollections2 = nock(NFT_API_BASE_URL) + .get(`/users/${selectedAddress}/collections`) + .query({ + chainId: '59144', + limit: '20', + includeTopBid: true, + offset: '20', + }) + .reply(200, { + collections: [], + }); + + const mockApiCallCollections = nock(NFT_API_BASE_URL) + .get(`/collections`) + .query({ + chainId: '59144', + contract: '0x8bec24c57d944779417ab93c6e745ccf56e47225', + }) + .reply(200, { + collections: [ + { + collection: { + id: '0x8bec24c57d944779417ab93c6e745ccf56e47225', + slug: '', + name: '', + image: null, + isSpam: false, + tokenCount: '2', + primaryContract: '0x8bec24c57d944779417ab93c6e745ccf56e47225', + tokenSetId: + 'contract:0x8bec24c57d944779417ab93c6e745ccf56e47225', + rank: { + '1day': null, + '7day': null, + '30day': null, + allTime: null, + }, + volume: { + '1day': 0, + '7day': 0, + '30day': 0, + allTime: 0, + }, + volumeChange: { + '1day': null, + '7day': null, + '30day': null, + }, + floorSale: { + '1day': null, + '7day': null, + '30day': null, + }, + contractKind: 'erc721', + }, + ownership: { + tokenCount: '1', + totalValue: 0, + }, + }, + ], + }); + const mockApiCall = nock(NFT_API_BASE_URL) .get(`/users/${selectedAddress}/tokens`) .query({ @@ -417,6 +627,7 @@ describe('NftDetectionController', () => { limit: '50', chainIds: '59144', includeTopBid: true, + collection: '0x8bec24c57d944779417ab93c6e745ccf56e47225', }) .reply(200, { tokens: [], @@ -426,6 +637,9 @@ describe('NftDetectionController', () => { await controller.detectNfts(); expect(mockApiCall.isDone()).toBe(true); + expect(mockApiCallUserCollections.isDone()).toBe(true); + expect(mockApiCallUserCollections2.isDone()).toBe(true); + expect(mockApiCallCollections.isDone()).toBe(true); }, ); }); @@ -488,62 +702,6 @@ 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: {}, - mockGetSelectedAccount, - }, - async ({ controller, controllerEvents }) => { - controllerEvents.triggerPreferencesStateChange({ - ...getDefaultPreferencesState(), - useNftDetection: true, - }); - - // Mock /getCollections call - - nock(NFT_API_BASE_URL) - .get( - `/collections?contract=0xCE7ec4B2DfB30eB6c0BB5656D33aAd6BFb4001Fc&contract=0x0B0fa4fF58D28A88d63235bd0756EDca69e49e6d&contract=0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD&chainId=1`, - ) - .replyWithError(new Error('Failed to fetch')); - - // Wait for detect call triggered by preferences state change to settle - await advanceTime({ - clock, - duration: 1, - }); - mockAddNft.mockReset(); - - await controller.detectNfts(); - - expect(mockAddNft).toHaveBeenCalledWith( - '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', - '2574', - { - nftMetadata: { - description: 'Description 2574', - image: 'image/2574.png', - name: 'ID 2574', - standard: 'ERC721', - imageOriginal: 'imageOriginal/2574.png', - }, - userAddress: selectedAccount.address, - source: Source.Detected, - networkClientId: undefined, - }, - ); - }, - ); - }); - describe('getCollections', () => { it('should not call getCollections api when collection ids do not match contract address', async () => { const mockAddNft = jest.fn(); @@ -571,7 +729,7 @@ describe('NftDetectionController', () => { mockAddNft.mockReset(); nock(NFT_API_BASE_URL) .get( - `/users/${selectedAddress}/tokens?chainIds=1&limit=50&includeTopBid=true&continuation=`, + `/users/${selectedAddress}/tokens?chainIds=1&limit=50&includeTopBid=true&collection=0xtest1&collection=0xtest2&continuation=`, ) .reply(200, { tokens: [ @@ -669,9 +827,10 @@ describe('NftDetectionController', () => { }, ); }); - it('should detect and add NFTs correctly when blockaid result is in response with unsuccessful getCollections', async () => { + it('should detect and add NFTs correctly when getCollections call is unsuccessful', async () => { const mockAddNft = jest.fn(); const selectedAddress = '0x123'; + const selectedAccount = createMockInternalAccount({ address: selectedAddress, }); @@ -694,6 +853,109 @@ describe('NftDetectionController', () => { }); mockAddNft.mockReset(); + // Nock getUserNfts + nock(NFT_API_BASE_URL) + .get( + `/users/${selectedAddress}/tokens?chainIds=1&limit=50&includeTopBid=true&collection=0xtest1&collection=0xtest2&continuation=`, + ) + .reply(200, { + tokens: [ + { + token: { + contract: '0xtest1', + kind: 'erc721', + name: 'ID 2574', + description: 'Description 2574', + image: 'image/2574.png', + tokenId: '2574', + metadata: { + imageOriginal: 'imageOriginal/2574.png', + imageMimeType: 'image/png', + tokenURI: 'tokenURITest', + }, + isSpam: false, + collection: { + openseaVerificationStatus: 'verified', + id: '0xtest1', + }, + }, + }, + { + token: { + contract: '0xtest2', + kind: 'erc721', + name: 'ID 2575', + description: 'Description 2575', + image: 'image/2575.png', + tokenId: '2575', + metadata: { + imageOriginal: 'imageOriginal/2575.png', + imageMimeType: 'image/png', + tokenURI: 'tokenURITest', + }, + isSpam: false, + collection: { + openseaVerificationStatus: 'verified', + id: '0xtest2', + }, + }, + }, + ], + }); + + // Nock successful getUserCollections api call + nock(NFT_API_BASE_URL) + .get( + `/users/0x123/collections?chainId=1&limit=20&includeTopBid=true&offset=0`, + ) + .reply(200, { + collections: [ + { + collection: { + id: '0xtest1', + slug: '', + name: '', + image: null, + isSpam: false, + tokenCount: '2', + primaryContract: '0xtest1', + + contractKind: 'erc721', + }, + ownership: { + tokenCount: '1', + totalValue: 0, + }, + }, + { + collection: { + id: '0xtest2', + slug: '', + name: '', + image: null, + isSpam: false, + tokenCount: '2', + primaryContract: '0xtest2', + + contractKind: 'erc721', + }, + ownership: { + tokenCount: '1', + totalValue: 0, + }, + }, + ], + }); + + nock(NFT_API_BASE_URL) + .get( + `/users/0x123/collections?chainId=1&limit=20&includeTopBid=true&offset=20`, + ) + .reply(200, { + collections: [], + }); + + // Nock failed getCollections api call nock(NFT_API_BASE_URL) .get(`/collections?contract=0xtest1&contract=0xtest2&chainId=1`) .replyWithError(new Error('Failed to fetch')); @@ -710,6 +972,7 @@ describe('NftDetectionController', () => { imageOriginal: 'imageOriginal/2574.png', collection: { id: '0xtest1', + openseaVerificationStatus: 'verified', }, }, userAddress: selectedAccount.address, @@ -725,6 +988,7 @@ describe('NftDetectionController', () => { imageOriginal: 'imageOriginal/2575.png', collection: { id: '0xtest2', + openseaVerificationStatus: 'verified', }, }, userAddress: selectedAccount.address, @@ -734,8 +998,9 @@ describe('NftDetectionController', () => { }, ); }); - it('should detect and add NFTs correctly when blockaid result is in response with successful getCollections', async () => { + it('should detect and add NFTs correctly when getCollections call is successful', async () => { const mockAddNft = jest.fn(); + nock.cleanAll(); const selectedAddress = '0x123'; const selectedAccount = createMockInternalAccount({ address: selectedAddress, @@ -759,6 +1024,149 @@ describe('NftDetectionController', () => { }); mockAddNft.mockReset(); + // Nock getUserNfts + nock(NFT_API_BASE_URL) + .get( + `/users/${selectedAddress}/tokens?chainIds=1&limit=50&includeTopBid=true&collection=0xtest1&collection=0xtest2&continuation=`, + ) + .reply(200, { + tokens: [ + { + token: { + contract: '0xtest1', + kind: 'erc721', + name: 'ID 2574', + description: 'Description 2574', + image: 'image/2574.png', + tokenId: '2574', + metadata: { + imageOriginal: 'imageOriginal/2574.png', + imageMimeType: 'image/png', + tokenURI: 'tokenURITest', + }, + isSpam: false, + collection: { + id: '0xtest1', + openseaVerificationStatus: 'verified', + }, + }, + }, + { + token: { + contract: '0xtest2', + kind: 'erc721', + name: 'ID 2575', + description: 'Description 2575', + image: 'image/2575.png', + tokenId: '2575', + metadata: { + imageOriginal: 'imageOriginal/2575.png', + imageMimeType: 'image/png', + tokenURI: 'tokenURITest', + }, + isSpam: false, + collection: { + id: '0xtest2', + openseaVerificationStatus: 'verified', + }, + }, + }, + ], + }); + + // Nock successful getUserCollections api call + nock(NFT_API_BASE_URL) + .get( + `/users/0x123/collections?chainId=1&limit=20&includeTopBid=true&offset=0`, + ) + .reply(200, { + collections: [ + { + collection: { + id: '0xtest1', + slug: '', + name: '', + image: null, + isSpam: false, + tokenCount: '2', + primaryContract: '0xtest1', + rank: { + '1day': null, + '7day': null, + '30day': null, + allTime: null, + }, + volume: { + '1day': 0, + '7day': 0, + '30day': 0, + allTime: 0, + }, + volumeChange: { + '1day': null, + '7day': null, + '30day': null, + }, + floorSale: { + '1day': null, + '7day': null, + '30day': null, + }, + contractKind: 'erc721', + }, + ownership: { + tokenCount: '1', + totalValue: 0, + }, + }, + { + collection: { + id: '0xtest2', + slug: '', + name: '', + image: null, + isSpam: false, + tokenCount: '2', + primaryContract: '0xtest2', + rank: { + '1day': null, + '7day': null, + '30day': null, + allTime: null, + }, + volume: { + '1day': 0, + '7day': 0, + '30day': 0, + allTime: 0, + }, + volumeChange: { + '1day': null, + '7day': null, + '30day': null, + }, + floorSale: { + '1day': null, + '7day': null, + '30day': null, + }, + contractKind: 'erc721', + }, + ownership: { + tokenCount: '1', + totalValue: 0, + }, + }, + ], + }); + nock(NFT_API_BASE_URL) + .get( + `/users/0x123/collections?chainId=1&limit=20&includeTopBid=true&offset=20`, + ) + .reply(200, { + collections: [], + }); + const testTopBid = { id: 'id', sourceDomain: 'opensea.io', @@ -817,11 +1225,10 @@ describe('NftDetectionController', () => { imageOriginal: 'imageOriginal/2574.png', collection: { id: '0xtest1', - contractDeployedAt: undefined, + creator: '0xcreator1', openseaVerificationStatus: 'verified', - ownerCount: undefined, - tokenCount: undefined, + topBid: testTopBid, }, }, @@ -877,6 +1284,117 @@ describe('NftDetectionController', () => { }); mockAddNft.mockReset(); + // Nock successful getUserCollections api call + nock(NFT_API_BASE_URL) + .get( + `/users/${selectedAddress}/collections?chainId=1&limit=20&includeTopBid=true&offset=0`, + ) + .reply(200, { + collections: [ + { + collection: { + id: '0xtestCollection1', + slug: '', + name: '', + image: null, + isSpam: false, + tokenCount: '2', + primaryContract: '0xtestCollection1', + + floorSale: { + '1day': null, + '7day': null, + '30day': null, + }, + contractKind: 'erc721', + }, + ownership: { + tokenCount: '1', + totalValue: 0, + }, + }, + { + collection: { + id: '0xtestCollection2', + slug: '', + name: '', + image: null, + isSpam: false, + tokenCount: '2', + primaryContract: '0xtestCollection2', + + floorSale: { + '1day': null, + '7day': null, + '30day': null, + }, + contractKind: 'erc721', + }, + ownership: { + tokenCount: '1', + totalValue: 0, + }, + }, + ], + }); + nock(NFT_API_BASE_URL) + .get( + `/users/${selectedAddress}/collections?chainId=1&limit=20&includeTopBid=true&offset=20`, + ) + .reply(200, { + collections: [], + }); + + // Nock getUserNfts + nock(NFT_API_BASE_URL) + .get( + `/users/${selectedAddress}/tokens?chainIds=1&limit=50&includeTopBid=true&collection=0xtestCollection1&collection=0xtestCollection2&continuation=`, + ) + .reply(200, { + tokens: [ + { + token: { + contract: '0xtestCollection1', + kind: 'erc721', + name: 'ID 1', + description: 'Description 1', + image: 'image/1.png', + tokenId: '1', + metadata: { + imageOriginal: 'imageOriginal/1.png', + imageMimeType: 'image/png', + tokenURI: 'tokenURITest', + }, + isSpam: false, + collection: { + id: '0xtestCollection1', + openseaVerificationStatus: 'verified', + }, + }, + }, + { + token: { + contract: '0xtestCollection2', + kind: 'erc721', + name: 'ID 2', + description: 'Description 2', + image: 'image/2.png', + tokenId: '2', + metadata: { + imageOriginal: 'imageOriginal/2.png', + imageMimeType: 'image/png', + tokenURI: 'tokenURITest', + }, + isSpam: false, + collection: { + id: '0xtestCollection2', + openseaVerificationStatus: 'verified', + }, + }, + }, + ], + }); + nock(NFT_API_BASE_URL) .get( `/collections?contract=0xtestCollection1&contract=0xtestCollection2&chainId=1`, @@ -958,6 +1476,7 @@ describe('NftDetectionController', () => { it('should detect and add NFTs from a single collection', async () => { const mockAddNft = jest.fn(); const selectedAddress = 'Oxuser'; + nock.cleanAll(); const selectedAccount = createMockInternalAccount({ address: selectedAddress, }); @@ -979,9 +1498,49 @@ describe('NftDetectionController', () => { duration: 1, }); mockAddNft.mockReset(); + + // Nock successful getUserCollections api call nock(NFT_API_BASE_URL) .get( - `/users/${selectedAddress}/tokens?chainIds=1&limit=50&includeTopBid=true&continuation=`, + `/users/${selectedAddress}/collections?chainId=1&limit=20&includeTopBid=true&offset=0`, + ) + .reply(200, { + collections: [ + { + collection: { + id: '0xtestCollection1', + slug: '', + name: '', + image: null, + isSpam: false, + tokenCount: '2', + primaryContract: '0xtestCollection1', + + floorSale: { + '1day': null, + '7day': null, + '30day': null, + }, + contractKind: 'erc721', + }, + ownership: { + tokenCount: '1', + totalValue: 0, + }, + }, + ], + }); + nock(NFT_API_BASE_URL) + .get( + `/users/${selectedAddress}/collections?chainId=1&limit=20&includeTopBid=true&offset=20`, + ) + .reply(200, { + collections: [], + }); + + nock(NFT_API_BASE_URL) + .get( + `/users/${selectedAddress}/tokens?chainIds=1&limit=50&includeTopBid=true&collection=0xtestCollection1&continuation=`, ) .reply(200, { tokens: [ @@ -1000,14 +1559,10 @@ describe('NftDetectionController', () => { }, isSpam: false, collection: { + openseaVerificationStatus: 'verified', id: '0xtestCollection1', }, }, - blockaidResult: { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - result_type: BlockaidResultType.Benign, - }, }, { token: { @@ -1024,6 +1579,7 @@ describe('NftDetectionController', () => { }, isSpam: false, collection: { + openseaVerificationStatus: 'verified', id: '0xtestCollection1', }, }, @@ -1062,11 +1618,9 @@ describe('NftDetectionController', () => { imageOriginal: 'imageOriginal/1.png', collection: { id: '0xtestCollection1', - contractDeployedAt: undefined, creator: '0xcreator1', openseaVerificationStatus: 'verified', ownerCount: '555', - tokenCount: undefined, }, }, userAddress: selectedAccount.address, @@ -1087,11 +1641,10 @@ describe('NftDetectionController', () => { imageOriginal: 'imageOriginal/2.png', collection: { id: '0xtestCollection1', - contractDeployedAt: undefined, + creator: '0xcreator1', openseaVerificationStatus: 'verified', ownerCount: '555', - tokenCount: undefined, }, }, userAddress: selectedAccount.address, @@ -1133,6 +1686,55 @@ describe('NftDetectionController', () => { }); mockAddNft.mockReset(); + // Nock successful getUserCollections api call + nock(NFT_API_BASE_URL) + .get( + `/users/${selectedAddress}/collections?chainId=1&limit=20&includeTopBid=true&offset=0`, + ) + .reply(200, { + collections: [ + { + collection: { + id: '0xtest1', + slug: '', + name: '', + image: null, + isSpam: false, + tokenCount: '2', + primaryContract: '0xtest1', + contractKind: 'erc721', + }, + ownership: { + tokenCount: '1', + totalValue: 0, + }, + }, + { + collection: { + id: '0xtest2', + slug: '', + name: '', + image: null, + isSpam: false, + tokenCount: '2', + primaryContract: '0xtest2', + contractKind: 'erc721', + }, + ownership: { + tokenCount: '1', + totalValue: 0, + }, + }, + ], + }); + nock(NFT_API_BASE_URL) + .get( + `/users/${selectedAddress}/collections?chainId=1&limit=20&includeTopBid=true&offset=20`, + ) + .reply(200, { + collections: [], + }); + nock(NFT_API_BASE_URL) .get(`/collections?contract=0xtest1&chainId=1`) .reply(200, { @@ -1180,6 +1782,7 @@ describe('NftDetectionController', () => { standard: 'ERC721', imageOriginal: 'imageOriginal/2575.png', collection: { + openseaVerificationStatus: 'verified', id: '0xtest2', }, }, @@ -1223,6 +1826,46 @@ describe('NftDetectionController', () => { duration: 1, }); mockAddNft.mockReset(); + + // Nock successful getUserCollections api call + nock(NFT_API_BASE_URL) + .get( + `/users/0x9/collections?chainId=1&limit=20&includeTopBid=true&offset=0`, + ) + .reply(200, { + collections: [ + { + collection: { + id: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', + slug: '', + name: '', + image: null, + isSpam: false, + tokenCount: '2', + primaryContract: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', + + floorSale: { + '1day': null, + '7day': null, + '30day': null, + }, + contractKind: 'erc721', + }, + ownership: { + tokenCount: '1', + totalValue: 0, + }, + }, + ], + }); + nock(NFT_API_BASE_URL) + .get( + `/users/0x9/collections?chainId=1&limit=20&includeTopBid=true&offset=20`, + ) + .reply(200, { + collections: [], + }); + nock(NFT_API_BASE_URL) .get( `/collections?contract=0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD&chainId=1`, @@ -1294,6 +1937,45 @@ describe('NftDetectionController', () => { }); mockAddNft.mockReset(); + // Nock successful getUserCollections api call + nock(NFT_API_BASE_URL) + .get( + `/users/0x9/collections?chainId=1&limit=20&includeTopBid=true&offset=0`, + ) + .reply(200, { + collections: [ + { + collection: { + id: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', + slug: '', + name: '', + image: null, + isSpam: false, + tokenCount: '2', + primaryContract: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', + + floorSale: { + '1day': null, + '7day': null, + '30day': null, + }, + contractKind: 'erc721', + }, + ownership: { + tokenCount: '1', + totalValue: 0, + }, + }, + ], + }); + nock(NFT_API_BASE_URL) + .get( + `/users/0x9/collections?chainId=1&limit=20&includeTopBid=true&offset=20`, + ) + .reply(200, { + collections: [], + }); + nock(NFT_API_BASE_URL) .get( `/collections?contract=0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD&chainId=1`, @@ -1469,6 +2151,51 @@ describe('NftDetectionController', () => { clock, duration: 1, }); + // Nock successful getUserCollections api call + nock(NFT_API_BASE_URL) + .get( + `/users/${selectedAddress}/collections?chainId=1&limit=20&includeTopBid=true&offset=0`, + ) + .reply(200, { + collections: [ + { + collection: { + id: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', + slug: '', + name: '', + image: null, + isSpam: false, + tokenCount: '2', + primaryContract: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', + + floorSale: { + '1day': null, + '7day': null, + '30day': null, + }, + contractKind: 'erc721', + }, + ownership: { + tokenCount: '1', + totalValue: 0, + }, + }, + ], + }); + nock(NFT_API_BASE_URL) + .get( + `/users/${selectedAddress}/collections?chainId=1&limit=20&includeTopBid=true&offset=20`, + ) + .reply(200, { + collections: [], + }); + + nock(NFT_API_BASE_URL) + .get( + `/collections?contract=0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD&chainId=1`, + ) + .replyWithError(new Error('Failed to fetch')); + // This mock is for the call under test nock(NFT_API_BASE_URL) .get(`/users/${selectedAddress}/tokens`) @@ -1477,6 +2204,7 @@ describe('NftDetectionController', () => { limit: '50', chainIds: '1', includeTopBid: true, + collection: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', }) .replyWithError(new Error('UNEXPECTED ERROR')); @@ -1514,6 +2242,88 @@ describe('NftDetectionController', () => { mockAddNft.mockReset(); mockAddNft.mockRejectedValueOnce(new Error('UNEXPECTED ERROR')); + nock(NFT_API_BASE_URL) + .get( + `/users/${selectedAddress}/collections?chainId=1&limit=20&includeTopBid=true&offset=0`, + ) + .reply(200, { + collections: [ + { + collection: { + id: '0xCE7ec4B2DfB30eB6c0BB5656D33aAd6BFb4001Fc', + slug: '', + name: '', + image: null, + isSpam: false, + tokenCount: '2', + primaryContract: '0xCE7ec4B2DfB30eB6c0BB5656D33aAd6BFb4001Fc', + + floorSale: { + '1day': null, + '7day': null, + '30day': null, + }, + contractKind: 'erc721', + }, + ownership: { + tokenCount: '1', + totalValue: 0, + }, + }, + { + collection: { + id: '0x0B0fa4fF58D28A88d63235bd0756EDca69e49e6d', + slug: '', + name: '', + image: null, + isSpam: false, + tokenCount: '2', + primaryContract: '0x0B0fa4fF58D28A88d63235bd0756EDca69e49e6d', + + floorSale: { + '1day': null, + '7day': null, + '30day': null, + }, + contractKind: 'erc721', + }, + ownership: { + tokenCount: '1', + totalValue: 0, + }, + }, + { + collection: { + id: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', + slug: '', + name: '', + image: null, + isSpam: false, + tokenCount: '2', + primaryContract: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', + + floorSale: { + '1day': null, + '7day': null, + '30day': null, + }, + contractKind: 'erc721', + }, + ownership: { + tokenCount: '1', + totalValue: 0, + }, + }, + ], + }); + nock(NFT_API_BASE_URL) + .get( + `/users/${selectedAddress}/collections?chainId=1&limit=20&includeTopBid=true&offset=20`, + ) + .reply(200, { + collections: [], + }); + nock(NFT_API_BASE_URL) .get( `/collections?contract=0xCE7ec4B2DfB30eB6c0BB5656D33aAd6BFb4001Fc&contract=0x0B0fa4fF58D28A88d63235bd0756EDca69e49e6d&contract=0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD&chainId=1`, @@ -1582,13 +2392,52 @@ describe('NftDetectionController', () => { useNftDetection: true, }); + nock(NFT_API_BASE_URL) + .get( + `/users/${selectedAddress}/collections?chainId=1&limit=20&includeTopBid=true&offset=0`, + ) + .reply(200, { + collections: [ + { + collection: { + id: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', + slug: '', + name: '', + image: null, + isSpam: false, + tokenCount: '2', + primaryContract: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', + + floorSale: { + '1day': null, + '7day': null, + '30day': null, + }, + contractKind: 'erc721', + }, + ownership: { + tokenCount: '1', + totalValue: 0, + }, + }, + ], + }); + nock(NFT_API_BASE_URL) + .get( + `/users/${selectedAddress}/collections?chainId=1&limit=20&includeTopBid=true&offset=20`, + ) + .reply(200, { + collections: [], + }); + nock(NFT_API_BASE_URL) .get( `/collections?contract=0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD&chainId=1`, ) - .replyWithError(new Error('Failed to fetch')); + .reply(200, { + collections: [], + }); await Promise.all([controller.detectNfts(), controller.detectNfts()]); - expect(mockAddNft).toHaveBeenCalledTimes(1); }, ); diff --git a/packages/assets-controllers/src/NftDetectionController.ts b/packages/assets-controllers/src/NftDetectionController.ts index e1ed6f83b8c..53a97e1e71c 100644 --- a/packages/assets-controllers/src/NftDetectionController.ts +++ b/packages/assets-controllers/src/NftDetectionController.ts @@ -340,6 +340,60 @@ export type GetCollectionsResponse = { collections: CollectionResponse[]; }; +export type UserCollectionResponse = { + collection: { + id: string; + slug: string; + name: string; + image: string; + isSpam: boolean; + banner: string; + + discordUrl: string; + externalUrl: string; + twitterUsername: string; + openseaVerificationStatus: string; + description: string; + metadataDisabled: boolean; + sampleImages: string[]; + tokenCount: string; + primaryContract: string; + tokenSetId: string; + floorAskPrice: Price | null; + rank: { + '1day': null | number; + '7day': null | number; + '30day': null | number; + allTime: null | number; + }; + volume: { + '1day': number; + '7day': number; + '30day': number; + allTime: number; + }; + volumeChange: { + '1day': number; + '7day': number; + '30day': number; + }; + floorSale: { + '1day': number; + '7day': number; + '30day': number; + }; + contractKind: string; + }; + ownership: { + tokenCount: string; + totalValue: number; + }; +}; + +export type GetUserCollectionsResponse = { + collections: UserCollectionResponse[]; +}; + export type CollectionResponse = { id?: string; openseaVerificationStatus?: string; @@ -535,22 +589,45 @@ export class NftDetectionController extends BaseController< #getOwnerNftApi({ chainId, address, + collectionsArr, next, }: { chainId: string; address: string; + collectionsArr: string[]; next?: string; }) { + const collectionArrString = collectionsArr + ?.map((s) => `collection=${s}`) + .join('&'); + return `${ NFT_API_BASE_URL as string - }/users/${address}/tokens?chainIds=${chainId}&limit=50&includeTopBid=true&continuation=${ + }/users/${address}/tokens?chainIds=${chainId}&limit=50&includeTopBid=true&${collectionArrString}&continuation=${ next ?? '' }`; } + #getOwnerCollectionsApi({ + chainId, + address, + limit, + offset, + }: { + chainId: string; + address: string; + limit: number; + offset: number; + }) { + return `${ + NFT_API_BASE_URL as string + }/users/${address}/collections?chainId=${chainId}&limit=${limit}&includeTopBid=true&offset=${offset}`; + } + async #getOwnerNfts( address: string, chainId: Hex, + collectionsArr: string[], cursor: string | undefined, ) { // Convert hex chainId to number @@ -558,6 +635,7 @@ export class NftDetectionController extends BaseController< const url = this.#getOwnerNftApi({ chainId: convertedChainId, address, + collectionsArr, next: cursor, }); const nftApiResponse: ReservoirResponse = await handleFetch(url, { @@ -568,6 +646,28 @@ export class NftDetectionController extends BaseController< return nftApiResponse; } + async #getOwnerCollections( + address: string, + chainId: Hex, + limit: number, + offset: number, + ) { + // Convert hex chainId to number + const convertedChainId = convertHexToDecimal(chainId).toString(); + const url = this.#getOwnerCollectionsApi({ + chainId: convertedChainId, + address, + limit, + offset, + }); + const nftApiResponse = await handleFetch(url, { + headers: { + Version: NFT_API_VERSION, + }, + }); + return nftApiResponse; + } + /** * Triggers asset ERC721 token auto detection on mainnet. Any newly detected NFTs are * added. @@ -621,66 +721,83 @@ export class NftDetectionController extends BaseController< } = createDeferredPromise({ suppressUnhandledRejection: true }); this.#inProcessNftFetchingUpdates[updateKey] = inProgressUpdate; - let next; let apiNfts: TokensResponse[] = []; - let resultNftApi: ReservoirResponse; + let resultNftApiCollection: GetUserCollectionsResponse; + let offset = 0; + try { + const LIMIT_COLLECTIONS = 20; + // We will fetch the first page of user collections + do { - resultNftApi = await this.#getOwnerNfts(userAddress, chainId, next); - apiNfts = resultNftApi.tokens.filter( - (elm) => - elm.token.isSpam === false && - (elm.blockaidResult?.result_type - ? elm.blockaidResult?.result_type === BlockaidResultType.Benign - : true), + resultNftApiCollection = await this.#getOwnerCollections( + userAddress, + chainId, + LIMIT_COLLECTIONS, + offset, ); - // Retrieve collections from apiNfts - // contract and collection.id are equal for simple contract addresses; this is to exclude cases for shared contracts - const collections = apiNfts.reduce((acc, currValue) => { - if ( - !acc.includes(currValue.token.contract) && - currValue.token.contract === currValue?.token?.collection?.id - ) { - acc.push(currValue.token.contract); - } - return acc; - }, []); - - if (collections.length !== 0) { - // Call API to retrive collections infos - // The api accept a max of 20 contracts - const collectionResponse: GetCollectionsResponse = - await reduceInBatchesSerially({ - values: collections, - batchSize: MAX_GET_COLLECTION_BATCH_SIZE, - eachBatch: async (allResponses, batch) => { - const params = new URLSearchParams( - batch.map((s) => ['contract', s]), - ); - params.append('chainId', '1'); // Adding chainId 1 because we are only detecting for mainnet - const collectionResponseForBatch = await fetchWithErrorHandling( - { - url: `${ - NFT_API_BASE_URL as string - }/collections?${params.toString()}`, - options: { - headers: { - Version: NFT_API_VERSION, - }, - }, - timeout: NFT_API_TIMEOUT, - }, - ); - return { - ...allResponses, - ...collectionResponseForBatch, - }; - }, - initialResult: {}, - }); + if (resultNftApiCollection.collections.length === 0) { + break; + } - // Add collections response fields to newnfts + // fetch NFTs for the retrieved collections + const contractAddressIds: string[] = + resultNftApiCollection.collections.map( + (collectionItem) => collectionItem.collection.id, + ); + + const contractAddressArr: string[] = + resultNftApiCollection.collections.map( + (collectionItem) => collectionItem.collection.primaryContract, + ); + + // The api accept a max of 20 contracts + const convertedChainId = convertHexToDecimal(chainId).toString(); + const collectionResponse: GetCollectionsResponse = + await reduceInBatchesSerially({ + values: contractAddressArr, + batchSize: MAX_GET_COLLECTION_BATCH_SIZE, + eachBatch: async (allResponses, batch) => { + const params = new URLSearchParams( + batch.map((s) => ['contract', s]), + ); + params.append('chainId', convertedChainId); + const collectionResponseForBatch = await fetchWithErrorHandling({ + url: `${ + NFT_API_BASE_URL as string + }/collections?${params.toString()}`, + options: { + headers: { + Version: NFT_API_VERSION, + }, + }, + timeout: NFT_API_TIMEOUT, + }); + + return { + ...allResponses, + ...collectionResponseForBatch, + }; + }, + initialResult: {}, + }); + let nftsResult: ReservoirResponse; + let nextNftResult; + do { + // fetch all nft for retrieved collections + nftsResult = await this.#getOwnerNfts( + userAddress, + chainId, + contractAddressIds, + nextNftResult, + ); + + apiNfts = nftsResult.tokens.filter( + (elm) => elm.token.isSpam === false, + ); + + // fill in missing collection info if (collectionResponse.collections?.length) { apiNfts.forEach((singleNFT) => { const found = collectionResponse.collections.find( @@ -688,14 +805,14 @@ export class NftDetectionController extends BaseController< elm.id?.toLowerCase() === singleNFT.token.contract.toLowerCase(), ); + if (found) { singleNFT.token = { ...singleNFT.token, collection: { ...(singleNFT.token.collection ?? {}), creator: found?.creator, - openseaVerificationStatus: found?.openseaVerificationStatus, - contractDeployedAt: found.contractDeployedAt, + // contractDeployedAt: found.contractDeployedAt, ownerCount: found.ownerCount, topBid: found.topBid, }, @@ -703,68 +820,70 @@ export class NftDetectionController extends BaseController< } }); } - } - // Proceed to add NFTs - const addNftPromises = apiNfts.map(async (nft) => { - const { - tokenId, - contract, - kind, - image: imageUrl, - imageSmall: imageThumbnailUrl, - metadata: { imageOriginal: imageOriginalUrl } = {}, - name, - description, - attributes, - topBid, - lastSale, - rarityRank, - rarityScore, - collection, - } = nft.token; - - let ignored; - /* istanbul ignore else */ - const { ignoredNfts } = this.#getNftState(); - if (ignoredNfts.length) { - ignored = ignoredNfts.find((c) => { + // Proceed to add NFTs + const addNftPromises = apiNfts.map(async (nft) => { + const { + tokenId, + contract, + kind, + image: imageUrl, + imageSmall: imageThumbnailUrl, + metadata: { imageOriginal: imageOriginalUrl } = {}, + name, + description, + attributes, + topBid, + lastSale, + rarityRank, + rarityScore, + collection, + } = nft.token; + + let ignored; + /* istanbul ignore else */ + const { ignoredNfts } = this.#getNftState(); + if (ignoredNfts.length) { + ignored = ignoredNfts.find((c) => { + /* istanbul ignore next */ + return ( + c.address === toChecksumHexAddress(contract) && + c.tokenId === tokenId + ); + }); + } + + /* istanbul ignore else */ + if (!ignored) { /* istanbul ignore next */ - return ( - c.address === toChecksumHexAddress(contract) && - c.tokenId === tokenId + const nftMetadata: NftMetadata = Object.assign( + {}, + { name }, + description && { description }, + imageUrl && { image: imageUrl }, + imageThumbnailUrl && { imageThumbnail: imageThumbnailUrl }, + imageOriginalUrl && { imageOriginal: imageOriginalUrl }, + kind && { standard: kind.toUpperCase() }, + lastSale && { lastSale }, + attributes && { attributes }, + topBid && { topBid }, + rarityRank && { rarityRank }, + rarityScore && { rarityScore }, + collection && { collection }, ); - }); - } - - /* istanbul ignore else */ - if (!ignored) { - /* istanbul ignore next */ - const nftMetadata: NftMetadata = Object.assign( - {}, - { name }, - description && { description }, - imageUrl && { image: imageUrl }, - imageThumbnailUrl && { imageThumbnail: imageThumbnailUrl }, - imageOriginalUrl && { imageOriginal: imageOriginalUrl }, - kind && { standard: kind.toUpperCase() }, - lastSale && { lastSale }, - attributes && { attributes }, - topBid && { topBid }, - rarityRank && { rarityRank }, - rarityScore && { rarityScore }, - collection && { collection }, - ); - await this.#addNft(contract, tokenId, { - nftMetadata, - userAddress, - source: Source.Detected, - networkClientId: options?.networkClientId, - }); - } - }); - await Promise.all(addNftPromises); - } while ((next = resultNftApi.continuation)); + await this.#addNft(contract, tokenId, { + nftMetadata, + userAddress, + source: Source.Detected, + networkClientId: options?.networkClientId, + }); + } + }); + await Promise.all(addNftPromises); + } while ((nextNftResult = nftsResult.continuation)); + + offset += LIMIT_COLLECTIONS; + } while (resultNftApiCollection.collections.length !== 0); updateSucceeded(); } catch (error) { updateFailed(error);