From 7a7b5a959d16df44341730f3b42fd14895a80fe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Avila=20Gast=C3=B3n?= <72628438+avilagaston9@users.noreply.github.com> Date: Wed, 24 Apr 2024 15:05:23 -0300 Subject: [PATCH] feat: add stacktrace to SSZ (#1017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Tomás Grüner <47506558+MegaRedHand@users.noreply.github.com> --- lib/ssz_ex/decode.ex | 35 ++++---- lib/ssz_ex/encode.ex | 19 ++--- lib/ssz_ex/error.ex | 47 ++++++++++- lib/ssz_ex/merkleization.ex | 14 +++- lib/ssz_ex/ssz_ex.ex | 11 ++- lib/ssz_ex/utils.ex | 2 +- test/unit/ssz_ex_test.exs | 157 +++++++++++++++++++++++++++++++++--- 7 files changed, 240 insertions(+), 45 deletions(-) diff --git a/lib/ssz_ex/decode.ex b/lib/ssz_ex/decode.ex index 2dd49671f..0a4084fba 100644 --- a/lib/ssz_ex/decode.ex +++ b/lib/ssz_ex/decode.ex @@ -55,7 +55,7 @@ defmodule SszEx.Decode do {:error, %Error{ message: - "Invalid binary length while decoding uint.\nExpected size: #{size}.\nFound:#{bit_size(binary)}\n" + "Invalid binary length while decoding uint.\nExpected size: #{size}.\nFound:#{bit_size(binary)}" }} defp decode_uint(binary, size) do @@ -68,7 +68,7 @@ defmodule SszEx.Decode do {:error, %Error{ message: - "Invalid binary length while decoding bool.\nExpected size: 1.\nFound:#{byte_size(binary)}\n" + "Invalid binary length while decoding bool.\nExpected size: 1.\nFound:#{byte_size(binary)}" }} defp decode_bool("\x01"), do: {:ok, true} @@ -86,7 +86,7 @@ defmodule SszEx.Decode do do: {:error, %Error{ - message: "Invalid binary value while decoding BitList.\nEmpty binary found.\n" + message: "Invalid binary value while decoding BitList.\nEmpty binary found." }} defp decode_bitlist(bit_list, max_size) do @@ -98,14 +98,14 @@ defmodule SszEx.Decode do match?(<<_::binary-size(num_bytes - 1), 0>>, bit_list) -> {:error, %Error{ - message: "Invalid binary value while decoding BitList.\nMissing sentinel bit.\n" + message: "Invalid binary value while decoding BitList.\nMissing sentinel bit." }} len > max_size -> {:error, %Error{ message: - "Invalid binary length while decoding BitList. \nExpected max_size: #{max_size}. Found: #{len}.\n" + "Invalid binary length while decoding BitList. \nExpected max_size: #{max_size}. Found: #{len}." }} true -> @@ -125,7 +125,7 @@ defmodule SszEx.Decode do _ -> {:error, %Error{ - message: "Invalid binary length while decoding BitVector. \nExpected size: #{size}.\n" + message: "Invalid binary length while decoding BitVector. \nExpected size: #{size}." }} end end @@ -162,7 +162,7 @@ defmodule SszEx.Decode do {:error, %Error{ message: - "Invalid binary length while decoding list of #{inspect(inner_type)}.\nExpected max_size: #{max_size}.\nFound: #{byte_length}\n" + "Invalid binary length while decoding list of #{inspect(inner_type)}.\nExpected max_size: #{max_size}.\nFound: #{byte_length}" }} defp check_valid_fixed_list_size(_byte_length, _inner_type, _inner_type_size, _max_size), @@ -174,7 +174,7 @@ defmodule SszEx.Decode do {:error, %Error{ message: - "Invalid binary length while decoding vector of #{inspect(inner_type)}.\nExpected size #{inner_type_size * size} bytes.\nFound: #{byte_length}.\n" + "Invalid binary length while decoding vector of #{inspect(inner_type)}.\nExpected size #{inner_type_size * size} bytes.\nFound: #{byte_length}." }} defp check_valid_vector_size(_byte_length, _inner_type, _inner_type_size, _size), @@ -310,7 +310,7 @@ defmodule SszEx.Decode do {:error, %Error{ message: - "Invalid binary length while decoding #{module}. \nExpected #{expected_length}. \nFound #{size}.\n" + "Invalid binary length while decoding #{module}. \nExpected #{expected_length}. \nFound #{size}." }} defp check_fixed_container_size(_module, _expected_length, _size), @@ -322,7 +322,7 @@ defmodule SszEx.Decode do {:error, %Error{ message: - "First offset does not point to the first variable byte.\nExpected index: #{items_index}.\nOffset: #{offset}. " + "First offset does not point to the first variable byte.\nExpected index: #{items_index}.\nOffset: #{offset}." }} defp check_first_offset(_offsets, _items_index, _binary_size), @@ -337,14 +337,17 @@ defmodule SszEx.Decode do :ok -> size = next_offset - offset <> = rest_bytes - {:cont, {rest, [{key, decode(chunk, schema)} | acc_variable_parts]}} + + {:cont, + {rest, [{key, decode(chunk, schema) |> Error.add_trace(key)} | acc_variable_parts]}} error -> {:halt, {<<>>, [{key, error} | acc_variable_parts]}} end [{_offset, {key, schema}}], {rest_bytes, acc_variable_parts} -> - {:cont, {<<>>, [{key, decode(rest_bytes, schema)} | acc_variable_parts]}} + {:cont, + {<<>>, [{key, decode(rest_bytes, schema) |> Error.add_trace(key)} | acc_variable_parts]}} end) |> then(fn {<<>>, variable_parts} -> flatten_container_results(variable_parts) @@ -363,7 +366,9 @@ defmodule SszEx.Decode do else ssz_fixed_len = Utils.get_fixed_size(schema) <> = binary - {rest, [{key, decode(chunk, schema)} | fixed_parts], offsets, items_index + ssz_fixed_len} + + {rest, [{key, decode(chunk, schema) |> Error.add_trace(key)} | fixed_parts], offsets, + items_index + ssz_fixed_len} end end) |> then(fn {_rest_bytes, fixed_parts, offsets, items_index} -> @@ -441,7 +446,7 @@ defmodule SszEx.Decode do {:error, %Error{ message: - "Invalid binary length while decoding collection. \nInner type size: #{chunk_size} bytes. Binary length: #{byte_size(binary)} bytes.\n" + "Invalid binary length while decoding collection. \nInner type size: #{chunk_size} bytes. Binary length: #{byte_size(binary)} bytes." }} | results ] @@ -455,7 +460,7 @@ defmodule SszEx.Decode do case Enum.group_by(results, fn {_, {type, _}} -> type end, fn {key, {_, result}} -> {key, result} end) do - %{error: errors} -> {:error, errors} + %{error: [first_error | _rest]} -> {:error, first_error} summary -> {:ok, Map.get(summary, :ok, [])} end end diff --git a/lib/ssz_ex/encode.ex b/lib/ssz_ex/encode.ex index 7345cdbe8..9c63aea03 100644 --- a/lib/ssz_ex/encode.ex +++ b/lib/ssz_ex/encode.ex @@ -55,7 +55,7 @@ defmodule SszEx.Encode do {:error, %Error{ message: - "Invalid binary length while encoding list of #{inspect(inner_type)}.\nExpected max_size: #{max_size}.\nFound: #{size}\n" + "Invalid binary length while encoding list of #{inspect(inner_type)}.\nExpected max_size: #{max_size}.\nFound: #{size}" }} else list @@ -71,7 +71,7 @@ defmodule SszEx.Encode do {:error, %Error{ message: - "Invalid binary length while encoding BitList.\nExpected max_size: #{max_size}. Found: #{len}.\n" + "Invalid binary length while encoding BitList.\nExpected max_size: #{max_size}. Found: #{len}." }} else {:ok, BitList.to_bytes(bit_list)} @@ -96,7 +96,7 @@ defmodule SszEx.Encode do {:error, %Error{ message: - "Invalid binary length while encoding list of #{inspect(inner_type)}.\nExpected max_size: #{max_size}.\nFound: #{size}\n" + "Invalid binary length while encoding list of #{inspect(inner_type)}.\nExpected max_size: #{max_size}.\nFound: #{size}" }} else fixed_lengths = @bytes_per_length_offset * length(list) @@ -147,8 +147,7 @@ defmodule SszEx.Encode do Enum.reduce(variable_parts, 0, fn part, acc -> byte_size(part) + acc end), :ok <- check_length(fixed_length, variable_length), {:ok, fixed_parts} <- - replace_offsets(fixed_size_values, offsets) - |> encode_schemas() do + replace_offsets(fixed_size_values, offsets) |> encode_schemas() do (fixed_parts ++ variable_parts) |> Enum.join() |> then(&{:ok, &1}) @@ -163,23 +162,25 @@ defmodule SszEx.Encode do if Utils.variable_size?(schema) do {[:offset | acc_fixed_size_values], @bytes_per_length_offset + acc_fixed_length, - [{value, schema} | acc_variable_values]} + [{value, key, schema} | acc_variable_values]} else - {[{value, schema} | acc_fixed_size_values], + {[{value, key, schema} | acc_fixed_size_values], acc_fixed_length + Utils.get_fixed_size(schema), acc_variable_values} end end) end defp encode_schemas(tuple_values) do - Enum.map(tuple_values, fn {value, schema} -> encode(value, schema) end) + Enum.map(tuple_values, fn {value, key, schema} -> + encode(value, schema) |> Error.add_trace(key) + end) |> Utils.flatten_results() end defp calculate_offsets(variable_parts, fixed_length) do {offsets, _} = Enum.reduce(variable_parts, {[], fixed_length}, fn element, {res, acc} -> - {[{acc, {:int, 32}} | res], byte_size(element) + acc} + {[{acc, :offset, {:int, 32}} | res], byte_size(element) + acc} end) offsets diff --git a/lib/ssz_ex/error.ex b/lib/ssz_ex/error.ex index fa20dfa41..dbbc14780 100644 --- a/lib/ssz_ex/error.ex +++ b/lib/ssz_ex/error.ex @@ -3,13 +3,52 @@ defmodule SszEx.Error do Error messages for SszEx domain. """ alias SszEx.Error - defstruct [:message] - @type t :: %__MODULE__{message: String.t()} + defstruct [:message, stacktrace: []] + @type t :: %__MODULE__{message: String.t(), stacktrace: list()} - def format(%Error{message: message}) do - "#{message}" + def format(%Error{message: message, stacktrace: []}), do: "#{message}\n" + + def format(%Error{message: message, stacktrace: stacktrace}) do + formatted_stacktrace = stacktrace |> Enum.join(".") + "#{message}\nStacktrace: #{formatted_stacktrace}" + end + + def add_container(%Error{message: message, stacktrace: stacktrace}, value) + when is_struct(value) do + new_trace = + value.__struct__ |> Module.split() |> List.last() + + %Error{message: message, stacktrace: [new_trace | stacktrace]} + end + + def add_container(%Error{} = error, :bool), do: error + + def add_container(%Error{message: message, stacktrace: stacktrace}, value) + when is_atom(value) do + new_trace = + value |> Module.split() |> List.last() + + %Error{message: message, stacktrace: [new_trace | stacktrace]} end + def add_container(%Error{message: message, stacktrace: stacktrace}, new_trace) do + %Error{message: message, stacktrace: [new_trace | stacktrace]} + end + + def add_container({:error, %Error{} = error}, new_trace), + do: {:error, Error.add_container(error, new_trace)} + + def add_container(value, _module), do: value + + def add_trace(%Error{message: message, stacktrace: stacktrace}, new_trace) do + %Error{message: message, stacktrace: [new_trace | stacktrace]} + end + + def add_trace({:error, %Error{} = error}, new_trace), + do: {:error, Error.add_trace(error, new_trace)} + + def add_trace(value, _module), do: value + defimpl String.Chars, for: __MODULE__ do def to_string(error), do: Error.format(error) end diff --git a/lib/ssz_ex/merkleization.ex b/lib/ssz_ex/merkleization.ex index 777593593..c4d3f512d 100644 --- a/lib/ssz_ex/merkleization.ex +++ b/lib/ssz_ex/merkleization.ex @@ -39,6 +39,14 @@ defmodule SszEx.Merkleization do @spec hash_tree_root(binary, {:byte_vector, non_neg_integer}) :: {:ok, Types.root()} + def hash_tree_root(value, {:byte_vector, size}) when byte_size(value) != size, + do: + {:error, + %Error{ + message: + "Invalid binary length while merkleizing byte_vector.\nExpected size: #{size}.\nFound: #{byte_size(value)}" + }} + def hash_tree_root(value, {:byte_vector, _size}) do packed_chunks = pack_bytes(value) leaf_count = packed_chunks |> get_chunks_len() |> next_pow_of_two() @@ -73,7 +81,7 @@ defmodule SszEx.Merkleization do {:error, %Error{ message: - "Invalid binary length while merkleizing list of #{inspect(type)}.\nExpected max_size: #{max_size}.\nFound: #{len}\n" + "Invalid binary length while merkleizing list of #{inspect(type)}.\nExpected max_size: #{max_size}.\nFound: #{len}" }} Utils.basic_type?(type) -> @@ -91,7 +99,7 @@ defmodule SszEx.Merkleization do {:error, %Error{ message: - "Invalid binary length while merkleizing vector of #{inspect(inner_type)}.\nExpected size: #{size}.\nFound: #{length(vector)}\n" + "Invalid binary length while merkleizing vector of #{inspect(inner_type)}.\nExpected size: #{size}.\nFound: #{length(vector)}" }} def hash_tree_root(vector, {:vector, type, _size} = schema) do @@ -118,7 +126,7 @@ defmodule SszEx.Merkleization do case hash_tree_root(value, schema) do {:ok, root} -> {:cont, {:ok, acc_root <> root}} - {:error, %Error{}} = error -> {:halt, error} + {:error, %Error{} = error} -> {:halt, {:error, Error.add_trace(error, key)}} end end) diff --git a/lib/ssz_ex/ssz_ex.ex b/lib/ssz_ex/ssz_ex.ex index 05be7f40d..c2bdb7df6 100644 --- a/lib/ssz_ex/ssz_ex.ex +++ b/lib/ssz_ex/ssz_ex.ex @@ -62,12 +62,17 @@ defmodule SszEx do @spec encode(struct()) :: {:ok, binary()} | {:error, Error.t()} - def encode(%name{} = value), do: encode(value, name) + def encode(%name{} = value), do: encode(value, name) |> Error.add_container(value) @spec encode(any(), schema()) :: {:ok, binary()} | {:error, Error.t()} def encode(value, schema), do: Encode.encode(value, schema) + @spec decode(binary(), schema()) :: + {:ok, any()} | {:error, Error.t()} + def decode(value, module) when is_atom(module), + do: Decode.decode(value, module) |> Error.add_container(module) + @spec decode(binary(), schema()) :: {:ok, any()} | {:error, Error.t()} def decode(value, schema), do: Decode.decode(value, schema) @@ -78,6 +83,10 @@ defmodule SszEx do @spec hash_tree_root!(any, any) :: Types.root() def hash_tree_root!(value, schema), do: Merkleization.hash_tree_root!(value, schema) + @spec hash_tree_root(struct()) :: {:ok, Types.root()} | {:error, Error.t()} + def hash_tree_root(%name{} = value), + do: hash_tree_root(value, name) |> Error.add_container(value) + @spec hash_tree_root(any, any) :: {:ok, Types.root()} | {:error, Error.t()} def hash_tree_root(value, schema), do: Merkleization.hash_tree_root(value, schema) diff --git a/lib/ssz_ex/utils.ex b/lib/ssz_ex/utils.ex index 68b639e46..099bb7ad0 100644 --- a/lib/ssz_ex/utils.ex +++ b/lib/ssz_ex/utils.ex @@ -67,7 +67,7 @@ defmodule SszEx.Utils do def flatten_results_by(results, fun) do case Enum.group_by(results, fn {type, _} -> type end, fn {_, result} -> result end) do - %{error: errors} -> {:error, errors} + %{error: [first_error | _rest]} -> {:error, first_error} summary -> {:ok, fun.(Map.get(summary, :ok, []))} end end diff --git a/test/unit/ssz_ex_test.exs b/test/unit/ssz_ex_test.exs index 5f2a32e3c..30fafa071 100644 --- a/test/unit/ssz_ex_test.exs +++ b/test/unit/ssz_ex_test.exs @@ -1,4 +1,5 @@ defmodule Unit.SSZExTest do + alias LambdaEthereumConsensus.Utils.BitVector alias LambdaEthereumConsensus.Utils.Diff alias SszEx.Error alias SszEx.Merkleization @@ -17,6 +18,59 @@ defmodule Unit.SSZExTest do assert {:ok, deserialized} === SszEx.decode(serialized, schema) end + def build_broken_attester_slashing() do + checkpoint_source = %Types.Checkpoint{ + epoch: 3_776_037_760_046_644_755, + root: + <<29, 22, 191, 147, 188, 238, 162, 89, 147, 162, 202, 111, 169, 162, 84, 95, 194, 85, 54, + 172, 44, 74, 37, 128, 248, 21, 86, 246, 151, 54, 24, 54>> + } + + checkpoint_target = %Types.Checkpoint{ + epoch: 2_840_053_453_521_072_037, + root: + <<15, 174, 23, 120, 4, 9, 2, 116, 67, 73, 254, 53, 197, 3, 191, 166, 104, 34, 121, 2, 57, + 69, 75, 69, 254, 237, 132, 68, 254, 49, 127, 175>> + } + + attestation_data = %Types.AttestationData{ + slot: 5_057_010_135_270_197_978, + index: 6_920_931_864_607_509_210, + beacon_block_root: + <<31, 38, 101, 174, 248, 168, 116, 226, 15, 39, 218, 148, 42, 8, 80, 80, 241, 149, 162, + 32, 176, 208, 120, 120, 89, 123, 136, 115, 154, 28, 21, 174>>, + source: checkpoint_source, + target: checkpoint_target + } + + indexed_attestation_1 = %Types.IndexedAttestation{ + attesting_indices: [15_833_676_831_095_072_535, 7_978_643_446_947_046_229], + data: attestation_data, + signature: + <<46, 244, 83, 164, 182, 222, 218, 247, 8, 186, 138, 100, 5, 96, 34, 117, 134, 123, 219, + 188, 181, 11, 209, 57, 207, 24, 249, 42, 74, 27, 228, 97, 73, 46, 219, 202, 122, 149, + 135, 30, 91, 126, 180, 69, 129, 170, 147, 142, 242, 27, 233, 63, 242, 7, 144, 8, 192, + 165, 194, 220, 77, 247, 128, 107, 41, 199, 166, 59, 34, 160, 222, 114, 250, 250, 3, 130, + 145, 8, 45, 65, 13, 82, 44, 80, 30, 181, 239, 54, 152, 237, 244, 72, 231, 179, 239, 22>> + } + + broken_attestation = %Types.IndexedAttestation{ + attesting_indices: List.duplicate(0, 3000), + data: attestation_data, + signature: + <<46, 244, 83, 164, 182, 222, 218, 247, 8, 186, 138, 100, 5, 96, 34, 117, 134, 123, 219, + 188, 181, 11, 209, 57, 207, 24, 249, 42, 74, 27, 228, 97, 73, 46, 219, 202, 122, 149, + 135, 30, 91, 126, 180, 69, 129, 170, 147, 142, 242, 27, 233, 63, 242, 7, 144, 8, 192, + 165, 194, 220, 77, 247, 128, 107, 41, 199, 166, 59, 34, 160, 222, 114, 250, 250, 3, 130, + 145, 8, 45, 65, 13, 82, 44, 80, 30, 181, 239, 54, 152, 237, 244, 72, 231, 179, 239, 22>> + } + + %Types.AttesterSlashing{ + attestation_1: indexed_attestation_1, + attestation_2: broken_attestation + } + end + test "packing a list of uints" do list_1 = [1, 2, 3, 4, 5] @@ -737,24 +791,20 @@ defmodule Unit.SSZExTest do encoded_checkpoint = <<0, 0, 0>> - assert SszEx.decode(encoded_checkpoint, Checkpoint) == - {:error, - %Error{ - message: - "Invalid binary length while decoding Elixir.Types.Checkpoint. \nExpected 40. \nFound 3.\n" - }} + {:error, error} = SszEx.decode(encoded_checkpoint, Checkpoint) + + assert "#{error}" == + "Invalid binary length while decoding Elixir.Types.Checkpoint. \nExpected 40. \nFound 3.\nStacktrace: Checkpoint" end test "decode longer checkpoint" do encoded_checkpoint = <<0::size(41 * 8)>> - assert SszEx.decode(encoded_checkpoint, Checkpoint) == - {:error, - %Error{ - message: - "Invalid binary length while decoding Elixir.Types.Checkpoint. \nExpected 40. \nFound 41.\n" - }} + {:error, error} = SszEx.decode(encoded_checkpoint, Checkpoint) + + assert "#{error}" == + "Invalid binary length while decoding Elixir.Types.Checkpoint. \nExpected 40. \nFound 41.\nStacktrace: Checkpoint" end test "hash tree root of list exceeding max size" do @@ -766,4 +816,87 @@ defmodule Unit.SSZExTest do {:error, result} = SszEx.hash_tree_root(initial_list, {:list, {:int, 8}, 2}) assert error == "#{result}" end + + test "hash_tree_root with invalid logs_bloom" do + execution_payload = %ExecutionPayload{ + parent_hash: @default_hash, + fee_recipient: <<0::size(20 * 8)>>, + state_root: @default_root, + receipts_root: @default_root, + logs_bloom: <<0, 0, 0>>, + prev_randao: <<0::size(32 * 8)>>, + block_number: 0, + gas_limit: 0, + gas_used: 0, + timestamp: 0, + extra_data: <<>>, + base_fee_per_gas: 0, + block_hash: @default_hash, + transactions: [], + withdrawals: [], + blob_gas_used: 0, + excess_blob_gas: 0 + } + + {:error, error} = SszEx.hash_tree_root(execution_payload, ExecutionPayload) + + assert "#{error}" == + "Invalid binary length while merkleizing byte_vector.\nExpected size: 256.\nFound: 3\nStacktrace: logs_bloom" + end + + test "stacktrace in hash_tree_root with invalid logs_bloom" do + execution_payload = %ExecutionPayload{ + parent_hash: @default_hash, + fee_recipient: <<0::size(20 * 8)>>, + state_root: @default_root, + receipts_root: @default_root, + logs_bloom: <<0, 0, 0>>, + prev_randao: <<0::size(32 * 8)>>, + block_number: 0, + gas_limit: 0, + gas_used: 0, + timestamp: 0, + extra_data: <<>>, + base_fee_per_gas: 0, + block_hash: @default_hash, + transactions: [], + withdrawals: [], + blob_gas_used: 0, + excess_blob_gas: 0 + } + + {:error, error} = SszEx.hash_tree_root(execution_payload) + + assert "#{error}" == + "Invalid binary length while merkleizing byte_vector.\nExpected size: 256.\nFound: 3\nStacktrace: ExecutionPayload.logs_bloom" + end + + test "stacktrace in encode with invalid sync_committee_bits" do + sync_aggregate = %SyncAggregate{ + sync_committee_bits: BitVector.new(2), + sync_committee_signature: <<0::size(32 * 8)>> + } + + {:error, error} = SszEx.encode(sync_aggregate) + + assert "#{error}" == + "Invalid binary length while encoding BitVector. \nExpected: 512.\nFound: 2.\nStacktrace: SyncAggregate.sync_committee_bits" + end + + test "stacktrace encode nested container" do + attester_slashing = build_broken_attester_slashing() + + {:error, error} = SszEx.encode(attester_slashing) + + assert "#{error}" == + "Invalid binary length while encoding list of {:int, 64}.\nExpected max_size: 2048.\nFound: 3000\nStacktrace: AttesterSlashing.attestation_2.attesting_indices" + end + + test "stacktrace hash_tree_root nested container" do + attester_slashing = build_broken_attester_slashing() + {:error, error} = SszEx.hash_tree_root(attester_slashing) + + assert "Invalid binary length while merkleizing list of {:int, 64}.\nExpected max_size: 2048.\nFound: 3000\nStacktrace: AttesterSlashing.attestation_2.attesting_indices" = + "#{error}" + end end