diff --git a/Cargo.lock b/Cargo.lock index be6aebeea7de..53cfab2eb56a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3708,6 +3708,7 @@ dependencies = [ "proptest", "rand", "revm", + "revm-inspectors", "semver 1.0.23", "serde_json", "thiserror", @@ -3982,6 +3983,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 7f990a8e56be..adce79b211bd 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 06ddba5bd5d1..d64df4860abe 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 fffc146a9d2c..662853e9e811 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 e73755de1d3b..761d64e9bedb 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 b9a3d7047492..acc349be15d3 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 000000000000..b9f0f89cb01f --- /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 9a40c3cb1b31..acb86681f848 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 6ccb630ca0c9..4fb417db5c1e 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 6792145fe245..c3645e31b153 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 0f2399a20da9..55e4834f58d8 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 000000000000..1db7420d7873 --- /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 b6da4b49a5d9..1a2ac4c4a0f4 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 1458e3e4621d..2572108972b1 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 000000000000..ade2e7aafb7e --- /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"); + } +}