1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-12-23 09:52:26 +01:00

Merge pull request #1617 from MetaMask/nonce-tracker

transaction controller - use nonce-tracker
This commit is contained in:
Frankie 2017-07-13 15:48:50 -04:00 committed by GitHub
commit 9d3207fb73
9 changed files with 205 additions and 121 deletions

View File

@ -1,12 +1,11 @@
const EventEmitter = require('events') const EventEmitter = require('events')
const async = require('async') const async = require('async')
const extend = require('xtend') const extend = require('xtend')
const Semaphore = require('semaphore')
const ObservableStore = require('obs-store') const ObservableStore = require('obs-store')
const ethUtil = require('ethereumjs-util') const ethUtil = require('ethereumjs-util')
const TxProviderUtil = require('../lib/tx-utils') const TxProviderUtil = require('../lib/tx-utils')
const createId = require('../lib/random-id') const createId = require('../lib/random-id')
const denodeify = require('denodeify') const NonceTracker = require('../lib/nonce-tracker')
module.exports = class TransactionController extends EventEmitter { module.exports = class TransactionController extends EventEmitter {
constructor (opts) { constructor (opts) {
@ -20,6 +19,17 @@ module.exports = class TransactionController extends EventEmitter {
this.txHistoryLimit = opts.txHistoryLimit this.txHistoryLimit = opts.txHistoryLimit
this.provider = opts.provider this.provider = opts.provider
this.blockTracker = opts.blockTracker this.blockTracker = opts.blockTracker
this.nonceTracker = new NonceTracker({
provider: this.provider,
blockTracker: this.provider._blockTracker,
getPendingTransactions: (address) => {
return this.getFilteredTxList({
from: address,
status: 'submitted',
err: undefined,
})
},
})
this.query = opts.ethQuery this.query = opts.ethQuery
this.txProviderUtils = new TxProviderUtil(this.query) this.txProviderUtils = new TxProviderUtil(this.query)
this.blockTracker.on('rawBlock', this.checkForTxInBlock.bind(this)) this.blockTracker.on('rawBlock', this.checkForTxInBlock.bind(this))
@ -29,7 +39,6 @@ module.exports = class TransactionController extends EventEmitter {
this.blockTracker.once('latest', () => this.blockTracker.on('latest', this.resubmitPendingTxs.bind(this))) this.blockTracker.once('latest', () => this.blockTracker.on('latest', this.resubmitPendingTxs.bind(this)))
this.blockTracker.on('sync', this.queryPendingTxs.bind(this)) this.blockTracker.on('sync', this.queryPendingTxs.bind(this))
this.signEthTx = opts.signTransaction this.signEthTx = opts.signTransaction
this.nonceLock = Semaphore(1)
this.ethStore = opts.ethStore this.ethStore = opts.ethStore
// memstore is computed from a few different stores // memstore is computed from a few different stores
this._updateMemstore() this._updateMemstore()
@ -173,29 +182,32 @@ module.exports = class TransactionController extends EventEmitter {
}, {}) }, {})
} }
approveTransaction (txId, cb = warn) { async approveTransaction (txId) {
const self = this let nonceLock
// approve try {
self.setTxStatusApproved(txId) // approve
// only allow one tx at a time for atomic nonce usage this.setTxStatusApproved(txId)
self.nonceLock.take(() => { // get next nonce
// begin signature process const txMeta = this.getTx(txId)
async.waterfall([ const fromAddress = txMeta.txParams.from
(cb) => self.fillInTxParams(txId, cb), nonceLock = await this.nonceTracker.getNonceLock(fromAddress)
(cb) => self.signTransaction(txId, cb), txMeta.txParams.nonce = nonceLock.nextNonce
(rawTx, cb) => self.publishTransaction(txId, rawTx, cb), this.updateTx(txMeta)
], (err) => { // sign transaction
self.nonceLock.leave() const rawTx = await this.signTransaction(txId)
if (err) { await this.publishTransaction(txId, rawTx)
this.setTxStatusFailed(txId, { // must set transaction to submitted/failed before releasing lock
errCode: err.errCode || err, nonceLock.releaseLock()
message: err.message || 'Transaction failed during approval', } catch (err) {
}) this.setTxStatusFailed(txId, {
return cb(err) errCode: err.errCode || err,
} message: err.message || 'Transaction failed during approval',
cb()
}) })
}) // must set transaction to submitted/failed before releasing lock
if (nonceLock) nonceLock.releaseLock()
// continue with error chain
throw err
}
} }
cancelTransaction (txId, cb = warn) { cancelTransaction (txId, cb = warn) {
@ -203,13 +215,9 @@ module.exports = class TransactionController extends EventEmitter {
cb() cb()
} }
fillInTxParams (txId, cb) { async updateAndApproveTransaction (txMeta) {
const txMeta = this.getTx(txId) this.updateTx(txMeta)
this.txProviderUtils.fillInTxParams(txMeta.txParams, (err) => { await this.approveTransaction(txMeta.id)
if (err) return cb(err)
this.updateTx(txMeta)
cb()
})
} }
getChainId () { getChainId () {
@ -222,31 +230,27 @@ module.exports = class TransactionController extends EventEmitter {
} }
} }
signTransaction (txId, cb) { async signTransaction (txId) {
const txMeta = this.getTx(txId) const txMeta = this.getTx(txId)
const txParams = txMeta.txParams const txParams = txMeta.txParams
const fromAddress = txParams.from const fromAddress = txParams.from
// add network/chain id // add network/chain id
txParams.chainId = this.getChainId() txParams.chainId = this.getChainId()
const ethTx = this.txProviderUtils.buildEthTxFromParams(txParams) const ethTx = this.txProviderUtils.buildEthTxFromParams(txParams)
this.signEthTx(ethTx, fromAddress).then(() => { const rawTx = await this.signEthTx(ethTx, fromAddress).then(() => {
this.setTxStatusSigned(txMeta.id) this.setTxStatusSigned(txMeta.id)
cb(null, ethUtil.bufferToHex(ethTx.serialize())) return ethUtil.bufferToHex(ethTx.serialize())
}).catch((err) => {
cb(err)
}) })
return rawTx
} }
publishTransaction (txId, rawTx, cb = warn) { async publishTransaction (txId, rawTx) {
const txMeta = this.getTx(txId) const txMeta = this.getTx(txId)
txMeta.rawTx = rawTx txMeta.rawTx = rawTx
this.updateTx(txMeta) this.updateTx(txMeta)
await this.txProviderUtils.publishTransaction(rawTx).then((txHash) => {
this.txProviderUtils.publishTransaction(rawTx, (err, txHash) => {
if (err) return cb(err)
this.setTxHash(txId, txHash) this.setTxHash(txId, txHash)
this.setTxStatusSubmitted(txId) this.setTxStatusSubmitted(txId)
cb()
}) })
} }
@ -264,10 +268,19 @@ module.exports = class TransactionController extends EventEmitter {
to: '0x0..', to: '0x0..',
from: '0x0..', from: '0x0..',
status: 'signed', status: 'signed',
err: undefined,
} }
and returns a list of tx with all and returns a list of tx with all
options matching 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 this is for things like filtering a the tx list
for only tx's from 1 account for only tx's from 1 account
or for filltering for all txs from one account or for filltering for all txs from one account
@ -416,8 +429,7 @@ module.exports = class TransactionController extends EventEmitter {
const pending = this.getTxsByMetaData('status', 'submitted') const pending = this.getTxsByMetaData('status', 'submitted')
// only try resubmitting if their are transactions to resubmit // only try resubmitting if their are transactions to resubmit
if (!pending.length) return if (!pending.length) return
const resubmit = denodeify(this._resubmitTx.bind(this)) pending.forEach((txMeta) => this._resubmitTx(txMeta).catch((err) => {
pending.forEach((txMeta) => resubmit(txMeta).catch((err) => {
/* /*
Dont marked as failed if the error is a "known" transaction warning Dont marked as failed if the error is a "known" transaction warning
"there is already a transaction with the same sender-nonce "there is already a transaction with the same sender-nonce
@ -445,7 +457,7 @@ module.exports = class TransactionController extends EventEmitter {
})) }))
} }
_resubmitTx (txMeta, cb) { async _resubmitTx (txMeta, cb) {
const address = txMeta.txParams.from const address = txMeta.txParams.from
const balance = this.ethStore.getState().accounts[address].balance const balance = this.ethStore.getState().accounts[address].balance
if (!('retryCount' in txMeta)) txMeta.retryCount = 0 if (!('retryCount' in txMeta)) txMeta.retryCount = 0
@ -464,7 +476,7 @@ module.exports = class TransactionController extends EventEmitter {
// Increment a try counter. // Increment a try counter.
txMeta.retryCount++ txMeta.retryCount++
const rawTx = txMeta.rawTx const rawTx = txMeta.rawTx
this.txProviderUtils.publishTransaction(rawTx, cb) return await this.txProviderUtils.publishTransaction(rawTx, cb)
} }
// checks the network for signed txs and // checks the network for signed txs and

View File

@ -1,24 +1,9 @@
module.exports = function (promiseFn) { const promiseToCallback = require('promise-to-callback')
return function () {
var args = []
for (var i = 0; i < arguments.length - 1; i++) {
args.push(arguments[i])
}
var cb = arguments[arguments.length - 1]
const nodeified = promiseFn.apply(this, args) module.exports = function(fn, context) {
return function(){
if (!nodeified) { const args = [].slice.call(arguments)
const methodName = String(promiseFn).split('(')[0] const callback = args.pop()
throw new Error(`The ${methodName} did not return a Promise, but was nodeified.`) promiseToCallback(fn.apply(context, args))(callback)
}
nodeified.then(function (result) {
cb(null, result)
})
.catch(function (reason) {
cb(reason)
})
return nodeified
} }
} }

View File

@ -0,0 +1,59 @@
const EthQuery = require('eth-query')
class NonceTracker {
constructor ({ blockTracker, provider, getPendingTransactions }) {
this.blockTracker = blockTracker
this.ethQuery = new EthQuery(provider)
this.getPendingTransactions = getPendingTransactions
this.lockMap = {}
}
// releaseLock must be called
// releaseLock must be called after adding signed tx to pending transactions (or discarding)
async getNonceLock (address) {
// await lock free
await this.lockMap[address]
// take lock
const releaseLock = this._takeLock(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 pendingTransactions = this.getPendingTransactions(address)
const baseCount = await this._getTxCount(address, currentBlock)
const nextNonce = parseInt(baseCount) + pendingTransactions.length
// return next nonce and release cb
return { nextNonce: nextNonce.toString(16), releaseLock }
}
async _getCurrentBlock () {
const currentBlock = this.blockTracker.getCurrentBlock()
if (currentBlock) return currentBlock
return await Promise((reject, resolve) => {
this.blockTracker.once('latest', resolve)
})
}
_takeLock (lockId) {
let releaseLock = null
// create and store lock
const lock = new Promise((resolve, reject) => { releaseLock = resolve })
this.lockMap[lockId] = lock
// setup lock teardown
lock.then(() => delete this.lockMap[lockId])
return releaseLock
}
async _getTxCount (address, currentBlock) {
const blockNumber = currentBlock.number
return new Promise((resolve, reject) => {
this.ethQuery.getTransactionCount(address, blockNumber, (err, result) => {
err ? reject(err) : resolve(result)
})
})
}
}
module.exports = NonceTracker

View File

@ -106,8 +106,13 @@ module.exports = class txProviderUtils {
return ethTx return ethTx
} }
publishTransaction (rawTx, cb) { publishTransaction (rawTx) {
this.query.sendRawTransaction(rawTx, cb) return new Promise((resolve, reject) => {
this.query.sendRawTransaction(rawTx, (err, ress) => {
if (err) reject(err)
else resolve(ress)
})
})
} }
validateTxParams (txParams, cb) { validateTxParams (txParams, cb) {
@ -118,11 +123,11 @@ module.exports = class txProviderUtils {
} }
} }
sufficientBalance (tx, hexBalance) { sufficientBalance (txParams, hexBalance) {
const balance = hexToBn(hexBalance) const balance = hexToBn(hexBalance)
const value = hexToBn(tx.value) const value = hexToBn(txParams.value)
const gasLimit = hexToBn(tx.gas) const gasLimit = hexToBn(txParams.gas)
const gasPrice = hexToBn(tx.gasPrice) const gasPrice = hexToBn(txParams.gasPrice)
const maxCost = value.add(gasLimit.mul(gasPrice)) const maxCost = value.add(gasLimit.mul(gasPrice))
return balance.gte(maxCost) return balance.gte(maxCost)

View File

@ -294,34 +294,33 @@ module.exports = class MetamaskController extends EventEmitter {
submitPassword: this.submitPassword.bind(this), submitPassword: this.submitPassword.bind(this),
// PreferencesController // PreferencesController
setSelectedAddress: nodeify(preferencesController.setSelectedAddress).bind(preferencesController), setSelectedAddress: nodeify(preferencesController.setSelectedAddress, preferencesController),
addToken: nodeify(preferencesController.addToken).bind(preferencesController), addToken: nodeify(preferencesController.addToken, preferencesController),
setCurrentAccountTab: nodeify(preferencesController.setCurrentAccountTab).bind(preferencesController), setCurrentAccountTab: nodeify(preferencesController.setCurrentAccountTab, preferencesController),
setDefaultRpc: nodeify(this.setDefaultRpc).bind(this), setDefaultRpc: nodeify(this.setDefaultRpc, this),
setCustomRpc: nodeify(this.setCustomRpc).bind(this), setCustomRpc: nodeify(this.setCustomRpc, this),
// AddressController // AddressController
setAddressBook: nodeify(addressBookController.setAddressBook).bind(addressBookController), setAddressBook: nodeify(addressBookController.setAddressBook, addressBookController),
// KeyringController // KeyringController
setLocked: nodeify(keyringController.setLocked).bind(keyringController), setLocked: nodeify(keyringController.setLocked, keyringController),
createNewVaultAndKeychain: nodeify(keyringController.createNewVaultAndKeychain).bind(keyringController), createNewVaultAndKeychain: nodeify(keyringController.createNewVaultAndKeychain, keyringController),
createNewVaultAndRestore: nodeify(keyringController.createNewVaultAndRestore).bind(keyringController), createNewVaultAndRestore: nodeify(keyringController.createNewVaultAndRestore, keyringController),
addNewKeyring: nodeify(keyringController.addNewKeyring).bind(keyringController), addNewKeyring: nodeify(keyringController.addNewKeyring, keyringController),
saveAccountLabel: nodeify(keyringController.saveAccountLabel).bind(keyringController), saveAccountLabel: nodeify(keyringController.saveAccountLabel, keyringController),
exportAccount: nodeify(keyringController.exportAccount).bind(keyringController), exportAccount: nodeify(keyringController.exportAccount, keyringController),
// txController // txController
approveTransaction: txController.approveTransaction.bind(txController),
cancelTransaction: txController.cancelTransaction.bind(txController), cancelTransaction: txController.cancelTransaction.bind(txController),
updateAndApproveTransaction: this.updateAndApproveTx.bind(this), updateAndApproveTransaction: nodeify(txController.updateAndApproveTransaction, txController),
// messageManager // messageManager
signMessage: nodeify(this.signMessage).bind(this), signMessage: nodeify(this.signMessage, this),
cancelMessage: this.cancelMessage.bind(this), cancelMessage: this.cancelMessage.bind(this),
// personalMessageManager // personalMessageManager
signPersonalMessage: nodeify(this.signPersonalMessage).bind(this), signPersonalMessage: nodeify(this.signPersonalMessage, this),
cancelPersonalMessage: this.cancelPersonalMessage.bind(this), cancelPersonalMessage: this.cancelPersonalMessage.bind(this),
// notices // notices
@ -502,13 +501,6 @@ module.exports = class MetamaskController extends EventEmitter {
}) })
} }
updateAndApproveTx (txMeta, cb) {
log.debug(`MetaMaskController - updateAndApproveTx: ${JSON.stringify(txMeta)}`)
const txController = this.txController
txController.updateTx(txMeta)
txController.approveTransaction(txMeta.id, cb)
}
signMessage (msgParams, cb) { signMessage (msgParams, cb) {
log.info('MetaMaskController - signMessage') log.info('MetaMaskController - signMessage')
const msgId = msgParams.metamaskId const msgId = msgParams.metamaskId

View File

@ -11,6 +11,7 @@
"dist": "npm run clear && npm install && gulp dist", "dist": "npm run clear && npm install && gulp dist",
"test": "npm run lint && npm run test-unit && npm run test-integration", "test": "npm run lint && npm run test-unit && npm run test-integration",
"test-unit": "METAMASK_ENV=test mocha --require test/helper.js --recursive \"test/unit/**/*.js\"", "test-unit": "METAMASK_ENV=test mocha --require test/helper.js --recursive \"test/unit/**/*.js\"",
"single-test": "METAMASK_ENV=test mocha --require test/helper.js",
"test-integration": "npm run buildMock && npm run buildCiUnits && testem ci -P 2", "test-integration": "npm run buildMock && npm run buildCiUnits && testem ci -P 2",
"lint": "gulp lint", "lint": "gulp lint",
"buildCiUnits": "node test/integration/index.js", "buildCiUnits": "node test/integration/index.js",
@ -56,7 +57,6 @@
"copy-to-clipboard": "^2.0.0", "copy-to-clipboard": "^2.0.0",
"debounce": "^1.0.0", "debounce": "^1.0.0",
"deep-extend": "^0.4.1", "deep-extend": "^0.4.1",
"denodeify": "^1.2.1",
"detect-node": "^2.0.3", "detect-node": "^2.0.3",
"disc": "^1.3.2", "disc": "^1.3.2",
"dnode": "^1.2.2", "dnode": "^1.2.2",

View File

@ -11,7 +11,7 @@ describe('nodeify', function () {
} }
it('should retain original context', function (done) { it('should retain original context', function (done) {
var nodified = nodeify(obj.promiseFunc).bind(obj) var nodified = nodeify(obj.promiseFunc, obj)
nodified('baz', function (err, res) { nodified('baz', function (err, res) {
assert.equal(res, 'barbaz') assert.equal(res, 'barbaz')
done() done()

View File

@ -0,0 +1,40 @@
const assert = require('assert')
const NonceTracker = require('../../app/scripts/lib/nonce-tracker')
describe('Nonce Tracker', function () {
let nonceTracker, provider, getPendingTransactions, pendingTxs
beforeEach(function () {
pendingTxs = [{
'status': 'submitted',
'txParams': {
'from': '0x7d3517b0d011698406d6e0aed8453f0be2697926',
'gas': '0x30d40',
'value': '0x0',
'nonce': '0x0',
},
}]
getPendingTransactions = () => pendingTxs
provider = { sendAsync: (_, cb) => { cb(undefined, {result: '0x0'}) } }
nonceTracker = new NonceTracker({
blockTracker: {
getCurrentBlock: () => '0x11b568',
},
provider,
getPendingTransactions,
})
})
describe('#getNonceLock', function () {
it('should work', async function (done) {
this.timeout(15000)
const nonceLock = await nonceTracker.getNonceLock('0x7d3517b0d011698406d6e0aed8453f0be2697926')
assert.equal(nonceLock.nextNonce, '1', 'nonce should be 1')
await nonceLock.releaseLock()
done()
})
})
})

View File

@ -1,5 +1,4 @@
const assert = require('assert') const assert = require('assert')
const EventEmitter = require('events')
const ethUtil = require('ethereumjs-util') const ethUtil = require('ethereumjs-util')
const EthTx = require('ethereumjs-tx') const EthTx = require('ethereumjs-tx')
const EthQuery = require('eth-query') const EthQuery = require('eth-query')
@ -19,15 +18,16 @@ describe('Transaction Controller', function () {
txController = new TransactionController({ txController = new TransactionController({
networkStore: new ObservableStore(currentNetworkId), networkStore: new ObservableStore(currentNetworkId),
txHistoryLimit: 10, txHistoryLimit: 10,
blockTracker: { getCurrentBlock: noop, on: noop, once: noop },
provider: { sendAsync: noop },
ethQuery: new EthQuery({ sendAsync: noop }),
ethStore: { getState: noop }, ethStore: { getState: noop },
provider: { _blockTracker: new EventEmitter()},
blockTracker: new EventEmitter(),
ethQuery: new EthQuery(new EventEmitter()),
signTransaction: (ethTx) => new Promise((resolve) => { signTransaction: (ethTx) => new Promise((resolve) => {
ethTx.sign(privKey) ethTx.sign(privKey)
resolve() resolve()
}), }),
}) })
txController.nonceTracker.getNonceLock = () => Promise.resolve({ nextNonce: 0, releaseLock: noop })
}) })
describe('#validateTxParams', function () { describe('#validateTxParams', function () {
@ -271,56 +271,47 @@ describe('Transaction Controller', function () {
it('does not overwrite set values', function (done) { it('does not overwrite set values', function (done) {
this.timeout(15000)
const wrongValue = '0x05' const wrongValue = '0x05'
txController.addTx(txMeta) txController.addTx(txMeta)
const estimateStub = sinon.stub(txController.txProviderUtils.query, 'estimateGas') const estimateStub = sinon.stub(txController.txProviderUtils.query, 'estimateGas')
.callsArgWith(1, null, wrongValue) .callsArgWithAsync(1, null, wrongValue)
const priceStub = sinon.stub(txController.txProviderUtils.query, 'gasPrice') const priceStub = sinon.stub(txController.txProviderUtils.query, 'gasPrice')
.callsArgWith(0, null, wrongValue) .callsArgWithAsync(0, null, wrongValue)
const nonceStub = sinon.stub(txController.txProviderUtils.query, 'getTransactionCount')
.callsArgWith(2, null, wrongValue)
const signStub = sinon.stub(txController, 'signTransaction') const signStub = sinon.stub(txController, 'signTransaction', () => Promise.resolve())
.callsArgWith(1, null, noop)
const pubStub = sinon.stub(txController.txProviderUtils, 'publishTransaction') const pubStub = sinon.stub(txController.txProviderUtils, 'publishTransaction', () => Promise.resolve(originalValue))
.callsArgWith(1, null, originalValue)
txController.approveTransaction(txMeta.id, (err) => {
assert.ifError(err, 'should not error')
txController.approveTransaction(txMeta.id).then(() => {
const result = txController.getTx(txMeta.id) const result = txController.getTx(txMeta.id)
const params = result.txParams const params = result.txParams
assert.equal(params.gas, originalValue, 'gas unmodified') assert.equal(params.gas, originalValue, 'gas unmodified')
assert.equal(params.gasPrice, originalValue, 'gas price unmodified') assert.equal(params.gasPrice, originalValue, 'gas price unmodified')
assert.equal(params.nonce, originalValue, 'nonce unmodified') assert.equal(result.hash, originalValue, `hash was set \n got: ${result.hash} \n expected: ${originalValue}`)
assert.equal(result.hash, originalValue, 'hash was set')
estimateStub.restore() estimateStub.restore()
priceStub.restore() priceStub.restore()
signStub.restore() signStub.restore()
nonceStub.restore()
pubStub.restore() pubStub.restore()
done() done()
}) }).catch(done)
}) })
}) })
describe('#sign replay-protected tx', function () { describe('#sign replay-protected tx', function () {
it('prepares a tx with the chainId set', function (done) { it('prepares a tx with the chainId set', function (done) {
txController.addTx({ id: '1', status: 'unapproved', metamaskNetworkId: currentNetworkId, txParams: {} }, noop) txController.addTx({ id: '1', status: 'unapproved', metamaskNetworkId: currentNetworkId, txParams: {} }, noop)
txController.signTransaction('1', (err, rawTx) => { txController.signTransaction('1').then((rawTx) => {
if (err) return done('it should not fail')
const ethTx = new EthTx(ethUtil.toBuffer(rawTx)) const ethTx = new EthTx(ethUtil.toBuffer(rawTx))
assert.equal(ethTx.getChainId(), currentNetworkId) assert.equal(ethTx.getChainId(), currentNetworkId)
done() done()
}) }).catch(done)
}) })
}) })