diff --git a/src/cli/call.rs b/src/cli/call.rs index cdcfe9d..4fa7697 100644 --- a/src/cli/call.rs +++ b/src/cli/call.rs @@ -116,24 +116,25 @@ pub fn run(args: CallArgs) -> Result<(), TraceError> { tx_object["value"] = json!(hex_value); } - let payload = json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "debug_traceCall", - "params": [ - tx_object, - block_id, - { - "tracer": "callTracer", - "tracerConfig": { - "onlyTopCall": false, - "withLog": args.opts.include_logs, - } - } - ] + let payload = trace::rpc_payload( + 1, + "debug_traceCall", + json!([ + &tx_object, + &block_id, + trace::call_tracer_config(args.opts.include_logs) + ]), + ); + + let prestate_payload = args.opts.include_storage.then(|| { + trace::rpc_payload( + 2, + "debug_traceCall", + json!([&tx_object, &block_id, trace::prestate_tracer_config()]), + ) }); - trace::execute_and_print(&payload, args.opts) + trace::execute_and_print(&payload, prestate_payload.as_ref(), args.opts) } /// Parse a block identifier from user input into a JSON-RPC block parameter. @@ -180,6 +181,7 @@ mod tests { include_args: false, include_calldata: false, include_logs: false, + include_storage: false, no_proxy: false, no_color: false, }, diff --git a/src/cli/trace.rs b/src/cli/trace.rs index a9a1919..ef56b6c 100644 --- a/src/cli/trace.rs +++ b/src/cli/trace.rs @@ -1,6 +1,11 @@ use crate::utils::{ - color::Palette, contract_resolver::ContractResolver, event_formatter::Log, hex_utils, rpc_url, - selector_resolver::SelectorResolver, trace_renderer, + color::Palette, + contract_resolver::ContractResolver, + event_formatter::Log, + hex_utils, rpc_url, + selector_resolver::SelectorResolver, + storage_diff::{self, PrestateDiff}, + trace_renderer, }; use clap::Parser; use reqwest::blocking::Client; @@ -52,6 +57,12 @@ pub struct TraceOpts { #[arg(long)] pub include_logs: bool, + /// Include storage changes (state diffs) after the trace. + /// + /// Requires an additional RPC call using `prestateTracer` in diff mode. + #[arg(long)] + pub include_storage: bool, + /// Disable system proxy for RPC requests. #[arg(long)] pub no_proxy: bool, @@ -172,14 +183,39 @@ pub fn validate_hex(data: &str, field: &str) -> Result<(), TraceError> { Ok(()) } -/// Fetch the chain ID from the RPC node as a decimal string (e.g. `"1"`). -fn fetch_chain_id(client: &Client, rpc_url: &str) -> Result { - let payload = serde_json::json!({ +/// Build a JSON-RPC request envelope. +pub fn rpc_payload(id: u32, method: &str, params: serde_json::Value) -> serde_json::Value { + let mut v = serde_json::json!({ "jsonrpc": "2.0", - "id": 1, - "method": "eth_chainId", - "params": [] + "id": id, + "method": method, }); + v["params"] = params; + v +} + +pub fn call_tracer_config(include_logs: bool) -> serde_json::Value { + serde_json::json!({ + "tracer": "callTracer", + "tracerConfig": { + "onlyTopCall": false, + "withLog": include_logs, + } + }) +} + +pub fn prestate_tracer_config() -> serde_json::Value { + serde_json::json!({ + "tracer": "prestateTracer", + "tracerConfig": { + "diffMode": true + } + }) +} + +/// Fetch the chain ID from the RPC node as a decimal string (e.g. `"1"`). +fn fetch_chain_id(client: &Client, rpc_url: &str) -> Result { + let payload = rpc_payload(1, "eth_chainId", serde_json::json!([])); let resp = client.post(rpc_url).json(&payload).send()?; let body: RpcResponse = resp @@ -208,7 +244,14 @@ fn parse_chain_id_hex(hex_str: &str) -> Result { } /// Send an RPC payload, parse the trace response, and print it. -pub fn execute_and_print(payload: &serde_json::Value, opts: TraceOpts) -> Result<(), TraceError> { +/// +/// When `prestate_payload` is provided, a second RPC call is made using +/// `prestateTracer` in diff mode to display storage changes after the trace. +pub fn execute_and_print( + payload: &serde_json::Value, + prestate_payload: Option<&serde_json::Value>, + opts: TraceOpts, +) -> Result<(), TraceError> { if opts.include_args && !opts.resolve_selectors { return Err(TraceError::IncludeArgsRequiresResolveSelectors); } @@ -229,8 +272,25 @@ pub fn execute_and_print(payload: &serde_json::Value, opts: TraceOpts) -> Result }; let resp = client.post(&rpc_url).json(payload).send()?; - - let call_trace = parse_rpc_response(resp)?; + let call_trace: CallTrace = parse_rpc_response(resp)?; + + let prestate_diff: Option = if let Some(ps) = prestate_payload { + let result = client + .post(&rpc_url) + .json(ps) + .send() + .map_err(TraceError::from) + .and_then(parse_rpc_response); + match result { + Ok(diff) => Some(diff), + Err(e) => { + eprintln!("warning: storage diff unavailable: {e}"); + None + } + } + } else { + None + }; let palette = if opts.no_color { Palette::new(false) @@ -250,6 +310,10 @@ pub fn execute_and_print(payload: &serde_json::Value, opts: TraceOpts) -> Result palette, ); + if let Some(diff) = &prestate_diff { + storage_diff::print_storage_diff(diff, &mut contract_resolver, palette); + } + let warnings: Vec = [ selector_resolver.take_warning(), contract_resolver.take_warning(), @@ -269,7 +333,9 @@ pub fn execute_and_print(payload: &serde_json::Value, opts: TraceOpts) -> Result } /// Parse the RPC response, returning the result or an error. -fn parse_rpc_response(resp: reqwest::blocking::Response) -> Result { +fn parse_rpc_response( + resp: reqwest::blocking::Response, +) -> Result { let status = resp.status(); let body = resp.text()?; @@ -280,7 +346,7 @@ fn parse_rpc_response(resp: reqwest::blocking::Response) -> Result = serde_json::from_str(&body) + let rpc_resp: RpcResponse = serde_json::from_str(&body) .map_err(|e| TraceError::Decode(format!("invalid JSON: {e}")))?; if let Some(err) = rpc_resp.error { diff --git a/src/cli/tx.rs b/src/cli/tx.rs index dc01a90..e7b04b8 100644 --- a/src/cli/tx.rs +++ b/src/cli/tx.rs @@ -20,21 +20,21 @@ pub fn run(args: TxArgs) -> Result<(), TraceError> { ))); } - let payload = json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "debug_traceTransaction", - "params": [ - args.tx_hash, - { - "tracer": "callTracer", - "tracerConfig": { - "onlyTopCall": false, - "withLog": args.opts.include_logs, - } - } - ] + let TxArgs { tx_hash, opts } = args; + + let payload = trace::rpc_payload( + 1, + "debug_traceTransaction", + json!([&tx_hash, trace::call_tracer_config(opts.include_logs)]), + ); + + let prestate_payload = opts.include_storage.then(|| { + trace::rpc_payload( + 2, + "debug_traceTransaction", + json!([&tx_hash, trace::prestate_tracer_config()]), + ) }); - trace::execute_and_print(&payload, args.opts) + trace::execute_and_print(&payload, prestate_payload.as_ref(), opts) } diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 35817f4..73009a4 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -7,5 +7,6 @@ pub mod hex_utils; pub mod precompiles; pub mod rpc_url; pub mod selector_resolver; +pub mod storage_diff; pub mod trace_renderer; pub mod value_parser; diff --git a/src/utils/storage_diff.rs b/src/utils/storage_diff.rs new file mode 100644 index 0000000..17a9ca9 --- /dev/null +++ b/src/utils/storage_diff.rs @@ -0,0 +1,278 @@ +use crate::utils::{color::Palette, contract_resolver::ContractResolver}; +use serde::Deserialize; +use std::collections::{BTreeMap, HashMap, HashSet}; + +/// Prestate tracer result in `diffMode: true`. +/// +/// Each map entry is keyed by contract address. `pre` captures state before the +/// transaction; `post` captures state after. Only modified accounts are included. +/// +/// Both fields are always present in valid diffMode output. A missing key causes +/// deserialization to fail, catching non-diffMode responses early. +#[derive(Debug, Deserialize)] +pub struct PrestateDiff { + pub pre: HashMap, + pub post: HashMap, +} + +#[derive(Debug, Deserialize, Default)] +pub struct AccountState { + #[serde(default)] + pub storage: HashMap, +} + +struct SlotChange { + slot: String, + before: Option, + after: Option, +} + +/// Compute per-address storage slot changes from a prestate diff. +fn compute_changes(diff: &PrestateDiff) -> BTreeMap<&str, Vec> { + let mut changes_by_addr: BTreeMap<&str, Vec> = BTreeMap::new(); + + let all_addrs: HashSet<&str> = diff + .pre + .keys() + .chain(diff.post.keys()) + .map(String::as_str) + .collect(); + + for addr in all_addrs { + let pre_storage = diff.pre.get(addr).map(|a| &a.storage); + let post_storage = diff.post.get(addr).map(|a| &a.storage); + + let mut slots = Vec::new(); + + if let Some(pre_s) = pre_storage { + for (slot, pre_val) in pre_s { + match post_storage.and_then(|ps| ps.get(slot)) { + Some(post_val) if post_val != pre_val => { + slots.push(SlotChange { + slot: slot.clone(), + before: Some(pre_val.clone()), + after: Some(post_val.clone()), + }); + } + None => { + slots.push(SlotChange { + slot: slot.clone(), + before: Some(pre_val.clone()), + after: None, + }); + } + _ => {} + } + } + } + + if let Some(post_s) = post_storage { + for (slot, post_val) in post_s { + let in_pre = pre_storage.is_some_and(|ps| ps.contains_key(slot)); + if !in_pre { + slots.push(SlotChange { + slot: slot.clone(), + before: None, + after: Some(post_val.clone()), + }); + } + } + } + + if !slots.is_empty() { + slots.sort_by(|a, b| a.slot.cmp(&b.slot)); + changes_by_addr.insert(addr, slots); + } + } + + changes_by_addr +} + +/// Print storage slot diffs grouped by contract address. +pub fn print_storage_diff( + diff: &PrestateDiff, + contract_resolver: &mut ContractResolver, + pal: Palette, +) { + let changes_by_addr = compute_changes(diff); + + if changes_by_addr.is_empty() { + return; + } + + println!(); + println!("Storage changes:"); + for (addr, slots) in &changes_by_addr { + let header = match contract_resolver.resolve(addr) { + Some(name) => format!(" {} ({}):", pal.cyan(&name), pal.dim(addr)), + None => format!(" {}:", pal.cyan(addr)), + }; + println!("{header}"); + + for change in slots { + let before = change.before.as_deref().unwrap_or("0x0"); + let after = change.after.as_deref().unwrap_or("0x0"); + println!( + " {} {} {} {}", + pal.dim(&format!("@ {}:", change.slot)), + before, + pal.dim("→"), + after, + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_diff( + pre: Vec<(&str, Vec<(&str, &str)>)>, + post: Vec<(&str, Vec<(&str, &str)>)>, + ) -> PrestateDiff { + let to_map = |entries: Vec<(&str, Vec<(&str, &str)>)>| { + entries + .into_iter() + .map(|(addr, slots)| { + let storage = slots + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + (addr.to_string(), AccountState { storage }) + }) + .collect() + }; + PrestateDiff { + pre: to_map(pre), + post: to_map(post), + } + } + + #[test] + fn test_compute_changes_slot_modified() { + let diff = make_diff( + vec![("0xaaa", vec![("0x01", "0xold")])], + vec![("0xaaa", vec![("0x01", "0xnew")])], + ); + let changes = compute_changes(&diff); + assert_eq!(changes.len(), 1); + let slots = &changes["0xaaa"]; + assert_eq!(slots.len(), 1); + assert_eq!(slots[0].slot, "0x01"); + assert_eq!(slots[0].before.as_deref(), Some("0xold")); + assert_eq!(slots[0].after.as_deref(), Some("0xnew")); + } + + #[test] + fn test_compute_changes_slot_unchanged() { + let diff = make_diff( + vec![("0xaaa", vec![("0x01", "0xsame")])], + vec![("0xaaa", vec![("0x01", "0xsame")])], + ); + let changes = compute_changes(&diff); + assert!(changes.is_empty()); + } + + #[test] + fn test_compute_changes_slot_new() { + let diff = make_diff(vec![], vec![("0xaaa", vec![("0x01", "0xnew")])]); + let changes = compute_changes(&diff); + assert_eq!(changes.len(), 1); + let slots = &changes["0xaaa"]; + assert_eq!(slots.len(), 1); + assert_eq!(slots[0].before, None); + assert_eq!(slots[0].after.as_deref(), Some("0xnew")); + } + + #[test] + fn test_compute_changes_slot_deleted() { + let diff = make_diff(vec![("0xaaa", vec![("0x01", "0xold")])], vec![]); + let changes = compute_changes(&diff); + assert_eq!(changes.len(), 1); + let slots = &changes["0xaaa"]; + assert_eq!(slots.len(), 1); + assert_eq!(slots[0].before.as_deref(), Some("0xold")); + assert_eq!(slots[0].after, None); + } + + #[test] + fn test_compute_changes_empty_diff() { + let diff = make_diff(vec![], vec![]); + let changes = compute_changes(&diff); + assert!(changes.is_empty()); + } + + #[test] + fn test_compute_changes_addresses_sorted() { + let diff = make_diff( + vec![ + ("0xzzz", vec![("0x01", "0xa")]), + ("0xaaa", vec![("0x01", "0xb")]), + ], + vec![ + ("0xzzz", vec![("0x01", "0xc")]), + ("0xaaa", vec![("0x01", "0xd")]), + ], + ); + let changes = compute_changes(&diff); + let addrs: Vec<&&str> = changes.keys().collect(); + assert_eq!(addrs, vec![&"0xaaa", &"0xzzz"]); + } + + #[test] + fn test_compute_changes_slots_sorted() { + let diff = make_diff( + vec![("0xaaa", vec![("0x03", "0xa"), ("0x01", "0xb")])], + vec![("0xaaa", vec![("0x03", "0xc"), ("0x01", "0xd")])], + ); + let changes = compute_changes(&diff); + let slots: Vec<&str> = changes["0xaaa"].iter().map(|s| s.slot.as_str()).collect(); + assert_eq!(slots, vec!["0x01", "0x03"]); + } + + #[test] + fn test_compute_changes_address_only_in_post() { + let diff = make_diff(vec![], vec![("0xnew", vec![("0x01", "0xval")])]); + let changes = compute_changes(&diff); + assert_eq!(changes.len(), 1); + assert!(changes.contains_key("0xnew")); + } + + #[test] + fn test_compute_changes_address_only_in_pre() { + let diff = make_diff(vec![("0xold", vec![("0x01", "0xval")])], vec![]); + let changes = compute_changes(&diff); + assert_eq!(changes.len(), 1); + let slots = &changes["0xold"]; + assert_eq!(slots[0].before.as_deref(), Some("0xval")); + assert_eq!(slots[0].after, None); + } + + #[test] + fn test_compute_changes_mixed_operations_single_address() { + let diff = make_diff( + vec![( + "0xaaa", + vec![("0x01", "0xa"), ("0x02", "0xb"), ("0x03", "0xsame")], + )], + vec![( + "0xaaa", + vec![("0x01", "0xnew_a"), ("0x03", "0xsame"), ("0x04", "0xd")], + )], + ); + let changes = compute_changes(&diff); + assert_eq!(changes.len(), 1); + let slots = &changes["0xaaa"]; + assert_eq!(slots.len(), 3); + assert_eq!(slots[0].slot, "0x01"); // modified + assert_eq!(slots[0].before.as_deref(), Some("0xa")); + assert_eq!(slots[0].after.as_deref(), Some("0xnew_a")); + assert_eq!(slots[1].slot, "0x02"); // deleted + assert_eq!(slots[1].before.as_deref(), Some("0xb")); + assert_eq!(slots[1].after, None); + assert_eq!(slots[2].slot, "0x04"); // new + assert_eq!(slots[2].before, None); + assert_eq!(slots[2].after.as_deref(), Some("0xd")); + } +} diff --git a/tests/fixtures/call/main.json b/tests/fixtures/call/main.json index 24437c6..8a48759 100644 --- a/tests/fixtures/call/main.json +++ b/tests/fixtures/call/main.json @@ -242,6 +242,28 @@ }, "expected_trace": "usdt-transfer.log", "expected_trace_logs": "usdt-transfer-logs.log", + "prestate_response": { + "jsonrpc": "2.0", + "id": 2, + "result": { + "pre": { + "0xdac17f958d2ee523a2206206994597c13d831ec7": { + "storage": { + "0x0e11a23e5ee6a63bb5bc0332a5d301e3fce50f853a73c10cf32c3e9bae1e8488": "0x00000000000000000000000000000000000000000000000000000002540be400", + "0xd1fafa7e5cc19f8ff1ed979e86ed52b7ff6be7ca4ee50ed03154ad7c3c364a3e": "0x0000000000000000000000000000000000000000000000000000000000000000" + } + } + }, + "post": { + "0xdac17f958d2ee523a2206206994597c13d831ec7": { + "storage": { + "0x0e11a23e5ee6a63bb5bc0332a5d301e3fce50f853a73c10cf32c3e9bae1e8488": "0x00000000000000000000000000000000000000000000000000000001f28e88c0", + "0xd1fafa7e5cc19f8ff1ed979e86ed52b7ff6be7ca4ee50ed03154ad7c3c364a3e": "0x000000000000000000000000000000000000000000000000000000003b9aca00" + } + } + } + } + }, "expected_trace_full": "usdt-transfer-full.log" }, { @@ -277,6 +299,26 @@ }, "expected_trace": "weth-deposit.log", "expected_trace_logs": "weth-deposit-logs.log", + "prestate_response": { + "jsonrpc": "2.0", + "id": 2, + "result": { + "pre": { + "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2": { + "storage": { + "0x6a91e089e32674930cf24d77f3f25bfc71de3113940acb4e14d51a68d3ca2451": "0x0000000000000000000000000000000000000000000000000000000000000000" + } + } + }, + "post": { + "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2": { + "storage": { + "0x6a91e089e32674930cf24d77f3f25bfc71de3113940acb4e14d51a68d3ca2451": "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000" + } + } + } + } + }, "expected_trace_full": "weth-deposit-full.log" }, { @@ -343,6 +385,26 @@ }, "expected_trace": "balancer-basepool.log", "expected_trace_logs": "balancer-basepool-logs.log", + "prestate_response": { + "jsonrpc": "2.0", + "id": 2, + "result": { + "pre": { + "0xba12222222228d8ba445958a75a0704d566bf2c8": { + "storage": { + "0x06fbb4b6b6e429be0e0c28b08a10a8ed2f3ec5e2a6a08e8d25be38e894c8d4d": "0x0000000000000000000000000000000000000000000000000000000000000000" + } + } + }, + "post": { + "0xba12222222228d8ba445958a75a0704d566bf2c8": { + "storage": { + "0x06fbb4b6b6e429be0e0c28b08a10a8ed2f3ec5e2a6a08e8d25be38e894c8d4d": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + } + } + }, "expected_trace_full": "balancer-basepool-full.log" }, { @@ -488,6 +550,20 @@ }, "expected_trace": "agglayer-vault.log", "expected_trace_logs": "agglayer-vault-logs.log", + "prestate_response": { + "jsonrpc": "2.0", + "id": 2, + "result": { + "pre": {}, + "post": { + "0xbd770416a3345f91e4b34576cb804a576fa48eb1": { + "storage": { + "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc": "0x000000000000000000000000cc865b0324121b43728176024f58bdbb3afd6f29" + } + } + } + } + }, "expected_trace_full": "agglayer-vault-full.log" } ] diff --git a/tests/fixtures/call/traces/agglayer-vault-full.log b/tests/fixtures/call/traces/agglayer-vault-full.log index e8ba72d..49cdc62 100644 --- a/tests/fixtures/call/traces/agglayer-vault-full.log +++ b/tests/fixtures/call/traces/agglayer-vault-full.log @@ -50,3 +50,7 @@ Traces: └─ [0] 0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF::networkID() [staticcall] ↳ error: execution reverted data: 0xbab161bf + +Storage changes: + 0xbd770416a3345f91e4b34576cb804a576fa48eb1: + @ 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc: 0x0 → 0x000000000000000000000000cc865b0324121b43728176024f58bdbb3afd6f29 diff --git a/tests/fixtures/call/traces/balancer-basepool-full.log b/tests/fixtures/call/traces/balancer-basepool-full.log index 02c2029..d2992e5 100644 --- a/tests/fixtures/call/traces/balancer-basepool-full.log +++ b/tests/fixtures/call/traces/balancer-basepool-full.log @@ -16,3 +16,7 @@ Traces: │ data: 0x7dd7b0e2 └─ [1332] Vault::getAuthorizer() [staticcall] data: 0xaaabadc5 + +Storage changes: + Vault (0xba12222222228d8ba445958a75a0704d566bf2c8): + @ 0x06fbb4b6b6e429be0e0c28b08a10a8ed2f3ec5e2a6a08e8d25be38e894c8d4d: 0x0000000000000000000000000000000000000000000000000000000000000000 → 0x0000000000000000000000000000000000000000000000000000000000000001 diff --git a/tests/fixtures/call/traces/usdt-transfer-full.log b/tests/fixtures/call/traces/usdt-transfer-full.log index 1154de9..e8abe1d 100644 --- a/tests/fixtures/call/traces/usdt-transfer-full.log +++ b/tests/fixtures/call/traces/usdt-transfer-full.log @@ -5,3 +5,8 @@ Traces: [0] address = 0xd8da6bf26964af9d7eed9e03e53415d37aa96045 [1] uint256 = 1000000000 data: 0xa9059cbb000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045000000000000000000000000000000000000000000000000000000003b9aca00 + +Storage changes: + TetherToken (0xdac17f958d2ee523a2206206994597c13d831ec7): + @ 0x0e11a23e5ee6a63bb5bc0332a5d301e3fce50f853a73c10cf32c3e9bae1e8488: 0x00000000000000000000000000000000000000000000000000000002540be400 → 0x00000000000000000000000000000000000000000000000000000001f28e88c0 + @ 0xd1fafa7e5cc19f8ff1ed979e86ed52b7ff6be7ca4ee50ed03154ad7c3c364a3e: 0x0000000000000000000000000000000000000000000000000000000000000000 → 0x000000000000000000000000000000000000000000000000000000003b9aca00 diff --git a/tests/fixtures/call/traces/weth-deposit-full.log b/tests/fixtures/call/traces/weth-deposit-full.log index 69a5e81..4584702 100644 --- a/tests/fixtures/call/traces/weth-deposit-full.log +++ b/tests/fixtures/call/traces/weth-deposit-full.log @@ -2,3 +2,7 @@ Traces: [27938] WETH9::deposit{value: 1000000000000000000}() [call] └─ emit Deposit(param0: 0xd8da6bf26964af9d7eed9e03e53415d37aa96045, param1: 1000000000000000000) data: 0xd0e30db0 + +Storage changes: + WETH9 (0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2): + @ 0x6a91e089e32674930cf24d77f3f25bfc71de3113940acb4e14d51a68d3ca2451: 0x0000000000000000000000000000000000000000000000000000000000000000 → 0x0000000000000000000000000000000000000000000000000de0b6b3a7640000 diff --git a/tests/fixtures/tx/main.json b/tests/fixtures/tx/main.json index 1086556..d9fa10e 100644 --- a/tests/fixtures/tx/main.json +++ b/tests/fixtures/tx/main.json @@ -398,6 +398,36 @@ }, "expected_trace": "uniswapv4-execute.log", "expected_trace_logs": "uniswapv4-execute-logs.log", + "prestate_response": { + "jsonrpc": "2.0", + "id": 2, + "result": { + "pre": { + "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2": { + "storage": { + "0x390f6178407c9b8e95f34597e92f120c7478b3b4adab2e0e6a14e8e3108fe7c3": "0x00000000000000000000000000000000000000000000004563918244f4000000" + } + }, + "0xec70ff4a5b09110e4d20ada4f2db4a86ec61fac6": { + "storage": { + "0x8b9e16f0a36a0960db45bf6076e0770f865ab0cd4e2a4b3c34485b5830347692": "0x0000000000000000000000000000000000000000000001a784379d99db420000" + } + } + }, + "post": { + "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2": { + "storage": { + "0x390f6178407c9b8e95f34597e92f120c7478b3b4adab2e0e6a14e8e3108fe7c3": "0x00000000000000000000000000000000000000000000004566b000c82cab0ebd" + } + }, + "0xec70ff4a5b09110e4d20ada4f2db4a86ec61fac6": { + "storage": { + "0x8b9e16f0a36a0960db45bf6076e0770f865ab0cd4e2a4b3c34485b5830347692": "0x0000000000000000000000000000000000000000000000e4d4a510762ca60000" + } + } + } + } + }, "expected_trace_full": "uniswapv4-execute-full.log" }, { @@ -714,6 +744,36 @@ }, "expected_trace": "blur-buyToBorrowV2.log", "expected_trace_logs": "blur-buyToBorrowV2-logs.log", + "prestate_response": { + "jsonrpc": "2.0", + "id": 2, + "result": { + "pre": { + "0x01a656024de4b89e2d0198bf4d468e8fd2358b17": { + "storage": { + "0x48be7977e89539eaa4c72bf83bac54d23ef996e5ef722e9718ba391265084031": "0x00000000000000000000000000000000000000000000000382acb3dcb0a6176d" + } + }, + "0xbd3531da5cf5857e7cfaa92426877b022e612cf8": { + "storage": { + "0xe53776c124528520fc08a6e338209ca29b53e53765fc176239d2cc872824d137": "0x000000000000000000000000988a3fe412f75382c005be3a37881a78129c8bbb" + } + } + }, + "post": { + "0x01a656024de4b89e2d0198bf4d468e8fd2358b17": { + "storage": { + "0x48be7977e89539eaa4c72bf83bac54d23ef996e5ef722e9718ba391265084031": "0x0000000000000000000000000000000000000000000000035783c356ed2f176d" + } + }, + "0xbd3531da5cf5857e7cfaa92426877b022e612cf8": { + "storage": { + "0xe53776c124528520fc08a6e338209ca29b53e53765fc176239d2cc872824d137": "0x00000000000000000000000029469395eaf6f95920e59f858042f0e28d98a20b" + } + } + } + } + }, "expected_trace_full": "blur-buyToBorrowV2-full.log" }, { @@ -1798,6 +1858,26 @@ }, "expected_trace": "pimlico-handleOps.log", "expected_trace_logs": "pimlico-handleOps-logs.log", + "prestate_response": { + "jsonrpc": "2.0", + "id": 2, + "result": { + "pre": { + "0x0000000071727de22e5e9d8baf0edac6f37da032": { + "storage": { + "0xa1829a9003092132f585b6ccdd167c19fe9774dbdea4260287e8a8e8ca8185d7": "0x000000000000000000000000000000000000000000000000001ed5a8ae1a5000" + } + } + }, + "post": { + "0x0000000071727de22e5e9d8baf0edac6f37da032": { + "storage": { + "0xa1829a9003092132f585b6ccdd167c19fe9774dbdea4260287e8a8e8ca8185d7": "0x000000000000000000000000000000000000000000000000001883c2d3dd4553" + } + } + } + } + }, "expected_trace_full": "pimlico-handleOps-full.log" }, { @@ -2079,6 +2159,20 @@ }, "expected_trace": "usual-create.log", "expected_trace_logs": "usual-create-logs.log", + "prestate_response": { + "jsonrpc": "2.0", + "id": 2, + "result": { + "pre": {}, + "post": { + "0xc4441c2be5d8fa8126822b9929ca0b81ea0de38e": { + "storage": { + "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc": "0x0000000000000000000000002b65f9d2e4b84a2df6ff0525741b75d1276a9c2f" + } + } + } + } + }, "expected_trace_full": "usual-create-full.log" }, { @@ -2244,6 +2338,20 @@ }, "expected_trace": "plume-deploy.log", "expected_trace_logs": "plume-deploy-logs.log", + "prestate_response": { + "jsonrpc": "2.0", + "id": 2, + "result": { + "pre": {}, + "post": { + "0x4c1746a800d224393fe2470c70a35717ed4ea5f1": { + "storage": { + "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc": "0x000000000000000000000000226e2dae9b561b314be6eec531590faa82daa3a8" + } + } + } + } + }, "expected_trace_full": "plume-deploy-full.log" }, { @@ -2424,6 +2532,20 @@ }, "expected_trace": "movement-deploy.log", "expected_trace_logs": "movement-deploy-logs.log", + "prestate_response": { + "jsonrpc": "2.0", + "id": 2, + "result": { + "pre": {}, + "post": { + "0x3fa729673f4b3cdf1b072b4c3f71860f3af38e2b": { + "storage": { + "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc": "0x0000000000000000000000001e1bf2adf28e2e0549ad2474f04f3e1b0de77e9c" + } + } + } + } + }, "expected_trace_full": "movement-deploy-full.log" } ] diff --git a/tests/fixtures/tx/traces/blur-buyToBorrowV2-full.log b/tests/fixtures/tx/traces/blur-buyToBorrowV2-full.log index 6053d74..7737be1 100644 --- a/tests/fixtures/tx/traces/blur-buyToBorrowV2-full.log +++ b/tests/fixtures/tx/traces/blur-buyToBorrowV2-full.log @@ -238,3 +238,9 @@ Traces: │ data: 0x150b7a020000000000000000000000002f18f339620a63e43f0839eeb18d7de1e1be4dfb000000000000000000000000988a3fe412f75382c005be3a37881a78129c8bbb00000000000000000000000000000000000000000000000000000000000002e000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000 └─ [0] 0x988a3fe412f75382c005be3a37881a78129c8bbb::fallback{value: 4750000000000000000}() [call] data: 0x + +Storage changes: + BlurPool (0x01a656024de4b89e2d0198bf4d468e8fd2358b17): + @ 0x48be7977e89539eaa4c72bf83bac54d23ef996e5ef722e9718ba391265084031: 0x00000000000000000000000000000000000000000000000382acb3dcb0a6176d → 0x0000000000000000000000000000000000000000000000035783c356ed2f176d + PudgyPenguins (0xbd3531da5cf5857e7cfaa92426877b022e612cf8): + @ 0xe53776c124528520fc08a6e338209ca29b53e53765fc176239d2cc872824d137: 0x000000000000000000000000988a3fe412f75382c005be3a37881a78129c8bbb → 0x00000000000000000000000029469395eaf6f95920e59f858042f0e28d98a20b diff --git a/tests/fixtures/tx/traces/movement-deploy-full.log b/tests/fixtures/tx/traces/movement-deploy-full.log index 5862190..23aa8f0 100644 --- a/tests/fixtures/tx/traces/movement-deploy-full.log +++ b/tests/fixtures/tx/traces/movement-deploy-full.log @@ -57,3 +57,7 @@ Traces: └─ [227793] 0x8365aa031806a1ac2b31a5d3b8323020fc85dfec [create] └─ emit OwnershipTransferred(param0: 0, param1: 0xa649f6335828f070dddd7a8c4f5bef2b6ff7bd51) data: 0x608060405234801561000f575f80fd5b506040516104ef3803806104ef83398101604081905261002e916100bb565b806001600160a01b03811661005c57604051631e4fbdf760e01b81525f600482015260240160405180910390fd5b6100658161006c565b50506100e8565b5f80546001600160a01b038381166001600160a01b0319831681178455604051919092169283917f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e09190a35050565b5f602082840312156100cb575f80fd5b81516001600160a01b03811681146100e1575f80fd5b9392505050565b6103fa806100f55f395ff3fe608060405260043610610049575f3560e01c8063715018a61461004d5780638da5cb5b146100635780639623609d1461008e578063ad3cb1cc146100a1578063f2fde38b146100de575b5f80fd5b348015610058575f80fd5b506100616100fd565b005b34801561006e575f80fd5b505f546040516001600160a01b0390911681526020015b60405180910390f35b61006161009c366004610260565b610110565b3480156100ac575f80fd5b506100d1604051806040016040528060058152602001640352e302e360dc1b81525081565b6040516100859190610365565b3480156100e9575f80fd5b506100616100f836600461037e565b61017b565b6101056101bd565b61010e5f6101e9565b565b6101186101bd565b60405163278f794360e11b81526001600160a01b03841690634f1ef2869034906101489086908690600401610399565b5f604051808303818588803b15801561015f575f80fd5b505af1158015610171573d5f803e3d5ffd5b5050505050505050565b6101836101bd565b6001600160a01b0381166101b157604051631e4fbdf760e01b81525f60048201526024015b60405180910390fd5b6101ba816101e9565b50565b5f546001600160a01b0316331461010e5760405163118cdaa760e01b81523360048201526024016101a8565b5f80546001600160a01b038381166001600160a01b0319831681178455604051919092169283917f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e09190a35050565b6001600160a01b03811681146101ba575f80fd5b634e487b7160e01b5f52604160045260245ffd5b5f805f60608486031215610272575f80fd5b833561027d81610238565b9250602084013561028d81610238565b9150604084013567ffffffffffffffff8111156102a8575f80fd5b8401601f810186136102b8575f80fd5b803567ffffffffffffffff8111156102d2576102d261024c565b604051601f8201601f19908116603f0116810167ffffffffffffffff811182821017156103015761030161024c565b604052818152828201602001881015610318575f80fd5b816020840160208301375f602083830101528093505050509250925092565b5f81518084528060208401602086015e5f602082860101526020601f19601f83011685010191505092915050565b602081525f6103776020830184610337565b9392505050565b5f6020828403121561038e575f80fd5b813561037781610238565b6001600160a01b03831681526040602082018190525f906103bc90830184610337565b94935050505056fea2646970667358221220def1ca9b5fe53ae7582cac45dc9f62f92e0f0d18509d044fe0ed34cd71f1407864736f6c634300081a0033000000000000000000000000a649f6335828f070dddd7a8c4f5bef2b6ff7bd51 + +Storage changes: + 0x3fa729673f4b3cdf1b072b4c3f71860f3af38e2b: + @ 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc: 0x0 → 0x0000000000000000000000001e1bf2adf28e2e0549ad2474f04f3e1b0de77e9c diff --git a/tests/fixtures/tx/traces/pimlico-handleOps-full.log b/tests/fixtures/tx/traces/pimlico-handleOps-full.log index 09c4765..47d02fd 100644 --- a/tests/fixtures/tx/traces/pimlico-handleOps-full.log +++ b/tests/fixtures/tx/traces/pimlico-handleOps-full.log @@ -506,3 +506,7 @@ Traces: │ data: 0xa9059cbb00000000000000000000000015d7cdb6709eb721910a54ae1ead695428f816f3000000000000000000000000000000000000000000000000000000000448c37b └─ [0] 0x4337002c5702ce424cb62a56ca038e31e1d4a93d::fallback{value: 6868109336115}() [call] data: 0x + +Storage changes: + EntryPoint (0x0000000071727de22e5e9d8baf0edac6f37da032): + @ 0xa1829a9003092132f585b6ccdd167c19fe9774dbdea4260287e8a8e8ca8185d7: 0x000000000000000000000000000000000000000000000000001ed5a8ae1a5000 → 0x000000000000000000000000000000000000000000000000001883c2d3dd4553 diff --git a/tests/fixtures/tx/traces/plume-deploy-full.log b/tests/fixtures/tx/traces/plume-deploy-full.log index 2279737..3f48952 100644 --- a/tests/fixtures/tx/traces/plume-deploy-full.log +++ b/tests/fixtures/tx/traces/plume-deploy-full.log @@ -25,3 +25,7 @@ Traces: args: [0] address = 0x92f623e5b94130ce6aab372b35e7c94e8806b08d data: 0xc4d66de800000000000000000000000092f623e5b94130ce6aab372b35e7c94e8806b08d + +Storage changes: + 0x4c1746a800d224393fe2470c70a35717ed4ea5f1: + @ 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc: 0x0 → 0x000000000000000000000000226e2dae9b561b314be6eec531590faa82daa3a8 diff --git a/tests/fixtures/tx/traces/uniswapv4-execute-full.log b/tests/fixtures/tx/traces/uniswapv4-execute-full.log index 95781c4..5e5eabd 100644 --- a/tests/fixtures/tx/traces/uniswapv4-execute-full.log +++ b/tests/fixtures/tx/traces/uniswapv4-execute-full.log @@ -135,3 +135,9 @@ Traces: │ data: 0x └─ [0] 0x07e3cbdcbb019e78018fbc5957dc9969b70a3508::fallback{value: 65107847605822}() [call] data: 0x + +Storage changes: + WETH9 (0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2): + @ 0x390f6178407c9b8e95f34597e92f120c7478b3b4adab2e0e6a14e8e3108fe7c3: 0x00000000000000000000000000000000000000000000004563918244f4000000 → 0x00000000000000000000000000000000000000000000004566b000c82cab0ebd + GrapeToken (0xec70ff4a5b09110e4d20ada4f2db4a86ec61fac6): + @ 0x8b9e16f0a36a0960db45bf6076e0770f865ab0cd4e2a4b3c34485b5830347692: 0x0000000000000000000000000000000000000000000001a784379d99db420000 → 0x0000000000000000000000000000000000000000000000e4d4a510762ca60000 diff --git a/tests/fixtures/tx/traces/usual-create-full.log b/tests/fixtures/tx/traces/usual-create-full.log index 5ad8430..809a935 100644 --- a/tests/fixtures/tx/traces/usual-create-full.log +++ b/tests/fixtures/tx/traces/usual-create-full.log @@ -21,3 +21,7 @@ Traces: └─ [230399] 0x430a2712cefaac8cb66e9cb29ff267cfcfa38a42 [create] └─ emit OwnershipTransferred(param0: 0, param1: 0xaada24358620d4638a2ee8788244c6f4b197ca16) data: 0x608060405234801561000f575f80fd5b506040516104fc3803806104fc83398101604081905261002e916100bb565b806001600160a01b03811661005c57604051631e4fbdf760e01b81525f600482015260240160405180910390fd5b6100658161006c565b50506100e8565b5f80546001600160a01b038381166001600160a01b0319831681178455604051919092169283917f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e09190a35050565b5f602082840312156100cb575f80fd5b81516001600160a01b03811681146100e1575f80fd5b9392505050565b610407806100f55f395ff3fe608060405260043610610049575f3560e01c8063715018a61461004d5780638da5cb5b146100635780639623609d1461008e578063ad3cb1cc146100a1578063f2fde38b146100de575b5f80fd5b348015610058575f80fd5b506100616100fd565b005b34801561006e575f80fd5b505f546040516001600160a01b0390911681526020015b60405180910390f35b61006161009c366004610260565b610110565b3480156100ac575f80fd5b506100d1604051806040016040528060058152602001640352e302e360dc1b81525081565b6040516100859190610372565b3480156100e9575f80fd5b506100616100f836600461038b565b61017b565b6101056101bd565b61010e5f6101e9565b565b6101186101bd565b60405163278f794360e11b81526001600160a01b03841690634f1ef28690349061014890869086906004016103a6565b5f604051808303818588803b15801561015f575f80fd5b505af1158015610171573d5f803e3d5ffd5b5050505050505050565b6101836101bd565b6001600160a01b0381166101b157604051631e4fbdf760e01b81525f60048201526024015b60405180910390fd5b6101ba816101e9565b50565b5f546001600160a01b0316331461010e5760405163118cdaa760e01b81523360048201526024016101a8565b5f80546001600160a01b038381166001600160a01b0319831681178455604051919092169283917f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e09190a35050565b6001600160a01b03811681146101ba575f80fd5b634e487b7160e01b5f52604160045260245ffd5b5f805f60608486031215610272575f80fd5b833561027d81610238565b9250602084013561028d81610238565b9150604084013567ffffffffffffffff808211156102a9575f80fd5b818601915086601f8301126102bc575f80fd5b8135818111156102ce576102ce61024c565b604051601f8201601f19908116603f011681019083821181831017156102f6576102f661024c565b8160405282815289602084870101111561030e575f80fd5b826020860160208301375f6020848301015280955050505050509250925092565b5f81518084525f5b8181101561035357602081850181015186830182015201610337565b505f602082860101526020601f19601f83011685010191505092915050565b602081525f610384602083018461032f565b9392505050565b5f6020828403121561039b575f80fd5b813561038481610238565b6001600160a01b03831681526040602082018190525f906103c99083018461032f565b94935050505056fea2646970667358221220bec3bfe387b12a39f7b13024b11bc4d4ec3caafab8e85b0cc76aa28dd001c52464736f6c63430008140033000000000000000000000000aada24358620d4638a2ee8788244c6f4b197ca16 + +Storage changes: + 0xc4441c2be5d8fa8126822b9929ca0b81ea0de38e: + @ 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc: 0x0 → 0x0000000000000000000000002b65f9d2e4b84a2df6ff0525741b75d1276a9c2f diff --git a/tests/main.rs b/tests/main.rs index a4c36bc..d6fa6d3 100644 --- a/tests/main.rs +++ b/tests/main.rs @@ -136,29 +136,44 @@ fn setup_rpc_mock(server: &mut Server, rpc_response: &serde_json::Value) -> mock fn setup_rpc_with_chain_id_mock( server: &mut Server, rpc_response: &serde_json::Value, -) -> (mockito::Mock, mockito::Mock) { + prestate_response: Option<&serde_json::Value>, +) -> Vec { let chain_id_response = json!({ "jsonrpc": "2.0", "id": 1, "result": "0x1" }); + let mut mocks = Vec::new(); + + mocks.push( + server + .mock("POST", "/") + .match_body(Matcher::Regex(r#""tracer":"callTracer""#.to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(serde_json::to_string(rpc_response).unwrap()) + .create(), + ); - // reqwest serializes compact JSON, so the method field is "method":"debug_trace…". - // Matching the field name + value avoids false positives if the bare string ever - // appears inside calldata or other request parameters. - let rpc_mock = server - .mock("POST", "/") - .match_body(Matcher::Regex(r#""method":"debug_trace"#.to_string())) - .with_status(200) - .with_header("content-type", "application/json") - .with_body(serde_json::to_string(rpc_response).unwrap()) - .create(); + if let Some(ps_response) = prestate_response { + mocks.push( + server + .mock("POST", "/") + .match_body(Matcher::Regex(r#""tracer":"prestateTracer""#.to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(serde_json::to_string(ps_response).unwrap()) + .create(), + ); + } - let chain_id_mock = server - .mock("POST", "/") - .match_body(Matcher::Regex(r#""method":"eth_chainId""#.to_string())) - .with_status(200) - .with_header("content-type", "application/json") - .with_body(chain_id_response.to_string()) - .create(); + mocks.push( + server + .mock("POST", "/") + .match_body(Matcher::Regex(r#""method":"eth_chainId""#.to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(chain_id_response.to_string()) + .create(), + ); - (rpc_mock, chain_id_mock) + mocks } fn load_trace_file(dir: &str, filename: &str) -> String { @@ -176,7 +191,11 @@ struct TestEnv { _servers: Vec, } -fn setup_test_env(rpc_response: &serde_json::Value, mode: TestMode) -> TestEnv { +fn setup_test_env( + rpc_response: &serde_json::Value, + prestate_response: Option<&serde_json::Value>, + mode: TestMode, +) -> TestEnv { let mut rpc_server = Server::new(); let mut sourcify_server = Server::new(); let mut sourcify_v2_server = Server::new(); @@ -187,9 +206,11 @@ fn setup_test_env(rpc_response: &serde_json::Value, mode: TestMode) -> TestEnv { let mut mocks: Vec = Vec::new(); if matches!(mode, TestMode::Full) { - let (rpc_mock, chain_id_mock) = setup_rpc_with_chain_id_mock(&mut rpc_server, rpc_response); - mocks.push(rpc_mock); - mocks.push(chain_id_mock); + mocks.extend(setup_rpc_with_chain_id_mock( + &mut rpc_server, + rpc_response, + prestate_response, + )); mocks.extend(setup_sourcify_mock(&mut sourcify_server, &load_selectors())); mocks.extend(setup_sourcify_v2_mock( &mut sourcify_v2_server, @@ -220,6 +241,7 @@ fn apply_mode_flags(cmd: &mut Command, mode: TestMode, env: &TestEnv) { .arg("--resolve-contracts") .arg("--include-args") .arg("--include-calldata") + .arg("--include-storage") .env("SOURCIFY_4BYTE_URL", format!("{}/", env.sourcify_url)) .env("SOURCIFY_SERVER_URL", format!("{}/", env.sourcify_v2_url)); } @@ -252,6 +274,7 @@ struct TxTestCase { name: String, tx_hash: String, rpc_response: serde_json::Value, + prestate_response: Option, expected_trace: String, expected_trace_logs: Option, expected_trace_full: Option, @@ -266,7 +289,11 @@ fn load_tx_fixtures() -> Vec { fn run_tx_test(test_case: &TxTestCase, expected_file: &str, mode: TestMode) { let traces_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/tx/traces"); let expected_output = load_trace_file(traces_dir, expected_file); - let env = setup_test_env(&test_case.rpc_response, mode); + let env = setup_test_env( + &test_case.rpc_response, + test_case.prestate_response.as_ref(), + mode, + ); let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("torge")); cmd.arg("tx") @@ -316,6 +343,7 @@ struct CallTestCase { from: Option, value: Option, rpc_response: serde_json::Value, + prestate_response: Option, expected_trace: String, expected_trace_logs: Option, expected_trace_full: Option, @@ -330,7 +358,11 @@ fn load_call_fixtures() -> Vec { fn run_call_test(test_case: &CallTestCase, expected_file: &str, mode: TestMode) { let traces_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/call/traces"); let expected_output = load_trace_file(traces_dir, expected_file); - let env = setup_test_env(&test_case.rpc_response, mode); + let env = setup_test_env( + &test_case.rpc_response, + test_case.prestate_response.as_ref(), + mode, + ); let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("torge")); cmd.arg("call"); @@ -378,6 +410,127 @@ fn test_call_full_trace_outputs() { } } +// --------------------------------------------------------------------------- +// storage-diff edge-case tests +// --------------------------------------------------------------------------- + +/// Basic-mode output must never contain storage diffs, even when the fixture +/// carries a `prestate_response`. The flag is absent, so no prestate RPC call +/// should be made. +#[test] +fn test_tx_basic_mode_omits_storage_changes() { + let fixtures = load_tx_fixtures(); + let tc = fixtures + .iter() + .find(|tc| tc.prestate_response.is_some()) + .expect("need at least one fixture with prestate_response"); + + let env = setup_test_env(&tc.rpc_response, None, TestMode::Basic); + + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("torge")); + cmd.arg("tx") + .arg(&tc.tx_hash) + .arg("-r") + .arg(&env.rpc_url) + .env("TORGE_DISABLE_CACHE", "1"); + + let output = cmd.output().expect("failed to run"); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + !stdout.contains("Storage changes:"), + "basic mode must not include storage diffs" + ); +} + +/// When the prestateTracer RPC call fails, the tool should still succeed and +/// print the call trace. The storage diff warning goes to stderr. +#[test] +fn test_tx_prestate_rpc_failure_degrades_gracefully() { + let fixtures = load_tx_fixtures(); + let tc = fixtures + .iter() + .find(|tc| tc.prestate_response.is_some()) + .expect("need at least one fixture with prestate_response"); + + let mut rpc_server = Server::new(); + let mut sourcify_server = Server::new(); + let mut sourcify_v2_server = Server::new(); + + let rpc_url = rpc_server.url(); + let sourcify_url = sourcify_server.url(); + let sourcify_v2_url = sourcify_v2_server.url(); + + let chain_id_response = json!({ "jsonrpc": "2.0", "id": 1, "result": "0x1" }); + + let calltrace_mock = rpc_server + .mock("POST", "/") + .match_body(Matcher::Regex(r#""tracer":"callTracer""#.to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(serde_json::to_string(&tc.rpc_response).unwrap()) + .create(); + + let prestate_mock = rpc_server + .mock("POST", "/") + .match_body(Matcher::Regex(r#""tracer":"prestateTracer""#.to_string())) + .with_status(500) + .with_body("internal server error") + .create(); + + let chain_id_mock = rpc_server + .mock("POST", "/") + .match_body(Matcher::Regex(r#""method":"eth_chainId""#.to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(chain_id_response.to_string()) + .create(); + + let selector_mocks = setup_sourcify_mock(&mut sourcify_server, &load_selectors()); + let contract_mocks = setup_sourcify_v2_mock(&mut sourcify_v2_server, &load_contracts()); + + let _mocks = ( + calltrace_mock, + prestate_mock, + chain_id_mock, + selector_mocks, + contract_mocks, + ); + let _servers = vec![rpc_server, sourcify_server, sourcify_v2_server]; + + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("torge")); + cmd.arg("tx") + .arg(&tc.tx_hash) + .arg("-r") + .arg(&rpc_url) + .arg("--include-logs") + .arg("--resolve-selectors") + .arg("--resolve-contracts") + .arg("--include-args") + .arg("--include-calldata") + .arg("--include-storage") + .env("TORGE_DISABLE_CACHE", "1") + .env("SOURCIFY_4BYTE_URL", format!("{sourcify_url}/")) + .env("SOURCIFY_SERVER_URL", format!("{sourcify_v2_url}/")); + + let output = cmd.output().expect("failed to run"); + assert!( + output.status.success(), + "should succeed despite prestate failure: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stdout.contains("Storage changes:"), + "storage diffs should not appear when prestate RPC fails" + ); + assert!( + stderr.contains("storage diff unavailable"), + "stderr should contain degradation warning, got: {stderr}" + ); +} + // --------------------------------------------------------------------------- // clean tests // ---------------------------------------------------------------------------