diff --git a/crates/vm/levm/src/vm.rs b/crates/vm/levm/src/vm.rs
index df15dbc1d09..0548b3ee889 100644
--- a/crates/vm/levm/src/vm.rs
+++ b/crates/vm/levm/src/vm.rs
@@ -30,6 +30,7 @@ use rustc_hash::{FxHashMap, FxHashSet};
use std::{
cell::{OnceCell, RefCell},
collections::{BTreeMap, BTreeSet},
+ io::Write,
mem,
rc::Rc,
};
@@ -435,6 +436,8 @@ pub struct VM<'a> {
pub storage_original_values: FxHashMap
>,
/// Call tracer for execution tracing.
pub tracer: LevmCallTracer,
+ /// EIP-3155: When true, emit per-opcode JSON traces to stderr.
+ pub eip3155_trace: bool,
/// Debug mode for development diagnostics.
pub debug_mode: DebugMode,
/// Pool of reusable stacks to reduce allocations.
@@ -481,6 +484,7 @@ impl<'a> VM<'a> {
hooks: get_hooks(&vm_type),
storage_original_values: FxHashMap::default(),
tracer,
+ eip3155_trace: false,
debug_mode: DebugMode::disabled(),
stack_pool: Vec::new(),
vm_type,
@@ -648,6 +652,25 @@ impl<'a> VM<'a> {
loop {
let opcode = self.current_call_frame.next_opcode();
+
+ // EIP-3155: capture pre-execution state for trace output.
+ #[allow(clippy::as_conversions)]
+ let trace_snapshot = if self.eip3155_trace {
+ let pc = self.current_call_frame.pc;
+ let gas = self.current_call_frame.gas_remaining;
+ let depth = self.current_call_frame.depth + 1; // EIP-3155 depth is 1-indexed
+ let mem_size = self.current_call_frame.memory.len();
+ let stack: Vec = {
+ let s = &self.current_call_frame.stack;
+ let mut v = s.values[s.offset..].to_vec();
+ v.reverse(); // EIP-3155: bottom-to-top order
+ v
+ };
+ Some((pc, gas, depth, mem_size, stack))
+ } else {
+ None
+ };
+
self.advance_pc(1)?;
#[cfg(feature = "perf_opcode_timings")]
@@ -657,6 +680,14 @@ impl<'a> VM<'a> {
#[allow(clippy::indexing_slicing, clippy::as_conversions)]
let op_result = self.opcode_table[opcode as usize].call(self, &mut error);
+ // EIP-3155: emit trace line to stderr after opcode execution.
+ #[allow(clippy::as_conversions)]
+ if let Some((pc, gas_before, depth, mem_size, stack)) = trace_snapshot {
+ let gas_after = self.current_call_frame.gas_remaining;
+ let gas_cost = gas_before.saturating_sub(gas_after);
+ eip3155_emit(pc, opcode, gas_before, gas_cost, &stack, depth, mem_size);
+ }
+
#[cfg(feature = "perf_opcode_timings")]
{
let time = opcode_time_start.elapsed();
@@ -813,3 +844,36 @@ impl Substate {
Ok(substate)
}
}
+
+/// Emit a single EIP-3155 trace line to stderr.
+///
+/// Format: `{"pc":,"op":,"gas":"0x","gasCost":"0x","stack":[...],"depth":,"memSize":}`
+#[allow(clippy::as_conversions)]
+fn eip3155_emit(
+ pc: usize,
+ opcode: u8,
+ gas_before: i64,
+ gas_cost: i64,
+ stack: &[U256],
+ depth: usize,
+ mem_size: usize,
+) {
+ let mut stderr = std::io::stderr().lock();
+
+ // Build the JSON manually to avoid pulling in serde for the VM crate's hot path.
+ let _ = write!(
+ stderr,
+ r#"{{"pc":{},"op":{},"gas":"0x{:x}","gasCost":"0x{:x}","stack":["#,
+ pc,
+ opcode,
+ gas_before.max(0) as u64,
+ gas_cost.max(0) as u64,
+ );
+ for (i, val) in stack.iter().enumerate() {
+ if i > 0 {
+ let _ = write!(stderr, ",");
+ }
+ let _ = write!(stderr, "\"0x{:x}\"", val);
+ }
+ let _ = writeln!(stderr, r#"],"depth":{},"memSize":{}}}"#, depth, mem_size);
+}
diff --git a/tooling/Cargo.lock b/tooling/Cargo.lock
index 25b2b35537a..ed6131244d3 100644
--- a/tooling/Cargo.lock
+++ b/tooling/Cargo.lock
@@ -2899,6 +2899,41 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "ef_tests-blocktest"
+version = "4.0.0"
+dependencies = [
+ "clap 4.5.54",
+ "ef_tests-blockchain",
+ "rayon",
+ "regex",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "ef_tests-enginetest"
+version = "4.0.0"
+dependencies = [
+ "bytes",
+ "clap 4.5.54",
+ "ef_tests-blockchain",
+ "ethrex-blockchain",
+ "ethrex-common 9.0.0",
+ "ethrex-crypto",
+ "ethrex-p2p",
+ "ethrex-rlp 9.0.0",
+ "ethrex-rpc",
+ "ethrex-storage 9.0.0",
+ "ethrex-vm",
+ "hex",
+ "rayon",
+ "regex",
+ "serde",
+ "serde_json",
+ "tokio",
+]
+
[[package]]
name = "ef_tests-state"
version = "0.1.0"
@@ -2927,6 +2962,23 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "ef_tests-statetest"
+version = "4.0.0"
+dependencies = [
+ "clap 4.5.54",
+ "ethrex-common 9.0.0",
+ "ethrex-crypto",
+ "ethrex-levm",
+ "ethrex-rlp 9.0.0",
+ "rayon",
+ "regex",
+ "rustc-hash 2.1.1",
+ "secp256k1 0.30.0",
+ "serde",
+ "serde_json",
+]
+
[[package]]
name = "ef_tests-statev2"
version = "4.0.0"
diff --git a/tooling/Cargo.toml b/tooling/Cargo.toml
index 0f8a310280e..5906b6907c5 100644
--- a/tooling/Cargo.toml
+++ b/tooling/Cargo.toml
@@ -1,8 +1,11 @@
[workspace]
members = [
"archive_sync",
+ "ef_tests/blocktest",
"ef_tests/blockchain",
+ "ef_tests/enginetest",
"ef_tests/state",
+ "ef_tests/statetest",
"ef_tests/state_v2",
"hive_report",
"load_test",
diff --git a/tooling/ef_tests/blocktest/Cargo.toml b/tooling/ef_tests/blocktest/Cargo.toml
new file mode 100644
index 00000000000..b71fbf86f48
--- /dev/null
+++ b/tooling/ef_tests/blocktest/Cargo.toml
@@ -0,0 +1,24 @@
+[package]
+name = "ef_tests-blocktest"
+version.workspace = true
+edition.workspace = true
+authors.workspace = true
+documentation.workspace = true
+license.workspace = true
+
+[dependencies]
+ef_tests-blockchain = { path = "../blockchain" }
+
+serde.workspace = true
+serde_json.workspace = true
+clap.workspace = true
+rayon.workspace = true
+regex = "1"
+
+[features]
+default = ["c-kzg"]
+c-kzg = ["ef_tests-blockchain/c-kzg"]
+
+[[bin]]
+name = "ef_tests-blocktest"
+path = "src/main.rs"
diff --git a/tooling/ef_tests/blocktest/src/main.rs b/tooling/ef_tests/blocktest/src/main.rs
new file mode 100644
index 00000000000..0cbb215e69e
--- /dev/null
+++ b/tooling/ef_tests/blocktest/src/main.rs
@@ -0,0 +1,194 @@
+use std::path::PathBuf;
+use std::sync::atomic::{AtomicUsize, Ordering};
+use std::time::Instant;
+
+use clap::Parser;
+use rayon::iter::{IntoParallelIterator, ParallelIterator};
+use regex::Regex;
+use serde::Serialize;
+
+use ef_tests_blockchain::test_runner::parse_and_execute;
+
+/// Fast blockchain test runner for ethrex -- CLI binary with parallel execution.
+#[derive(Parser, Debug)]
+#[command(name = "ef_tests-blockfast")]
+struct Cli {
+ /// Path to a blockchain test directory or a single .json file.
+ #[arg(short, long, value_name = "PATH")]
+ path: PathBuf,
+
+ /// Number of parallel worker threads.
+ #[arg(short, long, default_value = "1")]
+ workers: usize,
+
+ /// Regex filter: only run tests whose file path matches this pattern.
+ #[arg(long)]
+ run: Option,
+
+ /// Output results as JSON array to stdout.
+ #[arg(long)]
+ json: bool,
+}
+
+/// Known tests to skip (same as SKIPPED_BASE in blockchain/tests/all.rs).
+const SKIPPED: &[&str] = &[
+ // Skip because they take too long to run, but they pass
+ "static_Call50000_sha256",
+ "CALLBlake2f_MaxRounds",
+ "loopMul",
+ // Skip because it tries to deserialize number > U256::MAX
+ "ValueOverflowParis",
+ // Skip because it's a "Create" Blob Transaction, which doesn't actually exist.
+ "createBlobhashTx",
+];
+
+#[derive(Serialize)]
+struct TestResult {
+ name: String,
+ pass: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ error: Option,
+}
+
+fn main() {
+ let cli = Cli::parse();
+ let start = Instant::now();
+
+ // Discover all .json fixture files.
+ eprintln!("Scanning for fixture files in {:?} ...", cli.path);
+ let mut files = if cli.path.is_file() {
+ vec![cli.path.clone()]
+ } else {
+ collect_json_files(&cli.path)
+ };
+
+ // Apply skip list: remove files whose name contains a skipped pattern.
+ files.retain(|f| {
+ let name = f.file_name().unwrap_or_default().to_string_lossy();
+ !SKIPPED.iter().any(|s| name.contains(s))
+ });
+
+ // Apply --run regex filter on the full file path.
+ if let Some(ref pattern) = cli.run {
+ let re = Regex::new(pattern).unwrap_or_else(|e| {
+ eprintln!("Invalid --run regex '{}': {}", pattern, e);
+ std::process::exit(1);
+ });
+ files.retain(|f| re.is_match(&f.to_string_lossy()));
+ }
+
+ let parse_time = start.elapsed();
+ eprintln!(
+ "Found {} fixture files in {:.2}s",
+ files.len(),
+ parse_time.as_secs_f64(),
+ );
+
+ // Build rayon thread pool.
+ let pool = rayon::ThreadPoolBuilder::new()
+ .num_threads(cli.workers)
+ .build()
+ .expect("failed to build thread pool");
+
+ let passing = AtomicUsize::new(0);
+ let failing = AtomicUsize::new(0);
+
+ let exec_start = Instant::now();
+
+ let results: Vec = pool.install(|| {
+ files
+ .into_par_iter()
+ .map(|file| {
+ let name = file
+ .file_name()
+ .unwrap_or_default()
+ .to_string_lossy()
+ .to_string();
+
+ // parse_and_execute handles:
+ // - JSON parsing
+ // - skipping pre-Merge forks
+ // - building store, executing blocks, verifying post-state
+ // - returning Ok(()) or Err with combined failure messages
+ let result = parse_and_execute(&file, Some(SKIPPED), None);
+
+ match result {
+ Ok(()) => {
+ passing.fetch_add(1, Ordering::Relaxed);
+ TestResult {
+ name,
+ pass: true,
+ error: None,
+ }
+ }
+ Err(e) => {
+ failing.fetch_add(1, Ordering::Relaxed);
+ TestResult {
+ name,
+ pass: false,
+ error: Some(e.to_string()),
+ }
+ }
+ }
+ })
+ .collect()
+ });
+
+ let exec_time = exec_start.elapsed();
+ let total = results.len();
+ let passed = passing.load(Ordering::Relaxed);
+ let failed = failing.load(Ordering::Relaxed);
+
+ if cli.json {
+ println!(
+ "{}",
+ serde_json::to_string_pretty(&results).unwrap()
+ );
+ } else {
+ // Print failures to stderr.
+ for r in &results {
+ if !r.pass {
+ eprintln!(
+ "FAIL: {} -- {}",
+ r.name,
+ r.error.as_deref().unwrap_or("unknown"),
+ );
+ }
+ }
+ }
+
+ eprintln!(
+ "\nTotal: {} | Passed: {} | Failed: {} | Time: {:.2}s (scan: {:.2}s, exec: {:.2}s)",
+ total,
+ passed,
+ failed,
+ start.elapsed().as_secs_f64(),
+ parse_time.as_secs_f64(),
+ exec_time.as_secs_f64(),
+ );
+
+ if failed > 0 {
+ std::process::exit(1);
+ }
+}
+
+/// Recursively collect all .json files under a directory.
+fn collect_json_files(dir: &PathBuf) -> Vec {
+ let mut result = Vec::new();
+ let entries = match std::fs::read_dir(dir) {
+ Ok(rd) => rd,
+ Err(e) => {
+ eprintln!("Cannot read directory {:?}: {}", dir, e);
+ return result;
+ }
+ };
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ result.extend(collect_json_files(&path));
+ } else if path.extension().is_some_and(|ext| ext == "json") {
+ result.push(path);
+ }
+ }
+ result
+}
diff --git a/tooling/ef_tests/enginetest/Cargo.toml b/tooling/ef_tests/enginetest/Cargo.toml
new file mode 100644
index 00000000000..be9ea8fdfa9
--- /dev/null
+++ b/tooling/ef_tests/enginetest/Cargo.toml
@@ -0,0 +1,36 @@
+[package]
+name = "ef_tests-enginetest"
+version.workspace = true
+edition.workspace = true
+authors.workspace = true
+documentation.workspace = true
+license.workspace = true
+
+[dependencies]
+ef_tests-blockchain = { path = "../blockchain" }
+
+ethrex-blockchain.workspace = true
+ethrex-common.workspace = true
+ethrex-crypto.workspace = true
+ethrex-p2p.workspace = true
+ethrex-rpc.workspace = true
+ethrex-storage.workspace = true
+ethrex-vm.workspace = true
+ethrex-rlp.workspace = true
+
+serde = { workspace = true, features = ["derive"] }
+serde_json.workspace = true
+bytes.workspace = true
+hex.workspace = true
+clap.workspace = true
+rayon.workspace = true
+regex = "1"
+tokio = { workspace = true, features = ["full"] }
+
+[features]
+default = ["c-kzg"]
+c-kzg = ["ef_tests-blockchain/c-kzg"]
+
+[[bin]]
+name = "ef_tests-enginetest"
+path = "src/main.rs"
diff --git a/tooling/ef_tests/enginetest/src/main.rs b/tooling/ef_tests/enginetest/src/main.rs
new file mode 100644
index 00000000000..a0cf62a451a
--- /dev/null
+++ b/tooling/ef_tests/enginetest/src/main.rs
@@ -0,0 +1,272 @@
+#[allow(dead_code)]
+mod runner;
+#[allow(dead_code)]
+mod types;
+
+use std::path::PathBuf;
+use std::sync::atomic::{AtomicUsize, Ordering};
+use std::time::Instant;
+
+use clap::Parser;
+use rayon::iter::{IntoParallelIterator, ParallelIterator};
+use regex::Regex;
+use runner::TestResult;
+use types::EngineTestFile;
+
+use ef_tests_blockchain::fork::Fork;
+
+/// Engine test runner for ethrex -- runs blockchain_test_engine
+/// fixtures through the real Engine API execution path.
+#[derive(Parser, Debug)]
+#[command(name = "ef_tests-enginetest")]
+struct Cli {
+ /// Path to a blockchain_tests_engine directory or a single
+ /// .json file.
+ #[arg(short, long, value_name = "PATH")]
+ path: PathBuf,
+
+ /// Number of parallel worker threads.
+ #[arg(short, long, default_value = "1")]
+ workers: usize,
+
+ /// Regex filter: only run tests whose name matches this pattern.
+ #[arg(long)]
+ run: Option,
+
+ /// Output results as JSON array to stdout.
+ #[arg(long)]
+ json: bool,
+}
+
+/// Tests to skip.
+const SKIPPED: &[&str] = &[
+ // Too slow but pass
+ "static_Call50000_sha256",
+ "CALLBlake2f_MaxRounds",
+ "loopMul",
+ // Deserialize error: number > U256::MAX
+ "ValueOverflowParis",
+ // Create Blob Transaction (invalid)
+ "createBlobhashTx",
+];
+
+fn main() {
+ let cli = Cli::parse();
+ let start = Instant::now();
+
+ eprintln!(
+ "Scanning for engine test fixtures in {:?} ...",
+ cli.path
+ );
+
+ let mut files = if cli.path.is_file() {
+ vec![cli.path.clone()]
+ } else {
+ collect_json_files(&cli.path)
+ };
+
+ // Apply skip list
+ files.retain(|f| {
+ let name =
+ f.file_name().unwrap_or_default().to_string_lossy();
+ !SKIPPED.iter().any(|s| name.contains(s))
+ });
+
+ // Apply --run regex filter
+ if let Some(ref pattern) = cli.run {
+ let re = Regex::new(pattern).unwrap_or_else(|e| {
+ eprintln!("Invalid --run regex '{pattern}': {e}");
+ std::process::exit(1);
+ });
+ files.retain(|f| re.is_match(&f.to_string_lossy()));
+ }
+
+ let scan_time = start.elapsed();
+ eprintln!(
+ "Found {} fixture files in {:.2}s",
+ files.len(),
+ scan_time.as_secs_f64(),
+ );
+
+ // Build rayon thread pool
+ let pool = rayon::ThreadPoolBuilder::new()
+ .num_threads(cli.workers)
+ .build()
+ .expect("failed to build thread pool");
+
+ let passing = AtomicUsize::new(0);
+ let failing = AtomicUsize::new(0);
+
+ let exec_start = Instant::now();
+
+ let results: Vec = pool.install(|| {
+ files
+ .into_par_iter()
+ .flat_map(|file| {
+ let data = match std::fs::read(&file) {
+ Ok(d) => d,
+ Err(e) => {
+ eprintln!(
+ "Cannot read {:?}: {e}",
+ file
+ );
+ return vec![TestResult {
+ name: file
+ .to_string_lossy()
+ .to_string(),
+ pass: false,
+ error: Some(format!(
+ "read error: {e}"
+ )),
+ }];
+ }
+ };
+
+ let test_map: EngineTestFile =
+ match serde_json::from_slice(&data) {
+ Ok(m) => m,
+ Err(e) => {
+ eprintln!(
+ "JSON parse error in {:?}: {e}",
+ file
+ );
+ return vec![TestResult {
+ name: file
+ .to_string_lossy()
+ .to_string(),
+ pass: false,
+ error: Some(format!(
+ "parse error: {e}"
+ )),
+ }];
+ }
+ };
+
+ let rt =
+ tokio::runtime::Runtime::new().unwrap();
+
+ test_map
+ .into_iter()
+ .map(|(test_name, test)| {
+ // Skip pre-Merge forks
+ if test.network < Fork::Merge {
+ return TestResult {
+ name: test_name,
+ pass: true,
+ error: None,
+ };
+ }
+
+ // Skip tests whose names match the
+ // skip list
+ if SKIPPED.iter().any(|s| {
+ test_name.contains(s)
+ }) {
+ return TestResult {
+ name: test_name,
+ pass: true,
+ error: None,
+ };
+ }
+
+ let result = rt.block_on(
+ runner::run_engine_test(
+ &test_name,
+ &test,
+ ),
+ );
+
+ match result {
+ Ok(()) => {
+ passing.fetch_add(
+ 1,
+ Ordering::Relaxed,
+ );
+ TestResult {
+ name: test_name,
+ pass: true,
+ error: None,
+ }
+ }
+ Err(e) => {
+ failing.fetch_add(
+ 1,
+ Ordering::Relaxed,
+ );
+ TestResult {
+ name: test_name,
+ pass: false,
+ error: Some(e),
+ }
+ }
+ }
+ })
+ .collect::>()
+ })
+ .collect()
+ });
+
+ let exec_time = exec_start.elapsed();
+ let total = results.len();
+ let passed = passing.load(Ordering::Relaxed);
+ let failed = failing.load(Ordering::Relaxed);
+
+ if cli.json {
+ println!(
+ "{}",
+ serde_json::to_string_pretty(&results).unwrap()
+ );
+ } else {
+ for r in &results {
+ if !r.pass {
+ eprintln!(
+ "FAIL: {} -- {}",
+ r.name,
+ r.error.as_deref().unwrap_or("unknown"),
+ );
+ }
+ }
+ }
+
+ eprintln!(
+ "\nTotal: {} | Passed: {} | Failed: {} | \
+ Time: {:.2}s (scan: {:.2}s, exec: {:.2}s)",
+ total,
+ passed,
+ failed,
+ start.elapsed().as_secs_f64(),
+ scan_time.as_secs_f64(),
+ exec_time.as_secs_f64(),
+ );
+
+ if failed > 0 {
+ std::process::exit(1);
+ }
+}
+
+/// Recursively collect all .json files under a directory.
+fn collect_json_files(dir: &PathBuf) -> Vec {
+ let mut result = Vec::new();
+ let entries = match std::fs::read_dir(dir) {
+ Ok(rd) => rd,
+ Err(e) => {
+ eprintln!(
+ "Cannot read directory {:?}: {e}",
+ dir
+ );
+ return result;
+ }
+ };
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ result.extend(collect_json_files(&path));
+ } else if path
+ .extension()
+ .is_some_and(|ext| ext == "json")
+ {
+ result.push(path);
+ }
+ }
+ result
+}
diff --git a/tooling/ef_tests/enginetest/src/runner.rs b/tooling/ef_tests/enginetest/src/runner.rs
new file mode 100644
index 00000000000..1d66a78594b
--- /dev/null
+++ b/tooling/ef_tests/enginetest/src/runner.rs
@@ -0,0 +1,492 @@
+use std::sync::Arc;
+
+use ethrex_blockchain::Blockchain;
+use ethrex_common::{
+ H256,
+ constants::EMPTY_KECCACK_HASH,
+ types::{Account as CoreAccount, DEFAULT_BUILDER_GAS_CEIL},
+};
+use ethrex_rpc::{
+ ClientVersion, GasTipEstimator, NodeData, RpcApiContext,
+ RpcErr, RpcErrorMetadata, RpcHandler,
+ start_block_executor,
+ test_utils::{
+ dummy_peer_handler, dummy_sync_manager,
+ example_local_node_record, example_p2p_node,
+ },
+};
+use ethrex_rpc::engine::fork_choice::{
+ ForkChoiceUpdatedV1, ForkChoiceUpdatedV2,
+ ForkChoiceUpdatedV3, ForkChoiceUpdatedV4,
+};
+use ethrex_rpc::engine::payload::{
+ NewPayloadV1Request, NewPayloadV2Request,
+ NewPayloadV3Request, NewPayloadV4Request,
+ NewPayloadV5Request,
+};
+use ethrex_rpc::types::fork_choice::ForkChoiceResponse;
+use ethrex_rpc::types::payload::{
+ PayloadStatus, PayloadValidationStatus,
+};
+use ethrex_storage::{EngineType, Store};
+use serde::Serialize;
+use tokio::sync::{Mutex as TokioMutex, OnceCell};
+
+use crate::types::{EngineNewPayload, EngineTestUnit};
+
+use bytes::Bytes;
+use ethrex_p2p::peer_handler::PeerHandler;
+use ethrex_p2p::sync_manager::SyncManager;
+
+/// Shared dummy infrastructure (SyncManager + PeerHandler) that is
+/// expensive to create. Built once and reused across all tests so
+/// we don't exhaust OS thread limits.
+struct SharedTestInfra {
+ syncer: Arc,
+ peer_handler: PeerHandler,
+}
+
+/// Lazily-initialised shared infrastructure.
+static SHARED_INFRA: OnceCell =
+ OnceCell::const_new();
+
+/// Returns a reference to the shared infra, initializing it on the
+/// first call (within the current async runtime).
+async fn shared_infra() -> &'static SharedTestInfra {
+ SHARED_INFRA
+ .get_or_init(|| async {
+ let dummy_store = Store::new(
+ "",
+ EngineType::InMemory,
+ )
+ .expect("Failed to create dummy store");
+ SharedTestInfra {
+ syncer: Arc::new(dummy_sync_manager().await),
+ peer_handler: dummy_peer_handler(
+ dummy_store,
+ )
+ .await,
+ }
+ })
+ .await
+}
+
+/// Build a lightweight `RpcApiContext` for a single test, reusing the
+/// shared SyncManager and PeerHandler.
+#[allow(unexpected_cfgs)]
+async fn build_context(store: Store) -> RpcApiContext {
+ let blockchain =
+ Arc::new(Blockchain::default_with_store(store.clone()));
+ let block_worker_channel =
+ start_block_executor(blockchain.clone());
+ let infra = shared_infra().await;
+
+ RpcApiContext {
+ storage: store,
+ blockchain,
+ active_filters: Default::default(),
+ syncer: Some(infra.syncer.clone()),
+ peer_handler: Some(infra.peer_handler.clone()),
+ node_data: NodeData {
+ jwt_secret: Default::default(),
+ local_p2p_node: example_p2p_node(),
+ local_node_record: example_local_node_record(),
+ client_version: ClientVersion::new(
+ "ethrex".to_string(),
+ "0.1.0".to_string(),
+ "test".to_string(),
+ "abcd1234".to_string(),
+ "x86_64".to_string(),
+ "1.70.0".to_string(),
+ ),
+ extra_data: Bytes::new(),
+ },
+ gas_tip_estimator: Arc::new(
+ TokioMutex::new(GasTipEstimator::new()),
+ ),
+ log_filter_handler: None,
+ gas_ceil: DEFAULT_BUILDER_GAS_CEIL,
+ block_worker_channel,
+ #[cfg(feature = "eip-8025")]
+ proof_coordinator: None,
+ }
+}
+
+#[derive(Serialize)]
+pub struct TestResult {
+ pub name: String,
+ pub pass: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub error: Option,
+}
+
+/// Run a single engine test. Returns `Ok(())` on pass, `Err(msg)` on
+/// failure.
+pub async fn run_engine_test(
+ test_key: &str,
+ test: &EngineTestUnit,
+) -> Result<(), String> {
+ let store = build_store(test).await;
+ let context = build_context(store.clone()).await;
+
+ // Track the current head hash for fork-choice updates.
+ #[allow(unused_assignments)]
+ let mut head_hash = test.genesis_block_header.hash;
+
+ for (i, payload_entry) in
+ test.engine_new_payloads.iter().enumerate()
+ {
+ let expects_error = payload_entry.expects_error();
+ let expected_rpc_code = payload_entry.error_code;
+
+ let version = payload_entry.new_payload_version;
+ let params = Some(payload_entry.params.clone());
+
+ // ---- 1. Dispatch to the real RPC handler ----
+ //
+ // RpcHandler::parse() validates parameter count and
+ // deserializes the payload, and handle() runs the full
+ // engine pipeline: payload validation, block construction,
+ // block hash check, blob versioned hash check, execution
+ // requests validation, block execution, and storage.
+ let handler_result: Result =
+ dispatch_new_payload(version, ¶ms, &context).await;
+
+ // ---- 2. Check for RPC-level errors ----
+ match handler_result {
+ Err(rpc_err) => {
+ let meta: RpcErrorMetadata = rpc_err.into();
+ if let Some(expected_code) = expected_rpc_code {
+ if meta.code == expected_code as i32 {
+ // Expected RPC error, skip this payload.
+ continue;
+ }
+ return Err(format!(
+ "payload[{i}]: expected RPC error code \
+ {expected_code}, got {} ({})",
+ meta.code, meta.message
+ ));
+ }
+ if expects_error {
+ // The fixture expected an INVALID status but
+ // we got an RPC error instead. Treat it as a
+ // matching error.
+ continue;
+ }
+ return Err(format!(
+ "payload[{i}]: unexpected RPC error: \
+ code={}, msg={}",
+ meta.code, meta.message
+ ));
+ }
+ Ok(ref _val) if expected_rpc_code.is_some() => {
+ return Err(format!(
+ "payload[{i}]: expected RPC error code {:?} \
+ but handler succeeded",
+ expected_rpc_code
+ ));
+ }
+ Ok(response_value) => {
+ // ---- 3. Inspect PayloadStatus ----
+ let status: PayloadStatus =
+ serde_json::from_value(response_value.clone())
+ .map_err(|e| {
+ format!(
+ "payload[{i}]: failed to \
+ deserialize PayloadStatus: {e} \
+ (raw: {response_value})"
+ )
+ })?;
+
+ match status.status {
+ PayloadValidationStatus::Valid => {
+ if expects_error {
+ return Err(format!(
+ "payload[{i}]: expected error \
+ ({:?}) but got VALID",
+ payload_entry.validation_error
+ ));
+ }
+ }
+ PayloadValidationStatus::Invalid => {
+ if !expects_error {
+ return Err(format!(
+ "payload[{i}]: got INVALID \
+ unexpectedly: {:?}",
+ status.validation_error
+ ));
+ }
+ // Expected error -- do NOT advance fork
+ // choice, but continue processing
+ // subsequent payloads.
+ continue;
+ }
+ PayloadValidationStatus::Syncing
+ | PayloadValidationStatus::Accepted => {
+ // Syncing/Accepted in a test context is
+ // unexpected; the dummy SyncManager runs
+ // in Full mode so this should not happen.
+ return Err(format!(
+ "payload[{i}]: unexpected status {:?}",
+ status.status
+ ));
+ }
+ }
+ }
+ }
+
+ // ---- 4. Apply fork choice (advance the canonical
+ // head) via the real handler ----
+ head_hash = payload_entry
+ .block_hash_from_params()
+ .unwrap_or(head_hash);
+
+ let fcu_version = payload_entry.forkchoice_updated_version;
+ dispatch_fork_choice(
+ fcu_version,
+ head_hash,
+ &context,
+ )
+ .await
+ .map_err(|e| {
+ format!("payload[{i}]: fork choice update failed: {e}")
+ })?;
+ }
+
+ // ---- 5. Verify post-state ----
+ verify_post_state(test_key, test, &store).await?;
+
+ Ok(())
+}
+
+// ---- RPC dispatch helpers ----
+
+/// Dispatch to the version-appropriate `engine_newPayload` handler.
+async fn dispatch_new_payload(
+ version: u8,
+ params: &Option>,
+ context: &RpcApiContext,
+) -> Result {
+ match version {
+ 1 => {
+ let req = NewPayloadV1Request::parse(params)?;
+ req.handle(context.clone()).await
+ }
+ 2 => {
+ let req = NewPayloadV2Request::parse(params)?;
+ req.handle(context.clone()).await
+ }
+ 3 => {
+ let req = NewPayloadV3Request::parse(params)?;
+ req.handle(context.clone()).await
+ }
+ 4 => {
+ let req = NewPayloadV4Request::parse(params)?;
+ req.handle(context.clone()).await
+ }
+ 5 => {
+ let req = NewPayloadV5Request::parse(params)?;
+ req.handle(context.clone()).await
+ }
+ _ => Err(RpcErr::BadParams(format!(
+ "Unsupported newPayload version: {version}"
+ ))),
+ }
+}
+
+/// Dispatch to the version-appropriate `engine_forkchoiceUpdated`
+/// handler. We only pass the fork-choice state (no payload
+/// attributes) since the test runner does not build payloads.
+async fn dispatch_fork_choice(
+ version: u8,
+ head_hash: H256,
+ context: &RpcApiContext,
+) -> Result<(), String> {
+ let fcu_state = serde_json::json!({
+ "headBlockHash": head_hash,
+ "safeBlockHash": head_hash,
+ "finalizedBlockHash": head_hash,
+ });
+ let params = Some(vec![fcu_state]);
+
+ let result = match version {
+ 1 => {
+ let req = ForkChoiceUpdatedV1::parse(¶ms)
+ .map_err(|e| format!("FCU parse: {e}"))?;
+ req.handle(context.clone())
+ .await
+ .map_err(|e| format!("FCU handle: {e}"))
+ }
+ 2 => {
+ let req = ForkChoiceUpdatedV2::parse(¶ms)
+ .map_err(|e| format!("FCU parse: {e}"))?;
+ req.handle(context.clone())
+ .await
+ .map_err(|e| format!("FCU handle: {e}"))
+ }
+ 3 => {
+ let req = ForkChoiceUpdatedV3::parse(¶ms)
+ .map_err(|e| format!("FCU parse: {e}"))?;
+ req.handle(context.clone())
+ .await
+ .map_err(|e| format!("FCU handle: {e}"))
+ }
+ 4 => {
+ let req = ForkChoiceUpdatedV4::parse(¶ms)
+ .map_err(|e| format!("FCU parse: {e}"))?;
+ req.handle(context.clone())
+ .await
+ .map_err(|e| format!("FCU handle: {e}"))
+ }
+ _ => {
+ return Err(format!(
+ "Unsupported forkchoiceUpdated version: {version}"
+ ));
+ }
+ }?;
+
+ // Verify the FCU response indicates VALID (not SYNCING or
+ // INVALID).
+ let response: ForkChoiceResponse =
+ serde_json::from_value(result).map_err(|e| {
+ format!("Failed to parse ForkChoiceResponse: {e}")
+ })?;
+
+ match response.payload_status.status {
+ PayloadValidationStatus::Valid => Ok(()),
+ other => Err(format!(
+ "Fork choice returned {:?}: {:?}",
+ other, response.payload_status.validation_error
+ )),
+ }
+}
+
+// ---- Store setup ----
+
+async fn build_store(test: &EngineTestUnit) -> Store {
+ let mut store = Store::new("store.db", EngineType::InMemory)
+ .expect("Failed to build DB for engine test");
+ let genesis = test.get_genesis();
+ store
+ .add_initial_state(genesis)
+ .await
+ .expect("Failed to add genesis state");
+ store
+}
+
+// ---- Post-state verification ----
+
+async fn verify_post_state(
+ test_key: &str,
+ test: &EngineTestUnit,
+ store: &Store,
+) -> Result<(), String> {
+ let latest_block_number =
+ store.get_latest_block_number().await.map_err(|e| {
+ format!("Failed to get latest block number: {e}")
+ })?;
+
+ // Verify account state
+ if let Some(post_state) = &test.post_state {
+ for (addr, account) in post_state {
+ let expected: CoreAccount = account.clone().into();
+
+ let db_info = store
+ .get_account_info(latest_block_number, *addr)
+ .await
+ .map_err(|e| format!("DB read error: {e}"))?
+ .ok_or_else(|| {
+ format!(
+ "{test_key}: account {addr} not found \
+ in post-state"
+ )
+ })?;
+
+ if db_info != expected.info {
+ return Err(format!(
+ "{test_key}: account {addr} info mismatch: \
+ expected {:?}, got {:?}",
+ expected.info, db_info
+ ));
+ }
+
+ let code_hash = expected.info.code_hash;
+ if code_hash != *EMPTY_KECCACK_HASH {
+ let db_code = store
+ .get_account_code(code_hash)
+ .map_err(|e| format!("DB read error: {e}"))?
+ .ok_or_else(|| {
+ format!(
+ "{test_key}: code {code_hash} \
+ not found"
+ )
+ })?;
+ if db_code != expected.code {
+ return Err(format!(
+ "{test_key}: code mismatch for {addr}"
+ ));
+ }
+ }
+
+ for (key, value) in &expected.storage {
+ let db_val = store
+ .get_storage_at(
+ latest_block_number,
+ *addr,
+ *key,
+ )
+ .map_err(|e| format!("DB read error: {e}"))?
+ .ok_or_else(|| {
+ format!(
+ "{test_key}: storage {key} for \
+ {addr} not found"
+ )
+ })?;
+ if db_val != *value {
+ return Err(format!(
+ "{test_key}: storage mismatch for \
+ {addr} key {key}: expected {value}, \
+ got {db_val}"
+ ));
+ }
+ }
+ }
+ }
+
+ // Verify lastblockhash
+ let last_header = store
+ .get_block_header(latest_block_number)
+ .map_err(|e| format!("DB read error: {e}"))?
+ .ok_or_else(|| {
+ format!("{test_key}: last block header not found")
+ })?;
+ let last_hash = last_header.hash();
+
+ if test.lastblockhash != last_hash {
+ return Err(format!(
+ "{test_key}: lastblockhash mismatch: expected \
+ {:#x}, got {last_hash:#x}",
+ test.lastblockhash
+ ));
+ }
+
+ Ok(())
+}
+
+impl EngineNewPayload {
+ /// Returns true if this payload entry expects an error (INVALID
+ /// status or validation error).
+ pub fn expects_error(&self) -> bool {
+ self.validation_error
+ .as_ref()
+ .is_some_and(|s| !s.is_empty())
+ }
+
+ /// Extract the block hash from params[0] of the fixture.
+ pub fn block_hash_from_params(&self) -> Option {
+ self.params.first().and_then(|p| {
+ p.get("blockHash")
+ .and_then(|v| serde_json::from_value(v.clone()).ok())
+ })
+ }
+}
diff --git a/tooling/ef_tests/enginetest/src/types.rs b/tooling/ef_tests/enginetest/src/types.rs
new file mode 100644
index 00000000000..f6cb4712d11
--- /dev/null
+++ b/tooling/ef_tests/enginetest/src/types.rs
@@ -0,0 +1,352 @@
+use std::collections::HashMap;
+
+use bytes::Bytes;
+use ethrex_common::{
+ Address, Bloom, H256, H64, U256,
+ types::{
+ Block, BlockBody, BlockHeader, Genesis, Withdrawal,
+ block_access_list::BlockAccessList, compute_transactions_root,
+ compute_withdrawals_root, requests::EncodedRequests,
+ },
+};
+use ethrex_crypto::NativeCrypto;
+use serde::Deserialize;
+
+use ef_tests_blockchain::fork::Fork;
+use ef_tests_blockchain::types::{Account, BlobSchedule, Info};
+
+// ---- Top-level fixture map ----
+
+/// A single JSON file maps test names to `EngineTestUnit`.
+pub type EngineTestFile = HashMap;
+
+// ---- Engine test fixture ----
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct EngineTestUnit {
+ #[serde(default, rename = "_info")]
+ pub info: Info,
+ pub network: Fork,
+ pub genesis_block_header: Header,
+ pub pre: HashMap,
+ #[serde(default)]
+ pub post_state: Option>,
+ pub lastblockhash: H256,
+ #[serde(rename = "engineNewPayloads")]
+ pub engine_new_payloads: Vec,
+ pub config: Option,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct FixtureConfig {
+ pub blob_schedule: Option,
+}
+
+impl EngineTestUnit {
+ pub fn get_genesis(&self) -> Genesis {
+ let mut config = *self.network.chain_config();
+ if let Some(test_config) = &self.config
+ && let Some(ref schedule) = test_config.blob_schedule
+ {
+ config.blob_schedule = schedule.clone().into();
+ }
+ Genesis {
+ config,
+ alloc: self
+ .pre
+ .clone()
+ .into_iter()
+ .map(|(key, val)| (key, val.into()))
+ .collect(),
+ coinbase: self.genesis_block_header.coinbase,
+ difficulty: self.genesis_block_header.difficulty,
+ extra_data: self.genesis_block_header.extra_data.clone(),
+ gas_limit: self.genesis_block_header.gas_limit.as_u64(),
+ nonce: self.genesis_block_header.nonce.to_low_u64_be(),
+ mix_hash: self.genesis_block_header.mix_hash,
+ timestamp: self.genesis_block_header.timestamp.as_u64(),
+ base_fee_per_gas: self
+ .genesis_block_header
+ .base_fee_per_gas
+ .map(|v| v.as_u64()),
+ blob_gas_used: self
+ .genesis_block_header
+ .blob_gas_used
+ .map(|v| v.as_u64()),
+ excess_blob_gas: self
+ .genesis_block_header
+ .excess_blob_gas
+ .map(|v| v.as_u64()),
+ requests_hash: self.genesis_block_header.requests_hash,
+ block_access_list_hash: self
+ .genesis_block_header
+ .block_access_list_hash,
+ slot_number: self
+ .genesis_block_header
+ .slot_number
+ .map(|v| v.as_u64()),
+ }
+ }
+}
+
+// ---- Genesis block header (reuse from blockchain tests) ----
+
+#[derive(Debug, PartialEq, Eq, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Header {
+ pub bloom: Bloom,
+ pub coinbase: Address,
+ pub difficulty: U256,
+ #[serde(with = "ethrex_common::serde_utils::bytes")]
+ pub extra_data: Bytes,
+ pub gas_limit: U256,
+ pub gas_used: U256,
+ pub hash: H256,
+ pub mix_hash: H256,
+ pub nonce: H64,
+ pub number: U256,
+ pub parent_hash: H256,
+ pub receipt_trie: H256,
+ pub state_root: H256,
+ pub timestamp: U256,
+ pub transactions_trie: H256,
+ pub uncle_hash: H256,
+ pub base_fee_per_gas: Option,
+ pub withdrawals_root: Option,
+ pub blob_gas_used: Option,
+ pub excess_blob_gas: Option,
+ pub parent_beacon_block_root: Option,
+ pub requests_hash: Option,
+ pub block_access_list_hash: Option,
+ pub slot_number: Option,
+}
+
+// ---- Engine new payload entry ----
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct EngineNewPayload {
+ /// params[0] = ExecutionPayload
+ /// params[1] = versionedHashes (V3+)
+ /// params[2] = parentBeaconBlockRoot (V3+)
+ /// params[3] = executionRequests (V4+)
+ pub params: Vec,
+
+ /// "1", "2", "3", "4", or "5"
+ #[serde(deserialize_with = "deserialize_version_string")]
+ pub new_payload_version: u8,
+
+ /// "1", "2", "3", or "4"
+ #[serde(deserialize_with = "deserialize_version_string")]
+ pub forkchoice_updated_version: u8,
+
+ /// Empty string means no error expected (VALID).
+ /// Non-empty means INVALID with this error description.
+ #[serde(default)]
+ pub validation_error: Option,
+
+ /// JSON-RPC error code (e.g. -32602). Null/absent means no RPC
+ /// error. May appear as an integer or a string in fixtures.
+ #[serde(
+ default,
+ deserialize_with = "deserialize_error_code"
+ )]
+ pub error_code: Option,
+}
+
+fn deserialize_error_code<'de, D>(
+ deserializer: D,
+) -> Result