~dcrck/solana

c2a272918c37d5cbfa03af409151c232bc4b9463 — dcrck 5 months ago 819bb73
add Transaction.parse/1, improve CompactArray ergonomics

this commit adds Transaction.parse/1, which parses a Transaction
(without the signers) out of an encoded binary, i.e. one created with
Transaction.to_binary/1. This functionality was previously in place in
the TransactionTest module, so remove it there as well.

Additionally, allow CompactArray decoding to return an error if the
array is invalid, since we now expose this functionality publicly.
3 files changed, 190 insertions(+), 99 deletions(-)

M lib/solana/compact_array.ex
M lib/solana/tx.ex
M test/solana/tx_test.exs
M lib/solana/compact_array.ex => lib/solana/compact_array.ex +16 -14
@@ 23,33 23,35 @@ defmodule Solana.CompactArray do

  defp encode_bits(bits), do: band(bits, 0x7F)

  @spec decode_and_split(encoded :: binary) :: {binary, non_neg_integer}
  @spec decode_and_split(encoded :: binary) :: {binary, non_neg_integer} | :error
  def decode_and_split(""), do: :error

  def decode_and_split(encoded) do
    count = decode_length(encoded)
    count_size = compact_length_bytes(count)

    <<
      length::count_size*8,
      rest::binary
    >> = encoded

    {rest, length}
    case encoded do
      <<length::count_size*8, rest::binary>> -> {rest, length}
      _ -> :error
    end
  end

  @spec decode_and_split(encoded :: binary, item_size :: non_neg_integer) ::
          {[binary], binary, non_neg_integer}
          {[binary], binary, non_neg_integer} | :error
  def decode_and_split("", _), do: :error

  def decode_and_split(encoded, item_size) do
    count = decode_length(encoded)
    count_size = compact_length_bytes(count)
    data_size = count * item_size

    <<
      length::count_size*8,
      data::binary-size(data_size),
      rest::binary
    >> = encoded
    case encoded do
      <<length::count_size*8, data::binary-size(data_size), rest::binary>> ->
        {Solana.Helpers.chunk(data, item_size), rest, length}

    {Solana.Helpers.chunk(data, item_size), rest, length}
      _ ->
        :error
    end
  end

  def decode_length(bytes), do: decode_length(bytes, 0)

M lib/solana/tx.ex => lib/solana/tx.ex +98 -1
@@ 53,7 53,7 @@ defmodule Solana.Transaction do

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


@@ 197,4 197,101 @@ defmodule Solana.Transaction do
  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

M test/solana/tx_test.exs => test/solana/tx_test.exs +76 -84
@@ 4,80 4,7 @@ defmodule Solana.TransactionTest do
  import ExUnit.CaptureLog
  import Solana, only: [pubkey!: 1]

  alias Solana.{Transaction, Instruction, Account, CompactArray}

  defp deserialize_tx(tx) do
    {signatures, message} = extract_signatures(tx)
    {header, contents} = extract_header(message)
    {account_keys, blockhash_and_instrs, num_accounts} = extract_accounts(contents)
    {blockhash, instructions} = extract_blockhash(blockhash_and_instrs)
    instructions = extract_instructions(instructions)

    accounts = derive_accounts(account_keys, num_accounts, header)

    account_idxs = Enum.into(Enum.with_index(accounts, &{&2, &1}), %{})

    %{
      header: header,
      accounts: accounts,
      signatures: signatures,
      transaction: %Solana.Transaction{
        payer: List.first(accounts),
        blockhash: blockhash,
        instructions:
          Enum.map(instructions, fn ix ->
            %Solana.Instruction{
              data: if(ix.data == "", do: nil, else: ix.data),
              program: Map.get(account_idxs, ix.program),
              accounts: Enum.map(ix.accounts, &Map.get(account_idxs, &1))
            }
          end)
      }
    }
  end

  defp extract_signatures(tx) do
    {signatures, message, _} = CompactArray.decode_and_split(tx, 64)
    {signatures, message}
  end

  defp extract_header(message) do
    <<signers::8, signers_readonly::8, nonsigners_readonly::8, contents::binary>> = message
    {[signers, signers_readonly, nonsigners_readonly], contents}
  end

  defp extract_accounts(data), do: CompactArray.decode_and_split(data, 32)

  defp extract_blockhash(data) do
    <<blockhash::binary-size(32), rest::binary>> = data
    {blockhash, rest}
  end

  defp extract_instructions(ixs_data) do
    {ixs, length} = CompactArray.decode_and_split(ixs_data)

    Enum.map(0..length, fn _ ->
      <<program::8, ixs::binary>> = ixs
      {accounts, data, _} = CompactArray.decode_and_split(ixs, 1)
      %{program: program, accounts: Enum.map(accounts, &:binary.decode_unsigned/1), data: data}
    end)
  end

  defp derive_accounts(keys, size, header) do
    [signers_count, signers_readonly_count, nonsigners_readonly_count] = 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, size - 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
  alias Solana.{Transaction, Instruction, Account}

  describe "to_binary/1" do
    test "fails if there's no blockhash" do


@@ 181,11 108,11 @@ defmodule Solana.TransactionTest do
      }

      {:ok, tx_bin} = Transaction.to_binary(tx)
      message = deserialize_tx(tx_bin)
      {_, extras} = Transaction.parse(tx_bin)

      assert [pubkey!(payer), pubkey!(signer), pubkey!(read_only)] ==
               message
               |> Map.get(:accounts)
               extras
               |> Keyword.get(:accounts)
               |> Enum.map(& &1.key)
               |> Enum.take(3)
    end


@@ 209,9 136,9 @@ defmodule Solana.TransactionTest do
      }

      {:ok, tx_bin} = Transaction.to_binary(tx)
      message = deserialize_tx(tx_bin)
      {_, extras} = Transaction.parse(tx_bin)

      [actual_payer | _] = Map.get(message, :accounts)
      [actual_payer | _] = Keyword.get(extras, :accounts)

      assert actual_payer.key == pubkey!(payer)
      assert actual_payer.writable?


@@ 244,11 171,11 @@ defmodule Solana.TransactionTest do
      }

      {:ok, tx_bin} = Transaction.to_binary(tx)
      message = deserialize_tx(tx_bin)
      {_, extras} = Transaction.parse(tx_bin)

      # 2 signers, one read-only signer, 2 read-only non-signers (read_only and
      # program)
      assert message.header == [2, 1, 2]
      assert Keyword.get(extras, :header) == <<2, 1, 2>>
    end

    test "dedups signatures and accounts" do


@@ 273,10 200,75 @@ defmodule Solana.TransactionTest do
      }

      {:ok, tx_bin} = Transaction.to_binary(tx)
      message = deserialize_tx(tx_bin)
      {_, extras} = Transaction.parse(tx_bin)

      assert [_] = Keyword.get(extras, :signatures)
      assert length(Keyword.get(extras, :accounts)) == 3
    end
  end

  describe "parse/1" do
    test "cannot parse an empty string" do
      assert :error = Transaction.parse("")
    end

    test "cannot parse an improperly encoded transaction" do
      payer = Solana.keypair()
      signer = Solana.keypair()
      read_only = Solana.keypair()
      program = Solana.keypair() |> pubkey!()
      blockhash = Solana.keypair() |> pubkey!()

      ix = %Instruction{
        program: program,
        accounts: [
          %Account{signer?: true, key: pubkey!(read_only)},
          %Account{signer?: true, writable?: true, key: pubkey!(signer)},
          %Account{signer?: true, writable?: true, key: pubkey!(payer)}
        ]
      }

      tx = %Transaction{
        payer: pubkey!(payer),
        instructions: [ix],
        blockhash: blockhash,
        signers: [payer, signer, read_only]
      }

      {:ok, <<_::8, clipped_tx::binary>>} = Transaction.to_binary(tx)
      assert :error = Transaction.parse(clipped_tx)
    end

    test "can parse a properly encoded tranaction" do
      from = Solana.keypair()
      to = Solana.keypair()
      program = Solana.keypair() |> pubkey!()
      blockhash = Solana.keypair() |> pubkey!()

      ix = %Instruction{
        program: program,
        accounts: [
          %Account{key: pubkey!(to)},
          %Account{signer?: true, writable?: true, key: pubkey!(from)}
        ],
        data: <<1, 2, 3>>
      }

      tx = %Transaction{
        payer: pubkey!(from),
        instructions: [ix, ix],
        blockhash: blockhash,
        signers: [from]
      }

      {:ok, tx_bin} = Transaction.to_binary(tx)
      {actual, extras} = Transaction.parse(tx_bin)

      assert [_signature] = Keyword.get(extras, :signatures)

      assert [_] = message.signatures
      assert length(message.accounts) == 3
      assert actual.payer == pubkey!(from)
      assert actual.instructions == [ix, ix]
      assert actual.blockhash == blockhash
    end
  end