controllers/transactions/tx-state-manager.js

const extend = require('xtend')
const EventEmitter = require('events')
const ObservableStore = require('obs-store')
const ethUtil = require('ethereumjs-util')
const log = require('loglevel')
const txStateHistoryHelper = require('./lib/tx-state-history-helper')
const createId = require('../../lib/random-id')
const { getFinalStates } = require('./lib/util')
/**
  TransactionStateManager is responsible for the state of a transaction and
  storing the transaction
  it also has some convenience methods for finding subsets of transactions
  *
  *STATUS METHODS
  <br>statuses:
  <br>   - `'unapproved'` the user has not responded
  <br>   - `'rejected'` the user has responded no!
  <br>   - `'approved'` the user has approved the tx
  <br>   - `'signed'` the tx is signed
  <br>   - `'submitted'` the tx is sent to a server
  <br>   - `'confirmed'` the tx has been included in a block.
  <br>   - `'failed'` the tx failed for some reason, included on tx data.
  <br>   - `'dropped'` the tx nonce was already used
  @param opts {object}
  @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
*/
class TransactionStateManager extends EventEmitter {
  constructor ({ initState, txHistoryLimit, getNetwork }) {
    super()

    this.store = new ObservableStore(
      extend({
        transactions: [],
    }, initState))
    this.txHistoryLimit = txHistoryLimit
    this.getNetwork = getNetwork
  }

  /**
    @param opts {object} - the object to use when overwriting defaults
    @returns {txMeta} the default txMeta object
  */
  generateTxMeta (opts) {
    return extend({
      id: createId(),
      time: (new Date()).getTime(),
      status: 'unapproved',
      metamaskNetworkId: this.getNetwork(),
      loadingDefaults: true,
    }, opts)
  }

  /**
    @returns {array} of txMetas that have been filtered for only the current network
  */
  getTxList () {
    const network = this.getNetwork()
    const fullTxList = this.getFullTxList()
    return fullTxList.filter((txMeta) => txMeta.metamaskNetworkId === network)
  }

  /**
    @returns {array} of all the txMetas in store
  */
  getFullTxList () {
    return this.store.getState().transactions
  }

  /**
    @returns {array} the tx list whos status is unapproved
  */
  getUnapprovedTxList () {
    const txList = this.getTxsByMetaData('status', 'unapproved')
    return txList.reduce((result, tx) => {
      result[tx.id] = tx
      return result
    }, {})
  }

  /**
    @param [address] {string} - hex prefixed address to sort the txMetas for [optional]
    @returns {array} the tx list whos status is submitted if no address is provide
    returns all txMetas who's status is submitted for the current network
  */
  getPendingTransactions (address) {
    const opts = { status: 'submitted' }
    if (address) opts.from = address
    return this.getFilteredTxList(opts)
  }

  /**
    @param [address] {string} - hex prefixed address to sort the txMetas for [optional]
    @returns {array} the tx list whos 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: '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 allso add the key `history` to the txMeta with the snap shot of the original
    object
    @param txMeta {Object}
    @returns {object} the txMeta
  */
  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) {
      const index = transactions.findIndex((metaTx) => {
        return getFinalStates().includes(metaTx.status)
      })
      if (index !== -1) {
        transactions.splice(index, 1)
      }
    }
    transactions.push(txMeta)
    this._saveTxList(transactions)
    return txMeta
  }
  /**
    @param txId {number}
    @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 txMeta {Object} - the txMeta to update
    @param [note] {string} - a note about the update for history
  */
  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 txMeta.txParams
    use extend to ensure that all fields are filled
    @param txId {number} - the id of the txMeta
    @param txParams {object} - the updated txParams
  */
  updateTxParams (txId, txParams) {
    const txMeta = this.getTx(txId)
    txMeta.txParams = extend(txMeta.txParams, txParams)
    this.updateTx(txMeta, `txStateManager#updateTxParams`)
  }

  /**
    validates txParams members by type
    @param txParams {object} - 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})`)
          if (!ethUtil.isHexPrefixed(value)) throw new Error(`${key} in txParams is not hex prefixed. got: (${value})`)
          break
      }
    })
  }

/**
  @param opts {object} -  an object of fields to search for eg:<br>
  let <code>thingsToLookFor = {<br>
    to: '0x0..',<br>
    from: '0x0..',<br>
    status: 'signed',<br>
    err: undefined,<br>
  }<br></code>
  @param [initialList=this.getTxList()]
  @returns a {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 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
  }
  /**

    @param key {string} - the key to check
    @param value - the value your looking for
    @param [txList=this.getTxList()] {array} - 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()) {
    return txList.filter((txMeta) => {
      if (key in txMeta.txParams) {
        return txMeta.txParams[key] === value
      } else {
        return txMeta[key] === value
      }
    })
  }

  // get::set status

  /**
    @param txId {number} - the txMeta Id
    @return {string} the status of the tx.
  */
  getTxStatus (txId) {
    const txMeta = this.getTx(txId)
    return txMeta.status
  }

  /**
    should update the status of the tx to 'rejected'.
    @param txId {number} - the txMeta Id
  */
  setTxStatusRejected (txId) {
    this._setTxStatus(txId, 'rejected')
    this._removeTx(txId)
  }

  /**
    should update the status of the tx to 'unapproved'.
    @param txId {number} - the txMeta Id
  */
  setTxStatusUnapproved (txId) {
    this._setTxStatus(txId, 'unapproved')
  }
  /**
    should update the status of the tx to 'approved'.
    @param txId {number} - the txMeta Id
  */
  setTxStatusApproved (txId) {
    this._setTxStatus(txId, 'approved')
  }

  /**
    should update the status of the tx to 'signed'.
    @param txId {number} - the txMeta Id
  */
  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
    @param txId {number} - 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, 'submitted')
  }

  /**
    should update the status of the tx to 'confirmed'.
    @param txId {number} - the txMeta Id
  */
  setTxStatusConfirmed (txId) {
    this._setTxStatus(txId, 'confirmed')
  }

  /**
    should update the status of the tx to 'dropped'.
    @param txId {number} - the txMeta Id
  */
  setTxStatusDropped (txId) {
    this._setTxStatus(txId, 'dropped')
  }


  /**
    should update the status of the tx to 'failed'.
    and put the error on the txMeta
    @param txId {number} - the txMeta Id
    @param err {erroObject} - error object
  */
  setTxStatusFailed (txId, err) {
    const txMeta = this.getTx(txId)
    txMeta.err = {
      message: err.toString(),
      rpc: err.value,
      stack: err.stack,
    }
    this.updateTx(txMeta)
    this._setTxStatus(txId, 'failed')
  }

  /**
    Removes transaction from the given address for the current network
    from the txList
    @param address {string} - 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
//

  // 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

  /**
    @param txId {number} - the txMeta Id
    @param status {string} - 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)
    txMeta.status = status
    setTimeout(() => {
      try {
        this.updateTx(txMeta, `txStateManager: setting status to ${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.emit('update:badge')
      } catch (error) {
        log.error(error)
      }
    })
  }

  /**
    Saves the new/updated txList.
    @param transactions {array} - the list of transactions to save
  */
  // Function is intended only for internal use
  _saveTxList (transactions) {
    this.store.updateState({ transactions })
  }

  _removeTx (txId) {
    const transactionList = this.getFullTxList()
    this._saveTxList(transactionList.filter((txMeta) => txMeta.id !== txId))
  }
}

module.exports = TransactionStateManager