diff options
Diffstat (limited to 'app/scripts')
57 files changed, 2929 insertions, 648 deletions
diff --git a/app/scripts/background.js b/app/scripts/background.js index 6296eaa21..37e2b68fc 100644 --- a/app/scripts/background.js +++ b/app/scripts/background.js @@ -1,3 +1,7 @@ +/** + * @file The entry point for the web extension singleton process. + */ + const urlUtil = require('url') const endOfStream = require('end-of-stream') const pump = require('pump') @@ -61,6 +65,90 @@ initialize().catch(log.error) // setup metamask mesh testing container setupMetamaskMeshMetrics() +/** + * An object representing a transaction, in whatever state it is in. + * @typedef TransactionMeta + * + * @property {number} id - An internally unique tx identifier. + * @property {number} time - Time the tx was first suggested, in unix epoch time (ms). + * @property {string} status - The current transaction status (unapproved, signed, submitted, dropped, failed, rejected), as defined in `tx-state-manager.js`. + * @property {string} metamaskNetworkId - The transaction's network ID, used for EIP-155 compliance. + * @property {boolean} loadingDefaults - TODO: Document + * @property {Object} txParams - The tx params as passed to the network provider. + * @property {Object[]} history - A history of mutations to this TransactionMeta object. + * @property {boolean} gasPriceSpecified - True if the suggesting dapp specified a gas price, prevents auto-estimation. + * @property {boolean} gasLimitSpecified - True if the suggesting dapp specified a gas limit, prevents auto-estimation. + * @property {string} estimatedGas - A hex string represented the estimated gas limit required to complete the transaction. + * @property {string} origin - A string representing the interface that suggested the transaction. + * @property {Object} nonceDetails - A metadata object containing information used to derive the suggested nonce, useful for debugging nonce issues. + * @property {string} rawTx - A hex string of the final signed transaction, ready to submit to the network. + * @property {string} hash - A hex string of the transaction hash, used to identify the transaction on the network. + * @property {number} submittedTime - The time the transaction was submitted to the network, in Unix epoch time (ms). + */ + +/** + * The data emitted from the MetaMaskController.store EventEmitter, also used to initialize the MetaMaskController. Available in UI on React state as state.metamask. + * @typedef MetaMaskState + * @property {boolean} isInitialized - Whether the first vault has been created. + * @property {boolean} isUnlocked - Whether the vault is currently decrypted and accounts are available for selection. + * @property {boolean} isAccountMenuOpen - Represents whether the main account selection UI is currently displayed. + * @property {boolean} isMascara - True if the current context is the extensionless MetaMascara project. + * @property {boolean} isPopup - Returns true if the current view is an externally-triggered notification. + * @property {string} rpcTarget - DEPRECATED - The URL of the current RPC provider. + * @property {Object} identities - An object matching lower-case hex addresses to Identity objects with "address" and "name" (nickname) keys. + * @property {Object} unapprovedTxs - An object mapping transaction hashes to unapproved transactions. + * @property {boolean} noActiveNotices - False if there are notices the user should confirm before using the application. + * @property {Array} frequentRpcList - A list of frequently used RPCs, including custom user-provided ones. + * @property {Array} addressBook - A list of previously sent to addresses. + * @property {address} selectedTokenAddress - Used to indicate if a token is globally selected. Should be deprecated in favor of UI-centric token selection. + * @property {Object} tokenExchangeRates - Info about current token prices. + * @property {Array} tokens - Tokens held by the current user, including their balances. + * @property {Object} send - TODO: Document + * @property {Object} coinOptions - TODO: Document + * @property {boolean} useBlockie - Indicates preferred user identicon format. True for blockie, false for Jazzicon. + * @property {Object} featureFlags - An object for optional feature flags. + * @property {string} networkEndpointType - TODO: Document + * @property {boolean} isRevealingSeedWords - True if seed words are currently being recovered, and should be shown to user. + * @property {boolean} welcomeScreen - True if welcome screen should be shown. + * @property {string} currentLocale - A locale string matching the user's preferred display language. + * @property {Object} provider - The current selected network provider. + * @property {string} provider.rpcTarget - The address for the RPC API, if using an RPC API. + * @property {string} provider.type - An identifier for the type of network selected, allows MetaMask to use custom provider strategies for known networks. + * @property {string} network - A stringified number of the current network ID. + * @property {Object} accounts - An object mapping lower-case hex addresses to objects with "balance" and "address" keys, both storing hex string values. + * @property {hex} currentBlockGasLimit - The most recently seen block gas limit, in a lower case hex prefixed string. + * @property {TransactionMeta[]} selectedAddressTxList - An array of transactions associated with the currently selected account. + * @property {Object} unapprovedMsgs - An object of messages associated with the currently selected account, mapping a unique ID to the options. + * @property {number} unapprovedMsgCount - The number of messages in unapprovedMsgs. + * @property {Object} unapprovedPersonalMsgs - An object of messages associated with the currently selected account, mapping a unique ID to the options. + * @property {number} unapprovedPersonalMsgCount - The number of messages in unapprovedPersonalMsgs. + * @property {Object} unapprovedTypedMsgs - An object of messages associated with the currently selected account, mapping a unique ID to the options. + * @property {number} unapprovedTypedMsgCount - The number of messages in unapprovedTypedMsgs. + * @property {string[]} keyringTypes - An array of unique keyring identifying strings, representing available strategies for creating accounts. + * @property {Keyring[]} keyrings - An array of keyring descriptions, summarizing the accounts that are available for use, and what keyrings they belong to. + * @property {Object} computedBalances - Maps accounts to their balances, accounting for balance changes from pending transactions. + * @property {string} currentAccountTab - A view identifying string for displaying the current displayed view, allows user to have a preferred tab in the old UI (between tokens and history). + * @property {string} selectedAddress - A lower case hex string of the currently selected address. + * @property {string} currentCurrency - A string identifying the user's preferred display currency, for use in showing conversion rates. + * @property {number} conversionRate - A number representing the current exchange rate from the user's preferred currency to Ether. + * @property {number} conversionDate - A unix epoch date (ms) for the time the current conversion rate was last retrieved. + * @property {Object} infuraNetworkStatus - An object of infura network status checks. + * @property {Block[]} recentBlocks - An array of recent blocks, used to calculate an effective but cheap gas price. + * @property {Array} shapeShiftTxList - An array of objects describing shapeshift exchange attempts. + * @property {Array} lostAccounts - TODO: Remove this feature. A leftover from the version-3 migration where our seed-phrase library changed to fix a bug where some accounts were mis-generated, but we recovered the old accounts as "lost" instead of losing them. + * @property {boolean} forgottenPassword - Returns true if the user has initiated the password recovery screen, is recovering from seed phrase. + */ + +/** + * @typedef VersionedData + * @property {MetaMaskState} data - The data emitted from MetaMask controller, or used to initialize it. + * @property {Number} version - The latest migration version that has been run. + */ + +/** + * Initializes the MetaMask controller, and sets up all platform configuration. + * @returns {Promise} Setup complete. + */ async function initialize () { const initState = await loadStateFromPersistence() const initLangCode = await getFirstPreferredLangCode() @@ -72,6 +160,11 @@ async function initialize () { // State and Persistence // +/** + * Loads any stored data, prioritizing the latest storage strategy. + * Migrates that data schema in case it was last loaded on an older version. + * @returns {Promise<MetaMaskState>} Last data emitted from previous instance of MetaMask. + */ async function loadStateFromPersistence () { // migrations const migrator = new Migrator({ migrations }) @@ -134,6 +227,16 @@ async function loadStateFromPersistence () { return versionedData.data } +/** + * Initializes the MetaMask Controller with any initial state and default language. + * Configures platform-specific error reporting strategy. + * Streams emitted state updates to platform-specific storage strategy. + * Creates platform listeners for new Dapps/Contexts, and sets up their data connections to the controller. + * + * @param {Object} initState - The initial state to start the controller with, matches the state that is emitted from the controller. + * @param {String} initLangCode - The region code for the language preferred by the current user. + * @returns {Promise} After setup is complete. + */ function setupController (initState, initLangCode) { // // MetaMask Controller @@ -158,7 +261,11 @@ function setupController (initState, initLangCode) { controller.txController.on(`tx:status-update`, (txId, status) => { if (status !== 'failed') return const txMeta = controller.txController.txStateManager.getTx(txId) - reportFailedTxToSentry({ raven, txMeta }) + try { + reportFailedTxToSentry({ raven, txMeta }) + } catch (e) { + console.error(e) + } }) // setup state persistence @@ -172,6 +279,11 @@ function setupController (initState, initLangCode) { } ) + /** + * Assigns the given state to the versioned object (with metadata), and returns that. + * @param {Object} state - The state object as emitted by the MetaMaskController. + * @returns {VersionedData} The state object wrapped in an object that includes a metadata key. + */ function versionifyData (state) { versionedData.data = state return versionedData @@ -209,6 +321,18 @@ function setupController (initState, initLangCode) { return popupIsOpen || Boolean(Object.keys(openMetamaskTabsIDs).length) || notificationIsOpen } + /** + * A runtime.Port object, as provided by the browser: + * @link https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/runtime/Port + * @typedef Port + * @type Object + */ + + /** + * Connects a Port to the MetaMask controller via a multiplexed duplex stream. + * This method identifies trusted (MetaMask) interfaces, and connects them differently from untrusted (web pages). + * @param {Port} remotePort - The port provided by a new context. + */ function connectRemote (remotePort) { const processName = remotePort.name const isMetaMaskInternalProcess = metamaskInternalProcessHash[processName] @@ -267,7 +391,10 @@ function setupController (initState, initLangCode) { controller.messageManager.on('updateBadge', updateBadge) controller.personalMessageManager.on('updateBadge', updateBadge) - // plugin badge text + /** + * Updates the Web Extension's "badge" number, on the little fox in the toolbar. + * The number reflects the current number of pending transactions or message signatures needing user approval. + */ function updateBadge () { var label = '' var unapprovedTxCount = controller.txController.getUnapprovedTxCount() @@ -289,7 +416,9 @@ function setupController (initState, initLangCode) { // Etc... // -// popup trigger +/** + * Opens the browser popup for user confirmation + */ function triggerUi () { extension.tabs.query({ active: true }, tabs => { const currentlyActiveMetamaskTab = Boolean(tabs.find(tab => openMetamaskTabsIDs[tab.id])) diff --git a/app/scripts/config.js b/app/scripts/config.js deleted file mode 100644 index a8470ed82..000000000 --- a/app/scripts/config.js +++ /dev/null @@ -1,44 +0,0 @@ -const MAINET_RPC_URL = 'https://mainnet.infura.io/metamask' -const ROPSTEN_RPC_URL = 'https://ropsten.infura.io/metamask' -const KOVAN_RPC_URL = 'https://kovan.infura.io/metamask' -const RINKEBY_RPC_URL = 'https://rinkeby.infura.io/metamask' -const LOCALHOST_RPC_URL = 'http://localhost:8545' - -const MAINET_RPC_URL_BETA = 'https://mainnet.infura.io/metamask2' -const ROPSTEN_RPC_URL_BETA = 'https://ropsten.infura.io/metamask2' -const KOVAN_RPC_URL_BETA = 'https://kovan.infura.io/metamask2' -const RINKEBY_RPC_URL_BETA = 'https://rinkeby.infura.io/metamask2' - -const DEFAULT_RPC = 'rinkeby' -const OLD_UI_NETWORK_TYPE = 'network' -const BETA_UI_NETWORK_TYPE = 'networkBeta' - -global.METAMASK_DEBUG = process.env.METAMASK_DEBUG - -module.exports = { - network: { - localhost: LOCALHOST_RPC_URL, - mainnet: MAINET_RPC_URL, - ropsten: ROPSTEN_RPC_URL, - kovan: KOVAN_RPC_URL, - rinkeby: RINKEBY_RPC_URL, - }, - // Used for beta UI - networkBeta: { - localhost: LOCALHOST_RPC_URL, - mainnet: MAINET_RPC_URL_BETA, - ropsten: ROPSTEN_RPC_URL_BETA, - kovan: KOVAN_RPC_URL_BETA, - rinkeby: RINKEBY_RPC_URL_BETA, - }, - networkNames: { - 3: 'Ropsten', - 4: 'Rinkeby', - 42: 'Kovan', - }, - enums: { - DEFAULT_RPC, - OLD_UI_NETWORK_TYPE, - BETA_UI_NETWORK_TYPE, - }, -} diff --git a/app/scripts/contentscript.js b/app/scripts/contentscript.js index fe1766273..dbf1c6d4c 100644 --- a/app/scripts/contentscript.js +++ b/app/scripts/contentscript.js @@ -23,6 +23,9 @@ if (shouldInjectWeb3()) { setupStreams() } +/** + * Creates a script tag that injects inpage.js + */ function setupInjection () { try { // inject in-page script @@ -37,6 +40,10 @@ function setupInjection () { } } +/** + * Sets up two-way communication streams between the + * browser extension and local per-page browser context + */ function setupStreams () { // setup communication to page and plugin const pageStream = new LocalMessageDuplexStream({ @@ -89,17 +96,34 @@ function setupStreams () { mux.ignoreStream('publicConfig') } + +/** + * Error handler for page to plugin stream disconnections + * + * @param {string} remoteLabel Remote stream name + * @param {Error} err Stream connection error + */ function logStreamDisconnectWarning (remoteLabel, err) { let warningMsg = `MetamaskContentscript - lost connection to ${remoteLabel}` if (err) warningMsg += '\n' + err.stack console.warn(warningMsg) } +/** + * Determines if Web3 should be injected + * + * @returns {boolean} {@code true} if Web3 should be injected + */ function shouldInjectWeb3 () { return doctypeCheck() && suffixCheck() && documentElementCheck() && !blacklistedDomainCheck() } +/** + * Checks the doctype of the current document if it exists + * + * @returns {boolean} {@code true} if the doctype is html or if none exists + */ function doctypeCheck () { const doctype = window.document.doctype if (doctype) { @@ -109,6 +133,11 @@ function doctypeCheck () { } } +/** + * Checks the current document extension + * + * @returns {boolean} {@code true} if the current extension is not prohibited + */ function suffixCheck () { var prohibitedTypes = ['xml', 'pdf'] var currentUrl = window.location.href @@ -122,6 +151,11 @@ function suffixCheck () { return true } +/** + * Checks the documentElement of the current document + * + * @returns {boolean} {@code true} if the documentElement is an html node or if none exists + */ function documentElementCheck () { var documentElement = document.documentElement.nodeName if (documentElement) { @@ -130,6 +164,11 @@ function documentElementCheck () { return true } +/** + * Checks if the current domain is blacklisted + * + * @returns {boolean} {@code true} if the current domain is blacklisted + */ function blacklistedDomainCheck () { var blacklistedDomains = [ 'uscourts.gov', @@ -148,6 +187,9 @@ function blacklistedDomainCheck () { return false } +/** + * Redirects the current page to a phishing information page + */ function redirectToPhishingWarning () { console.log('MetaMask - redirecting to phishing warning') window.location.href = 'https://metamask.io/phishing.html' diff --git a/app/scripts/controllers/address-book.js b/app/scripts/controllers/address-book.js index 6fb4ee114..c91e6b2e4 100644 --- a/app/scripts/controllers/address-book.js +++ b/app/scripts/controllers/address-book.js @@ -4,9 +4,22 @@ const extend = require('xtend') class AddressBookController { - // Controller in charge of managing the address book functionality from the - // recipients field on the send screen. Manages a history of all saved - // addresses and all currently owned addresses. + /** + * Controller in charge of managing the address book functionality from the + * recipients field on the send screen. Manages a history of all saved + * addresses and all currently owned addresses. + * + * @typedef {Object} AddressBookController + * @param {object} opts Overrides the defaults for the initial state of this.store + * @property {array} opts.initState initializes the the state of the AddressBookController. Can contain an + * addressBook property to initialize the addressBook array + * @param {KeyringController} keyringController (Soon to be deprecated) The keyringController used in the current + * MetamaskController. Contains the identities used in this AddressBookController. + * @property {object} store The the store of the current users address book + * @property {array} store.addressBook An array of addresses and nicknames. These are set by the user when sending + * to a new address. + * + */ constructor (opts = {}, keyringController) { const initState = extend({ addressBook: [], @@ -19,7 +32,14 @@ class AddressBookController { // PUBLIC METHODS // - // Sets a new address book in store by accepting a new address and nickname. + /** + * Sets a new address book in store by accepting a new address and nickname. + * + * @param {string} address A hex address of a new account that the user is sending to. + * @param {string} name The name the user wishes to associate with the new account + * @returns {Promise<void>} Promise resolves with undefined + * + */ setAddressBook (address, name) { return this._addToAddressBook(address, name) .then((addressBook) => { @@ -30,14 +50,16 @@ class AddressBookController { }) } - // - // PRIVATE METHODS - // - - - // Performs the logic to add the address and name into the address book. The - // pushed object is an object of two fields. Current behavior does not set an - // upper limit to the number of addresses. + /** + * Performs the logic to add the address and name into the address book. The pushed object is an object of two + * fields. Current behavior does not set an upper limit to the number of addresses. + * + * @private + * @param {string} address A hex address of a new account that the user is sending to. + * @param {string} name The name the user wishes to associate with the new account + * @returns {Promise<array>} Promises the updated addressBook array + * + */ _addToAddressBook (address, name) { const addressBook = this._getAddressBook() const identities = this._getIdentities() @@ -62,14 +84,26 @@ class AddressBookController { return Promise.resolve(addressBook) } - // Internal method to get the address book. Current persistence behavior - // should not require that this method be called from the UI directly. + /** + * Internal method to get the address book. Current persistence behavior should not require that this method be + * called from the UI directly. + * + * @private + * @returns {array} The addressBook array from the store. + * + */ _getAddressBook () { return this.store.getState().addressBook } - // Retrieves identities from the keyring controller in order to avoid - // duplication + /** + * Retrieves identities from the keyring controller in order to avoid + * duplication + * + * @deprecated + * @returns {array} Returns the identies array from the keyringContoller's state + * + */ _getIdentities () { return this.keyringController.memStore.getState().identities } diff --git a/app/scripts/controllers/balance.js b/app/scripts/controllers/balance.js index f83f294cc..86619fce1 100644 --- a/app/scripts/controllers/balance.js +++ b/app/scripts/controllers/balance.js @@ -4,6 +4,24 @@ const BN = require('ethereumjs-util').BN class BalanceController { + /** + * Controller responsible for storing and updating an account's balance. + * + * @typedef {Object} BalanceController + * @param {Object} opts Initialize various properties of the class. + * @property {string} address A base 16 hex string. The account address which has the balance managed by this + * BalanceController. + * @property {AccountTracker} accountTracker Stores and updates the users accounts + * for which this BalanceController manages balance. + * @property {TransactionController} txController Stores, tracks and manages transactions. Here used to create a listener for + * transaction updates. + * @property {BlockTracker} blockTracker Tracks updates to blocks. On new blocks, this BalanceController updates its balance + * @property {Object} store The store for the ethBalance + * @property {string} store.ethBalance A base 16 hex string. The balance for the current account. + * @property {PendingBalanceCalculator} balanceCalc Used to calculate the accounts balance with possible pending + * transaction costs taken into account. + * + */ constructor (opts = {}) { this._validateParams(opts) const { address, accountTracker, txController, blockTracker } = opts @@ -26,6 +44,11 @@ class BalanceController { this._registerUpdates() } + /** + * Updates the ethBalance property to the current pending balance + * + * @returns {Promise<void>} Promises undefined + */ async updateBalance () { const balance = await this.balanceCalc.getBalance() this.store.updateState({ @@ -33,6 +56,15 @@ class BalanceController { }) } + /** + * Sets up listeners and subscriptions which should trigger an update of ethBalance. These updates include: + * - when a transaction changes state to 'submitted', 'confirmed' or 'failed' + * - when the current account changes (i.e. a new account is selected) + * - when there is a block update + * + * @private + * + */ _registerUpdates () { const update = this.updateBalance.bind(this) @@ -51,6 +83,14 @@ class BalanceController { this.blockTracker.on('block', update) } + /** + * Gets the balance, as a base 16 hex string, of the account at this BalanceController's current address. + * If the current account has no balance, returns undefined. + * + * @returns {Promise<BN|void>} Promises a BN with a value equal to the balance of the current account, or undefined + * if the current account has no balance + * + */ async _getBalance () { const { accounts } = this.accountTracker.store.getState() const entry = accounts[this.address] @@ -58,6 +98,14 @@ class BalanceController { return balance ? new BN(balance.substring(2), 16) : undefined } + /** + * Gets the pending transactions (i.e. those with a 'submitted' status). These are accessed from the + * TransactionController passed to this BalanceController during construction. + * + * @private + * @returns {Promise<array>} Promises an array of transaction objects. + * + */ async _getPendingTransactions () { const pending = this.txController.getFilteredTxList({ from: this.address, @@ -67,6 +115,14 @@ class BalanceController { return pending } + /** + * Validates that the passed options have all required properties. + * + * @param {Object} opts The options object to validate + * @throws {string} Throw a custom error indicating that address, accountTracker, txController and blockTracker are + * missing and at least one is required + * + */ _validateParams (opts) { const { address, accountTracker, txController, blockTracker } = opts if (!address || !accountTracker || !txController || !blockTracker) { diff --git a/app/scripts/controllers/blacklist.js b/app/scripts/controllers/blacklist.js index d965f80b8..f100c4525 100644 --- a/app/scripts/controllers/blacklist.js +++ b/app/scripts/controllers/blacklist.js @@ -10,6 +10,22 @@ const POLLING_INTERVAL = 4 * 60 * 1000 class BlacklistController { + /** + * Responsible for polling for and storing an up to date 'eth-phishing-detect' config.json file, while + * exposing a method that can check whether a given url is a phishing attempt. The 'eth-phishing-detect' + * config.json file contains a fuzzylist, whitelist and blacklist. + * + * + * @typedef {Object} BlacklistController + * @param {object} opts Overrides the defaults for the initial state of this.store + * @property {object} store The the store of the current phishing config + * @property {object} store.phishing Contains fuzzylist, whitelist and blacklist arrays. @see + * {@link https://github.com/MetaMask/eth-phishing-detect/blob/master/src/config.json} + * @property {object} _phishingDetector The PhishingDetector instantiated by passing store.phishing to + * PhishingDetector. + * @property {object} _phishingUpdateIntervalRef Id of the interval created to periodically update the blacklist + * + */ constructor (opts = {}) { const initState = extend({ phishing: PHISHING_DETECTION_CONFIG, @@ -22,16 +38,28 @@ class BlacklistController { this._phishingUpdateIntervalRef = null } - // - // PUBLIC METHODS - // - + /** + * Given a url, returns the result of checking if that url is in the store.phishing blacklist + * + * @param {string} hostname The hostname portion of a url; the one that will be checked against the white and + * blacklists of store.phishing + * @returns {boolean} Whether or not the passed hostname is on our phishing blacklist + * + */ checkForPhishing (hostname) { if (!hostname) return false const { result } = this._phishingDetector.check(hostname) return result } + /** + * Queries `https://api.infura.io/v2/blacklist` for an updated blacklist config. This is passed to this._phishingDetector + * to update our phishing detector instance, and is updated in the store. The new phishing config is returned + * + * + * @returns {Promise<object>} Promises the updated blacklist config for the phishingDetector + * + */ async updatePhishingList () { const response = await fetch('https://api.infura.io/v2/blacklist') const phishing = await response.json() @@ -40,6 +68,11 @@ class BlacklistController { return phishing } + /** + * Initiates the updating of the local blacklist at a set interval. The update is done via this.updatePhishingList(). + * Also, this method store a reference to that interval at this._phishingUpdateIntervalRef + * + */ scheduleUpdates () { if (this._phishingUpdateIntervalRef) return this.updatePhishingList().catch(log.warn) @@ -48,10 +81,14 @@ class BlacklistController { }, POLLING_INTERVAL) } - // - // PRIVATE METHODS - // - + /** + * Sets this._phishingDetector to a new PhishingDetector instance. + * @see {@link https://github.com/MetaMask/eth-phishing-detect} + * + * @private + * @param {object} config A config object like that found at {@link https://github.com/MetaMask/eth-phishing-detect/blob/master/src/config.json} + * + */ _setupPhishingDetector (config) { this._phishingDetector = new PhishingDetector(config) } diff --git a/app/scripts/controllers/computed-balances.js b/app/scripts/controllers/computed-balances.js index 907b087cf..1a6802f9a 100644 --- a/app/scripts/controllers/computed-balances.js +++ b/app/scripts/controllers/computed-balances.js @@ -2,8 +2,24 @@ const ObservableStore = require('obs-store') const extend = require('xtend') const BalanceController = require('./balance') -class ComputedbalancesController { +/** + * @typedef {Object} ComputedBalancesOptions + * @property {Object} accountTracker Account tracker store reference + * @property {Object} txController Token controller reference + * @property {Object} blockTracker Block tracker reference + * @property {Object} initState Initial state to populate this internal store with + */ +/** + * Background controller responsible for syncing + * and computing ETH balances for all accounts + */ +class ComputedbalancesController { + /** + * Creates a new controller instance + * + * @param {ComputedBalancesOptions} [opts] Controller configuration parameters + */ constructor (opts = {}) { const { accountTracker, txController, blockTracker } = opts this.accountTracker = accountTracker @@ -19,6 +35,9 @@ class ComputedbalancesController { this._initBalanceUpdating() } + /** + * Updates balances associated with each internal address + */ updateAllBalances () { Object.keys(this.balances).forEach((balance) => { const address = balance.address @@ -26,12 +45,23 @@ class ComputedbalancesController { }) } + /** + * Initializes internal address tracking + * + * @private + */ _initBalanceUpdating () { const store = this.accountTracker.store.getState() this.syncAllAccountsFromStore(store) this.accountTracker.store.subscribe(this.syncAllAccountsFromStore.bind(this)) } + /** + * Uses current account state to sync and track all + * addresses associated with the current account + * + * @param {{ accounts: Object }} store Account tracking state + */ syncAllAccountsFromStore (store) { const upstream = Object.keys(store.accounts) const balances = Object.keys(this.balances) @@ -50,6 +80,13 @@ class ComputedbalancesController { }) } + /** + * Conditionally establishes a new subscription + * to track an address associated with the current + * account + * + * @param {string} address Address to conditionally subscribe to + */ trackAddressIfNotAlready (address) { const state = this.store.getState() if (!(address in state.computedBalances)) { @@ -57,6 +94,12 @@ class ComputedbalancesController { } } + /** + * Establishes a new subscription to track an + * address associated with the current account + * + * @param {string} address Address to conditionally subscribe to + */ trackAddress (address) { const updater = new BalanceController({ address, diff --git a/app/scripts/controllers/currency.js b/app/scripts/controllers/currency.js index d9e0a3e34..480c08b1c 100644 --- a/app/scripts/controllers/currency.js +++ b/app/scripts/controllers/currency.js @@ -1,4 +1,4 @@ -const ObservableStore = require('obs-store') + const ObservableStore = require('obs-store') const extend = require('xtend') const log = require('loglevel') @@ -7,6 +7,22 @@ const POLLING_INTERVAL = 600000 class CurrencyController { + /** + * Controller responsible for managing data associated with the currently selected currency. + * + * @typedef {Object} CurrencyController + * @param {object} opts Overrides the defaults for the initial state of this.store + * @property {array} opts.initState initializes the the state of the CurrencyController. Can contain an + * currentCurrency, conversionRate and conversionDate properties + * @property {string} currentCurrency A 2-4 character shorthand that describes a specific currency, currently + * selected by the user + * @property {number} conversionRate The conversion rate from ETH to the selected currency. + * @property {string} conversionDate The date at which the conversion rate was set. Expressed in in milliseconds + * since midnight of January 1, 1970 + * @property {number} conversionInterval The id of the interval created by the scheduleConversionInterval method. + * Used to clear an existing interval on subsequent calls of that method. + * + */ constructor (opts = {}) { const initState = extend({ currentCurrency: 'usd', @@ -20,30 +36,73 @@ class CurrencyController { // PUBLIC METHODS // + /** + * A getter for the currentCurrency property + * + * @returns {string} A 2-4 character shorthand that describes a specific currency, currently selected by the user + * + */ getCurrentCurrency () { return this.store.getState().currentCurrency } + /** + * A setter for the currentCurrency property + * + * @param {string} currentCurrency The new currency to set as the currentCurrency in the store + * + */ setCurrentCurrency (currentCurrency) { this.store.updateState({ currentCurrency }) } + /** + * A getter for the conversionRate property + * + * @returns {string} The conversion rate from ETH to the selected currency. + * + */ getConversionRate () { return this.store.getState().conversionRate } + /** + * A setter for the conversionRate property + * + * @param {number} conversionRate The new rate to set as the conversionRate in the store + * + */ setConversionRate (conversionRate) { this.store.updateState({ conversionRate }) } + /** + * A getter for the conversionDate property + * + * @returns {string} The date at which the conversion rate was set. Expressed in milliseconds since midnight of + * January 1, 1970 + * + */ getConversionDate () { return this.store.getState().conversionDate } + /** + * A setter for the conversionDate property + * + * @param {number} conversionDate The date, expressed in milliseconds since midnight of January 1, 1970, that the + * conversionRate was set + * + */ setConversionDate (conversionDate) { this.store.updateState({ conversionDate }) } + /** + * Updates the conversionRate and conversionDate properties associated with the currentCurrency. Updated info is + * fetched from an external API + * + */ async updateConversionRate () { let currentCurrency try { @@ -59,6 +118,12 @@ class CurrencyController { } } + /** + * Creates a new poll, using setInterval, to periodically call updateConversionRate. The id of the interval is + * stored at the controller's conversionInterval property. If it is called and such an id already exists, the + * previous interval is clear and a new one is created. + * + */ scheduleConversionInterval () { if (this.conversionInterval) { clearInterval(this.conversionInterval) diff --git a/app/scripts/controllers/network/enums.js b/app/scripts/controllers/network/enums.js new file mode 100644 index 000000000..4f29e301b --- /dev/null +++ b/app/scripts/controllers/network/enums.js @@ -0,0 +1,56 @@ +const ROPSTEN = 'ropsten' +const RINKEBY = 'rinkeby' +const KOVAN = 'kovan' +const MAINNET = 'mainnet' +const LOCALHOST = 'localhost' + +const ROPSTEN_CODE = 3 +const RINKEYBY_CODE = 4 +const KOVAN_CODE = 42 + +const ROPSTEN_DISPLAY_NAME = 'Ropsten' +const RINKEBY_DISPLAY_NAME = 'Rinkeby' +const KOVAN_DISPLAY_NAME = 'Kovan' +const MAINNET_DISPLAY_NAME = 'Main Ethereum Network' + +const MAINNET_RPC_URL = 'https://mainnet.infura.io/metamask' +const ROPSTEN_RPC_URL = 'https://ropsten.infura.io/metamask' +const KOVAN_RPC_URL = 'https://kovan.infura.io/metamask' +const RINKEBY_RPC_URL = 'https://rinkeby.infura.io/metamask' +const LOCALHOST_RPC_URL = 'http://localhost:8545' + +const MAINNET_RPC_URL_BETA = 'https://mainnet.infura.io/metamask2' +const ROPSTEN_RPC_URL_BETA = 'https://ropsten.infura.io/metamask2' +const KOVAN_RPC_URL_BETA = 'https://kovan.infura.io/metamask2' +const RINKEBY_RPC_URL_BETA = 'https://rinkeby.infura.io/metamask2' + +const DEFAULT_NETWORK = 'rinkeby' +const OLD_UI_NETWORK_TYPE = 'network' +const BETA_UI_NETWORK_TYPE = 'networkBeta' + +module.exports = { + ROPSTEN, + RINKEBY, + KOVAN, + MAINNET, + LOCALHOST, + ROPSTEN_CODE, + RINKEYBY_CODE, + KOVAN_CODE, + ROPSTEN_DISPLAY_NAME, + RINKEBY_DISPLAY_NAME, + KOVAN_DISPLAY_NAME, + MAINNET_DISPLAY_NAME, + MAINNET_RPC_URL, + ROPSTEN_RPC_URL, + KOVAN_RPC_URL, + RINKEBY_RPC_URL, + LOCALHOST_RPC_URL, + MAINNET_RPC_URL_BETA, + ROPSTEN_RPC_URL_BETA, + KOVAN_RPC_URL_BETA, + RINKEBY_RPC_URL_BETA, + DEFAULT_NETWORK, + OLD_UI_NETWORK_TYPE, + BETA_UI_NETWORK_TYPE, +} diff --git a/app/scripts/controllers/network/index.js b/app/scripts/controllers/network/index.js new file mode 100644 index 000000000..fb095bf33 --- /dev/null +++ b/app/scripts/controllers/network/index.js @@ -0,0 +1,2 @@ +const NetworkController = require('./network') +module.exports = NetworkController diff --git a/app/scripts/controllers/network.js b/app/scripts/controllers/network/network.js index 45574e673..2f5b81cd2 100644 --- a/app/scripts/controllers/network.js +++ b/app/scripts/controllers/network/network.js @@ -1,17 +1,24 @@ const assert = require('assert') const EventEmitter = require('events') const createMetamaskProvider = require('web3-provider-engine/zero.js') -const SubproviderFromProvider = require('web3-provider-engine/subproviders/web3.js') +const SubproviderFromProvider = require('web3-provider-engine/subproviders/provider.js') const createInfuraProvider = require('eth-json-rpc-infura/src/createProvider') const ObservableStore = require('obs-store') const ComposedStore = require('obs-store/lib/composed') const extend = require('xtend') const EthQuery = require('eth-query') -const createEventEmitterProxy = require('../lib/events-proxy.js') -const networkConfig = require('../config.js') +const createEventEmitterProxy = require('../../lib/events-proxy.js') const log = require('loglevel') -const { OLD_UI_NETWORK_TYPE, DEFAULT_RPC } = networkConfig.enums -const INFURA_PROVIDER_TYPES = ['ropsten', 'rinkeby', 'kovan', 'mainnet'] +const { + ROPSTEN, + RINKEBY, + KOVAN, + MAINNET, + OLD_UI_NETWORK_TYPE, + DEFAULT_NETWORK, +} = require('./enums') +const { getNetworkEndpoints } = require('./util') +const INFURA_PROVIDER_TYPES = [ROPSTEN, RINKEBY, KOVAN, MAINNET] module.exports = class NetworkController extends EventEmitter { @@ -19,8 +26,8 @@ module.exports = class NetworkController extends EventEmitter { super() this._networkEndpointVersion = OLD_UI_NETWORK_TYPE - this._networkEndpoints = this.getNetworkEndpoints(OLD_UI_NETWORK_TYPE) - this._defaultRpc = this._networkEndpoints[DEFAULT_RPC] + this._networkEndpoints = getNetworkEndpoints(OLD_UI_NETWORK_TYPE) + this._defaultRpc = this._networkEndpoints[DEFAULT_NETWORK] config.provider.rpcTarget = this.getRpcAddressForType(config.provider.type, config.provider) this.networkStore = new ObservableStore('loading') @@ -37,17 +44,13 @@ module.exports = class NetworkController extends EventEmitter { } this._networkEndpointVersion = version - this._networkEndpoints = this.getNetworkEndpoints(version) - this._defaultRpc = this._networkEndpoints[DEFAULT_RPC] + this._networkEndpoints = getNetworkEndpoints(version) + this._defaultRpc = this._networkEndpoints[DEFAULT_NETWORK] const { type } = this.getProviderConfig() return this.setProviderType(type, true) } - getNetworkEndpoints (version = OLD_UI_NETWORK_TYPE) { - return networkConfig[version] - } - initializeProvider (_providerParams) { this._baseProviderParams = _providerParams const { type, rpcTarget } = this.providerStore.getState() diff --git a/app/scripts/controllers/network/util.js b/app/scripts/controllers/network/util.js new file mode 100644 index 000000000..4f38ccda4 --- /dev/null +++ b/app/scripts/controllers/network/util.js @@ -0,0 +1,65 @@ +const { + ROPSTEN, + RINKEBY, + KOVAN, + MAINNET, + LOCALHOST, + ROPSTEN_CODE, + RINKEYBY_CODE, + KOVAN_CODE, + ROPSTEN_DISPLAY_NAME, + RINKEBY_DISPLAY_NAME, + KOVAN_DISPLAY_NAME, + MAINNET_DISPLAY_NAME, + MAINNET_RPC_URL, + ROPSTEN_RPC_URL, + KOVAN_RPC_URL, + RINKEBY_RPC_URL, + LOCALHOST_RPC_URL, + MAINNET_RPC_URL_BETA, + ROPSTEN_RPC_URL_BETA, + KOVAN_RPC_URL_BETA, + RINKEBY_RPC_URL_BETA, + OLD_UI_NETWORK_TYPE, + BETA_UI_NETWORK_TYPE, +} = require('./enums') + +const networkToNameMap = { + [ROPSTEN]: ROPSTEN_DISPLAY_NAME, + [RINKEBY]: RINKEBY_DISPLAY_NAME, + [KOVAN]: KOVAN_DISPLAY_NAME, + [MAINNET]: MAINNET_DISPLAY_NAME, + [ROPSTEN_CODE]: ROPSTEN_DISPLAY_NAME, + [RINKEYBY_CODE]: RINKEBY_DISPLAY_NAME, + [KOVAN_CODE]: KOVAN_DISPLAY_NAME, +} + +const networkEndpointsMap = { + [OLD_UI_NETWORK_TYPE]: { + [LOCALHOST]: LOCALHOST_RPC_URL, + [MAINNET]: MAINNET_RPC_URL, + [ROPSTEN]: ROPSTEN_RPC_URL, + [KOVAN]: KOVAN_RPC_URL, + [RINKEBY]: RINKEBY_RPC_URL, + }, + [BETA_UI_NETWORK_TYPE]: { + [LOCALHOST]: LOCALHOST_RPC_URL, + [MAINNET]: MAINNET_RPC_URL_BETA, + [ROPSTEN]: ROPSTEN_RPC_URL_BETA, + [KOVAN]: KOVAN_RPC_URL_BETA, + [RINKEBY]: RINKEBY_RPC_URL_BETA, + }, +} + +const getNetworkDisplayName = key => networkToNameMap[key] + +const getNetworkEndpoints = (networkType = OLD_UI_NETWORK_TYPE) => { + return { + ...networkEndpointsMap[networkType], + } +} + +module.exports = { + getNetworkDisplayName, + getNetworkEndpoints, +} diff --git a/app/scripts/controllers/preferences.js b/app/scripts/controllers/preferences.js index b4819d951..1d3308d36 100644 --- a/app/scripts/controllers/preferences.js +++ b/app/scripts/controllers/preferences.js @@ -4,6 +4,21 @@ const extend = require('xtend') class PreferencesController { + /** + * + * @typedef {Object} PreferencesController + * @param {object} opts Overrides the defaults for the initial state of this.store + * @property {object} store The stored object containing a users preferences, stored in local storage + * @property {array} store.frequentRpcList A list of custom rpcs to provide the user + * @property {string} store.currentAccountTab Indicates the selected tab in the ui + * @property {array} store.tokens The tokens the user wants display in their token lists + * @property {boolean} store.useBlockie The users preference for blockie identicons within the UI + * @property {object} store.featureFlags A key-boolean map, where keys refer to features and booleans to whether the + * user wishes to see that feature + * @property {string} store.currentLocale The preferred language locale key + * @property {string} store.selectedAddress A hex string that matches the currently selected address in the app + * + */ constructor (opts = {}) { const initState = extend({ frequentRpcList: [], @@ -17,18 +32,43 @@ class PreferencesController { } // PUBLIC METHODS + /** + * Setter for the `useBlockie` property + * + * @param {boolean} val Whether or not the user prefers blockie indicators + * + */ setUseBlockie (val) { this.store.updateState({ useBlockie: val }) } + /** + * Getter for the `useBlockie` property + * + * @returns {boolean} this.store.useBlockie + * + */ getUseBlockie () { return this.store.getState().useBlockie } + /** + * Setter for the `currentLocale` property + * + * @param {string} key he preferred language locale key + * + */ setCurrentLocale (key) { this.store.updateState({ currentLocale: key }) } + /** + * Setter for the `selectedAddress` property + * + * @param {string} _address A new hex address for an account + * @returns {Promise<void>} Promise resolves with undefined + * + */ setSelectedAddress (_address) { return new Promise((resolve, reject) => { const address = normalizeAddress(_address) @@ -37,10 +77,37 @@ class PreferencesController { }) } + /** + * Getter for the `selectedAddress` property + * + * @returns {string} The hex address for the currently selected account + * + */ getSelectedAddress () { return this.store.getState().selectedAddress } + /** + * Contains data about tokens users add to their account. + * @typedef {Object} AddedToken + * @property {string} address - The hex address for the token contract. Will be all lower cased and hex-prefixed. + * @property {string} symbol - The symbol of the token, usually 3 or 4 capitalized letters + * {@link https://github.com/ethereum/EIPs/blob/master/EIPS/eip-20.md#symbol} + * @property {boolean} decimals - The number of decimals the token uses. + * {@link https://github.com/ethereum/EIPs/blob/master/EIPS/eip-20.md#decimals} + */ + + /** + * Adds a new token to the token array, or updates the token if passed an address that already exists. + * Modifies the existing tokens array from the store. All objects in the tokens array array AddedToken objects. + * @see AddedToken {@link AddedToken} + * + * @param {string} rawAddress Hex address of the token contract. May or may not be a checksum address. + * @param {string} symbol The symbol of the token + * @param {number} decimals The number of decimals the token uses. + * @returns {Promise<array>} Promises the new array of AddedToken objects. + * + */ async addToken (rawAddress, symbol, decimals) { const address = normalizeAddress(rawAddress) const newEntry = { address, symbol, decimals } @@ -62,6 +129,13 @@ class PreferencesController { return Promise.resolve(tokens) } + /** + * Removes a specified token from the tokens array. + * + * @param {string} rawAddress Hex address of the token contract to remove. + * @returns {Promise<array>} The new array of AddedToken objects + * + */ removeToken (rawAddress) { const tokens = this.store.getState().tokens @@ -71,10 +145,23 @@ class PreferencesController { return Promise.resolve(updatedTokens) } + /** + * A getter for the `tokens` property + * + * @returns {array} The current array of AddedToken objects + * + */ getTokens () { return this.store.getState().tokens } + /** + * Gets an updated rpc list from this.addToFrequentRpcList() and sets the `frequentRpcList` to this update list. + * + * @param {string} _url The the new rpc url to add to the updated list + * @returns {Promise<void>} Promise resolves with undefined + * + */ updateFrequentRpcList (_url) { return this.addToFrequentRpcList(_url) .then((rpcList) => { @@ -83,6 +170,13 @@ class PreferencesController { }) } + /** + * Setter for the `currentAccountTab` property + * + * @param {string} currentAccountTab Specifies the new tab to be marked as current + * @returns {Promise<void>} Promise resolves with undefined + * + */ setCurrentAccountTab (currentAccountTab) { return new Promise((resolve, reject) => { this.store.updateState({ currentAccountTab }) @@ -90,6 +184,15 @@ class PreferencesController { }) } + /** + * Returns an updated rpcList based on the passed url and the current list. + * The returned list will have a max length of 2. If the _url currently exists it the list, it will be moved to the + * end of the list. The current list is modified and returned as a promise. + * + * @param {string} _url The rpc url to add to the frequentRpcList. + * @returns {Promise<array>} The updated frequentRpcList. + * + */ addToFrequentRpcList (_url) { const rpcList = this.getFrequentRpcList() const index = rpcList.findIndex((element) => { return element === _url }) @@ -105,10 +208,24 @@ class PreferencesController { return Promise.resolve(rpcList) } + /** + * Getter for the `frequentRpcList` property. + * + * @returns {array<string>} An array of one or two rpc urls. + * + */ getFrequentRpcList () { return this.store.getState().frequentRpcList } + /** + * Updates the `featureFlags` property, which is an object. One property within that object will be set to a boolean. + * + * @param {string} feature A key that corresponds to a UI feature. + * @param {boolean} activated Indicates whether or not the UI feature should be displayed + * @returns {Promise<object>} Promises a new object; the updated featureFlags object. + * + */ setFeatureFlag (feature, activated) { const currentFeatureFlags = this.store.getState().featureFlags const updatedFeatureFlags = { @@ -121,6 +238,13 @@ class PreferencesController { return Promise.resolve(updatedFeatureFlags) } + /** + * A getter for the `featureFlags` property + * + * @returns {object} A key-boolean map, where keys refer to features and booleans to whether the + * user wishes to see that feature + * + */ getFeatureFlags () { return this.store.getState().featureFlags } diff --git a/app/scripts/controllers/recent-blocks.js b/app/scripts/controllers/recent-blocks.js index 0c1ee4e38..1377c1ba9 100644 --- a/app/scripts/controllers/recent-blocks.js +++ b/app/scripts/controllers/recent-blocks.js @@ -6,6 +6,23 @@ const log = require('loglevel') class RecentBlocksController { + /** + * Controller responsible for storing, updating and managing the recent history of blocks. Blocks are back filled + * upon the controller's construction and then the list is updated when the given block tracker gets a 'block' event + * (indicating that there is a new block to process). + * + * @typedef {Object} RecentBlocksController + * @param {object} opts Contains objects necessary for tracking blocks and querying the blockchain + * @param {BlockTracker} opts.blockTracker Contains objects necessary for tracking blocks and querying the blockchain + * @param {BlockTracker} opts.provider The provider used to create a new EthQuery instance. + * @property {BlockTracker} blockTracker Points to the passed BlockTracker. On RecentBlocksController construction, + * listens for 'block' events so that new blocks can be processed and added to storage. + * @property {EthQuery} ethQuery Points to the EthQuery instance created with the passed provider + * @property {number} historyLength The maximum length of blocks to track + * @property {object} store Stores the recentBlocks + * @property {array} store.recentBlocks Contains all recent blocks, up to a total that is equal to this.historyLength + * + */ constructor (opts = {}) { const { blockTracker, provider } = opts this.blockTracker = blockTracker @@ -21,12 +38,23 @@ class RecentBlocksController { this.backfill() } + /** + * Sets store.recentBlocks to an empty array + * + */ resetState () { this.store.updateState({ recentBlocks: [], }) } + /** + * Receives a new block and modifies it with this.mapTransactionsToPrices. Then adds that block to the recentBlocks + * array in storage. If the recentBlocks array contains the maximum number of blocks, the oldest block is removed. + * + * @param {object} newBlock The new block to modify and add to the recentBlocks array + * + */ processBlock (newBlock) { const block = this.mapTransactionsToPrices(newBlock) @@ -40,6 +68,15 @@ class RecentBlocksController { this.store.updateState(state) } + /** + * Receives a new block and modifies it with this.mapTransactionsToPrices. Adds that block to the recentBlocks + * array in storage, but only if the recentBlocks array contains fewer than the maximum permitted. + * + * Unlike this.processBlock, backfillBlock adds the modified new block to the beginning of the recent block array. + * + * @param {object} newBlock The new block to modify and add to the beginning of the recentBlocks array + * + */ backfillBlock (newBlock) { const block = this.mapTransactionsToPrices(newBlock) @@ -52,6 +89,14 @@ class RecentBlocksController { this.store.updateState(state) } + /** + * Receives a block and gets the gasPrice of each of its transactions. These gas prices are added to the block at a + * new property, and the block's transactions are removed. + * + * @param {object} newBlock The block to modify. It's transaction array will be replaced by a gasPrices array. + * @returns {object} The modified block. + * + */ mapTransactionsToPrices (newBlock) { const block = extend(newBlock, { gasPrices: newBlock.transactions.map((tx) => { @@ -62,6 +107,16 @@ class RecentBlocksController { return block } + /** + * On this.blockTracker's first 'block' event after this RecentBlocksController's instantiation, the store.recentBlocks + * array is populated with this.historyLength number of blocks. The block number of the this.blockTracker's first + * 'block' event is used to iteratively generate all the numbers of the previous blocks, which are obtained by querying + * the blockchain. These blocks are backfilled so that the recentBlocks array is ordered from oldest to newest. + * + * Each iteration over the block numbers is delayed by 100 milliseconds. + * + * @returns {Promise<void>} Promises undefined + */ async backfill() { this.blockTracker.once('block', async (block) => { let blockNum = block.number @@ -90,12 +145,25 @@ class RecentBlocksController { }) } + /** + * A helper for this.backfill. Provides an easy way to ensure a 100 millisecond delay using await + * + * @returns {Promise<void>} Promises undefined + * + */ async wait () { return new Promise((resolve) => { setTimeout(resolve, 100) }) } + /** + * Uses EthQuery to get a block that has a given block number. + * + * @param {number} number The number of the block to get + * @returns {Promise<object>} Promises A block with the passed number + * + */ async getBlockByNumber (number) { const bn = new BN(number) return new Promise((resolve, reject) => { diff --git a/app/scripts/controllers/shapeshift.js b/app/scripts/controllers/shapeshift.js index b38b3812d..b2a1462c2 100644 --- a/app/scripts/controllers/shapeshift.js +++ b/app/scripts/controllers/shapeshift.js @@ -7,6 +7,17 @@ const POLLING_INTERVAL = 3000 class ShapeshiftController { + /** + * Controller responsible for managing the list of shapeshift transactions. On construction, it initiates a poll + * that queries a shapeshift.io API for updates to any pending shapeshift transactions + * + * @typedef {Object} ShapeshiftController + * @param {object} opts Overrides the defaults for the initial state of this.store + * @property {array} opts.initState initializes the the state of the ShapeshiftController. Can contain an + * shapeShiftTxList array. + * @property {array} shapeShiftTxList An array of ShapeShiftTx objects + * + */ constructor (opts = {}) { const initState = extend({ shapeShiftTxList: [], @@ -15,21 +26,54 @@ class ShapeshiftController { this.pollForUpdates() } + /** + * Represents, and contains data about, a single shapeshift transaction. + * @typedef {Object} ShapeShiftTx + * @property {string} depositAddress - An address at which to send a crypto deposit, so that eth can be sent to the + * user's Metamask account + * @property {string} depositType - An abbreviation of the type of crypto currency to be deposited. + * @property {string} key - The 'shapeshift' key differentiates this from other types of txs in Metamask + * @property {number} time - The time at which the tx was created + * @property {object} response - Initiated as an empty object, which will be replaced by a Response object. @see {@link + * https://developer.mozilla.org/en-US/docs/Web/API/Response} + */ + // // PUBLIC METHODS // + /** + * A getter for the shapeShiftTxList property + * + * @returns {array<ShapeShiftTx>} + * + */ getShapeShiftTxList () { const shapeShiftTxList = this.store.getState().shapeShiftTxList return shapeShiftTxList } + /** + * A getter for all ShapeShiftTx in the shapeShiftTxList that have not successfully completed a deposit. + * + * @returns {array<ShapeShiftTx>} Only includes ShapeShiftTx which has a response property with a status !== complete + * + */ getPendingTxs () { const txs = this.getShapeShiftTxList() const pending = txs.filter(tx => tx.response && tx.response.status !== 'complete') return pending } + /** + * A poll that exists as long as there are pending transactions. Each call attempts to update the data of any + * pendingTxs, and then calls itself again. If there are no pending txs, the recursive call is not made and + * the polling stops. + * + * this.updateTx is used to attempt the update to the pendingTxs in the ShapeShiftTxList, and that updated data + * is saved with saveTx. + * + */ pollForUpdates () { const pendingTxs = this.getPendingTxs() @@ -46,6 +90,15 @@ class ShapeshiftController { }) } + /** + * Attempts to update a ShapeShiftTx with data from a shapeshift.io API. Both the response and time properties + * can be updated. The response property is updated with every call, but the time property is only updated when + * the response status updates to 'complete'. This will occur once the user makes a deposit as the ShapeShiftTx + * depositAddress + * + * @param {ShapeShiftTx} tx The tx to update + * + */ async updateTx (tx) { try { const url = `https://shapeshift.io/txStat/${tx.depositAddress}` @@ -61,6 +114,13 @@ class ShapeshiftController { } } + /** + * Saves an updated to a ShapeShiftTx in the shapeShiftTxList. If the passed ShapeShiftTx is not in the + * shapeShiftTxList, nothing happens. + * + * @param {ShapeShiftTx} tx The updated tx to save, if it exists in the current shapeShiftTxList + * + */ saveTx (tx) { const { shapeShiftTxList } = this.store.getState() const index = shapeShiftTxList.indexOf(tx) @@ -70,6 +130,12 @@ class ShapeshiftController { } } + /** + * Removes a ShapeShiftTx from the shapeShiftTxList + * + * @param {ShapeShiftTx} tx The tx to remove + * + */ removeShapeShiftTx (tx) { const { shapeShiftTxList } = this.store.getState() const index = shapeShiftTxList.indexOf(index) @@ -79,6 +145,14 @@ class ShapeshiftController { this.updateState({ shapeShiftTxList }) } + /** + * Creates a new ShapeShiftTx, adds it to the shapeShiftTxList, and initiates a new poll for updates of pending txs + * + * @param {string} depositAddress - An address at which to send a crypto deposit, so that eth can be sent to the + * user's Metamask account + * @param {string} depositType - An abbreviation of the type of crypto currency to be deposited. + * + */ createShapeShiftTx (depositAddress, depositType) { const state = this.store.getState() let { shapeShiftTxList } = state diff --git a/app/scripts/controllers/token-rates.js b/app/scripts/controllers/token-rates.js index 22e3e8154..87d716aa6 100644 --- a/app/scripts/controllers/token-rates.js +++ b/app/scripts/controllers/token-rates.js @@ -1,4 +1,5 @@ const ObservableStore = require('obs-store') +const { warn } = require('loglevel') // By default, poll every 3 minutes const DEFAULT_INTERVAL = 180 * 1000 @@ -39,14 +40,17 @@ class TokenRatesController { */ async fetchExchangeRate (address) { try { - const response = await fetch(`https://exchanges.balanc3.net/prices?from=${address}&to=ETH&autoConversion=false&summaryOnly=true`) + const response = await fetch(`https://metamask.balanc3.net/prices?from=${address}&to=ETH&autoConversion=false&summaryOnly=true`) const json = await response.json() return json && json.length ? json[0].averagePrice : 0 - } catch (error) { } + } catch (error) { + warn(`MetaMask - TokenRatesController exchange rate fetch failed for ${address}.`, error) + return 0 + } } /** - * @type {Number} - Interval used to poll for exchange rates + * @type {Number} */ set interval (interval) { this._handle && clearInterval(this._handle) @@ -55,7 +59,7 @@ class TokenRatesController { } /** - * @type {Object} - Preferences controller instance + * @type {Object} */ set preferences (preferences) { this._preferences && this._preferences.unsubscribe() @@ -66,7 +70,7 @@ class TokenRatesController { } /** - * @type {Array} - Array of token objects with contract addresses + * @type {Array} */ set tokens (tokens) { this._tokens = tokens diff --git a/app/scripts/controllers/transactions/README.md b/app/scripts/controllers/transactions/README.md new file mode 100644 index 000000000..b414762dc --- /dev/null +++ b/app/scripts/controllers/transactions/README.md @@ -0,0 +1,92 @@ +# Transaction Controller + +Transaction Controller is an aggregate of sub-controllers and trackers +exposed to the MetaMask controller. + +- txStateManager + responsible for the state of a transaction and + storing the transaction +- pendingTxTracker + watching blocks for transactions to be include + and emitting confirmed events +- txGasUtil + gas calculations and safety buffering +- nonceTracker + calculating nonces + +## Flow diagram of processing a transaction + +![transaction-flow](../../../../docs/transaction-flow.png) + +## txMeta's & txParams + +A txMeta is the "meta" object it has all the random bits of info we need about a transaction on it. txParams are sacred every thing on txParams gets signed so it must +be a valid key and be hex prefixed except for the network number. Extra stuff must go on the txMeta! + +Here is a txMeta too look at: + +```js +txMeta = { + "id": 2828415030114568, // unique id for this txMeta used for look ups + "time": 1524094064821, // time of creation + "status": "confirmed", + "metamaskNetworkId": "1524091532133", //the network id for the transaction + "loadingDefaults": false, // used to tell the ui when we are done calculatyig gass defaults + "txParams": { // the txParams object + "from": "0x8acce2391c0d510a6c5e5d8f819a678f79b7e675", + "to": "0x8acce2391c0d510a6c5e5d8f819a678f79b7e675", + "value": "0x0", + "gasPrice": "0x3b9aca00", + "gas": "0x7b0c", + "nonce": "0x0" + }, + "history": [{ //debug + "id": 2828415030114568, + "time": 1524094064821, + "status": "unapproved", + "metamaskNetworkId": "1524091532133", + "loadingDefaults": true, + "txParams": { + "from": "0x8acce2391c0d510a6c5e5d8f819a678f79b7e675", + "to": "0x8acce2391c0d510a6c5e5d8f819a678f79b7e675", + "value": "0x0" + } + }, + [ + { + "op": "add", + "path": "/txParams/gasPrice", + "value": "0x3b9aca00" + }, + ...], // I've removed most of history for this + "gasPriceSpecified": false, //whether or not the user/dapp has specified gasPrice + "gasLimitSpecified": false, //whether or not the user/dapp has specified gas + "estimatedGas": "5208", + "origin": "MetaMask", //debug + "nonceDetails": { + "params": { + "highestLocallyConfirmed": 0, + "highestSuggested": 0, + "nextNetworkNonce": 0 + }, + "local": { + "name": "local", + "nonce": 0, + "details": { + "startPoint": 0, + "highest": 0 + } + }, + "network": { + "name": "network", + "nonce": 0, + "details": { + "baseCount": 0 + } + } + }, + "rawTx": "0xf86980843b9aca00827b0c948acce2391c0d510a6c5e5d8f819a678f79b7e67580808602c5b5de66eea05c01a320b96ac730cb210ca56d2cb71fa360e1fc2c21fa5cf333687d18eb323fa02ed05987a6e5fd0f2459fcff80710b76b83b296454ad9a37594a0ccb4643ea90", // used for rebroadcast + "hash": "0xa45ba834b97c15e6ff4ed09badd04ecd5ce884b455eb60192cdc73bcc583972a", + "submittedTime": 1524094077902 // time of the attempt to submit the raw tx to the network, used in the ui to show the retry button +} +``` diff --git a/app/scripts/controllers/transactions.js b/app/scripts/controllers/transactions/index.js index c8211ebd7..541f1db73 100644 --- a/app/scripts/controllers/transactions.js +++ b/app/scripts/controllers/transactions/index.js @@ -3,28 +3,42 @@ const ObservableStore = require('obs-store') const ethUtil = require('ethereumjs-util') const Transaction = require('ethereumjs-tx') const EthQuery = require('ethjs-query') -const TransactionStateManager = require('../lib/tx-state-manager') -const TxGasUtil = require('../lib/tx-gas-utils') -const PendingTransactionTracker = require('../lib/pending-tx-tracker') -const NonceTracker = require('../lib/nonce-tracker') +const TransactionStateManager = require('./tx-state-manager') +const TxGasUtil = require('./tx-gas-utils') +const PendingTransactionTracker = require('./pending-tx-tracker') +const NonceTracker = require('./nonce-tracker') +const txUtils = require('./lib/util') const log = require('loglevel') -/* +/** Transaction Controller is an aggregate of sub-controllers and trackers composing them in a way to be exposed to the metamask controller - - txStateManager + <br>- txStateManager responsible for the state of a transaction and storing the transaction - - pendingTxTracker + <br>- pendingTxTracker watching blocks for transactions to be include and emitting confirmed events - - txGasUtil + <br>- txGasUtil gas calculations and safety buffering - - nonceTracker + <br>- nonceTracker calculating nonces + + + @class + @param {object} - opts + @param {object} opts.initState - initial transaction list default is an empty array + @param {Object} opts.networkStore - an observable store for network number + @param {Object} opts.blockTracker - An instance of eth-blocktracker + @param {Object} opts.provider - A network provider. + @param {Function} opts.signTransaction - function the signs an ethereumjs-tx + @param {Function} [opts.getGasPrice] - optional gas price calculator + @param {Function} opts.signTransaction - ethTx signer that returns a rawTx + @param {Number} [opts.txHistoryLimit] - number *optional* for limiting how many transactions are in state + @param {Object} opts.preferencesStore */ -module.exports = class TransactionController extends EventEmitter { +class TransactionController extends EventEmitter { constructor (opts) { super() this.networkStore = opts.networkStore || new ObservableStore({}) @@ -38,45 +52,19 @@ module.exports = class TransactionController extends EventEmitter { this.query = new EthQuery(this.provider) this.txGasUtil = new TxGasUtil(this.provider) + this._mapMethods() this.txStateManager = new TransactionStateManager({ initState: opts.initState, txHistoryLimit: opts.txHistoryLimit, getNetwork: this.getNetwork.bind(this), }) - - this.txStateManager.getFilteredTxList({ - status: 'unapproved', - loadingDefaults: true, - }).forEach((tx) => { - this.addTxDefaults(tx) - .then((txMeta) => { - txMeta.loadingDefaults = false - this.txStateManager.updateTx(txMeta, 'transactions: gas estimation for tx on boot') - }).catch((error) => { - this.txStateManager.setTxStatusFailed(tx.id, error) - }) - }) - - this.txStateManager.getFilteredTxList({ - status: 'approved', - }).forEach((txMeta) => { - const txSignError = new Error('Transaction found as "approved" during boot - possibly stuck during signing') - this.txStateManager.setTxStatusFailed(txMeta.id, txSignError) - }) - + this._onBootCleanUp() this.store = this.txStateManager.store - this.txStateManager.on('tx:status-update', this.emit.bind(this, 'tx:status-update')) this.nonceTracker = new NonceTracker({ provider: this.provider, getPendingTransactions: this.txStateManager.getPendingTransactions.bind(this.txStateManager), - getConfirmedTransactions: (address) => { - return this.txStateManager.getFilteredTxList({ - from: address, - status: 'confirmed', - err: undefined, - }) - }, + getConfirmedTransactions: this.txStateManager.getConfirmedTransactions.bind(this.txStateManager), }) this.pendingTxTracker = new PendingTransactionTracker({ @@ -88,60 +76,14 @@ module.exports = class TransactionController extends EventEmitter { }) this.txStateManager.store.subscribe(() => this.emit('update:badge')) - - this.pendingTxTracker.on('tx:warning', (txMeta) => { - this.txStateManager.updateTx(txMeta, 'transactions/pending-tx-tracker#event: tx:warning') - }) - this.pendingTxTracker.on('tx:confirmed', (txId) => this._markNonceDuplicatesDropped(txId)) - this.pendingTxTracker.on('tx:failed', this.txStateManager.setTxStatusFailed.bind(this.txStateManager)) - this.pendingTxTracker.on('tx:block-update', (txMeta, latestBlockNumber) => { - if (!txMeta.firstRetryBlockNumber) { - txMeta.firstRetryBlockNumber = latestBlockNumber - this.txStateManager.updateTx(txMeta, 'transactions/pending-tx-tracker#event: tx:block-update') - } - }) - this.pendingTxTracker.on('tx:retry', (txMeta) => { - if (!('retryCount' in txMeta)) txMeta.retryCount = 0 - txMeta.retryCount++ - this.txStateManager.updateTx(txMeta, 'transactions/pending-tx-tracker#event: tx:retry') - }) - - this.blockTracker.on('block', this.pendingTxTracker.checkForTxInBlock.bind(this.pendingTxTracker)) - // this is a little messy but until ethstore has been either - // removed or redone this is to guard against the race condition - this.blockTracker.on('latest', this.pendingTxTracker.resubmitPendingTxs.bind(this.pendingTxTracker)) - this.blockTracker.on('sync', this.pendingTxTracker.queryPendingTxs.bind(this.pendingTxTracker)) + this._setupListners() // memstore is computed from a few different stores this._updateMemstore() this.txStateManager.store.subscribe(() => this._updateMemstore()) this.networkStore.subscribe(() => this._updateMemstore()) this.preferencesStore.subscribe(() => this._updateMemstore()) } - - getState () { - return this.memStore.getState() - } - - getNetwork () { - return this.networkStore.getState() - } - - getSelectedAddress () { - return this.preferencesStore.getState().selectedAddress - } - - getUnapprovedTxCount () { - return Object.keys(this.txStateManager.getUnapprovedTxList()).length - } - - getPendingTxCount (account) { - return this.txStateManager.getPendingTransactions(account).length - } - - getFilteredTxList (opts) { - return this.txStateManager.getFilteredTxList(opts) - } - + /** @returns {number} the chainId*/ getChainId () { const networkState = this.networkStore.getState() const getChainId = parseInt(networkState) @@ -152,16 +94,30 @@ module.exports = class TransactionController extends EventEmitter { } } - wipeTransactions (address) { - this.txStateManager.wipeTransactions(address) - } - - // Adds a tx to the txlist +/** + Adds a tx to the txlist + @emits ${txMeta.id}:unapproved +*/ addTx (txMeta) { this.txStateManager.addTx(txMeta) this.emit(`${txMeta.id}:unapproved`, txMeta) } + /** + Wipes the transactions for a given account + @param {string} address - hex string of the from address for txs being removed + */ + wipeTransactions (address) { + this.txStateManager.wipeTransactions(address) + } + + /** + add a new unapproved transaction to the pipeline + + @returns {Promise<string>} the hash of the transaction after being submitted to the network + @param txParams {object} - txParams for the transaction + @param opts {object} - with the key origin to put the origin on the txMeta + */ async newUnapprovedTransaction (txParams, opts = {}) { log.debug(`MetaMaskController newUnapprovedTransaction ${JSON.stringify(txParams)}`) const initialTxMeta = await this.addUnapprovedTransaction(txParams) @@ -184,17 +140,24 @@ module.exports = class TransactionController extends EventEmitter { }) } + /** + Validates and generates a txMeta with defaults and puts it in txStateManager + store + + @returns {txMeta} + */ + async addUnapprovedTransaction (txParams) { // validate - const normalizedTxParams = this._normalizeTxParams(txParams) - this._validateTxParams(normalizedTxParams) + const normalizedTxParams = txUtils.normalizeTxParams(txParams) + txUtils.validateTxParams(normalizedTxParams) // construct txMeta let txMeta = this.txStateManager.generateTxMeta({ txParams: normalizedTxParams }) this.addTx(txMeta) this.emit('newUnapprovedTx', txMeta) // add default tx params try { - txMeta = await this.addTxDefaults(txMeta) + txMeta = await this.addTxGasDefaults(txMeta) } catch (error) { console.log(error) this.txStateManager.setTxStatusFailed(txMeta.id, error) @@ -206,21 +169,33 @@ module.exports = class TransactionController extends EventEmitter { return txMeta } - - async addTxDefaults (txMeta) { +/** + adds the tx gas defaults: gas && gasPrice + @param txMeta {Object} - the txMeta object + @returns {Promise<object>} resolves with txMeta +*/ + async addTxGasDefaults (txMeta) { const txParams = txMeta.txParams // ensure value + txParams.value = txParams.value ? ethUtil.addHexPrefix(txParams.value) : '0x0' txMeta.gasPriceSpecified = Boolean(txParams.gasPrice) let gasPrice = txParams.gasPrice if (!gasPrice) { gasPrice = this.getGasPrice ? this.getGasPrice() : await this.query.gasPrice() } txParams.gasPrice = ethUtil.addHexPrefix(gasPrice.toString(16)) - txParams.value = txParams.value || '0x0' // set gasLimit return await this.txGasUtil.analyzeGasUsage(txMeta) } + /** + Creates a new txMeta with the same txParams as the original + to allow the user to resign the transaction with a higher gas values + @param originalTxId {number} - the id of the txMeta that + you want to attempt to retry + @return {txMeta} + */ + async retryTransaction (originalTxId) { const originalTxMeta = this.txStateManager.getTx(originalTxId) const lastGasPrice = originalTxMeta.txParams.gasPrice @@ -234,15 +209,31 @@ module.exports = class TransactionController extends EventEmitter { return txMeta } + /** + updates the txMeta in the txStateManager + @param txMeta {Object} - the updated txMeta + */ async updateTransaction (txMeta) { this.txStateManager.updateTx(txMeta, 'confTx: user updated transaction') } + /** + updates and approves the transaction + @param txMeta {Object} + */ async updateAndApproveTransaction (txMeta) { this.txStateManager.updateTx(txMeta, 'confTx: user approved transaction') await this.approveTransaction(txMeta.id) } + /** + sets the tx status to approved + auto fills the nonce + signs the transaction + publishes the transaction + if any of these steps fails the tx status will be set to failed + @param txId {number} - the tx's Id + */ async approveTransaction (txId) { let nonceLock try { @@ -274,7 +265,11 @@ module.exports = class TransactionController extends EventEmitter { throw err } } - + /** + adds the chain id and signs the transaction and set the status to signed + @param txId {number} - the tx's Id + @returns - rawTx {string} + */ async signTransaction (txId) { const txMeta = this.txStateManager.getTx(txId) // add network/chain id @@ -290,6 +285,12 @@ module.exports = class TransactionController extends EventEmitter { return rawTx } + /** + publishes the raw tx and sets the txMeta to submitted + @param txId {number} - the tx's Id + @param rawTx {string} - the hex string of the serialized signed transaction + @returns {Promise<void>} + */ async publishTransaction (txId, rawTx) { const txMeta = this.txStateManager.getTx(txId) txMeta.rawTx = rawTx @@ -299,11 +300,20 @@ module.exports = class TransactionController extends EventEmitter { this.txStateManager.setTxStatusSubmitted(txId) } + /** + Convenience method for the ui thats sets the transaction to rejected + @param txId {number} - the tx's Id + @returns {Promise<void>} + */ async cancelTransaction (txId) { this.txStateManager.setTxStatusRejected(txId) } - // receives a txHash records the tx as signed + /** + Sets the txHas on the txMeta + @param txId {number} - the tx's Id + @param txHash {string} - the hash for the txMeta + */ setTxHash (txId, txHash) { // Add the tx hash to the persisted meta-tx object const txMeta = this.txStateManager.getTx(txId) @@ -314,63 +324,92 @@ module.exports = class TransactionController extends EventEmitter { // // PRIVATE METHODS // + /** maps methods for convenience*/ + _mapMethods () { + /** @returns the state in transaction controller */ + this.getState = () => this.memStore.getState() + /** @returns the network number stored in networkStore */ + this.getNetwork = () => this.networkStore.getState() + /** @returns the user selected address */ + this.getSelectedAddress = () => this.preferencesStore.getState().selectedAddress + /** Returns an array of transactions whos status is unapproved */ + this.getUnapprovedTxCount = () => Object.keys(this.txStateManager.getUnapprovedTxList()).length + /** + @returns a number that represents how many transactions have the status submitted + @param account {String} - hex prefixed account + */ + this.getPendingTxCount = (account) => this.txStateManager.getPendingTransactions(account).length + /** see txStateManager */ + this.getFilteredTxList = (opts) => this.txStateManager.getFilteredTxList(opts) + } - _normalizeTxParams (txParams) { - // functions that handle normalizing of that key in txParams - const whiteList = { - from: from => ethUtil.addHexPrefix(from).toLowerCase(), - to: to => ethUtil.addHexPrefix(txParams.to).toLowerCase(), - nonce: nonce => ethUtil.addHexPrefix(nonce), - value: value => ethUtil.addHexPrefix(value), - data: data => ethUtil.addHexPrefix(data), - gas: gas => ethUtil.addHexPrefix(gas), - gasPrice: gasPrice => ethUtil.addHexPrefix(gasPrice), - } + /** + If transaction controller was rebooted with transactions that are uncompleted + in steps of the transaction signing or user confirmation process it will either + transition txMetas to a failed state or try to redo those tasks. + */ - // apply only keys in the whiteList - const normalizedTxParams = {} - Object.keys(whiteList).forEach((key) => { - if (txParams[key]) normalizedTxParams[key] = whiteList[key](txParams[key]) + _onBootCleanUp () { + this.txStateManager.getFilteredTxList({ + status: 'unapproved', + loadingDefaults: true, + }).forEach((tx) => { + this.addTxGasDefaults(tx) + .then((txMeta) => { + txMeta.loadingDefaults = false + this.txStateManager.updateTx(txMeta, 'transactions: gas estimation for tx on boot') + }).catch((error) => { + this.txStateManager.setTxStatusFailed(tx.id, error) + }) }) - return normalizedTxParams + this.txStateManager.getFilteredTxList({ + status: 'approved', + }).forEach((txMeta) => { + const txSignError = new Error('Transaction found as "approved" during boot - possibly stuck during signing') + this.txStateManager.setTxStatusFailed(txMeta.id, txSignError) + }) } - _validateTxParams (txParams) { - this._validateFrom(txParams) - this._validateRecipient(txParams) - if ('value' in txParams) { - const value = txParams.value.toString() - if (value.includes('-')) { - throw new Error(`Invalid transaction value of ${txParams.value} not a positive number.`) + /** + is called in constructor applies the listeners for pendingTxTracker txStateManager + and blockTracker + */ + _setupListners () { + this.txStateManager.on('tx:status-update', this.emit.bind(this, 'tx:status-update')) + this.pendingTxTracker.on('tx:warning', (txMeta) => { + this.txStateManager.updateTx(txMeta, 'transactions/pending-tx-tracker#event: tx:warning') + }) + this.pendingTxTracker.on('tx:confirmed', (txId) => this.txStateManager.setTxStatusConfirmed(txId)) + this.pendingTxTracker.on('tx:confirmed', (txId) => this._markNonceDuplicatesDropped(txId)) + this.pendingTxTracker.on('tx:failed', this.txStateManager.setTxStatusFailed.bind(this.txStateManager)) + this.pendingTxTracker.on('tx:block-update', (txMeta, latestBlockNumber) => { + if (!txMeta.firstRetryBlockNumber) { + txMeta.firstRetryBlockNumber = latestBlockNumber + this.txStateManager.updateTx(txMeta, 'transactions/pending-tx-tracker#event: tx:block-update') } + }) + this.pendingTxTracker.on('tx:retry', (txMeta) => { + if (!('retryCount' in txMeta)) txMeta.retryCount = 0 + txMeta.retryCount++ + this.txStateManager.updateTx(txMeta, 'transactions/pending-tx-tracker#event: tx:retry') + }) - if (value.includes('.')) { - throw new Error(`Invalid transaction value of ${txParams.value} number must be in wei`) - } - } - } + this.blockTracker.on('block', this.pendingTxTracker.checkForTxInBlock.bind(this.pendingTxTracker)) + // this is a little messy but until ethstore has been either + // removed or redone this is to guard against the race condition + this.blockTracker.on('latest', this.pendingTxTracker.resubmitPendingTxs.bind(this.pendingTxTracker)) + this.blockTracker.on('sync', this.pendingTxTracker.queryPendingTxs.bind(this.pendingTxTracker)) - _validateFrom (txParams) { - if ( !(typeof txParams.from === 'string') ) throw new Error(`Invalid from address ${txParams.from} not a string`) - if (!ethUtil.isValidAddress(txParams.from)) throw new Error('Invalid from address') } - _validateRecipient (txParams) { - if (txParams.to === '0x' || txParams.to === null ) { - if (txParams.data) { - delete txParams.to - } else { - throw new Error('Invalid recipient address') - } - } else if ( txParams.to !== undefined && !ethUtil.isValidAddress(txParams.to) ) { - throw new Error('Invalid recipient address') - } - return txParams - } + /** + Sets other txMeta statuses to dropped if the txMeta that has been confirmed has other transactions + in the list have the same nonce + @param txId {Number} - the txId of the transaction that has been confirmed in a block + */ _markNonceDuplicatesDropped (txId) { - this.txStateManager.setTxStatusConfirmed(txId) // get the confirmed transactions nonce and from address const txMeta = this.txStateManager.getTx(txId) const { nonce, from } = txMeta.txParams @@ -385,6 +424,9 @@ module.exports = class TransactionController extends EventEmitter { }) } + /** + Updates the memStore in transaction controller + */ _updateMemstore () { const unapprovedTxs = this.txStateManager.getUnapprovedTxList() const selectedAddressTxList = this.txStateManager.getFilteredTxList({ @@ -394,3 +436,5 @@ module.exports = class TransactionController extends EventEmitter { this.memStore.updateState({ unapprovedTxs, selectedAddressTxList }) } } + +module.exports = TransactionController diff --git a/app/scripts/lib/tx-state-history-helper.js b/app/scripts/controllers/transactions/lib/tx-state-history-helper.js index 94c7b6792..59a4b562c 100644 --- a/app/scripts/lib/tx-state-history-helper.js +++ b/app/scripts/controllers/transactions/lib/tx-state-history-helper.js @@ -1,6 +1,6 @@ const jsonDiffer = require('fast-json-patch') const clone = require('clone') - +/** @module*/ module.exports = { generateHistoryEntry, replayHistory, @@ -8,7 +8,11 @@ module.exports = { migrateFromSnapshotsToDiffs, } - +/** + converts non-initial history entries into diffs + @param longHistory {array} + @returns {array} +*/ function migrateFromSnapshotsToDiffs (longHistory) { return ( longHistory @@ -20,6 +24,17 @@ function migrateFromSnapshotsToDiffs (longHistory) { ) } +/** + generates an array of history objects sense the previous state. + The object has the keys opp(the operation preformed), + path(the key and if a nested object then each key will be seperated with a `/`) + value + with the first entry having the note + @param previousState {object} - the previous state of the object + @param newState {object} - the update object + @param note {string} - a optional note for the state change + @reurns {array} +*/ function generateHistoryEntry (previousState, newState, note) { const entry = jsonDiffer.compare(previousState, newState) // Add a note to the first op, since it breaks if we append it to the entry @@ -27,11 +42,19 @@ function generateHistoryEntry (previousState, newState, note) { return entry } +/** + Recovers previous txMeta state obj + @return {object} +*/ function replayHistory (_shortHistory) { const shortHistory = clone(_shortHistory) return shortHistory.reduce((val, entry) => jsonDiffer.applyPatch(val, entry).newDocument) } +/** + @param txMeta {Object} + @returns {object} a clone object of the txMeta with out history +*/ function snapshotFromTxMeta (txMeta) { // create txMeta snapshot for history const snapshot = clone(txMeta) diff --git a/app/scripts/controllers/transactions/lib/util.js b/app/scripts/controllers/transactions/lib/util.js new file mode 100644 index 000000000..84f7592a0 --- /dev/null +++ b/app/scripts/controllers/transactions/lib/util.js @@ -0,0 +1,99 @@ +const { + addHexPrefix, + isValidAddress, +} = require('ethereumjs-util') + +/** +@module +*/ +module.exports = { + normalizeTxParams, + validateTxParams, + validateFrom, + validateRecipient, + getFinalStates, +} + + +// functions that handle normalizing of that key in txParams +const normalizers = { + from: from => addHexPrefix(from).toLowerCase(), + to: to => addHexPrefix(to).toLowerCase(), + nonce: nonce => addHexPrefix(nonce), + value: value => addHexPrefix(value), + data: data => addHexPrefix(data), + gas: gas => addHexPrefix(gas), + gasPrice: gasPrice => addHexPrefix(gasPrice), +} + + /** + normalizes txParams + @param txParams {object} + @returns {object} normalized txParams + */ +function normalizeTxParams (txParams) { + // apply only keys in the normalizers + const normalizedTxParams = {} + for (const key in normalizers) { + if (txParams[key]) normalizedTxParams[key] = normalizers[key](txParams[key]) + } + return normalizedTxParams +} + + /** + validates txParams + @param txParams {object} + */ +function validateTxParams (txParams) { + validateFrom(txParams) + validateRecipient(txParams) + if ('value' in txParams) { + const value = txParams.value.toString() + if (value.includes('-')) { + throw new Error(`Invalid transaction value of ${txParams.value} not a positive number.`) + } + + if (value.includes('.')) { + throw new Error(`Invalid transaction value of ${txParams.value} number must be in wei`) + } + } +} + + /** + validates the from field in txParams + @param txParams {object} + */ +function validateFrom (txParams) { + if (!(typeof txParams.from === 'string')) throw new Error(`Invalid from address ${txParams.from} not a string`) + if (!isValidAddress(txParams.from)) throw new Error('Invalid from address') +} + + /** + validates the to field in txParams + @param txParams {object} + */ +function validateRecipient (txParams) { + if (txParams.to === '0x' || txParams.to === null) { + if (txParams.data) { + delete txParams.to + } else { + throw new Error('Invalid recipient address') + } + } else if (txParams.to !== undefined && !isValidAddress(txParams.to)) { + throw new Error('Invalid recipient address') + } + return txParams +} + + /** + @returns an {array} of states that can be considered final + */ +function getFinalStates () { + return [ + 'rejected', // the user has responded no! + 'confirmed', // the tx has been included in a block. + 'failed', // the tx failed for some reason, included on tx data. + 'dropped', // the tx nonce was already used + ] +} + diff --git a/app/scripts/lib/nonce-tracker.js b/app/scripts/controllers/transactions/nonce-tracker.js index 5b1cd7f43..f8cdc5523 100644 --- a/app/scripts/lib/nonce-tracker.js +++ b/app/scripts/controllers/transactions/nonce-tracker.js @@ -1,7 +1,15 @@ const EthQuery = require('ethjs-query') const assert = require('assert') const Mutex = require('await-semaphore').Mutex - +/** + @param opts {Object} + @param {Object} opts.provider a ethereum provider + @param {Function} opts.getPendingTransactions a function that returns an array of txMeta + whosee status is `submitted` + @param {Function} opts.getConfirmedTransactions a function that returns an array of txMeta + whose status is `confirmed` + @class +*/ class NonceTracker { constructor ({ provider, getPendingTransactions, getConfirmedTransactions }) { @@ -12,6 +20,9 @@ class NonceTracker { this.lockMap = {} } + /** + @returns {Promise<Object>} with the key releaseLock (the gloabl mutex) + */ async getGlobalLock () { const globalMutex = this._lookupMutex('global') // await global mutex free @@ -19,8 +30,20 @@ class NonceTracker { return { releaseLock } } - // releaseLock must be called - // releaseLock must be called after adding signed tx to pending transactions (or discarding) + /** + * @typedef NonceDetails + * @property {number} highestLocallyConfirmed - A hex string of the highest nonce on a confirmed transaction. + * @property {number} nextNetworkNonce - The next nonce suggested by the eth_getTransactionCount method. + * @property {number} highetSuggested - The maximum between the other two, the number returned. + */ + + /** + this will return an object with the `nextNonce` `nonceDetails` of type NonceDetails, and the releaseLock + Note: releaseLock must be called after adding a signed tx to pending transactions (or discarding). + + @param address {string} the hex string for the address whose nonce we are calculating + @returns {Promise<NonceDetails>} + */ async getNonceLock (address) { // await global mutex free await this._globalMutexFree() @@ -123,6 +146,17 @@ class NonceTracker { return highestNonce } + /** + @typedef {object} highestContinuousFrom + @property {string} - name the name for how the nonce was calculated based on the data used + @property {number} - nonce the next suggested nonce + @property {object} - details the provided starting nonce that was used (for debugging) + */ + /** + @param txList {array} - list of txMeta's + @param startPoint {number} - the highest known locally confirmed nonce + @returns {highestContinuousFrom} + */ _getHighestContinuousFrom (txList, startPoint) { const nonces = txList.map((txMeta) => { const nonce = txMeta.txParams.nonce @@ -140,6 +174,10 @@ class NonceTracker { // this is a hotfix for the fact that the blockTracker will // change when the network changes + + /** + @returns {Object} the current blockTracker + */ _getBlockTracker () { return this.provider._blockTracker } diff --git a/app/scripts/lib/pending-tx-tracker.js b/app/scripts/controllers/transactions/pending-tx-tracker.js index e8869e6b8..6e2fcb40b 100644 --- a/app/scripts/lib/pending-tx-tracker.js +++ b/app/scripts/controllers/transactions/pending-tx-tracker.js @@ -1,23 +1,24 @@ const EventEmitter = require('events') +const log = require('loglevel') const EthQuery = require('ethjs-query') -/* - - Utility class for tracking the transactions as they - go from a pending state to a confirmed (mined in a block) state +/** + Event emitter utility class for tracking the transactions as they<br> + go from a pending state to a confirmed (mined in a block) state<br> +<br> As well as continues broadcast while in the pending state +<br> +@param config {object} - non optional configuration object consists of: + @param {Object} config.provider - A network provider. + @param {Object} config.nonceTracker see nonce tracker + @param {function} config.getPendingTransactions a function for getting an array of transactions, + @param {function} config.publishTransaction a async function for publishing raw transactions, - ~config is not optional~ - requires a: { - provider: //, - nonceTracker: //see nonce tracker, - getPendingTransactions: //() a function for getting an array of transactions, - publishTransaction: //(rawTx) a async function for publishing raw transactions, - } +@class */ -module.exports = class PendingTransactionTracker extends EventEmitter { +class PendingTransactionTracker extends EventEmitter { constructor (config) { super() this.query = new EthQuery(config.provider) @@ -29,8 +30,13 @@ module.exports = class PendingTransactionTracker extends EventEmitter { this._checkPendingTxs() } - // checks if a signed tx is in a block and - // if included sets the tx status as 'confirmed' + /** + checks if a signed tx is in a block and + if it is included emits tx status as 'confirmed' + @param block {object}, a full block + @emits tx:confirmed + @emits tx:failed + */ checkForTxInBlock (block) { const signedTxList = this.getPendingTransactions() if (!signedTxList.length) return @@ -52,6 +58,11 @@ module.exports = class PendingTransactionTracker extends EventEmitter { }) } + /** + asks the network for the transaction to see if a block number is included on it + if we have skipped/missed blocks + @param object - oldBlock newBlock + */ queryPendingTxs ({ oldBlock, newBlock }) { // check pending transactions on start if (!oldBlock) { @@ -63,7 +74,11 @@ module.exports = class PendingTransactionTracker extends EventEmitter { if (diff > 1) this._checkPendingTxs() } - + /** + Will resubmit any transactions who have not been confirmed in a block + @param block {object} - a block object + @emits tx:warning + */ resubmitPendingTxs (block) { const pending = this.getPendingTransactions() // only try resubmitting if their are transactions to resubmit @@ -100,6 +115,13 @@ module.exports = class PendingTransactionTracker extends EventEmitter { })) } + /** + resubmits the individual txMeta used in resubmitPendingTxs + @param txMeta {Object} - txMeta object + @param latestBlockNumber {string} - hex string for the latest block number + @emits tx:retry + @returns txHash {string} + */ async _resubmitTx (txMeta, latestBlockNumber) { if (!txMeta.firstRetryBlockNumber) { this.emit('tx:block-update', txMeta, latestBlockNumber) @@ -123,7 +145,13 @@ module.exports = class PendingTransactionTracker extends EventEmitter { this.emit('tx:retry', txMeta) return txHash } - + /** + Ask the network for the transaction to see if it has been include in a block + @param txMeta {Object} - the txMeta object + @emits tx:failed + @emits tx:confirmed + @emits tx:warning + */ async _checkPendingTx (txMeta) { const txHash = txMeta.hash const txId = txMeta.id @@ -162,8 +190,9 @@ module.exports = class PendingTransactionTracker extends EventEmitter { } } - // checks the network for signed txs and - // if confirmed sets the tx status as 'confirmed' + /** + checks the network for signed txs and releases the nonce global lock if it is + */ async _checkPendingTxs () { const signedTxList = this.getPendingTransactions() // in order to keep the nonceTracker accurate we block it while updating pending transactions @@ -171,12 +200,17 @@ module.exports = class PendingTransactionTracker extends EventEmitter { try { await Promise.all(signedTxList.map((txMeta) => this._checkPendingTx(txMeta))) } catch (err) { - console.error('PendingTransactionWatcher - Error updating pending transactions') - console.error(err) + log.error('PendingTransactionWatcher - Error updating pending transactions') + log.error(err) } nonceGlobalLock.releaseLock() } + /** + checks to see if a confirmed txMeta has the same nonce + @param txMeta {Object} - txMeta object + @returns {boolean} + */ async _checkIfNonceIsTaken (txMeta) { const address = txMeta.txParams.from const completed = this.getCompletedTransactions(address) @@ -185,5 +219,6 @@ module.exports = class PendingTransactionTracker extends EventEmitter { }) return sameNonce.length > 0 } - } + +module.exports = PendingTransactionTracker diff --git a/app/scripts/lib/tx-gas-utils.js b/app/scripts/controllers/transactions/tx-gas-utils.js index c579e462a..36b5cdbc9 100644 --- a/app/scripts/lib/tx-gas-utils.js +++ b/app/scripts/controllers/transactions/tx-gas-utils.js @@ -3,22 +3,27 @@ const { hexToBn, BnMultiplyByFraction, bnToHex, -} = require('./util') +} = require('../../lib/util') const { addHexPrefix } = require('ethereumjs-util') const SIMPLE_GAS_COST = '0x5208' // Hex for 21000, cost of a simple send. -/* -tx-utils are utility methods for Transaction manager +/** +tx-gas-utils are gas utility methods for Transaction manager its passed ethquery and used to do things like calculate gas of a tx. +@param {Object} provider - A network provider. */ -module.exports = class TxGasUtil { +class TxGasUtil { constructor (provider) { this.query = new EthQuery(provider) } + /** + @param txMeta {Object} - the txMeta object + @returns {object} the txMeta object with the gas written to the txParams + */ async analyzeGasUsage (txMeta) { const block = await this.query.getBlockByNumber('latest', true) let estimatedGasHex @@ -38,6 +43,12 @@ module.exports = class TxGasUtil { return txMeta } + /** + Estimates the tx's gas usage + @param txMeta {Object} - the txMeta object + @param blockGasLimitHex {string} - hex string of the block's gas limit + @returns {string} the estimated gas limit as a hex string + */ async estimateTxGas (txMeta, blockGasLimitHex) { const txParams = txMeta.txParams @@ -70,6 +81,12 @@ module.exports = class TxGasUtil { return await this.query.estimateGas(txParams) } + /** + Writes the gas on the txParams in the txMeta + @param txMeta {Object} - the txMeta object to write to + @param blockGasLimitHex {string} - the block gas limit hex + @param estimatedGasHex {string} - the estimated gas hex + */ setTxGas (txMeta, blockGasLimitHex, estimatedGasHex) { txMeta.estimatedGas = addHexPrefix(estimatedGasHex) const txParams = txMeta.txParams @@ -87,6 +104,13 @@ module.exports = class TxGasUtil { return } + /** + Adds a gas buffer with out exceeding the block gas limit + + @param initialGasLimitHex {string} - the initial gas limit to add the buffer too + @param blockGasLimitHex {string} - the block gas limit + @returns {string} the buffered gas limit as a hex string + */ addGasBuffer (initialGasLimitHex, blockGasLimitHex) { const initialGasLimitBn = hexToBn(initialGasLimitHex) const blockGasLimitBn = hexToBn(blockGasLimitHex) @@ -100,4 +124,6 @@ module.exports = class TxGasUtil { // otherwise use blockGasLimit return bnToHex(upperGasLimitBn) } -}
\ No newline at end of file +} + +module.exports = TxGasUtil
\ No newline at end of file diff --git a/app/scripts/lib/tx-state-manager.js b/app/scripts/controllers/transactions/tx-state-manager.js index c6d10ee62..f05c7d095 100644 --- a/app/scripts/lib/tx-state-manager.js +++ b/app/scripts/controllers/transactions/tx-state-manager.js @@ -1,22 +1,34 @@ const extend = require('xtend') const EventEmitter = require('events') const ObservableStore = require('obs-store') -const createId = require('./random-id') const ethUtil = require('ethereumjs-util') -const txStateHistoryHelper = require('./tx-state-history-helper') - -// STATUS METHODS - // statuses: - // - `'unapproved'` the user has not responded - // - `'rejected'` the user has responded no! - // - `'approved'` the user has approved the tx - // - `'signed'` the tx is signed - // - `'submitted'` the tx is sent to a server - // - `'confirmed'` the tx has been included in a block. - // - `'failed'` the tx failed for some reason, included on tx data. - // - `'dropped'` the tx nonce was already used - -module.exports = class TransactionStateManager extends EventEmitter { +const log = require('loglevel') +const txStateHistoryHelper = require('./lib/tx-state-history-helper') +const createId = require('../../lib/random-id') +const { getFinalStates } = require('./lib/util') +/** + TransactionStateManager is responsible for the state of a transaction and + storing the transaction + it also has some convenience methods for finding subsets of transactions + * + *STATUS METHODS + <br>statuses: + <br> - `'unapproved'` the user has not responded + <br> - `'rejected'` the user has responded no! + <br> - `'approved'` the user has approved the tx + <br> - `'signed'` the tx is signed + <br> - `'submitted'` the tx is sent to a server + <br> - `'confirmed'` the tx has been included in a block. + <br> - `'failed'` the tx failed for some reason, included on tx data. + <br> - `'dropped'` the tx nonce was already used + @param opts {object} + @param {object} [opts.initState={ transactions: [] }] initial transactions list with the key transaction {array} + @param {number} [opts.txHistoryLimit] limit for how many finished + transactions can hang around in state + @param {function} opts.getNetwork return network number + @class +*/ +class TransactionStateManager extends EventEmitter { constructor ({ initState, txHistoryLimit, getNetwork }) { super() @@ -28,6 +40,10 @@ module.exports = class TransactionStateManager extends EventEmitter { this.getNetwork = getNetwork } + /** + @param opts {object} - the object to use when overwriting defaults + @returns {txMeta} the default txMeta object + */ generateTxMeta (opts) { return extend({ id: createId(), @@ -38,17 +54,25 @@ module.exports = class TransactionStateManager extends EventEmitter { }, opts) } + /** + @returns {array} of txMetas that have been filtered for only the current network + */ getTxList () { const network = this.getNetwork() const fullTxList = this.getFullTxList() return fullTxList.filter((txMeta) => txMeta.metamaskNetworkId === network) } + /** + @returns {array} of all the txMetas in store + */ getFullTxList () { return this.store.getState().transactions } - // Returns the tx list + /** + @returns {array} the tx list whos status is unapproved + */ getUnapprovedTxList () { const txList = this.getTxsByMetaData('status', 'unapproved') return txList.reduce((result, tx) => { @@ -57,18 +81,37 @@ module.exports = class TransactionStateManager extends EventEmitter { }, {}) } + /** + @param [address] {string} - hex prefixed address to sort the txMetas for [optional] + @returns {array} the tx list whos status is submitted if no address is provide + returns all txMetas who's status is submitted for the current network + */ getPendingTransactions (address) { const opts = { status: 'submitted' } if (address) opts.from = address return this.getFilteredTxList(opts) } + /** + @param [address] {string} - hex prefixed address to sort the txMetas for [optional] + @returns {array} the tx list whos status is confirmed if no address is provide + returns all txMetas who's status is confirmed for the current network + */ getConfirmedTransactions (address) { const opts = { status: 'confirmed' } if (address) opts.from = address return this.getFilteredTxList(opts) } + /** + Adds the txMeta to the list of transactions in the store. + if the list is over txHistoryLimit it will remove a transaction that + is in its final state + it will allso add the key `history` to the txMeta with the snap shot of the original + object + @param txMeta {Object} + @returns {object} the txMeta + */ addTx (txMeta) { this.once(`${txMeta.id}:signed`, function (txId) { this.removeAllListeners(`${txMeta.id}:rejected`) @@ -92,7 +135,9 @@ module.exports = class TransactionStateManager extends EventEmitter { // or rejected tx's. // not tx's that are pending or unapproved if (txCount > txHistoryLimit - 1) { - let index = transactions.findIndex((metaTx) => metaTx.status === 'confirmed' || metaTx.status === 'rejected') + const index = transactions.findIndex((metaTx) => { + return getFinalStates().includes(metaTx.status) + }) if (index !== -1) { transactions.splice(index, 1) } @@ -101,12 +146,21 @@ module.exports = class TransactionStateManager extends EventEmitter { this._saveTxList(transactions) return txMeta } - // gets tx by Id and returns it + /** + @param txId {number} + @returns {object} the txMeta who matches the given id if none found + for the network returns undefined + */ getTx (txId) { const txMeta = this.getTxsByMetaData('id', txId)[0] return txMeta } + /** + updates the txMeta in the list and adds a history entry + @param txMeta {Object} - the txMeta to update + @param [note] {string} - a not about the update for history + */ updateTx (txMeta, note) { // validate txParams if (txMeta.txParams) { @@ -134,16 +188,23 @@ module.exports = class TransactionStateManager extends EventEmitter { } - // merges txParams obj onto txData.txParams - // use extend to ensure that all fields are filled + /** + merges txParams obj onto txMeta.txParams + use extend to ensure that all fields are filled + @param txId {number} - the id of the txMeta + @param txParams {object} - the updated txParams + */ updateTxParams (txId, txParams) { const txMeta = this.getTx(txId) txMeta.txParams = extend(txMeta.txParams, txParams) this.updateTx(txMeta, `txStateManager#updateTxParams`) } - // validates txParams members by type - validateTxParams(txParams) { + /** + validates txParams members by type + @param txParams {object} - txParams to validate + */ + validateTxParams (txParams) { Object.keys(txParams).forEach((key) => { const value = txParams[key] // validate types @@ -159,17 +220,19 @@ module.exports = class TransactionStateManager extends EventEmitter { }) } -/* - Takes an object of fields to search for eg: - let thingsToLookFor = { - to: '0x0..', - from: '0x0..', - status: 'signed', - err: undefined, - } - and returns a list of tx with all +/** + @param opts {object} - an object of fields to search for eg:<br> + let <code>thingsToLookFor = {<br> + to: '0x0..',<br> + from: '0x0..',<br> + status: 'signed',<br> + err: undefined,<br> + }<br></code> + @param [initialList=this.getTxList()] + @returns a {array} of txMeta with all options matching - + */ + /* ****************HINT**************** | `err: undefined` is like looking | | for a tx with no err | @@ -190,7 +253,14 @@ module.exports = class TransactionStateManager extends EventEmitter { }) return filteredTxList } + /** + @param key {string} - the key to check + @param value - the value your looking for + @param [txList=this.getTxList()] {array} - the list to search. default is the txList + from txStateManager#getTxList + @returns {array} a list of txMetas who matches the search params + */ getTxsByMetaData (key, value, txList = this.getTxList()) { return txList.filter((txMeta) => { if (txMeta.txParams[key]) { @@ -203,33 +273,51 @@ module.exports = class TransactionStateManager extends EventEmitter { // get::set status - // should return the status of the tx. + /** + @param txId {number} - the txMeta Id + @return {string} the status of the tx. + */ getTxStatus (txId) { const txMeta = this.getTx(txId) return txMeta.status } - // should update the status of the tx to 'rejected'. + /** + should update the status of the tx to 'rejected'. + @param txId {number} - the txMeta Id + */ setTxStatusRejected (txId) { this._setTxStatus(txId, 'rejected') } - // should update the status of the tx to 'unapproved'. + /** + should update the status of the tx to 'unapproved'. + @param txId {number} - the txMeta Id + */ setTxStatusUnapproved (txId) { this._setTxStatus(txId, 'unapproved') } - // should update the status of the tx to 'approved'. + /** + should update the status of the tx to 'approved'. + @param txId {number} - the txMeta Id + */ setTxStatusApproved (txId) { this._setTxStatus(txId, 'approved') } - // should update the status of the tx to 'signed'. + /** + should update the status of the tx to 'signed'. + @param txId {number} - the txMeta Id + */ setTxStatusSigned (txId) { this._setTxStatus(txId, 'signed') } - // should update the status of the tx to 'submitted'. - // and add a time stamp for when it was called + /** + should update the status of the tx to 'submitted'. + and add a time stamp for when it was called + @param txId {number} - the txMeta Id + */ setTxStatusSubmitted (txId) { const txMeta = this.getTx(txId) txMeta.submittedTime = (new Date()).getTime() @@ -237,17 +325,29 @@ module.exports = class TransactionStateManager extends EventEmitter { this._setTxStatus(txId, 'submitted') } - // should update the status of the tx to 'confirmed'. + /** + should update the status of the tx to 'confirmed'. + @param txId {number} - the txMeta Id + */ setTxStatusConfirmed (txId) { this._setTxStatus(txId, 'confirmed') } - // should update the status dropped + /** + should update the status of the tx to 'dropped'. + @param txId {number} - the txMeta Id + */ setTxStatusDropped (txId) { this._setTxStatus(txId, 'dropped') } + /** + should update the status of the tx to 'failed'. + and put the error on the txMeta + @param txId {number} - the txMeta Id + @param err {erroObject} - error object + */ setTxStatusFailed (txId, err) { const txMeta = this.getTx(txId) txMeta.err = { @@ -258,6 +358,11 @@ module.exports = class TransactionStateManager extends EventEmitter { this._setTxStatus(txId, 'failed') } + /** + Removes transaction from the given address for the current network + from the txList + @param address {string} - hex string of the from address on the txParams to remove + */ wipeTransactions (address) { // network only tx const txs = this.getFullTxList() @@ -273,9 +378,8 @@ module.exports = class TransactionStateManager extends EventEmitter { // PRIVATE METHODS // - // Should find the tx in the tx list and - // update it. - // should set the status in txData + // STATUS METHODS + // statuses: // - `'unapproved'` the user has not responded // - `'rejected'` the user has responded no! // - `'approved'` the user has approved the tx @@ -283,21 +387,41 @@ module.exports = class TransactionStateManager extends EventEmitter { // - `'submitted'` the tx is sent to a server // - `'confirmed'` the tx has been included in a block. // - `'failed'` the tx failed for some reason, included on tx data. + // - `'dropped'` the tx nonce was already used + + /** + @param txId {number} - the txMeta Id + @param status {string} - the status to set on the txMeta + @emits tx:status-update - passes txId and status + @emits ${txMeta.id}:finished - if it is a finished state. Passes the txMeta + @emits update:badge + */ _setTxStatus (txId, status) { const txMeta = this.getTx(txId) txMeta.status = status - this.emit(`${txMeta.id}:${status}`, txId) - this.emit(`tx:status-update`, txId, status) - if (['submitted', 'rejected', 'failed'].includes(status)) { - this.emit(`${txMeta.id}:finished`, txMeta) - } - this.updateTx(txMeta, `txStateManager: setting status to ${status}`) - this.emit('update:badge') + setTimeout(() => { + try { + this.updateTx(txMeta, `txStateManager: setting status to ${status}`) + this.emit(`${txMeta.id}:${status}`, txId) + this.emit(`tx:status-update`, txId, status) + if (['submitted', 'rejected', 'failed'].includes(status)) { + this.emit(`${txMeta.id}:finished`, txMeta) + } + this.emit('update:badge') + } catch (error) { + log.error(error) + } + }) } - // Saves the new/updated txList. + /** + Saves the new/updated txList. + @param transactions {array} - the list of transactions to save + */ // Function is intended only for internal use _saveTxList (transactions) { this.store.updateState({ transactions }) } } + +module.exports = TransactionStateManager diff --git a/app/scripts/edge-encryptor.js b/app/scripts/edge-encryptor.js index 24c0c93a8..dcb06873b 100644 --- a/app/scripts/edge-encryptor.js +++ b/app/scripts/edge-encryptor.js @@ -1,69 +1,97 @@ const asmcrypto = require('asmcrypto.js') const Unibabel = require('browserify-unibabel') +/** + * A Microsoft Edge-specific encryption class that exposes + * the interface expected by eth-keykeyring-controller + */ class EdgeEncryptor { + /** + * Encrypts an arbitrary object to ciphertext + * + * @param {string} password Used to generate a key to encrypt the data + * @param {Object} dataObject Data to encrypt + * @returns {Promise<string>} Promise resolving to an object with ciphertext + */ + encrypt (password, dataObject) { + var salt = this._generateSalt() + return this._keyFromPassword(password, salt) + .then(function (key) { + var data = JSON.stringify(dataObject) + var dataBuffer = Unibabel.utf8ToBuffer(data) + var vector = global.crypto.getRandomValues(new Uint8Array(16)) + var resultbuffer = asmcrypto.AES_GCM.encrypt(dataBuffer, key, vector) - encrypt (password, dataObject) { + var buffer = new Uint8Array(resultbuffer) + var vectorStr = Unibabel.bufferToBase64(vector) + var vaultStr = Unibabel.bufferToBase64(buffer) + return JSON.stringify({ + data: vaultStr, + iv: vectorStr, + salt: salt, + }) + }) + } - var salt = this._generateSalt() - return this._keyFromPassword(password, salt) - .then(function (key) { + /** + * Decrypts an arbitrary object from ciphertext + * + * @param {string} password Used to generate a key to decrypt the data + * @param {string} text Ciphertext of an encrypted object + * @returns {Promise<Object>} Promise resolving to copy of decrypted object + */ + decrypt (password, text) { + const payload = JSON.parse(text) + const salt = payload.salt + return this._keyFromPassword(password, salt) + .then(function (key) { + const encryptedData = Unibabel.base64ToBuffer(payload.data) + const vector = Unibabel.base64ToBuffer(payload.iv) + return new Promise((resolve, reject) => { + var result + try { + result = asmcrypto.AES_GCM.decrypt(encryptedData, key, vector) + } catch (err) { + return reject(new Error('Incorrect password')) + } + const decryptedData = new Uint8Array(result) + const decryptedStr = Unibabel.bufferToUtf8(decryptedData) + const decryptedObj = JSON.parse(decryptedStr) + resolve(decryptedObj) + }) + }) + } - var data = JSON.stringify(dataObject) - var dataBuffer = Unibabel.utf8ToBuffer(data) - var vector = global.crypto.getRandomValues(new Uint8Array(16)) - var resultbuffer = asmcrypto.AES_GCM.encrypt(dataBuffer, key, vector) + /** + * Retrieves a cryptographic key using a password + * + * @private + * @param {string} password Password used to unlock a cryptographic key + * @param {string} salt Random base64 data + * @returns {Promise<Object>} Promise resolving to a derived key + */ + _keyFromPassword (password, salt) { - var buffer = new Uint8Array(resultbuffer) - var vectorStr = Unibabel.bufferToBase64(vector) - var vaultStr = Unibabel.bufferToBase64(buffer) - return JSON.stringify({ - data: vaultStr, - iv: vectorStr, - salt: salt, - }) - }) - } + var passBuffer = Unibabel.utf8ToBuffer(password) + var saltBuffer = Unibabel.base64ToBuffer(salt) + return new Promise((resolve) => { + var key = asmcrypto.PBKDF2_HMAC_SHA256.bytes(passBuffer, saltBuffer, 10000) + resolve(key) + }) + } - decrypt (password, text) { - - const payload = JSON.parse(text) - const salt = payload.salt - return this._keyFromPassword(password, salt) - .then(function (key) { - const encryptedData = Unibabel.base64ToBuffer(payload.data) - const vector = Unibabel.base64ToBuffer(payload.iv) - return new Promise((resolve, reject) => { - var result - try { - result = asmcrypto.AES_GCM.decrypt(encryptedData, key, vector) - } catch (err) { - return reject(new Error('Incorrect password')) - } - const decryptedData = new Uint8Array(result) - const decryptedStr = Unibabel.bufferToUtf8(decryptedData) - const decryptedObj = JSON.parse(decryptedStr) - resolve(decryptedObj) - }) - }) - } - - _keyFromPassword (password, salt) { - - var passBuffer = Unibabel.utf8ToBuffer(password) - var saltBuffer = Unibabel.base64ToBuffer(salt) - return new Promise((resolve) => { - var key = asmcrypto.PBKDF2_HMAC_SHA256.bytes(passBuffer, saltBuffer, 10000) - resolve(key) - }) - } - - _generateSalt (byteCount = 32) { - var view = new Uint8Array(byteCount) - global.crypto.getRandomValues(view) - var b64encoded = btoa(String.fromCharCode.apply(null, view)) - return b64encoded - } + /** + * Generates random base64 encoded data + * + * @private + * @returns {string} Randomized base64 encoded data + */ + _generateSalt (byteCount = 32) { + var view = new Uint8Array(byteCount) + global.crypto.getRandomValues(view) + var b64encoded = btoa(String.fromCharCode.apply(null, view)) + return b64encoded + } } module.exports = EdgeEncryptor diff --git a/app/scripts/first-time-state.js b/app/scripts/first-time-state.js index 3063df627..c49d89288 100644 --- a/app/scripts/first-time-state.js +++ b/app/scripts/first-time-state.js @@ -1,15 +1,24 @@ // test and development environment variables const env = process.env.METAMASK_ENV const METAMASK_DEBUG = process.env.METAMASK_DEBUG +const { DEFAULT_NETWORK, MAINNET } = require('./controllers/network/enums') -// -// The default state of MetaMask -// -module.exports = { +/** + * @typedef {Object} FirstTimeState + * @property {Object} config Initial configuration parameters + * @property {Object} NetworkController Network controller state + */ + +/** + * @type {FirstTimeState} + */ +const initialState = { config: {}, NetworkController: { provider: { - type: (METAMASK_DEBUG || env === 'test') ? 'rinkeby' : 'mainnet', + type: (METAMASK_DEBUG || env === 'test') ? DEFAULT_NETWORK : MAINNET, }, }, } + +module.exports = initialState diff --git a/app/scripts/inpage.js b/app/scripts/inpage.js index 92c732813..6d16eebd4 100644 --- a/app/scripts/inpage.js +++ b/app/scripts/inpage.js @@ -42,20 +42,20 @@ log.debug('MetaMask - injected web3') setupDappAutoReload(web3, inpageProvider.publicConfigStore) // set web3 defaultAccount - inpageProvider.publicConfigStore.subscribe(function (state) { web3.eth.defaultAccount = state.selectedAddress }) -// -// util -// - // need to make sure we aren't affected by overlapping namespaces // and that we dont affect the app with our namespace // mostly a fix for web3's BigNumber if AMD's "define" is defined... var __define +/** + * Caches reference to global define object and deletes it to + * avoid conflicts with other global define objects, such as + * AMD's define function + */ function cleanContextForImports () { __define = global.define try { @@ -65,6 +65,9 @@ function cleanContextForImports () { } } +/** + * Restores global define object from cached reference + */ function restoreContextAfterImports () { try { global.define = __define diff --git a/app/scripts/lib/account-tracker.js b/app/scripts/lib/account-tracker.js index 8c3dd8c71..0f7b3d865 100644 --- a/app/scripts/lib/account-tracker.js +++ b/app/scripts/lib/account-tracker.js @@ -16,6 +16,24 @@ function noop () {} class AccountTracker extends EventEmitter { + /** + * This module is responsible for tracking any number of accounts and caching their current balances & transaction + * counts. + * + * It also tracks transaction hashes, and checks their inclusion status on each new block. + * + * @typedef {Object} AccountTracker + * @param {Object} opts Initialize various properties of the class. + * @property {Object} store The stored object containing all accounts to track, as well as the current block's gas limit. + * @property {Object} store.accounts The accounts currently stored in this AccountTracker + * @property {string} store.currentBlockGasLimit A hex string indicating the gas limit of the current block + * @property {Object} _provider A provider needed to create the EthQuery instance used within this AccountTracker. + * @property {EthQuery} _query An EthQuery instance used to access account information from the blockchain + * @property {BlockTracker} _blockTracker A BlockTracker instance. Needed to ensure that accounts and their info updates + * when a new block is created. + * @property {Object} _currentBlockNumber Reference to a property on the _blockTracker: the number (i.e. an id) of the the current block + * + */ constructor (opts = {}) { super() @@ -34,10 +52,17 @@ class AccountTracker extends EventEmitter { this._currentBlockNumber = this._blockTracker.currentBlock } - // - // public - // - + /** + * Ensures that the locally stored accounts are in sync with a set of accounts stored externally to this + * AccountTracker. + * + * Once this AccountTracker's accounts are up to date with those referenced by the passed addresses, each + * of these accounts are given an updated balance via EthQuery. + * + * @param {array} address The array of hex addresses for accounts with which this AccountTracker's accounts should be + * in sync + * + */ syncWithAddresses (addresses) { const accounts = this.store.getState().accounts const locals = Object.keys(accounts) @@ -61,6 +86,13 @@ class AccountTracker extends EventEmitter { this._updateAccounts() } + /** + * Adds a new address to this AccountTracker's accounts object, which points to an empty object. This object will be + * given a balance as long this._currentBlockNumber is defined. + * + * @param {string} address A hex address of a new account to store in this AccountTracker's accounts object + * + */ addAccount (address) { const accounts = this.store.getState().accounts accounts[address] = {} @@ -69,16 +101,27 @@ class AccountTracker extends EventEmitter { this._updateAccount(address) } + /** + * Removes an account from this AccountTracker's accounts object + * + * @param {string} address A hex address of a the account to remove + * + */ removeAccount (address) { const accounts = this.store.getState().accounts delete accounts[address] this.store.updateState({ accounts }) } - // - // private - // - + /** + * Given a block, updates this AccountTracker's currentBlockGasLimit, and then updates each local account's balance + * via EthQuery + * + * @private + * @param {object} block Data about the block that contains the data to update to. + * @fires 'block' The updated state, if all account updates are successful + * + */ _updateForBlock (block) { this._currentBlockNumber = block.number const currentBlockGasLimit = block.gasLimit @@ -93,12 +136,26 @@ class AccountTracker extends EventEmitter { }) } + /** + * Calls this._updateAccount for each account in this.store + * + * @param {Function} cb A callback to pass to this._updateAccount, called after each account is successfully updated + * + */ _updateAccounts (cb = noop) { const accounts = this.store.getState().accounts const addresses = Object.keys(accounts) async.each(addresses, this._updateAccount.bind(this), cb) } + /** + * Updates the current balance of an account. Gets an updated balance via this._getAccount. + * + * @private + * @param {string} address A hex address of a the account to be updated + * @param {Function} cb A callback to call once the account at address is successfully update + * + */ _updateAccount (address, cb = noop) { this._getAccount(address, (err, result) => { if (err) return cb(err) @@ -113,6 +170,14 @@ class AccountTracker extends EventEmitter { }) } + /** + * Gets the current balance of an account via EthQuery. + * + * @private + * @param {string} address A hex address of a the account to query + * @param {Function} cb A callback to call once the account at address is successfully update + * + */ _getAccount (address, cb = noop) { const query = this._query async.parallel({ diff --git a/app/scripts/lib/buy-eth-url.js b/app/scripts/lib/buy-eth-url.js index b9dde3c28..4e2d0bc79 100644 --- a/app/scripts/lib/buy-eth-url.js +++ b/app/scripts/lib/buy-eth-url.js @@ -1,5 +1,16 @@ module.exports = getBuyEthUrl +/** + * Gives the caller a url at which the user can acquire eth, depending on the network they are in + * + * @param {object} opts Options required to determine the correct url + * @param {string} opts.network The network for which to return a url + * @param {string} opts.amount The amount of ETH to buy on coinbase. Only relevant if network === '1'. + * @param {string} opts.address The address the bought ETH should be sent to. Only relevant if network === '1'. + * @returns {string|undefined} The url at which the user can access ETH, while in the given network. If the passed + * network does not match any of the specified cases, or if no network is given, returns undefined. + * + */ function getBuyEthUrl ({ network, amount, address }) { let url switch (network) { diff --git a/app/scripts/lib/config-manager.js b/app/scripts/lib/config-manager.js index 34b603b96..221746467 100644 --- a/app/scripts/lib/config-manager.js +++ b/app/scripts/lib/config-manager.js @@ -1,12 +1,11 @@ const ethUtil = require('ethereumjs-util') const normalize = require('eth-sig-util').normalize -const MetamaskConfig = require('../config.js') - - -const MAINNET_RPC = MetamaskConfig.network.mainnet -const ROPSTEN_RPC = MetamaskConfig.network.ropsten -const KOVAN_RPC = MetamaskConfig.network.kovan -const RINKEBY_RPC = MetamaskConfig.network.rinkeby +const { + MAINNET_RPC_URL, + ROPSTEN_RPC_URL, + KOVAN_RPC_URL, + RINKEBY_RPC_URL, +} = require('../controllers/network/enums') /* The config-manager is a convenience object * wrapping a pojo-migrator. @@ -154,19 +153,19 @@ ConfigManager.prototype.getCurrentRpcAddress = function () { switch (provider.type) { case 'mainnet': - return MAINNET_RPC + return MAINNET_RPC_URL case 'ropsten': - return ROPSTEN_RPC + return ROPSTEN_RPC_URL case 'kovan': - return KOVAN_RPC + return KOVAN_RPC_URL case 'rinkeby': - return RINKEBY_RPC + return RINKEBY_RPC_URL default: - return provider && provider.rpcTarget ? provider.rpcTarget : RINKEBY_RPC + return provider && provider.rpcTarget ? provider.rpcTarget : RINKEBY_RPC_URL } } diff --git a/app/scripts/lib/createLoggerMiddleware.js b/app/scripts/lib/createLoggerMiddleware.js index fc6abf828..996c3477c 100644 --- a/app/scripts/lib/createLoggerMiddleware.js +++ b/app/scripts/lib/createLoggerMiddleware.js @@ -1,16 +1,20 @@ const log = require('loglevel') -// log rpc activity module.exports = createLoggerMiddleware -function createLoggerMiddleware ({ origin }) { - return function loggerMiddleware (req, res, next, end) { - next((cb) => { +/** + * Returns a middleware that logs RPC activity + * @param {{ origin: string }} opts - The middleware options + * @returns {Function} + */ +function createLoggerMiddleware (opts) { + return function loggerMiddleware (/** @type {any} */ req, /** @type {any} */ res, /** @type {Function} */ next) { + next((/** @type {Function} */ cb) => { if (res.error) { log.error('Error in RPC response:\n', res) } if (req.isMetamaskInternal) return - log.info(`RPC (${origin}):`, req, '->', res) + log.info(`RPC (${opts.origin}):`, req, '->', res) cb() }) } diff --git a/app/scripts/lib/createOriginMiddleware.js b/app/scripts/lib/createOriginMiddleware.js index f8bdb2dc2..98bb0e3b3 100644 --- a/app/scripts/lib/createOriginMiddleware.js +++ b/app/scripts/lib/createOriginMiddleware.js @@ -1,9 +1,13 @@ -// append dapp origin domain to request module.exports = createOriginMiddleware -function createOriginMiddleware ({ origin }) { - return function originMiddleware (req, res, next, end) { - req.origin = origin +/** + * Returns a middleware that appends the DApp origin to request + * @param {{ origin: string }} opts - The middleware options + * @returns {Function} + */ +function createOriginMiddleware (opts) { + return function originMiddleware (/** @type {any} */ req, /** @type {any} */ _, /** @type {Function} */ next) { + req.origin = opts.origin next() } } diff --git a/app/scripts/lib/createProviderMiddleware.js b/app/scripts/lib/createProviderMiddleware.js index 4e667bac2..8a939ba4e 100644 --- a/app/scripts/lib/createProviderMiddleware.js +++ b/app/scripts/lib/createProviderMiddleware.js @@ -1,6 +1,10 @@ module.exports = createProviderMiddleware -// forward requests to provider +/** + * Forwards an HTTP request to the current Web3 provider + * + * @param {{ provider: Object }} config Configuration containing current Web3 provider + */ function createProviderMiddleware ({ provider }) { return (req, res, next, end) => { provider.sendAsync(req, (err, _res) => { diff --git a/app/scripts/lib/events-proxy.js b/app/scripts/lib/events-proxy.js index c0a490b05..f83773ccc 100644 --- a/app/scripts/lib/events-proxy.js +++ b/app/scripts/lib/events-proxy.js @@ -1,26 +1,37 @@ +/** + * Returns an EventEmitter that proxies events from the given event emitter + * @param {any} eventEmitter + * @param {object} listeners - The listeners to proxy to + * @returns {any} + */ module.exports = function createEventEmitterProxy (eventEmitter, listeners) { let target = eventEmitter const eventHandlers = listeners || {} - const proxy = new Proxy({}, { - get: (obj, name) => { + const proxy = /** @type {any} */ (new Proxy({}, { + get: (_, name) => { // intercept listeners if (name === 'on') return addListener if (name === 'setTarget') return setTarget if (name === 'proxyEventHandlers') return eventHandlers - return target[name] + return (/** @type {any} */ (target))[name] }, - set: (obj, name, value) => { + set: (_, name, value) => { target[name] = value return true }, - }) - function setTarget (eventEmitter) { + })) + function setTarget (/** @type {EventEmitter} */ eventEmitter) { target = eventEmitter // migrate listeners Object.keys(eventHandlers).forEach((name) => { - eventHandlers[name].forEach((handler) => target.on(name, handler)) + /** @type {Array<Function>} */ (eventHandlers[name]).forEach((handler) => target.on(name, handler)) }) } + /** + * Attaches a function to be called whenever the specified event is emitted + * @param {string} name + * @param {Function} handler + */ function addListener (name, handler) { if (!eventHandlers[name]) eventHandlers[name] = [] eventHandlers[name].push(handler) diff --git a/app/scripts/lib/extractEthjsErrorMessage.js b/app/scripts/lib/extractEthjsErrorMessage.js index bac541735..0f100756f 100644 --- a/app/scripts/lib/extractEthjsErrorMessage.js +++ b/app/scripts/lib/extractEthjsErrorMessage.js @@ -4,17 +4,18 @@ const errorLabelPrefix = 'Error: ' module.exports = extractEthjsErrorMessage -// -// ethjs-rpc provides overly verbose error messages -// if we detect this type of message, we extract the important part -// Below is an example input and output -// -// Error: [ethjs-rpc] rpc error with payload {"id":3947817945380,"jsonrpc":"2.0","params":["0xf8eb8208708477359400830398539406012c8cf97bead5deae237070f9587f8e7a266d80b8843d7d3f5a0000000000000000000000000000000000000000000000000000000000081d1a000000000000000000000000000000000000000000000000001ff973cafa800000000000000000000000000000000000000000000000000000038d7ea4c68000000000000000000000000000000000000000000000000000000000000003f48025a04c32a9b630e0d9e7ff361562d850c86b7a884908135956a7e4a336fa0300d19ca06830776423f25218e8d19b267161db526e66895567147015b1f3fc47aef9a3c7"],"method":"eth_sendRawTransaction"} Error: replacement transaction underpriced -// -// Transaction Failed: replacement transaction underpriced -// - - +/** + * Extracts the important part of an ethjs-rpc error message. If the passed error is not an isEthjsRpcError, the error + * is returned unchanged. + * + * @param {string} errorMessage The error message to parse + * @returns {string} Returns an error message, either the same as was passed, or the ending message portion of an isEthjsRpcError + * + * @example + * // returns 'Transaction Failed: replacement transaction underpriced' + * extractEthjsErrorMessage(`Error: [ethjs-rpc] rpc error with payload {"id":3947817945380,"jsonrpc":"2.0","params":["0xf8eb8208708477359400830398539406012c8cf97bead5deae237070f9587f8e7a266d80b8843d7d3f5a0000000000000000000000000000000000000000000000000000000000081d1a000000000000000000000000000000000000000000000000001ff973cafa800000000000000000000000000000000000000000000000000000038d7ea4c68000000000000000000000000000000000000000000000000000000000000003f48025a04c32a9b630e0d9e7ff361562d850c86b7a884908135956a7e4a336fa0300d19ca06830776423f25218e8d19b267161db526e66895567147015b1f3fc47aef9a3c7"],"method":"eth_sendRawTransaction"} Error: replacement transaction underpriced`) + * +*/ function extractEthjsErrorMessage(errorMessage) { const isEthjsRpcError = errorMessage.includes(ethJsRpcSlug) if (isEthjsRpcError) { diff --git a/app/scripts/lib/get-first-preferred-lang-code.js b/app/scripts/lib/get-first-preferred-lang-code.js index e3635434e..5473fccf0 100644 --- a/app/scripts/lib/get-first-preferred-lang-code.js +++ b/app/scripts/lib/get-first-preferred-lang-code.js @@ -4,6 +4,13 @@ const allLocales = require('../../_locales/index.json') const existingLocaleCodes = allLocales.map(locale => locale.code.toLowerCase().replace('_', '-')) +/** + * Returns a preferred language code, based on settings within the user's browser. If we have no translations for the + * users preferred locales, 'en' is returned. + * + * @returns {Promise<string>} Promises a locale code, either one from the user's preferred list that we have a translation for, or 'en' + * + */ async function getFirstPreferredLangCode () { const userPreferredLocaleCodes = await promisify( extension.i18n.getAcceptLanguages, diff --git a/app/scripts/lib/getObjStructure.js b/app/scripts/lib/getObjStructure.js index 3db389507..52250d3fb 100644 --- a/app/scripts/lib/getObjStructure.js +++ b/app/scripts/lib/getObjStructure.js @@ -14,6 +14,15 @@ module.exports = getObjStructure // } // } +/** + * Creates an object that represents the structure of the given object. It replaces all values with the result of their + * type. + * + * @param {object} obj The object for which a 'structure' will be returned. Usually a plain object and not a class. + * @returns {object} The "mapped" version of a deep clone of the passed object, with each non-object property value + * replaced with the javascript type of that value. + * + */ function getObjStructure(obj) { const structure = clone(obj) return deepMap(structure, (value) => { @@ -21,6 +30,14 @@ function getObjStructure(obj) { }) } +/** + * Modifies all the properties and deeply nested of a passed object. Iterates recursively over all nested objects and + * their properties, and covers the entire depth of the object. At each property value which is not an object is modified. + * + * @param {object} target The object to modify + * @param {Function} visit The modifier to apply to each non-object property value + * @returns {object} The modified object + */ function deepMap(target = {}, visit) { Object.entries(target).forEach(([key, value]) => { if (typeof value === 'object' && value !== null) { diff --git a/app/scripts/lib/hex-to-bn.js b/app/scripts/lib/hex-to-bn.js index 184217279..b28746920 100644 --- a/app/scripts/lib/hex-to-bn.js +++ b/app/scripts/lib/hex-to-bn.js @@ -1,6 +1,11 @@ -const ethUtil = require('ethereumjs-util') +const ethUtil = (/** @type {object} */ (require('ethereumjs-util'))) const BN = ethUtil.BN +/** + * Returns a [BinaryNumber]{@link BN} representation of the given hex value + * @param {string} hex + * @return {any} + */ module.exports = function hexToBn (hex) { return new BN(ethUtil.stripHexPrefix(hex), 16) } diff --git a/app/scripts/lib/local-store.js b/app/scripts/lib/local-store.js index 2dda0ba1f..139ff86bd 100644 --- a/app/scripts/lib/local-store.js +++ b/app/scripts/lib/local-store.js @@ -1,11 +1,13 @@ -// We should not rely on local storage in an extension! -// We should use this instead! -// https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/storage/local - const extension = require('extensionizer') const log = require('loglevel') +/** + * A wrapper around the extension's storage local API + */ module.exports = class ExtensionStore { + /** + * @constructor + */ constructor() { this.isSupported = !!(extension.storage.local) if (!this.isSupported) { @@ -13,6 +15,10 @@ module.exports = class ExtensionStore { } } + /** + * Returns all of the keys currently saved + * @return {Promise<*>} + */ async get() { if (!this.isSupported) return undefined const result = await this._get() @@ -25,14 +31,24 @@ module.exports = class ExtensionStore { } } + /** + * Sets the key in local state + * @param {object} state - The state to set + * @return {Promise<void>} + */ async set(state) { return this._set(state) } + /** + * Returns all of the keys currently saved + * @private + * @return {object} the key-value map from local storage + */ _get() { const local = extension.storage.local return new Promise((resolve, reject) => { - local.get(null, (result) => { + local.get(null, (/** @type {any} */ result) => { const err = extension.runtime.lastError if (err) { reject(err) @@ -43,6 +59,12 @@ module.exports = class ExtensionStore { }) } + /** + * Sets the key in local state + * @param {object} obj - The key to set + * @return {Promise<void>} + * @private + */ _set(obj) { const local = extension.storage.local return new Promise((resolve, reject) => { @@ -58,6 +80,11 @@ module.exports = class ExtensionStore { } } +/** + * Returns whether or not the given object contains no keys + * @param {object} obj - The object to check + * @returns {boolean} + */ function isEmpty(obj) { return Object.keys(obj).length === 0 } diff --git a/app/scripts/lib/message-manager.js b/app/scripts/lib/message-manager.js index f52e048e0..901367f04 100644 --- a/app/scripts/lib/message-manager.js +++ b/app/scripts/lib/message-manager.js @@ -3,8 +3,37 @@ const ObservableStore = require('obs-store') const ethUtil = require('ethereumjs-util') const createId = require('./random-id') +/** + * Represents, and contains data about, an 'eth_sign' type signature request. These are created when a signature for + * an eth_sign call is requested. + * + * @see {@link https://github.com/ethereum/wiki/wiki/JSON-RPC#eth_sign} + * + * @typedef {Object} Message + * @property {number} id An id to track and identify the message object + * @property {Object} msgParams The parameters to pass to the eth_sign method once the signature request is approved. + * @property {Object} msgParams.metamaskId Added to msgParams for tracking and identification within MetaMask. + * @property {string} msgParams.data A hex string conversion of the raw buffer data of the signature request + * @property {number} time The epoch time at which the this message was created + * @property {string} status Indicates whether the signature request is 'unapproved', 'approved', 'signed' or 'rejected' + * @property {string} type The json-prc signing method for which a signature request has been made. A 'Message' with + * always have a 'eth_sign' type. + * + */ module.exports = class MessageManager extends EventEmitter { + + /** + * Controller in charge of managing - storing, adding, removing, updating - Messages. + * + * @typedef {Object} MessageManager + * @param {Object} opts @deprecated + * @property {Object} memStore The observable store where Messages are saved. + * @property {Object} memStore.unapprovedMsgs A collection of all Messages in the 'unapproved' state + * @property {number} memStore.unapprovedMsgCount The count of all Messages in this.memStore.unapprobedMsgs + * @property {array} messages Holds all messages that have been created by this MessageManager + * + */ constructor (opts) { super() this.memStore = new ObservableStore({ @@ -14,15 +43,35 @@ module.exports = class MessageManager extends EventEmitter { this.messages = [] } + /** + * A getter for the number of 'unapproved' Messages in this.messages + * + * @returns {number} The number of 'unapproved' Messages in this.messages + * + */ get unapprovedMsgCount () { return Object.keys(this.getUnapprovedMsgs()).length } + /** + * A getter for the 'unapproved' Messages in this.messages + * + * @returns {Object} An index of Message ids to Messages, for all 'unapproved' Messages in this.messages + * + */ getUnapprovedMsgs () { return this.messages.filter(msg => msg.status === 'unapproved') .reduce((result, msg) => { result[msg.id] = msg; return result }, {}) } + /** + * Creates a new Message with an 'unapproved' status using the passed msgParams. this.addMsg is called to add the + * new Message to this.messages, and to save the unapproved Messages from that list to this.memStore. + * + * @param {Object} msgParams The params for the eth_sign call to be made after the message is approved. + * @returns {number} The id of the newly created message. + * + */ addUnapprovedMessage (msgParams) { msgParams.data = normalizeMsgData(msgParams.data) // create txData obj with parameters and meta data @@ -42,24 +91,61 @@ module.exports = class MessageManager extends EventEmitter { return msgId } + /** + * Adds a passed Message to this.messages, and calls this._saveMsgList() to save the unapproved Messages from that + * list to this.memStore. + * + * @param {Message} msg The Message to add to this.messages + * + */ addMsg (msg) { this.messages.push(msg) this._saveMsgList() } + /** + * Returns a specified Message. + * + * @param {number} msgId The id of the Message to get + * @returns {Message|undefined} The Message with the id that matches the passed msgId, or undefined if no Message has that id. + * + */ getMsg (msgId) { return this.messages.find(msg => msg.id === msgId) } + /** + * Approves a Message. Sets the message status via a call to this.setMsgStatusApproved, and returns a promise with + * any the message params modified for proper signing. + * + * @param {Object} msgParams The msgParams to be used when eth_sign is called, plus data added by MetaMask. + * @param {Object} msgParams.metamaskId Added to msgParams for tracking and identification within MetaMask. + * @returns {Promise<object>} Promises the msgParams object with metamaskId removed. + * + */ approveMessage (msgParams) { this.setMsgStatusApproved(msgParams.metamaskId) return this.prepMsgForSigning(msgParams) } + /** + * Sets a Message status to 'approved' via a call to this._setMsgStatus. + * + * @param {number} msgId The id of the Message to approve. + * + */ setMsgStatusApproved (msgId) { this._setMsgStatus(msgId, 'approved') } + /** + * Sets a Message status to 'signed' via a call to this._setMsgStatus and updates that Message in this.messages by + * adding the raw signature data of the signature request to the Message + * + * @param {number} msgId The id of the Message to sign. + * @param {buffer} rawSig The raw data of the signature request + * + */ setMsgStatusSigned (msgId, rawSig) { const msg = this.getMsg(msgId) msg.rawSig = rawSig @@ -67,19 +153,40 @@ module.exports = class MessageManager extends EventEmitter { this._setMsgStatus(msgId, 'signed') } + /** + * Removes the metamaskId property from passed msgParams and returns a promise which resolves the updated msgParams + * + * @param {Object} msgParams The msgParams to modify + * @returns {Promise<object>} Promises the msgParams with the metamaskId property removed + * + */ prepMsgForSigning (msgParams) { delete msgParams.metamaskId return Promise.resolve(msgParams) } + /** + * Sets a Message status to 'rejected' via a call to this._setMsgStatus. + * + * @param {number} msgId The id of the Message to reject. + * + */ rejectMsg (msgId) { this._setMsgStatus(msgId, 'rejected') } - // - // PRIVATE METHODS - // - + /** + * Updates the status of a Message in this.messages via a call to this._updateMsg + * + * @private + * @param {number} msgId The id of the Message to update. + * @param {string} status The new status of the Message. + * @throws A 'MessageManager - Message not found for id: "${msgId}".' if there is no Message in this.messages with an + * id equal to the passed msgId + * @fires An event with a name equal to `${msgId}:${status}`. The Message is also fired. + * @fires If status is 'rejected' or 'signed', an event with a name equal to `${msgId}:finished` is fired along with the message + * + */ _setMsgStatus (msgId, status) { const msg = this.getMsg(msgId) if (!msg) throw new Error('MessageManager - Message not found for id: "${msgId}".') @@ -91,6 +198,14 @@ module.exports = class MessageManager extends EventEmitter { } } + /** + * Sets a Message in this.messages to the passed Message if the ids are equal. Then saves the unapprovedMsg list to + * storage via this._saveMsgList + * + * @private + * @param {msg} Message A Message that will replace an existing Message (with the same id) in this.messages + * + */ _updateMsg (msg) { const index = this.messages.findIndex((message) => message.id === msg.id) if (index !== -1) { @@ -99,6 +214,13 @@ module.exports = class MessageManager extends EventEmitter { this._saveMsgList() } + /** + * Saves the unapproved messages, and their count, to this.memStore + * + * @private + * @fires 'updateBadge' + * + */ _saveMsgList () { const unapprovedMsgs = this.getUnapprovedMsgs() const unapprovedMsgCount = Object.keys(unapprovedMsgs).length @@ -108,6 +230,13 @@ module.exports = class MessageManager extends EventEmitter { } +/** + * A helper function that converts raw buffer data to a hex, or just returns the data if it is already formatted as a hex. + * + * @param {any} data The buffer data to convert to a hex + * @returns {string} A hex string conversion of the buffer data + * + */ function normalizeMsgData (data) { if (data.slice(0, 2) === '0x') { // data is already hex diff --git a/app/scripts/lib/migrator/index.js b/app/scripts/lib/migrator/index.js index 85c2717ea..345ca8001 100644 --- a/app/scripts/lib/migrator/index.js +++ b/app/scripts/lib/migrator/index.js @@ -1,7 +1,23 @@ const EventEmitter = require('events') +/** + * @typedef {object} Migration + * @property {number} version - The migration version + * @property {Function} migrate - Returns a promise of the migrated data + */ + +/** + * @typedef {object} MigratorOptions + * @property {Array<Migration>} [migrations] - The list of migrations to apply + * @property {number} [defaultVersion] - The version to use in the initial state + */ + class Migrator extends EventEmitter { + /** + * @constructor + * @param {MigratorOptions} opts + */ constructor (opts = {}) { super() const migrations = opts.migrations || [] @@ -42,19 +58,30 @@ class Migrator extends EventEmitter { return versionedData - // migration is "pending" if it has a higher - // version number than currentVersion + /** + * Returns whether or not the migration is pending + * + * A migration is considered "pending" if it has a higher + * version number than the current version. + * @param {Migration} migration + * @returns {boolean} + */ function migrationIsPending (migration) { return migration.version > versionedData.meta.version } } - generateInitialState (initState) { + /** + * Returns the initial state for the migrator + * @param {object} [data] - The data for the initial state + * @returns {{meta: {version: number}, data: any}} + */ + generateInitialState (data) { return { meta: { version: this.defaultVersion, }, - data: initState, + data, } } diff --git a/app/scripts/lib/nodeify.js b/app/scripts/lib/nodeify.js index 9b595d93c..25be6537b 100644 --- a/app/scripts/lib/nodeify.js +++ b/app/scripts/lib/nodeify.js @@ -1,6 +1,14 @@ const promiseToCallback = require('promise-to-callback') const noop = function () {} +/** + * A generator that returns a function which, when passed a promise, can treat that promise as a node style callback. + * The prime advantage being that callbacks are better for error handling. + * + * @param {Function} fn The function to handle as a callback + * @param {Object} context The context in which the fn is to be called, most often a this reference + * + */ module.exports = function nodeify (fn, context) { return function () { const args = [].slice.call(arguments) diff --git a/app/scripts/lib/notification-manager.js b/app/scripts/lib/notification-manager.js index 1fcb7cf69..5dfb42078 100644 --- a/app/scripts/lib/notification-manager.js +++ b/app/scripts/lib/notification-manager.js @@ -5,10 +5,18 @@ const width = 360 class NotificationManager { - // - // Public - // + /** + * A collection of methods for controlling the showing and hiding of the notification popup. + * + * @typedef {Object} NotificationManager + * + */ + /** + * Either brings an existing MetaMask notification window into focus, or creates a new notification window. New + * notification windows are given a 'popup' type. + * + */ showPopup () { this._getPopup((err, popup) => { if (err) throw err @@ -29,6 +37,10 @@ class NotificationManager { }) } + /** + * Closes a MetaMask notification if it window exists. + * + */ closePopup () { // closes notification popup this._getPopup((err, popup) => { @@ -38,10 +50,14 @@ class NotificationManager { }) } - // - // Private - // - + /** + * Checks all open MetaMask windows, and returns the first one it finds that is a notification window (i.e. has the + * type 'popup') + * + * @private + * @param {Function} cb A node style callback that to whcih the found notification window will be passed. + * + */ _getPopup (cb) { this._getWindows((err, windows) => { if (err) throw err @@ -49,6 +65,13 @@ class NotificationManager { }) } + /** + * Returns all open MetaMask windows. + * + * @private + * @param {Function} cb A node style callback that to which the windows will be passed. + * + */ _getWindows (cb) { // Ignore in test environment if (!extension.windows) { @@ -60,6 +83,13 @@ class NotificationManager { }) } + /** + * Given an array of windows, returns the first that has a 'popup' type, or null if no such window exists. + * + * @private + * @param {array} windows An array of objects containing data about the open MetaMask extension windows. + * + */ _getPopupIn (windows) { return windows ? windows.find((win) => { // Returns notification popup diff --git a/app/scripts/lib/pending-balance-calculator.js b/app/scripts/lib/pending-balance-calculator.js index 6ae526463..0f1dc19a9 100644 --- a/app/scripts/lib/pending-balance-calculator.js +++ b/app/scripts/lib/pending-balance-calculator.js @@ -3,16 +3,28 @@ const normalize = require('eth-sig-util').normalize class PendingBalanceCalculator { - // Must be initialized with two functions: - // getBalance => Returns a promise of a BN of the current balance in Wei - // getPendingTransactions => Returns an array of TxMeta Objects, - // which have txParams properties, which include value, gasPrice, and gas, - // all in a base=16 hex format. + /** + * Used for calculating a users "pending balance": their current balance minus the total possible cost of all their + * pending transactions. + * + * @typedef {Object} PendingBalanceCalculator + * @param {Function} getBalance Returns a promise of a BN of the current balance in Wei + * @param {Function} getPendingTransactions Returns an array of TxMeta Objects, which have txParams properties, + * which include value, gasPrice, and gas, all in a base=16 hex format. + * + */ constructor ({ getBalance, getPendingTransactions }) { this.getPendingTransactions = getPendingTransactions this.getNetworkBalance = getBalance } + /** + * Returns the users "pending balance": their current balance minus the total possible cost of all their + * pending transactions. + * + * @returns {Promise<string>} Promises a base 16 hex string that contains the user's "pending balance" + * + */ async getBalance () { const results = await Promise.all([ this.getNetworkBalance(), @@ -29,6 +41,15 @@ class PendingBalanceCalculator { return `0x${balance.sub(pendingValue).toString(16)}` } + /** + * Calculates the maximum possible cost of a single transaction, based on the value, gas price and gas limit. + * + * @param {object} tx Contains all that data about a transaction. + * @property {object} tx.txParams Contains data needed to calculate the maximum cost of the transaction: gas, + * gasLimit and value. + * + * @returns {string} Returns a base 16 hex string that contains the maximum possible cost of the transaction. + */ calculateMaxCost (tx) { const txValue = tx.txParams.value const value = this.hexToBn(txValue) @@ -42,6 +63,13 @@ class PendingBalanceCalculator { return value.add(gasCost) } + /** + * Converts a hex string to a BN object + * + * @param {string} hex A number represented as a hex string + * @returns {Object} A BN object + * + */ hexToBn (hex) { return new BN(normalize(hex).substring(2), 16) } diff --git a/app/scripts/lib/personal-message-manager.js b/app/scripts/lib/personal-message-manager.js index 43a7d0b42..e96ced1f2 100644 --- a/app/scripts/lib/personal-message-manager.js +++ b/app/scripts/lib/personal-message-manager.js @@ -5,8 +5,37 @@ const createId = require('./random-id') const hexRe = /^[0-9A-Fa-f]+$/g const log = require('loglevel') +/** + * Represents, and contains data about, an 'personal_sign' type signature request. These are created when a + * signature for an personal_sign call is requested. + * + * @see {@link https://web3js.readthedocs.io/en/1.0/web3-eth-personal.html#sign} + * + * @typedef {Object} PersonalMessage + * @property {number} id An id to track and identify the message object + * @property {Object} msgParams The parameters to pass to the personal_sign method once the signature request is + * approved. + * @property {Object} msgParams.metamaskId Added to msgParams for tracking and identification within MetaMask. + * @property {string} msgParams.data A hex string conversion of the raw buffer data of the signature request + * @property {number} time The epoch time at which the this message was created + * @property {string} status Indicates whether the signature request is 'unapproved', 'approved', 'signed' or 'rejected' + * @property {string} type The json-prc signing method for which a signature request has been made. A 'Message' will + * always have a 'personal_sign' type. + * + */ module.exports = class PersonalMessageManager extends EventEmitter { + /** + * Controller in charge of managing - storing, adding, removing, updating - PersonalMessage. + * + * @typedef {Object} PersonalMessageManager + * @param {Object} opts @deprecated + * @property {Object} memStore The observable store where PersonalMessage are saved with persistance. + * @property {Object} memStore.unapprovedPersonalMsgs A collection of all PersonalMessages in the 'unapproved' state + * @property {number} memStore.unapprovedPersonalMsgCount The count of all PersonalMessages in this.memStore.unapprobedMsgs + * @property {array} messages Holds all messages that have been created by this PersonalMessageManager + * + */ constructor (opts) { super() this.memStore = new ObservableStore({ @@ -16,15 +45,37 @@ module.exports = class PersonalMessageManager extends EventEmitter { this.messages = [] } + /** + * A getter for the number of 'unapproved' PersonalMessages in this.messages + * + * @returns {number} The number of 'unapproved' PersonalMessages in this.messages + * + */ get unapprovedPersonalMsgCount () { return Object.keys(this.getUnapprovedMsgs()).length } + /** + * A getter for the 'unapproved' PersonalMessages in this.messages + * + * @returns {Object} An index of PersonalMessage ids to PersonalMessages, for all 'unapproved' PersonalMessages in + * this.messages + * + */ getUnapprovedMsgs () { return this.messages.filter(msg => msg.status === 'unapproved') .reduce((result, msg) => { result[msg.id] = msg; return result }, {}) } + /** + * Creates a new PersonalMessage with an 'unapproved' status using the passed msgParams. this.addMsg is called to add + * the new PersonalMessage to this.messages, and to save the unapproved PersonalMessages from that list to + * this.memStore. + * + * @param {Object} msgParams The params for the eth_sign call to be made after the message is approved. + * @returns {number} The id of the newly created PersonalMessage. + * + */ addUnapprovedMessage (msgParams) { log.debug(`PersonalMessageManager addUnapprovedMessage: ${JSON.stringify(msgParams)}`) msgParams.data = this.normalizeMsgData(msgParams.data) @@ -45,24 +96,62 @@ module.exports = class PersonalMessageManager extends EventEmitter { return msgId } + /** + * Adds a passed PersonalMessage to this.messages, and calls this._saveMsgList() to save the unapproved PersonalMessages from that + * list to this.memStore. + * + * @param {Message} msg The PersonalMessage to add to this.messages + * + */ addMsg (msg) { this.messages.push(msg) this._saveMsgList() } + /** + * Returns a specified PersonalMessage. + * + * @param {number} msgId The id of the PersonalMessage to get + * @returns {PersonalMessage|undefined} The PersonalMessage with the id that matches the passed msgId, or undefined + * if no PersonalMessage has that id. + * + */ getMsg (msgId) { return this.messages.find(msg => msg.id === msgId) } + /** + * Approves a PersonalMessage. Sets the message status via a call to this.setMsgStatusApproved, and returns a promise + * with any the message params modified for proper signing. + * + * @param {Object} msgParams The msgParams to be used when eth_sign is called, plus data added by MetaMask. + * @param {Object} msgParams.metamaskId Added to msgParams for tracking and identification within MetaMask. + * @returns {Promise<object>} Promises the msgParams object with metamaskId removed. + * + */ approveMessage (msgParams) { this.setMsgStatusApproved(msgParams.metamaskId) return this.prepMsgForSigning(msgParams) } + /** + * Sets a PersonalMessage status to 'approved' via a call to this._setMsgStatus. + * + * @param {number} msgId The id of the PersonalMessage to approve. + * + */ setMsgStatusApproved (msgId) { this._setMsgStatus(msgId, 'approved') } + /** + * Sets a PersonalMessage status to 'signed' via a call to this._setMsgStatus and updates that PersonalMessage in + * this.messages by adding the raw signature data of the signature request to the PersonalMessage + * + * @param {number} msgId The id of the PersonalMessage to sign. + * @param {buffer} rawSig The raw data of the signature request + * + */ setMsgStatusSigned (msgId, rawSig) { const msg = this.getMsg(msgId) msg.rawSig = rawSig @@ -70,19 +159,41 @@ module.exports = class PersonalMessageManager extends EventEmitter { this._setMsgStatus(msgId, 'signed') } + /** + * Removes the metamaskId property from passed msgParams and returns a promise which resolves the updated msgParams + * + * @param {Object} msgParams The msgParams to modify + * @returns {Promise<object>} Promises the msgParams with the metamaskId property removed + * + */ prepMsgForSigning (msgParams) { delete msgParams.metamaskId return Promise.resolve(msgParams) } + /** + * Sets a PersonalMessage status to 'rejected' via a call to this._setMsgStatus. + * + * @param {number} msgId The id of the PersonalMessage to reject. + * + */ rejectMsg (msgId) { this._setMsgStatus(msgId, 'rejected') } - // - // PRIVATE METHODS - // - + /** + * Updates the status of a PersonalMessage in this.messages via a call to this._updateMsg + * + * @private + * @param {number} msgId The id of the PersonalMessage to update. + * @param {string} status The new status of the PersonalMessage. + * @throws A 'PersonalMessageManager - PersonalMessage not found for id: "${msgId}".' if there is no PersonalMessage + * in this.messages with an id equal to the passed msgId + * @fires An event with a name equal to `${msgId}:${status}`. The PersonalMessage is also fired. + * @fires If status is 'rejected' or 'signed', an event with a name equal to `${msgId}:finished` is fired along + * with the PersonalMessage + * + */ _setMsgStatus (msgId, status) { const msg = this.getMsg(msgId) if (!msg) throw new Error('PersonalMessageManager - Message not found for id: "${msgId}".') @@ -94,6 +205,15 @@ module.exports = class PersonalMessageManager extends EventEmitter { } } + /** + * Sets a PersonalMessage in this.messages to the passed PersonalMessage if the ids are equal. Then saves the + * unapprovedPersonalMsgs index to storage via this._saveMsgList + * + * @private + * @param {msg} PersonalMessage A PersonalMessage that will replace an existing PersonalMessage (with the same + * id) in this.messages + * + */ _updateMsg (msg) { const index = this.messages.findIndex((message) => message.id === msg.id) if (index !== -1) { @@ -102,6 +222,13 @@ module.exports = class PersonalMessageManager extends EventEmitter { this._saveMsgList() } + /** + * Saves the unapproved PersonalMessages, and their count, to this.memStore + * + * @private + * @fires 'updateBadge' + * + */ _saveMsgList () { const unapprovedPersonalMsgs = this.getUnapprovedMsgs() const unapprovedPersonalMsgCount = Object.keys(unapprovedPersonalMsgs).length @@ -109,6 +236,13 @@ module.exports = class PersonalMessageManager extends EventEmitter { this.emit('updateBadge') } + /** + * A helper function that converts raw buffer data to a hex, or just returns the data if it is already formatted as a hex. + * + * @param {any} data The buffer data to convert to a hex + * @returns {string} A hex string conversion of the buffer data + * + */ normalizeMsgData (data) { try { const stripped = ethUtil.stripHexPrefix(data) diff --git a/app/scripts/lib/port-stream.js b/app/scripts/lib/port-stream.js index a9716fb00..5c4224fd9 100644 --- a/app/scripts/lib/port-stream.js +++ b/app/scripts/lib/port-stream.js @@ -6,6 +6,13 @@ module.exports = PortDuplexStream inherits(PortDuplexStream, Duplex) +/** + * Creates a stream that's both readable and writable. + * The stream supports arbitrary objects. + * + * @class + * @param {Object} port Remote Port object + */ function PortDuplexStream (port) { Duplex.call(this, { objectMode: true, @@ -15,8 +22,13 @@ function PortDuplexStream (port) { port.onDisconnect.addListener(this._onDisconnect.bind(this)) } -// private - +/** + * Callback triggered when a message is received from + * the remote Port associated with this Stream. + * + * @private + * @param {Object} msg - Payload from the onMessage listener of Port + */ PortDuplexStream.prototype._onMessage = function (msg) { if (Buffer.isBuffer(msg)) { delete msg._isBuffer @@ -27,14 +39,31 @@ PortDuplexStream.prototype._onMessage = function (msg) { } } +/** + * Callback triggered when the remote Port + * associated with this Stream disconnects. + * + * @private + */ PortDuplexStream.prototype._onDisconnect = function () { this.destroy() } -// stream plumbing - +/** + * Explicitly sets read operations to a no-op + */ PortDuplexStream.prototype._read = noop + +/** + * Called internally when data should be written to + * this writable stream. + * + * @private + * @param {*} msg Arbitrary object to write + * @param {string} encoding Encoding to use when writing payload + * @param {Function} cb Called when writing is complete or an error occurs + */ PortDuplexStream.prototype._write = function (msg, encoding, cb) { try { if (Buffer.isBuffer(msg)) { diff --git a/app/scripts/lib/seed-phrase-verifier.js b/app/scripts/lib/seed-phrase-verifier.js index 7ba712c0d..3b5afb800 100644 --- a/app/scripts/lib/seed-phrase-verifier.js +++ b/app/scripts/lib/seed-phrase-verifier.js @@ -3,11 +3,19 @@ const log = require('loglevel') const seedPhraseVerifier = { - // Verifies if the seed words can restore the accounts. - // - // The seed words can recreate the primary keyring and the accounts belonging to it. - // The created accounts in the primary keyring are always the same. - // The keyring always creates the accounts in the same sequence. + /** + * Verifies if the seed words can restore the accounts. + * + * Key notes: + * - The seed words can recreate the primary keyring and the accounts belonging to it. + * - The created accounts in the primary keyring are always the same. + * - The keyring always creates the accounts in the same sequence. + * + * @param {array} createdAccounts The accounts to restore + * @param {string} seedWords The seed words to verify + * @returns {Promise<void>} Promises undefined + * + */ verifyAccounts (createdAccounts, seedWords) { return new Promise((resolve, reject) => { diff --git a/app/scripts/lib/setupMetamaskMeshMetrics.js b/app/scripts/lib/setupMetamaskMeshMetrics.js index 40343f017..02690a948 100644 --- a/app/scripts/lib/setupMetamaskMeshMetrics.js +++ b/app/scripts/lib/setupMetamaskMeshMetrics.js @@ -1,6 +1,9 @@ module.exports = setupMetamaskMeshMetrics +/** + * Injects an iframe into the current document for testing + */ function setupMetamaskMeshMetrics() { const testingContainer = document.createElement('iframe') testingContainer.src = 'https://metamask.github.io/mesh-testing/' diff --git a/app/scripts/lib/setupRaven.js b/app/scripts/lib/setupRaven.js index 9ec9a256f..b1b67f771 100644 --- a/app/scripts/lib/setupRaven.js +++ b/app/scripts/lib/setupRaven.js @@ -23,22 +23,16 @@ function setupRaven(opts) { release, transport: function(opts) { const report = opts.data - // simplify certain complex error messages - report.exception.values.forEach(item => { - let errorMessage = item.value - // simplify ethjs error messages - errorMessage = extractEthjsErrorMessage(errorMessage) - // simplify 'Transaction Failed: known transaction' - if (errorMessage.indexOf('Transaction Failed: known transaction') === 0) { - // cut the hash from the error message - errorMessage = 'Transaction Failed: known transaction' - } - // finalize - item.value = errorMessage - }) - - // modify report urls - rewriteReportUrls(report) + try { + // handle error-like non-error exceptions + nonErrorException(report) + // simplify certain complex error messages (e.g. Ethjs) + simplifyErrorMessages(report) + // modify report urls + rewriteReportUrls(report) + } catch (err) { + console.warn(err) + } // make request normally client._makeRequest(opts) }, @@ -48,15 +42,42 @@ function setupRaven(opts) { return Raven } +function nonErrorException(report) { + // handle errors that lost their error-ness in serialization + if (report.message.includes('Non-Error exception captured with keys: message')) { + if (!(report.extra && report.extra.__serialized__)) return + report.message = `Non-Error Exception: ${report.extra.__serialized__.message}` + } +} + +function simplifyErrorMessages(report) { + if (report.exception && report.exception.values) { + report.exception.values.forEach(item => { + let errorMessage = item.value + // simplify ethjs error messages + errorMessage = extractEthjsErrorMessage(errorMessage) + // simplify 'Transaction Failed: known transaction' + if (errorMessage.indexOf('Transaction Failed: known transaction') === 0) { + // cut the hash from the error message + errorMessage = 'Transaction Failed: known transaction' + } + // finalize + item.value = errorMessage + }) + } +} + function rewriteReportUrls(report) { // update request url report.request.url = toMetamaskUrl(report.request.url) // update exception stack trace - report.exception.values.forEach(item => { - item.stacktrace.frames.forEach(frame => { - frame.filename = toMetamaskUrl(frame.filename) + if (report.exception && report.exception.values) { + report.exception.values.forEach(item => { + item.stacktrace.frames.forEach(frame => { + frame.filename = toMetamaskUrl(frame.filename) + }) }) - }) + } } function toMetamaskUrl(origUrl) { diff --git a/app/scripts/lib/stream-utils.js b/app/scripts/lib/stream-utils.js index 8bb0b4f3c..3dbc064b5 100644 --- a/app/scripts/lib/stream-utils.js +++ b/app/scripts/lib/stream-utils.js @@ -8,20 +8,34 @@ module.exports = { setupMultiplex: setupMultiplex, } +/** + * Returns a stream transform that parses JSON strings passing through + * @return {stream.Transform} + */ function jsonParseStream () { - return Through.obj(function (serialized, encoding, cb) { + return Through.obj(function (serialized, _, cb) { this.push(JSON.parse(serialized)) cb() }) } +/** + * Returns a stream transform that calls {@code JSON.stringify} + * on objects passing through + * @return {stream.Transform} the stream transform + */ function jsonStringifyStream () { - return Through.obj(function (obj, encoding, cb) { + return Through.obj(function (obj, _, cb) { this.push(JSON.stringify(obj)) cb() }) } +/** + * Sets up stream multiplexing for the given stream + * @param {any} connectionStream - the stream to mux + * @return {stream.Stream} the multiplexed stream + */ function setupMultiplex (connectionStream) { const mux = new ObjectMultiplex() pump( diff --git a/app/scripts/lib/typed-message-manager.js b/app/scripts/lib/typed-message-manager.js index 60042155e..c58921610 100644 --- a/app/scripts/lib/typed-message-manager.js +++ b/app/scripts/lib/typed-message-manager.js @@ -5,7 +5,36 @@ const assert = require('assert') const sigUtil = require('eth-sig-util') const log = require('loglevel') +/** + * Represents, and contains data about, an 'eth_signTypedData' type signature request. These are created when a + * signature for an eth_signTypedData call is requested. + * + * @typedef {Object} TypedMessage + * @property {number} id An id to track and identify the message object + * @property {Object} msgParams The parameters to pass to the eth_signTypedData method once the signature request is + * approved. + * @property {Object} msgParams.metamaskId Added to msgParams for tracking and identification within MetaMask. + * @property {Object} msgParams.from The address that is making the signature request. + * @property {string} msgParams.data A hex string conversion of the raw buffer data of the signature request + * @property {number} time The epoch time at which the this message was created + * @property {string} status Indicates whether the signature request is 'unapproved', 'approved', 'signed' or 'rejected' + * @property {string} type The json-prc signing method for which a signature request has been made. A 'Message' will + * always have a 'eth_signTypedData' type. + * + */ + module.exports = class TypedMessageManager extends EventEmitter { + /** + * Controller in charge of managing - storing, adding, removing, updating - TypedMessage. + * + * @typedef {Object} TypedMessage + * @param {Object} opts @deprecated + * @property {Object} memStore The observable store where TypedMessage are saved. + * @property {Object} memStore.unapprovedTypedMessages A collection of all TypedMessages in the 'unapproved' state + * @property {number} memStore.unapprovedTypedMessagesCount The count of all TypedMessages in this.memStore.unapprobedMsgs + * @property {array} messages Holds all messages that have been created by this TypedMessage + * + */ constructor (opts) { super() this.memStore = new ObservableStore({ @@ -15,15 +44,37 @@ module.exports = class TypedMessageManager extends EventEmitter { this.messages = [] } + /** + * A getter for the number of 'unapproved' TypedMessages in this.messages + * + * @returns {number} The number of 'unapproved' TypedMessages in this.messages + * + */ get unapprovedTypedMessagesCount () { return Object.keys(this.getUnapprovedMsgs()).length } + /** + * A getter for the 'unapproved' TypedMessages in this.messages + * + * @returns {Object} An index of TypedMessage ids to TypedMessages, for all 'unapproved' TypedMessages in + * this.messages + * + */ getUnapprovedMsgs () { return this.messages.filter(msg => msg.status === 'unapproved') .reduce((result, msg) => { result[msg.id] = msg; return result }, {}) } + /** + * Creates a new TypedMessage with an 'unapproved' status using the passed msgParams. this.addMsg is called to add + * the new TypedMessage to this.messages, and to save the unapproved TypedMessages from that list to + * this.memStore. Before any of this is done, msgParams are validated + * + * @param {Object} msgParams The params for the eth_sign call to be made after the message is approved. + * @returns {number} The id of the newly created TypedMessage. + * + */ addUnapprovedMessage (msgParams) { this.validateParams(msgParams) @@ -45,6 +96,12 @@ module.exports = class TypedMessageManager extends EventEmitter { return msgId } + /** + * Helper method for this.addUnapprovedMessage. Validates that the passed params have the required properties. + * + * @param {Object} params The params to validate + * + */ validateParams (params) { assert.equal(typeof params, 'object', 'Params should ben an object.') assert.ok('data' in params, 'Params must include a data field.') @@ -56,24 +113,62 @@ module.exports = class TypedMessageManager extends EventEmitter { }, 'Expected EIP712 typed data') } + /** + * Adds a passed TypedMessage to this.messages, and calls this._saveMsgList() to save the unapproved TypedMessages from that + * list to this.memStore. + * + * @param {Message} msg The TypedMessage to add to this.messages + * + */ addMsg (msg) { this.messages.push(msg) this._saveMsgList() } + /** + * Returns a specified TypedMessage. + * + * @param {number} msgId The id of the TypedMessage to get + * @returns {TypedMessage|undefined} The TypedMessage with the id that matches the passed msgId, or undefined + * if no TypedMessage has that id. + * + */ getMsg (msgId) { return this.messages.find(msg => msg.id === msgId) } + /** + * Approves a TypedMessage. Sets the message status via a call to this.setMsgStatusApproved, and returns a promise + * with any the message params modified for proper signing. + * + * @param {Object} msgParams The msgParams to be used when eth_sign is called, plus data added by MetaMask. + * @param {Object} msgParams.metamaskId Added to msgParams for tracking and identification within MetaMask. + * @returns {Promise<object>} Promises the msgParams object with metamaskId removed. + * + */ approveMessage (msgParams) { this.setMsgStatusApproved(msgParams.metamaskId) return this.prepMsgForSigning(msgParams) } + /** + * Sets a TypedMessage status to 'approved' via a call to this._setMsgStatus. + * + * @param {number} msgId The id of the TypedMessage to approve. + * + */ setMsgStatusApproved (msgId) { this._setMsgStatus(msgId, 'approved') } + /** + * Sets a TypedMessage status to 'signed' via a call to this._setMsgStatus and updates that TypedMessage in + * this.messages by adding the raw signature data of the signature request to the TypedMessage + * + * @param {number} msgId The id of the TypedMessage to sign. + * @param {buffer} rawSig The raw data of the signature request + * + */ setMsgStatusSigned (msgId, rawSig) { const msg = this.getMsg(msgId) msg.rawSig = rawSig @@ -81,11 +176,24 @@ module.exports = class TypedMessageManager extends EventEmitter { this._setMsgStatus(msgId, 'signed') } + /** + * Removes the metamaskId property from passed msgParams and returns a promise which resolves the updated msgParams + * + * @param {Object} msgParams The msgParams to modify + * @returns {Promise<object>} Promises the msgParams with the metamaskId property removed + * + */ prepMsgForSigning (msgParams) { delete msgParams.metamaskId return Promise.resolve(msgParams) } + /** + * Sets a TypedMessage status to 'rejected' via a call to this._setMsgStatus. + * + * @param {number} msgId The id of the TypedMessage to reject. + * + */ rejectMsg (msgId) { this._setMsgStatus(msgId, 'rejected') } @@ -94,6 +202,19 @@ module.exports = class TypedMessageManager extends EventEmitter { // PRIVATE METHODS // + /** + * Updates the status of a TypedMessage in this.messages via a call to this._updateMsg + * + * @private + * @param {number} msgId The id of the TypedMessage to update. + * @param {string} status The new status of the TypedMessage. + * @throws A 'TypedMessageManager - TypedMessage not found for id: "${msgId}".' if there is no TypedMessage + * in this.messages with an id equal to the passed msgId + * @fires An event with a name equal to `${msgId}:${status}`. The TypedMessage is also fired. + * @fires If status is 'rejected' or 'signed', an event with a name equal to `${msgId}:finished` is fired along + * with the TypedMessage + * + */ _setMsgStatus (msgId, status) { const msg = this.getMsg(msgId) if (!msg) throw new Error('TypedMessageManager - Message not found for id: "${msgId}".') @@ -105,6 +226,15 @@ module.exports = class TypedMessageManager extends EventEmitter { } } + /** + * Sets a TypedMessage in this.messages to the passed TypedMessage if the ids are equal. Then saves the + * unapprovedTypedMsgs index to storage via this._saveMsgList + * + * @private + * @param {msg} TypedMessage A TypedMessage that will replace an existing TypedMessage (with the same + * id) in this.messages + * + */ _updateMsg (msg) { const index = this.messages.findIndex((message) => message.id === msg.id) if (index !== -1) { @@ -113,6 +243,13 @@ module.exports = class TypedMessageManager extends EventEmitter { this._saveMsgList() } + /** + * Saves the unapproved TypedMessages, and their count, to this.memStore + * + * @private + * @fires 'updateBadge' + * + */ _saveMsgList () { const unapprovedTypedMessages = this.getUnapprovedMsgs() const unapprovedTypedMessagesCount = Object.keys(unapprovedTypedMessages).length diff --git a/app/scripts/lib/util.js b/app/scripts/lib/util.js index df815906f..431d1e59c 100644 --- a/app/scripts/lib/util.js +++ b/app/scripts/lib/util.js @@ -7,11 +7,26 @@ const { ENVIRONMENT_TYPE_FULLSCREEN, } = require('./enums') +/** + * Generates an example stack trace + * + * @returns {string} A stack trace + * + */ function getStack () { const stack = new Error('Stack trace generator - not an error').stack return stack } +/** + * Used to determine the window type through which the app is being viewed. + * - 'popup' refers to the extension opened through the browser app icon (in top right corner in chrome and firefox) + * - 'responsive' refers to the main browser window + * - 'notification' refers to the popup that appears in its own window when taking action outside of metamask + * + * @returns {string} A single word label that represents the type of window through which the app is being viewed + * + */ const getEnvironmentType = (url = window.location.href) => { if (url.match(/popup.html(?:\?.+)*$/)) { return ENVIRONMENT_TYPE_POPUP @@ -22,6 +37,17 @@ const getEnvironmentType = (url = window.location.href) => { } } +/** + * Checks whether a given balance of ETH, represented as a hex string, is sufficient to pay a value plus a gas fee + * + * @param {object} txParams Contains data about a transaction + * @param {string} txParams.gas The gas for a transaction + * @param {string} txParams.gasPrice The price per gas for the transaction + * @param {string} txParams.value The value of ETH to send + * @param {string} hexBalance A balance of ETH represented as a hex string + * @returns {boolean} Whether the balance is greater than or equal to the value plus the value of gas times gasPrice + * + */ function sufficientBalance (txParams, hexBalance) { // validate hexBalance is a hex string assert.equal(typeof hexBalance, 'string', 'sufficientBalance - hexBalance is not a hex string') @@ -36,14 +62,37 @@ function sufficientBalance (txParams, hexBalance) { return balance.gte(maxCost) } +/** + * Converts a BN object to a hex string with a '0x' prefix + * + * @param {BN} inputBn The BN to convert to a hex string + * @returns {string} A '0x' prefixed hex string + * + */ function bnToHex (inputBn) { return ethUtil.addHexPrefix(inputBn.toString(16)) } +/** + * Converts a hex string to a BN object + * + * @param {string} inputHex A number represented as a hex string + * @returns {Object} A BN object + * + */ function hexToBn (inputHex) { return new BN(ethUtil.stripHexPrefix(inputHex), 16) } +/** + * Used to multiply a BN by a fraction + * + * @param {BN} targetBN The number to multiply by a fraction + * @param {number|string} numerator The numerator of the fraction multiplier + * @param {number|string} denominator The denominator of the fraction multiplier + * @returns {BN} The product of the multiplication + * + */ function BnMultiplyByFraction (targetBN, numerator, denominator) { const numBN = new BN(numerator) const denomBN = new BN(denominator) diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index a12b6776e..c4a73d8ea 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -263,6 +263,7 @@ module.exports = class MetamaskController extends EventEmitter { /** * Constructor helper: initialize a public config store. + * This store is used to make some config info available to Dapps synchronously. */ initPublicConfigStore () { // get init state @@ -313,9 +314,11 @@ module.exports = class MetamaskController extends EventEmitter { } /** - * Returns an api-object which is consumed by the UI + * Returns an Object containing API Callback Functions. + * These functions are the interface for the UI. + * The API object can be transmitted over a stream with dnode. * - * @returns {Object} + * @returns {Object} Object containing API functions. */ getApi () { const keyringController = this.keyringController @@ -405,16 +408,18 @@ module.exports = class MetamaskController extends EventEmitter { //============================================================================= /** - * Creates a new Vault(?) and create a new keychain(?) + * Creates a new Vault and create a new keychain. * - * A vault is ... + * A vault, or KeyringController, is a controller that contains + * many different account strategies, currently called Keyrings. + * Creating it new means wiping all previous keyrings. * - * A keychain is ... + * A keychain, or keyring, controls many accounts with a single backup and signing strategy. + * For example, a mnemonic phrase can generate many accounts, and is a keyring. * + * @param {string} password * - * @param {} password - * - * @returns {} vault + * @returns {Object} vault */ async createNewVaultAndKeychain (password) { const release = await this.createVaultMutex.acquire() @@ -440,7 +445,7 @@ module.exports = class MetamaskController extends EventEmitter { } /** - * Create a new Vault and restore an existent keychain + * Create a new Vault and restore an existent keyring. * @param {} password * @param {} seed */ @@ -458,10 +463,16 @@ module.exports = class MetamaskController extends EventEmitter { } /** + * @type Identity + * @property {string} name - The account nickname. + * @property {string} address - The account's ethereum address, in lower case. + * @property {boolean} mayBeFauceting - Whether this account is currently + * receiving funds from our automatic Ropsten faucet. + */ + + /** * Retrieves the first Identiy from the passed Vault and selects the related address * - * An Identity is ... - * * @param {} vault */ selectFirstIdentity (vault) { @@ -470,12 +481,12 @@ module.exports = class MetamaskController extends EventEmitter { this.preferencesController.setSelectedAddress(address) } - // ? - // Opinionated Keyring Management + // + // Account Management // /** - * Adds a new account to ... + * Adds a new account to the default (first) HD seed phrase Keyring. * * @returns {} keyState */ @@ -505,6 +516,8 @@ module.exports = class MetamaskController extends EventEmitter { * * Used when creating a first vault, to allow confirmation. * Also used when revealing the seed words in the confirmation view. + * + * @param {Function} cb - A callback called on completion. */ placeSeedWords (cb) { @@ -524,6 +537,8 @@ module.exports = class MetamaskController extends EventEmitter { * Validity: seed phrase restores the accounts belonging to the current vault. * * Called when the first account is created and on unlocking the vault. + * + * @returns {Promise<string>} Seed phrase to be confirmed by the user. */ async verifySeedPhrase () { @@ -554,6 +569,7 @@ module.exports = class MetamaskController extends EventEmitter { * * The seed phrase remains available in the background process. * + * @param {function} cb Callback function called with the current address. */ clearSeedWordCache (cb) { this.configManager.setSeedWords(null) @@ -561,9 +577,13 @@ module.exports = class MetamaskController extends EventEmitter { } /** - * ? + * Clears the transaction history, to allow users to force-reset their nonces. + * Mostly used in development environments, when networks are restarted with + * the same network ID. + * + * @returns Promise<string> The current selected address. */ - async resetAccount (cb) { + async resetAccount () { const selectedAddress = this.preferencesController.getSelectedAddress() this.txController.wipeTransactions(selectedAddress) @@ -575,11 +595,13 @@ module.exports = class MetamaskController extends EventEmitter { } /** - * Imports an account ... ? + * Imports an account with the specified import strategy. + * These are defined in app/scripts/account-import-strategies + * Each strategy represents a different way of serializing an Ethereum key pair. * - * @param {} strategy - * @param {} args - * @param {} cb + * @param {string} strategy - A unique identifier for an account import strategy. + * @param {any} args - The data required by that strategy to import an account. + * @param {Function} cb - A callback function called with a state update on success. */ importAccountWithStrategy (strategy, args, cb) { accountImporter.importAccount(strategy, args) @@ -593,13 +615,42 @@ module.exports = class MetamaskController extends EventEmitter { } // --------------------------------------------------------------------------- - // Identity Management (sign) + // Identity Management (signature operations) + + // eth_sign methods: + + /** + * Called when a Dapp uses the eth_sign method, to request user approval. + * eth_sign is a pure signature of arbitrary data. It is on a deprecation + * path, since this data can be a transaction, or can leak private key + * information. + * + * @param {Object} msgParams - The params passed to eth_sign. + * @param {Function} cb = The callback function called with the signature. + */ + newUnsignedMessage (msgParams, cb) { + const msgId = this.messageManager.addUnapprovedMessage(msgParams) + this.sendUpdate() + this.opts.showUnconfirmedMessage() + this.messageManager.once(`${msgId}:finished`, (data) => { + switch (data.status) { + case 'signed': + return cb(null, data.rawSig) + case 'rejected': + return cb(new Error('MetaMask Message Signature: User denied message signature.')) + default: + return cb(new Error(`MetaMask Message Signature: Unknown problem: ${JSON.stringify(msgParams)}`)) + } + }) + } /** - * @param {} msgParams - * @param {} cb + * Signifies user intent to complete an eth_sign method. + * + * @param {Object} msgParams The params passed to eth_call. + * @returns {Promise<Object>} Full state update. */ - signMessage (msgParams, cb) { + signMessage (msgParams) { log.info('MetaMaskController - signMessage') const msgId = msgParams.metamaskId @@ -618,14 +669,37 @@ module.exports = class MetamaskController extends EventEmitter { }) } - // Prefixed Style Message Signing Methods: + /** + * Used to cancel a message submitted via eth_sign. + * + * @param {string} msgId - The id of the message to cancel. + */ + cancelMessage (msgId, cb) { + const messageManager = this.messageManager + messageManager.rejectMsg(msgId) + if (cb && typeof cb === 'function') { + cb(null, this.getState()) + } + } + + // personal_sign methods: /** + * Called when a dapp uses the personal_sign method. + * This is identical to the Geth eth_sign method, and may eventually replace + * eth_sign. * - * @param {} msgParams - * @param {} cb + * We currently define our eth_sign and personal_sign mostly for legacy Dapps. + * + * @param {Object} msgParams - The params of the message to sign & return to the Dapp. + * @param {Function} cb - The callback function called with the signature. + * Passed back to the requesting Dapp. */ - approvePersonalMessage (msgParams, cb) { + newUnsignedPersonalMessage (msgParams, cb) { + if (!msgParams.from) { + return cb(new Error('MetaMask Message Signature: from field is required.')) + } + const msgId = this.personalMessageManager.addUnapprovedMessage(msgParams) this.sendUpdate() this.opts.showUnconfirmedMessage() @@ -634,7 +708,7 @@ module.exports = class MetamaskController extends EventEmitter { case 'signed': return cb(null, data.rawSig) case 'rejected': - return cb(new Error('MetaMask Message Signature: User denied transaction signature.')) + return cb(new Error('MetaMask Message Signature: User denied message signature.')) default: return cb(new Error(`MetaMask Message Signature: Unknown problem: ${JSON.stringify(msgParams)}`)) } @@ -642,7 +716,11 @@ module.exports = class MetamaskController extends EventEmitter { } /** - * @param {} msgParams + * Signifies a user's approval to sign a personal_sign message in queue. + * Triggers signing, and the callback function from newUnsignedPersonalMessage. + * + * @param {Object} msgParams - The params of the message to sign & return to the Dapp. + * @returns {Promise<Object>} - A full state update. */ signPersonalMessage (msgParams) { log.info('MetaMaskController - signPersonalMessage') @@ -663,7 +741,54 @@ module.exports = class MetamaskController extends EventEmitter { } /** - * @param {} msgParams + * Used to cancel a personal_sign type message. + * @param {string} msgId - The ID of the message to cancel. + * @param {Function} cb - The callback function called with a full state update. + */ + cancelPersonalMessage (msgId, cb) { + const messageManager = this.personalMessageManager + messageManager.rejectMsg(msgId) + if (cb && typeof cb === 'function') { + cb(null, this.getState()) + } + } + + // eth_signTypedData methods + + /** + * Called when a dapp uses the eth_signTypedData method, per EIP 712. + * + * @param {Object} msgParams - The params passed to eth_signTypedData. + * @param {Function} cb - The callback function, called with the signature. + */ + newUnsignedTypedMessage (msgParams, cb) { + let msgId + try { + msgId = this.typedMessageManager.addUnapprovedMessage(msgParams) + this.sendUpdate() + this.opts.showUnconfirmedMessage() + } catch (e) { + return cb(e) + } + + this.typedMessageManager.once(`${msgId}:finished`, (data) => { + switch (data.status) { + case 'signed': + return cb(null, data.rawSig) + case 'rejected': + return cb(new Error('MetaMask Message Signature: User denied message signature.')) + default: + return cb(new Error(`MetaMask Message Signature: Unknown problem: ${JSON.stringify(msgParams)}`)) + } + }) + } + + /** + * The method for a user approving a call to eth_signTypedData, per EIP 712. + * Triggers the callback in newUnsignedTypedMessage. + * + * @param {Object} msgParams - The params passed to eth_signTypedData. + * @returns {Object} Full state update. */ signTypedMessage (msgParams) { log.info('MetaMaskController - signTypedMessage') @@ -683,12 +808,30 @@ module.exports = class MetamaskController extends EventEmitter { }) } + /** + * Used to cancel a eth_signTypedData type message. + * @param {string} msgId - The ID of the message to cancel. + * @param {Function} cb - The callback function called with a full state update. + */ + cancelTypedMessage (msgId, cb) { + const messageManager = this.typedMessageManager + messageManager.rejectMsg(msgId) + if (cb && typeof cb === 'function') { + cb(null, this.getState()) + } + } + // --------------------------------------------------------------------------- - // Account Restauration + // MetaMask Version 3 Migration Account Restauration Methods /** - * ? + * A legacy method (probably dead code) that was used when we swapped out our + * key management library that we depended on. * + * Described in: + * https://medium.com/metamask/metamask-3-migration-guide-914b79533cdd + * + * @deprecated * @param {} migratorOutput */ restoreOldVaultAccounts (migratorOutput) { @@ -698,8 +841,26 @@ module.exports = class MetamaskController extends EventEmitter { } /** - * ? + * A legacy method used to record user confirmation that they understand + * that some of their accounts have been recovered but should be backed up. + * + * @deprecated + * @param {Function} cb - A callback function called with a full state update. + */ + markAccountsFound (cb) { + this.configManager.setLostAccounts([]) + this.sendUpdate() + cb(null, this.getState()) + } + + /** + * A legacy method (probably dead code) that was used when we swapped out our + * key management library that we depended on. * + * Described in: + * https://medium.com/metamask/metamask-3-migration-guide-914b79533cdd + * + * @deprecated * @param {} migratorOutput */ restoreOldLostAccounts (migratorOutput) { @@ -712,12 +873,23 @@ module.exports = class MetamaskController extends EventEmitter { } /** - * Import (lost) Accounts + * An account object + * @typedef Account + * @property string privateKey - The private key of the account. + */ + + /** + * Probably no longer needed, related to the Version 3 migration. + * Imports a hash of accounts to private keys into the vault. * - * @param {Object} {lostAccounts} @Array accounts <{ address, privateKey }> + * Described in: + * https://medium.com/metamask/metamask-3-migration-guide-914b79533cdd * * Uses the array's private keys to create a new Simple Key Pair keychain * and add it to the keyring controller. + * @deprecated + * @param {Account[]} lostAccounts - + * @returns {Keyring[]} An array of the restored keyrings. */ importLostAccounts ({ lostAccounts }) { const privKeys = lostAccounts.map(acct => acct.privateKey) @@ -731,113 +903,37 @@ module.exports = class MetamaskController extends EventEmitter { // END (VAULT / KEYRING RELATED METHODS) //============================================================================= -// - -//============================================================================= -// MESSAGES -//============================================================================= - + /** + * Allows a user to try to speed up a transaction by retrying it + * with higher gas. + * + * @param {string} txId - The ID of the transaction to speed up. + * @param {Function} cb - The callback function called with a full state update. + */ async retryTransaction (txId, cb) { await this.txController.retryTransaction(txId) const state = await this.getState() return state } +//============================================================================= +// PASSWORD MANAGEMENT +//============================================================================= - newUnsignedMessage (msgParams, cb) { - const msgId = this.messageManager.addUnapprovedMessage(msgParams) - this.sendUpdate() - this.opts.showUnconfirmedMessage() - this.messageManager.once(`${msgId}:finished`, (data) => { - switch (data.status) { - case 'signed': - return cb(null, data.rawSig) - case 'rejected': - return cb(new Error('MetaMask Message Signature: User denied message signature.')) - default: - return cb(new Error(`MetaMask Message Signature: Unknown problem: ${JSON.stringify(msgParams)}`)) - } - }) - } - - newUnsignedPersonalMessage (msgParams, cb) { - if (!msgParams.from) { - return cb(new Error('MetaMask Message Signature: from field is required.')) - } - - const msgId = this.personalMessageManager.addUnapprovedMessage(msgParams) - this.sendUpdate() - this.opts.showUnconfirmedMessage() - this.personalMessageManager.once(`${msgId}:finished`, (data) => { - switch (data.status) { - case 'signed': - return cb(null, data.rawSig) - case 'rejected': - return cb(new Error('MetaMask Message Signature: User denied message signature.')) - default: - return cb(new Error(`MetaMask Message Signature: Unknown problem: ${JSON.stringify(msgParams)}`)) - } - }) - } - - newUnsignedTypedMessage (msgParams, cb) { - let msgId - try { - msgId = this.typedMessageManager.addUnapprovedMessage(msgParams) - this.sendUpdate() - this.opts.showUnconfirmedMessage() - } catch (e) { - return cb(e) - } - - this.typedMessageManager.once(`${msgId}:finished`, (data) => { - switch (data.status) { - case 'signed': - return cb(null, data.rawSig) - case 'rejected': - return cb(new Error('MetaMask Message Signature: User denied message signature.')) - default: - return cb(new Error(`MetaMask Message Signature: Unknown problem: ${JSON.stringify(msgParams)}`)) - } - }) - } - - cancelMessage (msgId, cb) { - const messageManager = this.messageManager - messageManager.rejectMsg(msgId) - if (cb && typeof cb === 'function') { - cb(null, this.getState()) - } - } - - cancelPersonalMessage (msgId, cb) { - const messageManager = this.personalMessageManager - messageManager.rejectMsg(msgId) - if (cb && typeof cb === 'function') { - cb(null, this.getState()) - } - } - - cancelTypedMessage (msgId, cb) { - const messageManager = this.typedMessageManager - messageManager.rejectMsg(msgId) - if (cb && typeof cb === 'function') { - cb(null, this.getState()) - } - } - - markAccountsFound (cb) { - this.configManager.setLostAccounts([]) - this.sendUpdate() - cb(null, this.getState()) - } - + /** + * Allows a user to begin the seed phrase recovery process. + * @param {Function} cb - A callback function called when complete. + */ markPasswordForgotten(cb) { this.configManager.setPasswordForgotten(true) this.sendUpdate() cb() } + /** + * Allows a user to end the seed phrase recovery process. + * @param {Function} cb - A callback function called when complete. + */ unMarkPasswordForgotten(cb) { this.configManager.setPasswordForgotten(false) this.sendUpdate() @@ -848,6 +944,13 @@ module.exports = class MetamaskController extends EventEmitter { // SETUP //============================================================================= + /** + * Used to create a multiplexed stream for connecting to an untrusted context + * like a Dapp or other extension. + * @param {*} connectionStream - The Duplex stream to connect to. + * @param {string} originDomain - The domain requesting the stream, which + * may trigger a blacklist reload. + */ setupUntrustedCommunication (connectionStream, originDomain) { // Check if new connection is blacklisted if (this.blacklistController.checkForPhishing(originDomain)) { @@ -863,6 +966,16 @@ module.exports = class MetamaskController extends EventEmitter { this.setupPublicConfig(mux.createStream('publicConfig')) } + /** + * Used to create a multiplexed stream for connecting to a trusted context, + * like our own user interfaces, which have the provider APIs, but also + * receive the exported API from this controller, which includes trusted + * functions, like the ability to approve transactions or sign messages. + * + * @param {*} connectionStream - The duplex stream to connect to. + * @param {string} originDomain - The domain requesting the connection, + * used in logging and error reporting. + */ setupTrustedCommunication (connectionStream, originDomain) { // setup multiplexing const mux = setupMultiplex(connectionStream) @@ -871,12 +984,25 @@ module.exports = class MetamaskController extends EventEmitter { this.setupProviderConnection(mux.createStream('provider'), originDomain) } + /** + * Called when we detect a suspicious domain. Requests the browser redirects + * to our anti-phishing page. + * + * @private + * @param {*} connectionStream - The duplex stream to the per-page script, + * for sending the reload attempt to. + * @param {string} hostname - The URL that triggered the suspicion. + */ sendPhishingWarning (connectionStream, hostname) { const mux = setupMultiplex(connectionStream) const phishingStream = mux.createStream('phishing') phishingStream.write({ hostname }) } + /** + * A method for providing our API over a stream using Dnode. + * @param {*} outStream - The stream to provide our API over. + */ setupControllerConnection (outStream) { const api = this.getApi() const dnode = Dnode(api) @@ -895,6 +1021,11 @@ module.exports = class MetamaskController extends EventEmitter { }) } + /** + * A method for serving our ethereum provider over a given stream. + * @param {*} outStream - The stream to provide over. + * @param {string} origin - The URI of the requesting resource. + */ setupProviderConnection (outStream, origin) { // setup json rpc engine stack const engine = new RpcEngine() @@ -924,6 +1055,16 @@ module.exports = class MetamaskController extends EventEmitter { ) } + /** + * A method for providing our public config info over a stream. + * This includes info we like to be synchronous if possible, like + * the current selected account, and network ID. + * + * Since synchronous methods have been deprecated in web3, + * this is a good candidate for deprecation. + * + * @param {*} outStream - The stream to provide public config over. + */ setupPublicConfig (outStream) { pump( asStream(this.publicConfigStore), @@ -934,10 +1075,21 @@ module.exports = class MetamaskController extends EventEmitter { ) } + /** + * A method for emitting the full MetaMask state to all registered listeners. + * @private + */ privateSendUpdate () { this.emit('update', this.getState()) } + /** + * A method for estimating a good gas price at recent prices. + * Returns the lowest price that would have been included in + * 50% of recent blocks. + * + * @returns {string} A hex representation of the suggested wei gas price. + */ getGasPrice () { const { recentBlocksController } = this const { recentBlocks } = recentBlocksController.store.getState() @@ -971,6 +1123,11 @@ module.exports = class MetamaskController extends EventEmitter { // Log blocks + /** + * A method for setting the user's preferred display currency. + * @param {string} currencyCode - The code of the preferred currency. + * @param {Function} cb - A callback function returning currency info. + */ setCurrentCurrency (currencyCode, cb) { try { this.currencyController.setCurrentCurrency(currencyCode) @@ -986,6 +1143,13 @@ module.exports = class MetamaskController extends EventEmitter { } } + /** + * A method for forwarding the user to the easiest way to obtain ether, + * or the network "gas" currency, for the current selected network. + * + * @param {string} address - The address to fund. + * @param {string} amount - The amount of ether desired, as a base 10 string. + */ buyEth (address, amount) { if (!amount) amount = '5' const network = this.networkController.getNetworkState() @@ -993,18 +1157,33 @@ module.exports = class MetamaskController extends EventEmitter { if (url) this.platform.openWindow({ url }) } + /** + * A method for triggering a shapeshift currency transfer. + * @param {string} depositAddress - The address to deposit to. + * @property {string} depositType - An abbreviation of the type of crypto currency to be deposited. + */ createShapeShiftTx (depositAddress, depositType) { this.shapeshiftController.createShapeShiftTx(depositAddress, depositType) } // network - async setCustomRpc (rpcTarget, rpcList) { + /** + * A method for selecting a custom URL for an ethereum RPC provider. + * @param {string} rpcTarget - A URL for a valid Ethereum RPC API. + * @returns {Promise<String>} - The RPC Target URL confirmed. + */ + async setCustomRpc (rpcTarget) { this.networkController.setRpcTarget(rpcTarget) await this.preferencesController.updateFrequentRpcList(rpcTarget) return rpcTarget } + /** + * Sets whether or not to use the blockie identicon format. + * @param {boolean} val - True for bockie, false for jazzicon. + * @param {Function} cb - A callback function called when complete. + */ setUseBlockie (val, cb) { try { this.preferencesController.setUseBlockie(val) @@ -1014,6 +1193,11 @@ module.exports = class MetamaskController extends EventEmitter { } } + /** + * A method for setting a user's current locale, affecting the language rendered. + * @param {string} key - Locale identifier. + * @param {Function} cb - A callback function called when complete. + */ setCurrentLocale (key, cb) { try { this.preferencesController.setCurrentLocale(key) @@ -1023,6 +1207,11 @@ module.exports = class MetamaskController extends EventEmitter { } } + /** + * A method for initializing storage the first time. + * @param {Object} initState - The default state to initialize with. + * @private + */ recordFirstTimeInfo (initState) { if (!('firstTimeInfo' in initState)) { initState.firstTimeInfo = { @@ -1032,11 +1221,21 @@ module.exports = class MetamaskController extends EventEmitter { } } + /** + * A method for recording whether the MetaMask user interface is open or not. + * @private + * @param {boolean} open + */ set isClientOpen (open) { this._isClientOpen = open this.isClientOpenAndUnlocked = this.getState().isUnlocked && open } + /** + * A method for activating the retrieval of price data, which should only be fetched when the UI is visible. + * @private + * @param {boolean} active - True if price data should be getting fetched. + */ set isClientOpenAndUnlocked (active) { this.tokenRatesController.isActive = active } diff --git a/app/scripts/migrations/018.js b/app/scripts/migrations/018.js index bea1fe3da..ffbf24a4b 100644 --- a/app/scripts/migrations/018.js +++ b/app/scripts/migrations/018.js @@ -7,7 +7,7 @@ This migration updates "transaction state history" to diffs style */ const clone = require('clone') -const txStateHistoryHelper = require('../lib/tx-state-history-helper') +const txStateHistoryHelper = require('../controllers/transactions/lib/tx-state-history-helper') module.exports = { diff --git a/app/scripts/platforms/sw.js b/app/scripts/platforms/sw.js index 007d8dc5b..56c5f2774 100644 --- a/app/scripts/platforms/sw.js +++ b/app/scripts/platforms/sw.js @@ -1,20 +1,25 @@ - class SwPlatform { - - // - // Public - // - + /** + * Reloads the platform + */ reload () { - // you cant actually do this - global.location.reload() + // TODO: you can't actually do this + /** @type {any} */ (global).location.reload() } - openWindow ({ url }) { - // this doesnt actually work - global.open(url, '_blank') + /** + * Opens a window + * @param {{url: string}} opts - The window options + */ + openWindow (opts) { + // TODO: this doesn't actually work + /** @type {any} */ (global).open(opts.url, '_blank') } + /** + * Returns the platform version + * @returns {string} + */ getVersion () { return '<unable to read version>' } diff --git a/app/scripts/platforms/window.js b/app/scripts/platforms/window.js index 1527c008b..943b2a703 100644 --- a/app/scripts/platforms/window.js +++ b/app/scripts/platforms/window.js @@ -1,18 +1,23 @@ - class WindowPlatform { - - // - // Public - // - + /** + * Reload the platform + */ reload () { - global.location.reload() + /** @type {any} */ (global).location.reload() } - openWindow ({ url }) { - global.open(url, '_blank') + /** + * Opens a window + * @param {{url: string}} opts - The window options + */ + openWindow (opts) { + /** @type {any} */ (global).open(opts.url, '_blank') } + /** + * Returns the platform version + * @returns {string} + */ getVersion () { return '<unable to read version>' } diff --git a/app/scripts/popup-core.js b/app/scripts/popup-core.js index 2e4334bb1..6325b8a8d 100644 --- a/app/scripts/popup-core.js +++ b/app/scripts/popup-core.js @@ -7,10 +7,14 @@ const launchMetamaskUi = require('../../ui') const StreamProvider = require('web3-stream-provider') const setupMultiplex = require('./lib/stream-utils.js').setupMultiplex - module.exports = initializePopup - +/** + * Asynchronously initializes the MetaMask popup UI + * + * @param {{ container: Element, connectionStream: * }} config Popup configuration object + * @param {Function} cb Called when initialization is complete + */ function initializePopup ({ container, connectionStream }, cb) { // setup app async.waterfall([ @@ -19,6 +23,12 @@ function initializePopup ({ container, connectionStream }, cb) { ], cb) } +/** + * Establishes streamed connections to background scripts and a Web3 provider + * + * @param {PortDuplexStream} connectionStream PortStream instance establishing a background connection + * @param {Function} cb Called when controller connection is established + */ function connectToAccountManager (connectionStream, cb) { // setup communication with background // setup multiplexing @@ -28,6 +38,11 @@ function connectToAccountManager (connectionStream, cb) { setupWeb3Connection(mx.createStream('provider')) } +/** + * Establishes a streamed connection to a Web3 provider + * + * @param {PortDuplexStream} connectionStream PortStream instance establishing a background connection + */ function setupWeb3Connection (connectionStream) { var providerStream = new StreamProvider() providerStream.pipe(connectionStream).pipe(providerStream) @@ -38,6 +53,12 @@ function setupWeb3Connection (connectionStream) { global.eth = new Eth(providerStream) } +/** + * Establishes a streamed connection to the background account manager + * + * @param {PortDuplexStream} connectionStream PortStream instance establishing a background connection + * @param {Function} cb Called when the remote account manager connection is established + */ function setupControllerConnection (connectionStream, cb) { // this is a really sneaky way of adding EventEmitter api // to a bi-directional dnode instance |