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