From b31dc281671c4739976172179c99d3f233f74478 Mon Sep 17 00:00:00 2001 From: boolafish Date: Wed, 9 Oct 2024 23:57:38 +0900 Subject: [PATCH] feat(cheatcode): `startDebugTraceRecording` and `stopDebugTraceRecording` for ERC4337 testing (#8571) * feat: add record opcode cheat code feat: capture stack inputs as part of the opcode feat: record opcode -> record debug trace fix: memory OOG, need to only use needed stack, mem input fix: missing op code, instruction results fix: accessing out-of-bound idx memory When running on some project, we noticed that it sometimes try to access memory with out of bound index and panics. This commit fix it by: 1. Enfore reset to Nonce after stopDebugTraceRecording(), this ensures the `some(..) = ...` part will not be triggered 2. Change how opcode_utils.rs accesses memory. Return empty vector if trying access out-of-bound memory. * test: add DebugTrace.t.sol for the debug trace cheatcode * fix: rebase errors * feat: use tracer for debug trace instead of recording during inspector This commit also cleans up the previous implementaiton on inspector. And then change the cheatcode interface to be of three steps: 1. start recording debug trace 2. stop recording 3. get the debug trace by index The reason is to avoid out-of-memory issue by returning the whole traces at once. * fix: rebase duplication * feat: replace instruction result with isOutOfGas * fix: CI issues * fix: remove DebugTrace wrapper in inspector * fix: revert to original tracer config when stops * chore: reuse existing opcode functions * chore: refactor, fmt, clippy run * chore: use ref instead of clone, returning Error when not able to access * chore: move buffer to evm_core from debugger * fix: disable dummy tracer by default, return explicit error Since enabling dummy tracer still come with performance impact, remove the auto dummy tracer initiation. The cheatcode will return explicit error and require the test to be run in -vvv mode to have the tracer enabled by default. * fix: return all traces, turn on necessary tracer config There was OOM concern but using the get-by-index style, despite improved, does not solve the root cause. The main issue is that the tracer config did not turn off after the stop recording cheatcode being called. It seems too much burden for the tracer to record the returned traces inside forge tests as the tests will also pass around the debug traces, causing memory boost. This commit also only turns on necessary tracer config instead of using all(). * chore: cleanup comments, typo * fix: use bytes for memory, remove flattern function, fix get_slice_from_memory * fix: style fmt * fix: ensure steps in the order of node when flatten A node can have steps that calls to another node, so the child node's step might occur before some steps of its parent node. This introduce the flatten_call_trace function back using recursive call to ensure the steps are in correct order despite not in the same order of the node index. see PR comment: https://github.com/foundry-rs/foundry/pull/8571#discussion_r1785386322 * doc: remove legacy comment in test * style: reuse empty initialized var on return val --------- Co-authored-by: zerosnacks <95942363+zerosnacks@users.noreply.github.com> --- Cargo.lock | 2 + crates/cheatcodes/Cargo.toml | 1 + crates/cheatcodes/assets/cheatcodes.json | 76 ++++++++ crates/cheatcodes/spec/src/lib.rs | 1 + crates/cheatcodes/spec/src/vm.rs | 33 ++++ crates/cheatcodes/src/evm.rs | 67 ++++++- .../cheatcodes/src/evm/record_debug_step.rs | 144 +++++++++++++++ crates/cheatcodes/src/inspector.rs | 14 +- crates/debugger/Cargo.toml | 1 + crates/debugger/src/tui/context.rs | 29 +-- crates/debugger/src/tui/draw.rs | 90 +--------- crates/evm/core/src/buffer.rs | 117 ++++++++++++ crates/evm/core/src/lib.rs | 1 + testdata/cheats/Vm.sol | 3 + .../default/cheats/RecordDebugTrace.t.sol | 166 ++++++++++++++++++ 15 files changed, 626 insertions(+), 119 deletions(-) create mode 100644 crates/cheatcodes/src/evm/record_debug_step.rs create mode 100644 crates/evm/core/src/buffer.rs create mode 100644 testdata/default/cheats/RecordDebugTrace.t.sol diff --git a/Cargo.lock b/Cargo.lock index 381ed5a7514ff..7438ab7f1acb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3634,6 +3634,7 @@ dependencies = [ "proptest", "rand", "revm", + "revm-inspectors", "semver 1.0.23", "serde_json", "thiserror", @@ -3908,6 +3909,7 @@ dependencies = [ "eyre", "foundry-common", "foundry-compilers", + "foundry-evm-core", "foundry-evm-traces", "ratatui", "revm", diff --git a/crates/cheatcodes/Cargo.toml b/crates/cheatcodes/Cargo.toml index 7f990a8e56beb..adce79b211bd3 100644 --- a/crates/cheatcodes/Cargo.toml +++ b/crates/cheatcodes/Cargo.toml @@ -58,6 +58,7 @@ p256 = "0.13.2" ecdsa = "0.16" rand = "0.8" revm.workspace = true +revm-inspectors.workspace = true semver.workspace = true serde_json.workspace = true thiserror.workspace = true diff --git a/crates/cheatcodes/assets/cheatcodes.json b/crates/cheatcodes/assets/cheatcodes.json index 06ddba5bd5d1f..d64df4860abe1 100644 --- a/crates/cheatcodes/assets/cheatcodes.json +++ b/crates/cheatcodes/assets/cheatcodes.json @@ -488,6 +488,42 @@ "description": "The amount of gas remaining." } ] + }, + { + "name": "DebugStep", + "description": "The result of the `stopDebugTraceRecording` call", + "fields": [ + { + "name": "stack", + "ty": "uint256[]", + "description": "The stack before executing the step of the run.\n stack\\[0\\] represents the top of the stack.\n and only stack data relevant to the opcode execution is contained." + }, + { + "name": "memoryInput", + "ty": "bytes", + "description": "The memory input data before executing the step of the run.\n only input data relevant to the opcode execution is contained.\n e.g. for MLOAD, it will have memory\\[offset:offset+32\\] copied here.\n the offset value can be get by the stack data." + }, + { + "name": "opcode", + "ty": "uint8", + "description": "The opcode that was accessed." + }, + { + "name": "depth", + "ty": "uint64", + "description": "The call depth of the step." + }, + { + "name": "isOutOfGas", + "ty": "bool", + "description": "Whether the call end up with out of gas error." + }, + { + "name": "contractAddr", + "ty": "address", + "description": "The contract address where the opcode is running" + } + ] } ], "cheatcodes": [ @@ -8863,6 +8899,26 @@ "status": "stable", "safety": "safe" }, + { + "func": { + "id": "startDebugTraceRecording", + "description": "Records the debug trace during the run.", + "declaration": "function startDebugTraceRecording() external;", + "visibility": "external", + "mutability": "", + "signature": "startDebugTraceRecording()", + "selector": "0x419c8832", + "selectorBytes": [ + 65, + 156, + 136, + 50 + ] + }, + "group": "evm", + "status": "stable", + "safety": "safe" + }, { "func": { "id": "startMappingRecording", @@ -8983,6 +9039,26 @@ "status": "stable", "safety": "safe" }, + { + "func": { + "id": "stopAndReturnDebugTraceRecording", + "description": "Stop debug trace recording and returns the recorded debug trace.", + "declaration": "function stopAndReturnDebugTraceRecording() external returns (DebugStep[] memory step);", + "visibility": "external", + "mutability": "", + "signature": "stopAndReturnDebugTraceRecording()", + "selector": "0xced398a2", + "selectorBytes": [ + 206, + 211, + 152, + 162 + ] + }, + "group": "evm", + "status": "stable", + "safety": "safe" + }, { "func": { "id": "stopAndReturnStateDiff", diff --git a/crates/cheatcodes/spec/src/lib.rs b/crates/cheatcodes/spec/src/lib.rs index fffc146a9d2c1..662853e9e8117 100644 --- a/crates/cheatcodes/spec/src/lib.rs +++ b/crates/cheatcodes/spec/src/lib.rs @@ -85,6 +85,7 @@ impl Cheatcodes<'static> { Vm::AccountAccess::STRUCT.clone(), Vm::StorageAccess::STRUCT.clone(), Vm::Gas::STRUCT.clone(), + Vm::DebugStep::STRUCT.clone(), ]), enums: Cow::Owned(vec![ Vm::CallerMode::ENUM.clone(), diff --git a/crates/cheatcodes/spec/src/vm.rs b/crates/cheatcodes/spec/src/vm.rs index e73755de1d3bc..761d64e9bedbc 100644 --- a/crates/cheatcodes/spec/src/vm.rs +++ b/crates/cheatcodes/spec/src/vm.rs @@ -261,6 +261,28 @@ interface Vm { uint64 depth; } + /// The result of the `stopDebugTraceRecording` call + struct DebugStep { + /// The stack before executing the step of the run. + /// stack\[0\] represents the top of the stack. + /// and only stack data relevant to the opcode execution is contained. + uint256[] stack; + /// The memory input data before executing the step of the run. + /// only input data relevant to the opcode execution is contained. + /// + /// e.g. for MLOAD, it will have memory\[offset:offset+32\] copied here. + /// the offset value can be get by the stack data. + bytes memoryInput; + /// The opcode that was accessed. + uint8 opcode; + /// The call depth of the step. + uint64 depth; + /// Whether the call end up with out of gas error. + bool isOutOfGas; + /// The contract address where the opcode is running + address contractAddr; + } + // ======== EVM ======== /// Gets the address for a given private key. @@ -287,6 +309,17 @@ interface Vm { #[cheatcode(group = Evm, safety = Unsafe)] function loadAllocs(string calldata pathToAllocsJson) external; + // -------- Record Debug Traces -------- + + /// Records the debug trace during the run. + #[cheatcode(group = Evm, safety = Safe)] + function startDebugTraceRecording() external; + + /// Stop debug trace recording and returns the recorded debug trace. + #[cheatcode(group = Evm, safety = Safe)] + function stopAndReturnDebugTraceRecording() external returns (DebugStep[] memory step); + + /// Clones a source account code, state, balance and nonce to a target account and updates in-memory EVM state. #[cheatcode(group = Evm, safety = Unsafe)] function cloneAccount(address source, address target) external; diff --git a/crates/cheatcodes/src/evm.rs b/crates/cheatcodes/src/evm.rs index b9a3d70474929..acc349be15d31 100644 --- a/crates/cheatcodes/src/evm.rs +++ b/crates/cheatcodes/src/evm.rs @@ -1,8 +1,9 @@ //! Implementations of [`Evm`](spec::Group::Evm) cheatcodes. use crate::{ - inspector::InnerEcx, BroadcastableTransaction, Cheatcode, Cheatcodes, CheatcodesExecutor, - CheatsCtxt, Result, Vm::*, + inspector::{InnerEcx, RecordDebugStepInfo}, + BroadcastableTransaction, Cheatcode, Cheatcodes, CheatcodesExecutor, CheatsCtxt, Error, Result, + Vm::*, }; use alloy_consensus::TxEnvelope; use alloy_genesis::{Genesis, GenesisAccount}; @@ -14,10 +15,14 @@ use foundry_evm_core::{ backend::{DatabaseExt, RevertStateSnapshotAction}, constants::{CALLER, CHEATCODE_ADDRESS, HARDHAT_CONSOLE_ADDRESS, TEST_CONTRACT_ADDRESS}, }; +use foundry_evm_traces::StackSnapshotType; use rand::Rng; use revm::primitives::{Account, Bytecode, SpecId, KECCAK_EMPTY}; use std::{collections::BTreeMap, path::Path}; +mod record_debug_step; +use record_debug_step::{convert_call_trace_to_debug_step, flatten_call_trace}; + mod fork; pub(crate) mod mapping; pub(crate) mod mock; @@ -715,6 +720,64 @@ impl Cheatcode for setBlockhashCall { } } +impl Cheatcode for startDebugTraceRecordingCall { + fn apply_full(&self, ccx: &mut CheatsCtxt, executor: &mut dyn CheatcodesExecutor) -> Result { + let Some(tracer) = executor.tracing_inspector().and_then(|t| t.as_mut()) else { + return Err(Error::from("no tracer initiated, consider adding -vvv flag")) + }; + + let mut info = RecordDebugStepInfo { + // will be updated later + start_node_idx: 0, + // keep the original config to revert back later + original_tracer_config: *tracer.config(), + }; + + // turn on tracer configuration for recording + tracer.update_config(|config| { + config + .set_steps(true) + .set_memory_snapshots(true) + .set_stack_snapshots(StackSnapshotType::Full) + }); + + // track where the recording starts + if let Some(last_node) = tracer.traces().nodes().last() { + info.start_node_idx = last_node.idx; + } + + ccx.state.record_debug_steps_info = Some(info); + Ok(Default::default()) + } +} + +impl Cheatcode for stopAndReturnDebugTraceRecordingCall { + fn apply_full(&self, ccx: &mut CheatsCtxt, executor: &mut dyn CheatcodesExecutor) -> Result { + let Some(tracer) = executor.tracing_inspector().and_then(|t| t.as_mut()) else { + return Err(Error::from("no tracer initiated, consider adding -vvv flag")) + }; + + let Some(record_info) = ccx.state.record_debug_steps_info else { + return Err(Error::from("nothing recorded")) + }; + + // Revert the tracer config to the one before recording + tracer.update_config(|_config| record_info.original_tracer_config); + + // Use the trace nodes to flatten the call trace + let root = tracer.traces(); + let steps = flatten_call_trace(0, root, record_info.start_node_idx); + + let debug_steps: Vec = + steps.iter().map(|&step| convert_call_trace_to_debug_step(step)).collect(); + + // Clean up the recording info + ccx.state.record_debug_steps_info = None; + + Ok(debug_steps.abi_encode()) + } +} + pub(super) fn get_nonce(ccx: &mut CheatsCtxt, address: &Address) -> Result { let account = ccx.ecx.journaled_state.load_account(*address, &mut ccx.ecx.db)?; Ok(account.info.nonce.abi_encode()) diff --git a/crates/cheatcodes/src/evm/record_debug_step.rs b/crates/cheatcodes/src/evm/record_debug_step.rs new file mode 100644 index 0000000000000..b9f0f89cb01ff --- /dev/null +++ b/crates/cheatcodes/src/evm/record_debug_step.rs @@ -0,0 +1,144 @@ +use alloy_primitives::{Bytes, U256}; + +use foundry_evm_traces::CallTraceArena; +use revm::interpreter::{InstructionResult, OpCode}; + +use foundry_evm_core::buffer::{get_buffer_accesses, BufferKind}; +use revm_inspectors::tracing::types::{CallTraceStep, RecordedMemory, TraceMemberOrder}; +use spec::Vm::DebugStep; + +// Do a depth first traverse of the nodes and steps and return steps +// that are after `node_start_idx` +pub(crate) fn flatten_call_trace( + root: usize, + arena: &CallTraceArena, + node_start_idx: usize, +) -> Vec<&CallTraceStep> { + let mut steps = Vec::new(); + let mut record_started = false; + + // Start the recursion from the root node + recursive_flatten_call_trace(root, arena, node_start_idx, &mut record_started, &mut steps); + steps +} + +// Inner recursive function to process nodes. +// This implementation directly mutates `record_started` and `flatten_steps`. +// So the recursive call can change the `record_started` flag even for the parent +// unfinished processing, and append steps to the `flatten_steps` as the final result. +fn recursive_flatten_call_trace<'a>( + node_idx: usize, + arena: &'a CallTraceArena, + node_start_idx: usize, + record_started: &mut bool, + flatten_steps: &mut Vec<&'a CallTraceStep>, +) { + // Once node_idx exceeds node_start_idx, start recording steps + // for all the recursive processing. + if !*record_started && node_idx >= node_start_idx { + *record_started = true; + } + + let node = &arena.nodes()[node_idx]; + + for order in node.ordering.iter() { + match order { + TraceMemberOrder::Step(step_idx) => { + if *record_started { + let step = &node.trace.steps[*step_idx]; + flatten_steps.push(step); + } + } + TraceMemberOrder::Call(call_idx) => { + let child_node_idx = node.children[*call_idx]; + recursive_flatten_call_trace( + child_node_idx, + arena, + node_start_idx, + record_started, + flatten_steps, + ); + } + _ => {} + } + } +} + +// Function to convert CallTraceStep to DebugStep +pub(crate) fn convert_call_trace_to_debug_step(step: &CallTraceStep) -> DebugStep { + let opcode = step.op.get(); + let stack = get_stack_inputs_for_opcode(opcode, step.stack.as_ref()); + + let memory = get_memory_input_for_opcode(opcode, step.stack.as_ref(), step.memory.as_ref()); + + let is_out_of_gas = step.status == InstructionResult::OutOfGas || + step.status == InstructionResult::MemoryOOG || + step.status == InstructionResult::MemoryLimitOOG || + step.status == InstructionResult::PrecompileOOG || + step.status == InstructionResult::InvalidOperandOOG; + + DebugStep { + stack, + memoryInput: memory, + opcode: step.op.get(), + depth: step.depth, + isOutOfGas: is_out_of_gas, + contractAddr: step.contract, + } +} + +// The expected `stack` here is from the trace stack, where the top of the stack +// is the last value of the vector +fn get_memory_input_for_opcode( + opcode: u8, + stack: Option<&Vec>, + memory: Option<&RecordedMemory>, +) -> Bytes { + let mut memory_input = Bytes::new(); + let Some(stack_data) = stack else { return memory_input }; + let Some(memory_data) = memory else { return memory_input }; + + if let Some(accesses) = get_buffer_accesses(opcode, stack_data) { + if let Some((BufferKind::Memory, access)) = accesses.read { + memory_input = get_slice_from_memory(memory_data.as_bytes(), access.offset, access.len); + } + }; + + memory_input +} + +// The expected `stack` here is from the trace stack, where the top of the stack +// is the last value of the vector +fn get_stack_inputs_for_opcode(opcode: u8, stack: Option<&Vec>) -> Vec { + let mut inputs = Vec::new(); + + let Some(op) = OpCode::new(opcode) else { return inputs }; + let Some(stack_data) = stack else { return inputs }; + + let stack_input_size = op.inputs() as usize; + for i in 0..stack_input_size { + inputs.push(stack_data[stack_data.len() - 1 - i]); + } + inputs +} + +fn get_slice_from_memory(memory: &Bytes, start_index: usize, size: usize) -> Bytes { + let memory_len = memory.len(); + + let end_bound = start_index + size; + + // Return the bytes if data is within the range. + if start_index < memory_len && end_bound <= memory_len { + return memory.slice(start_index..end_bound); + } + + // Pad zero bytes if attempting to load memory partially out of range. + if start_index < memory_len && end_bound > memory_len { + let mut result = memory.slice(start_index..memory_len).to_vec(); + result.resize(size, 0u8); + return Bytes::from(result); + } + + // Return empty bytes with the size if not in range at all. + Bytes::from(vec![0u8; size]) +} diff --git a/crates/cheatcodes/src/inspector.rs b/crates/cheatcodes/src/inspector.rs index 9a40c3cb1b313..acb86681f8480 100644 --- a/crates/cheatcodes/src/inspector.rs +++ b/crates/cheatcodes/src/inspector.rs @@ -36,7 +36,7 @@ use foundry_evm_core::{ utils::new_evm_with_existing_context, InspectorExt, }; -use foundry_evm_traces::TracingInspector; +use foundry_evm_traces::{TracingInspector, TracingInspectorConfig}; use itertools::Itertools; use proptest::test_runner::{RngAlgorithm, TestRng, TestRunner}; use rand::Rng; @@ -219,6 +219,14 @@ pub struct BroadcastableTransaction { pub transaction: TransactionMaybeSigned, } +#[derive(Clone, Debug, Copy)] +pub struct RecordDebugStepInfo { + /// The debug trace node index when the recording starts. + pub start_node_idx: usize, + /// The original tracer config when the recording starts. + pub original_tracer_config: TracingInspectorConfig, +} + /// Holds gas metering state. #[derive(Clone, Debug, Default)] pub struct GasMetering { @@ -396,6 +404,9 @@ pub struct Cheatcodes { /// merged into the previous vector. pub recorded_account_diffs_stack: Option>>, + /// The information of the debug step recording. + pub record_debug_steps_info: Option, + /// Recorded logs pub recorded_logs: Option>, @@ -492,6 +503,7 @@ impl Cheatcodes { accesses: Default::default(), recorded_account_diffs_stack: Default::default(), recorded_logs: Default::default(), + record_debug_steps_info: Default::default(), mocked_calls: Default::default(), mocked_functions: Default::default(), expected_calls: Default::default(), diff --git a/crates/debugger/Cargo.toml b/crates/debugger/Cargo.toml index 6ccb630ca0c98..4fb417db5c1e5 100644 --- a/crates/debugger/Cargo.toml +++ b/crates/debugger/Cargo.toml @@ -16,6 +16,7 @@ workspace = true foundry-common.workspace = true foundry-compilers.workspace = true foundry-evm-traces.workspace = true +foundry-evm-core.workspace = true revm-inspectors.workspace = true alloy-primitives.workspace = true diff --git a/crates/debugger/src/tui/context.rs b/crates/debugger/src/tui/context.rs index 6792145fe245d..c3645e31b1533 100644 --- a/crates/debugger/src/tui/context.rs +++ b/crates/debugger/src/tui/context.rs @@ -3,6 +3,7 @@ use crate::{DebugNode, Debugger, ExitReason}; use alloy_primitives::{hex, Address}; use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; +use foundry_evm_core::buffer::BufferKind; use revm::interpreter::OpCode; use revm_inspectors::tracing::types::{CallKind, CallTraceStep}; use std::ops::ControlFlow; @@ -15,34 +16,6 @@ pub(crate) struct DrawMemory { pub(crate) current_stack_startline: usize, } -/// Used to keep track of which buffer is currently active to be drawn by the debugger. -#[derive(Debug, PartialEq)] -pub(crate) enum BufferKind { - Memory, - Calldata, - Returndata, -} - -impl BufferKind { - /// Helper to cycle through the active buffers. - pub(crate) fn next(&self) -> Self { - match self { - Self::Memory => Self::Calldata, - Self::Calldata => Self::Returndata, - Self::Returndata => Self::Memory, - } - } - - /// Helper to format the title of the active buffer pane - pub(crate) fn title(&self, size: usize) -> String { - match self { - Self::Memory => format!("Memory (max expansion: {size} bytes)"), - Self::Calldata => format!("Calldata (size: {size} bytes)"), - Self::Returndata => format!("Returndata (size: {size} bytes)"), - } - } -} - pub(crate) struct DebuggerContext<'a> { pub(crate) debugger: &'a mut Debugger, diff --git a/crates/debugger/src/tui/draw.rs b/crates/debugger/src/tui/draw.rs index 0f2399a20da9c..55e4834f58d8b 100644 --- a/crates/debugger/src/tui/draw.rs +++ b/crates/debugger/src/tui/draw.rs @@ -1,9 +1,9 @@ //! TUI draw implementation. -use super::context::{BufferKind, DebuggerContext}; +use super::context::DebuggerContext; use crate::op::OpcodeParam; -use alloy_primitives::U256; use foundry_compilers::artifacts::sourcemap::SourceElement; +use foundry_evm_core::buffer::{get_buffer_accesses, BufferKind}; use foundry_evm_traces::debug::SourceData; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, @@ -12,7 +12,6 @@ use ratatui::{ widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, Frame, }; -use revm::interpreter::opcode; use revm_inspectors::tracing::types::CallKind; use std::{collections::VecDeque, fmt::Write, io}; @@ -624,91 +623,6 @@ impl<'a> SourceLines<'a> { } } -/// Container for buffer access information. -struct BufferAccess { - offset: usize, - len: usize, -} - -/// Container for read and write buffer access information. -struct BufferAccesses { - /// The read buffer kind and access information. - read: Option<(BufferKind, BufferAccess)>, - /// The only mutable buffer is the memory buffer, so don't store the buffer kind. - write: Option, -} - -/// The memory_access variable stores the index on the stack that indicates the buffer -/// offset/len accessed by the given opcode: -/// (read buffer, buffer read offset, buffer read len, write memory offset, write memory len) -/// \>= 1: the stack index -/// 0: no memory access -/// -1: a fixed len of 32 bytes -/// -2: a fixed len of 1 byte -/// -/// The return value is a tuple about accessed buffer region by the given opcode: -/// (read buffer, buffer read offset, buffer read len, write memory offset, write memory len) -fn get_buffer_accesses(op: u8, stack: &[U256]) -> Option { - let buffer_access = match op { - opcode::KECCAK256 | opcode::RETURN | opcode::REVERT => { - (Some((BufferKind::Memory, 1, 2)), None) - } - opcode::CALLDATACOPY => (Some((BufferKind::Calldata, 2, 3)), Some((1, 3))), - opcode::RETURNDATACOPY => (Some((BufferKind::Returndata, 2, 3)), Some((1, 3))), - opcode::CALLDATALOAD => (Some((BufferKind::Calldata, 1, -1)), None), - opcode::CODECOPY => (None, Some((1, 3))), - opcode::EXTCODECOPY => (None, Some((2, 4))), - opcode::MLOAD => (Some((BufferKind::Memory, 1, -1)), None), - opcode::MSTORE => (None, Some((1, -1))), - opcode::MSTORE8 => (None, Some((1, -2))), - opcode::LOG0 | opcode::LOG1 | opcode::LOG2 | opcode::LOG3 | opcode::LOG4 => { - (Some((BufferKind::Memory, 1, 2)), None) - } - opcode::CREATE | opcode::CREATE2 => (Some((BufferKind::Memory, 2, 3)), None), - opcode::CALL | opcode::CALLCODE => (Some((BufferKind::Memory, 4, 5)), None), - opcode::DELEGATECALL | opcode::STATICCALL => (Some((BufferKind::Memory, 3, 4)), None), - opcode::MCOPY => (Some((BufferKind::Memory, 2, 3)), Some((1, 3))), - opcode::RETURNDATALOAD => (Some((BufferKind::Returndata, 1, -1)), None), - opcode::EOFCREATE => (Some((BufferKind::Memory, 3, 4)), None), - opcode::RETURNCONTRACT => (Some((BufferKind::Memory, 1, 2)), None), - opcode::DATACOPY => (None, Some((1, 3))), - opcode::EXTCALL | opcode::EXTSTATICCALL | opcode::EXTDELEGATECALL => { - (Some((BufferKind::Memory, 2, 3)), None) - } - _ => Default::default(), - }; - - let stack_len = stack.len(); - let get_size = |stack_index| match stack_index { - -2 => Some(1), - -1 => Some(32), - 0 => None, - 1.. => { - if (stack_index as usize) <= stack_len { - Some(stack[stack_len - stack_index as usize].saturating_to()) - } else { - None - } - } - _ => panic!("invalid stack index"), - }; - - if buffer_access.0.is_some() || buffer_access.1.is_some() { - let (read, write) = buffer_access; - let read_access = read.and_then(|b| { - let (buffer, offset, len) = b; - Some((buffer, BufferAccess { offset: get_size(offset)?, len: get_size(len)? })) - }); - let write_access = write.and_then(|b| { - let (offset, len) = b; - Some(BufferAccess { offset: get_size(offset)?, len: get_size(len)? }) - }); - Some(BufferAccesses { read: read_access, write: write_access }) - } else { - None - } -} - fn hex_bytes_spans(bytes: &[u8], spans: &mut Vec>, f: impl Fn(usize, u8) -> Style) { for (i, &byte) in bytes.iter().enumerate() { if i > 0 { diff --git a/crates/evm/core/src/buffer.rs b/crates/evm/core/src/buffer.rs new file mode 100644 index 0000000000000..1db7420d78736 --- /dev/null +++ b/crates/evm/core/src/buffer.rs @@ -0,0 +1,117 @@ +use alloy_primitives::U256; +use revm::interpreter::opcode; + +/// Used to keep track of which buffer is currently active to be drawn by the debugger. +#[derive(Debug, PartialEq)] +pub enum BufferKind { + Memory, + Calldata, + Returndata, +} + +impl BufferKind { + /// Helper to cycle through the active buffers. + pub fn next(&self) -> Self { + match self { + Self::Memory => Self::Calldata, + Self::Calldata => Self::Returndata, + Self::Returndata => Self::Memory, + } + } + + /// Helper to format the title of the active buffer pane + pub fn title(&self, size: usize) -> String { + match self { + Self::Memory => format!("Memory (max expansion: {size} bytes)"), + Self::Calldata => format!("Calldata (size: {size} bytes)"), + Self::Returndata => format!("Returndata (size: {size} bytes)"), + } + } +} + +/// Container for buffer access information. +pub struct BufferAccess { + pub offset: usize, + pub len: usize, +} + +/// Container for read and write buffer access information. +pub struct BufferAccesses { + /// The read buffer kind and access information. + pub read: Option<(BufferKind, BufferAccess)>, + /// The only mutable buffer is the memory buffer, so don't store the buffer kind. + pub write: Option, +} + +/// A utility function to get the buffer access. +/// +/// The memory_access variable stores the index on the stack that indicates the buffer +/// offset/len accessed by the given opcode: +/// (read buffer, buffer read offset, buffer read len, write memory offset, write memory len) +/// \>= 1: the stack index +/// 0: no memory access +/// -1: a fixed len of 32 bytes +/// -2: a fixed len of 1 byte +/// +/// The return value is a tuple about accessed buffer region by the given opcode: +/// (read buffer, buffer read offset, buffer read len, write memory offset, write memory len) +pub fn get_buffer_accesses(op: u8, stack: &[U256]) -> Option { + let buffer_access = match op { + opcode::KECCAK256 | opcode::RETURN | opcode::REVERT => { + (Some((BufferKind::Memory, 1, 2)), None) + } + opcode::CALLDATACOPY => (Some((BufferKind::Calldata, 2, 3)), Some((1, 3))), + opcode::RETURNDATACOPY => (Some((BufferKind::Returndata, 2, 3)), Some((1, 3))), + opcode::CALLDATALOAD => (Some((BufferKind::Calldata, 1, -1)), None), + opcode::CODECOPY => (None, Some((1, 3))), + opcode::EXTCODECOPY => (None, Some((2, 4))), + opcode::MLOAD => (Some((BufferKind::Memory, 1, -1)), None), + opcode::MSTORE => (None, Some((1, -1))), + opcode::MSTORE8 => (None, Some((1, -2))), + opcode::LOG0 | opcode::LOG1 | opcode::LOG2 | opcode::LOG3 | opcode::LOG4 => { + (Some((BufferKind::Memory, 1, 2)), None) + } + opcode::CREATE | opcode::CREATE2 => (Some((BufferKind::Memory, 2, 3)), None), + opcode::CALL | opcode::CALLCODE => (Some((BufferKind::Memory, 4, 5)), None), + opcode::DELEGATECALL | opcode::STATICCALL => (Some((BufferKind::Memory, 3, 4)), None), + opcode::MCOPY => (Some((BufferKind::Memory, 2, 3)), Some((1, 3))), + opcode::RETURNDATALOAD => (Some((BufferKind::Returndata, 1, -1)), None), + opcode::EOFCREATE => (Some((BufferKind::Memory, 3, 4)), None), + opcode::RETURNCONTRACT => (Some((BufferKind::Memory, 1, 2)), None), + opcode::DATACOPY => (None, Some((1, 3))), + opcode::EXTCALL | opcode::EXTSTATICCALL | opcode::EXTDELEGATECALL => { + (Some((BufferKind::Memory, 2, 3)), None) + } + _ => Default::default(), + }; + + let stack_len = stack.len(); + let get_size = |stack_index| match stack_index { + -2 => Some(1), + -1 => Some(32), + 0 => None, + 1.. => { + if (stack_index as usize) <= stack_len { + Some(stack[stack_len - stack_index as usize].saturating_to()) + } else { + None + } + } + _ => panic!("invalid stack index"), + }; + + if buffer_access.0.is_some() || buffer_access.1.is_some() { + let (read, write) = buffer_access; + let read_access = read.and_then(|b| { + let (buffer, offset, len) = b; + Some((buffer, BufferAccess { offset: get_size(offset)?, len: get_size(len)? })) + }); + let write_access = write.and_then(|b| { + let (offset, len) = b; + Some(BufferAccess { offset: get_size(offset)?, len: get_size(len)? }) + }); + Some(BufferAccesses { read: read_access, write: write_access }) + } else { + None + } +} diff --git a/crates/evm/core/src/lib.rs b/crates/evm/core/src/lib.rs index b6da4b49a5d97..1a2ac4c4a0f49 100644 --- a/crates/evm/core/src/lib.rs +++ b/crates/evm/core/src/lib.rs @@ -21,6 +21,7 @@ pub mod abi { mod ic; pub mod backend; +pub mod buffer; pub mod constants; pub mod decode; pub mod fork; diff --git a/testdata/cheats/Vm.sol b/testdata/cheats/Vm.sol index 1458e3e4621d2..2572108972b17 100644 --- a/testdata/cheats/Vm.sol +++ b/testdata/cheats/Vm.sol @@ -20,6 +20,7 @@ interface Vm { struct AccountAccess { ChainInfo chainInfo; AccountAccessKind kind; address account; address accessor; bool initialized; uint256 oldBalance; uint256 newBalance; bytes deployedCode; uint256 value; bytes data; bool reverted; StorageAccess[] storageAccesses; uint64 depth; } struct StorageAccess { address account; bytes32 slot; bool isWrite; bytes32 previousValue; bytes32 newValue; bool reverted; } struct Gas { uint64 gasLimit; uint64 gasTotalUsed; uint64 gasMemoryUsed; int64 gasRefunded; uint64 gasRemaining; } + struct DebugStep { uint256[] stack; bytes memoryInput; uint8 opcode; uint64 depth; bool isOutOfGas; address contractAddr; } function _expectCheatcodeRevert() external; function _expectCheatcodeRevert(bytes4 revertData) external; function _expectCheatcodeRevert(bytes calldata revertData) external; @@ -438,12 +439,14 @@ interface Vm { function startBroadcast() external; function startBroadcast(address signer) external; function startBroadcast(uint256 privateKey) external; + function startDebugTraceRecording() external; function startMappingRecording() external; function startPrank(address msgSender) external; function startPrank(address msgSender, address txOrigin) external; function startSnapshotGas(string calldata name) external; function startSnapshotGas(string calldata group, string calldata name) external; function startStateDiffRecording() external; + function stopAndReturnDebugTraceRecording() external returns (DebugStep[] memory step); function stopAndReturnStateDiff() external returns (AccountAccess[] memory accountAccesses); function stopBroadcast() external; function stopExpectSafeMemory() external; diff --git a/testdata/default/cheats/RecordDebugTrace.t.sol b/testdata/default/cheats/RecordDebugTrace.t.sol new file mode 100644 index 0000000000000..ade2e7aafb7e1 --- /dev/null +++ b/testdata/default/cheats/RecordDebugTrace.t.sol @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity 0.8.18; + +import "ds-test/test.sol"; +import "cheats/Vm.sol"; + +contract MStoreAndMLoadCaller { + uint256 public constant expectedValueInMemory = 999; + + uint256 public memPtr; // the memory pointer being used + + function storeAndLoadValueFromMemory() public returns (uint256) { + uint256 mPtr; + assembly { + mPtr := mload(0x40) // load free pointer + mstore(mPtr, expectedValueInMemory) + mstore(0x40, add(mPtr, 0x20)) + } + + // record & expose the memory pointer location + memPtr = mPtr; + + uint256 result = 123; + assembly { + // override with `expectedValueInMemory` + result := mload(mPtr) + } + return result; + } +} + +contract FirstLayer { + SecondLayer secondLayer; + + constructor(SecondLayer _secondLayer) { + secondLayer = _secondLayer; + } + + function callSecondLayer() public view returns (uint256) { + return secondLayer.endHere(); + } +} + +contract SecondLayer { + uint256 public constant endNumber = 123; + + function endHere() public view returns (uint256) { + return endNumber; + } +} + +contract OutOfGas { + uint256 dummyVal = 0; + + function consumeGas() public { + dummyVal += 1; + } + + function triggerOOG() public { + bytes memory encodedFunctionCall = abi.encodeWithSignature("consumeGas()", ""); + uint256 notEnoughGas = 50; + (bool success,) = address(this).call{gas: notEnoughGas}(encodedFunctionCall); + require(!success, "it should error out of gas"); + } +} + +contract RecordDebugTraceTest is DSTest { + Vm constant cheats = Vm(HEVM_ADDRESS); + /** + * The goal of this test is to ensure the debug steps provide the correct OPCODE with its stack + * and memory input used. The test checke MSTORE and MLOAD and ensure it records the expected + * stack and memory inputs. + */ + + function testDebugTraceCanRecordOpcodeWithStackAndMemoryData() public { + MStoreAndMLoadCaller testContract = new MStoreAndMLoadCaller(); + + cheats.startDebugTraceRecording(); + + uint256 val = testContract.storeAndLoadValueFromMemory(); + assertTrue(val == testContract.expectedValueInMemory()); + + Vm.DebugStep[] memory steps = cheats.stopAndReturnDebugTraceRecording(); + + bool mstoreCalled = false; + bool mloadCalled = false; + + for (uint256 i = 0; i < steps.length; i++) { + Vm.DebugStep memory step = steps[i]; + if ( + step.opcode == 0x52 /*MSTORE*/ && step.stack[0] == testContract.memPtr() // MSTORE offset + && step.stack[1] == testContract.expectedValueInMemory() // MSTORE val + ) { + mstoreCalled = true; + } + + if ( + step.opcode == 0x51 /*MLOAD*/ && step.stack[0] == testContract.memPtr() // MLOAD offset + && step.memoryInput.length == 32 // MLOAD should always load 32 bytes + && uint256(bytes32(step.memoryInput)) == testContract.expectedValueInMemory() // MLOAD value + ) { + mloadCalled = true; + } + } + + assertTrue(mstoreCalled); + assertTrue(mloadCalled); + } + + /** + * This test tests that the cheatcode can correctly record the depth of the debug steps. + * This is test by test -> FirstLayer -> SecondLayer and check that the + * depth of the FirstLayer and SecondLayer are all as expected. + */ + function testDebugTraceCanRecordDepth() public { + SecondLayer second = new SecondLayer(); + FirstLayer first = new FirstLayer(second); + + cheats.startDebugTraceRecording(); + + first.callSecondLayer(); + + Vm.DebugStep[] memory steps = cheats.stopAndReturnDebugTraceRecording(); + + bool goToDepthTwo = false; + bool goToDepthThree = false; + for (uint256 i = 0; i < steps.length; i++) { + Vm.DebugStep memory step = steps[i]; + + if (step.depth == 2) { + assertTrue(step.contractAddr == address(first), "must be first layer on depth 2"); + goToDepthTwo = true; + } + + if (step.depth == 3) { + assertTrue(step.contractAddr == address(second), "must be second layer on depth 3"); + goToDepthThree = true; + } + } + assertTrue(goToDepthTwo && goToDepthThree, "must have been to both first and second layer"); + } + + /** + * The goal of this test is to ensure it can return expected `isOutOfGas` flag. + * It is tested with out of gas result here. + */ + function testDebugTraceCanRecordOutOfGas() public { + OutOfGas testContract = new OutOfGas(); + + cheats.startDebugTraceRecording(); + + testContract.triggerOOG(); + + Vm.DebugStep[] memory steps = cheats.stopAndReturnDebugTraceRecording(); + + bool isOOG = false; + for (uint256 i = 0; i < steps.length; i++) { + Vm.DebugStep memory step = steps[i]; + + if (step.isOutOfGas) { + isOOG = true; + } + } + assertTrue(isOOG, "should OOG"); + } +}