aboutsummaryrefslogtreecommitdiffstats
path: root/app/scripts/controllers/transactions
diff options
context:
space:
mode:
authorfrankiebee <frankie.diamond@gmail.com>2018-04-07 02:07:20 +0800
committerfrankiebee <frankie.diamond@gmail.com>2018-04-11 05:28:05 +0800
commit2d7c3c2b00a698b19ac015624154c3c1cd2619b2 (patch)
tree3cfb7821143431735821954f54e90ce71ef173ef /app/scripts/controllers/transactions
parent2b787f2833d4f4cfda74ca22d3d340f0f924c94e (diff)
downloadtangerine-wallet-browser-2d7c3c2b00a698b19ac015624154c3c1cd2619b2.tar.gz
tangerine-wallet-browser-2d7c3c2b00a698b19ac015624154c3c1cd2619b2.tar.zst
tangerine-wallet-browser-2d7c3c2b00a698b19ac015624154c3c1cd2619b2.zip
meta - transactions - create a transactions dir in controller and move relevant files into it
Diffstat (limited to 'app/scripts/controllers/transactions')
-rw-r--r--app/scripts/controllers/transactions/index.js341
-rw-r--r--app/scripts/controllers/transactions/lib/tx-state-history-helper.js41
-rw-r--r--app/scripts/controllers/transactions/lib/util.js66
-rw-r--r--app/scripts/controllers/transactions/nonce-tracker.js148
-rw-r--r--app/scripts/controllers/transactions/pending-tx-tracker.js189
-rw-r--r--app/scripts/controllers/transactions/tx-gas-utils.js103
-rw-r--r--app/scripts/controllers/transactions/tx-state-manager.js315
7 files changed, 1203 insertions, 0 deletions
diff --git a/app/scripts/controllers/transactions/index.js b/app/scripts/controllers/transactions/index.js
new file mode 100644
index 000000000..6f66e3a1e
--- /dev/null
+++ b/app/scripts/controllers/transactions/index.js
@@ -0,0 +1,341 @@
+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')
+/*
+ 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 = txUtils.normalizeTxParams(txParams)
+ txUtils.validateTxParams(normalizedTxParams)
+ // construct txMeta
+ let txMeta = this.txStateManager.generateTxMeta({ txParams: normalizedTxParams })
+ this.addTx(txMeta)
+ this.emit('newUnapprovedTx', txMeta)
+ // add default tx params
+ try {
+ txMeta = await this.addTxDefaults(txMeta)
+ } 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
+//
+
+ _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/lib/tx-state-history-helper.js b/app/scripts/controllers/transactions/lib/tx-state-history-helper.js
new file mode 100644
index 000000000..94c7b6792
--- /dev/null
+++ b/app/scripts/controllers/transactions/lib/tx-state-history-helper.js
@@ -0,0 +1,41 @@
+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/controllers/transactions/lib/util.js b/app/scripts/controllers/transactions/lib/util.js
new file mode 100644
index 000000000..f403b0758
--- /dev/null
+++ b/app/scripts/controllers/transactions/lib/util.js
@@ -0,0 +1,66 @@
+const {
+ addHexPrefix,
+ isValidAddress,
+} = require('ethereumjs-util')
+
+module.exports = {
+ normalizeTxParams,
+ validateTxParams,
+ validateFrom,
+ validateRecipient
+}
+
+
+function normalizeTxParams (txParams) {
+ // functions that handle normalizing of that key in txParams
+ const whiteList = {
+ from: from => addHexPrefix(from).toLowerCase(),
+ to: to => addHexPrefix(txParams.to).toLowerCase(),
+ nonce: nonce => addHexPrefix(nonce),
+ value: value => addHexPrefix(value),
+ data: data => addHexPrefix(data),
+ gas: gas => addHexPrefix(gas),
+ gasPrice: gasPrice => 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
+}
+
+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`)
+ }
+ }
+}
+
+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')
+}
+
+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
+} \ No newline at end of file
diff --git a/app/scripts/controllers/transactions/nonce-tracker.js b/app/scripts/controllers/transactions/nonce-tracker.js
new file mode 100644
index 000000000..5b1cd7f43
--- /dev/null
+++ b/app/scripts/controllers/transactions/nonce-tracker.js
@@ -0,0 +1,148 @@
+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/controllers/transactions/pending-tx-tracker.js b/app/scripts/controllers/transactions/pending-tx-tracker.js
new file mode 100644
index 000000000..e8869e6b8
--- /dev/null
+++ b/app/scripts/controllers/transactions/pending-tx-tracker.js
@@ -0,0 +1,189 @@
+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/controllers/transactions/tx-gas-utils.js b/app/scripts/controllers/transactions/tx-gas-utils.js
new file mode 100644
index 000000000..f40542603
--- /dev/null
+++ b/app/scripts/controllers/transactions/tx-gas-utils.js
@@ -0,0 +1,103 @@
+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-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/controllers/transactions/tx-state-manager.js b/app/scripts/controllers/transactions/tx-state-manager.js
new file mode 100644
index 000000000..cb24b8c99
--- /dev/null
+++ b/app/scripts/controllers/transactions/tx-state-manager.js
@@ -0,0 +1,315 @@
+const extend = require('xtend')
+const EventEmitter = require('events')
+const ObservableStore = require('obs-store')
+const createId = require('../../lib/random-id')
+const ethUtil = require('ethereumjs-util')
+const txStateHistoryHelper = require('./lib/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) => {
+ return this.getFinalStates().includes(metaTx.status)
+ })
+ 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')
+ }
+
+ // returns an array of states that can be considered final
+ 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
+ ]
+ }
+
+ 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
+ _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 })
+ }
+}