From 6bc8cc819a16118acc010d0efdec90afbda14590 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 14 May 2018 11:00:50 -0230 Subject: Merge branch 'develop' into i3725-refactor-send-component- --- app/scripts/background.js | 6 +- app/scripts/contentscript.js | 1 + app/scripts/controllers/balance.js | 56 +++ app/scripts/controllers/blacklist.js | 53 ++- app/scripts/controllers/network/network.js | 2 +- app/scripts/controllers/preferences.js | 4 +- app/scripts/controllers/recent-blocks.js | 68 +++ app/scripts/controllers/token-rates.js | 8 +- app/scripts/controllers/transactions.js | 396 ------------------ app/scripts/controllers/transactions/README.md | 92 +++++ app/scripts/controllers/transactions/index.js | 455 +++++++++++++++++++++ .../transactions/lib/tx-state-history-helper.js | 64 +++ app/scripts/controllers/transactions/lib/util.js | 99 +++++ .../controllers/transactions/nonce-tracker.js | 186 +++++++++ .../controllers/transactions/pending-tx-tracker.js | 224 ++++++++++ .../controllers/transactions/tx-gas-utils.js | 129 ++++++ .../controllers/transactions/tx-state-manager.js | 420 +++++++++++++++++++ app/scripts/lib/account-tracker.js | 81 +++- app/scripts/lib/config-manager.js | 22 +- app/scripts/lib/extractEthjsErrorMessage.js | 23 +- app/scripts/lib/getObjStructure.js | 17 + app/scripts/lib/message-manager.js | 137 ++++++- app/scripts/lib/nonce-tracker.js | 148 ------- app/scripts/lib/notification-manager.js | 44 +- app/scripts/lib/pending-balance-calculator.js | 38 +- app/scripts/lib/pending-tx-tracker.js | 189 --------- app/scripts/lib/personal-message-manager.js | 142 ++++++- app/scripts/lib/seed-phrase-verifier.js | 18 +- app/scripts/lib/setupRaven.js | 61 ++- app/scripts/lib/tx-gas-utils.js | 103 ----- app/scripts/lib/tx-state-history-helper.js | 41 -- app/scripts/lib/tx-state-manager.js | 303 -------------- app/scripts/lib/typed-message-manager.js | 137 +++++++ app/scripts/metamask-controller.js | 3 +- app/scripts/migrations/018.js | 2 +- 35 files changed, 2490 insertions(+), 1282 deletions(-) delete mode 100644 app/scripts/controllers/transactions.js create mode 100644 app/scripts/controllers/transactions/README.md create mode 100644 app/scripts/controllers/transactions/index.js create mode 100644 app/scripts/controllers/transactions/lib/tx-state-history-helper.js create mode 100644 app/scripts/controllers/transactions/lib/util.js create mode 100644 app/scripts/controllers/transactions/nonce-tracker.js create mode 100644 app/scripts/controllers/transactions/pending-tx-tracker.js create mode 100644 app/scripts/controllers/transactions/tx-gas-utils.js create mode 100644 app/scripts/controllers/transactions/tx-state-manager.js delete mode 100644 app/scripts/lib/nonce-tracker.js delete mode 100644 app/scripts/lib/pending-tx-tracker.js delete mode 100644 app/scripts/lib/tx-gas-utils.js delete mode 100644 app/scripts/lib/tx-state-history-helper.js delete mode 100644 app/scripts/lib/tx-state-manager.js (limited to 'app/scripts') diff --git a/app/scripts/background.js b/app/scripts/background.js index 38b871bb5..69d549c85 100644 --- a/app/scripts/background.js +++ b/app/scripts/background.js @@ -261,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 diff --git a/app/scripts/contentscript.js b/app/scripts/contentscript.js index dbf1c6d4c..ddf1a9432 100644 --- a/app/scripts/contentscript.js +++ b/app/scripts/contentscript.js @@ -174,6 +174,7 @@ function blacklistedDomainCheck () { 'uscourts.gov', 'dropbox.com', 'webbyawards.com', + 'cdn.shopify.com/s/javascripts/tricorder/xtld-read-only-frame.html', ] var currentUrl = window.location.href var currentRegex 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} 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} 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} 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} 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/network/network.js b/app/scripts/controllers/network/network.js index 6fd983bb2..2f5b81cd2 100644 --- a/app/scripts/controllers/network/network.js +++ b/app/scripts/controllers/network/network.js @@ -1,7 +1,7 @@ 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') diff --git a/app/scripts/controllers/preferences.js b/app/scripts/controllers/preferences.js index d4d508026..1d3308d36 100644 --- a/app/scripts/controllers/preferences.js +++ b/app/scripts/controllers/preferences.js @@ -8,8 +8,8 @@ class PreferencesController { * * @typedef {Object} PreferencesController * @param {object} opts Overrides the defaults for the initial state of this.store - * @property {object} store The an object containing a users preferences, stored in local storage - * @property {array} store.frequentRpcList A list of custom rpcs to provide the user + * @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 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} 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} 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} 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/token-rates.js b/app/scripts/controllers/token-rates.js index 21384f262..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,10 +40,13 @@ class TokenRatesController { */ async fetchExchangeRate (address) { try { - const response = await fetch(`https://metamask.dev.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 + } } /** diff --git a/app/scripts/controllers/transactions.js b/app/scripts/controllers/transactions.js deleted file mode 100644 index c8211ebd7..000000000 --- a/app/scripts/controllers/transactions.js +++ /dev/null @@ -1,396 +0,0 @@ -const EventEmitter = require('events') -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 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 - 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 -*/ - -module.exports = class TransactionController extends EventEmitter { - constructor (opts) { - super() - this.networkStore = opts.networkStore || new ObservableStore({}) - this.preferencesStore = opts.preferencesStore || new ObservableStore({}) - this.provider = opts.provider - this.blockTracker = opts.blockTracker - this.signEthTx = opts.signTransaction - this.getGasPrice = opts.getGasPrice - - this.memStore = new ObservableStore({}) - this.query = new EthQuery(this.provider) - this.txGasUtil = new TxGasUtil(this.provider) - - 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.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, - }) - }, - }) - - this.pendingTxTracker = new PendingTransactionTracker({ - provider: this.provider, - nonceTracker: this.nonceTracker, - publishTransaction: (rawTx) => this.query.sendRawTransaction(rawTx), - getPendingTransactions: this.txStateManager.getPendingTransactions.bind(this.txStateManager), - getCompletedTransactions: this.txStateManager.getConfirmedTransactions.bind(this.txStateManager), - }) - - 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)) - // 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) - } - - getChainId () { - const networkState = this.networkStore.getState() - const getChainId = parseInt(networkState) - if (Number.isNaN(getChainId)) { - return 0 - } else { - return getChainId - } - } - - wipeTransactions (address) { - this.txStateManager.wipeTransactions(address) - } - - // Adds a tx to the txlist - addTx (txMeta) { - this.txStateManager.addTx(txMeta) - this.emit(`${txMeta.id}:unapproved`, txMeta) - } - - async newUnapprovedTransaction (txParams, opts = {}) { - log.debug(`MetaMaskController newUnapprovedTransaction ${JSON.stringify(txParams)}`) - const initialTxMeta = await this.addUnapprovedTransaction(txParams) - initialTxMeta.origin = opts.origin - this.txStateManager.updateTx(initialTxMeta, '#newUnapprovedTransaction - adding the origin') - // listen for tx completion (success, fail) - return new Promise((resolve, reject) => { - this.txStateManager.once(`${initialTxMeta.id}:finished`, (finishedTxMeta) => { - switch (finishedTxMeta.status) { - case 'submitted': - return resolve(finishedTxMeta.hash) - case 'rejected': - return reject(new Error('MetaMask Tx Signature: User denied transaction signature.')) - case 'failed': - return reject(new Error(finishedTxMeta.err.message)) - default: - return reject(new Error(`MetaMask Tx Signature: Unknown problem: ${JSON.stringify(finishedTxMeta.txParams)}`)) - } - }) - }) - } - - async addUnapprovedTransaction (txParams) { - // validate - const normalizedTxParams = this._normalizeTxParams(txParams) - this._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) - } catch (error) { - console.log(error) - this.txStateManager.setTxStatusFailed(txMeta.id, error) - throw error - } - txMeta.loadingDefaults = false - // save txMeta - this.txStateManager.updateTx(txMeta) - - return txMeta - } - - async addTxDefaults (txMeta) { - const txParams = txMeta.txParams - // ensure value - 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) - } - - async retryTransaction (originalTxId) { - const originalTxMeta = this.txStateManager.getTx(originalTxId) - const lastGasPrice = originalTxMeta.txParams.gasPrice - const txMeta = this.txStateManager.generateTxMeta({ - txParams: originalTxMeta.txParams, - lastGasPrice, - loadingDefaults: false, - }) - this.addTx(txMeta) - this.emit('newUnapprovedTx', txMeta) - return txMeta - } - - async updateTransaction (txMeta) { - this.txStateManager.updateTx(txMeta, 'confTx: user updated transaction') - } - - async updateAndApproveTransaction (txMeta) { - this.txStateManager.updateTx(txMeta, 'confTx: user approved transaction') - await this.approveTransaction(txMeta.id) - } - - async approveTransaction (txId) { - let nonceLock - try { - // approve - this.txStateManager.setTxStatusApproved(txId) - // get next nonce - const txMeta = this.txStateManager.getTx(txId) - const fromAddress = txMeta.txParams.from - // wait for a nonce - nonceLock = await this.nonceTracker.getNonceLock(fromAddress) - // add nonce to txParams - // if txMeta has lastGasPrice then it is a retry at same nonce with higher - // gas price transaction and their for the nonce should not be calculated - const nonce = txMeta.lastGasPrice ? txMeta.txParams.nonce : nonceLock.nextNonce - txMeta.txParams.nonce = ethUtil.addHexPrefix(nonce.toString(16)) - // add nonce debugging information to txMeta - txMeta.nonceDetails = nonceLock.nonceDetails - this.txStateManager.updateTx(txMeta, 'transactions#approveTransaction') - // sign transaction - const rawTx = await this.signTransaction(txId) - await this.publishTransaction(txId, rawTx) - // must set transaction to submitted/failed before releasing lock - nonceLock.releaseLock() - } catch (err) { - this.txStateManager.setTxStatusFailed(txId, err) - // must set transaction to submitted/failed before releasing lock - if (nonceLock) nonceLock.releaseLock() - // continue with error chain - throw err - } - } - - async signTransaction (txId) { - const txMeta = this.txStateManager.getTx(txId) - // add network/chain id - const chainId = this.getChainId() - const txParams = Object.assign({}, txMeta.txParams, { chainId }) - // sign tx - const fromAddress = txParams.from - const ethTx = new Transaction(txParams) - await this.signEthTx(ethTx, fromAddress) - // set state to signed - this.txStateManager.setTxStatusSigned(txMeta.id) - const rawTx = ethUtil.bufferToHex(ethTx.serialize()) - return rawTx - } - - async publishTransaction (txId, rawTx) { - const txMeta = this.txStateManager.getTx(txId) - txMeta.rawTx = rawTx - this.txStateManager.updateTx(txMeta, 'transactions#publishTransaction') - const txHash = await this.query.sendRawTransaction(rawTx) - this.setTxHash(txId, txHash) - this.txStateManager.setTxStatusSubmitted(txId) - } - - async cancelTransaction (txId) { - this.txStateManager.setTxStatusRejected(txId) - } - - // receives a txHash records the tx as signed - setTxHash (txId, txHash) { - // Add the tx hash to the persisted meta-tx object - const txMeta = this.txStateManager.getTx(txId) - txMeta.hash = txHash - this.txStateManager.updateTx(txMeta, 'transactions#setTxHash') - } - -// -// PRIVATE METHODS -// - - _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), - } - - // apply only keys in the whiteList - const normalizedTxParams = {} - Object.keys(whiteList).forEach((key) => { - if (txParams[key]) normalizedTxParams[key] = whiteList[key](txParams[key]) - }) - - return normalizedTxParams - } - - _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.`) - } - - if (value.includes('.')) { - throw new Error(`Invalid transaction value of ${txParams.value} number must be in wei`) - } - } - } - - _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 - } - - _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 - const sameNonceTxs = this.txStateManager.getFilteredTxList({nonce, from}) - if (!sameNonceTxs.length) return - // mark all same nonce transactions as dropped and give i a replacedBy hash - sameNonceTxs.forEach((otherTxMeta) => { - if (otherTxMeta.id === txId) return - otherTxMeta.replacedBy = txMeta.hash - this.txStateManager.updateTx(txMeta, 'transactions/pending-tx-tracker#event: tx:confirmed reference to confirmed txHash with same nonce') - this.txStateManager.setTxStatusDropped(otherTxMeta.id) - }) - } - - _updateMemstore () { - const unapprovedTxs = this.txStateManager.getUnapprovedTxList() - const selectedAddressTxList = this.txStateManager.getFilteredTxList({ - from: this.getSelectedAddress(), - metamaskNetworkId: this.getNetwork(), - }) - this.memStore.updateState({ unapprovedTxs, selectedAddressTxList }) - } -} 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/index.js b/app/scripts/controllers/transactions/index.js new file mode 100644 index 000000000..3886db104 --- /dev/null +++ b/app/scripts/controllers/transactions/index.js @@ -0,0 +1,455 @@ +const EventEmitter = require('events') +const ObservableStore = require('obs-store') +const ethUtil = require('ethereumjs-util') +const Transaction = require('ethereumjs-tx') +const EthQuery = require('ethjs-query') +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 + 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 + + + @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 +*/ + +class TransactionController extends EventEmitter { + constructor (opts) { + super() + this.networkStore = opts.networkStore || new ObservableStore({}) + this.preferencesStore = opts.preferencesStore || new ObservableStore({}) + this.provider = opts.provider + this.blockTracker = opts.blockTracker + this.signEthTx = opts.signTransaction + this.getGasPrice = opts.getGasPrice + + this.memStore = new ObservableStore({}) + 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._onBootCleanUp() + + this.store = this.txStateManager.store + this.nonceTracker = new NonceTracker({ + provider: this.provider, + getPendingTransactions: this.txStateManager.getPendingTransactions.bind(this.txStateManager), + getConfirmedTransactions: this.txStateManager.getConfirmedTransactions.bind(this.txStateManager), + }) + + this.pendingTxTracker = new PendingTransactionTracker({ + provider: this.provider, + nonceTracker: this.nonceTracker, + publishTransaction: (rawTx) => this.query.sendRawTransaction(rawTx), + getPendingTransactions: this.txStateManager.getPendingTransactions.bind(this.txStateManager), + getCompletedTransactions: this.txStateManager.getConfirmedTransactions.bind(this.txStateManager), + }) + + this.txStateManager.store.subscribe(() => this.emit('update:badge')) + 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()) + } + /** @returns {number} the chainId*/ + getChainId () { + const networkState = this.networkStore.getState() + const getChainId = parseInt(networkState) + if (Number.isNaN(getChainId)) { + return 0 + } else { + return getChainId + } + } + +/** + 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) + } + + /** + Check if a txMeta in the list with the same nonce has been confirmed in a block + if the txParams dont have a nonce will return false + @returns {boolean} whether the nonce has been used in a transaction confirmed in a block + @param {object} txMeta - the txMeta object + */ + async isNonceTaken (txMeta) { + const { from, nonce } = txMeta.txParams + if ('nonce' in txMeta.txParams) { + const sameNonceTxList = this.txStateManager.getFilteredTxList({from, nonce, status: 'confirmed'}) + return (sameNonceTxList.length >= 1) + } + return false + } + + /** + add a new unapproved transaction to the pipeline + + @returns {Promise} 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) + initialTxMeta.origin = opts.origin + this.txStateManager.updateTx(initialTxMeta, '#newUnapprovedTransaction - adding the origin') + // listen for tx completion (success, fail) + return new Promise((resolve, reject) => { + this.txStateManager.once(`${initialTxMeta.id}:finished`, (finishedTxMeta) => { + switch (finishedTxMeta.status) { + case 'submitted': + return resolve(finishedTxMeta.hash) + case 'rejected': + return reject(new Error('MetaMask Tx Signature: User denied transaction signature.')) + case 'failed': + return reject(new Error(finishedTxMeta.err.message)) + default: + return reject(new Error(`MetaMask Tx Signature: Unknown problem: ${JSON.stringify(finishedTxMeta.txParams)}`)) + } + }) + }) + } + + /** + Validates and generates a txMeta with defaults and puts it in txStateManager + store + + @returns {txMeta} + */ + + async addUnapprovedTransaction (txParams) { + // validate + 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.addTxGasDefaults(txMeta) + } catch (error) { + console.log(error) + this.txStateManager.setTxStatusFailed(txMeta.id, error) + throw error + } + txMeta.loadingDefaults = false + // save txMeta + this.txStateManager.updateTx(txMeta) + + return txMeta + } +/** + adds the tx gas defaults: gas && gasPrice + @param txMeta {Object} - the txMeta object + @returns {Promise} 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)) + // 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 + const txMeta = this.txStateManager.generateTxMeta({ + txParams: originalTxMeta.txParams, + lastGasPrice, + loadingDefaults: false, + }) + this.addTx(txMeta) + this.emit('newUnapprovedTx', txMeta) + 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 { + // approve + this.txStateManager.setTxStatusApproved(txId) + // get next nonce + const txMeta = this.txStateManager.getTx(txId) + const fromAddress = txMeta.txParams.from + // wait for a nonce + nonceLock = await this.nonceTracker.getNonceLock(fromAddress) + // add nonce to txParams + // if txMeta has lastGasPrice then it is a retry at same nonce with higher + // gas price transaction and their for the nonce should not be calculated + const nonce = txMeta.lastGasPrice ? txMeta.txParams.nonce : nonceLock.nextNonce + txMeta.txParams.nonce = ethUtil.addHexPrefix(nonce.toString(16)) + // add nonce debugging information to txMeta + txMeta.nonceDetails = nonceLock.nonceDetails + this.txStateManager.updateTx(txMeta, 'transactions#approveTransaction') + // sign transaction + const rawTx = await this.signTransaction(txId) + await this.publishTransaction(txId, rawTx) + // must set transaction to submitted/failed before releasing lock + nonceLock.releaseLock() + } catch (err) { + this.txStateManager.setTxStatusFailed(txId, err) + // must set transaction to submitted/failed before releasing lock + if (nonceLock) nonceLock.releaseLock() + // continue with error chain + 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 + const chainId = this.getChainId() + const txParams = Object.assign({}, txMeta.txParams, { chainId }) + // sign tx + const fromAddress = txParams.from + const ethTx = new Transaction(txParams) + await this.signEthTx(ethTx, fromAddress) + // set state to signed + this.txStateManager.setTxStatusSigned(txMeta.id) + const rawTx = ethUtil.bufferToHex(ethTx.serialize()) + 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} + */ + async publishTransaction (txId, rawTx) { + const txMeta = this.txStateManager.getTx(txId) + txMeta.rawTx = rawTx + this.txStateManager.updateTx(txMeta, 'transactions#publishTransaction') + const txHash = await this.query.sendRawTransaction(rawTx) + this.setTxHash(txId, txHash) + this.txStateManager.setTxStatusSubmitted(txId) + } + + /** + Convenience method for the ui thats sets the transaction to rejected + @param txId {number} - the tx's Id + @returns {Promise} + */ + async cancelTransaction (txId) { + this.txStateManager.setTxStatusRejected(txId) + } + + /** + 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) + txMeta.hash = txHash + this.txStateManager.updateTx(txMeta, 'transactions#setTxHash') + } + +// +// 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) + } + + /** + 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. + */ + + _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) + }) + }) + + 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) + }) + } + + /** + 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') + }) + + 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)) + + } + + /** + 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) { + // get the confirmed transactions nonce and from address + const txMeta = this.txStateManager.getTx(txId) + const { nonce, from } = txMeta.txParams + const sameNonceTxs = this.txStateManager.getFilteredTxList({nonce, from}) + if (!sameNonceTxs.length) return + // mark all same nonce transactions as dropped and give i a replacedBy hash + sameNonceTxs.forEach((otherTxMeta) => { + if (otherTxMeta.id === txId) return + otherTxMeta.replacedBy = txMeta.hash + this.txStateManager.updateTx(txMeta, 'transactions/pending-tx-tracker#event: tx:confirmed reference to confirmed txHash with same nonce') + this.txStateManager.setTxStatusDropped(otherTxMeta.id) + }) + } + + /** + Updates the memStore in transaction controller + */ + _updateMemstore () { + const unapprovedTxs = this.txStateManager.getUnapprovedTxList() + const selectedAddressTxList = this.txStateManager.getFilteredTxList({ + from: this.getSelectedAddress(), + metamaskNetworkId: this.getNetwork(), + }) + this.memStore.updateState({ unapprovedTxs, selectedAddressTxList }) + } +} + +module.exports = TransactionController diff --git a/app/scripts/controllers/transactions/lib/tx-state-history-helper.js b/app/scripts/controllers/transactions/lib/tx-state-history-helper.js new file mode 100644 index 000000000..59a4b562c --- /dev/null +++ b/app/scripts/controllers/transactions/lib/tx-state-history-helper.js @@ -0,0 +1,64 @@ +const jsonDiffer = require('fast-json-patch') +const clone = require('clone') +/** @module*/ +module.exports = { + generateHistoryEntry, + replayHistory, + snapshotFromTxMeta, + migrateFromSnapshotsToDiffs, +} + +/** + converts non-initial history entries into diffs + @param longHistory {array} + @returns {array} +*/ +function migrateFromSnapshotsToDiffs (longHistory) { + return ( + longHistory + // convert non-initial history entries into diffs + .map((entry, index) => { + if (index === 0) return entry + return generateHistoryEntry(longHistory[index - 1], entry) + }) + ) +} + +/** + 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 + if (note && entry[0]) entry[0].note = 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) + // dont include previous history in this snapshot + delete snapshot.history + return snapshot +} 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/controllers/transactions/nonce-tracker.js b/app/scripts/controllers/transactions/nonce-tracker.js new file mode 100644 index 000000000..f8cdc5523 --- /dev/null +++ b/app/scripts/controllers/transactions/nonce-tracker.js @@ -0,0 +1,186 @@ +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 }) { + this.provider = provider + this.ethQuery = new EthQuery(provider) + this.getPendingTransactions = getPendingTransactions + this.getConfirmedTransactions = getConfirmedTransactions + this.lockMap = {} + } + + /** + @returns {Promise} with the key releaseLock (the gloabl mutex) + */ + async getGlobalLock () { + const globalMutex = this._lookupMutex('global') + // await global mutex free + const releaseLock = await globalMutex.acquire() + return { releaseLock } + } + + /** + * @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} + */ + async getNonceLock (address) { + // await global mutex free + await this._globalMutexFree() + // await lock free, then take lock + const releaseLock = await this._takeMutex(address) + // evaluate multiple nextNonce strategies + const nonceDetails = {} + const networkNonceResult = await this._getNetworkNextNonce(address) + const highestLocallyConfirmed = this._getHighestLocallyConfirmed(address) + const nextNetworkNonce = networkNonceResult.nonce + const highestSuggested = Math.max(nextNetworkNonce, highestLocallyConfirmed) + + const pendingTxs = this.getPendingTransactions(address) + const localNonceResult = this._getHighestContinuousFrom(pendingTxs, highestSuggested) || 0 + + nonceDetails.params = { + highestLocallyConfirmed, + highestSuggested, + nextNetworkNonce, + } + nonceDetails.local = localNonceResult + nonceDetails.network = networkNonceResult + + const nextNonce = Math.max(networkNonceResult.nonce, localNonceResult.nonce) + assert(Number.isInteger(nextNonce), `nonce-tracker - nextNonce is not an integer - got: (${typeof nextNonce}) "${nextNonce}"`) + + // return nonce and release cb + return { nextNonce, nonceDetails, releaseLock } + } + + async _getCurrentBlock () { + const blockTracker = this._getBlockTracker() + const currentBlock = blockTracker.getCurrentBlock() + if (currentBlock) return currentBlock + return await new Promise((reject, resolve) => { + blockTracker.once('latest', resolve) + }) + } + + async _globalMutexFree () { + const globalMutex = this._lookupMutex('global') + const release = await globalMutex.acquire() + release() + } + + async _takeMutex (lockId) { + const mutex = this._lookupMutex(lockId) + const releaseLock = await mutex.acquire() + return releaseLock + } + + _lookupMutex (lockId) { + let mutex = this.lockMap[lockId] + if (!mutex) { + mutex = new Mutex() + this.lockMap[lockId] = mutex + } + return mutex + } + + async _getNetworkNextNonce (address) { + // calculate next nonce + // we need to make sure our base count + // and pending count are from the same block + const currentBlock = await this._getCurrentBlock() + const blockNumber = currentBlock.blockNumber + const baseCountBN = await this.ethQuery.getTransactionCount(address, blockNumber || 'latest') + const baseCount = baseCountBN.toNumber() + assert(Number.isInteger(baseCount), `nonce-tracker - baseCount is not an integer - got: (${typeof baseCount}) "${baseCount}"`) + const nonceDetails = { blockNumber, baseCount } + return { name: 'network', nonce: baseCount, details: nonceDetails } + } + + _getHighestLocallyConfirmed (address) { + const confirmedTransactions = this.getConfirmedTransactions(address) + const highest = this._getHighestNonce(confirmedTransactions) + return Number.isInteger(highest) ? highest + 1 : 0 + } + + _reduceTxListToUniqueNonces (txList) { + const reducedTxList = txList.reduce((reducedList, txMeta, index) => { + if (!index) return [txMeta] + const nonceMatches = txList.filter((txData) => { + return txMeta.txParams.nonce === txData.txParams.nonce + }) + if (nonceMatches.length > 1) return reducedList + reducedList.push(txMeta) + return reducedList + }, []) + return reducedTxList + } + + _getHighestNonce (txList) { + const nonces = txList.map((txMeta) => { + const nonce = txMeta.txParams.nonce + assert(typeof nonce, 'string', 'nonces should be hex strings') + return parseInt(nonce, 16) + }) + const highestNonce = Math.max.apply(null, nonces) + 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 + assert(typeof nonce, 'string', 'nonces should be hex strings') + return parseInt(nonce, 16) + }) + + let highest = startPoint + while (nonces.includes(highest)) { + highest++ + } + + return { name: 'local', nonce: highest, details: { startPoint, highest } } + } + + // 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 + } +} + +module.exports = NonceTracker diff --git a/app/scripts/controllers/transactions/pending-tx-tracker.js b/app/scripts/controllers/transactions/pending-tx-tracker.js new file mode 100644 index 000000000..6e2fcb40b --- /dev/null +++ b/app/scripts/controllers/transactions/pending-tx-tracker.js @@ -0,0 +1,224 @@ +const EventEmitter = require('events') +const log = require('loglevel') +const EthQuery = require('ethjs-query') +/** + + Event emitter utility class for tracking the transactions as they
+ go from a pending state to a confirmed (mined in a block) state
+
+ As well as continues broadcast while in the pending state +
+@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, + + +@class +*/ + +class PendingTransactionTracker extends EventEmitter { + constructor (config) { + super() + this.query = new EthQuery(config.provider) + this.nonceTracker = config.nonceTracker + // default is one day + this.getPendingTransactions = config.getPendingTransactions + this.getCompletedTransactions = config.getCompletedTransactions + this.publishTransaction = config.publishTransaction + this._checkPendingTxs() + } + + /** + 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 + signedTxList.forEach((txMeta) => { + const txHash = txMeta.hash + const txId = txMeta.id + + if (!txHash) { + const noTxHashErr = new Error('We had an error while submitting this transaction, please try again.') + noTxHashErr.name = 'NoTxHashError' + this.emit('tx:failed', txId, noTxHashErr) + return + } + + + block.transactions.forEach((tx) => { + if (tx.hash === txHash) this.emit('tx:confirmed', txId) + }) + }) + } + + /** + 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) { + this._checkPendingTxs() + return + } + // if we synced by more than one block, check for missed pending transactions + const diff = Number.parseInt(newBlock.number, 16) - Number.parseInt(oldBlock.number, 16) + 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 + if (!pending.length) return + pending.forEach((txMeta) => this._resubmitTx(txMeta, block.number).catch((err) => { + /* + Dont marked as failed if the error is a "known" transaction warning + "there is already a transaction with the same sender-nonce + but higher/same gas price" + + Also don't mark as failed if it has ever been broadcast successfully. + A successful broadcast means it may still be mined. + */ + const errorMessage = err.message.toLowerCase() + const isKnownTx = ( + // geth + errorMessage.includes('replacement transaction underpriced') || + errorMessage.includes('known transaction') || + // parity + errorMessage.includes('gas price too low to replace') || + errorMessage.includes('transaction with the same hash was already imported') || + // other + errorMessage.includes('gateway timeout') || + errorMessage.includes('nonce too low') + ) + // ignore resubmit warnings, return early + if (isKnownTx) return + // encountered real error - transition to error state + txMeta.warning = { + error: errorMessage, + message: 'There was an error when resubmitting this transaction.', + } + this.emit('tx:warning', txMeta, err) + })) + } + + /** + 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) + } + + const firstRetryBlockNumber = txMeta.firstRetryBlockNumber || latestBlockNumber + const txBlockDistance = Number.parseInt(latestBlockNumber, 16) - Number.parseInt(firstRetryBlockNumber, 16) + + const retryCount = txMeta.retryCount || 0 + + // Exponential backoff to limit retries at publishing + if (txBlockDistance <= Math.pow(2, retryCount) - 1) return + + // Only auto-submit already-signed txs: + if (!('rawTx' in txMeta)) return + + const rawTx = txMeta.rawTx + const txHash = await this.publishTransaction(rawTx) + + // Increment successful tries: + 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 + + // extra check in case there was an uncaught error during the + // signature and submission process + if (!txHash) { + const noTxHashErr = new Error('We had an error while submitting this transaction, please try again.') + noTxHashErr.name = 'NoTxHashError' + this.emit('tx:failed', txId, noTxHashErr) + return + } + + // If another tx with the same nonce is mined, set as failed. + const taken = await this._checkIfNonceIsTaken(txMeta) + if (taken) { + const nonceTakenErr = new Error('Another transaction with this nonce has been mined.') + nonceTakenErr.name = 'NonceTakenErr' + return this.emit('tx:failed', txId, nonceTakenErr) + } + + // get latest transaction status + let txParams + try { + txParams = await this.query.getTransactionByHash(txHash) + if (!txParams) return + if (txParams.blockNumber) { + this.emit('tx:confirmed', txId) + } + } catch (err) { + txMeta.warning = { + error: err.message, + message: 'There was a problem loading this transaction.', + } + this.emit('tx:warning', txMeta, err) + } + } + + /** + 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 + const nonceGlobalLock = await this.nonceTracker.getGlobalLock() + try { + await Promise.all(signedTxList.map((txMeta) => this._checkPendingTx(txMeta))) + } catch (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) + const sameNonce = completed.filter((otherMeta) => { + return otherMeta.txParams.nonce === txMeta.txParams.nonce + }) + return sameNonce.length > 0 + } +} + +module.exports = PendingTransactionTracker diff --git a/app/scripts/controllers/transactions/tx-gas-utils.js b/app/scripts/controllers/transactions/tx-gas-utils.js new file mode 100644 index 000000000..36b5cdbc9 --- /dev/null +++ b/app/scripts/controllers/transactions/tx-gas-utils.js @@ -0,0 +1,129 @@ +const EthQuery = require('ethjs-query') +const { + hexToBn, + BnMultiplyByFraction, + bnToHex, +} = require('../../lib/util') +const { addHexPrefix } = require('ethereumjs-util') +const SIMPLE_GAS_COST = '0x5208' // Hex for 21000, cost of a simple send. + +/** +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. +*/ + +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 + try { + estimatedGasHex = await this.estimateTxGas(txMeta, block.gasLimit) + } catch (err) { + const simulationFailed = ( + err.message.includes('Transaction execution error.') || + err.message.includes('gas required exceeds allowance or always failing transaction') + ) + if (simulationFailed) { + txMeta.simulationFails = true + return txMeta + } + } + this.setTxGas(txMeta, block.gasLimit, estimatedGasHex) + 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 + + // check if gasLimit is already specified + txMeta.gasLimitSpecified = Boolean(txParams.gas) + + // if it is, use that value + if (txMeta.gasLimitSpecified) { + return txParams.gas + } + + // if recipient has no code, gas is 21k max: + const recipient = txParams.to + const hasRecipient = Boolean(recipient) + let code + if (recipient) code = await this.query.getCode(recipient) + + if (hasRecipient && (!code || code === '0x')) { + txParams.gas = SIMPLE_GAS_COST + txMeta.simpleSend = true // Prevents buffer addition + return SIMPLE_GAS_COST + } + + // if not, fall back to block gasLimit + const blockGasLimitBN = hexToBn(blockGasLimitHex) + const saferGasLimitBN = BnMultiplyByFraction(blockGasLimitBN, 19, 20) + txParams.gas = bnToHex(saferGasLimitBN) + + // run tx + 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 + + // if gasLimit was specified and doesnt OOG, + // use original specified amount + if (txMeta.gasLimitSpecified || txMeta.simpleSend) { + txMeta.estimatedGas = txParams.gas + return + } + // if gasLimit not originally specified, + // try adding an additional gas buffer to our estimation for safety + const recommendedGasHex = this.addGasBuffer(txMeta.estimatedGas, blockGasLimitHex) + txParams.gas = recommendedGasHex + 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) + const upperGasLimitBn = blockGasLimitBn.muln(0.9) + const bufferedGasLimitBn = initialGasLimitBn.muln(1.5) + + // if initialGasLimit is above blockGasLimit, dont modify it + if (initialGasLimitBn.gt(upperGasLimitBn)) return bnToHex(initialGasLimitBn) + // if bufferedGasLimit is below blockGasLimit, use bufferedGasLimit + if (bufferedGasLimitBn.lt(upperGasLimitBn)) return bnToHex(bufferedGasLimitBn) + // otherwise use blockGasLimit + return bnToHex(upperGasLimitBn) + } +} + +module.exports = TxGasUtil \ No newline at end of file diff --git a/app/scripts/controllers/transactions/tx-state-manager.js b/app/scripts/controllers/transactions/tx-state-manager.js new file mode 100644 index 000000000..380214c1d --- /dev/null +++ b/app/scripts/controllers/transactions/tx-state-manager.js @@ -0,0 +1,420 @@ +const extend = require('xtend') +const EventEmitter = require('events') +const ObservableStore = require('obs-store') +const ethUtil = require('ethereumjs-util') +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 +
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 + @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() + + this.store = new ObservableStore( + extend({ + transactions: [], + }, initState)) + this.txHistoryLimit = txHistoryLimit + 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(), + time: (new Date()).getTime(), + status: 'unapproved', + metamaskNetworkId: this.getNetwork(), + loadingDefaults: true, + }, 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 {array} the tx list whos status is unapproved + */ + getUnapprovedTxList () { + const txList = this.getTxsByMetaData('status', 'unapproved') + return txList.reduce((result, tx) => { + result[tx.id] = tx + return result + }, {}) + } + + /** + @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`) + }) + this.once(`${txMeta.id}:rejected`, function (txId) { + this.removeAllListeners(`${txMeta.id}:signed`) + }) + // initialize history + txMeta.history = [] + // capture initial snapshot of txMeta for history + const snapshot = txStateHistoryHelper.snapshotFromTxMeta(txMeta) + txMeta.history.push(snapshot) + + const transactions = this.getFullTxList() + const txCount = transactions.length + const txHistoryLimit = this.txHistoryLimit + + // checks if the length of the tx history is + // longer then desired persistence limit + // and then if it is removes only confirmed + // or rejected tx's. + // not tx's that are pending or unapproved + if (txCount > txHistoryLimit - 1) { + const index = transactions.findIndex((metaTx) => { + return getFinalStates().includes(metaTx.status) + }) + if (index !== -1) { + transactions.splice(index, 1) + } + } + transactions.push(txMeta) + this._saveTxList(transactions) + return txMeta + } + /** + @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) { + if (typeof txMeta.txParams.data === 'undefined') { + delete txMeta.txParams.data + } + + this.validateTxParams(txMeta.txParams) + } + + // create txMeta snapshot for history + const currentState = txStateHistoryHelper.snapshotFromTxMeta(txMeta) + // recover previous tx state obj + const previousState = txStateHistoryHelper.replayHistory(txMeta.history) + // generate history entry and add to history + const entry = txStateHistoryHelper.generateHistoryEntry(previousState, currentState, note) + txMeta.history.push(entry) + + // commit txMeta to state + const txId = txMeta.id + const txList = this.getFullTxList() + const index = txList.findIndex(txData => txData.id === txId) + txList[index] = txMeta + this._saveTxList(txList) + } + + + /** + 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 + @param txParams {object} - txParams to validate + */ + validateTxParams (txParams) { + Object.keys(txParams).forEach((key) => { + const value = txParams[key] + // validate types + switch (key) { + case 'chainId': + if (typeof value !== 'number' && typeof value !== 'string') throw new Error(`${key} in txParams is not a Number or hex string. got: (${value})`) + break + default: + if (typeof value !== 'string') throw new Error(`${key} in txParams is not a string. got: (${value})`) + if (!ethUtil.isHexPrefixed(value)) throw new Error(`${key} in txParams is not hex prefixed. got: (${value})`) + break + } + }) + } + +/** + @param opts {object} - an object of fields to search for eg:
+ let thingsToLookFor = {
+ to: '0x0..',
+ from: '0x0..',
+ status: 'signed',
+ err: undefined,
+ }
+ @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 | + | so you can also search txs that | + | dont have something as well by | + | setting the value as undefined | + ************************************ + + this is for things like filtering a the tx list + for only tx's from 1 account + or for filltering for all txs from one account + and that have been 'confirmed' + */ + getFilteredTxList (opts, initialList) { + let filteredTxList = initialList + Object.keys(opts).forEach((key) => { + filteredTxList = this.getTxsByMetaData(key, opts[key], filteredTxList) + }) + 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 (key in txMeta.txParams) { + return txMeta.txParams[key] === value + } else { + return txMeta[key] === value + } + }) + } + + // get::set status + + /** + @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'. + @param txId {number} - the txMeta Id + */ + setTxStatusRejected (txId) { + this._setTxStatus(txId, 'rejected') + } + + /** + 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'. + @param txId {number} - the txMeta Id + */ + setTxStatusApproved (txId) { + this._setTxStatus(txId, 'approved') + } + + /** + 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 + @param txId {number} - the txMeta Id + */ + setTxStatusSubmitted (txId) { + const txMeta = this.getTx(txId) + txMeta.submittedTime = (new Date()).getTime() + this.updateTx(txMeta, 'txStateManager - add submitted time stamp') + this._setTxStatus(txId, 'submitted') + } + + /** + 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 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 = { + message: err.toString(), + stack: err.stack, + } + this.updateTx(txMeta) + 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() + const network = this.getNetwork() + + // Filter out the ones from the current account and network + const otherAccountTxs = txs.filter((txMeta) => !(txMeta.txParams.from === address && txMeta.metamaskNetworkId === network)) + + // Update state + this._saveTxList(otherAccountTxs) + } +// +// PRIVATE METHODS +// + + // 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 + + /** + @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') + } + + /** + 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/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/config-manager.js b/app/scripts/lib/config-manager.js index c10ff2f4e..221746467 100644 --- a/app/scripts/lib/config-manager.js +++ b/app/scripts/lib/config-manager.js @@ -101,6 +101,7 @@ ConfigManager.prototype.setShowSeedWords = function (should) { this.setData(data) } + ConfigManager.prototype.getShouldShowSeedWords = function () { var data = this.getData() return data.showSeedWords @@ -116,27 +117,6 @@ ConfigManager.prototype.getSeedWords = function () { var data = this.getData() return data.seedWords } - -/** - * Called to set the isRevealingSeedWords flag. This happens only when the user chooses to reveal - * the seed words and not during the first time flow. - * @param {boolean} reveal - Value to set the isRevealingSeedWords flag. - */ -ConfigManager.prototype.setIsRevealingSeedWords = function (reveal = false) { - const data = this.getData() - data.isRevealingSeedWords = reveal - this.setData(data) -} - -/** - * Returns the isRevealingSeedWords flag. - * @returns {boolean|undefined} - */ -ConfigManager.prototype.getIsRevealingSeedWords = function () { - const data = this.getData() - return data.isRevealingSeedWords -} - ConfigManager.prototype.setRpcTarget = function (rpcUrl) { var config = this.getConfig() config.provider = { 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/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/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} 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} 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/nonce-tracker.js b/app/scripts/lib/nonce-tracker.js deleted file mode 100644 index 5b1cd7f43..000000000 --- a/app/scripts/lib/nonce-tracker.js +++ /dev/null @@ -1,148 +0,0 @@ -const EthQuery = require('ethjs-query') -const assert = require('assert') -const Mutex = require('await-semaphore').Mutex - -class NonceTracker { - - constructor ({ provider, getPendingTransactions, getConfirmedTransactions }) { - this.provider = provider - this.ethQuery = new EthQuery(provider) - this.getPendingTransactions = getPendingTransactions - this.getConfirmedTransactions = getConfirmedTransactions - this.lockMap = {} - } - - async getGlobalLock () { - const globalMutex = this._lookupMutex('global') - // await global mutex free - const releaseLock = await globalMutex.acquire() - return { releaseLock } - } - - // releaseLock must be called - // releaseLock must be called after adding signed tx to pending transactions (or discarding) - async getNonceLock (address) { - // await global mutex free - await this._globalMutexFree() - // await lock free, then take lock - const releaseLock = await this._takeMutex(address) - // evaluate multiple nextNonce strategies - const nonceDetails = {} - const networkNonceResult = await this._getNetworkNextNonce(address) - const highestLocallyConfirmed = this._getHighestLocallyConfirmed(address) - const nextNetworkNonce = networkNonceResult.nonce - const highestSuggested = Math.max(nextNetworkNonce, highestLocallyConfirmed) - - const pendingTxs = this.getPendingTransactions(address) - const localNonceResult = this._getHighestContinuousFrom(pendingTxs, highestSuggested) || 0 - - nonceDetails.params = { - highestLocallyConfirmed, - highestSuggested, - nextNetworkNonce, - } - nonceDetails.local = localNonceResult - nonceDetails.network = networkNonceResult - - const nextNonce = Math.max(networkNonceResult.nonce, localNonceResult.nonce) - assert(Number.isInteger(nextNonce), `nonce-tracker - nextNonce is not an integer - got: (${typeof nextNonce}) "${nextNonce}"`) - - // return nonce and release cb - return { nextNonce, nonceDetails, releaseLock } - } - - async _getCurrentBlock () { - const blockTracker = this._getBlockTracker() - const currentBlock = blockTracker.getCurrentBlock() - if (currentBlock) return currentBlock - return await new Promise((reject, resolve) => { - blockTracker.once('latest', resolve) - }) - } - - async _globalMutexFree () { - const globalMutex = this._lookupMutex('global') - const release = await globalMutex.acquire() - release() - } - - async _takeMutex (lockId) { - const mutex = this._lookupMutex(lockId) - const releaseLock = await mutex.acquire() - return releaseLock - } - - _lookupMutex (lockId) { - let mutex = this.lockMap[lockId] - if (!mutex) { - mutex = new Mutex() - this.lockMap[lockId] = mutex - } - return mutex - } - - async _getNetworkNextNonce (address) { - // calculate next nonce - // we need to make sure our base count - // and pending count are from the same block - const currentBlock = await this._getCurrentBlock() - const blockNumber = currentBlock.blockNumber - const baseCountBN = await this.ethQuery.getTransactionCount(address, blockNumber || 'latest') - const baseCount = baseCountBN.toNumber() - assert(Number.isInteger(baseCount), `nonce-tracker - baseCount is not an integer - got: (${typeof baseCount}) "${baseCount}"`) - const nonceDetails = { blockNumber, baseCount } - return { name: 'network', nonce: baseCount, details: nonceDetails } - } - - _getHighestLocallyConfirmed (address) { - const confirmedTransactions = this.getConfirmedTransactions(address) - const highest = this._getHighestNonce(confirmedTransactions) - return Number.isInteger(highest) ? highest + 1 : 0 - } - - _reduceTxListToUniqueNonces (txList) { - const reducedTxList = txList.reduce((reducedList, txMeta, index) => { - if (!index) return [txMeta] - const nonceMatches = txList.filter((txData) => { - return txMeta.txParams.nonce === txData.txParams.nonce - }) - if (nonceMatches.length > 1) return reducedList - reducedList.push(txMeta) - return reducedList - }, []) - return reducedTxList - } - - _getHighestNonce (txList) { - const nonces = txList.map((txMeta) => { - const nonce = txMeta.txParams.nonce - assert(typeof nonce, 'string', 'nonces should be hex strings') - return parseInt(nonce, 16) - }) - const highestNonce = Math.max.apply(null, nonces) - return highestNonce - } - - _getHighestContinuousFrom (txList, startPoint) { - const nonces = txList.map((txMeta) => { - const nonce = txMeta.txParams.nonce - assert(typeof nonce, 'string', 'nonces should be hex strings') - return parseInt(nonce, 16) - }) - - let highest = startPoint - while (nonces.includes(highest)) { - highest++ - } - - return { name: 'local', nonce: highest, details: { startPoint, highest } } - } - - // this is a hotfix for the fact that the blockTracker will - // change when the network changes - _getBlockTracker () { - return this.provider._blockTracker - } -} - -module.exports = NonceTracker 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} 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/pending-tx-tracker.js b/app/scripts/lib/pending-tx-tracker.js deleted file mode 100644 index e8869e6b8..000000000 --- a/app/scripts/lib/pending-tx-tracker.js +++ /dev/null @@ -1,189 +0,0 @@ -const EventEmitter = require('events') -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 - - As well as continues broadcast while in the pending state - - ~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, - } - -*/ - -module.exports = class PendingTransactionTracker extends EventEmitter { - constructor (config) { - super() - this.query = new EthQuery(config.provider) - this.nonceTracker = config.nonceTracker - // default is one day - this.getPendingTransactions = config.getPendingTransactions - this.getCompletedTransactions = config.getCompletedTransactions - this.publishTransaction = config.publishTransaction - this._checkPendingTxs() - } - - // checks if a signed tx is in a block and - // if included sets the tx status as 'confirmed' - checkForTxInBlock (block) { - const signedTxList = this.getPendingTransactions() - if (!signedTxList.length) return - signedTxList.forEach((txMeta) => { - const txHash = txMeta.hash - const txId = txMeta.id - - if (!txHash) { - const noTxHashErr = new Error('We had an error while submitting this transaction, please try again.') - noTxHashErr.name = 'NoTxHashError' - this.emit('tx:failed', txId, noTxHashErr) - return - } - - - block.transactions.forEach((tx) => { - if (tx.hash === txHash) this.emit('tx:confirmed', txId) - }) - }) - } - - queryPendingTxs ({ oldBlock, newBlock }) { - // check pending transactions on start - if (!oldBlock) { - this._checkPendingTxs() - return - } - // if we synced by more than one block, check for missed pending transactions - const diff = Number.parseInt(newBlock.number, 16) - Number.parseInt(oldBlock.number, 16) - if (diff > 1) this._checkPendingTxs() - } - - - resubmitPendingTxs (block) { - const pending = this.getPendingTransactions() - // only try resubmitting if their are transactions to resubmit - if (!pending.length) return - pending.forEach((txMeta) => this._resubmitTx(txMeta, block.number).catch((err) => { - /* - Dont marked as failed if the error is a "known" transaction warning - "there is already a transaction with the same sender-nonce - but higher/same gas price" - - Also don't mark as failed if it has ever been broadcast successfully. - A successful broadcast means it may still be mined. - */ - const errorMessage = err.message.toLowerCase() - const isKnownTx = ( - // geth - errorMessage.includes('replacement transaction underpriced') || - errorMessage.includes('known transaction') || - // parity - errorMessage.includes('gas price too low to replace') || - errorMessage.includes('transaction with the same hash was already imported') || - // other - errorMessage.includes('gateway timeout') || - errorMessage.includes('nonce too low') - ) - // ignore resubmit warnings, return early - if (isKnownTx) return - // encountered real error - transition to error state - txMeta.warning = { - error: errorMessage, - message: 'There was an error when resubmitting this transaction.', - } - this.emit('tx:warning', txMeta, err) - })) - } - - async _resubmitTx (txMeta, latestBlockNumber) { - if (!txMeta.firstRetryBlockNumber) { - this.emit('tx:block-update', txMeta, latestBlockNumber) - } - - const firstRetryBlockNumber = txMeta.firstRetryBlockNumber || latestBlockNumber - const txBlockDistance = Number.parseInt(latestBlockNumber, 16) - Number.parseInt(firstRetryBlockNumber, 16) - - const retryCount = txMeta.retryCount || 0 - - // Exponential backoff to limit retries at publishing - if (txBlockDistance <= Math.pow(2, retryCount) - 1) return - - // Only auto-submit already-signed txs: - if (!('rawTx' in txMeta)) return - - const rawTx = txMeta.rawTx - const txHash = await this.publishTransaction(rawTx) - - // Increment successful tries: - this.emit('tx:retry', txMeta) - return txHash - } - - async _checkPendingTx (txMeta) { - const txHash = txMeta.hash - const txId = txMeta.id - - // extra check in case there was an uncaught error during the - // signature and submission process - if (!txHash) { - const noTxHashErr = new Error('We had an error while submitting this transaction, please try again.') - noTxHashErr.name = 'NoTxHashError' - this.emit('tx:failed', txId, noTxHashErr) - return - } - - // If another tx with the same nonce is mined, set as failed. - const taken = await this._checkIfNonceIsTaken(txMeta) - if (taken) { - const nonceTakenErr = new Error('Another transaction with this nonce has been mined.') - nonceTakenErr.name = 'NonceTakenErr' - return this.emit('tx:failed', txId, nonceTakenErr) - } - - // get latest transaction status - let txParams - try { - txParams = await this.query.getTransactionByHash(txHash) - if (!txParams) return - if (txParams.blockNumber) { - this.emit('tx:confirmed', txId) - } - } catch (err) { - txMeta.warning = { - error: err.message, - message: 'There was a problem loading this transaction.', - } - this.emit('tx:warning', txMeta, err) - } - } - - // checks the network for signed txs and - // if confirmed sets the tx status as 'confirmed' - async _checkPendingTxs () { - const signedTxList = this.getPendingTransactions() - // in order to keep the nonceTracker accurate we block it while updating pending transactions - const nonceGlobalLock = await this.nonceTracker.getGlobalLock() - try { - await Promise.all(signedTxList.map((txMeta) => this._checkPendingTx(txMeta))) - } catch (err) { - console.error('PendingTransactionWatcher - Error updating pending transactions') - console.error(err) - } - nonceGlobalLock.releaseLock() - } - - async _checkIfNonceIsTaken (txMeta) { - const address = txMeta.txParams.from - const completed = this.getCompletedTransactions(address) - const sameNonce = completed.filter((otherMeta) => { - return otherMeta.txParams.nonce === txMeta.txParams.nonce - }) - return sameNonce.length > 0 - } - -} 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} 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} 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/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} Promises undefined + * + */ verifyAccounts (createdAccounts, seedWords) { return new Promise((resolve, reject) => { 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/tx-gas-utils.js b/app/scripts/lib/tx-gas-utils.js deleted file mode 100644 index c579e462a..000000000 --- a/app/scripts/lib/tx-gas-utils.js +++ /dev/null @@ -1,103 +0,0 @@ -const EthQuery = require('ethjs-query') -const { - hexToBn, - BnMultiplyByFraction, - bnToHex, -} = require('./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 -its passed ethquery -and used to do things like calculate gas of a tx. -*/ - -module.exports = class TxGasUtil { - - constructor (provider) { - this.query = new EthQuery(provider) - } - - async analyzeGasUsage (txMeta) { - const block = await this.query.getBlockByNumber('latest', true) - let estimatedGasHex - try { - estimatedGasHex = await this.estimateTxGas(txMeta, block.gasLimit) - } catch (err) { - const simulationFailed = ( - err.message.includes('Transaction execution error.') || - err.message.includes('gas required exceeds allowance or always failing transaction') - ) - if (simulationFailed) { - txMeta.simulationFails = true - return txMeta - } - } - this.setTxGas(txMeta, block.gasLimit, estimatedGasHex) - return txMeta - } - - async estimateTxGas (txMeta, blockGasLimitHex) { - const txParams = txMeta.txParams - - // check if gasLimit is already specified - txMeta.gasLimitSpecified = Boolean(txParams.gas) - - // if it is, use that value - if (txMeta.gasLimitSpecified) { - return txParams.gas - } - - // if recipient has no code, gas is 21k max: - const recipient = txParams.to - const hasRecipient = Boolean(recipient) - let code - if (recipient) code = await this.query.getCode(recipient) - - if (hasRecipient && (!code || code === '0x')) { - txParams.gas = SIMPLE_GAS_COST - txMeta.simpleSend = true // Prevents buffer addition - return SIMPLE_GAS_COST - } - - // if not, fall back to block gasLimit - const blockGasLimitBN = hexToBn(blockGasLimitHex) - const saferGasLimitBN = BnMultiplyByFraction(blockGasLimitBN, 19, 20) - txParams.gas = bnToHex(saferGasLimitBN) - - // run tx - return await this.query.estimateGas(txParams) - } - - setTxGas (txMeta, blockGasLimitHex, estimatedGasHex) { - txMeta.estimatedGas = addHexPrefix(estimatedGasHex) - const txParams = txMeta.txParams - - // if gasLimit was specified and doesnt OOG, - // use original specified amount - if (txMeta.gasLimitSpecified || txMeta.simpleSend) { - txMeta.estimatedGas = txParams.gas - return - } - // if gasLimit not originally specified, - // try adding an additional gas buffer to our estimation for safety - const recommendedGasHex = this.addGasBuffer(txMeta.estimatedGas, blockGasLimitHex) - txParams.gas = recommendedGasHex - return - } - - addGasBuffer (initialGasLimitHex, blockGasLimitHex) { - const initialGasLimitBn = hexToBn(initialGasLimitHex) - const blockGasLimitBn = hexToBn(blockGasLimitHex) - const upperGasLimitBn = blockGasLimitBn.muln(0.9) - const bufferedGasLimitBn = initialGasLimitBn.muln(1.5) - - // if initialGasLimit is above blockGasLimit, dont modify it - if (initialGasLimitBn.gt(upperGasLimitBn)) return bnToHex(initialGasLimitBn) - // if bufferedGasLimit is below blockGasLimit, use bufferedGasLimit - if (bufferedGasLimitBn.lt(upperGasLimitBn)) return bnToHex(bufferedGasLimitBn) - // otherwise use blockGasLimit - return bnToHex(upperGasLimitBn) - } -} \ No newline at end of file diff --git a/app/scripts/lib/tx-state-history-helper.js b/app/scripts/lib/tx-state-history-helper.js deleted file mode 100644 index 94c7b6792..000000000 --- a/app/scripts/lib/tx-state-history-helper.js +++ /dev/null @@ -1,41 +0,0 @@ -const jsonDiffer = require('fast-json-patch') -const clone = require('clone') - -module.exports = { - generateHistoryEntry, - replayHistory, - snapshotFromTxMeta, - migrateFromSnapshotsToDiffs, -} - - -function migrateFromSnapshotsToDiffs (longHistory) { - return ( - longHistory - // convert non-initial history entries into diffs - .map((entry, index) => { - if (index === 0) return entry - return generateHistoryEntry(longHistory[index - 1], entry) - }) - ) -} - -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 - if (note && entry[0]) entry[0].note = note - return entry -} - -function replayHistory (_shortHistory) { - const shortHistory = clone(_shortHistory) - return shortHistory.reduce((val, entry) => jsonDiffer.applyPatch(val, entry).newDocument) -} - -function snapshotFromTxMeta (txMeta) { - // create txMeta snapshot for history - const snapshot = clone(txMeta) - // dont include previous history in this snapshot - delete snapshot.history - return snapshot -} diff --git a/app/scripts/lib/tx-state-manager.js b/app/scripts/lib/tx-state-manager.js deleted file mode 100644 index c6d10ee62..000000000 --- a/app/scripts/lib/tx-state-manager.js +++ /dev/null @@ -1,303 +0,0 @@ -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 { - constructor ({ initState, txHistoryLimit, getNetwork }) { - super() - - this.store = new ObservableStore( - extend({ - transactions: [], - }, initState)) - this.txHistoryLimit = txHistoryLimit - this.getNetwork = getNetwork - } - - generateTxMeta (opts) { - return extend({ - id: createId(), - time: (new Date()).getTime(), - status: 'unapproved', - metamaskNetworkId: this.getNetwork(), - loadingDefaults: true, - }, opts) - } - - getTxList () { - const network = this.getNetwork() - const fullTxList = this.getFullTxList() - return fullTxList.filter((txMeta) => txMeta.metamaskNetworkId === network) - } - - getFullTxList () { - return this.store.getState().transactions - } - - // Returns the tx list - getUnapprovedTxList () { - const txList = this.getTxsByMetaData('status', 'unapproved') - return txList.reduce((result, tx) => { - result[tx.id] = tx - return result - }, {}) - } - - getPendingTransactions (address) { - const opts = { status: 'submitted' } - if (address) opts.from = address - return this.getFilteredTxList(opts) - } - - getConfirmedTransactions (address) { - const opts = { status: 'confirmed' } - if (address) opts.from = address - return this.getFilteredTxList(opts) - } - - addTx (txMeta) { - this.once(`${txMeta.id}:signed`, function (txId) { - this.removeAllListeners(`${txMeta.id}:rejected`) - }) - this.once(`${txMeta.id}:rejected`, function (txId) { - this.removeAllListeners(`${txMeta.id}:signed`) - }) - // initialize history - txMeta.history = [] - // capture initial snapshot of txMeta for history - const snapshot = txStateHistoryHelper.snapshotFromTxMeta(txMeta) - txMeta.history.push(snapshot) - - const transactions = this.getFullTxList() - const txCount = transactions.length - const txHistoryLimit = this.txHistoryLimit - - // checks if the length of the tx history is - // longer then desired persistence limit - // and then if it is removes only confirmed - // 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') - if (index !== -1) { - transactions.splice(index, 1) - } - } - transactions.push(txMeta) - this._saveTxList(transactions) - return txMeta - } - // gets tx by Id and returns it - getTx (txId) { - const txMeta = this.getTxsByMetaData('id', txId)[0] - return txMeta - } - - updateTx (txMeta, note) { - // validate txParams - if (txMeta.txParams) { - if (typeof txMeta.txParams.data === 'undefined') { - delete txMeta.txParams.data - } - - this.validateTxParams(txMeta.txParams) - } - - // create txMeta snapshot for history - const currentState = txStateHistoryHelper.snapshotFromTxMeta(txMeta) - // recover previous tx state obj - const previousState = txStateHistoryHelper.replayHistory(txMeta.history) - // generate history entry and add to history - const entry = txStateHistoryHelper.generateHistoryEntry(previousState, currentState, note) - txMeta.history.push(entry) - - // commit txMeta to state - const txId = txMeta.id - const txList = this.getFullTxList() - const index = txList.findIndex(txData => txData.id === txId) - txList[index] = txMeta - this._saveTxList(txList) - } - - - // merges txParams obj onto txData.txParams - // use extend to ensure that all fields are filled - 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) { - Object.keys(txParams).forEach((key) => { - const value = txParams[key] - // validate types - switch (key) { - case 'chainId': - if (typeof value !== 'number' && typeof value !== 'string') throw new Error(`${key} in txParams is not a Number or hex string. got: (${value})`) - break - default: - if (typeof value !== 'string') throw new Error(`${key} in txParams is not a string. got: (${value})`) - if (!ethUtil.isHexPrefixed(value)) throw new Error(`${key} in txParams is not hex prefixed. got: (${value})`) - break - } - }) - } - -/* - 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 - options matching - - ****************HINT**************** - | `err: undefined` is like looking | - | for a tx with no err | - | so you can also search txs that | - | dont have something as well by | - | setting the value as undefined | - ************************************ - - this is for things like filtering a the tx list - for only tx's from 1 account - or for filltering for all txs from one account - and that have been 'confirmed' - */ - getFilteredTxList (opts, initialList) { - let filteredTxList = initialList - Object.keys(opts).forEach((key) => { - filteredTxList = this.getTxsByMetaData(key, opts[key], filteredTxList) - }) - return filteredTxList - } - - getTxsByMetaData (key, value, txList = this.getTxList()) { - return txList.filter((txMeta) => { - if (txMeta.txParams[key]) { - return txMeta.txParams[key] === value - } else { - return txMeta[key] === value - } - }) - } - - // get::set status - - // should return the status of the tx. - getTxStatus (txId) { - const txMeta = this.getTx(txId) - return txMeta.status - } - - // should update the status of the tx to 'rejected'. - setTxStatusRejected (txId) { - this._setTxStatus(txId, 'rejected') - } - - // should update the status of the tx to 'unapproved'. - setTxStatusUnapproved (txId) { - this._setTxStatus(txId, 'unapproved') - } - // should update the status of the tx to 'approved'. - setTxStatusApproved (txId) { - this._setTxStatus(txId, 'approved') - } - - // should update the status of the tx to 'signed'. - 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 - setTxStatusSubmitted (txId) { - const txMeta = this.getTx(txId) - txMeta.submittedTime = (new Date()).getTime() - this.updateTx(txMeta, 'txStateManager - add submitted time stamp') - this._setTxStatus(txId, 'submitted') - } - - // should update the status of the tx to 'confirmed'. - setTxStatusConfirmed (txId) { - this._setTxStatus(txId, 'confirmed') - } - - // should update the status dropped - setTxStatusDropped (txId) { - this._setTxStatus(txId, 'dropped') - } - - - setTxStatusFailed (txId, err) { - const txMeta = this.getTx(txId) - txMeta.err = { - message: err.toString(), - stack: err.stack, - } - this.updateTx(txMeta) - this._setTxStatus(txId, 'failed') - } - - wipeTransactions (address) { - // network only tx - const txs = this.getFullTxList() - const network = this.getNetwork() - - // Filter out the ones from the current account and network - const otherAccountTxs = txs.filter((txMeta) => !(txMeta.txParams.from === address && txMeta.metamaskNetworkId === network)) - - // Update state - this._saveTxList(otherAccountTxs) - } -// -// PRIVATE METHODS -// - - // Should find the tx in the tx list and - // update it. - // should set the status in txData - // - `'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. - _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') - } - - // Saves the new/updated txList. - // Function is intended only for internal use - _saveTxList (transactions) { - this.store.updateState({ transactions }) - } -} 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} 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} 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/metamask-controller.js b/app/scripts/metamask-controller.js index edde38819..a90acb4d5 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -309,7 +309,6 @@ module.exports = class MetamaskController extends EventEmitter { lostAccounts: this.configManager.getLostAccounts(), seedWords: this.configManager.getSeedWords(), forgottenPassword: this.configManager.getPasswordForgotten(), - isRevealingSeedWords: Boolean(this.configManager.getIsRevealingSeedWords()), }, } } @@ -351,7 +350,6 @@ module.exports = class MetamaskController extends EventEmitter { clearSeedWordCache: this.clearSeedWordCache.bind(this), resetAccount: nodeify(this.resetAccount, this), importAccountWithStrategy: this.importAccountWithStrategy.bind(this), - setIsRevealingSeedWords: this.configManager.setIsRevealingSeedWords.bind(this.configManager), // vault management submitPassword: nodeify(keyringController.submitPassword, keyringController), @@ -384,6 +382,7 @@ module.exports = class MetamaskController extends EventEmitter { updateTransaction: nodeify(txController.updateTransaction, txController), updateAndApproveTransaction: nodeify(txController.updateAndApproveTransaction, txController), retryTransaction: nodeify(this.retryTransaction, this), + isNonceTaken: nodeify(txController.isNonceTaken, txController), // messageManager signMessage: nodeify(this.signMessage, this), 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 = { -- cgit v1.2.3 From 0f20fce9b761fc0aa16d61b2b739fa7f9b9f6a7d Mon Sep 17 00:00:00 2001 From: Dan Date: Wed, 23 May 2018 14:13:25 -0230 Subject: Auto update gas estimate when to changes. --- app/scripts/metamask-controller.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) (limited to 'app/scripts') diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 1b1d26886..d3d15e737 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -383,6 +383,8 @@ module.exports = class MetamaskController extends EventEmitter { updateAndApproveTransaction: nodeify(txController.updateAndApproveTransaction, txController), retryTransaction: nodeify(this.retryTransaction, this), getFilteredTxList: nodeify(txController.getFilteredTxList, txController), + isNonceTaken: nodeify(txController.isNonceTaken, txController), + estimateGas: nodeify(this.estimateGas, this), // messageManager signMessage: nodeify(this.signMessage, this), @@ -921,6 +923,18 @@ module.exports = class MetamaskController extends EventEmitter { return state } + estimateGas (estimateGasParams) { + return new Promise((resolve, reject) => { + return this.txController.txGasUtil.query.estimateGas(estimateGasParams, (err, res) => { + if (err) { + return reject(err) + } + + return resolve(res) + }) + }) + } + //============================================================================= // PASSWORD MANAGEMENT //============================================================================= -- cgit v1.2.3 From c53c5d5c9e2a384c6b0a17b844919f9fff9f960e Mon Sep 17 00:00:00 2001 From: Dan Finlay Date: Wed, 6 Jun 2018 16:46:39 -0700 Subject: Fix bug where reset account would not work. Fixes #4462 Ensures that resetAccount() can work on non-stock providers. I'm unclear how this was ever working, this code hasn't moved in months, but users report it recently breaking. Maybe we only recently pushed it to prod. --- app/scripts/controllers/network/network.js | 11 +++++++++-- app/scripts/metamask-controller.js | 5 +---- 2 files changed, 10 insertions(+), 6 deletions(-) (limited to 'app/scripts') diff --git a/app/scripts/controllers/network/network.js b/app/scripts/controllers/network/network.js index 93fde7c57..5e0c63e7d 100644 --- a/app/scripts/controllers/network/network.js +++ b/app/scripts/controllers/network/network.js @@ -89,14 +89,21 @@ module.exports = class NetworkController extends EventEmitter { type: 'rpc', rpcTarget, } - this.providerStore.updateState(providerConfig) - this._switchNetwork(providerConfig) + this.providerConfig = providerConfig } async setProviderType (type) { assert.notEqual(type, 'rpc', `NetworkController - cannot call "setProviderType" with type 'rpc'. use "setRpcTarget"`) assert(INFURA_PROVIDER_TYPES.includes(type) || type === LOCALHOST, `NetworkController - Unknown rpc type "${type}"`) const providerConfig = { type } + this.providerConfig = providerConfig + } + + resetConnection () { + this.providerConfig = this.getProviderConfig() + } + + set providerConfig (providerConfig) { this.providerStore.updateState(providerConfig) this._switchNetwork(providerConfig) } diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 1bb0af5ee..873ba7995 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -628,10 +628,7 @@ module.exports = class MetamaskController extends EventEmitter { async resetAccount () { const selectedAddress = this.preferencesController.getSelectedAddress() this.txController.wipeTransactions(selectedAddress) - - const networkController = this.networkController - const oldType = networkController.getProviderConfig().type - await networkController.setProviderType(oldType, true) + this.networkController.resetConnection() return selectedAddress } -- cgit v1.2.3 From 8f93e341750b99b8ff0e66f2a6831799c7d9ab58 Mon Sep 17 00:00:00 2001 From: kumavis Date: Tue, 12 Jun 2018 10:55:54 -0700 Subject: nonce-tracker - wrap nonce calculations in try-catch and release lock on error --- .../controllers/transactions/nonce-tracker.js | 50 ++++++++++++---------- 1 file changed, 28 insertions(+), 22 deletions(-) (limited to 'app/scripts') diff --git a/app/scripts/controllers/transactions/nonce-tracker.js b/app/scripts/controllers/transactions/nonce-tracker.js index f8cdc5523..ca340bae4 100644 --- a/app/scripts/controllers/transactions/nonce-tracker.js +++ b/app/scripts/controllers/transactions/nonce-tracker.js @@ -49,29 +49,35 @@ class NonceTracker { await this._globalMutexFree() // await lock free, then take lock const releaseLock = await this._takeMutex(address) - // evaluate multiple nextNonce strategies - const nonceDetails = {} - const networkNonceResult = await this._getNetworkNextNonce(address) - const highestLocallyConfirmed = this._getHighestLocallyConfirmed(address) - const nextNetworkNonce = networkNonceResult.nonce - const highestSuggested = Math.max(nextNetworkNonce, highestLocallyConfirmed) - - const pendingTxs = this.getPendingTransactions(address) - const localNonceResult = this._getHighestContinuousFrom(pendingTxs, highestSuggested) || 0 - - nonceDetails.params = { - highestLocallyConfirmed, - highestSuggested, - nextNetworkNonce, + try { + // evaluate multiple nextNonce strategies + const nonceDetails = {} + const networkNonceResult = await this._getNetworkNextNonce(address) + const highestLocallyConfirmed = this._getHighestLocallyConfirmed(address) + const nextNetworkNonce = networkNonceResult.nonce + const highestSuggested = Math.max(nextNetworkNonce, highestLocallyConfirmed) + + const pendingTxs = this.getPendingTransactions(address) + const localNonceResult = this._getHighestContinuousFrom(pendingTxs, highestSuggested) || 0 + + nonceDetails.params = { + highestLocallyConfirmed, + highestSuggested, + nextNetworkNonce, + } + nonceDetails.local = localNonceResult + nonceDetails.network = networkNonceResult + + const nextNonce = Math.max(networkNonceResult.nonce, localNonceResult.nonce) + assert(Number.isInteger(nextNonce), `nonce-tracker - nextNonce is not an integer - got: (${typeof nextNonce}) "${nextNonce}"`) + + // return nonce and release cb + return { nextNonce, nonceDetails, releaseLock } + } catch (err) { + // release lock if we encounter an error + releaseLock() + throw err } - nonceDetails.local = localNonceResult - nonceDetails.network = networkNonceResult - - const nextNonce = Math.max(networkNonceResult.nonce, localNonceResult.nonce) - assert(Number.isInteger(nextNonce), `nonce-tracker - nextNonce is not an integer - got: (${typeof nextNonce}) "${nextNonce}"`) - - // return nonce and release cb - return { nextNonce, nonceDetails, releaseLock } } async _getCurrentBlock () { -- cgit v1.2.3 From 177cc3f280f26c5fb4cfc1b934e95b9d16def1a6 Mon Sep 17 00:00:00 2001 From: kumavis Date: Tue, 12 Jun 2018 11:51:35 -0700 Subject: metamask - ensure all nonce locks are released --- app/scripts/controllers/transactions/index.js | 7 ++++++- .../controllers/transactions/nonce-tracker.js | 4 ++-- .../controllers/transactions/pending-tx-tracker.js | 4 ++-- app/scripts/metamask-controller.js | 20 ++++++++------------ 4 files changed, 18 insertions(+), 17 deletions(-) (limited to 'app/scripts') diff --git a/app/scripts/controllers/transactions/index.js b/app/scripts/controllers/transactions/index.js index b53947e27..339052543 100644 --- a/app/scripts/controllers/transactions/index.js +++ b/app/scripts/controllers/transactions/index.js @@ -264,7 +264,12 @@ class TransactionController extends EventEmitter { // must set transaction to submitted/failed before releasing lock nonceLock.releaseLock() } catch (err) { - this.txStateManager.setTxStatusFailed(txId, err) + // this is try-catch wrapped so that we can guarantee that the nonceLock is released + try { + this.txStateManager.setTxStatusFailed(txId, err) + } catch (err) { + console.error(err) + } // must set transaction to submitted/failed before releasing lock if (nonceLock) nonceLock.releaseLock() // continue with error chain diff --git a/app/scripts/controllers/transactions/nonce-tracker.js b/app/scripts/controllers/transactions/nonce-tracker.js index ca340bae4..35ca08d6c 100644 --- a/app/scripts/controllers/transactions/nonce-tracker.js +++ b/app/scripts/controllers/transactions/nonce-tracker.js @@ -91,8 +91,8 @@ class NonceTracker { async _globalMutexFree () { const globalMutex = this._lookupMutex('global') - const release = await globalMutex.acquire() - release() + const releaseLock = await globalMutex.acquire() + releaseLock() } async _takeMutex (lockId) { diff --git a/app/scripts/controllers/transactions/pending-tx-tracker.js b/app/scripts/controllers/transactions/pending-tx-tracker.js index 6e2fcb40b..4e41cdaf8 100644 --- a/app/scripts/controllers/transactions/pending-tx-tracker.js +++ b/app/scripts/controllers/transactions/pending-tx-tracker.js @@ -196,14 +196,14 @@ class PendingTransactionTracker extends EventEmitter { async _checkPendingTxs () { const signedTxList = this.getPendingTransactions() // in order to keep the nonceTracker accurate we block it while updating pending transactions - const nonceGlobalLock = await this.nonceTracker.getGlobalLock() + const { releaseLock } = await this.nonceTracker.getGlobalLock() try { await Promise.all(signedTxList.map((txMeta) => this._checkPendingTx(txMeta))) } catch (err) { log.error('PendingTransactionWatcher - Error updating pending transactions') log.error(err) } - nonceGlobalLock.releaseLock() + releaseLock() } /** diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index a362e3826..e444180cc 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -436,28 +436,24 @@ module.exports = class MetamaskController extends EventEmitter { * @returns {Object} vault */ async createNewVaultAndKeychain (password) { - const release = await this.createVaultMutex.acquire() - let vault - + const releaseLock = await this.createVaultMutex.acquire() try { + let vault const accounts = await this.keyringController.getAccounts() - if (accounts.length > 0) { vault = await this.keyringController.fullUpdate() - } else { vault = await this.keyringController.createNewVaultAndKeychain(password) const accounts = await this.keyringController.getAccounts() this.preferencesController.setAddresses(accounts) this.selectFirstIdentity() } - release() + releaseLock() + return vault } catch (err) { - release() + releaseLock() throw err } - - return vault } /** @@ -466,7 +462,7 @@ module.exports = class MetamaskController extends EventEmitter { * @param {} seed */ async createNewVaultAndRestore (password, seed) { - const release = await this.createVaultMutex.acquire() + const releaseLock = await this.createVaultMutex.acquire() try { // clear known identities this.preferencesController.setAddresses([]) @@ -476,10 +472,10 @@ module.exports = class MetamaskController extends EventEmitter { const accounts = await this.keyringController.getAccounts() this.preferencesController.setAddresses(accounts) this.selectFirstIdentity() - release() + releaseLock() return vault } catch (err) { - release() + releaseLock() throw err } } -- cgit v1.2.3 From 604289c96cde7e5f4634fe5e76a50dfa9174fcbd Mon Sep 17 00:00:00 2001 From: kumavis Date: Tue, 12 Jun 2018 12:08:06 -0700 Subject: controllers - transaction - prefer log over console --- app/scripts/controllers/transactions/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'app/scripts') diff --git a/app/scripts/controllers/transactions/index.js b/app/scripts/controllers/transactions/index.js index 339052543..8e2288aed 100644 --- a/app/scripts/controllers/transactions/index.js +++ b/app/scripts/controllers/transactions/index.js @@ -165,7 +165,7 @@ class TransactionController extends EventEmitter { // add default tx params txMeta = await this.addTxGasDefaults(txMeta) } catch (error) { - console.log(error) + log.warn(error) this.txStateManager.setTxStatusFailed(txMeta.id, error) throw error } @@ -268,7 +268,7 @@ class TransactionController extends EventEmitter { try { this.txStateManager.setTxStatusFailed(txId, err) } catch (err) { - console.error(err) + log.error(err) } // must set transaction to submitted/failed before releasing lock if (nonceLock) nonceLock.releaseLock() -- cgit v1.2.3