Merge pull request #921 from MetaMask/TxManager
Feature: TxManager handles transaction state trackingfeature/default_network_editable
commit
fc723e7f7c
@ -0,0 +1,87 @@ |
||||
const async = require('async') |
||||
const EthQuery = require('eth-query') |
||||
const ethUtil = require('ethereumjs-util') |
||||
const BN = ethUtil.BN |
||||
|
||||
/* |
||||
tx-utils are utility methods for Transaction manager |
||||
its passed a provider and that is passed to ethquery |
||||
and used to do things like calculate gas of a tx. |
||||
*/ |
||||
|
||||
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) |
||||
}) |
||||
} |
||||
|
||||
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() |
||||
} |
||||
|
||||
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)) |
||||
} |
||||
} |
@ -0,0 +1,288 @@ |
||||
const EventEmitter = require('events') |
||||
const extend = require('xtend') |
||||
const ethUtil = require('ethereumjs-util') |
||||
const Transaction = require('ethereumjs-tx') |
||||
const BN = ethUtil.BN |
||||
const TxProviderUtil = require('./lib/tx-utils') |
||||
const createId = require('./lib/random-id') |
||||
const normalize = require('./lib/sig-util').normalize |
||||
|
||||
module.exports = class TransactionManager extends EventEmitter { |
||||
constructor (opts) { |
||||
super() |
||||
this.txList = opts.txList || [] |
||||
this._setTxList = opts.setTxList |
||||
this.txHistoryLimit = opts.txHistoryLimit |
||||
this.getSelectedAccount = opts.getSelectedAccount |
||||
this.provider = opts.provider |
||||
this.blockTracker = opts.blockTracker |
||||
this.txProviderUtils = new TxProviderUtil(this.provider) |
||||
this.blockTracker.on('block', this.checkForTxInBlock.bind(this)) |
||||
this.getGasMultiplier = opts.getGasMultiplier |
||||
this.getNetwork = opts.getNetwork |
||||
} |
||||
|
||||
getState () { |
||||
var selectedAccount = this.getSelectedAccount() |
||||
return { |
||||
transactions: this.getTxList(), |
||||
unconfTxs: this.getUnapprovedTxList(), |
||||
selectedAccountTxList: this.getFilteredTxList({metamaskNetworkId: this.getNetwork(), from: selectedAccount}), |
||||
} |
||||
} |
||||
|
||||
// Returns the tx list
|
||||
getTxList () { |
||||
return this.txList |
||||
} |
||||
|
||||
// Adds a tx to the txlist
|
||||
addTx (txMeta, onTxDoneCb = warn) { |
||||
var txList = this.getTxList() |
||||
var txHistoryLimit = this.txHistoryLimit |
||||
|
||||
// checks if the length of th 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 (txList.length > txHistoryLimit - 1) { |
||||
var index = txList.findIndex((metaTx) => metaTx.status === 'confirmed' || metaTx.status === 'rejected') |
||||
txList.splice(index, 1) |
||||
} |
||||
txList.push(txMeta) |
||||
|
||||
this._saveTxList(txList) |
||||
// keep the onTxDoneCb around in a listener
|
||||
// for after approval/denial (requires user interaction)
|
||||
// This onTxDoneCb fires completion to the Dapp's write operation.
|
||||
this.once(`${txMeta.id}:signed`, function (txId) { |
||||
this.removeAllListeners(`${txMeta.id}:rejected`) |
||||
onTxDoneCb(null, true) |
||||
}) |
||||
this.once(`${txMeta.id}:rejected`, function (txId) { |
||||
this.removeAllListeners(`${txMeta.id}:signed`) |
||||
onTxDoneCb(null, false) |
||||
}) |
||||
|
||||
this.emit('updateBadge') |
||||
this.emit(`${txMeta.id}:unapproved`, txMeta) |
||||
} |
||||
|
||||
// gets tx by Id and returns it
|
||||
getTx (txId, cb) { |
||||
var txList = this.getTxList() |
||||
var txMeta = txList.find(txData => txData.id === txId) |
||||
return cb ? cb(txMeta) : txMeta |
||||
} |
||||
|
||||
//
|
||||
updateTx (txMeta) { |
||||
var txId = txMeta.id |
||||
var txList = this.getTxList() |
||||
var index = txList.findIndex(txData => txData.id === txId) |
||||
txList[index] = txMeta |
||||
this._saveTxList(txList) |
||||
} |
||||
|
||||
get unapprovedTxCount () { |
||||
return Object.keys(this.getUnapprovedTxList()).length |
||||
} |
||||
|
||||
get pendingTxCount () { |
||||
return this.getTxsByMetaData('status', 'signed').length |
||||
} |
||||
|
||||
addUnapprovedTransaction (txParams, onTxDoneCb, cb) { |
||||
// create txData obj with parameters and meta data
|
||||
var time = (new Date()).getTime() |
||||
var txId = createId() |
||||
txParams.metamaskId = txId |
||||
txParams.metamaskNetworkId = this.getNetwork() |
||||
var txData = { |
||||
id: txId, |
||||
txParams: txParams, |
||||
time: time, |
||||
status: 'unapproved', |
||||
gasMultiplier: this.getGasMultiplier() || 1, |
||||
metamaskNetworkId: this.getNetwork(), |
||||
} |
||||
this.txProviderUtils.analyzeGasUsage(txData, this.txDidComplete.bind(this, txData, onTxDoneCb, cb)) |
||||
// calculate metadata for tx
|
||||
} |
||||
|
||||
txDidComplete (txMeta, onTxDoneCb, cb, err) { |
||||
if (err) return cb(err) |
||||
this.addTx(txMeta, onTxDoneCb) |
||||
cb(null, txMeta) |
||||
} |
||||
|
||||
getUnapprovedTxList () { |
||||
var txList = this.getTxList() |
||||
return txList.filter((txMeta) => txMeta.status === 'unapproved') |
||||
.reduce((result, tx) => { |
||||
result[tx.id] = tx |
||||
return result |
||||
}, {}) |
||||
} |
||||
|
||||
approveTransaction (txId, cb = warn) { |
||||
this.setTxStatusSigned(txId) |
||||
cb() |
||||
} |
||||
|
||||
cancelTransaction (txId, cb = warn) { |
||||
this.setTxStatusRejected(txId) |
||||
cb() |
||||
} |
||||
|
||||
// formats txParams so the keyringController can sign it
|
||||
formatTxForSigining (txParams, cb) { |
||||
var address = txParams.from |
||||
var metaTx = this.getTx(txParams.metamaskId) |
||||
var gasMultiplier = metaTx.gasMultiplier |
||||
var gasPrice = new BN(ethUtil.stripHexPrefix(txParams.gasPrice), 16) |
||||
gasPrice = gasPrice.mul(new BN(gasMultiplier * 100, 10)).div(new BN(100, 10)) |
||||
txParams.gasPrice = ethUtil.intToHex(gasPrice.toNumber()) |
||||
|
||||
// normalize values
|
||||
txParams.to = normalize(txParams.to) |
||||
txParams.from = normalize(txParams.from) |
||||
txParams.value = normalize(txParams.value) |
||||
txParams.data = normalize(txParams.data) |
||||
txParams.gasLimit = normalize(txParams.gasLimit || txParams.gas) |
||||
txParams.nonce = normalize(txParams.nonce) |
||||
const ethTx = new Transaction(txParams) |
||||
|
||||
// listener is assigned in metamaskController
|
||||
this.emit(`${txParams.metamaskId}:formatted`, ethTx, address, txParams.metamaskId, cb) |
||||
} |
||||
|
||||
// receives a signed tx object and updates the tx hash
|
||||
// and pass it to the cb to be sent off
|
||||
resolveSignedTransaction ({tx, txId, cb = warn}) { |
||||
// Add the tx hash to the persisted meta-tx object
|
||||
var txHash = ethUtil.bufferToHex(tx.hash()) |
||||
var metaTx = this.getTx(txId) |
||||
metaTx.hash = txHash |
||||
this.updateTx(metaTx) |
||||
var rawTx = ethUtil.bufferToHex(tx.serialize()) |
||||
cb(null, rawTx) |
||||
} |
||||
|
||||
/* |
||||
Takes an object of fields to search for eg: |
||||
var thingsToLookFor = { |
||||
to: '0x0..', |
||||
from: '0x0..', |
||||
status: 'signed', |
||||
} |
||||
and returns a list of tx with all |
||||
options matching |
||||
|
||||
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) { |
||||
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((txMeta) => { |
||||
if (key in txMeta.txParams) { |
||||
return txMeta.txParams[key] === value |
||||
} else { |
||||
return txMeta[key] === value |
||||
} |
||||
}) |
||||
} |
||||
|
||||
// STATUS METHODS
|
||||
// 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 'signed'.
|
||||
setTxStatusSigned (txId) { |
||||
this._setTxStatus(txId, 'signed') |
||||
this.emit('updateBadge') |
||||
} |
||||
|
||||
// should update the status of the tx to 'rejected'.
|
||||
setTxStatusRejected (txId) { |
||||
this._setTxStatus(txId, 'rejected') |
||||
this.emit('updateBadge') |
||||
} |
||||
|
||||
setTxStatusConfirmed (txId) { |
||||
this._setTxStatus(txId, 'confirmed') |
||||
} |
||||
|
||||
// merges txParams obj onto txData.txParams
|
||||
// use extend to ensure that all fields are filled
|
||||
updateTxParams (txId, txParams) { |
||||
var txMeta = this.getTx(txId) |
||||
txMeta.txParams = extend(txMeta.txParams, txParams) |
||||
this.updateTx(txMeta) |
||||
} |
||||
|
||||
// checks if a signed tx is in a block and
|
||||
// if included sets the tx status as 'confirmed'
|
||||
checkForTxInBlock () { |
||||
var signedTxList = this.getFilteredTxList({status: 'signed', err: undefined}) |
||||
if (!signedTxList.length) return |
||||
signedTxList.forEach((tx) => { |
||||
var txHash = tx.hash |
||||
var txId = tx.id |
||||
if (!txHash) return |
||||
this.txProviderUtils.query.getTransactionByHash(txHash, (err, txMeta) => { |
||||
if (err || !txMeta) { |
||||
tx.err = err || 'Tx could possibly have not been submitted' |
||||
this.updateTx(tx) |
||||
return txMeta ? console.error(err) : console.debug(`txMeta is ${txMeta} for:`, tx) |
||||
} |
||||
if (txMeta.blockNumber) { |
||||
this.setTxStatusConfirmed(txId) |
||||
} |
||||
}) |
||||
}) |
||||
} |
||||
|
||||
// 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!
|
||||
// - `'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 txMeta = this.getTx(txId) |
||||
txMeta.status = status |
||||
this.emit(`${txMeta.id}:${status}`, txId) |
||||
this.updateTx(txMeta) |
||||
} |
||||
|
||||
// Saves the new/updated txList.
|
||||
// Function is intended only for internal use
|
||||
_saveTxList (txList) { |
||||
this.txList = txList |
||||
this._setTxList(txList) |
||||
} |
||||
} |
||||
|
||||
|
||||
const warn = () => console.warn('warn was used no cb provided') |
@ -0,0 +1,190 @@ |
||||
const assert = require('assert') |
||||
const extend = require('xtend') |
||||
const EventEmitter = require('events') |
||||
const STORAGE_KEY = 'metamask-persistance-key' |
||||
const TransactionManager = require('../../app/scripts/transaction-manager') |
||||
|
||||
describe('Transaction Manager', function() { |
||||
let txManager |
||||
|
||||
const onTxDoneCb = () => true |
||||
beforeEach(function() { |
||||
txManager = new TransactionManager ({ |
||||
txList: [], |
||||
setTxList: () => {}, |
||||
provider: "testnet", |
||||
txHistoryLimit: 10, |
||||
blockTracker: new EventEmitter(), |
||||
}) |
||||
}) |
||||
|
||||
describe('#getTxList', function() { |
||||
it('when new should return empty array', function() { |
||||
var result = txManager.getTxList() |
||||
assert.ok(Array.isArray(result)) |
||||
assert.equal(result.length, 0) |
||||
}) |
||||
it('should also return transactions from local storage if any', function() { |
||||
|
||||
}) |
||||
}) |
||||
|
||||
describe('#_saveTxList', function() { |
||||
it('saves the submitted data to the tx list', function() { |
||||
var target = [{ foo: 'bar' }] |
||||
txManager._saveTxList(target) |
||||
var result = txManager.getTxList() |
||||
assert.equal(result[0].foo, 'bar') |
||||
}) |
||||
}) |
||||
|
||||
describe('#addTx', function() { |
||||
it('adds a tx returned in getTxList', function() { |
||||
var tx = { id: 1, status: 'confirmed',} |
||||
txManager.addTx(tx, onTxDoneCb) |
||||
var result = txManager.getTxList() |
||||
assert.ok(Array.isArray(result)) |
||||
assert.equal(result.length, 1) |
||||
assert.equal(result[0].id, 1) |
||||
}) |
||||
|
||||
it('cuts off early txs beyond a limit', function() { |
||||
const limit = txManager.txHistoryLimit |
||||
for (let i = 0; i < limit + 1; i++) { |
||||
let tx = { id: i, time: new Date(), status: 'confirmed'} |
||||
txManager.addTx(tx, onTxDoneCb) |
||||
} |
||||
var result = txManager.getTxList() |
||||
assert.equal(result.length, limit, `limit of ${limit} txs enforced`) |
||||
assert.equal(result[0].id, 1, 'early txs truncted') |
||||
}) |
||||
|
||||
it('cuts off early txs beyond a limit weather or not it is confirmed or rejected', function() { |
||||
const limit = txManager.txHistoryLimit |
||||
for (let i = 0; i < limit + 1; i++) { |
||||
let tx = { id: i, time: new Date(), status: 'rejected'} |
||||
txManager.addTx(tx, onTxDoneCb) |
||||
} |
||||
var result = txManager.getTxList() |
||||
assert.equal(result.length, limit, `limit of ${limit} txs enforced`) |
||||
assert.equal(result[0].id, 1, 'early txs truncted') |
||||
}) |
||||
|
||||
it('cuts off early txs beyond a limit but does not cut unapproved txs', function() { |
||||
var unconfirmedTx = { id: 0, time: new Date(), status: 'unapproved'} |
||||
txManager.addTx(unconfirmedTx, onTxDoneCb) |
||||
const limit = txManager.txHistoryLimit |
||||
for (let i = 1; i < limit + 1; i++) { |
||||
let tx = { id: i, time: new Date(), status: 'confirmed'} |
||||
txManager.addTx(tx, onTxDoneCb) |
||||
} |
||||
var result = txManager.getTxList() |
||||
assert.equal(result.length, limit, `limit of ${limit} txs enforced`) |
||||
assert.equal(result[0].id, 0, 'first tx should still be there') |
||||
assert.equal(result[0].status, 'unapproved', 'first tx should be unapproved') |
||||
assert.equal(result[1].id, 2, 'early txs truncted') |
||||
}) |
||||
}) |
||||
|
||||
describe('#setTxStatusSigned', function() { |
||||
it('sets the tx status to signed', function() { |
||||
var tx = { id: 1, status: 'unapproved' } |
||||
txManager.addTx(tx, onTxDoneCb) |
||||
txManager.setTxStatusSigned(1) |
||||
var result = txManager.getTxList() |
||||
assert.ok(Array.isArray(result)) |
||||
assert.equal(result.length, 1) |
||||
assert.equal(result[0].status, 'signed') |
||||
}) |
||||
|
||||
it('should emit a signed event to signal the exciton of callback', (done) => { |
||||
this.timeout(10000) |
||||
var tx = { id: 1, status: 'unapproved' } |
||||
let onTxDoneCb = function (err, txId) { |
||||
assert(true, 'event listener has been triggered and onTxDoneCb executed') |
||||
done() |
||||
} |
||||
txManager.addTx(tx, onTxDoneCb) |
||||
txManager.setTxStatusSigned(1) |
||||
}) |
||||
}) |
||||
|
||||
describe('#setTxStatusRejected', function() { |
||||
it('sets the tx status to rejected', function() { |
||||
var tx = { id: 1, status: 'unapproved' } |
||||
txManager.addTx(tx, onTxDoneCb) |
||||
txManager.setTxStatusRejected(1) |
||||
var result = txManager.getTxList() |
||||
assert.ok(Array.isArray(result)) |
||||
assert.equal(result.length, 1) |
||||
assert.equal(result[0].status, 'rejected') |
||||
}) |
||||
|
||||
it('should emit a rejected event to signal the exciton of callback', (done) => { |
||||
this.timeout(10000) |
||||
var tx = { id: 1, status: 'unapproved' } |
||||
let onTxDoneCb = function (err, txId) { |
||||
assert(true, 'event listener has been triggered and onTxDoneCb executed') |
||||
done() |
||||
} |
||||
txManager.addTx(tx, onTxDoneCb) |
||||
txManager.setTxStatusRejected(1) |
||||
}) |
||||
|
||||
}) |
||||
|
||||
describe('#updateTx', function() { |
||||
it('replaces the tx with the same id', function() { |
||||
txManager.addTx({ id: '1', status: 'unapproved' }, onTxDoneCb) |
||||
txManager.addTx({ id: '2', status: 'confirmed' }, onTxDoneCb) |
||||
txManager.updateTx({ id: '1', status: 'blah', hash: 'foo' }) |
||||
var result = txManager.getTx('1') |
||||
assert.equal(result.hash, 'foo') |
||||
}) |
||||
}) |
||||
|
||||
describe('#getUnapprovedTxList', function() { |
||||
it('returns unapproved txs in a hash', function() { |
||||
txManager.addTx({ id: '1', status: 'unapproved' }, onTxDoneCb) |
||||
txManager.addTx({ id: '2', status: 'confirmed' }, onTxDoneCb) |
||||
let result = txManager.getUnapprovedTxList() |
||||
assert.equal(typeof result, 'object') |
||||
assert.equal(result['1'].status, 'unapproved') |
||||
assert.equal(result['2'], undefined) |
||||
}) |
||||
}) |
||||
|
||||
describe('#getTx', function() { |
||||
it('returns a tx with the requested id', function() { |
||||
txManager.addTx({ id: '1', status: 'unapproved' }, onTxDoneCb) |
||||
txManager.addTx({ id: '2', status: 'confirmed' }, onTxDoneCb) |
||||
assert.equal(txManager.getTx('1').status, 'unapproved') |
||||
assert.equal(txManager.getTx('2').status, 'confirmed') |
||||
}) |
||||
}) |
||||
|
||||
describe('#getFilteredTxList', function() { |
||||
it('returns a tx with the requested data', function() { |
||||
var foop = 0 |
||||
var zoop = 0 |
||||
for (let i = 0; i < 10; ++i ){ |
||||
let everyOther = i % 2 |
||||
txManager.addTx({ id: i, |
||||
status: everyOther ? 'unapproved' : 'confirmed', |
||||
txParams: { |
||||
from: everyOther ? 'foop' : 'zoop', |
||||
to: everyOther ? 'zoop' : 'foop', |
||||
} |
||||
}, onTxDoneCb) |
||||
everyOther ? ++foop : ++zoop |
||||
} |
||||
assert.equal(txManager.getFilteredTxList({status: 'confirmed', from: 'zoop'}).length, zoop) |
||||
assert.equal(txManager.getFilteredTxList({status: 'confirmed', to: 'foop'}).length, zoop) |
||||
assert.equal(txManager.getFilteredTxList({status: 'confirmed', from: 'foop'}).length, 0) |
||||
assert.equal(txManager.getFilteredTxList({status: 'confirmed'}).length, zoop) |
||||
assert.equal(txManager.getFilteredTxList({from: 'foop'}).length, foop) |
||||
assert.equal(txManager.getFilteredTxList({from: 'zoop'}).length, zoop) |
||||
}) |
||||
}) |
||||
|
||||
}) |
Loading…
Reference in new issue