From b2916c3d4ae05c091e0e774b283679730bc05fad Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Mon, 16 Mar 2026 22:20:20 +0530 Subject: [PATCH 1/7] introduce the debug trace transaction API --- src/interpreter/vm.rs | 2 +- src/rpc/methods/eth.rs | 153 ++++++++++ src/rpc/methods/eth/trace/geth.rs | 239 +++++++++++++++ src/rpc/methods/eth/trace/mod.rs | 36 +++ src/rpc/methods/eth/trace/parity.rs | 40 +-- src/rpc/methods/eth/trace/state_diff.rs | 34 ++- src/rpc/methods/eth/trace/types.rs | 386 ++++++++++++++++++++++++ src/rpc/methods/eth/trace/utils.rs | 32 ++ src/rpc/methods/eth/utils.rs | 2 +- src/rpc/mod.rs | 1 + src/state_manager/mod.rs | 106 +++++++ 11 files changed, 988 insertions(+), 43 deletions(-) create mode 100644 src/rpc/methods/eth/trace/geth.rs create mode 100644 src/rpc/methods/eth/trace/utils.rs diff --git a/src/interpreter/vm.rs b/src/interpreter/vm.rs index 6985c695577a..52aa36e03cc2 100644 --- a/src/interpreter/vm.rs +++ b/src/interpreter/vm.rs @@ -521,7 +521,7 @@ where Ok((ret, duration)) } - fn reward_message( + pub(crate) fn reward_message( &self, epoch: ChainEpoch, miner: Address, diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index 801486b68ffd..541bd3032ba5 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -3440,6 +3440,159 @@ where Ok(all_traces) } +pub enum EthDebugTraceTransaction {} +impl RpcMethod<2> for EthDebugTraceTransaction { + const N_REQUIRED_PARAMS: usize = 1; + const NAME: &'static str = "Forest.EthDebugTraceTransaction"; + const NAME_ALIAS: Option<&'static str> = Some("debug_traceTransaction"); + const PARAM_NAMES: [&'static str; 2] = ["txHash", "opts"]; + const API_PATHS: BitFlags = make_bitflags!(ApiPaths::{ V1 | V2 }); + const PERMISSION: Permission = Permission::Read; + const DESCRIPTION: Option<&'static str> = + Some("Replays a transaction and returns execution traces in Geth-compatible format."); + + type Params = (String, Option); + type Ok = GethTrace; + + async fn handle( + ctx: Ctx, + (tx_hash, opts): Self::Params, + ext: &http::Extensions, + ) -> Result { + let opts = opts.unwrap_or_default(); + debug_trace_transaction(ctx, ext, Self::api_path(ext)?, tx_hash, opts).await + } +} + +async fn debug_trace_transaction( + ctx: Ctx, + ext: &http::Extensions, + api_path: ApiPaths, + tx_hash: String, + opts: GethDebugTracingOptions, +) -> Result +where + DB: Blockstore + Send + Sync + 'static, +{ + let tracer = match &opts.tracer { + Some(t) => t.clone(), + None => { + tracing::debug!( + "no tracer specified for debug_traceTransaction; defaulting to callTracer (struct logger not supported)" + ); + GethDebugBuiltInTracerType::Call + } + }; + + let eth_hash = EthHash::from_str(&tx_hash).context("invalid transaction hash")?; + let eth_txn = get_eth_transaction_by_hash(&ctx, ð_hash, None) + .await? + .ok_or(ServerError::internal_error("transaction not found", None))?; + + // Mempool/pending transactions cannot be traced — they have no containing tipset. + if eth_txn.block_hash == EthHash::default() { + return Err(ServerError::invalid_params( + "no trace for pending transactions", + None, + )); + } + + if tracer == GethDebugBuiltInTracerType::Noop { + return Ok(GethTrace::Noop(NoopFrame {})); + } + + let resolver = TipsetResolver::new(&ctx, api_path); + let ts = resolver + .tipset_by_block_number_or_hash(eth_txn.block_number, ResolveNullTipset::TakeOlder) + .await?; + + // prestateTracer uses per-message replay for exact state boundaries, + // so it does not need the full tipset trace. + if tracer == GethDebugBuiltInTracerType::PreState { + let prestate_config = opts.prestate_config(); + + let message_cid = ctx + .chain_store() + .get_mapping(ð_hash)? + .unwrap_or_else(|| eth_hash.to_cid()); + + let (pre_root, invoc_result, post_root) = ctx + .state_manager + .replay_for_prestate(ts.clone(), message_cid) + .await + .map_err(|e| anyhow::anyhow!("replay for prestate failed: {e}"))?; + + let execution_trace = invoc_result + .execution_trace + .context("no execution trace for transaction")?; + + let mut touched = extract_touched_eth_addresses(&execution_trace); + if let Ok(addr) = EthAddress::from_filecoin_address(&invoc_result.msg.from()) { + touched.insert(addr); + } + + if let Ok(addr) = EthAddress::from_filecoin_address(&invoc_result.msg.to()) { + touched.insert(addr); + } + + let pre_state = StateTree::new_from_root(ctx.store_owned(), &pre_root)?; + let post_state = StateTree::new_from_root(ctx.store_owned(), &post_root)?; + + let frame = trace::build_prestate_frame( + ctx.store(), + &pre_state, + &post_state, + &touched, + &prestate_config, + )?; + + return Ok(GethTrace::PreState(frame)); + } + + let (state, entries) = execute_tipset_traces(&ctx, &ts, ext).await?; + let entry = entries + .into_iter() + .find(|e| e.tx_hash == eth_hash) + .ok_or_else(|| ServerError::internal_error("transaction trace not found in block", None))?; + + let execution_trace = entry + .invoc_result + .execution_trace + .context("no execution trace for transaction")?; + + let mut env = trace::base_environment(&state, &entry.invoc_result.msg.from).map_err(|e| { + anyhow::anyhow!( + "when processing message {}: {e}", + entry.invoc_result.msg_cid + ) + })?; + + match tracer { + GethDebugBuiltInTracerType::Call => { + let call_config = opts.call_config()?; + let frame = trace::build_geth_call_frame(&mut env, execution_trace, &call_config)?; + Ok(GethTrace::Call(frame.unwrap_or_default())) + } + GethDebugBuiltInTracerType::FlatCall => { + trace::build_traces(&mut env, &[], execution_trace)?; + let block_hash: EthHash = ts.key().cid()?.into(); + let traces = env + .traces + .into_iter() + .map(|t| EthBlockTrace { + trace: t, + block_hash, + block_number: ts.epoch(), + transaction_hash: eth_hash, + transaction_position: entry.msg_position, + }) + .collect(); + Ok(GethTrace::FlatCall(traces)) + } + _ => unreachable!("noopTracer and prestateTracer handled above"), + } +} + pub enum EthTraceCall {} impl RpcMethod<3> for EthTraceCall { const NAME: &'static str = "Forest.EthTraceCall"; diff --git a/src/rpc/methods/eth/trace/geth.rs b/src/rpc/methods/eth/trace/geth.rs new file mode 100644 index 000000000000..1b1deae3335f --- /dev/null +++ b/src/rpc/methods/eth/trace/geth.rs @@ -0,0 +1,239 @@ +// Copyright 2019-2026 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT + +use super::Environment; +use super::types::{CallTracerConfig, GethCallFrame}; +use crate::eth::EAMMethod; +use crate::rpc::eth::EthBigInt; +use crate::rpc::eth::trace::state_diff::{diff_entry_keys, extract_evm_storage_entries}; +use crate::rpc::eth::trace::types::{ + DiffMode, EthTrace, GethCallType, PreStateFrame, PreStateMode, TraceAction, TraceResult, +}; +use crate::rpc::eth::trace::utils::{ZERO_HASH, trace_to_address, u256_to_eth_hash}; +use crate::rpc::eth::types::{EthAddress, EthBytes, EthHash}; +use crate::rpc::eth::utils::{ActorStateEthExt, parse_eth_revert}; +use crate::rpc::state::ExecutionTrace; +use crate::shim::actors::{EVMActorStateLoad, evm, is_evm_actor}; +use crate::shim::address::Address; +use crate::shim::state_tree::{ActorState, StateTree}; +use ahash::{HashMap, HashSet}; +use fil_actor_evm_state::evm_shared::v17::uints::U256; +use fvm_ipld_blockstore::Blockstore; +use num_traits::FromPrimitive; +use std::collections::BTreeMap; + +/// Error string used in Geth-format traces. +pub(crate) const GETH_TRACE_REVERT_ERROR: &str = "execution reverted"; + +/// Builds a Geth-style nested call frame tree from a Filecoin execution trace. +/// +/// Reuses [`build_trace`] for classification and data extraction, then converts +/// the Parity-style [`EthTrace`] into a nested [`GethCallFrame`]. +pub fn build_geth_call_frame( + env: &mut Environment, + trace: ExecutionTrace, + tracer_cfg: &CallTracerConfig, +) -> anyhow::Result> { + build_geth_frame_recursive(env, trace, tracer_cfg, true) +} + +fn build_geth_frame_recursive( + env: &mut Environment, + trace: ExecutionTrace, + tracer_cfg: &CallTracerConfig, + is_root: bool, +) -> anyhow::Result> { + let msg_to = trace.msg.to; + let msg_method = trace.msg.method; + + // Reuse build_trace for all classification logic (EVM call, create, delegatecall, etc.). + // Pass an empty address for root (skips the insufficient-funds early-return) and a + // non-empty placeholder for subcalls (enables it). + let address: &[i64] = if is_root { &[] } else { &[0] }; + let (eth_trace, recurse_into) = super::parity::build_trace(env, address, trace)?; + + let eth_trace = match eth_trace { + Some(t) => t, + None => return Ok(None), + }; + + let call_type = match ð_trace.action { + TraceAction::Call(action) => GethCallType::from_parity_call_type(&action.call_type), + TraceAction::Create(_) => { + if msg_to == Address::ETHEREUM_ACCOUNT_MANAGER_ACTOR + && matches!(EAMMethod::from_u64(msg_method), Some(EAMMethod::Create2)) + { + GethCallType::Create2 + } else { + GethCallType::Create + } + } + }; + + let mut frame = eth_trace.to_geth_frame(call_type)?; + if !tracer_cfg.only_top_call.unwrap_or_default() + && let Some(recurse_trace) = recurse_into + && let Some(invoked_actor) = &recurse_trace.invoked_actor + { + let mut sub_env = Environment { + caller: trace_to_address(invoked_actor), + is_evm: is_evm_actor(&invoked_actor.state.code), + ..Environment::default() + }; + + let mut subcalls = Vec::new(); + for subcall in recurse_trace.subcalls { + if let Some(f) = build_geth_frame_recursive(&mut sub_env, subcall, tracer_cfg, false)? { + subcalls.push(f); + } + } + + if !subcalls.is_empty() { + frame.calls = Some(subcalls); + } + } + + Ok(Some(frame)) +} + +/// Build an [`AccountState`] snapshot from an actor. +/// Returns `None` when the actor does not exist. +/// +/// When `storage_filter` is provided, only storage keys in the filter set are +/// included. This limits output to slots that actually changed between pre and +/// post state. +fn build_account_snapshot_from_entries( + store: &DB, + actor: Option<&ActorState>, + config: &super::types::PreStateConfig, + entries: &HashMap<[u8; 32], U256>, + storage_filter: Option<&HashSet<[u8; 32]>>, +) -> Option { + let actor = actor?; + + let nonce = Some(actor.eth_nonce(store)?); + let code = if config.is_code_disabled() { + None + } else { + actor.eth_bytecode(store).ok()? + }; + let storage = if config.is_storage_disabled() { + BTreeMap::new() + } else { + entries + .iter() + .filter(|(k, _)| storage_filter.is_none_or(|f| f.contains(*k))) + .map(|(k, v)| (EthHash(ethereum_types::H256(*k)), u256_to_eth_hash(v))) + .collect() + }; + + Some(super::types::AccountState { + balance: Some(EthBigInt(actor.balance.atto().clone())), + code, + nonce, + storage, + }) +} + +/// Build a [`PreStateFrame`] for the `prestateTracer`. +/// +/// In default mode, returns the pre-execution state of every touched account. +/// Only storage slots that changed between pre and post state are included. +/// +/// In diff mode, returns separate `pre` and `post` snapshots with unchanged +/// fields and accounts stripped. +pub(crate) fn build_prestate_frame( + store: &S, + pre_state: &StateTree, + post_state: &StateTree, + touched_addresses: &HashSet, + config: &super::types::PreStateConfig, +) -> anyhow::Result { + if config.is_diff_mode() { + let mut pre_map = BTreeMap::new(); + let mut post_map = BTreeMap::new(); + let mut deleted_addrs = HashSet::default(); + + for eth_addr in touched_addresses { + let fil_addr = eth_addr.to_filecoin_address()?; + let pre_actor = pre_state.get_actor(&fil_addr)?; + let post_actor = post_state.get_actor(&fil_addr)?; + + if pre_actor.is_some() && post_actor.is_none() { + deleted_addrs.insert(*eth_addr); + } + + let pre_entries = extract_evm_storage_entries(store, pre_actor.as_ref()); + let post_entries = extract_evm_storage_entries(store, post_actor.as_ref()); + let changed_keys = diff_entry_keys(&pre_entries, &post_entries); + + let pre_snap = build_account_snapshot_from_entries( + store, + pre_actor.as_ref(), + config, + &pre_entries, + Some(&changed_keys), + ); + let post_snap = build_account_snapshot_from_entries( + store, + post_actor.as_ref(), + config, + &post_entries, + Some(&changed_keys), + ); + + // Created accounts (pre=None) only appear in post. + if let Some(ref snap) = pre_snap { + pre_map.insert(*eth_addr, snap.clone()); + } + + // Deleted accounts (post=None) only appear in pre. + // For modified accounts, strip unchanged fields from the post snapshot. + if let Some(mut snap) = post_snap { + // Strip zero-valued storage entries from post. + snap.storage.retain(|_, v| *v != ZERO_HASH); + if let Some(ref pre) = pre_snap { + snap.retain_changed(pre); + } + if !snap.is_empty() { + post_map.insert(*eth_addr, snap); + } + } + } + + // Remove fully unchanged accounts: keep only those with changes + // (in post_map) or that were deleted. + pre_map.retain(|addr, _| post_map.contains_key(addr) || deleted_addrs.contains(addr)); + + Ok(PreStateFrame::Diff(DiffMode { + pre: pre_map, + post: post_map, + })) + } else { + let mut result = BTreeMap::new(); + + for eth_addr in touched_addresses { + let fil_addr = eth_addr.to_filecoin_address()?; + let pre_actor = pre_state.get_actor(&fil_addr)?; + let post_actor = post_state.get_actor(&fil_addr)?; + + // Extract storage once per actor and derive both changed keys and + // the snapshot from the cached entries. + let pre_entries = extract_evm_storage_entries(store, pre_actor.as_ref()); + let post_entries = extract_evm_storage_entries(store, post_actor.as_ref()); + let changed_keys = diff_entry_keys(&pre_entries, &post_entries); + + if let Some(snap) = build_account_snapshot_from_entries( + store, + pre_actor.as_ref(), + config, + &pre_entries, + Some(&changed_keys), + ) { + result.insert(*eth_addr, snap); + } + } + + Ok(PreStateFrame::Default(PreStateMode(result))) + } +} diff --git a/src/rpc/methods/eth/trace/mod.rs b/src/rpc/methods/eth/trace/mod.rs index 1f8e8c6e8dd1..2a802620b6f4 100644 --- a/src/rpc/methods/eth/trace/mod.rs +++ b/src/rpc/methods/eth/trace/mod.rs @@ -10,9 +10,45 @@ //! execution. //! - [`types`] — shared type definitions for all `trace_*` RPC responses. +mod geth; mod parity; mod state_diff; pub(crate) mod types; +mod utils; +pub(super) use geth::*; pub(super) use parity::*; pub(crate) use state_diff::build_state_diff; + +use super::lookup_eth_address; +use super::types::EthAddress; +use anyhow::Context; +use fvm_ipld_blockstore::Blockstore; +use types::EthTrace; + +use crate::shim::{address::Address, state_tree::StateTree}; + +/// Shared mutable context threaded through recursive trace building. +/// +/// Used by both Parity-style and Geth-style trace constructors to track +/// the current caller, collected traces, and subtrace count. +#[derive(Default)] +pub(super) struct Environment { + pub(in crate::rpc::methods::eth::trace) caller: EthAddress, + pub(in crate::rpc::methods::eth::trace) is_evm: bool, + pub(in crate::rpc::methods::eth::trace) subtrace_count: i64, + pub(super) traces: Vec, + pub(in crate::rpc::methods::eth::trace) last_byte_code: Option, +} + +pub(super) fn base_environment( + state: &StateTree, + from: &Address, +) -> anyhow::Result { + let sender = lookup_eth_address(from, state)? + .with_context(|| format!("top-level message sender {from} could not be found"))?; + Ok(Environment { + caller: sender, + ..Environment::default() + }) +} diff --git a/src/rpc/methods/eth/trace/parity.rs b/src/rpc/methods/eth/trace/parity.rs index 7057ba6bafc5..a5bc95181e17 100644 --- a/src/rpc/methods/eth/trace/parity.rs +++ b/src/rpc/methods/eth/trace/parity.rs @@ -13,11 +13,13 @@ use super::super::{ decode_payload, encode_filecoin_params_as_abi, encode_filecoin_returns_as_abi, lookup_eth_address, }; +use super::Environment; use super::types::{ EthCallTraceAction, EthCallTraceResult, EthCreateTraceAction, EthCreateTraceResult, EthTrace, TraceAction, TraceResult, }; use crate::eth::{EAMMethod, EVMMethod}; +use crate::rpc::eth::trace::utils::trace_to_address; use crate::rpc::methods::state::ExecutionTrace; use crate::rpc::state::ActorTrace; use crate::shim::fvm_shared_latest::METHOD_CONSTRUCTOR; @@ -32,7 +34,7 @@ use num::FromPrimitive; use tracing::debug; /// Error string used in Parity-format traces. -const PARITY_TRACE_REVERT_ERROR: &str = "Reverted"; +pub const PARITY_TRACE_REVERT_ERROR: &str = "Reverted"; const PARITY_EVM_INVALID_INSTRUCTION: &str = "invalid instruction"; const PARITY_EVM_UNDEFINED_INSTRUCTION: &str = "undefined instruction"; const PARITY_EVM_STACK_UNDERFLOW: &str = "stack underflow"; @@ -42,36 +44,6 @@ const PARITY_EVM_BAD_JUMPDEST: &str = "invalid jump destination"; const PARITY_EVM_SELFDESTRUCT_FAILED: &str = "self destruct failed"; const PARITY_EVM_OUT_OF_GAS: &str = "out of gas"; -#[derive(Default)] -pub struct Environment { - caller: EthAddress, - is_evm: bool, - subtrace_count: i64, - pub traces: Vec, - last_byte_code: Option, -} - -pub fn base_environment( - state: &StateTree, - from: &Address, -) -> anyhow::Result { - let sender = lookup_eth_address(from, state)? - .with_context(|| format!("top-level message sender {from} could not be found"))?; - Ok(Environment { - caller: sender, - ..Environment::default() - }) -} - -fn trace_to_address(trace: &ActorTrace) -> EthAddress { - if let Some(addr) = trace.state.delegated_address - && let Ok(eth_addr) = EthAddress::from_filecoin_address(&addr.into()) - { - return eth_addr; - } - EthAddress::from_actor_id(trace.id) -} - /// Returns true if the trace is a call to an EVM or EAM actor. fn trace_is_evm_or_eam(trace: &ExecutionTrace) -> bool { if let Some(invoked_actor) = &trace.invoked_actor { @@ -82,7 +54,7 @@ fn trace_is_evm_or_eam(trace: &ExecutionTrace) -> bool { } } -/// Returns true if the trace is a call to an EVM or EAM actor. +/// Converts trace error codes into the `parity` errors fn trace_err_msg(trace: &ExecutionTrace) -> Option { let code = trace.msg_rct.exit_code; @@ -177,7 +149,7 @@ pub fn build_traces( // `build_trace` processes the passed execution trace and updates the environment, if necessary. // // On success, it returns a trace to add (or `None` to skip) and the trace to recurse into (or `None` to skip). -fn build_trace( +pub(crate) fn build_trace( env: &mut Environment, address: &[i64], trace: ExecutionTrace, @@ -658,7 +630,7 @@ impl TipsetTraceEntry { &self, state: &StateTree, ) -> Result, crate::rpc::error::ServerError> { - let mut env = base_environment(state, &self.invoc_result.msg.from).map_err(|e| { + let mut env = super::base_environment(state, &self.invoc_result.msg.from).map_err(|e| { format!( "when processing message {}: {}", self.invoc_result.msg_cid, e diff --git a/src/rpc/methods/eth/trace/state_diff.rs b/src/rpc/methods/eth/trace/state_diff.rs index 9eec5949ca1d..a722525d4e29 100644 --- a/src/rpc/methods/eth/trace/state_diff.rs +++ b/src/rpc/methods/eth/trace/state_diff.rs @@ -10,6 +10,7 @@ use super::super::types::{EthAddress, EthHash}; use super::super::utils::ActorStateEthExt as _; use super::types::{AccountDiff, ChangedType, Delta, StateDiff}; use crate::rpc::eth::EthBigInt; +use crate::rpc::eth::trace::utils::{ZERO_HASH, u256_to_eth_hash}; use crate::shim::actors::{EVMActorStateLoad as _, evm, is_evm_actor}; use crate::shim::state_tree::{ActorState, StateTree}; use ahash::{HashMap, HashSet}; @@ -43,12 +44,6 @@ impl AsHashedKey for EvmStateHashAlgorithm { /// Type alias for EVM storage KAMT with configuration. type EvmStorageKamt = Kamt; -fn u256_to_eth_hash(value: &U256) -> EthHash { - EthHash(ethereum_types::H256(value.to_big_endian())) -} - -const ZERO_HASH: EthHash = EthHash(ethereum_types::H256([0u8; 32])); - /// Build state diff by comparing pre and post-execution states for touched addresses. pub(crate) fn build_state_diff( store: &S, @@ -208,7 +203,7 @@ fn diff_evm_storage_for_actors( /// Extract all storage entries from an EVM actor's KAMT. /// Returns empty map if actor is None, not an EVM actor, or state cannot be loaded. -fn extract_evm_storage_entries( +pub fn extract_evm_storage_entries( store: &DB, actor: Option<&ActorState>, ) -> HashMap<[u8; 32], U256> { @@ -248,6 +243,31 @@ fn extract_evm_storage_entries( entries } +// Compute the set of storage keys that differ between pre and post actor states. +pub(crate) fn diff_entry_keys( + pre_entries: &HashMap<[u8; 32], U256>, + post_entries: &HashMap<[u8; 32], U256>, +) -> HashSet<[u8; 32]> { + let mut changed = HashSet::default(); + + for (k, v) in pre_entries { + match post_entries.get(k) { + Some(pv) if pv == v => {} // unchanged + _ => { + changed.insert(*k); + } + } + } + + for k in post_entries.keys() { + if !pre_entries.contains_key(k) { + changed.insert(*k); + } + } + + changed +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/rpc/methods/eth/trace/types.rs b/src/rpc/methods/eth/trace/types.rs index d23ceed23821..e969841c06ae 100644 --- a/src/rpc/methods/eth/trace/types.rs +++ b/src/rpc/methods/eth/trace/types.rs @@ -13,6 +13,8 @@ use anyhow::{Result, bail}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; +use crate::rpc::eth::trace::{GETH_TRACE_REVERT_ERROR, PARITY_TRACE_REVERT_ERROR}; +use crate::rpc::eth::trace::utils::extract_revert_reason; #[derive(Eq, Hash, PartialEq, Default, Serialize, Deserialize, Debug, Clone, JsonSchema)] #[serde(rename_all = "camelCase")] @@ -76,6 +78,294 @@ impl Default for TraceResult { } } +/// The Available built-in tracer. +#[derive(PartialEq, Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub enum GethDebugBuiltInTracerType { + /// The call tracer builds a hierarchical call tree, showing the hierarchy of calls (e.g., `call`, `create`, `reward`) + #[serde(rename = "callTracer")] + Call, + /// The flat call tracer builds a flat list of all calls, showing the hierarchy of calls (e.g., `call`, `create`, `reward`) + #[serde(rename = "flatCallTracer")] + FlatCall, + /// The prestate tracer builds a state snapshot of the accounts necessary to execute the transaction, and the state after the transaction. + #[serde(rename = "prestateTracer")] + PreState, + /// The noop tracer does not build any traces. + #[serde(rename = "noopTracer")] + Noop, +} + +/// Options for the `debug_traceTransaction` API. +#[derive(PartialEq, Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct GethDebugTracingOptions { + /// The tracer to use for the transaction. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tracer: Option, + /// The configuration for the provided tracer. + /// The configuration is a JSON object that is specific to the tracer. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tracer_config: Option, +} + +lotus_json_with_self!(GethDebugTracingOptions); + +impl GethDebugTracingOptions { + /// Extracts and validates the `callTracer` config. + /// Returns an error if an unsupported flag (e.g. `withLog`) is set to true. + pub fn call_config(&self) -> anyhow::Result { + let cfg = parse_tracer_config::(&self.tracer_config); + if cfg.with_log.unwrap_or(false) { + anyhow::bail!("callTracer: withLog is not yet supported"); + } + Ok(cfg) + } + + /// Extracts the `prestateTracer` config, defaulting to no-op values when absent. + pub fn prestate_config(&self) -> PreStateConfig { + parse_tracer_config::(&self.tracer_config) + } +} + +/// Parses a tracer-specific config from the opaque [`TracerConfig`] JSON blob. +/// Returns `T::default()` when the config is absent or null, and logs a warning +/// if the config is present but fails to deserialize. +fn parse_tracer_config(raw: &Option) -> T { + let Some(cfg) = raw.as_ref().filter(|c| !c.0.is_null()) else { + return T::default(); + }; + serde_json::from_value(cfg.0.clone()).unwrap_or_else(|e| { + tracing::warn!( + error = %e, + "invalid tracerConfig — using defaults" + ); + T::default() + }) +} + +/// Configuration for the `callTracer`. +#[derive(PartialEq, Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct CallTracerConfig { + /// When set to true, only the top call will be returned. + /// Otherwise, the call tracer will return the full call tree. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub only_top_call: Option, + /// When set to true, logs emitted during calls will be included in the trace. + /// Not yet supported — a request with this flag set to true will return an error. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub with_log: Option, +} + +lotus_json_with_self!(CallTracerConfig); + +/// Configuration for the `prestateTracer`. +// Taken from https://github.com/alloy-rs/alloy/blob/v1.5.2/crates/rpc-types-trace/src/geth/pre_state.rs#L236 +#[derive(PartialEq, Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PreStateConfig { + /// When set to true, the pre and post state of the accounts will be returned in the trace. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub diff_mode: Option, + /// When set to true, the code of the accounts will not be returned in the trace. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disable_code: Option, + /// When set to true, the storage of the accounts will not be returned in the trace. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disable_storage: Option, +} + +lotus_json_with_self!(PreStateConfig); + +impl PreStateConfig { + pub fn is_diff_mode(&self) -> bool { + self.diff_mode.unwrap_or(false) + } + + pub fn is_code_disabled(&self) -> bool { + self.disable_code.unwrap_or(false) + } + + pub fn is_storage_disabled(&self) -> bool { + self.disable_storage.unwrap_or(false) + } +} + +/// Opaque JSON blob for per-tracer configuration. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(transparent)] +pub struct TracerConfig(pub serde_json::Value); +lotus_json_with_self!(TracerConfig); + +/// EVM call/create operation type for Geth-style trace frames. +/// +/// Maps to the EVM opcodes: CALL, STATICCALL, DELEGATECALL, CREATE, CREATE2. +/// Used as the `type` field in [`GethCallFrame`]. +#[derive(PartialEq, Eq, Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +pub enum GethCallType { + #[default] + #[serde(rename = "CALL")] + Call, + #[serde(rename = "STATICCALL")] + StaticCall, + #[serde(rename = "DELEGATECALL")] + DelegateCall, + #[serde(rename = "CREATE")] + Create, + #[serde(rename = "CREATE2")] + Create2, +} + +impl GethCallType { + pub const fn as_str(&self) -> &'static str { + match self { + Self::Call => "CALL", + Self::StaticCall => "STATICCALL", + Self::DelegateCall => "DELEGATECALL", + Self::Create => "CREATE", + Self::Create2 => "CREATE2", + } + } + + pub const fn is_static_call(&self) -> bool { + matches!(self, Self::StaticCall) + } + + /// Converts a Parity-style call type string to a [`GethCallType`]. + pub fn from_parity_call_type(call_type: &str) -> Self { + match call_type { + "staticcall" => Self::StaticCall, + "delegatecall" => Self::DelegateCall, + _ => Self::Call, + } + } +} + +impl std::fmt::Display for GethCallType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Geth-style nested call frame returned by the `callTracer`. +#[derive(PartialEq, Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct GethCallFrame { + pub r#type: GethCallType, + pub from: EthAddress, + #[serde(skip_serializing_if = "Option::is_none")] + pub to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, + pub gas: EthUint64, + pub gas_used: EthUint64, + pub input: EthBytes, + #[serde(skip_serializing_if = "Option::is_none")] + pub output: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub revert_reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub calls: Option>, +} + +lotus_json_with_self!(GethCallFrame); + +/// Empty frame returned by the `noopTracer`. +#[derive(PartialEq, Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +pub struct NoopFrame {} + +lotus_json_with_self!(NoopFrame); + +/// Snapshot of a single account's state at a point in time. +/// All fields are optional; absent means "not relevant" or "default". +// Taken from https://github.com/alloy-rs/alloy/blob/v1.5.2/crates/rpc-types-trace/src/geth/pre_state.rs#L108 +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct AccountState { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub balance: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nonce: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub storage: BTreeMap, +} + +impl AccountState { + /// Strips fields that are identical in `other`, keeping only changed ones. + /// Used to minimize the `post` side of diff-mode output. + pub fn retain_changed(&mut self, other: &Self) { + if self.balance == other.balance { + self.balance = None; + } + if self.nonce == other.nonce { + self.nonce = None; + } + if self.code == other.code { + self.code = None; + } + self.storage.retain(|k, v| other.storage.get(k) != Some(v)); + } + + pub fn is_empty(&self) -> bool { + self.balance.is_none() + && self.code.is_none() + && self.nonce.is_none() + && self.storage.is_empty() + } +} + +/// Returns the account states necessary to execute a given transaction. +// Taken from https://github.com/alloy-rs/alloy/blob/v1.5.2/crates/rpc-types-trace/src/geth/pre_state.rs#L72 +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(transparent)] +pub struct PreStateMode(pub BTreeMap); + +lotus_json_with_self!(PreStateMode); + +/// Account state differences between the transaction's pre and post-state. +// Taken from https://github.com/alloy-rs/alloy/blob/v1.5.2/crates/rpc-types-trace/src/geth/pre_state.rs#L88 +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct DiffMode { + /// account state before the transaction is executed. + pub pre: BTreeMap, + /// account state after the transaction is executed. + pub post: BTreeMap, +} + +lotus_json_with_self!(DiffMode); + +/// Return type for the `prestateTracer`. +// Taken from https://github.com/alloy-rs/alloy/blob/v1.5.2/crates/rpc-types-trace/src/geth/pre_state.rs#L33 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum PreStateFrame { + /// Default mode: returns the accounts necessary to execute a given transaction. + Default(PreStateMode), + /// Diff mode: returns the differences between the transaction's pre and post-state. + Diff(DiffMode), +} + +lotus_json_with_self!(PreStateFrame); + +/// Tracing response objects +#[derive(PartialEq, Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum GethTrace { + /// Response object for the call tracer. + Call(GethCallFrame), + /// Response object for the flat call tracer. + FlatCall(Vec), + /// Response object for the prestate tracer. + PreState(PreStateFrame), + /// Response object for the noop tracer. + Noop(NoopFrame), +} + +lotus_json_with_self!(GethTrace); + /// Selects which trace outputs to include in the `trace_call` response. #[derive(PartialEq, Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] @@ -136,6 +426,102 @@ pub struct EthTrace { pub error: Option, } +impl EthTrace { + pub fn is_success(&self) -> bool { + self.error.is_none() + } + + /// Returns true if the trace is a revert error. + /// + /// This is not a complete check for reverted traces (there are other possible revert reasons). + pub fn is_reverted(&self) -> bool { + self.error + .as_deref() + .is_some_and(|e| e == PARITY_TRACE_REVERT_ERROR) + } + + /// Converts the Parity-format error stored in this trace to the Geth-format. + pub fn to_geth_error(&self) -> Option { + self.error.as_deref().map(|error| { + if error == PARITY_TRACE_REVERT_ERROR { + GETH_TRACE_REVERT_ERROR.into() + } else { + error.to_string() + } + }) + } + + /// Converts a Parity-style [`EthTrace`] into a Geth-style [`GethCallFrame`]. + // Code taken from https://github.com/paradigmxyz/revm-inspectors/blob/v0.36.0/src/tracing/types.rs#L430 + pub fn to_geth_frame(self, call_type: GethCallType) -> anyhow::Result { + let is_success = self.is_success(); + let is_revert = self.is_reverted(); + let error = self.to_geth_error(); + + match (self.action, self.result) { + (TraceAction::Call(action), TraceResult::Call(result)) => { + let mut frame = GethCallFrame { + r#type: call_type.clone(), + from: action.from, + to: action.to, + value: if call_type.is_static_call() { + None + } else { + Some(action.value) + }, + gas: action.gas, + gas_used: result.gas_used, + input: action.input, + output: (!result.output.is_empty()).then_some(result.output.clone()), + error: None, + revert_reason: None, + calls: None, + }; + + if !is_success { + if !is_revert { + frame.gas_used = action.gas; + frame.output = None; + } else { + frame.revert_reason = extract_revert_reason(&result.output); + } + frame.error = error; + } + + Ok(frame) + } + (TraceAction::Create(action), TraceResult::Create(result)) => { + let mut frame = GethCallFrame { + r#type: call_type, + from: action.from, + to: result.address, + value: Some(action.value), + gas: action.gas, + gas_used: result.gas_used, + input: action.init, + output: (!result.code.is_empty()).then_some(result.code.clone()), + error: None, + revert_reason: None, + calls: None, + }; + + if !is_success { + frame.to = None; + if !is_revert { + frame.gas_used = action.gas; + frame.output = None; + } else { + frame.revert_reason = extract_revert_reason(&result.code); + } + frame.error = error; + } + Ok(frame) + } + _ => anyhow::bail!("mismatched trace action and result types"), + } + } +} + #[derive(Eq, Hash, PartialEq, Default, Serialize, Deserialize, Debug, Clone, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct EthBlockTrace { diff --git a/src/rpc/methods/eth/trace/utils.rs b/src/rpc/methods/eth/trace/utils.rs new file mode 100644 index 000000000000..54919c90227b --- /dev/null +++ b/src/rpc/methods/eth/trace/utils.rs @@ -0,0 +1,32 @@ +// Copyright 2019-2026 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT + +use crate::rpc::eth::trace::geth::GETH_TRACE_REVERT_ERROR; +use crate::rpc::eth::trace::parity::PARITY_TRACE_REVERT_ERROR; +use crate::rpc::eth::trace::types::{ + EthTrace, GethCallFrame, GethCallType, TraceAction, TraceResult, +}; +use crate::rpc::eth::types::{EthAddress, EthBytes, EthHash}; +use crate::rpc::eth::utils::parse_eth_revert; +use crate::rpc::state::ActorTrace; +use fil_actor_evm_state::evm_shared::v17::uints::U256; + +pub const ZERO_HASH: EthHash = EthHash(ethereum_types::H256([0u8; 32])); + +pub fn trace_to_address(trace: &ActorTrace) -> EthAddress { + if let Some(addr) = trace.state.delegated_address + && let Ok(eth_addr) = EthAddress::from_filecoin_address(&addr.into()) + { + return eth_addr; + } + EthAddress::from_actor_id(trace.id) +} + +pub fn extract_revert_reason(output: &EthBytes) -> Option { + let reason = parse_eth_revert(&output.0); + (!reason.starts_with("0x")).then_some(reason) +} + +pub fn u256_to_eth_hash(value: &U256) -> EthHash { + EthHash(ethereum_types::H256(value.to_big_endian())) +} diff --git a/src/rpc/methods/eth/utils.rs b/src/rpc/methods/eth/utils.rs index 35d2c9cc2d89..8e2a1c252753 100644 --- a/src/rpc/methods/eth/utils.rs +++ b/src/rpc/methods/eth/utils.rs @@ -184,7 +184,7 @@ const EVM_UINT_PADDING_LENGTH: usize = 24; /// Solidity's revert conventions. /// /// See https://docs.soliditylang.org/en/latest/control-structures.html#panic-via-assert-and-error-via-require -fn parse_eth_revert(data: &[u8]) -> String { +pub(crate) fn parse_eth_revert(data: &[u8]) -> String { // If it's not long enough to contain an ABI encoded response, return immediately. if data.len() < EVM_FUNC_SELECTOR_LENGTH + EVM_WORD_LENGTH { return format!("0x{}", hex::encode(data)); diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs index f7cc4bef31be..457e804218fe 100644 --- a/src/rpc/mod.rs +++ b/src/rpc/mod.rs @@ -143,6 +143,7 @@ macro_rules! for_each_rpc_method { $callback!($crate::rpc::eth::EthTraceCall); $callback!($crate::rpc::eth::EthTraceFilter); $callback!($crate::rpc::eth::EthTraceTransaction); + $callback!($crate::rpc::eth::EthDebugTraceTransaction); $callback!($crate::rpc::eth::EthTraceReplayBlockTransactions); $callback!($crate::rpc::eth::Web3ClientVersion); $callback!($crate::rpc::eth::EthSendRawTransaction); diff --git a/src/state_manager/mod.rs b/src/state_manager/mod.rs index a60b9aaa23d4..c10c975999f1 100644 --- a/src/state_manager/mod.rs +++ b/src/state_manager/mod.rs @@ -849,6 +849,112 @@ where api_invoc_result.ok_or_else(|| Error::Other("failed to replay".into())) } + /// Replays a tipset up to a target message, capturing the state root before + /// and after execution. + pub async fn replay_for_prestate( + self: &Arc, + ts: Tipset, + target_mcid: Cid, + ) -> Result<(Cid, ApiInvocResult, Cid), Error> { + let this = Arc::clone(self); + tokio::task::spawn_blocking(move || this.replay_for_prestate_blocking(ts, target_mcid)) + .await + .map_err(|e| Error::Other(format!("{e}")))? + } + + fn replay_for_prestate_blocking( + self: &Arc, + ts: Tipset, + target_mcid: Cid, + ) -> Result<(Cid, ApiInvocResult, Cid), Error> { + if ts.epoch() == 0 { + return Err(Error::Other( + "cannot trace messages in the genesis block".into(), + )); + } + + let genesis_timestamp = self.chain_store().genesis_block_header().timestamp; + let exec = TipsetExecutor::new( + self.chain_index().clone(), + self.chain_config().clone(), + self.beacon_schedule().clone(), + &self.engine, + ts.clone(), + ); + let mut no_cb = NO_CALLBACK; + let (parent_state, epoch, block_messages) = + exec.prepare_parent_state(genesis_timestamp, VMTrace::NotTraced, &mut no_cb)?; + + Ok(stacker::grow(64 << 20, || { + let mut vm = + exec.create_vm(parent_state, epoch, ts.min_timestamp(), VMTrace::NotTraced)?; + let mut processed = ahash::HashSet::default(); + + for block in block_messages.iter() { + let mut penalty = TokenAmount::zero(); + let mut gas_reward = TokenAmount::zero(); + + for msg in block.messages.iter() { + let cid = msg.cid(); + if processed.contains(&cid) { + continue; + } + + processed.insert(cid); + + if cid == target_mcid { + let pre_root = vm.flush()?; + let mut traced_vm = + exec.create_vm(pre_root, epoch, ts.min_timestamp(), VMTrace::Traced)?; + let (ret, duration) = traced_vm.apply_message(msg)?; + let post_root = traced_vm.flush()?; + + return Ok(( + pre_root, + ApiInvocResult { + msg_cid: cid, + msg: msg.message().clone(), + msg_rct: Some(ret.msg_receipt()), + error: ret.failure_info().unwrap_or_default(), + duration: duration.as_nanos().clamp(0, u64::MAX as u128) as u64, + gas_cost: MessageGasCost::default(), + execution_trace: structured::parse_events(ret.exec_trace()) + .unwrap_or_default(), + }, + post_root, + )); + } + + let (ret, _) = vm.apply_message(msg)?; + gas_reward += ret.miner_tip(); + penalty += ret.penalty(); + } + + if let Some(rew_msg) = + vm.reward_message(epoch, block.miner, block.win_count, penalty, gas_reward)? + { + let (ret, _) = vm.apply_implicit_message(&rew_msg)?; + if let Some(err) = ret.failure_info() { + bail!( + "failed to apply reward message for miner {}: {err}", + block.miner + ); + } + + // This is more of a sanity check, this should not be able to be hit. + if !ret.msg_receipt().exit_code().is_success() { + bail!( + "reward application message failed (exit: {:?})", + ret.msg_receipt().exit_code() + ); + } + } + } + + bail!("message {target_mcid} not found in tipset") + })?) + } + /// Checks the eligibility of the miner. This is used in the validation that /// a block's miner has the requirements to mine a block. pub fn eligible_to_mine( From 5b7b5804a5a614b968a19e8363303aa14a8a3762 Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Mon, 16 Mar 2026 23:40:28 +0530 Subject: [PATCH 2/7] refactor and include tests --- src/rpc/methods/eth/trace/geth.rs | 243 ++++++++++- src/rpc/methods/eth/trace/mod.rs | 11 +- src/rpc/methods/eth/trace/parity.rs | 19 +- src/rpc/methods/eth/trace/state_diff.rs | 196 ++------- src/rpc/methods/eth/trace/test_helpers.rs | 169 ++++++++ src/rpc/methods/eth/trace/types.rs | 403 +++++++++++++++++- src/rpc/methods/eth/trace/utils.rs | 5 - .../forest__rpc__tests__rpc__v1.snap | 189 ++++++++ .../forest__rpc__tests__rpc__v2.snap | 189 ++++++++ .../api_cmd/test_snapshots_ignored.txt | 1 + 10 files changed, 1222 insertions(+), 203 deletions(-) create mode 100644 src/rpc/methods/eth/trace/test_helpers.rs diff --git a/src/rpc/methods/eth/trace/geth.rs b/src/rpc/methods/eth/trace/geth.rs index 1b1deae3335f..51979a97eab2 100644 --- a/src/rpc/methods/eth/trace/geth.rs +++ b/src/rpc/methods/eth/trace/geth.rs @@ -1,29 +1,34 @@ // Copyright 2019-2026 ChainSafe Systems // SPDX-License-Identifier: Apache-2.0, MIT +//! Geth-style trace construction from Filecoin execution traces. +//! +//! Builds nested [`GethCallFrame`] trees and [`PreStateFrame`] snapshots +//! from FVM [`ExecutionTrace`] data. + +use super::super::EthBigInt; +use super::super::types::{EthAddress, EthHash}; +use super::super::utils::ActorStateEthExt; use super::Environment; -use super::types::{CallTracerConfig, GethCallFrame}; -use crate::eth::EAMMethod; -use crate::rpc::eth::EthBigInt; -use crate::rpc::eth::trace::state_diff::{diff_entry_keys, extract_evm_storage_entries}; -use crate::rpc::eth::trace::types::{ - DiffMode, EthTrace, GethCallType, PreStateFrame, PreStateMode, TraceAction, TraceResult, +use super::state_diff::{diff_entry_keys, extract_evm_storage_entries}; +use super::types::{ + CallTracerConfig, DiffMode, GethCallFrame, GethCallType, PreStateFrame, PreStateMode, + TraceAction, }; -use crate::rpc::eth::trace::utils::{ZERO_HASH, trace_to_address, u256_to_eth_hash}; -use crate::rpc::eth::types::{EthAddress, EthBytes, EthHash}; -use crate::rpc::eth::utils::{ActorStateEthExt, parse_eth_revert}; +use super::utils::{ZERO_HASH, trace_to_address, u256_to_eth_hash}; +use crate::eth::EAMMethod; use crate::rpc::state::ExecutionTrace; -use crate::shim::actors::{EVMActorStateLoad, evm, is_evm_actor}; +use crate::shim::actors::is_evm_actor; use crate::shim::address::Address; use crate::shim::state_tree::{ActorState, StateTree}; use ahash::{HashMap, HashSet}; use fil_actor_evm_state::evm_shared::v17::uints::U256; use fvm_ipld_blockstore::Blockstore; -use num_traits::FromPrimitive; +use num::FromPrimitive as _; use std::collections::BTreeMap; /// Error string used in Geth-format traces. -pub(crate) const GETH_TRACE_REVERT_ERROR: &str = "execution reverted"; +pub const GETH_TRACE_REVERT_ERROR: &str = "execution reverted"; /// Builds a Geth-style nested call frame tree from a Filecoin execution trace. /// @@ -70,7 +75,7 @@ fn build_geth_frame_recursive( } }; - let mut frame = eth_trace.to_geth_frame(call_type)?; + let mut frame = eth_trace.into_geth_frame(call_type)?; if !tracer_cfg.only_top_call.unwrap_or_default() && let Some(recurse_trace) = recurse_into && let Some(invoked_actor) = &recurse_trace.invoked_actor @@ -111,11 +116,11 @@ fn build_account_snapshot_from_entries( ) -> Option { let actor = actor?; - let nonce = Some(actor.eth_nonce(store)?); + let nonce = actor.eth_nonce(store).ok(); let code = if config.is_code_disabled() { None } else { - actor.eth_bytecode(store).ok()? + actor.eth_bytecode(store).ok().flatten() }; let storage = if config.is_storage_disabled() { BTreeMap::new() @@ -142,7 +147,7 @@ fn build_account_snapshot_from_entries( /// /// In diff mode, returns separate `pre` and `post` snapshots with unchanged /// fields and accounts stripped. -pub(crate) fn build_prestate_frame( +pub fn build_prestate_frame( store: &S, pre_state: &StateTree, post_state: &StateTree, @@ -237,3 +242,209 @@ pub(crate) fn build_prestate_frame( Ok(PreStateFrame::Default(PreStateMode(result))) } } + +#[cfg(test)] +mod tests { + use super::super::test_helpers::*; + use super::super::types::{PreStateConfig, PreStateFrame}; + use super::*; + use crate::rpc::eth::{EthBigInt, EthUint64}; + use ahash::HashSetExt as _; + use num::BigInt; + + #[test] + fn test_build_prestate_frame_default_mode_empty() { + let trees = TestStateTrees::new().unwrap(); + let config = PreStateConfig::default(); + let touched = HashSet::new(); + + let frame = build_prestate_frame( + trees.store.as_ref(), + &trees.pre_state, + &trees.post_state, + &touched, + &config, + ) + .unwrap(); + + match frame { + PreStateFrame::Default(mode) => { + assert!(mode.0.is_empty()); + } + _ => panic!("Expected Default mode"), + } + } + + #[test] + fn test_build_prestate_frame_default_mode_with_actor() { + let actor_id = 5001u64; + let pre_actor = create_test_actor(1000, 5); + let post_actor = create_test_actor(2000, 6); + let trees = TestStateTrees::with_changed_actor(actor_id, pre_actor, post_actor).unwrap(); + + let config = PreStateConfig::default(); + let mut touched = HashSet::new(); + let actor_addr = create_masked_id_eth_address(actor_id); + touched.insert(actor_addr); + + let frame = build_prestate_frame( + trees.store.as_ref(), + &trees.pre_state, + &trees.post_state, + &touched, + &config, + ) + .unwrap(); + + match frame { + PreStateFrame::Default(mode) => { + assert_eq!(mode.0.len(), 1); + let snap = mode.0.get(&actor_addr).unwrap(); + // Default mode returns pre-state + assert_eq!(snap.balance, Some(EthBigInt(BigInt::from(1000)))); + assert_eq!(snap.nonce, Some(EthUint64(5))); + } + _ => panic!("Expected Default mode"), + } + } + + #[test] + fn test_build_prestate_frame_diff_mode() { + let actor_id = 5002u64; + let pre_actor = create_test_actor(1000, 5); + let post_actor = create_test_actor(2000, 6); + let trees = TestStateTrees::with_changed_actor(actor_id, pre_actor, post_actor).unwrap(); + + let config = PreStateConfig { + diff_mode: Some(true), + ..PreStateConfig::default() + }; + let mut touched = HashSet::new(); + touched.insert(create_masked_id_eth_address(actor_id)); + + let frame = build_prestate_frame( + trees.store.as_ref(), + &trees.pre_state, + &trees.post_state, + &touched, + &config, + ) + .unwrap(); + + match frame { + PreStateFrame::Diff(diff) => { + let addr = create_masked_id_eth_address(actor_id); + + // Pre should contain the original state + let pre_snap = diff.pre.get(&addr).unwrap(); + assert_eq!(pre_snap.balance, Some(EthBigInt(BigInt::from(1000)))); + assert_eq!(pre_snap.nonce, Some(EthUint64(5))); + + // Post should only contain changed fields + let post_snap = diff.post.get(&addr).unwrap(); + assert_eq!(post_snap.balance, Some(EthBigInt(BigInt::from(2000)))); + assert_eq!(post_snap.nonce, Some(EthUint64(6))); + } + _ => panic!("Expected Diff mode"), + } + } + + #[test] + fn test_build_prestate_frame_diff_mode_unchanged_actor_excluded() { + let actor_id = 5003u64; + let actor = create_test_actor(1000, 5); + let trees = + TestStateTrees::with_changed_actor(actor_id, actor.clone(), actor.clone()).unwrap(); + + let config = PreStateConfig { + diff_mode: Some(true), + ..PreStateConfig::default() + }; + let mut touched = HashSet::new(); + touched.insert(create_masked_id_eth_address(actor_id)); + + let frame = build_prestate_frame( + trees.store.as_ref(), + &trees.pre_state, + &trees.post_state, + &touched, + &config, + ) + .unwrap(); + + match frame { + PreStateFrame::Diff(diff) => { + // Unchanged actors should not appear in either pre or post + assert!(diff.pre.is_empty()); + assert!(diff.post.is_empty()); + } + _ => panic!("Expected Diff mode"), + } + } + + #[test] + fn test_build_prestate_frame_diff_mode_created_actor() { + let actor_id = 5004u64; + let post_actor = create_test_actor(5000, 0); + let trees = TestStateTrees::with_created_actor(actor_id, post_actor).unwrap(); + + let config = PreStateConfig { + diff_mode: Some(true), + ..PreStateConfig::default() + }; + let mut touched = HashSet::new(); + touched.insert(create_masked_id_eth_address(actor_id)); + + let frame = build_prestate_frame( + trees.store.as_ref(), + &trees.pre_state, + &trees.post_state, + &touched, + &config, + ) + .unwrap(); + + match frame { + PreStateFrame::Diff(diff) => { + let addr = create_masked_id_eth_address(actor_id); + // Created accounts should only appear in post + assert!(!diff.pre.contains_key(&addr)); + assert!(diff.post.contains_key(&addr)); + } + _ => panic!("Expected Diff mode"), + } + } + + #[test] + fn test_build_prestate_frame_diff_mode_deleted_actor() { + let actor_id = 5005u64; + let pre_actor = create_test_actor(3000, 10); + let trees = TestStateTrees::with_deleted_actor(actor_id, pre_actor).unwrap(); + + let config = PreStateConfig { + diff_mode: Some(true), + ..PreStateConfig::default() + }; + let mut touched = HashSet::new(); + touched.insert(create_masked_id_eth_address(actor_id)); + + let frame = build_prestate_frame( + trees.store.as_ref(), + &trees.pre_state, + &trees.post_state, + &touched, + &config, + ) + .unwrap(); + + match frame { + PreStateFrame::Diff(diff) => { + let addr = create_masked_id_eth_address(actor_id); + // Deleted accounts should only appear in pre + assert!(diff.pre.contains_key(&addr)); + assert!(!diff.post.contains_key(&addr)); + } + _ => panic!("Expected Diff mode"), + } + } +} diff --git a/src/rpc/methods/eth/trace/mod.rs b/src/rpc/methods/eth/trace/mod.rs index 2a802620b6f4..8dba2abfb735 100644 --- a/src/rpc/methods/eth/trace/mod.rs +++ b/src/rpc/methods/eth/trace/mod.rs @@ -6,6 +6,7 @@ //! Submodules: //! - [`parity`] — builds Parity-compatible [`types::EthTrace`] entries from //! FVM execution traces. +//! - [`geth`] - builds Geth-compatible entries from the FVM execution traces. //! - [`state_diff`] — computes account-level state diffs between pre/post //! execution. //! - [`types`] — shared type definitions for all `trace_*` RPC responses. @@ -13,6 +14,8 @@ mod geth; mod parity; mod state_diff; +#[cfg(test)] +mod test_helpers; pub(crate) mod types; mod utils; @@ -34,11 +37,11 @@ use crate::shim::{address::Address, state_tree::StateTree}; /// the current caller, collected traces, and subtrace count. #[derive(Default)] pub(super) struct Environment { - pub(in crate::rpc::methods::eth::trace) caller: EthAddress, - pub(in crate::rpc::methods::eth::trace) is_evm: bool, - pub(in crate::rpc::methods::eth::trace) subtrace_count: i64, + pub(super) caller: EthAddress, + pub(super) is_evm: bool, + pub(super) subtrace_count: i64, pub(super) traces: Vec, - pub(in crate::rpc::methods::eth::trace) last_byte_code: Option, + pub(super) last_byte_code: Option, } pub(super) fn base_environment( diff --git a/src/rpc/methods/eth/trace/parity.rs b/src/rpc/methods/eth/trace/parity.rs index a5bc95181e17..5a03ed6050f1 100644 --- a/src/rpc/methods/eth/trace/parity.rs +++ b/src/rpc/methods/eth/trace/parity.rs @@ -9,22 +9,18 @@ use super::super::types::{EthAddress, EthBytes, EthHash}; use super::super::utils::{decode_params, decode_return}; -use super::super::{ - decode_payload, encode_filecoin_params_as_abi, encode_filecoin_returns_as_abi, - lookup_eth_address, -}; +use super::super::{decode_payload, encode_filecoin_params_as_abi, encode_filecoin_returns_as_abi}; use super::Environment; use super::types::{ EthCallTraceAction, EthCallTraceResult, EthCreateTraceAction, EthCreateTraceResult, EthTrace, TraceAction, TraceResult, }; +use super::utils::trace_to_address; use crate::eth::{EAMMethod, EVMMethod}; -use crate::rpc::eth::trace::utils::trace_to_address; use crate::rpc::methods::state::ExecutionTrace; -use crate::rpc::state::ActorTrace; use crate::shim::fvm_shared_latest::METHOD_CONSTRUCTOR; use crate::shim::{actors::is_evm_actor, address::Address, error::ExitCode, state_tree::StateTree}; -use anyhow::{Context, bail}; +use anyhow::bail; use fil_actor_eam_state::v12 as eam12; use fil_actor_evm_state::v15 as evm12; use fil_actor_init_state::v12::ExecReturn; @@ -44,7 +40,7 @@ const PARITY_EVM_BAD_JUMPDEST: &str = "invalid jump destination"; const PARITY_EVM_SELFDESTRUCT_FAILED: &str = "self destruct failed"; const PARITY_EVM_OUT_OF_GAS: &str = "out of gas"; -/// Returns true if the trace is a call to an EVM or EAM actor. +/// Returns `true` if the invoked actor is an EVM contract or the Ethereum Account Manager. fn trace_is_evm_or_eam(trace: &ExecutionTrace) -> bool { if let Some(invoked_actor) = &trace.invoked_actor { is_evm_actor(&invoked_actor.state.code) @@ -54,7 +50,8 @@ fn trace_is_evm_or_eam(trace: &ExecutionTrace) -> bool { } } -/// Converts trace error codes into the `parity` errors +/// Converts a trace's exit code into a human-readable Parity-style error string. +/// Returns `None` when the trace completed successfully. fn trace_err_msg(trace: &ExecutionTrace) -> Option { let code = trace.msg_rct.exit_code; @@ -149,7 +146,7 @@ pub fn build_traces( // `build_trace` processes the passed execution trace and updates the environment, if necessary. // // On success, it returns a trace to add (or `None` to skip) and the trace to recurse into (or `None` to skip). -pub(crate) fn build_trace( +pub fn build_trace( env: &mut Environment, address: &[i64], trace: ExecutionTrace, @@ -618,7 +615,7 @@ fn trace_evm_private( } } -pub(in crate::rpc::methods::eth) struct TipsetTraceEntry { +pub struct TipsetTraceEntry { pub tx_hash: EthHash, pub msg_position: i64, pub invoc_result: crate::rpc::state::ApiInvocResult, diff --git a/src/rpc/methods/eth/trace/state_diff.rs b/src/rpc/methods/eth/trace/state_diff.rs index a722525d4e29..9f86c1ea28ce 100644 --- a/src/rpc/methods/eth/trace/state_diff.rs +++ b/src/rpc/methods/eth/trace/state_diff.rs @@ -6,11 +6,11 @@ //! Compares pre- and post-execution actor states to produce per-account diffs //! covering balance, nonce, code, and storage. +use super::super::EthBigInt; use super::super::types::{EthAddress, EthHash}; use super::super::utils::ActorStateEthExt as _; use super::types::{AccountDiff, ChangedType, Delta, StateDiff}; -use crate::rpc::eth::EthBigInt; -use crate::rpc::eth::trace::utils::{ZERO_HASH, u256_to_eth_hash}; +use super::utils::{ZERO_HASH, u256_to_eth_hash}; use crate::shim::actors::{EVMActorStateLoad as _, evm, is_evm_actor}; use crate::shim::state_tree::{ActorState, StateTree}; use ahash::{HashMap, HashSet}; @@ -45,7 +45,7 @@ impl AsHashedKey for EvmStateHashAlgorithm { type EvmStorageKamt = Kamt; /// Build state diff by comparing pre and post-execution states for touched addresses. -pub(crate) fn build_state_diff( +pub fn build_state_diff( store: &S, pre_state: &StateTree, post_state: &StateTree, @@ -244,7 +244,7 @@ pub fn extract_evm_storage_entries( } // Compute the set of storage keys that differ between pre and post actor states. -pub(crate) fn diff_entry_keys( +pub fn diff_entry_keys( pre_entries: &HashMap<[u8; 32], U256>, post_entries: &HashMap<[u8; 32], U256>, ) -> HashSet<[u8; 32]> { @@ -270,173 +270,17 @@ pub(crate) fn diff_entry_keys( #[cfg(test)] mod tests { + use super::super::test_helpers::*; use super::*; use crate::db::MemoryDB; - use crate::networks::ACTOR_BUNDLES_METADATA; use crate::rpc::eth::EthUint64; use crate::rpc::eth::types::EthBytes; use crate::shim::address::Address as FilecoinAddress; - use crate::shim::econ::TokenAmount; - use crate::shim::machine::BuiltinActor; use crate::shim::state_tree::StateTreeVersion; - use crate::utils::db::CborStoreExt as _; use ahash::HashSetExt as _; - use cid::Cid; use num::BigInt; use std::sync::Arc; - fn create_test_actor(balance_atto: u64, sequence: u64) -> ActorState { - ActorState::new( - Cid::default(), // Non-EVM actor code CID - Cid::default(), // State CID (not used for non-EVM) - TokenAmount::from_atto(balance_atto), - sequence, - None, // No delegated address - ) - } - - fn get_evm_actor_code_cid() -> Option { - for bundle in ACTOR_BUNDLES_METADATA.values() { - if bundle.actor_major_version().ok() == Some(17) - && let Ok(cid) = bundle.manifest.get(BuiltinActor::EVM) - { - return Some(cid); - } - } - None - } - - fn create_evm_actor_with_bytecode( - store: &MemoryDB, - balance_atto: u64, - actor_sequence: u64, - evm_nonce: u64, - bytecode: Option<&[u8]>, - ) -> Option { - use fvm_ipld_blockstore::Blockstore as _; - - let evm_code_cid = get_evm_actor_code_cid()?; - - // Store bytecode as raw bytes (not CBOR-encoded) - let bytecode_cid = if let Some(code) = bytecode { - use multihash_codetable::MultihashDigest; - let mh = multihash_codetable::Code::Blake2b256.digest(code); - let cid = Cid::new_v1(fvm_ipld_encoding::IPLD_RAW, mh); - store.put_keyed(&cid, code).ok()?; - cid - } else { - Cid::default() - }; - - let bytecode_hash = if let Some(code) = bytecode { - use keccak_hash::keccak; - let hash = keccak(code); - fil_actor_evm_state::v17::BytecodeHash::from(hash.0) - } else { - fil_actor_evm_state::v17::BytecodeHash::EMPTY - }; - - let evm_state = fil_actor_evm_state::v17::State { - bytecode: bytecode_cid, - bytecode_hash, - contract_state: Cid::default(), - transient_data: None, - nonce: evm_nonce, - tombstone: None, - }; - - let state_cid = store.put_cbor_default(&evm_state).ok()?; - - Some(ActorState::new( - evm_code_cid, - state_cid, - TokenAmount::from_atto(balance_atto), - actor_sequence, - None, - )) - } - - fn create_masked_id_eth_address(actor_id: u64) -> EthAddress { - EthAddress::from_actor_id(actor_id) - } - - struct TestStateTrees { - store: Arc, - pre_state: StateTree, - post_state: StateTree, - } - - impl TestStateTrees { - fn new() -> anyhow::Result { - let store = Arc::new(MemoryDB::default()); - // Use V4 which creates FvmV2 state trees that allow direct set_actor - let pre_state = StateTree::new(store.clone(), StateTreeVersion::V5)?; - let post_state = StateTree::new(store.clone(), StateTreeVersion::V5)?; - Ok(Self { - store, - pre_state, - post_state, - }) - } - - /// Create state trees with different actors in pre and post. - fn with_changed_actor( - actor_id: u64, - pre_actor: ActorState, - post_actor: ActorState, - ) -> anyhow::Result { - let store = Arc::new(MemoryDB::default()); - let mut pre_state = StateTree::new(store.clone(), StateTreeVersion::V5)?; - let mut post_state = StateTree::new(store.clone(), StateTreeVersion::V5)?; - let addr = FilecoinAddress::new_id(actor_id); - pre_state.set_actor(&addr, pre_actor)?; - post_state.set_actor(&addr, post_actor)?; - Ok(Self { - store, - pre_state, - post_state, - }) - } - - /// Create state trees with actor only in post (creation scenario). - fn with_created_actor(actor_id: u64, post_actor: ActorState) -> anyhow::Result { - let store = Arc::new(MemoryDB::default()); - let pre_state = StateTree::new(store.clone(), StateTreeVersion::V5)?; - let mut post_state = StateTree::new(store.clone(), StateTreeVersion::V5)?; - let addr = FilecoinAddress::new_id(actor_id); - post_state.set_actor(&addr, post_actor)?; - Ok(Self { - store, - pre_state, - post_state, - }) - } - - /// Create state trees with actor only in pre (deletion scenario). - fn with_deleted_actor(actor_id: u64, pre_actor: ActorState) -> anyhow::Result { - let store = Arc::new(MemoryDB::default()); - let mut pre_state = StateTree::new(store.clone(), StateTreeVersion::V5)?; - let post_state = StateTree::new(store.clone(), StateTreeVersion::V5)?; - let addr = FilecoinAddress::new_id(actor_id); - pre_state.set_actor(&addr, pre_actor)?; - Ok(Self { - store, - pre_state, - post_state, - }) - } - - /// Build state diff for given touched addresses. - fn build_diff(&self, touched_addresses: &HashSet) -> anyhow::Result { - build_state_diff( - self.store.as_ref(), - &self.pre_state, - &self.post_state, - touched_addresses, - ) - } - } - #[test] fn test_build_state_diff_empty_touched_addresses() { let trees = TestStateTrees::new().unwrap(); @@ -895,4 +739,34 @@ mod tests { let result = actor.eth_bytecode(store.as_ref()).unwrap(); assert!(result.is_none()); } + + #[test] + fn test_diff_entry_keys_both_empty() { + let pre = HashMap::default(); + let post = HashMap::default(); + let keys = diff_entry_keys(&pre, &post); + assert!(keys.is_empty()); + } + + #[test] + fn test_diff_entry_keys_non_evm_actors() { + let store = MemoryDB::default(); + let pre = create_test_actor(1000, 5); + let post = create_test_actor(2000, 6); + // Non-EVM actors have no EVM storage, so no changed keys + let pre_entries = extract_evm_storage_entries(&store, Some(&pre)); + let post_entries = extract_evm_storage_entries(&store, Some(&post)); + let keys = diff_entry_keys(&pre_entries, &post_entries); + assert!(keys.is_empty()); + } + + #[test] + fn test_diff_entry_keys_pre_none_post_non_evm() { + let store = MemoryDB::default(); + let post = create_test_actor(1000, 0); + let pre_entries = extract_evm_storage_entries(&store, None); + let post_entries = extract_evm_storage_entries(&store, Some(&post)); + let keys = diff_entry_keys(&pre_entries, &post_entries); + assert!(keys.is_empty()); + } } diff --git a/src/rpc/methods/eth/trace/test_helpers.rs b/src/rpc/methods/eth/trace/test_helpers.rs new file mode 100644 index 000000000000..421bff698402 --- /dev/null +++ b/src/rpc/methods/eth/trace/test_helpers.rs @@ -0,0 +1,169 @@ +// Copyright 2019-2026 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT + +//! Shared test fixtures for the trace module. + +use crate::db::MemoryDB; +use crate::networks::ACTOR_BUNDLES_METADATA; +use crate::rpc::eth::trace::state_diff::build_state_diff; +use crate::rpc::eth::trace::types::StateDiff; +use crate::rpc::eth::types::EthAddress; +use crate::shim::address::Address as FilecoinAddress; +use crate::shim::econ::TokenAmount; +use crate::shim::machine::BuiltinActor; +use crate::shim::state_tree::{ActorState, StateTree, StateTreeVersion}; +use crate::utils::db::CborStoreExt as _; +use ahash::HashSet; +use cid::Cid; +use std::sync::Arc; + +pub fn create_test_actor(balance_atto: u64, sequence: u64) -> ActorState { + ActorState::new( + Cid::default(), // Non-EVM actor code CID + Cid::default(), // State CID (not used for non-EVM) + TokenAmount::from_atto(balance_atto), + sequence, + None, // No delegated address + ) +} + +pub fn get_evm_actor_code_cid() -> Option { + for bundle in ACTOR_BUNDLES_METADATA.values() { + if bundle.actor_major_version().ok() == Some(17) + && let Ok(cid) = bundle.manifest.get(BuiltinActor::EVM) + { + return Some(cid); + } + } + None +} + +pub fn create_evm_actor_with_bytecode( + store: &MemoryDB, + balance_atto: u64, + actor_sequence: u64, + evm_nonce: u64, + bytecode: Option<&[u8]>, +) -> Option { + use fvm_ipld_blockstore::Blockstore as _; + + let evm_code_cid = get_evm_actor_code_cid()?; + + // Store bytecode as raw bytes (not CBOR-encoded) + let bytecode_cid = if let Some(code) = bytecode { + use multihash_codetable::MultihashDigest; + let mh = multihash_codetable::Code::Blake2b256.digest(code); + let cid = Cid::new_v1(fvm_ipld_encoding::IPLD_RAW, mh); + store.put_keyed(&cid, code).ok()?; + cid + } else { + Cid::default() + }; + + let bytecode_hash = if let Some(code) = bytecode { + use keccak_hash::keccak; + let hash = keccak(code); + fil_actor_evm_state::v17::BytecodeHash::from(hash.0) + } else { + fil_actor_evm_state::v17::BytecodeHash::EMPTY + }; + + let evm_state = fil_actor_evm_state::v17::State { + bytecode: bytecode_cid, + bytecode_hash, + contract_state: Cid::default(), + transient_data: None, + nonce: evm_nonce, + tombstone: None, + }; + + let state_cid = store.put_cbor_default(&evm_state).ok()?; + + Some(ActorState::new( + evm_code_cid, + state_cid, + TokenAmount::from_atto(balance_atto), + actor_sequence, + None, + )) +} + +pub fn create_masked_id_eth_address(actor_id: u64) -> EthAddress { + EthAddress::from_actor_id(actor_id) +} + +pub struct TestStateTrees { + pub store: Arc, + pub pre_state: StateTree, + pub post_state: StateTree, +} + +impl TestStateTrees { + pub fn new() -> anyhow::Result { + let store = Arc::new(MemoryDB::default()); + let pre_state = StateTree::new(store.clone(), StateTreeVersion::V5)?; + let post_state = StateTree::new(store.clone(), StateTreeVersion::V5)?; + Ok(Self { + store, + pre_state, + post_state, + }) + } + + /// Create state trees with different actors in pre and post. + pub fn with_changed_actor( + actor_id: u64, + pre_actor: ActorState, + post_actor: ActorState, + ) -> anyhow::Result { + let store = Arc::new(MemoryDB::default()); + let mut pre_state = StateTree::new(store.clone(), StateTreeVersion::V5)?; + let mut post_state = StateTree::new(store.clone(), StateTreeVersion::V5)?; + let addr = FilecoinAddress::new_id(actor_id); + pre_state.set_actor(&addr, pre_actor)?; + post_state.set_actor(&addr, post_actor)?; + Ok(Self { + store, + pre_state, + post_state, + }) + } + + /// Create state trees with actor only in post (creation scenario). + pub fn with_created_actor(actor_id: u64, post_actor: ActorState) -> anyhow::Result { + let store = Arc::new(MemoryDB::default()); + let pre_state = StateTree::new(store.clone(), StateTreeVersion::V5)?; + let mut post_state = StateTree::new(store.clone(), StateTreeVersion::V5)?; + let addr = FilecoinAddress::new_id(actor_id); + post_state.set_actor(&addr, post_actor)?; + Ok(Self { + store, + pre_state, + post_state, + }) + } + + /// Create state trees with actor only in pre (deletion scenario). + pub fn with_deleted_actor(actor_id: u64, pre_actor: ActorState) -> anyhow::Result { + let store = Arc::new(MemoryDB::default()); + let mut pre_state = StateTree::new(store.clone(), StateTreeVersion::V5)?; + let post_state = StateTree::new(store.clone(), StateTreeVersion::V5)?; + let addr = FilecoinAddress::new_id(actor_id); + pre_state.set_actor(&addr, pre_actor)?; + Ok(Self { + store, + pre_state, + post_state, + }) + } + + /// Build state diff for given touched addresses. + pub fn build_diff(&self, touched_addresses: &HashSet) -> anyhow::Result { + build_state_diff( + self.store.as_ref(), + &self.pre_state, + &self.post_state, + touched_addresses, + ) + } +} diff --git a/src/rpc/methods/eth/trace/types.rs b/src/rpc/methods/eth/trace/types.rs index e969841c06ae..65d09a762906 100644 --- a/src/rpc/methods/eth/trace/types.rs +++ b/src/rpc/methods/eth/trace/types.rs @@ -9,12 +9,12 @@ use super::super::types::{EthAddress, EthAddressList, EthBytes, EthHash}; use super::super::{EthBigInt, EthUint64}; use crate::lotus_json::lotus_json_with_self; +use crate::rpc::eth::trace::utils::extract_revert_reason; +use crate::rpc::eth::trace::{GETH_TRACE_REVERT_ERROR, PARITY_TRACE_REVERT_ERROR}; use anyhow::{Result, bail}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; -use crate::rpc::eth::trace::{GETH_TRACE_REVERT_ERROR, PARITY_TRACE_REVERT_ERROR}; -use crate::rpc::eth::trace::utils::extract_revert_reason; #[derive(Eq, Hash, PartialEq, Default, Serialize, Deserialize, Debug, Clone, JsonSchema)] #[serde(rename_all = "camelCase")] @@ -114,7 +114,7 @@ impl GethDebugTracingOptions { /// Extracts and validates the `callTracer` config. /// Returns an error if an unsupported flag (e.g. `withLog`) is set to true. pub fn call_config(&self) -> anyhow::Result { - let cfg = parse_tracer_config::(&self.tracer_config); + let cfg = parse_tracer_config::(self.tracer_config.as_ref()); if cfg.with_log.unwrap_or(false) { anyhow::bail!("callTracer: withLog is not yet supported"); } @@ -123,14 +123,14 @@ impl GethDebugTracingOptions { /// Extracts the `prestateTracer` config, defaulting to no-op values when absent. pub fn prestate_config(&self) -> PreStateConfig { - parse_tracer_config::(&self.tracer_config) + parse_tracer_config::(self.tracer_config.as_ref()) } } /// Parses a tracer-specific config from the opaque [`TracerConfig`] JSON blob. /// Returns `T::default()` when the config is absent or null, and logs a warning /// if the config is present but fails to deserialize. -fn parse_tracer_config(raw: &Option) -> T { +fn parse_tracer_config(raw: Option<&TracerConfig>) -> T { let Some(cfg) = raw.as_ref().filter(|c| !c.0.is_null()) else { return T::default(); }; @@ -453,7 +453,7 @@ impl EthTrace { /// Converts a Parity-style [`EthTrace`] into a Geth-style [`GethCallFrame`]. // Code taken from https://github.com/paradigmxyz/revm-inspectors/blob/v0.36.0/src/tracing/types.rs#L430 - pub fn to_geth_frame(self, call_type: GethCallType) -> anyhow::Result { + pub fn into_geth_frame(self, call_type: GethCallType) -> anyhow::Result { let is_success = self.is_success(); let is_revert = self.is_reverted(); let error = self.to_geth_error(); @@ -738,6 +738,7 @@ lotus_json_with_self!(StateDiff); #[cfg(test)] mod tests { use super::*; + use num_bigint::BigInt; #[test] fn test_changed_type_serialization() { @@ -813,4 +814,394 @@ mod tests { let changed: Delta = serde_json::from_str(r#"{"*":{"from":10,"to":20}}"#).unwrap(); assert_eq!(changed, Delta::Changed(ChangedType { from: 10, to: 20 })); } + + #[test] + fn test_account_state_is_empty() { + assert!(AccountState::default().is_empty()); + + assert!( + !AccountState { + balance: Some(EthBigInt(BigInt::from(100))), + ..Default::default() + } + .is_empty() + ); + + assert!( + !AccountState { + nonce: Some(EthUint64(1)), + ..Default::default() + } + .is_empty() + ); + + assert!( + !AccountState { + code: Some(EthBytes(vec![0x60])), + ..Default::default() + } + .is_empty() + ); + + let mut with_storage = AccountState::default(); + with_storage.storage.insert( + EthHash(ethereum_types::H256::zero()), + EthHash(ethereum_types::H256::from_low_u64_be(1)), + ); + assert!(!with_storage.is_empty()); + } + + #[test] + fn test_account_state_retain_changed_strips_identical_fields() { + let pre = AccountState { + balance: Some(EthBigInt(num::BigInt::from(1000))), + nonce: Some(EthUint64(5)), + code: Some(EthBytes(vec![0x60])), + storage: BTreeMap::new(), + }; + + // Post identical to pre: everything stripped + let mut post = pre.clone(); + post.retain_changed(&pre); + assert!(post.is_empty()); + } + + #[test] + fn test_account_state_retain_changed_keeps_different_fields() { + let pre = AccountState { + balance: Some(EthBigInt(num::BigInt::from(1000))), + nonce: Some(EthUint64(5)), + code: Some(EthBytes(vec![0x60])), + storage: BTreeMap::new(), + }; + + let mut post = AccountState { + balance: Some(EthBigInt(BigInt::from(2000))), // changed + nonce: Some(EthUint64(5)), // same + code: Some(EthBytes(vec![0x60, 0x80])), // changed + storage: BTreeMap::new(), + }; + + post.retain_changed(&pre); + assert!( + post.balance + .is_some_and(|b| b.eq(&EthBigInt(BigInt::from(2000)))) + ); + assert!(post.nonce.is_none()); // stripped + assert!(post.code.is_some_and(|b| b.eq(&EthBytes(vec![0x60, 0x80])))); + } + + #[test] + fn test_account_state_retain_changed_storage_diff() { + let slot = EthHash(ethereum_types::H256::from_low_u64_be(1)); + let val_a = EthHash(ethereum_types::H256::from_low_u64_be(100)); + let val_b = EthHash(ethereum_types::H256::from_low_u64_be(200)); + + let mut pre_storage = BTreeMap::new(); + pre_storage.insert(slot, val_a); + + let pre = AccountState { + storage: pre_storage, + ..Default::default() + }; + + // Same slot, same value -> stripped + let mut post_same = AccountState { + storage: { + let mut m = BTreeMap::new(); + m.insert(slot, val_a); + m + }, + ..Default::default() + }; + post_same.retain_changed(&pre); + assert!(post_same.storage.is_empty()); + + // Same slot, different value -> kept + let mut post_diff = AccountState { + storage: { + let mut m = BTreeMap::new(); + m.insert(slot, val_b); + m + }, + ..Default::default() + }; + post_diff.retain_changed(&pre); + assert_eq!(post_diff.storage.len(), 1); + assert_eq!(post_diff.storage[&slot], val_b); + } + + #[test] + fn test_account_diff_is_unchanged() { + assert!(AccountDiff::default().is_unchanged()); + assert!( + !AccountDiff { + balance: Delta::Added(EthBigInt(num::BigInt::from(1))), + ..Default::default() + } + .is_unchanged() + ); + assert!( + !AccountDiff { + nonce: Delta::Changed(ChangedType { + from: EthUint64(0), + to: EthUint64(1), + }), + ..Default::default() + } + .is_unchanged() + ); + let mut with_storage = AccountDiff::default(); + with_storage.storage.insert( + EthHash(ethereum_types::H256::zero()), + Delta::Added(EthHash(ethereum_types::H256::from_low_u64_be(1))), + ); + assert!(!with_storage.is_unchanged()); + } + #[test] + fn test_state_diff_insert_if_changed() { + let mut sd = StateDiff::new(); + let addr = EthAddress::default(); + // Unchanged diff is not inserted + sd.insert_if_changed(addr, AccountDiff::default()); + assert!(sd.0.is_empty()); + // Changed diff is inserted + let changed = AccountDiff { + balance: Delta::Added(EthBigInt(num::BigInt::from(100))), + ..Default::default() + }; + sd.insert_if_changed(addr, changed); + assert_eq!(sd.0.len(), 1); + } + #[test] + fn test_prestate_config_defaults() { + let cfg = PreStateConfig { + diff_mode: None, + disable_code: None, + disable_storage: None, + }; + assert!(!cfg.is_diff_mode()); + assert!(!cfg.is_code_disabled()); + assert!(!cfg.is_storage_disabled()); + } + + #[test] + fn test_prestate_config_enabled() { + let cfg = PreStateConfig { + diff_mode: Some(true), + disable_code: Some(true), + disable_storage: Some(true), + }; + assert!(cfg.is_diff_mode()); + assert!(cfg.is_code_disabled()); + assert!(cfg.is_storage_disabled()); + } + + #[test] + fn test_prestate_config_explicit_false() { + let cfg = PreStateConfig { + diff_mode: Some(false), + disable_code: Some(false), + disable_storage: Some(false), + }; + assert!(!cfg.is_diff_mode()); + assert!(!cfg.is_code_disabled()); + assert!(!cfg.is_storage_disabled()); + } + + #[test] + fn test_geth_call_type_from_parity_call_type() { + assert_eq!( + GethCallType::from_parity_call_type("staticcall"), + GethCallType::StaticCall + ); + assert_eq!( + GethCallType::from_parity_call_type("delegatecall"), + GethCallType::DelegateCall + ); + assert_eq!( + GethCallType::from_parity_call_type("call"), + GethCallType::Call + ); + // Unknown types default to Call + assert_eq!( + GethCallType::from_parity_call_type("unknown"), + GethCallType::Call + ); + assert_eq!(GethCallType::from_parity_call_type(""), GethCallType::Call); + } + + #[test] + fn test_geth_call_type_is_static_call() { + assert!(GethCallType::StaticCall.is_static_call()); + assert!(!GethCallType::Call.is_static_call()); + assert!(!GethCallType::DelegateCall.is_static_call()); + assert!(!GethCallType::Create.is_static_call()); + assert!(!GethCallType::Create2.is_static_call()); + } + #[test] + fn test_geth_call_type_serialization() { + assert_eq!( + serde_json::to_string(&GethCallType::Call).unwrap(), + r#""CALL""# + ); + assert_eq!( + serde_json::to_string(&GethCallType::StaticCall).unwrap(), + r#""STATICCALL""# + ); + assert_eq!( + serde_json::to_string(&GethCallType::DelegateCall).unwrap(), + r#""DELEGATECALL""# + ); + assert_eq!( + serde_json::to_string(&GethCallType::Create).unwrap(), + r#""CREATE""# + ); + assert_eq!( + serde_json::to_string(&GethCallType::Create2).unwrap(), + r#""CREATE2""# + ); + } + + #[test] + fn test_eth_trace_to_geth_frame_successful_call() { + let from = EthAddress::default(); + let to = EthAddress::from_actor_id(100); + + let trace = EthTrace { + r#type: "call".into(), + action: TraceAction::Call(EthCallTraceAction { + call_type: "call".into(), + from, + to: Some(to), + gas: EthUint64(21000), + value: EthBigInt(num::BigInt::from(1000)), + input: EthBytes(vec![0x01, 0x02]), + }), + result: TraceResult::Call(EthCallTraceResult { + gas_used: EthUint64(5000), + output: EthBytes(vec![0x03]), + }), + error: None, + ..EthTrace::default() + }; + + let frame = trace.into_geth_frame(GethCallType::Call).unwrap(); + assert_eq!(frame.r#type, GethCallType::Call); + assert_eq!(frame.from, from); + assert_eq!(frame.to, Some(to)); + assert_eq!(frame.gas.0, 21000); + assert_eq!(frame.gas_used.0, 5000); + assert!(frame.error.is_none()); + assert!(frame.revert_reason.is_none()); + assert_eq!(frame.value, Some(EthBigInt(num::BigInt::from(1000)))); + } + + #[test] + fn test_eth_trace_to_geth_frame_static_call_no_value() { + let trace = EthTrace { + r#type: "call".into(), + action: TraceAction::Call(EthCallTraceAction { + call_type: "staticcall".into(), + from: EthAddress::default(), + to: Some(EthAddress::from_actor_id(100)), + gas: EthUint64(21000), + value: EthBigInt(num::BigInt::from(0)), + input: EthBytes(vec![]), + }), + result: TraceResult::Call(EthCallTraceResult { + gas_used: EthUint64(100), + output: EthBytes(vec![]), + }), + error: None, + ..EthTrace::default() + }; + + let frame = trace.into_geth_frame(GethCallType::StaticCall).unwrap(); + // Static calls omit the value field + assert!(frame.value.is_none()); + } + + #[test] + fn test_eth_trace_to_geth_frame_reverted_call() { + let trace = EthTrace { + r#type: "call".into(), + action: TraceAction::Call(EthCallTraceAction { + call_type: "call".into(), + from: EthAddress::default(), + to: Some(EthAddress::from_actor_id(100)), + gas: EthUint64(21000), + value: EthBigInt(num::BigInt::from(0)), + input: EthBytes(vec![]), + }), + result: TraceResult::Call(EthCallTraceResult { + gas_used: EthUint64(100), + output: EthBytes(vec![]), + }), + error: Some(PARITY_TRACE_REVERT_ERROR.into()), + ..EthTrace::default() + }; + + let frame = trace.into_geth_frame(GethCallType::Call).unwrap(); + assert!(frame.error.is_some()); + assert_eq!( + frame.error.as_deref(), + Some(GETH_TRACE_REVERT_ERROR) // to_geth_error converts + ); + } + + #[test] + fn test_eth_trace_to_geth_frame_successful_create() { + let from = EthAddress::default(); + let created = EthAddress::from_actor_id(200); + let init_code = EthBytes(vec![0x60, 0x80]); + let trace = EthTrace { + r#type: "create".into(), + action: TraceAction::Create(EthCreateTraceAction { + from, + gas: EthUint64(100000), + value: EthBigInt(num::BigInt::from(0)), + init: init_code.clone(), + }), + result: TraceResult::Create(EthCreateTraceResult { + gas_used: EthUint64(50000), + address: Some(created), + code: EthBytes(vec![0xFE]), + }), + error: None, + ..EthTrace::default() + }; + + let frame = trace.into_geth_frame(GethCallType::Create).unwrap(); + assert_eq!(frame.r#type, GethCallType::Create); + assert_eq!(frame.from, from); + assert_eq!(frame.to, Some(created)); + assert_eq!(frame.input.0, init_code.0); // initcode goes to input + assert!(frame.error.is_none()); + } + + #[test] + fn test_eth_trace_to_geth_frame_mismatched_action_result() { + // Call action with Create result should fail + let trace = EthTrace { + r#type: "call".into(), + action: TraceAction::Call(EthCallTraceAction { + call_type: "call".into(), + from: EthAddress::default(), + to: None, + gas: EthUint64(0), + value: EthBigInt(num::BigInt::from(0)), + input: EthBytes(vec![]), + }), + result: TraceResult::Create(EthCreateTraceResult { + gas_used: EthUint64(0), + address: None, + code: EthBytes(vec![]), + }), + error: None, + ..EthTrace::default() + }; + + assert!(trace.into_geth_frame(GethCallType::Call).is_err()); + } } diff --git a/src/rpc/methods/eth/trace/utils.rs b/src/rpc/methods/eth/trace/utils.rs index 54919c90227b..63a913d25991 100644 --- a/src/rpc/methods/eth/trace/utils.rs +++ b/src/rpc/methods/eth/trace/utils.rs @@ -1,11 +1,6 @@ // Copyright 2019-2026 ChainSafe Systems // SPDX-License-Identifier: Apache-2.0, MIT -use crate::rpc::eth::trace::geth::GETH_TRACE_REVERT_ERROR; -use crate::rpc::eth::trace::parity::PARITY_TRACE_REVERT_ERROR; -use crate::rpc::eth::trace::types::{ - EthTrace, GethCallFrame, GethCallType, TraceAction, TraceResult, -}; use crate::rpc::eth::types::{EthAddress, EthBytes, EthHash}; use crate::rpc::eth::utils::parse_eth_revert; use crate::rpc::state::ActorTrace; diff --git a/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap b/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap index 36040a8d3123..e1cdf91bae37 100644 --- a/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap +++ b/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap @@ -1669,6 +1669,44 @@ methods: items: $ref: "#/components/schemas/EthBlockTrace" paramStructure: by-position + - name: Forest.EthDebugTraceTransaction + description: Replays a transaction and returns execution traces in Geth-compatible format. + params: + - name: txHash + required: true + schema: + type: string + - name: opts + required: true + schema: + anyOf: + - $ref: "#/components/schemas/GethDebugTracingOptions" + - type: "null" + result: + name: Forest.EthDebugTraceTransaction.Result + required: true + schema: + $ref: "#/components/schemas/GethTrace" + paramStructure: by-position + - name: debug_traceTransaction + description: Replays a transaction and returns execution traces in Geth-compatible format. + params: + - name: txHash + required: true + schema: + type: string + - name: opts + required: true + schema: + anyOf: + - $ref: "#/components/schemas/GethDebugTracingOptions" + - type: "null" + result: + name: debug_traceTransaction.Result + required: true + schema: + $ref: "#/components/schemas/GethTrace" + paramStructure: by-position - name: Filecoin.EthTraceReplayBlockTransactions description: Replays all transactions in a block returning the requested traces for each transaction. params: @@ -4374,6 +4412,26 @@ components: - code - nonce - storage + AccountState: + description: "Snapshot of a single account's state at a point in time.\nAll fields are optional; absent means \"not relevant\" or \"default\"." + type: object + properties: + balance: + anyOf: + - $ref: "#/components/schemas/EthBigInt" + - type: "null" + code: + anyOf: + - $ref: "#/components/schemas/EthBytes" + - type: "null" + nonce: + anyOf: + - $ref: "#/components/schemas/EthUint64" + - type: "null" + storage: + type: object + additionalProperties: + $ref: "#/components/schemas/EthHash" ActorEvent: type: object properties: @@ -5422,6 +5480,23 @@ components: additionalProperties: false required: - "*" + DiffMode: + description: "Account state differences between the transaction's pre and post-state." + type: object + properties: + post: + description: account state after the transaction is executed. + type: object + additionalProperties: + $ref: "#/components/schemas/AccountState" + pre: + description: account state before the transaction is executed. + type: object + additionalProperties: + $ref: "#/components/schemas/AccountState" + required: + - pre + - post ECTipSet: type: object properties: @@ -6511,6 +6586,103 @@ components: - cg - sg - tt + GethCallFrame: + description: "Geth-style nested call frame returned by the `callTracer`." + type: object + properties: + calls: + type: + - array + - "null" + items: + $ref: "#/components/schemas/GethCallFrame" + error: + type: + - string + - "null" + from: + $ref: "#/components/schemas/EthAddress" + gas: + $ref: "#/components/schemas/EthUint64" + gasUsed: + $ref: "#/components/schemas/EthUint64" + input: + $ref: "#/components/schemas/EthBytes" + output: + anyOf: + - $ref: "#/components/schemas/EthBytes" + - type: "null" + revertReason: + type: + - string + - "null" + to: + anyOf: + - $ref: "#/components/schemas/EthAddress" + - type: "null" + type: + $ref: "#/components/schemas/GethCallType" + value: + anyOf: + - $ref: "#/components/schemas/EthBigInt" + - type: "null" + required: + - type + - from + - gas + - gasUsed + - input + GethCallType: + description: "EVM call/create operation type for Geth-style trace frames.\n\nMaps to the EVM opcodes: CALL, STATICCALL, DELEGATECALL, CREATE, CREATE2.\nUsed as the `type` field in [`GethCallFrame`]." + type: string + enum: + - CALL + - STATICCALL + - DELEGATECALL + - CREATE + - CREATE2 + GethDebugBuiltInTracerType: + description: The Available built-in tracer. + oneOf: + - description: "The call tracer builds a hierarchical call tree, showing the hierarchy of calls (e.g., `call`, `create`, `reward`)" + type: string + const: callTracer + - description: "The flat call tracer builds a flat list of all calls, showing the hierarchy of calls (e.g., `call`, `create`, `reward`)" + type: string + const: flatCallTracer + - description: "The prestate tracer builds a state snapshot of the accounts necessary to execute the transaction, and the state after the transaction." + type: string + const: prestateTracer + - description: The noop tracer does not build any traces. + type: string + const: noopTracer + GethDebugTracingOptions: + description: "Options for the `debug_traceTransaction` API." + type: object + properties: + tracer: + description: The tracer to use for the transaction. + anyOf: + - $ref: "#/components/schemas/GethDebugBuiltInTracerType" + - type: "null" + tracerConfig: + description: "The configuration for the provided tracer.\nThe configuration is a JSON object that is specific to the tracer." + anyOf: + - $ref: "#/components/schemas/TracerConfig" + - type: "null" + GethTrace: + description: Tracing response objects + anyOf: + - description: Response object for the call tracer. + $ref: "#/components/schemas/GethCallFrame" + - description: Response object for the flat call tracer. + type: array + items: + $ref: "#/components/schemas/EthBlockTrace" + - description: Response object for the prestate tracer. + $ref: "#/components/schemas/PreStateFrame" + - description: Response object for the noop tracer. + $ref: "#/components/schemas/NoopFrame" GossipBlock: type: object properties: @@ -7064,6 +7236,9 @@ components: minItems: 1 Nonce: type: string + NoopFrame: + description: "Empty frame returned by the `noopTracer`." + type: object Nullable_Address: anyOf: - $ref: "#/components/schemas/Address" @@ -7291,6 +7466,18 @@ components: - ParticipantID - PowerDelta - SigningKey + PreStateFrame: + description: "Return type for the `prestateTracer`." + anyOf: + - description: "Default mode: returns the accounts necessary to execute a given transaction." + $ref: "#/components/schemas/PreStateMode" + - description: "Diff mode: returns the differences between the transaction's pre and post-state." + $ref: "#/components/schemas/DiffMode" + PreStateMode: + description: Returns the account states necessary to execute a given transaction. + type: object + additionalProperties: + $ref: "#/components/schemas/AccountState" PubSubConfig: type: object properties: @@ -7676,6 +7863,8 @@ components: anyOf: - $ref: "#/components/schemas/EthCallTraceResult" - $ref: "#/components/schemas/EthCreateTraceResult" + TracerConfig: + description: Opaque JSON blob for per-tracer configuration. Transaction: type: object properties: diff --git a/src/rpc/snapshots/forest__rpc__tests__rpc__v2.snap b/src/rpc/snapshots/forest__rpc__tests__rpc__v2.snap index 20ab1274023f..e79961f0ed8f 100644 --- a/src/rpc/snapshots/forest__rpc__tests__rpc__v2.snap +++ b/src/rpc/snapshots/forest__rpc__tests__rpc__v2.snap @@ -1264,6 +1264,44 @@ methods: items: $ref: "#/components/schemas/EthBlockTrace" paramStructure: by-position + - name: Forest.EthDebugTraceTransaction + description: Replays a transaction and returns execution traces in Geth-compatible format. + params: + - name: txHash + required: true + schema: + type: string + - name: opts + required: true + schema: + anyOf: + - $ref: "#/components/schemas/GethDebugTracingOptions" + - type: "null" + result: + name: Forest.EthDebugTraceTransaction.Result + required: true + schema: + $ref: "#/components/schemas/GethTrace" + paramStructure: by-position + - name: debug_traceTransaction + description: Replays a transaction and returns execution traces in Geth-compatible format. + params: + - name: txHash + required: true + schema: + type: string + - name: opts + required: true + schema: + anyOf: + - $ref: "#/components/schemas/GethDebugTracingOptions" + - type: "null" + result: + name: debug_traceTransaction.Result + required: true + schema: + $ref: "#/components/schemas/GethTrace" + paramStructure: by-position - name: Filecoin.EthTraceReplayBlockTransactions description: Replays all transactions in a block returning the requested traces for each transaction. params: @@ -1468,6 +1506,26 @@ components: - code - nonce - storage + AccountState: + description: "Snapshot of a single account's state at a point in time.\nAll fields are optional; absent means \"not relevant\" or \"default\"." + type: object + properties: + balance: + anyOf: + - $ref: "#/components/schemas/EthBigInt" + - type: "null" + code: + anyOf: + - $ref: "#/components/schemas/EthBytes" + - type: "null" + nonce: + anyOf: + - $ref: "#/components/schemas/EthUint64" + - type: "null" + storage: + type: object + additionalProperties: + $ref: "#/components/schemas/EthHash" ActorState: type: object properties: @@ -1940,6 +1998,23 @@ components: additionalProperties: false required: - "*" + DiffMode: + description: "Account state differences between the transaction's pre and post-state." + type: object + properties: + post: + description: account state after the transaction is executed. + type: object + additionalProperties: + $ref: "#/components/schemas/AccountState" + pre: + description: account state before the transaction is executed. + type: object + additionalProperties: + $ref: "#/components/schemas/AccountState" + required: + - pre + - post ElectionProof: type: object properties: @@ -2470,6 +2545,103 @@ components: - tipset_keys - skip_checksum - dry_run + GethCallFrame: + description: "Geth-style nested call frame returned by the `callTracer`." + type: object + properties: + calls: + type: + - array + - "null" + items: + $ref: "#/components/schemas/GethCallFrame" + error: + type: + - string + - "null" + from: + $ref: "#/components/schemas/EthAddress" + gas: + $ref: "#/components/schemas/EthUint64" + gasUsed: + $ref: "#/components/schemas/EthUint64" + input: + $ref: "#/components/schemas/EthBytes" + output: + anyOf: + - $ref: "#/components/schemas/EthBytes" + - type: "null" + revertReason: + type: + - string + - "null" + to: + anyOf: + - $ref: "#/components/schemas/EthAddress" + - type: "null" + type: + $ref: "#/components/schemas/GethCallType" + value: + anyOf: + - $ref: "#/components/schemas/EthBigInt" + - type: "null" + required: + - type + - from + - gas + - gasUsed + - input + GethCallType: + description: "EVM call/create operation type for Geth-style trace frames.\n\nMaps to the EVM opcodes: CALL, STATICCALL, DELEGATECALL, CREATE, CREATE2.\nUsed as the `type` field in [`GethCallFrame`]." + type: string + enum: + - CALL + - STATICCALL + - DELEGATECALL + - CREATE + - CREATE2 + GethDebugBuiltInTracerType: + description: The Available built-in tracer. + oneOf: + - description: "The call tracer builds a hierarchical call tree, showing the hierarchy of calls (e.g., `call`, `create`, `reward`)" + type: string + const: callTracer + - description: "The flat call tracer builds a flat list of all calls, showing the hierarchy of calls (e.g., `call`, `create`, `reward`)" + type: string + const: flatCallTracer + - description: "The prestate tracer builds a state snapshot of the accounts necessary to execute the transaction, and the state after the transaction." + type: string + const: prestateTracer + - description: The noop tracer does not build any traces. + type: string + const: noopTracer + GethDebugTracingOptions: + description: "Options for the `debug_traceTransaction` API." + type: object + properties: + tracer: + description: The tracer to use for the transaction. + anyOf: + - $ref: "#/components/schemas/GethDebugBuiltInTracerType" + - type: "null" + tracerConfig: + description: "The configuration for the provided tracer.\nThe configuration is a JSON object that is specific to the tracer." + anyOf: + - $ref: "#/components/schemas/TracerConfig" + - type: "null" + GethTrace: + description: Tracing response objects + anyOf: + - description: Response object for the call tracer. + $ref: "#/components/schemas/GethCallFrame" + - description: Response object for the flat call tracer. + type: array + items: + $ref: "#/components/schemas/EthBlockTrace" + - description: Response object for the prestate tracer. + $ref: "#/components/schemas/PreStateFrame" + - description: Response object for the noop tracer. + $ref: "#/components/schemas/NoopFrame" NonEmpty_Array_of_BlockHeader: type: array items: @@ -2487,6 +2659,9 @@ components: minItems: 1 Nonce: type: string + NoopFrame: + description: "Empty frame returned by the `noopTracer`." + type: object Nullable_Address: anyOf: - $ref: "#/components/schemas/Address" @@ -2531,6 +2706,18 @@ components: required: - PoStProof - ProofBytes + PreStateFrame: + description: "Return type for the `prestateTracer`." + anyOf: + - description: "Default mode: returns the accounts necessary to execute a given transaction." + $ref: "#/components/schemas/PreStateMode" + - description: "Diff mode: returns the differences between the transaction's pre and post-state." + $ref: "#/components/schemas/DiffMode" + PreStateMode: + description: Returns the account states necessary to execute a given transaction. + type: object + additionalProperties: + $ref: "#/components/schemas/AccountState" Signature: type: object properties: @@ -2636,6 +2823,8 @@ components: anyOf: - $ref: "#/components/schemas/EthCallTraceResult" - $ref: "#/components/schemas/EthCreateTraceResult" + TracerConfig: + description: Opaque JSON blob for per-tracer configuration. Transactions: anyOf: - type: array diff --git a/src/tool/subcommands/api_cmd/test_snapshots_ignored.txt b/src/tool/subcommands/api_cmd/test_snapshots_ignored.txt index cf8319fcf33c..0c05063bfb4c 100644 --- a/src/tool/subcommands/api_cmd/test_snapshots_ignored.txt +++ b/src/tool/subcommands/api_cmd/test_snapshots_ignored.txt @@ -83,6 +83,7 @@ Forest.ChainExportDiff Forest.ChainExportStatus Forest.ChainGetMinBaseFee Forest.ChainGetTipsetByParentState +Forest.EthDebugTraceTransaction Forest.EthTraceCall Forest.NetInfo Forest.SnapshotGC From db66030d8faa136ebe4e31e7ac25c04117399b93 Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Tue, 17 Mar 2026 14:38:10 +0530 Subject: [PATCH 3/7] add docs for the debug trace transaction API --- .../guides/debug_trace_transaction_guide.md | 251 +++++++++++++ .../rpc/debug_trace_transaction.md | 352 ++++++++++++++++++ 2 files changed, 603 insertions(+) create mode 100644 docs/docs/developers/guides/debug_trace_transaction_guide.md create mode 100644 docs/docs/users/knowledge_base/rpc/debug_trace_transaction.md diff --git a/docs/docs/developers/guides/debug_trace_transaction_guide.md b/docs/docs/developers/guides/debug_trace_transaction_guide.md new file mode 100644 index 000000000000..ec265ba07582 --- /dev/null +++ b/docs/docs/developers/guides/debug_trace_transaction_guide.md @@ -0,0 +1,251 @@ +# `debug_traceTransaction` Developer Guide + +This guide covers testing and development workflows for Forest's `debug_traceTransaction` implementation. For API documentation and user-facing usage, see the [API guide](/knowledge_base/rpc/debug_trace_transaction). + +## Tracer Contract + +The [`Tracer.sol`](https://github.com/ChainSafe/forest/blob/963237708137e9c7388c57eba39a2f8bf12ace74/src/tool/subcommands/api_cmd/contracts/tracer/Tracer.sol) contract provides various functions to test different tracing scenarios. + +### Storage Layout + +| Slot | Variable | Description | +| ---- | -------------- | ---------------------------- | +| 0 | `x` | Initialized to 42 | +| 1 | `balances` | Mapping base slot | +| 2 | `storageTestA` | Starts empty (for add tests) | +| 3 | `storageTestB` | Starts empty | +| 4 | `storageTestC` | Starts empty | +| 5 | `dynamicArray` | Array length slot | + +### Function Reference + +#### Basic Operations + +| Function | Selector | Description | +| ------------------- | ------------ | --------------------------- | +| `setX(uint256)` | `0x4018d9aa` | Write to slot 0 | +| `deposit()` | `0xd0e30db0` | Receive ETH, update mapping | +| `withdraw(uint256)` | `0x2e1a7d4d` | Send ETH from contract | +| `doRevert()` | `0xafc874d2` | Always reverts | + +#### Call Tracing + +| Function | Selector | Description | +| ----------------------- | ------------ | ---------------------- | +| `callSelf(uint256)` | `0xa1a88595` | Single nested CALL | +| `delegateSelf(uint256)` | `0x8f5e07b8` | `DELEGATECALL` trace | +| `complexTrace()` | `0x6659ab96` | Multiple nested calls | +| `deepTrace(uint256)` | `0x0f3a17b8` | Recursive N-level deep | + +#### Storage Diff Testing + +| Function | Selector | Description | +| ------------------------------------------ | ------------ | -------------------- | +| `storageAdd(uint256)` | `0x55cb64b4` | Add to empty slot 2 | +| `storageChange(uint256)` | `0x7c8f6e57` | Modify existing slot | +| `storageDelete()` | `0xd92846a3` | Set slot to zero | +| `storageMultiple(uint256,uint256,uint256)` | `0x310af204` | Change slots 2,3,4 | + +### Generating Function Selectors + +Use `cast` from Foundry to generate function selectors: + +```bash +# Get selector for a function +cast sig "setX(uint256)" +# Output: 0x4018d9aa + +# Encode full calldata +cast calldata "setX(uint256)" 123 +# Output: 0x4018d9aa000000000000000000000000000000000000000000000000000000000000007b +``` + +### Deployed Contracts + +Pre-deployed Tracer contracts for quick testing: + +| Network | Contract Address | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| Calibnet | [`0x73a43475aa2ccb14246613708b399f4b2ba546c7`](https://calibration.filfox.info/en/address/0x73a43475aa2ccb14246613708b399f4b2ba546c7) | +| Mainnet | [`0x9BB686Ba6a50D1CF670a98f522a59555d4977fb2`](https://filecoin.blockscout.com/address/0x9BB686Ba6a50D1CF670a98f522a59555d4977fb2) | + +## Comparison Testing with Anvil + +Anvil uses the same **Geth style** tracing as `debug_traceTransaction`, making it ideal for direct comparison testing — verifying that Forest produces identical or semantically equivalent results. + +### Prerequisites + +- [Foundry](https://book.getfoundry.sh/getting-started/installation) installed (`forge`, `cast` commands) +- A running [Forest node](https://docs.forest.chainsafe.io/getting_started/syncing) and Anvil instance + +### What is Anvil? + +[Anvil](https://getfoundry.sh/anvil/reference/) is a local Ethereum development node included with Foundry. It provides: + +- Instant block mining +- Pre-funded test accounts (10 accounts with 10,000 ETH each) +- Support for `debug_traceTransaction` with various tracers +- No real tokens required + +### Starting Anvil + +```bash +# Start Anvil with tracer to allow `debug_traceTransaction` API's +anvil --tracing +``` + +Anvil RPC endpoint: `http://localhost:8545` + +### Deploying Contract on Anvil + +```bash +forge create src/tool/subcommands/api_cmd/contracts/tracer/Tracer.sol:Tracer \ + --rpc-url http://localhost:8545 \ + --broadcast \ + --private-key + +# Output: +# Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 +# Deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3 +# Transaction hash: 0x... +``` + +### Sending Test Transactions on Anvil + +Unlike `trace_call` (which simulates calls), `debug_traceTransaction` traces mined transactions. You must first send transactions to get transaction hashes: + +```bash +# Set variables +export ANVIL_RPC="http://localhost:8545" +export ANVIL_KEY="0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" # Anvil default key +export ANVIL_CONTRACT="0x5FbDB2315678afecb367f032d93F642f64180aa3" # Deployed address + +# Send transactions +cast send $ANVIL_CONTRACT "setX(uint256)" 123 \ + --rpc-url $ANVIL_RPC --private-key $ANVIL_KEY + +cast send $ANVIL_CONTRACT "deposit()" \ + --value 1ether --rpc-url $ANVIL_RPC --private-key $ANVIL_KEY + +cast send $ANVIL_CONTRACT "storageAdd(uint256)" 100 \ + --rpc-url $ANVIL_RPC --private-key $ANVIL_KEY + +cast send $ANVIL_CONTRACT "callSelf(uint256)" 456 \ + --rpc-url $ANVIL_RPC --private-key $ANVIL_KEY + +cast send $ANVIL_CONTRACT "storageMultiple(uint256,uint256,uint256)" 10 20 30 \ + --rpc-url $ANVIL_RPC --private-key $ANVIL_KEY +``` + +Save the transaction hashes from the output for use in the tracing examples below. + +### Getting the correct transaction hash on Forest + +`debug_traceTransaction` expects the canonical `EthHash` (0x...). If your client returned something else, resolve it first. + +- **0x... value** (e.g. from `cast send`): call `eth_getTransactionByHash` and use the response's **`hash`** field for tracing. Forest resolves via the indexer or by message lookup. +- **Literal CID** (e.g. `bafy2bzace...`): `eth_getTransactionByHash` accepts only `EthHash`. Use `eth_getTransactionHashByCid` to get the hash, then pass it to `debug_traceTransaction`. + +Example (0x... from cast): + +```bash +HASH_0X="0x..." # from cast send or block explorer +TX_HASH=$(curl -s -X POST http://localhost:2345/rpc/v1 -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"eth_getTransactionByHash","params":["'"$HASH_0X"'"]}' \ + | jq -r '.result.hash // empty') + +curl -s -X POST "http://localhost:2345/rpc/v1" -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"debug_traceTransaction","params":["'"$TX_HASH"'",{"tracer":"prestateTracer","tracerConfig":{"diffMode":true}}]}' +``` + +If you have a CID: `TX_HASH=$(curl -s ... -d '{"method":"eth_getTransactionHashByCid","params":["'"$MSG_CID"'"]}' | jq -r '.result // empty')`, then use `$TX_HASH` in the trace call above. + +### Comparing Forest vs Anvil Responses + +Both Forest and Anvil use the same `debug_traceTransaction` method and tracer format, so responses can be compared directly. + +**Forest** (use the canonical `hash` from `eth_getTransactionByHash` as described above): + +```bash +curl -s -X POST "http://localhost:2345/rpc/v1" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "debug_traceTransaction", + "params": [ + "'$TX_HASH'", + {"tracer": "prestateTracer", "tracerConfig": {"diffMode": true}} + ] + }' +``` + +**Anvil:** + +```bash +curl -s -X POST "http://localhost:8545" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "debug_traceTransaction", + "params": [ + "'$TX_HASH'", + {"tracer": "prestateTracer", "tracerConfig": {"diffMode": true}} + ] + }' +``` + +### Expected Differences Between Forest and Anvil + +When comparing `debug_traceTransaction` output from Forest and Anvil, expect these `Filecoin-specific` differences: + +| Aspect | Forest (Filecoin) | Anvil (Ethereum) | +| ---------------------- | ------------------------------------------ | ------------------------------ | +| **Extra addresses** | Includes `0xff00...` Filecoin ID addresses | Only EVM addresses | +| **Coinbase** | Not included (gas at protocol level) | Included as `0x0000...0000` | +| **Nonce format** | Hex string (e.g., `"0x1e"`) | Integer (e.g., `30`) | +| **Balance format** | Hex string (e.g., `"0xde0b6b3a7640000"`) | Hex string (same) | +| **Storage key format** | Full 32-byte padded hex | Full 32-byte padded hex (same) | + +### Test Scenarios + +When testing `debug_traceTransaction`, cover these categories: + +#### 1. `prestateTracer` Tests + +| Test Case | Function | What to Verify | +| --------------------------- | --------------------------- | ----------------------------------------------------------- | +| Simple storage write | `setX(123)` | `Pre-state` shows old value, `post-state` shows new value | +| ETH deposit | `deposit()` with value | Balance changes in `pre/post` for sender and contract | +| Storage add (empty → value) | `storageAdd(100)` | Storage slot absent in `pre`, present in `post` (diff mode) | +| Storage change | `storageChange(200)` | Storage slot values differ between `pre` and `post` | +| Multiple storage writes | `storageMultiple(10,20,30)` | Multiple storage slots change in single transaction | +| Default mode (no diff) | `setX(123)` | Only `pre-state` returned, no post object | + +#### 2. `callTracer` Tests + +| Test Case | Function | What to Verify | +| --------------- | ------------------- | -------------------------------------------- | +| Simple call | `setX(123)` | Single top-level CALL frame | +| Nested call | `callSelf(456)` | Parent CALL with child CALL in `calls` array | +| Delegate call | `delegateSelf(789)` | `DELEGATECALL` type in call frame | +| Deep recursive | `deepTrace(3)` | N-level nested CALL hierarchy | +| Complex mixed | `complexTrace()` | Mix of CALL, `DELEGATECALL`, `STATICCALL` | +| Revert at depth | `failAtDepth(3,1)` | Error field populated in failing frame | + +#### 3. `flatCallTracer` Tests + +| Test Case | Function | What to Verify | +| -------------- | --------------- | ------------------------------------------------------- | +| Simple call | `setX(123)` | Single flat trace entry | +| Nested call | `callSelf(456)` | Two entries with correct `traceAddress` and `subtraces` | +| Deep recursive | `deepTrace(3)` | Flat list with incrementing `traceAddress` depth | + +## Official Resources + +- [Geth `debug_traceTransaction`](https://geth.ethereum.org/docs/interacting-with-geth/rpc/ns-debug#debugtracetransaction) +- [Geth Built-in Tracers](https://geth.ethereum.org/docs/developers/evm-tracing/built-in-tracers) +- [Reth debug Namespace](https://reth.rs/jsonrpc/debug) +- [Foundry Book - Anvil](https://book.getfoundry.sh/reference/anvil/) +- [Foundry Book - Cast](https://book.getfoundry.sh/reference/cast/) diff --git a/docs/docs/users/knowledge_base/rpc/debug_trace_transaction.md b/docs/docs/users/knowledge_base/rpc/debug_trace_transaction.md new file mode 100644 index 000000000000..26f061e42c3c --- /dev/null +++ b/docs/docs/users/knowledge_base/rpc/debug_trace_transaction.md @@ -0,0 +1,352 @@ +# `debug_traceTransaction` API Guide + +This guide explains the `debug_traceTransaction` RPC method implemented in Forest, which follows the **[Geth debug namespace](https://geth.ethereum.org/docs/interacting-with-geth/rpc/ns-debug#debugtracetransaction)** format. + +## Overview + +`debug_traceTransaction` re-executes an existing on-chain transaction and returns detailed execution traces. Unlike `trace_call` (which simulates a call), this method traces a transaction that has already been mined. It's useful for: + +- Debugging failed or unexpected transactions +- Analyzing the full execution trace of a historical transaction +- Inspecting `pre/post` state changes caused by a specific transaction +- Understanding nested call hierarchies in complex transactions + +## Request Format + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "debug_traceTransaction", + "params": [ + "0x...", + { + "tracer": "prestateTracer", + "tracerConfig": { "diffMode": false } + } + ] +} +``` + +### Supported Tracers + +| Tracer | Description | +| ---------------- | --------------------------------------------------------------- | +| `callTracer` | Call hierarchy with inputs, outputs, gas used, and nested calls | +| `flatCallTracer` | Flattened list of all calls (no nesting) | +| `prestateTracer` | `Pre-execution` state snapshot of all touched accounts | + +If `tracer` is omitted, Forest currently defaults to `callTracer`. This differs from `Geth`, which defaults to the `opcode/struct` logger. + +### Tracer Configuration + +#### `prestateTracer` config + +| Option | Type | Default | Description | +| ----------------- | ------- | ------- | --------------------------------------------------------------------------- | +| `diffMode` | boolean | `false` | When `true`, returns both `pre` and `post` state | +| `disable_code` | boolean | `false` | When `true`, the code of the accounts will not be returned in the trace. | +| `disable_storage` | boolean | `false` | When `true`, the storage of the accounts will not be returned in the trace. | + +#### `callTracer` config + +| Option | Type | Default | Description | +| ------------- | ------- | ------- | ------------------------------------------------------------- | +| `onlyTopCall` | boolean | `false` | When `true`, only trace top call | +| `withLog` | boolean | `false` | When `true`, includes logs in the trace (not yet implemented) | + +## Response Formats + +### `prestateTracer` (Default Mode) + +Returns the `pre-execution` state of every account touched during the transaction. Each account includes only the fields that are relevant (empty fields are omitted). + +```json +{ + "result": { + "0xcontract...": { + "balance": "0xde0b6b3a7640000", + "code": "0x6080...", + "nonce": "0x1", + "storage": { + "0x0000...0000": "0x0000...002a" + } + }, + "0xsender...": { + "balance": "0x72c3a2e371dcc225", + "nonce": "0x1e" + } + } +} +``` + +**Fields per account:** + +| Field | Type | Description | +| --------- | ---------------- | -------------------------------------------------- | +| `balance` | hex string | Account balance before the transaction | +| `nonce` | hex string | Account nonce before the transaction | +| `code` | hex string | Contract bytecode (omitted for EOAs) | +| `storage` | map (hex -> hex) | Storage slots that were changed by the transaction | + +### `prestateTracer` (Diff Mode) + +When `diffMode: true`, returns separate `pre` and `post` objects showing the state before and after the transaction. + +```json +{ + "result": { + "pre": { + "0xcontract...": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x1", + "storage": { + "0x0000...0000": "0x0000...002a" + } + }, + "0xsender...": { + "balance": "0x72c3a2e371dcc225", + "nonce": "0x1e" + } + }, + "post": { + "0xcontract...": { + "storage": { + "0x0000...0000": "0x0000...007b" + } + }, + "0xsender...": { + "balance": "0x72c3a1fb5a411da3", + "nonce": "0x1f" + } + } + } +} +``` + +**Diff mode behavior:** + +- `pre` contains the state before execution for all touched accounts +- `post` contains only the fields that changed after execution +- Accounts that were deleted appear in `pre` but not in `post` +- Accounts that were created appear in `post` but not in `pre` +- Unchanged accounts are omitted from both `pre` and `post` +- Zero-value storage entries are stripped from `post` + +### `callTracer` + +Returns the call hierarchy as a nested tree of call frames. + +```json +{ + "result": { + "type": "CALL", + "from": "0xsender...", + "to": "0xcontract...", + "value": "0x0", + "gas": "0x...", + "gasUsed": "0x...", + "input": "0x4018d9aa...", + "output": "0x...", + "calls": [ + { + "type": "CALL", + "from": "0xcontract...", + "to": "0xcontract...", + "value": "0x0", + "gas": "0x...", + "gasUsed": "0x...", + "input": "0x...", + "output": "0x..." + } + ] + } +} +``` + +### `flatCallTracer` + +Returns a flat list of all call frames (equivalent to Parity-style `trace`). + +```json +{ + "result": [ + { + "type": "call", + "subtraces": 1, + "traceAddress": [], + "action": { + "callType": "call", + "from": "0x...", + "to": "0x...", + "gas": "0x...", + "value": "0x0", + "input": "0x..." + }, + "result": { + "gasUsed": "0x...", + "output": "0x..." + } + } + ] +} +``` + +## Using `debug_traceTransaction` with Forest + +### Prerequisites + +- A running Forest node — follow the [Getting Started](https://docs.forest.chainsafe.io/getting_started/syncing) guide to start and sync. +- A transaction hash from a mined transaction on the synced network. + +Forest RPC endpoint: `http://localhost:2345/rpc/v1` + +### Deployed Contracts + +A [Tracer](https://github.com/ChainSafe/forest/blob/963237708137e9c7388c57eba39a2f8bf12ace74/src/tool/subcommands/api_cmd/contracts/tracer/Tracer.sol) contract is `pre-deployed` on Calibnet and Mainnet for testing. You can send transactions to these contracts and then trace them. + +| Network | Contract Address | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| Calibnet | [`0x73a43475aa2ccb14246613708b399f4b2ba546c7`](https://calibration.filfox.info/en/address/0x73a43475aa2ccb14246613708b399f4b2ba546c7) | +| Mainnet | [`0x9BB686Ba6a50D1CF670a98f522a59555d4977fb2`](https://filecoin.blockscout.com/address/0x9BB686Ba6a50D1CF670a98f522a59555d4977fb2) | + +> **Note:** To trace a transaction, the Forest node must have the state data for the epoch containing that transaction. Recent transactions on a synced node should always be traceable. + +### Sending a Test Transaction + +To generate a transaction for tracing, send a transaction using `cast`: + +```bash +cast send $CONTRACT "setX(uint256)" 123 \ + --rpc-url http://localhost:2345/rpc/v1 \ + --private-key + +# Output: +# transactionHash: 0x90aa2b46... +``` + +Save the transaction hash for use in the tracing examples below. + +## Example curl Requests + +Before running the examples, set the following environment variables: + +```bash +export FOREST_RPC_URL="http://localhost:2345/rpc/v1" +export TX_HASH="0xYOUR_TRANSACTION_HASH" +``` + +### 1. `Prestate` Trace - Default Mode + +Returns the `pre-execution` state of all accounts touched by the transaction. + +```bash +curl -s -X POST "$FOREST_RPC_URL" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "debug_traceTransaction", + "params": [ + "'$TX_HASH'", + {"tracer": "prestateTracer"} + ] + }' | jq '.' +``` + +### 2. `Prestate` Trace - Diff Mode + +Returns both `pre` and `post` state, showing exactly what changed. + +```bash +curl -s -X POST "$FOREST_RPC_URL" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "debug_traceTransaction", + "params": [ + "'$TX_HASH'", + {"tracer": "prestateTracer", "tracerConfig": {"diffMode": true}} + ] + }' | jq '.' +``` + +### 3. Call Trace + +Returns the call hierarchy showing nested calls, gas usage, and return values. + +```bash +curl -s -X POST "$FOREST_RPC_URL" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "debug_traceTransaction", + "params": [ + "'$TX_HASH'", + {"tracer": "callTracer"} + ] + }' | jq '.' +``` + +### 4. Flat Call Trace + +Returns a flattened list of all calls (Parity-style trace format). + +```bash +curl -s -X POST "$FOREST_RPC_URL" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "debug_traceTransaction", + "params": [ + "'$TX_HASH'", + {"tracer": "flatCallTracer"} + ] + }' | jq '.' +``` + +## Troubleshooting + +### Common Issues + +1. **"message not found in tipset"**: The transaction hash may not exist on the chain, or the node may not have synced the epoch containing this transaction. +2. **"replay for `prestate` failed"**: The node does not have the state data required to re-execute the transaction. Ensure the node is synced past the epoch containing the transaction. +3. **Empty storage in `prestate`**: The contract may not be an EVM actor, or no storage slots were modified by the transaction. +4. **Extra addresses in response**: Forest may include Filecoin ID addresses (e.g., `0xff00...`) alongside EVM addresses. This is expected behavior due to `Filecoin's` dual address representation. + +### Debug Tips + +```bash +# Look up a transaction by hash +curl -s -X POST "$FOREST_RPC_URL" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"eth_getTransactionByHash","params":["'$TX_HASH'"]}' \ + | jq '.' + +# Get transaction receipt (confirms it was mined) +curl -s -X POST "$FOREST_RPC_URL" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"eth_getTransactionReceipt","params":["'$TX_HASH'"]}' \ + | jq '.result.status' +``` + +## `Filecoin-Specific` Behavior + +Forest's `debug_traceTransaction` implementation has some differences from standard Ethereum implementations due to `Filecoin's` architecture: + +| Aspect | Forest (Filecoin) | Geth (Ethereum) | +| --------------------- | ---------------------------------------------- | ------------------------------------------- | +| **ID addresses** | May include `0xff00...` Filecoin ID addresses | Only EVM addresses | +| **Coinbase** | Not included (gas handled at protocol level) | Included as `0x0000...0000` | +| **Per-message state** | Re-executes all prior messages in the tipset | Re-executes all prior transactions in block | +| **Storage model** | EVM storage via KAMT (Key-Address-Merkle-Tree) | Standard `Merkle Patricia Trie` | + +## Official Resources + +- [Geth `debug_traceTransaction`](https://geth.ethereum.org/docs/interacting-with-geth/rpc/ns-debug#debugtracetransaction) +- [Geth Built-in Tracers](https://geth.ethereum.org/docs/developers/evm-tracing/built-in-tracers) +- [Reth debug Namespace](https://reth.rs/jsonrpc/debug) +- [Foundry Book - Cast](https://book.getfoundry.sh/reference/cast/) From a15e446f413cb7a4cd02c7913063c441369e1011 Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Wed, 18 Mar 2026 15:06:52 +0530 Subject: [PATCH 4/7] address comments --- .../rpc/debug_trace_transaction.md | 6 +- src/rpc/methods/eth.rs | 9 +- src/rpc/methods/eth/trace/geth.rs | 165 +++++++++++++++--- src/rpc/methods/eth/trace/types.rs | 37 ++-- src/rpc/methods/eth/trace/utils.rs | 51 ++++++ src/state_manager/mod.rs | 18 +- 6 files changed, 237 insertions(+), 49 deletions(-) diff --git a/docs/docs/users/knowledge_base/rpc/debug_trace_transaction.md b/docs/docs/users/knowledge_base/rpc/debug_trace_transaction.md index 26f061e42c3c..6eca1f1b0b11 100644 --- a/docs/docs/users/knowledge_base/rpc/debug_trace_transaction.md +++ b/docs/docs/users/knowledge_base/rpc/debug_trace_transaction.md @@ -126,11 +126,11 @@ When `diffMode: true`, returns separate `pre` and `post` objects showing the sta **Diff mode behavior:** -- `pre` contains the state before execution for all touched accounts -- `post` contains only the fields that changed after execution +- `pre` contains the pre-execution state for accounts/fields that changed +- `post` contains only the fields that differ from `pre` after execution - Accounts that were deleted appear in `pre` but not in `post` - Accounts that were created appear in `post` but not in `pre` -- Unchanged accounts are omitted from both `pre` and `post` +- Accounts with no changes are omitted from both `pre` and `post` - Zero-value storage entries are stripped from `post` ### `callTracer` diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index 541bd3032ba5..632bca590a38 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -1236,7 +1236,7 @@ async fn new_eth_tx_receipt( block_hash: tx.block_hash, block_number: tx.block_number, r#type: tx.r#type, - status: u64::from(msg_receipt.exit_code().is_success()).into(), + status: (u64::from(msg_receipt.exit_code().is_success())).into(), gas_used: msg_receipt.gas_used().into(), ..EthTxReceipt::new() }; @@ -3509,7 +3509,7 @@ where // prestateTracer uses per-message replay for exact state boundaries, // so it does not need the full tipset trace. if tracer == GethDebugBuiltInTracerType::PreState { - let prestate_config = opts.prestate_config(); + let prestate_config = opts.prestate_config()?; let message_cid = ctx .chain_store() @@ -3589,7 +3589,10 @@ where .collect(); Ok(GethTrace::FlatCall(traces)) } - _ => unreachable!("noopTracer and prestateTracer handled above"), + _ => Err(anyhow::anyhow!( + "unexpected tracer type: noopTracer and prestateTracer should be handled above" + ) + .into()), } } diff --git a/src/rpc/methods/eth/trace/geth.rs b/src/rpc/methods/eth/trace/geth.rs index 51979a97eab2..3b31ad6536df 100644 --- a/src/rpc/methods/eth/trace/geth.rs +++ b/src/rpc/methods/eth/trace/geth.rs @@ -4,7 +4,10 @@ //! Geth-style trace construction from Filecoin execution traces. //! //! Builds nested [`GethCallFrame`] trees and [`PreStateFrame`] snapshots -//! from FVM [`ExecutionTrace`] data. +//! from FVM [`ExecutionTrace`] data, reusing Parity-style classification +//! via [`super::parity::build_trace`]. +//! +//! Tested against go-ethereum (Geth) v1.15.x behaviour. use super::super::EthBigInt; use super::super::types::{EthAddress, EthHash}; @@ -102,7 +105,7 @@ fn build_geth_frame_recursive( } /// Build an [`AccountState`] snapshot from an actor. -/// Returns `None` when the actor does not exist. +/// Returns `Ok(None)` when the actor does not exist. /// /// When `storage_filter` is provided, only storage keys in the filter set are /// included. This limits output to slots that actually changed between pre and @@ -113,14 +116,16 @@ fn build_account_snapshot_from_entries( config: &super::types::PreStateConfig, entries: &HashMap<[u8; 32], U256>, storage_filter: Option<&HashSet<[u8; 32]>>, -) -> Option { - let actor = actor?; +) -> anyhow::Result> { + let Some(actor) = actor else { + return Ok(None); + }; - let nonce = actor.eth_nonce(store).ok(); + let nonce = Some(actor.eth_nonce(store)?); let code = if config.is_code_disabled() { None } else { - actor.eth_bytecode(store).ok().flatten() + actor.eth_bytecode(store)? }; let storage = if config.is_storage_disabled() { BTreeMap::new() @@ -132,12 +137,12 @@ fn build_account_snapshot_from_entries( .collect() }; - Some(super::types::AccountState { + Ok(Some(super::types::AccountState { balance: Some(EthBigInt(actor.balance.atto().clone())), code, nonce, storage, - }) + })) } /// Build a [`PreStateFrame`] for the `prestateTracer`. @@ -168,9 +173,14 @@ pub fn build_prestate_frame( deleted_addrs.insert(*eth_addr); } - let pre_entries = extract_evm_storage_entries(store, pre_actor.as_ref()); - let post_entries = extract_evm_storage_entries(store, post_actor.as_ref()); - let changed_keys = diff_entry_keys(&pre_entries, &post_entries); + let (pre_entries, post_entries, changed_keys) = if config.is_storage_disabled() { + (HashMap::default(), HashMap::default(), HashSet::default()) + } else { + let pre = extract_evm_storage_entries(store, pre_actor.as_ref()); + let post = extract_evm_storage_entries(store, post_actor.as_ref()); + let keys = diff_entry_keys(&pre, &post); + (pre, post, keys) + }; let pre_snap = build_account_snapshot_from_entries( store, @@ -178,14 +188,14 @@ pub fn build_prestate_frame( config, &pre_entries, Some(&changed_keys), - ); + )?; let post_snap = build_account_snapshot_from_entries( store, post_actor.as_ref(), config, &post_entries, Some(&changed_keys), - ); + )?; // Created accounts (pre=None) only appear in post. if let Some(ref snap) = pre_snap { @@ -200,7 +210,10 @@ pub fn build_prestate_frame( if let Some(ref pre) = pre_snap { snap.retain_changed(pre); } - if !snap.is_empty() { + // Insert if any field changed OR if storage keys changed (pure + // storage clears leave an empty post snapshot after stripping + // zeros, but the account must still appear in the diff). + if !snap.is_empty() || !changed_keys.is_empty() { post_map.insert(*eth_addr, snap); } } @@ -224,9 +237,14 @@ pub fn build_prestate_frame( // Extract storage once per actor and derive both changed keys and // the snapshot from the cached entries. - let pre_entries = extract_evm_storage_entries(store, pre_actor.as_ref()); - let post_entries = extract_evm_storage_entries(store, post_actor.as_ref()); - let changed_keys = diff_entry_keys(&pre_entries, &post_entries); + let (pre_entries, changed_keys) = if config.is_storage_disabled() { + (HashMap::default(), HashSet::default()) + } else { + let pre = extract_evm_storage_entries(store, pre_actor.as_ref()); + let post = extract_evm_storage_entries(store, post_actor.as_ref()); + let keys = diff_entry_keys(&pre, &post); + (pre, keys) + }; if let Some(snap) = build_account_snapshot_from_entries( store, @@ -234,7 +252,7 @@ pub fn build_prestate_frame( config, &pre_entries, Some(&changed_keys), - ) { + )? { result.insert(*eth_addr, snap); } } @@ -409,7 +427,9 @@ mod tests { let addr = create_masked_id_eth_address(actor_id); // Created accounts should only appear in post assert!(!diff.pre.contains_key(&addr)); - assert!(diff.post.contains_key(&addr)); + let post_snap = diff.post.get(&addr).unwrap(); + assert_eq!(post_snap.balance, Some(EthBigInt(BigInt::from(5000)))); + assert_eq!(post_snap.nonce, Some(EthUint64(0))); } _ => panic!("Expected Diff mode"), } @@ -441,10 +461,115 @@ mod tests { PreStateFrame::Diff(diff) => { let addr = create_masked_id_eth_address(actor_id); // Deleted accounts should only appear in pre - assert!(diff.pre.contains_key(&addr)); + let pre_snap = diff.pre.get(&addr).unwrap(); + assert_eq!(pre_snap.balance, Some(EthBigInt(BigInt::from(3000)))); + assert_eq!(pre_snap.nonce, Some(EthUint64(10))); assert!(!diff.post.contains_key(&addr)); } _ => panic!("Expected Diff mode"), } } + + #[test] + fn test_build_prestate_frame_diff_mode_disable_storage() { + let actor_id = 5006u64; + let pre_actor = create_test_actor(1000, 5); + let post_actor = create_test_actor(2000, 6); + let trees = TestStateTrees::with_changed_actor(actor_id, pre_actor, post_actor).unwrap(); + + let config = PreStateConfig { + diff_mode: Some(true), + disable_storage: Some(true), + ..PreStateConfig::default() + }; + let mut touched = HashSet::new(); + touched.insert(create_masked_id_eth_address(actor_id)); + + let frame = build_prestate_frame( + trees.store.as_ref(), + &trees.pre_state, + &trees.post_state, + &touched, + &config, + ) + .unwrap(); + + match frame { + PreStateFrame::Diff(diff) => { + let addr = create_masked_id_eth_address(actor_id); + let pre_snap = diff.pre.get(&addr).unwrap(); + let post_snap = diff.post.get(&addr).unwrap(); + // Balance/nonce reflect actual values, storage skipped + assert_eq!(pre_snap.balance, Some(EthBigInt(BigInt::from(1000)))); + assert_eq!(pre_snap.nonce, Some(EthUint64(5))); + assert!(pre_snap.storage.is_empty()); + assert_eq!(post_snap.balance, Some(EthBigInt(BigInt::from(2000)))); + assert_eq!(post_snap.nonce, Some(EthUint64(6))); + assert!(post_snap.storage.is_empty()); + } + _ => panic!("Expected Diff mode"), + } + } + + #[test] + fn test_build_prestate_frame_default_mode_disable_storage() { + let actor_id = 5007u64; + let pre_actor = create_test_actor(1000, 5); + let post_actor = create_test_actor(2000, 6); + let trees = TestStateTrees::with_changed_actor(actor_id, pre_actor, post_actor).unwrap(); + + let config = PreStateConfig { + disable_storage: Some(true), + ..PreStateConfig::default() + }; + let mut touched = HashSet::new(); + let actor_addr = create_masked_id_eth_address(actor_id); + touched.insert(actor_addr); + + let frame = build_prestate_frame( + trees.store.as_ref(), + &trees.pre_state, + &trees.post_state, + &touched, + &config, + ) + .unwrap(); + + match frame { + PreStateFrame::Default(mode) => { + assert_eq!(mode.0.len(), 1); + let snap = mode.0.get(&actor_addr).unwrap(); + assert_eq!(snap.balance, Some(EthBigInt(BigInt::from(1000)))); + assert_eq!(snap.nonce, Some(EthUint64(5))); + assert!(snap.storage.is_empty()); + } + _ => panic!("Expected Default mode"), + } + } + + #[test] + fn test_build_prestate_frame_default_mode_nonexistent_actor() { + // Touched address where the actor doesn't exist in either pre or post state. + let trees = TestStateTrees::new().unwrap(); + let config = PreStateConfig::default(); + let mut touched = HashSet::new(); + touched.insert(create_masked_id_eth_address(9999)); + + let frame = build_prestate_frame( + trees.store.as_ref(), + &trees.pre_state, + &trees.post_state, + &touched, + &config, + ) + .unwrap(); + + match frame { + PreStateFrame::Default(mode) => { + // Actor doesn't exist, so no snapshot produced + assert!(mode.0.is_empty()); + } + _ => panic!("Expected Default mode"), + } + } } diff --git a/src/rpc/methods/eth/trace/types.rs b/src/rpc/methods/eth/trace/types.rs index 65d09a762906..a31bfff25474 100644 --- a/src/rpc/methods/eth/trace/types.rs +++ b/src/rpc/methods/eth/trace/types.rs @@ -11,7 +11,7 @@ use super::super::{EthBigInt, EthUint64}; use crate::lotus_json::lotus_json_with_self; use crate::rpc::eth::trace::utils::extract_revert_reason; use crate::rpc::eth::trace::{GETH_TRACE_REVERT_ERROR, PARITY_TRACE_REVERT_ERROR}; -use anyhow::{Result, bail}; +use anyhow::{Context as _, Result, bail}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -114,7 +114,7 @@ impl GethDebugTracingOptions { /// Extracts and validates the `callTracer` config. /// Returns an error if an unsupported flag (e.g. `withLog`) is set to true. pub fn call_config(&self) -> anyhow::Result { - let cfg = parse_tracer_config::(self.tracer_config.as_ref()); + let cfg = parse_tracer_config::(self.tracer_config.as_ref())?; if cfg.with_log.unwrap_or(false) { anyhow::bail!("callTracer: withLog is not yet supported"); } @@ -122,25 +122,21 @@ impl GethDebugTracingOptions { } /// Extracts the `prestateTracer` config, defaulting to no-op values when absent. - pub fn prestate_config(&self) -> PreStateConfig { + pub fn prestate_config(&self) -> anyhow::Result { parse_tracer_config::(self.tracer_config.as_ref()) } } /// Parses a tracer-specific config from the opaque [`TracerConfig`] JSON blob. -/// Returns `T::default()` when the config is absent or null, and logs a warning -/// if the config is present but fails to deserialize. -fn parse_tracer_config(raw: Option<&TracerConfig>) -> T { +/// Returns `T::default()` when the config is absent or null, and returns an +/// error if the config is present but fails to deserialize. +fn parse_tracer_config( + raw: Option<&TracerConfig>, +) -> anyhow::Result { let Some(cfg) = raw.as_ref().filter(|c| !c.0.is_null()) else { - return T::default(); + return Ok(T::default()); }; - serde_json::from_value(cfg.0.clone()).unwrap_or_else(|e| { - tracing::warn!( - error = %e, - "invalid tracerConfig — using defaults" - ); - T::default() - }) + serde_json::from_value(cfg.0.clone()).context("invalid tracerConfig") } /// Configuration for the `callTracer`. @@ -1118,8 +1114,14 @@ mod tests { }; let frame = trace.into_geth_frame(GethCallType::StaticCall).unwrap(); + assert_eq!(frame.r#type, GethCallType::StaticCall); + assert_eq!(frame.from, EthAddress::default()); + assert_eq!(frame.to, Some(EthAddress::from_actor_id(100))); + assert_eq!(frame.gas.0, 21000); + assert_eq!(frame.gas_used.0, 100); // Static calls omit the value field assert!(frame.value.is_none()); + assert!(frame.error.is_none()); } #[test] @@ -1143,11 +1145,12 @@ mod tests { }; let frame = trace.into_geth_frame(GethCallType::Call).unwrap(); - assert!(frame.error.is_some()); assert_eq!( frame.error.as_deref(), Some(GETH_TRACE_REVERT_ERROR) // to_geth_error converts ); + // On revert, gas_used stays as the result's value (not overridden to action.gas) + assert_eq!(frame.gas_used.0, 100); } #[test] @@ -1176,7 +1179,11 @@ mod tests { assert_eq!(frame.r#type, GethCallType::Create); assert_eq!(frame.from, from); assert_eq!(frame.to, Some(created)); + assert_eq!(frame.gas.0, 100000); + assert_eq!(frame.gas_used.0, 50000); + assert_eq!(frame.value, Some(EthBigInt(num::BigInt::from(0)))); assert_eq!(frame.input.0, init_code.0); // initcode goes to input + assert_eq!(frame.output, Some(EthBytes(vec![0xFE]))); // deployed code assert!(frame.error.is_none()); } diff --git a/src/rpc/methods/eth/trace/utils.rs b/src/rpc/methods/eth/trace/utils.rs index 63a913d25991..d4dee96ac3d3 100644 --- a/src/rpc/methods/eth/trace/utils.rs +++ b/src/rpc/methods/eth/trace/utils.rs @@ -6,8 +6,11 @@ use crate::rpc::eth::utils::parse_eth_revert; use crate::rpc::state::ActorTrace; use fil_actor_evm_state::evm_shared::v17::uints::U256; +/// The zero-valued `EthHash`, used as a sentinel for cleared storage slots. pub const ZERO_HASH: EthHash = EthHash(ethereum_types::H256([0u8; 32])); +/// Resolves an actor trace to its Ethereum address. +/// Prefers the delegated (0x) address when available, falls back to a masked actor ID. pub fn trace_to_address(trace: &ActorTrace) -> EthAddress { if let Some(addr) = trace.state.delegated_address && let Ok(eth_addr) = EthAddress::from_filecoin_address(&addr.into()) @@ -17,11 +20,59 @@ pub fn trace_to_address(trace: &ActorTrace) -> EthAddress { EthAddress::from_actor_id(trace.id) } +/// Parses a human-readable revert reason from EVM output bytes. +/// Returns `None` if the output cannot be decoded or is a raw hex string. pub fn extract_revert_reason(output: &EthBytes) -> Option { let reason = parse_eth_revert(&output.0); (!reason.starts_with("0x")).then_some(reason) } +/// Converts a `U256` value to an `EthHash` using big-endian byte order. pub fn u256_to_eth_hash(value: &U256) -> EthHash { EthHash(ethereum_types::H256(value.to_big_endian())) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_u256_to_eth_hash_zero() { + let zero = U256::from(0u64); + assert_eq!(u256_to_eth_hash(&zero), ZERO_HASH); + } + + #[test] + fn test_u256_to_eth_hash_nonzero() { + let val = U256::from(1u64); + let hash = u256_to_eth_hash(&val); + // Big-endian: value 1 should be in the last byte + assert_eq!(hash.0.0[31], 1); + assert_eq!(hash.0.0[0], 0); + } + + #[test] + fn test_u256_to_eth_hash_max() { + let max = U256::MAX; + let hash = u256_to_eth_hash(&max); + assert!(hash.0.0.iter().all(|&b| b == 0xff)); + } + + #[test] + fn test_extract_revert_reason_empty() { + assert!(extract_revert_reason(&EthBytes(vec![])).is_none()); + } + + #[test] + fn test_extract_revert_reason_hex_passthrough() { + // parse_eth_revert returns "0x..." for unrecognized selectors, + // which extract_revert_reason filters out. + let unknown = EthBytes(vec![0xde, 0xad, 0xbe, 0xef]); + assert!(extract_revert_reason(&unknown).is_none()); + } + + #[test] + fn test_zero_hash_is_all_zeros() { + assert_eq!(ZERO_HASH.0, ethereum_types::H256([0u8; 32])); + } +} diff --git a/src/state_manager/mod.rs b/src/state_manager/mod.rs index c10c975999f1..4bb8bbbb6daa 100644 --- a/src/state_manager/mod.rs +++ b/src/state_manager/mod.rs @@ -854,18 +854,20 @@ where pub async fn replay_for_prestate( self: &Arc, ts: Tipset, - target_mcid: Cid, + target_message_cid: Cid, ) -> Result<(Cid, ApiInvocResult, Cid), Error> { let this = Arc::clone(self); - tokio::task::spawn_blocking(move || this.replay_for_prestate_blocking(ts, target_mcid)) - .await - .map_err(|e| Error::Other(format!("{e}")))? + tokio::task::spawn_blocking(move || { + this.replay_for_prestate_blocking(ts, target_message_cid) + }) + .await + .map_err(|e| Error::Other(format!("{e}")))? } fn replay_for_prestate_blocking( self: &Arc, ts: Tipset, - target_mcid: Cid, + target_msg_cid: Cid, ) -> Result<(Cid, ApiInvocResult, Cid), Error> { if ts.epoch() == 0 { return Err(Error::Other( @@ -902,7 +904,7 @@ where processed.insert(cid); - if cid == target_mcid { + if cid == target_msg_cid { let pre_root = vm.flush()?; let mut traced_vm = exec.create_vm(pre_root, epoch, ts.min_timestamp(), VMTrace::Traced)?; @@ -916,7 +918,7 @@ where msg: msg.message().clone(), msg_rct: Some(ret.msg_receipt()), error: ret.failure_info().unwrap_or_default(), - duration: duration.as_nanos().clamp(0, u64::MAX as u128) as u64, + duration: duration.as_nanos().clamp(0, u128::from(u64::MAX)) as u64, gas_cost: MessageGasCost::default(), execution_trace: structured::parse_events(ret.exec_trace()) .unwrap_or_default(), @@ -951,7 +953,7 @@ where } } - bail!("message {target_mcid} not found in tipset") + bail!("message {target_msg_cid} not found in tipset") })?) } From c4da3ee587068ebb6122efab4e1971156379a5a1 Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Wed, 18 Mar 2026 15:30:46 +0530 Subject: [PATCH 5/7] small fix --- src/rpc/methods/eth/trace/parity.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/rpc/methods/eth/trace/parity.rs b/src/rpc/methods/eth/trace/parity.rs index 5a03ed6050f1..865a16da15bc 100644 --- a/src/rpc/methods/eth/trace/parity.rs +++ b/src/rpc/methods/eth/trace/parity.rs @@ -31,14 +31,14 @@ use tracing::debug; /// Error string used in Parity-format traces. pub const PARITY_TRACE_REVERT_ERROR: &str = "Reverted"; -const PARITY_EVM_INVALID_INSTRUCTION: &str = "invalid instruction"; -const PARITY_EVM_UNDEFINED_INSTRUCTION: &str = "undefined instruction"; -const PARITY_EVM_STACK_UNDERFLOW: &str = "stack underflow"; -const PARITY_EVM_STACK_OVERFLOW: &str = "stack overflow"; -const PARITY_EVM_ILLEGAL_MEMORY_ACCESS: &str = "illegal memory access"; -const PARITY_EVM_BAD_JUMPDEST: &str = "invalid jump destination"; -const PARITY_EVM_SELFDESTRUCT_FAILED: &str = "self destruct failed"; -const PARITY_EVM_OUT_OF_GAS: &str = "out of gas"; +pub const PARITY_EVM_INVALID_INSTRUCTION: &str = "invalid instruction"; +pub const PARITY_EVM_UNDEFINED_INSTRUCTION: &str = "undefined instruction"; +pub const PARITY_EVM_STACK_UNDERFLOW: &str = "stack underflow"; +pub const PARITY_EVM_STACK_OVERFLOW: &str = "stack overflow"; +pub const PARITY_EVM_ILLEGAL_MEMORY_ACCESS: &str = "illegal memory access"; +pub const PARITY_EVM_BAD_JUMPDEST: &str = "invalid jump destination"; +pub const PARITY_EVM_SELFDESTRUCT_FAILED: &str = "self destruct failed"; +pub const PARITY_EVM_OUT_OF_GAS: &str = "out of gas"; /// Returns `true` if the invoked actor is an EVM contract or the Ethereum Account Manager. fn trace_is_evm_or_eam(trace: &ExecutionTrace) -> bool { From 7739f710a3922a02f095c6a83cb3f1d77a249186 Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Tue, 24 Mar 2026 16:40:31 +0530 Subject: [PATCH 6/7] address comments --- src/rpc/methods/eth/trace/types.rs | 81 ++++++++++++------------------ 1 file changed, 32 insertions(+), 49 deletions(-) diff --git a/src/rpc/methods/eth/trace/types.rs b/src/rpc/methods/eth/trace/types.rs index a31bfff25474..393f6eef6740 100644 --- a/src/rpc/methods/eth/trace/types.rs +++ b/src/rpc/methods/eth/trace/types.rs @@ -735,6 +735,7 @@ lotus_json_with_self!(StateDiff); mod tests { use super::*; use num_bigint::BigInt; + use rstest::rstest; #[test] fn test_changed_type_serialization() { @@ -1005,58 +1006,40 @@ mod tests { assert!(!cfg.is_storage_disabled()); } - #[test] - fn test_geth_call_type_from_parity_call_type() { - assert_eq!( - GethCallType::from_parity_call_type("staticcall"), - GethCallType::StaticCall - ); - assert_eq!( - GethCallType::from_parity_call_type("delegatecall"), - GethCallType::DelegateCall - ); - assert_eq!( - GethCallType::from_parity_call_type("call"), - GethCallType::Call - ); - // Unknown types default to Call - assert_eq!( - GethCallType::from_parity_call_type("unknown"), - GethCallType::Call - ); - assert_eq!(GethCallType::from_parity_call_type(""), GethCallType::Call); + #[rstest] + #[case("staticcall", GethCallType::StaticCall)] + #[case("delegatecall", GethCallType::DelegateCall)] + #[case("call", GethCallType::Call)] + #[case("unknown", GethCallType::Call)] + #[case("", GethCallType::Call)] + fn test_geth_call_type_from_parity_call_type( + #[case] input: &str, + #[case] expected: GethCallType, + ) { + assert_eq!(GethCallType::from_parity_call_type(input), expected); } - #[test] - fn test_geth_call_type_is_static_call() { - assert!(GethCallType::StaticCall.is_static_call()); - assert!(!GethCallType::Call.is_static_call()); - assert!(!GethCallType::DelegateCall.is_static_call()); - assert!(!GethCallType::Create.is_static_call()); - assert!(!GethCallType::Create2.is_static_call()); + #[rstest] + #[case(GethCallType::StaticCall, true)] + #[case(GethCallType::Call, false)] + #[case(GethCallType::DelegateCall, false)] + #[case(GethCallType::Create, false)] + #[case(GethCallType::Create2, false)] + fn test_geth_call_type_is_static_call(#[case] call_type: GethCallType, #[case] expected: bool) { + assert_eq!(call_type.is_static_call(), expected); } - #[test] - fn test_geth_call_type_serialization() { - assert_eq!( - serde_json::to_string(&GethCallType::Call).unwrap(), - r#""CALL""# - ); - assert_eq!( - serde_json::to_string(&GethCallType::StaticCall).unwrap(), - r#""STATICCALL""# - ); - assert_eq!( - serde_json::to_string(&GethCallType::DelegateCall).unwrap(), - r#""DELEGATECALL""# - ); - assert_eq!( - serde_json::to_string(&GethCallType::Create).unwrap(), - r#""CREATE""# - ); - assert_eq!( - serde_json::to_string(&GethCallType::Create2).unwrap(), - r#""CREATE2""# - ); + + #[rstest] + #[case(GethCallType::Call, r#""CALL""#)] + #[case(GethCallType::StaticCall, r#""STATICCALL""#)] + #[case(GethCallType::DelegateCall, r#""DELEGATECALL""#)] + #[case(GethCallType::Create, r#""CREATE""#)] + #[case(GethCallType::Create2, r#""CREATE2""#)] + fn test_geth_call_type_serialization( + #[case] call_type: GethCallType, + #[case] expected_json: &str, + ) { + assert_eq!(serde_json::to_string(&call_type).unwrap(), expected_json); } #[test] From 0ed900d1830262021c75379ca62b793030ef9681 Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Tue, 24 Mar 2026 17:20:29 +0530 Subject: [PATCH 7/7] update the contract for trace call so they always have new state for tests --- docs/docs/developers/guides/trace_call_guide.md | 2 +- docs/docs/users/knowledge_base/rpc/trace_call.md | 4 ++-- scripts/tests/trace_call_integration_test.sh | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/docs/developers/guides/trace_call_guide.md b/docs/docs/developers/guides/trace_call_guide.md index 92ae832c2ee1..60a23d619401 100644 --- a/docs/docs/developers/guides/trace_call_guide.md +++ b/docs/docs/developers/guides/trace_call_guide.md @@ -66,7 +66,7 @@ Pre-deployed Tracer contracts for quick testing: | Network | Contract Address | | -------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| Calibnet | [`0x73a43475aa2ccb14246613708b399f4b2ba546c7`](https://calibration.filfox.info/en/address/0x73a43475aa2ccb14246613708b399f4b2ba546c7) | +| Calibnet | [`0x1112da864d5b3a9e23bbdb543699351a694f814d`](https://calibration.filfox.info/en/address/0x1112da864d5b3a9e23bbdb543699351a694f814d) | | Mainnet | [`0x9BB686Ba6a50D1CF670a98f522a59555d4977fb2`](https://filecoin.blockscout.com/address/0x9BB686Ba6a50D1CF670a98f522a59555d4977fb2) | ## Comparison Testing with Anvil diff --git a/docs/docs/users/knowledge_base/rpc/trace_call.md b/docs/docs/users/knowledge_base/rpc/trace_call.md index bebc289e4dce..00d2bd1896a9 100644 --- a/docs/docs/users/knowledge_base/rpc/trace_call.md +++ b/docs/docs/users/knowledge_base/rpc/trace_call.md @@ -173,7 +173,7 @@ A [Tracer](https://github.com/ChainSafe/forest/blob/963237708137e9c7388c57eba39a | Network | Contract Address | | -------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| Calibnet | [`0x73a43475aa2ccb14246613708b399f4b2ba546c7`](https://calibration.filfox.info/en/address/0x73a43475aa2ccb14246613708b399f4b2ba546c7) | +| Calibnet | [`0x1112da864d5b3a9e23bbdb543699351a694f814d`](https://calibration.filfox.info/en/address/0x1112da864d5b3a9e23bbdb543699351a694f814d) | | Mainnet | [`0x9BB686Ba6a50D1CF670a98f522a59555d4977fb2`](https://filecoin.blockscout.com/address/0x9BB686Ba6a50D1CF670a98f522a59555d4977fb2) | > **Note:** Contract availability depends on network state. Verify the contract exists before testing: @@ -181,7 +181,7 @@ A [Tracer](https://github.com/ChainSafe/forest/blob/963237708137e9c7388c57eba39a > ```bash > curl -s -X POST "http://localhost:2345/rpc/v1" \ > -H "Content-Type: application/json" \ -> -d '{"jsonrpc":"2.0","id":1,"method":"eth_getCode","params":["0x73a43475aa2ccb14246613708b399f4b2ba546c7","latest"]}' \ +> -d '{"jsonrpc":"2.0","id":1,"method":"eth_getCode","params":["0x1112da864d5b3a9e23bbdb543699351a694f814d","latest"]}' \ > | jq -r '.result | length' > ``` > diff --git a/scripts/tests/trace_call_integration_test.sh b/scripts/tests/trace_call_integration_test.sh index cecd2f9e858b..6ed168579eb6 100755 --- a/scripts/tests/trace_call_integration_test.sh +++ b/scripts/tests/trace_call_integration_test.sh @@ -17,7 +17,7 @@ done FOREST_RPC_URL="${FOREST_RPC_URL:-http://localhost:2345/rpc/v1}" ANVIL_RPC_URL="${ANVIL_RPC_URL:-http://localhost:8545}" FOREST_ACCOUNT="${FOREST_ACCOUNT:-0xb7aa1e9c847cda5f60f1ae6f65c3eae44848d41f}" -FOREST_CONTRACT="${FOREST_CONTRACT:-0x73a43475aa2ccb14246613708b399f4b2ba546c7}" +FOREST_CONTRACT="${FOREST_CONTRACT:-0x1112da864d5b3a9e23bbdb543699351a694f814d}" ANVIL_ACCOUNT="${ANVIL_ACCOUNT:-0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266}" ANVIL_CONTRACT="${ANVIL_CONTRACT:-0x5FbDB2315678afecb367f032d93F642f64180aa3}" # -- This private key is of anvil dev node --