TransactionBuilder.js

import * as bitcoin from '@oipwg/bitcoinjs-lib'
import coinselect from '@oipwg/coinselect'

import Address from './Address'
import { isValidPublicAddress } from './util'

import { FloPsbt } from './FloTransaction'

/**
 * An Output for a Transaction
 * @typedef {Object} OutputAddress
 * @example
 * { "FHQvhgDut1rn1nvQRZ3z9QgMEVMavRo2Tu": 0.00001 }
 * @example
 * { "base58-public-address": valueInWholeCoin }
 */

/**
 * An object returned from `coinselect` that contains information about selected inputs, outputs, and the fee.
 * @typedef {Object} SelectedInputOutput
 * @property {Array<TXInput>} inputs - An Array of Transaction Inputs
 * @property {Array<TXOutput>} outputs - An Array of Transaction Outputs
 * @property {number} fee - The Calculated Fee to pay
 */

/**
 * A Transaction Input
 * @typedef {Object} TXInput
 * @property {string} address - Base58 Public Address
 * @property {string} txId - Parent Transaction ID
 * @property {number} vout - Index of output in Parent Transaction
 * @property {string} scriptPubKey - Script Public Key Hash
 * @property {number} value - Balance of the input in Satoshis
 * @example
 * {
 *   address: 'F8P6nUvDfcHikqdUnoQaGPBVxoMcUSpGDp',
 *   txId: '7687e361f00998f96b29938bf5b7d9003a15ec182c13b6ddbd5adc0f993cbf9c',
 *   vout: 1,
 *   scriptPubKey: '76a9141bfcff1731caf3a16225d3e78735ddc229e4fc6c88ac',
 *   value: 100000
 * }
 */

/**
 * A Transaction Output
 * @typedef {Object} TXOutput
 * @property {string} address - Base58 Public Address
 * @property {number} value - Amount to send Satoshis
 * @example
 * {
 *   address: 'FHQvhgDut1rn1nvQRZ3z9QgMEVMavRo2Tu',
 *   value: 1000
 * }
 */

/**
 * Build & Send Transactions out to the network Easily using Addresses!
 */
class TransactionBuilder {
  /**
   * Create a new TransactionBuilder
   * ##### Example
   * ```
   * import * as bip32 from 'bip32'
   * import { Address, TransactionBuilder, Networks } from '@oipwg/hdmw'
   *
   * let node = bip32.fromBase58("Fprv52CvMcVNkt3jU7MjybjTNie1Bqm7T66KBueSVFW74hXH43sXMAUdmk73TENACSHhHbwm7ZnHiaW3DxtkwhsbtpNjsh4EpnFVjZVJS7oxNqw", Networks.flo.network)
   * let address = new Address(node, Networks.flo, false)
   *
   * let builder = new TransactionBuilder(Networks.flo, {
   *   from: address,
   *   to: {"FHQvhgDut1rn1nvQRZ3z9QgMEVMavRo2Tu": 0.00001},
   *   floData: "Testing oip-hdmw!"
   * })
   * ```
   * @param  {CoinInfo} coin - CoinInfo for this specific Network you want to send the Transaction on.
   * @param  {Object} [options]
   * @param  {Address|Array.<Address>} options.from - The Address(es) to send from.
   * @param  {OutputAddress|Array.<OutputAddress>} options.to - The amounts & Address(es) to send to.
   * @param  {string} [options.floData=""] - The FloData to be added to the Transaction
   * @param  {Account} [account] - An Account to get a Change Address from if needed, if undefined, change will be sent to first `from` Address.
   * @return {TransactionBuilder}
   */
  constructor (coin, options, account) {
    this.coin = coin
    this.account = account

    // Addresses we are sending from
    this.from = []
    // Addresses we want to send to & amounts
    this.to = []

    this.passedOptions = {}

    this.parseOptions(options)
  }

  /**
   * Add an Address to send from
   * @example
   * import * as bip32 from 'bip32'
   * import { Address, TransactionBuilder, Networks } from '@oipwg/hdmw'
   *
   * let node = bip32.fromBase58("Fprv52CvMcVNkt3jU7MjybjTNie1Bqm7T66KBueSVFW74hXH43sXMAUdmk73TENACSHhHbwm7ZnHiaW3DxtkwhsbtpNjsh4EpnFVjZVJS7oxNqw", Networks.flo.network)
   * let address = new Address(node, Networks.flo, false)
   *
   * let builder = new TransactionBuilder(Networks.flo)
   * builder.addFrom(address);
   * @param {Address} address - Address to add to the From Addresses
   */
  addFrom (address) {
    if (address instanceof Address) {
      if (isValidPublicAddress(address.getPublicAddress(), this.coin.network)) {
        this.from.push(address)
      }
    } else {
      throw new Error('From Address MUST BE InstanceOf Address')
    }
  }

  /**
   * Add an Address and Amount to send to
   * @example
   * import * as bip32 from 'bip32'
   * import { TransactionBuilder, Networks } from '@oipwg/hdmw'
   *
   * let builder = new TransactionBuilder(Networks.flo)
   * builder.addTo("FHQvhgDut1rn1nvQRZ3z9QgMEVMavRo2Tu", 0.001);
   * @param {string} address - Base58 Public Address to send To
   * @param {number} amount - Amount to Send (in whole coin)
   */
  addTo (address, amount) {
    if (isValidPublicAddress(address, this.coin.network) && !isNaN(amount)) {
      const tmpTo = {
        address: address,
        value: amount
      }
      this.to.push(tmpTo)
    }
  }

  /**
   * Load From & To addresses
   * @param  {Object} options
   * @param  {Address|Array.<Address>} options.from - The Address(es) to send from.
   * @param  {OutputAddress|Array.<OutputAddress>} options.to - The amounts & Address(es) to send to.
   * @param  {string} [options.floData=""] - The FloData to be added to the Transaction
   * @example
   * import * as bip32 from 'bip32'
   * import { Address, TransactionBuilder, Networks } from '@oipwg/hdmw'
   *
   * let node = bip32.fromBase58("Fprv52CvMcVNkt3jU7MjybjTNie1Bqm7T66KBueSVFW74hXH43sXMAUdmk73TENACSHhHbwm7ZnHiaW3DxtkwhsbtpNjsh4EpnFVjZVJS7oxNqw", Networks.flo.network)
   * let address = new Address(node, Networks.flo, false)
   *
   * let builder = new TransactionBuilder(Networks.flo)
   *
   * builder.parseOptions({
   *   from: address,
   *   to: {"FHQvhgDut1rn1nvQRZ3z9QgMEVMavRo2Tu": 0.00001},
   *   floData: "Testing oip-hdmw!"
   * })
   */
  parseOptions (options) {
    if (!options) { return }

    // Grab the From Addresses, it can be an array or regular.
    if (options.from) {
      if (Array.isArray(options.from)) {
        for (const addr of options.from) {
          this.addFrom(addr)
        }
      } else {
        this.addFrom(options.from)
      }
    }

    // Load who we are sending to
    if (options.to) {
      // Check if we are providing an address string and amount separately
      if (Array.isArray(options.to)) {
        for (const payTo of options.to) {
          for (const address in payTo) {
            if (!Object.prototype.hasOwnProperty.call(payTo, address)) continue
            this.addTo(address, payTo[address])
          }
        }
      } else {
        for (const address in options.to) {
          if (!Object.prototype.hasOwnProperty.call(options.to, address)) continue
          this.addTo(address, options.to[address])
        }
      }
    }

    this.passedOptions = options
  }

  /**
   * Get the Unspent Transaction Outputs for all the From addresses specified.
   * @example
   * import * as bip32 from 'bip32'
   * import { Address, TransactionBuilder, Networks } from '@oipwg/hdmw'
   *
   * let node = bip32.fromBase58("Fprv52CvMcVNkt3jU7MjybjTNie1Bqm7T66KBueSVFW74hXH43sXMAUdmk73TENACSHhHbwm7ZnHiaW3DxtkwhsbtpNjsh4EpnFVjZVJS7oxNqw", Networks.flo.network)
   * let address = new Address(node, Networks.flo, false)
   *
   * let builder = new TransactionBuilder(Networks.flo, {
   *   from: address,
   *   to: {"FHQvhgDut1rn1nvQRZ3z9QgMEVMavRo2Tu": 0.00001}
   * })
   *
   * builder.getUnspents().then((utxos) => {
   *   console.log(utxos)
   * })
   * @return {Promise<Array.<utxo>>} Returns a Promise that will resolve to an Array of unspent utxos
   */
  async getUnspents () {
    const utxos = []

    for (const addr of this.from) {
      try {
        const tmpUtxos = await addr.getUnspent()

        for (const utxo of tmpUtxos) { utxos.push(utxo) }
      } catch (e) { throw new Error('Unable to get Unspents \n' + e) }
    }

    return utxos
  }

  /**
   * Get calculated Inputs and Outputs (and Fee) for From and To Addresses
   * @param {Array.<utxo>} [manualUtxos] - Pass in utxos for the function to use. If not passed, it will call the function getUnspents()
   * @example
   * import * as bip32 from 'bip32'
   * import { Account, Address, TransactionBuilder, Networks } from '@oipwg/hdmw'
   *
   * let accountMaster = bip32.fromBase58("Fprv4xQSjQhWzrCVzvgkjam897LUV1AfxMuG8FBz5ouGAcbyiVcDYmqh7R2Fi22wjA56GQdmoU1AzfxsEmVnc5RfjGrWmAiqvfzmj4cCL3fJiiC", networks.flo.network)
   * let account = new Account(accountMaster, networks.flo, false);
   *
   * let node = bip32.fromBase58("Fprv52CvMcVNkt3jU7MjybjTNie1Bqm7T66KBueSVFW74hXH43sXMAUdmk73TENACSHhHbwm7ZnHiaW3DxtkwhsbtpNjsh4EpnFVjZVJS7oxNqw", Networks.flo.network)
   * let address = new Address(node, Networks.flo, false)
   *
   * let builder = new TransactionBuilder(Networks.flo, {
   *   from: address,
   *   to: {"FHQvhgDut1rn1nvQRZ3z9QgMEVMavRo2Tu": 0.00001}
   * }, account)
   *
   * builder.buildInputsAndOutputs().then((calculated) => {
   *   console.log(calculated.inputs)
   *   console.log(calculated.outputs)
   *   console.log(calculated.fee)
   * })
   * @return {SelectedInputOutput}
   */
  async buildInputsAndOutputs (manualUtxos) {
    try {
      await this.discoverChange()
    } catch (e) { throw new Error('Unable to Discover Change Addresses \n' + e) }

    let utxos = manualUtxos

    if (!utxos) {
      try {
        utxos = await this.getUnspents()
      } catch (e) { throw new Error('Unable to get Unspents for Addresses \n' + e) }
    }

    const formattedUtxos = utxos.map((utxo) => {
      return {
        address: utxo.address,
        txId: utxo.txid,
        vout: utxo.vout,
        scriptPubKey: utxo.scriptPubKey,
        value: utxo.satoshis,
        confirmations: utxo.confirmations
      }
    })

    const targets = this.to.map((toObj) => {
      return {
        address: toObj.address,
        value: Math.floor(toObj.value * this.coin.satPerCoin)
      }
    })

    let extraBytesLength = 0

    if (this.coin.network.hasFloData) { extraBytesLength = this.passedOptions.floData ? this.passedOptions.floData.length : 0 }

    const utxosNoUnconfirmed = formattedUtxos.filter(utx => utx.confirmations > 0)

    let selected = coinselect(utxosNoUnconfirmed, targets, Math.ceil(this.coin.feePerByte), this.coin.minFee, 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) {
      // return selected
    } else { // else, build with the regular ones
      selected = coinselect(formattedUtxos, targets, Math.ceil(this.coin.feePerByte), this.coin.minFee, extraBytesLength)
    }

    if (selected.inputs) {
      for (let i = 0; i < selected.inputs.length; i++) {
        const raw = await this.coin.explorer.getRawTransaction(selected.inputs[i].txId)
        selected.inputs[i].rawtx = raw.rawtx
      }
    }
    return selected
  }

  /**
   * Discover the used change addresses if we were passed an Account to discover from.
   * @example
   * import * as bip32 from 'bip32'
   * import { Account, Address, TransactionBuilder, Networks } from '@oipwg/hdmw'
   *
   * let accountMaster = bip32.fromBase58("Fprv4xQSjQhWzrCVzvgkjam897LUV1AfxMuG8FBz5ouGAcbyiVcDYmqh7R2Fi22wjA56GQdmoU1AzfxsEmVnc5RfjGrWmAiqvfzmj4cCL3fJiiC", networks.flo.network)
   * let account = new Account(accountMaster, networks.flo, false);
   *
   * let node = bip32.fromBase58("Fprv52CvMcVNkt3jU7MjybjTNie1Bqm7T66KBueSVFW74hXH43sXMAUdmk73TENACSHhHbwm7ZnHiaW3DxtkwhsbtpNjsh4EpnFVjZVJS7oxNqw", Networks.flo.network)
   * let address = new Address(node, Networks.flo, false)
   *
   * let builder = new TransactionBuilder(Networks.flo, {
   *   from: address,
   *   to: {"FHQvhgDut1rn1nvQRZ3z9QgMEVMavRo2Tu": 0.00001}
   * }, account)
   *
   * builder.discoverChange().then(() => {
   *   console.log("Done Discovering Change!")
   * })
   * @return {Promise}
   */
  async discoverChange () {
    if (this.account) {
      try {
        await this.account.discoverChain(1)
        return
      } catch (e) { throw new Error('Unable to Discover Chain \n' + e) }
    } else {

    }
  }

  /**
   * Build the Transaction hex for the From and To addresses
   * @param {SelectedInputOutput} [manualSelected] - Inputs and Outputs to use. If not passed, the function buildInputsAndOutputs() is run.
   * @example
   * import * as bip32 from 'bip32'
   * import { Address, TransactionBuilder, Networks } from '@oipwg/hdmw'
   *
   * let accountMaster = bip32.fromBase58("Fprv4xQSjQhWzrCVzvgkjam897LUV1AfxMuG8FBz5ouGAcbyiVcDYmqh7R2Fi22wjA56GQdmoU1AzfxsEmVnc5RfjGrWmAiqvfzmj4cCL3fJiiC", networks.flo.network)
   * let account = new Account(accountMaster, networks.flo, false);
   *
   * // F8P6nUvDfcHikqdUnoQaGPBVxoMcUSpGDp
   * let addressNode = bip32.fromBase58("Fprv52CvMcVNkt3jU7MjybjTNie1Bqm7T66KBueSVFW74hXH43sXMAUdmk73TENACSHhHbwm7ZnHiaW3DxtkwhsbtpNjsh4EpnFVjZVJS7oxNqw", networks.flo.network)
   * let address = new Address(addressNode, networks.flo, false);
   *
   * let builder = new TransactionBuilder(networks.flo, {
   *   from: address,
   *   to: {"FHQvhgDut1rn1nvQRZ3z9QgMEVMavRo2Tu": 0.00001},
   *   floData: "Testing oip-hdmw!"
   * }, account)
   *
   * builder.buildTX().then((hex) => {
   *   console.log(hex)
   * })
   * @return {Promise<string>} Returns a Promise that resolves to the calculated Transaction Hex
   */
  async buildTX (manualSelected) {
    let selected = manualSelected

    if (!selected) {
      try {
        selected = await this.buildInputsAndOutputs()
      } catch (e) {
        throw new Error('Unable to select inputs and outputs \n' + e)
      }
    }

    this.selected = selected

    const inputs = selected.inputs
    let outputs = selected.outputs

    // 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
    if (this.coin.hasFloData === true) {
      const floData = Buffer.from(this.passedOptions.floData || '')
      txb = new FloPsbt({ network: this.coin.network })
      txb.setFloData(floData)
    } else {
      txb = new bitcoin.Psbt({ network: this.coin.network })
    }

    txb.setVersion(this.coin.txVersion)

    inputs.forEach(input =>
      txb.addInput({
        hash: input.txId,
        index: input.vout,
        nonWitnessUtxo: Buffer.from(input.rawtx, 'hex')
      }))

    // Check if we are paying to ourself, if so, merge the outputs to just a single output.
    // Check if we only have one from address, and two outputs (i.e. pay to and change)
    if (this.from.length === 1 && 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.from[0].getPublicAddress() && !outputs[1].address) {
        const totalToSend = outputs[0].value + outputs[1].value
        outputs = [{
          address: this.from[0].getPublicAddress(),
          value: totalToSend
        }]
      }
    }

    outputs.forEach(output => {
      // watch out, outputs may have been added that you need to provide
      // an output address/script for
      if (!output.address) {
        // Check if we have access to an account to get the change address from
        if (this.account) {
          output.address = this.account.getNextChangeAddress().getPublicAddress()
        } else {
          // If the change is undefined, send change to the first from address
          output.address = this.from[0].getPublicAddress()
        }
      }

      txb.addOutput({ address: output.address, value: output.value })
    })

    for (const addr of this.from) {
      try {
        txb.signAllInputs(addr.getECPair())
      } catch (e) {
        // sign throws if there is no input to be signed by addr
        throw new Error('No input to be signed by addr! \n' + e)
      }
    }

    if (!txb.validateSignaturesOfAllInputs()) {
      throw new Error('Transaction input signatures do not validate')
    }

    txb.finalizeAllInputs()

    let builtHex

    try {
      const tx = txb.extractTransaction()
      builtHex = tx.toHex()
    } catch (e) {
      throw new Error('Unable to build Transaction Hex! \n' + e)
    }

    return builtHex
  }

  /**
   * Build & Send the Transaction that we have been forming
   * @param {String} [manualHex] - The hex you wish to send the tx for. If not used, the hex is grabbed from buildTX().
   * @example
   * import * as bip32 from 'bip32'
   * import { Address, TransactionBuilder, Networks } from '@oipwg/hdmw'
   *
   * let accountMaster = bip32.fromBase58("Fprv4xQSjQhWzrCVzvgkjam897LUV1AfxMuG8FBz5ouGAcbyiVcDYmqh7R2Fi22wjA56GQdmoU1AzfxsEmVnc5RfjGrWmAiqvfzmj4cCL3fJiiC", networks.flo.network)
   * let account = new Account(accountMaster, networks.flo, false);
   *
   * // F8P6nUvDfcHikqdUnoQaGPBVxoMcUSpGDp
   * let addressNode = bip32.fromBase58("Fprv52CvMcVNkt3jU7MjybjTNie1Bqm7T66KBueSVFW74hXH43sXMAUdmk73TENACSHhHbwm7ZnHiaW3DxtkwhsbtpNjsh4EpnFVjZVJS7oxNqw", networks.flo.network)
   * let address = new Address(addressNode, networks.flo, false);
   *
   * let builder = new TransactionBuilder(networks.flo, {
   *   from: address,
   *   to: {"FHQvhgDut1rn1nvQRZ3z9QgMEVMavRo2Tu": 0.00001},
   *   floData: "Testing oip-hdmw!"
   * }, account)
   *
   * builder.sendTX().then((txid) => {
   *   console.log(txid)
   * })
   * @return {Promise<string>} Returns a promise that will resolve to the success TXID
   */
  async sendTX (manualHex) {
    let hex = manualHex

    if (!hex) {
      try {
        hex = await this.buildTX()
      } catch (e) { throw new Error('Unable to build Transaction \n' + e) }
    }

    if (hex) {
      console.log('BroadcastHex: ' + hex)

      let response
      try {
        response = await this.coin.explorer.broadcastRawTransaction(hex)
      } catch (e) { throw new Error('Unable to Broadcast Transaction hex! \n' + e) }

      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
      }

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

      return txid
    } else {
      throw new Error('TransactionBuilder.buildTX() did not create any hex!')
    }
  }
}

module.exports = TransactionBuilder