From 4bbf740d789cbd8c738744d2c0c4238b93415dbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esteban=20Mi=C3=B1o?= Date: Thu, 18 Oct 2018 21:40:32 -0300 Subject: [PATCH] Assets detection and tokens balances controllers (#19) * assets controller tokens * Update PreferencesController no more tokens responsability * Update AssetsController handling collectibles * Update tests according to new AssetsController * Fix some lines on AssetsController * AssetsController using preferences selected address and assets getter * AssetsController tokens object with selected address as keys * AssetsController test tokens per account * AssetsController tokens by selected address respective test * AssetsController update new tokens when adding token * AssetsController add and remove collectibles per account * AssetsController assets per account and network * AssetsDetectionController basic structure and web3 * AssetsDetectionControllers methods for token detection and collectibles placeholders * update AssetsDetectionController test * web3 changes * AssetsDetectionController detecting tokens * AssetsDetectionController handle web3 unset * AssetsDetectionController correctly detecting tokens * AssetsDetection collectibles first approach * AssetsDetectionCOntroller correctly detecting new collectibles, if contract has tokenOfOwnerByIndex method * AssetsDetection handle ERC721 standard tokens * Collectibles following standard autodetection * AssetsDetection fully api based collectibles * AssetdDetection clean up * AssetsDetection improve documentation * AssetsDetection tests WIP * AssetsDetectionController improve documentation * AssetsDetection bignumber/web3 addition and full line jest coverage * AssetDetection hide private methods and add BigNumber type * AssetdDetection handle web3 send async issue * new AssetsContractController for interaction with asset contracts * AssetsDetection full coverage * fix web3 bignumber dependency * expose AssetsContractController to API * detecting new assets only when account changes or polling period * add fetch-mock where it is possible * restore script test * add TokenBalancesController * expose TokenBalancesController to API * add collectible image util * add missing documentation --- package-lock.json | 165 ++++++++++++++-- package.json | 8 +- src/AssetsContractController.test.ts | 51 +++++ src/AssetsContractController.ts | 193 +++++++++++++++++++ src/AssetsController.test.ts | 146 +++++++++----- src/AssetsController.ts | 95 +++++++--- src/AssetsDetectionController.test.ts | 215 +++++++++++++++++++++ src/AssetsDetectionController.ts | 263 ++++++++++++++++++++++++++ src/BaseController.ts | 2 +- src/ComposableController.test.ts | 5 + src/NetworkController.ts | 2 + src/TokenBalancesController.test.ts | 106 +++++++++++ src/TokenBalancesController.ts | 114 +++++++++++ src/TokenRatesController.test.ts | 4 +- src/TokenRatesController.ts | 6 +- src/index.ts | 3 + src/util.test.ts | 8 + src/util.ts | 19 ++ 18 files changed, 1312 insertions(+), 93 deletions(-) create mode 100644 src/AssetsContractController.test.ts create mode 100644 src/AssetsContractController.ts create mode 100644 src/AssetsDetectionController.test.ts create mode 100644 src/AssetsDetectionController.ts create mode 100644 src/TokenBalancesController.test.ts create mode 100644 src/TokenBalancesController.ts diff --git a/package-lock.json b/package-lock.json index 905f22efa8e..135f8a42d48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,6 +64,15 @@ "samsam": "1.3.0" } }, + "@types/bn.js": { + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/@types/bn.js/-/bn.js-4.11.2.tgz", + "integrity": "sha512-OC3E/26kp/+JSOE4eJo86KivQM95MKPgnkug3uSCt+pXTWPovVBhixL1GwK1MLLsxgR3uZmTGjQQd8uKJEnurA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/events": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@types/events/-/events-1.2.0.tgz", @@ -154,6 +163,22 @@ "integrity": "sha512-Tt7w/ylBS/OEAlSCwzB0Db1KbxnkycP/1UkQpbvKFYoUuRn4uYsC3xh5TRPrOjTy0i8TIkSz1JdNL4GPVdf3KQ==", "dev": true }, + "@types/underscore": { + "version": "1.8.9", + "resolved": "https://registry.npmjs.org/@types/underscore/-/underscore-1.8.9.tgz", + "integrity": "sha512-vfzZGgZKRFy7KEWcBGfIFk+h6B+thDCLfkD1exMBMRlUsx2icA+J6y4kAbZs/TjSTeY1duw89QUU133TSzr60Q==", + "dev": true + }, + "@types/web3": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/web3/-/web3-1.0.6.tgz", + "integrity": "sha512-WHCAL0E0TXzpfMNLPHOe3lyjBZt7JxBGHBZ5FbSiw0ptltTjJSKnBnYNhKNM69CHZbGPA20vOKmF4ew3H2P9Rw==", + "dev": true, + "requires": { + "@types/bn.js": "*", + "@types/underscore": "*" + } + }, "abab": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/abab/-/abab-1.0.4.tgz", @@ -1948,6 +1973,11 @@ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.5.1.tgz", "integrity": "sha1-uCeAl7m8IpNl3lxiz1/K7YtVmeU=" }, + "cookiejar": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.2.tgz", + "integrity": "sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA==" + }, "copy-descriptor": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", @@ -2058,6 +2088,11 @@ "which": "^1.2.9" } }, + "crypto-js": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-3.1.8.tgz", + "integrity": "sha1-cV8HC/YBTyrpkqmLOSkli3E/CNU=" + }, "cssom": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.4.tgz", @@ -2456,8 +2491,17 @@ "resolved": "https://registry.npmjs.org/eth-sig-util/-/eth-sig-util-2.0.1.tgz", "integrity": "sha512-lxHZOQspexk3DaGj4RBbWy4C/qNOWRnxpaJzNnYD3WEmC8shcJ4tHs7Xv878rzvILfJnSFSCCiKQhng1m80oBQ==", "requires": { - "ethereumjs-abi": "git+https://github.com/ethereumjs/ethereumjs-abi.git#00ba8463a7f7a67fcad737ff9c2ebd95643427f7", "ethereumjs-util": "^5.1.1" + }, + "dependencies": { + "ethereumjs-abi": { + "version": "git+https://github.com/ethereumjs/ethereumjs-abi.git#00ba8463a7f7a67fcad737ff9c2ebd95643427f7", + "from": "git+https://github.com/ethereumjs/ethereumjs-abi.git#00ba8463a7f7a67fcad737ff9c2ebd95643427f7", + "requires": { + "bn.js": "^4.10.0", + "ethereumjs-util": "^5.0.0" + } + } } } } @@ -2533,8 +2577,17 @@ "resolved": "https://registry.npmjs.org/eth-sig-util/-/eth-sig-util-1.4.2.tgz", "integrity": "sha1-jZWCAsftuq6Dlwf7pvCf8ydgYhA=", "requires": { - "ethereumjs-abi": "git+https://github.com/ethereumjs/ethereumjs-abi.git#00ba8463a7f7a67fcad737ff9c2ebd95643427f7", "ethereumjs-util": "^5.1.1" + }, + "dependencies": { + "ethereumjs-abi": { + "version": "git+https://github.com/ethereumjs/ethereumjs-abi.git#00ba8463a7f7a67fcad737ff9c2ebd95643427f7", + "from": "git+https://github.com/ethereumjs/ethereumjs-abi.git#00ba8463a7f7a67fcad737ff9c2ebd95643427f7", + "requires": { + "bn.js": "^4.10.0", + "ethereumjs-util": "^5.0.0" + } + } } }, "eth-simple-keyring": { @@ -2555,8 +2608,17 @@ "resolved": "https://registry.npmjs.org/eth-sig-util/-/eth-sig-util-2.0.1.tgz", "integrity": "sha512-lxHZOQspexk3DaGj4RBbWy4C/qNOWRnxpaJzNnYD3WEmC8shcJ4tHs7Xv878rzvILfJnSFSCCiKQhng1m80oBQ==", "requires": { - "ethereumjs-abi": "git+https://github.com/ethereumjs/ethereumjs-abi.git#00ba8463a7f7a67fcad737ff9c2ebd95643427f7", "ethereumjs-util": "^5.1.1" + }, + "dependencies": { + "ethereumjs-abi": { + "version": "git+https://github.com/ethereumjs/ethereumjs-abi.git#00ba8463a7f7a67fcad737ff9c2ebd95643427f7", + "from": "git+https://github.com/ethereumjs/ethereumjs-abi.git#00ba8463a7f7a67fcad737ff9c2ebd95643427f7", + "requires": { + "bn.js": "^4.10.0", + "ethereumjs-util": "^5.0.0" + } + } } } } @@ -3199,7 +3261,8 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -3220,12 +3283,14 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3240,17 +3305,20 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -3367,7 +3435,8 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -3379,6 +3448,7 @@ "version": "1.0.0", "bundled": true, "dev": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -3393,6 +3463,7 @@ "version": "3.0.4", "bundled": true, "dev": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -3400,12 +3471,14 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "minipass": { "version": "2.2.4", "bundled": true, "dev": true, + "optional": true, "requires": { "safe-buffer": "^5.1.1", "yallist": "^3.0.0" @@ -3424,6 +3497,7 @@ "version": "0.5.1", "bundled": true, "dev": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -3504,7 +3578,8 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -3516,6 +3591,7 @@ "version": "1.4.0", "bundled": true, "dev": true, + "optional": true, "requires": { "wrappy": "1" } @@ -3601,7 +3677,8 @@ "safe-buffer": { "version": "5.1.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -3637,6 +3714,7 @@ "version": "1.0.2", "bundled": true, "dev": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -3656,6 +3734,7 @@ "version": "3.0.1", "bundled": true, "dev": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -3699,12 +3778,14 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "yallist": { "version": "3.0.2", "bundled": true, - "dev": true + "dev": true, + "optional": true } } }, @@ -4022,6 +4103,16 @@ "sshpk": "^1.7.0" } }, + "human-standard-collectible-abi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/human-standard-collectible-abi/-/human-standard-collectible-abi-1.0.2.tgz", + "integrity": "sha512-nD3ITUuSAIBgkaCm9J2BGwlHL8iEzFjJfTleDAC5Wi8RBJEXXhxV0JeJjd95o+rTwf98uTE5MW+VoBKOIYQh0g==" + }, + "human-standard-token-abi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/human-standard-token-abi/-/human-standard-token-abi-2.0.0.tgz", + "integrity": "sha512-m1f5DiIvqaNmpgphNqx2OziyTCj4Lvmmk28uMSxGWrOc9/lMpAKH8UcMPhvb13DMNZPzxn07WYFhxOGKuPLryg==" + }, "husky": { "version": "0.14.3", "resolved": "https://registry.npmjs.org/husky/-/husky-0.14.3.tgz", @@ -9384,6 +9475,23 @@ } } }, + "web3": { + "version": "0.20.7", + "resolved": "https://registry.npmjs.org/web3/-/web3-0.20.7.tgz", + "integrity": "sha512-VU6/DSUX93d1fCzBz7WP/SGCQizO1rKZi4Px9j/3yRyfssHyFcZamMw2/sj4E8TlfMXONvZLoforR8B4bRoyTQ==", + "requires": { + "crypto-js": "^3.1.4", + "utf8": "^2.1.1", + "xhr2-cookies": "^1.1.0", + "xmlhttprequest": "*" + }, + "dependencies": { + "bignumber.js": { + "version": "git+https://github.com/frozeman/bignumber.js-nolookahead.git#57692b3ecfc98bbdd6b3a516cb2353652ea49934", + "from": "git+https://github.com/frozeman/bignumber.js-nolookahead.git#57692b3ecfc98bbdd6b3a516cb2353652ea49934" + } + } + }, "web3-provider-engine": { "version": "14.0.6", "resolved": "https://registry.npmjs.org/web3-provider-engine/-/web3-provider-engine-14.0.6.tgz", @@ -9410,6 +9518,22 @@ "ws": "^5.1.1", "xhr": "^2.2.0", "xtend": "^4.0.1" + }, + "dependencies": { + "eth-block-tracker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/eth-block-tracker/-/eth-block-tracker-3.0.1.tgz", + "integrity": "sha512-WUVxWLuhMmsfenfZvFO5sbl1qFY2IqUlw/FPVmjjdElpqLsZtSG+wPe9Dz7W/sB6e80HgFKknOmKk2eNlznHug==", + "requires": { + "eth-query": "^2.1.0", + "ethereumjs-tx": "^1.3.3", + "ethereumjs-util": "^5.1.3", + "ethjs-util": "^0.1.3", + "json-rpc-engine": "^3.6.0", + "pify": "^2.3.0", + "tape": "^4.6.3" + } + } } }, "webidl-conversions": { @@ -9532,12 +9656,25 @@ "integrity": "sha1-y/xHWaabSoiOeM9PILBRA4dXvRE=", "dev": true }, + "xhr2-cookies": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xhr2-cookies/-/xhr2-cookies-1.1.0.tgz", + "integrity": "sha1-fXdEnQmZGX8VXLc7I99yUF7YnUg=", + "requires": { + "cookiejar": "^2.1.1" + } + }, "xml-name-validator": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==", "dev": true }, + "xmlhttprequest": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest/-/xmlhttprequest-1.8.0.tgz", + "integrity": "sha1-Z/4HXFwk/vOfnWX197f+dRcZaPw=" + }, "xtend": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", diff --git a/package.json b/package.json index f58f1745eb5..5bb124872b7 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "jest": { "moduleFileExtensions": [ "js", - "ts" + "ts", + "json", + "node" ], "transform": { "^.+\\.tsx?$": "ts-jest" @@ -51,6 +53,7 @@ "@types/jest": "^22.2.3", "@types/node": "^10.1.4", "@types/sinon": "^4.3.3", + "@types/web3": "^1.0.6", "ethjs-provider-http": "^0.1.6", "fetch-mock": "^6.4.3", "husky": "^0.14.3", @@ -78,7 +81,10 @@ "ethereumjs-util": "^5.2.0", "ethereumjs-wallet": "0.6.0", "ethjs-query": "^0.3.8", + "human-standard-collectible-abi": "^1.0.2", + "human-standard-token-abi": "^2.0.0", "percentile": "^1.2.1", + "web3": "^0.20.7", "web3-provider-engine": "^14.0.5" } } diff --git a/src/AssetsContractController.test.ts b/src/AssetsContractController.test.ts new file mode 100644 index 00000000000..1531f71eb65 --- /dev/null +++ b/src/AssetsContractController.test.ts @@ -0,0 +1,51 @@ +import { AssetsContractController } from './AssetsContractController'; +const HttpProvider = require('ethjs-provider-http'); +const MAINNET_PROVIDER = new HttpProvider('https://mainnet.infura.io'); +const GODSADDRESS = '0x6EbeAf8e8E946F0716E6533A6f2cefc83f60e8Ab'; +const CKADDRESS = '0x06012c8cf97BEaD5deAe237070F9587f8E7A266d'; + +describe('AssetsContractController', () => { + let assetsContract: AssetsContractController; + + beforeEach(() => { + assetsContract = new AssetsContractController(); + }); + + it('should set default config', () => { + expect(assetsContract.config).toEqual({ + provider: undefined + }); + }); + + 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 () => { + assetsContract.configure({ provider: MAINNET_PROVIDER }); + const CKBalance = await assetsContract.getBalanceOf(CKADDRESS, '0xb1690c08e213a35ed9bab7b318de14420fb57d8c'); + const CKNoBalance = await assetsContract.getBalanceOf(CKADDRESS, '0xfoO'); + expect(CKBalance.toNumber()).not.toEqual(0); + expect(CKNoBalance.toNumber()).toEqual(0); + }); + + it('should get collectible tokenId correctly', async () => { + assetsContract.configure({ provider: MAINNET_PROVIDER }); + const tokenId = await assetsContract.getCollectibleTokenId( + GODSADDRESS, + '0x9a90bd8d1149a88b42a99cf62215ad955d6f498a', + 0 + ); + expect(tokenId).not.toEqual(0); + }); + + it('should get collectible tokenURI correctly', async () => { + assetsContract.configure({ provider: MAINNET_PROVIDER }); + const tokenId = await assetsContract.getCollectibleTokenURI(GODSADDRESS, 0); + expect(tokenId).toEqual('https://api.godsunchained.com/card/0'); + }); +}); diff --git a/src/AssetsContractController.ts b/src/AssetsContractController.ts new file mode 100644 index 00000000000..d4eefd020a6 --- /dev/null +++ b/src/AssetsContractController.ts @@ -0,0 +1,193 @@ +import 'isomorphic-fetch'; +import BaseController, { BaseConfig, BaseState } from './BaseController'; + +const BN = require('ethereumjs-util').BN; +const Web3 = require('web3'); +const abiERC20 = require('human-standard-token-abi'); +const abiERC721 = require('human-standard-collectible-abi'); +const ERC721METADATA_INTERFACE_ID = '0x5b5e139f'; +const ERC721ENUMERABLE_INTERFACE_ID = '0x780e9d63'; + +/** + * @type AssetsContractConfig + * + * Assets Contract controller configuration + * + * @property provider - Provider used to create a new web3 instance + */ +export interface AssetsContractConfig extends BaseConfig { + provider: any; +} + +/** + * Controller that interacts with contracts on mainnet through web3 + */ +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 { + /* istanbul ignore if */ + if (!this.web3) { + return false; + } + try { + const contract = this.web3.eth.contract(abiERC721).at(address); + return await new Promise((resolve, reject) => { + contract.supportsInterface(interfaceId, (error: Error, result: boolean) => { + /* istanbul ignore if */ + if (error) { + reject(error); + return; + } + resolve(result); + }); + }); + } catch (error) { + /* istanbul ignore next */ + /* waiting for https://github.com/ethereum/web3.js/issues/1119 */ + return false; + } + } + + /** + * Name of this controller used during composition + */ + name = 'AssetsContractController'; + + /** + * Creates a AssetsContractController instance + * + * @param config - Initial options used to configure this controller + * @param state - Initial state to set on this controller + */ + constructor(config?: Partial, state?: Partial) { + super(config, state); + this.defaultConfig = { + provider: undefined + }; + this.initialize(); + } + + /** + * Sets a new provider + * + * @property provider - Provider used to create a new underlying Web3 instance + */ + set provider(provider: any) { + this.web3 = new Web3(provider); + } + + /** + * 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 selectedAddress - Current account public address + * @returns - Promise resolving to balance for current account on specific asset contract + */ + async getBalanceOf(address: string, selectedAddress: string): Promise { + /* istanbul ignore if */ + if (!this.web3) { + return new BN(0); + } + try { + const contract = this.web3.eth.contract(abiERC20).at(address); + return await new Promise((resolve, reject) => { + contract.balanceOf(selectedAddress, (error: Error, result: typeof BN) => { + /* istanbul ignore if */ + if (error) { + reject(error); + return; + } + resolve(result); + }); + }); + } catch (error) { + /* istanbul ignore next */ + /* waiting for https://github.com/ethereum/web3.js/issues/1119 */ + return new BN(0); + } + } + + /** + * Enumerate assets assigned to an owner + * + * @param address - ERC721 asset contract address + * @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(address: string, selectedAddress: string, index: number): Promise { + const contract = this.web3.eth.contract(abiERC721).at(address); + return new Promise((resolve, reject) => { + contract.tokenOfOwnerByIndex(selectedAddress, index, (error: Error, result: typeof BN) => { + /* istanbul ignore if */ + if (error) { + reject(error); + return; + } + resolve(result.toNumber()); + }); + }); + } + + /** + * Query for tokenURI for a given asset + * + * @param address - ERC721 asset contract address + * @param tokenId - ERC721 asset identifier + * @returns - Promise resolving to the 'tokenURI' + */ + async getCollectibleTokenURI(address: string, tokenId: number): Promise { + /* istanbul ignore if */ + if (!this.web3) { + return ''; + } + try { + const contract = this.web3.eth.contract(abiERC721).at(address); + return await new Promise((resolve, reject) => { + contract.tokenURI(tokenId, (error: Error, result: string) => { + /* istanbul ignore if */ + if (error) { + reject(error); + return; + } + resolve(result); + }); + }); + } catch (error) { + /* istanbul ignore next */ + /* waiting for https://github.com/ethereum/web3.js/issues/1119 */ + return ''; + } + } +} + +export default AssetsContractController; diff --git a/src/AssetsController.test.ts b/src/AssetsController.test.ts index 97fe0871923..131f5ec74e5 100644 --- a/src/AssetsController.test.ts +++ b/src/AssetsController.test.ts @@ -1,16 +1,29 @@ import { stub } from 'sinon'; +import { getOnce } from 'fetch-mock'; import AssetsController from './AssetsController'; import ComposableController from './ComposableController'; import PreferencesController from './PreferencesController'; import { NetworkController } from './NetworkController'; +import { AssetsContractController } from './AssetsContractController'; +const HttpProvider = require('ethjs-provider-http'); const TOKENS = [{ address: '0xfoO', symbol: 'bar', decimals: 2 }]; const COLLECTIBLES = [{ address: '0xfoO', image: 'url', name: 'name', tokenId: 1234 }]; +const GODSADDRESS = '0x6EbeAf8e8E946F0716E6533A6f2cefc83f60e8Ab'; +const CKADDRESS = '0x06012c8cf97BEaD5deAe237070F9587f8E7A266d'; +const MAINNET_PROVIDER = new HttpProvider('https://mainnet.infura.io'); describe('AssetsController', () => { let assetsController: AssetsController; + let preferences: PreferencesController; + let network: NetworkController; + let assetsContract: AssetsContractController; + beforeEach(() => { assetsController = new AssetsController(); + preferences = new PreferencesController(); + network = new NetworkController(); + assetsContract = new AssetsContractController(); }); it('should set default state', () => { @@ -38,12 +51,10 @@ describe('AssetsController', () => { }); it('should add token by selected address', () => { - const preferences = new PreferencesController(); - const network = new NetworkController(); const firstAddress = '0x123'; const secondAddress = '0x321'; /* tslint:disable-next-line:no-unused-expression */ - new ComposableController([assetsController, network, preferences]); + new ComposableController([assetsController, assetsContract, network, preferences]); preferences.update({ selectedAddress: firstAddress }); assetsController.addToken('foo', 'bar', 2); preferences.update({ selectedAddress: secondAddress }); @@ -57,12 +68,10 @@ describe('AssetsController', () => { }); it('should add token by provider type', () => { - const preferences = new PreferencesController(); - const network = new NetworkController(); const firstNetworkType = 'rinkeby'; const secondNetworkType = 'ropsten'; /* tslint:disable-next-line:no-unused-expression */ - new ComposableController([assetsController, network, preferences]); + new ComposableController([assetsController, assetsContract, network, preferences]); network.update({ provider: { type: firstNetworkType } }); assetsController.addToken('foo', 'bar', 2); network.update({ provider: { type: secondNetworkType } }); @@ -82,12 +91,10 @@ describe('AssetsController', () => { }); it('should remove token by selected address', () => { - const preferences = new PreferencesController(); - const network = new NetworkController(); const firstAddress = '0x123'; const secondAddress = '0x321'; /* tslint:disable-next-line:no-unused-expression */ - new ComposableController([assetsController, network, preferences]); + new ComposableController([assetsController, assetsContract, network, preferences]); preferences.update({ selectedAddress: firstAddress }); assetsController.addToken('fou', 'baz', 2); preferences.update({ selectedAddress: secondAddress }); @@ -103,12 +110,10 @@ describe('AssetsController', () => { }); it('should remove token by provider type', () => { - const preferences = new PreferencesController(); - const network = new NetworkController(); const firstNetworkType = 'rinkeby'; const secondNetworkType = 'ropsten'; /* tslint:disable-next-line:no-unused-expression */ - new ComposableController([assetsController, network, preferences]); + new ComposableController([assetsController, assetsContract, network, preferences]); network.update({ provider: { type: firstNetworkType } }); assetsController.addToken('fou', 'baz', 2); network.update({ provider: { type: secondNetworkType } }); @@ -124,24 +129,78 @@ describe('AssetsController', () => { }); it('should add collectible', async () => { - stub(assetsController, 'requestNFTCustomInformation' as any).returns({ name: 'name', image: 'url' }); + /* tslint:disable-next-line:no-unused-expression */ + new ComposableController([assetsController, assetsContract, network, preferences]); + assetsContract.configure({ provider: MAINNET_PROVIDER }); await assetsController.addCollectible('foo', 1234); - expect(assetsController.state.collectibles[0]).toEqual({ - address: '0xfoO', - image: 'url', - name: 'name', - tokenId: 1234 - }); + expect(assetsController.state.collectibles).toEqual([ + { + address: '0xfoO', + image: '', + name: '', + tokenId: 1234 + } + ]); + }); + + it('should add collectible with enumerable support but no tokenURI', async () => { + /* tslint:disable-next-line:no-unused-expression */ + new ComposableController([assetsController, assetsContract, network, preferences]); + assetsContract.configure({ provider: MAINNET_PROVIDER }); + await assetsController.addCollectible('0x8c9b261Faef3b3C2e64ab5E58e04615F8c788099', 1); + expect(assetsController.state.collectibles).toEqual([ + { + address: '0x8c9b261Faef3b3C2e64ab5E58e04615F8c788099', + image: '', + name: 'LucidSight-MLB-NFT', + tokenId: 1 + } + ]); + }); + + it('should add collectible with tokenURI, metadata and enumerable support', async () => { + /* tslint:disable-next-line:no-unused-expression */ + new ComposableController([assetsController, assetsContract, network, preferences]); + assetsContract.configure({ provider: MAINNET_PROVIDER }); + await assetsController.addCollectible(GODSADDRESS, 1); + expect(assetsController.state.collectibles).toEqual([ + { + address: '0x6EbeAf8e8E946F0716E6533A6f2cefc83f60e8Ab', + image: 'https://api.godsunchained.com/v0/image/7', + name: 'Broken Harvester', + tokenId: 1 + } + ]); + }); + + it('should add collectible with no tokenURI with no enumerable neither metadata support', async () => { + getOnce('https://api.cryptokitties.co/kitties/1', () => ({ + body: JSON.stringify({ + id: 1, + image_url: 'https://img.cryptokitties.co/0x06012c8cf97bead5deae237070f9587f8e7a266d/1.png', + name: 'Genesis' + }) + })); + /* tslint:disable-next-line:no-unused-expression */ + new ComposableController([assetsController, assetsContract, network, preferences]); + assetsContract.configure({ provider: MAINNET_PROVIDER }); + await assetsController.addCollectible(CKADDRESS, 1); + expect(assetsController.state.collectibles).toEqual([ + { + address: '0x06012c8cf97BEaD5deAe237070F9587f8E7A266d', + image: 'https://img.cryptokitties.co/0x06012c8cf97bead5deae237070f9587f8e7a266d/1.png', + name: 'Genesis', + tokenId: 1 + } + ]); }); it('should add collectible by selected address', async () => { - const preferences = new PreferencesController(); - const network = new NetworkController(); const firstAddress = '0x123'; const secondAddress = '0x321'; - stub(assetsController, 'requestNFTCustomInformation' as any).returns({ name: 'name', image: 'url' }); + stub(assetsController, 'getCollectibleCustomInformation' as any).returns({ name: 'name', image: 'url' }); /* tslint:disable-next-line:no-unused-expression */ - new ComposableController([assetsController, network, preferences]); + new ComposableController([assetsController, assetsContract, network, preferences]); preferences.update({ selectedAddress: firstAddress }); await assetsController.addCollectible('foo', 1234); preferences.update({ selectedAddress: secondAddress }); @@ -156,13 +215,11 @@ describe('AssetsController', () => { }); it('should add collectible by provider type', async () => { - const preferences = new PreferencesController(); - const network = new NetworkController(); const firstNetworkType = 'rinkeby'; const secondNetworkType = 'ropsten'; - stub(assetsController, 'requestNFTCustomInformation' as any).returns({ name: 'name', image: 'url' }); + stub(assetsController, 'getCollectibleCustomInformation' as any).returns({ name: 'name', image: 'url' }); /* tslint:disable-next-line:no-unused-expression */ - new ComposableController([assetsController, network, preferences]); + new ComposableController([assetsController, assetsContract, network, preferences]); network.update({ provider: { type: firstNetworkType } }); await assetsController.addCollectible('foo', 1234); network.update({ provider: { type: secondNetworkType } }); @@ -177,20 +234,18 @@ describe('AssetsController', () => { }); it('should remove collectible', () => { - stub(assetsController, 'requestNFTCustomInformation' as any).returns({ name: 'name', image: 'url' }); + stub(assetsController, 'getCollectibleCustomInformation' as any).returns({ name: 'name', image: 'url' }); assetsController.addCollectible('0xfoO', 1234); assetsController.removeCollectible('0xfoO', 1234); expect(assetsController.state.collectibles.length).toBe(0); }); it('should remove collectible by selected address', async () => { - const preferences = new PreferencesController(); - const network = new NetworkController(); - stub(assetsController, 'requestNFTCustomInformation' as any).returns({ name: 'name', image: 'url' }); + stub(assetsController, 'getCollectibleCustomInformation' as any).returns({ name: 'name', image: 'url' }); const firstAddress = '0x123'; const secondAddress = '0x321'; /* tslint:disable-next-line:no-unused-expression */ - new ComposableController([assetsController, network, preferences]); + new ComposableController([assetsController, assetsContract, network, preferences]); preferences.update({ selectedAddress: firstAddress }); await assetsController.addCollectible('fou', 4321); preferences.update({ selectedAddress: secondAddress }); @@ -207,13 +262,11 @@ describe('AssetsController', () => { }); it('should remove collectible by provider type', async () => { - const preferences = new PreferencesController(); - const network = new NetworkController(); - stub(assetsController, 'requestNFTCustomInformation' as any).returns({ name: 'name', image: 'url' }); + stub(assetsController, 'getCollectibleCustomInformation' as any).returns({ name: 'name', image: 'url' }); const firstNetworkType = 'rinkeby'; const secondNetworkType = 'ropsten'; /* tslint:disable-next-line:no-unused-expression */ - new ComposableController([assetsController, network, preferences]); + new ComposableController([assetsController, assetsContract, network, preferences]); network.update({ provider: { type: firstNetworkType } }); await assetsController.addCollectible('fou', 4321); network.update({ provider: { type: secondNetworkType } }); @@ -231,7 +284,7 @@ describe('AssetsController', () => { }); it('should not add duplicated collectible', async () => { - const func = stub(assetsController, 'requestNFTCustomInformation' as any).returns({ + const func = stub(assetsController, 'getCollectibleCustomInformation' as any).returns({ image: 'url', name: 'name' }); @@ -242,11 +295,18 @@ describe('AssetsController', () => { }); it('should request collectible default data and handle on adding collectible', async () => { + getOnce('https://api.cryptokitties.co/kitties/740632', () => ({ + body: JSON.stringify({ + id: 1, + image_url: 'https://img.cryptokitties.co/0x06012c8cf97bead5deae237070f9587f8e7a266d/1.png', + name: 'TestName' + }) + })); await assetsController.addCollectible('0x06012c8cf97BEaD5deAe237070F9587f8E7A266d', 740632); - expect(assetsController.state.collectibles[0]).not.toEqual({ + expect(assetsController.state.collectibles[0]).toEqual({ address: '0x06012c8cf97BEaD5deAe237070F9587f8E7A266d', - image: '', - name: '', + image: 'https://img.cryptokitties.co/0x06012c8cf97bead5deae237070f9587f8e7a266d/1.png', + name: 'TestName', tokenId: 740632 }); await assetsController.addCollectible('foo', 1); @@ -259,12 +319,10 @@ describe('AssetsController', () => { }); it('should subscribe to new sibling preference controllers', async () => { - const preferences = new PreferencesController(); - const network = new NetworkController(); const networkType = 'rinkeby'; const address = '0x123'; /* tslint:disable-next-line:no-unused-expression */ - new ComposableController([assetsController, network, preferences]); + new ComposableController([assetsController, assetsContract, network, preferences]); preferences.update({ selectedAddress: address }); expect(assetsController.context.PreferencesController.state.selectedAddress).toEqual(address); network.update({ provider: { type: networkType } }); @@ -272,7 +330,7 @@ describe('AssetsController', () => { }); it('should return correct assets state', async () => { - stub(assetsController, 'requestNFTCustomInformation' as any).returns({ name: 'name', image: 'url' }); + stub(assetsController, 'getCollectibleCustomInformation' as any).returns({ name: 'name', image: 'url' }); await assetsController.addCollectible('foo', 1234); assetsController.addToken('foo', 'bar', 2); expect(assetsController.state.tokens).toEqual(TOKENS); diff --git a/src/AssetsController.ts b/src/AssetsController.ts index d12a81395c4..982d0aa6d29 100644 --- a/src/AssetsController.ts +++ b/src/AssetsController.ts @@ -3,6 +3,8 @@ import BaseController, { BaseConfig, BaseState } from './BaseController'; import PreferencesController from './PreferencesController'; import NetworkController, { NetworkType } from './NetworkController'; import { Token } from './TokenRatesController'; +import { AssetsContractController } from './AssetsContractController'; +import { manageCollectibleImage } from './util'; const contractMap = require('eth-contract-metadata'); const { toChecksumAddress } = require('ethereumjs-util'); @@ -24,6 +26,35 @@ export interface Collectible { image: string; } +/** + * @type MappedContract + * + * Contract information representation that is found in contractMap + * + * @property name - Contract name + * @property logo - Contract logo + * @property address - Contract address + * @property symbol - Contract symbol + * @property decimals - Contract decimals + * @property api - Contract api, in case of a collectible contract + * @property collectibles_api - Contract API specific endpoint to get collectibles information, as custom information + * @property owner_api - Contract API specific endpoint to get owner information, as quantity of assets owned + * @property erc20 - Whether is ERC20 asset + * @property erc721 - Whether is ERC721 asset + */ +export interface MappedContract { + name: string; + logo?: string; + address: string; + symbol?: string; + decimals?: number; + api?: string; + collectibles_api?: string; + owner_api?: string; + erc20?: boolean; + erc721?: boolean; +} + /** * @type CollectibleCustomInformation * @@ -71,47 +102,53 @@ export interface AssetsState extends BaseState { * Controller that stores assets and exposes convenience methods */ export class AssetsController extends BaseController { - private getCollectibleApi(api: string, tokenId: number): string { - return `${api}${tokenId}`; - } - /** - * Request NFT custom information, name and image url + * Get collectible tokenURI API * - * @param address - Hex address of the collectible contract - * @param tokenId - The NFT identifier - * @returns - Promise resolving to the current collectible name and image + * @param contractAddress - ERC721 asset contract address + * @param tokenId - ERC721 asset identifier + * @returns - Collectible tokenURI */ - private async requestNFTCustomInformation(address: string, tokenId: number): Promise { - if (address in contractMap && contractMap[address].erc721) { - const contract = contractMap[address]; - const api = contract.api; - const { name, image } = await this.fetchCollectibleBasicInformation(api, tokenId); - return { name, image }; - } else { - return { name: '', image: '' }; + private async getCollectibleTokenURI(contract: MappedContract, tokenId: number): Promise { + if (contract.api && contract.collectibles_api) { + return `${contract.api + contract.collectibles_api}${tokenId}`; + } + const assetsContract = this.context.AssetsContractController as AssetsContractController; + const supportsMetadata = await assetsContract.contractSupportsMetadataInterface(contract.address); + /* istanbul ignore if */ + if (!supportsMetadata) { + return ''; } + return await assetsContract.getCollectibleTokenURI(contract.address, tokenId); } /** - * Fetch NFT basic information, name and image url + * Request NFT custom information, name and image url * - * @param api - API url to fetch custom collectible information + * @param address - Hex address of the collectible contract * @param tokenId - The NFT identifier * @returns - Promise resolving to the current collectible name and image */ - private async fetchCollectibleBasicInformation( - api: string, + private async getCollectibleCustomInformation( + address: string, tokenId: number ): Promise { - try { - const response = await fetch(this.getCollectibleApi(api, tokenId)); - const json = await response.json(); - return { image: json.image_url, name: json.name }; - } catch (error) { - /* istanbul ignore next */ - return { image: '', name: '' }; + if (address in contractMap && contractMap[address].erc721) { + try { + const contract = contractMap[address]; + contract.address = address; + const tokenURI = await this.getCollectibleTokenURI(contract, tokenId); + const response = await fetch(tokenURI); + const json = await response.json(); + const imageParam = json.hasOwnProperty('image') ? 'image' : 'image_url'; + const collectibleImage = manageCollectibleImage(address, json[imageParam]); + return { image: collectibleImage, name: json.name }; + } catch (error) { + return { image: '', name: contractMap[address].name }; + } } + /* istanbul ignore */ + return { name: '', image: '' }; } /** @@ -122,7 +159,7 @@ export class AssetsController extends BaseController /** * List of required sibling controllers this controller needs to function */ - requiredControllers = ['NetworkController', 'PreferencesController']; + requiredControllers = ['AssetsContractController', 'NetworkController', 'PreferencesController']; /** * Creates a AssetsController instance @@ -190,7 +227,7 @@ export class AssetsController extends BaseController if (existingEntry) { return collectibles; } - const { name, image } = await this.requestNFTCustomInformation(address, tokenId); + const { name, image } = await this.getCollectibleCustomInformation(address, tokenId); const newEntry: Collectible = { address, tokenId, name, image }; const newCollectibles = [...collectibles, newEntry]; const addressCollectibles = allCollectibles[selectedAddress]; diff --git a/src/AssetsDetectionController.test.ts b/src/AssetsDetectionController.test.ts new file mode 100644 index 00000000000..a232be5c123 --- /dev/null +++ b/src/AssetsDetectionController.test.ts @@ -0,0 +1,215 @@ +import { createSandbox } from 'sinon'; +import { getOnce } from 'fetch-mock'; +import { AssetsDetectionController } from './AssetsDetectionController'; +import { NetworkController } from './NetworkController'; +import { PreferencesController } from './PreferencesController'; +import { ComposableController } from './ComposableController'; +import { AssetsController } from './AssetsController'; +import { AssetsContractController } from './AssetsContractController'; + +const BN = require('ethereumjs-util').BN; +const HttpProvider = require('ethjs-provider-http'); +const DEFAULT_INTERVAL = 180000; +const MAINNET_PROVIDER = new HttpProvider('https://mainnet.infura.io'); +const GODSADDRESS = '0x6EbeAf8e8E946F0716E6533A6f2cefc83f60e8Ab'; +const CKADDRESS = '0x06012c8cf97BEaD5deAe237070F9587f8E7A266d'; +const TOKENS = [{ address: '0xfoO', symbol: 'bar', decimals: 2 }]; + +describe('AssetsDetectionController', () => { + let assetsDetection: AssetsDetectionController; + let preferences: PreferencesController; + let network: NetworkController; + let assets: AssetsController; + let assetsContract: AssetsContractController; + const sandbox = createSandbox(); + + beforeEach(() => { + assetsDetection = new AssetsDetectionController(); + preferences = new PreferencesController(); + network = new NetworkController(); + assets = new AssetsController(); + assetsContract = new AssetsContractController(); + /* tslint:disable-next-line:no-unused-expression */ + new ComposableController([assets, assetsContract, assetsDetection, network, preferences]); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should set default config', () => { + expect(assetsDetection.config).toEqual({ + interval: DEFAULT_INTERVAL, + providerType: '', + selectedAddress: '', + tokens: [] + }); + }); + + it('should poll on correct interval', () => { + const func = sandbox.stub(global, 'setInterval'); + /* tslint:disable-next-line:no-unused-expression */ + new AssetsDetectionController({ interval: 1337 }); + expect(func.getCall(0).args[1]).toBe(1337); + func.restore(); + }); + + it('should poll and detect assets on interval while mainnet', () => { + const clock = sandbox.useFakeTimers(); + assetsDetection.configure({ providerType: 'mainnet' }); + const detectTokens = sandbox.stub(assetsDetection, 'detectTokens').returns(null); + const detectCollectibles = sandbox.stub(assetsDetection, 'detectCollectibles').returns(null); + clock.tick(180001); + expect(detectTokens.called).toBe(true); + expect(detectCollectibles.called).toBe(true); + }); + + it('should detect assets only while mainnet', () => { + const clock = sandbox.useFakeTimers(); + const detectTokens = sandbox.stub(assetsDetection, 'detectTokens').returns(null); + const detectCollectibles = sandbox.stub(assetsDetection, 'detectCollectibles').returns(null); + clock.tick(180001); + expect(detectTokens.called).toBe(false); + expect(detectCollectibles.called).toBe(false); + assetsDetection.configure({ providerType: 'mainnet' }); + clock.tick(180001); + expect(detectTokens.called).toBe(true); + expect(detectCollectibles.called).toBe(true); + }); + + it('should call detect tokens correctly', () => { + const clock = sandbox.useFakeTimers(); + assetsDetection.configure({ providerType: 'mainnet' }); + const detectTokenOwnership = sandbox.stub(assetsDetection, 'detectTokenOwnership').returns(null); + const detectCollectibles = sandbox.stub(assetsDetection, 'detectCollectibles').returns(null); + clock.tick(180001); + expect(detectTokenOwnership.called).toBe(true); + expect(detectCollectibles.called).toBe(true); + }); + + it('should call detect collectibles correctly', () => { + const clock = sandbox.useFakeTimers(); + assetsDetection.configure({ providerType: 'mainnet' }); + const detectTokens = sandbox.stub(assetsDetection, 'detectTokens').returns(null); + const detectCollectibleOwnership = sandbox.stub(assetsDetection, 'detectCollectibleOwnership').returns(null); + clock.tick(180001); + expect(detectCollectibleOwnership.called).toBe(true); + expect(detectTokens.called).toBe(true); + }); + + it('should detect tokens correctly', async () => { + assetsDetection.configure({ providerType: 'mainnet' }); + sandbox + .stub(assetsContract, 'getBalanceOf') + .returns(new BN(0)) + .withArgs('0x6810e776880C02933D47DB1b9fc05908e5386b96') + .returns(new BN(1)); + await assetsDetection.detectTokens(); + expect(assets.state.tokens).toEqual([ + { + address: '0x6810e776880C02933D47DB1b9fc05908e5386b96', + decimals: 18, + symbol: 'GNO' + } + ]); + }); + + it('should detect enumerable collectibles correctly', async () => { + assetsDetection.configure({ + providerType: 'mainnet', + selectedAddress: '0x06012c8cf97BEaD5deAe237070F9587f8E7A266d' + }); + assetsContract.configure({ provider: MAINNET_PROVIDER }); + sandbox.stub(assetsContract, 'getBalanceOf').returns(new BN(1)); + sandbox.stub(assetsContract, 'getCollectibleTokenId').returns(new Promise((resolve) => resolve(0))); + await assetsDetection.detectCollectibleOwnership(GODSADDRESS); + expect(assets.state.collectibles).toEqual([ + { + address: '0x6EbeAf8e8E946F0716E6533A6f2cefc83f60e8Ab', + image: 'https://api.godsunchained.com/v0/image/380', + name: 'First Pheonix', + tokenId: 0 + } + ]); + }); + + it('should detect not enumerable collectibles correctly', async () => { + getOnce( + 'https://api.cryptokitties.co/kitties?owner_wallet_address=0xb161330dc0d6a9e1cb441b3f2593ba689136b4e4', + () => ({ + body: JSON.stringify({ offset: 0, limit: 12, kitties: [{ id: 411073 }] }) + }) + ); + getOnce('https://api.cryptokitties.co/kitties/411073', () => ({ + body: JSON.stringify({ + id: 411073, + image_url: 'https://img.cryptokitties.co/0x06012c8cf97bead5deae237070f9587f8e7a266d/411073.svg', + name: 'TestName' + }) + })); + assetsDetection.configure({ + providerType: 'mainnet', + selectedAddress: '0xb161330dc0d6a9e1cb441b3f2593ba689136b4e4' + }); + assetsContract.configure({ provider: MAINNET_PROVIDER }); + sandbox.stub(assetsContract, 'getBalanceOf').returns(new BN(1)); + await assetsDetection.detectCollectibleOwnership(CKADDRESS); + expect(assets.state.collectibles).toEqual([ + { + address: '0x06012c8cf97BEaD5deAe237070F9587f8E7A266d', + image: 'https://img.cryptokitties.co/0x06012c8cf97bead5deae237070f9587f8e7a266d/411073.svg', + name: 'TestName', + tokenId: 411073 + } + ]); + }); + + it('should not detect asset ownership when no balance of', async () => { + assetsDetection.configure({ + providerType: 'mainnet', + selectedAddress: '0xb1690C08E213a35Ed9bAb7B318DE14420FB57d8C' + }); + assetsContract.configure({ provider: MAINNET_PROVIDER }); + sandbox.stub(assetsContract, 'getBalanceOf').returns(new BN(0)); + const contractSupportsEnumerableInterface = sandbox + .stub(assetsContract, 'contractSupportsEnumerableInterface') + .returns(false); + const addToken = sandbox.stub(assets, 'addToken'); + await assetsDetection.detectCollectibleOwnership(GODSADDRESS); + expect(contractSupportsEnumerableInterface.called).toBe(false); + expect(addToken.called).toBe(false); + }); + + it('should not detect asset ownership when address in contract metadata', async () => { + assetsDetection.configure({ providerType: 'mainnet' }); + assetsContract.configure({ provider: MAINNET_PROVIDER }); + sandbox.stub(assetsContract, 'getBalanceOf').returns(new BN(1)); + const getEnumerableCollectiblesIds = sandbox + .stub(assetsDetection, 'getEnumerableCollectiblesIds' as any) + .returns(false); + const getApiCollectiblesIds = sandbox.stub(assetsDetection, 'getApiCollectiblesIds' as any).returns(false); + await assetsDetection.detectCollectibleOwnership('0xfoo'); + expect(getEnumerableCollectiblesIds.called).toBe(false); + expect(getApiCollectiblesIds.called).toBe(false); + }); + + it('should subscribe to new sibling detecting assets when account changes', async () => { + const firstNetworkType = 'rinkeby'; + const secondNetworkType = 'mainnet'; + const firstAddress = '0x123'; + const secondAddress = '0x321'; + const detectAssets = sandbox.stub(assetsDetection, 'detectAssets'); + preferences.update({ selectedAddress: secondAddress }); + preferences.update({ selectedAddress: secondAddress }); + expect(assetsDetection.context.PreferencesController.state.selectedAddress).toEqual(secondAddress); + expect(detectAssets.calledTwice).toBe(false); + preferences.update({ selectedAddress: firstAddress }); + expect(assetsDetection.context.PreferencesController.state.selectedAddress).toEqual(firstAddress); + network.update({ provider: { type: secondNetworkType } }); + expect(assetsDetection.context.NetworkController.state.provider.type).toEqual(secondNetworkType); + network.update({ provider: { type: firstNetworkType } }); + expect(assetsDetection.context.NetworkController.state.provider.type).toEqual(firstNetworkType); + assets.update({ tokens: TOKENS }); + expect(assetsDetection.config.tokens).toEqual(TOKENS); + }); +}); diff --git a/src/AssetsDetectionController.ts b/src/AssetsDetectionController.ts new file mode 100644 index 00000000000..3eea0904a71 --- /dev/null +++ b/src/AssetsDetectionController.ts @@ -0,0 +1,263 @@ +import 'isomorphic-fetch'; +import BaseController, { BaseConfig, BaseState } from './BaseController'; +import AssetsController from './AssetsController'; +import NetworkController from './NetworkController'; +import PreferencesController from './PreferencesController'; +import AssetsContractController from './AssetsContractController'; +import { safelyExecute } from './util'; +import { Token } from './TokenRatesController'; + +const contractMap = require('eth-contract-metadata'); +const DEFAULT_INTERVAL = 180000; +const MAINNET = 'mainnet'; + +/** + * @type CollectibleEntry + * + * Collectible minimal representation expected on collectibles api + * + * @property id - Collectible identifier + */ +export interface CollectibleEntry { + id: number; +} + +/** + * @type AssetsConfig + * + * Assets controller configuration + * + * @property interval - Polling interval used to fetch new token rates + * @property providerType - Provider type network ID as per net_version + * @property selectedAddress - Vault selected address + * @property tokens - List of tokens associated with the active vault + */ +export interface AssetsDetectionConfig extends BaseConfig { + interval: number; + providerType: string; + selectedAddress: string; + tokens: Token[]; +} + +/** + * Controller that passively polls on a set interval for assets auto detection + */ +export class AssetsDetectionController extends BaseController { + private handle?: NodeJS.Timer; + + /** + * Get user information API for collectibles based on API provided in contract metadata + * + * @param address - ERC721 asset contract address + * @returns - User information URI + */ + private getCollectibleUserApi(address: string) { + const contract = contractMap[address]; + const { selectedAddress } = this.config; + const collectibleUserApi = contract.api + contract.owner_api + selectedAddress; + return collectibleUserApi; + } + + /** + * Get current account collectibles ids, if ERC721Enumerable interface implemented + * + * @param address - ERC721 asset contract address + * @return - Promise resolving to collectibles entries array + */ + private async getEnumerableCollectiblesIds(address: string): Promise { + const assetsContractController = this.context.AssetsContractController as AssetsContractController; + const collectibleEntries: CollectibleEntry[] = []; + try { + const { selectedAddress } = this.config; + const balance = await assetsContractController.getBalanceOf(address, selectedAddress); + const indexes: number[] = Array.from(new Array(balance.toNumber()), (_, index) => index); + const promises = indexes.map((index) => { + return assetsContractController.getCollectibleTokenId(address, selectedAddress, index); + }); + const tokenIds = await Promise.all(promises); + for (const key in tokenIds) { + collectibleEntries.push({ id: tokenIds[key] }); + } + return collectibleEntries; + } catch (error) { + /* istanbul ignore next */ + return collectibleEntries; + } + } + + /** + * Get current account collectibles, using collectible API + * if there is one defined in contract metadata + * + * @param address - ERC721 asset contract address + * @returns - Promise resolving to collectibles entries array + */ + private async getApiCollectiblesIds(address: string): Promise { + const contract = contractMap[address]; + const collectibleEntries: CollectibleEntry[] = []; + try { + const collectibleUserApi = this.getCollectibleUserApi(address); + const response = await fetch(collectibleUserApi); + const json = await response.json(); + const collectiblesJson = json[contract.collectibles_entry]; + for (const key in collectiblesJson) { + const collectibleEntry: CollectibleEntry = collectiblesJson[key]; + collectibleEntries.push({ id: collectibleEntry.id }); + } + return collectibleEntries; + } catch (error) { + /* istanbul ignore next */ + return collectibleEntries; + } + } + + /** + * Name of this controller used during composition + */ + name = 'AssetsDetectionController'; + + /** + * List of required sibling controllers this controller needs to function + */ + requiredControllers = [ + 'AssetsContractController', + 'AssetsController', + 'NetworkController', + 'PreferencesController' + ]; + + /** + * Creates a AssetsDetectionController instance + * + * @param config - Initial options used to configure this controller + * @param state - Initial state to set on this controller + */ + constructor(config?: Partial, state?: Partial) { + super(config, state); + this.defaultConfig = { + interval: DEFAULT_INTERVAL, + providerType: '', + selectedAddress: '', + tokens: [] + }; + this.initialize(); + } + + /** + * Sets a new polling interval + * + * @param interval - Polling interval used to auto detect assets + */ + set interval(interval: number) { + this.handle && clearInterval(this.handle); + this.handle = setInterval(() => { + safelyExecute(() => this.detectAssets()); + }, interval); + } + + /** + * Detect if current account is owner of ERC20 token. If is the case, adds it to state + * + * @param address - Asset ERC20 contract address + */ + async detectTokenOwnership(address: string) { + const assetsController = this.context.AssetsController as AssetsController; + const assetsContractController = this.context.AssetsContractController as AssetsContractController; + const { selectedAddress } = this.config; + const balance = await assetsContractController.getBalanceOf(address, selectedAddress); + if (balance.toNumber() !== 0) { + assetsController.addToken(address, contractMap[address].symbol, contractMap[address].decimals); + } + } + + /** + * Detect if current account is owner of ERC721 token. If is the case, adds it to state + * + * @param address - ERC721 asset contract address + */ + async detectCollectibleOwnership(address: string) { + const assetsContractController = this.context.AssetsContractController as AssetsContractController; + const assetsController = this.context.AssetsController as AssetsController; + const { selectedAddress } = this.config; + const balance = await assetsContractController.getBalanceOf(address, selectedAddress); + if (balance.toNumber() !== 0) { + let collectibleIds: CollectibleEntry[] = []; + const contractApiDefined = + contractMap[address] && contractMap[address].api && contractMap[address].owner_api; + if (contractApiDefined) { + collectibleIds = await this.getApiCollectiblesIds(address); + } else { + const supportsEnumerable = await assetsContractController.contractSupportsEnumerableInterface(address); + if (supportsEnumerable) { + collectibleIds = await this.getEnumerableCollectiblesIds(address); + } + } + for (const key in collectibleIds) { + await assetsController.addCollectible(address, collectibleIds[key].id); + } + } + } + + /** + * Detect assets owned by current account on mainnet + */ + async detectAssets() { + /* istanbul ignore if */ + if (this.config.providerType !== MAINNET || this.disabled) { + return; + } + this.detectTokens(); + this.detectCollectibles(); + } + + /** + * Triggers asset ERC20 token auto detection for each contract address in contract metadata + */ + async detectTokens() { + const tokensAddresses = this.config.tokens.filter(/* istanbul ignore next*/ (token) => token.address); + for (const address in contractMap) { + const contract = contractMap[address]; + if (contract.erc20 && !(address in tokensAddresses)) { + await this.detectTokenOwnership(address); + } + } + } + + /** + * Triggers asset ERC721 token auto detection for each contract address in contract metadata + */ + async detectCollectibles() { + for (const address in contractMap) { + const contract = contractMap[address]; + if (contract.erc721) { + await this.detectCollectibleOwnership(address); + } + } + } + + /** + * Extension point called if and when this controller is composed + * with other controllers using a ComposableController + */ + onComposed() { + super.onComposed(); + const preferences = this.context.PreferencesController as PreferencesController; + const network = this.context.NetworkController as NetworkController; + const assets = this.context.AssetsController as AssetsController; + assets.subscribe(({ tokens }) => { + this.configure({ tokens }); + }); + preferences.subscribe(({ selectedAddress }) => { + const actualSelectedAddress = this.config.selectedAddress; + if (selectedAddress !== actualSelectedAddress) { + this.configure({ selectedAddress }); + this.detectAssets(); + } + }); + network.subscribe(({ provider }) => { + this.configure({ providerType: provider.type }); + }); + } +} + +export default AssetsDetectionController; diff --git a/src/BaseController.ts b/src/BaseController.ts index d64ea8b339b..621dd282970 100644 --- a/src/BaseController.ts +++ b/src/BaseController.ts @@ -17,7 +17,7 @@ export interface BaseConfig { } /** - * @type BaseStaate + * @type BaseState * * Base state representation * diff --git a/src/ComposableController.test.ts b/src/ComposableController.test.ts index 26212b0b94c..33e55adf9d3 100644 --- a/src/ComposableController.test.ts +++ b/src/ComposableController.test.ts @@ -5,18 +5,21 @@ import PreferencesController from './PreferencesController'; import TokenRatesController from './TokenRatesController'; import { AssetsController } from './AssetsController'; import { NetworkController } from './NetworkController'; +import { AssetsContractController } from './AssetsContractController'; describe('ComposableController', () => { it('should compose controller state', () => { const controller = new ComposableController([ new AddressBookController(), new AssetsController(), + new AssetsContractController(), new NetworkController(), new PreferencesController(), new TokenRatesController() ]); expect(controller.state).toEqual({ AddressBookController: { addressBook: [] }, + AssetsContractController: {}, AssetsController: { allTokens: {}, allCollectibles: {}, collectibles: [], tokens: [] }, NetworkController: { network: 'loading', @@ -36,6 +39,7 @@ describe('ComposableController', () => { const controller = new ComposableController([ new AddressBookController(), new AssetsController(), + new AssetsContractController(), new NetworkController(), new PreferencesController(), new TokenRatesController() @@ -60,6 +64,7 @@ describe('ComposableController', () => { const controller = new ComposableController([ new AddressBookController(), new AssetsController(), + new AssetsContractController(), new NetworkController(), new PreferencesController(), new TokenRatesController() diff --git a/src/NetworkController.ts b/src/NetworkController.ts index 4ba84f41d0f..624597c87b2 100644 --- a/src/NetworkController.ts +++ b/src/NetworkController.ts @@ -59,6 +59,8 @@ export class NetworkController extends BaseController { + let tokenBalances: TokenBalancesController; + const sandbox = createSandbox(); + + beforeEach(() => { + tokenBalances = new TokenBalancesController(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should set default state', () => { + expect(tokenBalances.state).toEqual({ contractBalances: {} }); + }); + + it('should set default config', () => { + expect(tokenBalances.config).toEqual({ + interval: 180000, + tokens: [] + }); + }); + + it('should poll on correct interval', () => { + const func = sandbox.stub(global, 'setInterval'); + /* tslint:disable-next-line:no-unused-expression */ + new TokenBalancesController({ interval: 1337 }); + expect(func.getCall(0).args[1]).toBe(1337); + func.restore(); + }); + + it('should update balances on interval', () => { + const clock = sandbox.useFakeTimers(); + tokenBalances.configure({ interval: 180000 }); + const updateBalances = sandbox.stub(tokenBalances, 'updateBalances'); + clock.tick(180001); + expect(updateBalances.called).toBe(true); + }); + + it('should update all balances', async () => { + const address = '0x86fa049857e0209aa7d9e616f7eb3b3b78ecfdb0'; + expect(tokenBalances.state.contractBalances).toEqual({}); + tokenBalances.configure({ tokens: [{ address, decimals: 18, symbol: 'EOS' }] }); + const assets = new AssetsController(); + const assetsContract = new AssetsContractController(); + const network = new NetworkController(); + const preferences = new PreferencesController(); + /* tslint:disable-next-line:no-unused-expression */ + new ComposableController([assets, assetsContract, network, preferences, tokenBalances]); + assetsContract.configure({ provider: MAINNET_PROVIDER }); + stub(assetsContract, 'getBalanceOf').returns(new BN(1)); + await tokenBalances.updateBalances(); + expect(Object.keys(tokenBalances.state.contractBalances)).toContain(address); + expect(tokenBalances.state.contractBalances[address].toNumber()).toBeGreaterThan(0); + }); + + it('should not update balances if disabled', async () => { + tokenBalances.disabled = true; + const assets = new AssetsController(); + const assetsContract = new AssetsContractController(); + const network = new NetworkController(); + const preferences = new PreferencesController(); + /* tslint:disable-next-line:no-unused-expression */ + new ComposableController([assets, assetsContract, network, preferences, tokenBalances]); + assetsContract.configure({ provider: MAINNET_PROVIDER }); + const getBalanceOf = sandbox.stub(assetsContract, 'getBalanceOf'); + await tokenBalances.updateBalances(); + expect(getBalanceOf.called).toBe(false); + }); + + it('should clear previous interval', () => { + const func = sandbox.stub(global, 'clearInterval'); + const controller = new TokenBalancesController({ interval: 1337 }); + controller.interval = 1338; + expect(func.called).toBe(true); + func.restore(); + }); + + it('should subscribe to new sibling assets controllers', async () => { + const assets = new AssetsController(); + const assetsContract = new AssetsContractController(); + const network = new NetworkController(); + const preferences = new PreferencesController(); + /* tslint:disable-next-line:no-unused-expression */ + new ComposableController([assets, assetsContract, network, preferences, tokenBalances]); + const updateBalances = sandbox.stub(tokenBalances, 'updateBalances'); + assets.addToken('0xfoO', 'FOO', 18); + const tokens = tokenBalances.context.AssetsController.state.tokens; + const found = tokens.filter((token: Token) => token.address === '0xfoO'); + expect(found.length > 0).toBe(true); + expect(updateBalances.called).toBe(true); + }); +}); diff --git a/src/TokenBalancesController.ts b/src/TokenBalancesController.ts new file mode 100644 index 00000000000..de600414c21 --- /dev/null +++ b/src/TokenBalancesController.ts @@ -0,0 +1,114 @@ +import 'isomorphic-fetch'; +import BaseController, { BaseConfig, BaseState } from './BaseController'; +import AssetsController from './AssetsController'; +import { Token } from './TokenRatesController'; +import { safelyExecute } from './util'; +import { AssetsContractController } from './AssetsContractController'; + +const { BN } = require('ethereumjs-util'); + +/** + * @type TokenBalancesConfig + * + * Token balances controller configuration + * + * @property interval - Polling interval used to fetch new token balances + * @property tokens - List of tokens to track balances for + */ +export interface TokenBalancesConfig extends BaseConfig { + interval: number; + tokens: Token[]; +} + +/** + * @type TokenBalancesState + * + * Token balances controller state + * + * @property contractBalances - Hash of token contract addresses to balances + */ +export interface TokenBalancesState extends BaseState { + contractBalances: { [address: string]: typeof BN }; +} + +/** + * Controller that passively polls on a set interval token balances + * for tokens stored in the AssetsController + */ +export class TokenBalancesController extends BaseController { + private handle?: NodeJS.Timer; + + /** + * Name of this controller used during composition + */ + name = 'TokenBalancesController'; + + /** + * List of required sibling controllers this controller needs to function + */ + requiredControllers = ['AssetsContractController', 'AssetsController']; + + /** + * Creates a TokenBalancesController instance + * + * @param config - Initial options used to configure this controller + * @param state - Initial state to set on this controller + */ + constructor(config?: Partial, state?: Partial) { + super(config, state); + this.defaultConfig = { + interval: 180000, + tokens: [] + }; + this.defaultState = { contractBalances: {} }; + this.initialize(); + } + + /** + * Sets a new polling interval + * + * @param interval - Polling interval used to fetch new token balances + */ + set interval(interval: number) { + this.handle && clearInterval(this.handle); + this.handle = setInterval(() => { + safelyExecute(() => this.updateBalances()); + }, interval); + } + + /** + * Updates balances for all tokens + * + * @returns Promise resolving when this operation completes + */ + async updateBalances() { + if (this.disabled) { + return; + } + const assetsContract = this.context.AssetsContractController as AssetsContractController; + const assets = this.context.AssetsController as AssetsController; + const { selectedAddress } = assets.config; + const { tokens } = this.config; + const newContractBalances: { [address: string]: typeof BN } = {}; + for (const i in tokens) { + const address = tokens[i].address; + newContractBalances[address] = await assetsContract.getBalanceOf(address, selectedAddress); + } + this.update({ contractBalances: newContractBalances }); + } + + /** + * Extension point called if and when this controller is composed + * with other controllers using a ComposableController + */ + onComposed() { + super.onComposed(); + const assets = this.context.AssetsController as AssetsController; + assets.subscribe(({ tokens }) => { + this.configure({ tokens }); + this.updateBalances(); + }); + } +} + +export default TokenBalancesController; diff --git a/src/TokenRatesController.test.ts b/src/TokenRatesController.test.ts index d1cc209f925..a85df0f039e 100644 --- a/src/TokenRatesController.test.ts +++ b/src/TokenRatesController.test.ts @@ -4,6 +4,7 @@ import TokenRatesController, { Token } from './TokenRatesController'; import { AssetsController } from './AssetsController'; import { PreferencesController } from './PreferencesController'; import { NetworkController } from './NetworkController'; +import { AssetsContractController } from './AssetsContractController'; describe('TokenRatesController', () => { it('should set default state', () => { @@ -72,11 +73,12 @@ describe('TokenRatesController', () => { it('should subscribe to new sibling assets controllers', async () => { const assets = new AssetsController(); + const assetsContract = new AssetsContractController(); const controller = new TokenRatesController(); const network = new NetworkController(); const preferences = new PreferencesController(); /* tslint:disable-next-line:no-unused-expression */ - new ComposableController([controller, assets, network, preferences]); + new ComposableController([controller, assets, assetsContract, network, preferences]); assets.addToken('0xfoO', 'FOO', 18); const tokens = controller.context.AssetsController.state.tokens; const found = tokens.filter((token: Token) => token.address === '0xfoO'); diff --git a/src/TokenRatesController.ts b/src/TokenRatesController.ts index edb3469ba4f..217757c9512 100644 --- a/src/TokenRatesController.ts +++ b/src/TokenRatesController.ts @@ -136,12 +136,12 @@ export class TokenRatesController extends BaseController { expect(util.hexToBN('0x1337').toNumber()).toBe(4919); }); + describe('manageCollectibleImage', () => { + const address1 = '0x6EbeAf8e8E946F0716E6533A6f2cefc83f60e8Ab'; + const address2 = '0x6EbeAf8e8E946F0716E6533A6f2cefc83f60e8Ac'; + const image = 'https://api.godsunchained.com/v0/image/351?format=card&quality=diamond'; + expect(util.manageCollectibleImage(address1, image)).toEqual('https://api.godsunchained.com/v0/image/351'); + expect(util.manageCollectibleImage(address2, image)).toEqual(image); + }); + it('normalizeTransaction', () => { const normalized = util.normalizeTransaction({ data: 'data', diff --git a/src/util.ts b/src/util.ts index 9cda984e55e..5e78020e307 100644 --- a/src/util.ts +++ b/src/util.ts @@ -131,11 +131,30 @@ export function validateTransaction(transaction: Transaction) { } } +/** + * Modifies collectible images URI in case is necessary + * + * @param address - Collectible address + * @param image - Initial image URI given by collectible tokenURI + * @returns - Modified image URI + */ +export function manageCollectibleImage(address: string, image: string) { + const GODSADDRESS = '0x6EbeAf8e8E946F0716E6533A6f2cefc83f60e8Ab'; + let collectibleImage; + if (address === GODSADDRESS) { + collectibleImage = image.split('?')[0]; + } else { + collectibleImage = image; + } + return collectibleImage; +} + export default { BNToHex, fractionBN, getBuyURL, hexToBN, + manageCollectibleImage, normalizeTransaction, safelyExecute, validateTransaction