diff --git a/Cargo.lock b/Cargo.lock index b98d6bbb06c93..3fa53f75fb625 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9945,6 +9945,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e33493cf6d996e9242db4f2002caef48ec9aa8c4f3fff3b18aed10ef3942eec7" dependencies = [ "bitvec", + "paste", "phf 0.13.1", "revm-primitives 41.0.0", "serde", diff --git a/crates/evm/traces/src/decoder/mod.rs b/crates/evm/traces/src/decoder/mod.rs index 4b9f7e836aa80..1ba69592dd227 100644 --- a/crates/evm/traces/src/decoder/mod.rs +++ b/crates/evm/traces/src/decoder/mod.rs @@ -1,5 +1,5 @@ use crate::{ - CallTrace, CallTraceArena, CallTraceNode, DecodedCallData, + CallTrace, CallTraceArena, CallTraceNode, DecodedCallData, DecodedTraceStep, debug::DebugTraceIdentifier, identifier::{IdentifiedAddress, LocalTraceIdentifier, SignaturesIdentifier, TraceIdentifier}, }; @@ -26,7 +26,9 @@ use foundry_evm_core::{ }; use foundry_evm_hardforks::TempoHardfork; use itertools::Itertools; +use revm::bytecode::opcode::OpCode; use revm_inspectors::tracing::types::{DecodedCallLog, DecodedCallTrace}; + use std::{collections::BTreeMap, sync::OnceLock}; use tempo_contracts::precompiles::{ IAccountKeychain, IAddressRegistry, IFeeManager, IReceivePolicyGuard, ISignatureVerifier, @@ -196,6 +198,9 @@ pub struct CallTraceDecoder { /// The chain ID, used to determine network-specific precompiles. pub chain_id: Option, + /// Detailed opcodes for analysis + pub opcodes: Vec, + /// The Tempo hardfork, used to determine hardfork-specific precompiles. pub tempo_hardfork: Option, } @@ -323,6 +328,8 @@ impl CallTraceDecoder { chain_id: None, + opcodes: Vec::new(), + tempo_hardfork: None, } } @@ -527,6 +534,28 @@ impl CallTraceDecoder { /// [CallTraceDecoder::decode_event] for more details. pub async fn populate_traces(&self, traces: &mut Vec) { for node in traces { + if !self.opcodes.is_empty() { + for step in &mut node.trace.steps { + if step.decoded.is_some() { + continue; + } + for opcode in &self.opcodes { + if step.op == *opcode { + let res = match &step.storage_change { + Some(change) => format!( + "[{}] {} 0x{:x} → (0x{:x})", + step.gas_cost, opcode, change.key, change.value + ), + None => format!("[{}] {}", step.gas_cost, opcode), + }; + + step.decoded = Some(Box::new(DecodedTraceStep::Line(res))); + break; + } + } + } + } + node.trace.decoded = Some(Box::new(self.decode_function(&node.trace).await)); for log in &mut node.logs { log.decoded = diff --git a/crates/evm/traces/src/lib.rs b/crates/evm/traces/src/lib.rs index ab280660da365..00fd16b8858f7 100644 --- a/crates/evm/traces/src/lib.rs +++ b/crates/evm/traces/src/lib.rs @@ -15,6 +15,7 @@ use foundry_common::{ contracts::{ContractsByAddress, ContractsByArtifact}, shell, }; +use foundry_config::NamedChain::SuperpositionTestnet; use revm::bytecode::opcode::OpCode; use revm_inspectors::tracing::{ OpcodeFilter, @@ -209,18 +210,35 @@ pub fn render_trace_arena_inner( return serde_json::to_string(&arena.resolve_arena()).expect("Failed to serialize traces"); } - let resolved = arena.resolve_arena(); + // Remove overlapping decoded trace data + let mut resolved = arena.resolve_arena(); + + let mut tempo_changes = String::new(); + if with_storage_changes { + append_tempo_channel_storage_decodes(&mut tempo_changes, &resolved); + } + + let resolved_mut = resolved.to_mut(); + if with_storage_changes { + for node in resolved_mut.nodes_mut() { + for step in &mut node.trace.steps { + if step.decoded.is_some() { + step.storage_change = None; + } + } + } + } + let mut w = TraceWriter::new(Vec::::new()) .color_cheatcodes(true) .use_colors(convert_color_choice(shell::color_choice())) .write_bytecodes(with_bytecodes) .with_storage_changes(with_storage_changes); - w.write_arena(&resolved).expect("Failed to write traces"); + w.write_arena(&resolved_mut).expect("Failed to write traces"); let mut rendered = String::from_utf8(w.into_writer()).expect("trace writer wrote invalid UTF-8"); - if with_storage_changes { - append_tempo_channel_storage_decodes(&mut rendered, &resolved); - } + rendered.push_str(&tempo_changes); + rendered } diff --git a/crates/forge/Cargo.toml b/crates/forge/Cargo.toml index 222ba1386964f..4f9f20b284582 100644 --- a/crates/forge/Cargo.toml +++ b/crates/forge/Cargo.toml @@ -75,7 +75,7 @@ inferno = { version = "0.12", default-features = false } itertools.workspace = true parking_lot.workspace = true regex = { workspace = true, default-features = false } -revm.workspace = true +revm = { workspace = true, features = ["parse"] } semver.workspace = true serde_json.workspace = true similar = { version = "3", features = ["inline"] } diff --git a/crates/forge/src/cmd/test/mod.rs b/crates/forge/src/cmd/test/mod.rs index f8570cd4a7721..7157b16720053 100644 --- a/crates/forge/src/cmd/test/mod.rs +++ b/crates/forge/src/cmd/test/mod.rs @@ -62,7 +62,7 @@ use foundry_evm::{ }; use rand::Rng; use regex::Regex; -use revm::context::Transaction; +use revm::{bytecode::opcode::OpCode, context::Transaction}; use std::{ collections::{BTreeMap, BTreeSet}, fmt::Write, @@ -540,6 +540,15 @@ pub struct TestArgs { #[arg(long)] pub rerun: bool, + /// Print the given opcodes in trace output, with their gas + /// cost and the storage slot and value, if available. + /// + /// Accepts a comma-separated list of opcode names, e.g. + /// `--opcodes SLOAD,MLOAD,SSTORE`. Names are in uppercase. + /// Requires `-vvvvv` to render. + #[arg(long, value_parser = parse_opcode, value_delimiter(','))] + pub opcodes: Vec, + /// Print test summary table. #[arg(long, help_heading = "Display options")] pub summary: bool, @@ -1602,6 +1611,11 @@ impl TestArgs { let num_filtered = runner.matching_test_functions(filter).count(); + if !self.opcodes.is_empty() && verbosity < 5 { + sh_eprintln!()?; + eyre::bail!("Not enough verbosity. Use -vvvvv to show opcodes."); + } + if num_filtered == 0 { let total_tests = if filter.is_empty() { num_filtered @@ -1856,8 +1870,9 @@ impl TestArgs { }; if should_include { - decode_trace_arena(arena, &decoder).await; + decoder.opcodes = self.opcodes.clone(); + decode_trace_arena(arena, &decoder).await; if let Some(trace_depth) = self.trace_depth { prune_trace_depth(arena, trace_depth); } @@ -2350,6 +2365,10 @@ impl Provider for TestArgs { } } +fn parse_opcode(s: &str) -> Result { + OpCode::parse(s).ok_or_else(|| format!("invalid opcode: {s}")) +} + const fn apply_mutation_compiler_overrides(config: &mut Config) { if let Some(optimizer_runs) = config.mutation.optimizer_runs { let default_optimizer_settings = diff --git a/crates/forge/tests/cli/test_cmd/mod.rs b/crates/forge/tests/cli/test_cmd/mod.rs index a9a04fc43a2f1..5802136135c5c 100644 --- a/crates/forge/tests/cli/test_cmd/mod.rs +++ b/crates/forge/tests/cli/test_cmd/mod.rs @@ -3729,6 +3729,126 @@ Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) "#]]); }); +// Tests that test traces display opcodes when verbosity level is 5 +forgetest_init!(should_show_opcodes, |prj, cmd| { + prj.initialize_default_contracts(); + cmd.args([ + "test", + "--mt", + "test_Increment", + "-vvvvv", + "--opcodes", + "SLOAD,MLOAD", + "--no-dynamic-test-linking", + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/Counter.t.sol:CounterTest +[PASS] test_Increment() ([GAS]) +Traces: + [..] CounterTest::setUp() + ├─ [..] → new Counter@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f + │ └─ ← [Return] 481 bytes of code + ├─ [..] Counter::setNumber(0) + │ └─ ← [Stop] + └─ ← [Stop] + + [..] CounterTest::test_Increment() + ├─ [..] SLOAD 0x1f → (0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f01) + ├─ [..] MLOAD + ├─ [..] MLOAD + ├─ [..] Counter::increment() + │ ├─ [..] SLOAD 0x0 → (0x0) + │ ├─ storage changes: + │ │ @ 0: 0 → 1 + │ └─ ← [Stop] + ├─ [..] SLOAD + ├─ [..] MLOAD + ├─ [..] MLOAD + ├─ [..] Counter::number() [staticcall] + │ ├─ [..] SLOAD 0x0 → (0x1) + │ ├─ [..] MLOAD + │ ├─ [..] MLOAD + │ └─ ← [Return] 1 + ├─ [..] MLOAD + ├─ [..] MLOAD + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +}); + +// Tests that --opcodes properly errors (fails early) with wrong opcode names +forgetest_init!(opcodes_invalid_name, |prj, cmd| { + prj.initialize_default_contracts(); + cmd.args(["test", "--mt", "test_Increment", "-vvvvv", "--opcodes", "BOGUS"]) + .assert_failure() + .stderr_eq(str![[r#" +... +error: invalid value 'BOGUS' for '--opcodes ': invalid opcode: BOGUS + +For more information, try '--help'. + +"#]]); +}); + +// Tests that --opcodes errors when not provided with -vvvvv +forgetest_init!(opcodes_not_enough_verbosity, |prj, cmd| { + prj.initialize_default_contracts(); + cmd.args(["test", "--mt", "test_Increment", "-vvv", "--opcodes", "ADD"]) + .assert_failure() + .stderr_eq(str![[r#" +... +Error: Not enough verbosity. Use -vvvvv to show opcodes. + +"#]]); +}); + +// Tests that the test file path is not swallowed by the --opcodes flag +forgetest_init!(opcodes_path_after_flag, |prj, cmd| { + prj.initialize_default_contracts(); + cmd.args([ + "test", + "--mt", + "test_Increment", + "-vvvvv", + "--opcodes", + "SSTORE", + "test/Counter.t.sol", + "--no-dynamic-test-linking", + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/Counter.t.sol:CounterTest +[PASS] test_Increment() ([GAS]) +Traces: + [..] CounterTest::setUp() + ├─ [..] → new Counter@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f + │ └─ ← [Return] 481 bytes of code + ├─ [..] Counter::setNumber(0) + │ └─ ← [Stop] + └─ ← [Stop] + + [..] CounterTest::test_Increment() + ├─ [..] Counter::increment() + │ ├─ [..] SSTORE 0x0 → (0x1) + │ └─ ← [Stop] + ├─ [..] Counter::number() [staticcall] + │ └─ ← [Return] 1 + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +}); + // Tests that chained errors are properly displayed. // forgetest!(displays_chained_error, |prj, cmd| {