diff --git a/Cargo.lock b/Cargo.lock index c3afd5e..4eff2e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5258,6 +5258,26 @@ dependencies = [ "tracing", ] +[[package]] +name = "morph-statetest" +version = "0.3.0" +dependencies = [ + "alloy-evm", + "alloy-primitives", + "alloy-rlp", + "alloy-trie", + "clap", + "eyre", + "morph-chainspec", + "morph-evm", + "morph-revm", + "revm", + "revm-statetest-types", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "morph-txpool" version = "0.3.0" @@ -9421,6 +9441,24 @@ dependencies = [ "serde", ] +[[package]] +name = "revm-statetest-types" +version = "17.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94743de1e54a812077b79dd3a87b4059175a804650b21582fb6feae96ef2be9c" +dependencies = [ + "alloy-eip7928", + "k256", + "revm-context", + "revm-context-interface", + "revm-database", + "revm-primitives", + "revm-state", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "rfc6979" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index 539b1cb..d43df3b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ publish = false [workspace] resolver = "3" members = [ + "bin/morph-statetest", "bin/morph-reth", "crates/chainspec", "crates/consensus", @@ -180,6 +181,7 @@ alloy-network = { version = "2.0.4", default-features = false } alloy-primitives = { version = "1.5.6", default-features = false } alloy-provider = { version = "2.0.4", default-features = false } alloy-rlp = "0.3.13" +alloy-trie = "0.9.5" alloy-rpc-types-engine = "2.0.4" alloy-rpc-types-eth = { version = "2.0.4" } alloy-serde = "2.0.4" @@ -190,6 +192,7 @@ alloy-transport = "2.0.4" alloy-chains = { version = "0.2.33", default-features = false } crossbeam-channel = "0.5.13" revm-primitives = { version = "23.0.0", default-features = false } +revm-statetest-types = { version = "17.0.1", default-features = false } arbitrary = { version = "1.3", features = ["derive"] } async-lock = "3.4.1" async-trait = "0.1" @@ -237,4 +240,3 @@ criterion = "0.7.0" test-case = "3" pyroscope = "0.5.8" pyroscope_pprofrs = "0.2.10" - diff --git a/bin/morph-statetest/Cargo.toml b/bin/morph-statetest/Cargo.toml new file mode 100644 index 0000000..44e0192 --- /dev/null +++ b/bin/morph-statetest/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "morph-statetest" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +alloy-evm.workspace = true +alloy-primitives.workspace = true +alloy-rlp.workspace = true +alloy-trie.workspace = true +clap.workspace = true +eyre.workspace = true +morph-chainspec.workspace = true +morph-evm.workspace = true +morph-revm.workspace = true +revm = { workspace = true, features = ["tracer"] } +revm-statetest-types.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true + +[lints] +workspace = true diff --git a/bin/morph-statetest/src/lib.rs b/bin/morph-statetest/src/lib.rs new file mode 100644 index 0000000..70860d9 --- /dev/null +++ b/bin/morph-statetest/src/lib.rs @@ -0,0 +1,85 @@ +pub mod runner; +pub mod schema; + +#[cfg(test)] +mod tests { + use crate::{runner::run_suite_str, schema::MorphTestSuite}; + use alloy_primitives::B256; + + const MINIMAL_SUITE: &str = r#"{ + "minimal_transfer": { + "env": { + "currentCoinbase": "0x0000000000000000000000000000000000000000", + "currentDifficulty": "0x0", + "currentGasLimit": "0x989680", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + } + }, + "transaction": { + "nonce": "0x0", + "gasPrice": "0x1", + "gasLimit": ["0x5208"], + "to": "0x0000000000000000000000000000000000000001", + "value": ["0x1"], + "data": ["0x"], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8", + "feeTokenID": "0x0", + "feeLimit": "0x0", + "version": "0x0", + "reference": "0x0000000000000000000000000000000000000000000000000000000000000000", + "memo": "0x" + }, + "post": { + "Jade": [ + { + "indexes": { "data": 0, "gas": 0, "value": 0 }, + "hash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "logs": "0x0000000000000000000000000000000000000000000000000000000000000000", + "expectException": null + } + ] + } + } + }"#; + + #[test] + fn preserves_morph_fork_names() { + let suite: MorphTestSuite = serde_json::from_str(MINIMAL_SUITE).unwrap(); + let unit = suite.0.get("minimal_transfer").unwrap(); + + assert!(unit.post.contains_key("Jade")); + } + + #[test] + fn accepts_hex_string_transaction_type() { + let suite_json = MINIMAL_SUITE.replace( + r#""transaction": { + "nonce": "0x0","#, + r#""transaction": { + "type": "0x7f", + "nonce": "0x0","#, + ); + let suite: MorphTestSuite = serde_json::from_str(&suite_json).unwrap(); + let unit = suite.0.get("minimal_transfer").unwrap(); + + assert_eq!(unit.transaction.tx_type, Some(0x7f)); + } + + #[test] + fn executes_minimal_transfer_and_returns_actual_roots() { + let outcomes = run_suite_str(MINIMAL_SUITE).unwrap(); + + assert_eq!(outcomes.len(), 1); + assert_eq!(outcomes[0].fork, "Jade"); + assert_ne!(outcomes[0].state_root, B256::ZERO); + } +} diff --git a/bin/morph-statetest/src/main.rs b/bin/morph-statetest/src/main.rs new file mode 100644 index 0000000..78810a8 --- /dev/null +++ b/bin/morph-statetest/src/main.rs @@ -0,0 +1,63 @@ +use clap::Parser; +use morph_statetest::runner::{RunnerOptions, run_path}; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +#[derive(Debug, Parser)] +#[command(about = "Execute Ethereum statetest JSON fixtures with Morph EVM semantics")] +struct Cli { + /// JSON file or directory containing statetest fixtures. + path: PathBuf, + /// Emit EIP-3155 execution trace events to stderr. + #[arg(long)] + trace: bool, + /// Validate post-state roots from the fixture and exit non-zero on mismatch. + #[arg(long)] + validate: bool, +} + +fn main() -> eyre::Result<()> { + let cli = Cli::parse(); + let paths = collect_json_files(&cli.path)?; + if paths.is_empty() { + eyre::bail!("no JSON statetest files found at {}", cli.path.display()); + } + + let options = RunnerOptions { + trace: cli.trace, + validate: cli.validate, + }; + for path in paths { + for outcome in run_path(&path, options)? { + println!("{}", serde_json::to_string(&outcome)?); + } + } + + Ok(()) +} + +fn collect_json_files(path: &Path) -> eyre::Result> { + if path.is_file() { + return Ok( + match path.extension().and_then(|extension| extension.to_str()) { + Some("json") => vec![path.to_path_buf()], + _ => Vec::new(), + }, + ); + } + + let mut paths = Vec::new(); + for entry in fs::read_dir(path)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + paths.extend(collect_json_files(&path)?); + } else if path.extension().and_then(|extension| extension.to_str()) == Some("json") { + paths.push(path); + } + } + paths.sort(); + Ok(paths) +} diff --git a/bin/morph-statetest/src/runner.rs b/bin/morph-statetest/src/runner.rs new file mode 100644 index 0000000..a3c650b --- /dev/null +++ b/bin/morph-statetest/src/runner.rs @@ -0,0 +1,337 @@ +use crate::schema::{MorphTestSuite, MorphTestUnit, SchemaError, parse_fork}; +use alloy_evm::{Evm, EvmEnv}; +use alloy_primitives::{B256, Bytes}; +use alloy_trie::{HashBuilder, Nibbles, TrieAccount, root::storage_root_unhashed}; +use morph_chainspec::hardfork::MorphHardfork; +use morph_evm::{MorphBlockEnv, evm::MorphEvm}; +use revm::{ + context::{CfgEnv, result::ExecutionResult}, + database::{EmptyDB, PlainAccount, State}, + inspector::inspectors::TracerEip3155, + primitives::{Address, Log, U256, address, keccak256}, +}; +use revm_statetest_types::Test; +use serde::Serialize; +use std::{fs, io::stderr, path::Path}; +use thiserror::Error; + +const MORPH_STATE_TEST_FEE_VAULT_ADDRESS: Address = + address!("48442aa154897eef141df231cc1517fc8c1d170f"); + +#[derive(Clone, Copy, Debug, Default)] +pub struct RunnerOptions { + pub trace: bool, + pub validate: bool, +} + +#[derive(Debug, Error)] +pub enum RunnerError { + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Serde(#[from] serde_json::Error), + #[error(transparent)] + Schema(#[from] SchemaError), + #[error("state test validation failed: {0}")] + Validation(String), +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Outcome { + #[serde(rename = "stateRoot")] + pub state_root: B256, + #[serde(rename = "logsRoot")] + pub logs_root: B256, + pub output: Bytes, + #[serde(rename = "gasUsed")] + pub gas_used: u64, + pub pass: bool, + #[serde(rename = "errorMsg")] + pub error_msg: String, + #[serde(rename = "evmResult")] + pub evm_result: String, + #[serde(rename = "postLogsHash")] + pub post_logs_hash: B256, + pub fork: String, + pub test: String, + pub d: usize, + pub g: usize, + pub v: usize, +} + +pub fn run_suite_str(input: &str) -> Result, RunnerError> { + run_suite_str_with_options(input, RunnerOptions::default()) +} + +pub fn run_suite_str_with_options( + input: &str, + options: RunnerOptions, +) -> Result, RunnerError> { + let suite: MorphTestSuite = serde_json::from_str(input)?; + run_suite(suite, options) +} + +pub fn run_path(path: &Path, options: RunnerOptions) -> Result, RunnerError> { + let input = fs::read_to_string(path)?; + run_suite_str_with_options(&input, options) +} + +fn run_suite(suite: MorphTestSuite, options: RunnerOptions) -> Result, RunnerError> { + let mut outcomes = Vec::new(); + for (name, unit) in suite.0 { + for (fork_name, tests) in &unit.post { + let fork = parse_fork(fork_name)?; + for test in tests { + let outcome = execute_case(&name, &unit, fork_name, fork, test, options)?; + if options.validate && !outcome.pass { + return Err(RunnerError::Validation(outcome.error_msg)); + } + outcomes.push(outcome); + } + } + } + Ok(outcomes) +} + +fn execute_case( + name: &str, + unit: &MorphTestUnit, + fork_name: &str, + fork: MorphHardfork, + test: &Test, + options: RunnerOptions, +) -> Result { + let cache = unit.state(); + let mut state = State::builder() + .with_cached_prestate(cache) + .with_bundle_update() + .build(); + let mut cfg = CfgEnv::::default() + .with_chain_id( + unit.env + .current_chain_id + .unwrap_or(U256::ONE) + .try_into() + .unwrap_or(1), + ) + .with_spec_and_mainnet_gas_params(fork); + cfg.disable_eip7623 = true; + + let mut block = unit.block_env(&mut cfg); + if fork.is_curie() { + block.beneficiary = MORPH_STATE_TEST_FEE_VAULT_ADDRESS; + } + cfg.tx_gas_limit_cap = Some(block.gas_limit); + + let tx = match unit.morph_tx_env(test, fork) { + Ok(tx) => tx, + Err(error) if test.expect_exception.is_some() => { + let exec_result: Result, String> = + Err(error.to_string()); + return Ok(build_outcome( + name, + fork_name, + test, + &exec_result, + &state, + unit.out.as_ref(), + &[], + )); + } + Err(error) => return Err(error.into()), + }; + let env = EvmEnv { + cfg_env: cfg, + block_env: MorphBlockEnv { inner: block }, + }; + + if options.trace { + let mut evm = MorphEvm::new(&mut state, env) + .with_inspector(TracerEip3155::buffered(stderr()).without_summary()); + evm.enable_inspector(); + let exec_result = evm.transact_commit(tx); + let receipt_logs = collect_receipt_logs(&mut evm, &exec_result); + return Ok(build_outcome( + name, + fork_name, + test, + &exec_result, + &state, + unit.out.as_ref(), + &receipt_logs, + )); + } + + let mut evm = MorphEvm::new(&mut state, env); + let exec_result = evm.transact_commit(tx); + let receipt_logs = collect_receipt_logs(&mut evm, &exec_result); + Ok(build_outcome( + name, + fork_name, + test, + &exec_result, + &state, + unit.out.as_ref(), + &receipt_logs, + )) +} + +fn build_outcome( + name: &str, + fork_name: &str, + test: &Test, + exec_result: &Result, E>, + db: &State, + expected_output: Option<&Bytes>, + receipt_logs: &[Log], +) -> Outcome +where + E: std::fmt::Display, +{ + let logs_root = log_rlp_hash(receipt_logs); + let state_root = state_merkle_trie_root(db.cache.trie_account()); + let error_msg = validation_error(test, exec_result, expected_output, state_root, logs_root); + let output = exec_result + .as_ref() + .ok() + .and_then(|result| result.output().cloned()) + .unwrap_or_default(); + let gas_used = exec_result + .as_ref() + .ok() + .map(ExecutionResult::tx_gas_used) + .unwrap_or_default(); + + Outcome { + state_root, + logs_root, + output, + gas_used, + pass: error_msg.is_none(), + error_msg: error_msg.unwrap_or_default(), + evm_result: format_evm_result(exec_result), + post_logs_hash: logs_root, + fork: fork_name.to_string(), + test: name.to_string(), + d: test.indexes.data, + g: test.indexes.gas, + v: test.indexes.value, + } +} + +fn collect_receipt_logs( + evm: &mut MorphEvm, + exec_result: &Result, E>, +) -> Vec +where + DB: alloy_evm::Database, + I: revm::Inspector>, +{ + let mut logs = evm.take_pre_fee_logs(); + if let Ok(result) = exec_result { + logs.extend(result.logs().iter().cloned()); + } + logs.extend(evm.take_post_fee_logs()); + logs +} + +fn validation_error( + test: &Test, + exec_result: &Result, E>, + expected_output: Option<&Bytes>, + state_root: B256, + logs_root: B256, +) -> Option +where + E: std::fmt::Display, +{ + match (&test.expect_exception, exec_result) { + (Some(_), Err(_)) => return None, + (Some(expected), Ok(_)) => { + return Some(format!( + "expected exception {expected:?}, but execution succeeded" + )); + } + (None, Err(error)) => return Some(format!("unexpected execution error: {error}")), + (None, Ok(_)) => {} + } + + if let (Some(expected), Ok(result)) = (expected_output, exec_result) + && result.output() != Some(expected) + { + return Some(format!( + "unexpected output: got {:?}, expected {expected:?}", + result.output() + )); + } + if logs_root != test.logs { + return Some(format!( + "logs root mismatch: got {logs_root}, expected {}", + test.logs + )); + } + if state_root != test.hash { + return Some(format!( + "state root mismatch: got {state_root}, expected {}", + test.hash + )); + } + None +} + +fn format_evm_result( + exec_result: &Result, E>, +) -> String +where + E: std::fmt::Display, +{ + match exec_result { + Ok(ExecutionResult::Success { reason, .. }) => format!("Success: {reason:?}"), + Ok(ExecutionResult::Revert { .. }) => "Revert".to_string(), + Ok(ExecutionResult::Halt { reason, .. }) => format!("Halt: {reason:?}"), + Err(error) => error.to_string(), + } +} + +fn log_rlp_hash(logs: &[Log]) -> B256 { + let mut out = Vec::with_capacity(alloy_rlp::list_length(logs)); + alloy_rlp::encode_list(logs, &mut out); + keccak256(&out) +} + +fn state_merkle_trie_root<'a>( + accounts: impl IntoIterator, +) -> B256 { + let mut accounts: Vec<_> = accounts + .into_iter() + .map(|(address, account)| { + let storage_root = storage_root_unhashed( + account + .storage + .iter() + .filter(|&(_, &value)| !value.is_zero()) + .map(|(key, value)| (B256::from(*key), *value)), + ); + ( + keccak256(address), + TrieAccount { + nonce: account.info.nonce, + balance: account.info.balance, + storage_root, + code_hash: account.info.code_hash, + }, + ) + }) + .collect(); + accounts.sort_unstable_by_key(|(key, _)| *key); + + let mut trie = HashBuilder::default(); + let mut account_rlp = Vec::new(); + for (hashed_key, account) in accounts { + account_rlp.clear(); + alloy_rlp::Encodable::encode(&account, &mut account_rlp); + trie.add_leaf(Nibbles::unpack(hashed_key), &account_rlp); + } + trie.root() +} diff --git a/bin/morph-statetest/src/schema.rs b/bin/morph-statetest/src/schema.rs new file mode 100644 index 0000000..38501a2 --- /dev/null +++ b/bin/morph-statetest/src/schema.rs @@ -0,0 +1,542 @@ +use morph_chainspec::hardfork::MorphHardfork; +use morph_revm::{MorphTxEnv, MorphTxExt}; +use revm::{ + context::{BlockEnv, CfgEnv, TransactionType, TxEnv}, + context_interface::{ + block::BlobExcessGasAndPrice, + transaction::{AccessList, SignedAuthorization}, + }, + database::CacheState, + primitives::{Address, AddressMap, B256, Bytes, TxKind, U256, keccak256}, + state::Bytecode, +}; +use revm_statetest_types::{ + AccountInfo, Env, Test, TestAuthorization, deserialize_maybe_empty, recover_address, +}; +use serde::{Deserialize, Deserializer, de}; +use std::collections::BTreeMap; +use thiserror::Error; + +#[derive(Debug, Deserialize)] +pub struct MorphTestSuite(pub BTreeMap); + +#[derive(Debug, Deserialize)] +pub struct MorphTestUnit { + #[serde(default, rename = "_info")] + pub info: Option, + pub env: Env, + pub pre: AddressMap, + pub post: BTreeMap>, + pub transaction: MorphTransactionParts, + #[serde(default)] + pub out: Option, +} + +#[derive(Debug, Error)] +pub enum SchemaError { + #[error("unknown Morph hardfork: {0}")] + UnknownFork(String), + #[error("unknown private key: {0}")] + UnknownPrivateKey(B256), + #[error("transaction index {index} is out of bounds for {field}")] + IndexOutOfBounds { field: &'static str, index: usize }, + #[error("transaction type is invalid for the selected fields")] + InvalidTransactionType, + #[error("quantity does not fit in {target}: {value}")] + QuantityOverflow { target: &'static str, value: String }, + #[error( + "blob transaction without post.txbytes is not representable: morph-statetest cannot fabricate an Eip4844 envelope for L1 fee calculation, so the L1 fee path would silently fall through to 0 and mask cross-client divergence" + )] + BlobTxRequiresTxBytes, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MorphTransactionParts { + #[serde( + default, + rename = "type", + deserialize_with = "deserialize_opt_u8_quantity" + )] + pub tx_type: Option, + pub data: Vec, + pub gas_limit: Vec, + pub gas_price: Option, + pub nonce: U256, + pub secret_key: B256, + #[serde(default)] + pub sender: Option
, + #[serde(default, deserialize_with = "deserialize_maybe_empty")] + pub to: Option
, + pub value: Vec, + pub max_fee_per_gas: Option, + pub max_priority_fee_per_gas: Option, + pub initcodes: Option>, + #[serde(default)] + pub access_lists: Vec>, + pub authorization_list: Option>, + #[serde(default)] + pub blob_versioned_hashes: Vec, + pub max_fee_per_blob_gas: Option, + #[serde( + default, + rename = "feeTokenID", + alias = "feeTokenId", + deserialize_with = "deserialize_opt_u16_quantity" + )] + pub fee_token_id: Option, + #[serde(default)] + pub fee_limit: Option, + #[serde(default, deserialize_with = "deserialize_opt_u8_quantity")] + pub version: Option, + #[serde(default)] + pub reference: Option, + #[serde(default)] + pub memo: Option, +} + +impl MorphTestUnit { + pub fn state(&self) -> CacheState { + let mut cache_state = CacheState::new(); + for (address, info) in &self.pre { + let code_hash = keccak256(&info.code); + let bytecode = Bytecode::new_raw_checked(info.code.clone()) + .unwrap_or_else(|_| Bytecode::new_legacy(info.code.clone())); + let account_info = revm::state::AccountInfo { + balance: info.balance, + code_hash, + code: Some(bytecode), + nonce: info.nonce, + account_id: None, + }; + cache_state.insert_account_with_storage(*address, account_info, info.storage.clone()); + } + cache_state + } + + pub fn block_env(&self, cfg: &mut CfgEnv) -> BlockEnv { + let mut block = BlockEnv { + number: self.env.current_number, + beneficiary: self.env.current_coinbase, + timestamp: self.env.current_timestamp, + gas_limit: self.env.current_gas_limit.try_into().unwrap_or(u64::MAX), + basefee: self + .env + .current_base_fee + .unwrap_or_default() + .try_into() + .unwrap_or(u64::MAX), + difficulty: self.env.current_difficulty, + prevrandao: self.env.current_random, + slot_num: self + .env + .slot_number + .unwrap_or_default() + .try_into() + .unwrap_or(u64::MAX), + ..BlockEnv::default() + }; + + if let Some(excess_blob_gas) = self.env.current_excess_blob_gas { + block.set_blob_excess_gas_and_price( + excess_blob_gas.to(), + cfg.blob_base_fee_update_fraction(), + ); + } else { + block.blob_excess_gas_and_price = Some(BlobExcessGasAndPrice { + excess_blob_gas: 0, + blob_gasprice: 1, + }); + } + + if block.prevrandao.is_none() { + block.prevrandao = Some(B256::ZERO); + } + + block + } + + pub fn morph_tx_env( + &self, + test: &Test, + _fork: MorphHardfork, + ) -> Result { + self.transaction.morph_tx_env( + test.indexes.data, + test.indexes.gas, + test.indexes.value, + self.env.current_chain_id, + test.txbytes.clone(), + self.env.current_base_fee.is_some(), + ) + } +} + +impl MorphTransactionParts { + fn morph_tx_env( + &self, + data_index: usize, + gas_index: usize, + value_index: usize, + chain_id: Option, + txbytes: Option, + has_base_fee: bool, + ) -> Result { + let caller = match self.sender { + Some(address) => address, + None => recover_address(self.secret_key.as_slice()) + .ok_or(SchemaError::UnknownPrivateKey(self.secret_key))?, + }; + // Preserve the raw tx-type byte when the JSON explicitly specifies + // it. Routing it through `revm::context::TransactionType` first + // would lossily map any non-standard byte (Morph's 0x7f, L1Msg's + // 0x7e, future fork types) to `Custom = 0xFF`, since revm's enum + // only knows the 5 vanilla Ethereum tx types. The cast-to-u8 of + // `Custom` then yields 0xFF, so `MorphTxExt::is_morph_tx` and + // `is_l1_msg` both return false and the morph fee handler / + // L1Message short-circuit never activate. Only fall through to + // the enum-based inference for txs that didn't carry a `type` + // field at all. + let tx_type = match self.tx_type { + Some(t) => t, + None => self + .tx_type(data_index) + .ok_or(SchemaError::InvalidTransactionType)? as u8, + }; + let gas_limit = self + .gas_limit + .get(gas_index) + .ok_or(SchemaError::IndexOutOfBounds { + field: "gasLimit", + index: gas_index, + })? + .saturating_to(); + let data = self + .data + .get(data_index) + .ok_or(SchemaError::IndexOutOfBounds { + field: "data", + index: data_index, + })? + .clone(); + let value = *self + .value + .get(value_index) + .ok_or(SchemaError::IndexOutOfBounds { + field: "value", + index: value_index, + })?; + let access_list = self + .access_lists + .get(data_index) + .cloned() + .flatten() + .unwrap_or_default(); + let authorization_list = self + .authorization_list + .clone() + .map(|auth_list| { + auth_list + .into_iter() + .map(|auth| { + revm::context::either::Either::::Left(auth.into()) + }) + .collect() + }) + .unwrap_or_default(); + + let gas_price = self + .gas_price + .or(self.max_fee_per_gas) + .unwrap_or_default() + .try_into() + .unwrap_or(u128::MAX); + let gas_priority_fee = self + .max_priority_fee_per_gas + .map(|fee| fee.try_into().unwrap_or(u128::MAX)) + .or_else(|| has_base_fee.then_some(gas_price)); + + let inner = TxEnv { + caller, + gas_price, + gas_priority_fee, + blob_hashes: self.blob_versioned_hashes.clone(), + max_fee_per_blob_gas: self + .max_fee_per_blob_gas + .map(|fee| fee.try_into().unwrap_or(u128::MAX)) + .unwrap_or(u128::MAX), + tx_type, + gas_limit, + data, + nonce: u64::try_from(self.nonce).unwrap_or(u64::MAX), + value, + access_list, + authorization_list, + chain_id: chain_id.and_then(|id| id.try_into().ok()), + kind: match self.to { + Some(address) => TxKind::Call(address), + None => TxKind::Create, + }, + }; + + let mut tx = MorphTxEnv::new(inner); + if let Some(version) = self.version { + tx = tx.with_version(version); + } + if let Some(fee_token_id) = self.fee_token_id { + tx = tx.with_fee_token_id(fee_token_id); + } + if let Some(fee_limit) = self.fee_limit { + tx = tx.with_fee_limit(fee_limit); + } + if let Some(reference) = self.reference { + tx = tx.with_reference(reference); + } + if let Some(memo) = &self.memo + && !memo.is_empty() + { + tx = tx.with_memo(memo.clone()); + } + if let Some(txbytes) = txbytes { + tx = tx.with_rlp_bytes(txbytes); + } else if !tx.blob_hashes.is_empty() { + // morph-reth has no Eip4844 envelope variant — see + // crates/primitives/src/transaction/envelope.rs. A blob tx + // arriving here without post.txbytes cannot be encoded for + // L1 fee calculation, and silently falling through would + // leave rlp_bytes empty in handler.rs (unwrap_or_default), + // making L1 fee = 0 on reth while geth charges normally. + // Fail loud at parse time instead of producing a phantom + // state divergence downstream. + return Err(SchemaError::BlobTxRequiresTxBytes); + } else if !tx.is_l1_msg() { + let fallback_chain_id = chain_id + .and_then(|id| id.try_into().ok()) + .unwrap_or_default(); + let rlp_bytes = tx.encode_for_l1_fee(fallback_chain_id); + tx = tx.with_rlp_bytes(rlp_bytes); + } + + Ok(tx) + } + + fn tx_type(&self, access_list_index: usize) -> Option { + if let Some(tx_type) = self.tx_type { + return Some(TransactionType::from(tx_type)); + } + + let mut tx_type = TransactionType::Legacy; + if self + .access_lists + .get(access_list_index) + .is_some_and(Option::is_some) + { + tx_type = TransactionType::Eip2930; + } + if self.max_fee_per_gas.is_some() { + tx_type = TransactionType::Eip1559; + } + if self.max_fee_per_blob_gas.is_some() { + self.to?; + return Some(TransactionType::Eip4844); + } + if self.authorization_list.is_some() { + self.to?; + return Some(TransactionType::Eip7702); + } + Some(tx_type) + } +} + +pub fn parse_fork(name: &str) -> Result { + match name.to_ascii_lowercase().as_str() { + "bernoulli" => Ok(MorphHardfork::Bernoulli), + "curie" => Ok(MorphHardfork::Curie), + "morph203" | "morph-203" => Ok(MorphHardfork::Morph203), + "viridian" | "prague" => Ok(MorphHardfork::Viridian), + "emerald" => Ok(MorphHardfork::Emerald), + "jade" | "osaka" => Ok(MorphHardfork::Jade), + "cancun" => Ok(MorphHardfork::Morph203), + _ => Err(SchemaError::UnknownFork(name.to_string())), + } +} + +fn deserialize_opt_u16_quantity<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + deserialize_opt_quantity(deserializer, "u16", |value| { + u16::try_from(value).map_err(|_| ()) + }) +} + +fn deserialize_opt_u8_quantity<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + deserialize_opt_quantity(deserializer, "u8", |value| { + u8::try_from(value).map_err(|_| ()) + }) +} + +fn deserialize_opt_quantity<'de, D, T, F>( + deserializer: D, + target: &'static str, + convert: F, +) -> Result, D::Error> +where + D: Deserializer<'de>, + F: FnOnce(u64) -> Result, +{ + let Some(value) = Option::::deserialize(deserializer)? else { + return Ok(None); + }; + if value.is_null() { + return Ok(None); + } + + let (raw, parsed) = match value { + serde_json::Value::Number(number) => { + let raw = number.to_string(); + let parsed = number + .as_u64() + .ok_or_else(|| de::Error::custom(format!("invalid quantity: {raw}")))?; + (raw, parsed) + } + serde_json::Value::String(raw) => { + let parsed = if let Some(hex) = raw.strip_prefix("0x") { + u64::from_str_radix(hex, 16) + } else { + raw.parse() + } + .map_err(de::Error::custom)?; + (raw, parsed) + } + other => { + return Err(de::Error::custom(format!( + "expected quantity string or number, got {other}" + ))); + } + }; + + convert(parsed) + .map(Some) + .map_err(|_| SchemaError::QuantityOverflow { target, value: raw }) + .map_err(de::Error::custom) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn statetest_without_txbytes_sets_l1_fee_encoding() { + let suite: MorphTestSuite = serde_json::from_str( + r#"{ + "case": { + "env": { + "currentChainID": "0x1", + "currentCoinbase": "0x0000000000000000000000000000000000000000", + "currentDifficulty": "0x0", + "currentGasLimit": "0x989680", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x0" + }, + "pre": {}, + "transaction": { + "nonce": "0x0", + "gasPrice": "0x1", + "gasLimit": ["0x5208"], + "to": "0x00000000000000000000000000000000000000f1", + "value": ["0x0"], + "data": ["0x"], + "accessLists": [null], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Jade": [{ + "indexes": { "data": 0, "gas": 0, "value": 0 }, + "hash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "logs": "0x0000000000000000000000000000000000000000000000000000000000000000", + "expectException": null + }] + } + } + }"#, + ) + .expect("suite should parse"); + + let unit = suite.0.values().next().unwrap(); + let post = &unit.post["Jade"][0]; + let tx = unit + .morph_tx_env(post, MorphHardfork::Jade) + .expect("tx env should build"); + + assert!( + tx.rlp_bytes.as_ref().is_some_and(|bytes| !bytes.is_empty()), + "statetest txs without post.txbytes still need fallback L1 fee bytes" + ); + assert_eq!( + tx.rlp_bytes.as_ref().unwrap().first(), + Some(&0x02), + "when currentBaseFee is present, fallback L1 fee encoding must match go-ethereum's dynamic-fee envelope" + ); + } + + #[test] + fn blob_tx_without_txbytes_errors_instead_of_silently_zeroing_l1_fee() { + let suite: MorphTestSuite = serde_json::from_str( + r#"{ + "case": { + "env": { + "currentChainID": "0x1", + "currentCoinbase": "0x0000000000000000000000000000000000000000", + "currentDifficulty": "0x0", + "currentGasLimit": "0x989680", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x0" + }, + "pre": {}, + "transaction": { + "type": "0x3", + "nonce": "0x0", + "gasLimit": ["0x5208"], + "to": "0x00000000000000000000000000000000000000f1", + "value": ["0x0"], + "data": ["0x"], + "accessLists": [null], + "maxFeePerGas": "0x10", + "maxPriorityFeePerGas": "0x1", + "maxFeePerBlobGas": "0x20", + "blobVersionedHashes": ["0x011e0690904ead419cbeb3e3f051f17f3ec476e751b7fcb7b36f4e470616b713"], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Jade": [{ + "indexes": { "data": 0, "gas": 0, "value": 0 }, + "hash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "logs": "0x0000000000000000000000000000000000000000000000000000000000000000", + "expectException": null + }] + } + } + }"#, + ) + .expect("suite should parse"); + + let unit = suite.0.values().next().unwrap(); + let post = &unit.post["Jade"][0]; + let result = unit.morph_tx_env(post, MorphHardfork::Jade); + + match result { + Err(SchemaError::BlobTxRequiresTxBytes) => {} + other => panic!( + "expected blob tx without txbytes to fail loudly with BlobTxRequiresTxBytes; \ + got {other:?}. If this assertion ever flips, double-check handler.rs is not \ + silently computing L1 fee against empty rlp_bytes for blob txs." + ), + } + } +} diff --git a/crates/revm/src/tx.rs b/crates/revm/src/tx.rs index d92c5fe..29f555f 100644 --- a/crates/revm/src/tx.rs +++ b/crates/revm/src/tx.rs @@ -105,8 +105,11 @@ impl MorphTxEnv { /// This is used by simulation paths (`eth_call`, `eth_estimateGas`) where /// we have tx env fields but no pre-encoded transaction bytes. pub fn encode_for_l1_fee(&self, fallback_chain_id: u64) -> Bytes { - // Signature validity is irrelevant for fee sizing, but encoded length matters. - let placeholder_signature = Signature::new(Default::default(), Default::default(), false); + // Signature validity is irrelevant for fee sizing, but encoded zero/non-zero + // bytes matter for the pre-Curie calldata-gas formula. Match + // go-ethereum's EstimateL1DataFeeForMessage placeholder: + // 64 bytes of 0xff followed by yParity=1. + let placeholder_signature = Signature::new(U256::MAX, U256::MAX, true); match self.build_morph_tx_for_l1_fee(fallback_chain_id) { Some(morph_tx) => { @@ -610,6 +613,58 @@ mod tests { assert!(!tx.encode_for_l1_fee(53077).is_empty()); } + #[test] + fn encode_for_l1_fee_uses_go_ethereum_placeholder_signature_shape() { + let tx = MorphTxEnv { + inner: TxEnv { + chain_id: Some(53077), + gas_limit: 21_000, + gas_price: 1, + nonce: 1, + kind: TxKind::Call(Address::ZERO), + ..Default::default() + }, + ..Default::default() + }; + let encoded = tx.encode_for_l1_fee(53077); + let full_ff_words = encoded + .windows(32) + .filter(|window| window.iter().all(|byte| *byte == 0xff)) + .count(); + + assert_eq!( + full_ff_words, 2, + "fallback L1 fee encoding must use go-ethereum's 0xff...ff placeholder r/s words" + ); + } + + #[test] + fn encode_for_l1_fee_dynamic_matches_go_ethereum_bytes() { + let tx = MorphTxEnv { + inner: TxEnv { + chain_id: Some(1), + gas_limit: 8_000_000, + gas_price: 16, + gas_priority_fee: Some(16), + nonce: 0, + kind: TxKind::Call(Address::with_last_byte(0xf1)), + data: Bytes::from_static(&[ + 0x22, 0xcf, 0xae, 0xfc, 0x92, 0xe4, 0xed, 0xb9, 0xb0, 0xae, 0x01, 0xa6, 0x3a, + 0x95, 0xdf, 0x11, 0xf1, 0x27, 0x9b, 0x7b, 0x6b, 0xdd, 0xe4, 0xe0, 0x48, 0xf6, + 0xec, 0xe8, 0xf8, 0xc8, 0xf2, 0xc2, 0xde, 0x97, 0x54, 0x4a, 0x16, 0x3f, 0x7d, + 0x30, 0x23, 0x15, 0xee, 0x0f, 0x78, 0x69, 0xfb, 0xdc, 0xfd, 0x86, + ]), + ..Default::default() + }, + ..Default::default() + }; + + assert_eq!( + alloy_primitives::hex::encode_prefixed(tx.encode_for_l1_fee(1)), + "0x02f89501801010837a12009400000000000000000000000000000000000000f180b222cfaefc92e4edb9b0ae01a63a95df11f1279b7b6bdde4e048f6ece8f8c8f2c2de97544a163f7d302315ee0f7869fbdcfd86c001a0ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa0ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + ); + } + #[test] fn test_encode_for_l1_fee_morph_tx() { let tx = MorphTxEnv {