From a87d2a0c12410a6a39a2fe9662f64cbe9a4ae2ea Mon Sep 17 00:00:00 2001 From: Gustavo Antunes Date: Thu, 28 Oct 2021 14:32:42 -0300 Subject: [PATCH] BREAKING: ERC1155 support (#615) Enhancement to support ERC1155 collectible standard by adding the smart contract ABI. --- package.json | 1 + src/ComposableController.test.ts | 18 ++ src/assets/AssetsContractController.test.ts | 108 ++++---- src/assets/AssetsContractController.ts | 238 ++++++++---------- src/assets/AssetsDetectionController.test.ts | 78 +++++- src/assets/AssetsDetectionController.ts | 26 +- .../ERC1155/ERC1155Standard.test.ts | 28 +++ .../ERC1155/ERC1155Standard.ts | 145 +++++++++++ .../ERC721/ERC721Standard.test.ts | 33 +++ .../ERC721/ERC721Standard.ts | 176 +++++++++++++ src/assets/CollectiblesController.test.ts | 176 +++++++++---- src/assets/CollectiblesController.ts | 155 ++++++++++-- src/assets/assetsUtil.test.ts | 6 +- src/constants.ts | 7 + src/dependencies.d.ts | 2 + yarn.lock | 5 + 16 files changed, 947 insertions(+), 255 deletions(-) create mode 100644 src/assets/CollectibleStandards/ERC1155/ERC1155Standard.test.ts create mode 100644 src/assets/CollectibleStandards/ERC1155/ERC1155Standard.ts create mode 100644 src/assets/CollectibleStandards/ERC721/ERC721Standard.test.ts create mode 100644 src/assets/CollectibleStandards/ERC721/ERC721Standard.ts diff --git a/package.json b/package.json index 282f2d9fa9..4788eb521b 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "ethjs-unit": "^0.1.6", "ethjs-util": "^0.1.6", "human-standard-collectible-abi": "^1.0.2", + "human-standard-multi-collectible-abi": "^1.0.2", "human-standard-token-abi": "^2.0.0", "immer": "^9.0.6", "isomorphic-fetch": "^3.0.0", diff --git a/src/ComposableController.test.ts b/src/ComposableController.test.ts index c7f335f022..ee30ad7d62 100644 --- a/src/ComposableController.test.ts +++ b/src/ComposableController.test.ts @@ -106,6 +106,15 @@ describe('ComposableController', () => { getCollectibleTokenURI: assetContractController.getCollectibleTokenURI.bind( assetContractController, ), + getOwnerOf: assetContractController.getOwnerOf.bind( + assetContractController, + ), + balanceOfERC1155Collectible: assetContractController.balanceOfERC1155Collectible.bind( + assetContractController, + ), + uriERC1155Collectible: assetContractController.uriERC1155Collectible.bind( + assetContractController, + ), }); const tokensController = new TokensController({ onPreferencesStateChange: (listener) => @@ -178,6 +187,15 @@ describe('ComposableController', () => { getCollectibleTokenURI: assetContractController.getCollectibleTokenURI.bind( assetContractController, ), + getOwnerOf: assetContractController.getOwnerOf.bind( + assetContractController, + ), + balanceOfERC1155Collectible: assetContractController.balanceOfERC1155Collectible.bind( + assetContractController, + ), + uriERC1155Collectible: assetContractController.uriERC1155Collectible.bind( + assetContractController, + ), }); const tokensController = new TokensController({ onPreferencesStateChange: (listener) => diff --git a/src/assets/AssetsContractController.test.ts b/src/assets/AssetsContractController.test.ts index 44d072d320..163271ce85 100644 --- a/src/assets/AssetsContractController.test.ts +++ b/src/assets/AssetsContractController.test.ts @@ -4,9 +4,16 @@ import { AssetsContractController } from './AssetsContractController'; const MAINNET_PROVIDER = new HttpProvider( 'https://mainnet.infura.io/v3/341eacb578dd44a1a049cbc5f6fd4035', ); -const GODSADDRESS = '0x6EbeAf8e8E946F0716E6533A6f2cefc83f60e8Ab'; -const CKADDRESS = '0x06012c8cf97BEaD5deAe237070F9587f8E7A266d'; -const SAI_ADDRESS = '0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359'; + +const ERC20_UNI_ADDRESS = '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984'; +const ERC20_DAI_ADDRESS = '0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359'; +const ERC721_GODS_ADDRESS = '0x6EbeAf8e8E946F0716E6533A6f2cefc83f60e8Ab'; +const ERC1155_ADDRESS = '0x495f947276749ce646f68ac8c248420045cb7b5e'; +const ERC1155_ID = + '40815311521795738946686668571398122012172359753720345430028676522525371400193'; + +const TEST_ACCOUNT_PUBLIC_ADDRESS = + '0x5a3CA5cD63807Ce5e4d7841AB32Ce6B6d9BbBa2D'; describe('AssetsContractController', () => { let assetsContract: AssetsContractController; @@ -27,86 +34,101 @@ describe('AssetsContractController', () => { ); }); - it('should determine if contract supports interface correctly', async () => { - assetsContract.configure({ provider: MAINNET_PROVIDER }); - const CKSupportsEnumerable = await assetsContract.contractSupportsEnumerableInterface( - CKADDRESS, - ); - const GODSSupportsEnumerable = await assetsContract.contractSupportsEnumerableInterface( - GODSADDRESS, - ); - expect(CKSupportsEnumerable).toBe(false); - expect(GODSSupportsEnumerable).toBe(true); - }); - - it('should get balance of contract correctly', async () => { + it('should get balance of ERC-20 token contract correctly', async () => { assetsContract.configure({ provider: MAINNET_PROVIDER }); - const CKBalance = await assetsContract.getBalanceOf( - CKADDRESS, - '0xb1690c08e213a35ed9bab7b318de14420fb57d8c', + const UNIBalance = await assetsContract.getBalanceOf( + ERC20_UNI_ADDRESS, + TEST_ACCOUNT_PUBLIC_ADDRESS, ); - const CKNoBalance = await assetsContract.getBalanceOf( - CKADDRESS, - '0xb1690c08e213a35ed9bab7b318de14420fb57d81', + const UNINoBalance = await assetsContract.getBalanceOf( + ERC20_UNI_ADDRESS, + '0x202637dAAEfbd7f131f90338a4A6c69F6Cd5CE91', ); - expect(CKBalance.toNumber()).not.toStrictEqual(0); - expect(CKNoBalance.toNumber()).toStrictEqual(0); + expect(UNIBalance.toNumber()).not.toStrictEqual(0); + expect(UNINoBalance.toNumber()).toStrictEqual(0); }); - it('should get collectible tokenId correctly', async () => { + it('should get ERC-721 collectible tokenId correctly', async () => { assetsContract.configure({ provider: MAINNET_PROVIDER }); const tokenId = await assetsContract.getCollectibleTokenId( - GODSADDRESS, + ERC721_GODS_ADDRESS, '0x9a90bd8d1149a88b42a99cf62215ad955d6f498a', 0, ); expect(tokenId).not.toStrictEqual(0); }); - it('should get collectible tokenURI correctly', async () => { + it('should get ERC-721 collectible tokenURI correctly', async () => { assetsContract.configure({ provider: MAINNET_PROVIDER }); - const tokenId = await assetsContract.getCollectibleTokenURI(GODSADDRESS, 0); + const tokenId = await assetsContract.getCollectibleTokenURI( + ERC721_GODS_ADDRESS, + '0', + ); expect(tokenId).toStrictEqual('https://api.godsunchained.com/card/0'); }); - it('should return empty string as URI when address given is not an NFT', async () => { + it('should return empty string as URI when address given is not an ERC-721 collectible', async () => { assetsContract.configure({ provider: MAINNET_PROVIDER }); const tokenId = await assetsContract.getCollectibleTokenURI( '0x0000000000000000000000000000000000000000', - 0, + '0', ); expect(tokenId).toStrictEqual(''); }); - it('should get collectible name', async () => { + it('should get ERC-721 collectible name', async () => { assetsContract.configure({ provider: MAINNET_PROVIDER }); - const name = await assetsContract.getAssetName(GODSADDRESS); + const name = await assetsContract.getAssetName(ERC721_GODS_ADDRESS); expect(name).toStrictEqual('Gods Unchained'); }); - it('should get collectible symbol', async () => { + it('should get ERC-721 collectible symbol', async () => { assetsContract.configure({ provider: MAINNET_PROVIDER }); - const symbol = await assetsContract.getAssetSymbol(GODSADDRESS); + const symbol = await assetsContract.getAssetSymbol(ERC721_GODS_ADDRESS); expect(symbol).toStrictEqual('GODS'); }); - it('should get token decimals', async () => { + it('should get ERC-20 token decimals', async () => { assetsContract.configure({ provider: MAINNET_PROVIDER }); - const symbol = await assetsContract.getTokenDecimals(SAI_ADDRESS); + const symbol = await assetsContract.getTokenDecimals(ERC20_DAI_ADDRESS); expect(Number(symbol)).toStrictEqual(18); }); - it('should get collectible ownership', async () => { + it('should get ERC-721 collectible ownership', async () => { assetsContract.configure({ provider: MAINNET_PROVIDER }); - const tokenId = await assetsContract.getOwnerOf(GODSADDRESS, 148332); + const tokenId = await assetsContract.getOwnerOf( + ERC721_GODS_ADDRESS, + '148332', + ); expect(tokenId).not.toStrictEqual(''); }); - it('should get balances in a single call', async () => { + it('should get balance of ERC-20 token in a single call', async () => { + assetsContract.configure({ provider: MAINNET_PROVIDER }); + const balances = await assetsContract.getBalancesInSingleCall( + ERC20_DAI_ADDRESS, + [ERC20_DAI_ADDRESS], + ); + expect(balances[ERC20_DAI_ADDRESS]).not.toStrictEqual(0); + }); + + it('should get the balance of a ERC-1155 collectible for a given address', async () => { assetsContract.configure({ provider: MAINNET_PROVIDER }); - const balances = await assetsContract.getBalancesInSingleCall(SAI_ADDRESS, [ - SAI_ADDRESS, - ]); - expect(balances[SAI_ADDRESS]).not.toStrictEqual(0); + const balance = await assetsContract.balanceOfERC1155Collectible( + TEST_ACCOUNT_PUBLIC_ADDRESS, + ERC1155_ADDRESS, + ERC1155_ID, + ); + expect(Number(balance)).toBeGreaterThan(0); + }); + + it('should get the URI of a ERC-1155 collectible', async () => { + assetsContract.configure({ provider: MAINNET_PROVIDER }); + const expectedUri = `https://api.opensea.io/api/v1/metadata/${ERC1155_ADDRESS}/0x{id}`; + const uri = await assetsContract.uriERC1155Collectible( + ERC1155_ADDRESS, + ERC1155_ID, + ); + expect(uri.toLowerCase()).toStrictEqual(expectedUri); }); }); diff --git a/src/assets/AssetsContractController.ts b/src/assets/AssetsContractController.ts index 1dc8caf564..8d691069c9 100644 --- a/src/assets/AssetsContractController.ts +++ b/src/assets/AssetsContractController.ts @@ -2,11 +2,12 @@ import { BN } from 'ethereumjs-util'; import Web3 from 'web3'; import abiERC20 from 'human-standard-token-abi'; import abiERC721 from 'human-standard-collectible-abi'; +import abiERC1155 from 'human-standard-multi-collectible-abi'; import abiSingleCallBalancesContract from 'single-call-balance-checker-abi'; import { BaseController, BaseConfig, BaseState } from '../BaseController'; +import { ERC721Standard } from './CollectibleStandards/ERC721/ERC721Standard'; +import { ERC1155Standard } from './CollectibleStandards/ERC1155/ERC1155Standard'; -const ERC721METADATA_INTERFACE_ID = '0x5b5e139f'; -const ERC721ENUMERABLE_INTERFACE_ID = '0x780e9d63'; const SINGLE_CALL_BALANCES_ADDRESS = '0xb1f8e55c7f64d203c1400b9d8555d050f94adf39'; @@ -39,32 +40,9 @@ export class AssetsContractController extends BaseController< > { private web3: any; - /** - * Query if a contract implements an interface. - * - * @param address - Asset contract address. - * @param interfaceId - Interface identifier. - * @returns Promise resolving to whether the contract implements `interfaceID`. - */ - private async contractSupportsInterface( - address: string, - interfaceId: string, - ): Promise { - const contract = this.web3.eth.contract(abiERC721).at(address); - return new Promise((resolve, reject) => { - contract.supportsInterface( - interfaceId, - (error: Error, result: boolean) => { - /* istanbul ignore if */ - if (error) { - reject(error); - return; - } - resolve(result); - }, - ); - }); - } + private erc721Standard: ERC721Standard = new ERC721Standard(); + + private erc1155Standard: ERC1155Standard = new ERC1155Standard(); /** * Name of this controller used during composition @@ -103,33 +81,10 @@ export class AssetsContractController extends BaseController< throw new Error('Property only used for setting'); } - /** - * Query if contract implements ERC721Metadata interface. - * - * @param address - ERC721 asset contract address. - * @returns Promise resolving to whether the contract implements ERC721Metadata interface. - */ - async contractSupportsMetadataInterface(address: string): Promise { - return this.contractSupportsInterface(address, ERC721METADATA_INTERFACE_ID); - } - - /** - * Query if contract implements ERC721Enumerable interface. - * - * @param address - ERC721 asset contract address. - * @returns Promise resolving to whether the contract implements ERC721Enumerable interface. - */ - async contractSupportsEnumerableInterface(address: string): Promise { - return this.contractSupportsInterface( - address, - ERC721ENUMERABLE_INTERFACE_ID, - ); - } - /** * Get balance or count for current account on specific asset contract. * - * @param address - Asset contract address. + * @param address - Asset ERC20 contract address. * @param selectedAddress - Current account public address. * @returns Promise resolving to BN object containing balance for current account on specific asset contract. */ @@ -147,6 +102,26 @@ export class AssetsContractController extends BaseController< }); } + /** + * Query for name for a given ERC20 asset. + * + * @param address - ERC20 asset contract address. + * @returns Promise resolving to the 'decimals'. + */ + async getTokenDecimals(address: string): Promise { + const contract = this.web3.eth.contract(abiERC20).at(address); + return new Promise((resolve, reject) => { + contract.decimals((error: Error, result: string) => { + /* istanbul ignore if */ + if (error) { + reject(error); + return; + } + resolve(result); + }); + }); + } + /** * Enumerate assets assigned to an owner. * @@ -159,22 +134,13 @@ export class AssetsContractController extends BaseController< address: string, selectedAddress: string, index: number, - ): Promise { + ): Promise { const contract = this.web3.eth.contract(abiERC721).at(address); - return new Promise((resolve, reject) => { - contract.tokenOfOwnerByIndex( - selectedAddress, - index, - (error: Error, result: BN) => { - /* istanbul ignore if */ - if (error) { - reject(error); - return; - } - resolve(result.toNumber()); - }, - ); - }); + return this.erc721Standard.getCollectibleTokenId( + contract, + selectedAddress, + index, + ); } /** @@ -186,45 +152,10 @@ export class AssetsContractController extends BaseController< */ async getCollectibleTokenURI( address: string, - tokenId: number, + tokenId: string, ): Promise { - const supportsMetadata = await this.contractSupportsMetadataInterface( - address, - ); - if (!supportsMetadata) { - return ''; - } const contract = this.web3.eth.contract(abiERC721).at(address); - return new Promise((resolve, reject) => { - contract.tokenURI(tokenId, (error: Error, result: string) => { - /* istanbul ignore if */ - if (error) { - reject(error); - return; - } - resolve(result); - }); - }); - } - - /** - * Query for name for a given ERC20 asset. - * - * @param address - ERC20 asset contract address. - * @returns Promise resolving to the 'decimals'. - */ - async getTokenDecimals(address: string): Promise { - const contract = this.web3.eth.contract(abiERC20).at(address); - return new Promise((resolve, reject) => { - contract.decimals((error: Error, result: string) => { - /* istanbul ignore if */ - if (error) { - reject(error); - return; - } - resolve(result); - }); - }); + return this.erc721Standard.getCollectibleTokenURI(contract, tokenId); } /** @@ -235,16 +166,7 @@ export class AssetsContractController extends BaseController< */ async getAssetName(address: string): Promise { const contract = this.web3.eth.contract(abiERC721).at(address); - return new Promise((resolve, reject) => { - contract.name((error: Error, result: string) => { - /* istanbul ignore if */ - if (error) { - reject(error); - return; - } - resolve(result); - }); - }); + return this.erc721Standard.getAssetName(contract); } /** @@ -255,16 +177,7 @@ export class AssetsContractController extends BaseController< */ async getAssetSymbol(address: string): Promise { const contract = this.web3.eth.contract(abiERC721).at(address); - return new Promise((resolve, reject) => { - contract.symbol((error: Error, result: string) => { - /* istanbul ignore if */ - if (error) { - reject(error); - return; - } - resolve(result); - }); - }); + return this.erc721Standard.getAssetSymbol(contract); } /** @@ -274,18 +187,73 @@ export class AssetsContractController extends BaseController< * @param tokenId - ERC721 asset identifier. * @returns Promise resolving to the owner address. */ - async getOwnerOf(address: string, tokenId: number): Promise { + async getOwnerOf(address: string, tokenId: string): Promise { const contract = this.web3.eth.contract(abiERC721).at(address); - return new Promise((resolve, reject) => { - contract.ownerOf(tokenId, (error: Error, result: string) => { - /* istanbul ignore if */ - if (error) { - reject(error); - return; - } - resolve(result); - }); - }); + return this.erc721Standard.getOwnerOf(contract, tokenId); + } + + /** + * Query for tokenURI for a given asset. + * + * @param address - ERC1155 asset contract address. + * @param tokenId - ERC1155 asset identifier. + * @returns Promise resolving to the 'tokenURI'. + */ + async uriERC1155Collectible( + address: string, + tokenId: string, + ): Promise { + const contract = this.web3.eth.contract(abiERC1155).at(address); + return this.erc1155Standard.uri(contract, tokenId); + } + + /** + * Query for balance of a given ERC 1155 token. + * + * @param userAddress - Wallet public address. + * @param collectibleAddress - ERC1155 asset contract address. + * @param collectibleId - ERC1155 asset identifier. + * @returns Promise resolving to the 'balanceOf'. + */ + async balanceOfERC1155Collectible( + userAddress: string, + collectibleAddress: string, + collectibleId: string, + ): Promise { + const contract = this.web3.eth.contract(abiERC1155).at(collectibleAddress); + return await this.erc1155Standard.getBalanceOf( + contract, + userAddress, + collectibleId, + ); + } + + /** + * Transfer single ERC1155 token. + * + * @param collectibleAddress - ERC1155 token address. + * @param senderAddress - ERC1155 token sender. + * @param recipientAddress - ERC1155 token recipient. + * @param collectibleId - ERC1155 token id. + * @param qty - Quantity of tokens to be sent. + * @returns Promise resolving to the 'transferSingle' ERC1155 token. + */ + async transferSingleERC1155Collectible( + collectibleAddress: string, + senderAddress: string, + recipientAddress: string, + collectibleId: string, + qty: string, + ): Promise { + const contract = this.web3.eth.contract(abiERC1155).at(collectibleAddress); + return await this.erc1155Standard.transferSingle( + contract, + collectibleAddress, + senderAddress, + recipientAddress, + collectibleId, + qty, + ); } /** diff --git a/src/assets/AssetsDetectionController.test.ts b/src/assets/AssetsDetectionController.test.ts index 64e6e73271..7cf2b3cd6b 100644 --- a/src/assets/AssetsDetectionController.test.ts +++ b/src/assets/AssetsDetectionController.test.ts @@ -137,6 +137,13 @@ describe('AssetsDetectionController', () => { getCollectibleTokenURI: assetsContract.getCollectibleTokenURI.bind( assetsContract, ), + getOwnerOf: assetsContract.getOwnerOf.bind(assetsContract), + balanceOfERC1155Collectible: assetsContract.balanceOfERC1155Collectible.bind( + assetsContract, + ), + uriERC1155Collectible: assetsContract.uriERC1155Collectible.bind( + assetsContract, + ), }); nock(TOKEN_END_POINT_API) @@ -177,6 +184,11 @@ describe('AssetsDetectionController', () => { { asset_contract: { address: '0x1d963688fe2209a98db35c67a041524822cf04ff', + schema_name: 'ERC721', + }, + collection: { + name: 'Collection 2577', + image_url: 'url', }, description: 'Description 2577', image_original_url: 'image/2577.png', @@ -226,6 +238,11 @@ describe('AssetsDetectionController', () => { { asset_contract: { address: '0xCE7ec4B2DfB30eB6c0BB5656D33aAd6BFb4001Fc', + schema_name: 'ERC721', + }, + collection: { + name: 'Collection 2577', + image_url: 'url', }, description: 'Description 2577', image_url: 'image/2577.png', @@ -235,6 +252,11 @@ describe('AssetsDetectionController', () => { { asset_contract: { address: '0x0B0fa4fF58D28A88d63235bd0756EDca69e49e6d', + schema_name: 'ERC721', + }, + collection: { + name: 'Collection 2577', + image_url: 'url', }, description: 'Description 2578', image_url: 'image/2578.png', @@ -244,6 +266,11 @@ describe('AssetsDetectionController', () => { { asset_contract: { address: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', + schema_name: 'ERC721', + }, + collection: { + name: 'Collection 2574', + image_url: 'url', }, description: 'Description 2574', image_url: 'image/2574.png', @@ -263,6 +290,11 @@ describe('AssetsDetectionController', () => { { asset_contract: { address: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', + schema_name: 'ERC721', + }, + collection: { + name: 'Collection 2574', + image_url: 'url', }, description: 'Description 2574', image_url: 'image/2574.png', @@ -398,7 +430,10 @@ describe('AssetsDetectionController', () => { description: 'Description 2574', image: 'image/2574.png', name: 'ID 2574', - tokenId: 2574, + tokenId: '2574', + standard: 'ERC721', + collectionImage: 'url', + collectionName: 'Collection 2574', }, ]); }); @@ -407,7 +442,7 @@ describe('AssetsDetectionController', () => { assetsDetection.configure({ networkType: MAINNET, selectedAddress: '0x1' }); await collectiblesController.addCollectible( '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', - 2573, + '2573', { description: 'Description 2573', image: 'image/2573.png', @@ -421,14 +456,17 @@ describe('AssetsDetectionController', () => { description: 'Description 2573', image: 'image/2573.png', name: 'ID 2573', - tokenId: 2573, + tokenId: '2573', }, { address: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', description: 'Description 2574', image: 'image/2574.png', name: 'ID 2574', - tokenId: 2574, + tokenId: '2574', + standard: 'ERC721', + collectionImage: 'url', + collectionName: 'Collection 2574', }, ]); }); @@ -440,7 +478,7 @@ describe('AssetsDetectionController', () => { expect(collectiblesController.state.ignoredCollectibles).toHaveLength(0); collectiblesController.removeAndIgnoreCollectible( '0x1d963688fe2209a98db35c67a041524822cf04ff', - 2577, + '2577', ); await assetsDetection.detectCollectibles(); expect(collectiblesController.state.collectibles).toHaveLength(0); @@ -473,21 +511,30 @@ describe('AssetsDetectionController', () => { description: 'Description 2574', image: 'image/2574.png', name: 'ID 2574', - tokenId: 2574, + tokenId: '2574', + standard: 'ERC721', + collectionImage: 'url', + collectionName: 'Collection 2574', }; const collectibleGG2574 = { address: '0xCE7ec4B2DfB30eB6c0BB5656D33aAd6BFb4001Fc', description: 'Description 2574', image: 'image/2574.png', name: 'ID 2574', - tokenId: 2574, + tokenId: '2574', + standard: 'ERC721', + collectionImage: 'url', + collectionName: 'Collection 2574', }; const collectibleII2577 = { address: '0x0B0fa4fF58D28A88d63235bd0756EDca69e49e6d', description: 'Description 2577', image: 'image/2577.png', name: 'ID 2577', - tokenId: 2577, + tokenId: '2577', + standard: 'ERC721', + collectionImage: 'url', + collectionName: 'Collection 2577', }; const collectibleContractHH = { address: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', @@ -552,6 +599,11 @@ describe('AssetsDetectionController', () => { { asset_contract: { address: '0x0B0fa4fF58D28A88d63235bd0756EDca69e49e6d', + schema_name: 'ERC721', + }, + collection: { + name: 'Collection 2577', + image_url: 'url', }, description: 'Description 2577', image_url: 'image/2577.png', @@ -561,6 +613,11 @@ describe('AssetsDetectionController', () => { { asset_contract: { address: '0xCE7ec4B2DfB30eB6c0BB5656D33aAd6BFb4001Fc', + schema_name: 'ERC721', + }, + collection: { + name: 'Collection 2574', + image_url: 'url', }, description: 'Description 2574', image_url: 'image/2574.png', @@ -570,6 +627,11 @@ describe('AssetsDetectionController', () => { { asset_contract: { address: '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', + schema_name: 'ERC721', + }, + collection: { + name: 'Collection 2574', + image_url: 'url', }, description: 'Description 2574', image_url: 'image/2574.png', diff --git a/src/assets/AssetsDetectionController.ts b/src/assets/AssetsDetectionController.ts index 90d821737a..1db6fd1be3 100644 --- a/src/assets/AssetsDetectionController.ts +++ b/src/assets/AssetsDetectionController.ts @@ -34,6 +34,7 @@ const DEFAULT_INTERVAL = 180000; * @property assetContract - The collectible contract information object * @property creator - The collectible owner information object * @property lastSale - When this item was last sold + * @property collection - Collectible collection data object. */ export interface ApiCollectible { token_id: string; @@ -51,6 +52,7 @@ export interface ApiCollectible { asset_contract: ApiCollectibleContract; creator: ApiCollectibleCreator; last_sale: ApiCollectibleLastSale | null; + collection: ApiCollectibleCollection; } /** @@ -109,6 +111,18 @@ export interface ApiCollectibleCreator { address: string; } +/** + * @type ApiCollectibleCollection + * + * Collectible collection object from OpenSea api. + * @property name - Collection name. + * @property image_url - URI collection image. + */ +export interface ApiCollectibleCollection { + name: string; + image_url: string; +} + /** * @type AssetsConfig * @@ -418,7 +432,8 @@ export class AssetsDetectionController extends BaseController< description, external_link, creator, - asset_contract: { address }, + asset_contract: { address, schema_name }, + collection, last_sale, } = collectible; @@ -430,7 +445,7 @@ export class AssetsDetectionController extends BaseController< /* istanbul ignore next */ return ( c.address === toChecksumHexAddress(address) && - c.tokenId === Number(token_id) + c.tokenId === token_id ); }); } @@ -456,12 +471,17 @@ export class AssetsDetectionController extends BaseController< animation_original_url && { animationOriginal: animation_original_url, }, + schema_name && { standard: schema_name }, external_link && { externalLink: external_link }, last_sale && { lastSale: last_sale }, + collection.name && { collectionName: collection.name }, + collection.image_url && { + collectionImage: collection.image_url, + }, ); await this.addCollectible( address, - Number(token_id), + token_id, collectibleMetadata, true, ); diff --git a/src/assets/CollectibleStandards/ERC1155/ERC1155Standard.test.ts b/src/assets/CollectibleStandards/ERC1155/ERC1155Standard.test.ts new file mode 100644 index 0000000000..eb3956c28a --- /dev/null +++ b/src/assets/CollectibleStandards/ERC1155/ERC1155Standard.test.ts @@ -0,0 +1,28 @@ +import Web3 from 'web3'; +import HttpProvider from 'ethjs-provider-http'; +import abiERC1155 from 'human-standard-multi-collectible-abi'; +import { ERC1155Standard } from './ERC1155Standard'; + +const MAINNET_PROVIDER = new HttpProvider( + 'https://mainnet.infura.io/v3/341eacb578dd44a1a049cbc5f6fd4035', +); + +const ERC1155_ADDRESS = '0xfaaFDc07907ff5120a76b34b731b278c38d6043C'; + +describe('ERC1155Standard', () => { + let erc1155Standard: ERC1155Standard; + let web3: any; + + beforeEach(() => { + erc1155Standard = new ERC1155Standard(); + web3 = new Web3(MAINNET_PROVIDER); + }); + + it('should determine if contract supports URI metadata interface correctly', async () => { + const contract = web3.eth.contract(abiERC1155).at(ERC1155_ADDRESS); + const contractSupportsUri = await erc1155Standard.contractSupportsURIMetadataInterface( + contract, + ); + expect(contractSupportsUri).toBe(true); + }); +}); diff --git a/src/assets/CollectibleStandards/ERC1155/ERC1155Standard.ts b/src/assets/CollectibleStandards/ERC1155/ERC1155Standard.ts new file mode 100644 index 0000000000..bbe1384079 --- /dev/null +++ b/src/assets/CollectibleStandards/ERC1155/ERC1155Standard.ts @@ -0,0 +1,145 @@ +const ERC1155_METADATA_URI_INTERFACE_ID = '0x0e89341c'; +const ERC1155_TOKEN_RECEIVER_INTERFACE_ID = '0x4e2312e0'; + +export class ERC1155Standard { + /** + * Query if contract implements ERC1155 URI Metadata interface. + * + * @param contract - ERC1155 asset contract. + * @returns Promise resolving to whether the contract implements ERC1155 URI Metadata interface. + */ + contractSupportsURIMetadataInterface = async ( + contract: any, + ): Promise => { + return this.contractSupportsInterface( + contract, + ERC1155_METADATA_URI_INTERFACE_ID, + ); + }; + + /** + * Query if contract implements ERC1155 Token Receiver interface. + * + * @param contract - ERC1155 asset contract. + * @returns Promise resolving to whether the contract implements ERC1155 Token Receiver interface. + */ + contractSupportsTokenReceiverInterface = async ( + contract: any, + ): Promise => { + return this.contractSupportsInterface( + contract, + ERC1155_TOKEN_RECEIVER_INTERFACE_ID, + ); + }; + + /** + * Query for tokenURI for a given asset. + * + * @param contract - ERC1155 asset contract. + * @param tokenId - ERC1155 asset identifier. + * @returns Promise resolving to the 'tokenURI'. + */ + uri = async (contract: any, tokenId: string): Promise => { + return new Promise((resolve, reject) => { + contract.uri(tokenId, (error: Error, result: string) => { + /* istanbul ignore if */ + if (error) { + reject(error); + return; + } + resolve(result); + }); + }); + }; + + /** + * Query for balance of a given ERC1155 token. + * + * @param contract - ERC1155 asset contract. + * @param address - Wallet public address. + * @param tokenId - ERC1155 asset identifier. + * @returns Promise resolving to the 'balanceOf'. + */ + getBalanceOf = async ( + contract: any, + address: string, + tokenId: string, + ): Promise => { + return new Promise((resolve, reject) => { + contract.balanceOf(address, tokenId, (error: Error, result: number) => { + /* istanbul ignore if */ + if (error) { + reject(error); + return; + } + resolve(result); + }); + }); + }; + + /** + * Transfer single ERC1155 token. + * When minting/creating tokens, the from arg MUST be set to 0x0 (i.e. zero address). + * When burning/destroying tokens, the to arg MUST be set to 0x0 (i.e. zero address). + * + * @param contract - ERC1155 asset contract. + * @param operator - ERC1155 token address. + * @param from - ERC1155 token holder. + * @param to - ERC1155 token recipient. + * @param id - ERC1155 token id. + * @param value - Number of tokens to be sent. + * @returns Promise resolving to the 'transferSingle'. + */ + transferSingle = async ( + contract: any, + operator: string, + from: string, + to: string, + id: string, + value: string, + ): Promise => { + return new Promise((resolve, reject) => { + contract.transferSingle( + operator, + from, + to, + id, + value, + (error: Error, result: void) => { + /* istanbul ignore if */ + if (error) { + reject(error); + return; + } + resolve(result); + }, + ); + }); + }; + + /** + * Query if a contract implements an interface. + * + * @param contract - ERC1155 asset contract. + * @param interfaceId - Interface identifier. + * @returns Promise resolving to whether the contract implements `interfaceID`. + */ + private contractSupportsInterface = async ( + contract: any, + interfaceId: string, + ): Promise => { + return new Promise((resolve, reject) => { + contract.supportsInterface( + interfaceId, + (error: Error, result: boolean) => { + /* istanbul ignore if */ + if (error) { + reject(error); + return; + } + resolve(result); + }, + ); + }); + }; +} diff --git a/src/assets/CollectibleStandards/ERC721/ERC721Standard.test.ts b/src/assets/CollectibleStandards/ERC721/ERC721Standard.test.ts new file mode 100644 index 0000000000..9be4c46684 --- /dev/null +++ b/src/assets/CollectibleStandards/ERC721/ERC721Standard.test.ts @@ -0,0 +1,33 @@ +import Web3 from 'web3'; +import HttpProvider from 'ethjs-provider-http'; +import abiERC721 from 'human-standard-collectible-abi'; +import { ERC721Standard } from './ERC721Standard'; + +const MAINNET_PROVIDER = new HttpProvider( + 'https://mainnet.infura.io/v3/341eacb578dd44a1a049cbc5f6fd4035', +); +const ERC721_GODSADDRESS = '0x6EbeAf8e8E946F0716E6533A6f2cefc83f60e8Ab'; +const ERC721_CKADDRESS = '0x06012c8cf97BEaD5deAe237070F9587f8E7A266d'; + +describe('ERC721Standard', () => { + let erc721Standard: ERC721Standard; + let web3: any; + + beforeEach(() => { + erc721Standard = new ERC721Standard(); + web3 = new Web3(MAINNET_PROVIDER); + }); + + it('should determine if contract supports interface correctly', async () => { + const ckContract = web3.eth.contract(abiERC721).at(ERC721_CKADDRESS); + const CKSupportsEnumerable = await erc721Standard.contractSupportsEnumerableInterface( + ckContract, + ); + const godsContract = web3.eth.contract(abiERC721).at(ERC721_GODSADDRESS); + const GODSSupportsEnumerable = await erc721Standard.contractSupportsEnumerableInterface( + godsContract, + ); + expect(CKSupportsEnumerable).toBe(false); + expect(GODSSupportsEnumerable).toBe(true); + }); +}); diff --git a/src/assets/CollectibleStandards/ERC721/ERC721Standard.ts b/src/assets/CollectibleStandards/ERC721/ERC721Standard.ts new file mode 100644 index 0000000000..657e0ae66d --- /dev/null +++ b/src/assets/CollectibleStandards/ERC721/ERC721Standard.ts @@ -0,0 +1,176 @@ +const ERC721_METADATA_INTERFACE_ID = '0x5b5e139f'; +const ERC721_ENUMERABLE_INTERFACE_ID = '0x780e9d63'; + +export class ERC721Standard { + /** + * Query if contract implements ERC721Metadata interface. + * + * @param contract - ERC721 asset contract. + * @returns Promise resolving to whether the contract implements ERC721Metadata interface. + */ + contractSupportsMetadataInterface = async ( + contract: any, + ): Promise => { + return this.contractSupportsInterface( + contract, + ERC721_METADATA_INTERFACE_ID, + ); + }; + + /** + * Query if contract implements ERC721Enumerable interface. + * + * @param contract - ERC721 asset contract. + * @returns Promise resolving to whether the contract implements ERC721Enumerable interface. + */ + contractSupportsEnumerableInterface = async ( + contract: any, + ): Promise => { + return this.contractSupportsInterface( + contract, + ERC721_ENUMERABLE_INTERFACE_ID, + ); + }; + + /** + * Enumerate assets assigned to an owner. + * + * @param contract - ERC721 asset contract. + * @param selectedAddress - Current account public address. + * @param index - A collectible counter less than `balanceOf(selectedAddress)`. + * @returns Promise resolving to token identifier for the 'index'th asset assigned to 'selectedAddress'. + */ + getCollectibleTokenId = async ( + contract: any, + selectedAddress: string, + index: number, + ): Promise => { + return new Promise((resolve, reject) => { + contract.tokenOfOwnerByIndex( + selectedAddress, + index, + (error: Error, result: string) => { + /* istanbul ignore if */ + if (error) { + reject(error); + return; + } + resolve(result); + }, + ); + }); + }; + + /** + * Query for tokenURI for a given asset. + * + * @param contract - ERC721 asset contract. + * @param tokenId - ERC721 asset identifier. + * @returns Promise resolving to the 'tokenURI'. + */ + getCollectibleTokenURI = async ( + contract: any, + tokenId: string, + ): Promise => { + const supportsMetadata = await this.contractSupportsMetadataInterface( + contract, + ); + if (!supportsMetadata) { + return ''; + } + return new Promise((resolve, reject) => { + contract.tokenURI(tokenId, (error: Error, result: string) => { + /* istanbul ignore if */ + if (error) { + reject(error); + return; + } + resolve(result); + }); + }); + }; + + /** + * Query for name for a given asset. + * + * @param contract - ERC721 asset contract. + * @returns Promise resolving to the 'name'. + */ + getAssetName = async (contract: any): Promise => { + return new Promise((resolve, reject) => { + contract.name((error: Error, result: string) => { + /* istanbul ignore if */ + if (error) { + reject(error); + return; + } + resolve(result); + }); + }); + }; + + /** + * Query for symbol for a given asset. + * + * @param contract - ERC721 asset contract address. + * @returns Promise resolving to the 'symbol'. + */ + getAssetSymbol = async (contract: any): Promise => { + return new Promise((resolve, reject) => { + contract.symbol((error: Error, result: string) => { + /* istanbul ignore if */ + if (error) { + reject(error); + return; + } + resolve(result); + }); + }); + }; + + /** + * Query for owner for a given ERC721 asset. + * + * @param contract - ERC721 asset contract. + * @param tokenId - ERC721 asset identifier. + * @returns Promise resolving to the owner address. + */ + async getOwnerOf(contract: any, tokenId: string): Promise { + return new Promise((resolve, reject) => { + contract.ownerOf(tokenId, (error: Error, result: string) => { + /* istanbul ignore if */ + if (error) { + reject(error); + return; + } + resolve(result); + }); + }); + } + + /** + * Query if a contract implements an interface. + * + * @param contract - Asset contract. + * @param interfaceId - Interface identifier. + * @returns Promise resolving to whether the contract implements `interfaceID`. + */ + private contractSupportsInterface = async ( + contract: any, + interfaceId: string, + ): Promise => { + return new Promise((resolve, reject) => { + contract.supportsInterface( + interfaceId, + (error: Error, result: boolean) => { + /* istanbul ignore if */ + if (error) { + reject(error); + return; + } + resolve(result); + }, + ); + }); + }; +} diff --git a/src/assets/CollectiblesController.test.ts b/src/assets/CollectiblesController.test.ts index ce378b7592..545f8451ee 100644 --- a/src/assets/CollectiblesController.test.ts +++ b/src/assets/CollectiblesController.test.ts @@ -9,7 +9,15 @@ import { import { AssetsContractController } from './AssetsContractController'; import { CollectiblesController } from './CollectiblesController'; -const KUDOSADDRESS = '0x2aea4add166ebf38b63d09a75de1a7b94aa24163'; +const ERC721_KUDOSADDRESS = '0x2aea4add166ebf38b63d09a75de1a7b94aa24163'; +const CRYPTOPUNK_ADDRESS = '0xb47e3cd837dDF8e4c57F05d70Ab865de6e193BBB'; +const ERC721_COLLECTIBLE_ADDRESS = '0x60f80121c31a0d46b5279700f9df786054aa5ee5'; +const ERC721_COLLECTIBLE_ID = '1144858'; +const ERC1155_COLLECTIBLE_ADDRESS = + '0x495f947276749ce646f68ac8c248420045cb7b5e'; +const ERC1155_COLLECTIBLE_ID = + '40815311521795738946686668571398122012172359753720345430028676522525371400193'; +const OWNER_ADDRESS = '0x5a3CA5cD63807Ce5e4d7841AB32Ce6B6d9BbBa2D'; const MAINNET_PROVIDER = new HttpProvider( 'https://mainnet.infura.io/v3/341eacb578dd44a1a049cbc5f6fd4035', ); @@ -35,6 +43,13 @@ describe('CollectiblesController', () => { getCollectibleTokenURI: assetsContract.getCollectibleTokenURI.bind( assetsContract, ), + getOwnerOf: assetsContract.getOwnerOf.bind(assetsContract), + balanceOfERC1155Collectible: assetsContract.balanceOfERC1155Collectible.bind( + assetsContract, + ), + uriERC1155Collectible: assetsContract.uriERC1155Collectible.bind( + assetsContract, + ), }); nock(OPEN_SEA_HOST) @@ -59,6 +74,13 @@ describe('CollectiblesController', () => { description: 'Description', image_original_url: 'url', name: 'Name', + asset_contract: { + schema_name: 'ERC1155', + }, + collection: { + name: 'Collection Name', + image_url: 'collection.url', + }, }) .get( `${OPEN_SEA_PATH}/asset/0x2aEa4Add166EBf38b63d09a75dE1a7b94Aa24163/1203`, @@ -67,6 +89,13 @@ describe('CollectiblesController', () => { description: 'Kudos Description', image_original_url: 'Kudos url', name: 'Kudos Name', + asset_contract: { + schema_name: 'ERC721', + }, + collection: { + name: 'Collection Name', + image_url: 'collection.url', + }, }) .get( `${OPEN_SEA_PATH}/asset/0x6EbeAf8e8E946F0716E6533A6f2cefc83f60e8Ab/798958393`, @@ -111,7 +140,7 @@ describe('CollectiblesController', () => { }); it('should add collectible and collectible contract', async () => { - await collectiblesController.addCollectible('0x01', 1, { + await collectiblesController.addCollectible('0x01', '1', { name: 'name', image: 'image', description: 'description', @@ -122,7 +151,7 @@ describe('CollectiblesController', () => { description: 'description', image: 'image', name: 'name', - tokenId: 1, + tokenId: '1', }); expect(collectiblesController.state.collectibleContracts[0]).toStrictEqual({ @@ -136,7 +165,7 @@ describe('CollectiblesController', () => { }); it('should update collectible if image is different', async () => { - await collectiblesController.addCollectible('0x01', 1, { + await collectiblesController.addCollectible('0x01', '1', { name: 'name', image: 'image', description: 'description', @@ -147,10 +176,10 @@ describe('CollectiblesController', () => { description: 'description', image: 'image', name: 'name', - tokenId: 1, + tokenId: '1', }); - await collectiblesController.addCollectible('0x01', 1, { + await collectiblesController.addCollectible('0x01', '1', { name: 'name', image: 'image-updated', description: 'description', @@ -161,18 +190,18 @@ describe('CollectiblesController', () => { description: 'description', image: 'image-updated', name: 'name', - tokenId: 1, + tokenId: '1', }); }); it('should not duplicate collectible nor collectible contract if already added', async () => { - await collectiblesController.addCollectible('0x01', 1, { + await collectiblesController.addCollectible('0x01', '1', { name: 'name', image: 'image', description: 'description', }); - await collectiblesController.addCollectible('0x01', 1, { + await collectiblesController.addCollectible('0x01', '1', { name: 'name', image: 'image', description: 'description', @@ -182,13 +211,13 @@ describe('CollectiblesController', () => { }); it('should not add collectible contract if collectible contract already exists', async () => { - await collectiblesController.addCollectible('0x01', 1, { + await collectiblesController.addCollectible('0x01', '1', { name: 'name', image: 'image', description: 'description', }); - await collectiblesController.addCollectible('0x01', 2, { + await collectiblesController.addCollectible('0x01', '2', { name: 'name', image: 'image', description: 'description', @@ -198,13 +227,16 @@ describe('CollectiblesController', () => { }); it('should add collectible and get information from OpenSea', async () => { - await collectiblesController.addCollectible('0x01', 1); + await collectiblesController.addCollectible('0x01', '1'); expect(collectiblesController.state.collectibles[0]).toStrictEqual({ address: '0x01', description: 'Description', imageOriginal: 'url', name: 'Name', - tokenId: 1, + standard: 'ERC1155', + tokenId: '1', + collectionName: 'Collection Name', + collectionImage: 'collection.url', }); }); @@ -220,12 +252,13 @@ describe('CollectiblesController', () => { sandbox .stub(collectiblesController, 'getCollectibleInformationFromApi' as any) .returns(undefined); - await collectiblesController.addCollectible(KUDOSADDRESS, 1203); + await collectiblesController.addCollectible(ERC721_KUDOSADDRESS, '1203'); expect(collectiblesController.state.collectibles[0]).toStrictEqual({ address: '0x2aEa4Add166EBf38b63d09a75dE1a7b94Aa24163', image: 'Kudos Image', name: 'Kudos Name', - tokenId: 1203, + tokenId: '1203', + standard: 'ERC721', }); expect(collectiblesController.state.collectibleContracts[0]).toStrictEqual({ @@ -242,16 +275,16 @@ describe('CollectiblesController', () => { .stub(collectiblesController, 'getCollectibleInformation' as any) .returns({ name: 'name', image: 'url', description: 'description' }); preferences.update({ selectedAddress: firstAddress }); - await collectiblesController.addCollectible('0x01', 1234); + await collectiblesController.addCollectible('0x01', '1234'); preferences.update({ selectedAddress: secondAddress }); - await collectiblesController.addCollectible('0x02', 4321); + await collectiblesController.addCollectible('0x02', '4321'); preferences.update({ selectedAddress: firstAddress }); expect(collectiblesController.state.collectibles[0]).toStrictEqual({ address: '0x01', description: 'description', image: 'url', name: 'name', - tokenId: 1234, + tokenId: '1234', }); }); @@ -268,7 +301,7 @@ describe('CollectiblesController', () => { chainId: NetworksChainId[firstNetworkType], }, }); - await collectiblesController.addCollectible('0x01', 1234); + await collectiblesController.addCollectible('0x01', '1234'); network.update({ provider: { type: secondNetworkType, @@ -288,14 +321,14 @@ describe('CollectiblesController', () => { description: 'description', image: 'url', name: 'name', - tokenId: 1234, + tokenId: '1234', }); }); it('should not add collectibles with no contract information when auto detecting', async () => { await collectiblesController.addCollectible( '0x6EbeAf8e8E946F0716E6533A6f2cefc83f60e8Ab', - 123, + '123', undefined, true, ); @@ -303,7 +336,7 @@ describe('CollectiblesController', () => { expect(collectiblesController.state.collectibleContracts).toStrictEqual([]); await collectiblesController.addCollectible( '0x2aEa4Add166EBf38b63d09a75dE1a7b94Aa24163', - 1203, + '1203', undefined, true, ); @@ -314,7 +347,10 @@ describe('CollectiblesController', () => { description: 'Kudos Description', imageOriginal: 'Kudos url', name: 'Kudos Name', - tokenId: 1203, + standard: 'ERC721', + tokenId: '1203', + collectionImage: 'collection.url', + collectionName: 'Collection Name', }, ]); @@ -331,29 +367,29 @@ describe('CollectiblesController', () => { }); it('should remove collectible and collectible contract', async () => { - await collectiblesController.addCollectible('0x01', 1, { + await collectiblesController.addCollectible('0x01', '1', { name: 'name', image: 'image', description: 'description', }); - collectiblesController.removeCollectible('0x01', 1); + collectiblesController.removeCollectible('0x01', '1'); expect(collectiblesController.state.collectibles).toHaveLength(0); expect(collectiblesController.state.collectibleContracts).toHaveLength(0); }); it('should not remove collectible contract if collectible still exists', async () => { - await collectiblesController.addCollectible('0x01', 1, { + await collectiblesController.addCollectible('0x01', '1', { name: 'name', image: 'image', description: 'description', }); - await collectiblesController.addCollectible('0x01', 2, { + await collectiblesController.addCollectible('0x01', '2', { name: 'name', image: 'image', description: 'description', }); - collectiblesController.removeCollectible('0x01', 1); + collectiblesController.removeCollectible('0x01', '1'); expect(collectiblesController.state.collectibles).toHaveLength(1); expect(collectiblesController.state.collectibleContracts).toHaveLength(1); }); @@ -365,10 +401,10 @@ describe('CollectiblesController', () => { const firstAddress = '0x123'; const secondAddress = '0x321'; preferences.update({ selectedAddress: firstAddress }); - await collectiblesController.addCollectible('0x02', 4321); + await collectiblesController.addCollectible('0x02', '4321'); preferences.update({ selectedAddress: secondAddress }); - await collectiblesController.addCollectible('0x01', 1234); - collectiblesController.removeCollectible('0x01', 1234); + await collectiblesController.addCollectible('0x01', '1234'); + collectiblesController.removeCollectible('0x01', '1234'); expect(collectiblesController.state.collectibles).toHaveLength(0); preferences.update({ selectedAddress: firstAddress }); expect(collectiblesController.state.collectibles[0]).toStrictEqual({ @@ -376,7 +412,7 @@ describe('CollectiblesController', () => { description: 'description', image: 'url', name: 'name', - tokenId: 4321, + tokenId: '4321', }); }); @@ -392,16 +428,16 @@ describe('CollectiblesController', () => { chainId: NetworksChainId[firstNetworkType], }, }); - await collectiblesController.addCollectible('0x02', 4321); + await collectiblesController.addCollectible('0x02', '4321'); network.update({ provider: { type: secondNetworkType, chainId: NetworksChainId[secondNetworkType], }, }); - await collectiblesController.addCollectible('0x01', 1234); + await collectiblesController.addCollectible('0x01', '1234'); // collectiblesController.removeToken('0x01'); - collectiblesController.removeCollectible('0x01', 1234); + collectiblesController.removeCollectible('0x01', '1234'); expect(collectiblesController.state.collectibles).toHaveLength(0); network.update({ provider: { @@ -415,7 +451,7 @@ describe('CollectiblesController', () => { description: 'description', image: 'url', name: 'name', - tokenId: 4321, + tokenId: '4321', }); }); @@ -431,13 +467,13 @@ describe('CollectiblesController', () => { }); it('should not add duplicate collectibles to the ignoredCollectibles list', async () => { - await collectiblesController.addCollectible('0x01', 1, { + await collectiblesController.addCollectible('0x01', '1', { name: 'name', image: 'image', description: 'description', }); - await collectiblesController.addCollectible('0x01', 2, { + await collectiblesController.addCollectible('0x01', '2', { name: 'name', image: 'image', description: 'description', @@ -446,11 +482,11 @@ describe('CollectiblesController', () => { expect(collectiblesController.state.collectibles).toHaveLength(2); expect(collectiblesController.state.ignoredCollectibles).toHaveLength(0); - collectiblesController.removeAndIgnoreCollectible('0x01', 1); + collectiblesController.removeAndIgnoreCollectible('0x01', '1'); expect(collectiblesController.state.collectibles).toHaveLength(1); expect(collectiblesController.state.ignoredCollectibles).toHaveLength(1); - await collectiblesController.addCollectible('0x01', 1, { + await collectiblesController.addCollectible('0x01', '1', { name: 'name', image: 'image', description: 'description', @@ -458,13 +494,13 @@ describe('CollectiblesController', () => { expect(collectiblesController.state.collectibles).toHaveLength(2); expect(collectiblesController.state.ignoredCollectibles).toHaveLength(1); - collectiblesController.removeAndIgnoreCollectible('0x01', 1); + collectiblesController.removeAndIgnoreCollectible('0x01', '1'); expect(collectiblesController.state.collectibles).toHaveLength(1); expect(collectiblesController.state.ignoredCollectibles).toHaveLength(1); }); it('should be able to clear the ignoredCollectibles list', async () => { - await collectiblesController.addCollectible('0x02', 1, { + await collectiblesController.addCollectible('0x02', '1', { name: 'name', image: 'image', description: 'description', @@ -473,7 +509,7 @@ describe('CollectiblesController', () => { expect(collectiblesController.state.collectibles).toHaveLength(1); expect(collectiblesController.state.ignoredCollectibles).toHaveLength(0); - collectiblesController.removeAndIgnoreCollectible('0x02', 1); + collectiblesController.removeAndIgnoreCollectible('0x02', '1'); expect(collectiblesController.state.collectibles).toHaveLength(0); expect(collectiblesController.state.ignoredCollectibles).toHaveLength(1); @@ -485,4 +521,58 @@ describe('CollectiblesController', () => { collectiblesController.setApiKey('new-api-key'); expect(collectiblesController.openSeaApiKey).toBe('new-api-key'); }); + + it('should verify the ownership of an ERC-721 collectible with the correct owner address', async () => { + assetsContract.configure({ provider: MAINNET_PROVIDER }); + const isOwner = await collectiblesController.isCollectibleOwner( + OWNER_ADDRESS, + ERC721_COLLECTIBLE_ADDRESS, + String(ERC721_COLLECTIBLE_ID), + ); + expect(isOwner).toBe(true); + }); + + it('should not verify the ownership of an ERC-721 collectible with the wrong owner address', async () => { + assetsContract.configure({ provider: MAINNET_PROVIDER }); + const isOwner = await collectiblesController.isCollectibleOwner( + '0x0000000000000000000000000000000000000000', + ERC721_COLLECTIBLE_ADDRESS, + String(ERC721_COLLECTIBLE_ID), + ); + expect(isOwner).toBe(false); + }); + + it('should verify the ownership of an ERC-1155 collectible with the correct owner address', async () => { + assetsContract.configure({ provider: MAINNET_PROVIDER }); + const isOwner = await collectiblesController.isCollectibleOwner( + OWNER_ADDRESS, + ERC1155_COLLECTIBLE_ADDRESS, + ERC1155_COLLECTIBLE_ID, + ); + expect(isOwner).toBe(true); + }); + + it('should not verify the ownership of an ERC-1155 collectible with the wrong owner address', async () => { + assetsContract.configure({ provider: MAINNET_PROVIDER }); + const isOwner = await collectiblesController.isCollectibleOwner( + '0x0000000000000000000000000000000000000000', + ERC1155_COLLECTIBLE_ADDRESS, + ERC1155_COLLECTIBLE_ID, + ); + expect(isOwner).toBe(false); + }); + + it('should throw an error for an unsupported standard', async () => { + assetsContract.configure({ provider: MAINNET_PROVIDER }); + const error = + 'Unable to verify ownership. Probably because the standard is not supported or the chain is incorrect'; + const result = async () => { + await collectiblesController.isCollectibleOwner( + '0x0000000000000000000000000000000000000000', + CRYPTOPUNK_ADDRESS, + '0', + ); + }; + await expect(result).rejects.toThrow(error); + }); }); diff --git a/src/assets/CollectiblesController.ts b/src/assets/CollectiblesController.ts index eb9a1e1155..71bdd96998 100644 --- a/src/assets/CollectiblesController.ts +++ b/src/assets/CollectiblesController.ts @@ -4,7 +4,7 @@ import { BaseController, BaseConfig, BaseState } from '../BaseController'; import type { PreferencesState } from '../user/PreferencesController'; import type { NetworkState, NetworkType } from '../network/NetworkController'; import { safelyExecute, handleFetch, toChecksumHexAddress } from '../util'; -import { MAINNET } from '../constants'; +import { MAINNET, RINKEBY_CHAIN_ID, ERC721, ERC1155 } from '../constants'; import type { ApiCollectible, ApiCollectibleCreator, @@ -34,7 +34,7 @@ import { compareCollectiblesMetadata } from './assetsUtil'; * @property creator - The collectible owner information object */ export interface Collectible extends CollectibleMetadata { - tokenId: number; + tokenId: string; address: string; } @@ -82,6 +82,9 @@ export interface CollectibleContract { * @property animationOriginal - URI of the original animation associated with this collectible * @property externalLink - External link containing additional information * @property creator - The collectible owner information object + * @property standard - NFT standard name for the collectible, e.g., ERC-721 or ERC-1155 + * @property collectionName - The name of the collectible collection. + * @property collectionImage - The image URI of the collectible collection. */ export interface CollectibleMetadata { name?: string; @@ -97,6 +100,9 @@ export interface CollectibleMetadata { externalLink?: string; creator?: ApiCollectibleCreator; lastSale?: ApiCollectibleLastSale; + standard?: string; + collectionName?: string; + collectionImage?: string; } /** @@ -141,12 +147,24 @@ export class CollectiblesController extends BaseController< > { private mutex = new Mutex(); - private getCollectibleApi(contractAddress: string, tokenId: number) { - return `https://api.opensea.io/api/v1/asset/${contractAddress}/${tokenId}`; + private getCollectibleApi(contractAddress: string, tokenId: string) { + const { chainId } = this.config; + switch (chainId) { + case RINKEBY_CHAIN_ID: + return `https://testnets-api.opensea.io/api/v1/asset/${contractAddress}/${tokenId}`; + default: + return `https://api.opensea.io/api/v1/asset/${contractAddress}/${tokenId}`; + } } private getCollectibleContractInformationApi(contractAddress: string) { - return `https://api.opensea.io/api/v1/asset_contract/${contractAddress}`; + const { chainId } = this.config; + switch (chainId) { + case RINKEBY_CHAIN_ID: + return `https://testnets-api.opensea.io/api/v1/asset_contract/${contractAddress}`; + default: + return `https://api.opensea.io/api/v1/asset_contract/${contractAddress}`; + } } /** @@ -158,7 +176,7 @@ export class CollectiblesController extends BaseController< */ private async getCollectibleInformationFromApi( contractAddress: string, - tokenId: number, + tokenId: string, ): Promise { const tokenURI = this.getCollectibleApi(contractAddress, tokenId); let collectibleInformation: ApiCollectible; @@ -184,6 +202,8 @@ export class CollectiblesController extends BaseController< external_link, creator, last_sale, + asset_contract: { schema_name }, + collection, } = collectibleInformation; /* istanbul ignore next */ @@ -204,6 +224,9 @@ export class CollectiblesController extends BaseController< }, external_link && { externalLink: external_link }, last_sale && { lastSale: last_sale }, + schema_name && { standard: schema_name }, + collection.name && { collectionName: collection.name }, + collection.image_url && { collectionImage: collection.image_url }, ); return collectibleMetadata; @@ -218,16 +241,25 @@ export class CollectiblesController extends BaseController< */ private async getCollectibleInformationFromTokenURI( contractAddress: string, - tokenId: number, + tokenId: string, ): Promise { const tokenURI = await this.getCollectibleTokenURI( contractAddress, tokenId, ); + const standard = await this.getCollectibleStandard( + contractAddress, + tokenId, + ); const object = await handleFetch(tokenURI); const image = Object.prototype.hasOwnProperty.call(object, 'image') ? 'image' : /* istanbul ignore next */ 'image_url'; + + if (standard) { + return { image: object[image], name: object.name, standard }; + } + return { image: object[image], name: object.name }; } @@ -240,9 +272,10 @@ export class CollectiblesController extends BaseController< */ private async getCollectibleInformation( contractAddress: string, - tokenId: number, + tokenId: string, ): Promise { let information; + // First try with OpenSea information = await safelyExecute(async () => { return await this.getCollectibleInformationFromApi( @@ -374,8 +407,8 @@ export class CollectiblesController extends BaseController< */ private async addIndividualCollectible( address: string, - tokenId: number, - collectibleMetadata?: CollectibleMetadata, + tokenId: string, + collectibleMetadata: CollectibleMetadata, ): Promise { const releaseLock = await this.mutex.acquire(); try { @@ -387,10 +420,6 @@ export class CollectiblesController extends BaseController< collectible.address.toLowerCase() === address.toLowerCase() && collectible.tokenId === tokenId, ); - /* istanbul ignore next */ - collectibleMetadata = - collectibleMetadata || - (await this.getCollectibleInformation(address, tokenId)); if (existingEntry) { const differentMetadata = compareCollectiblesMetadata( @@ -475,7 +504,7 @@ export class CollectiblesController extends BaseController< image_url, } = contractInformation; // If being auto-detected opensea information is expected - // Oherwise at least name and symbol from contract is needed + // Otherwise at least name and symbol from contract is needed if ( (detection && !image_url) || Object.keys(contractInformation).length === 0 @@ -526,7 +555,7 @@ export class CollectiblesController extends BaseController< */ private removeAndIgnoreIndividualCollectible( address: string, - tokenId: number, + tokenId: string, ) { address = toChecksumHexAddress(address); const { allCollectibles, collectibles, ignoredCollectibles } = this.state; @@ -567,7 +596,7 @@ export class CollectiblesController extends BaseController< * @param address - Hex address of the collectible contract. * @param tokenId - Token identifier of the collectible. */ - private removeIndividualCollectible(address: string, tokenId: number) { + private removeIndividualCollectible(address: string, tokenId: string) { address = toChecksumHexAddress(address); const { allCollectibles, collectibles } = this.state; const { chainId, selectedAddress } = this.config; @@ -624,6 +653,34 @@ export class CollectiblesController extends BaseController< return newCollectibleContracts; } + /** + * Method to verify the token standard by querying the metadata uri form the contract. + * + * @param address - Collectible asset contract address. + * @param tokenId - Collectible asset identifier. + * @returns Promise resolving the token standard. + */ + private async getCollectibleStandard( + address: string, + tokenId: string, + ): Promise { + try { + await this.getCollectibleTokenURI(address, tokenId); + return ERC721; + } catch { + // Ignore error + } + + try { + await this.uriERC1155Collectible(address, tokenId); + return ERC1155; + } catch { + // Ignore error + } + + return ''; + } + /** * EventEmitter instance used to listen to specific EIP747 events */ @@ -645,6 +702,12 @@ export class CollectiblesController extends BaseController< private getCollectibleTokenURI: AssetsContractController['getCollectibleTokenURI']; + private getOwnerOf: AssetsContractController['getOwnerOf']; + + private balanceOfERC1155Collectible: AssetsContractController['balanceOfERC1155Collectible']; + + private uriERC1155Collectible: AssetsContractController['uriERC1155Collectible']; + /** * Creates a CollectiblesController instance. * @@ -654,6 +717,9 @@ export class CollectiblesController extends BaseController< * @param options.getAssetName - Gets the name of the asset at the given address. * @param options.getAssetSymbol - Gets the symbol of the asset at the given address. * @param options.getCollectibleTokenURI - Gets the URI of the NFT at the given address, with the given ID. + * @param options.getOwnerOf - Get the owner of a ERC-721 collectible. + * @param options.balanceOfERC1155Collectible - Gets balance of a ERC-1155 collectible. + * @param options.uriERC1155Collectible - Gets uri for ERC-1155 metadata. * @param config - Initial options used to configure this controller. * @param state - Initial state to set on this controller. */ @@ -664,6 +730,9 @@ export class CollectiblesController extends BaseController< getAssetName, getAssetSymbol, getCollectibleTokenURI, + getOwnerOf, + balanceOfERC1155Collectible, + uriERC1155Collectible, }: { onPreferencesStateChange: ( listener: (preferencesState: PreferencesState) => void, @@ -674,6 +743,9 @@ export class CollectiblesController extends BaseController< getAssetName: AssetsContractController['getAssetName']; getAssetSymbol: AssetsContractController['getAssetSymbol']; getCollectibleTokenURI: AssetsContractController['getCollectibleTokenURI']; + getOwnerOf: AssetsContractController['getOwnerOf']; + balanceOfERC1155Collectible: AssetsContractController['balanceOfERC1155Collectible']; + uriERC1155Collectible: AssetsContractController['uriERC1155Collectible']; }, config?: Partial, state?: Partial, @@ -696,6 +768,9 @@ export class CollectiblesController extends BaseController< this.getAssetName = getAssetName; this.getAssetSymbol = getAssetSymbol; this.getCollectibleTokenURI = getCollectibleTokenURI; + this.getOwnerOf = getOwnerOf; + this.balanceOfERC1155Collectible = balanceOfERC1155Collectible; + this.uriERC1155Collectible = uriERC1155Collectible; onPreferencesStateChange(({ selectedAddress }) => { const { allCollectibleContracts, allCollectibles } = this.state; const { chainId } = this.config; @@ -729,6 +804,46 @@ export class CollectiblesController extends BaseController< this.openSeaApiKey = openSeaApiKey; } + /** + * Checks the ownership of a ERC-721 or ERC-1155 collectible for a given address. + * + * @param ownerAddress - User public address. + * @param collectibleAddress - Collectible contract address. + * @param collectibleId - Collectible token ID. + * @returns Promise resolving the collectible ownership. + */ + async isCollectibleOwner( + ownerAddress: string, + collectibleAddress: string, + collectibleId: string, + ): Promise { + // Checks the ownership for ERC-721. + try { + const owner = await this.getOwnerOf(collectibleAddress, collectibleId); + return ownerAddress.toLowerCase() === owner.toLowerCase(); + // eslint-disable-next-line no-empty + } catch { + // Ignore ERC-721 contract error + } + + // Checks the ownership for ERC-1155. + try { + const balance = await this.balanceOfERC1155Collectible( + ownerAddress, + collectibleAddress, + collectibleId, + ); + return balance > 0; + // eslint-disable-next-line no-empty + } catch { + // Ignore ERC-1155 contract error + } + + throw new Error( + 'Unable to verify ownership. Probably because the standard is not supported or the chain is incorrect.', + ); + } + /** * Adds a collectible and respective collectible contract to the stored collectible and collectible contracts lists. * @@ -740,7 +855,7 @@ export class CollectiblesController extends BaseController< */ async addCollectible( address: string, - tokenId: number, + tokenId: string, collectibleMetadata?: CollectibleMetadata, detection?: boolean, ) { @@ -774,7 +889,7 @@ export class CollectiblesController extends BaseController< * @param address - Hex address of the collectible contract. * @param tokenId - Token identifier of the collectible. */ - removeCollectible(address: string, tokenId: number) { + removeCollectible(address: string, tokenId: string) { address = toChecksumHexAddress(address); this.removeIndividualCollectible(address, tokenId); const { collectibles } = this.state; @@ -793,7 +908,7 @@ export class CollectiblesController extends BaseController< * @param address - Hex address of the collectible contract. * @param tokenId - Token identifier of the collectible. */ - removeAndIgnoreCollectible(address: string, tokenId: number) { + removeAndIgnoreCollectible(address: string, tokenId: string) { address = toChecksumHexAddress(address); this.removeAndIgnoreIndividualCollectible(address, tokenId); const { collectibles } = this.state; diff --git a/src/assets/assetsUtil.test.ts b/src/assets/assetsUtil.test.ts index 717fe10765..a3b29d16a8 100644 --- a/src/assets/assetsUtil.test.ts +++ b/src/assets/assetsUtil.test.ts @@ -16,7 +16,7 @@ describe('assetsUtil', () => { }; const collectible: Collectible = { address: 'address', - tokenId: 123, + tokenId: '123', name: 'name', image: 'image', backgroundColor: 'backgroundColor', @@ -41,7 +41,7 @@ describe('assetsUtil', () => { }; const collectible: Collectible = { address: 'address', - tokenId: 123, + tokenId: '123', name: 'name', image: 'image', backgroundColor: 'backgroundColor', @@ -67,7 +67,7 @@ describe('assetsUtil', () => { }; const collectible: Collectible = { address: 'address', - tokenId: 123, + tokenId: '123', name: 'name', image: 'image', backgroundColor: 'backgroundColor', diff --git a/src/constants.ts b/src/constants.ts index 9f925f23b7..29b4a8a371 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,3 +1,10 @@ export const MAINNET = 'mainnet'; export const RPC = 'rpc'; export const FALL_BACK_VS_CURRENCY = 'ETH'; + +// NETWORKS ID +export const RINKEBY_CHAIN_ID = '4'; + +// TOKEN STANDARDS +export const ERC721 = 'ERC721'; +export const ERC1155 = 'ERC1155'; diff --git a/src/dependencies.d.ts b/src/dependencies.d.ts index f564e5ea41..47880fe1f2 100644 --- a/src/dependencies.d.ts +++ b/src/dependencies.d.ts @@ -22,6 +22,8 @@ declare module 'ethjs-unit'; declare module 'human-standard-collectible-abi'; +declare module 'human-standard-multi-collectible-abi' + declare module 'human-standard-token-abi'; declare module 'isomorphic-fetch'; diff --git a/yarn.lock b/yarn.lock index deea1f6d1f..a1d33f62e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4221,6 +4221,11 @@ human-standard-collectible-abi@^1.0.2: resolved "https://registry.yarnpkg.com/human-standard-collectible-abi/-/human-standard-collectible-abi-1.0.2.tgz#077bae9ed1b0b0b82bc46932104b4b499c941aa0" integrity sha512-nD3ITUuSAIBgkaCm9J2BGwlHL8iEzFjJfTleDAC5Wi8RBJEXXhxV0JeJjd95o+rTwf98uTE5MW+VoBKOIYQh0g== +human-standard-multi-collectible-abi@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/human-standard-multi-collectible-abi/-/human-standard-multi-collectible-abi-1.0.3.tgz#be5896b13f8622289cff70040e478366931bf3d7" + integrity sha512-1VXqats7JQqDZozLKhpmFG0S33hVePrkLNRJNKfJTxewR0heYKjSoz72kqs+6O/Tywi0zW4fWe7dfTaPX4j7gQ== + human-standard-token-abi@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/human-standard-token-abi/-/human-standard-token-abi-2.0.0.tgz#e0c2057596d0a1d4a110f91f974a37f4b904f008"