diff --git a/e2e/test/external/e2e-json-rpc.test.ts b/e2e/test/external/e2e-json-rpc.test.ts index 9dbbe5e50..c5db0f775 100644 --- a/e2e/test/external/e2e-json-rpc.test.ts +++ b/e2e/test/external/e2e-json-rpc.test.ts @@ -733,6 +733,50 @@ describe("JSON-RPC", () => { expect(logEntry.topics.length).to.equal(2); }); }); + + // Minimal coverage for the endpoint's own logic (state loading + never-sent semantics). + // The tracer engine itself is exercised exhaustively by the debug_traceTransaction block + // above, since both endpoints share it, so tracers are not re-tested per-variant here. + describe("debug_traceCall", () => { + it("callTracer traces a contract call that was never sent (latest)", async () => { + const contract = await deployTestRevertReason(); + await sendEvmMine(); + await contract.waitForDeployment(); + + const data = contract.interface.encodeFunctionData("revertWithKnownError", []); + const call = { from: ALICE.address, to: await contract.getAddress(), data }; + + const trace = await sendAndGetFullResponse("debug_traceCall", [call, "latest", { tracer: "callTracer" }]); + + expect(trace.data.result.from).to.eq("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"); + expect(trace.data.result.to).to.eq((await contract.getAddress()).toLowerCase()); + expect(trace.data.result.input).to.eq("0x2b3d7bd2"); + expect(trace.data.result.output).to.eq("0x22aa4404"); + expect(trace.data.result.error).to.eq("execution reverted"); + expect(trace.data.result.type).to.eq("CALL"); + }); + + it("traces against pending state without mutating it", async () => { + const contract = await deployTestContractBalances(); + await sendEvmMine(); + await contract.waitForDeployment(); + + // trace a state-changing call against pending — exercises the from_pending_block branch + const data = contract.interface.encodeFunctionData("add", [ALICE.address, 10]); + const call = { from: ALICE.address, to: await contract.getAddress(), data }; + + const trace = await sendAndGetFullResponse("debug_traceCall", [call, "pending", { tracer: "callTracer" }]); + + expect(trace.data.result.to).to.eq((await contract.getAddress()).toLowerCase()); + expect(trace.data.result.type).to.eq("CALL"); + expect(trace.data.result.error).to.be.undefined; + + // the traced call must NOT have persisted: ALICE's balance is still zero + const getData = contract.interface.encodeFunctionData("get", [ALICE.address]); + const balance = await send("eth_call", [{ to: await contract.getAddress(), data: getData }, "latest"]); + expect(balance).to.eq(toPaddedHex(0, 32)); + }); + }); }); describe("Call", () => { diff --git a/src/eth/executor/evm/mod.rs b/src/eth/executor/evm/mod.rs index fc174804f..9414ef105 100644 --- a/src/eth/executor/evm/mod.rs +++ b/src/eth/executor/evm/mod.rs @@ -9,6 +9,7 @@ use alloy_consensus::transaction::TransactionInfo; use alloy_rpc_types_trace::geth::FourByteFrame; use alloy_rpc_types_trace::geth::GethDebugBuiltInTracerType; use alloy_rpc_types_trace::geth::GethDebugTracerType; +use alloy_rpc_types_trace::geth::GethDebugTracingOptions; use alloy_rpc_types_trace::geth::GethTrace; use alloy_rpc_types_trace::geth::NoopFrame; use anyhow::anyhow; @@ -19,6 +20,7 @@ use revm::InspectEvm; use revm::context::result::ExecResultAndState; use revm::context::result::ExecutionResult as RevmExecResult; use revm::database::CacheDB; +use revm::primitives::hardfork::SpecId; use revm_inspectors::tracing::FourByteInspector; use revm_inspectors::tracing::MuxInspector; use revm_inspectors::tracing::TracingInspector; @@ -35,15 +37,20 @@ use crate::eth::executor::ExecutionResult; use crate::eth::executor::ExecutorConfig; use crate::eth::executor::TransactionExecution; use crate::eth::executor::TransactionExecutionInput; +use crate::eth::executor::evm::types::CallExecutionInput; use crate::eth::executor::evm::types::EvmInput; use crate::eth::executor::evm::types::InspectorInput; use crate::eth::executor::evm::util::EvmExt; use crate::eth::executor::evm::util::create_evm; use crate::eth::rpc::BlockFilter; +use crate::eth::rpc::RpcError; use crate::eth::storage::ExecutionKind; use crate::eth::storage::StorageError; use crate::eth::storage::StratusStorage; +use crate::eth::types::CallInput; +use crate::eth::types::Hash; use crate::eth::types::MinedData; +use crate::eth::types::PointInTime; use crate::eth::types::StratusError; pub type RevmResultAndState = ExecResultAndState; @@ -105,14 +112,24 @@ impl Evm { } impl Evm { - /// Execute a transaction using a tracer. + /// Execute a transaction or a synthetic call using a tracer. pub fn inspect(&mut self, input: InspectorInput) -> Result { - let InspectorInput { - tx_hash, - opts, - trace_unsuccessful_only, - } = input; - let tracer_type = opts.tracer.ok_or_else(|| anyhow!("no tracer type provided"))?; + match input { + InspectorInput::Transaction { + tx_hash, + opts, + trace_unsuccessful_only, + } => self.inspect_transaction(tx_hash, opts, trace_unsuccessful_only), + InspectorInput::Call { call, point_in_time, opts } => self.inspect_call(call, point_in_time, opts), + } + } + + /// Re-executes an already-mined transaction, looked up by hash, wrapped in the requested tracer. + /// + /// Because Stratus only stores state at block boundaries, every transaction before the target one in the + /// same block is replayed first, to reconstruct the exact mid-block state the target transaction saw. + fn inspect_transaction(&mut self, tx_hash: Hash, opts: GethDebugTracingOptions, trace_unsuccessful_only: bool) -> Result { + let tracer_type = opts.tracer.clone().ok_or_else(|| anyhow!("no tracer type provided"))?; if matches!(tracer_type, GethDebugTracerType::BuiltInTracer(GethDebugBuiltInTracerType::NoopTracer)) { return Ok(NoopFrame::default().into()); @@ -157,11 +174,12 @@ impl Evm { self.evm.journaled_state.database.reset(ExecutionKind::CallPast(target)); let spec = self.evm.cfg.spec; + let chain_id: u64 = inspect_input.chain_id.unwrap_or_default().into(); let mut cache_db = CacheDB::new(&self.evm.journaled_state.database); - let mut evm = create_evm(inspect_input.chain_id.unwrap_or_default().into(), spec, &mut cache_db, self.kind); + let mut evm = create_evm(chain_id, spec, &mut cache_db, self.kind); - // Execute all transactions before target tx_hash + // Execute all transactions before target tx_hash, to reconstruct the mid-block state it saw. for tx in block.transactions.into_iter() { if tx.info.hash == tx_hash { break; @@ -174,73 +192,137 @@ impl Evm { evm.transact_commit(tx)?; } - let trace_result: GethTrace = match tracer_type { - GethDebugTracerType::BuiltInTracer(GethDebugBuiltInTracerType::FourByteTracer) => { - let mut inspector = FourByteInspector::default(); - let mut evm_with_inspector = evm.with_inspector(&mut inspector); - evm_with_inspector.fill_env(inspect_input); - let tx = std::mem::take(&mut evm_with_inspector.tx); - evm_with_inspector.inspect_tx(tx)?; - FourByteFrame::from(&inspector).into() - } - GethDebugTracerType::BuiltInTracer(GethDebugBuiltInTracerType::CallTracer) => { - let call_config = opts.tracer_config.into_call_config()?; - let mut inspector = TracingInspector::new(TracingInspectorConfig::from_geth_call_config(&call_config)); - let mut evm_with_inspector = evm.with_inspector(&mut inspector); - evm_with_inspector.fill_env(inspect_input); - let tx = std::mem::take(&mut evm_with_inspector.tx); - let res = evm_with_inspector.inspect_tx(tx)?; - let mut trace = inspector.geth_builder().geth_call_traces(call_config, res.result.tx_gas_used()).into(); - enhance_trace_with_decoded_errors(&mut trace); - trace - } - GethDebugTracerType::BuiltInTracer(GethDebugBuiltInTracerType::PreStateTracer) => { - let prestate_config = opts.tracer_config.into_pre_state_config()?; - let mut inspector = TracingInspector::new(TracingInspectorConfig::from_geth_prestate_config(&prestate_config)); - let mut evm_with_inspector = evm.with_inspector(&mut inspector); - evm_with_inspector.fill_env(inspect_input); - let tx = std::mem::take(&mut evm_with_inspector.tx); - let res = evm_with_inspector.inspect_tx(tx)?; - - inspector.geth_builder().geth_prestate_traces(&res, &prestate_config, &cache_db)?.into() - } - GethDebugTracerType::BuiltInTracer(GethDebugBuiltInTracerType::NoopTracer) => NoopFrame::default().into(), - GethDebugTracerType::BuiltInTracer(GethDebugBuiltInTracerType::MuxTracer) => { - let mux_config = opts.tracer_config.into_mux_config()?; - let mut inspector = MuxInspector::try_from_config(mux_config).map_err(|e| anyhow!(e))?; - let mut evm_with_inspector = evm.with_inspector(&mut inspector); - evm_with_inspector.fill_env(inspect_input); - let tx = std::mem::take(&mut evm_with_inspector.tx); - let res = evm_with_inspector.inspect_tx(tx)?; - inspector.try_into_mux_frame(&res, &cache_db, tx_info)?.into() - } - GethDebugTracerType::BuiltInTracer(GethDebugBuiltInTracerType::FlatCallTracer) => { - let flat_call_config = opts.tracer_config.into_flat_call_config()?; - let mut inspector = TracingInspector::new(TracingInspectorConfig::from_flat_call_config(&flat_call_config)); - let mut evm_with_inspector = evm.with_inspector(&mut inspector); - evm_with_inspector.fill_env(inspect_input); - let tx = std::mem::take(&mut evm_with_inspector.tx); - let res = evm_with_inspector.inspect_tx(tx)?; - inspector - .with_transaction_gas_limit(res.result.tx_gas_used()) - .into_parity_builder() - .into_localized_transaction_traces(tx_info) - .into() - } - GethDebugTracerType::JsTracer(code) => { - let mut inspector = JsInspector::new(code, opts.tracer_config.into_json()).map_err(|e| anyhow!(e.to_string()))?; - let mut evm_with_inspector = evm.with_inspector(&mut inspector); - evm_with_inspector.fill_env(inspect_input); - let tx = std::mem::take(&mut evm_with_inspector.tx); - let block = std::mem::take(&mut evm_with_inspector.block); - let res = evm_with_inspector.inspect_tx(tx.clone())?; - GethTrace::JS(inspector.json_result(res, &tx, &block, &cache_db).map_err(|e| anyhow!(e.to_string()))?) + run_tracer(tracer_type, opts, spec, chain_id, self.kind, cache_db, inspect_input, tx_info) + } + + /// Executes a synthetic call that was never signed or broadcast, wrapped in the requested tracer, against a + /// chosen point in time. Unlike [`Self::inspect_transaction`], there is no real position in a block to + /// reconstruct, so the call runs directly against the resolved state boundary — same as [`Self::execute`] + /// does for `eth_call`, just with a tracer attached. + fn inspect_call(&mut self, call: CallInput, point_in_time: PointInTime, opts: GethDebugTracingOptions) -> Result { + let tracer_type = opts.tracer.clone().ok_or_else(|| anyhow!("no tracer type provided"))?; + + if matches!(tracer_type, GethDebugTracerType::BuiltInTracer(GethDebugBuiltInTracerType::NoopTracer)) { + return Ok(NoopFrame::default().into()); + } + + let inspect_input: CallExecutionInput = match point_in_time { + PointInTime::Pending => { + let (pending_header, tx_count) = self.evm.journaled_state.database.storage.read_pending_block_header(); + CallExecutionInput::from_pending_block(call, pending_header, tx_count) } - GethDebugTracerType::BuiltInTracer(tracer) => { - return Err(anyhow!("tracer {tracer:?} is not implemented").into()); + point_in_time => { + let Some(block) = self.evm.journaled_state.database.storage.read_block(point_in_time.into())? else { + return Err(RpcError::BlockFilterInvalid { filter: point_in_time.into() }.into()); + }; + CallExecutionInput::try_from_mined_block(call, block, point_in_time)? } }; - Ok(trace_result) + let tx_info = TransactionInfo { + block_hash: None, + block_timestamp: Some(*inspect_input.block_timestamp), + hash: None, + index: None, + block_number: Some(inspect_input.block_number.as_u64()), + base_fee: None, + }; + + // point the base session at the resolved state boundary — no replay needed, this is the only execution + self.evm.journaled_state.database.reset(inspect_input.kind()); + + let spec = self.evm.cfg.spec; + let chain_id = self.evm.cfg.chain_id; + let cache_db = CacheDB::new(&self.evm.journaled_state.database); + + run_tracer(tracer_type, opts, spec, chain_id, self.kind, cache_db, inspect_input, tx_info) } } + +/// Runs `inspect_input` through the EVM wrapped in the inspector implied by `tracer_type`, against `cache_db`. +/// +/// Shared by [`Evm::inspect_transaction`] (where `cache_db` already has the preceding transactions of the block +/// committed into it) and [`Evm::inspect_call`] (where `cache_db` is empty and reads fall straight through to +/// the underlying session). +#[allow(clippy::too_many_arguments)] +fn run_tracer( + tracer_type: GethDebugTracerType, + opts: GethDebugTracingOptions, + spec: SpecId, + chain_id: u64, + kind: EvmKind, + mut cache_db: CacheDB<&RevmSession>, + inspect_input: Input, + tx_info: TransactionInfo, +) -> Result { + let evm = create_evm(chain_id, spec, &mut cache_db, kind); + + let trace_result: GethTrace = match tracer_type { + GethDebugTracerType::BuiltInTracer(GethDebugBuiltInTracerType::FourByteTracer) => { + let mut inspector = FourByteInspector::default(); + let mut evm_with_inspector = evm.with_inspector(&mut inspector); + inspect_input.fill_env(&mut evm_with_inspector); + let tx = std::mem::take(&mut evm_with_inspector.tx); + evm_with_inspector.inspect_tx(tx)?; + FourByteFrame::from(&inspector).into() + } + GethDebugTracerType::BuiltInTracer(GethDebugBuiltInTracerType::CallTracer) => { + let call_config = opts.tracer_config.into_call_config()?; + let mut inspector = TracingInspector::new(TracingInspectorConfig::from_geth_call_config(&call_config)); + let mut evm_with_inspector = evm.with_inspector(&mut inspector); + inspect_input.fill_env(&mut evm_with_inspector); + let tx = std::mem::take(&mut evm_with_inspector.tx); + let res = evm_with_inspector.inspect_tx(tx)?; + let mut trace = inspector.geth_builder().geth_call_traces(call_config, res.result.tx_gas_used()).into(); + enhance_trace_with_decoded_errors(&mut trace); + trace + } + GethDebugTracerType::BuiltInTracer(GethDebugBuiltInTracerType::PreStateTracer) => { + let prestate_config = opts.tracer_config.into_pre_state_config()?; + let mut inspector = TracingInspector::new(TracingInspectorConfig::from_geth_prestate_config(&prestate_config)); + let mut evm_with_inspector = evm.with_inspector(&mut inspector); + inspect_input.fill_env(&mut evm_with_inspector); + let tx = std::mem::take(&mut evm_with_inspector.tx); + let res = evm_with_inspector.inspect_tx(tx)?; + + inspector.geth_builder().geth_prestate_traces(&res, &prestate_config, &cache_db)?.into() + } + GethDebugTracerType::BuiltInTracer(GethDebugBuiltInTracerType::NoopTracer) => NoopFrame::default().into(), + GethDebugTracerType::BuiltInTracer(GethDebugBuiltInTracerType::MuxTracer) => { + let mux_config = opts.tracer_config.into_mux_config()?; + let mut inspector = MuxInspector::try_from_config(mux_config).map_err(|e| anyhow!(e))?; + let mut evm_with_inspector = evm.with_inspector(&mut inspector); + inspect_input.fill_env(&mut evm_with_inspector); + let tx = std::mem::take(&mut evm_with_inspector.tx); + let res = evm_with_inspector.inspect_tx(tx)?; + inspector.try_into_mux_frame(&res, &cache_db, tx_info)?.into() + } + GethDebugTracerType::BuiltInTracer(GethDebugBuiltInTracerType::FlatCallTracer) => { + let flat_call_config = opts.tracer_config.into_flat_call_config()?; + let mut inspector = TracingInspector::new(TracingInspectorConfig::from_flat_call_config(&flat_call_config)); + let mut evm_with_inspector = evm.with_inspector(&mut inspector); + inspect_input.fill_env(&mut evm_with_inspector); + let tx = std::mem::take(&mut evm_with_inspector.tx); + let res = evm_with_inspector.inspect_tx(tx)?; + inspector + .with_transaction_gas_limit(res.result.tx_gas_used()) + .into_parity_builder() + .into_localized_transaction_traces(tx_info) + .into() + } + GethDebugTracerType::JsTracer(code) => { + let mut inspector = JsInspector::new(code, opts.tracer_config.into_json()).map_err(|e| anyhow!(e.to_string()))?; + let mut evm_with_inspector = evm.with_inspector(&mut inspector); + inspect_input.fill_env(&mut evm_with_inspector); + let tx = std::mem::take(&mut evm_with_inspector.tx); + let block = std::mem::take(&mut evm_with_inspector.block); + let res = evm_with_inspector.inspect_tx(tx.clone())?; + GethTrace::JS(inspector.json_result(res, &tx, &block, &cache_db).map_err(|e| anyhow!(e.to_string()))?) + } + GethDebugTracerType::BuiltInTracer(tracer) => { + return Err(anyhow!("tracer {tracer:?} is not implemented").into()); + } + }; + + Ok(trace_result) +} diff --git a/src/eth/executor/evm/types/input/inspector.rs b/src/eth/executor/evm/types/input/inspector.rs index 7071ad3f0..52937f04b 100644 --- a/src/eth/executor/evm/types/input/inspector.rs +++ b/src/eth/executor/evm/types/input/inspector.rs @@ -1,9 +1,21 @@ use alloy_rpc_types_trace::geth::GethDebugTracingOptions; +use crate::eth::types::CallInput; use crate::eth::types::Hash; +use crate::eth::types::PointInTime; -pub struct InspectorInput { - pub tx_hash: Hash, - pub opts: GethDebugTracingOptions, - pub trace_unsuccessful_only: bool, +pub enum InspectorInput { + /// Traces an already-mined transaction, looked up by hash (`debug_traceTransaction`). + Transaction { + tx_hash: Hash, + opts: GethDebugTracingOptions, + trace_unsuccessful_only: bool, + }, + + /// Traces a synthetic call that was never signed or broadcast, against a chosen point in time (`debug_traceCall`). + Call { + call: CallInput, + point_in_time: PointInTime, + opts: GethDebugTracingOptions, + }, } diff --git a/src/eth/executor/mod.rs b/src/eth/executor/mod.rs index 1c8c0ad57..9564a5f08 100644 --- a/src/eth/executor/mod.rs +++ b/src/eth/executor/mod.rs @@ -466,7 +466,7 @@ impl Executor { let tracer_type = opts.tracer.clone(); timed(|| { - self.evms.inspect(InspectorInput { + self.evms.inspect(InspectorInput::Transaction { tx_hash, opts, trace_unsuccessful_only, @@ -474,4 +474,19 @@ impl Executor { }) .with(|m| metrics::inc_executor_inspect(m.elapsed, serde_json::to_string(&tracer_type).unwrap_or_else(|_| "unkown".to_owned()))) } + + /// Traces a call that was never signed or broadcast, without requiring it to be mined first (`debug_traceCall`). + pub fn trace_call(&self, call: CallInput, point_in_time: PointInTime, opts: Option) -> Result { + Span::with(|s| { + s.rec_opt("from", &call.from); + s.rec_opt("to", &call.to); + }); + + tracing::info!(%point_in_time, "inspecting call"); + let opts = opts.unwrap_or_default(); + let tracer_type = opts.tracer.clone(); + + timed(|| self.evms.inspect(InspectorInput::Call { call, point_in_time, opts })) + .with(|m| metrics::inc_executor_inspect(m.elapsed, serde_json::to_string(&tracer_type).unwrap_or_else(|_| "unkown".to_owned()))) + } } diff --git a/src/eth/rpc/server.rs b/src/eth/rpc/server.rs index 1d6e57693..54482c095 100644 --- a/src/eth/rpc/server.rs +++ b/src/eth/rpc/server.rs @@ -351,6 +351,7 @@ fn register_methods(mut module: RpcModule) -> anyhow::Result, ctx: Arc, ext: Extens } } +fn debug_trace_call(params: Params<'_>, ctx: Arc, ext: Extensions) -> Result { + // enter span + let _middleware_enter = ext.enter_middleware_span(); + let _method_enter = info_span!("rpc::debug_traceCall", tx_from = field::Empty, tx_to = field::Empty, filter = field::Empty).entered(); + + // parse params + let (params, call) = next_rpc_param::(params.sequence())?; + let (params, filter) = next_rpc_param_or_default::(params)?; + let (_, opts) = next_rpc_param_or_default::>(params)?; + + // track + Span::with(|s| { + s.rec_opt("tx_from", &call.from); + s.rec_opt("tx_to", &call.to); + s.rec_str("filter", &filter); + }); + tracing::info!(%filter, "executing debug_traceCall"); + + // execute + let point_in_time = ctx.server.storage.translate_to_point_in_time(filter)?; + match ctx.server.executor.trace_call(call, point_in_time, opts) { + Ok(result) => { + tracing::info!("executed debug_traceCall successfully"); + Ok(enhance_trace_with_decoded_info(&result)) + } + Err(err) => { + tracing::warn!(?err, "error executing debug_traceCall"); + Err(err) + } + } +} + fn stratus_call(params: Params<'_>, ctx: Arc, ext: Extensions) -> Result { // enter span let _middleware_enter = ext.enter_middleware_span();