import EventEmitter from '@metamask/safe-event-emitter'; import log from 'loglevel'; import EthQuery from 'ethjs-query'; import { TransactionStatus } from '../../../../shared/constants/transaction'; import { ERROR_SUBMITTING } from './tx-state-manager'; /** * Event emitter 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. */ export default class PendingTransactionTracker extends EventEmitter { /** * We wait this many blocks before emitting a 'tx:dropped' event * * This is because we could be talking to a node that is out of sync. * * @type {number} */ DROPPED_BUFFER_COUNT = 3; /** * A map of transaction hashes to the number of blocks we've seen * since first considering it dropped * * @type {Map} */ droppedBlocksBufferByHash = new Map(); /** * @param {object} config - Configuration. * @param {Function} config.approveTransaction - Approves a transaction. * @param {Function} config.confirmTransaction - Set a transaction as confirmed. * @param {Function} config.getCompletedTransactions - Returns completed transactions. * @param {Function} config.getPendingTransactions - Returns an array of pending transactions, * @param {object} config.nonceTracker - see nonce tracker * @param {object} config.provider - A network provider. * @param {object} config.query - An EthQuery instance. * @param {Function} config.publishTransaction - Publishes a raw transaction, */ constructor(config) { super(); this.query = config.query || new EthQuery(config.provider); this.nonceTracker = config.nonceTracker; this.getPendingTransactions = config.getPendingTransactions; this.getCompletedTransactions = config.getCompletedTransactions; this.publishTransaction = config.publishTransaction; this.approveTransaction = config.approveTransaction; this.confirmTransaction = config.confirmTransaction; } /** * checks the network for signed txs and releases the nonce global lock if it is */ async updatePendingTxs() { // in order to keep the nonceTracker accurate we block it while updating pending transactions const nonceGlobalLock = await this.nonceTracker.getGlobalLock(); try { const pendingTxs = this.getPendingTransactions(); await Promise.all( pendingTxs.map((txMeta) => this._checkPendingTx(txMeta)), ); } catch (err) { log.error( 'PendingTransactionTracker - Error updating pending transactions', ); log.error(err); } nonceGlobalLock.releaseLock(); } /** * Resubmits each pending transaction * * @param {string} blockNumber - the latest block number in hex * @fires tx:warning * @returns {Promise} */ async resubmitPendingTxs(blockNumber) { const pending = this.getPendingTransactions(); if (!pending.length) { return; } for (const txMeta of pending) { try { await this._resubmitTx(txMeta, blockNumber); } catch (err) { const errorMessage = err.value?.message?.toLowerCase() || 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: ERROR_SUBMITTING, }; this.emit('tx:warning', txMeta, err); } } } /** * Attempts to resubmit the given transaction with exponential backoff * * Will only attempt to retry the given tx every {@code 2**(txMeta.retryCount)} blocks. * * @param {object} txMeta - the transaction metadata * @param {string} latestBlockNumber - the latest block number in hex * @returns {Promise} the tx hash if retried * @fires tx:block-update * @fires tx:retry * @private */ 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 (capped at ~15 minutes between retries) if (txBlockDistance < Math.min(50, Math.pow(2, retryCount))) { return undefined; } ///: BEGIN:ONLY_INCLUDE_IN(build-mmi) // Don't ever resubmit custodian transactions if (txMeta.custodyId) { return undefined; } ///: END:ONLY_INCLUDE_IN // Only auto-submit already-signed txs: if (!('rawTx' in txMeta)) { return this.approveTransaction(txMeta.id); } const { rawTx } = txMeta; const txHash = await this.publishTransaction(rawTx); // Increment successful tries: this.emit('tx:retry', txMeta); return txHash; } /** * Query the network to see if the given {@code txMeta} has been included in a block * * @param {object} txMeta - the transaction metadata * @returns {Promise} * @fires tx:confirmed * @fires tx:dropped * @fires tx:failed * @fires tx:warning * @private */ async _checkPendingTx(txMeta) { const txHash = txMeta.hash; const txId = txMeta.id; // Only check submitted txs if (txMeta.status !== TransactionStatus.submitted) { return; } // extra check in case there was an uncaught error during the // signature and submission process let hasNoHash = !txHash; ///: BEGIN:ONLY_INCLUDE_IN(build-mmi) // Don't emit noTxHashErr for custodian transactions hasNoHash ||= !txMeta.custodyId; ///: END:ONLY_INCLUDE_IN if (hasNoHash) { 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 (await this._checkIfNonceIsTaken(txMeta)) { this.emit('tx:dropped', txId); return; } try { const transactionReceipt = await this.query.getTransactionReceipt(txHash); if (transactionReceipt?.blockNumber) { const { baseFeePerGas, timestamp: blockTimestamp } = await this.query.getBlockByHash(transactionReceipt?.blockHash, false); this.emit( 'tx:confirmed', txId, transactionReceipt, baseFeePerGas, blockTimestamp, ); return; } } catch (err) { txMeta.warning = { error: err.message, message: 'There was a problem loading this transaction.', }; this.emit('tx:warning', txMeta, err); return; } if (await this._checkIfTxWasDropped(txMeta)) { this.emit('tx:dropped', txId); } } /** * Checks whether the nonce in the given {@code txMeta} is behind the network nonce * * @param {object} txMeta - the transaction metadata * @returns {Promise} * @private */ async _checkIfTxWasDropped(txMeta) { const { hash: txHash, txParams: { nonce, from }, } = txMeta; const networkNextNonce = await this.query.getTransactionCount(from); if (parseInt(nonce, 16) >= networkNextNonce.toNumber()) { return false; } if (!this.droppedBlocksBufferByHash.has(txHash)) { this.droppedBlocksBufferByHash.set(txHash, 0); } const currentBlockBuffer = this.droppedBlocksBufferByHash.get(txHash); if (currentBlockBuffer < this.DROPPED_BUFFER_COUNT) { this.droppedBlocksBufferByHash.set(txHash, currentBlockBuffer + 1); return false; } this.droppedBlocksBufferByHash.delete(txHash); return true; } /** * Checks whether the nonce in the given {@code txMeta} is correct against the local set of transactions * * @param {object} txMeta - the transaction metadata * @returns {Promise} * @private */ async _checkIfNonceIsTaken(txMeta) { const address = txMeta.txParams.from; const completed = this.getCompletedTransactions(address); return completed.some( // This is called while the transaction is in-flight, so it is possible that the // list of completed transactions now includes the transaction we were looking at // and if that is the case, don't consider the transaction to have taken its own nonce (other) => !(other.id === txMeta.id) && other.txParams.nonce === txMeta.txParams.nonce, ); } }