From 03e1c66038e0fb98bbe4d3cdb976d496d914ba05 Mon Sep 17 00:00:00 2001 From: mw2000 Date: Sat, 25 Apr 2026 12:02:57 -0700 Subject: [PATCH] feat: add transaction receipt module with EIP-2718 encoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `EEVM.Receipt` — the post-execution summary that gets folded into a block's receipts trie. Four fields per Yellow Paper §4.3.1: `status`, `cumulative_gas_used`, `logs`, `logs_bloom`. - `new/0` / `new/1` — keyword overrides; supplying `:logs` without an explicit `:logs_bloom` derives the bloom from the logs so the two cannot drift. A caller re-hydrating from RLP can pass `:logs_bloom` directly and skip `:logs`. - `encode/2` — wire form. Default `tx_type: :legacy` produces a plain RLP list `[status, cumulative_gas, bloom, logs]`. Typed variants (`:eip2930` / `:eip1559` / `:eip4844`, or a raw integer byte) prepend the EIP-2718 type byte. Each log encodes as `[address(20B), [topic(32B)...], data]`. Co-Authored-By: Claude Opus 4.7 --- lib/eevm/receipt.ex | 153 ++++++++++++++++++++++++++++++++++++++++++ test/receipt_test.exs | 148 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 301 insertions(+) create mode 100644 lib/eevm/receipt.ex create mode 100644 test/receipt_test.exs diff --git a/lib/eevm/receipt.ex b/lib/eevm/receipt.ex new file mode 100644 index 0000000..f295e50 --- /dev/null +++ b/lib/eevm/receipt.ex @@ -0,0 +1,153 @@ +defmodule EEVM.Receipt do + @moduledoc """ + Transaction receipt — the post-execution summary that gets folded into the + block's receipts trie. + + ## EVM Concepts + + After a transaction is executed, the chain commits a receipt that captures + just enough information for clients to verify what happened without re-running + the transaction. Per the Yellow Paper §4.3.1 a receipt has four fields: + + - `status` — `1` for a successful transaction, `0` for a + reverted/exceptional one. (Pre-Byzantium this slot + held a state root; we only support the post-EIP-658 + status form.) + - `cumulative_gas_used` — total gas used in the block *up to and including* + this transaction. The trie key is the tx index, so + this lets a verifier reconstruct each tx's gas use. + - `logs_bloom` — 256-byte bloom filter over every log's address and + topics (data is not included). Light clients use + this to skip blocks that can't contain events of + interest. + - `logs` — the ordered list of LOG-opcode entries the + transaction emitted. + + ### Wire encoding (EIP-2718) + + - **Legacy** (type-0) receipts encode as a plain RLP list: + `RLP([status, cumulative_gas_used, logs_bloom, logs])`. + - **Typed** receipts (EIP-2930 / 1559 / 4844) prepend a single type byte to + the same RLP list, e.g. `<<0x02>> <> RLP([...])` for EIP-1559. + + Each log encodes as `[address, [topic, ...], data]`, with the address as a + 20-byte big-endian binary and each topic as a 32-byte big-endian binary. + + ## Elixir Learning Notes + + - `defstruct` builds an immutable record; `new/1` lets callers override any + subset of fields without re-stating defaults. + - `:logs_bloom` is *derived* from `:logs` whenever logs are supplied through + `new/1`, so the two can never drift. A caller that wants to inject a + bloom directly (e.g. when re-hydrating from RLP) can pass `:logs_bloom` + explicitly and skip `:logs`. + - `ExRLP.encode/1` accepts a list of binaries / nested lists; integers must + be encoded as their minimal big-endian representation, with `0` rendered + as the empty binary `<<>>`. We do that conversion locally so the rest of + the module sees regular integers. + """ + + alias EEVM.Bloom + + @type status :: 0 | 1 + + @type log_entry :: Bloom.log_entry() + + @type tx_type :: :legacy | :eip2930 | :eip1559 | :eip4844 | non_neg_integer() + + @type t :: %__MODULE__{ + status: status(), + cumulative_gas_used: non_neg_integer(), + logs: [log_entry()], + logs_bloom: Bloom.t() + } + + defstruct status: 1, + cumulative_gas_used: 0, + logs: [], + logs_bloom: <<0::2048>> + + @type_access_list 0x01 + @type_fee_market 0x02 + @type_blob 0x03 + + @doc "A successful, no-op receipt with empty logs and zero cumulative gas." + @spec new() :: t() + def new, do: %__MODULE__{logs_bloom: Bloom.empty()} + + @doc """ + Build a receipt, filling in unspecified fields from the defaults. + + When `:logs` is supplied (and `:logs_bloom` is not), the bloom filter is + derived from the logs so the two stay in sync. + """ + @spec new(keyword()) :: t() + def new(opts) when is_list(opts) do + base = %__MODULE__{logs_bloom: Bloom.empty()} + + {logs_provided?, logs} = + case Keyword.fetch(opts, :logs) do + {:ok, value} -> {true, value} + :error -> {false, base.logs} + end + + bloom = + case Keyword.fetch(opts, :logs_bloom) do + {:ok, value} -> value + :error -> if logs_provided?, do: Bloom.from_logs(logs), else: base.logs_bloom + end + + %__MODULE__{ + status: Keyword.get(opts, :status, base.status), + cumulative_gas_used: Keyword.get(opts, :cumulative_gas_used, base.cumulative_gas_used), + logs: logs, + logs_bloom: bloom + } + end + + @doc """ + Encode a receipt to its wire (RLP) form. + + Pass `tx_type:` to produce a typed (EIP-2718) receipt. The accepted values + are `:legacy` (default), `:eip2930`, `:eip1559`, `:eip4844`, or the raw + integer type byte. + """ + @spec encode(t(), keyword()) :: binary() + def encode(%__MODULE__{} = receipt, opts \\ []) do + payload = ExRLP.encode(rlp_fields(receipt)) + + case type_byte(Keyword.get(opts, :tx_type, :legacy)) do + nil -> payload + type_byte -> <> <> payload + end + end + + defp rlp_fields(%__MODULE__{} = receipt) do + [ + encode_integer(receipt.status), + encode_integer(receipt.cumulative_gas_used), + receipt.logs_bloom, + Enum.map(receipt.logs, &encode_log/1) + ] + end + + defp encode_log(%{address: address, topics: topics, data: data}) + when is_integer(address) and is_list(topics) and is_binary(data) do + [ + <>, + Enum.map(topics, fn topic -> <> end), + data + ] + end + + defp encode_integer(0), do: <<>> + + defp encode_integer(value) when is_integer(value) and value > 0, + do: :binary.encode_unsigned(value) + + defp type_byte(:legacy), do: nil + defp type_byte(:eip2930), do: @type_access_list + defp type_byte(:eip1559), do: @type_fee_market + defp type_byte(:eip4844), do: @type_blob + defp type_byte(byte) when is_integer(byte) and byte >= 0 and byte <= 0x7F, do: byte +end diff --git a/test/receipt_test.exs b/test/receipt_test.exs new file mode 100644 index 0000000..01f6f23 --- /dev/null +++ b/test/receipt_test.exs @@ -0,0 +1,148 @@ +defmodule EEVM.ReceiptTest do + use ExUnit.Case, async: true + + alias EEVM.{Bloom, Receipt} + + describe "new/0" do + test "returns a successful, empty receipt" do + receipt = Receipt.new() + + assert receipt.status == 1 + assert receipt.cumulative_gas_used == 0 + assert receipt.logs == [] + assert receipt.logs_bloom == Bloom.empty() + end + end + + describe "new/1" do + test "applies overrides while keeping defaults for unspecified fields" do + receipt = Receipt.new(status: 0, cumulative_gas_used: 21_000) + + assert receipt.status == 0 + assert receipt.cumulative_gas_used == 21_000 + assert receipt.logs == [] + assert receipt.logs_bloom == Bloom.empty() + end + + test "derives logs_bloom from :logs when only logs are supplied" do + logs = [ + %{address: 0xAAAA, topics: [0x1111, 0x2222], data: <<0xDE, 0xAD>>}, + %{address: 0xBBBB, topics: [0x3333], data: <<>>} + ] + + receipt = Receipt.new(logs: logs) + + assert receipt.logs == logs + assert receipt.logs_bloom == Bloom.from_logs(logs) + refute receipt.logs_bloom == Bloom.empty() + end + + test "lets callers inject a logs_bloom directly when re-hydrating" do + precomputed = Bloom.add(Bloom.empty(), <<0xCAFE::unsigned-big-160>>) + + receipt = Receipt.new(logs_bloom: precomputed) + + assert receipt.logs == [] + assert receipt.logs_bloom == precomputed + end + end + + describe "encode/2 — legacy form" do + test "produces the canonical RLP list of [status, cumulative_gas, bloom, logs]" do + receipt = Receipt.new(status: 1, cumulative_gas_used: 21_000) + + expected = + ExRLP.encode([ + # status = 1 → minimal big-endian byte + <<1>>, + # 21_000 → 0x5208 + <<0x52, 0x08>>, + Bloom.empty(), + [] + ]) + + assert Receipt.encode(receipt) == expected + # RLP list prefix for a list with this much content lives in 0xC0..0xFF. + assert :binary.first(Receipt.encode(receipt)) >= 0xC0 + end + + test "encodes a reverted (status=0) receipt with empty status byte" do + receipt = Receipt.new(status: 0, cumulative_gas_used: 50_000) + + decoded = ExRLP.decode(Receipt.encode(receipt)) + + # RLP integer 0 round-trips through ExRLP as the empty binary. + assert [<<>>, <<0xC3, 0x50>>, bloom, []] = decoded + assert byte_size(bloom) == 256 + end + + test "encodes log entries as [address(20B), [topics(32B)...], data]" do + log = %{address: 0xCAFE, topics: [0x01, 0x02], data: <<0xAA, 0xBB>>} + receipt = Receipt.new(status: 1, cumulative_gas_used: 100, logs: [log]) + + [_status, _cumulative, _bloom, [encoded_log]] = + ExRLP.decode(Receipt.encode(receipt)) + + assert encoded_log == [ + <<0xCAFE::unsigned-big-160>>, + [<<1::unsigned-big-256>>, <<2::unsigned-big-256>>], + <<0xAA, 0xBB>> + ] + end + end + + describe "encode/2 — typed (EIP-2718)" do + test "tx_type: :legacy emits no prefix byte" do + receipt = Receipt.new(status: 1) + + assert Receipt.encode(receipt, tx_type: :legacy) == Receipt.encode(receipt) + end + + test "tx_type: :eip2930 prepends 0x01" do + receipt = Receipt.new(status: 1) + encoded = Receipt.encode(receipt, tx_type: :eip2930) + + assert <<0x01, rest::binary>> = encoded + assert rest == Receipt.encode(receipt) + end + + test "tx_type: :eip1559 prepends 0x02" do + receipt = Receipt.new(status: 1) + encoded = Receipt.encode(receipt, tx_type: :eip1559) + + assert <<0x02, rest::binary>> = encoded + assert rest == Receipt.encode(receipt) + end + + test "tx_type: :eip4844 prepends 0x03" do + receipt = Receipt.new(status: 1) + encoded = Receipt.encode(receipt, tx_type: :eip4844) + + assert <<0x03, rest::binary>> = encoded + assert rest == Receipt.encode(receipt) + end + + test "accepts a raw integer tx type byte" do + receipt = Receipt.new(status: 1) + + assert <<0x02, _::binary>> = Receipt.encode(receipt, tx_type: 0x02) + end + end + + describe "logs_bloom integration" do + test "bloom contains the address and indexed topics of every log" do + logs = [ + %{address: 0x1111, topics: [0xAAA], data: <<>>}, + %{address: 0x2222, topics: [0xBBB, 0xCCC], data: <<0x01>>} + ] + + receipt = Receipt.new(logs: logs) + + assert Bloom.contains?(receipt.logs_bloom, <<0x1111::unsigned-big-160>>) + assert Bloom.contains?(receipt.logs_bloom, <<0x2222::unsigned-big-160>>) + assert Bloom.contains?(receipt.logs_bloom, <<0xAAA::unsigned-big-256>>) + assert Bloom.contains?(receipt.logs_bloom, <<0xBBB::unsigned-big-256>>) + assert Bloom.contains?(receipt.logs_bloom, <<0xCCC::unsigned-big-256>>) + end + end +end