Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
57b952c
feat(l1): EIP-3155 StructLog tracer (geth structLogLegacy compatible)
edg-l May 8, 2026
413d29c
refactor(test): move EIP-3155 fixtures under test/tests/levm/
edg-l May 8, 2026
3738a31
refactor(l1): use strict EIP-3155 step format instead of geth structL…
edg-l May 11, 2026
0c014c4
chore(test): add trailing newlines to EIP-3155 fixtures
edg-l May 11, 2026
d861344
refactor(test): drop EIP-3155 snapshot fixtures, keep one smoke test
edg-l May 11, 2026
d99c832
refactor(rpc): rename structLog tracer to opcodeTracer
edg-l May 11, 2026
4c9eec8
refactor: rename StructLog types to OpcodeStep (opcodeTracer)
edg-l May 11, 2026
5c71885
refactor(test): move opcodeTracer tests under test/, drop unit tests
edg-l May 11, 2026
6a887e8
chore(perf): drop opcode_tracer disabled-path microbench
edg-l May 11, 2026
278268a
chore(rpc): silence enum_variant_names on TracerType
edg-l May 11, 2026
11a1c61
refactor(l1): opcodeTracer cleanup and SLOAD/SSTORE storage-context fix
edg-l May 11, 2026
db99dc0
refactor(l1): opcodeTracer review fixes
edg-l May 12, 2026
6e3aa5a
fix(l1): opcodeTracer wrapper alignment + JUMPDEST under fused jump
edg-l May 12, 2026
42039a7
chore(localnet): bump ethereum-package, EL/CL images, mark ethrex sup…
edg-l May 12, 2026
9597683
feat(common): EIP-3155 streaming serializer for evm --json parity
edg-l May 12, 2026
60cf94d
feat(levm): streaming sink on LevmOpcodeTracer
edg-l May 12, 2026
44ae1c2
feat(ethrex-evm): scaffold crate + compute_post_state_root helper
edg-l May 12, 2026
f8c42c9
feat(levm): parameterize StackUnderflow/StackOverflow with counts
edg-l May 12, 2026
075e9dc
feat(ethrex-evm): statetest subcommand (goevmlab-compatible)
edg-l May 12, 2026
8b78fc0
fix(ethrex-evm): turn statetest fixture into a happy-path test
edg-l May 12, 2026
c516d41
fix(ethrex-evm): decode hex calldata in statetest TestTransaction.data
edg-l May 12, 2026
31a3c6b
feat(ethrex-evm): run subcommand for raw bytecode execution
edg-l May 12, 2026
cf9b27c
feat(ethrex-evm): EIP-4844 blob + EIP-7702 setcode + sender field
edg-l May 12, 2026
10c3120
refactor(ethrex-evm): drop run subcommand + JSON fixtures for scope
edg-l May 12, 2026
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
23 changes: 23 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
members = [
"benches",
"cmd/ethrex",
"cmd/ethrex-evm",
"crates/blockchain",
"crates/blockchain/dev",
"crates/common",
Expand Down Expand Up @@ -132,6 +133,8 @@ tower-http = { version = "0.6.2", features = ["cors"] }
indexmap = { version = "2.11.4" }
k256 = "0.13.4"
anyhow = "1.0.86"
regex = "1"
walkdir = "2"

rocksdb = { version = "0.24.0", default-features = false, features = [
"bindgen-runtime",
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ dev: ## 🏃 Run the ethrex client in DEV_MODE with the InMemory Engine
--dev \
--datadir memory

ETHEREUM_PACKAGE_REVISION := e4b330579580477814cfaebb004e354f7eb396f4
ETHEREUM_PACKAGE_REVISION := 71b02f6e4a57ad19629c729cb2989e7f868866d2
ETHEREUM_PACKAGE_DIR := ethereum-package

checkout-ethereum-package: ## 📦 Checkout specific Ethereum package revision
Expand Down
39 changes: 39 additions & 0 deletions cmd/ethrex-evm/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
[package]
name = "ethrex-evm"
version.workspace = true
edition.workspace = true
authors.workspace = true
documentation.workspace = true
license.workspace = true

[[bin]]
name = "ethrex-evm"
path = "src/main.rs"

[dependencies]
ethrex-blockchain = { workspace = true }
ethrex-common = { workspace = true }
ethrex-crypto = { workspace = true }
ethrex-storage = { workspace = true }
ethrex-vm = { workspace = true }
ethrex-levm = { workspace = true }

bytes.workspace = true
clap = { workspace = true }
serde = { workspace = true }
serde_json.workspace = true
hex.workspace = true
eyre.workspace = true
rustc-hash.workspace = true
regex.workspace = true
walkdir.workspace = true
secp256k1 = { workspace = true }
# add_initial_state is async, so we need a tokio runtime
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }

[dev-dependencies]
ethrex-common = { workspace = true }
ethrex-levm = { workspace = true }
ethrex-storage = { workspace = true }
hex.workspace = true
serde_json.workspace = true
116 changes: 116 additions & 0 deletions cmd/ethrex-evm/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# ethrex-evm

A standalone EVM CLI for the `ethrex` execution client, intended as a
drop-in differential-fuzzing target for
[`holiman/goevmlab`](https://github.com/holiman/goevmlab).

The binary exposes one subcommand: `statetest`. It accepts the exact
invocation goevmlab's `evms/geth.go` adapter uses, reads
GeneralStateTest JSON, runs each `(fork, subtest)` through LEVM, and
streams an EIP-3155 trace plus a `{"stateRoot": "0x..."}` terminator
on stderr.

## Build

```bash
cargo build -p ethrex-evm --bin ethrex-evm --release
# binary at target/release/ethrex-evm
```

## Usage

```bash
ethrex-evm statetest --trace --trace.format=json \
--trace.nomemory=true --trace.noreturndata=true \
path/to/StateTest.json
```

Stdin batch mode (one path per line, EOF or blank line terminates):

```bash
echo "path/to/test1.json
path/to/test2.json" | ethrex-evm statetest --trace --trace.format=json
```

### Flags

| Flag | Default | Notes |
|---|---|---|
| `--trace` | off | Enable EIP-3155 streaming. Bare boolean. |
| `--trace.format` | `json` | Only `json` accepted; other values exit 1. |
| `--trace.nomemory` | `true` | Suppress `memory` in steps. |
| `--trace.memory` | `false` | Opt-in alias for the inverse. |
| `--trace.nostack` | `false` | Suppress `stack`. |
| `--trace.noreturndata` | `true` | Suppress `returnData`. |
| `--trace.nostorage` | `false` | Suppress storage diffs. |
| `--statetest.fork` | _all forks_ | Limit to one fork (e.g. `Prague`). |
| `--statetest.index` | _all subtests_ | Limit to one subtest by index. |
| `--run` | _match all_ | Regex applied to test names. |

### EIP-3155 line schema

Each opcode step is one `\n`-terminated JSON object:

```json
{"pc":4,"op":1,"gas":"0x2540be3fa","gasCost":"0x3","memSize":0,"stack":["0x1","0x1"],"depth":1,"refund":0,"opName":"ADD"}
```

| Field | Encoding | Notes |
|---|---|---|
| `pc` | number | Program counter, decimal |
| `op` | number | Raw byte value (e.g. `96` for PUSH1) |
| `opName` | string | Mnemonic; fallback `"opcode 0xNN not defined"` |
| `gas` | hex string | Gas remaining before opcode |
| `gasCost` | hex string | Charged for this opcode |
| `memSize` | number | Bytes |
| `stack` | array of hex strings | Bottom-first; omitted when `--trace.nostack=true` |
| `memory` | hex string | Single contiguous blob; omitted unless enabled |
| `returnData` | hex string | Omitted unless enabled |
| `depth` | number | Call depth (1 = top) |
| `refund` | number | Refund counter |
| `error` | string | Present iff the step errored |

Summary line (after the last opcode):

```json
{"output":"<hex without 0x>","gasUsed":"0x...","error":"..."}
```

State-root terminator (after the summary):

```json
{"stateRoot": "0x<64 hex chars>"}
```

The literal colon-space in `"stateRoot": "` is required for goevmlab's
`ParseStateRoot` byte search.

## Supported transaction shapes

GeneralStateTest vectors using any of these execute end-to-end:

- Legacy / EIP-1559 / EIP-2930 (envelope unified via `EIP1559Transaction`).
- EIP-4844 blob txs (`blobVersionedHashes`, `maxFeePerBlobGas`, `currentExcessBlobGas`).
- EIP-7702 setcode txs (`authorizationList` with `v` or `yParity`).
- Vectors that ship a pre-derived `sender` field instead of `secretKey`.

## goevmlab integration

`ethrex-evm` is binary-compatible with goevmlab's invocation contract.
To register it as a fuzzing target in a goevmlab fork:

1. Build the binary: `cargo build -p ethrex-evm --bin ethrex-evm --release`.
2. Add a goevmlab `evms/ethrex.go` adapter modeled after `evms/geth.go`,
pointing at the binary path.
3. Run goevmlab's state-fuzzer — it will diff `ethrex-evm`'s output
against the other configured clients.

The upstream goevmlab adapter PR is tracked separately; this repo only
ships the binary.

## Future work

- `run` subcommand for raw-bytecode debugging.
- `t8n` subcommand.
- CI workflow running ethrex-evm against a goevmlab fuzz corpus nightly.
- Upstream `evms/ethrex.go` in goevmlab.
6 changes: 6 additions & 0 deletions cmd/ethrex-evm/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
pub mod statetest;

pub use statetest::StatetestArgs;
pub use statetest::state_root::{
build_generalized_db, compute_post_state_root, eoa_info, minimal_chain_config, setup_store,
};
23 changes: 23 additions & 0 deletions cmd/ethrex-evm/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
use clap::{Parser, Subcommand};

use ethrex_evm::statetest::{StatetestArgs, runner};

fn main() -> eyre::Result<()> {
let cli = Cli::parse();
match cli.command {
Command::Statetest(args) => runner::run(args),
}
}

#[derive(Parser)]
#[command(name = "ethrex-evm", about = "EVM execution tool")]
struct Cli {
#[command(subcommand)]
command: Command,
}

#[derive(Subcommand)]
enum Command {
/// Execute EF-style state tests and stream EIP-3155 traces to stderr.
Statetest(StatetestArgs),
}
92 changes: 92 additions & 0 deletions cmd/ethrex-evm/src/statetest/error_map.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//! Maps LEVM [`VMError`] variants to the error strings emitted by geth's EVM.
//!
//! Strings are taken from `go-ethereum/core/vm/errors.go`. Keeping them
//! identical allows goevmlab diff tools to match traces across implementations.

use ethrex_levm::errors::{ExceptionalHalt, TxValidationError, VMError};

/// Returns the geth-compatible error string for a LEVM [`VMError`].
///
/// For variants whose [`Display`] impl already matches the geth string exactly
/// (notably `StackUnderflow` and `StackOverflow` which were made geth-compatible
/// in Phase 4a), the Display output is used directly.
pub fn vm_error_to_geth_string(err: &VMError) -> String {
match err {
VMError::RevertOpcode => "execution reverted".to_owned(),
VMError::ExceptionalHalt(halt) => exceptional_halt_to_geth_string(halt),
VMError::TxValidation(tv) => tx_validation_to_geth_string(tv),
VMError::Internal(internal) => internal.to_string(),
}
}

/// Maps `TxValidationError` variants to the strings geth emits from
/// `core/types/transaction.go` and `core/state_transition.go`. Variants without
/// a clear geth analog fall through to LEVM Display.
fn tx_validation_to_geth_string(tv: &TxValidationError) -> String {
match tv {
TxValidationError::IntrinsicGasTooLow
| TxValidationError::IntrinsicGasBelowFloorGasCost => "intrinsic gas too low".to_owned(),
TxValidationError::NonceMismatch { actual, expected } if actual < expected => {
"nonce too low".to_owned()
}
TxValidationError::NonceMismatch { .. } => "nonce too high".to_owned(),
TxValidationError::NonceIsMax => "nonce has max value".to_owned(),
TxValidationError::InsufficientAccountFunds => {
"insufficient funds for gas * price + value".to_owned()
}
TxValidationError::InsufficientMaxFeePerGas => {
"max fee per gas less than block base fee".to_owned()
}
TxValidationError::PriorityGreaterThanMaxFeePerGas { .. } => {
"max priority fee per gas higher than max fee per gas".to_owned()
}
TxValidationError::InsufficientMaxFeePerBlobGas { .. } => {
"max fee per blob gas less than block blob gas fee".to_owned()
}
TxValidationError::Type3TxPreFork => "blob tx used before Cancun".to_owned(),
TxValidationError::Type3TxZeroBlobs => "blobless blob transaction".to_owned(),
TxValidationError::Type3TxInvalidBlobVersionedHash => "invalid versioned hash".to_owned(),
TxValidationError::Type3TxBlobCountExceeded { .. } => "too many blobs".to_owned(),
TxValidationError::Type3TxContractCreation => {
"blob transaction is a contract creation".to_owned()
}
TxValidationError::Type4TxPreFork => "setcode tx used before Prague".to_owned(),
TxValidationError::Type4TxAuthorizationListIsEmpty => {
"EIP-7702 transaction with empty auth list".to_owned()
}
TxValidationError::Type4TxContractCreation => {
"setcode tx is a contract creation".to_owned()
}
TxValidationError::InitcodeSizeExceeded { .. } => "max initcode size exceeded".to_owned(),
TxValidationError::GasAllowanceExceeded { .. } => "gas limit reached".to_owned(),
TxValidationError::SenderNotEOA(_) => "sender not an eoa".to_owned(),
// Fall through to LEVM Display for variants without a clean geth analog.
TxValidationError::GasLimitPriceProductOverflow
| TxValidationError::TxMaxGasLimitExceeded { .. } => tv.to_string(),
}
}

fn exceptional_halt_to_geth_string(halt: &ExceptionalHalt) -> String {
match halt {
// Phase 4a gave these variants a geth-compatible Display: use it directly.
ExceptionalHalt::StackUnderflow { .. } => halt.to_string(),
ExceptionalHalt::StackOverflow { .. } => halt.to_string(),

ExceptionalHalt::OutOfGas => "out of gas".to_owned(),
ExceptionalHalt::InvalidJump => "invalid jump destination".to_owned(),
ExceptionalHalt::OpcodeNotAllowedInStaticContext => "write protection".to_owned(),
ExceptionalHalt::InvalidContractPrefix => {
"invalid code: must not begin with 0xef".to_owned()
}
// geth emits "invalid opcode: OPCODE_NAME"; without opcode-name info we
// emit the shorter form which is still valid per geth's error.go.
ExceptionalHalt::InvalidOpcode => "invalid opcode".to_owned(),
ExceptionalHalt::AddressAlreadyOccupied => "contract address collision".to_owned(),
ExceptionalHalt::ContractOutputTooBig => "max code size exceeded".to_owned(),
ExceptionalHalt::OutOfBounds => "return data out of bounds".to_owned(),
ExceptionalHalt::VeryLargeNumber => "gas uint64 overflow".to_owned(),
// Precompile errors are not a top-level geth error string; fall through
// to LEVM Display which includes the precompile-specific message.
ExceptionalHalt::Precompile(_) => halt.to_string(),
}
}
Loading
Loading