Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions lib/eevm/block/header.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
defmodule EEVM.Block.Header do
@moduledoc """
Ethereum block header — the consensus-critical metadata for a block.

## EVM Concepts

A block header is a thin descriptor of a block. It advertises the pre-state
parent pointers, the execution parameters (gas limit, base fee, randomness),
and the post-execution commitments (state / receipts / transactions roots
plus the aggregated logs bloom).

The execution client is responsible for checking that the post-execution
commitments it computes match the ones advertised here. This struct is a
pure data carrier — validation lives in `EEVM.Block.Processor`.

### Fields

| Field | Description |
|-------|-------------|
| `number` | Block number (height on the chain) |
| `parent_hash` | Keccak hash of the parent header |
| `timestamp` | Block timestamp in seconds since epoch |
| `coinbase` | Address of the block producer (fee recipient) |
| `gas_limit` | Maximum cumulative gas the block may consume |
| `base_fee_per_gas` | EIP-1559 base fee |
| `prev_randao` | RANDAO mix inherited from the beacon chain (post-Merge) |
| `parent_beacon_block_root` | EIP-4788 beacon root pointer from the parent slot |
| `state_root` | Post-execution world-state MPT root |
| `receipts_root` | MPT root of `{rlp(index) => rlp(receipt)}` |
| `transactions_root` | MPT root of `{rlp(index) => rlp(transaction)}` |
| `logs_bloom` | 256-byte bloom summarising every log in the block |

## Elixir Learning Notes

- This module defines only a struct plus tiny constructors. The field types
deliberately mirror `EEVM.Context.Block` where they overlap so callers can
move values between the "what the network says" layer (`Header`) and the
"what opcodes see" layer (`Context.Block`) without impedance.
- `new/0` and `new/1` follow the same zero-arg / keyword-override idiom used
by the rest of the codebase.
"""

@type t :: %__MODULE__{
number: non_neg_integer(),
parent_hash: binary(),
timestamp: non_neg_integer(),
coinbase: non_neg_integer(),
gas_limit: non_neg_integer(),
base_fee_per_gas: non_neg_integer(),
prev_randao: non_neg_integer(),
parent_beacon_block_root: non_neg_integer(),
state_root: binary(),
receipts_root: binary(),
transactions_root: binary(),
logs_bloom: binary()
}

defstruct number: 0,
parent_hash: <<0::256>>,
timestamp: 0,
coinbase: 0,
gas_limit: 0,
base_fee_per_gas: 0,
prev_randao: 0,
parent_beacon_block_root: 0,
state_root: <<0::256>>,
receipts_root: <<0::256>>,
transactions_root: <<0::256>>,
logs_bloom: <<0::2048>>

@doc "Returns a zero-initialised header."
@spec new() :: t()
def new, do: %__MODULE__{}

@doc """
Returns a header with the given overrides.

## Example

iex> header = EEVM.Block.Header.new(number: 18_000_000, gas_limit: 30_000_000)
iex> {header.number, header.gas_limit}
{18_000_000, 30_000_000}
"""
@spec new(keyword()) :: t()
def new(opts) when is_list(opts) do
struct!(__MODULE__, opts)
end
end
290 changes: 290 additions & 0 deletions lib/eevm/block/processor.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
defmodule EEVM.Block.Processor do
@moduledoc """
Execute a block end-to-end: system calls, then transactions in order, then
the commitments a consensus-level verifier will check.

## EVM Concepts

A block is not just a bag of transactions. The execution client must:

1. Fire any *system calls* the active hardfork mandates before user code
runs. Post-Cancun this is EIP-4788 (beacon roots) and post-Prague this
is EIP-2935 (historical block hashes).
2. Reject the block outright if the transactions, taken together, declare
more gas than the header allows.
3. Execute each transaction against the evolving state, recording a receipt
whose `cumulative_gas_used` is the running total across the block.
4. Produce the three MPT roots — `state_root`, `transactions_root`,
`receipts_root` — plus the aggregated `logs_bloom` and the block
`gas_used` figure. These are what external verifiers compare against the
header.

If any transaction fails (the injected executor returns an `{:error, _}`)
the processor halts and reports the failing index. The post-state database
is left at whatever the last *successful* transaction produced — this
processor does not attempt optimistic rollback.

## Design: dependency injection

The issues that own the real transaction executor (#83) and the real
system-call hooks (#81 EIP-2935 / #82 EIP-4788) are being developed on
parallel branches and are not yet merged. To keep this PR independently
mergeable, we do not `alias` those modules here — callers pass them in:

- `:tx_executor` — `(tx, %Context.Block{}, %Database{}) ->
{:ok, tx_result} | {:error, reason}`. Each call receives the *current*
database after all prior transactions and system calls have been
applied. The returned `tx_result` is a map with keys `:status`,
`:gas_used`, `:logs`, and `:db`.
- `:system_calls` — list of hooks, each `(%Database{}, %Context.Block{})
-> %Database{}`. Executed in order, strictly before any transaction.
The default is `[]`.
- `:tx_encoder` — `(tx) -> binary` used to compute the
`transactions_root`. Defaults to `EEVM.Transaction.Envelope.encode/1`,
which handles every typed envelope this codebase knows about. Tests
using plain maps supply their own.
- `:hardfork` — optional atom, purely informational today; the real
executor will consume it once wired in.

Once #83 / #81 / #82 land, a follow-up PR will set sensible defaults for
`:tx_executor` and supply the real system-call hooks; until then callers
inject their own.

## Elixir Learning Notes

- The transaction fold uses `Enum.reduce_while/3` so we can bail out
early on the first executor error without recursing manually.
- The receipts / transactions trie roots use `EEVM.MPT.Trie.root_hash/1`
(non-secure — keys are RLP-encoded indices, not hashes), matching the
Yellow Paper's specification for block-level tries.
- The logs-bloom aggregation reuses `EEVM.Bloom.merge/2`: a 256-byte
bitwise OR already optimised in the bloom module.
"""

alias EEVM.Block.{Header, Receipt}
alias EEVM.{Bloom, BlockResult, Database, StateRoot}
alias EEVM.Context.Block, as: BlockCtx
alias EEVM.MPT.Trie
alias EEVM.Transaction.Envelope

@type tx_result :: %{
required(:status) => 0 | 1,
required(:gas_used) => non_neg_integer(),
required(:logs) => [Bloom.log_entry()],
required(:db) => Database.t()
}

@type tx_executor :: (term(), BlockCtx.t(), Database.t() ->
{:ok, tx_result()} | {:error, term()})

@type system_call :: (Database.t(), BlockCtx.t() -> Database.t())

@type tx_encoder :: (term() -> binary())

@type process_opts :: [
tx_executor: tx_executor(),
system_calls: [system_call()],
tx_encoder: tx_encoder(),
hardfork: atom() | nil
]

@type process_error ::
:block_gas_limit_exceeded
| {:tx_failed, non_neg_integer(), term()}

@doc """
Execute every phase of the block and produce a `%EEVM.BlockResult{}`.

The caller owns `pre_state_db` and must pass an injected `:tx_executor`.
See the module doc for the full option list.

Returns:

- `{:ok, %BlockResult{}}` on success.
- `{:error, :block_gas_limit_exceeded}` when `sum(tx.gas_limit)` exceeds
`header.gas_limit`.
- `{:error, {:tx_failed, index, reason}}` when the injected executor
rejects a transaction. `index` is 0-based in `transactions` order.
"""
@spec process_block(Header.t(), [term()], Database.t(), process_opts()) ::
{:ok, BlockResult.t()} | {:error, process_error()}
def process_block(%Header{} = header, transactions, %Database{} = pre_state_db, opts)
when is_list(transactions) and is_list(opts) do
tx_executor = fetch_executor!(opts)
system_calls = Keyword.get(opts, :system_calls, [])
tx_encoder = Keyword.get(opts, :tx_encoder, &Envelope.encode/1)

block_ctx = to_block_ctx(header)

with :ok <- check_block_gas_limit(header, transactions) do
db_after_system = apply_system_calls(pre_state_db, block_ctx, system_calls)

case execute_transactions(transactions, block_ctx, db_after_system, tx_executor) do
{:ok, receipts, post_db, gas_used} ->
{:ok, build_result(post_db, receipts, transactions, gas_used, tx_encoder)}

{:error, _} = error ->
error
end
end
end

# ---------------------------------------------------------------------------
# Phase 1 — block-level gas check
# ---------------------------------------------------------------------------

@spec check_block_gas_limit(Header.t(), [term()]) :: :ok | {:error, :block_gas_limit_exceeded}
defp check_block_gas_limit(%Header{gas_limit: block_gas_limit}, transactions) do
declared = Enum.reduce(transactions, 0, fn tx, acc -> acc + tx_gas_limit(tx) end)

if declared > block_gas_limit do
{:error, :block_gas_limit_exceeded}
else
:ok
end
end

# Works for any struct or map carrying a `:gas_limit` field — that's every
# envelope struct and every plausible test double. Anything without the
# field counts as zero gas so callers aren't forced to pad stubs.
@spec tx_gas_limit(term()) :: non_neg_integer()
defp tx_gas_limit(%{gas_limit: gas_limit}) when is_integer(gas_limit) and gas_limit >= 0,
do: gas_limit

defp tx_gas_limit(_tx), do: 0

# ---------------------------------------------------------------------------
# Phase 2 — system calls (EIP-4788 / EIP-2935 etc. inject here)
# ---------------------------------------------------------------------------

@spec apply_system_calls(Database.t(), BlockCtx.t(), [system_call()]) :: Database.t()
defp apply_system_calls(db, block_ctx, system_calls) do
Enum.reduce(system_calls, db, fn hook, db_acc -> hook.(db_acc, block_ctx) end)
end

# ---------------------------------------------------------------------------
# Phase 3 — transaction fold with cumulative gas tracking
# ---------------------------------------------------------------------------

@spec execute_transactions([term()], BlockCtx.t(), Database.t(), tx_executor()) ::
{:ok, [Receipt.t()], Database.t(), non_neg_integer()}
| {:error, {:tx_failed, non_neg_integer(), term()}}
defp execute_transactions(transactions, block_ctx, db, tx_executor) do
initial = {[], db, 0, 0}

transactions
|> Enum.reduce_while(initial, fn tx, {receipts, db_acc, cumulative_gas, index} ->
case tx_executor.(tx, block_ctx, db_acc) do
{:ok, %{status: status, gas_used: gas_used, logs: logs, db: new_db}} ->
new_cumulative = cumulative_gas + gas_used

receipt = %Receipt{
status: status,
cumulative_gas_used: new_cumulative,
logs: logs,
logs_bloom: Bloom.from_logs(logs)
}

{:cont, {[receipt | receipts], new_db, new_cumulative, index + 1}}

{:error, reason} ->
{:halt, {:error, {:tx_failed, index, reason}}}
end
end)
|> case do
{:error, _} = error ->
error

{receipts, post_db, cumulative_gas, _index} ->
{:ok, Enum.reverse(receipts), post_db, cumulative_gas}
end
end

# ---------------------------------------------------------------------------
# Phase 4 — commitments
# ---------------------------------------------------------------------------

@spec build_result(
Database.t(),
[Receipt.t()],
[term()],
non_neg_integer(),
tx_encoder()
) :: BlockResult.t()
defp build_result(post_db, receipts, transactions, gas_used, tx_encoder) do
%BlockResult{
post_state_db: post_db,
receipts: receipts,
state_root: StateRoot.compute_state_root(post_db),
receipts_root: compute_receipts_root(receipts),
transactions_root: compute_transactions_root(transactions, tx_encoder),
logs_bloom: aggregate_logs_bloom(receipts),
gas_used: gas_used
}
end

@spec compute_receipts_root([Receipt.t()]) :: binary()
defp compute_receipts_root(receipts) do
receipts
|> Enum.with_index()
|> Enum.map(fn {receipt, index} -> {ExRLP.encode(index), encode_receipt(receipt)} end)
|> Trie.root_hash()
end

@spec compute_transactions_root([term()], tx_encoder()) :: binary()
defp compute_transactions_root(transactions, tx_encoder) do
transactions
|> Enum.with_index()
|> Enum.map(fn {tx, index} -> {ExRLP.encode(index), tx_encoder.(tx)} end)
|> Trie.root_hash()
end

@spec aggregate_logs_bloom([Receipt.t()]) :: Bloom.t()
defp aggregate_logs_bloom(receipts) do
Enum.reduce(receipts, Bloom.empty(), fn %Receipt{logs_bloom: bloom}, acc ->
Bloom.merge(acc, bloom)
end)
end

# Byzantium-and-later receipt layout: [status, cumulativeGasUsed, bloom, logs].
@spec encode_receipt(Receipt.t()) :: binary()
defp encode_receipt(%Receipt{} = receipt) do
encoded_logs =
Enum.map(receipt.logs, fn %{address: address, topics: topics, data: data} ->
[<<address::unsigned-big-160>>, Enum.map(topics, &<<&1::unsigned-big-256>>), data]
end)

ExRLP.encode([receipt.status, receipt.cumulative_gas_used, receipt.logs_bloom, encoded_logs])
end

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

@spec fetch_executor!(process_opts()) :: tx_executor()
defp fetch_executor!(opts) do
case Keyword.fetch(opts, :tx_executor) do
{:ok, executor} when is_function(executor, 3) ->
executor

{:ok, _other} ->
raise ArgumentError, ":tx_executor must be a 3-arity function"

:error ->
raise ArgumentError, ":tx_executor is required"
end
end

@spec to_block_ctx(Header.t()) :: BlockCtx.t()
defp to_block_ctx(%Header{} = header) do
%BlockCtx{
number: header.number,
timestamp: header.timestamp,
coinbase: header.coinbase,
gaslimit: header.gas_limit,
prevrandao: header.prev_randao,
basefee: header.base_fee_per_gas,
parent_beacon_block_root: header.parent_beacon_block_root
}
end
end
Loading
Loading