UNPKG

sparrow-controllers

Version:

Collection of platform-agnostic modules for creating secure data models for cryptocurrency wallets

393 lines 19.1 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.TokensController = void 0; const events_1 = require("events"); const ubq_contract_metadata_1 = __importDefault(require("ubq-contract-metadata")); const metamask_eth_abis_1 = require("@metamask/metamask-eth-abis"); const uuid_1 = require("uuid"); const async_mutex_1 = require("async-mutex"); const ethers_1 = require("ethers"); const BaseController_1 = require("../BaseController"); const util_1 = require("../util"); const constants_1 = require("../constants"); var SuggestedAssetStatus; (function (SuggestedAssetStatus) { SuggestedAssetStatus["accepted"] = "accepted"; SuggestedAssetStatus["failed"] = "failed"; SuggestedAssetStatus["pending"] = "pending"; SuggestedAssetStatus["rejected"] = "rejected"; })(SuggestedAssetStatus || (SuggestedAssetStatus = {})); /** * Controller that stores assets and exposes convenience methods */ class TokensController extends BaseController_1.BaseController { /** * Creates a TokensController instance. * * @param options - The controller options. * @param options.onPreferencesStateChange - Allows subscribing to preference controller state changes. * @param options.onNetworkStateChange - Allows subscribing to network controller state changes. * @param options.config - Initial options used to configure this controller. * @param options.state - Initial state to set on this controller. */ constructor({ onPreferencesStateChange, onNetworkStateChange, config, state, }) { super(config, state); this.mutex = new async_mutex_1.Mutex(); /** * EventEmitter instance used to listen to specific EIP747 events */ this.hub = new events_1.EventEmitter(); /** * Name of this controller used during composition */ this.name = 'TokensController'; this.defaultConfig = Object.assign({ networkType: constants_1.MAINNET, selectedAddress: '', chainId: '', provider: undefined }, config); this.defaultState = Object.assign({ allTokens: {}, allIgnoredTokens: {}, ignoredTokens: [], suggestedAssets: [], tokens: [] }, state); this.initialize(); onPreferencesStateChange(({ selectedAddress }) => { var _a, _b; const { allTokens, allIgnoredTokens } = this.state; const { chainId } = this.config; this.configure({ selectedAddress }); this.update({ tokens: ((_a = allTokens[chainId]) === null || _a === void 0 ? void 0 : _a[selectedAddress]) || [], ignoredTokens: ((_b = allIgnoredTokens[chainId]) === null || _b === void 0 ? void 0 : _b[selectedAddress]) || [], }); }); onNetworkStateChange(({ provider }) => { var _a, _b; const { allTokens, allIgnoredTokens } = this.state; const { selectedAddress } = this.config; const { chainId } = provider; this.configure({ chainId }); this.ethersProvider = this._instantiateNewEthersProvider(); this.update({ tokens: ((_a = allTokens[chainId]) === null || _a === void 0 ? void 0 : _a[selectedAddress]) || [], ignoredTokens: ((_b = allIgnoredTokens[chainId]) === null || _b === void 0 ? void 0 : _b[selectedAddress]) || [], }); }); } failSuggestedAsset(suggestedAssetMeta, error) { const failedSuggestedAssetMeta = Object.assign(Object.assign({}, suggestedAssetMeta), { status: SuggestedAssetStatus.failed, error }); this.hub.emit(`${suggestedAssetMeta.id}:finished`, failedSuggestedAssetMeta); } _instantiateNewEthersProvider() { var _a; return new ethers_1.ethers.providers.Web3Provider((_a = this.config) === null || _a === void 0 ? void 0 : _a.provider); } /** * Adds a token to the stored token list. * * @param address - Hex address of the token contract. * @param symbol - Symbol of the token. * @param decimals - Number of decimals the token uses. * @param image - Image of the token. * @returns Current token list. */ addToken(address, symbol, decimals, image) { return __awaiter(this, void 0, void 0, function* () { const releaseLock = yield this.mutex.acquire(); try { address = (0, util_1.toChecksumHexAddress)(address); const { tokens, ignoredTokens } = this.state; const isERC721 = yield this._detectIsERC721(address); const newEntry = { address, symbol, decimals, image, isERC721 }; const previousEntry = tokens.find((token) => token.address.toLowerCase() === address.toLowerCase()); if (previousEntry) { const previousIndex = tokens.indexOf(previousEntry); tokens[previousIndex] = newEntry; } else { tokens.push(newEntry); } const newIgnoredTokens = ignoredTokens.filter((tokenAddress) => tokenAddress.toLowerCase() !== address.toLowerCase()); const { newAllTokens, newAllIgnoredTokens } = this._getNewAllTokensState(tokens, newIgnoredTokens); this.update({ allTokens: newAllTokens, tokens, allIgnoredTokens: newAllIgnoredTokens, ignoredTokens: newIgnoredTokens, }); return tokens; } finally { releaseLock(); } }); } /** * Adds a batch of tokens to the stored token list. * * @param tokensToAdd - Array of Tokens to be added or updated. * @returns Current token list. */ addTokens(tokensToAdd) { return __awaiter(this, void 0, void 0, function* () { const releaseLock = yield this.mutex.acquire(); const { tokens, ignoredTokens } = this.state; try { tokensToAdd = yield Promise.all(tokensToAdd.map((token) => __awaiter(this, void 0, void 0, function* () { token.isERC721 = yield this._detectIsERC721(token.address); return token; }))); let newIgnoredTokens = ignoredTokens; tokensToAdd.forEach((tokenToAdd) => { const { address, symbol, decimals, image, isERC721 } = tokenToAdd; const checksumAddress = (0, util_1.toChecksumHexAddress)(address); const newEntry = { address: checksumAddress, symbol, decimals, image, isERC721, }; const previousEntry = tokens.find((token) => token.address.toLowerCase() === checksumAddress.toLowerCase()); if (previousEntry) { const previousIndex = tokens.indexOf(previousEntry); tokens[previousIndex] = newEntry; } else { tokens.push(newEntry); } newIgnoredTokens = newIgnoredTokens.filter((tokenAddress) => tokenAddress.toLowerCase() !== address.toLowerCase()); }); const { newAllTokens, newAllIgnoredTokens } = this._getNewAllTokensState(tokens, newIgnoredTokens); this.update({ tokens, allTokens: newAllTokens, allIgnoredTokens: newAllIgnoredTokens, ignoredTokens: newIgnoredTokens, }); return tokens; } finally { releaseLock(); } }); } /** * Adds isERC721 field to token object. This is called when a user attempts to add tokens that * were previously added which do not yet had isERC721 field. * * @param tokenAddress - The contract address of the token requiring the isERC721 field added. * @returns The new token object with the added isERC721 field. */ updateTokenType(tokenAddress) { return __awaiter(this, void 0, void 0, function* () { const isERC721 = yield this._detectIsERC721(tokenAddress); const { tokens } = this.state; const tokenIndex = tokens.findIndex((token) => { return token.address.toLowerCase() === tokenAddress.toLowerCase(); }); tokens[tokenIndex].isERC721 = isERC721; this.update({ tokens }); return tokens[tokenIndex]; }); } /** * Detects whether or not a token is ERC-721 compatible. * * @param tokenAddress - The token contract address. * @returns A boolean indicating whether the token address passed in supports the EIP-721 * interface. */ _detectIsERC721(tokenAddress) { var _a, _b; return __awaiter(this, void 0, void 0, function* () { const checksumAddress = (0, util_1.toChecksumHexAddress)(tokenAddress); // if this token is already in our contract metadata map we don't need // to check against the contract if (((_a = ubq_contract_metadata_1.default[checksumAddress]) === null || _a === void 0 ? void 0 : _a.erc721) === true) { return Promise.resolve(true); } else if (((_b = ubq_contract_metadata_1.default[checksumAddress]) === null || _b === void 0 ? void 0 : _b.erc20) === true) { return Promise.resolve(false); } const tokenContract = yield this._createEthersContract(tokenAddress, metamask_eth_abis_1.abiERC721, this.ethersProvider); try { return yield tokenContract.supportsInterface(constants_1.ERC721_INTERFACE_ID); } catch (error) { // currently we see a variety of errors across different networks when // token contracts are not ERC721 compatible. We need to figure out a better // way of differentiating token interface types but for now if we get an error // we have to assume the token is not ERC721 compatible. return false; } }); } _createEthersContract(tokenAddress, abi, ethersProvider) { return __awaiter(this, void 0, void 0, function* () { const tokenContract = yield new ethers_1.ethers.Contract(tokenAddress, abi, ethersProvider); return tokenContract; }); } _generateRandomId() { return (0, uuid_1.v1)(); } /** * Adds a new suggestedAsset to state. Parameters will be validated according to * asset type being watched. A `<suggestedAssetMeta.id>:pending` hub event will be emitted once added. * * @param asset - The asset to be watched. For now only ERC20 tokens are accepted. * @param type - The asset type. * @returns Object containing a Promise resolving to the suggestedAsset address if accepted. */ watchAsset(asset, type) { return __awaiter(this, void 0, void 0, function* () { const suggestedAssetMeta = { asset, id: this._generateRandomId(), status: SuggestedAssetStatus.pending, time: Date.now(), type, }; try { switch (type) { case 'ERC20': (0, util_1.validateTokenToWatch)(asset); break; default: throw new Error(`Asset of type ${type} not supported`); } } catch (error) { this.failSuggestedAsset(suggestedAssetMeta, error); return Promise.reject(error); } const result = new Promise((resolve, reject) => { this.hub.once(`${suggestedAssetMeta.id}:finished`, (meta) => { switch (meta.status) { case SuggestedAssetStatus.accepted: return resolve(meta.asset.address); case SuggestedAssetStatus.rejected: return reject(new Error('User rejected to watch the asset.')); case SuggestedAssetStatus.failed: return reject(new Error(meta.error.message)); /* istanbul ignore next */ default: return reject(new Error(`Unknown status: ${meta.status}`)); } }); }); const { suggestedAssets } = this.state; suggestedAssets.push(suggestedAssetMeta); this.update({ suggestedAssets: [...suggestedAssets] }); this.hub.emit('pendingSuggestedAsset', suggestedAssetMeta); return { result, suggestedAssetMeta }; }); } /** * Accepts to watch an asset and updates it's status and deletes the suggestedAsset from state, * adding the asset to corresponding asset state. In this case ERC20 tokens. * A `<suggestedAssetMeta.id>:finished` hub event is fired after accepted or failure. * * @param suggestedAssetID - The ID of the suggestedAsset to accept. */ acceptWatchAsset(suggestedAssetID) { return __awaiter(this, void 0, void 0, function* () { const { suggestedAssets } = this.state; const index = suggestedAssets.findIndex(({ id }) => suggestedAssetID === id); const suggestedAssetMeta = suggestedAssets[index]; try { switch (suggestedAssetMeta.type) { case 'ERC20': const { address, symbol, decimals, image } = suggestedAssetMeta.asset; yield this.addToken(address, symbol, decimals, image); suggestedAssetMeta.status = SuggestedAssetStatus.accepted; this.hub.emit(`${suggestedAssetMeta.id}:finished`, suggestedAssetMeta); break; default: throw new Error(`Asset of type ${suggestedAssetMeta.type} not supported`); } } catch (error) { this.failSuggestedAsset(suggestedAssetMeta, error); } const newSuggestedAssets = suggestedAssets.filter(({ id }) => id !== suggestedAssetID); this.update({ suggestedAssets: [...newSuggestedAssets] }); }); } /** * Rejects a watchAsset request based on its ID by setting its status to "rejected" * and emitting a `<suggestedAssetMeta.id>:finished` hub event. * * @param suggestedAssetID - The ID of the suggestedAsset to accept. */ rejectWatchAsset(suggestedAssetID) { const { suggestedAssets } = this.state; const index = suggestedAssets.findIndex(({ id }) => suggestedAssetID === id); const suggestedAssetMeta = suggestedAssets[index]; if (!suggestedAssetMeta) { return; } suggestedAssetMeta.status = SuggestedAssetStatus.rejected; this.hub.emit(`${suggestedAssetMeta.id}:finished`, suggestedAssetMeta); const newSuggestedAssets = suggestedAssets.filter(({ id }) => id !== suggestedAssetID); this.update({ suggestedAssets: [...newSuggestedAssets] }); } /** * Removes a token from the stored token list and saves it in ignored tokens list. * * @param address - The hex address of the token contract. */ removeAndIgnoreToken(address) { address = (0, util_1.toChecksumHexAddress)(address); const { tokens, ignoredTokens } = this.state; const alreadyIgnored = ignoredTokens.find((tokenAddress) => tokenAddress.toLowerCase() === address.toLowerCase()); const newTokens = tokens.filter((token) => { if (token.address.toLowerCase() === address.toLowerCase()) { !alreadyIgnored && ignoredTokens.push(address); return false; } return true; }); const { newAllTokens, newAllIgnoredTokens } = this._getNewAllTokensState(newTokens, ignoredTokens); this.update({ allTokens: newAllTokens, tokens: newTokens, allIgnoredTokens: newAllIgnoredTokens, ignoredTokens, }); } /** * Takes a new tokens and ignoredTokens array for the current network/account combination * and returns new allTokens and allIgnoredTokens state to update to. * * @param newTokens - The new tokens to set for the current network and selected account. * @param newIgnoredTokens - The new ignored tokens to set for the current network and selected account. * @returns The updated `allTokens` and `allIgnoredTokens` state. */ _getNewAllTokensState(newTokens, newIgnoredTokens) { const { allTokens, allIgnoredTokens } = this.state; const { chainId, selectedAddress } = this.config; const networkTokens = allTokens[chainId]; const networkIgnoredTokens = allIgnoredTokens[chainId]; const newNetworkTokens = Object.assign(Object.assign({}, networkTokens), { [selectedAddress]: newTokens }); const newIgnoredNetworkTokens = Object.assign(Object.assign({}, networkIgnoredTokens), { [selectedAddress]: newIgnoredTokens }); const newAllTokens = Object.assign(Object.assign({}, allTokens), { [chainId]: newNetworkTokens }); const newAllIgnoredTokens = Object.assign(Object.assign({}, allIgnoredTokens), { [chainId]: newIgnoredNetworkTokens }); return { newAllTokens, newAllIgnoredTokens }; } /** * Removes all tokens from the ignored list. */ clearIgnoredTokens() { this.update({ ignoredTokens: [], allIgnoredTokens: {} }); } } exports.TokensController = TokensController; exports.default = TokensController; //# sourceMappingURL=TokensController.js.map