lib/solana/tx.ex

defmodule Solana.Transaction do
  @moduledoc """
  Functions for building and encoding Solana
  [transactions](https://docs.solana.com/developing/programming-model/transactions)
  """
  require Logger
  alias Solana.{Account, CompactArray, Instruction}

  @typedoc """
  All the details needed to encode a transaction.
  """
  @type t :: %__MODULE__{
          payer: Solana.key() | nil,
          blockhash: binary | nil,
          instructions: [Instruction.t()],
          signers: [Solana.keypair()]
        }

  @typedoc """
  The possible errors encountered when encoding a transaction.
  """
  @type encoding_err ::
          :no_payer
          | :no_blockhash
          | :no_program
          | :no_instructions
          | :mismatched_signers

  defstruct [
    :payer,
    :blockhash,
    instructions: [],
    signers: []
  ]

  @doc """
  decodes a base58-encoded signature and returns it in a tuple.

  If it fails, return an error tuple.
  """
  @spec decode(encoded :: binary) :: {:ok, binary} | {:error, binary}
  def decode(encoded) when is_binary(encoded) do
    case B58.decode58(encoded) do
      {:ok, decoded} -> check(decoded)
      _ -> {:error, "invalid signature"}
    end
  end

  def decode(_), do: {:error, "invalid signature"}

  @doc """
  decodes a base58-encoded signature and returns it.

  Throws an `ArgumentError` if it fails.
  """
  @spec decode!(encoded :: binary) :: binary
  def decode!(encoded) when is_binary(encoded) do
    case decode(encoded) do
      {:ok, key} ->
        key

      {:error, _} ->
        raise ArgumentError, "invalid signature input: #{encoded}"
    end
  end

  @doc """
  Checks to see if a transaction's signature is valid.

  Returns `{:ok, signature}` if it is, and an error tuple if it isn't.
  """
  @spec check(binary) :: {:ok, binary} | {:error, :invalid_signature}
  def check(signature)
  def check(<<signature::binary-64>>), do: {:ok, signature}
  def check(_), do: {:error, :invalid_signature}

  @doc """
  Encodes a `t:Solana.Transaction.t/0` into a [binary
  format](https://docs.solana.com/developing/programming-model/transactions#anatomy-of-a-transaction)

  Returns `{:ok, encoded_transaction}` if the transaction was successfully
  encoded, or an error tuple if the encoding failed -- plus more error details
  via `Logger.error/1`.
  """
  @spec to_binary(tx :: t) :: {:ok, binary()} | {:error, encoding_err()}
  def to_binary(%__MODULE__{payer: nil}), do: {:error, :no_payer}
  def to_binary(%__MODULE__{blockhash: nil}), do: {:error, :no_blockhash}
  def to_binary(%__MODULE__{instructions: []}), do: {:error, :no_instructions}

  def to_binary(tx = %__MODULE__{instructions: ixs, signers: signers}) do
    with {:ok, ixs} <- check_instructions(List.flatten(ixs)),
         accounts = compile_accounts(ixs, tx.payer),
         true <- signers_match?(accounts, signers) do
      message = encode_message(accounts, tx.blockhash, ixs)

      signatures =
        signers
        |> reorder_signers(accounts)
        |> Enum.map(&sign(&1, message))
        |> CompactArray.to_iolist()

      {:ok, :erlang.list_to_binary([signatures, message])}
    else
      {:error, :no_program, idx} ->
        Logger.error("Missing program id on instruction at index #{idx}")
        {:error, :no_program}

      {:error, message, idx} ->
        Logger.error("error compiling instruction at index #{idx}: #{inspect(message)}")
        {:error, message}

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

  defp check_instructions(ixs) do
    ixs
    |> Enum.with_index()
    |> Enum.reduce_while({:ok, ixs}, fn
      {{:error, message}, idx}, _ -> {:halt, {:error, message, idx}}
      {%{program: nil}, idx}, _ -> {:halt, {:error, :no_program, idx}}
      _, acc -> {:cont, acc}
    end)
  end

  # https://docs.solana.com/developing/programming-model/transactions#account-addresses-format
  defp compile_accounts(ixs, payer) do
    ixs
    |> Enum.map(fn ix -> [%Account{key: ix.program} | ix.accounts] end)
    |> List.flatten()
    |> Enum.reject(&(&1.key == payer))
    |> Enum.sort_by(&{&1.signer?, &1.writable?}, &>=/2)
    |> Enum.uniq_by(& &1.key)
    |> cons(%Account{writable?: true, signer?: true, key: payer})
  end

  defp cons(list, item), do: [item | list]

  defp signers_match?(accounts, signers) do
    expected = MapSet.new(Enum.map(signers, &elem(&1, 1)))

    accounts
    |> Enum.filter(& &1.signer?)
    |> Enum.map(& &1.key)
    |> MapSet.new()
    |> MapSet.equal?(expected)
  end

  # https://docs.solana.com/developing/programming-model/transactions#message-format
  defp encode_message(accounts, blockhash, ixs) do
    [
      create_header(accounts),
      CompactArray.to_iolist(Enum.map(accounts, & &1.key)),
      blockhash,
      CompactArray.to_iolist(encode_instructions(ixs, accounts))
    ]
    |> :erlang.list_to_binary()
  end

  # https://docs.solana.com/developing/programming-model/transactions#message-header-format
  defp create_header(accounts) do
    accounts
    |> Enum.reduce(
      {0, 0, 0},
      &{
        unary(&1.signer?) + elem(&2, 0),
        unary(&1.signer? && !&1.writable?) + elem(&2, 1),
        unary(!&1.signer? && !&1.writable?) + elem(&2, 2)
      }
    )
    |> Tuple.to_list()
  end

  defp unary(result?), do: if(result?, do: 1, else: 0)

  # https://docs.solana.com/developing/programming-model/transactions#instruction-format
  defp encode_instructions(ixs, accounts) do
    idxs = index_accounts(accounts)

    Enum.map(ixs, fn ix = %Instruction{} ->
      [
        Map.get(idxs, ix.program),
        CompactArray.to_iolist(Enum.map(ix.accounts, &Map.get(idxs, &1.key))),
        CompactArray.to_iolist(ix.data)
      ]
    end)
  end

  defp reorder_signers(signers, accounts) do
    account_idxs = index_accounts(accounts)
    Enum.sort_by(signers, &Map.get(account_idxs, elem(&1, 1)))
  end

  defp index_accounts(accounts) do
    Enum.into(Enum.with_index(accounts, &{&1.key, &2}), %{})
  end

  defp sign({secret, pk}, message), do: Ed25519.signature(message, secret, pk)

  @doc """
  Parses a `t:Solana.Transaction.t/0` from data encoded in Solana's [binary
  format](https://docs.solana.com/developing/programming-model/transactions#anatomy-of-a-transaction)

  Returns `{transaction, extras}` if the transaction was successfully
  parsed, or `:error` if the provided binary could not be parsed. `extras`
  is a keyword list containing information about the encoded transaction,
  namely:

  - `:header` - the [transaction message
  header](https://docs.solana.com/developing/programming-model/transactions#message-header-format)
  - `:accounts` - an [ordered array of
  accounts](https://docs.solana.com/developing/programming-model/transactions#account-addresses-format)
  - `:signatures` - a [list of signed copies of the transaction
  message](https://docs.solana.com/developing/programming-model/transactions#signatures)
  """
  @spec parse(encoded :: binary) :: {t(), keyword} | :error
  def parse(encoded) do
    with {signatures, message, _} <- CompactArray.decode_and_split(encoded, 64),
         <<header::binary-size(3), contents::binary>> <- message,
         {account_keys, hash_and_ixs, key_count} <- CompactArray.decode_and_split(contents, 32),
         <<blockhash::binary-size(32), ix_data::binary>> <- hash_and_ixs,
         {:ok, instructions} <- extract_instructions(ix_data) do
      tx_accounts = derive_accounts(account_keys, key_count, header)
      indices = Enum.into(Enum.with_index(tx_accounts, &{&2, &1}), %{})

      {
        %__MODULE__{
          payer: tx_accounts |> List.first() |> Map.get(:key),
          blockhash: blockhash,
          instructions:
            Enum.map(instructions, fn {program, accounts, data} ->
              %Instruction{
                data: if(data == "", do: nil, else: :binary.list_to_bin(data)),
                program: Map.get(indices, program) |> Map.get(:key),
                accounts: Enum.map(accounts, &Map.get(indices, &1))
              }
            end)
        },
        [
          accounts: tx_accounts,
          header: header,
          signatures: signatures
        ]
      }
    else
      _ -> :error
    end
  end

  defp extract_instructions(data) do
    with {ix_data, ix_count} <- CompactArray.decode_and_split(data),
         {reversed_ixs, ""} <- extract_instructions(ix_data, ix_count) do
      {:ok, Enum.reverse(reversed_ixs)}
    else
      error -> error
    end
  end

  defp extract_instructions(data, count) do
    Enum.reduce_while(1..count, {[], data}, fn _, {acc, raw} ->
      case extract_instruction(raw) do
        {ix, rest} -> {:cont, {[ix | acc], rest}}
        _ -> {:halt, :error}
      end
    end)
  end

  defp extract_instruction(raw) do
    with <<program::8, rest::binary>> <- raw,
         {accounts, rest, _} <- CompactArray.decode_and_split(rest, 1),
         {data, rest, _} <- extract_instruction_data(rest) do
      {{program, Enum.map(accounts, &:binary.decode_unsigned/1), data}, rest}
    else
      _ -> :error
    end
  end

  defp extract_instruction_data(""), do: {"", "", 0}
  defp extract_instruction_data(raw), do: CompactArray.decode_and_split(raw, 1)

  defp derive_accounts(keys, total, header) do
    <<signers_count::8, signers_readonly_count::8, nonsigners_readonly_count::8>> = header
    {signers, nonsigners} = Enum.split(keys, signers_count)
    {signers_write, signers_read} = Enum.split(signers, signers_count - signers_readonly_count)

    {nonsigners_write, nonsigners_read} =
      Enum.split(nonsigners, total - signers_count - nonsigners_readonly_count)

    List.flatten([
      Enum.map(signers_write, &%Account{key: &1, writable?: true, signer?: true}),
      Enum.map(signers_read, &%Account{key: &1, signer?: true}),
      Enum.map(nonsigners_write, &%Account{key: &1, writable?: true}),
      Enum.map(nonsigners_read, &%Account{key: &1})
    ])
  end
end