modules/wallets/ExplorerWallet.js

import { sign } from 'bitcoinjs-message'
import { ECPair, payments, address } from 'bitcoinjs-lib'
import coinselect from 'coinselect'
import Insight from 'insight-explorer'

// This dependency was not found:
//
// * fs in ./node_modules/bindings/bindings.js
import floTx from 'fcoin/lib/primitives/tx'
import { isValidWIF } from '../../util'
import { floMainnet, floTestnet } from '../../config'
import FLOTransactionBuilder from '../flo/FLOTransactionBuilder'

if (typeof window === 'undefined' || typeof window.localStorage === 'undefined') {
  if (typeof localStorage === 'undefined') { // eslint-disable-line
    // var is needed her for the javascript hoisting effect or else localstorage won't be scoped
    var LocalStorage = require('node-localstorage').LocalStorage
    var localStorage = new LocalStorage('./localStorage')
  }
} else {
  localStorage = window.localStorage
}

/**
 * @typedef {Object} utxo
 * @param {string} address - pay to public key hash (pub address)
 * @param {TXID} txid - transaction id
 * @param {number} vout - index of output in transaction
 * @param {string} scriptPubKey -  script which ensures that the script supplied in the redeeming transaction hashes to the script used to create the address
 * @param {number} amount - the amount spent
 * @param {number} satoshis - the amount spent in satoshis
 * @param {number} height - the block height of the transaction
 * @param {number} confirmations - number of blocks that have been confirmed after the transaction's block
 *
 * @example
 * {
 *     address: 'ofbB67gqjgaYi45u8Qk2U3hGoCmyZcgbN4',
 *     txid: '40bf49a02731b04b71951d2e7782b93bd30678c5f5608f0cfe9cdaed6d392903',
 *     vout: 1,
 *     scriptPubKey: '76a914f93aef4f4ef998b7ae44bd5bc8f6627b79cdc07588ac',
 *     amount: 659.9999325,
 *     satoshis: 65999993250,
 *     height: 295680,
 *     confirmations: 6933
 * }
 */

/**
 * Class to use a web explorer wallet
 */
class ExplorerWallet {
  /**
   * ##### Example
   * ```javascript
   * import {OIP} from 'js-oip'
   *
   * let wif = "cRVa9rNx5N1YKBw8PhavegJPFCiYCfC4n8cYmdc3X1Y6TyFZGG4B"
   * let oip = new OIP(wif, "testnet")
   * ```
   * @param {string} options.wif - private key in Wallet Import Format (WIF) see: {@link https://en.bitcoin.it/wiki/Wallet_import_format}
   * @param {string} [options.network="mainnet"] - Use "testnet" for testnet
   */
  // ToDo:: Switch to mainnet for prod
  constructor (options) {
    let network = floMainnet

    if (options.network === 'testnet') { network = floTestnet }

    if (options.explorerUrl) { network.explorer = new Insight(options.explorerUrl) }

    if (!isValidWIF(options.wif, network.network)) {
      return { success: false, message: 'Invalid WIF', wif: options.wif, network: network.network }
    }

    this.coin = network
    this.network = network.network
    this.explorer = network.explorer
    this.ECPair = ECPair.fromWIF(options.wif, this.network)
    this.p2pkh = payments.p2pkh({ pubkey: this.ECPair.publicKey, network: this.network }).address
    this.spentTransactions = []
    this.history = []

    this.deserialize()
  }

  signMessage (message) {
    let privateKeyBuffer = this.ECPair.privateKey

    let compressed = this.ECPair.compressed || true

    let signatureBuffer
    try {
      signatureBuffer = sign(message, privateKeyBuffer, compressed, this.ECPair.network.messagePrefix)
    } catch (e) {
      throw new Error(e)
    }

    let signature = signatureBuffer.toString('base64')

    return signature
  }

  /**
   * Send string data to the FLO Chain
   * @param {string} data - String data. Must be below or equal to 1040 characters
   * @return {Promise<string>} txid - Returns the id of the transaction that contains the published data
   * @example
   * let oip = new OIP(wif, "testnet")
   * let txid = await oip.sendDataToChain('Hello, world')
   */
  async sendDataToChain (data) {
    if (typeof data !== 'string') {
      throw new Error(`Data must be of type string. Got: ${typeof data}`)
    }
    if (data.length > 1040) {
      throw new Error(`Error: data length exceeds 1040 characters. Try using OIPPublisher.publish(data) instead.`)
    }
    let hex
    try {
      hex = await this.buildTXHex(data)
    } catch (err) {
      throw new Error(`Error building TX Hex: ${err}`)
    }
    let txid
    try {
      txid = await this.broadcastRawHex(hex)
    } catch (err) {
      throw new Error(`Error broadcasting TX Hex: ${err}`)
    }

    // Add txid to spentTransactions for each spent input
    for (let inp of this.selected.inputs) {
      if (this.p2pkh === inp.address) {
        this.addSpentTransaction(inp.txId)
      }
    }

    this.save(txid, hex)

    return txid
  }

  /**
   * Build a valid FLO Raw TX Hex containing floData
   * @param {String} [floData=""] - String data to send with tx. Defaults to an empty string
   * @param {object|Array.<object>} [output] - Custom output object
   * @return {Promise<string>} hex - Returns raw transaction hex
   * @example
   * //if no output is designed, it will send 0.0001 * 1e8 FLO to yourself
   * let output = {
   *     address: "ofbB67gqjgaYi45u8Qk2U3hGoCmyZcgbN4",
   *     value: 1e8 //satoshis
   * }
   * let op = new OIP(wif, "testnet")
   * let hex = await op.buildTXHex("floData", output)
   */
  async buildTXHex (floData = '', output) {
    let selected
    try {
      selected = await this.buildInputsAndOutputs(floData, output)
    } catch (err) {
      throw new Error(`Failed to build inputs and outputs: ${err}`)
    }

    this.selected = selected
    // console.log('selected: ', selected)
    let { inputs, outputs } = selected

    // inputs and outputs will be undefined if no solution was found
    if (!inputs || !outputs) {
      throw new Error('No Inputs or Outputs selected! Fail!')
    }

    let txb = new FLOTransactionBuilder(this.network)

    inputs.forEach(input => txb.addInput(input.txId, input.vout))

    // Check if we are paying to ourself, if so, merge the outputs to just a single output.
    // Check if we have two outputs (i.e. pay to and change)
    if (outputs.length === 2) {
      // If the first input is sending to the from address, and there is a change output,
      // then merge the outputs.
      if (outputs[0].address === this.p2pkh && !outputs[1].address) {
        let totalToSend = outputs[0].value + outputs[1].value
        outputs = [{
          address: this.p2pkh,
          value: totalToSend
        }]
      } else {
        // send the original amount to the first address and send the rest to yourself as change
        if (outputs[0].address !== this.p2pkh && !outputs[1].address) {
          outputs[1].address = this.p2pkh
        }
      }
    }

    outputs.forEach(output => {
      if (!output.address) {
        throw new Error(`Missing output address: ${outputs}`)
      }
      txb.addOutput(output.address, output.value)
    })

    txb.setFloData(floData)

    for (let i in inputs) {
      if (this.p2pkh !== inputs[i].address) throw new Error(`Invalid inputs. Addresses don't match: ${inputs} & ${this.p2pkh}`)
      txb.sign(parseInt(i), this.ECPair)
    }

    let builtHex

    try {
      builtHex = txb.build().toHex()
    } catch (err) {
      throw new Error(`Unable to build Transaction Hex!: ${err}`)
    }

    return builtHex
  }

  /**
   * Builds the inputs and outputs to form a valid transaction hex for the FLO Chain
   * @param {string} [floData=""] - defaults to an empty string
   * @param {object|Array.<object>} [outputs] - Output or an array of Outputs to send to
   * @return {Promise<Object>} Returns the selected inputs, outputs, and fee to use for the transaction hex
   * @example
   * //basic
   * let oip = new OIP(wif, "testnet")
   * let selected = await oip.buildInputsAndOutputs("floData") //returns selected inputs, outputs, and fee
   * @example
   * //with custom output and object destructuring
   * let oip = new OIP(wif, "testnet")
   * let output = {
   *     address: "ofbB67gqjgaYi45u8Qk2U3hGoCmyZcgbN4",
   *     value: 1e8 //in satoshis
   * }
   * let {inputs, outputs, fee} = await oip.buildInputsAndOutputs("floData", output)
   */
  async buildInputsAndOutputs (floData = '', outputs) {
    let utxo
    try {
      utxo = await this.getUTXO()
    } catch (err) {
      throw new Error(`Failed to get utxo: ${err}`)
    }

    // backup in case insight api hasn't given us updated responses
    if (utxo.length === 0) {
      let start = Date.now(); let finish = 0
      console.log('Insight API returned stale results. Waiting on Insight API for update...')
      while (utxo.length === 0 && finish < 6000) {
        // console.log('while', finish)
        const delay = ms => new Promise(resolve => setTimeout(resolve, ms))
        await delay(500)
        try {
          utxo = await this.getUTXO()
        } catch (err) {
          throw new Error(`Failed to get utxo from insight explorer: ${err}`)
        }
        finish = Date.now() - start
      }
    }

    let formattedUtxos
    if (utxo.length === 0) {
      // second backup in case insight really is not liking us
      console.log('Insight API failed to update. Attempting to manually create utxos...')
      formattedUtxos = this.createManualUtxos()
      if (formattedUtxos.length === 0) {
        throw new Error(`P2PKH: ${this.p2pkh} has no unspent transaction outputs`)
      }
    } else {
      formattedUtxos = utxo.map(utxo => {
        return {
          address: utxo.address,
          txId: utxo.txid,
          vout: utxo.vout,
          scriptPubKey: utxo.scriptPubKey,
          value: utxo.satoshis,
          confirmations: utxo.confirmations
        }
      })
    }

    // console.log('formatted utxos', formattedUtxos)

    outputs = outputs || {
      address: this.p2pkh,
      value: Math.floor(0.0001 * this.coin.satPerCoin)
    }

    if (!Array.isArray(outputs)) {
      outputs = [outputs]
    }
    let targets = outputs

    let extraBytes = this.coin.getExtraBytes({ floData })
    let extraBytesLength = extraBytes.length

    // console.log(formattedUtxos)

    // let utxosNoUnconfirmed = formattedUtxos.filter(utx => utx.confirmations > 0) //ToDo

    // console.log(utxosNoUnconfirmed)

    let selected = coinselect(formattedUtxos, targets, Math.ceil(this.coin.feePerByte), extraBytesLength)

    // Check if we are able to build inputs/outputs off only unconfirmed transactions with confirmations > 0
    if (!selected.inputs || selected.inputs.length === 0 || !selected.outputs || selected.outputs.length === 0 || !selected.fee) {
      selected = coinselect(formattedUtxos, targets, Math.ceil(this.coin.feePerByte), extraBytesLength)
    }

    return selected
  }

  /**
   * Get Unspent Transaction Outputs for the given keypair
   * @return {Promise<Array.<utxo>>}
   * @example
   * const wif = 'cRVa9rNx5N1YKBw8PhavegJPFCiYCfC4n8cYmdc3X1Y6TyFZGG4B'
   * let oip = new OIP(wif, "testnet")
   * let utxos = await oip.getUTXO()
   * for (let tx of utxos) {
   *     console.log(tx)
   *     // [ { address: 'ofbB67gqjgaYi45u8Qk2U3hGoCmyZcgbN4',
   *     //     txid: '40bf49a02731b04b71951d2e7782b93bd30678c5f5608f0cfe9cdaed6d392903',
   *     //     vout: 1,
   *     //     scriptPubKey: '76a914f93aef4f4ef998b7ae44bd5bc8f6627b79cdc07588ac',
   *     //     amount: 659.9999325,
   *     //     satoshis: 65999993250,
   *     //     height: 295680,
   *     //     confirmations: 5706
   *     //  } ]
   * }
   */
  async getUTXO () {
    let utxo
    try {
      utxo = await this.explorer.getAddressUtxo(this.p2pkh)
    } catch (err) {
      throw new Error(`Error fetching UTXOs: ${err}`)
    }
    // console.log('preutxo: ', utxo)
    // console.log(utxo, this.getSpentTransactions())

    return this.removeSpent(utxo)
  }

  /**
   * Removes already spent transactions (that are kept in local memory)
   * @param {Array.<utxo>} unspentTransactions - An array of utxos
   * @return {Array.<utxo>}
   * @example
   * //shouldn't ever have to write this. Use `OIP.getUTXO()` instead
   * let oip = new OIP(wif, 'testnet')
   * let utxo
   * try {
   *     utxo = await oip.explorer.getAddressUtxo(pubAddr)
   * } catch (err) {
   *     throw new Error(`${err}`)
   * }
   * return oip.removeSpent(utxo)
   */
  removeSpent (unspentTransactions) {
    if (!unspentTransactions || !Array.isArray(unspentTransactions)) { return }

    let unspent = []

    for (let tx of unspentTransactions) {
      let spent = false
      for (let txid of this.getSpentTransactions()) {
        if (txid === tx.txid) {
          spent = true
        }
      }

      if (!spent) { unspent.push(tx) }
    }

    return unspent
  }

  /**
   * Manually create Unspent Transaction Outputs from previous known transactions.
   * Loops through spent transaction IDs in localStorage and created txs to find outputs to use.
   * @return {Array.<utxo>}
   */
  createManualUtxos () {
    // console.log('manually creating utxos')
    let unspents = []
    for (let txObj of this.history) {
      let match = false
      for (let tx of this.getSpentTransactions()) {
        for (let txid in txObj) {
          if (txid === tx) {
            match = true
          }
        }
      }
      if (!match) {
        // console.log(txObj)
        unspents.push(txObj)
      }
    }

    let floTxs = []
    for (let txObj of unspents) {
      for (let txid in txObj) {
        floTxs.push(floTx.fromRaw(txObj[txid], 'hex'))
      }
    }

    // console.log(floTxs)
    let utxos = []
    for (let f of floTxs) {
      // console.log(f)
      let outputs = f.outputs

      for (let i = 0; i < outputs.length; i++) {
        let addr = outputs[i].getAddress()
        // console.log(addr)
        if (Array.isArray(addr)) {
          throw new Error(`Can't handle array output`)
        }
        // convert mainnet addr -> testnet addr
        addr = addr.toBase58()
        let { hash } = address.fromBase58Check(addr)
        let testnetAddr = address.toBase58Check(hash, 115)
        if (testnetAddr === this.p2pkh) {
          let tmpObj = {
            address: testnetAddr,
            txId: f.txid(),
            vout: i,
            value: outputs[i].value,
            scriptPubKey: outputs[i].script.toRaw().toString('hex'),
            confirmations: 0
          }
          // console.log(tmpObj)
          utxos.push(tmpObj)
        }
      }
    }
    return utxos
  }

  /**
   * Add a spent transaction to local memory
   * @param {TXID} txid - transaction id
   * @return {void}
   * @example
   * let oip = new OIP(wif,  "testnet")
   * let output = {
   *     address: "oNAydz5TjkhdP3RPuu3nEirYQf49Jrzm4S",
   *     value: Math.floor(0.001 * floTestnet.satPerCoin)
   * }
   * let txid = await oip.createAndSendFloTx(output, "sending floData to testnet")
   * oip.addSpentTransaction(txid)
   * let spentTxs = oip.getSpentTransactions()
   * spentTxs === [txid] //true
   */
  addSpentTransaction (txid) {
    this.spentTransactions.push(txid)
  }

  /**
   * Returns an array of spent transaction ids
   * @return {Array.<TXID>}
   * @example
   * let oip = new OIP(wif, "testnet")
   * oip.addSpentTransaction(txid)
   * let txids = oip.getSpentTransactions()
   * txids = [txid] //true
   */
  getSpentTransactions () {
    return this.spentTransactions
  }

  /**
   * Broadcast raw transaction hex to the FLO chain
   * @param hex
   * @return {Promise<string>} txid - Returns a transaction id
   */
  async broadcastRawHex (hex) {
    let response
    try {
      response = await this.explorer.broadcastRawTransaction(hex)
    } catch (err) {
      throw new Error(`Failed to broadcast TX Hex: ${err}`)
    }
    let txid

    /** Handle { txid: "txid" } */
    if (response && typeof response.txid === 'string') { txid = response.txid }

    /**
     * Handle
     * {
     *    txid: {
     *        result: '05d2dd88d69cc32717d315152bfb474b0b1b561ae9a477aae091714c4ab216ac',
     *        error: null,
     *        id: 47070
     *     }
     * }
     */
    if (response && response.txid && response.txid.result) {
      txid = response.txid.result
    }

    /**
     * Handle
     * {
     *     result: '05d2dd88d69cc32717d315152bfb474b0b1b561ae9a477aae091714c4ab216ac',
     *     error: null,
     *     id: 47070
     * }
     */
    if (response && response.result) {
      txid = response.result
    }

    return txid
  }

  /**
   * Create and send a FLO tx with a custom output
   * @param {object|Array.<object>} outputs
   * @param {string} floData
   * @return {Promise<TXID>}
   * @example
   * let oip = new OIP(wif, "testnet")
   * let output = {
   *     address: "oNAydz5TjkhdP3RPuu3nEirYQf49Jrzm4S",
   *     value: 100000000
   * }
   * let txid = await oip.createAndSendFloTx(output, "to testnet")
   */
  async sendTx (outputs, floData = '') {
    if (floData && typeof floData !== 'string') {
      throw new Error(`Data must be of type string. Got: ${typeof floData}`)
    }
    if (floData.length > 1040) {
      return `Error: data length exceeds 1040 characters.`
    }
    let hex
    try {
      hex = await this.buildTXHex(floData, outputs)
    } catch (err) {
      throw new Error(`Error building TX Hex: ${err}`)
    }
    let txid
    try {
      txid = await this.broadcastRawHex(hex)
    } catch (err) {
      throw new Error(`Error broadcasting TX Hex: ${err}`)
    }

    // Add txid to spentTransactions for each spent input
    for (let inp of this.selected.inputs) {
      if (this.p2pkh === inp.address) {
        this.addSpentTransaction(inp.txId)
      }
    }

    this.save(txid, hex)

    return txid
  }

  /**
   * Saves a transaction to localStorage and memory
   * @param {string} txid
   * @param {string} hex
   * @example
   * let oip = new OIP(wif)
   * oip.save(`${txid}`, `${hex}`)
   */
  save (txid, hex) {
    let tmpObj = {}
    tmpObj[txid] = hex

    this.history.push(tmpObj)
    this.serialize()
  }

  /**
   * Stores important local variables to localStorage such as spent transactions and publish history
   * @example
   * let oip = new OIP(wif)
   * oip.serialize() //saves this.spentTransactions and this.history to localStorage memory
   */
  serialize () {
    let serialized = {
      spentTransactions: this.spentTransactions,
      history: this.history
    }

    localStorage.setItem('tx_history', JSON.stringify(serialized))
  }

  /**
   * Imports publisher history from localStorage
   * @example
   * let oip = new OIP(wif)
   * oip.deserialize() //sets this.spentTransactions and this.history from localStorage memory variables
   */
  deserialize () {
    let deserialized = JSON.parse(localStorage.getItem('tx_history'))
    if (!deserialized) { deserialized = {} }

    if (deserialized.spentTransactions) {
      this.spentTransactions = deserialized.spentTransactions
    }

    if (deserialized.history) {
      this.history = deserialized.history
    }
  }

  /**
   * Returns tx history variables
   * @example
   * let oip = new OIP(wif)
   * oip.getTxHistory()
   * //returns
   * // {
   * //   history: this.history,
   * //   spentTransactions: this.spentTransactions
   * // }
   * @return {{history: Array, spentTransactions: Array}}
   */
  getTxHistory () {
    return {
      history: this.history,
      spentTransactions: this.spentTransactions
    }
  }

  /**
   * WARNING!!! Deleting history may cause publishing to temporary fail as it might attempt to use spent transactions. Deletes the publisher history from localStorage
   * @example
   * let oip = new OIP(wif)
   * oip.deleteHistory()
   */
  deleteHistory () {
    localStorage.removeItem('tx_history')
    this.spentTransactions = []
    this.history = []
  }

  /**
   * Returns the pay-to-pubkey-hash address generated from the given wif
   * @return {string}
   */
  getPubAddress () {
    return this.p2pkh
  }

  /**
   * Returns the ECPair (private/public key pair) generated from the given wif
   * @return {object}
   */
  getECPair () {
    return this.ECPair
  }

  /**
   * Returns information about the current coin (either FLO or FLO_Testnet)
   * @return {CoinInfo}
   */
  getCoinInfo () {
    return this.coin
  }

  /**
   * Returns coin network information needed for address generation
   * @return {CoinNetwork}
   */
  getNetwork () {
    return this.network
  }
}

export default ExplorerWallet