diff --git a/app/scripts/background.js b/app/scripts/background.js index 7cb25d8bf..854b679da 100644 --- a/app/scripts/background.js +++ b/app/scripts/background.js @@ -23,7 +23,7 @@ const controller = new MetamaskController({ loadData, }) const keyringController = controller.keyringController - +const txManager = controller.txManager function triggerUi () { if (!popupIsOpen) notification.show() } @@ -97,12 +97,11 @@ function setupControllerConnection (stream) { // plugin badge text // -keyringController.on('update', updateBadge) +txManager.on('update', updateBadge) function updateBadge () { var label = '' - var unconfTxs = controller.configManager.unconfirmedTxs() - var unconfTxLen = Object.keys(unconfTxs).length + var unconfTxLen = controller.txManager.unConftxCount var unconfMsgs = messageManager.unconfirmedMsgs() var unconfMsgLen = Object.keys(unconfMsgs).length var count = unconfTxLen + unconfMsgLen @@ -113,6 +112,25 @@ function updateBadge () { extension.browserAction.setBadgeBackgroundColor({ color: '#506F8B' }) } +// txManger :: tx approvals and rejection cb's + +txManager.on('signed', function (txId) { + var approvalCb = this._unconfTxCbs[txId] + + approvalCb(null, true) + // clean up + delete this._unconfTxCbs[txId] +}) + +txManager.on('rejected', function (txId) { + var approvalCb = this._unconfTxCbs[txId] + approvalCb(null, false) + // clean up + delete this._unconfTxCbs[txId] +}) + +// data :: setters/getters + function loadData () { var oldData = getOldStyleData() var newData diff --git a/app/scripts/keyring-controller.js b/app/scripts/keyring-controller.js index 40c9695dd..37b3a947f 100644 --- a/app/scripts/keyring-controller.js +++ b/app/scripts/keyring-controller.js @@ -1,6 +1,4 @@ -const async = require('async') const ethUtil = require('ethereumjs-util') -const EthQuery = require('eth-query') const bip39 = require('bip39') const Transaction = require('ethereumjs-tx') const EventEmitter = require('events').EventEmitter @@ -36,7 +34,7 @@ module.exports = class KeyringController extends EventEmitter { this.ethStore = opts.ethStore this.encryptor = encryptor this.keyringTypes = keyringTypes - + this.txManager = opts.txManager this.keyrings = [] this.identities = {} // Essentially a name hash @@ -73,7 +71,7 @@ module.exports = class KeyringController extends EventEmitter { // or accept a state-resolving promise to consume their results. // // Not all methods end with this, that might be a nice refactor. - fullUpdate() { + fullUpdate () { this.emit('update') return Promise.resolve(this.getState()) } @@ -102,8 +100,8 @@ module.exports = class KeyringController extends EventEmitter { isInitialized: (!!wallet || !!vault), isUnlocked: Boolean(this.password), isDisclaimerConfirmed: this.configManager.getConfirmedDisclaimer(), // AUDIT this.configManager.getConfirmedDisclaimer(), - unconfTxs: this.configManager.unconfirmedTxs(), - transactions: this.configManager.getTxList(), + transactions: this.txManager.getTxList(), + unconfTxs: this.txManager.getUnapprovedTxList(), unconfMsgs: messageManager.unconfirmedMsgs(), messages: messageManager.getMsgList(), selectedAccount: address, @@ -341,7 +339,7 @@ module.exports = class KeyringController extends EventEmitter { // Caches the requesting Dapp's callback, `onTxDoneCb`, for resolution later. addUnconfirmedTransaction (txParams, onTxDoneCb, cb) { const configManager = this.configManager - + const txManager = this.txManager // create txData obj with parameters and meta data var time = (new Date()).getTime() var txId = createId() @@ -351,95 +349,26 @@ module.exports = class KeyringController extends EventEmitter { id: txId, txParams: txParams, time: time, - status: 'unconfirmed', + status: 'unapproved', gasMultiplier: configManager.getGasMultiplier() || 1, metamaskNetworkId: this.getNetwork(), } - // keep the onTxDoneCb around for after approval/denial (requires user interaction) // This onTxDoneCb fires completion to the Dapp's write operation. - this._unconfTxCbs[txId] = onTxDoneCb - - var provider = this.ethStore._query.currentProvider - var query = new EthQuery(provider) - + txManager.txProviderUtils.analyzeGasUsage(txData, this.txDidComplete.bind(this, txData, onTxDoneCb, cb)) // calculate metadata for tx - this.analyzeTxGasUsage(query, txData, this.txDidComplete.bind(this, txData, cb)) - } - - estimateTxGas (query, txData, blockGasLimitHex, cb) { - const txParams = txData.txParams - // check if gasLimit is already specified - txData.gasLimitSpecified = Boolean(txParams.gas) - // if not, fallback to block gasLimit - if (!txData.gasLimitSpecified) { - txParams.gas = blockGasLimitHex - } - // run tx, see if it will OOG - query.estimateGas(txParams, cb) - } - - checkForTxGasError (txData, estimatedGasHex, cb) { - txData.estimatedGas = estimatedGasHex - // all gas used - must be an error - if (estimatedGasHex === txData.txParams.gas) { - txData.simulationFails = true - } - cb() - } - - setTxGas (txData, blockGasLimitHex, cb) { - const txParams = txData.txParams - // if OOG, nothing more to do - if (txData.simulationFails) { - cb() - return - } - // if gasLimit was specified and doesnt OOG, - // use original specified amount - if (txData.gasLimitSpecified) { - txData.estimatedGas = txParams.gas - cb() - return - } - // if gasLimit not originally specified, - // try adding an additional gas buffer to our estimation for safety - const estimatedGasBn = new BN(ethUtil.stripHexPrefix(txData.estimatedGas), 16) - const blockGasLimitBn = new BN(ethUtil.stripHexPrefix(blockGasLimitHex), 16) - const estimationWithBuffer = new BN(this.addGasBuffer(estimatedGasBn), 16) - // added gas buffer is too high - if (estimationWithBuffer.gt(blockGasLimitBn)) { - txParams.gas = txData.estimatedGas - // added gas buffer is safe - } else { - const gasWithBufferHex = ethUtil.intToHex(estimationWithBuffer) - txParams.gas = gasWithBufferHex - } - cb() - return } - txDidComplete (txData, cb, err) { + txDidComplete (txData, onTxDoneCb, cb, err) { if (err) return cb(err) - const configManager = this.configManager - configManager.addTx(txData) + const txManager = this.txManager + txManager.addTx(txData, onTxDoneCb) // signal update this.emit('update') // signal completion of add tx cb(null, txData) } - analyzeTxGasUsage (query, txData, cb) { - query.getBlockByNumber('latest', true, (err, block) => { - if (err) return cb(err) - async.waterfall([ - this.estimateTxGas.bind(this, query, txData, block.gasLimit), - this.checkForTxGasError.bind(this, txData), - this.setTxGas.bind(this, txData, block.gasLimit), - ], cb) - }) - } - // Cancel Transaction // @string txId // @function cb @@ -448,14 +377,8 @@ module.exports = class KeyringController extends EventEmitter { // // Forgets any tx matching `txId`. cancelTransaction (txId, cb) { - const configManager = this.configManager - var approvalCb = this._unconfTxCbs[txId] || noop - - // reject tx - approvalCb(null, false) - // clean up - configManager.rejectTx(txId) - delete this._unconfTxCbs[txId] + const txManager = this.txManager + txManager.setTxStatusRejected(txId) if (cb && typeof cb === 'function') { cb() @@ -473,16 +396,10 @@ module.exports = class KeyringController extends EventEmitter { // // Calls back the cached Dapp's confirmation callback, also. approveTransaction (txId, cb) { - const configManager = this.configManager - var approvalCb = this._unconfTxCbs[txId] || noop - - // accept tx - cb() - approvalCb(null, true) - // clean up - configManager.confirmTx(txId) - delete this._unconfTxCbs[txId] + const txManager = this.txManager + txManager.setTxStatusSigned(txId) this.emit('update') + cb() } signTransaction (txParams, cb) { @@ -510,9 +427,9 @@ module.exports = class KeyringController extends EventEmitter { .then((tx) => { // Add the tx hash to the persisted meta-tx object var txHash = ethUtil.bufferToHex(tx.hash()) - var metaTx = this.configManager.getTx(txParams.metamaskId) + var metaTx = this.txManager.getTx(txParams.metamaskId) metaTx.hash = txHash - this.configManager.updateTx(metaTx) + this.txManager.updateTx(metaTx) // return raw serialized tx var rawTx = ethUtil.bufferToHex(tx.serialize()) @@ -586,7 +503,6 @@ module.exports = class KeyringController extends EventEmitter { // Attempts to sign the provided @object msgParams. signMessage (msgParams, cb) { try { - const msgId = msgParams.metamaskId delete msgParams.metamaskId const approvalCb = this._unconfMsgCbs[msgId] || noop diff --git a/app/scripts/lib/config-manager.js b/app/scripts/lib/config-manager.js index 59cc2b63c..913a76a6e 100644 --- a/app/scripts/lib/config-manager.js +++ b/app/scripts/lib/config-manager.js @@ -209,61 +209,12 @@ ConfigManager.prototype.getTxList = function () { } } -ConfigManager.prototype.unconfirmedTxs = function () { - var transactions = this.getTxList() - return transactions.filter(tx => tx.status === 'unconfirmed') - .reduce((result, tx) => { result[tx.id] = tx; return result }, {}) -} - -ConfigManager.prototype._saveTxList = function (txList) { +ConfigManager.prototype.setTxList = function (txList) { var data = this.migrator.getData() data.transactions = txList this.setData(data) } -ConfigManager.prototype.addTx = function (tx) { - var transactions = this.getTxList() - while (transactions.length > this.txLimit - 1) { - transactions.shift() - } - transactions.push(tx) - this._saveTxList(transactions) -} - -ConfigManager.prototype.getTx = function (txId) { - var transactions = this.getTxList() - var matching = transactions.filter(tx => tx.id === txId) - return matching.length > 0 ? matching[0] : null -} - -ConfigManager.prototype.confirmTx = function (txId) { - this._setTxStatus(txId, 'confirmed') -} - -ConfigManager.prototype.rejectTx = function (txId) { - this._setTxStatus(txId, 'rejected') -} - -ConfigManager.prototype._setTxStatus = function (txId, status) { - var tx = this.getTx(txId) - tx.status = status - this.updateTx(tx) -} - -ConfigManager.prototype.updateTx = function (tx) { - var transactions = this.getTxList() - var found, index - transactions.forEach((otherTx, i) => { - if (otherTx.id === tx.id) { - found = true - index = i - } - }) - if (found) { - transactions[index] = tx - } - this._saveTxList(transactions) -} // wallet nickname methods diff --git a/app/scripts/lib/idStore.js b/app/scripts/lib/idStore.js index d36504f13..71bee8026 100644 --- a/app/scripts/lib/idStore.js +++ b/app/scripts/lib/idStore.js @@ -13,6 +13,8 @@ const autoFaucet = require('./auto-faucet') const messageManager = require('./message-manager') const DEFAULT_RPC = 'https://testrpc.metamask.io/' const IdManagement = require('./id-management') +const TxManager = require('../transaction-manager') + module.exports = IdentityStore @@ -36,6 +38,11 @@ function IdentityStore (opts = {}) { } // not part of serilized metamask state - only kept in memory + this.txManager = new TxManager({ + TxListFromStore: opts.configManager.getTxList(), + setTxList: opts.configManager.setTxList.bind(opts.configManager), + txLimit: 40, + }) this._unconfTxCbs = {} this._unconfMsgCbs = {} } @@ -87,6 +94,7 @@ IdentityStore.prototype.recoverFromSeed = function (password, seed, cb) { IdentityStore.prototype.setStore = function (store) { this._ethStore = store + this.txManager.setProvider(this._ethStore._query.currentProvider) } IdentityStore.prototype.clearSeedWordCache = function (cb) { @@ -97,14 +105,15 @@ IdentityStore.prototype.clearSeedWordCache = function (cb) { IdentityStore.prototype.getState = function () { const configManager = this.configManager + const TxManager = this.txManager var seedWords = this.getSeedIfUnlocked() return clone(extend(this._currentState, { isInitialized: !!configManager.getWallet() && !seedWords, isUnlocked: this._isUnlocked(), seedWords: seedWords, isDisclaimerConfirmed: configManager.getConfirmedDisclaimer(), - unconfTxs: configManager.unconfirmedTxs(), - transactions: configManager.getTxList(), + unconfTxs: TxManager.getUnapprovedTxList(), + transactions: TxManager.getTxList(), unconfMsgs: messageManager.unconfirmedMsgs(), messages: messageManager.getMsgList(), selectedAddress: configManager.getSelectedAccount(), diff --git a/app/scripts/lib/provider-utils.js b/app/scripts/lib/provider-utils.js new file mode 100644 index 000000000..d1678c964 --- /dev/null +++ b/app/scripts/lib/provider-utils.js @@ -0,0 +1,106 @@ +const async = require('async') +const EthQuery = require('eth-query') +const ethUtil = require('ethereumjs-util') +const BN = ethUtil.BN +const ethBinToOps = require('eth-bin-to-ops') + +module.exports = class txProviderUtils { + constructor (provider) { + this.provider = provider + this.query = new EthQuery(provider) + } + analyzeGasUsage (txData, cb) { + var self = this + this.query.getBlockByNumber('latest', true, (err, block) => { + if (err) return cb(err) + async.waterfall([ + self.estimateTxGas.bind(self, txData, block.gasLimit), + self.checkForTxGasError.bind(self, txData), + self.setTxGas.bind(self, txData, block.gasLimit), + ], cb) + }) + } + + // perform static analyis on the target contract code + analyzeForDelegateCall (txParams, cb) { + if (txParams.to) { + this.query.getCode(txParams.to, function (err, result) { + if (err) return cb(err) + + var code = ethUtil.toBuffer(result) + if (code !== '0x') { + var ops = ethBinToOps(code) + var containsDelegateCall = ops.some((op) => op.name === 'DELEGATECALL') + cb(containsDelegateCall) + } else { + cb() + } + }) + } else { + cb() + } + } + + estimateTxGas (txData, blockGasLimitHex, cb) { + const txParams = txData.txParams + // check if gasLimit is already specified + txData.gasLimitSpecified = Boolean(txParams.gas) + // if not, fallback to block gasLimit + if (!txData.gasLimitSpecified) { + txParams.gas = blockGasLimitHex + } + // run tx, see if it will OOG + this.query.estimateGas(txParams, cb) + } + + checkForTxGasError (txData, estimatedGasHex, cb) { + txData.estimatedGas = estimatedGasHex + // all gas used - must be an error + if (estimatedGasHex === txData.txParams.gas) { + txData.simulationFails = true + } + cb() + } + + handleFork (block) { + + } + + setTxGas (txData, blockGasLimitHex, cb) { + const txParams = txData.txParams + // if OOG, nothing more to do + if (txData.simulationFails) { + cb() + return + } + // if gasLimit was specified and doesnt OOG, + // use original specified amount + if (txData.gasLimitSpecified) { + txData.estimatedGas = txParams.gas + cb() + return + } + // if gasLimit not originally specified, + // try adding an additional gas buffer to our estimation for safety + const estimatedGasBn = new BN(ethUtil.stripHexPrefix(txData.estimatedGas), 16) + const blockGasLimitBn = new BN(ethUtil.stripHexPrefix(blockGasLimitHex), 16) + const estimationWithBuffer = new BN(this.addGasBuffer(estimatedGasBn), 16) + // added gas buffer is too high + if (estimationWithBuffer.gt(blockGasLimitBn)) { + txParams.gas = txData.estimatedGas + // added gas buffer is safe + } else { + const gasWithBufferHex = ethUtil.intToHex(estimationWithBuffer) + txParams.gas = gasWithBufferHex + } + cb() + return + } + + addGasBuffer (gas) { + const gasBuffer = new BN('100000', 10) + const bnGas = new BN(ethUtil.stripHexPrefix(gas), 16) + const correct = bnGas.add(gasBuffer) + return ethUtil.addHexPrefix(correct.toString(16)) + } +} diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index ae761c753..3b70f63db 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -3,6 +3,7 @@ const EthStore = require('eth-store') const MetaMaskProvider = require('web3-provider-engine/zero.js') const KeyringController = require('./keyring-controller') const messageManager = require('./lib/message-manager') +const TxManager = require('./transaction-manager') const HostStore = require('./lib/remote-store.js').HostStore const Web3 = require('web3') const ConfigManager = require('./lib/config-manager') @@ -18,13 +19,21 @@ module.exports = class MetamaskController { this.opts = opts this.listeners = [] this.configManager = new ConfigManager(opts) + this.txManager = new TxManager({ + TxListFromStore: this.configManager.getTxList(), + txLimit: this.configManager.txLimit, + setTxList: this.configManager.setTxList.bind(this.configManager), + }) + this.keyringController = new KeyringController({ configManager: this.configManager, + txManager: this.txManager, getNetwork: this.getStateNetwork.bind(this), }) this.provider = this.initializeProvider(opts) this.ethStore = new EthStore(this.provider) this.keyringController.setStore(this.ethStore) + this.txManager.setProvider(this.provider) this.getNetwork() this.messageManager = messageManager this.publicConfigStore = this.initPublicConfigStore() @@ -49,7 +58,7 @@ module.exports = class MetamaskController { getApi () { const keyringController = this.keyringController - + const txManager = this.txManager return { getState: (cb) => { cb(null, this.getState()) }, setRpcTarget: this.setRpcTarget.bind(this), @@ -81,6 +90,9 @@ module.exports = class MetamaskController { signMessage: keyringController.signMessage.bind(keyringController), cancelMessage: keyringController.cancelMessage.bind(keyringController), + // forward directly to txManager + getUnapprovedTxList: txManager.getTxList.bind(txManager), + getFilterdTxList: txManager.getFilterdTxList.bind(txManager), // coinbase buyEth: this.buyEth.bind(this), // shapeshift @@ -154,7 +166,7 @@ module.exports = class MetamaskController { var web3 = new Web3(provider) this.web3 = web3 keyringController.web3 = web3 - + this.txManager.web3 = web3 provider.on('block', this.processBlock.bind(this)) provider.on('error', this.getNetwork.bind(this)) diff --git a/app/scripts/transaction-manager.js b/app/scripts/transaction-manager.js new file mode 100644 index 000000000..07e588679 --- /dev/null +++ b/app/scripts/transaction-manager.js @@ -0,0 +1,179 @@ +const EventEmitter = require('events') +const extend = require('xtend') +const TxProviderUtil = require('./lib/provider-utils') + +module.exports = class TransactionManager extends EventEmitter { + constructor (opts) { + super() + this.txList = opts.TxListFromStore || [] + this._persistTxList = opts.setTxList + this._unconfTxCbs = {} + this.txLimit = opts.txLimit + this.provider = opts.provider + } + +// Returns the tx list + getTxList () { + return this.txList + } + + // Saves the new/updated txList. + // Function is intended only for internal use + _saveTxList (txList) { + this.txList = txList + this._persistTxList(txList) + } + + // Adds a tx to the txlist + addTx (txData, onTxDoneCb) { + var txList = this.getTxList() + var txLimit = this.txLimit + if (txList.length > txLimit - 1) { + txList.shift() + } + txList.push(txData) + this._saveTxList(txList) + this.addOnTxDoneCb(txData.id, onTxDoneCb) + this.emit('unapproved', txData) + this.emit('update') + } + + getTx (txId, cb) { + var txList = this.getTxList() + var tx = txList.find((tx) => tx.id === txId) + return cb ? cb(tx) : tx + } + + updateTx (txData) { + var txId = txData.id + var txList = this.getTxList() + + var updatedTxList = txList.map((tx) => { + if (tx.id === txId) { + tx = txData + } + return tx + }) + this._saveTxList(updatedTxList) + } + + get unConftxCount () { + return Object.keys(this.getUnapprovedTxList()).length + } + + get pendingTxCount () { + return this.getTxsByMetaData('status', 'signed').length + } + + getUnapprovedTxList () { + var txList = this.getTxList() + return txList.filter((tx) => { + return tx.status === 'unapproved' + }).reduce((result, tx) => { + result[tx.id] = tx + return result + }, {}) + } + + getFilterdTxList (opts) { + var filteredTxList + Object.keys(opts).forEach((key) => { + filteredTxList = this.getTxsByMetaData(key, opts[key], filteredTxList) + }) + return filteredTxList + } + + getTxsByMetaData (key, value, txList = this.getTxList()) { + return txList.filter((tx) => { + if (key in tx.txParams) { + return tx.txParams[key] === value + } else { + return tx[key] === value + } + }) + } + + addOnTxDoneCb (txId, cb) { + this._unconfTxCbs[txId] = cb || noop + } + + // should return the tx + + // 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! + // // - `'signed'` the tx is signed + // // - `'submitted'` the tx is sent to a server + // // - `'confirmed'` the tx has been included in a block. + setTxStatus (txId, status) { + var txData = this.getTx(txId) + txData.status = status + this.emit(status, txId) + this.updateTx(txData, status) + } + + + // should return the status of the tx. + getTxStatus (txId, cb) { + const txData = this.getTx(txId) + return cb ? cb(txData.staus) : txData.status + } + + + // should update the status of the tx to 'signed'. + setTxStatusSigned (txId) { + this.setTxStatus(txId, 'signed') + this.emit('update') + } + + // should update the status of the tx to 'rejected'. + setTxStatusRejected (txId) { + this.setTxStatus(txId, 'rejected') + this.emit('update') + } + + setTxStatusConfirmed (txId) { + this.setTxStatus(txId, 'confirmed') + // this.removeListener(`check${txId}`, this.checkForTxInBlock) + } + + // merges txParams obj onto txData.txParams + // use extend to ensure that all fields are filled + updateTxParams (txId, txParams) { + var txData = this.getTx(txId) + txData.txParams = extend(txData, txParams) + this.updateTx(txData) + } + + setProvider (provider) { + this.provider = provider + this.txProviderUtils = new TxProviderUtil(provider) + this.provider.on('block', this.checkForTxInBlock.bind(this)) + } + + checkForTxInBlock () { + var signedTxList = this.getFilterdTxList({status: 'signed'}) + if (!signedTxList.length) return + var self = this + signedTxList.forEach((tx) => { + var txHash = tx.hash + var txId = tx.id + if (!txHash) return + // var d + this.txProviderUtils.query.getTransactionByHash(txHash, (err, txData) => { + if (err) { + tx + + return console.error(err) + } + if (txData.blockNumber !== null) { + self.setTxStatusConfirmed(txId) + } + }) + }) + } +} + +function noop () {}