lib/bsv/script.ex

defmodule BSV.Script do
  @moduledoc """
  Module for parsing, serialising and building Scripts.

  Script is the scripting language built into Bitcoin. Transaction outputs each
  contain a "locking script" which lock a number of satoshis. Transaction inputs
  contain an "unlocking script" which unlock the satoshis contained in a
  previous output. Both the unlocking script and previous locking script are
  concatenated in the following order:

      unlocking_script <> locking_script

  The entire script is evaluated and if it returns a truthy value, the output is
  unlocked and spent.
  """
  alias BSV.{OpCode, ScriptNum}
  import BSV.Util, only: [decode: 2, decode!: 2, encode: 2]

  defstruct chunks: [], coinbase: nil

  @typedoc "Script struct"
  @type t() :: %__MODULE__{
    chunks: list(chunk()),
    coinbase: nil | binary()
  }

  @typedoc "Script chunk"
  @type chunk() :: atom() | binary()

  @typedoc "Coinbase data"
  @type coinbase_data() :: %{
    height: integer(),
    data: binary(),
    nonce: binary()
  }

  @doc """
  Parses the given ASM encoded string into a `t:BSV.Script.t/0`.

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

  ## Examples

      iex> Script.from_asm("OP_DUP OP_HASH160 5ae866af9de106847de6111e5f1faa168b2be689 OP_EQUALVERIFY OP_CHECKSIG")
      {:ok, %Script{chunks: [
        :OP_DUP,
        :OP_HASH160,
        <<90, 232, 102, 175, 157, 225, 6, 132, 125, 230, 17, 30, 95, 31, 170, 22, 139, 43, 230, 137>>,
        :OP_EQUALVERIFY,
        :OP_CHECKSIG
      ]}}
  """
  @spec from_asm(binary()) :: {:ok, t()} | {:error, term()}
  def from_asm(data) when is_binary(data) do
    chunks = data
    |> String.split(" ")
    |> Enum.map(&parse_asm_chunk/1)

    {:ok, struct(__MODULE__, chunks: chunks)}
  rescue
    _error ->
      {:error, {:invalid_encoding, :asm}}
  end

  @doc """
  Parses the given ASM encoded string into a `t:BSV.Script.t/0`.

  As `from_asm/1` but returns the result or raises an exception.
  """
  @spec from_asm!(binary()) :: t()
  def from_asm!(data) when is_binary(data) do
    case from_asm(data) do
      {:ok, script} ->
        script
      {:error, error} ->
        raise BSV.DecodeError, error
    end
  end

  @doc """
  Parses the given binary into a `t:BSV.Script.t/0`.

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

  ## Options

  The accepted options are:

  * `:encoding` - Optionally decode the binary with either the `:base64` or `:hex` encoding scheme.

  ## Examples

      iex> Script.from_binary("76a9145ae866af9de106847de6111e5f1faa168b2be68988ac", encoding: :hex)
      {:ok, %Script{chunks: [
        :OP_DUP,
        :OP_HASH160,
        <<90, 232, 102, 175, 157, 225, 6, 132, 125, 230, 17, 30, 95, 31, 170, 22, 139, 43, 230, 137>>,
        :OP_EQUALVERIFY,
        :OP_CHECKSIG
      ]}}
  """
  @spec from_binary(binary(), keyword()) :: {:ok, t()} | {:error, term()}
  def from_binary(data, opts \\ []) when is_binary(data) do
    encoding = Keyword.get(opts, :encoding)

    with {:ok, data} <- decode(data, encoding),
         {:ok, chunks} <- parse_bytes(data)
    do
      {:ok, struct(__MODULE__, chunks: chunks)}
    end
  end

  @doc """
  Parses the given binary into a `t:BSV.Script.t/0`.

  As `from_binary/2` but returns the result or raises an exception.
  """
  @spec from_binary!(binary(), keyword()) :: t()
  def from_binary!(data, opts \\ []) when is_binary(data) do
    case from_binary(data, opts) do
      {:ok, script} ->
        script
      {:error, error} ->
        raise BSV.DecodeError, error
    end
  end

  @doc """
  Returns formatted coinbase data from the given Script.
  """
  @spec get_coinbase_data(t()) :: coinbase_data() | binary()
  def get_coinbase_data(%__MODULE__{coinbase: data}) when is_binary(data) do
    case data do
      <<n::integer, blknum::bytes-size(n), d::integer, data::bytes-size(d), nonce::binary>> ->
        %{
          block: BSV.ScriptNum.decode(blknum),
          data: data,
          nonce: nonce
        }
      data ->
        data
    end
  end

  @doc """
  Pushes a chunk into the `t:BSV.Script.t/0`.

  The chunk can be any binary value, `t:BSV.OpCode.t/0` or `t:integer/0`.
  Integer values will be encoded as a `t:BSV.ScriptNum.t/0`.

  ## Examples

      iex> %Script{}
      ...> |> Script.push(:OP_FALSE)
      ...> |> Script.push(:OP_RETURN)
      ...> |> Script.push("Hello world!")
      ...> |> Script.push(2021)
      %Script{chunks: [
        :OP_FALSE,
        :OP_RETURN,
        "Hello world!",
        <<229, 7>>
      ]}
  """
  @spec push(t(), atom() | integer() | binary()) :: t()
  def push(%__MODULE__{} = script, data) when is_atom(data) do
    opcode = OpCode.to_atom!(data)
    push_chunk(script, opcode)
  end

  def push(%__MODULE__{} = script, data) when is_binary(data),
    do: push_chunk(script, data)

  def push(%__MODULE__{} = script, data) when data in 0..16,
    do: push_chunk(script, String.to_atom("OP_#{ data }"))

  def push(%__MODULE__{} = script, data) when is_integer(data),
    do: push_chunk(script, ScriptNum.encode(data))

  @doc """
  Returns the size of the Script in bytes.

  ## Examples

      iex> Script.get_size(@p2pkh_script)
      25
  """
  @spec get_size(t()) :: non_neg_integer()
  def get_size(%__MODULE__{} = script),
    do: to_binary(script) |> byte_size()

  @doc """
  Serialises the given `t:BSV.Script.t/0` into an ASM encoded string.

  ## Examples

      iex> Script.to_asm(@p2pkh_script)
      "OP_DUP OP_HASH160 5ae866af9de106847de6111e5f1faa168b2be689 OP_EQUALVERIFY OP_CHECKSIG"
  """
  @spec to_asm(t()) :: binary()
  def to_asm(%__MODULE__{chunks: chunks}) do
    chunks
    |> Enum.map(&serialize_asm_chunk/1)
    |> Enum.join(" ")
  end

  @doc """
  Serialises the given `t:BSV.Script.t/0` into a binary.

  ## Options

  The accepted options are:

  * `:encoding` - Optionally encode the binary with either the `:base64` or `:hex` encoding scheme.

  ## Examples

      iex> Script.to_binary(@p2pkh_script, encoding: :hex)
      "76a9145ae866af9de106847de6111e5f1faa168b2be68988ac"
  """
  @spec to_binary(t(), keyword()) :: binary()
  def to_binary(script, opts \\ [])

  def to_binary(%__MODULE__{chunks: [], coinbase: data}, opts)
    when is_binary(data)
  do
    encoding = Keyword.get(opts, :encoding)
    encode(data, encoding)
  end

  def to_binary(%__MODULE__{chunks: chunks}, opts) do
    encoding = Keyword.get(opts, :encoding)

    chunks
    |> serialize_chunks()
    |> encode(encoding)
  end

  # Parses the ASM chunk into a Script chunk
  defp parse_asm_chunk(<<"OP_", _::binary>> = chunk),
    do: String.to_existing_atom(chunk)

  defp parse_asm_chunk("-1"), do: :OP_1NEGATE
  defp parse_asm_chunk("0"), do: :OP_0
  defp parse_asm_chunk(chunk), do: decode!(chunk, :hex)

  # Parses the given binary into a list of Script chunks
  defp parse_bytes(data, chunks \\ [])

  defp parse_bytes(<<>>, chunks), do: {:ok, Enum.reverse(chunks)}

  defp parse_bytes(<<size::integer, chunk::bytes-size(size), data::binary>>, chunks)
    when size > 0 and size < 76
  do
    parse_bytes(data, [chunk | chunks])
  end

  defp parse_bytes(<<76, size::integer, chunk::bytes-size(size), data::binary>>, chunks) do
    parse_bytes(data, [chunk | chunks])
  end

  defp parse_bytes(<<77, size::little-16, chunk::bytes-size(size), data::binary>>, chunks) do
    parse_bytes(data, [chunk | chunks])
  end

  defp parse_bytes(<<78, size::little-32, chunk::bytes-size(size), data::binary>>, chunks) do
    parse_bytes(data, [chunk | chunks])
  end

  defp parse_bytes(<<op::integer, data::binary>>, chunks) do
    opcode = OpCode.to_atom!(op)
    parse_bytes(data, [opcode | chunks])
  end

  # Pushes the chunk onto the script
  defp push_chunk(%__MODULE__{} = script, data),
    do: update_in(script.chunks, & Enum.concat(&1, [data]))

  # Serilises the Script chunk as an ASM chunk
  defp serialize_asm_chunk(:OP_1NEGATE), do: "-1"
  defp serialize_asm_chunk(chunk) when chunk in [:OP_0, :OP_FALSE], do: "0"
  defp serialize_asm_chunk(chunk) when is_atom(chunk), do: Atom.to_string(chunk)
  defp serialize_asm_chunk(chunk) when is_binary(chunk), do: encode(chunk, :hex)

  # Serilises the list of Script chunks as a binary
  defp serialize_chunks(chunks, data \\ <<>>)

  defp serialize_chunks([], data), do: data

  defp serialize_chunks([chunk | chunks], data) when is_atom(chunk) do
    opcode = OpCode.to_integer(chunk)
    serialize_chunks(chunks, <<data::binary, opcode::integer>>)
  end

  defp serialize_chunks([chunk | chunks], data) when is_binary(chunk) do
    suffix = case byte_size(chunk) do
      op when op > 0 and op < 76 ->
        <<op::integer, chunk::binary>>
      len when len < 0x100 ->
        <<76::integer, len::integer, chunk::binary>>
      len when len < 0x10000 ->
        <<77::integer, len::little-16, chunk::binary>>
      len when len < 0x100000000 ->
        <<78::integer, len::little-32, chunk::binary>>
      op -> << op::integer >>
    end
    serialize_chunks(chunks, data <> suffix)
  end

end