Address.js

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

import { toBase58, isValidPublicAddress, isValidWIF } from './util'

const ECPair = bitcoin.ECPair

/**
 * [@oipwg/bitcoinjs-lib ECPair](https://github.com/bitcoinjs/@oipwg/bitcoinjs-lib/blob/master/src/ecpair.js#L16)
 * @typedef {Object} ECPair
 */

/**
 * Contains information about an Unspent Transaction Output
 * @typedef {Object} utxo
 * @property {string} address - Base58 Public Address
 * @property {string} txid - The Transaction ID
 * @property {number} vout - The Index of this specific Output in its parent Transaction
 * @property {string} scriptPubKey - The Script Public Key Hash
 * @property {number} amount - Amount (in whole Coin) of this Output
 * @property {number} satoshis - Amount in Satoshis of this Output
 * @property {number} height - The Blockheight the Parent Transaction was confirmed in
 * @property {number} confirmations - The total number of Confirmations the Parent Transaction has received
 * @example
 * {
 *   address: 'F8P6nUvDfcHikqdUnoQaGPBVxoMcUSpGDp',
 *   txid: '7687e361f00998f96b29938bf5b7d9003a15ec182c13b6ddbd5adc0f993cbf9c',
 *   vout: 1,
 *   scriptPubKey: '76a9141bfcff1731caf3a16225d3e78735ddc229e4fc6c88ac',
 *   amount: 0.001,
 *   satoshis: 100000,
 *   height: 2784696,
 *   confirmations: 6828
 * }
 */

/**
 * Contains information about an Unspent Transaction Output
 * @typedef {Object} AddressState
 * @property {string} addrStr - Base58 Public Address
 * @property {number} balanceSat - Balance of the Address in Satoshis
 * @property {number} totalReceivedSat - Total Received to the Address in Satoshis
 * @property {number} unconfirmedBalanceSat - Unconfirmed Balance of the Address in Satoshis
 * @property {Array.<string>} transactions - Array of `txids` that have been confirmed on the Network
 * @property {Array.<string>} spentTransactions - Array of `txids` that have been spent, but not yet confirmed on the Network
 * @property {number} lastUpdated - Timestamp of when the Address was last updated/synced with the Explorer
 * @example
 * {
 *   addrStr: 'F8P6nUvDfcHikqdUnoQaGPBVxoMcUSpGDp',
 *   balanceSat: 0,
 *   totalReceivedSat: 0,
 *   unconfirmedBalanceSat: 0,
 *   transactions: [],
 *   spentTransactions: [],
 *   lastUpdated: 0
 * }
 */

/**
 * Manages information about a specific Address
 */
class Address {
  /**
   * Create a new Address based on either a bip32 node, WIF Private Key, or Public Address
   * ##### Examples
   * Create Address from bip32
   * ```
   * import * as bip32 from 'bip32';
   * import { Address, Networks } from '@oipwg/hdmw';
   *
   * let node = bip32.fromBase58("Fprv52CvMcVNkt3jU7MjybjTNie1Bqm7T66KBueSVFW74hXH43sXMAUdmk73TENACSHhHbwm7ZnHiaW3DxtkwhsbtpNjsh4EpnFVjZVJS7oxNqw", Networks.flo.network)
   * let address = new Address(node, Networks.flo);
   * ```
   * Create Address from WIF
   * ```
   * import { Address, Networks } from '@oipwg/hdmw';
   *
   * let address = new Address("RAtKUeXYMEHEFkhbJuXGMEQZsqgHosnP2BLVaLWMRswWrcCNbZk5", Networks.flo);
   * ```
   * Create Address from Base58 Public Address
   * ```
   * import { Address, Networks } from '@oipwg/hdmw';
   *
   * let address = new Address("F8P6nUvDfcHikqdUnoQaGPBVxoMcUSpGDp", Networks.flo);
   * ```
   * @param  {bip32|string} address - The Public Address, Private Key (WIF), or bip32 Node that the Address is for.
   * @param  {CoinInfo} coin - CoinInfo for the specific Address
   * @param  {boolean|AddressState} [discover=false] - Either a `boolean` value for if the Address should auto-discover, or an AddressState object to load the Internal state from.
   * @return {Address}
   */
  constructor (address, coin, discover) {
    if (address.network !== undefined) {
      this.fromBIP32 = true

      if (address.address) {
        this.address = address.address
      } else if (address.index !== undefined && address.depth !== undefined) {
        this.address = address
      }

      // Make sure that the networks match and throw an error if they don't
      if (address.network.pubKeyHash !== coin.network.pubKeyHash) {
        throw new Error('Address Network and Coin Network DO NOT MATCH!!!!!')
      }
    } else {
      if (isValidPublicAddress(address, coin.network)) {
        this.fromBIP32 = false
        this.pubAddress = address
      } else if (isValidWIF(address, coin.network)) {
        this.fromBIP32 = true

        this.address = ECPair.fromWIF(address, coin.network)
      }
    }

    this.coin = coin || { satPerCoin: 1e8 }

    // Setup internal variables
    this.transactions = []

    this.balanceSat = 0
    this.totalReceivedSat = 0
    this.totalSentSat = 0
    this.unconfirmedBalanceSat = 0

    this.lastUpdated = 0

    this.spentTransactions = []

    if (discover === true) {
      // Update the state from the explorer
      this.updateState()
    } else if (discover) {
      // Load from serialized JSON
      this.deserialize(discover)
    }
  }

  /**
   * Get the Base58 sharable Public Address
   * @example
   * import { Address, Networks } from '@oipwg/hdmw';
   *
   * let address = new Address("RAtKUeXYMEHEFkhbJuXGMEQZsqgHosnP2BLVaLWMRswWrcCNbZk5", Networks.flo);
   * let pubAddr = address.getPublicAddress();
   * // pubAddr = F8P6nUvDfcHikqdUnoQaGPBVxoMcUSpGDp
   * @return {string}
   */
  getPublicAddress () {
    let publicKey

    if (this.fromBIP32 && this.address) {
      publicKey = this.address.publicKey

      if (!publicKey && this.address.getPublicKeyBuffer) { publicKey = this.address.getPublicKeyBuffer() }
    }

    return this.fromBIP32 ? toBase58(publicKey, this.coin.network.pubKeyHash) : this.pubAddress
  }

  /**
   * Get the Base58 sharable Private Address (WIF)
   * @example
   * import * as bip32 from 'bip32';
   * import { Address, Networks } from '@oipwg/hdmw';
   *
   * let node = bip32.fromBase58("Fprv52CvMcVNkt3jU7MjybjTNie1Bqm7T66KBueSVFW74hXH43sXMAUdmk73TENACSHhHbwm7ZnHiaW3DxtkwhsbtpNjsh4EpnFVjZVJS7oxNqw", Networks.flo.network)
   * let address = new Address(node, Networks.flo);
   * let wif = address.getPrivateAddress();
   * // wif = RAtKUeXYMEHEFkhbJuXGMEQZsqgHosnP2BLVaLWMRswWrcCNbZk5
   * @return {string}
   */
  getPrivateAddress () {
    return this.address ? this.address.toWIF() : undefined
  }

  /**
   * Get the internal ECPair. This is used when you need to Sign Transactions, or to access the raw public/private Buffers.
   * Please note that if you create the Address from a Public Key, you will not get back an ECPair, since we need access
   * to the Private Key in order to create/access the ECPair. When Address is created using a bip32 node or a Private Key (WIF)
   * the ECPair will exist.
   * @example
   * import * as bip32 from 'bip32';
   * import { Address, Networks } from '@oipwg/hdmw';
   *
   * let node = bip32.fromBase58("Fprv52CvMcVNkt3jU7MjybjTNie1Bqm7T66KBueSVFW74hXH43sXMAUdmk73TENACSHhHbwm7ZnHiaW3DxtkwhsbtpNjsh4EpnFVjZVJS7oxNqw", Networks.flo.network)
   * let address = new Address(node, Networks.flo);
   * let ecpair = address.getECPair();
   * @return {ECPair}
   */
  getECPair () {
    return this.address
  }

  /**
   * Get the signature of a specific message that can be verified by others
   * @param  {String} message - The message you wish to get the signature for
   * @return {String} Returns the base64 string of the created Signature
   */
  signMessage (message) {
    if (!message || typeof message !== 'string') { throw new Error('Message must be defined and a String!') }

    const privatekeyEcpair = this.getECPair()

    if (!privatekeyEcpair) { throw new Error('No Private Key available! Unable to sign message!') }

    const privateKeyBuffer = privatekeyEcpair.privateKey

    const compressed = privatekeyEcpair.compressed !== undefined ? privatekeyEcpair.compressed : true
    const messagePrefix = this.coin.network.messagePrefix

    let signatureBuffer
    try {
      signatureBuffer = bitcoinMessage.sign(message, privateKeyBuffer, compressed, messagePrefix)
    } catch (e) {
      throw new Error('Unable to create signature! \n' + e)
    }

    return signatureBuffer.toString('base64')
  }

  /**
   * Verify the signature of a given message
   * @param  {String} message   - The message you want to verify
   * @param  {String} signature - The signature of the message
   * @return {Boolean} Returns either `true` or `false` depending on if the signature and message match
   */
  verifySignature (message, signature) {
    let valid

    try {
      valid = bitcoinMessage.verify(message, this.getPublicAddress(), signature, this.coin.network.messagePrefix)
    } catch (e) {
      throw new Error('Unable to verify signature! \n' + e)
    }

    return valid
  }

  /**
   * Get the latest State for this address from the Blockchain Explorer
   * @example
   * import { Address, Networks } from '@oipwg/hdmw';
   *
   * let address = new Address("F8P6nUvDfcHikqdUnoQaGPBVxoMcUSpGDp", Networks.flo);
   * address.updateState().then((addr) => {
   *   console.log(addr.getTotalReceived())
   * })
   * @return {Promise<Address>} Returns a Promise that will resolve to the Address
   */
  async updateState () {
    let state
    try {
      state = await this.coin.explorer.getAddress(this.getPublicAddress())
    } catch (e) {
      throw new Error('Error Updating Address State for: ' + this.getPublicAddress() + '\n' + e)
    }

    return this.deserialize(state)
  }

  /**
   * Hydrate an Address from the serialized JSON, or update the state
   * @param  {AddressState} state
   * @example
   * import { Address, Networks } from '@oipwg/hdmw';
   *
   * let address = new Address("F8P6nUvDfcHikqdUnoQaGPBVxoMcUSpGDp", Networks.flo, false);
   *
   * address.deserialize({
   *   addrStr: 'F8P6nUvDfcHikqdUnoQaGPBVxoMcUSpGDp',
   *   balanceSat: 123,
   *   totalReceivedSat: 234,
   *   unconfirmedBalanceSat: 345,
   *   transactions: ['abcde'],
   *   spentTransactions: ['bcdef'],
   *   lastUpdated: 456
   * })
   *
   * let balance = address.getBalance()
   * // balance = 0.00000123
   * @return {Address}
   */
  deserialize (state) {
    if (!state) { return }

    // If the state doesn't match for this address, ignore it.
    if (state.addrStr && state.addrStr !== this.getPublicAddress()) { return }

    if (!isNaN(state.balanceSat)) { this.balanceSat = state.balanceSat }

    if (!isNaN(state.totalReceivedSat)) { this.totalReceivedSat = state.totalReceivedSat }

    if (!isNaN(state.totalSentSat)) { this.totalSentSat = state.totalSentSat }

    if (!isNaN(state.unconfirmedBalanceSat)) { this.unconfirmedBalanceSat = state.unconfirmedBalanceSat }

    if (Array.isArray(state.transactions)) {
      this.transactions = state.transactions
    }

    if (Array.isArray(state.spentTransactions)) {
      for (const tx of state.spentTransactions) {
        this.spentTransactions.push(tx)
      }
    }

    if (!isNaN(state.lastUpdated)) { this.lastUpdated = state.lastUpdated } else { this.lastUpdated = Date.now() }

    return this
  }

  /**
   * Get a serialized version of the Address (dried out JSON)
   * @example
   * import { Address, Networks } from '@oipwg/hdmw';
   *
   * let address = new Address("F8P6nUvDfcHikqdUnoQaGPBVxoMcUSpGDp", Networks.flo, false);
   *
   * let addressState = address.serialize()
   * // addressState = {
   * //   addrStr: 'F8P6nUvDfcHikqdUnoQaGPBVxoMcUSpGDp',
   * //   balanceSat: 0,
   * //   totalReceivedSat: 0,
   * //   unconfirmedBalanceSat: 0,
   * //   transactions: [],
   * //   spentTransactions: [],
   * //   lastUpdated: 0
   * // }
   * @return {AddressState}
   */
  serialize () {
    return {
      addrStr: this.getPublicAddress(),
      wif: this.getPrivateAddress(),
      balanceSat: this.balanceSat,
      totalReceivedSat: this.totalReceivedSat,
      unconfirmedBalanceSat: this.unconfirmedBalanceSat,
      transactions: this.transactions,
      spentTransactions: this.spentTransactions,
      lastUpdated: this.lastUpdated
    }
  }

  /**
   * Get the Balance (in whole coins) for the Address
   * @example
   * import { Address, Networks } from '@oipwg/hdmw';
   *
   * let address = new Address("F8P6nUvDfcHikqdUnoQaGPBVxoMcUSpGDp", Networks.flo, false);
   * let balance = address.getBalance();
   * // balance = 0
   * @return {number}
   */
  getBalance () {
    return this.balanceSat / this.coin.satPerCoin
  }

  /**
   * Get the Total Received balance (in whole coins) for the Address
   * @example
   * import { Address, Networks } from '@oipwg/hdmw';
   *
   * let address = new Address("F8P6nUvDfcHikqdUnoQaGPBVxoMcUSpGDp", Networks.flo, false);
   * let totReceived = address.getTotalReceived();
   * // totReceived = 0
   * @return {number}
   */
  getTotalReceived () {
    return this.totalReceivedSat / this.coin.satPerCoin
  }

  /**
   * Get the Total Sent balance (in whole coins) for the Address
   * @example
   * import { Address, Networks } from '@oipwg/hdmw';
   *
   * let address = new Address("F8P6nUvDfcHikqdUnoQaGPBVxoMcUSpGDp", Networks.flo, false);
   * let totSent = address.getTotalSent();
   * // totSent = 0
   * @return {number}
   */
  getTotalSent () {
    return this.totalSentSat / this.coin.satPerCoin
  }

  /**
   * Get the Unconfirmed Balance (in whole coins) for the Address
   * @example
   * import { Address, Networks } from '@oipwg/hdmw';
   *
   * let address = new Address("F8P6nUvDfcHikqdUnoQaGPBVxoMcUSpGDp", Networks.flo, false);
   * let uBal = address.getUnconfirmedBalance();
   * // uBal = 0
   * @return {number}
   */
  getUnconfirmedBalance () {
    return this.unconfirmedBalanceSat / this.coin.satPerCoin
  }

  /**
   * Get the unspent transaction outputs for the Address
   * @example
   * import { Address, Networks } from '@oipwg/hdmw';
   *
   * let address = new Address("F8P6nUvDfcHikqdUnoQaGPBVxoMcUSpGDp", Networks.flo, false);
   * address.getUnspent().then((utxos) => {
   *   console.log(utxos);
   * })
   * @return {Promise<Array.<utxo>>} Returns a Promise that resolves to an Array of utxos.
   */
  getUnspent () {
    return this.coin.explorer.getAddressUtxo(this.getPublicAddress()).then((utxos) => {
      return this.removeSpent(utxos)
    })
  }

  /**
   * Remove the already spent outputs from the array we are given.
   * @param  {Array.<utxo>} unspentTransactions - An Array containing utxos to sort through
   * @example
   * import { Address, Networks } from '@oipwg/hdmw';
   *
   * let address = new Address("F8P6nUvDfcHikqdUnoQaGPBVxoMcUSpGDp", Networks.flo, false);
   * address.getUnspent().then((utxos) => {
   *   let unspentUtxos = address.removeSpent(utxos)
   *   console.log(unspentUtxos)
   * })
   * @return {Array.<utxo>}
   */
  removeSpent (unspentTransactions) {
    // If we are not defined, or we are not an array, just return
    if (!unspentTransactions || !Array.isArray(unspentTransactions)) { return }

    const unspent = []

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

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

    // @ToDo: Check if some spentTransactions txids are missing from the unspentTransactions array
    // If the txid is missing from the unspentTransactions array, then remove it from the spentTransactions array
    // This clears out any spentTransactions that have been confirmed as no longer unspent.

    return unspent
  }

  /**
   * Add a TXID to the local Spent Transactions of the Address to prevent a specific output from being doublespent.
   * @example
   * import { Address, Networks } from '@oipwg/hdmw';
   *
   * let address = new Address("F8P6nUvDfcHikqdUnoQaGPBVxoMcUSpGDp", Networks.flo, false);
   * address.addSpentTransaction("7687e361f00998f96b29938bf5b7d9003a15ec182c13b6ddbd5adc0f993cbf9c")
   * @param {string} txid - The TXID of the spent output that we should remove
   */
  addSpentTransaction (txid) {
    this.spentTransactions.push(txid)
  }
}

module.exports = Address