lib/manic/fees.ex

defmodule Manic.Fees do
  @moduledoc """
  Query dynamic fee rates from Bitcoin miners, and calculate accurate
  transaction fees.

  Miners are moving to a model where they will fix their fees in Fiat terms. In
  addition, miners will compete with each other and some may specialise in
  different types on transactions. All of this will lead to a fluid fee market
  where the rates offered by miners will differ and shift over time.

  This module allows developers to query miners directly for up to date fee rates,
  plus calculate accurate fees for any given transaction.
  """
  alias Manic.{JSONEnvelope, Miner, Multi}


  @typedoc """
  The type of transaction data any given fee applies to.

  Currently fees are broken down by `standard` and `data` types. `data` fees are
  applied to any data carrier output (`OP_RETURN`) whereas all other transaction
  data is priced at the standard rate. In future other fee types may be introduced.
  """
  @type fee_type :: :standard | :data | atom


  @typedoc """
  Fee rates broken down by [`fee types`](`t:fee_type/0`).
  """
  @type fee_rates :: %{
    optional(fee_type) => float
  }


  @typedoc """
  A simplified miner fee quote.

  The quote contains an expiry date, allowing developers to know when the quoted
  fees remain valid until. [`Fee rates`](`t:fee_rates/0`) are further broken
  down by:

  * `:mine` - Minimum threshold where a miner would be willing to mine the transaction
  * `:relay` - Minimum threshold where a miner would be willing to relay and hold a transaction in their mempool
  """
  @type fee_quote :: %{
    expires: DateTime.t,
    mine: fee_rates,
    relay: fee_rates
  }


  @doc """
  Get a [`fee quote`](`t:fee_quote/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:

  * `:fees` - The structured [`fee quote`](`t:fee_quote/0`) data **(Default)**
  * `:payload` - The decoded JSON [`payload`](`t:Manic.JSONEnvelope.payload/0`)
  * `:envelope` - The raw [`JSON envolope`](`t:Manic.JSONEnvelope.t/0`)

  ## Examples

  To get a fee quote from the given miner.

      iex> Manic.Fees.get(miner)
      {:ok, %{
        mine: %{data: 0.5, standard: 0.5},
        relay: %{data: 0.25, standard: 0.25},
        verified: true
      }}

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

      iex> Manic.Fees.get(miner, as: :envelope)
      {:ok, %Manic.JSONEnvelope{
        encoding: "UTF-8",
        mimetype: "application/json",
        payload: "{\\"apiVersion\\":\\"0.1.0\\",\\"timestamp\\":\\"2020-04-20T14:10:15.079Z\\",\\"expiryTime\\":\\"2020-04-20T14:20:15.079Z\\",\\"minerId\\":\\"03e92d3e5c3f7bd945dfbf48e7a99393b1bfb3f11f380ae30d286e7ff2aec5a270\\",\\"currentHighestBlockHash\\":\\"00000000000000000020900d959b83325068f28ff635cb541888ef16ec8ebaf7\\",\\"currentHighestBlockHeight\\":631451,\\"minerReputation\\":null,\\"fees\\":[{\\"feeType\\":\\"standard\\",\\"miningFee\\":{\\"satoshis\\":5,\\"bytes\\":10},\\"relayFee\\":{\\"satoshis\\":25,\\"bytes\\":100}},{\\"feeType\\":\\"data\\",\\"miningFee\\":{\\"satoshis\\":5,\\"bytes\\":10},\\"relayFee\\":{\\"satoshis\\":25,\\"bytes\\":100}}]}",
        public_key: "03e92d3e5c3f7bd945dfbf48e7a99393b1bfb3f11f380ae30d286e7ff2aec5a270",
        signature: "304402206fc2744bc3626e5becbc3a708760917c6f78f83a61fd557b238c613862929412022047d22f89bd6fe98ca50e819452db81318641f74544252b1f04536cc689cf5f55",
        verified: true
      }}
  """
  @spec get(Manic.miner | Manic.multi_miner, keyword) ::
    {:ok, fee_quote | JSONEnvelope.payload | JSONEnvelope.t} |
    {:error, Exception.t} |
    Multi.result

  def get(miner, options \\ [])

  def get(%Miner{} = miner, options) do
    format = Keyword.get(options, :as, :fees)

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

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


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

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


  # Builds the simplified `t:fee_quote/0` map from the given payload.
  defp build_fee_quote(%{"expiry_time" => expires, "fees" => fees, "verified" => verified})
    when is_list(fees)
  do
    {:ok, expires, _} = DateTime.from_iso8601(expires)
    fees = Enum.reduce(fees, %{expires: expires, mine: %{}, relay: %{}, verified: verified}, fn f, fees ->
      type = String.to_atom(f["fee_type"])
      %{"mining_fee" => m, "relay_fee" => r} = f

      fees
      |> Map.update!(:mine, & Map.put(&1, type, m["satoshis"] / m["bytes"]))
      |> Map.update!(:relay, & Map.put(&1, type, r["satoshis"] / r["bytes"]))
    end)
    {:ok, fees}
  end


  @doc """
  Calculates the fee of the given [`transaction`](`t:BSV.Tx.t/0`) using
  the specified [`rates`](`t:fee_rates/0`).

  Returns the fee in satoshis as an `t:integer/0`.

  If a [`miner`](`t:Manic.miner/0`) is passed as the first argument, the
  function firstly gets the [`rates`](`t:fee_rates/0`) for that miner, before
  calculating the fee for the given transaction. The transaction can be passed
  as either a `t:BSV.Tx.t/0` or as a hex encoded binary.

  ## Example

      iex> Manic.Fees.calculate(%{data: 0.5, standard: 0.5}, tx)
      346
  """
  @spec calculate(Manic.miner, BSV.Tx.t | String.t) ::
    {:ok, integer} |
    {:error, Exception.t}

  def calculate(%Miner{} = miner, tx) do
    case get(miner) do
      {:ok, fee_quote} ->
        calculate(miner, tx, fee_quote)

      {:error, error} ->
        {:error, error}
    end
  end

  @spec calculate(Manic.miner, BSV.Tx.t | String.t, fee_quote) ::
    {:ok, integer} |
    {:error, Exception.t}

  def calculate(miner, tx, fee_quote) when is_binary(tx) do
    case validate_tx(tx) do
      {:ok, tx} ->
        calculate(miner, tx, fee_quote)

      {:error, error} ->
        {:error, error}
    end
  end

  def calculate(_miner, %BSV.Tx{} = tx, fee_quote) do
    # Convert tx into txbuilder so can use the fee calc method
    builder = %BSV.TxBuilder{
      inputs: Enum.map(tx.inputs, fn %{outpoint: outpoint, script: script} ->
        utxo = %BSV.UTXO{outpoint: outpoint}
        BSV.Contract.Raw.unlock(utxo, %{script: script})
      end),
      outputs: Enum.map(tx.outputs, fn %{satoshis: satoshis, script: script} ->
        BSV.Contract.Raw.lock(satoshis, %{script: script})
      end)
    }

    try do
      {:ok, BSV.TxBuilder.calc_required_fee(builder, fee_quote)}
    rescue error ->
      {:error, error}
    end
  end


  @doc """
  As `calculate/2` but returns the result or raises an exception if it fails.
  """
  @spec calculate!(Manic.miner, BSV.Tx.t | String.t) :: integer

  def calculate!(miner, tx) do
    case calculate(miner, tx) do
      {:ok, fee} -> fee
      {:error, error} -> raise error
    end
  end

  @spec calculate!(Manic.miner, BSV.Tx.t | String.t, fee_quote) :: integer

  def calculate!(miner, tx, fee_quote) do
    case calculate(miner, tx, fee_quote) do
      {:ok, fee} -> fee
      {: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

end