import EventEmitter from 'safe-event-emitter' import { ObservableStore } from '@metamask/obs-store' import log from 'loglevel' import createId from '../../lib/random-id' import { TRANSACTION_STATUSES } from '../../../../shared/constants/transaction' import { generateHistoryEntry, replayHistory, snapshotFromTxMeta, } from './lib/tx-state-history-helpers' import { getFinalStates, normalizeTxParams } from './lib/util' /** * TransactionStatuses reimported from the shared transaction constants file * @typedef {import('../../../../shared/constants/transaction').TransactionStatuses} TransactionStatuses */ /** * 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 {Object} [opts.initState={ transactions: [] }] - initial transactions list with the key transaction {Array} * @param {number} [opts.txHistoryLimit] - limit for how many finished * transactions can hang around in state * @param {Function} opts.getNetwork - return network number * @class */ export default class TransactionStateManager extends EventEmitter { constructor({ initState, txHistoryLimit, getNetwork }) { super() this.store = new ObservableStore({ transactions: [], ...initState }) this.txHistoryLimit = txHistoryLimit this.getNetwork = getNetwork } /** * @param {Object} opts - the object to use when overwriting defaults * @returns {txMeta} the default txMeta object */ generateTxMeta(opts) { const netId = this.getNetwork() if (netId === 'loading') { throw new Error('MetaMask is having trouble connecting to the network') } return { id: createId(), time: new Date().getTime(), status: TRANSACTION_STATUSES.UNAPPROVED, metamaskNetworkId: netId, loadingDefaults: true, ...opts, } } /** * Returns the full tx list for the current network * * The list is iterated backwards as new transactions are pushed onto it. * * @param {number} [limit] - a limit for the number of transactions to return * @returns {Object[]} The {@code txMeta}s, filtered to the current network */ getTxList(limit) { const network = this.getNetwork() const fullTxList = this.getFullTxList() const nonces = new Set() const txs = [] for (let i = fullTxList.length - 1; i > -1; i--) { const txMeta = fullTxList[i] if (txMeta.metamaskNetworkId !== network) { continue } if (limit !== undefined) { const { nonce } = txMeta.txParams if (!nonces.has(nonce)) { if (nonces.size < limit) { nonces.add(nonce) } else { continue } } } txs.unshift(txMeta) } return txs } /** * @returns {Array} of all the txMetas in store */ getFullTxList() { return this.store.getState().transactions } /** * @returns {Array} the tx list with unapproved status */ getUnapprovedTxList() { const txList = this.getTxsByMetaData( 'status', TRANSACTION_STATUSES.UNAPPROVED, ) return txList.reduce((result, tx) => { result[tx.id] = tx return result }, {}) } /** * @param {string} [address] - hex prefixed address to sort the txMetas for [optional] * @returns {Array} the tx list with approved status if no address is provide * returns all txMetas with approved statuses for the current network */ getApprovedTransactions(address) { const opts = { status: TRANSACTION_STATUSES.APPROVED } if (address) { opts.from = address } return this.getFilteredTxList(opts) } /** * @param {string} [address] - hex prefixed address to sort the txMetas for [optional] * @returns {Array} the tx list submitted status if no address is provide * returns all txMetas with submitted statuses for the current network */ getPendingTransactions(address) { const opts = { status: TRANSACTION_STATUSES.SUBMITTED } if (address) { opts.from = address } return this.getFilteredTxList(opts) } /** @param {string} [address] - hex prefixed address to sort the txMetas for [optional] @returns {Array} the tx list whose status is confirmed if no address is provide returns all txMetas who's status is confirmed for the current network */ getConfirmedTransactions(address) { const opts = { status: TRANSACTION_STATUSES.CONFIRMED } if (address) { opts.from = address } return this.getFilteredTxList(opts) } /** * 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 {Object} txMeta * @returns {Object} the txMeta */ addTx(txMeta) { // normalize and validate txParams if present if (txMeta.txParams) { txMeta.txParams = this.normalizeAndValidateTxParams(txMeta.txParams) } 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.getFullTxList() const txCount = transactions.length const { txHistoryLimit } = this // 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) { const index = transactions.findIndex((metaTx) => { return getFinalStates().includes(metaTx.status) }) if (index !== -1) { transactions.splice(index, 1) } } const newTxIndex = transactions.findIndex( (currentTxMeta) => currentTxMeta.time > txMeta.time, ) newTxIndex === -1 ? transactions.push(txMeta) : transactions.splice(newTxIndex, 0, txMeta) this._saveTxList(transactions) return txMeta } /** * @param {number} txId * @returns {Object} the txMeta who matches the given id if none found * for the network returns undefined */ getTx(txId) { const txMeta = this.getTxsByMetaData('id', txId)[0] return txMeta } /** * 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 */ updateTx(txMeta, note) { // normalize and validate txParams if present if (txMeta.txParams) { txMeta.txParams = this.normalizeAndValidateTxParams(txMeta.txParams) } // 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 const txList = this.getFullTxList() const index = txList.findIndex((txData) => txData.id === txId) txList[index] = txMeta this._saveTxList(txList) } /** * merges txParams obj onto txMeta.txParams use extend to ensure * that all fields are filled * @param {number} txId - the id of the txMeta * @param {Object} txParams - the updated txParams */ updateTxParams(txId, txParams) { const txMeta = this.getTx(txId) txMeta.txParams = { ...txMeta.txParams, ...txParams } this.updateTx(txMeta, `txStateManager#updateTxParams`) } /** * normalize and validate txParams members * @param {Object} txParams - txParams */ normalizeAndValidateTxParams(txParams) { if (typeof txParams.data === 'undefined') { delete txParams.data } // eslint-disable-next-line no-param-reassign txParams = normalizeTxParams(txParams, false) this.validateTxParams(txParams) return txParams } /** * validates txParams members by type * @param {Object} txParams - txParams to validate */ 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})`, ) } break } }) } /** @param {Object} opts - an object of fields to search for eg:
let thingsToLookFor = {
to: '0x0..',
from: '0x0..',
status: 'signed', \\ (status) => status !== 'rejected' give me all txs who's status is not rejected
err: undefined,
}
optionally the values of the keys can be functions for situations like where you want all but one status. @param {Array} [initialList=this.getTxList()] @returns {Array} array of txMeta 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 filtering 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 } /** * @param {string} key - the key to check * @param {any} value - the value your looking for can also be a function that returns a bool * @param {Array} [txList=this.getTxList()] - the list to search. default is the txList * from txStateManager#getTxList * @returns {Array} a list of txMetas who matches the search params */ getTxsByMetaData(key, value, txList = this.getTxList()) { const filter = typeof value === 'function' ? value : (v) => v === value return txList.filter((txMeta) => { if (key in txMeta.txParams) { return filter(txMeta.txParams[key]) } return filter(txMeta[key]) }) } // get::set status /** * @param {number} txId - the txMeta Id * @returns {string} the status of the tx. */ getTxStatus(txId) { const txMeta = this.getTx(txId) return txMeta.status } /** * Update the status of the tx to 'rejected'. * @param {number} txId - the txMeta Id */ setTxStatusRejected(txId) { this._setTxStatus(txId, 'rejected') this._removeTx(txId) } /** * Update the status of the tx to 'unapproved'. * @param {number} txId - the txMeta Id */ setTxStatusUnapproved(txId) { this._setTxStatus(txId, TRANSACTION_STATUSES.UNAPPROVED) } /** * Update the status of the tx to 'approved'. * @param {number} txId - the txMeta Id */ setTxStatusApproved(txId) { this._setTxStatus(txId, TRANSACTION_STATUSES.APPROVED) } /** * Update the status of the tx to 'signed'. * @param {number} txId - the txMeta Id */ setTxStatusSigned(txId) { this._setTxStatus(txId, TRANSACTION_STATUSES.SIGNED) } /** * Update the status of the tx to 'submitted' and add a time stamp * for when it was called * @param {number} txId - the txMeta Id */ setTxStatusSubmitted(txId) { const txMeta = this.getTx(txId) txMeta.submittedTime = new Date().getTime() this.updateTx(txMeta, 'txStateManager - add submitted time stamp') this._setTxStatus(txId, TRANSACTION_STATUSES.SUBMITTED) } /** * Update the status of the tx to 'confirmed'. * @param {number} txId - the txMeta Id */ setTxStatusConfirmed(txId) { this._setTxStatus(txId, TRANSACTION_STATUSES.CONFIRMED) } /** * Update the status of the tx to 'dropped'. * @param {number} txId - the txMeta Id */ setTxStatusDropped(txId) { this._setTxStatus(txId, TRANSACTION_STATUSES.DROPPED) } /** * Updates the status of the tx to 'failed' and put the error on the txMeta * @param {number} txId - the txMeta Id * @param {erroObject} err - error object */ setTxStatusFailed(txId, err) { const error = err || new Error('Internal metamask failure') const txMeta = this.getTx(txId) txMeta.err = { message: error.toString(), rpc: error.value, stack: error.stack, } this.updateTx(txMeta, 'transactions:tx-state-manager#fail - add error') this._setTxStatus(txId, TRANSACTION_STATUSES.FAILED) } /** * Removes transaction from the given address for the current network * from the txList * @param {string} address - hex string of the from address on the txParams * to remove */ 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 // /** * @param {number} txId - the txMeta Id * @param {TransactionStatuses[keyof TransactionStatuses]} status - the status to set on the txMeta * @emits tx:status-update - passes txId and status * @emits ${txMeta.id}:finished - if it is a finished state. Passes the txMeta * @emits update:badge */ _setTxStatus(txId, status) { const txMeta = this.getTx(txId) if (!txMeta) { return } txMeta.status = status try { this.updateTx(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('update:badge') } catch (error) { log.error(error) } } /** * Saves the new/updated txList. Intended only for internal use * @param {Array} transactions - the list of transactions to save */ _saveTxList(transactions) { this.store.updateState({ transactions }) } _removeTx(txId) { const transactionList = this.getFullTxList() this._saveTxList(transactionList.filter((txMeta) => txMeta.id !== txId)) } /** * Filters out the unapproved transactions */ clearUnapprovedTxs() { const transactions = this.getFullTxList() const nonUnapprovedTxs = transactions.filter( (tx) => tx.status !== TRANSACTION_STATUSES.UNAPPROVED, ) this._saveTxList(nonUnapprovedTxs) } }