From a87d2a0c12410a6a39a2fe9662f64cbe9a4ae2ea Mon Sep 17 00:00:00 2001 From: Gustavo Antunes Date: Thu, 28 Oct 2021 14:32:42 -0300 Subject: [PATCH 1/5] 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" From 5950bb3146ee04a3b6d9a6b41d2deefa3ee9e703 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 28 Oct 2021 14:09:43 -0600 Subject: [PATCH 2/5] Allow JSDoc descriptions to end with list of links (#617) When giving descriptions to functions, classes, etc., it can sometimes be useful to add a list of links at the end which the reader may wish to visit in order to learn more. For instance: ``` javascript /* * This function does something important. For more information, see: * * - * - */ function foo() { // do something } ``` This commit adds support for the above. It also adds support for Markdown links with alternate text, e.g. `[some link](https://foo.com)`, as well as the use of `@link`, e.g. `{@link SomeClass}`. --- .eslintrc.js | 4 ++++ 1 file changed, 4 insertions(+) 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': { From 8ecd2c65fb24187df6ac98a669a0bd86747f2135 Mon Sep 17 00:00:00 2001 From: Gustavo Antunes Date: Fri, 29 Oct 2021 12:44:22 -0300 Subject: [PATCH 3/5] Custom network + IPFS support (#616) Add support for custom networks and IPFS urls --- src/assets/AssetsContractController.test.ts | 16 +- src/assets/AssetsDetectionController.test.ts | 2 + .../ERC721/ERC721Standard.ts | 2 +- src/assets/CollectiblesController.test.ts | 163 +++++++++++-- src/assets/CollectiblesController.ts | 218 ++++++++++-------- src/assets/assetsUtil.test.ts | 15 ++ src/constants.ts | 1 + src/util.test.ts | 23 ++ src/util.ts | 18 ++ 9 files changed, 347 insertions(+), 111 deletions(-) diff --git a/src/assets/AssetsContractController.test.ts b/src/assets/AssetsContractController.test.ts index 163271ce85..8341ca0301 100644 --- a/src/assets/AssetsContractController.test.ts +++ b/src/assets/AssetsContractController.test.ts @@ -67,13 +67,17 @@ describe('AssetsContractController', () => { expect(tokenId).toStrictEqual('https://api.godsunchained.com/card/0'); }); - it('should return empty string as URI when address given is not an ERC-721 collectible', 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 ERC-721 collectible name', async () => { diff --git a/src/assets/AssetsDetectionController.test.ts b/src/assets/AssetsDetectionController.test.ts index 7cf2b3cd6b..b79ae1004b 100644 --- a/src/assets/AssetsDetectionController.test.ts +++ b/src/assets/AssetsDetectionController.test.ts @@ -447,6 +447,7 @@ describe('AssetsDetectionController', () => { description: 'Description 2573', image: 'image/2573.png', name: 'ID 2573', + standard: 'ERC721', }, ); await assetsDetection.detectCollectibles(); @@ -456,6 +457,7 @@ describe('AssetsDetectionController', () => { description: 'Description 2573', image: 'image/2573.png', name: 'ID 2573', + standard: 'ERC721', tokenId: '2573', }, { diff --git a/src/assets/CollectibleStandards/ERC721/ERC721Standard.ts b/src/assets/CollectibleStandards/ERC721/ERC721Standard.ts index 657e0ae66d..e530467c03 100644 --- a/src/assets/CollectibleStandards/ERC721/ERC721Standard.ts +++ b/src/assets/CollectibleStandards/ERC721/ERC721Standard.ts @@ -76,7 +76,7 @@ export class ERC721Standard { contract, ); if (!supportsMetadata) { - return ''; + throw new Error('Contract does not support ERC721 metadata interface.'); } return new Promise((resolve, reject) => { contract.tokenURI(tokenId, (error: Error, result: string) => { diff --git a/src/assets/CollectiblesController.test.ts b/src/assets/CollectiblesController.test.ts index 545f8451ee..42b7ab088f 100644 --- a/src/assets/CollectiblesController.test.ts +++ b/src/assets/CollectiblesController.test.ts @@ -9,21 +9,29 @@ import { import { AssetsContractController } from './AssetsContractController'; import { CollectiblesController } from './CollectiblesController'; -const ERC721_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'; + '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; @@ -73,6 +81,7 @@ describe('CollectiblesController', () => { .reply(200, { description: 'Description', image_original_url: 'url', + image_url: 'url', name: 'Name', asset_contract: { schema_name: 'ERC1155', @@ -86,9 +95,9 @@ describe('CollectiblesController', () => { `${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', }, @@ -119,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(() => { @@ -144,6 +186,7 @@ describe('CollectiblesController', () => { name: 'name', image: 'image', description: 'description', + standard: 'standard', }); expect(collectiblesController.state.collectibles[0]).toStrictEqual({ @@ -152,6 +195,7 @@ describe('CollectiblesController', () => { image: 'image', name: 'name', tokenId: '1', + standard: 'standard', }); expect(collectiblesController.state.collectibleContracts[0]).toStrictEqual({ @@ -169,6 +213,7 @@ describe('CollectiblesController', () => { name: 'name', image: 'image', description: 'description', + standard: 'standard', }); expect(collectiblesController.state.collectibles[0]).toStrictEqual({ @@ -176,6 +221,7 @@ describe('CollectiblesController', () => { description: 'description', image: 'image', name: 'name', + standard: 'standard', tokenId: '1', }); @@ -183,6 +229,7 @@ describe('CollectiblesController', () => { name: 'name', image: 'image-updated', description: 'description', + standard: 'standard', }); expect(collectiblesController.state.collectibles[0]).toStrictEqual({ @@ -191,6 +238,7 @@ describe('CollectiblesController', () => { image: 'image-updated', name: 'name', tokenId: '1', + standard: 'standard', }); }); @@ -199,12 +247,14 @@ describe('CollectiblesController', () => { name: 'name', image: 'image', description: 'description', + standard: 'standard', }); 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); @@ -215,12 +265,14 @@ describe('CollectiblesController', () => { name: 'name', image: 'image', description: 'description', + standard: 'standard', }); 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); @@ -232,6 +284,7 @@ describe('CollectiblesController', () => { address: '0x01', description: 'Description', imageOriginal: 'url', + image: 'url', name: 'Name', standard: 'ERC1155', tokenId: '1', @@ -240,8 +293,60 @@ describe('CollectiblesController', () => { }); }); - it('should add collectible and get collectible contract information from contract', async () => { + 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 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, @@ -254,15 +359,16 @@ describe('CollectiblesController', () => { .returns(undefined); await collectiblesController.addCollectible(ERC721_KUDOSADDRESS, '1203'); expect(collectiblesController.state.collectibles[0]).toStrictEqual({ - address: '0x2aEa4Add166EBf38b63d09a75dE1a7b94Aa24163', - image: 'Kudos Image', - name: 'Kudos Name', + 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', }); @@ -335,7 +441,7 @@ describe('CollectiblesController', () => { expect(collectiblesController.state.collectibles).toStrictEqual([]); expect(collectiblesController.state.collectibleContracts).toStrictEqual([]); await collectiblesController.addCollectible( - '0x2aEa4Add166EBf38b63d09a75dE1a7b94Aa24163', + ERC721_KUDOSADDRESS, '1203', undefined, true, @@ -343,10 +449,11 @@ describe('CollectiblesController', () => { expect(collectiblesController.state.collectibles).toStrictEqual([ { - address: '0x2aEa4Add166EBf38b63d09a75dE1a7b94Aa24163', + address: ERC721_KUDOSADDRESS, description: 'Kudos Description', imageOriginal: 'Kudos url', name: 'Kudos Name', + image: null, standard: 'ERC721', tokenId: '1203', collectionImage: 'collection.url', @@ -356,7 +463,7 @@ describe('CollectiblesController', () => { expect(collectiblesController.state.collectibleContracts).toStrictEqual([ { - address: '0x2aEa4Add166EBf38b63d09a75dE1a7b94Aa24163', + address: ERC721_KUDOSADDRESS, description: 'Kudos Description', logo: 'Kudos url', name: 'Kudos', @@ -371,6 +478,7 @@ describe('CollectiblesController', () => { name: 'name', image: 'image', description: 'description', + standard: 'standard', }); collectiblesController.removeCollectible('0x01', '1'); expect(collectiblesController.state.collectibles).toHaveLength(0); @@ -382,12 +490,14 @@ describe('CollectiblesController', () => { name: 'name', image: 'image', description: 'description', + standard: 'standard', }); await collectiblesController.addCollectible('0x01', '2', { name: 'name', image: 'image', description: 'description', + standard: 'standard', }); collectiblesController.removeCollectible('0x01', '1'); expect(collectiblesController.state.collectibles).toHaveLength(1); @@ -471,12 +581,14 @@ describe('CollectiblesController', () => { name: 'name', image: 'image', description: 'description', + standard: 'standard', }); await collectiblesController.addCollectible('0x01', '2', { name: 'name', image: 'image', description: 'description', + standard: 'standard', }); expect(collectiblesController.state.collectibles).toHaveLength(2); @@ -490,6 +602,7 @@ describe('CollectiblesController', () => { name: 'name', image: 'image', description: 'description', + standard: 'standard', }); expect(collectiblesController.state.collectibles).toHaveLength(2); expect(collectiblesController.state.ignoredCollectibles).toHaveLength(1); @@ -504,6 +617,7 @@ describe('CollectiblesController', () => { name: 'name', image: 'image', description: 'description', + standard: 'standard', }); expect(collectiblesController.state.collectibles).toHaveLength(1); @@ -575,4 +689,27 @@ describe('CollectiblesController', () => { }; 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 71bdd96998..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, RINKEBY_CHAIN_ID, ERC721, ERC1155 } 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, @@ -87,11 +100,12 @@ export interface CollectibleContract { * @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; @@ -100,7 +114,6 @@ export interface CollectibleMetadata { externalLink?: string; creator?: ApiCollectibleCreator; lastSale?: ApiCollectibleLastSale; - standard?: string; collectionName?: string; collectionImage?: string; } @@ -180,6 +193,7 @@ export class CollectiblesController extends BaseController< ): Promise { const tokenURI = this.getCollectibleApi(contractAddress, tokenId); let collectibleInformation: ApiCollectible; + /* istanbul ignore if */ if (this.openSeaApiKey) { collectibleInformation = await handleFetch(tokenURI, { @@ -188,6 +202,7 @@ export class CollectiblesController extends BaseController< } else { collectibleInformation = await handleFetch(tokenURI); } + const { num_sales, background_color, @@ -209,10 +224,10 @@ export class CollectiblesController extends BaseController< /* 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 }, @@ -243,24 +258,86 @@ export class CollectiblesController extends BaseController< contractAddress: string, tokenId: string, ): Promise { - const tokenURI = await this.getCollectibleTokenURI( + const result = await this.getCollectibleURIAndStandard( 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'; + let tokenURI = result[0]; + const standard = result[1]; - if (standard) { - return { image: object[image], name: object.name, standard }; + if (tokenURI.startsWith('ipfs://')) { + const contentId = getIpfsUrlContentIdentifier(tokenURI); + tokenURI = IPFS_DEFAULT_GATEWAY_URL + contentId; } - return { image: object[image], name: object.name }; + 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 ['', '']; } /** @@ -274,34 +351,29 @@ export class CollectiblesController extends BaseController< contractAddress: string, 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, + }; } /** @@ -334,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, }; } @@ -360,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, @@ -410,6 +469,7 @@ export class CollectiblesController extends BaseController< tokenId: string, collectibleMetadata: CollectibleMetadata, ): Promise { + // TODO: Remove unused return const releaseLock = await this.mutex.acquire(); try { address = toChecksumHexAddress(address); @@ -427,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() && @@ -492,6 +553,7 @@ export class CollectiblesController extends BaseController< const contractInformation = await this.getCollectibleContractInformation( address, ); + const { asset_contract_type, created_date, @@ -511,6 +573,7 @@ export class CollectiblesController extends BaseController< ) { return collectibleContracts; } + /* istanbul ignore next */ const newEntry: CollectibleContract = Object.assign( {}, @@ -519,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 }, @@ -653,34 +717,6 @@ 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 */ @@ -716,10 +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 uri for ERC-1155 metadata. + * @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. */ diff --git a/src/assets/assetsUtil.test.ts b/src/assets/assetsUtil.test.ts index a3b29d16a8..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', @@ -19,6 +22,8 @@ describe('assetsUtil', () => { tokenId: '123', name: 'name', image: 'image', + description: 'description', + standard: 'standard', backgroundColor: 'backgroundColor', imagePreview: 'imagePreview', imageThumbnail: 'imageThumbnail', @@ -36,7 +41,10 @@ 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 = { @@ -44,6 +52,8 @@ describe('assetsUtil', () => { 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', @@ -70,6 +83,8 @@ describe('assetsUtil', () => { 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 29b4a8a371..07bf5f1914 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,6 +1,7 @@ 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'; 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; +} From ed2af374dfaec608239b416b6ee78b8b14ec619e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 29 Oct 2021 12:37:47 -0600 Subject: [PATCH 4/5] 18.0.0 (#622) * 18.0.0 * Update changelog * Update changelog * Update changelog * Update changelog * Update CHANGELOG.md Co-authored-by: Alex Donesky Co-authored-by: github-actions Co-authored-by: Pedro Pablo Aste Kompen Co-authored-by: gantunesr Co-authored-by: Alex Donesky --- CHANGELOG.md | 12 +++++++++++- package.json | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) 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 4788eb521b..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", From d87b8c458118e4035656f7ff8ee90be23bd437d5 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Mon, 25 Oct 2021 16:36:43 -0600 Subject: [PATCH 5/5] Refactor and backfill GasFeeController tests The tests for GasFeeController are currently lacking in a few ways: * `nock` is being used to test that requests are being used to obtain data; however, GasFeeController takes functions as arguments which can be used to stub out those requests, thereby removing the need for `nock`. * The logic within `getGasFeeEstimatesAndStartPolling` is not being fully exercised, specifically the polling code. * There are no tests for `disconnectPoller`. * There are no explicit tests for `stopPolling`. * The logic within `_fetchGasFeeEstimateData` is not being fully exercised, specifically with regard to how the result of `getChainId` changes the URL that ends up being hit for both EIP-1559 and non-EIP-1559 flows. * There are tests categorized under a `getChainId` describe block which are actually for `_fetchGasFeeEstimateData`. This commit attempts to address these issues so that future changes to GasFeeController do not break existing behavior. --- src/gas/GasFeeController.test.ts | 848 ++++++++++++++++++++++++------- 1 file changed, 666 insertions(+), 182 deletions(-) diff --git a/src/gas/GasFeeController.test.ts b/src/gas/GasFeeController.test.ts index 6c5e2bf267..f1c10d2449 100644 --- a/src/gas/GasFeeController.test.ts +++ b/src/gas/GasFeeController.test.ts @@ -1,15 +1,19 @@ -import { stub } from 'sinon'; -import nock from 'nock'; +import { useFakeTimers, SinonFakeTimers } from 'sinon'; +import { mocked } from 'ts-jest/utils'; import { ControllerMessenger } from '../ControllerMessenger'; import { + EstimatedGasFeeTimeBounds, GasFeeController, - GetGasFeeState, + GasFeeEstimates, GasFeeStateChange, + GetGasFeeState, LegacyGasPriceEstimate, } from './GasFeeController'; +import { calculateTimeEstimate } from './gas-util'; + +const mockedCalculateTimeEstimate = mocked(calculateTimeEstimate); -const TEST_GAS_FEE_API = 'https://mock-gas-server.herokuapp.com/'; -const TEST_LEGACY_FEE_API = 'https://test/'; +jest.mock('./gas-util'); const name = 'GasFeeController'; @@ -33,214 +37,694 @@ function getRestrictedMessenger() { return messenger; } +/** + * Builds a mock return value for the `fetchGasEstimates` function that GasFeeController takes. All + * of the values here are filled in to satisfy the GasFeeEstimates type as well as the gas fee + * estimate logic within GasFeeController and are not intended to represent any particular scenario. + * + * @param args - The arguments. + * @param args.modifier - A number you can use to build a unique return value in the event that + * `fetchGasEstimates` is called multiple times. All data points will be multiplied by this number. + * @returns The mock data. + */ +function buildMockReturnValueForFetchGasEstimates({ + modifier = 1, +} = {}): GasFeeEstimates { + return { + low: { + minWaitTimeEstimate: 10000 * modifier, + maxWaitTimeEstimate: 20000 * modifier, + suggestedMaxPriorityFeePerGas: modifier.toString(), + suggestedMaxFeePerGas: (10 * modifier).toString(), + }, + medium: { + minWaitTimeEstimate: 30000 * modifier, + maxWaitTimeEstimate: 40000 * modifier, + suggestedMaxPriorityFeePerGas: (1.5 * modifier).toString(), + suggestedMaxFeePerGas: (20 * modifier).toString(), + }, + high: { + minWaitTimeEstimate: 50000 * modifier, + maxWaitTimeEstimate: 60000 * modifier, + suggestedMaxPriorityFeePerGas: (2 * modifier).toString(), + suggestedMaxFeePerGas: (30 * modifier).toString(), + }, + estimatedBaseFee: (100 * modifier).toString(), + }; +} + +/** + * Builds a mock return value for the `legacyFetchGasPriceEstimates` function that GasFeeController + * takes. All of the values here are filled in to satisfy the LegacyGasPriceEstimate type as well as + * the gas fee estimate logic in GasFeeController and are not intended to represent any particular + * scenario. + * + * @param args - The arguments. + * @param args.modifier - A number you can use to build a unique return value in the event that + * `legacyFetchGasPriceEstimates` is called multiple times. All data points will be multiplied by + * this number. + * @returns The mock data. + */ +function buildMockReturnValueForLegacyFetchGasPriceEstimates({ + modifier = 1, +} = {}): LegacyGasPriceEstimate { + return { + low: (10 * modifier).toString(), + medium: (20 * modifier).toString(), + high: (30 * modifier).toString(), + }; +} + +/** + * Builds a mock returnv alue for the `calculateTimeEstimate` function that GasFeeController takes. + * All of the values here are filled in to satisfy the EstimatedGasFeeTimeBounds type and are not + * intended to represent any particular scenario. + * + * @returns The mock data. + */ +function buildMockReturnValueForCalculateTimeEstimate(): EstimatedGasFeeTimeBounds { + return { + lowerTimeBound: 0, + upperTimeBound: 0, + }; +} + +/** + * Returns a Jest mock function for a fetch* function that GasFeeController takes which is + * configured to return the given mock data. + * + * @param mockReturnValues - A set of values that the mock function should return, for all of the + * expected invocations of that function. + * @returns The Jest mock function. + */ +function createMockForFetchMethod(mockReturnValues: any[]) { + const mock = jest.fn(); + + if (mockReturnValues.length === 1) { + mock.mockReturnValue(mockReturnValues[0]); + } else { + mockReturnValues.forEach((response: any) => { + mock.mockImplementationOnce(() => Promise.resolve(response)); + }); + } + + return mock; +} + describe('GasFeeController', () => { + let clock: SinonFakeTimers; let gasFeeController: GasFeeController; - let getCurrentNetworkLegacyGasAPICompatibility: jest.Mock; - let getIsEIP1559Compatible: jest.Mock>; - let getChainId: jest.Mock<`0x${string}` | `${number}` | number>; - let mockGasFeeRequest: any; - const mockRequestHandler = jest.fn(); - - beforeAll(() => { - nock.disableNetConnect(); - }); - - afterAll(() => { - nock.enableNetConnect(); - }); + let fetchGasEstimates: jest.Mock; + let fetchLegacyGasPriceEstimates: jest.Mock; - beforeEach(() => { - getChainId = jest.fn().mockImplementation(() => '0x1'); + /** + * Builds an instance of GasFeeController for use in testing, and then makes it available in + * tests along with mocks for fetch* functions passed to GasFeeController. + * + * @param options - The options. + * @param options.getChainId - Sets getChainId on the GasFeeController. + * @param options.getIsEIP1559Compatible - Sets getCurrentNetworkEIP1559Compatibility on the + * GasFeeController. + * @param options.getCurrentNetworkLegacyGasAPICompatibility - Sets + * getCurrentNetworkLegacyGasAPICompatibility on the GasFeeController. + * @param options.mockReturnValuesForFetchGasEstimates - Specifies mock data for one or more + * invocations of `fetchGasEstimates`. + * @param options.mockReturnValuesForFetchLegacyGasPriceEstimates - Specifies mock data for one or + * more invocations of `fetchLegacyGasPriceEstimates`. + * @param options.legacyAPIEndpoint - Sets legacyAPIEndpoint on the GasFeeController. + * @param options.EIP1559APIEndpoint - Sets EIP1559APIEndpoint on the GasFeeController. + * @param options.clientId - Sets clientId on the GasFeeController. + */ + function setupGasFeeController({ + getChainId = jest.fn().mockReturnValue('0x1'), + getIsEIP1559Compatible = jest.fn().mockResolvedValue(true), getCurrentNetworkLegacyGasAPICompatibility = jest .fn() - .mockImplementation(() => false); + .mockReturnValue(false), + mockReturnValuesForFetchGasEstimates = [ + buildMockReturnValueForFetchGasEstimates(), + ], + mockReturnValuesForFetchLegacyGasPriceEstimates = [ + buildMockReturnValueForLegacyFetchGasPriceEstimates(), + ], + legacyAPIEndpoint = 'http://legacy.endpoint/', + EIP1559APIEndpoint = 'http://eip-1559.endpoint/', + clientId, + }: { + getChainId?: jest.Mock<`0x${string}` | `${number}` | number>; + getIsEIP1559Compatible?: jest.Mock>; + getCurrentNetworkLegacyGasAPICompatibility?: jest.Mock; + mockReturnValuesForFetchGasEstimates?: any[]; + mockReturnValuesForFetchLegacyGasPriceEstimates?: any[]; + legacyAPIEndpoint?: string; + EIP1559APIEndpoint?: string; + clientId?: string; + } = {}) { + fetchGasEstimates = createMockForFetchMethod( + mockReturnValuesForFetchGasEstimates, + ); - getIsEIP1559Compatible = jest - .fn() - .mockImplementation(() => Promise.resolve(true)); - - mockGasFeeRequest = nock(TEST_GAS_FEE_API.replace('', '1')) - .get(/.+/u) - .reply(200, { - low: { - minWaitTimeEstimate: 60000, - maxWaitTimeEstimate: 600000, - suggestedMaxPriorityFeePerGas: '1', - suggestedMaxFeePerGas: '35', - }, - medium: { - minWaitTimeEstimate: 15000, - maxWaitTimeEstimate: 60000, - suggestedMaxPriorityFeePerGas: '1.8', - suggestedMaxFeePerGas: '38', - }, - high: { - minWaitTimeEstimate: 0, - maxWaitTimeEstimate: 15000, - suggestedMaxPriorityFeePerGas: '2', - suggestedMaxFeePerGas: '50', - }, - estimatedBaseFee: '28', - }) - .persist(); - mockGasFeeRequest.on('request', mockRequestHandler); - - nock(TEST_LEGACY_FEE_API.replace('', '0x1')) - .get(/.+/u) - .reply(200, { - SafeGasPrice: '22', - ProposeGasPrice: '25', - FastGasPrice: '30', - }) - .persist(); + fetchLegacyGasPriceEstimates = createMockForFetchMethod( + mockReturnValuesForFetchLegacyGasPriceEstimates, + ); gasFeeController = new GasFeeController({ - interval: 10000, messenger: getRestrictedMessenger(), - getProvider: () => stub(), + getProvider: jest.fn(), getChainId, - legacyAPIEndpoint: TEST_LEGACY_FEE_API, - EIP1559APIEndpoint: TEST_GAS_FEE_API, - onNetworkStateChange: () => stub(), + fetchGasEstimates, + fetchLegacyGasPriceEstimates, + fetchEthGasPriceEstimate: jest.fn().mockResolvedValue({ gasPrice: '1' }), + onNetworkStateChange: jest.fn(), getCurrentNetworkLegacyGasAPICompatibility, getCurrentNetworkEIP1559Compatibility: getIsEIP1559Compatible, // change this for networkController.state.properties.isEIP1559Compatible ??? + legacyAPIEndpoint, + EIP1559APIEndpoint, + clientId, }); + } + + beforeEach(() => { + clock = useFakeTimers(); }); afterEach(() => { - nock.cleanAll(); - jest.clearAllMocks(); + clock.uninstall(); gasFeeController.destroy(); + jest.clearAllMocks(); }); - it('should initialize', async () => { - expect(gasFeeController.name).toBe(name); + describe('constructor', () => { + beforeEach(() => { + setupGasFeeController(); + }); + + it('should set the name of the controller to GasFeeController', () => { + expect(gasFeeController.name).toBe(name); + }); }); describe('getGasFeeEstimatesAndStartPolling', () => { - it('should fetch estimates and start polling', async () => { - expect(gasFeeController.state.gasFeeEstimates).toStrictEqual({}); - const result = await gasFeeController.getGasFeeEstimatesAndStartPolling( - undefined, - ); - expect(result).toHaveLength(36); - expect(gasFeeController.state.gasFeeEstimates).toHaveProperty('low'); - expect(gasFeeController.state.gasFeeEstimates).toHaveProperty('medium'); - expect(gasFeeController.state.gasFeeEstimates).toHaveProperty('high'); - expect(gasFeeController.state.gasFeeEstimates).toHaveProperty( - 'estimatedBaseFee', - ); - }); - - it('should not fetch estimates if the controller is already polling, and should still return the passed token', async () => { - const pollToken = 'token'; - - const firstCallPromise = gasFeeController.getGasFeeEstimatesAndStartPolling( - undefined, - ); - const secondCallPromise = gasFeeController.getGasFeeEstimatesAndStartPolling( - pollToken, - ); - - const result1 = await firstCallPromise; - const result2 = await secondCallPromise; - - expect(mockRequestHandler).toHaveBeenCalledTimes(1); - expect(result1).toHaveLength(36); - expect(result2).toStrictEqual(pollToken); - }); - - it('should cause the fetching new estimates if called after the poll tokens are cleared, and then should not cause additional new fetches when subsequently called', async () => { - const pollToken = 'token'; - - const firstCallPromise = gasFeeController.getGasFeeEstimatesAndStartPolling( - undefined, - ); - const secondCallPromise = gasFeeController.getGasFeeEstimatesAndStartPolling( - pollToken, - ); - - await firstCallPromise; - await secondCallPromise; - - expect(mockRequestHandler).toHaveBeenCalledTimes(1); - - gasFeeController.stopPolling(); - - const result3 = await gasFeeController.getGasFeeEstimatesAndStartPolling( - undefined, - ); - expect(result3).toHaveLength(36); - expect(mockRequestHandler).toHaveBeenCalledTimes(2); - - const result4 = await gasFeeController.getGasFeeEstimatesAndStartPolling( - undefined, - ); - expect(result4).toHaveLength(36); - expect(mockRequestHandler).toHaveBeenCalledTimes(2); + describe('if never called before', () => { + describe('and called with undefined', () => { + it('should update the state with a fetched set of estimates', async () => { + const mockReturnValuesForFetchGasEstimates = [ + buildMockReturnValueForFetchGasEstimates(), + ]; + setupGasFeeController({ + mockReturnValuesForFetchGasEstimates, + }); + + await gasFeeController.getGasFeeEstimatesAndStartPolling(undefined); + + expect(gasFeeController.state.gasFeeEstimates).toStrictEqual( + mockReturnValuesForFetchGasEstimates[0], + ); + + expect( + gasFeeController.state.estimatedGasFeeTimeBounds, + ).toStrictEqual({}); + + expect(gasFeeController.state.gasEstimateType).toStrictEqual( + 'fee-market', + ); + }); + + it('should continue updating the state with all estimate data (including new time estimates because of a subsequent request) on a set interval', async () => { + const mockReturnValuesForFetchGasEstimates = [ + buildMockReturnValueForFetchGasEstimates({ modifier: 1 }), + buildMockReturnValueForFetchGasEstimates({ modifier: 1.5 }), + ]; + setupGasFeeController({ + mockReturnValuesForFetchGasEstimates, + }); + const estimatedGasFeeTimeBounds = buildMockReturnValueForCalculateTimeEstimate(); + mockedCalculateTimeEstimate.mockReturnValue( + estimatedGasFeeTimeBounds, + ); + + await gasFeeController.getGasFeeEstimatesAndStartPolling(undefined); + await clock.nextAsync(); + + expect(gasFeeController.state.gasFeeEstimates).toStrictEqual( + mockReturnValuesForFetchGasEstimates[1], + ); + + expect( + gasFeeController.state.estimatedGasFeeTimeBounds, + ).toStrictEqual(estimatedGasFeeTimeBounds); + + expect(gasFeeController.state.gasEstimateType).toStrictEqual( + 'fee-market', + ); + }); + }); + + describe('and called with a previously unseen token', () => { + it('should make a request to fetch estimates', async () => { + setupGasFeeController(); + + await gasFeeController.getGasFeeEstimatesAndStartPolling( + 'some-previously-unseen-token', + ); + + expect(fetchGasEstimates.mock.calls).toHaveLength(1); + }); + + it('should make further requests on a set interval', async () => { + setupGasFeeController(); + + await gasFeeController.getGasFeeEstimatesAndStartPolling( + 'some-previously-unseen-token', + ); + await clock.nextAsync(); + + expect(fetchGasEstimates.mock.calls).toHaveLength(2); + }); + }); + }); + + describe('if called twice with undefined', () => { + it('should not make another request to fetch estimates', async () => { + setupGasFeeController(); + + await gasFeeController.getGasFeeEstimatesAndStartPolling(undefined); + await gasFeeController.getGasFeeEstimatesAndStartPolling(undefined); + + expect(fetchGasEstimates.mock.calls).toHaveLength(1); + }); + + it('should not make more than one request per set interval', async () => { + setupGasFeeController(); + + await gasFeeController.getGasFeeEstimatesAndStartPolling(undefined); + await gasFeeController.getGasFeeEstimatesAndStartPolling(undefined); + await clock.nextAsync(); + await clock.nextAsync(); + + expect(fetchGasEstimates.mock.calls).toHaveLength(3); + }); + }); + + describe('if called once with undefined and again with the same token', () => { + it('should make another request to fetch estimates', async () => { + setupGasFeeController(); + + const pollToken = await gasFeeController.getGasFeeEstimatesAndStartPolling( + undefined, + ); + await gasFeeController.getGasFeeEstimatesAndStartPolling(pollToken); + + expect(fetchGasEstimates.mock.calls).toHaveLength(2); + }); + + it('should not make more than one request per set interval', async () => { + setupGasFeeController(); + + const pollToken = await gasFeeController.getGasFeeEstimatesAndStartPolling( + undefined, + ); + await gasFeeController.getGasFeeEstimatesAndStartPolling(pollToken); + await clock.nextAsync(); + await clock.nextAsync(); + + expect(fetchGasEstimates.mock.calls).toHaveLength(4); + }); + }); + + describe('if called twice, both with previously unseen tokens', () => { + it('should not make another request to fetch estimates', async () => { + setupGasFeeController(); + + await gasFeeController.getGasFeeEstimatesAndStartPolling( + 'some-previously-unseen-token-1', + ); + + await gasFeeController.getGasFeeEstimatesAndStartPolling( + 'some-previously-unseen-token-2', + ); + + expect(fetchGasEstimates.mock.calls).toHaveLength(1); + }); + + it('should not make more than one request per set interval', async () => { + setupGasFeeController(); + + await gasFeeController.getGasFeeEstimatesAndStartPolling( + 'some-previously-unseen-token-1', + ); + + await gasFeeController.getGasFeeEstimatesAndStartPolling( + 'some-previously-unseen-token-2', + ); + await clock.nextAsync(); + await clock.nextAsync(); + + expect(fetchGasEstimates.mock.calls).toHaveLength(3); + }); }); }); - describe('when on any network supporting legacy gas estimation api', () => { - it('should _fetchGasFeeEstimateData', async () => { - getCurrentNetworkLegacyGasAPICompatibility.mockImplementation(() => true); - getIsEIP1559Compatible.mockImplementation(() => Promise.resolve(false)); - expect(gasFeeController.state.gasFeeEstimates).toStrictEqual({}); - const estimates = await gasFeeController._fetchGasFeeEstimateData(); - expect(estimates).toHaveProperty('gasFeeEstimates'); - expect( - (gasFeeController.state.gasFeeEstimates as LegacyGasPriceEstimate).high, - ).toBe('30'); + describe('disconnectPoller', () => { + describe('assuming that updateWithAndStartPollingFor was already called exactly once', () => { + describe('given the same token as the result of the first call', () => { + it('should prevent requests from being made periodically', async () => { + setupGasFeeController(); + const pollToken = await gasFeeController.getGasFeeEstimatesAndStartPolling( + undefined, + ); + await clock.nextAsync(); + expect(fetchGasEstimates.mock.calls).toHaveLength(2); + + gasFeeController.disconnectPoller(pollToken); + + await clock.nextAsync(); + expect(fetchGasEstimates.mock.calls).toHaveLength(2); + }); + + it('should make it so that a second call to getGasFeeEstimatesAndStartPolling with the same token has the same effect as the inaugural call', async () => { + setupGasFeeController(); + const pollToken = await gasFeeController.getGasFeeEstimatesAndStartPolling( + undefined, + ); + await clock.nextAsync(); + expect(fetchGasEstimates.mock.calls).toHaveLength(2); + + gasFeeController.disconnectPoller(pollToken); + + await gasFeeController.getGasFeeEstimatesAndStartPolling(pollToken); + await clock.nextAsync(); + expect(fetchGasEstimates.mock.calls).toHaveLength(4); + }); + }); + + describe('given a previously unseen token', () => { + it('should not prevent requests from being made periodically', async () => { + setupGasFeeController(); + await gasFeeController.getGasFeeEstimatesAndStartPolling(undefined); + await clock.nextAsync(); + expect(fetchGasEstimates.mock.calls).toHaveLength(2); + + gasFeeController.disconnectPoller('some-previously-unseen-token'); + + await clock.nextAsync(); + expect(fetchGasEstimates.mock.calls).toHaveLength(3); + }); + }); + }); + + describe('if updateWithAndStartPollingFor was called twice with different tokens', () => { + it('should not prevent requests from being made periodically', async () => { + setupGasFeeController(); + const pollToken1 = await gasFeeController.getGasFeeEstimatesAndStartPolling( + undefined, + ); + await gasFeeController.getGasFeeEstimatesAndStartPolling(undefined); + await clock.nextAsync(); + expect(fetchGasEstimates.mock.calls).toHaveLength(2); + + gasFeeController.disconnectPoller(pollToken1); + + await clock.nextAsync(); + expect(fetchGasEstimates.mock.calls).toHaveLength(3); + }); + }); + + describe('if updateWithAndStartPollingFor was never called', () => { + it('should not throw an error', () => { + setupGasFeeController(); + expect(() => + gasFeeController.disconnectPoller('some-token'), + ).not.toThrow(); + }); }); }); - describe('getChainId', () => { - it('should work with a number input', async () => { - getChainId.mockImplementation(() => 1); - getCurrentNetworkLegacyGasAPICompatibility.mockImplementation(() => true); - getIsEIP1559Compatible.mockImplementation(() => Promise.resolve(false)); - expect(gasFeeController.state.gasFeeEstimates).toStrictEqual({}); - const estimates = await gasFeeController._fetchGasFeeEstimateData(); - expect(estimates).toHaveProperty('gasFeeEstimates'); - expect( - (gasFeeController.state.gasFeeEstimates as LegacyGasPriceEstimate).high, - ).toBe('30'); - }); - - it('should work with a hexstring input', async () => { - getChainId.mockImplementation(() => '0x1'); - getCurrentNetworkLegacyGasAPICompatibility.mockImplementation(() => true); - getIsEIP1559Compatible.mockImplementation(() => Promise.resolve(false)); - expect(gasFeeController.state.gasFeeEstimates).toStrictEqual({}); - const estimates = await gasFeeController._fetchGasFeeEstimateData(); - expect(estimates).toHaveProperty('gasFeeEstimates'); - expect( - (gasFeeController.state.gasFeeEstimates as LegacyGasPriceEstimate).high, - ).toBe('30'); - }); - - it('should work with a numeric string input', async () => { - getChainId.mockImplementation(() => '1'); - getCurrentNetworkLegacyGasAPICompatibility.mockImplementation(() => true); - getIsEIP1559Compatible.mockImplementation(() => Promise.resolve(false)); - expect(gasFeeController.state.gasFeeEstimates).toStrictEqual({}); - const estimates = await gasFeeController._fetchGasFeeEstimateData(); - expect(estimates).toHaveProperty('gasFeeEstimates'); - expect( - (gasFeeController.state.gasFeeEstimates as LegacyGasPriceEstimate).high, - ).toBe('30'); + describe('stopPolling', () => { + describe('assuming that updateWithAndStartPollingFor was already called once', () => { + it('should prevent requests from being made periodically', async () => { + setupGasFeeController(); + await gasFeeController.getGasFeeEstimatesAndStartPolling(undefined); + await clock.nextAsync(); + expect(fetchGasEstimates.mock.calls).toHaveLength(2); + + gasFeeController.stopPolling(); + + await clock.nextAsync(); + expect(fetchGasEstimates.mock.calls).toHaveLength(2); + }); + + it('should make it so that a second call to getGasFeeEstimatesAndStartPolling with the same token has the same effect as the inaugural call', async () => { + setupGasFeeController(); + const pollToken = await gasFeeController.getGasFeeEstimatesAndStartPolling( + undefined, + ); + await clock.nextAsync(); + expect(fetchGasEstimates.mock.calls).toHaveLength(2); + + gasFeeController.stopPolling(); + + await gasFeeController.getGasFeeEstimatesAndStartPolling(pollToken); + await clock.nextAsync(); + expect(fetchGasEstimates.mock.calls).toHaveLength(4); + }); + + it('should revert the state back to its original form', async () => { + setupGasFeeController(); + await gasFeeController.getGasFeeEstimatesAndStartPolling(undefined); + + gasFeeController.stopPolling(); + + expect(gasFeeController.state.gasFeeEstimates).toStrictEqual({}); + expect(gasFeeController.state.estimatedGasFeeTimeBounds).toStrictEqual( + {}, + ); + + expect(gasFeeController.state.gasEstimateType).toStrictEqual('none'); + }); + }); + + describe('if updateWithAndStartPollingFor was called multiple times with the same token (thereby restarting the polling once)', () => { + it('should prevent requests from being made periodically', async () => { + setupGasFeeController(); + const pollToken = await gasFeeController.getGasFeeEstimatesAndStartPolling( + undefined, + ); + await gasFeeController.getGasFeeEstimatesAndStartPolling(pollToken); + await clock.nextAsync(); + expect(fetchGasEstimates.mock.calls).toHaveLength(3); + + gasFeeController.stopPolling(); + + await clock.nextAsync(); + expect(fetchGasEstimates.mock.calls).toHaveLength(3); + }); + + it('should make it so that another call to getGasFeeEstimatesAndStartPolling with a previously generated token has the same effect as the inaugural call', async () => { + setupGasFeeController(); + const pollToken = await gasFeeController.getGasFeeEstimatesAndStartPolling( + undefined, + ); + await gasFeeController.getGasFeeEstimatesAndStartPolling(pollToken); + await clock.nextAsync(); + expect(fetchGasEstimates.mock.calls).toHaveLength(3); + + gasFeeController.stopPolling(); + + await gasFeeController.getGasFeeEstimatesAndStartPolling(pollToken); + await clock.nextAsync(); + expect(fetchGasEstimates.mock.calls).toHaveLength(5); + }); + }); + + describe('if updateWithAndStartPollingFor was never called', () => { + it('should not throw an error', () => { + setupGasFeeController(); + expect(() => gasFeeController.stopPolling()).not.toThrow(); + }); }); }); - describe('when on any network supporting EIP-1559', () => { - it('should _fetchGasFeeEstimateData', async () => { - getCurrentNetworkLegacyGasAPICompatibility.mockImplementation(() => true); - expect(gasFeeController.state.gasFeeEstimates).toStrictEqual({}); - const estimates = await gasFeeController._fetchGasFeeEstimateData(); - expect(estimates).toHaveProperty('gasFeeEstimates'); - expect(gasFeeController.state.gasFeeEstimates).toHaveProperty( - 'estimatedBaseFee', - ); + describe('_fetchGasFeeEstimateData', () => { + describe('when on any network supporting legacy gas estimation api', () => { + const mockReturnValuesForFetchLegacyGasPriceEstimates = [ + buildMockReturnValueForLegacyFetchGasPriceEstimates(), + ]; + const defaultConstructorOptions = { + getIsEIP1559Compatible: jest.fn().mockResolvedValue(false), + getCurrentNetworkLegacyGasAPICompatibility: jest + .fn() + .mockReturnValue(true), + mockReturnValuesForFetchLegacyGasPriceEstimates, + }; + + it('should update the state with a fetched set of estimates', async () => { + setupGasFeeController(defaultConstructorOptions); + + await gasFeeController._fetchGasFeeEstimateData(); + + expect(gasFeeController.state.gasFeeEstimates).toStrictEqual( + mockReturnValuesForFetchLegacyGasPriceEstimates[0], + ); + + expect(gasFeeController.state.estimatedGasFeeTimeBounds).toStrictEqual( + {}, + ); + + expect(gasFeeController.state.gasEstimateType).toStrictEqual('legacy'); + }); + + it('should return the same data that it puts into state', async () => { + setupGasFeeController(defaultConstructorOptions); + + const estimateData = await gasFeeController._fetchGasFeeEstimateData(); + + expect(estimateData.gasFeeEstimates).toStrictEqual( + mockReturnValuesForFetchLegacyGasPriceEstimates[0], + ); + + expect(estimateData.estimatedGasFeeTimeBounds).toStrictEqual({}); + + expect(estimateData.gasEstimateType).toStrictEqual('legacy'); + }); + + it('should call fetchLegacyGasPriceEstimates correctly when getChainId returns a number input', async () => { + setupGasFeeController({ + ...defaultConstructorOptions, + legacyAPIEndpoint: 'http://legacy.endpoint/', + getChainId: jest.fn().mockReturnValue(1), + clientId: '123', + }); + + await gasFeeController._fetchGasFeeEstimateData(); + + expect(fetchLegacyGasPriceEstimates).toHaveBeenCalledWith( + 'http://legacy.endpoint/1', + '123', + ); + }); + + it('should call fetchLegacyGasPriceEstimates correctly when getChainId returns a hexstring input', async () => { + setupGasFeeController({ + ...defaultConstructorOptions, + legacyAPIEndpoint: 'http://legacy.endpoint/', + getChainId: jest.fn().mockReturnValue('0x1'), + clientId: '123', + }); + + await gasFeeController._fetchGasFeeEstimateData(); + + expect(fetchLegacyGasPriceEstimates).toHaveBeenCalledWith( + 'http://legacy.endpoint/1', + '123', + ); + }); + + it('should call fetchLegacyGasPriceEstimates correctly when getChainId returns a numeric string input', async () => { + setupGasFeeController({ + ...defaultConstructorOptions, + legacyAPIEndpoint: 'http://legacy.endpoint/', + getChainId: jest.fn().mockReturnValue('1'), + clientId: '123', + }); + + await gasFeeController._fetchGasFeeEstimateData(); + + expect(fetchLegacyGasPriceEstimates).toHaveBeenCalledWith( + 'http://legacy.endpoint/1', + '123', + ); + }); + }); + + describe('when on any network supporting EIP-1559', () => { + const mockReturnValuesForFetchGasEstimates = [ + buildMockReturnValueForFetchGasEstimates(), + ]; + const defaultConstructorOptions = { + getIsEIP1559Compatible: jest.fn().mockResolvedValue(true), + mockReturnValuesForFetchGasEstimates, + }; + + it('should update the state with a fetched set of estimates', async () => { + setupGasFeeController(defaultConstructorOptions); + + await gasFeeController._fetchGasFeeEstimateData(); + + expect(gasFeeController.state.gasFeeEstimates).toStrictEqual( + mockReturnValuesForFetchGasEstimates[0], + ); + + expect(gasFeeController.state.estimatedGasFeeTimeBounds).toStrictEqual( + {}, + ); + + expect(gasFeeController.state.gasEstimateType).toStrictEqual( + 'fee-market', + ); + }); + + it('should return the same data that it puts into state', async () => { + setupGasFeeController(defaultConstructorOptions); + + const estimateData = await gasFeeController._fetchGasFeeEstimateData(); + + expect(estimateData.gasFeeEstimates).toStrictEqual( + mockReturnValuesForFetchGasEstimates[0], + ); + + expect(estimateData.estimatedGasFeeTimeBounds).toStrictEqual({}); + + expect(estimateData.gasEstimateType).toStrictEqual('fee-market'); + }); + + it('should call fetchGasEstimates correctly when getChainId returns a number input', async () => { + setupGasFeeController({ + ...defaultConstructorOptions, + EIP1559APIEndpoint: 'http://eip-1559.endpoint/', + getChainId: jest.fn().mockReturnValue(1), + clientId: '123', + }); + + await gasFeeController._fetchGasFeeEstimateData(); + + expect(fetchGasEstimates).toHaveBeenCalledWith( + 'http://eip-1559.endpoint/1', + '123', + ); + }); + + it('should call fetchGasEstimates correctly when getChainId returns a hexstring input', async () => { + setupGasFeeController({ + ...defaultConstructorOptions, + EIP1559APIEndpoint: 'http://eip-1559.endpoint/', + getChainId: jest.fn().mockReturnValue('0x1'), + clientId: '123', + }); + + await gasFeeController._fetchGasFeeEstimateData(); + + expect(fetchGasEstimates).toHaveBeenCalledWith( + 'http://eip-1559.endpoint/1', + '123', + ); + }); + + it('should call fetchGasEstimates correctly when getChainId returns a numeric string input', async () => { + setupGasFeeController({ + ...defaultConstructorOptions, + EIP1559APIEndpoint: 'http://eip-1559.endpoint/', + getChainId: jest.fn().mockReturnValue('1'), + clientId: '123', + }); + + await gasFeeController._fetchGasFeeEstimateData(); + + expect(fetchGasEstimates).toHaveBeenCalledWith( + 'http://eip-1559.endpoint/1', + '123', + ); + }); }); }); });