diff --git a/.eslintrc.js b/.eslintrc.js index 4c4b31c736..11892a73f5 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -65,6 +65,10 @@ module.exports = { 'no-param-reassign': 'off', radix: 'off', 'require-atomic-updates': 'off', + 'jsdoc/match-description': [ + 'error', + { matchDescription: '^[A-Z`\\d_][\\s\\S]*[.?!`>)}]$' }, + ], }, settings: { 'import/resolver': { diff --git a/CHANGELOG.md b/CHANGELOG.md index d34eb53901..90288f9756 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [18.0.0] +### Added +- **BREAKING**: ERC1155 support ([#615](https://github.com/MetaMask/controllers/pull/615)) + - `CollectiblesController` requires `getOwnerOf`, `balanceOfERC1155Collectible` and `uriERC1155Collectible` properties in the constructor which are methods from `AssetsContractController`. +- Add support for custom networks by querying the blockchain as default and add support for IPFS metadata URIs ([#616](https://github.com/MetaMask/controllers/pull/616)) + +### Changed +- Bump @metamask/contract-metadata from 1.29.0 to 1.30.0 ([#607](https://github.com/MetaMask/controllers/pull/607)) + ## [17.0.0] ### Added - Add client id header to GasFeeController ([#597](https://github.com/MetaMask/controllers/pull/597)) @@ -393,7 +402,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed - Remove shapeshift controller (#209) -[Unreleased]: https://github.com/MetaMask/controllers/compare/v17.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/controllers/compare/v18.0.0...HEAD +[18.0.0]: https://github.com/MetaMask/controllers/compare/v17.0.0...v18.0.0 [17.0.0]: https://github.com/MetaMask/controllers/compare/v16.0.0...v17.0.0 [16.0.0]: https://github.com/MetaMask/controllers/compare/v15.1.0...v16.0.0 [15.1.0]: https://github.com/MetaMask/controllers/compare/v15.0.2...v15.1.0 diff --git a/package.json b/package.json index 282f2d9fa9..4f501a195e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/controllers", - "version": "17.0.0", + "version": "18.0.0", "description": "Collection of platform-agnostic modules for creating secure data models for cryptocurrency wallets", "keywords": [ "MetaMask", @@ -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..8341ca0301 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,105 @@ 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 throw an error when address given is not an ERC-721 collectible', async () => { assetsContract.configure({ provider: MAINNET_PROVIDER }); - const tokenId = await assetsContract.getCollectibleTokenURI( - '0x0000000000000000000000000000000000000000', - 0, - ); - expect(tokenId).toStrictEqual(''); + const result = async () => { + await assetsContract.getCollectibleTokenURI( + '0x0000000000000000000000000000000000000000', + '0', + ); + }; + + const error = 'Contract does not support ERC721 metadata interface.'; + await expect(result).rejects.toThrow(error); }); - 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..b79ae1004b 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,11 +442,12 @@ describe('AssetsDetectionController', () => { assetsDetection.configure({ networkType: MAINNET, selectedAddress: '0x1' }); await collectiblesController.addCollectible( '0xebE4e5E773AFD2bAc25De0cFafa084CFb3cBf1eD', - 2573, + '2573', { description: 'Description 2573', image: 'image/2573.png', name: 'ID 2573', + standard: 'ERC721', }, ); await assetsDetection.detectCollectibles(); @@ -421,14 +457,18 @@ describe('AssetsDetectionController', () => { description: 'Description 2573', image: 'image/2573.png', name: 'ID 2573', - tokenId: 2573, + standard: 'ERC721', + 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 +480,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 +513,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 +601,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 +615,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 +629,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..e530467c03 --- /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) { + throw new Error('Contract does not support ERC721 metadata interface.'); + } + 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..42b7ab088f 100644 --- a/src/assets/CollectiblesController.test.ts +++ b/src/assets/CollectiblesController.test.ts @@ -9,13 +9,29 @@ import { import { AssetsContractController } from './AssetsContractController'; import { CollectiblesController } from './CollectiblesController'; -const KUDOSADDRESS = '0x2aea4add166ebf38b63d09a75de1a7b94aa24163'; +const CRYPTOPUNK_ADDRESS = '0xb47e3cd837dDF8e4c57F05d70Ab865de6e193BBB'; +const ERC721_KUDOSADDRESS = '0x2aEa4Add166EBf38b63d09a75dE1a7b94Aa24163'; +const ERC721_COLLECTIBLE_ADDRESS = '0x60f80121c31a0d46b5279700f9df786054aa5ee5'; +const ERC721_COLLECTIBLE_ID = '1144858'; +const ERC1155_COLLECTIBLE_ADDRESS = + '0x495f947276749Ce646f68AC8c248420045cb7b5e'; +const ERC1155_COLLECTIBLE_ID = + '40815311521795738946686668571398122012172359753720345430028676522525371400193'; +const ERC1155_DEPRESSIONIST_ADDRESS = + '0x18e8e76aeb9e2d9fa2a2b88dd9cf3c8ed45c3660'; +const ERC1155_DEPRESSIONIST_ID = '36'; +const OWNER_ADDRESS = '0x5a3CA5cD63807Ce5e4d7841AB32Ce6B6d9BbBa2D'; const MAINNET_PROVIDER = new HttpProvider( 'https://mainnet.infura.io/v3/341eacb578dd44a1a049cbc5f6fd4035', ); + const OPEN_SEA_HOST = 'https://api.opensea.io'; const OPEN_SEA_PATH = '/api/v1'; +const CLOUDFARE_PATH = 'https://cloudflare-ipfs.com/ipfs'; +const DEPRESSIONIST_IPFS_PATH = + '/QmVChNtStZfPyV8JfKpube3eigQh5rUXqYchPgLc91tWLJ'; + describe('CollectiblesController', () => { let collectiblesController: CollectiblesController; let preferences: PreferencesController; @@ -35,6 +51,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) @@ -58,15 +81,30 @@ describe('CollectiblesController', () => { .reply(200, { description: 'Description', image_original_url: 'url', + image_url: 'url', name: 'Name', + asset_contract: { + schema_name: 'ERC1155', + }, + collection: { + name: 'Collection Name', + image_url: 'collection.url', + }, }) .get( `${OPEN_SEA_PATH}/asset/0x2aEa4Add166EBf38b63d09a75dE1a7b94Aa24163/1203`, ) .reply(200, { - description: 'Kudos Description', image_original_url: 'Kudos url', name: 'Kudos Name', + description: 'Kudos Description', + asset_contract: { + schema_name: 'ERC721', + }, + collection: { + name: 'Collection Name', + image_url: 'collection.url', + }, }) .get( `${OPEN_SEA_PATH}/asset/0x6EbeAf8e8E946F0716E6533A6f2cefc83f60e8Ab/798958393`, @@ -90,9 +128,42 @@ describe('CollectiblesController', () => { nock('https://ipfs.gitcoin.co:443') .get('/api/v0/cat/QmPmt6EAaioN78ECnW5oCL8v2YvVSpoBjLCjrXhhsAvoov') .reply(200, { - image: 'Kudos Image', - name: 'Kudos Name', + image: 'Kudos Image (from uri)', + name: 'Kudos Name (from uri)', + description: 'Kudos Description (from uri)', }); + + nock(OPEN_SEA_HOST) + .get( + '/api/v1/metadata/0x495f947276749Ce646f68AC8c248420045cb7b5e/0x5a3ca5cd63807ce5e4d7841ab32ce6b6d9bbba2d000000000000010000000001', + ) + .reply(200, { + name: 'name (from contract uri)', + description: null, + external_link: null, + image: 'image (from contract uri)', + animation_url: null, + }); + + nock(OPEN_SEA_HOST) + .get( + '/api/v1/asset/0x495f947276749Ce646f68AC8c248420045cb7b5e/40815311521795738946686668571398122012172359753720345430028676522525371400193', + ) + .reply(200, { + num_sales: 1, + image_original_url: 'image.uri', + name: 'name', + image: 'image', + description: 'description', + asset_contract: { schema_name: 'ERC1155' }, + collection: { name: 'collection', image_uri: 'collection.uri' }, + }); + + nock(CLOUDFARE_PATH).get(DEPRESSIONIST_IPFS_PATH).reply(200, { + name: 'name', + image: 'image', + description: 'description', + }); }); afterEach(() => { @@ -111,10 +182,11 @@ 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', + standard: 'standard', }); expect(collectiblesController.state.collectibles[0]).toStrictEqual({ @@ -122,7 +194,8 @@ describe('CollectiblesController', () => { description: 'description', image: 'image', name: 'name', - tokenId: 1, + tokenId: '1', + standard: 'standard', }); expect(collectiblesController.state.collectibleContracts[0]).toStrictEqual({ @@ -136,10 +209,11 @@ 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', + standard: 'standard', }); expect(collectiblesController.state.collectibles[0]).toStrictEqual({ @@ -147,13 +221,15 @@ describe('CollectiblesController', () => { description: 'description', image: 'image', name: 'name', - tokenId: 1, + standard: 'standard', + tokenId: '1', }); - await collectiblesController.addCollectible('0x01', 1, { + await collectiblesController.addCollectible('0x01', '1', { name: 'name', image: 'image-updated', description: 'description', + standard: 'standard', }); expect(collectiblesController.state.collectibles[0]).toStrictEqual({ @@ -161,55 +237,116 @@ describe('CollectiblesController', () => { description: 'description', image: 'image-updated', name: 'name', - tokenId: 1, + tokenId: '1', + standard: 'standard', }); }); 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', + standard: 'standard', }); - await collectiblesController.addCollectible('0x01', 1, { + await collectiblesController.addCollectible('0x01', '1', { name: 'name', image: 'image', description: 'description', + standard: 'standard', }); expect(collectiblesController.state.collectibles).toHaveLength(1); expect(collectiblesController.state.collectibleContracts).toHaveLength(1); }); 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', + standard: 'standard', }); - await collectiblesController.addCollectible('0x01', 2, { + await collectiblesController.addCollectible('0x01', '2', { name: 'name', image: 'image', description: 'description', + standard: 'standard', }); expect(collectiblesController.state.collectibles).toHaveLength(2); expect(collectiblesController.state.collectibleContracts).toHaveLength(1); }); 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', + image: 'url', name: 'Name', - tokenId: 1, + standard: 'ERC1155', + tokenId: '1', + collectionName: 'Collection Name', + collectionImage: 'collection.url', + }); + }); + + it('should add collectible erc1155 and get collectible contract information from contract', async () => { + assetsContract.configure({ provider: MAINNET_PROVIDER }); + await collectiblesController.addCollectible( + ERC1155_COLLECTIBLE_ADDRESS, + ERC1155_COLLECTIBLE_ID, + ); + + expect(collectiblesController.state.collectibles[0]).toStrictEqual({ + address: ERC1155_COLLECTIBLE_ADDRESS, + image: 'image (from contract uri)', + name: 'name (from contract uri)', + description: 'description', + tokenId: + '40815311521795738946686668571398122012172359753720345430028676522525371400193', + collectionName: 'collection', + imageOriginal: 'image.uri', + numberOfSales: 1, + standard: 'ERC1155', }); }); - it('should add collectible and get collectible contract information from contract', async () => { + it('should add collectible erc721 and get collectible contract information from contract and OpenSea', async () => { assetsContract.configure({ provider: MAINNET_PROVIDER }); + + sandbox + .stub( + collectiblesController, + 'getCollectibleContractInformationFromApi' as any, + ) + .returns(undefined); + + await collectiblesController.addCollectible(ERC721_KUDOSADDRESS, '1203'); + expect(collectiblesController.state.collectibles[0]).toStrictEqual({ + address: ERC721_KUDOSADDRESS, + image: 'Kudos Image (from uri)', + name: 'Kudos Name (from uri)', + description: 'Kudos Description (from uri)', + tokenId: '1203', + collectionImage: 'collection.url', + collectionName: 'Collection Name', + imageOriginal: 'Kudos url', + standard: 'ERC721', + }); + + expect(collectiblesController.state.collectibleContracts[0]).toStrictEqual({ + address: ERC721_KUDOSADDRESS, + name: 'KudosToken', + symbol: 'KDO', + }); + }); + + it('should add collectible erc721 and get collectible contract information only from contract', async () => { + assetsContract.configure({ provider: MAINNET_PROVIDER }); + sandbox .stub( collectiblesController, @@ -220,16 +357,18 @@ 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, + address: ERC721_KUDOSADDRESS, + image: 'Kudos Image (from uri)', + name: 'Kudos Name (from uri)', + description: 'Kudos Description (from uri)', + tokenId: '1203', + standard: 'ERC721', }); expect(collectiblesController.state.collectibleContracts[0]).toStrictEqual({ - address: '0x2aEa4Add166EBf38b63d09a75dE1a7b94Aa24163', + address: ERC721_KUDOSADDRESS, name: 'KudosToken', symbol: 'KDO', }); @@ -242,16 +381,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 +407,7 @@ describe('CollectiblesController', () => { chainId: NetworksChainId[firstNetworkType], }, }); - await collectiblesController.addCollectible('0x01', 1234); + await collectiblesController.addCollectible('0x01', '1234'); network.update({ provider: { type: secondNetworkType, @@ -288,39 +427,43 @@ 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, ); expect(collectiblesController.state.collectibles).toStrictEqual([]); expect(collectiblesController.state.collectibleContracts).toStrictEqual([]); await collectiblesController.addCollectible( - '0x2aEa4Add166EBf38b63d09a75dE1a7b94Aa24163', - 1203, + ERC721_KUDOSADDRESS, + '1203', undefined, true, ); expect(collectiblesController.state.collectibles).toStrictEqual([ { - address: '0x2aEa4Add166EBf38b63d09a75dE1a7b94Aa24163', + address: ERC721_KUDOSADDRESS, description: 'Kudos Description', imageOriginal: 'Kudos url', name: 'Kudos Name', - tokenId: 1203, + image: null, + standard: 'ERC721', + tokenId: '1203', + collectionImage: 'collection.url', + collectionName: 'Collection Name', }, ]); expect(collectiblesController.state.collectibleContracts).toStrictEqual([ { - address: '0x2aEa4Add166EBf38b63d09a75dE1a7b94Aa24163', + address: ERC721_KUDOSADDRESS, description: 'Kudos Description', logo: 'Kudos url', name: 'Kudos', @@ -331,29 +474,32 @@ 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', + standard: 'standard', }); - 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', + standard: 'standard', }); - await collectiblesController.addCollectible('0x01', 2, { + await collectiblesController.addCollectible('0x01', '2', { name: 'name', image: 'image', description: 'description', + standard: 'standard', }); - collectiblesController.removeCollectible('0x01', 1); + collectiblesController.removeCollectible('0x01', '1'); expect(collectiblesController.state.collectibles).toHaveLength(1); expect(collectiblesController.state.collectibleContracts).toHaveLength(1); }); @@ -365,10 +511,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 +522,7 @@ describe('CollectiblesController', () => { description: 'description', image: 'url', name: 'name', - tokenId: 4321, + tokenId: '4321', }); }); @@ -392,16 +538,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 +561,7 @@ describe('CollectiblesController', () => { description: 'description', image: 'url', name: 'name', - tokenId: 4321, + tokenId: '4321', }); }); @@ -431,49 +577,53 @@ 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', + standard: 'standard', }); - await collectiblesController.addCollectible('0x01', 2, { + await collectiblesController.addCollectible('0x01', '2', { name: 'name', image: 'image', description: 'description', + standard: 'standard', }); 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', + standard: 'standard', }); 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', + standard: 'standard', }); 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 +635,81 @@ 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); + }); + + it('should add collectible with metadata hosted in IPFS', async () => { + assetsContract.configure({ provider: MAINNET_PROVIDER }); + await collectiblesController.addCollectible( + ERC1155_DEPRESSIONIST_ADDRESS, + ERC1155_DEPRESSIONIST_ID, + ); + + expect(collectiblesController.state.collectibleContracts[0]).toStrictEqual({ + address: '0x18E8E76aeB9E2d9FA2A2b88DD9CF3C8ED45c3660', + name: "Maltjik.jpg's Depressionists", + symbol: 'DPNS', + }); + + expect(collectiblesController.state.collectibles[0]).toStrictEqual({ + address: '0x18E8E76aeB9E2d9FA2A2b88DD9CF3C8ED45c3660', + tokenId: '36', + image: 'image', + name: 'name', + description: 'description', + standard: 'ERC721', + }); + }); }); diff --git a/src/assets/CollectiblesController.ts b/src/assets/CollectiblesController.ts index eb9a1e1155..1656e4869c 100644 --- a/src/assets/CollectiblesController.ts +++ b/src/assets/CollectiblesController.ts @@ -1,10 +1,23 @@ import { EventEmitter } from 'events'; +import { BN, stripHexPrefix } from 'ethereumjs-util'; import { Mutex } from 'async-mutex'; 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 { + safelyExecute, + handleFetch, + toChecksumHexAddress, + BNToHex, + getIpfsUrlContentIdentifier, +} from '../util'; +import { + MAINNET, + RINKEBY_CHAIN_ID, + IPFS_DEFAULT_GATEWAY_URL, + ERC721, + ERC1155, +} from '../constants'; import type { ApiCollectible, ApiCollectibleCreator, @@ -34,7 +47,7 @@ import { compareCollectiblesMetadata } from './assetsUtil'; * @property creator - The collectible owner information object */ export interface Collectible extends CollectibleMetadata { - tokenId: number; + tokenId: string; address: string; } @@ -82,13 +95,17 @@ 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; - description?: string; + name: string | null; + description: string | null; + image: string | null; + standard: string | null; numberOfSales?: number; backgroundColor?: string; - image?: string; imagePreview?: string; imageThumbnail?: string; imageOriginal?: string; @@ -97,6 +114,8 @@ export interface CollectibleMetadata { externalLink?: string; creator?: ApiCollectibleCreator; lastSale?: ApiCollectibleLastSale; + collectionName?: string; + collectionImage?: string; } /** @@ -141,12 +160,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,10 +189,11 @@ export class CollectiblesController extends BaseController< */ private async getCollectibleInformationFromApi( contractAddress: string, - tokenId: number, + tokenId: string, ): Promise { const tokenURI = this.getCollectibleApi(contractAddress, tokenId); let collectibleInformation: ApiCollectible; + /* istanbul ignore if */ if (this.openSeaApiKey) { collectibleInformation = await handleFetch(tokenURI, { @@ -170,6 +202,7 @@ export class CollectiblesController extends BaseController< } else { collectibleInformation = await handleFetch(tokenURI); } + const { num_sales, background_color, @@ -184,15 +217,17 @@ export class CollectiblesController extends BaseController< external_link, creator, last_sale, + asset_contract: { schema_name }, + collection, } = collectibleInformation; /* istanbul ignore next */ const collectibleMetadata: CollectibleMetadata = Object.assign( {}, - { name }, + { name: name || null }, + { description: description || null }, + { image: image_url || null }, creator && { creator }, - description && { description }, - image_url && { image: image_url }, num_sales && { numberOfSales: num_sales }, background_color && { backgroundColor: background_color }, image_preview_url && { imagePreview: image_preview_url }, @@ -204,6 +239,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,17 +256,88 @@ export class CollectiblesController extends BaseController< */ private async getCollectibleInformationFromTokenURI( contractAddress: string, - tokenId: number, + tokenId: string, ): Promise { - const tokenURI = await this.getCollectibleTokenURI( + const result = await this.getCollectibleURIAndStandard( contractAddress, tokenId, ); - const object = await handleFetch(tokenURI); - const image = Object.prototype.hasOwnProperty.call(object, 'image') - ? 'image' - : /* istanbul ignore next */ 'image_url'; - return { image: object[image], name: object.name }; + let tokenURI = result[0]; + const standard = result[1]; + + if (tokenURI.startsWith('ipfs://')) { + const contentId = getIpfsUrlContentIdentifier(tokenURI); + tokenURI = IPFS_DEFAULT_GATEWAY_URL + contentId; + } + + try { + const object = await handleFetch(tokenURI); + // TODO: Check image_url existence. This is not part of EIP721 nor EIP1155 + const image = Object.prototype.hasOwnProperty.call(object, 'image') + ? 'image' + : /* istanbul ignore next */ 'image_url'; + + return { + image: object[image], + name: object.name, + description: object.description, + standard, + }; + } catch { + return { + image: null, + name: null, + description: null, + standard: standard || null, + }; + } + } + + /** + * Retrieve collectible uri with metadata. TODO Update method to use IPFS. + * + * @param contractAddress - Collectible contract address. + * @param tokenId - Collectible token id. + * @returns Promise resolving collectible uri and token standard. + */ + private async getCollectibleURIAndStandard( + contractAddress: string, + tokenId: string, + ): Promise<[string, string]> { + // try ERC721 uri + try { + const uri = await this.getCollectibleTokenURI(contractAddress, tokenId); + return [uri, ERC721]; + } catch { + // Ignore error + } + + // try ERC1155 uri + try { + const tokenURI = await this.uriERC1155Collectible( + contractAddress, + tokenId, + ); + + /** + * According to EIP1155 the URI value allows for ID substitution + * in case the string `{id}` exists. + * https://eips.ethereum.org/EIPS/eip-1155#metadata + */ + + if (!tokenURI.includes('{id}')) { + return [tokenURI, ERC1155]; + } + + const hexTokenId = stripHexPrefix(BNToHex(new BN(tokenId))) + .padStart(64, '0') + .toLowerCase(); + return [tokenURI.replace('{id}', hexTokenId), ERC1155]; + } catch { + // Ignore error + } + + return ['', '']; } /** @@ -240,35 +349,31 @@ 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( + const blockchainMetadata = await safelyExecute(async () => { + return await this.getCollectibleInformationFromTokenURI( contractAddress, tokenId, ); }); - if (information) { - return information; - } - - // Then following ERC721 standard - information = await safelyExecute(async () => { - return await this.getCollectibleInformationFromTokenURI( + const openSeaMetadata = await safelyExecute(async () => { + return await this.getCollectibleInformationFromApi( contractAddress, tokenId, ); }); - /* istanbul ignore next */ - if (information) { - return information; - } - /* istanbul ignore next */ - return {}; + return { + ...openSeaMetadata, + name: blockchainMetadata.name ?? openSeaMetadata?.name ?? null, + description: + blockchainMetadata.description ?? openSeaMetadata?.description ?? null, + image: blockchainMetadata.image ?? openSeaMetadata?.image ?? null, + standard: + blockchainMetadata.standard ?? openSeaMetadata?.standard ?? null, + }; } /** @@ -301,20 +406,13 @@ export class CollectiblesController extends BaseController< */ private async getCollectibleContractInformationFromContract( contractAddress: string, - ): Promise { + ): Promise> { const name = await this.getAssetName(contractAddress); const symbol = await this.getAssetSymbol(contractAddress); return { name, symbol, address: contractAddress, - asset_contract_type: null, - created_date: null, - schema_name: null, - total_supply: null, - description: null, - external_link: null, - image_url: null, }; } @@ -327,28 +425,22 @@ export class CollectiblesController extends BaseController< private async getCollectibleContractInformation( contractAddress: string, ): Promise { - let information; - // First try with OpenSea - information = await safelyExecute(async () => { - return await this.getCollectibleContractInformationFromApi( + const blockchainContractData = await safelyExecute(async () => { + return await this.getCollectibleContractInformationFromContract( contractAddress, ); }); - if (information) { - return information; - } - - // Then following ERC721 standard - information = await safelyExecute(async () => { - return await this.getCollectibleContractInformationFromContract( + const openSeaContractData = await safelyExecute(async () => { + return await this.getCollectibleContractInformationFromApi( contractAddress, ); }); - if (information) { - return information; + if (blockchainContractData || openSeaContractData) { + return { ...openSeaContractData, ...blockchainContractData }; } + /* istanbul ignore next */ return { address: contractAddress, @@ -374,9 +466,10 @@ export class CollectiblesController extends BaseController< */ private async addIndividualCollectible( address: string, - tokenId: number, - collectibleMetadata?: CollectibleMetadata, + tokenId: string, + collectibleMetadata: CollectibleMetadata, ): Promise { + // TODO: Remove unused return const releaseLock = await this.mutex.acquire(); try { address = toChecksumHexAddress(address); @@ -387,10 +480,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( @@ -398,6 +487,7 @@ export class CollectiblesController extends BaseController< existingEntry, ); if (differentMetadata) { + // TODO: Switch to indexToUpdate const indexToRemove = collectibles.findIndex( (collectible) => collectible.address.toLowerCase() === address.toLowerCase() && @@ -463,6 +553,7 @@ export class CollectiblesController extends BaseController< const contractInformation = await this.getCollectibleContractInformation( address, ); + const { asset_contract_type, created_date, @@ -475,13 +566,14 @@ 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 ) { return collectibleContracts; } + /* istanbul ignore next */ const newEntry: CollectibleContract = Object.assign( {}, @@ -490,7 +582,8 @@ export class CollectiblesController extends BaseController< name && { name }, image_url && { logo: image_url }, symbol && { symbol }, - total_supply !== null && { totalSupply: total_supply }, + total_supply !== null && + typeof total_supply !== 'undefined' && { totalSupply: total_supply }, asset_contract_type && { assetContractType: asset_contract_type }, created_date && { createdDate: created_date }, schema_name && { schemaName: schema_name }, @@ -526,7 +619,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 +660,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; @@ -645,6 +738,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. * @@ -653,7 +752,10 @@ export class CollectiblesController extends BaseController< * @param options.onNetworkStateChange - Allows subscribing to network controller state changes. * @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.getCollectibleTokenURI - Gets the URI of the ERC721 token 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 the URI of the ERC1155 token at the given address, with the given ID. * @param config - Initial options used to configure this controller. * @param state - Initial state to set on this controller. */ @@ -664,6 +766,9 @@ export class CollectiblesController extends BaseController< getAssetName, getAssetSymbol, getCollectibleTokenURI, + getOwnerOf, + balanceOfERC1155Collectible, + uriERC1155Collectible, }: { onPreferencesStateChange: ( listener: (preferencesState: PreferencesState) => void, @@ -674,6 +779,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 +804,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 +840,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 +891,7 @@ export class CollectiblesController extends BaseController< */ async addCollectible( address: string, - tokenId: number, + tokenId: string, collectibleMetadata?: CollectibleMetadata, detection?: boolean, ) { @@ -774,7 +925,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 +944,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..103093f4d7 100644 --- a/src/assets/assetsUtil.test.ts +++ b/src/assets/assetsUtil.test.ts @@ -5,7 +5,10 @@ describe('assetsUtil', () => { describe('compareCollectiblesMetadata', () => { it('should resolve true if any key is different', () => { const collectibleMetadata: CollectibleMetadata = { + name: 'name', image: 'image', + description: 'description', + standard: 'standard', backgroundColor: 'backgroundColor', imagePreview: 'imagePreview', imageThumbnail: 'imageThumbnail', @@ -16,9 +19,11 @@ describe('assetsUtil', () => { }; const collectible: Collectible = { address: 'address', - tokenId: 123, + tokenId: '123', name: 'name', image: 'image', + description: 'description', + standard: 'standard', backgroundColor: 'backgroundColor', imagePreview: 'imagePreview', imageThumbnail: 'imageThumbnail', @@ -36,14 +41,19 @@ describe('assetsUtil', () => { it('should resolve true if any key is different as always as metadata is not undefined', () => { const collectibleMetadata: CollectibleMetadata = { + name: 'name', image: 'image', + description: 'description', + standard: 'standard', externalLink: 'externalLink', }; const collectible: Collectible = { address: 'address', - tokenId: 123, + tokenId: '123', name: 'name', image: 'image', + standard: 'standard', + description: 'description', backgroundColor: 'backgroundColor', externalLink: 'externalLink', }; @@ -56,7 +66,10 @@ describe('assetsUtil', () => { it('should resolve false if no key is different', () => { const collectibleMetadata: CollectibleMetadata = { + name: 'name', image: 'image', + description: 'description', + standard: 'standard', backgroundColor: 'backgroundColor', imagePreview: 'imagePreview', imageThumbnail: 'imageThumbnail', @@ -67,9 +80,11 @@ describe('assetsUtil', () => { }; const collectible: Collectible = { address: 'address', - tokenId: 123, + tokenId: '123', name: 'name', image: 'image', + standard: 'standard', + description: 'description', backgroundColor: 'backgroundColor', imagePreview: 'imagePreview', imageThumbnail: 'imageThumbnail', diff --git a/src/constants.ts b/src/constants.ts index 9f925f23b7..07bf5f1914 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,3 +1,11 @@ export const MAINNET = 'mainnet'; export const RPC = 'rpc'; export const FALL_BACK_VS_CURRENCY = 'ETH'; +export const IPFS_DEFAULT_GATEWAY_URL = 'https://cloudflare-ipfs.com/ipfs/'; + +// 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/src/gas/GasFeeController.test.ts b/src/gas/GasFeeController.test.ts index 5bfe9a3ceb..781afc965e 100644 --- a/src/gas/GasFeeController.test.ts +++ b/src/gas/GasFeeController.test.ts @@ -13,6 +13,7 @@ import { } from './GasFeeController'; import determineGasFeeSuggestions from './determineGasFeeSuggestions'; +jest.mock('./gas-util'); jest.mock('./determineGasFeeSuggestions'); const mockedDetermineGasFeeSuggestions = mocked( diff --git a/src/util.test.ts b/src/util.test.ts index 8912217167..e052ea49f9 100644 --- a/src/util.test.ts +++ b/src/util.test.ts @@ -14,6 +14,9 @@ const VALID = '4e1fF7229BDdAf0A73DF183a88d9c3a04cc975e0'; const SOME_API = 'https://someapi.com'; const SOME_FAILING_API = 'https://somefailingapi.com'; +const DEFAULT_IPFS_URL = 'ipfs://0001'; +const ALTERNATIVE_IPFS_URL = 'ipfs://ipfs/0001'; + const MAX_FEE_PER_GAS = 'maxFeePerGas'; const MAX_PRIORITY_FEE_PER_GAS = 'maxPriorityFeePerGas'; const GAS_PRICE = 'gasPrice'; @@ -1064,4 +1067,24 @@ describe('util', () => { ).not.toThrow(Error); }); }); + + describe('getIpfsUrlContentIdentifier', () => { + it('should return content identifier from default ipfs url', () => { + expect(util.getIpfsUrlContentIdentifier(DEFAULT_IPFS_URL)).toStrictEqual( + '0001', + ); + }); + + it('should return content identifier from alternative ipfs url', () => { + expect( + util.getIpfsUrlContentIdentifier(ALTERNATIVE_IPFS_URL), + ).toStrictEqual('0001'); + }); + + it('should return url if its not a ipfs standard url', () => { + expect(util.getIpfsUrlContentIdentifier(SOME_API)).toStrictEqual( + SOME_API, + ); + }); + }); }); diff --git a/src/util.ts b/src/util.ts index 56ec3fccbe..905f6820bc 100644 --- a/src/util.ts +++ b/src/util.ts @@ -767,3 +767,21 @@ export function validateMinimumIncrease(proposed: string, min: string) { const errorMsg = `The proposed value: ${proposedDecimal} should meet or exceed the minimum value: ${minDecimal}`; throw new Error(errorMsg); } + +/** + * Extracts content identifier from ipfs url. + * + * @param url - Ipfs url. + * @returns Ipfs content identifier as string. + */ +export function getIpfsUrlContentIdentifier(url: string): string { + if (url.startsWith('ipfs://ipfs/')) { + return url.replace('ipfs://ipfs/', ''); + } + + if (url.startsWith('ipfs://')) { + return url.replace('ipfs://', ''); + } + + return url; +} 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"