diff options
author | frankiebee <frankie.diamond@gmail.com> | 2018-04-07 02:07:20 +0800 |
---|---|---|
committer | frankiebee <frankie.diamond@gmail.com> | 2018-04-11 05:28:05 +0800 |
commit | 2d7c3c2b00a698b19ac015624154c3c1cd2619b2 (patch) | |
tree | 3cfb7821143431735821954f54e90ce71ef173ef /app/scripts/controllers/transactions | |
parent | 2b787f2833d4f4cfda74ca22d3d340f0f924c94e (diff) | |
download | tangerine-wallet-browser-2d7c3c2b00a698b19ac015624154c3c1cd2619b2.tar tangerine-wallet-browser-2d7c3c2b00a698b19ac015624154c3c1cd2619b2.tar.gz tangerine-wallet-browser-2d7c3c2b00a698b19ac015624154c3c1cd2619b2.tar.bz2 tangerine-wallet-browser-2d7c3c2b00a698b19ac015624154c3c1cd2619b2.tar.lz tangerine-wallet-browser-2d7c3c2b00a698b19ac015624154c3c1cd2619b2.tar.xz 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.js | 341 | ||||
-rw-r--r-- | app/scripts/controllers/transactions/lib/tx-state-history-helper.js | 41 | ||||
-rw-r--r-- | app/scripts/controllers/transactions/lib/util.js | 66 | ||||
-rw-r--r-- | app/scripts/controllers/transactions/nonce-tracker.js | 148 | ||||
-rw-r--r-- | app/scripts/controllers/transactions/pending-tx-tracker.js | 189 | ||||
-rw-r--r-- | app/scripts/controllers/transactions/tx-gas-utils.js | 103 | ||||
-rw-r--r-- | app/scripts/controllers/transactions/tx-state-manager.js | 315 |
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 }) + } +} |