From bfc794a11938d66fa56ddff683a51abd4b4dc523 Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Wed, 25 Feb 2026 22:14:40 +0530 Subject: [PATCH 01/14] refactor replay block txn trace to use the eth trace result --- src/rpc/methods/eth.rs | 40 +++++------------------------------- src/rpc/methods/eth/types.rs | 37 +++++++++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 39 deletions(-) diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index fddab489e05a..b088dab71fed 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -478,37 +478,6 @@ impl ExtBlockNumberOrHash { } } -/// Selects which trace outputs to include in the `trace_call` response. -#[derive(PartialEq, Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub enum EthTraceType { - /// Requests a structured call graph, showing the hierarchy of calls (e.g., `call`, `create`, `reward`) - /// with details like `from`, `to`, `gas`, `input`, `output`, and `subtraces`. - Trace, - /// Requests a state difference object, detailing changes to account states (e.g., `balance`, `nonce`, `storage`, `code`) - /// caused by the simulated transaction. - /// - /// It shows `"from"` and `"to"` values for modified fields, using `"+"`, `"-"`, or `"="` for code changes. - StateDiff, -} - -lotus_json_with_self!(EthTraceType); - -/// Result payload returned by `trace_call`. -#[derive(PartialEq, Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct EthTraceResults { - /// Output bytes from the transaction execution. - pub output: EthBytes, - /// State diff showing all account changes. - pub state_diff: Option, - /// Call trace hierarchy (empty when not requested). - #[serde(default)] - pub trace: Vec, -} - -lotus_json_with_self!(EthTraceResults); - #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, GetSize)] #[serde(untagged)] // try a Vec, then a Vec pub enum Transactions { @@ -4333,11 +4302,12 @@ where }; all_traces.push(EthReplayBlockTransactionTrace { - output: get_output(), - state_diff: None, - trace: env.traces.clone(), + full_trace: EthTraceResults { + output: get_output(), + state_diff: None, + trace: env.traces.clone(), + }, transaction_hash: tx_hash, - vm_trace: None, }); }; } diff --git a/src/rpc/methods/eth/types.rs b/src/rpc/methods/eth/types.rs index 9ff55728cbcc..464416c3f749 100644 --- a/src/rpc/methods/eth/types.rs +++ b/src/rpc/methods/eth/types.rs @@ -638,6 +638,37 @@ impl Default for TraceResult { } } +/// Selects which trace outputs to include in the `trace_call` response. +#[derive(PartialEq, Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum EthTraceType { + /// Requests a structured call graph, showing the hierarchy of calls (e.g., `call`, `create`, `reward`) + /// with details like `from`, `to`, `gas`, `input`, `output`, and `subtraces`. + Trace, + /// Requests a state difference object, detailing changes to account states (e.g., `balance`, `nonce`, `storage`, `code`) + /// caused by the simulated transaction. + /// + /// It shows `"from"` and `"to"` values for modified fields, using `"+"`, `"-"`, or `"="` for code changes. + StateDiff, +} + +lotus_json_with_self!(EthTraceType); + +/// Result payload returned by `trace_call`. +#[derive(PartialEq, Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct EthTraceResults { + /// Output bytes from the transaction execution. + pub output: EthBytes, + /// State diff showing all account changes. + pub state_diff: Option, + /// Call trace hierarchy (empty when not requested). + #[serde(default)] + pub trace: Vec, +} + +lotus_json_with_self!(EthTraceResults); + #[derive(Eq, Hash, PartialEq, Default, Serialize, Deserialize, Debug, Clone, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct EthTrace { @@ -675,11 +706,9 @@ impl EthBlockTrace { #[derive(PartialEq, Default, Serialize, Deserialize, Debug, Clone, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct EthReplayBlockTransactionTrace { - pub output: EthBytes, - pub state_diff: Option, - pub trace: Vec, + #[serde(flatten)] + pub full_trace: EthTraceResults, pub transaction_hash: EthHash, - pub vm_trace: Option, } lotus_json_with_self!(EthReplayBlockTransactionTrace); From e8dcf253b792240645196e9da240ae1072260f28 Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Tue, 3 Mar 2026 18:59:20 +0530 Subject: [PATCH 02/14] add types for supporting the geth style call frames --- src/rpc/methods/eth/types.rs | 151 +++++++++++++++++++++++++++++++++++ src/rpc/methods/eth/utils.rs | 2 +- 2 files changed, 152 insertions(+), 1 deletion(-) diff --git a/src/rpc/methods/eth/types.rs b/src/rpc/methods/eth/types.rs index 464416c3f749..e67e6892b3c3 100644 --- a/src/rpc/methods/eth/types.rs +++ b/src/rpc/methods/eth/types.rs @@ -638,6 +638,157 @@ impl Default for TraceResult { } } +#[derive(PartialEq, Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub enum GethDebugBuiltInTracerType { + #[serde(rename = "callTracer")] + CallTracer, + #[serde(rename = "flatCallTracer")] + FlatCallTracer, + #[serde(rename = "prestateTracer")] + PreStateTracer, + #[serde(rename = "noopTracer")] + NoopTracer, +} + +/// Options for the `debug_traceTransaction` API. +#[derive(PartialEq, Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct GethDebugTracingOptions { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tracer: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tracer_config: Option, +} + +lotus_json_with_self!(GethDebugTracingOptions); + +impl GethDebugTracingOptions { + /// Extracts the `callTracer` config, defaulting to no-op values when absent. + pub fn call_config(&self) -> CallTracerConfig { + self.tracer_config + .as_ref() + .filter(|c| !c.0.is_null()) + .and_then(|c| serde_json::from_value(c.0.clone()).ok()) + .unwrap_or_default() + } +} + +/// Configuration for the `callTracer`. +#[derive(PartialEq, Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct CallTracerConfig { + #[serde(default)] + pub only_top_call: bool, +} + +lotus_json_with_self!(CallTracerConfig); + +/// Opaque JSON blob for per-tracer configuration. +/// +/// Exists as a newtype because `serde_json::Value` does not implement +/// `JsonSchema`. The actual interpretation depends on the selected tracer. +#[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) + } + + pub const fn is_delegate_call(&self) -> bool { + matches!(self, Self::DelegateCall) + } + + /// 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); + +/// Polymorphic trace result from `debug_traceTransaction`. +/// The shape depends on the selected tracer. +#[derive(PartialEq, Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum GethTrace { + CallTracer(GethCallFrame), + FlatCallTracer(Vec), + NoopTracer(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")] diff --git a/src/rpc/methods/eth/utils.rs b/src/rpc/methods/eth/utils.rs index 9152ca8955fe..4e4f91649061 100644 --- a/src/rpc/methods/eth/utils.rs +++ b/src/rpc/methods/eth/utils.rs @@ -151,7 +151,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)); From 413373b8d98d0f75df93a1aa8b4fb7d2cdb8541d Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Tue, 3 Mar 2026 18:59:59 +0530 Subject: [PATCH 03/14] introduce the debug trace transaction API [skip ci] --- src/rpc/methods/eth.rs | 218 +++++++++++++++++++++++++++-------- src/rpc/methods/eth/trace.rs | 191 ++++++++++++++++++++++++++++-- src/rpc/mod.rs | 1 + 3 files changed, 353 insertions(+), 57 deletions(-) diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index b088dab71fed..5389ce183c65 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -31,7 +31,7 @@ use crate::rpc::eth::errors::EthErrors; use crate::rpc::eth::filter::{ SkipEvent, event::EventFilter, mempool::MempoolFilter, tipset::TipSetFilter, }; -use crate::rpc::eth::types::{EthBlockTrace, EthTrace}; +use crate::rpc::eth::types::EthBlockTrace; use crate::rpc::eth::utils::decode_revert_reason; use crate::rpc::methods::chain::ChainGetTipSetV2; use crate::rpc::state::ApiInvocResult; @@ -69,6 +69,7 @@ use filter::{ParsedFilter, ParsedFilterTipsets}; use fvm_ipld_blockstore::Blockstore; use fvm_ipld_encoding::{CBOR, DAG_CBOR, IPLD_RAW, RawBytes}; use get_size2::GetSize; +use http::Extensions; use ipld_core::ipld::Ipld; use itertools::Itertools; use nonzero_ext::nonzero; @@ -3974,22 +3975,31 @@ impl RpcMethod<1> for EthTraceBlockV2 { } } -async fn eth_trace_block( +/// A resolved transaction trace entry within a tipset, with its ETH hash already computed. +struct TipsetTraceEntry { + tx_hash: EthHash, + msg_position: i64, + invoc_result: ApiInvocResult, +} + +/// Replays a tipset and resolves every non-system transaction into a [`TipsetTraceEntry`]. +/// +/// Returns the post-execution state tree alongside the resolved entries, ready +/// for any trace-building step (Parity flat traces, Geth call frames, etc.). +async fn execute_tipset_traces( ctx: &Ctx, ts: &Tipset, - ext: &http::Extensions, -) -> Result, ServerError> + ext: &Extensions, +) -> Result<(StateTree, Vec), ServerError> where DB: Blockstore + Send + Sync + 'static, { - let (state_root, trace) = ctx.state_manager.execution_trace(ts)?; + let (state_root, raw_traces) = ctx.state_manager.execution_trace(ts)?; let state = ctx.state_manager.get_state_tree(&state_root)?; - let cid = ts.key().cid()?; - let block_hash: EthHash = cid.into(); - let mut all_traces = vec![]; + + let mut entries = Vec::new(); let mut msg_idx = 0; - for ir in trace.into_iter() { - // ignore messages from system actor + for ir in raw_traces { if ir.msg.from == system::ADDRESS.into() { continue; } @@ -3997,24 +4007,45 @@ where let tx_hash = EthGetTransactionHashByCid::handle(ctx.clone(), (ir.msg_cid,), ext).await?; let tx_hash = tx_hash .with_context(|| format!("cannot find transaction hash for cid {}", ir.msg_cid))?; - let mut env = trace::base_environment(&state, &ir.msg.from) - .map_err(|e| format!("when processing message {}: {}", ir.msg_cid, e))?; - if let Some(execution_trace) = ir.execution_trace { + entries.push(TipsetTraceEntry { + tx_hash, + msg_position: msg_idx, + invoc_result: ir, + }); + } + + Ok((state, entries)) +} + +async fn eth_trace_block( + ctx: &Ctx, + ts: &Tipset, + ext: &Extensions, +) -> Result, ServerError> +where + DB: Blockstore + Send + Sync + 'static, +{ + let (state, entries) = execute_tipset_traces(ctx, ts, ext).await?; + let block_hash: EthHash = ts.key().cid()?.into(); + let mut all_traces = vec![]; + + for entry in entries { + let mut env = + trace::base_environment(&state, &entry.invoc_result.msg.from).map_err(|e| { + format!( + "when processing message {}: {}", + entry.invoc_result.msg_cid, e + ) + })?; + if let Some(execution_trace) = entry.invoc_result.execution_trace { trace::build_traces(&mut env, &[], execution_trace)?; for trace in env.traces { all_traces.push(EthBlockTrace { - trace: EthTrace { - r#type: trace.r#type, - subtraces: trace.subtraces, - trace_address: trace.trace_address, - action: trace.action, - result: trace.result, - error: trace.error, - }, + trace, block_hash, block_number: ts.epoch(), - transaction_hash: tx_hash, - transaction_position: msg_idx as i64, + transaction_hash: entry.tx_hash, + transaction_position: entry.msg_position, }); } } @@ -4022,6 +4053,107 @@ where Ok(all_traces) } +pub enum EthDebugTraceTransaction {} +impl RpcMethod<2> for EthDebugTraceTransaction { + const N_REQUIRED_PARAMS: usize = 1; + const NAME: &'static str = "Filecoin.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: &Extensions, + ) -> Result { + let opts = opts.unwrap_or_default(); + debug_trace_transaction(ctx, ext, tx_hash, opts).await + } +} + +async fn debug_trace_transaction( + ctx: Ctx, + ext: &Extensions, + tx_hash: String, + opts: GethDebugTracingOptions, +) -> Result +where + DB: Blockstore + Send + Sync + 'static, +{ + let call_config = opts.call_config(); + let tracer = opts + .tracer + .unwrap_or(GethDebugBuiltInTracerType::CallTracer); + + // Exit early if the tracer is no op + if tracer == GethDebugBuiltInTracerType::NoopTracer { + return Ok(GethTrace::NoopTracer(NoopFrame {})); + } + + 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))?; + + let ts = tipset_by_ext_block_number_or_hash( + ctx.chain_store(), + ExtBlockNumberOrHash::from_block_number(eth_txn.block_number.0 as i64), + ResolveNullTipset::TakeOlder, + )?; + + 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::CallTracer => { + let frame = + trace::build_geth_call_frame(&mut env, execution_trace, call_config.only_top_call)?; + Ok(GethTrace::CallTracer(frame.unwrap_or_default())) + } + GethDebugBuiltInTracerType::FlatCallTracer => { + 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::FlatCallTracer(traces)) + } + GethDebugBuiltInTracerType::PreStateTracer => { + unimplemented!("prestateTracer is not yet supported") + } + _ => unreachable!("noopTracer handled above"), + } +} + pub enum EthTraceCall {} impl RpcMethod<3> for EthTraceCall { const NAME: &'static str = "Forest.EthTraceCall"; @@ -4267,49 +4399,43 @@ impl RpcMethod<2> for EthTraceReplayBlockTransactionsV2 { async fn eth_trace_replay_block_transactions( ctx: &Ctx, ts: &Tipset, - ext: &http::Extensions, + ext: &Extensions, ) -> Result, ServerError> where DB: Blockstore + Send + Sync + 'static, { - let (state_root, trace) = ctx.state_manager.execution_trace(ts)?; - - let state = ctx.state_manager.get_state_tree(&state_root)?; + let (state, entries) = execute_tipset_traces(ctx, ts, ext).await?; let mut all_traces = vec![]; - for ir in trace.into_iter() { - if ir.msg.from == system::ADDRESS.into() { - continue; - } - - let tx_hash = EthGetTransactionHashByCid::handle(ctx.clone(), (ir.msg_cid,), ext).await?; - let tx_hash = tx_hash - .with_context(|| format!("cannot find transaction hash for cid {}", ir.msg_cid))?; - - let mut env = trace::base_environment(&state, &ir.msg.from) - .map_err(|e| format!("when processing message {}: {}", ir.msg_cid, e))?; + for entry in entries { + let mut env = + trace::base_environment(&state, &entry.invoc_result.msg.from).map_err(|e| { + format!( + "when processing message {}: {}", + entry.invoc_result.msg_cid, e + ) + })?; - if let Some(execution_trace) = ir.execution_trace { + if let Some(execution_trace) = entry.invoc_result.execution_trace { trace::build_traces(&mut env, &[], execution_trace)?; - let get_output = || -> EthBytes { + let output = env.traces .first() .map_or_else(EthBytes::default, |trace| match &trace.result { TraceResult::Call(r) => r.output.clone(), TraceResult::Create(r) => r.code.clone(), - }) - }; + }); all_traces.push(EthReplayBlockTransactionTrace { full_trace: EthTraceResults { - output: get_output(), + output, state_diff: None, - trace: env.traces.clone(), + trace: env.traces, }, - transaction_hash: tx_hash, + transaction_hash: entry.tx_hash, }); - }; + } } Ok(all_traces) diff --git a/src/rpc/methods/eth/trace.rs b/src/rpc/methods/eth/trace.rs index 936f4fe3ee70..d136739933d8 100644 --- a/src/rpc/methods/eth/trace.rs +++ b/src/rpc/methods/eth/trace.rs @@ -2,9 +2,10 @@ // SPDX-License-Identifier: Apache-2.0, MIT use super::types::{ - EthAddress, EthBytes, EthCallTraceAction, EthHash, EthTrace, TraceAction, TraceResult, + EthAddress, EthBytes, EthCallTraceAction, EthHash, EthTrace, GethCallFrame, GethCallType, + TraceAction, TraceResult, }; -use super::utils::{decode_params, decode_return}; +use super::utils::{decode_params, decode_return, parse_eth_revert}; use super::{ EthCallTraceResult, EthCreateTraceAction, EthCreateTraceResult, decode_payload, encode_filecoin_params_as_abi, encode_filecoin_returns_as_abi, @@ -33,6 +34,16 @@ use std::borrow::Cow; use std::collections::BTreeMap; use tracing::debug; +const EVM_REVERTED_CONTRACT: &str = "Reverted"; // capitalized for compatibility +const EVM_INVALID_INSTRUCTION: &str = "invalid instruction"; +const EVM_UNDEFINED_INSTRUCTION: &str = "undefined instruction"; +const EVM_STACK_UNDERFLOW: &str = "stack underflow"; +const EVM_STACK_OVERFLOW: &str = "stack overflow"; +const EVM_ILLEGAL_MEMORY_ACCESS: &str = "illegal memory access"; +const EVM_BAD_JUMPDEST: &str = "invalid jump destination"; +const EVM_SELFDESTRUCT_FAILED: &str = "self destruct failed"; +const EVM_OUT_OF_GAS: &str = "out of gas"; + /// KAMT configuration matching the EVM actor in builtin-actors. // Code is taken from: https://github.com/filecoin-project/builtin-actors/blob/v17.0.0/actors/evm/src/interpreter/system.rs#L47 fn evm_kamt_config() -> KamtConfig { @@ -112,7 +123,7 @@ fn trace_err_msg(trace: &ExecutionTrace) -> Option { // EVM tools often expect this literal string. if code == ExitCode::SYS_OUT_OF_GAS { - return Some("out of gas".into()); + return Some(EVM_OUT_OF_GAS.into()); } // indicate when we have a "system" error. @@ -123,18 +134,18 @@ fn trace_err_msg(trace: &ExecutionTrace) -> Option { // handle special exit codes from the EVM/EAM. if trace_is_evm_or_eam(trace) { match code.into() { - evm12::EVM_CONTRACT_REVERTED => return Some("Reverted".into()), // capitalized for compatibility - evm12::EVM_CONTRACT_INVALID_INSTRUCTION => return Some("invalid instruction".into()), + evm12::EVM_CONTRACT_REVERTED => return Some(EVM_REVERTED_CONTRACT.into()), + evm12::EVM_CONTRACT_INVALID_INSTRUCTION => return Some(EVM_INVALID_INSTRUCTION.into()), evm12::EVM_CONTRACT_UNDEFINED_INSTRUCTION => { - return Some("undefined instruction".into()); + return Some(EVM_UNDEFINED_INSTRUCTION.into()); } - evm12::EVM_CONTRACT_STACK_UNDERFLOW => return Some("stack underflow".into()), - evm12::EVM_CONTRACT_STACK_OVERFLOW => return Some("stack overflow".into()), + evm12::EVM_CONTRACT_STACK_UNDERFLOW => return Some(EVM_STACK_UNDERFLOW.into()), + evm12::EVM_CONTRACT_STACK_OVERFLOW => return Some(EVM_STACK_OVERFLOW.into()), evm12::EVM_CONTRACT_ILLEGAL_MEMORY_ACCESS => { - return Some("illegal memory access".into()); + return Some(EVM_ILLEGAL_MEMORY_ACCESS.into()); } - evm12::EVM_CONTRACT_BAD_JUMPDEST => return Some("invalid jump destination".into()), - evm12::EVM_CONTRACT_SELFDESTRUCT_FAILED => return Some("self destruct failed".into()), + evm12::EVM_CONTRACT_BAD_JUMPDEST => return Some(EVM_BAD_JUMPDEST.into()), + evm12::EVM_CONTRACT_SELFDESTRUCT_FAILED => return Some(EVM_SELFDESTRUCT_FAILED.into()), _ => (), } } @@ -142,6 +153,13 @@ fn trace_err_msg(trace: &ExecutionTrace) -> Option { Some(format!("actor error: {code}")) } +fn parity_error_to_geth(parity_error: &str) -> String { + match parity_error { + EVM_REVERTED_CONTRACT => "execution reverted".into(), + other => other.to_string(), + } +} + /// Recursively builds the traces for a given ExecutionTrace by walking the subcalls pub fn build_traces( env: &mut Environment, @@ -659,6 +677,157 @@ fn trace_evm_private( } } +/// 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, + only_top_call: bool, +) -> anyhow::Result> { + build_geth_frame_recursive(env, trace, true, only_top_call) +} + +fn build_geth_frame_recursive( + env: &mut Environment, + trace: ExecutionTrace, + is_root: bool, + only_top_call: 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) = 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(eth_trace, call_type)?; + + if !only_top_call { + if let Some(recurse_trace) = recurse_into { + if 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, false, false)? + { + subcalls.push(f); + } + } + if !subcalls.is_empty() { + frame.calls = Some(subcalls); + } + } + } + } + + Ok(Some(frame)) +} + +/// Converts a Parity-style [`EthTrace`] into a Geth-style [`GethCallFrame`]. +fn eth_trace_to_geth_frame( + trace: EthTrace, + call_type: GethCallType, +) -> anyhow::Result { + let error = trace.error.map(|e| parity_error_to_geth(&e)); + let is_error = error.is_some(); + let is_revert = error.as_deref() == Some("execution reverted"); + + match (trace.action, trace.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_error { + 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_error { + 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) + } + _ => bail!("mismatched trace action and result types"), + } +} + +fn extract_revert_reason(output: &EthBytes) -> Option { + let reason = parse_eth_revert(&output.0); + (!reason.starts_with("0x")).then_some(reason) +} + /// Build state diff by comparing pre and post-execution states for touched addresses. pub(crate) fn build_state_diff( store: &S, diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs index 4fcc69066bf3..fcb0f5f80587 100644 --- a/src/rpc/mod.rs +++ b/src/rpc/mod.rs @@ -157,6 +157,7 @@ macro_rules! for_each_rpc_method { $callback!($crate::rpc::eth::EthTraceFilter); $callback!($crate::rpc::eth::EthTraceFilterV2); $callback!($crate::rpc::eth::EthTraceTransaction); + $callback!($crate::rpc::eth::EthDebugTraceTransaction); $callback!($crate::rpc::eth::EthTraceReplayBlockTransactions); $callback!($crate::rpc::eth::EthTraceReplayBlockTransactionsV2); $callback!($crate::rpc::eth::Web3ClientVersion); From 77ff5c58111da283130cb431a47841f1f5baff7c Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Fri, 6 Mar 2026 11:11:52 +0530 Subject: [PATCH 04/14] refactor and improve the structure --- src/rpc/methods/eth.rs | 3 +- src/rpc/methods/eth/trace.rs | 81 ++++++++++++++++++++---------------- src/rpc/methods/eth/types.rs | 52 ++++++++++++++++++++--- src/rpc/methods/eth/utils.rs | 5 +-- 4 files changed, 93 insertions(+), 48 deletions(-) diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index 5389ce183c65..ba3fe56328a0 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -4127,8 +4127,7 @@ where match tracer { GethDebugBuiltInTracerType::CallTracer => { - let frame = - trace::build_geth_call_frame(&mut env, execution_trace, call_config.only_top_call)?; + let frame = trace::build_geth_call_frame(&mut env, execution_trace, &call_config)?; Ok(GethTrace::CallTracer(frame.unwrap_or_default())) } GethDebugBuiltInTracerType::FlatCallTracer => { diff --git a/src/rpc/methods/eth/trace.rs b/src/rpc/methods/eth/trace.rs index d136739933d8..c517a7fe79e1 100644 --- a/src/rpc/methods/eth/trace.rs +++ b/src/rpc/methods/eth/trace.rs @@ -11,7 +11,7 @@ use super::{ encode_filecoin_params_as_abi, encode_filecoin_returns_as_abi, }; use crate::eth::{EAMMethod, EVMMethod}; -use crate::rpc::eth::types::{AccountDiff, Delta, StateDiff}; +use crate::rpc::eth::types::{AccountDiff, CallTracerConfig, Delta, StateDiff}; use crate::rpc::eth::{EthBigInt, EthUint64}; use crate::rpc::methods::eth::lookup_eth_address; use crate::rpc::methods::state::ExecutionTrace; @@ -34,15 +34,19 @@ use std::borrow::Cow; use std::collections::BTreeMap; use tracing::debug; -const EVM_REVERTED_CONTRACT: &str = "Reverted"; // capitalized for compatibility -const EVM_INVALID_INSTRUCTION: &str = "invalid instruction"; -const EVM_UNDEFINED_INSTRUCTION: &str = "undefined instruction"; -const EVM_STACK_UNDERFLOW: &str = "stack underflow"; -const EVM_STACK_OVERFLOW: &str = "stack overflow"; -const EVM_ILLEGAL_MEMORY_ACCESS: &str = "illegal memory access"; -const EVM_BAD_JUMPDEST: &str = "invalid jump destination"; -const EVM_SELFDESTRUCT_FAILED: &str = "self destruct failed"; -const EVM_OUT_OF_GAS: &str = "out of gas"; +// EVM geth format Error Message +pub(crate) const GETH_EVM_REVERTED_CONTRACT: &str = "Reverted"; // capitalized for compatibility + +// EVM Parity format Error Message +pub(crate) const PARITY_EVM_REVERTED_CONTRACT: &str = "execution 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"; /// KAMT configuration matching the EVM actor in builtin-actors. // Code is taken from: https://github.com/filecoin-project/builtin-actors/blob/v17.0.0/actors/evm/src/interpreter/system.rs#L47 @@ -123,7 +127,7 @@ fn trace_err_msg(trace: &ExecutionTrace) -> Option { // EVM tools often expect this literal string. if code == ExitCode::SYS_OUT_OF_GAS { - return Some(EVM_OUT_OF_GAS.into()); + return Some(PARITY_EVM_OUT_OF_GAS.into()); } // indicate when we have a "system" error. @@ -134,18 +138,22 @@ fn trace_err_msg(trace: &ExecutionTrace) -> Option { // handle special exit codes from the EVM/EAM. if trace_is_evm_or_eam(trace) { match code.into() { - evm12::EVM_CONTRACT_REVERTED => return Some(EVM_REVERTED_CONTRACT.into()), - evm12::EVM_CONTRACT_INVALID_INSTRUCTION => return Some(EVM_INVALID_INSTRUCTION.into()), + evm12::EVM_CONTRACT_REVERTED => return Some(GETH_EVM_REVERTED_CONTRACT.into()), + evm12::EVM_CONTRACT_INVALID_INSTRUCTION => { + return Some(PARITY_EVM_INVALID_INSTRUCTION.into()); + } evm12::EVM_CONTRACT_UNDEFINED_INSTRUCTION => { - return Some(EVM_UNDEFINED_INSTRUCTION.into()); + return Some(PARITY_EVM_UNDEFINED_INSTRUCTION.into()); } - evm12::EVM_CONTRACT_STACK_UNDERFLOW => return Some(EVM_STACK_UNDERFLOW.into()), - evm12::EVM_CONTRACT_STACK_OVERFLOW => return Some(EVM_STACK_OVERFLOW.into()), + evm12::EVM_CONTRACT_STACK_UNDERFLOW => return Some(PARITY_EVM_STACK_UNDERFLOW.into()), + evm12::EVM_CONTRACT_STACK_OVERFLOW => return Some(PARITY_EVM_STACK_OVERFLOW.into()), evm12::EVM_CONTRACT_ILLEGAL_MEMORY_ACCESS => { - return Some(EVM_ILLEGAL_MEMORY_ACCESS.into()); + return Some(PARITY_EVM_ILLEGAL_MEMORY_ACCESS.into()); + } + evm12::EVM_CONTRACT_BAD_JUMPDEST => return Some(PARITY_EVM_BAD_JUMPDEST.into()), + evm12::EVM_CONTRACT_SELFDESTRUCT_FAILED => { + return Some(PARITY_EVM_SELFDESTRUCT_FAILED.into()); } - evm12::EVM_CONTRACT_BAD_JUMPDEST => return Some(EVM_BAD_JUMPDEST.into()), - evm12::EVM_CONTRACT_SELFDESTRUCT_FAILED => return Some(EVM_SELFDESTRUCT_FAILED.into()), _ => (), } } @@ -153,13 +161,6 @@ fn trace_err_msg(trace: &ExecutionTrace) -> Option { Some(format!("actor error: {code}")) } -fn parity_error_to_geth(parity_error: &str) -> String { - match parity_error { - EVM_REVERTED_CONTRACT => "execution reverted".into(), - other => other.to_string(), - } -} - /// Recursively builds the traces for a given ExecutionTrace by walking the subcalls pub fn build_traces( env: &mut Environment, @@ -684,16 +685,16 @@ fn trace_evm_private( pub fn build_geth_call_frame( env: &mut Environment, trace: ExecutionTrace, - only_top_call: bool, + tracer_cfg: &CallTracerConfig, ) -> anyhow::Result> { - build_geth_frame_recursive(env, trace, true, only_top_call) + 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, - only_top_call: bool, ) -> anyhow::Result> { let msg_to = trace.msg.to; let msg_method = trace.msg.method; @@ -722,9 +723,13 @@ fn build_geth_frame_recursive( } }; - let mut frame = eth_trace_to_geth_frame(eth_trace, call_type)?; + let mut frame = eth_trace_to_geth_frame( + eth_trace, + call_type, + tracer_cfg.with_log.unwrap_or_default(), + )?; - if !only_top_call { + if !tracer_cfg.only_top_call.unwrap_or_default() { if let Some(recurse_trace) = recurse_into { if let Some(invoked_actor) = &recurse_trace.invoked_actor { let mut sub_env = Environment { @@ -735,7 +740,7 @@ fn build_geth_frame_recursive( let mut subcalls = Vec::new(); for subcall in recurse_trace.subcalls { if let Some(f) = - build_geth_frame_recursive(&mut sub_env, subcall, false, false)? + build_geth_frame_recursive(&mut sub_env, subcall, &tracer_cfg, false)? { subcalls.push(f); } @@ -751,13 +756,15 @@ fn build_geth_frame_recursive( } /// 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 fn eth_trace_to_geth_frame( trace: EthTrace, call_type: GethCallType, + with_log: bool, ) -> anyhow::Result { - let error = trace.error.map(|e| parity_error_to_geth(&e)); - let is_error = error.is_some(); - let is_revert = error.as_deref() == Some("execution reverted"); + let is_success = trace.is_success(); + let is_revert = trace.is_reverted(); + let error = trace.parity_error_to_geth(); match (trace.action, trace.result) { (TraceAction::Call(action), TraceResult::Call(result)) => { @@ -779,7 +786,7 @@ fn eth_trace_to_geth_frame( calls: None, }; - if is_error { + if !is_success { if !is_revert { frame.gas_used = action.gas; frame.output = None; @@ -806,7 +813,7 @@ fn eth_trace_to_geth_frame( calls: None, }; - if is_error { + if !is_success { frame.to = None; if !is_revert { frame.gas_used = action.gas; diff --git a/src/rpc/methods/eth/types.rs b/src/rpc/methods/eth/types.rs index e67e6892b3c3..8936b9303f22 100644 --- a/src/rpc/methods/eth/types.rs +++ b/src/rpc/methods/eth/types.rs @@ -317,13 +317,23 @@ pub struct EthCallMessage { pub gas_price: Option, #[serde(skip_serializing_if = "Option::is_none", default)] pub value: Option, - // Some clients use `input`, others use `data`. We have to support both. - #[serde(alias = "input", skip_serializing_if = "Option::is_none", default)] + // Ethereum tools (cast, ethers.js, etc.) send calldata as `data`. + #[serde(skip_serializing_if = "Option::is_none", default)] pub data: Option, + // Lotus/Filecoin clients send calldata as `input`. + // Both are accepted; `input` takes precedence when both are present. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub input: Option, } lotus_json_with_self!(EthCallMessage); impl EthCallMessage { + /// Returns the effective calldata, preferring `input` over `data` when both are set. + // Some ethereum tools uses both `data` and `input` to represent calldata. + pub fn effective_input(&self) -> Option<&EthBytes> { + self.input.as_ref().or(self.data.as_ref()) + } + pub fn convert_data_to_message_params(data: EthBytes) -> anyhow::Result { if data.0.is_empty() { Ok(RawBytes::new(data.0)) @@ -353,7 +363,8 @@ impl TryFrom for Message { } }; let params = tx - .data + .effective_input() + .cloned() .map(EthCallMessage::convert_data_to_message_params) .transpose()? .unwrap_or_default(); @@ -677,8 +688,10 @@ impl GethDebugTracingOptions { #[derive(PartialEq, Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct CallTracerConfig { - #[serde(default)] - pub only_top_call: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub only_top_call: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub with_log: Option, } lotus_json_with_self!(CallTracerConfig); @@ -832,6 +845,35 @@ 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 { + if let Some(error) = self.error.as_ref() { + error == trace::GETH_EVM_REVERTED_CONTRACT + } else { + false + } + } + + pub fn parity_error_to_geth(&self) -> Option { + if let Some(error) = self.error.as_ref() { + if error == trace::GETH_EVM_REVERTED_CONTRACT { + Some(trace::PARITY_EVM_REVERTED_CONTRACT.into()) + } else { + Some(error.to_string()) + } + } else { + None + } + } +} + #[derive(Eq, Hash, PartialEq, Default, Serialize, Deserialize, Debug, Clone, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct EthBlockTrace { diff --git a/src/rpc/methods/eth/utils.rs b/src/rpc/methods/eth/utils.rs index 4e4f91649061..2ed1c4b3d047 100644 --- a/src/rpc/methods/eth/utils.rs +++ b/src/rpc/methods/eth/utils.rs @@ -199,10 +199,7 @@ fn parse_error_revert(data: &[u8]) -> String { // Attempt to decode valid UTF-8 let string = data.get(string_start..string_start + len).ok_or(())?; - Ok(format!( - "Error({})", - std::str::from_utf8(string).map_err(|_| ())? - )) + Ok(format!("{}", std::str::from_utf8(string).map_err(|_| ())?)) })(); parse_result.unwrap_or_else(|_| fallback()) From d75acba186aaf28da67060d9eddc591231e9a99c Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Fri, 6 Mar 2026 21:47:10 +0530 Subject: [PATCH 05/14] add prestate tracer support WIP [skip ci] --- src/interpreter/vm.rs | 2 +- src/rpc/methods/eth.rs | 69 +++++++++-- src/rpc/methods/eth/trace.rs | 214 +++++++++++++++++++++++++-------- src/rpc/methods/eth/types.rs | 126 +++++++++++++++++++ src/rpc/methods/state/types.rs | 2 +- src/shim/executor.rs | 2 +- src/state_manager/mod.rs | 144 ++++++++++++++++++++++ 7 files changed, 501 insertions(+), 58 deletions(-) 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 ba3fe56328a0..efbd7ba2f053 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -3527,7 +3527,7 @@ pub struct CollectedEvent { pub(crate) msg_cid: Cid, } -fn match_key(key: &str) -> Option { +pub(crate) fn match_key(key: &str) -> Option { match key.get(0..2) { Some("t1") => Some(0), Some("t2") => Some(1), @@ -3537,7 +3537,7 @@ fn match_key(key: &str) -> Option { } } -fn eth_log_from_event(entries: &[EventEntry]) -> Option<(EthBytes, Vec)> { +pub(crate) fn eth_log_from_event(entries: &[EventEntry]) -> Option<(EthBytes, Vec)> { let mut topics_found = [false; 4]; let mut topics_found_count = 0; let mut data_found = false; @@ -4086,12 +4086,11 @@ async fn debug_trace_transaction( where DB: Blockstore + Send + Sync + 'static, { - let call_config = opts.call_config(); let tracer = opts .tracer + .clone() .unwrap_or(GethDebugBuiltInTracerType::CallTracer); - // Exit early if the tracer is no op if tracer == GethDebugBuiltInTracerType::NoopTracer { return Ok(GethTrace::NoopTracer(NoopFrame {})); } @@ -4107,6 +4106,14 @@ where ResolveNullTipset::TakeOlder, )?; + if tracer == GethDebugBuiltInTracerType::PreStateTracer { + let message_cid = ctx + .chain_store() + .get_mapping(ð_hash)? + .unwrap_or_else(|| eth_hash.to_cid()); + return debug_trace_prestate(&ctx, &ts, message_cid, &opts).await; + } + let (state, entries) = execute_tipset_traces(&ctx, &ts, ext).await?; let entry = entries .into_iter() @@ -4127,6 +4134,7 @@ where match tracer { GethDebugBuiltInTracerType::CallTracer => { + let call_config = opts.call_config(); let frame = trace::build_geth_call_frame(&mut env, execution_trace, &call_config)?; Ok(GethTrace::CallTracer(frame.unwrap_or_default())) } @@ -4146,13 +4154,58 @@ where .collect(); Ok(GethTrace::FlatCallTracer(traces)) } - GethDebugBuiltInTracerType::PreStateTracer => { - unimplemented!("prestateTracer is not yet supported") - } - _ => unreachable!("noopTracer handled above"), + _ => unreachable!("noopTracer and prestateTracer handled above"), } } +/// Handles the `prestateTracer` variant of `debug_traceTransaction`. +/// +/// Replays the tipset up to the target message to obtain per-transaction +/// pre/post state roots, then builds the appropriate [`PreStateFrame`]. +async fn debug_trace_prestate( + ctx: &Ctx, + ts: &Tipset, + target_mcid: Cid, + opts: &GethDebugTracingOptions, +) -> Result +where + DB: Blockstore + Send + Sync + 'static, +{ + let prestate_config = opts.prestate_config(); + + let (pre_root, invoc_result, post_root) = ctx + .state_manager + .replay_message_with_state_roots(ts.clone(), target_mcid) + .await + .map_err(|e| anyhow::anyhow!("failed to replay message with state roots: {e}"))?; + + 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 mut touched = invoc_result + .execution_trace + .as_ref() + .map(extract_touched_eth_addresses) + .unwrap_or_default(); + + 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 frame = trace::build_prestate_frame( + ctx.store(), + &pre_state, + &post_state, + &touched, + &prestate_config, + )?; + + Ok(GethTrace::PreStateTracer(frame)) +} + pub enum EthTraceCall {} impl RpcMethod<3> for EthTraceCall { const NAME: &'static str = "Forest.EthTraceCall"; diff --git a/src/rpc/methods/eth/trace.rs b/src/rpc/methods/eth/trace.rs index c517a7fe79e1..79fef2395a97 100644 --- a/src/rpc/methods/eth/trace.rs +++ b/src/rpc/methods/eth/trace.rs @@ -2,13 +2,13 @@ // SPDX-License-Identifier: Apache-2.0, MIT use super::types::{ - EthAddress, EthBytes, EthCallTraceAction, EthHash, EthTrace, GethCallFrame, GethCallType, - TraceAction, TraceResult, + CallLogFrame, EthAddress, EthBytes, EthCallTraceAction, EthHash, EthTrace, GethCallFrame, + GethCallType, TraceAction, TraceResult, }; use super::utils::{decode_params, decode_return, parse_eth_revert}; use super::{ EthCallTraceResult, EthCreateTraceAction, EthCreateTraceResult, decode_payload, - encode_filecoin_params_as_abi, encode_filecoin_returns_as_abi, + encode_filecoin_params_as_abi, encode_filecoin_returns_as_abi, eth_log_from_event, }; use crate::eth::{EAMMethod, EVMMethod}; use crate::rpc::eth::types::{AccountDiff, CallTracerConfig, Delta, StateDiff}; @@ -16,9 +16,11 @@ use crate::rpc::eth::{EthBigInt, EthUint64}; use crate::rpc::methods::eth::lookup_eth_address; use crate::rpc::methods::state::ExecutionTrace; use crate::rpc::state::ActorTrace; +use crate::rpc::types::EventEntry; use crate::shim::actors::{EVMActorStateLoad, evm}; +use crate::shim::executor::StampedEvent; use crate::shim::fvm_shared_latest::METHOD_CONSTRUCTOR; -use crate::shim::state_tree::ActorState; +use crate::shim::state_tree::{ActorID, ActorState}; use crate::shim::{actors::is_evm_actor, address::Address, error::ExitCode, state_tree::StateTree}; use ahash::{HashMap, HashSet}; use anyhow::{Context, bail}; @@ -682,6 +684,10 @@ fn trace_evm_private( /// /// Reuses [`build_trace`] for classification and data extraction, then converts /// the Parity-style [`EthTrace`] into a nested [`GethCallFrame`]. +/// +/// When `with_log` is set and `events` is non-empty, logs are correlated to +/// call frames by matching each event's emitter actor ID against the frame's +/// invoked actor. pub fn build_geth_call_frame( env: &mut Environment, trace: ExecutionTrace, @@ -723,11 +729,7 @@ fn build_geth_frame_recursive( } }; - let mut frame = eth_trace_to_geth_frame( - eth_trace, - call_type, - tracer_cfg.with_log.unwrap_or_default(), - )?; + let mut frame = eth_trace_to_geth_frame(eth_trace, call_type)?; if !tracer_cfg.only_top_call.unwrap_or_default() { if let Some(recurse_trace) = recurse_into { @@ -760,7 +762,6 @@ fn build_geth_frame_recursive( fn eth_trace_to_geth_frame( trace: EthTrace, call_type: GethCallType, - with_log: bool, ) -> anyhow::Result { let is_success = trace.is_success(); let is_revert = trace.is_reverted(); @@ -784,6 +785,7 @@ fn eth_trace_to_geth_frame( error: None, revert_reason: None, calls: None, + logs: Vec::new(), }; if !is_success { @@ -811,6 +813,7 @@ fn eth_trace_to_geth_frame( error: None, revert_reason: None, calls: None, + logs: Vec::new(), }; if !is_success { @@ -835,6 +838,32 @@ fn extract_revert_reason(output: &EthBytes) -> Option { (!reason.starts_with("0x")).then_some(reason) } +/// Returns the effective nonce for an actor: EVM nonce for EVM actors, sequence otherwise. +fn actor_nonce(store: &DB, actor: &ActorState) -> EthUint64 { + if is_evm_actor(&actor.code) { + EthUint64::from( + evm::State::load(store, actor.code, actor.state) + .map(|s| s.nonce()) + .unwrap_or(actor.sequence), + ) + } else { + EthUint64::from(actor.sequence) + } +} + +/// Returns the deployed bytecode of an EVM actor, or `None` for non-EVM actors. +fn actor_bytecode(store: &DB, actor: &ActorState) -> Option { + if !is_evm_actor(&actor.code) { + return None; + } + let evm_state = evm::State::load(store, actor.code, actor.state).ok()?; + store + .get(&evm_state.bytecode()) + .ok() + .flatten() + .map(EthBytes) +} + /// Build state diff by comparing pre and post-execution states for touched addresses. pub(crate) fn build_state_diff( store: &S, @@ -847,7 +876,6 @@ pub(crate) fn build_state_diff( for eth_addr in touched_addresses { let fil_addr = eth_addr.to_filecoin_address()?; - // Get actor state before and after let pre_actor = pre_state .get_actor(&fil_addr) .map_err(|e| anyhow::anyhow!("failed to get actor state: {e}"))?; @@ -858,7 +886,6 @@ pub(crate) fn build_state_diff( let account_diff = build_account_diff(store, pre_actor.as_ref(), post_actor.as_ref())?; - // Only include it if there were actual changes state_diff.insert_if_changed(*eth_addr, account_diff); } @@ -873,54 +900,147 @@ fn build_account_diff( ) -> anyhow::Result { let mut diff = AccountDiff::default(); - // Compare balance let pre_balance = pre_actor.map(|a| EthBigInt(a.balance.atto().clone())); let post_balance = post_actor.map(|a| EthBigInt(a.balance.atto().clone())); diff.balance = Delta::from_comparison(pre_balance, post_balance); - // Helper to get nonce from actor (uses EVM nonce for EVM actors) - let get_nonce = |actor: &ActorState| -> EthUint64 { - if is_evm_actor(&actor.code) { - EthUint64::from( - evm::State::load(store, actor.code, actor.state) - .map(|s| s.nonce()) - .unwrap_or(actor.sequence), - ) - } else { - EthUint64::from(actor.sequence) - } - }; - - // Helper to get bytecode from an EVM actor - let get_bytecode = |actor: &ActorState| -> Option { - if !is_evm_actor(&actor.code) { - return None; - } - - let evm_state = evm::State::load(store, actor.code, actor.state).ok()?; - store - .get(&evm_state.bytecode()) - .ok() - .flatten() - .map(EthBytes) - }; - - // Compare nonce - let pre_nonce = pre_actor.map(get_nonce); - let post_nonce = post_actor.map(get_nonce); + let pre_nonce = pre_actor.map(|a| actor_nonce(store, a)); + let post_nonce = post_actor.map(|a| actor_nonce(store, a)); diff.nonce = Delta::from_comparison(pre_nonce, post_nonce); - // Compare code (bytecode for EVM actors) - let pre_code = pre_actor.and_then(get_bytecode); - let post_code = post_actor.and_then(get_bytecode); + let pre_code = pre_actor.and_then(|a| actor_bytecode(store, a)); + let post_code = post_actor.and_then(|a| actor_bytecode(store, a)); diff.code = Delta::from_comparison(pre_code, post_code); - // Compare storage slots for EVM actors diff.storage = diff_evm_storage_for_actors(store, pre_actor, post_actor)?; Ok(diff) } +/// Build a [`PreStateFrame`] for the `prestateTracer`. +/// +/// In default mode, returns the pre-execution state of every touched account. +/// In diff mode, returns separate `pre` and `post` snapshots with unchanged +/// fields stripped from `post`. +pub(crate) fn build_prestate_frame( + store: &S, + pre_state: &StateTree, + post_state: &StateTree, + touched_addresses: &HashSet, + config: &super::types::PreStateConfig, +) -> anyhow::Result { + use super::types::{DiffMode, PreStateFrame, PreStateMode}; + + let include_code = !config.is_code_disabled(); + let include_storage = !config.is_storage_disabled(); + + if config.is_diff_mode() { + let mut pre_map = BTreeMap::new(); + let mut post_map = 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) + .map_err(|e| anyhow::anyhow!("failed to get pre actor state: {e}"))?; + let post_actor = post_state + .get_actor(&fil_addr) + .map_err(|e| anyhow::anyhow!("failed to get post actor state: {e}"))?; + + let pre_snap = + build_account_snapshot(store, pre_actor.as_ref(), include_code, include_storage); + let mut post_snap = + build_account_snapshot(store, post_actor.as_ref(), include_code, include_storage); + + let is_created = pre_actor.is_none() && post_actor.is_some(); + let is_deleted = pre_actor.is_some() && post_actor.is_none(); + + if !is_created { + if let Some(snap) = pre_snap.as_ref() { + if !snap.is_empty() { + pre_map.insert(*eth_addr, snap.clone()); + } + } + } + + if !is_deleted { + if let Some(ref mut snap) = post_snap { + if let Some(pre_snap) = pre_snap.as_ref() { + snap.retain_changed(pre_snap); + } + if !snap.is_empty() { + post_map.insert(*eth_addr, snap.clone()); + } + } + } + } + + 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) + .map_err(|e| anyhow::anyhow!("failed to get pre actor state: {e}"))?; + + if let Some(snap) = + build_account_snapshot(store, pre_actor.as_ref(), include_code, include_storage) + { + if !snap.is_empty() { + result.insert(*eth_addr, snap); + } + } + } + + Ok(PreStateFrame::Default(PreStateMode(result))) + } +} + +/// Build an [`AccountState`] snapshot from an actor. +/// Returns `None` when the actor does not exist. +fn build_account_snapshot( + store: &DB, + actor: Option<&ActorState>, + include_code: bool, + include_storage: bool, +) -> Option { + let actor = actor?; + + let balance = Some(EthBigInt(actor.balance.atto().clone())); + let nonce = { + let n = actor_nonce(store, actor); + (n.0 != 0).then_some(n) + }; + let code = if include_code { + actor_bytecode(store, actor) + } else { + None + }; + let storage = if include_storage { + let entries = extract_evm_storage_entries(store, Some(actor)); + entries + .into_iter() + .map(|(k, v)| (EthHash(ethereum_types::H256(k)), u256_to_eth_hash(&v))) + .collect() + } else { + BTreeMap::new() + }; + + Some(super::types::AccountState { + balance, + code, + nonce, + storage, + }) +} + /// Compute storage diff between pre and post actor states. /// /// Uses different Delta types based on the scenario: diff --git a/src/rpc/methods/eth/types.rs b/src/rpc/methods/eth/types.rs index 8936b9303f22..670744c1a77d 100644 --- a/src/rpc/methods/eth/types.rs +++ b/src/rpc/methods/eth/types.rs @@ -682,6 +682,15 @@ impl GethDebugTracingOptions { .and_then(|c| serde_json::from_value(c.0.clone()).ok()) .unwrap_or_default() } + + /// Extracts the `prestateTracer` config, defaulting to no-op values when absent. + pub fn prestate_config(&self) -> PreStateConfig { + self.tracer_config + .as_ref() + .filter(|c| !c.0.is_null()) + .and_then(|c| serde_json::from_value(c.0.clone()).ok()) + .unwrap_or_default() + } } /// Configuration for the `callTracer`. @@ -696,6 +705,35 @@ pub struct CallTracerConfig { 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#L14 +#[derive(PartialEq, Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PreStateConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub diff_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disable_code: Option, + #[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. /// /// Exists as a newtype because `serde_json::Value` does not implement @@ -780,16 +818,103 @@ pub struct GethCallFrame { pub revert_reason: Option, #[serde(skip_serializing_if = "Option::is_none")] pub calls: Option>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub logs: Vec, } lotus_json_with_self!(GethCallFrame); +/// A log entry emitted during a traced call, attached to a [`GethCallFrame`] +/// when `withLog: true` is set in the callTracer config. +#[derive(PartialEq, Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct CallLogFrame { + #[serde(skip_serializing_if = "Option::is_none")] + pub address: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub topics: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +lotus_json_with_self!(CallLogFrame); + /// 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() + } +} + +/// Default prestate mode: flat map of address → pre-execution account state. +// 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); + +/// Diff mode: separate `pre` and `post` account snapshots. +/// Created accounts appear only in `post`; deleted accounts appear only in `pre`. +/// Unchanged fields are stripped from `post` entries. +// 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 { + pub pre: BTreeMap, + 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(PreStateMode), + Diff(DiffMode), +} + +lotus_json_with_self!(PreStateFrame); + /// Polymorphic trace result from `debug_traceTransaction`. /// The shape depends on the selected tracer. #[derive(PartialEq, Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -797,6 +922,7 @@ lotus_json_with_self!(NoopFrame); pub enum GethTrace { CallTracer(GethCallFrame), FlatCallTracer(Vec), + PreStateTracer(PreStateFrame), NoopTracer(NoopFrame), } diff --git a/src/rpc/methods/state/types.rs b/src/rpc/methods/state/types.rs index 65a9f20efb60..dacdbc9e4400 100644 --- a/src/rpc/methods/state/types.rs +++ b/src/rpc/methods/state/types.rs @@ -4,7 +4,7 @@ use crate::blocks::TipsetKey; use crate::lotus_json::{LotusJson, lotus_json_with_self}; use crate::message::Message as _; -use crate::shim::executor::ApplyRet; +use crate::shim::executor::{ApplyRet, StampedEvent}; use crate::shim::{ address::Address, clock::ChainEpoch, diff --git a/src/shim/executor.rs b/src/shim/executor.rs index 8c61dc085344..f2ea00a61f8b 100644 --- a/src/shim/executor.rs +++ b/src/shim/executor.rs @@ -22,7 +22,7 @@ use fvm_shared4::receipt::Receipt as Receipt_v4; use fvm2::executor::ApplyRet as ApplyRet_v2; use fvm3::executor::ApplyRet as ApplyRet_v3; use fvm4::executor::ApplyRet as ApplyRet_v4; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use spire_enum::prelude::delegated_enum; use std::borrow::Borrow as _; diff --git a/src/state_manager/mod.rs b/src/state_manager/mod.rs index c042351222c5..bbb50b26a67e 100644 --- a/src/state_manager/mod.rs +++ b/src/state_manager/mod.rs @@ -851,6 +851,150 @@ where api_invoc_result.ok_or_else(|| Error::Other("failed to replay".into())) } + /// Replays a tipset up to a specific message, returning the pre-execution + /// and post-execution state roots alongside the traced invocation result. + /// + /// This is used by the `prestateTracer` in `debug_traceTransaction` which + /// needs to compare account states before and after a single transaction. + pub async fn replay_message_with_state_roots( + self: &Arc, + ts: Tipset, + target_mcid: Cid, + ) -> Result<(Cid, ApiInvocResult, Cid), Error> { + let this = Arc::clone(self); + tokio::task::spawn_blocking(move || { + this.replay_message_with_state_roots_blocking(ts, target_mcid) + }) + .await + .map_err(|e| Error::Other(format!("{e}")))? + } + + /// Blocking version of [`Self::replay_message_with_state_roots`]. + fn replay_message_with_state_roots_blocking( + self: &Arc, + ts: Tipset, + target_mcid: Cid, + ) -> Result<(Cid, ApiInvocResult, Cid), Error> { + use crate::shim::clock::EPOCH_DURATION_SECONDS; + use ahash::HashSet; + + let genesis_timestamp = self.chain_store().genesis_block_header().timestamp; + let chain_config = self.chain_config().clone(); + let chain_index = self.chain_index().clone(); + let rand = ChainRand::new( + chain_config.clone(), + ts.clone(), + chain_index.clone(), + self.beacon_schedule().clone(), + ); + + let genesis_info = GenesisInfo::from_chain_config(chain_config.clone()); + let create_vm = |state_root: Cid, epoch, timestamp, trace_config| { + let circulating_supply = + genesis_info.get_vm_circulating_supply(epoch, chain_index.db(), &state_root)?; + VM::new( + ExecutionContext { + heaviest_tipset: ts.clone(), + state_tree_root: state_root, + epoch, + rand: Box::new(rand.clone()), + base_fee: ts.min_ticket_block().parent_base_fee.clone(), + circ_supply: circulating_supply, + chain_config: chain_config.clone(), + chain_index: chain_index.clone(), + timestamp, + }, + &self.engine, + trace_config, + ) + }; + + let mut parent_state = *ts.parent_state(); + let parent_epoch = Tipset::load_required(chain_index.db(), ts.parents())?.epoch(); + let epoch = ts.epoch(); + + for epoch_i in parent_epoch..epoch { + if epoch_i > parent_epoch { + let timestamp = genesis_timestamp + ((EPOCH_DURATION_SECONDS * epoch_i) as u64); + parent_state = stacker::grow(64 << 20, || -> anyhow::Result { + let mut vm = create_vm(parent_state, epoch_i, timestamp, VMTrace::NotTraced)?; + if let Err(e) = vm.run_cron(epoch_i, NO_CALLBACK) { + error!("Beginning of epoch cron failed to run: {e}"); + } + vm.flush() + })?; + } + if let Some(new_state) = + run_state_migrations(epoch_i, &chain_config, chain_index.db(), &parent_state)? + { + parent_state = new_state; + } + } + + let block_messages = BlockMessages::for_tipset(chain_index.db(), &ts) + .map_err(|e| Error::Other(e.to_string()))?; + + stacker::grow(64 << 20, || -> Result<(Cid, ApiInvocResult, Cid), Error> { + let mut vm = create_vm(parent_state, epoch, ts.min_timestamp(), VMTrace::NotTraced)?; + let mut processed = 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 = + create_vm(pre_root, epoch, ts.min_timestamp(), VMTrace::Traced)?; + let (ret, duration) = traced_vm.apply_message(msg)?; + let post_root = traced_vm.flush()?; + + let invoc_result = 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(), + }; + + return Ok((pre_root, invoc_result, 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() { + return Err(Error::Other(format!( + "failed to apply reward message for miner {}: {err}", + block.miner + ))); + } + } + } + + Err(Error::Other(format!( + "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 404abcfb851ceb870e1ad8ce82f545b26dfc9fe8 Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Mon, 9 Mar 2026 17:57:21 +0530 Subject: [PATCH 06/14] refactor the prestate tracer implementation --- src/rpc/methods/eth.rs | 106 +++++------ src/rpc/methods/eth/trace.rs | 181 ++++++++++-------- src/rpc/methods/eth/types.rs | 41 +--- src/rpc/methods/eth/utils.rs | 3 +- src/rpc/methods/state/types.rs | 2 +- src/shim/executor.rs | 2 +- src/state_manager/mod.rs | 331 ++++++++++++++++----------------- 7 files changed, 325 insertions(+), 341 deletions(-) diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index efbd7ba2f053..6eb9613b7f40 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -3527,7 +3527,7 @@ pub struct CollectedEvent { pub(crate) msg_cid: Cid, } -pub(crate) fn match_key(key: &str) -> Option { +fn match_key(key: &str) -> Option { match key.get(0..2) { Some("t1") => Some(0), Some("t2") => Some(1), @@ -3537,7 +3537,7 @@ pub(crate) fn match_key(key: &str) -> Option { } } -pub(crate) fn eth_log_from_event(entries: &[EventEntry]) -> Option<(EthBytes, Vec)> { +fn eth_log_from_event(entries: &[EventEntry]) -> Option<(EthBytes, Vec)> { let mut topics_found = [false; 4]; let mut topics_found_count = 0; let mut data_found = false; @@ -4065,7 +4065,7 @@ impl RpcMethod<2> for EthDebugTraceTransaction { Some("Replays a transaction and returns execution traces in Geth-compatible format."); type Params = (String, Option); - type Ok = GethTrace; + type Ok = GethTracer; async fn handle( ctx: Ctx, @@ -4082,20 +4082,21 @@ async fn debug_trace_transaction( ext: &Extensions, tx_hash: String, opts: GethDebugTracingOptions, -) -> Result +) -> Result where DB: Blockstore + Send + Sync + 'static, { let tracer = opts .tracer .clone() - .unwrap_or(GethDebugBuiltInTracerType::CallTracer); + .unwrap_or(GethDebugBuiltInTracerType::Call); - if tracer == GethDebugBuiltInTracerType::NoopTracer { - return Ok(GethTrace::NoopTracer(NoopFrame {})); + if tracer == GethDebugBuiltInTracerType::Noop { + return Ok(GethTracer::Noop(NoopFrame {})); } let eth_hash = EthHash::from_str(&tx_hash).context("invalid transaction hash")?; + // TODO: We should return if the transaction is in pending state, need to verify this behaviour in Geth and Reth let eth_txn = get_eth_transaction_by_hash(&ctx, ð_hash, None) .await? .ok_or(ServerError::internal_error("transaction not found", None))?; @@ -4106,12 +4107,43 @@ where ResolveNullTipset::TakeOlder, )?; - if tracer == GethDebugBuiltInTracerType::PreStateTracer { + // 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()); - return debug_trace_prestate(&ctx, &ts, message_cid, &opts).await; + + 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(GethTracer::PreState(frame)); } let (state, entries) = execute_tipset_traces(&ctx, &ts, ext).await?; @@ -4133,12 +4165,12 @@ where })?; match tracer { - GethDebugBuiltInTracerType::CallTracer => { + GethDebugBuiltInTracerType::Call => { let call_config = opts.call_config(); let frame = trace::build_geth_call_frame(&mut env, execution_trace, &call_config)?; - Ok(GethTrace::CallTracer(frame.unwrap_or_default())) + Ok(GethTracer::Call(frame.unwrap_or_default())) } - GethDebugBuiltInTracerType::FlatCallTracer => { + GethDebugBuiltInTracerType::FlatCall => { trace::build_traces(&mut env, &[], execution_trace)?; let block_hash: EthHash = ts.key().cid()?.into(); let traces = env @@ -4152,60 +4184,12 @@ where transaction_position: entry.msg_position, }) .collect(); - Ok(GethTrace::FlatCallTracer(traces)) + Ok(GethTracer::FlatCall(traces)) } _ => unreachable!("noopTracer and prestateTracer handled above"), } } -/// Handles the `prestateTracer` variant of `debug_traceTransaction`. -/// -/// Replays the tipset up to the target message to obtain per-transaction -/// pre/post state roots, then builds the appropriate [`PreStateFrame`]. -async fn debug_trace_prestate( - ctx: &Ctx, - ts: &Tipset, - target_mcid: Cid, - opts: &GethDebugTracingOptions, -) -> Result -where - DB: Blockstore + Send + Sync + 'static, -{ - let prestate_config = opts.prestate_config(); - - let (pre_root, invoc_result, post_root) = ctx - .state_manager - .replay_message_with_state_roots(ts.clone(), target_mcid) - .await - .map_err(|e| anyhow::anyhow!("failed to replay message with state roots: {e}"))?; - - 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 mut touched = invoc_result - .execution_trace - .as_ref() - .map(extract_touched_eth_addresses) - .unwrap_or_default(); - - 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 frame = trace::build_prestate_frame( - ctx.store(), - &pre_state, - &post_state, - &touched, - &prestate_config, - )?; - - Ok(GethTrace::PreStateTracer(frame)) -} - pub enum EthTraceCall {} impl RpcMethod<3> for EthTraceCall { const NAME: &'static str = "Forest.EthTraceCall"; diff --git a/src/rpc/methods/eth/trace.rs b/src/rpc/methods/eth/trace.rs index 79fef2395a97..5757e4a13f35 100644 --- a/src/rpc/methods/eth/trace.rs +++ b/src/rpc/methods/eth/trace.rs @@ -2,13 +2,13 @@ // SPDX-License-Identifier: Apache-2.0, MIT use super::types::{ - CallLogFrame, EthAddress, EthBytes, EthCallTraceAction, EthHash, EthTrace, GethCallFrame, - GethCallType, TraceAction, TraceResult, + EthAddress, EthBytes, EthCallTraceAction, EthHash, EthTrace, GethCallFrame, GethCallType, + TraceAction, TraceResult, }; use super::utils::{decode_params, decode_return, parse_eth_revert}; use super::{ EthCallTraceResult, EthCreateTraceAction, EthCreateTraceResult, decode_payload, - encode_filecoin_params_as_abi, encode_filecoin_returns_as_abi, eth_log_from_event, + encode_filecoin_params_as_abi, encode_filecoin_returns_as_abi, }; use crate::eth::{EAMMethod, EVMMethod}; use crate::rpc::eth::types::{AccountDiff, CallTracerConfig, Delta, StateDiff}; @@ -16,11 +16,9 @@ use crate::rpc::eth::{EthBigInt, EthUint64}; use crate::rpc::methods::eth::lookup_eth_address; use crate::rpc::methods::state::ExecutionTrace; use crate::rpc::state::ActorTrace; -use crate::rpc::types::EventEntry; use crate::shim::actors::{EVMActorStateLoad, evm}; -use crate::shim::executor::StampedEvent; use crate::shim::fvm_shared_latest::METHOD_CONSTRUCTOR; -use crate::shim::state_tree::{ActorID, ActorState}; +use crate::shim::state_tree::ActorState; use crate::shim::{actors::is_evm_actor, address::Address, error::ExitCode, state_tree::StateTree}; use ahash::{HashMap, HashSet}; use anyhow::{Context, bail}; @@ -731,27 +729,24 @@ fn build_geth_frame_recursive( let mut frame = eth_trace_to_geth_frame(eth_trace, call_type)?; - if !tracer_cfg.only_top_call.unwrap_or_default() { - if let Some(recurse_trace) = recurse_into { - if 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); - } + 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)) @@ -785,7 +780,6 @@ fn eth_trace_to_geth_frame( error: None, revert_reason: None, calls: None, - logs: Vec::new(), }; if !is_success { @@ -813,7 +807,6 @@ fn eth_trace_to_geth_frame( error: None, revert_reason: None, calls: None, - logs: Vec::new(), }; if !is_success { @@ -920,8 +913,10 @@ fn build_account_diff( /// 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 stripped from `post`. +/// fields and accounts stripped. pub(crate) fn build_prestate_frame( store: &S, pre_state: &StateTree, @@ -931,51 +926,50 @@ pub(crate) fn build_prestate_frame( ) -> anyhow::Result { use super::types::{DiffMode, PreStateFrame, PreStateMode}; - let include_code = !config.is_code_disabled(); - let include_storage = !config.is_storage_disabled(); - 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_actor = pre_state - .get_actor(&fil_addr) - .map_err(|e| anyhow::anyhow!("failed to get pre actor state: {e}"))?; - let post_actor = post_state - .get_actor(&fil_addr) - .map_err(|e| anyhow::anyhow!("failed to get post actor state: {e}"))?; + let changed_keys = changed_storage_keys(store, pre_actor.as_ref(), post_actor.as_ref()); let pre_snap = - build_account_snapshot(store, pre_actor.as_ref(), include_code, include_storage); - let mut post_snap = - build_account_snapshot(store, post_actor.as_ref(), include_code, include_storage); - - let is_created = pre_actor.is_none() && post_actor.is_some(); - let is_deleted = pre_actor.is_some() && post_actor.is_none(); + build_account_snapshot(store, pre_actor.as_ref(), config, Some(&changed_keys)); + let post_snap = + build_account_snapshot(store, post_actor.as_ref(), config, Some(&changed_keys)); - if !is_created { - if let Some(snap) = pre_snap.as_ref() { - if !snap.is_empty() { - pre_map.insert(*eth_addr, snap.clone()); - } - } + // Created accounts (pre=None) only appear in post. + if let Some(ref snap) = pre_snap { + pre_map.insert(*eth_addr, snap.clone()); } - if !is_deleted { - if let Some(ref mut snap) = post_snap { - if let Some(pre_snap) = pre_snap.as_ref() { - snap.retain_changed(pre_snap); - } - if !snap.is_empty() { - post_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, @@ -985,17 +979,15 @@ pub(crate) fn build_prestate_frame( 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)?; - let pre_actor = pre_state - .get_actor(&fil_addr) - .map_err(|e| anyhow::anyhow!("failed to get pre actor state: {e}"))?; + let changed_keys = changed_storage_keys(store, pre_actor.as_ref(), post_actor.as_ref()); if let Some(snap) = - build_account_snapshot(store, pre_actor.as_ref(), include_code, include_storage) + build_account_snapshot(store, pre_actor.as_ref(), config, Some(&changed_keys)) { - if !snap.is_empty() { - result.insert(*eth_addr, snap); - } + result.insert(*eth_addr, snap); } } @@ -1005,42 +997,71 @@ pub(crate) fn build_prestate_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( store: &DB, actor: Option<&ActorState>, - include_code: bool, - include_storage: bool, + config: &super::types::PreStateConfig, + storage_filter: Option<&HashSet<[u8; 32]>>, ) -> Option { let actor = actor?; - let balance = Some(EthBigInt(actor.balance.atto().clone())); - let nonce = { - let n = actor_nonce(store, actor); - (n.0 != 0).then_some(n) - }; - let code = if include_code { - actor_bytecode(store, actor) - } else { + let nonce = Some(actor_nonce(store, actor)); + let code = if config.is_code_disabled() { None + } else { + actor_bytecode(store, actor) }; - let storage = if include_storage { - let entries = extract_evm_storage_entries(store, Some(actor)); - entries + let storage = if config.is_storage_disabled() { + BTreeMap::new() + } else { + extract_evm_storage_entries(store, Some(actor)) .into_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() - } else { - BTreeMap::new() }; Some(super::types::AccountState { - balance, + balance: Some(EthBigInt(actor.balance.atto().clone())), code, nonce, storage, }) } +/// Compute the set of storage keys that differ between pre and post actor states. +fn changed_storage_keys( + store: &DB, + pre_actor: Option<&ActorState>, + post_actor: Option<&ActorState>, +) -> HashSet<[u8; 32]> { + let pre_entries = extract_evm_storage_entries(store, pre_actor); + let post_entries = extract_evm_storage_entries(store, post_actor); + + 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 +} + /// Compute storage diff between pre and post actor states. /// /// Uses different Delta types based on the scenario: diff --git a/src/rpc/methods/eth/types.rs b/src/rpc/methods/eth/types.rs index 670744c1a77d..669201bbe7bd 100644 --- a/src/rpc/methods/eth/types.rs +++ b/src/rpc/methods/eth/types.rs @@ -652,13 +652,13 @@ impl Default for TraceResult { #[derive(PartialEq, Debug, Clone, Serialize, Deserialize, JsonSchema)] pub enum GethDebugBuiltInTracerType { #[serde(rename = "callTracer")] - CallTracer, + Call, #[serde(rename = "flatCallTracer")] - FlatCallTracer, + FlatCall, #[serde(rename = "prestateTracer")] - PreStateTracer, + PreState, #[serde(rename = "noopTracer")] - NoopTracer, + Noop, } /// Options for the `debug_traceTransaction` API. @@ -777,10 +777,6 @@ impl GethCallType { matches!(self, Self::StaticCall) } - pub const fn is_delegate_call(&self) -> bool { - matches!(self, Self::DelegateCall) - } - /// Converts a Parity-style call type string to a [`GethCallType`]. pub fn from_parity_call_type(call_type: &str) -> Self { match call_type { @@ -818,27 +814,10 @@ pub struct GethCallFrame { pub revert_reason: Option, #[serde(skip_serializing_if = "Option::is_none")] pub calls: Option>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub logs: Vec, } lotus_json_with_self!(GethCallFrame); -/// A log entry emitted during a traced call, attached to a [`GethCallFrame`] -/// when `withLog: true` is set in the callTracer config. -#[derive(PartialEq, Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct CallLogFrame { - #[serde(skip_serializing_if = "Option::is_none")] - pub address: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub topics: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option, -} - -lotus_json_with_self!(CallLogFrame); - /// Empty frame returned by the `noopTracer`. #[derive(PartialEq, Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] pub struct NoopFrame {} @@ -919,14 +898,14 @@ lotus_json_with_self!(PreStateFrame); /// The shape depends on the selected tracer. #[derive(PartialEq, Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(untagged)] -pub enum GethTrace { - CallTracer(GethCallFrame), - FlatCallTracer(Vec), - PreStateTracer(PreStateFrame), - NoopTracer(NoopFrame), +pub enum GethTracer { + Call(GethCallFrame), + FlatCall(Vec), + PreState(PreStateFrame), + Noop(NoopFrame), } -lotus_json_with_self!(GethTrace); +lotus_json_with_self!(GethTracer); /// Selects which trace outputs to include in the `trace_call` response. #[derive(PartialEq, Debug, Clone, Serialize, Deserialize, JsonSchema)] diff --git a/src/rpc/methods/eth/utils.rs b/src/rpc/methods/eth/utils.rs index 2ed1c4b3d047..5657a72d1c20 100644 --- a/src/rpc/methods/eth/utils.rs +++ b/src/rpc/methods/eth/utils.rs @@ -199,7 +199,8 @@ fn parse_error_revert(data: &[u8]) -> String { // Attempt to decode valid UTF-8 let string = data.get(string_start..string_start + len).ok_or(())?; - Ok(format!("{}", std::str::from_utf8(string).map_err(|_| ())?)) + // TODO: Earlier we used to return `Error()` format same as lotus but now we are matching Geth + Ok(std::str::from_utf8(string).map_err(|_| ())?.to_string()) })(); parse_result.unwrap_or_else(|_| fallback()) diff --git a/src/rpc/methods/state/types.rs b/src/rpc/methods/state/types.rs index dacdbc9e4400..65a9f20efb60 100644 --- a/src/rpc/methods/state/types.rs +++ b/src/rpc/methods/state/types.rs @@ -4,7 +4,7 @@ use crate::blocks::TipsetKey; use crate::lotus_json::{LotusJson, lotus_json_with_self}; use crate::message::Message as _; -use crate::shim::executor::{ApplyRet, StampedEvent}; +use crate::shim::executor::ApplyRet; use crate::shim::{ address::Address, clock::ChainEpoch, diff --git a/src/shim/executor.rs b/src/shim/executor.rs index f2ea00a61f8b..8c61dc085344 100644 --- a/src/shim/executor.rs +++ b/src/shim/executor.rs @@ -22,7 +22,7 @@ use fvm_shared4::receipt::Receipt as Receipt_v4; use fvm2::executor::ApplyRet as ApplyRet_v2; use fvm3::executor::ApplyRet as ApplyRet_v3; use fvm4::executor::ApplyRet as ApplyRet_v4; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use spire_enum::prelude::delegated_enum; use std::borrow::Borrow as _; diff --git a/src/state_manager/mod.rs b/src/state_manager/mod.rs index bbb50b26a67e..d49dad55d0c1 100644 --- a/src/state_manager/mod.rs +++ b/src/state_manager/mod.rs @@ -851,92 +851,46 @@ where api_invoc_result.ok_or_else(|| Error::Other("failed to replay".into())) } - /// Replays a tipset up to a specific message, returning the pre-execution - /// and post-execution state roots alongside the traced invocation result. - /// - /// This is used by the `prestateTracer` in `debug_traceTransaction` which - /// needs to compare account states before and after a single transaction. - pub async fn replay_message_with_state_roots( + /// 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_message_with_state_roots_blocking(ts, target_mcid) - }) - .await - .map_err(|e| Error::Other(format!("{e}")))? + tokio::task::spawn_blocking(move || this.replay_for_prestate_blocking(ts, target_mcid)) + .await + .map_err(|e| Error::Other(format!("{e}")))? } - /// Blocking version of [`Self::replay_message_with_state_roots`]. - fn replay_message_with_state_roots_blocking( + fn replay_for_prestate_blocking( self: &Arc, ts: Tipset, target_mcid: Cid, ) -> Result<(Cid, ApiInvocResult, Cid), Error> { - use crate::shim::clock::EPOCH_DURATION_SECONDS; - use ahash::HashSet; + 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 chain_config = self.chain_config().clone(); - let chain_index = self.chain_index().clone(); - let rand = ChainRand::new( - chain_config.clone(), - ts.clone(), - chain_index.clone(), + 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)?; - let genesis_info = GenesisInfo::from_chain_config(chain_config.clone()); - let create_vm = |state_root: Cid, epoch, timestamp, trace_config| { - let circulating_supply = - genesis_info.get_vm_circulating_supply(epoch, chain_index.db(), &state_root)?; - VM::new( - ExecutionContext { - heaviest_tipset: ts.clone(), - state_tree_root: state_root, - epoch, - rand: Box::new(rand.clone()), - base_fee: ts.min_ticket_block().parent_base_fee.clone(), - circ_supply: circulating_supply, - chain_config: chain_config.clone(), - chain_index: chain_index.clone(), - timestamp, - }, - &self.engine, - trace_config, - ) - }; - - let mut parent_state = *ts.parent_state(); - let parent_epoch = Tipset::load_required(chain_index.db(), ts.parents())?.epoch(); - let epoch = ts.epoch(); - - for epoch_i in parent_epoch..epoch { - if epoch_i > parent_epoch { - let timestamp = genesis_timestamp + ((EPOCH_DURATION_SECONDS * epoch_i) as u64); - parent_state = stacker::grow(64 << 20, || -> anyhow::Result { - let mut vm = create_vm(parent_state, epoch_i, timestamp, VMTrace::NotTraced)?; - if let Err(e) = vm.run_cron(epoch_i, NO_CALLBACK) { - error!("Beginning of epoch cron failed to run: {e}"); - } - vm.flush() - })?; - } - if let Some(new_state) = - run_state_migrations(epoch_i, &chain_config, chain_index.db(), &parent_state)? - { - parent_state = new_state; - } - } - - let block_messages = BlockMessages::for_tipset(chain_index.db(), &ts) - .map_err(|e| Error::Other(e.to_string()))?; - - stacker::grow(64 << 20, || -> Result<(Cid, ApiInvocResult, Cid), Error> { - let mut vm = create_vm(parent_state, epoch, ts.min_timestamp(), VMTrace::NotTraced)?; - let mut processed = HashSet::default(); + 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(); @@ -951,24 +905,25 @@ where if cid == target_mcid { let pre_root = vm.flush()?; - let mut traced_vm = - create_vm(pre_root, epoch, ts.min_timestamp(), VMTrace::Traced)?; + 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()?; - let invoc_result = 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(), - }; - - return Ok((pre_root, invoc_result, post_root)); + 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)?; @@ -981,18 +936,16 @@ where { let (ret, _) = vm.apply_implicit_message(&rew_msg)?; if let Some(err) = ret.failure_info() { - return Err(Error::Other(format!( + bail!( "failed to apply reward message for miner {}: {err}", block.miner - ))); + ); } } } - Err(Error::Other(format!( - "message {target_mcid} not found in tipset" - ))) - }) + bail!("message {target_mcid} not found in tipset") + })?) } /// Checks the eligibility of the miner. This is used in the validation that @@ -2000,6 +1953,119 @@ where }) } +/// Shared context for creating VMs and preparing tipset state. +/// +/// Encapsulates the common setup needed by both [`apply_block_messages`] and +/// [`StateManager::replay_for_prestate_blocking`]: randomness source, genesis +/// info, VM construction, null-epoch cron handling, and state migrations. +struct TipsetExecutor<'a, DB: Blockstore + Send + Sync + 'static> { + tipset: Tipset, + rand: ChainRand, + chain_config: Arc, + chain_index: Arc>>, + genesis_info: GenesisInfo, + engine: &'a MultiEngine, +} + +impl<'a, DB: Blockstore + Send + Sync + 'static> TipsetExecutor<'a, DB> { + fn new( + chain_index: Arc>>, + chain_config: Arc, + beacon: Arc, + engine: &'a MultiEngine, + tipset: Tipset, + ) -> Self { + let rand = ChainRand::new( + chain_config.clone(), + tipset.clone(), + chain_index.clone(), + beacon, + ); + let genesis_info = GenesisInfo::from_chain_config(chain_config.clone()); + Self { + tipset, + rand, + chain_config, + chain_index, + genesis_info, + engine, + } + } + + fn create_vm( + &self, + state_root: Cid, + epoch: ChainEpoch, + timestamp: u64, + trace: VMTrace, + ) -> anyhow::Result> { + let circ_supply = self.genesis_info.get_vm_circulating_supply( + epoch, + self.chain_index.db(), + &state_root, + )?; + VM::new( + ExecutionContext { + heaviest_tipset: self.tipset.clone(), + state_tree_root: state_root, + epoch, + rand: Box::new(self.rand.clone()), + base_fee: self.tipset.min_ticket_block().parent_base_fee.clone(), + circ_supply, + chain_config: self.chain_config.clone(), + chain_index: self.chain_index.clone(), + timestamp, + }, + self.engine, + trace, + ) + } + + /// Runs null-epoch `crons` and state migrations, producing the state root + /// ready for message execution and the block messages for the tipset. + fn prepare_parent_state( + &self, + genesis_timestamp: u64, + null_epoch_trace: VMTrace, + cron_callback: &mut Option, + ) -> anyhow::Result<(Cid, ChainEpoch, Vec)> + where + F: FnMut(MessageCallbackCtx<'_>) -> anyhow::Result<()>, + { + use crate::shim::clock::EPOCH_DURATION_SECONDS; + + let mut parent_state = *self.tipset.parent_state(); + let parent_epoch = + Tipset::load_required(self.chain_index.db(), self.tipset.parents())?.epoch(); + let epoch = self.tipset.epoch(); + + for epoch_i in parent_epoch..epoch { + if epoch_i > parent_epoch { + let timestamp = genesis_timestamp + ((EPOCH_DURATION_SECONDS * epoch_i) as u64); + parent_state = stacker::grow(64 << 20, || -> anyhow::Result { + let mut vm = + self.create_vm(parent_state, epoch_i, timestamp, null_epoch_trace)?; + if let Err(e) = vm.run_cron(epoch_i, cron_callback.as_mut()) { + error!("Beginning of epoch cron failed to run: {e}"); + } + vm.flush() + })?; + } + if let Some(new_state) = run_state_migrations( + epoch_i, + &self.chain_config, + self.chain_index.db(), + &parent_state, + )? { + parent_state = new_state; + } + } + + let block_messages = BlockMessages::for_tipset(self.chain_index.db(), &self.tipset)?; + Ok((parent_state, epoch, block_messages)) + } +} + /// Messages are transactions that produce new states. The state (usually /// referred to as the 'state-tree') is a mapping from actor addresses to actor /// states. Each block contains the hash of the state-tree that should be used @@ -2090,19 +2156,8 @@ pub fn apply_block_messages( where DB: Blockstore + Send + Sync + 'static, { - // This function will: - // 1. handle the genesis block as a special case - // 2. run 'cron' for any null-tipsets between the current tipset and our parent tipset - // 3. run migrations - // 4. execute block messages - // 5. write the state-tree to the DB and return the CID - - // step 1: special case for genesis block + // Special case for genesis block. if tipset.epoch() == 0 { - // NB: This is here because the process that executes blocks requires that the - // block miner reference a valid miner in the state tree. Unless we create some - // magical genesis miner, this won't work properly, so we short circuit here - // This avoids the question of 'who gets paid the genesis block reward' let message_receipts = tipset.min_ticket_block().message_receipts; return Ok(StateOutput { state_root: *tipset.parent_state(), @@ -2112,83 +2167,28 @@ where }); } - let rand = ChainRand::new( - Arc::clone(&chain_config), - tipset.clone(), - Arc::clone(&chain_index), + let exec = TipsetExecutor::new( + chain_index.clone(), + chain_config, beacon, + engine, + tipset.clone(), ); - - let genesis_info = GenesisInfo::from_chain_config(chain_config.clone()); - let create_vm = |state_root: Cid, epoch, timestamp| { - let circulating_supply = - genesis_info.get_vm_circulating_supply(epoch, chain_index.db(), &state_root)?; - VM::new( - ExecutionContext { - heaviest_tipset: tipset.clone(), - state_tree_root: state_root, - epoch, - rand: Box::new(rand.clone()), - base_fee: tipset.min_ticket_block().parent_base_fee.clone(), - circ_supply: circulating_supply, - chain_config: Arc::clone(&chain_config), - chain_index: Arc::clone(&chain_index), - timestamp, - }, - engine, - enable_tracing, - ) - }; - - let mut parent_state = *tipset.parent_state(); - - let parent_epoch = Tipset::load_required(chain_index.db(), tipset.parents())?.epoch(); - let epoch = tipset.epoch(); - - for epoch_i in parent_epoch..epoch { - if epoch_i > parent_epoch { - // step 2: running cron for any null-tipsets - let timestamp = genesis_timestamp + ((EPOCH_DURATION_SECONDS * epoch_i) as u64); - - // FVM requires a stack size of 64MiB. The alternative is to use `ThreadedExecutor` from - // FVM, but that introduces some constraints, and possible deadlocks. - parent_state = stacker::grow(64 << 20, || -> anyhow::Result { - let mut vm = create_vm(parent_state, epoch_i, timestamp)?; - // run cron for null rounds if any - if let Err(e) = vm.run_cron(epoch_i, callback.as_mut()) { - error!("Beginning of epoch cron failed to run: {}", e); - } - vm.flush() - })?; - } - - // step 3: run migrations - if let Some(new_state) = - run_state_migrations(epoch_i, &chain_config, chain_index.db(), &parent_state)? - { - parent_state = new_state; - } - } - - let block_messages = BlockMessages::for_tipset(chain_index.db(), &tipset) - .map_err(|e| Error::Other(e.to_string()))?; + let (parent_state, epoch, block_messages) = + exec.prepare_parent_state(genesis_timestamp, enable_tracing, &mut callback)?; // FVM requires a stack size of 64MiB. The alternative is to use `ThreadedExecutor` from // FVM, but that introduces some constraints, and possible deadlocks. stacker::grow(64 << 20, || -> anyhow::Result { - let mut vm = create_vm(parent_state, epoch, tipset.min_timestamp())?; + let mut vm = exec.create_vm(parent_state, epoch, tipset.min_timestamp(), enable_tracing)?; - // step 4: apply tipset messages let (receipts, events, events_roots) = vm.apply_block_messages(&block_messages, epoch, callback)?; - // step 5: construct receipt root from receipts let receipt_root = Amtv0::new_from_iter(chain_index.db(), receipts)?; - // step 6: store events AMTs in the blockstore for (msg_events, events_root) in events.iter().zip(events_roots.iter()) { if let Some(event_root) = events_root { - // Store the events AMT - the root CID should match the one computed by FVM let derived_event_root = Amt::new_from_iter_with_bit_width( chain_index.db(), EVENTS_AMT_BITWIDTH, @@ -2196,7 +2196,6 @@ where ) .map_err(|e| Error::Other(format!("failed to store events AMT: {e}")))?; - // Verify the stored root matches the FVM-computed root ensure!( derived_event_root.eq(event_root), "Events AMT root mismatch: derived={derived_event_root}, actual={event_root}." From 10d1e0fafd1411679a01744f4af2fbe246ac980c Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Mon, 9 Mar 2026 20:05:26 +0530 Subject: [PATCH 07/14] add docs for the debug trace transaction API [skip ci] --- .../guides/debug_trace_transaction_guide.md | 248 +++++++++++++ .../rpc/debug_trace_transaction.md | 347 ++++++++++++++++++ 2 files changed, 595 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..e2520322e1d9 --- /dev/null +++ b/docs/docs/developers/guides/debug_trace_transaction_guide.md @@ -0,0 +1,248 @@ +# 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 [debug_traceTransaction 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. + +### Comparing Forest vs Anvil Responses + +Both Forest and Anvil use the same `debug_traceTransaction` method and tracer format, so responses can be compared directly (unlike `trace_call` vs `debug_traceCall` which use different formats). + +**Forest:** + +```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 | + +## Integration Test Script + +An automated test script is available to compare Forest's `debug_traceTransaction` with Anvil: + +```bash +# Run the test (requires Forest and Anvil running) +./scripts/tests/debug_trace_transaction_test.sh + +# Deploy contract on Anvil first, forest and anvil node should already be running +./scripts/tests/debug_trace_transaction_test.sh --deploy +``` + +### Test Categories + +1. **Prestate Tests**: Pre/post state snapshots, default mode, diff mode +2. **Call Trace Tests**: Call hierarchy, nested calls, reverts +3. **Storage Tests**: Single slot, multiple slots, add/change/delete patterns + +## 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..8ebb8733ed22 --- /dev/null +++ b/docs/docs/users/knowledge_base/rpc/debug_trace_transaction.md @@ -0,0 +1,347 @@ +# 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...", // Transaction hash + { + "tracer": "prestateTracer", // Tracer type + "tracerConfig": { "diffMode": false } // Tracer-specific config (optional) + } + ] +} +``` + +### 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 | + +### Tracer Configuration + +#### `prestateTracer` Config + +| Option | Type | Default | Description | +| ---------- | ---- | ------- | ------------------------------------------------ | +| `diffMode` | bool | `false` | When `true`, returns both `pre` and `post` state | + +#### `callTracer` Config + +| Option | Type | Default | Description | +| ------------- | ---- | ------- | -------------------------------- | +| `onlyTopCall` | bool | `false` | When `true`, only trace top call | + +## 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 txns 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 0ad63096ac9abb080675527ffe134fc6c77bd661 Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Mon, 9 Mar 2026 23:36:23 +0530 Subject: [PATCH 08/14] add comments and refactor --- src/rpc/methods/eth.rs | 13 ++++++------ src/rpc/methods/eth/trace.rs | 4 ---- src/rpc/methods/eth/types.rs | 39 ++++++++++++++++++++++++------------ src/rpc/methods/eth/utils.rs | 6 ++++-- 4 files changed, 36 insertions(+), 26 deletions(-) diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index c12e9605b859..35a9120f993e 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -3456,7 +3456,7 @@ impl RpcMethod<2> for EthDebugTraceTransaction { Some("Replays a transaction and returns execution traces in Geth-compatible format."); type Params = (String, Option); - type Ok = GethTracer; + type Ok = GethTrace; async fn handle( ctx: Ctx, @@ -3473,7 +3473,7 @@ async fn debug_trace_transaction( ext: &Extensions, tx_hash: String, opts: GethDebugTracingOptions, -) -> Result +) -> Result where DB: Blockstore + Send + Sync + 'static, { @@ -3483,11 +3483,10 @@ where .unwrap_or(GethDebugBuiltInTracerType::Call); if tracer == GethDebugBuiltInTracerType::Noop { - return Ok(GethTracer::Noop(NoopFrame {})); + return Ok(GethTrace::Noop(NoopFrame {})); } let eth_hash = EthHash::from_str(&tx_hash).context("invalid transaction hash")?; - // TODO: We should return if the transaction is in pending state, need to verify this behaviour in Geth and Reth let eth_txn = get_eth_transaction_by_hash(&ctx, ð_hash, None) .await? .ok_or(ServerError::internal_error("transaction not found", None))?; @@ -3534,7 +3533,7 @@ where &touched, &prestate_config, )?; - return Ok(GethTracer::PreState(frame)); + return Ok(GethTrace::PreState(frame)); } let (state, entries) = execute_tipset_traces(&ctx, &ts, ext).await?; @@ -3559,7 +3558,7 @@ where GethDebugBuiltInTracerType::Call => { let call_config = opts.call_config(); let frame = trace::build_geth_call_frame(&mut env, execution_trace, &call_config)?; - Ok(GethTracer::Call(frame.unwrap_or_default())) + Ok(GethTrace::Call(frame.unwrap_or_default())) } GethDebugBuiltInTracerType::FlatCall => { trace::build_traces(&mut env, &[], execution_trace)?; @@ -3575,7 +3574,7 @@ where transaction_position: entry.msg_position, }) .collect(); - Ok(GethTracer::FlatCall(traces)) + Ok(GethTrace::FlatCall(traces)) } _ => unreachable!("noopTracer and prestateTracer handled above"), } diff --git a/src/rpc/methods/eth/trace.rs b/src/rpc/methods/eth/trace.rs index 5757e4a13f35..59d1c7f093b9 100644 --- a/src/rpc/methods/eth/trace.rs +++ b/src/rpc/methods/eth/trace.rs @@ -682,10 +682,6 @@ fn trace_evm_private( /// /// Reuses [`build_trace`] for classification and data extraction, then converts /// the Parity-style [`EthTrace`] into a nested [`GethCallFrame`]. -/// -/// When `with_log` is set and `events` is non-empty, logs are correlated to -/// call frames by matching each event's emitter actor ID against the frame's -/// invoked actor. pub fn build_geth_call_frame( env: &mut Environment, trace: ExecutionTrace, diff --git a/src/rpc/methods/eth/types.rs b/src/rpc/methods/eth/types.rs index c34665d8d1e9..36708a84ae5d 100644 --- a/src/rpc/methods/eth/types.rs +++ b/src/rpc/methods/eth/types.rs @@ -649,14 +649,19 @@ 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, } @@ -665,8 +670,11 @@ pub enum GethDebugBuiltInTracerType { #[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, } @@ -697,23 +705,26 @@ impl GethDebugTracingOptions { #[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, - #[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#L14 +// 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, } @@ -735,9 +746,6 @@ impl PreStateConfig { } /// Opaque JSON blob for per-tracer configuration. -/// -/// Exists as a newtype because `serde_json::Value` does not implement -/// `JsonSchema`. The actual interpretation depends on the selected tracer. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(transparent)] pub struct TracerConfig(pub serde_json::Value); @@ -871,13 +879,13 @@ pub struct PreStateMode(pub BTreeMap); lotus_json_with_self!(PreStateMode); -/// Diff mode: separate `pre` and `post` account snapshots. -/// Created accounts appear only in `post`; deleted accounts appear only in `pre`. -/// Unchanged fields are stripped from `post` entries. +/// 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, } @@ -888,24 +896,29 @@ lotus_json_with_self!(DiffMode); #[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); -/// Polymorphic trace result from `debug_traceTransaction`. -/// The shape depends on the selected tracer. +/// Tracing response objects #[derive(PartialEq, Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(untagged)] -pub enum GethTracer { +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!(GethTracer); +lotus_json_with_self!(GethTrace); /// Selects which trace outputs to include in the `trace_call` response. #[derive(PartialEq, Debug, Clone, Serialize, Deserialize, JsonSchema)] diff --git a/src/rpc/methods/eth/utils.rs b/src/rpc/methods/eth/utils.rs index 5657a72d1c20..4e4f91649061 100644 --- a/src/rpc/methods/eth/utils.rs +++ b/src/rpc/methods/eth/utils.rs @@ -199,8 +199,10 @@ fn parse_error_revert(data: &[u8]) -> String { // Attempt to decode valid UTF-8 let string = data.get(string_start..string_start + len).ok_or(())?; - // TODO: Earlier we used to return `Error()` format same as lotus but now we are matching Geth - Ok(std::str::from_utf8(string).map_err(|_| ())?.to_string()) + Ok(format!( + "Error({})", + std::str::from_utf8(string).map_err(|_| ())? + )) })(); parse_result.unwrap_or_else(|_| fallback()) From d96eeb20eb0a157d6349cc3ef8b9910202b13876 Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Mon, 9 Mar 2026 23:40:09 +0530 Subject: [PATCH 09/14] update snap files --- src/rpc/methods/eth/types.rs | 2 +- .../forest__rpc__tests__rpc__v0.snap | 217 +++++++++++++++++- .../forest__rpc__tests__rpc__v1.snap | 209 ++++++++++++++++- .../forest__rpc__tests__rpc__v2.snap | 209 ++++++++++++++++- .../api_cmd/test_snapshots_ignored.txt | 1 + 5 files changed, 613 insertions(+), 25 deletions(-) diff --git a/src/rpc/methods/eth/types.rs b/src/rpc/methods/eth/types.rs index 36708a84ae5d..5635a2ef2ceb 100644 --- a/src/rpc/methods/eth/types.rs +++ b/src/rpc/methods/eth/types.rs @@ -871,7 +871,7 @@ impl AccountState { } } -/// Default prestate mode: flat map of address → pre-execution account state. +/// 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)] diff --git a/src/rpc/snapshots/forest__rpc__tests__rpc__v0.snap b/src/rpc/snapshots/forest__rpc__tests__rpc__v0.snap index c05c7b15059b..cc707dda4e36 100644 --- a/src/rpc/snapshots/forest__rpc__tests__rpc__v0.snap +++ b/src/rpc/snapshots/forest__rpc__tests__rpc__v0.snap @@ -4337,6 +4337,26 @@ methods: paramStructure: by-position components: schemas: + AccountDiff: + description: "Account state diff after transaction execution.\nTracks changes to balance, nonce, code, and storage." + type: object + properties: + balance: + $ref: "#/components/schemas/Delta" + code: + $ref: "#/components/schemas/Delta2" + nonce: + $ref: "#/components/schemas/Delta3" + storage: + description: All touched/changed storage values (key -> delta) + type: object + additionalProperties: + $ref: "#/components/schemas/Delta4" + required: + - balance + - code + - nonce + - storage ActorEvent: type: object properties: @@ -5113,6 +5133,58 @@ components: - tipset_keys - skip_checksum - dry_run + ChangedType: + description: Represents a changed value with before and after states. + type: object + properties: + from: + description: Value before the change + $ref: "#/components/schemas/EthBigInt" + to: + description: Value after the change + $ref: "#/components/schemas/EthBigInt" + required: + - from + - to + ChangedType2: + description: Represents a changed value with before and after states. + type: object + properties: + from: + description: Value before the change + $ref: "#/components/schemas/EthBytes" + to: + description: Value after the change + $ref: "#/components/schemas/EthBytes" + required: + - from + - to + ChangedType3: + description: Represents a changed value with before and after states. + type: object + properties: + from: + description: Value before the change + $ref: "#/components/schemas/EthUint64" + to: + description: Value after the change + $ref: "#/components/schemas/EthUint64" + required: + - from + - to + ChangedType4: + description: Represents a changed value with before and after states. + type: object + properties: + from: + description: Value before the change + $ref: "#/components/schemas/EthHash" + to: + description: Value after the change + $ref: "#/components/schemas/EthHash" + required: + - from + - to Cid: type: object properties: @@ -5213,6 +5285,126 @@ components: required: - Min - Max + Delta: + description: Represents how a value changed during transaction execution. + oneOf: + - description: "Existing value didn't change." + type: string + const: "=" + - description: A new value was added (account/storage created). + type: object + properties: + +: + $ref: "#/components/schemas/EthBigInt" + additionalProperties: false + required: + - + + - description: The existing value was removed (account/storage deleted). + type: object + properties: + "-": + $ref: "#/components/schemas/EthBigInt" + additionalProperties: false + required: + - "-" + - description: The existing value changed from one value to another. + type: object + properties: + "*": + $ref: "#/components/schemas/ChangedType" + additionalProperties: false + required: + - "*" + Delta2: + description: Represents how a value changed during transaction execution. + oneOf: + - description: "Existing value didn't change." + type: string + const: "=" + - description: A new value was added (account/storage created). + type: object + properties: + +: + $ref: "#/components/schemas/EthBytes" + additionalProperties: false + required: + - + + - description: The existing value was removed (account/storage deleted). + type: object + properties: + "-": + $ref: "#/components/schemas/EthBytes" + additionalProperties: false + required: + - "-" + - description: The existing value changed from one value to another. + type: object + properties: + "*": + $ref: "#/components/schemas/ChangedType2" + additionalProperties: false + required: + - "*" + Delta3: + description: Represents how a value changed during transaction execution. + oneOf: + - description: "Existing value didn't change." + type: string + const: "=" + - description: A new value was added (account/storage created). + type: object + properties: + +: + $ref: "#/components/schemas/EthUint64" + additionalProperties: false + required: + - + + - description: The existing value was removed (account/storage deleted). + type: object + properties: + "-": + $ref: "#/components/schemas/EthUint64" + additionalProperties: false + required: + - "-" + - description: The existing value changed from one value to another. + type: object + properties: + "*": + $ref: "#/components/schemas/ChangedType3" + additionalProperties: false + required: + - "*" + Delta4: + description: Represents how a value changed during transaction execution. + oneOf: + - description: "Existing value didn't change." + type: string + const: "=" + - description: A new value was added (account/storage created). + type: object + properties: + +: + $ref: "#/components/schemas/EthHash" + additionalProperties: false + required: + - + + - description: The existing value was removed (account/storage deleted). + type: object + properties: + "-": + $ref: "#/components/schemas/EthHash" + additionalProperties: false + required: + - "-" + - description: The existing value changed from one value to another. + type: object + properties: + "*": + $ref: "#/components/schemas/ChangedType4" + additionalProperties: false + required: + - "*" ECTipSet: type: object properties: @@ -5344,6 +5536,10 @@ components: anyOf: - $ref: "#/components/schemas/EthBigInt" - type: "null" + input: + anyOf: + - $ref: "#/components/schemas/EthBytes" + - type: "null" to: anyOf: - $ref: "#/components/schemas/EthAddress" @@ -5531,27 +5727,27 @@ components: - blockHash - blockNumber EthReplayBlockTransactionTrace: + description: "Result payload returned by `trace_call`." type: object properties: output: + description: Output bytes from the transaction execution. $ref: "#/components/schemas/EthBytes" stateDiff: - type: - - string - - "null" + description: State diff showing all account changes. + anyOf: + - $ref: "#/components/schemas/StateDiff" + - type: "null" trace: + description: Call trace hierarchy (empty when not requested). type: array + default: [] items: $ref: "#/components/schemas/EthTrace" transactionHash: $ref: "#/components/schemas/EthHash" - vmTrace: - type: - - string - - "null" required: - output - - trace - transactionHash EthSyncingResultLotusJson: anyOf: @@ -7330,6 +7526,11 @@ components: - Manifest - Bundle - ActorCids + StateDiff: + description: State diff containing all account changes from a transaction. + type: object + additionalProperties: + $ref: "#/components/schemas/AccountDiff" SupplementalData: type: object properties: diff --git a/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap b/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap index 5a769b287320..c5fc4792f841 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: Filecoin.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: Filecoin.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: @@ -5553,6 +5628,10 @@ components: anyOf: - $ref: "#/components/schemas/EthBigInt" - type: "null" + input: + anyOf: + - $ref: "#/components/schemas/EthBytes" + - type: "null" to: anyOf: - $ref: "#/components/schemas/EthAddress" @@ -5740,27 +5819,27 @@ components: - blockHash - blockNumber EthReplayBlockTransactionTrace: + description: "Result payload returned by `trace_call`." type: object properties: output: + description: Output bytes from the transaction execution. $ref: "#/components/schemas/EthBytes" stateDiff: - type: - - string - - "null" + description: State diff showing all account changes. + anyOf: + - $ref: "#/components/schemas/StateDiff" + - type: "null" trace: + description: Call trace hierarchy (empty when not requested). type: array + default: [] items: $ref: "#/components/schemas/EthTrace" transactionHash: $ref: "#/components/schemas/EthHash" - vmTrace: - type: - - string - - "null" required: - output - - trace - transactionHash EthSyncingResultLotusJson: anyOf: @@ -6499,6 +6578,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: @@ -7052,6 +7228,9 @@ components: minItems: 1 Nonce: type: string + NoopFrame: + description: "Empty frame returned by the `noopTracer`." + type: object Nullable_Address: anyOf: - $ref: "#/components/schemas/Address" @@ -7279,6 +7458,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: @@ -7664,6 +7855,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 f7b79ee27fdb..de1a87219a10 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: Filecoin.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: Filecoin.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: @@ -2024,6 +2099,10 @@ components: anyOf: - $ref: "#/components/schemas/EthBigInt" - type: "null" + input: + anyOf: + - $ref: "#/components/schemas/EthBytes" + - type: "null" to: anyOf: - $ref: "#/components/schemas/EthAddress" @@ -2211,27 +2290,27 @@ components: - blockHash - blockNumber EthReplayBlockTransactionTrace: + description: "Result payload returned by `trace_call`." type: object properties: output: + description: Output bytes from the transaction execution. $ref: "#/components/schemas/EthBytes" stateDiff: - type: - - string - - "null" + description: State diff showing all account changes. + anyOf: + - $ref: "#/components/schemas/StateDiff" + - type: "null" trace: + description: Call trace hierarchy (empty when not requested). type: array + default: [] items: $ref: "#/components/schemas/EthTrace" transactionHash: $ref: "#/components/schemas/EthHash" - vmTrace: - type: - - string - - "null" required: - output - - trace - transactionHash EthSyncingResultLotusJson: anyOf: @@ -2458,6 +2537,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: @@ -2475,6 +2651,9 @@ components: minItems: 1 Nonce: type: string + NoopFrame: + description: "Empty frame returned by the `noopTracer`." + type: object Nullable_Address: anyOf: - $ref: "#/components/schemas/Address" @@ -2519,6 +2698,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: @@ -2624,6 +2815,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..3924ea422997 100644 --- a/src/tool/subcommands/api_cmd/test_snapshots_ignored.txt +++ b/src/tool/subcommands/api_cmd/test_snapshots_ignored.txt @@ -84,6 +84,7 @@ Forest.ChainExportStatus Forest.ChainGetMinBaseFee Forest.ChainGetTipsetByParentState Forest.EthTraceCall +Forest.EthDebugTraceTransaction Forest.NetInfo Forest.SnapshotGC Forest.StateActorInfo From 318ffb98484a73068d8e8ba43a663e5f65c435cc Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Tue, 10 Mar 2026 11:57:33 +0530 Subject: [PATCH 10/14] fix linter issues --- docs/dictionary.txt | 9 ++ .../guides/debug_trace_transaction_guide.md | 97 +++++++++++-------- .../rpc/debug_trace_transaction.md | 67 ++++++------- src/rpc/methods/eth.rs | 14 +-- .../api_cmd/test_snapshots_ignored.txt | 2 +- 5 files changed, 110 insertions(+), 79 deletions(-) diff --git a/docs/dictionary.txt b/docs/dictionary.txt index 4a4190eb96ca..695be020a03b 100644 --- a/docs/dictionary.txt +++ b/docs/dictionary.txt @@ -173,3 +173,12 @@ VPS WebSocket WIP zstd +Pre +pre +Post +post +Coinbase +EOAs +KAMT +Merkle +Trie diff --git a/docs/docs/developers/guides/debug_trace_transaction_guide.md b/docs/docs/developers/guides/debug_trace_transaction_guide.md index e2520322e1d9..8d51da038360 100644 --- a/docs/docs/developers/guides/debug_trace_transaction_guide.md +++ b/docs/docs/developers/guides/debug_trace_transaction_guide.md @@ -1,6 +1,6 @@ -# debug_traceTransaction Developer Guide +# `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 [debug_traceTransaction API guide](/knowledge_base/rpc/debug_trace_transaction). +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 @@ -90,7 +90,7 @@ Anvil uses the same **Geth style** tracing as `debug_traceTransaction`, making i ### Starting Anvil ```bash -# Start Anvil with tracer to allow debug_traceTransaction API's +# Start Anvil with tracer to allow `debug_traceTransaction` API's anvil --tracing ``` @@ -139,11 +139,50 @@ cast send $ANVIL_CONTRACT "storageMultiple(uint256,uint256,uint256)" 10 20 30 \ Save the transaction hashes from the output for use in the tracing examples below. +### Getting the correct transaction hash on Forest + +When you send a transaction to Forest (e.g. with `cast send` or another client), the value returned may be a **Filecoin message CID** (or another identifier), not the canonical **Ethereum transaction hash** that `debug_traceTransaction` expects. + +To obtain the canonical `hash` (EthHash) that Forest uses for tracing: + +1. Call `eth_getTransactionByHash` with the hash you received when sending the transaction (e.g. the value returned by `cast send`). +2. From the response, use the **`hash`** field of the returned transaction object. That is the canonical Ethereum transaction hash; use it when calling `debug_traceTransaction`. + +Example: resolve the hash and then trace: + +```bash +# 1. Get the canonical transaction hash (`EthHash`) by querying with the hash from cast send. +HASH_FROM_CAST="0x..." # or your Filecoin-style identifier +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_FROM_CAST"'"] + }' | jq -r '.result.hash // empty') + +# 2. Call `debug_traceTransaction` with the canonical hash. +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 already have the canonical Ethereum hash (e.g. from a block explorer or a previous `eth_getTransactionByHash` response), you can pass it directly to `debug_traceTransaction` without step 1. + ### Comparing Forest vs Anvil Responses -Both Forest and Anvil use the same `debug_traceTransaction` method and tracer format, so responses can be compared directly (unlike `trace_call` vs `debug_traceCall` which use different formats). +Both Forest and Anvil use the same `debug_traceTransaction` method and tracer format, so responses can be compared directly. -**Forest:** +**Forest** (use the canonical `hash` from `eth_getTransactionByHash` as described above): ```bash curl -s -X POST "http://localhost:2345/rpc/v1" \ @@ -177,7 +216,7 @@ curl -s -X POST "http://localhost:8545" \ ### Expected Differences Between Forest and Anvil -When comparing `debug_traceTransaction` output from Forest and Anvil, expect these Filecoin-specific differences: +When comparing `debug_traceTransaction` output from Forest and Anvil, expect these Filecoin specific differences: | Aspect | Forest (Filecoin) | Anvil (Ethereum) | | ---------------------- | ------------------------------------------ | ------------------------------ | @@ -191,29 +230,29 @@ When comparing `debug_traceTransaction` output from Forest and Anvil, expect the When testing `debug_traceTransaction`, cover these categories: -#### 1. prestateTracer Tests +#### 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 | +| 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 +#### 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 | +| 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 | +| Complex mixed | `complexTrace()` | Mix of CALL, `DELEGATECALL`, `STATICCALL` | | Revert at depth | `failAtDepth(3,1)` | Error field populated in failing frame | -#### 3. flatCallTracer Tests +#### 3. `flatCallTracer` Tests | Test Case | Function | What to Verify | | -------------- | --------------- | ------------------------------------------------------- | @@ -221,27 +260,9 @@ When testing `debug_traceTransaction`, cover these categories: | Nested call | `callSelf(456)` | Two entries with correct `traceAddress` and `subtraces` | | Deep recursive | `deepTrace(3)` | Flat list with incrementing `traceAddress` depth | -## Integration Test Script - -An automated test script is available to compare Forest's `debug_traceTransaction` with Anvil: - -```bash -# Run the test (requires Forest and Anvil running) -./scripts/tests/debug_trace_transaction_test.sh - -# Deploy contract on Anvil first, forest and anvil node should already be running -./scripts/tests/debug_trace_transaction_test.sh --deploy -``` - -### Test Categories - -1. **Prestate Tests**: Pre/post state snapshots, default mode, diff mode -2. **Call Trace Tests**: Call hierarchy, nested calls, reverts -3. **Storage Tests**: Single slot, multiple slots, add/change/delete patterns - ## Official Resources -- [Geth debug_traceTransaction](https://geth.ethereum.org/docs/interacting-with-geth/rpc/ns-debug#debugtracetransaction) +- [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/) 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 8ebb8733ed22..c98d35edb85b 100644 --- a/docs/docs/users/knowledge_base/rpc/debug_trace_transaction.md +++ b/docs/docs/users/knowledge_base/rpc/debug_trace_transaction.md @@ -1,4 +1,4 @@ -# debug_traceTransaction API Guide +# `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. @@ -8,7 +8,7 @@ This guide explains the `debug_traceTransaction` RPC method implemented in Fores - Debugging failed or unexpected transactions - Analyzing the full execution trace of a historical transaction -- Inspecting pre/post state changes caused by a specific transaction +- Inspecting `pre/post` state changes caused by a specific transaction - Understanding nested call hierarchies in complex transactions ## Request Format @@ -19,10 +19,10 @@ This guide explains the `debug_traceTransaction` RPC method implemented in Fores "id": 1, "method": "debug_traceTransaction", "params": [ - "0x...", // Transaction hash + "0x...", { - "tracer": "prestateTracer", // Tracer type - "tracerConfig": { "diffMode": false } // Tracer-specific config (optional) + "tracer": "prestateTracer", + "tracerConfig": { "diffMode": false } } ] } @@ -34,27 +34,28 @@ This guide explains the `debug_traceTransaction` RPC method implemented in Fores | ---------------- | --------------------------------------------------------------- | | `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 | +| `prestateTracer` | `Pre-execution` state snapshot of all touched accounts | ### Tracer Configuration -#### `prestateTracer` Config +#### `prestateTracer` config -| Option | Type | Default | Description | -| ---------- | ---- | ------- | ------------------------------------------------ | -| `diffMode` | bool | `false` | When `true`, returns both `pre` and `post` state | +| Option | Type | Default | Description | +| ---------- | ------- | ------- | ------------------------------------------------ | +| `diffMode` | boolean | `false` | When `true`, returns both `pre` and `post` state | -#### `callTracer` Config +#### `callTracer` config -| Option | Type | Default | Description | -| ------------- | ---- | ------- | -------------------------------- | -| `onlyTopCall` | bool | `false` | When `true`, only trace top call | +| 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). +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 { @@ -187,7 +188,7 @@ Returns a flat list of all call frames (equivalent to Parity-style `trace`). } ``` -## Using debug_traceTransaction with Forest +## Using `debug_traceTransaction` with Forest ### Prerequisites @@ -198,7 +199,7 @@ 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. +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 | | -------- | ------------------------------------------------------------------------------------------------------------------------------------- | @@ -231,9 +232,9 @@ export FOREST_RPC_URL="http://localhost:2345/rpc/v1" export TX_HASH="0xYOUR_TRANSACTION_HASH" ``` -### 1. Prestate Trace - Default Mode +### 1. `Prestate` Trace - Default Mode -Returns the pre-execution state of all accounts touched by the transaction. +Returns the `pre-execution` state of all accounts touched by the transaction. ```bash curl -s -X POST "$FOREST_RPC_URL" \ @@ -249,9 +250,9 @@ curl -s -X POST "$FOREST_RPC_URL" \ }' | jq '.' ``` -### 2. Prestate Trace - Diff Mode +### 2. `Prestate` Trace - Diff Mode -Returns both pre and post state, showing exactly what changed. +Returns both `pre` and `post` state, showing exactly what changed. ```bash curl -s -X POST "$FOREST_RPC_URL" \ @@ -308,9 +309,9 @@ curl -s -X POST "$FOREST_RPC_URL" \ ### 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. +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 @@ -328,20 +329,20 @@ curl -s -X POST "$FOREST_RPC_URL" \ | jq '.result.status' ``` -## Filecoin-Specific Behavior +## Filecoin Specific Behavior -Forest's `debug_traceTransaction` implementation has some differences from standard Ethereum implementations due to Filecoin's architecture: +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 txns in block | -| **Storage model** | EVM storage via KAMT (Key-Address-Merkle-Tree) | Standard Merkle Patricia Trie | +| 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 `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/) diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index 35a9120f993e..22b38b55ae4c 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -33,7 +33,7 @@ use crate::rpc::{ eth::{ errors::EthErrors, filter::{SkipEvent, event::EventFilter, mempool::MempoolFilter, tipset::TipSetFilter}, - types::{EthBlockTrace, EthTrace}, + types::EthBlockTrace, utils::decode_revert_reason, }, methods::chain::ChainGetTipSetV2, @@ -3464,13 +3464,14 @@ impl RpcMethod<2> for EthDebugTraceTransaction { ext: &Extensions, ) -> Result { let opts = opts.unwrap_or_default(); - debug_trace_transaction(ctx, ext, tx_hash, opts).await + debug_trace_transaction(ctx, ext, Self::api_path(ext)?, tx_hash, opts).await } } async fn debug_trace_transaction( ctx: Ctx, ext: &Extensions, + api_path: ApiPaths, tx_hash: String, opts: GethDebugTracingOptions, ) -> Result @@ -3491,11 +3492,10 @@ where .await? .ok_or(ServerError::internal_error("transaction not found", None))?; - let ts = tipset_by_ext_block_number_or_hash( - ctx.chain_store(), - ExtBlockNumberOrHash::from_block_number(eth_txn.block_number.0 as i64), - ResolveNullTipset::TakeOlder, - )?; + 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. diff --git a/src/tool/subcommands/api_cmd/test_snapshots_ignored.txt b/src/tool/subcommands/api_cmd/test_snapshots_ignored.txt index 3924ea422997..0c05063bfb4c 100644 --- a/src/tool/subcommands/api_cmd/test_snapshots_ignored.txt +++ b/src/tool/subcommands/api_cmd/test_snapshots_ignored.txt @@ -83,8 +83,8 @@ Forest.ChainExportDiff Forest.ChainExportStatus Forest.ChainGetMinBaseFee Forest.ChainGetTipsetByParentState -Forest.EthTraceCall Forest.EthDebugTraceTransaction +Forest.EthTraceCall Forest.NetInfo Forest.SnapshotGC Forest.StateActorInfo From 04e4398592d318156216292a9f1243776c3f18ab Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Tue, 10 Mar 2026 17:31:53 +0530 Subject: [PATCH 11/14] address comments and add more tests --- docs/dictionary.txt | 18 +- .../rpc/debug_trace_transaction.md | 2 +- src/rpc/methods/eth.rs | 36 +- src/rpc/methods/eth/trace.rs | 502 +++++++++++++++++- src/rpc/methods/eth/types.rs | 313 ++++++++++- .../forest__rpc__tests__rpc__v0.snap | 8 +- .../forest__rpc__tests__rpc__v1.snap | 12 +- .../forest__rpc__tests__rpc__v2.snap | 12 +- src/state_manager/mod.rs | 7 + 9 files changed, 834 insertions(+), 76 deletions(-) diff --git a/docs/dictionary.txt b/docs/dictionary.txt index 695be020a03b..ea93a0a1e12a 100644 --- a/docs/dictionary.txt +++ b/docs/dictionary.txt @@ -28,6 +28,7 @@ CLI cli Cloudflare codebase +Coinbase config Datacap datacap @@ -41,6 +42,7 @@ DigitalOcean Drand EAM enums +EOAs ETH Eth Ethereum @@ -80,6 +82,7 @@ JSON JSON-RPC JWT JWTs +KAMT keypair keystore Kubernetes @@ -99,6 +102,7 @@ macOS Mainnet mainnet MDBX +Merkle MetaMask migrator migrators @@ -124,6 +128,10 @@ Organisation P2P p2p performant +Post +post +Pre +pre pre-compiled Pre-deployed pre-deployed @@ -165,6 +173,7 @@ TiB Tipset tipset tipsets +Trie URL Verifier verifier @@ -173,12 +182,3 @@ VPS WebSocket WIP zstd -Pre -pre -Post -post -Coinbase -EOAs -KAMT -Merkle -Trie 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 c98d35edb85b..e1fc62167be6 100644 --- a/docs/docs/users/knowledge_base/rpc/debug_trace_transaction.md +++ b/docs/docs/users/knowledge_base/rpc/debug_trace_transaction.md @@ -329,7 +329,7 @@ curl -s -X POST "$FOREST_RPC_URL" \ | jq '.result.status' ``` -## Filecoin Specific Behavior +## `Filecoin-Specific` Behavior Forest's `debug_traceTransaction` implementation has some differences from standard Ethereum implementations due to `Filecoin's` architecture: diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index 22b38b55ae4c..ef50a980eb13 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -3385,7 +3385,14 @@ async fn execute_tipset_traces( where DB: Blockstore + Send + Sync + 'static, { - let (state_root, raw_traces) = ctx.state_manager.execution_trace(ts)?; + // the async executor — execution_trace replays all messages in the tipset. + let (state_root, raw_traces) = { + let sm = ctx.state_manager.clone(); + let ts = ts.clone(); + tokio::task::spawn_blocking(move || sm.execution_trace(&ts)) + .await + .context("execution_trace task panicked")?? + }; let state = ctx.state_manager.get_state_tree(&state_root)?; let mut entries = Vec::new(); @@ -3394,7 +3401,6 @@ where if ir.msg.from == system::ADDRESS.into() { continue; } - msg_idx += 1; let tx_hash = EthGetTransactionHashByCid::handle(ctx.clone(), (ir.msg_cid,), ext).await?; let tx_hash = tx_hash .with_context(|| format!("cannot find transaction hash for cid {}", ir.msg_cid))?; @@ -3403,6 +3409,7 @@ where msg_position: msg_idx, invoc_result: ir, }); + msg_idx += 1; } Ok((state, entries)) @@ -3447,7 +3454,7 @@ where pub enum EthDebugTraceTransaction {} impl RpcMethod<2> for EthDebugTraceTransaction { const N_REQUIRED_PARAMS: usize = 1; - const NAME: &'static str = "Filecoin.EthDebugTraceTransaction"; + 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 }); @@ -3464,6 +3471,7 @@ impl RpcMethod<2> for EthDebugTraceTransaction { ext: &Extensions, ) -> Result { let opts = opts.unwrap_or_default(); + debug_trace_transaction(ctx, ext, Self::api_path(ext)?, tx_hash, opts).await } } @@ -3478,10 +3486,15 @@ async fn debug_trace_transaction( where DB: Blockstore + Send + Sync + 'static, { - let tracer = opts - .tracer - .clone() - .unwrap_or(GethDebugBuiltInTracerType::Call); + 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 + } + }; if tracer == GethDebugBuiltInTracerType::Noop { return Ok(GethTrace::Noop(NoopFrame {})); @@ -3492,6 +3505,14 @@ where .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, + )); + } + let resolver = TipsetResolver::new(&ctx, api_path); let ts = resolver .tipset_by_block_number_or_hash(eth_txn.block_number, ResolveNullTipset::TakeOlder) @@ -3823,6 +3844,7 @@ where trace: env.traces, }, transaction_hash: entry.tx_hash, + vm_trace: None, }); } } diff --git a/src/rpc/methods/eth/trace.rs b/src/rpc/methods/eth/trace.rs index 59d1c7f093b9..413c3b3815b1 100644 --- a/src/rpc/methods/eth/trace.rs +++ b/src/rpc/methods/eth/trace.rs @@ -34,11 +34,11 @@ use std::borrow::Cow; use std::collections::BTreeMap; use tracing::debug; -// EVM geth format Error Message -pub(crate) const GETH_EVM_REVERTED_CONTRACT: &str = "Reverted"; // capitalized for compatibility +/// Error string used in Parity-format traces. +pub(crate) const PARITY_TRACE_REVERT_ERROR: &str = "Reverted"; -// EVM Parity format Error Message -pub(crate) const PARITY_EVM_REVERTED_CONTRACT: &str = "execution reverted"; +/// Error string used in Geth-format traces. +pub(crate) const GETH_TRACE_REVERT_ERROR: &str = "execution 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"; @@ -138,7 +138,7 @@ fn trace_err_msg(trace: &ExecutionTrace) -> Option { // handle special exit codes from the EVM/EAM. if trace_is_evm_or_eam(trace) { match code.into() { - evm12::EVM_CONTRACT_REVERTED => return Some(GETH_EVM_REVERTED_CONTRACT.into()), + evm12::EVM_CONTRACT_REVERTED => return Some(PARITY_TRACE_REVERT_ERROR.into()), evm12::EVM_CONTRACT_INVALID_INSTRUCTION => { return Some(PARITY_EVM_INVALID_INSTRUCTION.into()); } @@ -756,7 +756,7 @@ fn eth_trace_to_geth_frame( ) -> anyhow::Result { let is_success = trace.is_success(); let is_revert = trace.is_reverted(); - let error = trace.parity_error_to_geth(); + let error = trace.to_geth_error(); match (trace.action, trace.result) { (TraceAction::Call(action), TraceResult::Call(result)) => { @@ -936,12 +936,24 @@ pub(crate) fn build_prestate_frame( deleted_addrs.insert(*eth_addr); } - let changed_keys = changed_storage_keys(store, pre_actor.as_ref(), post_actor.as_ref()); + 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(store, pre_actor.as_ref(), config, Some(&changed_keys)); - let post_snap = - build_account_snapshot(store, post_actor.as_ref(), config, Some(&changed_keys)); + 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 { @@ -978,11 +990,19 @@ pub(crate) fn build_prestate_frame( let pre_actor = pre_state.get_actor(&fil_addr)?; let post_actor = post_state.get_actor(&fil_addr)?; - let changed_keys = changed_storage_keys(store, pre_actor.as_ref(), post_actor.as_ref()); + // 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(store, pre_actor.as_ref(), config, Some(&changed_keys)) - { + 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); } } @@ -997,10 +1017,11 @@ pub(crate) fn build_prestate_frame( /// 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( +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?; @@ -1014,10 +1035,10 @@ fn build_account_snapshot( let storage = if config.is_storage_disabled() { BTreeMap::new() } else { - extract_evm_storage_entries(store, Some(actor)) - .into_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))) + 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() }; @@ -1029,18 +1050,14 @@ fn build_account_snapshot( }) } -/// Compute the set of storage keys that differ between pre and post actor states. -fn changed_storage_keys( - store: &DB, - pre_actor: Option<&ActorState>, - post_actor: Option<&ActorState>, +// Compute the set of storage keys that differ between pre and post actor states. +fn diff_entry_keys( + pre_entries: &HashMap<[u8; 32], U256>, + post_entries: &HashMap<[u8; 32], U256>, ) -> HashSet<[u8; 32]> { - let pre_entries = extract_evm_storage_entries(store, pre_actor); - let post_entries = extract_evm_storage_entries(store, post_actor); - let mut changed = HashSet::default(); - for (k, v) in &pre_entries { + for (k, v) in pre_entries { match post_entries.get(k) { Some(pv) if pv == v => {} // unchanged _ => { @@ -1773,4 +1790,431 @@ mod tests { // Code should be unchanged (None -> None for non-EVM actors) assert!(diff.code.is_unchanged()); } + + #[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()); + } + + // ---- actor_nonce tests ---- + + #[test] + fn test_actor_nonce_non_evm() { + let store = MemoryDB::default(); + let actor = create_test_actor(1000, 42); + let nonce = actor_nonce(&store, &actor); + assert_eq!(nonce.0, 42); + } + + #[test] + fn test_actor_nonce_evm() { + let store = Arc::new(MemoryDB::default()); + if let Some(actor) = create_evm_actor_with_bytecode(&store, 1000, 0, 7, Some(&[0x60])) { + let nonce = actor_nonce(store.as_ref(), &actor); + // EVM actors use the EVM nonce field, not the actor sequence + assert_eq!(nonce.0, 7); + } + } + + #[test] + fn test_actor_bytecode_non_evm() { + let store = MemoryDB::default(); + let actor = create_test_actor(1000, 0); + assert!(actor_bytecode(&store, &actor).is_none()); + } + + #[test] + fn test_actor_bytecode_evm() { + let store = Arc::new(MemoryDB::default()); + let bytecode = &[0x60, 0x80, 0x60, 0x40, 0x52]; + if let Some(actor) = create_evm_actor_with_bytecode(&store, 1000, 0, 1, Some(bytecode)) { + let result = actor_bytecode(store.as_ref(), &actor); + assert_eq!(result, Some(EthBytes(bytecode.to_vec()))); + } + } + + #[test] + fn test_actor_bytecode_evm_no_bytecode() { + let store = Arc::new(MemoryDB::default()); + if let Some(actor) = create_evm_actor_with_bytecode(&store, 1000, 0, 1, None) { + // No bytecode stored => None (Cid::default() won't resolve to raw data) + let result = actor_bytecode(store.as_ref(), &actor); + assert!(result.is_none()); + } + } + + #[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 = eth_trace_to_geth_frame(trace, 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 = eth_trace_to_geth_frame(trace, 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 = eth_trace_to_geth_frame(trace, 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 = eth_trace_to_geth_frame(trace, 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!(eth_trace_to_geth_frame(trace, GethCallType::Call).is_err()); + } + + #[test] + fn test_build_prestate_frame_default_mode_empty() { + let trees = TestStateTrees::new().unwrap(); + let config = super::super::types::PreStateConfig { + diff_mode: None, + disable_code: None, + disable_storage: None, + }; + let touched = HashSet::new(); + + let frame = build_prestate_frame( + trees.store.as_ref(), + &trees.pre_state, + &trees.post_state, + &touched, + &config, + ) + .unwrap(); + + match frame { + super::super::types::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 = super::super::types::PreStateConfig { + diff_mode: None, + disable_code: None, + disable_storage: None, + }; + 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 { + super::super::types::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 = super::super::types::PreStateConfig { + diff_mode: Some(true), + disable_code: None, + disable_storage: None, + }; + 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 { + super::super::types::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 = super::super::types::PreStateConfig { + diff_mode: Some(true), + disable_code: None, + disable_storage: None, + }; + 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 { + super::super::types::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 = super::super::types::PreStateConfig { + diff_mode: Some(true), + disable_code: None, + disable_storage: None, + }; + 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 { + super::super::types::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 = super::super::types::PreStateConfig { + diff_mode: Some(true), + disable_code: None, + disable_storage: None, + }; + 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 { + super::super::types::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/types.rs b/src/rpc/methods/eth/types.rs index 5635a2ef2ceb..914aa301eb59 100644 --- a/src/rpc/methods/eth/types.rs +++ b/src/rpc/methods/eth/types.rs @@ -684,23 +684,31 @@ lotus_json_with_self!(GethDebugTracingOptions); impl GethDebugTracingOptions { /// Extracts the `callTracer` config, defaulting to no-op values when absent. pub fn call_config(&self) -> CallTracerConfig { - self.tracer_config - .as_ref() - .filter(|c| !c.0.is_null()) - .and_then(|c| serde_json::from_value(c.0.clone()).ok()) - .unwrap_or_default() + parse_tracer_config::(&self.tracer_config) } /// Extracts the `prestateTracer` config, defaulting to no-op values when absent. pub fn prestate_config(&self) -> PreStateConfig { - self.tracer_config - .as_ref() - .filter(|c| !c.0.is_null()) - .and_then(|c| serde_json::from_value(c.0.clone()).ok()) - .unwrap_or_default() + 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")] @@ -972,23 +980,20 @@ impl EthTrace { /// /// This is not a complete check for reverted traces (there are other possible revert reasons). pub fn is_reverted(&self) -> bool { - if let Some(error) = self.error.as_ref() { - error == trace::GETH_EVM_REVERTED_CONTRACT - } else { - false - } + self.error + .as_deref() + .is_some_and(|e| e == trace::PARITY_TRACE_REVERT_ERROR) } - pub fn parity_error_to_geth(&self) -> Option { - if let Some(error) = self.error.as_ref() { - if error == trace::GETH_EVM_REVERTED_CONTRACT { - Some(trace::PARITY_EVM_REVERTED_CONTRACT.into()) + /// 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 == trace::PARITY_TRACE_REVERT_ERROR { + trace::GETH_TRACE_REVERT_ERROR.into() } else { - Some(error.to_string()) + error.to_string() } - } else { - None - } + }) } } @@ -1014,12 +1019,18 @@ impl EthBlockTrace { } } +/// Replay block transaction trace. #[derive(PartialEq, Default, Serialize, Deserialize, Debug, Clone, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct EthReplayBlockTransactionTrace { + /// The full trace of the transaction. #[serde(flatten)] pub full_trace: EthTraceResults, + /// The hash of the transaction. pub transaction_hash: EthHash, + /// The VM trace of the transaction. + /// This is optional because the VM trace is not always available (not supported by FVM). + pub vm_trace: Option, } lotus_json_with_self!(EthReplayBlockTransactionTrace); @@ -1334,4 +1345,260 @@ 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""# + ); + } } diff --git a/src/rpc/snapshots/forest__rpc__tests__rpc__v0.snap b/src/rpc/snapshots/forest__rpc__tests__rpc__v0.snap index cc707dda4e36..26bf154b757b 100644 --- a/src/rpc/snapshots/forest__rpc__tests__rpc__v0.snap +++ b/src/rpc/snapshots/forest__rpc__tests__rpc__v0.snap @@ -5727,7 +5727,7 @@ components: - blockHash - blockNumber EthReplayBlockTransactionTrace: - description: "Result payload returned by `trace_call`." + description: Replay block transaction trace. type: object properties: output: @@ -5745,7 +5745,13 @@ components: items: $ref: "#/components/schemas/EthTrace" transactionHash: + description: The hash of the transaction. $ref: "#/components/schemas/EthHash" + vmTrace: + description: "The VM trace of the transaction.\nThis is optional because the VM trace is not always available (not supported by FVM)." + type: + - string + - "null" required: - output - transactionHash diff --git a/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap b/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap index c5fc4792f841..3f8327b855fb 100644 --- a/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap +++ b/src/rpc/snapshots/forest__rpc__tests__rpc__v1.snap @@ -1669,7 +1669,7 @@ methods: items: $ref: "#/components/schemas/EthBlockTrace" paramStructure: by-position - - name: Filecoin.EthDebugTraceTransaction + - name: Forest.EthDebugTraceTransaction description: Replays a transaction and returns execution traces in Geth-compatible format. params: - name: txHash @@ -1683,7 +1683,7 @@ methods: - $ref: "#/components/schemas/GethDebugTracingOptions" - type: "null" result: - name: Filecoin.EthDebugTraceTransaction.Result + name: Forest.EthDebugTraceTransaction.Result required: true schema: $ref: "#/components/schemas/GethTrace" @@ -5819,7 +5819,7 @@ components: - blockHash - blockNumber EthReplayBlockTransactionTrace: - description: "Result payload returned by `trace_call`." + description: Replay block transaction trace. type: object properties: output: @@ -5837,7 +5837,13 @@ components: items: $ref: "#/components/schemas/EthTrace" transactionHash: + description: The hash of the transaction. $ref: "#/components/schemas/EthHash" + vmTrace: + description: "The VM trace of the transaction.\nThis is optional because the VM trace is not always available (not supported by FVM)." + type: + - string + - "null" required: - output - transactionHash diff --git a/src/rpc/snapshots/forest__rpc__tests__rpc__v2.snap b/src/rpc/snapshots/forest__rpc__tests__rpc__v2.snap index de1a87219a10..377908a8c528 100644 --- a/src/rpc/snapshots/forest__rpc__tests__rpc__v2.snap +++ b/src/rpc/snapshots/forest__rpc__tests__rpc__v2.snap @@ -1264,7 +1264,7 @@ methods: items: $ref: "#/components/schemas/EthBlockTrace" paramStructure: by-position - - name: Filecoin.EthDebugTraceTransaction + - name: Forest.EthDebugTraceTransaction description: Replays a transaction and returns execution traces in Geth-compatible format. params: - name: txHash @@ -1278,7 +1278,7 @@ methods: - $ref: "#/components/schemas/GethDebugTracingOptions" - type: "null" result: - name: Filecoin.EthDebugTraceTransaction.Result + name: Forest.EthDebugTraceTransaction.Result required: true schema: $ref: "#/components/schemas/GethTrace" @@ -2290,7 +2290,7 @@ components: - blockHash - blockNumber EthReplayBlockTransactionTrace: - description: "Result payload returned by `trace_call`." + description: Replay block transaction trace. type: object properties: output: @@ -2308,7 +2308,13 @@ components: items: $ref: "#/components/schemas/EthTrace" transactionHash: + description: The hash of the transaction. $ref: "#/components/schemas/EthHash" + vmTrace: + description: "The VM trace of the transaction.\nThis is optional because the VM trace is not always available (not supported by FVM)." + type: + - string + - "null" required: - output - transactionHash diff --git a/src/state_manager/mod.rs b/src/state_manager/mod.rs index d49dad55d0c1..1d4f8a32d7e1 100644 --- a/src/state_manager/mod.rs +++ b/src/state_manager/mod.rs @@ -941,6 +941,13 @@ where 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() + ); + } } } From 4f89d214398d892ee631138ebbf0a3c0563bccfc Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Tue, 10 Mar 2026 18:24:36 +0530 Subject: [PATCH 12/14] fix spell checks --- .config/forest.dic | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.config/forest.dic b/.config/forest.dic index d5a82d414844..2f53a0c7e7dc 100644 --- a/.config/forest.dic +++ b/.config/forest.dic @@ -1,4 +1,4 @@ -258 + 266 Algorand/M API's API/SM @@ -52,6 +52,7 @@ clonable Cloudflare codebase codec +Coinbase config/S cron crypto @@ -81,6 +82,7 @@ Enum enum enums env +EOAs EOF eth Ethereum @@ -124,6 +126,7 @@ jsonrpc jsonrpsee JWT/S Kademlia +KAMT keypair/S keystore/S Kubernetes @@ -177,6 +180,10 @@ pnpm PoC pointer/SM PoSt +Post +post +Pre +pre precommit preloaded pubsub @@ -227,6 +234,7 @@ tipsetkey/S Tokio TOML toolchain/S +Trie trie truthy TTY From 542a6ad26156af401d5dacb1e811c8d318329ac4 Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Tue, 10 Mar 2026 20:15:51 +0530 Subject: [PATCH 13/14] address comments --- docs/dictionary.txt | 0 .../guides/debug_trace_transaction_guide.md | 42 ++++++------------- .../rpc/debug_trace_transaction.md | 10 +++-- mise.toml | 3 -- src/rpc/methods/eth.rs | 12 +++--- src/rpc/methods/eth/types.rs | 15 +++++-- 6 files changed, 36 insertions(+), 46 deletions(-) delete mode 100644 docs/dictionary.txt diff --git a/docs/dictionary.txt b/docs/dictionary.txt deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/docs/docs/developers/guides/debug_trace_transaction_guide.md b/docs/docs/developers/guides/debug_trace_transaction_guide.md index 8d51da038360..ae8f777c4804 100644 --- a/docs/docs/developers/guides/debug_trace_transaction_guide.md +++ b/docs/docs/developers/guides/debug_trace_transaction_guide.md @@ -141,42 +141,24 @@ Save the transaction hashes from the output for use in the tracing examples belo ### Getting the correct transaction hash on Forest -When you send a transaction to Forest (e.g. with `cast send` or another client), the value returned may be a **Filecoin message CID** (or another identifier), not the canonical **Ethereum transaction hash** that `debug_traceTransaction` expects. +`debug_traceTransaction` expects the canonical **EthHash** (0x...). If your client returned something else, resolve it first. -To obtain the canonical `hash` (EthHash) that Forest uses for tracing: +- **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`. -1. Call `eth_getTransactionByHash` with the hash you received when sending the transaction (e.g. the value returned by `cast send`). -2. From the response, use the **`hash`** field of the returned transaction object. That is the canonical Ethereum transaction hash; use it when calling `debug_traceTransaction`. - -Example: resolve the hash and then trace: +Example (0x... from cast): ```bash -# 1. Get the canonical transaction hash (`EthHash`) by querying with the hash from cast send. -HASH_FROM_CAST="0x..." # or your Filecoin-style identifier -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_FROM_CAST"'"] - }' | jq -r '.result.hash // empty') - -# 2. Call `debug_traceTransaction` with the canonical hash. -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}} - ] - }' +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 already have the canonical Ethereum hash (e.g. from a block explorer or a previous `eth_getTransactionByHash` response), you can pass it directly to `debug_traceTransaction` without step 1. +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 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 e1fc62167be6..61316aea313d 100644 --- a/docs/docs/users/knowledge_base/rpc/debug_trace_transaction.md +++ b/docs/docs/users/knowledge_base/rpc/debug_trace_transaction.md @@ -40,9 +40,11 @@ This guide explains the `debug_traceTransaction` RPC method implemented in Fores #### `prestateTracer` config -| Option | Type | Default | Description | -| ---------- | ------- | ------- | ------------------------------------------------ | -| `diffMode` | boolean | `false` | When `true`, returns both `pre` and `post` state | +| 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 @@ -338,7 +340,7 @@ Forest's `debug_traceTransaction` implementation has some differences from stand | **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 | +| **Storage model** | EVM storage via KAMT (Key-Address-Merkle-Tree) | Standard `Merkle Patricia Trie` | ## Official Resources diff --git a/mise.toml b/mise.toml index e3455b3186bb..abb7e50b56fc 100644 --- a/mise.toml +++ b/mise.toml @@ -35,9 +35,6 @@ run = ''' cargo spellcheck --code 1 || (echo "See .config/spellcheck.md for tips"; false) ''' - - - [tasks."lint:toml"] description = "Run taplo-cli to check TOML files." run = ''' diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index ef50a980eb13..3e6ddfe385aa 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -3401,6 +3401,7 @@ where if ir.msg.from == system::ADDRESS.into() { continue; } + msg_idx += 1; let tx_hash = EthGetTransactionHashByCid::handle(ctx.clone(), (ir.msg_cid,), ext).await?; let tx_hash = tx_hash .with_context(|| format!("cannot find transaction hash for cid {}", ir.msg_cid))?; @@ -3409,7 +3410,6 @@ where msg_position: msg_idx, invoc_result: ir, }); - msg_idx += 1; } Ok((state, entries)) @@ -3496,10 +3496,6 @@ where } }; - if tracer == GethDebugBuiltInTracerType::Noop { - return Ok(GethTrace::Noop(NoopFrame {})); - } - let eth_hash = EthHash::from_str(&tx_hash).context("invalid transaction hash")?; let eth_txn = get_eth_transaction_by_hash(&ctx, ð_hash, None) .await? @@ -3513,6 +3509,10 @@ where )); } + 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) @@ -3577,7 +3577,7 @@ where match tracer { GethDebugBuiltInTracerType::Call => { - let call_config = opts.call_config(); + 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())) } diff --git a/src/rpc/methods/eth/types.rs b/src/rpc/methods/eth/types.rs index 914aa301eb59..b319a56d63b9 100644 --- a/src/rpc/methods/eth/types.rs +++ b/src/rpc/methods/eth/types.rs @@ -682,9 +682,14 @@ pub struct GethDebugTracingOptions { lotus_json_with_self!(GethDebugTracingOptions); impl GethDebugTracingOptions { - /// Extracts the `callTracer` config, defaulting to no-op values when absent. - pub fn call_config(&self) -> CallTracerConfig { - parse_tracer_config::(&self.tracer_config) + /// 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. @@ -717,6 +722,10 @@ pub struct CallTracerConfig { /// 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); From a4f765e07948b6998c4e2bbd717d370c1fbe01cf Mon Sep 17 00:00:00 2001 From: Aryan Tikarya Date: Tue, 10 Mar 2026 21:26:21 +0530 Subject: [PATCH 14/14] fix linter issues --- .config/forest.dic | 2 +- docs/docs/developers/guides/debug_trace_transaction_guide.md | 2 +- docs/docs/users/knowledge_base/rpc/debug_trace_transaction.md | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.config/forest.dic b/.config/forest.dic index 2f53a0c7e7dc..6059d692cd9d 100644 --- a/.config/forest.dic +++ b/.config/forest.dic @@ -1,4 +1,4 @@ - 266 +266 Algorand/M API's API/SM diff --git a/docs/docs/developers/guides/debug_trace_transaction_guide.md b/docs/docs/developers/guides/debug_trace_transaction_guide.md index ae8f777c4804..41261088e1ad 100644 --- a/docs/docs/developers/guides/debug_trace_transaction_guide.md +++ b/docs/docs/developers/guides/debug_trace_transaction_guide.md @@ -198,7 +198,7 @@ curl -s -X POST "http://localhost:8545" \ ### Expected Differences Between Forest and Anvil -When comparing `debug_traceTransaction` output from Forest and Anvil, expect these Filecoin specific differences: +When comparing `debug_traceTransaction` output from Forest and Anvil, expect these `Filecoin-specific` differences: | Aspect | Forest (Filecoin) | Anvil (Ethereum) | | ---------------------- | ------------------------------------------ | ------------------------------ | 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 61316aea313d..26f061e42c3c 100644 --- a/docs/docs/users/knowledge_base/rpc/debug_trace_transaction.md +++ b/docs/docs/users/knowledge_base/rpc/debug_trace_transaction.md @@ -36,6 +36,8 @@ This guide explains the `debug_traceTransaction` RPC method implemented in Fores | `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