lib/manic/tx.ex

defmodule Manic.TX do
  @moduledoc """
  Send transactions directly to miners and query the status of any transaction.

  By giving a transaction directly to a miner (instead of broadcasting it to the
  Bitcoin peer network), you are pushing the transaction directly to the centre
  of the network. As the miner will have already provided the correct fees to
  ensure the transaction is relayed and mined, you can confidently accept the
  transaction on a "zero confirmation" basis.

  This module allows developers to push transactions directly to miners and
  query the status of any transaction. As each payload from the Merchant API
  includes and is signed by the miner's [Miner ID](https://github.com/bitcoin-sv/minerid-reference),
  the response can be treated as a legally binding signed message backed by the
  miner's own proof of work.
  """
  alias Manic.{JSONEnvelope, Miner, Multi}


  @typedoc "Hex-encoded transaction ID."
  @type txid :: String.t


  @doc """
  Sends the given [`transaction`](`t:BSV.Tx.t/0`) directly to a [`miner`](`t:Manic.miner/0`).

  Returns the result in an `:ok` / `:error` tuple pair.

  The transaction can be passed as either a `t:BSV.Tx.t/0` or as a hex
  encoded binary.

  ## Options

  The `:as` option can be used to speficy how to recieve the fees. The accepted
  values are:

  * `:payload` - The decoded JSON [`payload`](`t:Manic.JSONEnvelope.payload/0`) **(Default)**
  * `:envelope` - The raw [`JSON envolope`](`t:Manic.JSONEnvelope.t/0`)

  ## Examples

  To push a transaction to the minder.

      iex> Manic.TX.push(miner, tx)
      {:ok, %{
        "api_version" => "0.1.0",
        "current_highest_block_hash" => "00000000000000000397a5a37c1f9b409b4b58e76fd6bcac06db1a3004cccb38",
        "current_highest_block_height" => 631603,
        "miner_id" => "03e92d3e5c3f7bd945dfbf48e7a99393b1bfb3f11f380ae30d286e7ff2aec5a270",
        "result_description" => "",
        "return_result" => "success",
        "timestamp" => "2020-04-21T14:04:39.563Z",
        "tx_second_mempool_expiry" => 0,
        "txid" => "9c8c5cf37f4ad1a82891ff647b13ec968f3ccb44af2d9deaa205b03ab70a81fa",
        "verified" => true
      }}

  Using the `:as` option to return the [`JSON envolope`](`t:Manic.JSONEnvelope.t/0`).

      iex> Manic.TX.push(miner, tx, as: :envelope)
      {:ok, %Manic.JSONEnvelope{
        encoding: "UTF-8",
        mimetype: "application/json",
        payload: "{\\"apiVersion\\":\\"0.1.0\\",\\"timestamp\\":\\"2020-04-21T14:04:39.563Z\\",\\"txid\\":\\"\\"9c8c5cf37f4ad1a82891ff647b13ec968f3ccb44af2d9deaa205b03ab70a81fa\\"\\",\\"returnResult\\":\\"success\\",\\"resultDescription\\":\\"\\",\\"minerId\\":\\"03e92d3e5c3f7bd945dfbf48e7a99393b1bfb3f11f380ae30d286e7ff2aec5a270\\",\\"currentHighestBlockHash\\":\\"00000000000000000397a5a37c1f9b409b4b58e76fd6bcac06db1a3004cccb38\\",\\"currentHighestBlockHeight\\":631603,\\"txSecondMempoolExpiry\\":0}",
        public_key: "03e92d3e5c3f7bd945dfbf48e7a99393b1bfb3f11f380ae30d286e7ff2aec5a270",
        signature: "3045022100a490e469426f34fcf62d0f095c10039cf5a1d535c042172786c364d41de65b3a0220654273ca42b5e955179d617ea8252e64ddf74657aa0caebda7372b40a0f07a53",
        verified: true
      }}

  """
  @spec push(Manic.miner | Manic.multi_miner, BSV.Tx.t | String.t, keyword) ::
    {:ok, JSONEnvelope.payload | JSONEnvelope.t} |
    {:error, Exception.t} |
    Multi.result

  def push(miner, tx, options \\ [])

  def push(%Miner{} = miner, %BSV.Tx{} = tx, options),
    do: push(miner, BSV.Tx.to_binary(tx, encoding: :hex), options)

  def push(%Miner{} = miner, tx, options) when is_binary(tx) do
    format = Keyword.get(options, :as, :payload)

    with {:ok, _tx} <- validate_tx(tx),
         {:ok, %{body: body, status: status}} when status in 200..202 <- Tesla.post(miner.client, "/mapi/tx", %{"rawtx" => tx}),
         {:ok, body} <- JSONEnvelope.verify(body),
         {:ok, payload} <- JSONEnvelope.parse_payload(body)
    do
      res = case format do
        :envelope -> body
        _ -> payload
      end
      {:ok, res}
    else
      {:ok, res} ->
        {:error, "HTTP Error: #{res.status}"}
      {:error, err} ->
        {:error, err}
    end
  end

  def push(%Multi{} = multi, tx, options) do
    multi
    |> Multi.async(__MODULE__, :push, [tx, options])
    |> Multi.yield
  end


  @doc """
  As `push/3` but returns the result or raises an exception if it fails.
  """
  @spec push!(Manic.miner | Manic.multi_miner, BSV.Tx.t | String.t, keyword) ::
    JSONEnvelope.payload | JSONEnvelope.t

  def push!(miner, tx, options \\ []) do
    case push(miner, tx, options) do
      {:ok, res} -> res
      {:error, error} -> raise error
    end
  end


  @doc """
  Query the status of a transaction by its [`txid`](`t:txid/0`), from the given
  [`miner`](`t:Manic.miner/0`).

  Returns the result in an `:ok` / `:error` tuple pair.

  ## Options

  The `:as` option can be used to speficy how to recieve the fees. The accepted
  values are:

  * `:payload` - The decoded JSON [`payload`](`t:Manic.JSONEnvelope.payload/0`) **(Default)**
  * `:envelope` - The raw [`JSON envolope`](`t:Manic.JSONEnvelope.t/0`)

  ## Examples

  To get the status of a transaction/

      iex> Manic.TX.boradcast(miner, "e4763d71925c2ac11a4de0b971164b099dbdb67221f03756fc79708d53b8800e")
      {:ok, %{
        "api_version" => "0.1.0",
        "block_hash" => "000000000000000000983dee680071d63939f4690a8a797c022eddadc88f925e",
        "block_height" => 630712,
        "confirmations" => 765,
        "miner_id" => "03e92d3e5c3f7bd945dfbf48e7a99393b1bfb3f11f380ae30d286e7ff2aec5a270",
        "result_description" => "",
        "return_result" => "success",
        "timestamp" => "2020-04-20T21:45:38.808Z",
        "tx_second_mempool_expiry" => 0,
        "verified" => true
      }}

  Using the `:as` option to return the [`JSON envolope`](`t:Manic.JSONEnvelope.t/0`).

      iex> Manic.TX.boradcast(miner, tx, as: :envelope)
      {:ok, %Manic.JSONEnvelope{
        encoding: "UTF-8",
        mimetype: "application/json",
        payload: "{\\"apiVersion\\":\\"0.1.0\\",\\"timestamp\\":\\"2020-04-20T21:45:38.808Z\\",\\"returnResult\\":\\"success\\",\\"resultDescription\\":\\"\\",\\"blockHash\\":\\"000000000000000000983dee680071d63939f4690a8a797c022eddadc88f925e\\",\\"blockHeight\\":630712,\\"confirmations\\":765,\\"minerId\\":\\"03e92d3e5c3f7bd945dfbf48e7a99393b1bfb3f11f380ae30d286e7ff2aec5a270\\",\\"txSecondMempoolExpiry\\":0}",
        public_key: "03e92d3e5c3f7bd945dfbf48e7a99393b1bfb3f11f380ae30d286e7ff2aec5a270",
        signature: "304502210092b822497cfe065136522b33b0fbec790c77f62818bd252583a615efd35697af022059c4ca7e97c90960860ed9d7b0ff4a1601cfe207b638c672c60a44027aed1f2d",
        verified: true
      }}

  """
  @spec status(Manic.miner | Manic.multi_miner, TX.txid, keyword) ::
    {:ok, JSONEnvelope.payload | JSONEnvelope.t} |
    {:error, Exception.t} |
    Multi.result

  def status(miner, txid, options \\ [])

  def status(%Miner{} = miner, txid, options) when is_binary(txid) do
    format = Keyword.get(options, :as, :payload)

    with {:ok, txid} <- validate_txid(txid),
         {:ok, %{body: body, status: status}} when status in 200..202 <- Tesla.get(miner.client, "/mapi/tx/" <> txid),
         {:ok, body} <- JSONEnvelope.verify(body),
         {:ok, payload} <- JSONEnvelope.parse_payload(body)
    do
      res = case format do
        :envelope -> body
        _ -> payload
      end
      {:ok, res}
    else
      {:ok, res} ->
        {:error, "HTTP Error: #{res.status}"}
      {:error, err} ->
        {:error, err}
    end
  end

  def status(%Multi{} = multi, txid, options) do
    multi
    |> Multi.async(__MODULE__, :status, [txid, options])
    |> Multi.yield
  end


  @doc """
  As `status/3` but returns the result or raises an exception if it fails.
  """
  @spec status!(Manic.miner | Manic.multi_miner, String.t, keyword) ::
    JSONEnvelope.payload | JSONEnvelope.t

  def status!(miner, txid, options \\ []) do
    case status(miner, txid, options) do
      {:ok, res} -> res
      {:error, error} -> raise error
    end
  end


  # Validates the given transaction binary by attempting to parse it.
  defp validate_tx(tx) when is_binary(tx) do
    try do
      {:ok, BSV.Tx.from_binary!(tx, encoding: :hex)}
    rescue
      _err -> {:error, "Not valid transaction"}
    end
  end


  # Validates the given txid binary by regex matching it.
  defp validate_txid(txid) do
    case String.match?(txid, ~r/^[a-f0-9]{64}$/i) do
      true -> {:ok, txid}
      false -> {:error, "Not valid TXID"}
    end
  end

end