import EventEmitter from 'safe-event-emitter'; import { ObservableStore } from '@metamask/obs-store'; import log from 'loglevel'; import { keyBy, mapValues, omitBy, pickBy, sortBy } from 'lodash'; import createId from '../../../../shared/modules/random-id'; import { TRANSACTION_STATUSES } from '../../../../shared/constants/transaction'; import { METAMASK_CONTROLLER_EVENTS } from '../../metamask-controller'; import { transactionMatchesNetwork } from '../../../../shared/modules/transaction.utils'; import { generateHistoryEntry, replayHistory, snapshotFromTxMeta, } from './lib/tx-state-history-helpers'; import { getFinalStates, normalizeAndValidateTxParams, validateConfirmedExternalTransaction, } from './lib/util'; /** * TransactionStatuses reimported from the shared transaction constants file * * @typedef {import( * '../../../../shared/constants/transaction' * ).TransactionStatusString} TransactionStatusString */ /** * @typedef {import('../../../../shared/constants/transaction').TxParams} TxParams */ /** * @typedef {import( * '../../../../shared/constants/transaction' * ).TransactionMeta} TransactionMeta */ /** * @typedef {Object} TransactionState * @property {Record} transactions - TransactionMeta * keyed by the transaction's id. */ /** * TransactionStateManager is responsible for the state of a transaction and * storing the transaction. It also has some convenience methods for finding * subsets of transactions. * * @param {Object} opts * @param {TransactionState} [opts.initState={ transactions: {} }] - initial * transactions list keyed by id * @param {number} [opts.txHistoryLimit] - limit for how many finished * transactions can hang around in state * @param {Function} opts.getNetwork - return network number */ export default class TransactionStateManager extends EventEmitter { constructor({ initState, txHistoryLimit, getNetwork, getCurrentChainId }) { super(); this.store = new ObservableStore({ transactions: {}, ...initState, }); this.txHistoryLimit = txHistoryLimit; this.getNetwork = getNetwork; this.getCurrentChainId = getCurrentChainId; } /** * Generates a TransactionMeta object consisting of the fields required for * use throughout the extension. The argument here will override everything * in the resulting transaction meta. * * TODO: Don't overwrite everything? * * @param {Partial} opts - the object to use when * overwriting default keys of the TransactionMeta * @returns {TransactionMeta} the default txMeta object */ generateTxMeta(opts = {}) { const netId = this.getNetwork(); const chainId = this.getCurrentChainId(); if (netId === 'loading') { throw new Error('MetaMask is having trouble connecting to the network'); } let dappSuggestedGasFees = null; // If we are dealing with a transaction suggested by a dapp and not // an internally created metamask transaction, we need to keep record of // the originally submitted gasParams. if ( opts.txParams && typeof opts.origin === 'string' && opts.origin !== 'metamask' ) { if (typeof opts.txParams.gasPrice !== 'undefined') { dappSuggestedGasFees = { gasPrice: opts.txParams.gasPrice, }; } else if ( typeof opts.txParams.maxFeePerGas !== 'undefined' || typeof opts.txParams.maxPriorityFeePerGas !== 'undefined' ) { dappSuggestedGasFees = { maxPriorityFeePerGas: opts.txParams.maxPriorityFeePerGas, maxFeePerGas: opts.txParams.maxFeePerGas, }; } if (typeof opts.txParams.gas !== 'undefined') { dappSuggestedGasFees = { ...dappSuggestedGasFees, gas: opts.txParams.gas, }; } } return { id: createId(), time: new Date().getTime(), status: TRANSACTION_STATUSES.UNAPPROVED, metamaskNetworkId: netId, originalGasEstimate: opts.txParams?.gas, userEditedGasLimit: false, chainId, loadingDefaults: true, dappSuggestedGasFees, ...opts, }; } /** * Get an object containing all unapproved transactions for the current * network. This is the only transaction fetching method that returns an * object, so it doesn't use getTransactions like everything else. * * @returns {Record} Unapproved transactions keyed * by id */ getUnapprovedTxList() { const chainId = this.getCurrentChainId(); const network = this.getNetwork(); return pickBy( this.store.getState().transactions, (transaction) => transaction.status === TRANSACTION_STATUSES.UNAPPROVED && transactionMatchesNetwork(transaction, chainId, network), ); } /** * Get all approved transactions for the current network. If an address is * provided, the list will be further refined to only those transactions * originating from the supplied address. * * @param {string} [address] - hex prefixed address to find transactions for. * @returns {TransactionMeta[]} the filtered list of transactions */ getApprovedTransactions(address) { const searchCriteria = { status: TRANSACTION_STATUSES.APPROVED }; if (address) { searchCriteria.from = address; } return this.getTransactions({ searchCriteria }); } /** * Get all pending transactions for the current network. If an address is * provided, the list will be further refined to only those transactions * originating from the supplied address. * * @param {string} [address] - hex prefixed address to find transactions for. * @returns {TransactionMeta[]} the filtered list of transactions */ getPendingTransactions(address) { const searchCriteria = { status: TRANSACTION_STATUSES.SUBMITTED }; if (address) { searchCriteria.from = address; } return this.getTransactions({ searchCriteria }); } /** * Get all confirmed transactions for the current network. If an address is * provided, the list will be further refined to only those transactions * originating from the supplied address. * * @param {string} [address] - hex prefixed address to find transactions for. * @returns {TransactionMeta[]} the filtered list of transactions */ getConfirmedTransactions(address) { const searchCriteria = { status: TRANSACTION_STATUSES.CONFIRMED }; if (address) { searchCriteria.from = address; } return this.getTransactions({ searchCriteria }); } /** * Adds the txMeta to the list of transactions in the store. * if the list is over txHistoryLimit it will remove a transaction that * is in its final state. * it will also add the key `history` to the txMeta with the snap shot of * the original object * * @param {TransactionMeta} txMeta - The TransactionMeta object to add. * @returns {TransactionMeta} The same TransactionMeta, but with validated * txParams and transaction history. */ addTransaction(txMeta) { // normalize and validate txParams if present if (txMeta.txParams) { txMeta.txParams = normalizeAndValidateTxParams(txMeta.txParams, false); } this.once(`${txMeta.id}:signed`, () => { this.removeAllListeners(`${txMeta.id}:rejected`); }); this.once(`${txMeta.id}:rejected`, () => { this.removeAllListeners(`${txMeta.id}:signed`); }); // initialize history txMeta.history = []; // capture initial snapshot of txMeta for history const snapshot = snapshotFromTxMeta(txMeta); txMeta.history.push(snapshot); const transactions = this.getTransactions({ filterToCurrentNetwork: false, }); const { txHistoryLimit } = this; // checks if the length of the tx history is longer then desired persistence // limit and then if it is removes the oldest confirmed or rejected tx. // Pending or unapproved transactions will not be removed by this // operation. For safety of presenting a fully functional transaction UI // representation, this function will not break apart transactions with the // same nonce, per network. Not accounting for transactions of the same // nonce and network combo can result in confusing or broken experiences // in the UI. // // TODO: we are already limiting what we send to the UI, and in the future // we will send UI only collected groups of transactions *per page* so at // some point in the future, this persistence limit can be adjusted. When // we do that I think we should figure out a better storage solution for // transaction history entries. const nonceNetworkSet = new Set(); const txsToDelete = transactions .reverse() .filter((tx) => { const { nonce, from } = tx.txParams; const { chainId, metamaskNetworkId, status } = tx; const key = `${nonce}-${chainId ?? metamaskNetworkId}-${from}`; if (nonceNetworkSet.has(key)) { return false; } else if ( nonceNetworkSet.size < txHistoryLimit - 1 || getFinalStates().includes(status) === false ) { nonceNetworkSet.add(key); return false; } return true; }) .map((tx) => tx.id); this._deleteTransactions(txsToDelete); this._addTransactionsToState([txMeta]); return txMeta; } addExternalTransaction(txMeta) { const fromAddress = txMeta?.txParams?.from; const confirmedTransactions = this.getConfirmedTransactions(fromAddress); const pendingTransactions = this.getPendingTransactions(fromAddress); validateConfirmedExternalTransaction({ txMeta, pendingTransactions, confirmedTransactions, }); this._addTransactionsToState([txMeta]); return txMeta; } /** * @param {number} txId * @returns {TransactionMeta} the txMeta who matches the given id if none found * for the network returns undefined */ getTransaction(txId) { const { transactions } = this.store.getState(); return transactions[txId]; } /** * updates the txMeta in the list and adds a history entry * * @param {Object} txMeta - the txMeta to update * @param {string} [note] - a note about the update for history */ updateTransaction(txMeta, note) { // normalize and validate txParams if present if (txMeta.txParams) { txMeta.txParams = normalizeAndValidateTxParams(txMeta.txParams, false); } // create txMeta snapshot for history const currentState = snapshotFromTxMeta(txMeta); // recover previous tx state obj const previousState = replayHistory(txMeta.history); // generate history entry and add to history const entry = generateHistoryEntry(previousState, currentState, note); if (entry.length) { txMeta.history.push(entry); } // commit txMeta to state const txId = txMeta.id; this.store.updateState({ transactions: { ...this.store.getState().transactions, [txId]: txMeta, }, }); } /** * SearchCriteria can search in any key in TxParams or the base * TransactionMeta. This type represents any key on either of those two * types. * * @typedef {TxParams[keyof TxParams] | TransactionMeta[keyof TransactionMeta]} SearchableKeys */ /** * Predicates can either be strict values, which is shorthand for using * strict equality, or a method that receives he value of the specified key * and returns a boolean. * * @typedef {(v: unknown) => boolean | unknown} FilterPredicate */ /** * Retrieve a list of transactions from state. By default this will return * the full list of Transactions for the currently selected chain/network. * Additional options can be provided to change what is included in the final * list. * * @param opts - options to change filter behavior * @param {Record} [opts.searchCriteria] - * an object with keys that match keys in TransactionMeta or TxParams, and * values that are predicates. Predicates can either be strict values, * which is shorthand for using strict equality, or a method that receives * the value of the specified key and returns a boolean. The transaction * list will be filtered to only those items that the predicate returns * truthy for. **HINT**: `err: undefined` is like looking for a tx with no * err. so you can also search txs that don't have something as well by * setting the value as undefined. * @param {TransactionMeta[]} [opts.initialList] - If provided the filtering * will occur on the provided list. By default this will be the full list * from state sorted by time ASC. * @param {boolean} [opts.filterToCurrentNetwork] - Filter transaction * list to only those that occurred on the current chain or network. * Defaults to true. * @param {number} [opts.limit] - limit the number of transactions returned * to N unique nonces. * @returns {TransactionMeta[]} The TransactionMeta objects that all provided * predicates return truthy for. */ getTransactions({ searchCriteria = {}, initialList, filterToCurrentNetwork = true, limit, } = {}) { const chainId = this.getCurrentChainId(); const network = this.getNetwork(); // searchCriteria is an object that might have values that aren't predicate // methods. When providing any other value type (string, number, etc), we // consider this shorthand for "check the value at key for strict equality // with the provided value". To conform this object to be only methods, we // mapValues (lodash) such that every value on the object is a method that // returns a boolean. const predicateMethods = mapValues(searchCriteria, (predicate) => { return typeof predicate === 'function' ? predicate : (v) => v === predicate; }); // If an initial list is provided we need to change it back into an object // first, so that it matches the shape of our state. This is done by the // lodash keyBy method. This is the edge case for this method, typically // initialList will be undefined. const transactionsToFilter = initialList ? keyBy(initialList, 'id') : this.store.getState().transactions; // Combine sortBy and pickBy to transform our state object into an array of // matching transactions that are sorted by time. const filteredTransactions = sortBy( pickBy(transactionsToFilter, (transaction) => { // default matchesCriteria to the value of transactionMatchesNetwork // when filterToCurrentNetwork is true. if ( filterToCurrentNetwork && transactionMatchesNetwork(transaction, chainId, network) === false ) { return false; } // iterate over the predicateMethods keys to check if the transaction // matches the searchCriteria for (const [key, predicate] of Object.entries(predicateMethods)) { // We return false early as soon as we know that one of the specified // search criteria do not match the transaction. This prevents // needlessly checking all criteria when we already know the criteria // are not fully satisfied. We check both txParams and the base // object as predicate keys can be either. if (key in transaction.txParams) { if (predicate(transaction.txParams[key]) === false) { return false; } } else if (predicate(transaction[key]) === false) { return false; } } return true; }), 'time', ); if (limit !== undefined) { // We need to have all transactions of a given nonce in order to display // necessary details in the UI. We use the size of this set to determine // whether we have reached the limit provided, thus ensuring that all // transactions of nonces we include will be sent to the UI. const nonces = new Set(); const txs = []; // By default, the transaction list we filter from is sorted by time ASC. // To ensure that filtered results prefers the newest transactions we // iterate from right to left, inserting transactions into front of a new // array. The original order is preserved, but we ensure that newest txs // are preferred. for (let i = filteredTransactions.length - 1; i > -1; i--) { const txMeta = filteredTransactions[i]; const { nonce } = txMeta.txParams; if (!nonces.has(nonce)) { if (nonces.size < limit) { nonces.add(nonce); } else { continue; } } // Push transaction into the beginning of our array to ensure the // original order is preserved. txs.unshift(txMeta); } return txs; } return filteredTransactions; } /** * Update status of the TransactionMeta with provided id to 'rejected'. * After setting the status, the TransactionMeta is deleted from state. * * TODO: Should we show historically rejected transactions somewhere in the * UI? Seems like it could be valuable for information purposes. Of course * only after limit issues are reduced. * * @param {number} txId - the target TransactionMeta's Id */ setTxStatusRejected(txId) { this._setTransactionStatus(txId, TRANSACTION_STATUSES.REJECTED); this._deleteTransaction(txId); } /** * Update status of the TransactionMeta with provided id to 'unapproved' * * @param {number} txId - the target TransactionMeta's Id */ setTxStatusUnapproved(txId) { this._setTransactionStatus(txId, TRANSACTION_STATUSES.UNAPPROVED); } /** * Update status of the TransactionMeta with provided id to 'approved' * * @param {number} txId - the target TransactionMeta's Id */ setTxStatusApproved(txId) { this._setTransactionStatus(txId, TRANSACTION_STATUSES.APPROVED); } /** * Update status of the TransactionMeta with provided id to 'signed' * * @param {number} txId - the target TransactionMeta's Id */ setTxStatusSigned(txId) { this._setTransactionStatus(txId, TRANSACTION_STATUSES.SIGNED); } /** * Update status of the TransactionMeta with provided id to 'submitted' * and sets the 'submittedTime' property with the current Unix epoch time. * * @param {number} txId - the target TransactionMeta's Id */ setTxStatusSubmitted(txId) { const txMeta = this.getTransaction(txId); txMeta.submittedTime = new Date().getTime(); this.updateTransaction(txMeta, 'txStateManager - add submitted time stamp'); this._setTransactionStatus(txId, TRANSACTION_STATUSES.SUBMITTED); } /** * Update status of the TransactionMeta with provided id to 'confirmed' * * @param {number} txId - the target TransactionMeta's Id */ setTxStatusConfirmed(txId) { this._setTransactionStatus(txId, TRANSACTION_STATUSES.CONFIRMED); } /** * Update status of the TransactionMeta with provided id to 'dropped' * * @param {number} txId - the target TransactionMeta's Id */ setTxStatusDropped(txId) { this._setTransactionStatus(txId, TRANSACTION_STATUSES.DROPPED); } /** * Update status of the TransactionMeta with provided id to 'failed' and put * the error on the TransactionMeta object. * * @param {number} txId - the target TransactionMeta's Id * @param {Error} err - error object */ setTxStatusFailed(txId, err) { const error = err || new Error('Internal metamask failure'); const txMeta = this.getTransaction(txId); txMeta.err = { message: error.message?.toString() || error.toString(), rpc: error.value, stack: error.stack, }; this.updateTransaction( txMeta, 'transactions:tx-state-manager#fail - add error', ); this._setTransactionStatus(txId, TRANSACTION_STATUSES.FAILED); } /** * Removes all transactions for the given address on the current network, * preferring chainId for comparison over networkId. * * @param {string} address - hex string of the from address on the txParams * to remove */ wipeTransactions(address) { // network only tx const { transactions } = this.store.getState(); const network = this.getNetwork(); const chainId = this.getCurrentChainId(); // Update state this.store.updateState({ transactions: omitBy( transactions, (transaction) => transaction.txParams.from === address && transactionMatchesNetwork(transaction, chainId, network), ), }); } /** * Filters out the unapproved transactions from state */ clearUnapprovedTxs() { this.store.updateState({ transactions: omitBy( this.store.getState().transactions, (transaction) => transaction.status === TRANSACTION_STATUSES.UNAPPROVED, ), }); } // // PRIVATE METHODS // /** * Updates a transaction's status in state, and then emits events that are * subscribed to elsewhere. See below for best guesses on where and how these * events are received. * * @param {number} txId - the TransactionMeta Id * @param {TransactionStatusString} status - the status to set on the * TransactionMeta * @fires txMeta.id:txMeta.status - every time a transaction's status changes * we emit the change passing along the id. This does not appear to be used * outside of this file, which only listens to this to unsubscribe listeners * of :rejected and :signed statuses when the inverse status changes. Likely * safe to drop. * @fires tx:status-update - every time a transaction's status changes we * emit this event and pass txId and status. This event is subscribed to in * the TransactionController and re-broadcast by the TransactionController. * It is used internally within the TransactionController to try and update * pending transactions on each new block (from blockTracker). It's also * subscribed to in metamask-controller to display a browser notification on * confirmed or failed transactions. * @fires txMeta.id:finished - When a transaction moves to a finished state * this event is emitted, which is used in the TransactionController to pass * along details of the transaction to the dapp that suggested them. This * pattern is replicated across all of the message managers and can likely * be supplemented or replaced by the ApprovalController. * @fires updateBadge - When the number of transactions changes in state, * the badge in the browser extension bar should be updated to reflect the * number of pending transactions. This particular emit doesn't appear to * bubble up anywhere that is actually used. TransactionController emits * this *anytime the state changes*, so this is probably superfluous. */ _setTransactionStatus(txId, status) { const txMeta = this.getTransaction(txId); if (!txMeta) { return; } txMeta.status = status; try { this.updateTransaction( txMeta, `txStateManager: setting status to ${status}`, ); this.emit(`${txMeta.id}:${status}`, txId); this.emit(`tx:status-update`, txId, status); if ( [ TRANSACTION_STATUSES.SUBMITTED, TRANSACTION_STATUSES.REJECTED, TRANSACTION_STATUSES.FAILED, ].includes(status) ) { this.emit(`${txMeta.id}:finished`, txMeta); } this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE); } catch (error) { log.error(error); } } /** * Adds one or more transactions into state. This is not intended for * external use. * * @private * @param {TransactionMeta[]} transactions - the list of transactions to save */ _addTransactionsToState(transactions) { this.store.updateState({ transactions: transactions.reduce((result, newTx) => { result[newTx.id] = newTx; return result; }, this.store.getState().transactions), }); } /** * removes one transaction from state. This is not intended for external use. * * @private * @param {number} targetTransactionId - the transaction to delete */ _deleteTransaction(targetTransactionId) { const { transactions } = this.store.getState(); delete transactions[targetTransactionId]; this.store.updateState({ transactions, }); } /** * removes multiple transaction from state. This is not intended for external use. * * @private * @param {number[]} targetTransactionIds - the transactions to delete */ _deleteTransactions(targetTransactionIds) { const { transactions } = this.store.getState(); targetTransactionIds.forEach((transactionId) => { delete transactions[transactionId]; }); this.store.updateState({ transactions, }); } }